package
0.0.0-20240826130954-40fd50bc6bd8
Repository: https://github.com/zhao520a1a/go-utils.git
Documentation: pkg.go.dev

# README


type: "docs" weight: 5 title: "错误处理"

错误即 error,在 Go 程序中通常以 err 之名存在;错误处理即 error handling,指我们如何在 Go 程序中合理地处理错误。为了避免歧义,本文剩余内容将直接使用错误的英文单词 error 代替。

关于本话题的详细调研内容请参考这篇博客,本文的重心在于阐述最终选择的最佳实践

希望以此此文建立更加扎实的工程实践方法论,进一步提升交付项目质量。

  • 前端错误信息可读性差
  • 后端服务识别报错困难
if strings.Contains(err.Error(), m) {
    tracelog.Info(ctx, fmt.Sprintf("ignore err :%v", err))
    return 
}

ErrEmptyResult = errors.New(`[scanner]: empty result`)
if err == scanner.ErrEmptyResult {
    err = nil
}

相关术语说明:

英文中文
error-code-based基于错误码
exception-based基于异常
error wrapping/unwrapping包装错误/解包装
error inspection错误检查
error formatting错误格式化
error chain错误链表,即通过包装将错误组织成链表结构
error class错误类别、类型

错误处理方式?

通常错误处理方案分为两种:error-code-based 和 exception-based。很早就有人 指出 exception-based 错误处理更不利于写出优质的代码,也更难辨别优质和劣质的代码; Go 在设计时选择了 error-code-based error handling 方案,许多来自 Java、Python 等语言的工程师习惯了 exception-based 的方案,遇到 Go 时感到十分不习惯,本质原因是不了解语法后面的设计理念。

Go 语法的设计理念?

"happy path" 与 "sad path" 地位相同

如果我们将函数的正常逻辑路径称为 "happy path",异常逻辑路径称为 "sad path"。在使用 exception-based error handling 的编程语言时,工程师认为 "sad path" 是一种需要额外考虑的特殊情况,需要特殊对待;而在 Go 开发者眼里,"happy path" 和 "sad path" 都是一般的情况,二者应该同样重要,被同等对待。

// Go 鼓励工程师将逻辑的 "happy path" 留在函数缩进的最外层,而把 "sad path" 放到第二级缩进中;
// 采用 fail-fast 的策略结束执行,不要使用其它返回值的特征作为调用成功与否的依据
func Do() (ret interface{}, err error) {
    // happy path
    v1, err := A()
    if err != nil {
      // sad path 1
  }

    v2, err := B(v1)
    if err != nil {
      // sad path 2
  }

    ret = process(v1, v2)
    return
}

errors are values

任何实现了 Error 接口的数据类型都是 error,它们与字符串、整数、结构体相比并没有特别之处。Go 官博中提出了Errors are values的理念,鼓励开发者显式地在 error 出现的地方直接处理,任何实现了 Error 接口的数据类型都是 error,它们与字符串、整数、结构体相比并没有特别之处。

都有谁关心 error?

在任意一个服务的生命周期中,通常至少有 3 个角色关心 error:

应用程序与error

error 类型检查 面向的是应用程序,用于逻辑判断;应用程序可能拥有各种各样的外部依赖,比如第三方服务、内部 RPC 服务、数据库服务、消息队列服务等。应用程序需要能够准确、方便、健壮地获取 error 特征,从容地根据 error 的特点处理。

程序维护者与error

遇到线上问题时,服务的维护者接到报警后,需要根据详细的 error 信息做根源分析,这时信息越多越好,当然更高的可读性能够帮助维护人员更快地定位问题,解决问题。 PS:一个设计精良的 errors package 要能够让工程师自如地处理 error 与各个角色之间的信息传递。

用户 与 error

当服务运行遇到 error 时,需要向普通的 C 端用户提供友好、明确的消息提示,让他明白系统正处于异常状态,可以稍后重试或联系客服、技术人员。消息应该是对人类友好的自然语言。除此之外,系统内部的细节,如错误栈信息,不应当直接暴露给 C 端用户,对于未明确定义的 error 更应如此。主要原因在于:

  • 用户不应该关心服务的实现细节
  • 暴露不必要的细节可能会降低系统安全性

