DevGang
Авторизоваться

Дополнение исключений — введение монад для обработки ошибок в 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).

Источник:

#Ruby
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу