整洁代码与处理异常的艺术


/**
 * 谨献给正在长大的小黑
 *
 * 原文出处:https://www.toptal.com/qa/clean-code-and-the-art-of-exception-handling
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-04-12
 */

异常如同编程本身一样那么古老。在过去好些日子里,当在硬件中完成了编程,或者通过底层编程语言,异常用于 警告程序流,以及用于避免硬件失败。今天,维基百科定义的异常如下:

异常或者需要特殊处理的例外情况 -- 经常改变程序的执行流程...

并且处理他们要求:

专门的编程语言结构或计算机硬件机制。

所以,异常需要特别的对待,并且一个并处理的异常可能引起非预期的行为。而后果则经常让人为之一惊。 在1996年,著名的阿丽亚娜5型火箭发射失败是由于一个未处理的浮点异常。 历史上最严重的软件问题包含了一些其他 由于未处理或者误处理异常的Bug。

随意时间的流逝,这些和其他(可能不是戏剧性的,但仍然是灾难性的)不计其数的错误造成了异常是糟糕的这一印象。

但是异常是现代编程中一个基本的元素;他们存在是为了让我们的软件更好。与其恐惧异常,倒不如拥抱他们并学 习如何从中受益。在这篇文章中,我们将会讨论如何高效管理异常,并使用他们编写更可维护的整洁代码。

异常处理:这是件好事

随着面向对象编程(OOP)的兴起,对异常的 支持已成为了现代编程语言的一个关键元素。如今,在大部分语言中都内置了强壮的异常处理系统。例如,Ruby 提供了以下经典的模式:

begin  
  do_something_that_might_not_work!
rescue SpecificError => e  
  do_some_specific_error_clean_up
  retry if some_condition_met?
ensure  
  this_will_always_be_executed
end  

前面这些代码没有什么问题。但过度使用这些模式会引起代码异味,并且不一定会是有益的。同样地,滥用他们 其实会对你的代码库造成很多伤害,如变得脆弱或者混淆错误的起因。

围绕异常的耻辱经常使得程序员感到迷茫。生活中的一个事实就是:异常无可避免,但我们经常被告之的是必须迅 速果断地处理异常。正如我们将会看到的那样,这不一定是对的。相反,我闪应该学习优雅处理异常的艺术,使得 他们和其他代码和谐共处。

以下是一些推荐的实践,可帮助你拥抱异常并且使用他们及其能力来保持你的代码具有可维护性可扩展性可读性
+ 可维护性:可以轻易找到并修复新的bug,无须恐惧破坏当前的功能、引入新的bug、或者由于日益的复杂性而不得不抛弃全部的代码。
+ 可扩展性:可以轻易追加到代码库,实现新的或者修改需求而不会破坏已有的功能。扩展性提供了灵活度,以及针对代码库的高层重用性。
+ 可读性:容易阅读并且无须花费太多的时间深挖就能发现它的目的。这对于高效发现bug和未测试的代码是关键的。

这些元素就是我们称之为清洁度或者质量的主要因素,清洁度和质量本身不是直接的试题,而是由前面几 点结合的度量,正如这幅漫画所画得那样:

也就是说,让我们投入到这些实践并看下他们各自是如何影响这三个度量。

注意:我们会用Ruby来呈现示例,但这里所演示的全部构造函数在大部分通用的OOP语言中都有共同性。

总是创建自己的ApplicationError体系

大部分语言都带有大量的异常类,像其他任何OOP类一样以继承体系的形式组织。为了维持代码的可读性、可维护性 以及可扩展性,创建我们自己的扩展于基础异常类的特定应用异常子树是一个好主意。投资一些时间在构造这个体 系的逻辑结构是相当有用的。例如:

class ApplicationError < StandardError; end

# Validation Errors
class ValidationError < ApplicationError; end  
class RequiredFieldError < ValidationError; end  
class UniqueFieldError < ValidationError; end

# HTTP 4XX Response Errors
class ResponseError < ApplicationError; end  
class BadRequestError < ResponseError; end  
class UnauthorizedError < ResponseError; end  
# ...

