前言
系统内和系统间的错误处理,贯穿系统整个开发、运行、消亡的生命周期,是代码书写过程中特别需要花心思的一点。一个地方报错了,我是直接返回,还是打印一行日志再返回?嵌套函数的报错,如何找到报错的根本原因?http或rpc接口中的错误码应该定义在每个response结构体内还是说通过http code、rpc error统一返回?本文会从系统内、系统间两个方面去阐述错误的定义、处理方式及相关的缘由。由于我平时主要使用go进行开发,系统内错误处理更多是从go角度出发。
系统内错误处理
"Go Proverbs”
复制代码
"Go圣经"中关于错误处理的有两条: 1.errors仅仅是变量;2.不要只是检查,更要平滑地处理errors。这两条其实既概括了我们平时处理errors的几种方式,又给出了处理错误的最佳方式(如果能做到的话...)。
定义错误变量
初次接触golang errors时,我其实感觉这种错误处理方式还是蛮好的,有一个变量让我去明确我犯了什么错,多明确、直接,并且标准库、三方库里也有很多类似的例子,io.EOF、sql.ErrNoRows等。但是,这种使用方式也有缺点。缺点一: 不能够包装错误信息
本来某个函数返回io.EOF,但是业务系统中往往会通过fmt.Errorf("xx文件: %v", err),这样在最外层直接导致判定失败。
缺点二: errors变量成了public api
因为errors变量需要到处使用,肯定是public的,某些interface如果使用这个变量进行method定义,那所有实现该接口的struct都需要识别,处理这种错误,甚至有的方法实现本来是必须要返回其他类型错误,但是因为要实现这个接口,也需要做更多的设计、编码工作,非常不方便。另外一个影响是:因为各个模块都会定义自己的errors变量,导致在使用过程中,这些包之间很容易建立起关联,随着errors变量的增多,很容易造成逻辑上和代码上的循环依赖。
所以,尽量少用、不用errors变量。
使用errors的文本信息
错误文本信息更多的是给人看的,不是给代码看的,但是这种方式在日常使用中还是比较多的(Dave Cheney建议尽量避免,但是也看个人喜好)。
使用errors断言
errors断言只定义一个struct实现了error接口,例如:
errors断言比errors变量进步不少,解决了可以携带更多上下文的问题,但是没有解决被作为public api到处引用的问题,所以,尽量少用、或使用非导出error断言。黑盒传递错误,通过断言行为处理错误(且只处理一次错误)
Dave最推崇的一种方式,名为: Opaque errors。Opaque errors的理念是这样的,作为函数、方法等的调用方,你只能知道本次操作的结果是否ok,但是对于可能发生的错误是不可预期的,所以你应该直接返回(但是返回的过程中应该带上补充的上下文),这样携带上下文的错误就可以在各个caller之间黑盒传递下去。每一层返回的error,我们不关系error的content,但是我们关心对应错误实现了那些行为,概括为:
这个理念其实不是太好理解,实际中用的应该也比较少。我的理解是大概是每一次都返回错误,然后在逻辑层定义一些非导出error,实现对应的behavior,然后在最外层对error的behavior去断言,例如:Errors are just values
以上通过阐述几种错误处理的方式,也其实体现出errors确实就是一种特殊的变量。对于这几种方式,我感觉大家在了解了对应的优缺点之后,可以有的放矢地去使用,Opaque errors的处理方式给人眼前一亮,很值得大家去尝试,面向行为而不是错误编程。前言里有些问题其实也有答案了,我们不需要在每个错误处打印日志,只需要传递错误的上下文,错误只需要在一个地方,被集中处理。那么错误如何进行上下文的传递呢?这就引出了接下来的pkg/errors
库(Dave不但指明了方向,还做出了实现,茅塞顿开 and 喜出望外,哈哈哈。。)
在错误中传递上下文
我们开发中最常使用的方式是: if err!=nil{return err}
这种方式做到了快速返回,由外层统一处理,但是缺乏更加丰富的信息,比如xx module failed/ xx file open failed
,如果通过fmt.Errorf
包装,这有可能导致上层在错误判等(Sentinel errors)失败,所以我们需要一种既能保证找到错误源头(error cause
)又能传递每一层上下文的方式,这就是pkg/errors
这个库为我们做的事情。
Wrap进行error包装,传递上下文
代码其实比较简单,以下通过Dave某些示例素材展示Wrap的使用:
- 读取文件(Wrapper One)
- 调用读取文件函数(Wrapper Two)
- 可能的报错结果
- 通过errors.Print获取调用栈
Cause进行error解包,获取报错源头
通过Cause可以获取到报错源头。如果我们需要根据错误源头做出不同处理时,需要使用Cause,示例如下:
系统内错误处理小结
以上主要通过davey的几篇文章和自己的一些理解总结了go中处理错误的几种方式和利弊权衡,总结如下:
- 最小化error变量、error断言、error内容的使用(可以使用,但是要注意权衡)。
- 把错误看做过程中不可见的特殊变量,尽量断言它的行为而不是类型。
- error应该只被处理一次,处理error的过程应该是根据error内容,决定不同行为。
- 通过Wrap包装error上下文,通过Cause获取error源头。
- 圣经仅仅是个故事,不是规范,具体还是根据自己的情况来。
系统间错误处理
上半部分主要讲了go系统内错误应该如何定义、传递、处理,下半部分主要分析系统之间的错误定义、传递。我们在处理http、rpc请求的时候也会有疑问,http code是不是应该一直传200,然后通过自定义结构体传递错误码呢?rpc之间的错误应该怎么传递,网络错误是不是应该和业务错误通过同一结构体传递传递呢?全公司的错误码是不是应该统一呢?APP的错误文案是不是需要在一个系统集中配置呢?
错误定义在外部
在thrift服务中,我们经常会这样定义应答:
struct DeleteProductRes {
1: optional DeleteProductData data
1000: optional ThriftUtil.ErrInfo errinfo
}
复制代码
其中errinfo包含了错误码和错误信息,每一个结构体都是类似的表现形式。这样造成的问题是,在框架层很难统计到业务系统SLI,SLI包含系统的可用性、质量等。举个例子,A调用B,B调用C,B和C之间因为C负载过高触发了熔断,这时候B返回给A的熔断信息都包含在了errinfo里,但是,这时候A的SLA其实是受到影响的,我们却没有方法及时、可视地让对应负责人看到,所以这里的errinfo应该提到最外层。相对照的,以下是grpc的结构定义和错误处理:
message QueryChangeResponse {
message Item{
string service_name = 1;
}
message Data{
repeated Item items= 1;
}
Data data = 1;
}
rpc QueryChange(QueryChangeRequest) returns (QueryChangeResponse);
复制代码
grpc的idl中不包含错误信息的定义,但是grpc的client和server之间原生自带Status并且可以和标准error之间互相转换。
- protobuf定义(grpc自包含,无需自定义实现):
package google.rpc;
message Status {
// A simple error code that can be easily handled by the client. The
// actual error code is defined by `google.rpc.Code`.
// 一个可以被客户端处理的编码值
int32 code = 1;
// A developer-facing human-readable error message in English. It should
// both explain the error and offer an actionable resolution to it.
// debug使用,报错的具体原因
string message = 2;
// Additional error information that the client code can use to handle
// the error, such as retry delay or a help link.
// 附加错误信息,比如是否重试、重试策略、报错帮助链接等
repeated google.protobuf.Any details = 3;
}
复制代码
- 服务端的错误赋值:
// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.GetName())
// 有报错
return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
// 无报错,请求成功
return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}
复制代码
- 客户端的处理逻辑:
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
s,ok := status.FromError(err)
if ok{// 可转为Status
log.Println(s.Code())
log.Println(s.Message())
log.Println(s.Details())
}else{// 普通error
}
}else{
// 无报错,请求成功
log.Printf("Greeting: %s", r.GetMessage())
}
复制代码
- 拦截器:
// server rpc cost, record to log and prometheus
func monitorServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
resp, err = handler(ctx, req)
框架层面的各种通用处理...
return resp, err
}
复制代码
框架层可以利用拦截器很方便的拿到err(Status)信息,进行统一的处理,这些信息可以用来监控、报警、评估系统SLA等等。http通过对http code赋予不同的数值,也可以达到类似的效果(并且可以通过header统一传递类似于grpc Status的信息)。对于业务无关的系统层面的错误,status库也有统一将系统error转化为Status,并保留了cause信息,我们可以很方便的针对Status的code或Message进行错误处理。所以错误定义在外部应该是比较合适的。
错误码设计
定义良好的错误码可以很方便的通过错误码定位到报错的系统。
- 定义 一个比较好的方式是可以按照各个业务线、系统分配一定号段的段位码。例如code为整数,8位长度(1000,0400)前四位代表业务线,后四位可以是业务线自定义的错误码,两者组合成一个完整的错误码。
- 使用 各个业务系统的错误码统一定义在基础库内,方便错误信息共享。我们也可以定义一些公共错误码,类似于400、500等的错误,这类错误的具体信息可以通过Message字段进行展示,框架层面对于这类错误码会比较敏感,进行统一的打点、报警等。
- 推广 错误码其实很难做到公司级别的统一,如果存在不同的语言,想统一定义就更麻烦了,所以对于错误码的定义更多的是一种约定,而不强制。通过规范错误码,降低异常排查时的沟通成本,对应系统也能享受到框架层面带来的好处,我们的想法更多的是通过这些好处吸引大家逐步使用规范化的错误码,不使用也没关系,毕竟不影响业务的正常流程。
错误文案统一配置
错误文案更加靠近用户,我们肯定不希望自己的用户在APP上看到127.0.0.1:8000 i/o timeout
的错误。同时,用户请求某个接口,这个接口应该是最终处理错误,决定行为的位置,所以错误码肯定需要转义为用户能接受的信息。错误文案内容、模板也会经常发生变化,所以一套统一的文案配置系统还是必须的,获取文案的依据可以是上述业务定义的标准错误码,或者是文案系统自己条件的一条key-content映射,设计上会比较简单,这里就不过多展开了。
结尾
设计良好的错误处理体系,能够清晰的展现系统内部错误发生的链路、降低系统间发生错误时的沟通成本、在排查线上问题时也能够快速定位到错误原因。以上通过系统内、系统间错误处理两部分讲述了我对错误处理的一些思考,由于篇幅的原因,有些点比如错误码和Status之间的封装、增加易用性,面向行为断言的实际例子等就不再做展开,感觉只要能大致做到系统内、系统间错误串联就能达到一个比较理想的效果了。
参考
有疑问加站长微信联系(非本文作者)