log
码中赤兔

Fastify 最佳实践 #6: 全局日志输出

发布于 2025年6月25日
更新于 2025年6月26日
20 分钟阅读
TypeScript

在任何服务端应用中,日志都扮演着至关重要的角色。它不仅是调试 Bug 的关键线索,也是监控系统状态、分析用户行为的基石。一个理想的日志系统应该能做到:

  1. 可追踪:每条日志都应能关联到它所属的原始请求。
  2. 信息丰富:日志内容应包含足够多的上下文,如请求的 API 名称、用户信息等。
  3. 易于使用:在项目的任何地方,都能以一种简单、统一的方式来记录日志。

Fastify 默认的日志功能已经很强大,但通过一些最佳实践,我们可以构建一个真正企业级的日志系统。本文将带你一步步实现这个目标。

第一步:自定义唯一的请求追踪ID

为了在海量的日志中精准定位到某一次请求的全过程,我们需要一个唯一的追踪ID(Trace ID 或 Request ID)。Fastify 允许我们通过 genReqId 选项自定义ID的生成规则。

默认的ID(如 req-1, req-2)仅在当前进程的生命周期内唯一,当服务重启或在多实例部署时会重复。我们最好使用一个更健壮的唯一字符串。

// main.ts
import fastify from 'fastify';

const server = fastify({
  // 当请求进来时,调用此函数生成请求ID
  genReqId(_req) {
    // 使用随机字符串,确保高唯一性
    return Math.random().toString(36).substring(2, 12);
  },
  // ... 其他配置
});

现在,通过 request.log 输出的每一条日志都会自动包含一个像 {"level":30,"time":167... ,"pid":...,"hostname":"...","reqId":"a1b2c3d4e5",...} 这样的 reqId 字段。

第二步:为日志注入更多上下文(如 API 名称)

有了 reqId,我们可以追踪一次请求。但如果能直接在日志里看到“这是‘获取用户信息’的API产生的日志”,排查问题会更高效。

我们可以利用 Fastify 的钩子(Hook)和路由定义(Route Schema)来实现这一点。在Fastify 最佳实践 #5:Swagger 文档生成中,我们为每一个API都设置了一个 summary 字段来描述接口用途,正好可以把它用作日志的上下文。

我们创建一个 onRequest 钩子,它会在每个请求进入路由处理程序之前执行。

// hooks/request.hook.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';

const requestHook: FastifyPluginAsync = fp(async (fastify) => {
  fastify.addHook('onRequest', (request, _reply, done) => {
    // 1. 从路由定义中获取 API 的 summary
    const summary = request.routeOptions.schema?.summary || '';

    // 2. 基于当前请求的 logger 创建一个子 logger,并附加额外信息
    const childLogger = request.log.child({ api: summary });

    // 3. 将新创建的子 logger 覆盖到当前请求上
    request.log = childLogger;

    done();
  });
});

export default requestHook;

经过这一步,现在使用 request.log.info('...') 输出的日志会自动包含 reqIdapi 字段了。

第三步:核心难题与终极方案 AsyncLocalStorage

目前一切看起来不错,但还有一个痛点:我们必须通过 request 对象来调用日志方法(request.log.info(...))。

这意味着,如果想在一个深层嵌套的 Service 函数或者一个独立的工具函数里记录日志,就必须把 request 对象或者 request.log 一路透传下去。这不仅非常繁琐,而且严重破坏了代码的整洁和封装性。

我们希望达到的理想状态是:在任何地方导入一个全局的 logger,直接调用 logger.info(),它就能自动关联到当前正在处理的那个请求的上下文。

这听起来像魔法,而实现这个魔法的工具就是 Node.js v13.10+ 内置的 async_hooks 模块中的 AsyncLocalStorage

解密:AsyncLocalStorage 是什么?

你可以把 AsyncLocalStorage 想象成一个“请求专属的隐形背包”。

  1. 创建背包:首先,你在应用中创建一个 AsyncLocalStorage 的全局实例。这就好比是设计了一款背包。
  2. 分发背包:当一个HTTP请求进来时,你调用 als.run(store, callback)。这个动作会给当前这个请求的整个后续操作链(包括所有 await.then()、回调函数等)都“背上”一个新背包。store 就是你想要放进背包里的东西,比如我们这里就是包含了 reqIdapi 的那个 logger 实例。
  3. 随时取用:在这个请求的处理过程中,无论代码执行到多深,只要它是由最初的请求触发的异步链的一部分,你都可以通过 als.getStore()随时随地拿到这个请求专属的背包,并取出里面的东西。
  4. 请求隔离:不同的请求会拿到不同的背包,它们之间的数据是完全隔离的。请求A永远拿不到请求B背包里的东西。这对于处理并发请求至关重要。

