深色模式
Go 错误处理实践
概述
Go 的错误处理看起来朴素,甚至有点啰嗦,但它的设计目标一直很明确:把失败路径写在明面上,不把控制流藏进异常机制里。
所以写 Go 错误处理时,真正要掌握的不是“如何少写几行 if err != nil”,而是三件事:错误值怎么设计、上下文怎么补、调用方怎么判断。
错误首先是一个普通值
Go 里 error 只是一个接口:
go
type error interface {
Error() string
}这意味着错误不是一种神秘机制,而是一种普通返回值。函数失败时把错误返回给调用方,调用方决定是继续往上抛,还是在当前层处理。
一个最基础的例子:
go
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return data, nil
}这里没有“捕获异常”这一层魔法,只有非常直接的失败返回。
先做到两件基本功
失败就尽早返回
Go 代码里最常见、也最稳的错误处理方式,就是尽早返回:
go
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, nil
}这样做的好处很直接:
- 正常路径更清楚。
- 错误不会被拖到后面再处理。
- 每一层都更容易补充自己的上下文。
不要吞掉错误
下面这种写法很常见,也很危险:
go
if err != nil {
fmt.Println("读取失败")
}问题不在于打印,而在于它既没有返回,也没有处理,只是把失败轻轻放过去了。后面的代码继续执行,通常只会把问题放大。
返回时补上下文,而不是只丢原始错误
光把底层错误往上抛,有时信息不够。调用栈跨了两三层以后,只剩一个 connection refused 或 no such file or directory,定位会很费劲。
更稳的方式是补一层业务上下文:
go
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
return cfg, nil
}这里 %w 很关键。它不是普通字符串拼接,而是在保留原始错误链的前提下补充上下文。
判断错误时优先用 errors.Is 和 errors.As
errors.Is
当关心“是不是某一类错误”时,用 errors.Is:
go
data, err := os.ReadFile("app.yaml")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("配置文件不存在: %w", err)
}
return err
}它会沿着错误链向下判断,所以很适合配合 %w 使用。
errors.As
当关心“是不是某种具体错误类型”时,用 errors.As:
go
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败", e.Field)
}
func handle(err error) {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("无效字段:", ve.Field)
}
}errors.As 的重点不是比较值,而是把错误断言成某个具体类型。
什么时候用哨兵错误,什么时候用自定义类型
哨兵错误
如果调用方只需要知道“是不是这个特定错误”,定义哨兵错误就够了:
go
var ErrEmptyName = errors.New("name is empty")配合 errors.Is 判断即可。
自定义错误类型
如果调用方还需要拿到更多字段,比如哪个参数错了、哪个资源没找到,就该用自定义错误类型:
go
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s 不存在", e.Resource, e.ID)
}这时调用方可以通过 errors.As 取出结构化信息,而不是再去解析错误字符串。
两个常见坏习惯
一边记录日志,一边原样返回
如果每一层都 log.Println(err),最后顶层再打一遍,日志里就会变成同一个错误刷屏。更稳的做法是:
- 中间层负责补上下文并返回。
- 边界层统一记录日志或输出给用户。
靠字符串判断错误
这种写法尽量别留:
go
if strings.Contains(err.Error(), "not found") {
// ...
}错误文案是给人看的,不是给程序做稳定匹配的。程序逻辑应该依赖 errors.Is、errors.As 或者明确的错误类型。
一段更接近实际项目的写法
go
var ErrInvalidPort = errors.New("invalid port")
func parsePort(raw string) (int, error) {
port, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("解析端口 %q 失败: %w", raw, err)
}
if port <= 0 || port > 65535 {
return 0, ErrInvalidPort
}
return port, nil
}调用方可以这样处理:
go
port, err := parsePort(input)
if err != nil {
if errors.Is(err, ErrInvalidPort) {
return fmt.Errorf("端口超出合法范围: %w", err)
}
return err
}这个组合很典型:
- 底层错误保留原始原因。
- 业务错误用明确的哨兵值。
- 调用方按语义判断,而不是猜字符串。
