干净的代码和异常处理的艺术
已发表: 2022-03-11异常与编程本身一样古老。 早在使用硬件或通过低级编程语言进行编程的日子里,异常被用来改变程序的流程,并避免硬件故障。 今天,维基百科将例外定义为:
需要特殊处理的异常或异常情况——经常改变程序执行的正常流程……
处理它们需要:
专门的编程语言结构或计算机硬件机制。
因此,异常需要特殊处理,未处理的异常可能会导致意外行为。 结果往往是惊人的。 1996 年,著名的阿丽亚娜 5 号火箭发射失败归咎于未处理的溢出异常。 历史上最糟糕的软件错误包含一些其他错误,这些错误可能归因于未处理或未处理的异常。
随着时间的推移,这些错误以及无数其他错误(可能没有那么严重,但对相关人员来说仍然是灾难性的)造成了异常是坏的印象。
但是异常是现代编程的基本要素。 它们的存在是为了让我们的软件变得更好。 与其害怕例外,我们应该拥抱它们并学习如何从中受益。 在本文中,我们将讨论如何优雅地管理异常,并使用它们编写更易于维护的干净代码。
异常处理:这是一件好事
随着面向对象编程 (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
前面的代码没有任何问题。 但是过度使用这些模式会导致代码异味,而且不一定是有益的。 同样,滥用它们实际上会对您的代码库造成很大伤害,使其变得脆弱,或者混淆错误的原因。
围绕异常的耻辱常常让程序员感到无所适从。 例外是不可避免的,这是生活中的一个事实,但我们经常被教导必须迅速果断地处理它们。 正如我们将看到的,这不一定是真的。 相反,我们应该学习优雅地处理异常的艺术,使它们与我们的其余代码和谐相处。
以下是一些推荐的做法,它们将帮助您接受异常并利用它们以及它们的能力来保持代码的可维护性、可扩展性和可读性:
- 可维护性:使我们能够轻松地发现和修复新的错误,而不必担心破坏当前功能、引入更多错误或由于随着时间的推移增加复杂性而不得不完全放弃代码。
- 可扩展性:允许我们轻松地添加到我们的代码库,实现新的或更改的需求,而不会破坏现有的功能。 可扩展性提供了灵活性,并为我们的代码库实现了高度的可重用性。
- 可读性:允许我们轻松阅读代码并发现其用途,而无需花费太多时间挖掘。 这对于有效地发现错误和未经测试的代码至关重要。
这些元素是我们所谓的清洁度或质量的主要因素,这本身并不是一个直接的衡量标准,而是前面几点的综合效果,如这部漫画所示:
话虽如此,让我们深入研究这些实践,看看它们中的每一个如何影响这三个措施。
注意:我们将展示来自 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 # ...
为我们的应用程序提供一个可扩展的、全面的异常包可以更轻松地处理这些特定于应用程序的情况。 例如,我们可以决定以更自然的方式处理哪些异常。 这不仅提高了我们代码的可读性,还提高了我们的应用程序和库(gems)的可维护性。
从可读性的角度来看,它更容易阅读:
rescue ValidationError => e
比阅读:
rescue RequiredFieldError, UniqueFieldError, ... => e
例如,从可维护性的角度来看,我们正在实现一个 JSON API,并且我们已经定义了我们自己的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
也就是说,永远不要尝试为基本异常类型实现包罗万象的处理程序。 拯救或捕获所有异常在任何语言中都不是一个好主意,无论是在全局基础应用程序级别上,还是在仅使用一次的小型隐藏方法中。 我们不想拯救Exception
,因为它会混淆真正发生的一切,损害可维护性和可扩展性。 我们可能会浪费大量时间来调试实际问题,因为它可能像语法错误一样简单:
# 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
您可能已经注意到前面示例中的错误; return
输入错误。 尽管现代编辑器针对这种特定类型的语法错误提供了一些保护,但此示例说明了rescue Exception
如何损害我们的代码。 任何时候都没有处理异常的实际类型(在本例中为NoMethodError
),也从未向开发人员公开,这可能会导致我们浪费大量时间在循环中运行。
永远不要rescue
比你需要的更多的异常
前一点是这条规则的一个具体案例:我们应该始终注意不要过度概括我们的异常处理程序。 原因是一样的; 每当我们挽救的异常多于应有的异常时,我们最终都会对应用程序的更高级别隐藏部分应用程序逻辑,更不用说抑制开发人员自己处理异常的能力了。 这严重影响了代码的可扩展性和可维护性。

如果我们确实试图在同一个处理程序中处理不同的异常子类型,我们就会引入具有太多职责的胖代码块。 例如,如果我们正在构建一个使用远程 API 的库,则处理MethodNotAllowedError
(HTTP 405) 通常与处理UnauthorizedError
(HTTP 401) 不同,即使它们都是ResponseError
。
正如我们将看到的,通常存在应用程序的不同部分,它们更适合以更干燥的方式处理特定异常。
因此,定义您的类或方法的单一职责,并处理满足此职责要求的最少的异常。 例如,如果一个方法负责从远程 API 获取股票信息,那么它应该只处理因获取该信息而产生的异常,并将其他错误的处理留给专门为这些职责设计的不同方法:
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 API 的应用程序)中看到的一种常见模式是以下控制器方法:
# 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
错误都在一个地方,在基本ApplicationController
级别上得到正确和 DRY 处理。 如果我们想在较低级别处理特定情况,或者只是让它们优雅地传播,这使我们可以自由地摆弄它们。
并非所有异常都需要处理
在开发 gem 或库时,许多开发人员会尝试封装功能,并且不允许任何异常传播到库之外。 但有时,在实现特定应用程序之前,如何处理异常并不明显。
让我们以ActiveRecord
作为理想解决方案的示例。 该库为开发人员提供了两种完整性方法。 save
方法处理异常而不传播它们,只是返回false
,同时save!
失败时引发异常。 这使开发人员可以选择以不同的方式处理特定的错误情况,或者以一般方式简单地处理任何故障。
但是,如果您没有时间或资源来提供如此完整的实现怎么办? 在这种情况下,如果有任何不确定性,最好将异常暴露出来,并将其释放到野外。
原因如下:我们几乎一直在处理移动需求,并且做出始终以特定方式处理异常的决定实际上可能会损害我们的实现,破坏可扩展性和可维护性,并可能增加巨大的技术债务,尤其是在开发时图书馆。
以股票 API 消费者获取股票价格的较早示例为例。 我们选择当场处理不完整和格式错误的响应,并且我们选择再次重试相同的请求,直到我们得到有效的响应。 但后来,需求可能会发生变化,因此我们必须回退到保存的历史股票数据,而不是重试请求。
此时,我们将被迫更改库本身,更新处理此异常的方式,因为依赖项目不会处理此异常。 (他们怎么可能?他们以前从未接触过它。)我们还必须通知依赖我们库的项目的所有者。 如果有很多这样的项目,这可能会成为一场噩梦,因为它们很可能是建立在假设这个错误将以特定方式处理的基础上的。
现在,我们可以看到依赖管理的发展方向。 前景并不好。 这种情况经常发生,而且往往会降低库的实用性、可扩展性和灵活性。
所以这是底线:如果不清楚应该如何处理异常,让它优雅地传播。 在许多情况下,存在明确的内部处理异常的地方,但在许多其他情况下,暴露异常更好。 因此,在您选择处理异常之前,请三思而后行。 一个好的经验法则是,仅在您直接与最终用户交互时才坚持处理异常。
遵守约定
Ruby 以及 Rails 的实现遵循一些命名约定,例如区分method_names
和method_names!
“砰”的一声。 在 Ruby 中,bang 表示该方法将更改调用它的对象,而在 Rails 中,这意味着该方法如果未能执行预期的行为将引发异常。 尝试遵守相同的约定,特别是如果您要开源您的库。
如果我们要写一个新method!
在 Rails 应用程序中,我们必须考虑这些约定。 当这个方法失败时,没有什么强迫我们引发异常,但是通过偏离约定,这个方法可能会误导程序员相信他们将有机会自己处理异常,而实际上他们不会。
Jim Weirich 的另一个 Ruby 约定是使用fail
来指示方法失败,并且只有在重新引发异常时才使用raise
。
顺便说一句,因为我使用异常来指示失败,所以在 Ruby 中我几乎总是使用
fail
关键字而不是raise
关键字。 Fail 和 raise 是同义词,所以除了 fail 更清楚地表明方法失败之外没有区别。 我唯一一次使用 raise 是当我捕获异常并重新引发它时,因为在这里我没有失败,而是明确且有目的地引发异常。 这是我遵循的风格问题,但我怀疑许多其他人会这样做。
许多其他语言社区已经围绕如何处理异常采用了类似的约定,而忽略这些约定将损害我们代码的可读性和可维护性。
Logger.log(一切)
当然,这种做法不仅仅适用于异常,但如果有一件事应该始终记录,那就是异常。
日志记录非常重要(对于 Ruby 以标准版本发布记录器非常重要)。 这是我们应用程序的日记,比记录我们的应用程序如何成功更重要的是记录它们如何以及何时失败。
不乏日志库或基于日志的服务和设计模式。 跟踪我们的异常情况至关重要,这样我们就可以查看发生的情况并调查是否有问题。 适当的日志消息可以将开发人员直接指出问题的原因,从而为他们节省大量时间。
清洁代码的信心
异常是每种编程语言的基本组成部分。 它们很特别而且非常强大,我们必须利用它们的力量来提升我们代码的质量,而不是让自己筋疲力尽地与它们战斗。
在本文中,我们深入探讨了一些构建异常树的良好实践,以及逻辑地构建异常树如何提高可读性和质量。 我们研究了处理异常的不同方法,无论是在一个地方还是在多个级别。
我们看到“全部抓住”是不好的,让它们四处漂浮并冒泡是可以的。
我们查看了在哪里以 DRY 方式处理异常,并了解到我们没有义务在它们首次出现的时间或地点处理它们。
我们讨论了什么时候处理它们是个好主意,什么时候是个坏主意,以及为什么在有疑问时让它们传播是个好主意。
最后,我们讨论了有助于最大化异常有用性的其他要点,例如遵循约定和记录所有内容。
有了这些基本的指导方针,我们在处理代码中的错误情况时会感到更加自在和自信,并使我们的异常真正异常!
特别感谢 Avdi Grimm 和他精彩的演讲 Exceptional Ruby,这对本文的制作有很大帮助。