Files
redis-stream-springboot-sta…/docs/superpowers/plans/2026-06-25-redis-cache.md

1797 lines
68 KiB
Markdown
Raw Normal View History

# Redis 缓存增强模板 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在现有 `redis-stream-springboot-starter` 内新增编程式缓存模板 `RedisCacheEnhanceTemplate`,把 TTL、序列化、穿透/雪崩/击穿三防、显式 `expire` 与可选滑动过期全部封装进去。
**Architecture:** 新增独立包 `com.njcn.middle.cache`(与 `stream` 平行、零耦合)。底层复用 `StringRedisTemplate` 存字符串、fastjson 序列化。`getOrLoad` 内编排三防:空值标记(穿透)、TTL 抖动(雪崩)、`SET NX PX` 互斥锁 + Lua 安全释放 + double-check + 超时降级(击穿)。独立 `RedisCacheAutoConfiguration` 追加进 `spring.factories`
**Tech Stack:** JDK 8、Spring Boot 2.3.12、spring-data-redis 2.3.x(Lettuce)、fastjson 1.2.83、hutool 5.7.9、lombok;测试 JUnit 5 + Mockito(均来自 `spring-boot-starter-test`)。
设计依据:`docs/superpowers/specs/2026-06-25-redis-cache-design.md`
## Global Constraints
- **JDK 8 / Spring Boot 2.3.12 / spring-data-redis 2.3.x**;**不新增任何第三方依赖**(只用 `StringRedisTemplate` / fastjson / hutool / lombok)。
- 全部新增代码在 **`com.njcn.middle.cache`** 包下;**严禁修改 `com.njcn.middle.stream` 任何现有类与测试**(唯一允许改的现存文件是 `spring.factories``README.md`)。
- **构建/测试命令(mvn 不在 PATH,必须用全路径,且不要带 `-q``-DskipTests`)**:
- 全量:`C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml`
- 单类:在上面基础上加 `-Dtest=类名`(放在 `test` 之前),例如 `... -Dmaven.repo.local=... -Dtest=CacheKeyBuilderTest test -f pom.xml`
- 集成测试(`com.njcn.middle.cache.integration` 包)需真实 Redis,默认连 `127.0.0.1:6379`,**探测不到自动 skip(不 fail)**;指向其它 Redis 追加 `-Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…`
- **提交时只 `git add` 本任务明确列出的文件,严禁 `git add .` / `git add -A`** —— 工作区存在一处与本功能无关的未提交改动 `M README.md`,绝不可裹挟进提交。
- 单元测试不依赖 Redis(用 Mockito mock `StringRedisTemplate`)。
- 当前分支:`feat/redis-cache`(spec 已提交于 `ec2a8da`)。
---
## File Structure
主代码(`src/main/java/com/njcn/middle/cache/`):
| 文件 | 职责 |
|---|---|
| `constant/CacheConstant.java` | 常量:空值标记 `NULL_SENTINEL`、锁后缀 `LOCK_SUFFIX` |
| `support/CacheKeyBuilder.java` | 业务 key → 实际 key(前缀 + 环境隔离),派生锁 key |
| `support/CacheValueCodec.java` | 对象 ↔ 字符串(fastjson),空值标记判定 |
| `autoconfig/RedisCacheProperties.java` | 绑定 `redis-cache.*` |
| `template/RedisCacheEnhanceTemplate.java` | 核心模板:get/set/delete/exists/expire/getOrLoad |
| `autoconfig/RedisCacheAutoConfiguration.java` | 自动装配 |
| `src/main/resources/META-INF/spring.factories` | 追加注册(修改) |
测试(`src/test/java/com/njcn/middle/cache/`):`CacheKeyBuilderTest``CacheValueCodecTest``RedisCachePropertiesTest``RedisCacheEnhanceTemplateTest``autoconfig/RedisCacheAutoConfigurationTest``integration/{RedisAvailableCondition, CacheRoundTripTest, CachePenetrationTest, CacheExpireSlidingTest, CacheBreakdownConcurrencyTest}`
任务顺序:1 常量+Key → 2 Codec → 3 Properties → 4 模板读写 → 5 模板 getOrLoad → 6 自动装配 → 7 集成 RoundTrip → 8 集成穿透 → 9 集成 expire/滑动 → 10 集成击穿并发 → 11 README + 全量回归。
---
### Task 1: 常量 + Key 构造器
**Files:**
- Create: `src/main/java/com/njcn/middle/cache/constant/CacheConstant.java`
- Create: `src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java`
- Test: `src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java`
**Interfaces:**
- Produces:
- `CacheConstant.NULL_SENTINEL`(String)、`CacheConstant.LOCK_SUFFIX`(String)
- `new CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env)`
- `String CacheKeyBuilder.build(String bizKey)``String CacheKeyBuilder.buildLock(String bizKey)`
- [ ] **Step 1: 写失败测试**
`src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java`
```java
package com.njcn.middle.cache.support;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CacheKeyBuilderTest {
@Test
void buildWithPrefixOnly() {
CacheKeyBuilder kb = new CacheKeyBuilder("cache:", false, "");
assertEquals("cache:user:1", kb.build("user:1"));
}
@Test
void buildWithEnvIsolation() {
CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, "prod");
assertEquals("cache:prod:user:1", kb.build("user:1"));
}
@Test
void envIsolationButBlankEnvFallsBack() {
CacheKeyBuilder kb = new CacheKeyBuilder("cache:", true, "");
assertEquals("cache:user:1", kb.build("user:1"));
}
@Test
void buildLockDerivesFromDataKey() {
assertEquals("cache:user:1:__lock__",
new CacheKeyBuilder("cache:", false, "").buildLock("user:1"));
assertEquals("cache:prod:user:1:__lock__",
new CacheKeyBuilder("cache:", true, "prod").buildLock("user:1"));
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml`
Expected: 编译失败(`CacheKeyBuilder` / `CacheConstant` 不存在)。
- [ ] **Step 3: 写实现**
`src/main/java/com/njcn/middle/cache/constant/CacheConstant.java`
```java
package com.njcn.middle.cache.constant;
/**
* Redis 缓存相关常量。
*/
public final class CacheConstant {
private CacheConstant() {
}
/**
* 空值标记:回源结果为 null 时写入此值(穿透防护)。
* 内含 NUL 控制字符(Java unicode 转义书写),fastjson 序列化任何对象都不会产出含 NUL 的裸文本,故无冲突。
*/
public static final String NULL_SENTINEL = "\u0000NULL\u0000";
/** 击穿互斥锁 key 后缀(由数据 key 派生)。 */
public static final String LOCK_SUFFIX = ":__lock__";
}
```
`src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java`
```java
package com.njcn.middle.cache.support;
import cn.hutool.core.util.StrUtil;
import com.njcn.middle.cache.constant.CacheConstant;
/**
* 缓存 key 构造器:统一前缀 +(可选)环境隔离段。
*
* <p>基础:{@code keyPrefix + bizKey};开启隔离且 env 非空:{@code keyPrefix + env + ":" + bizKey}。
* 锁 key 由数据 key 派生,保证数据与其锁落在同一隔离域。
*/
public class CacheKeyBuilder {
private final String keyPrefix;
private final boolean envIsolation;
private final String env;
public CacheKeyBuilder(String keyPrefix, boolean envIsolation, String env) {
this.keyPrefix = keyPrefix == null ? "" : keyPrefix;
this.envIsolation = envIsolation;
this.env = env;
}
/** 业务 key → 实际数据 key。 */
public String build(String bizKey) {
if (envIsolation && StrUtil.isNotBlank(env)) {
return keyPrefix + env + ":" + bizKey;
}
return keyPrefix + bizKey;
}
/** 数据 key 派生锁 key。 */
public String buildLock(String bizKey) {
return build(bizKey) + CacheConstant.LOCK_SUFFIX;
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheKeyBuilderTest test -f pom.xml`
Expected: `Tests run: 4, Failures: 0`
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/constant/CacheConstant.java \
src/main/java/com/njcn/middle/cache/support/CacheKeyBuilder.java \
src/test/java/com/njcn/middle/cache/support/CacheKeyBuilderTest.java
git commit -m "feat(cache): 缓存常量与 key 构造器(前缀 + 环境隔离 + 锁 key 派生)"
```
---
### Task 2: 值编解码器 `CacheValueCodec`
**Files:**
- Create: `src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java`
- Test: `src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java`
**Interfaces:**
- Consumes: `CacheConstant.NULL_SENTINEL`(Task 1)
- Produces:
- `String CacheValueCodec.encode(Object value)` — null → `NULL_SENTINEL`,否则 `JSON.toJSONString`
- `boolean CacheValueCodec.isNullSentinel(String raw)`
- `<T> T CacheValueCodec.decode(String raw, Class<T> type)`
- `<T> T CacheValueCodec.decode(String raw, com.alibaba.fastjson.TypeReference<T> type)`
- 解码失败抛 fastjson 的 `RuntimeException`(由调用方决定包装)
- [ ] **Step 1: 写失败测试**
`src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java`
```java
package com.njcn.middle.cache.support;
import com.alibaba.fastjson.TypeReference;
import com.njcn.middle.cache.constant.CacheConstant;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CacheValueCodecTest {
private final CacheValueCodec codec = new CacheValueCodec();
@Data
@AllArgsConstructor
@NoArgsConstructor
static class Foo {
private Long id;
private String name;
}
@Test
void encodeNullReturnsSentinel() {
assertEquals(CacheConstant.NULL_SENTINEL, codec.encode(null));
assertTrue(codec.isNullSentinel(codec.encode(null)));
assertFalse(codec.isNullSentinel("{\"id\":1}"));
}
@Test
void encodeDecodePojoRoundTrip() {
Foo foo = new Foo(1L, "alice");
String raw = codec.encode(foo);
Foo back = codec.decode(raw, Foo.class);
assertEquals(foo, back);
}
@Test
void encodeDecodeStringRoundTrip() {
String raw = codec.encode("abc");
assertEquals("abc", codec.decode(raw, String.class));
}
@Test
void decodeGenericWithTypeReference() {
List<Integer> list = Arrays.asList(1, 2, 3);
String raw = codec.encode(list);
List<Integer> back = codec.decode(raw, new TypeReference<List<Integer>>() {
});
assertEquals(list, back);
}
@Test
void decodeDirtyStringThrows() {
assertThrows(RuntimeException.class, () -> codec.decode("{not-json", Foo.class));
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml`
Expected: 编译失败(`CacheValueCodec` 不存在)。
- [ ] **Step 3: 写实现**
`src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java`
```java
package com.njcn.middle.cache.support;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.njcn.middle.cache.constant.CacheConstant;
/**
* 缓存值编解码:对象 ↔ 字符串(fastjson),并承载空值标记。
*
* <p>解码失败直接抛 fastjson 异常({@link RuntimeException}),由调用方决定显式暴露(public get)
* 还是当未命中降级(getOrLoad 内部)。
*/
public class CacheValueCodec {
/** 对象 → 字符串:null → 空值标记;否则 fastjson 序列化。 */
public String encode(Object value) {
if (value == null) {
return CacheConstant.NULL_SENTINEL;
}
return JSON.toJSONString(value);
}
/** 是否空值标记。 */
public boolean isNullSentinel(String raw) {
return CacheConstant.NULL_SENTINEL.equals(raw);
}
/** 字符串 → 对象(Class)。 */
public <T> T decode(String raw, Class<T> type) {
return JSON.parseObject(raw, type);
}
/** 字符串 → 对象(TypeReference,支持泛型)。 */
public <T> T decode(String raw, TypeReference<T> type) {
return JSON.parseObject(raw, type);
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheValueCodecTest test -f pom.xml`
Expected: `Tests run: 5, Failures: 0`
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/support/CacheValueCodec.java \
src/test/java/com/njcn/middle/cache/support/CacheValueCodecTest.java
git commit -m "feat(cache): 值编解码器(fastjson 序列化 + 空值标记)"
```
---
### Task 3: 配置属性 `RedisCacheProperties`
**Files:**
- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java`
- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java`
**Interfaces:**
- Produces(lombok `@Data` 生成 getter/setter):
- `String keyPrefix=cache:``boolean envIsolation=false``String env=""`
- `long defaultTtlSeconds=3600``long nullTtlSeconds=60``double jitterRatio=0.1`
- `boolean slidingExpireEnabled=false`
- `Breakdown breakdown`,内嵌:`boolean enabled=true``long lockTtlMs=10000``long waitTimeoutMs=3000``long waitIntervalMs=50`
- 注意 `boolean` 字段的 getter 形如 `isEnvIsolation()` / `isSlidingExpireEnabled()` / `breakdown.isEnabled()`
- [ ] **Step 1: 写失败测试**
`src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java`
```java
package com.njcn.middle.cache.autoconfig;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.mock.env.MockEnvironment;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RedisCachePropertiesTest {
@Test
void defaults() {
RedisCacheProperties p = new RedisCacheProperties();
assertEquals("cache:", p.getKeyPrefix());
assertFalse(p.isEnvIsolation());
assertEquals("", p.getEnv());
assertEquals(3600, p.getDefaultTtlSeconds());
assertEquals(60, p.getNullTtlSeconds());
assertEquals(0.1, p.getJitterRatio());
assertFalse(p.isSlidingExpireEnabled());
assertTrue(p.getBreakdown().isEnabled());
assertEquals(10000, p.getBreakdown().getLockTtlMs());
assertEquals(3000, p.getBreakdown().getWaitTimeoutMs());
assertEquals(50, p.getBreakdown().getWaitIntervalMs());
}
@Test
void relaxedBinding() {
MockEnvironment env = new MockEnvironment()
.withProperty("redis-cache.default-ttl-seconds", "1800")
.withProperty("redis-cache.sliding-expire-enabled", "true")
.withProperty("redis-cache.breakdown.lock-ttl-ms", "5000")
.withProperty("redis-cache.breakdown.enabled", "false");
RedisCacheProperties p = Binder.get(env)
.bind("redis-cache", RedisCacheProperties.class).get();
assertEquals(1800, p.getDefaultTtlSeconds());
assertTrue(p.isSlidingExpireEnabled());
assertEquals(5000, p.getBreakdown().getLockTtlMs());
assertFalse(p.getBreakdown().isEnabled());
}
}
```
> 注:`MockEnvironment` 来自 `spring-test`(已随 `spring-boot-starter-test` 引入);`ConfigurationPropertySources` import 保留以确保 `Binder.get(Environment)` 走宽松绑定。
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml`
Expected: 编译失败(`RedisCacheProperties` 不存在)。
- [ ] **Step 3: 写实现**
`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java`
```java
package com.njcn.middle.cache.autoconfig;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Redis 缓存配置属性,前缀 {@code redis-cache}。全部可选,有合理默认。
*/
@Data
@ConfigurationProperties("redis-cache")
public class RedisCacheProperties {
/** 统一 key 前缀。 */
private String keyPrefix = "cache:";
/** 是否按环境隔离 key。 */
private boolean envIsolation = false;
/** 环境标识(隔离开启且非空时拼入 key)。 */
private String env = "";
/** set/getOrLoad 不传 TTL 时的默认过期(秒)。 */
private long defaultTtlSeconds = 3600;
/** 空值标记 TTL(秒)——穿透防护,宜短。 */
private long nullTtlSeconds = 60;
/** TTL 随机抖动比例——雪崩防护;0 关闭抖动。 */
private double jitterRatio = 0.1;
/** 读命中真实值时自动续期(滑动过期),默认关闭。 */
private boolean slidingExpireEnabled = false;
private Breakdown breakdown = new Breakdown();
/** 击穿:互斥锁。 */
@Data
public static class Breakdown {
/** 关闭后 getOrLoad 退化为无锁直接回源。 */
private boolean enabled = true;
/** 锁持有 TTL(ms),防持锁线程崩溃死锁。 */
private long lockTtlMs = 10000;
/** 未抢到锁的线程最长等待(ms),超时降级直接回源。 */
private long waitTimeoutMs = 3000;
/** 未抢到锁时自旋重读间隔(ms)。 */
private long waitIntervalMs = 50;
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCachePropertiesTest test -f pom.xml`
Expected: `Tests run: 2, Failures: 0`
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheProperties.java \
src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.java
git commit -m "feat(cache): 配置属性 redis-cache.*(TTL/抖动/滑动/击穿锁)"
```
---
### Task 4: 模板读写基础(set/get/delete/exists/expire + 抖动 + 滑动续期)
**Files:**
- Create: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
- Test: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`
**Interfaces:**
- Consumes: `CacheKeyBuilder`(Task 1)、`CacheValueCodec`(Task 2)、`RedisCacheProperties`(Task 3)、`CacheConstant`
- Produces(本任务实现这些;`getOrLoad` 由 Task 5 追加):
- 构造 `new RedisCacheEnhanceTemplate(StringRedisTemplate, CacheKeyBuilder, CacheValueCodec, RedisCacheProperties)`
- `void set(String key, Object value)` / `void set(String key, Object value, long ttlSeconds)`
- `<T> T get(String key, Class<T> type)` / `<T> T get(String key, TypeReference<T> type)`
- `boolean delete(String key)` / `boolean exists(String key)`
- `boolean expire(String key, long ttlSeconds)`
- 包级 `long applyJitter(long ttlSeconds)`(供测试与 Task 5 复用)
- 私有 `void slidingTouch(String dataKey)`、私有 `<T> T doGet(String key, java.util.function.Function<String,T> decoder)`
- 关键语义:`get` 命中真实值时若开启滑动则续期;`get` 反序列化失败抛 `IllegalStateException`;`set`/`expire` 的 TTL 走 `applyJitter`
- [ ] **Step 1: 写失败测试**
`src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`
```java
package com.njcn.middle.cache.template;
import com.alibaba.fastjson.JSON;
import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
import com.njcn.middle.cache.constant.CacheConstant;
import com.njcn.middle.cache.support.CacheKeyBuilder;
import com.njcn.middle.cache.support.CacheValueCodec;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RedisCacheEnhanceTemplateTest {
@Mock
StringRedisTemplate redis;
@Mock
ValueOperations<String, String> valueOps;
CacheValueCodec codec = new CacheValueCodec();
CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, "");
RedisCacheProperties props = new RedisCacheProperties();
RedisCacheEnhanceTemplate template;
@Data
@AllArgsConstructor
@NoArgsConstructor
static class User {
private Long id;
private String name;
}
@BeforeEach
void init() {
MockitoAnnotations.initMocks(this);
lenient().when(redis.opsForValue()).thenReturn(valueOps);
template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props);
}
@Test
void setSerializesWithExactTtlWhenNoJitter() {
props.setJitterRatio(0);
User u = new User(1L, "a");
template.set("user:1", u, 100);
verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS);
}
@Test
void getHitDeserializes() {
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
User got = template.get("user:1", User.class);
assertEquals("a", got.getName());
}
@Test
void getMissReturnsNull() {
when(valueOps.get("cache:user:1")).thenReturn(null);
assertNull(template.get("user:1", User.class));
}
@Test
void getNullSentinelReturnsNull() {
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
assertNull(template.get("user:1", User.class));
}
@Test
void getDirtyDataThrowsIllegalState() {
when(valueOps.get("cache:user:1")).thenReturn("{not-json");
assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class));
}
@Test
void expireDelegatesWithJitterTtl() {
props.setJitterRatio(0);
when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true);
assertTrue(template.expire("user:1", 200));
verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS);
}
@Test
void applyJitterWithinBounds() {
props.setJitterRatio(0.1);
long bound = (long) (100 * 0.1);
for (int i = 0; i < 50; i++) {
long ttl = template.applyJitter(100);
assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl);
}
props.setJitterRatio(0);
assertEquals(100, template.applyJitter(100));
}
@Test
void slidingOffDoesNotRenewOnGet() {
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
template.get("user:1", User.class);
verify(redis, never()).expire(any(), anyLong(), any());
}
@Test
void slidingOnRenewsRealValueOnGet() {
props.setSlidingExpireEnabled(true);
props.setJitterRatio(0);
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
template.get("user:1", User.class);
verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS);
}
@Test
void slidingOnDoesNotRenewNullSentinel() {
props.setSlidingExpireEnabled(true);
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
assertNull(template.get("user:1", User.class));
verify(redis, never()).expire(any(), anyLong(), any());
}
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
Expected: 编译失败(`RedisCacheEnhanceTemplate` 不存在)。
- [ ] **Step 3: 写实现**
`src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
```java
package com.njcn.middle.cache.template;
import com.alibaba.fastjson.TypeReference;
import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
import com.njcn.middle.cache.support.CacheKeyBuilder;
import com.njcn.middle.cache.support.CacheValueCodec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
/**
* Redis 缓存增强模板。
*
* <p>底层 {@link StringRedisTemplate} 存字符串,fastjson 序列化。本类(Task 4)实现读写基础;
* {@code getOrLoad} 三防(穿透/雪崩/击穿)由 Task 5 追加。
*/
@Slf4j
public class RedisCacheEnhanceTemplate {
private final StringRedisTemplate redis;
private final CacheKeyBuilder keyBuilder;
private final CacheValueCodec codec;
private final RedisCacheProperties props;
public RedisCacheEnhanceTemplate(StringRedisTemplate redis, CacheKeyBuilder keyBuilder,
CacheValueCodec codec, RedisCacheProperties props) {
this.redis = redis;
this.keyBuilder = keyBuilder;
this.codec = codec;
this.props = props;
}
// ---- 写 ----
public void set(String key, Object value) {
set(key, value, props.getDefaultTtlSeconds());
}
public void set(String key, Object value, long ttlSeconds) {
redis.opsForValue().set(keyBuilder.build(key), codec.encode(value),
applyJitter(ttlSeconds), TimeUnit.SECONDS);
}
// ---- 读 ----
public <T> T get(String key, Class<T> type) {
return doGet(key, raw -> codec.decode(raw, type));
}
public <T> T get(String key, TypeReference<T> type) {
return doGet(key, raw -> codec.decode(raw, type));
}
private <T> T doGet(String key, Function<String, T> decoder) {
String dataKey = keyBuilder.build(key);
String raw = redis.opsForValue().get(dataKey);
if (raw == null || codec.isNullSentinel(raw)) {
return null;
}
T value;
try {
value = decoder.apply(raw);
} catch (RuntimeException e) {
throw new IllegalStateException("cache decode failed, key=" + dataKey, e);
}
slidingTouch(dataKey);
return value;
}
// ---- 删 / 判存 ----
public boolean delete(String key) {
return Boolean.TRUE.equals(redis.delete(keyBuilder.build(key)));
}
public boolean exists(String key) {
return Boolean.TRUE.equals(redis.hasKey(keyBuilder.build(key)));
}
// ---- 续期 ----
public boolean expire(String key, long ttlSeconds) {
return Boolean.TRUE.equals(
redis.expire(keyBuilder.build(key), applyJitter(ttlSeconds), TimeUnit.SECONDS));
}
// ---- 内部 ----
/** 滑动过期:开启时对已存在的真实值 key 续期(直接对 dataKey 操作,失败忽略)。 */
private void slidingTouch(String dataKey) {
if (!props.isSlidingExpireEnabled()) {
return;
}
try {
redis.expire(dataKey, applyJitter(props.getDefaultTtlSeconds()), TimeUnit.SECONDS);
} catch (RuntimeException e) {
log.warn("[redis-cache] sliding expire failed key={}", dataKey, e);
}
}
/** 在原 TTL 上叠加 [0, ttl*ratio] 的随机抖动(只增不减);ratio<=0 或 ttl<=0 时原样返回。 */
long applyJitter(long ttlSeconds) {
double ratio = props.getJitterRatio();
if (ratio <= 0 || ttlSeconds <= 0) {
return ttlSeconds;
}
long bound = (long) (ttlSeconds * ratio);
if (bound <= 0) {
return ttlSeconds;
}
return ttlSeconds + ThreadLocalRandom.current().nextLong(bound + 1);
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
Expected: `Tests run: 10, Failures: 0`
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
git commit -m "feat(cache): 模板读写基础(set/get/delete/exists/expire + TTL 抖动 + 滑动续期)"
```
---
### Task 5: 模板 `getOrLoad` 三防(穿透 + 雪崩 + 击穿互斥锁)
**Files:**
- Modify: `src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java`
- Modify: `src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java`(追加测试)
**Interfaces:**
- Consumes: Task 4 的全部私有/包级成员(`applyJitter``slidingTouch``codec``keyBuilder``props``redis`)、`CacheConstant.LOCK_SUFFIX`
- Produces:
- `<T> T getOrLoad(String key, Class<T> type, java.util.function.Supplier<T> loader)`
- `<T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader)`
- `<T> T getOrLoad(String key, com.alibaba.fastjson.TypeReference<T> type, long ttlSeconds, Supplier<T> loader)`
- 锁实现:`opsForValue().setIfAbsent(lockKey, token, Duration)` 抢锁;Lua `if get==token then del` 释放。
- [ ] **Step 1: 追加失败测试**
`RedisCacheEnhanceTemplateTest` 类中追加以下导入与测试方法(导入加到文件顶部已有 import 区):
```java
// 追加 import:
import org.springframework.data.redis.core.script.RedisScript;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.junit.jupiter.api.Assertions.assertEquals;
```
```java
@Test
void getOrLoadHitDoesNotCallLoader() {
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
AtomicInteger calls = new AtomicInteger();
Supplier<User> loader = () -> {
calls.incrementAndGet();
return new User(9L, "x");
};
User got = template.getOrLoad("user:1", User.class, 60, loader);
assertEquals("a", got.getName());
assertEquals(0, calls.get());
}
@Test
void getOrLoadNullSentinelHitReturnsNullNoLoader() {
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
AtomicInteger calls = new AtomicInteger();
assertNull(template.getOrLoad("user:1", User.class, 60, () -> {
calls.incrementAndGet();
return new User(1L, "a");
}));
assertEquals(0, calls.get());
}
@Test
void getOrLoadMissAcquiresLockLoadsAndCaches() {
props.setJitterRatio(0);
when(valueOps.get("cache:user:1")).thenReturn(null);
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
.thenReturn(true);
User loaded = new User(1L, "a");
User got = template.getOrLoad("user:1", User.class, 60, () -> loaded);
assertEquals("a", got.getName());
verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS);
// 释放锁:执行了 Lua 脚本
verify(redis).execute(any(RedisScript.class), anyList(), any());
}
@Test
void getOrLoadLoaderReturnsNullWritesSentinel() {
when(valueOps.get("cache:user:1")).thenReturn(null);
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
.thenReturn(true);
assertNull(template.getOrLoad("user:1", User.class, 60, () -> null));
verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL,
props.getNullTtlSeconds(), TimeUnit.SECONDS);
}
@Test
void getOrLoadNotLockedWaitsThenReadsValue() {
props.getBreakdown().setWaitIntervalMs(1);
props.getBreakdown().setWaitTimeoutMs(1000);
// 初始 tryGet 返回 null,未抢到锁,随后等待中读到值
when(valueOps.get("cache:user:1"))
.thenReturn(null)
.thenReturn(null)
.thenReturn(JSON.toJSONString(new User(1L, "a")));
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
AtomicInteger calls = new AtomicInteger();
User got = template.getOrLoad("user:1", User.class, 60, () -> {
calls.incrementAndGet();
return new User(9L, "x");
});
assertEquals("a", got.getName());
assertEquals(0, calls.get());
}
@Test
void getOrLoadWaitTimeoutFallsBackToLoader() {
props.getBreakdown().setWaitIntervalMs(1);
props.getBreakdown().setWaitTimeoutMs(5);
when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
AtomicInteger calls = new AtomicInteger();
User got = template.getOrLoad("user:1", User.class, 60, () -> {
calls.incrementAndGet();
return new User(7L, "fallback");
});
assertEquals("fallback", got.getName());
assertEquals(1, calls.get());
}
@Test
void getOrLoadBreakdownDisabledLoadsWithoutLock() {
props.getBreakdown().setEnabled(false);
when(valueOps.get("cache:user:1")).thenReturn(null);
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a"));
assertEquals("a", got.getName());
verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class));
}
@Test
void getOrLoadInternalDecodeFailureFallsBackToReload() {
// 初始读到脏数据(当未命中降级),抢到锁后回源重建
when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty");
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
.thenReturn(true);
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh"));
assertEquals("fresh", got.getName());
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
Expected: 编译失败(`getOrLoad` 不存在)。
- [ ] **Step 3: 写实现**
`RedisCacheEnhanceTemplate` 顶部追加 import:
```java
import cn.hutool.core.util.IdUtil;
import com.njcn.middle.cache.constant.CacheConstant;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.time.Duration;
import java.util.Collections;
import java.util.function.Supplier;
```
在类内(`applyJitter` 之后)追加以下成员;并在类顶部字段区加入脚本常量:
```java
/** 释放锁 Lua:token 匹配才删,防误删他人锁。 */
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
Long.class);
```
```java
// ---- 读取或回源(三防核心)----
public <T> T getOrLoad(String key, Class<T> type, Supplier<T> loader) {
return getOrLoad(key, type, props.getDefaultTtlSeconds(), loader);
}
public <T> T getOrLoad(String key, Class<T> type, long ttlSeconds, Supplier<T> loader) {
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
}
public <T> T getOrLoad(String key, TypeReference<T> type, long ttlSeconds, Supplier<T> loader) {
return doGetOrLoad(key, raw -> codec.decode(raw, type), ttlSeconds, loader);
}
private <T> T doGetOrLoad(String key, Function<String, T> decoder, long ttlSeconds, Supplier<T> loader) {
String dataKey = keyBuilder.build(key);
Lookup<T> r = tryGet(dataKey, decoder);
if (r.hit) {
if (r.realValue) {
slidingTouch(dataKey);
}
return r.value;
}
RedisCacheProperties.Breakdown bd = props.getBreakdown();
if (!bd.isEnabled()) {
return loadAndCache(dataKey, ttlSeconds, loader);
}
String lockKey = dataKey + CacheConstant.LOCK_SUFFIX;
String token = IdUtil.fastSimpleUUID();
boolean locked = Boolean.TRUE.equals(redis.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofMillis(bd.getLockTtlMs())));
if (locked) {
try {
Lookup<T> r2 = tryGet(dataKey, decoder); // double-check
if (r2.hit) {
if (r2.realValue) {
slidingTouch(dataKey);
}
return r2.value;
}
return loadAndCache(dataKey, ttlSeconds, loader);
} finally {
releaseLock(lockKey, token);
}
}
// 未抢到锁:自旋等待重读,超时降级直接回源
long waited = 0;
while (waited < bd.getWaitTimeoutMs()) {
sleep(bd.getWaitIntervalMs());
waited += bd.getWaitIntervalMs();
Lookup<T> r3 = tryGet(dataKey, decoder);
if (r3.hit) {
if (r3.realValue) {
slidingTouch(dataKey);
}
return r3.value;
}
}
log.warn("[redis-cache] breakdown wait timeout, fallback to loader, key={}", key);
return loader.get();
}
/** 读取三态:未命中 / 命中空值标记 / 命中真实值;反序列化失败当未命中降级。 */
private <T> Lookup<T> tryGet(String dataKey, Function<String, T> decoder) {
String raw = redis.opsForValue().get(dataKey);
if (raw == null) {
return Lookup.miss();
}
if (codec.isNullSentinel(raw)) {
return Lookup.nullHit();
}
try {
return Lookup.hit(decoder.apply(raw));
} catch (RuntimeException e) {
log.warn("[redis-cache] decode failed, treat as miss key={}", dataKey, e);
return Lookup.miss();
}
}
/** 回源并写缓存:null → 空值标记(穿透);非 null → 抖动 TTL(雪崩)。 */
private <T> T loadAndCache(String dataKey, long ttlSeconds, Supplier<T> loader) {
T value = loader.get();
if (value == null) {
redis.opsForValue().set(dataKey, CacheConstant.NULL_SENTINEL,
props.getNullTtlSeconds(), TimeUnit.SECONDS);
} else {
redis.opsForValue().set(dataKey, codec.encode(value),
applyJitter(ttlSeconds), TimeUnit.SECONDS);
}
return value;
}
private void releaseLock(String lockKey, String token) {
try {
redis.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token);
} catch (RuntimeException e) {
log.warn("[redis-cache] release lock failed lockKey={}", lockKey, e);
}
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/** 读取三态载体(对外不暴露)。 */
private static final class Lookup<T> {
final boolean hit; // 命中(真实值或空值标记)
final boolean realValue; // 命中且为真实值
final T value;
private Lookup(boolean hit, boolean realValue, T value) {
this.hit = hit;
this.realValue = realValue;
this.value = value;
}
static <T> Lookup<T> miss() {
return new Lookup<>(false, false, null);
}
static <T> Lookup<T> nullHit() {
return new Lookup<>(true, false, null);
}
static <T> Lookup<T> hit(T v) {
return new Lookup<>(true, true, v);
}
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheEnhanceTemplateTest test -f pom.xml`
Expected: `Tests run: 18, Failures: 0`(Task 4 的 10 + Task 5 的 8)。
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplate.java \
src/test/java/com/njcn/middle/cache/template/RedisCacheEnhanceTemplateTest.java
git commit -m "feat(cache): getOrLoad 三防(空值穿透 + TTL 抖动雪崩 + 互斥锁击穿 + 超时降级)"
```
---
### Task 6: 自动装配 + spring.factories 注册
**Files:**
- Create: `src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java`
- Modify: `src/main/resources/META-INF/spring.factories`
- Test: `src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java`
**Interfaces:**
- Consumes: `RedisCacheProperties``CacheKeyBuilder``CacheValueCodec``RedisCacheEnhanceTemplate`
- Produces: 三个 `@ConditionalOnMissingBean` Bean(`CacheKeyBuilder``CacheValueCodec``RedisCacheEnhanceTemplate`),并经 `spring.factories` 自动加载。
- [ ] **Step 1: 写失败测试**
`src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java`
```java
package com.njcn.middle.cache.autoconfig;
import com.njcn.middle.cache.support.CacheKeyBuilder;
import com.njcn.middle.cache.support.CacheValueCodec;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class RedisCacheAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
RedisAutoConfiguration.class, RedisCacheAutoConfiguration.class))
.withPropertyValues("spring.redis.host=127.0.0.1", "spring.redis.port=6379");
@Test
void registersCacheBeans() {
runner.run(ctx -> {
assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class);
assertThat(ctx).hasSingleBean(CacheKeyBuilder.class);
assertThat(ctx).hasSingleBean(CacheValueCodec.class);
});
}
@Test
void backsOffWhenUserDefinesOwnTemplate() {
runner.withUserConfiguration(CustomConfig.class).run(ctx -> {
assertThat(ctx).hasSingleBean(RedisCacheEnhanceTemplate.class);
assertThat(ctx.getBean(RedisCacheEnhanceTemplate.class))
.isSameAs(ctx.getBean("myTemplate"));
});
}
@Configuration
static class CustomConfig {
@Bean
RedisCacheEnhanceTemplate myTemplate() {
return new RedisCacheEnhanceTemplate(
new StringRedisTemplate(mock(RedisConnectionFactory.class)),
new CacheKeyBuilder("cache:", false, ""),
new CacheValueCodec(),
new RedisCacheProperties());
}
}
}
```
> ApplicationContextRunner 启动上下文但 Lettuce 懒连接,不会真的连 Redis,故本测试无需真实 Redis。
- [ ] **Step 2: 运行测试,确认失败**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml`
Expected: 编译失败(`RedisCacheAutoConfiguration` 不存在)。
- [ ] **Step 3: 写实现**
`src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java`
```java
package com.njcn.middle.cache.autoconfig;
import com.njcn.middle.cache.support.CacheKeyBuilder;
import com.njcn.middle.cache.support.CacheValueCodec;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* Redis 缓存自动装配。装配在 {@link RedisAutoConfiguration} 之后,确保 {@link StringRedisTemplate} 就绪。
* 与 stream 装配互不引用;纯模板、无后台线程。
*/
@Configuration
@ConditionalOnClass(StringRedisTemplate.class)
@AutoConfigureAfter(RedisAutoConfiguration.class)
@EnableConfigurationProperties(RedisCacheProperties.class)
public class RedisCacheAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public CacheKeyBuilder cacheKeyBuilder(RedisCacheProperties props) {
return new CacheKeyBuilder(props.getKeyPrefix(), props.isEnvIsolation(), props.getEnv());
}
@Bean
@ConditionalOnMissingBean
public CacheValueCodec cacheValueCodec() {
return new CacheValueCodec();
}
@Bean
@ConditionalOnMissingBean
public RedisCacheEnhanceTemplate redisCacheEnhanceTemplate(StringRedisTemplate redis,
CacheKeyBuilder cacheKeyBuilder,
CacheValueCodec cacheValueCodec,
RedisCacheProperties props) {
return new RedisCacheEnhanceTemplate(redis, cacheKeyBuilder, cacheValueCodec, props);
}
}
```
修改 `src/main/resources/META-INF/spring.factories` 为(追加 cache 装配,逗号续行):
```
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.njcn.middle.stream.autoconfig.RedisStreamAutoConfiguration,\
com.njcn.middle.cache.autoconfig.RedisCacheAutoConfiguration
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=RedisCacheAutoConfigurationTest test -f pom.xml`
Expected: `Tests run: 2, Failures: 0`
- [ ] **Step 5: 提交**
```bash
git add src/main/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfiguration.java \
src/main/resources/META-INF/spring.factories \
src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.java
git commit -m "feat(cache): 自动装配 + spring.factories 注册"
```
---
### Task 7: 集成测试 — 收发闭环 RoundTrip(含 Redis 探测脚手架)
**Files:**
- Create: `src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java`
- Create: `src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java`
**Interfaces:**
- Consumes: 自动装配出的 `RedisCacheEnhanceTemplate`(Task 6)
- Produces: `RedisAvailableCondition`(供 Task 8/9/10 复用的 JUnit5 `ExecutionCondition`)
- [ ] **Step 1: 写测试(集成测试天然是"行为测试",无 Redis 时自动 skip)**
`src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java`
```java
package com.njcn.middle.cache.integration;
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.net.InetSocketAddress;
import java.net.Socket;
/** Redis 不可达时禁用整个测试类(skipped,非 failed),保证无 Redis 环境 mvn test 仍全绿。 */
public class RedisAvailableCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
String host = System.getProperty("spring.redis.host", "127.0.0.1");
int port = Integer.parseInt(System.getProperty("spring.redis.port", "6379"));
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(host, port), 500);
return ConditionEvaluationResult.enabled("redis reachable at " + host + ":" + port);
} catch (Exception e) {
return ConditionEvaluationResult.disabled(
"no redis at " + host + ":" + port + ", skip integration test");
}
}
}
```
`src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java`
```java
package com.njcn.middle.cache.integration;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 集成测试:真实 Redis 下 set/get/exists/delete 闭环。Redis 不可达自动 skip。
*
* <p>连接默认 127.0.0.1:6379;覆盖用 {@code -Dspring.redis.host=… -Dspring.redis.port=… -Dspring.redis.password=…}。
*/
@ExtendWith(RedisAvailableCondition.class)
@SpringBootTest(classes = CacheRoundTripTest.TestApp.class,
properties = {"redis-cache.key-prefix=it-cache:"})
class CacheRoundTripTest {
@Autowired
private RedisCacheEnhanceTemplate cache;
@Test
void setGetExistsDelete() {
String key = "rt:user:1";
cache.delete(key);
cache.set(key, new User(1L, "alice"), 60);
User got = cache.get(key, User.class);
assertEquals("alice", got.getName());
assertTrue(cache.exists(key));
assertTrue(cache.delete(key));
assertNull(cache.get(key, User.class));
assertFalse(cache.exists(key));
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
}
@Data
@AllArgsConstructor
@NoArgsConstructor
static class User {
private Long id;
private String name;
}
}
```
- [ ] **Step 2: 运行,确认行为(有 Redis 则 PASS;无 Redis 则 skipped)**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheRoundTripTest test -f pom.xml`
Expected: 有本地 Redis → `Tests run: 1, Failures: 0`;无 Redis → 该类 skipped、构建仍成功。本地起 Redis:`docker run -d -p 6379:6379 redis:6.2`)
- [ ] **Step 3: 提交**
```bash
git add src/test/java/com/njcn/middle/cache/integration/RedisAvailableCondition.java \
src/test/java/com/njcn/middle/cache/integration/CacheRoundTripTest.java
git commit -m "test(cache): 集成测试 set/get/exists/delete 闭环 + Redis 探测脚手架"
```
---
### Task 8: 集成测试 — 穿透(空值缓存,loader 仅调用一次)
**Files:**
- Create: `src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java`
**Interfaces:**
- Consumes: `RedisCacheEnhanceTemplate``RedisAvailableCondition`(Task 7)
- [ ] **Step 1: 写测试**
`src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java`
```java
package com.njcn.middle.cache.integration;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/** 集成测试:回源返回 null 时缓存空值标记,二次查询命中空标记、loader 不再被调用。 */
@ExtendWith(RedisAvailableCondition.class)
@SpringBootTest(classes = CachePenetrationTest.TestApp.class,
properties = {"redis-cache.key-prefix=it-cache:", "redis-cache.null-ttl-seconds=30"})
class CachePenetrationTest {
@Autowired
private RedisCacheEnhanceTemplate cache;
@Test
void nullResultCachedAndLoaderCalledOnce() {
String key = "pen:missing:1";
cache.delete(key);
AtomicInteger calls = new AtomicInteger();
Supplier<User> loader = () -> {
calls.incrementAndGet();
return null; // 数据不存在
};
assertNull(cache.getOrLoad(key, User.class, 60, loader));
assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 命中空值标记
assertEquals(1, calls.get());
cache.delete(key);
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
}
@Data
@AllArgsConstructor
@NoArgsConstructor
static class User {
private Long id;
private String name;
}
}
```
- [ ] **Step 2: 运行,确认行为**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CachePenetrationTest test -f pom.xml`
Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。
- [ ] **Step 3: 提交**
```bash
git add src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.java
git commit -m "test(cache): 集成测试穿透防护(空值缓存,loader 仅调用一次)"
```
---
### Task 9: 集成测试 — 显式 expire 续期 + 滑动过期
**Files:**
- Create: `src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java`
**Interfaces:**
- Consumes: `RedisCacheEnhanceTemplate``CacheKeyBuilder`(用于算实际 key 读 TTL)、`StringRedisTemplate``RedisAvailableCondition`
- 本类配置 `redis-cache.sliding-expire-enabled=true`
- [ ] **Step 1: 写测试**
`src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java`
```java
package com.njcn.middle.cache.integration;
import com.njcn.middle.cache.support.CacheKeyBuilder;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 集成测试:显式 expire 续期、滑动过期续真实值、空值标记不被续。
* default-ttl=3600,故滑动续期会把 TTL 拉到远大于初始 60s。
*/
@ExtendWith(RedisAvailableCondition.class)
@SpringBootTest(classes = CacheExpireSlidingTest.TestApp.class,
properties = {
"redis-cache.key-prefix=it-cache:",
"redis-cache.default-ttl-seconds=3600",
"redis-cache.null-ttl-seconds=60",
"redis-cache.jitter-ratio=0",
"redis-cache.sliding-expire-enabled=true"
})
class CacheExpireSlidingTest {
@Autowired
private RedisCacheEnhanceTemplate cache;
@Autowired
private StringRedisTemplate redis;
@Autowired
private CacheKeyBuilder keyBuilder;
private long ttl(String bizKey) {
Long e = redis.getExpire(keyBuilder.build(bizKey), TimeUnit.SECONDS);
return e == null ? -1 : e;
}
@Test
void explicitExpireExtendsTtl() {
String key = "exp:1";
cache.set(key, new User(1L, "a"), 60);
assertTrue(ttl(key) <= 60 && ttl(key) > 0);
assertTrue(cache.expire(key, 600));
assertTrue(ttl(key) > 100, "expire 应把 TTL 续到 600 量级");
cache.delete(key);
}
@Test
void expireOnMissingKeyReturnsFalse() {
String key = "exp:missing";
cache.delete(key);
assertFalse(cache.expire(key, 600));
}
@Test
void slidingRenewsRealValueOnGet() {
String key = "sld:1";
cache.set(key, new User(1L, "a"), 60); // 初始 ~60s
assertTrue(ttl(key) <= 60 && ttl(key) > 0);
cache.get(key, User.class); // 命中真实值 → 滑动续到 default 3600
assertTrue(ttl(key) > 1000, "滑动过期应把 TTL 续到 default 量级");
cache.delete(key);
}
@Test
void slidingDoesNotRenewNullSentinel() {
String key = "sld:missing";
cache.delete(key);
Supplier<User> loader = () -> null;
assertNull(cache.getOrLoad(key, User.class, 60, loader)); // 写空标记 ttl=60
long before = ttl(key);
assertTrue(before <= 60 && before > 0);
assertNull(cache.get(key, User.class)); // 命中空标记,不续
assertTrue(ttl(key) <= 60, "空值标记不应被滑动续期");
cache.delete(key);
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
}
@Data
@AllArgsConstructor
@NoArgsConstructor
static class User {
private Long id;
private String name;
}
}
```
- [ ] **Step 2: 运行,确认行为**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheExpireSlidingTest test -f pom.xml`
Expected: 有 Redis → `Tests run: 4, Failures: 0`;无 Redis → skipped。
- [ ] **Step 3: 提交**
```bash
git add src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.java
git commit -m "test(cache): 集成测试显式 expire 续期与滑动过期(真实值续、空标记不续)"
```
---
### Task 10: 集成测试 — 击穿并发(loader 仅回源一次)
**Files:**
- Create: `src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java`
**Interfaces:**
- Consumes: `RedisCacheEnhanceTemplate``RedisAvailableCondition`;默认击穿锁配置(enabled=true)。
- [ ] **Step 1: 写测试**
`src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java`
```java
package com.njcn.middle.cache.integration;
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 集成测试:N 个线程并发 getOrLoad 同一未命中热点 key,慢回源放大竞争窗口,
* 互斥锁应保证 loader 仅被调用 1 次,且所有线程拿到同一结果。
*/
@ExtendWith(RedisAvailableCondition.class)
@SpringBootTest(classes = CacheBreakdownConcurrencyTest.TestApp.class,
properties = {"redis-cache.key-prefix=it-cache:"})
class CacheBreakdownConcurrencyTest {
@Autowired
private RedisCacheEnhanceTemplate cache;
@Test
void onlyOneLoaderUnderConcurrency() throws Exception {
String key = "bd:hot:1";
cache.delete(key);
AtomicInteger calls = new AtomicInteger();
java.util.function.Supplier<User> loader = () -> {
calls.incrementAndGet();
try {
Thread.sleep(300); // 慢回源
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
return new User(1L, "hot");
};
int n = 10;
ExecutorService pool = Executors.newFixedThreadPool(n);
CountDownLatch ready = new CountDownLatch(n);
CountDownLatch go = new CountDownLatch(1);
List<Future<User>> futures = new ArrayList<>();
for (int i = 0; i < n; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
go.await();
return cache.getOrLoad(key, User.class, 60, loader);
}));
}
ready.await();
go.countDown();
for (Future<User> f : futures) {
assertEquals("hot", f.get(10, TimeUnit.SECONDS).getName());
}
assertEquals(1, calls.get(), "并发下 loader 应只回源一次");
pool.shutdownNow();
cache.delete(key);
}
@SpringBootConfiguration
@EnableAutoConfiguration
static class TestApp {
}
@Data
@AllArgsConstructor
@NoArgsConstructor
static class User {
private Long id;
private String name;
}
}
```
- [ ] **Step 2: 运行,确认行为**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository -Dtest=CacheBreakdownConcurrencyTest test -f pom.xml`
Expected: 有 Redis → `Tests run: 1, Failures: 0`;无 Redis → skipped。
- [ ] **Step 3: 提交**
```bash
git add src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.java
git commit -m "test(cache): 集成测试击穿并发(互斥锁下 loader 仅回源一次)"
```
---
### Task 11: README 缓存章节 + 全量回归
**Files:**
- Modify: `README.md`(在文末追加「缓存」章节,**不改动现有 stream 内容**)
**Interfaces:** 无新代码。仅文档 + 全量测试。
- [ ] **Step 1: 在 README 末尾追加缓存章节**
> 先 `git status` 确认 `README.md` 既有的未提交改动是否还在;追加内容到文件**末尾**,不要触碰现有任何行。
`README.md` 末尾追加:
```markdown
---
## 十、Redis 缓存(`RedisCacheEnhanceTemplate`)
除 Stream 收发外,starter 同样提供开箱即用的**编程式缓存**,把 TTL、序列化、穿透/雪崩/击穿三防封装进一个 `getOrLoad`。注入即用,无需额外注解。
### 发送/读取
```java
import com.njcn.middle.cache.template.RedisCacheEnhanceTemplate;
import org.springframework.stereotype.Service;
@Service
public class UserCache {
private final RedisCacheEnhanceTemplate cache;
public UserCache(RedisCacheEnhanceTemplate cache) {
this.cache = cache;
}
public User getUser(Long id) {
// 命中即返回;未命中则回源 loadFromDb 并写缓存,自动防穿透/雪崩/击穿
return cache.getOrLoad("user:" + id, User.class, 3600, () -> loadFromDb(id));
}
public void evict(Long id) {
cache.delete("user:" + id);
}
}
```
核心方法:
| 方法 | 说明 |
|---|---|
| `set(key, value[, ttlSeconds])` | 写缓存,TTL 叠加随机抖动(防雪崩);不传 TTL 用 `default-ttl-seconds` |
| `get(key, Class<T>)` / `get(key, TypeReference<T>)` | 读缓存,支持泛型;未命中或命中空值标记返回 `null`;脏数据抛 `IllegalStateException` |
| `delete(key)` / `exists(key)` | 删除 / 判存 |
| `expire(key, ttlSeconds)` | 显式续期(走抖动);key 不存在返回 `false` |
| `getOrLoad(key, type[, ttlSeconds], loader)` | 读取或回源;内置穿透(空值标记)+ 雪崩(TTL 抖动)+ 击穿(互斥锁)三防 |
### 配置项(前缀 `redis-cache`,均可选)
```yaml
redis-cache:
key-prefix: "cache:" # 统一 key 前缀
env-isolation: false # 按环境隔离 key(开启且 env 非空 → cache:env:key)
env: ""
default-ttl-seconds: 3600 # 默认 TTL(秒)
null-ttl-seconds: 60 # 空值标记 TTL(秒,穿透防护)
jitter-ratio: 0.1 # TTL 随机抖动比例(雪崩防护;0 关闭)
sliding-expire-enabled: false # 读命中真实值自动续 default-ttl(滑动过期,空值标记不续)
breakdown: # 击穿:互斥锁
enabled: true # 关闭则退化为无锁直接回源
lock-ttl-ms: 10000 # 锁持有 TTL
wait-timeout-ms: 3000 # 未抢到锁的最长等待,超时降级直接回源
wait-interval-ms: 50 # 未抢到锁的自旋重读间隔
```
> 缓存模块与 Stream 完全独立:只用缓存不注册任何 handler 也能工作;反之亦然。
```
- [ ] **Step 2: 全量回归(确认整库全绿,且未破坏 stream 任何测试)**
Run: `C:\software\apache-maven-3.8.9\bin\mvn.cmd -s C:\software\apache-maven-3.8.9\conf\settings.xml -Dmaven.repo.local=C:\software\apache-maven-3.8.9\repository test -f pom.xml`
Expected: `BUILD SUCCESS`;所有单元测试 PASS;集成测试在有 Redis 时 PASS、无 Redis 时 skipped;**stream 既有测试不受影响**。
- [ ] **Step 3: 提交(只提交 README)**
```bash
git add README.md
git commit -m "docs(cache): README 增补缓存章节(用法与配置)"
```
> ⚠️ 仍只 `git add README.md`;若 `git status` 显示 README 还有本功能之外的旧改动,先与维护者确认再决定是否分离提交,**不要**用 `git add -A`。
---
## Self-Review(本计划对照 spec 的自检结果)
- **spec §一/§六 API**:`set/get/delete/exists/expire/getOrLoad` → Task 4(读写)+ Task 5(getOrLoad)。✅
- **spec §五 序列化/空值标记**:`CacheValueCodec` + `CacheConstant.NULL_SENTINEL` → Task 1/2。✅
- **spec §四 Key 设计**:`CacheKeyBuilder.build/buildLock` → Task 1。✅
- **spec §三 配置项**:`RedisCacheProperties` 全字段 + `sliding-expire-enabled` → Task 3。✅
- **spec §七 三防算法**:空值穿透 + TTL 抖动 + 互斥锁 + double-check + 超时降级 → Task 5。✅
- **spec 滑动过期**:`slidingTouch`(只续真实值)→ Task 4(get)+ Task 5(getOrLoad);空值不续 → 单测 + 集成(Task 9)。✅
- **spec §八 自动装配**:`RedisCacheAutoConfiguration` + spring.factories → Task 6。✅
- **spec §九 错误处理**:get 抛 `IllegalStateException`(Task 4)、getOrLoad 内部解码失败降级(Task 5)、续期失败忽略(Task 4/5)。✅
- **spec §十 测试策略**:单测(Task 1-6)+ 集成 RoundTrip/穿透/expire-滑动/击穿(Task 7-10),复用不可达 skip。✅
- **类型一致性**:`build/buildLock``encode/decode/isNullSentinel``applyJitter``getOrLoad` 重载签名在各任务间一致;`Lookup` 三态、`UNLOCK_SCRIPT` 仅在 Task 5 内部。✅
- **无新依赖 / 不改 stream**:仅改 `spring.factories``README.md`。✅
```