Redis with Spring-data repository allows to use complex model to persist/load out of the box while we don’t use complex object as map keys or map values that contain collection.
Common structures for our examples
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.davidxxx</groupId> <artifactId>spring-boot-redis-repository-example</artifactId> <version>1.0.0-SNAPSHOT</version> <name>${project.artifactId}</name> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.1</version> <relativePath></relativePath> </parent> <properties> <maven.compiler.release>11</maven.compiler.release> <java.version>11</java.version> </properties> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> </configuration> </plugin> </plugins> </build> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>it.ozimov</groupId> <artifactId>embedded-redis</artifactId> <version>0.7.3</version> <scope>test</scope> </dependency> </dependencies> </project> |
Abstract test class
We defined an abstract base test class but a Junit 5 extension would be still better !
package davidxxx.repository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.connection.RedisConnectionFactory; import redis.embedded.RedisServer; import javax.annotation.PostConstruct; public abstract class AbstractRedisRepositoryTest { private RedisServer redisServer; @Autowired RedisConnectionFactory redisConnectionFactory; @Autowired private @Value("${spring.redis.port}") int port; @PostConstruct void init() { this.redisServer = new RedisServer(port); } @BeforeEach public void beforeTestMethod() { redisServer.start(); redisConnectionFactory.getConnection().flushAll(); } @AfterEach public void afterTestMethod() { redisServer.stop(); } } |
Example with a basic model
Model
package davidxxx.friendly; import org.springframework.data.annotation.PersistenceConstructor; import org.springframework.data.redis.core.RedisHash; // value attr is optional. It allows to specific the prefix key (alternative to the config way seen above) // timeToLive attr is optional. Expressed in seconds. @RedisHash(value = "people", timeToLive = 86400) // 1 days public class Person { @org.springframework.data.annotation.Id private Long id; private String firstname; private String lastname; // Functional constructor public Person(String firstname, String lastname) { this.firstname = firstname; this.lastname = lastname; } // Technical constructor // For redis persistence (Person and MailDirectory storage) @PersistenceConstructor public Person(Long id, String firstname, String lastname) { this.id = id; this.firstname = firstname; this.lastname = lastname; } public Long getId() { return id; } public String getFirstname() { return firstname; } public String getLastname() { return lastname; } @Override public String toString() { return "Person{" + "id=" + id + ", firstname='" + firstname + '\'' + ", lastname='" + lastname + '\'' + '}'; } } |
Repository
package davidxxx.friendly; imporat org.springframework.data.repository.CrudRepository; public interface PersonRepository extends CrudRepository<Person, Long> { } |
Test
package davidxxx.friendly; import davidxxx.RedisRepositoryExampleApplication; import davidxxx.repository.AbstractRedisRepositoryTest; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; import java.util.Optional; import static org.assertj.core.groups.Tuple.tuple; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = { RedisRepositoryExampleApplication.class, // RedisRepositoryExampleApplication.class should not be required but without that, Spring Boot contexts init fails with : java.lang.IllegalStateException: Unable to retrieve @EnableAutoConfiguration base packages }) @DataRedisTest(properties = { "spring.redis.host=localhost", "spring.redis.port=8850" }) class PersonRepositoryTest extends AbstractRedisRepositoryTest { @Autowired PersonRepository personRepository; @Autowired RedisConnectionFactory redisConnectionFactory; @Test void save_and_findBy() { //WHEN Person saved = personRepository.save(new Person( "david", "doe")); Optional<Person> optPerson = personRepository.findById(saved.getId()); //THEN Assertions.assertThat(optPerson).isNotEmpty(); Person actualPerson = optPerson.get(); Assertions.assertThat(actualPerson).returns("david", Person::getFirstname) .returns("doe", Person::getLastname); } @Test void saveAll_and_findAll() { //WHEN personRepository.saveAll(List.of(new Person( "david", "doe"), new Person( "jane", "doe"))); Iterable<Person> actualPersons = personRepository.findAll(); //THEN Assertions.assertThat(actualPersons).hasSize(2).extracting(Person::getFirstname, Person::getLastname) .containsExactlyInAnyOrder(tuple("david", "doe"), tuple("jane", "doe")); } } |
Storage under the hood
127.0.0.1:8850> hgetall people:-4873479186349762867 1) "_class" 2) "davidxxx.friendly.Person" 3) "id" 4) "-4873479186349762867" 5) "firstname" 6) "david" 7) "lastname" 8) "doe" |
Example with a complex model Spring-Boot Redis friendly
We don’t define a Map that contains as value a List of Person that would cause serialization issues, instead of we defined a Map that have a complex object as values (Contacts).
Model Class
package davidxxx.friendly; import java.util.List; public class Contacts { private List<Person> persons; // Both Technical constructor(for redis Storage) and Functional constructor public Contacts(List<Person> persons) { this.persons = persons; } public List<Person> getPersons() { return persons; } @Override public String toString() { return "Contacts{" + "persons=" + persons + '}'; } } |
package davidxxx.friendly; import org.springframework.data.annotation.PersistenceConstructor; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; import java.util.Map; import java.util.concurrent.TimeUnit; @RedisHash(value = "mailDirectory") public class MailDirectory { @TimeToLive(unit = TimeUnit.MINUTES) private long expiration() { return 1; } @org.springframework.data.annotation.Id private Long id; private Map<String, Contacts> contactsByGroup; // Functional constructor public MailDirectory(Map<String, Contacts> contactsByGroup) { this.contactsByGroup = contactsByGroup; } // Technical constructor // For redis persistence @PersistenceConstructor public MailDirectory(Long id, Map<String, Contacts> contactsByGroup) { this.id = id; this.contactsByGroup = contactsByGroup; } public Long getId() { return id; } public Map<String, Contacts> getContactsByGroup() { return contactsByGroup; } @Override public String toString() { return "MailDirectory{" + "id=" + id + ", contactsByGroup=" + contactsByGroup + '}'; } } |
Repository Class
package davidxxx.repository; import davidxxx.model.MailDirectory; import org.springframework.data.repository.CrudRepository; public interface MailDirectoryRepository extends CrudRepository<MailDirectory, Long> { } |
Test Class
package davidxxx.friendly; import davidxxx.RedisRepositoryExampleApplication; import davidxxx.repository.AbstractRedisRepositoryTest; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; import java.util.Map; import static java.util.Map.entry; import static org.assertj.core.groups.Tuple.tuple; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = { RedisRepositoryExampleApplication.class // RedisRepositoryExampleApplication.class should not be required but without that, Spring Boot contexts init fails with : java.lang.IllegalStateException: Unable to retrieve @EnableAutoConfiguration base packages }) @DataRedisTest(properties = { "spring.redis.host=localhost", "spring.redis.port=8850" }) class MailDirectoryRepositoryTest extends AbstractRedisRepositoryTest { @Autowired MailDirectoryRepository mailDirectoryRepository; @Test void save_and_findBy() { var contactsFamily = new Contacts( List.of(new Person(10L, "johnny", "deep"), new Person(15L, "melanie", "grifoun"))); var contactsFriends = new Contacts( List.of(new Person(20L, "harisson", "fard"), new Person(30L, "bruce", "loo"))); //WHEN MailDirectory saved = mailDirectoryRepository .save(new MailDirectory(Map.ofEntries(entry("family", contactsFamily), entry("friends", contactsFriends)))); var optDirectory = mailDirectoryRepository.findById(saved.getId()); //THEN Assertions.assertThat(optDirectory).isNotEmpty(); Map<String, Contacts> contactsByGroup = optDirectory.get().getContactsByGroup(); Assertions.assertThat(contactsByGroup).hasSize(2); Assertions.assertThat(contactsByGroup.get("family").getPersons()) .extracting(Person::getId, Person::getFirstname, Person::getLastname) .containsExactlyInAnyOrder(tuple(10L, "johnny", "deep"), tuple(15L, "melanie", "grifoun") ); Assertions.assertThat(contactsByGroup.get("friends").getPersons()) .extracting(Person::getId, Person::getFirstname, Person::getLastname) .containsExactlyInAnyOrder(tuple(20L, "harisson", "fard"), tuple(30L, "bruce", "loo") ); } } |
Under the hood : how data are persisted by Spring Boot repository
127.0.0.1:8850> hgetall mailDirectory:3732897434514724375 1) "_class" 2) "davidxxx.friendly.MailDirectory" 3) "id" 4) "3732897434514724375" 5) "contactsByGroup.[family].persons.[0].id" 6) "10" 7) "contactsByGroup.[family].persons.[0].firstname" 8) "johnny" 9) "contactsByGroup.[family].persons.[0].lastname" 10) "deep" 11) "contactsByGroup.[family].persons.[1].id" 12) "15" 13) "contactsByGroup.[family].persons.[1].firstname" 14) "melanie" 15) "contactsByGroup.[family].persons.[1].lastname" 16) "grifoun" 17) "contactsByGroup.[friends].persons.[0].id" 18) "20" 19) "contactsByGroup.[friends].persons.[0].firstname" 20) "harisson" 21) "contactsByGroup.[friends].persons.[0].lastname" 22) "fard" 23) "contactsByGroup.[friends].persons.[1].id" 24) "30" 25) "contactsByGroup.[friends].persons.[1].firstname" 26) "bruce" 27) "contactsByGroup.[friends].persons.[1].lastname" 28) "loo" |
Example with a complex model Spring-Boot Redis unfriendly
Here instead of defining a Map that contains as value complex objects, we defined a List of Person as value. That would cause serialization inconsistencies and so deserialzation inconsistencies (shit in, shit out).
To overcome that, we will persist the objects into JSON and so we need to define a reading and a writing converter from Java to JSON and reversely.
Model Class
package davidxxx.unfriendly; import com.fasterxml.jackson.annotation.JsonCreator; import org.springframework.data.redis.core.RedisHash; // value attr is optional. It allows to specific the prefix key (alternative to the config way seen above) // timeToLive attr is optional. Expressed in seconds. @RedisHash(value = "people", timeToLive = 86400) // 1 days public class Person { @org.springframework.data.annotation.Id private Long id; private String firstname; private String lastname; // Functional constructor public Person(String firstname, String lastname) { this.firstname = firstname; this.lastname = lastname; } // Technical constructor // For Json serialization (because ComplexMailDirectory is configured to be stored in in Json in Redis config) @JsonCreator public Person(Long id, String firstname, String lastname) { this.id = id; this.firstname = firstname; this.lastname = lastname; } public Long getId() { return id; } public String getFirstname() { return firstname; } public String getLastname() { return lastname; } @Override public String toString() { return "Person{" + "id=" + id + ", firstname='" + firstname + '\'' + ", lastname='" + lastname + '\'' + '}'; } } |
package davidxxx.unfriendly; import org.springframework.data.redis.core.RedisHash; import org.springframework.data.redis.core.TimeToLive; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @RedisHash(value = "ComplexMailDirectory") public class ComplexMailDirectory { @TimeToLive(unit = TimeUnit.MINUTES) private long expiration(){ return 1; } @org.springframework.data.annotation.Id private Long id; private Map<String, List<Person>> contactsByGroup; public ComplexMailDirectory(Map<String, List<Person>> contactsByGroup) { this.contactsByGroup = contactsByGroup; } // For Jackson public ComplexMailDirectory(Long id, Map<String, List<Person>> contactsByGroup) { this.id = id; this.contactsByGroup = contactsByGroup; } public Long getId() { return id; } public Map<String, List<Person>> getContactsByGroup() { return contactsByGroup; } @Override public String toString() { return "ComplexMailDirectory{" + "id=" + id + ", contactsByGroup=" + contactsByGroup + '}'; } } |
Repository Class
package davidxxx.unfriendly; import org.springframework.data.repository.CrudRepository; public interface ComplexMailDirectoryRepository extends CrudRepository<ComplexMailDirectory, Long> { } |
Redis configuration and custom writer and reader
package davidxxx.unfriendly.converter; import com.fasterxml.jackson.databind.ObjectMapper; import davidxxx.unfriendly.ComplexMailDirectory; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.stereotype.Component; @Component @ReadingConverter public class RedisByteToComplexMailDirectoryConverter implements Converter<byte[], ComplexMailDirectory> { private ObjectMapper objectMapper; Jackson2JsonRedisSerializer<ComplexMailDirectory> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer( ComplexMailDirectory.class); public RedisByteToComplexMailDirectoryConverter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; jackson2JsonRedisSerializer.setObjectMapper(objectMapper); } @Override public ComplexMailDirectory convert(byte[] source) { return jackson2JsonRedisSerializer.deserialize(source); } } |
package davidxxx.unfriendly.converter; import com.fasterxml.jackson.databind.ObjectMapper; import davidxxx.unfriendly.ComplexMailDirectory; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.stereotype.Component; @Component @WritingConverter public class RedisComplexMailDirectoryToBytesConverter implements Converter<ComplexMailDirectory, byte[]> { private ObjectMapper objectMapper; Jackson2JsonRedisSerializer<ComplexMailDirectory> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer( ComplexMailDirectory.class); public RedisComplexMailDirectoryToBytesConverter(ObjectMapper objectMapper) { this.objectMapper = objectMapper; jackson2JsonRedisSerializer.setObjectMapper(objectMapper); } @Override public byte[] convert(ComplexMailDirectory value) { return jackson2JsonRedisSerializer.serialize(value); } } |
package davidxxx; import com.fasterxml.jackson.databind.ObjectMapper; import davidxxx.unfriendly.Person; import davidxxx.unfriendly.converter.RedisByteToComplexMailDirectoryConverter; import davidxxx.unfriendly.converter.RedisComplexMailDirectoryToBytesConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.convert.KeyspaceConfiguration; import org.springframework.data.redis.core.convert.RedisCustomConversions; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import java.util.Collections; import java.util.List; @EnableRedisRepositories @Configuration public class RedisConfiguration { // Optional : allow to define the key prefix for classes annotated with @RedisHash (instead of the simple class name as default) public static class MyKeyspaceConfiguration extends KeyspaceConfiguration { @Override protected Iterable<KeyspaceSettings> initialConfiguration() { return Collections.singleton(new KeyspaceSettings(Person.class, "people")); } } @Bean RedisCustomConversions redisCustomConversions(ObjectMapper objectMapper) { return new RedisCustomConversions(List.of(new RedisByteToComplexMailDirectoryConverter(objectMapper), new RedisComplexMailDirectoryToBytesConverter(objectMapper))); } } |
Test Class
package davidxxx.unfriendly; import davidxxx.RedisConfiguration; import davidxxx.RedisRepositoryExampleApplication; import davidxxx.repository.AbstractRedisRepositoryTest; import davidxxx.unfriendly.converter.RedisByteToComplexMailDirectoryConverter; import davidxxx.unfriendly.converter.RedisComplexMailDirectoryToBytesConverter; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.util.List; import java.util.Map; import static java.util.Map.entry; import static org.assertj.core.groups.Tuple.tuple; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = { RedisRepositoryExampleApplication.class, RedisConfiguration.class, JacksonAutoConfiguration.class, // RedisRepositoryExampleApplication.class should not be required but without that, Spring Boot contexts init fails with : java.lang.IllegalStateException: Unable to retrieve @EnableAutoConfiguration base packages RedisByteToComplexMailDirectoryConverter.class, RedisComplexMailDirectoryToBytesConverter.class }) @DataRedisTest(properties = { "spring.redis.host=localhost", "spring.redis.port=8850" }) class ComplexMailDirectoryRepositoryTest extends AbstractRedisRepositoryTest { @Autowired ComplexMailDirectoryRepository complexMailDirectoryRepository; @Test void save_and_findBy() { var familyList = List.of(new Person(10L, "johnny", "deep"), new Person(15L, "melanie", "grifoun")); var friendsList = List.of(new Person(20L, "harisson", "fard"), new Person(30L, "bruce", "loo")); //WHEN ComplexMailDirectory saved = complexMailDirectoryRepository .save(new ComplexMailDirectory(Map.ofEntries(entry("family", familyList), entry("friends", friendsList)))); System.out.println(saved); var optDirectory = complexMailDirectoryRepository.findById(saved.getId()); //THEN Assertions.assertThat(optDirectory).isNotEmpty(); Map<String, List<Person>> personListByGroup = optDirectory.get().getContactsByGroup(); Assertions.assertThat(personListByGroup).hasSize(2); Assertions.assertThat(personListByGroup.get("family")) .extracting(Person::getId, Person::getFirstname, Person::getLastname) .containsExactlyInAnyOrder(tuple(10L, "johnny", "deep"), tuple(15L, "melanie", "grifoun") ); Assertions.assertThat(personListByGroup.get("friends")) .extracting(Person::getId, Person::getFirstname, Person::getLastname) .containsExactlyInAnyOrder(tuple(20L, "harisson", "fard"), tuple(30L, "bruce", "loo") ); } } |
Under the hood : how data are persisted by Spring Boot repository
127.0.0.1:8850> hgetall ComplexMailDirectory:7522639433661337001 1) "_raw" 2) "{\"id\":7522639433661337001,\"contactsByGroup\":{\"family\":[{\"id\":10,\"firstname\":\"johnny\",\"lastname\":\"deep\"},{\"id\":15,\"firstname\":\"melanie\",\"lastname\":\"grifoun\"}],\"friends\":[{\"id\":20,\"firstname\":\"harisson\",\"lastname\":\"fard\"},{\"id\":30,\"firstname\":\"bruce\",\"lastname\":\"loo\"}]}}" |