请求映射与参数绑定:从 RequestMappingInfo 到 WebDataBinder
路径相同不代表映射相同,字段同名也不代表允许绑定。Spring MVC 先用 RequestMappingInfo 的组合条件选出唯一 HandlerMethod,再逐参数选择 HandlerMethodArgumentResolver;对象参数还会进入 WebDataBinder。把这三步揉成“自动注入”,会同时制造歧义路由与越权字段写入。
写一个能上线的最小 Controller
先定义响应对象。这里用 Java record,让示例短一些:
package com.example.order.api;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderResponse(
Long orderId,
String status,
BigDecimal amount,
Instant createdAt
) {
}请求对象:
package com.example.order.api;
import java.math.BigDecimal;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "商品编码不能为空")
String skuCode,
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额必须大于 0")
BigDecimal amount,
@Size(max = 200, message = "备注不能超过 200 个字符")
String remark
) {
}Controller:
package com.example.order.api;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/orders")
class OrderController {
@GetMapping("/{orderId}")
OrderResponse getOrder(
@PathVariable Long orderId,
@RequestHeader(name = "X-Request-Id", required = false) String requestId
) {
return new OrderResponse(orderId, "CREATED", new BigDecimal("99.00"), Instant.now());
}
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") @Min(value = 1, message = "page 最小为 1") int page,
@RequestParam(defaultValue = "20") @Min(value = 1, message = "size 最小为 1") int size
) {
return List.of(new OrderResponse(1L, "CREATED", new BigDecimal("99.00"), Instant.now()));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return new OrderResponse(1001L, "CREATED", request.amount(), Instant.now());
}
}启动后验证查询:
curl -i http://127.0.0.1:8080/api/v1/orders/1001验证列表:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=1&size=10'验证创建:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: demo-001' \
-d '{"skuCode":"SKU-001","amount":99.00,"remark":"first order"}'你应该看到 201 和订单 JSON。
这里有几个实践约束:
查询单个资源:优先 GET /api/v1/orders/{orderId}
创建资源:优先 POST /api/v1/orders
请求体必须声明 Content-Type: application/json
创建成功用 201,比所有成功都 200 更清楚
路径版本 /api/v1 简单直接,适合大部分团队参数绑定按来源分清楚
参数绑定不要混着写。先问一句:这个参数从哪里来?
底层处理不是一个注解一个 if,而是 RequestMappingHandlerAdapter 按方法参数逐个找 HandlerMethodArgumentResolver:
@PathVariable -> PathVariableMethodArgumentResolver -> URI 模板变量
@RequestParam -> RequestParamMethodArgumentResolver -> query string / form 参数
@RequestHeader -> RequestHeaderMethodArgumentResolver -> HTTP header
@RequestBody -> RequestResponseBodyMethodProcessor -> HttpMessageConverter 读 body
普通对象 -> ModelAttributeMethodProcessor -> WebDataBinder 绑定属性解析器拿到原始值后,简单类型会走 ConversionService 做类型转换,复杂对象会交给 WebDataBinder 做属性绑定。带 @Valid 或约束注解时,再进入 Validator。所以排查参数问题时要按三步看:
第一步:参数来源是否正确,例如 query、path、header、body 有没有放错。
第二步:类型转换是否成功,例如 abc 能不能转成 Long、日期格式是否符合配置。
第三步:校验是否触发,以及异常有没有被全局处理成稳定错误响应。PathVariable
资源标识放路径里:
@GetMapping("/{orderId}")
OrderResponse getOrder(@PathVariable Long orderId) {
return orderQueryService.getOrder(orderId);
}请求:
curl -i http://127.0.0.1:8080/api/v1/orders/1001常见坑:
路径变量名和方法参数名不一致
Long 转换失败导致 400
路径层级太深,接口难维护如果参数名不一致,显式指定:
@PathVariable("orderId") Long idRequestParam
筛选、分页、排序放 query:
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status
) {
return orderQueryService.list(page, size, status);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=1&size=20&status=CREATED'数组参数:
@GetMapping("/batch")
List<OrderResponse> batchGet(@RequestParam List<Long> orderIds) {
return orderQueryService.batchGet(orderIds);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders/batch?orderIds=1&orderIds=2'RequestHeader
请求追踪、调用方、幂等键、灰度标识可以从 header 来:
@PostMapping
OrderResponse createOrder(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateOrderRequest request
) {
return orderCommandService.create(idempotencyKey, request);
}请求:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-<RUN_ID>' \
-d '{"skuCode":"SKU-001","amount":99.00}'幂等键不是装饰。生产上只要接口会被客户端重试、网关重试、消息补偿调用,就要明确幂等语义。
RequestBody
复杂写操作用 JSON body:
@PostMapping
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderCommandService.create(request);
}请求必须带:
Content-Type: application/json如果没带,常见现象是 415:
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}'表单和查询对象
GET 查询条件太多时,不要让方法参数排成一长串,可以用对象绑定:
package com.example.order.api;
import jakarta.validation.constraints.Min;
public class OrderSearchQuery {
@Min(value = 1, message = "page 最小为 1")
private int page = 1;
@Min(value = 1, message = "size 最小为 1")
private int size = 20;
private String status;
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}Controller:
@GetMapping("/search")
List<OrderResponse> search(@Valid OrderSearchQuery query) {
return orderQueryService.search(query);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders/search?page=1&size=20&status=CREATED'这里要注意:GET 查询对象通常来自 query string,不是 JSON body。不要为了偷懒让 GET 带 body,代理、网关、缓存和客户端兼容性都可能出问题。
用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/mapping-arguments-binding/RequestMappingSelectionDemo.java examples/backend-development/spring-mvc/mapping-arguments-binding/ArgumentBindingDemo.java
java -cp examples/backend-development/spring-mvc/mapping-arguments-binding RequestMappingSelectionDemo
java -cp examples/backend-development/spring-mvc/mapping-arguments-binding ArgumentBindingDemoselected=POST /orders version=2
path.orderId=42 query.page=3 disallowedFieldRejected=true第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
