Ohhnews

分类导航

$ cd ..
foojay原文

语言学习卡片系统开发指南:第一部分

#后端开发#spring boot#mongodb#api设计#应用架构

目录

我的母语是西班牙语。我一生都在学习(并且糟蹋)英语。但在某个时刻,我对英语感到自信,想去学日语。大错特错。这完全是另一回事:三种书写系统(平假名 ひらがな、片假名 カタカナ 和汉字 漢字)、完全不同的语法,与欧洲语言毫无关联……我需要帮助。我需要学习工具。其中之一就是基于间隔重复系统 (Spaced Repetition System, SRS) 的应用程序。

这些应用是闪存卡工具,卡片正面是问题,背面是答案。你可以写下西班牙语的“Hola”作为正面,然后猜测含义(Hello)。翻转卡片后,你可以对你的回答进行评分:完全错误、需要努力学习、还可以,或者太简单了。现代闪存卡应用包含了间隔重复系统,这是一种即使你不完全遗忘已知内容,也能反复练习你最容易出错的卡片的方法。这是一个众所周知的语言学习系统,有助于词汇记忆,同时也常用于备考、学习课程等。

在本文中,我们将编写一个没有前端的 Java Spring Boot REST API 后端应用程序,用于在 MongoDB 中存储闪存卡和卡组。在第二篇博文中,我们将添加 SRS 部分并使用一个功能齐全的 React 前端来使用我们的卡片。

完整代码示例

如果你想跟随教程操作,但不想复制/粘贴代码,可以在这里获取应用程序:https://github.com/mongodb-developer/srsapp

使用 Spring Initializr 创建项目

让我们从在 Spring Initializr 中创建基础的空项目开始。这是一个用于快速创建 Spring Boot 项目的 Web 工具。前往 https://start.spring.io/index.html 并选择:

  • 构建系统:Maven。
  • 编程语言:Java。
  • Spring Boot 版本:选择 4.x.x 系列中的最新版本。
  • Group:我使用 com.mongodb.nimongo(我正在学习日语,罗马字读作 nihongo,所以我混合了 MongoDBNihongo。是的,如果一个笑话需要解释的话……)。
  • Artifact:srsapp。名称也设为 srsapp
  • 包名将是 com.mongodb.nimongo.srsapp
  • 我们将使用基于 YAML 的配置文件。
  • Java 版本:v25。
  • 最后,添加依赖项:
    • Spring Data MongoDB(在项目的 pom.xml 中应为 spring-boot-starter-data-mongodb,这是 Spring Data 官方支持的 MongoDB 依赖)。
    • Spring Web(在项目的 pom.xml 中应为 spring-boot-starter-web)。

生成项目。它将下载一个名为 srsapp.zip 的文件。解压缩它。在你的系统中打开终端并进入该目录。

在命令行界面(Linux / macOS)中运行应用:

$ bash
./mvnw spring-boot:run

你需要安装 Java SDK 并将其添加到 PATH 中。

它会失败!你会看到类似以下的错误:

com.mongodb.MongoSocketOpenException: Exception opening socket

这是因为 Spring Boot 正在尝试连接到 MongoDB(因为 MongoDB 是 starter 依赖项的一部分),而我们还没有数据库。让我们通过创建一个免费的 MongoDB Atlas 集群来解决这个问题。你需要 注册一个免费的 Atlas 账户,然后按照说明 部署免费集群。选择 Atlas UI 以通过浏览器创建集群(你也可以使用命令行和 Atlas CLI 创建集群)。

添加 MongoDB 连接 URI

一旦创建了集群,我们就可以 复制连接字符串,并将其添加到 Spring Boot 启动时读取的属性文件中。

MongoDB 连接字符串看起来应该是这样的:

mongodb+srv://<user>:<password>@<your-cluster>.mongodb.net/

你需要在此处填入你的用户名和密码。

为了避免在代码中硬编码机密信息,我们将连接字符串放在名为 MONGODB_URI 的环境变量中。然后,我们在 src/main/resources/application.yaml 文件中读取它。我们的数据库名称将是 srsapp

$ config
spring:
  application:
    name: srsapp
  mongodb:
    base-uri: ${MONGODB_URI}
    uri: ${spring.mongodb.base-uri}?appName=
    database: srsapp

