《Go 语言编程入门》6.3 errors.Is/As 与自定义错误

本节收尾 TaskAPI 的错误体系。讲清 errors.Is 判根因、errors.As 取回结构化错误类型的区别与用法,实现带字段的 ValidationError 与自定义 Is 方法,并给出哨兵错误、包装、判别三者协作的完整分层方案。

6.3 errors.Is/As 与自定义错误

6.1 定义了哨兵错误,6.2 用 %w 给它们加上了上下文。本节补上最后一块:如何在有包装的情况下,既判断错误的根因,又取回错误携带的结构化数据。errors.Is 负责前者,errors.As 负责后者,两者是 Go 错误处理的左右手。

本节把 TaskAPI 的错误体系收口:实现带字段的 ValidationError,用 errors.As 提取非法字段与取值,并确定哨兵错误 + %w 包装 + Is/As 判别的三层分层方案,为第 15 章统一错误响应做铺垫。

6.3.1 errors.Is:判断根因

errors.Is(err, target) 判断 err 的错误链中是否存在与 target 相等的错误。它替代了 6.1 里的 err == target:

err := store.Rename(99, "新")
fmt.Println(errors.Is(err, ErrNotFound))     // true
fmt.Println(errors.Is(err, ErrInvalidTitle)) // false

关键优势:它会自动 Unwrap。无论错误被包装了多少层,errors.Is 都能穿透到根因。这就是为什么 6.2 加了 %w 之后,判别依然有效——你不再需要关心错误被包了几层。

errors.Is 的匹配规则(按顺序):

  1. err == target 直接相等。
  2. err 实现了 Is(target error) bool 方法且返回 true。
  3. 递归 Unwrap 后对每一层重复 1、2。

规则 2 是自定义判等的入口,6.3.4 会用到。规则 3 就是错误链遍历。

6.3.2 errors.As:取回结构化错误

errors.Is 只能回答「是不是这个错误」,但有时你需要错误里的字段。比如校验失败时,调用方想知道「是哪个字段不合法」。这时用自定义错误类型携带字段,再用 errors.As 取回:

type ValidationError struct {
	Field string
	Value string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed: field=%s value=%q", e.Field, e.Value)
}

errors.As(err, &target) 沿错误链查找第一个类型匹配的错误,并把它赋给 target。注意 target 必须是指向「错误类型」的指针:

var ve *ValidationError
if errors.As(err3, &ve) {
	fmt.Println("As ValidationError:", ve.Field) // title
}

实测输出 As ValidationError: title。和 errors.Is 一样,errors.As 会自动 Unwrap,所以即使 ValidationError 被包在多层 %w 里也能取到。

函数问的问题目标参数
errors.Is(err, target)链里有和 target 相等的吗?一个错误值
errors.As(err, &target)链里有类型是 T 的错误吗?指向错误类型的指针

一个常见错误是把 errors.Is 和 errors.As 的用途搞混:想取字段却用 Is,想判哨兵却用 As。记法是:Is 比「值」,As 取「类型」。

6.3.3 用 %w 包装自定义错误

ValidationError 要被 errors.As 取回,同样需要被正确包装。在 Create 里:

func (m *MemStore) Create(title string) (Task, error) {
	if len([]rune(title)) > 50 {
		return Task{}, fmt.Errorf("create task: %w", &ValidationError{Field: "title", Value: title})
	}
	...
}

注意这里传的是 &ValidationError{...}——指针。因为 Error() 方法用指针接收者,只有 *ValidationError 满足 error,也只有它能被 errors.As 匹配到 *ValidationError 类型。如果把 Error() 改成值接收者,那么 errors.As(err, &ve) 里的 ve 就应该是 ValidationError 而非 *ValidationError——接收者与 As 的目标类型必须一致。

实测完整调用:

var ve *ValidationError
_, err3 := store.Create(string(long))
fmt.Println(err3) // create task: validation failed: field=title value="aaa..."
if errors.As(err3, &ve) {
	fmt.Println("As ValidationError:", ve.Field, ve.Value[:5]+"...") // title aaaaa...
}

6.3.4 自定义 Is 方法

有时「相等」不是简单的 ==,而是有语义的。比如一个 OpError,当 HTTP 状态码 ≥ 500 时才认为「可重试」:

var ErrRetryable = errors.New("retryable")

type OpError struct{ Code int }

func (e *OpError) Error() string        { return fmt.Sprintf("op error code=%d", e.Code) }
func (e *OpError) Is(target error) bool { return target == ErrRetryable && e.Code >= 500 }

实现了 Is(target error) bool 后,errors.Is 会调用它来判断匹配:

fmt.Println(errors.Is(&OpError{Code: 503}, ErrRetryable)) // true
fmt.Println(errors.Is(&OpError{Code: 400}, ErrRetryable)) // false

实测结果正是 true 和 false。自定义 Is 让错误可以表达「等价关系」,而不只是字面相等。TaskAPI 里如果以后引入限流错误,就可以用类似方式把「可重试」语义挂上去。

