我用等号判断 Go 的错误类型一直好好的,下游一改成用 %w 包装错误,我的判断就全失效、走错了分支,我对着错误链排查了大半天的复盘

我用 if err == ErrNotFound 判断错误类型一直好用,直到下游为了让错误信息更丰富,把直接返回 ErrNotFound 改成了 fmt.Errorf("查询失败: %w", ErrNotFound) 包装错误,我的 == ErrNotFound 就再也匹配不上、走进了错误分支(把404当500返回了)。深挖才懂是 Go 的错误包装与错误链:%w 包装后 err 变成一个全新的外层错误、原来的 ErrNotFound 被包裹在内层形成错误链,而 == 只比最外层那个新对象、自然不等于内层的 ErrNotFound。正解是用 Go 专为错误链设计的 errors.Is(沿链查找是不是某哨兵错误)和 errors.As(沿链提取某类型并读字段),它们能穿透任意层包装。这篇从错误包装与错误链讲起,到 errors.Is/As 判断+%w 包装+自定义错误实现 Unwrap 的正解、Go 错误处理其他坑(忽略err/%v断链/defer吞错)、错误判断方式速查、好的错误处理为了可排查、完整错误链实战示例,以及那句最戳心的——语言和库会演进,停留在旧习惯里你曾经正确的代码会在新机制面前悄悄变成错误,要持续跟上演进、定期审视一直没问题的旧习惯。

我用等号判断 Go 的错误类型一直好好的,下游一改成用 %w 包装错误,我的判断就全失效、走错了分支,我对着错误链排查了大半天的复盘

这是一个让我对 Go 的"错误包装与错误链"彻底搞懂的故事。我有段代码,调用下游函数后,要根据错误类型走不同分支,比如:if err == ErrNotFound { ... 返回404 }。一直用 == 比较哨兵错误(sentinel error),也一直工作得很好。可有一天,下游团队改了一下他们的错误处理——为了让错误信息更丰富,他们把原来直接返回的 ErrNotFound,改成了 fmt.Errorf("查询用户失败: %w", ErrNotFound) 这种包装过的错误。从那以后,我的代码就出问题了:我那个 if err == ErrNotFound 的判断,再也匹配不上了!明明本质上就是 ErrNotFound,可 == ErrNotFound 返回 false,于是代码走进了错误的分支(把"没找到"当成了"未知错误",返回了 500 而不是 404)。同样的错误,只是被"包装"了一下,我的判断怎么就失灵了?

我顺着"== 匹配不上"的线索深挖,才终于揭开真相,补上了我对 Go 错误处理一个最关键的认知漏洞:问题的核心,是 Go 的"错误包装(error wrapping)与错误链(error chain)"机制。我一直想当然地以为,"判断错误类型,用 == 比一下就行";可真相是:当一个错误被 fmt.Errorf("...: %w", err)%w 包装之后,它就不再是原来那个错误了——而是变成了一个全新的、外层的错误,而原来的错误(ErrNotFound),被"包裹"在了这个新错误的内部,形成了一条"错误链"(外层错误 → 内层的 ErrNotFound)。== 比较,只比的是最外层那个错误对象;最外层现在是 fmt.Errorf 返回的那个新错误,它当然不等于 ErrNotFound(ErrNotFound 被埋在里面了);所以 == ErrNotFound 返回 false正确的做法,是用 Go 专门为"错误链"设计的两个函数:errors.Is(err, ErrNotFound)——它会顺着错误链一层层往里找,只要链上的任何一层ErrNotFound,就返回 true;以及 errors.As(err, &target)——它会顺着链找到某个特定类型的错误,并把它提取出来(用于自定义错误类型)。我这才痛彻地明白:在现代 Go 里,错误不再是一个"扁平的、单一的"对象,而可能是一条"层层包装的链";判断错误类型,绝不能再用 ==(只比最外层)或直接类型断言,而必须用 errors.Is / errors.As——它们懂得"穿透"包装、沿着错误链去查找错误包装(%w)是个好东西(它能在保留原始错误的同时,层层添加上下文,让错误信息更有助于排查);但用了它,就必须配套地用 errors.Is/errors.As 来判断,否则,你的错误判断,就会在错误链面前,集体失灵

故障现场:错误被 %w 包装后,== 匹配不上

我把这个"== 失灵"的现场,摊开给你看:

// ✗ 灾难: 用 == 判断错误, 下游一包装错误就匹配不上
var ErrNotFound = errors.New("not found")   // 哨兵错误

// 下游(改之前): 直接返回 ErrNotFound
//   return ErrNotFound

// 下游(改之后): 用 %w 包装, 加了上下文信息
func queryUser(id int) error {
    // ...
    return fmt.Errorf("查询用户 %d 失败: %w", id, ErrNotFound)  // ✗ 包装了!
}

// 我的判断(一直用 ==):
err := queryUser(123)
if err == ErrNotFound {              // ✗ false! 现在 err 是包装后的新错误
    return http.StatusNotFound       // ✗ 进不来了
}
return http.StatusInternalServerError  // ✗ 错误地走到了这里(把404当成了500)

// 为什么 == 失灵了?
//   - %w 包装后, err 是 fmt.Errorf 返回的"新错误对象"(外层)。
//   - 原来的 ErrNotFound 被"包"在这个新错误的内部, 形成错误链:
//     [外层: "查询用户123失败"] --包裹--> [内层: ErrNotFound]
//   - == 只比最外层那个对象 → 最外层不是 ErrNotFound → false。

// ✓ 正解: 用 errors.Is(顺着错误链查找)
if errors.Is(err, ErrNotFound) {     // ✓ true! 它会穿透包装, 沿链找到 ErrNotFound
    return http.StatusNotFound
}

// 类似的坑: 用类型断言判断自定义错误类型, 包装后也失效
type MyError struct{ Code int }
func (e *MyError) Error() string { return "..." }
// ✗ if e, ok := err.(*MyError); ok  → 包装后 ok=false
// ✓ var me *MyError
//   if errors.As(err, &me)  → 穿透包装, 提取出 MyError

// 根因: 错误被 %w 包装后变成新的外层错误、原错误埋在链内层;
//   == 只比最外层故失效; 要用 errors.Is/As 穿透错误链判断。

看着这个"包装后 == 就 false"的现场,我才算彻底想明白了根源。问题的核心,是错误被 %w 包装后,err 变成了 fmt.Errorf 返回的"新错误对象"(外层),原来的 ErrNotFound 被"包"在了内部、形成错误链(外层"查询失败" → 内层 ErrNotFound)。== 只比最外层那个对象,最外层不是 ErrNotFound(它埋在里面了),所以返回 false,代码就错误地走到了 500 分支正解是用 errors.Is:errors.Is(err, ErrNotFound) 会穿透包装、沿错误链找到 ErrNotFound,返回 true类似的坑还有:用类型断言 err.(*MyError) 判断自定义错误类型,包装后也失效,要用 errors.As(err, &me) 穿透包装提取归根结底:错误被 %w 包装后变成新的外层错误、原错误埋在链内层;== 只比最外层故失效;要用 errors.Is/errors.As 穿透错误链判断——这,就是根源。

第一件事:搞懂错误包装与错误链

定位到根源,我必须把"错误包装、错误链、Is/As"从根上彻底搞清楚:

错误包装(%w)形成错误链; 判断错误要用 errors.Is/As 穿透链, 而非 ==

# Go 的错误处理基础:
#   - error 是个接口; 哨兵错误: var ErrXxx = errors.New("...")。
#   - 旧方式判断: err == ErrXxx(只在"没包装"时有效)。

# 错误包装(error wrapping):
#   - fmt.Errorf("上下文: %w", err): %w 把 err "包"进一个新错误里。
#   - 目的: 既保留原始错误, 又层层加上下文(如 "查DB失败: 查用户失败: 连接超时")。
#   - 结果: 形成一条"错误链"(外层 → ... → 最内层的根因错误)。

# 为什么 == 在包装后失效?
#   - 包装后, err 是最外层的新错误对象。
#   - 原错误(ErrXxx)在链的内层, 不是最外层。
#   - == 只比最外层对象 → 不等于内层的 ErrXxx → false。

# 正确的判断工具(会穿透错误链):
#   - errors.Is(err, target): 沿链查找, 链上任一层 == target(或匹配 Is)就 true。
#     用于: 判断"是不是某个哨兵错误"(如 errors.Is(err, sql.ErrNoRows))。
#   - errors.As(err, &target): 沿链查找某"类型", 找到则提取赋给 target。
#     用于: 判断"是不是某种自定义错误类型", 并拿到它(读它的字段)。
#   - errors.Unwrap(err): 剥一层, 拿到被包裹的内层错误(底层 API)。

# 关键认知: 包装是为了"加上下文不丢根因"; 判断必须用 Is/As "穿透"链。
#   - 库/下游可能随时给错误加包装; 你的判断要对包装"免疫"(用 Is/As)。

# 经验:
#   - 判断错误: 一律 errors.Is / errors.As, 别用 == / 类型断言(会被包装坑)。
#   - 传递错误并加上下文: 用 %w(保留链), 别用 %v(丢失链, 变成纯字符串)。

# 核心: %w 包装错误形成错误链、== 只比最外层会失效; 判断错误用 errors.Is(查哨兵)
#   /errors.As(查类型)穿透链; 包装用 %w 保留根因, 别用 %v。

原理终于清晰了。Go 错误处理基础:error 是接口、哨兵错误 var ErrXxx = errors.New(...),旧方式 err == ErrXxx 只在"没包装"时有效错误包装:fmt.Errorf("上下文: %w", err)%w 把 err"包"进一个新错误,目的是既保留原始错误、又层层加上下文,结果形成一条错误链(外层 → … → 最内层根因)为什么 == 包装后失效?包装后 err 是最外层的新错误对象,原错误在链的内层,== 只比最外层、故不等于内层的 ErrXxx正确的判断工具(会穿透链):errors.Is(err, target)(沿链查找,链上任一层匹配就 true,用于判断哨兵错误);errors.As(err, &target)(沿链找某类型并提取,用于自定义错误类型);errors.Unwrap(err)(剥一层、拿内层错误)由此,我刻下一个关键认知:包装是为了"加上下文不丢根因",判断必须用 Is/As 穿透链;库/下游可能随时给错误加包装,你的判断要对包装"免疫";经验是——判断错误一律用 errors.Is/errors.As(别用 ==/类型断言),传递错误加上下文用 %w(保留链)而非 %v(丢链、变纯字符串)。归根结底:%w 包装错误形成错误链、== 只比最外层会失效;判断错误用 errors.Is(查哨兵)/errors.As(查类型)穿透链;包装用 %w 保留根因、别用 %v

第二件事:正解——errors.Is/As 判断 + %w 包装

搞懂了原理,正解就清晰了:判断错误用 errors.Is(查哨兵)/errors.As(查类型),传递错误加上下文用 %w 保留链

// ✓ 正解一: 判断哨兵错误用 errors.Is(穿透包装)
var ErrNotFound = errors.New("not found")

err := queryUser(123)   // 可能返回 fmt.Errorf("...: %w", ErrNotFound)
if errors.Is(err, ErrNotFound) {   // ✓ 沿链查找, 即使被包装也能匹配
    return http.StatusNotFound
}

// ✓ 正解二: 判断/提取自定义错误类型用 errors.As
type APIError struct {
    Code int
    Msg  string
}
func (e *APIError) Error() string { return e.Msg }

var apiErr *APIError
if errors.As(err, &apiErr) {       // ✓ 沿链找到 *APIError 并提取出来
    return apiErr.Code             // ✓ 能读它的字段了
}

// ✓ 正解三: 传递错误并加上下文用 %w(保留错误链)
func service() error {
    if err := dao(); err != nil {
        return fmt.Errorf("service 处理失败: %w", err)  // ✓ %w 保留 err 链
        // ✗ 别用 %v: fmt.Errorf("...: %v", err) → err 变成纯字符串, 链断了!
    }
    return nil
}

// ✓ 正解四: 让自定义错误支持 errors.Is/As(实现 Unwrap / Is)
type NotFoundError struct{ ID int }
func (e *NotFoundError) Error() string { return fmt.Sprintf("id %d not found", e.ID) }
func (e *NotFoundError) Unwrap() error { return ErrNotFound }  // ✓ 让它"链到" ErrNotFound
//   → errors.Is(notFoundErr, ErrNotFound) 也能为 true

// ✓ 正解五: 别过度包装/重复包装
//   - 每层加"有意义的上下文", 别每层都裹一句废话。
//   - 顶层统一记录日志(打印完整链); 中间层只加上下文不重复记日志。

// 原则速记:
//   - 判断错误: errors.Is(哨兵) / errors.As(类型), 永不用 == / 类型断言。
//   - 传递错误: %w 保留链, 加有意义的上下文。
//   - 自定义错误: 实现 Unwrap 让它能被 Is/As 穿透。

// 核心: 判断哨兵用 errors.Is、判断类型用 errors.As(都穿透包装); 传递用 %w 保链;
//   自定义错误实现 Unwrap; 别用 == / %v。

修复的方向,是"用对 Go 现代错误处理的那套工具"正解一,判断哨兵错误用 errors.Is:errors.Is(err, ErrNotFound)沿链查找、即使被包装也能匹配正解二,判断/提取自定义错误类型用 errors.As:errors.As(err, &apiErr) 沿链找到 *APIError 并提取出来,就能读它的字段(如 Code)了正解三,传递错误并加上下文用 %w:fmt.Errorf("...: %w", err) 保留错误链——千万别用 %v(%v 会把 err 变成纯字符串、链就断了,后续 Is/As 也查不到了)。正解四,让自定义错误支持 Is/As:实现 Unwrap() 方法返回它要"链到"的错误,这样 errors.Is 也能穿透它。正解五,别过度包装:每层加"有意义的上下文"、别每层裹废话;顶层统一记完整链日志、中间层只加上下文不重复记归根结底:判断哨兵用 errors.Is、判断类型用 errors.As(都穿透包装);传递用 %w 保链;自定义错误实现 Unwrap;别用 ==/%v

第三件事:Go 错误处理的其他常见坑

这次踩坑后,我把 Go 错误处理里其他常见的坑,也一并梳理清楚了:

// Go 错误处理的其他常见坑:

// 1. 忽略错误(最常见)
result, _ := doSomething()   // ✗ 用 _ 丢掉了 err! 出错了也不知道
//   → 老老实实 if err != nil 处理; 用 errcheck/linter 揪出被忽略的 err。

// 2. 用 %v 而非 %w 传递错误 → 错误链断了
return fmt.Errorf("失败: %v", err)  // ✗ 链断, errors.Is/As 查不到根因
//   → 加上下文要保留链就用 %w。

// 3. defer 里的错误被吞
func f() (err error) {
    defer file.Close()       // ✗ Close 的 error 被丢了
    // ✓ defer func(){ if cerr := file.Close(); cerr != nil && err == nil { err = cerr } }()
}

// 4. 在循环里只记最后一个错误 / 多个错误丢失
//   → Go 1.20+ 用 errors.Join(err1, err2) 合并多个错误。

// 5. 错误信息没上下文, 排查像大海捞针
//   return err          // ✗ 一路裸传, 最后只看到个 "not found", 不知哪来的
//   return fmt.Errorf("处理订单 %d: %w", id, err)  // ✓ 带上下文

// 6. panic 当 error 用 / 该 error 的地方用了 panic
//   → 可预期的错误用 error 返回; panic 只用于"不可恢复的程序 bug"。

// 7. nil error 的 typed nil 坑(见 typed nil 篇)
//   → 返回 error 接口时小心包装了 nil 的具体类型。

