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