Go 错误处理最佳实践——errors.Is/As、自定义错误类型、错误链路追踪
Go 错误处理最佳实践——errors.Is/As、自定义错误类型、错误链路追踪
适读人群:Go开发者、想让代码错误处理既优雅又可追踪的工程师 | 阅读时长:约16分钟 | 核心价值:Go的错误处理不是Java异常的劣化版,掌握正确姿势后它更强大
一、小美的错误处理「黑洞」
小美转Go半年后,有次我帮她review代码,翻出了这么一段:
func getUserProfile(userID int) (*Profile, error) {
user, err := db.QueryUser(userID)
if err != nil {
return nil, err // 直接透传
}
perms, err := auth.GetPermissions(userID)
if err != nil {
return nil, err // 又直接透传
}
return buildProfile(user, perms), nil
}我问她:「如果这个函数返回错误,你怎么知道错误发生在哪一层?是数据库查不到用户,还是权限服务超时,还是buildProfile逻辑出了问题?」
她愣了一下说:「看日志……」
「日志里有上下文吗?有调用链吗?」
「没有……」
这是Go错误处理里最常见的坑:错误被当成「烫手山芋」,一层层往上抛,到最上层时已经失去了所有上下文。线上出问题,面对一个裸露的 sql: no rows in result set,你完全不知道是哪个查询,哪个入参,在哪个业务逻辑下触发的。
二、Go的错误哲学:为什么不用异常
Java的方式: throw new SomeException("msg"),让调用栈自动展开,异常可以携带堆栈信息,catch可以在任意层处理。优点是堆栈信息自带,缺点是控制流不清晰,容易被吞(catch里什么都不做)。
Go的方式: error是普通返回值,每个调用点都必须显式处理。看起来啰嗦,但每个error的处理时机和方式都是明确的。
Go的错误处理的精髓在于:在错误产生时包装上下文,在错误消费时精确判断类型。
三、fmt.Errorf + %w:错误包装的基础用法
Go 1.13引入了 %w 动词,用于包装错误(wrapping):
package main
import (
"errors"
"fmt"
)
// 底层错误
var ErrNotFound = errors.New("记录不存在")
func queryDB(id int) error {
if id == 0 {
return ErrNotFound
}
return nil
}
func getUser(id int) error {
err := queryDB(id)
if err != nil {
// %w 包装:保留原始错误,增加上下文
return fmt.Errorf("getUser(id=%d): %w", id, err)
}
return nil
}
func handleRequest(userID int) error {
err := getUser(userID)
if err != nil {
return fmt.Errorf("handleRequest: %w", err)
}
return nil
}
func main() {
err := handleRequest(0)
if err != nil {
fmt.Println("错误信息:", err)
// 输出:handleRequest: getUser(id=0): 记录不存在
// errors.Is:检查错误链中是否包含目标错误
if errors.Is(err, ErrNotFound) {
fmt.Println("检测到NotFound错误,返回404")
}
}
}%w和%v的区别:
%v:只是字符串格式化,不建立错误链,errors.Is无法穿透%w:建立错误链(wrapped error),errors.Is和errors.As可以穿透整个链
四、errors.Is 和 errors.As:错误链的精准判断
errors.Is:判断错误链中是否包含目标错误
package main
import (
"errors"
"fmt"
"os"
)
func readFile(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("readFile(%s): %w", path, err)
}
return nil
}
func loadConfig(path string) error {
err := readFile(path)
if err != nil {
return fmt.Errorf("loadConfig: %w", err)
}
return nil
}
func main() {
err := loadConfig("/nonexistent/config.yaml")
if err != nil {
// errors.Is 穿透错误链,检查是否是 os.ErrNotExist
if errors.Is(err, os.ErrNotExist) {
fmt.Println("配置文件不存在,使用默认配置")
return
}
fmt.Println("未知错误:", err)
}
}errors.As:从错误链中提取特定类型的错误
package main
import (
"errors"
"fmt"
)
// 自定义错误类型
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 验证失败: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Message: "必须在0-150之间"}
}
return nil
}
func createUser(name string, age int) error {
if err := validateAge(age); err != nil {
return fmt.Errorf("createUser: %w", err)
}
return nil
}
func main() {
err := createUser("老张", -5)
if err != nil {
var valErr *ValidationError
// errors.As 从错误链中提取 *ValidationError 类型的错误
if errors.As(err, &valErr) {
fmt.Printf("验证错误: 字段=%s, 原因=%s\n", valErr.Field, valErr.Message)
// 可以针对验证错误做特殊处理,比如返回400 Bad Request
} else {
fmt.Println("系统错误:", err)
}
}
}五、自定义错误类型设计
模式1:哨兵错误(Sentinel Error)
适合「只需要识别类型,不需要额外信息」的场景:
package main
import (
"errors"
"fmt"
)
// 哨兵错误:包级别变量,用于精确匹配
var (
ErrNotFound = errors.New("not found")
ErrPermissionDeny = errors.New("permission denied")
ErrInvalidInput = errors.New("invalid input")
)
func findItem(id int) error {
if id <= 0 {
return ErrInvalidInput
}
if id > 100 {
return ErrNotFound
}
return nil
}
func main() {
err := findItem(200)
switch {
case errors.Is(err, ErrNotFound):
fmt.Println("返回404")
case errors.Is(err, ErrPermissionDeny):
fmt.Println("返回403")
case errors.Is(err, ErrInvalidInput):
fmt.Println("返回400")
case err != nil:
fmt.Println("返回500:", err)
}
}模式2:结构体错误类型(携带额外信息)
package main
import (
"errors"
"fmt"
"net/http"
)
// 业务错误:携带HTTP状态码和业务码
type BusinessError struct {
Code int // 业务错误码
HTTPStatus int // 对应的HTTP状态码
Message string // 面向用户的错误信息
Cause error // 底层原因(可选)
}
func (e *BusinessError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *BusinessError) Unwrap() error {
return e.Cause // 实现Unwrap,让errors.Is/As可以穿透
}
// 预定义业务错误
var (
ErrUserNotFound = &BusinessError{Code: 10001, HTTPStatus: http.StatusNotFound, Message: "用户不存在"}
ErrTokenExpired = &BusinessError{Code: 10002, HTTPStatus: http.StatusUnauthorized, Message: "Token已过期"}
)
func wrapBusinessError(base *BusinessError, cause error) error {
return &BusinessError{
Code: base.Code,
HTTPStatus: base.HTTPStatus,
Message: base.Message,
Cause: cause,
}
}
func getUserFromDB(userID int) error {
// 模拟DB错误
dbErr := fmt.Errorf("sql: no rows in result set")
return wrapBusinessError(ErrUserNotFound, dbErr)
}
func handleGetUser(userID int) {
err := getUserFromDB(userID)
if err != nil {
var bizErr *BusinessError
if errors.As(err, &bizErr) {
fmt.Printf("HTTP %d: code=%d, msg=%s\n",
bizErr.HTTPStatus, bizErr.Code, bizErr.Message)
if bizErr.Cause != nil {
fmt.Printf("底层原因(日志用): %v\n", bizErr.Cause)
}
}
}
}
func main() {
handleGetUser(999)
}六、错误链路追踪:给错误加上调用栈
标准库的错误没有调用栈信息,线上排查很痛苦。有两种解决方案:
方案1:每层手动加上下文(推荐,轻量)
package main
import "fmt"
func layer3() error {
return fmt.Errorf("数据库连接超时")
}
func layer2() error {
err := layer3()
if err != nil {
return fmt.Errorf("layer2.queryUser: %w", err)
}
return nil
}
func layer1() error {
err := layer2()
if err != nil {
return fmt.Errorf("layer1.handleRequest: %w", err)
}
return nil
}
func main() {
err := layer1()
// 输出:layer1.handleRequest: layer2.queryUser: 数据库连接超时
// 这已经是一个完整的调用链,不需要堆栈信息也能定位问题
fmt.Println(err)
}方案2:使用第三方库 pkg/errors(适合需要完整堆栈的场景)
// 安装:go get github.com/pkg/errors
package main
import (
"fmt"
pkgerrors "github.com/pkg/errors"
)
func dbQuery() error {
return pkgerrors.New("connection refused") // 自动捕获堆栈
}
func service() error {
err := dbQuery()
return pkgerrors.Wrap(err, "service.getData") // 包装+堆栈
}
func handler() error {
err := service()
return pkgerrors.Wrap(err, "handler.processRequest")
}
func main() {
err := handler()
if err != nil {
fmt.Println(err) // 普通错误信息
fmt.Printf("%+v\n", err) // 完整堆栈信息
}
}七、常见的错误处理反模式
反模式1:吞错误
// 错误:完全忽略错误
result, _ := someOperation()
// 正确:至少记录日志
result, err := someOperation()
if err != nil {
log.Printf("someOperation failed: %v", err)
// 根据情况决定是否继续
}反模式2:在每层都打日志,导致重复日志
// 错误:每层都打日志,同一个错误出现N次日志
func layer2() error {
err := layer3()
if err != nil {
log.Printf("layer2 error: %v", err) // 打了一次
return err
}
return nil
}
func layer1() error {
err := layer2()
if err != nil {
log.Printf("layer1 error: %v", err) // 又打了一次,重复!
return err
}
return nil
}
// 正确:错误只在最顶层(处理错误的地方)打日志,中间层只包装
func layer2Correct() error {
err := layer3()
if err != nil {
return fmt.Errorf("layer2.queryUser: %w", err) // 包装,不打日志
}
return nil
}反模式3:用字符串比较判断错误类型
// 错误:脆弱,一旦错误消息变化就坏了
if err.Error() == "not found" {
// 处理
}
// 正确:用errors.Is或errors.As
if errors.Is(err, ErrNotFound) {
// 处理
}八、Java vs Go 错误处理对比
| 场景 | Java | Go |
|---|---|---|
| 定义错误类型 | class XxxException extends Exception | struct XxxError + Error()方法 |
| 抛出错误 | throw new XxxException() | return &XxxError{} |
| 捕获特定类型 | catch (XxxException e) | errors.As(err, &target) |
| 判断是否是某个错误 | e instanceof XxxException | errors.Is(err, target) |
| 错误链 | getCause() | Unwrap() |
| 自带堆栈 | 是(性能代价高) | 否(需要手动或第三方库) |
| 编译器强制处理 | checked exception:是 | 否(靠自律) |
九、总结:我的错误处理原则
经历了不少线上故障排查后,我总结出几条原则:
- 产生错误的地方: 加上足够的上下文(函数名、关键入参),用
%w包装 - 传递错误的中间层: 包装上下文,不打日志,不丢弃
- 消费错误的顶层: 打完整日志,用
errors.Is/As判断类型,决定处理方式 - 面向用户的错误: 用业务错误类型,区分「用户可见消息」和「内部原因」
- 永远不要用
_丢弃可能有意义的错误
小美后来改了代码,每个函数调用都用 fmt.Errorf("%w", err) 包装了上下文,下次线上报错,一行日志就能定位到具体是哪个SQL查询哪个用户ID出了问题。错误处理不是麻烦,是安全网。
