🚀

ソースコードを読んで理解するRuby on Rails のセッション管理

2024/10/04に公開

この記事は Ruby on Rails のセッション管理について、ソースコードを辿りながらその動作を説明したものです。ネタとしてはn番煎じではありますが

  • Ruby on Rails でWebアプリケーション開発を行っている
  • Rails のセッションは雰囲気で使っている。詳しくことは分かっていない
  • そろそろ Rails のコードを読んでみたいがやり方がわからない or 他の人の読み方が知りたい

という方にはご活用いただける内容です。

Bookにもまとめています
長い記事になるので Zenの Book にもまとめました。
内容は同じですので、お好きな方をご利用ください。
全体をざっと眺めたい方にはこの記事を、もう少し詳しく読みたい方にはBookがおすすめです。

https://zenn.dev/kappaz/books/f36da4afa70517

時間がない方向けのまとめ

本記事はとても長い内容になっております。お忙しい方や内容をざっと確認したい方は、以下だけ読んでいただければ記事の要点は掴めると思います。

そもそもセッションとは?

ここから本題に入っていきます。まずは「セッション」そのものについて、色々な定義や説明があると思いますが、この記事では以下のように解釈することにします。

  • CS分野におけるセッション = 「継続的な通信、及びそれを実現するための仕組み」
    • ここでいう「通信」はネットワーク通信に限定されず、同一ホスト内のプロセス間通信(例:アプリケーションとデータベース間のやり取り)等も含む
  • Web分野でのセッション = 「ステートレスなhttp上での継続的な通信、及びそれを実現するための仕組み」
    • 複数のHTTPリクエスト間で仮想的なつながりを持たせる
    • 代表的なユースケースとしては「ユーザーの状態を維持すること」が挙げられる。具体的にはログイン状態を維持したり、ショッピングサイトにおけるカートの中身の維持など。
    • Cookie や トークン を用いて実現するのが一般的

Ruby on Rails におけるセッション管理の概要

次に Ruby on Rails ではどのようにセッションを実現しているかというと、基本的には「IDベースのセッション管理」を採用しています。

具体的な内容については Rails ガイドの「ActionControllerの概要 5.セッション」を参照してください。ここでは、この記事で取り上げる視点を簡単にまとめます。Rails のセッション管理について既に理解がある方は、以下の内容は読み飛ばしていただいて構いません。

  • リクエストに対して Cookie を使用してセッションIDを付与し、ユーザーごとのセッションを実現している。
    • クライアントはその後、付与されたセッションIDを含む Cookie をつけてリクエストを送信する。
    • サーバーは同じセッションIDのリクエストを、同じユーザーとして扱う
    • サーバーはこのセッションIDをキーとして、セッションデータ(複数のリクエスト間で共通して利用する必要のある情報)を保存・参照する
  • セッションデータの保存先(セッションストア)は任意に選択できる。
    • 多くのセッションストアでは、セッションデータはサーバが保持し、Cookie内のセッションIDを用いてそれを検索する。
    • ただしRailsデフォルトのセッションストアである CookieStore においては、全てのセッションデータをセッションIDと共に Cookie に保持している。
    • なお CookieStore ではセッションデータを直接 Cookie 内に保持していることから、Cookie 自体の暗号化が行われている。

ソースコードを追ってみる

ここからが本題です。Ruby on Rails でセッション管理がどのように実現されているか、ソースコードを追って理解を深めていこうと思います。全てはカバーできないので、この記事では「セッションデータの参照」をテーマにして進めていきます。

前提

コントローラ内でセッションデータを参照するケースを考えます。

class UsersController < ApplicationController
  def show
    user_id = session[:test]
    user = User.find(user_id)
  end
end

また以下2点も前提とします。

  • セッションIDは既に割り振られており、セッションデータにも、test: 'test'が既に保持されているものとする
  • SessionStore は デフォルトの CookieStore を用いる(参考)

この状況で、user_id = session[:test]を実行することで、どのようにセッションデータが参照されるか、Rails のソースコードを追いながら確認していきます。

全体像

この後、1つずつコードを追っていきますが、全体の流れが見えないと混乱するかもしれません。そこで最初に全体像を確認しておきます。

