我意识到我迟到了 6 年,但是……我以为直到本周我才理解 Ruby 错误处理并遇到了这个问题。虽然答案很有用,但有一些不明显的(和未记录的)行为可能对这个线程的未来读者有用。所有代码都在 ruby v2.3.1 下运行。
@Andrew Grimm 问
如何在不更改 ruby 中的类的情况下向异常消息添加信息?
然后提供示例代码:
raise $!.class, "Problem with string number #{i}: #{$!}"
我认为关键指出这不会向原始错误实例对象添加信息,而是引发具有相同类的新错误对象。
@BoosterStage 说
重新引发异常并修改消息...
但同样,提供的代码
raise $!, "Problem with string number #{i}: #{$!}", $!.backtrace
将引发 $! 引用的任何错误类的新实例,但它不会与 $! 完全相同。
@Andrew Grimm 的代码和@BoosterStage 的示例之间的区别在于,第一种情况下#raise 的第一个参数是Class,而在第二种情况下,它是一些(大概)@ 987654330@。区别很重要,因为Kernel#raise 的文档说:
使用单个字符串参数,引发一个带有字符串作为消息的 RuntimeError。否则,第一个参数应该是异常类的名称(或发送异常消息时返回异常对象的对象)。
如果只给出一个参数并且它是一个错误对象实例,该对象将是raised IF该对象的#exception 方法继承或实现Exception#exception(string)中定义的默认行为:
没有参数,或者参数与接收者相同,返回接收者。否则,创建一个与接收者相同类的新异常对象,但消息等于 string.to_str。
很多人都会猜到:
...
catch StandardError => e
raise $!
...
引发 $! 引用的相同错误,与简单调用相同:
...
catch StandardError => e
raise
...
但可能不是出于人们可能认为的原因。在这种情况下,对raise 的调用NOT 只是引发$! 中的对象...它引发$!.exception(nil) 的结果,在这种情况下恰好是$!。
要澄清这种行为,请考虑以下玩具代码:
class TestError < StandardError
def initialize(message=nil)
puts 'initialize'
super
end
def exception(message=nil)
puts 'exception'
return self if message.nil? || message == self
super
end
end
运行它(这与我上面引用的@Andrew Grimm 的示例相同):
2.3.1 :071 > begin ; raise TestError, 'message' ; rescue => e ; puts e ; end
结果:
initialize
message
所以 TestError 是 initialized、rescued,并打印了它的消息。到目前为止,一切都很好。第二个测试(类似于上面引用的@BoosterStage 的示例):
2.3.1 :073 > begin ; raise TestError.new('foo'), 'bar' ; rescue => e ; puts e ; end
有些令人惊讶的结果:
initialize
exception
bar
所以TestError 是带有'foo' 的initialized,但随后#raise 在第一个参数(TestError 的一个实例)上调用了#exception 并传入了'bar' 的消息创建TestError 的第二个实例,这就是最终得到的结果。
直到。
另外,和@Sim 一样,我非常关心保留任何原始回溯上下文,但不是像他的raise_with_new_message 那样实现自定义错误处理程序,Ruby 的Exception#cause 支持我:无论何时我想捕获一个错误,将它包装在一个特定于域的错误中,然后引发 that 错误,我仍然可以通过#cause 获得关于引发的特定于域的错误的原始回溯。 p>
这一切的重点是——就像@Andrew Grimm——我想用更多的上下文来提出错误;具体来说,我只想从我的应用程序中可能有许多与网络相关的故障模式的某些点引发特定于域的错误。然后我的错误报告可以在我的应用程序的顶层处理域错误,并且我通过递归调用#cause 来获得记录/报告所需的所有上下文,直到找到“根本原因”。
我使用这样的东西:
class BaseDomainError < StandardError
attr_reader :extra
def initialize(message = nil, extra = nil)
super(message)
@extra = extra
end
end
class ServerDomainError < BaseDomainError; end
然后,如果我使用 Faraday 之类的东西来调用远程 REST 服务,我可以将所有可能的错误包装到特定于域的错误中并传递额外的信息(我相信这是这个线程的原始问题):
class ServiceX
def initialize(foo)
@foo = foo
end
def get_data(args)
begin
# This method is not defined and calling it will raise an error
make_network_call_to_service_x(args)
rescue StandardError => e
raise ServerDomainError.new('error calling service x', binding)
end
end
end
是的,没错:我刚刚意识到我可以将 extra 信息设置为当前的 binding 以获取在 ServerDomainError 实例化/引发时定义的所有本地变量。本次测试代码:
begin
ServiceX.new(:bar).get_data(a: 1, b: 2)
rescue
puts $!.extra.receiver
puts $!.extra.local_variables.join(', ')
puts $!.extra.local_variable_get(:args)
puts $!.extra.local_variable_get(:e)
puts eval('self.instance_variables', $!.extra)
puts eval('self.instance_variable_get(:@foo)', $!.extra)
end
将输出:
exception
#<ServiceX:0x00007f9b10c9ef48>
args, e
{:a=>1, :b=>2}
undefined method `make_network_call_to_service_x' for #<ServiceX:0x00007f9b10c9ef48 @foo=:bar>
@foo
bar
现在调用 ServiceX 的 Rails 控制器不需要特别知道 ServiceX 正在使用 Faraday(或 gRPC,或其他任何东西),它只是进行调用并处理 BaseDomainError。同样:出于日志记录的目的,顶层的单个处理程序可以递归地记录任何捕获的错误的所有#causes,并且对于错误链中的任何BaseDomainError 实例,它还可以记录extra 值,可能包括从封装的binding(s) 中提取的局部变量。
我希望这次旅行对其他人和我一样有用。我学到了很多。
更新:Skiptrace 似乎将绑定添加到 Ruby 错误。
另外,请参阅this other post,了解Exception#exception 的实现如何克隆对象(复制实例变量)。