JS 转 Go:错误处理
本页关键词:error 是值、
if err != nil、%w、errors.Is、errors.As、哨兵错误、早期返回、panic/recover
一、哲学:错误是值
JS 用 throw / try/catch,错误会跳栈。Go 把失败当成普通返回值,调用方必须看见、必须处理。没有隐藏的 catch 路径。
先看一个最小函数签名:
func divide(a, b float64) (float64, error)它读作:divide 接收两个 float64,返回两个值;第一个是计算结果,第二个是 error。Go 里最后一个返回值如果叫 error,通常表示“这个函数可能失败”。
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除零")
}
return a / b, nil
}
result, err := divide(10, 0)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)这里的控制流是直线的:
- 调用函数,拿到
result和err。 - 如果
err != nil,说明失败,立刻处理或向上返回。 - 如果
err == nil,才继续使用result。
所以 Go 的错误处理不是“异常从某处跳出来”,而是“每一步都把失败显式摆在返回值里”。这也是为什么 Go 代码里会反复出现 if err != nil。
| JavaScript | Go | |
|---|---|---|
| 机制 | 异常 | error 值 |
| 传播 | 自动上抛 | 手动 return err |
| 控制流 | 可被 catch 打断 | 直线往下,失败就提前 return |
| 异步 | try/catch + Promise rejection | 同一套 error,经 channel 传回即可 |
面试要点:优点是错误路径看得见;缺点是啰嗦。Go 团队认为「显式」比「少写几行」重要。不要用 panic 模拟 throw。
二、error 接口
type error interface {
Error() string
}error 不是特殊语法,本质上就是一个接口。任何类型只要实现了 Error() string 方法,就可以当成 error 返回。
自定义错误可以带字段,比单纯字符串更适合给上层判断:
type ValidationError struct {
Field, Message string
}
func (e ValidationError) Error() string {
return e.Field + ": " + e.Message
}
func validate(name string) error {
if name == "" {
return ValidationError{Field: "name", Message: "必填"}
}
return nil
}这段代码里:
ValidationError是一个结构体,保存字段名和错误信息。Error() string让它满足error接口。validate返回ValidationError{...}时,调用方看到的是一个error,但后面可以用errors.As把具体类型取回来。
哨兵错误:包级变量,表示固定语义(对照 ENOENT):
var ErrNotFound = errors.New("not found")
func Find(id int) error {
if id < 0 {
return ErrNotFound
}
return nil
}哨兵错误适合表示稳定、可公开判断的错误类别,例如“没找到”“已存在”“权限不足”。它不是用来塞动态信息的;动态信息应该通过包装补上下文。
比较包装过的错误要用 errors.Is,不要只写 err == ErrNotFound。原因是错误可能被包了好几层,== 只能比较当前这一层,errors.Is 会沿着错误链往里找。
三、包装与判定
Go 1.13+ 用 %w 包一层上下文,同时保留原错误:
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("读配置 %s: %w", path, err)
}包装错误的目的有两个:
- 给低层错误补业务上下文,比如“读配置
/etc/app.yaml失败”。 - 保留原始错误,方便上层继续判断它到底是不是
os.ErrNotExist之类的错误。
| API | 用途 | JS 类比 |
|---|---|---|
fmt.Errorf("...: %w", err) | 包一层 | Error 的 cause |
errors.Unwrap(err) | 剥一层 | — |
errors.Is(err, ErrNotFound) | 沿链找哨兵 | error === CODE |
errors.As(err, &valErr) | 沿链找具体类型 | instanceof |
if errors.Is(err, ErrNotFound) {
// 404
}
var vErr ValidationError
if errors.As(err, &vErr) {
fmt.Println(vErr.Field)
}%w 和 %v 很容易混:
wrapped := fmt.Errorf("load user: %w", ErrNotFound)
fmt.Println(errors.Is(wrapped, ErrNotFound)) // true
textOnly := fmt.Errorf("load user: %v", ErrNotFound)
fmt.Println(errors.Is(textOnly, ErrNotFound)) // false%v 只字符串化,不会加入错误链;要能 Is/As 必须用 %w。
不要靠字符串判断错误:
if err.Error() == "not found" { // 脆弱:文案一改就坏
}项目里优先用 errors.Is 判断错误类别,用 errors.As 拿具体错误类型里的字段。
四、写法习惯
早期返回,不要嵌套 if err == nil { ... }:
func loadUser(id string) (*User, error) {
u, err := repo.Get(id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if err := u.Validate(); err != nil {
return nil, fmt.Errorf("validate: %w", err)
}
return u, nil
}早期返回的好处是:主流程不被一层层 else 包住。读 Go 代码时,可以把 if err != nil { return ... } 当成“失败出口”,剩下的代码就是成功路径。
不要 _ = do() 丢掉 error。唯一能忽略的是你确定无意义的关闭错误,也要写清楚原因:
defer func() {
_ = rows.Close() // 查询已经结束,关闭错误无法补救,只能忽略
}()更常见的是直接处理:
if err := do(); err != nil {
return fmt.Errorf("do something: %w", err)
}多个校验错误可以聚合成一个类型:
type ValidationErrors struct{ Msgs []string }
func (e ValidationErrors) Error() string {
return strings.Join(e.Msgs, "; ")
}日志里打 %v 或 %+v(部分库),返回给调用方时继续包,不要只 log 然后返回 nil:
if err != nil {
log.Println(err)
return nil // 错:上层以为成功了
}业务函数一般只负责返回错误;入口层(HTTP handler、CLI main、定时任务边界)再决定记录日志、打指标、转状态码。
五、panic 与 recover
panic 类似未捕获异常:栈展开,一路执行 defer。recover 只能写在 defer 里,把 panic 变成值。
适用:真正不可继续的程序错误(数组越界、配置在启动时就坏了)。业务失败用 error。
可以粗略这么分:
- 用户输入不合法、数据库没查到、下游超时:返回
error。 - 程序员写错导致数组越界、启动期必要配置缺失、不可恢复的不变量被破坏:可以
panic。
不要把 panic/recover 当成 Go 版 throw/catch。Go 项目里主流程靠 error,recover 多放在边界层兜底,防止服务整个崩掉。
func mustCompile(expr string) *regexp.Regexp {
re, err := regexp.Compile(expr)
if err != nil {
panic(err) // 启动期常量,编不过就别跑
}
return re
}
func Guard(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
http.Error(w, "internal", 500)
}
}()
h.ServeHTTP(w, r)
})
}goroutine 里的 panic 不会被其他 goroutine 的 recover 接到。HTTP 中间件要自己套一层,否则一个 handler panic 就能干掉整个进程。
这句话的重点是“recover 不跨 goroutine”。如果你在新 goroutine 里做任务,兜底也要写在那个 goroutine 内部:
go func() {
defer func() {
if rec := recover(); rec != nil {
log.Println("panic:", rec)
}
}()
risky()
}()六、并发里的 error
go f() 的返回值会被丢掉,因为 go 启动的是异步执行,调用点不会等它返回:
go func() error {
return job()
}() // 这个 error 没有人接几种做法:
- 错误 channel:
errCh <- err(注意缓冲,避免阻塞) errgroup.Group(golang.org/x/sync/errgroup):第一个错误取消其余,类似Promise.all失败短路- 收集:每个结果
{val, err}放进 channel,类似Promise.allSettled
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return jobA(ctx) })
g.Go(func() error { return jobB(ctx) })
if err := g.Wait(); err != nil {
return err
}errgroup 的常见价值是把“启动多个 goroutine、等待全部结束、任一失败就返回错误”这套样板收起来。它比手写 WaitGroup + errCh + context cancel 更不容易漏边界。
面试要点:能画出
New→%w包装 →Is/As判定这条链,再说「panic 只给不可恢复 + 中间件 recover」,错误处理题就闭环了。