Angular-企业就绪的-Web-应用-五-

62 阅读1小时+

Angular 企业就绪的 Web 应用(五)

原文:zh.annas-archive.org/md5/eaf56b09bedec2a30920ca225cb1149e

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:使用 Docker 进行 DevOps

第八章结束,即设计身份验证和授权,我们已经拥有了一个相当复杂的应用程序。在第四章,即自动化测试、持续集成和发布到生产环境,我强调了确保我们创建的每个代码推送都通过测试、遵循编码标准,并且是一个团队成员可以运行测试的可执行工件的重要性。到第七章结束,即创建以路由器为第一线的业务应用程序,你应该已经复制了我们为 LemonMart 的本地天气应用程序实现的相同 CircleCI 设置。如果没有,在我们开始为我们的业务线应用程序LOB)构建更复杂的功能之前,请先完成这项工作。

我们生活在一个快速行动并打破事物的时代。然而,这个陈述的后半部分在企业中很少适用。你可以选择生活在边缘并采用 YOLO 生活方式,但这并不符合商业逻辑。

图片

图 9.1:一个工具的创意命令行界面选项

持续集成CI)对于确保通过在每次代码推送时构建和执行测试来交付高质量的产品至关重要。设置 CI 环境可能耗时且需要使用工具的专业知识。在第四章,即自动化测试、持续集成和发布到生产环境,我们实现了 GitHub 流程与 CircleCI 的集成。然而,我们手动部署了我们的应用程序。为了快速行动而不破坏事物,我们需要使用 DevOps 最佳实践,如基础设施即代码IaC)来实现持续部署CD),这样我们就可以更频繁地验证我们运行中的代码的正确性。

在本章中,我们将介绍一种基于 Docker 的方法来实现 IaC,它可以在大多数 CI 服务和云服务提供商上运行,允许你从任何 CI 环境到任何云服务提供商实现可重复的构建和部署。使用灵活的工具,你将避免在单一服务上过度专业化,并保持你的配置管理技能在不同 CI 服务中相关。

本书利用 CircleCI 作为持续集成服务器。其他值得注意的持续集成服务器包括 Jenkins、Azure DevOps 以及 GitLab 和 GitHub 内置的机制。

在本章中,你将学习以下内容:

  • DevOps 和 IaC

  • 使用 Docker 容器化 Web 应用程序

  • 使用 Google Cloud Run 部署容器化应用程序

  • 将代码部署到多个云服务提供商

  • 高级持续集成

  • 代码覆盖率报告

遵循本章内容所需的软件包括:

  • Docker Desktop Community 版本 2+

  • Docker Engine CE 版本 18+

  • 一个 Google Cloud Engine 账户

  • 一个 Coveralls 账户

书中示例代码的最新版本可在 GitHub 上找到,链接如下列表中的存储库。该存储库包含代码的最终和完成版本。每个部分都包含信息框,以帮助您找到 GitHub 上的正确文件名或分支,以便您可以使用它们来验证您的进度。

对于基于 local-weather-app第九章 示例,请执行以下操作:

  1. github.com/duluca/local-weather-app 处克隆仓库。

  2. 在根目录下执行 npm install 以安装依赖项。

  3. 使用 .circleci/config.ch9.yml 来验证您的 config.yml 实现。

  4. 要运行 CircleCI Vercel Now 配置,请执行

    git checkout deploy_Vercelnow 
    

    请参阅 github.com/duluca/local-weather-app/pull/50 的拉取请求。

  5. 要运行 CircleCI GCloud 配置,请执行

    git checkout deploy_cloudrun 
    

    请参阅 github.com/duluca/local-weather-app/pull/51 的拉取请求。

注意,这两个分支都利用修改后的代码来使用来自 local-weather-app 仓库的 projects/ch6 代码。

对于基于 lemon-mart第九章 示例,请执行以下操作:

  1. github.com/duluca/lemon-mart 处克隆仓库。

  2. 使用 .circleci/config.ch9.ymlconfig.docker-integration.yml 来验证您的 config.yml 实现。

  3. 在根目录下执行 npm install 以安装依赖项。

  4. 要运行 CircleCI Docker 集成配置,请执行

    git checkout docker-integration 
    

    请参阅 github.com/duluca/lemon-mart/pull/25 的拉取请求。

注意,docker-integration 分支略有修改,以使用来自 lemon-mart 仓库的 projects/ch8 文件夹中的代码。

请注意,由于生态系统不断演变,书中代码与 GitHub 上的代码之间可能存在细微的差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建问题或提交 GitHub 上的拉取请求,以惠及所有读者。

您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具始终如一。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen 在线找到此附录。

让我们先来了解一下 DevOps 是什么。

DevOps

DevOps 是开发和运维的结合。在开发中,代码仓库如 Git 跟踪每个代码更改是众所周知的事实。在运维中,长期以来一直存在各种技术来跟踪环境更改,包括脚本和各种旨在自动化操作系统和服务器配置的自动化工具。

然而,你听过多少次“在我的机器上它工作得很好”的说法?开发者经常用这句话作为玩笑。尽管如此,软件在测试服务器上运行得很好,但由于配置的细微差异,最终在生产服务器上遇到问题的情形也经常发生。

第四章自动化测试、持续集成和发布到生产中,我们讨论了 GitHub 流程如何使我们能够创建一个价值交付流。我们在做出任何更改之前总是从主分支分叉。强制更改通过我们的 CI 管道,一旦我们合理确信我们的代码可以工作,我们就可以合并回主分支。请参见以下图表:

图片

图 9.2:分叉和合并

记住,你的主分支应该始终是可部署的,你应该经常将你的工作合并到主分支。

Docker 允许我们通过一个名为Dockerfile的特殊文件以声明方式定义我们的代码所依赖的软件和特定的配置参数。同样,CircleCI 允许我们通过一个名为config.yml的文件定义我们的 CI 环境配置。通过将配置存储在文件中,我们能够与代码一起检查这些文件。我们可以使用 Git 跟踪更改,并强制它们通过我们的 CI 管道进行验证。通过将基础设施的定义存储在代码中,我们实现了基础设施即代码(IaC)。通过 IaC,我们还实现了可重复的集成,因此无论我们在什么环境中运行我们的基础设施,我们都应该能够通过一条命令启动我们的全栈应用。

你可能还记得,在第一章Angular 介绍及其概念中,我们介绍了 TypeScript 如何覆盖 JavaScript 功能差距。与 TypeScript 类似,Docker 覆盖了配置差距,如下所示:

图片

图 9.3:覆盖配置差距

通过使用 Docker,我们可以合理确信,在测试期间在我们机器上工作的代码,在发布时将以完全相同的方式运行。

总结来说,通过 DevOps,我们将运维与开发更紧密地结合在一起,在开发中更改和解决问题成本更低。因此,DevOps 主要是开发者的责任,但它也是一种思维方式,运维团队必须愿意支持。让我们更深入地了解 Docker。

使用 Docker 容器化 Web 应用

Docker,可在 docker.io 找到,是一个用于开发、运输和运行应用程序的开源平台。Docker 结合了一个轻量级的容器虚拟化平台以及帮助管理和部署应用程序的工作流程和工具。虚拟机VMs)和 Docker 容器之间最明显的区别是,VMs 通常大小为数十个吉字节,需要数吉字节内存,而容器在磁盘和内存大小需求方面仅占用兆字节。此外,Docker 平台抽象化了主机 操作系统OS)级别的配置设置,因此成功运行应用程序所需的所有配置都编码在可读格式中。

Dockerfile 的结构

一个 Dockerfile 由四个主要部分组成:

  • FROM – 我们可以继承 Docker 的最小 "scratch" 镜像或现有的镜像

  • SETUP – 我们根据需求配置软件依赖的地方

  • COPY – 我们将构建的代码复制到操作环境中的地方

  • CMD – 我们指定启动操作环境的命令的地方

引导程序指的是一组初始指令,描述了程序如何加载或启动。

考虑以下 Dockerfile 结构的可视化:

图 9.4:Dockerfile 的结构

以下代码展示了 Dockerfile 的具体表示:

**Dockerfile**
FROM duluca/minimal-nginx-web-server:1-alpine
COPY /dist/local-weather-app /var/www
CMD 'nginx' 

你可以将脚本中的 FROM, COPY, 和 CMD 部分映射到可视化中。我们使用 FROM 命令从 duluca/minimal-nginx-web-server 镜像继承。然后,我们使用 COPY(或,作为替代,ADD)命令将我们的应用程序的编译结果从我们的开发机器或构建环境复制到镜像中。最后,我们指示容器使用 CMD(或,作为替代,ENTRYPOINT)命令执行 nginx 网络服务器。

注意,前面的 Dockerfile 没有独立的 SETUP 部分。SETUP 不对应实际的 Dockerfile 命令,但代表了一组你可以执行的命令来设置你的容器。在这种情况下,所有必要的设置都由基础镜像完成,因此没有额外的命令要运行。

常见的 Dockerfile 命令有 FROM, COPY, ADD, RUN, CMD, ENTRYPOINT, ENV, 和 EXPOSE。对于完整的 Dockerfile 参考,请参阅 docs.docker.com/engine/reference/builder/

Dockerfile描述了一个从名为duluca/minimal-nginx-web-server的容器继承的新容器。这是一个我在 Docker Hub 上发布的容器,它从nginx:alpine镜像继承,该镜像本身又从alpine镜像继承。alpine镜像是一个最小的 Linux 操作系统环境,大小仅为 5MB。alpine镜像本身从scratch继承,scratch是一个空镜像。请参见以下图表中展示的继承层次结构:

图 9.5:Docker 继承

然后Dockerfile将开发环境中的dist文件夹内容复制到容器的www文件夹中,如下所示:

图 9.6:将代码复制到容器化的 Web 服务器中

在这种情况下,父镜像配置了 nginx 服务器作为 Web 服务器来服务www文件夹内的内容。此时,我们的源代码可以从互联网上访问,但生活在多层安全环境中。即使我们的应用程序存在某种漏洞,攻击者也很难伤害我们正在运行的系统。以下图表展示了 Docker 提供的多层安全:

图 9.7:Docker 安全

总结来说,在基础层,我们有运行 Docker 运行时的宿主操作系统,例如 Windows 或 macOS,它将在下一节中安装。Docker 运行时能够运行自包含的 Docker 镜像,这些镜像由上述Dockerfile定义。duluca/minimal-nginx-web-server基于轻量级的 Linux 操作系统 Alpine。Alpine 是 Linux 的一个完全精简版本,不带任何 GUI、驱动程序,甚至没有大多数 Linux 系统可能期望的 sCLI 工具。因此,该操作系统的大小仅为约 5MB。然后我们从 nginx 镜像继承,该镜像安装了 Web 服务器,其自身大小约为几 MB。最后,我们的自定义 nginx 配置覆盖在默认镜像之上,结果是一个小巧的约 7MB 镜像。nginx 服务器配置为服务/var/www文件夹的内容。在Dockerfile中,我们仅复制开发环境中/dist文件夹的内容并将其放置到/var/www文件夹中。我们稍后将构建并执行此镜像,该镜像将运行包含我们dist文件夹输出的 Nginx Web 服务器。我已经发布了一个类似的镜像,名为duluca/minimal-node-web-server,大小约为 15MB。

duluca/minimal-node-web-server 可以更直接地工作,特别是如果你不熟悉 Nginx。它依赖于一个 Express.js 服务器来提供静态内容。大多数云服务提供商都提供了使用 Node 和 Express 的具体示例,这可以帮助你缩小任何错误的范围。此外,duluca/minimal-node-web-server 内置了 HTTPS 重定向支持。你可以花很多时间尝试设置一个 nginx 代理来做同样的事情,而你只需要在 Dockerfile 中设置环境变量 ENFORCE_HTTPS。请参阅以下示例 Dockerfile:

**Dockerfile**
FROM duluca/minimal-node-web-server:lts-alpine
WORKDIR /usr/src/app
COPY dist/local-weather-app public
ENTRYPOINT [ "npm", "start" ]
ENV ENFORCE_HTTPS=xProto 

你可以在 github.com/duluca/minimal-node-web-server 上了解更多关于 minimal-node-web-server 提供的选项。

正如我们现在所看到的,Docker 的美妙之处在于你可以导航到 hub.docker.com,搜索 duluca/minimal-nginx-web-serverduluca/minimal-node-web-server,阅读其 Dockerfile,并追踪其起源,直到原始的基础镜像,这是网络服务器的基础。我鼓励你以这种方式检查你使用的每个 Docker 镜像,以了解它为你带来的确切价值。你可能发现它要么是过度设计,要么是具有你从未意识到的功能,这些功能可以使你的生活变得更加容易。

注意,父镜像应该拉取 duluca/minimal-nginx-web-server 的特定标签,即 1-alpine。同样,duluca/minimal-node-web-serverlts-alpine 拉取。这些都是常绿的基础包,始终包含 Nginx 和 Alpine 或 Node 的 LTS 版本的最新发布版本。我已经设置了管道,当发布新的基础镜像时,会自动更新这两个镜像。所以,无论何时你拉取这些镜像,你都会得到最新的错误修复和安全补丁。

