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 提供了 begin
和 rescue
捕获异常,但是如果不封装的话,会导致很多不优雅的垃圾代码。
依然是举例子,一个捕获 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
前没有调用 NotificationsResponse
的 ok?
方法, 继续改造一下。
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)>}