この記事では、Controller からセッションデータを参照する際の流れを、以下の4つの階層に分けて説明していきます。

  1. Controller(ActionController::Base)
  2. ActionDispatch::Request::Session: ストアに依らないセッション管理を実現
  3. ActionDispatch::Session::CookieStore: CookieStore を使う場合のセッション管理を実現
  4. ActionDispatch::Cookies::CookieJar: セッションに限らず、Cookie操作全般を実現

いずれの階層も ActionPack のコードに該当します。
ActionPack は、リクエストのルーティングからコントローラーの処理、そしてレスポンスの生成までを担当する、Rails のフレームワークの一部です。リクエスト処理の中心と言えるため、複数のリクエストに仮想的なつながりを持たせるセッションの実装が含まれているのは、納得できる...ような気がします。

この後のソースコードの説明は、上記の4つの階層をもとに進めていきます。

1. Controller(ActionController::Base)階層の話

ではここから セッション管理に関わる ActionPack のソースコードを読んでいこうと思います。改めてになりますが、テーマになるのは以下のサンプルコードです。

class UsersController < ApplicationController
  def show
    user_id = session[:test] # ⭐️ この処理を深堀していきます
    user = User.find(user_id)
  end
end

sessionはどこで定義されている?

まずは Controller がなぜsessionメソッドを直接実行できるのか?というところから見ていきます。sessionメソッドは以下の通り ActionPack のActionController::Metalで他のインスタンスに移譲されています。

https://github.com/rails/rails/blob/b60cf01e9d076d235fdb02db1072ea2a31c94dbe/actionpack/lib/action_controller/metal.rb#L172-L176

ActionController::BaseActionController::Metalを継承しているため、Controller からsessionメソッドを直接呼び出すことができます。

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_controller/base.rb#L207

sessionは何を返す?

Controller 内でsessionが呼び出せることはわかりました。ではその中身はどうなっていて、何を返してくれるのでしょうか?user_id = session[:test]を実行したいので、sessionの中身は[:test]をつけて実行できる何かであるはずです。

引き続きsessionの中身をもう少し見ていきます。
前述の通りsessionの呼び出しは、内部的には@_requestに移譲されています。

    delegate :session, to: "@_request"

@_requestが何なのかを知る必要がありそうです。

https://github.com/rails/rails/blob/b60cf01e9d076d235fdb02db1072ea2a31c94dbe/actionpack/lib/action_controller/metal.rb#L210-L211
調べてみると@_requestActionController::Metal#initializeで定義されているインスタンス変数であることがわかりました。

前述の通りActionController::MetalActionController::Baseの親クラスのため、@_requestは Controller から直接参照することができます。

実際にやってみます。

# Controller内の適当な箇所で止める
(ruby) @_request
#<ActionDispatch::Request GET "http://localhost:3000/login" for ::1>
(ruby) @_request.session
#<ActionDispatch::Request::Session:0x7b48 not yet loaded>

@_requestにはActionDispatch::Requestのインスタンスが入っており、さらに@_request.sessionを実行するとActionDispatch::Request::Sessionのインスタンスが返ってきました!

次の階層であるActionDispatch::Request::Sessionが出てきました。このインスタンスに[:test]をつけて実行することで、セッションデータが取得できるようです。次はこの中身について詳しくみていこうと思います。

not yet loaded ?

次の階層の話に進む前に、1点だけ補足しておきます。

前項でActionDispatch::Request::Sessionのインスタンスが 「not yet loaded」 となっていました。これはちょっと気になる内容ですね。

ただ以下のようにセッションデータを読み込むとこの表示は無くなりました。

# Controller内の適当な箇所で止める
(ruby) session
#<ActionDispatch::Request::Session:0x7b48 not yet loaded>
(ruby) session[:test]
"test"
(rdbg) session
#<ActionDispatch::Request::Session:0x000000011e09fdc0 ...>

どうやらActionDispatch::Request::Sessionは、実際にセッションデータの参照が求められるまで、セッションデータをロードしない仕組みになっているようです。

2. ActionDispatch::Request::Session階層の話

session[:test]を実行するとどうなるか?

