Fastify 最佳实践 #6: 全局日志输出
在任何服务端应用中,日志都扮演着至关重要的角色。它不仅是调试 Bug 的关键线索,也是监控系统状态、分析用户行为的基石。一个理想的日志系统应该能做到:
- 可追踪:每条日志都应能关联到它所属的原始请求。
- 信息丰富:日志内容应包含足够多的上下文,如请求的 API 名称、用户信息等。
- 易于使用:在项目的任何地方,都能以一种简单、统一的方式来记录日志。
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('...') 输出的日志会自动包含 reqId 和 api 字段了。
第三步:核心难题与终极方案 AsyncLocalStorage
目前一切看起来不错,但还有一个痛点:我们必须通过 request 对象来调用日志方法(request.log.info(...))。
这意味着,如果想在一个深层嵌套的 Service 函数或者一个独立的工具函数里记录日志,就必须把 request 对象或者 request.log 一路透传下去。这不仅非常繁琐,而且严重破坏了代码的整洁和封装性。
我们希望达到的理想状态是:在任何地方导入一个全局的 logger,直接调用 logger.info(),它就能自动关联到当前正在处理的那个请求的上下文。
这听起来像魔法,而实现这个魔法的工具就是 Node.js v13.10+ 内置的 async_hooks 模块中的 AsyncLocalStorage。
解密:AsyncLocalStorage 是什么?
你可以把 AsyncLocalStorage 想象成一个“请求专属的隐形背包”。
- 创建背包:首先,你在应用中创建一个
AsyncLocalStorage的全局实例。这就好比是设计了一款背包。 - 分发背包:当一个HTTP请求进来时,你调用
als.run(store, callback)。这个动作会给当前这个请求的整个后续操作链(包括所有await、.then()、回调函数等)都“背上”一个新背包。store就是你想要放进背包里的东西,比如我们这里就是包含了reqId和api的那个logger实例。 - 随时取用:在这个请求的处理过程中,无论代码执行到多深,只要它是由最初的请求触发的异步链的一部分,你都可以通过
als.getStore()随时随地拿到这个请求专属的背包,并取出里面的东西。 - 请求隔离:不同的请求会拿到不同的背包,它们之间的数据是完全隔离的。请求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 日志系统:
genReqId: 确保日志的可追踪性。onRequestHook: 动态丰富日志上下文。AsyncLocalStorage: 解决了在异步流程中传递上下文的世纪难题。- 全局 Logger 工具: 提供了极致简洁的开发者体验。
这套方案不仅适用于日志,任何需要与请求生命周期绑定的数据(如用户信息、租户ID等)都可以用类似的方式进行管理,是现代 Node.js 后端架构的必备利器。