SpringBoot4 云端咖啡站 阶段一:起步与基础
第 0 章:总览与学习路线
本教程面向谁
如果你会一点 Java 基础语法(类、方法、集合),想用 Spring Boot 做 Web 后端------连数据库、写接口、做登录、发图片,最后还能接上 AI 大模型------但跟着传统教程总是"讲到一半发现缺前置知识"------那这份教程就是为你写的。
什么是零跳跃学习
传统路线的问题:知识点 A → 知识点 C(发现需要 B,回头补 B)→ 知识点 D(发现需要前置 X)。来回回补,学习曲线断断续续。
本路线的目标:知识点 A → 知识点 B(A 的自然延伸)→ 知识点 C(B 的必然发展)→ 知识点 D(C 的合理进阶)。每一步只引入一个新概念,且这个概念是你刚学完的内容自然引出的疑问。
举例:第 2 章我们把咖啡菜单硬编码在 Java 代码里 → "改个价格还要重新编译发布?数据该放哪?" → 第 3 章学配置文件 → "配置文件也存不下几千种商品啊" → 第 4 章自然过渡到 MySQL。每一章的结尾都是下一章的开头。
项目:云端咖啡站
我们要做一个咖啡店的线上点单后端,功能包括:
- 菜单管理:浏览咖啡菜单、按条件搜索、上架新品
- 在线下单:选饮品下单、自动扣库存、超时未支付自动取消
- 会员体系:注册登录(密码加密)、JWT 无状态认证
- 门店运营:上传咖啡美图、热门商品缓存加速、接口耗时监控
- AI 彩蛋:"不知道喝什么?"让大模型流式推荐一杯适合你的咖啡
技术栈:Spring Boot 4.1 + JDK 25 + Maven + MySQL 9 + MyBatis + JJWT + 智谱 AI(免费模型)。
业务是咖啡餐饮,不涉及学生或学校。
17 章学习路线图
| 章 | 主题 | 引出下一章的"自然疑问" |
|---|---|---|
| 01 | 初识 Spring Boot | 访问 8080 是 404,接口怎么写? |
| 02 | 第一个 REST 接口 | 价格写死在代码里,改价要重新编译? |
| 03 | 配置文件与多环境 | 数据量大、结构复杂,配置文件装不下? |
| 04 | 连接 MySQL 与 JdbcTemplate | SQL 混在 Controller 里太乱,代码怎么组织? |
| 05 | 三层架构与 IoC/DI | JDBC 样板代码到处重复,怎么消除? |
| 06 | MyBatis 注解版 | 复杂的动态查询条件怎么拼? |
| 07 | MyBatis XML 动态 SQL | 下单要扣库存,两步必须同生共死怎么办? |
| 08 | 事务管理 @Transactional | 入参脏数据、错误响应格式乱怎么办? |
| 09 | 参数校验与全局异常 | 想监控每个接口的耗时怎么办? |
| 10 | AOP 面向切面编程 | 部分接口需要登录才能访问怎么做? |
| 11 | 认证:Session 到 JWT | 咖啡图片存在哪? |
| 12 | 文件上传与下载 | 热门菜单每次都查库太慢怎么办? |
| 13 | Spring Cache 缓存 | 定时下架活动品、超时订单谁管? |
| 14 | 定时任务与异步 | 怎么保证改动不破坏旧功能? |
| 15 | 测试 | 写好的服务怎么交付运行? |
| 16 | 打包部署与监控 | 咖啡站还能更聪明吗? |
| 17 | AI 推荐(RestClient + SSE)彩蛋 | --- |
学完你能掌握什么
- Spring Boot 项目结构、自动配置原理、内嵌服务器
- RESTful 接口设计、参数校验、统一响应与全局异常
- HikariCP 连接池、JdbcTemplate 到 MyBatis 的完整数据访问演进
- 事务与传播行为、AOP 动态代理
- Session 与 JWT 两种认证方案、文件上传、缓存、定时任务与异步
- 单元测试、打包部署、Actuator 监控
- RestClient 调用大模型 API 与 SSE 流式输出
项目结构
sb-claude/
├── pom.xml # Maven 配置:依赖一次给全(注释标明每章用途)
├── docs/ # 教程文档(本目录)
│ ├── 00-总览与学习路线.md
│ ├── 01-初识SpringBoot.md
│ └── ...
└── src/
├── main/java/com/lihaozhe/
│ ├── chapter01/ # 每章一个独立包
│ │ └── CoffeeApplication.java # 本章启动类(只扫描本章包)
│ ├── chapter02/
│ └── ...
└── main/resources/
├── application.yaml # 全局配置:公共段 + 各章 profile 段
├── chapter04/schema.sql # 每章自带建表脚本(幂等可重复执行)
├── chapter04/data.sql # 每章自带初始数据
└── ...
为什么每章独立成包?
- 互不干扰 :
@SpringBootApplication默认只扫描"启动类所在包及子包"。chapter01 的启动类看不到 chapter02 的类,各章完全隔离。 - 对照学习:第 4 章 JdbcTemplate 版和第 6 章 MyBatis 版可以随时切换对比。
- 渐进重构:你能亲眼看到同一个咖啡店功能从"能跑"到"优雅"的完整演进过程,而不是一上来就是最终形态。
环境准备
你需要:
- JDK 17 及以上 (本项目用 JDK 25)------
java -version能正常输出 - Maven 3.9+ ------
mvn -version能正常输出;国内网络建议配阿里云镜像 - 一个 MySQL 数据库 (8.0+ 或 9.x)------ 本地安装或远程均可;连接信息写在
application.yaml
检查环境:
bash
java -version # 应 >= 17
mvn -version # 应 >= 3.9
所有代码文件均使用 UTF-8 字符集(pom.xml 已统一设置编译编码)。
如果 Windows 控制台中文乱码,启动时加 JVM 参数
-Dfile.encoding=UTF-8。
学习方法
- 按顺序读:章节环环相扣,跳章就会"跳跃"
- 自己敲:每章文档给出全部源码原文与逐行注释,合上文档能独立写出来才算掌握
- 跑起来:每章有"运行验证"步骤,给出 curl 命令和预期输出;代码必须真实运行过
如何运行某一章
每章的启动类都在自己的包里,运行方式一致:
bash
# 在项目根目录 sb-claude/ 下执行
mvn compile
mvn exec:java -Dexec.mainClass=com.lihaozhe.chapterNN.XxxApplication
或者在 IDEA / VS Code 中直接点击某章启动类的绿色三角运行。
看到日志输出类似下面这行,就说明该章应用启动成功:
Started CoffeeApplication in 2.xxx seconds (process running for ...)
第 1 章:初识 Spring Boot 与项目骨架
本章目标
- 理解 Spring Boot 解决了什么问题,它和"传统 Spring"的区别
- 看懂 pom.xml 的每一个部分
- 理解 @SpringBootApplication 启动时发生了什么
- 成功启动一个"空壳"应用,并理解为什么访问 8080 是 404
知识点讲解
什么是 Spring Boot
Spring 框架 的核心能力是 IoC 容器(第 5 章详解):把对象的创建和组装交给框架管理。但传统 Spring 项目要写大量 XML 配置或 Java 配置类、自己选十几个依赖的版本号、自己装部署 Tomcat------入门门槛极高。
Spring Boot = Spring + "约定优于配置"。它做了三件事:
- 起步依赖(starter) :
spring-boot-starter-webmvc一个依赖打包了 Spring MVC + JSON + 内嵌 Tomcat,版本号由官方统一仲裁,绝不冲突 - 自动配置:检测到 classpath 里有 Web 依赖,就自动帮你配好 Spring MVC;检测到 MySQL 驱动 + 数据源配置,就自动建好连接池
- 内嵌服务器 :Tomcat 直接嵌在 jar 包里,
main()一跑就是 Web 服务,不需要单独安装 Tomcat、打 war 包
一句话:Spring 让你不用 new 对象,Spring Boot 让你连配置都少写。
Boot 4 提示:
本教程使用 2026 年最新的 Spring Boot 4.1,底层是 Spring Framework 7、要求 JDK 17+。
网上老教程基于 Boot 2.x/3.x,很多写法已过时(如 starter-web 改名 starter-webmvc),跟着老教程抄代码会报错------这正是本教程存在的意义。
Maven 与 pom.xml
Maven 是 Java 世界的构建工具:编译、测试、打包一条龙,并负责从中央仓库下载依赖。
pom.xml(Project Object Model)是项目的"说明书",核心是三块:
- parent :继承 spring-boot-starter-parent 后,几百个常用依赖的版本号由官方锁定(术语叫"依赖管理"),你写依赖时不写
<version>也不会冲突 - properties:全局属性,如编码 UTF-8、Java 版本
- dependencies:项目用到哪些依赖;每个 dependency 注释标明"哪一章使用"
@SpringBootApplication 三合一
java
@SpringBootApplication
public class CoffeeApplication {
public static void main(String[] args) {
SpringApplication.run(CoffeeApplication.class, args);
}
}
这个注解是三个注解的组合:
| 组成 | 作用 |
|---|---|
| @SpringBootConfiguration | 本类是个配置类(Spring 容器的"装配说明书") |
| @EnableAutoConfiguration | 开启自动配置:classpath 有 webmvc → 配好 MVC;有 mysql 驱动但没配 url?启动直接报错提醒你(第 4 章配好) |
| @ComponentScan | 扫描"本类所在包及子包"下带 @Component/@Controller 等注解的类,注册进容器 |
这就是每章独立成包的原理 :chapter01 的启动类在 com.lihaozhe.chapter01 包,只扫描这个包,看不到 chapter02 的代码------章节天然隔离。
内嵌 Tomcat 是什么感觉
传统方式:写代码 → 打 war 包 → 拷贝到独立安装的 Tomcat/webapps → 启动 Tomcat。
Spring Boot 方式:main() 方法一跑,日志里 Tomcat 已经在 8080 端口监听了。
服务器成了应用的"一个库",而不是应用的"宿主机"。开发、调试、部署全部简化。
完整代码(最终版)
本章共 3 个文件:pom.xml、application.yaml、CoffeeApplication.java。
pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- ==================== 父工程:Spring Boot ====================
parent 指向 spring-boot-starter-parent 后,所有 Spring Boot 官方依赖的版本
都由它统一管理,我们写依赖时不需要再写 <version>。
这就是"依赖版本仲裁":避免你自己拼凑版本号导致不兼容。 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<!-- 2026 年 8 月最新稳定版;4.x 要求 JDK 17+,本项目用 JDK 25 -->
<version>4.1.1</version>
<relativePath/> <!-- 声明父工程不在本地目录,去远程仓库找 -->
</parent>
<!-- ==================== 项目基本信息 ====================
groupId+artifactId+version 三元组唯一标识一个 Maven 工程(GAV 坐标) -->
<groupId>com.lihaozhe</groupId>
<artifactId>sb-claude</artifactId>
<version>1.0.0</version>
<name>sb-claude</name>
<description>云端咖啡站 - Spring Boot 4 零跳跃教程项目</description>
<!-- ==================== 全局属性 ==================== -->
<properties>
<!-- 统一字符集:全部源码、配置文件、文档均使用 UTF-8 -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- Java 版本:parent 会用它设置编译器的 source/target/release -->
<java.version>25</java.version>
<!-- ===== Boot 官方 BOM 未覆盖的第三方依赖版本,在这里集中声明 ===== -->
<!-- MyBatis 官方适配 Spring Boot 4 的 starter(4.1.0 对应 Boot 4.1 线) -->
<mybatis-spring-boot.version>4.1.0</mybatis-spring-boot.version>
<!-- springdoc-openapi v3 才兼容 Boot 4(v2 只支持到 Boot 3) -->
<springdoc.version>3.1.0</springdoc.version>
<!-- JJWT:JWT 生成与解析(第 11 章) -->
<jjwt.version>0.13.0</jjwt.version>
</properties>
<!--
==================== 依赖清单(一次给全,后续章节不再改 pom) ====================
每个 dependency 注释标明"哪一章使用",提前引入不会影响运行------
starter 只是"依赖集合",没用到里面的类就不会被加载。
-->
<dependencies>
<!--
【第 01~17 章】Web MVC 支持:
内嵌 Tomcat + Spring MVC + JSON 序列化。
注意:Boot 4 把原来的 spring-boot-starter-web 改名为
spring-boot-starter-webmvc(旧名在 4.x 中已移除)。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!--
【第 04~14 章】数据库 JDBC 支持:
自动配置 HikariCP 连接池 + JdbcTemplate。
注意:引入它但没配数据源时启动会报错,第 01~03 章
通过 application.yaml 的 autoconfigure-exclude 排除相关自动配置。
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--
【第 04~16 章】MySQL 驱动:
版本由 Boot BOM 管理;scope=runtime 表示"只在运行时加载,
编译期代码不允许直接 import 驱动类"(面向接口编程)。
-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 【第 06~08 章】MyBatis 整合 Starter:自动扫描 @Mapper 接口、生成动态代理实现类 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<!-- 【第 09 章】参数校验:提供 @NotNull/@NotBlank/@Size 等 jakarta.validation 注解的实现 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--
【第 10 章】AOP 支持:
AspectJ 注解(@Aspect/@Around...)+ Spring AOP 动态代理。
注意:Boot 4 把原来的 spring-boot-starter-aop 改名为
spring-boot-starter-aspectj,名字更直白------它引入的就是
spring-aop + aspectjweaver 这两个包。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<!--
【第 11 章】Spring Security 的加密模块
(只取 BCrypt 密码哈希,不引入整套 Security 过滤器链------那是另一门课的内容)
-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- 【第 11 章】JJWT 三件套:api(编译用接口)/ impl(运行时实现)/ jackson(JSON 序列化) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- 【第 02~17 章】API 文档与在线调试:启动后访问 /swagger-ui.html 可视化调试所有接口 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 【第 16 章】生产监控端点:/actuator/health、/actuator/info 等 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!--
【全章节可选】Lombok:注解生成 getter/setter/构造器,减少样板代码;
scope=provided:只在编译期生效,不打进 jar 包
-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<scope>provided</scope>
</dependency>
<!-- 【第 15 章】测试支持:JUnit 5 + Mockito + MockMvc + 断言库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ==================== 构建插件 ==================== -->
<build>
<plugins>
<!--
repackage 插件:把普通 jar 打成可执行的 "fat jar"
(内含全部依赖 + 内嵌 Tomcat,java -jar 直接运行,第 16 章实战)
-->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 排除 Lombok:provided 依赖无需进入 fat jar -->
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
逐段说明:
parent:继承官方父工程,获得全部依赖版本仲裁与默认插件配置properties里的java.version=25:Boot 父工程会读取它设置编译器目标版本dependencies:所有章的依赖一次给全,避免后续章节频繁改 pom;没被用到的 starter 只是"躺在 classpath 里",不影响运行scope=runtime:编译期不可见、运行期生效------适合数据库驱动这类"只被框架反射加载"的库scope=provided / test:分别表示"编译期提供、不打进包"和"仅测试期生效"spring-boot-maven-plugin:普通mvn package打出的 jar 没有 Main-Class 入口信息;这个插件的 repackage 操作会把启动信息和全部依赖重新打进 fat jar
src/main/resources/application.yaml
yaml
# ============================================================
# 云端咖啡站 - Spring Boot 4 教程项目 全局配置文件
#
# 结构说明(多章共存的关键):
# 第一个文档是"公共段"------所有章节共享。
# 之后每个 "---" 分隔的文档是一个 Profile 段,通过
# spring.config.activate.on-profile: chNN 声明归属章节;
# 章节的启动类用 setAdditionalProfiles("chNN") 激活自己的段。
# 每个应用启动时 = 公共段 + 自己的 chNN 段 叠加生效。
# ============================================================
spring:
application:
# 应用名:日志与监控里标识本应用
name: sb-claude
# 【第 01~03 章专用】pom 引入了 starter-jdbc,但这两章还没有数据库配置,
# 必须排除数据源与 MyBatis 的自动配置,否则启动时 HikariCP 找不到 url 直接报错。
# (第 04 章配好数据源后,各章 profile 段会覆盖此排除列表。)
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
- org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration
- org.springframework.boot.jdbc.autoconfigure.health.DataSourceHealthContributorAutoConfiguration
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
# Web 服务器:内嵌 Tomcat 监听端口
server:
port: 8080
逐行说明:
-
spring.application.name:应用名,会出现在日志和监控中 -
spring.autoconfigure.exclude:黑名单机制。自动配置是"看到什么配什么"------starter-jdbc 把 DataSource 自动配置类带进了 classpath,它发现没有数据源 url 就抛异常拒绝启动(连 Actuator 的数据库健康检查也会跟着报错)。
显式排除这三个自动配置后,第 1~3 章"没有数据库"也能正常启动。这是理解自动配置机制的绝佳案例
Boot 4 注意 :这些类的包名从 Boot 3 的
org.springframework.boot.autoconfigure.jdbc.*迁移到了org.springframework.boot.jdbc.autoconfigure.*。写错包名时启动会提示 "cannot exclude class",照着日志改即可 -
server.port=8080:内嵌 Tomcat 的监听端口
src/main/java/com/lihaozhe/chapter01/CoffeeApplication.java
java
package com.lihaozhe.chapter01;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 01 章:云端咖啡站 ------ 项目启动入口。
*
* <p>@SpringBootApplication 是一个"三合一"组合注解:</p>
* <ul>
* <li>@SpringBootConfiguration:声明这是一个配置类(本质是 @Configuration)</li>
* <li>@EnableAutoConfiguration:开启自动配置------根据 classpath 里有什么依赖,
* 自动帮你装配对应的 Bean(比如看到 starter-webmvc 就配好 Tomcat + Spring MVC)</li>
* <li>@ComponentScan:自动扫描"本类所在包及其子包"下的所有组件。
* 所以每章的启动类必须放在 com.lihaozhe.chapterNN 包里,
* 只扫描本章自己的代码,实现章节间完全隔离</li>
* </ul>
*/
@SpringBootApplication
public class CoffeeApplication {
/**
* main 方法是整个应用的入口:
* 把 CoffeeApplication 类交给 SpringApplication,由它启动内嵌 Tomcat
* 并初始化 Spring 容器,之后应用就常驻运行、等待 HTTP 请求。
*/
public static void main(String[] args) {
SpringApplication.run(CoffeeApplication.class, args);
}
}
逐行说明:
package com.lihaozhe.chapter01;:包路径决定组件扫描范围(见上)@SpringBootApplication:三合一注解(见"知识点讲解"表格)SpringApplication.run(...):启动引导方法------创建 IoC 容器 → 执行自动配置 → 启动内嵌 Tomcat → 应用常驻
运行验证
第 1 步:编译项目
在项目根目录(pom.xml 所在目录)执行:
bash
mvn compile
第一次运行会从远程仓库下载依赖,需要几分钟;看到 BUILD SUCCESS 即成功。
第 2 步:启动应用
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter01.CoffeeApplication
或在 IDEA 中直接运行 CoffeeApplication 的 main 方法。
预期日志(节选):
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
...
Started CoffeeApplication in 2.xxx seconds (process running for ...)
Tomcat started on port 8080 (http) with context path '/'
第 3 步:验证 404(本章最重要的实验)
浏览器或 curl 访问:
bash
curl -i http://localhost:8080/menu
预期输出:
HTTP/1.1 404
Content-Type: application/json
{"timestamp":..., "status":404, ...}
为什么是 404? Tomcat 和 Spring MVC 都已经就位,但我们的代码里还没有任何一个接口!这个 404 正是下一章要解决的问题。
验证完毕,按 Ctrl+C 或点击红色方块停止应用。
常见坑 :8080 端口被占用时启动报错
Port 8080 was already in use。Windows 下排查占用进程:
netstat -ano | findstr :8080,找到 PID 后taskkill /F /PID xxx。
常见坑
| 现象 | 原因与解决 |
|---|---|
| mvn 命令不存在 | Maven 未安装或未加 PATH;重配环境变量 |
| 依赖下载超时 | 国内网络问题;给 Maven 配置阿里云镜像 |
启动报 Failed to configure a DataSource |
application.yaml 里漏了 autoconfigure.exclude 排除列表 |
| 控制台中文乱码 | 加 JVM 参数 -Dfile.encoding=UTF-8 |
自测题
- Spring Boot 相比传统 Spring 解决了哪三类痛点?
@SpringBootApplication由哪三个注解组成,各自的作用是什么?- 为什么 chapter01 的启动类"看不见" chapter02 的代码?
- pom.xml 中
scope=runtime和scope=test分别是什么含义? - 本章为什么必须在 yaml 中排除 DataSource 自动配置?不做会发生什么?
- 访问 http://localhost:8080/menu 返回 404,说明哪些东西已经就绪?
下一章预告
服务器起来了,但没有任何接口------404 就是最好的证明。
下一章我们就写出咖啡站的第一批 REST 接口:查菜单、查单品详情,并顺便解决"怎么让接口文档自动生成"的问题。
第 2 章:第一个 REST 接口
本章目标
- 理解 REST 风格与 HTTP 动词(GET/POST/DELETE)
- 掌握 @RestController、@GetMapping/@PostMapping/@DeleteMapping
- 掌握 @PathVariable、@RequestParam、@RequestBody 三种参数绑定方式
- 学会用 springdoc 自动生成的 Swagger 页面调试接口
- 理解 JSON 序列化:对象怎么变成响应体里的 JSON(Jackson 3)
上一章我们启动了应用但访问是 404------因为没有任何接口。本章就补上咖啡站的第一批接口。
知识点讲解
REST 是什么
REST 是一种接口设计风格,核心思想:用 URL 表示资源,用 HTTP 方法表示操作。
| 操作 | 方法 + URL | 含义 |
|---|---|---|
| 查列表 | GET /api/menu | 获取全部饮品 |
| 条件查 | GET /api/menu?keyword=拿铁 | 按名称搜索 |
| 查详情 | GET /api/menu/1 | 获取 id=1 的饮品 |
| 新增 | POST /api/menu | 上架新品 |
| 删除 | DELETE /api/menu/1 | 下架饮品 |
GET 是"读"(安全、幂等),POST 是"写",DELETE 是"删"。URL 里只出现名词(menu),动词交给 HTTP 方法表达------这就是"RESTful"。
@RestController 与路由注解
java
@RestController // 本类所有方法返回值直接写进响应体(自动转 JSON)
@RequestMapping("/api/menu") // 类级前缀:本类所有接口以 /api/menu 开头
public class MenuController {
@GetMapping // GET /api/menu → 这个方法
public List<Coffee> list(...) { ... }
@GetMapping("/{id}") // GET /api/menu/123 → 这个方法
public Coffee detail(@PathVariable Long id) { ... }
@PostMapping // POST /api/menu → 这个方法
public Coffee create(@RequestBody Coffee coffee) { ... }
@DeleteMapping("/{id}") // DELETE /api/menu/123 → 这个方法
public String remove(@PathVariable Long id) { ... }
}
Spring MVC 的请求分发流程(理解了它,后面所有注解都好懂):
浏览器请求 GET /api/menu/1
↓
内嵌 Tomcat 收到请求,转交给 DispatcherServlet(Spring MVC 的总调度)
↓
DispatcherServlet 查"路由表":哪个类的哪个方法匹配 GET /api/menu/{id}?
↓ 找到 MenuController.detail()
参数绑定:把 URL 里的 1 填给 Long id 参数
↓
执行方法体,得到返回值 Coffee 对象
↓
消息转换器(HttpMessageConverter)把对象序列化成 JSON 写入响应体
SpringMVC 完整执行流程:https://blog.csdn.net/qq_24330181/article/details/164043420

三种参数绑定
| 注解 | 从哪取值 | 示例 |
|---|---|---|
@PathVariable |
URL 路径占位符 {id} |
/api/menu/5 → id=5 |
@RequestParam |
URL 问号后的查询串 | /api/menu?keyword=拿铁 → keyword="拿铁" |
@RequestBody |
请求体里的 JSON | POST 的 body {"name":"冷萃",...} |
Jackson 3:JSON 序列化的新包名(Boot 4 重要变化)
返回的 Coffee 对象是谁变成 JSON 的?答案是 Jackson 。Boot 4 升级到了 Jackson 3:
- 包名从老的
com.fasterxml.jackson.**迁移到 **tools.jackson.** - Boot 3 教程里让你 import
com.fasterxml.jackson.databind.ObjectMapper,在 Boot 4 里要 importtools.jackson.databind.ObjectMapper
日常开发你几乎感知不到它存在(框架自动完成序列化),但一旦需要自定义 JSON 行为(比如日期格式),import 错包名就是 Boot 4 最常见的报错来源。本章不需要手动用它------记住这个变化即可。
顺带一提:Spring Framework 7 还引入了 JSpecify 空安全注解和内置 API 版本化能力,属于进阶话题,用到时再查官方文档即可。
springdoc-openapi:自动接口文档
pom 里引入的 springdoc-openapi-starter-webmvc-ui 会扫描所有 @RestController,自动生成两样东西:
/v3/api-docs:机器可读的 OpenAPI JSON/swagger-ui/index.html:可视化调试页面(老版本入口是 /swagger-ui.html,会重定向到这里)
开发期打开 Swagger 页面点一点就能测试接口,比 curl 直观;但它只是调试工具------本教程仍给出 curl 命令保证可复现。
完整代码(最终版)
本章共 4 个新文件。application.yaml 和 pom.xml 与第 1 章完全相同(无需修改)。
src/main/java/com/lihaozhe/chapter02/Coffee.java
java
package com.lihaozhe.chapter02;
import java.math.BigDecimal;
/**
* 第 02 章:咖啡饮品实体类(纯 Java,还没有数据库)。
*
* <p>为什么价格用 BigDecimal 而不是 double?</p>
* double 是二进制浮点数,算钱会出精度问题:
* 0.1 + 0.2 的结果是 0.30000000000000004。金额必须用 BigDecimal。
*/
public class Coffee {
/** 商品编号 */
private Long id;
/** 饮品名称,如 "拿铁" */
private String name;
/** 价格(元) */
private BigDecimal price;
/** 简介:一句话描述风味 */
private String description;
public Coffee() {
// Jackson 把 JSON 转成对象时需要一个无参构造器
}
public Coffee(Long id, String name, BigDecimal price, String description) {
this.id = id;
this.name = name;
this.price = price;
this.description = description;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
逐行说明:
BigDecimal price:金额类型铁律;构造时用字符串"28.00"而不是28.00,避免先经过 double 再损失精度- 无参构造器:Jackson 反序列化(JSON→对象)的反射机制需要它;有参构造器则方便代码里手工 new
- getter/setter:Jackson 序列化默认按 getter 方法找属性名(getName → "name")。第 5 章我们会用 Lombok 消掉这些样板
src/main/java/com/lihaozhe/chapter02/InMemoryCoffeeRepository.java
java
package com.lihaozhe.chapter02;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;
/**
* 第 02 章:内存版菜单仓库 ------ 数据暂时存在 JVM 的 List 里。
*
* <p>本章的"数据访问"是最朴素的形态:一个 List。
* 它的缺陷(重启丢数据、无法持久化)正是第 04 章引入 MySQL 的理由。</p>
*
* <p>@Repository 是 @Component 的"语义化版本":告诉 Spring
* "这个类负责数据访问"。目前它只是标记,第 5 章会真正发挥分层作用。</p>
*/
@Repository
public class InMemoryCoffeeRepository {
/** AtomicLong:线程安全的自增计数器,用来生成自增主键 id(模拟数据库的自增列) */
private final AtomicLong idGenerator = new AtomicLong(3);
/** Tomcat 为每个请求分配一个线程,多个请求可能同时读写这份菜单,
* 所以每个方法都加 synchronized:同一时刻只有一个线程能改 List
*/
private final List<Coffee> coffees = new ArrayList<>(List.of(
new Coffee(1L, "拿铁", new BigDecimal("28.00"), "浓缩咖啡与蒸汽牛奶的经典组合"),
new Coffee(2L, "美式", new BigDecimal("22.00"), "浓缩咖啡加热水,清爽纯粹"),
new Coffee(3L, "燕麦白", new BigDecimal("32.00"), "燕麦奶与浓缩的丝滑碰撞")));
/** 查全部菜单(按 id 升序返回新列表,防止外部修改内部数据) */
public synchronized List<Coffee> findAll() {
return coffees.stream()
.sorted((a, b) -> Long.compare(a.getId(), b.getId()))
.toList();
}
/** 按 id 查单品;Optional 明确表达"可能查不到",逼调用方处理空值 */
public synchronized Optional<Coffee> findById(Long id) {
return coffees.stream().filter(c -> c.getId().equals(id)).findFirst();
}
/** 新增饮品;id 由仓库生成,调用方不用管 */
public synchronized Coffee save(Coffee coffee) {
coffee.setId(idGenerator.incrementAndGet());
coffees.add(coffee);
return coffee;
}
/** 按 id 删除;返回是否真的删掉了(false = id 不存在) */
public synchronized boolean deleteById(Long id) {
return coffees.removeIf(c -> c.getId().equals(id));
}
}
逐行说明:
AtomicLong idGenerator:incrementAndGet()先加 1 再返回新值,天然线程安全,等价于数据库自增主键的行为synchronized:Tomcat 默认 200 个工作线程并发处理请求;不加锁的话两个请求同时 add 会破坏 ArrayListOptional<Coffee>:Java 8 引入的"可能为空的容器"。findById返回 Optional 强迫调用方显式处理"查不到"的情况,而不是运行时才 NullPointerExceptiontoList():Stream 收集为不可变 List(Java 16+),防止调用方改内部状态
src/main/java/com/lihaozhe/chapter02/MenuController.java
java
package com.lihaozhe.chapter02;
import java.math.BigDecimal;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 02 章:菜单接口 ------ 咖啡站的第一批 REST API。
*
* <p>@RestController = @Controller + @ResponseBody:
* 本类所有方法的返回值直接写入 HTTP 响应体(自动转 JSON),
* 而不是当作"视图页面名"去跳转------前后端分离项目的标准选择。</p>
*/
@RestController
@RequestMapping("/api/menu") // 类级前缀:本类所有接口都以 /api/menu 开头
public class MenuController {
private final InMemoryCoffeeRepository repository;
/**
* 构造器注入:Spring 自动把 InMemoryCoffeeRepository 的实例传进来。
* (这里先"照着用",第 5 章会讲透 IoC 与依赖注入的原理。)
*/
public MenuController(InMemoryCoffeeRepository repository) {
this.repository = repository;
}
/**
* GET /api/menu → 返回全部饮品
* GET /api/menu?keyword=拿铁 → 按名称模糊搜索
*
* @RequestParam 从 URL 查询串取值;required=false 表示可省略,
* 省略时 keyword 为 null,代表"不过滤"。
*/
@GetMapping
public List<Coffee> list(@RequestParam(required = false) String keyword) {
List<Coffee> all = repository.findAll();
if (keyword == null || keyword.isBlank()) {
return all; // 没有关键字就全量返回
}
// filter + contains 实现模糊匹配(真实项目里对应 SQL 的 LIKE)
return all.stream()
.filter(c -> c.getName().contains(keyword))
.toList();
}
/**
* GET /api/menu/{id} → 查单品详情
*
* @PathVariable 把 URL 路径里的 {id} 占位符绑定到方法参数。
* 查不到时抛出 IllegalArgumentException,由框架兜底返回 500(第 9 章统一处理)。
*/
@GetMapping("/{id}")
public Coffee detail(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("咖啡不存在: id=" + id));
}
/**
* POST /api/menu → 新增饮品
*
* @RequestBody 把请求体的 JSON 自动反序列化成 Coffee 对象。
* 参数校验第 9 章补上,本章保持简单。
*/
@PostMapping
public Coffee create(@RequestBody Coffee coffee) {
if (coffee.getName() == null || coffee.getName().isBlank()) {
throw new IllegalArgumentException("饮品名称不能为空");
}
BigDecimal price = coffee.getPrice();
if (price == null || price.signum() < 0) { // signum()<0 表示负数
throw new IllegalArgumentException("价格必须为非负数");
}
return repository.save(coffee);
}
/**
* DELETE /api/menu/{id} → 下架饮品
* 删除成功返回 200 + 提示文案(更 REST 的做法是 204 无内容,教学从简)。
*/
@DeleteMapping("/{id}")
public String remove(@PathVariable Long id) {
boolean removed = repository.deleteById(id);
if (!removed) {
throw new IllegalArgumentException("咖啡不存在: id=" + id);
}
return "已下架 id=" + id;
}
}
src/main/java/com/lihaozhe/chapter02/CoffeeApplication2.java
java
package com.lihaozhe.chapter02;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第 02 章:菜单接口章启动类。
*
* <p>与第 01 章的 CoffeeApplication 结构完全相同------
* 每章一个独立启动类,包扫描范围只覆盖本章代码,章节间互不干扰。</p>
*
* <p>命名带数字后缀是为了与其它章的启动类区分(同工程内类名不能重复)。</p>
*/
@SpringBootApplication
public class CoffeeApplication2 {
public static void main(String[] args) {
SpringApplication.run(CoffeeApplication2.class, args);
}
}
运行验证
第 1 步:编译并启动
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter02.CoffeeApplication2
预期日志:
Started CoffeeApplication2 in 2.xxx seconds
Tomcat started on port 8080 (http) with context path '/'
第 2 步:curl 逐个验证(另开一个终端)
bash
# ① 查全部菜单
curl http://localhost:8080/api/menu
预期输出:
json
[{"id":1,"name":"拿铁","price":28.00,"description":"浓缩咖啡与蒸汽牛奶的经典组合"},{"id":2,"name":"美式","price":22.00,"description":"浓缩咖啡加热水,清爽纯粹"},{"id":3,"name":"燕麦白","price":32.00,"description":"燕麦奶与浓缩的丝滑碰撞"}]
bash
# ② 按关键字搜索(URL 中文需 URL 编码,%E6%8B%BF%E9%93%81 即"拿铁")
curl "http://localhost:8080/api/menu?keyword=%E6%8B%BF%E9%93%81"
预期输出(只含拿铁一条):
json
[{"id":1,"name":"拿铁","price":28.00,"description":"浓缩咖啡与蒸汽牛奶的经典组合"}]
bash
# ③ 查单品详情
curl http://localhost:8080/api/menu/1
预期输出:{"id":1,"name":"拿铁","price":28.00,...}
bash
# ④ 新增饮品(中文写进临时文件避免控制台编码干扰)
printf '{"name":"\xe5\x86\xb7\xe8\x90\x83","price":26.5,"description":"\xe4\xbd\x8e\xe6\xb8\xa9\xe6\x85\xa2\xe8\x90\x83"}' > coffee.json
curl -X POST http://localhost:8080/api/menu -H "Content-Type: application/json; charset=utf-8" --data-binary @coffee.json
预期输出(注意 id 自动生成为 4):{"id":4,"name":"冷萃","price":26.5,"description":"低温慢萃"}
bash
# ⑤ 下架刚上架的
curl -X DELETE http://localhost:8080/api/menu/4
预期输出:已下架 id=4
bash
# ⑥ 查不存在的 id ------ 观察错误行为
curl -i http://localhost:8080/api/menu/99
预期:HTTP 状态码 500,body 是框架默认的错误 JSON。
发现了吗? ⑥ 很不友好:明明是"客户端传错 id",却返回了 500 Internal Server Error。而且每次调用的返回格式都不统一(有时是数组、有时是对象、有时是一句话)。这两个问题第 9 章统一解决------先记下这个不爽。
第 3 步:打开 Swagger 页面(可选)
浏览器访问 http://localhost:8080/swagger-ui/index.html,能看到 4 个接口的可视化文档,点击 "Try it out" 可以在页面上直接发请求调试。
验证完毕,Ctrl+C 停止应用。
常见坑
| 现象 | 原因与解决 |
|---|---|
| POST 报 400 JSON parse error: Invalid UTF-8 | Windows 终端把中文按 GBK 发出去了;中文 JSON 先写文件再 --data-binary @file |
| 404 但路径没拼错 | 类上少了 @RestController 或方法上没有映射注解;检查 DispatcherServlet 是否扫到 |
| 返回的 JSON 字段名不对 | Jackson 按 getter 推断字段名;检查 getName/getPrice 是否规范 |
| swagger-ui/index.html 打不开 | 确认 pom 里有 springdoc 依赖且重新编译过 |
自测题
- REST 风格里"URL 表示什么、HTTP 方法表示什么"?DELETE /api/menu/1 在表达什么语义?
- @PathVariable、@RequestParam、@RequestBody 分别从哪里取值?
- 一个请求从 Tomcat 到返回 JSON,中间经过哪几步?(DispatcherServlet 的角色是什么)
- 为什么实体类必须保留无参构造器?
- Boot 4 的 Jackson 3 与老版本的包名有什么区别?
- 价格为什么必须用 BigDecimal?"28.00" 和 28.00 作为构造参数有什么区别?
- 本章的 InMemoryCoffeeRepository 有哪些致命缺陷?
下一章预告
现在菜单写死在 Java 代码里:改个价格要改代码、重新编译、重启服务------这在真实生意里不可接受。有些东西确实适合放配置文件(比如店名、营业时间)。下一章学习 application.yaml 配置体系:怎么把配置读进 Java 代码、怎么区分开发/生产环境。
第 3 章:配置文件与多环境
本章目标
- 掌握 application.yaml 的语法规则(缩进、冒号、多文档段)
- 用 @Value 读取单个配置、用 @ConfigurationProperties 绑定整组配置
- 理解 Profile 多环境机制:一套代码,开发/生产两套配置
- 认识 Spring Boot 的配置加载优先级(命令行 > 配置文件)
上一章结束时的问题:菜单价格写死在 Java 代码里,改个价要重新编译发版。
本章先把"会变的东西"挪到配置文件------门店名、营业时间、折扣这些运营参数,从此改配置就能生效(重启后)。
知识点讲解
yaml 语法:用缩进表达层级
application.yaml 是 Spring Boot 的默认配置文件(也支持 .properties,但 yaml 层级更清晰):
yaml
spring:
application:
name: sb-claude # 三层结构:spring.application.name
server:
port: 8080 # server.port
四条铁律:
- 冒号后面必须有空格:
name: 云端咖啡站(name:云端咖啡站会解析失败) - 同级对齐用空格缩进(不许 Tab),一般 2 格
- 同一个段落里 key 不能重复------重复了应用直接起不来(本章运行验证里有真实翻车案例)
- 字符串通常不用加引号;含特殊字符时加单/双引号
配置读取的两种方式
| 方式 | 写法 | 适用场景 |
|---|---|---|
| @Value | @Value("${store.hours}") |
零散的一两个值 |
| @ConfigurationProperties | 类上声明前缀,字段按名绑定 | 一组相关的业务配置 |
@Value 要点:
java
@Value("${store.hours:未知}")
private String hours; // "冒号+默认值"语法:配置缺失时用默认值而不是报错
@ConfigurationProperties 要点:
yaml
store: # 前缀
name: 云端咖啡站 # store.name → StoreProperties.name 字段
discount: 0.88 # 自动转 double(类型转换框架做)
java
@Component
@ConfigurationProperties(prefix = "store")
public class StoreProperties {
private String name; // 名字对得上就自动注入
private double discount;
// 必须有 setter!绑定靠 setter 完成
}
它还支持松散绑定 :yaml 里写 env-name 能映射到 Java 字段 envName(yaml 惯例小写加连字符,Java 惯例驼峰,框架帮你对齐)。
理论:为什么需要两种方式?
@Value 本质是"占位符求值"------启动时把
${...}替换成 Environment 里的字符串值,轻量但零散。@ConfigurationProperties 则是把一组配置"建模成一个对象",配合类型转换和校验注解,是业务配置的正规军。企业规范:同一业务域的配置一律走 @ConfigurationProperties。
Profile 多环境
真实项目至少两套环境:开发(dev)连本地库、生产(prod)连线上库。Spring Boot 的方案是 Profile 段 ------一个文件里用 --- 切成多个文档,每段声明自己属于哪个 profile:
yaml
---
spring:
config:
activate:
on-profile: ch03 # 这段只在激活 ch03 时生效
store:
env-name: 开发环境(默认)
---
spring:
config:
activate:
on-profile: ch03-prod # 这段只在激活 ch03-prod 时生效
store:
env-name: 生产环境(ch03-prod)
激活方式与覆盖顺序:
java -jar app.jar --spring.profiles.active=ch03,ch03-prod
↑ 同时激活两个,逗号分隔,后面的覆盖前面同名 key
结果:env-name = 生产环境的值。这就是"一套代码、多套配置"。
本项目因为 17 章共用一个 yaml,约定:每章启动类在 main 里激活自己的 profile (app.setAdditionalProfiles("ch03")),各章配置互不可见。
配置从哪里来?优先级
同一 个 key 可能在多处定义,Spring Boot 按优先级取值(高→低):
- 命令行参数
--store.hours=24h - java:comp 里的 JNDI(几乎不用)
- 操作系统环境变量(容器化部署常用)
- application-{profile}.yaml / Profile 段
- application.yaml 主段
- @PropertySource 指定的文件
- 默认值(@Value 的冒号语法)
记住前两条和第 5 条的关系即可:命令行永远赢过文件------这是不改文件临时调参的依据。
侧栏:spring-boot-devtools 热重启
pom 加 spring-boot-devtools 后,保存代码自动重启应用(比手动快得多)。它是双类加载器实现:你的代码变更走 restart 类加载器(秒级),依赖库不动。本教程不引入它------教学场景下频繁整章重启更可控,但实际开发建议装上。
完整代码(最终版)
本章共 3 个新文件 + application.yaml 新增 ch03 段。
src/main/resources/application.yaml(新增部分)
yaml
# ============================================================
# 【第 03 章】配置文件章的专属段:门店信息 + 多环境演示
# ============================================================
---
spring:
config:
activate:
on-profile: ch03
# 自定义业务配置(store 前缀在第 03 章的 StoreProperties 里绑定)
store:
# 门店名称
name: 云端咖啡站
# 营业时间
hours: 09:00-22:00
# 首单折扣(8.8 折)
discount: 0.88
# 环境名默认值(演示 Profile 多环境覆盖:再激活 ch03-prod 时被下面的段覆盖)
env-name: 开发环境(默认)
# ============================================================
# 【第 03 章】生产环境覆盖段:同时激活 ch03 + ch03-prod 时,
# 后激活段的同名 key 覆盖先前的值 → env-name 变成"生产环境"
# ============================================================
---
spring:
config:
activate:
on-profile: ch03-prod
store:
env-name: 生产环境(ch03-prod)
逐行说明:
---:yaml 多文档分隔符;每个分隔后的文档可独立携带 on-profile 声明spring.config.activate.on-profile: ch03:Boot 2.4+ 的标准写法(老教程的spring.profiles写法已废弃)discount: 0.88:不带引号的数字,绑定到 double 字段env-name:连字符命名,松散绑定到envName字段- 覆盖段只写变化的 key(env-name),其余沿用 ch03 段------Profile 叠加是"合并"不是"替换"
src/main/java/com/lihaozhe/chapter03/StoreProperties.java
java
package com.lihaozhe.chapter03;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 第 03 章:门店配置属性类 ------ 把 application.yaml 里的 store.* 批量绑定成 Java 对象。
*
* <p>@ConfigurationProperties(prefix = "store") 的含义:</p>
* 把 yaml 里所有 store 开头的配置按名字注入本类字段:
* <pre>
* store.name → name 字段
* store.hours → hours 字段
* store.discount → discount 字段
* </pre>
* 相比一个一个 @Value,它有三大好处:
* 1. 批量管理:一组相关配置聚成一个对象(松散绑定:yaml 里写 env-name 也映射到 envName)
* 2. 类型安全:discount 直接就是 double,框架负责转换
* 3. IDE 提示:配合 spring-boot-configuration-processor 可获得自动补全
*
* <p>注意:@ConfigurationProperties 需要 Bean 存在才能绑定,
* 这里加 @Component 让它被扫描注册。</p>
*/
@Component
@ConfigurationProperties(prefix = "store")
public class StoreProperties {
/** 门店名称,对应 yaml 的 store.name */
private String name;
/** 营业时间,对应 store.hours */
private String hours;
/** 首单折扣,对应 store.discount */
private double discount;
/** 环境名(演示 Profile 多环境覆盖),默认值写在字段上 */
private String envName = "未设置";
// ===== getter / setter:绑定机制靠 setter 注入值,缺一不可 =====
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getHours() {
return hours;
}
public void setHours(String hours) {
this.hours = hours;
}
public double getDiscount() {
return discount;
}
public void setDiscount(double discount) {
this.discount = discount;
}
public String getEnvName() {
return envName;
}
public void setEnvName(String envName) {
this.envName = envName;
}
}
src/main/java/com/lihaozhe/chapter03/StoreController.java
java
package com.lihaozhe.chapter03;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 第 03 章:配置读取演示接口。
*
* <p>同一个接口展示两种读配置的方式:</p>
* <ul>
* <li>@Value("${store.hours}"):单个字段注入,适合零散的、一两个值的场景</li>
* <li>@ConfigurationProperties:整组配置绑定成对象(StoreProperties),适合成组的业务配置</li>
* </ul>
*/
@RestController
@RequestMapping("/api/store")
public class StoreController {
/** 方式一:@Value 直接把 store.hours 的值注入这个 String 字段 */
private final String hours;
/** 方式二:整个配置对象注入(它自己也是容器里的一个 Bean) */
private final StoreProperties properties;
public StoreController(@Value("${store.hours:未知}") String hours,
StoreProperties properties) {
// @Value 写在构造器参数上同样生效;":未知" 是默认值语法(配置缺失时不报错)
this.hours = hours;
this.properties = properties;
}
/**
* 响应载体:record 组件名即 JSON 字段名,声明顺序就是输出顺序------
* 替代"new LinkedHashMap + 一串 put"的旧写法,不可变且一目了然。
*/
public record StoreInfo(String name, String hours, double discount, String envName) {
}
/** GET /api/store/info → 返回门店信息 */
@GetMapping("/info")
public StoreInfo info() {
return new StoreInfo(
properties.getName(), // 来自 @ConfigurationProperties
hours, // 来自 @Value
properties.getDiscount(),
properties.getEnvName()); // 演示 Profile 覆盖效果
}
}
src/main/java/com/lihaozhe/chapter03/CoffeeApplication3.java
java
package com.lihaozhe.chapter03;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
/**
* 第 03 章:配置文件章启动类。
*
* <p>与第 01、02 章的两个区别:</p>
* <ul>
* <li>@ConfigurationPropertiesScan:额外扫描注册所有 @ConfigurationProperties 类
* (本章 StoreProperties 已用 @Component 注册,这里是展示另一种官方推荐方式)</li>
* <li>main 里 setAdditionalProfiles("ch03"):激活本章专属配置段------
* 这是多章共用一个 yaml 时"各章读各的配置"的关键机制</li>
* </ul>
*/
@SpringBootApplication
@ConfigurationPropertiesScan
public class CoffeeApplication3 {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(CoffeeApplication3.class);
// 激活本章的 Profile 段:让 application.yaml 里 on-profile: ch03 的配置生效
app.setAdditionalProfiles("ch03");
app.run(args);
}
}
运行验证
第 1 步:编译并启动(默认环境)
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter03.CoffeeApplication3
日志确认 profile 已激活:
The following 1 profile is active: "ch03"
Started CoffeeApplication3 in 2.xxx seconds
第 2 步:验证配置读取
bash
curl http://localhost:8080/api/store/info
预期输出:
json
{"name":"云端咖啡站","hours":"09:00-22:00","discount":0.88,"envName":"开发环境(默认)"}
四个值全部来自 yaml:@ConfigurationProperties 绑定了 name/discount/envName,@Value 注入了 hours。
第 3 步:验证 Profile 覆盖(另开终端不行,先 Ctrl+C 停掉,再加参数重启)
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter03.CoffeeApplication3 -Dexec.args="--spring.profiles.active=ch03,ch03-prod"
再查一次:
bash
curl http://localhost:8080/api/store/info
预期输出(只有 envName 变了):
json
{"name":"云端咖啡站","hours":"09:00-22:00","discount":0.88,"envName":"生产环境(ch03-prod)"}
这就是多环境的全部秘密:命令行激活的 ch03-prod 段覆盖了 ch03 段的同名 key,其余配置原样继承。
第 4 步:体验命令行最高优先级(可选)
保持上面的应用不动,直接访问时无法改配置;但你可以停掉应用,试试不加任何 profile 只传命令行参数:
bash
mvn compile exec:java -Dexec.mainClass=com.lihaozhe.chapter03.CoffeeApplication3 -Dexec.args="--store.hours=00:00-24:00"
注意此时没激活 ch03,store.name 等配置不存在------接口返回 null/默认值,而 hours 是你命令行给的值。这印证了优先级表:命令行 > 文件。
验证完毕,Ctrl+C 停止应用。
真实翻车案例 (本章开发时踩到的坑):最初把
store:写了两遍想演示覆盖,结果启动直接报错
found duplicate key store------yaml 解析器不允许同段落重复 key。"覆盖"必须用
---分隔的多个文档 + Profile 机制实现。这个错误留在这里引以为戒。
常见坑
| 现象 | 原因与解决 |
|---|---|
| 启动报 found duplicate key | 同一文档段重复 key;用 --- 分段 + on-profile 实现"覆盖" |
| @ConfigurationProperties 注入全是 null | 忘了加 @Component(或启动类忘加 @ConfigurationPropertiesScan);setter 缺失也会导致绑定失败 |
| yaml 改了不生效 | 缩进用了 Tab;或冒号后没空格 |
| 中文乱码 | 确保 IDE 文件编码设为 UTF-8;Windows 控制台加 -Dfile.encoding=UTF-8 |
| 激活了 profile 但配置没读到 | on-profile 名字拼错;或启动类忘了 setAdditionalProfiles |
自测题
- yaml 的四条语法铁律是什么?"覆盖配置"为什么不能直接写两遍 key?
- @Value 和 @ConfigurationProperties 各适合什么场景?后者为什么必须有 setter?
- 松散绑定是什么?yaml 里的
env-name怎么映射到envName? - Profile 覆盖规则是什么?
--spring.profiles.active=ch03,ch03-prod中谁赢? - 配置加载优先级最高的三种来源是什么?举一个"命令行临时调参"的场景。
- 本章启动类的
setAdditionalProfiles("ch03")解决了本项目的什么问题?
下一章预告
配置文件解决了"运营参数"的存放,但商品本身------几千种饮品、价格、库存------显然不该塞进 yaml。数据该进数据库了。
下一章我们给咖啡站接上 MySQL:连接池、JdbcTemplate、建表脚本自动初始化。