学习

使用 Redis 的领域模型

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

目标#

使用 Redis Stack将基于 JSON 的域模型添加到 Redi2Read。

议程#

在本课中,您将学习如何

注意

截至 Jedis 4.0.0 该库已弃用。其功能已合并到 Jedis 中。

购物车和购物车商品#

我们将实现 CartCartItem 模型,它们由一个自定义 Spring 存储库支持,该存储库使用 Redis JSON API(通过 JRedisJSON 客户端库)。

我们将用户的购物车表示为一个包含购物车商品子文档的 JSON 文档。如您在类图中所见,Cart 包含零个或多个 CartItems,并且它属于 User

Redis Stack#

Redis Stack 扩展了 Redis OSS 的核心功能,并为调试等提供了完整的开发者体验。除了 Redis OSS 的所有功能外,Redis Stack 还支持

  • 可查询的 JSON 文档
  • 跨哈希和 JSON 文档的查询
  • 时间序列数据支持(摄取和查询),包括全文搜索
  • 概率数据结构

JRedisJSON#

注意

截至 Jedis 4.0.0 该库已弃用。其功能已合并到 Jedis 中。

JRedisJSON (https://github.com/RedisJSON/JRedisJSON) 是一个 Java 客户端,它提供对 Redis 的 JSON API 的访问,并使用 Google 的 GSON 库提供 Java 序列化。

将 JRedisJSON 添加为依赖项

我们将使用 JRedisJSON 的 SNAPSHOT 版本来利用最近引入的更高级的 JSON 操作功能。将 snapshots-repo 添加到您的 Maven POM 中

<repositories>
  <repository>
    <id>snapshots-repo</id>
    <url>https://oss.sonatype.org/content/repositories/snapshots</url>
  </repository>
</repositories>

And then the JRedisJSON dependency to the dependencies block:
<dependency>
  <groupId>com.redislabs</groupId>
  <artifactId>jrejson</artifactId>
  <version>1.4.0-SNAPSHOT</version>
</dependency>

模型#

CartItem 模型#

我们将从 CartItem 模型开始。它包含有关 Cart 中的 Book 的信息;它存储 Book ISBN(id)、价格和添加到购物车的数量。添加文件 src/main/java/com/redislabs/edu/redi2read/models/CartItem.java,其内容如下

package com.redislabs.edu.redi2read.models;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class CartItem {
  private String isbn;
  private Double price;
  private Long quantity;
}

购物车模型#

购物车模型包含拥有用户的 ID 和一组 CartItems。实用方法用于返回购物车中的商品总数和总成本。添加文件 src/main/java/com/redislabs/edu/redi2read/models/Cart.java,其内容如下

package com.redislabs.edu.redi2read.models;

import java.util.Set;

import lombok.Builder;
import lombok.Data;
import lombok.Singular;

@Data
@Builder
public class Cart {
  private String id;
  private String userId;

  @Singular
  private Set<CartItem> cartItems;

  public Integer count() {
    return getCartItems().size();
  }

  public Double getTotal() {
    return cartItems //
        .stream() //
        .mapToDouble(ci -> ci.getPrice() * ci.getQuantity()) //
        .sum();
  }
}

已购买的书籍#

用户结账后,我们需要跟踪用户现在拥有的书籍。为了简化起见,我们将 Set<Book> 添加到 User 模型中,并用 @Reference 注解进行注释。我们还将包括一个将书籍添加到用户拥有的书籍集合中的实用方法。对 User 模型进行以下更改

// ...
@RedisHash
public class User {

  //...

 @Reference
 @JsonIdentityReference(alwaysAsId = true)
 private Set<Role> roles = new HashSet<Role>();

 public void addRole(Role role) {
   roles.add(role);
 }

 @Reference
 @JsonIdentityReference(alwaysAsId = true)
 private Set<Book> books = new HashSet<Book>();

 public void addBook(Book book) {
   books.add(book);
 }

}

在 Redis 序列化的情况下,@Reference 注解适用于我们的 Set,但您可能已经注意到,角色被 Jackson 完整地序列化到生成的 JSON 负载中。我们将添加 @JsonIdentityReference ,并将 alwaysAsId 参数设置为 true,在目标类(BookRole)中提供适当的元信息的情况下,这将使 Jackson 将这些对象的集合序列化为 ID。@JsonIdentityInfo 注解允许我们使用 id 属性设置一个生成器(ObjectIdGenerator.PropertyGenerator),以指导在存在 @JsonIdentityReference 注解的情况下如何进行序列化。按照所示将注解添加到 Book 模型中

@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@JsonIdentityInfo(
        generator = ObjectIdGenerators.PropertyGenerator.class,
        property = "id")
@RedisHash
public class Book {
//...
}

同样,我们将 @JsonIdentityInfo 添加到 Role 模型中

@Data
@Builder
@JsonIdentityInfo(
        generator = ObjectIdGenerators.PropertyGenerator.class,
        property = "id")
@RedisHash
public class Role {
//...
}

现在,当在 REST 控制器中进行 JSON 序列化时,用户集合将包含角色作为角色 ID 的 JSON 数组。用户集合还将包含新添加的书籍集合,作为书籍 ID 的数组。

购物车仓库#

下面,我们提供了一个 Spring 的 CrudRepository 的实现,以便我们可以实现我们的服务和控制器。将文件 src/main/java/com/redislabs/edu/redi2read/repositories/CartRepository.java 添加到以下内容中

package com.redislabs.edu.redi2read.repositories;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import com.redislabs.edu.redi2read.models.Cart;
import com.redislabs.modules.rejson.JReJSON;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public class CartRepository implements CrudRepository<Cart, String> {

  private JReJSON redisJson = new JReJSON();
  private final static String idPrefix = Cart.class.getName();

  @Autowired
  private RedisTemplate<String, String> template;

  private SetOperations<String, String> redisSets() {
    return template.opsForSet();
  }

  private HashOperations<String, String, String> redisHash() {
    return template.opsForHash();
  }

  @Override
  public <S extends Cart> S save(S cart) {
    // set cart id
    if (cart.getId() == null) {
      cart.setId(UUID.randomUUID().toString());
    }
    String key = getKey(cart);
    redisJson.set(key, cart);
    redisSets().add(idPrefix, key);
    redisHash().put("carts-by-user-id-idx", cart.getUserId().toString(), cart.getId().toString());

    return cart;
  }

  @Override
  public <S extends Cart> Iterable<S> saveAll(Iterable<S> carts) {
    return StreamSupport //
        .stream(carts.spliterator(), false) //
        .map(cart -> save(cart)) //
        .collect(Collectors.toList());
  }

  @Override
  public Optional<Cart> findById(String id) {
    Cart cart = redisJson.get(getKey(id), Cart.class);
    return Optional.ofNullable(cart);
  }

  @Override
  public boolean existsById(String id) {
    return template.hasKey(getKey(id));
  }

  @Override
  public Iterable<Cart> findAll() {
    String[] keys = redisSets().members(idPrefix).stream().toArray(String[]::new);
    return (Iterable<Cart>) redisJson.mget(Cart.class, keys);
  }

  @Override
  public Iterable<Cart> findAllById(Iterable<String> ids) {
    String[] keys = StreamSupport.stream(ids.spliterator(), false) //
      .map(id -> getKey(id)).toArray(String[]::new);
    return (Iterable<Cart>) redisJson.mget(Cart.class, keys);
  }

  @Override
  public long count() {
    return redisSets().size(idPrefix);
  }

  @Override
  public void deleteById(String id) {
    redisJson.del(getKey(id));
  }

  @Override
  public void delete(Cart cart) {
    deleteById(cart.getId());
  }

  @Override
  public void deleteAll(Iterable<? extends Cart> carts) {
    List<String> keys = StreamSupport //
        .stream(carts.spliterator(), false) //
        .map(cart -> idPrefix + cart.getId()) //
        .collect(Collectors.toList());
    redisSets().getOperations().delete(keys);
  }

  @Override
  public void deleteAll() {
    redisSets().getOperations().delete(redisSets().members(idPrefix));
  }

  public Optional<Cart> findByUserId(Long id) {
    String cartId = redisHash().get("carts-by-user-id-idx", id.toString());
    return (cartId != null) ? findById(cartId) : Optional.empty();
  }

  public static String getKey(Cart cart) {
    return String.format("%s:%s", idPrefix, cart.getId());
  }

  public static String getKey(String id) {
    return String.format("%s:%s", idPrefix, id);
  }

}

与使用 @RedisHash 注解的实体一样,我们的购物车通过 Redis JSON 对象集合和 Redis Set 来维护,以便维护键的集合。

购物车服务#

随着 Spring 应用程序变得越来越复杂,直接在控制器上使用仓库会使控制器过于复杂,并偏离了控制路由和处理传入参数和传出 JSON 负载的职责。为了防止模型和控制器因业务逻辑而膨胀(“臃肿”模型和“臃肿”控制器),一种方法是引入一个业务逻辑服务层。我们将通过引入 CartService 来实现购物车业务逻辑。CartService 引入了四个与购物车相关的业务方法

  • get: 通过 ID 查找购物车
  • addToCart: 将购物车商品添加到购物车
  • removeFromCart: 从购物车的购物车商品集合中移除 ISBN
  • checkout: 给定一个购物车 ID,将内容添加到用户的已购买书籍集合中

将文件 src/main/java/com/redislabs/edu/redi2read/services/CartService.java 添加到以下内容中

package com.redislabs.edu.redi2read.services;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.stream.LongStream;

import com.redislabs.edu.redi2read.models.Book;
import com.redislabs.edu.redi2read.models.Cart;
import com.redislabs.edu.redi2read.models.CartItem;
import com.redislabs.edu.redi2read.models.User;
import com.redislabs.edu.redi2read.repositories.BookRepository;
import com.redislabs.edu.redi2read.repositories.CartRepository;
import com.redislabs.edu.redi2read.repositories.UserRepository;
import com.redislabs.modules.rejson.JReJSON;
import com.redislabs.modules.rejson.Path;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CartService {

 @Autowired
 private CartRepository cartRepository;

 @Autowired
 private BookRepository bookRepository;

 @Autowired
 private UserRepository userRepository;

 private JReJSON redisJson = new JReJSON();

 Path cartItemsPath = Path.of(".cartItems");

 public Cart get(String id) {
   return cartRepository.findById(id).get();
 }

 public void addToCart(String id, CartItem item) {
   Optional<Book> book = bookRepository.findById(item.getIsbn());
   if (book.isPresent()) {
     String cartKey = CartRepository.getKey(id);
     item.setPrice(book.get().getPrice());
     redisJson.arrAppend(cartKey, cartItemsPath, item);
   }
 }

 public void removeFromCart(String id, String isbn) {
   Optional<Cart> cartFinder = cartRepository.findById(id);
   if (cartFinder.isPresent()) {
     Cart cart = cartFinder.get();
     String cartKey = CartRepository.getKey(cart.getId());
     List<CartItem> cartItems = new ArrayList<CartItem>(cart.getCartItems());
     OptionalLong cartItemIndex =  LongStream.range(0, cartItems.size()).filter(i -> cartItems.get((int) i).getIsbn().equals(isbn)).findFirst();
     if (cartItemIndex.isPresent()) {
       redisJson.arrPop(cartKey, CartItem.class, cartItemsPath, cartItemIndex.getAsLong());
     }
   }
 }

 public void checkout(String id) {
   Cart cart = cartRepository.findById(id).get();
   User user = userRepository.findById(cart.getUserId()).get();
   cart.getCartItems().forEach(cartItem -> {
     Book book = bookRepository.findById(cartItem.getIsbn()).get();
     user.addBook(book);
   });
   userRepository.save(user);
   // cartRepository.delete(cart);
 }
}

该服务使用 JSONPath 语法在 JSON 级本地实现 addToCartremoveFromCart 方法,以向购物车添加和删除商品。让我们深入研究这些方法的实现。

将商品添加到购物车

在 addToCart 方法中

  • 我们通过 ID 搜索购物车
  • 如果我们找到了卡,那么我们将使用 BookRepository 通过 ISBN 搜索要添加到购物车的书籍
  • 如果我们找到了书籍,我们将把书籍的当前价格添加到商品中(我们不希望客户设置自己的价格)
  • 然后我们使用 JSON.ARRAPPEND 命令将 JSON 对象插入到 JSONPath 表达式 ".cartItems" 中的 JSON 数组中
public void addToCart(String id, CartItem item) {
  Optional<Book> book = bookRepository.findById(item.getIsbn());
  if (book.isPresent()) {
    String cartKey = CartRepository.getKey(id);
    item.setPrice(book.get().getPrice());
    redisJson.arrAppend(cartKey, cartItemsPath, item);
  }
}

**从购物车中移除商品****​**

在 removeFromCart 方法中:我们通过 ID 搜索购物车。

  • 如果我们找到了购物车,我们将搜索要从购物车商品数组中删除的商品的索引。
  • 如果我们找到了商品,我们将使用 JSON.ARRPOP 命令通过其在 JSONPath 表达式 “.cartItems” 中的索引删除该商品。
public void removeFromCart(String id, String isbn) {
  Optional<Cart> cartFinder = cartRepository.findById(id);
  if (cartFinder.isPresent()) {
    Cart cart = cartFinder.get();
    String cartKey = CartRepository.getKey(cart.getId());
    List<CartItem> cartItems = new ArrayList<CartItem>(cart.getCartItems());
    OptionalLong cartItemIndex =  LongStream.range(0, cartItems.size()).filter(i -> cartItems.get((int) i).getIsbn().equals(isbn)).findFirst();
    if (cartItemIndex.isPresent()) {
      redisJson.arrPop(cartKey, CartItem.class, cartItemsPath, cartItemIndex.getAsLong());
    }
  }
}

生成随机购物车#

我们现在拥有所有必要的组件来创建一个 CommandLineRunner,该组件可以为我们的用户生成随机购物车。与之前一样,我们将使用应用程序属性来设置要生成的购物车数量。为此,请将以下内容添加到文件 src/main/resources/application.properties

app.numberOfCarts=2500

CreateCarts CommandLineRunner 如下所示。将其添加到引导包中。

package com.redislabs.edu.redi2read.boot;

import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import java.util.stream.IntStream;

import com.redislabs.edu.redi2read.models.Book;
import com.redislabs.edu.redi2read.models.Cart;
import com.redislabs.edu.redi2read.models.CartItem;
import com.redislabs.edu.redi2read.models.User;
import com.redislabs.edu.redi2read.repositories.BookRepository;
import com.redislabs.edu.redi2read.repositories.CartRepository;
import com.redislabs.edu.redi2read.services.CartService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Component
@Order(5)
@Slf4j
public class CreateCarts implements CommandLineRunner {

  @Autowired
  private RedisTemplate<String, String> redisTemplate;

  @Autowired
  CartRepository cartRepository;

  @Autowired
  BookRepository bookRepository;

  @Autowired
  CartService cartService;

  @Value("${app.numberOfCarts}")
  private Integer numberOfCarts;

  @Override
  public void run(String... args) throws Exception {
    if (cartRepository.count() == 0) {
      Random random = new Random();

      // loops for the number of carts to create
      IntStream.range(0, numberOfCarts).forEach(n -> {
        // get a random user
        String userId = redisTemplate.opsForSet()//
            .randomMember(User.class.getName());

        // make a cart for the user
        Cart cart = Cart.builder()//
            .userId(userId) //
            .build();

        // get between 1 and 7 books
        Set<Book> books = getRandomBooks(bookRepository, 7);

        // add to cart
        cart.setCartItems(getCartItemsForBooks(books));

        // save the cart
        cartRepository.save(cart);

        // randomly checkout carts
        if (random.nextBoolean()) {
          cartService.checkout(cart.getId());
        }
      });

      log.info(">>>> Created Carts...");
    }
  }

  private Set<Book> getRandomBooks(BookRepository bookRepository, int max) {
    Random random = new Random();
    int howMany = random.nextInt(max) + 1;
    Set<Book> books = new HashSet<Book>();
    IntStream.range(1, howMany).forEach(n -> {
      String randomBookId = redisTemplate.opsForSet().randomMember(Book.class.getName());
      books.add(bookRepository.findById(randomBookId).get());
    });

    return books;
  }

  private Set<CartItem> getCartItemsForBooks(Set<Book> books) {
    Set<CartItem> items = new HashSet<CartItem>();
    books.forEach(book -> {
      CartItem item = CartItem.builder()//
          .isbn(book.getId()) //
          .price(book.getPrice()) //
          .quantity(1L) //
          .build();
      items.add(item);
    });

    return items;
  }
}

让我们分解一下 CreateCarts 类

  • 与其他 CommandLineRunner 一样,我们检查是否创建了购物车。
  • 对于要创建的每个购物车,我们
  • 检索一个随机用户。
  • 为用户创建一个购物车。
  • 检索 1 到 7 本书籍。
  • 将购物车商品添加到检索到的书籍的购物车中。
  • 随机“结账”购物车。

该类的底部有两个私有实用程序方法,用于获取随机数量的书籍以及从书籍集合创建购物车商品。在服务器启动后(经过一些 CPU 周期),您应该看到

2021-04-04 14:58:08.737  INFO 31459 --- [  restartedMain] c.r.edu.redi2read.boot.CreateCarts       : >>>> Created Carts...

我们现在可以使用 Redis CLI 从购物车集合中获取一个随机购物车键,检查其中一个键的类型(ReJSON-RL),并使用 JSON.GET 命令检索 JSON 负载

127.0.0.1:6379> SRANDMEMBER "com.redislabs.edu.redi2read.models.Cart"
"com.redislabs.edu.redi2read.models.Cart:dcd6a6c3-59d6-43b4-8750-553d159cdeb8"
127.0.0.1:6379> TYPE "com.redislabs.edu.redi2read.models.Cart:dcd6a6c3-59d6-43b4-8750-553d159cdeb8"
ReJSON-RL
127.0.0.1:6379> JSON.GET "com.redislabs.edu.redi2read.models.Cart:dcd6a6c3-59d6-43b4-8750-553d159cdeb8"
"{\"id\":\"dcd6a6c3-59d6-43b4-8750-553d159cdeb8\",\"userId\":\"-3356969291827598172\",\"cartItems\":[{\"isbn\":\"1784391093\",\"price\":17.190000000000001,\"quantity\":1},{\"isbn\":\"3662433524\",\"price\":59.990000000000002,\"quantity\":1}]}"

购物车控制器#

CartController 基本上是 CartService 的一个传递器(正如控制器所预期的那样)。

package com.redislabs.edu.redi2read.controllers;

import com.redislabs.edu.redi2read.models.Cart;
import com.redislabs.edu.redi2read.models.CartItem;
import com.redislabs.edu.redi2read.services.CartService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/carts")
public class CartController {

  @Autowired
  private CartService cartService;

  @GetMapping("/{id}")
  public Cart get(@PathVariable("id") String id) {
    return cartService.get(id);
  }

  @PostMapping("/{id}")
  public void addToCart(@PathVariable("id") String id, @RequestBody CartItem item) {
    cartService.addToCart(id, item);
  }

  @DeleteMapping("/{id}")
  public void removeFromCart(@PathVariable("id") String id, @RequestBody String isbn) {
    cartService.removeFromCart(id, isbn);
  }

  @PostMapping("/{id}/checkout")
  public void checkout(@PathVariable("id") String id) {
    cartService.checkout(id);
  }

}

让我们使用 curl 请求一个购物车,并通过其 ID

curl --location --request GET 'https://localhost:8080/api/carts/dcd6a6c3-59d6-43b4-8750-553d159cdeb8'

这应该返回一个类似于以下内容的负载

{
  "id": "dcd6a6c3-59d6-43b4-8750-553d159cdeb8",
  "userId": "-3356969291827598172",
  "cartItems": [
    {
      "isbn": "1784391093",
      "price": 17.19,
      "quantity": 1
    },
    {
      "isbn": "3662433524",
      "price": 59.99,
      "quantity": 1
    }
  ],
  "total": 77.18
}