《Spring Boot 入门》8.1 控制器与路由映射

本节把「图书管理服务」的 HTTP 入口搭起来:分清 @Controller 与 @RestController(以及 @ResponseBody 的隐含关系),掌握 @RequestMapping 家族与类级、方法级路径拼接规则,用正则约束路径变量,并亲眼看到路径不匹配返回 404、HTTP 方法不匹配返回 405 的差别。最后演示 4.x 新增的 API Versioning 最小用法。

本节目标:把「图书管理服务」的 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/bookslist/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+}/abc404(无匹配映射)
/{id:\\d+}/42/extra404(多了一段)

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-Type415
produces请求头 Accept406

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 请求参数与响应体 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计