构建 Node.js/TypeScript REST API,第 1 部分:Express.js

已发表: 2022-03-11

如何在 Node.js 中编写 REST API?

在为 REST API 构建后端时,Express.js 通常是 Node.js 框架中的首选。 虽然它还支持构建静态 HTML 和模板,但在本系列中,我们将专注于使用 TypeScript 进行后端开发。 生成的 REST API 将是任何前端框架或外部后端服务都能够查询的 API。

你需要:

  • JavaScript 和 TypeScript 的基础知识
  • Node.js 基础知识
  • REST 架构的基本知识(如果需要,请参阅我之前的 REST API 文章的这一部分)
  • Node.js 的现成安装(最好是 14+ 版本)

在终端(或命令提示符)中,我们将为项目创建一个文件夹。 从该文件夹中,运行npm init 。 这将创建我们需要的一些基本 Node.js 项目文件。

接下来,我们将添加 Express.js 框架和一些有用的库:

 npm i express debug winston express-winston cors

这些库是 Node.js 开发人员的最爱有充分的理由:

  • debug是一个模块,我们将使用它来避免在开发应用程序时调用console.log() 。 这样,我们可以在故障排除期间轻松过滤调试语句。 它们也可以在生产中完全关闭,而不必手动移除。
  • winston负责记录对我们 API 的请求以及返回的响应(和错误)。 express-winston直接与 Express.js 集成,因此所有标准 API 相关的winston日志记录代码都已经完成。
  • cors是一个 Express.js 中间件,它允许我们启用跨域资源共享。 如果没有这个,我们的 API 将只能从与我们的后端完全相同的子域提供服务的前端使用。

我们的后端在运行时使用这些包。 但是我们还需要为我们的 TypeScript 配置安装一些开发依赖项。 为此,我们将运行:

 npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

这些依赖项是为我们的应用程序自己的代码以及 Express.js 和其他依赖项使用的类型启用 TypeScript 所必需的。 当我们使用像 WebStorm 或 VSCode 这样的 IDE 时,这可以让我们在编码时自动完成一些功能方法,从而节省大量时间。

package.json中的最终依赖项应该是这样的:

 "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }

现在我们已经安装了所有必需的依赖项,让我们开始构建我们自己的代码!

TypeScript REST API 项目结构

对于本教程,我们将只创建三个文件:

  1. ./app.ts
  2. ./common/common.routes.config.ts
  3. ./users/users.routes.config.ts

项目结构的两个文件夹( commonusers )背后的想法是拥有各自负责的各个模块。 从这个意义上说,我们最终将为每个模块提供以下部分或全部内容:

  • 路由配置来定义我们的 API 可以处理的请求
  • 用于连接到我们的数据库模型、进行查询或连接到特定请求所需的外部服务等任务的服务
  • 在路由的最终控制器处理其细节之前运行特定请求验证的中间件
  • 用于定义与给定数据库模式匹配的数据模型的模型,以促进数据存储和检索
  • 控制器,用于将路由配置与最终(在任何中间件之后)处理路由请求的代码分离,必要时调用上述服务函数,并向客户端提供响应

此文件夹结构提供了基本的 REST API 设计,是本教程系列其余部分的早期起点,足以开始练习。

TypeScript 中的常用路由文件

common文件夹中,让我们创建common.routes.config.ts文件,如下所示:

 import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }

我们在这里创建路由的方式是可选的。 但是由于我们使用的是 TypeScript,所以我们的路由场景是一个练习使用带有extends关键字的继承的机会,我们很快就会看到。 在这个项目中,所有路由文件都具有相同的行为:它们有一个名称(我们将用于调试目的)并可以访问主要的 Express.js Application对象。

现在,我们可以开始创建用户路由文件了。 在users文件夹中,让我们创建users.routes.config.ts并开始编写如下代码:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }

在这里,我们导入CommonRoutesConfig类并将其扩展到我们的新类,称为UsersRoutes 。 使用构造函数,我们将应用程序(主要的express.Application对象)和名称 UsersRoutes 发送到CommonRoutesConfig的构造函数。

这个例子很简单,但是在扩展创建多个路由文件时,这将帮助我们避免重复代码。

假设我们想在这个文件中添加新特性,比如日志。 我们可以在CommonRoutesConfig类中添加必要的字段,然后所有扩展CommonRoutesConfig的路由都可以访问它。

使用 TypeScript 抽象函数来实现跨类的类似功能

如果我们希望在这些类之间有一些相似的功能(比如配置 API 端点),但是每个类需要不同的实现怎么办? 一种选择是使用称为抽象的 TypeScript 功能。

