J6 · 请求处理链:异常、过滤器、管道、拦截器
从 HTTP socket 到你的控制器方法之间,一个 Nest 请求要依次经过一条固定的组件链:中间件、guard、拦截器、管道, 出错时再交给异常过滤器。Spring 把同样的职责分散在 servlet filter、HandlerInterceptor、AOP 和 @ControllerAdvice 里。学完今天的内容,你会知道横切逻辑该放在哪里,而 TaskFlow 会以统一的格式返回所有错误、 为每个请求记录日志,并用追踪 ID 跟踪每一次调用。
目标
- 了解请求处理链的执行顺序,为每种需求选对组件。
- 抛出内置的 HTTP 异常,并用一个全局过滤器把它们转换成统一的错误格式。
- 使用
ParseUUIDPipe、ParseEnumPipe,并编写自定义管道。 - 编写拦截器(日志、耗时、响应映射、缓存)。
- 编写中间件(类或函数形式),并用
configure(consumer)注册。 - 在 TaskFlow 里交付:
AllExceptionsFilter(problem+json)、TypeORM 错误映射、LoggingInterceptor和CorrelationIdMiddleware。
Spring → NestJS
| Spring | NestJS | 要点 |
|---|---|---|
servlet Filter(OncePerRequestFilter) | 中间件(NestMiddleware 或普通函数) | 在路由之前看到原始请求,不知道会由哪个处理函数响应 |
Spring Security 过滤器链、返回 false 的 HandlerInterceptor.preHandle() | Guard(CanActivate) | 决定放行与否;知道处理函数及其元数据(J8) |
AOP @Around、HandlerInterceptor | 拦截器(NestInterceptor) | 在处理函数前后执行代码,围绕一个 RxJS Observable |
Converter、@InitBinder、@Valid | 管道(PipeTransform) | 在调用前转换并校验每个参数 |
@ControllerAdvice + @ExceptionHandler | 全局过滤器(APP_FILTER)+ @Catch() | 整个 API 的错误格式只在一个地方定义 |
ResponseStatusException | HttpException、NotFoundException、ConflictException 等 | 要抛出,而不是返回 |
ProblemDetail(Spring 6) | 没有原生对应物:TaskFlow 自己写了 ProblemDetails | 格式要在过滤器里自己实现 |
SQLException → DataIntegrityViolationException 的转换 | 没有:读取 QueryFailedError.driverError.code | TypeORM 不转换 SQL 错误码 |
原理
执行顺序
请求
→ 中间件 CorrelationIdMiddleware, helmet(), pino-http
→ Guard ThrottlerGuard → JwtAuthGuard → RolesGuard
→ 拦截器(前) LoggingInterceptor, ClassSerializerInterceptor
→ 管道 ValidationPipe(全局), ParseUUIDPipe, ParseEnumPipe(参数级)
→ 处理函数 控制器方法,然后是 service
→ 拦截器(后) 顺序相反:ClassSerializerInterceptor,然后 LoggingInterceptor
响应
↳ 异常过滤器 AllExceptionsFilter,无论异常是在哪一步抛出的在每一层,顺序都是从最宽到最具体:先全局,再控制器,再方法(管道还有参数级)。过滤器则相反:Nest 先找方法上 的过滤器,再找控制器上的,最后找全局的,由第一个 @Catch() 匹配的过滤器处理异常。过滤器之间不会串联。
TaskFlow 把所有全局组件都作为 provider 声明在 AppModule 里:
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true, // strip properties without a decorator
forbidNonWhitelisted: true, // ...or reject the request: 400
transform: true, // plain JSON -> DTO instances, "2" -> 2
}),
},
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],这个顺序在实际中意味着:
- 没有 token 的请求会停在
JwtAuthGuard。拦截器和管道都不会执行:过滤器直接返回 401。所以一个既没有 token、又带着无效 id 的请求会得到 401,而不是 400。 - 带 token 访问
/v1/projects/not-a-uuid:通过 guard,启动LoggingInterceptor,然后ParseUUIDPipe抛出BadRequestException。拦截器看到错误经过,过滤器返回 400。 - 状态码(200、201 或
@HttpCode()的值)由 Nest 在 guard 之后、拦截器之前确定。所以拦截器可以在处理函数 结束后读到它。
内置 HTTP 异常
HttpException(response, status) 是基类。Nest 为常见状态码各提供了一个子类:BadRequestException(400)、 UnauthorizedException(401)、ForbiddenException(403)、NotFoundException(404)、ConflictException (409)、UnprocessableEntityException(422)、InternalServerErrorException(500)、 ServiceUnavailableException(503)等。你可以在链上任何地方(guard、管道、service)抛出它们,由过滤器转换成 响应。
没有自定义过滤器时,Nest 使用默认过滤器:
{ "message": "Project 42 not found", "error": "Not Found", "statusCode": 404 }其他任何错误(TypeError、PostgreSQL 驱动的错误等)都会变成 { "statusCode": 500, "message": "Internal server error" }。 有两点要注意:
exception.getResponse()返回的可能是字符串,也可能是对象。ValidationPipe在里面放的是消息数组 (["name should not be empty"],见 J2)。自定义过滤器必须 处理这两种形式。- 第二个参数可以传选项:
new ConflictException('Email already registered', { cause: error })会保留原始错误供 日志使用,但不暴露给客户端。
TaskFlow 在 service 里直接抛出 NotFoundException 和 ConflictException,就像在 Spring 的 @Service 里抛 ResponseStatusException。这样很务实:service 知道 HTTP 的存在。如果你想要一个独立的领域层,就抛自己的业务 异常,再在过滤器里转换。
异常过滤器与全局过滤器
过滤器实现 ExceptionFilter 及其 catch(exception, host) 方法。@Catch(QueryFailedError) 把它限定在某些 类型上;不带参数的 @Catch() 捕获一切,包括不是 HttpException 的错误。host(一个 ArgumentsHost)可以 拿到请求和响应:host.switchToHttp().getRequest()。
有三种挂载方式:
| 作用范围 | 声明 | 说明 |
|---|---|---|
| 方法或控制器 | @UseFilters(MyFilter) | 用于局部情况 |
| 全局,支持依赖注入 | 在某个模块里写 { provide: APP_FILTER, useClass: AllExceptionsFilter } | 可以注入 provider;在启动 AppModule 的端到端测试里也生效 |
| 全局,不支持依赖注入 | 在 main.ts 里写 app.useGlobalFilters(new AllExceptionsFilter(...)) | 实例由你自己构建;不执行 main.ts 的测试里不存在 |
全局过滤器也会捕获来自中间件、guard 和未知路由的错误:请求 /v1/nope 会得到 404,消息是 Cannot GET /v1/nope,格式和其他错误一致。
和 @ControllerAdvice 类比失效的地方:
- Spring 会根据异常的继承层次,选择最具体的
@ExceptionHandler方法。Nest 则从最近的作用范围到最宽的作用 范围(方法、控制器、全局)依次查找过滤器,取第一个@Catch()匹配的,不比较类型的具体程度。 @Catch()过滤器会完全取代默认行为:500 的日志记录、未知错误,全都要自己负责。如果只想处理部分情况,就 继承BaseExceptionFilter,其余的交给super.catch(exception, host)。
管道
管道接收处理函数的一个参数(@Param、@Query、@Body),对它进行转换或拒绝。它在调用处理函数之前执行, 位于 guard 和拦截器的前半段之后。如果它抛出异常,处理函数就永远不会被调用。
有用的内置管道:ValidationPipe、ParseIntPipe、ParseBoolPipe、ParseArrayPipe、ParseUUIDPipe、 ParseEnumPipe、DefaultValuePipe。TaskFlow 在参数上用了其中两个:
@Param('id', ParseUUIDPipe):传入类,由 Nest 创建实例(需要时还会为它注入依赖)。/v1/projects/not-a-uuid→ 400,消息是Validation failed (uuid is expected)。@Query('status', new ParseEnumPipe(TaskStatus, { optional: true })):传入实例,就可以传选项。?status=LATE→ 400,消息是Validation failed (enum string is expected);没有?status时,由于optional: true,值保持为undefined。
任务控制器把它们组合在一起:
@ApiTags('tasks')
@ApiBearerAuth()
@Controller()
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Post('projects/:projectId/tasks')
create(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: AuthUser,
) {
return this.tasks.create(projectId, dto, user);
}
// Pipes convert and validate one parameter, like a Spring Converter:
// "?status=foo" is rejected with 400 before the handler runs.
@Get('projects/:projectId/tasks')
list(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('status', new ParseEnumPipe(TaskStatus, { optional: true })) status: TaskStatus | undefined,
@CurrentUser() user: AuthUser,
) {
return this.tasks.list(projectId, status, user);
}
@Patch('tasks/:id/status')
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTaskStatusDto,
@CurrentUser() user: AuthUser,
) {
return this.tasks.updateStatus(id, dto.status, user);
}
}自定义管道实现 PipeTransform。metadata.data 里是参数名,写错误消息时很方便:
// TaskFlow 之外的示例:"/v1/reports/2026-11" → { year: 2026, month: 11 }
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
export interface YearMonth {
year: number;
month: number;
}
@Injectable()
export class ParseYearMonthPipe implements PipeTransform<string, YearMonth> {
transform(value: string, metadata: ArgumentMetadata): YearMonth {
const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value);
if (!match) throw new BadRequestException(`${metadata.data} must be YYYY-MM`);
return { year: Number(match[1]), month: Number(match[2]) };
}
}
// 用法:report(@Param('month', ParseYearMonthPipe) month: YearMonth)在 Spring 里,这是一个全局注册的 Converter<String, YearMonth>。区别在于:管道要显式挂到某个参数上(或者用 @UsePipes() 挂到处理函数上,用 APP_PIPE 挂到整个应用上)。没有任何东西会被“神奇地”转换:没有管道、也没有 transform: true 时,路由参数始终是字符串。
拦截器
拦截器实现 intercept(context, next)。写在 next.handle() 之前的代码在处理函数之前执行;next.handle() 返回一个会发出处理函数结果的 Observable,用 .pipe() 加上的 RxJS 运算符在之后执行。next.handle() 扮演的 是 @Around 里 ProceedingJoinPoint.proceed() 的角色,但有三点不同:
next.handle()是惰性的:如果你不返回它的Observable(或由它派生的),处理函数就不会执行。此时响应就是 你的Observable发出的内容,或者什么都没有。- 拦截器只包裹 Nest 的处理函数(HTTP 路由、gateway、微服务),而不是任意 bean 方法。
- 在它之前就被拒绝的请求(中间件、guard),它看不到。
计划里提到的用途:
| 用途 | 技巧 | 在 TaskFlow 里 |
|---|---|---|
| 日志和耗时 | 在 next.handle() 之前计时,之后用 tap() | LoggingInterceptor(TaskFlow 案例) |
| 响应映射 | 对结果用 map() | ClassSerializerInterceptor 在每个响应上应用 @Exclude() |
| 缓存 | 不调用 next.handle(),直接返回 of(缓存的值) | 没有(见下文) |
| 错误、超时 | catchError()、timeout() | 没有 |
两个简短的示例:
// TaskFlow 之外的示例:把每个响应包进 { data }
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';
@Injectable()
export class WrapDataInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<{ data: unknown }> {
return next.handle().pipe(map((data) => ({ data })));
}
}// TaskFlow 之外的示例:30 秒缓存,只用于对所有人都相同的公开路由
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request } from 'express';
import { Observable, of, tap } from 'rxjs';
@Injectable()
export class ShortCacheInterceptor implements NestInterceptor {
private readonly cache = new Map<string, { value: unknown; expires: number }>();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const key = context.switchToHttp().getRequest<Request>().originalUrl;
const hit = this.cache.get(key);
if (hit && hit.expires > Date.now()) return of(hit.value); // 不调用处理函数
return next.handle().pipe(tap((value) => this.cache.set(key, { value, expires: Date.now() + 30_000 })));
}
}以 URL 为键的缓存会给所有人返回同一个响应
在 TaskFlow 里,GET /v1/projects 取决于用户(成员只能看到自己的项目)。只以 URL 为键的缓存会把 Alice 的项目 返回给 Bob。要么把身份信息加进键里,要么只对公开数据做缓存。真正的缓存(过期、容量上限、Redis),官方文档推荐 @nestjs/cache-manager 的 CacheInterceptor,TaskFlow 没有安装它。
中间件
中间件是一个 Express 函数 (req, res, next)。它在 Nest 路由之前执行:既不知道控制器,也不知道处理函数,所以 读不到 @Public() 或 @Roles()。这就是为什么认证用 guard 而不是中间件来做。
两种形式:
- 实现
NestMiddleware的@Injectable()类:可以接收依赖。在实现了NestModule的模块里,用configure(consumer)注册:consumer.apply(...).exclude(...).forRoutes(...)。 - 函数:没有状态,也没有依赖。注册方式相同,也可以用
app.use()注册到整个应用。TaskFlow 在setupApp()里就是这样注册helmet()的。
// TaskFlow 之外的示例:函数式中间件
import type { NextFunction, Request, Response } from 'express';
export function noStore(_req: Request, res: Response, next: NextFunction) {
res.setHeader('Cache-Control', 'no-store'); // 每个用户各自不同的响应
next();
}
// 在 AppModule.configure() 里:consumer.apply(CorrelationIdMiddleware, noStore).forRoutes(...)和 servlet Filter 类比失效的地方:在 doFilter() 里,写在 chain.doFilter() 之后的代码会在响应生成后执行。 Express 中间件调用 next() 后拿不到任何返回结果;要在响应之后做事,必须监听 res.on('finish', ...)。另外, 一个既不调用 next() 也不调用 res.end() 的中间件会让请求一直挂起。
TaskFlow 案例
全局 AllExceptionsFilter
// Like @ControllerAdvice + @ExceptionHandler(Exception.class): one place that
// turns every error into the same problem+json body.
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost) {
const { httpAdapter } = this.adapterHost;
const request = host.switchToHttp().getRequest<Request>();
const { status, code, message } = this.describe(exception);
if (status >= 500) this.logger.error(exception);
const body: ProblemDetails = {
status,
code,
message,
path: request.url,
timestamp: new Date().toISOString(),
correlationId: request.headers[CORRELATION_HEADER] as string | undefined,
};
const response = host.switchToHttp().getResponse();
httpAdapter.setHeader(response, 'Content-Type', 'application/problem+json');
httpAdapter.reply(response, body, status);
}
private describe(exception: unknown): Pick<ProblemDetails, 'status' | 'code' | 'message'> {
if (exception instanceof HttpException) {
const res = exception.getResponse();
const message = typeof res === 'string' ? res : ((res as { message?: string | string[] }).message ?? exception.message);
return { status: exception.getStatus(), code: HttpStatus[exception.getStatus()] ?? 'ERROR', message };
}
if (exception instanceof EntityNotFoundError) {
return { status: HttpStatus.NOT_FOUND, code: 'NOT_FOUND', message: 'Resource not found' };
}
if (exception instanceof QueryFailedError) {
const pgCode = (exception.driverError as { code?: string } | undefined)?.code;
if (pgCode === PG_UNIQUE_VIOLATION) {
return { status: HttpStatus.CONFLICT, code: 'CONFLICT', message: 'Resource already exists' };
}
if (pgCode === PG_FOREIGN_KEY_VIOLATION) {
return { status: HttpStatus.UNPROCESSABLE_ENTITY, code: 'INVALID_REFERENCE', message: 'Referenced resource does not exist' };
}
}
// Never leak internal details (SQL, stack traces) to the client.
return { status: HttpStatus.INTERNAL_SERVER_ERROR, code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected error' };
}
}- 不带参数的
@Catch():所有错误都经过describe(),由它决定status、code和message。HttpException保留自己的状态码和消息(包括数组形式);code来自HttpStatus枚举(404→NOT_FOUND)。 correlationId从中间件设置的请求头里读取,path来自request.url。HttpAdapterHost让响应不依赖 Express(httpAdapter.reply()),这个过滤器换成 Fastify 也能工作。由于它通过 构造函数注入,过滤器通过APP_FILTER注册。- 这里只记录
>= 500的错误,并带上调用栈。客户端永远收不到原始消息:单元测试验证了包含password的错误 只会返回一个通用的 500。
端到端测试发送一个不带 token、但带有自己追踪 ID 的请求:
GET /v1/projects
x-correlation-id: test-123HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=utf-8
x-correlation-id: test-123
{
"status": 401,
"code": "UNAUTHORIZED",
"message": "Unauthorized",
"path": "/v1/projects",
"timestamp": "2026-11-17T10:04:51.312Z",
"correlationId": "test-123"
}用了 problem+json,但没有严格遵循 RFC 9457
TaskFlow 采用了 application/problem+json 媒体类型和 status 字段。RFC 9457 还定义了 type、title、 detail 和 instance;TaskFlow 改用 code、message 和 path(RFC 允许扩展字段)。如果客户端要求严格遵循 RFC,就把 message 改名为 detail、path 改名为 instance,并加上 title。
TypeORM 错误映射
TypeORM 不会像 Spring 那样转换 SQL 错误(DataIntegrityViolationException)。所以过滤器在上面的 describe() 里自己读取需要的信息:
| 错误 | 来源 | 响应 |
|---|---|---|
EntityNotFoundError | findOneOrFail()、findOneByOrFail() | 404 NOT_FOUND |
QueryFailedError,PostgreSQL 错误码 23505 | 违反唯一约束 | 409 CONFLICT |
QueryFailedError,错误码 23503 | 外键指向不存在的行 | 422 INVALID_REFERENCE |
| 其他任何错误 | bug、数据库不可用等 | 500 INTERNAL_SERVER_ERROR,不带细节 |
错误码是 pg 驱动返回的 PostgreSQL SQLSTATE(exception.driverError.code)。它取决于数据库:换成 MySQL,取值 会不一样。
这些分支在 TaskFlow 里的用途:
- 重复的邮箱。
AuthService.register()先检查邮箱,抛出ConflictException('Email already registered'): 这就是端到端测试验证的 409。但两个同时进行的注册可能一起通过这个检查;这时users.email上的唯一索引会拒绝 第二个(23505),过滤器同样返回 409。过滤器的单元测试覆盖了这种情况。 - 资源不存在。 service 用
findOneBy(),再抛出带有明确消息的NotFoundException。EntityNotFoundError分支是给使用findOneByOrFail()的人准备的安全网;目前代码里没有这类调用。 - 无效引用。 用一个不对应任何用户的
assigneeId创建任务,会违反外键约束,按代码逻辑返回 422(端到端测试 没有覆盖这种情况)。
POST /v1/auth/register
Content-Type: application/json
{ "email": "twice@example.com", "password": "a long enough password" }{
"status": 409,
"code": "CONFLICT",
"message": "Email already registered",
"path": "/v1/auth/register",
"timestamp": "2026-11-17T10:06:12.054Z",
"correlationId": "0f6c2a5e-8f0d-4c7e-9a43-4a1b2b8d5e11"
}绝不要返回 SQL 错误的内容
QueryFailedError 携带着 SQL 查询(query)、它的参数和驱动的各个字段,其中包括 PostgreSQL 的 detail (Key (email)=(...) already exists)。把它返回给客户端会暴露数据库结构和数据。所以过滤器返回的是固定消息; 细节只留在服务器日志里(500 的调用栈、LoggingInterceptor 的警告)。
LoggingInterceptor:方法、路由、耗时
// Like an AOP @Around advice: code before next.handle() runs before the
// handler, the RxJS pipe runs after it (or when it fails).
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<Request>();
const started = performance.now();
const route = `${request.method} ${request.route?.path ?? request.url}`;
return next.handle().pipe(
tap({
next: () => {
const status = http.getResponse<Response>().statusCode;
this.logger.log(`${route} ${status} ${Math.round(performance.now() - started)}ms`);
},
error: (error: unknown) => {
this.logger.warn(`${route} failed after ${Math.round(performance.now() - started)}ms: ${String(error)}`);
},
}),
);
}
}request.route?.path给出的是路由模板(/v1/projects/:id),而不是实际 URL:日志按路由归类,也不会包含 id。- 在
tap.next里,状态码已经确定(见执行顺序)。出错时,tap.error记录一条警告,然后让 错误继续传给过滤器。 - 它注册在
ClassSerializerInterceptor之前:最先开始、最后结束,所以耗时包含了序列化。
输出的消息类似这样(上下文为 HTTP):
GET /v1/projects 200 0ms
GET /v1/projects/:id failed after 0ms: NotFoundException: Project … not found被 guard 拒绝的请求(401、403、429)或未知路由不会产生任何日志:拦截器根本没有执行。如果需要记录这些情况, 就在过滤器里记录,或者开启 pino-http 的自动日志(TaskFlow 里关闭了,见 J7)。
CorrelationIdMiddleware
// The equivalent of a servlet Filter: runs before guards, sees the raw request.
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
ensureCorrelationId(req, res);
next();
}
}实际工作由 ensureCorrelationId()(src/common/correlation-id.ts)完成:如果收到的 x-correlation-id 请求头 是不超过 100 个字符的字符串,就沿用它,否则生成一个 UUID(randomUUID())。它把这个值写进 req.headers (过滤器从这里再读出来),并在响应头里返回。pino logger 的 genReqId 调用的也是同一个函数:两者中先执行的那个 确定 ID,后执行的复用它。
中间件应用于所有路由:
configure(consumer: MiddlewareConsumer) {
consumer.apply(CorrelationIdMiddleware).forRoutes({ path: '{*splat}', method: RequestMethod.ALL });
}{*splat} 是 path-to-regexp v8 的语法,Express 5 使用它,所以 Nest 11 和 12 也用它:一个命名通配符,写在 花括号里让它保持可选。Nest 10 那种不带名字的通配符('projects/*')不再被原样接受:Nest 会尝试转换它们,并在 启动时输出警告。
在 Spring 里,这是一个把 ID 放进 MDC 的 OncePerRequestFilter。Node 没有按请求划分的 ThreadLocal: nestjs-pino 用 AsyncLocalStorage 让每一行日志都带上这个 ID(J7)。
场景速查
- 所有错误都要用同一种格式 → 通过
APP_FILTER注册的@Catch()过滤器(全局过滤器)。 - 已被占用的邮箱要返回 409 → 在 service 里抛
ConflictException,再加上唯一约束和23505映射来应对并发访问(TypeORM 错误)。 - 路由参数必须是 UUID 或枚举值 →
ParseUUIDPipe、ParseEnumPipe(管道)。 - 自定义格式要转换成对象(
2026-11) → 自定义管道(管道)。 - 要测量每次调用的耗时 → 拦截器;如果 401 和 429 也要测量,就用带
res.on('finish')的中间件(拦截器)。 - 每个请求都要从头到尾带着一个 ID → 读取或创建
x-correlation-id的中间件(追踪 ID)。 - 所有响应都要做转换 → 用
map()的拦截器(拦截器)。 - 500 不能泄露任何信息 → 客户端只收到通用消息,调用栈连同
correlationId一起记入日志(过滤器)。
陷阱
在 main.ts 里注册全局过滤器
app.useGlobalFilters()(以及 useGlobalInterceptors() 和 useGlobalGuards())只对 main.ts 创建的应用生效: 构建 AppModule 的端到端测试拿到的还是旧的错误格式。这个过滤器也用不了依赖注入。优先用 APP_FILTER。
拦截器忘了返回 next.handle()
返回 of(...) 或一个不包含 next.handle() 的 Observable,会绕过处理函数。对缓存来说这是有意为之,在其他 地方就是 bug。中间件忘了调用 next() 也一样:请求会一直挂起,直到超时。
- 从 Nest 10 沿袭下来的无名通配符(
forRoutes('projects/*')):Nest 会转换它们并给出警告。直接写'projects/{*path}',或者对所有路由写{ path: '{*splat}', method: RequestMethod.ALL }。 @Catch(HttpException)过滤器会放过 TypeORM 错误和TypeError:它们得到的是 Nest 的默认格式。要统一格式, 就捕获一切(@Catch())。- 在过滤器里用
response.status().json()响应,会把代码绑定到 Express;HttpAdapterHost可以避免这种耦合。 - 在没有被等待的 Promise 里抛出的异常到不了任何过滤器:它会变成
unhandledRejection,默认会让 Node 进程退出 (忘了 await)。 - 处理函数里的
@Res()会让响应拦截器失效(手动@Res())。 - 指望
LoggingInterceptor记录 401:guard 在它之前就执行了。
练习
- [ ] 编写
AllExceptionsFilter(@Catch()),以application/problem+json返回status、code、message、path、timestamp、correlationId,并通过APP_FILTER注册。 - [ ] 映射 TypeORM 错误:违反唯一约束 → 409,实体不存在 → 404;用一个带错误码
23505的QueryFailedError测试 409。 - [ ] 编写
LoggingInterceptor(方法、路由、状态码、耗时),并通过APP_INTERCEPTOR注册。 - [ ] 编写
CorrelationIdMiddleware,用{*splat}应用它,并用curl -i验证x-correlation-id出现在响应头 和错误响应体里。 - [ ] 用
curl验证:没有 token → 401,/v1/projects/not-a-uuid→ 400,?status=LATE→ 400,/v1/nope→ 404, 格式全都一致。 - [ ] 编写一个自定义管道和一个做映射(
map())的拦截器,然后把它们删掉。 - [ ] 在中间件、guard、拦截器、管道和处理函数里各加一条临时日志,观察实际的执行顺序。
- [ ] 在
LEARNINGS.md里记下与@ControllerAdvice、servletFilter和@Around的不同之处。