Блог 7even

про ruby, rails, sinatra, git и всё на свете

Работа с несколькими соединениями с БД в Rails3

Вместо предисловия

В первой статье я расскажу о проблеме, которая встала передо мной при работе над rails-админкой для проекта, написанного на php/PostgreSQL.

Проект был в общем-то реализован до меня, и когда я пришел в команду, у него было 3 интерфейса: для пользователей, для менеджеров, и для админов. Причем, если первые два работали в любых браузерах и были выполнены практически в одном и том же дизайне, то админка работала исключительно в IE, и вроде бы использовала какой-то ActiveX (или что-то подобное, совместимое исключительно с виндой).

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

Среда

Проект разрабатывался на отдельном development-сервере, затем выкладывался на своеобразный staging-сервер, где его прорабатывал тестировщик, и смотрели (и снова тестировали) люди из компании-заказчика проекта; и уже после окончательного утверждения все переносилось на production.

У каждой среды был свой отдельный postgres-сервер, и периодически возникала необходимость смотреть и править данные на development и staging серваках. Структура БД, разумеется, была идентичной.

Взявшись за админку, я начал с описания стандартных MVC-компонентов, запуская все это добро локально в dev-режиме, нацеленным на development-базу. Когда я окончательно убедился в том, что админка не порушит ничего важного в БД, я решился запустить ее на staging - опять же, локально, при этом я вписал настройки соединения в рельсовую среду development и поочередно раскомментировал то один набор настроек, то другой. Выглядело это следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/database.yml
development:
  adapter: postgres
  encoding: unicode
  database: db_name
  pool: 5
  username: user_name
  port: 5432
  # development #
  ###############
  password: secret1
  host: development.domain.tld
  # staging #
  ###########
  # password: secret2
  # host: staging.domain.tld

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

ActiveRecord::Base.establish_connection(:dev)

Именно этот метод отвечает в AR за установку соединения с БД. Обычно рельсы вызывают его автоматически, передавая параметром название среды, которое представлено в config/database.yml.

Моя задача состояла в том, чтобы AR вызывал этот метод самостоятельно, и в качестве параметра передавал название нужного соединения, которое можно было бы переключать из контроллеров. В результате получился следующий initializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# config/initializers/active_record_tweaks.rb
class << ActiveRecord::Base
  # атрибут, который будет хранить текущее выбранное соединение
  attr_reader :selected_connection

  # соответствие Rails.env и названия соединения из database.yml
  AVAILABLE_CONNECTIONS = {
    :dev     => "#{Rails.env}_dev",
    :staging => "#{Rails.env}_staging"
  }

  # дефолтное соединение для каждого Rails.env
  DEFAULT_CONNECTIONS = {
    :development => :dev,
    :production  => :staging
  }

  # названия доступных соединений
  def available_connections
    AVAILABLE_CONNECTIONS.keys
  end

  # проверка указанного соединения на наличие в системе
  def valid_connection?(db_id)
    AVAILABLE_CONNECTIONS.has_key?(db_id)
  end

  # соединение по умолчанию для определенного Rails.env
  def default_connection_for(env_id)
    DEFAULT_CONNECTIONS[env_id]
  end

  def set_connection(db_id)
    @selected_connection = db_id
  end

  def restore_connection
    @selected_connection = nil
  end

  # основной метод - именно он устанавливает необходимое соединение с БД
  def choose_db
    establish_connection AVAILABLE_CONNECTIONS[ActiveRecord::Base.selected_connection]
  end
end

К сожалению, для поддержки работы с выбранным соединением не придумалось ничего лучше, чем вызов choose_db при определении модели, при этом для корректной работы ассоциаций они должны браться из одной и той же базы, поэтому choose_db должен вызываться в обеих моделях.

1
2
3
4
# app/models/user.rb
class User < ActiveRecord::Base
  choose_db
end

При этом если в системе подключена авторизация через какую-то модель (в моем случае к модели Admin был пристегнут Devise), то в этой модели работа должна идти всегда с одной и той же БД - т.е. в этой модели вызывать choose_db нельзя.

