Files
Inbox/Go项目实战/03_基础设施/全局日志模块 (Global Logging Infrastructure) 详细设计说明书.md
2025-12-11 07:24:36 +08:00

865 lines
46 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
tags: []
aliases:
- Project Context Aggregation
date created: 星期三, 十二月 10日 2025, 11:02:10 晚上
date modified: 星期三, 十二月 10日 2025, 11:50:40 晚上
---
# Project Context Aggregation
> Source Items: 1
==== 02_ 日志\01_ 设计目标.md ====
```markdown
---
tags: []
aliases:
- 1. 核心设计目标 (Core Design Goals)
date created: 星期三, 十二月 10日 2025, 10:27:39 晚上
date modified: 星期三, 十二月 10日 2025, 10:28:15 晚上
---
# 1. 核心设计目标 (Core Design Goals)
## 目标一:全链路上下文关联 (Contextual Traceability)
这是最核心的差异点。传统的 `log.Println("Database error")` 在并发环境下毫无价值,因为你不知道这条错误属于哪个请求。
- **设计要求**:
- **自动注入 TraceID**: 必须能够从 `context.Context` 中提取 `TraceID`(目前 `internal/pkg/app` 已经生成了 TraceID并自动将其附加到每一条日志中。
- **请求元数据绑定**: 除了 TraceID还应支持自动绑定 `UserID``IP``Method``Path` 等元数据,形成请求的完整快照。
- **跨组件穿透**: 日志对象必须能够在 Layer 之间传递(例如 Controller -> Service -> Repository且保持上下文不丢失。
## 目标二:严格的结构化契约 (Strict Structured Schema)
日志是写给机器看的,不是写给通过 SSH 连上服务器的人看的。
- **设计要求**:
- **JSON First**: 生产环境强制使用 JSON 格式。
- **Schema 统一**: 字段命名必须统一。例如,不要混用 `uid`, `user_id`, `userId`,必须在设计阶段锁定为 snake_case (如 `user_id`)。
- **类型安全**: 时间戳必须统一格式(推荐 ISO8601 或 Unix Nano数字字段不能变成字符串便于聚合计算
## 目标三:高性能与零侵入 (High Performance & Zero Allocation)
日志通常是系统中 IO 最密集的组件之一。
- **设计要求**:
- **低 GC 压力**: 利用 Zap 的核心优势,避免大量的 `interface{}` 反射和字符串拼接,使用强类型的 Field`zap.Int`, `zap.String`)。
- **异步 IO (可选)**: 考虑是否引入 Buffer 机制(牺牲极端崩溃下的日志完整性换取吞吐量)。
- **Level 级联过滤**: 在 Debug 级别关闭时Debug 级别的日志构造逻辑(如复杂的对象序列化)不应被执行。
## 目标四:安全与合规 (Security & Compliance)
这往往是被忽视的一点,也是导致安全事故的频发区。
- **设计要求**:
- **敏感数据脱敏**: 必须具备“黑名单”机制。任何包含 `password`, `token`, `mobile`, `credit_card` 的字段在输出前必须被自动掩盖Masking
- **安全截断**: 防止打印过大的 Body如 Base64 图片上传)导致磁盘爆满或日志系统瘫痪,限制单条日志最大长度。
---
# 2. 场景化行为对比 (Dev Vs Prod)
为了兼顾开发体验和生产运维标准,我们需要在设计中明确区分两种环境的行为。
|**维度**|**开发环境 (Development)**|**生产环境 (Production)**|**设计意图**|
|---|---|---|---|
|**编码格式**|Console (彩色,人类易读)|JSON (机器易读)|开发追求直观;生产追求 ELK 解析效率。|
|**输出目标**|Stdout (控制台)|File + Stdout (双写)|开发侧容器即焚;生产侧需持久化 + 容器采集。|
|**日志级别**|Debug|Info / Warn|生产环境过滤掉大量 Debug 噪音,节省存储成本。|
|**堆栈追踪**|Error 级别即打印|Panic 或 Fatal 才打印|减少生产环境日志体积,除非发生严重故障。|
|**调用行号**|显示 (Caller)|显示 (Caller)|快速定位代码位置。|
---
# 3. 架构定位与边界 (Architecture Boundary)
我们需要明确日志模块在架构中的位置:
- **位置**: 属于 `Infrastructure Layer` (Level 0/1)。
- **依赖关系**:
- **被谁依赖**: 所有层Handler, Service, Repository都依赖 Log。
- **依赖谁**: 仅依赖标准库和第三方 Log Driver (Zap)**不应依赖业务逻辑**。
- **与其他模块的关系**:
- **vs `ecode`**: `ecode` 定义错误的**类型**CodeLog 记录错误的**现场**Stack/Trace
- **vs `app.Response`**: Response 负责**对用户说话**经过清洗的、友好的信息Log 负责**对开发者说话**(原始的、包含脏数据的真相)。
---
# 4. 深度反思与自我反驳 (Critical Thinking & Risk Analysis)
在敲定设计目标前,必须审视潜在的矛盾和风险:
**反驳点 1全链路上下文TraceID的传递成本**
- **挑战**: 要想让 Repository 层的日志也打出 TraceID必须修改所有方法的签名为 `func (ctx context.Context, …)`。这对现有代码(如果是非 Context 风格)是巨大的重构。
- **回应**: 我们的 `Repository` 接口目前设计中已经包含了 `context.Context`。这是一个必须遵守的“硬约束”。如果缺少 Context日志将断层。
- **结论**: 必须在规范中强调:**所有层级的方法首个参数必须是 Context**。
**反驳点 2脱敏机制的性能损耗**
- **挑战**: 如果每一条日志都要遍历字段去匹配“黑名单”进行正则替换CPU 开销极大。
- **回应**: 不能使用正则扫描全文。
- **修正方案**: 利用 Zap 的 `Hook``Core` 包装,仅针对特定 Key`password`)进行值替换,或者要求开发者在打印敏感结构体时显式调用 `.Reduct()` 方法,而非隐式全局扫描。隐式扫描在 Golang 中通常是性能杀手。
**反驳点 3异步写入的丢数据风险**
- **挑战**: 为了性能使用 `Buffered Write`,如果进程被 `kill -9` 或 Panic 崩溃,缓冲区日志会丢失,而崩溃前的日志往往最重要。
- **回应**: 对于交易类系统,**可靠性 > 吞吐量**。
- **结论**: 默认采用 **同步写入 (Sync Write)**。Zap 本身性能已经足够强(纳秒级),除非达到数万 QPS否则不需要引入 Buffer。对于 Panic必须使用 `defer logger.Sync()` 确保刷盘。
---
# 5. 待确认问题 (Open Questions)
在进入下一步(编写详细规格说明书)之前,我需要确认你对以下几点的偏好:
1. **全局单例 vs 纯依赖注入**:
- _选项 A_: 提供 `log.Info()` 全局静态方法(方便,但有副作用)。
- _选项 B_: 强制必须通过 `l.Info()` 实例方法调用(架构更洁癖,但调用繁琐)。
- _推荐_: **选项 A + B**。提供全局方法作为快捷方式(底层代理到单例),同时支持 DI 注入。你倾向于哪种?
2. **日志轮转 (Rotation) 策略**:
- 你是倾向于按 **大小** 切割(如 100MB 一个文件),还是按 **时间** 切割(每天一个文件)?
- _通常建议_: 按大小切割(防止单文件过大无法打开),配合最大文件保留数。
```
==== 02_ 日志\02_ 技术栈基线.md ====
```markdown
---
tags: []
aliases:
- 1. 核心引擎 (The Engine)Uber Zap
date created: 星期三, 十二月 10日 2025, 10:28:15 晚上
date modified: 星期三, 十二月 10日 2025, 10:29:20 晚上
---
# 1. 核心引擎 (The Engine)Uber Zap
行业共识 (Consensus):
在 Go 语言的高性能后端领域go.uber.org/zap 是目前无可争议的事实标准De Facto Standard
我的推荐:
坚定地使用 Zap不要犹豫。
**老兵的经验谈 (Why & How):**
- **为何不是 Logrus?** Logrus 胜在 API 极其友好兼容标准库但它底层大量使用反射Reflection和锁在高并发场景下是严重的性能瓶颈GC 压力大)。
- **为何不是 Slog (Go 1.21+)?** Slog 是 Go 官方推出的结构化日志接口。虽然它是未来,但目前的生态和性能优化(尤其是在 JSON 序列化的极致性能上)尚未完全超越 Zap。且 Zap 可以很方便地作为 Slog 的 Backend。但在本项目中为了追求极致性能和成熟度直接使用 Zap 原生 API 是最高效的。
- **关键决策点**:
- **Field 强类型**: 我们必须强制团队使用 `zap.String("key", "val")` 而非 `zap.Any("key", val)``Any` 会导致反射,破坏 Zap 的零内存分配Zero Allocation优势。这是代码审查Code Review的红线。
- **Logger vs SugaredLogger**:
- **核心业务链路 (Hot Path)**: 使用 `zap.Logger`(极致性能,但语法繁琐)。
- **初始化/非热点代码**: 使用 `zap.SugaredLogger`(语法类似 `printf`,性能稍弱但开发快)。
- **基线**: 我们的封装层默认暴露 `Logger` 能力,保留高性能入口。
# 2. 轮转插件 (Rotation): Lumberjack V2
行业共识 (Consensus):
日志切割看似简单,实则坑多(并发写冲突、文件重命名原子性、不同操作系统的文件锁差异)。
我的推荐:
使用 gopkg.in/natefinch/lumberjack.v2。
**老兵的经验谈:**
- **不要造轮子**: 我见过无数团队尝试自己写 `file.Write` 然后计数切割,最后都在“多进程并发写同一个日志文件”或者“日志压缩时导致 IO 飙升”这些问题上翻车。
- **配置陷阱**:
- `MaxSize`: 建议 **100MB**。太小导致文件碎片化,太大导致像 grep/vim 这种工具打开困难。
- `MaxBackups`: 建议保留 **30-50 个**
- `MaxAge`: 建议 **7-14 天**
- **Compress**: 建议 **开启 (True)**。历史日志压缩存储gzip能节省 90% 以上的磁盘空间,这对于云盘成本控制非常重要。
# 3. 上下文管理 (Context Awareness): 自研封装层
这是我们作为“架构师”必须介入的地方。原生 Zap 不懂业务上下文,我们需要一个胶水层。
技术难点:
如何优雅地把 TraceID 塞进每一行日志?
设计路线:
我们需要定义一个轻量级的 Wrapper 或者 Helper 函数。
- **不要**:重写 `zap.Logger` 结构体的所有方法(那样维护成本太高)。
- **要**:提供一个入口函数,例如 `log.WithContext(ctx)`
- **原理**:这个函数会从 `ctx` 取出 `TraceID`,然后调用 `zap.With(zap.String("trace_id", id))`,返回一个携带了该字段的子 Logger 实例。这是一次极低成本的指针操作。
# 4. 抽象策略与混合模式 (Hybrid Pattern)
结合你选择的 **Option A+B**,我们的技术实现路径如下:
1. **全局变量 (The Global)**:
-`internal/pkg/log` 包内部维护一个私有的 `var globalLogger *zap.Logger`
- 利用 `sync.Once` 确保其并发安全的初始化。
- **兜底策略**: 在 `init()` 函数中先给它一个默认的 `Console Logger`。这样即使开发者忘记调用 `InitLogger`,程序启动时的日志也不会 panic只会打印到控制台。
2. **依赖注入 (The DI)**:
-`internal/pkg/log` 暴露一个 `Provider` 函数,供 Wire 使用。
- 这个 Provider 返回的必须是**同一个**底层 Zap 实例的引用(或者其派生),确保配置(如 Level、Output Path是一致的。
---
# 总结:最终的技术栈清单
|**组件**|**选型**|**理由**|
|---|---|---|
|**Logger Core**|`go.uber.org/zap` (v1.27+)|高性能、类型安全、零内存分配。|
|**Rotation**|`gopkg.in/natefinch/lumberjack.v2`|成熟稳定,处理并发写文件不仅是事实标准,更是避坑指南。|
|**Config**|`spf13/viper` (已引入)|利用现有的 Viper 读取 yaml 配置,实现动态等级调整。|
|**Trace Inject**|`Custom Wrapper` (Standard Lib)|基于 `context` 的轻量封装,连接 `gin.Context``zap.Fields`。|
老师的最后叮嘱 (The Moral of the story):
我们现在的设计,本质上是在 Zap 的高性能 和 业务开发的便利性 之间走钢丝。
最大的风险在于:封装层写得太重。
如果我们在 log.Info 里面加了太多的锁、反射或者字符串拼接,那么引入 Zap 的意义就没了。所以,接下来的详细设计文档中,我们要时刻警惕“过度封装”。
如果这个技术栈基线你没有异议,我们就以此为基础,开始生成《全局日志模块详细设计规格说明书》。
```
==== 02_ 日志\03_ 核心设计模式.md ====
```markdown
---
tags: []
aliases:
- 1. 核心模式:装饰器模式的变体 (Context-Decorator Pattern)
date created: 星期三, 十二月 10日 2025, 10:37:54 晚上
date modified: 星期三, 十二月 10日 2025, 10:38:26 晚上
---
# 1. 核心模式:装饰器模式的变体 (Context-Decorator Pattern)
这是我们处理 `TraceID` 和上下文的核心手段。
- 传统误区 (Over-Abstraction)
定义一个庞大的 MyLogger 结构体,把 zap.Logger 藏在里面,然后重写 Info, Error 等所有方法。
- _后果_维护成本极高每次 Zap 更新或增加新特性(如 `Panic``DPanic`你都得跟着改代码。且容易在转发参数时产生逃逸分析Escape Analysis导致的内存分配。
- 我们的决策 (The Thin Wrapper)
只封装“获取 Logger”的动作不封装“Logger 本身”。
我们将定义一个函数 log.WithContext(ctx context.Context) *zap.Logger。
- _行为_这个函数极其轻量。它从 `ctx` 中取出 `TraceID`,调用 `zap.With()` 生成一个新的 Zap 实例并返回。
- _优势_业务代码拿到的依然是原生的 `*zap.Logger`。这意味着开发者可以直接使用 Zap 强大的 `zap.String`, `zap.Int` 等强类型字段构建方法,享受极致性能,没有任何中间层损耗。
# 2. 接口策略:拒绝通用接口 (Concrete Type Dependency)
这是 Go 语言工程实践中关于日志的一个特殊共识,也是反直觉的地方。
- 传统误区 (The Java/Interface Way)
定义一个 type ILogger interface { Info(msg string, args …interface{}) }。
- _后果_`args …interface{}` 会导致大量的反射Reflection和装箱Boxing这直接抹杀了 Zap 存在的意义。Zap 的核心设计哲学就是通过 `zap.Field` 避免使用 `interface{}`
- 我们的决策 (Concrete Type)
直接依赖 *zap.Logger 具体类型。
- _原则_在 Handler、Service、Repository 层,注入的类型就是 `*zap.Logger`
- _测试怎么办_不要 Mock 日志接口。在单元测试中,直接传入 `zap.NewNop()`(什么都不做)或者 `zap.NewExample()`(输出到测试控制台)。这比 Mock 一个接口要简单且真实得多。
# 3. 访问模式:混合单例与依赖注入 (The Hybrid Accessor)
结合之前讨论的 Option A+B我们通过设计模式来解决“初始化顺序”和“热加载”的问题。
- 设计挑战:
如果 main.go 还没来得及读配置初始化 Logger其他 init() 函数里就调用了日志,程序会 Panic。
- **我们的决策 (Thread-Safe Proxy)**
- **原子替换 (Atomic Swap)**:全局变量 `globalLogger` 不会直接暴露给外部修改。我们将使用 `unsafe.Pointer``atomic.Value` (配合 Zap 的 `ReplaceGlobals`) 来保证在运行时重新加载配置(如动态修改 Log Level不会发生并发读写冲突。
- **懒汉式兜底 (Lazy Fallback)**:在 `internal/pkg/log``init()` 中,我们会默认初始化一个 `Console Logger`。这样即使 `main` 函数一行代码都没跑只要引用了包日志功能就是可用的虽然配置是默认的。这极大提升了开发体验DX
# 4. 字段构建模式:结构化优先 (Field-First API)
这关乎团队的编码规范,属于 API 设计模式。
- 传统误区 (Printf Style)
使用 SugaredLogger 的 Infof("User %s login failed, error: %v", user, err)。
- _后果_日志分析系统ELK只能拿到一串文本无法对 `user` 进行聚合统计。
- 我们的决策 (Structured Style)
默认只暴露 Logger强类型在必要时才暴露 SugaredLogger。
- _强制规范_代码中必须写成 `log.Info("user login failed", zap.String("user", user), zap.Error(err))`
- _设计意图_通过 API 的设计,“强迫”开发者思考每一个字段的语义。这虽然写起来繁琐一点,但对于后期的运维和排查是无价的。
---
# 总结:设计规格书的基调
基于以上讨论,在接下来的规格说明书中,我们将确立以下基调:
1. **不造轮子**:核心逻辑全权委托给 `zap``lumberjack`
2. **薄封装**`pkg/log` 代码行数应控制在 200 行以内,只做配置解析和 Context 桥接。
3. **强类型**:严禁在核心路径使用 `interface{}`
4. **显式传递**:通过 `WithContext` 显式传递上下文,而不是依赖某些黑魔法(如 Goroutine Local Storage
```
==== 02_ 日志\04_ 架构逻辑.md ====
```markdown
---
tags: []
aliases:
- 1. 代码组织方式 (Code Organization)
date created: 星期三, 十二月 10日 2025, 10:42:21 晚上
date modified: 星期三, 十二月 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 作为**请求参数**被传递。
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()` 机制。
```
==== 02_ 日志\05_ 目录结构与职责.md ====
```markdown
---
tags: []
aliases:
- 目录结构与职责
date created: 星期三, 十二月 10日 2025, 10:45:40 晚上
date modified: 星期三, 十二月 10日 2025, 11:40:48 晚上
---
# 目录结构与职责
## 1. 目录结构设计 (Directory Structure)
该结构旨在实现 **“配置分离”**、**“核心隐藏”** 与 **“上下文桥接”**。
```Plaintext
internal/
├── middleware/ # [Global] 全局中间件层
│ ├── access_log.go # [New] HTTP 请求访问日志 (请求入/出记录, 耗时统计)
│ └── trace.go # [New] 链路追踪 (生成/透传 TraceID -> 注入 Context)
└── pkg/
└── log/ # [Level 0] 全局日志核心包 (基于 Zap)
├── log.go # [Facade] 对外入口 (Init, Global L(), Static Proxies)
├── options.go # [Config] 配置定义 (Level, FilePath, MaxSize)
├── zap.go # [Core] Zap 实例构建 (Encoder, Core, AtomicLevel)
├── writer.go # [IO] 输出源管理 (Lumberjack 轮转, Console/File 双写)
├── context.go # [Bridge] 上下文桥接 (WithContext, TraceID 提取)
└── standard.go # [Schema] 标准字段定义 (Standardized Field Constructors)
```
---
## 2. 文件职责详解 (Responsibilities)
### A. `internal/pkg/log` (核心日志包)
这是一个基础设施包不应依赖任何业务逻辑User, Order 等)。
| **文件名** | **职责描述** | **关键设计点 (Design Decisions)** |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`log.go`** | **门面 (Facade) 与单例管理**<br>1. 维护私有全局变量 `globalLogger`<br>2. 提供 `Init(opts)` 初始化入口。<br>3. 提供 `L()` 获取底层 `*zap.Logger`<br>4. 提供 `Info/Error` 等静态代理方法。 | **单例兜底**:在 `init()` 中初始化一个默认的 `Nop``Console` Logger防止未初始化调用导致 Panic。<br>**Caller 修正**<br>1. 底层 `globalLogger` 配置 `AddCallerSkip(0)`<br>2. 静态代理方法 (`Info`, `Error`) 内部使用 `WithOptions(AddCallerSkip(1))`<br>3. `L()` 和 `WithContext()` 返回原生 Logger (Skip 0),确保业务层直接调用时行号正确。 |
| **`options.go`** | **配置对象 (DTO)**<br>定义 `Options` 结构体,用于接收 Viper 的配置映射。 | **配置解耦**:只定义 struct不包含逻辑。支持从 `config.yaml``log` 节点自动 Unmarshal。 |
| **`zap.go`** | **核心构建工厂 (Factory)**<br>负责组装 Encoder (JSON/Console)、Writer 和 Level。<br>实现 `New(opts)` 函数。 | **环境隔离**<br>- Dev: ConsoleEncoder + StackTrace (Warn 级)<br>- Prod: JsonEncoder + StackTrace (Panic 级) |
| **`writer.go`** | **IO 输出管理**<br>封装 `lumberjack.Logger`<br>实现 `zapcore.WriteSyncer` 接口。 | **可靠性**:配置 `Lumberjack``Compress: true``MaxSize: 100MB`。实现 Console + File 的 **Tee (双写)** 模式。 |
| **`context.go`** | **上下文装饰器与播种器 (Decorator & Seeder)**<br>1. `WithContext(ctx)`: 提取 TraceID。<br>2. **[New] `StartBackgroundTrace(ctx)`**: 为后台任务生成并注入根 TraceID。 | **零侵入**:仅通过 `zap.With()` 附加字段,返回 **派生 Logger**,不修改全局 Logger线程安全。 |
| **`standard.go`** | **标准化字段与存取器 (Schema & Accessor)**<br>1. 定义**私有** Context Key 类型,防止碰撞。<br>2. 提供 `WithTraceID(ctx, id)``GetTraceID(ctx)` 公开方法。<br>3. 定义标准字段构造器 (如 `zap.String("trace_id", …)`)。 | **规范约束**<br>- 统一使用 snake_case。<br>- 防止拼写错误 (如 `uid` vs `user_id`)。 |
### B. `internal/middleware` (中间件集成)
这是日志模块与 HTTP 框架 (Gin) 结合的触点。
| **文件名** | **职责描述** | **交互逻辑** |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **`trace.go`** | **链路起点**<br>链路追踪 (生成 TraceID -> **注入标准 Context** -> 挂载回 Gin Request) | **上下游打通**:保证 TraceID 在微服务或网关间的透传能力。 |
| **`access_log.go`** | **流量审计**<br>1. 记录 `Start Time`<br>2. 执行 `c.Next()`<br>3. 计算 `Latency`<br>4. 打印结构化日志。 | **字段映射**<br>`path`, `method`, `status`, `client_ip`, `latency`, `user_agent`。**必须使用 `log.WithContext(c)`**。 |
| `recovery.go` | 结构化灾难恢复。<br>1. `defer recover()` 捕获 Panic。<br>2. 获取 Stack Trace。<br>3. **调用 `pkg/log` 记录 JSON 格式的 Error 日志** (包含 `stack` 字段)。<br>4. 返回 500 响应。 | **替代 Gin 默认组件**:必须使用 `gin.New()` 启动,手动注册此中间件,杜绝默认的控制台文本打印。 |
---
## 3. 数据流转图示 (Data Flow)
为了确保你理解“上下文”是如何流动的,以下是逻辑路径:
1. **Request In** -> `middleware/trace.go` -> 生成 `trace_id` -> **Wrap 进 `std.Context`**。 …
2. **`pkg/log/context.go`** -> 从 **`std.Context`** 取出 `trace_id` -> …
3. **`pkg/log/context.go`** -> 从 `gin.Context` 取出 `trace_id` -> 创建带字段的 `zap.Logger`
4. **`pkg/log/zap.go`** -> 序列化为 JSON `{…"trace_id":"xyz"…}`
5. **`pkg/log/writer.go`** -> 写入 `app.log` 文件 (由 Lumberjack 轮转)。
## 4. 依赖关系检查 (Dependency Check)
- `pkg/log` **不依赖** `middleware` (防止循环依赖)。
- `middleware` **依赖** `pkg/log` (调用日志打印)。
- `pkg/log` **仅依赖** `uber-go/zap`, `natefinch/lumberjack`。**严禁依赖** `gin` 或其他 Web 框架。所有 Context 操作均基于 Go 标准库接口。
这个结构完全穷尽了我们在前几轮讨论中确定的技术决策。如果确认无误,我们将在下一步生成具体的代码实现。
```bash
==== 02_日志\06_日志模块开发规范与质量保证手册.md ====
```markdown
---
tags: []
aliases:
- 《日志模块开发规范与质量保证手册》
- 一、 核心开发规范 (The Golden Rules)
date created: 星期三, 十二月 10日 2025, 10:53:19 晚上
date modified: 星期三, 十二月 10日 2025, 11:31:04 晚上
---
# 《日志模块开发规范与质量保证手册》
---
## 一、 核心开发规范 (The Golden Rules)
这部分是“软约束”,属于团队共识,通过 Code Review 和 AI 辅助检查来执行。
### 1. 键名命名公约 (Key Naming Convention)
日志是给机器ELK/Loki读的键名必须统一方便建立索引。
- **规则**: 严禁使用 CamelCase (小驼峰) 或 PascalCase (大驼峰)**必须且只能使用 snake_case (下划线命名)**。
- **反例**: `userId`, `IPAddress`, `httpStatus`
- **正例**: `user_id`, `client_ip`, `http_status`
- **理由**: 多数数据库和搜索引擎(如 Elasticsearch的分词器对下划线更友好且 SQL 查询习惯也是下划线。
### 2. 类型安全铁律 (Type Safety Strictness)
利用 Zap 的强类型优势,拒绝隐式转换。
- **规则**: 在业务热点路径Hot Path**严禁使用 `zap.Any``zap.Reflect``Sugar` 模式**。
- **例外**: 仅在应用启动Init、Panic 恢复或非高频的配置加载阶段允许使用 `SugaredLogger`
- **理由**: `zap.Any` 会触发反射Reflection导致内存逃逸和 GC 压力。这是高性能系统的“隐形杀手”。
### 3. 上下文优先原则 (Context First)
日志不是孤岛,必须依附于请求上下文。
- **规则**: 所有 Controller、Service、Repository 层的方法,如果需要打印日志,**必须**使用 `log.WithContext(ctx).Info()` 及其变体。
- **禁止**: 严禁在业务流程中直接调用全局的 `log.Info()`(除非是系统级事件,如定时任务启动)。
- **理由**: 只有通过 `WithContext`,才能将 TraceID 串联起来。
### 4. 哨兵值与魔法字符串 (Sentinels & Magic Strings)
- **规则**: 核心日志字段的 Key 必须定义为常量Constant
- **实现**: 在 `pkg/log/standard.go` 中定义 `const TraceIDKey = "trace_id"`
- **禁止**: 代码中出现手写的 `zap.String("trace_id", …)`,防止拼写错误(如写成 `traceid`)。
### 5. 热点路径复用原则 (Hot Path Reuse)
针对循环Loop或复杂长流程函数严禁重复构建 Context Logger。
- **规则**: 必须在作用域入口处初始化 Logger 实例,并在该作用域内复用。
- **反例 (Bad)**:
```Go
for _, item := range items {
// ❌ 每次循环都分配内存
log.WithContext(ctx).Info("processing", zap.String("id", item.ID))
}
```
- **正例 (Good)**:
```Go
// ✅ 只分配一次,复用 l
l := log.WithContext(ctx)
for _, item := range items {
l.Info("processing", zap.String("id", item.ID))
}
```
- **理由**: 减少大量临时的 `zap.Logger` 结构体分配,降低 GC 的 Scavenge 阶段耗时。
### 6. 后台任务播种原则 (Background Trace Seeding)
所有非 HTTP 触发的后台任务入口Goroutine, Cron, MQ Handler必须是“有状态”的。
- **规则**: 任务的第一行代码必须调用 `StartBackgroundTrace`
- **反例 (Bad)**:
```Go
func ProcessOrder(msg []byte) {
ctx := context.Background()
// ❌ 此时 ctx 空空如也,日志将丢失 TraceID
log.WithContext(ctx).Info("processing order")
}
```
- **正例 (Good)**:
```Go
func ProcessOrder(msg []byte) {
// ✅ 自动生成一个新的 TraceID 注入 ctx
ctx := log.StartBackgroundTrace(context.Background())
log.WithContext(ctx).Info("processing order")
}
```
---
## 二、 Linter 规则配置 (Automated Enforcement)
这部分是“硬约束”,我们将在 `.golangci.yml` 中配置这些规则,强行阻断不合规代码的提交。
### 1. 禁用标准库日志 (`depguard`)
防止开发人员手滑使用了 Go 原生的 `log``fmt` 打印日志。
Linter: depguard
配置策略:
- **Deny**:
- `log`: 标准库日志(无结构化,无法分级)。
- `fmt.Print*`: 控制台打印(生产环境绝对禁止)。
- `github.com/sirupsen/logrus`: 防止引入其他日志库。
### 2. 强制错误处理 (`errcheck`)
Zap 的 `Sync()` 方法可能会返回错误(特别是在 Linux 的 `/dev/stdout` 上),通常需要忽略,但写入文件的错误不能忽略。
Linter: errcheck / gosec
配置策略:
- 对 `logger.Sync()` 的错误处理进行豁免Exclude因为在某些 OS 下 stdout sync 必然报错,这是已知 issue。
- 但对 `logger.Info` 等方法的 IO 错误,原则上 Zap 内部处理了,不需要业务层捕获。
### 3. 自定义规则 (`ruleguard` - 高级)
标准的 Linter 无法检测“键名必须是 snake_case”。如果需要极致的管控我们可以引入 `ruleguard`
AI 辅助检查逻辑:
由于配置 ruleguard 较复杂,我们约定在 AI 生成代码阶段 执行此逻辑:
- **Check 1**: 正则匹配所有 `zap.String("([a-z]+[A-Z][a-z]+)", …)` 模式,如果发现驼峰命名,立刻自我修正。
- **Check 2**: 扫描代码中是否存在 `fmt.Print`,如有则报错。
---
## 三、 安全与脱敏规范 (Security & Masking)
这是日志系统的“红线”。
### 1. PII (个人敏感信息) 零容忍
- **黑名单字段**: `password`, `token`, `access_token`, `refresh_token`, `credit_card`, `id_card`.
- **处理方式**:
- **方案 A (拦截器)**: 在 `zapcore` 层加 Hook但这会损耗性能。
- **方案 B (显式脱敏)**: 要求 AI 在生成代码时,对于敏感字段,自动包裹脱敏函数。例如 `zap.String("mobile", mask.Mobile(u.Mobile))`
- **决策**: 采用 **方案 B**。依赖编码时的自觉和 AI 的辅助,性能最优。
### 2. 大字段截断
- **规则**: 禁止将 Base64 图片数据、巨大的 HTML 内容直接打入日志。
- **限制**: 单个 Field 的 Value 长度建议限制在 2KB 以内。
---
## 四、 AI 辅助编码的“质量契约” (AI Quality Contract)
为了确保我AI生成的代码符合上述规范请你用户在审查我的代码时使用以下 **Checklist** 进行验证。这也是我对你的承诺:
1. **Imports 检查**: 确认没有引入 `log``fmt`
2. **Context 检查**: 确认 `log.WithContext(ctx)` 是日志调用的唯一起手式。
3. **Keys 检查**: 确认所有 JSON Key 都是 `snake_case`
4. **Args 检查**: 确认使用的是 `zap.String/Int` 等强类型构造器,而非 `zap.Any`
5. **Config 检查**: 确认没有硬编码的路径(如 `/var/log`),必须来自 `options.go`
---
## 五、 总结与下一步
我们确立了:
1. **命名**: 强制 snake_case。
2. **类型**: 拒绝 `zap.Any`,拒绝 `fmt`
3. **上下文**: 强制 `WithContext`
4. **安全**: 显式脱敏。
```
==== 02_ 日志\07_ 日志模块工程化实施标准.md ====
```markdown
---
tags: []
aliases:
- 日志模块工程化实施标准
date created: 星期三, 十二月 10日 2025, 10:58:53 晚上
date modified: 星期三, 十二月 10日 2025, 11:42:26 晚上
---
# 日志模块工程化实施标准
---
## 一、 注释与文档规范 (Documentation Standards)
目标:“中文友好 (Chinese Friendly)” 且 “符合 GoDoc 标准”。
我们采用 混合语言策略:结构定义用英文(为了 IDE 兼容性),业务解释用中文(为了团队协作)。
### 1. 导出的包与函数 (Exported Symbols)
所有对外暴露的函数(首字母大写),必须编写文档注释。
- **格式要求**
- 第一行:`// FunctionName 简短的英文或中文摘要` (符合 Go Lint 检查)。
- 空一行。
- 详细说明:**必须使用中文**解释函数的行为、副作用Side Effects和潜在风险。
- 参数说明:如果有复杂参数,使用 `// - param: explanation` 格式。
- **范例 (Style Guide)**:
> // WithContext returns a logger with the trace ID injected.
>
> //
>
> // [功能]: 从 context.Context 中提取 TraceID 并附加到 Logger 字段中。
>
> // [注意]: 这是一个轻量级操作,但如果 ctx 为 nil将返回原始 Logger 的 fallback。
>
> // [场景]: 务必在 Controller 或 Service 的入口处优先调用。
### 2. 内部实现细节 (Internal Logic)
对于 `internal/pkg/log` 内部复杂的逻辑(如 `lumberjack` 的配置转换),必须在代码块上方添加中文注释。
- **原则**:解释 **“为什么这么做 (Why)”**,而不是“做了什么 (What)”。代码本身已经展示了做了什么。
- **范例**:
> // [Why]: 这里不使用 zap.NewProduction 自带的 OutputPaths
>
> // 因为我们需要同时输出到控制台 (为了 Docker 采集) 和文件 (为了本地容灾)
>
> // 且文件输出需要通过 Lumberjack 进行轮转控制。
### 3. README 维护
`internal/pkg/log/README.md` 中维护一份**“速查手册”**。
- **必填内容**
- 如何在 `config.yaml` 中配置(给出默认值)。
- 如何动态调整日志级别(如通过信号或 API
- 常见错误码Code与日志关键字的对应关系。
---
## 二、 可拓展性设计 (Extensibility Design)
虽然我们拒绝“过度封装”但必须为未来的变化预留接口Hook Points
### 1. 配置扩展Functional Options 模式
我们在 `Init` 函数中,不应列出所有参数,而应使用 `Option` 模式。
- **设计**: `func Init(opts …Option) error`
- **预留能力**: 未来如果需要添加“发送日志到 Kafka”或“开启 Sentry 报警”,只需新增一个 `WithKafka(addr)` 的 Option而无需修改 `Init` 的函数签名,保证了对旧代码的兼容性。
### 2. 核心扩展Zap Hooks
Zap 原生支持 `Hooks`。我们的封装必须暴露这一能力。
- **场景**: 当日志级别为 `Error``Fatal` 时,可能需要同步触发飞书/钉钉报警。
- **实现标准**: 在 `zap.go` 的构建逻辑中,检查配置是否定义了 Hooks。这允许我们在不侵入日志核心代码的情况下挂载报警逻辑。
### 3. 字段扩展Context Key Registry
随着业务发展,需要记录的元数据会增加(如 `TenantID`, `RequestID`, `SpanID`)。
- **标准**: 不要在 `context.go` 里写死 key 的提取逻辑。
- **设计**: 定义一个 `type ContextExtractor func(ctx) []Field` 类型。默认提供 `TraceIDExtractor`。允许在初始化时注册新的 Extractor。这使得业务线可以自定义需要提取的 Context 字段。
---
## 三、 查漏补缺 (Gap Analysis)
在之前的讨论中,有几个隐蔽但致命的工程细节尚未覆盖,这里作为最后防线进行补充。
### 1. 关于 `Logger.Fatal` 的使用禁令
- **风险**: `zap.Logger.Fatal` 会在打印日志后调用 `os.Exit(1)`
- **工程标准**: **在 Web 服务HTTP Server严禁在业务逻辑层调用 `Fatal`。**
- _原因_: 这会直接杀死整个进程,导致所有正在处理的请求中断(没有 Graceful Shutdown
- _替代_: 遇到不可恢复错误,使用 `Error` 级别日志,并返回 `500` 错误给客户端,由上层中间件处理。
- _例外_: 仅在 `main.go` 启动阶段(如连不上数据库、读不到配置)可以使用 `Fatal`
### 2. 时间格式的一致性
- **问题**: Zap 默认的时间格式可能是浮点数Unix Epoch或非标准字符串。
- **标准**: 生产环境统一配置为 **`ISO8601` (2025-12-10T22:00:00.000Z)**。
- _理由_: 这种格式跨时区友好且能被几乎所有日志分析工具ELK, Splunk, CloudWatch自动识别并建立时间索引。
### 3. 动态日志级别 (Hot Reload)
- **需求**: 线上出 Bug 时,需要临时把 Level 调成 Debug查完再调回 Info且不能重启服务。
- **实现标准**: 利用 `zap.AtomicLevel`
- 我们需要暴露一个 HTTP 接口(如 `PUT /admin/log/level`)或监听配置文件的 `fsnotify` 事件。
- 收到变更信号后,直接调用 `atomicLevel.SetLevel(zap.DebugLevel)`。这是线程安全的,无需重启实例。
### 4. 测试支持 (Testing Support)
- **问题**: 单元测试时,不仅不想看到日志刷屏,有时还需要断言“是否打印了某条错误日志”。
- **标准**:
- 提供 `pkg/log/test_helper.go`
- 封装 `zaptest/observer`
- 允许测试代码通过 `log.NewTestLogger()` 获取一个观察者对象,从而断言 `logs.FilterMessage("error").Len() == 1`
### 5. 链路完整性保障
- **风险**: 开发者容易遗忘在 `go func()` 中传递 Context。
- **标准**: 在 Code Review 时,重点检查所有 `go` 关键字后是否跟随了 Context 的传递或播种操作。
### 6. 框架初始化与 Panic 处理
- **风险**: `gin.Default()` 会自动注册只打印文本日志的 Recovery 中间件,破坏 JSON 格式。
- **标准**:
1. 必须使用 `gin.New()` 初始化 Engine。
2. 必须手动注册我们自定义的 `middleware.Recovery``middleware.AccessLog`
3. 确保 Panic 日志中包含 TraceID`c.Request.Context` 中尝试恢复)。
---
## 四、 总结与就绪确认
至此,我们已经完成了日志模块的**全生命周期设计**
1. **架构**: 基础设施层,无业务依赖。
2. **技术栈**: Zap + Lumberjack + Context Adapter。
3. **模式**: 单例兜底 + 依赖注入,强类型约束。
4. **规范**: Snake_case 键名,中文友好文档,严禁 Fatal。
```