2018-02-22

Railsのsession周りコードリーディング

Railsのsessionがどうやって設定されるのかをコードリーディングをして追ってみました。Railsのバージョンは5.1.4です。

今回はセッションストアにRedis(redis-store)を使ったケースを追ってみます。

ActionController::Metal#sessionはActionDispatch::Request#sessionにdelegateし、Rack::Request::Env#session(Rack::Request::HelpersやRack::Request::Envなどをinclude)を呼び出します。

module Rack
  class Request
    module Env
      def fetch_header(name, &block)
        @env.fetch(name, &block)
      end

    module Helpers
      def session
        fetch_header(RACK_SESSION) do |k|
          set_header RACK_SESSION, default_session
        end
      end

#sessionはfetch_header経由で@envのHashから値を取得します。キーはRACK_SESSION = 'rack.session'です。

env[RACK_SESSION]の値はミドルウェア経由でセットされます。Railsで差し込まれるミドルウェアはRails::Application::DefaultMiddlewareStackで確認できます。

module Rails
  class Application
    class DefaultMiddlewareStack

      def build_stack
        ActionDispatch::MiddlewareStack.new.tap do |middleware|
# ...
          if !config.api_only && config.session_store
            if config.force_ssl && config.ssl_options.fetch(:secure_cookies, true) && !config.session_options.key?(:secure)
              config.session_options[:secure] = true
            end
            middleware.use config.session_store, config.session_options
            middleware.use ::ActionDispatch::Flash
          end

このconfig.session_storeがSessionのハンドリングになります。 config.session_storeに:redis_storeを指定するとActionDispatch::Session.const_get経由で、ActionDispatch::Session::RedisStoreがミドルウェアとして差し込まれます。

module Rails
  class Application
    class Configuration < ::Rails::Engine::Configuration
      def session_store(new_session_store = nil, **options)
        if new_session_store
# ...
          @session_store = new_session_store
          @session_options = options || {}
        else
          case @session_store
          when :disabled
            nil
          when :active_record_store
            ActionDispatch::Session::ActiveRecordStore
          when Symbol
            ActionDispatch::Session.const_get(@session_store.to_s.camelize)
          else
            @session_store
          end
        end
      end

ActionDispatch::Session::RedisStoreは以下のような継承をしています。

module ActionDispatch
  module Session
    class RedisStore < Rack::Session::Redis
      include Compatibility
      include StaleSessionCheck
      include SessionObject
# ...

moduleを含まない継承ツリーはRack::Session::Redis < Rack::Session::Abstract::ID < Rack::Session::Abstract::Persisted となっており、#callはRack::Session::Abstract::Persisted#callを呼び出します。

module Rack
  module Session
    module Abstract

      class Persisted
        def call(env)
          context(env)
        end

        def context(env, app=@app)
          req = make_request env
          prepare_session(req)
          status, headers, body = app.call(req.env)
          res = Rack::Response::Raw.new status, headers
          commit_session(req, res)
          [status, headers, body]
        end

#callは#contextを呼び出します。#make_requestはenvを引数にRack::Requestのインスタンスを生成します

ActionDispatch::Session::SessionObject#prepare_sessionはRequest::Session.createを呼び出します。

module ActionDispatch
  module Session
    module SessionObject # :nodoc:
      def prepare_session(req)
        Request::Session.create(self, req, @default_options)
      end

Request::Session.createはActionDispatch::Request::Sessionのインスタンスを生成し、setメソッドでENV_SESSION_KEY = Rack::RACK_SESSION = 'rack.session'のキーでenvに設定します。store変数にはActionDispatch::Session::RedisStoreミドルウェアのインスタンス自身が入ります。

module ActionDispatch
  class Request
    class Session # :nodoc:
      def self.create(store, req, default_options)
        session_was = find req
        session     = Request::Session.new(store, req)
        session.merge! session_was if session_was

        set(req, session)
        Options.set(req, Request::Session::Options.new(store, default_options))
        session
      end

      def self.set(req, session)
        req.set_header ENV_SESSION_KEY, session
      end

