Rails6.0で複数のデータベースを利用する

Rails6.0から複数のデータベースを標準機能として利用できるようになりました
複数のデータベースを利用することで、例えばプロジェクトの規模が大きくなった時にスケールしやすくなることや、コネクション数を増やすことができるなどの利点があります

この記事ではRailsアプリケーションで2つのデータベースの利用と、データベースのreplicaの利用のやり方を試してみます

作成したソースコードをGitHubで公開しています
https://github.com/youichiro/rails-multiple-db-sandbox

## やったこと

- 複数のデータベースの利用
  - commonデータベースとschoolデータベースを作成
  - それぞれのデータベースのモデルを作成
- primary / replicaを利用
  - commonデータベースのreplicaと、schoolデータベースのreplicaを用意
- GETリクエストはreplicaが呼び出されることを確認
- 異なるデータベースのテーブル間のJOINはできないことを確認

## 複数データベース
複数データベースは1つのアプリケーションから複数のデータベースに接続してデータの読み書きを行う仕組みです
データベースAとデータベースBの2つのDBがあった時、Railsは**呼び出すモデルに応じて接続するデータベースを切り替えることができます**
![スクリーンショット 2020-10-31 4.17.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/db35c065-1439-1362-c12b-933c553a581a.png)


## primary / replica データベース
1つのデータベースに対して読み込み専用のreplica(複製)を用意しておき、リクエストに応じてprimaryとreplicaを切り替える仕組みです
データベースへのアクセスが多くなった時に、書き込み用DBと読み込み用DBに分けておくことでアクセスの負荷を分散することができます
Railsでは**POST, PUT, PATCH, DELETEリクエストはprimary**に、直近の書き込みがなければ**GET, HEADリクエストはreplica**にアクセスするように自動的に切り替えられるようになっています

![スクリーンショット 2020-10-31 14.17.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/ddcc870e-d0d1-6945-f1bd-ebe1f302eebb.png)



## データベースの設定
次のようなデータベースを作成するときの`config/database.yml`は以下のようになります

- commonデータベース
    - commonデータベースのreplica
- schoolデータベース
    - schoolデータベースのreplica

```yml:config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  host: localhost
  port: 3306

development:
  common:
    <<: *default
    database: rails_app_common_development
    migrations_paths: db/common_migrate
  common_replica:
    <<: *default
    database: rails_app_common_development
    replica: true
  school:
    <<: *default
    database: rails_app_school_development
    migrations_paths: db/school_migrate
  school_replica:
    <<: *default
    database: rails_app_school_development
    replica: true
```

primaryのデータベースには`migrations_paths`にマイグレーションファイルの保存場所を指定しています
replicaには`replica: true`を指定します

この設定でデータベースを作成します

```sh
$ bin/rails db:create
```

## モデルの抽象クラスを作成
呼び出すモデルによって接続するデータベースを切り替えるようにします
commonデータベースに接続するモデルのベースとなる抽象クラスを作成し、データベースの接続先の設定を記述します

```ruby:app/models/common_base.rb
class CommonBase < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :common, reading: :common_replica }
end
```

ApplicationRecordを継承した`CommonBase`クラスを作成し、`connects_to`で書き込み時のDBと読み込み時のDBを指定しています

schoolデータベースに接続するモデルの抽象クラスも同様に作成します

```ruby:app/models/school_base.rb
class SchoolBase < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :school, reading: :school_replica }
end
```

新しいモデルを作成する時には`CommonBase`か`SchoolBase`のどちらかを継承するようにします
これによってモデルによってデータベースの接続先を切り替えることができます


## モデルの作成
commonデータベースに`Userモデル`を作成する場合の手順です
まずgenerate modelコマンドでモデルファイルとマイグレーションファイルを作成します

```sh
$ bin/rails g model user name:string school:references --database common

Running via Spring preloader in process 54763
      invoke  active_record
      create    db/common_migrate/20201030135726_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
```

マイグレーションファイルが`db/common_migrate`ディレクトリに作成されました
`--database`に接続するデータベースを指定することで、database.ymlで設定した`migrations_paths`に作成されるようになります
schoolデータベースにモデルを作成したい場合は`--database school`を指定します

次にマイグレートを行います

```sh
# 全てのマイグレーションファイルを適用する場合
$ bin/rails db:migrate

# commonデータベースのマイグレーションファイルのみを適用する場合
$ bin/rails db:migrate:common
```

