06 · 统一异常处理与 REST 错误响应
06 · 统一异常处理与 REST 错误响应
后端不能把 Java 异常堆栈直接甩给前端。统一异常处理让所有错误都返回「结构一致、前端好解析」的 JSON。本章先补一点 Java 异常基础,再看本项目的实现,最后总结常见坑与练习。
6.1 基础:Java 异常体系
先理解异常本身,才知道 Spring 为什么要这样设计。
Throwable
├── Error (严重错误:OutOfMemoryError、StackOverflowError,通常不该捕获)
└── Exception (程序可处理的异常)
├── RuntimeException (运行时异常,unchecked,不强制 try/catch)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── 自定义异常(本项目 BadRequestException / NotFoundException)
└── 受检异常 checked(如 IOException,编译器强制处理)- checked(受检)异常:
Exception的直接子类(除了RuntimeException一脉)。编译器强制你try/catch或throws声明,否则编译不过。 - unchecked(非受检)异常:
RuntimeException及其子类 +Error。编译器不强制处理。
为什么 Web 项目几乎都抛 RuntimeException 子类? 受检异常会让每个调用层都被迫写 throws 或 try/catch,在 Controller/Service 多层调用链里非常啰嗦。而 Web 应用希望「异常一路冒泡到最外层再统一处理」,于是约定:业务异常全部继承 RuntimeException,只抛出、不就地捕获,由全局处理器统一收口。本项目就遵循这条规则——BadRequestException 与 NotFoundException 都继承 RuntimeException。
// backend/src/main/java/com/yang/backend/exception/BadRequestException.java
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) { super(message); }
}// backend/src/main/java/com/yang/backend/exception/NotFoundException.java
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}6.2 为什么要「集中处理」而不是到处 try/catch
如果在每个 Controller 方法里都写 try/catch:
// ❌ 反例:到处捕获,重复且易漏
@PostMapping
public ResponseEntity<?> createUser(@RequestBody UserCreateRequest req) {
try {
return ResponseEntity.ok(userService.createUser(req));
} catch (BadRequestException e) {
return ResponseEntity.badRequest().body(...);
} catch (Exception e) {
return ResponseEntity.status(500).body(...);
}
}问题:
- 重复代码:每条路径都要复制一遍错误处理逻辑。
- 格式不统一:不同人写出来的错误 JSON 字段不同,前端无法统一解析。
- 关注点混杂:Controller 该只关心「接收参数、调用 Service、返回数据」,错误处理属于横切关注点。
- 容易漏:新增接口忘了写
catch,异常就直接变成 Tomcat 默认错误页。
集中处理的好处:错误转响应这件事只写一次,所有 Controller 共用;返回结构恒定;Controller 代码干净。
6.3 @RestControllerAdvice 与 @ControllerAdvice
Spring 提供两个「全局增强」注解来拦截 Controller 层抛出的异常:
| 注解 | 用途 |
|---|---|
@ControllerAdvice | 全局增强,可返回视图(ModelAndView)或 @ResponseBody 数据,传统 MVC 页面项目用 |
@RestControllerAdvice | 等价于 @ControllerAdvice + @ResponseBody,直接把返回值序列化成 JSON 响应体,REST 接口专用 |
本项目是纯 REST 后端,所以用 @RestControllerAdvice——处理器方法返回的对象会自动变成 HTTP 响应体(JSON),不需要再包 @ResponseBody。
// backend/src/main/java/com/yang/backend/exception/GlobalExceptionHandler.java:14
@RestControllerAdvice
public class GlobalExceptionHandler {
// 每个 @ExceptionHandler 方法对应一类异常
}6.4 统一错误外壳 ApiError
本项目定义一个 record 作为所有错误的统一信封,前端拿到任何错误都能按固定字段解析:
// backend/src/main/java/com/yang/backend/exception/ApiError.java
public record ApiError(
LocalDateTime timestamp, // 出错时间
int status, // HTTP 状态码(冗余存一份,方便前端判断)
String message, // 给人看的错误概述
List<String> details // 细节列表(如字段级校验错误)
) {
}为什么用固定信封?因为前端只要写一套解析逻辑:body.message 显示提示,body.details 显示明细,不用关心到底是哪种异常。示例响应:
{
"timestamp": "2026-07-07T02:55:31",
"status": 400,
"message": "请求参数校验失败",
"details": ["username: must not be blank"]
}处理器内部用一个小工具方法拼装这个信封:
// backend/src/main/java/com/yang/backend/exception/GlobalExceptionHandler.java:56
private ApiError error(HttpStatus status, String message, List<String> details) {
return new ApiError(LocalDateTime.now(), status.value(), message, details);
}6.5 全局处理器:方法与解析顺序
看完整的 GlobalExceptionHandler:
// backend/src/main/java/com/yang/backend/exception/GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class) // :17
@ResponseStatus(HttpStatus.NOT_FOUND) // 404
public ApiError handleNotFound(NotFoundException ex) {
return error(HttpStatus.NOT_FOUND, ex.getMessage(), List.of());
}
@ExceptionHandler(BadRequestException.class) // :23
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400
public ApiError handleBadRequest(BadRequestException ex) {
return error(HttpStatus.BAD_REQUEST, ex.getMessage(), List.of());
}
@ExceptionHandler(MethodArgumentNotValidException.class) // :29
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400 + 字段明细
public ApiError handleValidation(MethodArgumentNotValidException ex) {
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.toList();
return error(HttpStatus.BAD_REQUEST, "请求参数校验失败", details);
}
@ExceptionHandler(DataIntegrityViolationException.class) // :38
@ResponseStatus(HttpStatus.CONFLICT) // 409
public ApiError handleConflict(DataIntegrityViolationException ex) {
return error(HttpStatus.CONFLICT, "数据约束冲突,请检查唯一字段或关联数据", List.of());
}
@ExceptionHandler(AuthorizationException.class) // :44(Shiro)
@ResponseStatus(HttpStatus.FORBIDDEN) // 403
public ApiError handleForbidden(AuthorizationException ex) {
return error(HttpStatus.FORBIDDEN, "当前账号无权执行该操作", List.of());
}
@ExceptionHandler(Exception.class) // :50 兜底
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 500
public ApiError handleUnexpected(Exception ex) {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误", List.of(ex.getMessage()));
}
}几个关键点
1)@ExceptionHandler(XxxException.class) 的解析顺序
Spring 会选「最具体(最匹配)」的处理器。例如抛 NotFoundException(它继承 RuntimeException → Exception),Spring 优先匹配 NotFoundException.class 的方法,而不是泛化的 Exception.class。永远把 @ExceptionHandler(Exception.class) 放在最后当兜底网,否则它会「吃掉」所有更具体的异常。
2)@ResponseStatus 与返回体@ResponseStatus(HttpStatus.XXX) 决定 HTTP 状态码;方法返回值(这里是 ApiError)经消息转换器序列化成响应体 JSON。两者配合 = 「状态码 + 结构化错误体」。
3)静态映射 vs 动态@ResponseStatus 是写死的静态映射。如果状态码需要根据异常内容动态变化(比如不同业务错误返回不同码),就改用 ResponseEntity<ApiError> 作为返回类型,在方法里自己 return ResponseEntity.status(...).body(...)。
6.6 本项目异常 → 状态码 映射总表
| 异常 | 抛出位置(真实代码) | 状态码 | 含义 |
|---|---|---|---|
NotFoundException | UserService.requireUser backend/.../service/UserService.java:174、:182;RoleService.java:104 | 404 | 资源不存在 |
BadRequestException | PermissionGuard.require service/PermissionGuard.java:16、:23;UserService.java:166、:190;RoleService.java:95、:112;AuthService.java:37、:80 | 400 | 业务参数/权限错误 |
MethodArgumentNotValidException | Controller 的 @Valid 参数(见 6.7) | 400 | DTO 字段校验失败 |
DataIntegrityViolationException | 数据库唯一/外键约束被违反(Spring 自动抛出) | 409 | 数据约束冲突 |
AuthorizationException(Shiro) | Shiro 鉴权失败 | 403 | 无权限 |
其他未捕获 Exception | 任何未预期的错误 | 500 | 服务端意外错误 |
各场景在代码里的来源
- 404
NotFoundException:Service 里查不到实体时主动抛,例如:// backend/src/main/java/com/yang/backend/service/UserService.java:171 private SysUser requireUser(Long id) { SysUser user = userMapper.selectById(id); if (user == null) { throw new NotFoundException("用户不存在"); } return user; } - 400
BadRequestException:分两类——- 权限不足:
PermissionGuard.require("user:create")检查失败时抛(service/PermissionGuard.java:13-17)。注意UserController.createUser在调用业务前先permissionGuard.require(...)(controller/UserController.java:52),所以越权请求会先在这里变成 400。 - 业务冲突:如用户名重复,
UserService.ensureUsernameAvailable抛出(service/UserService.java:187-191)。
- 权限不足:
- 400 字段校验:见 6.7,由
@Valid触发,Spring 抛MethodArgumentNotValidException。 - 409
DataIntegrityViolationException:不是你手动抛的。当 MyBatis-Plus 插入/更新触碰到数据库唯一索引(如 username 唯一约束),JDBC 报错,Spring 的Jpa/Dao层把它包装成org.springframework.dao.DataIntegrityViolationException往上冒泡,被全局处理器接住返回 409。 - 403
AuthorizationException:来自 Shiro 框架(importorg.apache.shiro.authz.AuthorizationException)。当注解/编程式鉴权判定无权限时由 Shiro 抛出。 - 500
Exception:上面都没匹配的意外错误,统一返回「服务器内部错误」+ex.getMessage()。
6.7 Jakarta 校验:@Valid 与字段级错误
Controller 在接收 DTO 时用 @Valid 触发自动校验,若失败 Spring 抛出 MethodArgumentNotValidException,被 6.5 的 handleValidation 接住。
// backend/src/main/java/com/yang/backend/controller/UserController.java:51
@PostMapping
public UserResponse createUser(@Valid @RequestBody UserCreateRequest request) {
permissionGuard.require("user:create");
return userService.createUser(request);
}DTO 上的约束注解(Jakarta Validation):
// backend/src/main/java/com/yang/backend/dto/UserCreateRequest.java
public record UserCreateRequest(
@NotBlank @Size(max = 64) String username,
@NotBlank @Size(max = 100) String displayName,
@Email @Size(max = 160) String email,
@NotBlank @Size(min = 6, max = 100) String password,
Boolean enabled,
List<Long> roleIds
) {}常用注解:@NotBlank(非空且去空格后非空)、@NotNull、@Size(min,max)、@Email、@Min/@Max、@Pattern。
校验失败时,Spring 把每个字段错误收集进 BindingResult,全局处理器把它们拼成 details:
// backend/src/main/java/com/yang/backend/exception/GlobalExceptionHandler.java:32
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.toList();返回的 JSON 形如:
{
"timestamp": "2026-07-07T03:00:00",
"status": 400,
"message": "请求参数校验失败",
"details": ["username: must not be blank", "password: 长度需要在 6 到 100 之间"]
}LoginRequest 同样:backend/.../dto/LoginRequest.java 的 username、password 标了 @NotBlank,登录接口用 @Valid 校验。
6.8 自定义业务异常:怎么抛、怎么变响应
BadRequestException 的用法极其简单——在发现问题的地方直接抛,无需任何 try/catch:
// 例如在 AuthService 登录时
throw new BadRequestException("用户名或密码错误"); // backend/.../service/AuthService.java:37框架链路是:Service 抛异常 → Controller 不捕获 → DispatcherServlet 交给 @RestControllerAdvice → 匹配到 handleBadRequest → 返回 400 + ApiError JSON。这就是「早抛迟捕」:问题现场抛,最外层统一转响应。
6.9 扩展了解:事务回滚
UserService、RoleService 的写操作方法标了 @Transactional(如 UserService.java:93、:107、:121、:136;RoleService.java:48 起)。Spring 的事务规则是:方法内抛出 RuntimeException(及其子类)会触发回滚;受检异常默认不回滚。
正因 BadRequestException/NotFoundException 都是 RuntimeException 子类,所以「用户已存在」这类业务异常一旦在 @Transactional 方法里抛出,整个事务会回滚,避免脏数据落库。这也是 Web 项目偏爱 unchecked 异常的另一个原因。
6.10 扩展了解:日志与安全
1)500 处理器应当服务端记日志
当前兜底处理器只把 ex.getMessage() 返回给客户端,没有在服务端打印堆栈:
// backend/src/main/java/com/yang/backend/exception/GlobalExceptionHandler.java:50
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiError handleUnexpected(Exception ex) {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误", List.of(ex.getMessage()));
}问题:运维/开发看不到 500 的真实堆栈,难以排查。改进方式是加一个 logger 并打 error 级日志:
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiError handleUnexpected(Exception ex) {
log.error("未捕获异常", ex); // 服务端记录完整堆栈
return error(HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误", List.of(ex.getMessage()));
}2)绝不能把堆栈/SQL 错误泄露给前端
本项目 500 只返回 ex.getMessage() 而非完整 toString() 或 printStackTrace(),这是正确做法。若把 DataIntegrityViolationException 的数据库报错(含表名、字段、SQL)直接返回,会泄露内部结构,甚至被攻击者利用(如探测数据库结构)。本项目对 409 已经做了脱敏——统一返回「数据约束冲突,请检查唯一字段或关联数据」,不暴露原始 SQL 异常。
6.11 前端如何消费错误:App.vue 的 request()
前端统一在 request() 里解析错误体,并把 401 当作「登录失效」处理。
// frontend/src/App.vue:77
async function request(path, options = {}) {
const response = await fetch(`${apiBase}${path}`, { headers, ...options })
if (!response.ok) {
let message = `请求失败:${response.status}`
try {
const body = await response.json()
// 读取后端 ApiError 的 message / details
message = body.details?.length
? `${body.message}:${body.details.join(',')}`
: body.message || message
} catch {
// 响应不是 JSON 时,退回状态码提示
}
if (response.status === 401) {
resetAuth(message) // 401 → 清除登录态并登出
}
throw new Error(message)
}
return response.json()
}要点:
- 前端只读
body.message和body.details,与后端的ApiError信封字段一一对应——这正是「统一信封」的价值。 details非空时把多条明细用「,」拼到 message 后面,字段级校验错误(6.7)就能显示出来。- 401 特殊处理:调用
resetAuth(message)(App.vue:64)清空localStorage里的 token 和用户信息,相当于强制登出。本项目全局异常处理器没有专门处理 401,401 实际来自 Shiro 鉴权(未登录/登录过期),前端在request()里统一拦截。
各业务方法(loadUsers、saveUser、deleteUser 等)都用 try/catch 接住 request() 抛出的 Error,把 err.message 显示到页面顶部 .alert(App.vue:333)。
6.12 常见坑
- 在
@RestControllerAdvice里返回ModelAndView:REST 接口要返回数据对象(如ApiError),若返回视图对象,前端拿到的是 HTML 而非 JSON。 - 忘记写
@ResponseStatus:方法仍会执行,但状态码可能退回 200 或默认 500,前端按response.ok判断会出错。 - 在 Controller/Service 过早
catch (Exception e):异常被就地吞掉或重新包成别的东西,导致全局@ExceptionHandler永远匹配不到,统一响应失效。经验法则:业务代码只抛不捕,异常留给最外层。 - 把 SQL / 堆栈错误直接返回前端:如
return error(..., ex.toString()),泄露数据库结构,有安全风险。应脱敏(本项目 409 已脱敏)。 - 兜底
@ExceptionHandler(Exception.class)放在前面:它会「吃掉」更具体的处理器。Spring 按「最具体优先」匹配,但写在前面的泛型处理器 + 你自己的误判仍可能导致别的异常被错配;保持它唯一且最后最稳妥。 @Valid与@Validated混用/漏加:忘了在 Controller 参数上加@Valid,字段注解不生效,校验根本不会触发,请求带着空值进 Service。- 500 不记日志:如 6.10 所述,只返回 message 不打印堆栈,线上 500 将无从排查。
6.13 练习
基础:
- 调一个不存在的用户
GET /api/users/99999,观察返回的结构与 404 状态码,并对照UserService.requireUser(service/UserService.java:171)理解NotFoundException从抛到响应的完整链路。 - 在
UserService找一处throw new BadRequestException(...)(如:190用户名已存在),理解它在哪被捕获、如何变成 400 JSON。 - 故意用空
username调POST /api/auth/login,看MethodArgumentNotValidException返回的details内容。
进阶:
4. 仿照 BadRequestException,新增一个自定义异常(如 ConflictException extends RuntimeException),并在 GlobalExceptionHandler 加一个 @ExceptionHandler 方法,把它映射成 409。再思考:为什么本项目复用了 Spring 的 DataIntegrityViolationException 表示 409,而不是自定义异常?
5. 给 500 兜底处理器加上 Logger 与 log.error("未捕获异常", ex),重启后故意触发一个 NPE,去后端日志里找堆栈。
扩展了解(RFC 7807):
6. 研究 RFC 7807「Problem Details for HTTP APIs」:标准信封字段为 type / title / status / detail / instance。尝试把 ApiError 改造成兼容该规范的响应,例如 status 对应 status、message 对应 title、details 合并进 detail。这种标准格式能让不同微服务返回一致的错误结构,便于网关/前端统一处理。
6.14 学习要点小结
- 异常「早抛迟捕」:现场抛,最外层(
@RestControllerAdvice)统一转响应。 - Web 项目优先用
RuntimeException子类(unchecked),避免到处throws;同时它还能触发@Transactional回滚。 - 统一信封
ApiError(timestamp/status/message/details)让前端一套代码解析所有错误。 - 映射总表:404/400/409/403 由具体异常决定,500 兜底;最具体的
@ExceptionHandler优先,Exception.class永远最后。 - 安全:500 只返回 message,不泄露堆栈/SQL;但记得在服务端记日志以便排查。
- 前端
request()读message/details展示,401 触发登出。