我在 Genesis 里做 clog 时,并不想再发明一套日志 API。Go 标准库的 log/slog 已经定义好了结构化字段和扩展边界,组件只需要补上微服务里反复出现的几个问题:统一字段、从 context.Context 提取链路信息、按模块划分命名空间,以及在不重启服务的情况下调整日志级别。
这篇文章记录 clog 的设计和实现。代码细节以仓库当前实现为准,这里重点讲每一层为什么存在。
先理清 slog 的数据流
一次 slog 调用大致经过四步:
- 用
Attr表示结构化字段。 - 将时间、级别、消息和调用位置放进
Record。 - 通过
Handler.Enabled尽早过滤不需要的级别。 - 交给
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_id和span_id。WithContextField提取request_id、user_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.String 和 clog.Int,底层仍然交给 slog 处理。
Handler 如何组装
clog 创建 Handler 时按下面的顺序组装:
writer
-> slog.HandlerOptions
-> JSONHandler 或 TextHandler
-> 可选的 coloredTextHandler
-> clogHandler
slog.HandlerOptions 主要负责两件事。Level 指向 slog.LevelVar,因此 SetLevel 可以直接更新过滤阈值;ReplaceAttr 统一改写时间、级别和 source 字段。
coloredTextHandler 是一个完整的 Handler 包装器。这类包装器不能只实现 Handle,还要正确实现 Enabled、WithAttrs 和 WithGroup,否则预绑定字段或字段分组会在派生 Logger 中丢失。
clogHandler 放在最外层,保留动态级别和文件句柄。根 Logger 通过 Close 释放文件资源;With 和 WithNamespace 产生的子 Logger 不拥有这些资源,调用 Close 不会误关闭共享输出。
Context 字段与命名空间
日志库不应该要求每个业务函数手动传递 trace_id。clog 在 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 才能用来检索、聚合和设置告警,日志也不需要因此记录更多字段。
一次日志的完整路径
clog 的 With 会创建一个子 Logger,并把预绑定字段保存在子 Logger 中。记录日志时,它会:
- 调用
Handler.Enabled过滤日志级别。 - 合并预绑定字段和本次调用字段。
- 从 Context 提取配置过的字段,再加上 namespace。
- 通过
runtime.Callers取得调用位置,创建slog.Record。 - 将 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 包装器和构造选项中,不改业务侧的日志调用。这也是这层封装最重要的边界:业务只负责描述事件,日志策略由基础组件统一管理。