WEBKT

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

168 0 0 0

你好,我是老码农。今天我们来聊聊在 NestJS 中使用 AsyncLocalStorage 实现请求上下文追踪这个话题。这对于构建大型、可维护的微服务架构至关重要。尤其是在处理分布式追踪、日志记录、权限控制等场景时,一个可靠的请求上下文追踪方案能够极大地简化开发和调试的难度。我们将深入探讨 AsyncLocalStorage 的原理、在 NestJS 中的应用、错误处理、性能优化以及与日志系统的集成,并提供最佳实践。

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

在传统的单体应用中,请求上下文相对简单。你可以轻松地在整个请求的处理过程中传递和访问相关信息,比如用户 ID、请求 ID 等。然而,在微服务架构中,一个请求往往需要经过多个服务之间的调用才能完成。在这种情况下,如果不能有效地追踪请求在不同服务之间的传递和处理过程,那么调试和问题定位将变得异常困难。

请求上下文追踪的主要作用包括:

  • 分布式追踪: 跟踪一个请求在多个服务之间的调用链,帮助你了解请求的完整生命周期,快速定位性能瓶颈和错误发生的位置。
  • 日志记录: 在日志中记录请求的上下文信息,例如请求 ID、用户 ID 等,方便你关联不同服务产生的日志,从而更容易地理解请求的处理过程。
  • 权限控制: 在不同的服务中共享用户身份信息,确保用户在每个服务中都拥有正确的权限。
  • 事务管理: 在分布式事务场景下,确保多个服务之间的操作能够协同一致地完成。

2. AsyncLocalStorage 的原理

AsyncLocalStorage 是 Node.js 12.17.0 引入的一个实验性 API,它提供了一种在异步执行上下文中存储和访问数据的机制。与 ThreadLocal 类似,AsyncLocalStorage 允许你在异步操作(例如 Promiseasync/awaitsetTimeoutsetInterval 等)之间安全地传递和访问数据,而不会发生数据混淆。

AsyncLocalStorage 的核心概念是上下文(Context)。一个上下文就像一个容器,你可以将数据存储在其中。在异步操作开始时,你可以创建一个新的上下文,并将数据放入其中。当异步操作执行时,你可以随时访问该上下文中的数据。即使异步操作涉及多个异步调用,AsyncLocalStorage 也能确保数据在整个调用链中正确传递。

AsyncLocalStorage 的主要方法包括:

  • const asyncLocalStorage = new AsyncLocalStorage():创建一个 AsyncLocalStorage 实例。
  • asyncLocalStorage.run(store, callback, ...args):创建一个新的上下文,并将 store 中的数据存储在其中。然后,在新的上下文中执行 callback 函数,并传递 args 参数。callback 函数可以访问 store 中的数据。
  • asyncLocalStorage.getStore():在当前上下文中获取存储的数据。如果当前没有上下文,则返回 undefined

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

在 NestJS 中,我们可以利用中间件(Middleware)和拦截器(Interceptor)来拦截每个 HTTP 请求,并使用 AsyncLocalStorage 创建一个请求上下文。以下是具体步骤:

3.1. 安装依赖

首先,确保你的 Node.js 版本支持 AsyncLocalStorage(Node.js 12.17.0 及以上)。然后,创建一个新的 NestJS 项目或者在现有的项目中安装以下依赖:

npm install @nestjs/common @nestjs/core

3.2. 创建 AsyncLocalStorage 实例

创建一个 AsyncLocalStorage 实例,并将其导出,以便在其他模块中使用。通常,我们会将其定义在一个全局的模块或者单独的文件中,例如 async-local-storage.ts

// async-local-storage.ts
import { AsyncLocalStorage } from 'async_hooks';

export const asyncLocalStorage = new AsyncLocalStorage<{ requestId: string; userId?: string }>();

3.3. 创建中间件(Middleware)

