{T}

NestJS异常过滤器学习笔记

NestJS异常过滤器学习笔记

核心知识点

1. 日志方案回顾与选型建议

1.1 Winston vs Pino 优缺点总结

方案优点缺点推荐人群
Winstondaily-rotate-file 功能详细、稳定、开箱即用需要手动在每个位置加日志追求稳定、功能全面
Pino开箱即用、性能极高(很多大神推荐)文件滚动功能不如 Winston 完善比较懒、对性能有要求

1.2 手动加日志的痛点

typescript
//  每个接口都要手动 try-catch,非常繁琐
@Get()
async getUsers() {
  try {
    return await this.userService.findAll();
  } catch (error) {
    this.logger.error('获取用户列表失败', error.stack);
    throw error;
  }
}

@Post()
async createUser(@Body() body: any) {
  try {
    return await this.userService.create(body);
  } catch (error) {
    this.logger.error('创建用户失败', error.stack);
    throw error;
  }
}

解决思路:使用 NestJS 的 异常过滤器(Exception Filter),在全局层面统一捕获和记录异常。


2. NestJS 异常处理机制

2.1 异常处理流程

code
客户端请求
    ↓
路由层(Route)
    ↓
控制器层(Controller)
    ↓
服务层(Service)
    ↓
┌─────────────────────────┐
│   发生异常?              │
│   ↓ Yes                  │
│   异常被抛出              │
│   ↓                      │
│   Exception Filter 捕获   │
│   ├── 路由过滤器(作用域:单个路由)
│   ├── 控制器过滤器(作用域:整个控制器)
│   └── 全局过滤器(作用域:整个应用) ← 本节重点
│   ↓                      │
│   返回 HTTP 响应给前端     │
└─────────────────────────┘

2.2 三种异常过滤器

过滤器类型作用范围执行顺序优先级
全局过滤器(Global Filter)整个应用所有路由最后执行最低(兜底)
控制器过滤器(Controller Filter)当前控制器下所有路由中间执行
路由过滤器(Route Filter)单个路由最先执行最高

2.3 在请求生命周期中的位置

code
客户端请求
    ↓
Middleware(中间件)
    ↓
Guard(守卫)
    ↓
Interceptor(拦截器)— 前置
    ↓
Pipe(管道)
    ↓
Controller + Service(业务逻辑)
    ↓
Interceptor(拦截器)— 后置
    ↓
Exception Filter(异常过滤器)← 生命周期末端
    ↓
响应给客户端

异常过滤器处于生命周期的末端,所有异常最终都会经过这里。


3. 主动抛出异常

3.1 使用 HttpException

typescript
import { HttpException, HttpStatus } from '@nestjs/common';

@Get('users')
getUsers() {
  const user = { isAdmin: false };

  if (!user.isAdmin) {
    throw new HttpException(
      'User is not admin, forbidden to access get all users',
      HttpStatus.FORBIDDEN,  // 403
    );
  }

  return { users: [] };
}

Postman 响应

json
{
  "statusCode": 403,
  "message": "User is not admin, forbidden to access get all users",
  "error": "Forbidden"
}

3.2 使用内置异常类(推荐)

NestJS 提供了大量语义化的内置异常类,无需手动指定状态码:

typescript
import {
  BadRequestException,    // 400
  UnauthorizedException,  // 401
  ForbiddenException,     // 403
  NotFoundException,      // 404
  InternalServerErrorException,  // 500
} from '@nestjs/common';

使用示例

typescript
// 404 — 用户不存在
throw new NotFoundException('用户不存在');

// 401 — 未授权
throw new UnauthorizedException('用户没有权限');

// 400 — 请求参数错误
throw new BadRequestException('请求参数不合法');

3.3 常用内置异常类速查

异常类状态码说明
BadRequestException400请求参数错误
UnauthorizedException401未认证/未授权
ForbiddenException403禁止访问
NotFoundException404资源不存在
MethodNotAllowedException405请求方法不允许
RequestTimeoutException408请求超时
ConflictException409资源冲突
InternalServerErrorException500服务器内部错误
NotAcceptableException406不接受的响应格式
ServiceUnavailableException503服务不可用

所有异常类都可通过 Cmd/Ctrl + Click 跳转到源码,查看更多异常类及其说明。


4. 自定义全局异常过滤器

