10、实现统一的错误返回

在 Go 项目开发中,为了方便客户端处理返回,排查错误,还需要实现统一的错误返回。统一的错误返回包括以下 2 个方面:

  • 错误格式统一:返回统一的错误格式,方便客户端解析,并获取错误;
  • 自定义业务错误码:HTTP 的错误码有限,并且不适合业务错误码。所以,在实际开发中,还需要自定义业务错误码。

为了实现统一的错误返回,接下来还需要实现错误包和自定义错误码。

错误返回方法

先来看下错误返回的方式。在 Go 项目开发中,错误的返回方式通常有以下两种

  1. 始终返回 HTTP 200 状态码,并在 HTTP 返回体中返回错误信息;
  2. 返回 HTTP 400 状态码 (Bad Request),并在 HTTP 返回体中返回错误信息。
方式一: 成功返回,返回体中返回错误信息

例如 Facebook API 的错误返回设计,始终返回 200 HTTP 状态码:

image

在上述错误返回的实现方式中,HTTP 状态码始终固定返回 200,仅需关注业务错误码,整体实现较为简单。然而,此方式存在一个明显的缺点:对于每一次 HTTP 请求,既需要检查 HTTP 状态码以判断请求是否成功,还需要解析响应体以获取业务错误码,从而判断业务逻辑是否成功。理想情况下,我们期望客户端对成功的 HTTP 请求能够直接将响应体解析为需要的 Go 结构体,并进行后续的业务逻辑处理,而不用再判断请求是否成功。

方式二: 失败返回,返回体中返回错误信息

Twitter API 的错误返回设计会根据错误类型返回对应的 HTTP 状态码,并在返回体中返回错误信息和自定义业务错误码。成功的业务请求则返回 200 HTTP 状态码。例如:

image

方式二相比方式一,对于成功的请求不需要再次判错。然而,方式二还可以进一步优化:整数格式的业务错误码215 可读性较差,用户无法从215 直接获取任何有意义的信息。建议将其替换为语义化的字符串,例如: NotFound.PostNotFound

Twitter API 返回的错误是一个数组,在实际开发获取错误时,需要先判断数组是否为空,如不为空,再从数组中获取错误,开发复杂度较高。建议采用更简单的错误返回格式:

image

需要特别注意的是,message 字段会直接展示给外部用户,因此必须确保其内容不包含敏感信息,例如数据库的id 字段、内部组件的IP 地址、用户名等信息。返回的错误信息中,还可以根据需要返回更多字段,例如: 错误指引文档 URL 等。

fastgo 错误返回设计和实现

fastgo 项目错误返回格式采用了方式二,在接口失败时返回对应的 HTTP/gRPC 状态码,并在返回体中返回具体的错误信息,例如:

image

指定错误码规范

错误码是直接暴露给用户的,因此需要设计一个易读、易懂且规范化的错误码。在设计错误码时可以根据实际需求自行设计,也可以参考其他优秀的设计方案。

一般来说,当调研某项技术实现时,建议优先参考各大公有云厂商的实现方式,例如腾讯云、阿里云、华为云等。这些公有云厂商直接面向企业和个人,专注于技术本身,拥有强大的技术团队,因此它们的设计与实现具有很高的参考价值。

经过调研,此处采用了腾讯云 AP1 3.0 的错误码设计规范。腾讯云采用了两级错误码设计。以下是两级错误码设计相较于简单错误码 (如 215、InvalidParameter) 的优势:

  • 语义化::语义化的错误码可以通过名字直接反映错误的类型,便于快速理解错误;。
  • 更加灵活: 二级错误码的格式为<平台级.资源级>。其中,平台级错误码是固定值,用于指代某一类错误,客户端可以利用该错误码进行通用错误处理。资源级错误码则用于更精确的错误定位。此外,服务端既可根据需求自定义错误码,也可使用默认错误码
fastgo 错误码定义

在实现了 errorsx 错误包之后,便可以根据需要预定义项目需要的错误。这些错误,可以在代码中便捷的引用。通过直接引用预定义错误,不仅可以提高开发效率,还可以保持整个项目的错误返回是一致的。

