引言

错误处理一直都是开发中绕不开的坑, 而且往往一时偷懒, 摔得更惨. 所以这次打算从头梳理下这个让人头大的问题.

先简单介绍下思路, 本文开篇会讨论一些比较抽象的部分, 比如给错误下定义, 人和机器对于错误的不同关注点等等. 之后会给出一些具体的例子, 我本身是做 Go 微服务开发的, 所以讨论会局限在 Go + 微服务 这个上下文中, 当然, 背后的思想是各种语言和场景通用的. 最后会简单介绍下最近捣鼓的一个错误处理包, 也可以作为如何简化错误处理的一个实例.

好了, 大体上就这三大块, 下面我们就正式开始吧.

基本属性

“没有好的抽象就没有好的解决方案” 这个观点相信大部分有经验的开发者都会认同. 所以我们上来先重新认识一下错误, 看看怎样的抽象最适合错误处理.

😉 Happy path & 😭 Sad path

程序中我们最希望关注的是能够实现我们目标的流程, 这也是程序中使用价值最高的部分, 就把它称作 Happy path 好了. 但是, 现实中的程序在 😉 Happy path 外还有许多其他的执行路径, 处理这些路径并不会带来更多价值 (更多花哨的功能), 但是放任不管的话却会大幅增加使用成本, 用户必须小心翼翼, 并祈祷程序不会崩溃或失去响应. 这些 😉 Happy path 之外的路径, 就称它们为 😭 Sad path.

简单说来, 写好 😉 Happy path 可以创造更多价值, 管理好 😭 Sad path 可以降低使用成本 (和现实中一样, 管理手段不创造价值, 但降低成本).

而 😭 Sad path 还能细分, 其中不能被识别的部分就是隐藏的 Bug, 直到被某人发现并提出 issue . 而能识别的部分就是需要我们仔细分析的部分了.

在我的理解中, 所有使程序偏离 😉 Happy path 的状态都是错误. 现代编程语言帮助我们识别出了大部分最明显的错误, 并以诸如 Exception, Error 等方式返回或抛出, 尽力提醒我们去处理它.

但我们必须时刻铭记, 具体的业务场景中, 大部分错误都是编译器无法识别的. 比如, 某段程序的目标是帮助客户完成出库操作, 其中商品余量被定义为一个整形, 当余量为 0 时, 继续将其减 1 是不会有编译期或者运行时错误的, 余量会变成 -1. 但是整个系统已经处于一个错误的状态, 即商品量不应该小于 0. 所以此时整个系统已经偏离 😉 Happy path 了, 那么这就是一个错误 (即便机器保持沉默).

好了, 简单小结一下:

  • 😉 Happy path: 创造价值
  • 😭 Sad path: 使用成本
    • 未识别的: Bug
    • 已识别的: Error

分类

那么既然错误是如此地多样, 就连编译器都无能为力, 那么我们又该何去何从呢?

看似无奈, 实则不然. 仔细回想下我们平时的错误处理手段, 无非以下四种:

  • 忽略错误或者重置状态使程序继续
  • 终止业务流程, 将错误交给用户处理
  • 使程序崩溃
  • 重新定义目标, 使原本错误的状态从定义中消失

所以, 我们可以从上面四种情况将错误分成两大类:

  • 系统内可解决的
    • 可重试解决的
    • 可重新定义目标解决的
  • 系统内无法解决的
    • 用户可以解决的
    • 只能第三方解决的

关注点

还有一点不知道大家有没有意识到, 错误在程序中之所以特殊, 是因为它同时被程序和人使用, 而且两者的关注点还不一样.

简单说来, 程序更关注一个错误出现后该如何处理: 重试? 崩溃?

而人却更关注错误是如何产生的: 堆栈信息, 其他业务相关的上下文信息.

所以之后我们设计接口的时候也要注意将机器和人使用的接口分开对待.

处理手段

在重新认识错误之后, 就让我们来看看具体的处理手段吧.

重试解决

这类错误在网络通信中非常常见, 我们可以看看操作系统中的 TCP 通信是如何处理的. TCP 通信中的丢包可以说是无法避免, 如果每次都直接崩溃, 或者交给调用者处理的话, 我们的开发体验肯定比现在糟糕一万倍.

