提示:本节最终代码参考:feature/s13

在 Go 项目开发中,还有一个非常基础的功能,需要你在项目初期就设计、开发好。这个功能就是设计一个优雅的错误返回码。本节课我们就一起一步一步设计一个优雅的错误码。

为什么要定制业务自己的错误码?

引入错误码当然是为了解决企业开发时的痛点,我们来看以下几个跟错误码相关的痛点:

  • 用户在调用 API 接口后,服务端会返回错误信息,但错误信息是可变的,用户很难根据可变的错误信息,进行针对性的逻辑判断。例如,如果发现授权失败就告警;

  • 用户调用 API 接口,后端返回如下错误:request is invalid。用户很难根据这个宽泛的错误内容,判断出错误的原因;

  • 后端服务,经常会返回某种类别的错误,针对这种类别的错误,有一些解决方案。当我提供一个解决方案的时候,如何唯一定位到一个错误类型呢?

针对以上企业应用开发中经常遇到的问题,业界最常用的解决方案是为错误指定一个错误码。在实际开发中引入错误码有如下好处:

  • 开发者可以非常方便地定位问题和定位代码行(看到错误码就知道什么意思,grep 错误码可以定位到错误码所在行);
  • 客户可以根据错误信息,知道接口失败的原因,并可以提供错误码,给开发人员定位排障;
  • 错误码包含一定的信息,通过错误码可以判断出错误级别、错误模块和具体错误信息;
  • 业务开发过程中,可能需要判断错误是哪种类型,以便做相应的逻辑处理,通过定制的错误码很容易做到这点,例如:
  1. if err == errno.InternalServerError {
  2. ...
  3. }
  • Go 中的 HTTP 服务器开发都是引用 net/http 包,该包中只有 60 个错误码,基本都是跟 HTTP 请求相关的。在大型系统中,这些错误码完全不够用,而且跟业务没有任何关联,满足不了业务需求。

常见的错误码实现方式

  1. 不论请求成功或失败,始终返回 200 HTTP Status Code,在 HTTP Body 中包含错误信息。

例如 Facebook API 的错误 Code 设计,始终返回 200 HTTP Status Code:

  1. {
  2. "error": {
  3. "message": "Syntax error "Field picture specified more than once. This is only possible before version 2.1" at character 23: id,name,picture,picture",
  4. "type": "OAuthException",
  5. "code": 2500,
  6. "fbtrace_id": "xxxxxxxxxxx"
  7. }
  8. }

上述错误码实现方式中,HTTP Status Code 始终返回 200,只需要关注业务错误码,实现简单。

但也会带来一个明显的缺点:对于每一次 HTTP 请求,我们既需要判断 HTTP Status Code,查看 HTTP 请求是否成功,又需要解析 Body,获取错误码,判断业务请求是否成功。我们更期望,对于成功的 HTTP 请求,能够直接将 Body 解析到 Go 结构体中,进行下一步的业务开发。也就是说,对于成功的请求,我们期望能够只判错一次。

  1. 返回 HTTP 400 Bad Request 错误码,并在 Body 中返回简单的错误信息。

例如:Twitter API 的错误设计,会根据错误类型,返回合适的 HTTP Code,并在 Body 中返回错误信息和自定义业务 Code,成功的业务请求,返回 200 HTTP Status Code。

  1. HTTP/1.1 400 Bad Request
  2. x-connection-hash: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  3. set-cookie: guest_id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  4. Date: Thu, 01 Jun 2017 03:04:23 GMT
  5. Content-Length: 62
  6. x-response-time: 5
  7. strict-transport-security: max-age=631138519
  8. Connection: keep-alive
  9. Content-Type: application/json; charset=utf-8
  10. Server: tsa_b
  11. {
  12. "errors": [
  13. {
  14. "code": 215,
  15. "message": "Bad Authentication data."
  16. }
  17. ]
  18. }

这种方式,相较于第 1 种方式,对于成功的请求只需要判错一次,基本能符合我们的需求。但是这里,还有一个地方可以进一步优化:整数格式的业务错误码 215,可读性很差,用户根本无法从 215 直接获取任何有意义的信息,如果能换成语义化的字符串,效果会更好,例如:InvalidParameter.BadAuthenticationData

Twitter API 返回的错误码是一个数组,在实际开发中,这会给开发带来一定的复杂度。遇到错误直接报错,也没有什么不合理的。所以这里采用更简单的错误返回格式:

  1. {
  2. "code": "InvalidParameter.BadAuthenticationData",
  3. "message": "Bad Authentication data."
  4. }

