Java微服务(三):3.0 SpringBoot - 为简化开发而生

Java微服务(三):3.0 SpringBoot - 为简化开发而生
Prorise1. [SpringBoot 启航] 快速入门与核心原理
摘要: 本章将引导您迈出 Spring Boot 开发的第一步。我们将使用官方脚手架快速搭建并运行一个 Web 服务,理解其便捷部署的原理,并深入剖析 Spring Boot 两大核心特性——启动器 (Starter) 与 自动配置 (Auto-configuration)。
1.1. Spring Boot 快速部署
在开始动手之前,我们先花一分钟了解现代 Web 应用的主流架构模式,这将帮助我们理解为何 Spring Boot 的设计如此契合当下的开发需求。
1.1.1. 现代 Web 架构:前后端分离简介
现代 Web 开发大多采用 前后端分离 的模式。在这种架构下,前端(通常由 Vue, React, Angular 等框架构建)和后端(我们的 Spring Boot 应用)是两个独立的项目。
- 后端 (Backend): 不再负责渲染 HTML 页面。它的核心职责是提供一套 API 接口(通常是 RESTful API),处理业务逻辑,操作数据库,并以统一的数据格式(如 JSON)返回结果。
- 前端 (Frontend): 负责用户界面的展示和交互。它通过调用后端的 API 接口获取数据,然后将数据动态地渲染到页面上。
这种模式带来了职责清晰、开发高效、技术栈灵活等诸多好处。我们的教程将完全基于这种前后端分离的模式进行。
1.1.2. [实践] 使用 Spring Initializr 创建第一个 Web 应用
现在,我们来实现第一个需求:创建一个 Web 服务,当用户访问 http://localhost:8080/hello
时,页面显示 “Hello, Spring Boot!”。
创建 Spring Boot 项目最快、最标准的方式是使用官方的 Spring Initializr (俗称“脚手架”)。
1. 使用 Idea 生成项目
- 配置选项:
- Project: Maven
- Language: Java
- Spring Boot: 3.3.3 (或更高稳定版)
- Project Metadata:
- Group:
com.example
- Artifact:
spring-boot-demo
- Packaging: Jar
- Java: 17 (Spring Boot 3.x 要求 JDK 17+)
- Group:
- Dependencies: 点击 “ADD DEPENDENCIES…”,搜索并添加
Spring Web
。
配置完成后,点击 “GENERATE” 按钮,下载项目压缩包并用 IDEA 打开。
2. 剖析项目结构与 pom.xml
IDEA 加载完成后,您会看到一个标准的 Maven 项目结构。
1 | . 📂 spring-boot-demo |
我们重点关注 pom.xml
中的两个核心配置:
文件路径: spring-boot-demo/pom.xml
1 |
|
1.1.3. [实践] 编写 @RestController
并运行服务
1. 编写 Controller
文件路径: src/main/java/com/example/springbootdemo/controller/HelloController.java
(新增文件)
1 | package com.example.springbootdemo.controller; |
重要: 我们自己创建的类(如 Controller, Service)必须放在 启动类 SpringBootDemoApplication.java
所在的包或其子包 下,才能被 Spring Boot 默认的组件扫描机制发现。
2. 运行与验证
现在,找到 SpringBootDemoApplication.java
,直接右键运行其 main
方法。
1 | // SpringBootDemoApplication.java |
当控制台输出 Tomcat started on port(s): 8080 (http)
时,表示服务已成功启动。
打开浏览器,访问 http://localhost: 8080/hello。
1.2. 便捷的部署与执行
Spring Boot 极大地简化了应用的部署过程,其核心就是 可执行 JAR 包。
1.2.1. [实践] 打包为可执行 Fat JAR
在 IDEA 的 Maven 窗口中,执行 package
命令。
打包成功后,在 target
目录下会生成一个 spring-boot-demo-0.0.1-SNAPSHOT.jar
文件。将这个 JAR 包复制到 任何安装了 Java 17+ 环境 的机器上,在命令行中执行:
1 | java -jar spring-boot-demo-0.0.1-SNAPSHOT.jar |
1
2
3
4
5
6
7
8
9
10
11
12
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.3)
... (省略日志)
...
2025-08-14T21:30:00.123Z INFO 12345 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
...
服务再次启动!这证明了 Spring Boot 应用的 可移植性,无需外部的 Tomcat 服务器。
1.2.2. Spring Boot JAR 与普通 JAR 的区别
为什么 Spring Boot 的 JAR 包可以直接运行,而普通的 JAR 包不行?
Spring Boot 打包的“Fat JAR”和我们平时用的普通 JAR,在结构上有什么本质区别?
最大的区别在于内容。普通 JAR 只包含我们自己编写的 .class
文件和资源。而 Spring Boot 的 Fat JAR 除了包含我们自己的代码,还内置了三样关键东西:
1.所有运行时依赖 : 所有的第三方 JAR 包(如 Spring MVC, Tomcat, Jackson)都被打包进来了。
2.内嵌的 : Tomcat 服务器的 JAR 包也被包含在内。
3.一个特殊的启动器类 :Spring Boot 提供了一个 JarLauncher
类,它知道如何正确地加载所有内嵌的依赖并启动应用。
非常好。所以,可执行性就是这么来的?
是的。Fat JAR 的 MANIFEST.MF
文件中,Main-Class
被指向了这个 JarLauncher
。所以当我们执行 java -jar
时,实际上是 JarLauncher
在工作,它创建了一个特殊的类加载器来加载 BOOT-INF/lib/
下的依赖,然后才调用我们自己写的 main
方法。而普通 JAR 没有这个机制。
1.3. 自动配置核心揭秘
我们已经体验到了 Spring Boot 的便捷,现在是时候揭开其背后的魔法了。
1.3.1. [面试题] SpringApplication.run()
背后发生了什么?
当我们执行 SpringApplication.run(MyApplication.class, args)
这一行代码时,Spring Boot 在背后都做了哪些核心工作?
这是一个经典的启动流程问题。概括来说,它主要做了四件大事:
1.推断应用类型 : Spring Boot 会检查类路径,判断这是一个标准的 Servlet Web 应用,还是一个响应式的 WebFlux 应用,或是一个非 Web 应用。
2.加载自动配置类 : 这是最核心的一步。它会从所有依赖的 JAR 包中,找到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件,加载里面定义的所有自动配置类。
3.创建并准备IoC容器 : 创建一个 ApplicationContext
实例,然后执行一系列的准备工作,比如加载我们的主配置类 (MyApplication.class
),将命令行参数 args
注册为 Bean 等。
4.刷新容器并启动组件 : “刷新” 容器是 Spring 的一个术语,它会触发所有 Bean 的实例化、依赖注入、初始化等过程。在这个过程中,之前加载的自动配置类会根据条件 (@ConditionalOn...
) 判断是否生效。对于 Web 应用,此阶段还会启动内嵌的 Tomcat 服务器。
1.3.2. 魔法的起点:@SpringBootApplication
注解剖析
我们可以在 SpringBootDemoApplication
里面找到这个注解,那么这个注解的作用是什么?
SpringApplication.run()
的所有行为,都源于启动类上的 @SpringBootApplication
注解。它是一个复合注解,由三个核心注解组成:
@SpringBootConfiguration
:- 它本身被
@Configuration
注解,所以 Spring Boot 的主启动类本质上就是一个 Java 配置类。这意味着我们可以在启动类中直接定义@Bean
。
- 它本身被
@EnableAutoConfiguration
:- 启用自动配置。这是 Spring Boot 最神奇的地方。它会触发上面提到的“加载自动配置类”的流程。
@ComponentScan
:- 启用组件扫描。它告诉 Spring 去哪里查找我们自己定义的 Bean(如
@Component
,@Service
,@RestController
等)。默认的扫描路径是 启动类所在的包及其所有子包。
- 启用组件扫描。它告诉 Spring 去哪里查找我们自己定义的 Bean(如
1.3.3. 按需加载:条件注解 (@ConditionalOn...
) 的作用
@EnableAutoConfiguration
虽然加载了所有可能的自动配置类,但并非所有都会生效。每个自动配置类都被一系列的 条件注解 (@ConditionalOn...
) 所保护。
工作原理:
Spring Boot 在处理自动配置类时,会检查其上的条件注解。只有当所有条件都满足时,这个配置类才会生效
常用条件注解:
注解 | 作用 |
---|---|
@ConditionalOnClass | 当类路径下 存在 指定的类时,配置生效。 (这是最常用的) |
@ConditionalOnMissingClass | 当类路径下 不存在 指定的类时,配置生效。 |
@ConditionalOnBean | 当 IoC 容器中 存在 指定的 Bean 时,配置生效。 |
@ConditionalOnMissingBean | 当 IoC 容器中 不存在 指定的 Bean 时,配置生效。 (常用于提供默认 Bean) |
@ConditionalOnProperty | 当 application.properties 中 存在 指定的配置项时,配置生效。 |
@ConditionalOnWebApplication | 仅当当前应用是 Web 应用 时,配置生效。 |
示例:WebMvcAutoConfiguration
(Spring MVC 的自动配置) 的部分源码:
1 | // 只有当应用是 Servlet Web 应用,并且类路径下存在 Servlet, DispatcherServlet, WebMvcConfigurer 时, |
2. [核心配置] Spring Boot 外部化配置详解
摘要: 本章将深入探讨 Spring Boot 强大而灵活的配置管理机制。我们将对比 properties
和 yml
两种主流格式,并重点掌握 @Value
和 @ConfigurationProperties
两种读取配置的方式,最后学习如何通过 Profiles 实现多环境的配置隔离。
2.1. 外部化配置的核心思想与加载顺序
2.1.1. “痛点”场景:为什么配置需要与代码分离?
想象一下,在我们刚刚完成的 Hello, World!
项目中,我们需要连接一个数据库。一个初级的做法可能是将数据库连接信息硬编码在代码里:
1 | public class DatabaseConnector { |
这段代码存在一个致命问题:配置与代码高度耦合。当我们需要将应用从开发环境部署到测试环境,再到生产环境时,数据库的地址、用户名和密码必然会改变。难道我们每次部署都要去修改 Java 源代码,然后重新编译、打包吗?这显然是低效且极易出错的。
解决方案:
将这些易变的配置信息从代码中抽离出来,存放到代码外部的文件中。应用程序在启动时去读取这些外部文件,获取配置。这就是外部化配置的核心思想。
2.1.2. Spring Boot 外部化配置的优势
Spring Boot 将外部化配置思想发挥到了极致,带来了诸多好处:
优势 | 说明 |
---|---|
灵活性 | 同一份应用程序代码,无需重新打包,即可通过切换不同的配置文件,在不同环境中运行。 |
易于维护 | 配置信息集中管理,非开发人员(如运维)也可以安全地修改配置,只需重启应用即可生效。 |
安全性 | 可以将数据库密码、API密钥等敏感信息存储在生产服务器的特定文件中,而不会泄露在代码仓库里。 |
标准化 | Spring Boot 提供了一套标准化的配置机制,使得配置管理变得简单且有据可循。 |
2.1.3. 关键:Spring Boot 配置的加载优先级顺序
Spring Boot 可以在很多不同的地方查找配置,并且有一套严格的优先级顺序。高优先级的配置会覆盖低优先级的配置。了解这个顺序对于排查“配置为何没生效”的问题至关重要。
部署结构示例
假设我们的应用打包后,部署结构如下:
1 | . 📂 /opt/app/ |
在这个结构中,my-app-1.0.0.jar
内部的 resources/
目录下也包含一个 application.yml
文件。
配置加载优先级
- jar 包内部的
application.properties
或application.yml
(优先级最低) - jar 包外部的
application.properties
或application.yml
(与 jar 包同级目录下) - jar 包外部的
config/
目录下的application.properties
或application.yml
- 操作系统环境变量
- Java 系统属性 (
-Dkey=value
) - 通过命令行参数传入的配置 (
--key=value
) (优先级最高)
核心理念: 这个设计允许运维人员在不触碰任何代码包的情况下,通过命令行参数或外部配置文件来覆盖应用打包时的默认配置,实现了完美的“运维友好”。
2.2. 主流配置方式:.properties vs .yml
Spring Boot 主要支持两种格式的配置文件:.properties
和 .yml
。
2.2.1. application.properties
语法与用法
这是 Java 中传统的配置文件格式,以简单的键值对形式存在。
文件路径: src/main/resources/application.properties
1 | # 服务器端口 |
2.2.2. application.yml
的层级结构与语法优势
YAML (.yml
) 是一种对人类阅读更友好的数据序列化语言。它通过缩进来表示层级关系,结构非常清晰。
文件路径: src/main/resources/application.yml
1 | # 服务器配置 |
YAML 语法关键:
- 使用空格进行缩进,严禁使用 Tab 键。
:
冒号后面必须至少有一个空格。
2.2.3. 两种格式的优先级与选择建议
特性 | .properties 文件 | .yml 文件 |
---|---|---|
格式 | 扁平的键值对 (spring.datasource.url=... ) | 层级的树状结构,更清晰 |
可读性 | 配置多时可读性差 | 非常适合描述复杂的、有层级的配置数据 |
优先级 | 高于 .yml | 低于 .properties |
建议 | 推荐使用 .yml ,因其结构化能力远超 .properties | 仅在需要覆盖 .yml 中某个特定值时少量使用 |
当 resources
目录下同时存在 application.properties
和 application.yml
时,Spring Boot 会两个都加载。如果两个文件中有相同的配置项,.properties
文件中的值会覆盖 .yml
文件中的值。
2.3. 读取配置:@Value vs @ConfigurationProperties
“痛点”场景:
好了,我们已经在
application.yml
中定义了配置,但我们的 Java 代码如何才能获取到这些值呢?
Spring Boot 提供了两种主流的注解来解决这个问题:@Value
和 @ConfigurationProperties
。
2.3.1. [实践] 使用 @Value
读取单个配置 (含默认值)
@Value
注解非常适合用于注入单个、简单的配置值。
文件路径: src/main/resources/application.yml
1 | app: |
文件路径: src/main/java/com/example/springbootdemo/config/AppInfo.java
(新增文件)
1 | package com.example.springbootdemo.config; |
测试代码:
1 |
|
1
AppInfo{name='My Awesome App', owner='Prorise', port=8080}
2.3.2. [推荐] 使用 @ConfigurationProperties
进行类型安全的属性绑定
“痛点”场景:
@Value
注解虽然简单,但如果要注入的配置项非常多(比如一个数据源有十几个配置),在类中写十几个@Value
注解会显得非常繁琐和重复。而且,它对复杂的嵌套结构或集合配置支持不佳。
解决方案:@ConfigurationProperties
注解为此而生。它允许我们将配置文件中一个前缀下的所有属性,整体地、类型安全地绑定到一个 Java 对象(POJO)上。
文件路径: src/main/resources/application.yml
1 | datasource: |
文件路径: src/main/java/com/example/springbootdemo/config/MySQLProperties.java
(新增文件)
1 | package com.example.springbootdemo.config; |
测试代码:
注意: 记得将上一节的ApiInfo删除,因为我们已经修改了yml文件,不删除会找不到bean的
1 | package com.example.springbootdemo; |
1
2
3
MySQLProperties(url=jdbc:mysql://localhost:3306/prod, username=prod_user,
password=prod_password, connectionProperties=[characterEncoding=utf8, useSSL=false],
poolOptions=MySQLProperties.PoolOptions(maxActive=20, minIdle=5))
2.3.3. 启用属性类:@Component
vs @EnableConfigurationProperties
“痛点”场景:
我们已经创建了一个 MySQLProperties
类,并用 @ConfigurationProperties
标注了它。但是,Spring Boot 默认并不会知道这个普通 Java 类的存在。我们必须通过某种方式告诉 Spring:“嘿,这是一个需要你来管理的 Bean,请在容器启动时创建它,并把配置文件里的值绑定进去!”。那么,我们有几种方式来“激活”或“启用”这个属性类呢?
Spring Boot 提供了两种主流的激活方式。
方案一:使用 @Component
(简单、直接)
这是最直接的方式。我们只需在属性类上添加 @Component
注解,它就会被 Spring Boot 的组件扫描 (@ComponentScan
) 机制自动发现,并注册为一个 Bean。
文件路径: src/main/java/com/example/springbootdemo/config/MySQLProperties.java
1 | package com.example.springbootdemo.config; |
- 优点: 简单明了,所有配置都集中在一个类上。
- 缺点: 属性类 (
MySQLProperties
) 与 Spring 框架的@Component
注解产生了耦合。对于我们自己项目内部的配置类,这通常是可以接受的。
方案二:使用 @EnableConfigurationProperties
(集中、解耦)
“进阶痛点”:
如果 MySQLProperties
这个类来源于一个我们无法修改的第三方 jar
包呢?或者,在一个大型项目中,我们希望将所有的配置属性类在一个地方进行集中式的、统一的管理,而不是让它们散落在各个角落,我们该怎么做?
解决方案:
这就是 @EnableConfigurationProperties
的用武之地。它允许我们在一个集中的配置类(通常是我们自己创建的,被 @Configuration
标注的类)中,明确地列出所有需要被激活的属性类。
第一步:移除属性类上的 @Component
让 MySQLProperties
变回一个纯粹的、不依赖 Spring 框架的 POJO。
文件路径: src/main/java/com/example/springbootdemo/config/MySQLProperties.java
(修改)
1 | package com.example.springbootdemo.config; |
第二步:创建集中的配置管理类
文件路径: src/main/java/com/example/springbootdemo/config/AppConfig.java
(新增文件)
1 | package com.example.springbootdemo.config; |
对比总结
特性 | 方式一: @Component | 方式二: @EnableConfigurationProperties |
---|---|---|
使用方式 | 直接在属性类上添加 @Component | 在一个 @Configuration 配置类上添加 @EnableConfigurationProperties |
优点 | 简单、直接、自包含 | 集中管理、与框架解耦(属性类本身可以是纯 POJO) |
适用场景 | 项目内部自己定义的属性类 | 1. 启用第三方的属性类<br>2. 在项目中集中管理所有属性配置 |
2.4. 多环境配置管理 (Profiles)
“痛点”场景:
我们的应用开发完成,即将上线。但在部署时遇到了一个典型问题:开发环境用的是本地数据库,测试环境用的是测试库,而生产环境则需要连接生产主库。难道我们每次部署到不同环境,都要手动去修改 application.yml
里的数据库连接信息,然后再重新打包吗?这不仅效率低下,而且极易引发“配错环境”的生产事故。
解决方案:
Spring Profiles 是专门为解决这一问题而设计的。它允许我们为不同的环境创建各自独立的配置文件,在应用启动时,只需通过一个简单的开关,就能激活指定环境的配置,实现“一次打包,多环境灵活部署”。
2.4.1. [实践] application-{profile}.yml
的约定与使用
Spring Boot 约定,特定环境的配置文件命名格式为 application-{profile}.yml
(或 .properties
),其中 {profile}
就是你自定义的环境名(如 dev
, test
, prod
)。
现在,我们在 src/main/resources
目录下进行如下改造:
application.yml
: 作为主配置文件,存放所有环境共享的配置,并指定默认激活的环境。application-dev.yml
: 存放开发环境特有的配置。application-prod.yml
: 存放生产环境特有的配置。
文件路径: src/main/resources/application.yml
1 | # -------------------- |
文件路径: src/main/resources/application-dev.yml
1 | # -------------------- |
文件路径: src/main/resources/application-prod.yml
1 | # -------------------- |
工作原理: Spring Boot 启动时,会总是先加载主配置文件 application.yml
,然后再根据激活的 profile (比如 dev
),去加载对应的 application-dev.yml
。后加载的配置会覆盖先加载的同名配置。
2.4.2. [实践] 激活特定 Profile 的多种方式
我们有多种方式来激活一个 Profile,它们的优先级各不相同。
方式一:在主配置文件中指定 (开发默认)
这是我们在 application.yml
中已经做过的方式,通过 spring.profiles.active
属性来设置。它通常用于指定本地开发时的默认环境。
1 | spring: |
方式二:通过命令行参数 (生产部署首选)
这是优先级更高且生产环境部署最常用的方式。它可以在不修改任何打包文件的情况下,在启动时动态指定环境。
1 | # 激活生产环境配置来启动应用 |
实践验证
为了验证 Profile 是否生效,我们创建一个简单的 Bean 来读取配置。
文件路径: src/main/java/com/example/springbootdemo/config/AppInfo.java
(修改)
1 | package com.example.springbootdemo.config; |
文件路径: src/test/java/com/example/springbootdemo/SpringBootDemoApplicationTests.java
(添加新测试)
1 | // SpringBootDemoApplicationTests.java |
1. 默认环境测试
直接在 IDEA 中运行 testProfile()
测试。
1 | AppInfo(owner=Prorise, env=Development Environment) |
2. 切换环境测试
在 IDEA 中,我们可以模拟命令行参数来切换 Profile:
- 点击
Edit Configurations...
- 在
Program arguments
中输入--spring.profiles.active=prod
再次运行testProfile()
测试。
1 | AppInfo(owner=Prorise, env=Production Environment) |
结论: 实验证明,通过激活不同的 Profile,我们成功地让应用加载了不同的配置,而无需修改任何代码。
3. [AOP] 面向切面编程
摘要: 在我们的项目中,日志记录、事务管理、权限校验等通用逻辑散布在各个业务方法中,造成了代码冗余。本章我们将学习 Spring AOP,这是一种强大的编程范式,它能将这些横切关注点从主业务逻辑中优雅地分离出来。我们将通过实战,创建一个自定义的日志切面,并最终实现一个基于注解的声明式操作权限校验。
前置知识要求: 在深入学习本章之前,我们强烈建议您回顾或先行学习我们的 《Java微服务(二):3.0 SpringMVC - 前后端交互核心内核 章节,因为本章中的许多概念,如拦截器 (Interceptor),都与 AOP 的思想一脉相承。理解 Spring MVC 的请求处理流程将极大地帮助您 grasp(掌握)AOP 的核心精髓。
为了不影响读者的阅读和,这里提供一个整理好的仓库供读者快速Clone Spring_Mvc_Study: 教学用的SpringMVC文件 我们后续还会在这个项目的基础上加以改进
3.1. AOP 核心概念入门
3.1.1. 痛点:什么是横切关注点?(Why AOP)
让我们回顾一下之前的代码。在 UserServiceImpl
中,我们可能希望在每个公开的业务方法(如 findUserById
, saveUser
)执行前后都打印日志,用于追踪和调试。一个朴素的实现方式可能是这样的:
1 |
|
这种写法暴露了严重的问题:
核心逻辑混杂:缓存处理的代码(从缓存读、写入缓存)与真正的业务逻辑(查询数据库、对象转换)紧紧地耦合在一起,使得代码难以阅读和维护。
代码重复:如果未来 findProductById
、findOrderById
等方法也需要缓存,我们就必须在每个方法里都重复编写这套缓存逻辑。
像日志、事务、权限校验、性能监控这类需要“横向”地应用到多个业务模块中的功能,我们就称之为 横切关注点。
AOP (Aspect-Oriented Programming, 面向切面编程) 的核心目标,就是将这些横切关注点从主业务逻辑中优雅地剥离出来,使它们模块化,从而降低代码的耦合度,提升系统的可维护性和可扩展性。
3.1.2. 核心术语:构建 AOP 的“积木”
要理解 AOP 是如何工作的,我们必须先掌握它的几个核心术语。您可以将它们想象成一套用于构建“切面”的乐高积木。
核心概念 | 作用/比喻 | 简明解释 |
---|---|---|
连接点 (Join Point) | 所有可能的时机 | 程序执行过程中可以插入切面的点,如方法调用或执行。 |
切点 (Pointcut) | 选定的具体时机 | 一个或多个连接点的集合,它精确定义了通知将在哪里执行。 |
通知 (Advice) | 要做的具体事情 | 在切点匹配的连接点上执行的代码,例如记录日志。 |
切面 (Aspect) | 事情和时机的组合 | 切点和通知的结合体,封装了一个完整的横切关注点功能。 |
关系总结
- 切面 (Aspect) = 切点 (Pointcut) + 通知 (Advice)
- 通知 (Advice) 被应用到由 切点 (Pointcut) 筛选出的一系列 连接点 (Join Point) 上。
1. 连接点 (Join Point)
定义: 程序执行过程中的一个明确的点,例如方法的调用、异常的抛出等。在 Spring AOP 中,连接点总是指代方法的执行。
您可以把它想象成程序流程中的一个个“可以插入逻辑”的“时机点”。
2. 切点 (Pointcut)
定义: 一个谓词或表达式,它用于匹配和选中一组感兴趣的连接点。
如果说连接点是程序中所有可能插入逻辑的点,那么切点就是我们的“筛选器”,它精确地定义了我们到底要在哪些方法上应用我们的横切逻辑。例如,我们可以定义一个切点来选中 UserController
中所有以 get
开头的方法。
3. 通知 (Advice)
定义: 在切点所匹配的连接点上具体要执行的操作。
通知定义了我们的横切逻辑“做什么”以及“什么时候做”。Spring AOP 提供了五种标准的通知类型:
@Before
: 在目标方法执行之前执行。@AfterReturning
: 在目标方法成功返回之后执行。@AfterThrowing
: 在目标方法抛出异常之后执行。@After
: 无论目标方法是成功返回还是抛出异常,在它之后都会执行(类似于finally
块)。@Around
: 环绕通知。这是最强大的通知类型,它能完全包裹目标方法的执行,我们可以在方法执行前后自定义逻辑,甚至可以决定是否执行目标方法。
4. 切面 (Aspect)
定义: 通知 (Advice) 和 切点 (Pointcut) 的一个模块化组合。
一个切面将“在哪里做(切点)”和“做什么(通知)”这两件事有机地结合在了一起,形成了一个完整的横切关注点模块。例如,我们可以创建一个“日志切面”,它包含一个匹配所有 Service 层方法的切点,以及一个在方法执行前后打印日志的环绕通知。
3.2. [实战] 创建声明式缓存切面
现在,我们将亲手实践 AOP 的真正威力:创建一个自定义的 @SimpleCache
注解和一个配套的缓存切面。最终实现的效果是,任何方法只要加上 @SimpleCache
注解,就自动具备了专业的、带过期时间的缓存能力。
3.2.1. 引入 AOP Starter 依赖
首先,请确保 demo-framework
模块的 pom.xml
中已添加 spring-boot-starter-aop
依赖。
文件路径: demo-framework/pom.xml
1 | <dependency> |
3.2.2. 创建自定义缓存注解
我们在 demo-common
模块中创建 @SimpleCache
注解。
文件路径: demo-common/src/main/java/com/example/democommon/annotation/SimpleCache.java
(新增文件)
1 | package com.example.democommon.annotation; |
3.2.3. 技术选型:Hutool-Cache 简介
在实现切面之前,我们先来了解一下即将使用的强大工具——Hutool-cache
。它提供了几种成熟的缓存策略,让我们可以轻松应对不同场景。
缓存策略 | 核心思想 | 淘汰机制 | 容量限制 |
---|---|---|---|
FIFOCache | 先进先出 (First In, First Out) | 缓存满时,淘汰最先存入的数据 | 有 |
LFUCache | 最少使用 (Least Frequently Used) | 缓存满时,淘汰使用频率最低的数据 | 有 |
LRUCache | 最近最久未使用 (Least Recently Used) | 缓存满时,淘汰最长时间未被访问的数据 | 有 |
TimedCache | 定时过期 (Time-based Expiration) | 数据达到设定的超时时间后自动过期 | 无 |
补充说明:
FIFO
、LFU
、LRU
这三种策略都是容量驱动的缓存。它们的核心目标是在缓存达到容量上限时,决定应该淘汰哪些数据来为新数据腾出空间。TimedCache
是时间驱动的缓存。它不关心容量是否已满,只关心数据是否“新鲜”,一旦数据过期就会被清理。这与我们@SimpleCache
注解中的timeoutSeconds
属性完美契合,因此是本次实战的最佳选择。
3.2.4. 实现缓存切面
现在,我们来创建 SimpleCacheAspect
,并使用 Hutool 的 TimedCache
来实现专业的缓存逻辑。
文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/SimpleCacheAspect.java
(新增文件)
1 | package com.example.demoframework.aspect; |
3.3. 应用与测试
3.3.1. 在 Service 中应用注解
现在,我们回到 UserServiceImpl
,移除之前手写的缓存代码,并换上我们崭新的 @SimpleCache
注解。
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
(修改)
1 | package com.example.demosystem.service.impl; |
3.3.2. 回归测试:验证缓存与过期效果
重启您的 demo-admin
模块。
- 重复调用请求: 使用 Swagger UI 调用
GET /users/1
。观察控制台日志,您会看到:
您会发现,这次没有打印“正在执行 findUserById 核心业务逻辑…”,证明原方法根本没有被执行,数据直接从缓存返回。
在十秒后重新请求,可以看到耗时又变回去了,证明缓存条目已经因过期而被自动清除,程序重新执行了数据库查询。
3.3.3. AOP 与拦截器的对比总结
现在,我们可以清晰地总结 AOP 和拦截器的区别了,这对于选择正确的技术至关重要。
对比维度 | 拦截器 (Interceptor) | Spring AOP (Aspect) |
---|---|---|
作用层面 | Web 层,与 HttpServletRequest 强绑定 | Spring Bean 的方法执行层面,与 Web 无关 |
粒度 | 粗粒度,拦截所有匹配的 HTTP 请求 | 细粒度,可精确到具体类的具体方法 |
能力 | 可获取和修改 HTTP 请求和响应对象 | 可获取和修改方法参数、返回值;可决定是否执行原方法 |
典型场景 | 用户认证、全局日志、CORS、解决重复提交 | 事务管理、缓存、权限校验、性能监控等业务横切点 |
3.4. [实战] 使用 execution
监控 Service 层性能
在之前的缓存案例中,我们使用了 @annotation
来精确地“定点”增强某一个方法。现在,我们将学习如何使用 execution
来进行“范围”增强,实现一个对整个 Service 层所有公共方法进行性能监控的切面。
我们的目标:自动记录 demo-system
模块下,service
包及其子包内所有 public
方法的执行耗时,而无需修改任何 Service 代码。
3.4.1. 创建性能监控切面
文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/PerformanceAspect.java
(新增文件)
1 | package com.example.demoframework.aspect; |
3.4.2 回归测试
您无需做任何额外配置!因为这个切面已经被注册为 Spring Bean,并且它的切点会自动匹配所有符合条件的 Service 方法。
- 重启
demo-admin
应用。 - 使用 API 工具调用任意一个会触发 Service 层方法的接口,例如
GET /users
或POST /users
。 - 观察控制台日志,您会看到除了我们之前的 Web 日志,现在还多出了性能监控日志:
结论:execution
指示符为我们提供了一种极其强大的、基于包和方法签名进行“范围扫描”的能力。它与 @annotation
的“定点精确打击”形成了完美互补,两者结合,可以让我们随心所欲地将 AOP 的能力应用到项目的任何一个角落。
解惑:@Around
是否能替代所有其他通知?
从技术上讲,@Around
环绕通知确实是功能最强大的,它可以完全模拟 @Before
, @AfterReturning
, @AfterThrowing
和 @After
的所有功能。
既然如此,为什么还需要其他通知注解呢?
答案是:为了代码的简洁性、可读性和意图的清晰性。
我们可以把这看作是“瑞士军刀”和“专用工具”的区别:
@Around
(瑞士军刀): 功能万能,但使用起来也最复杂。您必须手动管理目标方法的执行(通过调用pjp.proceed()
),并且需要自己处理异常。如果忘记调用pjp.proceed()
,原始方法将永远不会被执行,这可能是一个难以发现的严重 bug。@Before
,@AfterReturning
等 (专用工具): 功能专一,但使用起来非常简单安全。它们清晰地表达了您的意图,并且您无需关心如何以及何时执行原始方法,Spring 框架会为您处理好一切。
最佳实践建议:
当您只想… | 最佳选择 | 为什么? |
---|---|---|
在方法执行前做些事 | @Before | 最简单,意图最明确,不会意外影响方法执行。 |
在方法成功返回后做些事 | @AfterReturning | 可直接获取返回值,代码简洁。 |
只在方法抛出异常时做些事 | @AfterThrowing | 专门的异常处理通道,逻辑清晰。 |
必须在方法执行前后都操作,或需要控制/改变方法执行流程时 | @Around | 只有在这种复杂场景下,才动用功能最强大的工具。 |
4. [数据深化] 事务、缓存与外部调用
摘要: CRUD 功能的实现只是数据操作的起点。本章我们将深入持久层的两大核心——事务管理与性能优化。我们将学习如何通过 @Transactional
注解优雅地保证数据一致性,并引入 Spring Cache 与 Redis,为我们的应用插上缓存的翅膀,最后探讨在生产环境中配置与监控多数据源的最佳实践。
4.1. [核心] 声明式事务管理 (@Transactional
)
承前启后: 在上一章,我们学习了 AOP 如何将通用逻辑(横切关注点)从业务代码中分离。Spring 的声明式事务管理正是 AOP 最经典、最成功的应用之一。您将亲眼见证,一个简单的 @Transactional
注解背后,蕴含着多么强大的切面技术。
4.1.1. 痛点:为什么需要事务?
想象一个简化的银行转账业务:账户 A 向账户 B 转账 100 元。这个操作至少需要两个数据库步骤:
- UPDATE: 将账户 A 的余额减去 100。
- UPDATE: 将账户 B 的余额增加 100。
现在,设想一种极端情况:在成功执行第一步后,数据库突然宕机或应用崩溃,导致第二步未能执行。结果将是灾难性的:A 的钱被扣了,B 却没有收到,凭空蒸发了 100 元。
事务 (Transaction) 正是为了解决这类问题而生。它将一组数据库操作捆绑成一个不可分割的原子单元。在这个单元内,所有操作要么全部成功,要么全部失败回滚到操作前的状态,从而确保数据的绝对一致性。
在 Spring 中,我们无需手动编写 try-catch-finally
和 commit/rollback
代码,只需一个注解就能实现这一切。
1. 实践准备:构建转账业务场景
为了演示事务,我们首先需要创建一个 t_account
表,并为其构建相应的业务代码。
请在您的数据库中执行以下 SQL:
1 | CREATE TABLE `t_account` ( |
接下来,创建相应的 Entity, Mapper 和 Service:
文件路径: demo-system/src/main/java/com/example/demosystem/entity/Account.java
(新增)
1 | package com.example.demosystem.entity; |
文件路径: demo-system/src/main/java/com/example/demosystem/mapper/AccountMapper.java
(新增)
1 | package com.example.demosystem.mapper; |
文件路径: demo-system/src/main/java/com/example/demosystem/service/AccountService.java
(新增)
1 | package com.example.demosystem.service; |
文件路径: demo-system/src/main/java/com/example/demosystem/controller/AccountController.java
(新增)
1 | package com.example.demosystem.controller; |
2. 验证事务效果
- 重启
demo-admin
应用。 - 调用转账接口: 使用 API 工具调用
POST http://localhost:8080/account/transfer?from=1&to=2&amount=100
。 - 观察结果: 您会收到一个由全局异常处理器捕获并返回的
500
错误(因为我们抛出了RuntimeException
)。 - 检查数据库: 查询
t_account
表,您会发现用户1
和2
的余额都还是 1000.00,没有任何变化。
结论: 因为 transfer
方法被 @Transactional
注解标记,当方法内抛出异常时,Spring AOP 切面捕获了它,并自动触发了事务回滚 (Rollback)。已经执行的第一步 decrease
操作被撤销,从而保证了数据的绝对一致性。
4.1.2. 事务的传播行为
“痛点”: 当一个已经开启了事务的方法(外部方法)去调用另一个同样需要事务的方法(内部方法)时,内部方法的事务应该如何表现?是加入外部方法的现有事务,还是自己开启一个全新的事务?
这就是事务传播行为 要解决的问题。它定义了事务在方法调用链中的传递规则。
传播行为 | 核心作用 | 场景说明 |
---|---|---|
— 最常用 — | ||
REQUIRED (默认) | 加入或新建事务 | 如果当前存在事务,就加入;如果不存在,就创建一个新的。这是保证业务完整性的首选。 |
REQUIRES_NEW | 总是新建事务 | 无论是否存在事务,都创建一个独立的新事务。常用于日志记录等需要与主业务隔离的操作。 |
— 其他 — | ||
SUPPORTS | 支持当前事务 | 有事务就用,没有就以非事务方式执行。 |
NOT_SUPPORTED | 以非事务方式执行 | 总是挂起当前事务(如果存在),以非事务方式运行。 |
MANDATORY | 强制要求有事务 | 必须在已有事务中执行,否则抛出异常。 |
NEVER | 强制要求无事务 | 必须在非事务环境中执行,否则抛出异常。 |
NESTED | 嵌套事务 | 在已有事务中创建一个保存点(Savepoint),可以独立回滚而不影响外部事务。 |
4.1.3. @Transactional
失效的典型场景
@Transactional
虽好,但它依赖于 Spring AOP 的代理机制,在某些情况下会“悄无声息”地失效,这是面试高频题和生产环境中的大坑。
核心原因: Spring AOP 是通过代理对象来织入事务切面的。只有当调用是通过代理对象发起的,事务才会生效。任何绕过代理的调用都会导致事务失效。
1. 方法非 public
@Transactional
只能用于 public
方法。如果用在 protected
, private
或 default
可见性的方法上,事务将不会生效。
2. 方法内部调用 (最常见)
在一个类中,一个未被 @Transactional
注解的方法 a()
去调用同一个类中被注解的方法 b()
,b()
的事务会失效。
1 |
|
原因: methodA()
是通过 this
关键字直接调用的 methodB()
,它调用的是原始对象的方法,而不是 Spring 创建的代理对象。
解决方案: 将 methodB()
移到另一个独立的 Service 类中,通过注入的代理对象来调用。
3. 异常被 catch
如果在事务方法内部 catch
了异常并且没有重新抛出,Spring 的事务切面将无法感知到异常的发生,从而不会触发回滚。
1 |
|
4. 默认只对 RuntimeException
回滚
Spring 默认只在遇到 RuntimeException
或 Error
时才会触发事务回滚。如果方法抛出的是一个受检异常(Checked Exception,如 IOException
),事务默认不会回滚。
解决方案: 使用 rollbackFor
属性,我们将在下一节详细讲解。
4.1.4. rollbackFor
和 isolation
属性详解
1. rollbackFor
: 精确控制回滚时机
rollbackFor
属性允许我们指定一个或多个异常类,当方法抛出这些类型的异常时,即使它们是受检异常,事务也依然会回滚。
语法:@Transactional(rollbackFor = Exception.class)
最佳实践: 通常建议将 rollbackFor
设置为 Exception.class
,这表示任何类型的异常都会触发回滚,更符合大多数业务场景的预期。
2. isolation
: 定义事务的隔离级别
isolation
属性用于定义事务的隔离级别,以解决并发访问时可能出现的脏读、不可重复读、幻读等问题。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ_UNCOMMITTED | 可能 | 可能 | 可能 |
READ_COMMITTED | 不会 | 可能 | 可能 |
REPEATABLE_READ (MySQL默认) | 不会 | 不会 | 可能 |
SERIALIZABLE | 不会 | 不会 | 不会 |
语法:@Transactional(isolation = Isolation.REPEATABLE_READ)
最佳实践: 绝大多数情况下,我们无需手动设置隔离级别,直接使用数据库的默认级别(如 MySQL 的 REPEATABLE_READ
)即可。只有在特定业务场景需要解决并发问题时,才考虑调整它。
4.2. [性能] 整合 Redis 实现分布式缓存
“痛点”:在我们的项目中,findUserById
这样的查询操作可能会被频繁调用。每次调用都去查询数据库,不仅响应速度受限于磁盘 I/O,而且当并发量巨大时,会给数据库带来沉重的压力。
解决方案:引入缓存。对于那些不经常变化但频繁访问的数据(“读多写少”),我们可以将其第一次查询的结果存储在一个读取速度更快的介质中(如内存)。后续的请求将直接从缓存中获取数据,避免了对数据库的访问,从而极大地提升了应用的性能和吞吐量。
Spring 框架提供了一套名为 Spring Cache 的缓存抽象,它允许我们通过几个简单的注解,以一种“无侵入”的方式为方法增加缓存功能,而无需在业务代码中编写任何缓存相关的逻辑。
4.2.1. Spring Cache 核心注解
Spring Cache 的核心就是它的“注解三剑客”,它们分别对应了缓存的读、写、删操作。
注解 | 核心作用 | 适用场景 |
---|---|---|
@Cacheable | 读/写缓存:方法执行前,先检查缓存。如果命中,直接返回缓存数据,不执行方法体。如果未命中,则执行方法体,并将返回值放入缓存。 | 查询操作 (SELECT) |
@CachePut | 更新缓存:总是执行方法体,然后将方法的返回值更新到缓存中。 | 修改操作 (UPDATE) |
@CacheEvict | 删除缓存:方法执行后,从缓存中移除指定的条目。 | 删除操作 (DELETE) |
这些注解都共享一些通用属性,最核心的是:
cacheNames
(或value
): 指定缓存的名称(可以理解为缓存的分组或命名空间)。key
: 指定缓存条目的键 (Key)。这是缓存的唯一标识,支持强大的 SpEL 表达式。
4.2.2. 配置 Spring Boot Data Redis 作为缓存后端
Spring Cache 只是一个标准接口,我们需要为它提供一个具体的缓存实现。我们将使用 Redis 这个业界最主流的分布式缓存方案。
1. 添加依赖
我们需要在 demo-framework
模块中添加 spring-boot-starter-data-redis
依赖,它已经包含了 Spring Cache 所需的依赖。
首先,在父 pom.xml
的 <properties>
中统一版本:
文件路径: pom.xml
(根目录)
1 | <properties> |
然后,在父 pom.xml
的 <dependencyManagement>
中声明依赖:
文件路径: pom.xml
(根目录)
1 | <dependencyManagement> |
最后,在 demo-framework
模块中引入依赖:
文件路径: demo-framework/pom.xml
1 | <dependency> |
2. 启用缓存与配置序列化
第一步:启用缓存
我们需要在 demo-admin
启动模块的一个配置类上添加 @EnableCaching
注解,来正式开启 Spring 的缓存功能。
文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 | // ... imports ... |
第二步:配置 Redis 连接
在 demo-admin
的配置文件中,添加 Redis 的连接信息。
文件路径: demo-admin/src/main/resources/application.yml
(修改)
1 | spring: |
第三步:配置 JSON 序列化 (关键步骤)
Spring Boot 默认使用 JDK 序列化将 Java 对象存入 Redis,这会产生难以阅读的二进制乱码,且存在兼容性问题。最佳实践是配置使用 JSON 格式进行序列化。
我们在 demo-framework
模块下创建 CacheConfig
。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/CacheConfig.java
(新增)
1 | package com.example.demoframework.config; |
4.2.3. 实战:为 UserService 添加缓存
现在,我们为 UserServiceImpl
中的 CRUD 方法添加缓存注解。
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
(修改)
1 | // ... imports ... |
4.2.4. 验证缓存效果
重启
demo-admin
应用。进行查询: 调用
GET /users/1
。应用日志: 您会看到
"执行数据库查询: findUserById, ID: 1"
。Redis: 使用
redis-cli
执行GET "users::1"
(双冒号是默认分隔符),您会看到UserVO
的 JSON 字符串。1
2> GET "users::1"
"{\"@class\":\"com.example.demosystem.vo.UserVO\",\"id\":1,\"userName\":\"张三\",...}"
4.3. [新特性] 现代 HTTP 调用:RestClient
“痛点”:我们的应用(demo-system
)不会是一个孤岛。在真实的业务场景中,它经常需要与外部的、第三方的 HTTP API 或内部的其他微服务进行通信。例如:
- 调用支付网关完成支付。
- 请求天气服务获取实时天气。
- 在微服务架构中,调用订单服务查询订单详情。
Spring 长期以来提供了 RestTemplate
来满足这一需求,但它的 API 设计已略显陈旧。从 Spring Boot 3.2 开始,官方推荐使用一个全新的、更现代化的同步 HTTP 客户端——RestClient
。
4.3.1. 对比 RestTemplate,理解 RestClient 的优势
在深入实践之前,我们先通过一个简单的对比,来理解为什么 RestClient
是未来的方向。
场景:我们需要调用一个 API GET /posts/1
,并将返回的 JSON 转换为 PostDTO
对象。
1 | // 1. 实例化 RestTemplate |
痛点
- API 较为笨重,URL 和参数拼接繁琐。
- 错误处理依赖于传统的
try-catch
异常机制,不够优雅。 - 官方已将其置于 维护模式,不再推荐用于新项目。
1 | // 1. 通过构建器创建 RestClient |
优势
- 流式 API:代码如行云流水,可读性极高。
- 优雅的错误处理:通过
.onStatus()
方法,可以链式地、精准地处理不同类型的 HTTP 错误,代码更整洁。 - 官方推荐:作为
RestTemplate
的继任者,是 Spring Boot 3.2+ 中同步 HTTP 调用的 首选。
4.3.2. 实战:使用 RestClient 调用公开 API
现在,我们为项目增加一个新功能:通过用户 ID,调用一个公开的 JSONPlaceholder API,获取该用户发布的第一篇文章。
1. 准备工作:创建 DTO 与配置 Bean
第一步:创建 PostDTO 并添加 SpringDoc 注解
这个 DTO 用于承载从外部 API 返回的文章数据。我们为其属性添加 @Schema
注解,以便在 Swagger UI 中清晰展示。
文件路径: demo-system/src/main/java/com/example/demosystem/dto/external/PostDTO.java
(新增包和文件)
1 | package com.example.demosystem.dto.external; |
第二步:配置 RestClient Bean
最佳实践是在配置类中预先创建一个 RestClient
Bean。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
(修改)
1 | package com.example.demoframework.config; |
2. 实现 Service 与 Controller
文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(新增)
1 | package com.example.demosystem.service; |
文件路径: demo-system/src/main/java/com/example/demosystem/controller/ExternalApiController.java
(新增)
1 | package com.example.demosystem.controller; |
3. 回归测试
- 重启
demo-admin
应用。 - 访问 Swagger UI (
http://localhost:8080/swagger-ui.html
)。
预期响应:
您将成功获取到用户 ID 为 1 的第一篇文章的 JSON 数据,并被包装在我们的标准 Result
结构中。
1 | { |
通过重构,我们不仅学习了 RestClient
的现代化用法,还将其完美地融入了我们现有的技术体系:使用 SpringDoc 为新 API 生成了专业的文档,并利用 Hutool Assert 增强了业务代码的健壮性。在下一节,我们将学习如何为这种可能失败的外部调用增加自动重试的能力。
4.4. [弹性设计] 使用 Spring Retry 实现精准重试
在 4.3
节,我们掌握了 RestClient
这一强大的工具。但一个残酷的现实是:任何网络调用都可能失败。外部服务可能暂时过载、正在重启,或者网络出现瞬时抖动。如果我们的应用在第一次调用失败后就直接放弃,那么系统的健壮性(即弹性)将非常脆弱。
Spring Retry 提供了一套优雅的、声明式的解决方案,让我们可以通过注解,为可能失败的操作自动增加重试能力。
4.4.1. 弹性设计的核心:区分瞬时与永久性故障
在引入代码之前,我们必须先建立一个由您提出的、至关重要的心智模型:并非所有失败都值得重试。
将失败分为两类,是构建专业重试策略的基石:
- 永久性故障: 这类失败表明请求本身存在根本性问题,无论重试多少次,结果都将是相同的。例如,请求了一个不存在的资源 (
404
) 或因参数错误导致请求无效 (400
)。 - 瞬时故障: 这类失败通常是由服务端临时性问题或网络波动引起的。服务可能只是暂时不可用或过载,稍后重试有可能会成功。例如,服务不可用 (
503
) 或网关超时 (504
)。
我们的目标就是:只对瞬时故障进行重试,而对永久性故障执行快速失败。
故障类型 | HTTP 状态码 / 异常 | 我们的策略 | 原因 |
---|---|---|---|
永久性故障 | 400 , 401 , 403 , 404 | 快速失败 (Fail Fast) | 请求本身存在问题,重试是浪费资源。 |
瞬时故障 | 502 , 503 , 504 , TimeoutException | 重试 (Retry) | 服务端或网络临时问题,重试可能成功。 |
4.4.2. 准备工作:引入依赖与启用重试
1. 添加 Maven 依赖
Spring Retry 是一个独立的 Spring 项目,需要我们手动添加依赖。
首先,在父 pom.xml
的 <dependencyManagement>
中声明依赖:
文件路径: pom.xml
(根目录)
1 | <dependencyManagement> |
然后,在 demo-framework
模块中引入依赖:
Spring Retry 依赖于 AOP。由于我们的 demo-framework
已经引入了 spring-boot-starter-aop
,所以此处无需重复添加。
文件路径: demo-framework/pom.xml
1 | <!--Spring Boot 重试--> |
2. 启用重试功能
我们在 demo-admin
的主启动类上添加 @EnableRetry
注解,全局激活重试能力。
文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 | // ... imports ... |
4.4.3. 实战:实现对 5xx 错误码的精准重试
现在,我们将改造 ExternalApiService
,使其能够智能地区分 4xx
和 5xx
错误,并只对后者进行重试。
1. 创建自定义的可重试异常
为了让 @Retryable
注解能够精准识别目标,我们先创建一个专门用于标识瞬时故障的异常。
文件路径: demo-common/src/main/java/com/example/democommon/exception/RetryableException.java
(新增)
1 | package com.example.democommon.exception; |
2. 改造 ExternalApiService
文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)
1 | package com.example.demosystem.service; |
当然!使用像 httpbin.org
这样的专业测试服务是验证重试和熔断逻辑的最佳实践。您的建议非常好,这比我之前提出的修改 URI 的方式要严谨得多。
我将完全按照您的思路,重写测试小节,引导用户修改 RestClient
的 baseUrl
,并调用 httpbin.org
来精准地模拟 5xx
和 4xx
失败场景。
4.4.4. 回归测试:使用 httpbin.org 模拟故障
要完美地测试我们的精准重试逻辑,我们需要一个能按需返回指定 HTTP 状态码的 API 端点。为此,我们将使用 httpbin.org
这个强大的公开测试服务。
第一步:临时修改 RestClient
配置
为了让我们的 ExternalApiService
调用 httpbin.org
,我们需要暂时修改 RestClient
Bean 的 baseUrl
。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
(修改)
1 | // ... imports ... |
重要提示: 请记住,这只是为了测试目的。测试完成后,我们需要将 baseUrl
改回 https://jsonplaceholder.typicode.com
。
第二步:模拟 5xx 瞬时故障 (验证重试)
修改代码: 暂时将
ExternalApiService
中的.uri(...)
调用修改为指向httpbin.org
的503
状态码端点。文件路径:
demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)1
2
3
4
5
6
7
8
9
10
11// ...
public PostDTO getUserFirstPost(Integer userId) {
log.info("正在尝试调用外部 API, userId: {}", userId);
// 暂时修改 URI 来触发 503 错误
List<PostDTO> posts = restClient.get()
.uri("/status/503") // 硬编码到 503 错误端点
.retrieve()
// ... onStatus 处理器保持不变 ...
// ...重启
demo-admin
应用。调用接口:
GET /external/users/1
。观察日志:
- 您会看到日志 “正在尝试调用外部 API…” 打印了 3 次。
- 第一次和第二次调用之间,有 2 秒 的延迟。
- 第二次和第三次调用之间,有 4 秒 的延迟。
- 最终,您会看到
@Recover
方法中的错误日志 “调用外部 API 达到最大重试次数后仍然失败…”。 - 前端收到的最终响应是
data: null
。
第三步:模拟 4xx 永久性故障 (验证快速失败)
修改代码: 再次修改
ExternalApiService
中的.uri(...)
调用,这次指向httpbin.org
的404
状态码端点。文件路径:
demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)1
2
3
4
5
6
7
8
9
10
11// ...
public PostDTO getUserFirstPost(Integer userId) {
log.info("正在尝试调用外部 API, userId: {}", userId);
// 暂时修改 URI 来触发 404 错误
List<PostDTO> posts = restClient.get()
.uri("/status/404") // 硬编码到 404 错误端点
.retrieve()
// ... onStatus 处理器保持不变 ...
// ...重启
demo-admin
应用。调用接口:
GET /external/users/1
。观察日志:
- 您会看到日志 “正在尝试调用外部 API…” 只打印了 1 次。
- 没有任何重试相关的日志。
- 全局异常处理器捕获了
BusinessException
,并立即向前端返回了400 Bad Request
的Result
错误响应。
测试完成后,请务必将 WebConfig.java
中的 baseUrl
和 ExternalApiService.java
中的 .uri()
调用恢复原状!通过本次重构和精准测试,我们的应用在面对外部服务故障时,变得无比“聪明”和“坚韧”,这正是企业级弹性设计的核心体现。
5. [并发与调度] 异步任务和虚拟线程
摘要: 并非所有任务都需要在一次 HTTP 请求的生命周期内同步完成。本章我们将深入 Spring Boot 的并发编程模型,学习如何将耗时操作(如发送邮件、生成报表)异步化以提升用户体验。我们将掌握传统的 @Async
异步任务和 @Scheduled
定时任务,并重点探索 Spring Boot 3.2+ 对 JDK 21+ 虚拟线程 (Virtual Threads) 的革命性支持,体验新一代高并发编程的魅力。
开始本节前的准备工作:清理认证代码
为了让我们的学习焦点更集中,我们将暂时移除之前添加的 Token 认证逻辑。请按照以下清单,删除对应的文件和代码:
1. 删除以下文件:
- 文件路径:
demo-framework/src/main/java/com/example/demoframework/interceptor/AuthInterceptor.java
- 文件路径:
demo-framework/src/main/java/com/example/demoframework/config/SpringDocConfig.java
- 文件路径:
demo-system/src/main/java/com/example/demosystem/controller/AuthController.java
- 文件路径:
demo-system/src/main/java/com/example/demosystem/dto/auth/LoginDTO.java
2. 修改 WebConfig.java
:
打开 WebConfig.java
文件,删除或注释掉注册 AuthInterceptor
的相关代码。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
1 | package com.example.demoframework.config; |
完成以上清理工作后,请重新加载 Maven 项目,确保项目可以正常编译和启动。
5.1. 经典异步编程:@Async
与自定义线程池
5.1.1. 痛点分析与异步启用
场景故事 (痛点)
想象一下我们
UserService
中的saveUser
方法。在成功创建用户后,产品经理提出了一个新需求:需要给新用户发送一封欢迎邮件。如果我们直接在saveUser
方法里加入邮件发送逻辑,而这个邮件服务可能因为网络等原因需要耗时3秒,那么用户在前端点击“注册”按钮后,必须在页面上“转圈”等待3秒以上,才能收到“注册成功”的提示。这是一种糟糕的用户体验。
对于这类非核心、可延迟处理的耗时任务,最佳解决方案就是将其异步化:主线程(处理 HTTP 请求的线程)在触发邮件发送任务后,不等待其完成,而是立即返回响应,将真正的发送操作交给后台的另一个线程去处理。@Async
正是 Spring 为此提供的优雅实现。
第一步:启用异步 (@EnableAsync
)
首先,我们需要在项目中显式地开启对异步任务的支持。最佳实践是在 demo-framework
模块中创建一个专门的配置类。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java
(新增)
1 | package com.example.demoframework.config; |
5.1.2. 实战:创建并调用异步方法
第二步:创建异步服务
我们在 demo-system
模块中创建一个专门处理通知的服务,并在其中定义一个异步方法。
文件路径: demo-system/src/main/java/com/example/demosystem/service/NotificationService.java
(新增)
1 | package com.example.demosystem.service; |
第三步:集成到业务流程
现在,我们在 UserServiceImpl
中调用这个新的异步方法。
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
(修改)
1 | // ... imports ... |
第四步:验证效果
- 重启
demo-admin
应用。 - 调用
POST /users
接口新增一个用户。 - 观察现象:
- API 响应: 您会发现接口几乎是立即返回了
201 Created
的成功响应。 - 控制台日志: 日志的打印顺序将完美地展示异步执行流程。
- API 响应: 您会发现接口几乎是立即返回了
1 | // Tomcat HTTP 线程(例如 TID:48)立即完成并返回响应 |
结论: @Async
成功地将耗时3秒的邮件发送任务从主请求流程中剥离,极大地优化了用户体验。
5.1.3. 生产级配置:自定义线程池
严重警告: Spring Boot 在默认情况下使用的 SimpleAsyncTaskExecutor
是一个极其危险的线程池。它不会复用线程,而是为每一个 @Async
调用都创建一个全新的线程。在高并发场景下,这将迅速耗尽服务器的内存和线程资源,导致应用崩溃。在生产环境中,必须自定义线程池!
我们在 AsyncConfig
中创建一个 ThreadPoolTaskExecutor
Bean 来覆盖默认配置。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java
(修改)
1 | package com.example.demoframework.config; |
重启应用并再次测试,您会发现异步任务日志中的线程名已经变成了 MyAsync-1
,证明我们的自定义线程池已成功生效。
5.1.4. [进阶] 处理异步方法的返回值
如果我们需要获取异步任务的执行结果怎么办?@Async
方法可以通过返回 java.util.concurrent.CompletableFuture;
的实现类来做到这一点。
实践步骤
- 修改
NotificationService
:
1 | package com.example.demosystem.service; |
5.1.4. [进阶] 处理异步方法的返回值
如果我们需要获取异步任务的执行结果怎么办?@Async
方法可以通过返回 java.util.concurrent.Future
的实现类来做到这一点。
实践步骤
- 修改
NotificationService
:
1 | // NotificationService.java |
- 创建测试 Controller:
文件路径: demo-system/src/main/java/com/example/demosystem/controller/AsyncTestController.java
(新增)
重要信息: 注意,为了方便测试,我们在SpringMVC定义的Token验证的
1 | package com.example.demosystem.controller; |
验证效果: 调用 GET /async-result
接口。您会观察到,浏览器会等待约2秒后才收到响应。控制台日志会显示,Controller 先打印了“调用完毕”,然后才在调用 thenApply()
后打印“成功获取结果”,这清晰地展示了主线程被阻塞以等待异步结果的过程。
5.2. 定时任务调度:@Scheduled
场景故事 (痛点)
随着应用的运行,数据库中的操作日志表越来越大,影响查询性能。运维团队希望我们能开发一个功能,在每天流量最低的凌晨3点,自动将30天前的旧日志数据迁移到归档表中。这就需要一个无需人工干预、能按预定时间自动触发的机制。
@Scheduled
注解正是 Spring 提供的解决此类需求的标准方案。
5.2.1. 核心语法与启用
在动手之前,我们首先需要掌握 @Scheduled
注解的几种核心调度策略。
属性 | 核心作用 | 计时基准 |
---|---|---|
fixedRate | 按固定速率执行 | 从上一次任务的开始时间计算 |
fixedDelay | 按固定延迟执行 | 从上一次任务的结束时间计算 |
cron | 按 Cron 表达式指定的时间点执行 | 由表达式定义(如每天凌晨3点) |
启用调度 (@EnableScheduling
)
与 @EnableAsync
类似,我们需要先在项目中开启对定时任务的支持。
文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 | package com.example.demoadmin; |
5.2.2. 实战:创建定时任务
现在,我们在 demo-system
模块中创建一个专门存放定时任务的组件,并一次性实现多种调度策略。
文件路径: demo-system/src/main/java/com/example/demosystem/tasks/SystemTasks.java
(新增)
1 | package com.example.demosystem.tasks; |
5.2.3. 验证与对比
重启应用并观察控制台日志,您会清晰地看到 fixedRate
和 fixedDelay
的区别。
fixedRate
(固定速率) 验证
fixedRate
强调的是按固定频率触发。它以任务的上一次开始执行的时间点为基准,来计算下一次的开始时间。
1 | // 以下任务代码,其执行日志如下所示 |
1
2
3
4
5
6
FixedRate Task - 开始 @ 11:56:02
FixedRate Task - 结束 @ 11:56:04
FixedRate Task - 开始 @ 11:56:07 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:09
FixedRate Task - 开始 @ 11:56:12 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:14
fixedDelay
(固定延迟) 验证
fixedDelay
强调的是在上一次任务结束后,再等待一个固定的延迟时间。
1 | // 以下任务代码,其执行日志如下所示 |
1
2
3
4
5
6
FixedDelay Task - 开始 @ 11:56:02
FixedDelay Task - 结束 @ 11:56:04
FixedDelay Task - 开始 @ 11:56:07 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:09
FixedDelay Task - 开始 @ 11:56:12 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:14
总结: fixedRate
适合对执行频率要求严格的任务(如每分钟的心跳检测),而 fixedDelay
适合不希望任务并发执行、需要保证执行间隔的场景(如轮询数据库)。
5.3. [前沿] JDK 21+ 虚拟线程:新时代的并发模型
5.3.1. 痛点:传统平台线程的瓶颈分析
在 5.1
节,我们配置了自定义线程池,但其底层使用的仍是传统的 平台线程。平台线程是 Java 线程的经典实现,其核心特征是与操作系统内核线程存在一对一的直接映射关系。
这种 1:1 的映射模型带来了两个固有的限制:
- 资源成本高: 每个平台线程都对应一个操作系统内核线程。创建和管理内核线程对操作系统而言是高成本操作,需要分配独立的栈内存并涉及昂贵的上下文切换。
- 数量有限: 由于其资源消耗,一台服务器能同时有效运行的平台线程数量通常被限制在几百到几千的规模。
在 Spring Boot 的传统阻塞式 Web 模型中,每个 HTTP 请求在处理期间都会独占一个平台线程。如果该请求中包含一个耗时 200ms 的数据库查询,那么在这 200ms 的 I/O 等待期间,这个平台线程会进入阻塞 (Blocking) 状态。尽管此时 CPU 可能处于空闲,但该线程资源被完全占用,无法执行任何其他任务。
核心瓶颈
当大量并发请求涌入,特别是当这些请求大多是 I/O 密集型(如等待数据库、外部 API 响应)时,有限的平台线程池会迅速被占满。后续的请求不得不进入队列等待,导致应用程序的吞吐量(TPS)达到上限,无法进一步扩展。
解决方案:虚拟线程
虚拟线程 是 JDK 引入的一种轻量级线程实现,由 JVM 直接调度和管理,而非操作系统。
它与平台线程的核心区别在于,虚拟线程与操作系统内核线程之间不再是 1:1 的映射关系。相反,大量的虚拟线程可以运行在一个由少量平台线程构成的池(这些平台线程被称为载体线程, Carrier Threads)之上。
虚拟线程的革命性机制在于其处理阻塞 I/O 的方式:
当一个虚拟线程执行一个阻塞操作时,JVM 会自动将其挂起,并从其载体平台线程上**“卸载” (unmount)**。这使得该平台线程可以立即被释放,去执行其他准备就绪的虚拟线程。当 I/O 操作完成后,JVM 会将原来的虚拟线程重新调度到任意一个可用的载体线程上继续执行。
这种机制带来了显著优势:
- 创建成本极低: 虚拟线程是 JVM 管理的轻量级实体,本质上是 Java 堆上的对象,不直接消耗宝贵的操作系统线程资源。
- 支持海量并发: 由于成本低廉,单个 JVM 实例可以轻松创建和管理数百万个虚拟线程。
通过这种高效的协作和调度机制,极少数的平台线程便能支撑起海量虚拟线程的并发执行。对于 I/O 密集型应用而言,这意味着线程不再是瓶颈,应用的吞吐能力和资源利用率得到极大提升。
5.3.2. [实践] 一键开启:在 Spring Boot 中启用虚拟线程
Spring Boot 3.2+ 对 JDK 21+ 的虚拟线程提供了无与伦比的、一等公民级的支持。开启它,简单到令人难以置信。
前置条件:
- 确保您的项目使用 JDK 21 或更高版本。
- 确保您的 Spring Boot 版本为 3.2 或更高。
由于我们的设置版本是17,这里就不测试了,核心也只是一个配置项而已
1. 修改配置文件
我们只需在 application.yml
中添加一行配置。
文件路径: demo-admin/src/main/resources/application.yml
(修改)
1 | spring: |
就这么简单! 加上这行配置后,Spring Boot 会自动将内部的 Tomcat
Web 服务器切换到使用虚拟线程来处理每一个进来的 HTTP 请求。
5.3.3. 性能对比:虚拟线程 vs. 传统线程池的适用场景
既然虚拟线程如此强大且易于开启,是否意味着我们之前配置的 @Async
传统线程池就过时了呢?答案是:并非如此。它们是为解决不同问题而设计的。
对比维度 | 平台线程 (我们自定义的线程池) | 虚拟线程 (Spring Boot 自动管理) |
---|---|---|
核心本质 | 珍贵的操作系统内核线程的直接映射 | JVM 管理的、轻量级的用户态线程 |
资源成本 | 高 (创建和上下文切换开销大) | 极低 (几乎没有额外开销) |
最佳场景 | CPU 密集型任务 (如:复杂计算、图像处理、数据加密) | I/O 密集型任务 (如:等待数据库、调用外部API、读写文件) |
数量 | 有限 (通常几十到几百) | 海量 (可轻松达到数百万) |
使用方式 | 通过 @Async 注解,用于后台异步计算 | 通过配置开启,用于处理海量并发的 Web 请求 |
既然 Spring Boot 3.2+ 开启虚拟线程如此简单,那我们为 @Async
自定义的 ThreadPoolTaskExecutor
线程池还有存在的必要吗?
非常有必要!它们解决的是不同维度的问题。
虚拟线程 的核心优势是解决海量并发 I/O 密集型 任务的等待问题,比如成千上万的用户同时请求我们的 API。它的设计目标是“不阻塞平台线程”,从而提高系统吞吐量。
而我们为 @Async
自定义的 平台线程池,其核心优势是处理后台的 CPU 密集型 任务。这类任务需要持续占用 CPU 进行计算,我们通过一个固定大小(比如等于 CPU 核心数)的线程池来执行它们,可以避免频繁的线程创建和销毁,并防止过多的线程因争抢 CPU 而导致性能下降。
明白了。所以可以总结为:用虚拟线程处理前端进来的海量 I/O 请求,用平台线程池处理后台的重量级计算任务。
完全正确。这正是 Spring Boot 3.2+ 所倡导的现代并发编程模型,两者相得益彰,而非互相替代。
总结: 虚拟线程是 Java 和 Spring Boot 在高并发领域的一项革命性进步。它以极低的成本,极大地提升了传统 I/O 密集型应用的性能和可伸缩性。在您的下一个项目中,如果使用了 JDK 21+ 和 Spring Boot 3.2+,请毫不犹豫地开启它。
6. [质量保障] Spring Test:构建可靠的应用
6.1. 基础构筑:测试环境与核心理念
在编写任何测试代码之前,我们必须先确保两件事:一是我们的“工具箱”是齐全的,二是我们的“指导思想”是正确的。本节将为您铺平这两条道路。
6.1.1. 依赖先行:检查我们的“测试工具箱”
一个好消息是,Spring Initializr 已经为我们准备好了一切。当您创建项目时,pom.xml
中会自动包含一个名为 spring-boot-starter-test
的依赖,它就是我们的“测试工具箱”。
文件路径: demo-system/pom.xml
1 | <dependency> |
这个工具箱里包含了四件宝物,让我们来逐一认识它们:
工具 | 它的角色 | 通俗解释 |
---|---|---|
JUnit 5 | 测试的“裁判员” | 它是执行我们测试代码的框架,负责运行测试、收集结果,并最终告诉我们测试是通过了 (Pass ) 还是失败了 (Fail )。 |
Spring Test | Spring世界的“翻译官” | 它是一座桥梁,让 JUnit 5 能够理解 Spring 的应用上下文(Application Context),使我们可以在测试中获取和使用 Spring 管理的 Bean。 |
AssertJ | 结果的“鉴定师” | 它提供了一套非常流畅、易读的 API 来验证我们的代码结果是否符合预期。例如 assertThat(name).isEqualTo("张三"); 读起来就像一句自然语言。 |
Mockito | 专业的“特技演员” | 它是最重要的工具之一。当我们的代码依赖其他复杂组件时,Mockito 可以创建一个“假的”替代品(称为 Mock),让我们能隔离地测试当前的代码。 |
6.1.2. 核心理念:为什么要“隔离”?
想象一下我们要测试一辆汽车的发动机。
集成测试: 把发动机装进完整的汽车里,打着火,开上路跑一圈。这种方式很真实,能测试所有部件的协同工作,但如果车子没启动,你很难立刻知道问题是出在发动机、变速箱还是电路系统上。而且,每次测试都要开动整辆车,成本很高,速度也很慢。
单元测试: 把发动机拆下来,放到一个专用的测试台上。我们用模拟的油管、电路和传动轴连接它,然后启动。如果发动机正常运转,我们就知道发动机本身是好的。这个过程非常快,而且能精准定位问题。
在我们的软件中,UserServiceImpl
就是“发动机”,而它依赖的 UserMapper
就是“变速箱”。纯单元测试的目标,就是把 UserServiceImpl
这台“发动机”单独拿出来测试,用 Mockito 创造一个假的“变速箱”(UserMapper
)来配合它,从而确保 UserServiceImpl
自身的业务逻辑是绝对正确的。
6.1.3. 两种核心测试模式的澄清
基于以上理念,Spring Boot 的测试主要分为两种泾渭分明的模式。混淆这两种模式,是导致测试失败和混乱的根源。
目标:快、准、狠地测试单个类
这种模式完全不涉及 Spring 容器,是我们测试 Service 层和工具类的首选。
核心工具
@ExtendWith(MockitoExtension.class)
:告诉 JUnit 5:“这场测试由 Mockito 负责!”@InjectMocks
:标记我们要测试的“发动机”(例如UserServiceImpl
)。@Mock
:标记需要被模拟的“变速箱”和其他依赖(例如UserMapper
)。
特征
- 不启动 Spring 容器。
- 执行速度极快,以毫秒计。
- 测试代码中绝对不会出现
@SpringBootTest
或@Autowired
。
目标:测试组件间的真实协作
当我们需要测试像 Controller、数据库交互这类依赖 Spring 框架功能的场景时,就需要启动一个真实的 Spring 容器。
核心工具
@SpringBootTest
或测试切片(如@WebMvcTest
):告诉 Spring:“请为我启动一个测试用的应用环境!”@Autowired
:从 Spring 容器中获取真实的 Bean 实例。@MockBean
:当我们需要在 Spring 容器中,用一个“假的”Bean 替换掉一个“真的”Bean 时使用。
特征
- 启动一个真实的 Spring 容器。
- 执行速度相对较慢。
- 用于测试跨层调用或框架集成点。
6.2. [核心实践] 纯单元测试:快如闪电的业务逻辑验证
我们实践的第一个、也是最重要的测试类型,就是纯单元测试。它的核心是隔离——把我们的“发动机”(UserServiceImpl
)单独拿出来,用一个假的“变速箱”(UserMapper
)来配合,以此验证“发动机”本身的逻辑是否正确。
核心工具: 本节我们将只使用 @ExtendWith(MockitoExtension.class)
, @InjectMocks
, 和 @Mock
。请注意,全程不会出现 @SpringBootTest
。
6.2.1. 场景设定:测试 UserServiceImpl
- 被测试对象:
UserServiceImpl
- 被模拟的依赖:
UserMapper
- 被测试方法:
findUserById(Long id)
- 核心验证逻辑:
- 当
UserMapper
返回一个User
实体时,UserServiceImpl
能否正确地将其转换为UserVO
? - 当
UserMapper
返回null
时,UserServiceImpl
能否同样返回null
?
- 当
6.2.2. 编写纯单元测试
现在,我们来创建测试文件。按照 Maven 的标准约定,测试代码应该放在 src/test/java
目录下,并且包结构与主代码(src/main/java
)保持一致。
我们遵循遵循的是 BDD(行为驱动开发)标准,强调从用户行为和需求出发,通过 Given - When - Then 这种结构化方式来描述和验证软件功能
Given 是设定测试初始条件,像准备好输入数据等;
When 是执行要测试的方法或操作;
Then 则是验证操作后的输出结果是否符合预期 。
文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.java
(新增)
1 | package com.example.demosystem.service.impl; |
执行与观察:
您可以直接在 IDEA 中运行这个测试类或单个测试方法。您会发现测试几乎是瞬时完成的。
核心结论: 我们成功地、完全隔离地验证了 UserServiceImpl
的内部业务逻辑,而整个过程完全没有启动 Spring Boot 应用,也没有连接数据库。这正是单元测试强大且高效的魅力所在。
6.2.3. [深入] 行为验证:verify
的使用
痛点:如何测试 void
方法?
在 6.2.2
中,我们测试的 findUserById
方法有返回值,所以我们可以通过断言返回值来判断方法是否正确。但如果一个方法没有返回值(void
),比如 deleteUser
,我们该如何测试它呢?
1 | // UserService.java |
我们无法断言返回值,但我们的测试目标是:确保 userService.deleteUserById(1L)
在被调用时,其内部的 userMapper.deleteById(1L)
方法也必须被正确地调用了。
这就是行为验证的用武之地,而 Mockito.verify()
正是实现这一目标的核心工具。
编写行为验证测试
我们回到 UserServiceImplTest.java
,为 deleteUser
方法添加一个新的测试用例。
文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.java
(添加新方法)
1 | // ... (imports and existing class structure) ... |
verify
的更多用法
verify
是一个非常灵活的工具,它还有很多强大的验证模式:
验证从未被调用:
1
2// 验证 userMapper 的 insert 方法在本次测试中从未被调用过
verify(userMapper, never()).insert(any(User.class));验证至少/至多调用次数:
1
2
3
4// 验证至少被调用了一次
verify(userMapper, atLeast(1)).deleteById(1L);
// 验证至多被调用了五次
verify(userMapper, atMost(5)).deleteById(1L);使用参数匹配器: 当我们不关心传入的具体参数值,只关心类型时:
1
2// 验证 deleteById 方法被调用过,且参数是任意的 Long 类型值
verify(userMapper).deleteById(anyLong());
核心结论:when(...).thenReturn(...)
用于设定(Given)模拟对象的返回值。verify(...)
用于验证(Then)模拟对象的行为是否发生。
对于 void
方法,行为验证 (verify
) 是我们最主要的、有时也是唯一的测试手段。它确保了被测试单元和其协作者之间的“交互约定”是正确的。
6.2.4. [深入] 深入测试:验证交互、细节与异常
在真实的业务场景中,一个方法通常不只是简单的数据转换。它包含了前置校验、与多个依赖组件的交互、内部状态处理以及异常情况的抛出。一个专业的单元测试必须能够全面地覆盖这些复杂的逻辑。
现在,更完整的 UserServiceImpl
中的 saveUser
方法为目标,来编写一套能体现专业水准的单元测试。
被测试方法源码回顾:
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
1 |
|
测试“成功路径”:verify
与 ArgumentCaptor
的组合拳
ArgumentCaptor 是 Mockito 框架中的一个工具类,主要用于在单元测试中捕获方法调用时的参数值。在测试过程中,我们经常需要验证某个方法是否被调用,以及调用时传入的参数值是否符合预期。ArgumentCaptor 提供了一种便捷的方式来捕获和断言这些参数值。使用 ArgumentCaptor,您可以:
- 捕获特定方法的参数。
- 对捕获的参数进行断言,确保它们符合测试的预期。
- 在测试中重用捕获的参数值。
目标: 验证当用户名不存在时,saveUser
方法能否正确执行所有预期操作。
文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.java
(添加新方法)
1 | package com.example.demosystem.service.impl; |
测试“失败路径”:验证异常与防御性编程
目标: 验证当用户名已存在时,saveUser
方法能否如期抛出 BusinessException
,并且不会执行后续的任何操作。
文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.java
(添加新方法)
1 | // ... (imports and existing class structure) ... |
核心结论:
通过组合使用 when
(设定条件)、verify
(验证交互)、ArgumentCaptor
(捕获细节) 和 assertThatThrownBy
(验证异常),我们为 saveUser
这个相对复杂的业务方法构建了一套全面而健壮的单元测试防护网。它不仅能验证成功时的结果,更能确保失败时的处理逻辑也如我们预期般稳固。
6.3. [核心实践] 集成测试:验证组件间的协同工作
在 6.2
节,我们像是在测试台上测试一台独立的“发动机”(UserServiceImpl
)。现在,我们要把“发动机”装回“车身”,并连接上“仪表盘”和“控制电路”(Controller
和 Spring MVC 框架),然后测试这辆“汽车”作为一个整体系统能否正确响应我们的操作。这就是集成测试。
6.3.1. 为何需要集成测试?(灵魂拷问:Postman 不香吗?)
在学习本节前,相信很多读者(包括正在阅读的您)心中都会有一个巨大的疑问:
“为什么我要学习一套这么复杂的测试框架?我用 Postman 或其他 API 工具,对着启动好的程序发一个真实的 POST 请求,然后看看返回结果,不也是测试吗?那样不是更简单、更真实吗?”
您能提出这个问题,说明您已经思考到了测试策略的核心。这个问题的答案,正是区分“手动测试”与“自动化质量保障”的关键。
对比维度 | 您的直觉 (如 Postman) | 我们正在学的方法 (MockMvc ) |
---|---|---|
测试目标 | 验证一个完整、正在运行的系统 | 验证开发中的 Controller 代码是否正确 |
运行环境 | 需要手动启动整个 Spring Boot 应用、数据库、Redis… | 无需启动应用,在内存中模拟 Web 环境 |
执行速度 | 慢 (秒级甚至分钟级) | 快 (毫秒级) |
自动化 | 难以集成到自动化构建流程(CI/CD) | 极易集成,是 CI/CD 的核心环节 |
稳定性 | 低 (测试结果易受网络、数据库数据变化的影响) | 高 (依赖被 Mock,每次运行结果都一样,可重复) |
最佳用途 | 开发完成后、部署前的手动探索性测试或系统功能验收 | 开发过程中,作为代码提交前的自动化质量卡点 |
核心价值:
您用 Postman 的方式,是一次性的功能验证。而我们学习 MockMvc
,是为了构建一套可重复的、自动化的安全网。
在团队协作中,这套安全网可以确保任何人的任何一次代码提交,都不会意外地破坏掉您或其他同事编写的 API 接口。它将“质量保障”从一件靠人力和自觉性的事情,变成了一个由机器自动执行的、可靠的工程流程。这,就是它虽然复杂,但却无可替代的理由。
6.3.2. 前置准备:解决多模块的“上下文”难题
现在,我们正式开始集成测试的准备工作。首先,必须解决那个在多模块项目中必定会遇到的“大坑”。
问题: 直接在 demo-system
这样的子模块中运行集成测试(如 @WebMvcTest
),会因为找不到主启动类而失败,抛出 Unable to find a @SpringBootConfiguration
异常。
解决方案:为 demo-system
模块的测试环境创建一个专门的、轻量级的启动类。
- 在
demo-system
模块的src/test/java
目录下,创建一个与主代码平行的包,例如com.example.demosystem
。 - 在这个包里创建一个新的 Java 类
TestApplication.java
。
文件路径: demo-system/src/test/java/com/example/demosystem/TestApplication.java
(新增)
1 | package com.example.demosystem; |
完成了这个简单的准备工作,我们就为后续所有集成测试铺平了道路。
6.3.3. [实战] 编写精准的 Controller 集成测试
在完成了前置的环境准备后,我们现在可以正式为 UserController
编写集成测试。
文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java
(新增)
1 | package com.example.demosystem.controller; |
6.3.4. [实战] 深入 MockMvc:测试 POST 请求与请求体
我们已经成功地测试了 GET
请求,确保了“读”操作的正确性。接下来,我们将更进一步,学习如何测试“写”操作,这需要我们向 Controller
发送一个带有 JSON 请求体(Request Body)的 POST
请求。
目标: 为 UserController
中的 POST /users
(新增用户) 接口编写集成测试,确保它能正确接收、处理 DTO,并返回预期的创建成功响应。
我们将继续在 UserControllerTest.java
中添加新的测试方法。
文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java
(添加新方法)
1 |
|
核心结论:
通过这个测试,我们掌握了模拟带有请求体的 POST
请求的关键步骤:
- 使用
@Autowired
的ObjectMapper
来将 Java 对象转换为 JSON 字符串。 - 在
perform()
中使用.contentType()
来声明请求体格式。 - 使用
.content()
来承载 JSON 字符串作为请求体。 - 使用
status().isCreated()
来断言 RESTful 风格的创建成功状态码。
至此,您已经掌握了测试 Controller
中最核心的“读”(GET
)和“写”(POST
)操作的能力。
6.3.5. [进阶] 深入 MockMvc:测试校验失败与异常处理
到目前为止,我们测试的都是“成功路径”(Happy Path)。但在真实世界中,代码的健壮性更多地体现在它如何优雅地处理错误。本节,我们将学习如何使用 MockMvc
来验证两种最常见的失败场景:输入校验失败和业务异常抛出。
场景一:测试 Bean Validation 校验失败
我们的 UserController
在 saveUser
方法上使用了 @Validated
注解,它会根据 UserEditDTO
中定义的规则(如 @NotBlank
)进行输入校验。如果校验失败,我们的 GlobalExceptionHandler
会捕获 MethodArgumentNotValidException
并返回一个 HTTP 400
响应。现在,我们就来测试这个流程。
文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java
(添加新方法)
1 |
|
场景二:测试 Service 层抛出的业务异常
Controller
的职责之一就是调用 Service
。如果 Service
抛出了一个业务异常(BusinessException
),Controller
并不直接处理,而是交由 GlobalExceptionHandler
来捕获并转换为统一的 JSON 响应。我们就来测试这个完整的“异常传递与处理”链路。
文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java
(添加新方法)
1 |
|
摘要: 恭喜您!坚持学习到这里,您已经走完了从 Spring Boot 基础到核心实践的关键一步。我们一起从零开始,搭建项目、管理配置、实践 AOP、操作数据库、实现事务与缓存、调用外部 API,并最终为我们的代码构建了一套专业、自动化的测试安全网。您现在掌握的,不仅仅是 Spring Boot 的使用方法,更是一套符合现代软件工程标准的开发思想与流程。
我们所学的,是构建任何一个坚实系统的“地基”。UserService
虽然简单,但“麻雀虽小,五脏俱全”,它身上凝聚了我们对配置、分层、数据处理、接口设计和质量保障的全部心血。
但这仅仅是开始。一个真正强大的、企业级的分布式系统,还需要在更多维度上进行深化和扩展。接下来的学习路线,将为您揭开这幅宏伟蓝图的全貌。