让我们创建一个非常简单的抽象函数, UsersRoutes类(以及未来的路由类)将从CommonRoutesConfig继承。 假设我们想强制所有路由都有一个名为configureRoutes() 。 这就是我们将声明每个路由类资源的端点的地方。

为此,我们将在common.routes.config.ts中添加三个快速的东西:

  1. 我们的class行的关键字abstract ,以启用该类的抽象。
  2. 我们类末尾的新函数声明, abstract configureRoutes(): express.Application; . 这会强制任何扩展CommonRoutesConfig的类提供与该签名匹配的实现——如果不匹配,TypeScript 编译器将抛出错误。
  3. 调用this.configureRoutes(); 在构造函数的末尾,因为我们现在可以确定这个函数将存在。

结果:

 import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }

这样,任何扩展CommonRoutesConfig的类都必须有一个名为configureRoutes()的函数,该函数返回一个express.Application对象。 这意味着users.routes.config.ts需要更新:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }

回顾一下我们所做的:

我们首先导入common.routes.config文件,然后是express模块。 然后我们定义UserRoutes类,说我们希望它扩展CommonRoutesConfig基类,这意味着我们承诺它将实现configureRoutes()

为了将信息发送到CommonRoutesConfig类,我们使用该类的constructor 。 它期望接收express.Application对象,我们将在下一步中更深入地描述它。 使用super() ,我们将应用程序和路由的名称传递给CommonRoutesConfig的构造函数,在这种情况下是 UsersRoutes。 ( super()反过来会调用我们的configureRoutes()实现。)

配置用户端点的 Express.js 路由

configureRoutes()函数是我们将为 REST API 的用户创建端点的地方。 在那里,我们将使用 Express.js 中的应用程序及其路由功能。

使用app.route()函数的想法是避免代码重复,这很容易,因为我们正在创建具有明确定义的资源的 REST API。 本教程的主要资源是users 。 在这种情况下,我们有两种情况:

  • 当 API 调用者想要创建一个新用户或列出所有现有用户时,端点最初应该只在请求路径的末尾有users 。 (我们不会在本文中讨论查询过滤、分页或其他此类查询。)
  • 当调用者想要对特定用户记录执行特定操作时,请求的资源路径将遵循模式users/:userId

.route()在 Express.js 中的工作方式让我们可以通过一些优雅的链接来处理 HTTP 动词。 这是因为.get().post()等都返回与第一个.route()调用相同的IRoute实例。 最终的配置将是这样的:

 configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }

上面的代码允许任何 REST API 客户端通过POSTGET请求调用我们的users端点。 同样,它允许客户端使用GETPUTPATCHDELETE请求调用我们的/users/:userId端点。

但是对于/users/:userId ,我们还使用all()函数添加了通用中间件,它将在任何get()put()patch()delete()函数之前运行。 当(在本系列的后面)我们创建仅供经过身份验证的用户访问的路由时,此功能将很有用。

您可能已经注意到,在我们的.all()函数中——与任何中间件一样——我们有三种类型的字段: RequestResponseNextFunction

  • Request 是 Express.js 表示要处理的 HTTP 请求的方式。 此类型升级并扩展了本机 Node.js 请求类型。
  • Response 同样是 Express.js 表示 HTTP 响应的方式,再次扩展了原生 Node.js 响应类型。
  • 同样重要的是, NextFunction用作回调函数,允许控制通过任何其他中间件函数。 在此过程中,所有中间件将共享相同的请求和响应对象,然后控制器最终将响应发送回请求者。

我们的 Node.js 入口点文件app.ts

现在我们已经配置了一些基本的路由骨架,我们将开始配置应用程序的入口点。 让我们在项目文件夹的根目录下创建app.ts文件,并从以下代码开始:

 import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';

在本文的这一点上,这些导入中只有两个是新的:

  • http是一个 Node.js 原生模块。 它是启动我们的 Express.js 应用程序所必需的。
  • body-parser是 Express.js 附带的中间件。 在控制权交给我们自己的请求处理程序之前,它会解析请求(在我们的例子中是 JSON)。

现在我们已经导入了文件,我们将开始声明我们想要使用的变量:

 const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');

express()函数返回我们将在整个代码中传递的主要 Express.js 应用程序对象,首先将其添加到http.Server对象。 (我们需要在配置express.Application后启动http.Server 。)

我们将监听端口 3000(TypeScript 会自动推断为Number )而不是标准端口 80 (HTTP) 或 443 (HTTPS),因为这些端口通常用于应用程序的前端。

为什么选择端口 3000?

没有规定端口应该是 3000——如果未指定,将分配任意端口——但是在 Node.js 和 Express.js 的文档示例中都使用 3000,所以我们在这里继续传统。

Node.js 可以与前端共享端口吗?

