はじめに

これは ドリコム Advent Calendar 2021 の1日目です。

自己紹介

どうも、DRIP エンジニアの小川です。
DRIP は Drecom Invention Project の略称で、ドリコムが発明を産み続けるためのプロジェクトです。
今年は総合エンターテイメント企業を目指す中、その周辺領域にも視野を広げながら新規事業開発を進めています。

drip_logo

ちなみに去年の投稿

水面下活動した一年でした。

と書きましたが、なんと今年、水面下から浮上しました。
去年からずっと Rooot というサービスに携わっております。

rooot_logo

Rooot とは「ファンをよりファンにする」をコンセプトとした、ユーザー同士で繋がるキッカケを与えるファンコミュニティ促進サービスです。
導入タイトルは日々着々と増加しておりまして、導入いただいたゲームは40タイトルを突破いたしました!
様々なクライアント様にご協力いただき、大変感謝です。
詳細は上記リンク先や https://www.rooot.biz/ を御覧ください。

今やっている事

DRIP では更に今、Rooot とは別の水面下活動を平行しています。
とある新規サービス立ち上げの話があり、早速 Munimum Variable Product を作ろうという流れの中で久々に Rails 環境の構築をする事に。
SPA + backend な Web サービスを提供しようと考えているため、frontend との疎通は backend API のみで構成予定です。
であるならば Rails による API 専用アプリケーションとしっかり向き合おうと思い、API 専用モードをオンにした上で土台を作り始めました。
しかし土台構築中に様々な問題にぶち当たりまして。
その中の1つ、session_store の設定について今日はご紹介します。

よくある session_store redis の設定

session の保存先を Redis にしたい!
というわけで改めてそのやり方を調べていくと、

この辺の gem を1つ導入し、各種 README の通り

# redis-actionpack README から拝借
MyApplication::Application.config.session_store :redis_store,
servers: ["redis://localhost:6379/0/session"],
expire_after: 90.minutes,
key: "_#{Rails.application.class.parent_name.downcase}_session",
threadsafe: true,
secure: true

のようなコードを config/initializers/ に配置すればできそうな感じでした。

class RedisController < ApplicationController
def session_set
session[:test_time] = Time.current
render json: { status: "ok" }
end


def session_get
render json: { time: session[:test_time]&.iso8601 }
end
end

resource :redis, only: [] do
get "/session_set", to: "redis#session_set"
get "/session_get", to: "redis#session_get"
end

上記のような、session に値を入れたり参照するアクションを生やし routing してブラウザから http://127.0.0.1:3000/redis/session_set にアクセスし Chrome DevTools を確認したところ、なんと session 用の cookie が見当たりません。

not_found_cookie

本来であれば自分の session データにアクセスするための Redis key が cookie に保存されているはずなのに…
当然、http://127.0.0.1:3000/redis/session_get にアクセスしても {"time":null} が返ってきます。

調査開始

rails session store redis not working でググってみたところ、欲しい情報は得られず…
ひょっとして API 専用アプリケーションが要因かも?と思い rails session store redis not working api_only でググってみたところ

色々な有益情報が出てきました。
要約すると、config.api_only = true の場合は使われる middleware が限定されており、Redis を使った session_store 設定を有効化するには必要な middleware を使うようにしなければならない、とのこと。
https://stackoverflow.com/questions/27710896/rails-4-sessions-cookie-is-not-set の情報を見ると「config.api_only = false にすれば直るよ!」とあり、確かに config.api_only = false にすれば解消するのですが API 専用アプリケーションではなくなるので本末転倒です。
しかも、結構な確率で「config.api_only = false にすれば直るよ!」を勧めてくるページがヒットします。
class ApplicationController &lt; ActionController::API のまま config.api_only = false にして大丈夫なのだろうか…
だったら rails new 時に --api しない、の方が安全な気がします。

cache_store を用いた session_store redis の設定方法

http://ggibucket.com/entries/7120ed0147f092c111ae にある通り

  • config.middleware.use ActionDispatch::Cookies する
  • config.middleware.use ActionDispatch::Session::CacheStore する
  • config.cache_store に redis を宛てる
