Go 错误处理机制详解:新手从 err != nil 到 errors.Is/As 刚开始学 Go 的时候很多人都会被这段代码刷屏if err ! nil { return err }写多了以后心里难免冒出一个问题为什么 Go 到处都要手动判断 err 为什么不像其他语言那样 try/catch这篇文章就从新手视角把 Go 的错误处理机制讲清楚。你会看到error到底是什么为什么 Go 推荐显式处理错误nil在错误处理中是什么意思如何创建错误如何给错误添加上下文如何判断一个错误是不是某种错误如何取出自定义错误里的字段panic和普通错误有什么区别实战中怎样写出清晰的错误处理代码先给一句核心结论Go 把错误当作普通值处理。错误不是隐藏的异常控制流而是函数返回值的一部分。你看得见它也必须决定怎么处理它。一、Go 的 error 是什么在 Go 里error是一个内置接口。它可以理解成这样type error interface { Error() string }只要一个类型实现了Error() string它就可以作为error使用。最简单的例子package main import ( errors fmt ) func divide(a, b int) (int, error) { if b 0 { return 0, errors.New(division by zero) } return a / b, nil } func main() { result, err : divide(10, 0) if err ! nil { fmt.Println(error:, err) return } fmt.Println(result:, result) }输出error: division by zero这里的函数返回两个值func divide(a, b int) (int, error)第一个值是正常结果。第二个值是错误。如果没有错误返回return result, nil如果发生错误返回return zeroValue, err这里的zeroValue是对应类型的零值例如int的零值是0string的零值是bool的零值是false指针、slice、map、channel、interface 的零值是nil二、为什么是 if err ! nilGo 的错误处理通常长这样value, err : doSomething() if err ! nil { return err } // 只有没有错误时才继续使用 value。这不是模板代码的装饰而是在表达一个很明确的流程调用函数 检查错误 如果有错误处理或返回 如果没有错误继续执行Go 希望错误路径是显式的。显式的好处是你能清楚看到每一步可能失败你能在失败处补充上下文你能决定是重试、忽略、返回、记录日志还是终止程序代码不会突然跳到远处的 catch 块这也是 Go 风格里很重要的一点错误处理是正常业务流程的一部分。三、nil 表示没有错误在 Go 里nil通常表示没有错误。例如func save(name string) error { if name { return errors.New(name is empty) } // 保存成功没有错误。 return nil }调用方这样判断err : save(alice) if err ! nil { fmt.Println(save failed:, err) return } fmt.Println(save success)这就是 Go 里最常见的错误处理模式。新手可以先记住err nil 表示成功 err ! nil 表示失败四、不要忽略错误新手有时会这样写result, _ : divide(10, 0) fmt.Println(result)这里的_表示丢弃错误。这在语法上可以但在业务上通常很危险。如果你忽略错误就等于告诉程序即使失败了我也不关心。但很多错误是必须处理的文件不存在网络请求失败JSON 解析失败数据库写入失败参数不合法权限不足除非你非常确定这个错误可以忽略否则不要随手写_。更好的写法是result, err : divide(10, 0) if err ! nil { fmt.Println(divide failed:, err) return } fmt.Println(result)五、创建错误errors.New最简单的创建错误方式是errors.New。package main import ( errors fmt ) func checkAge(age int) error { if age 0 { return errors.New(age cannot be negative) } if age 18 { return errors.New(age must be at least 18) } return nil } func main() { if err : checkAge(15); err ! nil { fmt.Println(invalid age:, err) return } fmt.Println(age is valid) }输出invalid age: age must be at least 18errors.New适合创建固定文本的错误。如果错误里要带变量就更常用fmt.Errorf。六、创建带变量的错误fmt.Errorffmt.Errorf可以像fmt.Sprintf一样格式化错误信息。package main import ( fmt ) func findUser(id int) error { if id 0 { return fmt.Errorf(invalid user id: %d, id) } return nil } func main() { if err : findUser(-1); err ! nil { fmt.Println(err) } }输出invalid user id: -1相比errors.New(invalid user id)fmt.Errorf可以把具体值放进去让排查问题更方便。七、错误信息应该怎么写错误信息不是越长越好。好的错误信息应该说明哪里失败尽量带上关键上下文不要首字母大写不要以句号结尾不要写成用户界面的提示语例如return fmt.Errorf(open config %q: %w, path, err)比下面这种更好return fmt.Errorf(Error! Something went wrong.)Go 里错误常常会被一层层包装最后组成一句完整信息。例如load config app.yaml: open file: permission denied如果每一层都写成大写开头、感叹号、句号最后就会很别扭。八、添加上下文不要只原样返回错误假设你写了这样一个函数func loadConfig(path string) error { data, err : os.ReadFile(path) if err ! nil { return err } _ data return nil }它能工作但调用方看到错误时可能只知道open config.yaml: no such file or directory如果项目里有很多地方都读文件就不容易知道这次失败发生在哪个业务步骤。更推荐给错误加上下文func loadConfig(path string) error { data, err : os.ReadFile(path) if err ! nil { return fmt.Errorf(read config %q: %w, path, err) } _ data return nil }注意这里用了%w。fmt.Errorf(read config %q: %w, path, err)%w表示包装一个错误。包装以后错误信息会带上上下文原始错误仍然可以被errors.Is或errors.As找到完整例子package main import ( fmt os ) func loadConfig(path string) error { data, err : os.ReadFile(path) if err ! nil { // %w 会包装原始错误保留错误链。 return fmt.Errorf(read config %q: %w, path, err) } fmt.Println(config size:, len(data)) return nil } func main() { if err : loadConfig(missing.yaml); err ! nil { fmt.Println(err) } }可能输出read config missing.yaml: open missing.yaml: no such file or directory这比单独返回底层错误更有用。九、错误包装是什么错误包装可以理解成在原始错误外面套一层上下文。例如open missing.yaml: no such file or directory被包装后变成read config missing.yaml: open missing.yaml: no such file or directory如果再往上包装start server: load app: read config missing.yaml: open missing.yaml: no such file or directory每一层都告诉你我在做什么的时候失败了。这对排查问题很重要。十、errors.Is判断错误是不是某个错误有时你不只想打印错误而是想判断错误类型。例如如果文件不存在就创建默认配置。 如果是权限错误就直接返回。Go 推荐用errors.Is来判断错误链里是否包含某个目标错误。package main import ( errors fmt os ) func loadConfig(path string) error { _, err : os.ReadFile(path) if err ! nil { return fmt.Errorf(read config %q: %w, path, err) } return nil } func main() { err : loadConfig(missing.yaml) if err nil { fmt.Println(config loaded) return } if errors.Is(err, os.ErrNotExist) { fmt.Println(config does not exist, use default config) return } fmt.Println(load config failed:, err) }输出config does not exist, use default config为什么不用字符串判断不要这样写if strings.Contains(err.Error(), no such file) { // ... }原因是错误文本可能变化不同系统上的错误文本可能不同字符串匹配容易误判包装错误后文本更复杂errors.Is是结构化判断比字符串判断可靠。十一、哨兵错误 sentinel error哨兵错误是预先定义好的固定错误值。例如var ErrNotFound errors.New(not found)调用方可以用errors.Is判断if errors.Is(err, ErrNotFound) { // 处理未找到 }完整例子package main import ( errors fmt ) var ErrUserNotFound errors.New(user not found) func findUserName(id int) (string, error) { if id 100 { return Alice, nil } return , fmt.Errorf(find user %d: %w, id, ErrUserNotFound) } func main() { name, err : findUserName(200) if err ! nil { if errors.Is(err, ErrUserNotFound) { fmt.Println(show empty user page) return } fmt.Println(find user failed:, err) return } fmt.Println(user:, name) }输出show empty user page哨兵错误适合表达稳定、可判断的错误状态。常见例子io.EOFos.ErrNotExistcontext.Canceledcontext.DeadlineExceeded但不要给每一个错误都创建哨兵错误。如果调用方不需要专门判断就普通返回错误即可。十二、errors.As取出某种错误类型errors.Is用来判断“是不是某个错误”。errors.As用来判断“错误链里有没有某种错误类型”并把它取出来。例如你定义了一个带字段的错误类型type ValidationError struct { Field string Value string } func (e ValidationError) Error() string { return fmt.Sprintf(invalid %s: %q, e.Field, e.Value) }调用方可能不只想知道“验证失败”还想知道哪个字段失败。完整例子package main import ( errors fmt ) type ValidationError struct { Field string Value string } func (e ValidationError) Error() string { return fmt.Sprintf(invalid %s: %q, e.Field, e.Value) } func validateName(name string) error { if name { return ValidationError{ Field: name, Value: name, } } return nil } func createUser(name string) error { if err : validateName(name); err ! nil { return fmt.Errorf(create user: %w, err) } return nil } func main() { err : createUser() if err nil { fmt.Println(user created) return } var validationErr ValidationError if errors.As(err, validationErr) { fmt.Println(field:, validationErr.Field) fmt.Println(value:, validationErr.Value) return } fmt.Println(create user failed:, err) }输出field: name value:注意errors.As的第二个参数errors.As(err, validationErr)这里要传目标变量的地址。十三、自定义错误类型当错误不只是一个字符串而是需要携带结构化信息时可以定义自己的错误类型。例如package main import ( fmt time ) type RateLimitError struct { RetryAfter time.Duration } func (e RateLimitError) Error() string { return fmt.Sprintf(rate limited, retry after %s, e.RetryAfter) } func callAPI() error { return RateLimitError{ RetryAfter: 2 * time.Second, } } func main() { err : callAPI() if err ! nil { fmt.Println(err) } }输出rate limited, retry after 2s自定义错误类型适合需要暴露错误分类需要携带字段调用方需要根据字段做不同处理如果只是简单描述失败原因errors.New或fmt.Errorf就够了。十四、errors.Join合并多个错误有时一个操作可能同时产生多个错误。例如关闭多个资源时可能每个资源都关闭失败。Go 的errors.Join可以把多个错误合并成一个错误。package main import ( errors fmt ) func main() { err1 : errors.New(close file failed) err2 : errors.New(close network failed) err : errors.Join(err1, err2) if err ! nil { fmt.Println(err) } }可能输出close file failed close network failederrors.Join会忽略 nil 错误。如果传进去的错误全是 nil它会返回 nil。示例package main import ( errors fmt ) func main() { err : errors.Join(nil, nil) fmt.Println(err nil) }输出true在新手阶段你不一定经常用到errors.Join但知道它可以表达“多个错误同时存在”就够了。十五、defer 和错误处理错误处理经常和defer一起出现。defer用来注册函数结束前要执行的操作常见于释放资源。例如package main import ( fmt os ) func readFile(path string) error { file, err : os.Open(path) if err ! nil { return fmt.Errorf(open file %q: %w, path, err) } defer file.Close() buffer : make([]byte, 16) _, err file.Read(buffer) if err ! nil { return fmt.Errorf(read file %q: %w, path, err) } return nil } func main() { tmp, err : os.CreateTemp(, go-error-demo-*.txt) if err ! nil { fmt.Println(create temp file:, err) return } defer os.Remove(tmp.Name()) if _, err : tmp.WriteString(hello go); err ! nil { fmt.Println(write temp file:, err) return } tmp.Close() if err : readFile(tmp.Name()); err ! nil { fmt.Println(read failed:, err) return } fmt.Println(read success) }输出read successdefer file.Close()的意思是不管 readFile 后面是成功返回还是因为错误提前返回都要关闭文件。十六、关闭资源时的错误要不要处理很多人会写defer file.Close()这很常见但有一个细节Close 本身也可能返回错误。如果你写的是只读文件忽略Close错误通常问题不大。但如果你写文件Close时可能才发现刷盘失败。这时最好处理关闭错误。示例package main import ( fmt os ) func writeReport(path string, content string) (err error) { file, err : os.Create(path) if err ! nil { return fmt.Errorf(create report %q: %w, path, err) } defer func() { closeErr : file.Close() if closeErr ! nil err nil { err fmt.Errorf(close report %q: %w, path, closeErr) } }() if _, err : file.WriteString(content); err ! nil { return fmt.Errorf(write report %q: %w, path, err) } return nil } func main() { tmp, err : os.CreateTemp(, report-*.txt) if err ! nil { fmt.Println(create temp file:, err) return } path : tmp.Name() tmp.Close() defer os.Remove(path) if err : writeReport(path, hello report); err ! nil { fmt.Println(write report failed:, err) return } fmt.Println(write report success) }这里用了命名返回值func writeReport(path string, content string) (err error)defer里可以看到即将返回的err并在需要时补上关闭错误。这个写法对新手来说稍微绕一点。先理解思路就好如果 Close 的错误很重要就不要完全忽略它。十七、panic 不是普通错误处理Go 里还有panic。panic会停止当前函数的正常执行并开始展开调用栈。已经注册的defer会执行。示例package main import fmt func main() { defer fmt.Println(defer runs) fmt.Println(before panic) panic(something is broken) }输出大致会是before panic defer runs panic: something is broken程序会异常退出。那什么时候用panic新手可以先记住普通可预期错误用 error。 程序无法继续、违反内部不变量时才考虑 panic。例如用户输入错误返回error文件不存在返回error网络超时返回error配置格式错误返回error数组越界、空指针、不可恢复的内部状态可能触发panic不要把panic当成 try/catch 的替代品。十八、recover从 panic 中恢复recover可以在defer函数中捕获 panic让程序恢复控制。示例package main import fmt func safeRun() { defer func() { if r : recover(); r ! nil { fmt.Println(recovered:, r) } }() fmt.Println(before panic) panic(boom) } func main() { safeRun() fmt.Println(program continues) }输出before panic recovered: boom program continuesrecover只能在 deferred function 里直接调用才有效。但是注意recover 不是让你随便吞掉所有 panic。更常见的使用场景是HTTP 服务器中间件兜底避免单个请求导致整个服务退出goroutine 顶层保护记录 panic 日志框架边界把 panic 转成错误响应业务逻辑里的普通失败仍然应该返回error。十九、把 panic 转成 error有时你调用的代码可能 panic但你希望函数对外返回error。可以这样写package main import ( fmt ) func riskyDivide(a, b int) int { if b 0 { panic(division by zero) } return a / b } func safeDivide(a, b int) (result int, err error) { defer func() { if r : recover(); r ! nil { err fmt.Errorf(safe divide failed: %v, r) } }() result riskyDivide(a, b) return result, nil } func main() { result, err : safeDivide(10, 0) if err ! nil { fmt.Println(err) return } fmt.Println(result) }输出safe divide failed: division by zero这类写法应该放在边界位置不应该让整个项目都依赖 panic/recover 做普通流程控制。二十、错误应该在哪里处理当一个函数返回错误时调用方有几种选择1. 直接处理例如文件不存在时使用默认配置if errors.Is(err, os.ErrNotExist) { useDefaultConfig() return nil }2. 添加上下文后继续返回例如底层读文件失败上层说明自己正在加载配置return fmt.Errorf(load config: %w, err)3. 转换成业务错误例如数据库没找到用户转换成业务层的ErrUserNotFoundreturn fmt.Errorf(get profile: %w, ErrUserNotFound)4. 记录日志并终止流程例如main函数里if err : run(); err ! nil { log.Fatal(err) }新手最容易犯的错误是每一层都打印日志然后又继续返回错误。例如if err ! nil { log.Println(err) return err }如果很多层都这么写最后日志里会重复出现一堆相似错误。更清楚的做法通常是底层返回错误 中间层添加上下文 最外层统一记录日志二十一、实战例子读取配置并处理错误下面写一个稍微完整的例子。需求读取配置文件。 如果文件不存在使用默认配置。 如果文件为空返回验证错误。 如果读取失败保留底层错误。完整代码package main import ( errors fmt os strings ) var ErrEmptyConfig errors.New(empty config) type Config struct { AppName string } func parseConfig(content string) (Config, error) { content strings.TrimSpace(content) if content { return Config{}, ErrEmptyConfig } return Config{AppName: content}, nil } func loadConfig(path string) (Config, error) { data, err : os.ReadFile(path) if err ! nil { return Config{}, fmt.Errorf(read config %q: %w, path, err) } config, err : parseConfig(string(data)) if err ! nil { return Config{}, fmt.Errorf(parse config %q: %w, path, err) } return config, nil } func defaultConfig() Config { return Config{AppName: demo-app} } func main() { config, err : loadConfig(missing.conf) if err ! nil { if errors.Is(err, os.ErrNotExist) { config defaultConfig() fmt.Println(config file missing, use default config) fmt.Println(app name:, config.AppName) return } if errors.Is(err, ErrEmptyConfig) { fmt.Println(config file is empty) return } fmt.Println(load config failed:, err) return } fmt.Println(app name:, config.AppName) }输出config file missing, use default config app name: demo-app这个例子里有几个关键点parseConfig只负责解析不负责读文件。loadConfig给读文件和解析错误加上下文。main决定怎么处理不同错误。判断错误时用errors.Is而不是字符串匹配。文件不存在是可恢复错误所以使用默认配置。这就是比较典型的 Go 错误处理风格。二十二、常见错误处理模式早返回Go 里很常见的写法是if err ! nil { return err }这叫早返回。好处是错误路径先处理正常路径不用包在很深的else里。不要写成if err nil { // 一大段正常逻辑 } else { return err }更推荐if err ! nil { return err } // 一大段正常逻辑包装后返回跨函数返回错误时给错误加上下文if err ! nil { return fmt.Errorf(save user %d: %w, userID, err) }判断特殊错误使用errors.Isif errors.Is(err, os.ErrNotExist) { // 文件不存在 }提取错误类型使用errors.Asvar pathErr *os.PathError if errors.As(err, pathErr) { fmt.Println(operation:, pathErr.Op) fmt.Println(path:, pathErr.Path) }二十三、常见误区误区一用 panic 处理普通错误错误写法if err ! nil { panic(err) }如果这是普通文件读取、网络请求、参数校验就不应该 panic。更好的写法if err ! nil { return fmt.Errorf(read input: %w, err) }误区二错误信息没有上下文不太好return err更好return fmt.Errorf(load user profile: %w, err)当然也不是每一层都必须包装。关键是让最终错误信息能说明失败路径。误区三用字符串判断错误不推荐if err.Error() not found { // ... }更推荐if errors.Is(err, ErrNotFound) { // ... }误区四吞掉错误不推荐doSomething()如果函数返回错误应该接住if err : doSomething(); err ! nil { return err }误区五重复打印同一个错误底层打印一次中间层打印一次最外层又打印一次会让日志变乱。通常只在边界层记录日志例如mainHTTP handlergoroutine 顶层CLI 命令入口中间层优先返回带上下文的错误。二十四、新手错误处理检查清单写 Go 代码时可以用这张清单检查函数会失败吗如果会是否返回error调用函数后是否检查了err ! nil返回错误时是否保留了原始错误需要跨层传递时是否用%w包装判断错误时是否使用errors.Is或errors.As是否避免了用字符串匹配错误是否只在真正异常的情况下使用panic打开文件、连接等资源后是否用defer释放关闭资源的错误是否重要如果重要是否处理了日志是否只在合适的边界层打印二十五、学习路线建议如果你是新手可以按这个顺序练写一个返回error的函数。用errors.New创建固定错误。用fmt.Errorf创建带变量的错误。用if err ! nil做早返回。用%w包装错误。用errors.Is判断哨兵错误。定义一个自定义错误类型。用errors.As取出自定义错误。用defer释放资源。理解panic/recover但不要滥用。这些练顺以后Go 的错误处理就不再只是“满屏 if err ! nil”而是一套很清晰的失败处理机制。总结Go 的错误处理可以压缩成几句话error是一个接口核心方法是Error() string。nil表示没有错误。普通失败应该返回error不要用panic。errors.New创建固定错误。fmt.Errorf创建格式化错误。%w用来包装错误保留错误链。errors.Is用来判断错误链里是否有某个错误。errors.As用来取出错误链里的某种错误类型。errors.Join可以合并多个错误。defer常用于释放资源。recover只适合在边界位置处理 panic。底层返回错误中间层加上下文边界层统一记录日志。最后记住一句Go 的错误处理不是为了少写代码而是为了让失败路径清楚可见。当你能清楚回答“这个错误在哪里产生、在哪里补充上下文、在哪里被处理”你就真正开始掌握 Go 的错误处理了。参考资料Go Blog: Errors are valuesGo Blog: Working with Errors in Go 1.13Package errorsPackage fmt: ErrorfBuiltin package: error, panic, recoverGo Blog: Defer, Panic, and RecoverEffective Go: Errors