拥有一个常绿依赖树可以减轻你作为开发者寻找最新 Docker 镜像版本的负担。或者,如果你指定了版本号,你的镜像将不会受到任何潜在的重大更改的影响。然而,记住在新构建后测试你的镜像,比永远不更新你的镜像并可能部署受损害的软件要好。毕竟,网络是不断变化的,不会因为你更新镜像而减速。

就像 npm 包一样,Docker 可以带来极大的便利和价值,但你必须小心,理解你正在使用的工具。

第十三章AWS 上的高可用云基础设施 中,我们将利用基于 Nginx 的低内存占用 Docker 镜像 duluca/minimal-nginx-web-server。如果你熟悉配置 nginx,这是理想的选择。

安装 Docker

为了能够构建和运行容器,你必须在你的电脑上首先安装 Docker 执行环境。请参考 第二章设置你的开发环境,了解安装 Docker 的说明。

设置 npm scripts for Docker

现在,让我们配置一些 Docker 脚本,您可以使用这些脚本来自动化 Angular 应用程序的构建、测试和发布。我开发了一套名为 npm scripts for Docker 的脚本,这些脚本在 Windows 10 和 macOS 上运行。您可以通过执行以下代码获取这些脚本的最新版本,并在项目中自动配置它们:

现在,在 local-weather-applemon-mart 项目上运行以下命令!

  1. 安装 Docker 任务的 npm 脚本:

    $ npm i -g mrm-task-npm-docker 
    
  2. 应用 Docker 配置的 npm 脚本:

    $ npx mrm npm-docker 
    

执行 mrm 脚本后,我们就可以使用 Local Weather 应用程序作为示例,深入查看配置设置。

构建并发布镜像到 Docker Hub

接下来,让我们确保您的项目配置正确,这样我们就可以将其容器化,构建可执行镜像,并将其发布到 Docker Hub,从而允许我们从任何构建环境中访问它。我们将使用我们在 第六章表单、Observables 和 Subjects 中最后更新的 Local Weather 应用程序来完成本节:

本节使用 local-weather-app 仓库。

  1. hub.docker.com/ 上注册 Docker Hub 账户。

  2. 为您的应用程序创建一个公共(免费)仓库。

    在本章的后面部分,我们使用 Google Cloud 的容器注册库作为私有仓库。此外,在 第十三章AWS 上的高可用云基础设施 中,我介绍了如何使用 AWS Elastic Container ServiceAWS ECS)设置私有容器仓库。

  3. package.json 中,添加或更新 config 属性,包含以下配置属性:

    **package.json**
      ...
      "config": {
        "imageRepo": "[namespace]/[repository]",
        "imageName": "custom_app_name",
        "imagePort": "0000",
        "internalContainerPort": "3000"
      },
      ... 
    

    namespace 将是您的 Docker Hub 用户名。您将在创建时定义您的仓库名称。一个示例的 repository 变量应看起来像 duluca/localcast-weather。镜像名称用于在使用 Docker 命令(如 docker ps)时轻松识别您的容器。我将我的命名为 localcast-weatherimagePort 属性将定义应使用哪个端口从容器内部公开您的应用程序。由于我们使用端口 5000 进行开发,请选择不同的端口,例如 8080internalContainerPort 定义了您的 Web 服务器映射到的端口。对于 Node 服务器,这通常是端口 3000,而对于 Nginx 服务器,则是 80。请参阅您所使用的基容器的文档。

  4. 让我们回顾一下之前由 mrm 任务添加到 package.json 中的 Docker 脚本。以下是一个注释版本的脚本,解释了每个功能。

    注意,使用 npm 脚本时,prepost 关键字分别用于在执行给定脚本之前或之后执行辅助脚本。脚本被有意拆分成更小的部分,以便更容易阅读和维护。

    build 脚本如下:

    注意以下 cross-conf-env 命令确保脚本在 macOS、Linux 和 Windows 环境中都能同样良好地执行。

    **package.json**
    ...
      "scripts": {
        ...
        "predocker:build": "npm run build",
        "docker:build": "cross-conf-env docker image build . -t $npm_package_config_imageRepo:$npm_package_version",
        "postdocker:build": "npm run docker:tag",
        ... 
    

    npm run docker:build 将在 pre 脚本中构建你的 Angular 应用程序,然后使用 docker image build 命令构建 Docker 镜像,并在 post 脚本中使用版本号标记镜像:

    在我的项目中,pre 命令以生产模式构建我的 Angular 应用程序,并运行测试以确保我有一个优化后的构建,没有失败的测试。

    我的预命令看起来像:

    "predocker:build": "npm run build:prod && npm test -- --watch=false"

    tag 脚本如下:

    **package.json**
        ...
        "docker:tag": " cross-conf-env docker image tag $npm_package_config_imageRepo:$npm_package_version $npm_package_config_imageRepo:latest",
        ... 
    

    npm run docker:tag 将使用 package.jsonversion 属性的版本号和最新标签标记已构建的 Docker 镜像。

    stop 脚本如下:

    **package.json**
        ...
        "docker:stop": "cross-conf-env docker stop $npm_package_config_imageName || true",
        ... 
    

    npm run docker:stop 如果镜像正在运行,将停止它,这样 run 脚本就可以无错误地执行。

    run 脚本如下:

    注意 run-srun-p 命令是 npm-run-all 包的一部分,用于同步或并行化 npm 脚本的执行。

    **package.json**
        ...
        "docker:run": "run-s -c docker:stop docker:runHelper",
        "docker:runHelper": "cross-conf-env docker run -e NODE_ENV=local --rm --name $npm_package_config_imageName -d -p $npm_package_config_imagePort:$npm_package_config_internalContainerPort $npm_package_config_imageRepo",
        ... 
    

    npm run docker:run 如果镜像已经在运行,将停止它,然后使用 docker run 命令运行新构建的镜像版本。注意,imagePort 属性用作 Docker 镜像的外部端口,映射到 Node.js 服务器监听的内部端口 3000

    publish 脚本如下:

    **package.json**
        ...
        "predocker:publish": "echo Attention! Ensure `docker login` is correct.",
        "docker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:$npm_package_version",
        "postdocker:publish": "cross-conf-env docker image push $npm_package_config_imageRepo:latest",
        ... 
    

    npm run docker:publish 将使用 docker image push 命令将构建好的镜像发布到配置的仓库,在这个例子中是 Docker Hub。

    首先,发布带有版本号的镜像,然后是带有 latest 标签的镜像。taillogs 脚本如下:

    **package.json**
        ...
        "docker:taillogs": "cross-conf-env docker logs -f $npm_package_config_imageName",
        ... 
    

    npm run docker:taillogs 将使用 docker log -f 命令显示正在运行的 Docker 实例的内部控制台日志,这是一个在调试 Docker 实例时非常有用的工具。

    open 脚本如下:

    **package.json**
        ...
        "docker:open": "sleep 2 && cross-conf-env open-cli http://localhost:$npm_package_config_imagePort",
        ... 
    

    npm run docker:open 将等待 2 秒,然后使用 imagePort 属性启动浏览器,显示你应用程序的正确 URL。

    debug 脚本如下:

    **package.json**
        ...
        "predocker:debug": "run-s docker:build docker:run",
        "docker:debug": "run-s -cs docker:open:win docker:open:mac docker:taillogs"
      },
    ... 
    

    npm run docker:debug 将构建你的镜像并在 pre 阶段运行一个实例,打开浏览器,然后开始显示容器的内部日志。

  5. 自定义预构建脚本以在生产模式下构建你的 Angular 应用程序,并在构建镜像之前执行单元测试:

    **package.json**
        "build": "ng build",
        "build:prod": "ng build --prod",
        "predocker:build": "npm run build:prod && npm test -- --watch=false", 
    

    注意 ng build 使用了 --prod 参数,这实现了两个目的:通过 Ahead-of-TimeAOT) 编译将应用程序的大小优化得显著更小,从而提高运行时性能,并且使用 src/environments/environment.prod.ts 中定义的配置项。

  6. 更新 src/environments/environment.prod.ts 以看起来你正在使用自己的 appIdOpenWeather

    export const environment = {   
      production: true,
      appId: '01ff1xxxxxxxxxxxxxxxxxxxxx',
      username: 'localcast',
      baseUrl: 'https://',
      geonamesApi: 'secure',
    } 
    

    我们正在修改 npm test 的执行方式,因此测试只运行一次,工具停止执行。提供 --watch=false 选项来实现此行为,而不是开发友好的默认连续执行行为。

  7. 在项目根目录下创建一个名为 Dockerfile 的新文件,不要添加文件扩展名。

  8. 实现或替换 Dockerfile 的内容,如下所示:

    **Dockerfile**
    FROM duluca/minimal-node-web-server:lts-alpine 
    WORKDIR /usr/src/app
    COPY dist/local-weather-app public 
    

    一定要检查你的 dist 文件夹的内容,以确保你正在复制正确的文件夹,该文件夹在其根目录下包含 index.html 文件。

  9. 执行 npm run predocker:build 并确保它在终端中无错误运行,以确保你的应用程序更改已成功。

  10. 执行 npm run docker:build 并确保它在终端中无错误运行,以确保镜像构建成功。

    虽然你可以单独运行提供的任何脚本,但向前看,你实际上只需要记住其中两个:

    • npm run docker:debug 将会测试、构建、标记、运行、跟踪并在新浏览器窗口中启动你的容器化应用进行测试。

    • npm run docker:publish 将将你刚刚构建和测试的镜像发布到在线 Docker 仓库。

  11. 在你的终端中执行 docker:debug

    $ npm run docker:debug 
    

    成功的 docker:debug 运行应该导致一个新的聚焦浏览器窗口,其中包含你的应用程序和终端中的服务器日志跟踪,如下所示:

    Current Environment: local.
    Server listening on port 3000 inside the container
    Attention: To access server, use http://localhost:EXTERNAL_PORT
    EXTERNAL_PORT is specified with 'docker run -p EXTERNAL_PORT:3000'. See 'package.json->imagePort' for the default port.      
    GET / 304 2.194 ms - -
    GET /runtime-es2015.js 304 0.371 ms - -
    GET /polyfills-es2015.js 304 0.359 ms - -
    GET /styles-es2015.js 304 0.839 ms - -
    GET /vendor-es2015.js 304 0.789 ms - -
    GET /main-es2015.js 304 0.331 ms - - 
    

    你应该始终运行 docker ps 来检查你的镜像是否正在运行,它上次更新是什么时候,以及它是否与任何声称相同端口的现有镜像冲突。

  12. 在你的终端中执行 docker:publish

    $ npm run docker:publish 
    

    你应该在终端窗口中观察到一次成功的运行,如下所示:

    The push refers to a repository [docker.io/duluca/localcast- weather]
    60f66aaaaa50: Pushed
    ...
    latest: digest: sha256:b680970d76769cf12cc48f37391d8a542fe226b66d9a6f8a7ac81ad77be4 f58b size: 2827 
    

随着时间的推移,你的本地 Docker 缓存可能会增长到相当大的大小;例如,在我的笔记本电脑上,两年内已经达到了大约 40 GB。你可以使用 docker image prunedocker container prune 命令来减小缓存的大小。有关更详细的信息,请参阅docs.docker.com/config/pruning文档。

通过定义 Dockerfile 并编写使用它的脚本,我们在代码库中创建了活生生的文档。我们已经实现了 DevOps 并关闭了配置差距。

确保以与 local-weather-app 相同的方式将 lemon-mart 容器化,并通过执行 npm run docker:debug 验证你的工作。

你可能会发现通过 CLI 交互 npm 脚本有些令人困惑。接下来,让我们看看 VS Code 的 npm 脚本支持。

VS Code 中的 NPM 脚本

VS Code 默认提供对 npm 脚本的支持。为了启用 npm 脚本探索器,打开 VS Code 设置并确保存在 "npm.enableScriptExplorer": true 属性。一旦这样做,你将在 探索器 面板中看到一个可展开的标题名为 NPM SCRIPTS,如下面的截图所示,箭头已突出显示:

img/B14094_09_08.png

图 9.8:VS Code 中的 NPM 脚本

您可以单击任何脚本以启动package.json中包含脚本的行,或者右键单击并选择运行来执行脚本。

让我们看看与 Docker 交互的一种更简单的方法。

VS Code 中的 Docker 扩展

与 Docker 镜像和容器交互的另一种方式是通过 VS Code。如果您已按照第二章设置开发环境中建议的,安装了来自 Microsoft 的ms-azuretools.vscode-docker Docker 扩展,您可以通过 VS Code 左侧导航菜单上的 Docker 标志识别该扩展,如下面的截图所示,用白色圆圈标出:

图 9.9:VS Code 中的 Docker 扩展

让我们来看看扩展提供的功能之一。参考前面的截图和以下列表中的编号步骤,以获得快速解释:

  1. 镜像包含您系统上所有容器快照的列表。

  2. 右键单击 Docker 镜像会弹出一个上下文菜单,可以运行各种操作,如运行推送标记****。

  3. 容器列出了您系统上所有可执行的 Docker 容器,您可以启动、停止或附加到它们。

  4. 注册表显示了您配置连接到的注册表,例如 Docker Hub 或AWS 弹性容器注册表AWS ECR)。

虽然扩展使与 Docker 的交互变得更容易,但Docker 的 npm 脚本(您使用mrm任务配置的)自动化了大量与构建、标记和测试镜像相关的任务。它们都是跨平台的,在 CI 环境中也能同样良好地工作。

