13 Мар

Настройка подключений к нескольким базам данных в Rails.

Решил сделать перевод на русский язык статьи Setting up multiple databases in Rails: the definitive guide

(By Roberto Ostinelli / December 2, 2015).

Переводчик я непрофессиональный, поэтому в некоторых местах буду позволять себе вольности, но общий смысл будет сохранен.

Статья написана для Rails 4, но работает и в Rails 5 тоже, по крайней мере, на момент перевода статьи. 

———————

Может быть множество разных причин, зачем вам может понадобиться подключение к нескольким базам данных в вашем Ruby in Rails приложении. В моём случае необходимо было хранить большое количество данных, отражающих поведение пользователя: клики, посещаемые страницы, изменения истории и т. д.

Такие типы баз данных обычно не критичны для основной цели, а разрастаются намного быстрее (и до гораздо бОльших размеров), чем другие БД.

Требования к ним достаточно разнообразны: к примеру, они требуют больше пространства, более терпимы к аппаратным и программным сбоям, а также у них в приоритете идёт запись в базу, а не чтение. По этим причинам иногда выгодно отделить такие БД от основной базы вашего приложения. Зачастую для таких задач используются нереляционные СУБД, что уже выходит за пределы темы, по которой писалась данная статья.

Я нашел и прочитал множество различных решений, но я так и не смог найти такого, которое бы полностью выполняло следующие условия:

  • Должны быть различные и изолированные друг от друга миграции и схемы для каждой базы данных
  • Должны использоваться генераторы Rails для создания новых миграций для каждой БД, независимо друг от друга
  • Должны присутствовать специфические для баз данных задания rake для основных операций над БД (например такие же, как и для основной БД)
  • Должна быть интеграция с заданиями spec RSpec’а.
  • Должно работать с Database Cleaner
  • Должно работать на Heroku

Это мой взгляд на то, как удовлетворить все вышеуказанные требования и получить полностью рабочее Rails-приложение с подключением к нескольким БД.

Создаём необходимые файлы

Для нужд этой инструкции мы собираемся установить подключение ко второй БД. Назовём её Stats. Чтобы это сделать, мы продублируем файл подключения к основной базе данных и не будем забывать о конвенциях.

Для начала создадим файл config/database_stats.yml и заполним его так же, как и основной config-файл. У вас он будет выглядеть примерно вот так:

development:
  adapter: postgresql
  encoding: utf8
  host: localhost
  pool: 10
  database: myapp_stats_development
  username: postgres
  password:

test:
  adapter: postgresql
  encoding: utf8
  host: localhost
  pool: 10
  database: myapp_stats_test
  username: postgres
  password:

production:
  adapter: postgresql
  encoding: utf8
  url:  <%= ENV["DATABASE_STATS_URL"] %>
  pool: <%= ENV["DB_POOL"] || 5 %>

Теперь мы создадим директорию, в которую будет содержаться схема и все миграции базы данных Stats, так что у неё будут свои собственные файлы, изолированные от основной БД. Мы, просто-напросто, продублируем директорию основной БД Rails db.Отмечу, что базам данных я дал специфические имена, пытаясь настолько сильно, насколько возможно следовать принятым конвенциям наименований в Rails. Также URL для базы данных в окружении production я установил в переменную DATABASE_STATS_URL. Это позволит нам легко установить эту переменную на вторичную базу данных при развертывании на Heroku.

Создадим директорию db_stats в корне Rails-приложения и удостоверимся, что скопировали структуру и файлы внутри директории db основной базы данных. У вас должно получиться что-то типа такого:

-- db    
   |-- migrate    
   schema.rb    
   seeds.rb 
-- db_stats    
   |-- migrate    
   schema.rb    
   seeds.rb

Созданные файлы schema.rb и seeds.rb, также как и директория migrate, должны быть пустыми.

Создаём задания Rake

Для работы с базой данных Stats, а также, чтобы иметь возможность её содавать, делать миграции, сохранять схему БД и выполнять другие функции, нам понадобятся собственные Rake задачи. Эти задачи предоставят нам тот же самый функционал, который Rails предоставляет нам для основной БД.

Создайте новый файл lib/tasks/db_stats.rake, и вставьте в него следующее:

task spec: ["stats:db:test:prepare"]


