错误处理是 Go 风格的核心部分
很多初学者看到 Go 代码里反复出现 if err != nil,第一反应是啰嗦。和异常机制相比,它确实更显眼。可是显眼正是 Go 的设计选择:失败路径应该被看见,调用者应该明确决定如何处理失败,而不是让错误从深层调用栈里悄悄冒出来。
真实后端系统里,失败是常态。文件可能不存在,数据库可能超时,用户输入可能非法,第三方接口可能返回 500,网络可能抖动。把这些情况当作正常控制流的一部分,代码会更可靠。
Go 的错误处理不是只会写 return err。你还需要知道什么时候包装错误,什么时候定义哨兵错误,什么时候使用自定义错误类型,日志应该在哪一层打,HTTP 接口应该如何把内部错误转换成用户能理解的响应。
error 是一个接口
Go 的错误类型是内置接口:
type error interface {
Error() string
}
任何类型只要实现 Error() string,就是一个错误。最常见的创建方式是:
err := errors.New("user not found")
或者格式化:
err := fmt.Errorf("user %d not found", userID)
函数通常把错误作为最后一个返回值:
func findUser(id int64) (User, error) {
if id <= 0 {
return User{}, fmt.Errorf("invalid user id: %d", id)
}
return User{ID: id, Name: "小林"}, nil
}
调用方处理:
user, err := findUser(1)
if err != nil {
fmt.Println("find user failed:", err)
return
}
fmt.Println(user.Name)
成功时错误为 nil,失败时结果值通常返回零值。这个模式非常稳定。
不要忽略错误
有时你会看到这样的代码:
user, _ := findUser(1)
fmt.Println(user.Name)
_ 会忽略错误。除非你非常确定错误不重要,否则不要这样写。被忽略的错误最终会变成更难查的问题。
更好的写法:
user, err := findUser(1)
if err != nil {
return fmt.Errorf("find user: %w", err)
}
如果确实要忽略,也应该让意图明显。例如关闭响应体时,有些场景可以记录日志但不影响主流程:
if err := resp.Body.Close(); err != nil {
log.Printf("close response body: %v", err)
}
错误处理的基本原则是:要么处理,要么返回,要么明确记录。不要假装它不存在。
错误包装:保留上下文
底层错误通常信息不够。比如文件读取失败只告诉你 no such file or directory,但调用者需要知道是在读哪个配置。
func loadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
return data, nil
}
%w 表示包装错误。它不仅把上下文拼进错误消息,还保留原始错误,方便上层使用 errors.Is 或 errors.As 判断。
调用:
data, err := loadConfig("config.json")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data))
输出可能是:
read config config.json: open config.json: no such file or directory
这比单独返回 err 更容易排查。包装错误时,上下文要具体,不要写空泛的 failed。好的错误消息应该回答“正在做什么时失败”。
errors.Is 判断错误类型
有些错误需要被上层识别。比如用户不存在和数据库连接失败,处理方式不同。可以定义哨兵错误:
var ErrUserNotFound = errors.New("user not found")
使用:
func findUser(id int64) (User, error) {
if id == 404 {
return User{}, ErrUserNotFound
}
return User{ID: id, Name: "小林"}, nil
}
判断:
user, err := findUser(404)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
fmt.Println("show empty page")
return
}
return err
}
如果中间层包装了错误:
return User{}, fmt.Errorf("query user %d: %w", id, ErrUserNotFound)
errors.Is 仍然能判断出来。这就是 %w 的价值。
不要用字符串比较错误:
if err.Error() == "user not found" {
}
错误消息是给人看的,不适合作为程序判断依据。
自定义错误类型
当错误需要携带结构化信息时,可以定义类型:
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
使用:
func validateEmail(email string) error {
if email == "" {
return ValidationError{Field: "email", Message: "is required"}
}
if !strings.Contains(email, "@") {
return ValidationError{Field: "email", Message: "is invalid"}
}
return nil
}
上层提取:
err := validateEmail("bad")
var validationErr ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field, validationErr.Message)
}
errors.As 用来从错误链里找某种类型。自定义错误适合需要字段名、错误码、重试标记、HTTP 状态码等结构化信息的场景。
但不要所有错误都自定义类型。普通上下文错误用 fmt.Errorf 足够。自定义错误应当服务于程序判断,而不是为了显得复杂。
日志应该打在边界
一个常见坏习惯是每层都打日志:
func repoFindUser(id int64) (User, error) {
err := query()
if err != nil {
log.Println("query failed:", err)
return User{}, err
}
return user, nil
}
func serviceGetUser(id int64) (User, error) {
user, err := repoFindUser(id)
if err != nil {
log.Println("repo failed:", err)
return User{}, err
}
return user, nil
}
这样一个错误可能被打印多次,日志噪声很大。更推荐的方式是:底层包装上下文,上层边界统一记录。
func repoFindUser(id int64) (User, error) {
if err := query(); err != nil {
return User{}, fmt.Errorf("query user %d: %w", id, err)
}
return user, nil
}
HTTP handler、CLI main、后台任务入口这些地方是边界,适合记录最终错误:
user, err := service.GetUser(r.Context(), id)
if err != nil {
log.Printf("get user: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
这样日志既有完整上下文,又不会重复。
HTTP 场景下的错误转换
内部错误不能原样暴露给用户。比如数据库错误不应该直接返回给前端。可以写一个简单转换函数:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
default:
log.Printf("unexpected error: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
Handler 使用:
func getUserHandler(w http.ResponseWriter, r *http.Request) {
user, err := findUser(404)
if err != nil {
writeError(w, err)
return
}
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Printf("encode user: %v", err)
}
}
真实项目里可能有统一响应格式:
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
然后根据错误类型映射成业务错误码。重点是边界清楚:内部错误用于排查,外部响应用于用户理解。两者不应该混在一起。
小结
Go 的错误处理看起来重复,但它让失败路径清晰可见。普通函数返回 (value, error),调用者用 if err != nil 处理;底层错误要加上下文,用 %w 包装;需要程序判断时,用 errors.Is、errors.As,不要比较错误字符串;日志尽量在边界统一记录,底层负责返回有信息量的错误。
写 Go 时,不要害怕错误处理占几行代码。真正危险的不是 if err != nil 太多,而是失败被隐藏、被忽略、被错误地转换。一个后端系统能否稳定运行,很大程度取决于错误路径是否清楚。
当你开始认真处理错误,Go 代码会显得更踏实。它不让你把异常藏起来,而是要求你在每个关键点做决定。这种显式,正是 Go 工程风格的重要部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。