自定义 Is 有两个约束:target 通常要和某个哨兵错误比较,且方法不应自己调用 errors.Is 造成递归。保持简单:比较、判断字段、返回布尔。

6.3.5 errors.As 也能匹配接口类型

errors.As 的目标不一定是具体类型,也可以是接口。它查找的是「链中第一个实现了该接口的错误」,标准库的 net.Error 就是这么被消费的。用一个自定义接口演示:

type Temporary interface{ Temporary() bool }

type NetError struct {
	msg  string
	temp bool
}

func (e *NetError) Error() string   { return e.msg }
func (e *NetError) Temporary() bool { return e.temp }

func main() {
	var netErr error = &NetError{msg: "timeout", temp: true}
	wrapped := fmt.Errorf("request failed: %w", netErr)

	var tmp Temporary
	if errors.As(wrapped, &tmp) {
		fmt.Println("temporary:", tmp.Temporary()) // true
	}
}

实测输出 temporary: true(wrapped 的文本是 request failed: timeout)。这种「按能力而非按类型判别」的方式非常强大:调用方不必知道具体错误类型,只要错误声明了「我是可重试的」就能被识别。TaskAPI 以后接网络存储时,可以给超时错误加上 Temporary() bool,让上层统一决定是否重试。

6.3.6 三种错误策略的选择

到本节为止,TaskAPI 用到了三种错误表达方式。它们不是互斥的,而是各有适用场景:

方式定义判别适用
哨兵错误var ErrX = errors.New(...)errors.Is调用方需要区分的有限类别(NotFound/InvalidTitle)
自定义类型type XError struct{...}errors.As需要携带结构化字段(字段名、错误码)
包装fmt.Errorf("...: %w", err)透传上述两者每层补充上下文

经验法则:能用哨兵错误就用哨兵错误(最简单),需要字段时才上自定义类型,包装则几乎总是需要。三者组合起来就是完整的错误分层。

6.3.7 项目落地:TaskAPI 错误分层

综合第 6 章三节,TaskAPI 的错误体系定型为:

// 哨兵错误:调用方需要区分的失败类别。
var (
	ErrNotFound     = errors.New("task not found")
	ErrInvalidTitle = errors.New("invalid title")
)

// ValidationError:需要携带字段信息时使用。
type ValidationError struct {
	Field string
	Value string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed: field=%s value=%q", e.Field, e.Value)
}

// 编译期断言:确保 *ValidationError 实现 error。
var _ error = (*ValidationError)(nil)

存储层的返回策略:

// 简单失败:包装哨兵错误
return fmt.Errorf("rename task %d: %w", id, ErrNotFound)

// 需要字段的失败:包装自定义类型
return fmt.Errorf("create task: %w", &ValidationError{Field: "title", Value: title})

上层判别:

func HandleError(err error) int {
	switch {
	case err == nil:
		return 200
	case errors.Is(err, ErrNotFound):
		return 404
	case errors.Is(err, ErrInvalidTitle):
		return 400
	}
	var ve *ValidationError
	if errors.As(err, &ve) {
		log.Printf("invalid field: %s", ve.Field)
		return 422
	}
	return 500
}

这段 HandleError 是第 15 章统一错误响应的雏形:先把哨兵错误映射成状态码,再用 errors.As 处理需要字段的场景,最后兜底 500。注意顺序——errors.As 的 ValidationError 分支放在哨兵判别之后,因为它是更具体的处理。

6.3.8 常见反模式

  • 用字符串匹配判断错误:strings.Contains(err.Error(), "not found")——脆弱且不可靠,一旦消息文案改动就失效。永远用 errors.Is / errors.As。
  • errors.As 的目标类型写错:var ve ValidationError; errors.As(err, &ve) 在 Error() 用指针接收者时会失败。先看接收者,再决定 As 的目标。
  • 忽略 errors.As 的返回值:errors.As 返回 bool,不检查就使用 ve 可能拿到 nil 指针。
  • 哨兵错误用 == 判等但有包装:包装后 == 恒为 false,必须用 errors.Is。

6.3.9 小结与检查清单

  • errors.Is 判根因(值相等),自动穿透错误链
  • errors.As 取回结构化错误类型,目标是错误类型的指针
  • 自定义错误实现 Is(target error) bool 可定义语义判等
  • 用 %w 包装,让 Is / As 能穿透
  • 接收者类型决定 As 的目标类型(指针接收者 → *T)
  • 用 switch + errors.Is 做分层判别,As 放具体分支
  • 绝不靠字符串匹配判断错误类型
  • errors.As 的目标也可以是接口,用于按能力(如 Temporary())判别

第 6 章到此结束。Task 有了方法、有了关系(第 4 章),抽象出了接口(第 5 章),错误也分好了层(第 6 章)。下一章把这套代码从单个 main.go 拆成多个包:internal/task、internal/store、cmd/taskapi,并讲清包可见性、导出规则与循环依赖的规避。

阅读导航:上一节:6.2 fmt.Errorf 与 %w 包装 · 下一节:7.1 包的声明、导入与可见性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练