Railsでよく使用されるマルチテナントGemのテナントの初期化について
このエントリは、SmartHR Advent Calendar 2022の7日目です。
SmartHRではマルチテナントの実現のために activerecord-multi-tenantというGemを使っているのですが、そのGemを調査したときに気づいたことを書きたいと思います。
TL;DR
activerecord-multi-tenant
とActsAsTenant
はリクエストごとにテナントを初期化するタイミングが違うよ。- とくにrequest specでは誤ったテストの原因になりかねないので、テナントが初期化されるタイミングを理解して実際のアプリケーションの動作になるだけ近づけよう。
テナントのリセットタイミングの再現コード
Railsにおいてマルチテナントを行う際に有力な選択肢として
あたりが有力な選択肢になるかと思われます。
(Apartmentも有名ですが先述の2つのGemと異なりマルチスキーマでマルチテナントを構成するGemで検証が間に合わなかったので言及しません :bow: )
どちらのGemもリクエストごとに設定されているテナントを初期化してくれる機能が提供されていますが、その初期化をするタイミングが微妙に異なっています。
↓サンプルコードです。
require 'bundler/inline'
gemfile(true) do
source 'https://rubygems.org'
gem 'rails', '7.0.4'
gem 'sqlite3', '1.5.4'
gem 'activerecord-multi-tenant', '2.1.6', require: false
gem 'acts_as_tenant', '0.5.3'
gem 'rspec-rails'
end
require 'action_controller/railtie'
require 'active_record'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
require 'activerecord-multi-tenant'
class App < Rails::Application
routes.append do
get '/tenant_test' => 'tenant#test'
end
end
class TenantController < ActionController::API
def test
puts "ActsAsTenant.current_tenant: #{ActsAsTenant.current_tenant}"
puts "ActsAsTenant.test_tenant: #{ActsAsTenant.test_tenant}"
puts "MultiTenant.current_tenant: #{MultiTenant.current_tenant}"
render json: { hello: :world }
end
end
App.configure do
config.hosts.clear
end
require 'acts_as_tenant/test_tenant_middleware'
Rails.application.configure do
config.middleware.use ActsAsTenant::TestTenantMiddleware
end
App.initialize!
require 'rspec/rails'
RSpec.describe 'Test', type: :request do
it 'first request' do
ActsAsTenant.current_tenant = 'Tenant set in spec'
ActsAsTenant.test_tenant = 'Tenant set in spec'
MultiTenant.current_tenant = 'Tenant set in spec'
get '/tenant_test'
expect(response.status).to eq(200)
end
it 'second request' do
get '/tenant_test'
expect(response.status).to eq(200)
end
end
実行結果
⋊> ~/acts_as_tenant_sandbox rspec -f d script.rb
Fetching gem metadata from https://rubygems.org/...........
###(中略)###
Test
ActsAsTenant.current_tenant: Tenant set in spec
ActsAsTenant.test_tenant:
MultiTenant.current_tenant:
first request
ActsAsTenant.current_tenant:
ActsAsTenant.test_tenant:
MultiTenant.current_tenant:
second request
Finished in 0.00789 seconds (files took 1.34 seconds to load)
2 examples, 0 failures
実行結果からわかる通りActsAsTenant.current_tenant
はテストコード内で設定したテナントがrequest specの機能で呼び出されたアプリケーションの中でも初期化されずに引き継がれていることがわかります。
この問題(?)についてはActsAsTenant
のREADMEにて記載があります。
If you set the
current_tenant
in your tests, make sure to clean up the tenant after each test by callingActsAsTenant.current_tenant = nil
. Integration tests are more difficult: manually setting thecurrent_tenant
value will not survive across multiple requests, even if they take place within the same test. This can result in undesired boilerplate to set the desired tenant. Moreover, the efficacy of the test can be compromised because the setcurrent_tenant
value will carry over into the request-response cycle.
また対処の方法としてサンプルコードでも使用していたActsAsTenant.test_tenant
の使用を推奨する記載もREADMEにあります。
なぜ初期化するタイミングが違うのか
なぜ初期化するタイミングが違うのかというとActsAsTenant
とMultiTenant
ではテナントの保存に使用しているクラスが異なっているからです。
ActsAsTenant
はRequestStore
を使っています。
対してMultiTenant
はActiveSupport::CurrentAttributes
を使っています。
どちらもThreadクラスを利用しつつリクエストごとに値を初期化してくれる場所を提供してくれるクラスですが、初期化を設定する方法が異なっています。
RequestStore
はRackに値を初期化するミドルウェアを追加しているのと、RailsのExecuterのto_complite
コールバックに値を初期化する動作が追加 されています。
Executerのコールバックについての細かい解説はRailsガイドにあります(いつもありがとうございます!)。
ミドルウェアの中身は↓のようになっており、RequestStore
はリクエストを処理した後にのみ値を初期化していることがわかります。
def call(env)
RequestStore.begin!
status, headers, body = @app.call(env)
body = Rack::BodyProxy.new(body) do
RequestStore.end!
RequestStore.clear!
end
...
対してActiveSupport::CurrentAttributes
ではRails のExecutorのコールバックであるto_run
とto_complete
に値を初期化する動作を追加しています。
先述のガイドにものっていますが、to_run
コールバックはアプリケーションの実行前に呼び出されるコールバックです。
↓は超ざっくりイメージです。
初期化されるタイミングの違いで起こりうること
request specで使えるようになるget
等のメソッドはテストと同じスレッドで擬似的にRackアプリ全体を呼び出すため、RequestStore
を使用しているActsAsTenant.current_tenant
はテスト内で設定したcurrent_tenant
の値が初期化されずに引き継がれます。
request specを統合テストと考えると事前にテナントが固定されたままテストするのはあまりよくないのでリクエストを処理する前に値を初期化してくれるミドルウェアに対応しているActsAsTenant.test_tenant
を使用したほうが良いでしょう。
実は activerecord-multi-tenant
も古いバージョンではRequestStore
を使用しており、ActsAsTenant.current_tenant
と同様にrequest spec内で固定したテナントがget
等で呼び出すアプリケーション内で初期化されず引き継がれるようになっていました。
request specを書く際にはテストのためにデータを準備するコードでテナントを固定する箇所は発生すると思いますので、
テナントがスレッド内でどのように固定と初期化されているのか理解しておくことで間違ったテストを書く可能性が減らせると思います。