找回密码
立即注册
搜索
发回帖 发新帖

6402

积分

0

好友

808

主题
发表于 9 小时前 | 查看: 1| 回复: 0

系列:《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,而是控制流程:

  1. 调用一个可能失败的函数;
  2. 接收其返回的结果和错误;
  3. 检查错误;
  4. 决定继续、重试、返回错误,还是向用户显示提示。

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) // 危险:忽略失败,调用方可能误以为保存成功

如果确实可以忽略,也应该有理由,而不是为了让代码少写几行。

七、实战:实现一个用户注册程序

现在把这些知识组合起来,完成一个小型注册程序。

需求如下:

  1. 用户名不能为空;
  2. 年龄必须在 18 到 120 岁之间;
  3. 用户名不能重复;
  4. 通过 errors.Is 识别“用户已存在”;
  5. 通过 errors.As 提取年龄错误;
  6. 使用 %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 层把内部错误转换成稳定的状态码和用户可理解的提示;
  • 日志记录必要的诊断信息,但避免泄露密码、令牌等敏感数据。

十、课后练习

  1. 给 UserService 增加一个 GetUser(username string) (User, error) 方法。查不到用户时,返回一个新的 ErrUserNotFound。
  2. 给注册功能增加邮箱字段,并检查邮箱是否为空。
  3. 在注册函数里包装年龄错误,再确认调用方是否仍能通过 errors.As 获取 AgeError。
  4. 思考:如果注册函数发现用户名重复,为什么不直接 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



上一篇:Rust 从零实现配置加载:INI 解析与环境变量、命令行多层合并
下一篇:HTTP2Shell 漏洞根源在 Brotli 解码器?波及 .NET、ClickHouse 等多组件
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-28 14:16 , Processed in 0.101570 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表