Go Error最佳实践

2024-12-17

在Go项目中,错误error的管理非常重要,如何处理好error值,如何正确打印error,如何将接口error合适地返回给前端,这些都是需要思考和设计的。本文想聊一聊Go error记录的实践,值得讨论的点有:

  • error如何合理记录。很多时候,我们都是在每一层接口调用时判断error并每一层打印,其实是否能向其他语言一样在顶层捕获这个error再打印呢?
  • http server怎么合理地把error告知client。有些人习惯使用固定http code 200 + body json 业务错误码的方式告知请求情况,有些人则习惯使用http code来告知请求错误。这两个模式的优缺点是什么呢?

标准库errors的使用痛点

在编写Go程序中,如果我们要返回错误,一般是使用Go标准库自带的errors包,比如errors.New("open file failed") 这样返回error给到调用者。使用标准库errors.New生成的error会有以下问题:

  • 不带调用栈:不方便从日志中看到调用栈,也很难准确定位error发生的位置,不便于处理问题。
  • 不支持Wrap/Unwrap:比如A->B->C的调用链,C抛出error,B如果想对C的error添加额外信息时,需要重新errors.New,并把自己添加的信息并和C的error.Error()拼接后再往上抛,这样A拿到的信息才是C的error + B添加的error,这样原始error已经被污染。

我们通过以下一个例子,来说明我们在项目中error记录的各种不爽。


package main

import (
	"fmt"
)

func bindUser(username string) error {
	if err := getUser(username); err != nil {
		return err
	}
  
	return nil
  }
  
  func getUser(username string) error {
	if err := queryDatabase(username); err != nil {
		return err
	}
  
	return nil
  }
  
  func queryDatabase(username string) error {
	return fmt.Errorf("bind user failed. username=%s", username)
  }

  func main() {
	username := "lijunshi"
	if err := bindUser(username); err != nil {
	  fmt.Printf("bindUser error: %s\n", err.Error())
	}
  }

输出:

bindUser error: bind user failed. username=lijunshi

以上代码的调用链是这样的:bindUser() -> getUser() -> queryDatabase()。此时我们希望只在调用顶层进行error的打印,其他层次无需打印,只需把error往上传即可。但这个模式有问题,我们怎么知道这个error是从哪一层哪个函数哪一行抛出的呢?难道我要根据日志关键词去代码搜,万一关键词在其他函数的某些error中也这么写呢?所以显然这不是一个好的error跟踪实现。

我们需要的模式是:

  1. 顶层捕捉的error可以打印出error抛出的具体位置,按需打印调用栈
  2. 抛出的error可以比较,比如我们可能会根据不同的error触发不同的逻辑,比如是数据库相关的error就执行A逻辑,是json解码失败的error就执行B逻辑,是业务error就执行C逻辑。

github.com/pkg/errors 包

针对以上需求,我们可以使用github.com/pkg/errors 包来满足,github.com/pkg/errors 包有以下主要功能:

  • 错误包装: 允许你将一个错误包装在另一个错误中,保留原始错误的信息。
  • 堆栈跟踪: 自动捕获错误的堆栈跟踪信息,便于调试。
  • 错误解包: 允许你解包一个包装的错误,获取原始错误。
package main

import (
	"fmt"

	"github.com/pkg/errors"
)

var ErrDatabase = errors.New("database error")
var ErrJsonDecode = errors.New("json decode error")

func bindUser(username string) error {
	if err := getUser(username); err != nil {
		return err
	}

	return nil
}

func getUser(username string) error {
	if err := queryDatabase(username); err != nil {
		wrappedErr := errors.Wrap(err, "additional context")
		return wrappedErr
	}

	if err := jsonDecode(); err != nil {
		return err
	}

	return nil
}

func queryDatabase(username string) error {
	return errors.Wrap(ErrDatabase, "query database error")
}

func jsonDecode() error {
	return ErrJsonDecode
}

func main() {
	username := "lijunshi"
	if err := bindUser(username); err != nil {
		fmt.Printf("bindUser error cause: %v\n",errors.Cause(err))
		fmt.Printf("bindUser error: %v\n", err)
		fmt.Printf("bindUser error: %+v\n",err)

		if errors.Is(err, ErrDatabase) {
			fmt.Println("database error")
		}

	}
}

输出:

bindUser error cause: database error
bindUser error: additional context: query database error: database error
bindUser error: database error
main.init
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:9
runtime.doInit1
        /usr/local/go/src/runtime/proc.go:6735
runtime.doInit
        /usr/local/go/src/runtime/proc.go:6702
