Skip to content

Spring MVC 请求映射与参数绑定 ​

S3-022必学进阶约 90 分钟JDK 21企业开发基础 · Spring 核心与 MVC

1. 本节定位 ​

S3-REST 设计了接口长什么样,本节讲它怎么落地成代码:

text
HTTP 请求  →  DispatcherServlet  →  Controller 的方法调用
POST /api/orders                →  OrderController.create(request)
{"productId":"P-1",...}            ↑  这个 JSON 怎么变成参数的?

这个"怎么变成"的过程就是参数绑定——它是本节的核心,也是最容易踩坑的地方。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 DispatcherServlet 处理一个请求的完整流程;
  • 说出 @RestController 与 @Controller 的区别;
  • 用三种方式绑定参数:路径变量、查询参数、请求体,并知道各自的适用场景;
  • 说出 @RequestParam 的 required 与 defaultValue 的作用;
  • 说出 -parameters 编译标志的作用,以及漏掉它会出现什么报错;
  • 用 ResponseEntity 精确控制状态码与响应头(如 201 Created + Location);
  • 说出 @RequestBody 只能被读取一次的原因。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

Spring MVC 把"处理 HTTP 请求"这件繁琐的事变成了"写一个方法":

java
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
    OrderResponse created = orderService.create(request);
    return ResponseEntity
            .created(URI.create("/api/orders/" + created.orderNo()))
            .body(created);
}

这 7 行代码背后,Spring MVC 做了这些事:

步骤谁做的
从 URL 匹配到 create 方法RequestMappingHandlerMapping
把 JSON 反序列化成 CreateOrderRequestRequestResponseBodyMethodProcessor + Jackson
触发 @Valid 校验MethodValidationPostProcessor(《参数校验与全局异常处理》)
把返回对象序列化成 JSONJackson
设置状态码 201 与 Location 头ResponseEntity

理解这个过程,才能在"参数绑不上"或"返回 406"时知道去哪找原因。

5. 核心概念 ​

5.1 一个请求的完整旅程 ​

text
① 客户端  POST /api/orders  {"productId":"P-1","quantity":2}
        │
        ▼
② Tomcat  接收 TCP 连接,解析成 HttpServletRequest
        │
        ▼
③ DispatcherServlet   前端控制器:所有请求的统一入口
        │
        ├─④ HandlerMapping   按 URL + 方法匹配到 OrderController.create
        │      (本节的主角之一)
        │
        ├─⑤ HandlerAdapter   调用方法前,先解析参数
        │      ├─ PathVariableMethodArgumentResolver   ← 处理 @PathVariable
        │      ├─ RequestParamMethodArgumentResolver   ← 处理 @RequestParam
        │      ├─ RequestResponseBodyMethodProcessor   ← 处理 @RequestBody(含 JSON 反序列化)
        │      └─ ...(十多个解析器,按顺序尝试)
        │
        ├─⑥ 反射调用 create(request)
        │
        ├─⑦ 处理返回值
        │      RequestResponseBodyMethodProcessor 把对象序列化成 JSON
        │      写入响应(状态码、响应头)
        │
        └─⑧ 如果抛异常 → HandlerExceptionResolver → @RestControllerAdvice([S3-023](./S3-023))
        │
        ▼
⑨ 客户端收到  HTTP/1.1 201 Created  Location: /api/orders/ORD-1

三个关键角色:

角色职责什么时候你会直接接触它
DispatcherServlet统一调度("前端控制器")几乎不——它是配置出来的
HandlerMappingURL → 方法排查 404("为什么没匹配上")
HandlerAdapter + 参数解析器请求 → 方法参数排查 400("为什么参数绑不上")

注意第 ⑤ 步的"解析器链":Spring MVC 有十几个参数解析器,按顺序尝试,谁支持这个参数类型就由谁处理。这就是"为什么参数类型写错就报 400"的原因——所有解析器都不支持时,抛 MethodArgumentTypeMismatchException 或类似异常。

5.2 请求映射注解家族 ​

java
@RestController                      // = @Controller + @ResponseBody
@RequestMapping("/api/orders")       // 类级前缀
public class OrderController {