简单来说,AsyncLocalStorage 为 Node.js 的异步操作提供了一种类似“线程局部存储(Thread-Local Storage)”的机制,完美解决了在异步流程中安全传递上下文数据的难题。

第四步:整合 AsyncLocalStorage,构建全局 Logger

现在,我们用 AsyncLocalStorage 来改造我们的日志系统。

1. 创建 AsyncLocalStorage 工具

// utils/als.util.ts
import { AsyncLocalStorage } from 'async_hooks';
import { FastifyBaseLogger } from 'fastify';

// 定义我们要在“背包”里存储的数据结构
export type IAsyncStore = {
  logger: FastifyBaseLogger;
};

// 创建一个全局唯一的 AsyncLocalStorage 实例
const asyncStore = new AsyncLocalStorage<IAsyncStore>();

// 提供一个安全的获取 logger 的方法
const getLogger = (): FastifyBaseLogger => {
  const store = asyncStore.getStore();
  // 如果 getStore() 返回 undefined,说明当前代码执行上下文不在 als.run() 内
  // 这通常意味着一个逻辑错误,比如在服务启动阶段就调用了业务日志
  if (!store) {
    throw new Error('Logger could not be found in the current async context.');
  }
  return store.logger;
};

export const ALS = {
  asyncStore,
  getLogger,
};

2. 改造 onRequest 钩子

我们修改之前的钩子,用 als.run 把整个请求的生命周期包裹起来。

// hooks/request.hook.ts
import { ALS } from '@/utils/als.util';
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';

const requestHook: FastifyPluginAsync = fp(async (fastify) => {
  fastify.addHook('onRequest', (request, _reply, done) => {
    const summary = request.routeOptions.schema?.summary || '';
    const childLogger = request.log.child({ api: summary });
    request.log = childLogger;

    // 重点:将后续的所有操作都包裹在 run 方法中
    // 我们将包含了上下文的 logger 实例存入 store
    ALS.asyncStore.run({ logger: childLogger }, () => {
      // run 的回调函数执行完毕后,上下文会自动销毁
      done();
    });
  });
});

export default requestHook;

3. 创建全局 logger 工具

这是最后一步,也是最妙的一步。我们创建一个可以被项目内任何文件导入的 logger 对象。

// utils/logger.util.ts
import { ALS } from './als.util';
import { FastifyBaseLogger } from 'fastify';

// 我们只暴露需要的日志级别方法
export const logger: Pick<FastifyBaseLogger, 'info' | 'error' | 'warn' | 'debug'> = {
  // 使用 getter 是这里的关键!
  // 它确保了 ALS.getLogger() 是在 logger.info() 被真正调用的那一刻才执行
  // 而不是在模块首次加载时执行
  get info() {
    const log = ALS.getLogger();
    return log.info.bind(log);
  },
  get error() {
    const log = ALS.getLogger();
    return log.error.bind(log);
  },
  get warn() {
    const log = ALS.getLogger();
    return log.warn.bind(log);
  },
  get debug() {
    const log = ALS.getLogger();
    return log.debug.bind(log);
  },
};

最终效果:随时随地,轻松记录

现在,无论是在 Controller、Service 还是任何其他模块,当你想记录日志时,用法都极其简单:

// services/user.service.ts
import { logger } from '@/utils/logger.util';

export class UserService {
  async findUser(userId: string) {
    logger.info({ userId }, 'Starting to find user...'); // 这条日志会自动带上 reqId 和 api

    // ... 业务逻辑 ...
    if (!user) {
      logger.warn({ userId }, 'User not found.'); // 这条日志也一样
      return null;
    }

    logger.info({ userId, username: user.name }, 'User found successfully.');
    return user;
  }
}

你不再需要关心 request 对象在哪里,只需导入全局的 logger 即可。它会自动从“隐形背包”(AsyncLocalStorage)中找到当前请求对应的 logger 实例,并将你的日志信息连同所有上下文一起输出。

总结

通过以下四步,我们构建了一个强大、优雅且易于维护的 Fastify 日志系统:

  1. genReqId: 确保日志的可追踪性。
  2. onRequest Hook: 动态丰富日志上下文。
  3. AsyncLocalStorage: 解决了在异步流程中传递上下文的世纪难题。
  4. 全局 Logger 工具: 提供了极致简洁的开发者体验。

这套方案不仅适用于日志,任何需要与请求生命周期绑定的数据(如用户信息、租户ID等)都可以用类似的方式进行管理,是现代 Node.js 后端架构的必备利器。

关于

分享技术见解、经验和思考的个人博客

联系方式

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 码中赤兔. 版权所有