Дополнение исключений — введение монад для обработки ошибок в Ruby
Вы когда-нибудь задумывались над своим подходом к обработке исключений? Я имею в виду метод, в котором вы используете ключевое слово повышения внутри класса, а затем вы используете ключевое слово спасения в функции, которая вызывает этот метод.
Однако в современных языках программирования, таких как OCaml, Rust, Elm, Haskell и Go, существует альтернативный подход, противоречащий исключениям. По сути, ошибки обрабатываются как значения, и мы управляем ими как обычными переменными, используя такие конструкции, как операторы соответствия или простые операторы if
.
В этой статье мы углубимся в реализацию этой техники с использованием dry-monads
.
В чем проблема с исключениями?
Исключения может быть довольно легко вызвать, когда вы просто смотрите на метод, который вы разрабатываете, но когда вы пытаетесь использовать библиотеку или метод внутри своей большой кодовой базы, заметны некоторые неудобства, которые мы рассмотрим прямо сейчас:
- Этот метод вызывает исключение? Всякий раз, когда метод вызывается в вашей кодовой базе или через стороннюю библиотеку, нет непосредственной уверенности в том, приведет ли эта функция к возникновению исключения. В самом неудачном сценарии, если вы не обратились к документации или не изучили код для выявления любых необработанных исключений, возникает внезапная проблема. В вашем приложении возникает неуправляемое исключение, и, вероятно, производство сейчас не работает.
- Какой метод вызвал эту ошибку? Довольно часто используется метод, который использует множество других методов, если все эти методы вызывают разные ошибки, может быть очень сложно определить, какой метод вызвал ошибку, пока мы разрабатываем метод, сообщения об ошибках могут быть полезными, но не на 100% точно.
Каковы плюсы и минусы использования ошибок в качестве значений?
До сих пор я представил вам болевые точки исключений и представил возможное решение (dry-monad gem
), но поскольку в области программирования нет серебряной пули, важно понимать плюсы и минусы каждого возможного решения, и это именно то, что мы увидим в этом разделе ниже:
Плюсы
- Среда выполнения помогает вам (по крайней мере, немного): Представьте себе следующий сценарий: вы вызываете функцию и ожидаете вернуть
User
, но этот метод возвращаетError
, внезапно вы не можете использоватьuser.name
, потому что Ruby сообщит вам, что переменная является типом ошибки, а неUser
, что упрощает отладку и предотвращает будущие ошибки в рабочей среде.
Дисклеймер: это не так хорошо, как ошибка времени компиляции, но идея здесь состоит в том, чтобы улучшить ваш текущий опыт разработки, приблизив исключения к вам.
- Займитесь обработкой ошибок перед бизнес-логикой: Хотя это может быть скорее личным предпочтением, чем чисто техническим преимуществом, работа с ошибками как значениями позволяет вам полностью изучить возможности раннего возврата, обрабатывая все неудачные пути в начале вашего метода.
- Четкое представление о том, какой метод вернул какую ошибку: Красота работы с ошибками в качестве возвращаемых значений становится очевидной благодаря прозрачности, которую они привносят в кодовую базу. Поскольку ошибки возвращаются напрямую из методов, становится ясно, какая ошибка соответствует какому методу.
Минусы
- Проблемы при управлении глубоко вложенными ошибками: Как мы представили в предыдущих темах преимущества связывания ошибок с их исходными методами, возникает трудность в сценариях, где у вас есть глубоко вложенная цепочка методов, в этом случае трудно сохранить контекст, где эти исключения прошли.
Начинаем работу
Сначала давайте определим некоторые дисклеймеры о dry-monads:
- Суть сухих монад заключается не в том, чтобы избежать исключений, а в том, чтобы использовать исключения в контролируемой среде, где вы знаете, что вызываете исключение.
- Сухие монады не идеальны, Ruby — это динамический язык, и у нас не может быть идеальных проверок времени компиляции, но мы можем улучшить наш опыт разработки, насколько это возможно.
Теперь, когда мы определились с нашими пунктами, давайте создадим пример проекта, чтобы показать, как использовать этот новый драгоценный камень:
1. Создание проекта
Вы можете создать пример проекта с помощью:
mkdir project && cd project && bundle init
А затем добавьте единственную зависимость этого проекта, dry-monads:
bundle add dry-monads
2. Как вернуть ошибку
Сначала мы рассмотрим монаду Result
. Если вы знакомы с Rust, понимание этой концепции может быть относительно простым. По сути, монада Result
инкапсулирует два возможных исхода: либо Success(value)
, либо Failure(error)
.
Чтобы работать с этой монадой результата, сначала нам нужно потребовать библиотеку, а затем для удобства включить Dry::Monads[:result]
, чтобы мы могли использовать Success
и Failure
без префиксов модуля, как показано ниже:
require 'dry/monads/all'
class Auth
include Dry::Monads[:result]
# @param name [String]
# @return [Failure(Symbol), Success({ name: String })]
def authenticate(name:)
return Failure(:unauthorized) unless name == 'correct'
Success({ name: 'cherry' })
end
end
Если мы попытаемся вызвать этот метод, ожидая увидеть от него параметр name
, вот так:
val = Auth.new.authenticate(name: 'incorrect')
puts val.name
Вы получите сообщение об ошибке среды выполнения Ruby:
$ ruby main.rb
main.rb:19:in `<main>': undefined method `name' for Failure(:unauthorized):Dry::Monads::Result::Failure (NoMethodError)
puts val.name
^^^^^
Круто, да? Теперь среда выполнения помогает нам понять, вызывает ли функция ошибку или нет, и мы можем обрабатывать это свойство, но как нам получить объект внутри Failure
от этого и вход в консоль? Давайте рассмотрим два подхода к этому.
3. Как развернуть варианты Result
1. Сопоставление с образцом
Вы можете использовать новый синтаксис сопоставления шаблонов, представленный в ruby версии 2.7, чтобы развернуть оба варианта, как показано ниже:
case Auth.new.authenticate(name: 'incorrect')
in Dry::Monads::Result::Success({name: String} => user)
puts user
in Dry::Monads::Result::Failure(:unauthorized => error)
puts error
end
Этот шаблон будет соответствовать варианту Failure
, и вывод будет таким:
$ ruby main.rb
:unauthorized
Если вы измените параметр имени с incorrect
на correct
, вместо этого вы получите следующий вывод:
$ ruby main.rb
{:name=>"cherry"}
2. Операторы if
Используя простой старый оператор if
, нам нужно будет использовать некоторые новые методы. Также можно использовать старые добрые операторы if
, результат предоставляет логические методы, а также метод связывания для развертывания варианта. В приведенном ниже примере мы обрабатываем это с помощью простого puts
, но вы можете себе представить, как легко использовать ранний возврат для обработки случаев неудачного или успешного варианта вместо этого.
value = Auth.new.authenticate(name: 'incorrect')
value.bind { |user| puts user } if value.success?
value.bind { |error| puts error } if value.failure?
Как вы можете видеть, у нас есть некоторые методы, такие как success?
и failure?
которые возвращают логические значения, облегчая нашу жизнь при работе с операторами управления. Кроме того, у нас есть bind
, предназначенная для развертывания варианта результата с замыканием.
3. Методы получения
Другой способ развернуть конкретный вариант - использовать соответствующий метод получения, это особенно полезно, когда вы уже находитесь внутри оператора if
, и его можно использовать следующим образом:
value = Auth.new.authenticate(name: 'incorrect')
puts "The error variant is: #{value.failure}" if value.failure?
puts "The success variant is: #{value.success}" if value.success?
4. Синтаксис Yield
Синтаксис yield
— это метод для развертывания только Success
варианта результата без ввода замыкания. Если метод возвращает Failure
, развертывание не произойдет, поэтому перед использованием yield
рекомендуется обработать конкретные случаи сбоя.
Дисклеймер: операторinclude
необходим для использования синтаксисаyield
.
class Runner
include Dry::Monads::Do.for(:call)
def call
value = Auth.new.authenticate(name: 'incorrect')
return value.failure if value.failure?
yield value
end
end
puts "Result: #{Runner.new.call}"
4. Работа с глубоко вложенными ошибками
В начале этой статьи я представил проблему с обработкой ошибок как значений. Эта проблема возникает, когда вам приходится возвращать разные ошибки для глубоко вложенного метода, когда метод вызывает другой и так далее. Но как нам справиться с этой проблемой?
В таких языках, как Golang, существует такая функция, как errors.Wrap()
, облегчающая контекстуальное добавление к ошибке, упрощающая идентификацию источника ошибки и предоставляющая гораздо больше информации, чем просто сообщение об ошибке.
Используя dry-monads
, мы можем использовать всю мощь динамической природы ruby, позволяя нам возвращать что-либо внутри варианта Failure
, таким образом, мы можем создавать сложные структуры данных, такие как хэши, для регистрации контекста стека вызовов ошибок.
Предположим, что у нас был тот же класс, но с измененной обработкой ошибок:
require 'dry/monads/all'
class Auth
include Dry::Monads[:result]
# @param name [String]
# @return [Failure({ error: Symbol, context: String, username: String }), Success({ name: String })]
def authenticate(name:)
return Failure({ error: :unauthorized, context: 'Auth#authenticate', username: name }) unless name == 'correct'
Success({ name: 'cherry' })
end
end
Как вы можете видеть, мы можем вернуть объект с некоторыми ключами, которые предоставляют больше информации об этой ошибке, где она была вызвана и любую полезную информацию о ней, эта свобода позволяет нам создать ключ, такой как parent: 'ParentClass#parent_method'
, который по существу имитирует функциональность errors.Wrap
в мир Golang. Мы наверняка можем создавать более сложные структуры с помощью пользовательских классов, но в этой статье я решил использовать более простой и понятный подход, чтобы представить потенциал!
5. Бонус, связанный с нулевым представлением
Мы видели, как обрабатывать неудачи и варианты успеха для нашей бизнес-логики, но, возможно, вы думаете про себя: «Я также могу абстрагироваться от отсутствия ценности?» и вы были бы абсолютно правы, мы можем!
Отсутствие значения можно понимать как None
, а само значение можно понимать как Some
, dry-monads gem
предоставляет нам эту удивительную функциональность, используя те же концепции, которые мы видели с Result
:
Рассмотрим аналогичный класс, который мы видели выше, но с вариантами возможного вместо результирующих.
require 'dry/monads/all'
class Auth
include Dry::Monads[:maybe]
# param name [String]
# @return [None(), Some({name: String})]
def authenticate(name:)
return None() unless name == 'correct'
Some({ name: 'cherry' })
end
end
none_val = Auth.new.authenticate(name: 'incorrect')
some_val = Auth.new.authenticate(name: 'correct')
puts "None -> #{none_val}"
puts "Some -> #{some_val}"
С этим примером кода наш вывод будет следующим:
$ ruby main.rb
None -> None
Some -> Some({:name=>"cherry"})
Как и в случае с монадой Result
, мы можем выполнять почти все операторы управления, как было показано ранее, ниже мы кратко рассмотрим их все:
1. Сопоставление с образцом
require 'dry/monads/all'
class Auth
include Dry::Monads[:maybe]
# param name [String]
# @return [None(), Some({name: String})]
def authenticate(name:)
return None() unless name == 'correct'
Some({ name: 'cherry' })
end
end
case Auth.new.authenticate(name: 'correct')
in Dry::Monads::Maybe::None
puts 'None branch'
in Dry::Monads::Maybe::Some({name: String} => user)
puts "Some branch #{user}"
end
2. Операторы if
require 'dry/monads/all'
class Auth
include Dry::Monads[:maybe]
# param name [String]
# @return [None(), Some({name: String})]
def authenticate(name:)
return None() unless name == 'correct'
Some({ name: 'cherry' })
end
end
option = Auth.new.authenticate(name: 'incorrect')
puts 'This is the none option' if option.none?
option.bind { |opt| puts "This is the some option #{opt}" } if option.some?
Важно отметить, что нам не нужно использовать метод bind
для None
, потому что этот вариант будет просто представлять ничтожность значения.
3. Синтаксис yield
В отличие от монады Result
, Maybe
не предоставляет нам метод получения, поэтому нам нужно полагаться на синтаксис yield
, когда мы не хотим использовать замыкание, как в bind
.
class Runner
include Dry::Monads::Do.for(:call)
include Dry::Monads[:maybe]
def call
option = Auth.new.get_user_name(id: 1)
return None if option.none?
yield option
end
end
puts "Result: #{Runner.new.call}"
Как и в случае сResult
, давайте просто работать на счастливых путях (в данном случае вариантSome
).