新增本地缓存基础设施
This commit is contained in:
18
doc/implementation-report-2026-03-28-remove-redis-backend.md
Normal file
18
doc/implementation-report-2026-03-28-remove-redis-backend.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 后端移除 Redis 实施记录
|
||||||
|
|
||||||
|
## 实施时间
|
||||||
|
- 2026-03-28
|
||||||
|
|
||||||
|
## 修改内容
|
||||||
|
- 为 `ruoyi-common` 增加 `spring-boot-starter-test` 测试依赖
|
||||||
|
- 新增 `InMemoryCacheStoreTest` 作为本地缓存基础设施的失败测试基线
|
||||||
|
- 新增 `InMemoryCacheEntry`、`InMemoryCacheStats`、`InMemoryCacheStore` 实现本地缓存基础能力
|
||||||
|
- 按实施计划开始执行后端移除 Redis 改造
|
||||||
|
|
||||||
|
## 文档路径
|
||||||
|
- `doc/2026-03-28-remove-redis-backend-plan.md`
|
||||||
|
- `doc/implementation-report-2026-03-28-remove-redis-backend.md`
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
- 已完成 Task 1 的测试基线与本地缓存基础设施
|
||||||
|
- 已验证 `mvn -pl ruoyi-common -am -Dtest=InMemoryCacheStoreTest test` 通过
|
||||||
@@ -131,6 +131,12 @@
|
|||||||
<version>3.5.10</version>
|
<version>3.5.10</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
9
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java
vendored
Normal file
9
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheEntry.java
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ruoyi.common.core.cache;
|
||||||
|
|
||||||
|
record InMemoryCacheEntry(Object value, Long expireAtMillis)
|
||||||
|
{
|
||||||
|
boolean isExpired(long now)
|
||||||
|
{
|
||||||
|
return expireAtMillis != null && expireAtMillis <= now;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java
vendored
Normal file
12
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStats.java
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.ruoyi.common.core.cache;
|
||||||
|
|
||||||
|
public record InMemoryCacheStats(
|
||||||
|
String cacheType,
|
||||||
|
String mode,
|
||||||
|
long keySize,
|
||||||
|
long hitCount,
|
||||||
|
long missCount,
|
||||||
|
long expiredCount,
|
||||||
|
long writeCount)
|
||||||
|
{
|
||||||
|
}
|
||||||
125
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java
vendored
Normal file
125
ruoyi-common/src/main/java/com/ruoyi/common/core/cache/InMemoryCacheStore.java
vendored
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package com.ruoyi.common.core.cache;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
public class InMemoryCacheStore
|
||||||
|
{
|
||||||
|
private final ConcurrentHashMap<String, InMemoryCacheEntry> entries = new ConcurrentHashMap<>();
|
||||||
|
private final AtomicLong hitCount = new AtomicLong();
|
||||||
|
private final AtomicLong missCount = new AtomicLong();
|
||||||
|
private final AtomicLong expiredCount = new AtomicLong();
|
||||||
|
private final AtomicLong writeCount = new AtomicLong();
|
||||||
|
|
||||||
|
public void set(String key, Object value)
|
||||||
|
{
|
||||||
|
putEntry(key, new InMemoryCacheEntry(value, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(String key, Object value, long timeout, TimeUnit unit)
|
||||||
|
{
|
||||||
|
long expireAtMillis = System.currentTimeMillis() + unit.toMillis(timeout);
|
||||||
|
putEntry(key, new InMemoryCacheEntry(value, expireAtMillis));
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T get(String key)
|
||||||
|
{
|
||||||
|
InMemoryCacheEntry entry = readEntry(key);
|
||||||
|
return entry == null ? null : (T) entry.value();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasKey(String key)
|
||||||
|
{
|
||||||
|
return readEntry(key) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean delete(String key)
|
||||||
|
{
|
||||||
|
return entries.remove(key) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> keys(String pattern)
|
||||||
|
{
|
||||||
|
purgeExpiredEntries();
|
||||||
|
Set<String> matchedKeys = new TreeSet<>();
|
||||||
|
entries.forEach((key, value) -> {
|
||||||
|
if (matches(pattern, key))
|
||||||
|
{
|
||||||
|
matchedKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return matchedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InMemoryCacheStats snapshot()
|
||||||
|
{
|
||||||
|
purgeExpiredEntries();
|
||||||
|
return new InMemoryCacheStats(
|
||||||
|
"IN_MEMORY",
|
||||||
|
"single-instance",
|
||||||
|
entries.size(),
|
||||||
|
hitCount.get(),
|
||||||
|
missCount.get(),
|
||||||
|
expiredCount.get(),
|
||||||
|
writeCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putEntry(String key, InMemoryCacheEntry entry)
|
||||||
|
{
|
||||||
|
entries.put(key, entry);
|
||||||
|
writeCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private InMemoryCacheEntry readEntry(String key)
|
||||||
|
{
|
||||||
|
InMemoryCacheEntry entry = entries.get(key);
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
missCount.incrementAndGet();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry.isExpired(System.currentTimeMillis()))
|
||||||
|
{
|
||||||
|
removeExpiredEntry(key, entry);
|
||||||
|
missCount.incrementAndGet();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
hitCount.incrementAndGet();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void purgeExpiredEntries()
|
||||||
|
{
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
entries.forEach((key, entry) -> {
|
||||||
|
if (entry.isExpired(now))
|
||||||
|
{
|
||||||
|
removeExpiredEntry(key, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeExpiredEntry(String key, InMemoryCacheEntry expectedEntry)
|
||||||
|
{
|
||||||
|
if (entries.remove(key, expectedEntry))
|
||||||
|
{
|
||||||
|
expiredCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matches(String pattern, String key)
|
||||||
|
{
|
||||||
|
if ("*".equals(pattern))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pattern.endsWith("*"))
|
||||||
|
{
|
||||||
|
return key.startsWith(pattern.substring(0, pattern.length() - 1));
|
||||||
|
}
|
||||||
|
return key.equals(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java
vendored
Normal file
53
ruoyi-common/src/test/java/com/ruoyi/common/core/cache/InMemoryCacheStoreTest.java
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package com.ruoyi.common.core.cache;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class InMemoryCacheStoreTest
|
||||||
|
{
|
||||||
|
@Test
|
||||||
|
void shouldExpireEntryAfterTtl() throws Exception
|
||||||
|
{
|
||||||
|
InMemoryCacheStore store = new InMemoryCacheStore();
|
||||||
|
store.set("captcha_codes:1", "1234", 20, TimeUnit.MILLISECONDS);
|
||||||
|
Thread.sleep(40);
|
||||||
|
assertNull(store.get("captcha_codes:1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnPrefixKeysInSortedOrder()
|
||||||
|
{
|
||||||
|
InMemoryCacheStore store = new InMemoryCacheStore();
|
||||||
|
store.set("login_tokens:a", "A");
|
||||||
|
store.set("login_tokens:b", "B");
|
||||||
|
store.set("sys_dict:x", "X");
|
||||||
|
assertEquals(Set.of("login_tokens:a", "login_tokens:b"), store.keys("login_tokens:*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldTrackHitsMissesWritesAndExpiredEntries() throws Exception
|
||||||
|
{
|
||||||
|
InMemoryCacheStore store = new InMemoryCacheStore();
|
||||||
|
store.set("captcha_codes:2", "5678", 20, TimeUnit.MILLISECONDS);
|
||||||
|
assertTrue(store.hasKey("captcha_codes:2"));
|
||||||
|
assertEquals("5678", store.get("captcha_codes:2"));
|
||||||
|
Thread.sleep(40);
|
||||||
|
assertNull(store.get("captcha_codes:2"));
|
||||||
|
assertFalse(store.hasKey("captcha_codes:2"));
|
||||||
|
|
||||||
|
InMemoryCacheStats stats = store.snapshot();
|
||||||
|
assertEquals("IN_MEMORY", stats.cacheType());
|
||||||
|
assertEquals("single-instance", stats.mode());
|
||||||
|
assertEquals(0, stats.keySize());
|
||||||
|
assertEquals(2, stats.hitCount());
|
||||||
|
assertEquals(2, stats.missCount());
|
||||||
|
assertEquals(1, stats.expiredCount());
|
||||||
|
assertEquals(1, stats.writeCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user