runtime.main
        /usr/local/go/src/runtime/proc.go:249
runtime.goexit
        /usr/local/go/src/runtime/asm_amd64.s:1650
query database error
main.queryDatabase
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:34
main.getUser
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:21
main.bindUser
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:13
main.main
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:43
runtime.main
        /usr/local/go/src/runtime/proc.go:267
runtime.goexit
        /usr/local/go/src/runtime/asm_amd64.s:1650
additional context
main.getUser
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:22
main.bindUser
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:13
main.main
        /mnt/d/code/gitea/car/car-data-center/demo/test/main.go:43
runtime.main
        /usr/local/go/src/runtime/proc.go:267
runtime.goexit
        /usr/local/go/src/runtime/asm_amd64.s:1650
database error

在上面的案例中,queryDatabase是我们错误发生根源的地方,错误会一直往上抛,直到顶层调用者捕获这个错误。
注意以下几点:
● queryDatabase的errors.Wrap 产生一个error。
● getUser捕获queryDatabase这个error, 并通过errors.Wrap追加自己的信息。
● bindUser捕获getUser的error,通过print的%+v模式打印出调用栈,定位到error抛出的路径。通过errors.Is判断error具体类型,如果是ErrDatabase则执行特定逻辑。

至此,我们用github.com/pkg/errors 包已经能满足了我们错误处理需求,这里重新强调我们错误记录的规范:

  1. 只在调用链顶层进行错误打印,一般模式下打印使用fmt.Printf的%s,如果只需打印最底层error,则使用fmt.Printf("%v\n",errors.Cause(err))。如果是调试模式,则使用printf的%+v,把完整的堆栈打印出来,方便定位问题。
  2. 抛出error的地方,可以使用errors.New或者errors.Warp;调用者得到error后如果要加入本层的一些信息,则使用errors.Warp来添加。
  3. 新增或者添加error信息时,需要带上当前所处的函数名,比如error.New("[queryDatabase] query database fail")。因为我们在顶层打印底层error时,可能不打印调用栈,此时可以通过error附带的函数名快速定位到位置。
  4. 调用者如果需要判断error的类型,则使用errors.Is()来判断。

http业务错误码的处理

上面的error的处理规范是针对内部项目,在对外展示错误时(如前端展示业务错误信息)时,同样需要一套错误处理规范。HTTP接口是前后端交互使用的,因此要求服务端HTTP返回的信息需要统一消息格式,如果接口报错,需要以一个规范的格式向用户展示错误的具体原因,我们一般的处理方式就是定义我们的业务错误码code+具体message来告知用户本次请求失败的原因以及应该怎么处理。

做了些调研,HTTP业务错误码的实现方式有2种(以资源未授权的请求为例):

业务错误始终返回HTTP STATUS CODE 200,通过业务错误码区分

这种方式下,当我们客户端对服务端发起请求时,若服务端资源无访问权限,其response 的HTTP STATUS CODE会置为200,其真正的错误码(设置为非0的某个数字)和message会放在body json串里。调用者检查HTTP STATUS CODE是否为200后,还需要检查body里的业务code是否为0,是0才能说明本次调用成功。

{
  "error": {
    "message": "Syntax error \"Field picture specified more than once. This is only possible before version 2.1\" at character 23: id,name,picture,picture",
    "type": "OAuthException",
    "code": 2500,
    "fbtrace_id": "xxxxxxxxxxx"
  }
}

采用固定返回200 HTTP STATUS CODE的方式,有其合理性。比如,HTTP STATUS CODE 通常代表 HTTP Transport 层的状态信息。当我们收到 HTTP 请求,并返回时,HTTP Transport 层是成功的,也就是说,从业务层来看,client和server的通信是正常的,消息server也能收到,只是处理过程中出现问题了。

从错误码监控的角度来看,监控的是HTTP STATUS CODE。业务逻辑错误的请求response的HTTP STATUS CODE是200,因此从监控的层面来看,业务逻辑错误属于正常情况。如果想监控某一类业务逻辑错误(如数据库连接异常),那么在服务程序中针对该异常应设置HTTP STATUS CODE为非200错误,比如510,这样监控系统就能把这类返回作为异常来监控了。

从前端角度来看,HTTP接口返回的HTTP STATUS CODE为200时,检查body里的业务code,如果code非0,取出message的消息,弹出对应的消息组件(比如绿色提醒框)。如果是HTTP STATUS CODE非200时,弹出红色提醒框,如HTTP STATUS CODE为500则提醒“服务器内部错误”,逻辑上比较好处理。

