Концепция железнодорожного ориентированного программирования
Введение
В программах нам много раз приходится иметь дело с потоками данных. В таких процессах нам часто приходится проверять входные аргументы, полученные от других частей общего потока. Однако сложные структуры могут потребовать сложных способов проверки, что, в свою очередь, может привести к тому, что код будет трудноуправляемым.
В этой статье мы рассмотрим монады и идею железнодорожного ориентированного программирования. Эти два элемента помогают создавать согласованные и масштабируемые процессы, состоящие из нескольких шагов.
Добро пожаловать в ад Если/иначе
Чтобы проиллюстрировать проблему, давайте предположим, что существует некая служба. Эта служба занимается доставкой посылок. Его реализация может выглядеть так:
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. Спасибо за прочтение!
Слова Петра Езусека, старшего инженера
Под редакцией Кинги Куснирз, автора контента
Материалы: