WEBKT

NestJS 中 AsyncLocalStorage 实现请求上下文追踪的最佳实践:深入解析与实战演练

48 0 0 0

1. 什么是请求上下文?为什么要追踪它?

2. AsyncLocalStorage 简介

3. 在 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪

4. 错误处理

5. 性能优化

6. 与日志系统的集成

7. 最佳实践总结

8. 进阶技巧与应用场景

9. 总结

你好,作为一名 NestJS 开发者,你是否经常遇到这样的场景:在复杂的微服务架构或大型应用中,需要追踪每个请求的上下文信息,比如用户 ID、请求 ID、链路追踪 ID 等,以便于调试、监控和问题排查?你是否曾为如何在异步操作中传递这些上下文信息而烦恼?

今天,我将带你深入探讨 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪的最佳实践。我们将从基础概念入手,逐步深入到实战演练,包括错误处理、性能优化以及与日志系统的集成。通过本文,你将掌握在 NestJS 应用中高效、可靠地追踪请求上下文的方法,提升你的开发技能和应用质量。

1. 什么是请求上下文?为什么要追踪它?

在 Web 开发中,一个请求通常会经过多个环节的处理,包括路由处理、中间件、服务调用、数据库操作等。在这些环节中,我们可能需要访问或传递一些与当前请求相关的上下文信息,例如:

  • 用户身份信息: 用户 ID、用户名、角色等。
  • 请求标识信息: 请求 ID(用于链路追踪)、调用链 ID 等。
  • 授权信息: 访问令牌、权限列表等。
  • 其他自定义信息: 例如,当前用户的语言偏好、客户端 IP 地址等。

追踪请求上下文的主要原因如下:

  • 调试和问题排查: 能够方便地定位问题发生的位置,了解请求在各个环节的处理情况。
  • 监控和告警: 可以根据上下文信息进行监控,例如统计特定用户的请求量、监控特定链路的响应时间等。
  • 安全性: 确保在每个环节都能够获取用户的身份信息,进行权限校验。
  • 日志记录: 将上下文信息记录到日志中,方便后续的分析和审计。

2. AsyncLocalStorage 简介

AsyncLocalStorage 是 Node.js 12.17.0 版本引入的一个实验性 API,后来在 Node.js 14.0.0 版本中转为稳定 API。它允许你在异步代码中存储和访问上下文数据,而无需手动传递参数。这对于追踪请求上下文来说非常有用,因为它可以在整个请求的生命周期中共享上下文信息,而无需在每个函数调用中都传递这些信息。

AsyncLocalStorage 的工作原理类似于线程局部存储(Thread Local Storage),它为每个异步执行上下文维护一个独立的存储空间。当你使用 AsyncLocalStorage.run() 方法时,它会创建一个新的异步执行上下文,并在该上下文中存储数据。在 AsyncLocalStorage.run() 方法内部,你可以在任何异步操作中通过 AsyncLocalStorage.getStore() 方法访问存储的数据。

关键方法:

  • AsyncLocalStorage.run(store: Map<any, any>, callback: (...args: any[]) => any, ...args: any[]):创建一个新的异步执行上下文,并在该上下文中运行回调函数。store 参数是一个用于存储上下文数据的 Map 对象。回调函数的参数将作为 ...args 传递。
  • AsyncLocalStorage.getStore():获取当前异步执行上下文中的存储数据。如果在 AsyncLocalStorage.run() 之外调用此方法,将返回 undefined

3. 在 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪

下面,我们将通过一个实际的例子来演示如何在 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪。

3.1 安装必要的依赖

首先,确保你已经安装了 NestJS 和 @nestjs/common 模块。如果你还没有安装,可以使用以下命令:

npm install @nestjs/core @nestjs/common

3.2 创建一个 AsyncLocalStorage 服务

创建一个名为 request-context.service.ts 的服务,用于管理 AsyncLocalStorage。这个服务将提供设置、获取和清除上下文信息的方法。

// request-context.service.ts
import { Injectable, Scope } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
@Injectable({ scope: Scope.REQUEST }) // 注意:使用 Scope.REQUEST,确保每个请求都有一个独立的实例
export class RequestContextService {
private readonly asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>();
public run<T>(store: Map<string, any>, callback: () => T): T {
return this.asyncLocalStorage.run(store, callback);
}
public get<T>(key: string): T | undefined {
const store = this.asyncLocalStorage.getStore();
return store?.get(key);
}
public set(key: string, value: any): void {
const store = this.asyncLocalStorage.getStore();
store?.set(key, value);
}
public has(key: string): boolean {
const store = this.asyncLocalStorage.getStore();
return store?.has(key) || false;
}
public clear(): void {
const store = this.asyncLocalStorage.getStore();
if (store) {
store.clear();
}
}
}

3.3 创建一个中间件

创建一个名为 request-context.middleware.ts 的中间件,用于在每个请求开始时初始化 AsyncLocalStorage

