Go 错误处理实战:别怕 if err != nil,它是在保护你

本文讲解 Go 错误处理的常见模式,包括 error 返回、错误包装、哨兵错误、自定义错误、日志边界和 HTTP 场景。

错误处理是 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.Iserrors.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.Iserrors.As,不要比较错误字符串;日志尽量在边界统一记录,底层负责返回有信息量的错误。

写 Go 时,不要害怕错误处理占几行代码。真正危险的不是 if err != nil 太多,而是失败被隐藏、被忽略、被错误地转换。一个后端系统能否稳定运行,很大程度取决于错误路径是否清楚。

当你开始认真处理错误,Go 代码会显得更踏实。它不让你把异常藏起来,而是要求你在每个关键点做决定。这种显式,正是 Go 工程风格的重要部分。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页