error handling 涉及那些方面?

  • checking:判断 error 发生与否
  • inspection:检查 error 类型
  • formatting :打印 error 上下文。

很多大佬针对上面的环节,提出了自己的优化见解,如:Russ Cox 早在 2018 年末发布了两个新提议:

  • Error Handling - 尝试解决 checking 代码冗长的问题;
  • Error Values - 尝试解决 inspection 的信息丢失以及 formatting 的上下文信息不足问题;

error 上下文 != 堆栈信息?

为什么采用堆栈信息不行?这里引用 GopherCon 2019 中工程师的观点,原因如下:

  • 它们很难阅读
  • 它们很难解析
  • 它们说的是哪里出错了,而不是为什么

error 的逻辑调用栈是啥?

Ben Johnson 提出 Failure is your Domain 的观点,认为每个项目应当构建特有的 error handling package,并提出逻辑调用栈 (logical stack) 的概念;针对 error 多层嵌套调用场景下的上下文注入问题,采用 error wrapping 的方式给程序提供额外的信息,用于后续决策。之后我们还能做什么?

  • 按严重性对错误进行分类。
  • 按类型对错误进行分类。
  • 添加特定于应用程序的数据。
  • 查询以上所有内容

方案调研

抄作业,先看看别人咋做的?

官方SDK

  • Go 1.13 以前:提供 Error 接口及 errors.New、fmt.Errorf 两个构建 error 的方法,建议创建一个实现错误接口的自定义结构,并将原始错误作为该结构上的字段,例如:PathError
  • Go 1.13 以后:提供类似的原生解决方案,支持利用 %w 格式化符号实现 error wrapping,并提供 Unwrap、errors.Is 以及 errors.As 来解决 error inspection 的问题。

社区轮子

Go 社区的开发者们因为语言本身对 error handling 的支持不足,因此创造各种各样的轮子;

代码库特点
juju errors在 wrap error 时,可以选择保留或隐藏 error 产生的原因 (cause),但它的 Cause 方法仅 unwrap 一层。
pkg/errors由 Go 核心工程师 R Dave Cheney 的开发被广泛使用。
提供 wrapping 和调用栈捕获的功能,并利用 %+v 格式化 error,会递归地遍历 error chain,展示更多的细节,它认为只有整个 error chain 最末端的 error 最有价值。
upspin err定制化 error 的实践范本,同时引入了 errors.Is 和 errors.Match 用于辅助检查 error 类型。
hashicorp/errwrap支持将 errors 组织成树状结构,并提供 Walk 方法遍历这棵树。

| | pingcap parser errors | 在 pkg/errors 的基础上二次开发,并增加了 error 类的概念。 | | cockroachdb/errors | 考虑了 error 在进程间传递的场景,让 error handling 具备网络传播兼容能力。 |

当前现状

目前,内部生产环境使用 Go 1.16 +,通查项目中都有这样一个基本的 error handling 工具类(虽然你有,我也不一定用),此外服务研发团队内部并没有统一 error handling 规范与方案。

type XError struct {
    code int
    err  error
}

// code 与 msg 是一一绑定的
errMsg = map[errorCode]string{
    CommonErrCode: "",
    MysqlErrCode:  "mysql",
    RedisErrCode:  "redis",
}

下面我整几段代码,先抛开代码逻辑的正确性,只谈 error handling 逻辑,看看错误是被如何处理的?

// RPC 请求
func baseRequest(ctx context.Context, url string, params map[string]string) (interface{}, error) {
    b, err := client.RequestWithContext(ctx, conf.URL+url, client.BuildOptions(&client.Options{
      Method:           "GET",
      Headers:          map[string][]string{},
      Params:           params,
      Timeout:          conf.Timeout,
      HostWithVip:      conf.VipHost,
      RecordWithParams: true,
  }))
    if err != nil {
      // ① - 纯通过手动拼接函数名的方式,便于生成日志,格式混乱,容易遗忘;
      traceerror.Error(ctx, fmt.Sprintf("#coupon#baseRequest##error# res=%v,err=%v", string(b), err))
      // ② - 不包装 error 上下文信息,同一错误在日志展现上是不同的;
      return nil, err
  }

    var resp *Resp
    if err = json.Unmarshal(b, &resp); err != nil {
      return nil, err
  }
    if resp.Code != "1" {
      return nil, xerror.New(resp.Message)
  }
    return resp.Data, nil
}
func OnlineStrategyFlow(ctx context.Context, flowID int64) (map[int64]*UpFlowResult, int64, error) {
    // ③ - 方便复用对,函数名做了声明,但日志拼接格式还是很混乱;
    fun := "#STRATEGY#OnlineStrategyFlow#"
    logger.Infof(ctx, "%s 策略流上线开始执行.flowID:%d", fun, flowID)
    strategies, err := service3.QueryStrategyInFlow(ctx, flowID)
    if err != nil {
      return nil, 0, err
  }

    if len(strategies) == 0 {
      logger.Errorf(ctx, "%s 策略流下策略信息不存在.flow_id:%d", fun, flowID)
      // ④ - 使用了特定 error,但却丢失了错误上下文;
      return nil, 0, xerror.ErrDBResEmpty
  }

}
func (o *Crowd) ScanRecordSyncData(ctx context.Context, bind xgin.Bind) (ret interface{}, err error) {
    var req crowd_service.ScanParam
    if err = bind(&req); err != nil {
      // ⑤ - 采用了 error code ,但没有当前函数信息;
      return nil, xerror.WrapCode(xerror.CommonErrCode, err)
  }
    return o.CrowdModule.ScanRecordSyncData(ctx, req)
}

造成的后果:

  • error 被多次日志打印,重复冗余干扰问题定位,并影响监控配置;
  • error 判断困难,无法区分特定error;
  • error 上下文信息丢失,没有当前函数信息,更无法串联出逻辑调用栈;

==> 前端报错没有可读性,全靠 traceId 和个人经验来定位问题!!

error 的消费者

在写出合理的 error handling 代码前,我们有必要思考:谁是 error 的消费者?在任意一个服务的生命周期中,通常至少会有 3 类人关心 error:

  • 应用程序本身 (application)
  • 服务的用户 (end user)
  • 服务的维护者 (operator)

error handling 的最佳实践

接下来我们阐述如何利用 dry 的 errors 模块完成我们日常项目开发的 error handling 需求。

替换 errors

在很多地方,我们会用到 Go 自带的 errors package,比如:

import (
	"errors"
)

func GetOneWorkflow(ctx context.Context, db manager.XDB, where map[string]interface{}) (*bpm.Workflow, error) {
	if nil == db {
		return nil, errors.New("manager.XDB object couldn't be nil")
	}
	//...
}

这里用到了 errors.New,在 dry 的 errors package 中,我们同样提供了 New 函数,因此只需替换依赖即可:

func GetOneWorkflow(ctx context.Context, db manager.XDB, where map[string]interface{}) (*bpm.Workflow, error) {
	if nil == db {
		return nil, errors.New("manager.XDB object couldn't be nil")
	}
	//...
}

用 errors.Op 替换 fun

只要阅读过老项目的代码,你就不可避免地会遇到 fun 这个变量,比如:

func CheckIAMAuth(ctx context.Context, header, uri, method string) bool {
	fun := "CheckIAMAuth -->"
  // ...
}

fun 存在的意义就是打日志的时候将当前的函数名称记录下来,方便排查问题时从 Kibana 上快速检索。我们可以利用 errors.Op 来代替 fun

func CheckIAMAuth(ctx context.Context, header, uri, method string) bool {
  op := errors.Op("CheckIAMAuth")
  // ...
}

下文我们会继续介绍 op 如何替代 fun 的功能,并提供更简洁、完整的错误信息。

指定 error 的类型

尽管在应用程序中遇到的具体 error 种类可能很多,但仔细梳理可以发现它们可以被划归到有限的几类:

package errors

const (
	Conflict   Class = "conflict"          // Action cannot be performed
	Internal   Class = "internal"          // Internal error
	Invalid    Class = "invalid"           // Validation failed
	NotFound   Class = "not_found"         // Entity does not exist
	PermDenied Class = "permission_denied" // Does not have permission
	Other      Class = "other"             // Unclassified error
)

在使用 dry 的 errors.E 方法时,我们可以直接指定 error 的类型,比如:

errors.E(op, errors.Invalid, "message")
errors.E(op, err, errors.Internal, "message")
//...

指定 error code

如果你的应用会直面 C 端用户,暴露 error code 通常能减少更多的沟通成本,通过 errors.E 可以指定 error code:

const (
  ErrCodeTrafficLimited int = 100001 // 流量限制
  ErrCodeInvalidToken int32 = 100002 // 无效 token
  //...
)

errors.E(op, err, errors.Conflict, ErrCodeTrafficLimited)
errors.E(op, err, errors.Invalid, ErrCodeInvalidToken)

任意整型 (int, int32) 参数传入 errors.E 都将被认为是 error code,因此你可以在应用中自行定义 error code。

指定 error msg

不论面对 C 端用户还是运维人员,精简、完备的文案可以大大提高问题定位的速度,通过 errors.E 可以制定 error msg:

errors.E(op, err, "this is message")
errors.E(op, err, fmt.Sprintf("paramA %v paramB %v", paramA, paramB))
//...

由于 errors.E 函数因为其功能和使用的便利性需要,会根据参数的类型决定它的用途,因此如果同一类型的参数被传入两次,只有最后的参数会覆盖生效 (相关讨论见 issue)。

无中生有的 error

在一些情况下,如参数不符合要求、找不到数据、与已知数据冲突、没有权限等,我们需要创建新的 error,这时候优先使用 errors.E

func (m *handlerGroupAgent) List(ctx context.Context, db manager.XDB, ...) (records []*bpm.HandlerGroup, err error) {
	op := errors.Op("DefaultHandlerGroupAgent.List")

	if query == nil {
		err = errors.E(op, errors.Invalid, "query condition is nil")
		return
	}
  //...
}

当然,你也可以使用 errors.New,但会失去当前执行的函数名称 (即 op)、error 类型 (即 errors.Invalid) 等信息。

包装下层 error

在访问下层函数、RPC、DB 时,会返回 error,使用 errors.E 来对它合理包装:

func (m *WorkflowInstanceService) Add(ctx context.Context, ins *bpm.WorkflowInstance) (lastInsertID int64, err error) {
	op := errors.Op("WorkflowInstanceService.Add")
  // ...
  rawOpContext, err := to.JSON(ins.OpContext)
	if err != nil {
    err = errors.E(op, err, errors.Invalid)
		return
	}
  // ...
}

如果你发现单个函数中会重复出现多次类似的代码,可以在 defer 中统一处理,让代码更加精简:

func (m *WorkflowInstanceService) Add(ctx context.Context, ins *bpm.WorkflowInstance) (lastInsertID int64, err error) {
	op := errors.Op("WorkflowInstanceService.Add")
	
	defer func() {
		if err != nil {
			err = errors.E(op, err)
		}
	}()
 
  rawOpContext, err := to.JSON(ins.OpContext)
  if err != nil {
		return
	}
  // ...
}

在同一个函数中,可以对同一个 errors 执行多次包装操作,但实际上只有第一次包装会生效,如:

func (m *WorkflowInstanceService) Add(ctx context.Context, ins *bpm.WorkflowInstance) (lastInsertID int64, err error) {
	op := errors.Op("WorkflowInstanceService.Add")

	defer func() {
		if err != nil {
			err = errors.E(op, err)
		}
	}()
 
  rawOpContext, err := to.JSON(ins.OpContext)
  if err != nil {
    err = errors.E(op, err, errors.Invalid)
		return
	}
  // ...
}

这时候只有 err = errors.E(op, err, errors.Invalid) 会生效,defer 中的包装不会生效。

需要特别注意的是:理论上,每个错误在向上抛出的过程中,应当只被指定一次类型,且应该在最接近跨进程交互的地方指定,中间层仅做简单包装和透传,即 errors.E(op, err)

满足应用的消费需求:利用 errors.Is 判断 error 类型

在上层处理 error 时,如 controller,可以利用 errors.Is 判断 error 类型:

func (m *WorkflowController) AddWorkflow(ctx context.Context, req *AddWorkflowReq) (res *AddWorkflowRes, err error) {
	op := errors.Op("ProcessController.AddWorkflow")
  // ...
  last, err := m.workflowService.Get(ctx, map[string]interface{}{
		"name":     name,
		"_orderby": "created_at DESC",
	})
	if err != nil && !errors.Is(err, errors.NotFound) {
		return res, errors.E(op, err)
	}
  // ...
}

满足维护者的消费需求:在最上层逻辑中打印 error 信息

在整个 error chain 上,error 应当只被打印一次,且一般在最上层打印。下面的示例代码中有三个方法:GrpcServiceImpl.HandleDelUser、UserService.DelUser 以及 DelUser,前面的依赖后面的,形成依赖链条 (chain)。

// grpc/user.go
func (m *GrpcServiceImpl) HandleDelUser(ctx context.Context, id int64) {
  op := errors.Op("HandleDelUser")
  //...
  err = m.userService.DelUser(ctx, id)
  if err != nil {
    xlog.Errorf(ctx, errors.E(op, err))
    return
  }
}

// service/user.go
func (m *UserService) DelUser(ctx context.Context, id int64) (err error) {
  op := errors.Op("UserService.DelUser")
  //...
  _, err = tidb.DelUser(where)
  if err != nil {
   	err = errors.E(op, err, errors.Internal)
    return
  }
  //...
}

// tidb/user.go
func DelUser(ctx context.Context, db manager.XDB, where map[string]interface{}) (n int64, err error) {
  op := errors.Op("tidb.DelUser")
  //...
  if err != nil {
    err = errors.E(op, err, errors.NotFound, fmt.Sprintf("where %v", where))
    return
  }
  //...
}

最底层的 DelUser 将 error 往上抛,在 handler 上打印错误信息,会得到:

HandleDelUser: UserService.DelUser: tidb.DelUser [not found] where ...

我们可以看到逻辑调用栈和实际 error 发生地上下文参数信息。

满足用户的消费需求:errors.Codeerrors.Msg

如果你利用「指定 error code」一节给出的方案在 error chain 上记录了 error code,就可以利用 errors.Code 将其取出;类似地,errors.Msg 会从 error chain 上最近的包含 error msg 的节点中取出 error msg。我们可以利用这两个函数在 http/grpc 响应中填入相应的信息:

res.Errinfo = &grpcutil.ErrInfo{
  Code: int32(errors.ErrCode(err)),
  Msg:  errors.ErrMsg(err),
}

其它功能函数

合并多个 errors

有时候你的逻辑可能是并发地访问外部服务,不论单次访问是否有问题,都不想停止其它访问的正常进行,这时可能会产生一个或多个 error。这些 errors 之间是并列关系,这时我们可以使用 errors.Combine 来处理:

// ...
var errs []error

for _, req := range reqs {
  data, err := req.Do()
  if err != nil {
    errs = append(errs, err)
  }
}

err = errors.Combine(errs)
// ...

errors.Combine 会妥善处理好 errs 为空的情况,因此不必担心特殊情况影响代码书写逻辑。

再来一套组合拳,降本提效YYDS

日志打印规范

  • 不要重复的打印日志 上述日志无非是想打印一下流程入参,但却在每一个嵌套环节都打印了重复的日志。我们在项目里经常看到,控制层里打了一遍,逻辑层又一遍,数据层再来一遍。。。代码自己可能都烦了。🤖️

降低存储成本

监控报警

沟通成本

错误处理看似是一件小事,有时甚至被忽视,但它本身也包含很多设计理念(你TM在逗我?)。试想一下,如果要追求极致,我们会想方设法以最少的字符来完成日志的使命。这个过程其实跟很多计算机体系相关的设计是类似的。比如:怎样以最少的字节数来表达信息(压缩算法),怎样以最小的包来传输数据(网络协议设计),怎样以最少的指令来完成相同的逻辑(广义的代码优化)等等。 推而广之,如果我们在设计数据表(各种存储)、MQ消息体、RPC传输对象的时候稍微追求一下极致,是不是也能节省不少成本? 有了这些理念,再去处理错误。你就不再是一个普通程序猿,而是一个有抓手、能闭环、带壁垒的程序猿。🐒