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跟踪实现。
我们需要的模式是:
- 顶层捕捉的error可以打印出error抛出的具体位置,按需打印调用栈
- 抛出的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 包已经能满足了我们错误处理需求,这里重新强调我们错误记录的规范:
- 只在调用链顶层进行错误打印,一般模式下打印使用fmt.Printf的%s,如果只需打印最底层error,则使用fmt.Printf("%v\n",errors.Cause(err))。如果是调试模式,则使用printf的%+v,把完整的堆栈打印出来,方便定位问题。
- 抛出error的地方,可以使用errors.New或者errors.Warp;调用者得到error后如果要加入本层的一些信息,则使用errors.Warp来添加。
- 新增或者添加error信息时,需要带上当前所处的函数名,比如error.New("[queryDatabase] query database fail")。因为我们在顶层打印底层error时,可能不打印调用栈,此时可以通过error附带的函数名快速定位到位置。
- 调用者如果需要判断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的映射。