提示:本节最终代码参考:feature/s13。
在 Go 项目开发中,还有一个非常基础的功能,需要你在项目初期就设计、开发好。这个功能就是设计一个优雅的错误返回码。本节课我们就一起一步一步设计一个优雅的错误码。
为什么要定制业务自己的错误码?
引入错误码当然是为了解决企业开发时的痛点,我们来看以下几个跟错误码相关的痛点:
用户在调用 API 接口后,服务端会返回错误信息,但错误信息是可变的,用户很难根据可变的错误信息,进行针对性的逻辑判断。例如,如果发现授权失败就告警;
用户调用 API 接口,后端返回如下错误:
request is invalid
。用户很难根据这个宽泛的错误内容,判断出错误的原因;后端服务,经常会返回某种类别的错误,针对这种类别的错误,有一些解决方案。当我提供一个解决方案的时候,如何唯一定位到一个错误类型呢?
针对以上企业应用开发中经常遇到的问题,业界最常用的解决方案是为错误指定一个错误码。在实际开发中引入错误码有如下好处:
- 开发者可以非常方便地定位问题和定位代码行(看到错误码就知道什么意思,grep 错误码可以定位到错误码所在行);
- 客户可以根据错误信息,知道接口失败的原因,并可以提供错误码,给开发人员定位排障;
- 错误码包含一定的信息,通过错误码可以判断出错误级别、错误模块和具体错误信息;
- 业务开发过程中,可能需要判断错误是哪种类型,以便做相应的逻辑处理,通过定制的错误码很容易做到这点,例如:
if err == errno.InternalServerError {
...
}
- Go 中的 HTTP 服务器开发都是引用
net/http
包,该包中只有 60 个错误码,基本都是跟 HTTP 请求相关的。在大型系统中,这些错误码完全不够用,而且跟业务没有任何关联,满足不了业务需求。
常见的错误码实现方式
- 不论请求成功或失败,始终返回 200 HTTP Status Code,在 HTTP Body 中包含错误信息。
例如 Facebook API 的错误 Code 设计,始终返回 200 HTTP Status Code:
{
"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"
}
}
上述错误码实现方式中,HTTP Status Code 始终返回 200
,只需要关注业务错误码,实现简单。
但也会带来一个明显的缺点:对于每一次 HTTP 请求,我们既需要判断 HTTP Status Code,查看 HTTP 请求是否成功,又需要解析 Body,获取错误码,判断业务请求是否成功。我们更期望,对于成功的 HTTP 请求,能够直接将 Body 解析到 Go 结构体中,进行下一步的业务开发。也就是说,对于成功的请求,我们期望能够只判错一次。
- 返回 HTTP 400 Bad Request 错误码,并在 Body 中返回简单的错误信息。
例如:Twitter API 的错误设计,会根据错误类型,返回合适的 HTTP Code,并在 Body 中返回错误信息和自定义业务 Code,成功的业务请求,返回 200 HTTP Status Code。
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."
}
]
}
这种方式,相较于第 1 种方式,对于成功的请求只需要判错一次,基本能符合我们的需求。但是这里,还有一个地方可以进一步优化:整数格式的业务错误码 215
,可读性很差,用户根本无法从 215
直接获取任何有意义的信息,如果能换成语义化的字符串,效果会更好,例如:InvalidParameter.BadAuthenticationData
。
Twitter API 返回的错误码是一个数组,在实际开发中,这会给开发带来一定的复杂度。遇到错误直接报错,也没有什么不合理的。所以这里采用更简单的错误返回格式:
{
"code": "InvalidParameter.BadAuthenticationData",
"message": "Bad Authentication data."
}
并且,成功的请求,HTTP Status Code 为 200
,失败的请求都有对应的 HTTP Status Code。另外,要注意 message
因为是直接展示给外部用户,所以需要确保内容中不包含敏感信息,例如:数据库的 id
字段,内部组件的 IP 等。
基于以上设计思路,接下来,我们在 miniblog 项目中引入错误码。
在 miniblog 中引入错误码
根据我们期望的错误码返回格式:
{
"code": "InvalidParameter.BadAuthenticationData",
"message": "Bad Authentication data."
}
我们应该很容易就知道,要实现错误码,需要实现 2 大类内容:
指定错误码规范;
编写 Code,返回规范化的错误信息。
制定错误码规范
错误码,是直接暴露给用户看的,这就需要我们设计一个易读、易懂、规范化的错误码。那么如何设计错误码呢?你可以根据需要自己设计,也可以参考其他优秀的设计。
通常,当你调研一种技术实现时,你可以优先参考各大公有云厂商的实现方式,例如:腾讯云、阿里云、华为云等。因为公有云厂商,直接面向企业/个人,专注于技术本身,并且具有非常强的技术团队,所以这些厂商的设计和实现,是非常值得我们参考的。
经过调研,这里我采用了腾讯云 API 3.0 的错误码设计规范。并将规范文档保存在项目的文档中:docs/devel/zh-CN/conversions/error_code.md。
这里解释下,为什么觉得两级错误码,比简单的 215
、InvalidParameter
这种方式更好:
语义化: 语义化的错误码,通过错误码名字,就能知道报错的类型;
更加灵活: 二级错误码格式为
平台级.资源级
。平台级错误码是固定的,用来指代某一类错误,客户端可使用该错误码,进行通用的错误处理。可使用资源级错误码进行更精准的错误处理。此外,服务端可以根据需要自定义错误码,也可以使用默认的错误码。
这里,我们预定义了以下平台级错误码:
错误码 | 错误描述 | 错误类型 |
---|---|---|
InternalError | 内部错误 | 1 |
InvalidParameter | 参数错误(包括参数类型、格式、值等错误) | 0 |
AuthFailure | 认证 / 授权错误 | 0 |
ResourceNotFound | 资源不存在 | 0 |
FailedOperation | 操作失败 | 2 |
提示:错误类型 - 0 代表客户端,1 代表服务端,2 代表客户端 / 服务端。
在 Go 中所有的功能都是以 Go 包的形式提供的。所以,接下来我们需要开发一个 Go 包来实现我们期望的错误码。Go 包的名称你可以根据需要命名,例如:errors
、errno
、minierr
等,这里我们使用 errno
,errno
也是很多项目采用的错误包命名。
开发自定义错误包
API 请求返回报错时,需要返回:HTTP Status Code(HTTP 状态码)、Code(业务错误码)、Message(可直接暴露给用户的错误信息),所以我们首先定义一个 Errno
结构体用来保存这些信息:
// Errno 定义了 miniblog 使用的错误类型.
type Errno struct {
HTTP int
Code string
Message string
}
Errno
是一个错误类型,所以我们需要实现 Error
方法:
// Error 实现 error 接口中的 `Error` 方法.
func (err *Errno) Error() string {
return err.Message
}
// SetMessage 设置 Errno 类型错误中的 Message 字段.
func (err *Errno) SetMessage(format string, args ...interface{}) *Errno {
err.Message = fmt.Sprintf(format, args...)
return err
}
为了能够指定返回的错误信息,我们开发了一个 SetMessage
方法,用来设置返回的错误信息。
接下来,我们需要定义一些预定义的错误类型,供程序直接引用(internal/pkg/errno/code.go
文件):
package errno
var (
// OK 代表请求成功.
OK = &Errno{HTTP: 200, Code: "", Message: ""}
// InternalServerError 表示所有未知的服务器端错误.
InternalServerError = &Errno{HTTP: 500, Code: "InternalError", Message: "Internal server error."}
)
上面,我们定义了程序中最常用到的 2 类错误:
OK
:代表请求成功;InternalServerError
:代码服务内部错误。
开发通用返回接口
上面我们开发了错误码相关功能。这时候还需要开发一个统一的返回方法,通过调用同一个返回方法,可以确保我们的返回信息格式是统一的。
首先,我们要定义统一的错误返回结构体(位于 internal/pkg/core/core.go
文件中):
// ErrResponse 定义了发生错误时的返回消息.
type ErrResponse struct {
// Code 指定了业务错误码.
Code string `json:"code"`
// Message 包含了可以直接对外展示的错误信息.
Message string `json:"message"`
}
接着,我们还要实现一个方法,该方法能够根据传入的参数,构建不同的返回(位于 internal/pkg/core/core.go
文件中):
// WriteResponse 将错误或响应数据写入 HTTP 响应主体。
// WriteResponse 使用 errno.Decode 方法,根据错误类型,尝试从 err 中提取业务错误码和错误信息.
func WriteResponse(c *gin.Context, err error, data interface{}) {
if err != nil {
hcode, code, message := errno.Decode(err)
c.JSON(hcode, ErrResponse{
Code: code,
Message: message,
})
return
}
c.JSON(http.StatusOK, data)
}
可以看到,WriteResponse
函数会判断 err
是否为 nil
,如果 err != nil
说明需要返回错误信息,这时候会调用 errno.Decode
函数,解析错误,尝试从中提取出我们关注的信息。如果 err == nil
,直接返回需要返回的内容即可。
errno.Decode
函数内容如下:
// Decode 尝试从 err 中解析出业务错误码和错误信息.
func Decode(err error) (int, string, string) {
if err == nil {
return OK.HTTP, OK.Code, OK.Message
}
switch typed := err.(type) {
case *Errno:
return typed.HTTP, typed.Code, typed.Message
default:
}
// 默认返回未知错误码和错误信息. 该错误代表服务端出错
return InternalServerError.HTTP, InternalServerError.Code, err.Error()
}
可以看到,Decode
函数会断言 err
类型,如果是 Errno
类型时,就会返回 HTTP Status Code、Code、Message,之后 WriteResponse
方法会根据这些信息,返回 ErrResponse
类型的错误返回格式,并设置 HTTP Status Code。如果 err
不是 Errno
类型,则返回默认的 InternalServerError
。
既然,我们实现了错误码功能,并开发了统一返回方法。那么我们就要改造已有的路由方法,使用统一的返回:
// 注册 404 Handler.
g.NoRoute(func(c *gin.Context) {
core.WriteResponse(c, errno.ErrPageNotFound, nil )
})
// 注册 /healthz handler.
g.GET("/healthz", func(c *gin.Context) {
log.C(c).Infow("Healthz function called")
core.WriteResponse(c, nil , map [ string ] string { "status" : "ok" })
})
编译、运行、测试
通过执行以下命令来构建并启动服务:
$ make
$ $ _output/miniblog -c configs/miniblog.yaml
打开另外一个终端,执行 curl
命令,发送 HTTP 请求进行测试:
$ curl -v http://127.0.0.1:8080/v1/users
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET /v1/users HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.61.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Access-Control-Allow-Origin: *
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate, value
< Content-Type: application/json; charset=utf-8
< Expires: Thu, 01 Jan 1970 00:00:00 GMT
< Last-Modified: Thu, 01 Dec 2022 05:52:46 GMT
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-Request-Id: 34d54da0-265e-49fa-940b-d4f2876b4a4b
< X-Xss-Protection: 1; mode=block
< Date: Thu, 01 Dec 2022 05:52:46 GMT
< Content-Length: 68
<
* Connection #0 to host 127.0.0.1 left intact
{ "code" : "ResourceNotFound.PageNotFound" , "message" : "Page not found." }
可以看到 HTTP 请求返回了期望的格式和 HTTP Status Code。
小结
在 Go 项目开发中,很多项目都需要自定义错误码。本课程调研了一些业界的错误码实现,并结合我的理解选择了一个我认为是最佳实践的错误码格式:
{
"code": "InvalidParameter.BadAuthenticationData",
"message": "Bad Authentication data."
}
根据我们预设的错误格式,一步一步开发代码,实现这种格式的错误返回。通过 errno
包、WriteResponse
函数能够在一定程度上,保证返回错误的规范性,但在实际开发中,还需要开发者能够根据错误返回规范,在合适的地方返回合适的错误码。