npm run docker:debug脚本自动化了大量任务,以验证您有一个良好的镜像构建!

现在让我们看看如何将我们的容器部署到云,并随后实现持续交付(CD)。

将 Dockerfile 部署到云

使用 Docker 的一个优点是我们可以在任何数量的操作系统环境中部署它,从个人 PC 到服务器和云提供商。在任何情况下,我们都期望我们的容器以相同的方式运行。让我们将 LocalCast 天气应用部署到 Google Cloud Run。

Google Cloud Run

Google Cloud Run 允许您部署任意 Docker 容器,并在 Google Cloud Platform 上执行它们,而无需任何繁重的开销。完全管理的实例提供一些免费时间;然而,这里没有永久免费的版本。请注意您可能产生的任何费用。请参阅cloud.google.com/run/pricing?hl=en_US%20for%20pricing

参考第二章,设置开发环境,了解如何安装 glcoud。

本节使用local-weather-app仓库。

让我们配置 glcoud,以便我们可以部署一个Dockerfile

  1. 更新您的Dockerfile以覆盖ENTRYPOINT命令:

    **Dockerfile**
    FROM duluca/minimal-node-web-server:lts-alpine
    WORKDIR /usr/src/app
    COPY dist/local-weather-app public
    ENTRYPOINT [ "npm", "start" ] 
    

    minimal-node-web-server 中的 ENTRYPOINT 命令运行一个名为 dumb-init 的进程,以强制你的 Node 进程的进程 ID 随机化。然而,gcloud 无法执行此命令,这就是我们为什么要覆盖它的原因。

  2. 创建一个新的 gcloud 项目:

    $ gcloud projects create localcast-weather 
    

    记得使用你自己的项目名称!

  3. 导航到 console.cloud.google.com/

  4. 定位到你的新项目,并从侧边栏中选择计费选项,如图下截图所示:

    图 9.10:计费选项

  5. 按照说明设置计费账户。

    如果你看到它,免费增值账户选项也将有效。否则,你可以选择利用免费试用优惠。然而,设置一个预算警报是个好主意,这样你就可以在每月超过一定金额时收到通知。更多信息请见 cloud.google.com/billing/docs/how-to/modify-project

  6. 创建一个 .gcloudignore 文件,忽略除 Dockerfiledist 文件夹之外的所有内容:

    **.gcloudignore**
    /*
    !Dockerfile
    !dist/ 
    
  7. 在云端添加一个新的 npm 脚本来构建你的 Dockerfile

    **package.json**
      scripts: {
        "gcloud:build": "gcloud builds submit --tag gcr.io/localcast-weather/localcast-weather --project localcast-weather",
      } 
    

    记得使用你自己的项目名称!

  8. 添加另一个 npm 脚本来部署你发布的容器:

    **package.json**
      scripts: {
        "gcloud:deploy": "gcloud run deploy --image gcr.io/localcast-weather/localcast-weather --platform managed --project localcast-weather --region us-east1"
      } 
    

    注意,你应该提供离你地理位置最近的区域,以获得最佳体验。

  9. 按照以下方式构建你的 Dockerfile

    $ npm run gcloud:build 
    

    在运行此命令之前,记得为 prod 构建你的应用程序。你 dist 文件夹中的任何内容都将被部署。

    注意,在首次运行时,你将需要回答问题以配置你的账户以供初始使用。正确选择你的账户和项目名称,否则,请选择默认选项。build 命令在首次运行时可能会失败。有时需要多次运行,gcloud 才能预热并成功构建你的容器。

  10. 一旦你的容器发布,使用以下命令部署它:

    $ npm run gcloud:deploy 
    

成功的部署看起来如下所示:

图 9.11:成功的部署

恭喜,你已经在 Google Cloud 上部署了你的容器。你应该能够通过终端输出的 URL 访问你的应用程序。

如往常一样,考虑将 CLI 命令作为 npm 脚本添加到你的项目中,这样你可以维护你脚本的活文档。这些脚本还将允许你利用 npm 中的预脚本和后脚本,从而自动化构建你的应用程序、容器和标记过程。因此,下次你需要部署时,你只需要运行一个命令。我鼓励读者从我们之前设置的 npm 脚本 Docker 工具中寻找灵感,以创建你自己的 gcloud 脚本集。

有关更多信息和一些示例项目,请参阅 cloud.google.com/run/docs/quickstarts/prebuilt-deploycloud.google.com/run/docs/quickstarts/build-and-deploy

使用 Cloud Run 配置 Docker

在上一节中,我们将我们的 Dockerfiledist 文件夹提交给 gcloud,以便它为我们构建容器。这是一个方便的选项,可以避免一些额外的配置步骤。然而,您仍然可以利用基于 Docker 的工作流程来构建和发布您的容器。

让我们使用 gcloud 配置 Docker:

  1. 设置您的默认区域:

    $ gcloud config set run/region us-east1 
    
  2. 使用 gcloud 容器注册库配置 Docker:

    $ gcloud auth configure-docker 
    
  3. 使用 gcloud 主机名标记您已构建的容器:

    $ docker tag duluca/localcast-weather:latest gcr.io/localcast-weather/localcast-weather:latest 
    

    有关如何标记镜像的详细说明,请参阅 cloud.google.com/container-registry/docs/pushing-and-pulling

  4. 使用 Docker 将容器发布到 gcloud:

    $ docker push gcr.io/localcast-weather/localcast-weather:latest 
    
  5. 执行 deploy 命令:

    $ gcloud run deploy --image gcr.io/localcast-weather/localcast-weather --platform managed --project localcast-weather 
    

    在初始部署期间,此命令可能看起来卡住了。大约 15 分钟后再试一次。

  6. 按照屏幕上的说明完成您的部署。

  7. 按照屏幕上显示的 URL 检查您的应用程序是否已成功部署。

上述步骤演示了一种与我们在 第十三章AWS 上的高可用云基础设施 中部署到 AWS ECS 时所利用的技术类似的部署技术。

有关更多信息,请参阅 cloud.google.com/sdk/gcloud/reference/run/deploy。在接下来的几节中,我们将切换回 LemonMart。

Cloud Run 故障排除

为了故障排除您的 glcoud 命令,您可以使用 Google Cloud Platform 控制台 console.cloud.google.com/

在 Cloud Run 菜单下,您可以跟踪您正在运行的容器。如果在部署过程中发生错误,您可能想检查日志以查看容器创建的消息。参考以下截图,它显示了 localcast-weather 部署的日志:

图 9.12:Cloud Run 日志

要了解更多关于 Cloud Run 故障排除的信息,请参阅 cloud.google.com/run/docs/troubleshooting

恭喜!您已经掌握了在本地开发环境中使用 Docker 容器以及将它们推送到云中的多个注册库和运行时环境的基本技能。

持续部署

CD 是指代码更改成功通过您的管道后可以自动部署到目标环境。尽管有持续部署到生产环境的例子,但大多数企业更喜欢将目标设置为开发环境。采用门控方法将更改通过开发的各个阶段,包括测试、预发布和最终的生产。CircleCI 可以通过审批工作流程促进门控部署,这一点将在本节后面介绍。

在 CircleCI 中,为了部署您的镜像,我们需要实现一个deploy作业。在这个作业中,您可以部署到多个目标,例如 Google Cloud Run、Docker Hub、Heroku、Azure 或 AWS ECS。与这些目标的集成将涉及多个步骤。从高层次来看,这些步骤如下:

  1. 为您的目标环境配置一个 orb,它提供了部署您的软件所需的 CLI 工具。

  2. 将针对目标环境的特定登录凭证或访问密钥存储为 CircleCI 环境变量。

  3. 如果不是使用特定平台的build命令,则在 CI 管道中构建容器。然后使用docker push将生成的 Docker 镜像提交到目标平台的 Docker 注册库。

  4. 执行特定平台的deploy命令,指示目标运行刚刚推送的 Docker 镜像。

通过使用基于 Docker 的工作流程,我们在系统和目标环境方面实现了极大的灵活性。以下图表通过突出我们可用的可能选择排列来阐述这一点:

图 9.13

图 9.13:n-to-n 部署

如您所见,在容器化的世界中,可能性是无限的。我将在本章后面演示如何使用容器和 CI 将应用程序部署到 Google Cloud Run。在基于 Docker 的工作流程之外,您可以使用专门构建的 CLI 工具快速部署您的应用程序。接下来,让我们看看如何使用 CircleCI 将应用程序部署到 Vercel Now。

使用 CircleCI 将应用程序部署到 Vercel Now

第四章,“自动化测试、CI 和发布到生产”中,我们配置了 LocalCast Weather 应用程序使用 CircleCI 进行构建。我们可以增强我们的 CI 管道,以使用构建输出,并可选择将其部署到 Vercel Now。

注意,ZEIT Now 在 2020 年更名为 Vercel Now。

本节使用local-weather-app仓库。本节的config.yml文件命名为.circleci/config.ch9.yml。您还可以在github.com/duluca/local-weather-app/pull/50找到执行本章中.yml文件的拉取请求,使用branch deploy_Vercelnow

注意,这个分支在config.ymlDockerfile中有一个修改过的配置,以使用来自local-weather-appprojects/ch6代码。

让我们更新config.yml文件,添加一个名为deploy的新作业。在即将到来的工作流程部分,我们将使用此作业在批准时部署管道:

  1. 从您的 Vercel Now 账户创建一个令牌。

  2. 在您的 CircleCI 项目中添加一个名为 NOW_TOKEN 的环境变量,并将您的 Vercel Now 令牌作为其值存储。

  3. config.yml 中,更新 build 作业的新步骤,并添加一个名为 deploy 的新作业:

    **.circleci/config.yml**
    ...
    jobs:
      build:
        ...       
        - run:
            name: Move compiled app to workspace
            command: |
              set -exu
              mkdir -p /tmp/workspace/dist
              mv dist/local-weather-app /tmp/workspace/dist/
        - persist_to_workspace:
            root: /tmp/workspace
            paths:
              - dist/local-weather-app
      deploy:
        docker:
          - image: circleci/node:lts
        working_directory: ~/repo
        steps:
          - attach_workspace:
              at: /tmp/workspace
          - run: npx now --token $NOW_TOKEN --platform-version 2 --prod /tmp/workspace/dist/local-weather-app --confirm 
    

    build 作业中,构建完成后,我们添加两个新步骤。首先,我们将位于 dist 文件夹中的编译后的应用程序移动到工作区,并持久化该工作区,以便我们可以在另一个作业中使用它。在名为 deploy 的新作业中,我们附加工作区并使用 npx 运行 now 命令来部署 dist 文件夹。这是一个简单的过程。

    注意 $NOW_TOKEN 是我们在 CircleCI 项目中存储的环境变量。

  4. 实现一个简单的 CircleCI 工作流程,以持续部署 build 作业的结果:

    **.circleci/config.yml**
    ...
    workflows:
      version: 2
      build-test-and-deploy:
        jobs:
          - build
          - deploy:
             requires:
               - build 
    

    注意,deploy 作业在执行之前会等待 build 作业完成。

  5. 通过检查测试结果来确保您的 CI 管道成功执行:

图片

图 9.14:在 deploy_Vercelnow 分支上成功部署 local-weather-app 的 Vercel Now

大多数云服务提供商的 CLI 命令都需要安装到您的管道中才能正常工作。由于 Vercel Now 有 npm 包,这很容易做到。AWS、Google Cloud 或 Microsoft Azure 的 CLI 工具需要使用 brewchoco 等工具安装。在 CI 环境中手动执行此操作很繁琐。接下来,我们将介绍 orbs,它有助于解决这个问题。

使用 orbs 将应用程序部署到 GCloud

Orbs 包含一组配置元素,用于封装 CircleCI 项目之间的可共享行为。CircleCI 提供由 CLI 工具维护者开发的 orbs。这些 orbs 使您能够轻松地将 CLI 工具添加到您的管道中,而无需手动设置,配置简单。

要使用 orbs,您的 config.yml 版本号必须设置为 2.1,并且在您的 CircleCI 安全设置中,您必须选择允许未认证 orbs 的选项。

以下是一些您可以在项目中使用的 orbs:

  • circleci/aws-clicircleci/aws-ecr 为您提供 AWS CLI 工具,并帮助您与 AWS 弹性容器服务AWS ECS)交互,执行诸如将容器部署到 AWS ECR 等任务。

  • circleci/aws-ecs 简化了您的 CircleCI 配置,以便将容器部署到 AWS ECS。

  • circleci/gcp-clicircleci/gcp-gcr 为您提供 GCloud CLI 工具和访问 Google 容器注册库GCR)的权限。

  • circleci/gcp-cloud-run 简化了您的 CircleCI 配置,以便将容器部署到 Cloud Run。

  • circleci/azure-clicircleci/azure-acr 为您提供 Azure CLI 工具和访问 Azure 容器注册库ACR)的权限。

查看 Orb 注册表以获取有关如何使用这些 orbs 的更多信息:circleci.com/orbs/registry

现在,让我们配置circleci/gcp-cloud-run orb 与 Local Weather 应用,这样我们就可以持续将我们的应用部署到 GCloud,而无需在 CI 服务器上手动安装和配置 gcloud CLI 工具。

local-weather-app仓库中,你可以找到一个从这一步开始在 CircleCI 上执行 Cloud Run 配置的 pull request,链接为github.com/duluca/local-weather-app/pull/51,使用的是deploy_cloudrun分支。

注意,这个分支在config.ymlDockerfile中有修改过的配置,以使用local-weather-app中的projects/ch6代码。

首先,配置你的 CircleCI 和 GCloud 账户,以便你可以从 CI 服务器部署。这与从你的开发机器部署明显不同,因为 gcloud CLI 工具会自动为你设置必要的认证配置。在这里,你必须手动完成:

  1. 在你的 CircleCI 账户设置中,在安全部分确保你允许执行未经认证/未签名的 orb。

  2. 在 CircleCI 项目设置中,添加一个名为GOOGLE_PROJECT_ID的环境变量。

    如果你和我使用的是相同的项目 ID,那么这个应该是localcast-weather

  3. 为你的项目现有的服务账户创建一个 GCloud 服务账户密钥。

    创建服务账户密钥将生成一个 JSON 文件。不要将此文件提交到你的代码仓库。不要通过不安全的通信渠道(如电子邮件或短信)共享其内容。泄露此文件的内容意味着任何第三方都可以通过密钥权限访问你的 GCloud 资源。

  4. 将 JSON 文件的内容复制到 CircleCI 环境变量GCLOUD_SERVICE_KEY中。

  5. 添加另一个名为GOOGLE_COMPUTE_ZONE的环境变量,并将其设置为你的首选区域。

    我使用了us-east1

  6. 更新你的config.yml文件,添加一个名为circleci/gcp-cloud-run的 orb:

    **.circleci/config.yml**
    **version: 2.1**
    orbs:
      cloudrun: circleci/gcp-cloud-run@1.0.2
      ... 
    
  7. 接下来,实现一个名为deploy_cloudrun的新作业,利用 orb 功能来初始化、构建、部署和测试我们的部署:

    **.circleci/config.yml**
    ...
    deploy_cloudrun:
      docker:
        - image: 'cimg/base:stable'
      working_directory: ~/repo
      steps:
        - attach_workspace:
            at: /tmp/workspace
        - checkout
        - run:
            name: Copy built app to dist folder
            command: cp -avR /tmp/workspace/dist/ .
        - cloudrun/init
        - cloudrun/build:
           tag: 'gcr.io/${GOOGLE_PROJECT_ID}/test-${CIRCLE_SHA1}'
           source: ~/repo
        - cloudrun/deploy:
            image: 'gcr.io/${GOOGLE_PROJECT_ID}/test-${CIRCLE_SHA1}'
            platform: managed
            region: us-east1
            service-name: localcast-weather
            unauthenticated: true
        - run:
            command: >
              GCP_API_RESULTS=$(curl -s "$GCP_DEPLOY_ENDPOINT")
              if ! echo "$GCP_API_RESULTS" | grep -nwo "LocalCast Weather"; then
                echo "Result is unexpected"
                echo 'Result: '
                curl -s "$GCP_DEPLOY_ENDPOINT"
                exit 1;
              fi
            name: Test managed deployed service. 
    

    我们首先从build作业中加载dist文件夹。然后运行cloudrun/init,以便初始化 CLI 工具。使用cloudrun/build,我们构建项目根目录下的Dockerfile,构建结果自动存储在 GCR 中。然后,cloudrun/deploy部署我们刚刚构建的镜像,使我们的代码上线。在最后一个命令中,使用curl工具检索我们网站的index.html文件,并检查它是否已正确部署,通过搜索 LocalCast Weather 字符串来验证。

  8. 更新你的工作流程以持续部署到 gcloud:

    **.circleci/config.yml**
    ...
    workflows:
     version: 2
      build-test-and-deploy:
        jobs:
          - build
          - deploy_cloudrun:
              requires:
                - build 
    

    注意,你可以有多个同时部署到多个目标的deploy作业。

  9. 通过检查测试结果来确保你的 CI 管道执行成功:

图片

图 9.15:在 deploy_cloudrun 分支上成功部署 local-weather-app 到 gcloud

CD 对于开发和测试环境来说效果很好。然而,通常希望有门控部署,即在部署到达生产环境之前,必须有人批准。接下来,让我们看看如何使用 CircleCI 实现这一点。

门控 CI 工作流程

在 CircleCI 中,您可以定义一个工作流程来控制作业的执行方式和时间。考虑以下配置,给定作业 builddeploy

**.circleci/config.yml**
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build
      - hold:
          type: approval
          requires:
            - build
      - deploy:
          requires:
            - hold 

首先,执行 build 作业。然后,我们引入一个名为 hold 的特殊作业,其类型为 approval,它要求 build 作业成功完成。一旦发生这种情况,管道就会被暂停。如果或当决策者批准了 hold,则 deploy 步骤可以执行。参考以下截图以查看 hold 的外观:

图片

图 9.16:管道中的暂停

build and test steps are broken out into two separate jobs:
workflows:
  version: 2
    build-test-and-approval-deploy:
      jobs:
      - build 
      - test
      - hold:
         type: approval
         requires:
           - build
           - test
         filters:
           branches:
             only: master
      - deploy:
        requires:
          - hold 

在这种情况下,buildtest 作业是并行执行的。如果我们在一个分支上,这就是管道停止的地方。一旦分支与 master 合并,管道就会被暂停,决策者有选择部署特定构建或不部署的选项。这种分支过滤类型确保只有合并到 master 的代码才能部署,这与 GitHub 流相一致。

接下来,我们将深入了解如何自定义 Docker 以适应您的流程和环境。

高级持续集成

第四章自动化测试、持续集成和发布到生产 中,我们介绍了利用默认功能的基本 CircleCI 工作流程。除了单元测试执行的基本自动化之外,CI 的另一个目标是通过每次代码推送都构建、测试和生成应用程序的可部署工件,以实现一致和可重复的环境。在推送一些代码之前,开发者应该对构建通过有一个合理的预期;因此,创建一个可靠的 CI 环境至关重要,该环境可以自动化开发者也可以在本地机器上运行的命令。为了实现这一目标,我们将构建一个自定义的构建管道,该管道可以在任何操作系统上运行,无需配置或任何行为变化。

本节使用 lemon-mart 仓库。请确保您的项目已按本章前面所述正确配置,通过执行 npm run docker:debug

容器化构建环境

为了确保在各种操作系统平台、开发机器和 CI 环境之间保持一致的构建环境,您可能需要将您的构建环境容器化。请注意,目前至少有六种常见的 CI 工具正在使用中。学习每个工具的细节几乎是一项几乎不可能完成的任务。

将构建环境容器化是一个高级概念,它超越了当前 CI 工具所期望的功能。然而,容器化是一种很好的方法,可以标准化超过 90% 的构建基础设施,并且可以在几乎任何 CI 环境中执行。采用这种方法,你学到的技能和创建的构建配置将更有价值,因为你的知识和你创建的工具都变得可转移和可重复使用。

有许多策略可以将构建环境容器化,具有不同粒度和性能期望。为了本书的目的,我们将关注可重用性和易用性。我们不会创建一个复杂且相互依赖的 Docker 镜像集,这可能允许更高效的失败和恢复路径,而是将重点放在一个简单直接的流程上。Docker 的新版本有一个名为多阶段构建的出色功能,它允许你以易于阅读的方式定义多镜像过程,并维护一个单一的 Dockerfile

在过程结束时,你可以提取一个优化的容器镜像作为我们的交付物,从而简化了之前过程中使用的镜像的复杂性。

作为提醒,你的单个 Dockerfile 可能看起来像以下示例:

**Dockerfile**
FROM duluca/minimal-node-web-server:lts-alpine 
WORKDIR /usr/src/app
COPY dist/lemon-mart public 

多阶段 Dockerfile

多阶段构建通过在单个 Dockerfile 中使用多个 FROM 语句来实现,其中每个阶段都可以执行一个任务,并将其实例内的任何资源提供给其他阶段。在构建环境中,我们可以将各种与构建相关的任务作为它们自己的阶段来实现,然后将最终结果,如 Angular 构建的 dist 文件夹,复制到包含 Web 服务器的最终镜像中。在这种情况下,我们将实现三个阶段的镜像:

  • 构建器:用于构建你的 Angular 应用的生产版本

  • 测试器:用于对无头 Chrome 实例运行单元和端到端测试

  • Web 服务器:仅包含优化后的生产代码的最终结果

多阶段构建需要 Docker 版本 17.05 或更高。要了解更多关于多阶段构建的信息,请阅读docs.docker.com/develop/develop-images/multistage-build上的文档。

如以下图所示,构建器将构建应用程序,测试器将执行测试:

图 9.17:多阶段 Dockerfile

最终镜像将使用构建步骤的结果来构建。

首先,在项目的根目录下创建一个新文件来实现多阶段配置,命名为 integration.Dockerfile

构建器

第一个阶段是 builder。我们需要一个轻量级的构建环境,以确保跨所有方面的构建一致性。为此,我创建了一个基于 Alpine 的 Node 构建环境,其中包括 npm、bash 和 Git 工具。这个最小容器被称为 duluca/minimal-node-build-env,基于 node-alpine,可以在 Docker Hub 上找到 hub.docker.com/r/duluca/minimal-node-build-env。这个镜像比 node 小大约 10 倍。

Docker 镜像的大小对构建时间有实际影响,因为 CI 服务器或您的团队成员将花费额外的时间来拉取更大的镜像。请选择最适合您需求的 环境。

让我们使用合适的基镜像创建一个构建器:

  1. 确保在 package.json 中有 build:prod 命令:

    **package.json**
    "scripts": {
      "build:prod": "ng build --prod",
    } 
    
  2. 继承自基于 Node.js 的构建环境,例如 node:lts-alpineduluca/minimal-node-build-env:lts-alpine

  3. 在新的 Dockerfile 中实现特定环境的构建脚本,命名为 integration.Dockerfile,如下所示:

    **integration.Dockerfile**
    FROM duluca/minimal-node-build-env:lts-alpine as builder
    ENV BUILDER_SRC_DIR=/usr/src
    # setup source code directory and copy source code
    WORKDIR $BUILDER_SRC_DIR
    COPY . .
    # install dependencies and build
    RUN npm ci
    RUN npm run style
    RUN npm run lint
    RUN npm run build:prod 
    

CI 环境将从 GitHub 检出您的源代码并将其放置在当前目录中。因此,使用点符号从 当前工作目录CWD)复制源代码应该可以工作,就像在您的本地开发环境中一样。如果您遇到问题,请参考您的 CI 提供商的文档。

接下来,让我们看看您如何调试您的 Docker 构建。

调试构建环境

根据您的特定需求,您对 Dockerfile 中构建器部分的初始设置可能很令人沮丧。为了测试新命令或调试错误,您可能需要直接与构建环境交互。

要在构建环境中交互式实验和/或调试,请执行以下命令:

$ docker run -it duluca/minimal-node-build-env:lts-alpine /bin/bash 

您可以在将它们烘焙到 Dockerfile 之前,在这个临时环境中测试或调试命令。

Tester

第二个阶段是 tester。默认情况下,Angular CLI 生成的测试需求是针对开发环境的。这在 CI 环境中不起作用;我们必须配置 Angular 以针对无头浏览器运行,该浏览器可以在没有 GPU 辅助的情况下执行,并且还需要一个容器化环境来执行测试。

Angular 测试工具在 第四章自动化测试、CI 和发布到生产 中有所介绍。

为 Angular 配置无头浏览器

Protractor 测试工具官方支持在无头模式下运行 Chrome。为了在 CI 环境中执行 Angular 测试,您需要配置测试运行器 Karma,使其与无头 Chrome 实例一起运行:

  1. 更新 karma.conf.js 以包括新的无头浏览器选项:

    **Karma.conf.js**
    ...
        browsers: ['Chrome', 'ChromiumHeadless', 'ChromiumNoSandbox'],
        customLaunchers: {
          ChromiumHeadless: {
            base: 'Chrome',
            flags: [
              '--headless',
              '--disable-gpu',
              // Without a remote debugging port, Google Chrome exits immediately.
              '--remote-debugging-port=9222',
              ],
            debug: true,
          },
          ChromiumNoSandbox: {
            base: 'ChromiumHeadless',
            flags: ['--no-sandbox', '--disable-translate', '--disable- extensions']
          },
        }, 
    

    ChromiumNoSandbox 自定义启动器封装了良好默认设置所需的所有配置元素。

  2. 更新 protractor 配置以在无头模式下运行:

    **e2e/protractor.conf.js**
    ...
      capabilities: { 
        browserName: 'chrome',
        chromeOptions: {
          args: [
            '--headless',
            '--disable-gpu',
            '--no-sandbox',
            '--disable-translate',
            '--disable-extensions',
            '--window-size=800,600',
          ],
        },
      },
    ... 
    

    为了测试你的应用程序在响应式场景下的表现,你可以使用前面提到的--window-size选项来更改浏览器设置。

  3. 更新package.json中的脚本,以在生产构建场景中选择新的浏览器选项:

    **package.json**
    "scripts": {
      ...
      "test": "ng test lemon-mart --browsers Chrome",
      "test:prod": "npm test -- --browsers ChromiumNoSandbox   --  watch=false"
    ...
    } 
    

    注意,test:prod不包括npm run e2e。e2e 测试是执行时间较长的集成测试,所以在将它们作为关键构建流程的一部分时要三思。e2e 测试不会在下一节中提到的轻量级测试环境中运行,因为它们需要更多的资源和时间来执行。

现在,让我们定义容器化的测试环境。

配置我们的测试环境

为了创建一个轻量级的测试环境,我们将利用基于 Alpine 的 Chromium 浏览器安装:

  1. 继承自duluca/minimal-node-chromium:lts-alpine

  2. 将以下配置追加到integration.Dockerfile中:

    **integration.Dockerfile**
    ...
    FROM duluca/minimal-node-chromium:lts-alpine as tester
    ENV BUILDER_SRC_DIR=/usr/src
    ENV TESTER_SRC_DIR=/usr/src
    WORKDIR $TESTER_SRC_DIR
    COPY --from=builder $BUILDER_SRC_DIR .
    # force update the webdriver, so it runs with latest version of Chrome
    RUN cd ./node_modules/protractor && npm i webdriver-manager@latest
    WORKDIR $TESTER_SRC_DIR
    RUN npm run test:prod 
    

前面的脚本将从builder阶段复制生产构建,并以可预测的方式执行你的测试脚本。

Web 服务器

第三个也是最后一个阶段生成将成为你的 Web 服务器的容器。一旦这个阶段完成,前面的阶段将被丢弃,最终结果将是一个优化后的小于 10 MB 的容器:

  1. 在文件末尾追加以下FROM语句来构建 Web 服务器,但这次,从builder复制生产就绪代码,如下代码片段所示:

    **integration.Dockerfile**
    ...
    FROM duluca/minimal-nginx-web-server:1-alpine as webserver
    ENV BUILDER_SRC_DIR=/usr/src
    COPY --from=builder $BUILDER_SRC_DIR/dist/lemon-mart /var/www
    CMD 'nginx' 
    
  2. 构建和测试你的多阶段Dockerfile

    $ docker build -f integration.Dockerfile . 
    

    根据你的操作系统,你可能会看到终端错误。只要 Docker 镜像最终成功构建,你就可以安全地忽略这些错误。为了参考,当我们稍后在 CircleCI 上构建此镜像时,CI 服务器上没有记录任何错误。

  3. 将你的脚本保存为一个新的 npm 脚本,命名为build:integration,如下所示:

    **package.json**
    "scripts": {
    ...
      "build:integration": "cross-conf-env docker image build -f integration.Dockerfile . -t $npm_package_config_imageRepo:latest",
    ...
    } 
    

伟大的工作!你已经定义了一个自定义的构建和测试环境。让我们如下可视化我们的努力结果:

图 9.18:多阶段构建环境结果

通过利用多阶段Dockerfile,我们可以定义一个定制的构建环境,并在过程结束时只传输必要的字节。在先前的例子中,我们避免了将 250+ MB 的开发依赖项传输到我们的生产服务器,并且只交付了一个 7 MB 的容器,它具有最小的内存占用。

现在,让我们在 CircleCI 上执行这个容器化流水线。

CircleCI 容器内容器

第四章自动化测试、持续集成和发布到生产中,我们创建了一个相对简单的 CircleCI 文件。稍后,我们也将为这个项目重复相同的配置,但现在,我们将使用一个容器内的容器设置,利用我们刚刚创建的多阶段Dockerfile

lemon-mart 仓库中,本节使用的 config.yml 文件名为 .circleci/config.docker-integration.yml。您还可以在 CircleCI 上找到从本章执行 .yml 文件的拉取请求,使用 docker-integration 分支,链接为 github.com/duluca/lemon-mart/pull/25

注意,此构建使用修改后的 integration.Dockerfile 来使用来自 lemon-martprojects/ch8 代码。

在您的源代码中,创建一个名为 .circleci 的文件夹,并添加一个名为 config.yml 的文件:

**.circleci/config.yml**
version: 2.1
jobs:
  build:
    docker:
      - image: circleci/node:lts
    working_directory: ~/repo
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: Execute Pipeline (Build Source -> Test -> Build Web Server)
          command: |
            docker build -f integration.Dockerfile . -t lemon-mart:$CIRCLE_BRANCH
            mkdir -p docker-cache
            docker save lemon-mart:$CIRCLE_BRANCH | gzip > docker-cache/built-image.tar.gz
      - save_cache:
          key: built-image-{{ .BuildNum }}
          paths:
            - docker-cache
      - store_artifacts:
          path: docker-cache/built-image.tar.gz
          destination: built-image.tar.gz
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - build 

在先前的 config.yml 文件中,定义了一个名为 build-and-deploy 的工作流程,其中包含一个名为 build 的作业。该作业使用 CircleCI 预构建的 circleci/node:lts 图像。

build 任务有五个步骤:

  1. checkout 从 GitHub 检出源代码。

  2. setup_remote_docker 通知 CircleCI 设置 Docker-within-Docker 环境,这样我们就可以在我们的流水线中运行容器。

  3. run 执行 docker build -f integration.Dockerfile . 命令以启动我们的自定义构建过程,将基于 Alpine 的图像缓存,并用 $CIRCLE_BRANCH. 标记它。

  4. save_cache 保存我们在缓存中创建的图像,以便在下一步中消费。

  5. store_artifacts 从缓存中读取创建的图像,并将其作为构建工件发布,可以从 Web 界面下载或由另一个作业用于将其部署到云环境中。

在您将更改同步到 GitHub 后,如果一切顺利,您将有一个通过 绿色 构建的结果。如图所示,此构建是成功的:

图片

图 9.19:使用 lemon-mart docker-integration 分支在 CircleCI 上进行的绿色构建

注意,压缩并 gzip 的图像文件大小为 9.2 MB,其中包括我们的 Web 应用程序,以及大约 7 MB 的基础镜像大小。

目前,CI 服务器正在运行并执行我们的三步流水线。正如您在前面的屏幕截图中所见,构建正在生成一个名为 built-image.tar.gz 的压缩文件,其中包含 Web 服务器图像。您可以从 Artifacts 选项卡下载此文件。然而,我们并没有将生成的图像部署到服务器上。

您现在已经充分掌握了使用 CircleCI 的技能。我们将在第十三章,AWS 上的高可用云基础设施中重新访问这个多阶段 Dockerfile 以在 AWS 上执行部署。

接下来,让我们看看您如何从您的 Angular 应用程序中获取代码覆盖率报告,并在 CircleCI 中记录结果。

代码覆盖率报告

了解您的 Angular 项目的单元测试覆盖量和趋势的一个好方法是查看代码覆盖率报告。

为了为您的应用程序生成报告,请从您的 project 文件夹中执行以下命令:

$ npx ng test --browsers ChromiumNoSandbox --watch=false --code-coverage 

生成的报告将作为一个名为 coverage 的文件夹下的 HTML 文件创建;执行以下命令以在浏览器中查看它:

$ npx http-server -c-1 -o -p 9875 ./coverage 

在您的项目中安装 http-server 作为开发依赖项。

这是 istanbul/nyc 为 LemonMart 生成的文件夹级样本覆盖率报告:

图片

图 9.20:LemonMart 的 Istanbul 代码覆盖率报告

您可以针对特定文件夹,例如 src/app/auth,进行深入分析,并获取文件级报告,如下所示:

图片

图 9.21:src/app/auth 的 Istanbul 代码覆盖率报告

您可以进一步深入以获取特定文件的行级覆盖率,例如 cache.service.ts,如下所示:

图片

图 9.22:cache.service.ts 的 Istanbul 代码覆盖率报告

在前面的屏幕截图中,您可以看到行 51217-1821-22 没有被任何测试覆盖。I 图标表示 if 路径没有被采取。我们可以通过实现单元测试来增加我们的代码覆盖率,这些单元测试将测试 CacheService 中包含的函数。作为练习,读者应尝试至少用一个新的单元测试覆盖这些函数之一,并观察代码覆盖率报告的变化。

CI 中的代码覆盖率

理想情况下,您的 CI 服务器配置应在每次测试运行时生成和托管代码覆盖率报告。然后,您可以使用代码覆盖率作为另一个代码质量关卡,防止合并拉取请求,如果新代码降低了整体代码覆盖率百分比。这是一种加强 测试驱动开发TDD)思维的好方法。

您可以使用 Coveralls 等服务(位于 coveralls.io/)来实现代码覆盖率检查,这些服务可以直接在 GitHub 拉取请求中嵌入您的代码覆盖率级别。

让我们为 LemonMart 配置 Coveralls:

lemon-mart 仓库中,本节 config.yml 文件命名为 .circleci/config.ch9.yml

  1. 在您的 CircleCI 账户设置中,在安全部分确保您允许执行未经认证/未签名的 orb。

  2. coveralls.io/ 注册您的 GitHub 项目。

  3. 复制仓库令牌并将其存储为 CircleCI 中的环境变量,名称为 COVERALLS_REPO_TOKEN

  4. 在进行任何代码更改之前,请创建一个新的分支。

  5. 更新 karma.conf.js 以使其在 coverage 文件夹下存储代码覆盖率结果:

    **karma.conf.js**
    ...
        coverageIstanbulReporter: {
          dir: require('path').join(__dirname, **'coverage'**),
          reports: ['html', 'lcovonly'],
          fixWebpackSourcePaths: true,
        },
    ... 
    
  6. 使用 Coveralls orb 更新 .circleci/config.yml 文件,如下所示:

    **.circleci/config.yml**
    version: 2.1
    orbs:
      **coveralls: coveralls/coveralls@1.0.4** 
    
  7. 更新 build 作业以存储代码覆盖率结果并将其上传到 Coveralls:

    **.circleci/config.yml**
    jobs:
      build:
        ...
          - run: npm test -- --watch=false --code-coverage
          - run: npm run e2e
          - store_test_results:
              path: ./test_results
          **- store_artifacts:**
     **path: ./coverage**
     **- coveralls/upload**
          - run:
              name: Tar & Gzip compiled app
              command: tar zcf dist.tar.gz dist/lemon-mart
          - store_artifacts:
              path: dist.tar.gz 
    

    注意,orb 会自动为您配置 Coveralls 账户,因此 coveralls/upload 命令可以上传您的代码覆盖率结果。

  8. 将您的更改提交到分支并发布。

  9. 使用分支在 GitHub 上创建拉取请求。

  10. 在拉取请求上,验证您是否可以看到 Coveralls 正在报告您的项目代码覆盖率,如图所示:图片

    图 9.23:Coveralls 报告代码覆盖率

  11. 将拉取请求合并到您的 master 分支。

恭喜!现在,你可以修改你的分支保护规则,要求在合并到主分支之前,代码覆盖率必须高于一定百分比。

LemonMart 项目在 github.com/duluca/lemon-mart 中实现了一个功能齐全的 config.yml 文件。此文件还在 CircleCI 中实现了 Cypress.io,与 Angular 的 e2e 工具相比,这是一个更加健壮的解决方案。Cypress orb 可以记录测试结果,并允许你在 CircleCI 管道中查看它们。

利用本章所学,你可以将 LocalCast Weather 的 deploy 脚本集成到 LemonMart 中,并实现门控部署工作流程。

摘要

在本章中,你学习了 DevOps 和 Docker。你将你的 Web 应用程序容器化,使用 CLI 工具将容器部署到 Google Cloud Run,并学习了如何实现门控 CI 工作流程。你利用高级 CI 技术构建了一个基于多阶段 Dockerfile 的 CI 环境。你还熟悉了 orbs、工作流程和代码覆盖率工具。

我们利用 CircleCI 作为基于云的 CI 服务,并强调了你可以将构建结果部署到所有主要的云托管提供商。你已经看到了如何实现 CD。我们介绍了通过 CircleCI 到 Vercel Now 和 Google Cloud Run 的示例部署,让你能够实现自动部署。

通过一个健壮的 CI/CD 管道,你可以与客户和团队成员分享你应用的每个迭代,并快速将错误修复或新功能交付给最终用户。

练习

  1. 将 CircleCI 和 Coveralls 徽章添加到你的代码仓库中的 README.md 文件。

  2. 为端到端测试实现 Cypress,并在你的 CircleCI 管道中使用 Cypress orb 运行它。

  3. 实现 Lemon Mart 应用程序的 Vercel Now 部署和条件工作流程。你可以在 lemon-mart 仓库中找到生成的 config.yml 文件,命名为 .circleci/config.ch9.yml

进一步阅读

问题

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

  1. 解释 Docker 镜像和 Docker 容器之间的区别。

  2. CD 管道的目的是什么?

  3. CD 的好处是什么?

  4. 我们如何覆盖配置差距?

  5. CircleCI orb 的功能是什么?

  6. 使用多阶段Dockerfile的好处是什么?

  7. 代码覆盖率报告如何帮助维护你应用程序的质量?

第十章:RESTful API 和全栈实现

第一章Angular 简介及其概念 中,我向你介绍了网络应用程序存在的更广泛的架构。在全栈架构中做出的选择可能会深刻影响你网络应用程序的成功。你绝对不能忽视你交互的 API 是如何设计的。在本章中,我们将介绍如何使用 Node、Express 和 Mongo 为你的前端实现后端。结合 Angular,这个软件堆栈被称为 MEAN 堆栈。

我对 MEAN 堆栈的看法是“最小化 MEAN”,它优先考虑易用性、幸福感和效率,这是优秀 开发者体验DX)的主要成分。为了保持主题一致,我们将实现 LemonMart 服务器。这个服务器将完善来自 第八章设计认证和授权 的 JWT 认证。此外,服务器还将支持我在 第十一章食谱 – 可重用性、路由和缓存第十二章食谱 – 主/详细、数据表和 NgRx 中将要介绍的功能。

本章涵盖了大量的内容。它旨在作为 GitHub 仓库 (github.com/duluca/lemon-mart-server) 的路线图。我涵盖了实现架构、设计和主要组件。我强调了一些重要的代码片段来解释解决方案是如何组合在一起的。然而,与前面的章节不同,你不能仅仅依靠文本中提供的代码示例来完成你的实现。对于本书的目的,理解我们为什么要实现各种功能比掌握实现细节更为重要。因此,对于这一章,我建议你阅读并理解服务器代码,而不是试图自己重新创建它。

你需要在章节的末尾采取行动,在你的 Angular 应用中实现一个自定义认证提供者,以对 lemon-mart-server 进行认证,并利用 Postman 生成测试数据,这在后面的章节中将会很有用。

我们首先介绍全栈架构,涵盖 lemon-mart-server 的单仓库设计以及如何使用 Docker Compose 运行一个包含 Web 应用、服务器和数据库的三层应用程序。然后,我们介绍 RESTful API 设计和文档,利用 OpenAPI 规范通过 Swagger.io 和使用 Express.js 的实现。然后,我们介绍使用我的 DocumentTS 库实现 MongoDB 的 对象文档映射器ODM),用于存储带有登录凭证的用户。我们实现了一个基于令牌的认证功能,并使用它来保护我们的 API。最后,我们利用 Postman 使用我们开发的 API 在数据库中生成测试数据。

在本章中,你将学习以下内容:

  • 全栈架构

  • Docker Compose

  • RESTful API

  • MongoDB ODM with DocumentTS

  • 实现 JWT 认证

  • 使用 Postman 生成用户

书籍示例代码的最新版本可以在以下链接的 GitHub 仓库中找到。该仓库包含代码的最终和完成状态。本章需要 Docker 和 Postman 应用程序。

在您的开发环境中运行lemon-mart-server并使其与lemon-mart通信是至关重要的。请参阅此处或 GitHub 上的README中的说明以启动您的服务器。

对于本章的情况:

  1. 使用--recurse-submodules选项克隆lemon-mart-server仓库:git clone --recurse-submodules github.com/duluca/lemon-mart-server

  2. 在 VS Code 终端中,执行cd web-app; git checkout master以确保来自github.com/duluca/lemon-mart的子模块位于 master 分支。

    在后面的Git 子模块部分,您可以配置web-app文件夹以从您的 lemon-mart 服务器拉取。

  3. 在根目录下执行npm install以安装依赖项。

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

  4. 在根目录下执行npm run init:env以配置.env文件中的环境变量。

    此命令将在根目录和server文件夹下创建两个.env文件,用于包含您的私有配置信息。初始文件基于example.env文件生成。您可以在以后修改这些文件并设置自己的安全密钥。

  5. 在根目录下执行npm run build以构建服务器和 Web 应用。

    注意,该 Web 应用是使用名为--configuration=lemon-mart-server的新配置构建的,它使用src/environments/environment.lemon-mart-server.ts

  6. 执行docker-compose up --build以运行服务器、Web 应用和 MongoDB 数据库的容器化版本。

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

  7. 导航到http://localhost:8080以查看 Web 应用。

    登录时,单击填写按钮以使用默认的演示凭据填充电子邮件和密码字段。

  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进行调试。

对于本章中基于lemon-mart的示例:

  1. 克隆仓库: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. 要为此章节构建一个生产就绪的 Angular 应用程序,请执行以下命令:

    npx ng build ch10 --prod 
    

    注意,存储库根目录下的dist/ch10文件夹将包含编译结果。

注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。此外,在 GitHub 上,你可能会找到更正、修复以支持库的新版本,或者为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果你发现错误或有疑问,请为所有读者创建一个 GitHub 问题或提交一个 pull request。

你可以在附录 C“保持 Angular 和工具常青”中了解更多关于更新 Angular 的信息。你可以在网上从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdfexpertlysimple.io/stay-evergreen找到这个附录。

当你的 LemonMart 服务器运行起来后,我们就可以探索 MEAN 栈的架构了。到本节结束时,你应该有自己的 LemonMart 版本与服务器通信。

全栈架构

全栈指的是使应用程序工作的整个软件栈,从数据库到服务器、API 以及利用它们的 Web 和/或移动应用程序。传说中的全栈开发者无所不知,可以轻松地在职业的各个垂直领域操作。在所有与软件相关的事物中专业化,并被认为是每个给定主题的专家,几乎是不可能的。然而,要被认为是某个主题的专家,你也必须对相关主题有深入的了解。在学习新主题时,保持你的工具和语言一致非常有帮助,这样你就可以在没有额外噪音的情况下吸收新信息。因此,我选择用 Java 或 C#使用 ASP.NET 介绍你使用 MEAN 栈而不是 Spring Boot。通过坚持使用熟悉的工具和语言,如 TypeScript、VS Code、npm、GitHub、Jasmine、Docker 和 CircleCI,你可以更好地理解全栈实现是如何结合在一起的,并因此成为一个更好的 Web 开发者。

最小化 MEAN

为你的项目选择正确的-Stack™ 是困难的。首先,你的技术架构应该足够满足业务需求。例如,如果你试图使用 Node.js 实现人工智能项目,你很可能会选择错误的堆栈。我们的重点是交付网络应用,但除此之外,我们还有其他参数要考虑,包括以下内容:

  • 易用性

  • 幸福

  • 效率

如果你的开发团队将长时间在你的应用程序上工作,那么考虑兼容性以外的因素非常重要。你的堆栈、工具选择和编码风格如果使代码库易于使用,让你的开发者感到快乐,或者让他们觉得自己是项目的有效贡献者,那么这些都会产生重大影响。

一个配置良好的堆栈对于优秀的开发体验至关重要。这可能是干燥的煎饼堆和带有适量黄油和糖浆的精美小堆之间的区别。

通过引入过多的库和依赖项,你可以减慢你的进度,使你的代码难以维护,并发现自己陷入引入更多库以解决其他库问题的反馈循环。赢得这场游戏的唯一方法就是简单地不玩。

如果你花时间学习如何使用几个基本的库,你可以成为一个更有效的开发者。本质上,你可以用更少的资源做更多的事情。我的建议是:

  • 在编写代码之前先思考,并应用 80-20 规则。

  • 等待库和工具成熟,跳过测试版

  • 通过减少对新包和工具的贪婪,掌握基础知识来实现快速

    在 YouTube 上观看我 2017 年 Ng 会议的演讲,标题为 用更少的 JavaScript 做更多的事情,链接为 www.youtube.com/watch?v=Sd1aM8181kc

这种简约思维是 minimal MEAN 的设计哲学。你可以在 GitHub 上查看参考实现 github.com/duluca/minimal-mean。参考以下图表了解整体架构:

图 10.1:Minimal MEAN 软件堆栈和工具

让我们逐个介绍架构的组件。

Angular

Angular 是表现层。Angular 是一个能够和可靠的开发平台。它被广泛理解,拥有一个伟大的社区。在考虑其他选项之前,你绝对应该花时间掌握 Angular 的基础知识。

如 Angular Material、Angular Evergreen 和 angular-unit-test-helper 这样的库可以帮助你以最小的努力提供最佳和外观出色的解决方案。

你可以使用最小的 Docker 容器 duluca/minimal-nginx-web-serverduluca/minimal-node-web-server 来容器化你的 Angular(或任何其他网络应用)。

Express

Express.js 将成为我们的 API 层。Express 是一个快速、无偏见且极简的 Node.js 网络框架。Express 拥有庞大的插件生态系统,几乎可以满足所有需求。在 Minimal MEAN 中,我们只利用了两个包:

  • cors: 配置跨源资源共享设置

  • morgan: 用于记录 HTTP 请求

此外,我们使用 express 解析器解析传入的 HTTP 请求中的req.body,并使用express.static函数来提供public文件夹的内容。

你可以在expressjs.com/了解更多关于 Express.js 的信息。

Node

Express.js 运行在 Node.js 上。我们将使用 Node 实现业务层。Node 是一个轻量级且高效的 JavaScript 运行时,使用事件驱动、非阻塞 I/O 模型,使其适用于高性能和实时应用。Node 可以在任何地方运行,从冰箱到智能手表。你可以通过使用 TypeScript 开发应用程序来提高 Node 应用程序的可靠性。

有关非阻塞 I/O 的更深入解释,请参阅 Frank Rosner 的博客文章blog.codecentric.de/en/2019/04/explain-non-blocking-i-o-like-im-five/

在本章的后面部分,你将学习如何使用 TypeScript 配置 Node 项目。

Mongo

MongoDB 代表持久层。MongoDB 是一个面向文档的数据库,具有动态的类似 JSON 的架构。使用基于 JSON 的数据库的主要好处是你不需要将数据从一种格式转换为另一种格式。你可以仅使用 JSON 检索、显示、编辑和更新数据。

此外,Node 的 MongoDB 原生驱动程序成熟、性能良好且功能强大。我开发了一个名为document-ts的库,旨在通过引入易于编码的丰富文档对象来简化与 MongoDB 的交互。DocumentTS 是一个非常薄的基于 TypeScript 的 MongoDB 辅助工具,具有可选的丰富 ODM 便利功能。

你可以在www.mongodb.com/了解更多关于 MongoDB 的信息,以及 DocumentTS 库的github.com/duluca/document-ts

工具

支持你开发的技术工具与你的软件栈选择一样重要。Minimal MEAN 利用以下工具:

  • VS Code: 优秀的扩展支持,轻量级、快速且跨平台

  • TypeScript: 快速且易于使用的转译器,具有使用 tslint 的出色 lint 支持

  • Npm: 多平台脚本和依赖管理,拥有丰富的包生态系统

  • GitHub: 灵活、免费且支持良好的 Git 托管服务。GitHub 流程与 CI 服务器协同,实现门控代码检查

  • Docker: 一种轻量级虚拟化技术,封装了你的环境配置和设置

  • 持续集成(CI): 确保代码交付质量的关键

  • Jasmine:包含电池的单元测试框架,与 nyc/istanbul.js 一起工作以提供代码覆盖率指标

注意,我们使用的工具和选择的语言与用于 Angular 开发的工具和语言相同。这使得开发者能够在前端和后端开发之间进行最小化的上下文切换。

现在我们已经涵盖了交付最小 MEAN 堆栈应用的所有主要组件和工具,让我们首先创建一个 Git 仓库,用于存放我们的前端和后端代码。

配置 monorepo

您可以通过创建包含您前端和后端代码的 monorepo 来优化您的开发体验。monorepo 允许开发者能够在同一 IDE 窗口内跳转项目。开发者可以更容易地在项目之间引用代码,例如在前端和后端之间共享 TypeScript 接口,从而确保数据对象每次都保持一致。CI 服务器可以一次性构建所有项目,以确保全栈应用的所有组件都处于正常工作状态。

注意,monorepo 与 VS Code 中的多根工作区不同,在多根工作区中,您可以将多个项目添加到同一 IDE 窗口中。monorepo 在源代码控制级别组合项目。有关多根工作区的更多信息,请参阅code.visualstudio.com/docs/editor/multi-root-workspaces

让我们快速浏览一下代码库。

Monorepo 结构

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-server版本上执行此操作。否则,您将需要创建一个新的项目并执行npm init -y以开始操作。

  1. 观察以下package.json脚本,这些脚本有助于初始化、更新和清理 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. webAppGitUrl更新为您自己的项目的 URL。

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

  4. 最后,执行 webapp:init 命令以在 web-app 文件夹中初始化你的项目:

    $ npm run webapp:init 
    

接下来,执行 modules:update 命令以更新子模块中的代码。在另一个环境中克隆仓库后,要拉取子模块,请执行 npm modules:init。如果你需要重置环境并重新启动,请执行 webapp:clean 以清理 Git 缓存并删除文件夹。

注意,你的仓库中可以有多个子模块。modules:update 命令将更新所有子模块。

你的 Web 应用程序代码现在位于名为 web-app 的文件夹中。此外,你应该能够在 VS Code 的 源代码管理 面板中看到这两个项目,如图所示:

图 10.2:VS Code 源代码管理提供者

使用 VS Code 的源代码管理,你可以独立地对任一仓库执行 Git 操作。

如果你的子模块出现混乱,只需在子模块目录中执行 cd 并运行 git pull,然后执行 git checkout master 以恢复主分支。使用此技术,你可以从项目中的任何分支检出并提交 PR。

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

使用 TypeScript 配置 Node 项目

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

以下步骤仅适用于你正在创建新的服务器项目。我建议你使用从 GitHub 克隆的 lemon-mart-server 项目中提供的现有一个。

  1. 创建子文件夹 server

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

    $ cd server 
    
  3. 初始化 npm 以在 server 文件夹中设置 package.json

    $ 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 Code 以获得优化的 TypeScript 开发体验,类似于我们在 第二章设置开发环境 中使用 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 脚本包:cross-conf-env (www.npmjs.com/package/cross-conf-env)、npm-run-all (www.npmjs.com/package/npm-run-all)、dev-norms (www.npmjs.com/package/dev-norms) 和 rimraf (www.npmjs.com/package/rimraf)

  • Npm 脚本用于样式、代码检查、构建和测试:

    • stylelint:检查代码样式和代码检查错误是否符合规范。它们主要用于 CI 服务器使用。

    • style:fixlint:fix: 将代码样式和检查规则应用到代码中。并非所有检查错误都可以自动修复。您需要手动解决每个错误。

    • build: 将代码转换为 dist 文件夹。

    • start: 在 Node.js 中运行转换后的代码。

    prepublishOnlyprepare 脚本仅在您开发 npm 包时相关。在这种情况下,您还应该实现一个 .npmignore 文件,该文件排除了 srctests 文件夹。

  • ImportSort: 维护 import 语句的顺序:

    • 将设置添加到 package.json

    • 支持的 npm 包已安装:import-sort、import-sort-cli、import-sort-parser-typescript 和 import-sort-style-module

  • 使用 TypeScript 和 tslint:

    • tsconfig.json: 常见的 TypeScript 设置

    • tsconfig.src.json: 适用于 src 文件夹下源代码的特定设置

    • tslint.json: 检查规则

  • 自动格式化我们代码样式的 Prettier 插件:

    • .prettierrc: Prettier 设置

    • .prettierignore: 忽略的文件

  • Jasmine 和 nyc 用于单元测试和代码覆盖率:

    • jasmine.json: 测试设置。

    • .nycrc: 代码覆盖率设置。

    • tests 文件夹:包含 spec.ts 文件,其中包含您的测试和 tsconfig.spec.json,它配置了更宽松的设置,使快速编写测试变得更容易。

    • package.json 中:创建测试脚本以使用 build:test 构建测试并使用 npm test 执行它们。test:ci 命令旨在用于 CI 服务器,而 test:nyc 提供代码覆盖率报告。

  • example.env:用于记录在您的私有 .env 文件中存在的必需环境变量

    • .env 已添加到 .gitignore
  • PR 模板:一个请求开发者提供额外信息的拉取请求模板

  • VS Code 扩展、设置和调试配置分别在三份文件中:

    • .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 文件,这样它们就不会被提交到我们的仓库中。

现在我们有了基本的单仓库,我们可以配置我们的持续集成服务器。

CircleCI 配置

使用 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作业。有关如何在 GitHub 上的config.yml文件中实现build_webapp作业的说明,请参阅第九章使用 Docker 的 DevOps,该作业与您在第九章中实现的类似,但包括一些细微的差异,以处理与子模块和文件夹结构变化的工作。构建服务器的管道与 Web 应用程序的管道不太相似,如下所示:

**.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 错误,运行测试,并检查代码覆盖率水平。

测试命令隐式构建服务器代码,该代码存储在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,我们能够精确地描述这里显示的架构。您可以在docs.docker.com/compose/了解更多关于 Compose 的信息。

接下来,让我们为 Lemon Mart 实现一个更高效的 Web 服务器。

使用 Nginx 作为 Web 服务器

我们的 Web 应用程序已经在第九章使用 Docker 的 DevOps中进行了容器化。对于这个练习,我们将使用基于 nginx 的容器。

web-app的根目录下添加一个名为nginx.Dockerfile的新 Dockerfile。这个镜像将比我们已有的基于 Node 的镜像小,因为我们使用 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 的服务器更详细实现的示例:

如果你需要,可以参考第九章使用 Docker 的 DevOps中的使用 Docker 容器化应用程序部分,作为 Docker 的复习。

  1. 让我们先定义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文件夹添加到我们的服务器中,然后使用 nodes 运行它。

    你可以通过查看github.com/duluca/minimal-node-web-server上类似配置的minimal-node-web-server仓库中的README.md来了解更多关于我们服务器容器配置的信息。

    现在,设置跨环境的npm 脚本用于 Docker,它适用于 Windows 10 和 macOS 上的我们的服务器。

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

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

    $ npx mrm npm-docker 
    
  4. 使用配置参数配置你的package.json

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

在构建 Docker 容器之前,请确保构建你的应用程序。

使用 DotEnv 配置环境变量

DotEnv 文件广泛支持,方便地将密钥存储在未提交到代码仓库的.env文件中。Docker 和 Compose 原生支持.env文件。

首先,让我们了解单一代码库核心的环境变量:

  1. 参考项目根目录下的example.env文件:

    **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. 通过执行以下命令确保init-dev-env已安装在项目根目录中:

    $ npm i -D init-dev-env 
    
  3. npm run init:env脚本使用init-dev-env包根据example.env文件生成.env文件:

    lemon-mart-server中,服务器的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 的连接字符串、我们将用于加密 JWT 的密钥以及一个种子用户,以便我们可以登录到应用程序。通常,你不会为你的种子用户配置密码或用户 ID。这些只在这里以支持可重复的演示代码。

现在,我们准备好定义 Compose 的 YAML 文件。

定义 Docker-Compose YAML

让我们在单一代码库的根目录中定义一个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 命令。你也可以使用 -d 选项以分离模式运行你的基础设施。你可以使用 down 命令停止它,并通过 rm 命令删除它创建的容器。

在你能够运行你的基础设施之前,你需要构建你的应用程序,这将在下一节中介绍。

编排 Compose 启动

运行 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 上组合

你可以在 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。在等待服务器通过dockerize -wait变得可用后,我们运行curl。同样,我们通过使用我们的演示用户登录来测试我们的 API 服务器和数据库的集成。

恭喜!现在,你对我们的全栈架构在高级别是如何拼接的有了相当好的理解。在本章的后半部分,我们将介绍 API 是如何实现的,它是如何与数据库集成的,以及我们将看到 JWT 身份验证是如何与 API 和数据库协同工作的。

让我们继续深入探讨 API 设计。

RESTful API

在全栈开发中,尽早确定 API 设计非常重要。API 设计本身与你的数据合约的外观密切相关。你可以创建 RESTful 端点或使用下一代 GraphQL 技术。在设计你的 API 时,前端和后端开发者应紧密合作以实现共同的设计目标。以下是一些高级目标:

  • 最小化客户端和服务器之间传输的数据

  • 遵循已建立的设计模式(换句话说,数据分页)

  • 设计以减少客户端中的业务逻辑

  • 扁平化数据结构

  • 不要暴露数据库键或关系

  • 从一开始就对端点进行版本控制

  • 围绕主要数据实体进行设计

你应该旨在在你的 RESTful API 中实现业务逻辑。理想情况下,你的前端不应该包含比展示逻辑更多的内容。任何由前端实现的 if 语句也应该在你的后端得到验证。

如同在 第一章Angular 简介及其概念 中讨论的那样,在后台和前端实现无状态设计至关重要。每个请求都应使用非阻塞 I/O 方法,并且不应依赖于任何现有会话。这是使用云托管提供商无限扩展你的 Web 应用程序的关键。

无论你在实施项目时,都应限制,如果可能的话,消除实验。这在全栈项目中尤其如此。一旦你的应用程序上线,API 设计中的失误的下游影响可能是深远的,并且无法纠正。

接下来,让我们看看如何围绕主要数据实体设计 API。在这种情况下,我们将回顾围绕用户(包括身份验证)的 API 实现。首先,我们将探索如何使用 Swagger 定义一个端点,这样我们就可以具体地向团队成员传达我们设计的意图。

记住,本章只涵盖了概念上重要的代码片段。虽然你可以选择从头开始实现这段代码,但理解其工作原理并不需要这样做。如果你选择从头开始实现,请参考 github.com/duluca/lemon-mart-server 上的完整源代码,以跟进并填补你实现中的空白。

在以后,Swagger 将成为文档工具,反映我们 API 的能力。

使用 Swagger 进行 API 设计

Swagger 将允许你设计和记录你的 Web API。对于团队来说,它可以作为前端和后端开发者之间出色的沟通工具,从而减少很多摩擦。此外,尽早定义你的 API 表面,可以让实现开始而不必担心后期集成挑战。

随着我们继续前进,我们将实现一个用户 API,以展示 Swagger 的工作原理。

我强烈推荐安装 Swagger Viewer VS Code 扩展,它允许我们在不运行任何额外工具的情况下预览 YAML 文件。

让我们从探索单一代码库根目录下的 swagger.yaml 文件开始:

  1. 在 VS Code 中打开 swagger.yaml

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

  3. Ctrl + Shift + P,或者点击 ++P,以打开命令面板并运行 预览 Swagger

  4. 看看预览,如图所示:

    图 10.4:Swagger.yaml 预览

使用 Swagger UI 视图,你将能够尝试命令并在你的服务器环境中执行它们。

定义 Swagger YAML 文件

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

有关 Swagger 文件定义的更多信息,请参阅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. components下,我们定义常见的securitySchemes和响应,这些定义了我们打算实施的认证方案以及我们的错误消息响应的形状:

    **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. paths下,我们开始定义 REST 端点,例如/login路径的post端点:

    **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,如第二步中定义的那样。

  6. paths下,我们继续添加以下路径:

    **swagger.yaml**
    ...
    **paths:**
      /v1/auth/me:
      get: ...
     /v2/users:
        get: ...
        post: ...
     /v2/users/{id}:
        get: ...
        put: ... 
    

OpenAPI 规范功能强大,允许您定义复杂的用户如何与您的 API 交互的要求。在swagger.io/docs/specification上的规范文档在开发您自己的 API 定义时是一个无价资源。

预览 Swagger 文件

您可以免费在swaggerhub.com验证您的 Swagger 文件。在您注册免费账户后,创建一个新的项目并定义您的 YAML 文件。SwaggerHub 将突出显示您所犯的错误。它还会为您提供网页预览,这与您在 Swagger Preview VS Code 扩展中获得的预览相同。

参考以下截图,查看有效的 Swagger YAML 定义在 SwaggerHub 上的样子:

图片

图 10.5:SwaggerHub 上的有效 Swagger YAML 定义

我们的目的是将这种交互式文档与我们的 Express.js API 集成。

现在,让我们看看您如何实现这样的 API。

使用 Express.js 实现 API

在我们开始实现我们的 API 之前,让我们分节回顾我们后端的目标文件结构,以便我们了解服务器是如何启动的,API 端点的路由是如何配置的,公共资源是如何提供的,以及服务是如何配置的。Minimal MEAN 故意坚持基本原理,这样您就可以更多地了解底层技术。虽然我已经使用 Minimal MEAN 实现了生产系统,但您可能不会像我一样享受这种骨架式开发体验。在这种情况下,您可以考虑 Nest.js,这是一个用于实现全栈 Node.js 应用程序的热门框架。Nest.js 具有丰富的功能集,其架构和编码风格与 Angular 非常相似。我建议在您掌握了 MEAN 栈的基础之后使用此类库。

向 Kamil Mysliwiec 和 Mark Pieszak 表示祝贺,他们创建了一个出色的工具,并在 Nest.js 周围营造了一个充满活力的社区。您可以在 nestjs.com/ 上了解更多关于 Nest.js 的信息,并在 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 

在前面的代码中,请注意配置 Express 使用 use() 方法非常简单。首先,我们配置 cors,然后是 express 解析器和 logger

接下来,使用express.static函数,我们在根路由/上提供public文件夹,这样我们就可以显示有关我们服务器的一些有用信息,如下所示:

图 10.7:LemonMart 服务器登录页面

我们将在下一节中介绍如何配置上面提到的/api-docs端点。

最后,我们配置路由器,它在api.ts中定义。

路由和版本控制

Api.ts配置了 Express 路由器。请参考以下实现:

**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 接受 userData,其形状为 IUser,当它完成用户创建后,返回一个 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 创建活页文档。

配置 Swagger 与 Express

配置 Swagger 与 Express 是一个手动过程。强迫自己手动记录端点有一个很好的副作用。通过放慢速度,您将有机会从消费者和实现者的角度考虑您的实现。这种视角将帮助您在开发过程中解决端点可能存在的潜在问题,从而避免昂贵的返工。

将 Swagger 集成到您的服务器中的主要好处是,您将获得本章前面提到的相同的交互式 Swagger UI,因此您的测试人员和开发者可以直接从网络界面发现或测试您的 API。

我们将使用两个辅助库来帮助我们集成 Swagger 到我们的服务器中:

  • swagger-jsdoc:它允许您通过在 JSDoc 注释块中使用 @swagger 标识符在相关代码上实现 OpenAPI 规范,生成 swagger.json 文件作为输出。

  • swagger-ui-express:它消费 swagger.json 文件以显示交互式 Swagger UI 网络界面。

让我们来看看 Swagger 如何配置与 Express.js 一起工作:

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

    $ npm i swagger-jsdoc swagger-ui-express
    $ npm i -D @types/swagger-jsdoc @types/swagger-ui-express 
    
  2. 让我们来看看 docs-config.ts 文件,它配置了基本的 OpenAPI 定义:

    **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 消费者使用网络界面测试 API,而无需额外的工具。请注意,apis 属性通知 swaggerJsdoc 在构建 swagger.json 文件时应解析的代码文件。此过程在服务器启动时运行,这就是为什么我们引用了转译的 .js 文件而不是 .ts 文件。

  3. app.ts 中启动 Swagger 配置:

    **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 
    

规范包含 swagger.json 文件的内容,然后传递给 swaggerUi。然后,使用服务器中间件,我们可以配置 swaggerUi/api-docs 上托管网络界面。

您已经拥有了从本章开始就需要用于完成应用程序实现的 OpenAPI 定义。有关更多信息,请参考完整的源代码 github.com/duluca/lemon-mart-server

恭喜!现在您已经很好地理解了我们的 Express 服务器是如何工作的。接下来,让我们看看如何连接到 MongoDB。

MongoDB ODM 与 DocumentTS

DocumentTS 作为 ODM,通过实现模型层来启用与数据库对象的丰富和可定制的交互。ODM 是文档数据库中与关系数据库中的 对象关系映射器ORM)相对应的。想想 Hibernate 或 Entity Framework。如果您不熟悉这些概念,我建议在继续之前进行进一步的研究。

在其核心,DocumentTS 利用 MongoDB 的 Node.js 驱动程序。该驱动程序由 MongoDB 的制作者实现。它保证提供最佳性能并与新 MongoDB 版本保持功能一致性,而第三方库通常在支持新功能方面落后。使用 database.getDbInstance 方法,您可以直接访问原生驱动程序。否则,您将通过您实现的模型访问 Mongo。请参考以下图表以获取概述:

图 10.8:DocumentTS 概述

您可以在 mongodb.github.io/node-mongodb-native/ 上了解更多关于 MongoDB Node.js 驱动程序的信息。

关于 DocumentTS

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 是 DocumentTS 中迄今为止最好的功能,允许您过滤、排序和分页大量数据。此功能旨在与数据表一起使用,因此您指定可搜索属性,关闭填充,并使用调试功能来微调您的查询:

    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 致力于成为可靠、可选且易于使用的工具。DocumentTS 直接将开发者暴露于原生 Node.js 驱动程序,因此您学习如何与 MongoDB 交互,而不是使用某个库。开发者可以选择利用库的便利功能,包括以下内容:

  • 通过简单的接口定义您自己的模型。

  • 选择您想要自动填充的字段,例如子对象或相关对象。

  • 每次请求时序列化计算字段。

  • 保护某些字段(如密码)免于序列化,以防止它们意外地通过网络发送。

由于 DocumentTS 是可选的,因此开发人员可以按自己的时间表过渡到新功能。如果性能成为关注点,您可以轻松切换到原生 MongoDB 调用以获得最佳性能。使用 DocumentTS,您将花费更多时间阅读 MongoDB 文档,而不是 DocumentTS 文档。

Mongoose 是一个用于与 MongoDB 交互的流行库。然而,它是一个围绕 MongoDB 的包装器,需要全面采用。此外,该库抽象了原生驱动程序,因此它对生态系统中的更改和更新非常敏感。您可以在mongoosejs.com/上了解更多关于 Mongoose 的信息。

使用以下命令安装 MongoDB 依赖项和 TypeScript 类型信息:

$ npm i mongodb document-ts
$ npm i -D @types/mongodb 

接下来,让我们看看如何连接到数据库。

连接到数据库

在编写完全异步的 Web 应用程序时,确保数据库连接存在可能是一个挑战。connect()函数使得连接到 MongoDB 实例变得简单,并且可以从多个同时启动的线程中安全地调用。

让我们先配置一下环境变量:

  1. 记住MONGO_URI连接字符串位于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. 让我们看看document-ts如何与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 的模型

您可以实施一个类似于 LemonMart 中的IUser接口。然而,这个接口将扩展 DocumentTS 中定义的IDocument

  1. 这是IUser接口:

    **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. 现在,这里是一个扩展Document<T>并实现 Swagger 文档的User类:

    **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 }对象来指示数据库即使在提供部分信息的情况下也要更新记录。

    记得提供完整的 Swagger 定义。

  3. 最后,让我们回顾一下实现CollectionFactory<T>UserCollectionFactory

    **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 的聚合功能可以执行更复杂和高效的查询。你可以在docs.mongodb.com/manual/aggregation上了解更多关于聚合的信息。

在文件底部,我们实例化一个UserCollection并将其导出,以便在应用程序的任何地方引用:

**server/src/models/user.ts**
**export** let UserCollection = new UserCollectionFactory(User) 

注意,UserCollectionFactory没有被导出,因为它只在user.ts文件中需要。

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

实现 JWT 身份验证

在第八章“设计身份验证和授权”中,我们讨论了如何实现基于 JWT 的身份验证机制。在 LemonMart 中,你实现了一个基础认证服务,它可以扩展为自定义认证服务。

我们将利用三个包来实现我们的功能:

  • jsonwebtoken:用于创建和编码 JWT

  • bcryptjs:用于在将用户的密码保存到数据库之前对其进行散列和加盐,所以我们永远不会以明文形式存储用户的密码

  • uuid:生成一个全局唯一标识符,当需要将用户的密码重置为随机值时很有用

哈希函数是一个一致可重复的单向加密方法,这意味着每次提供相同的输入时都会得到相同的输出,即使你有访问散列值的能力,也无法轻易地找出它存储的信息。然而,我们可以通过散列用户的输入并将其与存储的密码散列值进行比较,来比较用户是否输入了正确的密码。

  1. 让我们看看 JWT 身份验证相关的依赖和 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-serverauthServicelogin 方法的实现:

**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 字符来进一步改进此实现。考虑使用 express-validatorexpress-sanitizer 等库。

login 方法利用 user.comparePassword 函数来确认提供的密码的正确性。然后 createJwt 函数创建要返回给客户端的 accessToken

身份验证中间件

authenticate 函数是一个中间件,我们可以在我们的 API 实现中使用它来确保只有经过身份验证且具有适当权限的用户才能访问端点。请记住,真正的安全性是在你的后端实现中实现的,而这个 authenticate 函数是你的守门人。

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 端点可以方便地访问当前用户的信息。这将通过 me API 进行演示。如果成功,中间件调用 next() 函数将控制权交还给 Express。如果失败,则无法调用 API。

注意,authenticateHelper 返回有用的错误消息,所以如果用户尝试执行他们无权执行的操作,他们不会感到困惑。

考虑 me API 的实现,它通过 /v1/auth/me 将当前登录用户返回给客户端,如下所示:

**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 中实现自定义身份验证提供者,如第八章 设计身份验证和授权 中所述:

你必须在你的 Angular 应用程序中实现这个自定义身份验证提供者。

本节代码示例位于 lemon-mart 仓库的 projects/ch10 文件夹中。请注意,该示例也位于 web-app 文件夹下。

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

  2. 还选择 authModeAuthMode.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. 按照如下所示实现自定义身份验证提供者:

    **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 上,否则你将在开放的互联网上发送用户凭据。这对于在公共 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 应用程序以确保一切正常工作。

接下来,让我们实现获取用户端点,以便我们的身份验证提供者可以获取当前用户。

通过 ID 获取用户

让我们在 userRouter 中实现通过 ID 获取用户的 GET API 端点,在 /v2/users/{id}

**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 方法来创建新用户。使用这个 POST 端点和 Postman API 客户端,我们可以快速为测试目的生成用户记录。

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

让我们安装和配置 Postman。

前往www.getpostman.com下载并安装 Postman。

配置 Postman 进行认证调用

首先,我们需要配置 Postman,以便我们可以访问我们的认证端点:

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

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

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

  3. 在头部设置键值对,Content-Type: application/json

  4. 在正文部分,提供我们定义在顶级.env文件中的演示用户登录的电子邮件和密码:

    http://localhost:3000/v1/auth/login - Body
    {
        "email": "duluca@gmail.com",
        "password": "l0l1pop!!"
    } 
    
  5. 点击发送以登录。

  6. 复制accessToken,如下所示图 B14094_10_09

    图 10.9:设置 Postman

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

  8. 添加一个名为 LemonMart Server 的新环境。

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

  10. accessToken值粘贴为当前值(不带括号)。

  11. 点击添加/更新

从现在起,当你添加 Postman 中的新请求时,你必须提供令牌变量作为授权头,如下所示:

图 B14094_10_10

图 10.10:在 Postman 中提供令牌

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

  1. 切换到授权选项卡。

  2. 选择Bearer Token作为类型。

  3. 将令牌变量作为{{token}}提供。

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

Postman 自动化

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

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

  2. 授权选项卡中设置token

  3. 正文选项卡中,提供一个模板化的 JSON 对象,如下所示:

    {
      "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. 实现一个 Postman Pre-request Script,它在发送请求之前执行任意逻辑。该脚本将定义一个人员数组,并在请求执行时逐个设置当前环境变量:

    关于预请求脚本的更多信息,请查看 learning.postman.com/docs/postman/scripts/pre-request-scripts/

  5. 切换到 Pre-request 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. Tests 选项卡中实现一个 test 脚本:

    var people = pm.environment.get('people')
    if (people && people.length > 0) {
      postman.setNextRequest('Create Users')
    } else {
      postman.setNextRequest(null)
    } 
    
  7. 确保保存您的请求。

    在这里,我们定义一个 test 脚本,该脚本将一直执行,直到 people.length 达到零。在每次迭代中,我们调用 Create Users 请求。当没有剩下的人时,我们调用 null 来终止测试。

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

  8. 现在,使用屏幕左上角的 Runner 执行脚本:图片

    图 10.11:Postman UI 左上角的运行器按钮

  9. 在继续操作之前,请更新您的 login 令牌。

  10. 按照以下配置设置运行器:图片

    图 10.12:集合运行器配置

  11. 选择 LemonMart 集合。

    选择包含 token 变量的 LemonMart Server 环境。

    只选择 Create Users 请求。

    点击 Run LemonMart 来执行。

如果您的运行成功,您应该看到以下输出:

图片

图 10.13:集合运行器结果

如果您使用 Studio 3T 作为 MongoDB 探索器,您可以看到所有记录都已创建,或者您可以使用 Postman 检查它们,当我们实现 /v2/users 端点时。

注意,由于我们有一个唯一的电子邮件索引,您的下一次运行部分成功。对于已创建的记录的 POST 请求将返回 400 Bad Request

您可以在 studio3t.com/ 上了解更多关于 Studio 3T 的信息。

添加用户

我们已经在本章前面的 Services 部分介绍了如何创建 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 请求体中的 _id 属性,因为 URL 参数是信息的权威来源。此外,这还可以防止用户的 ID 被意外更改成不同的值。

然后,我们利用findOneAndUpdate方法定位并更新记录。我们使用 ID 查询记录。通过使用 MongoDB 的$set运算符来更新记录。

最后,我们从数据库中加载保存的记录并将其返回给客户端。

POST 和 PUT 方法应始终响应记录的更新状态。

对于我们最后的实现部分,让我们回顾一下可以支持分页数据表的 API 端点。

使用 DocumentTS 进行分页和过滤

到目前为止,DocumentTS 最有用的功能是findWithPagination,如关于 DocumentTS部分所述。让我们利用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 调用获取用户

第十二章食谱 - 主/详细,数据表和 NgRx中,我们将实现一个利用过滤、排序和数据限制功能的分页数据表。

恭喜!您现在掌握了代码在整个软件栈中如何工作的知识,从数据库到前端和后端。

摘要

在本章中,我们介绍了全栈架构。您学习了如何构建最小化的 MEAN 栈。您现在知道如何为全栈应用程序创建 monorepo,并使用 TypeScript 配置 Node.js 服务器。您将 Node.js 服务器容器化,并使用 Docker Compose 声明性地定义了您的基础设施。使用 Docker Compose 与 CircleCI,您在 CI 环境中验证了您的基础设施。

您使用 Swagger 和 OpenAPI 规范设计了 RESTful API,设置了 Express.js 应用程序,并配置了它,以便您可以将 Swagger 定义作为 API 的文档进行集成。您使用 DocumentTS ODM 配置 MongoDB,以便您可以轻松连接和查询文档。您定义了一个具有密码散列功能的用户模型。

然后您实现了基于 JWT 的认证服务。您实现了一个authenticate中间件来保护 API 端点并允许基于角色的访问。您学习了如何使用 Postman 与 RESTful API 交互。使用 Postman 的自动化功能,您生成了测试数据。最后,您实现了认证功能的 RESTful API 和用户的 CRUD 操作。

在接下来的两个章节中,我们将介绍 Angular 食谱来创建表单和数据表。当你实现它们时,你将希望 Lemon Mart 服务器运行起来,以验证表单和表格的正确功能。

练习

你使用authenticate中间件来保护你的端点。你已配置 Postman 发送有效的令牌,以便你可以与受保护的端点通信。作为练习,尝试移除authenticate中间件,并使用和没有有效令牌的方式调用相同的端点。重新添加中间件,再次尝试相同的事情。观察你从服务器收到的不同响应。

进一步阅读

问题

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

  1. 构成优秀开发者体验的主要组件有哪些?

  2. .env文件是什么?

  3. authenticate中间件的作用是什么?

  4. Docker Compose 与使用Dockerfile有何不同?

  5. 什么是 ODM?它与 ORM 有何不同?

  6. 中间件是什么?

  7. Swagger 的用途是什么?

  8. 你会如何重构userRouter.ts/v2/users/{id} PUT端点的代码,以便代码可重用?