错误处理
Go 语言的错误处理采用了独特的显式错误返回模式,而非其他语言中常见的 try-catch 机制。error 是一个内置接口类型,函数通过返回 error 来通知调用者发生了异常情况。这种设计使错误处理路径在代码中清晰可见,强制开发者直面每个可能出错的地方。
error 接口
error 是 Go 语言中唯一内置的错误类型,它是一个接口:
// builtin 包中定义
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都满足 error 接口。error 的零值是 nil,表示没有错误。
基本使用模式
Go 函数通常将 error 作为最后一个返回值:
package main
import (
"fmt"
"os"
)
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return data, nil
}
func main() {
data, err := readFile("config.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "读取文件失败: %v\n", err)
return
}
fmt.Println(string(data))
}
Go 错误处理的核心理念
Go 的哲学是**“Errors are values”**——错误就是值,像其他返回值一样处理。这意味着:
- 错误不是异常,不应被”忽略”或”抛出”
- 调用者必须显式检查并处理错误
- 错误处理代码和正常逻辑代码在一起,不会分离到远处
errors.New()
errors.New() 用于创建简单的错误值,它接受一个字符串参数,返回一个 error 接口值:
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("错误:", err)
// 输出: 错误: division by zero
return
}
fmt.Println("结果:", result)
}
errors.New() 创建的错误类型是内部结构 *errors.errorString,它实现了 error 接口:
// errors 包内部实现(简化)
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
func New(text string) error {
return &errorString{text}
}
哨兵错误(Sentinel Errors)
哨兵错误是预先定义好的错误值,通常使用包级别的 var 声明。调用者可以通过 == 或 errors.Is() 来判断函数返回的是哪种特定的错误。
package mypkg
import "errors"
// 定义哨兵错误
var (
ErrNotFound = errors.New("not found")
ErrInvalidInput = errors.New("invalid input")
ErrPermission = errors.New("permission denied")
)
func FindUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
// 模拟查找
if id == 999 {
return nil, ErrNotFound
}
return &User{ID: id, Name: "Alice"}, nil
}
// 调用方使用哨兵错误进行判断
user, err := mypkg.FindUser(999)
if err != nil {
if errors.Is(err, mypkg.ErrNotFound) {
fmt.Println("用户不存在")
} else if errors.Is(err, mypkg.ErrInvalidInput) {
fmt.Println("无效的输入参数")
}
return
}
哨兵错误的使用建议
- 哨兵错误应该是导出的(首字母大写),以
Err前缀命名 - 哨兵错误不适合携带额外信息,如果需要上下文,应使用自定义错误类型
- 使用
errors.Is()而非==来判断哨兵错误,以兼容错误包装后的场景 - 标准库中常见的哨兵错误:
io.EOF、sql.ErrNoRows等
fmt.Errorf 与错误包装(%w)
基本错误格式化
fmt.Errorf() 可以创建带有格式化信息的错误:
func openConfig(filename string) error {
if filename == "" {
return fmt.Errorf("配置文件名不能为空")
}
// ...
return nil
}
错误包装(Error Wrapping)
Go 1.13 引入了 %w 动词,支持错误包装,即在错误信息中嵌入另一个错误,形成错误链:
package main
import (
"fmt"
"os"
)
func readConfig(filename string) ([]byte, error) {
data, err := os.ReadFile(filename)
if err != nil {
// 使用 %w 包装底层错误
return nil, fmt.Errorf("读取配置文件 %q 失败: %w", filename, err)
}
return data, nil
}
func loadConfig(filename string) (*Config, error) {
data, err := readConfig(filename)
if err != nil {
// 继续包装,形成错误链
return nil, fmt.Errorf("加载配置失败: %w", err)
}
return parseConfig(data), nil
}
func main() {
_, err := loadConfig("missing.yaml")
if err != nil {
fmt.Println(err)
// 输出: 加载配置失败: 读取配置文件 "missing.yaml" 失败: open missing.yaml: no such file or directory
}
}
%w 与 %v 的区别
%w vs %v — 关键区别
%w:包装错误,保留原始错误的类型信息,可用errors.Is()/errors.As()检查%v:仅将错误转换为字符串嵌入,丢失原始错误类型,无法用errors.Is()/errors.As()检查- 两者显示效果相同,但行为完全不同
err := errors.New("原始错误")
// 使用 %w 包装
wrapped := fmt.Errorf("上下文信息: %w", err)
fmt.Println(errors.Is(wrapped, err)) // true ✅
// 使用 %v 包装(错误变成纯字符串)
notWrapped := fmt.Errorf("上下文信息: %v", err)
fmt.Println(errors.Is(notWrapped, err)) // false ❌
错误链(Error Chain)
通过多次 %w 包装,可以形成一条错误链。Go 1.13 新增的 errors.Unwrap() 函数可以逐层解包:
func main() {
err1 := errors.New("磁盘 I/O 错误")
err2 := fmt.Errorf("读取文件失败: %w", err1)
err3 := fmt.Errorf("初始化配置失败: %w", err2)
// 逐层解包
fmt.Println(err3) // 初始化配置失败: 读取文件失败: 磁盘 I/O 错误
fmt.Println(errors.Unwrap(err3)) // 读取文件失败: 磁盘 I/O 错误
fmt.Println(errors.Unwrap(errors.Unwrap(err3))) // 磁盘 I/O 错误
}
errors.Is() 与 errors.As()
Go 1.13 引入了 errors.Is() 和 errors.As(),专门用于在错误链中查找特定错误。
errors.Is() — 判断错误链中是否包含某个错误
// 函数签名
func Is(err, target error) bool
errors.Is() 会沿着错误链逐层检查,直到找到匹配的错误或到达链尾:
var ErrNotFound = errors.New("not found")
func getUser(id int) (*User, error) {
if id == 0 {
return nil, fmt.Errorf("查询用户失败: %w", ErrNotFound)
}
return &User{ID: id}, nil
}
func main() {
_, err := getUser(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("用户未找到")
}
// 即使 ErrNotFound 被多层包装,errors.Is() 也能找到它
}
errors.As() — 从错误链中提取特定类型的错误
// 函数签名
func As(err error, target any) bool
errors.As() 在错误链中查找第一个匹配目标指针类型的错误,并将其赋值给目标:
// 自定义错误类型
type NotFoundError struct {
Resource string
ID int
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s (id=%d) 未找到", e.Resource, e.ID)
}
func getUser(id int) (*User, error) {
if id == 0 {
return nil, &NotFoundError{Resource: "User", ID: id}
}
return &User{ID: id}, nil
}
func main() {
_, err := getUser(0)
// 使用 errors.As() 提取特定类型的错误
var notFound *NotFoundError
if errors.As(err, ¬Found) {
fmt.Printf("资源: %s, ID: %d\n", notFound.Resource, notFound.ID)
// 输出: 资源: User, ID: 0
}
}
errors.Is() vs errors.As() 的选择
- 使用
errors.Is()当你需要判断具体的错误值(如哨兵错误io.EOF) - 使用
errors.As()当你需要判断错误类型并获取其中的详细信息 - 两者的目标参数类型不同:
Is传入error,As传入指向目标类型的指针
自定义错误类型
当简单的错误字符串不够用时,可以定义自定义错误类型来携带更多上下文信息:
基本自定义错误
// 解析错误:携带字段名和期望类型
type ParseError struct {
Field string
Value string
Expect string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("字段 %q 的值 %q 无法解析为 %s", e.Field, e.Value, e.Expect)
}
func parseInt(value, field string) (int, error) {
n, err := strconv.Atoi(value)
if err != nil {
return 0, &ParseError{
Field: field,
Value: value,
Expect: "int",
}
}
return n, nil
}
func main() {
age, err := parseInt("abc", "age")
if err != nil {
// 类型断言获取详细信息
if pe, ok := err.(*ParseError); ok {
fmt.Printf("字段: %s, 值: %s, 期望: %s\n", pe.Field, pe.Value, pe.Expect)
}
}
}
带方法的自定义错误
自定义错误可以添加辅助方法,提供更丰富的错误处理能力:
// HTTP 错误
type HTTPError struct {
StatusCode int
Message string
Err error
}
func (e *HTTPError) Error() string {
if e.Err != nil {
return fmt.Sprintf("HTTP %d: %s: %v", e.StatusCode, e.Message, e.Err)
}
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}
// 实现 Unwrap 方法,使错误支持 errors.Is/As
func (e *HTTPError) Unwrap() error {
return e.Err
}
// 辅助方法
func (e *HTTPError) IsClientError() bool {
return e.StatusCode >= 400 && e.StatusCode < 500
}
func (e *HTTPError) IsServerError() bool {
return e.StatusCode >= 500
}
func fetchData(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, &HTTPError{StatusCode: 0, Message: "请求失败", Err: err}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, &HTTPError{
StatusCode: resp.StatusCode,
Message: "服务器返回错误状态码",
}
}
return io.ReadAll(resp.Body)
}
func main() {
_, err := fetchData("https://example.com/notfound")
if err != nil {
var httpErr *HTTPError
if errors.As(err, &httpErr) {
if httpErr.IsClientError() {
fmt.Printf("客户端错误: HTTP %d\n", httpErr.StatusCode)
}
}
}
}
Unwrap() 方法
如果你的自定义错误包装了另一个错误(Err error 字段),建议实现 Unwrap() error 方法。这样 errors.Is() 和 errors.As() 就能自动穿透你的自定义错误继续查找底层错误链。错误处理的最佳实践
1. 尽早返回(Fail Fast)
遇到错误时立即返回,避免深层嵌套:
// ❌ 不好的做法:深层嵌套
func process() error {
result, err := step1()
if err == nil {
result2, err := step2(result)
if err == nil {
result3, err := step3(result2)
if err == nil {
return doSomething(result3)
}
return err
}
return err
}
return err
}
// ✅ 好的做法:尽早返回
func process() error {
result, err := step1()
if err != nil {
return fmt.Errorf("step1 失败: %w", err)
}
result2, err := step2(result)
if err != nil {
return fmt.Errorf("step2 失败: %w", err)
}
result3, err := step3(result2)
if err != nil {
return fmt.Errorf("step3 失败: %w", err)
}
return doSomething(result3)
}
2. 错误包装添加上下文
每次处理错误时,用 fmt.Errorf 添加上下文信息,形成清晰的错误链:
// ✅ 在每一层添加有意义的上下文
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
}
config, err := parseConfig(data)
if err != nil {
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
}
return config, nil
}
3. 只处理一次错误
错误只处理一次
错误应该只被处理一次。如果记录日志,就不要再返回错误;如果返回错误,让调用者决定如何记录。// ❌ 不好的做法:既记录日志又返回错误(导致错误被多次处理)
func processFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
log.Printf("打开文件失败: %v", err) // 在这里记录了
return fmt.Errorf("打开文件失败: %w", err) // 调用者还会再记录
}
defer f.Close()
// ...
return nil
}
// ✅ 好的做法:只返回错误,让调用者决定如何处理
func processFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return fmt.Errorf("打开文件 %s: %w", filename, err)
}
defer f.Close()
// ...
return nil
}
// ✅ 好的做法:在顶层统一记录日志
func main() {
if err := processFile("data.txt"); err != nil {
log.Fatal(err) // 错误链中的所有上下文信息都会被打印
}
}
4. 不要忽略错误
// ❌ 绝对不要忽略错误
f, _ := os.Open("config.txt") // 如果打开失败,后续代码会 panic
// ✅ 始终检查错误
f, err := os.Open("config.txt")
if err != nil {
return fmt.Errorf("打开配置文件: %w", err)
}
defer f.Close()
5. 合理设计错误类型
// ✅ 包级别暴露错误值和类型
package mydb
// 哨兵错误,用于简单的判断
var ErrNotFound = errors.New("record not found")
// 自定义错误类型,用于携带详细信息
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
try 提案为什么被拒绝了?
Go 社区曾多次尝试简化错误处理的语法,其中最著名的是 Go 2 草案中的 try 提案。该提案希望引入一个内置的 try 函数,自动处理错误返回:
提案内容
// 提案设想的 try 语法(未通过)
func process() (Result, error) {
// try 会自动检查 error,如果非 nil 则立即返回
content := try(os.ReadFile("input.txt"))
parsed := try(parseContent(content))
result := try(transform(parsed))
return result, nil
}
被拒的原因
- 降低了可见性:
try隐藏了错误处理,违背了 Go “显式处理错误” 的设计哲学 - 错误上下文丢失:使用
try时很难方便地用fmt.Errorf包装错误上下文 - 不够通用:
try只适用于返回error的情况,但实际中还有bool+error等模式 - 可读性未必提升:
try让代码看起来像没有错误处理的”快乐路径”,但实际错误可能发生在任何一行
Go 1.13 的替代方案
虽然try 提案被拒,但 Go 1.13 带来了 errors.Is()、errors.As() 和 %w 错误包装,大幅增强了错误处理能力,被认为是更好的方向——保持显式,提供更好的工具。当前社区的趋势
Go 2 的错误处理改进转向了不改变语法、而是增强工具链的方向:
errors包增强:Is()、As()、Unwrap()fmt.Errorf的%w:错误包装errors.Join()(Go 1.20):合并多个错误- 多错误处理:
errors.Join(err1, err2, err3)
// Go 1.20+ 支持合并多个错误
err := errors.Join(
errors.New("文件不存在"),
errors.New("权限不足"),
errors.New("磁盘已满"),
)
// err.Error() = "文件不存在\n权限不足\n磁盘已满"
// errors.Is 对合并的错误同样有效
fmt.Println(errors.Is(err, os.ErrNotExist)) // 根据具体错误值判断
练习题
练习 1:错误链与 errors.Is
以下代码的输出是什么?请解释错误链的工作方式。
package main
import (
"errors"
"fmt"
)
var ErrDBConn = errors.New("数据库连接失败")
func queryDB() error {
return fmt.Errorf("执行查询失败: %w", ErrDBConn)
}
func handleRequest() error {
return fmt.Errorf("处理请求失败: %w", queryDB())
}
func main() {
err := handleRequest()
fmt.Println(err)
fmt.Println(errors.Is(err, ErrDBConn))
fmt.Println(errors.Is(err, errors.New("数据库连接失败")))
}
输出:
处理请求失败: 执行查询失败: 数据库连接失败
true
false分析:
handleRequest()返回的错误链为三层:"处理请求失败"→"执行查询失败"→ErrDBConnerrors.Is(err, ErrDBConn)返回true,因为errors.Is()会沿错误链逐层解包,找到ErrDBConn这个哨兵错误errors.Is(err, errors.New("数据库连接失败"))返回false,因为errors.Is()使用值比较(指针比较),每次调用errors.New()都会创建一个新的错误值,即使字符串内容相同也不是同一个错误
关键知识点:errors.Is() 比较的是错误值(指针),不是错误消息字符串。因此判断哨兵错误时,必须使用包中定义的那个变量。
练习 2:自定义错误类型与 errors.As
编写一个 ValidationError 类型,包含 Field 和 Message 字段。实现 Error() 和 Unwrap() 方法,并在 validateUser 函数中使用它来验证用户输入。
代码:
package main
import (
"errors"
"fmt"
"regexp"
)
// ValidationError 自定义验证错误类型
type ValidationError struct {
Field string
Message string
Err error // 包装底层错误
}
func (e *ValidationError) Error() string {
if e.Err != nil {
return fmt.Sprintf("验证失败 [%s]: %s: %v", e.Field, e.Message, e.Err)
}
return fmt.Sprintf("验证失败 [%s]: %s", e.Field, e.Message)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}
// validateUser 验证用户输入
func validateUser(name, email, ageStr string) error {
if name == "" {
return &ValidationError{
Field: "name",
Message: "用户名不能为空",
}
}
if len(name) > 20 {
return &ValidationError{
Field: "name",
Message: fmt.Sprintf("用户名长度不能超过 20 个字符(当前 %d)", len(name)),
}
}
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(email) {
return &ValidationError{
Field: "email",
Message: fmt.Sprintf("邮箱格式无效: %q", email),
}
}
// 验证年龄(包装底层错误)
age, err := strconv.Atoi(ageStr)
if err != nil {
return &ValidationError{
Field: "age",
Message: "年龄必须是数字",
Err: err, // 包装底层转换错误
}
}
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Message: fmt.Sprintf("年龄必须在 0-150 之间(当前 %d)", age),
}
}
return nil
}
func main() {
tests := []struct {
name, email, age string
}{
{"", "test@example.com", "25"},
{"Alice", "invalid-email", "25"},
{"Bob", "bob@example.com", "abc"},
{"Charlie", "charlie@example.com", "200"},
{"Dave", "dave@example.com", "30"},
}
for _, tt := range tests {
err := validateUser(tt.name, tt.email, tt.age)
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("字段: %s, 信息: %s\n", ve.Field, ve.Message)
// 检查是否有底层错误
if unwrapped := errors.Unwrap(err); unwrapped != nil {
fmt.Printf(" 底层错误: %v\n", unwrapped)
}
}
} else {
fmt.Printf("✅ 验证通过: name=%s, email=%s, age=%s\n", tt.name, tt.email, tt.age)
}
}
}输出:
字段: name, 信息: 用户名不能为空
字段: email, 信息: 邮箱格式无效: "invalid-email"
字段: age, 信息: 年龄必须是数字
底层错误: strconv.Atoi: parsing "abc": invalid syntax
字段: age, 信息: 年龄必须在 0-150 之间(当前 200)
✅ 验证通过: name=Dave, email=dave@example.com, age=30练习 3:错误包装的最佳实践
指出以下代码中的错误处理问题,并给出改进版本。
func ProcessData(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
log.Printf("打开文件失败: %v\n", err)
return nil, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
result := bytes.TrimSpace(data)
if len(result) == 0 {
return nil, fmt.Errorf("数据为空")
}
return result, nil
}
问题分析:
- 第 6-8 行:既记录了日志(
log.Printf),又返回了错误。这导致错误会被处理两次——当前函数记录一次,调用者可能还会再记录一次 - 第 6-8 行:直接返回
err而没有包装,丢失了上下文信息(哪个文件打开失败) - 第 14-15 行:直接返回底层
err,没有添加上下文信息(在读取哪个文件时失败) - 第 18-19 行:
fmt.Errorf("数据为空")没有携带额外信息,不如使用哨兵错误
改进版本:
var ErrEmptyData = errors.New("数据为空")
func ProcessData(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
// ✅ 添加上下文,不重复记录日志
return nil, fmt.Errorf("打开文件 %q: %w", filename, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
// ✅ 添加上下文
return nil, fmt.Errorf("读取文件 %q: %w", filename, err)
}
result := bytes.TrimSpace(data)
if len(result) == 0 {
// ✅ 使用哨兵错误,方便调用者使用 errors.Is() 判断
return nil, fmt.Errorf("文件 %q: %w", filename, ErrEmptyData)
}
return result, nil
}改进要点:
- 去掉了
log.Printf,遵循”错误只处理一次”原则 - 所有错误都用
fmt.Errorf添加了文件名上下文 - 空数据错误使用哨兵错误 + 包装,调用者可以用
errors.Is(err, ErrEmptyData)判断
