Files
Inbox/Go项目实战/03_基础设施/02_日志/04_架构逻辑.md
2025-12-11 07:24:36 +08:00

6.7 KiB
Raw Blame History

tags, aliases, date created, date modified
tags aliases date created date modified
1. 代码组织方式 (Code Organization)
星期三, 十二月 10日 2025, 10:42:21 晚上 星期三, 十二月 10日 2025, 11:38:44 晚上

1. 代码组织方式 (Code Organization)

我们将遵循 “高内聚、低耦合” 的原则,将日志模块放置在 internal/pkg/log 下。这里是所有日志逻辑的物理家园。

建议的文件结构如下(逻辑分层):

  • log.go (Facade/Entry Point):
    • 这是对外暴露的统一入口。包含全局单例的定义、初始化函数 (Init)、以及最常用的静态方法代理(如 Info, Error, WithContext)。
    • 设计意图: 让其他模块只 import 这一个包就能完成 90% 的工作。
  • options.go (Configuration):
    • 定义配置结构体Level, Filename, MaxSize, MaxAge 等)。
    • 设计意图: 将配置解析逻辑与日志初始化逻辑分离,方便单元测试。
  • zap.go (Core Implementation):
    • 负责 zap.Logger 的具体构建。包含 Encoder 配置JSON vs Console、Writer 配置Lumberjack 集成)和 Level 动态调整逻辑。
    • 这是“脏活累活”集中的地方,屏蔽 Zap 的复杂构建细节。
  • context.go (The Bridge):
    • 核心组件。实现 TraceID 的提取逻辑。
    • 定义如何从 context.Context 中挖掘元数据,并将其转化为 zap.Field

2. 调用方式与依赖注入 (Invocation & DI)

这里有一个经典的架构冲突:Singleton单例 vs Dependency Injection依赖注入。我们的策略是 “依赖注入为主,单例为辅”,但在具体使用上有一个极其重要的反直觉设计

A. 为什么 Service 层不应保存 Request Logger

你可能会想在 Service 初始化时注入一个带 Context 的 Logger。

  • 错误做法: type UserService struct { logger *zap.Logger },然后在请求进来时试图把 request-scoped 的 logger 塞进去。
  • 架构事实: 在 Wire 依赖注入中,ServiceRepository 通常是 单例 (Singleton) 的(即整个应用生命周期只有一个实例)。
  • 结论: 你不能把属于某一次 HTTP 请求的 TraceID 注入到单例的 Struct 成员变量中。

B. 正确的调用范式 (The Best Practice)

Logger 作为工具能力被注入Context 作为请求参数被传递。

  1. 依赖注入 (Setup Phase):

    • NewUserUsecase 时,注入基础的 *zap.Logger(不带 TraceID
    • 这个 Logger 配置好了输出路径、Level 等全局属性。
  2. 方法调用 (Runtime Phase):

    • 在具体的方法(如 Register)中,使用 log.WithContext(ctx) 来“临时”生成一个带有 TraceID 的 Logger 实例。

示例逻辑流:

  • Struct 定义: struct { baseLogger *zap.Logger }
  • 方法内部: l := log.WithContext(ctx, u.baseLogger) -> l.Info("user registered")
  • 说明: 这里的 WithContext 是一个纯内存操作(浅拷贝),开销极小,可以放心高频调用。

C. 高性能场景:作用域复用 (Scoped Logger)

虽然 log.WithContext 是浅拷贝,但在循环或长链路中频繁调用仍会产生大量临时对象,增加 GC 压力。

  • 反模式 (Anti-Pattern): 在 for 循环内部调用 log.WithContext(ctx)
  • 最佳实践 (Best Practice): 作用域提升。在函数或循环入口处调用一次 WithContext,生成局部变量 l (Logger),随后全程复用该变量。

3. 数据流与 TraceID 传递 (Data Flow)

这是实现“全链路可观测性”的生命线。数据流必须打通以下四个关卡:

关卡 1入口 (Entry - Middleware)

  • 位置: internal/middleware/trace.go (需新建) 或集成在 response 包中。
  • 行为: 当 HTTP 请求到达,生成一个 UUID。
  • 动作: 使用 c.Set("X-Trace-ID", uuid) 将其放入 Gin 的上下文存储中。同时,将其放入 HTTP Response 动作:
    1. 调用 pkg/log.WithTraceID(ctx, uuid)UUID 注入标准 Context
    2. 执行 c.Request = c.Request.WithContext(newCtx) 将其回写。
    3. (可选) 同时放入 Gin 上下文存储和 Response Header 供前端使用。

关卡 2桥接 (Bridge - Context Adapter)

  • 位置: internal/pkg/log/context.go
  • 设计原则: pkg/log 不依赖 gin,只识别标准库 context.Context
  • 行为: log.WithContext(ctx) 调用内部帮助函数 GetTraceID(ctx) 获取 TraceID。
  • 前置条件: 必须依赖上游Middleware将 TraceID 提前注入到标准 Context 中。
  • 输出: 返回一个预置了 zap.String("trace_id", id) 字段的 Logger。

关卡 3穿透 (Propagation - Service/Repo)

  • 行为: 所有的业务方法签名必须包含 ctx context.Context 作为第一个参数。
  • 动作: 严禁在层级调用中丢弃 Context例如使用 context.Background() 替代传入的 ctx这会导致链路断裂。

关卡 4异步与后台边界 (Async & Background Boundary)

  • 高危场景: 在 Handler 中启动 Goroutine 处理耗时任务。
  • 陷阱: gin.Context 是非线程安全的。如果 Goroutine 执行时 HTTP 请求已结束Gin 会重置该 Context导致数据竞争或脏读。
  • 解决方案: 必须在主协程中执行 ctx.Copy(),将副本传递给 Goroutine。日志模块必须支持处理这种副本 Context。
  • 新增场景:后台任务 (Background Tasks)
    • 场景: 定时任务 (Cron)、消息队列消费者 (MQ Consumer)、系统初始化。
    • 问题: 初始 context.Background() 不包含 TraceID。
    • 动作: 必须调用 log.StartBackgroundTrace(ctx) 进行“播种”。该函数会检测 Context若无 TraceID 则生成新 ID 并注入,确保链路可追踪。

4. 关键架构思考:防腐层 (Anti-Corruption Layer)

我们在设计时还需考虑一层“防腐”。

  • 问题: 如果未来我们想给所有的日志加一个字段,比如 env=prod,或者想把所有的 trace_id 改名为 traceId
  • 对策: 所有的业务代码严禁直接手动构建 zap.String("trace_id", …)
  • 约束: 这个字段的 Key 必须定义在 pkg/log 的常量中,且只能由 WithContext 内部逻辑自动附加。业务开发者只负责传 Context不负责管 ID 怎么拼写。

总结

  • 代码位置: internal/pkg/log,包含 log.go (入口), zap.go (实现), context.go (桥接)。
  • 调用方式: 注入 Base Logger -> 方法内 WithContext(ctx) -> 打印。
  • 数据流: Middleware 生成 -> Gin Context 携带 -> Log Adapter 提取 -> Zap Field 输出。
  • 并发安全: 警惕 Gin Context 在 Goroutine 中的误用,强调 Copy() 机制。