Skip to content

Latest commit

 

History

History
2334 lines (1824 loc) · 99.1 KB

File metadata and controls

2334 lines (1824 loc) · 99.1 KB

十、RESTful API 和全栈实现

第 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中记录的说明,以启动并运行服务器。

在本的情况下:

  1. 使用--recurse-submodules选项git clone --recurse-submodules克隆lemon mart 服务器存储库 https://github.com/duluca/lemon-mart-server

  2. 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 服务器中提取。

  3. Execute npm install on the root folder to install dependencies.

    请注意,在根文件夹上运行npm install命令会触发一个脚本,该脚本还会在serverweb-app文件夹下安装依赖项。

  4. Execute npm run init:env on the root folder to configure environment variables in .env files.

    此命令将创建两个.env文件,一个在根文件夹上,另一个在server文件夹下,以包含您的私有配置信息。初始文件是根据example.env文件生成的。您可以稍后修改这些文件并设置自己的安全机密。

  5. 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

  6. Execute docker-compose up --build to run containerized versions of the server, web app, and a MongoDB database.

    请注意,web 应用是使用名为nginx.Dockerfile的新文件进行容器化的。

  7. Navigate to http://localhost:8080 to view the web app.

    要登录,请单击填充按钮,用默认演示凭据填充电子邮件和密码字段。

  8. 导航至http://localhost:3000以查看服务器登录页。

  9. 导航至http://localhost:3000/api-docs查看交互式 API 文档。

  10. 您可以使用npm run start:database只启动数据库,并在server文件夹上使用npm start进行调试。

  11. 您可以使用npm run start:backend只启动数据库和服务器,并在web-app文件夹上使用npm start进行调试。

中基于柠檬超市的示例:

  1. 克隆存储库:https://github.com/duluca/lemon-mart

  2. 在根文件夹上执行npm install以安装依赖项。

  3. 本章的代码示例在子文件夹

    projects/ch10 

    下提供

  4. 要运行本章的 Angular 应用,请执行以下命令:

    npx ng serve ch10 
  5. 要运行本章的 Angular 单元测试,请执行以下命令:

    npx ng test ch10 --watch=false 
  6. 要运行本章的 Angular e2e 测试,请执行以下命令:

    npx ng e2e ch10 
  7. 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 的关键。这可能是一堆高耸的干煎饼和一堆美味的短煎饼之间的区别,上面有适量的黄油和糖浆。

通过引入太多的库和依赖项,您可以减慢进度,使代码难以维护,并发现自己处于一个反馈循环中,需要引入更多库来解决其他库的问题。赢得这场比赛的唯一方法就是不玩它。

如果您花时间学习如何使用一些基本的库,您可以成为一名更有效的开发人员。本质上,你可以用更少的钱做更多的事情。我的建议是:

这种极简主义的思维方式是极简主义背后的设计哲学。您可以在查看 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-serverduluca/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 来优化您的开发体验。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 子模块帮助您在多个存储库之间共享代码,同时保持提交分离。前端开发人员可以选择只使用前端存储库工作,而全栈开发人员则更喜欢访问所有代码。Git 子模块还为组合现有项目提供了一种方便的方式。

让我们先看看如何将自己的lemon-mart项目添加为lemon-mart-server的子模块,利用 monorepo 根文件夹package.json文件中的脚本:

我建议您在从 GitHub 克隆的lemon mart 服务器版本上执行此操作。否则,您将需要创建一个新项目并执行npm init -y以开始工作。

  1. 观察以下有助于 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"
      }, 
  2. 使用您自己项目的 URL 更新webAppGitUrl

  3. 执行webapp:clean删除已有的web-app文件夹。

  4. 最后,执行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 pullgit checkout master即可恢复主分支。使用此技术,您可以签出项目中的任何分支并提交 PRs。

现在我们已经准备好了子模块,让我们看看服务器项目是如何配置的。

使用 TypeScript 配置节点项目

要使用 TypeScript 创建一个新的 Node.js 应用,请执行以下步骤:

以下步骤仅在创建新服务器项目时相关。我建议您使用从 GitHub 克隆的lemon-mart-server项目中已经提供的。

  1. 创建子文件夹server

    $ mkdir server 
  2. 将当前目录更改为server文件夹:

    $ cd server 
  3. Initialize npm to set up package.json in the server folder:

    $ npm init -y 

    请注意,顶层package.json将用于与全栈项目相关的脚本。server/package.json将包含后端项目的脚本和依赖项。

  4. 使用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:

    • stylelint:检查代码样式和线头错误的符合性。它们用于 CI 服务器。
    • style:fixlint:fix:对代码应用代码样式和绒线规则。并非所有的脱毛错误都可以自动修复。您需要手动解决每个错误。
    • build:将代码传输到dist文件夹中。
    • start:运行 Node.js 中传输的代码。

    prepublishOnlyprepare脚本只有在开发 npm 包时才相关。在这种情况下,您还应该实现一个.npmignore文件,它不包括srctests文件夹。

  • 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 子模块的好处之一是我们可以验证我们的前端和后端在同一管道中工作。我们将实施两项工作:

  1. build_server
  2. 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 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 服务器。