namespace :stats do

  namespace :db do |ns|

    task :drop do
      Rake::Task["db:drop"].invoke
    end

    task :create do
      Rake::Task["db:create"].invoke
    end

    task :setup do
      Rake::Task["db:setup"].invoke
    end

    task :migrate do
      Rake::Task["db:migrate"].invoke
    end

    task :rollback do
      Rake::Task["db:rollback"].invoke
    end

    task :seed do
      Rake::Task["db:seed"].invoke
    end

    task :version do
      Rake::Task["db:version"].invoke
    end

    namespace :schema do

      task :load do
        Rake::Task["db:schema:load"].invoke
      end

      task :dump do
        Rake::Task["db:schema:dump"].invoke
      end

    end

    namespace :test do

      task :prepare do
        Rake::Task["db:test:prepare"].invoke
      end

    end
 

    # append and prepend proper tasks to all the tasks defined here above

    ns.tasks.each do |task|

      task.enhance ["stats:set_custom_config"] do
        Rake::Task["stats: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_stats/schema.rb"
    Rails.application.config.paths['db'] = ["db_stats"]
    Rails.application.config.paths['db/migrate'] = ["db_stats/migrate"]
    Rails.application.config.paths['db/seeds'] = ["db_stats/seeds.rb"]
    Rails.application.config.paths['config/database'] = ["config/database_stats.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

Здесь потребуется немного пояснений: давайте разделим этот файл на несколько главных секций. Сначала мы просто предоставляем «прокси» для стандартных Rails-задач для БД. В только что созданном stats:db:

 task :drop do
  Rake::Task["db:drop"].invoke
 end

 task :create do
  Rake::Task["db:create"].invoke
 end

 task :setup do
  Rake::Task["db:setup"].invoke
 end

 task :migrate do
  Rake::Task["db:migrate"].invoke
 end

[...]

Далее мы зациклим все эти задачи и убедимся, что задача stats:set_custom_config запускается до, а задача stats:revert_to_original_config запускается после каждой из этих «прокси»-задач

# append and prepend proper tasks to all tasks defined in stats:db namespace
ns.tasks.each do |task|
 task.enhance ["stats:set_custom_config"] do
 Rake::Task["stats:revert_to_original_config"].invoke
 end
end

Нам приходится делать это потому, что Rails, к сожалению, не очень хорошо поддерживает соединение с несколькими БД, поэтому приходится использовать такие вот «хаки», чтобы всё заработало. По этой причине нам нужно установить значение среды и конфигурационных переменных на собственные значения, которые будут совпадать с нашей базой данных Stats, до того, как мы запустим «прокси-задания», а потом убедимся, что оригинальные значения вернулись назад как только задание было запущено. Следующие два задания делают именно это:

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_stats/schema.rb"
 Rails.application.config.paths['db'] = ["db_stats"]
 Rails.application.config.paths['db/migrate'] = ["db_stats/migrate"]
 Rails.application.config.paths['db/seeds'] = ["db_stats/seeds.rb"]
 Rails.application.config.paths['config/database'] = ["config/database_stats.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

Заметьте, что в строках 9-13 мы устанавливаем в качестве значений файлы и директории, которые мы создавали в предыдущих шагах.

И наконец, если вы используете RSpec, вы можете добавить одну зависимость в задание spec, чтобы убедиться, что база данных Stats автоматически подготавливается (prepare), когда запускается тест.

task spec: ["stats:db:test:prepare"]

После того, как всё это настроено, мы можем создать базу данных Stats и запустить её первую миграцию:

$ rake stats:db:create
$ rake stats:db:migrate

Эти команды сгенерируют файл-схему db_stats/schema.rb базы данных Stats

Добавляем собственный генератор

К сожалению, мы не можем просто взять и использовать генератор Rails ActiveRecord::Generators::MigrationGenerator, потому что в нем захардкожена родительская директория миграции (обратите внимание на путь db/migrate, который захардкожен на четвертой строке ниже):

def create_migration_file
  set_local_assigns!
  validate_file_name!
  migration_template @migration_template, "db/migrate/#{file_name}.rb"
end

Поэтому нам понадобится собственный генератор, чтобы создавать миграции для БД Stats. Как бы то ни было, мы всё равно можем наследоваться от него (генератора Rails) и переопределить эту конкретную функцию. создадим следующий генератор в lib/generators/stats_migration_generator.rb:

require 'rails/generators/active_record/migration/migration_generator'

class StatsMigrationGenerator < ActiveRecord::Generators::MigrationGenerator
  source_root File.join(File.dirname(ActiveRecord::Generators::MigrationGenerator.instance_method(:create_migration_file).source_location.first), "templates")

  def create_migration_file
    set_local_assigns!
    validate_file_name!
    migration_template @migration_template, "db_stats/migrate/#{file_name}.rb"
  end
end

В строке 9 мы устанавливаем путь к директории с файлами миграций для нашей БД Stats. Также в строке 4 мы инициализируем каталог с шаблонами и указываем на оригинальный, используемый генератором, с которого мы наследуемся.

После выполнения всех этих манипуляций мы можем генерировать миграции для нашей базы данных Stats следующим образом:

   $ rails g stats_migration create_clicks

Вы увидите, что файл миграции создался в директории с миграциями нащей БД Stats, db_stats/migrate. Вы можете отредактировать этот файл и затем запустить миграцию при помощи Rake-задания, которое мы создали на предыдущих шагах, так же, как и обычно для вашей основной БД:

   $ rake stats:db:migrate

Собираем вместе соединения и модели

Итак, мы почти закончили. Добавляем новый файл инициализации config/initializers/db_stats.rb и вставляем туда следующее:

    
# save stats database settings in global var
DB_STATS = YAML::load(ERB.new(File.read(Rails.root.join("config","database_stats.yml"))).result)[Rails.env]

Заметьте, что мы ссылаемся на конфигурационный файл БД Stats, который мы создали в первом шаге. Сделав это, мы проинициализируем глобальную переменную DB_STATS, которая содержит конфигурацию текущего окружения базы данных Stats.
Наконец, мы можем создать подключить наши модели к этой конфигурации. К примеру, пусть у нас будет модель Click, которая соответствует миграции, которую мы делали выше в предыдущем шаге. Всё, что вам необходимо сделать — это добавить одну дополнительную строку, которая укажет, какое соединение использовать:

    
class Click < ActiveRecord::Base
  establish_connection DB_STATS

end

Вот и всё. Ваша модель теперь использует базу данных Stats.
Если у вас несколько моделей, которые нужно присоединить к вашей БД, вам понадобится выполнить ещё один дополнительный шаг. Если бы у вас была ещё одна модель, устанавливающая свое собственное соединение с вашей БД, у ней был бы свой собственный пул соединений и мог бы возникнуть риск уткнуться в предел доступных соединений с базой данных Stats. Поэтому, если у вас есть несколько моделей, рекомендуется наследоваться с одной модели, и тогда все дочерние модели будут использовать один пул соединений.
Чтобы так сделать, создайте базовую модель, которая подключается к БД Stats:

    
class StatsBase < ActiveRecord::Base
  establish_connection DB_STATS
  self.abstract_class = true
end

Теперь вы можете унаследовать от неё все остальные ваши модели:

    
class Click < StatsBase
end

class View < StatsBase
end

Heroku

Как вы могли догадаться, последним шагом, который вам понадобится, чтобы сделать эту работу на Heroku — это установить переменную окружения DATABASE_STATS_URL на базу данных, которую вы собираетесь использовать в качестве Stats. Например, если вы создали вторую БД с названием HEROKU_POSTGRESQL_TEAL_URL, то всё, что вам нужно — установить значение переменной, используя Heroku toolbelt:

    
$ heroku config:set DATABASE_STATS_URL=postgres://gsdfjrthjsnaew:gry6OJF6drDjththjkSDngldsf@ec2-116-22-114-221.compute-1.amazonaws.com:5432/hmsrthj24dfgks

И всё.

Bonus: DatabaseCleaner

Если вы используете гем DatabaseCleaner, вы также можете настроить его для очистки моделей, которые используют БД Stats. К примеру, ваш spec/rails_helper.rb будет выглядеть примерно вот так:

    
ENV["RAILS_ENV"] ||= 'test'
require 'spec_helper'
require File.expand_path("../../config/environment", __FILE__)
require 'rspec/rails'

Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }

ActiveRecord::Migration.maintain_test_schema!

RSpec.configure do |config|
  config.use_transactional_fixtures = false
  config.infer_spec_type_from_file_location!

  config.before(:suite) do
    DatabaseCleaner.clean_with(:truncation)
    DatabaseCleaner[:active_record, { model: Click }].clean_with(:truncation)
  end

  config.before(:each) do |example|
    unit_test = ![:feature, :request].include?(example.metadata[:type])
    strategy = unit_test ? :transaction : :truncation

    DatabaseCleaner.strategy = strategy
    DatabaseCleaner[:active_record, { model: Click }].strategy = strategy

    DatabaseCleaner.start
    DatabaseCleaner[:active_record, { model: Click }].start
  end

  config.after(:each) do
    DatabaseCleaner.clean
    DatabaseCleaner[:active_record, { model: Click }].clean
  end
end

Согласно README DatabaseCleaner’а, есть возможность установить опцию connection, вместо model. К сожалению, мои попытки сделать это были безуспешны.