class UsersController < ApplicationController
  def show
    # ⭐️sessionは`ActionDispatch::Request::Session`のインスタンス
    user_id = session[:test] 
    user = User.find(user_id)
  end
end

前項までの内容で、sessionActionDispatch::Request::Sessionのインスタンスであることがわかりました。ここからはsession[:test]の動作 = ActionDispatch::Request::Sessionの中身について見ていきます。

session[:test]は、session[]メソッドを実行しているのと同じ意味なので(参考)、ActionDispatch::Request::Session#[](key)のようなメソッドが存在すると予想できます。さらに、前述の通り、このタイミングでセッションデータがロードされるとも考えられます。

実際に探してみると以下のように定義されていることがわかります。

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_dispatch/request/session.rb#L114-L123

load_for_read!(115行目)を行った後に、@delegateから対象のセッションデータを取り出して、戻り値としているように見えます(121行目)。load_for_read!がセッションデータのロードを担当していることは明らかですね。さらに見ていきます。

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_dispatch/request/session.rb#L254-L256

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_dispatch/request/session.rb#L270-L280

load!メソッドの id, session = @by.load_session @req(273行目)でセッションデータを読み込み、@delegate.replace(session.stringify_keys)(275行目)で@delegateに値を格納しているようです。

セッションデータが@delegateに格納されるため、前述のActionDispatch::Request::Session#[](key)において、戻り値に@delegateが使われているということですね。

目的のセッションデータの参照処理は、@by.load_session @req を掘り下げていけば理解できそうです。

@by.load_session @req ?

では@by.load_session @reqを見てきます。最初に@by及び@reqの中身をそれぞれ確認します。

# `ActionDispatch::Request::Session#load!`内の適当な箇所で止める
(ruby) @by
#<ActionDispatch::Session::CookieStore:0x0000000128615a20
 @app=
...
(ruby) @req
#<ActionDispatch::Request GET "http://localhost:3000/" for ::1>

@byActionDispatch::Session::CookieStoreのインスタンスであり、@reqActionDispatch::Requestのインスタンスであるようです。

つまり@by.load_session @reqは、
ActionDispatch::Requestのインスタンスを引数にして、
ActionDispatch::Session::CookieStore#load_sessionを実行している、
ということになります。

ついに次の階層であるActionDispatch::Session::CookieStoreが出てきました。ここまでの処理はRails がどのセッションストアを利用しているか?に依存しないものでしたが、ここからの内容はCookieStore固有の処理になります。
(おそらく他のセッションストアを選んだ際には、@byには別のインスタンスが入るのでしょう)

次項からはActionDispatch::Session::CookieStoreについて見ていきます。

3. ActionDispatch::Session::CookieStore階層の話

ActionDispatch::Session::CookieStore#load_session の概要

では、ActionDispatch::Session::CookieStore#load_session を見ていきます。

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb#L77-L83

これだけを見ても、何をしているのか大体予測できますね。

  • data = unpacked_cookie_data(req)(79行目)で Cookie からデータを取り出す
  • data = persistent_session_id!(data)(80行目)はセッションIDの永続化をおこなっている
    • おそらくこの記事の対象範囲外の話
  • 戻り値として[Rack::Session::SessionId.new(data["session_id"]), data](81行目)でセッションIDとセッションデータのタプルを返している
    • これは前項のActionDispatch::Request::Session#load!におけるid, session = @by.load_session @reqに対応している

data = unpacked_cookie_data(req) で Cookie から取り出した情報は、この時点ではセッションIDとセッションデータが一緒になった情報であり、このメソッドの中で分割して返しているようです。

以降はこの「セッションIDとセッションデータが一緒になった情報」を
「セッション情報」
と呼称します。

この記事のテーマであるセッションデータの参照処理に関しては、unpacked_cookie_dataが最も関連性が高そうです。このメソッドを掘り下げて見ていきます。

https://github.com/rails/rails/blob/ac104520ee8518441bb4f8c2c7835d0bb8fd6800/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb#L93-L103