使用 Nginx 作为 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 进行复习。

  1. 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 的节点运行它。

    您可以通过在上的类似配置的minimal-node-web-server回购上查看README.md来了解我们的服务器容器是如何配置的 https://github.com/duluca/minimal-node-web-server

    现在,为 Docker 设置跨环境npm 脚本,该脚本在我们服务器的 Windows 10 和 macOS 上工作。

  2. 为 Docker 任务安装 npm 脚本:

    $ npm i -g mrm-task-npm-docker 
  3. 应用 npm 脚本进行 Docker 配置,确保执行server文件夹中的命令:

    $ npx mrm npm-docker 
  4. 使用配置参数

    **server/package.json**
      "config": {
        "imageRepo": "duluca/lemon-mart-server",
        "imageName": "lemon-mart-server",
        "imagePort": "3000",
        "internalContainerPort": "3000"
      } 

    配置您的package.json

在构建 Docker 容器之前,请确保构建应用。

使用 DotEnv 配置环境变量

DotEnv 文件得到广泛支持,可以方便地将机密存储在.env文件中,而该文件未签入代码存储库。Docker 和 Compose 本机支持.env文件。

让我们从了解 monorepo 核心的环境变量开始:

  1. 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文件,这样读者就可以运行示例,而不必配置所有这些参数。

  2. 通过执行

    $ npm i -D init-dev-env 

    确保安装在项目的根目录中

  3. The npm run init:env script generates .env files based on the example.env file using the init-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 
  4. 为服务器生成第二个.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 文件。

定义 Docker 编写 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 网络名称。服务器同时支持linksdepends_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:backendstart: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的新作业之前构建serverweb-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 设计。

RESTful 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 的功能。

狂妄自大的 API 设计

Swagger 将允许您设计和记录 web API。对于团队来说,它可以作为前端和后端开发人员之间的沟通工具,从而减少很多摩擦。此外,在早期定义 API 表面允许开始实现,而无需担心后期集成挑战。

接下来,我们将实现一个用户 API,以演示 Swagger 是如何工作的。

我强烈建议安装 Swagger Viewer VS 代码扩展,它允许我们在不运行任何其他工具的情况下预览 YAML 文件。

让我们从探索 monorepo 根目录下的swagger.yaml文件开始:

  1. 在 VS 代码中打开swagger.yaml

  2. 安装名为 Swagger Preview 的 VS 代码扩展。

  3. 点击Ctrl+Shift+P++P,调出命令面板,运行预览招摇

  4. See the preview, as shown here:

    图 10.4:Swagger.yaml 预览

使用招摇过市 UI 视图,您将能够在服务器环境中尝试命令并执行它们。

定义 Swagger YAML 文件

我们将使用 Swagger 规范版本openapi: 3.0.1,它实现了 OpenAPI 标准。让我们在这里回顾一下swagger.yaml文件的主要组成部分:

有关如何定义招摇过市文件的更多信息,请参阅https://swagger.io/specification/

  1. 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 
  2. Under components, we define common securitySchemes 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的定义。

  3. components下,我们定义了共享数据schemas,它声明了我们作为输入或返回给客户端的数据实体:

    **swagger.yaml**
    ...
     **schemas:**
        ServerMessage:
          type: object
          properties:
            message:
              type: string
        Role:
          type: string
          enum: [none, clerk, cashier, manager]
        ... 
  4. components下,我们添加了共享的parameters,使之易于重用常用模式,例如分页端点:

    **swagger.yaml**
    ...
      **parameters:**
        filterParam:
          in: query
          name: filter
          required: false
          schema:
            type: string
          description: Search text to filter the result set by
    ... 
  5. Under paths, we begin defining REST endpoints, such as a post 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 中所定义。

  6. 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。

使用 Express.js 实现 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函数,该函数利用三个主要助手引导应用:

  1. config.ts:管理环境变量和设置。
  2. app.ts:配置 Express.js,定义所有 API 路径,然后路由实现路径并利用包含业务逻辑的服务。服务使用user.ts等模型访问数据库。
  3. 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 

在本例中,v1v2有两个子路由。始终对您实现的 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 功能实现服务。不需要花哨的依赖注入。示例应用实现了两个服务–authServiceuserService

例如,在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