并且,成功的请求,HTTP Status Code 为 200,失败的请求都有对应的 HTTP Status Code。另外,要注意 message 因为是直接展示给外部用户,所以需要确保内容中不包含敏感信息,例如:数据库的 id 字段,内部组件的 IP 等。

基于以上设计思路,接下来,我们在 miniblog 项目中引入错误码。

在 miniblog 中引入错误码

根据我们期望的错误码返回格式:

  1. {
  2. "code": "InvalidParameter.BadAuthenticationData",
  3. "message": "Bad Authentication data."
  4. }

我们应该很容易就知道,要实现错误码,需要实现 2 大类内容:

  1. 指定错误码规范;

  2. 编写 Code,返回规范化的错误信息。

制定错误码规范

错误码,是直接暴露给用户看的,这就需要我们设计一个易读、易懂、规范化的错误码。那么如何设计错误码呢?你可以根据需要自己设计,也可以参考其他优秀的设计。

通常,当你调研一种技术实现时,你可以优先参考各大公有云厂商的实现方式,例如:腾讯云、阿里云、华为云等。因为公有云厂商,直接面向企业/个人,专注于技术本身,并且具有非常强的技术团队,所以这些厂商的设计和实现,是非常值得我们参考的。

经过调研,这里我采用了腾讯云 API 3.0 的错误码设计规范。并将规范文档保存在项目的文档中:docs/devel/zh-CN/conversions/error_code.md

这里解释下,为什么觉得两级错误码,比简单的 215InvalidParameter 这种方式更好:

  • 语义化: 语义化的错误码,通过错误码名字,就能知道报错的类型;

  • 更加灵活: 二级错误码格式为 平台级.资源级。平台级错误码是固定的,用来指代某一类错误,客户端可使用该错误码,进行通用的错误处理。可使用资源级错误码进行更精准的错误处理。此外,服务端可以根据需要自定义错误码,也可以使用默认的错误码。

这里,我们预定义了以下平台级错误码:

错误码 错误描述 错误类型
InternalError 内部错误 1
InvalidParameter 参数错误(包括参数类型、格式、值等错误) 0
AuthFailure 认证 / 授权错误 0
ResourceNotFound 资源不存在 0
FailedOperation 操作失败 2

提示:错误类型 - 0 代表客户端,1 代表服务端,2 代表客户端 / 服务端。

在 Go 中所有的功能都是以 Go 包的形式提供的。所以,接下来我们需要开发一个 Go 包来实现我们期望的错误码。Go 包的名称你可以根据需要命名,例如:errorserrnominierr 等,这里我们使用 errnoerrno 也是很多项目采用的错误包命名。

开发自定义错误包

API 请求返回报错时,需要返回:HTTP Status Code(HTTP 状态码)、Code(业务错误码)、Message(可直接暴露给用户的错误信息),所以我们首先定义一个 Errno 结构体用来保存这些信息:

  1. // Errno 定义了 miniblog 使用的错误类型.
  2. type Errno struct {
  3. HTTP int
  4. Code string
  5. Message string
  6. }

Errno 是一个错误类型,所以我们需要实现 Error 方法:

  1. // Error 实现 error 接口中的 `Error` 方法.
  2. func (err *Errno) Error() string {
  3. return err.Message
  4. }
  5. // SetMessage 设置 Errno 类型错误中的 Message 字段.
  6. func (err *Errno) SetMessage(format string, args ...interface{}) *Errno {
  7. err.Message = fmt.Sprintf(format, args...)
  8. return err
  9. }

为了能够指定返回的错误信息,我们开发了一个 SetMessage 方法,用来设置返回的错误信息。

接下来,我们需要定义一些预定义的错误类型,供程序直接引用(internal/pkg/errno/code.go 文件):

  1. package errno
  2. var (
  3. // OK 代表请求成功.
  4. OK = &Errno{HTTP: 200, Code: "", Message: ""}
  5. // InternalServerError 表示所有未知的服务器端错误.
  6. InternalServerError = &Errno{HTTP: 500, Code: "InternalError", Message: "Internal server error."}
  7. )

上面,我们定义了程序中最常用到的 2 类错误:

  • OK:代表请求成功;

  • InternalServerError:代码服务内部错误。

开发通用返回接口

上面我们开发了错误码相关功能。这时候还需要开发一个统一的返回方法,通过调用同一个返回方法,可以确保我们的返回信息格式是统一的。

首先,我们要定义统一的错误返回结构体(位于 internal/pkg/core/core.go 文件中):

  1. // ErrResponse 定义了发生错误时的返回消息.
  2. type ErrResponse struct {
  3. // Code 指定了业务错误码.
  4. Code string `json:"code"`
  5. // Message 包含了可以直接对外展示的错误信息.
  6. Message string `json:"message"`
  7. }

