Концепция железнодорожного ориентированного программирования

Введение

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

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

Добро пожаловать в ад Если/иначе

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

class SendPackage
  def call(package_id, send_from_id, send_to_id)
    package = Package.find_by(id: package_id)

    return false unless package

    awb = package.awb

    return false unless awb

    send_to_location = Location.find_by(id: send_to_id)

    return false unless send_to_location

    send_from_location = Location.find_by(id: send_from_id)

    return false unless send_from_location

    send_to_address = send_to_location.address
    send_from_address = send_from_location.address

    if !(send_to_address.nil? || send_from_address.nil?)
      # returns True or raises an error PackageCantBeSent
      package.send!(
        send_to_address:,
        send_from_address:,
        awb:
      )
    else
      false
    end
  rescue PackageCantBeSent => e
    return false
  end
end

result = SendPackage.new.call(1, 2, 3)
if result
  puts 'Package sent'
else
  puts 'Packages not sent'
end

Как видно из приведенного выше примера, существует много кода, ориентированного на защиту от использования значений nil. Без этого есть вероятность, что какая-то часть сервиса выдаст ошибку. Этот подход довольно неразборчив. Кроме того, расширение этой службы дополнительными элементами увеличит количество выражений if/else.

Язык Ruby и среда Ruby on Rails представили некоторые функции или выражения самого языка, чтобы упростить работу с системными элементами, которые могут возвращать нулевое значение. У нас есть функции «.try(…)» и «присутствие» из Active Support Core Extension или оператор «&», представленный в Ruby 2.3. Использование любого из них может значительно упростить приведенный выше код. Мы, однако, будем использовать другой подход. Мы будем использовать монады из библиотеки dry-monads.

Монады в Ruby

Что такое монады? Это понятие пришло из теории категорий. Его можно определить как «моноид в категории эндофункторов». Чтобы понять само определение, потребуется знание некоторых основных понятий теории категорий. Однако возможность использования монад в программировании не требует знания лежащей в их основе теории. Понятие монады можно рассматривать с точки зрения функционального программирования. Они есть, например, в Haskell или Scala.

Игнорируя строгое определение монад, можно сказать, что это некая схема строения функций, предполагающая непротиворечивый способ возврата значений. Результат функции несет информацию об успехе или неудаче операции, а также необязательную полезную нагрузку. Эта согласованность позволяет объединять функции в цепочки вызовов без необходимости постоянно проверять результат отдельных компонентов. Если один компонент процесса возвращает ошибку, последующие шаги не будут пытаться выполниться с пустым/ошибочным результатом предыдущей функции.

В библиотеке dry-monads есть несколько структур, которые мы можем назвать монадами. Они служат разным целям, но в некоторых случаях могут использоваться взаимозаменяемо. Мы обсудим некоторые из них, переходя от самого основного случая к структурам, которые позволяют выполнять расширенную обработку ошибок.

Монада Возможно

Одним из примеров монад, доступных в библиотеке dry-monads, которую мы обсудим в первую очередь, является монада Maybe. Возвращаемые значения: Some и None. Некоторые могут быть интерпретированы как положительный результат данной операции. С другой стороны, None не появится, если вы попытаетесь обработать значение nil. Попробуем рефакторить пример из начала статьи, используя монаду Maybe.

class SendPackage
  # [1]
  include Dry::Monads[:maybe]

  def call(package_id, send_from_id, send_to_id)
    # [2]
    find_package(package_id).bind do |package|
      # [3]
      Maybe(package.awb).bind do |awb|
        # [4]
        find_address(send_to_id).bind do |send_to_address|
          # [5]
          find_address(send_from_id).fmap do |send_from_address|
            # [6]
            package.send!(
              send_to_address:,
              send_from_address:,
              awb:
            )
          end
        end
      end
    end
  rescue PackageCantBeSent => e
    return None()
  end

  private

  def find_address(location_id)
    find_location(location_id).maybe(&:address)
  end

  def find_package(package_id)
    Maybe(Package.find_by(id: package_id))
  end

  def find_location(location_id)
    Maybe(Location.find_by(id: location_id))
  end
end

SendPackage.new.call(1, 2, 3).value_or('Package not sent')

Как видно из приведенного выше, использование Maybe позволяет полностью удалить выражения if/unless/else. Конструктор Maybe возвращает значение Some или None в зависимости от того, передано ли ему значение или nil соответственно. Кроме того, значения, возвращаемые функцией Maybe, можно объединить в цепочку с помощью функции may (см. функцию find_address). Это очень похоже на использование оператора &.

Давайте теперь обсудим конкретные шаги:

[1] Присоединение этого модуля к классу позволяет использовать Maybe(…), Some(…) и None().

