使用 @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 编写测试同样重要——因为这决定了当出现错误时客户端看到什么。