构建 Node.js/TypeScript REST API,第 2 部分:模型、中间件和服务
已发表: 2022-03-11在我们 REST API 系列的第一篇文章中,我们介绍了如何使用 npm 从头开始创建后端、添加 TypeScript 等依赖项、使用 Node.js 中内置的debug模块、构建 Express.js 项目结构以及记录运行时与温斯顿一起灵活地举办活动。 如果您已经对这些概念感到满意,只需克隆它,使用git checkout切换到toptal-article-01分支,然后继续阅读。
REST API 服务、中间件、控制器和模型
正如所承诺的,我们现在将详细了解这些模块:
- 通过将业务逻辑操作封装到中间件和控制器可以调用的函数中,使我们的代码更简洁的服务。
- 在 Express.js 调用适当的控制器函数之前验证先决条件的中间件。
- 在最终向请求者发送响应之前使用服务处理请求的控制器。
- 描述我们的数据并帮助编译时检查的模型。
我们还将添加一个非常不适合生产的非常基本的数据库。 (它的唯一目的是使本教程更易于理解,为我们的下一篇文章深入研究数据库连接以及与 MongoDB 和 Mongoose 的集成铺平道路。)
动手实践:DAO、DTO 和我们的临时数据库的第一步
对于我们教程的这一部分,我们的数据库甚至不会使用文件。 它只会将用户数据保存在一个数组中,这意味着只要我们退出 Node.js,数据就会消失。 它将仅支持最基本的创建、读取、更新和删除(CRUD) 操作。
我们将在这里使用两个概念:
- 数据访问对象 (DAO)
- 数据传输对象 (DTO)
首字母缩写词之间的一个字母差异是必不可少的:DAO 负责连接到定义的数据库并执行 CRUD 操作; DTO 是一个对象,它保存 DAO 将发送到数据库以及从数据库接收的原始数据。
换句话说,DTO 是符合数据模型类型的对象,而 DAO 是使用它们的服务。
虽然 DTO 可能会变得更复杂(例如,表示嵌套的数据库实体),但在本文中,单个 DTO 实例将对应于单个数据库行上的特定操作。
为什么是 DTO?
使用 DTO 使我们的 TypeScript 对象符合我们的数据模型有助于保持架构的一致性,正如我们将在下面的服务部分中看到的那样。 但是有一个重要的警告:DTO 和 TypeScript 本身都不承诺任何类型的自动用户输入验证,因为这必须在运行时发生。 当我们的代码在 API 的端点接收用户输入时,该输入可能:
- 有额外的字段
- 缺少必填字段(即那些不带
?后缀的字段) - 具有数据不是我们使用 TypeScript 在模型中指定的类型的字段
TypeScript(以及它转译成的 JavaScript)不会为我们神奇地检查这一点,因此不要忘记这些验证非常重要,尤其是在向公众开放 API 时。 像 ajv 这样的包可以帮助解决这个问题,但通常通过在特定于库的模式对象而不是本机 TypeScript 中定义模型来工作。 (在下一篇文章中讨论的 Mongoose 将在这个项目中扮演类似的角色。)
你可能会想,“真的最好同时使用 DAO 和 DTO,而不是更简单的东西吗?” 企业开发者 Gunther Popp 给出了答案; 您将希望在大多数较小的现实世界 Express.js/TypeScript 项目中避免 DTO,除非您可以合理地期望在中期进行扩展。
但是,即使您不打算在生产中使用它们,这个示例项目也是掌握 TypeScript API 架构的一个有价值的机会。 这是练习以其他方式利用 TypeScript 类型并使用 DTO 来查看它们在添加组件和模型时与更基本的方法相比如何的好方法。
我们在 TypeScript 级别的用户 REST API 模型
首先,我们将为我们的用户定义三个 DTO。 让我们在users文件夹中创建一个名为dto的文件夹,并在那里创建一个名为create.user.dto.ts的文件,其中包含以下内容:
export interface CreateUserDto { id: string; email: string; password: string; firstName?: string; lastName?: string; permissionLevel?: number; }我们是说每次创建用户时,无论数据库如何,它都应该有一个 ID、密码和电子邮件,以及可选的名字和姓氏。 这些需求可以根据给定项目的业务需求而改变。
对于PUT请求,我们想要更新整个对象,因此现在需要我们的可选字段。 在同一文件夹中,使用以下代码创建一个名为put.user.dto.ts的文件:
export interface PutUserDto { id: string; email: string; password: string; firstName: string; lastName: string; permissionLevel: number; } 对于PATCH请求,我们可以使用 TypeScript 的Partial功能,它通过复制另一个类型并使其所有字段可选来创建新类型。 这样,文件patch.user.dto.ts只需要包含以下代码:
import { PutUserDto } from './put.user.dto'; export interface PatchUserDto extends Partial<PutUserDto> {} 现在,让我们创建内存中的临时数据库。 让我们在users文件夹中创建一个名为daos的文件夹,并添加一个名为users.dao.ts的文件。
首先,我们要导入我们创建的 DTO:
import { CreateUserDto } from '../dto/create.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; import { PutUserDto } from '../dto/put.user.dto';现在,为了处理我们的用户 ID,让我们添加 shortid 库(使用终端):
npm i shortid npm i --save-dev @types/shortid 回到users.dao.ts ,我们将导入 shortid:
import shortid from 'shortid'; import debug from 'debug'; const log: debug.IDebugger = debug('app:in-memory-dao'); 我们现在可以创建一个名为UsersDao的类,如下所示:
class UsersDao { users: Array<CreateUserDto> = []; constructor() { log('Created new instance of UsersDao'); } } export default new UsersDao(); 使用单例模式,当我们将它导入其他文件时,这个类将始终提供相同的实例——而且至关重要的是,相同的users数组。 这是因为 Node.js 会在任何导入的位置缓存此文件,并且所有导入都发生在启动时。 也就是说,任何引用users.dao.ts的文件都将被传递给 Node.js 第一次处理此文件时导出的同一个new UsersDao()的引用。
当我们在本文中进一步使用这个类时,我们将看到它的工作原理,并将这种常见的 TypeScript/Express.js 模式用于整个项目中的大多数类。
注意:单例的一个经常被引用的缺点是它们很难编写单元测试。 在我们的许多类的情况下,这个缺点将不适用,因为没有任何需要重置的类成员变量。 但是对于那些需要的人,我们将其作为练习留给读者考虑使用依赖注入来解决这个问题。
现在我们要将基本的 CRUD 操作作为函数添加到类中。 创建函数将如下所示:
async addUser(user: CreateUserDto) { user.id = shortid.generate(); this.users.push(user); return user.id; }读取将有两种形式,“读取所有资源”和“按 ID 读取一个”。 它们的编码如下:
async getUsers() { return this.users; } async getUserById(userId: string) { return this.users.find((user: { id: string }) => user.id === userId); } 同样,更新将意味着覆盖整个对象(作为PUT )或仅覆盖对象的一部分(作为PATCH ):
async putUserById(userId: string, user: PutUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1, user); return `${user.id} updated via put`; } async patchUserById(userId: string, user: PatchUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); let currentUser = this.users[objIndex]; const allowedPatchFields = [ 'password', 'firstName', 'lastName', 'permissionLevel', ]; for (let field of allowedPatchFields) { if (field in user) { // @ts-ignore currentUser[field] = user[field]; } } this.users.splice(objIndex, 1, currentUser); return `${user.id} patched`; } 如前所述,尽管我们在这些函数签名中声明了UserDto ,但 TypeScript 不提供运行时类型检查。 这意味着:
-
putUserById()有一个错误。 它将允许 API 使用者存储不属于我们的 DTO 定义的模型的字段的值。 -
patchUserById()依赖于必须与模型保持同步的字段名称的重复列表。 如果没有这个,它将不得不使用正在为这个列表更新的对象。 这意味着它将默默地忽略作为 DTO 定义模型的一部分但之前没有为该特定对象实例保存的字段的值。
但是这两种情况都将在下一篇文章中在数据库级别正确处理。
最后一个操作是删除资源,如下所示:
async removeUserById(userId: string) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1); return `${userId} removed`; }作为奖励,知道创建用户的先决条件是验证用户电子邮件是否不重复,现在让我们添加一个“通过电子邮件获取用户”功能:
async getUserByEmail(email: string) { const objIndex = this.users.findIndex( (obj: { email: string }) => obj.email === email ); let currentUser = this.users[objIndex]; if (currentUser) { return currentUser; } else { return null; } }注意:在实际场景中,您可能会使用预先存在的库(例如 Mongoose 或 Sequelize)连接到数据库,这将抽象您可能需要的所有基本操作。 因此,我们不会详细介绍上面实现的功能。
我们的 REST API 服务层
现在我们有了一个基本的内存 DAO,我们可以创建一个调用 CRUD 函数的服务。 由于 CRUD 函数是每个连接到数据库的服务都需要具备的,因此我们将创建一个CRUD接口,其中包含每次我们想要实现新服务时想要实现的方法。
如今,我们使用的 IDE 具有代码生成功能,可以添加我们正在实现的功能,从而减少我们需要编写的重复代码量。
使用 WebStorm IDE 的快速示例:
IDE 突出显示 MyService 类名称并建议以下选项:
“实现所有成员”选项立即搭建了符合CRUD接口所需的功能:
说了这么多,让我们首先创建我们的 TypeScript 接口,称为CRUD 。 在我们的common文件夹中,让我们创建一个名为interfaces的文件夹并添加crud.interface.ts并添加以下内容:
export interface CRUD { list: (limit: number, page: number) => Promise<any>; create: (resource: any) => Promise<any>; putById: (id: string, resource: any) => Promise<string>; readById: (id: string) => Promise<any>; deleteById: (id: string) => Promise<string>; patchById: (id: string, resource: any) => Promise<string>; } 完成后,让我们在users文件夹中创建一个services文件夹,并在其中添加users.service.ts文件,其中包含:
import UsersDao from '../daos/users.dao'; import { CRUD } from '../../common/interfaces/crud.interface'; import { CreateUserDto } from '../dto/create.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; class UsersService implements CRUD { async create(resource: CreateUserDto) { return UsersDao.addUser(resource); } async deleteById(id: string) { return UsersDao.removeUserById(id); } async list(limit: number, page: number) { return UsersDao.getUsers(); } async patchById(id: string, resource: PatchUserDto) { return UsersDao.patchUserById(id, resource); } async readById(id: string) { return UsersDao.getUserById(id); } async putById(id: string, resource: PutUserDto) { return UsersDao.putUserById(id, resource); } async getUserByEmail(email: string) { return UsersDao.getUserByEmail(email); } } export default new UsersService(); 我们的第一步是导入内存中的 DAO、接口依赖项和每个 DTO 的 TypeScript 类型,是时候将UsersService实现为服务单例了,这与我们在 DAO 中使用的模式相同。
现在所有的CRUD函数都只是调用UsersDao的相应函数。 当需要替换 DAO 时,我们不必在项目中的其他任何地方进行更改,除了对调用 DAO 函数的这个文件进行一些调整,正如我们将在第 3 部分中看到的那样。
例如,我们不必跟踪对list()的每个调用并在替换它之前检查它的上下文。 这就是拥有这一层分离的优势,代价是您在上面看到的少量初始样板。
异步/等待和 Node.js
我们对服务功能使用async似乎毫无意义。 现在,它是:所有这些函数只是立即返回它们的值,没有任何内部使用Promise或await 。 这仅是为将使用async的服务准备我们的代码库。 同样,在下面,您将看到对这些函数的所有调用都使用await 。
在本文结束时,您将再次拥有一个可运行的项目来进行试验。 那将是尝试在代码库的不同位置添加各种类型的错误并查看编译和测试期间发生的情况的绝佳时机。 特别是async上下文中的错误可能与您预期的不太一样。 值得挖掘和探索各种解决方案,这超出了本文的范围。
现在,准备好 DAO 和服务,让我们回到用户控制器。
构建我们的 REST API 控制器
正如我们上面所说,控制器背后的想法是将路由配置与最终处理路由请求的代码分开。 这意味着所有的验证都应该在我们的请求到达控制器之前完成。 控制器只需要知道如何处理实际请求,因为如果请求达到了那么远,那么我们就知道它是有效的。 然后控制器将调用它将处理的每个请求的相应服务。
在开始之前,我们需要安装一个库来安全地散列用户密码:
npm i argon2 让我们首先在users控制器文件夹中创建一个名为controllers的文件夹,并在其中创建一个名为users.controller.ts的文件:
// we import express to add types to the request/response objects from our controller functions import express from 'express'; // we import our newly created user services import usersService from '../services/users.service'; // we import the argon2 library for password hashing import argon2 from 'argon2'; // we use debug with a custom context as described in Part 1 import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersController { async listUsers(req: express.Request, res: express.Response) { const users = await usersService.list(100, 0); res.status(200).send(users); } async getUserById(req: express.Request, res: express.Response) { const user = await usersService.readById(req.body.id); res.status(200).send(user); } async createUser(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); const userId = await usersService.create(req.body); res.status(201).send({ id: userId }); } async patch(req: express.Request, res: express.Response) { if (req.body.password) { req.body.password = await argon2.hash(req.body.password); } log(await usersService.patchById(req.body.id, req.body)); res.status(204).send(); } async put(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); log(await usersService.putById(req.body.id, req.body)); res.status(204).send(); } async removeUser(req: express.Request, res: express.Response) { log(await usersService.deleteById(req.body.id)); res.status(204).send(); } } export default new UsersController(); 注意:上面的行不发送任何内容并返回HTTP 204 No Content响应与该主题的 RFC 7231 一致。

完成我们的用户控制器单例后,我们准备编写依赖于示例 REST API 对象模型和服务的另一个模块:我们的用户中间件。
带有 Express.js 的 Node.js REST 中间件
我们可以用 Express.js 中间件做什么? 对于一个人来说,验证非常适合。 让我们添加一些基本的验证来充当请求的看门人,然后再将请求发送到我们的用户控制器:
- 确保存在创建或更新用户所需的用户字段,例如
email和password - 确保给定的电子邮件尚未使用
- 检查我们在创建后没有更改
email字段(因为为简单起见,我们将其用作面向用户的主要 ID) - 验证给定用户是否存在
为了使这些验证与 Express.js 一起使用,我们需要将它们转换为遵循 Express.js 使用next()的流控制模式的函数,如上一篇文章中所述。 我们需要一个新文件users/middleware/users.middleware.ts :
import express from 'express'; import userService from '../services/users.service'; import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersMiddleware { } export default new UsersMiddleware();使用熟悉的单例样板,让我们将一些中间件函数添加到类主体中:
async validateRequiredUserBodyFields( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.email && req.body.password) { next(); } else { res.status(400).send({ error: `Missing required fields email and password`, }); } } async validateSameEmailDoesntExist( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user) { res.status(400).send({ error: `User email already exists` }); } else { next(); } } async validateSameEmailBelongToSameUser( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user && user.id === req.params.userId) { next(); } else { res.status(400).send({ error: `Invalid email` }); } } // Here we need to use an arrow function to bind `this` correctly validatePatchEmail = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { if (req.body.email) { log('Validating email', req.body.email); this.validateSameEmailBelongToSameUser(req, res, next); } else { next(); } }; async validateUserExists( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.readById(req.params.userId); if (user) { next(); } else { res.status(404).send({ error: `User ${req.params.userId} not found`, }); } } 为了让我们的 API 使用者更容易对新添加的用户发出进一步的请求,我们将添加一个辅助函数,该函数将从请求参数中提取userId (来自请求 URL 本身)并将其添加到请求正文,其余用户数据所在的位置。
这里的想法是,当我们想更新用户信息时,能够简单地使用完整的请求,而不必担心每次都从参数中获取 ID。 相反,它只在一个地方处理,即中间件。 该函数将如下所示:
async extractUserId( req: express.Request, res: express.Response, next: express.NextFunction ) { req.body.id = req.params.userId; next(); } 除了逻辑之外,中间件和控制器之间的主要区别在于,现在我们使用next()函数沿着一系列配置的函数传递控制权,直到它到达最终目的地,在我们的例子中是控制器。
放在一起:重构我们的路线
现在我们已经实现了项目架构的所有新方面,让我们回到我们在上一篇文章中定义的users.routes.config.ts文件。 它将调用我们的中间件和控制器,它们都依赖于我们的用户服务,而这又需要我们的用户模型。
最终文件将像这样简单:
import { CommonRoutesConfig } from '../common/common.routes.config'; import UsersController from './controllers/users.controller'; import UsersMiddleware from './middleware/users.middleware'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes(): express.Application { this.app .route(`/users`) .get(UsersController.listUsers) .post( UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailDoesntExist, UsersController.createUser ); this.app.param(`userId`, UsersMiddleware.extractUserId); this.app .route(`/users/:userId`) .all(UsersMiddleware.validateUserExists) .get(UsersController.getUserById) .delete(UsersController.removeUser); this.app.put(`/users/:userId`, [ UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailBelongToSameUser, UsersController.put, ]); this.app.patch(`/users/:userId`, [ UsersMiddleware.validatePatchEmail, UsersController.patch, ]); return this.app; } } 在这里,我们通过添加中间件来验证我们的业务逻辑和适当的控制器函数来重新定义我们的路由,以在一切都有效的情况下处理请求。 我们还使用了 Express.js 中的.param()函数来提取userId 。
在.all()函数中,我们从UsersMiddleware传递我们的validateUserExists函数,以便在任何GET 、 PUT 、 PATCH或DELETE可以通过端点/users/:userId之前调用。 这意味着validateUserExists不需要在我们传递给.put()或.patch() () 的附加函数数组中——它将在此处指定的函数之前被调用。
我们也以另一种方式利用了中间件固有的可重用性。 通过传递将在POST和PUT上下文中使用的UsersMiddleware.validateRequiredUserBodyFields ,我们可以优雅地将其与其他中间件函数重新组合。
免责声明:我们仅在本文中介绍基本验证。 在现实世界的项目中,您需要考虑并找到编码所需的所有限制。 为了简单起见,我们还假设用户不能更改他们的电子邮件。
测试我们的 Express/TypeScript REST API
我们现在可以编译并运行我们的 Node.js 应用程序。 一旦它运行起来,我们就可以使用 REST 客户端(例如 Postman 或 cURL)来测试我们的 API 路由。
让我们首先尝试获取我们的用户:
curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'此时,我们将有一个空数组作为响应,这是准确的。 现在我们可以尝试用这个创建第一个用户资源:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json'请注意,现在我们的 Node.js 应用程序将从我们的中间件发回一个错误:
{ "error": "Missing required fields email and password" } 为了解决这个问题,让我们发送一个有效的请求来发布到/users资源:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23" }'这一次,我们应该看到如下内容:
{ "id": "ksVnfnPVW" } 此id是新创建用户的标识符,在您的机器上会有所不同。 为了使剩余的测试语句更容易,您可以使用您获得的命令运行此命令(假设您使用的是类似 Linux 的环境):
REST_API_EXAMPLE_ 我们现在可以看到使用上述变量发出GET请求得到的响应:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' 我们现在还可以使用以下PUT请求更新整个资源:
curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23", "firstName": "Marcos", "lastName": "Silva", "permissionLevel": 8 }'我们还可以通过更改电子邮件地址来测试我们的验证是否有效,这应该会导致错误。
请注意,当使用PUT到资源 ID 时,作为 API 使用者,如果我们想要符合标准 REST 模式,则需要发送整个对象。 这意味着如果我们只想更新lastName字段,但使用我们的PUT端点,我们将被迫发送要更新的整个对象。 使用PATCH请求会更容易,因为它仍然在标准 REST 约束范围内,只发送lastName字段:
curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "lastName": "Faraco" }' 回想一下,在我们自己的代码库中,使用我们在本文中添加的中间件函数强制区分PUT和PATCH的是我们的路由配置。
PUT 、 PATCH或两者兼而有之?
鉴于PATCH的灵活性,听起来似乎没有太多理由支持PUT ,并且一些 API 将采用这种方法。 其他人可能坚持支持PUT以使 API “完全符合 REST”,在这种情况下,创建每个字段的PUT路由可能是常见用例的适当策略。
实际上,这些观点是更大讨论的一部分,范围从两者之间的现实差异到更灵活的PATCH语义。 为了简单起见,我们在此处提供PUT支持和广泛使用的PATCH语义,但鼓励读者在准备好时深入研究。
像上面一样再次获取用户列表,我们应该会看到我们创建的用户的字段已更新:
[ { "id": "ksVnfnPVW", "email": "[email protected]", "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM", "firstName": "Marcos", "lastName": "Faraco", "permissionLevel": 8 } ]最后,我们可以用这个来测试删除用户:
curl --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'再次获取用户列表,我们应该看到已删除的用户不再存在。
这样,我们就可以为users资源工作的所有 CRUD 操作。
Node.js/TypeScript REST API
在本系列的这一部分中,我们进一步探索了使用 Express.js 构建 REST API 的关键步骤。 我们拆分代码以支持服务、中间件、控制器和模型。 它们的每个功能都有特定的作用,无论是验证、逻辑操作,还是处理有效请求并响应它们。
我们还创建了一种非常简单的数据存储方法,其目的是(请原谅双关语)此时允许进行一些测试,然后在我们系列的下一部分中替换为更实用的东西。
除了以简单的方式构建 API(例如,使用单例类)之外,还需要采取几个步骤来使其更易于维护、更具可扩展性和安全性。 在本系列的最后一篇文章中,我们将介绍:
- 用MongoDB替换内存数据库,然后使用Mongoose简化编码过程
- 使用 JWT 以无状态方法添加安全层和控制访问
- 配置自动化测试以允许我们的应用程序扩展
您可以在此处浏览本文的最终代码。
