精通Spring Boot 3 : 6. 使用 Spring Boot 的 Spring Data NoSQL (3)

Spring 数据 Redis

Spring Data Redis 是一种 NoSQL 内存数据结构存储,支持键值对、列表、集合、排序集合、位图、超日志等多种数据类型。由于其快速的特性,Redis 还可以用于发布/订阅消息。它具备可扩展性和高可用性等多种附加功能,广泛应用于会话管理、实时分析、任务队列、缓存、聊天应用和排行榜等场景。

Spring Data Redis 提供了丰富的低级和高级抽象,方便 Spring 应用程序与 Redis 进行交互。以下是它的一些主要功能:

  • Spring 编程模型。
  • RedisTemplate 是一个基于模板实现的类,提供了高层次的抽象,便于执行各种 Redis 操作、异常处理和序列化。同时,它还提供了 StringRedisTemplate,支持以字符串为主的操作。
  • 在多个 Redis 驱动(如 Lettuce 和 Jedis)之间轻松连接。
  • 将异常翻译为 Spring 的可移植数据访问异常体系。
  • 发布/订阅支持(例如用于消息驱动的普通 Java 对象的消息监听容器)。
  • 支持 Redis 集群和 Redis Sentinel。
  • Lettuce 驱动程序的响应式 API 支持。
  • JDK、字符串、JSON 以及 Spring 对象/XML 映射的序列化器。
  • 基于 Redis 的 JDK 集合实现方式。
  • Redis 实现的 Spring 3.1 缓存抽象。
  • 包含对使用@EnableRedisRepositories 的自定义查询方法支持的仓库接口。
  • 支持流媒体。

基于 Spring Boot 的 Spring Data Redis 使用指南

使用 Spring Data Redis 和 Spring Boot,您可以自动配置 Lettuce 和 Jedis 客户端,并享受 Spring Data Redis 提供的所有抽象功能。如果您希望在 Spring Boot 应用中使用 Spring Data Redis,您需要添加 spring-boot-starter-data-redis 启动依赖。如果您只打算使用 Spring Data Redis,而不使用 Spring Boot,并且使用的是仓库编程模型,则需要在 JavaConfig 类中添加 @EnableRedisRepositories 注解;当然,如果您使用的是 Spring Boot,则不需要这个注解,因为 Spring Boot 会自动处理配置。

让我们来看看在我们的项目中需要做些什么,以便从用户应用程序开始使用 Redis 进行数据持久化。

基于 Spring Boot 的用户应用程序,使用 Spring Data Redis

你可以选择重用之前版本中的一些代码,或者从头开始,访问 Spring Initializr (https://start.spring.io) 生成一个基础项目(不包含任何依赖)。请确保将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段设置为 users。下载项目后,解压并导入到你喜欢的 IDE 中。在本节结束时,你应该能够看到如图 6-3 所示的项目结构。

正如您所见,我们将使用 Docker Compose 和 Redis,这样可以更方便地测试和运行我们的应用。

首先打开 build.gradle 文件,并将其内容替换为清单 6-18 中所示的内容。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // Web
    implementation 'org.webjars:bootstrap:5.2.3'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}
test {
    testLogging {
        events "passed", "skipped", "failed"
        showExceptions true
        exceptionFormat "full"
        showCauses true
        showStackTraces true
        showStandardStreams = false
    }
}

6-18 build.gradle

我们正在添加 spring-boot-starter-data-redis 和 spring-boot-docker-compose 启动依赖,这将使 Spring Boot 能够利用自动配置功能,设置与 Redis 相关的所有内容,包括连接和进行任何交互所需的类。

接下来,创建或打开用户类。请参阅清单 6-19。

package com.apress.users;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import java.util.Collection;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@RedisHash("USERS")
public class User {
    @Id
    @NotBlank(message = "Email can not be empty")
    private String email;
    @NotBlank(message = "Name can not be empty")
    private String name;
    private String gravatarUrl;
    @NotBlank(message = "Password can not be empty")
    private String password;
    @Singular("role")
    private Collection<UserRole> userRole;
    private boolean active;
}

