Как организовать запуск Ruby приложения с Dry-system

Illustration of a person reading a book at a desk with a laptop displaying a 'Rails' logo, surrounded by shelves of books and a desk lamp, in a monochrome setting with orange highlights. Illustration of a person reading a book at a desk with a laptop displaying a 'Rails' logo, surrounded by shelves of books and a desk lamp, in a monochrome setting with orange highlights.

В первой статье я на высоком уровне рассказал о том, как мы используем Dry-rb в своем продукте. Теперь можно подробнее рассказать о применении каждой из библиотек, и начну я с Dry-system.

Решив, как и мы, покинуть Rails, вы начинаете проект на Sinatra или roda. Скорее всего, даже первый запуск сервера будет непростой задачей. Раньше Rails заботливо (но не очень гибко) подгружал все гемы, прогонял initializer’ы, делал require всего кода и еще множество вещей, о которых вы даже не подозреваете. А теперь вы сами сидите и пишете файлы с десятками require просто для того, чтобы запустить ваше приложение. То же самое нужно делать во всех файлах, где используются какие-то зависимости. Конечно, это делает ваше приложение быстрее, но удовольствие от разработки резко снижается.

Эти проблемы призван решить Dry-system. Он позволяет разбить приложение на независимые, изолированные друг от друга компоненты, упрощает инициализацию приложения, предоставляет Inversion of Control (IoC)-контейнер для регистрации и инстанциации зависимостей. Ниже я покажу на примере, как это можно использовать в реальном приложении.

По ходу цикла мы будем итеративно создавать простое приложение: оно будет проверять курсы акций и уведомлять вас, если они превышают заданный порог. В первой версии наше приложение будет иметь вид простого rake task, который будет запускаться следующим образом:

bundle exec rake check_price[ticker,price]

Подготовка проекта

В корне проекта создадим три папки: system, lib, log и добавим Gemfile:

source 'http://rubygems.org'

gem 'dry-system', '~> 0.10.1'
gem 'dry-monitor', '~> 0.1.2'

gem 'http'
gem 'rake'

Настройка контейнера

Теперь можно перейти к самому Dry-system. Первое, что мы настроим — это контейнер. В нем мы будем регистрировать зависимости, к которым будем обращаться во время работы приложения. Это избавит нас от необходимости писать множество лишних require и сделает возможной инверсию управления.

Создайте файл system/container.rb со следующим содержимым:

require 'dry/system/container'
require 'dry/system/components'
class Notifier < Dry::System::Container
  use :logging
  use :env, inferrer: -> { ENV.fetch('RACK_ENV', :development).to_sym }
  configure do |config|
    # config.root = /root/app/dir
    config.auto_register = 'lib'
  end
  load_paths!('lib', 'system')
end

В данном файле мы описываем контейнеру, каким образом он должен собирать наше приложение.

Теперь по порядку о том, что здесь происходит:

Эти три строки — подключение плагинов для логирования, определения среды исполнения и мониторинга.

use :logging
use :env, inferrer: -> { ENV.fetch('RACK_ENV', :development).to_sym }
use :monitoring

Задаем корневую папку контейнера, относительно которой он будет разрешать пути зависимостей. В данном случае я специально закомментировал ее, так как нам не нужно менять значение, которое стоит по умолчанию. Но знать о том, как разрешаются пути, полезно.

# config.root = /root/app/dir

Здесь мы задаем папку относительно корневой, в которой будут автоматически регистрироваться зависимости. Это значит, что будет выполнен require всех файлов в этой папке, а классы из них будут зарегистрированы по ключам, соответствующим их путям. Так, класс lib/folder/class.rb будет зарегистрирован в контейнере как folder.class. Подробнее о том, как этот механизм работает, я расскажу ниже.

config.auto_register = 'lib'

Это выражение добавляет переданные в аргументах папки в $LOAD_PATH, чтобы было проще делать require файлов.

load_paths!('lib', 'system')

Теперь создадим файл system/import.rb. С помощью этого модуля мы будем получать доступ к зарегистрированным в контейнере объектам.

require 'container'
Import = Notifier.injector

Вот и все, мы описали наш контейнер и то, как он должен себя вести. Теперь можно запустить контейнер вызвав на нем метод .finalize!. После запуска произойдет авторегистрация классов приложения, инициализируются сторонние зависимости из папки boot. Подробнее об обоих этих процессах написано ниже, а пока запустим контейнер из irb:

irb(main):001:0> require_relative 'system/container'
=> true
irb(main):002:0> Notifier.finalize!
=> Notifier

Постоянно запускать контейнер таким образом - не самое красивое решение. Поэтому вынесем эту логику в отдельный файл system/boot.rb. Отдельно отмечу, что мы делаем require не только контейнера, но и файла system/import.rb:

require 'bundler/setup'
require_relative 'container'
require_relative 'import'

Notifier.finalize!

Инициализция сторонних зависимостей

Как правило, приложению перед запуском нужно провести инициализацию сторонних библиотек. Для этого создадим еще одну папку system/boot. Она является аналогом папки config/initializers в Rails: после вызова .finalize! контейнера выполняется require всех файлов из этой папки, соответственно выполняется код всех этих файлов. Вот так выглядит настройка логгера нашего приложения в файле system/boot/logger.rb:

Notifier.boot :logger do
  start do |container|
    container[:logger].level = ENV.fetch('LOG_LEVEL', 1)
  end
end

Логика приложения

Итак, наш контейнер теперь проводит инициализацию как классов приложения, так и сторонних библиотек. Теперь можно перейти к описанию логики самого приложения. В этой части реализация будет примитивной, но уже в следующей я расскажу, как мы это улучшим. Здесь ничего непосредственно к Dry-system не относится, поэтому оставлю ее без пояснений.

lib/notifier/fetch_price.rb

require 'http'

class FetchPrice
  def call(ticker)
    HTTP.get(url(ticker)).yield_self do |response|
      BigDecimal(response.to_s)
    end
  end
  private
  def url(ticker)
    "https://api.iextrading.com/1.0/stock/#{ticker.upcase}/price"
  end
end

lib/notifier/check_price.rb

class CheckPrice
  def call(price, boundary)
    `say "Price is #{price}, time to buy"` if price > boundary
  end
end

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

lib/notifier/main.rb

require 'notifier/check_price'
require 'notifier/fetch_price'
class Main
  def call(ticker, boundary)
    price = FetchPrice.new.call(ticker)
    CheckPrice.new.call(price, boundary)
  end
end

Но так бы мы написали этот код, если бы у нас не было контейнера, в котором классы FetchPrice и CheckPrice (зависимости класса Main) уже зарегистрированы. Вот как выглядит код, если переписать его с использованием контейнера:

require 'import'
class Main
  include Import['notifier.check_price', 'notifier.fetch_price']

  def call(ticker, price)
    check_price.call(fetch_price.call(ticker), price)
  end
end

Теперь о том, как это работает: вы делаете include модуля Import, а в качестве аргумента передаете зависимость, которая вам требуется. После этого зависимость автоматически становится доступна внутри класса.

Данный принцип называется Dependency Inversion. Благодаря нему вы внутри своего кода опираетесь на абстракции, зарегистрированные в контейнере, а не на конкретные имплементации, как это было в первом варианте. Он уменьшает связность, упрощает тестирование и в целом упрощает поддержку. Именно он скрывается под D во всем известном SOLID.

Последнее, что нам осталось — добавить rake-таск, который будет запускать наше приложение. Создадим Rakefile:

Rakefile

desc 'Check stock price'
task :check_price, %i[ticker price] do |_task_name, args|
  require_relative './system/boot'

  ticker = args[:ticker]
  price = BigDecimal(args[:price])

  Notifier['notifier.main'].call(ticker, price)
end

Так как мы сделали require файла system/boot, нам не придется явно запускать контейнер и делать require файла system/import.rb во всех классах, где используется модуль Import.

Проверим что у нас получилось:

bundle exec rake check_price[googl,1000]
bundle exec rake check_price[googl,1200]

Теперь самое время добавить в наше приложение мониторинг для уверенности в том, что цена регулярно проверяется. В Dry-system есть встроенный механизм мониторинга, основанный на Dry-monitor. Он позволяет настраивать мониторинг объектов в контейнере по ключу. Модифицируем наш rake task, чтобы он проверял цену в вечном цикле и добавим мониторинг проверок:

Rakefile

desc 'Check stock price'
task :check_price, %i[ticker price] do |_task_name, args|
  require_relative './system/boot'

  ticker = args[:ticker]
  price = BigDecimal(args[:price])

  Notifier.monitor('notifier.main') do |event|
    payload = event.payload
    Notifier.logger.info "Price checked with args: #{payload[:args]} in #{payload[:time]}ms"
  end

  loop do
    Notifier['notifier.main'].call(ticker, price)
    sleep 10
  end
end

Вот и все! Запустим еще раз rake task и проверим файл log/development.log:

Price checked with args: ["googl", 0.15e4] in 903.98ms
Price checked with args: ["googl", 0.15e4] in 793.36ms
...

Дополнительное чтение

Dry-system — это мощный инструмент со множеством функций, которые остались вне этой статьи, так что я настоятельно рекомендую прочитать документацию гема Dry-system.

На этом я буду заканчивать, а в следующей части расскажу о том, как мы организуем код Ruby приложений с помощью Dry-transaction.