Redis Repository – Spring Boot

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\"}]}}"
Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *