Skip to content

J6 · 请求处理链:异常、过滤器、管道、拦截器 ​

第 6 天 · 11 月 17 日周二 · 交付物:全局错误处理、请求日志

从 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 ​

SpringNestJS要点
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 的错误格式只在一个地方定义
ResponseStatusExceptionHttpException、NotFoundException、ConflictException 等要抛出,而不是返回
ProblemDetail(Spring 6)没有原生对应物:TaskFlow 自己写了 ProblemDetails格式要在过滤器里自己实现
SQLException → DataIntegrityViolationException 的转换没有:读取 QueryFailedError.driverError.codeTypeORM 不转换 SQL 错误码

原理 ​

执行顺序 ​

text
请求
  → 中间件                CorrelationIdMiddleware, helmet(), pino-http
  → Guard                 ThrottlerGuard → JwtAuthGuard → RolesGuard
  → 拦截器(前)          LoggingInterceptor, ClassSerializerInterceptor
  → 管道                  ValidationPipe(全局), ParseUUIDPipe, ParseEnumPipe(参数级)
  → 处理函数              控制器方法,然后是 service
  → 拦截器(后)          顺序相反:ClassSerializerInterceptor,然后 LoggingInterceptor
响应
  ↳ 异常过滤器            AllExceptionsFilter,无论异常是在哪一步抛出的

在每一层,顺序都是从最宽到最具体:先全局,再控制器,再方法(管道还有参数级)。过滤器则相反:Nest 先找方法上 的过滤器,再找控制器上的,最后找全局的,由第一个 @Catch() 匹配的过滤器处理异常。过滤器之间不会串联。

TaskFlow 把所有全局组件都作为 provider 声明在 AppModule 里:

ts
// 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 使用默认过滤器:

json
{ "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。

任务控制器把它们组合在一起:

ts
@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 里是参数名,写错误消息时很方便:

ts
// 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()没有

两个简短的示例:

ts
// 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 })));
  }
}
ts
// 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() 的。
ts
// 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 ​

ts
// 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 的请求:

http
GET /v1/projects
x-correlation-id: test-123
http
HTTP/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() 里自己读取需要的信息:

错误来源响应
EntityNotFoundErrorfindOneOrFail()、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(端到端测试 没有覆盖这种情况)。
http
POST /v1/auth/register
Content-Type: application/json

{ "email": "twice@example.com", "password": "a long enough password" }
json
{
  "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:方法、路由、耗时 ​

ts
// 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):

text
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 ​

ts
// 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,后执行的复用它。

中间件应用于所有路由:

ts
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、servlet Filter 和 @Around 的不同之处。

延伸阅读 ​