学习

书籍、类别和目录

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

目标#

创建 Book-Category-Book-Ratings 域,加载和转换 JSON 数据,并实现 Books API。

议程#

在本课中,学生将学习

书籍、类别和书籍评分#

本课将首先用它们各自的 Spring 存储库填充 Book、Category 和 BookRating 模型。

类别模型#

我们将从 Category 开始。一个 Book 属于一个或多个类别。 Category 有一个名称,我们将从 src/main/resources/data/books 中的 JSON 数据文件中获取。与之前一样,我们将类映射到 Redis 哈希。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/models/Category.java

package com.redislabs.edu.redi2read.models;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
@RedisHash
public class Category {
  @Id
  private String id;
  private String name;
}

类别存储库#

相应的存储库扩展了 Spring 的 CrudRepository。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/repositories/CategoryRepository.java

package com.redislabs.edu.redi2read.repositories;

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

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

@Repository
public interface CategoryRepository extends CrudRepository<Category, String> {
}

书籍模型#

Book 模型直接映射到 src/main/resources/data/books 中的 *.json 文件中的 JSON 有效负载。例如,下面显示的 JSON 对象来自文件 redis_0.json

{
   "pageCount": 228,
   "thumbnail": "https://books.google.com/books/content?id=NsseEAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
   "price": 9.95,
   "subtitle": "Explore Redis - Its Architecture, Data Structures and Modules like Search, JSON, AI, Graph, Timeseries (English Edition)",
   "description": "Complete reference guide to Redis KEY FEATURES ● Complete coverage of Redis Modules.",
   "language": "en",
   "currency": "USD",
   "id": "8194837766",
   "title": "Redis(r) Deep Dive",
   "infoLink": "https://play.google.com/store/books/details?id=NsseEAAAQBAJ&source=gbs_api",
   "authors": ["Suyog Dilip Kale", "Chinmay Kulkarni"]
 },
...
}

类别名称从文件名中提取为“redis”。这同样适用于来自以下文件的任何书籍:redis_0.jsonredis_1.jsonredis_2.jsonredis_3.json。Book 类包含一个 Set<Category>,它目前将包含从文件名中提取的单个类别。 Set<String> 用于作者从有效负载中的“authors”JSON 数组中映射。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/repositories/Book.java

package com.redislabs.edu.redi2read.models;

import java.util.HashSet;
import java.util.Set;

import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Reference;
import org.springframework.data.redis.core.RedisHash;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@RedisHash
public class Book {

  @Id
  @EqualsAndHashCode.Include
  private String id;

  private String title;
  private String subtitle;
  private String description;
  private String language;
  private Long pageCount;
  private String thumbnail;
  private Double price;
  private String currency;
  private String infoLink;

  private Set<String> authors;

  @Reference
  private Set<Category> categories = new HashSet<Category>();

  public void addCategory(Category category) {
    categories.add(category);
  }
}

书籍存储库#

BookRepository 中,我们引入了 PaginationAndSortingRepository 的使用。 PaginationAndSortingRepository 扩展了 CrudRepository 接口,并添加了其他方法以简化对实体的分页访问。当我们实现 BookController 时,我们将进一步了解 PagingAndSortingRepository 的用法。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/repositories/BookRepository.java

package com.redislabs.edu.redi2read.repositories;

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

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

@Repository
public interface BookRepository extends PagingAndSortingRepository<Book, String> {
}

书籍评分模型

BookRating 模型表示用户对书籍的评分。我们实施了传统的 5 星级评分系统,作为多对多关系。 BookRating 模型在关系模型中起着等同于连接表或桥接表的作用。 BookRating 位于多对多关系中的另外两个实体 (Book 和 User) 之间。它的目的是为这两个其他实体 (Book 和 User) 的每种组合存储一条记录。我们使用 @Reference 注释来保留与 Book 和 User 模型的链接(相当于在关系数据库中具有外键)。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/models/BookRating.java

package com.redislabs.edu.redi2read.models;

import javax.validation.constraints.NotNull;

