15分钟阅读

构建Node.js / typescript REST API,第2部分:模型,中间件和服务

Marcos在IT和发展中有17多年。他的激情包括休息架构,敏捷开发方法和js。

第一篇文章 我们的REST API系列中,我们涵盖了如何使用NPM从头开始创建后端,添加依赖项 类型签字, use the debug module built into Node.js, build an Express.js project structure, and log runtime events flexibly with Winston. If you’re comfortable with those concepts already, simply clone , switch to the toptal-article-01 branch with git checkout, and read on.

休息API. 服务,中间件,控制器和模型

正如所承诺的那样,我们现在将详细了解这些模块:

  • 服务 通过将业务逻辑操作封装到中间件和控制器可以呼叫的功能,使我们的代码清洁器制作。
  • 中间件 这将在Express.js调用适当的控制器功能之前验证先决条件。
  • 控制器 使用服务在最后向请求者发送响应之前处理请求。
  • 楷模 描述我们的数据和援助编译时间检查。

我们还将添加银河游戏官方首页 非常 基本数据库,绝不适合生产。 (其唯一目的是使本教程更容易遵循,为我们的下一篇文章铺平道路,以将数据库连接和与MongoDB和Mongoose集成。)

实际动手:DAOS,DTO和我们的临时数据库的第一步

在我们教程的这一部分,我们的数据库甚至不会使用文件。它将简单地将用户数据保留在数组中,这意味着每当我们退出Node.js时都会迁移数据。它只支持最基本的 创建,读取,更新和删除 (CRUD) operations.

我们将在这里使用两个概念:

  • 数据 使用权 objects (DAOs)
  • 数据 转移 objects (DTOs)

首字母缩略词之间的银河游戏官方首页字母差异是必需的:DAO负责连接定义的数据库并执行CRUD操作; DTO是银河游戏官方首页对象,该对象包含DAO将发送到 - 数据库的原始数据。

换句话说,DTO是符合数据模型类型的对象,并且DAOS是使用它们的服务。

虽然DTO可以获得更复杂的嵌套数据库实体,例如 - 在本文中,单个DTO实例将对应于单个数据库行。

为什么DTOS?

使用DTO符合我们的类型符合我们的数据模型,有助于维护架构一致性,因为我们将在下面的服务部分中看到。但是,有银河游戏官方首页重要的警告:DTO也不是CypeScript本身都保证任何类型的自动用户输入验证,因为它必须在运行时发生。当我们的代码在API中的端点收到用户输入时,输入可能:

  • 有额外的领域
  • Be missing required fields (i.e., those not suffixed with ?)
  • 有些字段,其中数据不是我们在模型中指定的类型使用类型标准

类型签字(以及它被转发到的javascript) 不会神奇地检查一下 对我们来说,因此不要忘记这些验证是很重要的,特别是在向公众开启API时。包裹如 AJV 可以帮助实现这一点,而是通过定义特定于库的模式对象而不是本机制标注的模型来解决。 (猫鼬在下一篇文章中讨论,将在这个项目中发挥类似的作用。)

你可能会思考,“真的最好地使用DAOS和DTO,而不是更简单的东西吗?”企业开发人员Gunther Popp 提供答案;除非您可以合理地预期在中期,您将希望在大多数较小的真实表达式项目中避免DTO。

但即使您不习惯在生产中使用它们,这个示例项目也是掌握打字API架构的道路的有价值机会。这是练习利用打字类型的好方法 在额外的方式 并使用DTOS了解在添加组件和模型时如何与更基本的方法进行比较。

我们的用户REST API模型在打字级别

First we will define a DTO for our user. Let’s create a folder called dto inside the users folder, and create a file there called user.dto.ts containing the following:

export interface UserDto {
    id: string;
    email: string;
    password: string;
    firstName?: string;
    lastName?: string;
    permissionLevel?: number;
}

我们在指出,每次我们建模用户时,无论数据库如何,它都应该拥有ID,密码和电子邮件,以及可选的名字和姓氏。这些要求可以根据给定项目的业务需求进行更改。

Now, let’s create the in-memory temporary database. Let’s create a folder called daos inside the users folder, and add a file named users.dao.ts.

First, we want to import the UserDto that we created:

import {UserDto} from "../dto/user.dto";

现在,要处理我们的用户ID,让我们添加WhiteDID库(使用终端):

npm i --save shortid
npm i --save-dev @types/shortid

Back in users.dao.ts, we’ll import shortid:

import shortid from 'shortid';
import debug from 'debug';
const log: debug.IDebugger = debug('app:in-memory-dao');

We can now create a class called UsersDao, which will look like this:

class UsersDao {
    users: Array<UserDto> = [];