拥有一个针对应用的可扩展的、全面的异常包使得处理这些特定应用场景更加容易。例如,我们可以通过一种更自 然的方式决定处理哪一个异常。这不仅提升了代码的可读性,还提高了应用以及类库(这里是gem)的可维护性。

从可读性的视角,这样更易于阅读:

rescue ValidationError => e  

相比于:

rescue RequiredFieldError, UniqueFieldError, ... => e  

从可维护性的视角来说,例如,我们正在实现一个JSON接口,并且已经定义了自己的带有若干个子类型的ClientError, 以便在当客户发送一个错误请求时使用。如果任何其中一个被抛出来,应用应该在它的响应中渲染这个JSON错误的陈述。 这对于单单处理ClientError异常的代码块来说修复或者添加逻辑都更简单,而不是循环每一个可能的客户端错误并 为每个实现同样的处理代码。就可扩展性而言,如果稍候需要实现另一种客户端错误类型,我们可以相信它将会在那被正确处理。

此外,这并不妨碍我们在调用栈前面实现针对特定客户端错误的额外特殊处理,或修改沿途相同的异常对象:

# app/controller/pseudo_controller.rb
def authenticate_user!  
  fail AuthenticationError if token_invalid? || token_expired?
  User.find_by(authentication_token: token)
rescue AuthenticationError => e  
  report_suspicious_activity if token_invalid?
  raise e
end

def show  
  authenticate_user!
  show_private_stuff!(params[:id])
rescue ClientError => e  
  render_error(e)
end  

正如你看到那样,抛出这个特定异常并没有妨碍我们在不同级别上对它的处理能力、修改它、以及允许父类处理器解决它。

