Сергей Коба

Сергей Коба

Веб, блокчейн, мобильная разработка и интернет вещей

Веб тимлид в MobiDev. Цель: изучать и учить чему-то новому без остановки. Основные языки: PHP и Ruby. Также интересно: блокчейн, мобильная разработка, IoT и DevOps. Жизненное кредо: я жив, пока я учу что-то новое.

3 способа как мигрировать монолитное Rails приложение на сервисы с помощью Redis

4 августа 2017 13:39

Однажды каждый серьезный Rails разработчик задумывается о том, чтобы мигрировать свое монстроподобное приложение на сервисы. Очень смелая и амбициозная идея! Мы все знаем какие выгоды сулит нам миграция, но сложности, которые возникают в её процессе, заставляют нас подумать дважды или даже отказаться от миграции. Какие же основные страхи миграции на сервисы? Авторизация, производительность и сложность коммуникации между сервисами. К счастью у нас есть Redis, который поможет нам снова набраться храбрости и решить некоторые возникающие проблемы.

Иллюстрация от Анны Коба.

Общая сессия

Давайте представим, что вы создали несколько сервисов для социальной сети на Rails:

Очевидно, что пользователи не хотят отдельно авторизовываться на каждом сервисе. В этот момент Вы понимаете, что нужна общая авторизация, а как следствие общая сессия пользователя для всех сервисов. Как вообще устроена сессия в Rails? Обычно это зашифрованная cookie на стороне браузера, которая содержит всю информацию сессии. Проверьте config/initializers/session_store.rb наверняка там что-то вроде следующего кода:

Rails.application.config.session_store :cookie_store, key: '_your_app_session_key'

Итак, чтобы пользователи имели общую сесссию, сервисы должны иметь общую cookie сессии. Чтобы добиться этого, нужно во всех сервисах прописать одинаковый ключ сессии (key) и добавить опцию domain: all.

Rails.application.config.session_store :cookie_store, key: '_your_app_shared_session_key', domain: :all

Domain: :all сообщает Rails, что необходимо создавать общую cookie сессии для всех поддоменов Вашего доменного имени. Таким образом Вы можете располагать сервисы на разных поддоменах, но у них будет общая cookie сессии. 

Если Вы попробовали настроить общую сессию, используя указанные инструкции, то заметили, что ничего не работает... Не удивительно! Помните, я говорил "зашифрованная cookie" в начале статьи? Оказывается, Rails шифрует cookie с помощью secret_key_base в config/secrets.yml. Так что если этот ключ разный в каждом сервисе, то один Rails сервис не сможет расшифровать cookie, зашифрованную другим сервисом. Решением может быть использование одного и того же значения secret_key_base во всех сервисах...

Мда... Становится слишком сложно, чтобы заниматься этим :)

Достоинства зашифрованной cookie: 

  • Без использования стороннего программного обеспечения.
  • Легко настраивается.

Недостатки:

  • Каждый сервис должен иметь одинаковый secret_key_base. Если Вы когда-либо решите обновить это значение, то нужно будет это сделать одновременно на всех сервисах.
  • Знали ли Вы, что размер cookie ограничен?
  • Информация о пользователе, хоть и зашифрованная, хранится на стороне клиента.

Для меня этих недостатков достаточно, чтобы попробовать что-то новенькое. Подымайте Docker контейнер с Redis или используйте сторонние сервисы. Добавьте

gem 'redis-session-store'

в Gemfile и запустите bundle.

Следующим шагом необходимо модифицировать config/initializers/session_store.rb

Rails.application.config.session_store :redis_session_store, {
key: '_your_app_shared_session_key',
domain: :all,
httponly: false,
serializer: :hybrid, #transparently migrate existing Marshal cookie values to JSON
redis: {
key_prefix: 'myapp:session:',
url: 'redis://URL_TO_YOUR_REDIS',
}
}

Теперь у всех ваших сервисов будет общая cookie с уникальным идентификатором сессии в Redis, key_prefix указывает префикс, с которым Redis будет хранить переменные сессии, опция url говорит сама за себя :)

Достоинства хранения сессии в Redis:

  • Данные не хранятся на стороне клиента.
  • Вы можете получить доступ к сессии не только через запрос из браузера, но и напрямую из Redis, из любого сервиса.
  • Нет ограничения на размер сессии.
  • Не нужен secret_key_base.

Недостатки: теперь Ваше приложение зависит от Redis :D

Общий cache

Допустим, сервис News Feed должен показывать список из 20 последних обновлений Ваших друзей. Рядом с каждым обновлением он показывает имя друга и его аватар, но детальная информация о пользователе хранится в другом сервисе, а News Feed сервис имеет только уникальный идентификатор пользователя (uuid).

Что надо сделать? Правильно, послать запрос другому сервису. Ленивый способ без обработки ошибок может выглядеть так

require 'open-uri'
require 'json' uuid = '558fe613938306f4a6791b7e72ed0b1bc585a836927a41adf9a9b2e4d15ac3b9'
user_data = JSON.parse(open("https://my-users-service.com/users/#{uuid}").read)

Итак, если сервис отображает 20 записей об обновлениях, то выполнится 20 https запросов, чтобы отобразит информация о пользователях. Похоже на ловушку для производительности!!!

