前言
在 4 月 27 日举办的 Gopher China 2019 中,国内 Go 语言专家,Bilibili 架构师毛剑进行了题为《 Go 业务基础库之 Error & Context 》的演讲,主要探讨两个问题:
- 在业务的基础库中,经常需要针对异常进行处理;
这次分享针对业务逻辑的异常处理,异常日志记录,异常信息关联,
业务错误码,以及基于Go,error的特点如何来使用解决这类问题;
- 在Go引入context以后,我们如何改造自己的基础库。
利用context上下文解决元数据传递,超时传递,
在启动新的goroutine时候,如何保证上下文传递到位。
本文是他演讲的第一部分——Error 篇,以下为演讲实录。
毛剑:我之前讲过的一些 Topic 偏整体架构,比如微服务。这次讲的会比较详细一点。因为在整个微服务体系下,很多框架理念大家熟悉了,我也讲很多次了,所以想尝试一下讲比较细节的点,比如说 Go 里面很细节的点,或者在我们做业务的一些基础库或者公共库有一些什么需要注意的。我在听国外讲师分享的时候,发现他们非常非常细节的,比如说可能就讲讲 Go Test。
这次我大概会分两块 Go 里面两个重点,一个是 Error,一个是 Context。
No.1
Error
Error 有几个点是让我们日常开发觉得比较麻烦的。首先是错误检查和打印,其次是业务的错误码设计。我们经常在代码里面到处都是 return error 这个非常麻烦,每个地方都要处理。这个有利有弊。我自己理解的是每一行代码或者每一个函数调用结束以后,应该对它负责,所以尽早处理掉函数的错误也好,异常也好。
对于一个程序来说,这种错误我们应该进行保护,比如说你的程序因为遇到一些问题让你业务退出了,这个对于业务是有影响的。对于一个函数我们要尽快处理,对于一个程序我们要做保护编程。
首先业务开发会分层,Dao 或者 Service,然后开发同学在各个层都打日志,一个业务上线以后,有可能打几十个错误日志,这些日志散落在系统里面,你要查的时候要把整个上下文关联起来看非常麻烦。因为时间顺序是错乱的,非常难找。
其次即便看到日志以后还要猜,到底是哪一个地方代码出的问题,如果偏底层抛出来的错误会非常麻烦定位。还有根因的丢失,有一些错误如果要包装,要附带一些消息,原始的 error 就不见了。我们可能需要基于原始的 error 做等值的判断,就会比较麻烦。
另外业务里面 API 肯定有错误码,100、200,返回-001都有可能,客户端同学要给予这些错误做逻辑调整。API里面的错误 HINT 分两种,一种是面向终端用户,他可能想看到的是更友好的一些错误提示,而不是偏程序的消息展示。另外一种包含一些附带逻辑处理的数据(比如失败后的Retry策略等)。
针对这几个问题,我聊聊自己怎么考虑和解决的。
Handle Erreo-错误检查和打印
1. 追加上下文
首先上下文 error 堆栈不方便找,或者不方便定位,我当时找到一个库,找到 pkg/errors 这个库,这是 Dave 开发的,就是把上下文记录下来存到一个地方,之后就能够还原原始的 error,以及整个 error 堆栈,非常方便。
有一些错误,很典型的例子是一个文件打开报错了,或者是读他报错,
我想把具体的文件名一块儿带过去,如果报一个readfailed我根本不知道什么是东西报错了,如果把文件名信息或者原始error带过去。pkg/errors具体实现相对来说比较简单,我们看一下这张图:
比如说WithStack,这里就是原始error传递,,把这个error记录起来,内部使用一个withStack结构体,把堆栈信息存到error字段,这样返回另外一个error抛出去,上层一层一层传递就有信息了。
2. 根因追踪
第二个处理根因。这里提一个术语叫sentinel errors ,这个是什么东西呢?就是定义了一个包级别的变量。比如io.EOF= errros.New("eof"),基础库这样代码不少。我们编程过程中不是非常推荐使用这种方式来,因为定义非常多包级别的错误,会导致包API面积会变大,这些在Dave文章也提到了。类似IO.EOF,或者Syscall.ENOENT都是属于这种方式。但是我们通过withStack包装以后,是跟之前的error就不一样了,我们通过Cause的方法可以拿到原始error,实现非常简单:
首先定义了一个内部私有的Interface的方法。具体怎么返回呢?当找到第一个不是实现这个接口的Error就返回。如果有人故意实现了这个Cause就返回它,如果没有就一直找到第一个我就退出。这个非常好理解。
Best Pratice-错误检查和打印
通过两种方式,两年前我们内部的基础库为后续完整记录调用栈,进行全仓支持pkg/errors的使用。
有一些原则:
首先在业务基础库,以前使用标准库的errors,现在使用pkg/errors的New/Errorf可以返回,这个时候把当前的堆栈上下文已经保留了。
第二如果我调用的是我自己业务基础库里面的来自其他库的一个返回,我就不再二次处理直接透传,直接往上抛。比如说调了bpackage的方法,他返回一个error,这个时候直接往上抛,我不进行包装了(WithStack)。因为第一个人进行了WithStack或者Errorf/New包调了以后,已经把堆栈保存了,没有必要保存第二次,所以来自同包内的方法返回我就退出,因为可能被处理过。
第三当我们和Go的标准库或者第三方库交互的时候,我们需要WithStack把错误记录下来,我就知道是第三方库某个地方报了错。这里有一个小问题,如果第三方库也包了error异常的时候,其实会比较麻烦。
第四指当把这个错误抛给调用者的时候,我们以前的做法是每一个地方每一个层打日志,大家自己看一下实际业务很多人喜欢每个地方报一个错,记一条日志,记参数,每一个地方都有。而我们HTTP框架,在这个框架日志统一打印,这个相对来说是比较好的。我们看一下在HTTP网关代码我们会默认把这个Log的Middleware注入进去。在顶端打日志,不要每个地方打。这里面提稍微小一点,对于标准库返回的,像sql.ErrNoRows这种,建议不包的,如果包了就是破坏了以前业务代码,会导致判断不成立,因为我们不可能要求所有业务都用Cause方法还原根因再判断,这个对以前的有破坏。所以我们内部有一些小约定,对于特别特殊的Error我们不包的。对于其他错误,比某个网络的一些错误,觉得不需要处理就包装,再往上面抛。
我们再看一下实际做业务过程当中,比如做HTTP网关,我们有很多业务逻辑要并行调很多的服务。因为面向用户API,用户API面向的是用户场景,一个用户场景有很多很多各种各样的数据源组成。这些数据源一定涉及多个服务和多个RPC组成。我们发现Go的sub标准库里面有一个非常好的errorgroup的包,可以很方便用它做并行的调用,在代码结尾可以调Wait方法可以获得第一个异常。
比如说上述四个请求,可以获取第一个产生异常。如果你想在上述四个请求中,想忽略这些错误做一些降级,可以把Error覆盖nil返回。
这个库在用的过程中,我们还是要做一些加工。我们当时做了什么处理呢?我们先看一下原来的一个白色的图就是WithContext、Go、Wait的用法。
我发现几个问题:第一经常有同学用错返回的Context,我们看一下这个WithContext会返回Context,我发现有同学把Context不小心把后面业务代码里面继续传递,经常有同学说线上总收到Context Cancel这种问题会出现。
第二扇出请求没有控制。我发现有一些同学写的代码,本来应该是聚合的发的请求,被写成for循环,并行发出很多次请求。比如说只是一个QPS,对内放大是一百一千,这种情况下导致瞬时的Goroutine长了很多,内存也长了很多,这是不好的,我们不希望这种行为过多产生,所以我们提供另外一个功能就是控制扇出大小的能力。
第三写代码有各种各样的原因panic,尤其业务是容易panic的。panic一定要做保护。如果你是直接使用errgroup库,很有可能你没有做保护导致最后退出。所以我们做了改动:
第一加了GOMAXPROCS,可以限制这个errorgroup最多并行开多少个Goroutine;
第二我们不再返回Context,我觉得大部分情况没有人会用,所以我们不用返回他,我们直接返回使用Group。我们对他进行了整改,避免在我们业务开发过程中避免犯的一些错误。
我再提一下,坚持第一时间和现场处理error。在程序部署以后程序尽可能恢复异常,避免程序终止。
Handle Erreo-业务错误处理
我们上面主要讲了怎么处理异常,这个面向偏业务内部的一些基础库的做法。实际在业务逻辑里面他的一些业务错误码这种东西怎么处理呢?
我们知道error实现了Error的方法,可以改造它。这种改造方法叫ErrorType,可以实现接口可以自己定一个类型,所以我们也是一样的思路,实现了Error方法的ErrorType,我们内部命名为Codes(错误码的意思)。
第一个是Error接口,第二是获取到底报什么错误Code。第三是Message这个是指面向开发者和程序员的错误,就是请求参数错误之类的。还有就是Details,你有一些业务,比如说我被限流了,限流以后可能要返回,多久以后再重试,重试几次,这样的Data我们叫Detail。
同时在Codes包里提供几个包级别的方法,第一就是Cause,用于还原底层error转换成Codes,方便业务使用。
第二个以前代码里面很多同学会和特定的error进行Equal操作(结构体判断),判断是不是这个错误码,这个非常不安全,因为有可能实现了Cause的结构体可能是两种不同东西,最终判断两个东西是不是相等,是用Code的int(具体错误值)。所以我们高级别提供了Equal的方法,判断两个Error到底错误码是不是相等的,这是业务过程中我们想这些解决问题。
通过定义这样的errorinterface,我们抽象两种模型,一种是固定模型的错误码,例如用户没有找到,或者什么东西没有权限,这是比较固定的跟逻辑相关的错误码。这种我们一般约定变量名,然后返回统一错误的Message,这个Message可以在云端下发,这个11001是什么样的注释,可能文本从云端下发,这是固定模型的错误类型。
还有一种是自定义错误码类型。我们叫Status就是状态。为什么叫Status也是有原因,最早想把Status跟HTTP状态码一一适配,包括跟gRPC的状态一一适配,所以这样取名叫Status。我们看到有一个repeated字段,这个用于上面我们提到的错误详情,通过定义这个PB以后,生成HTTP状态码或者其他状态码都可以。
最终我们怎么做的呢?
有一些定义好的错误码,消息内容比如说是%s,要方便使用者自定义。所以我们参考标准库,提供了Error和Errorf,这个地方传的是Code,因为一定带Code码,而你具体自定义的是Message是什么,或者用format的方式传入进去。
咱们在gRPC内部还要处理这种错误,我们目前的做法不在gRPC的Message定义ErrorCode,ErrorMessage,发现非常麻烦,需要在每个Message强行带上这个东西,而且有嵌套的Message,发现每个地方都会冗余很多。所以我们没有把Code和Message这个东西定义进去,而是模仿的是gRPStatus挂载进去,因为gRPC有一些Status已经被官方征用。比如说01,但是有一些没有用,第八个(Unknown)就没有用。实际上我们把自定义的Codes挂载进去,在框架里面解出来,报的具体是什么业务Code,我们通过这样一个方式传递。
看一下传递过程,通过定义这样一个Code,传递给gRPCStatus,gRPC通过网络传给Server,解开就知道是什么错误码。这个拿到以后实际在写业务代码中,不会直接给ecode Interface,实际上因为实现了Error方法,所以可以直接返回一个普通的标准库error来做。
Best Pratice-业务错误处理
我们接下来讲一个经常容易讨论的一个话题,如何定义错误码。这个东西内部讨论非常久,也争议很多次。后来我们找了非常有意思的文章,这个是谷歌的一篇文章讲如何设计API,定义了这样一个HTTP。我为什么要提这个事,因为我发现以前我们很多同学在返回JSON的时候,会把错误放到JSON里面,但是实际上有一些错误应该用HTTP状态码,比如说500,504、503,如果放到JSON里面,HTTP接口永远是200,但是对于运维来说计算SLI的时候指标从JSON里面取不方便,我们期望公共错误码尽量收敛在HTTP状态里面返回,这是第一个要提议。
公共状态码还要提一下,我们用户不存在或者是某个物品不存在,视频不存在,等等各种不存在,我们希望这类错误尽可能收敛到一些统一的Code,比如说404,不要定义很多。客户端拿到一个接口会访问多少个各种不同类型的“404”他很麻烦处理,会导致面向错误编程。我们一个接口给对方的时候,返回哪一些错误码一定要能够写出来。
第二我们经常会扯接口错误码要不要统一,其实这个跟行业的几个朋友讨论交流过,我们想全公司所有API错误码唯一很难,要去一个地方统一注册很难。所以我们期望不是那么强烈的方式定义这个错误码,用业务的命名空间。这个参考微软定义蓝屏的错误码。OX1、OX2多少开头的。我们这样定义,正数是业务提示,负数是异常,用命名空间,比如说某一个高位的BIT或者低位的,我们通过这种方式区分不同的业务。
我们做微服务,会A调B,B调C,每一层调错误的时候,到底怎么处理的?到底从C传给B,B再透传给A,A传给用户呢?这种方法也不太好,因为最终A暴露给客户端,告诉他返回哪一些错误码,你是讲不清楚,因为调了很多人。在微服务传递错误的时候,应该立即消化并转化,我明确帮助你抛给我哪一些错误我转化掉,并且抛给我上游想知道的错误,如果他对这个错误不敏感或者不想处理,我就包成公共错误码(比如内部错误),这样相对来说比较好,这样我们API定义的错误数量是有限的。
讲完了Error,Error就是两个地方,实际一个是偏业务,一个是偏基础库。
有疑问加站长微信联系(非本文作者)