使用 Redis Stack 为 Redi2Read 添加一个由 JSON 支持的领域模型。
在本课程中,您将学习如何
CartController
。如果您遇到困难:自 Jedis 4.0.0 起,此库已弃用。其功能已合并到 Jedis 中。
我们将实现 Cart
和 CartItem
模型,它们由使用 JRedisJSON 客户端库通过 Redis JSON API 的自定义 Spring Repository 支持。
我们将用户的购物车表示为一个包含购物车项子文档的 JSON 文档。如类图所示,一个 Cart
包含零个或多个 CartItem
,并且属于一个 User
。
Redis Stack 扩展了 Redis OSS 的核心功能,并提供了完整的开发者体验,用于调试等。除了 Redis OSS 的所有功能外,Redis Stack 还支持
自 Jedis 4.0.0 起,此库已弃用。其功能已合并到 Jedis 中。
JRedisJSON (https://github.com/RedisJSON/JRedisJSON) 是一个 Java 客户端,它提供对 Redis JSON API 的访问,并使用 Google 的 GSON 库提供 Java 序列化。
我们将使用 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
模型开始。它包含 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;
}
Cart 模型包含拥有该购物车的 User 的 ID 以及一组 CartItem
。存在一些工具方法用于返回购物车中的总商品数量和总成本。添加文件 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();
}
}
用户结账后,我们需要记录用户现在拥有的书籍。为了简单起见,我们将在 User
模型中添加一个使用 @Reference
注解的 Set<Book>
。我们还将包含一个工具方法,用于将书籍添加到用户拥有的书籍集合中。对 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
,这样,在目标类(Book
和 Role
)中提供适当的元信息后,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 序列化时,用户集合将包含角色的 JSON 数组(角色 ID)。用户集合还将包含新添加的书籍集合(书籍 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
注解的实体一样,我们的 Cart 使用 Redis JSON 对象集合和 Redis Set 来维护键集合。
随着 Spring 应用变得越来越复杂,直接在控制器中使用仓库会使控制器过于复杂,偏离了控制路由和处理传入参数及传出 JSON 负载的职责。一种避免模型和控制器都充斥着业务逻辑(“胖”模型和“胖”控制器)的方法是引入一个业务逻辑服务层。我们将通过引入 CartService
来处理购物车业务逻辑。CartService
引入了四个与购物车相关的业务方法
get
: 按 ID 查找购物车addToCart
: 将购物车项添加到购物车removeFromCart
: 从购物车的购物车项集合中删除一个 ISBNcheckout
: 给定购物车 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 层级原生实现了 addToCart
和 removeFromCart
方法,用于向购物车添加和删除商品。让我们更深入地研究这些方法的实现。
在 addToCart 方法中
BookRepository
按 ISBN 查找要添加到购物车的书籍".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 查找购物车。
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
。将其添加到 boot 包中。
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
一样,我们检查是否没有创建购物车。在该类的底部有两个私有工具方法,用于获取随机数量的书籍和从一组书籍创建购物车项。服务器启动后(经过一些 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 'http://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
}