はじめに
現在携わっているとある Rails 5 プロジェクトでは、リクエストに応じて接続先の DB を切り替えるというマルチテナント構成を採用しています。具体的には URL のサブディレクトリに応じて切り替えています。DB の切り替えは Apartment という Gem を使っているのですが、Rails 6 からデフォルトで複数データベース接続機能が追加されるので試してみました。
例題
URL のサブディレクトリに応じて接続するデータベースを切り替えたい。
方法
実装
まず database.yml を次のように書きます。common が共通データベースで hidamari, madomagi がテナントのデータベースのイメージです。この状態で bin/rails db:migrate を実行すると、各データベースで同じマイグレーションが実行されます。
default: &default
  adapter: postgresql
  encoding: unicode
  pool: 5
  username: postgres
  password: postgres
development:
  common:
    <<: *default
    database: rails6_app_common_development
  hidamari:
    <<: *default
    database: rails6_app_hidamari_development
  madomagi:
    <<: *default
    database: rails6_app_madomagi_development
次に config/application.rb を編集します。
module Rails6App
  class Application < Rails::Application
    # 略
    # lib/autoload を自動で読み込むようにする。
    config.paths.add 'lib/autoload', eager_load: true
    # ActiveRecord::Middleware::DatabaseSelector という Rack ミドルウェアを有効にする。
    Rails.application.config.active_record.database_selector = {}
  end
end
ただし、この ActiveRecord::Middleware::DatabaseSelector というミドルウェアは「読み込み用と書き込み用のデータベースを用意して、HTTP メソッドに応じて接続を切り替える」という想定で設計されているため、今回の用途には合わないです。そこで独自のミドルウェアに置き換えます。
# サブディレクトリに応じて接続するデータベースを切り替えるための Rack ミドルウェア。
module Middleware
  module DatabaseSelector
    class SubDirectory
      class << self
        # URL のパスからサブディレクトリを抜き出す。
        # ただし、そのサブディレクトリと同名のデータベース (spec_name) が存在しない場合は nil を返す。
        def get_sub_directory(url)
          uri = URI.parse(url)
          sub_directory = uri.path[/[^\/]+/]&.to_sym
          return nil unless sub_directory.in?(databases)
          sub_directory
        end
        private
        def databases
          @databases ||=
            ActiveRecord::Base
              .configurations
              .configs_for(env_name: Rails.env)
              .map(&:spec_name)
              .map(&:to_sym)
        end
      end
      delegate :get_sub_directory, to: 'self.class'
      def initialize(app)
        @app = app
      end
      def call(env)
        request = ActionDispatch::Request.new(env)
        select_database(request) do
          @app.call(env)
        end
      end
      private
      def select_database(request)
        database = get_sub_directory(request.url) || :common
        ActiveRecord::Base.connected_to(database: database) do
          yield
        end
      end
    end
  end
end
# ActiveRecord::Middleware::DatabaseSelector を独自の Rack ミドルウェアに置き換える。
Rails.application.config.middleware.swap(
  ActiveRecord::Middleware::DatabaseSelector,
  Middleware::DatabaseSelector::SubDirectory
)
# ルーティングのサブディレクトリを制限する。
class SubDirectoryConstraint
  def initialize(include_common: false)
    @include_common = include_common
  end
  def matches?(request)
    sub_directory =
      Middleware::DatabaseSelector::SubDirectory.get_sub_directory(request.url)
    return false unless sub_directory
    return false if !include_common? && sub_directory == :common
    true
  end
  private
  def include_common?
    @include_common
  end
end
Rails.application.routes.draw do
  scope ':tenant', constraints: SubDirectoryConstraint.new do
    resources :characters, only: :index
  end
end
確認
テナントごとに異なるデータを用意します。
ActiveRecord::Base.connected_to(database: :hidamari) do
  %w[ゆの 宮子 沙英 ヒロ]
    .each do { |name| Character.create(name: name) }
end
ActiveRecord::Base.connected_to(database: :madomagi) do
  %w[鹿目まどか 暁美ほむら 巴マミ 百江なぎさ 美樹さやか 佐倉杏子]
    .each do { |name| Character.create(name: name) }
end
そして CharactersController#index を実装して実際にアクセスしてみると、URL のサブディレクトリに応じてデータベースが切り替わっていることが確認できました。
TODO
- Active Job でデータベースを切り替える方法を検討する。
 
参考
- 
rails/activerecord/lib/active_record/railtie.rb
- ActiveRecord::Middleware::DatabaseSelector を有効にする方法を調べた。
 
 - 
rails/activerecord/lib/active_record/middleware/database_selector.rb
- 独自の DatabaseSelector を実装する際に参考にした。
 
 

