Применяем паттерн Command при помощи Dry-transaction

An illustration of a person sitting at a desk reading a book with a laptop open beside them, displaying a Rails error message; behind them is a shelf full of programming books. An illustration of a person sitting at a desk reading a book with a laptop open beside them, displaying a Rails error message; behind them is a shelf full of programming books.

В прошлой статье этой серии мы с вами узнали, как можно использовать 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 шага:

  1. парсинг конфига;
  2. фетчинг текущих цен бумаг;
  3. проверка цен;
  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.