使用 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 一起工作的:

  1. TypeScript 的依赖项和类型信息如下所示:

    $ npm i swagger-jsdoc swagger-ui-express
    $ npm i -D @types/swagger-jsdoc @types/swagger-ui-express 
  2. 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文件。

  3. 引导在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。

带文档的 MongoDB ODM

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 异步连接线束
  • DocumentIDocument:用于帮助定义自己模型的基类和接口
  • CollectionFactory:定义集合,组织索引,并在集合实现的同时聚合查询

以下是 DocumentTS 集合提供的便利功能:

  • get collection返回本机 MongoDB 集合,可以直接操作:

    get collection(): ICollectionProvider<TDocument> 
  • aggregate允许您运行 MongoDB 聚合管道:

    aggregate(pipeline: object[]): AggregationCursor<TDocument> 
  • findOnefindOneAndUpdate简化了常用数据库功能的操作,自动补水返回的模型:

    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 实例,并且可以安全地从同时启动的多个线程同时调用。

让我们从配置环境变量开始:

  1. Remember that the MONGO_URI connection string resides in server/.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更改只有在重新启动服务器时才会生效。

  2. 让我们来看看是如何与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 索引对于使字段成为可搜索字段也是必要的。

带有 IDocument 的模型

您可以实现一个IUser接口,该接口与 LemonMart 中的接口类似。但是,这将扩展文档中定义的IDocument

  1. 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来探索基类和接口,这样您就可以看到它们背后的源代码。

  2. Now, here is the User class extending Document<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()
      }
    } 

    注意属性getCalculatedPropertiesToIncludegetPropertiesToExclude。这些定义了是由客户端序列化字段还是允许将字段写入数据库。

    数据的序列化和反序列化是将数据转换为可存储或传输的格式的概念。有关序列化和 JSON 数据格式的文章链接,请参阅进一步阅读部分。

    fullName是一个计算属性,因此我们不想将此值写入数据库。但是,fullName对客户机很有用。另一方面,password属性永远不应该传输回客户端,但显然我们需要能够将其保存到数据库中,以便进行密码比较和更改。保存后,我们传入{ upsert }对象,指示数据库更新记录,即使提供了部分信息。

    请记住提供完整的招摇过市定义。

  3. 还有最后我们来看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文件中需要。

让我们看看如何使用新的用户模型获取数据。

实现 jwtauth

第 8 章**设计认证和授权中,我们讨论了如何实现基于 JWT 的认证机制。在 LemonMart 中,您实现了一个基本认证服务,可以为自定义认证服务进行扩展。

我们将利用三个软件包进行实施:

  • jsonwebtoken:用于创建和编码 JWTs
  • bcryptjs:用于在将用户密码保存到数据库之前对其进行哈希和盐渍处理,因此我们从不以明文形式存储用户密码
  • uuid:生成的通用唯一标识符,用于将用户密码重置为随机值

散列函数是一种一致可重复的单向加密方法,这意味着您每次提供相同的输入时都会得到相同的输出,但即使您可以访问散列值,您也无法轻松确定它存储了哪些信息。但是,我们可以通过对用户输入进行散列,并将其输入的散列与存储的密码散列进行比较,来比较用户是否输入了正确的密码。

  1. 让我们看看与 JWT auth 相关的依赖项和 TypeScript 的类型信息:

    $ npm i bcryptjs jsonwebtoken uuid
    $ npm i -D @types/bcryptjs @types/jsonwebtoken @types/uuid 
  2. 观察带有密码散列功能的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函数将用户提供的值与散列密码进行比较。我们从不存储用户提供的值,因此系统永远无法复制用户的密码,使其成为安全的实现。

登录 API

以下是lemon-mart-serverauthService中的登录方式实现:

**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属性。

参见authenticateauthenticateHelper的以下实施:

**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 端点可以方便地访问当前用户的信息。这将通过meAPI 进行演示。如果成功,中间件将调用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文件夹下访问。

  1. environment.tsenvironment.prod.ts中,实现一个baseUrl变量。

  2. 同时选择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, 
  3. 安装帮助程序库以编程方式访问 TypeScript 枚举值:

    $ npm i ts-enum-util 
  4. 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 网络上的窃听者窃取用户凭据的时机已经成熟。

  5. 更新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**)
    } 
  6. app.modules.ts中,更新AuthService提供者的deps属性,将HttpClient注入authFactory

    **web-app/src/app/app.module.ts**
    ...
      {
        provide: AuthService,
        useFactory: authFactory,
        deps: [AngularFireAuth, **HttpClient**],
      },
    ... 
  7. 启动您的 web 应用以确保一切正常。

接下来,让我们实现 get user 端点,这样我们的认证提供程序就可以获取当前用户。

按 ID 获取用户

