Go 服务中的错误翻译

本文翻译自 Error translation in Go services,版权归原作者所有。

在分层 Go 服务中,很容易不小心将 sql.ErrNoRows 之类的存储错误一路泄漏到 handler 层,甚至更糟——泄漏到客户端。本文将展示如何在服务边界捕获这些错误,将它们翻译为领域错误,并防止内部细节泄露到不该去的地方。

当 handler 知道了你的数据库

假设你有一个由 Postgres 支撑的用户服务。Handler 根据 ID 获取用户,需要区分"未找到"和真正的故障:

// handler.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    u, err := h.svc.GetUser(r.Context(), id)
    if errors.Is(err, sql.ErrNoRows) { // (1)
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(u)
}
  • (1) 这就是耦合所在。Handler 导入了 database/sql 并检查 sql.ErrNoRows——一个存储层特有的错误。Handler 现在知道了服务使用的是 SQL。

对于只有一个数据库和一个传输层的小型服务来说,这是个合理的权衡。你知道用的是 SQL,而且短期内不会有什么变化。

然后服务逐渐壮大。有人在 Postgres 前面加了 Redis 作为透读缓存,现在出现了两种不同的"未找到"错误:

// handler.go

if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

Handler 现在导入了两个存储包。它知道服务同时使用了 Postgres 和 Redis。接着你又加入了软删除。软删除的用户在 Postgres 和 Redis 中都存在,所以 sql.ErrNoRowsredis.Nil 都不会触发。但服务认为该用户已经不存在了。Handler 没有办法为这种情况返回 404,因为两种存储错误都不适用。

然后有人为同一个服务添加了一个 gRPC handler:

// handler.go

func (h *Handler) GetUser(
    ctx context.Context, req *pb.GetUserRequest,
) (*pb.GetUserResponse, error) {
    u, err := h.svc.GetUser(ctx, req.GetId())
    if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) { // (1)
        return nil, status.Error(codes.NotFound, "not found")
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal error")
    }
    return &pb.GetUserResponse{
        Id: u.ID, Name: u.Name, Email: u.Email,
    }, nil
}
  • (1) 和 HTTP handler 中相同的存储错误检查,在这里被复制了一份。gRPC handler 同样导入了 database/sqlredis,并将相同的存储错误映射为不同的输出格式(codes.NotFound 而非 http.StatusNotFound)。

现在两个 handler 都知道 sql.ErrNoRowsredis.Nil。添加第三个存储后端或移除 Redis 意味着两处都要修改。存储层的每一次变更都会波及到传输层代码,而传输层本不该关心数据是如何存储的。

Handler 不应该知道这些细节。它只需检查一个统一的"未找到"错误,然后返回 404,不管原因是缺失的 SQL 行、Redis 缓存未命中,还是软删除。这意味着服务需要定义自己的错误类型。

定义领域错误

sql.ErrNoRows 穿过服务层到达 handler 时,它就变成了层间接口的一部分。把 Postgres 换成 DynamoDB,handler 就会崩溃,这完全违背了在中间设置 repository 层的初衷。Service 包可以通过定义用业务术语描述问题的错误来防止这种情况:

// user/user.go

package user

import (
    "context"
    "errors"
    "time"
)

