学习

用户角色和二级索引

Brian Sam-Bodden
作者
Brian Sam-Bodden, Redis 开发者倡导者

目标#

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

议程#

在本课中,您将学习

  • 如何使用 Jackson 加载 JSON 数据。
  • 如何创建和使用辅助索引
  • 如何在 REST 控制器中使用存储库。

如果你卡住了

加载用户#

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

{
  "password": "9yNvIO4GLBdboI",
  "name": "Georgia Spencer",
  "id": -5035019007718357598,
  "email": "georgia.spencer@example.com"
}

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 模型的电子邮件字段上创建的索引。Spring 存储库将在运行时提供查找方法的实现。当我们处理应用程序的安全问题时,我们将使用此查找器。

让我们在 boot 包中创建另一个 CommandLineRunner 来加载用户。我们将遵循与 Roles 相似的配方,但我们将从磁盘加载 JSON 数据并使用 Jackson (https://github.com/FasterXML/jackson),它是最流行的 Java JSON 库之一。

加载用户的配方如下

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

先决条件#

根据上面的加载配方,我们的应用程序目前无法执行但需要执行的两件事

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

密码编码#

我们的 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;

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

  1. 1.创建 "按名称" 索引:创建为一个 Redis 集合,其键为 com.redislabs.edu.redi2read.models.Role:name:superuser,包含一个条目;已索引对象的 id 为 "abc-123"
  2. 2. Role "superuser" 的索引列表:创建一个 Redis 集合,其键为 "com.redislabs.edu.redi2read.models.Role:abc-123:idx",包含一个条目;索引的键为 "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 命令接受一个或多个键。我们将传递 Role 哈希的三个当前键和 Role 键集。

在角色名称上创建辅助索引后,我们可以向 RoleRepository 添加查找方法

@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("admin@example.com");
      adminUser.setPassword(passwordEncoder.encode("Reindeer Flotilla"));//
      adminUser.addRole(admin);

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

让我们来分解一下:

  • 在文件开头,我们使用 @Autowired 注解来注入 RoleRepositoryUserRepositoryBCryptPasswordEncoder
  • CreateRolesCommandLineRunner 一样,我们只在数据库中没有用户的情况下执行逻辑。
  • 然后,我们使用 Repository 的自定义查找方法 findFirstByName 加载管理员和客户角色。
  • 为了处理 JSON 数据,我们创建了一个 Jackson 的 ObjectMapper 和一个 TypeReference,它们将充当将 JSON 数据序列化为 Java 对象的配方。
  • 使用 getResourceAsStream 方法(来自 Class 对象),我们从资源目录加载 JSON 文件。
  • 然后,我们使用 ObjectMapper 将传入的输入流转换为一个 List,其中包含 User 对象。
  • 对于每个用户,我们对其密码进行编码,并添加客户角色。
  • 在文件末尾,我们创建一个具有管理员角色的单个用户,我们将在后面的课程中使用它。

在应用程序重启后,我们应该看到以下内容:

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) "janice.garza@example.com"
11) "password"
12) "$2a$10$/UHTESWIqcl6HZmGpWSUHexNymIgM7rzOsWc4tcgqh6W5OVO4O46."

现在,我们有一个 Redis 集合,它保存了包含用户实例的 Redis Hashes 的用户键集合。我们使用 SCARD 命令来获取集合的基数(1001,来自 JSON 的 1000 个用户加上管理员用户)。使用 SRANDMEMBER 命令,我们可以从 Set 中提取一个随机成员。然后,我们使用它和 User Hashes 前缀来检索随机 User hash 的数据。需要注意以下几点:

  • 用户的角色集使用索引哈希字段(roles.[0], roles.[1] 等)进行存储,值是给定角色的键。这是使用 @Reference 注解 Java 的 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 'https://localhost:8080/api/users/'

输出应该是一个 JSON 对象数组,如下所示:

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

让我们成为良好的 RESTful 开发者,并在输出时过滤掉 passwordpasswordConfirm 字段。为了实现这一点,我们利用了 Jackson 是 Spring Web 中默认序列化器的事实,这意味着我们可以使用 @JsonIgnoreProperties 注解 User 类,只允许设置器(这样我们就可以加载数据),但在序列化期间隐藏 getter,如下所示:

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

使用 import 语句:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

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

[
    {
        "id": "-1180251602608130769",
        "name": "Denise Powell",
        "email": "denise.powell@example.com",
        "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();
   }
 }

我们使用请求参数来获取电子邮件,如果它存在,我们将调用 findFirstByEmail 查找器。我们将结果包装在一个列表中以匹配方法的结果类型。我们使用 Optional 来处理查找器返回的 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 'https://localhost:8080/api/users/?email=donald.gibson@example.com'

返回预期的结果。

[
  {
    "id": "-1266125356844480724",
    "name": "Donald Gibson",
    "email": "donald.gibson@example.com",
    "roles": [
      {
        "id": "a9f9609f-c173-4f48-a82d-ca88b0d62d0b",
        "name": "customer"
      }
    ]
  }
]