【问题标题】:How do I add information to an exception message in Ruby?如何在 Ruby 中向异常消息中添加信息?
【发布时间】:2011-02-18 21:45:23
【问题描述】:

如何在不更改 ruby​​ 中的类的情况下向异常消息添加信息?

我目前使用的方法是

strings.each_with_index do |string, i|
  begin
    do_risky_operation(string)
  rescue
    raise $!.class, "Problem with string number #{i}: #{$!}"
  end
end

理想情况下,我还想保留回溯。

有没有更好的办法?

【问题讨论】:

    标签: ruby exception exception-handling


    【解决方案1】:

    另一种方法是将有关异常的上下文(额外信息)添加为 hash 而不是 string。

    查看this pull request,我建议在其中添加一些新方法,以便为异常添加额外的上下文信息变得非常容易,如下所示:

    begin
      …
      User.find_each do |user|
        reraise_with_context(user: user) do
          send_reminder_email(user)
        end
      end
      …
    
    rescue
      # $!.context[:user], etc. is available here
      report_error $!, $!.context
    end
    

    甚至:

    User.find_each.reraise_with_context do |user|
      send_reminder_email(user)
    end
    

    这种方法的好处是它可以让您以非常简洁的方式添加额外的信息。它甚至不需要您定义新的异常类来包装原始异常。

    尽管出于多种原因,我非常喜欢@Lemon Cat 的answer,而且它在某些情况下肯定是合适的,但我觉得如果您实际上想要做的是附加有关原始异常的附加信息,它似乎更可取只需将其直接附加到它所属的异常,而不是发明一个新的包装异常(并添加另一层间接)。

    另一个例子:

    class ServiceX
      def get_data(args)
        reraise_with_context(StandardError, binding: binding, service: self.class, callee: __callee__) do
          # This method is not defined and calling it will raise an error
          make_network_call_to_service_x(args)
        end
      end
    end
    

    这种方法的缺点是您必须更新错误处理以实际使用exception.context 中可能提供的信息。但是您必须这样做无论如何才能递归调用cause 以获取根异常。

    【讨论】:

      【解决方案2】:

      我意识到我迟到了 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 的实现如何克隆对象(复制实例变量)。

      【讨论】:

      • 在某些时候,打印出未捕获的异常现在也会打印出先前的异常。正如您所说,各种选择正在创建和引发新异常,因此输出有两个堆栈。我还没有找到用修改后的消息引发相同异常的方法。
      【解决方案3】:

      这是我最终做的:

      Exception.class_eval do
        def prepend_message(message)
          mod = Module.new do
            define_method :to_s do
              message + super()
            end
          end
          self.extend mod
        end
      
        def append_message(message)
          mod = Module.new do
            define_method :to_s do
              super() + message
            end
          end
          self.extend mod
        end
      end
      

      例子:

      strings = %w[a b c]
      strings.each_with_index do |string, i|
        begin
          do_risky_operation(string)
        rescue
          raise $!.prepend_message "Problem with string number #{i}:"
        end
      end
      => NoMethodError: Problem with string number 0:undefined method `do_risky_operation' for main:Object
      

      和:

      pry(main)> exception = 0/0 rescue $!
      => #<ZeroDivisionError: divided by 0>
      pry(main)> exception = exception.append_message('. With additional info!')
      => #<ZeroDivisionError: divided by 0. With additional info!>
      pry(main)> exception.message
      => "divided by 0. With additional info!"
      pry(main)> exception.to_s
      => "divided by 0. With additional info!"
      pry(main)> exception.inspect
      => "#<ZeroDivisionError: divided by 0. With additional info!>"
      

      这类似于Mark Rushakoff 的回答,但是:

      1. 覆盖to_s 而不是message,因为默认情况下message 被简单定义为to_s(至少在我测试过的Ruby 2.0 和2.2 中)
      2. 为您拨打extend,而不是让呼叫者执行该额外步骤。
      3. 使用define_method 和闭包,以便可以引用局部变量message。当我尝试使用 variable @@message 类时,它警告说,“警告:从顶层访问类变量”(参见 question:“由于您没有使用 class 关键字创建类,因此您的类变量被设置为Object,不是 [你的匿名模块]")

      特点:

      • 易于使用
      • 重用同一个对象(而不是创建类的新实例),因此对象标识、类和回溯等内容得以保留
      • to_s、message 和 inspect 都做出了适当的回应
      • 可以与已经存储在变量中的异常一起使用;不需要您重新提出任何东西(例如涉及传递回溯以提出的解决方案:raise $!, …, $!.backtrace)。这对我很重要,因为异常被传递到我的日志记录方法中,而不是我自己拯救的东西。

      【讨论】:

        【解决方案4】:

        要重新引发异常并修改消息,同时保留异常类及其回溯,只需执行以下操作:

        strings.each_with_index do |string, i|
          begin
            do_risky_operation(string)
          rescue Exception => e
            raise $!, "Problem with string number #{i}: #{$!}", $!.backtrace
          end
        end
        

        这将产生:

        # RuntimeError: Problem with string number 0: Original error message here
        #     backtrace...
        

        【讨论】:

        • 在以raise 开头的那一行,有没有理由使用$! 而不是e?它们是同一个对象。
        • @Jordan e === $! 所以是的,你可以使用e 而不是$1,假设e 已定义。 $! 的优点是它始终在异常块中可用。另外值得注意的是$@ === e.backtrace.
        • 你也可以使用e2 = e.class.new "Foo: #{e}"创建一个相同类型的新异常,然后e2.set_backtrace(e.backtrace)从原始异常中获取回溯。
        • 请注意,拯救Exception 可能不是您想要的stackoverflow.com/questions/10048173/…。
        • 还要注意$! 是一个全局变量,指向运行Ruby 程序时引发的最后一个异常。它可能在救援块期间被另一个线程中引发的另一个异常覆盖。这是更喜欢使用e 的原因之一。
        【解决方案5】:

        这是另一种方式:

        class Exception
          def with_extra_message extra
            exception "#{message} - #{extra}"
          end
        end
        
        begin
          1/0
        rescue => e
          raise e.with_extra_message "you fool"
        end
        
        # raises an exception "ZeroDivisionError: divided by 0 - you fool" with original backtrace
        

        (修改为在内部使用exception 方法,感谢@Chuck)

        【讨论】:

          【解决方案6】:

          我投票认为Ryan Heneise's 的答案应该是被接受的答案。

          这是复杂应用程序中的常见问题,保留原始回溯通常非常关键,因此我们在 ErrorHandling 帮助模块中为此提供了实用方法。

          我们发现的一个问题是,有时在系统处于混乱状态时尝试生成更有意义的消息会导致异常处理程序本身内部生成异常,这导致我们强化了我们的实用程序函数,如下所示:

          def raise_with_new_message(*args)
            ex = args.first.kind_of?(Exception) ? args.shift : $!
            msg = begin
              sprintf args.shift, *args
            rescue Exception => e
              "internal error modifying exception message for #{ex}: #{e}"
            end
            raise ex, msg, ex.backtrace
          end
          

          一切顺利时

          begin
            1/0
          rescue => e
            raise_with_new_message "error dividing %d by %d: %s", 1, 0, e
          end
          

          您会收到一条经过精心修改的消息

          ZeroDivisionError: error dividing 1 by 0: divided by 0
              from (irb):19:in `/'
              from (irb):19
              from /Users/sim/.rvm/rubies/ruby-2.0.0-p247/bin/irb:16:in `<main>'
          

          当事情变得糟糕时

          begin
            1/0
          rescue => e
            # Oops, not passing enough arguments here...
            raise_with_new_message "error dividing %d by %d: %s", e
          end
          

          你仍然没有忘记大局

          ZeroDivisionError: internal error modifying exception message for divided by 0: can't convert ZeroDivisionError into Integer
              from (irb):25:in `/'
              from (irb):25
              from /Users/sim/.rvm/rubies/ruby-2.0.0-p247/bin/irb:16:in `<main>'
          

          【讨论】:

          • 你为什么要从Exception类中解救,而不是StandardError?
          • @AndrewGrimm 拯救StandardError 并未涵盖当谚语引起轰动时往往更频繁发生的边缘情况。很好的例子是LoadError 和NotImplementedError。我们也遇到过#to_s 导致堆栈溢出的案例,因为它们巧妙地尝试在复杂的数据结构中生成有意义的消息。你只是不知道会发生什么。
          【解决方案7】:

          我的方法是 extend rescued 错误,使用匿名模块扩展错误的 message 方法:

          def make_extended_message(msg)
              Module.new do
                @@msg = msg
                def message
                  super + @@msg
                end
              end
          end
          
          begin
            begin
                raise "this is a test"
            rescue
                raise($!.extend(make_extended_message(" that has been extended")))
            end
          rescue
              puts $! # just says "this is a test"
              puts $!.message # says extended message
          end
          

          这样,您就不会破坏异常中的任何其他信息(即其backtrace)。

          【讨论】:

          • 对于那些好奇的人,如果message 中发生异常,您不会得到 Stack Overflow,但如果您要在 Exception#initialize 中发生异常,您就会得到。
          • 由于 Ruby 是动态且强类型的,在 message 方法中很容易获得异常。只需尝试将字符串和数字一起添加:)
          • @yar:通过 super + String(@@msg) 或等效项轻松解决。
          • 有点争论,你的第一直觉是不要那样做。而且您可能也不会在单元测试中想到这一点(动态语言的圣杯)。所以有一天它会在运行时爆炸,然后你会添加那个保护措施。
          • 我不只是抱怨动态语言:我还在考虑如何在其中进行防御性编程。
          【解决方案8】:

          这并没有好多少,但您可以通过一条新消息重新引发异常:

          raise $!, "Problem with string number #{i}: #{$!}"
          

          您也可以通过exception方法自己获取修改后的异常对象:

          new_exception = $!.exception "Problem with string number #{i}: #{$!}"
          raise new_exception
          

          【讨论】:

          • 第一个 sn-p 是我所追求的。查看Kernel#raise 的文档,它说如果您有多个参数,则第一项可以是Exception 类,也可以是在调用.exception 时返回异常的对象。我想我的大脑刚刚出现了异常。
          • 但这不会否定原始消息吗? @Mark Rushakoff 的方法不会更保守吗?
          • @yar:不,这不会破坏原始消息。这就是#{$!} 插值的全部目的。如果您遗漏了原始消息,它将取消原始消息,就像您在 Mark 的方法中遗漏了对 super 的调用一样。我坦率地说我的方法更保守,因为它只是使用语言的预期异常重新引发机制,而 Mark 的解决方案涉及创建一个完整的模块并重新定义 message 方法以获得相同的效果。
          • 好的,感谢您的解释,这实际上很酷。我没有意识到raise 可以接受多个参数...我应该猜到在 Ruby 中已经考虑过重新引发错误,因为这是必要的。
          • 请注意,这将改变回溯,除非 $!.backtrace 作为第三个参数传递
          猜你喜欢
          • 2011-08-29
          • 1970-01-01
          • 1970-01-01
          • 2012-04-13
          • 2011-05-01
          • 2014-11-03
          • 1970-01-01
          • 1970-01-01
          • 2010-09-08
          相关资源
          最近更新 更多