学习

目标#

创建图书-分类-图书评分领域,加载和转换 JSON 数据,并实现图书 API。

议程#

在本课程中,学生将学习

图书、分类和图书评分#

本课程将首先完善 Book、Category 和 BookRating 模型及其各自的 Spring 存储库。

Category 模型#

我们将从 Category 开始。一本 Book 属于一个或多个分类。 Category 有一个名称,我们将从 src/main/resources/data/books 中的 JSON 数据文件派生。正如我们之前所做的那样,我们将类映射到 Redis Hash。添加文件 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;
}

Category 存储库#

相应的存储库扩展了 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 模型#

Book 模型直接映射到 src/main/resources/data/books 目录中的 *.json 文件中的 JSON payload。例如,下面所示的 JSON 对象来自文件 redis_0.json

{
   "pageCount": 228,
   "thumbnail": "http://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> 从 payload 中的“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);
  }
}

Book 存储库#

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 模型

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> {
}

加载图书#

现在我们已经定义了模型和存储库,接下来从 src/main/resources/data/books 目录中提供的 JSON 数据加载图书。我们将创建一个 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 的初始版本:我们的书店目录 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();
  }
}

获取所有图书#

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

curl --location --request GET 'http://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": "http://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 获取图书#

要获取指定图书,我们向 http://localhost:8080/api/books/{isbn} 发出 GET 请求。此端点在 get 方法中实现,该方法调用 BookRepository 的 findById 方法。使用 curl

curl --location --request GET 'http://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": "http://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"
    }
  ]
}

获取所有分类#

要获取所有分类,我们向 http://localhost:8080/api/books/categories 发出 GET 请求。此端点在 getCategories 方法中实现,该方法调用 CategoriesRepositoryfindAll 方法。使用 curl

curl --location --request GET 'http://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 注解的使用,它将从应用程序的属性文件中获取字符串参数 ${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 状态码、header 和 body。
  • 对于返回类型,我们包装了一个 Map<String,Object> 来返回书籍集合以及分页数据。
  • 我们添加了两个整数类型的请求参数 (HTTP 查询参数),分别用于获取的页码和页面大小。页码默认为 0,页面大小默认为 10。
  • 在方法的body中,我们使用 PageablePageRequest 抽象来构建分页请求。
  • 通过调用 findAll 方法并传入 Pageable 分页请求,我们获得了 Page<Book> 结果。
  • 如果返回的页面包含任何项目,我们将其添加到响应对象中。否则,我们添加一个空列表。
  • 通过实例化一个 Map 并添加书籍、当前页、总页数和总书籍数来构建响应。
  • 最后,我们将响应 Map 包装成一个 ResponseEntity

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

curl --location --request GET 'http://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": "http://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": "http://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"
                }
            ]
        },