Go语言以显式的错误返回著称,但这也导致业务代码里充斥大量雷同的判断语句。把错误处理收敛到通用函数中,既能减少重复,也能统一上下文信息。

为什么需要封装通用错误处理函数
在典型的Go服务中,每一层函数几乎都会写出类似的判断:先调用底层接口,再判断err是否为空,若出错就包装一层返回。这种写法的问题在于,当错误需要附加字段、需要打点上报或者需要区分业务码时,散落各处的判断很难保持一致。
另一个容易被忽视的点是圈复杂度。一个函数里如果每三步就有一次err判断,阅读者很难抓住主干逻辑。通过封装,主干只表达“做什么”,而“出错怎么办”被移到统一的地方,代码意图会更清晰。
基于闭包的通用处理函数
最直观的做法是把“可能出错的操作”作为参数传进一个包装函数。下面示例用一个执行函数加一个错误包装函数,统一处理返回:
package errutil
import (
"fmt"
)
// BizFunc 代表一段可能返回错误的业务逻辑
type BizFunc func() error
// WrapExecute 统一执行并包装错误,附加上下文信息
func WrapExecute(step string, fn BizFunc) error {
if err := fn(); err != nil {
return fmt.Errorf("step %s failed: %w", step, err)
}
return nil
}
// 使用方式
func CreateUser() error {
return WrapExecute("create_user", func() error {
if err := saveToDB(); err != nil {
return err
}
return nil
})
}
func saveToDB() error {
return nil
}
这种闭包方式把步骤名与错误包装集中起来。如果以后要在所有错误里加入请求ID,只需修改WrapExecute一处,而不必翻遍仓库。
不过闭包写法会让调用处多一层缩进,对极短逻辑来说稍显啰嗦。在团队规范里,建议只对跨越三层以上调用、或需要统一打点的场景使用,简单函数仍可保留原生判断。
使用泛型进一步抽象返回值
Go 1.18之后引入泛型,我们可以让通用函数不仅处理错误,还能把成功结果带出来,避免闭包内用外部变量捕获结果带来的隐患。
package errutil
import (
"fmt"
)
// TryRun 执行带返回值的函数,出错时包装步骤名
func TryRun[T any](step string, fn func() (T, error)) (T, error) {
res, err := fn()
if err != nil {
var zero T
return zero, fmt.Errorf("step %s failed: %w", step, err)
}
return res, nil
}
// 调用示例
func GetUserName(id int) (string, error) {
return TryRun("get_user_name", func() (string, error) {
// 模拟查询
if id <= 0 {
return "", fmt.Errorf("invalid id")
}
return "alice", nil
})
}
泛型版本的优势是类型安全,调用方直接拿到对应类型的零值与错误,不需要再用指针或闭包外的变量去接结果。对于工具库来说,这比单纯返回error更友好。
需要注意,泛型函数如果包装层级太深,编译器报错信息会比较复杂。建议在公共库里提供清晰的函数命名与注释,降低使用者的理解成本。
统一错误码与上下文
很多系统要求错误带业务码。我们可以在通用函数里结合一个错误构造器,把code、message、cause组合成统一结构。
package errutil
import (
"fmt"
)
type AppError struct {
Code int
Message string
Cause error
}
func (e *AppError) Error() string {
return fmt.Sprintf("code=%d msg=%s cause=%v", e.Code, e.Message, e.Cause)
}
// WithCode 统一生成带码错误
func WithCode(code int, msg string, cause error) *AppError {
return &AppError{Code: code, Message: msg, Cause: cause}
}
func LoadConfig() error {
_, err := readFile()
if err != nil {
return WithCode(1001, "load config", err)
}
return nil
}
func readFile() (string, error) {
return "", fmt.Errorf("file not found")
}
把错误码逻辑收口到WithCode后,业务函数只关心“什么环节出错”,不必记忆数字含义。配合前文WrapExecute,还能把步骤名与错误码一起写入日志。
实际项目中推荐把AppError实现Unwrap方法,方便用errors.Is和errors.As做判定,这样上层依然可以使用标准库的错误链能力。
避坑与最佳实践
封装虽好,但不要盲目替换所有err判断。在不需要统一上下文、且逻辑极短的地方,直接判断反而更易读。另外,通用函数内部不要吞掉错误,必须原样或包装后返回,否则会破坏Go的错误处理契约。
建议在代码评审时检查:是否所有跨模块调用都走了统一包装;是否在测试里覆盖了包装后的错误链。把这些规则写进CI的静态检查,长期能让仓库的错误处理保持稳健。