Spring 统一异常处理应把业务异常、参数校验异常和未知异常分开:前两类给客户端稳定错误码,未知异常记录完整日志并只返回通用信息。

你在每个 Controller 里写 try-catch,接口仍然会出现字段不一致、HTTP 状态码全是 200、堆栈直接暴露给前端等问题。统一处理不是把所有 Throwable 塞进一个方法,而是让异常类型、日志责任和响应语义一一对应。本文以 Spring Boot 3.x、Java 17 和 Jakarta Validation 为适用范围,给出同一最小项目中的完整核心文件。本机没有 Java 与 Maven,代码未执行,预期响应依据 Spring 的 @RestControllerAdvice 与 @ExceptionHandler 机制。
一、先看结论:三类异常怎么分层
| 异常类型 | 触发场景 | HTTP 状态 | 响应 code | 日志级别 | 是否暴露详情 |
|---|---|---|---|---|---|
| 参数校验异常 | DTO 格式不合法 | 400 | VALIDATION_FAILED |
warn/info | 字段提示 |
| 业务异常 | 资源不存在、状态冲突 | 404/409 等 | 业务码,如 PRODUCT_OFFLINE |
warn/按需 | 业务信息 |
| 未知异常 | 系统错误、空指针等 | 500 | INTERNAL_ERROR |
error | 通用提示,不暴露堆栈 |
一句话:业务异常和参数异常给客户端稳定 code,未知异常留日志、对外只说“服务器处理失败”。
二、先定义稳定的错误响应和业务异常
统一异常处理首先要统一返回结构。客户端需要机器可读的 code、给用户或开发者阅读的 message,以及用于追踪请求的 path。时间戳可由网关或日志补充,不必把堆栈放进响应。
// ApiError.java
package cn.w3cschool.demo;
// 统一错误响应结构:code 给程序判断,message 给人阅读,path 用于定位请求
public record ApiError(String code, String message, String path) {}
| 字段 | 作用 | 示例 |
|---|---|---|
code |
机器可读错误码 | VALIDATION_FAILED |
message |
人类可读提示 | 商品不能为空 |
path |
请求路径 | /orders |
业务异常表达“请求合法,但目标资源或业务状态不满足”。它应该携带稳定 code 和合适的 HTTP 状态:
// BusinessException.java
package cn.w3cschool.demo;
import org.springframework.http.HttpStatus;
// 业务异常:表示请求合法,但业务状态不满足,例如商品下架、订单不存在
public class BusinessException extends RuntimeException {
private final String code; // 稳定业务错误码
private final HttpStatus status; // 对应的 HTTP 状态码
public BusinessException(String code, String message, HttpStatus status) {
super(message);
this.code = code;
this.status = status;
}
public String getCode() { return code; }
public HttpStatus getStatus() { return status; }
}
不熟悉 Spring MVC 请求链时,可以先补 Spring 基础教程。异常类只描述事实,不应该在构造函数里写日志;是否记录、记录到什么级别,由统一处理器决定。
三、用 RestControllerAdvice 分流三类异常
@RestControllerAdvice 会把返回值直接序列化为响应体。下面的处理器分别覆盖业务异常、参数校验失败和最后兜底的 Exception。
// GlobalExceptionHandler.java
package cn.w3cschool.demo;
import jakarta.servlet.http.HttpServletRequest;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice // 全局异常处理,返回值直接序列化为 JSON
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// 处理业务异常:使用异常自带的 code 和 status
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> handleBusiness(
BusinessException ex, HttpServletRequest request) {
return ResponseEntity.status(ex.getStatus())
.body(new ApiError(ex.getCode(), ex.getMessage(), request.getRequestURI()));
}
// 处理参数校验异常:收集字段错误,返回 400
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest request) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest()
.body(new ApiError("VALIDATION_FAILED", message, request.getRequestURI()));
}
// 兜底未知异常:记录完整堆栈,对外只返回通用信息
@ExceptionHandler(Exception.class)
ResponseEntity<ApiError> handleUnknown(Exception ex, HttpServletRequest request) {
log.error("Unhandled request error, path={}", request.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("INTERNAL_ERROR", "服务器处理失败", request.getRequestURI()));
}
}
未知异常只向客户端返回通用信息,日志则保留异常对象 ex,便于输出完整堆栈。业务异常是否记录 warn 取决于它是否代表异常流量;“订单不存在”这类预期分支通常不需要 error 日志。
| 处理方法 | 捕获异常 | 返回状态 | 日志策略 |
|---|---|---|---|
handleBusiness |
BusinessException |
异常自带状态 | 按业务预期决定 |
handleValidation |
MethodArgumentNotValidException |
400 | 可记录 warn |
handleUnknown |
Exception |
500 | error + 完整堆栈 |
四、参数校验与业务异常要在不同层产生
请求 DTO 用 Jakarta Validation 描述格式规则,Service 负责业务规则。这样 400 表示输入不合法,404 或 409 表示业务状态不满足。
// CreateOrderRequest.java
package cn.w3cschool.demo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
// 请求 DTO:用 Jakarta Validation 描述格式规则
public record CreateOrderRequest(
@NotBlank(message = "商品不能为空") String product, // 商品名不能为空
@Positive(message = "数量必须大于 0") int quantity) {} // 数量必须为正数
// OrderController.java
package cn.w3cschool.demo;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
public class OrderController {
@PostMapping
String create(@Valid @RequestBody CreateOrderRequest request) {
// 业务规则:商品下架返回 409
if (request.product().equalsIgnoreCase("offline")) {
throw new BusinessException(
"PRODUCT_OFFLINE", "商品已下架", HttpStatus.CONFLICT);
}
return "created:" + request.product();
}
}
如果漏写 @Valid,DTO 上的注解不会自动触发 MethodArgumentNotValidException。若校验发生在路径参数或方法级约束,异常类型还可能不同,需要按项目的 Spring 版本和入口补相应处理器。
| 校验位置 | 触发方式 | 常见异常 |
|---|---|---|
| 请求体 DTO | @Valid @RequestBody |
MethodArgumentNotValidException |
| 路径参数 | @Validated + 约束注解 |
ConstraintViolationException |
| 方法级约束 | 方法参数校验 | ConstraintViolationException |
五、用 MockMvc 固定状态码与 JSON 契约
统一异常处理最适合用契约测试守住。下面是三个测试目标,属于同一项目的测试类片段;当前环境未执行。
// OrderControllerTest.java 中的测试方法片段
// 校验失败:空商品名和 0 数量,应返回 400 和 VALIDATION_FAILED
mockMvc.perform(post("/orders")
.contentType("application/json")
.content("{\"product\":\"\",\"quantity\":0}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
// 业务异常:商品下架,应返回 409 和 PRODUCT_OFFLINE
mockMvc.perform(post("/orders")
.contentType("application/json")
.content("{\"product\":\"offline\",\"quantity\":1}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("PRODUCT_OFFLINE"));
预期结果(未在本机执行)是校验失败返回 400,商品下架返回 409,响应体字段稳定。还应增加一个主动抛出未知异常的测试,断言返回 500 且 message 不包含类名、SQL 或堆栈。
| 测试目标 | 请求 | 预期状态 | 预期 code |
|---|---|---|---|
| 参数校验失败 | 空商品名、数量 0 | 400 | VALIDATION_FAILED |
| 业务状态冲突 | 商品下架 | 409 | PRODUCT_OFFLINE |
| 未知异常 | 主动抛出异常 | 500 | INTERNAL_ERROR |
Spring Boot 配置、测试和版本关系可以结合 Spring Boot 教程 继续核对。接口测试通过后,再检查日志是否带 requestId 或 traceId,才能把客户端报错和服务端堆栈关联起来。
六、Spring 统一异常处理最容易踩的四个坑
| 常见坑 | 问题 | 修复方向 |
|---|---|---|
| 所有异常都返回 200 | 网关、监控、客户端无法判断失败 | 按异常类型返回 400/404/409/500 |
| 原样返回异常 message | 泄露数据库、路径、类名 | 未知异常只返回通用提示 |
| 业务层到处记录同一异常 | 一条错误出现多次日志 | 只在统一处理器记录 |
| 多个 Advice 优先级不清 | 父子异常匹配冲突 | 用测试固定实际选择 |
统一处理器也不应吞掉框架原有的重要头部。例如部分 HTTP 异常带有 Allow 等响应信息,全面自定义前要检查是否需要保留。API 错误码一旦被客户端依赖,就应进入版本管理和接口文档。
七、用三条请求完成端到端验收
前置条件是固定一个正常接口、一个会触发业务异常的资源 ID,以及一个违反校验规则的请求体。依次发送请求后,至少核对 HTTP 状态、响应 code、字段提示、追踪标识和日志级别:
| 请求类型 | 预期 HTTP | 预期 code | 日志要求 |
|---|---|---|---|
| 参数错误 | 400 | VALIDATION_FAILED |
可定位字段 |
| 资源不存在 | 404 | 稳定业务码 | 按业务预期 |
| 未知异常 | 500 | INTERNAL_ERROR |
完整堆栈,含 traceId |
响应中不应出现 SQL、文件路径或内部类名。
若 MockMvc 没有进入预期的 ExceptionHandler,先确认异常是否在控制器链内抛出,再检查处理方法的参数类型、Advice 扫描范围与优先级。过滤器和安全框架入口要单独测试,不能假设 Controller Advice 自动覆盖。本文示例按 Spring Boot 3.x API 结构静态核对,但当前环境没有 Java 与 Maven;接入项目后必须实际运行三条契约测试,并让测试失败时打印收到的状态码和 JSON,避免只看到一条笼统断言。
上线前还应把错误响应示例加入接口文档,并让前端或调用方确认哪些 code 可重试、哪些需要提示用户修改输入。这样能避免服务端已经分层,客户端却仍把所有非 200 响应显示成同一句“系统异常”。

总结
Spring 统一异常处理的重点是分层:
- 业务异常携带稳定业务码;
- 参数异常返回可定位字段;
- 未知异常保留服务端堆栈并隐藏内部细节。
HTTP 状态码和 JSON code 各司其职。落地时先定响应契约,再写 Advice,最后用 MockMvc 覆盖 400、业务状态和 500 三条路径。只有响应、日志和测试同时一致,统一处理才真正减少维护成本。
延伸学习
- 用 Spring 入门课程(Java 开发框架) 练习控制器与异常处理;
- 对照 Spring Boot 异常处理笔记 扩展更多异常入口;
- 阅读 Spring Boot 入门笔记 补齐自动配置和项目结构。
常见问题
Q:业务异常要不要打印 error 日志?
A:通常不需要。可预期的资源不存在或状态冲突属于业务分支;未知系统异常才应记录 error 与完整堆栈。异常流量明显时可记录 warn。
Q:为什么 @Valid 没有触发统一处理?
A:先确认控制器参数前写了 @Valid,并检查使用的是 jakarta.validation 还是旧版 javax.validation。不同入口还可能抛出不同校验异常。
Q:统一异常处理能捕获过滤器里的异常吗?
A:不一定。过滤器发生在控制器调用链之外,通常需要在过滤器或安全框架的入口点单独处理,并复用同一错误响应结构。
Q:返回 500 时能把异常 message 发给前端吗?
A:不建议。未知异常信息可能含 SQL、文件路径或内部类名。客户端只接收通用提示,详细信息留在带追踪标识的服务端日志中。

免费 AI IDE



