Rails 6 の複数データベース接続機能でマルチテナントを試してみる

# はじめに

現在携わっているとある Rails 5 プロジェクトでは、リクエストに応じて接続先の DB を切り替えるというマルチテナント構成を採用しています。具体的には URL のサブディレクトリに応じて切り替えています。DB の切り替えは [Apartment](https://github.com/influitive/apartment) という Gem を使っているのですが、Rails 6 からデフォルトで複数データベース接続機能が追加されるので試してみました。

# 例題

URL のサブディレクトリに応じて接続するデータベースを切り替えたい。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6697/720bb1c7-8cde-cd56-62b3-0970a7600d89.png" width="600px">


# 方法

## 実装

まず `database.yml` を次のように書きます。`common` が共通データベースで `hidamari`, `madomagi` がテナントのデータベースのイメージです。この状態で `bin/rails db:migrate` を実行すると、各データベースで同じマイグレーションが実行されます。

```config/database.yml
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` を編集します。

```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 メソッドに応じて接続を切り替える」という想定で設計されているため、今回の用途には合わないです。そこで独自のミドルウェアに置き換えます。

```lib/autoload/middleware/database_selector/sub_directory.rb
# サブディレクトリに応じて接続するデータベースを切り替えるための 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
```

```config/initializers/database_selector.rb
# ActiveRecord::Middleware::DatabaseSelector を独自の Rack ミドルウェアに置き換える。
Rails.application.config.middleware.swap(
  ActiveRecord::Middleware::DatabaseSelector,
  Middleware::DatabaseSelector::SubDirectory
)
```

```config/routes.rb
# ルーティングのサブディレクトリを制限する。
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
```

## 確認

テナントごとに異なるデータを用意します。

```db/seeds.rb
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 のサブディレクトリに応じてデータベースが切り替わっていることが確認できました。

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6697/985144b5-b3bf-eeea-0850-79a8221bf36e.png)

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6697/7098d061-0e82-e9d7-b2f9-ce1ed6b99b94.png)

## TODO

* Active Job でデータベースを切り替える方法を検討する。

# 参考

* [rails/activerecord/lib/active_record/railtie.rb](https://github.com/rails/rails/blob/v6.0.0.rc1/activerecord/lib/active_record/railtie.rb)
    * ActiveRecord::Middleware::DatabaseSelector を有効にする方法を調べた。
* [rails/activerecord/lib/active_record/middleware/database_selector.rb](https://github.com/rails/rails/blob/v6.0.0.rc1/activerecord/lib/active_record/middleware/database_selector.rb)
    * 独自の DatabaseSelector を実装する際に参考にした。