ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

buildkit 中的 Go 结构化日志抽象层 logr:Logger/LogSink 解耦设计与 slog 互操作实战

buildkit 中的 Go 结构化日志抽象层 logr:Logger/LogSink 解耦设计与 slog 互操作实战 buildkit 中的 Go 结构化日志抽象层 logrLogger/LogSink 解耦设计与 slog 互操作实战【免费下载链接】buildkitconcurrent, cache-efficient, and Dockerfile-agnostic builder toolkit项目地址: https://gitcode.com/GitHub_Trending/bu/buildkit导读logr 是一套面向 Go 语言的极简日志 API 规范它的核心主张是让库与应用的代码只依赖一个抽象接口而把“到底用哪套日志实现”的决定权上移到 main() 附近。在 buildkit 仓库中logr 以 v1.4.4 间接依赖的形式随 klogKubernetes 日志库进入 vendor 目录见 go.mod是构建系统中结构化日志链路的底层抽象之一。读完本文你将掌握 logr 的两层 APILogger与LogSink的设计哲学、典型用法、V 级别语义以及它如何与 Go 1.21 标准库log/slog双向互操作并能在自己的 Go 项目中用它写出“与实现解耦”的日志代码。logr 是什么两层 API两类用户logr 不是一个日志实现而是一套API 规范。它在 README.md 中明确将 API 拆成两个层次服务两类完全不同的人群Logger类型面向应用作者和库作者。它提供一套相对较小的方法集合Info、Error、V、WithName、WithValues等可以在任何需要输出日志的地方使用。真正的“写日志”动作写文件、写 stdout或交给其他后端被延迟委托给LogSink接口。LogSink接口面向日志库的实现者。它是一个纯接口日志框架zap、logrus、zerolog、klog 等通过实现它来提供真正的日志输出功能。这种解耦带来的直接收益是应用和库的开发者在代码里只依赖logr.Logger依赖扇出极小而日志实现的装配发生在“栈的上游”即 main() 中或靠近 main() 的地方。应用可以随时按需替换底层日志实现而无需改动任何业务代码。关于“库到底该不该打日志”的争论README 中坦率地回应了“许多人认为库不应该打日志”的观点并指出数万个真实存在的库都会写日志这一事实logr 采取的是更务实的路径——既然库要打日志就为它们提供一个稳定的抽象而不是让每个库各自耦合一套实现。源码视角Logger 是 struct而不是 interface一个值得注意的实现事实是Logger被刻意实现为struct 而非纯接口。从 logr.go 可以看到type Logger struct { sink LogSink level int }README 中解释了这样设计的原因“Why is this not a pure interface?”struct 形态允许 Go 编译器对未触发的高 V 级别Info调用做优化例如跳过对Enabled的调用、避免参数求值而所有真正的工作仍发生在LogSink接口之后。也就是说struct 外壳 接口内核兼顾了编译期优化与实现可替换性。LogSink接口本身非常精简logr.go只有 6 个方法type LogSink interface { Init(info RuntimeInfo) // 接收 logr 核心库的运行信息如调用栈深度 Enabled(level int) bool // 判断该级别是否启用 Info(level int, msg string, keysAndValues ...any) Error(err error, msg string, keysAndValues ...any) WithValues(keysAndValues ...any) LogSink WithName(name string) LogSink }典型用法在 main() 装配在业务代码里消费README 给出了一套清晰的“装配—传递—消费”模式第一步在应用生命周期的早期决定使用哪套日志实现。在 main() 中调用某个 logr 实现如logimpl的构造函数得到一个logr.Loggerfunc main() { // ... other setup code ... // 创建根 logger。我们选定了 logimpl 这个实现 // 它接收若干初始参数并返回一个 logr.Logger。 logger : logimpl.New(param1, param2) // ... other setup code ... }第二步把logr.Logger传入其他库、存入结构体甚至作为包级全局变量。例如app : createTheAppObject(logger) app.Run()第三步业务代码只认logr.Logger完全不关心底层实现。除早期装配外没有任何包需要知道实现的选择它们只基于收到的logr.Logger打日志type appObject struct { // ... other fields ... logger logr.Logger // ... other fields ... } func (app *appObject) Run() { app.logger.Info(starting up, timestamp, time.Now()) // ... app code ... }结构化日志的核心形态Info / Errorlogr 把日志收敛为两种类型info 日志与 error 日志README 的 “Differences from Daves ideas” 一节明确了这一点。信息来自 Dave Cheney 的名篇《Lets talk about logging》的启发但 logr 与他提出的“干脆用fmt.Printf()取代日志 API”的观点分道扬镳——因为输出位置、时间戳、文件与行号修饰、结构化日志等需求都无法用 printf 解决。两种日志的写法对比来自 logr.go 的包级文档示例// 标准库 log 的写法 log.Printf(setting target value %s, targetValue) // logr 结构化写法 logger.Info(setting target, value, targetValue) // 错误日志标准库写法 log.Printf(failed to open the pod bay door for user %s: %v, user, err) // logr 错误日志写法 logger.Error(err, failed to open the pod bay door, user, user)注意语义约定Info与Error是两个独立方法而不是一个方法的两个级别这样LogSink实现可以在Error调用上附加额外信息例如堆栈跟踪。Error日志不受 verbosity 控制永远输出若没有可用的 error 实例传入nil是合法的logr.go。V 级别用数字代替语义化级别名logr 用数值 V 级别取代 “warning/trace/debug” 这类带语义的名字。核心规则logr.goV(0)是默认级别logger.V(0).Info(...)与logger.Info(...)完全等价V 级别越高表示信息越不重要越啰嗦、越偏调试未启用的级别根本不会写盘负数的 V 级别与V(0)等价源码中if level 0 { level 0 }显式钳制V 级别是可叠加的l.V(2)返回的新 Logger 会在原有级别上累加Error 日志没有 verbosity 概念永远输出。典型写法对比// 传统的 if 判断 if flVerbose 2 { log.Printf(an unusual thing happened) } // logr 写法 logger.V(2).Info(an unusual thing happened)WithName 与 WithValues给 Logger 加语境Logger支持为实例附加名字与固定键值对实现“按子系统/按对象”细化日志WithName追加名字段多次调用会累积 name 片段由LogSink决定如何拼接。README 强烈建议名字只包含字母、数字和连字符避免空格、逗号、点、斜杠、括号、引号等可能干扰拼接的字符logr.go。WithValues为 Logger 保存任意数量的键值对该实例发出的每条日志都会携带它们。经典场景是为“每个被管理对象”建一个专属 Logger// 某处设置好携带对象信息的 logger obj.logger mainLogger.WithValues( name, obj.name, namespace, obj.namespace) // 之后…… obj.logger.Info(setting foo, value, targetValue)对应的标准库写法log.Printf(decided to set field foo to value %q for object %s/%s, ...)需要把对象信息反复手写进每条日志而 logr 用WithValues一次性声明、处处携带。零值安全与 DiscardLogger{}的零值等价于Discard()会丢弃所有日志代码里拿到Logger值后可以直接调用方法而绝不会 paniclogr.go。Discard()的实现就是把nilsink 传给Newfunc Discard() Logger { return New(nil) }参见 discard.go。如果某个参数“可能没有日志需求”官方建议使用*Logger指针表示可选性而值类型Logger则假定永远可用。调用栈归属WithCallDepth 与 WithCallStackHelper日志输出文件与行号需要知道“真正调用点”在栈的哪一层。logr 提供两个可选接口与两个高层方法CallDepthLogSinksink 知道如何按帧数爬栈logr.go对应Logger.WithCallDepth(depth)CallStackHelperLogSink类似 Gotesting包的Helper()机制标记某个函数为“辅助函数”从而在栈归属中被跳过logr.go对应Logger.WithCallStackHelper()它返回(func(), Logger)——调用者必须调用返回的函数。Break GlassGetSink 与自定义 With 扩展若需要触达底层实现官方推荐 “Underlier” 模式定义一个暴露底层类型的接口再用Logger.GetSink()做类型断言logr.gotype Underlier interface { GetUnderlying() underlying-type } func DoSomethingWithImpl(log logr.Logger) { if underlier, ok : log.GetSink().(impl.Underlier); ok { implLogger : underlier.GetUnderlying() // ... } }自定义With*函数可以复制整个Loggerstruct 并用WithSink替换其中的 sink。注意两点警告不要用New从既有 Logger 取出 sink 再造新 Logger会导致源码归属错误并丢失未导出字段同一个LogSink实例可能被多个 Logger 共享修改 sink 的方法会影响所有共享者。Marshaler按需生成可记录的值logr.Marshaler是值类型可选的接口logr.go结构化输出如 JSON的 logger 会记录MarshalLog()的返回值而不是原始值。典型用途避免带String()方法的 struct 被错误地序列化成字符串、挑选复杂类型的部分字段输出、记录未导出字段通过返回一个导出字段的新 struct。logr 与 slog 的横向对比Go 标准库直到 slog 出现才补上了日志接口的空缺。README 用一张表对比了两者在关键设计上的取舍这里是忠实保留的完整对照Featurelogrslog高层 APILogger按值传递Logger按指针传递低层 APILogSinkHandler栈回溯由LogSink完成由Logger完成跳过辅助函数WithCallDepth、WithCallStackHelperLogger 层不支持按需生成日志值MarshalerLogValuer日志级别 0越大越不重要正负皆有0 为 info越大越重要错误日志条目总是记录无 verbosity级别 LevelError的普通条目通过 context 传递 loggerNewContext、FromContext无 API给 logger 加名字WithName无 API在调用链中调整 verbosityV无 API键值对分组不支持WithGroup、GroupValue从 context 提取附加值的 API无InfoCtx等变体slog 的高层 API 被定位为“可以叠加在共享slog.Handler之上的众多 API 之一”logr 正是其中之一二者通过转换函数互通详见下节。背景与灵感README 的 “Background” 一节点明了 logr 的由来如果 Go 标准库当初定义了日志接口这个项目可能就不需要了。当 Go 团队在开发 slog 时借鉴了 logr 的部分设计但也舍弃与修改了一些部分上表即完整记录。logr 的灵感来自 Dave Cheney 的博客文章双方观点大体一致差异集中在两点Dave 主张用fmt.Printf()取代日志 APIlogr 不同意并把 API 限制为仅 info 和 error 两类日志。logr 用数值 verbosity 表示 info 日志的重要度分级避免赋予 “warning/trace/debug” 这类语义名。由于 verbosity 是数值可以安全假设“verbosity 越高产生的日志越多且越不重要”。可用实现非穷举logr 只是一个 API 壳真正干活的是社区提供的各种LogSink实现。README 列出了以下生态此处仅列出名称与定位不展开外部链接funcr一个函数桥接实现可桥接到非结构化库也是本仓库 vendor 内自带的参考实现见 funcr/funcr.gotestr面向 Go 测试的testing.T实现输出 JSON 风格日志glogr桥接github.com/google/glogklogr桥接k8s.io/klogKubernetes 场景本仓库正是经由 k8s 依赖间接引入 logrktestingtesting.T实现带 klog 风格文本输出zapr桥接go.uber.org/zapstdr桥接 Go 标准库loglogger本仓库 vendor 中同样存在见 go.modlogrusr桥接github.com/sirupsen/logrusgenericr便于自研后端logfmtrlogfmtHeroku 风格输出zerologr桥接github.com/rs/zerologgokitlogr桥接github.com/go-kit/logbuflogr写入bytes.Buffer便于测试时断言值是否被记录。slog 互操作双向桥接logr 与 slog 的互操作是双向的四个函数完成两种转换FromSlogHandler把slog.Handler包成logr.Logger与ToSlogHandler把logr.Logger包成slog.Handler然后用slog.New将后者包装成高层 slog API。实现位于 slogr.go。方向一用 logr.LogSink 作为 slog 的后端理想情况下一个 logr sink 实现应同时支持两套 API既实现普通 logr 接口又实现SlogSinkslogr.go。由于Enabled方法的参数签名冲突同一个类型无法同时实现slog.Handler与logr.Sinkslogsink.go 中的slogSink就是 logr 侧为纯 slog.Handler 准备的适配器而slogHandler则是反向适配器。SlogSink接口定义type SlogSink interface { LogSink Handle(ctx context.Context, record slog.Record) error WithAttrs(attrs []slog.Attr) SlogSink WithGroup(name string) SlogSink }若 sink 同时支持两套 API日志调用可以不经参数转换直达后端FromSlogHandler/ToSlogHandler也能无额外包装地往返转换——唯一的例外是当Logger.V被用于调整 verbosity 时ToSlogHandler必须使用一个包装器来补偿后续日志调用的级别偏移对应 sloghandler.go 中slogHandler.levelBias字段与levelFromSlog的换算逻辑详见该文件 L184-L191 的注释示例。如果 sink 不支持 slog会有哪些缺陷README 完整列出了五点源码位置记录仅在经slog.Logger调用时正确其他情况可能出错——因为logr.Sink自己做栈回溯而不是用高层 API 提供的程序计数器slog 级别 0 可以无损地取负映射到 logr 级别但所有 slog 正级别如slog.LevelWarning必须映射为 0因为 logr 不支持“比 info 更重要”的级别slog 的 group 概念只能通过“在每个键名前加组名前缀点分隔”近似实现对 JSON 这类结构化输出来说理想做法是嵌套对象特殊的 slog 值与接口无法按预期工作开销很可能更高。README 的结论很直接这些缺陷足够严重混合使用 slog 与 logr 的应用应切换到支持SlogSink的后端。方向二用 slog.Handler 作为 logr 的后端反过来用纯slog.Handler不实现 logr 接口作为 logr 后端工作得更好对应slogsink.go的slogSink实现logr 的 verbosity 可以 1:1 地取负映射到对应 slog 级别栈回溯由SlogSink完成把得到的程序计数器传给slog.Handlerslogsink.go 中用runtime.Callers抓取 PC 并构造slog.NewRecordWithName累积的名字被收集到logger键中多个名字以/分隔作为额外属性Logger.Error被转换为slog.LevelError的记录若提供了 error 则附加err键。主要缺点logr.Marshaler不被支持。类型最好同时实现logr.Marshaler与slog.Valuer若不需要兼容不支持 slog 的 logr 实现只实现slog.Valuer就足够了。slog 的 context 支持弥补标准库空白slog 本身不支持把 logger 存入context.Context。logr 用NewContextWithSlogLogger与FromContextAsSlogLogger补齐这个能力且与 logr 自己的NewContext/FromContext共用同一个 context keycontext_slog.goNewContextWithSlogLogger之后调用FromContext后者会自动把slog.Logger转换成logr.LoggerFromContextAsSlogLogger则处理反向context 中存的是logr.Logger时自动转成*slog.Logger。API 存的是slog.Logger指针而非slog.Handler这是为了零额外分配若存 Handler取出时创建slog.Logger必然产生一次分配。代价是来回切换会引入更多分配。由于 logr API 已被大量包尤其 Kubernetes 生态采用README 的建议是在需要上下文日志的代码里优先使用logr.LoggerAPI。另一条可选路径是把附加值直接存在 context 里、让后端在输出时提取如slog-context这类包提供的思路但 logr API 本身不把 context 传入日志调用因此这条路仅在 slog API 侧可行。FAQ设计决策背后的 WhyREADME 的 FAQ 部分回答了日志设计中最常被追问的问题这里是完整保留并加以展开的答案。概念类问题为什么用结构化日志四点理由更易查询键值对结构让日志可按键值过滤例如在请求日志里搜错误码在 Kubernetes reconciler 日志里按对象名与命名空间检索更易交叉引用统一键名约定后可以聚合出与某个概念相关的所有日志行过滤维度更精细可以精确控制记录哪些键、只记录某键匹配某值的行而不只是靠 V 级别和名字更好地表达结构化数据日志数据本身可能是结构化的如元组对象结构化日志能在输出时保留这种结构。为什么用 V 级别给运维人员一个控制日志啰嗦程度的简易旋钮某个包如果日志太多用户只需调整该库对应的 V 级别即可无需改动代码。为什么不用 Info/Warning/Error 这类命名级别见前文 “Differences from Daves ideas”本质是避免为数值赋予语义含义。为什么不用格式化字符串格式化字符串会摧毁结构化日志的四大好处难以搜索只能模糊匹配或正则、无法良好存储结构化数据内容被摊平成字符串、无法交叉引用、不易压缩消息不恒定。除非你把位置参数改成带数字键的键值对——那就得到了带无意义键的键值日志。实操类问题为什么用键值对而不是 map键值对更容易做分配相关的性能优化。zaplogr 接口的灵感来源之一有公开的性能数据支撑这一结论。接口虽然看起来没那么直观但性能更好也免去用户每次打日志都敲map[string]string{}的负担。不同库的 V 级别不一致怎么办没关系。按 logger 粒度控制 V 级别用WithName给不同库传递不同 logger不过仍建议在同一个 logger 内部保持 V 级别相对一致这样更容易决定请求多高的 verbosity。实在想用格式化字符串官方给了一套心智迁移步骤用 TL;DR 风格概括“错误到底是什么”作为消息message原先每个格式符位置看它前面的词把“词”作为键、把值作为值。两个来自 Kubernetes 代码库的真实改造示例完整保留原文klog.V(4).Infof(Client is returning errors: code %v, error %v, responseCode, err)改为logger.Error(err, client returned an error, code, responseCode)klog.V(4).Infof(Got a Retry-After %ds response for attempt %d to %v, seconds, retries, url)改为logger.V(4).Info(got a retry-after response when requesting url, attempt, retries, after seconds, seconds, url, url)如果确实必须用格式化字符串就把结果放进某个键的值里自己调用fmt.Sprintflogger.Info(unable to reflect over type, type, fmt.Sprintf(%T))。README 提醒这类需求应当极少。如何选择 V 级别唯一的硬约束是V 级别越高日志越啰嗦、越偏 debug。起步建议0 “永远想看”1 “常见日志、可能想关掉”10 “我想压力测试日志收集栈”。然后从两端向中间填充从 10 往下是 debug/trace 类从 1 往上是更啰嗦的 info 类。作为参照slog 预定义 -4 为 debug对应 logr 的 4这也与 Kubernetes 社区的推荐一致。如何选择键名键名很灵活几乎任何字符串都行但为了与实现兼容、与既有代码保持一致建议键名要人类可读键名最好是常量整个代码库保持一致键名应与消息文本自然呼应简单键用小写复杂键用 lowerCamelCaseKubernetes 即采用此约定。键名基本不受限制空格也可接受但最好只用可打印 ASCII或至少与日志行的字符集保持一致。为什么键必须是常量值键就是每条日志消息的schema。同一行日志在不同实例间使用不同键会让后续的日志处理难以为继。Sprintf()是给值用的不是给键用的。为什么 Logger 不是纯接口前面已述struct 结构让 Go 编译器能够优化未触发的高 VInfo调用所有实质工作仍沉淀在LogSink接口之后。附funcr——随仓库自带的格式化参考实现本仓库 vendor 中完整携带了 logr 的官方参考实现 funcrfuncr/funcr.go它是理解“如何写一个 LogSink”的最佳样例提供两个构造函数// New 通过任意写入函数输出文本日志 func New(fn func(prefix, args string), opts Options) logr.Logger // NewJSON 产生 JSON 输出 func NewJSON(fn func(obj string), opts Options) logr.LoggerOptions中值得关注的参数funcr/funcr.goLogCaller是否附加caller键有开销、LogCallerFunc额外记录调用函数名、LogTimestamp附加ts键、TimestampFormat时间格式使用 Gotime.Layout、LogInfoLevelinfo 级别的键名设为则不输出、Verbosity启用哪些 V 级别、RenderBuiltinsHook渲染内建键值对前的钩子可审计或改写数据。格式化时会尊重logr.Marshaler、fmt.Stringer与error接口渲染 struct 时使用 Go 标准 JSON tagstring除外。funcr 还提供了支持 slog 的 slogsink.go与上文的SlogSink互操作设计一一对应。logr 在 buildkit 中的实际位置在 buildkit 仓库中logr 并非被业务代码直接 import而是以间接依赖的方式随 Kubernetes 相关依赖链进入项目go.mod声明了github.com/go-logr/logr v1.4.4 // indirect与github.com/go-logr/stdr v1.2.2 // indirect见 go.modvendor 中实际使用它的包括k8s.io/klog/v2其klogr、contextual.go等文件直接依赖 logr以及 OpenTelemetry、go-tuf 等组件的内部日志模块。buildkit 自身的日志主链路如 util/bklog/log.go基于 logrus 与 containerd/log 的上下文注入模式属于“各取所需”的典型多实现共存场景——这恰好印证了 logr 的设计初衷为一个生态内的不同组件提供统一的日志抽象使它们可以在同一进程中装配各自的实现而互不干扰。你可以通过go doc github.com/go-logr/logr在本仓库环境下查看完整的 API 文档或直接阅读 vendor/github.com/go-logr/logr/logr.go 的包级注释其中包含了全部用法示例与键名规范。【免费下载链接】buildkitconcurrent, cache-efficient, and Dockerfile-agnostic builder toolkit项目地址: https://gitcode.com/GitHub_Trending/bu/buildkit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表