学习

用户角色与二级索引

Brian Sam-Bodden
作者
Brian Sam-Bodden, Redis 开发者布道师

目标#

完成用户角色域的创建,加载和转换 JSON 数据,并开始构建 Redi2Read API。

议程#

在本课程中,你将学习

  • 如何使用 Jackson 加载 JSON 数据。
  • 如何创建和使用二级索引
  • 如何在 REST 控制器中使用仓库。

如果遇到困难

加载用户#

现在我们已经创建了角色,接下来从 src/main/resources/data/users/users.json 中提供的 JSON 数据加载 User 对象。该文件包含一个 JSON 用户对象数组,如下所示:

{
  "password": "9yNvIO4GLBdboI",
  "name": "Georgia Spencer",
  "id": -5035019007718357598,
  "email": "[email protected]"
}

JSON 字段与我们的 User POJO 属性的 JavaBean 名称完全对应。

用户仓库#

首先,我们将创建 UserRepository;就像我们创建 RoleRepository 一样,我们将扩展 CrudRepository。在 src/main/java/com/redislabs/edu/redi2read/repositories 目录下创建 UserRepository 接口,如下所示:

package com.redislabs.edu.redi2read.repositories;

import com.redislabs.edu.redi2read.models.User;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends CrudRepository<User, String> {
  User findFirstByEmail(String email);
}

findFirstByEmail 方法利用了我们之前在 User 模型的 email 字段上创建的索引。Spring Repository 将在运行时提供 finder 方法的实现。当我们处理应用程序的安全性时,我们将使用此 finder。

接下来,在 boot 包下创建另一个 CommandLineRunner 来加载用户。我们将遵循与加载角色类似的步骤,不同之处在于我们将从磁盘加载 JSON 数据并使用 Jackson (https://github.com/FasterXML/jackson),这是一个非常流行的 Java JSON 库。

加载用户的步骤如下

  1. 1.从用户 JSON 数据文件创建输入流
  2. 2.使用 Jackson 将输入流读入用户集合
  3. 3.对于每个用户:
  • 对纯文本密码进行编码
  • 添加 customer 角色

前提条件#

基于上述加载步骤,我们的应用程序目前无法做到但需要做的两件事是

  • 一种对纯文本用户密码进行编码的方法
  • 一种按名称查找角色的方法

密码编码#

我们的 PasswordEncoder 实现将使用强大的 BCrypt 哈希函数。在 Redi2readApplication 类中添加

@Bean
public BCryptPasswordEncoder passwordEncoder() {
  return new BCryptPasswordEncoder();
}

以及相应的导入

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

二级索引:按名称查找角色#

正如我们在上一课中了解到的,@Indexed 注解可用于创建二级索引。二级索引支持基于原生 Redis 结构的查找操作。每次保存/更新索引对象时都会维护索引。要在 Role 模型中添加二级索引,只需添加 @Indexed 注解即可

@Data
@Builder
@RedisHash
public class Role {
  @Id
  private String id;

  @Indexed
  private String name;
}

不要忘记添加相应的导入

import org.springframework.data.redis.core.index.Indexed;

现在,当创建一个 ID 为 "abc-123"、角色为 "superuser" 的新 Role 实例时,Spring Data Redis 将执行以下操作:

  1. 1.创建 "by name" 索引:创建为一个 Redis Set,其 key 为 com.redislabs.edu.redi2read.models.Role:name:superuser,包含一个条目:被索引对象的 id "abc-123"
  2. 2.Role "superuser" 的索引列表:创建一个 Redis Set,其 key 为 "com.redislabs.edu.redi2read.models.Role:abc-123:idx",包含一个条目:索引的 key "com.redislabs.edu.redi2read.models.Role:name:superuser"

遗憾的是,要为已经创建的角色创建索引,我们需要要么检索它们并重新保存,要么重新创建它们。由于我们已经自动化了角色的填充过程,并且还没有创建任何关联对象,我们可以简单地使用 Redis CLI 和 DEL 命令删除它们并重新启动服务器

127.0.0.1:6379> KEYS com.redislabs.edu.redi2read.models.Role*
1) "com.redislabs.edu.redi2read.models.Role:c4219654-0b79-4ee6-b928-cb75909c4464"
2) "com.redislabs.edu.redi2read.models.Role:9d383baf-35a0-4d20-8296-eedc4bea134a"
3) "com.redislabs.edu.redi2read.models.Role"
127.0.0.1:6379> DEL "com.redislabs.edu.redi2read.models.Role:c4219654-0b79-4ee6-b928-cb75909c4464" "com.redislabs.edu.redi2read.models.Role:9d383baf-35a0-4d20-8296-eedc4bea134a" "com.redislabs.edu.redi2read.models.Role"
(integer) 3
127.0.0.1:6379>

