在第 1 章**Angular 及其概念简介中,我向您介绍了 web 应用存在的更广泛的体系结构。在全栈体系结构中所做的选择可以深刻地影响 web 应用的成功。您不能忽视与之交互的 API 是如何设计的。在本章中,我们将介绍如何使用 Node、Express 和 Mongo 为前端实现后端。结合 Angular,该软件栈称为平均栈。
我对平均值栈的理解是最小平均值,它优先考虑易用性、快乐性和有效性,这是优秀的开发人员体验(DX的主要成分。为了跟上主题,我们将实现 LemonMart 服务器。该服务器将从第 8 章、设计认证和授权中完善 JWT 认证。此外,服务器将支持我将在第 11 章、秘籍–可重用性、路由和缓存和第 12 章、秘籍–主/细节、数据表和 NgRx中介绍的秘籍。
本章涵盖了很多方面。它被设计成 GitHub 存储库的路线图(https://github.com/duluca/lemon-mart-server )。我将介绍实现的体系结构、设计和主要组件。我重点介绍了一些重要的代码,以解释解决方案是如何结合在一起的。但是,与前面的章节不同,您不能仅仅依靠文本中提供的代码示例来完成实现。在本书中,更重要的是,您要理解为什么我们要实现各种功能,而不是对实现细节有很强的把握。因此,在本章中,我建议您阅读并理解服务器代码,而不是尝试自己重新创建。
您需要在本章末尾采取行动,在 Angular 应用中实现自定义认证提供程序,以针对lemon mart server进行认证,并利用 Postman 生成测试数据,这将在后面的章节中有所帮助。
我们首先介绍完整的栈体系结构,介绍 lemon mart server 的 monorepo 设计,以及如何使用 Docker Compose 运行一个包含 web 应用、服务器和数据库的三层应用。然后,我们回顾 RESTful API 的设计和文档,使用Swagger.io利用 OpenAPI 规范,并使用 Express.js 实现。然后,我们介绍了使用 my Documents 库存储具有登录凭据的用户的 MongoDB对象文档映射器(ODM的实现。我们实现了一个基于令牌的认证函数,并使用它来保护我们的 API。最后,我们利用 Postman,使用我们开发的 API 在数据库中生成测试数据。
在本章中,您将了解以下内容:
- 全栈结构
- Docker Compose
- RESTful API
- 带文档的 MongoDB ODM
- 实现 jwtauth
- 使用 Postman 生成用户
本书样本代码的最新版本可以在下面链接的存储库的 GitHub 上找到。存储库包含代码的最终和完成状态。本章要求 Docker 和 Postman 应用。
在您的开发环境中启动并运行lemon mart 服务器,并与lemon mart进行通信,这一点至关重要。请参阅此处或 GitHub 上的README
中记录的说明,以启动并运行服务器。
在本章的情况下:
-
使用
--recurse-submodules
选项git clone --recurse-submodules
克隆lemon mart 服务器存储库 https://github.com/duluca/lemon-mart-server -
In the VS Code terminal, execute
cd web-app; git checkout master
to ensure that the submodule from https://github.com/duluca/lemon-mart is on the master branch.稍后,在Git 子模块部分,您可以配置
web-app
文件夹以从 lemon mart 服务器中提取。 -
Execute
npm install
on the root folder to install dependencies.请注意,在根文件夹上运行
npm install
命令会触发一个脚本,该脚本还会在server
和web-app
文件夹下安装依赖项。 -
Execute
npm run init:env
on the root folder to configure environment variables in.env
files.此命令将创建两个
.env
文件,一个在根文件夹上,另一个在server
文件夹下,以包含您的私有配置信息。初始文件是根据example.env
文件生成的。您可以稍后修改这些文件并设置自己的安全机密。 -
Execute
npm run build
on the root folder, which builds both the server and the web app.请注意,web 应用是使用名为
--configuration=lemon-mart-server
的新配置构建的,该配置使用src/environments/environment.lemon-mart-server.ts
。 -
Execute
docker-compose up --build
to run containerized versions of the server, web app, and a MongoDB database.请注意,web 应用是使用名为
nginx.Dockerfile
的新文件进行容器化的。 -
Navigate to
http://localhost:8080
to view the web app.要登录,请单击填充按钮,用默认演示凭据填充电子邮件和密码字段。
-
导航至
http://localhost:3000
以查看服务器登录页。 -
导航至
http://localhost:3000/api-docs
查看交互式 API 文档。 -
您可以使用
npm run start:database
只启动数据库,并在server
文件夹上使用npm start
进行调试。 -
您可以使用
npm run start:backend
只启动数据库和服务器,并在web-app
文件夹上使用npm start
进行调试。
本章中基于柠檬超市的示例:
-
在根文件夹上执行
npm install
以安装依赖项。 -
本章的代码示例在子文件夹
projects/ch10
下提供
-
要运行本章的 Angular 应用,请执行以下命令:
npx ng serve ch10
-
要运行本章的 Angular 单元测试,请执行以下命令:
npx ng test ch10 --watch=false
-
要运行本章的 Angular e2e 测试,请执行以下命令:
npx ng e2e ch10
-
To build a production-ready Angular app for this chapter, execute the following command:
npx ng build ch10 --prod
请注意,存储库根目录下的
dist/ch10
文件夹将包含编译后的结果。
请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。书中的代码和 GitHub 上的代码在实现上也可能有细微的差异,因为生态系统是不断发展的。随着时间的推移,示例代码自然会发生变化。此外,在 GitHub 上,希望找到更正、修复以支持库的更新版本,或者同时实现多种技术,供读者观察。读者只希望实现书中推荐的理想解决方案。如果发现错误或有问题,请在 GitHub 上创建问题或提交请求,以方便所有读者。
您可以在附录 C、保持 Angular 和 Tools 常青中了解更多关于更新 Angular 的信息。您可以从在线找到此附录 https://static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdf 或处 https://expertlysimple.io/stay-evergreen 。
随着 LemonMart 服务器的启动和运行,我们已经准备好探索 MEAN 栈的体系结构。在本节结束时,您应该有自己的 LemonMart 版本与服务器通信。
全栈是指使应用工作的整个软件栈,从数据库到服务器、API 以及利用它们的 web 和/或移动应用。神话般的全栈开发人员是无所不知的,可以在该行业的所有垂直领域轻松操作。几乎不可能专攻所有与软件相关的事情,并且被认为是与每个给定主题相关的专家。然而,要想成为一个单一主题的专家,你还必须精通相关主题。在学习一个新主题时,保持工具和语言的一致性非常有帮助,这样您就可以吸收新信息而不会产生额外的噪音。出于这些原因,我选择向您介绍使用 Java 或使用 C#的 ASP.NET 进行 Spring 引导的平均栈。通过使用熟悉的工具和语言,如 TypeScript、VS Code、npm、GitHub、Jasmine、Docker 和 CircleCI,您可以更好地理解全栈实现是如何结合在一起的,从而成为一名更好的 web 开发人员。
选择正确的栈™ 因为你的项目很难。首先,您的技术架构应该足以满足业务需求。例如,如果您试图使用 Node.js 交付人工智能项目,则可能使用了错误的栈。我们的重点将是提供 Web 应用,但除此之外,我们还有其他的参数要考虑,包括以下内容:
- 易用性
- 幸福
- 有效性
如果你的开发团队将在你的应用上工作一段时间,那么考虑兼容性之外的因素是非常重要的。如果您的代码库易于使用,让开发人员感到满意,或者让他们觉得自己是项目的有效贡献者,那么您的栈、工具选择和编码风格可能会产生重大影响。
一个配置良好的栈是一个伟大的 DX 的关键。这可能是一堆高耸的干煎饼和一堆美味的短煎饼之间的区别,上面有适量的黄油和糖浆。
通过引入太多的库和依赖项,您可以减慢进度,使代码难以维护,并发现自己处于一个反馈循环中,需要引入更多库来解决其他库的问题。赢得这场比赛的唯一方法就是不玩它。
如果您花时间学习如何使用一些基本的库,您可以成为一名更有效的开发人员。本质上,你可以用更少的钱做更多的事情。我的建议是:
-
在编写一行代码之前先考虑,然后应用 80-20 规则。
-
等待库和工具成熟,跳过 Beta。
-
Fast by reducing your gluttony for new packages and tools, mastering the fundamentals instead.
在 YouTube 的上观看我的 2017 年 Ng 大会演讲,题目是用更少的 JavaScript 做更多的事情https://www.youtube.com/watch?v=Sd1aM8181kc 。
这种极简主义的思维方式是极简主义背后的设计哲学。您可以在查看 GitHub 上的参考实现 https://github.com/duluca/minimal-mean 。有关整体架构,请参阅下图:
图 10.1:最小平均软件栈和工具
让我们一个接一个地回顾一下架构的组件。
Angular 是表示层。Angular 是一个功能强大且可靠的开发平台。它被广泛理解,拥有一个伟大的社区。在考虑其他选择之前,您一定要花时间掌握 Angular 的基本原理。
库,如 Angular Material、Angular Evergreen 和angular-unit-test-helper
库,可帮助您以最小的努力提供最佳且美观的解决方案。
您可以使用最小的 Docker 容器duluca/minimal-nginx-web-server
或duluca/minimal-node-web-server
将 Angular(或任何其他 web 应用)容器化。
Express.js 将成为我们的 API 层。Express 是一个用于 Node.js 的快速、无偏见且极简的 web 框架。Express 拥有一个庞大的插件生态系统,几乎可以保证满足所有需求。至少,我们只利用了两个软件包:
cors
:配置异地资源共享设置morgan
:记录 HTTP 请求
此外,我们使用 express 解析器解析req.body
中传入的 HTTP 请求,并使用express.static
函数为public
文件夹的内容提供服务。
您可以在上阅读有关 Express.js 的更多信息 https://expressjs.com/ 。
Express.js 在 Node.js 上运行。我们将在 Node 中实现业务层。Node 是一个轻量级的高效 JavaScript 运行时,使用事件驱动的非阻塞 I/O 模型,使其适合于高性能和实时应用。Node 无处不在,从冰箱到智能手表。通过使用 TypeScript 开发应用,可以提高节点应用的可靠性。
请参阅 Frank Rosner 关于非阻塞 I/O 的博文,以获得关于主题的更深入解释 https://blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/ 。
在本章后面,您将学习如何使用 TypeScript 配置节点项目。
MongoDB 表示持久层。MongoDB 是一个面向文档的数据库,具有类似 JSON 的动态模式。使用基于 JSON 的数据库的主要好处是不需要将数据从一种格式转换为另一种格式。您可以单独使用 JSON 检索、显示、编辑和更新数据。
此外,节点的 MongoDB 本机驱动程序已经成熟、性能良好、功能强大。我开发了一个名为document-ts
的库,旨在通过引入易于编码的富文档对象来简化与 MongoDB 的交互。DocumentTS 是一个非常瘦的基于 TypeScript 的 MongoDB 助手,具有可选的、丰富的 ODM 便利特性。
您可以在上阅读更多关于 MongoDB 的信息 https://www.mongodb.com/ 和的文档库 https://github.com/duluca/document-ts 。
支持您开发的工具与您选择的软件栈一样重要。最小平均值利用了以下因素:
- VS 代码:非常好的扩展支持,轻量级,快速,跨平台
- TypeScript:快速且易于使用的 transpiler,使用 tslint 提供强大的绒线支撑
- Npm:具有丰富软件包生态系统的多平台脚本和依赖关系管理
- GitHub:灵活、免费,且支持良好的 Git 主机。GitHub 流与 CI 服务器一起启用门控代码签入
- Docker:轻量级虚拟化技术,封装您的环境配置和设置
- 持续集成(CI):确保质量代码交付的关键
- Jasmine:电池包括单元测试框架,该框架与 nyc/istanbul.js 一起工作,以提供代码覆盖率指标
请注意,我们使用的工具和语言与我们用于 Angular 开发的工具和语言相同。这使开发人员能够在前端和后端开发之间进行切换,而只需最少的上下文切换。
既然我们已经介绍了用于提供最小平均栈应用的所有主要组件和工具,那么让我们首先创建一个可以容纳前端和后端代码的 Git 存储库。
您可以通过创建包含前端和后端代码的 monorepo 来优化您的开发体验。monorepo 允许开发人员在同一 IDE 窗口内的项目之间切换。开发人员可以更容易地跨项目引用代码,例如在前端和后端之间共享 TypeScript 接口,从而确保数据对象每次都对齐。CI 服务器可以一次构建所有项目,以确保全栈应用的所有组件保持工作状态。
请注意,monorepo 与 VS 代码中的多个根工作区不同,在 VS 代码中,您可以添加多个项目以在同一 IDE 窗口中显示。monorepo 将项目合并到源代码控制级别。在上阅读有关多根工作区的更多信息 https://code.visualstudio.com/docs/editor/multi-root-workspaces 。
让我们快速浏览一下代码库。
在lemon-mart-server
项目下,您将有三个主文件夹,如下所示:
lemon-mart-server
├───bin
├───web-app (default Angular setup)
├───server
│ ├───src
│ │ ├───models
│ │ ├───public
│ │ ├───services
│ │ ├───v1
│ │ │ └───routes
│ │ └───v2
│ │ └───routes
│ └───tests
| package.json
| README.md
bin
文件夹包含助手脚本或工具,web-app
文件夹代表您的前端,server
包含后端的源代码。在我们的例子中,web-app
文件夹就是lemon-mart
项目。我们没有复制和粘贴现有项目中的代码,而是利用 Git 子模块将两个存储库链接在一起。
Git 子模块帮助您在多个存储库之间共享代码,同时保持提交分离。前端开发人员可以选择只使用前端存储库工作,而全栈开发人员则更喜欢访问所有代码。Git 子模块还为组合现有项目提供了一种方便的方式。
让我们先看看如何将自己的lemon-mart
项目添加为lemon-mart-server
的子模块,利用 monorepo 根文件夹package.json
文件中的脚本:
我建议您在从 GitHub 克隆的lemon mart 服务器版本上执行此操作。否则,您将需要创建一个新项目并执行npm init -y
以开始工作。
-
观察以下有助于 Git 子模块初始化、更新和清理的脚本:
**package.json** "config": { ... "webAppGitUrl": "https://github.com/duluca/lemon-mart.git" }, "scripts": { "webapp:clean": "cross-conf-env rimraf web-app && git rm -r --cached web-app", "webapp:init": "cross-conf-env git submodule add $npm_package_config_webAppGitUrl web-app", "postwebapp:init": "git submodule status web-app", "modules:init": "git submodule update --init --recursive", "modules:update": "git submodule update --recursive --remote" },
-
使用您自己项目的 URL 更新
webAppGitUrl
。 -
执行
webapp:clean
删除已有的web-app
文件夹。 -
最后,执行
webapp:init
命令初始化web-app
文件夹中的项目:$ npm run webapp:init
继续执行modules:update
命令更新子模块中的代码。要在另一个环境中克隆 repo 后拉取子模块,请执行npm modules:init
。如果您需要重置环境并重新启动,请执行webapp:clean
清理 Git 的缓存并删除文件夹。
请注意,存储库中可以有多个子模块。modules:update
命令将更新所有子模块。
您的 web 应用代码现在位于名为web-app
的文件夹中。此外,您应该能够在 VS Code 的源代码管理窗格下看到两个项目,如图所示:
图 10.2:VS 代码源代码控制提供程序
使用 VS 代码的源代码控制,您可以在任一存储库上独立执行 Git 操作。
如果子模块出现混乱,只需将cd
放入子模块目录,执行git pull
和git checkout master
即可恢复主分支。使用此技术,您可以签出项目中的任何分支并提交 PRs。
现在我们已经准备好了子模块,让我们看看服务器项目是如何配置的。
要使用 TypeScript 创建一个新的 Node.js 应用,请执行以下步骤:
以下步骤仅在创建新服务器项目时相关。我建议您使用从 GitHub 克隆的lemon-mart-server
项目中已经提供的。
-
创建子文件夹
server
:$ mkdir server
-
将当前目录更改为
server
文件夹:$ cd server
-
Initialize npm to set up
package.json
in theserver
folder:$ npm init -y
请注意,顶层
package.json
将用于与全栈项目相关的脚本。server/package.json
将包含后端项目的脚本和依赖项。 -
使用
mrm-task-typescript-vscode
:$ npm i -g mrm-task-typescript-vscode $ npx mrm typescript-vscode
配置您的存储库
mrm
任务为配置 VS 代码以获得优化的 TypeScript 开发体验,类似于我们在第 2 章中使用mrm-task-angular-vscode
的方式设置您的开发环境。
当命令执行完毕后,project
文件夹显示如下:
server
│ .gitignore
│ .nycrc
│ .prettierignore
│ .prettierrc
│ example.env
│ jasmine.json
│ package-lock.json
│ package.json
│ pull_request_template.md
│ tsconfig.json
│ tsconfig.src.json
│ tslint.json
│
├───.vscode
│ extensions.json
│ launch.json
│ settings.json
│
├───src
│ index.ts
│
└───tests
│ index.spec.ts
│ tsconfig.spec.json
任务配置如下:
-
用于脚本编写的通用 npm 包:跨配置环境(https://www.npmjs.com/package/cross-conf-env 、npm 运行全部(https://www.npmjs.com/package/npm-run-all 、开发规范(https://www.npmjs.com/package/dev-norms 和 rimraf(https://www.npmjs.com/package/rimraf )
-
Npm scripts for styling, linting, building, and testing:
style
和lint
:检查代码样式和线头错误的符合性。它们用于 CI 服务器。style:fix
和lint:fix
:对代码应用代码样式和绒线规则。并非所有的脱毛错误都可以自动修复。您需要手动解决每个错误。build
:将代码传输到dist
文件夹中。start
:运行 Node.js 中传输的代码。
prepublishOnly
和prepare
脚本只有在开发 npm 包时才相关。在这种情况下,您还应该实现一个.npmignore
文件,它不包括src
和tests
文件夹。 -
ImportSort
:维护import
语句的顺序:- 已将设置添加到
package.json
- 安装了支持 npm 的软件包:导入排序、导入排序 cli、导入排序解析器 typescript 和导入排序样式模块
- 已将设置添加到
-
带有 tslint 的 TypeScript:
tsconfig.json
:常用类型脚本设置tsconfig.src.json
:特定于src
文件夹下源代码的设置tslint.json
:皮棉规则
-
Prettier 插件,可自动设置代码样式:
.prettierrc
:更漂亮的设置.prettierignore
:要忽略的文件
-
Jasmine 和纽约市单元测试和代码覆盖率:
jasmine.json
:测试设置。.nycrc
:代码覆盖设置。tests
文件夹:包含spec.ts
文件,其中包括您的测试和tsconfig.spec.json
,后者配置了更宽松的设置,使快速编写测试更容易。- 在
package.json
中:创建测试脚本以使用build:test
构建测试,并使用npm test
执行测试。test:ci
命令用于 CI 服务器,test:nyc
提供代码覆盖率报告。
-
example.env
:用于记录将出现在您的私人.env
文件中的所需环境变量.gitignore
增加了.env
-
PR 模板:从开发人员处请求附加信息的请求模板
-
VS 代码扩展名、设置和调试配置分别位于三个文件中:
.vscode/extensions.json
.vscode/settings.json
.vscode/launch.json
一旦您对引入到项目中的更改感到满意,请验证您的项目是否处于正常工作状态。
通过执行以下测试来验证项目:
$ npm test
在test
命令运行之前,执行npm run build && npm run build:test
将我们的 TypeScript 代码传输到 JavaScript。输出放在dist
文件夹中,如图所示:
server
│
├───dist
│ index.js
│ index.js.map
请注意,在您的文件系统中,.js
和.js.map
文件与每个.ts
文件一起创建。在.vscode/settings.json
中,我们将files.exclude
属性配置为在 IDE 中隐藏这些文件,以便它们不会在开发过程中分散开发人员的注意力。此外,在.gitignore
中,我们还忽略了.js
和.js.map
文件,因此它们不会被签入我们的存储库。
既然我们有了一个基本的 monorepo,我们就可以配置我们的 CI 服务器了。
使用 Git 子模块的好处之一是我们可以验证我们的前端和后端在同一管道中工作。我们将实施两项工作:
build_server
build_webapp
这些作业将遵循此处显示的工作流:
**.circleci/config.yml**
...
workflows:
version: 2
build-and-test-compose:
jobs:
- build_server
- build_webapp
CI 管道将同时构建服务器和 web 应用,如果作业在主分支上成功,可以选择运行deploy
作业。关于如何实现build_webapp
作业,请参考 GitHub 上的config.yml
文件,该文件类似于您在第 9 章中使用 Docker 实现的DevOps,但其中包括处理子模块和文件夹结构更改的一些细微差异。构建服务器的管道与 web app one 没有太大区别,如下所示:
**.circleci/config.yml**
version: 2.1
orbs:
coveralls: coveralls/coveralls@1.0.4
jobs:
build_server:
docker:
- image: circleci/node:lts
working_directory: ~/repo/server
steps:
- checkout:
path: ~/repo
- restore_cache:
keys:
- web-modules-{{ checksum "package-lock.json" }}
# check npm dependencies for security risks - 'npm audit' to fix
- run: npx audit-ci --high --report-type full
- run: npm ci
- save_cache:
key: web-modules-{{ checksum "package-lock.json" }}
paths:
- ~/.npm
- run: npm run style
- run: npm run lint
# run tests and store test results
- run: npm run pretest
- run: npm run test:ci
- store_test_results:
path: ./test_results
# run code coverage and store coverage report
- run: npm run test:nyc
- store_artifacts:
path: ./coverage
- coveralls/upload
- run:
name: Move compiled app to workspace
command: |
set -exu
mkdir -p /tmp/workspace/server
mv dist /tmp/workspace/server
- persist_to_workspace:
root: /tmp/workspace
paths:
- server
管道检查代码,验证我们与audit-ci
一起使用的软件包的安全性,安装依赖项,检查样式和 linting 错误,运行测试,并检查代码覆盖率级别。
test 命令隐式构建服务器代码,该代码存储在dist
文件夹下。在最后一步中,我们将dist
文件夹移动到工作区中,以便在稍后阶段使用它。
接下来,让我们看看如何将应用的所有层组合在一起,并使用 Docker Compose 运行它。
由于我们有一个三层体系结构,我们需要一种方便的方法来为我们的全栈应用设置基础设施。您可以创建脚本来分别启动各种 Docker 容器,但是有一个专门构建的工具,可以运行称为 Docker Compose 的多容器应用。Compose 使用名为docker-compose.yml
的 YAML 文件格式,因此您可以声明性地定义应用的配置。Compose 允许您遵循基础架构作为代码的原则。Compose 还允许我们方便地启动一个数据库实例,而无需在开发环境中安装一个永久的、始终在线的数据库解决方案。
您可以使用 Compose 在云服务上部署应用,扩展正在运行的容器实例的数量,甚至在 CI 服务器上运行应用的集成测试。在本节后面,我们将介绍如何在 CircleCI 上运行 Docker Compose。
考虑以下应用的体系结构:每个层的通信端口:
图 10.3:Lemon-Mart 三层架构
使用 Docker Compose,我们能够精确地描述此处显示的架构。您可以在上阅读更多有关撰写的信息 https://docs.docker.com/compose/ 。
接下来,让我们为 Lemon Mart 实现一个更高效的 web 服务器。
我们的 web 应用已经按照第 9 章、使用 Docker的 DevOps 进行了集装箱化。对于本练习,我们将使用基于 nginx 的容器。
将名为nginx.Dockerfile
的新 Dockerfile 添加到您的web-app
的根目录中。此映像将比我们现有的基于节点的映像小,因为我们使用 nginx 作为 web 服务器:
**web-app/nginx.Dockerfile**
FROM duluca/minimal-nginx-web-server:1-alpine
COPY dist/lemon-mart /var/www
CMD 'nginx'
现在,让我们将服务器集装箱化。
到目前为止,我们一直在使用大部分预配置的 Docker 图像来部署我们的 web 应用。以下是基于 Node.js 的服务器的更详细实现:
如果您需要,请参阅第 9 章中的使用 Docker对应用进行容器化,DevOps 使用 Docker对 Docker 进行复习。
-
Let's begin by defining the
Dockerfile
:**server/Dockerfile** FROM node:lts-alpine RUN apk add --update --no-progress make python bash ENV NPM_CONFIG_LOGLEVEL error ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 /usr/local/bin/dumb-init RUN chmod +x /usr/local/bin/dumb-init RUN mkdir -p /usr/src/app RUN chown node: /usr/src/app USER node WORKDIR /usr/src/app COPY package*.json ./ RUN NODE_ENV=production RUN npm install --only=production ENV HOST "0.0.0.0" ENV PORT 3000 EXPOSE 3000 ADD dist dist ENTRYPOINT ["dumb-init", "--"] CMD ["node", "dist/src/index"]
请注意,我们将
dist
文件夹添加到服务器,然后使用带有 CMD 的节点运行它。现在,为 Docker 设置跨环境npm 脚本,该脚本在我们服务器的 Windows 10 和 macOS 上工作。
-
为 Docker 任务安装 npm 脚本:
$ npm i -g mrm-task-npm-docker
-
应用 npm 脚本进行 Docker 配置,确保执行
server
文件夹中的命令:$ npx mrm npm-docker
-
使用配置参数
**server/package.json** "config": { "imageRepo": "duluca/lemon-mart-server", "imageName": "lemon-mart-server", "imagePort": "3000", "internalContainerPort": "3000" }
配置您的
package.json
在构建 Docker 容器之前,请确保构建应用。
DotEnv 文件得到广泛支持,可以方便地将机密存储在.env
文件中,而该文件未签入代码存储库。Docker 和 Compose 本机支持.env
文件。
让我们从了解 monorepo 核心的环境变量开始:
-
Refer to the
example.env
file at the root of the project:**example.env** # Root database admin credentials MONGO_INITDB_ROOT_USERNAME=admin MONGO_INITDB_ROOT_PASSWORD=anAdminPasswordThatIsNotThis # Your application's database connection information. # Corresponds to MONGO_URI on server-example.env MONGODB_APPLICATION_DATABASE=lemon-mart MONGODB_APPLICATION_USER=john.smith MONGODB_APPLICATION_PASS=g00fy # Needed for AWS deployments AWS_ACCESS_KEY_ID=xxxxxx AWS_SECRET_ACCESS_KEY=xxxxxx # See server-example.env for server environment variables
不要在
example.env
中存储任何真正的秘密。将它们存储在.env
文件中。example.env
用于记录项目所需的环境变量。在本例中,我用示例值填充了我的example.env
文件,这样读者就可以运行示例,而不必配置所有这些参数。 -
通过执行
$ npm i -D init-dev-env
确保安装在项目的根目录中
-
The
npm run init:env
script generates.env
files based on theexample.env
file using theinit-dev-env
package:在lemon mart 服务器中,服务器的
example.env
文件存在于两个位置。第一个在项目的根目录中为server-example.env
,第二个在server/example.env
下。这样做是为了增加示例配置设置的可见性。$ npx init-dev-env generate-dot-env example.env -f && init-dev-env generate-dot-env server-example.env --source=. --target=server -f
-
为服务器生成第二个
.env
文件,如图所示:**server/.env** # MongoDB connection string as defined in example.env MONGO_URI=mongodb://john.smith:g00fy@localhost:27017/lemon-mart # Secret used to generate a secure JWT JWT_SECRET=aSecureStringThatIsNotThis # DEMO User Login Credentials DEMO_EMAIL=duluca@gmail.com DEMO_PASSWORD=l0l1pop!! DEMO_USERID=5da01751da27cc462d265913
请注意,该文件包含到 MongoDB 的连接字符串、我们将用于加密 JWTs 的秘密以及一个种子用户,以便我们可以登录到应用。通常,您不会为种子用户配置密码或用户 ID。这些只是为了支持可重复的演示代码。
现在,我们已经准备好为 Compose 定义 YAML 文件。
让我们在 monorepo 的根目录中定义一个docker-compose.yml
文件来反映我们的架构:
**docker-compose.yml**
version: '3.7'
services:
web-app:
container_name: web
build:
context: ./web-app
dockerfile: nginx.Dockerfile
ports:
- '8080:80'
links:
- server
depends_on:
- server
server:
container_name: lemon-mart-server
build: server
env_file: ./server/.env
environment:
- MONGO_URI=mongodb://john.smith:g00fy@lemondb:27017/lemon-mart
ports:
- '3000:3000'
links:
- database
depends_on:
- database
database:
container_name: lemondb
image: duluca/minimal-mongo:4.2.2
restart: always
env_file: .env
ports:
- '27017:27017'
volumes:
- 'dbdata:/data/db'
volumes:
dbdata:
在顶部,我们使用基于 nginx 的容器构建web-app
服务。build
属性自动为我们构建Dockerfile
。我们正在公开端口8080
上的web-app
并将其链接到server
服务。links
属性创建一个隔离的 Docker 网络,以确保我们的容器可以相互通信。通过使用depends_on
属性,我们确保服务器在web-app
启动之前启动。
server
还使用build
属性进行自动Dockerfile
构建。它还使用env_file
属性从server
文件夹下的.env
文件加载环境变量。使用environment
属性,我们重写MONGO_URI
变量以使用数据库容器的内部 Docker 网络名称。服务器同时支持links
和depends_on
数据库,数据库名为lemondb
。
database
服务从 Docker Hub 提取duluca/minimal-mongo
映像。使用restart
属性,我们确保数据库在崩溃时自动重启。我们使用.env
文件中的设置参数来配置和密码保护数据库。使用volumes
属性,我们将数据库的存储目录装载到本地目录,以便您的数据可以在容器重新启动时持久化。
在云环境中,您可以将数据库的卷装载到云提供商的持久化解决方案,包括 AWS弹性文件系统(EFS)或 Azure 文件存储。
此外,我们还定义了一个名为dbdata
的 Docker 卷用于数据存储。
有时,数据库可能会停止正常工作。如果升级容器、使用其他容器或在其他项目中使用相同的卷,则可能会发生这种情况。在此实例中,您可以通过执行以下命令重置 Docker 设置的状态:
$ docker image prune
$ docker container prune
$ docker volume prune
或
$ docker system prune --volumes **(this will delete everything)**
要运行您的基础设施,您将执行docker-compose up
命令。您还可以在分离模式下使用选项对您的基础设施进行隔离。您可以使用down
命令停止它,并通过rm
命令移除它创建的容器。
在运行基础设施之前,您需要构建应用,这将在下一节中介绍。
运行docker-compose up
是启动基础设施的一种方便而简单的方法。但是,您需要在构建容器之前构建代码。这是一个容易忽视的步骤。请参阅一些 npm 脚本,这些脚本可用于协调基础架构的启动:
**package.json**
scripts: {
"build": "npm run build --prefix ./server && npm run build --prefix ./web-app -- --configuration=lemon-mart-server",
"test": "npm test --prefix ./server && npm test --prefix ./web-app -- --watch=false",
"prestart": "npm run build && docker-compose build",
"start": "docker-compose up",
"stop": "docker-compose down",
"clean": "docker-compose rm",
"clean:all": "docker system prune --volumes",
"start:backend": "docker-compose -f docker-compose.backend.yml up --build",
"start:database": "docker-compose -f docker-compose.database.yml up --build",
我们实现了一个build
脚本,为服务器和 web 应用运行build
命令。test
脚本也可以执行相同的测试。我们实现了一个npm start
命令,可以自动运行build
命令并运行compose up
。作为奖励,我们还实现了start:backend
和start:database
脚本,可以运行备用docker-compose
文件来支持服务器或数据库。您可以通过删除主docker-compose.yml
文件中不必要的部分来创建这些文件。有关示例,请参阅 GitHub 回购协议。
在服务器上编码时,我通常执行npm run start:database
来建立数据库,并在一个单独的终端窗口中,使用server
文件夹中的npm start
启动服务器。这样,我可以看到两个系统同时生成日志。
执行npm start
至验证您的docker-compose
配置是否正常工作。点击Ctrl+C停止基础设施。
您可以在 CircleCI 上执行 Compose 基础设施,以验证配置的正确性并运行快速集成测试。请参阅以下更新的工作流:
**.circleci/config.yml**
workflows:
version: 2
build-and-test-compose:
jobs:
- build_server
- build_webapp
- test_compose:
requires:
- build_server
- build_webapp
我们确保在运行名为test_compose
的新作业之前构建server
和web-app
,该作业将检查代码、初始化子模块并复制两个构建的dist
文件夹,如下所示:
**.circleci/config.yml**
test_compose:
docker:
- image: circleci/node:lts-browsers
working_directory: ~/repo
steps:
- setup_remote_docker
- attach_workspace:
at: /tmp/workspace
- checkout:
path: ~/repo
- run: npm run modules:init
- run:
name: Copy built server to server/dist folder
command: cp -avR /tmp/workspace/server/dist/ ./server
- run:
name: Copy built web-app to web-app/dist folder
command: cp -avR /tmp/workspace/dist/ ./web-app
- run:
name: Restore .env files
command: |
set +H
echo -e $PROJECT_DOT_ENV > .env
echo -e $SERVER_DOT_ENV > server/.env
- run:
name: Compose up
command: |
set -x
docker-compose up -d
- run:
name: Verify web app
command: |
set -x
docker run --network container:web jwilder/dockerize -wait http://localhost:80
docker run --network container:web appropriate/curl http://localhost:80
- run:
name: Verify db login with api
command: |
set -x
docker run --network container:lemon-mart-server jwilder/dockerize -wait http://localhost:3000
docker run --network container:lemon-mart-server appropriate/curl \
-H "accept: application/json" -H "Content-Type: application/json" \
-d "$LOGIN_JSON" http://localhost:3000/v1/auth/login
复制dist
文件后,作业会从 CircleCI 环境变量中放置.env
文件。然后,我们运行docker-compose up
来支持我们的服务器。接下来,我们通过运行curl
命令来检索其index.html
文件来测试web-app
。我们run curl
在等待服务器使用dockerize -wait
可用后。同样,我们通过使用演示用户登录来测试 API 服务器和数据库的集成。
祝贺现在,您已经很好地理解了我们的全栈体系结构是如何在高层次上拼接在一起的。在本章的后半部分,我们将介绍 API 是如何实现的,它是如何与数据库集成的,并了解 JWT auth 是如何与 API 和数据库协同工作的。
让我们继续深入 API 设计。
在全栈开发中,尽早确定 API 设计是很重要的。API 设计本身与数据契约的外观密切相关。您可以创建 RESTful 端点或使用下一代 GraphQL 技术。在设计 API 时,前端和后端开发人员应密切协作,以实现共同的设计目标。以下列出了一些高级别目标:
- 最小化客户端和服务器之间传输的数据
- 坚持良好的设计模式(换句话说,数据分页)
- 旨在减少客户端中存在的业务逻辑的设计
- 扁平化数据结构
- 不要公开数据库键或关系
- 从开始到结束的版本端点
- 围绕主要数据实体进行设计
您的目标应该是在 RESTful API 中实现业务逻辑。理想情况下,前端应该只包含表示逻辑。前端实现的任何if
语句也应该在后端进行验证。
正如第 1 章**Angular 及其概念简介中所述,在后端和前端实现无状态设计至关重要。每个请求都应使用非阻塞 I/O 方法,并且不应依赖于任何现有会话。这是使用云托管提供商无限扩展 web 应用的关键。
无论何时,当你在实施一个项目时,如果不是消除实验的话,也要限制实验,这一点很重要。在全栈项目中尤其如此。一旦应用上线,API 设计中的失误可能会产生深远的影响,并且不可能纠正。
接下来,让我们看看围绕主要数据实体设计一个 API。在本例中,我们将回顾围绕用户的 API 的实现,包括认证。首先,我们将探索如何使用 Swagger 定义端点,以便我们能够具体地向团队成员传达我们的设计意图。
请记住,本章仅介绍概念上重要的代码片段。虽然您可以选择从头开始实现这段代码,但没有必要了解它是如何工作的。如果您选择从头开始实现,请参考中的完整源代码 https://github.com/duluca/lemon-mart-server 跟进并弥合实施过程中的差距。
稍后,Swagger 将成为一个文档工具,反映我们 API 的功能。
Swagger 将允许您设计和记录 web API。对于团队来说,它可以作为前端和后端开发人员之间的沟通工具,从而减少很多摩擦。此外,在早期定义 API 表面允许开始实现,而无需担心后期集成挑战。
接下来,我们将实现一个用户 API,以演示 Swagger 是如何工作的。
我强烈建议安装 Swagger Viewer VS 代码扩展,它允许我们在不运行任何其他工具的情况下预览 YAML 文件。
让我们从探索 monorepo 根目录下的swagger.yaml
文件开始:
-
在 VS 代码中打开
swagger.yaml
。 -
安装名为 Swagger Preview 的 VS 代码扩展。
-
See the preview, as shown here:
图 10.4:Swagger.yaml 预览
使用招摇过市 UI 视图,您将能够在服务器环境中尝试命令并执行它们。
我们将使用 Swagger 规范版本openapi: 3.0.1
,它实现了 OpenAPI 标准。让我们在这里回顾一下swagger.yaml
文件的主要组成部分:
有关如何定义招摇过市文件的更多信息,请参阅https://swagger.io/specification/ 。
-
YAML 文件以一般信息和目标服务器开始:
**swagger.yaml** openapi: 3.0.1 **info**: title: LemonMart description: LemonMart API version: "2.0.0" **servers**: - url: http://localhost:3000 description: Local environment - url: https://mystagingserver.com description: Staging environment - url: https://myprodserver.com description: Production environment
-
Under
components
, we define commonsecuritySchemes
and responses, which define the authentication scheme we intend to implement and how the shape of our error message response will appear:**swagger.yaml** ... **components:** **securitySchemes:** bearerAuth: type: http scheme: bearer bearerFormat: JWT **responses:** UnauthorizedError: description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/ServerMessage" type: string
注意使用
$ref
重用重复元素。你可以在这里看到ServerMessage
的定义。 -
在
components
下,我们定义了共享数据schemas
,它声明了我们作为输入或返回给客户端的数据实体:**swagger.yaml** ... **schemas:** ServerMessage: type: object properties: message: type: string Role: type: string enum: [none, clerk, cashier, manager] ...
-
在
components
下,我们添加了共享的parameters
,使之易于重用常用模式,例如分页端点:**swagger.yaml** ... **parameters:** filterParam: in: query name: filter required: false schema: type: string description: Search text to filter the result set by ...
-
Under
paths
, we begin defining REST endpoints, such as apost
endpoint for the/login
path:**swagger.yaml** ... **paths:** /v1/login: post: description: | Generates a JWT, given correct credentials. requestBody: required: true content: application/json: schema: type: object properties: email: type: string password: type: string required: - email - password responses: '200': # Response description: OK content: application/json: schema: type: object properties: accessToken: type: string description: JWT token that contains userId as subject, email and role as data payload. '401': $ref: '#/components/responses/UnauthorizedError'
注意,
requestBody
定义了string
类型所需的输入变量。在responses
下,我们可以定义对请求的成功200
响应和不成功401
响应是如何出现的。在前一种情况下,我们返回一个accessToken
,而在后一种情况下,我们返回一个UnauthorizedError
,如步骤 2 中所定义。 -
在
paths
下,我们通过添加以下路径继续:**swagger.yaml** ... **paths:** /v1/auth/me: get: ... /v2/users: get: ... post: ... /v2/users/{id}: get: ... put: ...
OpenAPI 规范功能强大,允许您定义用户如何与 API 交互的复杂需求。处的规范文件 https://swagger.io/docs/specification 是开发自己的 API 定义时的宝贵资源。
您可以在处验证您的招摇过市文件 https://swaggerhub.com 免费。注册免费帐户后,创建一个新项目并定义您的 YAML 文件。SwaggerHub 将突出显示您所犯的任何错误。它还将为您提供 web 视图的预览,这与您使用“大摇大摆预览 VS 代码扩展”获得的相同。
请参阅以下屏幕截图,查看有效的 Swagger YAML 定义在 Swagger Hub 上的外观:
图 10.5:Swagger Hub 上的有效 Swagger YAML 定义
我们的目标是将此交互式文档与我们的 Express.js API 集成。
现在,让我们看看如何实现这样一个 API。
在开始实施我们的 API 之前,让我们分几节回顾一下后端的目标文件结构,以便我们了解服务器是如何引导的,如何为 API 端点配置路由,如何为公共资源提供服务,以及如何配置服务。Minimal MEAN 故意坚持基本原则,因此您可以了解有关底层技术的更多信息。虽然我已经使用最小平均值交付了生产系统,但您可能不会像我那样享受赤裸裸的开发体验。在这种情况下,您可以考虑 Nest.js,它是实现全栈 NoDE.js 应用的流行框架。js 拥有丰富的功能集,其架构和编码风格与 Angular 非常相似。我建议您在掌握了平均栈的基础知识后使用这样的库。
感谢 Kamil Mysliwiec 和 Mark Pieszak 围绕 Nest.js 创建了一个很棒的工具和充满活力的社区。您可以在上阅读更多关于 Nest.js 的信息 https://nestjs.com/ 和在征集咨询服务 https://trilon.io/ 。
现在,让我们回顾一下 Express 服务器的文件结构:
**server/src**
│ api.ts
│ app.ts
│ config.ts
│ docs-config.ts
│ index.ts
│
├───models
│ enums.ts
│ phone.ts
│ user.ts
│
├───public
│ favicon.ico
│ index.html
│
├───services
│ authService.ts
│ userService.ts
│
├───v1
│ │ index.ts
│ │
│ └───routes
│ authRouter.ts
│
└───v2
│ index.ts
│
└───routes
userRouter.ts
让我们通过查看组件图来回顾这些文件的用途和相互作用,为我们提供架构和依赖关系树的概述:
图 10.6:Express 服务器体系结构
index.ts
包含一个start
函数,该函数利用三个主要助手引导应用:
config.ts
:管理环境变量和设置。app.ts
:配置 Express.js,定义所有 API 路径,然后路由实现路径并利用包含业务逻辑的服务。服务使用user.ts
等模型访问数据库。document-ts
:建立与数据库的连接并进行配置,并在启动期间利用user.ts
配置种子用户。
您可以看到,图顶部的组件负责启动和配置杂务,包括配置 API 路径,代表API层。服务层应该包含应用的大部分业务逻辑,而持久性在模型层处理。
参考index.ts
在没有任何数据库功能的情况下的以下实现:
**server/src/index.ts**
import * as http from 'http'
import app from './app'
import * as config from './config'
export let Instance: http.Server
async function start() {
console.log('Starting server: ')
console.log(`isProd: ${config.IsProd}`)
console.log(`port: ${config.Port}`)
Instance = http.createServer(app)
Instance.listen(config.Port, async () => {
console.log(`Server listening on port ${config.Port}...`)
})
}
start()
请注意,显示的最后一行代码start()
是触发服务器初始化的函数调用。
现在,让我们看看 Express 服务器是如何设置的。
App.ts
配置 Express.js,以及服务静态资产、路由和版本控制。Express.js 利用中间件功能与库或您自己的代码集成,例如认证方法:
**server/src/app.ts**
import * as path from 'path'
import * as cors from 'cors'
import * as express from 'express'
import * as logger from 'morgan'
import api from './api'
const app = express()
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(logger('dev'))
app.use('/', express.static(path.join(__dirname, '../public'), { redirect: false }))
app.use(api)
export default app
在前面的代码中,请注意,使用use()
方法配置 Express 非常简单。首先,我们配置cors
,然后表示解析器和logger
。
接下来,使用express.static
功能,我们在根目录的路径/
上提供public
文件夹,因此我们可以显示一些关于我们服务器的有用信息,如图所示:
图 10.7:LemonMart 服务器登录页
我们将在下一节介绍如何配置上面提到的/api-docs
端点。
最后,我们配置路由,在api.ts
中定义。
Api.ts
配置快速路由。请参阅以下实施:
**server/src/api.ts**
import { Router } from 'express'
import api_v1 from './v1'
import api_v2 from './v2'
const api = Router()
// Configure all routes here
api.use('/v1', api_v1)
api.use('/v2', api_v2)
export default api
在本例中,v1
和v2
有两个子路由。始终对您实现的 API 进行版本设置是至关重要的。一旦一个 API 公开,简单地淘汰一个新版本的 API 是非常棘手的,有时甚至是不可能的。即使是微小的代码更改或 API 中的细微差异也可能导致客户端中断。您必须注意只对 API 进行向后兼容的更改。
在某个时刻,您需要完全重写端点以满足新的需求、性能和业务需求,此时您可以简单地实现端点的v2
版本,同时保持v1
实现不变。这使您能够以需要的速度进行创新,同时保持应用的传统消费者功能正常。
简而言之,您应该对您创建的每个 API 进行版本化。通过这样做,您可以强制您的消费者将他们对 API 的 HTTP 调用版本化。随着时间的推移,您可以在不同版本下转换、复制和停用 API。然后,消费者可以选择调用适用于他们的 API 的任何版本。
配置路由非常简单。我们来看看v2
的配置,如图所示:
**server/src/v2/index.ts**
import { Router } from 'express'
import userRouter from './routes/userRouter'
const router = Router()
// Configure all v2 routers here
router.use('/users?', userRouter)
export default router
/users?
结尾的问号表示/user
和/users
都将对userRouter
中执行的操作起作用。这是一个避免打字错误的好方法,同时允许开发人员选择对操作有意义的复数。
在userRouter
中,您可以执行 GET、POST、PUT 和 DELETE 操作。请参阅以下实施:
**server/src/v2/routes/userRouter.ts**
const router = Router()
router.get('/', async (req: Request, res: Response) => {
})
router.post('/', async (req: Request, res: Response) => {
})
router.get('/:userId', async (req: Request, res: Response) => {
})
router.put('/:userId', async (req: Request, res: Response) => {
})
export default router
在前面的代码中,您可以观察路由参数的使用情况。您可以通过请求对象使用路由参数,例如req.params.userId
。
请注意,示例代码中的所有路由都标记为async
,因为它们都将进行数据库调用,我们将调用await
。如果您的路线是同步的,那么您不需要async
关键字。
接下来,让我们看看服务。
我们不希望在路由文件中实现我们的业务逻辑,路由文件代表我们的 API 层。API 层应该主要包括转换数据和调用业务逻辑层。
您可以使用 Node.js 和 TypeScript 功能实现服务。不需要花哨的依赖注入。示例应用实现了两个服务–authService
和userService
。
例如,在userService.ts
中,您可以实现一个名为createNewUser
的函数:
**server/src/services/userService.ts**
import { IUser, User } from '../models/user'
export async function createNewUser(userData: IUser): Promise<User | boolean> {
// create user
}
createNewUser
接受IUser
形状的userData
,创建完用户后返回User
实例。然后,我们可以在路由中使用此功能,如下所示:
**server/src/v2/routes/userRouter.ts**
import { createNewUser } from '../../services/userService'
router.post('/', async (req: Request, res: Response) => {
const userData = req.body as IUser
const success = await createNewUser(userData)
if (success instanceof User) {
res.send(success)
} else {
res.status(400).send({ message: 'Failed to create user.' })
}
})
我们可以等待createNewUser
的结果,如果成功,将创建的对象作为对 POST 请求的响应返回。
请注意,尽管我们将req.body
转换为IUser
,但这只是开发时的舒适特性。在运行时,使用者可以向主体传递任意数量的属性。不小心处理请求参数是恶意利用代码的主要方式之一。
现在我们已经了解了 Express 服务器的框架,让我们看看如何配置 Swagger,以便将其用作实现指南,并为 API 创建动态文档。
使用 Express 配置 Swagger 是一个手动过程。强迫自己手动记录端点有很大的副作用。通过减速,您将有机会从消费者的 Angular 以及执行者的 Angular 考虑您的实现。此透视图将帮助您在开发过程中解决端点的潜在问题,从而避免代价高昂的返工。
将 Swagger 与服务器集成的主要好处是,您将获得与本章前面介绍的相同的交互式 Swagger UI,因此您的测试人员和开发人员可以直接从 web 界面发现或测试您的 API。
我们将使用两个助手库来帮助我们将 Swagger 集成到服务器中:
swagger-jsdoc
:通过使用JSDoc
注释块中的@swagger
标识符,生成一个swagger.json
文件作为输出,您可以在相关代码的正上方实现 OpenAPI 规范。swagger-ui-express
:这将使用swagger.json
文件来显示交互的 Swagger UI web 界面。
让我们来看看 Swagger 是如何配置为与 Express.js 一起工作的:
-
TypeScript 的依赖项和类型信息如下所示:
$ npm i swagger-jsdoc swagger-ui-express $ npm i -D @types/swagger-jsdoc @types/swagger-ui-express
-
Let's go over the
docs-config.ts
file, which configures the base OpenAPI definition:**server/src/docs-config.ts** import * as swaggerJsdoc from 'swagger-jsdoc' import { Options } from 'swagger-jsdoc' import * as packageJson from '../package.json' const options: Options = { swaggerDefinition: { openapi: '3.0.1', components: {}, info: { title: packageJson.name, version: packageJson.version, description: packageJson.description, }, servers: [ { url: 'http://localhost:3000', description: 'Local environment', }, { url: 'https://mystagingserver.com', description: 'Staging environment', }, { url: 'https://myprodserver.com', description: 'Production environment', }, ], }, apis: [ '**/models/*.js', '**/v1/routes/*.js', '**/v2/routes/*. js' ], } export const specs = swaggerJsdoc(options)
修改
servers
属性以包括测试、暂存或生产环境的位置。这允许 API 的使用者使用 web 界面测试 API,而无需额外的工具。注意,apis
属性通知swaggerJsdoc
在构造swagger.json
文件时应该解析的代码文件。此例程在服务器引导期间运行,这就是为什么我们引用传输的.js
文件而不是.ts
文件。 -
引导在
app.ts
**server/src/app.ts** import * as swaggerUi from 'swagger-ui-express' import { specs } from './docs-config' const app = express() app.use(cors()) ... **app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs))** ... export default app
中的招摇配置
规范包含文件的内容,然后传递给swaggerUi
。然后,使用服务器中间件,我们可以配置swaggerUi
在/api-docs
托管 web 界面。
从本章开始,您已经有了完成应用实现所需的 OpenAPI 定义。参考处的完整源代码 https://github.com/duluca/lemon-mart-server 寻求更多帮助。
祝贺现在,您对我们的 Express 服务器的工作原理有了很好的了解。接下来,让我们看看如何连接到 MongoDB。
DocumentTS 充当 ODM,实现一层模型,以实现与数据库对象的丰富且可定制的交互。ODM 是基于文档的数据库,相当于关系数据库中的对象关系映射器(ORM)。考虑 Hibernate 或实体框架。如果您不熟悉这些概念,我建议您在继续之前做进一步的研究。
在其核心,DocumentTS 利用了 MongoDB 的 Node.js 驱动程序。该驱动程序由 MongoDB 的制造商实现。它保证提供与新 MongoDB 版本相同的最佳性能和功能,而第三方库在支持新功能方面往往滞后。使用database.getDbInstance
方法,您可以直接访问本机驱动程序。否则,您将通过实现的模型访问 Mongo。请参阅下图以了解概述:
图 10.8:文件概述
您可以在上阅读更多关于 MongoDB 的 Node.js 驱动程序的信息 https://mongodb.github.io/node-mongodb-native/ 。
DocumentTS 提供三大特点:
connect()
:MongoDB 异步连接线束Document
和IDocument
:用于帮助定义自己模型的基类和接口CollectionFactory
:定义集合,组织索引,并在集合实现的同时聚合查询
以下是 DocumentTS 集合提供的便利功能:
-
get collection
返回本机 MongoDB 集合,可以直接操作:get collection(): ICollectionProvider<TDocument>
-
aggregate
允许您运行 MongoDB 聚合管道:aggregate(pipeline: object[]): AggregationCursor<TDocument>
-
findOne
和findOneAndUpdate
简化了常用数据库功能的操作,自动补水返回的模型:async findOne( filter: FilterQuery<TDocument>, options?: FindOneOptions ): Promise<TDocument | null> async findOneAndUpdate( filter: FilterQuery<TDocument>, update: TDocument | UpdateQuery<TDocument>, options?: FindOneAndReplaceOption ): Promise<TDocument | null>
-
findWithPagination
是迄今为止 Documents 最好的功能,允许您对大量数据集进行筛选、排序和分页。此函数适合与数据表一起使用,因此您可以指定可搜索属性,关闭搜索,并使用调试功能微调查询:async findWithPagination<TReturnType extends IDbRecord>( queryParams: Partial<IQueryParameters> & object, aggregationCursorFunc?: Func<AggregationCursor<TReturnType>>, query?: string | object, searchableProperties?: string[], hydrate = true, debugQuery = false ): Promise<IPaginationResult<TReturnType>>
DocumentTS 的目标是可靠、可选和方便使用。documents 直接将开发人员暴露于 native Node.js 驱动程序,因此您将学习如何使用 MongoDB 而不是某些库。开发人员可以选择利用库的便利功能,包括以下功能:
- 通过简单的接口定义您自己的模型。
- 选择要自动添加水合物的字段,例如子对象或相关对象。
- 使用每个请求序列化计算字段。
- 保护某些字段(如密码)不被序列化,以便它们不会意外地通过网络发送。
documents 是可选的,它允许开发人员在自己的时间内转换到新功能。如果性能成为一个问题,您可以轻松切换到本机 MongoDB 调用以获得最佳性能。使用 DocumentTS,您将花费比 DocumentTS 更多的时间阅读 MongoDB 文档。
Mongoose 是一个与 MongoDB 交互的流行库。然而,它是 MongoDB 的包装,需要完全接受。此外,该库抽象掉了本机驱动程序,因此它对生态系统中的更改和更新非常敏感。您可以在上阅读更多关于猫鼬的信息 https://mongoosejs.com/ 。
使用以下命令为 TypeScript 安装 MongoDB 依赖项和类型信息:
$ npm i mongodb document-ts
$ npm i -D @types/mongodb
接下来,让我们看看如何连接到数据库。
在编写完全异步的 web 应用时,确保数据库连接存在可能是一个挑战。connect()
可以轻松连接到 MongoDB 实例,并且可以安全地从同时启动的多个线程同时调用。
让我们从配置环境变量开始:
-
Remember that the
MONGO_URI
connection string resides inserver/.env
:**server/.env** MONGO_URI=mongodb://john.smith:g00fy@localhost:27017/lemon-mart
为了更新用户名、密码和数据库名称,您需要在顶层
.env
文件中编辑以下变量:**.env** MONGODB_APPLICATION_DATABASE=lemon-mart MONGODB_APPLICATION_USER=john.smith MONGODB_APPLICATION_PASS=g00fy
记住
.env
更改只有在重新启动服务器时才会生效。 -
让我们来看看是如何与
index.ts
**server/src/index.ts** ... import * as document from 'document-ts' import { UserCollection } from './models/user' ... async function start() { ... console.log(`mongoUri: ${config.MongoUri}`) try { **await document.connect(config.MongoUri, config.IsProd)** console.log('Connected to database!') } catch (ex) { console.log(`Couldn't connect to a database: ${ex}`) } ... Instance.listen(config.Port, async () => { console.log(`Server listening on port ${config.Port}...`) **await createIndexes()** console.log('Done.') }) } async function createIndexes() { console.log('Create indexes...') **await UserCollection.createIndexes()** } start()
集成的
我们尝试使用try/catch
块连接到数据库。一旦 Express 服务器启动并运行,我们将调用createIndexes
,这反过来调用UserCollection
上同名的函数。除了性能方面的考虑之外,MongoDB 索引对于使字段成为可搜索字段也是必要的。
您可以实现一个IUser
接口,该接口与 LemonMart 中的接口类似。但是,这将扩展文档中定义的IDocument
:
-
Here is the
IUser
interface:**server/src/models/user.ts** export interface IUser extends IDocument { email: string name: IName picture: string role: Role userStatus: boolean dateOfBirth: Date level: number address: { line1: string line2?: string city: string state: string zip: string } phones?: IPhone[] }
DocumentTS 提供的接口和基类旨在帮助您以一致的方式开发业务逻辑和数据库查询。我鼓励您通过点击
Ctrl
来探索基类和接口,这样您就可以看到它们背后的源代码。 -
Now, here is the
User
class extendingDocument<T>
and implementing Swagger documentation:**server/src/models/user.ts** import { v4 as uuid } from 'uuid' /** * @swagger * components: * schemas: * Name: * type: object * … * User: * type: object * … */ export class User extends Document<IUser> implements IUser { static collectionName = 'users' private password: string public email: string public name: IName public picture: string public role: Role public dateOfBirth: Date public userStatus: boolean public level: number public address: { line1: string city: string state: string zip: string } public phones?: IPhone[] constructor(user?: Partial<IUser>) { super(User.collectionName, user) } fillData(data?: Partial<IUser>) { if (data) { Object.assign(this, data) } if (this.phones) { this.phones = this.hydrateInterfaceArray( Phone, Phone.Build, this.phones ) } } getCalculatedPropertiesToInclude(): string[] { return ['fullName'] } getPropertiesToExclude(): string[] { return ['password'] } public get fullName(): string { if (this.name.middle) { return `${this.name.first} ${this.name.middle} ${this.name.last}` } return `${this.name.first} ${this.name.last}` } async create(id?: string, password?: string, upsert = false) { if (id) { this._id = new ObjectID(id) } if (!password) { password = uuid() } this.password = await this.setPassword(password) await this.save({ upsert }) } hasSameId(id: ObjectID): boolean { return this._id.toHexString() === id.toHexString() } }
注意属性
getCalculatedPropertiesToInclude
和getPropertiesToExclude
。这些定义了是由客户端序列化字段还是允许将字段写入数据库。数据的序列化和反序列化是将数据转换为可存储或传输的格式的概念。有关序列化和 JSON 数据格式的文章链接,请参阅进一步阅读部分。
fullName
是一个计算属性,因此我们不想将此值写入数据库。但是,fullName
对客户机很有用。另一方面,password
属性永远不应该传输回客户端,但显然我们需要能够将其保存到数据库中,以便进行密码比较和更改。保存后,我们传入{ upsert }
对象,指示数据库更新记录,即使提供了部分信息。请记住提供完整的招摇过市定义。
-
还有最后我们来看
UserCollectionFactory
,它实现了CollectionFactory<T>
:**server/src/models/user.ts** class UserCollectionFactory extends CollectionFactory<User> { constructor(docType: typeof User) { super(User.collectionName, docType, ['name.first', 'name.last', 'email']) } async createIndexes() { await this.collection().createIndexes([ { key: { email: 1, }, unique: true, }, { key: { 'name.first': 'text', 'name.last': 'text', email: 'text', }, weights: { 'name.last': 4, 'name.first': 2, email: 1, }, name: 'TextIndex', }, ]) } userSearchQuery( searchText: string ): AggregationCursor<{ _id: ObjectID; email: string }> { const aggregateQuery = [ { $match: { $text: { $search: searchText }, }, }, { $project: { email: 1, }, }, ] if (searchText === undefined || searchText === '') { delete (aggregateQuery[0] as any).$match.$text } return this.collection().aggregate(aggregateQuery) } } export let UserCollection = new UserCollectionFactory(User)
在这里,我们创建了一个唯一的索引,这样另一个具有相同电子邮件的用户将无法注册。我们还创建了一个加权索引,它可以帮助编写过滤器查询。我们在index.ts
中连接数据库后立即应用索引。
userSearchQuery
是一个有点做作的示例,用于演示 MongoDB 中的聚合查询。使用 MongoDB 中的聚合可以执行更复杂和高性能的查询。有关 MongoDB 聚合的更多信息,请访问https://docs.mongodb.com/manual/aggregation 。
在文件的底部,我们实例化了一个UserCollection
并将其导出,因此可以从应用中的任何位置引用它:
**server/src/models/user.ts**
**export** let UserCollection = new UserCollectionFactory(User)
注意,UserCollectionFactory
没有导出,因为它只在user.ts
文件中需要。
让我们看看如何使用新的用户模型获取数据。
在第 8 章**设计认证和授权中,我们讨论了如何实现基于 JWT 的认证机制。在 LemonMart 中,您实现了一个基本认证服务,可以为自定义认证服务进行扩展。
我们将利用三个软件包进行实施:
jsonwebtoken
:用于创建和编码 JWTsbcryptjs
:用于在将用户密码保存到数据库之前对其进行哈希和盐渍处理,因此我们从不以明文形式存储用户密码uuid
:生成的通用唯一标识符,用于将用户密码重置为随机值
散列函数是一种一致可重复的单向加密方法,这意味着您每次提供相同的输入时都会得到相同的输出,但即使您可以访问散列值,您也无法轻松确定它存储了哪些信息。但是,我们可以通过对用户输入进行散列,并将其输入的散列与存储的密码散列进行比较,来比较用户是否输入了正确的密码。
-
让我们看看与 JWT auth 相关的依赖项和 TypeScript 的类型信息:
$ npm i bcryptjs jsonwebtoken uuid $ npm i -D @types/bcryptjs @types/jsonwebtoken @types/uuid
-
观察带有密码散列功能的
User
模型:**server/src/models/user.ts** import * as bcrypt from 'bcryptjs' async create(id?: string, password?: string, upsert = false) { ... this.password = await this.setPassword(password) await this.save({ upsert }) } async resetPassword(newPassword: string) { this.password = await this.setPassword(newPassword) await this.save() } private setPassword(newPassword: string): Promise<string> { return new Promise<string>((resolve, reject) => { bcrypt.genSalt(10, (err, salt) => { if (err) { return reject(err) } bcrypt.hash(newPassword, salt, (hashError, hash) => { if (hashError) { return reject(hashError) } resolve(hash) }) }) }) } comparePassword(password: string): Promise<boolean> { const user = this return new Promise((resolve, reject) => { bcrypt.compare(password, user.password, (err, isMatch) => { if (err) { return reject(err) } resolve(isMatch) }) }) }
使用setPassword
方法,您可以对用户提供的密码进行哈希运算,并将其安全地保存到数据库中。稍后,我们将使用comparePassword
函数将用户提供的值与散列密码进行比较。我们从不存储用户提供的值,因此系统永远无法复制用户的密码,使其成为安全的实现。
以下是lemon-mart-server
在authService
中的登录方式实现:
**server/src/services/authService.ts**
import * as jwt from 'jsonwebtoken'
import { JwtSecret } from '../config'
export const IncorrectEmailPasswordMessage = 'Incorrect email and/or password'
export const AuthenticationRequiredMessage = 'Request has not been authenticated'
export function createJwt(user: IUser): Promise<string> {
return new Promise<string>((resolve, reject) => {
const payload = {
email: user.email,
role: user.role,
picture: user.picture,
}
jwt.sign(
payload,
JwtSecret(),
{
subject: user._id.toHexString(),
expiresIn: '1d',
},
(err: Error, encoded: string) => {
if (err) {
reject(err.message)
}
resolve(encoded)
}
)
})
}
前面的代码示例实现了一个createJwt
函数来为每个用户创建一个 JWT。我们还为认证失败定义了屏蔽响应。请注意不正确的电子邮件/密码消息的模糊性,这意味着坏的参与者无法捕获系统以利用认证系统。
让我们在/v1/auth/login
实现登录 API:
**server/src/v1/routes/authRouter.ts**
import { Request, Response, Router } from 'express'
import { UserCollection } from '../../models/user'
import {
AuthenticationRequiredMessage,
IncorrectEmailPasswordMessage,
authenticate,
createJwt,
} from '../../services/authService'
const router = Router()
/**
* @swagger
* /v1/auth/login:
* post:
* …
*/
router.post('/login', async (req: Request, res: Response) => {
const userEmail = req.body.email?.toLowerCase()
const user = await UserCollection.findOne({ email: userEmail })
if (user && (await user.comparePassword(req.body.password))) {
return res.send({ accessToken: await createJwt(user) })
}
return res.status(401).send({
message: IncorrectEmailPasswordMessage
})
})
请注意,通过电子邮件检索用户时,请记住电子邮件不区分大小写。因此,您应该始终将输入转换为小写。您可以通过验证电子邮件、去除任何空白、脚本标记甚至恶意 Unicode 字符来进一步改进此实现。考虑使用库,如 PosiT0 或 Ty1 T1。
login
方法利用user.comparePassword
功能确认提供的密码的正确性。然后,createJwt
函数创建要返回给客户端的accessToken
。
authenticate
函数是一个中间件,我们可以在 API 实现中使用它来确保只有具有适当权限的经过认证的用户才能访问端点。请记住,真正的安全性是在后端实现中实现的,这个认证功能是您的把关人。
authenticate
使用一个可选的options
对象,用requiredRole
属性验证当前用户的角色,因此如果 API 配置如下所示,则只有管理员才能访问该 API:
authenticate(**{ requiredRole: Role.Manager }**)
在某些情况下,我们希望用户能够更新自己的记录,但也允许管理员更新其他人的记录。在这种情况下,我们利用permitIfSelf
属性,如图所示:
authenticate({
requiredRole: Role.Manager,
**permitIfSelf: {**
**idGetter: (req: Request) => req.body._id,**
**requiredRoleCanOverride: true,**
**},**
}),
在这种情况下,如果正在更新的记录的_id
与当前用户的_id
匹配,则用户可以更新自己的记录。因为,requiredRoleCanOverride
被设置为true
经理可以更新任何记录。如果设置为false
,则不允许这样做。通过保持这些特性的匹配,可以满足您的大多数混合需求。
请注意,idGetter
是一个函数委托,因此您可以指定在authenticate
中间件执行时如何访问_id
属性。
参见authenticate
和authenticateHelper
的以下实施:
**server/src/services/authService.ts**
import { NextFunction, Request, Response } from 'express'
import { ObjectID } from 'mongodb'
import { IUser, UserCollection } from '../models/user'
interface IJwtPayload {
email: string
role: string
picture: string
iat: number
exp: number
sub: string
}
export function authenticate(options?: {
requiredRole?: Role
permitIfSelf?: {
idGetter: (req: Request) => string
requiredRoleCanOverride: boolean
}
}) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
res.locals.currentUser =
await authenticateHelper(
req.headers.authorization, {
requiredRole: options?.requiredRole,
permitIfSelf: options?.permitIfSelf
? {
id: options?.permitIfSelf.idGetter(req),
requiredRoleCanOverride:
options?.permitIfSelf.requiredRoleCanOverride,
}
: undefined,
}
)
return next()
} catch (ex) {
return res.status(401).send({ message: ex.message })
}
}
}
export async function authenticateHelper(
authorizationHeader?: string,
options?: {
requiredRole?: Role
permitIfSelf?: {
id: string
requiredRoleCanOverride: boolean
}
}
): Promise<User> {
if (!authorizationHeader) {
throw new Error('Request is missing authorization header')
}
const payload = jwt.verify(
sanitizeToken(authorizationHeader),
JwtSecret()
) as IJwtPayload
const currentUser = await UserCollection.findOne({
_id: new ObjectID(payload?.sub),
})
if (!currentUser) {
throw new Error("User doesn't exist")
}
if (
options?.permitIfSelf &&
!currentUser._id.equals(options.permitIfSelf.id) &&
!options.permitIfSelf.requiredRoleCanOverride
) {
throw new Error(`You can only edit your own records`)
}
if (
options?.requiredRole &&
currentUser.role !== options.requiredRole
) {
throw new Error(`You must have role: ${options.requiredRole}`)
}
return currentUser
}
function sanitizeToken(authorization: string | undefined) {
const authString = authorization || ''
const authParts = authString.split(' ')
return authParts.length === 2 ? authParts[1] : authParts[0]
}
authenticate
方法作为 Express.js 中间件实现。它可以读取授权令牌的请求头,验证提供的 JWT 的有效性,加载当前用户,并将其注入响应流,因此经过认证的 API 端点可以方便地访问当前用户的信息。这将通过me
API 进行演示。如果成功,中间件将调用next()
函数将控制权交还给 Express。如果失败,则无法调用 API。
请注意,authenticateHelper
返回有用的错误消息,因此如果用户试图执行不允许执行的操作,他们不会感到困惑。
考虑 ME API 的实现,它通过 GROUT0T 将当前登录的用户返回给客户端,如下所示:
**server/src/v1/routes/authRouter.ts**
/**
* @swagger
* /v1/auth/me:
* get:
* ...
*/
// tslint:disable-next-line: variable-name
router.get('/me', **authenticate()**,
async (_req: Request, res: Response) => {
if (res.locals.currentUser) {
return res.send(res.locals.currentUser)
}
return res.status(401)
.send({ message: AuthenticationRequiredMessage })
}
)
注意,/v1/auth/me
方法使用authenticate
中间件,只返回加载到响应流中的用户。
既然我们的服务器中有了一个功能性的认证实现,我们可以在 LemonMart 中实现一个自定义认证提供程序,如第 8 章、设计认证和授权所述:
您必须在 Angular 应用中实现此自定义认证提供程序。
本节的代码示例位于lemon mart回购协议的projects/ch10
文件夹中。请注意,样本也可以在web-app
文件夹下访问。
-
在
environment.ts
和environment.prod.ts
中,实现一个baseUrl
变量。 -
同时选择
authMode
作为AuthMode.CustomServer
:**web-app/src/environments/environment.ts** **web-app/src/environments/environment.prod.ts** export const environment = { ... baseUrl: 'http://localhost:3000', authMode: AuthMode.CustomServer,
-
安装帮助程序库以编程方式访问 TypeScript 枚举值:
$ npm i ts-enum-util
-
Implement the custom authentication provider as shown here:
**web-app/src/app/auth/auth.custom.service.ts** import { $enum } from 'ts-enum-util' interface IJwtToken { email: string role: string picture: string iat: number exp: number sub: string } @Injectable() export class CustomAuthService extends AuthService { constructor(private httpClient: HttpClient) { super() } protected authProvider( email: string, password: string ): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>( `${environment.baseUrl}/v1/auth/login`, { email, password, } ) } protected transformJwtToken(token: IJwtToken): IAuthStatus { return { isAuthenticated: token.email ? true : false, userId: token.sub, userRole: $enum(Role) .asValueOrDefault(token.role, Role.None), userEmail: token.email, userPicture: token.picture, } as IAuthStatus } protected getCurrentUser(): Observable<User> { return this.httpClient .get<IUser>(`${environment.baseUrl}/v1/auth/me`) .pipe(map(User.Build, catchError(transformError))) } }
authProvider
方法调用我们的/v1/auth/login
方法,getCurrentUser
调用/v1/auth/me
检索当前用户。确保对
login
方法的调用始终在 HTTPS 上进行,否则您将在开放的 internet 上发送用户凭据。公共 Wi-Fi 网络上的窃听者窃取用户凭据的时机已经成熟。 -
更新
authFactory
以返回AuthMode.CustomServer
选项的新提供者:**web-app/src/app/auth/auth.factory.ts** export function authFactory( afAuth: **AngularFireAuth,** **httpClient**: HttpClient ) { ... case AuthMode.CustomServer: return new CustomAuthService(**httpClient**) }
-
在
app.modules.ts
中,更新AuthService
提供者的deps
属性,将HttpClient
注入authFactory
:**web-app/src/app/app.module.ts** ... { provide: AuthService, useFactory: authFactory, deps: [AngularFireAuth, **HttpClient**], }, ...
-
启动您的 web 应用以确保一切正常。
接下来,让我们实现 get user 端点,这样我们的认证提供程序就可以获取当前用户。
让我们在userRouter
中的/v2/users/{id}
实现按 ID 获取用户 API 端点:
**server/src/v2/routes/userRouter.ts**
import { ObjectID } from 'mongodb'
import { authenticate } from '../../services/authService'
import { IUser, User, UserCollection } from '../../models/user'
/**
* @swagger
* /v2/users/{id}:
* get: …
*/
router.get(
'/:userId',
authenticate({
requiredRole: Role.Manager,
permitIfSelf: {
idGetter: (req: Request) => req.body._id,
requiredRoleCanOverride: true,
},
}),
async (req: Request, res: Response) => {
const user = await UserCollection
.findOne({ _id: new ObjectID(req.params.userId) })
if (!user) {
res.status(404).send({ message: 'User not found.' })
} else {
res.send(user)
}
}
)
在前面的代码示例中,我们按用户 ID 查询数据库,以查找我们要查找的记录。我们导入UserCollection
并调用findOne
方法获取User
对象。请注意,我们没有利用userService
。由于我们只检索一条记录并立即将结果发送回,因此额外的抽象层很麻烦。但是,如果您开始向用户检索添加任何业务逻辑,则重构代码以利用userService
。
我们使用authenticate
中间件保护端点,允许用户检索他们的记录,允许管理员检索任何记录。
在本章前面,我们介绍了如何在的服务小节中创建一个新用户,该小节使用**Express.js部分实现 API。使用此 POST 端点和 Postman API 客户端,我们可以快速生成用于测试目的的用户记录。
您必须按照以下说明在lemon mart server中生成测试数据,这将在后面的章节中需要。
让我们安装并配置邮递员。
转到https://www.getpostman.com 下载并安装邮递员。
首先,我们需要配置邮递员,以便我们可以访问经过认证的端点:
使用docker-compose up
或npm run start:backend
启动服务器和数据库。请记住,首先确保您能够在执行 GitHub 上提供的示例服务器 https://github.com/duluca/lemon-mart-server 。让您自己的服务器版本运行是次要目标。
-
创建一个名为
LemonMart
的新集合。 -
添加 URL 为
http://localhost:3000/v1/auth/login
的 POST 请求。 -
在标题中,设置键值对,内容类型:
application/json
。 -
在正文部分,提供我们在顶层
.env
文件http://localhost:3000/v1/auth/login - Body { "email": "duluca@gmail.com", "password": "l0l1pop!!" }
中定义的演示用户登录的电子邮件和密码
-
点击发送登录。
-
Copy the
accessToken
, as shown here:图 10.9:设置邮递员
-
点击右上角的设置图标进行环境管理。
-
添加一个名为 LemonMart 服务器的新环境。
-
创建一个名为
token
的变量。 -
将您的
accessToken
值粘贴为当前值(无括号)。 -
点击添加/更新。
接下来,在 Postman 中添加新请求时,必须提供令牌变量作为授权标头,如图所示:
图 10.10:在邮递员中提供代币
使用 Postman 时,请始终确保在右上角的下拉列表中选择了正确的环境。
- 切换到授权页签。
- 选择承载令牌作为类型。
- 将令牌变量提供为
{{token}}
。
当您发送请求时,您应该会看到结果。请注意,当您的令牌过期时,您需要重复此过程。
使用 Postman,我们可以自动执行请求。为了在我们的系统中创建示例用户,我们可以利用以下功能:
-
为名为创建用户的
http://localhost:3000/v2/user
创建新的 POST 请求。 -
在授权页签中设置
token
-
In the Body tab, provide a templated JSON object, as shown here:
{ "email": "{{email}}", "name": { "first": "{{first}}", "last": "{{last}}" }, "picture": "https://en.wikipedia.org/wiki/Bugs_Bunny#/media/File:Bugs_Bunny.svg", "role": "clerk", "userStatus": true, "dateOfBirth": "1940-07-27", "address": { "line1": "123 Acme St", "city": "LooneyVille", "state": "Virginia", "zip": "22201" }, "phones": [ { "type": "mobile", "digits": "5551234567" } ] }
出于本例的目的,我仅对电子邮件以及名字和姓氏字段进行模板化。您可以为所有属性设置模板。
-
Implement a Postman Pre-request Script, which executes arbitrary logic before sending a request. The script will define an array of people, and one by one set the current environment variable to be the next row as requests are executed:
有关预请求脚本的更多信息,请查看https://learning.postman.com/docs/postman/scripts/pre-request-scripts/ 。
-
Switch to the Pre-request Script tab and implement the script:
var people = pm.environment.get('people') if (!people) { people = [ {email: 'efg@gmail.com', first: 'Ali', last: 'Smith'}, {email: 'veli@gmail.com', first: 'Veli', last: 'Tepeli'}, {email: 'thunderdome@hotmail.com', first: 'Justin', last: 'Thunderclaps'}, {email: 'jt23@hotmail.com', first: 'Tim', last: 'John'}, {email: 'apple@smith.com', first: 'Obladi', last: 'Oblada'}, {email: 'jones.smith@icloud.com', first: 'Smith', last: 'Jones'}, {email: 'bugs@bunnylove.com', first: 'Bugs', last: 'Bunny'}, ] } var person = people.shift() pm.environment.set('email', person.email) pm.environment.set('first', person.first) pm.environment.set('last', person.last) pm.environment.set('people', people)
pm
是一个全局变量,代表PostMan。在第一行中,我们从环境中获取
people
数组。在第一个请求期间,这将不存在,这允许我们使用测试数据初始化数组。接下来,我们切换到下一条记录,并设置在模板化请求主体中使用的各个变量。然后,我们将数组的当前状态保存回环境,以便在下一次执行期间,我们可以切换到下一条记录,直到记录用完为止。 -
在测试页签中执行一个
test
脚本:var people = pm.environment.get('people') if (people && people.length > 0) { postman.setNextRequest('Create Users') } else { postman.setNextRequest(null) }
-
Make sure to save your request.
在这里,我们定义一个
test
脚本,它将继续执行,直到people.length
达到零。在每次迭代中,我们调用创建用户请求。当没有人时,我们呼叫null
终止测试。正如您所想象的,您可以组合多个请求和多个环境变量来执行复杂的测试。
-
Now, execute the script using Runner, located in the top-left corner of the screen:
图 10.11:邮递员界面左上角的 Runner 按钮
-
在继续之前更新您的
login
令牌。 -
Configure the runner as shown:
![](img/B14094_10_12.png)
图 10.12:收集运行程序配置
- Select the LemonMart collection.
选择包含`token`变量的**LemonMart 服务器**环境。
只有选择**创建用户**请求。
点击**运行 LemonMart**执行。
如果运行成功,您将看到以下输出:
图 10.13:收集结果
如果您使用 Studio 3T 作为 MongoDB 资源管理器,那么在我们实现/v2/users
端点时,您可以观察到所有记录都已创建,或者您可以与 Postman 一起检查它们。
请注意,由于我们有一个唯一的电子邮件索引,您的跑步在下一次跑步中部分成功。已创建记录的 POST 请求将返回一个400 Bad Request
。
您可以在上阅读有关 3T 工作室的更多信息 https://studio3t.com/ 。
我们已经在本章前面的服务部分中介绍了如何创建 POST 请求。现在,让我们看看如何更新现有用户记录:
**server/src/v2/routes/userRouter.ts**
/**
* @swagger
* /v2/users/{id}:
* put:
*/
router.put(
'/:userId',
authenticate({
requiredRole: Role.Manager,
permitIfSelf: {
idGetter: (req: Request) => req.body._id,
requiredRoleCanOverride: true,
},
}),
async (req: Request, res: Response) => {
const userData = req.body as User
delete userData._id
await UserCollection.findOneAndUpdate(
{ _id: new ObjectID(req.params.userId) },
{
$set: userData,
}
)
const user = await UserCollection
.findOne({ _id: new ObjectID(req.params.userId) })
if (!user) {
res.status(404).send({ message: 'User not found.' })
} else {
res.send(user)
}
}
)
我们从请求主体设置userData
。然后我们在主体中添加delete
属性,因为 URL 参数是权威的信息源。此外,这可以防止用户的 ID 意外更改为其他值。
然后我们利用findOneAndUpdate
方法来定位和更新记录。我们使用 ID 查询记录。我们使用 MongoDB 的$set
操作符更新记录。
最后,我们从数据库加载保存的记录并将其返回给客户端。
POST 和 PUT 方法应始终响应记录的更新状态。
对于我们的最后一个实现,让我们回顾一下可以支持分页数据表的 API 端点。
到目前为止,documents 的最有用的功能是findWithPagination
,如关于 documents一节所述。让我们利用findWithPagination
实现/v2/users
端点,可以返回所有用户:
**server/src/v2/routes/userRouter.ts**
/**
* @swagger
* components:
* parameters:
* filterParam: …
* skipParam: …
* limitParam: …
* sortKeyParam: …
*/
/**
* @swagger
* /v2/users:
* get:
*/
router.get(
'/',
authenticate({ requiredRole: Role.Manager }),
async (req: Request, res: Response) => {
const query: Partial<IQueryParameters> = {
filter: req.query.filter,
limit: req.query.limit,
skip: req.query.skip,
sortKeyOrList: req.query.sortKey,
projectionKeyOrList: ['email', 'role', '_id', 'name'],
}
const users = await UserCollection.findWithPagination<User>(query)
res.send(users)
}
)
我们使用req.query
对象作为局部变量从 URL 复制所有参数。我们定义了一个名为projectionKeyOrList
的附加属性来限制可以返回给客户端的记录的属性。在这种情况下,只返回email
、role
、_id
和name
属性。这使得通过线路发送的数据量最小化。
最后,我们只需将新的query
对象传递给findWithPagination
函数,并将结果返回给客户端。
您可以在 Postman 中创建新请求,以验证新端点的正确功能,如以下屏幕截图所示:
图 10.14:使用 Postman 调用 get 用户
在第 12 章、秘籍-主/细节、数据表和 NgRx中,我们将实现一个分页的数据表,利用过滤、排序和数据限制功能。
祝贺您现在已经掌握了代码如何在整个软件栈中工作,从数据库到前端和后端。
在本章中,我们介绍了完整的栈体系结构。您学习了如何构建最小平均栈。现在,您已经知道如何为全栈应用创建 monorepo,并使用 TypeScript 配置 Node.js 服务器。您将 Node.js 服务器容器化,并使用 Docker Compose 声明性地定义了基础结构。使用 Docker Compose 和 CircleCI,您可以在 CI 环境中验证您的基础结构。
您使用 Swagger 和 OpenAPI 规范设计了一个 RESTful API,设置了一个 Express.js 应用并对其进行了配置,以便可以将 Swagger 定义集成为 API 的文档。您使用 DocumentsODM 配置了 MongoDB,以便可以轻松地连接和查询文档。您定义了一个具有密码哈希功能的用户模型。
然后实现了基于 JWT 的认证服务。您实现了一个authenticate
中间件来保护 API 端点并允许基于角色的访问。您学习了如何使用 Postman 与 RESTful API 交互。使用 Postman 的自动化特性,您生成了测试数据。最后,您为用户的认证函数和 CRUD 操作实现了 RESTful API。
在接下来的两章中,我们将介绍创建表单和数据表的方法。您需要启动并运行 Lemon Mart 服务器,以便在实现表单和表时验证它们的正确功能。
您使用authenticate
中间件保护端点。您已将 Postman 配置为发送有效令牌,以便可以与受保护的端点通信。作为练习,尝试移除authenticate
中间件,并使用或不使用有效令牌调用同一端点。重新添加中间件,然后重试相同的操作。观察您从服务器得到的不同响应。
- 什么是 DX?(开发者体验),阿尔伯特·卡瓦尔坎特,2019:https://medium.com/@阿尔伯塔·卡瓦尔坎特/what-is-dx-developer-experience-401a0e44a9d9
- 阻塞与非阻塞概述2020:https://nodejs.org/en/docs/guides/blocking-vs-non-blocking/
- 像我五岁一样解释非阻塞 I/O,Frank Rosner2019:https://blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/
- OpenAPI 规范2020:https://swagger.io/docs/specification
- 系列化2020:https://en.wikipedia.org/wiki/Serialization
- JSON2020:https://en.wikipedia.org/wiki/JSON
- MongoDB 聚集2020:https://docs.mongodb.com/manual/aggregation
尽可能回答以下问题,以确保您在不使用谷歌搜索的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?参见附录 D、自我评估答案在线https://static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问https://expertlysimple.io/angular-self-assessment 。
- 有哪些主要组件可以让开发人员获得良好的体验?
- 什么是
.env
文件? authenticate
中间件的用途是什么?- Docker Compose 与使用
Dockerfile
有何不同? - 什么是 ODM?它与 ORM 有何不同?
- 什么是中间件?
- 招摇过市有什么用?
- 您将如何重构
userRouter.ts
中/v2/users/{id} PUT
端点的代码,以使代码可重用?