import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Reference;
import org.springframework.data.redis.core.RedisHash;

import lombok.Builder;
import lombok.Data;

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

  @NotNull
  @Reference
  private User user;

  @NotNull
  @Reference
  private Book book;

  @NotNull
  private Integer rating;
}

书籍评分存储库#

相应的存储库只是扩展了 Spring 的 CrudRepository。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/repositories/BookRatingRepository.java

package com.redislabs.edu.redi2read.repositories;

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

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

@Repository
public interface BookRatingRepository extends CrudRepository<BookRating, String> {
}

加载书籍#

现在我们已经定义了模型和存储库,让我们从提供的 JSON 数据中加载书籍,这些数据位于 src/main/resources/data/books 目录中。我们将创建一个 CommandLineRunner 来迭代 data/books 目录中的每个 .json 文件。我们将使用 Jackson 将每个文件的内容映射到 Book 对象。我们将使用文件名中最后一个下划线之前的字符创建一个类别。如果还没有该名称的类别,我们将创建一个。然后将类别添加到书籍的类别集中。使用以下内容添加文件 src/main/java/com/redislabs/edu/redi2read/boot/CreateBooks.java

package com.redislabs.edu.redi2read.boot;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.redislabs.edu.redi2read.models.Book;
import com.redislabs.edu.redi2read.models.Category;
import com.redislabs.edu.redi2read.repositories.BookRepository;
import com.redislabs.edu.redi2read.repositories.CategoryRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Component
@Order(3)
@Slf4j
public class CreateBooks implements CommandLineRunner {
  @Autowired
  private BookRepository bookRepository;

  @Autowired
  private CategoryRepository categoryRepository;

  @Override
  public void run(String... args) throws Exception {
    if (bookRepository.count() == 0) {
    ObjectMapper mapper = new ObjectMapper();
    TypeReference<List<Book>> typeReference = new TypeReference<List<Book>>() {
    };

    List<File> files = //
        Files.list(Paths.get(getClass().getResource("/data/books").toURI())) //
            .filter(Files::isRegularFile) //
            .filter(path -> path.toString().endsWith(".json")) //
            .map(java.nio.file.Path::toFile) //
            .collect(Collectors.toList());

    Map<String, Category> categories = new HashMap<String, Category>();

    files.forEach(file -> {
          try {
            log.info(">>>> Processing Book File: " + file.getPath());
            String categoryName = file.getName().substring(0, file.getName().lastIndexOf("_"));
            log.info(">>>> Category: " + categoryName);

            Category category;
            if (!categories.containsKey(categoryName)) {
            category = Category.builder().name(categoryName).build();
            categoryRepository.save(category);
            categories.put(categoryName, category);
            } else {
            category = categories.get(categoryName);
            }

            InputStream inputStream = new FileInputStream(file);
            List<Book> books = mapper.readValue(inputStream, typeReference);
            books.stream().forEach((book) -> {
            book.addCategory(category);
            bookRepository.save(book);
            });
            log.info(">>>> " + books.size() + " Books Saved!");
          } catch (IOException e) {
            log.info("Unable to import books: " + e.getMessage());
          }
    });

    log.info(">>>> Loaded Book Data and Created books...");
    }
  }
}

这里有很多东西要解包,所以让我们从头开始

  • 与之前一样,我们只在存储库中没有书籍的情况下执行。
  • 我们使用 Jackson ObjectMapperTypeReference 来执行映射。
  • 我们收集目标目录中所有具有 .json 扩展名的文件的路径。
  • 我们创建一个 Map,将 String 映射到 Category 对象,以便在处理文件时收集类别,并快速确定是否已创建类别。
  • 对于每一本书,我们为其分配类别并将其保存到 Redis 中。

Book Controller#

现在,我们可以实现 BookController 的初始版本:我们的 Bookstore Catalog API。这个 BookController 的第一个版本将包含三个端点

  • 获取所有书籍
  • 通过 ISBN (id) 获取书籍
  • 获取所有类别 添加文件 src/main/java/com/redislabs/edu/redi2read/controllers/BookController.java,其内容如下:
package com.redislabs.edu.redi2read.controllers;

import com.redislabs.edu.redi2read.models.Book;
import com.redislabs.edu.redi2read.models.Category;
import com.redislabs.edu.redi2read.repositories.BookRepository;
import com.redislabs.edu.redi2read.repositories.CategoryRepository;

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

@RestController
@RequestMapping("/api/books")
public class BookController {
  @Autowired
  private BookRepository bookRepository;

  @Autowired
  private CategoryRepository categoryRepository;

  @GetMapping
  public Iterable<Book> all() {
    return bookRepository.findAll();
  }

  @GetMapping("/categories")
  public Iterable<Category> getCategories() {
    return categoryRepository.findAll();
  }

  @GetMapping("/{isbn}")
  public Book get(@PathVariable("isbn") String isbn) {
    return bookRepository.findById(isbn).get();
  }
}

获取所有书籍#

要获取所有书籍,我们可以向 https://localhost:8080/api/books/ 发出 GET 请求。这个端点在 all 方法中实现,该方法调用 BookRepository 的 findAll 方法。使用 curl

curl --location --request GET 'https://localhost:8080/api/books/'

结果是一个包含书籍的 JSON 对象数组

[
    {
        "id": "1783980117",
        "title": "RESTful Java Web Services Security",
        "subtitle": null,
        "description": "A sequential and easy-to-follow guide which allows you to understand the concepts related to securing web apps/services quickly and efficiently, since each topic is explained and described with the help of an example and in a step-by-step manner, helping you to easily implement the examples in your own projects. This book is intended for web application developers who use RESTful web services to power their websites. Prior knowledge of RESTful is not mandatory, but would be advisable.",
        "language": "en",
        "pageCount": 144,
        "thumbnail": "https://books.google.com/books/content?id=Dh8ZBAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
        "price": 11.99,
        "currency": "USD",
        "infoLink": "https://play.google.com/store/books/details?id=Dh8ZBAAAQBAJ&source=gbs_api",
        "authors": [
            "Andrés Salazar C.",
            "René Enríquez"
        ],
        "categories": [
            {
                "id": "f2ada1e2-7c18-4d90-bfe7-e321b650c0a3",
                "name": "redis"
            }
        ]
    },
...
]

通过 ISBN 获取书籍#

要获取特定书籍,我们可以向 https://localhost:8080/api/books/{isbn} 发出 GET 请求。这个端点在 get 方法中实现,该方法调用 BookRepository 的 findById 方法。使用 curl

curl --location --request GET 'https://localhost:8080/api/books/1680503545'

结果是一个包含书籍的 JSON 对象

{
  "id": "1680503545",
  "title": "Functional Programming in Java",
  "subtitle": "Harnessing the Power Of Java 8 Lambda Expressions",
  "description": "Intermediate level, for programmers fairly familiar with Java, but new to the functional style of programming and lambda expressions. Get ready to program in a whole new way. ...",
  "language": "en",
  "pageCount": 196,
  "thumbnail": "https://books.google.com/books/content?id=_g5QDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
  "price": 28.99,
  "currency": "USD",
  "infoLink": "https://play.google.com/store/books/details?id=_g5QDwAAQBAJ&source=gbs_api",
  "authors": ["Venkat Subramaniam"],
  "categories": [
    {
      "id": "9d5c025e-bf38-4b50-a971-17e0b7408385",
      "name": "java"
    }
  ]
}

获取所有类别#

要获取所有类别,我们可以向 https://localhost:8080/api/books/categories 发出 GET 请求。它在 getCategories 方法中实现,该方法调用 CategoriesRepositoryfindAll 方法。使用 curl

curl --location --request GET 'https://localhost:8080/api/books/categories'

结果是一个包含类别的 JSON 对象数组