unpacked_cookie_dataは主に以下のことを実施しているようです。

  • req.fetch_header("action_dispatch.request.unsigned_session_cookie")(94行目)を行い、空の場合はブロックの中身を実行している。
  • ブロックの処理は以下の通り
    • if data = get_cookie(req)(96行目)で Cookie のデータを取り出す
    • req.set_header k, v(101行目)で 取り出した Cookie のデータを"action_dispatch.request.unsigned_session_cookie" に設定している
    • (なお、fetch_header/set_headerは@envに格納されたRackの環境変数の参照/設定を行うためのメソッドであり、こちらで定義されている)

get_cookieでセッション情報を取り出し、Rack環境変数内のaction_dispatch.request.unsigned_session_cookieキーに格納しているようです。

get_cookieについて

さらにget_cookieをみていきます。

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb#L120-L126

request.cookie_jar.signed_or_encrypted[@key]でセッション情報を取り出しているようです。

@keyrequest.cookie_jarの中身を見て見ます。

# `ActionDispatch::Session::CookieStore#get_cookie`で止める
(ruby) @key
"_rails_cookie_session_session"
(ruby) request.cookie_jar
#<ActionDispatch::Cookies::CookieJar:0x000000012601bd60

@keyはセッション情報を格納している Cookie のキーを指しているようです。またrequest.cookie_jarActionDispatch::Cookies::CookieJarのインスタンスを返しています。

request.cookie_jar.signed_or_encrypted(125行目)でActionDispatch::Cookies::CookieJarのインスタンスに対してsigned_or_encryptedを実行し、そこから[]メソッドに@keyを渡して実行する(121行目)ことで、Cookie の中からセッション情報を取り出すようです。

(なおrequest.cookie_jarがどのように設定されているかは、こちら参照のこと。request 作成時にこのモジュールが組み込まれており、cookie_jarの参照時にインスタンスが作成されているようです)

ついに本記事の最後の階層であるActionDispatch::Cookies::CookieJarが登場しました。この先は、セッションとは直接関係のない、単純な Cookie の操作に移ります。

4. ActionDispatch::Cookies::CookieJar階層の話

EncryptedKeyRotatingCookieJarについて

引き続きget_cookie/cookie_jarを見て行きます。

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/session/cookie_store.rb#L120-L126

今度はrequest.cookie_jar.signed_or_encryptedの中身を確認してみることにしましょう。

# `ActionDispatch::Session::CookieStore#get_cookie`内の適当な箇所で止める
(ruby) 
request.cookie_jar.signed_or_encrypted
#<ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar:0x000000012619f1a0

結果ActionDispatch::Cookies::EncryptedKeyRotatingCookieJarのインスタンスが得られました。get_cookieはこれに対して[@key]を実行しています。Cookie の取得動作を知るには、例によってActionDispatch::Cookies::EncryptedKeyRotatingCookieJar#[](key)的なメソッドを確認すればよさそうです。

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/cookies.rb#L646

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/cookies.rb#L504-L521

目的の[]メソッドは、ActionDispatch::Cookies::EncryptedKeyRotatingCookieJarの継承元であるActionDispatch::Cookies::AbstractCookieJarで定義されていました。
このメソッドは、@parent_jar[name.to_s]で受け取ったデータをparseメソッドに渡して返しているようです(512, 513, 518行目)。@parent_jar[name.to_s]にはセッション情報が入っていると思われますが、おそらくは何かしらパース処理が必要な内容なのでしょう。

引き続き@parent_jar[name.to_s]parseメソッドをそれぞれ確認していきます。

@parent_jar[name.to_s] について

# `ActionDispatch::Cookies::AbstractCookieJar#[]`内の適当な箇所で止める
(ruby) @parent_jar
#<ActionDispatch::Cookies::CookieJar:0x0000000131c1e8a0
 @committed=false,
 @cookies="_rails_cookie_session_session"=>"jbtwl9YyylD8dIGJoI...4IBbLxooXg==", ...},
 @delete_cookies={},
...

@parent_jarActionDispatch::Cookies::CookieJarのインスタンスのようです。@cookiesにはセッション情報が含まれているように見えますが、暗号化されています。

https://github.com/ioquatix/rails/blob/a2174bd26043c25d546d04e647f6526efc563bc3/actionpack/lib/action_dispatch/middleware/cookies.rb#L336-L339

@parent_jar[name.to_s]ActionDispatch::Cookies::CookieJar#[](key)で定義されており、@cookiesからname.to_sに対応するものを抜き出しているだけです。

