Add redis: Boot 4.1 + Spring Data Redis 4.1, the JDK-serialisation default vs Jackson 3 serializers, @RedisHash TTL and keyspace events, @RedisListener, and Redis as the Spring cache
Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01B38FGKKam5SCGgwgduVAh3
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
/**
|
||||
* Companion project for "Redis with Spring Boot 4.1" on ankurm.com.
|
||||
*
|
||||
* <p>Nothing here is configured on purpose: the interesting part is what Boot does
|
||||
* <em>without</em> being asked. See docs/01-what-boot-gives-you.md.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
public class RedisDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RedisDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.redis.cache;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import com.ankurm.redis.model.SerializableUser;
|
||||
import com.ankurm.redis.model.User;
|
||||
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Two cached lookups that differ only in whether the result is Serializable.
|
||||
* For the annotations themselves see the cache article; this class only exists to
|
||||
* put something into a Redis-backed cache.
|
||||
*/
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
@Cacheable("legacy")
|
||||
public SerializableUser findSerializable(long id) {
|
||||
calls.incrementAndGet();
|
||||
return new SerializableUser(id, "Ankur");
|
||||
}
|
||||
|
||||
@Cacheable("users")
|
||||
public User find(long id) {
|
||||
calls.incrementAndGet();
|
||||
return new User(id, "Ankur");
|
||||
}
|
||||
|
||||
public int calls() {
|
||||
return calls.get();
|
||||
}
|
||||
|
||||
public void resetCalls() {
|
||||
calls.set(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.redis.config;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
|
||||
|
||||
/**
|
||||
* Switches the Redis-backed cache from JDK serialisation to JSON. Active under the
|
||||
* {@code json-cache} profile so the default behaviour stays reproducible. See docs/09-redis-as-cache.md.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Profile("json-cache")
|
||||
public class JsonCacheConfig {
|
||||
|
||||
@Bean
|
||||
RedisCacheConfiguration cacheConfiguration() {
|
||||
return RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ofMinutes(5))
|
||||
.serializeValuesWith(SerializationPair.fromSerializer(JsonRedisConfig.allowListSerializer()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.redis.config;
|
||||
|
||||
import com.ankurm.redis.model.User;
|
||||
|
||||
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.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||
|
||||
/**
|
||||
* The "after" picture: readable keys, readable JSON values.
|
||||
*
|
||||
* <p>Two Jackson 3 serializers, two trade-offs. See docs/04-json-serializers.md.
|
||||
* Beans are named so they sit next to Boot's own {@code redisTemplate} and {@code stringRedisTemplate}
|
||||
* instead of replacing them.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class JsonRedisConfig {
|
||||
|
||||
/**
|
||||
* Writes an {@code @class} property so it can read back any type, but only types under
|
||||
* {@code com.ankurm.redis.}. Whoever can write to Redis chooses the class it is read as,
|
||||
* so the allow-list is not optional. See docs/04-json-serializers.md.
|
||||
*/
|
||||
public static GenericJacksonJsonRedisSerializer allowListSerializer() {
|
||||
PolymorphicTypeValidator allowList = BasicPolymorphicTypeValidator.builder()
|
||||
.allowIfSubType("com.ankurm.redis.")
|
||||
.build();
|
||||
return GenericJacksonJsonRedisSerializer.builder()
|
||||
.enableDefaultTyping(allowList)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Any value type, String keys. Values carry an {@code @class} property. */
|
||||
@Bean
|
||||
RedisTemplate<String, Object> jsonRedisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
template.setKeySerializer(StringRedisSerializer.UTF_8);
|
||||
template.setHashKeySerializer(StringRedisSerializer.UTF_8);
|
||||
GenericJacksonJsonRedisSerializer json = allowListSerializer();
|
||||
template.setValueSerializer(json);
|
||||
template.setHashValueSerializer(json);
|
||||
return template;
|
||||
}
|
||||
|
||||
/** One value type, plain JSON with no {@code @class}. Cleaner in redis-cli; one template per type. */
|
||||
@Bean
|
||||
RedisTemplate<String, User> userRedisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, User> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
template.setKeySerializer(StringRedisSerializer.UTF_8);
|
||||
template.setValueSerializer(new JacksonJsonRedisSerializer<>(User.class));
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ankurm.redis.hash;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.redis.core.RedisHash;
|
||||
import org.springframework.data.redis.core.index.Indexed;
|
||||
|
||||
/**
|
||||
* A @RedisHash entity: one Redis hash per instance, at key {@code person:<id>}.
|
||||
* See docs/05-redis-hash.md.
|
||||
*/
|
||||
@RedisHash("person")
|
||||
public class Person {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
private String firstName;
|
||||
|
||||
/** Indexed fields get a Redis set per value, e.g. {@code person:lastName:Mhatre}. */
|
||||
@Indexed
|
||||
private String lastName;
|
||||
|
||||
private int age;
|
||||
|
||||
public Person() {
|
||||
}
|
||||
|
||||
public Person(String id, String firstName, String lastName, int age) {
|
||||
this.id = id;
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public int getAge() { return age; }
|
||||
public void setAge(int age) { this.age = age; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Person[id=" + id + ", " + firstName + " " + lastName + ", age=" + age + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.redis.hash;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
public interface PersonRepository extends CrudRepository<Person, String> {
|
||||
|
||||
/** Answered from the index set {@code person:lastName:<value>}, which exists because lastName is @Indexed. */
|
||||
List<Person> findByLastName(String lastName);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.redis.hash;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.redis.core.RedisHash;
|
||||
import org.springframework.data.redis.core.index.Indexed;
|
||||
|
||||
/**
|
||||
* A @RedisHash whose entries expire after two seconds.
|
||||
* The point of this class is what happens around the expiry. See docs/06-hash-ttl-and-keyspace-events.md.
|
||||
*/
|
||||
@RedisHash(value = "session", timeToLive = 2)
|
||||
public class Session {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Indexed
|
||||
private String user;
|
||||
|
||||
public Session() {
|
||||
}
|
||||
|
||||
public Session(String id, String user) {
|
||||
this.id = id;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getUser() { return user; }
|
||||
public void setUser(String user) { this.user = user; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Session[id=" + id + ", user=" + user + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.redis.hash;
|
||||
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
public interface SessionRepository extends CrudRepository<Session, String> {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.redis.messaging;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import org.springframework.data.redis.annotation.RedisListener;
|
||||
import org.springframework.data.redis.listener.support.PubSubHeaders;
|
||||
import org.springframework.messaging.handler.annotation.Header;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The annotation-driven way to receive Pub/Sub messages. Boot 4.1 auto-configures the listener
|
||||
* container and turns on {@code @EnableRedisListeners}, so this is all it takes.
|
||||
* See docs/07-pub-sub.md.
|
||||
*/
|
||||
@Component
|
||||
public class NewsListener {
|
||||
|
||||
private final List<String> received = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** A plain channel name: subscribes with SUBSCRIBE. The String parameter is the message body. */
|
||||
@RedisListener(topic = "news")
|
||||
void onNews(String body) {
|
||||
received.add("@RedisListener got '" + body + "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* A topic with a glob in it: subscribes with PSUBSCRIBE. The channel the message actually arrived
|
||||
* on is available as a header.
|
||||
*/
|
||||
@RedisListener(topic = "alerts.*")
|
||||
void onAlert(String body, @Header(PubSubHeaders.CHANNEL) String channel) {
|
||||
received.add("@RedisListener got '" + body + "' on " + channel);
|
||||
}
|
||||
|
||||
public List<String> received() {
|
||||
return received;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.redis.messaging;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import org.springframework.data.redis.connection.Message;
|
||||
import org.springframework.data.redis.connection.MessageListener;
|
||||
|
||||
/** Remembers what it was sent, and on which channel. */
|
||||
public class RecordingListener implements MessageListener {
|
||||
|
||||
private final String name;
|
||||
private final List<String> received = new CopyOnWriteArrayList<>();
|
||||
|
||||
public RecordingListener(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] pattern) {
|
||||
String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
|
||||
String body = new String(message.getBody(), StandardCharsets.UTF_8);
|
||||
received.add(name + " got '" + body + "' on " + channel);
|
||||
}
|
||||
|
||||
public List<String> received() {
|
||||
return received;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.redis.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* The same shape as {@link User}, but Serializable, so the JDK serializer accepts it.
|
||||
* What lands in Redis is a Java object stream that embeds this class's name.
|
||||
*/
|
||||
public record SerializableUser(long id, String name) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.redis.model;
|
||||
|
||||
/**
|
||||
* A plain record that is deliberately NOT Serializable.
|
||||
* The default RedisTemplate cannot store it; the Jackson-backed one can.
|
||||
* See docs/02-default-serialization.md.
|
||||
*/
|
||||
public record User(long id, String name) {
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
spring.application.name=redis-demo
|
||||
# Boot 4 uses spring.data.redis.* (not spring.redis.*). Defaults to localhost:6379.
|
||||
# The tests start their own redis-server on 6390 and override this.
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.other;
|
||||
|
||||
/** A class that exists on the classpath but sits outside the {@code com.ankurm.redis.} allow-list. */
|
||||
public record Outsider(String name) {
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.model.SerializableUser;
|
||||
import com.ankurm.redis.model.User;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.NestedExceptionUtils;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.SerializationException;
|
||||
|
||||
/**
|
||||
* What the RedisTemplate you get for free does to your data. Writes docs/output/01-default-template.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class DefaultTemplateTest {
|
||||
|
||||
/** Boot registers this one: RedisTemplate<Object, Object>, bean name "redisTemplate". */
|
||||
@Autowired
|
||||
RedisTemplate<Object, Object> redisTemplate;
|
||||
|
||||
static final String LUA_GET_FIRST_KEY =
|
||||
"return redis.call(\"GET\", redis.call(\"KEYS\", \"*\")[1])";
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDefaultTemplateWritesJavaSerialisedBytes() {
|
||||
try (Transcript t = new Transcript("01-default-template.txt",
|
||||
"The RedisTemplate Boot gives you: JDK serialisation for keys and values")) {
|
||||
|
||||
t.section("Which serializers is it using?");
|
||||
t.line("key serializer: %s", redisTemplate.getKeySerializer().getClass().getSimpleName());
|
||||
t.line("value serializer: %s", redisTemplate.getValueSerializer().getClass().getSimpleName());
|
||||
|
||||
t.section("Write a String under the key user:1");
|
||||
t.line("redisTemplate.opsForValue().set(\"user:1\", \"Ankur\")");
|
||||
redisTemplate.opsForValue().set("user:1", "Ankur");
|
||||
|
||||
t.section("What redis-cli sees");
|
||||
List<String> keys = t.cli("--no-raw", "KEYS", "*");
|
||||
assertThat(keys.get(0)).contains("\\xac\\xed\\x00\\x05t\\x00\\x06user:1");
|
||||
t.cli("--no-raw", "GET", "user:1");
|
||||
List<String> value = t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
|
||||
assertThat(value.get(0)).contains("\\xac\\xed\\x00\\x05t\\x00\\x05Ankur");
|
||||
|
||||
t.section("What Java sees");
|
||||
Object back = redisTemplate.opsForValue().get("user:1");
|
||||
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", back);
|
||||
assertThat(back).isEqualTo("Ankur");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueThatIsNotSerializableIsRejected() {
|
||||
try (Transcript t = new Transcript("02-default-template-not-serializable.txt",
|
||||
"The default template refuses a value that is not java.io.Serializable")) {
|
||||
|
||||
t.section("A record that does not implement Serializable");
|
||||
t.line("redisTemplate.opsForValue().set(\"user:2\", new User(2, \"Ankur\"))");
|
||||
Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(
|
||||
() -> redisTemplate.opsForValue().set("user:2", new User(2, "Ankur")));
|
||||
assertThat(thrown).isInstanceOf(SerializationException.class);
|
||||
t.line("%s", thrown.getClass().getName());
|
||||
t.line(" message: %s", thrown.getMessage());
|
||||
t.line(" cause: %s", thrown.getCause().getMessage());
|
||||
t.line(" root: %s", NestedExceptionUtils.getMostSpecificCause(thrown).getMessage());
|
||||
t.line("keys afterwards: %d", redisTemplate.keys("*").size());
|
||||
assertThat(redisTemplate.keys("*")).isEmpty();
|
||||
|
||||
t.section("The same shape, implementing Serializable");
|
||||
t.line("redisTemplate.opsForValue().set(\"user:3\", new SerializableUser(3, \"Ankur\"))");
|
||||
redisTemplate.opsForValue().set("user:3", new SerializableUser(3, "Ankur"));
|
||||
List<String> value = t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
|
||||
assertThat(value.get(0)).contains("com.ankurm.redis.model.SerializableUser");
|
||||
t.line("read back: %s", redisTemplate.opsForValue().get("user:3"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void incrementSucceedsAndTheNextReadFails() {
|
||||
try (Transcript t = new Transcript("03-default-template-increment.txt",
|
||||
"INCR works with the default template, but the template cannot read the counter it made")) {
|
||||
|
||||
t.section("Increment a counter");
|
||||
Long after = redisTemplate.opsForValue().increment("hits");
|
||||
t.line("redisTemplate.opsForValue().increment(\"hits\") = %d", after);
|
||||
assertThat(after).isEqualTo(1L);
|
||||
|
||||
t.section("What redis-cli sees");
|
||||
t.cli("--no-raw", "KEYS", "*");
|
||||
t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
|
||||
|
||||
t.section("Read it back through the same template");
|
||||
t.line("redisTemplate.opsForValue().get(\"hits\")");
|
||||
Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(
|
||||
() -> redisTemplate.opsForValue().get("hits"));
|
||||
assertThat(thrown).isInstanceOf(SerializationException.class);
|
||||
t.line("%s", thrown.getClass().getName());
|
||||
t.line(" message: %s", thrown.getMessage());
|
||||
Throwable root = NestedExceptionUtils.getMostSpecificCause(thrown);
|
||||
t.line(" root: %s: %s", root.getClass().getName(), root.getMessage());
|
||||
assertThatThrownBy(() -> redisTemplate.opsForValue().get("hits")).isInstanceOf(SerializationException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.hash.Session;
|
||||
import com.ankurm.redis.hash.SessionRepository;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/**
|
||||
* @RedisHash(timeToLive) with Boot's defaults: keyspace events are OFF, so Redis expires the hash
|
||||
* and Spring Data Redis never hears about it. Writes docs/output/09-hash-ttl-events-off.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class HashTtlEventsOffTest {
|
||||
|
||||
@Autowired
|
||||
SessionRepository sessions;
|
||||
|
||||
@BeforeAll
|
||||
static void serverDefaults() {
|
||||
LocalRedis.reset();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theHashExpiresButTheIndexEntriesStay() {
|
||||
try (Transcript t = new Transcript("09-hash-ttl-events-off.txt",
|
||||
"@RedisHash(timeToLive = 2) with Boot's defaults (keyspace events OFF)")) {
|
||||
|
||||
t.section("Server setting");
|
||||
t.cli("--no-raw", "CONFIG", "GET", "notify-keyspace-events");
|
||||
|
||||
t.section("Save a session that lives for two seconds");
|
||||
sessions.save(new Session("s1", "ankur"));
|
||||
t.line("sessions.save(new Session(\"s1\", \"ankur\"))");
|
||||
t.cliSorted("KEYS", "*");
|
||||
List<String> ttl = t.cli("--no-raw", "TTL", "session:s1");
|
||||
assertThat(ttl.get(0)).isEqualTo("(integer) 2");
|
||||
|
||||
t.section("Wait for Redis to expire it");
|
||||
await().atMost(Duration.ofSeconds(10)).until(() -> stringCount("session:s1") == 0);
|
||||
t.cli("--no-raw", "EXISTS", "session:s1");
|
||||
|
||||
t.section("What is left behind");
|
||||
t.cliSorted("KEYS", "*");
|
||||
t.cliSorted("SMEMBERS", "session");
|
||||
t.cliSorted("SMEMBERS", "session:user:ankur");
|
||||
|
||||
t.section("What the repository says");
|
||||
t.line("sessions.findById(\"s1\") = %s", sessions.findById("s1"));
|
||||
t.line("sessions.count() = %d", sessions.count());
|
||||
assertThat(sessions.findById("s1")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private long stringCount(String key) {
|
||||
return Long.parseLong(Transcript.run(java.util.List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "EXISTS", key)).get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import com.ankurm.redis.hash.Session;
|
||||
import com.ankurm.redis.hash.SessionRepository;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
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.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.data.redis.core.RedisKeyExpiredEvent;
|
||||
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
|
||||
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
|
||||
|
||||
/**
|
||||
* The same entity with {@code enableKeyspaceEvents = ON_STARTUP}. Spring Data Redis turns the
|
||||
* server setting on, keeps a phantom copy of the hash for five more minutes, and cleans up the
|
||||
* index entries when Redis announces the expiry. Writes docs/output/10-hash-ttl-events-on.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class HashTtlEventsOnTest {
|
||||
|
||||
static List<String> before;
|
||||
|
||||
@Autowired
|
||||
SessionRepository sessions;
|
||||
|
||||
@Autowired
|
||||
ExpiryLog expiryLog;
|
||||
|
||||
@TestConfiguration
|
||||
@EnableRedisRepositories(basePackageClasses = SessionRepository.class, enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
|
||||
static class Config {
|
||||
|
||||
@Bean
|
||||
ExpiryLog expiryLog() {
|
||||
return new ExpiryLog();
|
||||
}
|
||||
}
|
||||
|
||||
static class ExpiryLog {
|
||||
|
||||
final List<String> events = new CopyOnWriteArrayList<>();
|
||||
|
||||
@EventListener
|
||||
void on(RedisKeyExpiredEvent<?> event) {
|
||||
events.add("keyspace=" + event.getKeyspace() + " id=" + new String(event.getId()) + " value=" + event.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void serverDefaults() {
|
||||
// runs before the Spring context is created, so this is the setting Spring Data Redis finds
|
||||
LocalRedis.reset();
|
||||
before = Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "--no-raw", "CONFIG", "GET", "notify-keyspace-events"));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServerDefaults() {
|
||||
LocalRedis.reset();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void springCleansUpWhenRedisAnnouncesTheExpiry() {
|
||||
try (Transcript t = new Transcript("10-hash-ttl-events-on.txt",
|
||||
"@RedisHash(timeToLive = 2) with enableKeyspaceEvents = ON_STARTUP")) {
|
||||
|
||||
t.section("Server setting before the application context started");
|
||||
t.line("$ redis-cli -p %d --no-raw CONFIG GET notify-keyspace-events", LocalRedis.PORT);
|
||||
before.forEach(l -> t.line("%s", l));
|
||||
|
||||
t.section("Server setting now");
|
||||
t.cli("--no-raw", "CONFIG", "GET", "notify-keyspace-events");
|
||||
|
||||
t.section("Save a session that lives for two seconds");
|
||||
sessions.save(new Session("s1", "ankur"));
|
||||
t.line("sessions.save(new Session(\"s1\", \"ankur\"))");
|
||||
t.cliSorted("KEYS", "*");
|
||||
List<String> ttl = t.cli("--no-raw", "TTL", "session:s1");
|
||||
assertThat(ttl.get(0)).isEqualTo("(integer) 2");
|
||||
List<String> phantom = t.cli("--no-raw", "TTL", "session:s1:phantom");
|
||||
assertThat(phantom.get(0)).isEqualTo("(integer) 302");
|
||||
|
||||
t.section("Wait for Redis to expire it, and for Spring to react");
|
||||
await().atMost(Duration.ofSeconds(10)).until(() -> !expiryLog.events.isEmpty());
|
||||
await().atMost(Duration.ofSeconds(5)).until(() ->
|
||||
Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "DBSIZE")).get(0).equals("0"));
|
||||
expiryLog.events.forEach(e -> t.line("RedisKeyExpiredEvent: %s", e));
|
||||
|
||||
t.section("What is left behind");
|
||||
t.cli("--no-raw", "DBSIZE");
|
||||
t.line("sessions.count() = %d", sessions.count());
|
||||
assertThat(sessions.count()).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
import com.ankurm.redis.model.User;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.SerializationException;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* The "after" picture: String keys and Jackson 3 values. Writes docs/output/06-jackson-serializers.txt
|
||||
* and 07-jackson-untrusted-class.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class JacksonSerializerTest {
|
||||
|
||||
@Autowired
|
||||
@Qualifier("userRedisTemplate")
|
||||
RedisTemplate<String, User> userTemplate;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("jsonRedisTemplate")
|
||||
RedisTemplate<String, Object> jsonTemplate;
|
||||
|
||||
@Autowired
|
||||
RedisConnectionFactory factory;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void typedAndGenericSerializers() {
|
||||
try (Transcript t = new Transcript("06-jackson-serializers.txt",
|
||||
"Jackson 3 serializers: JacksonJsonRedisSerializer and GenericJacksonJsonRedisSerializer")) {
|
||||
|
||||
t.section("JacksonJsonRedisSerializer<User>: one type, plain JSON");
|
||||
t.line("value serializer: %s", userTemplate.getValueSerializer().getClass().getName());
|
||||
userTemplate.opsForValue().set("user:1", new User(1, "Ankur"));
|
||||
t.line("userTemplate.opsForValue().set(\"user:1\", new User(1, \"Ankur\"))");
|
||||
t.cli("KEYS", "*");
|
||||
t.cli("GET", "user:1");
|
||||
User typed = userTemplate.opsForValue().get("user:1");
|
||||
t.line("read back: %s (%s)", typed, typed.getClass().getSimpleName());
|
||||
assertThat(typed).isEqualTo(new User(1, "Ankur"));
|
||||
|
||||
t.section("GenericJacksonJsonRedisSerializer with an allow-list: any type, plus @class");
|
||||
t.line("value serializer: %s", jsonTemplate.getValueSerializer().getClass().getName());
|
||||
jsonTemplate.opsForValue().set("user:2", new User(2, "Ankur"));
|
||||
t.line("jsonTemplate.opsForValue().set(\"user:2\", new User(2, \"Ankur\"))");
|
||||
t.cli("GET", "user:2");
|
||||
Object generic = jsonTemplate.opsForValue().get("user:2");
|
||||
t.line("read back: %s (%s)", generic, generic.getClass().getSimpleName());
|
||||
assertThat(generic).isEqualTo(new User(2, "Ankur"));
|
||||
|
||||
t.section("GenericJacksonJsonRedisSerializer with no typing configured");
|
||||
RedisTemplate<String, Object> plain = new RedisTemplate<>();
|
||||
plain.setConnectionFactory(factory);
|
||||
plain.setKeySerializer(StringRedisSerializer.UTF_8);
|
||||
plain.setValueSerializer(GenericJacksonJsonRedisSerializer.builder().build());
|
||||
plain.afterPropertiesSet();
|
||||
plain.opsForValue().set("user:3", new User(3, "Ankur"));
|
||||
t.line("plain.opsForValue().set(\"user:3\", new User(3, \"Ankur\"))");
|
||||
t.cli("GET", "user:3");
|
||||
Object untyped = plain.opsForValue().get("user:3");
|
||||
t.line("read back: %s (%s)", untyped, untyped.getClass().getSimpleName());
|
||||
|
||||
t.section("Two different shapes of the same JSON");
|
||||
t.cli("GET", "user:1");
|
||||
t.cli("GET", "user:2");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void whoeverWritesToRedisChoosesTheClass() {
|
||||
try (Transcript t = new Transcript("07-jackson-untrusted-class.txt",
|
||||
"The @class property is input: the allow-list decides what it may name")) {
|
||||
|
||||
t.section("A class outside the allow-list");
|
||||
t.cli("SET", "user:evil", "{\"@class\":\"com.ankurm.other.Outsider\",\"name\":\"x\"}");
|
||||
t.line("jsonTemplate.opsForValue().get(\"user:evil\")");
|
||||
Throwable denied = catchThrowable(() -> jsonTemplate.opsForValue().get("user:evil"));
|
||||
assertThat(denied).isInstanceOf(SerializationException.class);
|
||||
t.line("%s", denied.getClass().getName());
|
||||
t.line(" message: %s", firstLine(denied.getMessage()));
|
||||
|
||||
t.section("A class inside the allow-list that no longer exists");
|
||||
t.cli("SET", "user:gone", "{\"@class\":\"com.ankurm.redis.model.Gone\",\"id\":9}");
|
||||
t.line("jsonTemplate.opsForValue().get(\"user:gone\")");
|
||||
Throwable gone = catchThrowable(() -> jsonTemplate.opsForValue().get("user:gone"));
|
||||
assertThat(gone).isInstanceOf(SerializationException.class);
|
||||
t.line("%s", gone.getClass().getName());
|
||||
t.line(" message: %s", firstLine(gone.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstLine(String message) {
|
||||
return message == null ? "null" : message.lines().findFirst().orElse("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
|
||||
/**
|
||||
* Starts a throwaway {@code redis-server} on port 6390 for the whole test JVM and stops it at the end.
|
||||
*
|
||||
* <p>It refuses to reuse a server that is already listening, because the tests call FLUSHALL. If the
|
||||
* port is busy, stop whatever owns it. Needs {@code redis-server} and {@code redis-cli} on the PATH
|
||||
* (or run one in Docker and change nothing: publish it on 6390 and the extension will complain, which
|
||||
* is the point).
|
||||
*/
|
||||
public class LocalRedis implements BeforeAllCallback {
|
||||
|
||||
public static final int PORT = 6390;
|
||||
private static Process server;
|
||||
|
||||
@Override
|
||||
public synchronized void beforeAll(ExtensionContext context) throws Exception {
|
||||
if (server != null) {
|
||||
return;
|
||||
}
|
||||
if (listening()) {
|
||||
throw new IllegalStateException("port " + PORT + " is already in use; the tests FLUSHALL, so they start their own server");
|
||||
}
|
||||
server = new ProcessBuilder("redis-server", "--port", String.valueOf(PORT),
|
||||
"--save", "", "--appendonly", "no", "--loglevel", "warning")
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.start();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
try {
|
||||
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "shutdown", "nosave"));
|
||||
} finally {
|
||||
server.destroy();
|
||||
}
|
||||
}));
|
||||
for (int i = 0; i < 100 && !listening(); i++) {
|
||||
Thread.sleep(50);
|
||||
}
|
||||
if (!listening()) {
|
||||
throw new IllegalStateException("redis-server did not start on " + PORT);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean listening() {
|
||||
try (Socket s = new Socket()) {
|
||||
s.connect(new InetSocketAddress("127.0.0.1", PORT), 200);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts server settings a test might change back to their defaults. */
|
||||
public static void reset() {
|
||||
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "config", "set", "notify-keyspace-events", ""));
|
||||
}
|
||||
|
||||
/** Wipes the test server so every transcript starts from an empty keyspace. */
|
||||
public static void flush() {
|
||||
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "flushall"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.messaging.NewsListener;
|
||||
import com.ankurm.redis.messaging.RecordingListener;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.listener.ChannelTopic;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
|
||||
/**
|
||||
* Pub/Sub with Boot 4.1's auto-configured listener container. Writes docs/output/11-pub-sub.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class PubSubTest {
|
||||
|
||||
@Autowired
|
||||
ApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
RedisMessageListenerContainer container;
|
||||
|
||||
@Autowired
|
||||
StringRedisTemplate template;
|
||||
|
||||
@Autowired
|
||||
NewsListener news;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
news.received().clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishAndSubscribe() {
|
||||
try (Transcript t = new Transcript("11-pub-sub.txt", "Pub/Sub: what is delivered, to whom, and what is lost")) {
|
||||
|
||||
t.section("The container Boot created");
|
||||
t.line("beans of type RedisMessageListenerContainer: %s",
|
||||
Arrays.toString(context.getBeanNamesForType(RedisMessageListenerContainer.class)));
|
||||
t.line("running=%s listening=%s", container.isRunning(), container.isListening());
|
||||
awaitSubscribers("news", 1);
|
||||
|
||||
t.section("Publishing to a channel nobody listens to");
|
||||
Long none = template.convertAndSend("quiet", "hello?");
|
||||
t.line("template.convertAndSend(\"quiet\", \"hello?\") = %d receivers", none);
|
||||
assertThat(none).isZero();
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "quiet");
|
||||
|
||||
t.section("Publishing to news, which has an @RedisListener");
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
|
||||
Long one = template.convertAndSend("news", "first");
|
||||
t.line("template.convertAndSend(\"news\", \"first\") = %d receiver", one);
|
||||
assertThat(one).isEqualTo(1L);
|
||||
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 1);
|
||||
news.received().forEach(t::line);
|
||||
|
||||
t.section("A second listener that subscribes late");
|
||||
RecordingListener late = new RecordingListener("late listener");
|
||||
container.addMessageListener(late, new ChannelTopic("news"));
|
||||
t.line("container.addMessageListener(late, new ChannelTopic(\"news\"))");
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
|
||||
Long two = template.convertAndSend("news", "second");
|
||||
t.line("template.convertAndSend(\"news\", \"second\") = %d receiver", two);
|
||||
assertThat(two).isEqualTo(1L);
|
||||
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 2 && late.received().size() == 1);
|
||||
news.received().forEach(t::line);
|
||||
late.received().forEach(t::line);
|
||||
container.removeMessageListener(late);
|
||||
|
||||
t.section("A pattern subscription (topic = \"alerts.*\")");
|
||||
t.cli("--no-raw", "PUBSUB", "NUMPAT");
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "alerts.disk");
|
||||
Long alert = template.convertAndSend("alerts.disk", "90% full");
|
||||
t.line("template.convertAndSend(\"alerts.disk\", \"90%% full\") = %d receiver", alert);
|
||||
assertThat(alert).isEqualTo(1L);
|
||||
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 3);
|
||||
t.line("%s", news.received().get(2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMessageIsNotKeptForAListenerThatIsNotThere() {
|
||||
try (Transcript t = new Transcript("12-pub-sub-lost-message.txt", "Pub/Sub is at-most-once: a stopped listener loses messages")) {
|
||||
|
||||
awaitSubscribers("news", 1);
|
||||
t.section("Stop the listener container, then publish");
|
||||
container.stop();
|
||||
t.line("container.stop()");
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
|
||||
Long lost = template.convertAndSend("news", "while you were away");
|
||||
t.line("template.convertAndSend(\"news\", \"while you were away\") = %d receivers", lost);
|
||||
assertThat(lost).isZero();
|
||||
|
||||
t.section("Start it again");
|
||||
container.start();
|
||||
t.line("container.start()");
|
||||
awaitSubscribers("news", 1);
|
||||
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
|
||||
t.line("messages the listener has: %s", news.received());
|
||||
assertThat(news.received()).isEmpty();
|
||||
|
||||
t.section("Once it is back, messages flow again");
|
||||
Long back = template.convertAndSend("news", "welcome back");
|
||||
t.line("template.convertAndSend(\"news\", \"welcome back\") = %d receiver", back);
|
||||
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 1);
|
||||
news.received().forEach(t::line);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitSubscribers(String channel, int expected) {
|
||||
await().atMost(Duration.ofSeconds(10)).until(() -> {
|
||||
List<String> out = Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "PUBSUB", "NUMSUB", channel));
|
||||
return out.size() == 2 && out.get(1).equals(String.valueOf(expected));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.cache.UserService;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.core.NestedExceptionUtils;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
/**
|
||||
* Redis as the Spring cache, with Boot's defaults. Writes docs/output/14-cache-default.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
@TestPropertySource(properties = "spring.cache.redis.time-to-live=10m")
|
||||
class RedisCacheDefaultTest {
|
||||
|
||||
@Autowired
|
||||
CacheManager cacheManager;
|
||||
|
||||
@Autowired
|
||||
UserService users;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
users.resetCalls();
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsUseJdkSerialisationForValues() {
|
||||
try (Transcript t = new Transcript("14-cache-default.txt",
|
||||
"Redis as the Spring cache: what the defaults store")) {
|
||||
|
||||
t.section("Which cache manager did Boot pick?");
|
||||
t.line("%s", cacheManager.getClass().getName());
|
||||
|
||||
t.section("A Serializable result, cached");
|
||||
users.findSerializable(1);
|
||||
users.findSerializable(1);
|
||||
t.line("findSerializable(1) called twice, method body ran %d time(s)", users.calls());
|
||||
assertThat(users.calls()).isEqualTo(1);
|
||||
t.cliSorted("KEYS", "*");
|
||||
List<String> ttl = t.cli("--no-raw", "TTL", "legacy::1");
|
||||
assertThat(ttl.get(0)).isEqualTo("(integer) 600");
|
||||
t.cli("--no-raw", "GET", "legacy::1");
|
||||
|
||||
t.section("A result that is not Serializable");
|
||||
t.line("users.find(1)");
|
||||
Throwable thrown = catchThrowable(() -> users.find(1));
|
||||
assertThat(thrown).isNotNull();
|
||||
t.line("%s", thrown.getClass().getName());
|
||||
t.line(" message: %s", thrown.getMessage());
|
||||
Throwable root = NestedExceptionUtils.getMostSpecificCause(thrown);
|
||||
t.line(" root: %s", root.getMessage());
|
||||
t.line("method body ran %d time(s) in total", users.calls());
|
||||
t.cliSorted("KEYS", "*");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.cache.UserService;
|
||||
import com.ankurm.redis.model.User;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
/**
|
||||
* The same cache with a RedisCacheConfiguration bean that writes JSON. Writes docs/output/15-cache-json.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
@ActiveProfiles("json-cache")
|
||||
@TestPropertySource(properties = "spring.cache.redis.time-to-live=10m")
|
||||
class RedisCacheJsonTest {
|
||||
|
||||
@Autowired
|
||||
CacheManager cacheManager;
|
||||
|
||||
@Autowired
|
||||
UserService users;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
users.resetCalls();
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonValuesAreReadableAndTypeless() {
|
||||
try (Transcript t = new Transcript("15-cache-json.txt",
|
||||
"Redis as the Spring cache with a Jackson 3 value serializer")) {
|
||||
|
||||
t.section("The result that was not Serializable now caches");
|
||||
User first = users.find(1);
|
||||
User second = users.find(1);
|
||||
t.line("find(1) called twice, method body ran %d time(s)", users.calls());
|
||||
t.line("second call returned: %s", second);
|
||||
assertThat(users.calls()).isEqualTo(1);
|
||||
assertThat(second).isEqualTo(first);
|
||||
|
||||
t.section("What is in Redis");
|
||||
t.cliSorted("KEYS", "*");
|
||||
t.cli("GET", "users::1");
|
||||
List<String> ttl = t.cli("--no-raw", "TTL", "users::1");
|
||||
t.line("spring.cache.redis.time-to-live=10m is set, and the bean says 5 minutes");
|
||||
assertThat(ttl.get(0)).isEqualTo("(integer) 300");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.redis.hash.Person;
|
||||
import com.ankurm.redis.hash.PersonRepository;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/**
|
||||
* What a @RedisHash repository actually writes. Writes docs/output/08-redis-hash.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class RedisHashTest {
|
||||
|
||||
@Autowired
|
||||
PersonRepository people;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void repositoryWritesAHashASetAndIndexSets() {
|
||||
try (Transcript t = new Transcript("08-redis-hash.txt",
|
||||
"@RedisHash and CrudRepository: the keys Spring Data Redis creates")) {
|
||||
|
||||
t.section("Save two people");
|
||||
people.save(new Person("1", "Ankur", "Mhatre", 40));
|
||||
people.save(new Person("2", "Asha", "Mhatre", 38));
|
||||
t.line("people.save(new Person(\"1\", \"Ankur\", \"Mhatre\", 40))");
|
||||
t.line("people.save(new Person(\"2\", \"Asha\", \"Mhatre\", 38))");
|
||||
|
||||
t.section("Every key it created");
|
||||
List<String> keys = t.cliSorted("KEYS", "*");
|
||||
assertThat(keys).containsExactly("person", "person:1", "person:1:idx", "person:2", "person:2:idx", "person:lastName:Mhatre");
|
||||
|
||||
t.section("The entity is a hash");
|
||||
t.cli("--no-raw", "TYPE", "person:1");
|
||||
t.cli("--no-raw", "HGETALL", "person:1");
|
||||
|
||||
t.section("The keyspace is a set of ids");
|
||||
t.cliSorted("SMEMBERS", "person");
|
||||
|
||||
t.section("The @Indexed field is a set per value");
|
||||
t.cliSorted("SMEMBERS", "person:lastName:Mhatre");
|
||||
|
||||
t.section("And each entity remembers which index sets it is in");
|
||||
t.cliSorted("SMEMBERS", "person:1:idx");
|
||||
|
||||
t.section("What the repository returns");
|
||||
t.line("people.findById(\"1\") = %s", people.findById("1").orElseThrow());
|
||||
t.line("people.count() = %d", people.count());
|
||||
List<Person> found = people.findByLastName("Mhatre");
|
||||
t.line("people.findByLastName(\"Mhatre\") = %d results", found.size());
|
||||
assertThat(found).hasSize(2);
|
||||
|
||||
t.section("Delete one, and look again");
|
||||
people.deleteById("1");
|
||||
t.line("people.deleteById(\"1\")");
|
||||
t.cliSorted("KEYS", "*");
|
||||
t.cliSorted("SMEMBERS", "person:lastName:Mhatre");
|
||||
t.cliSorted("SMEMBERS", "person");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/** A Spring Boot test wired to the throwaway redis-server that {@link LocalRedis} starts. */
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@ExtendWith(LocalRedis.class)
|
||||
@SpringBootTest(properties = {"spring.data.redis.port=" + LocalRedis.PORT, "spring.main.banner-mode=off"})
|
||||
public @interface RedisTest {
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
/**
|
||||
* StringRedisTemplate, and why two templates over one Redis do not share keys.
|
||||
* Writes docs/output/04-string-template.txt and 05-two-templates-two-keyspaces.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class StringTemplateTest {
|
||||
|
||||
@Autowired
|
||||
StringRedisTemplate stringTemplate;
|
||||
|
||||
@Autowired
|
||||
RedisTemplate<Object, Object> redisTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringTemplateWritesWhatRedisCliCanRead() {
|
||||
try (Transcript t = new Transcript("04-string-template.txt",
|
||||
"StringRedisTemplate: every key and value is UTF-8 text")) {
|
||||
|
||||
t.section("Which serializers is it using?");
|
||||
t.line("key serializer: %s", stringTemplate.getKeySerializer().getClass().getSimpleName());
|
||||
t.line("value serializer: %s", stringTemplate.getValueSerializer().getClass().getSimpleName());
|
||||
t.line("hash key serializer: %s", stringTemplate.getHashKeySerializer().getClass().getSimpleName());
|
||||
t.line("hash value serializer: %s", stringTemplate.getHashValueSerializer().getClass().getSimpleName());
|
||||
|
||||
t.section("A string value");
|
||||
stringTemplate.opsForValue().set("user:1", "Ankur");
|
||||
t.line("stringTemplate.opsForValue().set(\"user:1\", \"Ankur\")");
|
||||
t.cli("--no-raw", "GET", "user:1");
|
||||
|
||||
t.section("A counter");
|
||||
stringTemplate.opsForValue().increment("hits");
|
||||
stringTemplate.opsForValue().increment("hits");
|
||||
t.line("stringTemplate.opsForValue().increment(\"hits\") twice = %s", stringTemplate.opsForValue().get("hits"));
|
||||
t.cli("--no-raw", "GET", "hits");
|
||||
assertThat(stringTemplate.opsForValue().get("hits")).isEqualTo("2");
|
||||
|
||||
t.section("A hash");
|
||||
stringTemplate.opsForHash().put("user:1:profile", "city", "Pune");
|
||||
stringTemplate.opsForHash().put("user:1:profile", "editor", "vim");
|
||||
t.line("stringTemplate.opsForHash().put(\"user:1:profile\", \"city\", \"Pune\") and (\"editor\", \"vim\")");
|
||||
t.cli("--no-raw", "HGETALL", "user:1:profile");
|
||||
|
||||
t.section("A list, a set and a sorted set");
|
||||
stringTemplate.opsForList().rightPushAll("queue", "a", "b", "c");
|
||||
stringTemplate.opsForSet().add("tags", "java", "spring");
|
||||
stringTemplate.opsForZSet().add("scores", "ankur", 42);
|
||||
t.cli("--no-raw", "LRANGE", "queue", "0", "-1");
|
||||
t.cliSorted("SMEMBERS", "tags");
|
||||
t.cli("--no-raw", "ZRANGE", "scores", "0", "-1", "WITHSCORES");
|
||||
t.cliSorted("KEYS", "*");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoTemplatesOverOneRedisDoNotSeeEachOthersKeys() {
|
||||
try (Transcript t = new Transcript("05-two-templates-two-keyspaces.txt",
|
||||
"Same key name, two templates, two different keys")) {
|
||||
|
||||
t.section("Write user:1 with StringRedisTemplate");
|
||||
stringTemplate.opsForValue().set("user:1", "from-string-template");
|
||||
t.line("stringTemplate.opsForValue().get(\"user:1\") = %s", stringTemplate.opsForValue().get("user:1"));
|
||||
Object viaDefault = redisTemplate.opsForValue().get("user:1");
|
||||
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", viaDefault);
|
||||
assertThat(viaDefault).isNull();
|
||||
|
||||
t.section("Write user:1 with the default RedisTemplate");
|
||||
redisTemplate.opsForValue().set("user:1", "from-default-template");
|
||||
t.line("stringTemplate.opsForValue().get(\"user:1\") = %s", stringTemplate.opsForValue().get("user:1"));
|
||||
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", redisTemplate.opsForValue().get("user:1"));
|
||||
assertThat(stringTemplate.opsForValue().get("user:1")).isEqualTo("from-string-template");
|
||||
|
||||
t.section("What Redis holds");
|
||||
List<String> size = t.cli("--no-raw", "DBSIZE");
|
||||
assertThat(size.get(0)).isEqualTo("(integer) 2");
|
||||
t.cli("--no-raw", "GET", "user:1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code docs/output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*
|
||||
* <p>{@link #cli} runs the real {@code redis-cli} against the test server and records both the
|
||||
* command and what it printed, so the article's "what redis-cli shows" blocks are not typed by hand.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("docs", "output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Runs {@code redis-cli -p <port> <args>} and records the command and its output. Returns the output lines. */
|
||||
public List<String> cli(String... args) {
|
||||
return cli(false, args);
|
||||
}
|
||||
|
||||
/** Like {@link #cli} but sorts the output lines, printing {@code | sort} after the command (KEYS order is arbitrary). */
|
||||
public List<String> cliSorted(String... args) {
|
||||
return cli(true, args);
|
||||
}
|
||||
|
||||
private List<String> cli(boolean sort, String... args) {
|
||||
List<String> command = new ArrayList<>(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT)));
|
||||
command.addAll(List.of(args));
|
||||
out.println("$ " + shown(command) + (sort ? " | sort" : ""));
|
||||
List<String> lines = run(command);
|
||||
if (sort) {
|
||||
Collections.sort(lines);
|
||||
}
|
||||
lines.forEach(out::println);
|
||||
return lines;
|
||||
}
|
||||
|
||||
static List<String> run(List<String> command) {
|
||||
try {
|
||||
Process p = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
String text = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (!p.waitFor(10, TimeUnit.SECONDS)) {
|
||||
p.destroyForcibly();
|
||||
throw new IllegalStateException("timed out: " + command);
|
||||
}
|
||||
List<String> lines = new ArrayList<>(List.of(text.split("\n", -1)));
|
||||
while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
|
||||
lines.remove(lines.size() - 1);
|
||||
}
|
||||
return lines;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new IllegalStateException("could not run " + command, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String shown(List<String> command) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String s : command) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
boolean quote = s.isEmpty() || s.chars().anyMatch(c -> " *'\"{}[]$;|".indexOf(c) >= 0);
|
||||
sb.append(quote ? "'" + s.replace("'", "'\\''") + "'" : s);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.redis;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.types.Expiration;
|
||||
|
||||
/**
|
||||
* Time-to-live from the template's side, checked with redis-cli from the server's. Writes docs/output/13-ttl.txt.
|
||||
*/
|
||||
@RedisTest
|
||||
class TtlTest {
|
||||
|
||||
@Autowired
|
||||
StringRedisTemplate template;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
LocalRedis.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ttlSemantics() {
|
||||
try (Transcript t = new Transcript("13-ttl.txt", "TTL: set, read, lose, keep and clear it")) {
|
||||
|
||||
t.section("No TTL, and a key that does not exist");
|
||||
template.opsForValue().set("plain", "v");
|
||||
t.line("set(\"plain\", \"v\")");
|
||||
t.line("getExpire(\"plain\") = %d", template.getExpire("plain"));
|
||||
t.line("getExpire(\"missing\") = %d", template.getExpire("missing"));
|
||||
assertThat(template.getExpire("plain")).isEqualTo(-1L);
|
||||
assertThat(template.getExpire("missing")).isEqualTo(-2L);
|
||||
|
||||
t.section("Set a value with a Duration");
|
||||
template.opsForValue().set("session", "v1", Duration.ofMinutes(10));
|
||||
t.line("set(\"session\", \"v1\", Duration.ofMinutes(10))");
|
||||
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
|
||||
t.line("getExpire(\"session\", TimeUnit.MINUTES) = %d", template.getExpire("session", TimeUnit.MINUTES));
|
||||
List<String> ttl = t.cli("--no-raw", "TTL", "session");
|
||||
assertThat(ttl.get(0)).isEqualTo("(integer) 600");
|
||||
|
||||
t.section("A plain set() afterwards throws the TTL away");
|
||||
template.opsForValue().set("session", "v2");
|
||||
t.line("set(\"session\", \"v2\")");
|
||||
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
|
||||
t.cli("--no-raw", "TTL", "session");
|
||||
assertThat(template.getExpire("session")).isEqualTo(-1L);
|
||||
|
||||
t.section("Put a TTL back, then overwrite while keeping it");
|
||||
template.expire("session", Duration.ofMinutes(10));
|
||||
t.line("expire(\"session\", Duration.ofMinutes(10))");
|
||||
template.opsForValue().set("session", "v3", Expiration.keepTtl());
|
||||
t.line("set(\"session\", \"v3\", Expiration.keepTtl())");
|
||||
t.line("getExpire(\"session\") = %d, value = %s", template.getExpire("session"), template.opsForValue().get("session"));
|
||||
t.cli("--no-raw", "TTL", "session");
|
||||
assertThat(template.getExpire("session")).isEqualTo(600L);
|
||||
|
||||
t.section("Sub-second precision");
|
||||
template.opsForValue().set("short", "v", Duration.ofMillis(1500));
|
||||
t.line("set(\"short\", \"v\", Duration.ofMillis(1500))");
|
||||
t.line("getExpire(\"short\", TimeUnit.MILLISECONDS) <= 1500: %s", template.getExpire("short", TimeUnit.MILLISECONDS) <= 1500);
|
||||
t.line("getExpire(\"short\") in seconds = %d", template.getExpire("short"));
|
||||
|
||||
t.section("persist() removes a TTL");
|
||||
template.persist("session");
|
||||
t.line("persist(\"session\")");
|
||||
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
|
||||
assertThat(template.getExpire("session")).isEqualTo(-1L);
|
||||
|
||||
t.section("Expiry actually happens");
|
||||
template.opsForValue().set("blink", "v", Duration.ofMillis(300));
|
||||
t.line("set(\"blink\", \"v\", Duration.ofMillis(300))");
|
||||
t.line("get(\"blink\") straight away = %s", template.opsForValue().get("blink"));
|
||||
await().atMost(Duration.ofSeconds(5)).until(() -> template.opsForValue().get("blink") == null);
|
||||
t.line("get(\"blink\") later = %s", template.opsForValue().get("blink"));
|
||||
t.cli("--no-raw", "EXISTS", "blink");
|
||||
|
||||
t.section("setIfAbsent with a TTL is the smallest lock");
|
||||
Boolean first = template.opsForValue().setIfAbsent("lock:job", "worker-1", Duration.ofSeconds(30));
|
||||
Boolean second = template.opsForValue().setIfAbsent("lock:job", "worker-2", Duration.ofSeconds(30));
|
||||
t.line("setIfAbsent(\"lock:job\", \"worker-1\", 30s) = %s", first);
|
||||
t.line("setIfAbsent(\"lock:job\", \"worker-2\", 30s) = %s", second);
|
||||
t.cli("--no-raw", "GET", "lock:job");
|
||||
t.cli("--no-raw", "TTL", "lock:job");
|
||||
assertThat(first).isTrue();
|
||||
assertThat(second).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user