异常与过滤器
这一课你将能做到
- 在 Service 中抛出
NotFoundException、ConflictException 等语义异常,并自定义带业务码的异常
- 编写全局
ExceptionFilter,把所有错误统一成固定 JSON 外壳
- 区分「可预期业务错误」与「未知 500」,日志保留堆栈、响应体对客户端脱敏
为什么要学
前端和调用方需要稳定的错误形状:看到固定字段,才能写统一的提示逻辑(比如弹出 message、按 code 跳登录)。如果有的接口返回字符串、有的返回 HTML 错误页、有的直接挂掉,前端就得为每个接口写特例。
Nest 内置了 HttpException 异常体系,再配合 ExceptionFilter 在出口统一拦截——业务代码只管抛出语义异常,过滤器决定最终长什么样。这对应 Express 的统一错误处理中间件,也完全对应 Spring 的 @ControllerAdvice 加 @ExceptionHandler。
先建立图像
Service 抛出 NotFoundException
→ Nest 默认映射为 404 JSON
→ 被全局 AllExceptionsFilter 接住
→ 统一写成 { code, message, statusCode, path, timestamp }
未知 Error(空指针、第三方崩溃……)
→ 过滤器按 500 处理
→ 服务端日志记 stack;响应体只给通用文案
常用内置异常:
| 异常类 | 状态码 | 使用场景 |
|---|
BadRequestException | 400 | 参数格式错误(Pipe 默认用它) |
UnauthorizedException | 401 | 未登录、Token 无效 |
ForbiddenException | 403 | 已登录但权限不足 |
NotFoundException | 404 | 资源不存在 |
ConflictException | 409 | 资源冲突,如重复题干 |
UnprocessableEntityException | 422 | 格式正确但语义无法处理 |
跟着做
步骤 1:在业务中抛语义异常
确认 QuestionsService 的写法:
if (!question) {
throw new NotFoundException(`题目 ${id} 不存在`);
}
也可以在构造 HttpException 时携带自定义响应体:
import { HttpException, HttpStatus } from "@nestjs/common";
throw new HttpException(
{
code: "QUESTION_NOT_PUBLISHED",
message: "题目尚未发布,不能作答",
},
HttpStatus.UNPROCESSABLE_ENTITY, // 422
);
请求一次不存在的题目,默认会看到 Nest 自带的三个字段:statusCode、message、error。
步骤 2:定义业务异常基类
新建 src/common/exceptions/business.exception.ts:
import { HttpException, HttpStatus } from "@nestjs/common";
export class BusinessException extends HttpException {
constructor(
code: string,
message: string,
status: HttpStatus = HttpStatus.BAD_REQUEST,
) {
super({ code, message }, status);
}
}
export class QuestionNotPublishedException extends BusinessException {
constructor(questionId: number) {
super(
"QUESTION_NOT_PUBLISHED",
`题目 ${questionId} 尚未发布`,
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
export class DuplicateSubmissionException extends BusinessException {
constructor() {
super("DUPLICATE_SUBMISSION", "同一题目的作答已提交,请勿重复", 409);
}
}
Service 中直接 throw new QuestionNotPublishedException(id),比手写状态码和 message 数组更不容易出错。
步骤 3:写全局异常过滤器
新建 src/common/filters/all-exceptions.filter.ts:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import { Request, Response } from "express";
@Catch() // 不写参数 = 捕获一切,包括裸 Error
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger("Exception");
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const body = this.extractBody(exception);
if (status >= 500) {
// 人话:日志要细节,方便定位;给客户端要克制
this.logger.error(
`${req.method} ${req.url} → ${status}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
res.status(status).json({
code: body.code ?? this.defaultCode(status),
message: body.message,
statusCode: status,
path: req.url,
timestamp: new Date().toISOString(),
});
}
private extractBody(exception: unknown): {
code?: string;
message: string | string[];
} {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === "string") {
return { message: response };
}
const obj = response as Record<string, unknown>;
return {
code: typeof obj.code === "string" ? obj.code : undefined,
message:
(obj.message as string | string[] | undefined) ?? exception.message,
};
}
// 人话:未知错误不把内部信息透出
return { message: "服务开小差了,请稍后再试" };
}
private defaultCode(status: number): string {
return `HTTP_${status}`;
}
}
步骤 4:注册过滤器(推荐用依赖注入方式)
方式一,main.ts 中直接挂(过滤器无需注入依赖时够用):
app.useGlobalFilters(new AllExceptionsFilter());
方式二,在 AppModule 通过 APP_FILTER 注册,过滤器内可注入日志、监控等 Service,更推荐:
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
@Module({
// imports 不变
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}
再次请求不存在的题目,响应体会带你定义的 code、path、timestamp,团队把这个形状写进接口文档,前端只需要解析一种结构。
步骤 5:制造一次未知异常验证脱敏
临时在某个 Service 方法里写 throw new Error("database password is xyz"),触发请求。
应看到:响应状态 500,message 是通用文案,不包含密码和堆栈;终端日志里完整保留了 stack。验证后删掉这行。结论:内部细节只进日志,不进响应体,堆栈、SQL、内网地址都可能被攻击者利用。
步骤 6:与校验错误对齐
故意 POST 一个非法 DTO(如 options 为空数组),观察过滤器输出:message 是 class-validator 返回的字符串数组,逐条说明字段问题。
前端处理约定:message 为数组时展示第一条(或逐条渲染在表单字段旁);为字符串时直接弹窗。由于 ValidationPipe 抛的也是 HttpException,校验错误和业务错误天然走同一个过滤器出口。
步骤 7:对照 Spring @ControllerAdvice
抛异常:Service 中 throw new XxxException
统一捕获:@ControllerAdvice ↔ @Catch() 全局 Filter
固定响应:返回统一 Result 对象 ↔ 返回统一 JSON 外壳
讲清楚要点
- 错误形状就是接口契约:字段稳定、含义固定,调用方才好编程。
- 可预期失败用 HttpException 子类:状态码即语义,不要一切失败都返回 200 再在 body 里标成功与否。
- 未知错误必须脱敏:500 只回通用文案,堆栈和内部信息只写日志。
- 过滤器是横切层:天然适合挂日志、监控告警、traceId 透传等能力。
- 校验错误自动收口:Pipe 抛异常,过滤器统一处理,不需要为校验单写出口。
- 自定义异常承载业务码:code 字段比 HTTP 状态码更细,前端可据此做差异化处理。
你可能会懵的地方
-
挂了全局过滤器,Nest 默认异常层还在吗?
在,但最终响应内容由你的过滤器决定;@Catch() 捕获全部,@Catch(HttpException) 只处理 HTTP 异常、其余仍走默认逻辑。
-
getResponse() 为什么有时是对象有时是字符串?
内置异常默认返回 { message, error, statusCode },校验错误中 message 还是数组;自定义异常可以返回任意结构。过滤器要做归一化。
-
异步方法里抛异常能被捕获吗?
可以。Nest 会处理 Promise 的 rejection;不要写空 catch 把错误吞掉,那样请求会一直挂起。
-
过滤器执行顺序如何控制?
多个过滤器按绑定范围从窄到宽、同范围按注册顺序执行;一般一个全局 AllExceptionsFilter 足够,避免层层嵌套。
-
API 返回 HTML 错误页行不行?
API 服务只返回 JSON;HTML 错误页由前端路由或网关提供。初学者最容易忽略这一点,而混着返回收割的就是前端调试时间。
错法对照
| 错法 | 问题 | 正解 |
|---|
catch (e) { return null } | 静默失败,问题被掩盖 | 上抛或转成语义异常 |
| 500 响应带 stack / SQL / 内网信息 | 信息泄露 | 日志保留细节,响应通用文案 |
Controller 手写 res.status(404).json(...) | 各接口格式不统一 | Service 抛异常,过滤器统一收口 |
成功失败都返回 200 + { success } | HTTP 语义失效 | 用真实状态码表达结果 |
| 每个模块定义不同错误字段名 | 前端每接一个接口崩一次 | 全局统一错误外壳 |
卡点急救
- 过滤器不生效:确认注册方式与范围;同时使用
useGlobalFilters 和 APP_FILTER 会重复执行,只保留一种。
- 自定义异常仍返回默认结构:检查是否继承 HttpException 并正确调用
super(response, status);裸 Error 一律被当 500。
- 生产排查看不到错误详情:详情应在服务端日志/监控平台检索;用响应中的 path、timestamp、traceId 对应日志定位。
面试常问
-
NestJS 的异常过滤器是干什么的?
ExceptionFilter 在异常抛出后接管响应,用 @Catch 指定捕获范围,可按异常类型生成统一格式的错误响应;全局注册后所有错误统一出口,对应 Spring 的 @ControllerAdvice。
-
业务异常和系统异常在处理上有何不同?
业务异常是可预期的(资源不存在、冲突、校验失败),抛 HttpException 子类并透出明确信息;系统异常是未预期的程序错误,按 500 处理,响应脱敏、日志记录堆栈并告警。
-
如何返回自定义业务错误码?
抛 HttpException 时把 { code, message } 作为响应体传入,过滤器读取 code 并归一化输出;前端依据 code 做细粒度处理。
自测三问
NotFoundException 和裸 Error 在 HTTP 层处理结果有什么差别?
- 全局过滤器为什么要给响应加 path、timestamp 这类字段?
- 为什么生产环境不能把堆栈信息直接返回给客户端?
小结一句话
Service 抛语义异常,全局 ExceptionFilter 统一收口;500 对客户端脱敏、细节留在日志里。
延伸