Files
pig-farm-controller/design/provide-logger-with-mothed/implementation.md
2025-11-03 20:39:31 +08:00

6.7 KiB

实现方案:纯 Context 驱动的调用链追踪

本方案旨在提供一个绝对安全、符合 Go 语言习惯且对业务代码侵入性最小的调用链追踪方案。其核心思想是:调用链信息完全由标准的 context.Context 承载,并通过 logs 包提供的一系列无状态包级函数进行原子化、安全的操作。

1. 核心设计原则

  • Context 是唯一载体: 所有的调用链信息,包括组件名(对象名)和函数名(方法名),都只存储在标准的 context.Context 中。我们不再定义任何自定义的 Context 接口,以保证最大的兼容性。

  • 纯粹的包级函数: logs 包将提供一系列纯粹的、无状态的包级函数,作为与 Context 交互的唯一 API。这些函数负责向 Context 中添加信息,或从 Context 中生成 Logger

  • 无状态的 Logger: Logger 对象本身不再携带任何调用链信息。它在被 logs.GetLogger(ctx) 生成时,才被一次性地赋予包含完整调用链的配置。

2. 实现细节

a. Context 中存储的数据

context.Context 将通过 context.WithValue 在幕后存储两种核心信息,这两种信息都使用 logs 包内部的私有 key 类型,以避免与其他包的键冲突。

  • 组件名 (compNameKey): 用于存储一个字符串,表示当前上下文环境属于哪个组件(例如 \"组件1\")。
  • 调用链 (chainKey): 用于存储一个字符串切片 ([]string),记录了从请求开始到当前位置的完整调用路径(例如 [\"组件2.Create\", \"组件1.Create\"])。

b. logs 包提供的核心 API

logs 包需要对外提供以下四个核心的包级函数,以提供不同粒度的灵活性:

  1. logs.AddCompName(ctx, compName) context.Context

    • 职责: 将一个组件名(对象名)存入 Context,并返回一个包含该信息的新 Context。这通常在依赖注入时完成,用于创建组件的“身份名牌” selfCtx
    • 实现细节: 该函数接收一个 context.Context 和一个 compName 字符串。它内部使用 context.WithValue,以私有的 compNameKey 为键,将 compName 字符串存入 Context,然后返回这个全新的 Context
  2. logs.AddFuncName(upstreamCtx, selfCtx, funcName) context.Context

    • 职责: 这是构建调用链的核心原子操作。它智能地合并上游的调用链和当前组件的信息,生成并返回一个包含更新后调用链和当前组件名的 Context。此函数用于只需要传递调用链而不需要立即打印日志的场景。
    • 实现细节:
      1. 函数接收 upstreamCtx, selfCtx, funcName
      2. 获取上游调用链: 从 upstreamCtx 中,通过 chainKey 读取出已经存在的调用链(oldChain []string)。
      3. 获取当前组件名: 从 selfCtx 中,通过 compNameKey 读取出当前组件的名称(compName string)。
      4. 构建新节点: 将 compNamefuncName 拼接成一个新节点(例如 \"组件2.Create\")。
      5. 生成新调用链: 将这个新节点追加到 oldChain 的末尾,形成 newChain []string
      6. 创建新的 Context:
        • 使用 context.WithValue,以 chainKey 为键,将 newChain 存入一个新的 Context,我们称之为 tmpCtx
        • 接着,(关键修正) 基于 tmpCtx,再次调用 context.WithValue,以 compNameKey 为键,将从 selfCtx 中获取的 compName 存入,得到最终的 newCtx。这确保了传向下游的 Context 正确地标识了当前组件。
      7. 返回: 返回 newCtx
  3. logs.GetLogger(ctx) *Logger

    • 职责: 从 Context 中生成最终的、可用于打印的 Logger 实例。
    • 实现细节:
      1. 函数接收一个 context.Context
      2. 它从 ctx 中,通过 chainKey 读取出完整的调用链 []string
      3. 如果调用链不存在或为空,它就生成一个不带 trace 字段的普通 Logger 实例并返回。
      4. 如果调用链存在,它就用 -> 符号将切片中的所有节点拼接成一个完整的 trace 字符串。
      5. 最后,它创建一个一次性Logger 实例,将这个 trace 字符串和底层的 zap 配置传给它,然后返回这个准备就绪的 Logger
  4. logs.Trace(upstreamCtx, selfCtx, funcName) (context.Context, *Logger)

    • 职责: 作为 AddFuncNameGetLogger 的便捷封装,一步到位地完成调用链构建和 Logger 生成。用于需要立即打印日志的场景。
    • 实现细节:
      1. 内部调用 newCtx := logs.AddFuncName(upstreamCtx, selfCtx, funcName)
      2. 内部调用 logger := logs.GetLogger(newCtx)
      3. 返回 newCtxlogger

3. 最终使用模式

a. 依赖注入阶段

(保持不变)在应用启动和组装依赖时,我们不再注入 Logger 对象,而是为每个组件创建一个包含其自身名称的专属 Context,并将这个 Context 注入到组件实例中。

  • 流程:
    1. 在依赖注入的根源处,创建一个全局的、初始的 context.Background()
    2. 对于需要被追踪的组件(例如 组件1),调用 logs.AddCompName(ctx, \"组件1\") 来创建一个 ctxForC1
    3. 在创建 组件1 的实例时,将这个 ctxForC1 作为其成员变量(例如 selfCtx)保存起来。这个 selfCtx 就成了 组件1 的“身份名牌”。

b. 请求处理阶段

开发者可以根据需求灵活选择 API。

  • 场景一:需要立即打印日志 (推荐):

    1. 在方法入口处,立即调用 ctx, logger := logs.Trace(upstreamCtx, z.selfCtx, \"Create\")
    2. 使用这个 logger 打印日志:logger.Info(\"创建组件2\")
    3. 当需要调用下游方法时,将返回的 Context (ctx) 传递下去。
  • 场景二:只需要传递调用链:

    1. 在方法入口处,调用 ctx := logs.AddFuncName(upstreamCtx, z.selfCtx, \"Create\")
    2. 这个方法本身不打印日志,但在调用下游方法时,将这个包含了更新后调用链的 Context (ctx) 传递下去。
  • 场景三:在方法中间打印日志:

    1. 一个方法可能在执行了一部分逻辑后才需要打印日志。
    2. ctx := logs.AddFuncName(upstreamCtx, z.selfCtx, \"Create\") // 先在入口更新调用链
    3. // ... 执行一些业务逻辑 ...
    4. logger := logs.GetLogger(ctx) // 在需要时,基于更新后的 ctx 获取 logger
    5. logger.Info(\"业务逻辑执行到一半\")
    6. // ... 继续执行并传递 ctx ...