请求进 DispatcherServlet 后,先找到该由哪个方法处理,再把 HTTP 报文塞进方法参数。这两步看着是框架义务,实际上每一步都能让接口在线上返回 400、404、406 或 415,而堆栈里只有一行 MethodArgumentNotValidException,甚至什么都没有。
下面按"匹配 → 绑定 → 转换 → 返回"四段拆,每段说清可选面、默认值和坑。
1. @RequestMapping 的匹配维度
| 维度 | 属性 | 示例 | 不匹配的结果 |
|---|---|---|---|
| 路径 | value / path |
/orders/{id} |
404 |
| 方法 | method |
RequestMethod.GET |
405 |
| 参数 | params |
params = "type=paid" |
400 |
| 头 | headers |
headers = "X-Api-Version=2" |
404 |
| 请求体类型 | consumes |
application/json |
415 |
| 响应类型 | produces |
application/json |
406 |
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public OrderVO get(@PathVariable Long id) { return orderService.get(id); }
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public Long create(@RequestBody CreateCmd cmd) { return orderService.create(cmd); }
@GetMapping(params = "type=paid")
public List<OrderVO> paid() { return orderService.paid(); }
}
多个映射命中时按"具体程度"排优先级:精确路径 > 含路径变量的模式 > 通配,同级再比方法、params、headers 的约束数量,带正则的 {id:[0-9]+} 比纯 {id} 更具体。两个同等具体的映射会直接启动失败并报 Ambiguous mapping——比运行时随机选一个强。
2. 路径变量与正则
| 写法 | 说明 |
|---|---|
/orders/{id} |
基础形态,类型由方法参数决定 |
/orders/{id:[0-9]+} |
正则约束,不匹配就走别的映射或 404 |
/orders/{id}/items/{itemId} |
多变量,名字要和 @PathVariable 对上 |
/files/** |
尾部通配,只能出现在末尾 |
@GetMapping("/orders/{id:[0-9]{1,18}}")
public OrderVO byId(@PathVariable("id") Long id) { }
@GetMapping({"/orders/current", "/orders/{id}"}) // 字面优先于变量
public OrderVO either(@PathVariable(required = false) Long id) { }
@PathVariable 默认 required = true,而映射里声明的路径变量不可能缺失,所以这个属性只在"多个模式共享一个方法"时有意义。用 required = false 时参数必须是包装类,不能是 long,否则缺失时绑不了 null。
3. 参数绑定来源注解
| 注解 | 取哪里 | 编码/格式 | 典型误用 |
|---|---|---|---|
@RequestParam |
query string 或 application/x-www-form-urlencoded body |
字符串再转换 | 用在 JSON body 上,取不到 |
@PathVariable |
URI 模板 | 同上 | 路径含 . 被截断 |
@RequestBody |
整个请求体 | HttpMessageConverter |
用于表单,报 415 |
@RequestHeader |
请求头 | 字符串转换 | 大小写、多值头 |
@CookieValue |
Cookie | 字符串转换 | Cookie 名含特殊字符 |
@ModelAttribute |
表单字段/查询参数 | 逐字段 setter | 每次请求都执行 |
@PostMapping(value = "/search", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public PageResult<OrderVO> search(@RequestParam(defaultValue = "1") int page,
@RequestParam(required = false) OrderStatus status,
@ModelAttribute OrderQuery query) { }
@RequestBody 一个方法里只能出现一次(整条流被读完);@RequestParam 与它共用是允许的,但前者只从 query string 取,JSON body 里的同名字段取不到——第 9 节那条坑由此而来。
4. @RequestBody 与表单提交的分界
差别不在注解,在 Content-Type 与转换器的对应关系。
请求 Content-Type |
可用注解 | 转换器 |
|---|---|---|
application/json |
@RequestBody |
MappingJackson2HttpMessageConverter |
application/x-www-form-urlencoded |
@RequestParam / @ModelAttribute |
FormHttpMessageConverter |
multipart/form-data |
@RequestPart / @ModelAttribute |
MultipartHttpMessageConverter |
consumes 与真实头不匹配时 Spring 直接回 415,根本不进你的方法。常见于前端用默认 JSON 提交而后端写了 consumes = form-urlencoded,或接口写了 @RequestBody 但前端发了 FormData。
# 415 的现场复现
curl -i -X POST http://[HOST]/api/orders \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'sku=A1&qty=2'
# HTTP/1.1 415 Unsupported Media Type
5. 日期与枚举的绑定
| 类型 | 默认行为 | 显式指定 |
|---|---|---|
LocalDate |
依赖 spring.mvc.format.date,未配置时按 ISO |
@DateTimeFormat(iso = DATE) |
Date |
依赖注册的 Converter,容易随环境变化 |
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| 枚举 | 按 name 匹配,大小写敏感 | 自定义 Converter 或用 @JsonCreator |
Boolean |
true/false,1/0 视情况 |
显式 Converter |
@InitBinder
public void init(WebDataBinder binder) { // 只影响本 Controller
binder.registerCustomEditor(Date.class,
new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), true));
}
public enum OrderStatus {
CREATED, PAID, SHIPPED;
@JsonCreator
public static OrderStatus of(String v) { // 只对 @RequestBody 生效
return OrderStatus.valueOf(v.toUpperCase());
}
}
@JsonCreator 只对 @RequestBody 的 Jackson 反序列化生效;@RequestParam OrderStatus 走 StringToEnumConverterFactory,不在 Jackson 里,要另注册 Converter。这就是枚举绑定"时好时坏"的原因。
6. @ModelAttribute 的回填与副作用
@ModelAttribute 标注的方法在每次该 Controller 处理请求前都会执行,然后把结果放进 Model。
@Controller
public class OrderPageController {
@ModelAttribute("statuses")
public OrderStatus[] statuses() { // 每个请求都跑一次
return OrderStatus.values();
}
@GetMapping("/orders")
public String list(@ModelAttribute OrderQuery query, Model model) {
model.addAttribute("page", orderService.search(query));
return "order/list";
}
}
两个副作用:一是它在参数绑定之前执行,不可能依赖请求参数(除非参数是 @RequestParam);二是每次请求都执行,里面查库等于给所有接口加一次查询——缓存或 binding = false 都不解决,只能挪出这个方法。
7. 返回值的四种处理
| 返回 | 处理方式 | 说明 |
|---|---|---|
@ResponseBody 标注的方法返回对象 |
HttpMessageConverter 序列化 |
@RestController 等于类级 @ResponseBody |
ResponseEntity<T> |
状态码 + 头 + body 全控 | 需要 201、Location 头时用 |
String(无 @ResponseBody) |
视图名 | 与 @RestController 混用时极易搞错 |
void |
认为已自行写入响应 | 什么都不写会得到 200 空体 |
@PostMapping
public ResponseEntity<Long> create(@RequestBody CreateCmd cmd) {
Long id = orderService.create(cmd);
return ResponseEntity.created(URI.create("/api/orders/" + id)).body(id);
}
@RestController 里想返回视图名,必须去掉类级 @ResponseBody,否则 "order/list" 会被当字符串写进响应体。
8. 内容协商与 406
produces 与客户端 Accept 头做匹配,匹配不上就是 406。
| 场景 | 结果 |
|---|---|
produces = application/json,客户端 Accept: */* |
200 |
produces = application/json,客户端 Accept: text/html |
406 |
未写 produces,Jackson 在类路径上 |
默认返回 JSON |
spring:
mvc:
contentnegotiation:
favor-parameter: true
parameter-name: format # /api/orders?format=xml,慎用
用参数做内容协商会污染语义,也容易变成缓存键噪声。需要多格式时优先用不同路径或 Accept 头。
9. HandlerMethodArgumentResolver 扩展点
参数来源不在标准注解里时(比如从 token 解出当前用户),别在方法体里手工解析,加个解析器:
public class CurrentUserResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter p) {
return p.hasParameterAnnotation(CurrentUser.class);
}
@Override
public Object resolveArgument(MethodParameter p, ModelAndViewContainer m,
NativeWebRequest req, WebDataBinderFactory b) {
return userService.parse(req.getHeader("Authorization")); // 失败抛可映射 401 的异常
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserResolver());
}
}
注意这套配置对 WebFlux 无效,是两套扩展点。
10. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 415 | consumes 与实际 Content-Type 不符 |
改前端头,或改 consumes |
| 406 | produces 与 Accept 不符 |
补 produces 或让客户端放宽 Accept |
路径变量含 . 被截断(a.b 只剩 a) |
尾部路径匹配 + 后缀模式 | 关闭后缀匹配,或改用 query 传参 |
| URL 与 body 同名字段只取到一个 | 两者来源不同,@RequestParam 只读 query |
明确拆成两个不同参数名 |
GET 请求用 @RequestBody 失败 |
GET 语义无 body,部分客户端不发 | 改 @ModelAttribute 或 query 参数 |
| 返回视图名却输出字符串 | 类上有 @ResponseBody |
拆 Controller 或改返回 ModelAndView |
11. 边界
| 场景 | 建议 |
|---|---|
| 简单 CRUD | 注解足够,别上自定义解析器 |
| 参数来自 token / 签名 | 用 HandlerMethodArgumentResolver |
| 十几个可选查询条件 | 用 @ModelAttribute 包一层查询对象 |
| 文件 + JSON 混合上传 | multipart + @RequestPart |
一句话:映射决定"谁处理",consumes/produces 决定"能否进",参数注解决定"从哪取"。分开看,4xx 基本一次定位。
12. 自测
- 接口返回 415,最可能改哪一边:
consumes还是Accept? - 路径变量
{id:[0-9]+}不匹配时,最终是 404 还是 400? @RequestParam和@RequestBody同时出现,能从 body 取到值吗?- 枚举作为
@RequestParam时按什么匹配?和@RequestBody的差别在哪? @ModelAttribute标注的方法什么时候执行?为什么可能拖慢所有接口?@RestController里返回"order/list",会发生什么?