直接跳转查看错误处理最佳实践。
参考文章
Go 错误处理指北:Error vs Exception vs ErrNo
回顾错误处理
Exception 派系
Exception 派系的错误处理机制有Java、Python、C#、JavaScript等主流的高级语言,他们错误处理的特点在于抛出异常、处理异常、异常兜底。
异常兜底的处理方式简化了代码编写,我们不需要在每一处函数调用处都对异常进行处理,下层函数在遇到错误时直接向上抛出即可。同样的,我们处理错误也只需要在最上层做一次异常处理即可。这里举一个Python代码的例子。
def a():
...
def b():
...
def c():
...
def main():
try:
a()
b()
c()
except Exception as e:
logging.error(e)
else:
print("success")
finally:
print("release resources")这种方式极大的简化的错误编写,但是因为有异常兜底,开发者在写代码时就会更加随心所欲,程序会更加的不可控制。开发者会倾向于不将错误思考的那么细致,不再仔细思考什么情况会遇到什么错误。如果开发者滥用异常兜底(如 catch Exception 或 except Exception,把所有异常一网打尽,然后继续执行),原本用于表示严重问题的异常就被当成普通流程分支,导致真正的错误被悄悄吞掉。这样会隐藏失败、丢失错误的严重级别、让日志缺失、使程序在错误状态下继续运行,并让业务逻辑变得不可预测。
C错误处理
由于C语言只能返回一个返回值,所以说通常情况下,有三种方法进行错误处理:
- 返回值既表示结果也表示错误,比如
fopen函数。我们用该函数来打开一个文件,其返回值file可能是FILE*表示文件描述符,也可能是NULL表示函数出错。所以我们需要通过if (file == NULL)来判断打开文件是否出错,如果出错,全局变量errno会被赋值,使用perror函数或者strerror(errno)可以读取错误码对应的错误描述信息。这样的返回值就具有二义性,会存在一些问题,比如说让人忘记错误检查。 - 返回值为错误,结果作为参数,用参数列表传递结果指针。例如
int div(int a, int b, int *result),div函数还是单返回值,不过这个返回值只代表错误,返回0表示成功,返回-1表示失败。div函数计算结果通过参数的形式,被存储在指针所指向的变量int *result中。但这种用参数接收计算结果的方式,语义不够清晰。 - C 函数支持返回结构体指针,将错误和结果统一放在结构体。这种方式代码实现比较复杂,不过多赘述。
Go错误处理
得益于 Go 函数支持多返回值,所以 Go 就不再需要 errno 了。Go 定义了一个 error 类型,专门用来表示错误,使用 errors.New 可以轻松构建一个错误对象。
因此我们就有了 Go 中经典的错误处理:
if err != nil {
return nil, err
}我们将在之后的篇幅中讲Go错误处理的最佳实现。
一些错误处理思想
暂存错误状态(AND)
在使用 Builder 模式、链式调用或者 for 循环等场景下,暂存中间过程所出现的错误,有助于简化代码,使编写出的代码逻辑更加连贯。
例如以下代码:
// Get takes name of the pod, and returns the corresponding pod object, and an error if there is any.
func (c *pods) Get(ctx context.Context, name string, options metav1.GetOptions) (result *v1.Pod, err error) {
result = &v1.Pod{}
err = c.client.Get().
Namespace(c.ns).
Resource("pods").
Name(name).
VersionedParams(&options, scheme.ParameterCodec).
Do(ctx).
Into(result)
return
}
// Namespace applies the namespace scope to a request (<resource>/[ns/<namespace>/]<name>)
func (r *Request) Namespace(namespace string) *Request {
if r.err != nil {
return r
}
if r.namespaceSet {
r.err = fmt.Errorf("namespace already set to %q, cannot change to %q", r.namespace, namespace)
return r
}
if msgs := IsValidPathSegmentName(namespace); len(msgs) != 0 {
r.err = fmt.Errorf("invalid namespace %q: %v", namespace, msgs)
return r
}
r.namespaceSet = true
r.namespace = namespace
return r
}其中的*Request.Namespace 方法首先会通过 if r.err != nil 判断是否存在错误,如果存在则直接返回,不再继续执行。如果不存在错误,则接下来每次可能出现错误的调用,都会将错误信息暂存到 r.err 属性中。每个链上的函数都是这种执行逻辑,简化了调用过程出现错误的错误处理。
这种方式类似于AND逻辑,其中有一个err就中止执行,记录错误最后返回。
Try Sequence(OR)
刚刚我们提到的暂存错误状态适用于AND的逻辑,即一系列函数,其中有一个err就中止执行,记录错误最后返回。
我们也可能会遇到OR的逻辑,比如说我希望一系列函数,只要有一个成功就不报错,只有所有失败才报错,那这时我们会用Try Sequence来简化代码逻辑,如下所示。
func TryOr(funcs ...func() (string, error)) (string, error) {
var errs []error
for _, f := range funcs {
v, err := f()
if err == nil { // 有一个成功就直接返回
return v, nil
}
errs = append(errs, err)
}
// 都失败:把错误合并返回
//(取决于你自己,如果你只想返回最后一个错误,不用err list,只用err记录最后一个即可)
return "", fmt.Errorf("all failed: %v", errs)
}错误只应处理一次
虽然,不应该忽略你的错误,但错误也不应该被重复处理,你应该只处理一次错误。
处理错误意味着检查错误值并做出决定。也就是说,根据能否处理错误,我们实际上只有两种选择:
- 不能处理:直接向上传递错误,自身不对错误做任何假设,也就是
Opaque error。 - 可以处理:降级处理,并向上返回
nil,因为自己已经处理了错误,表示不再有错误了,上层不应该继续拿到这个错误。
记录日志也属于处理错误,如果每次错误传递时都记录日志,那么最终看到的日志将会有非常多的重复内容。
何时记录错误日志
记录日志其实是一个比较大的话题,并且存在一定争议。何时记录、记录什么以及如何更好的记录都是比较复杂的问题。更糟糕的是,对于不同项目,可能会有不同的答案。
所以,这里只提一种可能适用的记录日志准则。
其实核心还是一句话:错误只应处理一次。
- 只在调用函数的最上层记录一次错误日志,调用链中间遇到错误直接返回,不做任何处理(或附加当前调用处的错误信息后返回)。
- 如果遇到服务降级的情况(为了防止主要功能受影响,我们会临时关闭非核心功能或使用简化的替代方案),我们也可以记录日志,并返回
nil,不再继续向上报告当前错误。 - 最好将错误堆栈也记录到错误中,在记录错误日志时用
%+v打印。
不要做冗余的错误检查
不要写出这种代码:
func Foo() error {
err := Bar()
if err != nil {
return err
}
return nil
}正确写法如下:
func Foo() error {
return Bar()
}我们无需编写这种代码:
func Bar() error {
err := Foo()
if err != nil {
return errors.WithMessage(err, "bar")
}
return nil
}可以直接去掉那冗余的错误检查:
func Bar() error {
err := Foo()
return errors.WithMessage(err, "bar")
}这不对执行结果造成任何影响。
我们无需判断 err 是否为 nil,因为 pkg/errors 内部的方法帮我们做好了这项检查:
func WithMessage(err error, message string) error {
if err == nil {
return nil
}
return &withMessage{
cause: err,
msg: message,
}
}对于 errors.Wrap/errors.WithStack 同样如此。
错误处理最佳实践
这里给出一套错误处理的最佳实践。
package main
import (
"fmt"
"github.com/pkg/errors"
)
func C() error {
return errors.New("root error") // 这里返回一个错误,其同时记录了错误消息和错误堆栈
// return fmt.Errorf("root error") // 这里返回一个错误,但不包含堆栈信息
}
func B() error {
if err := C(); err != nil {
// return err // 直接返回错误,不做修改
// return errors.Wrap(err, "error in B") // 这里可以选择包装错误,添加消息和堆栈信息
return errors.WithMessage(err, "error in B") // 这里可以选择只添加消息
// return errors.WithStack(err) // 这里可以选择只添加堆栈信息
}
return nil
}
func A() error {
if err := B(); err != nil {
// return err // 直接返回错误,不做修改
// return errors.Wrap(err, "error in A") // 这里可以选择包装错误,添加消息和堆栈信息
return errors.WithMessage(err, "error in A") // 这里可以选择只添加消息
// return errors.WithStack(err) // 这里可以选择只添加堆栈信息
}
return nil
}
func main() {
err := A()
fmt.Printf("%+v\n", err) // %+v 会打印错误消息和错误堆栈,%v 、 %q 和 %s 只打印错误消息
}
- 在错误最底层时,使用
errors.New,同时记录了错误消息和错误堆栈,方便排查问题。 - 在中间层时,使用
errors.WithMessage包装或return err直接返回错误,避免堆栈信息过多干扰排查错误日志。 - 在最上层时,使用
%+v打印,这样可以同时打印错误消息和错误堆栈,方便排查错误日志。
注,如果想采用格式化字符串作为错误消息,可将方法分别替换成:
errors.New→errors.Errorferrors.Wrap→errors.Wrapferrors.WithMessage→errors.WithMessagef




