返回首页

Golang入门:1.8 错误处理

在很多编程语言里,提到错误处理,大家第一反应往往是 try/catch/finally。但 Go 走的是另一条路:它不鼓励把“异常”当作日常控制流,而是强调显式返回错误、逐层判断、按需包装、清晰传递

这种设计一开始可能会让初学者觉得“啰嗦”,但写过一段时间后你会发现,Go 的错误处理虽然朴素,却非常适合工程化开发:逻辑清晰、调用关系透明、定位问题直接,也更容易形成统一风格。

本章将围绕下面四个核心知识点展开:

  • error 接口与自定义错误
  • 错误包装:fmt.Errorf + %w
  • errors.Iserrors.As
  • 错误处理最佳实践

同时,我们会重点对比 Go 的错误处理哲学与 try/catch 异常机制的差异,并结合 Go 1.13+ 的错误链机制,帮助你建立更符合 Go 风格的错误处理思维。

为什么 Go 不主张用 try/catch 处理普通错误

很多语言会把“打开文件失败”“网络请求超时”“参数不合法”这类情况交给异常机制处理。例如:某段代码抛出异常,然后上层用 catch 捕获。这样写看起来集中,但也容易带来几个问题:

  • 错误是隐式传播的,调用处不一定能直接看出哪些步骤可能失败
  • 如果异常边界设计不清晰,程序控制流会变得跳跃
  • 一旦开发者过度依赖异常,普通业务分支和真正异常状态就容易混在一起

Go 的思路不同。Go 认为:

  • 普通错误是程序运行中的常见情况,不应该被当成“特殊控制流”隐藏起来
  • 函数应明确返回错误,让调用方决定如何处理
  • 真正不可恢复的情况,才考虑 panic

你可以把它理解为下面这张表:

对比维度 Go 错误处理 try/catch 异常机制
常规错误处理方式 函数返回 error 抛出异常后由 catch 捕获
错误传播方式 显式返回、显式判断 隐式向上抛出
代码可读性 调用点能直接看到错误处理 需要结合异常边界理解流程
设计哲学 错误是数据,应该被清楚处理 异常是特殊控制流
Go 中对应异常的机制 panic/recover,但不用于日常错误处理 throw/try/catch

所以,学习 Go 错误处理,最重要的不是记住几个 API,而是先接受它的核心理念:错误不是“突然发生的意外”,而是函数结果的一部分。

一、error 接口与自定义错误

1. error 到底是什么

Go 内置了一个非常小但非常重要的接口:

type error interface {
    Error() string
}

只要一个类型实现了 Error() string 方法,它就可以被当成错误来使用。

这说明两件事:

  1. Go 的错误本质上是一个值
  2. 你不仅可以使用标准库里的错误,也可以定义自己的错误类型

最常见的写法是函数返回两个值:一个结果值,加一个 error

例如:

  • 成功时返回有效结果,error == nil
  • 失败时返回零值或部分结果,并返回具体错误

这种模式在 Go 标准库里非常普遍,比如文件操作、字符串转换、JSON 解析、网络请求等。

2. 标准错误处理模式

先看一个最基础的例子:把字符串转换成整数。

package main

import (
	"fmt"
	"strconv"
)

func main() {
	text := "123a"

	number, err := strconv.Atoi(text)
	if err != nil {
		fmt.Println("转换失败:", err)
		return
	}

	fmt.Println("转换成功:", number)
}

这段代码体现了 Go 非常经典的错误处理模式:

  1. 调用可能失败的函数
  2. 立即判断 err != nil
  3. 根据当前上下文决定怎么处理:返回、记录日志、继续包装、降级等

这种“就地处理”的方式看起来比 try/catch 更分散,但优势是很直接:每一步可能失败的地方,都在代码中被明确写出来了。

3. 自定义错误的意义

有些时候,标准库返回的错误信息还不够表达业务语义。例如:

  • 用户年龄不能为负数
  • 订单状态不允许重复支付
  • 配置文件缺少某个关键字段

