欢迎阅读本系列关于使用 NestJS、Prisma 和 PostgreSQL 构建 REST API 的第四篇教程!在本教程中,您将学习如何在 NestJS REST API 中处理关系型数据。

目录
引言
在本系列的 第一章 中,您创建了一个新的 NestJS 项目,并将其与 Prisma、PostgreSQL 和 Swagger 集成。然后,您构建了一个用于博客应用程序后端的初级 REST API。在 第二章 中,您学习了如何进行输入验证和转换。
在本章中,您将学习如何在数据层和 API 层处理关系型数据。
- 首先,您将向数据库模式添加一个
User
模型,该模型将与Article
记录建立一对多关系(即一个用户可以拥有多篇文章)。 - 接下来,您将为
User
端点实现 API 路由,以对User
记录执行 CRUD(创建、读取、更新和删除)操作。 - 最后,您将学习如何在 API 层中对
User-Article
关系进行建模。
在本教程中,您将使用在 第二章 中构建的 REST API。
开发环境
要跟着本教程进行操作,您需要
- ... 安装 Node.js。
- ... 安装 Docker 和 Docker Compose。如果您使用的是 Linux,请确保您的 Docker 版本是 20.10.0 或更高。您可以通过在终端中运行
docker version
来检查您的 Docker 版本。 - ... *可选* 安装 Prisma VS Code Extension。Prisma VS Code Extension 为 Prisma 添加了非常好的智能感知(IntelliSense)和语法高亮功能。
- ... *可选* 使用 Unix shell(例如 Linux 和 macOS 中的终端/shell)来运行本系列中提供的命令。
如果您没有 Unix shell(例如,您使用的是 Windows 机器),您仍然可以跟着操作,但 shell 命令可能需要针对您的机器进行修改。
克隆仓库
本教程的起点是本系列 第二章 的结束部分。它包含一个使用 NestJS 构建的初级 REST API。
本教程的起点位于 GitHub 仓库 的 end-validation
分支中。要开始,请克隆仓库并切换到 end-validation
分支
现在,执行以下操作来开始
- 导航到克隆的目录
- 安装依赖项
- 使用 Docker 启动 PostgreSQL 数据库
- 应用数据库迁移
- 启动项目
注意:第 4 步还将生成 Prisma Client 并为数据库填充种子数据。
现在,您应该能够通过 http://localhost:3000/api/
访问 API 文档。
项目结构和文件
您克隆的仓库应具有以下结构
注意:您可能会注意到此文件夹还包含一个
test
目录。本教程不涉及测试。但是,如果您想了解使用 Prisma 测试应用程序的最佳实践,请务必查阅本教程系列:使用 Prisma 进行测试的终极指南
此仓库中值得注意的文件和目录有
src
目录包含应用程序的源代码。共有三个模块app
模块位于src
目录的根部,是应用程序的入口点。它负责启动 Web 服务器。prisma
模块包含 Prisma Client,它是您与数据库的接口。articles
模块定义了/articles
路由的端点和相应的业务逻辑。
prisma
文件夹包含以下内容schema.prisma
文件定义了数据库模式。migrations
目录包含数据库迁移历史记录。seed.ts
文件包含一个用于为您的开发数据库填充模拟数据的脚本。
docker-compose.yml
文件定义了您的 PostgreSQL 数据库的 Docker 镜像。.env
文件包含您的 PostgreSQL 数据库的连接字符串。
注意:有关这些组件的更多信息,请阅读本教程系列的 第一章。
向数据库添加一个 User
模型
目前,您的数据库模式只有一个模型:Article
。文章可以由注册用户撰写。因此,您将向数据库模式添加一个 User
模型来反映这种关系。
首先更新您的 Prisma schema
User
模型有一些您可能期望的字段,例如 id
、email
、password
等。它还与 Article
模型具有一对多关系。这意味着一个用户可以拥有多篇文章,但一篇文章只能有一个作者。为了简化,author
关系被设为可选,因此仍然可以在没有作者的情况下创建文章。
现在,要将更改应用到数据库,运行迁移命令
如果迁移成功运行,您应该会看到以下输出
更新种子脚本
种子脚本负责使用模拟数据填充数据库。您将更新种子脚本,在数据库中创建一些用户。
打开 prisma/seed.ts
文件并更新如下
种子脚本现在创建了两个用户和三篇文章。第一篇文章由第一个用户撰写,第二篇文章由第二个用户撰写,第三篇文章没有作者。
注意:目前,您正在以纯文本形式存储密码。在实际应用中,您绝对不应该这样做。您将在下一章中学习有关密码加盐和哈希的更多信息。
要执行种子脚本,请运行以下命令
如果种子脚本成功运行,您应该会看到以下输出
向 ArticleEntity
添加一个 authorId
字段
运行迁移后,您可能注意到一个新的 TypeScript 错误。ArticleEntity
类 implements
(实现)了由 Prisma 生成的 Article
类型。Article
类型有一个新的 authorId
字段,但 ArticleEntity
类没有定义该字段。TypeScript 识别出这种类型不匹配并抛出了错误。您将通过向 ArticleEntity
类添加 authorId
字段来修复此错误。
在 ArticleEntity
内部添加一个新的 authorId
字段
在像 JavaScript 这样的弱类型语言中,您必须自己识别并修复类似问题。拥有像 TypeScript 这样的强类型语言的一大优势在于,它可以快速帮助您发现与类型相关的问题。
为 Users
实现 CRUD 端点
在本节中,您将在 REST API 中实现 /users
资源。这将允许您对数据库中的用户执行 CRUD 操作。
注意:本节内容与本系列第一章中的 实现 Article 模型的 CRUD 操作 部分内容相似。该部分更深入地探讨了该主题,您可以阅读它以获得更好的概念理解。
生成新的 users
REST 资源
要为 users
生成新的 REST 资源,请运行以下命令
您将看到一些 CLI 提示。请相应地回答问题
您想为此资源使用什么名称(复数,例如,“users”)?
users您使用什么传输层?
REST API您要生成 CRUD 入口点吗?
Yes
您现在应该能在 src/users
目录中找到一个新的 users
模块,其中包含您的 REST 端点的所有样板代码。
在 src/users/users.controller.ts
文件中,您将看到不同路由(也称为路由处理程序)的定义。处理每个请求的业务逻辑封装在 src/users/users.service.ts
文件中。
如果您打开 Swagger 生成的 API 页面,您应该会看到类似以下内容
将 PrismaClient
添加到 Users
模块
要在 Users
模块中访问 PrismaClient
,您必须将 PrismaModule
作为导入添加到其中。将以下 imports
添加到 UsersModule
您现在可以在 UsersService
中注入 PrismaService
并使用它来访问数据库。为此,请在 users.service.ts
中添加一个构造函数,如下所示
定义 User
实体和 DTO 类
就像 ArticleEntity
一样,您将定义一个 UserEntity
类,用于在 API 层表示 User
实体。在 user.entity.ts
文件中定义 UserEntity
类,如下所示
@ApiProperty
装饰器用于使属性对 Swagger 可见。请注意,您没有将 @ApiProperty
装饰器添加到 password
字段。这是因为此字段是敏感信息,您不希望在 API 中暴露它。
注意:省略
@ApiProperty
装饰器只会从 Swagger 文档中隐藏password
属性。该属性仍将在响应体中可见。您将在后面的章节中处理此问题。
DTO(Data Transfer Object)是一个定义数据如何在网络上传输的对象。您需要实现 CreateUserDto
和 UpdateUserDto
类,分别定义在创建和更新用户时发送到 API 的数据。在 create-user.dto.ts
文件中定义 CreateUserDto
类,如下所示
@IsString
、@MinLength
和 @IsNotEmpty
是用于验证发送到 API 的数据的验证装饰器(decorator)。验证在本系列 第二章 中有更详细的介绍。
UpdateUserDto
的定义是从 CreateUserDto
定义自动推断出来的,因此无需显式定义。
定义 UsersService
类
UsersService
负责使用 Prisma Client 修改和获取数据库中的数据,并将其提供给 UsersController
。您将在此类中实现 create()
、findAll()
、findOne()
、update()
和 remove()
方法。
定义 UsersController
类
UsersController
负责处理对 users
端点的请求和响应。它将利用 UsersService
访问数据库,使用 UserEntity
定义响应体,并使用 CreateUserDto
和 UpdateUserDto
定义请求体。
控制器包含不同的路由处理程序。您将在此类中实现五个路由处理程序,对应五个端点
create()
-POST /users
findAll()
-GET /users
findOne()
-GET /users/:id
update()
-PATCH /users/:id
remove()
-DELETE /users/:id
更新 users.controller.ts
中这些路由处理程序的实现,如下所示
更新后的控制器使用 @ApiTags
装饰器将端点分组到 users
标签下。它还使用 @ApiCreatedResponse
和 @ApiOkResponse
装饰器来定义每个端点的响应体。
更新后的 Swagger API 页面 应该如下所示
您可以随意测试不同的端点,以验证它们是否按预期工作。
从响应体中排除 password
字段
虽然 users
API 按预期工作,但它存在一个主要的安全缺陷。password
字段在不同端点的响应体中返回。
您有两种方法来解决此问题
- 在控制器路由处理程序中手动从响应体中移除密码
- 使用 拦截器(interceptor) 自动从响应体中移除密码
第一种方法容易出错,并导致不必要的代码重复。因此,您将使用第二种方法。
使用 ClassSerializerInterceptor
从响应中移除字段
NestJS 中的 拦截器(Interceptor) 允许您介入请求-响应生命周期,并在路由处理程序执行之前和之后执行额外逻辑。在本例中,您将使用它从响应体中移除 password
字段。
NestJS 内置了 ClassSerializerInterceptor
,可用于转换对象。您将使用此拦截器从响应对象中移除 password
字段。
首先,通过更新 main.ts
文件来全局启用 ClassSerializerInterceptor
注意: 也可以将拦截器绑定到方法或控制器,而不是全局绑定。您可以在 NestJS 文档 中阅读更多信息。
ClassSerializerInterceptor
使用 class-transformer
包来定义如何转换对象。使用 @Exclude()
装饰器在 UserEntity
类中排除 password
字段
如果您再次尝试使用 GET /users/:id
端点,您会注意到 password
字段仍然暴露在外 🤔。这是因为,目前控制器中的路由处理程序返回的是 Prisma Client 生成的 User
类型。ClassSerializerInterceptor
只对使用 @Exclude()
装饰器装饰的类有效。在这种情况下,它是 UserEntity
类。因此,您需要更新路由处理程序以返回 UserEntity
类型。
首先,您需要创建一个构造函数来实例化一个 UserEntity
对象。
构造函数接受一个对象,并使用 Object.assign()
方法将 partial
对象的属性复制到 UserEntity
实例中。partial
的类型是 Partial<UserEntity>
。这意味着 partial
对象可以包含 UserEntity
类中定义的任何属性子集。
接下来,更新 UsersController
路由处理程序,使其返回 UserEntity
而不是 Prisma.User
对象
现在,密码应该从响应对象中被省略了。
与文章一起返回作者信息
在 第一章 中,您实现了 GET /articles/:id
端点,用于检索单篇文章。目前,此端点不返回文章的 author
(作者)信息,只返回 authorId
(作者 ID)。为了获取 author
信息,您必须向 GET /users/:id
端点发出额外的请求。如果您需要文章及其作者信息,这并不理想,因为您需要发出两个 API 请求。您可以通过与 Article
对象一起返回 author
信息来改进这一点。
数据访问逻辑在 ArticlesService
内部实现。更新 findOne()
方法,使其与 Article
对象一起返回 author
信息
如果您测试 GET /articles/:id
端点,您会注意到文章的作者信息(如果存在)包含在响应对象中。然而,这里有一个问题。password
字段又暴露出来了 🤦。
这个问题的原因与上次非常相似。目前,ArticlesController
返回的是 Prisma 生成的类型实例,而 ClassSerializerInterceptor
则与 UserEntity
类协同工作。为了解决这个问题,您需要更新 ArticleEntity
类的实现,并确保它使用 UserEntity
的实例来初始化 author
属性。
再次,您使用了 Object.assign()
方法将 data
对象中的属性复制到 ArticleEntity
实例。如果存在 author
属性,则将其初始化为 UserEntity
的实例。
现在更新 ArticlesController
以返回 ArticleEntity
对象的实例。
现在,GET /articles/:id
返回的 author
对象将不包含 password
字段。
总结与最终说明
在本章中,您学习了如何使用 Prisma 在 NestJS 应用程序中建模关系数据。您还了解了 ClassSerializerInterceptor
以及如何使用实体类来控制返回给客户端的数据。
您可以在 GitHub 仓库的 end-relational-data
分支找到本教程的完成代码。如果您发现问题,请随时在仓库中提出 issue 或提交 PR。您也可以直接在 Twitter 上与我联系。
不要错过下一篇文章!
订阅 Prisma 新闻通讯