10 August 2011

Ruby on Rails + legacy_migrations: односторонняя синхронизация данных между двумя проектами

Ruby on Rails
Эта статья ставит целью описать решение одной нетривиальной задачи — автоматическая односторонняя синхронизация данных в базах двух проектов средствами Ruby on Rails, гема legacy_migrations и относительно прямых рук.

Исходная ситуация


Имеется нагруженный проект, писавшийся на протяжении 3-х лет в несколько этапов без серьезного рефакторинга, отчего код разбух и используемые технологии ощутимо устарели. Было принято решение переписать проект с нуля на всем новом.


Старый проект:
  • Rails 2.3.4 (позже обновлен до 2.3.12 + контроль зависимостей через bundler)
  • MySQL 5
  • Sphinx, Delayed Job, AR sendmailer, interlock + memcached для кеширования и остальное по мелочи
Новый проект:
  • Rails 3.1.0.rc5 (на данный момент)
  • Postgresql 8.4 (возможно 9 в последствии)
  • О мелочах пока еще рано говорить, но предполагается Solr, Redis, resque

Основная трудность — это синхронизация контента баз данных с возможностью отойти от старой архитектуры БД в новом проекте. Было рассмотрено множество вариантов, но выбранный в конечном итоге позволил создать автоматическую систему синхронизации контента, которая сохраняет id записей, сохраняет временные метки записей, добавляет новые и обновляет уже имеющиеся записи. И при всем при этом не обязывает вас строго копировать схему существующей базы данных.

Gem legacy_migrations


В процессе поиска полуготового решения был найден (не без помощи группы ror2ru) удобный gem, написанный еще во времена Rails 2.3.x, который позволяет переносить содержимое произвольных атрибутов одной модели в другую, с возможностью выполнить произвольные операции над ними. Это было хорошее начало, но в процессе тестирования обнаружились существенные недостатки:
  • не сохранялись id записей (он укладывал объекты в базу подряд начиная с id=1)
  • не сохранялись временные штампы записей (updated_at, created_at)
  • при повторной прогонке rake задачи данные в таблице дублировались под новыми id
Хочу заострить внимание на том, зачем сохранять id элементов, есть следующий способ — создать атрибут наподобие old_id и по нему сделать перепривязку на новые id. Но задача стоит не просто в создании нового проекта, а в замещении старого проекта новым, а из этого вытекает как минимум идентичность всех url'ов. Чтобы понять насколько это важно, достаточно поймать на улице специалиста по SEO и рассказать о том, что вы хотите поменять урлы на работающем проекте. Должна последовать однозначная реакция, которая может проявляться в разных формах — от обморока до психоза :)
Для устранения выявленных недостатков я сделал этот форк. Чтобы лучше понять чего же такого я там понаписал можете проследовать сюда.
Надо отметить, что способ переноса данных через AR модели имеет один существенный недостаток — низкая производительность, однако в моем случае база оказалась относительно небольшой (около 400 Мб в целом), а сервер достаточно мощным, чтобы не отказываться от этого подхода.

Процесс переноса


Для начала надо отметить, что мне повезло и при переносе старого проекта (и параллельном апгрейде версии Rails с 2.3.4 до 2.3.12) базы данных обоих проектов оказались на одном сервере — для периодической синхронизации нет ничего лучше.

Установка необходимых гемов

Для начала необходимо убедиться, что в Gemfile вписаны адаптеры для обеих СУБД:
gem 'mysql2'
gem 'pg'

Для установки legacy_migrations есть два варианта — форк (в который уже внесены необходимые изменения) или оригинальный gem с возможностью собственноручного допиливания (привожу строки из Gemfile для обоих вариантов соответственно):
gem 'legacy_migrations', :git => 'git://github.com/Antiarchitect/legacy_migrations.git'
gem 'legacy_migrations', :path => 'vendor/gems/legacy_migrations-0.3.7'

после чего правки в код можно вносить самостоятельно, а чтобы gem оказался в path надо выполнить вот такую команду в корне вашего приложения:
gem unpack legacy_migrations --target vendor/gems


Принципы работы

Суть работы legacy_migrations следующая: в проекте должны существовать модели из которых мы берем данные и модели в которые мы эти записи зеркалим. Таким образом config/database.yml нового проекта будет выглядеть примерно следующим образом:
production:
  adapter: postgresql
  encoding: utf8
  database: newapp_production
  username: postgres
  password: somecomplicatedpassword