这里需要注意的两件事
+ 并不是全部的语言都支持在异常处理器中抛出异常。
+ 在大多数语言里,在异常处理器中抛出一个新的 异常会导致异常源永远丢失,所以最好是重新抛出同样的 异常对象(正如上面的示例那样)以避免丢失对错误引起的根源追踪。(除非你正在有目的地做这个

绝不rescue Exception

这就是,不要尝试为基础异常类型实现一个捕捉-全部(catch-all)处理器。在任何语言中不管三七二十一就rescue或捕捉全部异常 从来都不是一个好主意,不管是全局地在基础应用级别,抑或是在某个仅使用一次的隐藏小方法里。我们不想rescueException 是因为这样会混淆到底发生了什么,破坏了可维护性及可扩展性。我们可能会浪费大量的时间在于调试实际的问题到底是什么, 而它可能只是一个简单的语法错误:

# main.rb
def bad_example  
  i_might_raise_exception!
rescue Exception  
  nah_i_will_always_be_here_for_you
end

# elsewhere.rb
def i_might_raise_exception!  
  retrun do_a_lot_of_work!
end  

你可能已经注意到了前面这个示例中的错误;retrun拼写错了。忙乎现代编辑器针对这样指定语法错误拼写 提供了一些保护措施,这个示例说明了rescue Exception如何对我们的代码造成确切的伤害。对于所定位的 实际异常类型(这里是NoMethodError)毫无头绪,也从来没有暴露给开发人员,可能会导致我们浪费大量 的时间在一次又一次的运行上。

绝不rescue过多比你需要的异常

前面这点是这个规则的一个特定情况:我们应该总是小心翼翼的以免过度生成了异常处理器。理由是一样的:不管 何时rescue了过多的异常,我们最终从应用的更高层次隐藏了应用逻辑的部分,更不用说抑制了开发人员他/她自己 处理异常的能力。这点严重影响了代码的可扩展性和可维护性。

如果我们真的打算在相同的处理器中处理不同的异常类型,就会引入拥有过多职责的胖代码块。例如,如果 正在构建一个消费远程接口的类库,处理一个MethodNotAllowedError(HTTP 405),通常与处理一个UnauthorizedError(HTTP 401) 是不同的,尽管他们两者都是ResponseError

正如我们会看到的那样,应用经常会存在一个不同的部分更适合于通过更加DRY的方式来处理特定异常。

所以,定义类或方法的单一职责,并且 处理满足职责需求的最低限度的异常。例如,如果一个方法负责从远程接口获取库存信息, 那么它应该只处理从获取那些信息抛出的异常,并且让特别设计负责这些异常的不同方法来处理其他的错误:

def get_info  
  begin
    response = HTTP.get(STOCKS_URL + "#{@symbol}/info")

    fail AuthenticationError if response.code == 401
    fail StockNotFoundError, @symbol if response.code == 404
    return JSON.parse response.body
  rescue JSON::ParserError
    retry
  end
end  

这里我们定义了此方法的契约只是为我们获取关于库存的信息。它处理了终端特定错误,例如一个不完整或者 异常的JSON响应。它没有处理认证失败或者过期,或者是库存不存在的情况。这些是别人的职责,并且应明确地传 给调用栈 -- 以DRY方式处理这些错误的更好的地方。

抑制立即处理异常的冲动

这是最后一点的补充。一个异常可以在调用栈的任何点上处理,也可以在类继承体系的任意一点上处理,所以确切地知道 在哪里处理它可能是迷惑的。为了解决这个难题,很多开发人员选择在异常一旦抛出就尽快处理,但投资一些时间 彻底地思考这点通常会找到一个更合适的地方来处理特定异常。

在Rails应用(尤其那些仅暴露JSON的接口)中我们看到的一个通用模式是以下这个控制器方法:

# app/controllers/client_controller.rb

def create  
  @client = Client.new(params[:client])
  if @client.save
    render json: @client
  else
    render json: @client.errors
  end
end  

(请注意尽管技术上这不是一个异常处理器,但在功能上它服务了相同的目的,因为当@client.save遇到异常时只会返回false。)

然而,在这种情况下,在每个控制器重复相同的错误处理器违反了DRY,并且破坏了可维护性和可扩展性。相反, 我们可以使用异常传播的特殊性,并且只在父类控制器ApplicationController处理一次:

# app/controllers/client_controller.rb

def create  
  @client = Client.create!(params[:client])
  render json: @client
end  
# app/controller/application_controller.rb

rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity

def render_unprocessable_entity(e)  
  render \
    json: { errors: e.record.errors },
    status: 422
end  

这种方式,我们可以确保全部ActiveRecord::RecordInvalid错误都会正确地并且DRY地在一个地方处理,即 在ApplicationController这一基础级别。这样给了我们自由去捣鼓他们如果我们想在更低的级别处理特定情 况,或者简单地让他们优雅地传播。

并不是全部的异常都需要处理

当在开发一个gem或者一个类库时,很多开发人员都会尝试封装功能并且不允许向外界传播任何异常。但有时候, 如何处理一个异常并不明朗直到特定应用实现时。

让我们拿ActiveRecord作为理想解决方案 的一个示例。这个类库为开发人员提供了两种完整性的途径。save方法处理了异常而没有传播他们,只是简 单地返回了false,而save!则在失败时抛出一个异常。这给予了开发人员处理特定不同错误情况的选择, 或者简单地以通用的方式来处理全部失败。

但是如果你没有时间或者资源提供如此完整的实现的话该怎么办?那样的话,如果存在不确定的,最好是暴露这些异常, 放生它。

这就是为什么:我们几乎一直在和变化的需求工作,并且作出的异常应该总是以特定的方式来处理的这一决定可能 实际上损害了我们的实现,破坏了可扩展性与可维护性,并且可能增加了技术债务, 尤其在开发类库时。

以前面库存接口消费者获取库存价格为例。我们选择了立刻处理不完整或者格式错误的响应,并且我们选择了再次 重试相同的请求直到获得一个有效的响应。但是后面,需求可能会变,例如我们需要回滚到已保存的历史库存数据, 而不是重试请求。

这个时候,我们将会强制修改类库本身,更新如何处理这个异常,因为那些独立的项目不会处理这个异常。 (他们怎么处理得了?之前从来没有暴露过给他们。)我们还要提醒依赖于我们类库的项目主人。如果有很多这样 的项目的话,这可能会变成一场恶梦,因为他们很可能已经在这个错误将会以特定的方式处理这一假设上进行构建。

现在,我们可以看到我们正在与依赖管理一起迈进。前景并不好。这种情况常有发生,而且往往不是,它会降低 类库的有用性,可扩展性和灵活性。

所以底线就是:如果不清楚如何处理某个异常,那就让它优雅地传播。有很多情况是存在一个清晰的地方内部 处理此异常,但是还有很多情况是暴露此异常更好。所以在你选择处理异常前,三思而后行。经验法则是仅当你 直接与终端用户交互时才坚持 处理异常

遵循惯例

Ruby的实现,甚至乎Rails,遵循了一些命名的惯例,例如使用“bang”区分method_namesmethod_names!
在Ruby,bang意味着这个方法稍候会修改调用的对象,而在Rails,它意味着这个方法如果执行预期的行为失败则抛出 一个异常。试着遵守相同的惯例,尤其当你准备把类库开源的话。

如果我们在Rails应用中写了一个带bang的新method!,一定要考虑到这些惯例。没有什么东西强制当这个方法 失败时抛出一个异常,但偏离了惯例,这个方法可能会误导程序员相信他们会有机会自己处理异常,然而,实际上没有。

另一个Ruby惯例,归功于Jim Weirich使用了fail暗示方法失败,并且只用raise如果你正在重新抛出这个异常。

“顺便说一句,因为我使用异常暗示失败,我几乎总是在Ruby中使用“fail”关键字而不是“raise”关键字。 fail和raise是同义词所以除了“fail”更加明确地传达了此方法已失败外没有其他区别了。我使用“raise”的唯一时机
是当我在捕促某个异常并且重新抛出它时,因为这里我不是失败,而是明确且故意抛出一个异常。这是一个我遵循 的风格问题,但我怀疑很多其他人做的。”

很多其他语言社区已经采纳了像这些围绕如何对待异常的惯例,而忽略这些惯例会破坏我们代码的可读性和可维护性。

Logger.log(每件事)

当然,这个实践不光适用于异常,但如果有一样东西应该总是 被纪录的话,那就是异常。

日志纪录是相当重要的(重要到足以让Ruby发布了一个带logger的标准版本)。 它是我们应用的日记,并且甚至比保持一份应用如何成功的纪录更为重要的是,纪录如何及何时失败。

日志类库或者基于日志的服务和设计模式并不缺乏。 保持对异常的追踪很关键以便我们可以回顾发生了什么并且如果某些东西看起来不对劲的话进行调查。合适的日志 消息能把开发人员直接指向到问题发生的原因,节省他们不可计量的时间。

整洁代码的信心

干净的异常处理会把你的代码质量发送到月球!

异常是每一门编程语言的一个基础部分。他们特殊且相当强大,我们必须充分利用他们的力量来提升代码质量, 而不是忙于和他们作斗争。

在这篇文章中,我们潜入了一些组织异常树结构的好实践,以及它是如何有益于可读性和逻辑结构他们的质量。 我们看过了对于处理异常的不同途径,或是在一个地方或是在多个层级。

我们看到“捕获全部异常”是不好的,而让他们漂浮和冒出来是可以的。

我们看过了在哪里以DRY方式来处理异常,以及学习了在他们首次抛出时就处理并不是我们的义务。

我们讨论了究竟何时处理是个好主意,何时不是,以及为什么,当困惑时,让他们传播是个好主意。

最后,我们讨论了其他可以帮助最大化异常用处的点,例如遵循惯例和纪录每件事。

通过这些基本的准则,我们可以感觉得到在代码中处理错误情况时会更加舒适以及自信,并且使异常真正的无与伦比!

特别感谢Avdi Grimm以及他超级棒的演讲异常的Ruby,此演讲极大帮助了此文章。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州