新增本地缓存基础设施
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>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</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