[
  {
    "id": "2fd916fe-7ff8-44c7-9f86-ca388565256c",
    "name": "mongodb"
  },
  {
    "id": "9615a135-7472-48fc-b8ac-a5516a2c8b22",
    "name": "dynamodb"
  },
  {
    "id": "f2ada1e2-7c18-4d90-bfe7-e321b650c0a3",
    "name": "redis"
  },
  {
    "id": "08fc8148-d924-4d2e-af7e-f5fe6f2861f0",
    "name": "elixir"
  },
  {
    "id": "b6a0b57b-ebb8-4d98-9352-8236256dbc27",
    "name": "microservices"
  },
  {
    "id": "7821fd6a-ec94-4ac6-8089-a480a7c7f2ee",
    "name": "elastic_search"
  },
  {
    "id": "f2be1bc3-1700-45f5-a300-2c4cf2f90583",
    "name": "hbase"
  },
  {
    "id": "31c8ea64-cad2-40d9-b0f6-30b8ea6fcbfb",
    "name": "reactjs"
  },
  {
    "id": "5e527af7-93a1-4c00-8f20-f89e89a213e8",
    "name": "apache_spark"
  },
  {
    "id": "9d5c025e-bf38-4b50-a971-17e0b7408385",
    "name": "java"
  },
  {
    "id": "bcb2a01c-9b0a-4846-b1be-670168b5d768",
    "name": "clojure"
  },
  {
    "id": "aba53bb9-7cfa-4b65-8900-8c7e857311c6",
    "name": "couchbase"
  },
  {
    "id": "bd1b2877-1564-4def-b3f7-18871165ff10",
    "name": "riak"
  },
  {
    "id": "47d9a769-bbc2-4068-b27f-2b800bec1565",
    "name": "kotlin"
  },
  {
    "id": "400c8f5a-953b-4b8b-b21d-045535d8084d",
    "name": "nosql_big_data"
  },
  {
    "id": "06bc25ff-f2ab-481b-a4d9-819552dea0e0",
    "name": "javascript"
  }
]

生成书籍评分#

接下来,我们将创建一组随机的书籍评分。在课程的后面部分,我们将使用它们作为示例。按照我们之前用 CommandLineRunner 播种 Redis 的方法,添加文件 src/main/java/com/redislabs/edu/redi2read/boot/CreateBookRatings.java,其内容如下

package com.redislabs.edu.redi2read.boot;

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

import com.redislabs.edu.redi2read.models.Book;
import com.redislabs.edu.redi2read.models.BookRating;
import com.redislabs.edu.redi2read.models.User;
import com.redislabs.edu.redi2read.repositories.BookRatingRepository;

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(4)
@Slf4j
public class CreateBookRatings implements CommandLineRunner {

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

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

  @Autowired
  private RedisTemplate<String, String> redisTemplate;

  @Autowired
  private BookRatingRepository bookRatingRepo;

  @Override
  public void run(String... args) throws Exception {
    if (bookRatingRepo.count() == 0) {
    Random random = new Random();
    IntStream.range(0, numberOfRatings).forEach(n -> {
          String bookId = redisTemplate.opsForSet().randomMember(Book.class.getName());
          String userId = redisTemplate.opsForSet().randomMember(User.class.getName());
          int stars = random.nextInt(ratingStars) + 1;

          User user = new User();
          user.setId(userId);

          Book book = new Book();
          book.setId(bookId);

          BookRating rating = BookRating.builder() //
            .user(user) //
            .book(book) //
            .rating(stars).build();
          bookRatingRepo.save(rating);
    });

    log.info(">>>> BookRating created...");
    }
  }
}

这个 CommandLineRunner 为一组随机的书籍和用户创建可配置数量的随机评分。我们使用 RedisTemplate.opsForSet().randomMember() 从用户和书籍集合中请求随机 ID。然后我们选择一个介于 1 和评分系统中的总星数之间的随机整数来创建评分。这个类介绍了 @Value 注解的使用,它将从应用程序的属性文件中获取 String 参数 ${foo} 内的属性。在文件 src/main/resources/application.properties 中添加以下值