我们希望在应用启动时添加一条错误消息,以警告 URI 缺失。为此,我们要编辑 src/main/java/com/mongodb/nimongo/srsapp/SrsappApplication.java 中的 main 方法:

$ java
// SrsappApplication.java
@SpringBootApplication
public class SrsappApplication implements CommandLineRunner {
    private static final Logger log = LoggerFactory.getLogger(SrsappApplication.class);
    public static void main(String[] args) {
        String envUri = System.getenv("MONGODB_URI");

        try {
            if (envUri == null || envUri.isBlank()) {
                throw new IllegalArgumentException("Missing MongoDB URI");
            }

            SpringApplication.run(SrsappApplication.class, args);
        } catch (IllegalArgumentException e) {
            log.error(ErrorMessage.noDB);
            System.exit(1);
        }
    }

    @Override
    public void run(String... args) throws Exception {
        log.info("🚀 App Started");
        String envUri = System.getenv("MONGODB_URI");
        log.info("MongoDB URI from environment variable: {}", envUri);
    }
}

这里做了几处修改:

  • 我们引入了一个 Logger 实例来向控制台打印调试信息。请从 org.slf4j 包中导入相关类。
  • 然后我们检查是否定义了名为 MONGODB_URI 的环境变量。如果没有,我们抛出 IllegalArgumentException,并立即捕获它,记录错误并退出应用。

这样,如果我们不定义环境变量直接运行应用,就会得到错误:

./mvnw spring-boot:run

11:56:24.373 [main] ERROR com.mongodb.nimongo.srsapp.SrsappApplication -- 
    ####### ######  ######  ####### ######
    #       #     # #     # #       #     #
    #       #     # #     # #       #     #
    #####   ######  ######  #       ######
    #       #   #   #   #   #       #   #
    #       #    #  #    #  #       #    #
    ####### #     # #     # ####### #     #
    Missing database connection string!

要修复此问题,请在启动应用前设置环境变量:

$ bash
export MONGODB_URI="<YOUR_CONNECTION_STRING>"

然后再次运行应用程序。

最后,如果我们提供了 URI,应用就会启动:

$ bash
MONGODB_URI=mongodb+srv://user:password@your-cluster.mongodb.net/srsapp ./mvnw spring-boot:run

你应该能在日志中看到来自 org.mongodb.driver.client 的消息。

我们的逻辑模型

从逻辑角度来看,卡片被组织在“卡组 (Decks)”中。一个卡组有一个名称并包含多张卡片。系统中的卡组和卡片数量没有限制。一张卡片有正面文字、背面文字,并属于一个卡组。一张卡片只能属于一个卡组,这是一种一对多的关系:一个卡组可以有 0 到 n 张卡片。我们可以用以下 ERD 表示:

