Go 的 error 很简单,但业务系统里的错误并不简单:订单不存在、库存不足、余额不够、状态不允许、外部支付超时。这些错误需要被上层判断,映射成 HTTP 状态码、用户提示、重试策略。只靠字符串比较会非常脆弱。
本文用订单业务讲领域错误设计,以及 errors.Is 和 errors.As 怎么用得自然。
稳定状态用 sentinel error
var (
ErrOrderNotFound = errors.New("order not found")
ErrInvalidState = errors.New("invalid order state")
)
仓储层:
func (s *Store) GetOrder(ctx context.Context, id int64) (Order, error) {
order, err := s.queryOrder(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Order{}, ErrOrderNotFound
}
if err != nil {
return Order{}, fmt.Errorf("query order %d: %w", id, err)
}
return order, nil
}
上层:
if errors.Is(err, ErrOrderNotFound) {
return http.StatusNotFound
}
errors.Is 能穿透 %w 包装,不需要匹配错误字符串。
带字段的错误用类型
库存不足需要告诉调用方缺多少:
type InsufficientStockError struct {
SKU string
Requested int
Available int
}
func (e *InsufficientStockError) Error() string {
return fmt.Sprintf("insufficient stock for %s", e.SKU)
}
业务函数:
func ReserveStock(item Item, available int) error {
if item.Quantity > available {
return &InsufficientStockError{
SKU: item.SKU,
Requested: item.Quantity,
Available: available,
}
}
return nil
}
上层提取:
var stockErr *InsufficientStockError
if errors.As(err, &stockErr) {
log.Printf("sku=%s requested=%d available=%d", stockErr.SKU, stockErr.Requested, stockErr.Available)
}
errors.As 用于找错误链里的某个类型。需要字段时,它比 sentinel error 更合适。
包装时保留业务错误
if err := ReserveStock(item, available); err != nil {
return fmt.Errorf("reserve stock for order %d: %w", orderID, err)
}
上层仍然能 errors.As 到 InsufficientStockError。包装增加上下文,不应该破坏判断能力。不要用 %v 包装需要上层识别的错误。
HTTP 映射
func writeOrderError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrOrderNotFound) {
writeError(w, http.StatusNotFound, "order_not_found", "订单不存在")
return
}
if errors.Is(err, ErrInvalidState) {
writeError(w, http.StatusConflict, "invalid_order_state", "订单状态不允许此操作")
return
}
var stockErr *InsufficientStockError
if errors.As(err, &stockErr) {
writeError(w, http.StatusConflict, "insufficient_stock", "库存不足")
return
}
log.Printf("unexpected order error: %v", err)
writeError(w, http.StatusInternalServerError, "internal_error", "服务暂时不可用")
}
HTTP 层负责把领域错误翻译成协议响应。业务层不应该到处返回 HTTP 状态码,否则业务逻辑会和传输协议绑死。
错误文案和错误码分开
错误码稳定,文案可以调整:
{
"error": {
"code": "insufficient_stock",
"message": "库存不足"
}
}
前端根据 code 做逻辑判断,message 用于展示。不要让前端匹配中文文案。文案改一次,逻辑就坏一次。
测试错误判断
func TestReserveStockError(t *testing.T) {
err := ReserveStock(Item{SKU: "book", Quantity: 3}, 1)
var stockErr *InsufficientStockError
if !errors.As(err, &stockErr) {
t.Fatalf("expected stock error, got %v", err)
}
if stockErr.Available != 1 {
t.Fatalf("available = %d", stockErr.Available)
}
}
测试不要只比较字符串。你真正关心的是错误类型和字段。
不要让领域错误依赖 HTTP
领域层最好不要返回 http.StatusBadRequest 这样的值。HTTP 是传输层,领域错误应该描述业务事实:余额不足、库存不够、用户不存在、状态不允许。到了 handler 再把它们翻译成状态码。
func statusFor(err error) int {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrConflict):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
这样同一套 service 将来被 CLI、消息队列 worker 或 gRPC 调用时,不会带着 HTTP 概念到处跑。边界清楚后,错误处理会更容易测试。
错误是否可重试
有些错误适合重试,比如临时网络故障、数据库死锁、第三方接口限流;有些错误不该重试,比如参数非法、余额不足、权限不足。可以用自定义类型表达这个信息。
type RetryableError struct {
Err error
}
func (e RetryableError) Error() string { return e.Err.Error() }
func (e RetryableError) Unwrap() error { return e.Err }
func IsRetryable(err error) bool {
var r RetryableError
return errors.As(err, &r)
}
调用方不用解析错误字符串,只要判断类型。注意不要滥用可重试错误,否则任务系统会反复重试永远不会成功的业务失败。
日志里记录内部错误链
返回给用户的错误要克制,日志里的错误要完整。比如用户看到“创建订单失败”,日志里应该能追到库存校验、数据库写入或外部支付接口的具体失败。
if err := service.CreateOrder(ctx, req); err != nil {
log.Printf("create order user=%d sku=%s: %v", req.UserID, req.SKU, err)
http.Error(w, "创建订单失败", statusFor(err))
return
}
使用 %w 包装错误后,上层既能保留语义判断,又能在日志里看到链路。不要在每一层都打印日志,否则同一个错误会出现多次。通常在边界处记录一次:HTTP handler、worker 消费入口、命令行入口。
小结
Go 领域错误可以用两类方式表达:稳定状态用 sentinel error,并通过 errors.Is 判断;需要携带字段的错误用自定义类型,并通过 errors.As 提取。跨层包装时用 %w 保留错误链。
错误设计是业务建模的一部分。让错误携带稳定语义,上层才能正确映射 HTTP、日志、重试和用户提示。字符串只是给人看的,不应该成为系统判断的基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。