本节目标:把「图书管理服务」的 HTTP 入口搭起来,分清
@Controller与@RestController,掌握@RequestMapping家族与路径拼接规则,并亲眼看到 404 与 405 是怎么产生的。
适用版本:Spring Boot 4.1.x(Java 21)
8.1 控制器与路由映射
第 3 章我们用十几行代码跑通过一个 GET /hello。那时你只需要知道「注解写在方法上,Spring 就会把 URL 交给它」。从本节开始,我们把这个玩具升级成一套真正的接口层:一个图书管理服务。后续三节都会围绕它演进——8.1 搭路由骨架,8.2 处理参数与响应体,8.3 把它改造成符合 REST 约定的版本。
本节所有示例基于同一个依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
注意这里是 spring-boot-starter-webmvc,不是 3.x 时代的 spring-boot-starter-web。后者在 4.x 仍能用,但已废弃,新项目一律用新名。
8.1.1 @Controller 与 @RestController 的差别
Spring MVC 最早的定位是「服务端渲染」:控制器返回一个视图名,交给模板引擎(Thymeleaf、JSP)渲染成 HTML。@Controller 就是为这个场景设计的。而 REST 接口返回的是数据本身(JSON),不需要视图。
在纯 REST 场景里,如果你写 @Controller,就得在每个方法上加 @ResponseBody,否则 Spring 会把返回值当成视图名去查找模板。@RestController 是 4.0 之前就有的组合注解,它等价于:
@Controller
@ResponseBody
public @interface RestController {
}
也就是说,@RestController = @Controller + 类级 @ResponseBody。它把「整个类的方法都直接写响应体」这件事一次声明到位。
| 注解 | 返回值默认含义 | 典型用途 |
|---|---|---|
@Controller | 视图名,交由 ViewResolver 解析 | 服务端渲染页面 |
@Controller + 方法级 @ResponseBody | 直接写入响应体 | 页面里夹带少量 AJAX 接口 |
@RestController | 直接写入响应体(JSON/XML/文本) | 纯 REST API |
初学阶段可以直接记:做 JSON 接口就用 @RestController。8.2 节我们会展开 @ResponseBody 的序列化细节。
8.1.2 @RequestMapping 与五个快捷注解
@RequestMapping 是最底层的映射注解,它既能标在类上,也能标在方法上。它有 method 属性用来限定 HTTP 方法,但每次都写 method = RequestMethod.GET 很啰嗦,于是 Spring 提供了五个快捷注解:
| 快捷注解 | 等价于 | 语义 |
|---|---|---|
@GetMapping | @RequestMapping(method = GET) | 读取资源 |
@PostMapping | @RequestMapping(method = POST) | 创建资源 |
@PutMapping | @RequestMapping(method = PUT) | 整体替换资源 |
@DeleteMapping | @RequestMapping(method = DELETE) | 删除资源 |
@PatchMapping | @RequestMapping(method = PATCH) | 局部更新资源 |
它们与 @RequestMapping 拥有完全相同的属性(path、params、headers、consumes、produces),只是把 method 固定住了。本书一律使用快捷注解。
一个骨架长这样:
package com.example.bookstore.web;
import java.util.List;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
@GetMapping
public List<String> list() {
return List.of("Effective Java", "Spring in Action");
}
@GetMapping("/{id}")
public String get(@PathVariable Long id) {
return "book-" + id;
}
@PostMapping
public String create() {
return "created";
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
// 省略业务实现
}
}
8.1.3 类级与方法级路径拼接
路由规则里最容易出错的是路径拼接。规则其实很简单:最终路径 = 类级路径 + 方法级路径,两者都会先去掉首尾多余的斜杠再拼接。
| 类级 | 方法级 | 最终路径 |
|---|---|---|
/api/books | /list | /api/books/list |
/api/books | list | /api/books/list |
/api/books/ | /list/ | /api/books/list |
/api/books | "" 或省略 | /api/books |
"" 或省略 | /health | /health |
几个实用结论:
- 方法级路径开头不必写斜杠,写不写都会被规范化。
- 类级
@RequestMapping可以省略,此时方法级路径就是完整路径。 - 同一个控制器里不能有两条完全相同的映射,否则启动时抛
IllegalStateException,报「Ambiguous mapping」。这类错误在应用启动阶段就会暴露,不会等到请求进来才发现。
8.1.4 路径变量与正则约束
路径里的 {id} 是占位符,用 @PathVariable 取值。默认情况下它能匹配任意非斜杠片段,但有时我们要限定格式,比如「只接受数字 ID」。这时用正则:
@GetMapping("/{id:\\d+}")
public String getNumeric(@PathVariable Long id) {
return "book-" + id;
}
写成 {id:\\d+} 时要注意:Java 字符串里反斜杠要转义,所以正则 \d+ 在源码里是 \\d+。如果用 @PathVariable("id") 显式指定名字,还能让变量名和方法参数名解耦:
@GetMapping("/isbn/{code:[0-9]{13}}")
public String byIsbn(@PathVariable("code") String isbn) {
return "isbn-" + isbn;
}
当路径变量带正则约束时,不匹配的请求会落到 404,而不是 400——因为在 Spring 眼里,/api/books/abc 根本不匹配任何一条映射。下表是几种常见写法的行为差异:
| 映射 | 请求 | 结果 |
|---|---|---|
/{id} | /42 | 命中,id = 42 |
/{id:\\d+} | /42 | 命中 |
/{id:\\d+} | /abc | 404(无匹配映射) |
/{id:\\d+} | /42/extra | 404(多了一段) |
8.1.5 405 与 404:两种「找不到」的区别
初学时最困惑的就是:为什么有时是 404,有时是 405?我们用 curl 实测一遍(应用跑在 8080):
# 命中:GET /api/books/42
curl -i http://localhost:8080/api/books/42
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 9
book-42
# 路径不存在:GET /api/book/42(少了 s)
curl -i http://localhost:8080/api/book/42
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 121
# 路径存在但方法不对:DELETE /api/books(只有 GET/POST 映射在类级路径上)
curl -i -X DELETE http://localhost:8080/api/books
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST
Content-Type: application/json
Content-Length: 118
结论清晰:
- 路径不匹配任何映射 → 404 Not Found。
- 路径能匹配,但 HTTP 方法不匹配 → 405 Method Not Allowed,并且响应头会带
Allow,告诉你这个路径允许哪些方法。
这个区分对前端很重要:404 意味着「你请求的资源不存在」,405 意味着「地址对,但用错了动词」。调试接口时先看状态码,能省下大量猜测时间。
8.1.6 consumes 与 produces:内容协商
consumes 限定请求的 Content-Type,produces 限定响应的 Accept。它们让同一个 URL 可以按内容类型分流:
@PostMapping(path = "/import", consumes = "application/json")
public String importJson(@RequestBody String body) {
return "json:" + body.length();
}
@PostMapping(path = "/import", consumes = "text/csv")
public String importCsv(@RequestBody String body) {
return "csv:" + body.length();
}
请求 Content-Type: application/json 会命中第一个方法,text/csv 命中第二个。若请求的 Content-Type 一个都不匹配,返回 415 Unsupported Media Type;若 produces 声明的类型与请求的 Accept 不兼容,返回 406 Not Acceptable。
| 属性 | 检查对象 | 不匹配的状态码 |
|---|---|---|
consumes | 请求头 Content-Type | 415 |
produces | 请求头 Accept | 406 |
8.1.7 4.x 新增:API Versioning
Spring Framework 7 / Spring Boot 4.0 把 API 版本化做进了框架,不再需要自己解析 URL 或 Header。配置只需几行 application.yml:
spring:
mvc:
apiversion:
use:
header: X-API-Version # 从该请求头读取版本
default: "1.0" # 未带版本时的默认值
supported: "1.0, 1.1" # 支持的版本列表
控制器上,用 @RequestMapping 的 version 属性声明它服务哪个版本:
@RestController
@RequestMapping(path = "/api/books", version = "1.0")
public class BookV1Controller {
@GetMapping("/{id}")
public String getV1(@PathVariable Long id) {
return "v1:book-" + id;
}
}
@RestController
@RequestMapping(path = "/api/books", version = "1.1")
public class BookV11Controller {
@GetMapping("/{id}")
public String getV11(@PathVariable Long id) {
return "v1.1:book-" + id;
}
}
# 命中 1.1
curl -i -H "X-API-Version: 1.1" http://localhost:8080/api/books/42
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
v1.1:book-42
除了 header,spring.mvc.apiversion.use 还支持 path-segment(从指定下标的路径段取版本)、query-parameter、media-type-parameter 三种解析方式。若要更精细地控制,还可以注册 ApiVersionResolver、ApiVersionParser、ApiVersionDeprecationHandler 三类 bean。8.3 节会把三种版本化策略放在一起对比。
8.1.8 DispatcherServlet 在分发中的位置
请求从 Tomcat 到你的方法,中间要经过 DispatcherServlet。它继承自 HttpServlet,是 Spring MVC 的「前端控制器」:所有请求先到这里,再由它查路由表、找到方法、处理参数、写回响应。启动日志里能看到它:
2026-10-09T15:42:07.840+08:00 INFO 43496 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
一个请求的简化流程是:
客户端 → Tomcat(11.0) → DispatcherServlet
→ HandlerMapping 查路由(就是我们配的 @GetMapping 等)
→ HandlerAdapter 调用控制器方法
→ 返回值经 HttpMessageConverter 序列化成 JSON
→ 写回响应
本节关心的「路由映射」就发生在 HandlerMapping 这一步。至于拦截器、参数解析器、异常解析器如何在其中协作,属于高级卷的内容,入门阶段只需记住「DispatcherServlet 是所有请求的统一入口」即可。
小结
@RestController=@Controller+ 类级@ResponseBody,写 JSON 接口就用它。@GetMapping等五个快捷注解是@RequestMapping(method=…)的语法糖,本书统一使用快捷注解。- 最终路径 = 类级路径 + 方法级路径,多余斜杠会被规范化;重复映射会在启动时直接报错。
- 路径变量可用
{id:\\d+}加正则约束,注意 Java 字符串的转义。 - 路径不匹配返回 404,方法不匹配返回 405(响应带
Allow头);内容类型不匹配分别是 415 与 406。 - 4.x 内建 API Versioning:
spring.mvc.apiversion.*配解析方式,@RequestMapping(version = "1.0")声明版本。
路由骨架已经搭好,但方法里的参数还都是硬编码。下一节我们解决「数据怎么进来、怎么出去」——六种取参方式与 Jackson 3 序列化。
阅读导航:上一节:7.3 自动配置调试 · 下一节:8.2 请求参数与响应体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。