Railsで複数DBに接続してみたい


はじめに

Railsを改めてコツコツ学んでおりますが、ある日異なるDBのデータを同一のRailsアプリで利用したい、という場面に出くわしました。

実は、私はSQL/Plusやコマンドラインやストアドプロシージャ経由でDBを利用することが多かったので、割とDBをまたいでデータを検索したり一つにまとめたりしてデータを抽出するのは普通に行っておりました。異種DBでもそれなりに相互接続する機能があったりして、DBの管理者権限があればそんなに困ることはありませんでした。

ですが、今回はRails。DBの管理者権限はないので、相互接続用の権限をつけたりオブジェクトを作成したり...ということはできません。

実際にやってみると、「なるほど〜」な設定でしたが、書いておかないと忘れてしまうので、メモとして記載してみます。

今回やること

今回取り上げるのは、こんなことです。最初から何もなしにできたわけではなく、色々なネットの情報を頼りに設定をしていきました。(先人の皆様に感謝です!)

  • 異なるDBに接続する場合の設定方法
  • マイグレーションやダンプといった、rake db...に相当するタスクの設定
  • 接続確認

やらないこと

  • テスト / specについて

テストに関しては、別なメモに分けて書いてみようと思います。

それでは、1つづつ見てまいります。
今回もサンプルとして利用するアプリケーションは、毎度おなじみ? Redmineといたします。

異なるDBに接続する

想定している条件

メインのDBに対して、supportという位置付けのDBを利用するのを想定します。
簡単に確認するために、Redmineのメイン / support ともにSQLite3をDBとして利用します。

専用のdatabase.yml を用意する

まずはじめに、メインのDBへの設定 (config/database.yml) とは別の設定ファイルを用意します。他にもやり方はあるのかもしれませんが、ひとまずこのような感じで用意しています。

$ tree config -P "database.yml"
config
├── database.yml
└── support
    └── database.yml

$ cat config/support/database.yml 
development:
  adapter: sqlite3
  database: db/support/development.sqlite3

production:
  adapter: sqlite3
  database: db/support/production.sqlite3

test:
  adapter: sqlite3
  database: db/support/test.sqlite3

rake taskを用意する

メインのdatabase.ymlに対しては、railsのデフォルトのrake taskでdb:createやdb:migrateを実行できます。
ですが、追加のDBに関しては、自分でrake taskを用意しないといけません。
rake taskは必ずしも必要ではないのですが、異なるDBを使っているモデルにもテストを書きたい場合には、テスト用DBを作成するための設定が必要になります。

実際に、CIでテストする場合には、手順の中に、通常のメインのDBに対するrake db:migrateに加えて、異なるDBに対するmigrationも追加しています。

以下、見本になります。

  • 設定は config/support/database.yml を利用
  • マイグレーションやschema.rb、seedは db/support/ 以下に置く想定
  • rake taskは lib/tasks/support/db.rake とする
$ cat lib/tasks/support/db.rake

#
# rake spec時には Supportテスト用スキーマを作成
#
task spec: ['support:db:test:prepare']

