Go 领域错误入门:errors.Is 和 errors.As 如何服务业务判断

用订单业务示例讲 Go 领域错误的设计方式,如何使用 errors.Is、errors.As、错误包装和 HTTP 映射。

Go 的 error 很简单,但业务系统里的错误并不简单:订单不存在、库存不足、余额不够、状态不允许、外部支付超时。这些错误需要被上层判断,映射成 HTTP 状态码、用户提示、重试策略。只靠字符串比较会非常脆弱。

本文用订单业务讲领域错误设计,以及 errors.Iserrors.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.AsInsufficientStockError。包装增加上下文,不应该破坏判断能力。不要用 %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、日志、重试和用户提示。字符串只是给人看的,不应该成为系统判断的基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页