接着,我们还要实现一个方法,该方法能够根据传入的参数,构建不同的返回(位于 internal/pkg/core/core.go 文件中):

  1. // WriteResponse 将错误或响应数据写入 HTTP 响应主体。
  2. // WriteResponse 使用 errno.Decode 方法,根据错误类型,尝试从 err 中提取业务错误码和错误信息.
  3. func WriteResponse(c *gin.Context, err error, data interface{}) {
  4. if err != nil {
  5. hcode, code, message := errno.Decode(err)
  6. c.JSON(hcode, ErrResponse{
  7. Code: code,
  8. Message: message,
  9. })
  10. return
  11. }
  12. c.JSON(http.StatusOK, data)
  13. }

可以看到,WriteResponse 函数会判断 err 是否为 nil,如果 err != nil 说明需要返回错误信息,这时候会调用 errno.Decode 函数,解析错误,尝试从中提取出我们关注的信息。如果 err == nil,直接返回需要返回的内容即可。

errno.Decode 函数内容如下:

  1. // Decode 尝试从 err 中解析出业务错误码和错误信息.
  2. func Decode(err error) (int, string, string) {
  3. if err == nil {
  4. return OK.HTTP, OK.Code, OK.Message
  5. }
  6. switch typed := err.(type) {
  7. case *Errno:
  8. return typed.HTTP, typed.Code, typed.Message
  9. default:
  10. }
  11. // 默认返回未知错误码和错误信息. 该错误代表服务端出错
  12. return InternalServerError.HTTP, InternalServerError.Code, err.Error()
  13. }

可以看到,Decode 函数会断言 err 类型,如果是 Errno 类型时,就会返回 HTTP Status Code、Code、Message,之后 WriteResponse 方法会根据这些信息,返回 ErrResponse 类型的错误返回格式,并设置 HTTP Status Code。如果 err 不是 Errno 类型,则返回默认的 InternalServerError

既然,我们实现了错误码功能,并开发了统一返回方法。那么我们就要改造已有的路由方法,使用统一的返回:

  1. // 注册 404 Handler.
  2. g.NoRoute(func(c *gin.Context) {
  3. core.WriteResponse(c, errno.ErrPageNotFound, nil )
  4. })
  5. // 注册 /healthz handler.
  6. g.GET("/healthz", func(c *gin.Context) {
  7. log.C(c).Infow("Healthz function called")
  8. core.WriteResponse(c, nil , map [ string ] string { "status" : "ok" })
  9. })

编译、运行、测试

通过执行以下命令来构建并启动服务:

  1. $ make
  2. $ $ _output/miniblog -c configs/miniblog.yaml

打开另外一个终端,执行 curl 命令,发送 HTTP 请求进行测试:

  1. $ curl -v http://127.0.0.1:8080/v1/users
  2. * Trying 127.0.0.1...
  3. * TCP_NODELAY set
  4. * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
  5. > GET /v1/users HTTP/1.1
  6. > Host: 127.0.0.1:8080
  7. > User-Agent: curl/7.61.1
  8. > Accept: */*
  9. >
  10. < HTTP/1.1 404 Not Found
  11. < Access-Control-Allow-Origin: *
  12. < Cache-Control: no-cache, no-store, max-age=0, must-revalidate, value
  13. < Content-Type: application/json; charset=utf-8
  14. < Expires: Thu, 01 Jan 1970 00:00:00 GMT
  15. < Last-Modified: Thu, 01 Dec 2022 05:52:46 GMT
  16. < X-Content-Type-Options: nosniff
  17. < X-Frame-Options: DENY
  18. < X-Request-Id: 34d54da0-265e-49fa-940b-d4f2876b4a4b
  19. < X-Xss-Protection: 1; mode=block
  20. < Date: Thu, 01 Dec 2022 05:52:46 GMT
  21. < Content-Length: 68
  22. <
  23. * Connection #0 to host 127.0.0.1 left intact
  24. { "code" : "ResourceNotFound.PageNotFound" , "message" : "Page not found." }

可以看到 HTTP 请求返回了期望的格式和 HTTP Status Code。

小结

在 Go 项目开发中,很多项目都需要自定义错误码。本课程调研了一些业界的错误码实现,并结合我的理解选择了一个我认为是最佳实践的错误码格式:

  1. {
  2. "code": "InvalidParameter.BadAuthenticationData",
  3. "message": "Bad Authentication data."
  4. }

根据我们预设的错误格式,一步一步开发代码,实现这种格式的错误返回。通过 errno 包、WriteResponse 函数能够在一定程度上,保证返回错误的规范性,但在实际开发中,还需要开发者能够根据错误返回规范,在合适的地方返回合适的错误码。