6-19 src/main/java/apress/com/users/User.java

用户类现在被标记为 @RedisHashMark 注解,该注解接受一个字符串值,作为与其他领域类区分的前缀。此注解将对象标记为要存储在 Redis 哈希中的聚合根。

接下来,按照清单 6-20 的示例创建或打开 UserRepository 接口。

package com.apress.users;
import org.springframework.data.repository.CrudRepository;
public interface UserRepository extends CrudRepository<User,String>{ }

6-20 src/main/java/apress/com/users/UserRepository.java

用户仓库的代码示例 6-20src/main/java/apress/com/users/UserRepository.java

正如您所见,UserRepository 接口与之前的版本相同,继承自 CrudRepository 接口,而 CrudRepository 接口(您已经知道)提供了多个方法,这些方法将由 Spring Data 核心和 Redis 抽象层实现。

接下来,创建或打开 UsersController 类。请参阅第 6-21 号列表。

package com.apress.users;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@AllArgsConstructor
@RestController
@RequestMapping("/users")
public class UsersController {
    private UserRepository userRepository;
    @GetMapping
    public ResponseEntity<Iterable<User>> getAll(){
        return ResponseEntity.ok(this.userRepository.findAll());
    }
    @GetMapping("/{email}")
    public ResponseEntity<User> findUserById(@PathVariable String email) throws Throwable {
        return ResponseEntity.of(this.userRepository.findById(email));
    }
    @RequestMapping(method = {RequestMethod.POST,RequestMethod.PUT})
    public ResponseEntity<User> save(@RequestBody User user){
        if (user.getGravatarUrl()==null)
            user.setGravatarUrl(UserGravatar.getGravatarUrlFromEmail(user.getEmail()));
        if (user.getUserRole()==null)
            user.setUserRole(Collections.singleton(UserRole.INFO));
       this.userRepository.save(user);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{email}")
                .buildAndExpand(user.getEmail())
                .toUri();
        return ResponseEntity.created(location).body(user);
    }
    @DeleteMapping("/{email}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable String email){
        this.userRepository.deleteById(email);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return errors;
    }
}

6-21 src/main/java/apress/com/users/UsersController.java

UsersController 类与之前的版本相同。请注意,UserRepository 被使用,并将在应用程序启动时通过构造函数进行注入。

接下来,创建或打开 UserConfiguration 类。请参阅清单 6-22。

package com.apress.users;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class UserConfiguration{
    @Bean
    ApplicationListener<ApplicationReadyEvent> init(UserRepository userRepository) {
        return applicationReadyEvent -> {
            userRepository.save(User.builder()
                    .email("ximena@email.com")
                    .name("Ximena")
                    .gravatarUrl("https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar")
                    .password("aw2s0meR!")
                    .role(UserRole.USER)
                    .active(true)
                    .build());
            userRepository.save(User.builder()
                    .email("norma@email.com")
                    .name("Norma")
                    .gravatarUrl("https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar")
                    .password("aw2s0meR!")
                    .role(UserRole.USER)
                    .role(UserRole.ADMIN)
                    .active(true)
                    .build());
        };
    }
}

6-22 src/main/java/apress/com/users/UserConfiguration.java

在 UserConfiguration 类中,我们添加了一些用户,并且在这种情况下,我们还添加了 gravatarUrl 字段的值。在 Spring Data Redis 中,持久化任何对象之前没有回调或事件,因此我们可以在控制器中手动添加 gravatarUrl 字段的值,方法是使用记录类型和自定义构造函数,或者创建一个服务在保存之前处理这个字段。因此,正如你所看到的,我们有多种选择,目前可以在这里的配置中添加它。

UserGravatar 和 UserRole 类与之前的版本保持一致。application.properties 文件为空,没有任何属性。请注意,自动配置将使用默认的连接参数。如果您想要覆盖这些参数,可以将 spring.data.redis.* 属性设置为环境变量,既可以通过命令行设置,也可以在 application.properties 文件中设置。

用户应用测试

要测试用户应用程序,请创建或打开 UsersHttpRequestTests 类。请参见第 6-23 号列表。

