构建 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 项目结构
对于本教程,我们将只创建三个文件:
-
./app.ts
-
./common/common.routes.config.ts
-
./users/users.routes.config.ts
项目结构的两个文件夹( common
和users
)背后的想法是拥有各自负责的各个模块。 从这个意义上说,我们最终将为每个模块提供以下部分或全部内容:
- 路由配置来定义我们的 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
中添加三个快速的东西:
- 我们的
class
行的关键字abstract
,以启用该类的抽象。 - 我们类末尾的新函数声明,
abstract configureRoutes(): express.Application;
. 这会强制任何扩展CommonRoutesConfig
的类提供与该签名匹配的实现——如果不匹配,TypeScript 编译器将抛出错误。 - 调用
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 客户端通过POST
或GET
请求调用我们的users
端点。 同样,它允许客户端使用GET
、 PUT
、 PATCH
或DELETE
请求调用我们的/users/:userId
端点。

但是对于/users/:userId
,我们还使用all()
函数添加了通用中间件,它将在任何get()
、 put()
、 patch()
或delete()
函数之前运行。 当(在本系列的后面)我们创建仅供经过身份验证的用户访问的路由时,此功能将很有用。
您可能已经注意到,在我们的.all()
函数中——与任何中间件一样——我们有三种类型的字段: Request
、 Response
和NextFunction
。
- 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 debug
或npm 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。 然后,我们通过使用扩展CommonRoutesConfig
的UsersRoutesConfig
构建一个模式,迈出了掌握 TypeScript 的第一步,我们将在本系列的下一篇文章中重用该模式。 我们完成了配置我们的app.ts
入口点以使用我们的新路由和package.json
和脚本来构建和运行我们的应用程序。
但即使是使用 Express.js 和 TypeScript 制作的 REST API 的基础知识也相当重要。 在本系列的下一部分中,我们专注于为用户资源创建适当的控制器,并深入研究一些用于服务、中间件、控制器和模型的有用模式。
完整的项目可以在 GitHub 上找到,本文末尾的代码可以在toptal-article-01
分支中找到。