Skip to content

10|错误处理

运维和后端程序运行过程中,磁盘可能写满、配置文件可能格式错误、远程服务可能拒绝连接、数据库连接可能超时。这些异常不是程序本身的 bug,而是运行环境的不确定性。Go 没有 try-catch 异常机制,普通错误通过返回值传递,程序员的职责是显式检查每个可能出错的操作。这种设计迫使错误处理出现在调用点,不会被深层的异常栈隐藏。

一、error 接口

error 是 Go 标准库里最常用的接口,只有一个方法:

go
type error interface {
	Error() string
}

任何实现了 Error() string 方法的类型都可以作为错误使用。最简单的错误用 errors.New 创建:

go
package main

import (
	"errors"
	"fmt"
)

func validatePort(port int) error {
	if port < 1 || port > 65535 {
		return errors.New("invalid port")
	}
	return nil
}

func main() {
	if err := validatePort(70000); err != nil {
		fmt.Println(err)
	}
}

nil 表示没有错误。Go 里约定俗成:函数返回的最后一个值是 error 类型,为 nil 时调用成功,非 nil 时调用失败。

二、带上下文的错误

错误信息需要包含具体上下文,否则排查时不知道"invalid port"到底是哪个服务的端口。fmt.Errorf 可以格式化错误字符串:

go
func validatePort(port int) error {
	if port < 1 || port > 65535 {
		return fmt.Errorf("invalid port: %d", port)
	}
	return nil
}

一层层包装错误,形成错误链:

go
data, err := os.ReadFile("/etc/app.conf")
if err != nil {
	return fmt.Errorf("read config: %w", err)
}

%w 是 Go 1.13 引入的动词,它会保留原始错误,让调用方后续可以用 errors.Iserrors.As 判断错误类型。如果用了 %v,原始错误类型会丢失,只剩格式化后的字符串。

三、errors.Is 与 errors.As

errors.Is 判断错误链里是否包含某个特定错误:

go
import "errors"

if errors.Is(err, os.ErrNotExist) {
	fmt.Println("file not found")
}

即使 err 被包装了多层,errors.Is 也会沿着错误链逐层比对,直到找到匹配或链结束。这比字符串匹配 err.Error() 更可靠——错误信息改了文案,判断逻辑不受影响。

errors.As 把错误链里的某个错误提取到指定类型变量里,用于判断自定义错误类型:

go
type ConfigError struct {
	Path string
}

func (e ConfigError) Error() string {
	return "config error: " + e.Path
}

func main() {
	var cfgErr ConfigError
	if errors.As(err, &cfgErr) {
		fmt.Println("config path:", cfgErr.Path)
	}
}

errors.As 的第二个参数必须是指针,因为它需要把提取到的错误写进变量。

四、错误处理模式

提前返回

错误发生后立刻返回,减少嵌套层级:

go
func process(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("read: %w", err)
	}

	result, err := parse(data)
	if err != nil {
		return fmt.Errorf("parse: %w", err)
	}

	if err := save(result); err != nil {
		return fmt.Errorf("save: %w", err)
	}

	return nil
}

这种写法比大段 if-else 嵌套更易读。每一层的 if err != nil 都在操作之后立即出现,错误处理不远离产生错误的代码。

错误聚合

批量操作时,不想因为单个错误就中止全部,可以把错误收集起来统一返回:

go
type BatchError struct {
	Errors []error
}

func (e BatchError) Error() string {
	var msgs []string
	for _, err := range e.Errors {
		msgs = append(msgs, err.Error())
	}
	return strings.Join(msgs, "; ")
}

五、常见错误

用 panic 处理预期错误

go
// 错误:网络超时是可预见的,不应 panic
if err != nil {
	panic(err)
}

网络波动、服务重启、DNS 延迟都是生产环境的常态。用 panic 处理这些,等于让程序在每次网络抖动时崩溃。

错误信息不带上下文

go
return errors.New("failed") // 太模糊
return fmt.Errorf("check disk %s failed: %w", path, err) // 带上下文

生产排查时,日志里"failed"和"check disk /data failed: no such file"的差距,决定了是 1 分钟定位还是 1 小时定位。

忽略 strconv 的错误

go
port, _ := strconv.Atoi(portStr) // 危险:忽略了解析错误

输入不是数字时,port 会变成 0,后续逻辑可能把 0 当成正常值处理。

错误链断裂

go
return fmt.Errorf("read failed: %v", err) // %v 丢失了原始错误类型

%v 包装错误时,调用方无法再用 errors.Is 判断原始错误类型。包装预期错误时始终用 %w