コントローラ側でsessionが呼び出されるとenv['rack.session']の値が取り出されるのでActionDispatch::Request::Sessionのインスタンスを取得します。このインスタンス自身は遅延ロードする仕組みなので、[]などのアクセサを使った場合にロードする処理が走ります。

module ActionDispatch
  class Request
    class Session # :nodoc:
      def [](key)
        load_for_read!
        @delegate[key.to_s]
      end

      private

        def load_for_read!
          load! if !loaded? && exists?
        end

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

ActionDispatch::Request::Session#load!はActionDispatch::Session::RedisStore#load_sessionを呼び出し、@delegateのHashにセットします。

#load_sessionはStaleSessionCheck#load_session経由でRack::Session::Abstract::Persisted#load_sessionを呼び出します。

module Rack
  module Session
    module Abstract
      class Persisted

        def load_session(req)
          sid = current_session_id(req)
          sid, session = find_session(req, sid)
          [sid, session || {}]
        end

        # Extract session id from request object.

        def extract_session_id(request)
          sid = request.cookies[@key]
          sid ||= request.params[@key] unless @cookie_only
          sid
        end
        # Returns the current session id from the SessionHash.

        def current_session_id(req)
          req.get_header(RACK_SESSION).id
        end

      class ID < Persisted
        def find_session(req, sid)
          get_session req.env, sid
        end

#current_session_idはActionDispatch::Request::Session#id経由でRack::Session::Abstract::Persisted#extract_session_idを呼び出すので結果としてセッションキーのクッキー値を取得します。このセッションキーを使ってRack::Session::Redis#get_sessionを呼び出します。

      def get_session(env, sid)
        if env['rack.session.options'][:skip]
          [generate_sid, {}]
        else
          with_lock(env, [nil, {}]) do
            unless sid and session = with { |c| c.get(sid) }
              session = {}
              sid = generate_unique_sid(session)
            end
            [sid, session]
          end
        end
      end

Redis::Store#getを呼び出してRedisからセッションキーをキーとしてセッション情報を取得しています。

sessionの設定はコントローラの処理が終わってから、ミドルウェアのapp.call以後にActionDispatch::Session::Abstract::Persisted#commit_sessionによって処理されます。

module Actiondispatch
  module Session
    module Abstract
      class Persisted
        def commit_session(req, res)
          session = req.get_header RACK_SESSION
# ...
          session.send(:load!) unless loaded_session?(session)
          session_id ||= session.id
          session_data = session.to_hash.delete_if { |k,v| v.nil? }

          if not data = write_session(req, session_id, session_data, options)
            req.get_header(RACK_ERRORS).puts("Warning! #{self.class.name} failed to save session. Content dropped.")
          elsif options[:defer] and not options[:renew]
            req.get_header(RACK_ERRORS).puts("Deferring cookie for #{session_id}") if $VERBOSE
          else
            cookie = Hash.new
            cookie[:value] = data
            cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after]
            cookie[:expires] = Time.now + options[:max_age] if options[:max_age]
            set_cookie(req, res, cookie.merge!(options))
          end
        end
# ...

module ActionDispatch
  module Session
    class RedisStore < Rack::Session::Redis
      def set_cookie(env, session_id, cookie)
        if env.is_a? ActionDispatch::Request
          request = env
        else
          request = ActionDispatch::Request.new(env)
        end
        request.cookie_jar[key] = cookie.merge(cookie_options)
      end

      def cookie_options
        @default_options.slice(:httponly, :secure)
      end

#commit_sessionはRedisにセッションを書き込みつつ、Set-Cookieヘッダを書き出す処理を行います。

このように、ActionDispatch::Request::Sessionのインスタンスが格納されるenv['rack.session']をインターフェースにしてコントローラでセッションの入出力を行い、Rackミドルウェアレベルでセッションのハンドリングをしていることになります。

このエントリーをはてなブックマークに追加