    @PostMapping                     // = @RequestMapping(method = POST)
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }

    @GetMapping("/{orderNo}")        // = @RequestMapping(method = GET, path = "/{orderNo}")
    public OrderResponse find(@PathVariable String orderNo) { }

    @GetMapping
    public PageResponse<OrderResponse> list(@RequestParam(defaultValue = "1") int page) { }

    @DeleteMapping("/{orderNo}")
    public ResponseEntity<Void> delete(@PathVariable String orderNo) { }
}

@RestController 与 @Controller 的区别(高频面试):

注解返回值怎么处理
@Controller返回值是视图名(跳转到页面)
@RestController返回值直接写入响应体(序列化成 JSON)

@RestController = @Controller + @ResponseBody ✓

在前后端分离的项目里:用 @RestController(没有页面跳转)✓

七个组合注解(都是 @RequestMapping 的快捷方式):

注解对应方法
@GetMappingGET
@PostMappingPOST
@PutMappingPUT
@PatchMappingPATCH
@DeleteMappingDELETE
@RequestMapping任意(需显式指定 method)

5.3 三种参数绑定(本节重点) ​

方式一:@PathVariable —— 从 URL 路径取值 ​

java
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { }
text
GET /api/orders/ORD-1     →  orderNo = "ORD-1"
要点说明
适用资源的唯一标识(REST 的典型用法)
必填默认必填(路径里没有就是 404,不会进方法)
多路径变量/{a}/{b} → @PathVariable String a, @PathVariable String b
名字不一致用 @PathVariable("orderNo") String no 显式指定

方式二:@RequestParam —— 从查询参数取值 ​

java
@GetMapping
public PageResponse<OrderResponse> list(
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String status) { }
text
GET /api/orders?page=2&size=20&status=PAID
属性作用
required(默认 true)不带这个参数就报 400
defaultValue不传时用默认值(同时把 required 变为 false)
name / value参数名与形参名不一致时显式指定

一个实用的区分:

用 @PathVariable用 @RequestParam
资源标识(/orders/123)筛选、排序、分页(?status=PAID)
语义是"哪一个"语义是"怎么筛"

方式三:@RequestBody —— 从请求体取(JSON → 对象) ​

java
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }
text
POST /api/orders
Content-Type: application/json

{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}

三个关键点:

  1. 必须有 Content-Type: application/json——没有的话会报 415 Unsupported Media Type;
  2. 反序列化由 HttpMessageConverter 完成(默认是 Jackson);
  3. 请求体只能读一次——@RequestBody 背后是 InputStream,读完了就没了 ✓ 这是"为什么不能在 Controller 里既用 @RequestBody 又自己读 request.getInputStream()"的原因。

一个常见错误:

java
// ✗ 想在 Controller 里再读一遍请求体
public void create(@RequestBody String body, HttpServletRequest request) {
    request.getInputStream();       // 已经是空的(或抛异常)
}

需要读多次时:用 ContentCachingRequestWrapper 包装,或用 @RequestBody 一次性拿到对象 ✓

其他绑定方式(知道即可) ​

注解取值来源
@RequestHeader("X-Trace-Id")请求头
@CookieValue("sessionId")Cookie
@RequestPart("file") MultipartFile file文件上传(multipart/form-data)
@ModelAttribute表单参数绑定到对象(传统表单场景)
HttpServletRequest / HttpServletResponse原生的请求/响应对象(不推荐在业务代码里用)

5.4 -parameters:一个真实踩到的坑 ​

现象(本项目实际遇到的):

text
java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified,
and parameter name information not available via reflection.
Ensure the compiler uses the '-parameters' flag.

触发代码:

java
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { }     // ← 没写 ("orderNo")

根因:

事实说明
Java 字节码默认不保留方法参数名编译后只剩 arg0、arg1
@PathVariable 没写名字时Spring 需要从字节码的调试信息里读参数名
默认情况下读不到于是抛 IllegalArgumentException

两种修复方式:

方式一:加编译标志(推荐)

xml
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <parameters>true</parameters>
  </configuration>
</plugin>

方式二:显式写名字

java
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable("orderNo") String orderNo) { }

一个容易忽略的背景:spring-boot-starter-parent 会自动帮你开启 -parameters。

本项目没有继承它(因为 labs 有自己的父 POM),所以必须手动配——这就是"少继承一个 parent,多踩一个坑"的实例。

顺带一提:加了 -parameters 还有别的好处——@RequestParam、@RequestHeader 等注解都可以在名字一致时省略括号里的名字,代码更简洁 ✓

5.5 返回值与状态码控制 ​

三种返回形式:

java
// ① 直接返回对象:状态码固定 200,由 Jackson 序列化成 JSON
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { ... }

// ② 返回 ResponseEntity:完全控制状态码、响应头、响应体
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
    OrderResponse created = orderService.create(request);
    return ResponseEntity
            .created(URI.create("/api/orders/" + created.orderNo()))    // 201 + Location
            .body(created);
}

// ③ 返回 void / ResponseEntity<Void>:无响应体
@DeleteMapping("/{orderNo}")
public ResponseEntity<Void> delete(@PathVariable String orderNo) {
    orderService.delete(orderNo);
    return ResponseEntity.noContent().build();                          // 204
}

什么时候用哪个:

场景用哪个
查询成功(200 + 数据)直接返回对象(简洁)
创建成功(201 + Location)ResponseEntity(S3-REST 的规范要求)
删除成功(204)ResponseEntity
需要自定义响应头ResponseEntity

ResponseEntity 的常用静态方法:

方法结果
ResponseEntity.ok(body)200 + body
ResponseEntity.created(uri).body(body)201 + Location 头
ResponseEntity.noContent().build()204(无 body)
ResponseEntity.status(HttpStatus.CONFLICT).body(error)自定义状态码

注意:异常场景不要用 ResponseEntity 返回——那是 《参数校验与全局异常处理》 全局异常处理的职责 ✓

5.6 内容协商 ​

java
@GetMapping(value = "/{orderNo}", produces = MediaType.APPLICATION_JSON_VALUE)
public OrderResponse find(@PathVariable String orderNo) { }

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) { }
属性约束什么不满足时
consumes请求的 Content-Type415 Unsupported Media Type
produces响应的 Accept406 Not Acceptable

实践建议:

情况是否写
纯 JSON 接口可以不写(默认就是 JSON,写上是"自我文档化")
同一 URL 返回多种格式(JSON / XML / CSV)必须写(靠它区分)
需要严格约束契约建议写(明确拒绝非 JSON 请求)

一个实用技巧:produces 还可以用来强制返回 UTF-8:

java
@GetMapping(value = "/export", produces = "text/csv;charset=UTF-8")

6. 递进示例 ​

6.1 最小示例:三种绑定各来一个 ​

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    @GetMapping("/{orderNo}")                                         // 路径变量
    public OrderResponse find(@PathVariable String orderNo) { }

    @GetMapping                                                        // 查询参数
    public PageResponse<OrderResponse> list(@RequestParam(defaultValue = "1") int page) { }

    @PostMapping                                                       // 请求体
    public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) { }
}

6.2 业务示例:完整的订单接口 ​

见第 7 节。

6.3 反例:用 @RequestParam 传复杂对象 ​

java
// ✗ 把 JSON 拆成 5 个查询参数
@PostMapping("/search")
public List<OrderResponse> search(@RequestParam String productId,
                                  @RequestParam int minQuantity,
                                  @RequestParam int maxQuantity,
                                  @RequestParam String status,
                                  @RequestParam String address) { }
text
POST /api/orders/search?productId=P-1&minQuantity=1&maxQuantity=10&status=PAID&address=上海

问题:

问题说明
URL 太长、难读参数一多就无法维护
无法表达嵌套结构数组、对象都没法传
中文/特殊字符要编码容易出错
语义不符这是"查询"(复杂条件),但用了 POST + 查询参数

修复:用请求体传复杂条件:

java
@PostMapping("/search")
public List<OrderResponse> search(@RequestBody OrderSearchRequest request) { }

判断标准:参数是"简单筛选"还是"复杂条件"?

用查询参数用请求体
分页、排序、单个状态筛选多字段条件、嵌套结构、数组
能被用户直接改 URL 复现的查询复杂的组合条件

7. 完整可运行代码 ​

文件:labs/spring-boot-web/src/main/java/cn/kb/learn/web/controller/OrderController.java

java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    /** @RequestBody 把 JSON 绑定成对象;@Valid 触发字段校验 */
    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        OrderResponse created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.orderNo()))
                .body(created);
    }

    /** @PathVariable 绑定路径变量 */
    @GetMapping("/{orderNo}")
    public OrderResponse find(@PathVariable String orderNo) {
        return orderService.find(orderNo);
    }

    /** @RequestParam 绑定查询参数,defaultValue 提供默认值 */
    @GetMapping
    public PageResponse<OrderResponse> list(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String status) {
        return orderService.list(page, size, status);
    }

    @DeleteMapping("/{orderNo}")
    public ResponseEntity<Void> delete(@PathVariable String orderNo) {
        orderService.delete(orderNo);
        return ResponseEntity.noContent().build();
    }
}

运行命令(一条命令跑完全部场景):

powershell
cd labs
./mvnw -pl spring-boot-web package -DskipTests
cd spring-boot-web
./demo.ps1

预期输出(九个场景的真实结果):

text
===== 1. 创建订单(期望 201)=====
{"orderNo":"ORD-1","productId":"P-1","quantity":2,"amountInCents":39800,"status":"CREATED","createdAt":"2026-09-21T06:21:40.693441300Z"}
[HTTP 201]

===== 2. 查询 ORD-1(期望 200)=====
{"orderNo":"ORD-1","productId":"P-1","quantity":2,"amountInCents":39800,"status":"CREATED","createdAt":"2026-09-21T06:21:40.693441300Z"}
[HTTP 200]

===== 3. 查询不存在的订单(期望 404)=====
{"code":"RESOURCE_NOT_FOUND","message":"订单不存在:ORD-999","traceId":"e29b9399-...","details":[]}
[HTTP 404]

===== 4. 校验失败:quantity=0、address 为空(期望 422)=====
{"code":"VALIDATION_FAILED","message":"参数校验失败","traceId":"8ccd253a-...",
 "details":[{"field":"quantity","message":"数量必须大于 0"},{"field":"address","message":"收货地址不能为空"}]}
[HTTP 422]

===== 5. 业务冲突:商品已下架(期望 409)=====
{"code":"PRODUCT_UNAVAILABLE","message":"商品已下架","traceId":"b87e56fb-...","details":[]}
[HTTP 409]

===== 6. 分页列表(期望 200)=====
{"data":[{...ORD-4...},{...ORD-3...},{...ORD-2...}],"page":1,"size":5,"total":3,"totalPages":1}
[HTTP 200]

===== 7. 参数类型不匹配:page=abc(期望 400)=====
{"code":"TYPE_MISMATCH","message":"参数类型不正确:page","traceId":"186e6f0a-...","details":[]}
[HTTP 400]

===== 8. 删除订单(期望 204)=====
[HTTP 204]

===== 9. 再次删除同一订单(期望 404)=====
{"code":"RESOURCE_NOT_FOUND","message":"订单不存在:ORD-1","traceId":"a3665dc5-...","details":[]}
[HTTP 404]

九种情况覆盖了什么:

场景覆盖的知识点
1@RequestBody + @Valid + 201 + Location
2@PathVariable
3全局异常处理(404 由异常处理器产出,Controller 里没有 try-catch)
4校验失败 → 422 + 逐字段 details
5业务异常 → 409
6@RequestParam + 默认值 + 分页结构
7类型转换失败 → 400
8204 No Content
9幂等性:重复删除返回 404 而不是 500

注意场景 3、4、5、7、9 的响应格式完全一致(都是 code / message / traceId / details)——这就是"统一错误响应"的价值:客户端只需要写一套错误处理逻辑 ✓ (见 《参数校验与全局异常处理》)

8. Debug 观察点 ​

  1. 在 OrderController.create 的第一行打断点:观察 request 对象已经被 Jackson 反序列化好了——参数解析发生在方法调用之前;
  2. 在 DispatcherServlet.doDispatch 处打断点(或开启 logging.level.org.springframework.web=debug),观察它查找 handler、调用 adapter、处理返回值的全过程;
  3. 观察解析器链:在 RequestResponseBodyMethodProcessor.resolveArgument 处断点,看它如何用 HttpMessageConverter 读 JSON;
  4. 故意不写 Content-Type 发请求(curl -d '{}' 默认是 application/x-www-form-urlencoded)——观察 415 错误;
  5. 观察 @RequestParam 的类型转换:访问 /api/orders?page=abc,在 MethodArgumentTypeMismatchException 抛出处断点——类型转换发生在参数绑定阶段,不是业务代码里。

9. 常见错误 ​

错误 1:漏掉 -parameters(本项目实际踩到) ​

见 5.4 节。症状:Name for argument of type [java.lang.String] not specified。

修复:加编译标志,或给注解显式写名字。

错误 2:@RequestBody 与 @RequestParam 用混 ​

java
// ✗ 想用表单参数接收 JSON body
@PostMapping
public void create(@RequestParam String productId) { }

症状:参数永远是 null,或报 400(Required request parameter 'productId' is not present)。

原因:@RequestParam 从查询参数或表单取值,不读 JSON body。

修复:用 @RequestBody + DTO(S3-REST 的规范)。

错误 3:@RequestBody 读两次 ​

java
@PostMapping
public void create(@RequestBody CreateOrderRequest request, HttpServletRequest raw) {
    raw.getInputStream().readAllBytes();      // ✗ 已经读完了
}

修复:需要在多处读请求体时,用 ContentCachingRequestWrapper(在 Filter 里包装一次)。

错误 4:@RequestParam 忘了 required = false ​

java
@GetMapping
public PageResponse<OrderResponse> list(@RequestParam String status) { }

症状:不传 status 时返回 400(Required request parameter 'status' is not present)。

修复:

java
@RequestParam(required = false) String status                    // 可选
@RequestParam(defaultValue = "20") int size                       // 有默认值

错误 5:声称返回 JSON 但没引 Jackson ​

java
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { }

症状:500 或 406,日志里有 No converter found for return value of type。

原因:HttpMessageConverter 里没有能处理该类型的转换器 ✓

排查:确认 jackson-databind 在类路径上(引了 spring-boot-starter-web 就会有)✓

错误 6:把业务逻辑写在 Controller 里 ​

java
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
    if ("BANNED".equals(request.productId())) {                  // ✗ 业务规则
        return ResponseEntity.status(HttpStatus.CONFLICT).build();
    }
    // ... 更多业务逻辑
}

问题:Controller 变成"业务逻辑 + 参数绑定 + 响应组装"三合一,难以测试也难以复用。

修复:

职责放在哪
参数绑定、状态码Controller
业务规则Service(抛异常)
异常 → 响应@RestControllerAdvice(《参数校验与全局异常处理》)

改造后:

java
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
    OrderResponse created = orderService.create(request);        // 业务规则在 Service 里
    return ResponseEntity.created(URI.create("/api/orders/" + created.orderNo())).body(created);
}

Controller 变回"薄薄一层"——这就是"分层"的实际收益 ✓

10. 动手实验 ​

目标:把九种请求场景亲手跑一遍。

步骤:

  1. 运行第 7 节的命令,核对九个场景的状态码;
  2. 制造 415:用 curl 发 POST 但不带 Content-Type(或带 application/x-www-form-urlencoded),观察错误;
  3. 制造 406:请求时带 Accept: application/xml,观察响应(提示:produces 没配时可能不会 406,试着给方法加上 produces = APPLICATION_JSON_VALUE 再试);
  4. 验证 -parameters 的作用:把自己写的某个 @PathVariable String xxx 改成 @PathVariable("xxx") String xxx,观察行为一致(说明两种方式等价);
  5. 加一个新接口:GET /api/orders/{orderNo}/items(订单明细),用 @PathVariable 接收 orderNo,返回一个列表;
  6. 测试 defaultValue:分别访问 /api/orders、/api/orders?page=2、/api/orders?size=1,观察 page / size 的取值(从响应体的分页字段看)。

验收方式:能说出第 2、3 步的错误码与原因。

扩展任务:给 OrderController 加一个"按状态统计"的接口 GET /api/orders/stats?status=PAID,返回该状态的订单数与总金额。注意:这个 URL 与 /{orderNo} 冲突吗?如果冲突该怎么处理?(提示:Spring MVC 的精确路径优先于模板路径)

11. 练习题 ​

基础题

  1. 判断:@RequestParam 可以从 JSON 请求体里取参数。(对 / 错)
  2. 填空:从 URL 路径取值用 ______;从查询字符串取值用 ______;把 JSON 绑定成对象用 ______。
  3. 简答:@RestController 与 @Controller 的区别是什么?

代码阅读题

java
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public UserResponse find(@PathVariable Long id) { ... }

    @GetMapping
    public List<UserResponse> list(@RequestParam String keyword,
                                   @RequestParam(defaultValue = "10") int size) { ... }

    @PostMapping
    public UserResponse create(@RequestBody CreateUserRequest request) { ... }
}
  1. 访问 GET /api/users/abc 会发生什么?为什么?
  2. 访问 GET /api/users 会发生什么?如何修复?

编程题

  1. 为一个"文章"资源写完整的 Controller:
    • 接口:GET /api/articles/{id}、GET /api/articles?tag=java&page=1&size=10、POST /api/articles、PUT /api/articles/{id}(整体替换)、DELETE /api/articles/{id}(返回 204);
    • 要求:
      • 创建返回 201 + Location;
      • 查询支持"按标签筛选"(tag 可选);
      • 分页参数有默认值与上限(size 最大 100);
    • 用 demo.ps1 的方式实测五个接口并记录状态码;
    • 说明:PUT 与 PATCH 的语义差别,以及你会选哪个来做"修改文章标题"。

综合任务

  1. 设计并实现一个"搜索接口"并说明设计取舍:
    • 需求:按"关键词、标签列表、发布时间范围、状态"搜索文章,支持排序与分页;
    • 要求:
      • 设计两种方案:a) 全部用 @RequestParam;b) 复杂条件放 @RequestBody 用 POST /search;
      • 分别实现(可以只写 Controller 签名与 DTO);
      • 对比两种方案的优缺点(URL 可读性、可缓存性、语义、参数复杂度上限);
      • 说明:为什么业界更常用 POST /search 做复杂查询?(提示:URL 长度限制、缓存语义、日志与监控)
    • 额外要求:说明如果这个搜索接口要做分页,page/size 该放查询参数还是请求体?
分级提示

提示 1(方向):第 4 题的关键是"类型转换失败"——abc 转不成 Long。

提示 2(关键点):第 5 题的关键是 keyword 没有默认值也不是可选的。

提示 3(接近答案):第 7 题的"分页参数"通常仍放查询参数——因为它与"搜索条件"是两个维度,且分页需要被日志与监控单独识别。

12. 参考答案 ​

基础题
  1. 错。@RequestParam 从查询字符串或表单参数取值,不读 JSON 请求体。想从 JSON body 取值得用 @RequestBody。

判断方法:?key=value 用 @RequestParam;请求体里的 {"key":"value"} 用 @RequestBody。

  1. @PathVariable;@RequestParam;@RequestBody。
  2. @RestController = @Controller + @ResponseBody。
注解方法返回值怎么处理
@Controller当作视图名,由视图解析器找页面(如 return "orders/list" → orders/list.html)
@RestController直接写入响应体,由 HttpMessageConverter 序列化成 JSON

在前后端分离的项目里一律用 @RestController;@Controller 只在"服务端渲染页面(Thymeleaf / JSP)"时用。

代码阅读题(第 4、5 题)

第 4 题:返回 400 Bad Request。

原因:@PathVariable Long id 要求把路径里的 abc 转成 Long——转换失败,抛 MethodArgumentTypeMismatchException。

本项目的实测对照(/api/orders?page=abc):

json
{"code":"TYPE_MISMATCH","message":"参数类型不正确:page","traceId":"...","details":[]}
[HTTP 400]

注意:这个异常发生在参数绑定阶段,根本没进入方法体——所以不能在方法里 try-catch 它 ✓ 必须用全局异常处理(《参数校验与全局异常处理》)。

第 5 题:返回 400(Required request parameter 'keyword' is not present)。

原因:@RequestParam 默认 required = true,而请求里没带 keyword。

两种修复:

java
// 方案一:设为可选,方法里处理 null
@RequestParam(required = false) String keyword

// 方案二:给默认值
@RequestParam(defaultValue = "") String keyword

选哪个:

情况建议
keyword 是"可选筛选条件"required = false,方法里判空
keyword 是"必须有但可以为空串"用 defaultValue = ""

注意 size 那个参数:它有 defaultValue = "10",所以不传也没问题 ✓ —— 这正是"设计接口时要区分必填与可选"的实际体现。

编程题(第 6 题)
java
package cn.kb.learn.web.controller;

import cn.kb.learn.web.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/articles")
public class ArticleController {
    private static final int MAX_PAGE_SIZE = 100;

    private final ArticleService articleService;

    public ArticleController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @GetMapping("/{id}")
    public ArticleResponse find(@PathVariable Long id) {
        return articleService.find(id);
    }

    @GetMapping
    public PageResponse<ArticleResponse> list(
            @RequestParam(required = false) String tag,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {
        int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);      // 上限保护
        return articleService.list(tag, Math.max(page, 1), safeSize);
    }

    @PostMapping
    public ResponseEntity<ArticleResponse> create(@Valid @RequestBody CreateArticleRequest request) {
        ArticleResponse created = articleService.create(request);
        return ResponseEntity
                .created(URI.create("/api/articles/" + created.id()))
                .body(created);
    }

    @PutMapping("/{id}")
    public ArticleResponse replace(@PathVariable Long id,
                                   @Valid @RequestBody UpdateArticleRequest request) {
        return articleService.replace(id, request);                     // 整体替换,返回 200
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        articleService.delete(id);
        return ResponseEntity.noContent().build();                      // 204
    }
}

预期状态码:

接口成功失败
GET /api/articles/{id}200404(不存在)
GET /api/articles?tag=java200400(page/size 类型错)
POST /api/articles201 + Location422(校验失败)
PUT /api/articles/{id}200404 / 422
DELETE /api/articles/{id}204404

PUT 与 PATCH 的语义差别:

方法语义客户端要发什么幂等
PUT整体替换资源的完整表示(未提供的字段会被置空/默认值)✅
PATCH局部修改只发要改的字段⚠️ 取决于实现

"修改文章标题"该选哪个:

场景选择理由
编辑页提交(表单里有全部字段)PUT用户看到并且提交了完整内容
列表页"快捷改标题"PATCH只发 {"title":"新标题"},其他字段不动

关键判断:"客户端知不知道完整的资源状态?"

  • 知道(编辑页)→ PUT(并发的语义也更清晰:后提交的覆盖前提交的)
  • 不知道(局部操作)→ PATCH

一个实践提醒:PATCH 的实现要小心"null 与未提供"的区分:

json
{ "title": null }        // 想把标题设为 null?
{}                       // 还是"不改标题"?

在 Java 里这两者都映射成"字段为 null" ✗ 需要额外手段区分(如 JsonNullable、Optional 字段,或用 PATCH 的 JSON Merge Patch 标准语义)✓ 这也是很多团队干脆统一用 PUT 的原因。

综合任务(第 7 题)

方案 a:全部用 @RequestParam

java
@GetMapping("/search")
public PageResponse<ArticleResponse> search(
        @RequestParam(required = false) String keyword,
        @RequestParam(required = false) List<String> tags,          // ?tags=java&tags=spring
        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE_TIME) Instant from,
        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE_TIME) Instant to,
        @RequestParam(required = false) String status,
        @RequestParam(defaultValue = "publishedAt,desc") String sort,
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "20") int size) { }
text
GET /api/articles/search?keyword=索引&tags=java&tags=mysql&from=2026-01-01T00:00:00Z&status=PUBLISHED&sort=publishedAt,desc&page=1&size=20

方案 b:复杂条件放请求体

java
public record ArticleSearchRequest(
        String keyword,
        List<String> tags,
        InstantRange publishedAt,
        String status,
        String sort,
        int page,
        int size) {
    public record InstantRange(Instant from, Instant to) { }
}

@PostMapping("/search")
public PageResponse<ArticleResponse> search(@RequestBody ArticleSearchRequest request) { }

对比:

维度方案 a(@RequestParam)方案 b(@RequestBody + POST)
URL 可读性✅ 一眼看出查询条件❌ 条件在 body 里,看 URL 看不出来
可缓存✅ GET 可被浏览器/CDN 缓存❌ POST 默认不缓存
可直接分享/收藏✅ URL 复制给别人就能复现❌ 没法直接分享
语义正确性✅ 查询用 GET,符合 REST⚠️ 用 POST 做查询,语义上不纯粹
参数复杂度上限❌ URL 长度受限(浏览器约 2000 字符,服务器也有上限)✅ 无限制
嵌套结构❌ 难表达(要靠命名约定:publishedAt.from)✅ 天然支持
数组/多值⚠️ 可行但写法别扭(tags=java&tags=mysql)✅ 天然支持
敏感信息❌ 出现在 URL(会进日志、浏览器历史)✅ 在 body 里
日志与监控✅ 网关注/日志能直接看到查询条件⚠️ 需要额外记录 body

为什么业界更常用 POST /search 做复杂查询:

原因说明
1. URL 长度限制当条件有几十个字段(或含长文本)时,GET URL 会超限
2. 结构表达力嵌套条件、数组、范围——用查询参数要发明一堆命名约定
3. Elasticsearch 等引擎的影响ES 的搜索 API 本身就是 POST /_search + JSON body,业界的搜索接口几乎都照此设计
4. 参数演进加一个筛选字段时,body 是"加一个字段",查询参数是"加一个参数并考虑命名冲突"
5. 安全性条件里可能含不该进日志的内容(如用户手机号)

一条务实的原则:

简单查询用 GET + 查询参数(能被缓存、能分享、语义正确); 复杂查询用 POST + 请求体(表达力强、无长度限制)。

判断标准:条件数量与是否含嵌套结构——超过约 5 个条件、或有数组/区间时,就该考虑 POST。

分页参数该放哪:

仍然放查询参数(即使搜索条件放在 body 里):

理由说明
分页与搜索是两个维度分页是"怎么取结果",搜索是"取什么"
日志与监控需要独立识别网关、APM 常按 page / size 做统计与限流
翻页场景用户翻到第 5 页时,条件不变、只有分页变——放 URL 更自然
统一约定全站分页参数一致,客户端能复用分页组件

所以最终形态往往是"混合"的:

text
POST /api/articles/search?page=1&size=20
{"keyword":"索引","tags":["java","mysql"],"publishedAt":{"from":"..."},"status":"PUBLISHED"}

分页在 URL(可被网关识别),条件在 body(表达力强) ✓

13. 掌握标准 ​

  • [ ] 能说出 DispatcherServlet 处理请求的完整流程,知道参数绑定发生在哪一步;
  • [ ] 能区分 @RestController 与 @Controller;
  • [ ] 能熟练使用三种参数绑定,并知道各自的适用场景;
  • [ ] 知道 -parameters 的作用与漏掉它的报错;
  • [ ] 会用 ResponseEntity 实现 201 + Location 与 204;
  • [ ] 知道 415 与 406 分别对应哪个配置项;
  • [ ] 能判断"复杂查询"该用查询参数还是请求体。

14. 扩展资源 ​

15. 下一步学习 ​

《参数校验与全局异常处理》:让"错误响应"也保持一致的规范。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引