系列:《Go 1.27 入门实战-博客》
阶段:接口、错误与泛型
论文目标:理解Go的错误处理思想,并完成一个可以运行的用户注册程序。
一、为什么注册失败时,程序不应该直接“崩掉”?
假设我们正在写一个用户注册功能。用户提交表单后,可能会出现几种情况:
- 用户名未填写;
- 年龄不合法;
- 用户名已经被注册;
- 保存用户时,数据库暂时不可用。
这些情况都意味着:当前操作没有成功,但程序仍然有机会向用户解释原因,并继续处理其他请求。
很多语言会使用 try/catch 把可能失败的代码包起来。Go 选择了另一种常见方式:把错误作为普通的返回值交给调用者,由调用者决定如何处理。
先看一个简单的例子:
package main
import (
"fmt"
"strconv"
)
func main() {
age, err := strconv.Atoi("abc")
if err != nil {
fmt.Println("年龄转换失败:", err)
return
}
fmt.Println("年龄:", age)
}
strconv.Atoi("abc") 无法把 "abc" 转成整数,所以它返回一个错误。程序检查 err,发现它不为 nil,则输出提示并结束当前函数。
这里最值得记住的不是某个 API,而是控制流程:
- 调用一个可能失败的函数;
- 接收其返回的结果和错误;
- 检查错误;
- 决定继续、重试、返回错误,还是向用户显示提示。
Go 不要求你把所有业务失败都写成异常。它鼓励你在代码中明确表达:这一步可能失败,失败后应该怎么办。
二、error 到底是什么?
在 Go 中,error 是一个内置的接口类型。它的核心定义可以理解为:
type error interface {
Error() string
}
只要一个类型提供了 Error() string 方法,就满足这个接口。
调用错误的 Error() 方法,可以获取描述错误的文本。但在业务代码里,我们通常直接打印或打包这个错误,而不是手动调用 Error()。
例如:
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.New("用户名不能为空")
fmt.Println(err)
}
输出:
用户名不能为空
errors.New:创建一个简单错误
当你只需要表达一个明确的失败原因时,可以使用 errors.New:
err := errors.New("用户名不能为空")
它适合固定的错误信息,例如“用户名不能为空”“用户不存在”。
注意:错误信息通常用于排查问题或在程序内部传递。真正返回给终端用户的文案,应由业务层根据场景决定,不一定要原样传递底层错误。
fmt.Errorf:生成带上下文的错误
如果错误信息需要包含变量,可以使用 fmt.Errorf:
username := "zhangsan"
err := fmt.Errorf("注册用户 %q 失败", username)
输出类似:
注册用户 "zhangsan" 失败
实际项目中我们还经常需要保留原始错误,方便上层判断。由此引出错误的包装。
三、错误为什么要“包装”?
想象一下,注册服务调用了数据库层。数据库层发现用户名重复,返回“用户名已存在”。注册服务如果只返回一个“注册失败”,就丢失了更具体的原因。
比较好的做法是:在错误向上返回时,补充当前层的上下文,同时保留原始错误。
return fmt.Errorf("注册用户 %q 失败: %w", username, err)
这里 %w 很重要:它会把 err 包装进新的错误中,让调用者仍然可以检查原始错误。
而普通的 %v 只负责把错误格式化成文本,不会建立供 errors.Is / errors.As 沿着检查的错误链。
可以这样理解:
底层错误:用户名已存在
↓ 包装
注册服务:注册用户 "zhangsan" 失败: 用户名已存在
↓ 返回
调用方:知道错误发生在注册流程,也仍能识别底层错误
错误包装的价值不仅让日志更好阅读,还能让错误在跨越层级函数时保留可判断的身份。
四、errors.Is:判断错误链中是否包含某个错误
我们可以为特定的业务故障定义一个可重复识别的错误值:
var ErrUserExists = errors.New("用户已存在")
当注册函数返回这个错误时,调用方就可以使用 errors.Is 判断:
if errors.Is(err, ErrUserExists) {
fmt.Println("请更换用户名")
}
为什么不直接比较错误文本?
if err.Error() == "用户已存在" {
// 不推荐
}
因为错误文案可能会调整,包装后还可能附带额外的上下文。业务逻辑不应该依赖一段显示文本。errors.Is 会沿着错误包装链检查,适合判断“是不是某一类已知的错误”。
五、errors.As:提取特定类型的错误
errors.Is 主要回答“是不是这个错误”;errors.As 则适合回答“错误链里有没有某种类型的错误,并将其提取出来”。
例如,我们可以定义一个带字段的年龄错误:
type AgeError struct {
Age int
}
func (e *AgeError) Error() string {
return fmt.Sprintf("年龄不合法:%d", e.Age)
}
调用方可以这样提取它:
var ageErr *AgeError
if errors.As(err, &ageErr) {
fmt.Println("发现年龄错误,输入值:", ageErr.Age)
}
注意 errors.As 的第二个参数是指向目标变量的指针,所以这里传递 &ageErr。如果错误经过 %w 包装,errors.As 仍然能够沿着错误链找到目标类型。
初学阶段可以先记住:
errors.Is:判断错误链中是否存在指定错误值。
errors.As:判断错误链中是否存在指定类型的错误,并提取它。
六、为什么要经常写 if err != nil?
你可能已经发现,Go 代码里经常出现这样的结构:
result, err := doSomething()
if err != nil {
return err
}
它看起来重复,但有一个明显的好处:错误处理的位置很清楚。读代码的人不必跳到另一个异常处理区,才能知道失败时会发生什么。
当然,这并不意味着每个错误都应该直接返回。要根据当前函数的职责决定:
- 当前层能恢复或处理:在当前层处理;
- 当前层无法决定:补充上下文后返回;
- 错误属于预期的业务结果:转换成对应的业务响应;
- 错误意味着程序无法安全继续:才考虑更强的终止策略。
例如,用户名重复通常是可预期的业务错误,不需要使整个服务崩溃。我们应该把它作为错误返回,由上层转换为“用户名已存在”的提示。
错误检查不是形式主义,而是明确的失败路径。 要避免的是机械地吞掉错误:
_ = saveUser(user) // 危险:忽略失败,调用方可能误以为保存成功
如果确实可以忽略,也应该有理由,而不是为了让代码少写几行。
七、实战:实现一个用户注册程序
现在把这些知识组合起来,完成一个小型注册程序。
需求如下:
- 用户名不能为空;
- 年龄必须在 18 到 120 岁之间;
- 用户名不能重复;
- 通过
errors.Is 识别“用户已存在”;
- 通过
errors.As 提取年龄错误;
- 使用
%w 包装错误,保留原始错误信息。
为了让示例轻松运行,我们首先使用内存中的 map 模拟用户存储。它不是数据库,也不会在程序退出后保留数据;真正的项目需要替换成数据库或其他持久化存储。
完整代码
把下面代码保存为 main.go:
package main
import (
"errors"
"fmt"
"strings"
)
var (
ErrEmptyUsername = errors.New("用户名不能为空")
ErrUserExists = errors.New("用户已存在")
)
type User struct {
Username string
Age int
}
type AgeError struct {
Age int
}
func (e *AgeError) Error() string {
return fmt.Sprintf("年龄不合法:%d,年龄必须在 18 到 120 之间", e.Age)
}
type UserService struct {
users map[string]User
}
func NewUserService() *UserService {
return &UserService{
users: make(map[string]User),
}
}
func (s *UserService) Register(username string, age int) error {
username = strings.TrimSpace(username)
if username == "" {
return ErrEmptyUsername
}
if age < 18 || age > 120 {
return &AgeError{Age: age}
}
if _, exists := s.users[username]; exists {
return fmt.Errorf("注册用户 %q 失败: %w", username, ErrUserExists)
}
s.users[username] = User{
Username: username,
Age: age,
}
return nil
}
func main() {
service := NewUserService()
testCases := []struct {
username string
age int
}{
{"", 20}, // 用户名为空
{"zhangsan", 15}, // 年龄不合法
{"zhangsan", 20}, // 第一次注册成功
{"zhangsan", 20}, // 用户已存在
}
for _, tc := range testCases {
err := service.Register(tc.username, tc.age)
if err == nil {
fmt.Printf("注册成功:%s,年龄:%d\n", tc.username, tc.age)
continue
}
switch {
case errors.Is(err, ErrEmptyUsername):
fmt.Println("注册失败:请填写用户名")
case errors.Is(err, ErrUserExists):
fmt.Println("注册失败:该用户名已被使用")
default:
var ageErr *AgeError
if errors.As(err, &ageErr) {
fmt.Println("注册失败:", ageErr)
} else {
fmt.Println("注册失败:", err)
}
}
}
}
运行程序
在 main.go 所在目录执行:
go run .
如果当前目录还没有 Go 模块,可以先执行:
go mod init example.com/user-register
go run .
预期输出:
注册失败:请填写用户名
注册失败: 年龄不合法:15,年龄必须在 18 到 120 之间
注册成功:zhangsan,年龄:20
注册失败:该用户名已被使用
一起拆解执行流程
第一步:检查用户名。
if username == "" {
return ErrEmptyUsername
}
这里返回一个可识别的业务错误。函数不负责打印,也不决定如何向用户显示,而是把处理权交给调用方。
第二步:检查年龄。
if age < 18 || age > 120 {
return &AgeError{Age: age}
}
年龄错误不仅有文本,还记录了具体的输入值。调用方可以通过 errors.As 取出 AgeError,进而读取 Age 字段。
第三步:检查重复用户名。
if _, exists := s.users[username]; exists {
return fmt.Errorf("注册用户 %q 失败: %w", username, ErrUserExists)
}
这里给错误补充了“哪个用户注册失败”的上下文,同时用 %w 保留 ErrUserExists,所以调用方可以通过 errors.Is 判断。
第四步:调用方决定如何响应。
if errors.Is(err, ErrUserExists) {
fmt.Println("注册失败:该用户名已被使用")
}
注册服务只描述失败原因;调用方决定如何处理。以后如果把 main 换成 HTTP 接口处理器,完全可以将相同的业务错误转换为 HTTP 状态码和 JSON 响应,而不用把业务逻辑写进错误文本判断里。
八、初学者几个容易犯的错误
1. 只打印错误,不返回
func Register() error {
err := saveSomething()
if err != nil {
fmt.Println(err)
}
return nil // 错误被吞掉了
}
调用方看到 nil,会以为注册成功。除非当前层已经处理完了失败,否则应该把错误返回出去。
2. 包装错误时误用 %v
return fmt.Errorf("注册失败: %v", err)
这种写法保留了错误文本,但没有把原错误作为可遍历的包装链保留下来。如果上层需要用 errors.Is 或 errors.As 检查原始错误,应使用 %w:
return fmt.Errorf("注册失败: %w", err)
3. 用错误字符串做业务判断
if err.Error() == "用户已存在" {
// 不推荐
}
应该使用 errors.Is 或 errors.As,不要让业务判断依赖错误文案。
4. 认为 error 非 nil 就是严重故障
错误可以代表预期的业务情况,例如用户名重复、输入格式不合法。关键是根据错误的含义选择处理方式,而不是看到一个错误就让整个程序退出。
5. 误认为 if err != nil 可以处理所有失败
检查错误只是一种机制。函数必须正确返回错误,调用方也必须认真检查;如果底层错误被忽视、被错误地转换成 nil,上层检查就无法挽回。
九、为什么选择显式错误返回?
把 Go 的错误处理和异常机制放在一起看,差别不只是语法。
| 角度 |
Go 的显式错误返回 |
常见的 try/catch 异常机制 |
| 失败如何表达 |
函数通常将错误作为返回值 |
可能通过抛出异常转移控制流 |
| 处理位置 |
常在调用点附近检查 |
可能在调用栈上层捕获 |
| 代码阅读 |
失败路径通常比较显眼 |
需要理解哪些调用可能引发异常及其捕获范围 |
| 代价与取舍 |
可能出现重复检查,代码更冗长 |
语法可能更简洁,但控制流有时不够直观 |
这并不是说其中一种机制在所有情况下都更好。Go 的选择强调的是可见性和局部决策:当函数可能失败时,调用者通常可以直接看到并处理它。
它也有代价:如果设计不当,错误检查会重复、上下文会丢失,甚至有人会为了省事忽略错误。所以工程上的关键不是“多写几个 if”,而是建立清晰一致的错误策略。
在真实的服务里,常见的做法是:
- 底层返回有意义的错误;
- 中间层使用
%w 补充上下文;
- 业务层用
errors.Is / errors.As 分类处理;
- API 层把内部错误转换成稳定的状态码和用户可理解的提示;
- 日志记录必要的诊断信息,但避免泄露密码、令牌等敏感数据。
十、课后练习
- 给
UserService 增加一个 GetUser(username string) (User, error) 方法。查不到用户时,返回一个新的 ErrUserNotFound。
- 给注册功能增加邮箱字段,并检查邮箱是否为空。
- 在注册函数里包装年龄错误,再确认调用方是否仍能通过
errors.As 获取 AgeError。
- 思考:如果注册函数发现用户名重复,为什么不直接
fmt.Println("用户已存在"),而是返回错误?
完成练习后,你应该能够解释:Go 的 error 不是异常的另一种拼写,而是一种让失败路径显式出现在函数调用中的设计。
本文小结
error 是一个包含 Error() string 方法的接口。
errors.New 用于创建简单的错误值。
fmt.Errorf 用于格式化错误;使用 %w 可以包装并保留原始错误链。
if err != nil 是 Go 中显式检查错误的常见方式。
errors.Is 用于判断错误链中是否存在指定错误;errors.As 用于提取指定类型的错误。
- 业务层应返回可识别的错误,调用方再决定如何响应,不要依赖错误字符串判断业务逻辑。
延伸阅读
- Go 官方文档:错误
- Go 官方文档:fmt.Errorf
- Go 官方博客:Go 1.13 中的错误处理
- Go 官方文档:Effective Go