Spring统一异常处理怎么做?业务、参数与未知异常分层

编程狮 2026-09-20 10:22:57 浏览数 (17)
反馈

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

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 教程 继续核对。接口测试通过后,再检查日志是否带 requestIdtraceId,才能把客户端报错和服务端堆栈关联起来。

六、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 响应的分流图

总结

Spring 统一异常处理的重点是分层:

  • 业务异常携带稳定业务码;
  • 参数异常返回可定位字段;
  • 未知异常保留服务端堆栈并隐藏内部细节。

HTTP 状态码和 JSON code 各司其职。落地时先定响应契约,再写 Advice,最后用 MockMvc 覆盖 400、业务状态和 500 三条路径。只有响应、日志和测试同时一致,统一处理才真正减少维护成本。

延伸学习

  1. Spring 入门课程(Java 开发框架) 练习控制器与异常处理;
  2. 对照 Spring Boot 异常处理笔记 扩展更多异常入口;
  3. 阅读 Spring Boot 入门笔记 补齐自动配置和项目结构。

常见问题

Q:业务异常要不要打印 error 日志?

A:通常不需要。可预期的资源不存在或状态冲突属于业务分支;未知系统异常才应记录 error 与完整堆栈。异常流量明显时可记录 warn。

Q:为什么 @Valid 没有触发统一处理?

A:先确认控制器参数前写了 @Valid,并检查使用的是 jakarta.validation 还是旧版 javax.validation。不同入口还可能抛出不同校验异常。

Q:统一异常处理能捕获过滤器里的异常吗?

A:不一定。过滤器发生在控制器调用链之外,通常需要在过滤器或安全框架的入口点单独处理,并复用同一错误响应结构。

Q:返回 500 时能把异常 message 发给前端吗?

A:不建议。未知异常信息可能含 SQL、文件路径或内部类名。客户端只接收通用提示,详细信息留在带追踪标识的服务端日志中。

0 人点赞