控制器细节全解:参数绑定、返回值与类型转换
写控制器最容易犯的错,不是语法写错,而是不知道数据还能从哪儿来。一个 @GetMapping 方法上可写的参数类型多到超出直觉:查询串、路径占位符、请求体、请求头、Cookie、Session、原生输入输出流……它们各自由不同的 HandlerMethodArgumentResolver 负责装配。先用一张全景表把「可能性」列清楚,再逐个抠细节,比零散背 API 高效得多。
| 注解 / 类型 | 数据来源 | 典型适用方式 | 示例 |
|---|---|---|---|
@RequestParam | URL 查询串 ?page=1,或表单体 | 任意 | @RequestParam("page") int page |
@PathVariable | URL 路径中的占位符 | 任意 | @PathVariable Long id |
@RequestBody | 请求体(JSON / XML) | POST / PUT / PATCH | @RequestBody UserDTO dto |
@RequestHeader | HTTP 请求头 | 任意 | @RequestHeader("Authorization") String token |
@CookieValue | Cookie 中的某个键 | 任意 | @CookieValue("SESSION") String sid |
@RequestAttribute | 过滤器里 request.setAttribute 写入的值 | 任意 | @RequestAttribute("userId") Long uid |
@ModelAttribute | 查询串 / 表单 → 绑定到对象 | GET / POST | @ModelAttribute UserQuery q |
@SessionAttribute | HttpSession 中的属性 | 任意 | @SessionAttribute("user") User u |
HttpServletRequest / HttpServletResponse | 原生 Servlet 对象 | 任意 | HttpServletRequest req |
InputStream / OutputStream | 原始请求 / 响应字节流 | 任意 | InputStream in |
Principal | 已认证的主体信息 | 任意 | Principal principal |
@Valid | 不取值,触发参数校验 | 任意 | @Valid @RequestBody UserDTO dto |

这张表里真正「取数据」的只有前八行,HttpServletRequest 之类的原生对象是「逃生舱口」——能不用就不用,用了就意味着你放弃了框架帮你做的转换与校验。@Valid 更是例外,它不产生参数值,只是给已经绑好的参数挂上一个校验开关。
这张表能兑现多少,第一处真相不在注解里,在 pom.xml 里。自己勾一遍就懂:只勾 web,@RequestBody 当场能用,而 @Valid 会像没写一样——校验从来不是 MVC 自带的;@ModelAttribute 那个对象能不能绑上,取决于它有没有 getter/setter,这正是 lombok 那一段的意义:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>@RestController@RequestMapping("/api/products")public class ProductController { // 1) name / value 是别名:@RequestParam("page") 等价于 @RequestParam(name = "page") // 2) required 默认为 true,缺失时抛 MissingServletRequestParameterException → 400 // 3) defaultValue 一旦写上,required 自动变成 false @GetMapping public List<Product> list( @RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword) { return productService.search(keyword, page, size); }}name/value:指定参数名。不写时靠「参数名保留」编译(-parameters)从字节码里读,写错了就是 400,而不是编译错误required = true(默认):请求里没有这个参数时直接抛异常,别指望它进到方法体里再判空defaultValue = "...":同时隐式关闭了必填,这是最容易忽略的连带效果
@GetMapping("/users/{userId}/orders/{orderId:\\d+}")public Order detail(@PathVariable Long userId, @PathVariable("orderId") Long orderId) { return orderService.findByUserAndOrder(userId, orderId);}// 或者用 Map 一次性接收全部路径变量@GetMapping("/files/{folder}/{name}")public String read(@PathVariable Map<String, String> vars) { return files.read(vars.get("folder"), vars.get("name"));}路径里写 {orderId:\\d+} 就是给这段占位符加正则约束:只有纯数字才能匹配成功。/orders/abc 会因为不满足 \d+ 而落不到这个映射上,直接 404——比进到方法里再解析成 Long 失败要干净得多。
@PostMapping("/users")public UserVO create(@Valid @RequestBody UserCreateDTO dto) { // 请求体被 Jackson 读成 JSON 树反序列化成 dto // Content-Type 必须是 application/json,否则 415 return userService.create(dto);}public record UserCreateDTO( @NotBlank String username, @Email String email, Integer age) {}@RequestBody 底层依赖 HttpMessageConverter(JSON 场景是 MappingJackson2HttpMessageConverter)。它会先看 Content-Type:不是 application/json 就直接 415;是 JSON 但结构对不上(字段类型错、缺少必需项)则抛 HttpMessageNotReadableException,落到全局异常处理里通常映射成 400。
@GetMapping("/users")public PageResult<UserVO> page(@ModelAttribute UserQuery query) { // 查询串里的 username、status、page、size 会按 setter/构造器注入 query return userService.page(query);}public class UserQuery { private String username; private UserStatus status = UserStatus.ACTIVE; private int page = 1; private int size = 20; // getter / setter 省略}@ModelAttribute 会把 GET 查询串(或表单体)按属性名逐个写进对象,未出现的字段保留默认值。它让「一堆查询参数」看起来就像一个普通方法参数,是条件筛选接口的首选写法。
public class ReportQuery { // 声明文本格式,否则字符串 "2024-05-01" 无法变成 LocalDate @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate from; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt; // 枚举按名字匹配:?status=ACTIVE private UserStatus status;}日期是最典型的「类型不匹配现场」:不给格式说明,Spring 不知道 2024-05-01 该怎么解析,直接 400。枚举反而省心——默认按 Enum.valueOf 匹配名字,写成不存在的大小写会失败。
上面所有「String → 目标类型」的动作,都归一个组件管:ConversionService。它是 Spring 的通用类型转换门面,Converter 和 Formatter 都注册在它名下。

这两个是参数级的转换声明,只对当前字段生效,不需要全局注册:
public class InvoiceQuery { @DateTimeFormat(pattern = "yyyyMMdd") private LocalDate billDate; // 20240501 → LocalDate @NumberFormat(pattern = "#,###.##") private BigDecimal amount; // "12,345.67" → BigDecimal}当一个请求参数不是原始类型,而是你自己定义的值对象(比如订单号 OrderNo),就需要自定义转换器:
@Componentpublic class OrderNoConverter implements Converter<String, OrderNo> { @Override public OrderNo convert(String source) { // 形如 "ORD-20240501-0001",校验前缀与段数 if (source == null || !source.startsWith("ORD-")) { throw new IllegalArgumentException("非法订单号: " + source); } return OrderNo.parse(source); }}// 注册后,控制器里可以直接写值对象,框架负责转换@GetMapping("/orders/{no}")public OrderVO detail(@PathVariable OrderNo no) { return orderService.findByNo(no);}提示:Converter<String, T> 只处理单向转换。若正反两个方向都要,实现 Converter<S, T> 的同时再补一个 Converter<T, S>;或者用 Formatter<T>(同时支持 parse 与 print),后者还支持 Locale,做国际化更顺手。
@Componentpublic class PercentFormatter implements Formatter<BigDecimal> { @Override public BigDecimal parse(String text, Locale locale) { return new BigDecimal(text.replace("%", "")).movePointLeft(2); } @Override public String print(BigDecimal value, Locale locale) { return value.movePointRight(2).toPlainString() + "%"; }}Converter 与 Formatter 都通过实现 WebMvcConfigurer#addFormatters 注册(标注 @Component 也会被自动收集):
@Configurationpublic class WebConvertConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new OrderNoConverter()); registry.addFormatter(new PercentFormatter()); }}参数讲完,看另一头。控制器方法返回什么,同样决定 Spring 怎么处理它:
| 返回值 | 处理器 | 行为 |
|---|---|---|
普通对象 + @ResponseBody(或 @RestController) | RequestResponseBodyMethodProcessor | 经 HttpMessageConverter 序列化成 JSON 写回 |
ResponseEntity<T> | HttpEntityMethodProcessor | 完全掌控状态码、Header 与响应体 |
void | ServletInvocableHandlerMethod | 认为你已自行写出响应;在有 @ResponseBody 时也可能返回空体 |
String | 取决于有没有视图解析 | 无视图解析器 = 纯文本;有模板引擎 = 视图名 |
ModelAndView | ViewNameMethodReturnValueHandler | 携带视图名与模型,用于服务端渲染 |
StreamingResponseBody | StreamingResponseBodyReturnValueHandler | 在独立线程里分块写出,适合大文件下载 |
String 的语义是二义的。在 @RestController(等价于类上带 @ResponseBody)里,返回 "success" 就是文本 success;但在普通的 @Controller 里,同样返回 "success" 会被当成视图名,去渲染名为 success 的模板——找不到模板就是 404。搞不清这一点,是「为什么我的接口返回了一个 HTML 页面」的头号原因。
上面那张表的正确读法不是背下「哪个类型配哪个处理器」,而是看懂表决顺序:处理器表只问一个问题,而这个问题跟返回类型无关。点着走一遍:
这个分岔有真实现场可以点。同一个返回语句,只把类上的注解换掉,出口就换一个:
再把六个出口画成一张动图,第 ③ ④ 两帧是同一个方法体的两种命运:

想精确控制状态码和响应头,就用 ResponseEntity:
@RestController@RequestMapping("/api/files")public class FileController { // 1) 自定义状态码:创建成功返回 201 + Location 头 @PostMapping public ResponseEntity<UserVO> create(@RequestBody UserCreateDTO dto) { UserVO vo = userService.create(dto); return ResponseEntity .created(URI.create("/api/files/" + vo.id())) // 201 .body(vo); } // 2) 无内容:删除成功返回 204 @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { fileService.delete(id); return ResponseEntity.noContent().build(); // 204 } // 3) 文件下载:自定义 Content-Type 与 Content-Disposition @GetMapping("/{id}/download") public ResponseEntity<Resource> download(@PathVariable Long id) throws IOException { Resource resource = fileService.loadAsResource(id); String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename) .contentLength(resource.contentLength()) .body(resource); }}ResponseEntity 的链式 API 读起来像句子:.created(uri) = 201、.noContent() = 204、.ok() = 200。凡是「状态码不是 200」或「需要额外的响应头」的场景,都应该用 ResponseEntity,它比在方法里手工 setStatus 更清晰。
这两个错误几乎每个后端都遇到过,根因却完全不同:
| 现象 | HTTP 状态 | 触发条件 | 第一反应 |
|---|---|---|---|
HttpMediaTypeNotSupportedException | 415 | Content-Type 不是 application/json(或声明的 consumes 不匹配) | 检查前端请求头 |
HttpMediaTypeNotAcceptableException | 406 | 客户端 Accept 与能产出的类型都不匹配 | 检查 produces |
HttpMessageNotReadableException | 400 | 请求体结构非法:缺字段、类型错、JSON 语法错 | 看请求体原文 |
MissingServletRequestParameterException | 400 | 必填的 @RequestParam 没传 | 对齐前后端参数名 |
MethodArgumentTypeMismatchException | 400 | 传的字符串无法转成目标类型 | 检查参数格式 |
测试脚本里把请求体写成 {"name":"a"} 却漏了 Content-Type: application/json,服务端一律 415——它根本没机会去解析那个 JSON。这不是接口的 bug,是调用方少了一个头。
@GetMapping("/users")public PageResult<UserVO> page( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String sort, @RequestParam(required = false) String keyword) { return userService.page(keyword, page, size, sort);}参数少、语义独立、每个都要单独给默认值时,用 @RequestParam 逐个接最直观。
@GetMapping("/users/search")public PageResult<UserVO> search(@ModelAttribute UserQuery query) { // query 里可以塞十几个筛选字段,方法签名依然干净 return userService.search(query);}筛选字段一旦超过五六个,逐个 @RequestParam 会让签名爆炸,还会到处重复 required = false。这时把字段收进一个对象、用 @ModelAttribute 接,签名和校验都更好维护。
- @RequestParam 与 @PathVariable 同名混淆:
/users/{id}配@RequestParam Long id永远拿不到值,因为id在路径里而不是查询串里。报 400 时先确认「这个参数到底走的是哪条路」 - GET 想传对象却用了 @RequestBody:GET 通常没有请求体,
@RequestBody会 415 或读到空体。GET 传复合条件请用@ModelAttribute - @RequestBody 只能读一次:它背后是
HttpServletRequest.getInputStream(),流读一次就到底了。所以在拦截器里read过请求体之后,控制器再@RequestBody会抛HttpMessageNotReadableException(Stream closed / read end)。需要多次读取就得包一层可重复读的ContentCachingRequestWrapper
上面三个坑都写在代码里,还有第四个坑不写在代码里:application/x-www-form-urlencoded 的 POST 体积上限。一旦超过它,Tomcat 索性不解析表单参数——你的 @RequestParam 就像前端什么都没传一样报 400。把这个数字拖一遍,四种形态一次看全:
- 登录、筛选、下单这类表单基本都落在它之内
- 默认值不是保守值,它按「表单不是文件载体」这个前提定的
- 没人往表单里塞大段文本,就别去动它
前面八节把「能怎么写」讲完了,这一节退一步讲「为什么这么设计」——因为记住规则不如记住一个画面。
把控制器方法想成邮局柜台的玻璃窗。窗上贴着四张取件单,每张写明「从哪儿拿东西」:@RequestParam 是信封右上角的附加栏(收件人自己填的内容,来自查询串 ?page=1);@PathVariable 是门牌号(写死在地址里的一段路名,来自 /users/42);@RequestBody 是包裹本身(整个箱子里的东西,来自请求体);@RequestHeader 是快递面单背面的运输条款(跟内容无关但必须有的元数据,来自请求头)。柜台后面的分拣员就是参数解析器链:它不看你的业务,只看这张单子写着哪儿,就去哪儿翻。
而「玻璃窗的另一侧」还有第二条规矩:你递出来的东西长什么形状,决定它被怎么打包。返回对象 = 打成 JSON 包裹寄走;返回 String = 当成「去哪个房间取件」的视图名;这两条岔路就是第四节那个坑的全部来历。

类型转换(ConversionService)像家里的净水器接口。自来水(HTTP 世界的一切)永远是「字符串」这一种水质,而你家的每个龙头(方法参数)要的水不一样:厨房要直饮水(Long)、浴室要温水(LocalDate)、洗衣机要软水(你自己的值对象 OrderNo)。ConversionService 就是那台净水器——它按「龙头型号」(目标类型)挑对应的滤芯(Converter / Formatter)。你既可以用厂家预设的滤芯(内置转换器),也可以自己拧一个(注册 Converter<String, OrderNo>);而 @DateTimeFormat 相当于在龙头上贴一张「本机出水温度 45℃」的便签,只对这个龙头生效。滤芯挑错了不报错才可怕:它会把水原样递给你,于是你的 LocalDate from 收到 null。
学完这一篇你要能回答:
- 「
?page=1里的 1 和/users/42里的 42,走的到底是同一条路吗?」(不是:查询串 vs 路径占位符,两个解析器) - 「为什么同一个字符串,有时变成
Long、有时变成 400?」(差别在有没有格式声明与转换器) - 「为什么 GET 接口不该用
@RequestBody?」(语义上查询不该带身体,工程上会白丢浏览器直接访问的能力)
第二节说过「解析器按顺序尝试」,这句话值得亲手验证一遍。整条链的形状是这样:

关键在最后一格:没有解析器接手时抛的是 IllegalStateException(500),而不是 400。也就是说「我不知道该去哪给你取值」和「取到了但值不对」是两种完全不同的故障,前者是你的方法签名写错了,后者是调用方传错了。下面这个演示把这五种情况做成了可切换的现场:
责任链的形状看懂了,还得知道它在代码里长什么样。左边这七行就是 getMethodArgumentValues 的骨架,右边同步刷新变量与调用栈——连点下一步,盯住 args[i] 和那个 UNRESOLVED 哨兵值:
MethodParameter p = parameters.getParameter(i); // ① 取第 i 个参数if (p.getParameterAnnotation(PathVariable.class) != null) { ... } // ② 注解只是路标for (HandlerMethodArgumentResolver r : resolvers) { // ③ 挨个问 if (r.supportsParameter(p)) { // ④ 第一个点头的赢 args[i] = r.resolveArgument(p, mav, webRequest, null); // ⑤ 取值 + 转型 break; // ⑥ 后面的人不再被问 }}if (args[i] == UNRESOLVED) throw new IllegalStateException(...); // ⑦ 没人接手| i | 0 |
| 参数名 | id |
| 声明类型 | Long |
| 参数注解 | [@PathVariable] |
InvocableHandlerMethod.getMethodArgumentValues再把视角拉远一格:参数装配完整条链跑通之后,请求是怎么被送到这个方法的?这正是 #22 讲的 doDispatch。同一个方法在这两个尺度下看到的是同一件事,切到 /nope 你会看到「压根没走到参数解析这一步」:
最后是「取到值之后」的那段路——类型转换。"2024-05-01" 变成 LocalDate 不是魔法,它有一条固定的查找顺序:先看内置转换器,再看字段上的格式注解,最后才轮到你自己注册的 Converter。

当这条链失败时,异常解析器会接管并把结果翻译成状态码。切到「类型转换失败」那一格,你会看到第三节的 ConversionService 报错最终落在 400 上——这就是为什么「同样的输入错误,报的文字不同」:
反过来还有一段同样的路:你 return 出去的对象要怎么变回字符串。这条路由「内容协商 + 消息转换器」负责,四个场景刚好覆盖第四节那个 String 二义性——切到 string 那一格,你会亲眼看到同一个 "success" 因为有没有 @ResponseBody 而分成两种命运;切到 fail 那一格,就是第十一节速查表里 No converter for ... with preset Content-Type 那一行的现场:
实验点完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部真算——先 boot,再把四种参数来源逐条敲出来,最后两条是「死法」演示:
lab argres none 和 lab err convert 请连着敲。前者是「没人认领参数」(500),后者是「认领了但转不过去」(400)——同一屏里对照一次,这两种故障这辈子不会再混。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'(包装类为 MethodArgumentTypeMismatchException) | 值是字符串且转不成目标类型:/users/abc 配 Long id,或日期没写格式 | 先确认是调用方传错还是签名太宽;路径变量加正则 {id:\\d+} 让它 404 而不是 400 | 本篇第二、三节 |
Required request parameter 'keyword' for method parameter type String is not present(MissingServletRequestParameterException) | @RequestParam 默认 required = true,而请求里真没这个键 | 给 defaultValue(它会顺手把 required 关掉),或显式 required = false | 本篇 2.1 |
Could not resolve parameter [0] in public ... No suitable resolver found(IllegalStateException) | 参数既无支持的注解也不是内置类型——通常是注解拼错(@PathParam 是 JAX-RS 的) | 逐字检查注解包名是不是 org.springframework.web.bind.annotation.* | 本篇第十节 |
Required request body is missing(HttpMessageNotReadableException) | 声明了 @RequestBody 却没发体,最常见是 GET 请求硬套了 JSON 语义 | GET 改用 @ModelAttribute;Postman 记得选 raw + JSON | 本篇第六节 |
Content-type 'application/x-www-form-urlencoded' not supported(HttpMediaTypeNotSupportedException,415) | @RequestBody 只认转换器支持的类型,表单编码不在 Jackson 的能力范围 | 前端补 -H "Content-Type: application/json";真要收表单就别用 @RequestBody | 本篇第六节 |
JSON parse error: Unexpected character ('}' ...) | 请求体不是合法 JSON:多逗号、单引号、被截断 | 把 body 原文贴进任意 JSON 校验器 | #24 Jackson 细节 |
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable | 字段名对不上,Jackson 默认拒绝多余字段 | 对齐命名或用 @JsonProperty;全局放宽见下一行 | #24 |
传了 user_name 却拿到 null,不报错 | 名称策略不一致:Spring MVC 绑定用 setter 名,Jackson 用字段/PropertyNamingStrategy | 两套规则不要混用;统一 camelCase 最省心 | 本篇 2.4 |
No converter for [class java.util.LinkedHashMap] with preset Content-Type 'text/html' | 返回值类型与你手工设定的 Content-Type 没有匹配的转换器 | 去掉手工 header,让内容协商自己选;或返回 ResponseEntity 明确指定 | #24 |
加了 spring-boot-starter-validation 但 @Valid 毫无反应 | DTO 上只有 @Valid,字段上没有 @NotNull 之类的约束注解 | 约束写在字段上,@Valid 只是开关 | #25 |
BindingException 或表单体读不到 | @ModelAttribute 的对象没有 getter/setter,属性名对不上 | 补全 setter,或改用 record / Lombok @Data | 本篇 2.4 |
| 接口偶发返回 HTML 登录页而不是 JSON | 静态资源或重定向被拦截器接管,返回值走了视图解析 | 拦截器排除 /error 与静态路径,API 前缀单独放行 | #26 |
表格里那句 Could not resolve parameter [0] 是唯一一个「500 却是你的方法写错」的一行。下面就是它的真实现场,先别看解析——点出你认为的凶手行:
新写的 PUT 接口本地一调就 500。你确认 DTO 有 getter、有 setter,前端也真的发了 JSON——唯一没检查的是参数上那个注解从哪个包 import 进来的。
想让 Jackson 宽容地忽略未知字段,配置项是 spring.jackson.deserialization.fail-on-unknown-properties=false(Spring Boot 默认已经是 false,但很多老项目显式打开过)。放宽有代价:前端字段名写错时接口不再报错,而是静默给你一个 null——排查线上问题时这最难查。契约严格的对外 API 建议保持严格,内部系统再考虑放宽。
同一个方法签名,@RequestParam 的三个属性组合不同,客户端拿到的结果天差地别。选一组,输出就是这个接口的真实行为。
200 OKpage = 1#查询串 ?page=1 命中,required=true 也过得去
目标:一个接口同时用到四种入参来源,逐一验证它们各自从哪里取值。
package com.example.lab.controller;import jakarta.validation.constraints.Email;import jakarta.validation.constraints.NotBlank;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.Map;@RestController@RequestMapping("/api/orders")public class OrderLabController { record OrderVO(Long id, String username, LocalDate from, String token, String keyword) {} @PostMapping("/{id}/search") public ResponseEntity<OrderVO> handle( @PathVariable Long id, // 门牌号 @RequestParam(required = false, defaultValue = "1") int page, // 附加栏 @RequestHeader("X-Auth-Token") String token, // 面单条款 @RequestBody SearchForm form) { // 包裹本体 return ResponseEntity.ok(new OrderVO(id, form.keyword(), form.from(), token, form.keyword())); } record SearchForm(@NotBlank String keyword, LocalDate from) {} // from 必须是 yyyy-MM-dd // 表单式提交(没有请求体 JSON):@ModelAttribute 路线 @GetMapping public ResponseEntity<Map<String, Object>> list(@ModelAttribute PageQuery q) { return ResponseEntity.ok(Map.of("page", q.page, "size", q.size, "from", String.valueOf(q.from))); } static class PageQuery { private int page = 1; private int size = 20; @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) private LocalDate from; 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 LocalDate getFrom() { return from; } public void setFrom(LocalDate from) { this.from = from; } }}四条 curl,覆盖四种来源与两种失败:
# 1) 四种来源齐全 → 200curl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" \ -H "X-Auth-Token: tk-abc" \ -d '{"keyword":"phone","from":"2024-05-01"}'# 2) 漏掉自定义请求头 → 400 MissingRequestHeaderExceptioncurl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" -d '{"keyword":"phone"}'# 3) 漏掉 Content-Type → 415(后端根本没机会看你的 JSON)curl -i -X POST http://localhost:8080/api/orders/7/search -d '{"keyword":"phone"}'# 4) 日期格式不对 → 400 Failed to convert value of type 'java.lang.String'curl -i -X POST http://localhost:8080/api/orders/7/search \ -H "Content-Type: application/json" -H "X-Auth-Token: tk-abc" \ -d '{"keyword":"phone","from":"01/05/2024"}'# 5) 查询串绑对象 → 200,未传的字段保留字段上的默认值curl -i "http://localhost:8080/api/orders?page=3&from=2024-05-01"第 1 条预期响应:
{ "id": 7, "username": "phone", "from": "2024-05-01", "token": "tk-abc", "keyword": "phone"}第 5 条预期响应(size 用了字段默认值 20):
{ "page": 3, "size": 20, "from": "2024-05-01"}每个改动都只需一两行,重点是观察报错原文换了一句:
- 把
@PathVariable Long id改成@RequestParam Long id,重跑第 1 条 → 你观察到 400,且异常换成Required request parameter 'id' ... is not present:第十二节第一题的坑成真了 - 把
@RequestBody SearchForm form删掉,改用@ModelAttribute SearchForm form,并用curl -X POST .../search?keyword=phone&from=2024-05-01发送 → 你观察到 200:同一份数据可以走查询串进来,前提是你换了取件单 - 把
record SearchForm(@NotBlank String keyword, ...)的@NotBlank前面加上@Valid(方法参数写成@Valid @RequestBody SearchForm form),然后发{"keyword":""}→ 你观察到 400 +MethodArgumentNotValidException,且响应里能看到字段名keyword。少了@Valid时同样的请求会顺利 200——这就是「开关」与「约束」的分工 - 把
from字段类型从LocalDate改成String,重跑第 4 条 → 你观察到 200:错误没消失,只是被你推迟到业务代码里了。这一点在第十一节的速查表里对应「让框架早失败」的原则
做一个「筛选条件接收器」:把一个十几个筛选字段的列表接口写得干净、可读、可校验、可扩展。
要求:
- 一个
OrderQuery对象:包含status(枚举)、from/to(LocalDate,带@DateTimeFormat)、minAmount(BigDecimal,带@NumberFormat(pattern = "#,###.##"))、keyword、page、size - 一个自定义
Converter<String, OrderStatus>,让前端可以传小写的active也能绑成ACTIVE - 一个
@RestControllerAdvice,把MethodArgumentTypeMismatchException与MissingServletRequestParameterException分别映射成带code的统一 400 结构 - 一个
@ModelAttribute版本与一个@RequestBody版本的同一筛选条件(POST 搜索场景),证明两种取件单都能收到同样的值
验收清单:
- [ ]
?status=active&page=2&minAmount=12,345.67全部正确绑定(打印出来核对) - [ ]
?from=2024-13-01返回 400,且 body 里能看出是哪个字段坏了(不是裸的 Whitelabel 页) - [ ]
?size=(空值)不会炸,并说明它为什么不算「缺失」——对照第十三节沙盘的empty|plain格 - [ ] GET 版本不发请求体也能工作;把
@ModelAttribute换成@RequestBody后,同一个 GET 请求立刻失败,你能说出失败的状态码和异常全名 - [ ] 新增一个筛选项时,只改了
OrderQuery一处,方法签名一行没动
给你一个新需求「读取 PUT /coupons/{code}?force=true 的两个值」,你能立刻说出两个注解名字和各自的解析器类名吗?(@PathVariable → PathVariableMethodArgumentResolver,@RequestParam → RequestParamMethodArgumentResolver)
defaultValue 除了给默认值,还悄悄改变了什么行为?(把 required 变成 false——这是最容易忽略的连带效果)
参数「没人认领」和参数「值不对」,状态码分别是多少?为什么不同?(500 IllegalStateException: Could not resolve parameter 是签名写错;400 是调用方传错,两者发生在链上的不同站)
为什么 @RequestBody 之后 @RequestParam 常拿不到表单值?(getInputStream() 是一次性字节流,读完到底;需要重复读得包 ContentCachingRequestWrapper)
注解管「从哪儿拿」,类型管「怎么变」,@Valid 只管「拿完查一查」;URL 上看名字,报文里看结构。
控制器方法的每一处签名都在向框架「表态」——用哪个注解,就决定了数据从哪进;写什么类型,就决定了怎么转;返回什么,就决定了怎么写出。把参数来源表和返回值表记牢,再顺手踩过 415 / 400 / 只能读一次这三个坑,@RequestMapping 系列就再没有你不会写的方法了。