请求映射与参数绑定:从 HTTP 输入到方法参数
GET /inputs/orders/42?expand=true 中的 42 来自路径,expand 来自查询字符串。即使两者最终都传进同一个 Java 方法,取值位置、缺失处理和转换过程也不同。
Spring MVC 先确定要调用的方法,再为它逐个准备参数。理解这两步,可以解释“URL 看起来正确却返回 405”,也可以找到“JSON 已发送但对象仍使用默认值”的原因。
一个请求怎样选中 Controller 方法
将映射条件拆开
@RestController
@RequestMapping("/inputs")
class InputController {
@GetMapping("/orders/{id}")
Map<String, Object> order(
@PathVariable("id") long id,
@RequestParam(name = "expand", defaultValue = "false") boolean expand,
@RequestHeader(name = "X-Client", defaultValue = "anonymous") String client) {
return Map.of("id", id, "expand", expand, "client", client);
}
}这个方法接收三种来源的数据。类上的 /inputs 与方法上的 /orders/{id} 组成路径;@GetMapping 还限定 HTTP 方法。参数注解在方法已经选中后生效。
GET /inputs/orders/42?expand=true
X-Client: web
│
├── 映射阶段
│ ├── 路径:/inputs/orders/{id}
│ └── 方法:GET
│
└── 参数阶段
├── 路径变量 id="42" → long 42
├── 请求参数 expand="true" → boolean true
└── 请求头 X-Client="web" → String "web"RequestMappingInfo 还可以包含 params、headers、consumes、produces,以及启用后的 API 版本条件。它们共同缩小候选方法的范围。例如 params="mode=preview" 用于选择方法;@RequestParam("mode") 用于取得值。两者各自有职责。
consumes 描述请求主体允许的媒体类型,通常根据 Content-Type 判断;produces 描述响应能够生成的媒体类型,与 Accept 协商。JSON 接口收到 text/plain 和客户端只接受 XML,是两个方向的失败。类级与方法级条件也不全是相加关系:方法级 consumes、produces 会覆盖类级同类设置。组合规则见 Mapping Requests。
路径更具体的候选优先
/inputs/orders/recent 与 /inputs/orders/{id} 可以同时存在。请求 recent 时,静态路径比变量路径具体,框架选择 recent 方法;不会先把 recent 转为 long 再尝试另一个方法。
路径中的变量按路径段提取,查询字符串不参与这一步。/orders/42 与 /orders?id=42 是不同的接口地址。PathPattern 在注册时解析路径模式,匹配时处理分段后的请求路径;编码、分号路径参数和代理改写也需要考虑,不能先在应用中对整条 URI 重复解码。
默认的路径匹配不会把所有近似地址都归为同一个资源。尾部斜杠、大小写及代理移除的前缀应由接口和入口配置统一决定。若网关对 /api/orders 做了改写,检查应用实际收到的 servletPath、contextPath 和 requestURI,而不是只比较浏览器地址。
同一请求若仍有两个同等优先的最佳候选,会出现歧义。不要用方法在源文件中的排列顺序解决;应调整路径或请求条件,让调用者能够明确区分。
GET、HEAD 与 OPTIONS
GET 映射支持 HEAD 请求。HEAD 可经过相应处理逻辑,但真实 HTTP 响应不能包含主体,适合取得响应元数据;如果 GET 本身执行昂贵查询,HEAD 也可能承担这部分成本。
OPTIONS 通常由框架根据路径匹配结果计算 Allow。对仅声明 GET 的接口发送 DELETE,可能得到 405 与 Allow 头部。浏览器的 CORS 预检虽然也使用 OPTIONS,还带有 Origin 和 Access-Control-Request-Method,需要按 CORS 配置处理,不能仅凭普通 OPTIONS 成功认定跨域调用可用。
请求方法的安全性、幂等性是接口行为的约定。@GetMapping 不会阻止方法内部写数据库;应让读取接口遵守 GET 语义,修改状态使用相应写入方法。
参数解析和数据绑定分别做什么
先选择解析器,再取得参数值
RequestMappingHandlerAdapter 使用有序的 HandlerMethodArgumentResolver 集合。每个方法参数交给首个声明支持它的解析器,不会让所有解析器依次修改同一个参数。
| 方法参数 | 常用输入位置 | 需要注意的条件 |
|---|---|---|
| @PathVariable | 路径模板变量 | 名称需要对应,转换失败通常为 400 |
| @RequestParam | Servlet 请求参数 | 查询参数、表单参数;同名重复值可接收为集合 |
| @RequestHeader | 请求头 | 显式声明名称和缺省行为 |
| @CookieValue | Cookie | 客户端可以修改普通 Cookie,不能直接作为授权依据 |
| @ModelAttribute | 模型或新建对象,再进行绑定 | 适合表单、查询对象;对象属性需要限制 |
| @RequestBody | HTTP 主体 | 交给消息转换器读取,不读取查询参数来补齐 JSON |
| @RequestPart / MultipartFile | multipart 中的某个部分 | part 名称和媒体类型需要与客户端一致 |
| @RequestAttribute | 当前 request 的属性 | 通常由服务器前置处理放入 |
| @SessionAttribute | 已存在的会话属性 | 与新建 HttpSession、持久化业务状态分别处理 |
| Principal | 已建立的认证主体 | 依赖认证处理;未认证时可能为空 |
这是一张输入位置表。完整类型及其特殊行为可以查 Controller 方法参数。
单值输入应明确缺省规则。默认必填的 @RequestParam 缺失会被拒绝;defaultValue 同时提供缺省值并取消必填。Optional 或 required=false 适用于允许缺失的参数,但后续逻辑仍要明确“未提供”代表什么。可空输入使用包装类型,避免要求 int 承接 null。
@GetMapping("/batch")
List<Long> batch(@RequestParam("ids") List<Long> ids) {
return ids;
}请求 /inputs/batch?ids=1&ids=2 产生两个元素。集合元素仍然要经过转换,其中一个值为 abc 就可能使整个参数解析失败。接受的集合长度还应设置上限,防止查询条件或内存开销随输入无限增长。数组、集合和参数 Map 的规则见 @RequestParam。
表单对象的构造与属性赋值
record SearchRecord(String status, Integer size) {}
@GetMapping("/record")
SearchRecord search(@ModelAttribute SearchRecord search) {
return search;
}框架可以通过构造器参数创建这个 record。对于可变 JavaBean,还可以在创建后调用 setter 进行属性绑定。原始字符串通过 ConversionService 转为目标字段类型;page=abc 绑定到 int 时会产生绑定错误。
在 Spring Framework 7 的默认 Servlet 模型属性绑定中,输入还可能来自 URI 变量和请求头。请求参数优先,请求头名称中的短横线会被去除。由此产生一个实际影响:不能看到 suppressedFields 非空就一律返回 400,因为 Content-Type 等普通请求头也可能被绑定白名单排除。
模型属性文档推荐使用专用输入对象,优先考虑仅构造器绑定;使用属性绑定时,应指定允许字段。直接把数据库实体暴露给 WebDataBinder,可能让用户修改 role、ownerId、price 等本来由服务器决定的属性。
实验中的 SearchForm 为了展示这种风险,故意保留一个可写 role 属性,但绑定白名单只有 page、size、status:
@InitBinder("search")
void limitFormFields(WebDataBinder binder) {
binder.setAllowedFields("page", "size", "status");
}正常请求中 role 保持 USER。客户端提交 role=ADMIN 时,Binder 会抑制这个字段;默认行为是忽略,而非自动生成错误响应。实验进一步检查被抑制的字段是否确实出现在请求参数集合中,主动拒绝这类查询或表单输入。普通请求头被忽略,不影响正常表单。
这段防护用于解释旧式可变表单的处理。新接口通常直接定义只有允许字段的 DTO,再把服务端身份、价格计算结果等信息单独传给业务层。Binder 的配置和作用范围见 @InitBinder。
BindingResult 必须紧跟对应对象
Map<String, Object> search(
@ModelAttribute("search") SearchForm form,
BindingResult binding,
HttpServletRequest request) {
if (binding.hasErrors()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid form value");
}
// 继续检查允许字段,再使用 form。
}BindingResult 记录的是紧邻对象的绑定与校验结果。方法接收它以后,应先判断错误,再使用对象,否则某些字段可能仍保留默认值或部分绑定后的值。一个方法有多个模型对象时,每份结果分别放在对应参数后面。
绑定会尝试把字符串放入目标类型;Bean Validation 则检查值是否满足约束。没有启用相应校验时,quantity=0 可以成功转为整数并进入方法。校验的触发条件见转换与校验。
JSON 使用主体读取路径
record JsonOrder(String sku, int quantity) {}
@PostMapping(path = "/json",
consumes = "application/json",
produces = "application/json")
JsonOrder json(@RequestBody JsonOrder order) {
return order;
}@RequestBody 通过 HttpMessageConverter 从请求流中读取 JSON;@ModelAttribute 处理请求参数及其他绑定来源。给表单接口发送 {"page":9},并不会让 ModelAttribute 自动切换到 JSON 反序列化。
请求体通常只能消费一次。日志 Filter 若提前读完输入流,后续 JSON 解析可能看到空主体;需要使用合适的缓存包装,并限制体积、避免记录凭据和文件内容。表单请求应优先使用参数访问方式,避免把 Servlet 表单解析与手工读取原始流混用。相关限制见 @RequestBody。
为明确的类型增加自定义解析器
实验另有 ClientLabel 类型,从 X-Client-Label 读取一个长度受限的展示标签。它不代表登录用户。
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType() == ClientLabel.class;
}解析器只认这个专用类型,resolveArgument 中校验字符与长度,再返回 ClientLabel。通过 WebMvcConfigurer.addArgumentResolvers 注册后,/inputs/client-label 的未注解参数能够使用它。
自定义解析器并不总排在所有内置解析器之前。显式注解可能已由更早的内置解析器接管;添加一个支持所有 Object 的解析器既难预测,也容易误接管 DTO。实验使用专用类型和未注解参数,避开这种冲突。MVC 配置接口提供注册入口。
在 Linux 上验证映射与绑定
准备可运行工程
下载 MVC 实验工程,保存到当前目录。环境为 Linux Bash、Docker、unzip、curl;当前普通用户可以使用 Docker。工程使用 Boot 4.1.1、Framework 7.0.9,编译目标 Java 17,以下使用 Java 25 容器。每次实验在独立目录解包。
LAB_DIR="$(mktemp -d)"
test "$(id -u)" -ne 0 || exit 1
unzip mvc-runtime-lab.zip -d "$LAB_DIR"
PROJECT_DIR="$LAB_DIR/mvc-runtime-lab"
mkdir -p "$LAB_DIR/m2"
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache clean verify
docker run -d --name mvc09-binding \
--user 10001:10001 --read-only --tmpfs /tmp:rw,nosuid,size=64m \
-p 127.0.0.1:18091:8080 \
-v "$PROJECT_DIR/target/mvc-runtime-lab-1.0.0.jar:/app/app.jar:ro" \
eclipse-temurin:25.0.4_7-jdk java -jar /app/app.jar编译结果应为 BUILD SUCCESS,测试使用真实 MVC 解析器和嵌入式 Tomcat,不需要外部数据库。启动后用 docker logs --tail 40 mvc09-binding 检查日志,再请求 /lab/health。镜像下载、依赖下载或文件权限失败应先处理相应错误;编译失败时不要继续启动旧 JAR。
端口只发布到回环地址。工程没有配置生产认证,不能对外开放其观察接口。
第一组输入:路径、查询和头部
BASE=http://127.0.0.1:18091
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'X-Client: web' "$BASE/inputs/orders/42?expand=true"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/inputs/orders/recent"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
"$BASE/inputs/batch?ids=1&ids=2"第一条响应包含 id=42、expand=true、client=web。第二条是 selected=recent,显示静态映射被选中。第三条主体为 [1,2]。去掉 expand,观察其默认值 false;将 42 换成 abc,应得到 400,方法不会取得 long 参数。
curl 的 -q 必须放在第一个选项,关闭默认 curlrc;--noproxy '*' 隔离代理环境。正常请求使用 --fail-with-body,HTTP 错误时会保留主体并非零退出。curl 官方手册列出了这些选项的职责。
第二组输入:表单、JSON 与被禁止的字段
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--data-urlencode 'page=2' "$BASE/inputs/search"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'Content-Type: application/json' \
--data '{"sku":"SKU-001","quantity":2}' "$BASE/inputs/json"
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'X-Client-Label: web' "$BASE/inputs/client-label"表单响应中 page=2、role=USER;JSON 响应保留 SKU 和数量;自定义解析器返回 value=web。三个接口分别演示属性绑定、消息转换和自定义取值。
下面主动提交不允许的字段,预期 400。负例不使用 --fail-with-body,而是分别判断传输和 HTTP 状态:
RESULT="$(mktemp)"
STATUS="$(curl -q --noproxy '*' -sS --max-time 5 \
-o "$RESULT" -w '%{http_code}' \
--data-urlencode 'role=ADMIN' "$BASE/inputs/search")"
TRANSFER=$?
test "$TRANSFER" -eq 0 || exit 1
test "$STATUS" = 400 || exit 1
cat "$RESULT"
rm -- "$RESULT"主体说明 Unsupported form field。去掉 role 后恢复成功;如果仍失败,检查其他请求参数、Content-Type 和服务日志。不能因为一次请求被拒绝,就假定所有对象字段都已受到相同保护。
验证 HEAD 时保留真实 HTTP 层
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--head "$BASE/inputs/orders/42"这条请求只接收响应头。工程的 DispatcherChainTest 也使用 JDK HTTP 客户端断言 HEAD 响应主体为空。
MockMvc 适合验证映射、转换异常和模型结果,但它不启动真实网络连接,也不完整替代 Servlet 容器的 HTTP 输出处理。因此 MappingValidationTest 用它确认 HEAD 能匹配,主体为空则交给真实 Tomcat 测试。测试对象的区别见 MockMvc。
完成后运行 docker stop mvc09-binding,再运行 docker rm mvc09-binding。解包工程和 Maven 缓存仍保留在 LAB_DIR 中,可以继续检查测试源码。
输入失败时从哪一层检查
| 现象 | 优先检查 | 修正后怎样重试 |
|---|---|---|
| 404 | 应用实际路径、context path、Controller 是否注册、代理是否改写 | 在应用回环端口请求完整路径 |
| 405 | 实际 HTTP 方法与 Allow | 保持地址不变,改为允许的方法 |
| 415 | Content-Type、consumes、消息转换器 | 发送与接口声明一致的请求主体 |
| 406 | Accept、produces、可用的输出转换器 | 先明确接受 application/json |
| 缺参或类型错误 400 | 输入位置、名称、原始值、目标 Java 类型 | 只改出错字段,与成功请求对照 |
| 对象仍为默认值 | 是否把 JSON 发给 ModelAttribute、构造器参数是否匹配 | 改用正确输入协议,检查 BindingResult |
| 自定义解析器未调用 | supportsParameter、注册位置、是否被内置解析器先接管 | 使用明确类型验证,再检查注解 |
| 不允许字段悄悄消失 | allowedFields 与 suppressedFields 的处理 | 明确选择忽略或拒绝,测试业务字段不会改变 |
遇到 400 时,不必立即放宽全部参数为 String。先定位错误属于缺失、类型转换、绑定还是约束校验。把所有内容改成字符串虽然可能绕过框架检查,却会把同一批错误推迟到业务方法里。
如果 DTO 正确、字段也正确,却绑定到了错误对象,检查模型属性名称及是否复用了 Session 中的模型对象。会话中的旧值、共享可变对象和同名模型属性会让结果受前一次请求影响。每个写入接口采用清晰的输入对象,再明确合并哪些字段,通常更容易维护。
