Работа с несколькими БД в Ruby on Rails 3

в 4:38, , рубрики: rails, rails 3, ruby, ruby on rails, ruby on rails 3, метки: , , ,

Работа с несколькими БД в Ruby on Rails 3 Всем привет. Я — начинающий (относительно) Ruby on Rails разработчик. В данный момент разрабатываю приложение, которое использует несколько баз данных. Информации по данном вопросу в интернете не так много, как хотелось бы, поэтому решил собрать все воедино и поделиться с читателим.
Повторюсь, я считаю себя новичком в рельсах, поэтому это не статья о том, как делать правильно. Это просто сборник заметок о том, что и как делаю именно я.

У меня довольно специфичная задача, но кода не так много и не составит труда переделать его под свои нужды.

Задача

Нужно сделать некую ЦРМ для компаний, торгующих некоторыми товарами. Компании работают сразу с несколькими брендами и под каждый бренд нужна своя ЦРМ с отдельной БД. В моей реализации компания определяется по поддомену, а бренд из урла, например URL company1.myapp.dev/brand1/ говорит нам о том, что мы работаем с компанией company1 и брендом brand1.

Все начинается с моделей

В данном случае логично было выделить 2 модели: Компания и Бренд.

Company

  • db_user: string — Имя пользователя для подключения к СУБД
  • db_password: string — Пароль
  • db_host: string — Адрес сервера СУБД
  • db_port: integer — Порт сервера СУБД
  • subdomain: string — Имя поддомена для компании
  • alias: string — Альтернативный адрес (например, компания захочет привязать ЦРМ к своему сайту crm.company1.dev)
  • active: boolean — Чтобы быстро отключать доступ к ЦРМ
  • name: string — Имя компании

Brand

  • name: string — Имя бренда
  • db_name: string — Имя базы данных
  • company_id: integer — Ссылка на компанию

Примечание: чаще всего БД нужно переключать только по домену, поэтому модель Brand можно убрать и перенести поле db_name в модель Company. Если планируется использовать только СУБД на локальном сервере, то можно и вовсе убрать поля db_user, db_host и т.д. Я же планирую когда-нибудь перейти на облачные сервисы и это может пригодиться.

Таблицы для этих моделей должны быть в каждой БД, с которыми будет работать приложение, но данные храниться будут только в одной (production или development, в зависимости от вашего RAILS_ENV). Чтобы приложение искало данные только в определенной базе, нужно использовать метод establish_connection.

/models/company.rb

class Company < ActiveRecord::Base
  establish_connection "production"
  has_many :brands, dependent: :destroy

  validates :subdomain, :db_user, :db_host, :db_port, :name, presence: true
end
/models/brand.rb

class Brand < ActiveRecord::Base
  establish_connection "production"
  belongs_to :company

  validates :name, :db_name, presence: true
end

Пишем код

routes.rb

Поскольку нам всегда нужно знать с каким брендом мы сейчас работаем, то нужно обернуть все в scope.

MyApp::Application.routes.draw do
  scope ':brand' do
    resources :sessions, only: [:new, :create] do
      delete :destroy, :on => :collection
    end
    # Все остальное..
    match '/' => redirect("/%{brand}/orders"), as: 'brand_root'
  end
  root :to => "main#index"
end

application.rb

class ApplicationController < ActionController::Base
  protect_from_forgery
  
  before_filter :override_db
  before_filter :authenticate_user!

  def not_found
    raise ActionController::RoutingError.new('Not Found')
  end

  # Поскольку мы используем scope, то чтобы не передавать
  # название бренда в каждый урл, переопределяем этот метод
  # и бренд будет подставляться автоматически
  # Ex: вместо orders_path(brand: @current_brand.name) можно писать просто orders_path
  def url_options
    if @current_brand.present?
      { :brand => @current_brand.name }.merge(super)
    else
      super
    end
  end

