Rails の session を完全に理解した

こんにちは、食べログ DevOps チーム / データサイエンスチームの爲岡です。
[食べログ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/tabelog) に書くのは2度めです。
お時間ございましたら、1つめの記事も読んでいただけると嬉しいです。

[Ansible controller/target を Docker コンテナで構築する](https://qiita.com/zettaittenani/items/4c5844962ae85f851c29)

## はじめに
https://togetter.com/li/1268851 を御覧ください。
下記、記事の抜粋です。

>【エンジニア用語解説】
>
「完全に理解した」
製品を利用をするためのチュートリアルを完了できたという意味。
>
「なにもわからない」
製品が本質的に抱える問題に直面するほど熟知が進んだという意味。
>
「チョットデキル」
同じ製品を自分でも１から作れるという意味。または開発者本人。

私は今は DevOps チームとデータサイエンスチームでエンジニアをしてますが、新卒で入社した当初は決済システムの Rails エンジニアでした。
正直に言って、未だに Rails について詳しいとは言えません。

ある日、アプリケーションの controller を書いていて、
「Rails の session ってどういう仕組みで動いているんだろう?」
という疑問が浮かびました。

Rails の session について完全に理解するには、Rails チュートリアルの Session の項を完了できればよい、ということで、やってみました (Ruby on Rails の Session のチュートリアルは[こちら](https://www.railstutorial.org/book/basic_login#sec-sessions_and_failed_login)) 。

が、ただ「チュートリアルをやりました!」だけだとちょっと寂しいので、

「どうやって`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 を利用することができます。

``` ruby: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](https://github.com/rails/rails/blob/b2eb1d1c55a59fee1e6c4cba7030d8ceb524267c/actionpack/lib/action_controller/base.rb) を継承しているので、今度はこちらの実装を見ます。これ以降は Rails 自体の世界です。自分の Application `hoge`から出て Rails 自体のコードを読みにいきます。

参考: [rails/rails on GitHub](https://github.com/rails/rails/tree/b2eb1d1c55a59fee1e6c4cba7030d8ceb524267c)

### gem: 'rails'

ActionController::Base を見にいくと、そこには [session のインタフェースに関するコメントがあるのみ](https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/base.rb#L68-L93)で、やはり session メソッドは存在しません。

そろそろ飽きてきましたが、ActionController::Base の継承元の [ActionController::Metal](https://github.com/rails/rails/blob/b2eb1d1c55a59fee1e6c4cba7030d8ceb524267c/actionpack/lib/action_controller/metal.rb) というクラスに`session`メソッドがありました。

``` ruby:rails/actionpack/lib/action_controller/metal.rb
module ActionController
  ...

  class Metal < AbstractController::Base
    ...
    
    delegate :session, to: "@_request"

    ...
  end

  ...
end
```

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

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

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

``` ruby: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](https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/request/session.rb) を見に行きましょう。

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

``` ruby: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!`メソッドの中で、インスタンス変数`@by`の`load_session`に`@req`を引数に渡して呼び出していますが、
`@req`は何かというと、ActionDispatch::Request クラスのオブジェクトであり、
`@by`は、今回の場合は Memcached をミドルウェアとして利用しているので、 ActionDispatch::Middleware::Session::MemCacheStore クラスのオブジェクトになります。

というわけで、[ActionDispatch::Middleware::Session::MemCacheStore](https://github.com/rails/rails/blob/b2eb1d1c55a59fee1e6c4cba7030d8ceb524267c/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb) を見てみましょう。
全体のコード量が短いので全文掲載します。

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

``` ruby: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`しています。

``` ruby: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](https://github.com/rack/rack)

### gem: 'rack'
[Rack::Session::Abstract::Persisted](https://github.com/rack/rack/blob/master/lib/rack/session/abstract/id.rb) のコードを抜粋したものが以下です。

``` ruby: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](https://github.com/petergoldstein/dalli)

### gem: 'dalli'
[Rack::Session::Dalli](https://github.com/petergoldstein/dalli/blob/master/lib/rack/session/dalli.rb) クラスの中には`find_session`メソッドがありますが、その中で`get_session`メソッドを呼び出しています。

``` ruby:dalli/lib/rack/session/dalli.rb
def find_session(req, sid)
  get_session req.env, sid
end
```

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

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

``` ruby: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](https://github.com/petergoldstein/dalli/blob/master/lib/dalli/client.rb) には`with`メソッドが定義されており、その中身は`yield self`のみ。

``` ruby: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 として入る、というわけですね。

<br>
長くなりましたが、これで「どうやって Memcached から`session[:hoge]`に session の値をロードしているか」をコードベースで知ることができました。

単に仕組みを知ることができるだけでなく、コードを書く上で参考になる設計や実装も多いため、Rails など、Ruby のライブラリのコードリーディングは一度やってみることをオススメします!

次回は @ham0215 さんの「rubyに大和魂を注入するgemを作成」です。お楽しみに!!