Ruby 处理异常小技巧

类似用户未读通知的站内消息,每次访问站点时都必须查询的操作,如果出现数据库查询连接异常或网络连接错误,那么程序就会报 500 错误,用户不能正常访问站点。通常的做法是不管成功与否,这种行为都要进行异常捕获。
本篇文章是参考 Resilience in Ruby: Handling Failure 的总结,作者基于 GitHub 用户通知数据分库的实际场景而写。

基本的错误处理

由于某一个加载项出错而导致整个网址不可用,有必要对异常处理时(常见的场景就是用户未读消息,不能因为出错而直接给出 500 页面)通常是使用异常捕获,不让程序错误暴露给用户。例如发送 HTTP 请求,接口不可用时会出现程序报错,这是就需要抛出异常。

require "net/http"
Net::HTTP.new("localhost", 9999).request(Net::HTTP::Get.new("/"))

如果你把上面的代码在 irb 里面运行,会得到下面的报错结果(假设本地没有绑定在端口号为 999 的web 服务)

Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 9999

这个错误导致程序运行中断并退出。如果你把代码拷贝到文件中并使用 ruby 命令执行,然后再使用 echo $? 命令查看执行结果,你就会看到执行结果为 1(失败)

$ ruby resilience.rb
  ... [snip several lines of error and backtrace] ...
$ echo $?
1

为了处理这个错误,Ruby 提供了 begin/rescue

require "net/http"
begin
  Net::HTTP.new("localhost", 9999).request(Net::HTTP::Get.new("/"))
  puts "Succeeded"
rescue
  puts "Failed"
end

# Outputs: Failed

同样,放在一个 .rb 文件中执行,并查看执行状态,你就会看到返回 0 (成功)

$ ruby resilience.rb
Failed
$ echo $?
0

但是,这样简单地异常捕获处理也带来了缺陷。就是一些程序逻辑错误也会被捕获而不被发现,例如“参数错误”这样的错误应该被抛出来,以便修复 Bug 。例如下面的例子中,request 方法没有传递正确的参数,ruby 依然正确执行而并没有抛出异常。

require "net/http"
begin
  Net::HTTP.new("localhost", 9999).request()
  puts "Succeeded"
rescue
  puts "Failed"
end

# Outputs: Failed

If you execute this, your script indeed says that it failed and exits with success.
用 Ruby 命令执行结果输出 failed 并成功地退出(echo $? 输出结果 0)

$ ruby resilience.rb
Failed
$ echo $?
0

在这个例子中 rescue 刚好屏蔽了 ArgumentError 错误。由于开发者编写代码错误,但在报错信息中,request 方法没有传参的错误没有得到反馈,无法快速定位出错原因。
在开发时,编写代码错误这样的低级不应该被捕获,尽可能地捕获具体的异常。

require "net/http"
begin
  Net::HTTP.new("localhost", 9999).request(Net::HTTP::Get.new("/"))
  puts "Succeeded"
rescue Errno::ECONNREFUSED
  puts "Failed"
end

# Outputs: Failed

ruby resilience.rb ,输出 Failed 并返回成功状态。

$ ruby resilience.rb
Failed
$ echo $?
0

下面的例子中,request 方法参数错误,由于我们捕捉了具体的 Errno::ECONNREFUSED 异常,所以如果 request 参数错误不会被捕获,运行代码时会报错。

require "net/http"
begin
  Net::HTTP.new("localhost", 9999).request()
  puts "Succeeded"
rescue Errno::ECONNREFUSED
  puts "Failed"
end

results in:

$ ruby resilience.rb
/Users/jnunemaker/.rbenv/versions/2.2.5/lib/ruby/2.2.0/net/http.rb:1373:in `request': wrong number of arguments (0 for 1..2) (ArgumentError)
    from resilience.rb:3:in `<main>'
$ echo $?
1

异常捕获的封装和改进

尽管 Ruby 提供了 beginrescue 捕获异常,但是如果不封装的话,会导致很多不优雅的垃圾代码。
依然是举例子,一个捕获 HTTP 连接异常的请求,HTTP 请求连接成功返回 JSON

