diff --git a/Readme.md b/Readme.md
index 5e784243..f67ca28d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -18,7 +18,7 @@
## 模块一览
-一共 21 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。
+一共 26 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。
| 模块 | 演示内容 | 启动类 | 需要的外部服务 |
| --- | --- | --- | --- |
@@ -43,10 +43,15 @@
| `restclient` | 用 `RestClient` 调用外部 HTTP 服务 | `RestClientMain` | — |
| `cache` | Spring Cache 抽象 + Caffeine 本地缓存 | `CacheMain` | — |
| `actuator` | 健康检查、自定义 `HealthIndicator` 与业务指标 | `ActuatorMain` | — |
+| `aop` | AOP 切面:自定义 `@Loggable` 注解记录耗时与调用次数 | `AopMain` | — |
+| `httpinterface` | `@HttpExchange` 声明式 HTTP 客户端(代替 OpenFeign) | `HttpInterfaceMain` | — |
+| `i18n` | 国际化:`MessageSource` + `LocaleResolver` 多语言文案 | `I18nMain` | — |
+| `ratelimit` | 接口限流:`HandlerInterceptor` + 固定时间窗口 | `RateLimitMain` | — |
+| `graphql` | GraphQL 查询接口(`@QueryMapping` + schema) | `GraphqlMain` | — |
除 `socket` 使用 8081 外,其余模块都监听 **8080**,所以一次只启动一个模块。
-其中 `validation`、`fileupload`、`restclient`、`cache`、`actuator`、`async` 六个模块附带可直接运行的测试。它们都不依赖外部服务,`mvn test` 即可跑通,也可以当作各自技术点的可执行文档来读。
+其中 `validation`、`fileupload`、`restclient`、`cache`、`actuator`、`async`、`aop`、`httpinterface`、`i18n`、`ratelimit`、`graphql` 十一个模块附带可直接运行的测试。它们都不依赖外部服务,`mvn test` 即可跑通,也可以当作各自技术点的可执行文档来读。
## 技术栈
@@ -61,6 +66,8 @@
| 数据库驱动 | MySQL Connector/J |
| 缓存 | Caffeine(`cache` 模块) |
| 监控 | Micrometer + Spring Boot Actuator(`actuator` 模块) |
+| AOP | AspectJ(`aop` 模块) |
+| GraphQL | Spring GraphQL + GraphQL Java(`graphql` 模块) |
| 其他 | MyBatis、Lettuce(Redis)、jsoup、Apache POI、fastjson2、zxing |
Spring、Jackson、Hibernate、JUnit 等版本统一由 `spring-boot-dependencies` BOM 管理,不在本项目中单独指定。
diff --git a/aop/pom.xml b/aop/pom.xml
new file mode 100644
index 00000000..44dea647
--- /dev/null
+++ b/aop/pom.xml
@@ -0,0 +1,52 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ aop
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+
+ org.springframework.boot
+ spring-boot-starter-aspectj
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/aop/src/main/java/info/xiaomo/aop/AopMain.java b/aop/src/main/java/info/xiaomo/aop/AopMain.java
new file mode 100644
index 00000000..eb0d7e1d
--- /dev/null
+++ b/aop/src/main/java/info/xiaomo/aop/AopMain.java
@@ -0,0 +1,28 @@
+package info.xiaomo.aop;
+
+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;
+
+/**
+ * AOP 切面编程启动器。
+ *
+ *
classpath 上存在 aspectj 时, spring boot 会通过 AopAutoConfiguration 自动开启
+ * {@code @EnableAspectJAutoProxy}, 因此把切面声明成 {@code @Aspect} 的 {@code @Component} 即可生效,
+ * 无需再手动加开关。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@ComponentScan("info.xiaomo.aop")
+public class AopMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AopMain.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/aop/src/main/java/info/xiaomo/aop/annotation/Loggable.java b/aop/src/main/java/info/xiaomo/aop/annotation/Loggable.java
new file mode 100644
index 00000000..01aefbac
--- /dev/null
+++ b/aop/src/main/java/info/xiaomo/aop/annotation/Loggable.java
@@ -0,0 +1,23 @@
+package info.xiaomo.aop.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标记一个方法需要被切面记录日志与耗时。业务代码里加一个注解即可, 具体逻辑统一写在切面中,
+ * 这就是 AOP 把横切关注点从业务里剥离出来的价值。
+ *
+ * @author : xiaomo
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Loggable {
+
+ /**
+ * 日志里的别名, 留空则使用方法签名。
+ */
+ String value() default "";
+
+}
\ No newline at end of file
diff --git a/aop/src/main/java/info/xiaomo/aop/aspect/GreetingAspect.java b/aop/src/main/java/info/xiaomo/aop/aspect/GreetingAspect.java
new file mode 100644
index 00000000..2c65b612
--- /dev/null
+++ b/aop/src/main/java/info/xiaomo/aop/aspect/GreetingAspect.java
@@ -0,0 +1,44 @@
+package info.xiaomo.aop.aspect;
+
+import info.xiaomo.aop.annotation.Loggable;
+import info.xiaomo.aop.metrics.InvocationMetrics;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * 拦截所有带 {@link Loggable} 注解的方法, 打印耗时并累计调用次数。
+ * {@code @Around} 是能力最强的通知类型, 可以决定是否继续执行、替换返回值、记录耗时。
+ *
+ * @author : xiaomo
+ */
+@Aspect
+@Component
+public class GreetingAspect {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(GreetingAspect.class);
+
+ private final InvocationMetrics metrics;
+
+ public GreetingAspect(InvocationMetrics metrics) {
+ this.metrics = metrics;
+ }
+
+ @Around("@annotation(loggable)")
+ public Object logAndMeasure(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {
+ String signature = joinPoint.getSignature().toShortString();
+ long start = System.nanoTime();
+ try {
+ return joinPoint.proceed();
+ } finally {
+ long costMs = (System.nanoTime() - start) / 1_000_000;
+ metrics.record(signature);
+ String label = loggable.value().isBlank() ? signature : loggable.value();
+ LOGGER.info("[{}] 执行完成, 耗时 {} 毫秒", label, costMs);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/aop/src/main/java/info/xiaomo/aop/controller/GreetingController.java b/aop/src/main/java/info/xiaomo/aop/controller/GreetingController.java
new file mode 100644
index 00000000..33e6e725
--- /dev/null
+++ b/aop/src/main/java/info/xiaomo/aop/controller/GreetingController.java
@@ -0,0 +1,23 @@
+package info.xiaomo.aop.controller;
+
+import info.xiaomo.aop.annotation.Loggable;
+import info.xiaomo.core.base.Result;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 业务方法里看不到任何日志代码, 只通过一个注解声明关注点。
+ *
+ * @author : xiaomo
+ */
+@RestController
+public class GreetingController {
+
+ @Loggable("打招呼")
+ @GetMapping("/greeting/{name}")
+ public Result greet(@PathVariable("name") String name) {
+ return new Result<>("你好, " + name);
+ }
+
+}
\ No newline at end of file
diff --git a/aop/src/main/java/info/xiaomo/aop/metrics/InvocationMetrics.java b/aop/src/main/java/info/xiaomo/aop/metrics/InvocationMetrics.java
new file mode 100644
index 00000000..8608127c
--- /dev/null
+++ b/aop/src/main/java/info/xiaomo/aop/metrics/InvocationMetrics.java
@@ -0,0 +1,35 @@
+package info.xiaomo.aop.metrics;
+
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 记录每个方法被真正执行过的次数。与 cache 示例里的计数器作用相同: 用一个可观测的状态
+ * 来断言切面确实拦截到了方法, 而不是去断言 AOP 框架的内部机制。
+ *
+ * @author : xiaomo
+ */
+@Component
+public class InvocationMetrics {
+
+ private final ConcurrentHashMap counts = new ConcurrentHashMap<>();
+
+ public void record(String method) {
+ counts.computeIfAbsent(method, key -> new AtomicInteger()).incrementAndGet();
+ }
+
+ public int countOf(String method) {
+ return counts.getOrDefault(method, new AtomicInteger()).get();
+ }
+
+ public int totalCount() {
+ return counts.values().stream().mapToInt(AtomicInteger::get).sum();
+ }
+
+ public void reset() {
+ counts.clear();
+ }
+
+}
\ No newline at end of file
diff --git a/aop/src/main/resources/config/application.properties b/aop/src/main/resources/config/application.properties
new file mode 100644
index 00000000..4e7ecaf3
--- /dev/null
+++ b/aop/src/main/resources/config/application.properties
@@ -0,0 +1,7 @@
+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
\ No newline at end of file
diff --git a/aop/src/main/resources/config/logback-dev.xml b/aop/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/aop/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/aop/src/test/java/info/xiaomo/aop/AopAspectTest.java b/aop/src/test/java/info/xiaomo/aop/AopAspectTest.java
new file mode 100644
index 00000000..631b2aa5
--- /dev/null
+++ b/aop/src/test/java/info/xiaomo/aop/AopAspectTest.java
@@ -0,0 +1,50 @@
+package info.xiaomo.aop;
+
+import info.xiaomo.aop.metrics.InvocationMetrics;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(classes = AopMain.class)
+@AutoConfigureMockMvc
+class AopAspectTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private InvocationMetrics metrics;
+
+ @BeforeEach
+ void 清空计数() {
+ metrics.reset();
+ }
+
+ @Test
+ void 带Loggable注解的方法应当被切面拦截并计数() throws Exception {
+ mockMvc.perform(get("/greeting/{name}", "xiaomo"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value("你好, xiaomo"));
+
+ assertThat(metrics.totalCount()).isEqualTo(1);
+ assertThat(metrics.countOf("GreetingController.greet(..)")).isEqualTo(1);
+ }
+
+ @Test
+ void 每调用一次计数就增加一次() throws Exception {
+ mockMvc.perform(get("/greeting/{name}", "xiaomo")).andExpect(status().isOk());
+ mockMvc.perform(get("/greeting/{name}", "houko")).andExpect(status().isOk());
+
+ assertThat(metrics.totalCount()).isEqualTo(2);
+ assertThat(metrics.countOf("GreetingController.greet(..)")).isEqualTo(2);
+ }
+
+}
\ No newline at end of file
diff --git a/changeLog.md b/changeLog.md
index 4bcf1a9a..eec48151 100644
--- a/changeLog.md
+++ b/changeLog.md
@@ -93,3 +93,18 @@ LocalVariableTableParameterNameDiscoverer, 因此未显式命名的 @PathVariabl
```
第 1 条是行为变更: 之前连接任何 https 站点都不校验证书, 现在会。如果有服务端用的是自签名或过期证书, 升级后会连接失败 —— 这正是该被暴露出来的问题, 正确的做法是把该证书加进信任库, 而不是关掉校验。
+
+- 2026-09-14 新增五个示例模块
+
+```
+1. 新增 aop 模块: 自定义 @Loggable 注解 + @Aspect 记录耗时与调用次数
+2. 新增 httpinterface 模块: @HttpExchange + HttpServiceProxyFactory 声明式 HTTP 客户端
+3. 新增 i18n 模块: MessageSource + AcceptHeaderLocaleResolver 多语言文案
+4. 新增 ratelimit 模块: HandlerInterceptor + 固定时间窗口接口限流
+5. 新增 graphql 模块: @QueryMapping + .graphqls schema 提供 /graphql 查询接口
+6. 以上五个模块均不依赖外部服务, 且各自带可运行的测试
+```
+
+几个 Spring Boot 4 下的注意点: aop starter 已从 spring-boot-starter-aop 改名为 spring-boot-starter-aspectj;
+声明式客户端直接使用 spring 内置的 @HttpExchange, 无需再引入 OpenFeign; ratelimit 模块把 Clock 抽成
+bean, 测试里用 @Primary 注入一个可拨动的时钟, 不靠 sleep 验证"窗口滚动后放行"。
diff --git a/graphql/pom.xml b/graphql/pom.xml
new file mode 100644
index 00000000..6bb814ca
--- /dev/null
+++ b/graphql/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ graphql
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-graphql
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/graphql/src/main/java/info/xiaomo/graphql/GraphqlMain.java b/graphql/src/main/java/info/xiaomo/graphql/GraphqlMain.java
new file mode 100644
index 00000000..dd46472d
--- /dev/null
+++ b/graphql/src/main/java/info/xiaomo/graphql/GraphqlMain.java
@@ -0,0 +1,27 @@
+package info.xiaomo.graphql;
+
+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;
+
+/**
+ * GraphQL 启动器。
+ *
+ * schema 定义在 resources/graphql/*.graphqls, 数据取值逻辑写在 @Controller 的 @QueryMapping 方法里,
+ * spring-graphql 把它们拼成一个 /graphql 端点, 客户端一次请求可以按需取任意字段。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@ComponentScan("info.xiaomo.graphql")
+public class GraphqlMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(GraphqlMain.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/graphql/src/main/java/info/xiaomo/graphql/controller/BookController.java b/graphql/src/main/java/info/xiaomo/graphql/controller/BookController.java
new file mode 100644
index 00000000..ba141126
--- /dev/null
+++ b/graphql/src/main/java/info/xiaomo/graphql/controller/BookController.java
@@ -0,0 +1,36 @@
+package info.xiaomo.graphql.controller;
+
+import info.xiaomo.graphql.model.Book;
+import info.xiaomo.graphql.service.BookService;
+import org.springframework.graphql.data.method.annotation.Argument;
+import org.springframework.graphql.data.method.annotation.QueryMapping;
+import org.springframework.stereotype.Controller;
+
+import java.util.List;
+
+/**
+ * 这里的 @Controller 不是 REST 控制器, 而是 GraphQL 的"数据取值的入口"。
+ * 方法名与 schema 中的查询字段同名, @QueryMapping 会把它们绑定到一起。
+ *
+ * @author : xiaomo
+ */
+@Controller
+public class BookController {
+
+ private final BookService service;
+
+ public BookController(BookService service) {
+ this.service = service;
+ }
+
+ @QueryMapping
+ public Book bookById(@Argument String id) {
+ return service.findById(id);
+ }
+
+ @QueryMapping
+ public List allBooks() {
+ return service.findAll();
+ }
+
+}
\ No newline at end of file
diff --git a/graphql/src/main/java/info/xiaomo/graphql/model/Book.java b/graphql/src/main/java/info/xiaomo/graphql/model/Book.java
new file mode 100644
index 00000000..d7b3086d
--- /dev/null
+++ b/graphql/src/main/java/info/xiaomo/graphql/model/Book.java
@@ -0,0 +1,9 @@
+package info.xiaomo.graphql.model;
+
+/**
+ * 与 schema 里的 Book 类型一一对应。GraphQL 的 ID 标量在 Java 侧默认映射成 String。
+ *
+ * @author : xiaomo
+ */
+public record Book(String id, String title, String author) {
+}
\ No newline at end of file
diff --git a/graphql/src/main/java/info/xiaomo/graphql/service/BookService.java b/graphql/src/main/java/info/xiaomo/graphql/service/BookService.java
new file mode 100644
index 00000000..27a29fc4
--- /dev/null
+++ b/graphql/src/main/java/info/xiaomo/graphql/service/BookService.java
@@ -0,0 +1,34 @@
+package info.xiaomo.graphql.service;
+
+import info.xiaomo.graphql.model.Book;
+import org.springframework.stereotype.Service;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 内存数据源, 只用来演示 GraphQL 层怎么取值, 实际项目里换成 JPA / MyBatis 即可。
+ *
+ * @author : xiaomo
+ */
+@Service
+public class BookService {
+
+ private final Map books = new LinkedHashMap<>();
+
+ public BookService() {
+ books.put("1", new Book("1", "Spring Boot 实战", "小莫"));
+ books.put("2", new Book("2", "深入理解 Java 虚拟机", "周志明"));
+ books.put("3", new Book("3", "Clean Code", "Robert C. Martin"));
+ }
+
+ public Book findById(String id) {
+ return books.get(id);
+ }
+
+ public List findAll() {
+ return List.copyOf(books.values());
+ }
+
+}
\ No newline at end of file
diff --git a/graphql/src/main/resources/config/application.properties b/graphql/src/main/resources/config/application.properties
new file mode 100644
index 00000000..f681f9f1
--- /dev/null
+++ b/graphql/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
+
+# WebSocket 风格之外的 GraphQL 传输端点; 默认就是 /graphql, 这里显式写出便于理解
+spring.graphql.path=/graphql
\ No newline at end of file
diff --git a/graphql/src/main/resources/config/logback-dev.xml b/graphql/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/graphql/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/graphql/src/main/resources/graphql/books.graphqls b/graphql/src/main/resources/graphql/books.graphqls
new file mode 100644
index 00000000..ed9b3300
--- /dev/null
+++ b/graphql/src/main/resources/graphql/books.graphqls
@@ -0,0 +1,11 @@
+# GraphQL schema。@QueryMapping 方法只提供取值逻辑, 字段结构、类型、是否可空都在这里声明。
+type Query {
+ bookById(id: ID!): Book
+ allBooks: [Book!]!
+}
+
+type Book {
+ id: ID!
+ title: String!
+ author: String!
+}
\ No newline at end of file
diff --git a/graphql/src/test/java/info/xiaomo/graphql/BookGraphqlTest.java b/graphql/src/test/java/info/xiaomo/graphql/BookGraphqlTest.java
new file mode 100644
index 00000000..89f6d7d1
--- /dev/null
+++ b/graphql/src/test/java/info/xiaomo/graphql/BookGraphqlTest.java
@@ -0,0 +1,63 @@
+package info.xiaomo.graphql;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 直接对 /graphql 端点发 POST 请求, 走真实的 GraphQL 执行链路(校验 + 解析 + 取数 + 序列化)。
+ * 通过变量传参可以避免在 JSON 里转义 GraphQL 字符串中的引号。
+ */
+@SpringBootTest(classes = GraphqlMain.class)
+@AutoConfigureMockMvc
+class BookGraphqlTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void 按ID查询并只取需要字段() throws Exception {
+ String body = """
+ {"query":"query($id: ID!) { bookById(id: $id) { id title author } }","variables":{"id":"1"}}
+ """;
+
+ mockMvc.perform(post("/graphql").contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.errors").doesNotExist())
+ .andExpect(jsonPath("$.data.bookById.id").value("1"))
+ .andExpect(jsonPath("$.data.bookById.title").value("Spring Boot 实战"))
+ .andExpect(jsonPath("$.data.bookById.author").value("小莫"));
+ }
+
+ @Test
+ void 查询全部书籍() throws Exception {
+ String body = """
+ {"query":"{ allBooks { id title } }"}
+ """;
+
+ mockMvc.perform(post("/graphql").contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.errors").doesNotExist())
+ .andExpect(jsonPath("$.data.allBooks.length()").value(3));
+ }
+
+ @Test
+ void 查询不存在的字段应当返回errors() throws Exception {
+ String body = """
+ {"query":"query($id: ID!) { bookById(id: $id) { id noSuchField } }","variables":{"id":"1"}}
+ """;
+
+ mockMvc.perform(post("/graphql").contentType(MediaType.APPLICATION_JSON).content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.errors").isArray())
+ .andExpect(jsonPath("$.data").doesNotExist());
+ }
+
+}
\ No newline at end of file
diff --git a/httpinterface/pom.xml b/httpinterface/pom.xml
new file mode 100644
index 00000000..03b14125
--- /dev/null
+++ b/httpinterface/pom.xml
@@ -0,0 +1,47 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ httpinterface
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/httpinterface/src/main/java/info/xiaomo/httpinterface/HttpInterfaceMain.java b/httpinterface/src/main/java/info/xiaomo/httpinterface/HttpInterfaceMain.java
new file mode 100644
index 00000000..7f47c1a2
--- /dev/null
+++ b/httpinterface/src/main/java/info/xiaomo/httpinterface/HttpInterfaceMain.java
@@ -0,0 +1,28 @@
+package info.xiaomo.httpinterface;
+
+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;
+
+/**
+ * 声明式 HTTP 客户端启动器。
+ *
+ * {@code @HttpExchange} 是 Spring 6 引入的声明式 HTTP 接口, 用来替代 OpenFeign 这类第三方
+ * 注解客户端: 只需要写一个接口描述"调什么", Spring 生成代理去执行真正的 HTTP 调用,
+ * 业务代码里不再拼 URL、不再手写 RestTemplate / RestClient 的样板。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@ComponentScan("info.xiaomo.httpinterface")
+public class HttpInterfaceMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(HttpInterfaceMain.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/httpinterface/src/main/java/info/xiaomo/httpinterface/client/GithubApi.java b/httpinterface/src/main/java/info/xiaomo/httpinterface/client/GithubApi.java
new file mode 100644
index 00000000..83a7e183
--- /dev/null
+++ b/httpinterface/src/main/java/info/xiaomo/httpinterface/client/GithubApi.java
@@ -0,0 +1,20 @@
+package info.xiaomo.httpinterface.client;
+
+import info.xiaomo.httpinterface.model.Repository;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.service.annotation.GetExchange;
+
+import java.util.List;
+
+/**
+ * 声明式接口: 只描述"要什么"。方法名、参数、返回值就是全部约定,
+ * 底层的 HTTP 动词、序列化、连接管理都由 Spring 生成的代理负责。
+ *
+ * @author : xiaomo
+ */
+public interface GithubApi {
+
+ @GetExchange("/users/{user}/repos")
+ List listRepositories(@PathVariable("user") String user);
+
+}
\ No newline at end of file
diff --git a/httpinterface/src/main/java/info/xiaomo/httpinterface/controller/RepositoryController.java b/httpinterface/src/main/java/info/xiaomo/httpinterface/controller/RepositoryController.java
new file mode 100644
index 00000000..b66bcfab
--- /dev/null
+++ b/httpinterface/src/main/java/info/xiaomo/httpinterface/controller/RepositoryController.java
@@ -0,0 +1,40 @@
+package info.xiaomo.httpinterface.controller;
+
+import info.xiaomo.core.base.Result;
+import info.xiaomo.httpinterface.model.Repository;
+import info.xiaomo.httpinterface.service.GithubService;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+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.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * @author : xiaomo
+ */
+@RestController
+@RequestMapping("/repos")
+public class RepositoryController {
+
+ private final GithubService github;
+
+ public RepositoryController(GithubService github) {
+ this.github = github;
+ }
+
+ @GetMapping("/{user}")
+ public Result> listRepositories(@PathVariable("user") String user) {
+ return new Result<>(github.listRepositories(user));
+ }
+
+ @ExceptionHandler(GithubService.UserNotFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public Result handleNotFound(GithubService.UserNotFoundException e) {
+ return new Result<>(HttpStatus.NOT_FOUND.value(), e.getMessage(), null);
+ }
+
+}
\ No newline at end of file
diff --git a/httpinterface/src/main/java/info/xiaomo/httpinterface/model/Repository.java b/httpinterface/src/main/java/info/xiaomo/httpinterface/model/Repository.java
new file mode 100644
index 00000000..5aafbdc5
--- /dev/null
+++ b/httpinterface/src/main/java/info/xiaomo/httpinterface/model/Repository.java
@@ -0,0 +1,17 @@
+package info.xiaomo.httpinterface.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * 只声明用得上的字段。ignoreUnknown = true 让外部接口新增字段时不会把反序列化打挂。
+ *
+ * @author : xiaomo
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record Repository(
+ String name,
+ String description,
+ @JsonProperty("html_url") String htmlUrl,
+ @JsonProperty("stargazers_count") int stars) {
+}
\ No newline at end of file
diff --git a/httpinterface/src/main/java/info/xiaomo/httpinterface/service/GithubService.java b/httpinterface/src/main/java/info/xiaomo/httpinterface/service/GithubService.java
new file mode 100644
index 00000000..0d4775d6
--- /dev/null
+++ b/httpinterface/src/main/java/info/xiaomo/httpinterface/service/GithubService.java
@@ -0,0 +1,53 @@
+package info.xiaomo.httpinterface.service;
+
+import info.xiaomo.httpinterface.client.GithubApi;
+import info.xiaomo.httpinterface.model.Repository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.support.RestClientAdapter;
+import org.springframework.web.service.invoker.HttpServiceProxyFactory;
+
+import java.util.List;
+
+/**
+ * 用 {@link HttpServiceProxyFactory} 把声明式接口变成可调用的代理。
+ * 这里的底座是 {@link RestClient}, 换成 WebClient 只需替换 adapter, 接口本身不用动。
+ *
+ * @author : xiaomo
+ */
+@Service
+public class GithubService {
+
+ private final GithubApi api;
+
+ public GithubService(RestClient.Builder builder, @Value("${app.github.base-url}") String baseUrl) {
+ RestClient restClient = builder.baseUrl(baseUrl).build();
+ this.api = HttpServiceProxyFactory
+ .builderFor(RestClientAdapter.create(restClient))
+ .build()
+ .createClient(GithubApi.class);
+ }
+
+ /**
+ * 查询某个用户的公开仓库。404 单独转成语义明确的异常, 而不是让调用方去看状态码。
+ */
+ public List listRepositories(String user) {
+ try {
+ return api.listRepositories(user);
+ } catch (HttpClientErrorException.NotFound e) {
+ throw new UserNotFoundException(user);
+ }
+ }
+
+ /**
+ * 用户不存在。
+ */
+ public static class UserNotFoundException extends RuntimeException {
+ public UserNotFoundException(String user) {
+ super("GitHub 用户不存在: " + user);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/httpinterface/src/main/resources/config/application.properties b/httpinterface/src/main/resources/config/application.properties
new file mode 100644
index 00000000..d051dee4
--- /dev/null
+++ b/httpinterface/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
+
+# 外部服务地址, 测试时会被替换成 mock server
+app.github.base-url=https://api.github.com
\ No newline at end of file
diff --git a/httpinterface/src/main/resources/config/logback-dev.xml b/httpinterface/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/httpinterface/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/httpinterface/src/test/java/info/xiaomo/httpinterface/GithubApiTest.java b/httpinterface/src/test/java/info/xiaomo/httpinterface/GithubApiTest.java
new file mode 100644
index 00000000..4bab9ea7
--- /dev/null
+++ b/httpinterface/src/test/java/info/xiaomo/httpinterface/GithubApiTest.java
@@ -0,0 +1,84 @@
+package info.xiaomo.httpinterface;
+
+import info.xiaomo.httpinterface.model.Repository;
+import info.xiaomo.httpinterface.service.GithubService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+/**
+ * 拦住代理底层 RestClient 的请求做断言, 测试不依赖网络, 也不会因为 GitHub 限流而变成 flaky。
+ */
+class GithubApiTest {
+
+ private static final String BASE_URL = "https://api.github.example";
+
+ private MockRestServiceServer server;
+ private GithubService service;
+
+ @BeforeEach
+ void setUp() {
+ RestClient.Builder builder = RestClient.builder();
+ server = MockRestServiceServer.bindTo(builder).build();
+ service = new GithubService(builder, BASE_URL);
+ }
+
+ @Test
+ void 应当正确解析仓库列表并映射下划线字段() {
+ String json = """
+ [
+ {"name":"SpringBootUnity","description":"spring boot 示例集合",
+ "html_url":"https://github.com/houko/SpringBootUnity","stargazers_count":1024},
+ {"name":"another","description":null,
+ "html_url":"https://github.com/houko/another","stargazers_count":7,
+ "unknown_field":"新增字段不应导致反序列化失败"}
+ ]
+ """;
+ server.expect(requestTo(BASE_URL + "/users/houko/repos"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withSuccess(json, MediaType.APPLICATION_JSON));
+
+ List repos = service.listRepositories("houko");
+
+ server.verify();
+ assertThat(repos).hasSize(2);
+ assertThat(repos.getFirst().name()).isEqualTo("SpringBootUnity");
+ // html_url / stargazers_count 通过 @JsonProperty 映射到驼峰字段
+ assertThat(repos.getFirst().htmlUrl()).isEqualTo("https://github.com/houko/SpringBootUnity");
+ assertThat(repos.getFirst().stars()).isEqualTo(1024);
+ }
+
+ @Test
+ void 用户不存在时应当抛出语义明确的异常而不是原始状态码错误() {
+ server.expect(requestTo(BASE_URL + "/users/no-such-user/repos"))
+ .andRespond(withResourceNotFound());
+
+ assertThatThrownBy(() -> service.listRepositories("no-such-user"))
+ .isInstanceOf(GithubService.UserNotFoundException.class)
+ .hasMessageContaining("no-such-user");
+
+ server.verify();
+ }
+
+ @Test
+ void 空列表应当正常返回而不是null() {
+ server.expect(requestTo(BASE_URL + "/users/empty/repos"))
+ .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
+
+ assertThat(service.listRepositories("empty")).isEmpty();
+ server.verify();
+ }
+
+}
\ No newline at end of file
diff --git a/i18n/pom.xml b/i18n/pom.xml
new file mode 100644
index 00000000..c457e86e
--- /dev/null
+++ b/i18n/pom.xml
@@ -0,0 +1,47 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ i18n
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/i18n/src/main/java/info/xiaomo/i18n/I18nMain.java b/i18n/src/main/java/info/xiaomo/i18n/I18nMain.java
new file mode 100644
index 00000000..b8fbff88
--- /dev/null
+++ b/i18n/src/main/java/info/xiaomo/i18n/I18nMain.java
@@ -0,0 +1,55 @@
+package info.xiaomo.i18n;
+
+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.MessageSource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.web.servlet.LocaleResolver;
+import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+
+/**
+ * 国际化启动器。
+ *
+ * spring boot 本身会按 classpath 根目录的 messages*.properties 自动装配 MessageSource,
+ * 这里显式声明只是为了演示几个关键点: 指定资源文件位置、指定 UTF-8 编码、
+ * 以及关掉 fallbackToSystemLocale 让缺失的文案回退到基准文件而不是服务器 JVM 的地区。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@ComponentScan("info.xiaomo.i18n")
+public class I18nMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(I18nMain.class, args);
+ }
+
+ @Bean
+ public MessageSource messageSource() {
+ ResourceBundleMessageSource source = new ResourceBundleMessageSource();
+ source.setBasename("config/messages");
+ source.setDefaultEncoding(StandardCharsets.UTF_8.name());
+ source.setFallbackToSystemLocale(false);
+ return source;
+ }
+
+ /**
+ * 按请求头 Accept-Language 解析地区, 未指定时回退到简体中文。
+ */
+ @Bean
+ public LocaleResolver localeResolver() {
+ AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
+ resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
+ return resolver;
+ }
+
+}
\ No newline at end of file
diff --git a/i18n/src/main/java/info/xiaomo/i18n/controller/GreetingController.java b/i18n/src/main/java/info/xiaomo/i18n/controller/GreetingController.java
new file mode 100644
index 00000000..7a4b4160
--- /dev/null
+++ b/i18n/src/main/java/info/xiaomo/i18n/controller/GreetingController.java
@@ -0,0 +1,33 @@
+package info.xiaomo.i18n.controller;
+
+import info.xiaomo.core.base.Result;
+import org.springframework.context.MessageSource;
+import org.springframework.context.i18n.LocaleContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Locale;
+
+/**
+ * 用当前请求的地区去取对应文案。地区由 LocaleResolver 解析出来并放进 LocaleContextHolder,
+ * 因此控制器里不需要把 Locale 当参数传来传去。
+ *
+ * @author : xiaomo
+ */
+@RestController
+public class GreetingController {
+
+ private final MessageSource messageSource;
+
+ public GreetingController(MessageSource messageSource) {
+ this.messageSource = messageSource;
+ }
+
+ @GetMapping("/greeting/{name}")
+ public Result greet(@PathVariable("name") String name) {
+ Locale locale = LocaleContextHolder.getLocale();
+ return new Result<>(messageSource.getMessage("greeting.welcome", new Object[]{name}, locale));
+ }
+
+}
\ No newline at end of file
diff --git a/i18n/src/main/resources/config/application.properties b/i18n/src/main/resources/config/application.properties
new file mode 100644
index 00000000..4e7ecaf3
--- /dev/null
+++ b/i18n/src/main/resources/config/application.properties
@@ -0,0 +1,7 @@
+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
\ No newline at end of file
diff --git a/i18n/src/main/resources/config/logback-dev.xml b/i18n/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/i18n/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/i18n/src/main/resources/config/messages.properties b/i18n/src/main/resources/config/messages.properties
new file mode 100644
index 00000000..1cbf99de
--- /dev/null
+++ b/i18n/src/main/resources/config/messages.properties
@@ -0,0 +1,2 @@
+# 基准文案, 也是未命中任何语言包时的兜底(这里兜底设为中文)
+greeting.welcome=你好, {0}
\ No newline at end of file
diff --git a/i18n/src/main/resources/config/messages_en_US.properties b/i18n/src/main/resources/config/messages_en_US.properties
new file mode 100644
index 00000000..e9370178
--- /dev/null
+++ b/i18n/src/main/resources/config/messages_en_US.properties
@@ -0,0 +1 @@
+greeting.welcome=Hello, {0}
\ No newline at end of file
diff --git a/i18n/src/main/resources/config/messages_zh_CN.properties b/i18n/src/main/resources/config/messages_zh_CN.properties
new file mode 100644
index 00000000..e30c42aa
--- /dev/null
+++ b/i18n/src/main/resources/config/messages_zh_CN.properties
@@ -0,0 +1 @@
+greeting.welcome=你好, {0}
\ No newline at end of file
diff --git a/i18n/src/test/java/info/xiaomo/i18n/I18nTest.java b/i18n/src/test/java/info/xiaomo/i18n/I18nTest.java
new file mode 100644
index 00000000..695852b2
--- /dev/null
+++ b/i18n/src/test/java/info/xiaomo/i18n/I18nTest.java
@@ -0,0 +1,42 @@
+package info.xiaomo.i18n;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.HttpHeaders;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(classes = I18nMain.class)
+@AutoConfigureMockMvc
+class I18nTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void 中文环境返回中文文案() throws Exception {
+ mockMvc.perform(get("/greeting/{name}", "xiaomo").header(HttpHeaders.ACCEPT_LANGUAGE, "zh-CN"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value("你好, xiaomo"));
+ }
+
+ @Test
+ void 英文环境返回英文文案() throws Exception {
+ mockMvc.perform(get("/greeting/{name}", "xiaomo").header(HttpHeaders.ACCEPT_LANGUAGE, "en-US"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value("Hello, xiaomo"));
+ }
+
+ @Test
+ void 未指定语言时回退到默认的中文() throws Exception {
+ mockMvc.perform(get("/greeting/{name}", "xiaomo"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value("你好, xiaomo"));
+ }
+
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index b9e8fe33..73bbbfba 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,6 +50,16 @@
cache
actuator
+
+ aop
+
+ httpinterface
+
+ i18n
+
+ ratelimit
+
+ graphql
pom
diff --git a/ratelimit/pom.xml b/ratelimit/pom.xml
new file mode 100644
index 00000000..701400f6
--- /dev/null
+++ b/ratelimit/pom.xml
@@ -0,0 +1,47 @@
+
+
+
+ xiaomo
+ info.xiaomo
+ 2020.1
+
+ 4.0.0
+
+ ratelimit
+
+
+
+ info.xiaomo
+ core
+ 2020.1
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ratelimit/src/main/java/info/xiaomo/ratelimit/RateLimitMain.java b/ratelimit/src/main/java/info/xiaomo/ratelimit/RateLimitMain.java
new file mode 100644
index 00000000..08b0c883
--- /dev/null
+++ b/ratelimit/src/main/java/info/xiaomo/ratelimit/RateLimitMain.java
@@ -0,0 +1,35 @@
+package info.xiaomo.ratelimit;
+
+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.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+
+/**
+ * 接口限流启动器。
+ *
+ * 把 {@link Clock} 抽成一个 bean, 是这里最值得注意的地方: 生产环境用系统时钟,
+ * 测试里再用 {@code @Primary} 注入一个可拨动的时钟, 就能不靠 sleep 稳定地验证"窗口滚动后放行"。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class})
+@ComponentScan("info.xiaomo.ratelimit")
+public class RateLimitMain {
+
+ public static void main(String[] args) {
+ SpringApplication.run(RateLimitMain.class, args);
+ }
+
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+
+}
\ No newline at end of file
diff --git a/ratelimit/src/main/java/info/xiaomo/ratelimit/config/WebConfig.java b/ratelimit/src/main/java/info/xiaomo/ratelimit/config/WebConfig.java
new file mode 100644
index 00000000..31dd7d17
--- /dev/null
+++ b/ratelimit/src/main/java/info/xiaomo/ratelimit/config/WebConfig.java
@@ -0,0 +1,27 @@
+package info.xiaomo.ratelimit.config;
+
+import info.xiaomo.ratelimit.interceptor.RateLimitInterceptor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 把限流拦截器挂到 /api/** 上, 其他路径(例如 actuator)不受影响。
+ *
+ * @author : xiaomo
+ */
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ private final RateLimitInterceptor rateLimitInterceptor;
+
+ public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
+ this.rateLimitInterceptor = rateLimitInterceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/api/**");
+ }
+
+}
\ No newline at end of file
diff --git a/ratelimit/src/main/java/info/xiaomo/ratelimit/controller/HelloController.java b/ratelimit/src/main/java/info/xiaomo/ratelimit/controller/HelloController.java
new file mode 100644
index 00000000..391f5edf
--- /dev/null
+++ b/ratelimit/src/main/java/info/xiaomo/ratelimit/controller/HelloController.java
@@ -0,0 +1,20 @@
+package info.xiaomo.ratelimit.controller;
+
+import info.xiaomo.core.base.Result;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author : xiaomo
+ */
+@RestController
+@RequestMapping("/api")
+public class HelloController {
+
+ @GetMapping("/hello")
+ public Result hello() {
+ return new Result<>("你好, 这是一个被限流的接口");
+ }
+
+}
\ No newline at end of file
diff --git a/ratelimit/src/main/java/info/xiaomo/ratelimit/interceptor/RateLimitInterceptor.java b/ratelimit/src/main/java/info/xiaomo/ratelimit/interceptor/RateLimitInterceptor.java
new file mode 100644
index 00000000..b9681289
--- /dev/null
+++ b/ratelimit/src/main/java/info/xiaomo/ratelimit/interceptor/RateLimitInterceptor.java
@@ -0,0 +1,95 @@
+package info.xiaomo.ratelimit.interceptor;
+
+import tools.jackson.databind.ObjectMapper;
+import info.xiaomo.core.base.Result;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 基于固定时间窗口的极简限流: 每个客户端在每个窗口内最多允许 maxRequests 次请求。
+ * 纯内存实现, 单实例够用; 多实例部署需要换成 Redis 之类的共享存储, 但"拦截器"这个位置不变。
+ *
+ * 客户端标识优先取 X-Client-Id 请求头, 没有则退化为按来源 IP 限流。
+ *
+ * @author : xiaomo
+ */
+@Component
+public class RateLimitInterceptor implements HandlerInterceptor {
+
+ private final ConcurrentHashMap windows = new ConcurrentHashMap<>();
+ private final Clock clock;
+ private final ObjectMapper objectMapper;
+ private final int maxRequests;
+ private final long windowMillis;
+
+ public RateLimitInterceptor(Clock clock, ObjectMapper objectMapper,
+ @Value("${app.rate-limit.max-requests:5}") int maxRequests,
+ @Value("${app.rate-limit.window-millis:60000}") long windowMillis) {
+ this.clock = clock;
+ this.objectMapper = objectMapper;
+ this.maxRequests = maxRequests;
+ this.windowMillis = windowMillis;
+ }
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
+ throws Exception {
+ String clientId = clientIdOf(request);
+ if (tryAcquire(clientId)) {
+ return true;
+ }
+ reject(response);
+ return false;
+ }
+
+ /**
+ * 计算当前窗口并让计数加一, 返回是否还在额度内。窗口起点按时间向下取整到 windowMillis 的整数倍,
+ * 时间一翻篇 compute 就会换一个新的窗口对象, 计数自然清零。
+ */
+ private boolean tryAcquire(String clientId) {
+ long now = clock.millis();
+ long windowStart = now / windowMillis * windowMillis;
+ Window window = windows.compute(clientId, (key, existing) ->
+ existing == null || existing.windowStart != windowStart ? new Window(windowStart) : existing);
+ return window.count.incrementAndGet() <= maxRequests;
+ }
+
+ private String clientIdOf(HttpServletRequest request) {
+ String fromHeader = request.getHeader("X-Client-Id");
+ if (fromHeader != null && !fromHeader.isBlank()) {
+ return fromHeader;
+ }
+ // 没带客户端标识时退化为按来源 IP 限流; remoteAddr 可能为空(例如某些代理/测试环境), 兜底成一个常量
+ String remoteAddr = request.getRemoteAddr();
+ return remoteAddr == null || remoteAddr.isBlank() ? "unknown" : remoteAddr;
+ }
+
+ private void reject(HttpServletResponse response) throws IOException {
+ response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.getWriter().write(objectMapper.writeValueAsString(
+ new Result<>(HttpStatus.TOO_MANY_REQUESTS.value(), "请求过于频繁, 请稍后再试", null)));
+ }
+
+ private static final class Window {
+ final long windowStart;
+ final AtomicInteger count = new AtomicInteger();
+
+ Window(long windowStart) {
+ this.windowStart = windowStart;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/ratelimit/src/main/resources/config/application.properties b/ratelimit/src/main/resources/config/application.properties
new file mode 100644
index 00000000..0ef89206
--- /dev/null
+++ b/ratelimit/src/main/resources/config/application.properties
@@ -0,0 +1,12 @@
+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
+
+# 每个客户端在每个时间窗口内最多允许的请求次数
+app.rate-limit.max-requests=5
+# 时间窗口长度(毫秒)
+app.rate-limit.window-millis=60000
\ No newline at end of file
diff --git a/ratelimit/src/main/resources/config/logback-dev.xml b/ratelimit/src/main/resources/config/logback-dev.xml
new file mode 100644
index 00000000..39e7cc65
--- /dev/null
+++ b/ratelimit/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/ratelimit/src/test/java/info/xiaomo/ratelimit/RateLimitInterceptorTest.java b/ratelimit/src/test/java/info/xiaomo/ratelimit/RateLimitInterceptorTest.java
new file mode 100644
index 00000000..6aa0f093
--- /dev/null
+++ b/ratelimit/src/test/java/info/xiaomo/ratelimit/RateLimitInterceptorTest.java
@@ -0,0 +1,136 @@
+package info.xiaomo.ratelimit;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 用可拨动的时钟验证限流行为, 不需要 sleep, 也不会因为机器快慢而 flaky。
+ * maxRequests 与 windowMillis 见 config/application.properties。
+ */
+@SpringBootTest(classes = RateLimitMain.class)
+@AutoConfigureMockMvc
+@Import(RateLimitInterceptorTest.TestClockConfig.class)
+class RateLimitInterceptorTest {
+
+ private static final int MAX = 5;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private MutableClock mutableClock;
+
+ @BeforeEach
+ void 重置时钟() {
+ mutableClock.set(Instant.parse("2026-01-01T00:00:00Z"));
+ }
+
+ @Test
+ void 超出窗口上限后应当返回429() throws Exception {
+ String client = "client-a";
+ for (int i = 0; i < MAX; i++) {
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", client))
+ .andExpect(status().isOk());
+ }
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", client))
+ .andExpect(status().isTooManyRequests())
+ .andExpect(jsonPath("$.resultCode").value(429))
+ .andExpect(jsonPath("$.message").value("请求过于频繁, 请稍后再试"));
+ }
+
+ @Test
+ void 时间窗滚动后应当重新放行() throws Exception {
+ String client = "client-b";
+ for (int i = 0; i < MAX; i++) {
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", client))
+ .andExpect(status().isOk());
+ }
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", client))
+ .andExpect(status().isTooManyRequests());
+
+ mutableClock.advance(Duration.ofSeconds(61));
+
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", client))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ void 不同客户端互不影响() throws Exception {
+ String a = "client-c1";
+ String b = "client-c2";
+ for (int i = 0; i < MAX; i++) {
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", a)).andExpect(status().isOk());
+ }
+ // a 已经用尽额度
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", a)).andExpect(status().isTooManyRequests());
+ // b 不受影响
+ mockMvc.perform(get("/api/hello").header("X-Client-Id", b)).andExpect(status().isOk());
+ }
+
+ @Test
+ void 请求头缺失时退化为按来源地址限流() throws Exception {
+ // 不带 X-Client-Id 时, 同一来源地址累计到上限后同样被限制
+ for (int i = 0; i < MAX; i++) {
+ mockMvc.perform(get("/api/hello"))
+ .andExpect(status().isOk());
+ }
+ mockMvc.perform(get("/api/hello"))
+ .andExpect(status().isTooManyRequests())
+ .andExpect(jsonPath("$.resultCode").value(429));
+ }
+
+ @TestConfiguration
+ static class TestClockConfig {
+ @Bean
+ @Primary
+ MutableClock mutableClock() {
+ return new MutableClock();
+ }
+ }
+
+ static class MutableClock extends Clock {
+ private Instant instant = Instant.parse("2026-01-01T00:00:00Z");
+
+ void set(Instant instant) {
+ this.instant = instant;
+ }
+
+ void advance(Duration duration) {
+ this.instant = this.instant.plus(duration);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return instant;
+ }
+ }
+
+}
\ No newline at end of file