4.1 创建过滤器

typescript
// src/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { LogService } from '../log/log.service';

@Catch(HttpException)  // 指定捕获的异常类型
export class HttpExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: LogService) {}

  catch(exception: HttpException, host: ArgumentsHost) {
    // 获取上下文
    const ctx = host.switchToHttp();

    // 获取请求和响应对象
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();

    // 获取 HTTP 状态码
    const status = exception.getStatus();

    // 构造响应数据
    const errorResponse = {
      code: status,
      timestamp: new Date().toISOString(),
      message: exception.message || exception.name,
    };

    // 记录错误日志
    this.logger.error(
      `${request.method} ${request.url} - ${errorResponse.message}`,
      exception.stack,
    );

    // 返回自定义响应
    response.status(status).json(errorResponse);
  }
}

4.2 代码逐行解析

typescript
@Catch(HttpException)
// @Catch() 装饰器:指定要捕获的异常类型
// HttpException:捕获所有 HTTP 异常
// 如果写 @Catch() 不传参数,则捕获所有异常

class HttpExceptionFilter implements ExceptionFilter {
// implements ExceptionFilter:TypeScript 接口约束
// 必须实现 catch() 方法

  catch(exception: HttpException, host: ArgumentsHost) {
  // exception:捕获到的异常对象
  // host:参数宿主,可以获取到请求上下文

    const ctx = host.switchToHttp();
    // switchToHttp():切换到 HTTP 上下文
    // 类似 Express 中的 req/res 中间件

    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    // 获取 Express 的 request 和 response 对象

    const status = exception.getStatus();
    // 获取 HTTP 状态码(如 404、403、500)

    response.status(status).json({ ... });
    // 自定义响应格式返回给前端
  }
}

4.3 ArgumentsHost(参数宿主)详解

code
ArgumentsHost
    ├── switchToHttp()     → HTTP 上下文(req / res)
    │   ├── getRequest()   → Express Request 对象
    │   └── getResponse()  → Express Response 对象
    │
    ├── switchToRpc()      → RPC 上下文(微服务)
    │   └── getData()      → RPC 数据
    │
    └── switchToWs()       → WebSocket 上下文
        ├── getClient()    → WebSocket 客户端
        └── getData()      → WebSocket 数据

ArgumentsHost 是 NestJS 的核心概念,它允许同一个过滤器在不同的传输层(HTTP/RPC/WebSocket)中工作。


5. 注册全局过滤器

5.1 在 main.ts 中注册

typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 注册全局异常过滤器
  app.useGlobalFilters(
    new HttpExceptionFilter(app.get(WINSTON_MODULE_NEST_PROVIDER)),
  );

  await app.listen(3000);
}
bootstrap();

app.get(WINSTON_MODULE_NEST_PROVIDER) 从 DI 容器中获取 Logger 实例,注入到过滤器中。

5.2 注意事项

  • 全局 Exception Filter 只能有一个:如果注册了多个,后面的会覆盖前面的
  • 如果需要捕获非 HTTP 异常(如 WebSocket),可以创建单独的过滤器

6. 过滤器执行效果

6.1 请求不存在的路由

请求GET /api/v1/not-exist

默认响应(无自定义过滤器)

json
{
  "statusCode": 404,
  "message": "Cannot GET /api/v1/not-exist",
  "error": "Not Found"
}

自定义过滤器响应

json
{
  "code": 404,
  "timestamp": "2026-03-30T08:00:00.000Z",
  "message": "Cannot GET /api/v1/not-exist"
}

6.2 异常对象可用的信息

typescript
exception.message       // 错误消息:"Cannot GET /api/v1/not-exist"
exception.name          // 异常名称:"NotFoundException"
exception.getStatus()   // HTTP 状态码:404
exception.stack         // 完整堆栈信息(用于日志记录)
exception.getResponse() // 完整响应对象

6.3 日志记录效果

logs/application-2026-03-30.log 中自动记录:

code
2026-03-30T08:00:00.000Z error: GET /api/v1/not-exist - Cannot GET /api/v1/not-exist
    Error: Cannot GET /api/v1/not-exist
        at ...(完整堆栈信息)

7. 进阶:捕获所有异常

7.1 @Catch 不传参数