config.cache_store = :redis_store, {
host: "localhost",
port: 6379,
db: 0,
password: "mysecret",
namespace: "cache"
}, {
expires_in: 90.minutes
}

をすれば config.api_only = true のままでも session_store にて(結果として)Redis が使われるようになります!

ブラウザアクセスしたらまさかのエラー

実は、プロダクトを作り始めた当初は config.api_only = false で取り急ぎ対応しており、上記情報を手に入れたのは開発途中段階での事でした。
これらの情報を集めた時、これで config.api_only = true に戻せる!とウキウキしながら修正していったのですが、rails s 後にブラウザアクセスしてみるとまさかのエラー。

api_only_access_error

え!?なんで!?
色々ググってみたところ https://stackoverflow.com/questions/54979970/rails-5-integration-test-fails-with-nomethoderror-undefined-method-for-ni を発見し、middleware.use とは違う middleware 追加方法の存在に気付きました。

middleware.use するという事

一旦 middleware.use している箇所をコメントアウトし、bundle exec rails middleware を使って現在の middleware およびロード順を調べます。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run App::Application.routes

そして middleware.use を復活させると、こうなります。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use ActionDispatch::Cookies
use ActionDispatch::Session::CacheStore
run App::Application.routes

middleware.use すると、ロード順の最後に push される事がわかります。
config.api_only = false するとロードする middleware が増えるとの事だったので、改めて middleware.use している箇所をコメントアウトし、config.api_only = false した上で bundle exec rails middleware を確認します。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
+ use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run App::Application.routes

config.api_only = true との差分は以下の7行。

~~~
use Rack::MethodOverride
~~~
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
~~~
use Rack::TempfileReaper
~~~

今回追加したい ActionDispatch::CookiesActiveRecord::Migration::CheckPending の直下にいました。
なので ActiveRecord::Migration::CheckPending の直下に配置されるように、

config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CacheStore

を以下の通りに書き換えます。

config.middleware.insert_after ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies
config.middleware.insert_after ActionDispatch::Cookies, ActionDispatch::Session::CacheStore

これでエラーは無くなりました。

cache_store に設定した redis を session_store として使う場合の注意点

ちなみに1点、API 専用アプリケーションじゃなかった頃の session_store redis 設定との違いがありました。
同じ db を共有する事になります。
上記例で言うと 0 を共有しています。
例えばですが、session に入れた値だけ全部消したい!となった場合 flushdb は使えないなど、若干選択肢が狭まります。
cache_store と session_store で別々の db を指定したい!場合はどうするか、調べてみようと思い立ちました。

middleware に手動で登録

API 専用プロジェクトにしていない別の Rails プロジェクトでは redis-session-store を使っており、Rails.application.config.session_store を呼び出して設定しています。
ひょっとしてこれを呼び出すと middleware として追加されるのでは?と思い bundle exec rails middleware してみると

use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use RedisSessionStore

見つけた!
RedisSessionStore の実体は https://github.com/roidrage/redis-session-store/blob/d166f70d3ed781d7db21fa1da824c44e49f45d24/lib/redis-session-store.rb です。
つまり Rails.application.config.session_store に入れているものの実体を middleware.insert_after すればいけるのでは?
更に上記参考ページにある https://stackoverflow.com/questions/27710896/rails-4-sessions-cookie-is-not-set のコメント先 https://stackoverflow.com/a/61238872 を見てみると、middleware を渡す際に引数も渡せる事がわかりました。
おそらく Rails.application.config.session_store 時に渡す引数をそのまま渡せば、期待する設定を入れる事ができそうです。
というわけで以下のように書き換えました。

# redis-actionpack での例
Rails.application.config.middleware.insert_after ActiveRecord::Migration::CheckPending, ActionDispatch::Cookies
Rails.application.config.middleware.insert_after ActionDispatch::Cookies, ActionDispatch::Session::RedisStore,
servers: ["redis://#{ENV.fetch("REDIS_HOST") { "localhost" }}:6379/0"],
expire_after: 5.minutes,
key: "_redis_sample_session"

