2019-05-01

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

コントローラは #addmessage というキーに "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 へアクセスしよう。

2019-05-01-01.png
rails-flash-01

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がセットされる。

2019-05-01-01.png
rails-flash-01

このとき、セッションデータは次の状態だ。

{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM=","flash":{"discard":[],"flashes":{"message":"Hello, flash!"}}}}

(2) Flashを参照するリクエスト

次に Flash を表示する http://localhost:3000/flashes をリクエストする。

2019-05-01-03.png
rails-flash-02

FlashHash オブジェクトの discard というインスタンス変数にデータが追加されている。これが先ほど「削除フラグ」と呼んだものの正体だ。またこのとき、DBのセッションデータからは すでに Flash のデータが削除されている

{"value":{"_csrf_token":"vQXqgtLdSSRuREjuFx4RGZ5g3BGg+Ooo3b0V+ZxfEMM="}}

(3) その次のリクエスト

再度、Flash を表示するだけの http://localhost:3000/flashes をリクエストする。

2019-05-01-04.png
rails-flash-03

ここでは 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)がセッションを更新することで完全に削除される