Можно придумать специальный вызов API, который вернет информацию о нескольких пользователях за 1 вызов... Пачками... Но снова, становится слишком сложно, чтобы заниматься этим :) Вы можете подумать, что я очень ленивый человек. Ну... это правда, но плохо ли это для разработчика?

В любом случае, давайте позволим Redis помочь нам и позаботится о происзводительности. Давайте закэшируем https запросы с помощью Rails.cache, который сконфигурирован на использование Redis. Добавьте следующие гемы и запустите bundle

gem 'readthis'
gem 'hiredis'

Сконфигурируйте Rails.cache так, чтобы он использовал Redis

# config/initializers/cache_store.rb

Rails.application.config.cache_store = :readthis_store, {
namespace: 'cache',
redis: {url: 'redis://URL_TO_YOUR_REDIS', driver: :hiredis}
}

Теперь мы можем обернуть вызовы API в Rails.cache.fetch

user_data = Rails.cache.fetch("user_data_#{uuid}") do
JSON.parse(open("https://my-users-service.com/users/#{uuid}").read) end

Теперь каждый раз, когда сервис запрашивает user_data, Rails проверяет есть ли закэшированная версия в Redis. Если таковая имеется, то сервис быстро получит её. Если нет - Rails выполнит https щапрос и закеширует ответ в Redis с ключом "user_data_#{uuid}".

Отлично! Наше приложение на сервисах вновь работает быстро. Подождите минуточку... Почему мой друг изменил свою аватарку, а новостная лента показывает старую версию? Это потому что сервис показывает закэшированную версию. Кэш должен инвалидироваться (удаляться) каждый раз, когда меняется информация о пользователе.

Но как сервис News Feed узнает, что данные пользователя поменялись? Никак, он не должен об этом волноваться. Потому что кэш Redis является общим, то другие сервисы могу позаботиться о том, чтобы удалить кэш, когда он становится неактуальным. Это легка, достаточно выполнить следующий код:

uuid = '558fe613938306f4a6791b7e72ed0b1bc585a836927a41adf9a9b2e4d15ac3b9'
Rails.cache.delete("user_data_#{uuid}")

Другими словами, необходимо удалить кэш с ключом "user_data_#{uuid}" кжадый раз когда модель User обновляется. Я поклонник DDD и Trailblazer, поэтому я не говорю, что вы должны делать это в after_save колбэке Вашей модели User ;)

Достоинства общего кэша Redis:

  • Улучшает производительность, кэшируя  http(s) запросы.
  • Кэш является общим для всех сервисов и может быть изменен из любого из них (даже не под управлением Rails).
  • Легкая интеграция с Rails.

Недостатки как обычно: Ваше приложение зависит от Redis :D

Общий PubSub

Помните, мы "создали" сервис Notifications для нашей виртуальной соц сети? Мы сделали это не случайно! Сервис будет получать события от других сервисов и отсылать пользователям нотификации.

Мы можем создать специальное API для этого, которое часто называют webhook. Этот webhook вызывается каждый раз когда сервис хочет сообщить о неком событии Notifications сервису. Что-то вроде этого:

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

Представьте, что у Вас десятки или сотни сервисов! И всем им надо знать, куда посылать webhook-и!

Что ж...  И опять слишком сложно, чтобы заниматься этим :)

Паттерн программирования, который мне нравится и который решает эту проблему, называется publisher/subscriber. А соответствующий архитектурный подход - message broker.

Когда сервис хочет сообщить о событии, он послыает его message broker-у (publish). Когда другие сервисы хотят получить определенный тип событий, то они подписываются на обновления от message broker-а (subscribe). И все сервисы должны знать только где расположен message broker. Он выступает в роли централизованного общего агента по обмену событиями и сообщениями.

Угадайте что? Redis может нам помочь и в этом, выступив в роли message broker-а!

Допустим, Вы хотите поделится событием из News Feed сервиса со всеми остальными. Мы уже установили все нужные гемы, так что вот код:

message = { event: 'new_photo', url: 'http://my-social-network.com/photos/1.jpg' } redis = Redis.new(url: 'redis://URL_TO_YOUR_REDIS') redis.publish 'news_feed_channel', message.to_json

И это все? Да, вызываем метод publish с 2 параметрами: 1 - имя канала, куда публикуется сообщение, 2 - само сообщение.

Напишем rake task сервиса, который подписывается на сообщения от Redis.

namespace :redis do
task :subscribe => :environment do redis = Redis.new(url: 'redis://URL_TO_YOUR_REDIS')
redis.subscribe('news_feed_channel') do |on|
on.message do |channel, message|
message_data = JSON.parse(message)
# Send notifications # ...
end
end end
end

redis.subscribe это бесконечный цикл, который проверяет обновления от Redis. Необходимо запустить этот rake task как отдельный процесс. Вот так это можно сделать в Heroku с помощью Procfile

pubsub: bundle exec rake redis:subscribe

В будущем Вы захотите добавить в rake task больше логирования и механизмы повтора в случае timeout или обрыва сети.

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

Надеюсь после этой статьи Вы заново рассмотрите возможность декомпозиции Вашего монстроподобного монолитного Rails приложения :) К тому же этот процесс стал гораздо легче с использованием Redis!

Назад