这时候就可以定义自己的错误类型,让错误不仅能“打印出来看”,还能携带结构化信息,供调用方进一步判断。

下面是一个完整可运行的自定义错误示例。

package main

import "fmt"

type ValidationError struct {
	Field string
	Value any
	Msg   string
}

func (e ValidationError) Error() string {
	return fmt.Sprintf("字段 %s 的值 %v 非法:%s", e.Field, e.Value, e.Msg)
}

func registerUser(name string, age int) error {
	if name == "" {
		return ValidationError{
			Field: "name",
			Value: name,
			Msg:   "用户名不能为空",
		}
	}

	if age < 0 {
		return ValidationError{
			Field: "age",
			Value: age,
			Msg:   "年龄不能为负数",
		}
	}

	fmt.Printf("用户注册成功:name=%s, age=%d\n", name, age)
	return nil
}

func main() {
	err := registerUser("", -3)
	if err != nil {
		fmt.Println("注册失败:", err)
		return
	}

	fmt.Println("注册完成")
}

这个例子里,ValidationError 不只是一个字符串,它还包含:

  • 哪个字段出错:Field
  • 错误值是什么:Value
  • 业务提示信息:Msg

这类自定义错误特别适合业务层、校验层和 SDK 封装层。

4. 什么时候用 errors.New,什么时候自定义类型

如果你只是想表达一个简单错误,可以直接用 errors.New

如果你需要让错误带上额外信息,或者希望上层通过类型判断提取字段,就应该定义自己的错误类型。

场景 推荐方式 说明
只需要一个固定错误文本 errors.New("...") 简单直接
需要动态拼接错误信息 fmt.Errorf("...") 适合格式化输出
需要携带字段、状态码、上下文 自定义错误类型 便于结构化处理
需要保留底层错误链 fmt.Errorf("...: %w", err) 便于后续 errors.Is/As 判断

二、错误包装:fmt.Errorf + %w

1. 为什么要包装错误

在真实项目里,错误通常不会只在一层函数里处理完。

比如:

  • 底层函数负责读取文件
  • 上层函数负责解析配置
  • 更上层函数负责启动服务

如果底层报错,上层往往希望补充更多上下文信息,比如:

  • 当前处理的是哪个文件
  • 是哪个模块在失败
  • 这一步失败会影响什么功能

如果只是简单返回原始错误,上层看到的信息可能过于简略;如果只返回新的字符串错误,又会把原始错误丢掉。

这时就需要错误包装

Go 1.13 之后,标准库正式支持错误链机制。最常见的写法就是:

fmt.Errorf("读取配置失败: %w", err)

这里的 %w 表示:

  • 生成一个新的错误
  • 同时把原始错误包在里面
  • 形成一条可追踪的错误链

2. %v%w 的区别

这是初学者非常容易混淆的地方。

  • %v:只是把错误文本拼进去,不会保留错误链
  • %w:在格式化输出的同时,保留底层错误作为可追踪的包装对象

也就是说:

  • 想让人看懂错误信息,用 %v 也能打印
  • 想让程序后续还能判断错误来源,就必须用 %w

3. 完整示例:逐层包装错误

下面这个例子演示:

  • 底层函数模拟读取文件失败
  • 中间层补充“加载配置失败”信息
  • 最上层继续补充“启动应用失败”信息
  • 最终输出完整错误链信息
package main

import (
	"errors"
	"fmt"
)

var ErrFileNotFound = errors.New("文件不存在")

func readConfig(fileName string) error {
	return fmt.Errorf("读取文件 %s 失败: %w", fileName, ErrFileNotFound)
}

func loadAppConfig() error {
	if err := readConfig("config.yaml"); err != nil {
		return fmt.Errorf("加载应用配置失败: %w", err)
	}
	return nil
}

func startApp() error {
	if err := loadAppConfig(); err != nil {
		return fmt.Errorf("启动应用失败: %w", err)
	}
	return nil
}

func main() {
	err := startApp()
	if err != nil {
		fmt.Println("错误信息:")
		fmt.Println(err)
	}
}

运行后你会看到一条层层展开的错误信息,大致像这样:

启动应用失败: 加载应用配置失败: 读取文件 config.yaml 失败: 文件不存在

这正是错误包装的价值:既保留底层原因,又补充当前语境。

4. Go 1.13+ 错误链的意义

Go 1.13 之前,开发者也会手动拼接错误信息,但标准化程度不高。Go 1.13 之后,错误链机制让错误处理进入了更统一的阶段。

它解决的不是“错误打印得更漂亮”,而是更重要的两件事:

  1. 错误信息对人类更友好:能看到调用链上的上下文
  2. 错误判断对程序更可靠:后续可以通过 errors.Iserrors.As 穿透包装层继续识别底层错误

所以从工程实践上说,Go 1.13+ 的错误包装不是“可选技巧”,而是非常值得养成的日常习惯。

三、errors.Iserrors.As

有了错误包装之后,一个新问题就出现了:如果错误已经被包装了好多层,我该怎么判断它到底是不是某个特定错误?

Go 提供了两个标准工具:

  • errors.Is:判断错误链里是否包含某个特定错误
  • errors.As:判断错误链里是否包含某种特定错误类型,并提取出来

你可以把它们理解为:

  • Is 更适合判断“是不是这个错误”
  • As 更适合判断“能不能转成这种错误类型”

1. errors.Is:判断具体错误

当我们定义了一个哨兵错误(sentinel error)时,例如:

var ErrPermissionDenied = errors.New("没有权限")

如果错误在传播过程中被多次包装,直接用 err == ErrPermissionDenied 往往不可靠,因为当前的 err 可能已经不是最底层那个对象了。

这时应使用 errors.Is

下面是完整示例:

package main

import (
	"errors"
	"fmt"
)

var ErrPermissionDenied = errors.New("没有权限访问资源")

func readSecretFile() error {
	return fmt.Errorf("读取 secret.txt 失败: %w", ErrPermissionDenied)
}

func loadUserData() error {
	if err := readSecretFile(); err != nil {
		return fmt.Errorf("加载用户数据失败: %w", err)
	}
	return nil
}

func main() {
	err := loadUserData()
	if err != nil {
		fmt.Println("原始错误:", err)

		if errors.Is(err, ErrPermissionDenied) {
			fmt.Println("识别结果:当前错误链中包含权限不足错误")
		} else {
			fmt.Println("识别结果:不是权限不足错误")
		}
	}
}

这里即使 ErrPermissionDenied 被包了两层,errors.Is 仍然能识别出来。

这也是为什么前面强调:如果你希望后续还能识别底层错误,包装时就要用 %w,而不是 %v

2. errors.As:提取具体错误类型

如果错误是一个自定义类型,我们有时不仅想知道“是不是某类错误”,还想拿到它内部的字段信息,例如:

  • 业务错误码
  • 文件路径
  • 网络状态码
  • 字段名

这时就可以用 errors.As

下面是完整示例:

package main

import (
	"errors"
	"fmt"
)

type QueryError struct {
	SQL  string
	Code int
	Msg  string
}

func (e *QueryError) Error() string {
	return fmt.Sprintf("查询失败,code=%d, sql=%s, msg=%s", e.Code, e.SQL, e.Msg)
}

func queryUser() error {
	return &QueryError{
		SQL:  "SELECT * FROM users WHERE id = 10",
		Code: 1064,
		Msg:  "SQL 语法错误",
	}
}

func getProfile() error {
	if err := queryUser(); err != nil {
		return fmt.Errorf("获取用户资料失败: %w", err)
	}
	return nil
}

func main() {
	err := getProfile()
	if err != nil {
		fmt.Println("原始错误:", err)

		var queryErr *QueryError
		if errors.As(err, &queryErr) {
			fmt.Println("识别结果:命中了 QueryError 类型")
			fmt.Printf("错误码:%d\n", queryErr.Code)
			fmt.Printf("失败 SQL:%s\n", queryErr.SQL)
		}
	}
}

