Files
redis-stream-springboot-sta…/docs/superpowers/plans/2026-06-25-redis-cache.md
root 40882ecfaa docs: 新增 Redis 缓存增强模板实现计划
11 个 TDD 任务:常量/KeyBuilder → Codec → Properties → 模板读写 →
getOrLoad 三防 → 自动装配 → 4 个集成测试(RoundTrip/穿透/expire-滑动/击穿并发)
→ README + 全量回归。含完整代码、精确命令与对照 spec 的自检。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:01:19 +08:00

68 KiB
Raw Blame 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.factoriesREADME.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/):CacheKeyBuilderTestCacheValueCodecTestRedisCachePropertiesTestRedisCacheEnhanceTemplateTestautoconfig/RedisCacheAutoConfigurationTestintegration/{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

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

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

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: 提交
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

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

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: 提交
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=falseString env=""
    • long defaultTtlSeconds=3600long nullTtlSeconds=60double jitterRatio=0.1
    • boolean slidingExpireEnabled=false
    • Breakdown breakdown,内嵌:boolean enabled=truelong lockTtlMs=10000long waitTimeoutMs=3000long waitIntervalMs=50
    • 注意 boolean 字段的 getter 形如 isEnvIsolation() / isSlidingExpireEnabled() / breakdown.isEnabled()
  • Step 1: 写失败测试

src/test/java/com/njcn/middle/cache/autoconfig/RedisCachePropertiesTest.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

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: 提交
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

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.openMocks(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

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: 提交
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 的全部私有/包级成员(applyJitterslidingTouchcodeckeyBuilderpropsredis)、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 区):

// 追加 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;
    @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:

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 之后)追加以下成员;并在类顶部字段区加入脚本常量:

    /** 释放锁 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);
    // ---- 读取或回源(三防核心)----

    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: 提交
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: RedisCachePropertiesCacheKeyBuilderCacheValueCodecRedisCacheEnhanceTemplate

  • Produces: 三个 @ConditionalOnMissingBean Bean(CacheKeyBuilderCacheValueCodecRedisCacheEnhanceTemplate),并经 spring.factories 自动加载。

  • Step 1: 写失败测试

src/test/java/com/njcn/middle/cache/autoconfig/RedisCacheAutoConfigurationTest.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

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: 提交
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

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

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: 提交
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: RedisCacheEnhanceTemplateRedisAvailableCondition(Task 7)

  • Step 1: 写测试

src/test/java/com/njcn/middle/cache/integration/CachePenetrationTest.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: 提交
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: RedisCacheEnhanceTemplateCacheKeyBuilder(用于算实际 key 读 TTL)、StringRedisTemplateRedisAvailableCondition

  • 本类配置 redis-cache.sliding-expire-enabled=true

  • Step 1: 写测试

src/test/java/com/njcn/middle/cache/integration/CacheExpireSlidingTest.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: 提交
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: RedisCacheEnhanceTemplateRedisAvailableCondition;默认击穿锁配置(enabled=true)。

  • Step 1: 写测试

src/test/java/com/njcn/middle/cache/integration/CacheBreakdownConcurrencyTest.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: 提交
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 末尾追加:


---

## 十、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,均可选)

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/buildLockencode/decode/isNullSentinelapplyJittergetOrLoad 重载签名在各任务间一致;Lookup 三态、UNLOCK_SCRIPT 仅在 Task 5 内部。
  • 无新依赖 / 不改 stream:仅改 spring.factoriesREADME.md