require "json"
require "net/http"

class Client
  # Returns Hash of notifications data for successful response.
  # Returns nil if notification data cannot be retrieved.
  def notifications
    begin
      request = Net::HTTP::Get.new("/")
      http = Net::HTTP.new("localhost", 9999)
      response = http.request(request)
      JSON.parse(response.body)
    rescue Errno::ECONNREFUSED
      # what should we return here???
    end
  end
end

client = Client.new
p client.notifications

上面的代码初步封装了一个 notifications 方法,但我们输出为json 数据格式,即使 Errno::ECONNREFUSED 异常出现。
继续改造一下,添加了个 NotificationsResponse 类。

require "json"
require "net/http"

class Client
  class NotificationsResponse
    attr_reader :notifications, :error

    def initialize(&block)
      @error = false
      @notifications = begin
        yield
      rescue Errno::ECONNREFUSED => error
        @error = error
        {status: 400, message: @error.to_s} # sensible default
      end
    end

    def ok?
      @error == false
    end
  end

  def notifications
    NotificationsResponse.new do
      request = Net::HTTP::Get.new("/")
      http = Net::HTTP.new("localhost", 9999)
      http_response = http.request(request)
      JSON.parse(http_response.body)
    end
  end
end

client = Client.new
response = client.notifications

if response.ok?
  # Do something with notifications like show them as a list...
else
  # Communicate that things went wrong to the caller or user.
end

通过 response.ok? 方法的返回,我们可以在 if else 写相应的业务逻辑。
为了防止调用 Client 类的 notifications 前没有调用 NotificationsResponseok? 方法, 继续改造一下。

require "json"
require "net/http"

class Client
  class NotificationsResponse
    attr_reader :error

    def initialize(&block)
      @error = false
      @notifications = begin
        yield
      rescue Errno::ECONNREFUSED => error
        @error = error
        {} # sensible default
      end
    end

    def ok?
      @ok_predicate_checked = true
      @error == false
    end

    def notifications
      unless @ok_predicate_checked
        raise "ok? must be checked prior to accessing response data"
      end

      @notifications
    end
  end

  def notifications
    NotificationsResponse.new do
      request = Net::HTTP::Get.new("/")
      http = Net::HTTP.new("localhost", 9999)
      response = http.request(request)
      JSON.parse(response.body)
    end
  end
end

client = Client.new
response = client.notifications
# response.notifications would raise error because ok? was not checked

如果调用 client.notifications 方法之前没有执行 client.ok?,

raise "ok? must be checked prior to accessing response data"

则会抛出异常。
把代码放在一个文件中,并在 irb 中 require 进来测试结果如下:

2.3.0 :001 > require_relative "./test.rb"
 => true
2.3.0 :002 > client = Client.new
 => #<Client:0x007fabcb241430>
2.3.0 :003 > response = client.notifications
 => #<Client::NotificationsResponse:0x007fabcb239550 @error=#<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:3000 (Connection refused - connect(2) for "localhost" port 3000)>, @notifications={:code=>400, :message=>#<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:3000 (Connection refused - connect(2) for "localhost" port 3000)>}>
2.3.0 :004 > response.notifications
RuntimeError: ok? must be checked prior to accessing response data
    from /Users/hww/test.rb:25:in `notifications'
    from (irb):4
    from /Users/hww/.rvm/rubies/ruby-2.3.0/bin/irb:11:in `<main>'
2.3.0 :005 > response.instance_variable_get(:@ok_predicate_checked)
 => nil
2.3.0 :006 > response.ok?
 => false
2.3.0 :007 > response.instance_variable_get(:@ok_predicate_checked)
 => true
2.3.0 :008 > response.notifications
 => {:code=>400, :message=>#<Errno::ECONNREFUSED: Failed to open TCP connection to localhost:3000 (Connection refused - connect(2) for "localhost" port 3000)>}
0 条评论
您想说点什么吗?