type
status
date
slug
summary
tags
category
icon
password
Golang广为诟病的if err != nil,让无数开发者吐槽,甚至因为它退坑。我非常赞同Golang的设计哲学"less is more"——“大道至简”,里面非常多的设计思想都和我的期待非常相符,但满屏的if err != nil也曾经让我困惑了很久,网上找了很多方案也没有解决我的问题。直到了解了设计者对error处理的设计思路后,想到了一种“主动error处理模式”能够解决我的困惑,只需要30行代码即可实现,这里分享出来。工程名为base-error,模式名为”流水线模式“。

error处理设计的发展史

文章Go 语言 panic 与 error 最佳实践 - 知乎 (zhihu.com)对各语言error处理发展史介绍得很好,引用自Why Go gets exceptions right | Dave Cheney,这里自己加工一下,简要复述一遍。
在早期的C语言时代,函数只支持单值返回,语言上也没有错误处理机制。常见的错误处理方式是返回值检查和全局错误码,通过定义错误码来了解具体错误信息。当然,也可以设计结构体来存储错误信息来代替错误码,但不管怎样,错误信息维护起来很麻烦。
后来,C++引入了(try-catch)异常处理机制,能够大大减轻维护错误信息的负担。上层函数可以通过catch来捕获异常,不捕获则继续上抛,直到有人知道如何处理它。这种方式也有问题:你不知道调用的函数有没有抛异常,也不知道会抛出什么异常,很难知道什么时候应该去捕获或者处理异常,大大增加使用者的心智负担,也增加了程序出错的风险。
再后来,Java对这种异常处理机制有所改进,规定抛出异常一定要先声明,而对于编译期异常,调用者必须捕获处理,或者继续上抛。这样,编译器就能够帮你检查有没有正确处理应该处理的异常,降低了开发者的心智负担。同样后面的C++也有类似的设计,解决了一些问题,但大家对错误处理机制的用法终归比较散,难统一标准,新人没少被try-catch折磨过。
try-catch虽然大大减轻了程序员对错误信息维护的负担,但很容易被滥用,比如大大小小异常都往外抛,隐藏了真正需要被关注的异常,程序常常因此而崩溃。或者把try-catch当做流程控制的手段,导致程序逻辑混乱。
现在Golang出现了,Golang的设计方案是没有了“异常”这个名词,通过多值返回"error"来表示预期可能发生的错误,而更严重的错误则由完全不同的语法机制"panic"来表示。

Golang的error处理设计思路

通过上面error处理设计的发展史,我们体会一下Golang的error处理设计思路。
"panic"表示严重错误,你必须要严肃对待的,只要panic出现,程序就完蛋了。而"error"是预期可能发生的,一般来说,既然有预期了,大概率内部就能处理掉,不会那么严重。
"panic"提供了recover机制,panic-recover其实相当于C++、Java等众多语言的try-catch,"error"机制则相当于是C语言的错误返回模式。
panic-recover能够大大减轻try-catch滥用带来的风险。首先是大小错误都往外抛的问题,因为大多语言只有try-catch错误处理机制,语法上鼓励大家将大小错误统一处理,这样就容易导致滥用;而Golang除了"panic",还有"error",语法上强调了"panic"是用来处理严重错误的,一般的逻辑错误用"error"。然后是try-catch被当做流程控制的问题,同样是官方对"panic"的强化定义,让大家不要把它当做流程控制的手段。整体上,Golang是语法层面的约束和规范,提升了程序的健壮性。
"error"机制,相当于是C语言里面的错误返回处理方式,而且明确了它代表的就是错误,多值返回也不会干扰正常的函数计算结果返回。可以说,Golang的"error"机制比C语言的环境好太多,是可以用C语言里面的错误处理设计来套用在Golang上面的。
一切看起来都很合理,而且优于其他语言的错误处理设计。那为什么大家还吐槽呢?
实际上主要是try-catch玩家们对它的吐槽,这种吐槽由来已久。try-catch的出现,就是为了解决C语言时代的错误处理负担,要编写大量的对函数出错检查代码,影响开发效率,还降低代码的阅读性。但C语言玩家,也吐槽try-catch们对待错误的不严谨,喜欢try-catch一把梭哈,导致程序隐藏的问题多。Golang则是倾向于C语言对待错误的严谨性,因此被try-catch们吐槽开历史的倒车。
实际上Golang是有类似try-catch机制的,只是panic代表严重错误的理念深入人心了,大家不会用这种机制来处理一般错误,还是会使用官方建议的"error"机制,这是正确的。
确实,看看Golang代码,满屏的if err != nil,大多都是重复代码,太影响阅读和开发效率了。难怪有人开玩笑,1000行Golang代码,500行if err != nil
我想,大家吐槽的应该是:
  1. 大量重复的错误处理代码影响阅读和开发效率。
  1. 被动式的错误处理,开发者被迫必须应对每个error返回,虽然可以通过"_"忽略,但也占用了一个返回值,也需要接收处理,容易遗漏重要的错误。
  1. 没有集中处理错误的机制。

一种主动/集中error处理设计模式

