Connectly
技术2024-12-05

错误无处不在:我们如何在 Go 中集中化并结构化错误处理

作者:Oliver Nguyen

错误无处不在:我们如何在 Go 中集中化并结构化错误处理

在 Go 里处理错误既简单又灵活——但完全没有结构!

这本该很简单,对吧?返回一个 error,包一层消息,然后继续往下走。可随着代码库不断增长——更多的包、更多的开发者、更多"暂时先这样"却永远留在那里的快速修复——这种简单很快就演变成一团混乱。渐渐地,日志里全是"failed to do this"和"unexpected that",没有人知道这到底是用户的问题、服务器的问题、代码本身有 bug,还是纯粹的天意作祟!

错误的创建方式各不相同,消息也不一致。每个包都有自己的一套风格、常量或自定义错误类型。错误码被随意添加。想知道某个函数可能返回哪些错误,除了钻进它的实现代码里,根本无从得知!

于是,我接下了打造一套新错误框架的挑战。我们决定采用一套结构化、集中化的系统,用命名空间编码,让错误变得有意义、可追溯——最重要的是,能让我们安心!

这就是我们如何从一套简单的错误处理方式起步,随着问题不断累积而彻底崩溃,最终打造出属于自己的错误框架的故事。文中会讲到设计决策、具体实现方式、学到的经验,以及它如何改变了我们管理错误的方式。希望它也能给你带来一些启发!

本文同时发布于 olivernguyen.io。

Go 的错误就是普通的值

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;
}

这种方式确实为错误提供了结构,但随着时间推移,错误类型和错误码在没有明确规划的情况下不断被添加进来,导致不一致和重复的问题。

问题随着时间不断累积

错误声明散落各处

  • 每个包都自行定义自己的错误常量,没有集中管理的系统。
  • 常量和消息散布在整个代码库中,让人很难搞清楚一个函数究竟可能返回哪些错误——搞得人头大,到底是 gorm.ErrRecordNotFound 还是 user.ErrNotFound,还是两者都有?

随意的错误包装导致日志前后不一、杂乱无序

  • 许多函数在没有声明自己错误类型的情况下,用随意且不一致的消息去包装错误。
  • 日志冗长、重复,难以搜索或监控。
  • 错误消息过于笼统,往往说不清到底出了什么问题、是怎么发生的。而且很脆弱,容易在改动时悄悄出问题却无人察觉。
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled

缺乏标准化导致错误处理方式不当

  • 每个包处理错误的方式各不相同,很难判断一个函数究竟是返回、包装还是转换了错误。
  • 错误在传播过程中经常丢失上下文。
  • 上层收到的往往是含糊不清的 500 内部服务器错误,看不出根本原因。

缺乏分类使得监控无法进行

  • 错误没有按严重程度或行为进行分类:context.Canceled 错误在用户关闭浏览器标签页时可能是正常行为,但如果是因为某个查询随机变慢而导致请求被取消,那这个错误就很重要。
  • 重要问题被埋没在嘈杂的日志中,很难被识别出来。
  • 没有分类,就无法有效监控错误的发生频率、严重程度或影响范围。

是时候集中化错误处理了

回到设计起点

为了应对这些日益增长的挑战,我们决定围绕集中化和结构化的错误码这一核心理念,构建一套更好的错误策略。

  • 错误声明散落各处 → 把错误声明集中到一个地方,便于组织和追溯。
  • 日志前后不一、杂乱无序 → 使用格式清晰一致的结构化错误码。
  • 错误处理方式不当 → 在新的 Error 类型上标准化错误的创建与检查,并配套一整套完善的辅助函数。
  • 缺乏分类 → 为错误码打上标签进行分类,以便通过日志和指标有效监控。

设计决策

所有错误码都在一个集中的地方,以命名空间结构定义。

使用命名空间来创建清晰、有意义且可扩展的错误码。例如:

  • PRFL.USR.NOT_FOUND 表示"用户未找到"。
  • FLD.NOT_FOUND 表示"流程文档未找到"。
  • 两者都可以共享一个底层的基础码 DEPS.PG.NOT_FOUND,表示"PostgreSQL 中未找到记录"。

每一层服务或库都只能返回属于自己命名空间的错误码。

  • 每一层服务、仓库或库都声明自己的一套错误码。
  • 当某一层从依赖中收到一个错误时,必须先用自己的命名空间码把它包装起来,然后再返回。
  • 举例来说:当从某个依赖中收到 gorm.ErrRecordNotFound 错误时,"database" 包必须将其包装为 DEPS.PG.NOT_FOUND。随后,"profile/user" 服务再次将其包装为 PRFL.USR.NOT_FOUND。

所有错误都必须实现 Error 接口。

  • 这在第三方库的错误(error)和我们内部的 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。可以为错误附加上下文信息。

  • 我们多次在日志中看到孤立的错误,没有上下文,没有 trace_id,完全不知道它从哪来。
  • 可以为错误附加额外的键值对,用于日志记录或监控。

当错误跨服务边界传递时,只暴露顶层的错误码。

  • 调用者不需要看到该服务的内部实现细节。

对于外部错误,继续使用现有的 Protobuf ErrorCode 和 ErrorType。

  • 这确保了向后兼容性,客户端无需重写代码。

自动将命名空间错误码映射到 Protobuf 代码、HTTP 状态码和标签。

  • 工程师在集中的地方定义映射关系,框架会将每个错误码自动映射到对应的 Protobuf ErrorCode、ErrorType、gRPC 状态、HTTP 状态,以及用于日志/指标的标签。
  • 这确保了一致性,并减少了重复劳动。

命名空间错误框架

核心包与类型

有几个核心包构成了我们新错误处理框架的基础。

connectly.ai/go/pkgs/

  • errors:定义 Error 类型和错误码的主包。
  • errors/api:用于向前端或外部 API 发送错误。
  • errors/E:设计用于点导入(dot import)的辅助包。
  • testing:处理命名空间错误的测试工具。

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")
  • PRFL.USR.INVALID_ARGUMENT 是一个 Code。
  • 一个 Code 会暴露出 New() 或 Wrap() 等方法用于创建新的错误。
  • New() 函数的第一个参数接收 context.Context,之后是消息以及可选的参数。

用 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() 为错误添加上下文

  • 你可以通过 .With(l.String(...)) 为错误附加额外的键值对。
  • logging/l 是一个辅助包,提供了用于日志记录的语法糖函数。
  • l.String("flag", flag) 返回一个 Tag{String: flag},l.UUID("user_id, userID) 返回 Tag{Stringer: userID}。
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}

不同的类型:Error0、VlError、ApiError

目前有 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 使用的错误码

  • 命名空间错误码本身应仅用于内部使用。
  • 要让某个码可以在外部 HTTP API 中返回,需要用 api-code 对其进行标记,值为对应的 errorpb.ErrorCode。
  • 如果一个错误码没有用 api-code 标记,说明它是内部码,对外只会显示为一个通用的 Internal Server Error。
  • 注意 PRFL.USR.NOT_FOUND 是外部码,而 PRFL.USR.REPO.NOT_FOUND 是内部码。

用枚举选项在 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。它们的用途略有不同:

  • 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")
}

使用辅助函数处理内部命名空间错误

  • IsErrorCode(err, CODES...):检查错误是否包含指定的任一错误码。
  • IsErrorGroup(err, GROUP):如果错误属于输入的分组,则返回 true。

典型的使用模式:

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))

使用命名空间 Error 进行测试

测试对于任何认真对待的代码库来说都至关重要。该框架提供了像 Ω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。