Всем привет. Я — начинающий (относительно) 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