第 51 章:全局异常处理
学习目标
- 掌握 @RestControllerAdvice 用法
- 覆盖所有常见异常类型
- 理解生产环境的异常信息安全
一、没有全局处理会怎样?
java
// 用户传了个不存在的 ID
GET /api/users/999Spring 默认返回:
json
{
"timestamp": "2026-08-12T10:00:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException\n\tat com.taskflow.service.UserServiceImpl.getById(UserServiceImpl.java:45)\n\tat com.taskflow.controller...",
"path": "/api/users/999"
}三个严重问题:
| 问题 | 说明 |
|---|---|
| 结构不统一 | 和成功响应完全不同,前端要写两套解析 |
| 信息泄露 | 堆栈暴露了包名、类名、行号、框架版本,是攻击者的情报金矿 |
| 用户体验差 | 「Internal Server Error」用户看不懂 |
二、全局异常处理器
java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ==================== 业务异常 ====================
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
// ① 业务异常是预期内的,用 warn 而不是 error
log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
// ==================== 参数校验 ====================
/** @RequestBody + @Valid 校验失败 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("参数校验失败: {}", msg);
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), msg);
}
/** 表单参数(非 JSON)校验失败 */
@ExceptionHandler(BindException.class)
public Result<Void> handleBind(BindException e) {
String msg = e.getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), msg);
}
/** @RequestParam / @PathVariable 上的校验失败(类上需加 @Validated) */
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraint(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining("; "));
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), msg);
}
// ==================== 请求格式 ====================
/** 缺少必填参数 */
@ExceptionHandler(MissingServletRequestParameterException.class)
public Result<Void> handleMissingParam(MissingServletRequestParameterException e) {
return Result.fail(ErrorCode.PARAM_INVALID.getCode(),
"缺少必填参数: " + e.getParameterName());
}
/** 参数类型不匹配(如 id 传了 "abc") */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public Result<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
return Result.fail(ErrorCode.PARAM_INVALID.getCode(),
String.format("参数 %s 类型错误,期望 %s",
e.getName(),
e.getRequiredType() != null
? e.getRequiredType().getSimpleName() : "未知"));
}
/** JSON 格式错误 / 无法反序列化 */
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result<Void> handleNotReadable(HttpMessageNotReadableException e) {
log.warn("请求体解析失败: {}", e.getMessage());
// ② 不要把原始异常信息返回给前端(可能含内部类名)
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), "请求参数格式错误");
}
/** 请求方法不支持(用 GET 请求了 POST 接口) */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Result<Void>> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(Result.fail(ErrorCode.METHOD_NOT_ALLOWED.getCode(),
"不支持 " + e.getMethod() + " 请求"));
}
/** 404(需配合 throw-exception-if-no-handler-found=true) */
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<Result<Void>> handleNotFound(NoHandlerFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Result.fail(ErrorCode.NOT_FOUND));
}
/** 上传文件超限 */
@ExceptionHandler(MaxUploadSizeExceededException.class)
public Result<Void> handleUploadSize(MaxUploadSizeExceededException e) {
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), "上传文件过大");
}
// ==================== 权限认证 ====================
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Result<Void>> handleAccessDenied(AccessDeniedException e) {
// ③ 权限相关必须用 HTTP 状态码,前端拦截器靠它做跳转
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Result.fail(ErrorCode.FORBIDDEN));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Result<Void>> handleAuth(AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(ErrorCode.UNAUTHORIZED));
}
// ==================== 数据库 ====================
/** 唯一索引冲突 */
@ExceptionHandler(DuplicateKeyException.class)
public Result<Void> handleDuplicateKey(DuplicateKeyException e) {
log.warn("唯一约束冲突", e);
// ④ 不能把 SQL 错误信息返回给前端(暴露表名字段名)
return Result.fail(ErrorCode.PARAM_INVALID.getCode(), "数据已存在,请勿重复提交");
}
@ExceptionHandler(DataAccessException.class)
public Result<Void> handleDataAccess(DataAccessException e) {
log.error("数据库操作异常", e);
return Result.fail(ErrorCode.SYSTEM_ERROR);
}
// ==================== 兜底 ====================
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
// ⑤ 未预期的异常,必须打 ERROR + 完整堆栈
log.error("系统异常 | uri={} | method={}",
request.getRequestURI(), request.getMethod(), e);
// ⑥ 生产环境绝不返回 e.getMessage()
return Result.fail(ErrorCode.SYSTEM_ERROR);
}
}三、异常处理的匹配规则
就近原则:
BusinessException同时匹配BusinessException.class和Exception.class时,选继承层次最近的那个。所以兜底的Exception.class不会覆盖具体处理器。
四、生产环境的信息安全
java
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e); // ① 完整信息进日志
// ② 返回给前端的必须是脱敏的通用提示
if (isProduction()) {
return Result.fail(ErrorCode.SYSTEM_ERROR); // "系统繁忙,请稍后重试"
}
// ③ 开发环境可以返回详情,方便调试
return Result.fail(ErrorCode.SYSTEM_ERROR.getCode(),
e.getClass().getSimpleName() + ": " + e.getMessage());
}绝不能返回给前端的:
| 内容 | 泄露了什么 |
|---|---|
| 异常堆栈 | 包结构、类名、框架版本 → 针对性找已知漏洞 |
| SQL 语句 | 表名、字段名 → 为 SQL 注入铺路 |
| 文件路径 | 服务器目录结构、部署方式 |
e.getMessage() | 可能包含以上任意信息 |
yaml
# 关闭 Spring 默认的错误详情
server:
error:
include-stacktrace: never
include-message: never
include-binding-errors: never
include-exception: false五、返回 HTTP 状态码
三种写法,效果相同:
java
// ① ResponseEntity(推荐,最灵活)
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Result<Void>> handle(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Result.fail(ErrorCode.FORBIDDEN));
}
// ② @ResponseStatus 注解
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public Result<Void> handle(AccessDeniedException e) {
return Result.fail(ErrorCode.FORBIDDEN);
}
// ③ 手动设置 response
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handle(AccessDeniedException e, HttpServletResponse response) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return Result.fail(ErrorCode.FORBIDDEN);
}六、Filter 层异常处理不了
原因:
@RestControllerAdvice由DispatcherServlet处理,而 Filter 在DispatcherServlet之前执行。
解决方案:Filter 中自己写响应
java
public class JwtFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
try {
String token = extractToken(req);
if (token != null) {
validateAndSetAuth(token);
}
chain.doFilter(req, resp);
} catch (JwtException e) {
writeError(resp, HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED);
}
}
private void writeError(HttpServletResponse resp, HttpStatus status, ErrorCode code)
throws IOException {
resp.setStatus(status.value());
resp.setContentType(MediaType.APPLICATION_JSON_VALUE);
resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); // ① 必须!否则中文乱码
objectMapper.writeValue(resp.getWriter(), Result.fail(code));
}
}七、异步任务中的异常
java
// ① @Async 方法抛异常,不会被全局处理器捕获
@Async
public void asyncTask() {
throw new RuntimeException("异步异常"); // 静默消失
}
// ✅ 配置全局异步异常处理器
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) ->
log.error("异步任务异常 | method={} | params={}",
method.getName(), Arrays.toString(params), throwable);
}
}⚠️ 这个 handler 只对返回
void的@Async方法生效。返回Future的方法,异常在调用get()时抛出。
八、模块化异常处理
大项目中可以按包拆分处理器:
java
// ① 只处理 open 包下的异常(对外 API 有自己的响应格式)
@RestControllerAdvice(basePackages = "com.taskflow.open")
@Order(1)
public class OpenApiExceptionHandler { }
// ② 处理其他所有
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler { }九、本章小结
| 要点 | 关键 |
|---|---|
@RestControllerAdvice | 统一捕获,Controller 不写 try |
| 业务异常 | warn 级别,返回具体消息 |
| 系统异常 | error + 堆栈,返回通用提示 |
| 信息安全 | 绝不返回堆栈/SQL/路径 |
| 401/403 | 必须用 HTTP 状态码 |
| Filter 异常 | 处理器覆盖不到,要自己写响应 |
@Async 异常 | 配 AsyncUncaughtExceptionHandler |
| 匹配规则 | 就近原则,兜底不会覆盖具体处理器 |
动手练习
练习 1:基础题
实现全局异常处理器,覆盖:业务异常、参数校验、类型不匹配、兜底 Exception。用 Postman 分别触发验证。
练习 2:进阶题
实现一个开关:app.exception.show-detail=true 时返回异常详情(开发用),false 时返回通用提示(生产用)。
下一章:第 52 章:参数校验 JSR-303 →