fastgo 项目预定义了一些错误码,这些错误码位于以下 3 个文件中:

  • internal/pkg/errorsx/code.go:定义了一些通用的错误码;
package errorsx

import "net/http"

// errorsx 预定义标准的错误.
var (
	// OK 代表请求成功.
	OK = &ErrorX{Code: http.StatusOK, Message: ""}

	// ErrInternal 表示所有未知的服务器端错误.
	ErrInternal = &ErrorX{Code: http.StatusInternalServerError, Reason: "InternalError", Message: "Internal server error."}

	// ErrNotFound 表示资源未找到.
	ErrNotFound = &ErrorX{Code: http.StatusNotFound, Reason: "NotFound", Message: "Resource not found."}

	// ErrDBRead 表示数据库读取失败.
	ErrDBRead = &ErrorX{Code: http.StatusInternalServerError, Reason: "InternalError.DBRead", Message: "Database read failure."}

	// ErrDBWrite 表示数据库写入失败.
	ErrDBWrite = &ErrorX{Code: http.StatusInternalServerError, Reason: "InternalError.DBWrite", Message: "Database write failure."}

	// ErrBind 表示请求体绑定错误.
	ErrBind = &ErrorX{Code: http.StatusBadRequest, Reason: "BindError", Message: "Error occurred while binding the request body to the struct."}

	// ErrInvalidArgument 表示参数验证失败.
	ErrInvalidArgument = &ErrorX{Code: http.StatusBadRequest, Reason: "InvalidArgument", Message: "Argument verification failed."}

	// ErrSignToken 表示签发 JWT Token 时出错.
	ErrSignToken = &ErrorX{Code: http.StatusUnauthorized, Reason: "Unauthenticated.SignToken", Message: "Error occurred while signing the JSON web token."}

	// ErrTokenInvalid 表示 JWT Token 格式无效.
	ErrTokenInvalid = &ErrorX{Code: http.StatusUnauthorized, Reason: "Unauthenticated.TokenInvalid", Message: "Token was invalid."}
)
  • internal/pkg/errorsx/user.go:定义了用户相关的错误码;
package errorsx

import "net/http"

var (
	// ErrUsernameInvalid 表示用户名不合法.
	ErrUsernameInvalid = &ErrorX{
		Code:    http.StatusBadRequest,
		Reason:  "InvalidArgument.UsernameInvalid",
		Message: "Invalid username: Username must consist of letters, digits, and underscores only, and its length must be between 3 and 20 characters.",
	}

	// ErrPasswordInvalid 表示密码不合法.
	ErrPasswordInvalid = &ErrorX{
		Code:    http.StatusBadRequest,
		Reason:  "InvalidArgument.PasswordInvalid",
		Message: "Password is incorrect.",
	}

	// ErrUserAlreadyExists 表示用户已存在.
	ErrUserAlreadyExists = &ErrorX{Code: http.StatusBadRequest, Reason: "AlreadyExist.UserAlreadyExists", Message: "User already exists."}

	// ErrUserNotFound 表示未找到指定用户.
	ErrUserNotFound = &ErrorX{Code: http.StatusNotFound, Reason: "NotFound.UserNotFound", Message: "User not found."}
)
  • internal/pkg/errorsx/post.go:定义了博客相关的错误码。
package errorsx

import "net/http"

// ErrPostNotFound 表示未找到指定的博客.
var ErrPostNotFound = &ErrorX{Code: http.StatusNotFound, Reason: "NotFound.PostNotFound", Message: "Post not found."}
fastgo 错误包设计

为了避免与标准库的 errors 包命名冲突,fastgo 项目的错误包命名为 errorsx,寓意为“扩展的错误处理包”。

由于 fastgo 项目的错误包命名为 errorsx,为保持命名一致性,定义了一名为ErrorX 的结构体,用于描述错误信息,具体定义如下:

// ErrorX 定义了 OneX 项目体系中使用的错误类型,用于描述错误的详细信息.
type ErrorX struct {
    // Code 表示错误的 HTTP 状态码,用于与客户端进行交互时标识错误的类型.
    Code int `json:"code,omitempty"`

    // Reason 表示错误发生的原因,通常为业务错误码,用于精准定位问题.
    Reason string `json:"reason,omitempty"`

    // Message 表示简短的错误信息,通常可直接暴露给用户查看.
    Message string `json:"message,omitempty"`
}

ErrorX是一个错误类型,所以需要实现Error方法

// Error 实现 error 接口中的 `Error` 方法.
func (err *ErrorX) Error() string {
    return fmt.Sprintf("error: code = %d reason = %s message = %s", err.Code, err.Reason, err.Message)
}

Error() 返回的错误信息中,包含了 HTTP 状态码、错误发生的原因、错误信息。通过这些详尽的错误信息返回,帮助开发者快速定位错误。

在 Go 项目开发中,发生错误的原因有很多,大多数情况下,开发者希望将真实的错误信息返回给用户。因此,还需要提供一个方法用来设置 ErrorX 结构体中的 Message 字段。为了满足上述诉求,给 ErrorX 增加WithMessage 方法。实现方式如下是代码所示(位于文件 internal/pkg/errorsx/errorsx.go 中):

package errorsx

import (
	"errors"
	"fmt"
)

// ErrorX
// @Description: 定义项目中使用的错误类型,用于描述错误的详细信息
type ErrorX struct {
	// code表示错误的HTTP状态码,用于与客户端进行交互时标识错误的类型
	Code int `json:"code,omitempty"`
	// Reason表示错误发生的原因,通常为业务错误码,用于精准定位问题
	Reason string `json:"reason,omitempty"`
	// Message表示简短的错误信息,通常可直接暴露给用户查看
	Message string `json:"message,omitempty"`
}

// 创建一个新的错误
func New(code int, reason string, format string, args ...any) *ErrorX {
	return &ErrorX{
		Code:    code,
		Reason:  reason,
		Message: fmt.Sprintf(format, args...),
	}
}

// 实现error接口的Error方法
func (e *ErrorX) Error() string {
	return fmt.Sprintf("error: code = %d reason = %s message = %s", e.Code, e.Reason, e.Message)
}

// WithMessage 设置错误的Message字段
func (e *ErrorX) WithMessage(format string, args ...any) *ErrorX {
	e.Message = fmt.Sprintf(format, args...)
	return e
}

// 尝试将一个通用的error转换为自定义的 *ErrorX类型
func FromError(err error) *ErrorX {
	if err == nil {
		return nil
	}
	if errx := new(ErrorX); errors.As(err, &errx) {
		return errx
	}
	// 默认返回未知错误错误. 该错误代表服务端出错
	return New(ErrInternal.Code, ErrInternal.Reason, err.Error())
}

在 Go 项目开发中,通常需要将一个 error 类型的错误 err ,解析为*ErrorX 类型,并获取*ErrorX中的 Code 字段和 Reason 字段的值。 Code 字段可用来设置 HTTP 状态码,Reason 字段可用来判断错误类型。为此,errorsx 包实现了 FromError 方法,具体实现如下所示。

// 尝试将一个通用的error转换为自定义的 *ErrorX类型
func FromError(err error) *ErrorX {
	// 如果传入的错误是 nil,则直接返回 nil,表示没有错误需要处理.
	if err == nil {
		return nil
	}
	// 检查传入的 error 是否已经是 ErrorX 类型的实例.
	// 如果错误可以通过 errors.As 转换为 *ErrorX 类型,则直接返回该实例.
	if errx := new(ErrorX); errors.As(err, &errx) {
		return errx
	}
	// 默认返回未知错误错误. 该错误代表服务端出错
	return New(ErrInternal.Code, ErrInternal.Reason, err.Error())
}

fastgo 错误返回规范

为了标准化接口错误返回,fastgo 项目提供了通用的接口返回函数,该函数可以解析错误,并返回固定的错误返回格式。实现代码位于 internal/pkg/core/core.go 文件中,代码内容如下:

package core