// request-context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { RequestContextService } from './request-context.service';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private readonly requestContextService: RequestContextService) {}
use(req: Request, res: Response, next: NextFunction) {
const store = new Map<string, any>();
// 可以在这里设置一些默认的上下文信息,例如请求 ID
const requestId = req.headers['x-request-id'] || Math.random().toString(36).substring(2, 15);
store.set('requestId', requestId);
this.requestContextService.run(store, () => {
// 将请求 ID 设置到响应头中,方便客户端查看
res.setHeader('X-Request-Id', requestId);
next();
});
}
}

3.4 注册中间件

app.module.ts 中注册中间件。

// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RequestContextMiddleware } from './request-context.middleware';
import { RequestContextService } from './request-context.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService, RequestContextService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestContextMiddleware).forRoutes('*'); // 对所有路由应用中间件
}
}

3.5 使用请求上下文

现在,你可以在任何需要访问上下文信息的地方,通过 RequestContextService 获取或设置上下文信息。例如,在控制器中:

// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import { AppService } from './app.service';
import { RequestContextService } from './request-context.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService, private readonly requestContextService: RequestContextService) {}
@Get()
getHello(): string {
const requestId = this.requestContextService.get<string>('requestId');
console.log(`[Controller] Request ID: ${requestId}`);
return this.appService.getHello();
}
}

在服务中:

// app.service.ts
import { Injectable } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
@Injectable()
export class AppService {
constructor(private readonly requestContextService: RequestContextService) {}
getHello(): string {
const requestId = this.requestContextService.get<string>('requestId');
console.log(`[Service] Request ID: ${requestId}`);
return 'Hello World!';
}
}

3.6 测试

启动 NestJS 应用,并访问根路由。你将在控制台中看到类似如下的输出:

[Controller] Request ID: xxxxxx (随机生成的请求ID)
[Service] Request ID: xxxxxx (与Controller中相同的请求ID)

并且,在响应头中也会看到 X-Request-Id 字段,其值为生成的请求 ID。

4. 错误处理

在处理请求上下文的过程中,错误处理至关重要。我们需要确保在发生错误时,能够正确地清理上下文信息,避免信息泄露或污染。以下是一些建议:

  • AsyncLocalStorage.run() 中使用 try...catch 块: 捕获可能发生的错误,并在 catch 块中进行处理,例如记录错误日志、清理上下文信息等。
  • 使用 NestJS 的异常过滤器: 捕获全局的异常,并将请求 ID 等上下文信息添加到错误日志中,方便追踪错误。
  • 在异步操作中使用 try...catch 块: 确保在每个异步操作中都处理潜在的错误,并清理上下文信息。

4.1 异常过滤器示例

// http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { Request, Response } from 'express';
import { RequestContextService } from './request-context.service';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly requestContextService: RequestContextService) {}
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const requestId = this.requestContextService.get<string>('requestId');
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
requestId,
message: exception.message || 'Internal server error',
};
console.error('Exception occurred:', errorResponse, exception.stack);
// 可以在这里将错误信息发送到日志系统,例如 ELK Stack, Sentry 等
response.status(status).json(errorResponse);
this.requestContextService.clear(); // 清理上下文
}
}

4.2 在 app.module.ts 中注册异常过滤器:

// app.module.ts
import { Module, NestModule, MiddlewareConsumer, } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RequestContextMiddleware } from './request-context.middleware';
import { RequestContextService } from './request-context.service';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './http-exception.filter';
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
RequestContextService,
{ provide: APP_FILTER, useClass: HttpExceptionFilter }, // 注册异常过滤器
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestContextMiddleware).forRoutes('*');
}
}

5. 性能优化

使用 AsyncLocalStorage 可能会对性能产生一定的影响,特别是在高并发场景下。以下是一些优化建议:

  • 避免在 AsyncLocalStorage 中存储大量数据: 尽量只存储关键的上下文信息,例如用户 ID、请求 ID 等。避免存储大型对象或数据结构。
  • 减少 AsyncLocalStorage.getStore() 的调用次数: 将常用的上下文信息缓存在局部变量中,避免频繁地调用 getStore() 方法。
  • 使用更高效的存储方式: 虽然 AsyncLocalStorage 内部使用 Map 对象存储数据,但你可以在应用层进行优化,例如使用更高效的 Map 实现,或者根据具体情况选择其他的存储方式(例如,如果只需要存储几个简单的键值对,可以使用普通的对象)。
  • 仔细评估是否真的需要使用 AsyncLocalStorage 在某些场景下,手动传递上下文信息可能比使用 AsyncLocalStorage 更高效。例如,如果你的应用中大部分操作都是同步的,那么手动传递上下文信息可能更简单、更快速。
  • 进行性能测试: 在实际应用中,进行性能测试,评估 AsyncLocalStorage 对性能的影响。根据测试结果,进行相应的优化。

6. 与日志系统的集成

将请求上下文信息与日志系统集成,可以极大地提高问题排查的效率。以下是一些常见的集成方式:

  • 将请求 ID 附加到日志消息中: 在每个日志消息中,都包含请求 ID,以便于将相关的日志信息关联起来。
  • 使用链路追踪 ID: 如果你的应用使用了链路追踪系统(例如 Zipkin、Jaeger),可以将链路追踪 ID 存储在 AsyncLocalStorage 中,并将其附加到日志消息中。这样,你就可以在不同的微服务之间追踪请求的调用链。
  • 使用日志上下文: 某些日志库(例如 Winston、Pino)支持日志上下文,你可以将上下文信息传递给日志库,使其自动将上下文信息添加到日志消息中。

6.1 使用 Winston 日志库集成示例

首先,安装 Winston:

npm install winston winston-daily-rotate-file

创建一个名为 logger.service.ts 的服务,用于配置和使用 Winston 日志库:

// logger.service.ts
import { Injectable, LoggerService } from '@nestjs/common';
import * as winston from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';
import { RequestContextService } from './request-context.service';
@Injectable()
export class LoggerService implements LoggerService {
private readonly logger: winston.Logger;
constructor(private readonly requestContextService: RequestContextService) {
this.logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const requestId = this.requestContextService.get<string>('requestId');
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message} ${requestId ? `[requestId=${requestId}]` : ''} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`;
return logMessage;
}),
),
transports: [
new winston.transports.Console(),
new DailyRotateFile({
dirname: 'logs',
filename: 'application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
}),
],
});
}
log(message: string, context?: string) {
this.logger.info(message, { context });
}
error(message: string, trace?: string, context?: string) {
this.logger.error(message, { trace, context });
}
warn(message: string, context?: string) {
this.logger.warn(message, { context });
}
debug(message: string, context?: string) {
this.logger.debug(message, { context });
}
verbose(message: string, context?: string) {
this.logger.verbose(message, { context });
}
}

6.2 在 app.module.ts 中注册 LoggerService

// app.module.ts
import { Module, NestModule, MiddlewareConsumer, } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RequestContextMiddleware } from './request-context.middleware';
import { RequestContextService } from './request-context.service';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './http-exception.filter';
import { LoggerService } from './logger.service';
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
RequestContextService,
{ provide: APP_FILTER, useClass: HttpExceptionFilter }, // 注册异常过滤器
{ provide: LoggerService, useClass: LoggerService }, // 注册日志服务
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestContextMiddleware).forRoutes('*');
}
}

6.3 在控制器和服务中使用日志服务:

// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import { AppService } from './app.service';
import { LoggerService } from './logger.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService, private readonly loggerService: LoggerService) {}
@Get()
getHello(): string {
this.loggerService.log('Hello from controller');
return this.appService.getHello();
}
}

现在,你将在日志中看到类似如下的输出,其中包含了请求 ID:

[2024-01-26T10:00:00.000Z] [INFO] Hello from controller [requestId=xxxxxx]

7. 最佳实践总结

  • 使用 Scope.REQUEST 确保每个请求都有独立的 RequestContextService 实例: 这样可以避免不同请求之间的上下文信息互相干扰。
  • 在中间件中初始化 AsyncLocalStorage 这是设置请求上下文的最佳位置,因为中间件在请求处理的早期运行,可以确保在所有后续的处理环节中都可以访问到上下文信息。
  • 使用 try...catch 块进行错误处理: 捕获可能发生的错误,并清理上下文信息,避免信息泄露或污染。
  • 将请求 ID 附加到日志消息中: 方便追踪请求的整个生命周期。
  • 考虑性能优化: 避免在 AsyncLocalStorage 中存储大量数据,减少 AsyncLocalStorage.getStore() 的调用次数。
  • 与日志系统集成: 将上下文信息与日志系统集成,提高问题排查的效率。

8. 进阶技巧与应用场景

除了基本的请求上下文追踪,你还可以利用 AsyncLocalStorage 实现更高级的功能:

  • 分布式链路追踪: 将请求 ID 和链路追踪 ID 在不同的微服务之间传递,实现跨服务的链路追踪。这需要使用消息队列、HTTP 头部等方式进行上下文信息的传播。
  • 多租户支持: 在请求上下文中存储租户 ID,以便于在多租户环境中隔离数据和逻辑。
  • 权限控制: 在请求上下文中存储用户权限信息,方便在服务中进行权限校验。
  • 请求范围的缓存: 可以基于请求上下文实现请求范围的缓存,避免重复计算或数据库查询。
  • 数据库事务管理: 在请求上下文中存储数据库连接和事务,确保在整个请求的生命周期中,数据库操作都处于同一个事务中。

9. 总结

AsyncLocalStorage 是一个强大的工具,可以帮助你更好地管理请求上下文,提高 NestJS 应用的开发效率、可维护性和可扩展性。通过本文,你已经了解了如何在 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪,包括基础概念、实战演练、错误处理、性能优化和与日志系统的集成。希望这些知识和实践经验能够帮助你构建更健壮、更易于维护的 NestJS 应用。

记住,在实际应用中,要根据具体的需求和场景,选择最合适的实现方式,并进行充分的测试和优化。祝你开发顺利!

技术老鸟 NestJSAsyncLocalStorage请求上下文链路追踪

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/7898