requestId 的作用
在前端开发中,有浏览器这个载体,报错信息可以在浏览器的控制台看到。
后端开发的载体是服务器,上线出现的错误只能到服务器上去看打印的日志。
问题是服务器是支持并发请求的,当同一时间处理多个请求,每个请求的链路又比较长,且有的中间环节还有异步(请求其他服务)时,就很难分辨哪个日志属于哪个请求,所以日志和请求的关联关系对于后端开发查看日志来说就很重要了。
举例说明:
@Controller()
export class MiddleController {
@Get('/getList')
async getList(@Query('username') username: string) {
console.log('username', username);
const res = await this.getNotes(username);
console.log('res', res);
return res;
}
async getNotes(username: string) {
return fetch('http://localhost:3001/notes?username=' + username, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json());
}
}
当 usename 为 123 的用户访问接口 /getList
时,先打印 username 123
,然后请求远程的笔记接口。
随后 usename 为 789 的用户访问接口 /getList
,打印 username 789
然后请求远程的笔记接口。
然后两个用户的请求回来了,最终的打印结果为
username 123
username 789
res 返回的数据
res 返回的数据
现在无法区分返回的数据哪个是123的,哪个是789的。
当然你可以这样修改来查看归属:
console.log('res', res);
// 改成
console.log('res', username , res);
但如果这个接口调用很多的其他模块方法,每一个模块都要透传这个标记参数,那就太麻烦了。
最佳实践:AsyncLocalStorage
AsyncLocalStorage
通过 Node.js 的 async_hooks 模块来跟踪异步操作的执行上下文。当你在一个“存储区域”(store)中运行一个函数时,AsyncLocalStorage
会记录这个上下文。任何在该函数内部触发的后续异步操作,都能访问到之前在这个上下文中存储的值。
使用方法
- 创建存储空间 (Store): 创建一个 AsyncLocalStorage 实例,就像一个独立的盒子。
- 运行并存储 (Run and Store): 使用 .run() 方法在这个“盒子”里启动一个函数,并提供一个初始值(存储空间)。
- 在任何地方访问 (Access Anywhere): 在该函数及其所有同步或异步后继代码中,你都可以使用 .getStore() 方法来获取当前异步上下文中的那个值。
nest中如何使用
1. 首先定义一个async模块,用来包裹整个请求的异步上下文
async.service.ts
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
// 定义上下文数据接口
interface AsyncContext {
XTransactionID: string;
}
@Injectable()
export class AsyncContextService {
private readonly asyncLocalStorage = new AsyncLocalStorage<AsyncContext>();
run<T>(context: AsyncContext, fn: () => T): T {
return this.asyncLocalStorage.run(context, fn);
}
getXTransactionID(): string {
const context = this.asyncLocalStorage.getStore();
return context?.XTransactionID ?? 'NULL';
}
setXTransactionID(XTransactionID: string): void {
const context = this.asyncLocalStorage.getStore();
if (context) {
context.XTransactionID = XTransactionID;
}
}
getContext(): AsyncContext | undefined {
return this.asyncLocalStorage.getStore();
}
}
async.module.ts
import { Module, Global } from '@nestjs/common';
import { AsyncContextService } from './async.service';
@Global()
@Module({
providers: [AsyncContextService],
exports: [AsyncContextService],
})
export class AsyncContextModule {}
2. 定义一个logger模块,用来打印日志
logger.service.ts
import {
Injectable,
Inject,
LoggerService as LService,
} from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { AsyncContextService } from 'src/async.service';
@Injectable()
export class LoggerService {
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private logger!: LService;
@Inject(AsyncContextService)
private asyncContext!: AsyncContextService;
private get getId() {
return this.asyncContext.getXTransactionID();
}
error(message: string, trace?: string) {
this.logger.error(message, trace, this.getId);
}
warn(message: string) {
this.logger.warn(message, this.getId);
}
log(message: string) {
this.logger.log(message, this.getId);
}
verbose(message: string) {
this.logger.verbose?.(message, this.getId);
}
debug(message: string) {
this.logger.debug?.(message, this.getId);
}
}
logger.module.ts
import { Global, Module, Logger } from '@nestjs/common';
import * as winston from 'winston';
import { WinstonModule, utilities } from 'nest-winston';
import { ConfigService } from '@nestjs/config';
import { LoggerService } from 'src/logger.service';
import 'winston-daily-rotate-file';
@Global()
@Module({
imports: [
WinstonModule.forRootAsync({
useFactory: (configService: ConfigService) => {
const logger = new Logger('WinstonConfig');
const winstonConfig = configService.get('winston') as WinstonConfig;
logger.debug(`${JSON.stringify(winstonConfig)}`);
const { level, dirname, filename, datePattern, maxSize, maxFiles } =
winstonConfig;
return {
level: 'debug',
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
utilities.format.nestLike(),
),
}),
new winston.transports.DailyRotateFile({
level,
dirname,
filename,
datePattern,
maxSize,
maxFiles, // 最多保存多少天
zippedArchive: true, // 压缩
format: winston.format.combine(
winston.format.timestamp(),
winston.format.simple(),
),
}),
new winston.transports.DailyRotateFile({
level: 'error',
dirname,
filename: `error-${filename}`,
datePattern,
maxSize,
maxFiles,
zippedArchive: true,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.simple(),
),
}),
],
};
},
inject: [ConfigService],
}),
],
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
3. 定义一个拦截器:interceptor.ts,作用是在请求刚进入服务时,创建 XTransactionID(就是requestId),并且使用async模块进行包裹
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Inject,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { uuid } from 'src/utils';
import { LoggerService } from 'src/logger.service';
import { AsyncContextService } from 'src/async.service';
@Injectable()
export class InvokeRecordInterceptor implements NestInterceptor {
@Inject(LoggerService)
private logger!: LoggerService;
@Inject(AsyncContextService)
private asyncContext!: AsyncContextService;
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
const XTransactionID = uuid();
// 使用AsyncLocalStorage存储XTransactionID
return new Observable((observer) => {
this.asyncContext.run({ XTransactionID }, () => {
const result = this.handleRequest(next);
result.then((obs) => obs.subscribe(observer));
});
});
}
private async handleRequest(
next: CallHandler<any>,
): Promise<Observable<any>> {
const now = Date.now();
const tapOperator = tap((res) => {
this.logger.debug(
JSON.stringify({
time: `${Date.now() - now}ms`,
Response: res,
}),
);
});
return next.handle().pipe(tapOperator);
}
}
通过以上的操作,当请求进来时,拦截器创建的XTransactionID,随后无论当前请求流转在哪些模块的service中,调用的logger 模块都可以获取到在拦截器创建的XTransactionID,保证整个请求周期内XTransactionID一致。
效果展示
cc5fbd73-1e0b-45ee-8958-9aa5b2402ffc
就是流转中的 XTransactionID,在经过多个模块时,依然保持一致。
绝对不推荐使用:Scope.REQUEST
@Injectable({ scope: Scope.REQUEST }) // 请求级作用域
export class xxxService {
}
之所以着重说它,是因为大部分AI工具都会推荐你使用这个方法,我刚开始也是用的这个方法,但是实践过程中发现很多问题,所以最后抛弃这种写法。
Scope.REQUEST
的具体的示例代码就不展示了,因为不推荐,以下说明原因:
1. 性能损耗与内存开销
Scope.REQUEST 会导致每个请求都创建新的提供者实例,而非单例复用。在高并发场景下,这会显著增加内存占用和垃圾回收(GC)压力,降低应用吞吐量。对于需要频繁访问的服务(如日志、追踪等),这种开销会被放大。
在nest中,默认创建的模块都是单例模块,它的特点就是在服务启动时实例化一次(也就是new 这个类),在重新启动服务前,这个实例都不会再初始化了,并且这个模块可以使用 onModuleInit, onApplicationBootstrap 等生命周期钩子。
使用 Scope.REQUEST
定义的模块就是懒加载模块,他在服务启动时并不会实例化,在每个请求流转执行到他时都会实例化,也就是请求流转涉及到的 class 的 constructor 在每次请求时都会执行,这个要特别注意!这就造成一定的性能开销。onModuleInit, onApplicationBootstrap 等生命周期钩子都不会执行了,因为他们已经错过了执行时机,这就像一场派对(应用启动)。onApplicationBootstrap 是派对正式开始时的鸣枪仪式。如果一个客人(懒加载模块)在派对开始一小时后才迟到入场,我们不可能为了他再鸣一次枪。不要小瞧这些小钩子,有的时候他们很有用。而懒加载会导致有些生命周期钩子不可用(onModuleInit, onApplicationBootstrap)
2. 依赖链污染
Nest 的依赖注入系统具有 "作用域继承" 特性:如果一个单例作用域(默认)的提供者依赖了 Scope.REQUEST 作用域的提供者,Nest 会被迫将该单例提供者也转为 Scope.REQUEST 作用域(否则会出现 "单例依赖请求作用域实例" 的矛盾)。这可能导致整个依赖链被 "污染",大量本应是单例的服务被迫变为请求级实例,进一步加剧性能问题。
在 NestJS 中,一旦一个模块直接或间接引入了任何 Scope.REQUEST 的模块,整个模块都会变成“懒加载模块”(Lazy-loaded module)。这个是一个非常值得注意的点,我之前就是使用Scope.REQUEST
定义的logger.service,由于日志打印每个模块基本都用,造成了所有模块几乎都是懒加载模块!
3. 与某些特性不兼容
部分 Nest 特性(如拦截器的 @Injectable() 单例模式、某些缓存策略)与 Scope.REQUEST 存在兼容性问题,可能导致不可预期的行为(如上下文丢失、实例复用异常等)。
目前还没有发现什么场景下必须使用Scope.REQUEST
最后编辑于:2025-09-04 17:53:33
©著作权归作者所有,转载或内容合作请联系作者。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,little笔记系信息发布平台,仅提供信息存储服务。