import (
	"github.com/gin-gonic/gin"
	"gitlab.com/onexstack/fastgo/internal/pkg/errorsx"
	"net/http"
)

// ErrorResponse 定义了错误响应的结构,
// 用于 API 请求中发生错误时返回统一的格式化错误信息.
type ErrorResponse struct {
	// 错误原因,标识错误类型
	Reason string `json:"reason,omitempty"`
	// 错误详情的描述信息
	Message string `json:"message,omitempty"`
}

// WriteResponse 是通用的响应函数.
// 它会根据是否发生错误,生成成功响应或标准化的错误响应.
func WriteResponse(c *gin.Context, data any, err error) {
	if err != nil {
		// 如果发生错误,生成错误响应
		errx := errorsx.FromError(err) // 提取错误详细信息
		c.JSON(errx.Code, ErrorResponse{
			Reason:  errx.Reason,
			Message: errx.Message,
		})
		return
	}

	// 如果没有错误,返回成功响应
	c.JSON(http.StatusOK, data)
}

上述代码,定义了一个通用的错误返回结构体:ErrorResponse。ErrorResponse 中包含了错误返回的原因和消息。

在 API 接口返回时,会调用 WriteResponse 函数。WriteResponse 函数会判断是否发生了错误,如果发生了错误,会解析错误为 errorsx,Errorx 类型的错误,并从中获取 Code 和 Reason 字段,并设置给 ErrorResponse 类型的变量。如果没有发生错误,直接返回自定义数据。

返回统一的错误格式

上面,我们开发了错误包、自定义了错误返回码,并提供了接口返回函数 WriteResponse 。接下来,就可以使用 WriteResponse 来返回接口数据。

更新internal/apiserver/server.go 文件中的 NoRoute 返回实现和/healthz接口的返回实现。代码变更如下

func (cfg *Config) NewServer() (*Server, error) {
	// 创建Gin引擎
	engine := gin.New()
	// gin.Recovery() 中间件,用来捕获任何panic并恢复
	mws := []gin.HandlerFunc{gin.Recovery(), middleware.NoCache, middleware.Cors, middleware.RequestID()}
	engine.Use(mws...)
	// 注册404 handler
	engine.NoRoute(func(c *gin.Context) {
		core.WriteResponse(c, nil, errorsx.ErrNotFound.WithMessage("Page not found"))

	})
	// 注册/healthz handler
	engine.GET("/healthz", func(c *gin.Context) {
		core.WriteResponse(c, map[string]string{"status": "ok"}, nil)

	})
	// 创建HTTP Server实例
	httpsrv := &http.Server{Addr: cfg.Addr, Handler: engine}

	return &Server{cfg: cfg, srv: httpsrv}, nil
}

上面的代码通过调用WriteResponse 返回了标准的错误返回。并且在NoRoute路由函数中,还指定了要返回的自定义错误码ErrNotFound设置了自定义返回消息

编译并测试

(base) dujie@MacBook-Pro fastgo % sh build.sh                                        
gitlab.com/onexstack/fastgo/internal/apiserver
gitlab.com/onexstack/fastgo/cmd/fg-apiserver/app/options
command-line-arguments
(base) dujie@MacBook-Pro fastgo % 
(base) dujie@MacBook-Pro fastgo % 
(base) dujie@MacBook-Pro fastgo % ./_output/fg-apiserver -c configs/fg-apiserver.yaml
/Users/dujie/.fastgo/fg-apiserver.yaml
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /healthz                  --> gitlab.com/onexstack/fastgo/internal/apiserver.(*Config).NewServer.func2 (5 handlers)
{"time":"2025-04-02T15:40:49.328599+08:00","level":"INFO","msg":"Read MYSQL host from config 127.0.0.1:3306"}
{"time":"2025-04-02T15:40:49.328736+08:00","level":"INFO","msg":"Start to listening the incoming requests on http address","addr":"0.0.0.0:6666"}
(base) dujie@MacBook-Pro fastgo % curl http://127.0.0.1:6666/nonexist
{"reason":"NotFound","message":"Page not found"}%