转换、校验与消息协商:请求数据何时成为可信对象
HTTP 字节不会直接变成可信业务对象。媒体类型先决定消息转换器能否读取,字段文本再经历转换和绑定,约束校验随后判断结构是否合法;返回端还要根据 Accept 选择可写表示。顺序一旦含糊,团队就会把 400、415 与 406 都包装成同一个“参数错误”。
校验要在边界完成
接口边界必须做校验。不要把明显错误一路传到数据库层才炸。
请求体校验:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderCommandService.create(request);
}请求:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"","amount":0}'正常应该返回 400。
方法参数校验:
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size
) {
return orderQueryService.list(page, size);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=0&size=20'Spring Framework 6.1 之后,Spring MVC 内置了对控制器方法校验的支持。老资料里经常要求在 Controller 类上加 @Validated 才能触发方法校验;在新版本里,如果类级别 @Validated 触发 AOP 校验,反而可能绕过 MVC 内置方法校验。Boot 4 / Framework 7 项目要按当前官方文档确认,不要照抄旧写法。
校验常见异常:
MethodArgumentNotValidException:@RequestBody 对象校验失败
HandlerMethodValidationException:Controller 方法参数或返回值校验失败
MissingServletRequestParameterException:缺少必填 query 参数
MethodArgumentTypeMismatchException:参数类型转换失败
HttpMessageNotReadableException:JSON 格式错误或 body 无法读取把参数绑定、校验和错误响应串起来看,会更容易定位 400 到底来自哪一层。排障时先看请求参数来源,再看类型转换和 JSON 读取,最后看校验异常有没有被统一转成稳定的 ProblemDetail。
设计评审时也可以按这张图反问:入口 DTO 是否和数据库实体解耦,所有 400 是否能映射到明确异常,traceId 是否进入错误体和日志,@RestControllerAdvice 是否覆盖了当前 Spring MVC 版本真正会抛出的异常类型。
校验链路可以按下面这张表反推:
| 写法 | 触发位置 | 常见异常 | 排查入口 |
|---|---|---|---|
@Valid @RequestBody CreateOrderRequest | RequestResponseBodyMethodProcessor 读完 body 后校验对象 | MethodArgumentNotValidException | JSON 是否可读、字段约束是否在 DTO 上 |
@Valid OrderSearchQuery | ModelAttributeMethodProcessor 绑定 query/form 后校验对象 | HandlerMethodValidationException 或绑定错误 | query 名称、setter、默认值、类型转换 |
@RequestParam @Min(1) int page | 方法参数级校验 | HandlerMethodValidationException | 是否引入 validation starter、是否处理该异常 |
@RequestHeader @NotBlank String tenantId | 方法参数级校验 | HandlerMethodValidationException | Header 是否传入、网关是否透传 |
这里有一个生产建议:请求 DTO 只表达入口协议,不要复用数据库实体。入口字段、校验文案、默认值和数据库字段的变化节奏不同,复用实体会让接口兼容性和数据模型绑死在一起。
如果你希望局部处理校验错误,可以在待校验参数后面紧跟 BindingResult。但大多数 REST API 更推荐全局统一处理,避免每个 Controller 重复写错误拼装逻辑。
成功响应和错误响应分开设计
很多团队喜欢所有返回都包一层:
{
"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));
}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);
}
}但错误响应更推荐使用 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 等状态码生产原则:
参数错误: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 必须同时出现在响应头、错误体和日志里,否则错误码只能告诉你“错了”,不能帮你定位“哪一次错了”。用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/validation-conversion-message/ContentNegotiationDemo.java examples/backend-development/spring-mvc/validation-conversion-message/ValidationPipelineDemo.java
java -cp examples/backend-development/spring-mvc/validation-conversion-message ContentNegotiationDemo
java -cp examples/backend-development/spring-mvc/validation-conversion-message ValidationPipelineDemoacceptable=application/json converter=application/json status=200
convertedType=int value=-2 validation.invalid=true rejected=true第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
