系统内和系统间的错误处理,贯穿系统整个开发、运行、消亡的生命周期,是代码书写过程中特别需要花心思的一点。一个地方报错了,我是直接返回,还是打印一行日志再返回?嵌套函数的报错,如何找到报错的根本原因?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的,某些interface如果使用这个变量进行method定义,那所有实现该接口的struct都需要识别,处理这种错误,甚至有的方法实现本来是必须要返回其他类型错误,但是因为要实现这个接口,也需要做更多的设计、编码工作,非常不方便。另外一个影响是:因为各个模块都会定义自己的errors变量,导致在使用过程中,这些包之间很容易建立起关联,随着errors变量的增多,很容易造成逻辑上和代码上的循环依赖。
错误文本信息更多的是给人看的,不是给代码看的,但是这种方式在日常使用中还是比较多的(Dave Cheney建议尽量避免,但是也看个人喜好)。
errors断言只定义一个struct实现了error接口,例如:
errors断言比errors变量进步不少,解决了可以携带更多上下文的问题,但是没有解决被作为public api到处引用的问题, 所以,尽量少用、或使用非导出error断言。Dave最推崇的一种方式,名为: Opaque errors。Opaque errors的理念是这样的,作为函数、方法等的调用方,你只能知道本次操作的结果是否ok,但是对于可能发生的错误是不可预期的,所以你应该直接返回(但是返回的过程中应该带上补充的上下文),这样携带上下文的错误就可以在各个caller之间黑盒传递下去。每一层返回的error,我们不关系error的content,但是我们关心对应错误实现了那些行为,概括为:
这个理念其实不是太好理解,实际中用的应该也比较少。我的理解是大概率每一次都返回错误,然后在逻辑层定义一些非导出error,实现对应的behavior,然后再最外层对error的behavior去断言,例如:
以上通过阐述几种错误处理的方式,也其实体现除了errors的行为确实就是一种特殊的变量。对于这几种方式,我感觉大家在了解了对应的优缺点之后,可以有的放矢的去使用,Opaque errors的处理方式给人眼前一亮,很值得大家去尝试,面向行为而不是错误编程。前言里有些问题其实也有答案了,我们不需要在每个错误处打印日志,只需要传递错误的上下文,错误只需要在一个地方,被集中处理。name错误如何进行上下文的传递呢?这就引出了接下来的 pkg/errors
库(Dave不但指明了方向,还做出了实现,茅塞顿开 and 喜出望外,哈哈哈。。)
我们开发中最常使用的方式是: if err!=nil{return err}
这种方式做到了快速返回,由外层统一处理,但是缺乏更加丰富的信息,比如 xx module failed/ xx file open failed
,如果通过 fmt.Errorf
包装,这有可能导致上层在错误判等(Sentinel errors)失败,所以我们需要一种既能保证找到错误源头( error cause
)又能传递每一层上下文的方式,这就是 pkg/errors
这个库为我们做的事情。
代码其实比较简单,以下通过Dave某个ppt中的示例展示Wrap的使用:
通过Cause可以获取到报错源头。如果我们需要根据错误源头做出不同处理时,需要使用Cause,实例如下:
以上主要通过davey的几篇文章和自己的一些理解总结了go中处理错误的几种方式和利弊权衡,总结如下:
上半部分主要讲了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之间互相转换。
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进行错误处理。所以错误定义在外部应该是比较合适的。
定义良好的错误码可以很方便的通过错误码定位到报错的系统。
错误文案更加靠近用户,我们肯定不希望自己的用户在APP上看到 127.0.0.1:8000 i/o timeout
的错误。同时用户请求某个接口,这个接口应该是最终处理错误,决定行为的位置,所以错误码肯定需要转义为用户能接受的信息。错误文案内容、模板也会经常发生变化,所以一套统一的文案配置系统还是必须的,获取文案的依据可以是上述业务定义的标准错误码,或者是文案系统自己条件的一条key-content映射,设计上会比较简单,这里就不过多展开了。
设计良好的错误处理体系,能够清晰的展现系统内部错误发生的链路、降低系统间发生错误时的沟通成本、在排查线上问题时也能够快速定位到错误原因。以上通过系统内、系统间错误处理两部分讲述了我对错误处理的一些思考,由于篇幅的原因,有些点比如错误码和Status之间的封装、增加易用性,面向行为断言的实际例子等就不再做展开了,感觉只要能大致做到系统内、系统间错误串联就能达到一个比较理想的效果了。