package com.apress.users;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import java.util.Collection;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UsersHttpRequestTests {
    @Value("${local.server.port}")
    private int port;
    private final String BASE_URL = "http://localhost:";
    private final String USERS_PATH = "/users";
    @Autowired
    private TestRestTemplate restTemplate;
    @Test
    public void indexPageShouldReturnHeaderOneContent() throws Exception {
        assertThat(this.restTemplate.getForObject(BASE_URL + port,
                String.class)).contains("Simple Users Rest Application");
    }
    @Test
    public void usersEndPointShouldReturnCollectionWithTwoUsers() throws Exception {
        Collection<User> response = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(response.size()).isEqualTo(2);
    }
    @Test
    public void userEndPointPostNewUserShouldReturnUser() throws Exception {
        User user =  User.builder()
                .email("dummy@email.com")
                .name("Dummy")
                .gravatarUrl("https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar")
                .password("aw2s0meR!")
                .role(UserRole.USER)
                .active(true)
                .build();
        User response =  this.restTemplate.postForObject(BASE_URL + port + USERS_PATH,user,User.class);
        assertThat(response).isNotNull();
        assertThat(response.getEmail()).isEqualTo(user.getEmail());
        Collection<User> users = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(users.size()).isGreaterThanOrEqualTo(2);
    }
    @Test
    public void userEndPointDeleteUserShouldReturnVoid() throws Exception {
        this.restTemplate.delete(BASE_URL + port + USERS_PATH + "/norma@email.com");
        Collection<User> users = this.restTemplate.
                getForObject(BASE_URL + port + USERS_PATH, Collection.class);
        assertThat(users.size()).isLessThanOrEqualTo(2);
    }
    @Test
    public void userEndPointFindUserShouldReturnUser() throws Exception{
        User user = this.restTemplate.getForObject(BASE_URL + port + USERS_PATH + "/ximena@email.com",User.class);
        assertThat(user).isNotNull();
        assertThat(user.getEmail()).isEqualTo("ximena@email.com");
    }
}

6-23 src/test/java/apress/com/users/UsersHttpRequestTests.java

正如您所见,UsersHttpRequestTests 类并没有变化。要开始测试,您需要确保 Redis 已经启动并在运行。您可以使用您的 IDE 来运行 docker-compose.yaml 服务,或者在命令行中执行以下命令:

docker compose up -d

一旦 Redis 启动并正常运行,您可以通过 IDE 或以下命令来进行测试:

./gradlew clean test
UsersHttpRequestTests > userEndPointFindUserShouldReturnUser() PASSED
UsersHttpRequestTests > userEndPointDeleteUserShouldReturnVoid() PASSED
UsersHttpRequestTests > indexPageShouldReturnHeaderOneContent() PASSED
UsersHttpRequestTests > userEndPointPostNewUserShouldReturnUser() PASSED
UsersHttpRequestTests > usersEndPointShouldReturnCollectionWithTwoUsers() PASSED

启动用户应用程序

在运行应用程序之前,请确保已停止 Redis(来自 docker compose)。然后,您可以通过您的 IDE 或使用以下命令来启动应用程序:

./gradlew clean bootRun

首先启动 Docker Compose,然后启动应用程序。接下来,您可以通过浏览器访问 http://localhost:8080/users,或者使用以下命令:

curl -s http://localhost:8080/users | jq .
[
  {
    "email": "ximena@email.com",
    "name": "Ximena",
    "gravatarUrl": "https://www.gravatar.com/avatar/23bb62a7d0ca63c9a804908e57bf6bd4?d=wavatar",
    "password": "aw2s0meR!",
    "userRole": [
      "USER"
    ],
    "active": true
  },
  {
    "email": "norma@email.com",
    "name": "Norma",
    "gravatarUrl": "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar",
    "password": "aw2s0meR!",
    "userRole": [
      "USER",
      "ADMIN"
    ],
    "active": true
  }
]

如果你感兴趣,可以使用 Redis CLI 来查看 Spring Data Redis 是如何保存数据的。你可以执行以下命令:

docker run -it --rm --network users_default redis:alpine redis-cli -h redis

