diff --git a/build.gradle b/build.gradle index 479b44b74e..34f1fd4c24 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ ext { huaweiObsVersion = '3.21.8.1' templateInheritanceVersion = "0.4.RELEASE" jsoupVersion = '1.14.3' + embeddedRedisVersion = '0.6' diffUtilsVersion = '4.11' } @@ -82,7 +83,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-jetty" implementation "org.springframework.boot:spring-boot-starter-freemarker" - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation "org.springframework.boot:spring-boot-starter-validation" + implementation "org.springframework.boot:spring-boot-starter-data-redis" implementation "com.sun.mail:jakarta.mail" implementation "com.google.guava:guava:$guavaVersion" @@ -139,6 +141,10 @@ dependencies { } testImplementation "org.jsoup:jsoup:$jsoupVersion" + testImplementation ("com.github.kstyrc:embedded-redis:$embeddedRedisVersion") { + exclude group: 'org.slf4j', module: 'slf4j-simple' + } + developmentOnly "org.springframework.boot:spring-boot-devtools" } diff --git a/src/main/java/run/halo/app/cache/RedisCacheStore.java b/src/main/java/run/halo/app/cache/RedisCacheStore.java new file mode 100644 index 0000000000..988d9c86f0 --- /dev/null +++ b/src/main/java/run/halo/app/cache/RedisCacheStore.java @@ -0,0 +1,106 @@ +package run.halo.app.cache; + +import java.util.LinkedHashMap; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import javax.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * redis cache store. + * + * @author luoxx + */ +@Slf4j +public class RedisCacheStore extends AbstractStringCacheStore { + + private static final String REDIS_PREFIX = "halo.redis."; + + private final StringRedisTemplate redisTemplate; + + public RedisCacheStore(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + @NonNull + Optional> getInternal(@NonNull String key) { + Assert.hasText(key, "Cache key must not be blank"); + String value = redisTemplate.opsForValue().get(REDIS_PREFIX + key); + CacheWrapper cacheStore = new CacheWrapper<>(); + cacheStore.setData(value); + return Optional.of(cacheStore); + } + + @Override + void putInternal(@NonNull String key, @NonNull CacheWrapper cacheWrapper) { + Assert.hasText(key, "Cache key must not be blank"); + Assert.notNull(cacheWrapper, "Cache wrapper must not be null"); + if (cacheWrapper.getExpireAt() != null) { + long expire = cacheWrapper.getExpireAt().getTime() - System.currentTimeMillis(); + redisTemplate.opsForValue().set( + REDIS_PREFIX + key, cacheWrapper.getData(), expire, TimeUnit.MILLISECONDS); + } else { + redisTemplate.opsForValue().set(REDIS_PREFIX + key, cacheWrapper.getData()); + } + + log.debug("Put [{}] cache : [{}]", key, cacheWrapper); + } + + @Override + Boolean putInternalIfAbsent(@NonNull String key, @NonNull CacheWrapper cacheWrapper) { + Assert.hasText(key, "Cache key must not be blank"); + Assert.notNull(cacheWrapper, "Cache wrapper must not be null"); + + log.debug("Preparing to put key: [{}], value: [{}]", key, cacheWrapper); + + if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + log.warn("Failed to put the cache, the key: [{}] has been present already", key); + return false; + } + + putInternal(key, cacheWrapper); + log.debug("Put successfully"); + return true; + + } + + @Override + public Optional get(String key) { + Assert.notNull(key, "Cache key must not be blank"); + + return getInternal(key).map(CacheWrapper::getData); + } + + @Override + public void delete(@NonNull String key) { + Assert.hasText(key, "Cache key must not be blank"); + + if (Boolean.TRUE.equals(redisTemplate.hasKey(REDIS_PREFIX + key))) { + redisTemplate.delete(REDIS_PREFIX + key); + log.debug("Removed key: [{}]", key); + } + } + + @Override + public LinkedHashMap toMap() { + LinkedHashMap map = new LinkedHashMap<>(); + Set keys = redisTemplate.keys(REDIS_PREFIX + "*"); + if (CollectionUtils.isEmpty(keys)) { + return map; + } + keys.forEach(key -> map.put(key, redisTemplate.opsForValue().get(key))); + return map; + } + + @PreDestroy + public void preDestroy() { + //do nothing + } + +} diff --git a/src/main/java/run/halo/app/config/HaloConfiguration.java b/src/main/java/run/halo/app/config/HaloConfiguration.java index ae8a50cc6e..def9dfaff8 100644 --- a/src/main/java/run/halo/app/config/HaloConfiguration.java +++ b/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -8,10 +8,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.scheduling.annotation.EnableAsync; @@ -20,6 +22,7 @@ import run.halo.app.cache.AbstractStringCacheStore; import run.halo.app.cache.InMemoryCacheStore; import run.halo.app.cache.LevelCacheStore; +import run.halo.app.cache.RedisCacheStore; import run.halo.app.config.attributeconverter.AttributeConverterAutoGenerateConfiguration; import run.halo.app.config.properties.HaloProperties; import run.halo.app.repository.base.BaseRepositoryImpl; @@ -32,6 +35,7 @@ */ @Slf4j @EnableAsync +@EnableCaching @EnableScheduling @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(HaloProperties.class) @@ -42,8 +46,12 @@ public class HaloConfiguration { private final HaloProperties haloProperties; - public HaloConfiguration(HaloProperties haloProperties) { + private final StringRedisTemplate stringRedisTemplate; + + public HaloConfiguration(HaloProperties haloProperties, + StringRedisTemplate stringRedisTemplate) { this.haloProperties = haloProperties; + this.stringRedisTemplate = stringRedisTemplate; } @Bean @@ -70,14 +78,15 @@ AbstractStringCacheStore stringCacheStore() { case "level": stringCacheStore = new LevelCacheStore(this.haloProperties); break; + case "redis": + stringCacheStore = new RedisCacheStore(stringRedisTemplate); + break; case "memory": default: - //memory or default stringCacheStore = new InMemoryCacheStore(); break; } log.info("Halo cache store load impl : [{}]", stringCacheStore.getClass()); return stringCacheStore; - } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index d9f3c86f82..a3e566fe76 100755 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -46,6 +46,10 @@ spring: max-request-size: 10240MB cache: type: none + redis: + port: 6379 + database: 0 + host: 127.0.0.1 management: endpoints: web: diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e89c81126b..242fa9f464 100755 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -42,12 +42,19 @@ spring: - file:///${halo.work-dir}/templates/ - classpath:/templates/ expose-spring-macro-helpers: false + data: + redis: + repositories: + enabled: false management: endpoints: web: base-path: /api/admin/actuator exposure: include: [ 'httptrace', 'metrics', 'env', 'logfile', 'health' ] + health: + redis: + enabled: false logging: level: run.halo.app: INFO diff --git a/src/test/java/run/halo/app/cache/RedisCacheStoreTest.java b/src/test/java/run/halo/app/cache/RedisCacheStoreTest.java new file mode 100644 index 0000000000..0da546294e --- /dev/null +++ b/src/test/java/run/halo/app/cache/RedisCacheStoreTest.java @@ -0,0 +1,161 @@ +package run.halo.app.cache; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +import java.util.LinkedHashMap; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import redis.embedded.RedisServer; + +/** + * RedisCacheStoreTest. + * + * @author luoxx + * @author guqing + * @date 3/16/22 + */ +@Slf4j +@SpringBootTest +class RedisCacheStoreTest { + + @Autowired + private StringRedisTemplate redisTemplate; + + RedisCacheStore cacheStore; + + RedisServer redisServer; + + @BeforeEach + void startRedis() { + redisServer = RedisServer.builder() + .port(6379) + .build(); + redisServer.start(); + cacheStore = new RedisCacheStore(redisTemplate); + clearAllCache(); + } + + + @Test + void putNullValueTest() { + String key = "test_key"; + + assertThrows(IllegalArgumentException.class, () -> cacheStore.put(key, null)); + } + + @Test + void putNullKeyTest() { + String value = "test_value"; + + assertThrows(IllegalArgumentException.class, () -> cacheStore.put(null, value)); + } + + @Test + void getByNullKeyTest() { + assertThrows(IllegalArgumentException.class, () -> cacheStore.get(null)); + } + + @Test + void getNullTest() { + String key = "test_key"; + + Optional valueOptional = cacheStore.get(key); + + assertFalse(valueOptional.isPresent()); + } + + @Test + void expirationTest() throws InterruptedException { + String key = "test_key"; + String value = "test_value"; + cacheStore.put(key, value, 500, TimeUnit.MILLISECONDS); + + Optional valueOptional = cacheStore.get(key); + + assertTrue(valueOptional.isPresent()); + assertEquals(value, valueOptional.get()); + + TimeUnit.SECONDS.sleep(1L); + + valueOptional = cacheStore.get(key); + + assertFalse(valueOptional.isPresent()); + } + + @Test + void deleteTest() { + String key = "test_key"; + String value = "test_value"; + + // Put the cache + cacheStore.put(key, value); + + // Get the caceh + Optional valueOptional = cacheStore.get(key); + + // Assert + assertTrue(valueOptional.isPresent()); + assertEquals(value, valueOptional.get()); + + // Delete the cache + cacheStore.delete(key); + + // Get the cache again + valueOptional = cacheStore.get(key); + + // Assertion + assertFalse(valueOptional.isPresent()); + } + + @Test + void toMapTest() { + String key1 = "test_key_1"; + String value1 = "test_value_1"; + + // Put the cache + cacheStore.put(key1, value1); + LinkedHashMap map = cacheStore.toMap(); + assertThat(map).isNotNull(); + assertThat(map.size()).isEqualTo(1); + assertThat(map.get("halo.redis.test_key_1")).isEqualTo("test_value_1"); + + String key2 = "test_key_2"; + String value2 = "test_value_2"; + + // Put the cache + cacheStore.put(key2, value2); + + map = cacheStore.toMap(); + assertThat(map).isNotNull(); + assertThat(map.size()).isEqualTo(2); + assertThat(map.get("halo.redis.test_key_1")).isEqualTo("test_value_1"); + assertThat(map.get("halo.redis.test_key_1")).isEqualTo("test_value_1"); + } + + public void clearAllCache() { + Set keys = redisTemplate.keys("*"); + if (keys == null) { + return; + } + log.debug("Clear all cache."); + redisTemplate.delete(keys); + } + + @AfterEach + void stopRedis() { + redisServer.stop(); + } + +}