本文翻译自 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.ErrNoRows 和 redis.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/sql和redis,并将相同的存储错误映射为不同的输出格式(codes.NotFound而非http.StatusNotFound)。
现在两个 handler 都知道 sql.ErrNoRows 和 redis.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
}
writeError 和 toStatus 具有相同的结构。一个输出 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 目录中。