[2] Если find_package возвращает Some(package), вызывается блок, переданный команде связывания. В противном случае возвращается None.

[3] Если package.awb равен нулю, Maybe возвращает None. В противном случае возвращается Some(package.awb) и вызывается блок, переданный функции привязки.

[4] Поведение функции и блока такое же, как в [2].

[5] Использование функции fmap вместо bind позволяет блоку окружать возвращаемое значение конструктором Maybe. Это означает, что значение, возвращаемое fmap, будет иметь значение Some(…) или None и будет готово к дальнейшему объединению в цепочку вызовов.

[6] Нет необходимости проверять, переданы ли значения для отправки! пусты. Использование bind и Maybe гарантирует, что при вызове этого блока переданные параметры действительны.

Код, использующий монаду Maybe, короче. Он также обеспечивает согласованный способ возврата информации из нашего сервиса. Это позволило бы нам создавать цепочки звонков без необходимости постоянно проверять, успешно ли работает наш сервис.

class HandlePackage
  def call(...)
    SendPackage.new.call(...).bind do |package|
      SendNotification.new.call(package:).fmap do |notification|
        SaveToLog.new.call(notification:)
      end
    end
  end
end
HandlePackage.new.call(...).value_or('Problem with package')

Однако использование монад Maybe не всегда будет более читаемым способом написания кода, чем if/else. Вложенные операции подвержены ошибкам. Кроме того, мы не получаем информацию о причине ошибки. Эту проблему можно решить, используя другую монаду под названием Result и подход, называемый do-notation.

Монада результата

Монада Result, в отличие от Maybe, возвращает значения Success и Failure. Каждое из этих значений позволяет возвращать полезные данные, которые затем могут быть прочитаны вызывающим сервисом.

class SendPackage
  include Dry::Monads[:result]

  def call(package_id, send_from_id, send_to_id)
    find_package(package_id).bind do |package|
      find_awb(package).bind do |awb|
        find_address(send_to_id).bind do |send_to_address|
          find_address(send_from_id).fmap do |send_from_address|
            package.send!(
              send_to_address:,
              send_from_address:,
              awb:
            )
          end
        end
      end
    end
  rescue PackageCantBeSent => e
    return Failure(e.message)
  end

  private

  def find_awb(package)
    if package.awb
      Success(package.awb)
    else
      Failure("AWB for #{package} not found")
    end
  end

  def find_address(location_id)
    find_location(location_id).bind do |location|
      if location.address
        Success(location.address)
      else
        Failure("Address for #{location_id} not found")
      end
    end
  end

  def find_package(package_id)
    package = Package.find_by(id: package_id)

    if package
      Success(package)
    else
      Failure("Package with #{package_id} not found")
    end
  end

  def find_location(location_id)
    location = Location.find_by(id: location_id)

    if location
      Success(location)
    else
      Failure("Location with #{location} not found")
    end
  end
end

result = SendPackage.new.call(1, 2, 3)
# [1]
if result.success?
  puts "Package has been sent"
  # [2]
  puts "send! returned #{result.success}"
else
  # [3]
  puts result.failure
end

Метод вызова выглядит в основном так же. Мы сосредоточимся на поддерживающих методах. Вместо значения «Возможно» возвращается значение «Успех» или «Отказ». Они тоже несут полезную нагрузку. Это желаемое значение или сообщение об ошибке. Как и в монаде Maybe, Success и Failure также здесь правильно интерпретируют успех и неудачу, что довольно очевидно, глядя на имена этих конструкторов. Для выполнения блока в функциях bind и fmap функции должны возвращать значение Success.

Обработка вызова службы существенно отличается:

[1] Если вызывающая функция возвращает значение Success(значение), успех? возвращает истину. Противоположностью этой функции является отказ?

[2] Чтобы получить доступ к полезной нагрузке, переносимой конструктором Success, используйте метод success.

[3] Если произошла ошибка, успех? вернет ложь. Затем выполняется код для обработки этой ошибки. В этом случае отображается содержимое полезной нагрузки, переносимой сообщением об ошибке.

Do-обозначение

Ранее мы упоминали do-нотацию, которая была представлена ​​в версии 1.0 библиотеки dry-monads. Его использование избавляет от вложенности, что значительно упрощает работу с монадами. Подход основан на ключевом слове «доходность», которое ведет себя аналогично методу привязки. Демонстрацию использования do-нотации можно найти ниже.

class SendPackage
  include Dry::Monads[:do, :result]

  def call(package_id, send_from_id, send_to_id)
    package = yield find_package(package_id)
    awb = yield find_awb(package)
    send_to_address = yield find_address(send_to_id)
    send_from_address = yield find_address(send_from_id)
    Success(
      package.send!(
        send_to_address:,
        send_from_address:,
        awb:
      )
    )

  rescue PackageCantBeSent => e
    return Failure(e.message)
  end

  private

  [...]