创建一个中间件,用于在每个请求开始时生成一个唯一的请求 ID,并将其存储在 AsyncLocalStorage 中。这个请求 ID 可以用于后续的日志记录和分布式追踪。例如,request-context.middleware.ts

// request-context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { asyncLocalStorage } from './async-local-storage';

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const requestId = req.headers['x-request-id'] || uuidv4();

    asyncLocalStorage.run({ requestId }, () => {
      // 将 requestId 设置到响应头中,方便客户端获取
      res.setHeader('X-Request-Id', requestId);
      next();
    });
  }
}

3.4. 注册中间件

在你的 NestJS 模块中注册中间件。例如,在 app.module.ts 中:

// app.module.ts
import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { RequestContextMiddleware } from './request-context.middleware';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(RequestContextMiddleware)
      .forRoutes({ path: '*', method: RequestMethod.ALL }); // 应用于所有路由
  }
}

3.5. 在服务中使用 AsyncLocalStorage

现在,你可以在你的服务中使用 asyncLocalStorage.getStore() 来获取当前请求的上下文信息。例如,在 app.service.ts 中:

// app.service.ts
import { Injectable } from '@nestjs/common';
import { asyncLocalStorage } from './async-local-storage';

@Injectable()
export class AppService {
  getData(): string {
    const store = asyncLocalStorage.getStore();
    const requestId = store?.requestId || 'unknown';
    return `Hello World! Request ID: ${requestId}`;
  }
}

3.6. 在控制器中使用

// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getData(): string {
    return this.appService.getData();
  }
}

3.7. 测试

运行你的 NestJS 应用,并访问根路径。你会看到响应中包含了请求 ID。你还可以通过在请求头中设置 X-Request-Id 来手动指定请求 ID。

4. 错误处理

在处理错误时,我们需要确保错误信息中包含请求上下文信息,以便于定位问题。以下是一些错误处理的最佳实践:

4.1. 全局异常过滤器

创建一个全局异常过滤器,用于捕获所有未处理的异常,并添加请求 ID 到错误日志中。例如,app.exception-filter.ts

// app.exception-filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import { asyncLocalStorage } from './async-local-storage';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  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 store = asyncLocalStorage.getStore();
    const requestId = store?.requestId || 'unknown';

    const errorResponse = {
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      requestId,
      message: exception.message || 'Internal server error',
    };

    console.error('Error with request ID:', requestId, exception);

    response.status(status).json(errorResponse);
  }
}

4.2. 注册全局异常过滤器

main.ts 中注册全局异常过滤器:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './app.exception-filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new AllExceptionsFilter());
  await app.listen(3000);
}
bootstrap();

4.3. 在服务中手动抛出异常

在你的服务中,当发生错误时,手动抛出异常,并确保异常信息能够被全局异常过滤器捕获。例如:

// app.service.ts
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { asyncLocalStorage } from './async-local-storage';

@Injectable()
export class AppService {
  getData(): string {
    const store = asyncLocalStorage.getStore();
    const requestId = store?.requestId || 'unknown';

    if (Math.random() < 0.5) {
      throw new HttpException('Something went wrong!', HttpStatus.INTERNAL_SERVER_ERROR);
    }

    return `Hello World! Request ID: ${requestId}`;
  }
}

5. 性能优化

使用 AsyncLocalStorage 的时候,虽然它提供了很好的请求上下文追踪功能,但是我们也需要注意性能问题。以下是一些性能优化的技巧:

5.1. 避免在每个请求中创建新的 AsyncLocalStorage 实例

AsyncLocalStorage 的创建和销毁有一定的开销。在 NestJS 中,我们只需要创建一个全局的 AsyncLocalStorage 实例,并在每个请求中使用 run 方法创建新的上下文即可。

5.2. 谨慎存储数据

AsyncLocalStorage 存储的数据应该尽量精简,避免存储过多的数据,特别是大型对象。如果需要存储大型对象,可以考虑使用缓存或者其他更高效的存储方式。

5.3. 避免在同步代码中访问 AsyncLocalStorage

AsyncLocalStorage 主要用于异步操作。在同步代码中访问 AsyncLocalStorage 可能会导致性能问题。尽量确保你在异步上下文中访问它。

5.4. 监控性能

使用性能监控工具来监控应用的性能,特别是 AsyncLocalStorage 的使用情况。如果发现性能瓶颈,可以根据监控数据进行优化。

6. 与日志系统的集成

将请求上下文信息与日志系统集成是请求上下文追踪的重要组成部分。这样,你可以更容易地关联不同服务产生的日志,从而更容易地理解请求的处理过程。

6.1. 使用 NestJS 的内置日志系统

NestJS 提供了内置的日志系统,你可以使用它来记录日志。通过在全局异常过滤器和中间件中获取请求 ID,并将它添加到日志中,可以实现请求上下文的关联。

// app.exception-filter.ts
import { Logger } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: Error, host: ArgumentsHost) {
    // ...
    this.logger.error(
      `Error with request ID: ${requestId}`,exception.stack,
    );
    // ...
  }
}
// request-context.middleware.ts
import { Logger } from '@nestjs/common';

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
    private readonly logger = new Logger(RequestContextMiddleware.name);
    use(req: Request, res: Response, next: NextFunction) {
        const requestId = req.headers['x-request-id'] || uuidv4();

        asyncLocalStorage.run({ requestId }, () => {
            res.setHeader('X-Request-Id', requestId);
            this.logger.log(`Request started. Request ID: ${requestId}`);
            next();
        });
    }
}

6.2. 使用第三方日志库

除了 NestJS 的内置日志系统,你还可以使用第三方的日志库,例如 Winston 或 Bunyan。这些日志库提供了更强大的功能,例如日志级别、日志格式、日志输出到不同的目标(文件、数据库、云服务等)。

在使用第三方日志库时,你需要配置它,以便将请求 ID 包含在日志中。例如,使用 Winston:

// winston.logger.ts
import * as winston from 'winston';
import { asyncLocalStorage } from './async-local-storage';

const { createLogger, format, transports } = winston;
const { combine, timestamp, printf, colorize } = format;

const myFormat = printf(({ level, message, timestamp, requestId, ...metadata }) => {
  let msg = `${timestamp} [${level}] ${message} `;
  if (requestId) {
    msg += `[Request ID: ${requestId}] `; // Include Request ID
  }
  if (metadata && Object.keys(metadata).length) {
    msg += JSON.stringify(metadata);
  }
  return msg;
});

const logger = createLogger({
  format: combine(colorize(), timestamp(), myFormat),
  transports: [new transports.Console()],
});

export function getLogger(context: string) {
  return {
    info: (message: string, meta?: any) => {
      const store = asyncLocalStorage.getStore();
      const requestId = store?.requestId;
      logger.info(message, { ...meta, requestId, context }); // Include Request ID
    },
    error: (message: string, meta?: any) => {
      const store = asyncLocalStorage.getStore();
      const requestId = store?.requestId;
      logger.error(message, { ...meta, requestId, context }); // Include Request ID
    },
    warn: (message: string, meta?: any) => {
      const store = asyncLocalStorage.getStore();
      const requestId = store?.requestId;
      logger.warn(message, { ...meta, requestId, context }); // Include Request ID
    },
    debug: (message: string, meta?: any) => {
      const store = asyncLocalStorage.getStore();
      const requestId = store?.requestId;
      logger.debug(message, { ...meta, requestId, context }); // Include Request ID
    },
  };
}

然后在你的服务中使用它:

// app.service.ts
import { Injectable } from '@nestjs/common';
import { getLogger } from './winston.logger';

@Injectable()
export class AppService {
  private readonly logger = getLogger(AppService.name);

  getData(): string {
    this.logger.info('Getting data');
    return 'Hello World!';
  }
}

6.3. 日志聚合和分析

将日志输出到文件或云服务后,你需要使用日志聚合和分析工具来收集、存储、搜索和分析日志。常见的日志聚合和分析工具有:

  • ELK Stack (Elasticsearch, Logstash, Kibana): 这是一个强大的开源日志解决方案,可以收集、处理、存储和分析日志。
  • Splunk: 这是一个商业日志解决方案,提供了更高级的功能,例如机器学习和异常检测。
  • Graylog: 这是一个开源的日志管理平台,提供了集中式日志管理、搜索和分析功能。
  • 云服务商提供的日志服务: 例如 AWS CloudWatch Logs, Azure Monitor, Google Cloud Logging 等,它们提供了云原生的日志收集、存储和分析服务。

7. 与其他 NestJS 特性的集成

AsyncLocalStorage 可以与其他 NestJS 特性无缝集成,例如:

7.1. 拦截器(Interceptor)

拦截器可以用来在请求处理的前后执行一些逻辑。你可以使用拦截器来记录请求的耗时、修改响应等。结合 AsyncLocalStorage,你可以在拦截器中访问请求的上下文信息,并将其用于记录日志或修改响应。

// request.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { asyncLocalStorage } from './async-local-storage';

@Injectable()
export class RequestInterceptor implements NestInterceptor {
    private readonly logger = new Logger(RequestInterceptor.name);
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        const now = Date.now();
        const store = asyncLocalStorage.getStore();
        const requestId = store?.requestId || 'unknown';
        return next
            .handle()
            .pipe(
                tap(() => {
                    const time = Date.now() - now;
                    this.logger.log(`Request completed. Request ID: ${requestId}. Time: ${time}ms`);
                }),
            );
    }
}

7.2. 守卫(Guard)

守卫可以用来在请求处理之前进行身份验证和授权。你可以使用守卫来获取用户身份信息,并将其存储在 AsyncLocalStorage 中,以便在后续的服务中使用。

// auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { asyncLocalStorage } from './async-local-storage';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const userId = request.headers['x-user-id'];

    if (!userId) {
      throw new UnauthorizedException();
    }

    asyncLocalStorage.run({ requestId: asyncLocalStorage.getStore()?.requestId, userId }, () => {
      // 将用户 ID 存储到 AsyncLocalStorage 中
    });

    return true;
  }
}

8. 总结与最佳实践

通过使用 AsyncLocalStorage,你可以在 NestJS 中实现可靠的请求上下文追踪,从而简化开发和调试的难度。以下是一些总结和最佳实践:

  • 创建全局的 AsyncLocalStorage 实例: 避免在每个请求中创建新的实例。
  • 使用中间件和拦截器: 拦截 HTTP 请求,并创建请求上下文。
  • 存储精简的数据: 避免存储过多的数据,特别是大型对象。
  • 与日志系统集成: 将请求上下文信息添加到日志中,方便问题定位。
  • 使用全局异常过滤器: 捕获未处理的异常,并添加请求上下文信息。
  • 监控性能: 监控 AsyncLocalStorage 的使用情况,并进行优化。
  • 考虑使用第三方库: 如果你的项目需要更高级的功能,可以考虑使用第三方库,例如 cls-hookedcontinuation-local-storage
  • 编写单元测试: 确保你的代码能够正确地处理异步上下文。
  • 避免在同步代码中访问: 确保你在异步上下文中访问 AsyncLocalStorage

希望这篇文章能够帮助你理解和应用 AsyncLocalStorage 在 NestJS 中的请求上下文追踪。记住,在实际开发中,你需要根据你的具体需求来调整和优化你的方案。祝你在 NestJS 的世界里玩得开心!如果你有任何问题,欢迎随时提出。我会持续更新,并分享更多关于 NestJS 的知识和经验。加油!

老码农 NestJSAsyncLocalStorage请求上下文分布式追踪

评论点评