既然对于一般错误,Golang选择了以类C的error处理模式为主,那么可以参考C语言的思路设计:用struct封装一类操作,错误统一放到err成员变量中,由使用者主动地,集中地处理错误,如果使用者不处理,则相当于忽略错误。这样,struct的调用者,不需要应对每个方法的error返回,而是主动访问err成员变量,获取错误信息并集中处理。
实现方案只需要30行代码,如下:
定义一个BaseErr的基类,包含err成员变量,用来存储最早发生的错误信息,以及一个泛型指针t,用来存储子类的对象地址。
实现Err()方法,用来返回错误;SetErr()方法,用来设置错误信息;InitAddr()方法,用来初始化子类对象的地址。
用起来也很简单:
将一系列方法封装在一个BaseErr的子类中(例子是MyErr),BaseErr子类中的方法,需要以if p.Err() != nil { return p }为开头,方法中所有的错误都通过p.SetErr()导入到err成员变量中。最后,在用户的调用端,可以随意调用BaseErr子类的方法,只要出现error,后面的方法都不会执行,用户可以通过Err()方法获取出错的信息,进行集中错误处理。
上面的实现,不只是提供给用户主动/集中处理错误的方案,而且还实现了便利的链式调用,以及流水线模式的函数封装,这些特性(TODO)后面再专门写文章说明。
这种设计模式特别适用中间件封装,尤其是需要多种方法组合操作的场景,对于使用者可以随意组合操作,然后集中处理错误,大大减少了代码量,提升开发效率。
当然,这种设计模式也不是针对if err != nil问题的解药,也有一些局限性:
  1. 首先,开发者和使用者都必须熟悉这种模式的开发和调用规范(TODO:后面专门写文章介绍规范),否则很容易出错。比如开发者一定要用InitAddr()方法初始化BaseErr子类对象的地址,否则就会出现空指针调用错误;使用者在通过Err()捕获并处理错误后,如果继续使用BaseErr子类对象,则必须通过SetErr(nil)清理错误。
  1. 方法必须封装在BaseErr的子类中,因为存储error的变量和相关方法都是struct下的成员。当然,也可以将这种设计思路拓展到package级别,只是需要通过copy变量和代码的方式进行拓展开发。
  1. 对于简单的操作,这种封装反而增加了工作量,不如if err != nil
这种设计模式的核心思路是:
  1. 中间件的开发者,将错误导入到一个“全局”(相对struct内部而言)error变量中,而且这个变量只保存最早出现的错误。
  1. 只要有错误出现,后续的操作都无效,且“应该”没有副作用。
这样,使用者就能够在使用中间件的最后,主动和集中地调用Err()方法来处理错误了。当然,在任何中间环节,如果对使用者的其他操作有副作用,也需要提前通过Err()处理错误,不是非得所有操作集中在最后一步之后处理。

语法上如何支持用户主动/集中处理error

前面的方案是在现有Golang语法特性支持的前提下实现的主动/集中处理error的设计模式,但因为语法限制,存在一些局限性。如果从语法方面支持,将能够大大增加使用的便利性和安全性。
目前Golang语法上并不支持这种错误处理机制,这里只是提供一个建议的设计思路:
  1. 语法上允许函数的使用者,在接收变量时可以忽略error的返回值,例如允许rvStr := func MyFunc() (string, error){ ... }(示意)类似的写法。这样主要是为了兼容老版本语法。
  1. 对于上面这种写法的语句,由Golang语法层面自行接收错误的返回值,然后提供一个集中处理错误的入口,比如每个函数中增加一个deal { ... }关键字,在这个关键字的代码块中,由使用者决定如何处理错误。
  1. 对于上面的语法,使用者没有处理的错误默认是忽略掉,但是必须有记录。也就是说,程序的全局都会有针对没有处理错误的记录,通过deal global { ... }关键字可以提取所有没有处理错误的记录。这样可以方便开发者复查遗漏,可以通过覆盖测试达到更好的错误覆盖完备性。
随着大家对if err != nil带来负担的呼吁,语法层面支持主动/集中错误处理机制是大势所趋,最终必将会有这么一套可行方案推出,它是对传统类C严苛错误处理模式的补充。

error处理思路建议

error处理的核心目的是提升程序的健壮性。C语言的严苛错误处理模式开发负担太重,很难有精力应对好每个错误,实操中还是会漏掉应该关心的错误;try-catch容易带来的一把梭哈式的偷懒思路,容易忽略重要的错误,而导致严重问题。实际上两种错误处理的模式都很难达到提升程序健壮性的目的。
实际上,任何程序的开发,不可能在开发阶段就能够知道所有错误,程序运行出错在所难免。重要的是对错误分类,将精力放在重要,大概率出现的错误上面。
基于Golang的错误处理设计思想,开发阶段对待错误的合理思路应该是:
  1. 开发阶段尽可能暴露错误,将错误打印出来能够定位错误,也是一种错误处理方式,不要忽略错误。
  1. 将错误进行分类对待,开发阶段可能很难区分panic和error,前期不重要,最好能够做标记,知道哪些是已确定的哪些是待定的分类即可。但程序上线应用的时候,要评估好未确定的panic和error带来的后果,妥善对待,避免他们引发程序崩溃。
  1. 错误处理要分场景。对于终端业务开发,不重要的错误就吞掉,重要的就panic,不确定的就上抛。对于中间件,尽量上抛。
  1. 最后,最重要的事情,是通过不断的测试覆盖错误,然后加强和确定对待具体错误的处理方案。
Golang流水线模式与链式调用从黑神话的成功,浅析为什么大公司难做出好产品
Loading...