    constructor() {
        log('Created new instance of UsersDao');
    }
}

export default new UsersDao();

Using the singleton pattern, this class will always provide the same instance—and, critically, the same users array—when we import it in other files. That’s because Node.js caches this file wherever it’s imported, and all the imports happen on startup. That is, any file referring to users.dao.ts will be handed a reference to the same new UsersDao() that gets exported the first time Node.js processes this file.

当我们在本文中进一步使用此类时,我们将看到此工作,并在整个项目中使用此常用类型/ Express.js模式。

注意:对单身人士的缺点是它们难以编写单元测试。在许多类的情况下,这种缺点不适用,因为没有任何需要重置的类成员变量。但对于那些它来说,我们将其作为读者担任练习,以考虑使用使用 依赖注入.

现在我们将作为函数添加到类的基本CRUD操作。这 创建 功能看起来像这样:

async addUser(user: UserDto) {
    user.id = shortid.generate();
    this.users.push(user);
    return user.id;
}

将有两种口味,“阅读所有资源”和“逐个读取”。它们是如此编码:

async getUsers() {
    return this.users;
}

async getUserById(userId: string) {
    return this.users.find((user: { id: string; }) => user.id === userId);
}

同样地, 更新 will mean either overwriting the complete object (as a PUT) or just parts of the object (as a PATCH):

async putUserById(user: UserDto) {
    const objIndex = this.users.findIndex((obj: { id: string; }) => obj.id === user.id);
    this.users.splice(objIndex, 1, user);
    return `${user.id} updated via put`;
}

async patchUserById(user: UserDto) {
    const objIndex = this.users.findIndex((obj: { id: string; }) => obj.id === user.id);
    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`;
}

As mentioned earlier, despite our UserDto declaration in these function signatures, TypeScript provides no runtime type checking. This means that:

  • 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;
    }
}

注意:在真实的方案中,您可能会使用预先存在的库连接到数据库,例如 猫鼬 或者 ese esefelize.,这将抽出您可能需要的所有基本操作。因此,我们不会进入上面实施的职能的细节。

我们的REST API服务层

Now that we have a basic, in-memory DAO, we can create a service that will call the CRUD functions. Since CRUD functions are something that every service that will connect to a database will need to have, we are going to create a CRUD interface that contains the methods we want to implement every time we want to implement a new service.

如今,我们使用的IDES具有代码生成功能来添加我们正在实现的功能,从而减少我们需要写入的重复代码量。

银河游戏官方首页快速的例子使用 WebStorm IDE:

WebStorv的屏幕截图显示银河游戏官方首页名为MyService的类的空定义,该类实现了银河游戏官方首页名为Crud的接口。 MyService的名称由IDE中的红色下划线。

IDE突出显示MyService类名称,并建议以下选项:

银河游戏官方首页类似于前银河游戏官方首页的屏幕截图,但使用上下文菜单列出多个选项,其中的第一选项是"实施所有成员。"

The “Implement all members” option instantly scaffolds the functions needed to conform to the CRUD interface:

WebStorm中的MyService类屏幕截图。 MyService不再用红色带下划线,而类定义现在包含所有打字键盘类型的函数签名(以及在CRUD接口中指定的函数体或包含返回语句)。

That all said, let’s first create our TypeScript interface, called CRUD. At our common folder, let’s create a folder called interfaces and add crud.interface.ts with the following:

export interface CRUD {
    list: (limit: number, page: number) => Promise<any>,
    create: (resource: any) => Promise<any>,
    updateById: (resourceId: any) => Promise<string>,
    readById: (resourceId: any) => Promise<any>,
    deleteById: (resourceId: any) => Promise<string>,
    patchById: (resourceId: any) => Promise<string>,
}

With that done, lets create a services folder within the users folder and add the users.service.ts file there, starting with:

import UsersDao from '../daos/users.dao';
import {CRUD} from "../../common/interfaces/crud.interface";
import {UserDto} from "../dto/user.dto";

class UsersService implements CRUD {

    async create(resource: UserDto) {
        return UsersDao.addUser(resource);
    }

    async deleteById(resourceId: string) {
        return UsersDao.removeUserById(resourceId);
    };

    async list(limit: number, page: number) {
        return UsersDao.getUsers();
    };

    async patchById(resource: UserDto) {
        return UsersDao.patchUserById(resource)
    };

    async readById(resourceId: string) {
        return UsersDao.getUserById(resourceId);
    };

    async updateById(resource: UserDto) {
        return UsersDao.putUserById(resource);
    };

    async getUserByEmail(email: string) {
        return UsersDao.getUserByEmail(email);
    }
}

export default new UsersService();

我们的第一步是导入我们的内存DAO:

import usersDao from '../daos/users.dao';

The name usersDao could be anything when we import it. But since we are already receiving an instance of the class, we’ll use the same name converted to camel case, as if it would be something like const usersDao = new UsersDao().

After importing our interface dependency and the TypeScript type of our DTO, it’s time to implement UsersService as a service singleton, the same pattern we used with our DAO.

全部 the CRUD functions now just call the usersDao and its own respective functions. When it comes time to replace the DAO, we won’t have to make changes anywhere else in the project, not even in this file where the DAO functions are called.

For example, we won’t have to track down every call to list() and check its context before replacing it. That’s the advantage of having this layer of separation, at the cost of the small amount of initial boilerplate you see above.

async / await和node.js

Our use of async for the service functions may seem pointless. For now, it is: All of these functions just immediately return their values, without any internal use of Promises or await. This is solely to prepare our codebase for services that will use async. Likewise, below, you’ll see that all calls to these functions use await.

By the end of this article, you’ll again have a runnable project to experiment with. That will be an excellent moment to try adding various types of errors in different places in the codebase, and seeing what happens during compilation and testing. Errors in an async context in particular may not behave quite as you’d expect. It’s worth digging in and 探索各种解决方案,这超出了本文的范围。


现在,让我们的DAO和服务准备好,让我们回到用户控制器。

构建我们的REST API控制器

正如我们上面所说的那样,控制器背后的想法是将路由配置与最终处理路由请求的代码分开。这意味着在我们的请求到达控制器之前,应完成所有验证。控制器只需要知道如何处理实际请求,因为如果请求将其执行此操作,那么我们就知道它已有效。然后,控制器将调用它将处理的每个请求的相应服务。

在我们开始之前,我们需要安装银河游戏官方首页库以安全地散列用户密码:

npm i --save argon2 

Let’s start by creating a folder called controllers inside the users controller folder and creating a file called users.controller.ts in it:

// 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.params.userId);
        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));
        res.status(204).send(``);
    }

    async put(req: express.Request, res: express.Response) {
        req.body.password = await argon2.hash(req.body.password);
        log(await usersService.updateById({id: req.params.userId, ...req.body}));
        res.status(204).send(``);
    }

    async removeUser(req: express.Request, res: express.Response) {
        log(await usersService.deleteById(req.params.userId));
        res.status(204).send(``);
    }
}

export default new UsersController();

使用我们的用户控制器单例完成,我们已准备好代码依赖于我们的示例REST API对象模型和服务的其他模块:我们的用户中间件。

node.js rest中间件使用Express.js

我们可以用Express.js中间件做什么?验证非常适合。让我们添加一些基本验证,以便在他们的用户控制器之前作为请求的网守:

  • Ensure the presence of user fields such as email and password as required to create or update a user
  • 确保已经使用给定的电子邮件
  • Check that we’re not changing the email field after creation (since we’re using that as the primary user-facing ID for simplicity)
  • 验证是否存在给定用户

To make these validations to work with Express.js, we will need to translate them into functions that follow the Express.js pattern of flow control using next(), as 描述 in the previous article. We’ll need a new file, users/middleware/users.middleware.ts:

import express from 'express';
import userService from '../services/users.service';

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/or password`});
    }
}