// 核心: Go 错误处理坑还有 忽略err、%v断链、defer吞错、多错丢失、缺上下文、
//   panic滥用; 处理err要 不忽略+%w保链+加上下文+errors.Is/As判断。

原来 Go 错误处理的坑,远不止包装一个忽略错误(result, _ := ..._ 丢掉 err,最常见,要用 linter 揪出);%v 而非 %w 传递(链断了、Is/As 查不到根因);defer 里的错误被吞(defer file.Close() 丢了 Close 的 error);循环里多个错误丢失(Go 1.20+ 用 errors.Join 合并);错误信息没上下文(一路裸传,最后只看到个 "not found" 不知哪来的,要 fmt.Errorf("处理订单%d: %w", ...) 带上下文);panic 当 error 滥用(可预期错误用 error 返回、panic 只用于不可恢复的 bug);typed nil 坑(见 typed nil 篇)。它们共同指向 Go 错误处理的几条准则:不忽略 err、用 %w 保链、加有意义的上下文、用 errors.Is/errors.As 判断归根结底:Go 错误处理坑还有忽略 err、%v 断链、defer 吞错、多错丢失、缺上下文、panic 滥用;处理 err 要不忽略 + %w 保链 + 加上下文 + errors.Is/As 判断。

下面这张图,是这次"== 失灵"的成因与解法:

第四件事:错误判断方式速查对照

这次踩坑后,我把 Go 里几种判断/处理错误的方式,整理成一张速查表,以后判断错误时用对。

方式 做什么 能穿透包装吗 用于
err == ErrXxx 比最外层是否是某哨兵 ✗ 不能(被包装就失效) ✗ 别再用(除非确定没包装)
err.(*MyError) 类型断言最外层 ✗ 不能 ✗ 别再用
errors.Is(err, ErrXxx) 沿链找是否有某哨兵 ✓ 能 判断哨兵错误(推荐)
errors.As(err, &t) 沿链找某类型并提取 ✓ 能 判断+提取自定义错误类型
fmt.Errorf("%w", err) 包装并保留链 —(用于包装) 加上下文且保留根因
errors.Unwrap(err) 剥一层拿内层 —(手动剥) 底层 API, 一般用 Is/As

这张表,把"该用哪个"讲清了。核心结论:判断错误,一律用 errors.Is(查哨兵)和 errors.As(查类型),因为只有它们能"穿透包装";而 == ErrXxx 和 类型断言 err.(*T),都只比最外层、被包装就失效,应该弃用(除非你 100% 确定那个错误不会被包装)。包装错误则用 fmt.Errorf("%w", err)(保留链)。它给我的启发是:Go 在 1.13 引入了 %w 包装和 errors.Is/As 这套"错误链"机制后,判断错误的"正确方式",就已经从 ==/类型断言,整体迁移到了 errors.Is/errors.As;如果你还停留在用 == 判断错误的旧习惯里,就会在任何一个"错误被包装"的地方,悄无声息地踩坑跟上语言的演进、用对它新提供的工具,本身就是避坑的一部分

第五件事:好的错误处理,是为了"可排查"

这次踩坑也让我重新思考"为什么要好好处理错误"。我把好的错误处理的几个目标和做法梳理了一下。

目标 怎么做
能正确判断错误类型 errors.Is/As 穿透包装, 别用 ==
错误信息能定位问题 每层用 %w 加有意义的上下文(谁、做什么、什么参数)
不丢失根因 用 %w 不用 %v, 保留完整错误链
不丢失任何错误 不忽略 err(别 _); 多错用 errors.Join
区分"预期错误"和"程序bug" 预期错误用 error, bug 用 panic
顶层统一记录 顶层打印完整链(含上下文), 中间层别重复记日志

