本节目标:掌握六种取参方式与四种返回类型的适用场景,理解
@RequestBody与@ModelAttribute的本质区别,并用 Jackson 3 口径写出可运行的图书增删改查接口。
适用版本:Spring Boot 4.1.x(Java 21)
8.2 请求参数与响应体
8.1 节我们把路由骨架搭好了,但方法里的数据还是硬编码的字符串。真实接口必须解决两件事:请求里的数据怎么进到方法参数,以及方法返回的对象怎么写回 HTTP 响应。Spring MVC 用一套「参数解析器 + 消息转换器」的机制把这两件事都自动化了,我们要做的只是选对注解。
本节继续用图书管理服务,先定义领域对象:
package com.example.bookstore.domain;
public record Book(
Long id,
String title,
String author,
String isbn,
int publishedYear) {
}
8.2.1 六种取参方式一览
| 注解 | 数据来源 | 典型场景 |
|---|---|---|
@PathVariable | URL 路径片段 | /books/{id} 中的 id |
@RequestParam | 查询串或表单字段 | ?page=1&size=20 |
@RequestBody | 请求体(JSON/XML) | POST/PUT 提交的 JSON |
@RequestHeader | 请求头 | Authorization、X-Trace-Id |
@CookieValue | Cookie | 会话标识 |
@ModelAttribute | 查询串或表单字段,绑定到对象 | HTML 表单提交 |
记忆口诀:路径上的用 @PathVariable,? 后面的用 @RequestParam,请求体里的用 @RequestBody。这三者覆盖了 90% 的场景,其余三个是补充。
8.2.2 @PathVariable
路径变量在 8.1 已经见过,这里补充两点。第一,多个路径变量按名字匹配,与方法参数名解耦:
@GetMapping("/{bookId}/chapters/{chapterNo}")
public String chapter(@PathVariable Long bookId,
@PathVariable int chapterNo) {
return "book " + bookId + ", chapter " + chapterNo;
}
第二,方法参数名在编译时可能被擦除(未加 -parameters),此时必须显式写 @PathVariable("bookId")。Spring Boot 的 Maven/Gradle 插件默认开启 -parameters,所以按名匹配通常可用;但一旦你手动改了编译配置,显式指定名字最稳妥。
8.2.3 @RequestParam:required、defaultValue 与集合
查询参数用 @RequestParam 获取。它默认 required = true,缺失就返回 400:
@GetMapping
public String list(@RequestParam String keyword) {
return "search:" + keyword;
}
curl -i "http://localhost:8080/api/books"
HTTP/1.1 400 Bad Request
Content-Type: application/json
要让参数可选,有两种做法:
// 1) 显式声明非必填,缺失时得到 null
@RequestParam(required = false) String category
// 2) 给默认值(推荐),缺失时用默认值
@RequestParam(defaultValue = "1") int page
@RequestParam 还能直接绑定集合和映射。同一名字出现多次会绑定到 List:
@GetMapping("/filter")
public String filter(@RequestParam List<String> tag) {
return "tags:" + tag;
}
请求 ?tag=java&tag=spring 会得到 tags:[java, spring]。若想一次性拿到所有查询参数,用 Map:@RequestParam Map<String, String> params 会收集所有查询参数;如果写成 @RequestParam("tag") List<String> tag,则只取 tag。
8.2.4 @RequestBody 与 @ModelAttribute 的区别
这是初学者最容易混淆的一对。
@RequestBody:把整个请求体交给HttpMessageConverter(JSON 场景就是 Jackson)反序列化成对象,要求Content-Type: application/json。@ModelAttribute:把查询串或表单字段按名字逐个绑定到对象属性。它读的是参数,不是 body。
@PostMapping("/json")
public String createJson(@RequestBody Book book) {
return "json:" + book.title();
}
@PostMapping("/form")
public String createForm(@ModelAttribute BookForm form) {
return "form:" + form.getTitle();
}
BookForm 是带 getter/setter 的可变类:
package com.example.bookstore.web;
public class BookForm {
private String title;
private String author;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
}
| 维度 | @RequestBody | @ModelAttribute |
|---|---|---|
| 数据来源 | 请求体 | 查询串 / 表单字段 |
| 内容类型 | 通常是 application/json | application/x-www-form-urlencoded |
| 绑定方式 | 消息转换器整体反序列化 | 逐字段 setter 绑定 |
| 目标类型 | 任意(record 也可以) | 需要无参构造 + setter |
| 嵌套结构 | 支持深层嵌套 | 只支持扁平字段 |
一句话选型:前后端分离传 JSON 用 @RequestBody;传统表单提交用 @ModelAttribute。
8.2.5 @RequestHeader 与 @CookieValue
这两个注解用法与 @RequestParam 类似,只是数据来源不同:
@GetMapping("/me")
public String me(@RequestHeader("Authorization") String token,
@RequestHeader(value = "X-Trace-Id", defaultValue = "none") String traceId,
@CookieValue(value = "SESSION", required = false) String session) {
return "token=" + token + ", trace=" + traceId + ", session=" + session;
}
它们同样支持 required 与 defaultValue。请求头名是大小写不敏感的,Authorization 和 authorization 等价。
8.2.6 返回类型的四种选择
| 写法 | 状态码 | 适用场景 |
|---|---|---|
| 直接返回对象 | 200 | 简单查询,成功即 200 |
ResponseEntity<T> | 自己指定 | 需要控制状态码、响应头 |
方法加 @ResponseStatus | 注解指定 | 固定状态码,如创建返回 201 |
ResponseEntity<Void> | 自己指定 | 无响应体,如删除返回 204 |
直接返回对象最省事:方法返回 Book,Spring 就以 200 + JSON 写出。需要控制状态码时用 ResponseEntity:
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
Book saved = new Book(1L, book.title(), book.author(), book.isbn(), book.publishedYear());
return ResponseEntity
.created(URI.create("/api/books/" + saved.id()))
.body(saved);
}
也可以用 @ResponseStatus(HttpStatus.CREATED) 标在方法上固定状态码,适合「这个接口永远是 201」的情况;缺点是无法根据运行时结果动态决定。删除接口没有响应体,用 ResponseEntity<Void>:
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
return ResponseEntity.noContent().build();
}
补充一点:@ResponseBody 标在方法或类上时,告诉 Spring「返回值直接序列化写进响应体」,@RestController 只是把它提到了类级。在 @RestController 里仍然可以返回 ResponseEntity,两者不冲突——它本身就是「带状态码的响应体」。
8.2.7 Jackson 3 序列化注意点
Spring Boot 4 把 JSON 库升级到 Jackson 3,这是升级时最容易被绊倒的地方。要点如下:
- 包名变了:Jackson 的核心与数据绑定包从
com.fasterxml.jackson迁到tools.jackson(例如tools.jackson.databind.json.JsonMapper)。 - 注解没变:
@JsonProperty、@JsonFormat、@JsonIgnore等注解仍在com.fasterxml.jackson.annotation,import 不用改。 - 自动配置改用
JsonMapper/XmlMapper;自定义ObjectMapperbean 不再能替换自动配置,要定制请用JsonMapperBuilderCustomizer(旧名Jackson2ObjectMapperBuilderCustomizer已改名)。 - 属性迁移:Jackson 的
read/write相关属性统一归入spring.jackson.json.read/.write之下。
日期格式化是高频需求。Java 8 日期时间类型在 Jackson 3 里已内建支持,默认输出 ISO-8601 字符串;要自定义格式,用 @JsonFormat:
package com.example.bookstore.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDate;
public record BookDetail(
Long id,
@JsonProperty("book_title") String title,
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate publishedOn) {
}
@JsonProperty("book_title") 让字段在 JSON 里改名为 book_title,@JsonFormat 把日期输出成 2018-06-01 而不是带时间的默认格式。
8.2.8 一套完整的增删改查 + curl 实测
把上面的知识点串起来,得到一个可运行的 BookController:
package com.example.bookstore.web;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.bookstore.domain.Book;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final Map<Long, Book> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
@GetMapping
public List<Book> list() {
return List.copyOf(store.values());
}
@GetMapping("/{id}")
public ResponseEntity<Book> get(@PathVariable Long id) {
Book book = store.get(id);
return book == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(book);
}
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
long id = seq.incrementAndGet();
Book saved = new Book(id, book.title(), book.author(), book.isbn(), book.publishedYear());
store.put(id, saved);
return ResponseEntity.created(URI.create("/api/books/" + id)).body(saved);
}
@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
if (!store.containsKey(id)) {
return ResponseEntity.notFound().build();
}
Book saved = new Book(id, book.title(), book.author(), book.isbn(), book.publishedYear());
store.put(id, saved);
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
store.remove(id);
return ResponseEntity.noContent().build();
}
}
实测四步:
# 1) 创建,期望 201 + Location
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"978-0134685991","publishedYear":2018}'
HTTP/1.1 201 Created
Location: /api/books/1
Content-Type: application/json
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"978-0134685991","publishedYear":2018}
# 2) 查询单本,期望 200
curl -s http://localhost:8080/api/books/1
# → {"id":1,"title":"Effective Java",...}
# 3) 整体更新,期望 200
curl -s -X PUT http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Effective Java 3rd","author":"Joshua Bloch","isbn":"978-0134685991","publishedYear":2018}'
# 4) 删除,期望 204(无响应体)
curl -i -X DELETE http://localhost:8080/api/books/1
# → HTTP/1.1 204 No Content
若忘了带 Content-Type: application/json,@RequestBody 会因找不到匹配的消息转换器而返回 415,这是新手最常见的报错之一。
小结
- 取参六件套:
@PathVariable(路径)、@RequestParam(查询串)、@RequestBody(请求体)、@RequestHeader、@CookieValue、@ModelAttribute(表单/查询串绑对象)。 @RequestParam支持required/defaultValue,也能绑定List与Map。@RequestBody读请求体(JSON),@ModelAttribute逐字段绑定(表单);前者支持嵌套,后者要求无参构造 + setter。- 返回类型按需选:直接返回对象(200)、
ResponseEntity(自定状态码与响应头)、@ResponseStatus(固定状态码)、ResponseEntity<Void>(204)。 - Jackson 3:核心包
tools.jackson,注解仍在com.fasterxml.jackson.annotation,定制走JsonMapperBuilderCustomizer。
现在接口能跑通了,但写法还很「随手」:URL 用动词、状态码随意、分页参数没约定。下一节我们把它改造成一套符合 REST 约定的 API。
阅读导航:上一节:8.1 控制器与路由映射 · 下一节:8.3 RESTful 设计约定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。