SpringBoot4 云端咖啡站 阶段一:起步与基础

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。每一章的结尾都是下一章的开头。

项目:云端咖啡站

我们要做一个咖啡店的线上点单后端,功能包括:

  1. 菜单管理:浏览咖啡菜单、按条件搜索、上架新品
  2. 在线下单:选饮品下单、自动扣库存、超时未支付自动取消
  3. 会员体系:注册登录(密码加密)、JWT 无状态认证
  4. 门店运营:上传咖啡美图、热门商品缓存加速、接口耗时监控
  5. 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     # 每章自带初始数据
        └── ...

为什么每章独立成包?

  1. 互不干扰@SpringBootApplication 默认只扫描"启动类所在包及子包"。chapter01 的启动类看不到 chapter02 的类,各章完全隔离。
  2. 对照学习:第 4 章 JdbcTemplate 版和第 6 章 MyBatis 版可以随时切换对比。
  3. 渐进重构:你能亲眼看到同一个咖啡店功能从"能跑"到"优雅"的完整演进过程,而不是一上来就是最终形态。

环境准备

你需要:

  • 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

学习方法

  1. 按顺序读:章节环环相扣,跳章就会"跳跃"
  2. 自己敲:每章文档给出全部源码原文与逐行注释,合上文档能独立写出来才算掌握
  3. 跑起来:每章有"运行验证"步骤,给出 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 + "约定优于配置"。它做了三件事:

  1. 起步依赖(starter)spring-boot-starter-webmvc 一个依赖打包了 Spring MVC + JSON + 内嵌 Tomcat,版本号由官方统一仲裁,绝不冲突
  2. 自动配置:检测到 classpath 里有 Web 依赖,就自动帮你配好 Spring MVC;检测到 MySQL 驱动 + 数据源配置,就自动建好连接池
  3. 内嵌服务器 :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)是项目的"说明书",核心是三块:

  1. parent :继承 spring-boot-starter-parent 后,几百个常用依赖的版本号由官方锁定(术语叫"依赖管理"),你写依赖时不写 <version> 也不会冲突
  2. properties:全局属性,如编码 UTF-8、Java 版本
  3. 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

自测题

  1. Spring Boot 相比传统 Spring 解决了哪三类痛点?
  2. @SpringBootApplication 由哪三个注解组成,各自的作用是什么?
  3. 为什么 chapter01 的启动类"看不见" chapter02 的代码?
  4. pom.xml 中 scope=runtimescope=test 分别是什么含义?
  5. 本章为什么必须在 yaml 中排除 DataSource 自动配置?不做会发生什么?
  6. 访问 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 里要 import tools.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 idGeneratorincrementAndGet() 先加 1 再返回新值,天然线程安全,等价于数据库自增主键的行为
  • synchronized:Tomcat 默认 200 个工作线程并发处理请求;不加锁的话两个请求同时 add 会破坏 ArrayList
  • Optional<Coffee>:Java 8 引入的"可能为空的容器"。findById 返回 Optional 强迫调用方显式处理"查不到"的情况,而不是运行时才 NullPointerException
  • toList():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 依赖且重新编译过

自测题

  1. REST 风格里"URL 表示什么、HTTP 方法表示什么"?DELETE /api/menu/1 在表达什么语义?
  2. @PathVariable、@RequestParam、@RequestBody 分别从哪里取值?
  3. 一个请求从 Tomcat 到返回 JSON,中间经过哪几步?(DispatcherServlet 的角色是什么)
  4. 为什么实体类必须保留无参构造器?
  5. Boot 4 的 Jackson 3 与老版本的包名有什么区别?
  6. 价格为什么必须用 BigDecimal?"28.00" 和 28.00 作为构造参数有什么区别?
  7. 本章的 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

四条铁律

  1. 冒号后面必须有空格:name: 云端咖啡站name:云端咖啡站 会解析失败)
  2. 同级对齐用空格缩进(不许 Tab),一般 2 格
  3. 同一个段落里 key 不能重复------重复了应用直接起不来(本章运行验证里有真实翻车案例)
  4. 字符串通常不用加引号;含特殊字符时加单/双引号

配置读取的两种方式

方式 写法 适用场景
@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 里激活自己的 profileapp.setAdditionalProfiles("ch03")),各章配置互不可见。

配置从哪里来?优先级

同一 个 key 可能在多处定义,Spring Boot 按优先级取值(高→低):

  1. 命令行参数 --store.hours=24h
  2. java:comp 里的 JNDI(几乎不用)
  3. 操作系统环境变量(容器化部署常用)
  4. application-{profile}.yaml / Profile 段
  5. application.yaml 主段
  6. @PropertySource 指定的文件
  7. 默认值(@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

自测题

  1. yaml 的四条语法铁律是什么?"覆盖配置"为什么不能直接写两遍 key?
  2. @Value 和 @ConfigurationProperties 各适合什么场景?后者为什么必须有 setter?
  3. 松散绑定是什么?yaml 里的 env-name 怎么映射到 envName
  4. Profile 覆盖规则是什么?--spring.profiles.active=ch03,ch03-prod 中谁赢?
  5. 配置加载优先级最高的三种来源是什么?举一个"命令行临时调参"的场景。
  6. 本章启动类的 setAdditionalProfiles("ch03") 解决了本项目的什么问题?

下一章预告

配置文件解决了"运营参数"的存放,但商品本身------几千种饮品、价格、库存------显然不该塞进 yaml。数据该进数据库了。

下一章我们给咖啡站接上 MySQL:连接池、JdbcTemplate、建表脚本自动初始化。

相关推荐
wangchunyu1141 小时前
Elasticsearch 入门与实战:Spring Boot 3 + ES 8 从零搭建商品搜索服务
大数据·数据库·spring boot·elasticsearch
xcl09252 小时前
从零开发流浪宠物领养平台:技术选型与核心模块设计
java·spring boot·宠物
李昊哲小课3 小时前
Spring Boot 4 旅游主题实战教程 阶段三:数据持久化
spring boot·后端·mybatis·旅游
wno7043 小时前
Spring-Boot-shiro用户认证
spring boot
paopaokaka_luck3 小时前
基于springboot3+vue3+uniapp的河南非遗数字图谱小程序(协同过滤算法、数字图谱展示、ECharts 图形化分析)
java·前端·spring boot·学习·小程序·uni-app·echarts
步行cgn3 小时前
传统 SSM 与 Spring Boot 开发对比:从配置地狱到约定优于配置
java·spring boot·后端
码视野13 小时前
基于 Spring Boot + Vue3 的【大学英语四六级 (CET-4/6) 作文智能评分与句式润色系统】设计与实现(含PRD/三端高保真源码/大屏)
java·前端·人工智能·spring boot·后端·vue3
李昊哲小课16 小时前
SpringBoot4 云端咖啡站 阶段二:数据访问与分层架构
spring boot·架构
宠友信息17 小时前
社区类源码开发实践中的仿小红书系统技术要点分析
java·spring boot·redis·mysql·uni-app·vue·内容运营