end

result = SendPackage.new.call(1, 2, 3)
[...]

Первое изменение можно найти в определении включения модуля. Он был обновлен с помощью «: do». Теперь давайте посмотрим на выражение yield. Если метод, переданный yield, возвращает Success(value), все выражение возвращает нагрузку, которую несет этот конструктор (то есть значение). В противном случае вызов прерывается и возвращается сообщение об ошибке. Легко заметить, что код выглядит так, как будто он был написан последовательно. Вам просто нужно привыкнуть к тому, как работает yield и к тому факту, что любая строка может прервать вызов метода.

Монада Try

Монады также позволяют нам обрабатывать исключения, вызванные внешними функциями. Для этого мы можем использовать монаду Try.

class SendPackage
  include Dry::Monads[:do, :result, :try]

  def call(package_id, send_from_id, send_to_id)
    package = yield find_package(package_id)
    awb = yield find_awb(package)
    send_to_address = yield find_address(send_to_id)
    send_from_address = yield find_address(send_from_id)
    Try[PackageCantBeSent] do
      package.send!(
        send_to_address:,
        send_from_address:,
        awb:
      )
    end.to_result
  end

  private

  [...]
end

result = SendPackage.new.call(1, 2, 3)
[...]

По умолчанию Try обрабатывает все исключения. Однако это считается антипаттерном. Поэтому мы всегда должны определять список исключений, которые необходимо обрабатывать. Try возвращает значение, если исключение не возникло, или ошибку, если такое исключение произошло. Однако мы можем легко преобразовать результат из домена Try в функцию to_result.

Железнодорожное программирование

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

Успех и неудача как рельсы

Железнодорожное программирование предполагает существование путей, по которым могут следовать программы. В простейшем случае было бы два пути — Успех и Неудача. Возникновение Успеха или Неудачи в какой-то момент процесса направляет его по соответствующему пути. Обработка окончательного результата происходит при последнем высокоуровневом вызове. Как и в железной дороге — упомянутые пути являются рельсами, а функции, возвращающие Success или Failure, — переключателями.

Приведенная выше аналогия хорошо иллюстрируется следующим рисунком:

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

Результат обработки

Мы не ограничиваемся обработкой только результатов типа Success/Fail. Как мы упоминали ранее, «Успех» и «Неудача» могут нести полезную нагрузку. Это может быть сообщение, коды ошибок или созданные/обновленные объекты. Для примера давайте еще раз взглянем на вызов HandlePackage:

result = HandlePackage.new.call(...)
if result.success? && result.success == true
  puts 'Everything is fine'
elsif result.success? && result.success.is_a?(String)
  puts "Everything is fine but with message #{result.success}"
elsif result.success?
  puts "Everything is fine but with object #{result.success}"
elsif result.failure? && result.failure.is_a?(Symbol)
  puts "Error code: #{result.failure}"
elsif result.failure? && result.failure.is_a?(ActiveRecord::RecordInvalid)
  puts "Errors from object: #{result.failure.record.errors}"
else
  puts "Unknown error: #{result.failure}"
end

Как мы видим из вышеизложенного, обработка результата всего процесса выполняется в одном месте. Здесь стоит упомянуть, что, начиная с версии 1.3, dry-monads поддерживает инструмент под названием сопоставление с образцом, представленный в Ruby 2.7. Это позволяет упростить код, обрабатывающий результат процесса.

result = HandlePackage.new.call(...)
case result
in Success(Boolean)
  puts 'Everything is fine'
in Success(String => m)
  puts "Everything is fine but with message #{m}"
in Success(_)
  puts "Everything is fine but with object #{_}"
in Failure(Symbol => s)
  puts "Error code: #{s}"
in Failure(ActiveRecord::RecordInvalid => e)
  puts "Errors from object: #{e.record.errors}"
in Failure(_)
  puts "Unknown error: #{_}"
end

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

Краткое содержание

Монады и железнодорожное программирование — интересные части мира программирования. Это отличные решения для кода, который постоянно имеет дело с обработкой ошибок или пустыми значениями. Однако, как и любой подход, их следует использовать с осторожностью и только тогда, когда они действительно необходимы. Чтобы правильно их использовать, стоит узнать о них больше. Отличным источником, безусловно, является документация по dry-monads. Чтобы узнать больше о железнодорожном программировании, лучше всего начать со статьи Скотта Влашина — F# for Fun and Profit. Спасибо за прочтение!

Слова Петра Езусека, старшего инженера

Под редакцией Кинги Куснирз, автора контента

Материалы: