本节摘要:Go 用返回值而非异常来传递失败,本节讲清 error 接口、哨兵错误、错误包装与判定三件套,再给出一套结构化日志的落地写法。学完本节,你写的程序不再只有"能跑"一种状态,而是失败可判定、原因可追溯、现场可回放——这是值班系统的底座。
上一节的多返回值已经埋了伏笔:第二个返回值是 error。本节把这最后一道工序补齐。错误处理和日志在夜班场景里就是"异常上报与值班记录"——出了事,谁上报、怎么上报、记录里要留哪些线索,都有规矩。本章也到此收口,第 2 章将带着这套规程进入类型系统。
行话先讲清楚:error 不是异常,不是信号,就是一个接口——任何实现了 Error() string 方法的值都能当错误用。它跟着函数返回值走,调用方逐层检查,责任清晰。标准库里定义了哨兵错误(如 io.EOF)表示特定失败,供调用方精确比对:
package main import ( "errors" "fmt" ) // 预定义哨兵错误:包级变量,调用方可用 errors.Is 精确判定 var ErrEmptyQueue = errors.New("队列为空") func next(ticket []string, idx int) (string, error) { if idx >= len(ticket) { return "", ErrEmptyQueue // 返回哨兵,调用方能认出它 } return ticket[idx], nil } func main() { ticket := []string{"备份", "巡检"} name, err := next(ticket, 2) if err != nil { if errors.Is(err, ErrEmptyQueue) { fmt.Println("识别出哨兵错误:", err) } return } fmt.Println("下一个工单:", name) } // 运行输出: // 识别出哨兵错误: 队列为空
用 errors.Is 而不是 == 来比对,原因马上揭晓:错误在层层上报的途中会被包装,直接等值比较会失手。
失败发生在底层,处理决策却往往在高层,错误必须穿过中间层传递。fmt.Errorf 的百分号 w 动词在包装时保留原始错误,形成一条可回溯的链:
package main import ( "errors" "fmt" "os" ) func loadConfig() ([]byte, error) { data, err := os.ReadFile("app.conf") if err != nil { // 百分号 w 把底层错误包进来,链路得以保留 return nil, fmt.Errorf("读取配置失败: %w", err) } return data, nil } func boot() error { if _, err := loadConfig(); err != nil { return fmt.Errorf("启动失败: %w", err) } return nil } func main() { err := boot() if err != nil { fmt.Println("报错链:", err) if errors.Is(err, os.ErrNotExist) { fmt.Println("判定: 配置文件不存在,属首次部署") } } } // 运行输出: // 报错链: 启动失败: 读取配置失败: open app.conf: no such file or directory // 判定: 配置文件不存在,属首次部署
一条输出信息同时看到三层现场:启动在干什么、配置在干什么、系统调用报了什么。底层排障时这就是完整的线索链。除了值判定,errors.As 还能把链上的错误还原成具体类型,取出结构化字段:
type NetErr struct { Addr string Code int } func (e *NetErr) Error() string { return fmt.Sprintf("网络错误 %d: %s", e.Code, e.Addr) } func handle(err error) { var ne *NetErr if errors.As(err, &ne) { // 沿包装链找到 NetErr 类型的错误 fmt.Println("重试目标地址:", ne.Addr, "状态码:", ne.Code) } } // 传入包装过的 NetErr 时输出: 重试目标地址: 10.0.0.8:6379 状态码: 503
fmt.Println 打印的信息转瞬即逝,日志则要留下时间、级别、来源与结构化字段,供事后检索。标准库从 1.21 起内置了结构化日志包 slog,一行配置就能输出机器可解析的记录:
package main import ( "log/slog" "os" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, // 低于 Info 的调试日志不再输出 })) slog.SetDefault(logger) slog.Info("工单处理完成", "工单号", 1024, "耗时秒", 3.2) slog.Warn("重试次数偏高", "当前", 5, "阈值", 3) } // 运行输出(JSON 行,可直接接入采集系统): // {"time":"2026-08-30T02:00:00","level":"INFO","msg":"工单处理完成","工单号":1024,"耗时秒":3.2} // {"time":"2026-08-30T02:00:00","level":"WARN","msg":"重试次数偏高","当前":5,"阈值":3}
键值对形式的日志让"按工单号检索"成为一次查询而不是人眼扫屏,生产服务值得从第一天就用结构化日志。
背景:服务在测试机正常,上了预发环境起不来,值班同学只有一条启动日志可用。
操作:按本节规矩改造启动代码——每层用百分号 w 包装错误再上抛,顶层统一记一条结构化错误日志;判定哨兵错误走分支逻辑(文件不存在则生成默认配置后继续启动)。
结果:日志输出完整错误链与判定结论,服务自动生成默认配置并正常拉起,值班同学不需要登机器排查。
解读:这套流程的可贵之处在于错误信息"只记一次、逐层包装、顶层落盘"。中间层不重复打日志,避免同一场故障刷出十条重复告警;顶层拿到完整链条再决定处理策略,信息不丢、噪音不增。
变式:如果故障需要立即人工介入(比如证书损坏),把判定分支改成 slog.Error 加告警通知,服务转为拒绝启动——错误该恢复还是该升级,取决于业务语义,代码结构不变。
至此第一章收工:语言、环境、语法、组织、容错五件套齐了。第 2 章进入类型系统,去看看 Go 怎么给每位"员工"建档案。