Skip to content
第 51 / 250 章后端⏱ 10 分钟阅读

第 51 章:全局异常处理

学习目标

  • 掌握 @RestControllerAdvice 用法
  • 覆盖所有常见异常类型
  • 理解生产环境的异常信息安全

一、没有全局处理会怎样?

java
// 用户传了个不存在的 ID
GET /api/users/999

Spring 默认返回

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.classException.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 层异常处理不了

原因@RestControllerAdviceDispatcherServlet 处理,而 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

本站基于 VitePress 构建 · 由 Codebook 团队维护