Skip to content
Go
errors & log — 错误处理与日志记录

errors & log — 错误处理与日志记录

Go 的错误处理哲学是:错误是值,应该被显式处理。Go 不使用异常(try/catch),而是使用多返回值。


1. errors 包 — 错误创建与处理

1.1 创建错误

// 简单错误(静态字符串)
err := errors.New("something went wrong")

// 格式化错误
err := fmt.Errorf("user %s not found", username)

// Go 1.13+: 错误包装(关键特性)
err := fmt.Errorf("failed to process: %w", originalErr)

%w(小写 w)是 Go 1.13 引入的特殊动词:格式化结果里不会出现 %w 本身,而是生成一个包装错误,把 originalErr 记在内部。errors.Is 和 errors.As 会沿这条包装链逐层解包查找。自定义包装类型只要实现 Unwrap() error 就能接入同一套解包机制。

1.2 errors.Is vs errors.As — 何时用哪个?

函数 用场景 比较方式
errors.Is(err, target) 判断错误"是否等于"某个哨兵错误(含包装链) == 或目标错误自定义的 Is 方法
errors.As(err, &target) 从包装链中"提取"某个具体类型的错误 类型断言
// errors.Is — 判断是否为 io.EOF(可能在多层包装内)
if errors.Is(err, io.EOF) {
    // 正常结束
}

// errors.As — 获取具体错误类型以读取字段
var pathError *os.PathError
if errors.As(err, &pathError) {
    fmt.Println("出问题的路径:", pathError.Path)
}

陷阱:

  • errors.As 的第二个参数必须是指向"错误类型指针"的指针(var target *MyError; errors.As(err, &target)),即 **MyError。传 errors.As(err, target) 少了取地址会 panic 或编译报错。
  • 不要用 == 直接比较被包装的错误——fmt.Errorf("...: %w", ErrX) 的结果不等于 ErrX,但 errors.Is 能识别。== 只适合最外层未包装的哨兵错误。
  • 当包装链上存在多个同类型错误时,As 只匹配第一个。

1.3 自定义错误类型

// 方案 A:简单哨兵错误
var ErrNotFound = errors.New("not found")

// 方案 B:带字段的错误类型
type ValidationError struct {
    Field   string
    Value   any
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: field=%s value=%v: %s", e.Field, e.Value, e.Message)
}