Соединения пришлось разбить на две группы - для рельсовой среды development и production, в каждой среде должны были работать все соединения:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# config/database.yml
# общие поля
common: &common
  adapter: postgresql
  encoding: unicode
  database: db_name
  pool: 5
  username: user_name
  port: 5432
# параметры для соединений
dev: &dev
  <<: *common
  password: secret1
  host: dev.domain.tld
staging: &staging
  <<: *common
  password: secret2
  host: staging.domain.tld
# алиасы для development-режима рельсов
development_dev: &development_dev
  <<: *dev
development_staging: &development_staging
  <<: *staging
# алиас во избежание проблем при запуске в development-режиме
development:
  <<: *development_dev
# алиасы для production-режима рельсов
production_dev: &production_dev
  <<: *dev
production_staging: &production_staging
  <<: *staging
# алиас во избежание проблем при запуске в production-режиме
production:
  <<: *production_staging

Далее нужно было просто вызывать ActiveRecord::Base.set_connection при каждом запросе, и ActiveRecord::Base.restore_connection после отработки запроса (последний нужен был для того, чтобы отрабатывать запрос с запрошенной базой, но не менять выбранное соединение в session[:db] - чтобы при дальнейших запросах работать уже с соединением из сессии). Это вылилось в before_filter и after_filter в ApplicationController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # экшен change был в системе только один
  # и он менял текущее соединение с БД в сессии
  before_filter :choose_database, :except => :change
  after_filter :restore_database, :except => :change
private
  # решаем, с какой БД отрабатывать текущий запрос
  def choose_database
    # ставим дефолтную базу
    session[:db] ||= ActiveRecord::Base.default_connection_for(Rails.env.to_sym)

    if params[:db] && ActiveRecord::Base.valid_connection?(params[:db].to_sym)
      # если передан параметр db, временно (на текущий запрос)
      # меняем соединение с БД
      ActiveRecord::Base.set_connection(params[:db].to_sym)
    else
      # задаем соединение из сессии текущим на данный запрос
      ActiveRecord::Base.set_connection(session[:db])
    end
  end

  # восстанавливаем соединение после окончания запроса
  # фактически это нужно только для случая, когда передан params[:db]
  def restore_database
    ActiveRecord::Base.restore_connection
  end
end

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# app/controllers/database_controller.rb
class DatabaseController < ApplicationController
  def change
    if params[:db].present? && ActiveRecord::Base.valid_connection?(new_db_id = params[:db].to_sym)
      session[:db] = new_db_id
      ActiveRecord::Base.set_connection(new_db_id)
      redirect_to :back, :notice => "Database changed to #{params[:db]}"
    else
      redirect_to :back
    end
  end
end

# app/helpers/application_helper.rb
module ApplicationHelper
  def db_links
    ActiveRecord::Base.available_connections.map do |db_id|
      # db_id - это :dev или :staging
      link_to_if (session[:db] != db_id), db_id, database_change_path(:db => db_id)
    end
  end
end

# app/views/common/_db_select.html.erb
Выберите БД:
<%=raw db_links.join(' | ') %>

Ну и дальше просто подвешиваем ссылки в лэйауте, где-нибудь в области шапки:

1
2
3
4
# app/views/layouts/application.html.erb
<span id="db_select">
  <%= render 'common/db_select' %>
</span>

Вместо заключения

Конечно, решение получилось немного костыльным в некоторых местах, но оно позволило переключаться между БД из интерфейса самого приложения, а также появилась возможность давать ссылку на страницу, которая будет отрендерена с использованием переданного соединения, не меняя при этом session[:db], например http://dev.domain.tld/users/123?db=staging.

Возможно, когда-нибудь у меня дойдут руки вычистить весь этот код, написать спеки и оформить в виде gem-а, к тому же есть пока еще нерешенные задачи из смежных областей - например, работа одновременно с несколькими схемами в postgres (имеются в виду конечно схемы самого постгреса, а не рельсовая schema.rb). Но на данный момент при необходимости это решение вполне можно использовать.

Комментарии