这张表,让我看清了"好好处理错误"的终极目的——不是为了"程序不报错",而是为了"当它出错时,你能快速、准确地定位和解决问题"一个好的错误处理体系,要做到:能正确判断错误类型(errors.Is/As)、错误信息能定位问题(每层加有意义的上下文)、不丢失根因(%w 保留链)、不丢失任何错误(不忽略、多错用 errors.Join)、区分预期错误和程序 bug(error vs panic)、顶层统一记录完整链它给我的最大启发是:错误处理,不是写代码时"顺手应付一下"的边角料,而是决定"系统出问题时,你是几分钟定位,还是抓瞎几小时"的关键工程;那条层层包装、带着完整上下文的错误链,在线上出事时,就是你顺藤摸瓜、直达根因的"救命绳索"。所以,认真对待每一个错误的判断、包装、上下文和记录——你在写代码时为错误处理多花的每一分心思,都会在某个深夜排查线上故障时,加倍地回报给你好的错误处理,是写给"未来那个正在救火的自己"的一封情书

第六件事:处理一个错误时,我现在会怎么决策

现在,每当我处理一个错误,脑子里都会过一遍这张决策图——核心两问:我是要"判断"它,还是要"传递"它?

这张图的灵魂,是先分清"判断"还是"传递"第一问:我要做什么?——判断它是什么错误(再分:判断哨兵用 errors.Is、判断类型并读字段用 errors.As);传递给上层(fmt.Errorf("上下文: %w", err) 加有意义的上下文并保留链);当前层能处理(处理掉、别再往上抛)。而贯穿始终的铁律是:判断绝不用 ==/类型断言(会被包装坑)、绝不忽略 err(别用 _)、多个错误用 errors.Join、顶层统一记录完整错误链这套判断,让我处理错误时,不再被"包装"坑、不再丢失根因、不再让错误悄悄溜走——核心始终是:判断用 Is/As 穿透链,传递用 %w 保留链。

我立下的几条规矩

这场"== 失灵"的事故,换来了我写 Go 时,刻进骨子里的几条铁律:

  1. 判断错误用 errors.Is/As,绝不用 ==/类型断言。错误一旦被 %w 包装,== 只比最外层就失效;Is/As 能穿透错误链。
  2. 判断哨兵用 errors.Is,判断类型用 errors.As。Is 查"是不是某个错误",As 查"是不是某类型"并提取它。
  3. 传递错误用 %w 保留链,别用 %v。%v 把错误变成纯字符串、链就断了,根因和后续 Is/As 都没了。
  4. 每层加有意义的上下文。"处理订单N失败: %w",让错误链自带排查线索,别一路裸传。
  5. 自定义错误实现 Unwrap。让它能被 errors.Is/As 穿透,链到它包裹的根因。
  6. 绝不忽略 err。别用 _ 丢掉错误;多个错误用 errors.Join 合并;用 linter 揪出被忽略的。
  7. 错误处理是为了可排查。顶层记录完整链;好的错误处理是写给未来救火的自己的。

附:一个完整的错误链 + errors.Is/As 实战示例

把哨兵错误、自定义错误类型、%w 包装、Is/As 判断揉到一起,这是一个能直接参照的完整示例:

package main

import (
    "errors"
    "fmt"
)

// 1. 哨兵错误
var ErrNotFound = errors.New("not found")

// 2. 自定义错误类型(带字段, 实现 Unwrap 让它能链到哨兵)
type QueryError struct {
    Table string
    ID    int
    err   error   // 包裹的底层错误
}
func (e *QueryError) Error() string {
    return fmt.Sprintf("query %s id=%d: %v", e.Table, e.ID, e.err)
}
func (e *QueryError) Unwrap() error { return e.err }   // ✓ 让 Is/As 能穿透到 e.err

// 3. dao 层: 返回包装了哨兵的自定义错误
func dao(id int) error {
    return &QueryError{Table: "users", ID: id, err: ErrNotFound}
}

// 4. service 层: 再用 %w 加一层上下文
func service(id int) error {
    if err := dao(id); err != nil {
        return fmt.Errorf("service 获取用户失败: %w", err)   // ✓ 又包一层
    }
    return nil
}