type User struct {
    ID        int64      `json:"id"`
    Name      string     `json:"name"`
    Email     string     `json:"email"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

var (
    ErrNotFound = errors.New("not found")
    ErrConflict = errors.New("conflict")
)

type Store interface {
    Get(ctx context.Context, id int64) (User, error)
    Create(ctx context.Context, u User) (int64, error)
}

ErrNotFound 表示用户不存在。它不说明原因。缺失的 SQL 行、过期的 Redis key、软删除的记录,都产生同一个错误。Handler 不需要区分这些情况,因为三种场景下的响应都是 404。

ErrConflict 表示会违反唯一性约束。至于那是 SQL UNIQUE 索引还是 DynamoDB 条件检查,是存储包需要操心的事。

定义好这些之后,映射工作就在 repository 层进行:捕获存储层特有的错误,返回领域错误。

在 repository 中捕获存储错误

下面是 repository 接口的 SQLite 实现。两条错误路径故意采用了不同的处理方式:

// sqlite/store.go

func (s *UserStore) Get(
    ctx context.Context, id int64,
) (user.User, error) {
    row := s.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ?", id)

    var u user.User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return user.User{}, fmt.Errorf(
                "user %d not in db: %w", id, user.ErrNotFound, // (1)
            )
        }
        return user.User{}, fmt.Errorf(
            "querying user %d: %v", id, err, // (2)
        )
    }
    return u, nil
}

两条路径使用了不同的格式化动词,包装的内容也不同:

  • (1) %w 包装了 user.ErrNotFound——领域哨兵错误,而非原始的 sql.ErrNoRows。Repository 在上面的 if 检查中捕获了 sql.ErrNoRows,但没有直接包装它,而是围绕 user.ErrNotFound 构建了一个新错误。因此 errors.Is(err, user.ErrNotFound) 能匹配成功,但 errors.Is(err, sql.ErrNoRows) 不会匹配,因为那个错误在这里被消费了,而不是被包装。错误消息 "user 42 not in db: not found" 仍然能在调试时告诉你发生了什么。
  • (2) %v 包装了来自 database/sql 的原始 err。这是一个调用方不应该能通过编程方式检查的存储错误。%v 保留了错误消息用于日志记录,但切断了错误链,所以 errors.Is(err, sql.ErrWhatever) 不会匹配。如果这里用了 %w,调用方就能通过 errors.Is 穿透到 database/sql 的类型,耦合就又回来了。关于这个选择,我在 Go errors: to wrap or not to wrap? 中做了更多讨论。

规则是:对你自己的领域错误使用 %w(调用方应该能检查它们),对存储错误使用 %v(调用方不应该检查)。

对于创建操作,约束违例也采用同样的处理方式:

// sqlite/store.go

func (s *UserStore) Create(
    ctx context.Context, u user.User,
) (int64, error) {
    res, err := s.db.ExecContext(ctx,
        "INSERT INTO users (name, email) VALUES (?, ?)",
        u.Name, u.Email,
    )
    if err != nil {
        if sqliteErr, ok := errors.AsType[sqlite3.Error](err); ok &&
            sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf(
                "user %s already exists: %w", // (1)
                u.Email, user.ErrConflict,
            )
        }
        return 0, fmt.Errorf("inserting user: %v", err) // (2)
    }
    return res.LastInsertId()
}
  • (1) 与 Get 相同的模式。数据库特有的约束错误变成了用 %w 包装的 user.ErrConflict,并附带冲突的 email 用于调试上下文。Handler 看到"冲突"就返回 409。它不知道是哪个数据库、哪个约束被违反了。
  • (2) 未知错误使用 %v 包装,和之前一样。消息被保留用于日志记录,但错误链被切断。

Service 层不需要做任何自己的映射。它将来自 store 的领域错误直接透传。当它有业务理由独立产生同样的错误时,使用相同的哨兵错误:

// user/service.go

func (s *Service) GetUser(
    ctx context.Context, id int64,
) (User, error) {
    u, err := s.store.Get(ctx, id)
    if err != nil {
        return User{}, err // (1)
    }
    if u.DeletedAt != nil {
        return User{}, fmt.Errorf(
            "user %d soft-deleted: %w", id, ErrNotFound, // (2)
        )
    }
    return u, nil
}
  • (1) 如果 store 返回了 ErrNotFound(缺失行),它原样透传。Service 在这里不做任何翻译,因为错误已经是领域术语了。
  • (2) 软删除的用户在数据库中存在,但逻辑上已经消失。Service 用 %w 包装 ErrNotFound 并附带用户 ID。这里用 %w 是合适的,因为 ErrNotFound 是服务自身的错误,不是泄漏的存储细节。Handler 仍然可以用 errors.Is(err, ErrNotFound) 匹配。

重要提示 你不需要在每一层都做翻译。Repository 将存储错误映射为领域错误。Handler 将领域错误映射为传输格式。中间的 service 层只是原样透传领域错误。两个翻译点,而不是每层一个。

一旦 repository 处理了存储到领域的映射,handler 就变得简单多了。

将领域错误映射为状态码

和文章开头的 handler 对比一下。没有 database/sql 导入,没有 redis 导入,不需要知道存在哪些存储后端:

// main.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

    u, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        writeError(w, err)
        return
    }
    json.NewEncoder(w).Encode(u)
}

func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    json.NewDecoder(r.Body).Decode(&req)

    u, err := h.svc.CreateUser(r.Context(), req.Name, req.Email)
    if err != nil {
        writeError(w, err)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(u)
}

所有错误到状态码的映射都集中在一个函数中。领域错误进去,HTTP 状态码出来:

// main.go

func writeError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, user.ErrNotFound):
        http.Error(w, "not found", http.StatusNotFound) // (1)
    case errors.Is(err, user.ErrConflict):
        http.Error(w, "conflict", http.StatusConflict) // (2)
    default:
        http.Error(w, "internal error", http.StatusInternalServerError) // (3)
    }
}
  • (1) ErrNotFound 变成 404。Handler 不知道原因是 SQL 未命中、Redis 未命中还是软删除。它不需要知道。
  • (2) ErrConflict 变成 409。Handler 不知道哪个约束被违反了。
  • (3) 其他所有情况变成 500,附带通用消息。没有内部细节泄漏给客户端。

gRPC handler 使用相同的服务,只是映射函数不同:

// main.go

func toStatus(err error) error {
    switch {
    case errors.Is(err, user.ErrNotFound):
        return status.Error(codes.NotFound, "not found") // 404 equivalent
    case errors.Is(err, user.ErrConflict):
        return status.Error(codes.AlreadyExists, "conflict") // 409 equivalent
    default:
        return status.Error(codes.Internal, "internal error")
    }
}
// main.go

func (h *handler) GetUser(
    ctx context.Context, req *api.GetUserRequest,
) (*api.GetUserResponse, error) {
    u, err := h.svc.GetUser(ctx, req.GetId())
    if err != nil {
        return nil, toStatus(err)
    }
    return &api.GetUserResponse{
        Id: u.ID, Name: u.Name, Email: u.Email,
    }, nil
}

func (h *handler) CreateUser(
    ctx context.Context, req *api.CreateUserRequest,
) (*api.CreateUserResponse, error) {
    u, err := h.svc.CreateUser(
        ctx, req.GetName(), req.GetEmail(),
    )
    if err != nil {
        return nil, toStatus(err)
    }
    return &api.CreateUserResponse{Id: u.ID}, nil
}

writeErrortoStatus 具有相同的结构。一个输出 HTTP 状态码,另一个输出 gRPC 状态码。背后的服务完全相同。如果你添加一个新错误比如 ErrForbidden,只需在 user 包中定义一个哨兵错误,然后在每个映射函数中添加一个 case。

你失去了什么,以及如何弥补

当 handler 看到 ErrNotFound 时,它不知道那是 SQL 未命中、Redis 未命中还是软删除。这正是翻译的目的所在,但在故障排查时你需要这些信息。

这就是为什么 repository 和 service 用 %w 包装 ErrNotFound 并附带描述性上下文,如前文所示。Repository 产生 "user 42 not in db: not found",service 产生 "user 42 soft-deleted: not found"。相同的领域错误,不同的来源。Handler 将两者都当作 404 处理,但错误字符串是不同的。

为了让这发挥作用,handler 在返回响应之前记录完整的错误:

// main.go

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)

    u, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        slog.ErrorContext(r.Context(), "get user failed",
            "user_id", id,
            "err", err,
        )
        writeError(w, err) // responds with "not found", not err.Error()
        return
    }
    json.NewEncoder(w).Encode(u)
}

客户端看到 404,响应体为 not found。值班工程师看到的是:

level=ERROR msg="get user failed" user_id=42
    err="user 42 not in db: not found"

错误字符串告诉你哪个代码路径产生了这个错误。如果你配置了链路追踪,请求作用域的 context 也会携带 trace ID,这样你可以一路追踪 404 回到失败的存储调用。

标准库也在做同样的事

os 包将平台特有的错误翻译为可移植的错误。在 Linux 上,打开不存在的文件会以 syscall.ENOENT 失败。在 Windows 上,以 ERROR_FILE_NOT_FOUND 失败。但调用方永远不会看到其中任何一个:

// Example usage

f, err := os.Open("/etc/missing.yaml")
if errors.Is(err, fs.ErrNotExist) {
    // same check works on Linux, macOS, and Windows
}

os.Open 捕获平台错误并包装它,使得 errors.Is 将其映射为 fs.ErrNotExist。和 repository 捕获 sql.ErrNoRows 并改为包装 user.ErrNotFound 是同样的思路。

etcd 的 clientv3 包在反方向上做了同样的翻译。客户端从服务器收到 gRPC 状态码,将它们映射为纯 Go 错误,这样调用方永远不需要导入 google.golang.org/grpc/status。我在 Wrapping a gRPC client in Go 中讨论过这一点。


HTTP 版本gRPC 版本的可运行示例在 GitHub 的 error-translation 目录中。

comments powered by Disqus