typescript
@Catch()  // 不传参数 = 捕获所有异常(包括非 HTTP 异常)
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    // 判断是否为 HTTP 异常
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      timestamp: new Date().toISOString(),
      message,
      path: request.url,
    });
  }
}

7.2 WebSocket 异常过滤器(课后作业)

typescript
@Catch(WsException)  // 捕获 WebSocket 异常
export class WsExceptionFilter implements ExceptionFilter {
  catch(exception: WsException, host: ArgumentsHost) {
    const ctx = host.switchToWs();
    const client = ctx.getClient();
    const data = ctx.getData();

    client.emit('error', {
      message: exception.message,
      timestamp: new Date().toISOString(),
    });
  }
}

代码实战案例

需求描述

创建自定义全局异常过滤器,统一捕获所有 HTTP 异常,返回标准化的错误响应格式,并将错误信息记录到 Winston 日志文件中。

完整实现

第一步:创建过滤器

typescript
// src/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

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

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    const status = exception.getStatus();

    // 构造响应
    const errorResponse = {
      code: status,
      timestamp: new Date().toISOString(),
      message: exception.message || exception.name,
    };

    // 记录错误日志(同时输出到控制台和文件)
    this.logger.error(
      `${request.method} ${request.url} - ${errorResponse.message}`,
      exception.stack,
    );

    // 返回响应
    response.status(status).json(errorResponse);
  }
}

第二步:注册全局过滤器

typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalFilters(new HttpExceptionFilter());

  await app.listen(3000);
}
bootstrap();

第三步:在业务代码中抛出异常

typescript
// user.controller.ts
import {
  Controller,
  Get,
  Param,
  NotFoundException,
  ForbiddenException,
} from '@nestjs/common';

@Controller('user')
export class UserController {
  @Get(':id')
  async getUser(@Param('id') id: string) {
    const user = null;  // 模拟用户不存在

    if (!user) {
      throw new NotFoundException(`用户 ${id} 不存在`);
    }

    return user;
  }

  @Get('admin/users')
  getAdminUsers() {
    const currentUser = { isAdmin: false };

    if (!currentUser.isAdmin) {
      throw new ForbiddenException('User is not admin, forbidden to access');
    }

    return { users: [] };
  }
}

第四步:测试

bash
# 测试 404
curl http://localhost:3000/user/999
# → {"code":404,"timestamp":"...","message":"用户 999 不存在"}

# 测试 403
curl http://localhost:3000/user/admin/users
# → {"code":403,"timestamp":"...","message":"User is not admin, forbidden to access"}

# 测试未知路由
curl http://localhost:3000/unknown-path
# → {"code":404,"timestamp":"...","message":"Cannot GET /unknown-path"}

常见问题与解决方案

问题原因解决方案
全局过滤器不生效未在 main.ts 中注册app.useGlobalFilters(new XxxFilter())
注入 Logger 报错过滤器未通过 DI 创建实例使用 app.get(LOGGER_TOKEN) 获取实例
非 HTTP 异常未被捕获@Catch(HttpException) 只捕获 HTTP 异常使用 @Catch() 不传参数捕获所有异常
多个全局过滤器冲突全局 Filter 只能有一个合并多个过滤器的逻辑到一个类中
exception.stack 为空异常被框架处理后丢失堆栈在 catch 中尽早捕获并记录

学习要点总结

  1. 异常过滤器三层作用域:路由 → 控制器 → 全局,执行顺序由小到大
  2. 内置异常类NotFoundExceptionUnauthorizedException 等,语义化、状态码自动匹配
  3. 自定义过滤器三步走@Catch() 指定类型 → implements ExceptionFilter → 实现 catch() 方法
  4. ArgumentsHost:统一接口获取 HTTP / RPC / WebSocket 上下文,通过 switchToHttp() 获取 req/res
  5. 全局过滤器 + 日志:在 main.ts 中注册,自动记录所有异常到日志文件,无需手动 try-catch

延伸学习资源

官方文档

后续课程预告

  • 守卫(Guards):认证与授权
  • 拦截器(Interceptors):请求/响应转换与日志记录
  • 管道(Pipes):数据验证与转换

课后作业

创建一个 WebSocket 异常过滤器WsExceptionFilter),捕获 WebSocket 连接中的异常,并通过 client.emit('error', ...) 返回错误信息给客户端。