func main() {
    err := service(123)
    // 错误链: [service上下文] -> [*QueryError] -> [ErrNotFound]

    // ✓ errors.Is: 穿透两层包装, 找到链最底层的 ErrNotFound
    if errors.Is(err, ErrNotFound) {
        fmt.Println("是 NotFound 错误 → 返回 404")   // ✓ 命中!
    }

    // ✓ errors.As: 穿透 service 那层, 提取出中间的 *QueryError, 读它的字段
    var qe *QueryError
    if errors.As(err, &qe) {
        fmt.Printf("出错的表=%s, id=%d\n", qe.Table, qe.ID)   // ✓ 拿到字段!
    }

    fmt.Println(err)   // 完整链信息: "service 获取用户失败: query users id=123: not found"
}

// 核心: 哨兵+自定义错误(实现Unwrap)+%w层层包装 形成错误链;
//   errors.Is 查根因哨兵、errors.As 提取中间类型读字段, 都能穿透任意层包装。

这个示例,把 Go 现代错误处理的全套要素,串成了一条完整的链它构建了一条三层的错误链:最底层是哨兵 ErrNotFound → 中间是带字段的自定义 *QueryError(它实现了 Unwrap 链到 ErrNotFound)→ 最外层是 service 用 %w 加的上下文然后展示了 Is 和 As 的各自威力:errors.Is(err, ErrNotFound)穿透两层包装、找到链最底层的哨兵,据此返回 404;errors.As(err, &qe)穿透 service 那层、提取出中间的 *QueryError,读到它的 Table、ID 字段;而打印整个 err,得到的是带完整上下文的链信息"service 获取用户失败: query users id=123: not found"——一眼就能看出"在哪、做什么、什么根因"这,正是我想用这个示例,留给每一个写 Go 的人的最后一课:Go 的错误处理,看似"啰嗦"(到处 if err != nil),实则提供了一套极其强大、可组合的"错误链"机制;用好它(哨兵 + 自定义类型 + %w 包装 + Is/As 判断),你就能构建出"既能精确判断类型、又能层层携带上下文、还能直达根因"的、真正工程级的错误处理错误处理的功力,藏在这些细节里;而它,正是区分"能跑的代码"和"好维护的系统"的、一道重要的分水岭

写在最后

回头看,这场由"错误包装"引发的、== 判断失灵的事故,真正教给我的,是一个比"用 errors.Is"本身更深的道理:语言和库,是会"演进"的;它们会引入新的机制(如错误包装)、提供新的最佳实践(如 errors.Is);而如果你停留在旧的习惯里、不跟上这种演进,你曾经"正确"的代码,就可能在新的机制面前,悄无声息地变成"错误"我的 == ErrNotFound,在 Go 引入错误包装之前,是完全正确、且地道的写法;可一旦整个生态开始普遍使用 %w 包装错误,这个旧写法,就从"正确",滑向了"会踩坑"——而我浑然不觉,直到被下游的一次改动绊倒。这让我深刻地领悟到:作为工程师,不能一次学会、就停止学习;要持续地关注你所用语言和库的演进——它们新增了什么能力?改变了什么最佳实践?废弃了什么旧用法?;因为技术的"正确答案",本身就是随着生态演进而动态变化那些"我一直都这么写、一直没问题"的旧习惯,恰恰最值得定期拿出来,对照最新的最佳实践,审视一遍——因为它可能早已悄悄过时、只是还没在你身上爆发而已持续跟上语言的演进,定期审视"一直没问题"的旧习惯——这,是我用一次"== 失灵"的事故,换来的、关于 Go、也关于"如何保持技术不落伍"的、最朴素也最深刻的领悟。如果这篇复盘,能让你回去把项目里那些 == ErrXxx 都换成 errors.Is,那我对着那个失灵的判断熬的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我写的 JS 里 setTimeout、Promise 和同步代码混在一起,打印顺序完全不按我写的来,Promise 居然比 setTimeout 先跑,我对着事件循环排查了大半天的复盘

2026-6-2 5:06:44

技术教程

我把一个 Integer 赋值给 int 居然抛了空指针,那行代码里根本没有方法调用、找不到 null 是怎么来的,我对着自动拆箱排查了大半天的复盘

2026-6-2 5:20:13

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索