Rails の Flash の機能はどのように実現されているか
Rails には Flash という機能がある。Flash の機能は次の通り。
flashはセッションの中の特殊な部分であり、リクエストごとにクリアされます。つまりflashは「直後のリクエスト」でのみ参照可能になるという特徴を持ち、エラーメッセージをビューに渡したりするのに便利です。 https://railsguides.jp/action_controller_overview.html#flash
この Flash について、内部でどのようなことをしているかを追ってみたのでまとめる。具体的には以下の2点について追いかけた。
- Flash は Rails 内部でどのように管理されているか
- Flash はどのようにして「直後のリクエスト」でだけ参照できるようにしているか
環境
- Rails 5.2
事前準備
理由は後述するが、事前準備として以下2点の変更を加えておく。
- activerecord-session-store という Gem を使ってセッションの管理を行うようにする
- セッションデータの保存形式をJSONにする
application.rb
に次の行を追加するだけ
# config/application.rb
module MyApp
class Application < Rails::Application
# ...
config.session_store :active_record_store # 追加
# ...
end
ActiveRecord::SessionStore::Session.serializer = :json # 追加
end
※ sessionsテーブルの作成手順は省略する
Flash は Rails 内部でどのように管理されているか
まず1つ目の調査テーマの解答となる。
最初に結論から書くが、Flash はセッションデータの一部として管理されている。 これは、Railsガイドにもしれっと書かれている。
flashはセッションの中の特殊な部分であり、リクエストごとにクリアされます。 https://railsguides.jp/action_controller_overview.html#flash
「事前準備」としてセッションの保存先をデフォルトの Cookie から ActiveRecord に変更し、保存形式をJSON にしたのはこの確認をしやすくするためだ。
実際に動かして確認する
では、実際に動かして確認してみよう。
まず、確認用のエンドポイントを作るため、ルーティングの設定を2つ追加する。
flash_add
で flash にデータを書き込み、flashes
の方で flash からデータを参照して view に出力する。
# config/routes.rb
Rails.application.routes.draw do
get 'flashes' => 'flashes#index', as: :flashes
get 'flash_add' => 'flashes#add'
end
コントローラは #add
で message
というキーに "Hello, flash!"
という値をセットする。#index
は view をレンダリングするのみ。
# app/controllers/flashes_controller.rb
class FlashesController < ApplicationController
def index; end
def add
flash[:message] = 'Hello, flash!'
render :index
end
end
ビューでは、URL と flash
を出力するだけ。
<!-- app/views/flashes/index.html.erb -->
URL: <%= request.url %>
<pre><%= debug flash %></pre>
それでは bundle exec rails s
でサーバを起動して、http://localhost:3000/flash_add
へアクセスしよう。
flash に message
がセットされていることを確認できた。
そして、このときに DB に保存されるセッションデータがどうなっているかも確認してみよう。
bundle exec rails db
で DB のコンソールを起動して、sessions テーブルからデータを取得する(デフォルトなのでSQLite)。
sqlite> SELECT data FROM sessions;
{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM=","flash":{"discard":[],"flashes":{"message":"Hello, flash!"}}}}
セッションデータの一部として、flash
キーが存在し、内部のデータも画面上に出力したデータと同等であることを確認できた。
Flash はどのようにして「直後のリクエスト」でだけ参照できるようにしているか
※ ここから先は一部確証が取れなかった部分もあるため、参考程度にしてください
2つ目の調査テーマ。こちらも最初に結論から書く。
- (1) "直後のリクエスト" でセッションから Flash を取得する段階で、削除すべきデータについてフラグのようなものを立てる
- (2) アプリケーション側の処理が完了した後、再びセッションに保存するための Hash へと変換する処理がある。このときに、先に削除フラグを立てたデータを Hash から削る
- (3) 最後に
activerecord-session-store
が受け取った Hash でセッションデータの更新を行う。結果 Flash が消える
これだけではよくわからないと思うので、順を追って見ていこう。
事象の確認
寄り道となるが、まずは事象、つまり Flash が直後のリクエストでのみ参照できることをセッションデータと合わせて確認してみよう。
(1) Flashをセットするリクエスト(再掲)
まず最初に、Flash にデータを追加する http://localhost:3000/flash_add
をリクエストする。ここでFlashがセットされる。
このとき、セッションデータは次の状態だ。
{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM=","flash":{"discard":[],"flashes":{"message":"Hello, flash!"}}}}
(2) Flashを参照するリクエスト
次に Flash を表示する http://localhost:3000/flashes
をリクエストする。
FlashHash オブジェクトの discard
というインスタンス変数にデータが追加されている。これが先ほど「削除フラグ」と呼んだものの正体だ。またこのとき、DBのセッションデータからは すでに Flash のデータが削除されている。
{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM="}}
(3) その次のリクエスト
再度、Flash を表示するだけの http://localhost:3000/flashes
をリクエストする。
ここでは View に出力される Flash も空になっている。DBの中も先と同様にFlashのデータは存在しない。
これで、ドキュメントにある通り直後のリクエストでだけ参照できることが確認できた。
ActionDispatch::Flash::FlashHash
ビューにダンプした結果からもわかるが、Flashは単なるHashではなく ActionDispatch::Flash::FlashHash
というクラスのインスタンスになっている。
そしてこのクラスに Flash が直後のリクエストでのみ参照できるカラクリがある。
一旦詳細は置いておくが、Flash が ActionDispatch::Flash::FlashHash
のインスタンスとして Rails 内部で扱われていることだけ押さえて次に進もう。
Flashを取得するときに削除フラグを立てる
"直後のリクエスト" が飛んできたとき、処理の流れとしてはアプリケーション本体に到達する前にいくつかのミドルウェアを通るが、Flashに関しては「行き」のリクエストでは特に何も行われず、アプリケーション本体に処理が流れ着く。
今回のサンプルコードでは、アプリケーションの view で flash
メソッドからデータを参照していた。このとき、内部では ActionDispatch::Flash:: RequestMethods#flash
メソッドがコールされている。
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L47-L51
def flash
flash = flash_hash
return flash if flash
self.flash = Flash::FlashHash.from_session_value(session["flash"])
end
重要なのは3行目で、セッションに保存されたデータから Flash を取得するために Flash::FlashHash.from_session_value
がコールされる。
このメソッドは Hash 形式の session["flash"]
を FlashHash に変換する。実装を見てみよう。
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L114-L131
def self.from_session_value(value)
case value
when FlashHash
# 省略
when Hash
flashes = value["flashes"]
if discard = value["discard"]
flashes.except!(*discard)
end
new(flashes, flashes.keys)
else
# 省略
end
end
引数 value
はDBに保存されているセッションデータが Hash 化されたものだ。そのため、when Hash
の条件に引っかかる。
DBに保存されたデータをもう一度見ればわかるが、discard
キーの値は空配列だった。よって、discard = value["discard"]
式の結果も空の配列 []
となる。Rubyでは空の配列は真となるので、flashes.except!(*discard)
は実行される。が、引数 discard
は空配列なので特に何も変化しない(※ ここは本来的には実行されるべきではないのかもしれない...)。
最後に #new(flashes, flashes.keys)
が実行されることになる。
#new
の第2引数は discard
となっており、ここで削除すべきデータをマークしている。
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L141-L145
def initialize(flashes = {}, discard = [])
@discard = Set.new(stringify_array(discard))
@flashes = flashes.stringify_keys
@now = nil
end
つまり、Flash::FlashHash.from_session_value
の返り値は FlashHash
のインスタンスであり、@flashes
インスタンス変数には {"message":"Hello, flash!"}
がセットされ、@discard
インスタンス変数には Set.new(["message"])
がセットされている。
FlashHashを再度セッションに保存するための形式へ変換する
アプリケーション側ではこうして作られた FlashHash
のインスタンスを用いてHTMLの構築が行われる。
そしてHTMLの構築が終わった後に Flash::RequestMethods#commit_flash
がコールされる。
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_controller/metal.rb#L192
def commit_flash # :nodoc:
session = self.session || {}
flash_hash = self.flash_hash
if flash_hash && (flash_hash.present? || session.key?("flash"))
session["flash"] = flash_hash.to_session_value
self.flash = flash_hash.dup
end
if (!session.respond_to?(:loaded?) || session.loaded?) && # reset_session uses {}, which doesn't implement #loaded?
session.key?("flash") && session["flash"].nil?
session.delete("flash")
end
end
ここで重要となるのは Flash::FlashHash#to_session_value
の部分で、FlashHash
のインスタンスをセッションに保存するためのデータに変換している。
# https://github.com/rails/rails/blob/5-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L135-L139
def to_session_value #:nodoc:
flashes_to_keep = @flashes.except(*@discard)
return nil if flashes_to_keep.empty?
{ "discard" => [], "flashes" => flashes_to_keep }
end
先に述べたように、@flashes
の値は {"message":"Hello, flash!"}
で、@discard
の値はSet.new(['message'])
なので @flashes.except(*@discard)
の結果は []
になる。
よって、その後の if flashes_to_keep.empty?
の結果は真となり、nil
が返されることになる。
そして再び #commit_flash
のコードに戻ってみよう。
#to_session_value
の結果は session["flash"]
に代入される。そして、その次の条件分岐は真となり、session.delete("flash")
が実行されることになる。
つまり、ここで Hash 形式の セッションデータから flash
のデータが消えることになる。
ActionDispatch::Session::ActiveRecordStore
がセッションデータを更新する
ただし、この段階ではまだ DB の方は更新されていない。DBの更新はミドルウェアである ActionDispatch::Session::ActiveRecordStore
が行うことになる。
ここから先の詳細は省略するが、ActionDispatch::Session::ActiveRecordStore
は session の値を持って、データをUPDATEする動きとなるため、結果としてセッションデータから Flash の情報が消えることになる。
よって、この次以降のリクエストでは Flash に保存した { message: "Hello, flash!" }
はもう参照できなくなる。
まとめ
Rails の Flash の内部を追いかけてみた。
- Flash はセッションの一部である
- Flash は Rails 内部では
ActionDispatch::Flash::FlashHash
のインスタンスである - "直後のリクエスト"でセッションから Flash を取得する段階で削除フラグがセットされる(=>
discard
インスタンス変数) - アプリケーション側の処理が終わった後に、FlashHash をセッション保存用に再変換する処理があり、そこで discard なデータが消される
- 最後にセッションストアのミドルウェア(本記事では
ActionDispatch::Session::ActiveRecordStore
)がセッションを更新することで完全に削除される