http://127.0.0.1:3000/redis/session_set にアクセスし Chrome DevTools を確認したところ、ちゃんと期待する key で cookie が作成されていました。

find_cookie

第二部 完

Rails.application.config.session_store が使われる条件

気になるのは Rails.application.config.session_store に入れた設定に対して、以下のように挙動が異なる点です。

  • config.api_only = true の場合、無視される
  • config.api_only = false の場合、有効になる
    • bundle exec rails middleware を見ると ActionDispatch::Session::CookieStore の箇所が session_store に設定したもので書き換わっている

config.api_only を直接見ているためなのでしょうか?それとも別の原因?
該当コードを探ってみます。
https://github.com/rails/rails/blob/f132be462b957ea4cd8b72bf9e7be77a184a887b/railties/lib/rails/application/finisher.rb#L48-L54

# Setup default session store if not already set in config/application.rb
initializer :setup_default_session_store, before: :build_middleware_stack do |app|
unless app.config.session_store?
app_name = app.class.name ? app.railtie_name.chomp("_application") : ""
app.config.session_store :cookie_store, key: "_#{app_name}_session"
end
end

session_store が設定されていない場合は session_store に :cookie_store が指定されるようになっているため、

bundle exec rails middleware を見ると ActionDispatch::Session::CookieStore の箇所が session_store に設定したもので書き換わっている

書き換わっているのではなく、Rails::Application::Finisher 側で挿入する middleware を決定している、と見れます。
config.api_only = true の場合は設定した session_store も ActionDispatch::Session::CookieStore も挿入されない、という事は initializer :setup_default_session_store が呼び出されていなさそうな雰囲気です。
bundle exec rails initializers を使って確認してみるも、config.api_only による差分は全くありませんでした。
という事は session_store に入れた値を middleware として設定する処理の部分に差分がある?
そして辿り着いたのが以下のコード。
https://github.com/rails/rails/blob/f132be462b957ea4cd8b72bf9e7be77a184a887b/railties/lib/rails/application/default_middleware_stack.rb#L64-L71

middleware.use ::ActionDispatch::Cookies unless config.api_only


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
end

やはり config.api_only が見られていました。
config.api_only = true の場合は session_store が無視される事がわかったため、手動で middleware に挿入するのが正しいようです。

RSpec 実行時にエラー発生

その後プロジェクトに RSpec を導入して bundle exec rspec したらエラーが発生。

RuntimeError:
No such middleware to insert after: ActiveRecord::Migration::CheckPending

ActiveRecord::Migration::CheckPending の後に insert できない?
ひょっとしてテスト時は middleware が異なる?
手動で入れている middleware.insert_after を一旦コメントアウトし、RAILS_ENV=test bundle exec rails middleware して確かめます。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run App::Application.routes

ActiveRecord::Migration::CheckPending が無い!
というわけで insert_after ActiveRecord::Migration::CheckPending から insert_before Rack::Head に書き換えて対応しました。

まとめ

  • API 専用アプリケーションにした Rails では今までの session_store 設定をしても有効にならない
    • config.api_only = true の場合、使用される middleware が制限されておりそれが原因だった
    • かつ session_store の設定も無視されてしまう
  • 必要な middleware を適切な場所に差し込む事で有効となる
    • ActionDispatch::Cookies および使用したい Redis store が対象

網羅した記事が見つからなかったため、ならば自分がやろう!と衝動ドリブンで書き上げました。
結局コード全体はどうなってるの?と思った皆様、ご安心ください。
https://github.com/ogwmtnr/rails-api-session-redis に全て push してあります!
この記事に該当する PR は https://github.com/ogwmtnr/rails-api-session-redis/pull/3 となりますので、お急ぎの方はこちらを見ていただければ設定方法がわかるかと思います。
少しでも、Rails エンジニアの役に立てたら嬉しいです。

さいごに

明日は広井淳貴さんです。

ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!