Ruby
Rails
rack
session
食べログ

Rails の session を完全に理解した

こんにちは、食べログ 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 を利用することができます。


hoge/app/controllers/sessions_controller

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 自体のコードを読みにいきます。

参考: rails/rails on GitHub


gem: 'rails'

ActionController::Base を見にいくと、そこには session のインタフェースに関するコメントがあるのみで、やはり session メソッドは存在しません。

そろそろ飽きてきましたが、ActionController::Base の継承元の ActionController::Metal というクラスにsessionメソッドがありました。


rails/actionpack/lib/action_controller/metal.rb

module ActionController

...

class Metal < AbstractController::Base
...

delegate :session, to: "@_request"

...
end

...
end


なるほど、ActionController::Metal はsessionメソッドをインタフェースとして持っていますが、その中身はインスタンス変数@_requestdelegateされていますね。

じゃあこの@_requestとはなんぞや。

わからないので先ほど作ったアプリケーションhogeの SessionsController をちょっといじって確認します。


hoge/app/controllers/sessions_controller

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 を見に行きましょう。


rails/actionpack/lib/action_dispatch/request/session.rb

...

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!メソッドを参照しています。


rails/actionpack/lib/action_dispatch/request/session.rb

def load!

id, session = @by.load_session @req
options[:id] = id
@delegate.replace(stringify_keys(session))
@loaded = true
end

このように、load!メソッドの中で、インスタンス変数@byload_session@reqを引数に渡して呼び出していますが、

@reqは何かというと、ActionDispatch::Request クラスのオブジェクトであり、

@byは、今回の場合は Memcached をミドルウェアとして利用しているので、 ActionDispatch::Middleware::Session::MemCacheStore クラスのオブジェクトになります。

というわけで、ActionDispatch::Middleware::Session::MemCacheStore を見てみましょう。

全体のコード量が短いので全文掲載します。


rails/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb

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メソッド自体は存在しますが、その実体はありません。


rails/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb

def load_session(env)

stale_session_check! { super }
end

load_session内ではstale_session_check!しており、その中ではど頭でyieldしています。


rails/actionpack/lib/action_dispatch/middleware/session/abstract_store.rb

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 の世界です。

参考: rack/rack on GitHub


gem: 'rack'

Rack::Session::Abstract::Persisted のコードを抜粋したものが以下です。


rack/lib/rack/session/abstract/id.rb

...

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メソッドを呼び出しています。


dalli/lib/rack/session/dalli.rb

def find_session(req, sid)

get_session req.env, sid
end

さらに見てみると、get_session内ではwith_blockメソッドの返り値に対してブロックを渡しており、その中でdc.getしています。


dalli/lib/rack/session/dalli.rb

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メソッドを呼び出しています。


dalli/lib/rack/session/dalli.rb

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のみ。


dalli/lib/dalli/client.rb

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を作成」です。お楽しみに!!