legacy:
  adapter: mysql2
  encoding: utf8
  database: oldapp_production
  username: root
  password: anothercomplicatedpassword

Где legacy — это конфигурация для базы старого проекта (название можно выбрать произвольно).
Далее следует посмотреть на модели старого проекта и выбрать свободный префикс во избежание дальнейшей путаницы. В моем случае это был префикс «Old». После чего создаем директорию app/models/old и помещаем туда абстрактный класс, от которого будут наследоваться все остальные. Пример app/models/old/old_base.rb:
class OldBase < ActiveRecord::Base
  self.abstract_class = true
  establish_connection 'legacy'
end

где аргумент 'legacy' должен соответствовать названию группы настроек для старой базы в config/database.yml. Таким образом все модели, наследуемые от класса OldBase (а не напрямую от ActiveRecord::Base) будут знать, к какой базе необходимо подключиться. Далее приведу пример одной такой модели:
class OldNewsDoc < OldBase
  set_table_name 'news_docs'
end

так как наши классы теперь имеют префикс, который изначально не предполагался, необходимо напрямую указывать название таблицы.
Для того, чтобы классы из app/models/old автоматически подгружались необходимо прописать этот путь в config/application.rb вот так:
module NewApp
  class Application < Rails::Application
    ...

    config.autoload_paths += %W(#{config.root}/app/models/old)
    
    ...
  end
end

А далее все довольно просто необходимо создать rake задачу, например вот такую (lib/tasks/legacy.rake):
require 'legacy_migrations'

namespace :legacy do
  namespace :transfer do
    desc 'Transfers News Docs from onru to onru2'
    task :news_docs => :environment do
      transfer_from OldNewsDoc, :to => NewsDoc do
        from :id, :to => :id
        from :updated_at, :to => :updated_at
        from :created_at, :to => :created_at

        from :news_rubric_id, :to => :news_rubric_id
        from :title, :to => :title
        from :annotation, :to => :annotation
        from :text, :to => :text
      end
    end
  end
end

Все — теперь запуск задачи возможен примерно так:
bundle exec rake legacy:transfer:news_docs RAILS_ENV=production

Подробнее про возможности legacy_migrations стоит читать в этом авторском посте.

Автоматизация процесса


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

Gem Whenever

Есть очень удобный gem — whenever — для целей автоматического запуска заданий для нужд приложения посредством cron, который с легкостью интегрируется в Capistrano и позволяет подстраивать запуск основных вещей (таких как rake задача, runner скрипт или консольная команда) под конкретную production среду и писать собственные типы исполняемых заданий.
Для этого необходимо установить whenever (строка из Gemfile):
gem 'whenever', :require => false
из корня приложения запустить команду
wheneverize .

и поместить во вновь созданный файл config/schedule.rb примерно следующее:
job_type :rake, "rvm use ree && cd :path && RAILS_ENV=:environment bundle exec rake :task :output"

if environment == 'production'
  every :day, :at => '2am' do
    rake "legacy:transfer:news_docs"
  end
end

я переписал определение rake задачи под мою среду: я использую rvm пользовательской установки и ree в качестве интерпретатора ruby (как только Rails 3.1 станут стабильны переключусь на 1.9.2 — пока наблюдаются некоторые проблемы), также я использую bundler, поэтому любой бинарник или скрипт следует запускать через bundle exec.
В Capistrano wheneverize интегрируется так же легко и непринужденно (deploy.rb):

require 'whenever/capistrano'
...
set :whenever_command, "bundle exec whenever" # это обязательно, если используется bundler - иначе он не найдет команду whenever

После деплоя можно полюбоваться красивыми и опрятными строками в crontab:
crontab -l

P.S. Надеюсь, что статья будет полезной для людей столкнувшихся с аналогичной проблемой переноса данных из одного проекта в другой.

Андрей Воронков, Evrone.com.
Tags:ruby on railsmysqlpostgresqlсинхронизация данныхперенос данныхlegacy_migrations
Hubs: Ruby on Rails
+27
3.5k 36
Comments 9
Popular right now
Программист Ruby on Rails
from 100,000 ₽TIQUMRemote job
Бэкенд-разработчик (Ruby on Rails)
from 120,000 ₽FunBoxМоскваRemote job
Ruby on Rails Developer
from 90,000 to 180,000 ₽FlatstackRemote job
Senior Backend-разработчик (Ruby on Rails)
from 200,000 to 325,000 ₽HoodiesRemote job
Программист (Ruby on Rails, React)
from 1,500 to 3,000 $HexletRemote job