private
  # Собственно сам метод переключения 
  def override_db
    @current_company = Company.where("(subdomain = ? or alias = ?) AND active = ?", request.env['HTTP_HOST'][/^[w-]+/], request.env['HTTP_HOST'], true).first
    not_found unless @current_company.present? && @current_company.brands.present?

    if params[:brand].present?
      @current_brand = @current_company.brands.find_by_name params[:brand]
      if @current_brand.present?
        ActiveRecord::Base.clear_cache!
        ActiveRecord::Base.establish_connection(
          :adapter  => "postgresql",
          :host     => @current_company.db_host,
          :username => @current_company.db_user,
          :password => @current_company.db_password,
          :database => @current_company.db_name
        )
        redefine_uploaders_store_dir
      else
        redirect_to root_url
      end
    end
  end

  # Маленький хак для CarrierWave
  def redefine_uploaders_store_dir
    CarrierWave::Uploader::Base.descendants.each do |d|
      d.class_eval <<-RUBY, __FILE__, __LINE__+1
        def store_dir
          "uploads/#{@current_company.subdomain}/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
        end
      RUBY
    end
  end
end

Перед подключением к новой базе нужно обязательно очистить кэш ActiveRecord (ActiveRecord::Base.clear_cache! или ActiveRecord::Base.connection_pool.clear_reloadable_connections!).
Метод redefine_uploaders_store_dir переопределяет директории, в которых CarrierWave будет хранить файлы. Можно бы и не делать этот хак, вероятность конфликта очень мала (должны совпасть имена файлов и id моделей), но она есть, поэтому решил подстраховаться.

Еще одна мелочь, без которой ничего не заработает

В config/environments/production.rb нужно отключить кэширование классов.

config.cache_classes = false

Да, производительность снижается, но пока не знаю как решить эту проблему иначе.

Сессии

В моем случае сессия может хранить много данных, поэтому мне нужно хранить их а базе данных, а не в кукисах. К тому же на главной странице у меня формы авторизации для разных брендов и хотелось бы показывать пользователю в каких он уже авторизован и может зайти нажатием одной кнопки, а для каких еще нужно ввести пароль. Поэтому хранить сессии нужно в одной базе данных, а не размазывать по нескольким.
Сначала говорим рельсам использовать ActiveRecord для хранения сессий:

rails g session_migration
rake db:migrate

config/initializers/session_store.rb

MyApp::Application.config.session_store :active_record_store

А затем говорим им использовать определенную бд для их хранения:

config/environment.rb

# Load the rails application
require File.expand_path('../application', __FILE__)

# Initialize the rails application
MyApp::Application.initialize!

ActiveRecord::SessionStore::Session.establish_connection "production"

Примечание: на всякий случай оговорюсь, что «production» здесь и в моделях не имя базы данных, а имя раздела в config/database.yml.

Миграции

Для миграций можно использовать такое решение:

lib/tasks/multimigrate.rake

namespace :db do
  desc "Migrations for all databases"
  task :multimigrate => :environment do
    Company.all.each do |company|
      company.brands.each do |brand|
        puts "Run migration for #{company.name} (#{brand.name})"
        sh "cd #{Rails.root.to_s} && bundle exec rake db:migrate RAILS_ENV=#{brand.db_name}"
      end
    end
  end
end

Чтобы все заработало, в database.yml должны быть перечислены все базы данных. Для себя я придумал правило, что раздел (окружение) в database.yml будет называться как и база данных.
Сейчас когда в админке добавляю новую компанию, контроллер добавляет новый раздел в database.yml, но можно и поручить это модели (не знаю насколько это кошерно будет, но удобно). Примерно так:

class Brand < ActiveRecord::Base
  establish_connection "production"
  belongs_to :company
  after_save :sync_to_yml

  validates :name, :db_name, presence: true

  private

    def sync_to_yml
      db_config = YAML.load_file(Rails.root.to_s + '/config/database.yml')
      db_config[self.db_name] = { 
          'adapter' => 'postgresql',
          'encoding' => 'unicode',
          'database' => self.db_name,
          'pool' => 5,
          'username' => self.company.db_user,
          'password' => self.company.db_password.present? ? self.company.db_password : nil
        }
      if self.company.db_host != 'localhost'
        db_config[self.db_name].merge(
          { 
            'host' => self.company.db_host,
            'port' => self.company.db_port
          }
        )
      end
      File.open( Rails.root.to_s + '/config/database.yml', 'w' ) do |out|
        YAML.dump( db_config, out )
      end
    end
end

Внимание! Код не протестирован. Просто предложил как это можно сделать. К тому же, нужно еще добавить колбэк на after_destroy.

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

Автор: SilentBrain


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js