async validateSameEmailDoesntExist(req: express.Request, res: express.Response, next: express.NextFunction) {
    const user = await userService.getByEmail(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) {
        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`});
    }
}

To make an easy way for our API consumers to make further requests about a newly added user, we are going to add a helper function that will extract the userId from the request parameters—coming in from the request URL itself—and add it to the request body, where the rest of the user data resides.

这里的想法是能够在我们想更新用户信息时简单地使用全身请求,而无需担心每次从参数中获取ID。相反,它只是在银河游戏官方首页地方照顾,中间件。该功能看起来像这样:

async extractUserId(req: express.Request, res: express.Response, next: express.NextFunction) {
    req.body.id = req.params.userId;
    next();
}

Besides the logic, the main difference between the middleware and the controller is that now we are using the next() function to pass control along a chain of configured functions until it arrives at the final destination, which in our case is the controller.

把它整合在一起:重构我们的路线

Now that we have implemented all the new aspects of our project architecture, let’s go back to the users.routes.config.ts file we 定义 在上一篇文章中。它将调用我们的中间件和我们的控制器,既依赖于我们的用户服务,反过来需要我们的用户模型。

最终文件就像这样简单:

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() {
        this.app.route(`/用户`)
            .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;
    }
}

Here, we’ve redefined our routes by adding middleware to validate our business logic and the appropriate controller functions to process the request if everything is valid. We’ve also used the .param() function from Express.js to extract the userId.

At the .all() function, we are passing our 证实UserExists function from UsersMiddleware to be called before any GET, PUT, PATCH, or DELETE can go through on the endpoint /users/:userId. This means 证实UserExists doesn’t need to be in the additional function arrays we pass to .put() 或者 .patch()—it will get called before the functions specified there.

We’ve leveraged the inherent reusability of middleware here in another way, too. By passing UsersMiddleware.validateRequiredUserBodyFields to be used in both POST and PUT contexts, we’re elegantly recombining it with other middleware functions.

免责声明:我们只涵盖了本文中的基本验证。在银河游戏官方首页真实的项目中,您需要考虑并找到您需要的所有限制。为简单起见,我们也是假设用户无法更改其电子邮件。

测试我们的Express / Typescript REST API

我们现在可以编译并运行我们的node.js应用程序。一旦运行,我们就可以使用邮递员或卷曲等REST客户端来测试我们的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"
}

要解决它,让我们发送银河游戏官方首页 有效的 request to post to /users resource:

curl --request POST 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
   "email": "[email protected]",
   "password": "sup3rS3cr3tPassw0rd!23"
}'

这次,我们应该看到以下内容:

{
    "id": "ksVnfnPVW"
}

This id is the identifier of the newly created user and will be different on your machine. To make the remaining testing statements easier, you can run this command with the one you get (assuming you’re using a Linux-like environment):

休息_API_EXAMPLE_ID="put_your_id_here"

We can now see the response we get from making a GET request using the above variable:

curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json'

We can now also update the entire resource with the following PUT request:

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
}'

我们还可以通过更改电子邮件地址来测试我们的验证工作,这应该导致错误。

Note that when using a PUT to a resource ID, we, as API consumers, need to send the entire object if we want to conform to the standard REST pattern. That means that if we want to update just the lastName field, but using our PUT endpoint, we will be forced to send the entire object to be updated. It would be easier to use a PATCH request since there it’s still within standard REST constraints to send just the lastName field:

curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
    "lastName": "Faraco"
}'

Recall that in our own codebase, it’s our route configuration that enforces this distinction between PUT and PATCH using the middleware functions we added in this article.

再次获取用户列表,如上所述,我们应该看到我们创建的用户更新其字段:

[
  {
    "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'

再次获取用户列表,我们应该看到已删除的用户不再存在。

With that, we have all the CRUD operations for the users resource working.

node.js / typescript REST API

在该系列的这一部分,我们进一步探索了使用Express.js构建REST API的关键步骤。我们将代码拆分以支持服务,中间件,控制器和模型。它们的每个功能都具有特定的作用,无论是验证,逻辑运算还是处理有效请求并响应它们。

我们还创建了银河游戏官方首页非常简单的方法来存储数据,(赦免双关语) 表示 目的是在这一点上允许一些测试,然后在我们系列的下一部分中更换更多的东西。

除了使用单身级别的简单性建立API之外,例如 - 有几个步骤可以让您更轻松地维护,更可扩展和安全。在我们的下一篇文章中,我们将介绍:

  • 用MongoDB替换内存内存数据库,然后使用Mongoose简化编码过程
  • 使用JWT添加银河游戏官方首页无状态方法的安全层和控制访问
  • 配置自动化测试以允许我们的应用程序缩放

您可以从本文中浏览最终代码 这里.

理解基础知识

中间件和API之间有什么区别?

这取决于。在Express.js后端的上下文中,中间件只是用于实现API的内部架构中的一种组件。然而,在较大的(例如,分布式)上下文中,有时API本身可能被视为“中间件”。

中间件的角色是什么?

在构建API时,中间件将有助于在到达控制器之前验证请求内的任何要求,同时保持代码组织且易于进行维护。

为什么需要中间件?

Express.js.中间件函数可以操纵请求,并结束请求响应生命周期。当多个路由需要相同的验证或操作时,可以在其中重用中间件,从而更容易维护和扩展码字。

中间件如何工作?

Express.js.中间件函数接收请求和响应对象和“下银河游戏官方首页”功能。它们可能会修改请求或使用响应对象来短路请求响应生命周期(例如,通过发送错误400.)“下银河游戏官方首页”函数调用下银河游戏官方首页中间件,将它们全部链接在一起。

什么是休息API中的控制器?

Express.js. REST API控制器可以定义为接收请求和响应对象的最后银河游戏官方首页函数,目的是在向请求者发送响应之前处理请求。

休息API.是银河游戏官方首页Web服务吗?

是的,由于REST的基础协议是HTTP,因此可以考虑银河游戏官方首页REST API,这允许在Web上进行机器到机器交互。

MVC中的数据传输对象是什么?

数据传输对象(DTO)是创建的设计模式,以定义两个不同的应用程序可以在它们不一定在同一环境中传输数据的方式。