diff --git a/Readme.md b/Readme.md
index f67ca28d..d410cece 100644
--- a/Readme.md
+++ b/Readme.md
@@ -18,7 +18,7 @@
## 模块一览
-一共 26 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。
+一共 30 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。
| 模块 | 演示内容 | 启动类 | 需要的外部服务 |
| --- | --- | --- | --- |
@@ -48,10 +48,14 @@
| `i18n` | 国际化:`MessageSource` + `LocaleResolver` 多语言文案 | `I18nMain` | — |
| `ratelimit` | 接口限流:`HandlerInterceptor` + 固定时间窗口 | `RateLimitMain` | — |
| `graphql` | GraphQL 查询接口(`@QueryMapping` + schema) | `GraphqlMain` | — |
+| `kafka` | Kafka 消息收发(`KafkaTemplate` + `@KafkaListener`) | `KafkaMain` | Kafka |
+| `mail` | 邮件发送(`SimpleMailMessage` / `MimeMessageHelper`) | `MailMain` | SMTP 服务器 |
+| `elasticsearch` | Spring Data Elasticsearch 文档检索 | `ElasticsearchMain` | Elasticsearch |
+| `flyway` | Flyway 数据库迁移 + `JdbcTemplate` 读取 | `FlywayMain` | MySQL |
除 `socket` 使用 8081 外,其余模块都监听 **8080**,所以一次只启动一个模块。
-其中 `validation`、`fileupload`、`restclient`、`cache`、`actuator`、`async`、`aop`、`httpinterface`、`i18n`、`ratelimit`、`graphql` 十一个模块附带可直接运行的测试。它们都不依赖外部服务,`mvn test` 即可跑通,也可以当作各自技术点的可执行文档来读。
+其中 `validation`、`fileupload`、`restclient`、`cache`、`actuator`、`async`、`aop`、`httpinterface`、`i18n`、`ratelimit`、`graphql`、`kafka`、`mail`、`flyway` 十四个模块附带可直接运行的测试。它们的测试都不依赖外部服务——`kafka` 用内嵌 Kafka、`mail` 用 GreenMail 内嵌 SMTP、`flyway` 用内嵌 H2,其余用 Spring 测试上下文;`mvn test` 即可跑通,也可以当作各自技术点的可执行文档来读。
## 技术栈
@@ -68,6 +72,10 @@
| 监控 | Micrometer + Spring Boot Actuator(`actuator` 模块) |
| AOP | AspectJ(`aop` 模块) |
| GraphQL | Spring GraphQL + GraphQL Java(`graphql` 模块) |
+| 消息 | Spring for Apache Kafka(`kafka` 模块) |
+| 邮件 | Spring Mail + Jakarta Mail(`mail` 模块) |
+| 搜索引擎 | Spring Data Elasticsearch(`elasticsearch` 模块) |
+| 数据库迁移 | Flyway(`flyway` 模块) |
| 其他 | MyBatis、Lettuce(Redis)、jsoup、Apache POI、fastjson2、zxing |
Spring、Jackson、Hibernate、JUnit 等版本统一由 `spring-boot-dependencies` BOM 管理,不在本项目中单独指定。
diff --git a/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java b/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java
index 8cddb33b..bd6b8179 100644
--- a/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java
+++ b/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java
@@ -1,5 +1,6 @@
package info.xiaomo.actuator.health;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
@@ -16,9 +17,14 @@
public class DiskSpaceRatioHealthIndicator implements HealthIndicator {
/**
- * 可用空间低于该比例就判定为不健康。
+ * 可用空间低于该比例就判定为不健康, 可通过配置覆盖(测试里把它设成 0 以消除对磁盘的依赖)。
*/
- private static final double THRESHOLD = 0.05;
+ private final double threshold;
+
+ public DiskSpaceRatioHealthIndicator(
+ @Value("${actuator.disk-space-ratio.threshold:0.05}") double threshold) {
+ this.threshold = threshold;
+ }
@Override
public Health health() {
@@ -30,12 +36,12 @@ public Health health() {
}
double freeRatio = (double) free / total;
- Health.Builder builder = freeRatio >= THRESHOLD ? Health.up() : Health.down();
+ Health.Builder builder = freeRatio >= threshold ? Health.up() : Health.down();
return builder
.withDetail("totalBytes", total)
.withDetail("freeBytes", free)
.withDetail("freeRatio", String.format("%.4f", freeRatio))
- .withDetail("threshold", THRESHOLD)
+ .withDetail("threshold", threshold)
.build();
}
diff --git a/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java b/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java
index c80698c0..ce04c6d7 100644
--- a/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java
+++ b/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java
@@ -10,7 +10,10 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-@SpringBootTest(classes = ActuatorMain.class)
+@SpringBootTest(classes = ActuatorMain.class, properties = {
+ // 把自定义磁盘比例阈值的判定下限改成 0, 让测试不依赖真实磁盘空间, 避免磁盘快满时误报不健康
+ "actuator.disk-space-ratio.threshold=0"
+})
@AutoConfigureMockMvc
class ActuatorEndpointTest {
diff --git a/changeLog.md b/changeLog.md
index eec48151..af5b8b39 100644
--- a/changeLog.md
+++ b/changeLog.md
@@ -108,3 +108,19 @@ LocalVariableTableParameterNameDiscoverer, 因此未显式命名的 @PathVariabl
几个 Spring Boot 4 下的注意点: aop starter 已从 spring-boot-starter-aop 改名为 spring-boot-starter-aspectj;
声明式客户端直接使用 spring 内置的 @HttpExchange, 无需再引入 OpenFeign; ratelimit 模块把 Clock 抽成
bean, 测试里用 @Primary 注入一个可拨动的时钟, 不靠 sleep 验证"窗口滚动后放行"。
+
+- 2026-09-14 新增四个带外部依赖的示例模块
+
+```
+1. 新增 kafka 模块: KafkaTemplate + @KafkaListener 消息收发, 测试用 @EmbeddedKafka 内嵌 broker
+2. 新增 mail 模块: SimpleMailMessage / MimeMessageHelper 发邮件, 测试用 GreenMail 内嵌 SMTP
+3. 新增 elasticsearch 模块: Spring Data Elasticsearch 定义索引 + 仓储检索(与 mongodb 一样不带自动化测试)
+4. 新增 flyway 模块: db/migration 下的 V1/V2 迁移脚本 + JdbcTemplate 读取, 测试用内嵌 H2
+5. kafka/mail/flyway 三个模块的测试都不依赖外部服务, CI 上 mvn clean install 可稳定跑通
+```
+
+几个 Spring Boot 4 下的注意点: kafka 和 flyway 的自动配置都从 spring-boot-autoconfigure 抽成了独立
+starter(spring-boot-starter-kafka / spring-boot-starter-flyway), 直接引 spring-kafka / flyway-core 拿不到自动配置;
+flyway 模块保留了 DataSource, 此时必须再把 JPA 相关自动配置(HibernateJpaAutoConfiguration + DataJpaRepositoriesAutoConfiguration)
+排除掉, 否则 core 传递引入的 spring-data-jpa 会尝试创建 entityManagerFactory; mail 测试要把
+spring.mail.properties.mail.smtp.auth / starttls 关掉并清空用户名密码, 否则 JavaMailSender 会在 GreenMail 上做 AUTH。
diff --git a/elasticsearch/pom.xml b/elasticsearch/pom.xml
new file mode 100644
index 00000000..2366f03e
--- /dev/null
+++ b/elasticsearch/pom.xml
@@ -0,0 +1,41 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ elasticsearch
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-data-elasticsearch
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/elasticsearch/src/main/java/info/xiaomo/elasticsearch/ElasticsearchMain.java b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/ElasticsearchMain.java
new file mode 100644
index 00000000..1fbe8ba1
--- /dev/null
+++ b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/ElasticsearchMain.java
@@ -0,0 +1,29 @@
+package info.xiaomo.elasticsearch;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
+import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
+
+/**
+ * Elasticsearch 启动器。
+ *
+ *
和 Mongo 一样走 Spring Data 家族: 定义 @Document 实体 + ElasticsearchRepository 接口,
+ * 框架自动生成实现。服务地址在 application.properties 里配置(spring.elasticsearch.uris)。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@EnableElasticsearchRepositories
+@ComponentScan("info.xiaomo.elasticsearch")
+public class ElasticsearchMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ElasticsearchMain.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/elasticsearch/src/main/java/info/xiaomo/elasticsearch/controller/ArticleController.java b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/controller/ArticleController.java
new file mode 100644
index 00000000..c125b9ea
--- /dev/null
+++ b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/controller/ArticleController.java
@@ -0,0 +1,44 @@
+package info.xiaomo.elasticsearch.controller;
+
+import info.xiaomo.core.base.Result;
+import info.xiaomo.elasticsearch.model.Article;
+import info.xiaomo.elasticsearch.service.ArticleService;
+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;
+
+import java.util.List;
+
+/**
+ * @author : xiaomo
+ */
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleController {
+
+ private final ArticleService service;
+
+ public ArticleController(ArticleService service) {
+ this.service = service;
+ }
+
+ @PostMapping
+ public Result index(@RequestBody Article article) {
+ return new Result<>(service.index(article));
+ }
+
+ @GetMapping("/search")
+ public Result> search(@RequestParam String title) {
+ return new Result<>(service.searchByTitle(title));
+ }
+
+ @GetMapping("/author/{author}")
+ public Result> byAuthor(@PathVariable String author) {
+ return new Result<>(service.findByAuthor(author));
+ }
+
+}
\ No newline at end of file
diff --git a/elasticsearch/src/main/java/info/xiaomo/elasticsearch/model/Article.java b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/model/Article.java
new file mode 100644
index 00000000..d7247025
--- /dev/null
+++ b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/model/Article.java
@@ -0,0 +1,40 @@
+package info.xiaomo.elasticsearch.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.elasticsearch.annotations.Document;
+import org.springframework.data.elasticsearch.annotations.Field;
+import org.springframework.data.elasticsearch.annotations.FieldType;
+
+import java.util.List;
+
+/**
+ * @Document(indexName = "articles") 把这个类映射成 ES 里的一个索引。
+ * 中文分词这类定制需要给索引配置相应 analyzer, 这里用默认标准分析器保持零依赖可跑。
+ *
+ * @author : xiaomo
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Document(indexName = "articles")
+public class Article {
+
+ @Id
+ private String id;
+
+ @Field(type = FieldType.Text)
+ private String title;
+
+ @Field(type = FieldType.Text)
+ private String content;
+
+ @Field(type = FieldType.Keyword)
+ private String author;
+
+ @Field(type = FieldType.Keyword)
+ private List tags;
+
+}
\ No newline at end of file
diff --git a/elasticsearch/src/main/java/info/xiaomo/elasticsearch/repository/ArticleRepository.java b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/repository/ArticleRepository.java
new file mode 100644
index 00000000..6c22c754
--- /dev/null
+++ b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/repository/ArticleRepository.java
@@ -0,0 +1,20 @@
+package info.xiaomo.elasticsearch.repository;
+
+import info.xiaomo.elasticsearch.model.Article;
+import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
+
+import java.util.List;
+
+/**
+ * 方法名即查询: findByTitleContaining 会生成对 title 字段做 match 的查询。
+ * 复杂的 bool/聚合查询再换成 NativeQuery 或 @Query 注解。
+ *
+ * @author : xiaomo
+ */
+public interface ArticleRepository extends ElasticsearchRepository {
+
+ List findByTitleContaining(String keyword);
+
+ List findByAuthor(String author);
+
+}
\ No newline at end of file
diff --git a/elasticsearch/src/main/java/info/xiaomo/elasticsearch/service/ArticleService.java b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/service/ArticleService.java
new file mode 100644
index 00000000..045e5c8b
--- /dev/null
+++ b/elasticsearch/src/main/java/info/xiaomo/elasticsearch/service/ArticleService.java
@@ -0,0 +1,37 @@
+package info.xiaomo.elasticsearch.service;
+
+import info.xiaomo.elasticsearch.model.Article;
+import info.xiaomo.elasticsearch.repository.ArticleRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * @author : xiaomo
+ */
+@Service
+public class ArticleService {
+
+ private final ArticleRepository repository;
+
+ public ArticleService(ArticleRepository repository) {
+ this.repository = repository;
+ }
+
+ public Article index(Article article) {
+ if (article.getId() == null || article.getId().isBlank()) {
+ article.setId(UUID.randomUUID().toString());
+ }
+ return repository.save(article);
+ }
+
+ public List searchByTitle(String keyword) {
+ return repository.findByTitleContaining(keyword);
+ }
+
+ public List findByAuthor(String author) {
+ return repository.findByAuthor(author);
+ }
+
+}
\ No newline at end of file
diff --git a/elasticsearch/src/main/resources/config/application.properties b/elasticsearch/src/main/resources/config/application.properties
new file mode 100644
index 00000000..501f0323
--- /dev/null
+++ b/elasticsearch/src/main/resources/config/application.properties
@@ -0,0 +1,10 @@
+logging.config=classpath:config/logback-dev.xml
+server.port=8080
+
+server.max-http-header-size=20971520
+
+spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
+spring.jackson.time-zone=GMT+8
+
+# elasticsearch
+spring.elasticsearch.uris=http://localhost:9200
\ No newline at end of file
diff --git a/elasticsearch/src/main/resources/config/logback-dev.xml b/elasticsearch/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/elasticsearch/src/main/resources/config/logback-dev.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/flyway/pom.xml b/flyway/pom.xml
new file mode 100644
index 00000000..35918ea5
--- /dev/null
+++ b/flyway/pom.xml
@@ -0,0 +1,57 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ flyway
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-jdbc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/flyway/src/main/java/info/xiaomo/flyway/FlywayMain.java b/flyway/src/main/java/info/xiaomo/flyway/FlywayMain.java
new file mode 100644
index 00000000..098d7a91
--- /dev/null
+++ b/flyway/src/main/java/info/xiaomo/flyway/FlywayMain.java
@@ -0,0 +1,29 @@
+package info.xiaomo.flyway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
+import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Flyway 启动器。
+ *
+ * 注意这里和其他模块不一样: 没有排除 DataSourceAutoConfiguration —— Flyway 需要一个
+ * DataSource 来执行 db/migration 下的脚本。因为示例用 JdbcTemplate 而非 JPA 读数据,
+ * 所以只需排除 JPA 相关的自动配置: 有了 DataSource 之后, 若不排除会触发 core 传递引入的
+ * Spring Data JPA 去建 entityManagerFactory。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {HibernateJpaAutoConfiguration.class, DataJpaRepositoriesAutoConfiguration.class})
+@ComponentScan("info.xiaomo.flyway")
+public class FlywayMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(FlywayMain.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/flyway/src/main/java/info/xiaomo/flyway/controller/UserController.java b/flyway/src/main/java/info/xiaomo/flyway/controller/UserController.java
new file mode 100644
index 00000000..268764e5
--- /dev/null
+++ b/flyway/src/main/java/info/xiaomo/flyway/controller/UserController.java
@@ -0,0 +1,35 @@
+package info.xiaomo.flyway.controller;
+
+import info.xiaomo.core.base.Result;
+import info.xiaomo.flyway.dao.UserDao;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * @author : xiaomo
+ */
+@RestController
+@RequestMapping("/api/users")
+public class UserController {
+
+ private final UserDao userDao;
+
+ public UserController(UserDao userDao) {
+ this.userDao = userDao;
+ }
+
+ @GetMapping("/count")
+ public Result count() {
+ return new Result<>(userDao.count());
+ }
+
+ @GetMapping("/{id}")
+ public Result