该命令使用了 users_default 网络,并通过 redis-cli 执行 redis:alpine 镜像,带有 -h(主机)参数和 redis(在 docker-compose.yaml 中声明的服务名称)。命令执行后,您将看到 redis:6379> 提示符。接下来,您可以通过执行 KEYS 命令来查看键:

redis:6379> KEYS *
1) "USERS"
2) "USERS:norma@email.com"
3) "USERS:ximena@email.com"

你可以获取类型,比如说:

redis:6379> TYPE "USERS:norma@email.com"
hash

你可以通过以下命令获取哈希表的键:

redis:6379> HKEYS "USERS:norma@email.com"
1) "email"
2) "active"
3) "_class"
4) "userRole.[1]"
5) "password"
6) "userRole.[0]"
7) "gravatarUrl"
8) "name"

你可以从这个哈希中获取值

redis:6379> HVALS "USERS:norma@email.com"
2) "1"
6) "USER"
7) "https://www.gravatar.com/avatar/f07f7e553264c9710105edebe6c465e7?d=wavatar"
8) "Norma"

你可以获取一个特定的字段

redis:6379> HGET "USERS:norma@email.com" name
"Norma"

在 Redis 客户端中尝试各种命令(您可以在 https://redis.io/commands/查看相关文档)。

接下来,让我们来看看如何在我的复古应用项目中使用 Spring Data Redis。

我使用 Spring Boot 开发的复古应用程序,基于 Spring Data Redis

如果你正在跟着进行,可以重用一些之前版本的代码。或者,你也可以从头开始,访问 Spring Initializr (https://start.spring.io) 生成一个基础项目(不包含任何依赖项);只需确保将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段设置为 myretro。下载项目,解压缩后导入到你喜欢的 IDE 中。在本节结束时,你应该能够看到如图 6-4 所示的结构。

打开 build.gradle 文件,并将其内容替换为清单 6-24 中的内容。

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.3'
    id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // Web
    implementation 'org.webjars:bootstrap:5.2.3'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
    useJUnitPlatform()
}

6-24 build.gradle

列表 6-24 显示我们包含了 spring-boot-starter-data-redis 启动依赖和 Docker Compose。

接下来,创建或打开 board 包以及 RetroBoard 类。请参阅清单 6-25。

package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import java.util.List;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@RedisHash("RETRO_BOARD")
public class RetroBoard {
    @Id
    @NotNull
    private UUID id;
    @NotBlank(message = "A name must be provided")
    private String name;
    @Singular
    private List<Card> cards;
}

6-25 src/main/java/apress/com/myretro/board/RetroBoard.java

列表 6-25 展示了 RetroBoard 类,您可以看到它使用@RedisHash 注解,并使用参数 RETRO_BOARD 来标识 Redis 的哈希。同时,我们使用@Id 注解来标记标识符,这个标识符用于(如在用户应用中)建立该哈希值的键。

接下来,创建或打开持久化包以及 RetroBoardRepository 接口。请参阅清单 6-26。

package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import org.springframework.data.repository.CrudRepository;
import java.util.UUID;
public interface RetroBoardRepository extends CrudRepository<RetroBoard, UUID> { }

6-26 src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java

我们正在使用 CrudRepository,这与其他版本相同,并且我们利用 Spring Data 核心来为我们实现这个接口,执行所有的 Redis 操作。

建议、配置、异常、服务和网络包及其所有类与之前的版本保持一致,在 board 包中,Card 和 CardType 也没有变化。此外,application.properties 文件是空的。因此,总结来说,唯一显著的变化是向 RetroBoard 类添加了@RedisHash 注解。

运行我的复古应用程序

要运行该应用程序,您可以使用 IDE,或者使用以下命令:

./gradlew clean bootRun

然后,请在浏览器中输入 http://localhost:8080/retros,或者执行以下命令:

curl -s http://localhost:8080/retros |  jq .
[
  {
    "id": "9dc9b71b-a07e-418b-b972-40225449aff2",
    "name": "Spring Boot Conference",
    "cards": [
      {
        "id": "bb2a80a5-a0f5-4180-a6dc-80c84bc014c9",
        "comment": "Spring Boot Rocks!",
        "cardType": "HAPPY"
      },
      {
        "id": "e39cd241-4c75-41c8-9750-8b01e8225774",
        "comment": "Meet everyone in person",
        "cardType": "HAPPY"
      },
      {
        "id": "e8947c01-fd30-4f78-81cf-31acf5cfd46b",
        "comment": "When is the next one?",
        "cardType": "MEH"
      },
      {
        "id": "ce228247-d414-4cf9-8356-dbccb89f9370",
        "comment": "Not enough time to talk to everyone",
        "cardType": "SAD"
      }
    ]
  }
]

如果你想了解它在 Redis 中的表示方式,可以运行以下 Docker 容器客户端来使用 redis-cli:

docker run -it --rm --network myretro_default redis:alpine redis-cli -h redis

请注意,我们正在使用应用程序运行时生成的 myretro_default 网络。

然后你可以使用 Redis 命令进行查看:

你可以通过以下 Redis 命令来查看键:

redis:6379> keys *
1) "RETRO_BOARD"
2) "RETRO_BOARD:9dc9b71b-a07e-418b-b972-40225449aff2"

