本文概述
异常与编程本身一样古老。在用硬件或通过低级编程语言进行编程的时代, 使用异常来更改程序流程并避免硬件故障。今天, 维基百科将例外定义为:
需要特殊处理的异常或异常情况–通常会改变程序执行的正常流程…
处理这些要求:
专门的编程语言构造或计算机硬件机制。
因此, 异常需要特殊处理, 而未处理的异常可能会导致意外行为。结果通常是惊人的。 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
# ...
为我们的应用程序提供一个可扩展的, 全面的异常包, 使处理这些特定于应用程序的情况变得更加容易。例如, 我们可以决定以更自然的方式处理哪些异常。这不仅提高了代码的可读性, 而且还提高了我们的应用程序和库(宝石)的可维护性。
从可读性的角度来看, 它更容易阅读:
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
如你所见, 引发此特定异常并不会阻止我们在不同级别上对其进行处理, 对其进行更改, 重新引发它并允许父类处理程序对其进行解决。
这里要注意两件事:
- 并非所有语言都支持从异常处理程序中引发异常。
- 在大多数语言中, 从处理程序中引发新的异常将导致原始异常永远丢失, 因此最好重新引发相同的异常对象(如上例中所示), 以避免丢失导致异常的原始原因。错误。 (除非你是故意这样做的)。
永不挽救异常
也就是说, 永远不要尝试为基本异常类型实现一个包罗万象的处理程序。无论是在基础应用程序级别上全局使用, 还是仅使用一次的小型隐埋方法, 挽救或捕获所有异常都不是任何语言的好主意。我们不想挽救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输入错误。尽管现代编辑器提供了针对这种特定类型的语法错误的保护措施, 但此示例说明了抢救Exception如何确实损害了我们的代码。异常的实际类型(在这种情况下为NoMethodError)绝不会得到解决, 也不会暴露给开发人员, 这可能会导致我们浪费大量时间在圈子中奔波。
永远不要抢救比你需要更多的例外
上一点是此规则的一个特定情况:我们应始终注意不要过度概括我们的异常处理程序。原因是相同的。每当我们营救了比应有的异常多的异常时, 我们最终都会在更高级别的应用程序中隐藏部分应用程序逻辑, 更不用说抑制开发人员自己处理异常的能力了。这严重影响了代码的可扩展性和可维护性。
如果确实尝试在同一处理程序中处理不同的异常子类型, 则会引入繁琐的代码块, 这些代码块的职责过多。例如, 如果我们要建立一个使用远程API的库, 则处理MethodNotAllowedError(HTTP 405)与处理UnauthorizedError(HTTP 401)通常是不同的, 即使它们都是ResponseErrors。
正如我们将看到的, 应用程序中通常存在一个不同的部分, 它更适合于以更干的方式处理特定的异常。
因此, 定义类或方法的单一职责, 并处理满足此职责要求的最少异常。例如, 如果某个方法负责从远程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错误均已正确地且在Dry-ly的Dry-ly处理中在基本ApplicationController级别上。如果我们想在较低级别处理特定案例, 或者只是让它们优雅地传播, 这使我们可以自由地摆弄它们。
并非所有异常都需要处理
在开发gem或库时, 许多开发人员将尝试封装功能, 并且不允许任何异常从库中传播出去。但是有时候, 在实现特定的应用程序之前, 如何处理异常并不清楚。
让我们以ActiveRecord作为理想解决方案的示例。该库为开发人员提供了两种实现完整性的方法。 save方法在不传播异常的情况下处理异常, 而在保存时仅返回false!失败时引发异常。这使开发人员可以选择以不同的方式处理特定的错误情况, 或简单地以一般方式处理任何故障。
但是, 如果你没有时间或资源来提供这样一个完整的实施方案, 该怎么办?在这种情况下, 如果存在任何不确定性, 则最好公开该异常, 然后将其释放。
原因如下:我们几乎一直都在处理不断变化的需求, 并且决定总是以特定的方式处理异常可能实际上会损害我们的实施, 破坏可扩展性和可维护性, 并可能增加巨大的技术负担, 尤其是在开发时库。
以先前的股票API使用者获取股票价格为例。我们选择当场处理不完整且格式错误的响应, 然后选择再次重试相同的请求, 直到获得有效的响应为止。但是稍后, 需求可能会更改, 因此我们必须退回到保存的历史库存数据, 而不是重试请求。
此时, 我们将不得不更改库本身, 以更新该异常的处理方式, 因为相关项目无法处理该异常。 (他们怎么可能?以前从未接触过它们。)我们还必须将依赖我们图书馆的项目通知业主。如果有很多这样的项目, 这可能会成为一场噩梦, 因为它们很可能是建立在假设将以特定方式处理此错误的前提下的。
现在, 我们可以看到依赖管理的发展方向。前景不好。这种情况经常发生, 而且经常发生, 这会降低库的实用性, 可扩展性和灵活性。
因此, 这是底线:如果不清楚应如何处理异常, 请让其正常传播。在许多情况下, 内部都有明确的位置可以处理异常, 但在许多其他情况下, 公开异常更好。因此, 在选择处理该异常之前, 请再考虑一下。一个好的经验法则是仅在直接与最终用户进行交互时坚持处理异常。
遵循惯例
Ruby(甚至是Rails)的实现遵循一些命名约定, 例如区分method_names和method_names!发出”砰”的一声。在Ruby中, bang表示该方法将更改调用它的对象, 而在Rails中, 则意味着该方法如果无法执行预期的行为将引发异常。尝试遵守相同的约定, 特别是如果你要开源库。
如果我们要写一个新方法!在Rails应用程序中大声疾呼, 我们必须考虑这些约定。没有什么可以迫使我们在此方法失败时引发异常, 但是通过偏离约定, 该方法可能会误导程序员, 使他们相信他们将有机会自己处理异常, 而实际上却没有。
归因于Jim Weirich的另一种Ruby约定是, 使用fail表示方法失败, 并且仅在重新引发异常时才使用引发。
顺便说一句, 因为我使用异常来指示失败, 所以我几乎总是在Ruby中使用fail关键字, 而不是raise关键字。 Fail和引发是同义词, 因此没有区别, 只是Fail更清楚地表明该方法已失败。我唯一一次使用加薪是在捕捉异常并重新引发异常时, 因为在这里我并不是失败, 而是明确而有目的地引发异常。我遵循这是一个风格问题, 但我怀疑其他许多人也这样做。
许多其他语言社区在处理异常方面都采用了类似的约定, 而忽略这些约定会损害代码的可读性和可维护性。
Logger.log(所有内容)
当然, 这种做法并不只适用于例外情况, 但是, 如果始终应记录一件事, 那就是例外情况。
日志记录非常重要(对于Ruby来说, 使用标准版本的记录器就非常重要)。这是我们应用程序的日记, 而且要记录我们的应用程序如何以及何时失败, 这比记录我们的应用程序如何成功更为重要。
不乏日志记录库或基于日志的服务和设计模式。跟踪我们的异常情况非常重要, 这样我们才能查看发生的情况并调查是否有异常情况。正确的日志消息可以使开发人员直接找到问题的原因, 从而节省了不可估量的时间。
干净的代码信心
干净的异常处理将把你的代码质量送上月球!
鸣叫
异常是每种编程语言的基本组成部分。它们非常特殊且功能强大, 我们必须利用它们的功能来提高代码的质量, 而不是用尽力与它们抗争。
在本文中, 我们深入探讨了一些用于构造异常树的良好实践, 以及如何以合理的方式构造异常树以提高可读性和质量。我们研究了在一个地方或在多个级别上处理异常的不同方法。
我们看到, “全力以赴”是不好的, 也可以让他们漂浮并冒泡。
我们研究了以DRY方式处理异常的位置, 并了解到我们没有义务在首次出现异常的时间或地点进行处理。
我们讨论了处理这些问题的确切时机, 什么时候是一个坏主意以及为什么有疑问时让它们传播是一个好主意。
最后, 我们讨论了有助于最大程度地发挥异常作用的其他方面, 例如遵循约定和记录所有内容。
有了这些基本准则, 我们可以更轻松, 更自信地处理代码中的错误情况, 并使异常真正地异常!
特别感谢Avdi Grimm和他的精彩演讲Exceptional Ruby, 这对本文的撰写很有帮助。
相关:面向Ruby开发人员的提示和最佳做法
评论前必须登录!
注册