无服务器-Web-应用构建指南-全-

无服务器 Web 应用构建指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

无服务器是一个用来标识由提供商完全管理的云服务,并且具有不同的计费模式,您只需为使用的时间付费,以十分之一秒的粒度计算,而不是按小时计算。无服务器计算允许广泛的程序从降低成本、加快开发以及减少可用性和可扩展性问题上的麻烦中受益。这些就足够让您开始学习如何构建无服务器应用程序。

除了教您什么是无服务器以及如何使用它,本书还提供了对该主题的更广泛视角。无服务器经常与 FaaS 相关联,人们没有意识到您可以使用无服务器做很多不仅仅是按需运行函数的事情。无服务器为数据库、安全、通知等提供了许多服务。我将教您如何使用它们。

本书可以分为以下三个部分:

  • 引言:在这里,您将了解本书中将使用的无服务器概念和工具。您将学习 AWS 服务和 Serverless 框架。本引言从第一章,理解无服务器模型,到第三章,使用 Serverless 模型

  • 构建无服务器应用程序:从第四章,托管网站,到第七章,管理无服务器数据库,您将了解如何开发和托管无服务器应用程序,并构建前端、后端和数据访问层。

  • 高级功能:本书以第八章,保护无服务器应用程序,到第十章,测试、部署和监控结束,为您提供如何使用无服务器实现安全性和实时通知的知识,以及如何测试、部署和监控您的应用程序。

本书涵盖内容

第一章,理解无服务器模型,介绍了该概念及其优缺点,以及一些用例。

第二章,AWS 入门,为新 AWS 用户提供介绍,并描述了本书中将使用哪些工具。

第三章,使用 Serverless 框架,教您如何配置和使用 Serverless 框架,这是一个构建无服务器应用程序的必备工具。

第四章,托管网站,帮助您配置域名并使用 HTTPS 支持托管您的网站。

第五章,构建前端,采用单页应用程序的方法设计 React 前端。

第六章,开发后端,介绍了如何设计 RESTful 接口以及使用 Node.js 构建后端代码。

第七章,管理无服务器数据库,展示了如何使用 SimpleDB 和 DynamoDB 为无服务器项目存储数据。

第八章,保障无服务器应用程序的安全,涵盖了标准安全实践以及如何在无服务器应用程序中实现身份验证和授权。

第九章,处理无服务器通知,演示了如何使用发布者-订阅模式构建无服务器通知。

第十章,测试、部署和监控,展示了如何测试无服务器解决方案,标准的生产部署实践,以及你需要监控的内容。

你需要这本书的内容

本书假设读者有使用 JavaScript 和 Node.js 进行 Web 开发的先验知识。虽然你有许多编程语言选项来开发无服务器应用程序,但本书的所有代码示例都使用 Node,因此至少需要一些 Node.js 的基本知识,以便理解 npm 和 JavaScript ES6 语法的使用。

有许多云服务提供商提供无服务器服务,但本书专注于 AWS。你不需要有 AWS 的先验知识,因为我们将涵盖基础知识,但你需要创建一个账户来开发和测试代码示例。如果这是一个全新的账户,AWS 为你提供为期 12 个月的免费层,以便你免费学习和开发。

这本书面向的对象

这本书是为想要使用云服务提高生产效率的 Web 开发者设计的,减少在基础设施配置和维护上的时间浪费,或者为想要使用现有服务以极少的努力构建解决方案的开发者。

此外,由于我是一名全栈开发者,我的工作需要我了解前端、后端、数据库、安全和 DevOps 等各个方面的一点点知识。因此,我在这本书中尝试提供一个关于使用无服务器概念的 Web 开发的广泛视角。如果你有类似的角色,或者至少想了解更多关于 Web 开发的各个层次,这本书非常适合你。

习惯用法

在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"此示例定义了一个<HelloReact/>HTML 元素,渲染输出将使用name属性的值"。

代码块设置如下:

     class HelloReact extends React.Component {
       render() {
         return <div>Hello, {this.props.name}!</div>;
       }
     }

     ReactDOM.render(
       <HelloReact name="World"/>,
       document.getElementById('root')
     );

当我们希望您注意代码块中的特定部分时,相关的行或项目会以粗体显示:

    [default]
    exten => s,1,Dial(Zap/1|30)
    exten => s,2,Voicemail(u100)
    exten => s,102,Voicemail(b100)
    exten => i,1,Voicemail(s0)

任何命令行输入或输出都按照以下方式编写:

aws s3 sync ./path/to/folder s3://my-bucket-name --acl public-read

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一个按钮将您带到下一个屏幕。”

警告或重要说明以这样的框显示。

小贴士和技巧会像这样出现。

读者反馈

我们欢迎读者的反馈。告诉我们您对本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户下载本书的示例代码文件,网址为www.packtpub.com。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”标签上。

  3. 点击“代码下载”和“勘误表”。

  4. 在搜索框中输入本书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击“代码下载”。

文件下载完成后,请确保您使用最新版本解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Mac 上的 Zipeg / iZip / UnRarX

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Building-Serverless-Web-Applications。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/BuildingServerlessWebApplications_ColorImages.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以追究补救措施。

请通过 copyright@packtpub.com 与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。

问题

如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章:理解无服务器模型

无服务器是一种模型,其中开发者不需要担心服务器:配置、维护或更新都不是他们的职责。尽管这不是一个全新的概念,但如今提供的服务要强大得多,能够支持更广泛的应用。如果你想构建成本效益高且可扩展的解决方案,你应该深入了解这个主题,并理解它是如何工作的。

在本章中,我们将涵盖以下主题:

  • 什么是无服务器?

  • 无服务器的主要目标

  • 优缺点

  • 用例

在这一章之后,你将准备好开始我们的动手实践,构建一个在线商店演示应用程序,每章一个部分。

介绍无服务器

无服务器可以是一个模型、一种架构类型、一种模式,或者你喜欢的任何其他称呼。对我来说,无服务器是一个形容词,一个修饰思维方式的词。它是一种抽象你编写的代码将如何执行的方式。无服务器思维就是不在服务器上思考。你编写代码,进行测试,部署,这就(几乎)足够了。

无服务器是一个热门词汇。你仍然需要服务器来运行你的应用程序,但你不必过于担心它们。维护服务器不是你的职责。重点是开发和编写代码,而不是运营。

DevOps 仍然有必要,尽管作用较小。你需要自动化部署,并至少对应用程序的运行情况和成本进行最小限度的监控,但你不需要启动或停止机器以匹配使用量,也不需要替换失败的实例或为操作系统应用安全补丁。

无服务器思维

无服务器解决方案完全是事件驱动的。每次用户请求一些信息时,一个触发器会通知你的云服务提供商选择你的代码并执行它以检索答案。相比之下,传统的解决方案也用于响应请求,但代码始终处于运行状态,消耗着专门为你预留的机器资源,即使没有人使用你的系统。

在无服务器架构中,没有必要将整个代码库加载到运行机器中以处理单个请求。为了加快加载步骤,只选择必要的代码来运行。这个解决方案的小部分被引用为函数。因此,我们只在需要时运行函数。

虽然我们简单地称之为函数,但它通常是一个包含运行入口点及其依赖项的代码片段的压缩包。

在以下图中,无服务器模型通过一系列步骤进行说明。这是一个云服务提供商如何实现该概念的示例,尽管它不必以这种方式实现:

图片

让我们理解前面图中显示的以下步骤:

  1. 用户向由云服务提供商处理的地址发送请求。

  2. 根据消息,云服务试图定位必须使用哪个包来回答请求。

  3. 包(或函数)被选中并加载到 Docker 容器中。

  4. 容器被执行并输出答案。

  5. 答案将发送给原始用户。

无服务器模型之所以有趣,在于你只需为执行函数所需的时间付费,通常以秒的分数来衡量,而不是按小时使用。如果没有人使用你的服务,你将不支付任何费用。

此外,如果你应用程序的用户访问量突然激增,云服务将加载不同的实例来处理所有并发请求。如果其中一台云机器出现故障,另一台将自动提供,无需进行任何配置。

无服务器和 PaaS

无服务器架构经常被与平台即服务PaaS)混淆。PaaS 是一种云计算模型,允许开发者无需担心基础设施即可部署应用程序。根据这个定义,它们有相同的宗旨!确实如此。无服务器架构就像是 PaaS 的重新命名,或者你也可以称之为 PaaS 的下一代。

PaaS 和无服务器之间的主要区别在于,在 PaaS 中,你不需要管理机器,但你将根据配置机器来收费,即使没有用户正在浏览你的网站。在 PaaS 中,你的代码始终在运行并等待新的请求。在无服务器中,有一个服务正在监听请求,并且只有在必要时才会触发你的代码运行。这一点在账单中得到了体现。你只需为代码执行的时间和向该监听器发出的请求数量付费。此外,无服务器在调用之间具有不可变的状态,因此对于每一次调用都是一个全新的环境。即使容器在后续调用中被重复使用,文件系统也会被更新。

IaaS 和本地部署

除了 PaaS 之外,无服务器架构经常与基础设施即服务IaaS)和本地部署解决方案进行比较,以展示其差异。IaaS 是另一种部署云解决方案的策略,你租用虚拟机并允许连接到它们以在虚拟操作系统中配置所需的一切。它为你提供了更大的灵活性,但同时也带来了更多的责任。你需要应用安全补丁、处理偶尔的故障,并设置新的服务器以处理使用高峰。此外,无论你使用机器 CPU 的 5%还是 100%,你都将按小时付费。

本地化是传统的解决方案类型,其中你购买物理计算机并在公司内部运行它们。这种方法提供了完全的灵活性和控制。托管自己的解决方案可能更便宜,但这仅在你的流量使用极其稳定的情况下才会发生。过度或不足配置计算机的情况很常见,以至于使用这种方法很难真正获得收益,尤其是在你需要雇佣一个团队来管理这些机器时。云服务提供商可能看起来很昂贵,但几个详细的使用案例证明,在云上运行的投资回报率(ROI)比本地化更高。使用云时,你可以从许多巨型数据中心的经济规模中受益。自行运行会暴露你的业务于一系列你永远无法预料的广泛风险和成本。

无服务器的主要目标

要将一项服务定义为无服务器,它必须至少具备以下特性:

  • 按需扩展:没有过度或不足配置

  • 高可用性:它具有容错性并且始终在线

  • 成本效益:你永远不会为闲置的服务器付费

扩展性

使用 IaaS,你可以通过任何云服务实现无限的扩展性。随着你的使用增长,你只需要雇佣新的机器。你也可以根据需求的变化自动启动和停止服务器。但这并不是快速扩展的方法。当你启动一台新机器时,通常需要等待大约 5 分钟,它才能被用于处理新的请求。此外,由于启动和停止机器的成本较高,你只有在确定需要时才会这样做。因此,你的自动化流程会在采取任何行动之前等待几分钟,以确认你的需求是否发生了变化。

无限扩展性被用作一种方式来强调你通常可以增长,而不用担心云服务提供商是否有足够的容量提供。但这并不总是正确的。每个云服务提供商都有其限制,如果你在考虑大型应用程序,你必须考虑这些限制。例如,AWS 将特定类型的运行虚拟机(IaaS)的数量限制为 20 台,并将并发 Lambda 函数(无服务器)的数量限制为 1,000。

IaaS 能够很好地处理良好的使用变化,但它无法处理在公告或营销活动之后发生的意外高峰。使用无服务器,你的扩展性是以毫秒计,而不是以分钟计。除了可扩展性外,它扩展得非常快。此外,它按调用进行扩展,无需配置容量。

当你考虑以分钟为单位的频繁高使用率时,IaaS 在满足所需容量方面会感到困难,而无服务器可以在更短的时间内满足更高的使用率。

在下面的图表中,左侧图表显示了 IaaS 如何实现扩展性。右侧图表显示了使用无服务器解决方案满足需求的效果:

使用本地化方法,这是一个更大的问题。随着使用量的增长,必须购买和准备新的机器,但增加基础设施需要创建和批准采购订单,你需要等待新服务器到达,并且你需要给你的团队时间来配置和测试它们。这可能需要几周的时间来增长,如果公司非常大并且需要填写许多步骤和程序,甚至可能需要几个月。

可用性

高可用性解决方案是指对硬件故障具有容错能力的解决方案。如果一台机器出现故障,你必须保持应用程序以令人满意的表现继续运行。如果你因为停电而丢失整个数据中心,你必须有另一个数据中心的机器来保持服务在线。高可用性通常意味着复制你的整个基础设施,将每一半放置在不同的数据中心。

高可用性解决方案在 IaaS 和本地化环境中通常非常昂贵。如果你有多台机器来处理你的工作负载,将它们放置在不同的物理位置并运行负载均衡服务可能就足够了。如果一个数据中心出现故障,你将保持剩余机器的流量并扩展以补偿。然而,有些情况下,即使没有使用那些机器,你也需要额外付费。

例如,如果你有一个巨大的垂直扩展的关系型数据库,你最终可能需要支付另一台昂贵的机器作为从机,仅为了保持可用性。即使是 NoSQL 数据库,如果你在一个一致的模式下设置 MongoDB 副本集,你也将为仅作为从机而不用于减轻读请求的实例付费。

而不是运行闲置的机器,你可以将它们设置为冷启动状态,这意味着机器已经准备好了,但关闭以降低成本。然而,如果你运行一个销售产品或服务的网站,即使在短暂的停机时间内,你也可能失去客户。Web 服务器的冷启动可能需要几分钟来恢复,但数据库可能需要更多的时间。

考虑这些场景,在无服务器中,你可以免费获得高可用性。成本已经包含在你支付的使用费用中。

可用性的另一个方面是如何处理分布式拒绝服务DDoS)攻击。当你在一个非常短的时间内接收到大量的请求时,你该如何处理?有一些工具和技术可以帮助减轻这个问题,例如,将超过特定请求速率的 IP 地址列入黑名单,但在这些工具开始工作之前,你需要扩展解决方案,并且它需要非常快速地扩展以防止可用性受损。在这方面,无服务器具有最佳的扩展速度。

成本效益

无法匹配流量使用与配置的量。在 IaaS 或本地部署的情况下,一般来说,CPU 和 RAM 的使用率必须始终低于 90%,机器才能被认为是健康的,理想情况下,CPU 的使用率应该低于 20%的正常流量。在这种情况下,当容量处于空闲状态时,你正在为 80%的浪费付费。为不使用的计算机资源付费是不高效的。

许多云服务提供商宣传你只需为使用的部分付费,但通常在长期(一年或更长时间)为 24 小时正常运行时间提供显著折扣。这意味着即使是在非常低流量的时段,你也需要为持续运行的机器付费。此外,即使你想关闭机器以降低成本,你也必须至少保持最小基础设施的 24/7 在线状态,以保持你的 Web 服务器和数据库始终在线。至于高可用性,你需要额外的机器来增加冗余。再次强调,这是资源的浪费。

另一个效率问题与数据库有关,尤其是关系型数据库。垂直扩展是一个非常麻烦的任务,因此关系型数据库总是根据最大峰值来配置。这意味着当大多数时间你不需要一台昂贵的机器时,你仍然需要为它付费。

在无服务器中,你不需要担心配置或空闲时间。你应该支付实际使用的 CPU 和 RAM 时间,以秒的分数计算,而不是按小时计算。如果是一个无服务器数据库,你需要永久存储数据,所以即使没有人使用你的系统,这也代表了一种成本。然而,与 CPU 时间相比,存储非常便宜。更高的成本是运行查询的数据库引擎所需的 CPU,它将根据使用时间收费,不考虑空闲时间。

无服务器系统连续运行一小时的成本比传统基础设施高得多。然而,区别在于无服务器是为具有可变使用量的应用程序设计的,你永远不会让一台机器连续一小时保持 100%的负载。无服务器的成本效率在流量平稳的网站上并不明显。

无服务器架构的优缺点

在本节中,我们将探讨与无服务器计算相关的各种优缺点。

优点

我们可以列出以下优势:

  • 快速可扩展性

  • 高可用性

  • 资源的高效使用

  • 降低运营成本

  • 专注于业务,而非基础设施

  • 系统安全外包

  • 持续交付

  • 微服务友好

  • 成本模型适合初创企业

让我们跳过前三个优点,因为它们已经在之前的页面中讨论过了,让我们看看其他的。

降低运营成本

由于基础设施完全由云服务提供商管理,因此它降低了运营成本,因为你不必担心硬件故障、为操作系统应用安全补丁或修复网络问题。这意味着你需要花费更少的系统管理员时间来保持应用程序运行。

此外,它还有助于降低风险。如果你投资部署一项新服务,结果却失败了,你不必担心销售机器或处理你已建立的数据中心。

专注于业务

精益软件开发指出,你必须花时间在最终产品中增加价值的地方。在无服务器项目中,重点是业务。基础设施是第二位的。

配置大型基础设施是一项既昂贵又耗时的任务。如果您想通过最小可行产品MVP)来验证一个想法,而又不想浪费市场时间,考虑使用无服务器架构来节省时间。有一些工具可以自动化部署,我们将在整本书中使用这些工具,并看看它们如何帮助开发者以最小的努力推出原型。如果这个想法失败了,由于没有预先支付费用,基础设施成本也会最小化。

系统安全

云服务提供商负责管理操作系统的安全、运行时、物理访问、网络以及所有使平台运行的相关技术。开发者仍然需要处理身份验证、授权和代码漏洞,但其余的都外包给了云提供商。如果你考虑到一个由大量专家组成的大型团队专注于实施最佳安全实践,并尽快修补新漏洞以服务他们的数百名客户,这是一个积极的特性。这就是规模经济的定义。

持续交付

无服务器架构基于将一个大项目拆分成数十个包,每个包由一个顶级函数表示,该函数处理请求。部署函数的新版本意味着上传一个 ZIP 文件来替换之前的版本,并更新事件配置,该配置指定了如何触发此函数。

手动执行这个任务,对于数十个函数来说是一项令人筋疲力的工作。在无服务器项目中工作,自动化是一个必不可少的特性。在这本书中,我们将使用 Serverless Framework,它帮助开发者管理和组织解决方案,使得部署任务变得像执行一条命令一样简单。有了自动化,持续交付是一个带来许多好处的功能,例如可以随时部署、缩短开发周期以及更容易回滚。

当部署自动化时,另一个相关的益处是创建不同的环境。你可以使用简单的命令创建一个新的测试环境,它是开发环境的精确副本。能够复制环境对于构建验收测试和从部署到生产的进展非常重要。

微服务友好

微服务是一个将在本书稍后更好地讨论的主题。简而言之,在无服务器项目中鼓励使用微服务架构。由于你的函数是单个部署单元,你可以让不同的团队在不同的用例上同时工作。你还可以在同一个项目中使用不同的编程语言,并利用新兴技术或团队技能。

成本模型

假设你已经使用无服务器构建了一个在线商店。平均用户会发出一些请求来查看一些产品,以及更多的请求来决定他们是否会购买。在无服务器中,单个代码单元对于给定的输入有一个可预测的执行时间。收集一些数据后,你可以预测单个用户平均的成本,并且随着你的应用程序使用量的增长,这个单位成本几乎保持不变。

知道单个用户成本是多少,并保持这个数字固定对于初创公司非常重要。这有助于决定你需要为服务收费多少,或者通过广告或销售赚取多少利润以获得利润。

在传统基础设施中,你需要预先支付费用,并且扩展你的应用程序意味着以步骤增加你的容量。因此,计算用户的单位成本是一个更困难的任务,并且是一个变量数字。

在下面的图中,左侧显示了具有阶梯式成本的传统基础设施,右侧描绘了具有线性成本的无服务器基础设施:

图片

缺点

无服务器很棒,但没有技术是一劳永逸的。你应该意识到以下问题:

  • 更高的延迟

  • 约束

  • 隐藏的低效率

  • 供应商依赖

  • 调试困难

  • 原子部署

  • 不确定性

我们现在将详细讨论这些缺点。

更高的延迟

无服务器是事件驱动的,因此你的代码并不是一直在运行。当一个请求被发起时,它会触发一个服务来找到你的函数,解压包,将其加载到容器中,并使其可执行。问题是这些步骤需要时间:多达几百毫秒。这个问题被称为冷启动延迟,是服务器无服务器成本效益模型和传统托管较低延迟之间的权衡。

有一些解决方案可以最小化这种性能问题。例如,你可以配置你的函数以预留更多的 RAM 内存。这会提供更快的启动和整体性能。编程语言也很重要。Java 的冷启动时间比 JavaScript(Node.js)长。

另一种解决方案是利用云服务提供商可能缓存已加载代码的事实,这意味着第一次执行会有延迟,但后续请求将受益于更小的延迟。你可以通过将大量功能聚合到一个函数中来优化无服务器函数。好处是,这个包将以更高的频率执行,并且经常跳过冷启动问题。问题是,大包加载需要更多时间,并且会导致更高的首次启动时间。

作为最后的手段,你可以安排另一个服务定期 ping 你的函数,例如每 5 分钟一次,以防止它们进入休眠状态。这将增加成本,但可以消除冷启动问题。

此外,还有一个无服务器数据库的概念,它指的是数据库完全由供应商管理的服务,并且仅按存储和执行数据库引擎的时间收费。这些解决方案很棒,但它们为你的请求增加了第二层延迟。

约束条件

如果你转向无服务器架构,你需要了解供应商的约束条件。例如,在 AWS 上,你不能运行超过 5 分钟的 Lambda 函数。这是有道理的,因为如果你长时间运行代码,你就是在错误地使用它。无服务器架构旨在在短时间内以成本效益的方式运行。对于持续和可预测的处理,它将非常昂贵。

AWS Lambda 的另一个约束是给定区域内所有函数的并发执行数量。亚马逊将其限制为 1,000。假设你的函数平均需要 100 毫秒来执行。在这种情况下,你可以处理每秒多达 10,000 个用户。这个限制背后的原因是避免由于编程错误可能造成的潜在跑道或递归迭代导致的过度成本。

AWS Lambda 有一个默认的 1,000 个并发执行的限制。然而,你可以向 AWS 支持中心提交案例来提高这个限制。如果你表示你的应用程序已经准备好投入生产,并且你理解其中的风险,他们可能会提高这个值。

当使用 Amazon CloudWatch(更多内容请参阅第十章[3c6f35a1-ca69-49db-ba87-f9b37af86ced.xhtml],测试、部署和监控)监控 Lambda 函数时,有一个名为节流的选项。每个超过并发调用安全限制的调用都被计为一个节流。你可以配置 CloudWatch 警报,以便在发生这种情况时接收电子邮件。

隐藏的低效率

有些人将无服务器视为一种 NoOps 解决方案。这并不正确。DevOps 仍然是必要的。你不必过于担心服务器,因为它们是二等公民,重点在于你的业务。然而,添加指标和监控你的应用程序始终是一种良好的实践。扩展如此容易,以至于一个特定的功能可能会以较差的性能部署,所需时间比必要的多得多,并且永远不被注意,因为没有人在监控操作。

此外,过配或欠配也是可能的(在较小程度上),因为你需要配置你的函数,设置它将保留的 RAM 内存量以及超时执行的阈值。这是一个非常不同的配置规模,但你需要记住这一点以避免错误。

供应商依赖

当你构建无服务器解决方案时,你将你的业务托付给第三方供应商。你应该意识到公司会失败,你可能会遭受停机时间、安全漏洞和性能问题。此外,供应商可能会改变计费模式,增加成本,在他们的服务中引入错误,提供糟糕的文档,修改 API 迫使你升级,甚至终止服务。可能会发生许多不好的事情。

你需要权衡的是,是值得信任另一家公司,还是进行大量投资自己构建一切。在选择供应商之前进行市场搜索可以减轻这些问题。然而,你仍然需要依赖运气。例如,Parse 是一家提供具有真正优秀功能的托管服务的供应商。它在 2013 年被 Facebook 收购,由于背后有大型公司的支持,因此提供了更多的可靠性。不幸的是,Facebook 在 2016 年决定关闭所有服务器,给客户一年时间迁移到其他供应商。

供应商锁定是另一个大问题。当你使用云服务时,很可能一个特定的服务与另一个供应商的实现完全不同,从而产生两个不同的 API。如果你决定迁移,你需要重写代码。这已经是一个常见问题。如果你使用托管服务发送电子邮件,在迁移到另一个供应商之前,你需要重写部分代码。这里引起红旗的是,无服务器解决方案完全基于一个供应商,迁移整个代码库可能会更加麻烦。

为了减轻这个问题,一些工具,如 Serverless Framework,支持多个供应商,这使得在它们之间切换变得更容易。多供应商支持代表你的业务安全,并赋予竞争力。

调试困难

单元测试无服务器解决方案相当简单,因为你的函数所依赖的任何代码都可以分离成模块并单独进行单元测试。集成测试稍微复杂一些,因为你需要在线测试使用外部服务。

当涉及到调试以测试一个功能或修复一个错误时,这是一个完全不同的问题。你不能连接到外部服务来查看你的代码如何逐步执行。此外,那些无服务器 API 没有开源,所以你不能在内部运行它们进行测试。你所能做的就是记录步骤,这是一个缓慢的调试方法,或者提取代码并将其适应到自己的服务器上,进行本地调用。

原子部署

部署一个无服务器函数的新版本很容易。你更新代码,下次触发器请求此函数时,你新部署的代码将被选中运行。这意味着,在很短的时间内,同一函数的两个实例可以同时执行,但实现不同。通常,这不会成为问题,但当你处理持久存储和数据库时,你应该意识到新代码可能会以旧版本无法理解的方式插入数据。

此外,如果你要部署一个依赖于另一个函数新实现的函数,你需要小心部署这些函数的顺序。通常,自动化部署过程的工具并不能保证顺序的安全性。

这里的问题是,当前的无服务器实现认为部署是每个函数的原子过程。你不能原子性地批量部署一组函数。你可以通过在部署特定组时禁用事件源来减轻这个问题,但这意味着在部署过程中引入了停机时间。另一个选择是,对于无服务器应用程序,使用单体方法而不是微服务架构。

不确定性

无服务器仍然是一个相对较新的概念。早期采用者正在勇敢地进入这个领域,测试哪些可行,以及哪些模式和可以使用的技术。新兴的工具正在定义开发过程。供应商正在发布和改进新的服务。人们对未来抱有很高的期望,但未来尚未到来。当涉及到构建大型应用程序时,一些不确定性仍然让开发者感到担忧。成为先驱可能会带来回报,但也存在风险。

技术债务是一个将软件开发与金融比较的概念。短期内最容易的解决方案并不总是最好的整体解决方案。当你最初做出错误的决定时,你会在以后用额外的时间来修复它。软件并不完美。每个架构都有其优点和缺点,长期来看都会产生技术债务。问题是:无服务器将多少技术债务聚集到软件开发过程中?是更多、更少,还是与你今天使用的架构相当?

用例

在本节中,我们将讨论哪些用例更适合无服务器环境,哪些你应该避免。由于这个概念仍在发展,仍然有一些未映射的应用程序,你不应该受到限制。所以请自由发挥你的创造力,思考和尝试新的用例。

静态网站

让我们看看以下几个静态网站的例子:

  • 公司网站

  • 站点展示

  • 博客

  • 在线文档

静态托管是最简单也是最古老的免服务器托管方式。根据定义,静态网站不需要服务器端逻辑。你只需要将你的网站 URL 映射到 HTML 文件上。在这本书中,我们将使用 Amazon S3 来分发 HTML、CSS、JavaScript 和图片文件。通过使用 Amazon Route 53,你赋予 AWS 将所有域名请求路由到一个充当简单且低成本文件系统的 S3 桶的权利。

在存储系统上托管静态文件是目前最好的解决方案。它便宜、快速、可扩展、高度可用。没有任何缺点。没有冷启动功能,没有调试,没有不确定性,更换供应商也是一个简单的任务。

如果你正在考虑使用 WordPress 来构建静态网站,请重新考虑。你需要启动一个服务器来运行一个网络服务器和一个存储数据的数据库。你开始每月支付几美元来托管一个基本网站,而随着受众的增加,成本会大幅增加。为了可用性,你会添加另一台机器和一个负载均衡器,账单至少每月几十美元。此外,由于 WordPress 被广泛使用,它成为黑客攻击的大目标,你将不得不担心 WordPress 及其插件的定期安全补丁。

那么,你应该如何使用免服务器的方式来构建一个静态网站呢?如今,有数十种工具。我个人推荐Jekyll。你可以免费托管在 GitHub pages 上,使用Disqus来处理博客评论,并且可以轻松找到许多其他插件和模板。对于我的个人博客,我更喜欢使用 Amazon,因为它可靠性高,我每月只需支付几美分。如果你愿意,你还可以添加 CloudFront,这是一个内容分发网络CDN),通过将用户近似到你的网站文件来减少延迟。

精简网站

一旦你学会了如何构建免服务器网站,将一个想法转化为运行中的服务会非常快,从而消除了准备基础设施的负担。遵循精益哲学,你的原型以最小的浪费和最大的速度进入市场,以验证一个概念。

小型电子商务网站

在本节中,我使用了限定词。这是因为有许多研究将页面加载时间与客户购买的可能性相关联。几十分之一秒的延迟可能会导致销售额的损失。如前所述,免服务器托管可以降低成本,但冷启动延迟可能会增加页面渲染的时间。面向用户的应用程序必须考虑这种额外的延迟是否值得。

如果电子商务只针对一个国家的特定小众客户群销售,那么流量很可能在白天集中,而在深夜几乎为零。这种用例非常适合免服务器托管。不频繁的访问是节省成本的主要部分。

一个真实的故事描述了 Reddit 上的这个用例。零售服装公司 Betabrand 与 Valve 合作,销售一些产品以推广一款游戏。Valve 创建了一篇博客文章来宣传这笔交易,但几分钟后,网站因为无法处理大量用户的即时峰值而崩溃。Valve 撤回了文章,Betabrand 的任务是在一个周末内改善他们的基础设施。

Betabrand 通过构建一个使用无服务器的小型网站解决了这个问题。Valve 再次宣传他们,他们能够在 24 小时内处理 50 万用户,峰值达到 5000 个并发用户。文章一开始说初始成本仅为 0.07 美元,但评论中更正为后端成本 4.00 美元,以及传输大(非优化)图片的成本 80.00 美元,这对于如此高流量的情况来说仍然是一个令人印象深刻的低成本(来源:www.reddit.com/r/webdev/3oiilb))。

临时网站

在本节中,考虑仅用于短期活动(如会议)的网站,这些网站会接待大量访客。他们需要推广活动,显示日程,也许收集电子邮件、评论、照片和其他类型的数据。无服务器有助于处理规模并提供快速开发。

另一个相关的用例是票务网站。假设一场大型、受欢迎的音乐会将在午夜开始售票。你可以预期大量粉丝将同时尝试购买门票。

触发处理

一个常见的例子是,一个移动应用程序将图片发送到 RESTful 服务。这张图片被存储,并触发一个函数来处理它,以优化并减小其大小,为桌面、平板电脑和手机创建不同的版本。

聊天机器人

大多数聊天机器人非常简单,专为特定用例设计。我们不是构建聊天机器人来通过图灵测试。我们不希望它们变得如此复杂和聪明,以至于欺骗人类认为它是另一个人在说话。我们想要的只是提供一个新的用户界面,以便在特定条件下更容易与系统交互。

你可以通过使用菜单和选项的应用程序订购披萨,也可以输入一条消息,比如“我想点一份小辣味披萨”,然后快速完成你的订单。如果用户输入“今天会下雨吗?”,披萨聊天机器人回答“我理解不了。你今天想吃什么披萨?我们有 X、Y 和 Z。”这些广泛的问题留给了多用途 AI 机器人,如 Siri、Cortana 或 Alexa。

考虑到这种受限的场景,无服务器后端可能非常有用。实际上,越来越多的演示和实际应用正在使用无服务器来构建聊天机器人。

物联网后端

物联网(IoT)是一个热门话题,许多云服务提供商都提供工具,可以轻松连接大量设备。这些设备通常需要通过一系列简单消息进行通信,并需要一个后端来处理它们。考虑到这种用例,亚马逊提供了 AWS IoT 作为无服务器服务来处理消息的广播,以及 AWS Lambda 进行无服务器处理。配置和管理这些服务非常简单,因此它们正在成为物联网系统的常见选择。

计划事件

你可以设置代码以定期、计划的方式执行。你不需要运行一台专门的机器来每小时执行一次代码,这可能会产生一些数据库读取或小文件处理,你可以使用无服务器并节省成本。

实际上,这是一个将无服务器功能引入现有解决方案的绝佳方法,因为计划中的事件通常由分离模块中的简单任务组成。

大数据

越来越多的应用程序正在用无服务器工具替代传统的 Hadoop 和 Spark 等大数据工具。你不再需要管理机器集群,而是可以创建一个大数据管道,将输入转换为数据流,并将数据块加载到并发无服务器函数中。

这种方法的优点是管理成本降低且易于使用。然而,由于你有一个持续的数据处理过程,你可以预期更高的成本。此外,在 AWS 上,Lambda 函数的运行时间不能超过 5 分钟,这个限制可能迫使你在处理之前将数据块的大小减小。

你应该避免的内容

避免具有以下特征的应用程序:

  • 需要大量 CPU 且运行时间长的任务

  • 持续且具有可预测的流量

  • 实时处理

  • 需要多人参与的游戏

关于多人游戏,你可以构建一个无服务器后端,通过无服务器通知以极低的延迟处理玩家之间的通信。它支持回合制和卡牌游戏,但可能不适合需要持续和频繁服务器端处理的第一人称射击游戏等。

摘要

在本章中,你了解了无服务器模型以及它与其他传统方法的区别。你已经知道主要的好处以及它可能为你的下一个应用程序提供的优势。你也意识到没有技术是万能的。你知道你可能会遇到哪些与无服务器相关的问题以及如何减轻其中的一些问题。

现在你已经了解了无服务器模型,我们准备深入了解你可以用来构建无服务器应用程序的工具和服务。在下一章中,你将学习 AWS 提供的哪些服务可以被视为无服务器,随后将简要解释它们的工作原理以及一系列代码示例。

第二章:AWS 入门

目前所有主要公共云服务提供商都提供无服务器产品。在本书中,我们将重点关注 AWS,它通常被认为是功能、成本和可靠性方面最佳的选择。由于我们需要在整本书中使用大量的 AWS 服务,因此本章介绍了它们,以帮助您熟悉我们示例应用程序的构建块。

本章涵盖的主要主题如下:

  • 处理用户账户

  • 使用 AWS CLI 和 SDK 服务

  • 部署你的第一个 Lambda 函数

  • 其他 AWS 无服务器产品

  • 我们示例应用程序的架构

  • 估算成本

在本章之后,你将能够开始尝试使用 AWS。

Amazon Web Services

AWS 在收入方面是最大的云服务提供商。通常被认为是功能最好的,它提供了优秀的无服务器产品。这就是我们选择 AWS 的原因。如果你更喜欢使用其他云服务提供商,以下提供者是其他优秀的无服务器选择:

  • Google Cloud Engine:在这里,你可以使用 Google Cloud Functions 以 Node.js 执行无服务器代码,以及 Google Cloud Datastore 作为无服务器数据库。此外,Google 还集成了 Firebase 平台,该平台为移动和 Web 应用程序提供了许多工具和无服务器服务,如存储、身份验证和消息传递。

  • Microsoft Azure:这提供了 Azure Functions 以支持无服务器代码执行,支持 C#、Node.js、Python 和 PHP。

  • IBM Cloud:这提供了 IBM Bluemix OpenWhisk 以支持无服务器代码执行,支持 C#、Node.js、Java 和 Swift。

本书中的所有代码示例都是使用 Node.js 为 AWS 设计的。它们可以被移植到其他云平台,但这不会是一个容易的任务。正如在第一章中之前所述的,理解无服务器模型,无服务器的一个缺点是供应商锁定。然而,你可以使用这本书来学习这些概念,也许可以混合来自不同供应商的服务。例如,你可以使用 Azure Functions 与 Amazon SimpleDB。

如果你刚开始使用 AWS 而且没有任何先前的经验,那不是问题,因为我们将从基础知识开始。你可以从在 aws.amazon.com 创建新账户开始。在 12 个月内,你可以享受免费层 (aws.amazon.com/free),该免费层旨在让你在构建演示应用程序的同时免费学习和获得实践经验。还有一些服务提供超过 12 个月的永久免费层。

下一节将介绍本书中将使用的一些服务。请注意,AWS 有一个官方的产品分类(aws.amazon.com/products),这与本书的分类不同。这是因为,我们不是根据服务的主要应用领域来分组服务,而是根据它们在我们用例中的使用方式来分组。例如,IoT 服务将用于通知,而不是连接设备。此外,Cognito 通常用于移动应用程序,但我们将使用其安全功能来构建网站:

  • 安全服务

    • AWS IAM

    • 亚马逊 Cognito

  • 管理

    • AWS SDKs

    • AWS CLI

    • AWS 云形成

    • 亚马逊云监控

  • 前端服务

    • 亚马逊 S3

    • 亚马逊 Route 53

    • 亚马逊云前端

    • AWS 证书管理器

  • 消息和通知

    • 亚马逊 SNS

    • AWS IoT

  • 后端服务

    • AWS Lambda

    • 亚马逊 API 网关

  • 数据库服务

    • 亚马逊简单数据库

    • 亚马逊 DynamoDB

处理用户账户和安全

我们将开始介绍安全主题,因为你需要知道如何正确配置用户访问以及如何授予我们用于自动化基础设施的工具的权限。

AWS IAM

当你创建你的 AWS 账户时,你会收到一个具有完全访问权限的根用户。它可以创建/删除和启动/停止任何服务。这对于学习来说很棒,但在开发真实项目时你不应该使用它。在信息安全中,最小权限原则要求用户或程序只能访问其合法目的所必需的信息或资源。如果你的访问密钥被泄露,如果访问范围受到限制,损害将会减少。

可追溯性是另一个重要方面。你不应该与他人共享你的用户。确保每个人都有自己的用户非常重要。AWS 提供了 CloudTrail 作为跟踪用户活动和 API 使用的工具。

因此,你需要学习如何使用身份和访问管理IAM)创建具有受限访问权限的用户账户和应用密钥。由于我们还没有应用密钥,我们将使用 IAM 管理控制台配置安全设置。

创建用户和组

查看以下步骤以了解如何创建用户并将组关联到用户以限制用户访问:

  1. 浏览到 IAM 网站 console.aws.amazon.com/iam

图片

  1. 点击左侧菜单中的“用户”。

  2. 选择“添加用户”,如图所示:

图片

  1. 输入用户名。在这里,你可以通过点击“添加另一个用户”选项一次添加多个用户。

  2. 选择“程序访问”框以启用使用 CLI 和 SDK 的 API 访问。

  3. 点击“下一步:权限”,如图所示:

图片

  1. 现在,我们需要为这个用户创建一个组。如果你还没有,请点击“创建组”:

图片

  1. 选择一个组名并选择一个策略。在本例中,我们将使用具有完全访问权限的简单存储服务S3)策略。点击创建组继续,然后点击下一步:审查:

图片

  1. 审查所选数据并点击创建用户:

图片

  1. 记录显示在“访问密钥 ID”和“秘密访问密钥”框中的访问密钥 ID 和秘密访问密钥。稍后您将需要它们来配置 CLI 和 SDK:

图片

在本章中,我们将运行 S3、SNS、Lambda 和 API Gateway 的示例。您可以利用这个机会,为每个服务提供适当的访问权限。管理员访问策略类型提供了对所有 AWS 资源的完全访问权限,如果您使用此账户部署到生产环境,应避免使用。

使用非 root 用户账户登录

之前创建的用户仅具有程序访问权限。您可以通过以下步骤编辑用户或创建另一个用户,以允许通过管理控制台访问:

  1. 在“添加用户”界面,您需要勾选 AWS 管理控制台访问选项:

图片

  1. 您可以保留自动生成的密码和需要重置密码的选项。在选择组并确认后,您将收到一个密码和一个链接,用于使用此非 root 用户访问 AWS 账户:

图片

  1. 访问链接的格式为https://your_aws_account_id.signin.aws.amazon.com/console。您只需点击链接并输入您的新凭据。

  2. 如果您不想公开 AWS 账户 ID 或您更喜欢使用友好的名称,如公司名称,您可以创建一个账户别名。在 IAM 控制台管理中,选择仪表板并点击自定义:

图片

  1. 现在,用户可以使用以下格式的链接登录:https://your_alias.signin.aws.amazon.com/console

亚马逊 Cognito

以安全的方式处理认证是一个复杂的问题,但这个用例如此常见,以至于许多框架和服务都是专门为解决它而构建的。如今,您只需复制几行代码,就可以顺利完成了。

Cognito 是亚马逊针对此问题的解决方案。它不仅解决了账户认证的问题,还提供了一个在不同设备之间同步数据的方法。当您使用 Cognito 账户登录时,您会收到一个临时 AWS 令牌,用于存储和检索特定于用户的数据,如偏好设置、用户资料或保存的游戏数据。

我们将在第八章,保护无服务器应用程序中通过代码示例进一步探讨此服务。

管理 AWS 资源

亚马逊提供的所有服务都是通过名为 AWS API 的 RESTful 接口配置的。您可以使用以下服务访问它们:

  • AWS 管理控制台

  • AWS SDKs

  • AWS CLI

请查看以下图示,它描述了亚马逊提供的服务:

AWS 架构的粗略示意图

AWS 管理控制台

控制台是亚马逊提供的图形用户界面,可通过官方网站 console.aws.amazon.com 访问。它是初学者最简单的界面,用于学习新服务很有用,但它并不完整。有些功能无法通过控制台访问或配置,例如管理您的 SimpleDB 数据。此外,在某些情况下,它需要大量的手动工作。如果您有一个重复的任务,最好使用 SDK 或 CLI 自动化它。

AWS SDKs

SDK 是通过可重用代码管理您的 AWS 资源的最佳方式。使用 SDK,您可以通过非常简单的命令来自动化您的基础设施并处理错误。SDK 支持多种不同的编程语言,如 Java、Python、Ruby 等。在这本书中,我们将专门使用 Node.js SDK。官方文档可在 docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html 查找。

本书中的所有代码示例都使用 Node.js,它是一个跨平台的 JavaScript 运行时,具有事件驱动模型。我们不会介绍基础知识,因此读者应具备 Node.js 的基本知识。此外,我们将使用 Node 的默认包管理器 npm 来安装依赖项。

让我们通过以下步骤学习如何使用 Node.js SDK:

  1. 使用 npm init 启动一个新的 Node 项目,并运行 npm 命令安装 AWS SDK:
 npm install aws-sdk --save

  1. 安装后,您需要设置 SDK 连接到 AWS 时将使用的访问密钥。这些密钥在上一节创建新用户时已生成。

  2. 以下是一些设置凭证的选项:

    • 使用硬编码的密钥设置凭证

    • 在磁盘上加载 JSON 文件

    • 设置凭证文件

    • 设置环境变量

您应始终避免在 AWS 密钥中硬编码,尤其是在 GitHub 上的开源项目中。您不希望不小心提交您的私钥。

  1. 我更喜欢通过环境变量来配置它们。如果您正在 macOS 或 Linux 上运行,请将以下行添加到 ~/.bash_profile 文件中。将 YOUR-KEYYOUR-REGION 替换为实际值:
 export AWS_ACCESS_KEY_ID=YOUR-KEY
 export AWS_SECRET_ACCESS_KEY=YOUR-KEY
 export AWS_REGION=YOUR-REGION

  1. 如果您正在 Windows 上运行,请在管理员命令提示符中执行以下命令,将密钥和区域的值替换为实际值:
 setx AWS_ACCESS_KEY_ID YOUR-KEY
 setx AWS_SECRET_ACCESS_KEY YOUR-KEY
 setx AWS_REGION YOUR-REGION

  1. 如果您没有首选区域,可以使用 us-east-1(美国东部弗吉尼亚州)。当您使用 AWS 管理控制台时,您可以通过右上角的下拉菜单设置您将要管理的资源所在区域。

这两种配置都是持久的,但它们只会在您下次打开命令行时生效。

  1. 您可以通过创建一个名为 index.js 的新文件并运行以下代码来测试您的设置,以列出您的 S3 存储桶。作为一个简化的定义,您可以将 存储桶 视为一个文件仓库。现在,如果您有适当的访问权限,此示例将返回您的存储桶列表或一个空数组,如果没有的话。如果您没有访问权限或设置凭证时出现问题,它将返回一个错误:
        const AWS = require('aws-sdk');
        const s3 = new AWS.S3();

        s3.listBuckets((err, data) => {
          if (err) console.log(err, err.stack); // an error occurred
          else console.log(data.Buckets); // successful response
        });

AWS CLI

CLI 是命令行界面。对于经验丰富的用户来说,这是一个获取信息和管理工作资源的优秀工具。如果您已经安装了 Python,只需运行 pip,这是 Python 的默认包管理器,即可安装 CLI:

 pip install awscli

CLI 的配置与 SDK 使用的配置非常相似。唯一的区别是您需要添加另一个环境变量:AWS_DEFAULT_REGION。您需要这个变量,因为 SDK 使用 AWS_REGION 而不是 AWS_DEFAULT_REGION 变量。

要测试您的设置是否正确,您可以执行 ls(列表)命令来列出 S3 存储桶:

 aws s3 ls

考虑到一个包含一个存储桶的 AWS 账户,前面的命令行将产生以下输出:

图片

AWS CloudFormation

CloudFormation 为开发者提供了使用模板脚写整个基础设施的可能性。这种方法被称为 基础设施即代码。这是一个强大的功能,因为它使得将服务器和资源的配置复制到另一个区域或不同的账户变得容易。此外,您还可以对脚本进行版本控制,以帮助您开发基础设施。作为一个快速入门,AWS 为常见用例提供了许多示例模板 (aws.amazon.com/cloudformation/aws-cloudformation-templates)。

在这本书中,我们不会直接使用 CloudFormation,但在下一章中,我们将开始使用 Serverless Framework,它广泛使用 CloudFormation 来管理资源。这就是您如何轻松地将解决方案复制到不同环境,使得生产部署与开发或测试环境完全相同。

Amazon CloudWatch

CloudWatch 是 AWS 资源的监控服务。它通常用于监控虚拟机,但它不仅限于这一点,在您的操作仅基于无服务器函数时也发挥着重要作用。使用 CloudWatch,您可以监控错误、限制、调用次数、持续时间以及成本。您还可以通过自定义插件进一步扩展监控。

这个主题将在第十章 测试、部署和监控 中介绍。

前端服务

本节描述了与前端开发相关的主要服务。虽然在这里进行了介绍,但你将在第四章 托管网站中找到详细示例,届时我们将使用无服务器方法托管我们的应用程序前端。

Amazon S3

Amazon Simple Storage Service (S3) 是一个可以保存任何类型文件的服务,如图像、日志文件和备份。从一点命名学开始,Amazon 将每个文件称为 对象,要存储文件,你需要一个称为 存储桶 的根文件夹。在你的账户中,你可以有多个存储桶以更好地组织你的数据。你还可以在存储桶内创建文件夹,但不能在存储桶内创建存储桶。

一个有趣的功能是,每个文件都会收到一个以下格式的唯一 URL:

https://s3.amazonaws.com/bucketname/filename

使用这种格式,存储桶名称必须在所有账户中唯一,以确保 URL 的唯一性。这意味着你不能创建一个像“my-photos”这样的通用名称的存储桶,因为它已经被占用。在命名时要有创意,并寄希望于好运。

如果文件是备份或其他类型的私有数据,你可以限制文件访问,但在这里我们将探讨的是让文件公开可用以存储我们的前端数据。这是一个强大的功能。例如,你可以用它来流式传输视频。你只需要添加一个引用 mp4 文件 URL 的 <video> HTML5 标签。为了获得一个看起来不错的播放器,你可以使用类似 videojs.com 的东西,它是开源的。

我们将利用 S3,因为它是一个非常便宜的存储服务,并且具有将文件共享以构建我们的低成本无服务器前端的能力。在我们的存储桶中,我们将添加所有前端静态文件,包括 HTML、CSS、JavaScript、图像等。通过适当的配置,这将在第四章 托管网站中详细说明,它将准备好以高可用性、可扩展性和低成本为我们提供内容服务。

使用 CLI 与 S3 一起使用

管理控制台非常有用,可以从 S3 上传和下载文件,但 CLI 同样有用。在本节中,我们将通过以下步骤创建存储桶并存储文件,以熟悉 CLI。这些步骤将在 AWS Lambda 演示中很有用:

  1. 首先,选择一个存储桶名称,并使用 make-bucket 命令:
 aws s3 mb s3://my-bucket-name

  1. 现在,创建一个名为 test.txt 的文件并向其中写入内容。

  2. 将文件复制到新的存储桶中,并将 访问控制列表 (ACL) 设置为公开内容:

 aws s3 cp test.txt s3://my-bucket-name/ --acl public-read

  1. 使用以下命令行列出存储桶内容:
 aws s3 ls s3://my-bucket-name

  1. 使用以下命令行下载文件为 test2.txt
 aws s3 cp s3://my-bucket-name/test.txt test2.txt

想要了解更多命令,请参考官方指南:docs.aws.amazon.com/cli/latest/userguide/using-s3-commands.html

Amazon Route 53

Route 53 提供 DNS 服务,你可以在这里购买和托管你的网站域名。你可能更喜欢从其他卖家那里购买域名,比如 GoDaddy 或 Namecheap,但如果你想要使用 AWS 服务来托管你的无服务器前端,你需要使用 Route 53 来托管它。

当你配置一个子域名(例如 mysubdomain.mydomain.com)时,你可以设置一个 A 记录(IP 地址)或 CNAME(指向另一个地址的别名),但根域名(mydomain.com)需要 A 记录。如果你使用 S3 托管前端,你会收到一个设置 CNAME 记录的端点,但你不会得到一个固定的 IP 来设置 A 记录。由于 Route 53 是 AWS 服务,它接受 S3 端点作为 A 记录选项,从而解决了这个问题。

配置你的域名需要一个简单的设置,但它经常让不习惯 DNS 管理的网络开发者感到困惑。这个服务将在以后得到更多的关注,特别是在第四章,托管网站中。

Amazon CloudFront

CloudFront 是一个内容分发网络CDN)。这是一个特殊的服务,其目标是提高你的网站速度和可用性。它通过使用亚马逊在全球范围内的基础设施,包含超过 60 个边缘位置,每个位置都可以用来托管你的文件的副本,从而实现这一点。

一束以光速从悉尼(澳大利亚)到纽约(美国)传播的信号需要 53 毫秒。一个 ping 消息需要往返,覆盖两倍的距离,花费两倍的时间。此外,还有其他因素会增加这个时间:光在光纤(玻璃)中传播速度慢 33%,两个城市之间没有直线连接,以及像中继器和交换机这样的设备会减慢传输速度。结果是测量的延迟在 200 毫秒到 300 毫秒之间。相比之下,在同一城市提供内容可能会将延迟降低到 15 毫秒。

这种差异对于大多数应用程序通常并不显著。在无服务器网站上,冷启动延迟有更大的影响。如果你的用例对高延迟非常敏感,你应该避免使用无服务器,或者你可以使用 CloudFront 来最小化影响,至少在前端。

为了降低成本,CloudFront 不会自动在全球范围内复制你的内容。它只会复制存在需求的地方。例如,当从英国城市发起请求时,DNS 会将请求路由到最近的边缘位置,如果还没有本地副本的文件,它将临时复制(缓存)。当附近城市的另一个用户请求相同的文件时,它将受益于更低的延迟和快速响应。

AWS 证书管理器

证书管理器是一个你可以请求免费 SSL/TLS 证书以使你的网站支持 HTTPS 的服务。以前,证书对于小型网站来说是一项昂贵的购买,每年从 100 美元到 500 美元不等。为了帮助使证书(和 HTTPS)对每个人可访问,Let's Encrypt(letsencrypt.org)作为一个非营利性证书授权公司被创建,它基于捐赠和赞助运营。你可以获得免费证书,并且它们将被所有主要浏览器接受。

在 Let's Encrypt 之后,Amazon 推出了自己的服务,名为 AWS 证书管理器。它仅限于 AWS 客户,但也是免费的且更容易使用。一旦你颁发了一个新证书并将其与 CloudFront 分发关联,Amazon 还将负责在必要时自动续订证书。我们将在第四章,托管网站中介绍此服务。

消息和通知

本节介绍了你可以在 AWS 上使用哪些服务向用户发送通知。

Amazon SNS

Amazon 简单通知服务SNS)实现了发布-订阅消息模式。当你创建一个 SNS 主题时,它将可供其他服务订阅。如果有人在主题中发布消息,所有已订阅的服务都将被提醒。

这是一个非常简单且强大的服务。你可以用它动态地附加能够处理特定类型通知的不同服务。例如,一个应用程序可以向 SNS 主题发送通知,提醒你已收到需要处理的文件。你可以使用 HTTP 端点订阅此主题,SNS 会将需要处理的文件位置的消息发送到你的 Web 服务。稍后,你可以添加另一个端点,使用一个编程用于执行另一种类型处理的 Lambda 函数。

让我们执行以下步骤,以 CLI 为例创建一个简单的演示:

  1. 使用以下命令行创建一个 SNS 主题:
 aws sns create-topic --name email-alerts

  1. 结果是一个Amazon 资源名称ARN),你需要将其保存。ARN 将以以下示例中的格式创建:arn:aws:sns:us-east-1:1234567890:email-alerts

  2. 使用电子邮件协议订阅一个主题,这样每当应用程序向此主题发布时,你都会收到一封电子邮件:

 aws sns subscribe --topic-arn the_previous_arn --protocol email \
          --notification-endpoint myemail@example.com

  1. 打开你的电子邮件账户并确认你想要订阅事件。

  2. 使用以下命令行发布测试消息并查看其工作情况:

 aws sns publish --topic-arn the_previous_arn --message "test"

对于更多命令,请参阅官方指南docs.aws.amazon.com/cli/latest/userguide/cli-sqs-queue-sns-topic.html

AWS IoT

AWS IoT (物联网) 将在我们的解决方案中用于处理无服务器通知。尽管名称表明了物联网设备的使用,但我们将仅为此通过浏览器连接的用户使用此服务。这是因为将网页连接到通知服务以接收更新,通过订阅机制而不是数据轮询,需要使用 WebSocket,而 IoT 支持 WebSocket,而 Amazon SNS 不支持。因此,尽管 IoT 名称听起来可能有些奇怪,但我们仍会使用它,因为它是我们唯一能够处理我们用例的 AWS 服务。

AWS IoT 使用 消息队列遥测传输MQTT)协议实现发布-订阅模式。我们将在 第九章,处理无服务器通知 中看到代码示例,同时为我们的示例网站的产品评论页面实现实时评论。

后端服务

在本节中,我们将通过一些实际示例介绍构建后端所需的服务。

AWS Lambda

Lambda 是无服务器概念的主打产品。按需运行函数且无需管理以及其特定的定价模式是激发开发者社区兴趣的主要驱动力。我们可以这样说,我们有无服务器数据库、无服务器通知和无服务器前端,但这些都只是主要功能的扩展,即无服务器代码执行。

Lambda 目前仅支持 Node.js (JavaScript)、Python、Java 和 C# 语言,但有一个名为 Apex 的第三方框架 (github.com/apex/apex),通过在部署构建中注入 Node.js 模拟器,增加了对 Go、Rust 和 Clojure 的支持。

创建 Lambda 函数

在这本书中,我们将广泛使用 Serverless Framework 来简化 Lambda 函数的部署;然而,为了展示框架的实用性,在本章中,我们将使用 AWS 管理控制台进行对比。

我们现在将创建一个 Lambda 函数来处理日志文件。当新的日志文件添加到 S3 桶中时,该函数将被触发,Lambda 函数的结果是如果文件中存在错误,则创建一个 SNS 通知。

让我们看看以下必要的步骤:

  1. 浏览以下链接:console.aws.amazon.com/lambda。选择“立即开始”以创建一个新的函数:

图片

  1. AWS 提供了许多带有示例配置和代码的模板。例如,您可以使用处理退回电子邮件的 Lambda 函数模板。这对于营销活动来说,可以移除不存在的电子邮件地址。然而,在这个例子中,我们将选择“空白函数”模板:

图片

  1. Lambda 函数可以由许多不同的来源触发。在下一屏,您将看到所有可用选项的列表。选择 S3:

图片

  • 顺便提一下,看看一些可用触发器的用例:

    • Amazon S3:您可以选择一个存储桶名称,事件类型为“对象创建(全部)”和前缀images/。在此设置中,当您将图像上传到图像文件夹中的此存储桶时,将触发 Lambda 函数进行后处理和图像优化。

    • SNS:您可以使用此服务来处理通知。例如,您可以为收到新订单时激活的应用程序创建一个名为Process Order的 SNS 主题。SNS 可以配置为向特定员工列表发送电子邮件并触发 Lambda 函数以执行某些逻辑。

    • CloudWatch Logs:此服务帮助您监控 AWS 资源并执行自动化操作。您可以通过触发 Lambda 函数来处理警报消息并根据其内容执行特定操作。

  1. 选择 S3 后,您将看到一些配置选项。选择之前使用 CLI 创建的存储桶。对于事件类型,选择“对象创建(全部)”以在创建新文件时触发函数。对于前缀,键入logs/以仅考虑日志文件夹中的文件,并在后缀中键入txt以仅考虑文本文件。最后,勾选启用触发选项并点击下一步图片

  2. 为您的 Lambda 函数键入一个名称,例如processLog,并选择 Node.js 6.10 作为运行时选项:

    图片

  3. 现在,我们需要使用“编辑代码内联”选项实现 Lambda 函数将要执行的代码。在这个例子中,我们使用S3.getObject来检索创建的文件,并使用SNS.publish在文件中存在error单词时创建一个通知。对于 SNS 主题 ARN,您可以使用之前使用 CLI 创建的相同主题:

        const AWS = require('aws-sdk');
        const s3 = new AWS.S3();
        const sns = new AWS.SNS();

        exports.handler = (event, context, callback) => {   
          const bucketName = event.Records[0].s3.bucket.name;
          const objectKey = event.Records[0].s3.object.key;
          const s3Params = { 
            Bucket: bucketName, 
            Key: objectKey 
          };

 s3.getObject(s3Params, (err, data) => {
            if (err) throw err;   

            // check if file have errors to report
            const fileContent = data.Body.toString();   
            if (fileContent.indexOf('error') !== -1) {         
              const msg = `file ${objectKey} has errors`;
              const snsParams = { 
                Message: msg, 
                TopicArn: 'my-topic-arn' 
              };
 sns.publish(snsParams, callback);
            }
          });
        }; 

aws-sdk模块对所有 Lambda 函数都可用。如果您想添加不是aws-sdk模块或 Node 核心模块的依赖项,您需要上传一个包含您的函数和模块的 ZIP 文件到 AWS。

  1. 由于我们使用了内联选项来编写代码而不是上传 ZIP 文件,代码将被放置在一个index.js文件中。此外,我们创建的模块导出了一个名为handler的函数。在这种情况下,我们需要使用index.handler名称配置 Lambda 处理程序。对于角色框,我们需要创建一个新的角色,因为 Lambda 函数没有适当的访问权限是无法执行的。即使您使用管理员账户创建 Lambda,也必须明确授予 Lambda 可以访问哪些服务和资源的权限:

图片

  1. 为此角色输入一个角色名称,然后点击编辑以修改默认策略文档。添加以下 JSON 对象,并通过点击允许完成:

您需要将 S3 和 SNS ARN 替换为您各自的 ARN。

图片

使用以下 JSON 对象:

        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": ["s3:GetObject"],
              "Resource": "arn:aws:s3:::my-bucket-name/*"
            },
            {
              "Effect": "Allow",
              "Action": ["sns:Publish"],
              "Resource": "arn:aws:sns:us-east-1:1234567890:email-alerts"
            }
          ]
        }

  1. 最后一步是配置高级设置。设置分配给此函数的 RAM 内存量以及 AWS 必须等待此函数完成执行的超时值。根据您将要使用的日志文件的大小,您可能需要增加超时值:

图片

  1. 点击下一步,您将被重定向到审查页面,您需要确认函数创建。

  2. 要测试此函数,我们可以使用管理控制台,它允许我们创建自定义输入事件,但在此情况下,我们可以使用 CLI 上传新文件并触发 Lambda 函数。如果文件中有“错误”这个词,您应该会收到一个包含文件名的电子邮件消息。

查看以下 CLI 命令以触发此 Lambda 函数:

 aws s3 cp log1.txt s3://my-bucket-name/logs/

  1. 如果您遇到任何问题,可以尝试使用管理控制台查看出现的错误消息。在这种情况下,使用以下 JSON 对象作为事件触发器,替换桶名称:
        {
          "Records": [
            {
              "s3": {
                "bucket": {
                  "name": "my-bucket-name"
                },
                "object": {
                  "key": "logs/log1.txt"
                }
              }
            }
          ]
        }

亚马逊 API Gateway

API Gateway 是一个帮助您构建 RESTful API 的服务。您需要配置您的资源,设置支持的 HTTP 动词并指定将处理请求的内容。您可以使用它将请求重定向到 EC2 实例(虚拟机)或外部 Web 服务器,但我们将在这里探索的是使用它来触发 Lambda 函数。

此外,API Gateway 还具有其他有趣的功能。例如,在创建您的 API 端点后,您可以使用 API Gateway 为许多不同的平台自动生成客户端 SDK,您可以在其中轻松测试并将其分发给第三方开发者使用。您还可以创建第三方 API 密钥,以细粒度访问权限、请求配额限制和节流来访问您的内容。

在我们的架构中,API Gateway 将充当一个薄层,它只存在于向世界公开我们的 Lambda 函数。此外,您还可以设置安全控制,仅允许经过身份验证的用户触发您的代码。我们将在下一章中使用此服务,我们将讨论如何使用 Serverless Framework 配置我们的端点,我们将在第六章 开发后端 中看到更多,在构建我们的后端代码时,最后,在第八章 保护无服务器应用程序 中,当我们的安全措施将被解释。

使用 API Gateway 公开 Lambda 函数

让我们使用 API Gateway 通过以下步骤公开我们的前一个 Lambda 函数,使其可以通过 URL 访问:

  1. 首先,通过访问此链接console.aws.amazon.com/apigateway进入 API 网关管理控制台,然后点击创建 API。

  2. 在创建新 API 标题下,选择新建 API 选项并输入 API 名称。例如:log-processor

  1. 在资源下,点击操作下拉菜单并选择创建方法:

  1. 在新的下拉菜单中,选择 POST HTTP 动词。

  2. 在- POST - 设置下,选择 Lambda 函数作为我们的集成类型。选择你已部署我们之前 Lambda 函数的区域及其对应名称,然后点击保存按钮:

  1. 将会弹出一个窗口请求你允许此方法访问 Lambda 函数。接受它。

  2. 在资源下,再次点击操作下拉菜单并选择部署 API。

  3. 将会弹出一个窗口请求选择一个阶段。你可以选择[新建阶段]并将其命名为dev

  1. 点击部署。下一屏幕将显示 API 网关的部署位置。URL 将遵循以下格式,例如https://[identifier].execute-api.[region].amazonaws.com/dev

  2. 如果你尝试在浏览器中输入此 URL,结果将是一个认证错误,这是由于浏览器将尝试一个未定义的GET请求。因为我们只定义了一个POST资源,我们需要另一种测试方法。你可以使用 API 网关的测试功能,该功能位于左侧菜单的资源功能下,然后选择POST动词并点击测试。

  3. 你需要提供请求体。在我们的案例中,它将是一个类似于新 S3 对象事件的 JSON 对象:

        {
          "Records": [
            {
              "s3": {
                "bucket": {
                  "name": "my-bucket-name"
                },
                "object": {
                  "key": "logs/log1.txt"
                }
              }
            }
          ]
        }

  1. 你只需将存储桶名称更改为之前使用的名称,API 网关将触发 Lambda 函数处理我们之前上传的log1.txt文件。

另一种使用 API 网关测试此集成的方法是使用Postman,这是一个非常流行的用于测试任何类型 RESTful API 的工具。Postman 可以作为 Chrome 扩展或 macOS 应用程序安装。

数据库服务

在本节中,我们将简要介绍 SimpleDB 和 DynamoDB 产品。这两个产品将在第七章中详细说明,管理无服务器数据库

Amazon SimpleDB

SimpleDB 是一个 NoSQL 数据库,可以定义为无服务器数据库,因为它可以自动扩展,高度可用且无需支付配置费用,并且你只需为数据库引擎执行查询所需的秒数付费。

你可以使用 SimpleDB 使用类似 SQL 的语法进行查询,但 SimpleDB 在功能方面非常有限。它的限制如此之高,以至于它只存储字符串字段!如果你存储 datetime 数据类型,你需要将其保存为字符串 ISO 表示形式,以避免本地化问题并能够使用 where 子句。如果你想存储数字,请使用零填充。那么我们如何使用负数来制作 where 子句呢?这是通过给所有数字添加一个大的偏移量来实现的,以避免存储负数!正如你所看到的,在 SimpleDB 上构建系统可能会很困难。有许多需要考虑的因素,并且在处理大量数据集时可能会遇到性能问题。因此,SimpleDB 通常仅适用于小型项目。

你可以通过以下链接了解如何处理数据类型的更多技巧:aws.amazon.com/articles/Amazon-SimpleDB/1232

SimpleDB 是 AWS 提供的唯一无服务器数据库。如果你想获得更好的无服务器解决方案,你可能需要尝试其他云服务提供商。你目前有以下选择:Google Firebase 存储、Google Cloud Datastore 或 FaunaDB。

SimpleDB 是 AWS 最古老的服务之一,于 2007 年底宣布。然而,它仍然是极少数没有管理控制台的服务之一。如果你想有一个图形用户界面来轻松查询和管理你的 SimpleDB 数据,你可以安装第三方解决方案。在这种情况下,我建议使用 SdbNavigator Chrome 扩展程序 作为不错的选择。你只需要添加一个访问密钥和一个秘密密钥来连接到你的数据库。作为一个安全措施,使用 IAM 创建一个新的用户账户,并限制对 SimpleDB 的权限。

Amazon DynamoDB

DynamoDB 是一个完全托管的 NoSQL 数据库,旨在实现高度可扩展性,具有快速和一致的性能。与 SimpleDB 不同,DynamoDB 拥有你在 NoSQL 数据库中期望的所有常见功能,并且可以广泛应用于大型项目中。然而,对于我们的用例,DynamoDB 有一个缺陷:它不是一个无服务器数据库。它需要配置资源,因此你不能说 DynamoDB 真正是无服务器的。如果你为配置的容量付费,你需要担心服务器,因为你可能会配置过多或过少的资源,即使没有人使用你的数据库,你也需要为可用性付费。

幸运的是,AWS 有一个永久性的免费层,非常慷慨。你可以免费处理每月超过 1 亿次的读写请求,并且这个优惠不仅限于新 AWS 用户。考虑到这个优势,低廉的用户基础增长价格,以及自动化的吞吐量配置可能性,DynamoDB 对于大多数无服务器应用来说是一个不错的选择,这一点通过无服务器社区使用 DynamoDB 创建的众多项目和演示得到了证明。很难想象 SimpleDB 会被用于即使是小型项目,因为 DynamoDB 对于低使用量是免费的。

因此,即使您有一个大型项目并最终不得不为未使用的预留资源付费,DynamoDB 的管理需求要少得多,并且可能比运行传统数据库解决方案更便宜。出于所有这些原因,我们将在本书中介绍 SimpleDB 的使用,但我们的示例应用程序将运行在 DynamoDB 上。

我们在线商店的无服务器架构

在本书中,我们将构建一个无服务器解决方案的真实世界用例。此示例应用程序是一个在线商店,具有以下要求:

  • 可用产品列表

  • 产品详情和用户评分

  • 将产品添加到购物车

  • 创建账户和登录页面

我们将在下一章中描述和实现每个功能。为了更好地理解架构,以下图表给出了我们在本章中介绍的不同服务组织方式和它们之间交互的一般视图:

估算成本

在本节中,我们将根据一些使用假设和亚马逊的定价模型来估算我们的示例应用程序演示的成本。这里使用的所有定价值均来自 2017 年中期,并考虑了最便宜的地区,美国东部(弗吉尼亚北部)。

本节包含一个示例,说明如何计算成本。由于计费模型和价格可能会随时间变化,在做出自己的估算之前,请始终参考官方来源以获取更新的价格。您可以使用亚马逊的计算器,该计算器可通过此链接访问:calculator.s3.amazonaws.com/index.html。如果您在阅读说明后仍有任何疑问,您始终可以免费联系亚马逊的支持以获得商业指导。

假设

对于我们的定价示例,我们可以假设我们的在线商店每月将接收以下流量:

  • 100,000 次页面浏览

  • 1,000 个注册用户账户

  • 考虑到平均页面大小为 2 MB,已传输 200 GB 的数据

  • 5,000,000 次代码执行(Lambda 函数),平均每次请求 200 毫秒

Route 53 定价

我们需要一个托管区域来处理我们的域名,每月费用为 0.50 美元。此外,我们还需要为每百万次对域名的 DNS 查询支付 0.40 美元。由于这是一个按比例计算的费用,100,000 次页面浏览将只需支付 0.04 美元。

总计:0.54 美元

S3 定价

亚马逊 S3 按每 GB/月存储费用收取 0.023 美元,每 10,000 次对文件的请求费用为 0.004 美元,以及每 GB 传输费用为 0.09 美元。然而,由于我们正在考虑 CloudFront 的使用,传输费用将按 CloudFront 的价格收取,不会计入 S3 的账单中。

如果我们的网站静态文件小于 1 GB,平均每页 2 MB 和 20 个文件,我们可以以低于 US$ 20 的价格提供 100,000 次页面浏览。考虑到 CloudFront,S3 费用将降至 US$ 0.82,而您需要在另一个部分支付 CloudFront 的使用费用。实际费用会更低,因为 CloudFront 会缓存文件,它不需要向 S3 发出 2,000,000 次文件请求,但让我们跳过这个细节以减少估计的复杂性。

顺便提一下,如果您需要配置机器来处理这么多页面浏览量,以相同的可用性和可伸缩性来处理静态网站,费用将会更高。

总计:US$ 0.82

CloudFront 定价

由于不同区域的价格不同,CloudFront 的定价稍微复杂一些,因为您需要猜测每个区域的流量。以下表格显示了一个估计示例:

区域 预估流量 每 GB 传输费用 每 10,000 个 HTTPS 请求费用
北美 70% US$ 0.085 | US$ 0.010
欧洲 15% US$ 0.085 | US$ 0.012
亚洲 10% US$ 0.140 | US$ 0.012
南美 5% US$ 0.250 | US$ 0.022

根据我们估计的 200 GB 文件传输和 2,000,000 次请求,总费用将是 US$ 21.97。

总计:US$ 21.97

证书管理器定价

证书管理器提供免费的 SSL/TLS 证书。您只需为运行应用程序创建的 AWS 资源付费。

IAM 定价

IAM 使用没有特定费用。您只需为用户使用的 AWS 资源付费。

Cognito 定价

每个用户都有一个关联的配置文件,每月费用为 US$ 0.0055。然而,有一个永久免费层,允许 50,000 个每月活跃用户免费使用,这对于我们的用例来说已经足够了。

此外,我们还需要为用户配置文件的 Cognito 同步付费。每次 10,000 次同步操作的费用为 US$ 0.15,每 GB/月存储的费用为 US$ 0.15。如果我们估计有 1,000 个活跃且注册的用户,每个配置文件小于 1 MB,平均每月访问次数少于 10 次,我们可以估计费用为 US$ 0.30。

总计:US$ 0.30

物联网定价

物联网费用起价为每百万条交换消息 US$ 5。由于每次页面浏览至少会触发 2 次请求,一次用于连接,另一次用于订阅主题,我们可以估计每月至少有 200,000 条消息。如果我们假设 1%的用户会对产品进行评分,我们需要额外增加 10,000 条消息,我们可以忽略其他请求,如断开连接和取消订阅,因为它们不包括在计费中。在这种情况下,总费用将是 US$ 1.01。

总计:US$ 1.01

SNS 定价

我们将仅使用 SNS 进行内部通知,当 CloudWatch 触发关于我们基础设施问题的警告时。SNS 每 100,000 封电子邮件收费 US$ 2.00,但它提供 1,000 封电子邮件的永久免费层。因此,对我们来说将是免费的。

CloudWatch 定价

CloudWatch 每个指标/月收费 US$ 0.30 和每个警报收费 US$ 0.10,并提供每月 50 个指标和 10 个警报的永久免费层。如果我们创建 20 个指标并预计一个月内有 20 个警报,我们可以估计成本为 US$ 1.00。

总计:US$ 1.00

API Gateway 定价

API Gateway 开始对每百万个接收到的 API 调用收费 US$ 3.50 和每向互联网传输 1 GB 数据收费 US$ 0.09。如果我们假设每月有 500 万次请求,每次响应的平均大小为 1 KB,该服务的总成本将为 US$ 17.93。

总计:US$ 17.93

Lambda 定价

当你创建一个 Lambda 函数时,你需要配置将可用于使用的 RAM 内存量。它的范围从 128 MB 到 1.5 GB。分配更多内存意味着额外的成本。这打破了避免配置的哲学,但至少这是你需要担心的事情之一。这里的良好实践是估计每个函数所需的内存量,并在部署到生产之前进行一些测试。不良的配置可能会导致错误或更高的成本。

Lambda 具有以下计费模型:

  • 每 1,000,000 次请求收费 US$ 0.20

  • 每 GB-second 收费 US$ 0.00001667

运行时间按秒的分数计算,向上取整到最接近的 100 毫秒的倍数。

此外,还有一个永久免费层,每月提供 1,000,000 次请求和 400,000 GB-seconds,无需收费。

在我们的用例场景中,我们假设每月有 500 万次请求,平均每次执行时间为 200 毫秒。我们还可以假设每个函数分配的 RAM 内存为 512 MB:

  • 请求费用:由于 1,000,000 次请求是免费的,你只需为 4,000,000 次请求付费,费用为 US$ 0.80。

  • 计算费用:在这里,500 万次执行每次 200 毫秒的计算,总共为 1,000,000 秒。由于我们以 512 MB 的容量运行,这导致 500,000 GB-seconds,其中 400,000 GB-seconds 是免费的,因此产生 100,000 GB-seconds 的费用,费用为 US$ 1.67。

  • 总计:US$ 2.47

SimpleDB 定价

看看以下 SimpleDB 计费,其中免费层适用于新用户和现有用户:

  • 每机器小时收费 US$ 0.14(25 小时免费)

  • 每向互联网传输 1 GB 数据收费 US$ 0.09(1 GB 是免费的)

  • 每存储 1 GB 数据收费 US$ 0.25(1 GB 是免费的)

看看以下费用:

  • 计算费用:考虑到 500 万次请求,平均执行时间为 200 毫秒,其中 50% 的时间是在等待数据库引擎执行,我们估计每月需要 139 个机器小时。扣除 25 个免费小时,执行费用为 US$ 15.96。

  • 传输费用:由于我们将数据在 SimpleDB 和 AWS Lambda 之间传输,因此没有传输费用。

  • 存储费用:如果我们假设一个 5 GB 的数据库,由于 1 GB 是免费的,因此结果为 US$ 1.00。

  • 总计:US$ 16.96,但这个费用将不会添加到最终估计中,因为我们将在使用 DynamoDB 运行我们的应用程序。

DynamoDB

DynamoDB 要求您为预期您的表提供的吞吐量容量进行配置。您不需要配置硬件、内存、CPU 等因素,您需要说明您期望的读取和写入操作的数量,AWS 将处理必要的机器资源,以提供一致且低延迟的性能来满足您的吞吐量需求。

一个读取容量单元代表每秒一次强一致性读取或每秒两次最终一致性读取,其中对象大小最多为 4 KB。关于写入容量,一个单元意味着您每秒可以写入一个大小为 1 KB 的对象。考虑到这些定义,AWS 在永久免费层提供 25 个读取单元和 25 个写入单元的吞吐量容量,以及 25 GB 的免费存储。其收费如下:

  • 每个月每 写入容量单元WCU)收费美元 0.47

  • 每个月每 读取容量单元RCU)收费美元 0.09

  • 每存储 1 GB/月收费美元 0.25

  • 每传输 1 GB 到互联网收费美元 0.09

由于我们估计的数据库将只有 5 GB,我们处于免费层,我们不会为传输的数据付费,因为向 AWS Lambda 的传输没有费用。

关于读写容量,我们估计每月有 500 万次请求。如果我们平均分配,我们将每秒获得两次请求。在这种情况下,我们将考虑每秒一次读操作和一次写操作。

我们现在需要估计受读操作和写操作影响的对象数量。对于写操作,我们可以估计平均操纵 10 项,而读操作将扫描 100 个对象。在这种情况下,我们需要预留 10 WCU 和 100 RCU。由于我们有 25 WCU 和 25 RCU 的免费额度,我们每月只需为 75 RCU 支付费用,费用为美元 6.75。

总计:美元 6.75

总定价

让我们在下表中总结每种服务的成本:

服务 月度成本
Route 53 美元 0.54
S3 美元 0.82
CloudFront 美元 21.97
Cognito 美元 0.30
IoT 美元 1.01
CloudWatch 美元 1.00
API Gateway 美元 17.93
Lambda 美元 2.47
DynamoDB 美元 6.75
总计 美元 52.79

这导致在基础设施上每月总成本约为美元 50,以服务 10 万次页面浏览。如果您有 1% 的转化率,您每月可以获得 1,000 笔销售,这意味着您为每件销售的产品支付美元 0.05 的基础设施费用。

摘要

在本章中,您已经了解了本书中将使用的服务。您已经知道如何创建具有受限权限的新 AWS 用户,如何使用 AWS CLI 和 Node SDK,以及前端、后端和通知服务是什么。本章展示了每个服务如何适应我们的示例应用程序架构,并学习了如何估计其成本。

在下一章中,你将了解到在开发工作流程中扮演重要角色的无服务器框架,它能够自动化任务和组织代码。你将学习如何配置、部署 Lambda 函数,以及如何构建我们示例应用的初始结构。

第三章:使用 Serverless Framework

在开发无服务器项目时,你可以将多个功能组合成一个大的 Lambda 函数,或者将每个功能拆分成它自己的小函数。如果你选择第二种方案,你最终将管理数十个不同的函数的部署,每个函数都有其自己的配置和依赖。自动化这个过程可能是一个真正的挑战,但当你使用 Serverless Framework 作为你的工作流程时,它就变得容易多了。除了处理发布过程外,该框架还帮助您构建解决方案并管理不同的环境,并为基础设施版本化提供了一种干净简洁的语法。

在本章中,你将学习如何配置和使用 Serverless Framework。我们将涵盖以下主题:

  • 如何设置和使用框架

  • 部署 hello-world 应用程序

  • 创建端点和启用 CORS

  • 配置事件以触发函数

  • 访问其他 AWS 资源

在本章之后,你将学会如何构建无服务器项目后端的基本知识。

Serverless Framework

已经开发了许多工具来帮助管理无服务器项目。Serverless Framework 目前是最受欢迎的,本书将广泛使用它。本节将帮助你配置、使用和理解它如何融入你的工作流程。

理解 Serverless Framework

Serverless Framework 是一个强大的 Node.js 命令行工具,而不是云服务。它的目标是帮助开发者通过简化他们使用和管理云资源的方式,提高他们的生产力。它提供了一套命令,可以帮助你快速启动一个新项目,添加函数、端点、触发器、配置权限等。总的来说,该框架将管理你的项目,自动化你的代码部署,并与许多不同的服务集成:

图片

我们有以下输入:

  • 集成:描述了不同的云服务将如何触发我们的 Lambda 函数

  • 配置:为 Lambda 函数设置权限,并定义它们将在其下运行的限制(超时和 RAM 内存)

  • 插件:通过自定义代码扩展框架功能

这是该框架提供的内容:

  • 架构:帮助定义一个将保持我们的项目一致性的架构。

  • 部署:自动化代码部署。你可以通过一个命令随时部署。

  • 版本化:帮助版本化代码配置,这意味着版本化基础设施。将相同的架构复制到另一个区域或环境是一个简单的任务。

目前,它支持以下云服务提供商:AWS、Microsoft Azure、Google Cloud Platform 和 IBM OpenWhisk。从一个云迁移到另一个云是可能的,但这并不简单。该框架使用相同的命令进行管理任务,并试图使用类似的设置,但每个都需要不同的配置和设置。

另一个重要特征是,Serverless Framework 是开源的,并拥有 MIT 许可证,因此它可以免费使用,即使在商业产品中也是如此。

其他框架

无服务器是一种促进应用程序开发的概念,无需担心将运行这些应用程序的服务器。这是一个概念,并不指定将使用的工具,也不指定托管应用程序的云服务提供商。然而,利用这个词汇的热潮,JAWS 的创造者在 2015 年底将他们的项目重命名为 Serverless Framework,并购买了 serverless.com 域名。为了进一步提高他们的开源项目,他们成立了一家名为 Serverless, Inc 的风险投资公司。

目前,Serverless Framework 是构建通用无服务器项目的最佳工具,但不要将产品与概念混淆。该框架推广无服务器应用程序,但仅提供您可以使用无服务器执行的部分功能。还有许多其他服务和框架,它们具有不同的功能和目标。

例如,Apex 是另一个用于管理 AWS Lambda 函数的框架,它具有一个有趣的功能,即提供对 Go、Rust 和 Clojure 的支持,即使 Lambda 本身没有原生支持。还有数十种其他工具。更多选项,您可以查看这个精选列表:github.com/anaibol/awesome-serverless

安装框架

由于 Serverless Framework 使用 Node.js,您可以使用 npm 来安装它:

npm install serverless@1.x --global

@1.x 后缀指示 npm 下载与 1.x 版本兼容的包。这种限制建议是因为这本书是在框架的 1.18 规范之后编写的,示例可能不与未来的 2.x 版本兼容。

Serverless Framework 需要 Node.js v6.5 或更高版本。请确保您有一个更新的版本。您可以通过运行 node --version 来检查。如果您需要更新 Node 版本,请考虑使用 v6.10,因为这是 AWS 用于运行 Lambda 函数的最新版本。

要确认框架已成功安装,您可以通过运行以下命令来检查其版本:

 serverless --version

除了使用 serverless 命令外,您还可以使用缩写 sls 来使用所有命令。例如,sls --version

此外,对于以两个短横线开头的每个选项,例如在 --version 中,总会有一个使用单个字母的更短替代选项,例如本例中的 -v

配置框架

Serverless 框架使用 AWS SDK 来管理你的账户资源,因此所需的配置是将你的凭证设置在 SDK 可以访问它们的地方。如第二章,“AWS 入门”,所述,我们已经创建了一个用户,并将其访问密钥秘密访问密钥设置到环境变量中。

这里缺少的是正确限制用户访问。为了学习目的,使用具有完全访问权限的管理员账户是完全可以接受的。然而,如果你正在构建真实的产品,请遵循最小权限原则,只为框架预期使用的功能设置访问权限。在上一章中,你学习了如何使用 IAM 控制台配置它(console.aws.amazon.com/iam)。

最小访问要求是LambdaCloudFormationIAMCloudWatch。在设置权限时,你可以预测并授予我们样本项目中将来需要的访问权限。框架还需要访问API GatewayIoTSimpleDBDynamoDB

在团队中管理权限

在团队工作中,每个人都必须拥有自己的用户,以便拥有细粒度的权限集合。此外,它还允许审计和可追溯性,这两者都非常重要。审计可以阻止团队成员的不当行为,而可追溯性在不幸的情况下很有用,例如,如果你的网站遭到入侵,你可以发现入侵的源头。如果你想要这些功能,你必须配置AWS CloudTrail以将 AWS API 使用日志文件存储到 S3 中。

如果每个团队成员都有一个唯一的账户,你可以限制对生产环境的访问权限,仅限于一小群人。对生产环境的访问是一项重大的责任,应该只托付给有经验的人,以避免由于分心或知识不足而导致的失败。

创建新项目

让我们先创建一个新的文件夹来存储我们的项目数据。将其命名为hello-serverless并将命令提示符目录设置为该文件夹。现在,运行以下命令:

serverless create --template aws-nodejs --name hello-serverless

看一下下面的截图:

截图

此命令创建一个用于分组相关函数的服务。你可以将服务比作领域驱动设计DDD)中定义的边界上下文。例如,在这本书中,样本应用是一个在线商店。我们可以说,将用于展示产品和处理销售的功能属于一个上下文。处理用户账户和配置文件数据的功能属于另一个上下文。我们将在第六章,“开发后端”中讨论无服务器架构。

执行命令后,将创建以下两个文件:

  • handler.js文件

  • serverless.yml文件

让我们看看每个配置的上下文,并了解它们的作用。

handler.js 文件

此文件包含将由 AWS Lambda 执行的主要函数。对于简单示例,考虑以下代码:

    module.exports.hello = (event, context, callback) => 
    {

        const response =
        {
              statusCode: 200,   
              body: JSON.stringify({
                  message: `Hello, ${event.name}!`
            })
        };

        callback(null, response);
    };

我们的 response 对象具有 statusCodebody 属性。当您想使用 API Gateway 触发 Lambda 函数,或者当 Lambda 配置为代理(这是在 Serverless 框架中选择的默认选项)时,此架构是必需的。与在 API Gateway 中配置头、状态码和其他参数相比,Lambda 代理设置允许将这些配置作为代码的一部分。这对于大多数用例是推荐的做法。

命名为 hello 的函数将被配置为我们的主要入口。它接收 eventcontextcallback 三个参数。event 变量是我们的输入数据,callback 是在 Lambda 执行完成后必须执行并接收错误对象作为第一个参数、response 对象作为第二个参数的函数,context 是一个提供与我们的函数执行相关的数据的对象。以下是一个 context 内容的示例,以 JSON 格式显示:

    {
      "callbackWaitsForEmptyEventLoop": true,
      "logGroupName": "/aws/lambda/hello-serverless-dev-hello",
      "logStreamName":
        "2017/07/15/[$LATEST]01a23456bcd7890ef12gh34i56jk7890",
      "functionName": "hello-serverless-dev-hello",
      "memoryLimitInMB": "1024",
      "functionVersion": "$LATEST",
      "invokeid": "1234a567-8901-23b4-5cde-fg67h8901i23",
      "awsRequestId": "1234a567-8901-23b4-5cde-fg67h8901i23",
      "invokedFunctionArn": "arn:aws:lambda:us-east-1:1234567890:
        function:hello-serverless-dev-hello"
    }

在这个例子中,我们返回 状态码 200 (OK),响应 body 将返回一个使用事件作为输入变量的消息。

serverless.yml 文件

这是一个使用 YAML 标准的配置文件,其目的是使人类更容易阅读。YAML 的名字是一个递归缩写,意味着 YAML Ain't Markup Language

当我们创建服务时,我们使用了 aws-nodejs 参数。它创建了一个包含以下内容的文件:

    service: hello-serverless

    provider:
      name: aws
      runtime: nodejs6.10

    functions:
      hello:
        handler: handler.hello

让我们看看前面代码示例中描述的以下设置:

  • service: 这只是我们在创建服务时指定的服务名称。

  • provider: 这设置了云提供商和运行时。我们选择了 AWS 和可用的最新 Node.js 版本。

  • functions: 这里是我们定义 Lambda 函数的地方。

还有更多选项可用,但我们将根据需要介绍它们。

配置 Lambda 限制

在设置 serverless.yml 文件时,您可以配置您的函数限制。RAM 内存大小默认值为 1,024 MB。可能的值范围从 128 MB 到 1,536 MB,以 64 MB 为增量。

另一个可能的设置是 timeout 属性。如果您的函数超过预期时间,它将被终止。默认值为 6 秒,可能的值范围从 1 秒到 300 秒(5 分钟):

    functions:
      hello:
        handler: handler.hello
        memorySize: 128 # measured in megabytes
        timeout: 10 # measured in seconds

在 YAML 语法中,注释以井号 (#) 开头,并持续到行尾。

您也可以通过修改提供商设置来更改默认值。当您的函数未指定这些值时,将使用这些值:

    provider:
      name: aws
      runtime: nodejs6.10
      memorySize: 512
      timeout: 30

部署一个服务

部署一个服务是一个简单的任务。我们只需要运行以下命令:

 serverless deploy

您可以在以下屏幕截图中看到结果:

图片

默认情况下,它将在名为dev的阶段和名为us-east-1的区域中部署您的函数。阶段用于模拟不同的环境。例如,您可以创建一个用于开发,另一个用于生产,或者如果您想创建版本化的 API,您可以使用一个用于v1,另一个用于v2。至于区域,它用于标识将用于托管 Lambda 函数的哪个 AWS区域

这里有两个选项来更改默认值:

  • 第一种方法是修改serverless.yml文件,如下面的代码示例所示:
        provider:
          name: aws
          runtime: nodejs6.10
          stage: production
          region: eu-west-1

  • 第二种方法是使用部署命令的参数:
 serverless deploy --stage production --region eu-west-1

provider下,您可以将配置文件设置为dev阶段,只有当您想部署到生产时,您可以使用命令行中的阶段参数来这样做。对于不同的环境使用两种不同的方法是一种避免错误的好方法。

当我们使用deploy命令时,即使是对于小型项目,执行也可能需要几分钟。性能问题与 CloudFormation 有关,它需要在 AWS 机器之间更新堆栈。在第一次部署函数后,我们可以使用deploy function命令进行代码更新,因为这个命令将简单地交换函数的 ZIP 包。由于它不需要执行任何 CloudFormation 代码,这是一种部署更改的更快方式。以下示例显示了如何使用此命令:

 serverless deploy function --function hello

总是记得使用deploy function命令更新函数的代码以实现快速部署。如果您需要更新任何类型的配置,例如权限或 Lambda 限制,您需要运行deploy命令(不带function部分)。

调用函数

我们刚刚创建并部署了一个 Lambda 函数。现在,让我们通过以下步骤来查看这个函数如何被调用:

  1. 在您的项目文件夹中,创建一个包含以下内容的event.json文件。此文件将作为我们的输入数据:
        {
          "name": "Serverless"
        }

  1. 下一步是调用函数并确认其按预期工作。您可以通过执行invoke命令来完成此操作:
 serverless invoke --function hello --path event.json

event.json文件作为输入不是强制性的。我们之所以使用它,是因为我们的示例使用输入数据来创建response对象。

以下截图显示了调用结果:

  1. 如果您将函数部署到多个阶段/区域,您可以通过明确指定阶段/区域来调用它们。例如,看看以下命令:
 serverless invoke --function hello --stage test --region eu-west-1

  1. 最后一个观察结果是您可以本地调用函数。这个调用将使用您的机器执行函数,而不是运行托管在 AWS 上的函数。为此,只需使用invoke local命令:
 serverless invoke local --function hello --path event.json

我们稍后将会看到,我们可以为 Lambda 函数授予或限制权限。然而,如果您在本地执行代码,它将不会使用配置的角色。Lambda 将在您的本地 SDK 凭据下执行,因此本地测试 Lambda 可能很有用,但您需要知道您不会使用在函数托管在 AWS 上时将使用的相同权限来测试它。

检索日志

当 Lambda 函数由于未处理的异常而失败时,结果将是一个通用的消息:

    {
      "errorMessage": "Process exited before completing request"
    }

为了排除错误,我们需要检索执行日志。您可以通过将--log选项附加到invoke命令来完成此操作:

 serverless invoke --function hello --log

这将导致类似于以下错误消息的结果:

    START RequestId: 1ab23cde-4567-89f0-1234-56g7hijk8901
    Version: $LATEST2017-05-15 15:27:03.471 (-03:00) 
        1ab23cde-4567-89f0-1234-56g7hijk8901
 ReferenceError: x is not defined 
 at module.exports.hello (/var/task/handler.js:9:3)
    END RequestId: 1ab23cde-4567-89f0-1234-56g7hijk8901
    REPORT RequestId: 1ab23cde-4567-89f0-1234-
    56g7hijk8901 
    Duration: 60.26 ms
    Billed Duration: 100 ms        
    Memory Size: 128 MB      
    Max Memory Used: 17 MB

    Process exited before completing request

除了在调用函数时使用--log命令外,您还可以检索已部署但未调用新执行的 Lambda 函数的日志。此命令如下:

 serverless logs --function hello

以下是一个包含日志消息示例的截图:

截图

这个功能的一个问题是您必须指定函数名称。您无法以通用视图查看所有函数的执行情况,这在拥有数十个函数的项目中可能是理想的。

当在生产环境中运行时,使用命令行来监视日志可能会很麻烦。您可以使用--filter string命令来减少结果数量,只显示包含特定字符串的消息。这在查找错误消息时很有用,例如,使用--filter Error

--filter string选项是区分大小写的。如果您想查找错误消息,请使用--filter Error,因为大多数异常消息将以大写字母开头的错误词开始,例如:ReferenceError

另一个选项是按时间过滤。您可以使用--startTime time来过滤仅包含最近消息。例如,您可以将“time”替换为30m以查看 30 分钟前发生的消息。

看一下下面的示例:

 serverless logs --function hello --filter error --startTime 30m

此外,您还可以添加一个监听器,它会输出所有接收到的新的日志消息。在这种情况下,您需要添加--tail命令。

这里有一个示例:

 serverless logs --function hello --tail

添加端点

端点是 API 网关暴露给互联网的地址。

以下步骤展示了如何为我们的 Lambda 示例创建端点:

  1. 端点是通过在serverless.yml文件中设置 HTTP 事件来添加的。在以下示例中,我们指定在my-service/resource路径中使用的GET HTTP 动词将触发此 Lambda 函数:
        functions:
          hello:
            handler: handler.hello
 events:
 - http: GET my-service/resource

  1. 在编辑配置文件后,使用以下命令再次部署服务:
 serverless deploy

看一下下面的截图:

截图

这次,除了 Lambda 函数的更新外,deploy命令还将创建一个配置了先前路径和方法的 API Gateway 资源。在上一章中,我们部署了一个触发 Lambda 函数的 API Gateway 资源,这需要很多步骤。你现在看到 Serverless Framework 有多强大了吗?如果你有数十个函数和端点,一个命令就足以部署所有这些。这种自动化和易用性使得框架如此有趣。

  1. 在前面的屏幕截图中,我们可以看到框架列出了创建的端点地址。它使用以下格式:
https://[key].execute-api.[region].amazonaws.com/[stage]/[path]

  1. 如果你使用浏览器打开此 URL,你将看到一个包含我们的 hello-world 消息的response对象。当使用 API Gateway 时,event变量将包含更多数据,添加有关头和请求上下文的信息。其中大部分对我们来说没有用,但我们需要使用event对象来找到输入数据。由于这是一个GET请求,我们可以在 URL 末尾添加一个查询字符串来传递变量值并检索它们,在event对象内部查找queryStringParameters属性。看看以下 URL 示例:
        https://[key].execute-api.us-east-1.amazonaws.com/dev/my-service/resource?name=Serverless&foo=bar

?name=Serverless&foo=bar文件是映射到我们event变量queryStringParameters属性内的 JSON 对象的查询字符串,如下所示:

        {
          "name": "Serverless",
          "foo": "bar"
        }

  1. 由于我们现在使用 API Gateway 而不是直接调用 Lambda 函数,接收到的event对象将具有不同的属性。在这种情况下,我们需要调整我们的 Lambda 函数以正确处理它。以下示例使用event.queryStringParameters.name而不是event.name
        module.exports.hello = (event, context, callback) => {

          const response = {
            statusCode: 200,   
            body: JSON.stringify({
              message: `Hello, ${event.queryStringParameters.name}!`
            })
          };

          callback(null, response);
        };

  1. 为了测试,重新部署函数,并通过查询字符串浏览端点地址。

我们将在第六章“开发后端”中介绍其他 HTTP 动词。

跨源资源共享

如果你尝试通过 Ajax 调用在网站内部调用此 API 地址,它将抛出异常。这是因为 API Gateway 默认没有启用跨源资源共享CORS)。CORS 是一种机制,允许从另一个域托管在网页中请求资源。默认情况下,它是禁用的,以强制管理员仅在有意义且针对特定域时才为跨域请求提供权限。

我们正在构建一个将托管在 AWS 内部的网站,但网页将通过我们自己的域名访问,例如www.example.com,而不是从www.amazonaws.com访问。因此,我们需要启用 CORS,以允许我们的前端代码消费我们的服务。如果你有一个仅应由另一个 Lambda 或内部 AWS 服务访问的 Lambda 函数,则不需要 CORS。

要启用 CORS,我们需要修改我们的handler.js函数,以包含"Access-Control-Allow-Origin"头:

    module.exports.hello = (event, context, callback) => {

      const response = {
        statusCode: 200, 
        headers: {
 "Access-Control-Allow-Origin": "https://www.example.com" },        body: JSON.stringify({
          message: "Hello, ${event.queryStringParameters.name}!"
        })
      };

      callback(null, response);
    };

你可以为每个函数添加仅 一个 原因。当我们需要支持多个来源时,这是一个问题,而且这个要求非常常见。例如,以下地址被认为是不同的来源,因为它们有不同的协议(HTTP 与 HTTPS)或不同的子域名(无与 www):

  • http://example.com

  • https://example.com

  • http://www.example.com

  • https://www.example.com

要支持多个来源,你需要使用以下命令:

 "Access-Control-Allow-Origin": "*"

另一种解决方案,这在传统 Web 服务器中非常常见,是根据你可以在 event 对象中找到的 请求 头部动态编写 响应 头部。如果其来源包含在预定义的白名单中,你可以使用相应的来源构建 response 对象。

移除服务

完成此示例后,我们可以删除我们的测试函数和 API。remove 命令将删除创建的所有 AWS 资源,但会保留项目文件。语法相当简单:

 serverless remove

如果你已将服务部署到当前 serverless.yml 文件版本中未配置的阶段或区域,你可以使用 --stage--region 选项来选择性地删除它们:

 serverless remove --stage production --region eu-west-1

当你向 API Gateway 进行新的部署时,你会收到一个用于组成你的 API 地址的 API 密钥,例如,https://[key].execute-api.[region].amazonaws.com。这个密钥很重要,并将保存在我们的前端代码中。如果你删除服务并重新创建它们,将生成一个新的密钥,前端密钥需要更新。

超越基础

在本节中,我们将探讨我们可以使用 Serverless Framework 做些什么。

使用 npm 包

当你使用 Serverless Framework 部署你的 Lambda 函数时,它会创建一个包含项目文件夹内所有内容的 ZIP 文件。如果你需要使用不是 Node.js 核心模块或 AWS SDK 的模块,你只需使用 Node 的默认工作流程来添加依赖项。

看看以下步骤:

  1. 创建一个 package.json 文件来存储你的项目依赖项,并使用 npm install <your-module> --save 下载你的所需模块。

  2. 在你的项目目录中包含 node_modules 文件夹时,ZIP 文件将带有必要的依赖项部署到 AWS。

  3. 在以下示例中,文件 handle.js 的 Lambda 函数使用了一个名为 cat-names 的 npm 模块:

        module.exports.catNames = (event, context, callback) => {

          const catNames = require('cat-names');

          const response = {
            statusCode: 200,   
            body: JSON.stringify({
              message: catNames.random()
            })
          };

          callback(null, response);
        };

  1. 框架会压缩项目文件夹内找到的所有内容,除了你在 serverless.yml 文件中配置为忽略的内容。以下示例使用 package 配置来移除一些在项目文件夹中常见但不应包含在 ZIP 文件中的文件:
service: cat-names
        provider:
          name: aws
          runtime: nodejs6.10
        functions:
          catNames:
            handler: handler.catNames
 package:
 exclude:
 - package.json
            - event.json
            - tests/**
 - LICENSE
 - README.md

默认情况下,隐藏的文件和文件夹不包括在 ZIP 包中,例如,.gitignore 文件和 .serverless 文件夹,它们是 serverless 项目的组成部分,不需要显式排除。

  1. 要测试,只需使用以下命令部署并调用 catNames 函数:
 serverless deploy 
 serverless invoke --function catNames

访问其他 AWS 资源

默认情况下,Lambda 函数执行时没有任何权限。如果您想访问 S3 桶、DynamoDB 表或任何类型的 Amazon 资源,您的用户必须有权访问它们,并且您必须为您的服务明确授予权限。

此配置在 serverless.yml 文件中的 provider 标签下完成。以下示例展示了如何为 S3 桶授予权限:

    provider:
      name: aws
      runtime: nodejs6.10
 iamRoleStatements:
 - Effect: "Allow"
 Action:
 - 's3:PutObject'
 - 's3:GetObject'
 Resource: "arn:aws:s3:::my-bucket-name/*"

要测试此语句,我们可以修改我们的 handle.js 文件,使用以下代码写入和读取文件:

    module.exports.testPermissions = (event, context, callback) => {

      const AWS = require('aws-sdk');
      const s3 = new AWS.S3();
      const bucket = 'my-bucket-name';
      const key = 'my-file-name';
      const write = { 
        Bucket: bucket, 
        Key: key, 
        Body: 'Test' 
      };

      s3.putObject(write, (err, data) => {
        if (err) return callback(err);

        const read = { Bucket: bucket, Key: key };
        s3.getObject(read, (err, data) => {
          if (err) return callback(err);

          const response = {
            statusCode: 200,   
            body: data.Body.toString()
          };

          callback(null, response);
        });
      });
    };

在此示例中,我们将包含 Test 字符串的文件写入一个桶中,并在写入完成后,读取相同的文件并返回其内容到我们的响应中。

事件

Serverless Framework 当前支持以下事件:

  • Amazon API Gateway:通过触发 Lambda 函数通过 HTTP 消息创建 RESTful 接口

  • Amazon S3:当添加新文件或删除文件时触发后处理函数

  • Amazon SNS:使用 Lambda 函数处理 SNS 通知

  • Schedule:根据计划任务触发函数

  • Amazon DynamoDB:当向表中添加新条目时触发函数

  • Amazon Kinesis:使用 Lambda 函数处理 Kinesis 流

  • Amazon Alexa:使用 Alexa 技能触发函数

  • AWS IoT:处理发送到 IoT 主题的消息

  • Amazon CloudWatch:使用 Lambda 函数处理 CloudWatch 事件和日志消息

在此列表中,只有两个我们尚未见过的服务。第一个是 Amazon Kinesis,这是一个创建用于处理和分析由不同来源生成的流数据的服务的服务,另一个是 Amazon Alexa,这是亚马逊的智能个人助理。这两个服务都超出了本书的范围。

我们不会涵盖所有事件类型,因为列表很长,并且每个都需要不同的配置。您可以在官方文档中查看如何使用它们:serverless.com/framework/docs/providers/aws/events。在本章中,我们已经通过为 Lambda 函数创建端点来举例说明了 API Gateway。现在,我们将查看两个更多示例:一个用于 Amazon S3,以查看与上一章示例相比创建 S3 事件有多容易,另一个示例是调度触发器,这在运行计划任务时非常有用。

S3 事件

在上一章中,我们配置了 S3,当向桶中添加新文件且其名称符合某些规则时,触发 Lambda 函数。可以使用以下配置在我们的 serverless.yml 文件中应用相同的配置:

    functions:
      processLog:
        handler: handler.processLog
        events:
          - s3:
              bucket: my-bucket-name
              event: s3:ObjectCreated:*
              rules:
                - prefix: logs/
                - suffix: .txt

桶名称需要是新的。由于限制,您不能向现有桶添加事件。

调度事件

Lambda 执行的调度是一个对许多用例非常重要的功能。通过修改 serverless.yml 文件并使用 schedule 事件,这个设置可以很容易地通过框架完成。在下一个示例中,processTask 函数将每 15 分钟执行一次:

    functions:
      processTask:
        handler: handler.processTask
        events:
          - schedule: rate(15 minutes)

此设置接受 ratecron 表达式。

在以下顺序中,cron 语法由六个必需字段组成:分钟 | 小时 | 月份中的日期 | 月份 | 星期几 | 年份。在下一个示例中,cron 表达式用于安排一个函数在星期一到星期五上午 9:00(UTC)运行:

    - schedule: cron(0 9 ? * MON-FRI *)

查看以下链接以获取有关此设置的更多详细信息:

docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html

使用 Lambda 提供 HTML 页面

一个非常常见的误解是 Lambda 只是为服务 JSON 数据而设计的。这并不正确。由于我们控制响应结果,我们可以正确设置头部来服务 HTML 内容。以下代码展示了这一点:

    module.exports.hello = (event, context, callback) => {

      const html = `
        <!DOCTYPE html>
        <html>
          <head>
            <title>Page Title</title>
          </head>
          <body>
            <h1>Hello</h1>
          </body>
        </html>`;

      const response = {    
        statusCode: 200, 
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Content-Type': 'text/html'
        }, 
        body: html
      };

      callback(null, response);
    };

这种方法对于服务器端渲染非常有用。在 第五章,构建前端,我们将讨论使用客户端渲染的单页应用程序和使用服务器端渲染的传统 Web 应用程序。Serverless 支持这两种模型,开发者需要根据他们的用例选择最佳选项。

使用配置变量

Serverless 框架允许我们在 serverless.yml 配置文件中使用变量。这种灵活性在集中配置时非常有用,这些配置可以在多个地方引用。

使用变量的选项有很多。让我们通过编辑我们的配置文件来尝试它们:

  • 引用环境变量

查看以下代码片段中使用的环境变量:

        provider:
          name: aws
          runtime: nodejs6.10
          stage: ${env:DEPLOYMENT_STAGE}

  • 从 CLI 选项加载变量

查看以下代码片段中使用的局部变量:

        iamRoleStatements:
          - Effect: "Allow"
             Action:
              - 's3:PutObject'
              - 's3:GetObject'
            Resource: "arn:aws:s3:::{opt:bucket-name}/*"

  • 将变量存储在另一个配置文件中

查看以下代码片段中在其他文件中定义的变量的使用情况:

        functions:
          hello:
            handler: handler.hello
            events:
              - schedule: ${file(my-vars.yml):schedule}

插件

Serverless 框架提供的一个有趣的功能是它可以通过插件进行扩展。您可以使用插件来创建新的 CLI 命令或功能,这些命令将通过挂钩到现有命令来执行。

为了展示它们如何有用,我们将测试一个支持使用 TypeScript 进行 Lambda 开发的无服务器插件。当我们执行 deploy 命令时,该插件将编译代码并创建一个 JavaScript 版本,该版本将被压缩并由 Lambda 使用 Node.js 运行时使用。

要将此插件添加到我们的项目中,我们需要按照以下步骤进行:

  1. 使用 npm 安装插件:
 npm install serverless-plugin-typescript --save-dev

  1. 将插件引用添加到我们的 serverless.yml 文件末尾:
        plugins:
          - serverless-plugin-typescript

  1. 编写一个 TypeScript 文件,并将其保存为 handler.ts,如下面的代码所示:
 export async function hello(event, context, callback) {

          const response = {
            statusCode: 200,   
            body: JSON.stringify({
              message: 'Hello, TypeScript!'
            })
          };

          callback(null, response);
        }

  1. 使用以下命令进行部署和测试:
 serverless deploy
        serverless invoke --function hello

显示部署信息

如果你想知道部署了哪些函数以及它们的相关端点,你可以使用info命令:

 serverless info

以下截图显示了此命令的输出:

搭建

搭建是一种帮助开发者的技术,它通过提供一个常见问题的示例解决方案来帮助开发者。使用样板代码,你可以构建一个新的项目,利用一些功能已经配置、开发和经过良好测试的事实。你开始修改解决方案以满足自己的需求,遵循比你在这个技术方面更有经验的人推荐的实践,并使用被许多人使用、测试和改进的代码。这是使用开源项目的优势。此外,这也是通过模仿学习新技术的一个有用方法。你通过观察别人如何解决你想要解决的问题来学习。

让我们按照以下步骤搭建一个项目:

  1. 要搭建一个项目,请运行以下命令:
 serverless install --url <github-project-url>

  1. 例如,你可以运行以下命令来搭建一个用于发送电子邮件的无服务器服务:
 serverless install \
          --url https://github.com/eahefnawy/serverless-mailer

无服务器框架团队维护了一个包含大量有用示例的列表。您可以通过访问github.com/serverless/examples来查看。

  1. 这本书的目标是构建一个示例无服务器商店。你可以在这个目标下在 GitHub 上找到所有开发的代码github.com/zanon-io/serverless-store。该项目也可以使用相同的命令进行搭建:
 serverless install \
          --url https://github.com/zanon-io/serverless-store

在此命令中添加了反斜杠(\)字符,因为命令无法在一行中显示。这是可选的,适用于 Linux 和 macOS。在 Windows 上,相应的符号是^(符号)。

摘要

在本章中,你学习了无服务器框架是什么以及它将如何帮助我们构建无服务器应用程序。在配置框架后,你创建了一个 hello-world 服务,添加了端点,启用了 CORS,并将其部署为可通过公共 URL 访问。你还学习了如何添加 npm 包和访问 AWS 资源。

在下一章中,我们将根据无服务器概念托管前端。这将是使用 Amazon S3 完成的,我们将配置一个 CloudFront 分发,以添加一个免费的 TLS 证书来支持 HTTPS 连接。

第四章:托管网站

前端是过渡到无服务器架构最容易的一层。您只需要一个服务来托管您网站的静态文件,用户的浏览器将下载文件,渲染页面并执行客户端 JavaScript 代码。在 AWS 上,我们将使用 Amazon S3 来托管前端页面。在本章中,您还将学习如何通过添加 CDN 和支持的 HTTPS 连接来配置和优化您的网站。

总结来说,我们将涵盖以下主题:

  • 使用 Amazon S3 托管静态文件

  • 配置 Route 53 以将您的域名与 S3 关联

  • 使用 CloudFront 通过 CDN 提供文件服务

  • 请求免费的 SSL/TLS 证书以支持 HTTPS 连接

在本章之后,您将学会如何在无服务器基础设施中托管前端。

使用 Amazon S3 提供静态文件服务

Amazon S3 非常有用,因为它是一个提供高可用性和可扩展性且成本低的低成本服务,无需任何管理努力。基础设施完全由 AWS 管理。在本节中,我们将使用 S3 来托管我们的网站静态文件,如 HTML、CSS、JavaScript 和图像。您将看到这是通过将文件上传到存储桶并配置 S3 以启用网站托管来完成的。

除了托管静态网站,您还可以托管复杂的应用程序。您只需要有一个清晰的关注点分离:前端文件(HTML/CSS/JavaScript)将托管在 S3 上,并由浏览器用于渲染网页并请求发送到后端代码的额外数据,该代码将由 Lambda 函数托管和执行。正如我们将在第五章,“构建前端”中讨论的,您可以将前端构建为一个单页应用程序(SPA)。这需要浏览器来渲染页面。或者,您可以使用 Lambda 来提供服务器端渲染的页面。

创建存储桶

在第二章,“AWS 入门”中,我们使用 CLI 创建了一个存储桶。在本节中,我们将使用管理控制台创建另一个存储桶,以便您可以看到 S3 提供的配置选项。

让我们执行以下步骤来创建一个存储桶:

  1. 第一步是浏览 S3 控制台console.aws.amazon.com/s3并选择创建存储桶选项:

图片

  1. 我们正在创建一个新的存储桶来托管网站。如果您没有域名,您仍然可以使用 S3 来托管网站。您将需要通过以下格式的 URL 访问它:http://my-bucket-name.com.s3-website-us-east-1.amazonaws.com。如果您有一个域名,例如example.com,您必须将存储桶名称设置为与您的域名相同的名称,在这种情况下,example.com。您必须匹配域名和存储桶名称,以便 Route 53 能够建立关联。至于区域,请选择离您的目标受众更近的区域:

  1. 设置属性屏幕允许您添加版本控制、日志记录和标签。然而,这些对于我们的示例不是必需的,可以跳过:

  1. 在设置权限屏幕中,在管理公共权限下,选择授予此存储桶公共读取访问权限的选项。点击下一步继续:

  1. 通过检查选定的选项并点击创建存储桶来完成:

启用网站托管

要在此存储桶中启用网站托管,请执行以下步骤:

  1. 点击您的存储桶名称,并从第二个标签中选择属性:

  1. 在静态网站托管卡中,点击禁用按钮以启用选项:

  1. 现在选择使用此存储桶托管网站选项,并将索引文档和错误文档的值设置为index.html。此外,请注意,您可以在这张图片中看到存储桶的端点地址。在这个例子中是http://example.com.s3-website-us-east-1.amazonaws.com。请记下来,因为您稍后在进行测试和配置 CloudFront 分发时需要它:

  1. 点击保存以完成此设置。

处理错误

使用 S3 处理错误有四种常见方式。我将解释您拥有的选项,但在这样做之前,让我定义一下我所说的“处理错误”是什么意思。当用户发起一个 HTTP 请求时,S3 可能无法获取答案,并将返回一个状态码来解释失败。一个可能的 HTTP 500 内部服务器错误可能是一个结果,但它会非常罕见和奇怪。然而,S3 返回 404 未找到或 403 禁止错误相当常见。

404 未找到 是当用户浏览您的网站寻找不存在的页面时返回的错误。例如,如果您有一个名为 company.io/about 的页面,而用户错误地浏览了 company.io/abot,S3 将无法找到 abot.html 文件并返回 404 错误。另一个例子是当您使用 JavaScript 框架创建一个 单页应用(SPA)时。尽管您的前端代码知道如何服务 /about 页面,但框架不会创建一个物理的 about.html 文件上传到 S3,即使浏览 company.io/about 也会返回 404 错误。

我们将在第五章 构建前端中讨论单页应用(SPA)。目前,请注意,一个 SPA 可以服务多个页面,但只有一个完整的 HTML 文件,名为 index.html 文件。

403 禁止访问 是当存储桶有受限权限时发生的错误。如果存储桶不允许所有人访问,或者有特定的文件有受限访问权限,将返回 403 错误。在我看来,我更喜欢将存储桶中的所有文件都视为公开。如果有页面不应该公开,显示 HTML 文件不应成问题。目标是保护 数据 而不是 布局。设置授权和数据可见性需要在服务器端处理,而不是客户端。此外,如果有必须保持私密的静态文件,例如照片,您可以将它们保存在另一个存储桶中,而不是重复使用创建来托管网站的存储桶。这些 照片 可以被视为 数据,同样,需要由后端特别关注来控制。

考虑到存储桶中的所有文件都将保持公开,并且我们不必过于担心奇怪的 S3 错误,我们现在需要处理的唯一问题是 404 错误。

使用重定向规则

处理单页应用(SPA)的 404 错误的一个非常常见的方法是在静态网站托管页面中使用 ReplaceKeyPrefixWith 选项添加重定向规则:

    <RoutingRules>
      <RoutingRule>
 <Condition>
 <HttpErrorCodeReturnedEquals>
 404
 </HttpErrorCodeReturnedEquals>
 </Condition>
        <Redirect>
          <Hostname>
            example.com
          </Hostname>
 <ReplaceKeyPrefixWith>
 #!/
 </ReplaceKeyPrefixWith>
        </Redirect>
      </RoutingRule>
    </RoutingRules>

使用此解决方案,当用户浏览 company.io/about 时,地址将被替换为 company.io/#!/about,并返回 index.html 文件。如果您有一个单页应用(SPA),它能够识别需要显示的页面并正确渲染。如果页面不存在,它将能够渲染一个通用的 404 页面。此外,您还可以配置 HTML5 的 pushState 在页面加载后移除哈希符号(#!),但这会使页面闪烁。

这种方法的缺点是您需要在带有 #! 的污染 URL 和页面加载时页面闪烁之间做出选择。

使用错误文档

你可以设置一个带有index.html的错误文档,而不是设置重定向规则。这是 SPAs 的最简单解决方案。当用户请求一个没有物理about.html文件匹配的/about页面时,将加载index.html文件,SPA 读取地址并理解它需要提供/about页面的内容。

这就是我们在上一张图片中做的配置,它工作得相当好,没有在 URL 地址中污染#!,也没有在加载时使页面闪烁。然而,搜索引擎可能会拒绝索引你的网站,因为当浏览/about页面时,结果的body消息将使用正确的页面设置,但status code仍然设置为 404。如果谷歌爬虫看到 404 错误,它将理解页面不存在,页面内容可能是一个通用的错误页面。

委派给 CloudFront

你可以将错误文档设置为index.html,就像之前的解决方案一样,但使用 CloudFront 而不是 S3。CloudFront 提供了一个自定义错误响应设置,允许你更改response对象的status code。在这种情况下,我们可以配置 CloudFront 在 404 错误上操作,返回 S3 的index.html文件,并将status code修改为200 *OK*而不是返回 404。

如果你选择这样做,问题是你将返回状态码 200,即使对于不存在的页面。

预渲染页面

其他解决方案的另一个问题是搜索引擎优化SEO)没有被考虑,因为它们需要在浏览器中启用 JavaScript 才能渲染正确的页面。由于大多数网络爬虫无法执行 JavaScript 代码,它们将无法索引网页。这个问题通过预渲染得到了解决。

在下一章中,你将学习如何预渲染页面。这项技术为 SPA 路由创建物理页面。例如,在一个 SPA 中,/about页面没有about.html文件,但通过预渲染,你将能够创建它。

当你预渲染所有可能的页面并将文件上传到 S3 时,就无需担心 404 错误。如果页面存在,S3 将找到它的 HTML 文件。我们仍然需要将错误文档配置为index.html来处理不存在的页面,但我们不需要配置 CloudFront 强制状态码 200。

支持 www 锚点

没有 www 锚文本的域名通常被称为裸域名。例如,www.example.com是一个带有 www 锚点的域名,而example.com是一个裸域名。规范 URL是你选择作为主要地址的选项。

在决定哪个地址应该是你的网站主地址时,你需要考虑利弊。使用裸域名的一个问题是你如果网站有 cookies,那么将静态文件放在子域名中,例如static.example.com,将不会得到优化,因为每个浏览器对静态文件的请求都会自动发送为example.com创建的 cookies。如果你的登录发生在www.example.com,你可以在static.example.com内部放置静态内容,而不必担心 cookies。

通过创建另一个域名或使用 CDN 来检索静态文件可以减轻这个问题。尽管如此,当前的潮流是放弃 www 锚点。将你的科技公司品牌化为company.io看起来比使用旧的www.company.com格式更现代。

选择你的主要地址,但支持两种格式。有些人习惯于在地址中添加 www,而有些人可能会忘记包含它。在先前的例子中,我们创建了一个没有 www 地址的域名。现在我们将创建另一个以www.example.com格式的存储桶,并设置静态网站托管配置,将重定向请求选项设置为指向没有 www 的地址:

图片

上传静态文件

上传操作相当直接。让我们执行以下步骤来上传静态文件:

  1. 首先,为了测试目的,创建一个非常简单的index.html文件:
        <!DOCTYPE html>
        <html>
            <title>My Page</title>
        <body>
            <h1>Hosting with S3</h1>
        </body>
        </html>

  1. 当你点击主存储桶名称时,会出现一个上传按钮。点击它:

图片

  1. 下一屏幕很简单。点击添加文件,选择index.html文件,然后通过点击下一步继续:

图片

  1. 在设置权限步骤中,在管理公共权限下,选择授予此对象(s)公共读取访问权限的选项:

图片

  1. 在设置属性步骤中,保持存储类为默认选项标准,因为文件会被频繁访问。同时,将加密设置为无,因为所有文件都将公开可用,因此,添加额外的安全层是不必要的,并且只会减慢你的响应速度。至于元数据字段,你不需要设置它们:

图片

  1. 最后,检查选项,并通过点击上传来确认。

  2. 现在,你可以通过浏览存储桶的端点来检查它是否工作,例如,http://example.com.s3-website-us-east-1.amazonaws.com

如果第一次尝试不成功,请清除浏览器缓存并再次尝试,因为在你上传文件之前测试端点时,浏览器可能会缓存表示链接无效的信息。这可能会避免短时间内进一步的请求。

自动化网站发布

我们已经通过 S3 控制台完成了上传我们网站前端所需的步骤。这既简单又快捷,但也很容易自动化这个任务。如果你还没有按照第二章《AWS 入门》中的说明配置 AWS CLI,请现在就配置它,看看自动化文件上传有多有用。实际上,我们要做的是将存储桶内容与本地文件夹同步。CLI 只会上传已修改的文件,这将在你的网站未来增长时使上传速度大大加快。

你可以使用以下命令上传你的文件:

aws s3 sync ./path/to/folder s3://my-bucket-name --acl public-read

服务器端 gzip 文件

gzip 文件格式是 Web 上用于压缩文件并通过减少传输文件大小来提高下载速度的标准格式。它可以通过提供更小的文件来降低你和你用户的带宽成本。这种方法对加载网站时的感知性能有巨大影响。

它目前被每个主要浏览器支持。默认情况下,对于每个请求,浏览器都会添加一个Accept-Encoding: gzip头。如果服务器支持 gzip,则文件将以压缩形式发送。

以下图表显示了没有 gzip 支持的 HTTP 请求:

以下图表显示了使用 gzip 可以节省多少带宽。压缩后的响应通常比未压缩的文件小 10 倍:

使用这种格式,服务器需要 CPU 时间来压缩文件,用户的浏览器需要 CPU 周期来解压缩相同的文件。然而,随着现代 CPU 的发展,压缩/解压缩所需的时间远低于通过网络发送未压缩文件所花费的额外时间。即使对于低端移动设备,CPU 的速度也比移动网络快得多。

然而,有一个问题。由于我们没有在 Amazon S3 上进行服务器端处理,因此没有选项可以原生地根据请求压缩文件。你需要在上传文件并设置元数据以标识Content-Encodinggzip之前本地压缩它们。幸运的是,如果你使用 CloudFront,你可以跳过在部署工作流程中包含此步骤的麻烦。正如我们稍后将要看到的,CloudFront 有一个选项可以自动使用 gzip 压缩所有文件。

设置 Route 53

Route 53 是一个 DNS 管理服务。如果你想要暴露子域名,例如www.example.com,则不需要使用它,但是如果你想在 S3 或 CloudFront 上托管裸域名,例如example.com,则确实有必要使用它。这是由于 RFC 规则:你的域名根不能有 CNAME 记录,它必须是 A 记录。

有什么区别?CNAME 和 A 记录都是帮助 DNS 系统将域名转换为 IP 地址的记录类型。虽然 CNAME 引用另一个域名,但 A 记录引用一个 IP 地址。

因此,如果您不想使用 Route 53,您可以使用自己的域名管理系统,例如 GoDaddy,添加一个 CNAME,将您的www.example.com域名映射到 S3 端点,例如,www.example.com.s3-website-us-east-1.amazonaws.com。这种配置效果良好,但您不能对example.com做同样的操作,因为example.com.s3-website-us-east-1.amazonaws.com端点的 IP 地址会频繁变化,而您的第三方域名控制器(例如本例中的 GoDaddy)不会跟随这些变化。

在这种情况下,您需要使用 Route 53,因为它将允许创建一个引用您的 S3 桶端点的 A 记录,例如example.com.s3-website-us-east-1.amazonaws.com。您只需说明此端点是别名,Route 53 就能跟踪正确的 IP 地址以回答 DNS 查询。

创建托管区域

如果您在亚马逊注册了您的域名地址,托管区域将自动创建。如果您在其他供应商处注册了您的域名,您将需要创建一个新的托管区域。

托管区域允许您配置您域的 DNS 设置。您可以设置您的裸域名和子域名托管在哪里,并配置其他参数,例如,一个邮件交换记录集。

要创建托管区域,请执行以下步骤:

  1. 浏览 Route 53 管理控制台console.aws.amazon.com/route53。您将看到一个欢迎屏幕,点击立即开始。

  2. 在下一屏幕中,点击左侧菜单中的托管区域,然后点击创建托管区域按钮:

图片

  1. 输入域名并确认:

图片

  1. 将创建一个托管区域,包含两种记录类型,即NS(名称服务器)和SOA(权威开始):

图片

  1. 如果您已在其他供应商处注册了您的域名,您必须将 DNS 管理转移到 Route 53。这是通过更改您的域名注册商名称服务器(NS 记录)到亚马逊的名称服务器来完成的。

  2. 您的注册商可能为您提供的域名控制面板中有一个选项,如管理 DNS。找到名称服务器所在的位置,并编辑它们以使用亚马逊的服务器。

创建记录集

现在让我们创建两个记录集,例如一个用于example.com,另一个用于www.example.com。通过以下步骤执行此操作:

  1. 点击创建记录集:

图片

  1. 在第一条记录中,设置以下参数:

    • 名称:留空此字段

    • 类型:在此字段中,选择 A - IPv4 地址

    • 别名:选中此字段

    • 别名目标:此字段是一个下拉列表,您可以在其中选择您的存储桶的 S3 端点:

图片

  1. 创建另一个记录集。这次,使用以下参数值:

    • 名称:将此字段设置为www

    • 类型:在此字段中选择 CNAME - 规范名称

    • 别名:不选中此字段

    • 值:将此字段输入框填写为 S3 存储桶的端点:

图片

  1. 现在通过在浏览器地址栏中输入您的域名来测试您的域名。您将看到您已上传到 S3 存储桶的index.html文件。

如果您已将 DNS 控制权从其他供应商转移到 AWS,那么由于 DNS 缓存,您可能需要等待几分钟甚至几小时才能完成转移。只有在转移完成后,您才能看到托管在 Amazon S3 上的文件。

设置 CloudFront

CloudFront 作为内容分发网络CDN)提供静态文件。在用户附近保留文件副本可以减少延迟并提高您感知的网站速度。另一个我们将在后面讨论的功能是支持 HTTPS 请求。

在下一节中,我们将创建一个 CloudFront 分发并调整 Route 53 设置以使用 CloudFront 而不是 S3 存储桶。

创建分发

CloudFront 分发使得将 DNS 配置(Route 53)与 CloudFront 关联起来以分发静态内容成为可能。分发需要一个源服务器来知道文件存储的位置。在我们的案例中,源将是之前配置的 S3 存储桶。

让我们执行以下步骤来创建 CloudFront 分发:

  1. 浏览console.aws.amazon.com/cloudfront的 CloudFront 管理控制台并点击创建分发:

图片

  1. 下一步是选择分发类型。对于我们的网站,在 Web 选项下选择入门:

图片

  1. 下一屏是一个需要我们填写的大表单。在第一个字段集中,源设置,源域名选项将提供一个 S3 端点的下拉列表。

您不应使用此处提供的端点,因为在使用此地址时,某些 S3 配置,如重定向或错误消息,将不可用。

相反,请在静态网站托管设置中的存储桶属性中使用的端点。这些 S3 端点之间的区别在于建议的端点没有存储桶区域(例如,example.com.s3.amazonaws.com),而我们将要使用的端点确实有区域(例如,example.com.s3-website-us-east-1.amazonaws.com)。在您提供源域名后,源 ID 将自动设置。请将源路径和源自定义头留空:

图片

  1. 在默认缓存行为设置中,设置以下参数:

    • 观众协议策略为 HTTP 和 HTTPS

    • 允许的 HTTP 方法为所有 HTTP 动词选项

    • 缓存的 HTTP 方法 带有 OPTIONS 选中

    • 以使用源缓存头作为对象缓存

图片

  1. 将本节中其余字段保留为默认值,除了自动压缩对象选项。此功能用于按需使用 gzip 压缩文件。正如本章已讨论的,Amazon S3 不提供自动压缩,但 CloudFront 提供。你只需要设置“是”选项:

图片

  1. 在分布设置中,设置以下参数:

    • 根据你的目标受众和愿意承担的成本(更好的性能意味着更高的成本)设置价格类别。

    • 使用裸域名和 www 域名设置替代域名(CNAMEs),域名之间用逗号分隔。

    • 在 SSL 证书中,选择默认的 CloudFront 证书(*.cloudfront.net)。一旦我们颁发自己的证书,我们将回到这个选项。

    • 将其余字段保留为默认值。

图片

  1. 现在点击创建分布。

  2. CloudFront 需要在几分钟内将分布配置复制到所有边缘点之间,但你可以在 CloudFront 控制台主页面中跟踪状态。完成后,它将显示状态为已部署。在下面的屏幕截图中,你可以看到 CloudFront 分布地址。将此链接复制到浏览器中并测试以检查它是否正常工作:

图片

处理 CloudFront 缓存

默认情况下,CloudFront 将缓存所有文件 24 小时。这意味着如果你修改了 S3 存储桶中的文件,你通过 CloudFront 分布浏览时将看不到任何变化。强制在浏览器中重置缓存不会有所帮助,因为它是服务器端缓存。那么,处理缓存的推荐做法是什么?你有以下两种选择:

  1. 服务器端:创建缓存失效请求

  2. 客户端:在更改静态文件内容时给所有文件添加后缀

使服务器端缓存失效

向 CloudFront 创建缓存失效请求需要一些时间来处理,因为 CloudFront 需要联系所有边缘位置并请求它们清除各自的缓存。

由于这将是一个重复的任务,我不建议你使用 CloudFront 控制台。使用 CLI 更好。然而,目前 CloudFront 的 CLI 支持仅处于预览阶段。因此,你可能需要通过运行以下命令来启用它:

 aws configure set preview.cloudfront true

要为所有文件(路径 /*)创建缓存失效请求,执行以下命令:

 aws cloudfront \
      create-invalidation --distribution-id=DISTRIBUTION_ID --paths "/*"

你可以通过查看 CloudFront 控制台或运行以下 CLI 命令来找到你的 CloudFront 分布的DISTRIBUTION_ID

 aws cloudfront list-distributions

你可以将 --query DistributionList.Items[0].Id 添加到之前的命令中,以便只输出第一个分布的分布 ID。

这种解决方案需要很长时间才能使失效生效,并且它不能解决客户端缓存的问题。

使客户端缓存失效

当您浏览网页时,浏览器会缓存所有下载以显示页面(HTML、CSS、JavaScript、图片等)的文件,以避免在不久的将来需要相同的文件来提高性能。然而,如果您修改了一个文件的 内容,您如何告诉浏览器丢弃之前的缓存内容呢?您不能创建一个失效请求,因为这次缓存是在客户端,而不是服务器端,但您可以通过更改已修改文件的名称来强制浏览器发出新的请求。例如,您可以将文件名从script.js更改为script.v2.js,并在 HTML 页面中使用其新名称:

    <script src="img/strong>"></script>

另一个选项是在 HTML 页面内部声明文件名时添加查询字符串,如下所示:

    <script src="img/strong>"></script>

在这个例子中,文件名没有更改,但引用已更改,这对浏览器来说已经足够,使其认为之前的缓存已失效,并发出新的请求以获取更新的内容。

这两种策略的问题在于您无法缓存 HTML 页面。除了 HTML 页面外,所有其他数据都可以被缓存。否则,客户端将不会理解它应该下载文件依赖项的新版本。

为了上传 HTML 文件,使其永远不会被缓存,您必须在上传文件时将Cache-Control头设置为no-cache。在我们的网站上,在将本地文件夹与存储桶同步后,再次上传index.html文件,但这次,使用cp(复制)命令并添加Cache-Control头:

 aws s3 cp index.html s3://my-bucket-name \
      --cache-control no-cache --acl public-read

这种策略效果相当不错,但它要求您自动化构建过程,以更改所有更改文件的文件名或查询字符串参数。在下一章中,我们将使用“Create React App”工具构建一个 React 应用。幸运的是,这个工具已经配置好了,会更改所有部署的文件名。它添加了随机字符串,例如在main.12657c03.js文件中。

将 Route 53 更新为使用 CloudFront

我们当前的记录集使用 S3 存储桶。您只需回到 Route 53,将其替换为新的 CloudFront 分发即可。对于使用 A 记录类型的裸域名,您需要在下拉菜单中选择别名选项为是,并选择 CloudFront 分发。

对于使用 CNAME 记录类型的www域名,选择别名选项为否。在这种情况下,复制 A 记录中可用的 CloudFront 分发地址,并将其粘贴到 CNAME 记录的框中。

支持 HTTPS

不幸的是,Amazon S3 不支持 HTTPS 连接,它只支持 HTTP。我们已经将 Route 53 记录集设置为使用 CloudFront 分发,但我们还没有在 CloudFront 中启用 HTTPS 支持。

但为什么我们应该支持 HTTPS 呢?现在有很多原因。让我们列举一些:

  • 我们正在建立一个在线商店。我们需要处理登录和支付交易。在没有加密连接的情况下做这些事情是不安全的。很容易窃听网络并窃取敏感数据。

  • HTTP/2 是最新协议,比旧的 HTTP/1.1 版本快得多。目前,所有主要浏览器都支持 HTTP/2,并且要求HTTPS。在未加密的 HTTP 连接上支持 HTTP/2 是不可能的。

  • 加密的 HTTP/2 比未加密的 HTTP/1.1 更快。Troy Hunt 在这个链接中展示了有趣的演示:www.troyhunt.com/i-wanna-go-fast-https-massive-speed-advantage。在他的测试中,由于新协议的多路复用功能,使用 HTTP/2 over TLS 加载包含数百个小文件的网站比 HTTP/1.1 快 80%。

  • 另一个很好的理由是隐私。在所有地方使用 HTTPS 有助于保护您的浏览数据安全。这还不够,因为您访问的网站域名将继续被暴露,但它帮助很大。您访问的页面以及您阅读或编写的内容将不会(轻易)受到损害,因为数据总是以加密的形式传输。

如果您确信并想支持 HTTPS,请按照以下步骤操作:

  1. 在 Route 53 中创建一个邮件交换记录。

  2. 向 AWS 请求免费 SSL/TLS 证书。

  3. 编辑 CloudFront 分发以使用此新证书。

第一步,创建邮件账户是必要的,因为 AWS 只有在您证明您拥有该域名时才会发放免费 SSL/TLS 证书,并且这种验证是通过发送到admin@example.com电子邮件地址的链接来完成的。

创建邮件交换记录

在我们向 AWS 请求免费证书之前,我们需要一个处理电子邮件消息的服务。我建议使用 Zoho Mail 作为免费选项(高达 5 GB 空间)。在本节中,我们将通过执行以下步骤来了解如何配置此服务:

  1. 首先,浏览www.zoho.com/mail并注册一个免费的商业电子邮件账户。此账户将与您的域名相关联。在选择管理员账户时,选择名称admin。这个名称很重要,因为 AWS 将通过发送确认电子邮件到admin@example.com来检查您的域名所有权。

  2. 在您创建账户后,您将需要确认关联域的所有权。有几种方法可以证明您的所有权,我更喜欢使用 CNAME 方法。在从列表中选择您的域名 DNS 管理器(DNS 托管提供商)选项中,选择“其他...”,因为 AWS 没有列出。现在,选择 CNAME 方法,CNAME 和目标将显示出来。您需要使用这对组合配置一个新的临时 Route 53 记录集,并完成点击“进行 CNAME 验证”按钮:

图片

  1. 验证后,确认创建admin账户。您可以按顺序添加其他用户。

  2. 下一步是在 Route 53 中配置 MX(邮件交换)记录。复制 Zoho 提供的值:

图片

  1. 返回到 Route 53。删除用于验证 Zoho 账户的 CNAME 记录集,因为它不再必要。现在您需要使用前面的截图中的值创建一个新的 MX 类型记录集:

图片

  1. 我们完成了。您可以通过发送电子邮件到这个新地址并检查您的 Zoho 电子邮件账户中收到的电子邮件来测试它是否正常工作。

使用 AWS 证书管理器请求免费证书

让我们通过以下步骤来了解如何使用 AWS 证书管理器请求免费证书:

  1. console.aws.amazon.com/acm/home?region=us-east-1请求 TLS 证书。

您需要位于 us-east-1,因为 CloudFront 只使用该区域的证书。

  1. 在欢迎屏幕上,点击“开始”。在下一个屏幕上,输入您的裸域名和 www 版本,然后点击“审查和请求”:

图片

  1. 在下一个屏幕上,您只需点击“确认”并请求。亚马逊将通过发送电子邮件到admin@example.com来尝试证明您的域名所有权。如果您在上一节中正确配置了您的电子邮件账户,您将在 Zoho 收件箱中收到一封电子邮件。

  2. 该电子邮件有一个确认链接,您必须点击以证明您的所有权。之后,亚马逊将颁发一个新的 TLS 证书,该证书将可用于您的账户。

您无需担心证书到期日期。AWS 将在必要时自动续订。

配置 CloudFront 以支持 HTTPS 连接

支持 HTTPS 的最后一步是编辑 CloudFront 分发以使用新证书。要执行此任务,请查看以下步骤:

  1. 浏览 CloudFront 管理控制台console.aws.amazon.com/cloudfront,并打开您的分发。

  2. 在“常规”选项卡下,点击“编辑”选项。

  3. 点击“自定义 SSL 证书(example.com)”选项,并使用下拉按钮选择您的域名证书:

图片

  1. 保存以返回上一页,然后点击第三个选项卡“行为”,并点击“编辑”来编辑现有行为。

  2. 现在我们可以将查看器协议策略参数更改为将 HTTP 重定向到 HTTPS,以强制用户始终使用 HTTPS:

图片

  1. 更改这些设置后,CloudFront 将自动将新配置部署到所有边缘节点。

  2. 等待几分钟之后,您可以通过浏览您的域名来确认 HTTPS 支持。

摘要

在本章中,你学习了如何配置 S3、CloudFront 和 Route 53 来托管无服务器前端解决方案。现在,你的网站已经分布在全球各地,以降低延迟并提高速度,同时提供 HTTPS 支持,使网络更加安全和私密。

在下一章中,我们将使用 React 作为单页应用(SPA)来构建我们的无服务器商店应用的前端。

第五章:构建前端

在本章中,我们将构建我们的演示应用的网络页面。这里的目的是不是教授前端开发,而是展示你可以使用现代工具以及无服务器。对于这个演示,我们将使用 React,但你也可以使用 Angular、Vue.js 或其他任何工具,并且仍然可以利用无服务器的功能。此外,我们将从无服务器的角度讨论 SPA 的优缺点,并看看我们如何预渲染 SPA 页面以优化搜索引擎优化SEO)。

在本章中,我们将涵盖以下主题:

  • 如何使用 React 构建我们的网页

  • 单页应用(SPA)的优缺点

  • 预渲染页面以优化 SEO

在本章之后,你将已经构建了我们无服务器在线商店的前端。

React 入门

教授前端工具不是本书的目标,但我们需要构建一些有用的东西来展示无服务器如何处理现代前端开发。在这里,我们将使用 React,因为它目前是最受欢迎的工具之一。如果你不知道 React 是什么或者如何使用它,我会引导你理解基本概念。

React 原则

你应该注意的第一件事是,React 是一个库而不是一个框架。区别在于,一个库提供了一套功能来解决一个特定的问题,而一个框架提供了一套围绕特定方法的库。

React 只负责应用视图层。这就是 React 解决的问题。如果你需要执行 Ajax 调用或处理页面路由,你需要添加其他库。当你用 React 开发时,你需要以组件的方式思考。你的用户界面是由简单的组件组成的,其中每个组件都有一个内部状态和 HTML 定义。在使用 React 时,你不会直接操作网页。你改变组件的状态,React 会重新渲染它以匹配当前状态。这种方法促进了可预测性。对于给定的状态,你总是知道组件将如何渲染。这对于测试和维护复杂的应用程序非常重要。

另一个重要的概念是虚拟 DOM。文档对象模型DOM)是 HTML 页面所有节点的表示。如果页面上有变化,你需要渲染不同的视图,DOM 就需要被操作。问题是当你有数百个节点时。重新创建整个视图会有性能成本,这是用户可以感知到的。

虚拟 DOM 是真实 DOM 的抽象版本。React 跟踪所有组件的状态,并知道其中任何一个何时被修改。它不会重新渲染整个视图,而是比较修改后的虚拟 DOM 和真实 DOM,并制作一个只包含差异的小补丁。这个补丁以更好的性能应用。

总结来说,你需要知道 React 是一个,其特定目的是处理视图层,它基于组件,其中每个组件都有一个内部的状态视图定义,你不能直接修改 DOM,因为这属于虚拟 DOM 的责任。

The Flux pattern

Flux 是一种用于应用程序状态管理的模式,Redux 是最受欢迎的 Flux 灵感实现。如果你正在构建一个复杂的 React 应用程序,你应该学习 Redux 或另一个类似 Flux 的框架。然而,你可能不需要 Redux,正如 Redux 的创造者 Dan Abramov 在他的博客中提到的(medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367):“人们经常在他们需要 Redux 之前就选择了 Redux。”

Redux 是一个优秀的框架,但它增加了项目的复杂性。由于我们正在构建一个小型的前端应用程序,我们在这里不会使用它,对于具有短组件树的应用程序,这个决定是有意义的。再次强调,本书的目标是专注于无服务器,而不是前端开发,因此 Redux 超出了我们的范围。在现实世界的应用程序中,你需要考虑利弊。大多数时候,你会选择使用 Redux,但并不总是如此。

React hello-world

React 推荐使用 JSX,这是一种将 JavaScript 与 XML 混合的语法。你不需要使用 JSX,但应该使用它来提高代码的可读性。例如,看看下面的 JSX:

    class HelloReact extends React.Component {
      render() {
        return <div>Hello, {this.props.name}!</div>;
      }
    }

    ReactDOM.render(
      <HelloReact name="World"/>,
      document.getElementById('root')
    );

这个例子定义了一个<HelloReact/>HTML 元素,渲染输出将使用name属性的值。如果输入是World,渲染结果将是<div>Hello, World!</div>

然而,浏览器无法执行这段代码,因为 JSX 没有原生支持。你需要使用一个 JSX 编译器,它将这个例子转换成以下 JavaScript 代码:

    class HelloReact extends React.Component {
      render() {
        return React.createElement(
          "div", null,
          "Hello, ", this.props.name, "!"
        );
      }
    }

    ReactDOM.render(
      React.createElement(HelloReact, { name: "World" }),
      document.getElementById('root')
    );

将 JavaScript 代码与 HTML 混合听起来很奇怪,但我们可以习惯它。最终,大多数人会发现它更令人愉快且更容易维护。

要使这段代码工作,我们需要添加两个依赖项,例如 React 和 ReactDOM。前者是核心,它让我们创建组件,后者是库,它渲染组件并将它们附加到 HTML 节点中。

你可以在unpkg.com/react/unpkg.com/react-dom/找到这些依赖项。你将在dist文件夹内找到必要的文件。

以下代码是一个工作的 hello-world 示例:

    <!DOCTYPE html>
    <html>
      <head>
        <title>Hello, World!</title>
      </head>
      <body>
        <div id="root"> <!-- this is where we'll hook React -->
        </div>
 <script src="img/react.min.js"></script>
 <script src="img/react-dom.min.js"></script>
        <script type="text/javascript">
          class HelloReact extends React.Component {
 render() {
 return React.createElement(
 "div", null,
 "Hello, ", this.props.name, "!"
 );
 }
 }

          ReactDOM.render(
 React.createElement(HelloReact, { name: "World" }),
 document.getElementById('root')
 );
        </script>
      </body>
    </html>

构建购物车

要理解 React,我们需要了解 props 和 states 是如何工作的,以及我们如何使用不同的组件来组合一个界面。为了一个实际的例子,我们将构建一个购物车。这个例子将成为我们无服务器商店的基础,现在的目标是实现以下结果:

准备开发环境

React 的一项批评是需要外部工具进行开发。实际上,人们可以使用纯 JavaScript,但正如我们所看到的,JSX 更容易理解。因此,你需要添加到项目中的第一个工具就是一个 JSX 编译器。

当你浏览 React 项目或其他现代 Web 项目时,你也会发现人们使用许多其他工具,例如 Babel(ES6 到 ES5 编译器)、Webpack(模块打包器)、ESLint(代码分析)等。每个工具都有许多竞争对手。例如,你可能更喜欢使用 Browserify 而不是 Webpack。理解和配置这些工具需要很长时间。如果你在学习 React,你首先需要了解 React 是如何工作的,而不是环境是如何配置的。

在这种情况下,Create React App 工具提供了一个有见地的配置,它使用经过验证的工具和实践。你不再需要担心环境,只需遵循他人的建议即可。

查看以下步骤,了解如何使用此工具启动新项目:

  1. 使用以下 npm 命令安装 Create React App 工具:
 npm install create-react-app@1.3.1 --global

@1.3.1 这个术语意味着它将下载用于本书示例的确切版本。如果你愿意,你可以移除这个 @1.3.1 限制以获取最新功能,但这可能会引入对示例的破坏性更改。

  1. 现在,使用以下命令创建一个新的应用程序:
 create-react-app react-shopping-cart

  1. 将目录切换到新文件夹,并使用以下命令启动应用程序:
 cd react-shopping-cart
 npm start

  1. 你可以在 localhost:3000 上看到运行中的应用程序,如下面的截图所示:

图片

组织解决方案

此应用程序将创建以下结构:

    node_modules
    public
      |- favicon.ico
      |- index.html
      |- manifest.json
    src
      |- App.css
      |- App.js
      |- App.test.js
      |- index.css
      |- index.js
      |- logo.svg
      |- registerServiceWorker.js
    .gitignore
    package.json
    README.md

public/manifest.jsonsrc/registerServiceWorker.js 文件用于支持渐进式 Web 应用PWA),这是一个构建快速且更可靠的网页的出色功能,因为它缓存静态资源并允许离线访问。然而,PWA 对于在线商店来说并不那么有用,并且超出了本书的范围,因此它将从示例中删除。

我们将在此处进行以下更改,以使示例适应我们的项目:

  1. 删除 PWA 支持:删除 public/manifest.jsonsrc/registerServiceWorker.js 文件。

  2. 删除未使用的 src 文件:删除 App.cssApp.jsApp.test.jsindex.csslogo.svg 文件。

  3. 创建文件夹:在 src/ 目录下创建 css/components/images/ 文件夹。

  4. 添加组件:在 components/ 目录下添加 App.jsShoppingCart.jsShoppingCartItem.jsProduct.jsProductList.js 文件。

  5. 添加 CSS:在 css/ 目录下创建一个名为 site.css 的文件,该文件将作为我们的自定义样式。

  6. 添加图片:添加两张将用作我们产品的图片。我使用了来自 Pixabay 的免费图片(Creative Commons CC0)(pixabay.com)。

您可以浏览本章的 Packt 资源(github.com/PacktPublishing/Building-Serverless-Web-Applications)以查看最终结果。该项目位于名为react-shopping-cart的文件夹中。

现在您应该有以下项目结构:

    node_modules
    public
      |- favicon.ico
      |- index.html
    src
 |- components
 |- App.js
 |- Product.js
 |- ProductList.js
         |- ShoppingCart.js
 |- ShoppingCartItem.js
 |- css
 |- site.css
 |- images
 |- <images>
      |- index.js
    .gitignore
    package.json
    README.md

在开始编写组件代码之前,我们需要在index.js文件中做一些修改,以匹配新的项目结构。使用以下代码:

    import React from 'react';
    import ReactDOM from 'react-dom';
 import App from './components/App'; import './css/site.css';    ReactDOM.render(
      <App/>,
      document.getElementById('root')
    );

对于响应式网站,我已经在public/index.html文件中包含了 Twitter Bootstrap 3(getbootstrap.com)样式:

    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <title>Serverless Store</title>
        <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
 <link rel="stylesheet" href="bootstrap.min.css">     
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>

组合组件

用户界面是组件的组合。为了使这一点更清晰,以下图表显示了我们将如何组合我们的组件以创建应用程序设计:

图片

App组件持有整个视图,并负责定位产品列表购物车组件。产品列表组件包含一系列产品组件,购物车将列出所选产品作为购物车项

在 React 中,组件之间没有交互,除非通过从父组件传递属性到子组件。这些属性被称为 props。除非父组件提供一个处理函数作为属性,并且子组件在事件发生时执行此处理函数,否则子组件不能向父组件传递数据。例如,父组件可以提供一个handleClick函数,当子组件中的按钮被点击时,该函数将被触发。

由于只能有父子交互的限制,产品列表和购物车组件需要一个共同的父组件。当选择一个产品时,它将在产品列表中触发一个函数,这反过来又会在应用程序组件中触发另一个函数。这个处理程序将改变应用程序组件的状态,因此购物车组件的状态也会随之改变,因为应用程序组件将被定义为通过props将数据传递到购物车。

实现组件

在以下代码片段中,展示了组件的骨架。此格式将被用于实现所有组件。为了专注于内容,进一步的示例将只显示constructor()render()实现。完整的代码示例可以从与本书相关的 Packt 资源中下载:

    // import React and other dependencies
    import React, { Component } from 'react';
    import AnotherComponent from './AnotherComponent';

    // define the Component as a class
 class MyComponent extends Component {
      // optional method
      constructor() {
 super();
 this.state = {
 // state };
 }

      // this method must be implemented
      render() {
 return (
 // HTML definition
 );
 }
    }

    // export the component, so it can be used by others    
 export default MyComponent;

应用程序组件

应用程序是一个负责组织页面布局的组件。它使用 Bootstrap 的网格系统定位两个其他主要组件,如产品列表和购物车。它应该按照以下方式渲染:

    render() {
      return (
        <div className="container">
          <div className="row">
            <div className="col-md-12">
              <h1>Serverless Store</h1>
            </div>
          </div>
          <div className="row">
            <div className="col-md-8">
              <h3>Products</h3>
              <ProductList
 products={this.state.products} onSelect={this.handleSelect}/>            </div>
            <div className="col-md-4">
              <h3>Shopping Cart</h3>
              <ShoppingCart 
                selectedProducts={
                  this.state
                      .products
 .filter(p => p.isSelected)
                } 
                onDeselect={this.handleDeselect}/>            </div>
          </div>
        </div>
      );
    }

当使用 JSX 时,你通过className属性给 HTML 元素添加一个类,例如:

<div className="container"></div>

在此代码中,我们可以看到产品列表组件被设置了两个属性,例如productsonSelect

 <ProductList products={this.state.products} onSelect={this.handleSelect}/>

products 属性将接收一个由 App 组件的状态控制的产品列表。onSelect 属性将接收一个处理函数,该函数将由子组件用于在产品被选中时触发父组件。

我们还可以看到,购物车组件有两个属性,例如 selectedProductsonDeselect

 <ShoppingCart      selectedProducts={
        this.state
            .products
 .filter(p => p.isSelected)
      }onDeselect={this.handleDeselect}/>

selectedProducts 属性将接收一个所选产品的列表,而 onDeselect 属性定义了一个处理函数,当产品被取消选中时,购物车组件应该触发此函数。

因此,在这个组件中,App 组件正在与产品列表和购物车组件共享其状态,因此 App 负责定义产品对象并跟踪所选产品。产品列表的初始定义如下代码所示:

    import lonelyBird from '../images/lonely-bird.jpg';
    import solidFriendship from '../images/solid-friendship.jpg';

    const products = [{
        id: 1,
        name: 'Lonely Bird',
        image: lonelyBird,
        price: 29.99,
        isSelected: false
    }, {
        id: 2,
        name: 'Solid Friendship',
        image: solidFriendship,
        price: 19.99,
        isSelected: false
    }];

前面的代码片段是一个简化的例子。在下一章中,我们将从这个 Lambda 函数中检索这个列表。

初始状态是在类构造函数中定义的。此外,你还需要将组件的 this 实例绑定到回调函数上。否则,当函数在另一个组件内部被调用时,将找不到 this.state

    constructor() {
       super();
       this.state = {
 products: products };       // bind the component's "this" to the callback
       this.handleSelect = this.handleSelect.bind(this);
       this.handleDeselect = this.handleDeselect.bind(this);     }

handleSelect 函数应该在类内部定义,并且它将接收一个产品作为参数来设置其 isSelected 状态:

    handleSelect(product) {
      // create a copy of the products array
      const products = this.state.products.slice();

      // find the index of the product to modify
 const index = products.map(i => i.id).indexOf(product.id);

      // modify the selection state
 products[index].isSelected = product.isSelected;      // make React aware that the state has changed
      this.setState({products: products});
    } 

在这个例子中,有几个需要注意的地方:使用了 slice() 来创建另一个数组,而不是修改当前数组,并且使用了 setState() 而不是直接更改 products 的引用。这是因为使用不可变对象有性能优势。通过检查引用是否已更改,比检查所有属性值更容易识别已修改的对象。至于 setState(),它用于让 React 知道需要重新渲染组件。

最后,由于 handleSelect 函数正在接受 isSelected 属性并设置状态,无论该属性是真是假,我们可以使用 handleSelect 函数来定义 handleDeselect 函数:

    handleDeselect(product) {
      this.handleSelect(product);
    }

产品列表组件

此组件通过 props 变量访问其父组件提供的数据。它将使用 products 数组迭代并创建一个新的产品组件,为数组的每个项目。它还将设置由其父组件传递的 onSelect 处理函数:

    render() {
 const onSelect = this.props.onSelect;
      const productList = 
 this.props.products.map(product => {          return (
            <div key={product.id} 
                 className="product-box">
              <Product
 product={product} onSelect={onSelect}/>            </div>
          )
        });

      return (
        <div>
          {productList}
        </div>
      );
    }

产品组件

此组件负责渲染产品的详细信息,例如图片、描述、价格以及一个允许用户将产品添加到购物车的按钮。正如你所看到的,按钮的 onClick 事件将改变 isSelected 状态并触发 onSelect 函数:

    render() {
      return (
        <div>
          <img src={this.props.product.image}/>
          <div>
            <h3>{this.props.product.name}</h3>
            <div>US$ {this.props.product.price}</div>
            <div>
              <button onClick={() => {
 const product = this.props.product; product.isSelected = !product.isSelected; this.props.onSelect(product);              }}>
                {this.props
                     .product
                     .isSelected ? 'Remove' : 'Add to cart'}
              </button>
            </div>
          </div>
        </div>
      );
    }

购物车组件

购物车组件负责渲染所选产品并显示总价值。让我们看一下以下代码片段,看看这是如何实现的:

    render() {
      const onDeselect = this.props.onDeselect;
      const products = 
 this.props.selectedProducts.map(product => {
          return (
            <ShoppingCartItem key={product.id}
                              product={product}
                              onDeselect={onDeselect}/>
          )
 });

      const empty = 
        <div className="alert alert-warning">
          Cart is empty
        </div>;

      return (
        <div className="panel panel-default">
          <div className="panel-body">
 {products.length > 0 ? products : empty}
            <div>Total: US$ {this.getTotal()}</div>          </div>
        </div>
      );
    }

getTotal函数使用map/reduce操作来获取聚合的总值。map操作将转换输入,创建一个数字数组,而reduce将求和所有值:

    getTotal() {
      return this.props
                 .selectedProducts
                 .map(p => p.price)
                 .reduce((a, b) => a + b, 0);
    }

购物车组件

最后一个组件是购物车组件。对于每个选定的产品,购物车组件将添加一个购物车项。此组件使用产品名称和值进行渲染,以及一个带有 X 标记的 Glyphicon 图标。Glyphicon 是一组可以通过 Bootstrap 获取的图标。

此外,当用户点击图标时,我们需要触发onDeselect函数。查看以下代码片段以了解如何实现:

    render() {
      const product = this.props.product;
      return (
        <div>
          <span>
            {product.name}: US$ {product.price}
          </span>
          <a 
            onClick={() => {
 product.isSelected = false;
 this.props.onDeselect(product); 
 }}>
            <span className="glyphicon glyphicon-remove">
            </span>
          </a>
        </div>
      );
    }

发布我们的演示

由于我们使用了 JSX 来构建 React 演示,发布静态文件需要处理阶段。在这种情况下,Create React App 模块将再次帮助我们。

查看以下步骤了解如何发布我们的演示:

  1. 在发布之前,我们需要在本地测试它以确认一切按预期工作,可以使用以下命令完成:
 npm start

  1. 现在,我们可以使用以下命令准备我们的前端项目以供发布:
 npm run build

  1. 生成的文件将被处理、最小化和打包。你可以在build文件夹中找到所有文件。现在使用以下命令将它们上传到 Amazon S3:
 aws s3 sync ./build s3://my-bucket-name --acl public-read

  1. 现在,重新上传index.html文件,使用以下命令仅为此文件添加Cache-Control: no-cache头:
 aws s3 cp ./build/index.html s3://my-bucket-name \ --cache-control no-cache --acl public-read

发起 Ajax 请求

React 只负责视图层。React不关心数据是如何从服务器获取的。因此,没有限制,你可以使用许多不同的方法来检索服务器数据。Redux 提供了一个使用异步动作和 Relay 的模式,而 Relay 是另一个 JavaScript 框架,它使用GraphQL来处理数据。

在我们的示例应用程序中,我们将使用最简单的方法:根组件。这种模式很简单,对于具有浅层组件树的小型项目非常有用。我们将做的是将所有 Ajax 请求集中在一个组件中,而最佳选择是使用根组件,因为它是可以与所有其他组件通信的唯一组件。

当根组件从服务器检索一些数据时,子组件将通过属性进行更新,React 如预期的那样,将仅重新渲染更改的部分。并且每当组件需要执行操作时,它将执行由父组件作为属性传递的函数。此信息将向上传递,直到达到根级别,在那里它可以发送到服务器。

在我们的示例中,我们将考虑 App 组件作为根组件。index.js文件在技术上被视为根,因为它是最先加载的,但index只负责将 React 应用程序附加到 HTML 页面。由于 App 组件是由index加载的,而 App 是所有其他组件的公共父组件,因此它将被定义为我们的根。

从服务器获取数据

在以下示例中,我们将构建一个在加载时请求产品列表的页面。这个请求将在根组件中完成,但我们需要定义它确切地在哪里执行。render函数永远不是一个好的选择,因为render被认为是一个纯函数:对于给定的输入,输出总是相同的,这意味着不允许有副作用。

排除render,我们有两个候选者:componentWillMountcomponentDidMount,它们都只执行一次,并且在第一次render执行之前(componentWillMount)或之后(componentDidMount)。由于异步调用需要一些时间来执行,而组件渲染将在收到结果之前进行,因此使用componentWillMount选项并没有帮助。第一次渲染总是使用空数据完成的。因此,使用componentWillMount函数将初始状态设置为空(并避免在属性中存在未定义的值)以及使用componentDidMount函数从服务器获取数据更有意义。

另一个问题是在constructor函数还是componentWillMount函数中设置初始状态。技术上它们是等效的,但使用constructor函数来完成这个任务更为常见。实际上,componentWillMount几乎从未被使用过。

最后要决定的是:将使用哪个 Ajax 库?我喜欢用axios来完成这个任务,但如果您愿意,您可以使用其他库来完成这个任务,例如FetchSuperAgent。有些人喜欢使用jQuery进行 Ajax 调用,但只为一个任务添加一个功能齐全的库并没有太多意义。

要安装 axios,请运行以下命令:

 npm install axios --save

要在组件中包含 axios,请添加以下导入:

 import axios from 'axios';

示例的第一部分展示了在构造函数内部如何定义初始状态。它设置了一个空的产品数组和一个布尔变量ready,其值为false。一旦请求完成,这个布尔值将被设置为true。使用这种方法,我们可以控制渲染状态,并在页面仍在获取数据时显示一个加载图标:

    constructor() {
      super();

      // empty initial state
      this.state = {
 products: [],
 ready: false
 };  
    }    

检查ready: false状态,我们可以显示一个glyphicon-refresh图标而不是产品列表:

图片

看看以下代码片段中的componentDidMount实现。API 地址用于触发 Lambda 函数:

    componentDidMount() {
      const apiAddress =
        'https://abc123.execute-api.us-east-1.amazonaws.com';
      const stage = 'prod';
      const service = 'store/products';

      axios
 .get(`${apiAddress}/${stage}/${service}`)
 .then(res => {
 this.setState({ 
 products: res.data.products, 
 ready: true 
 });
 })
 .catch(error => {
 console.log(error);
 });
    }

获取结果后,我们使用true值设置ready状态,并使用我们收到的产品列表:

向服务器发送数据

在上一个示例中,我们将 Ajax 请求放置在 componentDidMount 函数内部,因为我们希望在页面加载时获取数据。然而,在下面的示例中,Ajax 请求将在按钮点击时执行,因此我们没有相同的限制。请查看以下步骤,这些步骤将逐步描述该过程:

  1. 请求将被定义为组件的一个函数:
        handleSave(products) {       
          axios
 .post(`${apiAddress}/${stage}/${service}`, 
 products) // data to send
 .then(res => {
 this.setState({ 
 products: this.state.products, 
 hasSaved: true 
 });
 })
 .catch(error => {
 console.log(error);
 });
       }

  1. handleSave 函数通过属性传递给子组件:
        <ShoppingCart 
          products={this.state.products} 
          hasSaved={this.state.hasSaved}
 onSave={this.handleSave}/>

  1. 最后,当按钮被点击时,子组件将触发 save 函数。请求完成后,父组件将 hasSaved 属性的状态更改为 true,我们可以在子组件中使用这个值来显示消息:
        return (
          <div>
            {products}
            <div>Total: US$ {this.getTotal()}</div>
            <button
              onClick={() => {this.props.onSave();}}>
              Save
            </button>
 {this.props.hasSaved ? <div>saved</div> : ''}
          </div>
        );

  1. 保存后,按钮下将出现“已保存”字样:

处理页面路由

在本章的后面部分,我们将讨论单页应用(SPA)的优缺点,但首先我们将看看如何构建一个。单页应用(SPA)是一个只加载一个 HTML 文件的程序,但在用户与之交互时,它将动态更新该页面。此外,内容将根据 URL 不同而渲染不同。例如,浏览 example.com 地址将渲染 Home 组件,而浏览 example.com/about 将渲染 About 组件。

为了实现这一点,我们将使用 React Router 模块。让我们看看以下步骤来构建一个单页应用(SPA):

  1. 使用 Create React App 工具创建一个新的应用程序,或者修改之前的购物车应用程序。

  2. 通过运行以下命令安装 React Router 模块:

 npm install react-router-dom@4.x --save

@4.x 这个术语意味着它将下载一个与此书示例兼容的版本。

  1. App.js 文件将被修改以定义应用程序路由。首先,我们需要导入 React Router 模块组件:
        import React, { Component } from 'react';
 import { 
 BrowserRouter as Router, Route, Switch 
 } from 'react-router-dom';

  1. 接下来,我们需要导入我们的 App 组件。在这个例子中,我们将使用以下组件:

    • Header: 这是将为所有页面渲染文本 "Serverless Store" 的组件

    • Footer: 这是将为所有页面渲染页脚文本的组件

    • ProductList: 这是一个产品列表,其中每个产品都链接到 Product 组件

    • Product: 这个组件提供了特定产品的详细信息

    • ShoppingCart: 这是用户选择的产品的列表

    • NoMatch: 这是一个将渲染文本“页面未找到”的组件

  2. App 组件将使用以下组件渲染页面:

    • Router: 这是页面路由的根组件。

    • Switch: 这将渲染与 URL 路径匹配的第一个子路由。如果没有匹配项,它将渲染 NoMatch 组件。

    • Route: 这将渲染指定路径的组件。

让我们看看以下代码片段中提到的先前组件:

        render() {
          return (
 <Router>
              <div>
                <Header/>
 <Switch>
                  <Route path="/" exact component={ProductList}/>
 <Route path="/product/:id" component={Product}/>
 <Route path="/shopping-cart" component={ShoppingCart}/>
 <Route component={NoMatch}/>
 </Switch>
                <Footer/>
              </div>
 </Router>
          );
        }

  1. 运行应用程序并测试 URL。如果它不匹配任何路径,NoMatch组件将被渲染,并显示“页面未找到”的消息:

链接页面

使用 React Router 的 Link 组件来链接一个页面到另一个页面:

    import { Link } from 'react-router-dom';

链接只是 HTML 锚元素的包装器。在以下示例中,Product List 组件的实现展示了如何链接到特定产品的页面:

    render() {
      return (
        <div>
          <ul>
            <li>
              <Link to='/product/1'>
 Product 1
 </Link>
            </li>
            <li>
              <Link to='/product/2'>
 Product 2
 </Link>
            </li>
          </ul>
        </div>
      );
    }

此组件将按以下方式渲染:

使用查询参数

当我们声明了应用程序的路由时,我们已经定义了 Product 组件的路由如下:

 <Route path="/product/:id" component={Product}/>

冒号符号定义了可以由相关组件使用的参数。在这种情况下,:id定义了一个名为id的参数,可以如下使用:

    render() {
      return (
        <div>
          <h4>
 Product Details for ID: {this.props.match.params.id}
          </h4>
        </div>
      );
    }

此产品组件将按以下截图所示渲染:

注意,localhost:3000/product/1路径定义了一个值为1id参数。

单页应用

在传统的多页网站中,每个 URL 都会加载不同的 HTML 页面。如果你在example.com页面,需要导航到example.com/about,整个视图会因为页面重新加载而闪烁。问题在于,通常情况下,页面重新加载是浪费时间,因为这两个页面共享相似的内容,例如页面头部和页脚。此外,CSS 和 JavaScript 依赖项可能完全相同。

在单页应用中,有一个基础 HTML 文件,对于每个 URL 都会被加载,并且根据给定的 URL,内部内容会动态加载以匹配地址。此外,URL 浏览是通过 JavaScript 在客户端控制的。从一个 URL 切换到另一个 URL 不会导致整个页面重新加载。而不是加载整个新文件,服务器会收到一个请求,只检索新地址所需的内容,并且只重新渲染页面的一部分。

单页应用的优缺点

SPA 是一种现代方法,旨在提供更好的用户体验,但它并不完美。在使用它之前,你需要知道它的优缺点。虽然这个话题可能很广泛,但我们将只突出最相关的部分。

优点

让我们列出这种方法的主要优点:

  • 无页面刷新:这是一个明显的优势。当用户切换到另一个视图时,页面不会闪烁。流畅的导航使浏览体验更加愉悦。

  • 解耦:你有一个更好的前端和后端代码的分离。

  • 减少服务器端代码:我们正在构建一个无服务器网站,因此我们必须考虑后端冷启动延迟可能会影响用户体验。在 SPA 中,客户端有更多的逻辑来实现动态性,我们可以使用这种方法来减少服务器端代码的大小,并通过减少对后端的请求数量来提高性能。

缺点

SPA 有一些缺点,我们可以考虑以下缺点:

  • 更大的文件大小:由于我们在客户端有更多的逻辑,应用程序通常有更大的 JavaScript 依赖。这是一个大问题,尤其是在网络条件较差的移动客户端中。网站的首次加载将花费更多时间。

  • 需要 JavaScript:由于安全原因,仍有少数用户禁用了 JavaScript。如果你有一个不需要任何花哨功能的简单网站,你的单页应用(SPA)强制要求支持 JavaScript,而这是可选的。

  • 搜索引擎优化:SPA 应用程序严重依赖于 JavaScript。在特定条件下,Google 爬虫可以执行一些 JavaScript 代码,但 Bing 和其他搜索引擎不会执行。如果我们希望搜索引擎正确索引我们的网站,我们需要为它们特别预渲染内容。

考虑事项

有些人可能会争论,低端移动设备由于 JavaScript 代码量的增加,可能会在 SPA 中表现不佳。虽然这在过去可能是真的,但目前在现实中可能不是这样,未来也不会是这样。如今,即使是低端设备也有强大的 CPU,可以完美地无缝执行大多数操作。移动设备真正的问题不是计算能力,而是下载更大代码量的网络性能。

在这本书中,我们将坚持使用 SPA,主要原因是因为它与无服务器方法很好地匹配。SPA 是一种现代方法,可以将运行网站所需的某些计算成本卸载到客户端。

Lambda 很便宜,但并非免费。另一方面,客户端执行是无限的。基于更多的客户端逻辑不会显著影响性能的假设,我更喜欢避免使用 Lambda 请求来处理应用程序状态。Lambda 应该仅用于检索或保存数据,而不是控制 UI 逻辑。

然而,正如我们领域中的大多数事情一样,每个案例都应该单独处理。你可能从多页应用中受益,这并没有什么不妥。在使用多页应用时,你只需要配置 Lambda 函数返回 HTML 内容而不是 JSON 数据,就像我们在第三章中看到的,使用无服务器框架

预渲染页面

在我们的前端方法中,布局完全由 JavaScript 代码使用 React 组件组成。预渲染网页意味着执行此 JavaScript 代码并保存输出 HTML 文件。

如前文所述,为了提高搜索引擎优化SEO),我们需要预渲染页面,因为大多数爬虫无法执行 JavaScript 代码,而那些可以执行(如 Google)的爬虫也不会执行所有类型的代码。

使用 PhantomJS

PhantomJS 是一个基于 WebKit 的无头浏览器,可以用来发送 HTTP 请求并保存 HTML 输出。它不是一个 Node.js 模块,但它可以使用 Node.js 模块。它在自己的进程中运行,这与 Node 运行时不同。你可以从官方网站下载它:phantomjs.org

如前一章所述,你可以配置 S3 存储桶,使其在发生 HTTP 404 未找到错误时返回index.html页面。因此,当用户浏览地址example.com/page1时,S3 将寻找page1.html文件。它将找不到,但会加载index.html文件。由于我们已经开发了一个 SPA,它将能够渲染相应的page1文件的内容,同时保持浏览器地址为example.com/page1

当我们预渲染page1文件时,输出 HTML 必须上传到 S3 存储桶。这意味着下次我们尝试获取地址example.com/page1时,S3 将找到page1.html文件并直接加载它。为真实用户加载预渲染的页面没有问题,从性能角度来看甚至更好。此用户将加载带有 React 依赖的 HTML。几秒钟后,React 应用程序将接管控制权,后续请求将像正常 SPA 一样处理。

预渲染页面的脚本相当简单。你可以参考以下示例:

    const fs = require('fs');
    const webPage = require('webpage');
    const page = webPage.create();

    const path = 'page1';
    const url = 'https://example.com/' + path;

    page.open(url, (status) => {

      if (status != 'success') {
        console.log('Error trying to prerender ' + url);
        phantom.exit();
      }

      const content = page.content;
      fs.write(path + '.html', content, 'w');

      console.log("The file was saved.");
      phantom.exit();
    });

为了测试,将 PhantomJS 二进制文件添加到 PATH 中,并执行以下命令:

 phantomjs prerender.js

这种方法的一个问题是,你需要跟踪应用程序的所有页面。如果添加了新页面,你需要记得将其包含在要处理的页面列表中。此外,你还需要预渲染应用程序的根文件(index.html),并在 S3 存储桶中替换它。

服务器输出 HTML 文件将使内容对所有网络爬虫可见。

使用 Lambda 函数进行预渲染

如果你的应用程序是一个静态网站,你可以一次性预渲染所有页面。然而,对于像我们的 Serverless Store 这样的动态应用程序,我们需要有一个预渲染页面的常规流程,以避免向爬虫提供过时内容。例如,https://serverless-store.com/products/lonely-bird页面显示了 Lonely Bird 产品的详细信息。如果产品被修改或删除,我们需要将这些更改应用到/products/lonely-bird.html文件中。你有以下两种选择:

  • 每当某些内容被修改时,触发一个 Lambda 函数来更新页面

  • 安排 Lambda 函数每天执行以更新所有页面

在这两种情况下,都将使用 Lambda 函数,但如果它不是一个 Node 模块,如何调用 PhantomJS 二进制文件?为此,我们可以安装 phantomjs-lambda-pack Node 模块,它提供了与 Amazon Linux AMI 机器兼容的二进制文件,以便在 Lambda 上运行。因为它将启动一个子进程来执行 PhantomJS,所以它可以作为一个 Node 模块使用。

在下一个示例中加载的 prerender.js 文件是上一节中实现的代码。它必须放在与 serverless.yml 文件相同的文件夹中。

以下代码可以用作我们的 Lambda 处理程序:

    const phantomjsLambdaPack = 
      require('phantomjs-lambda-pack');
    const exec = phantomjsLambdaPack.exec;

    exports.handler = (event, context, callback) => {
      exec('prerender.js', (err, stdout, stderr) => {
        console.log(err, 'finished'); 
        callback(err, 'finished');
      });
    };

这个 PhantomJS 包装器要求 Lambda 函数至少使用 1,024 MB 的 RAM 和 180 秒的超时时间。因此,与其为每个页面要求一个 Lambda 函数,不如调用 Lambda 来处理多个页面。

即时渲染

除了预渲染网页,您还可以即时渲染。您需要检测请求是否来自爬虫,并执行一些逻辑来渲染 HTML 页面。检测爬虫可以通过检查用户代理字符串并将其与已知常见爬虫列表进行比较来完成。这种方法是有效的,但需要定期维护,并且不会涵盖所有爬虫,只是最流行的那些。

有一个网站,prerender.io,当检测到爬虫时会提供即时预渲染网站的服务。您需要在您的服务器上安装一个中间件,它将负责检查请求以找到爬虫,并为它们提供一个缓存的预渲染版本。由于我们没有服务器,并且我们正在使用 CloudFront/S3 来托管前端,因此我们无法按需执行代码。

为了解决这类问题,AWS 发布了一项名为 Lambda@Edge 的新服务,目前处于预览阶段。该服务将在边缘位置响应所有 页面请求 执行 Lambda 函数。AWS 承诺执行这些 Lambda 函数的延迟非常短,如果代理是爬虫,您可以使用它来即时预渲染。您还可以用它来处理其他用例,例如根据代理、IP 地址或引用修改响应头或添加内容。

即时渲染的明显缺点是它将使响应请求的速度变慢,但 Lambda 函数可以直接访问数据库,因此渲染的页面将始终是最新的。

构建在线商店

我们将使用之前的购物车演示开始我们的应用程序。现在,我们知道如何设置页面路由和如何进行 Ajax 请求,所以我们有继续进行的一切。一个重要的区别是购物车组件将位于产品列表组件的不同页面中。此外,我们还需要创建其他页面。以下是一张页面列表:

  • 主页:这展示了所有可用产品的列表,用户可以将它们添加到购物车组件中

  • 产品详情:这是一个专门提供特定产品更多详情的页面,用户可以在此页面上查看和添加新的评论

  • 购物车:这显示了所有选定的产品,并负责处理支付

  • 注册页面:这处理账户创建

  • 登录页面:这允许用户登录

  • 页面未找到:当地址不存在时,将显示此页面

  • 错误页面:当发生错误时,将显示此页面

本书不会涵盖在线商店示例的所有代码。有许多部分实现简单或与无服务器概念无关。您可以在本书相关的 Packt 资源或我的 GitHub 仓库github.com/zanon-io/serverless-store中找到整个代码。对于运行演示,请访问serverless-store.zanon.io。我们不是在这里展示所有代码,而是专注于重要部分。以下各节将描述每个页面实现的内容,以及结果的截图。

Navbar 组件

Navbar 组件类似于所有页面都应该出现的页眉组件。对于其实现,让我们执行以下步骤:

  1. 首先,我们需要安装两个 Node 模块:react-bootstrapreact-router-bootstrap。使用以下 npm 命令安装它们:
 npm install react-boostrap --save npm install react-router-bootstrap --save

  1. 使用以下代码导入必要的组件:
        import { 
 Navbar, Nav, NavItem 
        } from 'react-bootstrap';
        import { 
 IndexLinkContainer, LinkContainer 
        } from 'react-router-bootstrap';

  1. 使用以下代码实现 Navbar 组件以设置链接:
        <Navbar>
          <Nav>
            <IndexLinkContainer to="/">
 <NavItem>Home</NavItem>
 </IndexLinkContainer>
 <LinkContainer to="/shopping-cart">
 <NavItem>Shopping Cart</NavItem>
 </LinkContainer>
          </Nav>
          <Nav pullRight>
            <LinkContainer to="/signup">
 <NavItem>Signup</NavItem>
 </LinkContainer>
 <LinkContainer to="/login">
 <NavItem>Login</NavItem>
 </LinkContainer>
            <NavItem>
              <span className="glyphicon glyphicon-bell">
              </span>
            </NavItem>
          </Nav>
        </Navbar>

我们将得到以下结果:

最后一个项目是一个通知图标。我们将在第九章中实现它,处理无服务器通知

首页

首页将渲染我们在本章中定义的 Product List 组件。一个重要的观察点是页面路由器如何选择此组件。之前,我们使用了以下代码:

 <Route path="/" exact component={ProductList}/> 

然而,我们需要从 App 组件传递一些属性到 Product List 组件,因为 App 组件负责管理应用程序状态。在这种情况下,我们需要使用render属性:

    <Route path="/" exact render={ 
      () => <ProductList 
 products={this.state.products} 
 onSelect={this.handleSelect}/> 
    }/>

同样适用于所有需要与 App 组件共享其状态的其他组件。

查看以下截图中的结果:

产品详情页面

产品详情页面将通过点击产品图片来访问。在此页面上,用户将能够查看产品和客户评价:

客户评价功能将在本书的第九章中实现,即处理无服务器通知

要显示产品详情页面,我们需要在产品图片中使用Link标签添加一个链接,如下面的代码片段所示:

 <Link to={`/product/${this.props.product.id}`}>
      <img src={this.props.product.image}/>
 </Link>

另一个需要改变的是页面路由如何能够识别要渲染哪个产品。在这种情况下,我们将修改Route组件,使用props.match.param对象中可用的 URL 参数来渲染Product组件:

    <Route path="/product/:id" render={
      (props) => <Product 
                   product={
                     this.state
                         .products
                         .find(x => 
                           x.id === props.match.params.id)
                   }
                   onSelect={this.handleSelect}/>
     }/>

购物车页面

购物车页面将像本章前面所做的那样实现。这里唯一的修改是添加了一个结账按钮,它将被用来处理请求:

图片

然而,处理支付是一个针对受限受众的复杂功能,因此这里不会讨论。如果您需要这个功能的无服务器服务,我建议您查看 Stripe(stripe.com)。

当用户点击此按钮时,我们将显示一个模态框,如下面的截图所示:

图片

此模态框使用react-bootstrap组件实现,如下面的示例所示:

    <Modal show={this.state.showModal} onHide={this.closeModal}>
      <Modal.Header closeButton>
        <Modal.Title>Your order has been sent</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <p>However, this is a demo...</p>
      </Modal.Body>
    </Modal>

在下面的代码片段中,closeModal是一个将showModal状态设置为false的方法:

    closeModal() {
      this.setState({ showModal: false });
    }

登录和注册页面

登录和注册页面将实现为简单的表单,如下面的截图所示:

图片

它们之间的唯一区别是注册页面有一个额外的字段,要求用户再次输入密码以进行确认。

这两个功能将在第八章中实现,Securing the Serverless Application

错误页面

我们必须支持两种类型的错误:HTTP 404 *Not Found*HTTP 500 *Internal Server Error*。当 URL 不匹配任何页面时,将渲染Not Found状态码,而Internal Server Error是我们可以在后端发生错误时显示的页面。这两个页面都将实现以显示错误信息。

查看下面的错误页面截图:

图片

查看下面的页面未找到截图:

图片

摘要

在本章中,我们介绍了 React 的基础知识,以展示如何使用现代工具构建无服务器前端。我们讨论了单页应用(SPA)以及如何预渲染页面以改善搜索引擎优化(SEO)。我们已经完成了定义如何构建我们的无服务器商店的前端。

在下一章中,我们将构建在线商店的无服务器后端,学习更多关于无服务器架构和 RESTful API 的知识。

第六章:开发后端

在没有服务器的情况下开发后端是无服务器概念的主要驱动力,也是它如此有趣的原因。在这个模型中,范式转变是将项目分解成可以单独部署的小逻辑块,而不是一个单一的庞大应用程序。如何架构这种分离将在本章中介绍。此外,我们将继续开发我们的无服务器在线商店,使用 REST 原则构建后端。

简而言之,我们将涵盖以下主题:

  • 无服务器架构概述

  • 组织项目的代码

  • 如何创建 RESTful 服务

在这一章之后,你将构建我们无服务器在线商店的后端。

定义项目架构

在本节中,我们将介绍四种不同的无服务器项目架构方法:

  • 纳米服务:这里每个功能都有自己的Lambda函数

  • 微服务:这里每个Lambda处理单个资源的所有 HTTP 动词

  • 单体:这里单个 Lambda函数处理所有功能

  • :这使用的是GraphQL标准,它是 REST API 的替代品

正如我们将看到的,每种架构方法都有其优点和缺点,没有万能药。你需要权衡利弊,选择你认为最适合你特定用例的方案。让我们更深入地了解它们。

单体与微服务

选择无服务器架构时,我们首先需要考虑的是应用程序是否仅使用一个 Lambda 函数执行(单体)或者它将拥有多个 Lambda 函数(微服务纳米服务)。Lambda 函数的数量代表了它有多少个部署单元。

单体是一个包含所有功能的自包含应用程序,所有功能都在单个解决方案中开发,对代码中某一部分的修改需要整个解决方案的新部署。

微服务架构是相反的。你有多个可以单独部署的单元,每个单元负责整个解决方案的一个特定部分。要遵循微服务架构,你需要向应用程序添加模块化。你将一个大应用程序分解成一组小型服务,这些服务可以通过 HTTP 请求相互通信。

微服务使用边界上下文的概念。例如,在一个在线商店中,我们有销售上下文,它代表与销售产品相关的所有业务规则,还有另一个支持上下文,涉及与客户服务相关的功能。我们可以根据它们有不同的业务规则并且能够独立演进来分离这些关注点。支持规则的修改不应影响销售功能,因此你可以部署一个服务而不部署另一个,这使得不同的团队能够同时在不同上下文中工作。

通常,微服务提供以下好处:

  • 更好的关注点分离和模块化

  • 独立且频繁的部署

  • 使用分离的团队更容易进行并行开发

与其他一切一样,它也伴随着一些缺点,如下所示:

  • 需要更多的 DevOps 工作(但可以通过 Serverless Framework 缓解)

  • 分布式系统增加了复杂性

  • 之间多个服务的集成测试更困难

纳米服务

纳米服务是从单体应用程序中可以提取的最小部分。你可以将单体拆分为多个微服务,但这些微服务也可以进一步拆分为多个纳米服务。

例如,你可以有一个用户微服务,负责处理与用户相关的所有操作,如创建、检索、更新、删除、密码恢复等。检索用户是一个单一的功能,可以非常简单,用不到 10 行代码实现。如果你只为这一段逻辑创建一个 Lambda 函数,你就是在创建一个纳米服务。

下面的图示显示了每个公开的功能都有自己的 Lambda 函数和 HTTP 端点:

要构建这样的应用程序,我们需要配置serverless.yml文件,为每个函数分配其自己的端点:

    functions:
      retrieveUsers:
        handler: handlers.retrieveUsers
        events:
          - http: GET users
      deleteUser:
        handler: handlers.deleteUser
        events:
          - http: DELETE users
      retrieveProducts:
        handler: handlers.retrieveProducts
        events:
          - http: GET products
      createProduct:
        handler: handlers.createProduct
        events:
          - http: POST products

由于简单性,这个例子忽略了在这个解决方案中所需的OPTIONS动词,因为在跨源请求中,浏览器在执行POSTPUTPATCHDELETE之前,会预先发送一个OPTIONS请求来检查 CORS 头。我们将在本章后面更详细地介绍这一点。

对于这种架构,我们可以列出以下优缺点:

优点:

  • 关注点的分离允许你修改一个功能而不影响系统的其他部分。自治团队也将从最小化冲突中受益。

  • 当一个函数只有一个责任时,调试问题会更容易。

缺点:

  • 性能可能会较慢。由于某些函数很少被触发,冷启动延迟会更频繁。

  • 在大型项目中,你可能会拥有数百个函数!大量的逻辑部分可能会造成弊大于利。

微服务

微服务模式增加了与应用程序边界上下文相关的功能模块化。在这个架构中,每个 Lambda 函数处理单个资源的所有 HTTP 动词。这通常导致每个函数有五个端点(GETPOSTPUTDELETEOPTIONS):

上述系统可以通过serverless.yml文件定义为两个边界上下文,其中给定上下文的所有 HTTP 动词都引用相同的 Lambda 函数:

    functions:
      users:
        handler: handlers.users
        events:
          - http: GET users
          - http: DELETE users
      products:
        handler: handlers.products
        events:
          - http: GET products
          - http: POST products

这种架构有以下优缺点:

优点:

  • 结果是管理的 Lambda 函数数量减少

  • 由于冷启动较少,性能可能略优于纳米服务

缺点:

  • 调试稍微复杂一些,因为每个函数都有更多的选项和可能的输出

  • 需要实现一个路由机制来正确处理每个请求

这种路由机制可以通过使用 Lambda 事件来查找 HTTP 方法以及 REST 资源的简单 switch…case 语句来实现:

    module.exports.users = (event, context, callback) => {

      switch(`${event.httpMethod} ${event.resource}`) {
        case 'GET /users':      
          users.retrieveUsers(callback);
          break;
        case 'DELETE /users':  
          let id = JSON.parse(event.body).id;    
          users.deleteUser(id, callback);
          break;
        default:
          // handle unexpected path
      }
    };

单体

单体模式仅使用一个 Lambda 函数来处理我们应用程序的所有功能。在这种情况下,我们所有的应用程序端点都会触发同一个 Lambda 函数:

图片

只有一个端点并不构成限制。你可以有多个端点,但此模式的目的是减少接口数量。

serverless.yml 文件非常简化:

    functions:
      store:
        handler: handler.store
        events:
          - http: POST query

在这里,我们将看到一个如何在 Lambda 函数内部构建 GraphQL API 的简单示例。让我们看看以下步骤:

  1. 安装 GraphQL 模块(npm install graphql --save)并在 handler.js 函数中引入它:
        const { graphql, buildSchema } = require('graphql');

  1. 下一步是描述你的数据是如何组织的。在下面的示例中,我们有一个 ShoppingCart 实体,它包含用户想要购买的一组 Products。对象键是属性名,值是其数据类型。模式是一个字符串输入,它将被 buildSchema 函数编译:
        const schema = buildSchema(`
          type Query {
            cart: ShoppingCart
          }

          type ShoppingCart {
            products: [Product],
            promotionCode: String,
            discountPercentage: Int
          }

          type Product {
            name: String,
            code: String,
            quantity: Int,
            price: Int
          }
        `);

decimal 数据类型不是一个内置的数据类型,但你可以使用 integer 类型以分而不是美元来计数货币。GraphQL 提供了 float 数据类型,但它不适用于处理货币。

  1. 现在,看看下面这个遵循定义模式的 JSON 对象:
        const data = {
          "cart": {
            "products": [
              {
                "name": "Lonely Bird",
                "code": "FOO",
                "quantity": 1,
                "price": 2999
              },
              {
                "name": "Solid Friendship",
                "code": "BAR",
                "quantity": 1,
                "price": 1999
              }
            ],
            promotionCode: null,
            discountPercentage: 0
          }
        };

在这个例子中,整个数据集将通过 data 变量作为输入提供给 graphql 函数。然而,在实际应用中,将整个数据库加载到内存中是不可行的。在这种情况下所做的是在模式定义中定义一个解析器函数,告诉 GraphQL 引擎如何获取所需的数据,这意味着如何查询数据库。

  1. 在定义了数据的结构和位置之后,你可以使用 graphql 查询数据。这个查询将由客户端定义,并将可在 Lambda 函数的 event 输入中找到。例如,考虑客户端发送的这个查询:
        const query = `{
          cart {
            products {
              name
              quantity
              price
            }
            discountPercentage
          }
        }`;

在这个查询中,客户端想要知道所选产品的列表,但有一些信息客户端不感兴趣。例如,客户端不想知道产品的 code 或是否与该购物车关联的 promotionCode

  1. 要在 Lambda 函数中使用它,请调用 graphql 函数并传递 schemaquerydata 参数:
        module.exports.store = (event, context, callback) => {

          const query = JSON.parse(event.body);

          graphql(schema, query, data).then((resp) => {

            const response = {
              statusCode: 200, 
              body: JSON.stringify(resp)
            };

            callback(null, response);
          });
        };

  1. 对此函数的请求将返回以下 JSON 对象:
        {
          "data": {
            "cart": {
              "products": [
                {
                  "name": "Lonely Bird",
                  "quantity": 1,
                  "price": 2999
                },
                {
                  "name": "Solid Friendship",
                  "quantity": 1,
                  "price": 1999
                }
              ],
              discountPercentage: 0
            } 
          }
        }

使 GraphQL 强大的是其简单的语法,它允许客户端请求它确切需要的数据,并以它期望的格式接收这些数据。在这个模型中,单个请求可以获取来自多个资源的数据。它可以是一个有趣的 RESTful API 的替代品,但它也有其局限性。忽略与 REST 相关的优缺点,以下列表比较了图模式作为无服务器架构解决方案:

优点:

  • 图查询可以更好地替代单体方法的路由机制

  • 当所有端点都使用相同的 Lambda 函数时,代码将不断缓存,冷启动几乎不会发生

  • 部署快速,因为只有一个函数和一个端点

缺点:

  • 如果代码库随着太多依赖项的增长而变得太大,Lambda 的大小可能会影响性能

  • 由于每个查询都有独特的执行,因此分配内存和设置超时时间要困难得多

GraphQL 有许多其他功能,需要大量的资料,但这本书的重点不是这个。为了入门,你可以在 graphql.org/learn 上了解更多。

命名差异

Serverless Framework 的团队对无服务器架构持有相似的看法,你可以在 serverless.com/blog/serverless-architecture-code-patterns 上查看。

然而,我所说的“纳米服务”,他们称之为“微服务”。在我看来,微服务不是一个很好的术语来描述每个单独的功能都被视为一个微服务的架构风格。微服务的概念是为了指代那些被分解成几个不同部分的单体应用程序,以便更好地处理功能和特性的管理和演进。当你有太多的部分时,这些原则就不那么容易应用了。幸运的是,Serverless Framework 使得处理数十个服务变得更加容易,但对于传统应用程序来说,当微服务过于细粒度时,维护和通信的开销超过了它的好处,为了区分,这里将其称为纳米服务。

此外,请注意,他们将我称为“微服务”的模式命名为“服务”。您可以按自己的喜好命名这些模式,但请理解这些术语可能会造成混淆。

以下图表展示了从我的角度来看,单体微服务纳米服务架构之间的区别:

开发后端

在对架构进行了概述之后,我们可以开始构建后端。在这个仅作为实验的示例中,我选择了单体架构,因为它减少了冷启动延迟,而且我们的后端逻辑非常小。在你的解决方案中,你需要考虑用例,权衡每个选项的利弊。

定义功能

在上一章中,我们开发了前端并硬编码了一些数据来显示静态页面。现在,我们将创建后端以公开前端将使用的信息。请查看以下前端视图以及它们将需要后端提供的哪些功能:

  1. 主页:这个页面需要显示所有可用的产品列表

  2. 产品详情:这个页面需要关于产品的详细信息,包括用户评论列表

  3. 购物车:这个页面需要显示选定的产品,并允许用户保存或结账

  4. 注册页面:这个页面的逻辑也将在这个第八章中实现,保护无服务器应用程序

  5. 登录页面:这个页面的逻辑也将在这个第八章中实现,保护无服务器应用程序

  6. 页面未找到:当 URL 无效时,无需请求后端

  7. 错误页面:当发生错误时,这个页面不会向后端发出任何额外的请求

除了这些页面之外,我们还有一个将显示在所有页面上的 Navbar 组件,它有一个通知图标。我们将在这个第九章中实现这个功能,处理无服务器通知

简而言之,我们现在需要实现以下功能:

  1. 检索所有可销售的产品

  2. 检索特定产品的详细信息

  3. 检索用户购物车中选定的产品列表

  4. 保存选定的产品列表

  5. 结账购物车

为了简化,第二和第三个功能将在第一个功能的结果中提供,这意味着当用户请求所有可用产品的列表时,响应对象将带来每个产品的所有信息,以及用户是否已将该产品添加到购物车的信息。

组织代码

选择如何组织项目的文件是一个个人选择。你只需使用有意义的名称放置文件,这样以后更容易找到它们。在这个项目中,我使用了以下结构:

图片

下面是关于前一张截图显示的每个文件夹的简要描述:

  • functions:这些是直接部署到 AWS 的 Lambda 函数。我们只有一个函数,因为我们的应用程序是一个单体。这个函数将处理与产品相关的一切。我们不需要 Lambda 函数来处理用户创建/身份验证,因为我们打算使用 Cognito 来完成这项任务。

  • lib:这是可以被不同 Lambda 函数使用的通用应用程序逻辑。

  • node_modules:这些是安装在本项目文件夹中的 Node 依赖项,并由 package.json 文件引用。它们将被压缩以供 Lambda 函数使用。

  • repositories:此文件夹包含连接数据库和定义查询的基础设施代码。它将在第七章,使用无服务器数据库中实现。在截图上,你可以看到我们将实现 SimpleDB 和 DynamoDB 的查询。在本章中,将使用 FakeDB 提供硬编码数据以进行测试。

  • test:此文件夹包含单元和集成测试文件。它将在第十章,测试、部署和监控中实现。

在其他文件夹中引用 Lambda 函数

当你使用 Serverless Framework 创建新的服务时,它将创建一个 serverless.yml 文件来引用一个示例函数:

    functions:
      hello:
        handler: handler.hello

你应该注意这里的是 handler.hello 意味着 Serverless Framework 将尝试在 serverless.yml 文件相同的目录下找到一个 handler.js 文件,并查找导出的 hello 函数。当你有一个大项目时,你可能更喜欢将处理函数分离到子文件夹中。语法相当简单,foldername/file.function

应注意的以下示例:

    functions:
      hello:
        handler: subfolder/handler.hello
      goodbye:
        handler: lambdas/greetings.bye

在这个项目中,我使用了以下代码:

    functions:
      products:
        handler: functions/products.handler

使用多个服务

另一点你应该注意的是,Serverless Framework 只会创建一个包含与serverless.yml文件同一级别或以下内容的 ZIP 文件夹。无法包含上级的依赖项。这意味着如果你的项目使用两个不同的服务,每个服务都有一个不同的serverless.yml文件,你无法直接在它们之间共享依赖项。

以下截图说明了具有此问题的项目示例:

greetings.js文件是一个简单的 Node.js 模块,只有一行代码:

    module.exports.saySomething = () => 'hello';

service1service2handler.js文件都被实现为使用greetings模块返回消息:

    const response = {
      statusCode: 200,
      body: JSON.stringify({
       message: greetings.saySomething()
      })
    };

它们之间的唯一区别在于如何加载greetings模块。在第一个服务中,由于它处于同一级别,我们使用以下代码进行加载:

    const greetings = require('./greetings');

在第二个服务中,我们需要引用service1文件:

    const greetings = require('../service1/greetings');

如果您在本地测试service2函数(serverless invoke local --function hello),它将无问题运行,但如果部署到 AWS,则会失败,因为 ZIP 文件将不会发布依赖项。

以下是此问题的两种解决方案:

  • 避免使用多个服务。将它们聚合到一个单一的服务中,并将合并的serverless.yml文件放置在项目根目录。

  • 使用本地 npm 包来管理公共依赖项。

尽管我更喜欢第一个选项,但第二个选项也是有效的。要创建一个本地的 npm 包,浏览到包含公共依赖项的文件夹,并运行以下命令:

 npm pack

此命令将创建一个与为公共npm模块创建的格式完全相同的压缩包。

现在,在包含您的无服务器服务的文件夹中,使用以下命令本地安装包:

 npm install ../path/to/pack.tgz

每当公共依赖项被修改时,您需要重复此过程。如果它不断更新,您可能需要将此阶段包含到您的构建工作流程中。

设置端点

正如我们所知,我们需要创建 API 网关端点以向世界展示我们的无服务器函数。这是在serverless.yml文件中完成的,以下示例显示了如何为无服务器商店创建端点:

    functions:
      products:
        handler: functions/products.handler
        events:
          - http: GET products
          - http: POST cart     # create the cart (new order)
          - http: OPTIONS cart
          - http: PUT checkout  # update the order (status = sent)
          - http: OPTIONS checkout

在需要支持跨域请求中的POSTPUTPATCHDELETE动词的情况下,设置OPTIONS端点是强制性的。原因是浏览器使用的一种安全措施,即在发出可能修改资源的 HTTP 请求之前,它先使用OPTIONS进行预检请求,以检查 CORS 是否启用以及 HTTP 动词是否允许。

RESTful API

如果您还不熟悉 RESTful API,您至少应该了解以下常见的 HTTP 动词及其用法:

  • GET:用于向服务器请求数据

  • POST:用于创建或修改资源:

    • POST/resource:如果没有 ID,将创建一个新的元素

    • POST/resource/id: 如果你知道 ID 并将其包含在请求中,元素将被更新,然而,它通常仅用于创建资源而不是更新资源

    • POST/resource/new-id: 如果没有给定 ID 的资源,此请求必须返回一个错误

  • PUT: 这用于创建或修改以下资源:

    • PUT/resource: 这应该返回一个错误,因为预期会有 ID

    • PUT/resource/id: 这将用提供的数据替换整个对象

    • PUT/resource/new-id: 如果没有给定 ID 的资源,它将被创建

  • PATCH: 这用于执行部分更新,而不是用给定的数据替换整个资源,它不会更新或删除与输入不匹配的属性

  • DELETE: 这用于删除资源,必须提供一个 ID

  • OPTIONS: 这返回允许的 HTTP 动词并告知是否启用了 CORS

路由 HTTP 动词

如前例所示,对于我们的路由策略,我们可以使用 httpMethodresourceswitch…case 语句来识别路径。我建议添加 try…catch 语句来警告客户端关于意外错误,而不是让 Lambda 吞噬这些消息。

以下示例展示了如何实现products函数的路由:

    module.exports.handler = (event, context, callback) => {

      const userId = '1'; // TODO: retrieve from authentication headers
      try {
        switch(`${event.httpMethod} ${event.resource}`) { 
 case 'GET /products': 
            products.retrieveAll(userId, callback);
            break;
 case 'POST /cart': 
            const selectedProducts = JSON.parse(event.body).products;
            cart.saveCart(userId, selectedProducts, callback);
            break;
 case 'OPTIONS /cart': 
            utils.optionsHandler(callback);
            break;
 case 'PUT /checkout':            const id = JSON.parse(event.body).id;
            checkout.processCheckout(id, callback);
            break; 
 case 'OPTIONS /checkout': 
            utils.optionsHandler(callback);
            break;
          default:
            utils.notFoundHandler(callback);
        }
      } catch (err) {
        utils.errorHandler(err, callback);
      }
    };

记住你需要运行 serverless deploy 来创建 Lambda 函数和端点,但之后可以使用 serverless deploy function --function products 命令进行更快的部署。

下一节将解释如何创建 utils 模块来处理响应。

处理 HTTP 响应

通常,我们需要处理至少四种响应类型:

  1. 成功: 当请求成功处理时,返回 HTTP 200 OK

  2. 错误: 当后端发生错误时,返回 HTTP 500 内部服务器错误

  3. 未找到: 当客户端请求不存在的资源时,返回 HTTP 404 未找到

  4. 选项: 返回 HTTP 200 OK 并附带此资源的允许方法。

还有许多其他的 HTTP 状态码,例如当客户端发送没有必要参数的请求时,返回 400 错误请求,但涵盖广泛的状态码超出了本书的范围,并且大多数状态码在大多数应用程序中都没有使用。

以下代码片段展示了如何实现这些处理程序:

    const corsHeaders = { 
      'Access-Control-Allow-Origin': '*' 
    };

    module.exports.successHandler = (obj, callback) => {
      callback(null, {
        statusCode: 200,
        headers: corsHeaders,
        body: JSON.stringify(obj)
      });
    };

    module.exports.errorHandler = (err, callback) => {
      callback(null, {
        statusCode: 500,
        headers: corsHeaders,
        body: JSON.stringify({
          message: 'Internal Server Error',
          error: err.toString()
        })
      });
    };

    module.exports.notFoundHandler = (callback) => {
      callback(null, {
        statusCode: 404,
        headers: corsHeaders,
        body: JSON.stringify({ message: 'Not Found' })
      });
    };

关于 OPTIONS 动词,我们需要用状态码 200 OK 回应请求并设置允许的方法和头信息:

    module.exports.optionsHandler = (callback) => {
      callback(null, {
        statusCode: 200,
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods":
            "GET, POST, PUT, PATCH, DELETE, OPTIONS",
          "Access-Control-Allow-Headers":
            "Accept, Content-Type, Origin"
        }
      });
    };

实现 Lambda 函数

在本节中,我们将看到如何实现后端功能。在实现和部署 Lambda 函数之后,我们可以修改前端代码以向后端发送 Ajax 请求。

检索所有产品

此功能具有以下三个职责:

  • 从产品表中检索所有产品

  • 检索所有用户评论/评分并将它们与产品列表连接

  • 获取用户购物车并将其与产品列表合并,以确定已选择哪些产品

这些查询将由repository创建和执行,其实现将在下一章第七章,管理无服务器数据库中定义。

到目前为止,让我们使用 FakeDB 来返回硬编码的值:

    const db = require('../repositories/fakedb');
    const utils = require('./utils');

    module.exports.retrieveAll = (userId, callback) => {
      db.retrieveAllProducts(userId, (err, res) => {
        if (err) utils.errorHandler(err, callback);
        else utils.successHandler(res, callback);
      });
    };

在这种情况下,FakeDB 将只返回产品列表:

    module.exports.retrieveAllProducts = (userId, callback) => {
      const comments = [{
        id: 1,
        username: "John Doe",
        age: "3 days ago",
        text: "I'm using this to decorate my desk. I liked it."
      }];

      const products = [{
        id: "lonely-bird",
        name: "Lonely Bird",
        image: 'https://s3.amazonaws.com/...',
        price: 29.99,
        isSelected: yes,
        comments: comments
      }];

      callback(null, products);
    };

保存购物车

此请求接收一个选定产品的列表和UserID作为输入。在我们的示例应用程序中,UserID将唯一标识购物车,这意味着每个用户只有一个购物车。

如果用户已登录,前端知道UserID。然而,我们不能直接从客户端接收ID并信任该信息是有效的。我们知道一个恶意用户可以修改 JavaScript 代码以发送另一个用户的ID

为了可靠地运行,我们必须分析所有已登录用户请求头中传递的认证令牌,并检查ID是否正确。这一步骤将在第八章,保护无服务器应用程序中实现。

检出

处理支付是一个复杂的特性,本书不会涉及。因此,当用户尝试结账购物车时,将显示一条消息,表明这只是一个演示应用程序。

然而,我们可以使用这个特性来学习无服务器通知是如何工作的。当用户开始支付流程时,后端接收信用卡信息并请求处理支付。由于这一步骤可能需要很长时间,我们不必使用客户端进行重复请求(轮询),而是可以使用 WebSockets 在响应可用时通知用户。无服务器通知将在第九章,处理无服务器通知中使用物联网进行介绍。

摘要

在本章中,你学习了无服务器架构,如纳米服务、微服务、单体和图。对于我们的无服务器商店,我们选择了单体架构来构建后端。我们还介绍了如何构建项目的代码和构建 RESTful API。

在下一章中,你将了解 SimpleDB 无服务器数据库。由于 SimpleDB 可能不足以满足大多数应用程序的需求,我们还将学习 DynamoDB,它不是一个无服务器数据库,但需要最少的维护。

第七章:管理无服务器数据库

无服务器数据库的定义与任何其他无服务器服务相同:它需要具有高可用性高可扩展性,并且定价模型必须考虑其实际使用情况。对于数据库来说,满足这些条件尤其困难,因为性能是一个关键特性。为了可预测的高性能,数据库通常配置在自己的专用服务器上,但无服务器需要共享模型以避免向客户收取数据库 100%可用时间的费用。在无服务器中,我们只想在请求完成时付费,而不是当数据库处于空闲状态时。

目前,只有少数服务成功地将无服务器模型引入数据库。AWS 仅提供一项服务:SimpleDB,但它缺少许多重要功能,并且极其有限。对于其他更好的选择,你可以尝试 FaunaDB、Google Firebase 或 Google Cloud Datastore。为了继续使用本书中的 AWS 服务,我们将介绍 DynamoDB,它是一种几乎无服务器的数据库。

此外,我们将了解如何使用 Amazon S3 来存储媒体文件,因为在大多数情况下,将文件保存在廉价的存储系统中比数据库服务器更好。

在本章中,我们将涵盖以下主题:

  • 使用和管理 SimpleDB 和 DynamoDB 数据库

  • 使用 Amazon S3 存储媒体文件

当你完成本章后,你将已经实现了在线商店的数据访问层,并获得了使用无服务器数据库的必要知识。

Amazon SimpleDB

SimpleDB 是一项旧服务(2007 年底),并且是 AWS 提供的唯一一个真正可以称为无服务器数据库的服务。AWS 提供许多其他托管数据库,如 DynamoDB 或 RDS,但所有这些都需要你设置配置并支付 24 小时的费用,即使没有人使用你的系统。当你需要不断检查容量是否适合你的流量时,你确实需要担心服务器。

SimpleDB 之所以是无服务器的,有以下原因:

  • 完全由 AWS 管理:你不需要启动机器并安装/配置数据库管理系统。

  • 高可用性:AWS 管理多个地理上分布的数据库副本,以实现高可用性和数据持久性。

  • 可扩展性:你可以快速增长,而无需担心配置。

  • 成本效益:你只需为存储的数据量、传输的数据量和运行查询所使用的 CPU 时间付费。如果没有人使用数据库,你只需为当前存储的内容付费。

SimpleDB 是一个 NoSQL 数据库,但遗憾的是,由于缺少重要的功能,它非常有限。例如,你可以使用的唯一数据类型是字符串。这使得你难以实现许多用例,但我们将在这里介绍一些技巧,使其可行。如果你的应用程序相对复杂,我会避免使用 SimpleDB。仅用于小型应用程序。

设计数据库模型

首先,让我们先了解一下一些术语:在 SimpleDB 中,一个相当于关系型世界中的,而一个相当于。它们非常相似,但你需要了解它们的意思才能理解 SDK 函数。此外,在 SimpleDB 中,每个都有一个属性值对的列表,其中属性类似于,而值总是字符串数据类型。

为了举例说明,我们将为无服务器商店建模数据库。我们只将使用两个域,例如ProductsShoppingCart。我们不会创建一个域来保存用户账户数据(电子邮件、密码等),因为在下一章中我们将使用 Amazon Cognito,而 Cognito 负责保存和管理用户数据。

下表列出了Products域的属性。所有这些属性都将创建以保存字符串,因为 SimpleDB 有一个限制,但我已经添加了理想的数据类型。在下一节中,我们将看到如何处理这个限制:

属性 期望的数据类型
ID 字符串
名称 字符串
价格 十进制
图片 字符串
评论 文档数组

关于此模型的一些观察如下:

  • IDID属性可以定义为整数,但我将其定义为字符串,因为我们将在 URL 中使用 ID。我们不是将 URL 显示为store.com/product/123,而是使用store.com/product/lonely-bird

  • 价格价格属性将以字符串形式保存,尽管我们希望将其保存为数字。

  • 图片图片属性将以字符串形式保存,因为我们将在数据库中保存 S3 对象的 URL,而不是保存整个对象。

  • 评论评论属性需要一个一对一的关系,其中一个产品有多个评论。一些 NoSQL 数据库,如 MongoDB,有一个“文档数组”数据类型,这在这里会有所帮助。

评论字段将是一个列表:

属性 期望的数据类型
ID 整数
用户名 字符串
日期 日期时间
文本 字符串

这个模型需要其他观察:

  • ID属性可以定义为整数,其中每个新评论的ID将是最后保存的评论ID加一。然而,SimpleDB 没有提供任何自动递增字段的特性。为了避免在保存新评论之前查询最后一个评论ID,以及由于缺乏事务而可能引起的冲突,我们可以使用这个属性以字符串形式保存一个全局唯一标识符UUID)。

  • 日期属性将在稍后讨论。

下表列出了ShoppingCart域的属性:

属性 期望的数据类型
UserID 字符串
最后更新 日期时间
SelectedProducts 文档数组

由于我们将使用 Amazon Cognito,所以UserID被定义为字符串类型。这个模型唯一的问题是,我们希望有一个字段来存储日期时间,另一个字段来存储数据数组,其中SelectedProductsProductIDQuantity对的列表定义。

处理一对一关系

在之前的模型中,我们看到了一个产品有多个评论,一个购物车有多个选定的产品。在一个关系型数据库中,我们会创建另一个表来列出所有评论或选定的产品,并在查询特定产品或购物车时使用join操作符来检索所有相关数据。然而,在 NoSQL 中,我们通常没有join操作符,所以我们需要进行两个独立的查询来检索所需的数据,或者我们可以将所有相关数据保存到一个字段中,作为一个文档数组。

在 SimpleDB 中,我们没有“文档数组”数据类型,但有另外两种选择:

  • 保存 JSON 对象的字符串化数组

  • 多值属性

第一种方案是一个笨拙的解决方案,你可以将 JavaScript 对象的数组字符串化并保存到单个属性中。问题是,你将无法查询这个字段中的属性,所以忘记查询“有多少不同的用户订购了 ProductID lonely-bird?”这样的查询。

第二种方案是最佳解决方案,因为 SimpleDB 允许你拥有多个具有相同名称的属性。看看以下ShoppingCart数据集,它使用了多值属性:

UserID LastUpdate ProductID QuantityX ProductID QuantityY ProductID QuantityZ
A <Date> X 2 Y 2 Z 4
B <Date> X 3
C <Date> X 1 Y 5

ProductID属性以相同的名称重复多次,这不是问题,因为 SimpleDB 允许两个具有相同名称的属性。SimpleDB 不允许两个具有相同名称和值的属性。在第一个项目(UserID值为A)中,我们有一个值为XProductID和一个值为YProductID,这是有效的。问题在于Quantity属性,因为两个属性在同一个项目中都有值为2。为了解决这个问题,将ProductID值附加到属性名称上,创建了QuantityXQuantityY属性。

SimpleDB 域是无模式的,这意味着当你插入一个新项目时,你只需说明它具有哪些属性,如果添加了一个尚不存在的属性名称,它也不会返回错误。

处理数字

SimpleDB 的最大问题不在于如何将数据作为字符串存储,而在于如何使用查询来检索它。你可以将数字27保存为"27",但使用Quantity > "5"进行过滤查询不会返回期望的值。

将数值数据作为字符串处理的一个解决方案是在保存之前对其进行修改。与其保存"27",不如使用零填充函数并将其存储为"000027"。现在用Quantity > "000005"进行查询,你将得到你想要的价值。

你需要添加多少个零?这取决于。考虑一下你的数据集可以达到的最大数字,并将所有其他数字都零填充以具有相同的字符数。

这个技巧适用于整数。如果你有一个小数,比如Price属性,你需要将其乘以小数位数。在这种情况下,在保存值之前乘以 100,在检索时除以 100。

另一个问题是如何处理负数。在这种情况下,你需要添加一个偏移量。这个偏移量必须大于你整个数据集中最大的负数。例如,如果你的偏移量是100,000,则必须将-27加到100,000(结果为99973),并用六个零进行零填充,结果为"099973"。如果你需要比较数字是否大于5,你需要添加偏移量并零填充比较值,结果为Quantity > "100005"

处理布尔值

你可以将布尔值存储为true/false1/0。你可以选择你喜欢的,只需定义一个约定并在所有布尔属性中使用相同的策略。

这里有一个例子:

    const boolean = true; 

    // save 'true' in the database
    const booleanStr = boolean.toString(); 

    // convert 'true' (string) to true (boolean)
    const booleanAgain = Boolean(booleanStr); 

处理日期

当保存日期时间变量时,你可以使用 ISO 8601 标准格式,例如,5:15:10 PM December 24th 2016 UTC变为2016-12-24T17:15:10.000Z。此格式可以用字符串进行查询。因此,Date > "2016-12-24T00:00:00.000Z"将返回上一个示例的值。

现在考虑你有一个LastAccess属性,你想要查询在过去 5 分钟内访问过你的系统的所有用户。在这种情况下,你只需要找到当前时间,减去 5 分钟,然后在查询之前将其转换为 ISO 字符串。

创建域

创建域相当简单。你只需要将域名作为参数设置,它将通过createDomain函数创建。

这里有一个例子:

    const AWS = require('aws-sdk');
    const simpledb = new AWS.SimpleDB();

    const params = { 
      DomainName: 'Products'
    }; 

    simpledb.createDomain(params, (err, data) => { 
      if (err) console.log(err, err.stack); 
      else console.log(data);
    });

关于属性,在创建域时你不需要指定它们。没有附加模式。每个项目都有自己的属性列表,这些属性不一定与其他属性相同。

限制

SimpleDB 是为小型工作负载设计的,因此 AWS 实施了一些限制,这些限制可能会限制你的应用程序。在下面的表中,我列出了在使用 SimpleDB 之前你应该注意的最重要限制。你可以在官方文档中找到更多关于此的信息:docs.aws.amazon.com/AmazonSimpleDB/latest/DeveloperGuide/SDBLimits.html

参数 限制
域大小 每个域 10 GB
域大小 每个域 1 亿个属性
属性值长度 1,024 字节
Select 响应中的最大项目数 2,500
最大查询执行时间 5 秒
Select 的最大响应大小 1 MB

插入和查询数据

以下示例展示了如何将数据插入 SimpleDB 域。我使用 batchPutAttributes 因为它允许同时进行多个插入,但您也可以调用 putAttributes 来插入单个项:

    const AWS = require('aws-sdk'); 
    const simpledb = new AWS.SimpleDB();

    const insertParams = { 
      DomainName: 'Products',
      Items: [
        {
          Attributes: [
            {
              Name: 'Name',
              Value: 'Lonely Bird'
            },
            {
              Name: 'Price',
              Value: '2999'
            },
            // more attributes
          ],
          // needs to be unique
          Name: 'lonely-bird'
        },
        // more items
      ]
    }; 

 simpledb.batchPutAttributes(insertParams, (err, data) => { 
      if (err) console.log(err, err.stack); 
      else console.log(data);
    });

以下示例展示了如何查询之前插入的数据。尽管 SimpleDB 是一个 NoSQL 数据库,但在查询时它使用类似 SQL 的语法:

    const AWS = require('aws-sdk');
    const simpledb = new AWS.SimpleDB(); 

    const selectParams = {
      SelectExpression: 'select * from Products where Name = "Lonely Bird"'
    };

    simpledb.select(selectParams, (err, data) => { 
      if (err) console.log(err, err.stack); 
      else if (data.Items) {
        data.Items.map(item => {
          item.Attributes.map(attr => {
            console.log(attr);
          });
        });
      }
      else console.log('No results');
    });

上述代码将生成以下输出:

    { Name: 'Name', Value: 'Lonely Bird' }
    { Name: 'Price', Value: '2999' }

性能和并发

AWS 将自动为每个创建的属性创建索引,但通过日期或转换为字符串的整数进行数据过滤查询可能会轻易导致性能问题。您应该始终注意您的性能需求和域的大小。

此外,像大多数 NoSQL 数据库一样,SimpleDB 不支持事务,因此并发可能会成为数据一致性的真正问题。而不是事务,SimpleDB 提供了 条件操作。例如,如果您需要插入一些数据,您可以放置一个条件,只有当属性尚不存在时才会执行该操作。另一个用例是实现计数器。您将只更新计数器的值为 X+1,如果当前值是 X。如果这个条件不满足,那是因为另一个用户已经增加了值,您的更新将被取消。

这里是一个条件操作的示例:

    const AWS = require('aws-sdk');
    const simpledb = new AWS.SimpleDB(); 

    const params = {
      Attributes: [
        {
          Name: 'Counter',
          Value: '10', // new value
          Replace: true
        }
      ],
      DomainName: 'MyCounter',
      ItemName: '123', // identifier
      Expected: {
        Exists: true,
        Name: 'Counter',
        Value: '9' // previous value
      }
    };

    simpledb.putAttributes(params, (err, data) => {
      if (err) console.log(err, err.stack);
      else console.log(data);
    });

管理数据库

您可以使用 AWS CLI 或 SDK 来管理您的数据库,但许多人更喜欢使用提供用户界面的工具。由于 AWS 不为 SimpleDB 提供控制台,我们需要依赖第三方工具。在这种情况下,我可以推荐 Chrome 扩展程序 SdbNavigator。这是一个非常简单的工具,但提供了一个很好的用户界面,具有创建域名、插入项和执行查询等基本功能。

查看以下步骤以管理数据库:

  1. 使用 Chrome,您可以添加来自chrome.google.com/webstore/detail/sdbnavigator/ddhigekdfabonefhiildaiccafacphgg 的扩展程序。

  2. 安装后,添加您的 AWS 密钥并选择一个区域进行连接。您可以使用添加域名按钮添加新的域名:

图片

  1. 此工具有一个添加属性的按钮。当您添加新项时,这些属性将成为项的属性:

图片

  1. 添加记录按钮用于添加您的域名项:

图片

备份和恢复数据

不幸的是,AWS 没有提供原生的功能来一致性地备份和恢复 SimpleDB 域。解决方案是执行查询以读取所有数据(备份),并在需要时使用脚本插入(恢复)这些保存的数据。然而,主要问题是数据一致性。如果在您复制数据时应用程序正在运行,则无法保证您的备份是一致的。应用程序可能已经开始了一个删除操作,而您的备份可能仍然包含一些应该被删除的项目。

除了这个一致性问题之外,您在复制/插入数据时仍然会遇到问题,因为 AWS 对此操作设置了众多限制。例如,select 中的最大项数是 2,500 项。为了解决第二个问题,您可以尝试许多第三方工具之一来减轻这个负担。

控制用户访问

SimpleDB 依赖于 AWS 安全模型。因此,如果您想管理访问权限,您将需要创建 IAM 用户和角色。这种控制的粒度在于用户可以访问的域以及他们可以执行的操作。

在我们的 Lambda 函数中,我们必须明确地赋予权限。如果不设置限制,您无法执行 SimpleDB 请求。此配置在 serverless.yml 文件下的 iamRoleStatements 函数中进行。在以下示例中,我正在为 ProductsShoppingCart 领域赋予读取(sdb:Select)和写入(sdb:PutAttributes)访问权限。如果您想允许完全访问,请使用 "sdb:*" 动作并将域设置为 domain/*

    service: simpledb-example

    provider:
      name: aws
      runtime: nodejs6.10
 iamRoleStatements:
 - Effect: "Allow"
 Action:
 - "sdb:BatchPutAttributes" 
 - "sdb:PutAttributes"
 - "sdb:Select"
 Resource: [
 "arn:aws:sdb:us-east-1:*:domain/Products",
 "arn:aws:sdb:us-east-1:*:domain/ShoppingCart"
 ]

    functions:
      query:
        handler: handler.query

DynamoDB

DynamoDB 是一个完全托管、高可用性的 NoSQL 数据库,可以配置为自动扩展。它不能被视为无服务器数据库的唯一原因是其定价模型。即使没有人使用您的应用程序,您也必须为预留资源付费。

然而,DynamoDB 是一个优秀的数据库,具有许多有用的功能,AWS 提供了慷慨的永久免费层。它被广泛用于许多无服务器项目中,因为它便宜、易于使用,并提供了可预测的性能。在这本书中,我们将使用 DynamoDB 作为我们的主要数据库。如果您浏览这一章的代码文件,您将看到无服务器存储的数据层是用 SimpleDB 和 DynamoDB 实现的,但 DynamoDB 将是默认的,也是我们将讨论需要为无服务器存储实现哪些功能的数据库。

设计数据库模型

在 DynamoDB 中,一个是一组,每个项是一组称为属性的键值对集合。像大多数 NoSQL 数据库一样,DynamoDB 是无模式的。您只需定义主键,就可以添加具有不同属性的项。

DynamoDB 支持以下数据类型:

  • 标量:存储单个值的数据类型类别:

    • String:最大大小为 400 KB 的 UTF-8 字符串。

    • Number:它最多存储 38 位数字,并接受负数。

    • 布尔型:它存储真或假。

    • 二进制:它允许保存二进制数据。由于最大大小为 400 KB,它可能不是许多应用程序的好选择。我们打算使用 S3 来存储产品图片,并在 DynamoDB 中保存一个String字段以保存 S3 URL。

    • 空值:它表示一个具有未知或未定义状态的属性。

  • 文档:它是一类存储多个值的数据类型:

    • 列表:它存储一个有序的值集合。它类似于可以存储任何类型元素的数组。例如:[5, "foo", 2, -4, "bar"]

    • Map:它存储一个无序的值集合。它与 JSON 对象类似。例如:{ "Name": "foo", "Address": 123 }

  • 集合:它是一个数据类型,您可以将数据作为数组存储,但所有元素必须是唯一且相同的数据类型。此外,顺序不被保留。例如:一组数字可以是[1, 7, 2, -4]

对于无服务器存储,我们需要创建两个表,例如ProductsShoppingCart。它们将被定义为以下内容:

  • Products:查看以下表格,描述其属性:
属性 数据类型
ID 字符串
Name 字符串
Price 数字
Image 字符串
Comments 地图对象列表
  • Comments:查看以下表格,描述其属性:
属性 数据类型
ID 字符串
Username 字符串
Date 字符串
Text 字符串
  • ShoppingCart:查看以下表格,描述其属性:
属性 数据类型
UserID 字符串
LastUpdate 字符串
SelectedProducts 地图对象列表
  • SelectedProducts:查看以下表格,描述其属性:
属性 数据类型
ProductID 字符串
Quantity 数字

关于此模型的一些观察如下:

  • CommentsSelectedProducts属性被定义为地图对象列表,这意味着我们将保存一个有序的 JSON 对象列表

  • 就像 SimpleDB 一样,DynamoDB 没有自增字段,所以我们将使用 UUIDs 作为评论 ID

  • DynamoDB 不支持 datetime 数据类型,因此我们需要将DateLastUpdate属性定义为使用 ISO 格式的字符串

创建表

我们将使用 AWS SDK 创建无服务器存储的表。由于 DynamoDB 是一个无模式数据库,我们只需要设置主键,属性将在插入项目时定义。

使用以下示例来创建它们:

    const AWS = require('aws-sdk');
    const dynamodb = new AWS.DynamoDB();

    let params = {
      TableName: 'Products',
      AttributeDefinitions: [
        {
          AttributeName: 'ID',
          AttributeType: 'S' // string
        }
      ],
      KeySchema: [
        {
          AttributeName: 'ID',
          KeyType: 'HASH'
        }   
      ],
      ProvisionedThroughput: {
        ReadCapacityUnits: 5, // default value
        WriteCapacityUnits: 5 // default value
      } 
    };

 dynamodb.createTable(params, (err, data) => {
      if (err) console.log(err, err.stack);
      else console.log(data);
    });

您可以使用相同的代码创建ShoppingCart表。只需将表名更改为ShoppingCart,并将主键名称更改为UserID

限制

DynamoDB 在构建应用程序之前强加了一些需要考虑的限制。它们如下列出:

参数 限制
表的数量 每个账户 256 个
表大小 项的数量没有限制
配置吞吐量 最多 40,000 个读和 40,000 个写容量单位
项目大小 一个项目的所有属性的大小之和不得超过 400 KB
二级索引 每个表 5 个本地和 5 个全局二级索引
API BatchGetItem() 最大 100 个项目或 16 MB 检索
API BatchWriteItem() 最大 25 个插入或删除请求或 16 MB 发送
API Query 或 Scan 结果集限制为 1 MB

插入和查询数据

我们将在本节中讨论如何使用 DynamoDB 插入和查询数据。

插入数据

DynamoDB 提供了两种插入数据的方式,例如putItem()batchWriteItem()。它们之间的区别在于,putItem允许你创建一个新项目或更新现有项目,而batchWriteItem允许你创建或删除多个项目,但不支持更新操作。

以下是一个putItem方法的示例:

    const AWS = require('aws-sdk');
    const dynamodb = new AWS.DynamoDB();    

    const params = {
      TableName: "Products",
      Item: {
        ID: { S: "lonely-bird" },
        Name: { S: "Lonely Bird" },
        Price: { N: "29.99" },
        Image: { S: "https://s3.amazonaws.com/..." },
        Comments: { 
          L: [
            { 
              M: { 
                ID: { S: "ABC"}, 
                Username: { S: "John Doe"},
                Date: { S: "2016-12-24T17:15:10.000Z" },
                Text: { S: "I liked it." }
              }
            },
            {
              M: { 
                ID: { S: "XYZ"}, 
                Username: { S: "Jane Smith"},
                Date: { S: "2016-12-24T18:15:10.000Z" },
                Text: { S: "I liked it too." }
              }
            } 
          ] 
        }
      }
    };

 dynamodb.putItem(params, (err, data) => {
      if (err) console.log(err, err.stack);
      else console.log(data);
    });

文档客户端 API

正如你所见,前面的示例展示了如何插入单个项目,但语法非常复杂。要定义一个字符串属性,我们需要创建一个 JSON 对象,其中键是"S"(字符串),值是所需的数据。

为了使这项任务更容易,我们可以使用 Dynamo 的文档客户端 API 通过使用原生 JavaScript 类型来抽象属性值,以进行读写操作。

以下示例展示了如何使用此 API 插入相同的项目。请注意,我们需要使用new AWS.DynamoDB.DocumentClient()检索客户端,命令是put而不是putItem

    const AWS = require('aws-sdk');
 const documentClient = new AWS.DynamoDB.DocumentClient();

    const params = {
      TableName: "Products",
      Item: {
        ID: "lonely-bird",
        Name: "Lonely Bird",
        Price: 29.99,
        Image: "https://s3.amazonaws.com/...",
        Comments: [
          { 
            ID: "ABC", 
            Username: "John Doe",
            Date: "2016-12-24T17:15:10.000Z",
            Text: "I liked it."
          },
          {
            ID: "XYZ", 
            Username: "Jane Smith",
            Date: "2016-12-24T18:15:10.000Z",
            Text: "I liked it too."
          } 
        ] 
      }
    };

 documentClient.put(params, (err, data) => {
      if (err) console.log(err, err.stack);
      else console.log(data);
    });

查询数据

要查询我们刚刚插入的项目,DynamoDB 提供了两种方法,例如scan()query()。我们将在下一节中看到它们是如何工作的。对于这两种方法,我们将使用文档客户端。

扫描方法

scan方法用于检索表中所有项目,无需按键进行筛选。筛选是可能的,但不是必须的。这种方法的问题在于,对于大型表,你需要进行多次请求,因为当扫描超过 1 MB 的数据时,它将中断操作。当扫描操作中断时,结果集将包含一个LastEvaluatedKey参数,可用于进一步的请求:

    const AWS = require('aws-sdk');
 const documentClient = new AWS.DynamoDB.DocumentClient();

    const params = {
      TableName: 'Products'
    };

 documentClient.scan(params, (err, data) => {
      if (err) console.log(err, err.stack);
      else console.log(data);
    });

查询方法

query方法基于哈希键查找项目。它与scan方法类似,因为如果读取超过 1 MB 的数据,查询将被中断,返回一个LastEvaluatedKey参数,但queryscan之间的主要区别在于,query在读取数据之前会考虑筛选条件,而scan会在读取表之后应用筛选。

以下是一个仅查询Lonely Bird产品的示例:

    const AWS = require('aws-sdk');
 const documentClient = new AWS.DynamoDB.DocumentClient();

    const params = {
      TableName: "Products",
      KeyConditionExpression: "ID = :id",
      ExpressionAttributeValues: { ":id": "lonely-bird" }
    };

 documentClient.query(params, (err, data) => {
      if (err) console.log(err);
      else console.log(data);
    });

性能和并发

与大多数 NoSQL 数据库一样,DynamoDB 不支持事务。原子操作只能在项目级别进行,这意味着你可以原子性地更改单个项目的两个属性,但你不能在单个操作中更新两个不同的项目。像 SimpleDB 一样,DynamoDB 支持条件更新以实现计数器。

关于性能,DynamoDB 为哈希键和可选的范围键创建索引,但如果你需要通过其他字段进行查询过滤,你需要创建额外的索引。例如,如果你想找到所有价格超过 10 美元的产品,你需要为 价格 属性创建一个索引。在我们的无服务器存储模型中,我们不需要这样做,因为我们只会通过两个表的哈希键进行查询,但我们将描述如何添加额外的索引。

首先,你需要了解 DynamoDB 有以下两种索引类型:

  • 本地二级索引:与基础表具有相同分区键的索引

  • 全局二级索引:一个不限于基础表相同分区的索引

它们之间的一个区别是,本地索引使用与哈希键相同的预配吞吐量,而全局索引则需要你为它们支付额外的预配吞吐量。全局索引的好处是,你不需要包含哈希键的过滤器,可以直接通过你指定的键进行过滤。在之前的例子中,如果你想查询所有价格高于 10 美元的产品,你需要为 价格 属性创建一个全局索引。

现在假设你有一个名为 Orders 的表,该表保存了 订单 ID产品 ID价格 以及其他信息。订单 ID 将作为哈希键,对于单个订单,我们会有许多条目。例如,看一下以下表格:

订单 ID 产品 ID 价格
1 77 15.99
1 88 18.99
1 23 12.99
2 18 15.00

在此模型中,如果你想通过 订单 ID 号码 1 进行查询并按 价格 大于 15 进行过滤,你会为 价格 属性创建一个 本地 二级索引,而不是 全局 索引。

以下示例显示了创建本地和全局索引的语法:

    const params = {
      TableName: 'TableWithIndexes',
      AttributeDefinitions: [
        { AttributeName: 'ID', AttributeType: 'S' },
        { AttributeName: 'MyOtherAttribute', AttributeType: 'S' },
        { AttributeName: 'MyLocalAttribute', AttributeType: 'S' },
        { AttributeName: 'MyGlobalAttribute', AttributeType: 'S' }
      ],
      KeySchema: [
        { AttributeName: 'ID', KeyType: 'HASH' },
        { AttributeName: 'MyOtherAttribute', KeyType: 'RANGE' }
      ],
      ProvisionedThroughput: 
        { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
      LocalSecondaryIndexes: [
        { 
          IndexName: 'MyLocalIndex',
          KeySchema: [
            { AttributeName: 'ID', KeyType: 'HASH' }, 
            { AttributeName: 'MyLocalAttribute', KeyType: 'RANGE' }
          ],
          Projection: { ProjectionType: 'ALL' }
        }
      ],
      GlobalSecondaryIndexes: [
        { 
          IndexName: 'MyGlobalIndex',
          KeySchema: [
            { AttributeName: 'MyGlobalAttribute', KeyType: 'HASH' }
          ],
          Projection: { ProjectionType: 'ALL' },
          ProvisionedThroughput: 
            { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }
        }
      ]
    };

你可以使用 DynamoDB.updateTable() 在创建表后添加 全局二级索引,但你只能在创建表时添加 本地二级索引。无法通过更新表来添加本地索引。

管理数据库

AWS 为 DynamoDB 提供了一个管理控制台,你可以在此配置表的容量、创建索引和查看 CloudWatch 指标。在以下步骤中,我将展示如何查看和操作你的表数据:

  1. 在此链接浏览管理控制台 console.aws.amazon.com/dynamodb.

  2. 在左侧菜单中,点击“表”:

  1. 现在点击您的表名称,然后在“项目”选项卡中。在此选项卡中,您可以创建、删除或更新项目。将自动执行扫描查询,但您可以根据需要更改查询参数以查看其他项目。点击项目 ID 以打开编辑模式:

图片

  1. 编辑项目模式允许您查看项目的所有属性,并在需要时更新属性:

图片

配置吞吐量

DynamoDB 的性能基于读和写操作的配置吞吐量。一个 读容量单元 代表每秒一个强一致性读取或对于大小最多为 4 KB 的对象每秒两个最终一致性读取,而一个 写容量单元 意味着您每秒可以写入一个 1 KB 的对象。您在创建表时需要定义这些值,但您可以在以后更新它们。在本章的示例中,表为每个键创建了五个读单元和五个写单元。

如果您的系统请求的读/写操作数超过了配置的容量,AWS 将允许在短时间内无错误地执行这些操作。如果您继续超出配置容量,一些请求将因 ProvisionedThroughputExceededException 错误而失败。好消息是,AWS SDK 内置了对延迟请求的重试支持,因此我们不需要编写此逻辑。

自动扩展

您可以配置 CloudWatch 在您的 DynamoDB 使用量高于配置吞吐量时发送电子邮件警报,并在必要时手动更新容量,或者您还可以配置自动扩展。由于我们想要避免担心服务器和可伸缩性,我们将配置自动扩展来为我们处理这个负担。

自动扩展将主动管理吞吐量容量,以在您的负载增加或减少时自动扩展和缩减,以匹配您的应用程序利用率。我们需要配置的是读和写容量单元的范围(上限和下限)以及此范围内的目标利用率百分比。您可以通过管理控制台访问自动扩展配置。在您想要启用此设置的表中单击,然后选择“容量”选项卡。

以下截图显示了自动扩展配置的示例:

图片

备份和恢复数据

不幸的是,DynamoDB 不提供简单的备份和恢复功能。AWS 提出的方案是使用其他两个服务来完成这项任务,例如 AWS 数据管道Amazon Elastic MapReduceEMR)。由于此配置的复杂性和长度,本书不会涉及。您可以遵循 AWS 教程来实现此任务:

AWS 数据管道文档

简而言之,你需要使用 AWS Data Pipeline 模板为 DynamoDB 创建任务,并安排一个任务来启动 EMR,使用 Hive 保存/恢复 DynamoDB 表。

控制用户访问

就像 SimpleDB 一样,我们通过 IAM 角色来管理 DynamoDB 用户访问。我们必须明确地给 Lambda 函数赋予权限,以便它们能够执行请求。这种配置是在 serverless.yml 文件下的 iamRoleStatements 函数中完成的:

    service: dynamodb-example

    provider:
      name: aws
      runtime: nodejs6.10
 iamRoleStatements:
 - Effect: "Allow"
 Action:
 - "dynamodb:Scan" 
            - "dynamodb:Query"
            - "dynamodb:PutItem"
 - "dynamodb:DeleteItem"
 - "dynamodb:BatchWriteItem"
 Resource: [
 "arn:aws:dynamodb:us-east-1:*:table/Products",
 "arn:aws:dynamodb:us-east-1:*:table/ShoppingCart"
 ]

    functions:
       query:
         handler: handler.query

优化无服务器存储

在本书的 GitHub 仓库中,你可以找到一个 scripts 文件夹,你可以使用它来创建 DynamoDB 和 SimpleDB 的表,以及用于我们测试的示例数据。此外,在根目录中,你可以找到一个 backend 文件夹,其中包含一个 repositories 文件夹,包含 dynamodb.jssimpledb.jsfakedb.js 文件。示例应用程序使用 fakedb 作为默认数据库,因为它不需要任何配置,因为它只提供硬编码的数据。

我们现在将实现 DynamoDB 代码。在 lib 文件夹中,我们将把依赖项从 const db = require('../repositories/fakedb') 更改为 const db = require('../repositories/dynamodb'),并在 dynamodb.js 文件中,我们需要开发四个方法,如 retrieveAllProductsretrieveCartsaveCartprocessCheckout

获取所有产品

获取所有产品是一个简单的函数,它将执行 scan 操作。由于我们只有少量项目,我们不需要担心这种情况下的 1 MB 限制:

    module.exports.retrieveAllProducts = (callback) => {

      const params = {
        TableName: 'Products'
      };

 documentClient.scan(params, callback);
    };

获取用户的购物车

获取用户的购物车使用一个简单的查询,我们将通过 UserID 进行过滤:

    module.exports.retrieveCart = (userId, callback) => {

      const params = {
        TableName: "ShoppingCart",
        KeyConditionExpression: "UserID = :userId",
        ExpressionAttributeValues: { ":userId": userId }
      };

 documentClient.query(params, callback);
    });

保存用户的购物车

saveCart 函数接收 userIdselectedProducts 作为参数,其中 selectedProductsProductId-Quantity 元素的配对:

    module.exports.saveCart = (userId, selectedProducts, callback) => {

      const params = {
        TableName: "ShoppingCart",
        Item: {
          UserID: userId,
          LastUpdate: new Date().toISOString(),
          SelectedProducts: selectedProducts
        }
      };

 documentClient.put(params, callback);
    };

处理结账

处理支付数据是一个复杂的过程,超出了本书的范围。在这种情况下,我们将实现一个函数,该函数将仅执行回调,并将 null 作为错误参数传递:

    module.exports.processCheckout = (callback) => {
      // do nothing
      callback(null);
    };

Amazon S3(用于媒体文件)

S3 不是一个数据库,它只是一个存储系统。它缺少数据库引擎和许多存储功能,但可以用于保存媒体文件,如照片、视频和音乐。

这种方法已经非常流行。例如,如果你开发了一个使用 MongoDB 数据库的应用程序,你可以使用 MongoDB GridFS 来存储大型二进制数据。然而,最有效的解决方案是将这类数据卸载到云服务中,因为负责你的数据库的机器通常是成本最高的。这意味着数据库中每千兆字节的成本通常高于云存储服务,如 S3。

在我们的无服务器存储中,我们将产品图片存储在 SimpleDB/DynamoDB 中的字符串字段中。我们不是保存完整的二进制数据,而是只保存图像文件的 URL。例如:

s3.amazonaws.com/serverless-store-media/product-images/lonely-bird.jpg

当我们在前端收到这些信息时,<img> 元素具有 src 属性,引用这个 S3 URL:

    <img src={this.props.product.imageURL} alt="product" />

用户将直接从 S3 下载图片,而不是从数据库中下载,从而减轻数据库的负担。

这是 S3 的一种用法。还有两种其他常见用法:

  • 用户需要上传他的头像图片:而不是保存在数据库中,我们可以为用户生成一个临时权限,直接将文件上传到 S3

  • 用户想要查看他的私有相册:而不是请求 Lambda 函数从 S3 下载文件,我们可以生成私有临时链接,用户可以从那里下载文件

在本节中,我们将讨论如何处理这些示例以及如何将 S3 作为媒体文件的数据库使用。

上传和下载文件

如果您的存储桶存储的是公共文件,您可以配置它以允许匿名请求上传和下载文件。但是,如果文件是私有的,您需要向客户端提供预签名 URL 以确保隐私和安全。上传和下载这两个操作都必须进行签名。

这些密钥是在后端生成的,因为您需要使用具有对存储桶凭证访问权限的 SDK。让我们看看以下上传和下载文件的步骤:

  1. 创建一个 Lambda 函数并公开一个端点,以便前端代码可以调用它。使用 S3 对象的 getSignedUrl 函数获取签名 URL:
        const AWS = require('aws-sdk');
        const s3 = new AWS.S3();

        const params = {
          Bucket: 'bucket', 
          Key: 'key'
        };

        const operation = 'putObject'; // upload operation
        // const operation = 'getObject'; // download operation

        s3.getSignedUrl(operation, params, (err, url) => {
          // return the url
        });

  1. 如果操作是下载私有文件,则使用具有此预签名 URL 的锚点标签渲染 HTML,并将 target 属性设置为 _blank 以执行下载:
        <a href="PRE-SIGNED-URL" target="_blank">Download</a>

  1. 如果操作是上传文件,则添加一个 input 元素以接收文件:
        <input type="file" />

  1. 并使用预签名 URL 通过 Ajax 请求上传文件:
        $.ajax({
          url: preSignedUrl, // use the signed URL in the request
          type: 'POST',
          data: file,
          // ...
          success: () => { console.log('Uploaded') },
          error: err => { console.log(err) }
        });

  1. 由于您已使用 Lambda 函数生成了预签名 URL,因此您将知道文件名和文件存储的位置,但如果用户真正启动了文件上传,您将无法确切知道文件上传何时完成。您有一个选项,就是添加另一个 Lambda 函数来接收由 S3 存储桶触发的对象创建事件。

启用 CORS

上一段代码只有在为 S3 存储桶启用 CORS 后才能正常工作。CORS 头是必要的,因为我们将从与 S3 域不同的域进行上传和下载请求。此设置可以使用 S3 控制台进行配置:console.aws.amazon.com/s3。打开您的存储桶属性,选择权限,然后选择 CORS 配置,如下面的截图所示:

此命令将为 GET 请求添加 CORS 配置。在保存之前,我们需要添加一行以包括对 POST 请求的授权,并将允许的头部改为*(所有):

    <CORSConfiguration>
      <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
 <AllowedMethod>POST</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
 <AllowedHeader>*</AllowedHeader>
      </CORSRule>
    </CORSConfiguration>

备份和恢复数据

亚马逊 S3 旨在提供高达 99.999999999%的持久性,这意味着 AWS 会做出巨大努力来复制您的数据并使其免受磁盘故障的影响。尽管您可以在 S3 上放心,但您必须考虑它对您自己的错误并不那么安全。例如,如果您有一个从 S3 删除特定文件的功能,您可能会犯错误并删除错误的文件,或者更糟糕的是,删除所有文件。因此,进行备份对于确保业务更安全的操作非常重要。

您可以将文件本地备份(下载)或在其他外部服务(如 Azure 或 Google Cloud)中创建副本,但这通常不是必要的。您可以使用 AWS CLI 的命令将存储桶中的所有文件保存到另一个存储桶:

 aws s3 sync s3://original-bucket s3://backup-bucket

如果您想将特定时间保存的所有文件恢复到备份存储桶,您需要添加--delete选项以删除目标存储桶中不存在于备份存储桶中的文件:

 aws s3 sync s3://backup-bucket s3://bucket-to-be-restored --delete

使用 S3 版本控制

S3 版本控制是保护数据的另一种方式。一旦启用,每次修改对象时,都会保存一个新的对象,当删除对象时,S3 只需在它上面放置一个删除标记。版本控制允许您恢复意外删除的文件,但您需要支付更多费用以保持这些文件可用。

要配置 S3 版本控制,请转到管理控制台并选择存储桶属性。您将看到一个启用版本控制的选项:

图片

您可以通过配置生命周期规则来删除旧版本文件来降低成本。此设置可以在管理标签页下找到:

图片

为了完成本节,关于安全性的一个观察:如果您的 AWS 访问密钥遭到泄露,恶意用户可能会删除 S3 存储桶中的文件,并删除已版本化的文件。为了防止这种情况,您可以通过启用 MFA 删除来添加额外的保护层。使用此设置,只有当您有权访问 AWS 账户并且能够从认证设备提供访问代码时,才能永久删除文件。

摘要

在本章中,您学习了如何使用无服务器数据库建模、查询和插入数据。我们看到了 SimpleDB 是如何工作的,但由于其功能不足,我们还介绍了如何使用 DynamoDB。此外,您还了解了更多关于 Amazon S3 及其如何用于存储媒体文件的信息。

在下一章中,我们将学习如何在 AWS 上使用身份验证和授权,并检查构建无服务器项目的标准安全实践。

第八章:保护无服务器应用程序

处理安全问题是一个广泛且复杂的话题。如果你没有正确处理,你可能会被黑客攻击。即使你做得一切正确,你也可能会被黑客攻击。因此,了解常见的安全机制以避免使你的网站暴露于漏洞是很重要的,同时,始终遵循经过大量测试和证明为稳健的推荐实践和方法。

在本章中,我们将涵盖以下主题:

  • 基本安全实践和概念

  • 学习如何使用 Amazon Cognito

  • 开发无服务器商店的注册和登录页面

  • 处理后端用户的授权和身份验证

到本章结束时,你将掌握如何在 AWS 上处理安全性的基础知识,以构建一个无服务器网站。

安全基础

安全专家的一个格言是:“不要自己动手”。这意味着你永远不应该在生产系统中使用你自己开发的任何类型的加密算法或安全模型。始终使用那些被高度使用、测试并由可信来源推荐过的解决方案。即使是经验丰富的人也可能犯错,使解决方案暴露于攻击之下,尤其是在需要高级数学的密码学领域。然而,当一个解决方案被大量专家分析和测试时,错误发生的频率会大大降低。

在安全领域,有一个术语叫做模糊安全。它被定义为一种安全模型,其中实现机制不为公众所知,因此人们认为它是安全的,因为没有人在事先了解其缺陷。它确实可能是安全的,但如果将其作为唯一的保护形式,则被认为是一种较差的安全实践。如果一个黑客足够坚持不懈,即使不知道内部代码,他或她也能发现缺陷。在这种情况下,使用经过高度测试的算法比使用自己的算法更好。

模糊安全可以比作某人试图通过在后院埋藏自己的钱来保护自己的钱,而常见的安全机制是将钱存入银行。钱在埋藏时可能是安全的,但它只能得到保护,直到有人发现它的存在并开始寻找它。

由于这个原因,在处理安全问题时,我们通常更喜欢使用开源算法和工具。每个人都可以访问并发现其中的缺陷,但也有很多专业人士参与寻找漏洞并修复它们。

在本节中,我们将讨论在构建系统时每个人都必须了解的其他安全概念。

信息安全

在处理安全问题时,有一些属性需要考虑。其中最重要的包括以下内容:

  • 身份验证:通过验证用户是否是他们声称的人来确认用户的身份

  • 授权:决定用户是否被允许执行请求的操作

  • 保密性:确保数据不会被第三方理解

  • 完整性:保护消息免受未检测到的修改

  • 不可否认性:确保某人不能否认他们自己的消息的真实性

  • 可用性:在需要时保持系统可用

这些术语将在下一节中更好地解释。

认证

认证是确认用户身份的能力。可以通过登录表单实现,要求用户输入他们的用户名和密码。如果散列密码与数据库中之前保存的密码匹配,那么你有足够的证据证明用户就是他们所声称的人。这种模型对于典型应用来说足够好。你通过要求用户提供他们知道的信息来确认身份。另一种认证方式是要求用户提供他们拥有的信息。这可能是一个物理设备(如 USB 密钥)或对电子邮件账户或电话号码的访问。

然而,你不能要求用户为每个请求输入他们的凭据。只要你在第一个请求中认证了它,你就必须创建一个将在后续请求中使用的安全令牌。此令牌将保存在客户端作为 cookie,并将自动发送到服务器上的所有请求。

在 AWS 上,此令牌可以使用 Cognito 服务创建。如何进行此操作将在本章后面描述。

授权

当后端收到请求时,我们需要检查用户是否有权限执行所请求的操作。例如,如果用户想要结账订单 ID 为123,我们需要对数据库进行查询以确定订单的所有者是谁,并比较是否是同一用户。

另一个场景是我们在一个应用程序中有多个角色,并且我们需要限制数据访问。例如,一个用于管理学校成绩的系统可能实现了两个角色,例如学生教师。教师将访问系统以插入或更新成绩,而学生将访问系统以读取这些成绩。在这种情况下,认证系统必须限制属于教师组的用户的插入更新操作,而属于学生组的用户必须限制读取他们自己的成绩。

大多数时候,我们在自己的后端处理授权,但一些无服务器服务不需要后端,并且它们自己负责正确检查授权。例如,在下一章中,我们将看到如何在 AWS 上实现无服务器通知。当我们使用 AWS IoT 时,如果我们想在两个用户之间建立私有通信通道,我们必须让他们访问双方都了解的一个特定资源,并限制其他用户的访问以避免泄露私人消息。这种授权将在下一章中详细介绍。

保密性

在第四章“托管网站”中,我们学习了如何使用 AWS 证书管理器免费请求 TLS 证书以及如何将它们添加到 CloudFront 分发中。开发一个对所有请求都使用 HTTPS 的网站是实现用户与你的网站之间通信机密性的主要驱动力。由于数据被加密,恶意用户很难解密并理解其内容。

尽管有一些攻击可以拦截通信并伪造证书(中间人攻击),但这些攻击需要恶意用户访问受害用户的机器或网络。从我们的角度来看,添加 HTTPS 支持是我们能做的最好的事情,以最大限度地减少攻击的机会。

完整性

完整性与机密性相关。虽然机密性依赖于加密消息以防止其他用户访问其内容,但完整性涉及通过使用数字签名(TLS 证书)加密消息来保护消息免受修改。

完整性是设计低级网络系统时的重要概念,但对我们来说,最重要的是添加 HTTPS 支持。

不可否认性

不可否认性是一个常被与身份验证混淆的术语,因为它们的目标都是证明谁发送了消息。然而,主要区别在于身份验证更关注技术视角,而不可否认性概念更关注法律术语、责任和审计。

当你有一个包含用户名和密码输入的登录表单时,你可以验证知道正确组合的用户,但你不能 100%确定,因为凭证可能被正确猜测或被第三方窃取。另一方面,如果你有一个更严格的访问机制,例如生物识别入口,你会有更高的可信度。然而,这也不是完美的。这只是一个更好的不可否认性机制。

可用性

可用性也是信息安全领域感兴趣的概念,因为可用性不仅限于如何配置硬件以满足用户需求。可用性可能受到攻击,也可能因恶意用户而中断。存在一些攻击,如分布式拒绝服务DDoS),旨在创建瓶颈以破坏网站可用性。在 DDoS 攻击中,目标网站被大量多余的请求淹没,目的是使系统过载。这通常是通过一个由受感染机器组成的受控网络完成的,称为僵尸网络

在 AWS 上,所有服务都在 AWS Shield 服务下运行,该服务旨在无需额外费用即可保护 DDoS 攻击。但是,如果您运行一个非常大且重要的服务,您可能成为高级和大型 DDoS 攻击的直接目标。在这种情况下,AWS Shield 服务提供了一个高级层,以确保即使在最坏的情况下,您的网站也能保持可用性。这需要每月投资 3000 美元,并且您将获得 24x7 的专属团队支持,以及访问其他用于缓解和分析 DDoS 攻击的工具。

AWS 上的安全

在这本书中,我们使用 AWS 凭证、角色和政策,但 AWS 上的安全远不止处理用户的身份验证和授权。这就是我们将在本节中讨论的内容。

共享责任模型

AWS 上的安全基于共享责任模型。虽然亚马逊负责保持基础设施的安全,但客户负责修补软件的安全更新并保护自己的用户账户。

AWS 的责任包括以下内容:

  • 硬件和设施的安全

  • 网络基础设施、虚拟化和存储

  • 按照服务级别协议(SLA)提供的服务可用性

  • Lambda、RDS、DynamoDB 等托管服务的安全

客户的责任包括以下内容:

  • 在 EC2 机器上应用操作系统安全补丁

  • 安装应用程序的安全

  • 避免泄露用户凭证

  • 正确配置访问策略和角色

  • 防火墙配置

  • 网络流量保护(加密数据以避免泄露敏感信息)

  • 服务器端数据和数据库加密

在无服务器模型中,我们仅依赖托管服务。在这种情况下,我们不需要担心对操作系统或运行时应用安全补丁,但我们需要关注我们的应用程序依赖以执行第三方库。当然,我们还需要关注所有需要配置的事项(防火墙、用户策略等),网络流量(支持 HTTPS)以及应用程序如何处理数据。

Trusted Advisor 工具

AWS 提供了一款名为 Trusted Advisor 的工具,可以通过console.aws.amazon.com/trustedadvisor访问。

它旨在提供有关如何优化成本或提高性能的帮助,但它还帮助识别安全漏洞和常见配置错误。它会搜索对您的 EC2 机器上特定端口的未授权访问,如果根账户启用了多因素认证,以及如果您的账户中创建了 IAM 用户。

您需要为 AWS 高级支持付费以解锁其他功能,例如成本优化建议。然而,安全检查是免费的。

突破测试

渗透测试(或渗透测试)是所有大型网站都必须定期执行的良好实践。即使您有一支优秀的安全专家团队,通常的建议也是聘请一家专业的第三方公司进行渗透测试,以发现漏洞。这是因为他们很可能拥有您团队尚未尝试过的工具和程序。

然而,这里的注意事项是,您在联系 AWS 之前不能执行这些测试。为了尊重他们的用户条款,您只能在预定的时间框架内尝试寻找您自己的账户和资产中的漏洞(这样他们可以禁用您的资产入侵检测系统),并且仅限于受限制的服务,例如 EC2 实例和 RDS。

AWS CloudTrail

AWS CloudTrail 是一项旨在记录您账户上执行的所有 AWS API 调用的服务。此服务的输出是一组日志文件,记录了 API 调用者、日期/时间、调用者的源 IP 地址、请求参数以及返回的响应元素。

这种服务对于安全分析非常重要,以防发生数据泄露,以及需要符合性标准审计机制的系统。

多因素认证(MFA)

多因素认证MFA)是一个额外的安全层,每个人都必须将其添加到他们的 AWS 根账户中,以防止未经授权的访问。除了知道用户名和密码外,恶意用户还需要物理访问您的智能手机或安全令牌,这大大限制了风险。

在 AWS 上,您可以通过以下方式使用 MFA:

  • 虚拟设备:安装在 Android、iPhone 或 Windows 手机上的应用程序

  • 物理设备:六位数令牌或一次性密码(OTP)卡

  • 短信:您手机上收到的消息

处理身份验证和授权

在本节中,我们将使用 Amazon Cognito 创建我们应用程序的用户,并能够处理他们的登录。在验证用户身份后,我们将能够为他们允许执行的任务提供适当的授权。

Amazon Cognito

Cognito 提供两种服务,例如 用户池身份池。第一种是您创建和存储用户凭证的地方,后者是您设置用户访问 AWS 资源权限的地方。

我们将首先创建一个用户池,这样我们就可以将注册和登录功能添加到我们的网站上。我们将用户池 ID 添加到我们的前端代码中,请求将直接发送到用户池服务,无需从 Lambda 函数中执行。

之后,我们将配置一个身份池,这将需要提供给用户临时访问 AWS 资源。在我们的示例中,用户将能够直接订阅 IoT 通知,而无需请求后端提供此授权。

创建用户池

让我们看看以下创建用户池的步骤:

  1. 要创建用户池,我们将使用控制台,您可以通过 console.aws.amazon.com/cognito 访问控制台。选择“管理您的用户池”选项:

图片

  1. 在下一屏幕上,点击创建用户池,如图所示:

图片

  1. 现在为您的用户池资源定义一个池名称,并检查您是想快速创建(使用默认值)还是想逐个设置每个选项。我已选择前者(审查默认值):

图片

  1. 下一屏幕将显示您在点击创建池之前需要修订的默认值列表:

图片

  • 以下是一个选项列表以及您在每个选项中需要考虑的内容:

    • 属性:此选项显示您可以选择作为登录所需选项的用户属性列表。通常,对于大多数应用程序来说,一个电子邮件就足够了,但您还可以包括用户名、电话号码或用户图片等属性。此外,您还可以设置自定义用户属性以保存到您的用户配置文件中。

    • 策略:此选项定义了用户的密码必须有多严格。例如,如果您要求最小长度、特殊字符以及大写或小写字母。此外,您还可以限制只有管理员可以创建用户。

    • 验证:您可以在用户注册时请求用户验证电子邮件地址或电话号码(短信)。Cognito 将发送一个用于验证的代码消息。此外,您可以为您的用户启用多因素认证(MFA)作为第二层安全措施。这个功能在当今社会非常重要,可以防止账户被黑客攻击,并且 Cognito 已经实现了这一功能,使其与您的应用程序集成变得非常容易。您可以通过电子邮件或电话启用 MFA。

    • 消息定制:这与之前的配置相关,您可以在其中请求用户验证他们的电子邮件或电话号码。这些消息的文本在此可配置。此外,如果您已经在 Amazon SES 中验证并配置了域名地址,您还可以设置电子邮件消息使用您的域名地址。

    • 标签:如果您想将用户池与一个将在您的计费数据中显示的标签关联起来,此选项非常有用。使用此选项,您可以创建一个带有成本中心或应用程序名称的标签,以便更好地管理成本分配。

    • 设备:您可以让设备被记住以供将来访问。此功能作为便利性而非常有用,并且如果您设备之前已经通过 MFA 进行过认证,您还可以抑制 MFA 请求。

    • 应用程序:您需要创建一个应用程序规范,以限制能够处理您的应用程序登录过程和处理忘记密码的应用程序。此功能创建了一个应用程序密钥和密钥。

    • 触发器:您可以在预注册、预身份验证、身份验证后、创建授权挑战和其他选项中触发 Lambda 函数。基本上,您可以对服务器端流程进行控制以处理用户身份验证。

  1. 创建用户池后,你可以看到分配的 Pool Id 和 Pool ARN。将这些值记下来,因为稍后需要使用:

图片

  1. 在完成此配置之前,我们还需要做一件事。因为我们想让我们的网站处理注册/登录,我们需要创建一个应用程序 ID。浏览“应用客户端”字段以添加我们的网站应用程序,并取消选中“生成客户端密钥”选项,因为此功能不支持 JavaScript SDK:

图片

  1. 创建应用客户端后,记下应用客户端 ID:

图片

创建身份池

现在我们将创建一个身份池。让我们看看以下步骤:

  1. 第一步是浏览到 Cognito 主页中的“联合身份”页面,或者从我们刚刚创建的用户池中找到:

图片

  1. 在创建新的身份池时,请勾选“启用对未经验证身份的访问”复选框。我们稍后将会配置未签名用户可以访问哪些资源,这将与已签名用户可以访问的级别不同。请看以下截图:

图片

  1. 下一个字段是设置“身份验证提供者”参数。Cognito 身份池是一个需要接收来自“身份验证”服务的用户输入的授权服务。在我们的例子中,我们将使用我们刚刚通过填写用户池 ID 和应用客户端 ID 字段创建的 Cognito 用户池,但如果你愿意,你也可以添加对其他提供者的支持,例如 Facebook、Google+ 或 Twitter:

图片

  1. 现在我们需要配置我们的已验证和未验证用户的访问权限。作为一个例子,我们可以允许访问 S3 存储桶中的一个文件夹,以便用户可以直接从网站上传照片,而无需后端执行此操作。在我们的无服务器存储中,我们需要处理与 IoT 相关的通知。所以这就是我们接下来要配置的内容:

图片

  1. 您需要编辑两种类型的策略文档(未经验证和已验证)。现在开始修改已验证用户的文档:
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": [
                "mobileanalytics:PutEvents",
                "cognito-sync:*",
                "cognito-identity:*"
              ],
              "Resource": ["*"]
            },
            {
              "Effect": "Allow",
              "Action": [
                "iot:Connect",
                "iot:AttachPrincipalPolicy"
              ],
              "Resource": ["*"]
            },
            {
              "Effect": "Allow",
              "Action": ["iot:Subscribe"],
              "Resource": [
 "arn:aws:iot:<region>:<account>:topicfilter/<public-topic>",
               "arn:aws:iot:<region>:<account>:topicfilter/<private-topic>"
              ]
            },
            {
              "Effect": "Allow",
              "Action": [
 "iot:Publish",
 "iot:Receive"
              ],
              "Resource": [
 "arn:aws:iot:<region>:<account>:topic/<public-topic>",
                "arn:aws:iot:<region>:<account>:topic/<private-topic>"
              ]
            }
          ]
        }

iot:Connectiot:AttachPrincipalPolicy 需要访问每个资源(*),而我们需要将 iot:Subscribe 限制为 topicfilter/<topic> 资源,将 iot:Publishiot:Receive 限制为 topic/<topic>

  1. 当构建 ARN 时,将 <region> 替换为你将要使用的 AWS IoT 区域,将 <account> 替换为你的账户 ID,将 <public-topic> 替换为 serverless-store-comments,将 <private-topic> 替换为 serverless-store-${cognito-identity.amazonaws.com:sub}。私有主题将允许经过身份验证的用户访问由其联合身份定义的主题。

  2. 对于未经身份验证的访问,使用相同的策略文档,但移除为私有主题添加的额外 ARN。您还可以移除 iot:AttachPrincipalPolicy,因为它对于未经身份验证的用户将不是必需的。

  3. 在创建身份池之后,转到仪表板选项并点击编辑身份池。您将在该屏幕上看到身份池 ID 选项。记下来,因为稍后需要它:

在我们的无服务器存储中使用 Cognito

现在我们将集成我们的 React 前端与 Cognito,以实现注册和登录页面。身份验证方法将直接与 Cognito 进行,而不使用 Lambda 函数。为了使这可行,我们需要配置我们的 React 应用程序:

  1. 首先,通过运行以下命令在我们的前端文件夹中安装模块 amazon-cognito-identity-js
 npm install amazon-cognito-identity-js --save

  1. lib 文件夹内,创建一个 config.js 文件来存储我们的 Cognito ID:
        export default {
          "cognito": {
            "USER_POOL_ID": "YOUR_USER_POOL_ID",
            "APP_CLIENT_ID": "YOUR_APP_CLIENT_ID",
            "IDENTITY_POOL_ID": "YOUR_IDENTITY_POOL_ID",
            "REGION": "YOUR_COGNITO_REGION"
          }
        };

  1. 如前几章所述,我们在 lib 文件夹内创建了一个 services.js 文件来执行所有 Ajax 请求。我们需要从 Cognito 模块导入以下内容:
        import {
          AuthenticationDetails,
          CognitoUser,
          CognitoUserAttribute,
          CognitoUserPool
        } from 'amazon-cognito-identity-js';

现在我们已经为前端使用 Cognito 准备好了。

注册页面

注册表单在 第五章 中创建,构建前端,其外观如图所示:

我们将通过以下步骤实现注册按钮的处理程序:

  1. 我们首先在我们的 services.js 文件中创建一个方法,该方法将执行对 Cognito 的请求,调用 signUp 函数,该函数将使用表单提供的电子邮件和密码:
        signup(email, password, callback) {
          const userPool = new CognitoUserPool({
            UserPoolId: config.cognito.USER_POOL_ID,
            ClientId: config.cognito.APP_CLIENT_ID
          });

          const attributeEmail = [
            new CognitoUserAttribute({ 
              Name: 'email', 
              Value: email 
            })
          ];

 userPool.signUp(email, 
                          password, 
                          attributeEmail,
                          null, 
                          callback);
        }

  1. App 组件将调用此函数,并将结果用户对象保存到其状态中:
        handleSignup(email, password) {    
          Services.signup(email, password, (err, res) => {
            if (err) alert(err);
            else this.setState({newUser: res.user});
          });
        }

  1. 用户成功注册后,我们需要重新渲染注册组件以显示确认请求。用户将被要求填写发送到他们电子邮件中的文本输入值:

  1. 对 Cognito 的请求将使用在注册结果中返回的相同的 user 对象:
        confirmSignup(newUser, code, callback) {
 newUser.confirmRegistration(code, true, callback);
        }

  1. 如果确认码正确,我们可以使用用户提供的电子邮件和密码来验证其访问权限,而无需要求用户再次输入。

如何进行身份验证将在下一节中定义。

登录页面

实现登录页面以验证用户需要几个步骤。让我们通过以下步骤来查看这是如何完成的:

  1. 登录页面也在 第五章 构建前端 中创建,其外观如下:

图片

  1. Login 按钮将触发 services.js 文件中定义的 Cognito 请求:
        login(email, password) {
          const userPool = new CognitoUserPool({
            UserPoolId: config.cognito.USER_POOL_ID,
            ClientId: config.cognito.APP_CLIENT_ID
          });

          const user = new CognitoUser({ 
            Username: email, 
            Pool: userPool 
          });

          const authenticationData = {
            Username: email,
            Password: password
          };

          const authDetails = 
            new AuthenticationDetails(authenticationData);

          return new Promise((resolve, reject) => {
 user.authenticateUser(authDetails, {
              onSuccess: (res) => 
                resolve(res.getIdToken().getJwtToken()),
              onFailture: (err) => reject(err)
            });          });
        }

  1. login 函数将由 App 组件使用。在成功登录后,我们需要将 userToken 保存到 App 的状态中:
        handleLogin(email, password) {
          Services.login(email, password)
            .then(res => {
 this.setState({userToken: res});
            })
            .catch(err => {
              alert(err);
            });
        }

持久化用户令牌

幸运的是,Cognito SDK 会自动将用户令牌持久化到浏览器本地存储中。如果用户在令牌过期之前再次浏览您的网站,数据将保留在那里,可供使用,无需再次请求用户输入电子邮件/密码。

此令牌可以通过以下代码检索:

    getUserToken(callback) {

      const userPool = new CognitoUserPool({
        UserPoolId: config.cognito.USER_POOL_ID,
        ClientId: config.cognito.APP_CLIENT_ID
      });

      const currentUser = userPool.getCurrentUser();

      if (currentUser) {
 currentUser.getSession((err, res) => {
 if (err) 
            callback(err);
 else 
            callback(null, res.getIdToken().getJwtToken());
 });      } else {
        callback(null);
      }
    }

注销

由于用户令牌正在持久化,我们可以在 App 初始化(componentDidMount)时检查其存在,并在导航栏中显示注销按钮而不是登录按钮:

图片

当点击此注销按钮时,我们可以通过执行以下代码来清除令牌:

    handleLogout() {
      const userPool = new CognitoUserPool({
        UserPoolId: config.cognito.USER_POOL_ID,
        ClientId: config.cognito.APP_CLIENT_ID
      });

      const currentUser = userPool.getCurrentUser();
      if (currentUser !== null) {
 currentUser.signOut();
      }

 this.setState({userToken: null});
    }

在 Lambda 函数中处理身份验证

API Gateway 与 Cognito Pools 在用户身份验证方面有很好的集成。我们可以通过 Serverless Framework 进行配置,以便在提供带有令牌 ID 的请求时从 Cognito 获取用户数据。让我们通过以下步骤来查看这是如何完成的:

  1. 修改 serverless.yml 文件以使用 Cognito User Pool 授权器:
        functions:
          products:
            handler: functions/products.handler
            events:
              - http:
 method: POST
 path: cart
 authorizer:
 arn: YOUR_COGNITO_USER_POOL_ARN
              - http: OPTIONS cart

  1. 当 Lambda 函数响应 OPTIONS 请求时,请将 Authorization 作为有效的头信息包含在内:
        "Accept-Control-Allow-Headers":
          "Accept, Authorization, Content-Type, Origin"

  1. 通过运行以下命令重新部署后端:
 serverless deploy

  1. 通过执行以下代码修改前端,以确保始终在 Authorization 头中包含可用的 userToken
        headers: {
 "Authorization": userToken
        }

  1. 现在我们已经可以访问后端的用户信息。如果我们分析 event 对象,可以通过以下代码检索 userId 变量:
        module.exports.handler = (event, context, callback) => {

          let userId = null;

          if (event.requestContext.authorizer)
 userId = event.requestContext.authorizer.claims.sub;

          // ...
        }

userId 术语是一个 通用唯一标识符UUID)。以下是一个 userId 的示例:

 b90d0bba-0b65-4455-ab5a-f30477430f46

claims 对象提供了更多用户数据,如电子邮件,使用 email 属性。

摘要

在本章中,我们讨论了基本的安全概念以及如何在无服务器项目中应用它们。对于我们的演示应用程序,我们使用了 Amazon Cognito 来处理用户的身份验证和授权,因此您已经学习了如何实现注册、登录和注销功能。

在下一章中,我们将使用 Cognito 凭据来访问 AWS IoT 资源以处理无服务器通知。我们将看到后端如何向已认证用户发送消息,以及如何向匿名用户提供实时通知。

第九章:处理无服务器通知

推送通知是现代应用程序的常见用例。它们不仅对移动设备重要,对网站也很重要。当你浏览你的 Facebook 时间线并收到通知,说有朋友评论了你的一张照片时,这就是一个推送通知。在本章中,你将学习如何在无服务器解决方案中实现这一功能。

本章我们将涵盖以下主题:

  • 使用 AWS IoT 实现无服务器通知

  • 公共和私有通知

到本章结束时,你将学会如何在无服务器应用程序中处理实时通知。

AWS IoT

将物联网作为网站的服务听起来可能有些奇怪,但 AWS IoT 是亚马逊提供的唯一支持在无服务器模型中使用 WebSocket 的服务。没有 WebSocket,我们需要依赖 轮询。轮询是客户端需要反复频繁地向服务器发送请求,检查是否有新消息的过程,而 WebSocket 则用于在客户端和服务器之间创建一个连接,服务器可以直接将消息发送到客户端,无需不断请求。WebSocket 用于实现 发布-订阅 模式,这比 轮询 更高效。

除了 AWS IoT,另一个用于实现实时无服务器通知的候选者是亚马逊 简单队列服务SQS)。你可以创建一个消息队列,这些消息是针对单个用户的,并等待这个用户请求 SQS 以查找新消息。虽然轮询对于这个解决方案是必要的,但亚马逊提供了一个名为 长轮询 的功能。使用这个功能,当你向 SQS 请求消息时,AWS 将保持你的请求长达 20 秒,等待新消息的到来。如果在此期间有新消息到达,你将立即收到响应。如果没有消息出现,20 秒后,你将收到一个空响应,并需要发出新的 SQS 请求。这种方法减少了请求的总数和频繁轮询方法相关的成本。

SQS 相比于 IoT 的一个优势是保证了消息的读取。如果你将一条消息放入 SQS,它只有在有人接收它时才会被移除,而在 IoT 中,用户必须连接才能接收消息。

另一个用于通知的服务是亚马逊 简单通知服务SNS)。尽管这个名字听起来像是无服务器通知的明显选择,但它不支持 WebSocket,你不能将浏览器客户端连接到接收按需通知。然而,对于移动应用程序,你可以使用它配合推送通知服务,如 Google Cloud MessagingGCM),以实现无需轮询的实时消息。

你可能不喜欢“物联网”(IoT)这个名字,但它是一项伟大的服务,解决了我们根据无服务器模型获取通知的使用案例。AWS IoT 是一个简单的消息服务。你可以让设备订阅主题以接收其他设备发布的消息。在我们的示例中,你将把设备视为通过网页浏览器连接的用户,他们将接收来自其他用户或 Lambda 函数的消息。

协议

AWS IoT 支持HTTPMQTT通过 WebSockets 的 MQTT协议。HTTP 使用 RESTful 端点,消息队列遥测传输MQTT)是一个为小型传感器和受限设备创建的轻量级消息协议。

你可能会认为对我们这些已经知道如何使用 RESTful 端点的人来说,使用 HTTP 会是最佳选择,但 HTTP 支持仅限于发布消息。在 REST 架构中,你不能订阅消息,因为服务器不能主动发起传输。服务器只能响应请求。

通过 WebSockets 的 MQTT 是对 MQTT 协议的一种增强,旨在支持基于浏览器的应用程序。它支持订阅功能,因此你的用户可以等待消息而不是每隔几秒就不断轮询更新。避免轮询机制对于效率和在你想同时服务数千个用户时的可扩展性是绝对必要的。

查找 IoT 端点

要使用 AWS IoT 服务,你必须提供你想要使用此服务的区域的账户的 IoT 端点。让我们执行以下步骤来查找 IoT 端点:

  1. 你可以使用 IoT 管理控制台找到这些信息,该控制台可在console.aws.amazon.com/iot找到。

  2. 在右上角,你可以更改服务区域。点击“开始”以跳转到下一屏幕:

图片

  1. 在控制台屏幕上,选择位于左下角的“设置”选项:

图片

  1. 将你的 IoT 端点地址记录下来,以便在应用程序中使用:

图片安全观察:端点地址不需要是私有的。你可以在应用程序中将其硬编码并分发给你的客户端,因为前端代码需要知道这个地址来访问 IoT 消息。

  1. 对于我们的演示应用程序,前端代码我们将在config.js文件中包含以下信息:
        "iot": {
          "REGION": "us-east-1",
          "ENDPOINT": "abcdef.iot.us-east-1.amazonaws.com"
        }

AWS IoT SDK

处理 MQTT 协议并对每个请求进行签名可能是一项麻烦的任务。幸运的是,我们不需要“重新发明轮子”。AWS 为我们提供了一个 SDK,它实现了 MQTT 协议,并提供了我们使用所需的所有功能。你可以在 GitHub 上找到源代码:github.com/aws/aws-iot-device-sdk-js

你可以通过执行以下命令使用 npm 安装模块:

 npm install aws-iot-device-sdk --save

要使用此 SDK,我们需要传递以下信息:

  • 凭证:SDK 需要知道 AWS 访问密钥、AWS 秘密访问密钥和会话令牌,以便能够签署请求并获取访问 AWS 资源的授权。我们稍后将使用 Cognito 动态检索临时凭证。

  • 区域:我们将要使用的 AWS IoT 服务的区域。

  • 物联网端点:我们刚刚检索到的物联网端点。

  • 物联网主题:您不需要事先明确创建物联网主题。只需选择一个词,并将其用作交换消息的通道。然而,您的凭证必须具有对此主题或 *(所有主题)的授权。

对于我们的示例,我们将在前端 lib 文件夹中的 iot.js 文件中创建一个类:

    import awsIot from 'aws-iot-device-sdk';
    import config from './config';

    export default class IoT {

      constructor(keys, messageCallback) {  
        this.client = null;
        this.accessKey = keys.accessKey;
        this.secretKey = keys.secretKey;
        this.sessionToken = keys.sessionToken;
        this.messageCallback = messageCallback;
      }

      connect() {
        // TODO
      }

      publish(topic, message) {
        // TODO
      }

      subscribe(topic) {
        // TODO
      }
    }

此类有一个构造函数,它接收必要的凭证和一个 messageCallback 函数,该函数将用作依赖注入。每当收到一条新消息时,我们将调用此 messageCallback 函数来执行创建物联网类的新对象实例的人所期望的逻辑。

现在我们来看看如何实现 connectpublishsubscribe 方法:

    connect() {
      this.client = awsIot.device({
        region: config.iot.REGION,
        host: config.iot.ENDPOINT, 
        accessKeyId: this.accessKey,
        secretKey: this.secretKey,
        sessionToken: this.sessionToken,
        port: 443,
        protocol: 'wss' // WebSocket with TLS 
      });

      this.client.on('connect', this.handleConnect);
      this.client.on('message', this.handleMessage);
      this.client.on('close', this.handleClose);
    }

   publish(topic, message) {
     this.client.publish(topic, message);
   }

   subscribe(topic) {
     this.client.subscribe(topic);
   }

在之前的代码中,connect 方法将 client 对象订阅了三个事件:

  • connect 事件

  • message 事件

  • close 事件

您还可以订阅另外三个事件,使您的应用程序更加健壮:

  • error 事件

  • reconnect 事件

  • offline 事件

最后一步是定义将处理这些事件的函数。它们被定义为以下内容:

    handleConnect() {
      console.log('Connected');
    }

    handleMessage(topic, message) {
      this.messageCallback(topic, message);
    }

    handleClose() {
      console.log('Connection closed');
    }

实现无服务器通知

在上一节中,您学习了关于 AWS IoT SDK 的内容,但我们还没有对其进行测试。在本节中,我们将将其用于我们无服务器商店的以下两个功能:

  • 产品评论页面的实时评论

  • 支付被接受后的通知

第一个功能是一种 公共通知 类型,因为它使用所有用户都可以读取的物联网主题。第二个是 私有通知,因此只有一个人和 Lambda 后端被允许访问物联网主题以订阅或发布消息。我们将介绍这两个,以了解如何为每种情况提供适当的访问权限。

这两个示例将说明您如何使用物联网来提供通知,但这并不限制您可以使用它的方式。您可以思考其他用例。例如,物联网也可以用于无服务器多人游戏。您可以构建一个 HTML5 游戏,该游戏可以向 Lambda 后端发出请求以执行某些逻辑(例如,找到一个游戏房间)以及一个物联网主题,用于玩家之间的消息交换。它可能不适合像 FPS 游戏这样非常动态的游戏,但对于卡牌游戏、谜题和不需要极低且可预测的响应时间的游戏来说可能非常有用且成本低廉。

公共通知

在第五章 构建前端 中,我们定义了产品详情视图,它包含所有客户评论的列表。我们在这里要实现的是实时评论。当用户添加新的评论时,浏览同一页面的另一个用户将看到与发布同一时刻的消息。这可能对客户评论页面来说并不重要,但这种功能对于聊天系统、论坛和社交网络来说非常重要。

添加评论框

以下截图显示了我们的产品详情页的当前状态:

我们将修改 React 应用程序以添加一个如下所示的评论框:

对于这个任务,我们需要创建一个CommentBox组件,该组件将作为输入文本和按钮渲染:

    return (
      <div className="comment-box">
        <input type="text" onChange={this.handleChange} 
               value={this.state.input} />
        <button onClick={this.handleClick}>
          <i className="glyphicon glyphicon-share-alt">
          </i> Send
        </button>
      </div>
    );

在定义元素时,我们为输入文本添加了一个onChange事件来保存输入值,并为发送信息到App组件添加了一个onClick事件。它们如下实现:

    handleChange(e) {
      this.setState({ input: e.target.value });
    }

    handleClick() {
      this.props.onComment(this.state.input, this.props.productId);
      this.setState({ input: '' });
    }

这完成了CommentBox的实现。接下来我们将看到App组件将如何处理这些事件来更新页面并向同一页面的其他用户发送消息。

更新评论列表

App组件中,我们需要处理评论创建。在下面的代码片段中,我们创建一个新的评论对象并将其添加到评论列表数组的开头:

    handleComments(comment, productId) {
      const newComment = {
        id: uuid(),
        username: 'user1337',
        age: 'a few seconds ago',
        text: comment
      };

      const product = this.state
                          .products
                          .find(p => p.id === productId);

      // add to the comment to the beginning of the array
      product.comments.unshift(newComment);

      this.setState({
        products: this.state.products
      });

      // TODO: send the new comment to IoT
    }

为了设置评论的 ID,我使用了UUID模块(npm install uuid --save)来创建一个随机值。UUID 的示例:110ec58a-a0f2-4ac4-8393-c866d813b8d1

我们现在需要做的是将新的评论发送到 IoT 服务,以便与其他页面的用户共享并保存在数据库中。目前,我们的评论功能应该已经工作,并更新客户评论列表:

创建 IoT SDK 的新实例

在本节中,我们将创建一个新的 IoT 类实例,该实例使用 IoT SDK。这个类需要 AWS 访问密钥来连接 IoT 服务。由于我们处理的是不需要认证用户的公共通知,我们需要为匿名用户创建凭证。

让我们按照以下步骤创建 IoT SDK 的新实例:

  1. 我们将开始使用 npm 将 AWS SDK 添加到我们的前端项目中:
 npm install aws-sdk --save

  1. 使用 AWS SDK,我们可以使用以下代码请求对 Cognito 的匿名访问:
        AWS.config.region = config.cognito.REGION;
        AWS.config.credentials = 
 new AWS.CognitoIdentityCredentials({ IdentityPoolId: config.cognito.IDENTITY_POOL_ID
 });

 AWS.config.credentials.get(() => {
          const keys = {
            accessKey: AWS.config.credentials.accessKeyId,
            secretKey: AWS.config.credentials.secretAccessKey,
            sessionToken: AWS.config.credentials.sessionToken
          }
        });

  1. 正如我们在上一章中配置的那样,这个身份池为匿名用户提供了对 IoT 主题serverless-store-comments的访问权限。有了这些密钥在手,我们就可以创建 IoT 类的实例,连接并订阅此主题:
        const getIotClient = (messageCallback, callback) {    
          retrieveAwsKeys(keys => {
            const client = new IoT(keys, messageCallback);
            client.connect();
            client.subscribe('serverless-store-comments');
            callback(null, client);
          });
        }

发送和接收新的评论

App组件是我们负责管理应用程序状态的实体。因此,它将负责发送和接收评论。为了实现这一点,我们需要进行以下三个更改:

  1. 修改componentDidMount以创建 IoT 类的实例:
        componentDidMount() {
          getIotClient(
            this.handleIotMessages, 
            (err, client) => {
              if (err) alert(err);
              else this.setState({iotClient: client})
            });
        }

  1. 修改handleComments函数以使用 IoT 发送新评论:
        handleComments(comment, productId) {
          const newComment = {
            id: uuid(),
            username: 'user1337',
            age: 'a few seconds ago',
            text: comment
          };

          const topic = 'serverless-store-comments';
          const message = JSON.stringify({
            comment: newComment,
            productId: productId
          });

          this.state.iotClient.publish(topic, message);
        }

  1. 创建handleIotMessages函数以接收消息并更新评论列表:
        handleIotMessages(topic, message) {
          const msg = JSON.parse(message.toString());

          if (topic === 'serverless-store-comments') {
            const id = msg.productId;
            const product = this.state
                                .products
                                .find(p => p.id === id);

            product.comments.unshift(msg.comment);
            this.setState({
              products: this.state.products
            });
          }
        }

测试应用程序,使用两个浏览器标签运行它。当你在其中一个标签中添加评论时,相同的评论必须立即出现在另一个标签中。

使用 IoT 触发 Lambda 函数

物联网服务正在用于在连接的用户之间交换实时消息。然而,信息没有被持久化。我们在这里要做的是,当新的消息到达 IoT 主题时触发一个 Lambda 函数,以便该消息可以被持久化。

我们可以通过在serverless.yml文件中配置一个事件来触发 Lambda 函数:

    functions:
      comments:
        handler: functions/comments.handler
        events:
          - iot:
              sql: "SELECT * FROM 'topic-name'"

对于我们的示例,将topic-name替换为serverless-store-comments

物联网(IoT)使用类似 SQL 的语法来触发 Lambda 函数并选择要发送的内容。在先前的示例中,我们将消息的所有内容传递给 Lambda 函数。

这个 SQL 语句可以非常有助于过滤消息,仅在必要时触发 Lambda 函数。例如,假设我们发送以下 JSON 对象的消息:

    {
      "comment": "this is a bad product",
      "rating": 2
    }

我们可以使用 SQL 语句来触发另一个 Lambda 函数,例如handle-bad-reviews,只有当评分低时:

    "SELECT * FROM 'topic-name' WHERE rating < 3"

回到我们的无服务器商店示例,我们已经定义了 Lambda 函数的触发器。现在我们可以实现将数据保存到数据库中的函数。由于在第七章管理无服务器数据库中已经涵盖了使用无服务器数据库,下一个示例将仅为了测试目的记录event对象的内容:

    const utils = require('../lib/utils');

    module.exports.handler = (event, context, callback) => {
      console.log(event);
      utils.successHandler(event, callback);
    };

您可以使用 Serverless Framework 的logs命令来测试它是否正常工作:

 serverless logs --function comments

私有通知

在第八章,保护无服务器应用程序中,我们为已认证的用户定义了一个策略文档,包括对以下 IoT 主题的授权:

 serverless-store-${cognito-identity.amazonaws.com:sub}

这意味着已认证的用户将能够访问一个专有的主题,其名称由其自己的联合身份定义。我们接下来要实现的是一种私有通知,其中 Lambda 函数将消息发布到 IoT 主题,只有一位用户能够接收它。

使用已认证用户的凭据

对于未认证的用户,我们看到了如何使用以下代码设置凭据:

    AWS.config.region = config.cognito.REGION;
    AWS.config.credentials = 
      new AWS.CognitoIdentityCredentials({
        IdentityPoolId: config.cognito.IDENTITY_POOL_ID
      });

然而,对于已认证的用户,需要将credentials对象设置为一个额外的属性:Logins。以下代码展示了如何实现这一点:

    const region = config.cognito.REGION;
    const pool = config.cognito.USER_POOL_ID;
 const authenticator = 
 `cognito-idp.${region}.amazonaws.com/${pool}`;

    AWS.config.credentials = 
      new AWS.CognitoIdentityCredentials({
        IdentityPoolId: config.cognito.IDENTITY_POOL_ID,
 Logins: {
 [authenticator]: userToken
 }
      });

更新登出功能

当我们使用 AWS 凭证功能时,AWS SDK 将用户数据保存到本地存储。为了避免另一个用户在同一个浏览器上登录并使用前一个用户的凭证,我们需要在注销时清除这些数据。这是通过将以下代码片段添加到 Logout 处理器来完成的:

    if (AWS.config.credentials) {
 AWS.config.credentials.clearCachedId();
    }

创建 IoT 策略

使用认证用户连接到 IoT 需要额外一步:我们需要附加一个 IoT 安全策略。如果没有这个附加,IoT 服务将拒绝所有请求。

让我们通过以下步骤来查看如何创建此策略:

  1. 打开 IoT 控制台,console.aws.amazon.com/iot

  2. 在左侧菜单中,导航到安全 | 策略,然后点击创建策略:

图片

  1. 选择一个策略名称,使用操作 iot:Connectiot:Subscribeiot:Publishiot:Receive,对于资源输入 *,并检查允许的效果:

图片

  1. 点击创建以完成。

安全观察:虽然我们选择了 * 资源,但我们无法订阅或发布到所有主题,因为 AWS 将使用 Cognito 角色来检查权限,而这个策略文档被设置为受限访问。

将 IoT 策略附加并连接

在上一章中,我们将 Cognito 策略文档设置为允许访问 iot:attachPrincipalPolicy 动作。现在我们将使用它。在获取 AWS 凭证后,我们将使用 AWS.Iot 模块和 attachPrincipalPolicy 函数将我们刚刚创建的 IoT 策略附加到认证用户。设置策略后,我们将连接到 IoT 并订阅公共和私有主题:

    AWS.config.credentials.get(() => {
      const keys = {
        accessKey: AWS.config.credentials.accessKeyId,
        secretKey: AWS.config.credentials.secretAccessKey,
        sessionToken: AWS.config.credentials.sessionToken
      }

 const awsIoT = new AWS.Iot();
 const params = {
 policyName: 'iot-policy',
 principal: AWS.config.credentials.identityId
 }

 awsIoT.attachPrincipalPolicy(params, (err, res) => {
        if (err) alert(err);
        else {
          const client = new IoT(keys, messageCallback);
          client.connect();

          // subscribe to the public topic
          client.subscribe('serverless-store-comments');

 // subscribe to the private topic
 const id = AWS.config.credentials.identityId;
 client.subscribe('serverless-store-' + id);

          callback(null, client);
        }
      });
    });

将 Cognito 身份传递给 Lambda 函数

在上一章中,当我们定义了对 IoT 资源的受限访问时,我们使用了 ${cognito-identity.amazonaws.com:sub} IAM 策略变量来定义 IoT 主题名称。此参数使用 Cognito 身份,但后端代码不知道此值。Lambda 函数将通过授权器(event.requestContext.authorizer.claims.sub)检索一个用户 ID,但授权器 ID 与 Cognito 身份不同。

为了将此值从前端代码传递到后端,AWS 建议我们使用其 Signature Version 4Sigv4)签名过程发送已签名的请求。此外,您不需要在 API Gateway 中设置 Cognito 授权器,而需要使用 AWS_IAM 授权器。这是将此信息传递到后端最安全的方式,因为这种方法保证只有真实用户才能发送其 Cognito ID。

然而,我们在这里不会涉及这个话题。使用Sigv4签名请求并使用AWS_IAM授权器要比使用 Cognito 授权器和我们的演示应用所需的复杂得多,因为我们使用授权器 ID 来识别用户,而不是 Cognito ID。此外,由于我们已经配置了 IoT 角色策略,一个用户无法接收为另一个用户创建的消息,即使恶意用户知道另一个用户的身份。最坏的情况是恶意用户触发对其他用户的未预期消息,这只会发生在其他用户的凭证被破坏的情况下。

因此,在我们的例子中,我们将通过Checkout请求中的AWS.config.credentials.identityId参数,从前端发送 Cognito ID 到后端。

使用 Lambda 发送 IoT 消息

我们已经修改了应用程序,使认证用户订阅了公共和私有主题。我们现在要探讨的是如何通过以下步骤使用 Lambda 函数向这个私有主题发送消息:

  1. 第一步是修改serverless.yml文件,以明确权限允许访问iot:Publish
        provider:
          name: aws
          runtime: nodejs6.10
 iamRoleStatements:
 - Effect: "Allow"
 Action:
 - "iot:Publish"
 Resource: 
 "arn:aws:iot:<region>:<account>:topic/*"

  1. 对于我们的示例,我们将使用processCheckout函数。用户将点击结账,这个动作将触发一个 Lambda 函数,该函数将向用户主题发布一条消息。结果是通知图标颜色改变,以通知用户有新消息可用:

图片

  1. 修改前端应用程序是一个简单的任务,所以这将是读者的练习。至于后端代码,我们将为processCheckout函数使用以下代码:
        const AWS = require('aws-sdk');
        const utils = require('./utils');

        module.exports.processCheckout = (cognitoId, callback) => {
          const iotdata = new AWS.IotData({
            endpoint: 'YOUR_IOT_ENDPOINT'
          }); 

          const params = {
            topic: 'serverless-store-' + cognitoId,
            payload: 'Your payment was confirmed.'
          };

 iotdata.publish(params, (err, res) => {
            if (err) utils.errorHandler(err, callback);
            else utils.successHandler(res, callback);
          });
      };

记住,userId变量是在前一章通过分析event对象检索的:event.requestContext.authorizer.claims.sub

摘要

在本章中,你学习了如何使用 AWS IoT 服务创建无服务器通知。我们介绍了如何实现实时评论系统和为单个用户推送通知。你已经知道如何使用 AWS IoT 设备 SDK,以及如何使用 IoT 触发 Lambda 函数或使用 Lambda 向 IoT 端点发送消息。

在下一章中,我们将完成在线商店,展示如何测试我们的无服务器应用,然后定义开发和生产环境中的部署工作流程,最后我们将展示在无服务器解决方案中你可以(和应该)监控的内容。

第十章:测试、部署和监控

我们正在接近本书的结尾,但在讨论一些超出编写解决方案编码的方面之前,我们无法结束。我们需要了解您如何测试运行在您不拥有的环境中的函数,一个好的开发工作流程来部署和交付您解决方案的新版本,以及尽管在构建无服务器项目时我们不需要担心服务器,但我们仍需要了解我们需要配置的最小监控,以提供成本效益和可靠的解决方案。

在本章中,我们将涵盖以下主题:

  • 测试无服务器解决方案

  • 定义如何处理新版本的部署和交付

  • 监控错误、性能和成本

在完成本章后,您将完成本书,并准备好使用无服务器组件构建您的下一个解决方案或增强现有的解决方案,从中受益于无服务器概念。

测试您的解决方案

测试无服务器项目可能是一个具有挑战性的经验,因为我们依赖于许多不同的云服务,这些服务在本地很难模拟,并且除了测试单个服务之外,我们还需要测试它们如何协同工作。

然而,您已经在传统项目中使用的所有实践都可以用于无服务器应用程序。为了提高您软件的质量,您可以使用 测试驱动开发TDD)、行为驱动开发BDD)或任何本质上依赖于自动化测试的开发流程。尽管我们没有访问将执行代码的机器,但我们可以在本地模拟许多事情,并且我们可以不时运行集成测试,以断言一切按预期工作。

在接下来的几节中,我们将看到如何为后端和前端创建测试。为了使这个主题更简单,我们将为简单的函数创建测试。如果您想查看更广泛的示例,可以浏览本章的代码文件,以了解无服务器存储是如何被测试的。

Lambda 函数的单元测试

由于 Lambda 函数是在一个公共 JavaScript 文件中定义的,您只需将您的测试工具设置为加载此文件并测试本地函数。为了模拟由 API 网关或其他触发器设置的输入数据,您需要根据预期的输入设置您的测试中的 event 变量。

让我们执行以下步骤,看看如何对 Lambda 函数进行单元测试:

  1. 首先,让我们通过运行以下命令创建一个新的无服务器项目:
 serverless create --template aws-nodejs --name testing

  1. 现在,让我们修改 serverless.yml 文件,如下所示:
       service: testing-service

       provider:
         name: aws
         runtime: nodejs6.10

       functions:
         hello:
           handler: functions/greetings.hello

  1. 在这个项目中,我们只有一个 Lambda 函数,这个 hello 函数是由位于 functions 文件夹内的 greetings.js 文件定义的。考虑以下简单的实现:
        module.exports.hello = (event, context, callback) => {
          const message = `Hello, ${event.name}!`
          callback(null, message);
        };

  1. 这个 hello 函数是我们想要测试的函数。现在,让我们创建我们的测试代码。在下面的屏幕截图中,我们展示了项目树,其中创建了一个名为 test 的文件夹,它包含一个 mocha.opts 文件,以及另外两个文件夹,unitintegration。由于这个示例代码不与任何其他服务交互,我们可以将其称为 unit 测试,而 test-greetings.js 文件就是它将被实现的地方:

  1. 我们可以通过在 serverless.yml 文件的末尾添加一个排除规则来排除这个 test 文件夹及其所有内容,从而将其从部署包中排除:
        package:
          exclude:
            - test/**

  1. 关于 mocha.opts 文件,它被包含进来是为了配置 Mocha 测试框架(mochajs.org/) 的选项,但你也可以使用任何其他的测试工具。

  2. 在这个 mocha.opts 文件中,我只添加了一行代码来指定必须使用哪个文件夹来运行测试:

        test/unit

unit 文件夹将包含单元测试,这些测试必须在毫秒内执行,以便开发者可以在每次修改后立即断言代码的状态。integration 文件夹包含访问外部服务的测试,允许它们在秒/分钟内完成。这些测试设计为偶尔执行,通常每天执行一次,而不是像单元测试那样频繁。因此,它们没有被包含在选项中。

  1. Mocha 通过 npm 安装,因此我们需要添加一个 package.json 文件并执行以下命令:
 npm install mocha --save-dev

  1. package.json 文件中,在 scripts 字段中添加一个 test 命令,其值为 mocha。这将很有帮助,因为你可以运行 npm test 命令来执行单元测试:
        {
            "name": "testing",
            "version": "1.0.0",
 "scripts": {
 "test": "mocha"
 },
            "devDependencies": {
                "mocha": "³.2.0"
            }
        }

  1. 现在我们已经正确设置了测试环境,我们可以实现名为 test-greetings.js 的测试文件。要使用 Mocha,我们需要使用 describe 函数来列出测试用例,以及 it 函数来实现测试用例:
        const assert = require('assert');

        // list the unit tests of the greetings function
        describe('Greetings', () => {

          // this is the only test that we have for this file
          describe('#hello()', () => {

            // the `done` argument must be used only for 
            // async tests, like this one
            it('should return hello + name', (done) => {

              // the test code will be defined here

            }); 
          });
        });

  1. 对于这个 Lambda 函数,我们可以实现以下测试:
        // load the Lambda function
        const greetings = require('../../lib/greetings');

        // set the event variable as expected by the function
        const event = { 
          name: 'John'
        };

        // context can be null in this test
        const context = null;

        // invoke the function locally
        greetings.hello(event, context, (err, response) => {

          const expected = 'Hello, John!';
          const actual = response;

          // testing if the result is the expected
 assert.equal(expected, actual);

          // exiting successfully if `err` variable is null
          done(err);
        });

  1. 要执行测试,请运行 npm test。你应该会收到以下输出:

  1. 作为一种良好的实践,你应该始终对测试进行测试。你可以通过将预期结果从 Hello, John! 更改为 Bye, John! 来做到这一点,这显然会导致断言失败,并产生以下输出:

模拟外部服务

有时候,你无法直接对 Lambda 函数进行单元测试,仅仅是因为有时候你不能将函数视为一个 单元。如果函数与外部服务交互,比如发送通知或在数据库中持久化一些数据,你就不能将其视为逻辑单元。在这种情况下,只有在你从测试中移除这些依赖项的情况下,你才能对函数进行单元测试,而你这样做是通过 模拟 它们来实现的。

模拟是指构建一个对象来模拟另一个对象的行为。当我们需要测试一个复杂的服务时,有许多底层行为我们可能不感兴趣测试。例如,如果我使用外部服务处理信用卡支付,并且我想测试它是否对给定的输入处理正确,我不希望处理意外事件,例如连接问题。在这种情况下,我可以创建一个模拟对象来模仿预期的行为,如果满足特定条件,我的测试用例将返回成功或失败。

为了能够模拟服务,我们需要将业务逻辑与外部服务分离。采用这种方法,我们可以编写单元测试,并保持解决方案对云服务的依赖性较低,这有助于有一天你需要从一家云服务提供商迁移到另一家。

以下代码展示了一个例子,其中没有清晰的业务逻辑和服务分离。因此,测试起来更困难:

    const db = require('db');
    const notifier = require('notifier');

    module.exports.saveOrder = (event, context, callback) => {

 db.saveOrder(event.order, (err) => {
        if (err) {
          callback(err);
        } else {
 notifier.sendEmail(event.email, callback);
        }
      });
    };

这个例子接收订单信息,将其保存到数据库中,并发送电子邮件通知。这里有两个主要问题,比如代码绑定到输入(它如何处理 event 对象),并且你不能在不触发对数据库和通知服务的请求的情况下对 Lambda 函数的内部内容进行单元测试。

更好的实现方式是创建一个独立的模块来控制业务逻辑,并且构建这个模块时允许你注入依赖:

    class Order {

 // Dependency Injection
      constructor(db, notifier) {
        this.db = db;
        this.notifier = notifier;
      }

 save(order, email, callback) { 
 this.db.saveOrder(order, (err) => {
          if (err) {
            callback(err);
          } else {
 this.notifier.sendEmail(email, callback);          
          }
        });
      }
    }

    module.exports = Order;

现在,由于数据库和通知服务作为输入值传递,这段代码可以进行单元测试,因此它们可以被模拟。

关于 Lambda 代码,它变得简单得多:

    const db = require('db');
    const notifier = require('notifier');
    const Order = require('order');

    const order = new Order(db, notifier);

    module.exports.saveOrder = (event, context, callback) => {
      order.save(event.order, event.email, callback);
    };

使用 Sinon.JS 进行模拟

在前面的例子中,我们通过创建一个名为 Order 的外部模块来改进 Lambda 函数,以处理所有与订单相关的操作。这是必要的,因为我们只能模拟我们能够访问的对象。我们无法直接测试 Lambda 函数,因为我们无法访问它使用的服务(数据库和通知),但至少我们可以测试 Order 类,因为它允许注入服务。

对于我们的模拟示例,我们将使用 Sinon.JS。可以使用以下命令进行安装:

 npm install sinon --save-dev

Sinon 将与 Mocha 一起使用。因此,我们需要创建一个如下所示的测试用例:

    const assert = require('assert');
 const sinon = require('sinon');
    const Order = require('./order');

    describe('Order', () => {
      describe('#saveOrder()', () => {
        it('should call db and notifier', (done) => {

          // the test code will be defined here

        });
      }); 
    });

我们可以像以下这样实现这个测试:

 // define the behavior of the fake functions
    const dbMock = {
      saveOrder: (order, callback) => {
        callback(null);
      }
    }  

    const notifierMock = {
      sendEmail: (email, callback) => {
        callback(null);
      }
    }

 // spy the objects to identify when and how they are executed
    sinon.spy(dbMock, 'saveOrder');
 sinon.spy(notifierMock, 'sendEmail');

    // define the input event 
    const event = { 
      order: { id: 1 },
      email: 'example@example.com'
    };

 // inject the mocked objects
 const order = new Order(dbMock, notifierMock);

    // execute the function
    order.save(event.order, event.email, (err, res) => {

 // assert if the mocked functions were used as expected
      assert(dbMock.saveOrder.calledOnce, true);
      assert(notifierMock.sendEmail.calledOnce, true);
      assert(dbMock.saveOrder.calledWith(event.order), true);
      assert(notifierMock.sendEmail.calledWith(event.email), true);

      done(err);
    });

这个例子表明,你可以使用 Sinon.JS 来检查你的依赖是否按预期和正确的参数被调用。你可以改进这个例子,添加假响应并测试不同的行为,但我们不会深入这个主题,因为这些功能与无服务器架构不是严格相关的。这里的目的是展示常见的测试框架可以在无服务器架构中使用,而不需要特殊配置。

测试前端

我们使用 React 开发了前端,因此我们将构建一个简单的示例来展示你如何测试它。目标是查看一个简单的组件是否正确渲染,并且是否如预期那样显示文本。

让我们看看以下步骤来创建这个示例:

  1. 我们将首先通过执行以下命令创建一个新的 React 项目:
 create-react-app frontend-test

  1. Create React App 使用 Jest 作为其测试运行器。按照惯例,它将始终寻找以 .test.js 结尾的文件来执行测试。在默认模板中,我们有 App.jsApp.test.js 文件。如果你运行 npm test,Jest 将执行在 App.test.js 中创建的示例测试,并将输出以下结果:

运行 npm test 后,Jest 将监视更改,因此你可以继续开发你的前端,并且每当保存文件时,Jest 都将执行所有测试用例。

  1. App.js 中,我们定义了一个 App 组件,其代码如下:
        render() {
          return (
            <div className="App">
              <div className="App-header">
                <img src={logo} alt="logo"/>
 <h2>Welcome to React</h2>
              </div>
            </div>
          );
        }

  1. 并且 App.test.js 的定义如下,它只是一个烟雾测试,用于查看组件是否可以渲染而不崩溃:
        import React from 'react';
        import ReactDOM from 'react-dom';
        import App from './App';

        it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App/>, div);
        });

  1. 现在我们将改进这个测试用例,并且我们需要安装两个辅助工具,如 Enzyme 和 react-test-renderer:
 npm install enzyme react-test-renderer --save-dev

  1. 使用 Enzyme,我们可以通过使用 mount 函数而不是 ReactDOM.render 来简化前面的示例:
        import React from 'react';
        import ReactDOM from 'react-dom';
        import App from './App';
 import { mount } from 'enzyme';

        it('renders without crashing', () => {
 mount(<App/>);
        });

  1. 为了完成这个示例,我们将添加另一个测试用例来查看是否如预期那样,给定的元素 <h2>Welcome to React</h2> 已在组件中渲染:
        it('renders with "Welcome to React"', () => {
          const wrapper = mount(<App/>);
          const welcome = <h2>Welcome to React</h2>;
          expect(wrapper.contains(welcome)).toEqual(true);
        });

在本地模拟 AWS 服务

使用云服务的缺点之一是它们提供的产品你不能安装在自己的机器上。如果你能安装它们,你的开发速度将会提高,因为本地测试比通过互联网连接它们要快。

为了解决这个限制,社区已经创建了多个工具来帮助你通过在本地运行来模拟 AWS 服务。你可以在以下链接中找到一些:

使用这种策略有一些优点和缺点。特别是,我不认同这个观点,并且我不会使用它们。你可以通过以下内容找到我对优缺点的看法,并自行决定这些工具是否可能改善你的开发工作流程:

优点:

  • 速度:在本地运行比使用互联网更快。

  • 测试:有些工具只是模拟器,它们模拟真实服务的行怍而不进行任何 I/O 操作,这意味着你可以测试你的服务而不必更改你的代码。其他工具是类似实现,允许你调试你的代码。

  • 成本:你可以使用自己的机器免费运行它们。

缺点

  • 速度:大多数服务都需要额外的配置步骤。对于一个小型项目,你可能会花费更多的时间来配置和调试假服务的问题,这比你从更快的测试中获得的收益要多。

  • 测试:如果你只使用模拟服务,你很难对自己的测试有信心。你需要时不时地运行与真实服务集成的测试。此外,你可能无法进行某些测试。例如,模拟 IAM 权限是非常困难的。

  • 成本:你可能会在配置这些工具上花费比你在云成本上节省的更多开发者时间。大多数云提供商已经采用了定价模式,他们提供免费层,允许开发者免费构建和测试他们的产品,并且只有在服务被密集使用时才开始收费。

部署你的应用程序

在本节中,我们将讨论无服务器应用程序的部署。我并不是指仅仅运行serverless deploy命令,我的意思是你需要知道并定义如何在生产环境中处理和管理应用程序的新版本。

你是否可以在任何时间点击部署按钮?这有什么影响?你如何只为测试创建生产环境的副本?这些都是本节将要讨论的内容。

开发工作流程

部署 Lambda 函数的新版本是一个简单的任务。我们运行一个命令,框架负责打包内容并将它们上传到 AWS。然而,运行serverless deploy命令通常需要几分钟。问题不在于上传 ZIP 文件的时间,而在于框架需要使用 CloudFormation 更新的内容。需要发布一个新的 CloudFormation 模板,要求 AWS 更新特定区域的全部相关资源,这需要时间。随着我们的代码库增长,我们可能需要创建数十个 API Gateway 端点,许多不同的 IAM 角色或其他类型的 AWS 资源。管理它们可能会很麻烦,因为它们会增加部署时间到一个不愉快的长度。

使用选择性部署可以减少这种时间。如果你只修改了特定的功能,你可以通过以下命令快速部署:

 serverless deploy function -f myFunction

蓝绿部署

蓝绿部署是一种常见的部署新版本软件的技术,它可以避免产生不可用性。假设你正在运行 3.0 版本的软件,并且你想要部署新的 3.1 版本。在你开始更新你的机器之前,所有的机器都在使用 3.0 版本,我们称它们处于蓝色状态。我们首先创建带有更新代码的新机器,版本号为 3.1,这些机器处于绿色状态。下一步是修改负载均衡器,将所有新的连接重定向到新机器(绿色),同时继续处理旧机器(蓝色)的请求。在之前的请求处理完成后,蓝色机器将不会收到任何新的请求,它们可以被关闭。

蓝绿部署之所以重要,是因为在过去,我们通常只有一个用于处理应用的 Web 服务器机器,常见的做法是停止 Web 服务器,更新代码,然后重新启动。过去,那几秒钟的不可用性是可以接受的,但今天,随着自动化和将负载分配到多个服务器的可能性,更新或维护流程中不再需要中断服务。

在无服务器世界中,这个概念是等效的。当你更新 Lambda 函数的代码时,AWS 会使用它来处理新的请求,而之前的版本将继续使用之前的代码运行。API 网关也将处理端点行为的修改,而不会造成不可用性:

图片

因此,回答之前的问题:你可以在任何时间点点击部署按钮吗?是的,你可以部署你应用的新的版本,无需担心可用性。然而,在部署新版本的过程中,我们可能会同时运行不同的版本。你应该注意这种情况,特别是关于需要更改数据库模型的版本。

使用不同数据库模型的部署新版本

在无服务器中,我们通常运行执行时间仅为几秒钟的代码。因此,同时运行两个不同的版本可能不到一秒钟,但我们如何在一个模型中应用数据库更改?重命名一个列可能会破坏旧版本的执行。

理想情况下,我们都会使用具有灵活模式的 NoSQL 数据库,但这并不现实。有些业务案例更适合由关系数据库或具有限制性模式的 NoSQL 数据库来处理。

当修改模式时,有三个操作需要我们注意,如创建、重命名和删除。

创建一个新表或列

在现有的表中添加表或列不应该破坏任何应用程序。然而,有一些 ORM 工具,如 Entity Framework(用于 .NET),将每个架构版本与迁移 ID 关联。在这种情况下,当你运行迁移命令以升级数据库架构时,它会添加一个新的迁移 ID,该 ID 将由应用程序代码检查。如果你运行之前的代码版本,ID 将不匹配,并且会返回错误。

这种限制是为了安全措施而创建的,以避免过时的代码在生产环境中运行,当预期的模型不同时造成不一致。尽管如此,如果你对你的部署有适当的控制,你可以禁用这个限制,以避免在升级版本时出现不可用的情况。

此外,我们还需要注意当我们添加约束或外键时。如果你修改包含数千行数据的表以添加一个新的外键,ALTER TABLE 命令可能需要一些显著的时间来处理。在处理过程中,表将锁定以供选择,这可能导致一些查询超时。

重命名表或列

假设你需要将一个列名从 A 改为 B。如果你进行这个变更,之前的代码可能无法正常工作,因为它找不到名为 A 的列,而最新的代码如果在重命名之前部署,也可能无法正常工作。

这里提出的解决方案是通过执行以下步骤来实施这一变更:

  1. 运行一个脚本以创建一个名为 B 的新列。

  2. 添加一个临时触发器,每次修改 A 列中的数据时都会执行,以将相同的修改应用到 B 列。

  3. AB 的所有内容进行复制。

  4. 部署一个与之前版本完全相同的新代码版本,但使用列 B 而不是 A 进行读写。

  5. 稍等片刻以确保所有请求都在使用新的 Lambda 代码,而不是之前的版本。你可能需要等待 Lambda 函数的最大超时时间。

  6. 运行另一个脚本,该脚本将删除列 A 和临时触发器。

  7. 部署使用列 B 并添加新功能的最新代码。

删除表或列

删除表或列稍微容易一些。你只需要部署一个新的应用程序代码,该代码不使用你想要删除的表或字段。在等待一段时间以确保之前的代码已执行完毕后,你可以安全地执行一个脚本,该脚本将删除表或移除字段。

回滚部署

有时,我们部署的应用程序的新版本可能会引入有缺陷的功能。根据错误的严重程度,在开始修复错误并进行新部署之前,你可能需要回滚应用程序。为此回滚你有两种选择:

  1. 使用标签对所有的部署进行版本控制。当你需要回滚时,选择之前标签中的代码,然后再次运行 serverless deploy

  2. 使用 serverless rollback 命令将你的函数更改为之前的版本。

AWS 为我们部署有一个版本控制系统,因此使用serverless rollback命令是安全且快速的。此命令应通过传递timestamp参数来使用,如下所示:

 serverless rollback --timestamp <timestamp>

要找到我们上次部署的时间戳信息,我们需要运行以下命令:

 serverless deploy list

它将给出以下输出:

图片

在之前的屏幕截图中,我们会使用值1499216616127作为timestamp参数。请注意,我们需要选择倒数第二个版本,而不是最后一个版本。

命令serverless rollback将回滚使用serverless deploy命令所做的先前部署的所有函数。如果您使用了serverless deploy function,则此更改不会进行版本控制。

创建预发布环境

最佳实践表明,我们必须为开发和生产环境拥有不同的环境。您还可以添加第三个环境,通常命名为预发布,用于测试:

  • 开发环境:这是您部署作为工作进度的代码的地方,测试它是否与其他服务一起工作

  • 预发布环境:通常需要客户或质量保证团队验证构建

  • 生产环境:这是您的应用程序对最终用户可见的地方

我们开发的全部软件都高度依赖于环境,例如操作系统、运行时版本、已安装的模块和 dlls、外部服务、配置文件以及其他。因此,至少在几年前,开发者解释生产错误时说“在我的机器上它运行正常”是一个相当常见的借口。将开发环境与生产设置相匹配是一个非常困难的任务。有时,对其中一个所做的更改并没有反映到另一个上,导致出现奇怪的错误。

使用虚拟机,以及最近使用 Docker 容器,这个问题已经大大减少,因为我们现在可以相信我们可以在我们的开发机器上完美地重现生产错误,并且我们构建的内容将按预期工作,无论在哪个机器上执行它。

使用云提供商,我们所有的基础设施都可以通过脚本进行。因此,我们可以通过代码来自动化创建环境的方式。在这种情况下,您只需更改一个变量的值,然后再次部署,就可以将您的开发代码与生产代码进行镜像。在您的serverless.yml文件中,provider下有一个选项允许您命名当前环境,并且只需通过为stage属性选择一个新名称,就可以轻松地将它镜像到其他环境:

    service: serverless-app

    provider:
      name: aws
      runtime: nodejs6.10
 stage: dev
      region: us-east-1

小心使用生产环境

能够轻松地将开发环境镜像到生产环境是一个非常强大的功能,需要明智地使用。在我作为开发者的早期,我有同时打开预发布和生产虚拟机的坏习惯。当然,当我误操作生产服务,以为我在更改预发布版本时,我就停止这样做。

我建议使用预发布选项来将开发环境与测试环境进行镜像。您可以轻松地为您的客户或质量保证团队部署新版本,但您应该永远不要使用您的开发机器在生产环境中应用更新,以避免相关的风险。

创建一个新的环境就像为新阶段选择一个新名称一样简单。因此,您可以将其命名为test-2017-08-02test-feature-x等,以创建具有特定测试环境的新端点。

您可以在团队中指定一个人,他将是唯一负责部署新生产版本的人。将责任限制在一个人身上将减少事故发生的可能性。另一种选择是拥有一台专门用于生产部署的机器。需要额外一步,即连接到该机器,有助于您专注于任务,并且不会意外选择错误的环境。

此外,我还建议您拥有两个不同的 AWS 账户,一个用于开发和测试,另一个专门用于生产。虽然可以配置 IAM 角色来保护您的环境并防止同一用户修改两个环境,但这仍然是有风险的。IAM 限制可能配置不正确,或者您可能添加了新的资源而忘记设置适当的访问权限,从而导致不希望的变化。

测试数据

当您的整个基础设施都脚本化时,开发和生产环境之间的唯一区别是相关数据。测试环境通常有自己的模拟数据,但有时我们无法重现错误,例如性能问题或不一致性,因为底层数据不同。

然而,为了以下原因,将生产数据备份并直接将其复制到测试环境中可能是一种不良做法:

  • 生产数据包含真实电子邮件。运行测试代码可能会意外地向真实的人发送电子邮件。

  • 生产数据包含敏感数据,如真实姓名、电子邮件、电话号码和地址。与所有开发者共享这些数据是不必要的,也是危险的。开发机器比生产环境更不安全,更容易受到黑客攻击。

在这种情况下,我建议在大多数测试中使用模拟数据,当您需要进行性能测试或分析特定问题时,您可以使用生产备份,但您需要有一个程序来修改内容,在将数据与所有开发者共享之前删除敏感数据。

保持您的函数活跃

如我们在前面的章节中讨论过的,无服务器函数的一个问题就是冷启动。当您的 Lambda 函数被触发时,AWS 会找到其包,解压并安装到容器中以执行。这些步骤需要一些时间(通常 5 秒),并且会延迟您的函数执行。

执行函数后,AWS 将将其保持一段时间挂起状态。如果几分钟后收到新的请求,它不会受到冷启动延迟的影响,因为包将随时可用。在 15 分钟的不活动后,它将再次“冻结”。

如果您的应用程序需要确保低响应时间,您可以通过配置将其部署为“预热”状态。有一个名为 WarmUP 的 Serverless Framework 插件(github.com/FidelLimited/serverless-plugin-warmup),它将创建一个定时 Lambda 函数,负责不时调用其他函数(默认为 5 分钟)。

让我们按照以下步骤来了解如何使用它:

  1. 通过执行以下命令创建一个新的无服务器项目:
 serverless create --template aws-nodejs --name warmup

  1. 创建一个 package.json 文件。

  2. 通过执行以下命令安装 WarmUP 插件:

 npm install serverless-plugin-warmup --save-dev

  1. serverless.yml 文件的末尾添加以下引用:
 plugins:
 - serverless-plugin-warmup

  1. 对于您想要保持“预热”的每个函数,添加 warm: true 配对:
        functions:
          hello:
            handler: handler.hello
 warmup: true

  1. 此插件将调用其他函数,因此我们需要给它必要的权限:
        iamRoleStatements:
          - Effect: 'Allow'
            Action:
              - 'lambda:InvokeFunction'
            Resource: "*"

  1. 最后一步是修改 Lambda 函数以忽略由该插件创建的请求:
        module.exports.hello = (event, context, callback) => {

 if (event.source === 'serverless-plugin-warmup') {
 console.log('WarmUP - Lambda is warm!')
 return callback(null, 'Lambda is warm!')
 }

          callback(null, { message: 'Hello!' });
        };

监控操作

无服务器概念被定义为运行您的代码而不必担心支持它的基础设施。这仍然成立,但有一些 DevOps 任务可能会提高您应用程序的效率和稳定性。因此,您不应将无服务器与 NoOps 混淆。您只需不必过多担心基础设施即可。

由于我们使用 AWS,我们将使用其监控工具:Amazon CloudWatch。还有一些其他付费和免费工具也可以用于此任务,所以在选择自己的工具之前请随意比较。

要使用 CloudWatch,请打开管理控制台 console.aws.amazon.com/cloudwatch,以下小节将展示我们如何监控我们的 Lambda 函数:

监控成本

在无服务器中估算成本是一项困难的任务,因为它高度依赖于使用情况。此外,由于编程错误,部署新函数可能会导致意外的成本。例如,假设您设置了一个具有 5 分钟超时和 1 GB RAM 的函数。也许它应该 95% 的时间在几毫秒内执行,但由于错误,它可能每次都会冻结并无限期运行,直到超时后停止。

另一种场景是当您使用 Lambda 函数调用另一个 Lambda 函数时,但编程错误可能会创建一个无限循环,导致您的 Lambda 函数持续执行。实际上,AWS 有一些限制和措施来防止这类错误,但这是我们应关注避免的事情。

您始终可以打开您的 AWS 计费仪表板来跟踪您的月度支出,但当出现这类问题时,您至少希望尽快收到警告。在这种情况下,您可以设置计费警报,如果月度成本达到意外水平,则发送电子邮件。

让我们通过以下步骤来监控成本:

  1. 打开您的 CloudWatch 控制台,浏览左侧菜单中的计费链接,然后点击创建警报:

图片

  1. 在下一屏幕上,选择计费指标:

图片

  1. CloudWatch 允许您为整个账户创建计费警报,或者根据服务过滤警报。在这种情况下,您可以选择 AWSLambda 服务并点击下一步:

图片

  1. 在最后一屏,您可以设置警报的阈值并定义当它超过可接受值时应该通知哪些人员:

图片

监控错误

返回到 CloudWatch 控制台主屏幕,点击位于中心的浏览指标按钮。它将带您到另一个页面,您可以在其中选择所有可用的 Lambda 函数指标。您可以选择按函数名称、资源或跨所有函数进行监控。可用的指标如下:

  • 错误:这是 Lambda 函数因错误而提前停止或达到超时限制后停止的次数指标。这是一个重要的指标,因为理想情况下,您希望在生产环境中看到零错误,并且当检测到错误时,您希望收到警告。

  • 调用次数:这是您的 Lambda 函数被调用的次数指标。如果这个函数是由计划执行的,那么当它执行次数超过预期时,您可能希望收到通知。此外,使用这个指标,您可以跟踪当函数执行次数超过合理值时,执行是否失控。

  • 持续时间:使用这个指标,您可以跟踪您的函数是否执行时间超过了预期。

  • 限制:这个指标在每次函数因为达到并发 Lambda 函数限制而没有执行时都会计数。如果您向 AWS 提交支持工单,这个值可以增加,但默认值是 1,000,对于某些用例来说可能非常低。

如您所见,这些指标是自动监控的,并且您可以使用历史数据构建一些图表。如果您想设置警报,请返回到控制台主页,点击左侧菜单中的警报,然后点击创建警报,并按您的意愿配置收件人。

使用 Serverless Framework 检索指标

您可以使用 Serverless Framework 来检索 CloudWatch 指标。这可以是一个查看应用程序操作的好功能,而不必浏览 CloudWatch 控制台。

以下截图显示了serverless metrics命令的输出:

图片

此命令可用于查看所有功能(serverless metrics)的合并操作或单个功能的统计信息(serverless metrics --function <your-function>)。

此外,您还可以使用--startTime--endTime参数按日期范围进行过滤。以下命令将仅包括与过去 30 分钟内发生的事件相关的统计信息:

 serverless metrics --startTime 30m

流式传输 Lambda 日志

当 Lambda 执行中出现错误时,错误消息通常是不够的。例如,考虑以下错误消息:

图片

您可以通过将日志流式传输到终端来检索有关错误消息的更多详细信息。您可以连接到特定功能并接收错误消息的历史记录和实时错误。为此,请运行以下命令:

 serverless logs -f myFunction --tail

--tail参数表示您想监听新的错误消息。您还可以使用--filter word来显示仅匹配过滤器的消息或使用--startTime来指定您想查看的日志范围。例如,--startTime 2h将显示过去两小时的日志。

日志消息显示了错误的堆栈跟踪,这对于理解问题的根本原因非常有用:

图片

处理错误

当一个功能执行出错时,Lambda 提供了两个处理程序,如 SNS 和 SQS。您可以使用它们来处理失败的事件,因此您可以稍后重试或检索更多信息以了解导致问题的原因。

SNS 用于在错误发生时发出通知,SQS 用于创建一个队列,其中包含失败的 Lambda 任务,这些任务可以由另一个服务处理:

    functions:
      hello:
        handler: handler.hello
 onError: <ARN>

您应将 SNS 主题的 ARN 设置为 SQS 队列。

由于 Serverless Framework v.1.18 中的一个错误,目前不支持 SQS,但这个错误已经是一个已知问题,应该很快得到修复。

监控性能

正如我们之前讨论的,您可以通过 CloudWatch 选项中的持续时间指标或运行框架的serverless logs命令来找到函数执行所需的时间。理想情况下,无论代码是在工作时间、午夜还是周末执行,都没有差异。AWS 致力于在任何时间都提供一致的经验。

在实践中,这并不总是正确的。没有已知的行为模式,但您可能期望执行时间存在很大差异。不考虑冷启动延迟,您的函数可能需要 50 毫秒来执行,而 1 分钟后,它可能需要 400 毫秒来执行相同的代码和相同的输入。在无服务器站点中提供一致的经验比使用传统基础设施要困难得多。这是因为您的基础设施始终与其他客户共享。

虽然你可以看到差异,但监控持续时间是一个好习惯。与其根据最大持续时间设置警报,不如设置平均百分位数,其中百分位数是一个统计单位,表示落在某个类别中的观测值的百分比。例如,p90 为 100 毫秒意味着你预计 90%的请求将在 100 毫秒内执行,如果在一个给定的时间段内这不是事实,你应该收到一个警报信息。

当我们的 Lambda 函数依赖于外部服务时,设置警报尤为重要。如果函数从数据库表中读取数据,当表中有 10 条记录时可能需要 200 毫秒,而当表中有 1,000,000 条记录时可能需要 1 分钟。在这种情况下,警报可能很有用,可以提醒你该清理一些旧数据或改进查询了。

监控效率

监控效率意味着你希望确保以最佳方式使用你的资源。当你创建一个新的 Lambda 函数时,有两个重要的配置选项,如超时值和分配的 RAM 内存。

设置一个长的超时值不会影响效率,但设置错误的 RAM 内存确实会影响功能性能和成本。

例如,考虑以下截图中的函数日志:

图片

它分配的内存大小为 1,024 MB(默认),而Max Memory Used仅为19 MB。在这种情况下,很明显你可以减少分配的内存以最小化成本。

我建议你始终用不同的内存大小测试你的代码,并跟踪持续时间。使用比所需更少的内存运行会导致处理时间大大增加。如果你的 Lambda 函数用于响应用户请求,你可能想多支付一点内存以更快地处理请求,而如果是后台任务,你可能可以使用最少的必要内存以节省金钱。

此外,当对您的代码进行基准测试以查看不同内存大小下的运行速度时,请注意它正在运行的场景。如果您的项目架构是单体(Monolith),使用仅几兆内存检索一些用户数据可能非常快,但它可能难以处理特定时间段的销售报告。

摘要

在本章中,你学习了如何在前端和后端测试无服务器代码。我们还讨论了一些你在部署工作流程中必须考虑的关键概念,并展示了如何使用 Amazon CloudWatch 监控无服务器应用程序。

现在本书已经完成。我希望您在阅读章节的过程中感到愉快,并且学到了足够多的知识来构建您下一个令人惊叹的无服务器应用程序。您可以将无服务器商店演示作为您未来项目的参考,但不要局限于它。使用您自己的首选工具进行测试、开发前端和访问数据库。我的目标不是定义一个严格的模式来指导您如何构建无服务器应用程序,而是提供一个例子来证明这个概念是有效的,并且可能适用于许多应用程序。

最后,我鼓励您尝试其他云服务提供商。本书之所以专注于 AWS,是因为我有积极的个人经验,但还有其他优秀的服务。在评估提供商时,不要只关注价格标签。看看提供的工具,这些工具将使您构建应用程序变得更加容易。混合来自不同提供商的服务也是可行的。祝您好运!

posted @ 2025-10-27 09:05  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报