登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go errors.Join 怎么保留多个校验错误并让调用方逐个判断

来源:17golang原创

时间:2026-09-07 21:39:17 214浏览 收藏

一次校验可能同时发现“姓名为空”和“年龄超范围”。如果只拼接错误字符串,调用方只能看到一段文案,无法稳定判断到底包含哪一类问题。Go 1.20 起可以用 errors.Join 保留多个错误,再用 errors.Is 分别判断;只有需要把每个字段提示单独列出来时,才读取 Unwrap() []error

把错误值留给控制流,把错误文本留给展示层:每个校验项先保留自己的原因,在函数出口统一 errors.Join;调用方用 errors.Is 做类别判断,不要比较 err.Error()

速记

  • errors.Join 会忽略 nil;所有输入都是 nil 时返回 nil。
  • 字段上下文用 Unwrap() error 包住哨兵错误,才能继续被 errors.Is 识别。
  • Joined error 的文本会按错误字符串换行拼接,但文本不是稳定的业务协议。
  • 需要逐条展示时读取 Unwrap() []error,普通分类判断不必手动遍历。

先让每个校验错误保留自己的原因

先定义稳定的错误原因,再给原因加字段名。下面的 FieldError 负责给人看的上下文,Unwrap 负责把语义交还给标准库:

package validation

import (
	"errors"
	"fmt"
	"strings"
)

var (
	ErrRequired = errors.New("required") // 表示字段没有填写
	ErrRange    = errors.New("out of range") // 表示字段值超出业务范围
)

type FieldError struct {
	Field string
	Cause error
}

func (e *FieldError) Error() string {
	return fmt.Sprintf("%s: %v", e.Field, e.Cause) // 文案带字段,语义仍由 Cause 保留
}

func (e *FieldError) Unwrap() error { return e.Cause }

func ValidateProfile(name string, age int) error {
	var problems []error
	if strings.TrimSpace(name) == "" {
		problems = append(problems, &FieldError{Field: "name", Cause: ErrRequired}) // 收集而不是立刻返回
	}
	if age  120 {
		problems = append(problems, &FieldError{Field: "age", Cause: ErrRange}) // 保留年龄错误类别
	}
	return errors.Join(problems...)
}

FieldError.Error 只负责形成可读文本,Unwrap 才是可判断的链路。这样 errors.Is 会沿着 joined error 的错误树继续查找,字段名不会因为增加上下文而丢失。

校验输入、哨兵错误、FieldError 与 errors.Join 的静态关系图
图1:字段错误保留原始原因后汇入 errors.Join,展示层与判断层都能继续使用。

在函数出口统一调用 errors.Join

收集切片的好处是每项校验都能完成,调用方一次就能拿到完整结果。errors.Join 会丢弃 nil;如果没有任何非 nil 错误,返回值就是 nil,因此不需要额外维护一个“是否失败”的布尔值。

它还保留加入顺序来生成展示文本,每个子错误的 Error() 结果之间用换行连接。不过这只是默认展示格式,不应被当成解析协议。字段级提示若需要本地化、排序或去重,应在展示层按自己的规则处理。

如果只有一个校验失败,也不要为了追求“单错误类型”而把它转成字符串。joined error 仍然可以被 errors.Is 判断;上层只需要依赖 error 接口的语义,而不是具体实现类型。

调用方如何分别判断每一种校验错误

调用方通常只关心错误类别,不需要知道内部收集了几个字段。对每个稳定原因调用一次 errors.Is 即可:

err := validation.ValidateProfile("", 150)
if err != nil {
	if errors.Is(err, validation.ErrRequired) {
		show("请填写姓名") // 按错误类别给出明确提示
	}
	if errors.Is(err, validation.ErrRange) {
		show("年龄应在 18 到 120 之间") // 另一个类别可以同时命中
	}
}

这里的两个判断可以同时为真,因为它们对应同一个 joined error 中的两个分支。不要改成 switch err.Error(),也不要用 err == validation.ErrRequired:前者依赖文案,后者无法穿过 FieldErrorerrors.Join

如果还要给日志加操作上下文,继续用 fmt.Errorf("validate profile: %w", err) 包装。只要使用 %w,外层判断仍然能到达内部错误树。

joined error 通过 errors.Is 分类判断并通过 Unwrap 列出字段提示的关系图
图2:调用方用 errors.Is 做类别判断,需要逐条展示时再读取 Unwrap() []error。

逐条展示时再读取 Unwrap() []error

标准库的 errors.Unwrap 适合单个 Unwrap() errorerrors.Join 返回的非 nil 错误实现的是 Unwrap() []error,所以展示每一项时应显式声明这个小接口:

type multiUnwrapper interface {
	Unwrap() []error // 只读取 Join 暴露的子错误,不依赖具体私有类型
}

func messages(err error) []string {
	var joined multiUnwrapper
	if !errors.As(err, &joined) {
		return []string{err.Error()} // 普通单错误仍有可读兜底
	}

	items := make([]string, 0, len(joined.Unwrap()))
	for _, item := range joined.Unwrap() {
		if item != nil {
			items = append(items, item.Error()) // 展示层收集字段级文案
		}
	}
	return items
}

这段遍历只适合展示、统计或附加字段上下文。若子错误还可能嵌套另一个多错误,需先明确产品是否要递归展开;很多表单场景只展示 Join 的第一层就够了。控制流判断仍优先写 errors.Iserrors.As,避免让业务代码绑定到某个实现细节。

用三组结果检查错误语义

最小测试矩阵至少覆盖三种输入:姓名和年龄都错、只错一项、全部正确。断言应优先检查 errors.Is 和 nil,不要把整段换行文本当成唯一断言:

  • 多错:errors.Is(err, ErrRequired)errors.Is(err, ErrRange) 都为 true。
  • 单错:对应类别为 true,另一类别为 false,展示列表只有一项。
  • 全通过:err == nil,不再调用展示函数。
  • 包装后:外层用 %w 增加上下文,两个类别判断仍然成立。

常见误区是把 nil 过滤、错误去重和文案排序混在校验函数里。errors.Join 只负责组合错误,不会自动去重;同一原因被加入两次时,errors.Is 仍然只是命中,但默认文本可能重复。是否去重应由业务决定,且不要通过拆解错误字符串实现。

相关问题

errors.Join 需要 Go 哪个版本?

它从 Go 1.20 加入标准库;项目若要兼容更早版本,需要先确认工具链约束,不能只替换 import 就假设行为一致。

为什么不用 strings.Join 拼错误消息?

字符串只能给人看,不能保留哨兵错误和包装关系。组合后需要分类处理时,应保留 error 值,再单独生成展示文案。

什么时候不该手动读取 Unwrap() []error?

只做“是否包含某类错误”的判断时不需要读取它;只有逐项展示、统计或需要字段上下文时,才在边界处遍历。

落地时可以记住一条线:字段校验返回带上下文的原始错误,函数出口用 errors.Join 收口,调用方用 errors.Is 分别判断,展示层再决定是否展开子错误。这样增加提示文案不会破坏业务分支。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>