标签: 工程实践

  • Go 错误处理实战:从 panic 到 errors.Is/As 的工程化用法

    Go 错误处理实战:从 panic 到 errors.Is/As 的工程化用法

    为什么 Go 的错误处理值得专门写一篇

    Go 没有 Java 那样的 checked exception,也没有 Python 的 try/except 统一捕获。它把 error 当作普通的值返回,让错误处理变成显式的、散布在每一行调用之后的判断。这种设计常被新手诟病”满屏 if err != nil”,但用好了,它带来的可读性与可控性是异常机制难以比拟的。

    一、error 是值,不是异常

    在 Go 里,error 是一个内建接口:

    type error interface {
        Error() string
    }

    任何实现了 Error() string 的类型都能当作错误。这意味着错误可以被比较、包装、携带上下文,而不只是抛出一个调用栈。最根本的工程纪律是:error 必须被处理或被显式忽略,绝不能随手丢弃。

    二、哨兵错误与 errors.Is

    标准库定义了一批可导出的”哨兵错误”(sentinel error),例如 io.EOF、sql.ErrNoRows。早期代码习惯用 == 比较:

    if err == sql.ErrNoRows {
        // 没有记录
    }

    问题在于,一旦错误被 wrap(用 %w 包装),== 比较就会失效。正确做法是用 errors.Is,它会沿错误链逐层比对底层错误:

    if errors.Is(err, sql.ErrNoRows) {
        // 正确处理包装后的错误
    }

    三、自定义错误类型与 errors.As

    当错误需要携带结构化字段(如错误码、重试次数)时,应定义自定义类型并用 errors.As 提取:

    type TimeoutError struct {
        Timeout time.Duration
    }
    
    func (e *TimeoutError) Error() string {
        return fmt.Sprintf("timeout after %s", e.Timeout)
    }
    
    // 调用方
    var te *TimeoutError
    if errors.As(err, &te) {
        log.Printf("超时时长: %v", te.Timeout)
    }

    errors.As 同样会穿透包装层,比直接对 err 做类型断言稳健得多。

    四、用 %w 包装错误链

    Go 1.13 引入 %w,让你在传播错误时保留原始错误:

    if err != nil {
        return fmt.Errorf("读取配置失败: %w", err)
    }

    这样上层既能用 errors.Is/As 还原底层错误,又能在日志里看到完整的上下文链。注意:%w 每个格式化串只能用一次,且不要用 %v 代替——%v 会丢失可解包性。

    五、panic 的正确边界

    panic 不是异常的替代品。它只应用于真正不可恢复的程序错误(如初始化阶段配置缺失)。对外的边界(HTTP handler、goroutine 入口)应当 recover 并返回错误,而不是让进程崩溃。

    经验法则:库函数返回 error,程序主流程在边界处兜底 panic。把 error 留给调用方决策,把 panic 留给真正不可恢复的场景。

    六、可直接落地的实践清单

    • 不要用 _ = err 静默丢弃,至少打日志或加注释说明为何忽略。
    • 对外 API 返回的错误不要暴露内部实现细节(数据库字段名、路径)。
    • 错误文案用小写开头、不加句末标点,保持 wrap 后拼接自然。
    • 在顶层统一用 errors.Is/As 做分支判断,而非 ==。

    错误处理没有银弹,但遵循”显式处理、链式包装、边界兜底”三原则,足以让 Go 项目在规模增长后仍保持错误可控。下一篇可以聊聊如何用结构化日志与错误链配合,形成可观测性闭环。