最後にモデルファイルを変更します
生成時はApplicationRecordを継承していますが、Userモデルはcommonデータベースを利用したいので`CommonBase`を継承するように変更します

```diff
- class User < ApplicationRecord
+ class User < CommonBase
  end
```

これでUserモデルに関してはcommonデータベースから読み書きを行うようになります


## リクエストによってprimary/replicaが切り替わっているか確認
replicaを用意することでPOST, PUT, DELETE, PATCHのリクエストはprimaryに書き込み、GET, HEADリクエストはreplicaから読み込むようになります
これを確認するために、[arproxy](https://github.com/cookpad/arproxy)を使用してクエリのログにデータベースの接続状況を表示するようにします


### arproxyの設定

- `Gemfile`に`gem arproxy`を追加して`bundle install`
- `config/initializers/arproxy.rb`に以下を記述

```ruby:config/initializers/arproxy.rb
if Rails.env.development? || Rails.env.test?
 require 'multiple_database_connection_logger'
 Arproxy.configure do |config|
   config.adapter = 'mysql2'
   config.use MultipleDatabaseConnectionLogger
 end
 Arproxy.enable!
end
```

- `lib/multiple_database_connection_logger.rb`に以下を記述

```ruby:lib/multiple_database_connection_logger.rb
class MultipleDatabaseConnectionLogger < Arproxy::Base
 def execute(sql, name = nil)
  role = ActiveRecord::Base.current_role
  name = "#{name} [#{role}]"
  super(sql, name)
 end
end
```

### リクエスト時のデータベース接続状況を確認
curlからリクエストを送信してログを見ると、呼び出されたのはwritingかreadingなのかを確認することができます
あらかじめ作成した[users_controller](https://github.com/youichiro/rails-multiple-db-sandbox/blob/master/app/controllers/users_controller.rb)で試します

index

```sh
$ curl localhost:3000/users
````

![スクリーンショット 2020-10-31 18.40.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/90eb4b0f-1779-82b7-f0c7-1b78d51b1c85.png)


show

```sh
$ curl localhost:3000/users/1
````

![スクリーンショット 2020-10-31 18.41.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/5dabe3ee-61c0-8cca-e1c5-183c447c9c20.png)

create

```sh
$ curl -X POST -H 'Content-Type: application/json' -d '{"name": "saito", "school_id": 1}' localhost:3000/users
````

![スクリーンショット 2020-10-31 18.47.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/7f74240b-3709-39e6-9d65-87d9d9e00dd4.png)


update

```sh
$ curl -X PUT -H 'Content-Type: application/json' -d '{"name": "saito(updated)"}' localhost:3000/users/5
````

![スクリーンショット 2020-10-31 18.48.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/0c5d472c-89be-ee9f-e723-d2767df14375.png)


destroy

```sh
$ curl -X DELETE http://localhost:3000/users/5
````

![スクリーンショット 2020-10-31 18.48.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145075/7caa9614-3373-8bb0-eeaa-ca8d58862ad0.png)

index, showアクションの場合はreading、create, update, destroyアクションの場合はwritingとなっており、primary / replicaが切り替わっていることがわかります


## JOINの挙動を確認
### 同じデータベースのテーブル間はJOINできる
studentsテーブルをgradeテーブルにJOINする場合は、同じデータベースなのでJOINできます

```ruby
Grade.joins(:students).where(name: 'grade1')
```

発行されるSQL

```sql
SELECT `grades`.*
FROM `grades`
INNER JOIN `students` ON `students`.`grade_id` = `grades`.`id`
WHERE `grades`.`name` = 'grade1
```

### 異なるデータベースのテーブル間はJOINできない
studentsテーブルをusersテーブルにJOINしようとした場合は、異なるデータベースなのでJOINできません

```ruby
User.joins(:students).where(name: 'ogawa')
```

発生するエラー

```
ActiveRecord::StatementInvalid (Mysql2::Error: Table 'rails_app_common_development.students' doesn't exist)
```


## さいごに
Rails6.1からサポートされる予定のシャーディング機能が楽しみ


## 参考
- [Railsガイド|Active Record で複数のデータベース利用](https://railsguides.jp/active_record_multiple_databases.html)
- [Rails6.0のmultiple databaseを使ってみた](https://note.com/tana_d/n/n8ba31f72732c)