控制器细节全解:参数绑定、返回值与类型转换

bee2026-10-0867 分钟0 次阅读
一个 @GetMapping 方法上到底能写多少种参数?数据从哪来、怎么转换、怎么校验?把控制器参数与返回值的全部玩法连同坑一起讲完。
1 / 129
小节
一、参数来源全景:方法签名就是一份输入清单
2 / 129

写控制器最容易犯的错,不是语法写错,而是不知道数据还能从哪儿来。一个 @GetMapping 方法上可写的参数类型多到超出直觉:查询串、路径占位符、请求体、请求头、Cookie、Session、原生输入输出流……它们各自由不同的 HandlerMethodArgumentResolver 负责装配。先用一张全景表把「可能性」列清楚,再逐个抠细节,比零散背 API 高效得多。

3 / 129
对照表
注解 / 类型数据来源典型适用方式示例
@RequestParamURL 查询串 ?page=1,或表单体任意@RequestParam("page") int page
@PathVariableURL 路径中的占位符任意@PathVariable Long id
@RequestBody请求体(JSON / XML)POST / PUT / PATCH@RequestBody UserDTO dto
@RequestHeaderHTTP 请求头任意@RequestHeader("Authorization") String token
@CookieValueCookie 中的某个键任意@CookieValue("SESSION") String sid
@RequestAttribute过滤器里 request.setAttribute 写入的值任意@RequestAttribute("userId") Long uid
@ModelAttribute查询串 / 表单 → 绑定到对象GET / POST@ModelAttribute UserQuery q
@SessionAttributeHttpSession 中的属性任意@SessionAttribute("user") User u
HttpServletRequest / HttpServletResponse原生 Servlet 对象任意HttpServletRequest req
InputStream / OutputStream原始请求 / 响应字节流任意InputStream in
Principal已认证的主体信息任意Principal principal
@Valid不取值,触发参数校验任意@Valid @RequestBody UserDTO dto
4 / 129
架构图
图 1 · 控制器的输入与输出
图 1 · 控制器的输入与输出
5 / 129
说明

这张表里真正「取数据」的只有前八行,HttpServletRequest 之类的原生对象是「逃生舱口」——能不用就不用,用了就意味着你放弃了框架帮你做的转换与校验。@Valid 更是例外,它不产生参数值,只是给已经绑好的参数挂上一个校验开关。

6 / 129

这张表能兑现多少,第一处真相不在注解里,在 pom.xml 里。自己勾一遍就懂:只勾 web,@RequestBody 当场能用,而 @Valid 会像没写一样——校验从来不是 MVC 自带的;@ModelAttribute 那个对象能不能绑上,取决于它有没有 getter/setter,这正是 lombok 那一段的意义:

7 / 129
生成器
生成器本篇能力的开关:四行依赖决定你能用什么pom.xml2 / 4
先只勾 web 生成一次,对照第二节:@RequestBody 能用、@Valid 无效;再补 validation,第七节那个「签名清爽的查询对象」才挂得上 @NotNull;最后勾 lombok 或 test,回头看 2.4 那条 BindingException 为什么会出现
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Validation给了 @Valid 才有人干活;漏掉它,@NotNull 会安静地什么也不校验。
8 / 129
小节
二、绑定细节逐个讲
9 / 129
小节
2.1 @RequestParam 的三个属性
10 / 129
代码对照
代码java
@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 = "...":同时隐式关闭了必填,这是最容易忽略的连带效果
11 / 129
小节
2.2 @PathVariable 的多变量与正则约束
12 / 129
java
@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"));}
13 / 129

路径里写 {orderId:\\d+} 就是给这段占位符加正则约束:只有纯数字才能匹配成功。/orders/abc 会因为不满足 \d+ 而落不到这个映射上,直接 404——比进到方法里再解析成 Long 失败要干净得多。

14 / 129
小节
2.3 @RequestBody 与 JSON 反序列化
15 / 129
java
@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) {}
16 / 129

@RequestBody 底层依赖 HttpMessageConverter(JSON 场景是 MappingJackson2HttpMessageConverter)。它会先看 Content-Type:不是 application/json 就直接 415;是 JSON 但结构对不上(字段类型错、缺少必需项)则抛 HttpMessageNotReadableException,落到全局异常处理里通常映射成 400。

17 / 129
小节
2.4 @ModelAttribute 表单绑定
18 / 129
java
@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 省略}
19 / 129

@ModelAttribute 会把 GET 查询串(或表单体)按属性名逐个写进对象,未出现的字段保留默认值。它让「一堆查询参数」看起来就像一个普通方法参数,是条件筛选接口的首选写法。

20 / 129
小节
2.5 日期与枚举的转换
21 / 129
java
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;}
22 / 129

日期是最典型的「类型不匹配现场」:不给格式说明,Spring 不知道 2024-05-01 该怎么解析,直接 400。枚举反而省心——默认按 Enum.valueOf 匹配名字,写成不存在的大小写会失败。

23 / 129
小节
三、类型转换内幕:ConversionService
24 / 129

上面所有「String → 目标类型」的动作,都归一个组件管:ConversionService。它是 Spring 的通用类型转换门面,Converter 和 Formatter 都注册在它名下。

25 / 129
原理动画
动图 · 一个参数是怎么被装配出来的
动图 · 一个参数是怎么被装配出来的
26 / 129
小节
3.1 @DateTimeFormat / @NumberFormat
27 / 129

这两个是参数级的转换声明,只对当前字段生效,不需要全局注册:

28 / 129
java
public class InvoiceQuery {    @DateTimeFormat(pattern = "yyyyMMdd")    private LocalDate billDate;           // 20240501 → LocalDate    @NumberFormat(pattern = "#,###.##")    private BigDecimal amount;            // "12,345.67" → BigDecimal}
29 / 129
小节
3.2 自定义 Converter:String → 值对象
30 / 129

当一个请求参数不是原始类型,而是你自己定义的值对象(比如订单号 OrderNo),就需要自定义转换器:

31 / 129
代码对照
代码java
@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,做国际化更顺手。

32 / 129
小节
3.3 Formatter:本地化感知的转换
33 / 129
java
@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() + "%";    }}
34 / 129

Converter 与 Formatter 都通过实现 WebMvcConfigurer#addFormatters 注册(标注 @Component 也会被自动收集):

35 / 129
java
@Configurationpublic class WebConvertConfig implements WebMvcConfigurer {    @Override    public void addFormatters(FormatterRegistry registry) {        registry.addConverter(new OrderNoConverter());        registry.addFormatter(new PercentFormatter());    }}
36 / 129
小节
四、返回值全解析
37 / 129

参数讲完,看另一头。控制器方法返回什么,同样决定 Spring 怎么处理它:

38 / 129
对照表
返回值处理器行为
普通对象 + @ResponseBody(或 @RestController)RequestResponseBodyMethodProcessor经 HttpMessageConverter 序列化成 JSON 写回
ResponseEntity<T>HttpEntityMethodProcessor完全掌控状态码、Header 与响应体
voidServletInvocableHandlerMethod认为你已自行写出响应;在有 @ResponseBody 时也可能返回空体
String取决于有没有视图解析无视图解析器 = 纯文本;有模板引擎 = 视图名
ModelAndViewViewNameMethodReturnValueHandler携带视图名与模型,用于服务端渲染
StreamingResponseBodyStreamingResponseBodyReturnValueHandler在独立线程里分块写出,适合大文件下载
39 / 129
坑

String 的语义是二义的。在 @RestController(等价于类上带 @ResponseBody)里,返回 "success" 就是文本 success;但在普通的 @Controller 里,同样返回 "success" 会被当成视图名,去渲染名为 success 的模板——找不到模板就是 404。搞不清这一点,是「为什么我的接口返回了一个 HTML 页面」的头号原因。

40 / 129

上面那张表的正确读法不是背下「哪个类型配哪个处理器」,而是看懂表决顺序:处理器表只问一个问题,而这个问题跟返回类型无关。点着走一遍:

41 / 129
交互图解
流程返回值的两条出口:点着看谁接手1 / 5
从 ① 点到 ⑤,关键在第 ③ 格——判据是注解,不是类型。想不通「为什么同样的 return 一个成了 JSON 一个成了页面」,就在这一格找答案
→
→
→
→
① 方法返回,类型已知
返回值此刻只是一个 Object 引用加它的声明类型。类型很重要,但它重要在「第 ⑤ 步怎么写出去」,而不是「走哪条出口」。这句话记住,第四节那张表就不用背了。
全部看懂了注解决定含义,类型只决定怎么写出去——这一句能解释第四节一半的坑。
42 / 129

这个分岔有真实现场可以点。同一个返回语句,只把类上的注解换掉,出口就换一个:

43 / 129
内核实验
TeaVM@Controller 还是 @RestController:一句 return 的四种结局未启动
先点 view 看字符串怎么被当视图名去找模板,再点 json 对照;missing 那一格是白标 404,string 那一格就是上面那条坑的完整解剖
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
44 / 129

再把六个出口画成一张动图,第 ③ ④ 两帧是同一个方法体的两种命运:

45 / 129
原理动画
动图 · 一个返回值的两条出口
动图 · 一个返回值的两条出口
46 / 129
小节
五、ResponseEntity 实战
47 / 129

想精确控制状态码和响应头,就用 ResponseEntity:

48 / 129
java
@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);    }}
49 / 129

ResponseEntity 的链式 API 读起来像句子:.created(uri) = 201、.noContent() = 204、.ok() = 200。凡是「状态码不是 200」或「需要额外的响应头」的场景,都应该用 ResponseEntity,它比在方法里手工 setStatus 更清晰。

50 / 129
小节
六、REST 下的 Content-Type 与请求体:415 与 400 从哪来
51 / 129

这两个错误几乎每个后端都遇到过,根因却完全不同:

52 / 129
对照表
现象HTTP 状态触发条件第一反应
HttpMediaTypeNotSupportedException415Content-Type 不是 application/json(或声明的 consumes 不匹配)检查前端请求头
HttpMediaTypeNotAcceptableException406客户端 Accept 与能产出的类型都不匹配检查 produces
HttpMessageNotReadableException400请求体结构非法:缺字段、类型错、JSON 语法错看请求体原文
MissingServletRequestParameterException400必填的 @RequestParam 没传对齐前后端参数名
MethodArgumentTypeMismatchException400传的字符串无法转成目标类型检查参数格式
53 / 129
注意

测试脚本里把请求体写成 {"name":"a"} 却漏了 Content-Type: application/json,服务端一律 415——它根本没机会去解析那个 JSON。这不是接口的 bug,是调用方少了一个头。

54 / 129
小节
七、两套常用组合模板
55 / 129
小节
7.1 分页查询:@RequestParam 逐个接
56 / 129
java
@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);}
57 / 129

参数少、语义独立、每个都要单独给默认值时,用 @RequestParam 逐个接最直观。

58 / 129
小节
7.2 条件筛选:@ModelAttribute 对象接参
59 / 129
java
@GetMapping("/users/search")public PageResult<UserVO> search(@ModelAttribute UserQuery query) {    // query 里可以塞十几个筛选字段,方法签名依然干净    return userService.search(query);}
60 / 129

筛选字段一旦超过五六个,逐个 @RequestParam 会让签名爆炸,还会到处重复 required = false。这时把字段收进一个对象、用 @ModelAttribute 接,签名和校验都更好维护。

61 / 129
小节
八、三个高频坑
62 / 129
  • @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
63 / 129
内核实验
TeaVM看参数解析器如何工作未启动
用 /users/42 观察 @PathVariable 的字符串到 Long 转换过程
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
64 / 129

上面三个坑都写在代码里,还有第四个坑不写在代码里:application/x-www-form-urlencoded 的 POST 体积上限。一旦超过它,Tomcat 索性不解析表单参数——你的 @RequestParam 就像前端什么都没传一样报 400。把这个数字拖一遍,四种形态一次看全:

65 / 129
参数调节台
调节台表单 POST 体积上限:超了不是报错,是参数凭空消失
从 0 拖到 20MB,注意第 1MB 那一档的报错文字——它会把你骗到前端那边去
server.tomcat.max-http-form-post-size
2097152字节当前 0 – 20971520
2MB:Boot 的默认值
  • 登录、筛选、下单这类表单基本都落在它之内
  • 默认值不是保守值,它按「表单不是文件载体」这个前提定的
  • 没人往表单里塞大段文本,就别去动它
参数丢失概率5%
堆内存风险10%
表单参数是键值对,不是集装箱:正文大就换 multipart,内容大就走对象存储,别拿提高上限当解决方案。
66 / 129
决策
决策新写一个支持十来个筛选字段的列表查询接口,参数用 `@RequestParam` 逐个接还是用 `@ModelAttribute` 对象接?
67 / 129
小节
九、30 秒看懂:控制器方法就是一只邮局的窗口
68 / 129

前面八节把「能怎么写」讲完了,这一节退一步讲「为什么这么设计」——因为记住规则不如记住一个画面。

69 / 129
类比

把控制器方法想成邮局柜台的玻璃窗。窗上贴着四张取件单,每张写明「从哪儿拿东西」:@RequestParam 是信封右上角的附加栏(收件人自己填的内容,来自查询串 ?page=1);@PathVariable 是门牌号(写死在地址里的一段路名,来自 /users/42);@RequestBody 是包裹本身(整个箱子里的东西,来自请求体);@RequestHeader 是快递面单背面的运输条款(跟内容无关但必须有的元数据,来自请求头)。柜台后面的分拣员就是参数解析器链:它不看你的业务,只看这张单子写着哪儿,就去哪儿翻。

70 / 129

而「玻璃窗的另一侧」还有第二条规矩:你递出来的东西长什么形状,决定它被怎么打包。返回对象 = 打成 JSON 包裹寄走;返回 String = 当成「去哪个房间取件」的视图名;这两条岔路就是第四节那个坑的全部来历。

71 / 129
架构图
图 2 · 四个入参注解怎么选
图 2 · 四个入参注解怎么选
72 / 129
类比

类型转换(ConversionService)像家里的净水器接口。自来水(HTTP 世界的一切)永远是「字符串」这一种水质,而你家的每个龙头(方法参数)要的水不一样:厨房要直饮水(Long)、浴室要温水(LocalDate)、洗衣机要软水(你自己的值对象 OrderNo)。ConversionService 就是那台净水器——它按「龙头型号」(目标类型)挑对应的滤芯(Converter / Formatter)。你既可以用厂家预设的滤芯(内置转换器),也可以自己拧一个(注册 Converter<String, OrderNo>);而 @DateTimeFormat 相当于在龙头上贴一张「本机出水温度 45℃」的便签,只对这个龙头生效。滤芯挑错了不报错才可怕:它会把水原样递给你,于是你的 LocalDate from 收到 null。

73 / 129

学完这一篇你要能回答:

74 / 129
  • 「?page=1 里的 1 和 /users/42 里的 42,走的到底是同一条路吗?」(不是:查询串 vs 路径占位符,两个解析器)
  • 「为什么同一个字符串,有时变成 Long、有时变成 400?」(差别在有没有格式声明与转换器)
  • 「为什么 GET 接口不该用 @RequestBody?」(语义上查询不该带身体,工程上会白丢浏览器直接访问的能力)
75 / 129
小节
十、上手实验:责任链的五步与三种死法
76 / 129

第二节说过「解析器按顺序尝试」,这句话值得亲手验证一遍。整条链的形状是这样:

77 / 129
架构图
图 3 · 参数解析器责任链怎么选
图 3 · 参数解析器责任链怎么选
78 / 129

关键在最后一格:没有解析器接手时抛的是 IllegalStateException(500),而不是 400。也就是说「我不知道该去哪给你取值」和「取到了但值不对」是两种完全不同的故障,前者是你的方法签名写错了,后者是调用方传错了。下面这个演示把这五种情况做成了可切换的现场:

79 / 129
内核实验
TeaVM参数解析器责任链现场未启动
依次切 pathvar / reqparam / body / special,最后点 none 看「没人接手」的 500
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 129

责任链的形状看懂了,还得知道它在代码里长什么样。左边这七行就是 getMethodArgumentValues 的骨架,右边同步刷新变量与调用栈——连点下一步,盯住 args[i] 和那个 UNRESOLVED 哨兵值:

81 / 129
单步调试台
单步台单步台:一个参数是怎么被装进 args 的1 / 7
点满七步。第 ④ 步的 break 与第 ⑦ 步的哨兵值,是这一节所有故障的分水岭
被调试的代码
1MethodParameter p = parameters.getParameter(i); // ① 取第 i 个参数
2if (p.getParameterAnnotation(PathVariable.class) != null) { ... } // ② 注解只是路标
3for (HandlerMethodArgumentResolver r : resolvers) { // ③ 挨个问
4 if (r.supportsParameter(p)) { // ④ 第一个点头的赢
5 args[i] = r.resolveArgument(p, mav, webRequest, null); // ⑤ 取值 + 转型
6 break; // ⑥ 后面的人不再被问
7 }
8}
9if (args[i] == UNRESOLVED) throw new IllegalStateException(...); // ⑦ 没人接手
此刻的变量
i0
参数名id
声明类型Long
参数注解[@PathVariable]
调用栈
1InvocableHandlerMethod.getMethodArgumentValues
1MethodParameter 是「签名上的一个位置」,不是值。此刻它身上只有两样信息:类型 Long 和注解列表。装配工作从这里才开始。
82 / 129

再把视角拉远一格:参数装配完整条链跑通之后,请求是怎么被送到这个方法的?这正是 #22 讲的 doDispatch。同一个方法在这两个尺度下看到的是同一件事,切到 /nope 你会看到「压根没走到参数解析这一步」:

83 / 129
内核实验
TeaVM从 URL 到方法:整条分派链路未启动
对比 /users/42 与 /users,再打 /nope 看在哪一站就断了
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
84 / 129

最后是「取到值之后」的那段路——类型转换。"2024-05-01" 变成 LocalDate 不是魔法,它有一条固定的查找顺序:先看内置转换器,再看字段上的格式注解,最后才轮到你自己注册的 Converter。

85 / 129
原理动画
动图 · 一个字符串是怎么变成对象的
动图 · 一个字符串是怎么变成对象的
86 / 129

当这条链失败时,异常解析器会接管并把结果翻译成状态码。切到「类型转换失败」那一格,你会看到第三节的 ConversionService 报错最终落在 400 上——这就是为什么「同样的输入错误,报的文字不同」:

87 / 129
内核实验
TeaVM异常解析器链:转换失败怎么变成 400未启动
依次试 convert / valid / handler / status / none,看清每一类异常由谁接住
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
88 / 129

反过来还有一段同样的路:你 return 出去的对象要怎么变回字符串。这条路由「内容协商 + 消息转换器」负责,四个场景刚好覆盖第四节那个 String 二义性——切到 string 那一格,你会亲眼看到同一个 "success" 因为有没有 @ResponseBody 而分成两种命运;切到 fail 那一格,就是第十一节速查表里 No converter for ... with preset Content-Type 那一行的现场:

89 / 129
内核实验
TeaVM内容协商与消息转换:返回值怎么变回文本未启动
依次看 json / accept / string / fail,重点对比 string 那格里视图名与响应体的分岔
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 129

实验点完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,回显全部真算——先 boot,再把四种参数来源逐条敲出来,最后两条是「死法」演示:

91 / 129
内核控制台
92 / 129
说明

lab argres none 和 lab err convert 请连着敲。前者是「没人认领参数」(500),后者是「认领了但转不过去」(400)——同一屏里对照一次,这两种故障这辈子不会再混。

93 / 129
小节
十一、常见报错速查
94 / 129
对照表
报错原文(片段)真实原因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
95 / 129

表格里那句 Could not resolve parameter [0] 是唯一一个「500 却是你的方法写错」的一行。下面就是它的真实现场,先别看解析——点出你认为的凶手行:

96 / 129
报错急救
报错急救IllegalStateException: Could not resolve parameter [0] in public org.springframework.http.ResponseEntity com.example.user.UserController.update(UserUpdateDTO)
注解拼错了半个包名,接口 500

新写的 PUT 接口本地一调就 500。你确认 DTO 有 getter、有 setter,前端也真的发了 JSON——唯一没检查的是参数上那个注解从哪个包 import 进来的。

java.lang.IllegalStateException: Could not resolve parameter [0] in public org.springframework.http.ResponseEntity com.example.user.UserController.update(com.example.user.UserUpdateDTO): No suitable resolver
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:175)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:143)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1046)
at com.example.user.UserController.update(UserController.java:31)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
97 / 129
坑

想让 Jackson 宽容地忽略未知字段,配置项是 spring.jackson.deserialization.fail-on-unknown-properties=false(Spring Boot 默认已经是 false,但很多老项目显式打开过)。放宽有代价:前端字段名写错时接口不再报错,而是静默给你一个 null——排查线上问题时这最难查。契约严格的对外 API 建议保持严格,内部系统再考虑放宽。

98 / 129
小节
十二、随堂自测
99 / 129
随堂自测
随堂自测一个 `@GetMapping("/orders/{no}")` 方法,参数写成 `@RequestParam String no`。请求 `GET /orders/A12` 会发生什么?
先自己选一个,选中立刻告诉你对不对
100 / 129
随堂自测
随堂自测`@RequestBody Map<String, Object> body` 的方法,前端用了 Postman 的 `form-data` 发同一个 JSON 内容。结果是?
先自己选一个,选中立刻告诉你对不对
101 / 129
小节
十三、沙盘:改一个注解属性,立刻看响应变成什么
102 / 129

同一个方法签名,@RequestParam 的三个属性组合不同,客户端拿到的结果天差地别。选一组,输出就是这个接口的真实行为。

103 / 129
沙盘
沙盘@RequestParam 的属性组合沙盘
运行结果
200 OK
page = 1
#查询串 ?page=1 命中,required=true 也过得去
最理想的对齐:前端确实传了,后端也确实要
104 / 129
小节
十四、动手练习
105 / 129
小节
第一档 · 照做
106 / 129

目标:一个接口同时用到四种入参来源,逐一验证它们各自从哪里取值。

107 / 129
java
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; }    }}
108 / 129

四条 curl,覆盖四种来源与两种失败:

109 / 129
bash
# 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"
110 / 129

第 1 条预期响应:

111 / 129
json
{  "id": 7,  "username": "phone",  "from": "2024-05-01",  "token": "tk-abc",  "keyword": "phone"}
112 / 129

第 5 条预期响应(size 用了字段默认值 20):

113 / 129
json
{  "page": 3,  "size": 20,  "from": "2024-05-01"}
114 / 129
小节
第二档 · 变体
115 / 129

每个改动都只需一两行,重点是观察报错原文换了一句:

116 / 129
  1. 把 @PathVariable Long id 改成 @RequestParam Long id,重跑第 1 条 → 你观察到 400,且异常换成 Required request parameter 'id' ... is not present:第十二节第一题的坑成真了
  2. 把 @RequestBody SearchForm form 删掉,改用 @ModelAttribute SearchForm form,并用 curl -X POST .../search?keyword=phone&from=2024-05-01 发送 → 你观察到 200:同一份数据可以走查询串进来,前提是你换了取件单
  3. 把 record SearchForm(@NotBlank String keyword, ...) 的 @NotBlank 前面加上 @Valid(方法参数写成 @Valid @RequestBody SearchForm form),然后发 {"keyword":""} → 你观察到 400 + MethodArgumentNotValidException,且响应里能看到字段名 keyword。少了 @Valid 时同样的请求会顺利 200——这就是「开关」与「约束」的分工
  4. 把 from 字段类型从 LocalDate 改成 String,重跑第 4 条 → 你观察到 200:错误没消失,只是被你推迟到业务代码里了。这一点在第十一节的速查表里对应「让框架早失败」的原则
117 / 129
小节
第三档 · 造一个
118 / 129

做一个「筛选条件接收器」:把一个十几个筛选字段的列表接口写得干净、可读、可校验、可扩展。

119 / 129

要求:

120 / 129
  • 一个 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 搜索场景),证明两种取件单都能收到同样的值
121 / 129

验收清单:

122 / 129
  • [ ] ?status=active&page=2&minAmount=12,345.67 全部正确绑定(打印出来核对)
  • [ ] ?from=2024-13-01 返回 400,且 body 里能看出是哪个字段坏了(不是裸的 Whitelabel 页)
  • [ ] ?size= (空值)不会炸,并说明它为什么不算「缺失」——对照第十三节沙盘的 empty|plain 格
  • [ ] GET 版本不发请求体也能工作;把 @ModelAttribute 换成 @RequestBody 后,同一个 GET 请求立刻失败,你能说出失败的状态码和异常全名
  • [ ] 新增一个筛选项时,只改了 OrderQuery 一处,方法签名一行没动
123 / 129
小节
十五、要点自查
124 / 129
自检

给你一个新需求「读取 PUT /coupons/{code}?force=true 的两个值」,你能立刻说出两个注解名字和各自的解析器类名吗?(@PathVariable → PathVariableMethodArgumentResolver,@RequestParam → RequestParamMethodArgumentResolver)

125 / 129
自检

defaultValue 除了给默认值,还悄悄改变了什么行为?(把 required 变成 false——这是最容易忽略的连带效果)

126 / 129
自检

参数「没人认领」和参数「值不对」,状态码分别是多少?为什么不同?(500 IllegalStateException: Could not resolve parameter 是签名写错;400 是调用方传错,两者发生在链上的不同站)

127 / 129
自检

为什么 @RequestBody 之后 @RequestParam 常拿不到表单值?(getInputStream() 是一次性字节流,读完到底;需要重复读得包 ContentCachingRequestWrapper)

128 / 129
口诀

注解管「从哪儿拿」,类型管「怎么变」,@Valid 只管「拿完查一查」;URL 上看名字,报文里看结构。

129 / 129
总结

控制器方法的每一处签名都在向框架「表态」——用哪个注解,就决定了数据从哪进;写什么类型,就决定了怎么转;返回什么,就决定了怎么写出。把参数来源表和返回值表记牢,再顺手踩过 415 / 400 / 只能读一次这三个坑,@RequestMapping 系列就再没有你不会写的方法了。