Post

从日志 TraceID 引发的基建重构

记录一次为了在日志中自动打印 TraceID 而进行的后端基建重构过程,探讨依赖注入、代码分层与项目演进的关系。

从日志 TraceID 引发的基建重构

引言:一个微小的需求引发的蝴蝶效应

事情的起源非常简单:我们想要在每一条业务日志里固定打印 TraceID

在分布式系统中,TraceID 是追踪请求链路的唯一标识。有了它,我们才能在海量的日志中快速定位某一次请求的所有相关日志。

然而,在旧的项目基建中,日志是一个全局单例(Global Singleton),业务代码直接调用 log.Info(...)。 这就带来了一个尴尬的问题:TraceID 存在于 context 中,而全局的 log 对象并不知道当前是哪个 context

为了解决这个问题,我们面临几种选择:

  1. 手动拼接:所有业务调用都自己从 context 取出 TraceID 拼接到日志里?(太繁琐,容易漏)
  2. 封装函数:封装一个 Log(ctx, msg) 函数?(那这个函数放在哪?基础设施层依赖业务层?)
  3. 重构基建:引入依赖注入和上下文感知的日志组件。

总结来说,是因为没有一个合理的代码分层和依赖注入机制,导致无法自然地处理“请求作用域”的数据。 所以,我们决定借此机会改造一下基建。

项目演进史:从脚本到框架

在深入技术细节之前,先回顾一下这个项目的基建是如何一步步演变成今天这个样子的。

第一阶段:异步任务脚本

最早的时候,这算不上一个后端项目,只是附着在其他老 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 等日志平台也变得轻而易举,因为我们的日志已经是结构化的、包含链路信息的了。

This post is licensed under CC BY 4.0 by the author.