实际上, 操作系统在超时时间内会以一定的延迟策略自动重发丢掉的包, 尽力使接收方收到所有的数据包, 而我们开发者却丝毫没有发觉: 操作系统已经悄悄将程序从 😭 Sad path 拉回了 😉 Happy path.

一般来说, 越是偏底层的调用, 越是需要考虑重试, 在微服务环境下 HTTP Client 就最好加上 503 Service Unavailable 错误的重试, 尽量不要因为集群内某些服务的重启而影响用户的使用.

注意: 务必确保被重试的操作具有幂等性, 不然错上加错就更难受了.

重新定义目标

具体说来就是重新定义操作, 使原本的 😭 Bad path 变成 😉 Happy path. 有点奇怪对吧, 没事, 看几个具体的例子就懂了.

例子1: 删除文件

同时用过 Windows 和 Linux 系统的朋友肯定会发现它们处理删除操作的不同手法.

Windows 中被打开的文件是无法删除的, 系统会给一个错误警告. 然后你就得满系统找, 到底是哪个应用锁定了这个文件, 有时实在找不到, 只能重启了, So sad.

而 Linux 中就没这个烦恼, 即使文件正在被编辑, rm 命令也能删除. 系统会确保文件无法被的进程访问到, 然后在合适的时候切实地将文件删除掉. 这样不管是删除者, 还是编辑者都没有任何感觉, So happy.

之所以有这样的区别, 是因为对删除操作的定义不同:

  • Windows: 命令执行后任何人都无法与之互动;
  • Linux: 命令执行后没有新的互动, 确保文件最终无法与任何人互动.

例子2: 选择日期范围

一般带仪表盘的页面都会有时间范围选择, 有一类设计就很不爽, 只要选择的日期超出允许的范围就会弹出警告, 并终止进一步的查询 (比如阿里云的仪表盘), 体验并不好.

而 Grafana 中的设计就很舒服, 无论选择什么时间范围, Grafana 都会画出完整的时间轴, 在轴上画出所有存在的数据点. 即使数据源出错, 也只会在出错的卡片上标记, 而不会弹出全局警告.

区别在于:

  • 阿里云仪表盘: 用户在业务允许的范围内查询, 并返回数据.
  • Grafana 仪表盘: 在用户指定的范围内, 查询并返回业务允许的数据.

备注:

在我眼中, 这种错误处理方式是最好的, 因为它扩大了 😉 Happy path 的范围, 使程序更通用, 可以说是带来了更大的价值.

返回给用户

这算是我们平时最常用的错误处理手段了, 实现起来最简单, 但也积累了大量的使用成本 (比如上面阿里云的例子).

由于程序中任何操作调用的次数往往都远高于定义次数, 因此使用成本会被成倍放大.

因此, 这部分的建议是:

  • 先考虑能不能用重定义的方式消除错误
  • 如果确实要交给用户处理, 尽量附加上最有价值的信息

使程序崩溃

有一些错误实在无法处理, 或者需要付出巨大精力却降低不了多少使用成本, 这种情况下就直接让程序崩溃吧.

举几个例子:

  • 可用内存过低: 也许该放宽内存限制, 或者检查是否出现内存泄漏, 但是在应用内处理这种错误实在没什么意义
  • 设计良好的重启恢复策略: 崩溃的原因很多, 但是重启恢复的策略只要设计的好就能处理大部分情况. 如果错误非常罕见, 并且恢复机制足够好, 那直接崩溃会比尝试处理好得多.
  • 关键依赖失败: 比如一个后台消费者完全依赖于一个消息队列, 然而消息队列宕机了, 这时与其尝试处理各种重连策略, 不如直接清理干净后退出程序.

总而言之: 如果怎么也想不明白该如何处理某种错误, 那就大胆让程序崩溃. 两个原因:

  • 一是, 这类错误一般都很罕见;
  • 二是, 不恰当的处理方式, 或者掩盖错误, 后果往往严重得多

接下来

考虑到篇幅, 就先在此打住. 这篇 Part 1 主要讨论理论部分, 重新定义错误. 并从如何处理这个角度对错误进行分类.

现在我们就有了抽象的基础, 下篇中我们就以 Go 语言为例, 具体地看看该如何操作.

最后, 本文中许多思想都源于 “A Philosophy of Software Design” 这本书, 好学的朋友不要错过哟.

备注: 评论区还没加, 可以到 V2ex 评论