前言
大家好,这里是程序员阿亮,这一篇来给大家讲解一下Go与Java、Python的一个很明显的区别: 错误处理机制
与其他语言(如 Java, Python)使用 try-catch 抛出并捕获异常不同,Go 语言秉持着一个极其重要的哲学:"Errors are values"(错误只是普通的返回值)。
Go 把错误当成正常逻辑的一部分,强迫开发者在调用的当下就思考:"如果失败了,我该怎么办?"
一、Go错误处理的基本盘
1、什么是error?
在 Go 的源码中,error 根本不是什么特殊关键字,它只是一个内置的、极其简单的接口(Interface):
Go
type error interface {
Error() string
}
核心法则:任何实现了 Error() string 方法的类型,都是一个合法的 error。
2、创建基础错误
Go 提供了两种最基本的方式来实例化一个错误:
Go
import (
"errors"
"fmt"
)
// 方式一:简单的静态字符串错误
var ErrNotFound = errors.New("record not found")
// 方式二:带有格式化的动态错误
func checkAge(age int) error {
if age < 18 {
return fmt.Errorf("age %d is too young", age)
}
return nil // 成功时返回 nil
}
二、进阶利器(Go 1.13+ 的错误链)
在复杂的调用链路中(如 Controller -> Service -> DAO),直接返回原始错误会丢失上下文(比如:你只知道"连接超时",却不知道是查询哪个用户的哪张表超时)。
Go 1.13 引入了错误包装(Wrapping),形成了错误链
1、包装错误:%w
使用 fmt.Errorf 配合 %w 动词,可以将底层错误包裹起来,加上当前层的上下文:
Go
func QueryDB() error {
return errors.New("database connection timeout") // 底层错误
}
func GetUser(id int) error {
err := QueryDB()
if err != nil {
// 使用 %w 包装,保留了原始错误
return fmt.Errorf("failed to get user %d: %w", id, err)
}
return nil
}
2. 预定义错误比对:errors.Is
因为错误被包装了,使用原生的 err == ErrNotFound 会失效。必须使用 errors.Is,它会剥洋葱一样一层层扒开错误链,寻找是否包含目标错误。
Go
var ErrTimeout = errors.New("timeout")
func doSomething() error {
return fmt.Errorf("network error: %w", ErrTimeout)
}
func main() {
err := doSomething()
// 错误做法:结果为 false
if err == ErrTimeout { /*...*/ }
// 正确做法:结果为 true
if errors.Is(err, ErrTimeout) {
fmt.Println("检测到超时错误,准备重试...")
}
}
3. 自定义错误提取:errors.As
如果你定义了一个携带更多信息的错误结构体,想要在调用层提取这些信息,需要使用 errors.As。
Go
// 自定义错误类型
type SQLError struct {
SQLQuery string
Msg string
}
func (e *SQLError) Error() string { return e.Msg }
// 模拟函数
func fetch() error {
// 假设发生错误
err := &SQLError{SQLQuery: "SELECT *", Msg: "syntax error"}
return fmt.Errorf("fetch failed: %w", err)
}
func main() {
err := fetch()
var sqlErr *SQLError
// errors.As 需要传入目标变量的指针的指针
if errors.As(err, &sqlErr) {
fmt.Printf("提取到了!出错的 SQL 是: %s\n", sqlErr.SQLQuery)
}
}
三、Go 的"异常"机制(defer, panic, recover)
虽然 Go 不推荐用异常处理业务逻辑,但程序总会遇到"致命错误"(如数组越界、空指针)。这就需要 panic 机制。
1. defer:资源的优雅释放
defer 用于延迟函数的执行,直到包含它的外层函数返回(无论是因为正常 return 还是发生了 panic)。
特点:先进后出(LIFO)的栈结构。
Go
func readFile() {
file, err := os.Open("test.txt")
if err != nil { return }
// 无论后面发生什么,函数退出前一定会关闭文件
defer file.Close()
// 业务逻辑...
}
2. panic:程序的"心脏骤停"
主动调用 panic() 会立即停止当前函数的正常控制流,开始执行当前协程内的 defer,最后导致程序崩溃退出。
原则:绝对不要用 panic 处理常规业务错误(如密码错误)。只在程序无法继续运行(如配置缺失、数据库彻底连不上)时使用。
3. recover:起死回生
recover 只能在 defer 的函数内部调用。它可以捕获 panic,阻止程序崩溃,并将控制权交还给正常流程。
Go
func safeDiv(a, b int) {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获到了 Panic:", r)
}
}()
fmt.Println(a / b) // 如果 b 为 0,会触发 panic
fmt.Println("这行代码(如果 panic 则不会执行)")
}
四、接口实战
在实际的 RESTful API 开发中(如基于 Gin),我们面临的核心需求是:
-
开发者视角:需要记录底层详尽的错误堆栈、SQL 语句等,用于排查 Bug。
-
用户视角:只能看到"用户名不存在"、"系统繁忙"等脱敏信息,并且 HTTP 状态码要正确。
我们将设计一套基于 自定义错误结构体 + 分层处理 + 全局中间件 的架构。
1. 定义全局的业务错误 (AppError)
新建一个 errors 包,定义我们的结构体:
Go
package apperr
import "fmt"
// AppError 企业级自定义错误
type AppError struct {
HTTPCode int // HTTP 状态码 (如 400, 404, 500)
BizCode int // 内部业务码 (如 10001, 20002)
Message string // 暴露给前端/用户的友好提示
Err error // 内部真实的底层错误(用于日志记录)
}
// 实现 error 接口
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("BizCode: %d, Msg: %s, InternalErr: %v", e.BizCode, e.Message, e.Err)
}
return fmt.Sprintf("BizCode: %d, Msg: %s", e.BizCode, e.Message)
}
// 辅助方法:快速剥离 AppError
func AsAppError(err error) (*AppError, bool) {
var appErr *AppError
ok := errors.As(err, &appErr)
return appErr, ok
}
// 快捷创建常用错误
func NewNotFound(msg string, err error) *AppError {
return &AppError{HTTPCode: 404, BizCode: 40004, Message: msg, Err: err}
}
func NewInternal(msg string, err error) *AppError {
return &AppError{HTTPCode: 500, BizCode: 50000, Message: msg, Err: err}
}
2. 业务代码分层处理
我们采用经典的三层架构:Repository(数据库层) -> Service(业务层) -> Controller(接口层)。
Repository 层 (DAO)
只负责查询,遇到错误直接返回,不负责包装业务信息。
Go
func (repo *UserRepo) FindUserByName(name string) (*User, error) {
var user User
// 假设使用 GORM
err := repo.db.Where("name = ?", name).First(&user).Error
if err != nil {
return nil, err // 可能是 gorm.ErrRecordNotFound 或底层 SQL 断连
}
return &user, nil
}
Service 层 (逻辑层)
判断错误类型,将其翻译成业务错误 AppError。
Go
func (s *UserService) Login(name, pwd string) (*User, error) {
user, err := s.repo.FindUserByName(name)
if err != nil {
// 如果是记录没找到
if errors.Is(err, gorm.ErrRecordNotFound) {
// 转换为对用户友好的 404 错误
return nil, apperr.NewNotFound("该用户不存在", err)
}
// 其他未知数据库错误,转换为 500
return nil, apperr.NewInternal("系统繁忙,请稍后再试", err)
}
if user.Password != pwd {
return nil, &apperr.AppError{
HTTPCode: 400, BizCode: 40001, Message: "密码错误", Err: nil,
}
}
return user, nil
}
Controller 层 (处理结果输出)
不做任何错误包装,直接将结果丢给全局处理逻辑(这里演示直接处理的方式)。
Go
func LoginHandler(c *gin.Context) {
name := c.PostForm("name")
pwd := c.PostForm("password")
user, err := userService.Login(name, pwd)
if err != nil {
// 判断是否是我们的业务错误
if appErr, ok := apperr.AsAppError(err); ok {
// 1. 打印内部真实日志供排查(如果有底层错误)
if appErr.Err != nil {
log.Printf("[Error] %v\n", appErr.Err)
}
// 2. 返回脱敏的 JSON 给前端
c.JSON(appErr.HTTPCode, gin.H{
"code": appErr.BizCode,
"msg": appErr.Message,
})
return
}
// 兜底:不是 AppError,说明是有地方漏了包装,当做 500 处理
log.Printf("[Unknown Error] %v\n", err)
c.JSON(500, gin.H{"code": 50000, "msg": "系统未知异常"})
return
}
c.JSON(200, gin.H{"code": 0, "msg": "success", "data": user})
}
3. 全局 Panic 恢复中间件 (Recovery Middleware)
即使错误处理做得再好,也难免会发生空指针引发的 panic。如果不拦截,整个 Web 服务就会挂掉。
我们使用 defer + recover 编写一个中间件兜底:
Go
func GlobalRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// 获取堆栈信息
log.Printf("[Panic Recovered] 发生了致命错误: %v\n", err)
// 中断当前请求并返回友好的 500
c.AbortWithStatusJSON(500, gin.H{
"code": 50000,
"msg": "服务器开小差了 (Panic)",
})
}
}()
c.Next() // 执行后续的路由处理
}
}
// 在 main.go 中注册
func main() {
r := gin.Default()
r.Use(GlobalRecovery()) // 挂载全局恢复中间件
r.POST("/login", LoginHandler)
r.Run(":8080")
}
总结:Go 错误处理的四大黄金铁律
-
不要屏蔽底层错误:使用 fmt.Errorf("...: %w", err) 包装错误,保留原始上下文。
-
Handle Once(只处理一次) :不要在 DAO 层打一遍日志,Service 层打一遍,Controller 层再打一遍。只在最外层(如 Controller 或全局错误中间件)集中打印日志。
-
区分业务错误与系统错误:前端只需要展示文案(如:余额不足),后端日志需要看堆栈(如:Redis 连接超时)。通过自定义 Struct 将两者解耦。
-
Panic 只用于灾难:永远使用返回值 error 来进行流程控制,让中间件的 recover() 去对付那些真正的意外。
实际上第二条第三条是我们开发中要遵循的规范,我们在java中也要遵循差不多的规范!