你可以审查这些值

redis:6379> hvals "RETRO_BOARD:9dc9b71b-a07e-418b-b972-40225449aff2"
 1) "com.apress.myretro.board.RetroBoard"
 2) "HAPPY"
 3) "Spring Boot Rocks!"
 4) "bb2a80a5-a0f5-4180-a6dc-80c84bc014c9"
 5) "HAPPY"
 6) "Meet everyone in person"
 7) "08821ce0-79ed-4dd4-ad8d-c6d0e841738f"
 8) "MEH"
 9) "When is the next one?"
10) "edf4dcc7-c800-48e2-bf2d-0af41b5c3a23"
11) "SAD"
12) "Not enough time to talk to everyone"
13) "7545e981-4042-4f9f-8986-78ff92242c3c"
14) "9dc9b71b-a07e-418b-b972-40225449aff2"
15) "Spring Boot Conference"

你可以查看这些密钥

redis:6379> Hkeys "RETRO_BOARD:9dc9b71b-a07e-418b-b972-40225449aff2"
 1) "_class"
 2) "cards.[0].cardType"
 3) "cards.[0].comment"
 4) "cards.[0].id"
 5) "cards.[1].cardType"
 6) "cards.[1].comment"
 7) "cards.[1].id"
 8) "cards.[2].cardType"
 9) "cards.[2].comment"
10) "cards.[2].id"
11) "cards.[3].cardType"
12) "cards.[3].comment"
13) "cards.[3].id"
14) "id"
15) "name"

你可以查看字段的值

redis:6379> hget "RETRO_BOARD:9dc9b71b-a07e-418b-b972-40225449aff2" "cards.[3].cardType"
"SAD"

请注意,我们的 Card 域类使用组合键设置,实例名称复数为 cards,采用数组表示法,以及 Card 字段名称。

所以,这就是如何使用 Spring Data Redis 和 Spring Boot 来运行您的应用程序的方法。当然,我们在这里使用的是仓库编程模型,但您也可以直接使用 RedisTemplate、StringRedisTemplate 或其他操作类,例如 HashOperation、ListOperations、ZSetOperations 等。

概要

在本章中,我们讨论了 MongoDB 和 Redis 这两种 NoSQL 数据库,您了解到它们是如何基于 Spring Data 核心项目的,并且它们提供了相同的仓库编程模型,这使得在领域类中只需进行最小的更改就能更轻松地使用。

你了解到 Spring Boot 利用自动配置功能来设置默认的连接参数,并为 NoSQL 数据库启用额外的功能,而你只需添加这些依赖即可。

你还通过这两个项目(用户应用和我的复古应用)学会了如何使用正确的注解(@Document,@RedisHash)来标记类,甚至如何使用核心注解如@Id 和 CrudRepository 接口。如果让 Spring Data 为你处理这些工作,只需进行很少的修改。

在第七章中,我们将深入探讨 Spring Reactor 及其如何用于创建反应式应用。