我在 Genesis 里做 clog 时,并不想再发明一套日志 API。Go 标准库的 log/slog 已经定义好了结构化字段和扩展边界,组件只需要补上微服务里反复出现的几个问题:统一字段、从 context.Context 提取链路信息、按模块划分命名空间,以及在不重启服务的情况下调整日志级别。

这篇文章记录 clog 的设计和实现。代码细节以仓库当前实现为准,这里重点讲每一层为什么存在。

先理清 slog 的数据流

一次 slog 调用大致经过四步:

  1. Attr 表示结构化字段。
  2. 将时间、级别、消息和调用位置放进 Record
  3. 通过 Handler.Enabled 尽早过滤不需要的级别。
  4. 交给 Handler.Handle 编码并写入 JSON、文本或其他目标。
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("create user",
    slog.Int("id", 1),
    slog.String("email", "a@b.com"),
)

Attr 并不是已经拼好的字符串。它保留了值的类型,常用类型不必先走 fmt.Sprintf 和反射。真正的字节编码放在 Handler 输出时完成,因此更准确的描述是“延迟格式化、减少中间分配”,而不是泛化的“零拷贝”或“零分配”。

Handler 则是 slog 最重要的扩展边界。字段脱敏、采样、多路输出、时间格式和调用位置裁剪,都可以放在 Handler 或 Handler 包装器中,不用让业务代码知道。

clog 的边界

clog 保留了 slog 的心智模型,只提供一个稳定的 Logger 接口。对外能力包括:

  • Debug、Info、Warn、Error 和 Fatal,以及对应的 Context 版本。
  • With 预绑定字段,WithNamespace 创建模块级命名空间。
  • WithTraceContext 自动提取 OpenTelemetry 的 trace_idspan_id
  • WithContextField 提取 request_iduser_id 等业务字段。
  • SetLevel 在运行时调整日志级别。
  • JSON 和 console 两种格式,输出到 stdout、stderr 或文件。

Field 直接定义为 slog.Attr 的类型别名:

type Field = slog.Attr

func String(k, v string) Field { return slog.String(k, v) }
func Int(k string, v int) Field { return slog.Int(k, v) }

这里没有做字段转换层。业务使用 clog.Stringclog.Int,底层仍然交给 slog 处理。

Handler 如何组装

clog 创建 Handler 时按下面的顺序组装:

writer
  -> slog.HandlerOptions
  -> JSONHandler 或 TextHandler
  -> 可选的 coloredTextHandler
  -> clogHandler

slog.HandlerOptions 主要负责两件事。Level 指向 slog.LevelVar,因此 SetLevel 可以直接更新过滤阈值;ReplaceAttr 统一改写时间、级别和 source 字段。

coloredTextHandler 是一个完整的 Handler 包装器。这类包装器不能只实现 Handle,还要正确实现 EnabledWithAttrsWithGroup,否则预绑定字段或字段分组会在派生 Logger 中丢失。

clogHandler 放在最外层,保留动态级别和文件句柄。根 Logger 通过 Close 释放文件资源;WithWithNamespace 产生的子 Logger 不拥有这些资源,调用 Close 不会误关闭共享输出。

Context 字段与命名空间

日志库不应该要求每个业务函数手动传递 trace_idclog 在 Context 版本的日志方法中集中提取这些字段:

logger, err := clog.New(
    clog.NewProdDefaultConfig("genesis"),
    clog.WithNamespace("user-service", "api"),
    clog.WithTraceContext(),
    clog.WithContextField(requestIDKey, "request_id"),
    clog.WithContextField(userIDKey, "user_id"),
)
if err != nil {
    return err
}
defer logger.Close()

logger.InfoContext(ctx, "get user",
    clog.String("path", "/v1/users/1"),
    clog.Int("id", 1),
)

WithTraceContext 需要请求链路上已经有活跃的 OpenTelemetry Span。如果应用没有初始化 TracerProvider,或 HTTP/gRPC 中间件没有建立 Span,这两个字段就不会出现。日志库不会为此报错。

命名空间与 Context 字段用来稳定字段名和来源。只有稳定的 key 才能用来检索、聚合和设置告警,日志也不需要因此记录更多字段。

一次日志的完整路径

clogWith 会创建一个子 Logger,并把预绑定字段保存在子 Logger 中。记录日志时,它会:

  1. 调用 Handler.Enabled 过滤日志级别。
  2. 合并预绑定字段和本次调用字段。
  3. 从 Context 提取配置过的字段,再加上 namespace。
  4. 通过 runtime.Callers 取得调用位置,创建 slog.Record
  5. 将 Record 交给 Handler 编码并写出。

这里有一个刻意的取舍:clog 的预绑定字段存在 Logger 实现中,不是直接调用底层 Handler 的 WithAttrs。这样方便在输出前统一合并 Context 和 namespace,也意味着文章或注释不能把 clog.With 简化成 slog.Logger.With 的完全透传。

错误字段与日志量

clog.Error 将错误记录为结构化字段,ErrorWithCode 另外保留业务错误码。只有确实需要调用栈时才使用 ErrorWithStack

logger.Error("db query failed",
    clog.ErrorWithCode(err, "DB_QUERY_FAILED"),
    clog.String("table", "users"),
)

错误栈、完整 SQL 和请求体都可能快速放大日志量,还可能携带敏感信息。结构化日志的价值不在于“全部记下来”,而在于用稳定字段保留足够定位问题的上下文。

后续扩展

如果后面要增加脱敏、采样或多路输出,我会继续把策略放在 Handler 包装器和构造选项中,不改业务侧的日志调用。这也是这层封装最重要的边界:业务只负责描述事件,日志策略由基础组件统一管理。