这个方案的缺点是对于每次请求,前端需解析body才能知道本次请求是否成功,这样的效率是挺低的。大部分的请求都是成功的,小部分是失败,所以很多性能都浪费在http response body的decode上了。

通过不同的http状态码来返回错误

这种方式下,当我们客户端对服务端发起请求时,若服务端资源无访问权限,其response 的HTTP STATUS CODE会置为400,其业务错误码和message也会放在body json串里。

HTTP/1.1 400 Bad Request
x-connection-hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
set-cookie: guest_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Date: Thu, 01 Jun 2017 03:04:23 GMT
Content-Length: 62
x-response-time: 5
strict-transport-security: max-age=631138519
Connection: keep-alive
Content-Type: application/json; charset=utf-8
Server: tsa_b

{"errors":[{"code":215,"message":"Bad Authentication data."}]}

这种方式有以下优点:

  • 调用方只需要关注HTTP STATUS CODE集合就好,调用方的逻辑动作都是基于HTTP STATUS CODE集合来进行的。这个集合比较小,而业务状态码往往比较多,进而沟通成本大。
  • 调用方可以直接通过HTTP STATUS CODE就可以判断请求是否成功,若失败了,才会解码body来了解具体的失败原因,性能更为优秀。
  • 监控系统可以通过HTTP STATUS CODE准确判断异常请求,无需额外配置监控的CODE,比如默认监控非200的CODE。

但是该方案缺点也有,那就是逻辑错误如何告知调用方?现在的做法是HTTP STATUS CODE为500时表示服务器错误,但服务器错误可能是业务逻辑错误,也可能是服务crash了,这两错误在调用方和监控平台中是无法区分的:我们监控平台一般只关注服务器异常的情况,业务逻辑错误并不应归类为异常请求;调用方要区分服务器异常和业务错误,因为需要有不同的表现。此时的方案是,业务逻辑错误应重新定义为510,跟服务器异常500区分开来。

两类方案各有优缺点,在业界也有大批的坚定实践者。我们项目最后选择了方案1作为我们的处理方案。

我们的方案

当业务层处理完业务逻辑后,可能会生成error,此时我们需要对error进行屏蔽,但是有些error有希望返回给前端展示。举个例子,数据库相关报错生成的error肯定是不能返回给前端展示,但是一些逻辑错误,比如输入字符串含有敏感字符这种逻辑错误,可以通过业务code和message返回给前端。

为了满足这个场景,一个方案是后端和前端一起维护error code映射,一个error code对应一个message;另一个方案是前端直接读message展示,error code映射只由后端维护。

本着维护的便捷性,我们采取只由后端来维护error code和message的映射,如:

  • 1001: 请求参数结构体绑定错误
  • 1002:用户已存在
  • 1003:密码不正确
  • 1000:未知的服务器错误
  • 1004:通用逻辑异常

定义response结构

package main

import (
	"fmt"

	"github.com/gin-gonic/gin"
	"github.com/pkg/errors"
)

var ErrDatabase = errors.New("database error")
var ErrJsonDecode = errors.New("json decode error")

type Response struct {
	// 业务响应状态码,0-表示业务响应成功,非0值表示业务响应失败
	Code int `json:"code"`

	// 数据
	Data interface{} `json:"data"`

	// 提示信息
	Msg string `json:"msg"`
}

func WriteResponse(c *gin.Context, msg string, data interface{}, code int) {
	c.JSON(200, Response{
		Code: code,
		Data: data,
		Msg:  msg,
	})
}

func bindUser(username string) error {
	if err := getUser(username); err != nil {
		return err
	}

	return nil
}

func getUser(username string) error {
	if err := queryDatabase(username); err != nil {
		wrappedErr := errors.Wrap(err, "additional context")
		return wrappedErr
	}

	if err := jsonDecode(); err != nil {
		return err
	}

	return nil
}

func queryDatabase(username string) error {
	return errors.Wrap(ErrDatabase, "query database error")
}

func jsonDecode() error {
	return ErrJsonDecode
}

func Handler(c *gin.Context) {
	username := "lijunshi"
	if err := bindUser(username); err != nil {
		if errors.Is(err, ErrDatabase)  || errors.Is(err, ErrJsonDecode) {
			WriteResponse(c, "内部异常", nil, 1001)
			return
		}
		WriteResponse(c, err.Error(), nil, 1004)
		return
	}
	WriteResponse(c, "", nil, 0)
}


在上面的案例中,对于数据库报的错误(内部错误),我们统一由接口层进行拦截,替换为更为模糊的message返回给调用方。对于一些业务错误(如密码错误),可以通过message透传给前端直接展示,前端无需再关心code到message的映射。