DEL 命令接受一个或多个 key。我们将传入角色哈希的三个当前 key 和角色 key set。

创建角色的 name 字段上的二级索引后,我们可以为 RoleRepository 添加一个 finder 方法

@Repository
public interface RoleRepository extends CrudRepository<Role, String> {
  Role findFirstByName(String role);
}

CreateUsers CommandLineRunner#

src/main/java/com/redislabs/edu/redi2read/boot 目录下创建 CreateUsers.java 文件,内容如下

package com.redislabs.edu.redi2read.boot;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.redislabs.edu.redi2read.models.Role;
import com.redislabs.edu.redi2read.models.User;
import com.redislabs.edu.redi2read.repositories.RoleRepository;
import com.redislabs.edu.redi2read.repositories.UserRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Component
@Order(2)
@Slf4j
public class CreateUsers implements CommandLineRunner {

  @Autowired
  private RoleRepository roleRepository;

  @Autowired
  private UserRepository userRepository;

  @Autowired
  private BCryptPasswordEncoder passwordEncoder;

  @Override
  public void run(String... args) throws Exception {
    if (userRepository.count() == 0) {
      // load the roles
      Role admin = roleRepository.findFirstByname("admin");
      Role customer = roleRepository.findFirstByname("customer");

      try {
        // create a Jackson object mapper
        ObjectMapper mapper = new ObjectMapper();
        // create a type definition to convert the array of JSON into a List of Users
        TypeReference<List<User>> typeReference = new TypeReference<List<User>>() {
        };
        // make the JSON data available as an input stream
        InputStream inputStream = getClass().getResourceAsStream("/data/users/users.json");
        // convert the JSON to objects
        List<User> users = mapper.readValue(inputStream, typeReference);

        users.stream().forEach((user) -> {
          user.setPassword(passwordEncoder.encode(user.getPassword()));
          user.addRole(customer);
          userRepository.save(user);
        });
        log.info(">>>> " + users.size() + " Users Saved!");
      } catch (IOException e) {
        log.info(">>>> Unable to import users: " + e.getMessage());
      }

      User adminUser = new User();
      adminUser.setName("Adminus Admistradore");
      adminUser.setEmail("[email protected]");
      adminUser.setPassword(passwordEncoder.encode("Reindeer Flotilla"));//
      adminUser.addRole(admin);

      userRepository.save(adminUser);
      log.info(">>>> Loaded User Data and Created users...");
    }
  }
}

让我们分解一下

  • 在顶部,我们使用 @Autowired 注解注入 RoleRepositoryUserRepositoryBCryptPasswordEncoder
  • CreateRoles CommandLineRunner 一样,只有在数据库中没有用户时才执行逻辑。
  • 然后我们使用 Repository 自定义 finder 方法 findFirstByName 加载 admin 和 customer 角色。
  • 为了处理 JSON,我们创建了一个 Jackson ObjectMapper 和一个 TypeReference,这将作为将 JSON 序列化为 Java 对象的“配方”。
  • 使用 Class 对象的 getResourceAsStream 方法,我们从 resources 目录加载 JSON 文件
  • 然后我们使用 ObjectMapper 将传入的输入流转换为 ListUser 对象
  • 对于每个用户,我们对其密码进行编码并添加 customer 角色
  • 在文件末尾,我们创建一个具有 admin 角色的用户,这将在后续课程中使用

应用程序重启后,现在应该看到

2021-04-03 10:05:04.222  INFO 40386 --- [  restartedMain] c.r.edu.redi2read.Redi2readApplication   : Started Redi2readApplication in 2.192 seconds (JVM running for 2.584)
2021-04-03 10:05:04.539  INFO 40386 --- [  restartedMain] c.r.edu.redi2read.boot.CreateRoles       : >>>> Created admin and customer roles...
2021-04-03 10:06:27.292  INFO 40386 --- [  restartedMain] c.r.edu.redi2read.boot.CreateUsers       : >>>> 1000 Users Saved!
2021-04-03 10:06:27.373  INFO 40386 --- [  restartedMain] c.r.edu.redi2read.boot.CreateUsers       : >>>> Loaded User Data and Created users...

探索已加载的用户#

如果你在 MONITOR 模式下观察 Redis CLI,你可能会看到我们刚刚创建的 1001 个用户执行了一连串 Redis 命令。让我们使用 CLI 探索数据