#
# NOTE: Support用DBのマイグレーション、テスト準備のためのRake Task
#
namespace :support do
  namespace :db do |ns|
    desc 'Support用: db:drop (config/support/database.yml)'
    task :drop do
      Rake::Task['db:drop'].invoke
    end

    desc 'Support用: db:create (config/support/database.yml)'
    task :create do
      Rake::Task['db:create'].invoke
    end

    desc 'Support用: db:setup (config/support/database.yml)'
    task :setup do
      Rake::Task['db:setup'].invoke
    end

    desc 'Support用: db:migrate (config/support/database.yml)'
    task :migrate do
      Rake::Task['db:migrate'].invoke
    end

    desc 'Support用: db:rollback (config/support/database.yml)'
    task :rollback do
      Rake::Task['db:rollback'].invoke
    end

    # トランザクション系ではなくマスタ系のデータを登録
    # acct_codesテーブルへcsvデータを流し込み
    #
    desc 'Support用: db:seed (config/support/database.yml, db/support/*.csv 読み込み)'
    task :seed do
      Rake::Task['db:seed'].invoke
    end

    desc 'Support用: db:version (config/support/database.yml)'
    task :version do
      Rake::Task['db:version'].invoke
    end

    desc 'Support用: db:reset (config/support/database.yml), create tables from schema.rb.'
    task :reset do
      Rake::Task['db:drop'].invoke
      Rake::Task['db:setup'].invoke
    end

    namespace :schema do
      desc 'Support用: db:schema:load (load from db/support/schema.rb)'
      task :load do
        Rake::Task['db:schema:load'].invoke
      end

      desc 'Support用: db:schema:dump (write into db/support/schema.rb)'
      task :dump do
        Rake::Task['db:schema:dump'].invoke
      end
    end

    namespace :test do
      desc 'Support用: db:test:prepare (テスト時にスキーマ設定が無ければ準備)'
      task :prepare do
        Rake::Task['db:test:prepare'].invoke
      end
    end

    # 上記の各タスクを実行する前に、Support専用の設定を読み込み
    # (Support用のDB設定を読み込む前に、設定を初期化してから上書き設定)
    #
    ns.tasks.each do |task|
      task.enhance ['support:set_custom_config'] do
        Rake::Task['support:revert_to_original_config'].invoke
      end
    end
  end

  task :set_custom_config do
    # save current vars
    @original_config = {
      env_schema: ENV['SCHEMA'],
      config: Rails.application.config.dup
    }

    # set config variables for custom database
    ENV['SCHEMA'] = 'db/support/schema.rb'
    Rails.application.config.paths['db'] = ['db/support']
    Rails.application.config.paths['db/migrate'] = ['db/support/migrate']
    Rails.application.config.paths['db/seeds.rb'] = ['db/support/seeds.rb']
    Rails.application.config.paths['config/database'] = ['config/support/database.yml']
  end

  task :revert_to_original_config do
    # reset config variables to original values
    ENV['SCHEMA'] = @original_config[:env_schema]
    Rails.application.config = @original_config[:config]
  end
end

rake taskの確認

では、追加したrake taskが利用できるかどうか確認してみます。
rake -T でrake taskの一覧が出るので、そのうち “support” に関連するものをピックアップしてみます。

$ bundle exec rake -T | grep support
rake db:schema:dump                                                                    # Create a db/schema.rb file that is portable against any DB supported by AR
rake support:db:create                                                                 # Support用: db:create (config/support/database.yml)
rake support:db:drop                                                                   # Support用: db:drop (config/support/database.yml)
rake support:db:migrate                                                                # Support用: db:migrate (config/support/database.yml)
rake support:db:reset                                                                  # Support用: db:reset (config/support/database.yml), create tables from schema.rb
rake support:db:rollback                                                               # Support用: db:rollback (config/support/database.yml)
rake support:db:schema:dump                                                            # Support用: db:schema:dump (write into db/support/schema.rb)
rake support:db:schema:load                                                            # Support用: db:schema:load (load from db/support/schema.rb)
rake support:db:seed                                                                   # Support用: db:seed (config/support/database.yml, db/support/*.csv 読み込み)
rake support:db:setup                                                                  # Support用: db:setup (config/support/database.yml)
rake support:db:test:prepare                                                           # Support用: db:test:prepare (テスト時にスキーマ設定が無ければ準備)
rake support:db:version                                                                # Support用: db:version (config/support/database.yml)

次に、DBのリセットを実行してみます。
resetでは最初にDBのdrop -> create, migrate, seed が実行されます。

$ mkdir -p db/support
$ ls !$
ls db/support

# DBがまだ作成されていないのを確認 / ENVをtestにしてみます
$ export RAILS_ENV=test

# DB resetのタスクを実行します
$ bundle exec rake support:db:reset  
db/support/schema.rb doesn't exist yet. Run `rake db:migrate` to create it, then try again. If you do not intend to use a database, you should instead alter redmine-3.3/config/application.rb to limit the frameworks that will be loaded.

$ bundle exec rake support:db:version 
Current version: 0

$ ls db/support/
test.sqlite3

マイグレーションのファイルやschema.rbがまだなにも無いので、DBができただけになりますが、うまくいったようです。

接続用のクラスを用意する

では実際に、この追加のDB設定を利用したモデルを作成してみます。

通常はDBに対して複数テーブルがあるので、複数テーブルを扱うのであれば、まずは接続だけを請け負うベースのクラスを作成するのが良いみたいです。
(「みたいです」という表現で申し訳ないのですが、いろいろ参考にしながら、ひとまずこの方法に落ち着いてます)

手順としては以下の通りです。

  • 追加DBの接続を使ったベースのクラスを作成する
    • ベースクラスは ActiveRecord::Baseを継承
    • 接続設定を、専用のファイルを参照するように調整
    • それ自身では特定のテーブルを参照はせず、抽象クラスとして振る舞う
    • ベースのクラスを継承するモデルを用意する
    • app/models/support.rb として作成
$ cat app/models/support.rb
module Support
  class Base < ActiveRecord::Base
    #       対応する抽象テーブルは設定しない(必ず継承させる)
    #
    databases = YAML.load(ERB.new(File.read('config/support/database.yml')).result)
    establish_connection(databases[Rails.env])
    self.abstract_class = true
  end
end

また、異なるDB同士でも、テーブル名が同じものが存在するケースはありますので、同じモデル名ではバッティングします。このため、追加のDB側は、”Support” という名前空間で利用するようにしました。

ベースクラスで確認する

まだテーブルは作成していないので、対応するモデルもありません。
ですが、ベースクラスはActiveRecordを継承しているので、ActiveRecord::Baseと同等のことができますので、接続設定確認やSQLの実行が可能です。

ここでは、rails consoleで確認してみます。

$ bundle exec rails c
Loading test environment (Rails 4.2.7.1)

# メインのDBのモデルの確認
irb(main):001:0> User.connection_config
=> {:adapter=>"sqlite3", :database=>"/Users/xxx/work/redmine-3.3/db/test.sqlite3", :pool=>10, :timeout=>5000}