让我们在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中间件保护端点,允许用户检索他们的记录,允许管理员检索任何记录。

使用 Postman 生成用户

在本章前面,我们介绍了如何在服务小节中创建一个新用户,该小节使用**Express.js部分实现 API。使用此 POST 端点和 Postman API 客户端,我们可以快速生成用于测试目的的用户记录。

您必须按照以下说明在lemon mart server中生成测试数据,这将在后面的章节中需要。

让我们安装并配置邮递员。

转到https://www.getpostman.com 下载并安装邮递员。

为经过认证的呼叫配置邮递员

首先,我们需要配置邮递员,以便我们可以访问经过认证的端点:

使用docker-compose upnpm run start:backend启动服务器和数据库。请记住,首先确保您能够在执行 GitHub 上提供的示例服务器 https://github.com/duluca/lemon-mart-server 。让您自己的服务器版本运行是次要目标。

  1. 创建一个名为LemonMart的新集合。

  2. 添加 URL 为http://localhost:3000/v1/auth/login的 POST 请求。

  3. 在标题中,设置键值对,内容类型:application/json

  4. 在正文部分,提供我们在顶层.env文件

    http://localhost:3000/v1/auth/login - Body
    {
        "email": "duluca@gmail.com",
        "password": "l0l1pop!!"
    } 

    中定义的演示用户登录的电子邮件和密码

  5. 点击发送登录。

  6. Copy the accessToken, as shown here:

    图 10.9:设置邮递员

  7. 点击右上角的设置图标进行环境管理。

  8. 添加一个名为 LemonMart 服务器的新环境。

  9. 创建一个名为token的变量。

  10. 将您的accessToken值粘贴为当前值(无括号)。

  11. 点击添加/更新

接下来,在 Postman 中添加新请求时,必须提供令牌变量作为授权标头,如图所示:

图 10.10:在邮递员中提供代币

使用 Postman 时,请始终确保在右上角的下拉列表中选择了正确的环境。

  1. 切换到授权页签。
  2. 选择承载令牌作为类型。
  3. 将令牌变量提供为{{token}}

当您发送请求时,您应该会看到结果。请注意,当您的令牌过期时,您需要重复此过程。

邮递员自动化

使用 Postman,我们可以自动执行请求。为了在我们的系统中创建示例用户,我们可以利用以下功能:

  1. 为名为创建用户http://localhost:3000/v2/user创建新的 POST 请求。

  2. 授权页签中设置token

  3. 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"
        }
      ]
    } 

    出于本例的目的,我仅对电子邮件以及名字和姓氏字段进行模板化。您可以为所有属性设置模板。

  4. 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/

  5. 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数组。在第一个请求期间,这将不存在,这允许我们使用测试数据初始化数组。接下来,我们切换到下一条记录,并设置在模板化请求主体中使用的各个变量。然后,我们将数组的当前状态保存回环境,以便在下一次执行期间,我们可以切换到下一条记录,直到记录用完为止。

  6. 测试页签中执行一个test脚本:

    var people = pm.environment.get('people')
    if (people && people.length > 0) {
      postman.setNextRequest('Create Users')
    } else {
      postman.setNextRequest(null)
    } 
  7. Make sure to save your request.

    在这里,我们定义一个test脚本,它将继续执行,直到people.length达到零。在每次迭代中,我们调用创建用户请求。当没有人时,我们呼叫null终止测试。

    正如您所想象的,您可以组合多个请求和多个环境变量来执行复杂的测试。

  8. Now, execute the script using Runner, located in the top-left corner of the screen:

    图 10.11:邮递员界面左上角的 Runner 按钮

  9. 在继续之前更新您的login令牌。

  10. Configure the runner as shown:

![](img/B14094_10_12.png)

图 10.12:收集运行程序配置
  1. 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的附加属性来限制可以返回给客户端的记录的属性。在这种情况下,只返回emailrole_idname属性。这使得通过线路发送的数据量最小化。

最后,我们只需将新的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中间件,并使用或不使用有效令牌调用同一端点。重新添加中间件,然后重试相同的操作。观察您从服务器得到的不同响应。

进一步阅读

问题

尽可能回答以下问题,以确保您在不使用谷歌搜索的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?参见附录 D自我评估答案在线https://static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf 或访问https://expertlysimple.io/angular-self-assessment

  1. 有哪些主要组件可以让开发人员获得良好的体验?
  2. 什么是.env文件?
  3. authenticate中间件的用途是什么?
  4. Docker Compose 与使用Dockerfile有何不同?
  5. 什么是 ODM?它与 ORM 有何不同?
  6. 什么是中间件?
  7. 招摇过市有什么用?
  8. 您将如何重构userRouter.ts/v2/users/{id} PUT端点的代码,以使代码可重用?