$ mermaid
erDiagram
    DECK ||--o{ CARD : contains
    DECK {
        string id
        string name
        string description
    }
    CARD {
        string id
        date frontText
        string backText
    }

我们的 MongoDB 模式

在对实体进行建模时,我们有多种选择。让我们分析一下,为这个问题找到最佳模式。

选项 1:所有内容放在一个集合中。 我们可以将卡组放在一个集合中,并将卡片定义为每个卡组内部的一个数组。这是建模一对多关系的一种方式,当我们知道“多”端的最大规模时推荐使用。它看起来像这样:

$ cat
{
    "_id": 1,
    "title": "Spanish Deck",
    "cards": [
        {
            "frontText": "Hola",
            "backText": "Hello"
        },
        ...
    ]
}

这里的主要问题是,我们可以不断向同一个卡组添加卡片。例如,Jōyō Kanji (常用漢字) 是你预计要学习的“基础”汉字集,共 2136 个。这意味着一个至少包含 2136 个元素的数组。每当你添加、删除或编辑一张卡片时,MongoDB 都会将整个卡组读入内存并进行隐式事务保存。如果很多人同时访问同一个卡组,这会拖慢系统,但主要问题是缺乏边界。该数组可能会无限增长并触及 BSON 对象的 16MB 限制,这就是所谓的 无边界数组反模式 (Unbounded Array Antipattern)

选项 2:我们将卡组和卡片保留在单独的集合中。 这将避免 无边界数组反模式 问题,我们可以通过以下方式获取卡组的所有卡片:

  • 执行两次 find 查询(一次获取卡组,另一次获取该卡组下的所有卡片)。
  • 只需一次查询,使用 MongoDB 的聚合管道 (Aggregation Pipeline) 和 $lookup 操作符来获取卡组及其所有关联的卡片。

我们将通过 parentDeckId 来维持卡片与卡组之间的链接。值得一提的是,MongoDB 没有外键约束的概念,因此如果你需要在添加卡片时检查卡组是否存在,必须在代码中实现。

我们的卡组将如下所示:

$ cat
{
    "_id": 1,
    "title": "Spanish Deck",
    "description": "A Deck to learn the Spanish Language"
}

我们的卡片:

$ cat
{
    "_id": 78,
    "parentDeckId": 1,
    "frontText": "Hola",
    "backText": "Hello"
}

我们的模型类

基于上述模式,我们将拥有以下两个模型类:

$ java
// Deck.java
package com.mongodb.nimongo.srsapp.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import com.fasterxml.jackson.annotation.JsonProperty;

@Document(collection = "decks")
public record Deck(
        @Id @JsonProperty("_id") String id,
        String name,
        String description) {
}

如你所见,集合名称为 decks,Java 中有一个 String id 字段,它将映射到数据库中的 _id 字段。

我们的闪存卡模型类 FlashCard 将非常相似:

$ java
// FlashCard.java
package com.mongodb.nimongo.srsapp.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import com.fasterxml.jackson.annotation.JsonProperty;

@Document(collection = "cards")
public record FlashCard(
        @Id @JsonProperty("_id") String id,
        String frontText,
        String backText,
        String parentDeckId
) {
    public FlashCard(String frontText, String backText, String parentDeckId) {
        this(null, frontText, backText, parentDeckId);
    }
}

我们将所有内容存储在 cards 集合中,并使用 parentDeckId 作为指向该卡片所属卡组的链接。

改善开发周期

每当我们更改代码时,都需要停止应用程序并重新启动。这很快就会变成一个乏味的过程,而且容易出错。如果我每次修改代码而忘记保存,导致修复没生效而感到困惑时都能得到一分钱,我现在就不会在这里写文章了,而是在豪华游艇上环游世界退休了。

打开你的 pom.xml 文件并添加此依赖项:

$ xml
<!-- Development Tools -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

现在,停止并最后一次重新启动应用。从现在开始,每当你更改代码,应用都会自动重新编译并重启。

我们的第一个 API 端点

我们需要为应用设置多个端点。

卡组 (Decks)

  • 创建新卡组
    • POST /decks
    • Body: { "name": "Spanish A1", ... }
    • 响应: 201 Created + 卡组表示(通常包含 Location: /decks/{deckId})
  • 列出所有卡组
    • GET /decks
    • 响应: 200 OK + [{...}, {...}]
  • 删除卡组
    • DELETE /decks/{deckId}
    • 响应: 204 No Content(如果未找到则为 404)
  • 获取一个卡组详情
    • GET /decks/{deckId}
  • 获取一张卡片详情
    • GET /decks/{deckId}/cards/{cardId}

卡片 (Cards)

  • 向卡组添加新卡片
    • POST /decks/{deckId}/cards
    • Body: { "front": "hola", "back": "hello", ... }
    • 响应: 201 Created + 卡片表示(通常包含 Location: /decks/{deckId}/cards/{cardId})
  • 删除卡片
    • DELETE /decks/{deckId}/cards/{cardId}
    • 响应: 204 No Content(如果未找到则为 404)
  • 列出卡组中所有卡片
    • GET /decks/{deckId}/cards
    • 响应: 200 OK + [{...}, {...}]
  • 删除卡组中所有卡片
    • 请求: DELETE /decks/{deckId}/cards
    • 响应: 204 No Content(成功时),或 404 Not Found(如果 deckId 不存在)。## 添加 Decks 的空端点

为了测试我们的 Spring Data API 端点是否有效,我们将首先添加一个空的 Deck 控制器。在 web/controller 目录下创建 BaseController.javaDeckController.java 文件,如下所示:

$ java
// BaseController.java
package com.mongodb.nimongo.srsapp.web.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.mongodb.nimongo.srsapp.SrsappApplication;
public abstract class BaseController {
    static final Logger log = LoggerFactory.getLogger(SrsappApplication.class);
}

BaseController 为我们的控制器添加了一个日志记录器(Logger)。

现在,创建 DeckController

$ java
// DeckController.java
package com.mongodb.nimongo.srsapp.web.controller;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.mongodb.nimongo.srsapp.model.Deck;
import com.mongodb.nimongo.srsapp.model.FlashCard;
@RestController
@RequestMapping("/decks")

public class DeckController extends BaseController{
    @GetMapping
    public ResponseEntity<List<Deck>> getAllDecks(@RequestParam Optional<Integer> pageSize, @RequestParam Optional<Integer> pageNumber) {
        log.info("💻 Getting all decks with pageSize {} and pageNumber {}", pageSize, pageNumber);
        List<Deck> emptyList = List.of();
        return new ResponseEntity<>(emptyList, HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Optional<Deck>> getDeck(@PathVariable String id) {
        log.info("💻 Getting deck with id {}", id);
        Optional<Deck> emptyDeck = Optional.empty();
        return new ResponseEntity<>(emptyDeck, HttpStatus.OK);
    }

    @GetMapping("/{id}/cards")
    public ResponseEntity<List<FlashCard>> getAllCardsInDeck(@RequestParam Optional<Integer> pageSize, @RequestParam Optional<Integer> pageNumber, @PathVariable String id) {
        int thePageSize = pageSize.orElse(10);
        int thePageNumber = pageNumber.orElse(0);
        log.info("💻 Getting all cards in deck with pageSize {} and pageNumber {} and deckId {}", thePageSize, thePageNumber, id);
        List<FlashCard> emptyList = List.of();
        return new ResponseEntity<>(emptyList, HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<Deck> createDeck(@RequestBody Deck deck) {
        log.info("💻 Creating deck with name {}", deck.description());
        Deck emptyDeck = new Deck(null, deck.name(), deck.description());
        return new ResponseEntity<>(emptyDeck, HttpStatus.CREATED);
    }

    @GetMapping("/search")
    public ResponseEntity<List<Deck>> searchDecks(@RequestParam Optional<String> term) {
        log.info("💻 Searching decks with term {}", term);
        List<Deck> emptyList = List.of();
        return new ResponseEntity<>(emptyList, HttpStatus.OK);
    }

   @DeleteMapping("/{id}")
   public ResponseEntity<Void> deleteDeck(@PathVariable String id) {
        log.info("💻 Deleting deck with id {}", id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
   }

   @DeleteMapping("/{id}/cards")
   public ResponseEntity<Void> deleteCardsInDeck(@PathVariable String id) {
        log.info("💻 Deleting all cards in deck with id {}", id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
   }
}

观察代码,你会发现我们只是在返回空响应。我们希望调用这些端点,确保 Web 层配置正常。

更改默认端口

接下来,我们将默认端口 (8080) 更改为 5400,并添加一些选项以改进调试日志。打开 application.yml 并将其修改为:

$ config
spring:
  application:
    name: srsapp
  mongodb:
    uri: ${MONGODB_URI}
    database: srsapp
  mvc:
    log-request-details: true
server:
  port: 5400
logging:
  level:
    org:
      springframework:
        web: DEBUG
        data:
          mongodb: DEBUG

测试端点

我们将使用 Linux/macOS 和 Windows 上均可用的 cURL 来测试我们的端点。

在终端中复制并粘贴以下命令,即可查询所有的 Decks:

$ bash
## GET decks

curl "http://localhost:5400/decks"

## GET decks 分页查询

curl "http://localhost:5400/decks?pageSize=2&pageNumber=0"

插入一个新的 Deck:

$ bash
## POST deck

curl -X "POST" "http://localhost:5400/decks" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "name": "Test new Deck",
  "description": "Test new Deck description"
}'

搜索和删除:

$ bash
## DELETE deck

curl -X "DELETE" "http://localhost:5400/decks/<deck id>"

## Search decks

curl "http://localhost:5400/decks/search?term=lang"

但请注意!上述所有操作目前仅在 DeckController 占位符中运行,并没有与数据库进行任何存储或检索!接下来,让我们通过添加针对 Decks 的 MongoDB 代码来解决这个问题。

Repositories

为了访问 MongoDB,我们将创建一个继承自 MongoRepositoryDeckRepository 接口。这是访问 MongoDB 最快捷的方式,因为 MongoRepository 包含了多种用于访问集合的实用方法。我们甚至可以在接口中添加自己的方法以实现自定义行为。如果需要更高级或定制化的功能,我们将需要使用 MongoTemplate

repository 文件夹中创建 DeckRepository

$ java
// DeckRepository

package com.mongodb.nimongo.srsapp.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
import com.mongodb.nimongo.srsapp.model.Deck;

/**
 * Deck 持久化操作的存储库接口。
 * 继承 Spring Data 的 {@link MongoRepository} 以提供 CRUD 和分页支持,
 * 并声明服务层使用的额外查询方法。
 */

@Repository
public interface DeckRepository extends MongoRepository<Deck, String> {
    /**
     * 使用正则表达式按名称或描述搜索 Decks
     * 
     * @param searchText - 要搜索的文本,不区分大小写
     * @param pageable   - 用于响应分页
     * @return 匹配 searchText 的 {@link Deck} 列表
     */
    @Query("{$or:[ {name: {$regex: ?0, $options: 'i'}}, {description: {$regex: ?0, $options: 'i'}} ] }")
    List<Deck> searchByText(String searchText, Pageable pageable);
}

如你所见,我们添加了一个新方法 searchByText,它使用正则表达式来搜索文本(由 ?0 表示),搜索范围为 namedescription。我们传入 i 选项以执行不区分大小写的比较。使用正则表达式并不是 MongoDB 中搜索文本的最佳方式;通常建议使用 全文检索 (Full Text Search),通过定义搜索索引并使用 $search 来实现。

同时,我们也将创建 CardRepository

$ java
// CardRepository

package com.mongodb.nimongo.srsapp.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.repository.DeleteQuery;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
import com.mongodb.nimongo.srsapp.model.FlashCard;

/**
 * FlashCard 持久化操作的存储库接口。
 * 继承 Spring Data 的 {@link MongoRepository} 以提供 CRUD 和分页支持,
 * 并声明服务层使用的额外查询方法。
 */

@Repository
public interface CardRepository extends MongoRepository<FlashCard, String> {
    /**
     * 使用正则表达式按 frontText 或 backText 搜索卡片
     * 
     * @param searchText - 要搜索的文本,不区分大小写
     * @param pageable   - 用于响应分页
     * @return 匹配 searchText 的 {@link FlashCard} 列表
     */

    @Query("{$or:[ {frontText: {$regex: ?0, $options: 'i'}}, {backText: {$regex: ?0, $options: 'i'}} ] }")
    List<FlashCard> searchByText(String searchText, Pageable pageable);
    
    /**
     * 返回属于给定父 Deck ID 的 FlashCard 分页结果。
     *
     * @param deckId  父 Deck 标识符
     * @param request 包含分页参数的 PageRequest
     * @return FlashCard 对象的 Page
     */
    Page<FlashCard> findAllByParentDeckId(String deckId, PageRequest request);
    
    /**
     * 删除属于给定父 Deck ID 的所有 FlashCard。
     *
     * @param deckId 需要移除卡片的父 Deck 标识符
     */
    @DeleteQuery("{parentDeckId: ?0}")
    void deleteAllByParentDeckId(String deckId);
}

Services

Service 层是我们实际使用数据库代码的地方。我们的 Service 将与数据库交互,执行查询、插入、更新等操作,并向 Web 控制器公开一系列业务层面的操作。现在,在新的 service 文件夹中创建 DeckService.java。在这里,我们将使用 DeckRepositoryCardRepository 来访问数据库。例如,要通过标识符获取 Deck,我们将使用 findById,它是 CrudRepository 的一部分,在此场景下由我们的 MongoDB 驱动程序实现。

$ java
@Service

public class DeckService {
    private final DeckRepository deckRepository;
    private final CardRepository cardRepository;
    DeckService(DeckRepository deckRepository, CardRepository cardRepository) {
        this.deckRepository = deckRepository;
        this.cardRepository = cardRepository;
    }

    public Optional<Deck> deckById(String id) {
        return deckRepository.findById(id);
    }
    // 其他方法的更多代码。
}

要添加一个 Deck,我们将使用 save

$ java
public Deck createDeck(Deck deck) {
        return deckRepository.save(deck);
    }

然后,进行删除操作:

$ java
public void deleteDeck(String id) {
        deckRepository.deleteById(id);
    }

如果需要删除 Deck 中的所有卡片,我们将使用 CardRepository 中的 deleteAllByParentDeckId

$ java
public void deleteCardsInDeck(String id) {
        cardRepository.deleteAllByParentDeckId(id);
    }

最终的 DeckService 将如下所示:

$ java
// DeckService.java

package com.mongodb.nimongo.srsapp.service;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import com.mongodb.nimongo.srsapp.model.Deck;
import com.mongodb.nimongo.srsapp.model.FlashCard;
import com.mongodb.nimongo.srsapp.repository.CardRepository;
import com.mongodb.nimongo.srsapp.repository.DeckRepository;

/**
 * 用于处理与 Decks 及其关联 FlashCards 相关业务逻辑的服务类。
 * 该类与 DeckRepository 和 CardRepository 交互,执行创建、检索、搜索和删除 Decks 及其卡片的操作。
 */

@Service
public class DeckService {
    private final DeckRepository deckRepository;
    private final CardRepository cardRepository;
    DeckService(DeckRepository deckRepository, CardRepository cardRepository) {
        this.deckRepository = deckRepository;
        this.cardRepository = cardRepository;
    }

    /**
     * 按标识符检索 Deck。
     *
     * @param id Deck 标识符
     * @return 包含 Deck 的 Optional,如果未找到则为空
     */
    public Optional<Deck> deckById(String id) {
        return deckRepository.findById(id);
    }

    /**
     * 返回 Deck 的分页列表。
     *
     * @param limit 每页最大 Deck 数
     * @param skip  基于零的页索引
     * @return 包含所请求页面 Deck 对象的 Page
     */
    public Page<Deck> findAllDecks(Integer limit, Integer skip) {
        PageRequest request = PageRequest.of(skip, limit, Sort.unsorted());
        return deckRepository.findAll(request);
    }

    /**
     * 返回属于给定 Deck 的 FlashCard 分页列表。
     *
     * @param deckId 父 Deck 标识符
     * @param limit  每页最大卡片数
     * @param skip   基于零的页索引
     * @return 包含所请求页面 FlashCard 对象的 Page
     */
    public Page<FlashCard> allCardsInDeck(String deckId, Integer limit, Integer skip) {
        PageRequest request = PageRequest.of(skip, limit, Sort.unsorted());
        return cardRepository.findAllByParentDeckId(deckId, request);
    }

    /**
     * 使用自由文本词汇搜索 Decks。
     *
     * @param theTerm 要在 Deck 文本字段中匹配的搜索词
     * @return 匹配搜索词的 Decks 列表(限制为第一页)
     */
    public List<Deck> searchDecks(String theTerm) {
        PageRequest request = PageRequest.of(0, 10, Sort.unsorted());
        return deckRepository.searchByText(theTerm, request);
    }

    /**
     * 创建或更新 Deck。
     *
     * @param deck 要持久化的 Deck
     * @return 已保存的 Deck 实例
     */
    public Deck createDeck(Deck deck) {
        return deckRepository.save(deck);
    }

    /**
     * 按标识符删除 Deck。
     *
     * @param id 要删除的 Deck 标识符
     */
    public void deleteDeck(String id) {
        deckRepository.deleteById(id);
    }

    /**
     * 删除与给定 Deck 关联的所有 FlashCards。
     *
     * @param id 需要移除卡片的父 Deck 标识符
     */
    public void deleteCardsInDeck(String id) {
        cardRepository.deleteAllByParentDeckId(id);
    }
}

对于 Cards,我们将以类似的方式使用 CardRepository

$ java
// CardService.java

package com.mongodb.nimongo.srsapp.service;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import com.mongodb.nimongo.srsapp.model.FlashCard;
import com.mongodb.nimongo.srsapp.repository.CardRepository;

@Service
public class CardService {
    private final CardRepository cardRepository;
    CardService(CardRepository cardRepository) {
        this.cardRepository = cardRepository;
    }

    /**
     * 按标识符检索 FlashCard。
     *
     * @param id FlashCard 标识符
     * @return 包含 FlashCard 的 Optional,如果未找到则为空
     */
    public Optional<FlashCard> cardById(String id) {
        return cardRepository.findById(id);
    }

    /**
     * 返回 FlashCard 的分页列表。
     *
     * @param limit 每页最大卡片数
     * @param skip  基于零的页索引
     * @return 包含所请求页面 FlashCard 对象的 Page
     */
    public Page<FlashCard> findAllCards(Integer limit, Integer skip) {
        PageRequest request = PageRequest.of(skip, limit, Sort.unsorted());
        return cardRepository.findAll(request);
    }

    /**
     * 使用自由文本词汇搜索 FlashCards。
     *
     * @param theTerm 要在卡片文本字段中匹配的搜索词
     * @return 匹配搜索词的 FlashCards 列表(限制为第一页)
     */
    public List<FlashCard> searchCards(String theTerm) {
        PageRequest request = PageRequest.of(0, 10, Sort.unsorted());
        return cardRepository.searchByText(theTerm, request);
    }

    /**
     * 创建或更新 FlashCard。
     *
     * @param card 要持久化的 FlashCard
     * @return 已保存的 FlashCard 实例
     */
    public FlashCard createCard(FlashCard card) {
        return cardRepository.save(card);
    }

    /**
     * 按标识符删除 FlashCard。
     *
     * @param id 要删除的 FlashCard 标识符
     */
    public void deleteCard(String id) {
        cardRepository.deleteById(id);
    }
}
```## **配置控制器 (Controllers)**

现在我们已经有了用于定义数据库操作的 Repository(存储库)以及处理业务逻辑的 Service(服务),接下来就可以在控制器中将它们整合起来。

为此,我们将在 `DeckController` 中添加 `DeckService`,并调用该服务提供的方法,进而通过它使用 `MongoRepository`。

例如,若要获取单个卡组 (Deck),我们将使用 `deckService` 中的 `deckById` 方法:

```java
@GetMapping("/{id}")
    public ResponseEntity<Optional<Deck>> getDeck(@PathVariable String id) {
        log.info("💻 Getting deck with id {}", id);
        return new ResponseEntity<>(deckService.deckById(id), HttpStatus.OK);
    }

DeckController 的完整更新代码如下:

$ java
package com.mongodb.nimongo.srsapp.web.controller; 

import static com.mongodb.nimongo.srsapp.web.controller.Constants.DEFAULT_PAGE_NUMBER;
import static com.mongodb.nimongo.srsapp.web.controller.Constants.DEFAULT_PAGE_SIZE;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.mongodb.nimongo.srsapp.model.Deck;
import com.mongodb.nimongo.srsapp.model.FlashCard;
import com.mongodb.nimongo.srsapp.service.DeckService;

@RestController
@RequestMapping("/decks")
public class DeckController extends BaseController{
    private final DeckService deckService;
    DeckController(DeckService deckService) {
        this.deckService = deckService;
    }

    @GetMapping
    public ResponseEntity<List<Deck>> getAllDecks(@RequestParam Optional<Integer> pageSize, @RequestParam Optional<Integer> pageNumber) {
        Integer thePageSize = pageSize.orElse(DEFAULT_PAGE_SIZE);
        Integer thePageNumber = pageNumber.orElse(DEFAULT_PAGE_NUMBER);
        log.info("💻 Getting all decks with pageSize {} and pageNumber {}", thePageSize, thePageNumber);
        Page<Deck> deckPage = deckService.findAllDecks(thePageSize, thePageNumber);
        return new ResponseEntity<>(deckPage.getContent(), HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Optional<Deck>> getDeck(@PathVariable String id) {
        log.info("💻 Getting deck with id {}", id);
        return new ResponseEntity<>(deckService.deckById(id), HttpStatus.OK);
    }

    @GetMapping("/{id}/cards")
    public ResponseEntity<List<FlashCard>> getAllCardsInDeck(@RequestParam Optional<Integer> pageSize, @RequestParam Optional<Integer> pageNumber, @PathVariable String id) {
        Integer thePageSize = pageSize.orElse(DEFAULT_PAGE_SIZE);
        Integer thePageNumber = pageNumber.orElse(DEFAULT_PAGE_NUMBER);
        log.info("💻 Getting all cards in deck with pageSize {} and pageNumber {} and deckId {}", thePageSize, thePageNumber, id);
        Page<FlashCard> cardPage = deckService.allCardsInDeck(id, thePageSize, thePageNumber);
        return new ResponseEntity<>(cardPage.getContent(), HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<Deck> createDeck(@RequestBody Deck deck) {
        log.info("💻 Creating deck with name {}", deck.description());
        Deck createdDeck = deckService.createDeck(deck);
        return new ResponseEntity<>(createdDeck, HttpStatus.CREATED);
    }

    @GetMapping("/search")
    public ResponseEntity<List<Deck>> searchDecks(@RequestParam Optional<String> term) {
        log.info("💻 Searching decks with term {}", term);
        String theTerm = term.orElse("");
        return new ResponseEntity<>(deckService.searchDecks(theTerm), HttpStatus.OK);
    }

   @DeleteMapping("/{id}")
   public ResponseEntity<Void> deleteDeck(@PathVariable String id) {
        deckService.deleteDeck(id);
        log.info("💻 Deleting deck with id {}", id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
   }

   @DeleteMapping("/{id}/cards")
   public ResponseEntity<Void> deleteCardsInDeck(@PathVariable String id) {
        deckService.deleteCardsInDeck(id);
        log.info("💻 Deleting all cards in deck with id {}", id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
   }
}

针对卡片 (Cards) 的控制器:

$ java
package com.mongodb.nimongo.srsapp.web.controller;

import static com.mongodb.nimongo.srsapp.web.controller.Constants.DEFAULT_PAGE_NUMBER;
import static com.mongodb.nimongo.srsapp.web.controller.Constants.DEFAULT_PAGE_SIZE;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.mongodb.nimongo.srsapp.model.FlashCard;
import com.mongodb.nimongo.srsapp.service.CardService;
import java.util.Map;

@RestController
@RequestMapping("/cards")
public class CardController extends BaseController{
    private final CardService cardService;
    CardController(CardService cardService) {
        this.cardService = cardService;
    }

    @GetMapping
    public ResponseEntity<List<FlashCard>> getAllCards(@RequestParam Optional<Integer> pageSize, @RequestParam Optional<Integer> pageNumber) {
        Integer thePageSize = pageSize.orElse(DEFAULT_PAGE_SIZE);
        Integer thePageNumber = pageNumber.orElse(DEFAULT_PAGE_NUMBER);
        log.info("Getting all cards with pageSize {} and pageNumber {}", thePageSize, thePageNumber);
        Page<FlashCard> cardPage = cardService.findAllCards(thePageSize, thePageNumber);
        return new ResponseEntity<>(cardPage.getContent(), HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Optional<FlashCard>> getCard(@PathVariable String id) {
        log.info("Getting card with id {}", id);
        return new ResponseEntity<>(cardService.cardById(id), HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<FlashCard> createCard(@RequestBody FlashCard card) {
        FlashCard newCard = new FlashCard(card.frontText(), card.backText(), card.parentDeckId());
        log.info("Creating card with name {}", card.frontText());
        return new ResponseEntity<>(cardService.createCard(newCard), HttpStatus.CREATED);
    }

    @GetMapping("/search")
    public ResponseEntity<List<FlashCard>> searchCards(@RequestParam Optional<String> term) {
        log.info("Searching cards with term {}", term);
        String theTerm = term.orElse("");
        return new ResponseEntity<>(cardService.searchCards(theTerm), HttpStatus.OK);
    }

   @DeleteMapping("/{id}")
   public ResponseEntity<Void> deleteCard(@PathVariable String id) {
        log.info("Deleting card with id {}", id);
        cardService.deleteCard(id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
   }
}

最后是常量类:

$ java
package com.mongodb.nimongo.srsapp.web.controller;

public class Constants {
    public static final int DEFAULT_PAGE_SIZE = 10;
    public static final int DEFAULT_PAGE_NUMBER = 0;
}

后续步骤

在本文中,我们完成了大量工作:构建了一个将数据存储在 MongoDB 中的 Spring Boot API,并对其进行了测试。在本文的第二部分,我们将引入一个间隔重复系统 (Spaced Repetition System) 库,并添加几个端点来实现实际的复习功能,同时还会进行模式变更。敬请期待!