使用 @ControllerAdvice 在 Spring Boot 中进行集中异常处理

1. 概述

编写 REST API 时,如果每个 controller 都自己 try-catch 然后按照自己的想法返回错误响应,你最终会在同一个系统中得到数十种不同的 JSON 错误格式。@ControllerAdvice 通过允许你在唯一的一个地方定义异常处理逻辑来解决这个问题,可以应用于全部或部分 controller。

读完这篇文章,你将学会如何编写一个规范的 GlobalExceptionHandler,将异常映射到合适的 HTTP 状态,返回一致的错误响应体,以及编写测试来确保它正常工作。

2. 不进行集中式异常处理的问题

让我们看一个初学 Spring Boot 时的“常见” controller:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getOrder(@PathVariable Long id) {
        try {
            Order order = orderService.findById(id);
            return ResponseEntity.ok(order);
        } catch (OrderNotFoundException ex) {
            Map<String, String> body = new HashMap<>();
            body.put("error", ex.getMessage());
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
        }
    }
}

问题:

  • 错误响应体的构建逻辑(Map<String, String>)在每个 method、每个 controller 中重复出现。
  • 容易在新写的 method 中忘记 try-catch,导致 500 错误和暴露的 stack trace。
  • 没有地方处理像 MethodArgumentNotValidException(@Valid 验证错误)这样的“公共”异常。

注意: 并非所有错误都应该在 controller 中使用 try-catch 来捕获。Controller 应该只负责调用 service,而“将异常转换为响应”是专门异常处理层的责任。

3. @ControllerAdvice 是什么

@ControllerAdvice 是一个类级别的注解,用于标记该类包含支持(advise)多个 controller 的方法——最常见的用途是通过 @ExceptionHandler 处理异常。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

解释:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody,用于 REST API,因为返回的响应体总是序列化为 JSON。
  • @ExceptionHandler(OrderNotFoundException.class) 将该方法注册为专门处理 OrderNotFoundException 类型异常的处理器——不再需要在 OrderController 中使用 try-catch。
  • ErrorResponse 是一个简单的 record,为整个系统定义统一的错误格式。

3.1. 定义 ErrorResponse 和自定义异常

public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {

    public OrderNotFoundException(Long orderId) {
        super("找不到 id 为 " + orderId + " 的订单");
    }
}

OrderNotFoundException 继承 RuntimeException,所以不需要在每个调用位置声明 throws。在 OrderService 中,当找不到时只需要 throw:

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order findById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

Controller 现在简洁多了:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

不再有 try-catch,不再有手动的 ResponseEntity——GlobalExceptionHandler 负责所有异常到响应的转换工作。

4. 处理多种异常类型

实际上你会遇到许多类型的错误:not found (404)、验证请求错误 (400)、数据冲突 (409)、系统错误 (500)。@ExceptionHandler 允许你声明多个方法,每个方法处理一个或多个异常。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
        return buildResponse(HttpStatus.CONFLICT, "数据冲突,请重试");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "系统错误,请联系支持");
    }

    private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}

解释:

  • 每个 @ExceptionHandler 只处理一种错误类型,Spring 根据抛出的异常的类层次结构自动选择最匹配的方法。
  • handleUnknown 捕获 Exception.class 作为最后的“安全网”——避免将原始 stack trace 暴露给客户端。这几乎应该出现在每一个 GlobalExceptionHandler 中。
  • 方法的声明顺序不决定优先级;Spring 选择与实际抛出的异常类型最具体匹配的处理器。

注意: 如果两个 @ExceptionHandler 在同一个 @ControllerAdvice 中声明相同的异常类型,Spring 在启动应用时会报告歧义映射错误。

5. 使用 @Valid 处理验证错误

一个非常常见的情况:使用 Bean Validation 验证请求体。当验证失败时,Spring 会自动抛出 MethodArgumentNotValidException,你只需要捕获它并重新格式化为易读的形式。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "customerName 不能为空") String customerName,
        @Positive(message = "amount 必须大于 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

为 MethodArgumentNotValidException 添加处理器:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> fieldErrors = new LinkedHashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        fieldErrors.put(error.getField(), error.getDefaultMessage());
    }

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("status", HttpStatus.BAD_REQUEST.value());
    body.put("errors", fieldErrors);
    body.put("timestamp", Instant.now());

    return ResponseEntity.badRequest().body(body);
}

解释:

  • ex.getBindingResult().getFieldErrors() 返回哪些字段出错及相应的消息列表——正是你在 @NotBlank、@Positive 中声明的消息。
  • 使用 LinkedHashMap 来保持字段顺序,便于调试时阅读。

发送缺少 customerName 且 amount 为负数的请求将返回:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": 400,
  "errors": {
    "customerName": "customerName 不能为空",
    "amount": "amount 必须大于 0"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. 使用 basePackages 缩小应用范围

默认情况下,@ControllerAdvice 应用于应用上下文中的所有 controller。如果你有多个模块(例如 admin 和 public-api)需要不同的错误处理,可以限制范围:

@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.FORBIDDEN.value(), "无权访问", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackages 仅将此 advice 应用于位于 package com.nonladev.shop.api.admin 中的 controller。除了 basePackages,你还可以使用 annotations(仅应用于具有特定注解的 controller)或 assignableTypes(仅应用于特定的 controller 类)。

7. 使用 ProblemDetail(RFC 7807 标准)——现代做法

从 Spring Framework 6 开始,有一个现成的 ProblemDetail 实现了 RFC 7807 标准用于错误响应——不再需要自己定义 ErrorResponse:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("订单未找到");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

响应将采用以下形式:

{
  "type": "about:blank",
  "title": "订单未找到",
  "status": 404,
  "detail": "找不到 id 为 99 的订单",
  "instance": null,
  "timestamp": "2026-10-05T10:20:00Z"
}

如果你的团队希望遵循共同标准,ProblemDetail 是一个不错的选择,易于与理解 RFC 7807 的客户端/工具集成(例如某些 API 网关、OpenAPI 工具)。

8. 为 GlobalExceptionHandler 编写测试

测试应该确保正确的状态码和正确的响应体——避免在无人知晓的情况下修改 handler 导致出错。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L))
                .willThrow(new OrderNotFoundException(99L));

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.message").value("找不到 id 为 99 的订单"));
    }
}

或者直接在 handler 上进行单元测试,无需 Spring context:

class GlobalExceptionHandlerTest {

    private final GlobalExceptionHandler handler = new GlobalExceptionHandler();

    @Test
    void shouldMapOrderNotFoundTo404() {
        ResponseEntity<ErrorResponse> response =
                handler.handleNotFound(new OrderNotFoundException(42L));

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(response.getBody().message())
                .isEqualTo("找不到 id 为 42 的订单");
    }
}

第二种方法更快,因为不需要加载 MockMvc,适合当你只想检查异常→响应的映射逻辑,与路由无关时。

9. 常见错误

  • 忘记为 Exception.class 添加处理器:当出现意外错误(NullPointerException、数据库连接错误等)时,客户端会收到 Spring Boot 默认的 500 响应和 stack trace——暴露内部信息。始终在末尾有一个“全捕获”处理器。

  • 暴露敏感细节的异常消息:例如来自 JPA 的异常可能包含表名和列名。不要直接将 DataIntegrityViolationException 的 ex.getMessage() 返回给客户端,应该使用通用消息。

  • 多个 @ControllerAdvice 处理同一个异常:容易造成混淆,因为不清楚哪个会执行。如果使用多个 advice,使用 @Order 确定顺序,或者最好将共同逻辑合并到一个类中。

  • 将 @ControllerAdvice 用于业务逻辑的滥用:advice 应该只负责“将”异常翻译“为响应,不应该包含业务处理逻辑(例如发送错误通知电子邮件)——这应该在 service 或单独的事件监听器中进行。

注意: @ControllerAdvice 只能捕获在同一个请求线程中从 controller 方法内(或从其中调用的层)抛出的异常。在其他线程中抛出的异常(例如 @Async)或在 DispatcherServlet 之前运行的 filter 中抛出的异常将不会被 @ControllerAdvice 捕获。

10. 总结

@ControllerAdvice 结合 @ExceptionHandler 帮助你彻底将错误处理逻辑与 controller 分离,确保所有错误响应具有相同的格式,同时避免向客户端暴露敏感信息。始终要有一个捕获 Exception.class 的处理器作为最后的安全网,对请求验证使用 @Valid + MethodArgumentNotValidException,如果想遵循 RFC 7807 标准可以考虑使用 ProblemDetail。为处理器编写测试与为 service 编写测试同样重要——因为这决定了当出现错误时客户端看到什么。

spring-bootexception-handlingcontroller-advicejavarest-api