Всем привет. Я — начинающий (относительно) 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 resource :sessions, only: [:new, :create, :destroy] # Все остальное.. 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-приложение под работу с несколькими БД очень даже просто. С удовольствием бы поделился источниками, которые помогли мне в решении, но их было очень много и уже сложно будет их найти (одним словом лень). Зато могу дать источник на картинку для поста.