ここまで来れば@parent_jar[name.to_s]の動作もわかりそうですね。

# `ActionDispatch::Cookies::AbstractCookieJar#[]`内の適当な箇所で止める
(ruby) name.to_s
"_rails_cookie_session_session"
(ruby) @parent_jar[name.to_s]
"jbtwl9YyylD8dIGJoI...4IBbLxooXg=="

ActionDispatch::Cookies::CookieJarのインスタンス変数@cookiesから、指定したkey(= _rails_cookie_session_session)に対応するデータ=セッション情報を取り出しています。この段階では取り出したセッション情報は暗号化されており中身は分かりません。

parse について

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/cookies.rb#L646

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/cookies.rb#L504-L521

前項までで @parent_jar[name.to_s](512行目)が、Cookie の中から暗号化状態のセッション情報を取り出したものだと分かりました。そのデータはresult = parse(name, data, purpose: "cookie.#{name}")(513行目)の形でparseメソッドに渡され、結果が最終的にはこのメソッドの戻り値として返されています(518行目)。おそらくはparseメソッド内で復号されて、理解できる形のセッション情報に変換されているものと思われます。

https://github.com/rails/rails/blob/d8d5554f089712e92b7ef2458b464704e6a01fa3/actionpack/lib/action_dispatch/middleware/cookies.rb#L683-L689

実際にparseメソッドの中身をみると @encryptor.decrypt_and_verify(encrypted_message, purpose: purpose, on_rotation: -> { rotated = true })(685行目)のように、復号処理と思しき箇所があります。このコードを直接実行してみます。

# `ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar#parse`内の適当な箇所で止める
(ruby) encrypted_message
"bCUn8kN/iBgJSbnxioslbRHLOwAYatBRkLYpM36LEeINRAh7XjdAn1oIPn5V/SEf7gxb/8DG59w1...Txs/vJiUaSA7JYw16mzMSg=="
(ruby) @encryptor.decrypt_and_verify(encrypted_message, purpose: purpose, on_rotation: -> { rotated = true })
"{\"session_id\":\"02c...1ebc\",\"_csrf_token\":\"EV1n...epc\",\"test\":\"test\"}"

やはりこの処理は暗号化されたセッション情報を復号しているようです!

ついに!Cookie に格納されたセッション情報を確認することができました。セッションIDやCSRFトークンと一緒に、セッションデータ(session[:test])が格納されているのが見てとれますね!
(decrypt_and_verifyの中身も興味深いですが、主題からは外れるのでここまでにしておきたいと思います)

この内容が、ActionDispatch::Session::CookieStoreActionDispatch::Request::Sessionに戻される中で、セッションID/セッションデータに分割され、指定されたキーのセッションデータが抽出され、最終的に Controller に渡される動作になっています。

まとめ

最後に一連の流れを振り返ります。

Controller からsession[:test]を呼び出すと、以下の順に処理が進み、Cookie からセッション情報が取り出されることが確認できました。

  1. Controller(ActionController::Base)
  2. ActionDispatch::Request::Session
  3. ActionDispatch::Session::CookieStore
  4. ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar

次に、処理の逆方向、つまり Cookie から取り出されたセッション情報が、どのように Controller に渡されるかも振り返ります。

  1. ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar
    • Cookie の中から暗号化されたセッション情報を取り出し復号化して返します。
  2. ActionDispatch::Session::CookieStore
    • 受け取ったセッション情報をセッションIDとセッションデータに分けて返します。
  3. ActionDispatch::Request::Session
    • 受け取ったセッションデータから指定されたキーに対応するものを Controller に返します。
  4. Controller(ActionController::Base)
    • 指定したセッションデータを受け取れたので、これを任意の形で処理に利用します。

Railsでは任意のセッションストアを選択することができますが、大筋の流れはどれも変わらないかと思います。

いかがだったでしょうか?長くなりましたが、その分 Rails のセッション関連の処理について、内部のクラスがどのように連携しているかをコードレベルで示せたのではないかと思います。

ここまで一通り読んでくださった方、もしいらっしゃいましたらこんな長い記事を読んでいただき、本当にありがとうございました。

ラグザイア

Discussion