作者:Oliver Nguyen

在 Go 里处理错误既简单又灵活——但完全没有结构!
这本该很简单,对吧?返回一个 error,包一层消息,然后继续往下走。可随着代码库不断增长——更多的包、更多的开发者、更多"暂时先这样"却永远留在那里的快速修复——这种简单很快就演变成一团混乱。渐渐地,日志里全是"failed to do this"和"unexpected that",没有人知道这到底是用户的问题、服务器的问题、代码本身有 bug,还是纯粹的天意作祟!
错误的创建方式各不相同,消息也不一致。每个包都有自己的一套风格、常量或自定义错误类型。错误码被随意添加。想知道某个函数可能返回哪些错误,除了钻进它的实现代码里,根本无从得知!
于是,我接下了打造一套新错误框架的挑战。我们决定采用一套结构化、集中化的系统,用命名空间编码,让错误变得有意义、可追溯——最重要的是,能让我们安心!
这就是我们如何从一套简单的错误处理方式起步,随着问题不断累积而彻底崩溃,最终打造出属于自己的错误框架的故事。文中会讲到设计决策、具体实现方式、学到的经验,以及它如何改变了我们管理错误的方式。希望它也能给你带来一些启发!
本文同时发布于 olivernguyen.io。
Go 处理错误的方式非常直接:错误就是值。一个 error 只是实现了 error 接口的一个值,该接口只有一个方法 Error() string。Go 函数不会抛出异常打断当前的执行流程,而是将 error 值和其他结果一起返回。调用者随后可以决定如何处理它:检查它的值来做决策、用新的消息和上下文把它包起来,或者干脆直接返回这个 error,把处理逻辑留给上层调用者。
我们可以让任何类型成为一个 error,只要给它加上 Error() string 方法即可。这种灵活性使每个包都可以定义自己的错误处理策略,选择最适合自己的方式。这也与 Go 强调可组合性的理念相契合,让错误的包装、扩展或自定义都变得容易。
常见的做法是返回一个实现了 error 接口的值,让调用者决定接下来该怎么做。下面是一个典型的例子:
func loadCredentials() (Credentials, error) {
data, err := os.ReadFile("cred.json")
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("file not found: %w", err)
}
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
cred, err := verifyCredentials(cred);
if err != nil {
return nil, fmt.Errorf("invalid credentials: %w", err)
}
return cred, nil
}
type xError struct {
msg message,
stack: callers(),
}
func Newf(msg string, args ...any) error {
return &xError{
msg: fmt.Sprintf(msg, args...),
stack: callers(), // 👈 stacktrace
}
}
func NewValuef(msg string, args ...any) error {
return fmt.Errorf(msg, args...) // 👈 no stacktrace
}
func Wrapf(err error, msg string, args ...any) error {
if err == nil { return nil }
stack := getStack(err)
if stack == nil { stack = callers() }
return &xError{
msg: fmt.Sprintf(msg, args...),
stack: stack,
}
}
package database
var ErrNotFound = errors.NewValue("record not found")
var ErrMultipleFound = errors.NewValue("multiple records found")
var ErrTimeout = errors.NewValue("request timeout")
package profile
var ErrUserNotFound = errors.NewValue("user not found")
var ErrBusinessNotFound = errors.NewValue("business not found")
var ErrContextCancel = errors.NewValue("context canceled")
用 errors.Is() 检查错误,并附加更多上下文进行包装
res, err := repo.QueryUser(ctx, req)
switch {
case err == nil:
// continue
case errors.Is(database.NotFound):
return nil, errors.Wrapf(ErrUserNotFound, "user not found (id=%v)", req.UserID)
default:
return nil, errors.Wrapf(ctx, "failed to query user (id=%v)", req.UserID)
}
这有助于在传播错误时附带更多细节,但也常常导致冗长、重复,日志的可读性反而变差:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
用 Protobuf 定义外部错误
对于面向外部的 API,我们采用了受 Meta 的 Graph API 启发的基于 Protobuf 的错误模型:
message Error {
string message = 1;
ErrorType type = 2;
ErrorCode code = 3;
string user_title = 4;
string user_message = 5;
string trace_id = 6;
map<string, string> details = 7;
}
enum ErrorType {
ERROR_TYPE_UNSPECIFIED = 1;
ERROR_TYPE_AUTHENTICATION = 2;
ERROR_TYPE_INVALID_REQUEST = 3;
ERROR_TYPE_RATE_LIMIT = 4;
ERROR_TYPE_BUSINESS_LIMIT = 5;
ERROR_TYPE_WEBHOOK_DELIVERY = 6;
}
enum ErrorCode {
ERROR_CODE_UNSPECIFIED = 1 [(error_type = UNSPECIFIED)];
ERROR_CODE_UNAUTHENTICATED = 2 [(error_type = AUTHENTICATION)];
ERROR_CODE_CAMPAIGN_NOT_FOUND = 3 [(error_type = NOT_FOUND)];
ERROR_CODE_META_CHOSE_NOT_TO_DELIVER = 4 /* ... */;
ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS = 5;
}
这种方式确实为错误提供了结构,但随着时间推移,错误类型和错误码在没有明确规划的情况下不断被添加进来,导致不一致和重复的问题。
错误声明散落各处
随意的错误包装导致日志前后不一、杂乱无序
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
缺乏标准化导致错误处理方式不当
缺乏分类使得监控无法进行
为了应对这些日益增长的挑战,我们决定围绕集中化和结构化的错误码这一核心理念,构建一套更好的错误策略。
所有错误码都在一个集中的地方,以命名空间结构定义。
使用命名空间来创建清晰、有意义且可扩展的错误码。例如:
每一层服务或库都只能返回属于自己命名空间的错误码。
所有错误都必须实现 Error 接口。
一个错误可以包装一个或多个错误。它们共同构成一棵树。
[FLD.INVALID_ARGUMENT] invalid argument
→ [TPL.INVALID_PARAMS] invalid input params
1. [TPL.PARAM.EMPTY] name can not be empty
2. [TPL.PARAM.MALFORM] invalid format for param[2]
始终要求传入 context.Context。可以为错误附加上下文信息。
当错误跨服务边界传递时,只暴露顶层的错误码。
对于外部错误,继续使用现有的 Protobuf ErrorCode 和 ErrorType。
自动将命名空间错误码映射到 Protobuf 代码、HTTP 状态码和标签。
有几个核心包构成了我们新错误处理框架的基础。
connectly.ai/go/pkgs/
Error 和 Code
Error 接口是标准 error 接口的扩展,额外提供了返回 Code 的方法。Code 的底层实现是 uint16。
package errors // import "connectly.ai/go/pkgs/errors"
type Error interface {
error
Code() Code
}
type Code struct {
code uint16
}
type CodeI interface {
CodeDesc() CodeDesc
}
type GroupI interface { /* ... */ }
type CodeDesc struct { /* ... */ }
包 errors/E 导出所有错误码和常用类型
package E // import "connectly.ai/go/pkgs/errors/E"
import "connectly.ai/go/pkgs/errors"
type Error = errors.Error
var (
DEPS = errors.DEPS
PRFL = errors.PRFL
)
func MapError(ctx context.Context, err error) errors.Mapper { /* ... */ }
func IsErrorCode(err error, codes ...errors.CodeI) { /* ... */ }
func IsErrorGroup(err error, groups ...errors.GroupI) { /* ... */ }
错误码示例:
// dependencies → postgres
DEPS.PG.NOT_FOUND
DEPS.PG.UNEXPECTED
// sdk → hash
SDK.HASH.UNEXPECTED
// profile → user
PRFL.USR.NOT_FOUND
PFRL.USR.UNKNOWN
// profile → user → repository
PRFL.USR.REPO.NOT_FOUND
PRFL.USR.REPO.UNKNOWN
// profile → auth
PRFL.AUTH.UNAUTHENTICATED
PRFL.AUTH.UNKNOWN
PRFL.AUTH.UNEXPECTED
database 包:
package database // import "connectly.ai/go/pkgs/database"
import "gorm.io/gorm"
import . "connectly.ai/go/pkgs/errors/E"
type DB struct { gorm: gorm.DB }
func (d *DB) Exec(ctx context.Context, sql string, params ...any) *DB {
tx := d.gorm.WithContext(ctx).Exec(sql, params...)
return wrapTx(tx)
}
func (x *DB) Error(msgArgs ...any) Error {
return wrapError(tx.Error()) // 👈 convert gorm error to 'Error'
}
func (x *DB) SingleRowError(msgArgs ...any) Error {
if err := x.Error(); err != nil { return err }
switch {
case x.RowsAffected == 1: return nil
case x.RowsAffected == 0:
return DEPS.PG.NOT_FOUND.CallerSkip(1).
New(x.Context(), formatMsgArgs(msgArgs))
default:
return DEPS.PG.UNEXPECTED.CallerSkip(1).
New(x.Context(), formatMsgArgs(msgArgs))
}
}
pb/services/profile 包:
package profile // import "connectly.ai/pb/services/profile"
// these types are generated from services/profile.proto
type QueryUserRequest struct {
BusinessId string
UserId string
}
type LoginRequest struct {
Username string
Password string
}
service/profile 包:
package profile
import uuid "github.com/google/uuid"
import . "connectly.ai/go/pkgs/errors/E"
import l "connectly.ai/go/pkgs/logging/l"
import profilepb "connectly.ai/pb/services/profile"
// repository requests
type QueryUserByUsernameRequest struct {
Username string
}
// repository layer → query user
func (r *UserRepository) QueryUserByUsernameAuth(
ctx context.Context, req *QueryUserByUsernameRequest,
) (*User, Error) {
if req.Username == "" {
return PRFL.USR.REPO.INVALID_ARGUMENT.New(ctx, "empty request")
}
var user User
sqlQuery := `SELECT * FROM "user" WHERE username = ? LIMIT 1`
tx := r.db.Exec(ctx, sqlQuery, req.Username).Scan(&user)
err := tx.SingleRowError()
switch {
case err == nil:
return &user, nil
case IsErrorCode(DEPS.PG.NOT_FOUND):
return PRFL.USR.REPO.USER_NOT_FOUND.
With(l.String("username", req.Username))
Wrap(ctx, "user not found")
default:
return PRFL.USR.REPO.UNKNOWN.
Wrap(ctx, "failed to query user")
}
}
// user service layer → query user
func (u *UserService) QueryUser(
ctx context.Context, req *profilepb.QueryUserRequest,
) (*profilepb.QueryUserResponse, Error) {
// ...
rr := QueryUserByUsernameRequest{ Username: req.Username }
err := u.repo.QueryUserByUsername(ctx, rr)
if err != nil {
return nil, MapError(ctx, err).
Map(PRFL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND,
"the user %q cannot be found", req.UserName,
api.UserTitle("User Not Found"),
api.UserMsg("The requested user id %q can not be found", req.UserId)).
KeepGroup(PRFL.USR).
Default(PRFL.USR.UNKNOWN, "failed to query user")
}
// ...
return resp, nil
}
// auth service layer → login user
func (a *AuthService) Login(
ctx context.Context, req *profilepb.LoginRequest,
) (*profilepb.LoginResponse, *profilepb.LoginResponse, Error) {
vl := PRFL.AUTH.INVALID_ARGUMENT.WithMsg("invalid request")
vl.Vl(req.Username != "", "no username", api.Detail("username is required"))
vl.Vl(req.Password != "", "no password", api.Detail("password is required"))
if err := vl.ToError(ctx); err != nil {
return err
}
hashpwd, err := hash.Hash(req.Password)
if err != nil {
return PRFL.AUTH.UNEXPECTED.Wrap(ctx, err, "failed to calc hash")
}
usrReq := profilepb.QueryUserByUsernameRequest{/*...*/}
usrRes, err := a.userServiceClient.QueryUserByUsername(ctx, usrReq)
if err != nil {
return nil, MapError(ctx, err).
Map(PRFL.USR.NOT_FOUND, PRFL.AUTH.UNAUTHENTICATED, "unauthenticated").
Default(PRFL.AUTH.UNKNOWN, "failed to query by username")
}
// ...
}
这里出现了不少新的函数和概念。我们一步步来梳理。
首先,使用点导入方式导入 errors/E 包
这样你就可以直接使用像 Error 这样的通用类型,而不必写成 errors.Error,也可以直接用 PRFL.USR.NOT_FOUND 这样的方式访问错误码,而不必写成 errors.PRFL.USR.NOT_FOUND。
import . "connectly.ai/go/pkgs/errors/E"
使用 CODE.New() 创建新的错误
假设你收到了一个无效请求,你可以这样创建一个新的错误:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
用 fmt.Print(err) 打印它:
[PRFL.USR.INVALID_ARGUMENT] invalid request
或者用 fmt.Printf("%+v") 查看更多细节:
[PRFL.USR.INVALID_ARGUMENT] invalid request
connectly.ai/go/services/profile.(*UserService).QueryUser
/usr/i/src/go/services/profile/user.go:1234
connectly.ai/go/services/profile.(*UserRepository).QueryUser
/usr/i/src/go/services/profile/repo/user.go:2341
使用 CODE.Wrap() 将一个错误包装进新的错误中
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found")
usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
用 fmt.Print(usrErr) 会输出:
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
或者用 fmt.Printf("%+v", usrErr):
[PRFL.USR.NOT_FOUND] user not found
→ [DEPS.PG.NOT_FOUND] not found
→ record not found
connectly.ai/go/services/profile.(*UserService).QueryUser
/usr/i/src/go/services/profile/user.go:1234
堆栈跟踪来自最内层的 Error。如果你在写一个辅助函数,可以使用 CallerSkip(skip) 来跳过对应层级:
func mapUserError(ctx context.Context, err error) Error {
switch {
case IsErrorCode(err, DEPS.PG.NOT_FOUND):
return PRFL.USR.NOT_FOUND.CallerSkip(1).Wrap(ctx, err, "...")
default:
return PRFL.USR.UNKNOWN.CallerSkip(1).Wrap(ctx, err, "...")
}
}
用****With() 为错误添加上下文
import l "connectly.ai/go/pkgs/logging/l"
usrErr := PRFL.USR.NOT_FOUND.
With(l.UUID("user_id", req.UserID), l.String("flag", flag)).
Wrap(ctx, dbErr, "user not found")
这些标签可以通过 fmt.Printf("%+v", usrErr) 输出:
[PRFL.USR.NOT_FOUND] user not found
{"user_id": "81febc07-5c06-4e01-8f9d-995bdc2e0a9a", "flag": "ABRW"}
→ [DEPS.PG.NOT_FOUND] not found
{"a number": 42}
→ record not found
直接在 New()、 Wrap() 或 MapError() 内部为错误添加上下文:
借助 l.String() 函数及其相关函数族,New() 等函数可以智能地从格式化参数中检测出标签,不需要为此单独引入不同的函数。
err := INF.HEALTH.NOT_READY.New(ctx,
"service %q is not ready (retried %v times)",
req.ServiceName,
l.String("flag", flag)
countRetries,
l.Number("count", countRetries),
)
将会输出:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times)
{"flag": "ABRW", "count": 2}
目前有 3 种类型实现了 Error 接口。如有需要,你可以添加更多类型。每种类型可以有不同的结构,并针对特定需求提供自定义方法。
Error 是 Go 标准 error 接口的扩展
type Error interface {
error
Code()
Message()
Fields() []tags.Field
StackTrace() stacktrace.StackTrace
_base() *base // a private method
}
它包含一个私有方法,用来确保我们不会在 errors 包之外意外实现新的 Error 类型。未来当我们对更多使用模式积累了经验后,也许会(或不会)取消这一限制。
为什么不直接用标准的 error 接口再做类型断言?
因为我们希望区分第三方错误和我们内部的错误。我们内部代码中的所有层和包都必须始终返回 Error。这样我们就能清楚地知道什么时候需要转换第三方错误,什么时候只需要处理我们内部的错误码。
这也在已迁移的包和尚未迁移的包之间划出了一条边界。*回到现实,我们不可能凭空声明一个新类型,挥一挥魔法棒,念一句咒语——或者写一条 prompt——然后几百万行代码就神奇地自动转换、无缝运行、毫无 bug!*不,那样的未来还没到来。或许有一天会实现,但现在,我们还得一个包一个包地进行迁移。
Error0 是默认的 Error 类型
大多数错误码会生成一个 Error0 值。它包含一个 base 和一个可选的子错误。你可以使用 NewX() 返回一个具体的 *Error0 结构体,而不是 Error 接口,但需要小心处理。
type Error0 struct {
base
err error
}
var errA: Error = DEPS.PG.NOT_FOUND.New (ctx, "not found")
var errB: *Error0 = DEPS.PG.NOT_FOUND.NewX(ctx, "not found")
base 是所有 Error 实现共享的通用结构,提供 Code()、Message()、StackTrace()、Fields() 等通用功能。
type base struct {
code Code
msg string
kv []tags.Field
stack stacktrace.StackTrace
}
VlError 用于验证类错误
它可以包含多个子错误,并提供了很多方便的方法来配合验证辅助函数使用。
type VlError struct {
base
errs []error
}
你可以像创建其他 Error 一样创建一个 VlError:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
或者创建一个 VlBuilder,往里面添加错误,再将其转换为 VlError:
userID, err0 := parseUUID(req.UserId)
err1 := validatePassword(req.Password)
vl := PRFL.USR.INVALID_ARGUMENT.WithMsg("invalid request")
vl.Add(err0, err1)
vlErr := vl.ToError(ctx)
并像往常一样附带键值对:
vl := PRFL.USR.INVALID_ARGUMENT.
With(l.Bool("testingenv", true)).
WithMsg("invalid request")
userID, err0 := parseUUID(req.UserId)
err1 := validatePassword(req.Password)
vl.Add(err0, err1)
vlErr := vl.ToError(ctx, l.String("user_id", req.UserId))
使用 fmt.Printf("%+v", vlErr) 将输出:
[PRFL.USR.INVALID_ARGUMENT] invalid request
{"testingenv": true, "user_id": "A1234567890"}
ApiError 是用于迁移 API 错误的适配器
在此之前,我们使用一个独立的 api.Error 结构体,将 API 错误返回给前端和外部客户端。它包含前面提到过的 ErrorType 和 ErrorCode。
package api
import errorpb "connectly.ai/pb/models/error"
// Deprecated
type Error struct {
pbType errorpb.ErrorType
pbCode errorpb.ErrorCode
cause error
msg string
usrMsg string
usrTitle string
// ...
}
这个类型现在已经被弃用。取而代之的是,我们会在一个集中的地方声明所有的映射关系(ErrorType、ErrorCode、gRPC 代码、HTTP 代码),并在相应的边界处进行转换。我会在下一节讨论错误码声明的问题。
为了迁移到新的命名空间错误框架,我们添加了一个临时命名空间 ZZZ.API_TODO。每个 ErrorCode 都会变成一个 ZZZ.API_TODO 码。
ZZZ.API_TODO.UNEXPECTED
ZZZ.API_TODO.INVALID_REQUEST
ZZZ.API_TODO.USERNAME_
ZZZ.API_TODO.META_CHOSE_NOT_TO_DELIVER
ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS
ApiError 则作为一个适配器被创建出来。所有之前返回 *api.Error 的函数,都被改为返回 Error(由 *ApiError 实现)。
package api
import . "connectly.ai/go/pkgs/errors/E"
// previous
func FailPreconditionf(err error, msg string, args ...any) *Error {
return &Error{
pbType: ERROR_TYPE_FAILED_PRECONDITION,
pbCode: ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS,
cause: err,
msg: fmt.Sprintf(msg, args...)
}
}
// current: this is deprecated, and serves and an adapter
func FailPreconditionf(err error, msg string, args ...any) *Error {
ctx := context.TODO()
return ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS.
CallerSkip(1). // correct the stacktrace by 1 frame
Wrap(ctx, err, msg, args...)
}
当所有迁移完成后,之前的用法:
wabaErr := verifyWabaTemplateStatus(tpl)
apiErr := api.FailPreconditionf(wabaErr, "template cannot be edited").
WithErrorCode(ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS).
WithUserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead.").
ErrorOrNil()
应该变成:
CPG.TPL.EDIT_ONCE_IN_24_HOURS.Wrap(
wabaErr, "template cannot be edited",
api.UserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."))
注意 ErrorCode 是从内部命名空间码隐式推导出来的,不需要每次都显式赋值。但如何声明码与码之间的关系呢?这将在下一节说明。
到这里,你已经了解了如何用现有的码创建新错误。接下来是时候讲讲码本身,以及如何添加一个新的码。
一个 Code 的底层实现是 uint16 值,对应一个字符串表示形式。
type Code struct { code: uint16 }
fmt.Printf("%q", DEPS.PG.NOT_FOUND)
// "DEPS.PG.NOT_FOUND"
为了存储这些字符串,有一个存放所有可用 CodeDesc 的数组:
const MaxCode = 321 // 👈 this value is generated
var allCodes [MaxCode]CodeDesc
type CodeDesc {
c int // 42
code string // DEPS.PG.NOT_FOUND
api APICodeDesc
}
type APICodeDesc {
ErrorType errorpb.ErrorType
ErrorCode errorpb.ErrorCode
HttpCode int
DefMessage string
UserMessage string
UserTitle string
}
下面是错误码的声明方式:
var DEPS deps // dependencies
var PRFL prfl // profile
var FLD fld // flow document
type deps struct {
PG pg // postgres
RD rd // redis
}
// tag:postgres
type pg struct {
NOT_FOUND Code0 // record not found
CONFLICT Code0 // record already exist
MALFORM_SQL Code0
}
// tag:profile
type PRFL struct {
REPO prfl_repo
USR usr
AUTH auth
}
// tag:profile
type prfl_repo struct {
NOT_FOUND Code0 // internal error code
INVALID_ARGUMENT VlCode // internal error code
}
// tag:user
type usr struct {
NOT_FOUND Code0 `api-code:"USER_NOT_FOUND"`
INVALID_ARGUMENT VlCode `api-code:"INVALID_ARGUMENT"`
DISABlED_ACCOUNT Code0 `api-code:"DISABLED_ACCOUNT"`
}
// tag:auth
type auth struct {
UNAUTHENTICATED Code0 `api-code:"UNAUTHENTICATED"`
PERMISSION_DENIED Code0 `api-code:"PERMISSION_DENIED"`
}
声明好新的错误码之后,你需要运行代码生成脚本:
run gen-errors
生成的代码会是这样的:
// Code generated by error-codes. DO NOT EDIT.
func init() {
// ...
PRFL.AUTH.UNAUTHENTICATED = Code0{Code{code: 143}}
PRFL.AUTH.PERMISSION_DENIED = Code0{Code{code: 144}}
// ...
allCodes[143] = CodeDesc{
c: 143, code: "PRFL.AUTH.UNAUTHENTICATED",
tags: []string{"auth", "profile"},
api: APICodeDesc{
ErrorType: ERROR_TYPE_UNAUTHENTICATED,
ErrorCode: ERROR_CODE_UNAUTHENTICATED,
HTTPCode: 401,
DefMessage: "Unauthenticated error",
UserMessage: "You are not authenticated.",
UserTitle: "Unauthenticated error",
}))
}
每种 Error 类型都对应一种 Code 类型
有没有想过,为什么 PRFL.USR.NOT_FOUND.New() 会创建出一个 *Error0,而 PRFL.USR.INVALID_ARGUMENTS.New() 却会创建出一个 *VlError?原因就在于它们使用了不同的码类型。
而每种 Code 类型返回不同的 Error 类型,各自还能有自己独有的额外方法:
type Code0 struct { Code }
type VlCode struct { Code }
func (c Code0) New(/*...*/) Error {
return &Error0{/*...*/}
}
func (c VlCode) New(/*...*/) Error {
return &VlError{/*...*/}
}
// extra methods on VlCode to create VlBuilder
func (c VlCode) WithMsg(msg string, args ...any) *VlBuilder {/*...*/}
type VlBuilder struct {
code VlCode
msg string
args []any
}
func (b *VlBuilder) ToError(/*...*/) Error {
return &VlError{Code: code, /*...*/ }
}
用 api-code 标记可供外部 API 使用的错误码
用枚举选项在 protobuf 中声明 ErrorCode、 ErrorType 与 gRPC/HTTP 代码之间的映射关系:
// error/type.proto
ERROR_TYPE_PERMISSION_DENIED = 707 [(error_type_detail_option) = {
type: "PermissionDeniedError",
grpc_code: PERMISSION_DENIED,
http_code: 403, // Forbidden
message: "permission denied",
user_title: "Permission denied",
user_message: "The caller does not have permission to execute the specified operation.",
}];
// error/code.proto
ERROR_CODE_DISABlED_ACCOUNT = 70020 [(error_code_detail_option) = {
error_type: ERROR_TYPE_DISABlED_ACCOUNT,
grpc_code: PERMISSION_DENIED,
http_code: 403, // Forbidden
message: "account is disabled",
user_title: "Account is disabled",
user_message: "Your account is disabled. Please contact support for more information.",
}];
UNEXPECTED 与 UNKNOWN 码
每一层通常都有 2 个通用码 UNEXPECTED 和 UNKNOWN。它们的用途略有不同:
当收到一个从函数返回的错误时,你需要处理它:把第三方错误转换为内部命名空间错误,并把内层的错误码映射到外层。
将第三方错误转换为内部命名空间错误
如何处理错误取决于:第三方包返回了什么,以及你的应用需要什么。例如,处理数据库或外部 API 错误时:
switch {
case errors.Is(err, sql.ErrNoRows):
// map a database "no rows" error to an internal "not found" error
return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found")
case errors.Is(err, context.DeadlineExceeded):
// map a context deadline exceeded error to a timeout error
return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout")
default:
// wrap any other error as unknown
return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error")
}
使用辅助函数处理内部命名空间错误
典型的使用模式:
user, err := queryUser(ctx, userReq)
switch {
case err == nil:
// continue
case IsErrorCode(PRL.USR.REPO.NOT_FOUND):
// check for specific error code and convert to external code
// and return as HTTP 400 Not Found
return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found")
case IsGroup(PRL.USR):
// errors belong to the PRFL.USR group are returned as is
return nil, err
default:
return nil, PRL.USR.UNKNOWN.Wrap(ctx, err, "failed to query user")
}
用 MapError() 更轻松地编写映射代码:
由于映射错误码是一种常见模式,我们提供了 MapError() 辅助函数来加快代码编写。上面的代码可以重写为:
user, err := queryUser(ctx, userReq)
if err != nil {
return nil, MapError(ctx, err).
Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user not found").
KeepGroup(PRF.USR).
Default(PRL.USR.UNKNOWN, "failed to query user")
}
你可以像往常一样格式化参数并附加键值对:
return nil, MapError(ctx, err).
Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND,
"user %v not found", username,
l.String("flag", flag)).
KeepGroup(PRF.USR).
Default(PRL.USR.UNKNOWN, "failed to query user",
l.Any("retries", retryCount))
测试对于任何认真对待的代码库来说都至关重要。该框架提供了像 ΩxError() 这样的专用辅助函数,让编写和断言错误条件的测试更轻松、更具表现力。
// 👉 return true if the error contains the message
ΩxError(err).Contains("not found")
// 👉 return true if the error does not contain the message
ΩxError(err).NOT().Contains("not found")
还有更多方法,而且它们也可以链式调用:
ΩxError(err).
MatchCode(DEPS.PG.NOT_FOUND). // match any code in top or wrapped errors
TopErrorMatchCode(PRFL.TPL.NOT_FOUND) // only match code from the top error
MatchAPICode(API_CODE.WABA_TEMPLATE_NOTE_FOUND). // match errorpb.ErrorCode
MatchExact("exact message to match")
为什么用方法而不是 Ω(err).To(testing.MatchCode())?
因为方法更容易被发现。当你面对像 testing.MatchValues() 这样几十个函数时,很难知道哪些适用于 Error、哪些不适用。而用方法的话,只需输入一个点 .,IDE 就会列出所有专门为断言 Error 而设计的可用方法。
框架只是故事的一半。写代码?那是简单的部分。真正的挑战开始于把它带入一个庞大、活跃的代码库中——几十位工程师每天都在推送变更,客户期望一切都完美运行,而系统必须持续运转,一刻不能停。
迁移是一件需要担责任的事情。它意味着小心地把代码拆成一小块一小块,每次只做微小的改动,过程中会打破大量测试。然后逐一手动检查并修复它们,合并到主分支,部署到生产环境,盯着日志和告警。如此反复……
以下是我们在迁移过程中学到的一些经验:
**从搜索替换开始:**先用新框架替换旧模式,修复这个过程中出现的编译错误。
例如,把这个包里所有的 error 替换为 Error。
type ProfileController interface {
LoginUser(req *LoginRequest) (*LoginResponse, error)
QueryUser(req *QueryUserRequest) (*QueryUserResponse, error)
}
新代码会是这样:
import . "connectly.ai/go/pkgs/errors"
type ProfileController interface {
LoginUser(req *LoginRequest) (*LoginResponse, Error)
QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error)
}
**一次迁移一个包:**从最底层的包开始,逐步往上迁移。这样可以确保底层包在向上层迁移之前已经完全迁移完毕。
**补充缺失的单元测试:**如果代码库某些部分缺少测试,就补上。如果你对自己的改动没有信心,就多加一些测试。它们能帮助确保你的改动不会破坏现有功能。
**如果你的包依赖调用更高层的包:**可以考虑把相关函数标记为 DEPRECATED,再添加使用新 Error 类型的新函数。
假设你正在迁移 database 包,它有一个 Transaction() 方法:
package database
func (db *DB) Transaction(ctx context.Context,
fn func(tx *gorm.DB) error) error {
return db.gorm.Transaction(func(tx *gorm.DB) error {
return fn(tx)
})
}
它被用在 user service 包中:
err = s.DB(ctx).Transaction(func(tx *database.DB) error {
user, usrErr := s.repo.CreateUser(ctx, tx, user)
if usrErr != nil {
return usrErr
}
}
由于你先迁移了 database 包,暂时保持 user 以及其他几十个包不变。s.repo.CreateUser() 调用仍然返回旧的错误类型,而 Transaction() 方法需要返回新的 Error 类型。你可以把 Transaction() 方法标记为 DEPRECATED,再添加一个新的 TransactionV2() 方法:
package database
// DEPRECATED: use TransactionV2 instead
func (db *DB) Transaction_DEPRECATED(ctx context.Context,
fn func(tx *gorm.DB) error) error {
return db.gorm.Transaction(func(tx *gorm.DB) error {
return fn(tx)
})
}
func (db *DB) TransactionV2(ctx context.Context,
fn func(tx *gorm.DB) error) Error {
err := db.gorm.Transaction(func(tx *gorm.DB) error {
return fn(tx)
})
return adaptToErrorV2(err)
}
随手添加新的错误码:当你遇到一个不适合归入现有码的错误时,就添加一个新码。这有助于你随着时间推移,逐步建立起一套完善的错误码体系。其他包中的码始终可以作为参考。
Go 里的错误处理乍一看很简单——返回一个 error,然后继续往下走。但随着我们的代码库不断扩大,这种简单很快演变成一团纠结的乱麻:含糊不清的日志、不一致的处理方式,以及没有尽头的调试过程。
通过退一步重新思考我们处理错误的方式,我们打造出了一套真正为我们所用、而不是与我们作对的系统。集中化和结构化的命名空间码为我们带来了清晰度,而用于映射、包装和测试错误的工具也让我们的工作轻松了不少。我们不再在茫茫日志海中挣扎,而是拥有了有意义、可追溯的错误,能清楚地告诉我们哪里出了问题、该往哪里看。
这套框架不仅仅是让我们的代码更整洁,它还为我们节省了时间、减少了挫败感,并帮助我们为未知的问题做好准备。这只是一段旅程的开始——我们仍在不断发现更多模式——但最终的结果是一套能给错误处理带来一丝安心的系统。希望它也能为你的项目带来一些启发!😊
我是 Oliver Nguyen —— Connectly.ai 的一名软件工程师。我喜欢不断学习,每天都希望看到更好的自己。偶尔会分拆出一些新的开源项目。在旅程中分享知识和想法。
本文同时发布于 olivernguyen.io。