app.numberOfRatings=5000
app.ratingStars=5

使用所有书籍实现分页#

当我们拥有大量数据集并希望将其以更小的块呈现给用户时,分页非常有用。正如我们在本节课的前面所学到的那样,BookRepository 扩展了 PagingAndSortingRepository,后者构建在 CrudRepository 之上。在本节中,我们将重构 BookController 的 all 方法,使其使用 PagingAndSortingRepository 的分页功能。用以下内容替换之前创建的 all 方法

  @GetMapping
  public ResponseEntity<Map<String, Object>> all( //
    @RequestParam(defaultValue = "0") Integer page, //
    @RequestParam(defaultValue = "10") Integer size //
  ) {
    Pageable paging = PageRequest.of(page, size);
    Page<Book> pagedResult = bookRepository.findAll(paging);
    List<Book> books = pagedResult.hasContent() ? pagedResult.getContent() : Collections.emptyList();

    Map<String, Object> response = new HashMap<>();
    response.put("books", books);
    response.put("page", pagedResult.getNumber());
    response.put("pages", pagedResult.getTotalPages());
    response.put("total", pagedResult.getTotalElements());

    return new ResponseEntity<>(response, new HttpHeaders(), HttpStatus.OK);
  }

让我们分解重构过程

  • 我们希望控制方法的返回值,因此我们将使用 ResponseEntity,它是 HttpEntity 的扩展,它使我们能够控制 HTTP 状态代码、头和正文。
  • 对于返回值类型,我们将 Map<String,Object> 包裹起来,以便返回书籍集合以及分页数据。
  • 我们添加了两个类型为整数的请求参数(HTTP 查询参数),用于表示要检索的页码和页面的大小。页码默认为 0,页面大小默认为 10。
  • 在方法主体中,我们使用 PageablePageRequest 抽象来构造分页请求。
  • 我们通过调用 findAll 方法并传入 Pageable 分页请求,得到一个 Page<Book> 结果。
  • 如果返回的页面包含任何项目,我们将它们添加到响应对象中。否则,我们将添加一个空列表。
  • 响应是通过实例化一个 Map 并添加书籍、当前页面、总页数和书籍总数来构造的。
  • 最后,我们将响应映射打包到 ResponseEntity 中。

让我们使用 curl 发起一个分页请求,如下所示

curl --location --request GET 'https://localhost:8080/api/books/?size=25&page=2'

传递页面大小为 25 并请求第 2 页,我们得到以下结果

{
    "total": 2403,
    "books": [
        {
            "id": "1786469960",
            "title": "Data Visualization with D3 4.x Cookbook",
            "subtitle": null,
            "description": "Discover over 65 recipes to help you create breathtaking data visualizations using the latest features of D3...",
            "language": "en",
            "pageCount": 370,
            "thumbnail": "https://books.google.com/books/content?id=DVQoDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
            "price": 22.39,
            "currency": "USD",
            "infoLink": "https://play.google.com/store/books/details?id=DVQoDwAAQBAJ&source=gbs_api",
            "authors": [
                "Nick Zhu"
            ],
            "categories": [
                {
                    "id": "f2ada1e2-7c18-4d90-bfe7-e321b650c0a3",
                    "name": "redis"
                }
            ]
        },
        {
            "id": "111871735X",
            "title": "Android Programming",
            "subtitle": "Pushing the Limits",
            "description": "Unleash the power of the Android OS and build the kinds ofbrilliant, innovative apps users love to use ...",
            "language": "en",
            "pageCount": 432,
            "thumbnail": "https://books.google.com/books/content?id=SUWPAQAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
            "price": 30.0,
            "currency": "USD",
            "infoLink": "https://play.google.com/store/books/details?id=SUWPAQAAQBAJ&source=gbs_api",
            "authors": [
                "Erik Hellman"
            ],
            "categories": [
                {
                    "id": "47d9a769-bbc2-4068-b27f-2b800bec1565",
                    "name": "kotlin"
                }
            ]
        },