我们仍然可以在自定义端口上本地运行,即使我们希望后端响应标准端口上的请求。 这将需要反向代理来接收端口 80 或 443 上具有特定域或子域的请求。 然后它将它们重定向到我们的内部端口 3000。

routes数组将跟踪我们的路由文件以进行调试,如下所示。

最后, debugLog最终将作为一个类似于console.log的函数,但更好:它更容易微调,因为它会自动限定到我们想要调用文件/模块上下文的任何内容。 (在这种情况下,当我们将它以字符串形式传递给debug()构造函数时,我们称它为“app”。)

现在,我们准备好配置所有 Express.js 中间件模块和 API 的路由:

 // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });

expressWinston.logger与 Express.js 挂钩,通过与debug相同的基础架构自动记录每个已完成请求的详细信息。 我们传递给它的选项将整齐地格式化和着色相应的终端输出,当我们处于调试模式时,会使用更详细的日志记录(默认)。

请注意,我们必须在设置expressWinston.logger之后定义我们的路线。

最后也是最重要的:

 server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });

这实际上启动了我们的服务器。 一旦启动,Node.js 将运行我们的回调函数,它在调试模式下报告我们配置的所有路由的名称——到目前为止,只有UsersRoutes 。 之后,我们的回调通知我们后端已准备好接收请求,即使在生产模式下运行也是如此。

更新package.json以将 TypeScript 转换为 JavaScript 并运行应用程序

现在我们已经准备好运行我们的骨架,我们首先需要一些样板配置来启用 TypeScript 转换。 让我们在项目根目录中添加文件tsconfig.json

 { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }

然后我们只需要以以下脚本的形式将最后的润色添加到package.json中:

 "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },

test脚本是一个占位符,我们将在本系列的后面部分替换它。

start脚本中的tsc属于 TypeScript。 它负责将我们的 TypeScript 代码转换成 JavaScript,然后输出到dist文件夹。 然后,我们只需使用node ./dist/app.js运行构建版本。

我们将--unhandled-rejections=strict传递给 Node.js(即使是 Node.js v16+),因为在实践中,使用直接的“崩溃并显示堆栈”方法进行调试比使用expressWinston.errorLogger对象进行更高级的日志记录更直接。 即使在生产环境中也是如此,让 Node.js 在未处理的拒绝情况下继续运行可能会使服务器处于意外状态,从而导致进一步(和更复杂)的错误发生。

debug脚本调用start脚本,但首先定义了一个DEBUG环境变量。 这样做的效果是启用我们所有的debugLog()语句(加上来自 Express.js 本身的类似语句,它使用与我们相同的debug模块)将有用的细节输出到终端——这些细节在运行时(方便地)以其他方式隐藏具有标准npm start的生产模式下的服务器。

尝试自己运行npm run debug ,然后将其与npm start进行比较,以查看控制台输出的变化。

提示:您可以使用DEBUG=app而不是DEBUG=*将调试输出限制为我们的app.ts文件自己的debugLog()语句。 debug模块通常是相当灵活的,这个特性也不例外。

Windows 用户可能需要将export更改为SET ,因为export是它在 Mac 和 Linux 上的工作方式。 如果你的项目需要支持多个开发环境,cross-env 包在这里提供了一个简单的解决方案。

测试 Live Express.js 后端

随着npm run debugnpm start仍在运行,我们的 REST API 将准备好为端口 3000 上的请求提供服务。此时,我们可以使用 cURL、Postman、Insomnia 等来测试后端。

由于我们只为用户资源创建了一个骨架,我们可以简单地发送没有正文的请求,以查看一切是否按预期工作。 例如:

 curl --request GET 'localhost:3000/users/12345'

我们的后端应该发回GET requested for id 12345的答案。

至于POST ing:

 curl --request POST 'localhost:3000/users' \ --data-raw ''

我们为之构建骨架的所有其他类型的请求看起来都非常相似。

准备使用 TypeScript 进行快速 Node.js REST API 开发

在本文中,我们开始通过从头开始配置项目并深入了解 Express.js 框架的基础知识来创建 REST API。 然后,我们通过使用扩展CommonRoutesConfigUsersRoutesConfig构建一个模式,迈出了掌握 TypeScript 的第一步,我们将在本系列的下一篇文章中重用该模式。 我们完成了配置我们的app.ts入口点以使用我们的新路由和package.json和脚本来构建和运行我们的应用程序。

但即使是使用 Express.js 和 TypeScript 制作的 REST API 的基础知识也相当重要。 在本系列的下一部分中,我们专注于为用户资源创建适当的控制器,并深入研究一些用于服务、中间件、控制器和模型的有用模式。

完整的项目可以在 GitHub 上找到,本文末尾的代码可以在toptal-article-01分支中找到。