# 追加のDBのベースクラスでの確認
irb(main):003:0> Support::Base.connection_config
=> {:adapter=>"sqlite3", :database=>"db/support/test.sqlite3”}

irb(main):004:0> Support::Base.class
=> Class

うまく設定を読み込んでいるようです。
では、続けて、SQLを実行させてみます。

irb(main):005:0> con = Support::Base.connection

# SQLite3でのテーブル一覧の取得 (空っぽなはず)
irb(main):009:0> con.execute("select name from sqlite_master where type = 'table';")
   (0.7ms)  select name from sqlite_master where type = 'table';
=> []

irb(main):015:0> con.execute("create table staffs(id integer primary key, name text);")
   (2.5ms)  create table staffs(id integer, name text);
=> []
irb(main):016:0> con.execute("select name from sqlite_master where type = 'table';")
   (0.3ms)  select name from sqlite_master where type = 'table';
=> [{"name"=>"staffs", 0=>"staffs"}]

実際のテーブルに対応するモデルを用意する

上記で create table staffs を実行したので、対応するモデルはStaffになります。
ただし、Supportの名前空間に所属させるので、このような感じになります。

  • app/models/support.rb としてベースクラスを用意
  • app/models/support/staff.rb として作成
$ cat app/models/support/staff.rb 
module Support
  class Staff < Support::Base
    # nothing
  end
end

では、再び rails console で確認してみます。

$ bundle exec rails console
irb(main):008:0> Support::Staff.count
   (0.3ms)  SELECT COUNT(*) FROM "staffs"
=> 0

irb(main):013:0> pp Support::Staff
Support::Staff(id: integer, name: text)
=> Support::Staff(id: integer, name: text)

うまくいったようです。

メインのDBのモデルに対してRelationを設定してみる

異なるDBのモデル間で、relationが設定できるか試してみます。
今回はRedmineを使っているので、Support::Staff から、Issueに対してhas_manyの設定を行ってみます。

module Support
  class Staff < Support::Base
    # 設定追加
    has_many :issues,  class_name: "Issue", foreign_key: "author_id"
  end
end

consoleで確認してみます。
まずは id: 1でStaffを作成してから、関連するissuesを取得できるかチェック。

irb(main):011:0> Support::Staff.create(id: 1, name: "Admin staff")
   (0.2ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "staffs" ("id", "name") VALUES (?, ?)  [["id", 1], ["name", "Admin staff"]]
   (3.4ms)  commit transaction

=> #<Support::Staff id: 1, name: "Admin staff">
irb(main):012:0> Support::Staff.first.issues
  Support::Staff Load (0.4ms)  SELECT  "staffs".* FROM "staffs"  ORDER BY "staffs"."id" ASC LIMIT 1
  Issue Load (0.2ms)  SELECT "issues".* FROM "issues" WHERE "issues"."author_id" = ?  [["author_id", 1]]

=> #<ActiveRecord::Associations::CollectionProxy [#<Issue id: 1, tracker_id: 1, project_id: 1, subject: "sample bug", description: "", due_date: nil, category_id: nil, status_id: 1, assigned_to_id: nil, priority_id: 2, fixed_version_id: nil, author_id: 1, lock_version: 0, created_on: "2017-03-17 15:23:51", updated_on: "2017-03-17 15:23:51", start_date: "2017-03-18", done_ratio: 0, estimated_hours: nil, parent_id: nil, root_id: 1, lft: 1, rgt: 2, is_private: false, closed_on: nil>]>

SQLの通り、いったんStaffのIDを取得して、それをもとにIssueを検索しています。
いちおうRelationも設定できるようですが、直接関連付けするよりは、いったん変数に値を格納してからお互いのDBに対応するモデルを取得するほうがいいのかな?とも思います。
(このあたりはなんとなく、な印象ですが...)

20170322追記:接続はどうなってるの?

「establish_connection という設定があるけれど、モデルのクエリが発行されるたびに接続が増えるの?」といったコメントをいただきましたので、動作面から確認した内容を追記します。

接続数は増えるの?

ActiveRecord::Base を継承し、基本機能はActiveRecord::Baseのものを使うので、接続(コネクションプールの扱い)もRailsの標準と同じ動作になります。

まず、Support::Base.connection_config や、Support::Staffとしただけでは、接続は生成されません。
そのうえで、実際に存在するテーブルに対応するモデルを呼び出して実際にクエリを発行したタイミングでコネクションが貼られます。発行直後、SQLite3の接続数(ファイルオープン数)をチェックすると、1になっています。

# rails console (test) で実行
# 初回establish_connectionが呼ばれるが、このタイミングでは接続していない
>> Support::Staff
Support::Staff (call 'Support::Staff.connection' to establish a connection)

>> Support::Staff.count
1
   (1.7ms)  SELECT COUNT(*) FROM “staffs"

# 自分のマシンでテスト / SQLiteのファイルのオープン数を接続数と読み替え
$ lsof test.sqlite3 
COMMAND  PID  USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
ruby    4815 akiko    9u   REG    1,4     8192 21185669 test.sqlite3

接続が1つ割り当たった状態で、続けてクエリを発行してみます。

# 引き続きrails consoleから何度もStaff.countを実行

>> Support::Staff.count
   (0.3ms)  SELECT COUNT(*) FROM "staffs"
1
>> Support::Staff.count
   (0.4ms)  SELECT COUNT(*) FROM "staffs"
1

# lsofの結果は変わりなし
$ lsof test.sqlite3 
COMMAND  PID  USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
ruby    4815 akiko    9u   REG    1,4     8192 21185669 test.sqlite3

では、rails consoleから再び確認してみます。
基本のDBと追加のDBとでは接続情報が異なるので、本体側のモデルを呼び出した場合はデフォルトのdatabase.ymlを参照した接続が1つ生成されます。

また、追加のDBのモデルを呼び出し、実際にSQLが発行された段階で、追加DB用のdatabase.ymlを参照した接続が1つ生成されます。

その後は、Support::Staff.countを何回か実行しても、クエリは都度発行されますが接続は追加では発生せず、空いていれば使い回されます。

# 本体(デフォルトDBのモデルを呼び出す)
>> Issue

# 追加DBのモデルを呼び出す
>> Support::Staff.count

# コネクションプールを確認(メイン、追加用に計2つできている)

>> >> ActiveRecord::Base.connection_handler.connection_pool_list.map { |pool| pool.spec.config[:database] }
["/work/redmine-3.3/db/test.sqlite3", "/work/redmine-3.3/db/support/test.sqlite3"]

今回はrails consoleでの動作確認なので、consoleのプロセスを終了すると接続も終了します。
また、接続はモデルに対する最初のクエリが発生するまでは生成されないので、たとえば接続対象のDBが停止していたり繋がらない状態であっても、呼び出しが発生しなければエラーにはなりません。

まとめ

以上、ざっくり設定の仕方を記載してみました。
同じDB間、同じ名前空間でのモデル同士は、もちろん普通にRelationが設定できます。

今回はRedmineを素材にしているので、プラグインベースで、プラグインのフォルダ配下に設定を保持しながら、別のDBを参照するモデルを利用することができそうな気がしました...。
(できたら試してみたいと思います)