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

439 阅读14分钟

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

如何在Node.js中写一个REST API?

在为REST API构建后端时,Express.js通常是Node.js框架的首选。虽然它也支持构建静态HTML和模板,但在本系列中,我们将专注于使用TypeScript进行后端开发。由此产生的REST 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

这些依赖项是为我们应用自己的代码启用TypeScript所需要的,还有Express.js和其他依赖项使用的类型。当我们使用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.jsApplication 对象的访问。

现在,我们可以开始创建用户路由文件了。在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。本教程的主要资源是用户。在这种情况下,我们有两种情况:

  • 当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() 函数中--就像任何中间件一样--我们有三种类型的字段。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-native模块。它是启动我们的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--如果没有指定,将指定一个任意的端口--但是3000在Node.js和Express.js的整个文档示例中都被使用,所以我们在这里延续了这个传统。

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 v16+),因为在实践中,使用直接的 "崩溃并显示堆栈 "的方法进行调试,比使用expressWinston.errorLogger 对象进行更多的日志记录更直接。即使在生产中也是如此,尽管有一个未处理的拒绝,但让Node.js继续运行很可能使服务器处于一个意想不到的状态,从而使更多(和更复杂)的bug发生。

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包在这里提供了一个直接的解决方案。

测试实时的Express.js后端

npm run debugnpm start ,我们的REST API将准备好为3000端口的请求提供服务。在这一点上,我们可以使用cURL、PostmanInsomnia等来测试后端。

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

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

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

至于POSTing:

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

这和所有其他类型的请求,我们建立的骨架将看起来非常相似。

为使用TypeScript快速开发Node.js REST API做好准备

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

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

完整的项目可以在GitHub上找到,截至本文结束时的代码可以在toptal-article-01 分支中找到。