Easton Notes
知识八股GoJS 转 Go

JS 转 Go:错误处理

本页关键词:error 是值、if err != nil%werrors.Iserrors.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)

这里的控制流是直线的:

  1. 调用函数,拿到 resulterr
  2. 如果 err != nil,说明失败,立刻处理或向上返回。
  3. 如果 err == nil,才继续使用 result

所以 Go 的错误处理不是“异常从某处跳出来”,而是“每一步都把失败显式摆在返回值里”。这也是为什么 Go 代码里会反复出现 if err != nil

JavaScriptGo
机制异常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)包一层Errorcause
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 类似未捕获异常:栈展开,一路执行 deferrecover 只能写在 defer 里,把 panic 变成值。

适用:真正不可继续的程序错误(数组越界、配置在启动时就坏了)。业务失败用 error

可以粗略这么分:

  • 用户输入不合法、数据库没查到、下游超时:返回 error
  • 程序员写错导致数组越界、启动期必要配置缺失、不可恢复的不变量被破坏:可以 panic

不要把 panic/recover 当成 Go 版 throw/catch。Go 项目里主流程靠 errorrecover 多放在边界层兜底,防止服务整个崩掉。

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.Groupgolang.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」,错误处理题就闭环了。

On this page