Применяем паттерн Command при помощи Dry-transaction
В прошлой статье этой серии мы с вами узнали, как можно использовать dry-system для запуска нашего приложения и использования контейнера. Сегодня мы рассмотрим, как организовать бизнес-логику с помощью транзакций, поближе познакомимся с тем какие преимущества дает использование контейнера.
Что такое транзакция
Начну с того, что мы будем считать транзакцией. Любое действие вашего приложения: создание пользователя, проверку цены акции, генерацию отчета, возможно представить в виде последовательности шагов. Например, при создании нового пользователя вы:
- валидируете типы входных данных, сложность пароля, соответствие регулярным выражениям;
- проверяете уникальность данных в базе;
- сохраняете пользователя в базе данных;
- отправляете письмо с ссылкой на подтверждение регистрации.
Каждый такой шаг можно вынести в отдельный класс. Такой класс - реализация паттерна "команда". Будем называть его операцией. Каждая такая операция должна быть атомарной. Это упрощает юнит-тестирование приложения, так как каждая операция может быть протестирована максимально изолированно только с теми комбинациями входных данных, которые необходимы. Пайплайн из таких операций и есть транзакция. Подобное вы могли видеть в геме interactor, о котором у нас даже была статья.
Посмотрим на операции в контексте гема dry-transaction. Любая операция должна иметь публичный метод #call
и возвращать один из двух вариантов: Success(value)
или Failure(error)
, где value
и error
вы определяете сами. Классы Success и Failure относятся к гему dry-monads. Сегодня мы не будем его рассматривать.
Если операция возвращает Success(value)
, выполнение транзакции продолжается, а value передается на вход следующей операции. Если все операции завершились успешно, результатом выполнения транзакции будет Success(value)
, полученный в последней операции. Если какая-то операция в процессе выполнения транзакции возвращает Failure(error)
, вся транзакция прерывается, и результатом ее выполнения будет Failure(error)
, который был получен от неудавшейся операции. Данный подход известен как "Railway Oriented Programming".
Подготовка приложения
Теперь можно перейти к коду. В прошлой статье мы начали делать простое приложение для проверки курсов ценных бумаг. Вы можете найти его в репозитории на Github. Для начала вам надо будет добавить в Gemfile новый гем dry-transaction:
gem 'dry-transaction', '~> 0.13.0'
Теперь внесем изменения в папку lib/notifier
. Удалите все классы, что были там до этого и создайте две папки: transactions
и operations
. Пока оставим их пустыми.
Переходим к папке system
. Во-первых, создадим файл application.rb
:
# system/application.rb
require 'yaml'
require 'dry/transaction'
require 'dry/transaction/operation'
В этом файле мы будем делать require зависимостей, необходимых для нашего приложения. Так что теперь нам надо убрать эту логику из system/boot.rb
:
# system/boot.rb
require 'bundler/setup'
require_relative 'application'
Notifier.finalize!
Наконец, сделаем еще одно небольшое, но важное изменение в system/container.rb
:
# system/container.rb
require 'dry/system/container'
require 'dry/system/components'
class Notifier < Dry::System::Container
use :logging
use :monitoring
use :env, inferrer: -> { ENV.fetch('RACK_ENV', :development).to_sym }
configure do |config|
config.auto_register = %w(lib)
config.default_namespace = 'notifier'
end
load_paths!('lib', 'system')
end
По сравнению с прошлым разом мы добавили строку config.default_namespace = 'notifier'
. Это позволит не писать каждый раз notifier
в ключе объекта в контейнере. Так например notifier.operations.operation
превратится в operations.operation
.
Теперь наконец можно перейти к описанию транзакции. Процесс проверки цены можно разбить на 4 шага:
- парсинг конфига;
- фетчинг текущих цен бумаг;
- проверка цен;
- уведомление о том, какие бумаги нужно купить, а какие продать.
Эти шаги и будут операциями нашей транзакции. Инициализируем классы, но пока не будем добавлять в них никакую логику. Они всегда будут выполняться успешно и передавать следующему шагу то, что получили на вход:
# lib/notifier/operations/fetch_prices.rb
module Operations
class FetchPrices
include Dry::Transaction::Operation
include Import[:logger]
def call(input)
Success(input)
end
end
end
# lib/notifier/operations/filter_prices.rb
module Operations
class FilterPrices
include Dry::Transaction::Operation
include Import[:logger]
def call(input)
Success(input)
end
end
end
# lib/notifier/operations/filter_prices.rb
module Operations
class ParseYaml
include Dry::Transaction::Operation
include Import[:logger]
def call(input)
Success(input)
end
end
end
# lib/notifier/operations/filter_prices.rb
module Operations
class Notify
include Dry::Transaction::Operation
include Import[:logger]
def call(input)
Success(input)
end
end
end
Мы создали 4 класса, которые возвращают Success(input)
. Это значит, что если их объединить в транзакцию, они все выполнятся в заданном порядке, и на выходе мы получим все тот же Success(input)
. Вот как выглядит описание транзакции:
# lib/notifier/transactoins/check_yaml_prices.yml
module Transactions
class CheckYamlPrices
include Dry::Transaction(container: Notifier)
step :parse_yaml, with: 'operations.parse_yaml'
step :fetch_prices, with: 'operations.fetch_prices'
step :filter_prices, with: 'operations.filter_prices'
tee :notify, with: 'operations.notify'
end
end
Здесь мы явно указываем транзакции, под какими ключами ей нужно искать в контейнере Notifier
описание классов для каждого шага. Также возможно описывать шаги методами. Например так:
module Transactions
class ExampleTransaction
include Dry::Transaction
step :step_example
def step_example(input)
Success(input)
end
end
end
Более того, вы можете использовать и метод, и ключ контейнера. Приоритет выполнения выше будет у метода и вы сможете, например, подготовить входные данные для операции из контейнера, после чего вызвать ее через super
:
module Transactions
class ExampleTransaction
include Dry::Transaction
step :step_example
def step_example(input)
adjusted_input = input.merge(useless_arg: 1)
super(adjusted_input)
end
end
end
Внимательные читатели могли заметить, что шаг notify
передается в функцию tee
, а не step
, как остальные. Так мы показываем, что данный шаг может ничего не возвращать. Это удобно, в случаях когда шаг только лишь логирует что-то. Если бы после шага notify
был еще один, он получил бы на вход то же, что и notify
. Существуют и другие адаптеры.
Теперь мы можем перейти к реализации классов. Вот как они будут выглядеть:
# lib/notifier/operations/parse_yaml.rb
module Operations
class ParseYaml
include Dry::Transaction::Operation
YAML_PATH = File.join('./', 'config')
def call(input)
filename = input.fetch :filename
path = File.join(YAML_PATH, "#{filename}.yml")
data = YAML.load_file(path).transform_values do |ticker_data|
ticker_data.transform_values { |price| BigDecimal(price.to_s) }
end
Success(tickers_data: data)
end
private
def raw_yaml(filename)
File.read(File.join(YAML_PATH, "#{filename}.yml"))
end
end
end
# lib/notifier/operations/notify.rb
module Operations
class Notify
include Dry::Transaction::Operation
def call(input)
buy = input.fetch :buy
sell = input.fetch :sell
prices = input.fetch :prices
buy.each do |ticker|
ticker_price = prices[ticker]
`say "#{ticker} price is #{ticker_price}, time to buy"`
end
sell.each do |ticker|
ticker_price = prices[ticker]
`say "#{ticker} price is #{ticker_price}, time to sell"`
end
end
end
end
Готово, теперь наша транзакция полностью собрана и готова к вызовам!
Так как мы изменили поведение нашего приложения, нашу rake task для запуска тоже нужно обновить:
# lib/tasks/check_prices.rake
desc 'Check yaml stock prices'
task :check_prices, %i[filename] do |_task_name, args|
require_relative './../../system/boot'
filename = args[:filename]
transaction_key = 'transactions.check_yaml_prices'
Notifier.monitor(transaction_key) do |event|
payload = event.payload
Notifier.logger.info "Prices checked with args: #{payload[:args]} in #{payload[:time]}ms"
end
Notifier[transaction_key].(filename: filename)
end
Теперь в качестве аргумента вы можете передавать имя файла, в котором будут описаны цены нужных вам бумаг. Формат его должен быть примерно таким:
googl:
buy: 800
sell: 1000
aapl:
buy: 150
sell: 210
fb:
buy: 100
sell: 200
Проверим, что все работает, выполнив из консоли bundle exec rake check_prices[prices]
. Как и прежде, можно посмотреть, что логи пишутся в файл log/development.log
:
# log/development.log
...
I, [2019-02-18T23:19:32.472590 #78281] INFO -- : AAPL price: 0.17042e3; boundaries: {"buy"=>0.15e3, "sell"=>0.21e3}
I, [2019-02-18T23:19:32.472692 #78281] INFO -- : FB price: 0.1625e3; boundaries: {"buy"=>0.1e3, "sell"=>0.2e3}
I, [2019-02-18T23:19:47.569804 #78281] INFO -- : Prices checked with args: [{:filename=>"prices"}] in 18253.77ms
Так как для конфигурации скрипта мы используем файл, перед тем как прочитать его, стоит проверять, что он существует, и сообщать пользователю, если это не так. Для этого изменим метод #call
нашего парсера:
# lib/notifier/operations/parse_yaml.rb
def call(input)
filename = input.fetch :filename
path = File.join(YAML_PATH, "#{filename}.yml")
return Failure('config file missing') unless File.exist?(path)
data = YAML.load_file(path).transform_values do |ticker_data|
ticker_data.transform_values { |price| BigDecimal(price.to_s) }
end
Success(tickers_data: data)
end
Мы добавили строку return Failure('config file missing') unless File.exist?(path)
, которая останавливает выполнение транзакции и заставляет ее вернуть Failure('config file missing')
. Так что в случае отсутствия конфига мы не получим ошибку. Но пользователь все еще не будет знать о проблеме и надо ему о ней сообщить об этом.
Для таких случаев очень удобно использовать matcher. Он основан на геме dry-matcher и позволяет задать различное поведение для Success
и Failure
полученных на разных шагах. Вот как это будет выглядеть в нашем случае:
# lib/tasks/check_prices.rake
Notifier[transaction_key].(filename: filename) do |result|
result.success do
puts "Prices checked at #{Time.now}"
end
result.failure :parse_yaml do |error|
message = "Failed to parse prices config due to error: #{error}"
logger.info message
puts message
end
end
Здесь мы передаем транзакции блок, который выполнится на результате ее выполнения (переменной result). Внутри этого блока мы определяем поведение для различных вариантов возвращенного значения. Если все прошло удачно, и в переменной result
лежит Success
, выполнится первая ветка. Если там Failure
, выполнится ветка того шага, на котором этот Failure
вернулся. У нас это был шаг parse_yaml
. Важно помнить, что из транзакции Failure
может вернуться на любом шаге, а вот Success
только после последнего, то есть только когда все операции транзакции выполнились успешно.
Я считаю, что matcher - одно из главных преимуществ dry-transaction. Он позволяет контролировать не только что возвращают отдельные операции, но и реакцию транзакции на эти значения.
На этом я буду заканчивать свой рассказ о dry-transaction. Больше об этом геме можно узнать из документации. В следующей части цикла мы посмотрим на монады: что это такое, и как их использовать в вашем приложении с помощью dry-monads.