异常解析与 ProblemDetail:把内部失败稳定翻译为 HTTP 契约
统一异常处理的核心不是捕获 Throwable,而是把可公开的失败稳定映射成 HTTP 语义,同时保留内部因果链。异常解析器只有在响应尚未提交时才能改写状态与主体;错误码、type URI、日志 traceId 和堆栈各自服务不同受众,不能塞进一个万能 JSON。
成功响应和错误响应分开设计
很多团队喜欢所有返回都包一层:
{
"code": "0",
"message": "success",
"data": {}
}这没问题,但不要把 HTTP 状态码全部固定成 200。比较稳的做法是:
成功:业务统一结构可以保留,HTTP 状态码正常表达 200 / 201 / 204
失败:HTTP 状态码表达协议层结果,ProblemDetail 或错误结构表达业务错误码成功响应可以这样:
package com.example.order.api;
public record ApiResponse<T>(
String code,
String message,
T data
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("0", "success", data);
}
}Controller:
@GetMapping("/{orderId}")
ApiResponse<OrderResponse> getOrder(@PathVariable Long orderId) {
return ApiResponse.ok(orderQueryService.getOrder(orderId));
}如果团队想统一包装所有成功响应,可以用 ResponseBodyAdvice,但边界要很清楚:它只适合处理正常返回,不适合吞异常、改 HTTP 状态码、强行包装文件下载、SSE、流式响应和已经是 ProblemDetail 的错误响应。
package com.example.order.web;
import com.example.order.api.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response
) {
if (body == null || body instanceof ApiResponse<?> || body instanceof ProblemDetail) {
return body;
}
return ApiResponse.ok(body);
}
}这段代码只是说明边界,真实项目里还要跳过 byte[]、Resource、StreamingResponseBody、文件下载和特定媒体类型。不要为了“统一返回”把所有响应都套成 JSON,否则下载、预览、网关探活和第三方回调都会出问题。
但错误响应更推荐使用 Spring 的 ProblemDetail,再补业务 code:
{
"type": "https://example.com/problems/validation-error",
"title": "请求参数校验失败",
"status": 400,
"detail": "skuCode: 商品编码不能为空",
"instance": "/api/v1/orders",
"code": "ORDER.PARAM_INVALID",
"traceId": "b6b6f..."
}配置:
spring:
mvc:
problemdetails:
enabled: true业务异常:
package com.example.order.error;
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}全局异常处理:
package com.example.order.error;
import java.net.URI;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(BusinessException.class)
ProblemDetail handleBusiness(BusinessException ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
detail.setTitle("业务处理失败");
detail.setType(URI.create("https://example.com/problems/business-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", ex.code());
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleBodyValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
detail.setTitle("请求参数校验失败");
detail.setType(URI.create("https://example.com/problems/validation-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.PARAM_INVALID");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(HandlerMethodValidationException.class)
ProblemDetail handleMethodValidation(HandlerMethodValidationException ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "请求参数校验失败");
detail.setTitle("请求参数校验失败");
detail.setType(URI.create("https://example.com/problems/validation-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.PARAM_INVALID");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(Exception.class)
ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "系统异常,请稍后重试");
detail.setTitle("系统异常");
detail.setType(URI.create("https://example.com/problems/internal-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.INTERNAL_ERROR");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
private String formatFieldError(FieldError error) {
return error.getField() + ": " + error.getDefaultMessage();
}
}未知异常要记录日志:
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(Exception.class)
ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
log.error("unexpected api error, uri={}, traceId={}", request.getRequestURI(), MDC.get("traceId"), ex);
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "系统异常,请稍后重试");
detail.setTitle("系统异常");
detail.setProperty("code", "COMMON.INTERNAL_ERROR");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}异常解析也有顺序。DispatcherServlet 捕获请求处理异常后,会交给 HandlerExceptionResolverComposite,常见链路是:
ExceptionHandlerExceptionResolver
-> 找 @ExceptionHandler、@RestControllerAdvice;开启 ProblemDetail 时也会纳入 Boot 自动配置的 ResponseEntityExceptionHandler
ResponseStatusExceptionResolver
-> 处理 @ResponseStatus、ResponseStatusException
DefaultHandlerExceptionResolver
-> 把 MVC 内置异常映射成 400 / 404 / 405 / 415 等状态码生产上要记住两件事。第一,业务异常和校验异常应该由你自己的 @RestControllerAdvice 稳定接住,否则不同异常会走不同默认响应。第二,如果开启 spring.mvc.problemdetails.enabled,Spring Boot 会自动配置处理内置异常的 ResponseEntityExceptionHandler;你要覆盖内置异常时,自己的 Advice 顺序必须排在它前面,否则看起来写了处理器,实际没有生效。
生产原则:
参数错误:400
未认证:401
无权限:403
资源不存在:404
方法不支持:405
媒体类型不支持:415
业务冲突:409
服务端未知异常:500
下游不可用或超时:502 / 503 / 504 视网关和服务边界决定不要把所有错误都塞进 200。调用方、监控、网关、缓存和 APM 都需要真实 HTTP 语义。
错误码和 ProblemDetail 的映射规则
错误码治理不能只靠约定“大家自觉”。建议把 HTTP 状态码、业务 code、ProblemDetail 字段、日志级别和调用方动作放在同一张表里维护。这样前端、移动端、第三方调用方、监控和后端排障看到的是同一套语言。
| 场景 | HTTP 状态 | code 示例 | ProblemDetail 字段 | 日志级别 | 调用方动作 |
|---|---|---|---|---|---|
| JSON 格式错误 | 400 | COMMON.JSON_INVALID | title=请求体不可读,detail 写解析失败摘要 | WARN | 修正请求体 |
| 参数校验失败 | 400 | COMMON.PARAM_INVALID | detail 写字段错误,instance 写请求路径 | WARN | 修正入参 |
| 未登录或凭证失效 | 401 | AUTH.UNAUTHORIZED | 不泄露内部鉴权细节 | INFO/WARN | 重新登录或刷新凭证 |
| 无权限 | 403 | AUTH.FORBIDDEN | 说明缺少权限,不暴露权限表达式 | WARN | 申请权限或停止调用 |
| 资源不存在 | 404 | ORDER.NOT_FOUND | instance 保留请求路径 | INFO | 不重试,展示空态或提示 |
| 业务状态冲突 | 409 | ORDER.STATUS_NOT_ALLOWED | detail 写当前状态和允许动作 | WARN | 按业务状态刷新后重试 |
| 幂等重复提交 | 409 或 200 | COMMON.IDEMPOTENT_REPLAY | 返回首次处理结果或冲突原因 | INFO | 使用同一结果,不重复创建 |
| 下游超时 | 504 | PAYMENT.TIMEOUT | 不暴露下游域名和内网地址 | ERROR | 查询最终状态,谨慎重试 |
| 未知异常 | 500 | COMMON.INTERNAL_ERROR | 固定用户文案,带 traceId | ERROR | 带 traceId 报障 |
落地时有三个硬约束。
第一,code 一旦对外发布,不能随手复用成别的语义。
第二,ProblemDetail 的 type 要表达问题类型,不要每个异常都生成一个随机 URL。
第三,traceId 必须同时出现在响应头、错误体和日志里,否则错误码只能告诉你“错了”,不能帮你定位“哪一次错了”。如果团队已经有统一 ApiResponse,也不要把 ProblemDetail 硬塞进 data 里。更稳的方式是:成功响应走 ApiResponse<T>,错误响应走 RFC 9457 风格的 ProblemDetail,并在其中扩展 code、traceId、errors 等业务字段。调用方只需要记住一条规则:非 2xx 先按 HTTP 状态处理,再读取 code 做业务分支。
排障手册:按状态码走
线上排障不要一上来就改代码。先把请求方法、URI、HTTP 状态码、业务 code、响应头里的 traceId 和应用日志对齐,再决定查映射、绑定、校验、鉴权、业务状态还是下游。
这张图的用法很简单:非 2xx 先按 HTTP 状态码分流,再读业务 code 做细分。设计评审时要检查每个分支是否都有可复现命令、稳定错误体、日志级别和调用方动作;如果只能看到“系统异常”但没有 traceId,排障链路就是断的。
404 Not Found
请求:
curl -i http://127.0.0.1:8080/api/v1/order/1001检查:
路径是否写错
Controller 是否被扫描
类上和方法上的 @RequestMapping 是否拼接正确
是否配置了 server.servlet.context-path
是否被 Nginx 或网关改写路径命令:
curl -i http://127.0.0.1:8080/actuator/mappings/actuator/mappings 可能暴露接口结构,生产不要公网开放。
405 Method Not Allowed
现象:接口存在,但 HTTP 方法错了。
curl -i -X DELETE http://127.0.0.1:8080/api/v1/orders/1001检查 Controller 是否只写了 @GetMapping 或 @PostMapping。不要用一个 @RequestMapping 不写 method 接住所有请求,后期很难治理。
400 Bad Request
常见原因:
query 参数类型转换失败
path 变量类型转换失败
JSON 格式错误
校验失败
缺少必填参数命令:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=abc'看日志时要找具体异常:
MethodArgumentTypeMismatchException
HttpMessageNotReadableException
MethodArgumentNotValidException
HandlerMethodValidationException415 Unsupported Media Type
现象:POST JSON 时没加 Content-Type。
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-d '{"skuCode":"SKU-001","amount":99.00}'修复:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"SKU-001","amount":99.00}'500 Internal Server Error
500 不是前端问题。先找 traceId,再查应用日志。
curl -i http://127.0.0.1:8080/api/v1/orders/1001 -H 'X-Request-Id: debug-001'
grep 'debug-001' logs/order-service.log如果日志没有 traceId,先修 Filter 和日志 pattern。
Interceptor 没执行
检查:
请求路径是否命中 addPathPatterns
是否被 excludePathPatterns 排除
请求是否被 Filter 或 Security 提前拦截
是否访问的是静态资源或 Actuator
是否手动加了 @EnableWebMvc 导致配置变化校验没生效
检查:
是否引入 spring-boot-starter-validation
@RequestBody 前是否加 @Valid
约束注解是否来自 jakarta.validation
方法参数约束是否被当前 Spring MVC 版本支持
全局异常是否吞掉校验异常命令:
mvn -q dependency:tree | grep validation用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/errors-problem-detail/ExceptionResolverChainDemo.java examples/backend-development/spring-mvc/errors-problem-detail/ProblemDetailMappingDemo.java
java -cp examples/backend-development/spring-mvc/errors-problem-detail ExceptionResolverChainDemo
java -cp examples/backend-development/spring-mvc/errors-problem-detail ProblemDetailMappingDemoexception=InventoryConflict chosen=advice@ExceptionHandler chainStopped=true
status=409 type=urn:problem:inventory-conflict instance=/orders/42 traceId=t-7第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