要注意两点:

  1. errors.As 的第二个参数必须传入目标类型变量的地址
  2. 如果目标错误类型本身是指针类型,那么这里通常是“指针变量的地址”

例如上面的:

var queryErr *QueryError
errors.As(err, &queryErr)

这不是语法技巧,而是因为 errors.As 需要把匹配到的具体错误对象写回到你的变量中。

3. errors.Iserrors.As 如何选择

需求 推荐方法 示例
判断是否是某个固定错误 errors.Is 是否是 ErrNotFound
判断是否属于某种错误类型 errors.As 是否是 *ValidationError
需要取出错误对象里的字段 errors.As 读取 CodeFieldPath
只看错误文本是否包含某些字样 不推荐作为主要方式 文本匹配脆弱,易变

很多初学者喜欢直接写字符串判断,例如:

if strings.Contains(err.Error(), "not found") {
    ...
}

这种方式能临时工作,但不够稳健。因为错误文本是给人看的,未来可能改文案、改语言、改上下文,一改就可能失效。

相比之下,errors.Iserrors.As 是给程序做结构化判断的,更符合工程实践。

四、错误处理最佳实践

前面讲的是语法和机制,这一节讲更接近真实开发的写法。Go 的错误处理好不好,不只是看你“会不会写 if err != nil”,而是看你能不能把错误处理写得清晰、稳定、可维护

1. 原则一:错误尽早处理,不要拖延

Go 社区非常推崇一种写法:在错误发生的地方,尽快判断并处理。

这样做的好处是:

  • 主流程更清楚
  • 避免错误状态继续扩散
  • 减少嵌套层级

例如不要把多个可能失败的步骤堆在一起,最后统一猜测是哪一步出了问题。更推荐每一步都及时判断。

2. 原则二:包装错误时补充上下文,但不要重复造句

错误信息不是越长越好,而是越有定位价值越好。

例如:

  • 好:保存订单失败: 写入数据库失败: 连接超时
  • 不好:发生了一个错误: 保存失败: 错误原因是数据库失败

好的错误信息应该回答两个问题:

  1. 哪一步失败了
  2. 底层原因是什么

3. 原则三:不要滥用 panic

很多新手在接触 Go 时,会把 panic 理解成异常机制的替代品,这是一个很常见的误区。

panic 更适合下面这些场景:

  • 程序已经处于不可恢复状态
  • 违反了绝不应该发生的内部假设
  • 框架边界层需要快速中断执行

而像下面这些情况,通常都不该用 panic

  • 用户输入错误
  • 文件不存在
  • 请求参数不合法
  • 数据库连接失败但允许重试或降级

也就是说:

  • 普通错误,返回 error
  • 严重故障,再考虑 panic

4. 原则四:在边界层统一转换错误,对内保留细节,对外给出友好信息

在真实项目中,错误面对的对象通常不止一个:

  • 对开发者:需要详细上下文,方便排查
  • 对用户:需要简洁可理解的信息,避免暴露内部细节

所以常见做法是:

  • 在底层和中间层保留详细错误链
  • 在接口层、HTTP 层、RPC 层统一转换为对外可读的提示

例如:

  • 内部错误链:创建订单失败: 扣减库存失败: redis 连接超时
  • 对用户提示:系统繁忙,请稍后重试

5. 原则五:对可预期错误做结构化判断,不依赖字符串匹配

这是 Go 1.13+ 错误链机制最重要的实际价值之一。

如果某类错误是你可以预期并希望特殊处理的,例如:

  • 资源不存在
  • 权限不足
  • 参数校验失败
  • 超时

那么就应该优先通过:

  • 哨兵错误 + errors.Is
  • 自定义类型 + errors.As

来判断,而不是依赖错误文本。

6. 实战示例:更符合 Go 风格的错误处理流程

下面这个例子把前面几个最佳实践串起来:

  • 参数校验失败时返回自定义错误
  • 底层仓储层返回哨兵错误
  • 服务层包装上下文
  • 最上层使用 errors.Aserrors.Is 分类处理
package main

import (
	"errors"
	"fmt"
)

var ErrUserNotFound = errors.New("用户不存在")

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("参数校验失败,字段=%s,原因=%s", e.Field, e.Msg)
}

func validateUserID(userID int) error {
	if userID <= 0 {
		return &ValidationError{
			Field: "userID",
			Msg:   "必须大于 0",
		}
	}
	return nil
}

func findUserFromRepo(userID int) error {
	if userID == 404 {
		return ErrUserNotFound
	}
	return nil
}

func getUserProfile(userID int) error {
	if err := validateUserID(userID); err != nil {
		return fmt.Errorf("获取用户资料前校验失败: %w", err)
	}

	if err := findUserFromRepo(userID); err != nil {
		return fmt.Errorf("查询用户资料失败,userID=%d: %w", userID, err)
	}

	fmt.Printf("成功获取用户资料,userID=%d\n", userID)
	return nil
}

func main() {
	testUserIDs := []int{-1, 404, 100}

	for _, userID := range testUserIDs {
		fmt.Printf("\n开始处理 userID=%d\n", userID)
		err := getUserProfile(userID)
		if err != nil {
			var validationErr *ValidationError
			switch {
			case errors.As(err, &validationErr):
				fmt.Println("结果:请求参数有误")
				fmt.Println("详细信息:", validationErr)
			case errors.Is(err, ErrUserNotFound):
				fmt.Println("结果:未找到对应用户")
				fmt.Println("详细信息:", err)
			default:
				fmt.Println("结果:系统内部错误")
				fmt.Println("详细信息:", err)
			}
			continue
		}

		fmt.Println("结果:处理成功")
	}
}

这个例子很接近真实项目的处理模式。

它体现了几个关键点:

  • 参数问题和数据不存在是两类不同错误
  • 错误可以逐层包装,但仍然可被识别
  • 上层可以根据错误类型和错误值做不同处理
  • 错误信息既保留排查价值,也保留业务语义

五、常见误区总结

初学 Go 错误处理时,下面这些问题特别常见。

常见误区 问题说明 更推荐的做法
只打印错误,不返回错误 调用方失去处理机会 该返回就返回,让上层决定
%v 包装错误后还想用 errors.Is 判断 %v 不保留错误链 %w 包装
通过字符串判断错误类型 文本容易变化,不稳定 使用 errors.Is / errors.As
把普通业务错误写成 panic 让程序中断,破坏正常控制流 普通错误返回 error
自定义错误只写文本,不保留结构字段 不利于分类处理 适当定义结构化错误类型
层层包装但没有补充有效上下文 错误链很长但没有价值 说明“哪一步失败了”

六、小结

Go 的错误处理看起来没有 try/catch 那么“集中”,但它的优势恰恰在于显式和透明。

学完这一章,你应该建立起下面这些核心认识:

  1. error 是一个接口,本质上错误就是值
  2. 普通错误通过返回值处理,而不是依赖异常机制
  3. 自定义错误适合表达更具体的业务语义
  4. fmt.Errorf("...: %w", err) 可以构建错误链
  5. errors.Is 用于判断某个具体错误,errors.As 用于提取某种错误类型
  6. Go 1.13+ 的错误链机制,让“补充上下文”和“保留原始错误”可以同时做到
  7. 好的错误处理,不只是避免崩溃,更是为了让程序更容易维护和排查

对于初学 Go 的读者来说,如果你之前习惯了 try/catch 思维,那么这一章最值得转变的一点就是:在 Go 里,错误不是隐藏起来等着统一捕获的“例外”,而是应该被明确返回、清楚处理的程序状态。

当你逐渐习惯这种风格后,会发现它非常适合写出稳定、直白、可维护的工程代码。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang入门:1.7 接口与多态

下一篇

Golang入门:1.9 包管理与模块化