补充异常 - 引入 Ruby 中的 Monad 进行错误处理
你有没有想过处理异常的方法?我指的是
在类中使用 raise 关键字,然后
在调用该方法的函数中使用 rescue 关键字。然而,在
OCaml、Rust、Elm、Haskell 和 Go 等现代编程语言中,存在一种与异常相反的替代方法。本质上,错误被视为值,我们使用 match 语句或简单的 if 语句等结构将它们作为常规变量进行管理。在本文中,我们
将深入研究如何使用 dry-monads gem 实现这种技术。
目录
异常有什么问题?
当您仅查看正在开发的方法时,异常可能很容易引发,但是当您尝试使用大型代码库中的库或方法时,您会发现一些我们现在要看的烦恼:
- 该方法会触发异常吗? :: 每当在您的代码库中或通过第三方库调用某个方法时,都无法立即确定该函数是否会导致抛出异常。在最不幸的情况下,如果您没有参考文档或检查代码以识别任何未处理的异常,就会突然出现问题。您的应用程序会抛出一个非托管异常,并且很可能会导致生产中断。
- 哪种方法触发了该错误? :: 一种方法会消耗很多其他方法,这是很常见的情况,如果所有这些方法都触发不同的错误,那么在开发该方法时很难确定哪种方法触发了什么错误,错误消息可能有帮助,但不是 100% 准确。
使用错误作为值的优点和缺点是什么?
到目前为止,我向您介绍了异常的痛点并介绍了一种可能的解决方案(dry-monad gem),但由于在编程领域没有灵丹妙药,因此了解每种可能解决方案的优缺点至关重要,这正是我们将在下面的这一节中看到的内容:
优点
- 运行时可以帮助您(至少有一点) :: 想象以下场景,您调用一个函数并期望返回一个
User
,但该方法返回一个Error
,突然您无法使用,user.name
因为 ruby 会告诉您该变量是一种错误类型而不是一个User
,从而可以轻松调试并防止将来在生产中出现错误。
免责声明:这不如出现编译时错误那么好,但这里的想法是通过让异常更接近您来改善您当前的开发体验。
-
在业务逻辑之前处理错误处理::虽然这可能更多的是个人偏好而不是纯粹的技术优势,但将错误作为值处理可以让您通过在方法开始时处理所有不愉快的路径来充分探索早期返回的威力。
-
清晰地查看哪个方法返回了哪个错误:: 将错误作为返回值处理的优点在于它为代码库带来的透明度。由于错误直接从方法返回,因此可以清楚地知道哪个错误对应于哪个方法。
缺点
- 管理深度嵌套错误时的挑战::正如我们在前面的主题中介绍的将错误链接到其原始方法的好处,在具有深度嵌套方法链的情况下会出现困难,在这种情况下很难保留这些异常传递的上下文。
动手做事
首先让我们定义一些关于dry-monads 的免责声明:
- 干单子并不是为了避免异常,而是为了在受控环境中使用异常,在这种环境中,您知道自己会引发异常。
- Dry monads 并不完美,ruby 是一种动态语言,我们无法进行完美的编译时检查,但我们可以尽力改善我们的开发体验。
现在我们已经解决了问题,让我们创建一个示例项目来展示如何使用这个新 gem:
1.创建项目
您可以使用以下方式创建示例项目:
mkdir project && cd project && bundle init
然后添加该项目的唯一依赖项dry-monads:
bundle add dry-monads
2. 如何返回错误
首先,我们来研究一下Result
monad。如果你熟悉 Rust,理解这个概念可能相对容易。本质上,Result monad 封装了两种可能的结果:aSuccess(value)
或 a Failure(error)
。
要使用这个结果 monad,首先我们需要需要这个库,然后为了方便起见,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
^^^^^
很酷吧?现在运行时可以帮助我们判断函数是否会触发错误
,并且我们可以处理它的属性,但是如何获取 this 中的对象Failure
并记录到控制台呢?让我们看看两种方法。
3. 如何解开结果变量
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
如果将名称参数从“不正确”更改为“正确”,则将打印以下输出:
$ ruby main.rb
{:name=>"cherry"}
2. If 语句
使用普通的 if 语句,我们需要使用一些新方法。
当然,也可以使用经典的 if 语句,其结果会提供布尔方法以及一个 bind 方法来解包变量。在下面的例子中,我们用普通的 来处理它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. Getter 方法
解开特定变量的另一种方法是使用相应的 getter 方法,当您已经在 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"})
与 monad 类似,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 语法
与 monad 不同Result
,Maybe
它不提供 getter 方法,因此当我们不想使用像 on 这样的闭包时,我们需要依赖 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
,yield 只在快乐路径上起作用(在本例中为Some
变体)。
结论
一如既往,希望你喜欢这篇文章并学到一些新东西。我正在开发一个新的 gem,用于包装异常并返回这个 monad,希望能尽快完成,并完成本文的第二部分。愿原力与你同在!
文章来源:https://dev.to/cherryramatis/complementing-exceptions-introducing-monads-on-ruby-5fip