新增本地缓存基础设施

This commit is contained in:
wkc
2026-03-28 10:45:08 +08:00
parent ed77eaea84
commit bc2582246b
6 changed files with 224 additions and 1 deletions

View 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` 通过

View File

@@ -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>

View 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;
}
}

View 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)
{
}

View 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);
}
}

View 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());
}
}