ソースコードを読んで理解するRuby on Rails のセッション管理
この記事は Ruby on Rails のセッション管理について、ソースコードを辿りながらその動作を説明したものです。ネタとしてはn番煎じではありますが
- Ruby on Rails でWebアプリケーション開発を行っている
- Rails のセッションは雰囲気で使っている。詳しくことは分かっていない
- そろそろ Rails のコードを読んでみたいがやり方がわからない or 他の人の読み方が知りたい
という方にはご活用いただける内容です。
Bookにもまとめています
長い記事になるので Zenの Book にもまとめました。
内容は同じですので、お好きな方をご利用ください。
全体をざっと眺めたい方にはこの記事を、もう少し詳しく読みたい方にはBookがおすすめです。
時間がない方向けのまとめ
本記事はとても長い内容になっております。お忙しい方や内容をざっと確認したい方は、以下だけ読んでいただければ記事の要点は掴めると思います。
そもそもセッションとは?
ここから本題に入っていきます。まずは「セッション」そのものについて、色々な定義や説明があると思いますが、この記事では以下のように解釈することにします。
- 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つの階層に分けて説明していきます。
- Controller(
ActionController::Base
) -
ActionDispatch::Request::Session
: ストアに依らないセッション管理を実現 -
ActionDispatch::Session::CookieStore
: CookieStore を使う場合のセッション管理を実現 -
ActionDispatch::Cookies::CookieJar
: セッションに限らず、Cookie操作全般を実現
いずれの階層も ActionPack のコードに該当します。
ActionPack は、リクエストのルーティングからコントローラーの処理、そしてレスポンスの生成までを担当する、Rails のフレームワークの一部です。リクエスト処理の中心と言えるため、複数のリクエストに仮想的なつながりを持たせるセッションの実装が含まれているのは、納得できる...ような気がします。
この後のソースコードの説明は、上記の4つの階層をもとに進めていきます。
ActionController::Base
)階層の話
1. Controller(ではここから セッション管理に関わる ActionPack のソースコードを読んでいこうと思います。改めてになりますが、テーマになるのは以下のサンプルコードです。
class UsersController < ApplicationController
def show
user_id = session[:test] # ⭐️ この処理を深堀していきます
user = User.find(user_id)
end
end
sessionはどこで定義されている?
まずは Controller がなぜsession
メソッドを直接実行できるのか?というところから見ていきます。session
メソッドは以下の通り ActionPack のActionController::Metal
で他のインスタンスに移譲されています。
ActionController::Base
がActionController::Metal
を継承しているため、Controller からsession
メソッドを直接呼び出すことができます。
sessionは何を返す?
Controller 内でsession
が呼び出せることはわかりました。ではその中身はどうなっていて、何を返してくれるのでしょうか?user_id = session[:test]
を実行したいので、session
の中身は[:test]
をつけて実行できる何かであるはずです。
引き続きsession
の中身をもう少し見ていきます。
前述の通りsession
の呼び出しは、内部的には@_request
に移譲されています。
delegate :session, to: "@_request"
@_request
が何なのかを知る必要がありそうです。
@_request
はActionController::Metal#initialize
で定義されているインスタンス変数であることがわかりました。
前述の通りActionController::Metal
はActionController::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
は、実際にセッションデータの参照が求められるまで、セッションデータをロードしない仕組みになっているようです。
ActionDispatch::Request::Session
階層の話
2. session[:test]を実行するとどうなるか?
class UsersController < ApplicationController
def show
# ⭐️sessionは`ActionDispatch::Request::Session`のインスタンス
user_id = session[:test]
user = User.find(user_id)
end
end
前項までの内容で、session
がActionDispatch::Request::Session
のインスタンスであることがわかりました。ここからはsession[:test]
の動作 = ActionDispatch::Request::Session
の中身について見ていきます。
session[:test]
は、session
の[]
メソッドを実行しているのと同じ意味なので(参考)、ActionDispatch::Request::Session#[](key)
のようなメソッドが存在すると予想できます。さらに、前述の通り、このタイミングでセッションデータがロードされるとも考えられます。
実際に探してみると以下のように定義されていることがわかります。
load_for_read!
(115行目)を行った後に、@delegate
から対象のセッションデータを取り出して、戻り値としているように見えます(121行目)。load_for_read!
がセッションデータのロードを担当していることは明らかですね。さらに見ていきます。
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>
@by
はActionDispatch::Session::CookieStore
のインスタンスであり、@req
はActionDispatch::Request
のインスタンスであるようです。
つまり@by.load_session @req
は、
ActionDispatch::Request
のインスタンスを引数にして、
ActionDispatch::Session::CookieStore#load_session
を実行している、
ということになります。
ついに次の階層であるActionDispatch::Session::CookieStore
が出てきました。ここまでの処理はRails がどのセッションストアを利用しているか?に依存しないものでしたが、ここからの内容はCookieStore固有の処理になります。
(おそらく他のセッションストアを選んだ際には、@by
には別のインスタンスが入るのでしょう)
次項からはActionDispatch::Session::CookieStore
について見ていきます。
ActionDispatch::Session::CookieStore
階層の話
3. ActionDispatch::Session::CookieStore#load_session の概要
では、ActionDispatch::Session::CookieStore#load_session
を見ていきます。
これだけを見ても、何をしているのか大体予測できますね。
-
data = unpacked_cookie_data(req)
(79行目)で Cookie からデータを取り出す- 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
が最も関連性が高そうです。このメソッドを掘り下げて見ていきます。
unpacked_cookie_data
について
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
をみていきます。
request.cookie_jar.signed_or_encrypted[@key]
でセッション情報を取り出しているようです。
@key
とrequest.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_jar
はActionDispatch::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 の操作に移ります。
ActionDispatch::Cookies::CookieJar
階層の話
4. EncryptedKeyRotatingCookieJarについて
引き続きget_cookie
/cookie_jar
を見て行きます。
今度は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)
的なメソッドを確認すればよさそうです。
目的の[]
メソッドは、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_jar
はActionDispatch::Cookies::CookieJar
のインスタンスのようです。@cookies
にはセッション情報が含まれているように見えますが、暗号化されています。
@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 について
前項までで @parent_jar[name.to_s]
(512行目)が、Cookie の中から暗号化状態のセッション情報を取り出したものだと分かりました。そのデータはresult = parse(name, data, purpose: "cookie.#{name}")
(513行目)の形でparse
メソッドに渡され、結果が最終的にはこのメソッドの戻り値として返されています(518行目)。おそらくはparse
メソッド内で復号されて、理解できる形のセッション情報に変換されているものと思われます。
実際に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::CookieStore
やActionDispatch::Request::Session
に戻される中で、セッションID/セッションデータに分割され、指定されたキーのセッションデータが抽出され、最終的に Controller に渡される動作になっています。
まとめ
最後に一連の流れを振り返ります。
Controller からsession[:test]
を呼び出すと、以下の順に処理が進み、Cookie からセッション情報が取り出されることが確認できました。
- Controller(
ActionController::Base
) ActionDispatch::Request::Session
ActionDispatch::Session::CookieStore
ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar
次に、処理の逆方向、つまり Cookie から取り出されたセッション情報が、どのように Controller に渡されるかも振り返ります。
-
ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar
- Cookie の中から暗号化されたセッション情報を取り出し復号化して返します。
-
ActionDispatch::Session::CookieStore
- 受け取ったセッション情報をセッションIDとセッションデータに分けて返します。
-
ActionDispatch::Request::Session
- 受け取ったセッションデータから指定されたキーに対応するものを Controller に返します。
- Controller(
ActionController::Base
)- 指定したセッションデータを受け取れたので、これを任意の形で処理に利用します。
Railsでは任意のセッションストアを選択することができますが、大筋の流れはどれも変わらないかと思います。
いかがだったでしょうか?長くなりましたが、その分 Rails のセッション関連の処理について、内部のクラスがどのように連携しているかをコードレベルで示せたのではないかと思います。
ここまで一通り読んでくださった方、もしいらっしゃいましたらこんな長い記事を読んでいただき、本当にありがとうございました。
株式会社ラグザイア(luxiar.com)の技術広報ブログです。 ラグザイアはRuby on RailsとC#に特化した町田の受託開発企業です。フルリモートでの開発を積極的に推進しており、全国からの参加を可能にしています。柔軟な働き方で最新のソフトウェアソリューションを提供します。
Discussion