Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

## 模块一览

一共 21 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。
一共 26 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。

| 模块 | 演示内容 | 启动类 | 需要的外部服务 |
| --- | --- | --- | --- |
Expand All @@ -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` 即可跑通,也可以当作各自技术点的可执行文档来读。

## 技术栈

Expand All @@ -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 管理,不在本项目中单独指定。
Expand Down
52 changes: 52 additions & 0 deletions aop/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>xiaomo</artifactId>
<groupId>info.xiaomo</groupId>
<version>2020.1</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>aop</artifactId>

<dependencies>
<dependency>
<groupId>info.xiaomo</groupId>
<artifactId>core</artifactId>
<version>2020.1</version>
</dependency>
<!-- spring boot 4 起 aop starter 改名为 aspectj, 与过去常见的 spring-boot-starter-aop 等价 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
28 changes: 28 additions & 0 deletions aop/src/main/java/info/xiaomo/aop/AopMain.java
Original file line number Diff line number Diff line change
@@ -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 切面编程启动器。
*
* <p>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);
}

}
23 changes: 23 additions & 0 deletions aop/src/main/java/info/xiaomo/aop/annotation/Loggable.java
Original file line number Diff line number Diff line change
@@ -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 "";

}
44 changes: 44 additions & 0 deletions aop/src/main/java/info/xiaomo/aop/aspect/GreetingAspect.java
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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<String> greet(@PathVariable("name") String name) {
return new Result<>("你好, " + name);
}

}
35 changes: 35 additions & 0 deletions aop/src/main/java/info/xiaomo/aop/metrics/InvocationMetrics.java
Original file line number Diff line number Diff line change
@@ -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<String, AtomicInteger> 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();
}

}
7 changes: 7 additions & 0 deletions aop/src/main/resources/config/application.properties
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions aop/src/main/resources/config/logback-dev.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>

<configuration scan="true">

<appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
<encoder charset="UTF-8">
<pattern>[%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n</pattern>
</encoder>
</appender>

<root level="INFO">
<appender-ref ref="stdout"/>
</root>

<logger name="info.xiaomo" level="DEBUG"/>

</configuration>
50 changes: 50 additions & 0 deletions aop/src/test/java/info/xiaomo/aop/AopAspectTest.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
15 changes: 15 additions & 0 deletions changeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 验证"窗口滚动后放行"。
Loading
Loading