こんにちは、食べログ DevOps チーム / データサイエンスチームの爲岡です。
食べログ Advent Calendar 2018 に書くのは2度めです。
お時間ございましたら、1つめの記事も読んでいただけると嬉しいです。
Ansible controller/target を Docker コンテナで構築する
はじめに
https://togetter.com/li/1268851 を御覧ください。
下記、記事の抜粋です。
【エンジニア用語解説】
「完全に理解した」
製品を利用をするためのチュートリアルを完了できたという意味。
「なにもわからない」
製品が本質的に抱える問題に直面するほど熟知が進んだという意味。
「チョットデキル」
同じ製品を自分でも1から作れるという意味。または開発者本人。
私は今は DevOps チームとデータサイエンスチームでエンジニアをしてますが、新卒で入社した当初は決済システムの Rails エンジニアでした。
正直に言って、未だに Rails について詳しいとは言えません。
ある日、アプリケーションの controller を書いていて、
「Rails の session ってどういう仕組みで動いているんだろう?」
という疑問が浮かびました。
Rails の session について完全に理解するには、Rails チュートリアルの Session の項を完了できればよい、ということで、やってみました (Ruby on Rails の Session のチュートリアルはこちら) 。
が、ただ「チュートリアルをやりました!」だけだとちょっと寂しいので、
「どうやってsession[:hoge]
に session の値をロードしているか」
というところまで、Rails のコードを読みながら掘り下げたので、それを記事にしようと思いました。
また、Rails では Session store には Cookie store を利用することを推奨していますが、一方でサービス開発において Memcached などの KVS を session 管理に利用することも多いと思います。
ということで、改めまして、今回は、
「どうやって Memcached からsession[:hoge]
に session の値をロードしているか」
の話です。
session[]
の実体はどこにあるか
app: 'hoge'
たとえば、Rails がインストールされた状態でrails new hoge
すると Rails アプリケーションができあがって、hoge
ディレクトリ内でrails g controller sessions
すると ApplicationController を継承した SessionsController が生成されますが、はじめからsession[:hoge]
といった書き方で session を利用することができます。
class SessionsController < ApplicationController
def create
if session[:user_id]
flash[:notice] = 'ログイン済みです'
else
session[:user_id] = params[:user_id]
end
end
...
end
こいつの実体はなんだ。
session
はインスタンス変数ではなく、ローカル変数としても定義していないので、ApplicationController が持つメソッドではないか、という目論見で、ApplicationController の実装を見にいきます。
が、ここにもsession
に関する記述は存在しません。
ApplicationController は ActionController::Base を継承しているので、今度はこちらの実装を見ます。これ以降は Rails 自体の世界です。自分の Application hoge
から出て Rails 自体のコードを読みにいきます。
gem: 'rails'
ActionController::Base を見にいくと、そこには session のインタフェースに関するコメントがあるのみで、やはり session メソッドは存在しません。
そろそろ飽きてきましたが、ActionController::Base の継承元の ActionController::Metal というクラスにsession
メソッドがありました。
module ActionController
...
class Metal < AbstractController::Base
...
delegate :session, to: "@_request"
...
end
...
end
なるほど、ActionController::Metal はsession
メソッドをインタフェースとして持っていますが、その中身はインスタンス変数@_request
にdelegate
されていますね。
じゃあこの@_request
とはなんぞや。
わからないので先ほど作ったアプリケーションhoge
の SessionsController をちょっといじって確認します。
class SessionsController < ApplicationController
def create
raise session.inspect
# if session[:user_id]
# flash[:notice] = 'ログイン済みです'
# else
# session[:user_id] = params[:user_id]
# end
end
...
end
こうしてからリクエストするなりなんなりでraise
してみると、@_request
の正体が ActionDispatch::Request::Session クラスのオブジェクトであることがわかります。
じゃあ ActionDispatch::Request::Session を見に行きましょう。
...
module ActionDispatch
class Request
...
class Session
...
def [](key)
@delegate[key]
end
...
def []=(k, v); @delegate[k] = v; end
...
end
end
end
ようやくありました!
[]
メソッドと[]=
メソッドです。
つまりまとめると、
アプリケーションの controller にsession[:hoge]
と書くと、リクエストが来たときに、その controller の継承元クラスである ActionController::Metal がdelegate
している ActionDispatch::Request::Session クラスのオブジェクトであるsession
のインスタンスメソッドである[]
メソッドが呼び出される、と。
こうしてコードを追ってみるとなかなか複雑ですね。
どうやって Memcached からロードしているか
ここまでで、インタフェースsession[]
がどのように提供されているかはわかりましたが、Memcached を session store として利用する場合は、[]
メソッドの中に Memcached から session の値をロードする処理があるはずです。
そいつを探っていきます。
引き続き gem: 'rails'
[]
メソッドはload_for_read!
メソッドを呼び出した後にインスタンス変数@delegate
から key に対する value を read していますが、load_for_read!
メソッドでは下記のload!
メソッドを参照しています。
def load!
id, session = @by.load_session @req
options[:id] = id
@delegate.replace(stringify_keys(session))
@loaded = true
end
このように、load!
メソッドの中で、インスタンス変数@by
のload_session
に@req
を引数に渡して呼び出していますが、
@req
は何かというと、ActionDispatch::Request クラスのオブジェクトであり、
@by
は、今回の場合は Memcached をミドルウェアとして利用しているので、 ActionDispatch::Middleware::Session::MemCacheStore クラスのオブジェクトになります。
というわけで、ActionDispatch::Middleware::Session::MemCacheStore を見てみましょう。
全体のコード量が短いので全文掲載します。
require "action_dispatch/middleware/session/abstract_store"
begin
require "rack/session/dalli"
rescue LoadError => e
$stderr.puts "You don't have dalli installed in your application. Please add it to your Gemfile and run bundle install"
raise e
end
module ActionDispatch
module Session
# A session store that uses MemCache to implement storage.
#
# ==== Options
# * <tt>expire_after</tt> - The length of time a session will be stored before automatically expiring.
class MemCacheStore < Rack::Session::Dalli
include Compatibility
include StaleSessionCheck
include SessionObject
def initialize(app, options = {})
options[:expire_after] ||= options[:expires]
super
end
end
end
end
おや、load_session
メソッドはここにはないようです。
require
している ActionDispatch::Middleware::Session::AbstractStore を見に行くと、load_session
メソッド自体は存在しますが、その実体はありません。
def load_session(env)
stale_session_check! { super }
end
load_session
内ではstale_session_check!
しており、その中ではど頭でyield
しています。
def stale_session_check!
yield
rescue ArgumentError => argument_error
if argument_error.message =~ %r{undefined class/module ([\w:]*\w)}
begin
# Note that the regexp does not allow $1 to end with a ':'.
$1.constantize
rescue LoadError, NameError
raise ActionDispatch::Session::SessionRestoreError
end
retry
else
raise
end
end
ここで、stale_session_check!
のブロック引数はsuper
なので、継承元のクラスにload_session
の実体があるはず。
継承元の Rack::Session::Abstract::Persisted を見に行くと、その中身があります。
というわけで今度は Rails の世界から出て、Rack の世界です。
gem: 'rack'
Rack::Session::Abstract::Persisted のコードを抜粋したものが以下です。
...
class Persisted
...
def load_session(req)
sid = current_session_id(req)
sid, session = find_session(req, sid)
[sid, session || {}]
end
...
end
load_session
内でfind_session
を呼び出していますが、find_session
は Rack::Session::Abstract::Persisted には実装されていません。
find_session
は Rails の ActionDispatch::Middleware::Session::MemCacheStore の継承元である Rack::Session::Dalli にあります。
ということで、最後に Dalli の世界です。
Dalli は Rails の Memcached Client として有名なライブラリです。
参考: petergoldstein/dalli on GitHub
gem: 'dalli'
Rack::Session::Dalli クラスの中にはfind_session
メソッドがありますが、その中でget_session
メソッドを呼び出しています。
def find_session(req, sid)
get_session req.env, sid
end
さらに見てみると、get_session
内ではwith_block
メソッドの返り値に対してブロックを渡しており、その中でdc.get
しています。
def get_session(env, sid)
with_block(env, [nil, {}]) do |dc|
unless sid and !sid.empty? and session = dc.get(sid)
old_sid, sid, session = sid, generate_sid_with(dc), {}
unless dc.add(sid, session, @default_ttl)
sid = old_sid
redo # generate a new sid and try again
end
end
[sid, session]
end
end
with_block
は Rack::Session::Dalli 内でメソッドとして定義されていて、インスタンス変数@pool
に対してwith
メソッドを呼び出しています。
def with_block(env, default=nil, &block)
@mutex.lock if @mutex and env['rack.multithread']
@pool.with(&block)
rescue ::Dalli::DalliError, Errno::ECONNREFUSED
raise if $!.message =~ /undefined class/
if $VERBOSE
warn "#{self} is unable to find memcached server."
warn $!.inspect
end
default
ensure
@mutex.unlock if @mutex and @mutex.locked?
end
ここで、インスタンス変数@pool
は Rack::Session::Dalli のinitialize
メソッドで定義されており、キャッシュがなければ ::Dalli::Client をnew
します。つまり@pool
は ::Dalli::Client クラスのオブジェクトです。
::Dalli::Client にはwith
メソッドが定義されており、その中身はyield self
のみ。
def with
yield self
end
つまりwith_block
内のdc.get
は ::Dalli::Client クラスのオブジェクトに対してget
メソッドを呼び出しています。
ここで、Memcached からget
した値が ActionDispatch::Request::Session クラスのオブジェクトの@_request
インスタンスに key, value として入る、というわけですね。
長くなりましたが、これで「どうやって Memcached から`session[:hoge]`に session の値をロードしているか」をコードベースで知ることができました。
単に仕組みを知ることができるだけでなく、コードを書く上で参考になる設計や実装も多いため、Rails など、Ruby のライブラリのコードリーディングは一度やってみることをオススメします!
次回は @ham0215 さんの「rubyに大和魂を注入するgemを作成」です。お楽しみに!!