1.5 错误处理与日志:异常上报与值班记录


1.5 错误处理与日志:异常上报与值班记录

本节摘要:Go 用返回值而非异常来传递失败,本节讲清 error 接口、哨兵错误、错误包装与判定三件套,再给出一套结构化日志的落地写法。学完本节,你写的程序不再只有"能跑"一种状态,而是失败可判定、原因可追溯、现场可回放——这是值班系统的底座。

上一节的多返回值已经埋了伏笔:第二个返回值是 error。本节把这最后一道工序补齐。错误处理和日志在夜班场景里就是"异常上报与值班记录"——出了事,谁上报、怎么上报、记录里要留哪些线索,都有规矩。本章也到此收口,第 2 章将带着这套规程进入类型系统。

error 就是普通的值

行话先讲清楚: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 加告警通知,服务转为拒绝启动——错误该恢复还是该升级,取决于业务语义,代码结构不变。

三条反模式,绕开它们

  • 吞错误:把 error 赋给下划线等于把火警铃塞进抽屉,除非你明确论证过"失败可忽略",否则至少记一条日志。
  • 先记日志再上抛:同一错误在每一层都打日志,排障时满屏重复。原则是处理错误的层才有资格记录它,纯传递层只包装。
  • 拿 panic 当异常用:panic 只留给不可恢复的编程错误(数组越界、空指针这类 bug),业务失败一律走 error。第 4 章讲并发安全时会再强调一次。

收班要点

  • error 是接口不是异常,跟返回值走,调用路径显式可见。
  • 哨兵错误配 errors.Is,包装链不破坏判定;errors.As 取回结构化错误字段。
  • 百分号 w 包装让错误链一次记录、全程可溯,中间层不重复落盘。
  • slog 结构化日志从第一天就用,键值对让检索变成查询。
  • 吞错误、重复记日志、滥用 panic 是三大反模式,评审时见到就打回。

至此第一章收工:语言、环境、语法、组织、容错五件套齐了。第 2 章进入类型系统,去看看 Go 怎么给每位"员工"建档案。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U