127.0.0.1:6379> KEYS "com.redislabs.edu.redi2read.models.User"
1) "com.redislabs.edu.redi2read.models.User"
127.0.0.1:6379> TYPE "com.redislabs.edu.redi2read.models.User"
set
127.0.0.1:6379> SCARD "com.redislabs.edu.redi2read.models.User"
(integer) 1001
127.0.0.1:6379> SRANDMEMBER "com.redislabs.edu.redi2read.models.User"
"-1848761758049653394"
127.0.0.1:6379> HGETALL "com.redislabs.edu.redi2read.models.User:-1848761758049653394"
 1) "id"
 2) "-1848761758049653394"
 3) "_class"
 4) "com.redislabs.edu.redi2read.models.User"
 5) "roles.[0]"
 6) "com.redislabs.edu.redi2read.models.Role:a9f9609f-c173-4f48-a82d-ca88b0d62d0b"
 7) "name"
 8) "Janice Garza"
 9) "email"
10) "[email protected]"
11) "password"
12) "$2a$10$/UHTESWIqcl6HZmGpWSUHexNymIgM7rzOsWc4tcgqh6W5OVO4O46."

现在我们有一个 Redis Set,其中包含存储用户实例的 Redis Hash 的用户 key 集合。我们使用 SCARD 命令获取 set 的基数(1001,来自 JSON 的 1000 个用户加上 admin 用户)。使用 SRANDMEMBER 命令,我们可以从 Set 中随机抽取一个成员。然后,我们使用该成员和 User Hashes 前缀来检索随机用户哈希的数据。有几点需要指出:

  • 用户的角色集使用索引哈希字段(roles.[0]、roles.[1] 等)存储,其值为给定角色的 key。这是使用 @Reference 注解 Java Set of Role 的结果
  • 密码字段已正确哈希。

构建 Redi2Read API#

现在我们已经有了 UserRole,接下来创建一个 UserController 来暴露一些用户管理功能。

package com.redislabs.edu.redi2read.controllers;

import com.redislabs.edu.redi2read.models.User;
import com.redislabs.edu.redi2read.repositories.UserRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

  @Autowired
  private UserRepository userRepository;

  @GetMapping
  public Iterable<User> all() {
    return userRepository.findAll();
  }
}

现在我们可以发出 GET 请求来检索所有用户

$ curl --location --request GET 'http://localhost:8080/api/users/'

输出应该是一个 JSON 对象数组,例如

[
   {
       "id": "-1180251602608130769",
       "name": "Denise Powell",
       "email": "[email protected]",
       "password": "$2a$10$pMJjQ2bFAUGlBTX9cHsx/uGrbbl3JZmmiR.vG5xaVwQodQyLaj52a",
       "passwordConfirm": null,
       "roles": [
           {
               "id": "a9f9609f-c173-4f48-a82d-ca88b0d62d0b",
               "name": "customer"
           }
       ]
   },
...
]

为了成为“优秀的 RESTful 公民”,我们应该在返回时过滤掉 passwordpasswordConfirm 字段。为此,我们利用 Jackson 是 Spring Web 中默认序列化器的事实,这意味着我们可以使用 @JsonIgnoreProperties 注解 User 类,只允许 setters(以便我们可以加载数据),但在序列化期间隐藏 getters,如下所示:

@JsonIgnoreProperties(value = { "password", "passwordConfirm" }, allowSetters = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
@Data
@RedisHash
public class User {
...
}

以及导入语句

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

再次发出请求应该会反映 JSON 响应中的更改

[
    {
        "id": "-1180251602608130769",
        "name": "Denise Powell",
        "email": "[email protected]",
        "roles": [
            {
                "id": "a9f9609f-c173-4f48-a82d-ca88b0d62d0b",
                "name": "customer"
            }
        ]
    },
...
]

让我们为 UserController 再添加一个方法。我们将添加按电子邮件地址检索用户的功能,这将利用 User 对象中电子邮件字段上的二级索引。我们将其实现为控制器 GET 根端点上的一个过滤器

 @GetMapping
 public Iterable<User> all(@RequestParam(defaultValue = "") String email) {
   if (email.isEmpty()) {
     return userRepository.findAll();
   } else {
     Optional<User> user = Optional.ofNullable(userRepository.findFirstByEmail(email));
     return user.isPresent() ? List.of(user.get()) : Collections.emptyList();
   }
 }

我们使用一个请求参数来接收 email,如果存在,则调用 findFirstByEmail finder 方法。我们将结果包装在列表中以匹配方法的返回类型。我们使用 Optional 来处理 finder 返回 null 的情况。别忘了导入所需的包

import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.springframework.web.bind.annotation.RequestParam;

使用 curl 调用该端点

curl --location --request GET 'http://localhost:8080/api/users/[email protected]'

返回预期结果

[
  {
    "id": "-1266125356844480724",
    "name": "Donald Gibson",
    "email": "[email protected]",
    "roles": [
      {
        "id": "a9f9609f-c173-4f48-a82d-ca88b0d62d0b",
        "name": "customer"
      }
    ]
  }
]