从日志 TraceID 引发的基建重构
记录一次为了在日志中自动打印 TraceID 而进行的后端基建重构过程,探讨依赖注入、代码分层与项目演进的关系。
引言:一个微小的需求引发的蝴蝶效应
事情的起源非常简单:我们想要在每一条业务日志里固定打印 TraceID。
在分布式系统中,TraceID 是追踪请求链路的唯一标识。有了它,我们才能在海量的日志中快速定位某一次请求的所有相关日志。
然而,在旧的项目基建中,日志是一个全局单例(Global Singleton),业务代码直接调用 log.Info(...)。 这就带来了一个尴尬的问题:TraceID 存在于 context 中,而全局的 log 对象并不知道当前是哪个 context。
为了解决这个问题,我们面临几种选择:
- 手动拼接:所有业务调用都自己从 context 取出 TraceID 拼接到日志里?(太繁琐,容易漏)
- 封装函数:封装一个
Log(ctx, msg)函数?(那这个函数放在哪?基础设施层依赖业务层?) - 重构基建:引入依赖注入和上下文感知的日志组件。
总结来说,是因为没有一个合理的代码分层和依赖注入机制,导致无法自然地处理“请求作用域”的数据。 所以,我们决定借此机会改造一下基建。
项目演进史:从脚本到框架
在深入技术细节之前,先回顾一下这个项目的基建是如何一步步演变成今天这个样子的。
第一阶段:异步任务脚本
最早的时候,这算不上一个后端项目,只是附着在其他老 C++ 项目上的“外挂”。
- 形态:作为消费者通过 MQ 异步处理小任务。
- 代码:直接
main函数连接 MQ/MySQL/Redis,编写几个业务方法作为 Callback 注册到 MQ。 - 特点:简单粗暴,没有分层,全是全局变量。
第二阶段:基于 MQ 的伪 RPC
需求越来越多,除了异步任务,我们开始基于 MQ 做 RPC 调用。
- 形态:定义了 Request/Response 的 Protobuf 结构,业务函数统一为
ApiFunc(ctx, req, resp) err。 - 代码:简单的路由管理(其实就是
mq.Reg("ApiName", ApiFunc))。 - 痛点:基础设施(DB/Redis)的初始化和调用虽然做了简单封装,但本质上还是全局单例。
第三阶段:引入 Kratos (为了 gRPC)
为了替换 MQ-RPC 的局限性,我们引入了 Kratos 框架。
- 形态:使用更成熟的 gRPC。
- 问题:这个时候还没有完全理解 Kratos 的设计理念(依赖注入、整洁架构),仅仅是为了跑通 gRPC 而把代码“塞”进去。
- 胶水代码:因为业务代码已有一定规模,为了对接 Kratos,写了大量利用反射或模板生成的“胶水代码”。
第四阶段:挣扎与觉醒
比较长一段时间里,我们试图在“旧代码习惯”和“新框架理念”之间妥协。
- 挣扎:为了短期效率,沿用旧代码,编写越来越多的胶水代码。
- 觉醒:想要增加监控、链路追踪、Mock 测试时,发现旧架构寸步难行。
- 比如日志:
logger.Debug("msg")变成了logger.Debug(traceID, "msg"),最后发现ctx.Log.Debug才是合理的,但旧架构不支持。
- 比如日志:
重构的核心思路
重构的目标不仅仅是打印 TraceID,而是建立一套符合现代微服务开发理念的架构。
1. 依赖注入 (Dependency Injection)
我们决定放弃全局单例的日志调用,转而使用依赖注入。
以前的代码:
1
2
3
4
5
// 业务代码直接依赖全局变量
func CreateUser(ctx context.Context, req *CreateUserReq) {
// ... 业务逻辑
globalLogger.Info("user created") // 无法自动获取 ctx 中的 trace_id
}
重构后的代码: Service 对象持有 Logger 实例,这个 Logger 实例是在 Service 初始化时注入的。
以下代码只是依赖注入的示例,事实上后面 logger 是由 myCtx 持有。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type UserService struct {
pb.UnimplementedUserServer
log log.Logger // 依赖接口,而不是具体实现
}
// 使用 Functional Options 模式进行注入
func WithLogger(l log.Logger) func(*UserService) {
return func(s *UserService) {
s.log = l
}
}
func NewUserService(opts ...func(*UserService)) *UserService {
s := &UserService{}
for _, opt := range opts {
opt(s)
}
return s
}
2. 上下文感知 (Context Awareness)
Kratos 的 log 接口设计得非常巧妙,它允许我们绑定一些“上下文信息”。
我们需要实现一个适配器,将 Kratos 的日志接口对接到底层的 Zap 日志库:
1
2
3
4
5
6
7
type kratosZapLogger struct {
// ... zap logger instance
}
func (l *kratosZapLogger) Log(level log.Level, keyvals ...interface{}) error {
// ... 适配逻辑
}
然后在程序启动时,通过中间件(Middleware)将 TraceID 注入到日志上下文中:
1
2
3
4
5
6
7
8
9
10
// 创建 Logger,并配置自动提取 trace.id
kLogger := log.With(newKratosZapLogger(),
"trace.id", tracing.TraceID(), // 这里的 tracing.TraceID() 是一个 Valuer,会在打印时从 ctx 取值
)
// 在 gRPC/HTTP Server 中配置中间件
gs := grpc.NewServer(grpc.Middleware(
tracing.Server(), // 链路追踪中间件,负责生成/传递 TraceID
logging.Server(kLogger), // 日志中间件,负责将 Logger 注入到 ctx 或使用 Logger 打印请求日志
))
3. 业务层封装 (myCtx)
为了让业务开发更爽,我们定义了 myCtx(或 BizContext),作为 context.Context 的一层封装。
xxSvc(Service): 存储“业务组件专用的东西”(如 DB 句柄、Logger 实例)。myCtx(Context): 存储“单次请求专用的东西”(如 UserID, TraceID)。
这样,业务代码写起来就非常顺滑:
1
2
3
4
5
6
7
8
9
10
11
12
func (s *UserService) CreateUser(ctx context.Context, req *req) (*resp, error) {
// 封装 context
myCtx := s.buildContext(ctx)
// 获取当前用户信息(不用到处传参)
userID := myCtx.UserID()
// 打印日志(自动带上 TraceID)
s.log.WithContext(ctx).Infof("create user %d", userID)
return &resp{}, nil
}
依赖注入的演进策略
重构不是一蹴而就的,总结以下不同阶段的策略:
| 阶段 | 策略 | 适用场景 |
|---|---|---|
| MVP 期 | 全局单例 | 需求不明确,快速验证原型。直接 main 函数一把梭,全局变量调用 DB/Log。 |
| 成长期 | 逐步抽象 | 业务变复杂,开始出现痛点(如无法 Mock 测试、无法追踪日志)。此时应识别关键路径,引入 Interface 和 DI。 |
| 稳定期 | 全面 DI | 完善架构,使用 Wire 等工具自动注入。整个项目各部分变成可插拔的积木。 |
我们之前的痛苦在于:项目已经进入了成长期甚至稳定期,却还保留着 MVP 期的架构代码。
结语
这次重构虽然起因只是一个 TraceID,但本质上是对代码分层和依赖管理的一次梳理。
- 全局单例在项目初期是效率神器,但在后期是维护噩梦。
- 依赖注入虽然增加了初始化的复杂度(“体操代码”),但换来了极高的灵活性和可测试性。
- 中间件机制是处理横切关注点(日志、监控、Tracing)的最佳场所。
现在的架构,不仅能自动打印 TraceID,对接 ELK/Loki 等日志平台也变得轻而易举,因为我们的日志已经是结构化的、包含链路信息的了。