Here's to Change

请问你真的有在努力吗 ?

0%

微服务环境下 Go 的错误处理体系设计

前言

刚开始接触 Go 的时候,被 Go 这种可以直接返回多个变量的设计惊呆了,而且错误也是作为变量与函数结果一并返回。在此之前接触的都是 Java 或者 Kotlin 这种只有一个返回值的语言,而这两种语言本身的错误处理机制都是在函数签名上抛出一个异常,由方法调用方决定是否要处理该异常。Java 中所有的异常都继承自 java.lang.Throwable 接口,然后又分为 Error 和 Exception 两个分支,其中 Error 表示不该由程序处理的错误,通常由虚拟机抛出;而 Exception 又继续分为检查异常和不受检查异常。检查异常必须显式处理,否则编译器无法编译通过,不受检查异常就可以选择性是否进行处理。

基本上 Java 中遇到 Error 或者未经捕获的 Exception ,程序肯定会退出了。这种会直接导致程序退出的错误,我们将其定义为不可恢复错误。其他不会导致程序退出的错误(包含被处理的错误),我们将其定义为可恢复错误。换到 Go 语言中,虽然只有 error 一种表示,但其仅代表可恢复异常。因为在 Go 中,即使你不对 error 作任何处理,也不会导致程序异常停止。能够导致程序停止,不可恢复的错误,全部由 panic 表示。当然为了保证系统稳定性,也可以用 recover 在可能出现 panic 的地方进行捕获。

在我看来,对于不可恢复错误也就是 panic 的处理比较简单,因为这代表一种无路可走的情况,能做的就是破罐子破摔,把错误的信息一股脑的抛出、打印和收集,能够作为排错的依据即可。为了确保生产环境系统的稳定性,可以在生产环境开启对 panic 的捕获,但我建议你在非生产环境关闭对 panic 的捕获,这样可以尽早发现和暴露问题,及早修复,降低生产环境事故率。根据环境决定是否捕获 panic 的方式,可以简单地根据环境变量来判断。

可恢复错误的处理

在前言里我们说过,Go 语言中,即使不对函数返回的 error 作任何处理,也不会导致程序退出,但业务流程受阻是肯定的。函数的调用方可以按照自己的需求和条件,决定接下来的执行逻辑,或中断执行业务,将错误继续返回,或检查自己的执行流程,或者进入备选的流程等。

根据实践,可以将可恢复错误分成这么几类:

  • 前置检查失败
    • 主要指参数未按照约定提供,例如参数不可空校验失败,参数值范围不正确等,这属于调用方的 bug
  • 程序错误
    • 例如 req.(sometype) 进行类型转换,程序运行起来后发现转换失败,这属于程序自身的 bug
  • 依赖服务调用错误
    • 例如数据库查询时出现了错误,往往都是依赖的某个服务出现了错误,这是最经常处理的错误之一
  • 业务执行错误
    • 例如在发送验证码时发现发送频率超过了阀值,那么这属于特定业务的错误

前两类错误属于开发阶段会出现的错误,换句话说,在开发阶段就应该找出尽可能多的这两类错误,这也是绝大多数我们通常所说的 bug,要极力避免其出现在生产环境。所以对于这两类的错误,最好的处理方式就是显式地抛出,及早修复,具体的抛出方式下面会讲到。

1. 错误应该包含的信息

错误最重要的,还是其包含的错误信息,我们要明确的是,错误信息是给人类阅读,更准确得说是给开发者阅读的。所以 Go 语言 error 接口的 Error() 方法直接返回一个表示错误信息的 string。为什么不直接返回一个 string,而是要包装成 error 返回呢?因为如果直接返回了 string,我们该如何识别这是正常的返回值,还是一个错误呢?从这里其实也可以看出,error 在 Go 语言中也只是一个普通的变量,这也是为什么即使不对 error 进行处理,也不会导致程序异常退出的原因。这样做还有一个好处,根据 Go 语言的接口规则,只要实现了 Error() 接口,那么就可以将其作为 error 返回,这提供了方便对 error 进行扩展的途径,我们可以通过自定义结构体来丰富 error 中包含的信息,有助于开发者高效率定位错误源头和原因。

话说回来,Go 语言标准包的 errors 包,仅有一个简单的 New() [^1] 方法。如果只有错误的文本,基本很难定位到出错的位置,尽管可以通过全局搜索的方式来定位,但毕竟信息有限,而且不是所有 error 都是在我们的项目中抛出的,会有很多错误无法定位。因此,我们需要将出错的调用栈信息附加在错误里。调用栈信息对调用方没有意义,也不该将这些信息暴露给调用方,调用方唯一需要关心的是错误的原因和错误类型。调用栈信息对于实现者才是有价值的,因此我们要将错误的文本返回给调用方,同时自身也要收集调用栈信息。这里就要推荐有名的 github/pkg/errors 包了,通过 errors.WithStack(err)errors.Wrap(err,"custom message") 把此时的调用栈信息附加进去,然后在某一个地方统一记录日志,方便开发者快速定位问题。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import "github.com/pkg/errors"

// NewUserCtxWithID 开始一个新的聚合根
func NewUserCtxWithID(userID int32, repo facade.UserRepository) (*UserCtx, error) {
if userID <= 0 {
// 这里属于前置检查失败,所以直接返回错误文本即可
return nil, errors.New("parameter userID invalid")
}
owner, err := repo.GetUserByUserID(userID)
if err != nil {
// 这里查找用户失败,包裹原错误返回
return nil, errors.Wrapf(err, "user %d not found",userID)
}
return &UserCtx{user: ToUserDO(owner), repo: repo}, nil
}

因为这里的调用属于同一个系统的内部调用,所以错误的消费方还是开发者,需要暴露出错误栈信息,这无可厚非,但是这些并不足以给调用方提供错误类型的判断依据,这在调用方需要根据不同的错误类型执行不同的分支逻辑的时候尤为重要。因此,需要通过自定义错误类型的方式提供错误判断依据。

2. 特定错误类型

上一小节提到, Go 中自定义错误类型非常简单,只需要实现 Error() 方法即可,但这样的做法有点重,我们可以直接通过标准包的 errors.New(msg) 来定义一个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var (
ErrFeedNotExist = errors.New("feed not exist")
ErrFeedPermisssionDenied = errors.New("feed permission denied")
)

func GetFeed(userID,feedID int32,repo facade.FeedRepositroy)(*Feed,error){
feed,err := repo.GetFeed(feedID)
if err!=nil{
return nil, ErrFeedNotExist
}
if !checkHasPermission(userID,feed){
return nil, ErrFeedPermissionDenied
}
return feed,nil
}

这样当调用方拿到返回值就可以对错误进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func GetFeed(ctx context.Context, req *feed.GetFeedRequest)(*feed.GetFeedResponse, error){
feed,err := app.GetFeed(req.GetUserID(),req.GetFeedID())
if err!=nil{
switch err{
case ErrFeedNotExist:
// handle with err
case ErrFeedPermisssionDenied:
// another logic flow
default:
// unknow err, return
}
}
return &feed.GetFeedResponse{
Feed:feed,
}, nil
}

以上这种方式仅限同一个微服务内,甚至限于同一个领域服务的同一个层(针对领域驱动设计),因为不同服务之间序列化会导致信息丢失,不同模块之间会导致产生依赖。虽然有一些第三方包也会采用这种方式来定义特定错误,但这样会明显导致公共库与调用方代码产生依赖,需要慎重考虑是否使用。除了自定义全局 Err 变量,自定义 Error 类型也同样有这个问题。

这样的话问题就来了,不能定义全局错误,自定义错误类型也不好,还能有什么办法判断错误类型吗?有,我们可以判断错误的行为。

3. 断言错误的行为

我们可以利用 Go 中的接口来做文章,这里我们通过自定义错误类型 (仅包内可见) 来实现。假定模块 A 依赖于模块 B ,A 需要判断是否因为权限不够导致失败,那么可以先定义一个 A 和 B 都可见的接口:

1
2
3
4
package c
type ErrPermissionDenyed interface{
PermissionDenied()
}

然后在 B 中定义结构体 errPermissionDenied:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package b

type errPermissionDenied struct{}

func (*errPermissionDinied) PermissionDenied(){
//nothing
}

func (*errPermissionDenied) Error() string{
return "permission denied"
}

func GetFeed(userID,feedID int32,repo facade.FeedRepositroy)(*Feed,error){
feed,err := repo.GetFeed(feedID)
if !checkHasPermission(userID,feed){
return nil, &ErrFeedPermissionDenied{}
}
return feed,nil
}

因为首字母小写,对包外不可见,避免了直接引用导致的依赖。
这样只需要在 A 中判断返回的错误是否实现了指定的接口来决定后续的逻辑走向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func GetFeed(ctx context.Context, req *feed.GetFeedRequest)(*feed.GetFeedResponse, error){
feed,err := app.GetFeed(req.GetUserID(),req.GetFeedID())
if err!=nil{
if ok:= err.(ErrPermissionDenied);ok {
// another logic flow
return ...
}
// default err handle
return nil,err
}
return &feed.GetFeedResponse{
Feed:feed,
}, nil
}

这样的好处在于,上层不需要引入第三方 package 中的类型就能知道错误的类型,只关心返回的 error 是否实现了特定行为即可。如果要替换第三方 package,只要修改与第三方 package 直接打交道的这一层即可。

但这也仅适用于某一个微服务内,跨服务的场景这种方式就无能为力了。

4. 错误码

如果以单个微服务划分边界,上面所讲到的都是边界内的调用,而边界与边界之间最好的错误传递方式应该就是错误码了。客户端调用远程的 Restful 服务就属于边界与边界之间的调用,我们也经常看到错误码的文档。微服务之间当然也可以通过 rest 调用,识别错误码,但更多的是采用 rpc 的方式进行跨边界的调用,尤其 grpc 更是 rpc 框架中的利器,所以我们首选就是通过 grpc 的错误码机制来跨边界传递错误。

与 rest 远程调用返回

1
2
3
4
5
{ 
"code":200,
"msg":"ok",
"data":{}
}

这样的结构不同,grpc 的调用与一般的方法调用并没有差异,也是返回 response, error 两个值,我们当然可以在每一个 response 中都加上 code, msg 两个字段,但是这样不够优雅,也会导致 rpc 接口上的 error 形同虚设,因此还是需要在 error 里做做文章。

rpc 框架因为序列化的关系,不如进程内调用简单直白,为了保证顺利序列化的同时信息不丢失,一般都会将错误信息打包成特定结构,所以我们要借助这个特定结构来传递错误信息。grpc 中 status 就是用于序列化 rpc 错误的特殊结构体。 status 包是对 Google.Golang.org/genproto/Googleapis/rpc/status 下 Status 的引用,其中包含 Code、Message 和 Details 3 个字段,我们可以实现对错误信息的传递。

status 包同时提供了开箱即用的转换的函数:

  • func status.FromError(err)(s *Status, ok bool)
    • 尝试将 error 转换为 Status 结构体,从而获取错误码和 message
  • func status.Error(c codes.Code,msg string) error
    • 直接将 code 和 msg 打包成 error

有了以上两个函数,可以轻松地在微服务之间传递错误详情,服务提供方通过 Error() 函数打包 error,服务调用方通过 FromError() 解析 error。

5. 网关服务的处理

我们的业务使用 GraphQL 作为业务网关,因此错误的返回需要能够匹配 GraphQL 的固定格式。GraphQL 的接口固定响应格式为:

1
2
3
4
5
6
7
8
9
10
{
"data":{},
"errors":[
{
"message":"",
"path":"",
"extensions":{}
}
]
}

errors 下面的 extensions 是一个可自定义的 json 对象,通常的做法是把错误码放进 extensions 中,作为客户端判断错误码类型的依据。

看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (s *AccountServer) CreateGroup(ctx context.Context, in *account.CreateGroupRequest) (*account.GetGroupResponse, error) {
cateGory := factory.ToCateGoryDO(in.CateGory)
if len(in.GetRoleName())==0{
return nil,errs.GenRPCErr(code.Code_INVALID_ROLE_NAME)
}
groupDO, err := appSrv.CreateGroup(...)
if err != nil {
return nil, err
}
return &account.GetGroupResponse{
Group: factory.ToGroupDTO(groupDO),
}, nil
}

上面是服务 F 的某一段程序,在检查传入的 roleName 不符合要求时,返回一个带有错误码错误,这里实际上就是一个 status 结构体,然后会通过 grpc 传输到网关服务 G :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (r *mutationResolver) CreateGroup(ctx context.Context, input generated.CreateGroupInput) (*accDO.Group, error) {
userID := ctx.Value(common.KeyUserID).(int32)
group,err := service.Account.CreateGroup(userID, input)
if err!=nil{
if status,ok := status.FromError(err);ok {
graphql.AddError(ctx, &gqlerror.Error{
Message: status.Message(),
Extensions: map[string]interface{}{
"code": uint32(status.Code()),
},
})
return nil,nil
}
return nil,err
}
return group,nil
}

