编程语言错误处理的清流:Go 错误处理

前言

大家好,这里是程序员阿亮,这一篇来给大家讲解一下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),我们面临的核心需求是:

  1. 开发者视角:需要记录底层详尽的错误堆栈、SQL 语句等,用于排查 Bug。

  2. 用户视角:只能看到"用户名不存在"、"系统繁忙"等脱敏信息,并且 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 错误处理的四大黄金铁律

  1. 不要屏蔽底层错误:使用 fmt.Errorf("...: %w", err) 包装错误,保留原始上下文。

  2. Handle Once(只处理一次) :不要在 DAO 层打一遍日志,Service 层打一遍,Controller 层再打一遍。只在最外层(如 Controller 或全局错误中间件)集中打印日志。

  3. 区分业务错误与系统错误:前端只需要展示文案(如:余额不足),后端日志需要看堆栈(如:Redis 连接超时)。通过自定义 Struct 将两者解耦。

  4. Panic 只用于灾难:永远使用返回值 error 来进行流程控制,让中间件的 recover() 去对付那些真正的意外。

实际上第二条第三条是我们开发中要遵循的规范,我们在java中也要遵循差不多的规范!

相关推荐
四维碎片2 小时前
【Qt】 无边框窗口方案
开发语言·qt
C++ 老炮儿的技术栈2 小时前
现代 C++(C++11 及以后)的移动语义
linux·c语言·开发语言·c++·github
sycmancia2 小时前
QT——Qt Creator工程介绍
开发语言·qt
deviant-ART2 小时前
为什么 Java 编译器要求 catch 块显式 return 或 throw
java·开发语言
木易 士心2 小时前
自然语言转数据库操作语句原理架构图分析和实现
数据库·后端
无心水2 小时前
Python时间处理通关指南:datetime/arrow/pandas实战
开发语言·人工智能·python·pandas·datetime·arrow·金融科技
2301_810160952 小时前
C++与Docker集成开发
开发语言·c++·算法
jgbazsh2 小时前
Spring中把一个bean对象交给Spring容器管理的三种方式
java·后端·spring
wjs20242 小时前
PHP MySQL 使用 Order By 排序
开发语言