// 方案 C:包装底层错误 + 添加上下文
func getUser(ctx context.Context, id string) (*User, error) {
    user, err := db.Find(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return user, nil
}

最佳实践:

  • 哨兵错误变量以 Err 开头(ErrNotFound、ErrTimeout);自定义错误类型以 Error 结尾(ValidationError)。
  • 包装时用 %w 保留原错误,让上层能继续 Is/As。包装信息应是"上下文"而非重复描述底层错误:"get user 42: connection refused" 比 "error: error: connection refused" 好。
  • 对外只暴露你希望调用者匹配的错误(哨兵或导出类型);内部实现细节的错误可以随时替换而不破坏调用方。

1.4 errors.Unwrap 与多重错误

// 手动遍历解包链
for err != nil {
    fmt.Println(err)
    err = errors.Unwrap(err)
}

// Go 1.20+: 多重错误
err1 := errors.New("err1")
err2 := errors.New("err2")
combined := errors.Join(err1, err2)
fmt.Println(combined) // "err1\nerr2"
// errors.Is(combined, err1) → true
// errors.Is(combined, err2) → true

Go 1.20 的 errors.Join 返回的复合错误实现了 Unwrap() []error(复数),表示"一个错误同时包含多个底层错误"。errors.Is/errors.As 会对每个子错误递归判断。典型用途:校验函数收集所有字段错误后一次性返回,而不是遇到第一个就返回。传入 errors.Join(nil, err2) 会忽略 nil,全 nil 则返回 nil——这让"累加错误"的循环写法很干净。


2. log 包 — 标准日志

2.1 基础使用

log.Print("message")
log.Printf("formatted %s", "msg")
log.Fatal("critical")  // Print 后 os.Exit(1) —— defer 不执行!
log.Panic("fatal")     // Print 后 panic() —— defer 执行

陷阱:log.Fatal 调用 os.Exit,不会执行 defer,也不会刷新起的 goroutine。若函数里 defer file.Close() 后接 log.Fatal,文件不会被关闭。要保证清理,用 log.Print + 正常 return,把退出决策交给 main。

2.2 日志标志 (Flags)

log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
// 输出:2024/01/15 10:30:00 server.go:42: message
标志 效果
Ldate 2009/01/23
Ltime 01:23:23
Lmicroseconds 01:23:23.123123
Llongfile /full/path/file.go:23
Lshortfile file.go:23
LUTC 使用 UTC 时间
Lmsgprefix 前缀放在消息前而非行首

2.3 自定义 Logger 与多路输出

// 不同级别用不同 logger,输出到不同目的地
var (
    Info  = log.New(os.Stdout, "[INFO] ", log.LstdFlags)
    Error = log.New(os.Stderr, "[ERROR] ", log.LstdFlags|log.Lshortfile)
)

// 同时写文件 + 控制台
file, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
log.SetOutput(io.MultiWriter(file, os.Stdout))

标准 log 只有"打字符串到某个 Writer"这一层能力。新项目直接上 log/slog——它既能做结构化,又能与 log 互转(sloglog 桥接,或 log.Default())。


3. log/slog — 结构化日志 (Go 1.21+)

slog 是 Go 1.21 引入的结构化日志包,目标是成为标准库日志的事实标准。核心思想:一条日志不是字符串,而是 时间 + 级别 + 消息 + 一组键值属性(Attr)。

3.1 两种内置 Handler

Handler 输出格式 场景
slog.NewTextHandler(w, opts) time=... level=INFO msg=... user_id=123 开发环境(人读)
slog.NewJSONHandler(w, opts) JSON 生产环境(被采集系统解析)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

logger.Info("user logged in",
    "user_id", 123,
    "ip", "192.168.1.1",
    "duration", 150*time.Millisecond,
)
// {"time":"...","level":"INFO","msg":"user logged in","user_id":123,"ip":"192.168.1.1","duration":"150ms"}

设置全局默认 logger,让包级函数 slog.Info(...) 直接可用:

slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
slog.Info("startup", slog.String("version", buildVersion))

3.2 性能:真正该知道的点

slog 有两种传参 API:

// 交替 key/value 的 ...any 形式
logger.Info("event", "user_id", 123, "ip", "1.2.3.4")

// 显式 Attr 形式(推荐)
logger.Info("event", slog.Int("user_id", 123), slog.String("ip", "1.2.3.4"))

// 最高效:直接传 []Attr,完全绕过 any 装箱
attrs := []slog.Attr{slog.Int("user_id", 123), slog.String("ip", "1.2.3.4")}
logger.LogAttrs(ctx, slog.LevelInfo, "event", attrs...)

...any 形式要把每个值装进 any(可能触发分配),且要两两配对校验;slog.Int/String/Any 构造的 Attr 内部是 tagged union,避免二次装箱,LogAttrs(1.24)则连可变参数的 any 都没有。注意:Attr 的构造本身不是"延迟求值"——传给 Info 的表达式在调用时就已经求值了。

真正的性能杠杆是先判级别再算消息:

// 错误:即使 Debug 关闭,fmt.Sprintf 仍然执行
logger.Debug("payload: " + expensiveString())

// 正确:先用 Enabled 短路,昂贵的构造根本不发生
if logger.Enabled(ctx, slog.LevelDebug) {
    logger.DebugContext(ctx, "payload", slog.String("data", expensiveString()))
}

重复出现的属性(如请求 ID)用 With 预绑定,内部会复用而不是每条重算:reqLogger := base.With(slog.String("req_id", id))。

3.3 带 Context 的日志

logger.InfoContext(ctx, "processing request",
    slog.String("method", "GET"),
    slog.String("path", "/api/users"),
)

常见误解要澄清:InfoContext 把 ctx 传给 Handler.Enabled 和 Handler.Handle,但内置的 TextHandler/JSONHandler 不会自动从 ctx 里取任何值。HandlerOptions.AddSource 加的也只是源码位置(文件/行/函数),与 ctx 无关。想让 trace_id/user_id 自动出现在每条日志里,有两条正路:

// 方案 1:请求入口用 With 把 ctx 里已知的值绑到 logger 上(最简单可靠)
reqLogger := logger.With("trace_id", traceIDFromCtx(ctx))
reqLogger.InfoContext(ctx, "processing")

// 方案 2:自定义 Handler,在 Handle 里从 ctx 参数抽值(框架级做法)
type ctxHandler struct{ slog.Handler }
func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
    if tid, ok := ctx.Value(traceKey).(string); ok {
        r.AddAttrs(slog.String("trace_id", tid))
    }
    return h.Handler.Handle(ctx, r)
}
// 注意:ctx 是 Handle 的第一个入参,不要写成 r.Context()——Record 早已不提供该方法。

方案 1 覆盖绝大多数场景;只有当你无法在每个调用点手动传 logger、只能拿到 ctx 时,才需要方案 2 这种自定义 Handler。

3.4 分组、动态级别

slog.Group(1.24)把相关属性聚合成嵌套结构,JSON 输出会变成子对象:

logger.Info("db query",
    slog.Group("metrics",
        slog.Duration("latency", 12*time.Millisecond),
        slog.Int("rows", 50),
    ),
)
// {"msg":"db query","metrics":{"latency":"12ms","rows":50}}

slog.LevelVar 是可运行时调整的级别,配合配置中心做"线上临时开 Debug"很方便:

var lvl slog.LevelVar
lvl.Set(slog.LevelInfo)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &lvl}))
// 收到 SIGHUP 时: lvl.Set(slog.LevelDebug)

3.5 级别常量

slog.LevelDebug  // -4
slog.LevelInfo   // 0(默认)
slog.LevelWarn   // 4
slog.LevelError  // 8

低于 Handler 设定 Level 的记录被丢弃。级别是 Leveler 接口,可用常量、LevelVar 或自定义实现。


4. 错误处理最佳实践总结

  1. 函数返回 error,不要 (bool, error)——只靠 error 判断失败并携带原因。
  2. 用 errors.Is 判断哨兵错误,用 errors.As 提取类型错误,别用 ==。
  3. 用 %w 包装底层错误,保留错误链;包装串写"上下文"而非重复底层消息。
  4. 不要吞没错误:_ = doSomething() 改成显式 if err := doSomething(); err != nil { return err }。
  5. 不要重复包装:调用方已加上下文,被调用方返回原始错误即可。
  6. 哨兵错误做成包级导出变量,别每次 errors.New。
  7. 日志用 slog,昂贵的字段构造前先 Enabled 短路;跨请求的稳定字段用 With 或自定义 Handler 注入,别指望 InfoContext 自动读 ctx。