主题
界面
主题
S3-REST 设计了接口长什么样,本节讲它怎么落地成代码:
HTTP 请求 → DispatcherServlet → Controller 的方法调用
POST /api/orders → OrderController.create(request)
{"productId":"P-1",...} ↑ 这个 JSON 怎么变成参数的?这个"怎么变成"的过程就是参数绑定——它是本节的核心,也是最容易踩坑的地方。
完成本节后,你应该能够:
@RestController 与 @Controller 的区别;@RequestParam 的 required 与 defaultValue 的作用;-parameters 编译标志的作用,以及漏掉它会出现什么报错;ResponseEntity 精确控制状态码与响应头(如 201 Created + Location);@RequestBody 只能被读取一次的原因。Spring MVC 把"处理 HTTP 请求"这件繁琐的事变成了"写一个方法":
@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 反序列化成 CreateOrderRequest | RequestResponseBodyMethodProcessor + Jackson |
触发 @Valid 校验 | MethodValidationPostProcessor(《参数校验与全局异常处理》) |
| 把返回对象序列化成 JSON | Jackson |
设置状态码 201 与 Location 头 | ResponseEntity |
理解这个过程,才能在"参数绑不上"或"返回 406"时知道去哪找原因。
① 客户端 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 | 统一调度("前端控制器") | 几乎不——它是配置出来的 |
HandlerMapping | URL → 方法 | 排查 404("为什么没匹配上") |
HandlerAdapter + 参数解析器 | 请求 → 方法参数 | 排查 400("为什么参数绑不上") |
注意第 ⑤ 步的"解析器链":Spring MVC 有十几个参数解析器,按顺序尝试,谁支持这个参数类型就由谁处理。这就是"为什么参数类型写错就报 400"的原因——所有解析器都不支持时,抛 MethodArgumentTypeMismatchException 或类似异常。
@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 的快捷方式):
| 注解 | 对应方法 |
|---|---|
@GetMapping | GET |
@PostMapping | POST |
@PutMapping | PUT |
@PatchMapping | PATCH |
@DeleteMapping | DELETE |
@RequestMapping | 任意(需显式指定 method) |
@PathVariable —— 从 URL 路径取值 @GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { }GET /api/orders/ORD-1 → orderNo = "ORD-1"| 要点 | 说明 |
|---|---|
| 适用 | 资源的唯一标识(REST 的典型用法) |
| 必填 | 默认必填(路径里没有就是 404,不会进方法) |
| 多路径变量 | /{a}/{b} → @PathVariable String a, @PathVariable String b |
| 名字不一致 | 用 @PathVariable("orderNo") String no 显式指定 |
@RequestParam —— 从查询参数取值 @GetMapping
public PageResponse<OrderResponse> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status) { }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 → 对象) @PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }POST /api/orders
Content-Type: application/json
{"productId":"P-1","quantity":2,"address":"上海市浦东新区"}三个关键点:
Content-Type: application/json——没有的话会报 415 Unsupported Media Type;HttpMessageConverter 完成(默认是 Jackson);@RequestBody 背后是 InputStream,读完了就没了 ✓ 这是"为什么不能在 Controller 里既用 @RequestBody 又自己读 request.getInputStream()"的原因。一个常见错误:
// ✗ 想在 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 | 原生的请求/响应对象(不推荐在业务代码里用) |
-parameters:一个真实踩到的坑 现象(本项目实际遇到的):
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.触发代码:
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable String orderNo) { } // ← 没写 ("orderNo")根因:
| 事实 | 说明 |
|---|---|
| Java 字节码默认不保留方法参数名 | 编译后只剩 arg0、arg1 |
@PathVariable 没写名字时 | Spring 需要从字节码的调试信息里读参数名 |
| 默认情况下读不到 | 于是抛 IllegalArgumentException |
两种修复方式:
方式一:加编译标志(推荐)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>方式二:显式写名字
@GetMapping("/{orderNo}")
public OrderResponse find(@PathVariable("orderNo") String orderNo) { }一个容易忽略的背景:spring-boot-starter-parent 会自动帮你开启 -parameters。
本项目没有继承它(因为 labs 有自己的父 POM),所以必须手动配——这就是"少继承一个 parent,多踩一个坑"的实例。
顺带一提:加了 -parameters 还有别的好处——@RequestParam、@RequestHeader 等注解都可以在名字一致时省略括号里的名字,代码更简洁 ✓
三种返回形式:
// ① 直接返回对象:状态码固定 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 返回——那是 《参数校验与全局异常处理》 全局异常处理的职责 ✓
@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-Type | 415 Unsupported Media Type |
produces | 响应的 Accept | 406 Not Acceptable |
实践建议:
| 情况 | 是否写 |
|---|---|
| 纯 JSON 接口 | 可以不写(默认就是 JSON,写上是"自我文档化") |
| 同一 URL 返回多种格式(JSON / XML / CSV) | 必须写(靠它区分) |
| 需要严格约束契约 | 建议写(明确拒绝非 JSON 请求) |
一个实用技巧:produces 还可以用来强制返回 UTF-8:
@GetMapping(value = "/export", produces = "text/csv;charset=UTF-8")@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) { }
}见第 7 节。
@RequestParam 传复杂对象 // ✗ 把 JSON 拆成 5 个查询参数
@PostMapping("/search")
public List<OrderResponse> search(@RequestParam String productId,
@RequestParam int minQuantity,
@RequestParam int maxQuantity,
@RequestParam String status,
@RequestParam String address) { }POST /api/orders/search?productId=P-1&minQuantity=1&maxQuantity=10&status=PAID&address=上海问题:
| 问题 | 说明 |
|---|---|
| URL 太长、难读 | 参数一多就无法维护 |
| 无法表达嵌套结构 | 数组、对象都没法传 |
| 中文/特殊字符要编码 | 容易出错 |
| 语义不符 | 这是"查询"(复杂条件),但用了 POST + 查询参数 |
修复:用请求体传复杂条件:
@PostMapping("/search")
public List<OrderResponse> search(@RequestBody OrderSearchRequest request) { }判断标准:参数是"简单筛选"还是"复杂条件"?
| 用查询参数 | 用请求体 |
|---|---|
| 分页、排序、单个状态筛选 | 多字段条件、嵌套结构、数组 |
| 能被用户直接改 URL 复现的查询 | 复杂的组合条件 |
文件:labs/spring-boot-web/src/main/java/cn/kb/learn/web/controller/OrderController.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();
}
}运行命令(一条命令跑完全部场景):
cd labs
./mvnw -pl spring-boot-web package -DskipTests
cd spring-boot-web
./demo.ps1预期输出(九个场景的真实结果):
===== 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 |
| 8 | 204 No Content |
| 9 | 幂等性:重复删除返回 404 而不是 500 |
注意场景 3、4、5、7、9 的响应格式完全一致(都是 code / message / traceId / details)——这就是"统一错误响应"的价值:客户端只需要写一套错误处理逻辑 ✓ (见 《参数校验与全局异常处理》)
OrderController.create 的第一行打断点:观察 request 对象已经被 Jackson 反序列化好了——参数解析发生在方法调用之前;DispatcherServlet.doDispatch 处打断点(或开启 logging.level.org.springframework.web=debug),观察它查找 handler、调用 adapter、处理返回值的全过程;RequestResponseBodyMethodProcessor.resolveArgument 处断点,看它如何用 HttpMessageConverter 读 JSON;Content-Type 发请求(curl -d '{}' 默认是 application/x-www-form-urlencoded)——观察 415 错误;@RequestParam 的类型转换:访问 /api/orders?page=abc,在 MethodArgumentTypeMismatchException 抛出处断点——类型转换发生在参数绑定阶段,不是业务代码里。-parameters(本项目实际踩到) 见 5.4 节。症状:Name for argument of type [java.lang.String] not specified。
修复:加编译标志,或给注解显式写名字。
@RequestBody 与 @RequestParam 用混 // ✗ 想用表单参数接收 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 的规范)。
@RequestBody 读两次 @PostMapping
public void create(@RequestBody CreateOrderRequest request, HttpServletRequest raw) {
raw.getInputStream().readAllBytes(); // ✗ 已经读完了
}修复:需要在多处读请求体时,用 ContentCachingRequestWrapper(在 Filter 里包装一次)。
@RequestParam 忘了 required = false @GetMapping
public PageResponse<OrderResponse> list(@RequestParam String status) { }症状:不传 status 时返回 400(Required request parameter 'status' is not present)。
修复:
@RequestParam(required = false) String status // 可选
@RequestParam(defaultValue = "20") int size // 有默认值@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 就会有)✓
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
if ("BANNED".equals(request.productId())) { // ✗ 业务规则
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// ... 更多业务逻辑
}问题:Controller 变成"业务逻辑 + 参数绑定 + 响应组装"三合一,难以测试也难以复用。
修复:
| 职责 | 放在哪 |
|---|---|
| 参数绑定、状态码 | Controller |
| 业务规则 | Service(抛异常) |
| 异常 → 响应 | @RestControllerAdvice(《参数校验与全局异常处理》) |
改造后:
@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 变回"薄薄一层"——这就是"分层"的实际收益 ✓
目标:把九种请求场景亲手跑一遍。
步骤:
curl 发 POST 但不带 Content-Type(或带 application/x-www-form-urlencoded),观察错误;Accept: application/xml,观察响应(提示:produces 没配时可能不会 406,试着给方法加上 produces = APPLICATION_JSON_VALUE 再试);-parameters 的作用:把自己写的某个 @PathVariable String xxx 改成 @PathVariable("xxx") String xxx,观察行为一致(说明两种方式等价);GET /api/orders/{orderNo}/items(订单明细),用 @PathVariable 接收 orderNo,返回一个列表;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 的精确路径优先于模板路径)
基础题
@RequestParam 可以从 JSON 请求体里取参数。(对 / 错)@RestController 与 @Controller 的区别是什么?代码阅读题
@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) { ... }
}GET /api/users/abc 会发生什么?为什么?GET /api/users 会发生什么?如何修复?编程题
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);tag 可选);size 最大 100);demo.ps1 的方式实测五个接口并记录状态码;PUT 与 PATCH 的语义差别,以及你会选哪个来做"修改文章标题"。综合任务
@RequestParam;b) 复杂条件放 @RequestBody 用 POST /search;POST /search 做复杂查询?(提示:URL 长度限制、缓存语义、日志与监控)page/size 该放查询参数还是请求体?提示 1(方向):第 4 题的关键是"类型转换失败"——abc 转不成 Long。
提示 2(关键点):第 5 题的关键是 keyword 没有默认值也不是可选的。
提示 3(接近答案):第 7 题的"分页参数"通常仍放查询参数——因为它与"搜索条件"是两个维度,且分页需要被日志与监控单独识别。
@RequestParam 从查询字符串或表单参数取值,不读 JSON 请求体。想从 JSON body 取值得用 @RequestBody。判断方法:?key=value 用 @RequestParam;请求体里的 {"key":"value"} 用 @RequestBody。
@PathVariable;@RequestParam;@RequestBody。@RestController = @Controller + @ResponseBody。| 注解 | 方法返回值怎么处理 |
|---|---|
@Controller | 当作视图名,由视图解析器找页面(如 return "orders/list" → orders/list.html) |
@RestController | 直接写入响应体,由 HttpMessageConverter 序列化成 JSON |
在前后端分离的项目里一律用 @RestController;@Controller 只在"服务端渲染页面(Thymeleaf / JSP)"时用。
第 4 题:返回 400 Bad Request。
原因:@PathVariable Long id 要求把路径里的 abc 转成 Long——转换失败,抛 MethodArgumentTypeMismatchException。
本项目的实测对照(/api/orders?page=abc):
{"code":"TYPE_MISMATCH","message":"参数类型不正确:page","traceId":"...","details":[]}
[HTTP 400]注意:这个异常发生在参数绑定阶段,根本没进入方法体——所以不能在方法里 try-catch 它 ✓ 必须用全局异常处理(《参数校验与全局异常处理》)。
第 5 题:返回 400(Required request parameter 'keyword' is not present)。
原因:@RequestParam 默认 required = true,而请求里没带 keyword。
两种修复:
// 方案一:设为可选,方法里处理 null
@RequestParam(required = false) String keyword
// 方案二:给默认值
@RequestParam(defaultValue = "") String keyword选哪个:
| 情况 | 建议 |
|---|---|
keyword 是"可选筛选条件" | required = false,方法里判空 |
keyword 是"必须有但可以为空串" | 用 defaultValue = "" |
注意 size 那个参数:它有 defaultValue = "10",所以不传也没问题 ✓ —— 这正是"设计接口时要区分必填与可选"的实际体现。
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} | 200 | 404(不存在) |
GET /api/articles?tag=java | 200 | 400(page/size 类型错) |
POST /api/articles | 201 + Location | 422(校验失败) |
PUT /api/articles/{id} | 200 | 404 / 422 |
DELETE /api/articles/{id} | 204 | 404 |
PUT 与 PATCH 的语义差别:
| 方法 | 语义 | 客户端要发什么 | 幂等 |
|---|---|---|---|
PUT | 整体替换 | 资源的完整表示(未提供的字段会被置空/默认值) | ✅ |
PATCH | 局部修改 | 只发要改的字段 | ⚠️ 取决于实现 |
"修改文章标题"该选哪个:
| 场景 | 选择 | 理由 |
|---|---|---|
| 编辑页提交(表单里有全部字段) | PUT | 用户看到并且提交了完整内容 |
| 列表页"快捷改标题" | PATCH | 只发 {"title":"新标题"},其他字段不动 |
关键判断:"客户端知不知道完整的资源状态?"
PUT(并发的语义也更清晰:后提交的覆盖前提交的)PATCH一个实践提醒:PATCH 的实现要小心"null 与未提供"的区分:
{ "title": null } // 想把标题设为 null?
{} // 还是"不改标题"?在 Java 里这两者都映射成"字段为 null" ✗ 需要额外手段区分(如 JsonNullable、Optional 字段,或用 PATCH 的 JSON Merge Patch 标准语义)✓ 这也是很多团队干脆统一用 PUT 的原因。
方案 a:全部用 @RequestParam
@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) { }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:复杂条件放请求体
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 更自然 |
| 统一约定 | 全站分页参数一致,客户端能复用分页组件 |
所以最终形态往往是"混合"的:
POST /api/articles/search?page=1&size=20
{"keyword":"索引","tags":["java","mysql"],"publishedAt":{"from":"..."},"status":"PUBLISHED"}分页在 URL(可被网关识别),条件在 body(表达力强) ✓
@RestController 与 @Controller;-parameters 的作用与漏掉它的报错;ResponseEntity 实现 201 + Location 与 204;《参数校验与全局异常处理》:让"错误响应"也保持一致的规范。