上面这段程序是 graphql 网关服务的某一个接口对错误的处理,我们首先会判断错误是否为 status 结构体,并将该结构体解析为适合 graphql 的错误形式返回,否则正常返回。

这个流程虽然没有问题,但是在每个 graphql 接口都要这么写也太麻烦了,看下 gqlgen 的文档,发现可以统一对 graphql 接受到的错误进行处理,类似于拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
// HandleGraphErr 统一转换错误为适配 graphql 的 error
func HandleGraphErr(ctx context.Context, err error) *gqlerror.Error {
if status, ok := status.FromError(err); ok {
return &gqlerror.Error{
Path: graphql.GetFieldContext(ctx).Path(),
Message: status.Message(),
Extensions: map[string]interface{}{
"code": uint32(status.Code()),
},
}
}
return graphql.DefaultErrorPresenter(ctx, err)
}

上面这段程序的作用是统一处理接受到的错误,优先识别 status 结构体错误,然后包装为适合 graphql 的错误,否则使用默认的错误处理直接返回。

然后在启动 graphql 服务的时候把这个处理函数设置一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func PrivateGraphqlHandler() gin.HandlerFunc {
defer common.Catch()

h := handler.NewDefaultServer(
privateGraphGenerated.NewExecutableSchema(
privateGraphGenerated.Config{Resolvers: &privateGraph.Resolver{}}))

h.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
return errs.HandleGraphErr(ctx, err)
})

return func(c *gin.Context) {
h.ServeHTTP(c.Writer, c.Request)
}
}

然后在具体的 graphql 接口上就非常简单了:

1
2
3
4
func (r *mutationResolver) CreateGroup(ctx context.Context, input generated.CreateGroupInput) (*accDO.Group, error) {
userID := ctx.Value(common.KeyUserID).(int32)
return service.Account.CreateGroup(userID, input)
}

当出现这个错误的时候,客户端就会得到如下错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"errors": [
{
"message": "INVALID_ROLENAME",
"path": [
"createGroup"
],
"extensions": {
"code": 110103
}
}
],
"data": null
}

6. 对用户友好的错误提示

在上面的错误码中,我们定义了很多错误,但是这些错误在返回到客户端的时候,仍然是开发者视角的错误信息。不管是错误码还是 “INVALID_ROLENAME” 这样的错误提示,对于用户都不够友好。客户端的错误提示要让用户能够理解,还需要隐藏一些用户不必知道的信息,同时考虑到针对同一个错误的提示内容和模版都有改变的可能,因此需要一个统一的文案配置系统,针对每个错误码进行翻译,甚至可以保留多个语言的翻译版本,对于国际化应用也能够方便一些。最简单的方式我觉得还是依托于 proto 错误码文件进行翻译,然后直接根据语言、错误码查找对应的提示内容即可。

不过除了生产环境,别的环境的应用都是我们开发自己在使用,所以有时候暴露一些调试信息能够加快定位问题的速度,所以我建议客户端在非生产环境也可以把报错信息完整打印出来到用户界面,通常都可以通过 logLevel 或环境变量来实现,不再赘述。

总结

在微服务开发大行其道的当下,一个错误从产生到被消费,会经过很多系统(服务),如果没有合理得对错误进行处理,会大大得增加各个系统之间沟通、调试的成本。很多像我一样刚进入服务端开发领域的小白,都会一股脑地将 error 直接返回,将业务错误、运行时错误、程序错误当成同样的 error 来处理,主要还是没有经历过在代码的海洋中艰难 debug 的窘态。一个设计良好的错误处理机制,不仅能够清晰展现系统内部错误发生的链路,加快 debug 速度,同时还能促进系统设计的合理性和可用性。

这篇文章虽然是基于 Go 语言,但实际上很多想法和思考都可以使用于其他大部分语言,也希望这些想法对你能有所启发。

注脚

[^1]: 截止本文发布时间(2020-08-14),Go 版本 1.14.2,errors 包中已经新增加了 UnWrapAsIs 等方法;

参考

  1. 微服务错误处理的一些思考
  2. 如何优雅的在 Golang 中进行错误处理
  3. 99designs/gqlgen
  4. pkg/errors