语言学习卡片系统开发指南:第一部分
目录
- 完整代码示例
- 使用 Spring Initializr 创建项目
- 添加 MongoDB 连接 URI
- 我们的逻辑模型
- 我们的 MongoDB 模式
- 我们的模型类
- 改善开发周期
- 我们的第一个 API 端点
- 为卡组添加空端点
- 更改默认端口
- 测试我们的端点
- 存储库 (Repositories)
- 服务 (Services)
- 连接控制器 (Wiring up our Controllers)
- 后续步骤
我的母语是西班牙语。我一生都在学习(并且糟蹋)英语。但在某个时刻,我对英语感到自信,想去学日语。大错特错。这完全是另一回事:三种书写系统(平假名 ひらがな、片假名 カタカナ 和汉字 漢字)、完全不同的语法,与欧洲语言毫无关联……我需要帮助。我需要学习工具。其中之一就是基于间隔重复系统 (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,所以我混合了 MongoDB 和 Nihongo。是的,如果一个笑话需要解释的话……)。 - 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)。
- Spring Data MongoDB(在项目的
生成项目。它将下载一个名为 srsapp.zip 的文件。解压缩它。在你的系统中打开终端并进入该目录。
在命令行界面(Linux / macOS)中运行应用:
你需要安装 Java SDK 并将其添加到 PATH 中。
它会失败!你会看到类似以下的错误:
这是因为 Spring Boot 正在尝试连接到 MongoDB(因为 MongoDB 是 starter 依赖项的一部分),而我们还没有数据库。让我们通过创建一个免费的 MongoDB Atlas 集群来解决这个问题。你需要 注册一个免费的 Atlas 账户,然后按照说明 部署免费集群。选择 Atlas UI 以通过浏览器创建集群(你也可以使用命令行和 Atlas CLI 创建集群)。
添加 MongoDB 连接 URI
一旦创建了集群,我们就可以 复制连接字符串,并将其添加到 Spring Boot 启动时读取的属性文件中。
MongoDB 连接字符串看起来应该是这样的:
你需要在此处填入你的用户名和密码。
为了避免在代码中硬编码机密信息,我们将连接字符串放在名为 MONGODB_URI 的环境变量中。然后,我们在 src/main/resources/application.yaml 文件中读取它。我们的数据库名称将是 srsapp。
我们希望在应用启动时添加一条错误消息,以警告 URI 缺失。为此,我们要编辑 src/main/java/com/mongodb/nimongo/srsapp/SrsappApplication.java 中的 main 方法:
这里做了几处修改:
- 我们引入了一个 Logger 实例来向控制台打印调试信息。请从
org.slf4j包中导入相关类。 - 然后我们检查是否定义了名为
MONGODB_URI的环境变量。如果没有,我们抛出IllegalArgumentException,并立即捕获它,记录错误并退出应用。
这样,如果我们不定义环境变量直接运行应用,就会得到错误:
要修复此问题,请在启动应用前设置环境变量:
然后再次运行应用程序。
最后,如果我们提供了 URI,应用就会启动:
你应该能在日志中看到来自 org.mongodb.driver.client 的消息。
我们的逻辑模型
从逻辑角度来看,卡片被组织在“卡组 (Decks)”中。一个卡组有一个名称并包含多张卡片。系统中的卡组和卡片数量没有限制。一张卡片有正面文字、背面文字,并属于一个卡组。一张卡片只能属于一个卡组,这是一种一对多的关系:一个卡组可以有 0 到 n 张卡片。我们可以用以下 ERD 表示:
我们的 MongoDB 模式
在对实体进行建模时,我们有多种选择。让我们分析一下,为这个问题找到最佳模式。
选项 1:所有内容放在一个集合中。 我们可以将卡组放在一个集合中,并将卡片定义为每个卡组内部的一个数组。这是建模一对多关系的一种方式,当我们知道“多”端的最大规模时推荐使用。它看起来像这样:
这里的主要问题是,我们可以不断向同一个卡组添加卡片。例如,Jōyō Kanji (常用漢字) 是你预计要学习的“基础”汉字集,共 2136 个。这意味着一个至少包含 2136 个元素的数组。每当你添加、删除或编辑一张卡片时,MongoDB 都会将整个卡组读入内存并进行隐式事务保存。如果很多人同时访问同一个卡组,这会拖慢系统,但主要问题是缺乏边界。该数组可能会无限增长并触及 BSON 对象的 16MB 限制,这就是所谓的 无边界数组反模式 (Unbounded Array Antipattern)。
选项 2:我们将卡组和卡片保留在单独的集合中。 这将避免 无边界数组反模式 问题,我们可以通过以下方式获取卡组的所有卡片:
- 执行两次 find 查询(一次获取卡组,另一次获取该卡组下的所有卡片)。
- 只需一次查询,使用 MongoDB 的聚合管道 (Aggregation Pipeline) 和
$lookup操作符来获取卡组及其所有关联的卡片。
我们将通过 parentDeckId 来维持卡片与卡组之间的链接。值得一提的是,MongoDB 没有外键约束的概念,因此如果你需要在添加卡片时检查卡组是否存在,必须在代码中实现。
我们的卡组将如下所示:
我们的卡片:
我们的模型类
基于上述模式,我们将拥有以下两个模型类:
如你所见,集合名称为 decks,Java 中有一个 String id 字段,它将映射到数据库中的 _id 字段。
我们的闪存卡模型类 FlashCard 将非常相似:
我们将所有内容存储在 cards 集合中,并使用 parentDeckId 作为指向该卡片所属卡组的链接。
改善开发周期
每当我们更改代码时,都需要停止应用程序并重新启动。这很快就会变成一个乏味的过程,而且容易出错。如果我每次修改代码而忘记保存,导致修复没生效而感到困惑时都能得到一分钱,我现在就不会在这里写文章了,而是在豪华游艇上环游世界退休了。
打开你的 pom.xml 文件并添加此依赖项:
现在,停止并最后一次重新启动应用。从现在开始,每当你更改代码,应用都会自动重新编译并重启。
我们的第一个 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.java 和 DeckController.java 文件,如下所示:
BaseController 为我们的控制器添加了一个日志记录器(Logger)。
现在,创建 DeckController:
观察代码,你会发现我们只是在返回空响应。我们希望调用这些端点,确保 Web 层配置正常。
更改默认端口
接下来,我们将默认端口 (8080) 更改为 5400,并添加一些选项以改进调试日志。打开 application.yml 并将其修改为:
测试端点
我们将使用 Linux/macOS 和 Windows 上均可用的 cURL 来测试我们的端点。
在终端中复制并粘贴以下命令,即可查询所有的 Decks:
插入一个新的 Deck:
搜索和删除:
但请注意!上述所有操作目前仅在 DeckController 占位符中运行,并没有与数据库进行任何存储或检索!接下来,让我们通过添加针对 Decks 的 MongoDB 代码来解决这个问题。
Repositories
为了访问 MongoDB,我们将创建一个继承自 MongoRepository 的 DeckRepository 接口。这是访问 MongoDB 最快捷的方式,因为 MongoRepository 包含了多种用于访问集合的实用方法。我们甚至可以在接口中添加自己的方法以实现自定义行为。如果需要更高级或定制化的功能,我们将需要使用 MongoTemplate。
在 repository 文件夹中创建 DeckRepository:
如你所见,我们添加了一个新方法 searchByText,它使用正则表达式来搜索文本(由 ?0 表示),搜索范围为 name 或 description。我们传入 i 选项以执行不区分大小写的比较。使用正则表达式并不是 MongoDB 中搜索文本的最佳方式;通常建议使用 全文检索 (Full Text Search),通过定义搜索索引并使用 $search 来实现。
同时,我们也将创建 CardRepository:
Services
Service 层是我们实际使用数据库代码的地方。我们的 Service 将与数据库交互,执行查询、插入、更新等操作,并向 Web 控制器公开一系列业务层面的操作。现在,在新的 service 文件夹中创建 DeckService.java。在这里,我们将使用 DeckRepository 和 CardRepository 来访问数据库。例如,要通过标识符获取 Deck,我们将使用 findById,它是 CrudRepository 的一部分,在此场景下由我们的 MongoDB 驱动程序实现。
要添加一个 Deck,我们将使用 save:
然后,进行删除操作:
如果需要删除 Deck 中的所有卡片,我们将使用 CardRepository 中的 deleteAllByParentDeckId:
最终的 DeckService 将如下所示:
对于 Cards,我们将以类似的方式使用 CardRepository:
DeckController 的完整更新代码如下:
针对卡片 (Cards) 的控制器:
最后是常量类:
后续步骤
在本文中,我们完成了大量工作:构建了一个将数据存储在 MongoDB 中的 Spring Boot API,并对其进行了测试。在本文的第二部分,我们将引入一个间隔重复系统 (Spaced Repetition System) 库,并添加几个端点来实现实际的复习功能,同时还会进行模式变更。敬请期待!