6.7 KiB
6.7 KiB
tags, aliases, date created, date modified
| tags | aliases | date created | date modified | |
|---|---|---|---|---|
|
星期三, 十二月 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 依赖注入中,
Service、Repository通常是 单例 (Singleton) 的(即整个应用生命周期只有一个实例)。 - 结论: 你不能把属于某一次 HTTP 请求的
TraceID注入到单例的 Struct 成员变量中。
B. 正确的调用范式 (The Best Practice)
Logger 作为工具能力被注入,Context 作为请求参数被传递。
-
依赖注入 (Setup Phase):
- 在
NewUserUsecase时,注入基础的*zap.Logger(不带 TraceID)。 - 这个 Logger 配置好了输出路径、Level 等全局属性。
- 在
-
方法调用 (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 动作:- 调用
pkg/log.WithTraceID(ctx, uuid)将UUID注入标准Context。 - 执行
c.Request = c.Request.WithContext(newCtx)将其回写。 - (可选) 同时放入 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()机制。