AWS Lambda 编程指南(一)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于本书

欢迎来到编程 AWS Lambda。我们很高兴您在这里!

无服务器计算是一种革命性的系统构建方式。在其核心,无服务器是关于进行最少的技术工作,以可持续地为用户提供价值。无服务器方法通过充分利用云供应商提供的服务来实现这一点,比如亚马逊网络服务(AWS)。

在本书中,您将学习如何设计、构建和操作使用AWS Lambda的无服务器应用程序——这是最初也是广泛采用的无服务器计算平台。然而,AWS Lambda 很少单独使用,因此在阅读本书时,您还将学习如何成功地将 Lambda 与其他无服务器 AWS 服务(如 S3、DynamoDB 等)集成。

为什么我们写了这本书

我们自 2015 年以来一直在使用 Lambda,自 Lambda 首次宣布支持 Java 以来便如此。仅仅在几周内,我们就看到 Lambda 具有让团队比以往任何时候都更快构建新功能的惊人能力。通过消除开发和运行系统的许多低级方面,而是专注于清晰的事件驱动方法,我们意识到在使用 Lambda 时许多妨碍团队的复杂性不再适用。Lambda 还让我们可以放大我们对 AWS 平台其余部分的使用——它对我们的效率产生了乘数效应。

我们最初对 Lambda 有两个担忧——一是它不能支持多年来我们在 Java 中构建的编程知识和软件库存,二是它在大规模运行时成本过高。

而我们发现的却出乎我们意料。

Lambda 对 Java 的支持并不仅仅是一个“附加功能”。事实上,Java 是 Lambda 平台内的一流运行时。在 Java 中构建 Lambda 应用程序使我们可以回归编程的本质,让我们可以利用我们的技能和现有的代码。

此外,Lambda 的运行成本竟然比等效的传统构建系统更低,而不是更高。Lambda 的“按使用付费”的高效模型,精确到亚秒级,使我们能够创建每天处理数亿事件的系统,但成本低于其前身。

这种开发速度、对现有语言的采纳以及成本效益的结合使我们相信,无服务器计算平台,以 Lambda 为前沿,是我们行业特别之处的开端。2016 年,我们创立了 Symphonia 公司,旨在帮助公司迈向这种新的系统构建方式。

本书适合对象

本书主要面向软件开发人员和软件架构师,但对于任何涉及在云中构建软件应用技术方面的人员也很有用。

我们假设您已经知道或可以学习 Java 编程语言的基础知识。您不需要了解或具有任何 Java 应用框架(如 Spring)或库(如 Guava)的知识或经验。我们不假设您具有任何关于 Amazon Web Services 的先验知识。

为什么需要本书

从许多方面来看,无服务器计算和 Lambda 也是数十年来构建服务器端软件的最重大变革之一。虽然我们的代码可能在每一行、甚至每一个类上看起来都与以前写的方式类似,但 Lambda 的架构约束和功能驱使设计具有与过去非常不同的形状。

在过去的几年里,我们已经了解到如何成功地使用 Lambda 构建系统。本书将帮助您快速学习这些相同的经验教训。

从入门技术到高级架构,从编程和测试到部署和监控,我们覆盖了您需要了解的构建 Lambda 生产质量系统的生命周期。

本书的独特之处在于我们在 Java 编程语言的背景下完成所有这些工作。我们两人都是 Java 程序员,每个人都有超过二十年的经验,所以在这本书中,我们帮助您以全新的方式使用您现有的 Java 技能。

所以系好安全带,欢迎来到无服务器时代!

使用章末练习

本书的每一章都以一些练习结束。其中一些练习鼓励您将本章的教训应用到 AWS 云中,看到它们在“真实”环境中的运作。虽然 Lambda 的某些元素可以在本地模拟,但只有通过在 AWS 平台的上下文中使用它,您才能真正感受到 Lambda 开发的感觉。好消息是,AWS 提供了一个健康的“免费层”,这样您就可以在不产生任何费用的情况下进行实验。

其他练习旨在让您考虑如何与 Lambda 与其他技术不同地工作。无服务器架构通常是一种非常不同的思维方式,通过这些练习将开始调整您的大脑思维方式。

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按字面输入的命令或其他文本。

等宽斜体

显示应该用用户提供的值或上下文确定的值替换的文本。

提示

这个元素表示一个提示或建议。

注意

这个元素表示一般说明。

警告

这个元素表示一个警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/symphoniacloud/programming-aws-lambda-book下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书的大量示例代码合并到您产品的文档中需要许可。

我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Programming AWS Lambda by John Chapin and Mike Roberts (O’Reilly). Copyright 2020 Symphonia LLC, 978-1-492-04105-4。”

如果您觉得您使用的代码示例超出了公平使用或上述许可范围,请随时通过permissions@oreilly.com与我们联系。

致谢

感谢我们的技术审阅者们为您花费的时间和对这本书的改进:布莱恩·格鲁伯、丹尼尔·布莱恩特、萨拉·韦尔斯和斯图尔特·西埃拉。感谢我们在 Intent Media 的前同事们,四年前加入并展示了这项革命性的新技术,展示了它如何改变团队。感谢所有 Symphonia 的客户、合作伙伴和朋友们——我们对您持续的信任和支持心存感激。感谢所有 O’Reilly 的工作人员,尤其是我们的编辑团队;我们在开始阅读动物书籍 20 多年后写下了自己的“动物”书籍,这真是令人惊奇。最后,感谢所有参与无服务器社区的朋友们!

还要特别感谢 AWS 无服务器团队的成员,特别是阿贾伊·奈尔、克里斯·蒙斯、诺尔·道林和萨尔曼·帕拉查,因为他们开发了一款革命性产品,并在过去几年里与我们交流。最后感谢蒂姆·瓦格纳,因为他在 Lambda 刚刚起步时为本书撰写了序言!

John 的致谢: 首先和最重要的是,感谢我的父母马克和布里奇特,他们给了我选择人生道路的特权和自由,并给予了我不脱轨的爱和支持。当然还要感谢我的合著者和商业伙伴迈克,没有他,这本书和我们的公司都不会存在——总有一天我会教他如何写美式英语(但今天不是)。无尽的感谢送给我的妻子杰西卡,她让我的精神振作,从不问文章的字数。

Mike 的致谢: 这里有太多的人要感谢了,但我还是试一试。感谢我的高中计算机课老师雷·洛维尔和我的大学导师卡罗尔·摩根。感谢这些年来的同事们,特别是在 ThoughtWorks 的时光里。丹尼尔·特霍斯特-诺斯一直是我职业生涯中的导师和智者;丹尼尔,请继续让我感到“啊!”。感谢布莱恩·古斯里、丽莎·范·格尔德和纽约极限星期二俱乐部社区的其他成员。还要感谢迈克·梅森,他是我的同事(两次)、室友(多次),并且是我一生中最亲近的朋友之一。 (是的,迈克,那个短语在书中——现在轮到你了!)

然而,我最大的感激之情要送给三个人“如果没有他们……”首先,感谢马丁·福勒的启发,感谢他的友谊,也感谢他发表了关于无服务器架构的文章,为您正在阅读的内容铺平了道路。其次,感谢我的合著者约翰,他和我一起经历了我们公司 Symphonia 的过山车式成长。最后,当然还要感谢我的美好配偶萨拉,她支持我做自由职业者的怪异时间,现在显然也支持我成为一名已出版作者。

第一章:服务器无服务器、Amazon Web Services 和 AWS Lambda 介绍

要开始您的无服务器之旅,我们将带您简要了解云,并定义无服务器。之后,我们将深入探讨 Amazon Web Services(AWS)——这对一些人来说是新的,对另一些人来说是一个复习。

有了这些基础,我们介绍 Lambda——它是什么,为什么要使用它,你可以用 Lambda 构建什么,以及 Java 和 Lambda 如何协同工作。

一个快速的历史课

让我们穿越时空回到 2006 年。现在还没有人拥有 iPhone,Ruby on Rails 是一个炙手可热的新编程环境,Twitter 正在推出。然而,对我们来说更重要的是,在这个时候,许多人都在自己拥有并在数据中心中架设的物理服务器上托管他们的服务器端应用程序。

2006 年 8 月发生了一件事,将从根本上改变这种模式。亚马逊的新 IT 部门 AWS 宣布推出了Elastic Compute Cloud(EC2)

EC2 是最早的基础设施即服务(IaaS)产品之一。IaaS 允许公司租用计算能力——即用于运行其互联网服务器应用程序的主机——而不是购买自己的机器。它还允许他们及时提供主机,从请求机器到可用性的延迟大约为几分钟。在 2006 年,这一切都成为可能,因为虚拟化技术的进步——那时的所有 EC2 主机都是虚拟机器。

EC2 的五个关键优势是:

降低人工成本

在 IaaS 之前,公司需要雇佣特定的技术运维人员,在数据中心工作并管理他们的物理服务器。这意味着从电力和网络到机架和安装再到修复诸如坏的 RAM 之类的机器的物理问题,再到设置操作系统(OS)的一切都消失了。使用 IaaS,所有这些都消失了,而是成为 IaaS 服务提供商(在 EC2 的情况下是 AWS)的责任。

降低风险

当公司管理自己的物理服务器时,他们会暴露于由于未计划的事件(如硬件故障)引起的问题。这会导致高度不稳定长度的停机时间,因为硬件问题通常很少见,并且可能需要很长时间才能修复。使用 IaaS,客户在硬件故障发生时仍然需要做一些工作,但不再需要知道如何修复硬件。相反,客户可以简单地请求一个新的机器实例,几分钟内就可用,并重新安装应用程序,从而限制了面临此类问题的风险。

降低基础设施成本

在许多情况下,连接的 EC2 实例的成本比自己运行的硬件要便宜,尤其是当你只想连续运行几天或几周而不是数月或数年时,考虑到电力、网络等因素。同样,按小时租赁主机而不是直接购买它们允许不同的会计处理:EC2 机器是运营费用(Opex),而不是物理机器的资本支出(Capex),通常允许更灵活的会计处理方式。

扩展

考虑到 IaaS 带来的扩展性好处,基础设施成本显著下降。使用 IaaS,公司在扩展其运行的服务器数量和类型方面具有更大的灵活性。不再需要提前购买 10 台高端服务器,因为你认为未来几个月可能会需要它们。相反,你可以从一两台低功率、低成本的虚拟机(VM)开始,然后随着时间的推移扩展你的 VM 数量和类型,而无需任何负面成本影响。

交付时间

在自助托管服务器的旧时代,为新应用程序采购和配置服务器可能需要几个月的时间。如果你在几周内想尝试一个想法,那就太糟糕了。使用 IaaS,交付时间从几个月缩短到几分钟。这引领了快速产品实验的时代,正如《精益创业》中鼓励的那样。

云计算的发展

IaaS 是云计算的第一批关键元素之一,与存储(例如 AWS 的简单存储服务(S3))一起。AWS 是云服务的早期开拓者,仍然是主要提供商,但也有许多其他云供应商,如微软和谷歌。

云计算的下一个演变是平台即服务(PaaS)。最受欢迎的 PaaS 提供商之一是 Heroku。PaaS 在 IaaS 之上层,抽象了主机操作系统的管理。使用 PaaS,你只部署应用程序,平台负责操作系统安装、补丁升级、系统级监控、服务发现等。

使用 PaaS 的替代方案是使用容器。在过去几年中,Docker作为一种更清晰地区分应用程序系统需求与操作系统细节的方法变得非常流行。有基于云的服务来代表团队托管和管理/编排容器,这些服务通常被称为容器即服务(CaaS)产品。亚马逊、谷歌和微软都提供 CaaS 平台。通过使用像Kubernetes这样的工具(例如谷歌的 GKE、亚马逊的 EKS 或微软的 AKS),管理 Docker 容器的数量变得更加容易,无论是自我管理的形式还是作为 CaaS 的一部分。

这三个概念——IaaS、PaaS 和 CaaS——都可以归为作为服务的计算;换句话说,它们是我们可以在其中运行我们自己专业软件的不同类型的通用环境。PaaS 和 CaaS 通过进一步提高抽象级别来与 IaaS 有所不同,允许我们将更多的“繁重工作”交给其他人处理。

进入 Serverless

Serverless 是云计算的下一个演进阶段,可以分为两个概念:Backend as a Service(BaaS)和 Functions as a Service(FaaS)。

Backend as a Service

Backend as a service(BaaS)允许我们用现成的服务替换我们自己编写和/或管理的服务器端组件。它在概念上更接近软件即服务(SaaS)而不是像虚拟实例和容器之类的东西。SaaS 通常是外包业务流程,比如人力资源或销售工具,或者在技术方面像 GitHub 这样的产品;而使用 BaaS,我们将应用程序分解成更小的部分,并且完全使用外部托管的产品实现其中一些部分。

BaaS 服务是领域通用的远程组件(即不是进程内库),我们可以将其整合到我们的产品中,其中应用程序编程接口(API)是典型的集成范式。

BaaS 已经在开发移动应用程序或单页 Web 应用程序的团队中变得特别流行。许多这样的团队可以大量依赖第三方服务来执行本来需要自己完成的任务。让我们看几个例子。

首先我们有像 Google 的Firebase这样的服务。Firebase 是一个数据库产品,由供应商(在这种情况下是 Google)完全管理,可以直接从移动或 Web 应用程序访问,无需我们自己的中介应用服务器。这代表了 BaaS 的一个方面:管理数据组件的服务。

BaaS 服务还允许我们依赖他人已经实现的应用逻辑。一个很好的例子是认证——许多应用程序实现自己的代码来执行注册、登录、密码管理等操作,但这些代码通常在许多应用程序中是相似的。跨团队和企业的这种重复工作正好可以提取为外部服务,这正是像Auth0和亚马逊的Cognito这样的产品的目标。这些产品允许移动应用程序和 Web 应用程序拥有完整的身份验证和用户管理功能,但不需要开发团队编写或管理实现这些功能的任何代码。

BaaS一词随着移动应用程序开发的兴起而显现出来;事实上,有时这个术语被称为移动后端即服务(MBaaS)。然而,使用完全外部管理的产品作为我们应用程序开发的一部分的关键思想并不局限于移动开发,甚至不限于前端开发。

作为服务的功能

无服务器的另一半是函数即服务(FaaS)。FaaS,像 IaaS、PaaS 和 CaaS 一样,是计算作为服务的另一种形式—一个通用的环境,在其中我们可以运行我们自己的软件。有些人喜欢使用术语无服务器计算来代替 FaaS。

使用 FaaS,我们将我们的代码部署为独立的函数或操作,并配置这些函数在 FaaS 平台内发生特定事件或请求时被调用或触发。平台本身通过为每个事件实例化专用环境来调用我们的函数—这个环境由一个临时的、完全托管的轻量级虚拟机或容器;FaaS 运行时;和我们的代码组成。

这种环境的结果是,我们不必关心我们代码的运行时管理,这与任何其他计算平台的风格都不同。

此外,由于我们稍后将描述的无服务器的几个因素,使用 FaaS 时我们不必担心主机或进程,并且缩放和资源管理由平台代为处理。

区分无服务器

使用外部托管的应用程序组件的想法,就像我们使用 BaaS 一样,不是新鲜事—人们已经使用托管的 SQL 数据库十年甚至更长时间了—那么是什么使得这些服务有资格作为后端服务呢?BaaS 和 FaaS 有哪些共同点,使我们将它们归为无服务器计算的概念?

有五个关键标准可以区分无服务器服务—包括 BaaS 和 FaaS—使我们能够以新的方式设计应用程序。这些标准如下:

不需要管理长期运行的主机或应用程序实例

这是无服务器的核心。操作服务器端软件的大多数其他方式都要求我们部署、运行和监视一个应用程序实例(无论是我们自己编写的还是其他人编写的),并且该应用程序的生命周期跨越一个以上的请求。无服务器则意味着相反:我们不需要管理长期运行的服务器进程或服务器主机。这并不意味着这些服务器不存在—它们绝对存在—但它们不是我们的关注或责任。

自动按负载自动调整和自动分配资源

自动缩放是系统根据负载动态调整容量需求的能力。大多数现有的自动缩放解决方案需要利用团队做一定的工作。无服务器服务从第一次使用起就能自动自动调整,无需任何努力。

无服务器服务在执行自动扩展时也会自动进行配置。它们消除了分配容量的所有工作,包括底层资源的数量和大小。这是一个巨大的操作负担减轻。

具有基于精确使用量的成本,从零到零的使用量

这与前一点密切相关——无服务器成本与使用量精确相关。例如,使用 BaaS 数据库的成本应与使用量紧密相关,而不是预定义的容量。此成本应主要来自实际使用的存储量和/或发出的请求。

请注意,我们并不是说成本应仅基于使用量——通常可能会有一些使用服务的基本成本——但是大部分成本应与细粒度的使用成正比。

具有以除主机大小/数量外的其他术语定义的性能能力

对于无服务器平台来说,暴露一些性能配置是合理且有用的。但是,这种配置应完全抽象出所使用的任何基础实例或主机类型。

具有隐式高可用性

在运行应用程序时,我们通常使用高可用性(HA)一词来表示即使底层组件失败,服务也将继续处理请求。对于无服务器服务,我们期望供应商为我们提供透明的 HA。

例如,如果我们正在使用 BaaS 数据库,我们假设提供商正在执行处理单个主机或内部组件失败所需的所有操作。

什么是 AWS?

在本章中,我们已经几次谈到了 AWS,现在是时候稍微详细地看一下这个云服务提供商的巨头了。

自 2006 年推出以来,AWS 以令人难以置信的速度增长,涉及的服务数量和类型,AWS 云提供的容量以及使用它的公司数量。让我们来看看所有这些方面。

服务类型

AWS 拥有一百多种不同的服务。其中一些是相当低级的——网络、虚拟机、基本块存储。在这些服务之上,在抽象层面上,是组件服务——数据库、平台即服务、消息总线。然后在所有这些服务之上,真正的应用程序组件——用户管理、机器学习、数据分析。

这个堆栈的侧面是必要的管理服务,用于以规模使用 AWS——安全性、成本报告、部署、监视等。

这些服务的组合如图 1-1 所示。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0101.png

图 1-1. AWS 服务层

AWS 喜欢将自己宣传为终极 IT “乐高积木”提供商——它提供了大量可插拔类型的资源,可以组合在一起创建庞大、高度可扩展的企业级应用程序。

容量

AWS 将其计算机设施分布在全球 60 多个数据中心,如图 1-2 所示。在 AWS 术语中,每个数据中心对应一个可用区(AZ),而紧邻的数据中心群组成区域。AWS 在 5 个大陆上拥有 20 多个不同的区域。

那是很多计算机。

尽管区域总数继续增长,但每个区域内的容量也在增加。大量美国互联网公司在北弗吉尼亚州的 us-east-1 区域(华盛顿特区外)运行其系统——公司在那里运行系统越多,AWS 在增加可用服务器数量方面就越有信心。这是亚马逊与其客户之间的良性循环。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0102.png

图 1-2. AWS 区域(来源:AWS

当你使用亚马逊的一些低级服务,比如 EC2 时,通常会指定要使用的可用区。不过,对于高级服务,你通常只需指定一个区域,亚马逊将为你在单个数据中心级别处理任何问题。

亚马逊的区域模型的一个引人注目的方面是,从物流和软件管理的角度来看,每个区域基本上是独立的。这意味着,如果一个区域出现了像停电这样的物理问题,或者像部署错误这样的软件问题,其他区域几乎肯定不会受到影响。从我们用户的角度来看,区域模型确实会增加一些额外的工作量,但总体上它运行良好。

谁在使用 AWS?

AWS 拥有遍布全球的大量客户。大型企业、政府、初创公司、个人以及中间的所有人都在使用 AWS。你使用的许多互联网服务可能都托管在 AWS 上。

AWS 不仅适用于网站。许多公司已经将大部分“后端”IT 基础设施迁移到 AWS 上,发现这比运行自己的物理基础设施更具吸引力。

当然,AWS 并不垄断。在英语世界至少,谷歌和微软是它们最大的竞争对手,而阿里云则在不断增长的中国市场上与它们竞争。还有许多其他云服务提供商,提供适合特定类型客户的服务。

你如何使用 AWS?

你与 AWS 的第一次互动很可能是通过AWS Web 控制台。为此,你将需要某种访问凭证,这将为你在一个账户内授予权限。账户是一个映射到结算(即向 AWS 支付你使用的服务费用)的概念,但它也是 AWS 内部定义的服务配置的分组。公司倾向于在一个账户中运行多个生产应用程序。(账户也可以有子账户,但在本书中我们不会过多讨论它们——只需知道如果使用公司提供的凭证,它们可能是为特定的子账户。)

如果公司没有向你提供凭证,你需要创建一个账户。你可以通过提供 AWS 你的信用卡详细信息来完成此操作,但要知道 AWS 提供了一个慷慨的免费套餐,如果你只是按照本书中的基本练习进行,应该不会需要支付 AWS 任何费用。

你的凭证可能采用典型的用户名和密码形式,也可能通过单点登录(SSO)工作流程(例如通过 Google Apps 或 Microsoft Active Directory)进行。无论哪种方式,最终你都将成功登录到 Web 控制台。第一次使用 Web 控制台可能会让人畏缩,因为有 100 多个 AWS 服务争相吸引你的注意——Amazon Polly 在与一个叫做 Macie 的奇怪东西平分秋色时大喊“选我!”。然后当然还有那些仅以首字母缩略词命名的服务——它们到底是什么呢?

AWS 控制台主页如此令人震惊的原因之一是因为它实际上并非作为一个产品开发的——它是作为一百个不同产品开发的,所有产品都在主页上得到了链接。此外,深入了解一个产品可能看起来与了解另一个产品大不相同,因为在 AWS 宇宙中,每个产品都被赋予了相当多的自治权。有时使用 AWS 可能感觉像是在探险中浏览 AWS 的公司组织——不用担心,我们都有这种感觉。

除了 Web 控制台,与 AWS 交互的另一种方式是通过其广泛的 API。亚马逊在其历史的早期甚至在 AWS 时代之前就拥有的一个伟大方面是,每个服务必须通过公共 API 完全可用,这意味着在 AWS 中可以配置的任何事情实际上都可以通过 API 完成。

API 的顶层是 CLI——命令行界面——这是我们在本书中使用的工具。CLI 最简单的描述是一个与 AWS API 通信的轻量级客户端应用程序。我们将在下一章讨论如何配置 CLI(“AWS 命令行界面”)。

什么是 AWS Lambda?

Lambda 是亚马逊的 FaaS 平台。我们在前面简要提到了 FaaS,但现在是时候更详细地挖掘它了。

作为服务的函数

正如我们之前介绍的,FaaS 是一种围绕部署单个函数或操作的构建和部署服务器端软件的新方式。FaaS 是关于无服务器的许多噪音的来源;事实上,许多人认为无服务器 就是 FaaS,但他们忽略了完整的图景。虽然本书专注于 FaaS,我们鼓励您在构建更大型应用程序时也考虑 BaaS。

当我们部署传统的服务器端软件时,我们首先使用主机实例,通常是 VM 实例或容器(参见 图 1-3)。然后我们部署我们的应用程序,在主机内作为操作系统进程运行。通常,我们的应用程序包含多个不同但相关的操作的代码;例如,Web 服务可能允许检索和更新资源。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0103.png

图 1-3. 传统服务器端软件部署

从所有权的角度来看,我们作为用户对此配置的三个方面负责——主机实例、应用程序进程,当然还有程序操作。

FaaS 改变了部署和所有权的模型(参见 图 1-4)。我们从模型中剥离了主机实例和应用程序进程。相反,我们专注于表达应用程序逻辑的单个操作或函数。我们将这些函数单独上传到 FaaS 平台,这个平台由云供应商负责,而不是我们。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0104.png

图 1-4. FaaS 软件部署

函数在应用程序进程中不是持续活动的,而是处于空闲状态,直到需要运行它们,就像传统系统中的方式。相反,FaaS 平台被配置为为每个操作监听特定的事件。当事件发生时,平台实例化 FaaS 函数,然后调用它,传递触发事件。

当函数执行完成后,FaaS 平台可以自由地将其销毁。或者作为优化,它可以将函数保留一段时间,直到有另一个事件需要处理。

Lambda 实现的 FaaS

AWS Lambda 在 2014 年推出,并且在范围、成熟度和使用率方面不断增长。某些 Lambda 函数可能吞吐量非常低——可能每天只执行一次,甚至更少。但是其他函数可能每天执行数十亿次。

Lambda 通过实例化短暂的托管 Linux 环境来实现 FaaS 模式,以托管每个函数实例。Lambda 保证每次只处理一个环境中的事件。在撰写本文时,Lambda 还要求函数在 15 分钟内完成对事件的处理;否则,执行将被中止。

Lambda 提供了一个异常轻量级的编程和部署模型——我们只需提供一个函数及其依赖项,打包成 ZIP 或 JAR 文件,Lambda 完全管理运行时环境。

Lambda 与许多其他 AWS 服务紧密集成。这对应于可以触发 Lambda 函数的许多不同类型的事件源,从而可以使用 Lambda 构建许多不同类型的应用程序。

Lambda 是一个完全无服务器的服务,根据我们与之前的区分标准定义的:

不需要管理长时间运行的主机或应用程序实例

使用 Lambda,我们完全抽象出运行我们代码的底层主机。此外,我们不管理长时间运行的应用程序——一旦我们的代码完成处理特定事件,AWS 就可以自由终止运行时环境。

自动按负载自动扩展和自动配置

这是 Lambda 的一个关键优势之一——资源管理和扩展是完全透明的。一旦我们上传函数代码,Lambda 平台将创建足够的环境来处理任何特定时间的负载。如果一个环境足够,Lambda 将在需要时创建环境。另一方面,如果需要数百个单独的实例,Lambda 将快速扩展,而我们无需任何努力。

具有基于精确使用量的费用结构,从零使用到上升

AWS 仅按照我们的代码在每个环境中执行的时间收费,精确到 100 毫秒。如果我们的函数每 5 分钟活动 200 毫秒,那么我们每小时只需支付 2.4 秒的使用费用。这种精确的使用费用结构,无论我们的函数需要一个实例还是一千个实例,都是相同的。

性能能力是根据除主机大小/数量以外的条件定义的。

由于 Lambda 完全抽象出底层主机,我们无法指定要使用的 EC2 实例的数量或类型。相反,我们指定我们的函数需要多少 RAM(最多 3GB),性能的其他方面也与此相关。我们将在本书后面更详细地探讨这个问题——参见“内存和 CPU”。

具有隐式高可用性

如果特定的基础主机失败,那么 Lambda 将自动在不同的主机上启动环境。同样,如果特定的数据中心/可用区失败,Lambda 将在同一地区的不同 AZ 中自动启动环境。请注意,作为 AWS 客户,我们需要处理区域范围的故障,我们将在本书末尾讨论这个问题——参见“全球分布式应用”。

为什么选择 Lambda?

正如我们之前所描述的,云的基本好处也适用于 Lambda——与其他类型的主机平台相比,它通常更便宜;运行 Lambda 应用程序需要的操作和时间更少;Lambda 的伸缩性灵活性超过了 AWS 内的任何其他计算选项。

然而,从我们的角度来看,最大的好处在于与其他 AWS 服务结合使用时,Lambda 可以多快地构建应用程序。我们经常听说公司可以在一两天内构建全新的应用程序,并将其部署到生产环境中。能够摆脱我们通常在常规应用程序中编写的大量基础设施相关代码,这是一个巨大的时间节省者。

Lambda 还比任何其他 FaaS 平台拥有更多的容量、成熟度和集成点。它并不完美,我们认为一些其他产品在开发者体验上比 Lambda 更好。但是在没有与现有云供应商的强大联系的情况下,我们会推荐 AWS Lambda,原因正如前面列出的那些。

Lambda 应用程序是什么样子?

传统的长时间运行的服务器应用通常有两种启动工作的方式之一:它们可以打开 TCP/IP 套接字并等待传入连接,或者有一个内部调度机制,使它们可以访问远程资源以检查新的工作。由于 Lambda 基本上是一个事件驱动的平台,并且 Lambda 强制执行超时,所以这两种模式都不适用于 Lambda 应用程序。那么我们如何构建 Lambda 应用程序呢?

要考虑的第一点是,Lambda 函数在最低级别上可以通过两种方式调用:

  • Lambda 函数可以被称为同步调用,由 AWS 称为RequestResponse。在这种情况下,上游组件调用 Lambda 函数,并等待 Lambda 函数生成的任何响应。

  • 或者,Lambda 函数可以异步调用,由 AWS 称为Event。这时来自上游调用者的请求会立即由 Lambda 平台响应,而 Lambda 函数继续处理请求。在这种情况下,不会向调用者返回进一步的响应。

这两种调用模型有各种其他行为,我们稍后会深入探讨,从“调用类型”开始。现在让我们看看它们在一些示例应用中的使用方式。

Web API

一个显而易见的问题是 Lambda 是否可以用于实现 HTTP API,幸运的是答案是肯定的!虽然 Lambda 函数本身不是 HTTP 服务器,但我们可以使用另一个 AWS 组件API Gateway来提供 HTTP 协议和路由逻辑,这些通常在 Web 服务中使用(见图 1-5)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0105.png

图 1-5. 使用 AWS Lambda 的 Web API

上图显示了单页 Web 应用程序或移动应用程序使用的典型 API。用户客户端通过 HTTP 进行各种调用,以从后端检索数据和/或发起请求。在我们的情况下,处理请求的组件是亚马逊 API 网关——它是一个 HTTP 服务器。

我们通过将请求映射到处理程序来配置 API 网关(例如,如果客户端发出GET /restaurants/123请求,则可以设置 API 网关调用名为RestaurantsFunction的 Lambda 函数,并传递请求的详细信息)。API 网关将同步调用 Lambda 函数,并等待函数评估请求并返回响应。

由于 Lambda 函数实例本身不是可远程调用的 API,API 网关实际上会调用 Lambda 平台,指定要调用的 Lambda 函数、调用类型(RequestResponse)和请求参数。Lambda 平台随后会实例化一个RestaurantsFunction实例,并使用请求参数调用它。

Lambda 平台确实有一些限制,例如我们已经提到的最大超时时间,但除此之外,它基本上是一个标准的 Linux 环境。在RestaurantsFunction中,例如,我们可以调用数据库——亚马逊的 DynamoDB 是与 Lambda 一起使用的流行数据库之一,部分原因是两个服务的类似扩展能力。

一旦函数完成其工作,它会返回一个响应,因为它以同步方式调用。Lambda 平台将此响应传回 API 网关,后者将响应转换为 HTTP 响应消息,并将其传递回客户端。

通常,Web API 将满足多种类型的请求,映射到不同的 HTTP 路径动词(如 GET、PUT、POST 等)。在开发由 Lambda 支持的 Web API 时,通常会将不同类型的请求实现为不同的 Lambda 函数,尽管您不必使用这样的设计——如果愿意,可以将所有请求作为一个函数处理,并根据原始 HTTP 请求路径和动词在函数内部切换逻辑。

文件处理

Lambda 的常见用例是文件处理。让我们想象一个移动应用程序可以将照片上传到远程服务器,然后我们希望以不同的图像尺寸在我们的产品套件的其他部分中使用,如图 1-6 所示。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0106.png

图 1-6. 使用 AWS Lambda 进行文件处理

S3 是亚马逊的简单存储服务,即 2006 年推出的同一服务。移动应用可以通过 AWS API 安全地将文件上传到 S3。

当文件上传时,可以配置 S3 来调用 Lambda 平台,指定要调用的函数,并传递文件路径。与前面的示例类似,Lambda 平台会实例化 Lambda 函数,并使用 S3 传递的请求详细信息调用它。但是,此时的调用是异步调用(S3 指定了Event调用类型)——不会向 S3 返回任何值,S3 也不会等待返回值。

这次我们的 Lambda 函数仅存在于副作用的目的——它加载由请求参数指定的文件,然后在不同的 S3 存储桶中创建新的调整大小版本的文件。副作用完成后,Lambda 函数的工作就完成了。由于它在 S3 存储桶中创建了文件,我们可以选择向该存储桶添加 Lambda 触发器,还可以调用进一步处理这些生成文件的 Lambda 函数,从而创建处理管道。

Lambda 应用程序的其他示例

前两个示例展示了两种不同的 Lambda 事件源的场景。还有许多其他事件源可以使我们构建许多其他类型的应用程序。其中一些如下:

  • 我们可以构建消息处理应用程序,使用消息总线,如简单通知服务(SNS)、简单队列服务(SQS)、事件桥或 Kinesis 作为事件源。

  • 我们可以构建邮件处理应用程序,使用简单电子邮件服务(SES)作为事件源。

  • 我们可以构建类似于 cron 程序的定时任务应用程序,使用 CloudWatch 计划事件作为触发器。

请注意,除 Lambda 外的许多服务都是BaaS服务,因此也是无服务器的。结合 FaaS 和 BaaS 来生成无服务器架构是一种非常强大的技术,因为它们具有类似的扩展性、安全性和成本特性。事实上,正是这些服务的组合推动了无服务器计算的流行。

我们在第五章中深入讨论了以这种方式构建应用程序的内容。

AWS Lambda 在 Java 世界中

AWS Lambda 原生支持大量编程语言。JavaScript 和 Python 是 Lambda 的非常流行的“入门”语言(以及重要的生产应用程序语言),部分原因是它们的动态类型和非编译性质使得开发周期非常快速。

但是,我们起步时都使用 Java 与 Lambda。Java 在 Lambda 的世界中偶尔会有不良声誉——其中有些是合理的,有些则不是。然而,如果 Lambda 函数所需的内容可以用大约 10 行或更少的代码表达,通常在 JavaScript 或 Python 中快速组合会更快。但是,对于较大的应用程序,有许多很好的理由在 Java 中实现 Lambda 函数,其中一些如下:

  • 如果您或您的团队比其他 Lambda 支持的语言更熟悉 Java,那么您将能够在一个新的运行时平台中重用这些技能和库。在 Lambda 生态系统中,Java 与 JavaScript、Python、Go 等一样是“一流语言”—Lambda 不会因为您使用 Java 而对您造成限制。此外,如果您已经在 Java 中实现了大量代码,则与其重新实现为其他语言相比,将其移植到 Lambda 可能会带来显著的时间市场优势。

  • 在高吞吐量消息系统中,相比于 JavaScript 或 Python,Java 通常能带来显著的运行时性能优势。在任何系统中,“更快”通常意味着“更好”,而在 Lambda 中,“更快”还可能导致实际的成本优势,这是由于 Lambda 的定价模型。

对于 JVM 工作负载,在撰写本文时,Lambda 原生支持 Java 8 和 Java 11 运行时。Lambda 平台将在其 Linux 环境中实例化一个 Java 运行环境版本,然后在该 Java 虚拟机中运行我们的代码。因此,我们的代码必须与该运行时环境兼容,但我们不仅仅局限于使用 Java 语言。Scala、Clojure、Kotlin 等都可以在 Lambda 上运行(详见“其他 JVM 语言和 Lambda”)。

Lambda 还有一个高级选项,即定义自己的运行时环境,如果这两个 Java 版本都不够用,我们在“自定义运行时”中进一步讨论这一点。

Lambda 平台提供了一些基本的库与运行时(例如 AWS Java 库的一个小子集),但您的代码需要的任何其他库必须随代码本身提供。您将在“构建和打包”中学习如何做到这一点。

最后,虽然 Java 具有Lambda 表达式的编程构造,但这与 AWS Lambda 函数无关。如果您愿意,您可以在 AWS Lambda 函数中使用 Java Lambda 表达式(因为 AWS Lambda 支持 Java 8 及更高版本),也可以选择不使用。

概要

在本章中,您了解到无服务器计算是云计算的下一个演进阶段—一种通过依赖处理资源管理、扩展等服务来构建应用程序的方式,而无需配置。

此外,您现在了解到函数即服务(FaaS)和后端即服务(BaaS)是无服务器的两个组成部分,其中 FaaS 是无服务器中的通用计算范式。有关无服务器的更多信息,请参阅我们的免费 O’Reilly 电子书什么是无服务器?

您还至少对亚马逊 Web 服务有基本的了解—这是全球最流行的云平台之一。您了解到 AWS 具有托管应用程序的巨大容量,并且了解到如何通过 Web 控制台以及 API/CLI 访问 AWS。

您已经了解了 AWS Lambda—亚马逊的函数即服务产品。我们将“思考 Lambda”与传统构建的应用程序进行了比较,讨论了为什么您可能希望使用 Lambda 而不是其他函数即服务的实现,然后给出了一些使用 Lambda 构建的应用程序示例。

最后,您看到了 Java 作为 Lambda 语言选项的快速概述。

在第二章中,我们实现了我们的第一个 Lambda 函数—为一个全新的世界做好准备!

练习

  1. 获取一个AWS 账户的凭据。最简单的方法是创建一个新账户。正如我们之前提到的,如果您这样做,您将需要提供信用卡号码,但我们在本书中所做的一切应该都在免费层范围内,除非您在测试中非常热情!

    或者您可以使用现有的 AWS 账户,但如果这样做,我们建议使用一个“开发”账户,以免干扰任何“生产”系统。

    我们还强烈建议,无论您使用何种访问方式,都应该为您授予账户内的完全管理员权限;否则,您将因分散注意力的安全问题而陷入困境。

  2. 登录到AWS 控制台。找到 Lambda 部分—那里已经有任何函数了吗?

  3. 扩展任务: 查看Amazon 的无服务器营销页面,特别是它描述“无服务器平台”中各种服务的部分。哪些服务完全满足我们之前描述的无服务器服务的区分标准?哪些不满足,并且以什么方式它们“大部分”是无服务器的?

第二章:开始使用 AWS Lambda

第一章为你提供了这本书其余部分的背景:云端、无服务器、AWS,以及对 Lambda 的介绍,它的工作原理及其用途。但这是一本实用的书籍,面向实际的人,所以在本章中,我们将卷起袖子,在云端部署一些工作函数。

我们将从使你更熟悉 AWS 控制台开始,然后部署和运行我们的第一个 Lambda 函数。之后,我们将准备一个本地开发环境,最后我们将构建并部署一个本地开发的函数到 Lambda。

注意

如果你已经对 AWS 很有经验,请随时跳到“Lambda Hello World (尽快)”。

AWS 控制台快速指南

第一章的前两个练习涉及获取 AWS 凭证,然后登录到AWS Web 控制台。如果你还没有这样做,现在应该去做。

有点令人困惑的是,你可能使用了三种不同类型的凭据来登录:

  • 你可能使用过账户“root”用户,使用电子邮件地址和密码登录。这相当于在 Linux 系统中使用 root 用户。

  • 你可能使用了一个“IAM 用户”和密码。在这种情况下,你还需要提供数值 AWS 账户 ID(或 AWS 账户别名)。

  • 最后,你可能使用了单一登录方法(例如通过 Google Apps 账户)。

现在你成功登录了吗?太棒了!让我们来一场关于 AWS 世界的小旅行。

注意

首先,我要简要警告和解释一下。AWS Web 控制台经常进行用户体验(UX)改进,所以当你阅读这本书时,UI 可能与书中所示有所不同。我们会尽量解释示例的意图,而不仅仅是操作方式,这样当亚马逊改变其 UI 时,你仍然能够跟上。

区域

让我们开始吧。首先让我们谈谈区域。在右上角,你会看到当前选择的区域(见图 2-1](#currently-selected-region))。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0201.png

图 2-1. 当前选择的区域

正如你在第一章学到的,AWS 将其基础设施组织成称为可用区(AZs)的数据中心,然后将 AZs 集成到一个称为区域的紧密位于一起的组中。每个区域都是半自治的。现在你正在查看特定区域的 Web 控制台首页——在我们之前的例子中,那是俄勒冈州,也被称为 us-west-2 区域。

当你登录时,不一定要使用默认选择的区域——你可以自由地环游世界,寻找适合你的正确区域。点击区域名称,查看可用区域的列表(见图 2-2](#pick-a-region))。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0202.png

图 2-2. 选择一个区域

对于本书中要涵盖的内容,任何区域都应该足够。我们将在所有操作中都默认使用美国西部(俄勒冈州),如果你愿意,也可以选择更接近你家的区域作为备用选择。

身份和访问管理

现在让我们选择我们的第一个服务。在 Web 控制台首页上,展开所有服务,找到名为IAM的服务,或者在搜索框中搜索IAM,然后选择它。

IAM 是身份和访问管理的缩写——它是 AWS 中最基础的安全服务之一。它也是少数几个不绑定到任何特定区域的 AWS 服务之一(注意引用全球,以前用于定义你的区域)。

IAM 允许你创建“IAM 用户”、组、角色、策略等等。如果你使用为本书创建的 AWS 帐户(因此使用“根”电子邮件地址用户登录),我们建议为将来的工作创建一个 IAM 用户。我们将在“获取 AWS CLI 的凭证”中描述如何做到这一点。

角色类似于用户,可以用来允许人或过程获取特定的权限以完成任务。与用户不同的是,它们没有用户名或密码,而是必须承担角色才能使用。

AWS 是安全性的坚定支持者,这一点你很快就会发现。当你创建 Lambda 函数时,必须指定它执行时要承担的角色。如果没有指定角色,AWS 不会为其提供默认角色。我们稍后将在创建第一个函数时看到这一点。

你必须对 IAM 有基本的理解,因为在 Lambda 开发中,像角色和策略这样的方面是无处不在的。我们会在“身份和访问管理”章节中为你提供 IAM 的全面基础知识。

Lambda Hello World(尽快上手)

在本节中,我们将部署和运行我们的第一个 Lambda 函数。我们会告诉你一个小秘密——我们将使用 JavaScript 完成这个任务。嘘——别告诉我们的编辑——我们曾承诺这将是一本 Java 书籍!

之所以选择 JavaScript 作为第一个示例,是因为我们可以在 Web 浏览器中完全进行整个练习,让我们在几分钟内尝试 Lambda 的潜力。

首先,返回 AWS Web 控制台主屏幕,选择 Lambda。如果你以前没有在此帐户中使用 Lambda,你将看到类似于图 2-3 的屏幕。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0203.png

图 2-3 Lambda 欢迎屏幕

如果在此帐户中之前使用过 Lambda,Web 控制台的外观会更像图 2-4。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0204.png

图 2-4 Lambda 函数列表

由于亚马逊不断变化的 UI 设计,你阅读时可能会看到不同的外观。

无论哪种方式,点击创建函数,然后选择从头开始编写—这里有一些其他选项可供您开始使用更复杂的函数,但我们现在将做一些非常简单的事情。

在名称框中(参见图 2-5),输入**HelloWorld**,在运行时下点击Node.js 10.x。别担心,我们很快就会开始使用 Java!现在点击创建函数

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0205.png

图 2-5. 创建 HelloWorld 函数

如果此后控制台扩展了权限部分,请在执行角色下拉菜单中选择使用基本 Lambda 权限创建新角色,然后再次点击创建函数(参见图 2-6)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0206.png

图 2-6. 创建 HelloWorld 函数,指定创建一个新角色

Lambda 将在 Lambda 平台内创建一个 Lambda 函数配置,并在短暂等待后将您带到 Lambda 函数的主控制台页面。

如果您向下滚动,您会看到它甚至已经为函数提供了一些默认代码—对我们来说,现在这些代码完全合适。

滚动回顶部,点击测试按钮。这将打开一个名为配置测试事件的对话框—在事件名称框中输入**HelloWorldTest**,然后点击创建。这将带您回到 Lambda 函数屏幕。现在再次点击测试

这次 Lambda 将实际执行您的函数,并且会有一个短暂的延迟,因为它正在为代码实例化一个环境。然后您将看到一个执行结果的框—应该会显示函数执行成功!

展开详细信息控件,您将看到从函数返回的值,以及一些其他诊断信息(参见图 2-7)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0207.png

图 2-7. HelloWorld 执行

恭喜您—您已经创建并运行了您的第一个 Lambda 函数!

设置您的开发环境

现在您已经尝试了运行函数的一点(无服务器!),我们将转而实际构建和部署 Java Lambda 函数,这种方式更适合快速迭代和自动化。

首先,您需要设置一个本地开发环境。

AWS 命令行界面

如果您以前使用过 AWS CLI 并且已在您的计算机上配置了它,您可以跳过这一步。

安装 AWS CLI

Amazon 和 AWS 都建立在 API 之上。在Amazon API 命令的经典故事中,我们看到“所有团队从现在开始都将通过服务接口公开其数据和功能”,以及“所有服务接口必须从头开始设计,以便能够外部化”。这意味着几乎我们可以通过 AWS Web 控制台 UI 做的任何事情,我们也可以通过 AWS API 和 CLI 完成。

AWS API 是一个大集合的 HTTP 终端点,我们可以调用它们在 AWS 内执行操作。虽然直接调用 API 得到了完全的支持,但由于诸如身份验证/请求签名、正确的序列化等问题,这也显得有些繁琐。因此,AWS 为我们提供了两个工具来简化操作 —— SDK 和 CLI。

软件开发工具包(SDK)是 AWS 提供的库,我们可以在代码中使用它们调用 AWS API,从而简化一些复杂或重复的工作,例如身份验证。我们稍后在本书中使用这些 SDK —— “示例:构建无服务器 API”深入探讨了这个主题。

现在,我们将使用 AWS CLI。CLI 是一个可以从终端使用的工具 —— 它包装了 AWS API,因此几乎可以通过 CLI 访问 API 提供的所有内容。

您可以在 macOS、Windows 和 Linux 上使用 CLI;但是,我们在这里给出的所有示例和建议都是针对 macOS 的。如果您的开发机器使用不同的操作系统,则应将此处的说明与 AWS CLI 文档中指定的内容结合使用。

按照以下说明来 安装 CLI。如果您使用 Mac 和 Homebrew,安装 CLI 就像运行 brew install awscli 一样简单。

要验证 CLI 安装的有效性,请从终端提示符下运行**aws --version**。它应该返回类似以下的内容:

$ aws --version
aws-cli/1.15.30 Python/3.6.5 Darwin/17.6.0 botocore/1.10.30

准确的输出将取决于您的操作系统,以及其他因素。

获取 AWS CLI 的凭证

使用 AWS CLI 的凭证与您用于登录 AWS Web 控制台的凭证不同。对于 CLI,您需要两个值:一个访问密钥 ID及其密钥访问密钥。如果您已经有了这些值,可以跳过到下一节。

访问密钥 ID 和密钥访问密钥对是分配给IAM 用户的凭证。也可以将密钥和密钥分配给与电子邮件地址关联的帐户根用户,但出于安全原因,AWS 强烈建议不要这样做,我们也一样。

如果您还没有 IAM 用户(因为您使用了根用户登录,或者因为您使用了 SSO),您需要创建一个 IAM 用户。要执行此操作,请转到本章前面访问过的 AWS Web 控制台中的 IAM 控制台。单击用户,并仔细检查屏幕上是否有适合您的用户(参见图 2-8)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0208.png

图 2-8. IAM 用户列表

如果您确实需要创建用户,请点击 添加用户。在第一个屏幕上,为您的用户取一个名称,并选择 程序访问AWS 管理控制台访问。然后选择 自定义密码 并输入新密码 —— 这将是使用此新用户登录 AWS Web 控制台的密码,如果您希望这样做的话。取消选中 密码重置(见 图 2-9)。然后点击 下一步:权限

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0209.png

图 2-9. 添加 IAM 用户

在下一个屏幕上,选择 直接附加现有策略 并选择 管理员访问(见 图 2-10)。为了学习 Lambda,拥有具备完整权限的用户将使我们的生活更加轻松。在真实的生产账户中不应执行此操作。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0210.png

图 2-10. 添加 IAM 用户权限

点击 下一步:标签,然后在接下来的屏幕上点击 下一步:审核

在下一个屏幕上,检查详细信息是否与我们刚刚描述的相符,并点击 创建用户

在最后一个屏幕上,您将获得新用户的编程安全凭证!将访问密钥 ID 和秘密访问密钥(在显示后)复制到一个备忘录中(保持安全),或下载提供的 CSV 文件。最后,点击 关闭

如果您已经有一个 IAM 用户,但没有编程凭证,或者您丢失了刚创建的账户的凭证,请返回 IAM 控制台中的用户列表,选择用户,然后选择 安全凭证 选项卡。您可以从那里创建新的访问密钥(及相关的秘密访问密钥 ID)。

配置 AWS CLI

现在是时候配置 CLI 了。从终端运行 aws configure。对于前两个字段,请粘贴您从上一节复制的值。对于默认区域名称,请输入与您选择的 AWS 区域相对应的区域代码。您可以在 Web 控制台的下拉菜单中看到区域代码(这些映射也可以在 AWS 文档 中找到)。因为我们在 Web 控制台中选择了 Oregon 作为示例,所以在终端的示例中我们将使用 us-west-2。最后,对于默认输出格式,请输入 json

警告

如果您已在 CLI 中配置了不同的 AWS 账户,并为本书添加了新账户,则需要创建一个不同的配置文件;否则,上述说明将替换您现有的凭证。使用 aws configure--profile 选项,并查看更多细节 在 AWS 文档中

要确认您的值,请再次运行 aws configure,您将看到类似以下设置的内容:

$ aws configure
AWS Access Key ID [********************]:
AWS Secret Access Key [********************]:
Default region name [us-west-2]:
Default output format [json]:

快速验证 AWS 配置文件的一种好方法是运行命令 aws iam get-user,其输出应该类似于以下内容,其中 UserName 是正确 IAM 用户的名称:

$ aws iam get-user
{
  "User": {
    "Path": "/",
    "UserName": "book",
    "UserId": "AIDA111111111111111111",
    "Arn": "arn:aws:iam::181111111111:user/book",
    "CreateDate": "2019-10-21T20:27:05Z"
  }
}

如果您需要更多帮助,请访问文档

Java 设置

现在您已经有了本地 AWS 环境,是时候设置 Java 环境了。

AWS Lambda 支持 Java 8 和 Java 11,强烈建议您在本地配置 Lambda 函数时与您使用的 Java SE Development Kit 的主要版本保持一致。大多数操作系统支持安装多个版本的 Java。

如果您尚未安装 Java,则至少有几个选项可供选择:

就 Lambda 开发者而言,目前这两个选项的主要区别基本上是法律上的而非技术上的。然而,我们预计 AWS 将会在未来将所有的 Java 环境转移到 Corretto 上,因此如果有疑问,我们建议 Lambda 开发者选择 Corretto Java SDK。

要验证您的 Java 环境,请从终端运行**java -version**,您应该会看到类似以下内容的输出:

$ java -version
openjdk version "1.8.0_232"
OpenJDK Runtime Environment Corretto-8.232.09.1 (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM Corretto-8.232.09.1 (build 25.232-b09, mixed mode)

Java 的精确构建版本并不重要(尽管保持与安全补丁的最新状态始终是明智的),但重要的是您有正确的基础版本。

我们还使用 Maven——构建和打包工具。如果您已经安装了 Maven,请确保它是比较更新的版本。如果您还没有安装 Maven 并且使用的是 Mac,那么我们建议使用 Homebrew 安装它——运行brew install maven。否则,请参阅Maven 官网获取安装指南。

打开终端并运行**mvn -v**来验证您的环境。您应该会看到类似以下内容开头的输出:

$ mvn -v
Apache Maven 3.6.0 (97c98ec64a1fdfee77...
Maven home: /usr/local/Cellar/maven/3.6.0/libexec
Java version: 1.8.0_232, vendor: Amazon.com Inc., runtime: /Library/Java...
Default locale: en_US, platform encoding: UTF-8
OS name: "mac os x", version: "10.14.6", arch: "x86_64", family: "mac"

本书中的任何 3.x 版本的 Maven 都能够满足我们的需求。

最后,您应该能够在您选择的开发编辑器中轻松创建使用 Maven 的 Java 项目。我们使用的是免费版本的IntelliJ IDEA,但您可以随意选择其他编辑器。

AWS SAM CLI 安装

您还需要安装的最后一个工具是 AWS SAM CLI。SAM 代表 Serverless Application Model,我们稍后会探讨它在“CloudFormation 和 Serverless Application Model”中的应用。现在您只需要知道 SAM CLI 是在常规 AWS CLI 的基础上提供一些有用的额外工具。

要安装 SAM,请参考 详细说明。如果时间紧迫,可以跳过关于 Docker 的文档部分,因为起初我们不会使用它们!

警告

我们使用 SAM CLI 的一些功能,这些功能在 2019 年末引入,所以如果你使用的是早期版本,请确保更新它。

Lambda Hello World(正确的方式)

开发环境准备好后,现在是时候创建和部署一个用 Java 编写的 Lambda 函数了。

创建你的第一个 Java Lambda 项目

在构建和部署 Lambda 函数的自动化过程中,有一些“样板代码”是必需的。在本书的过程中,我们将详细介绍所有复杂性,但为了让您快速上手,我们已经创建了一个模板来加快速度。

首先,进入终端并运行以下命令:

$ sam init --location gh:symphoniacloud/sam-init-HelloWorldLambdaJava

这将要求你提供一个 project_name 值,暂时只需按 Enter 使用默认值即可。

然后命令将生成一个项目目录。切换到该目录并查看。你将看到以下文件:

README.md

一些关于如何构建和部署项目的说明

pom.xml

一个 Maven 项目文件

template.yaml

一个 SAM 模板文件——用于将项目部署到 AWS

src/main/java/book/HelloWorld.java

一个 Lambda 函数的源代码

现在打开你选择的 IDE/editor 中的项目。如果你使用的是 Jetbrains IntelliJ IDEA,可以通过运行以下命令来打开:

$ idea pom.xml

pom.xml 文件中,如果你愿意的话,将 <groupId> 更改为更适合你自己的值。

现在看一下 示例 2-1,显示的是 src/main/java/book/HelloWorld.java 文件。

示例 2-1. Hello World Lambda(Java 实现)
package book;

public class HelloWorld {
  public String handler(String s) {
    return "Hello, " + s;
  }
}

这个类代表了一个完整的 Java Lambda 函数。很小,不是吗?不要太担心它的内容和原因;我们很快就会讲到。现在,让我们来构建我们的 Lambda 部署工件。

构建 Hello World

我们通过上传一个 ZIP 文件将代码部署到 Lambda 平台,或者在 Java 世界中,我们也可以部署一个 JAR 文件(JAR 文件只是一个包含了一些嵌入式元数据的 ZIP)。现在我们将创建一个 uberjar ——一个包含我们所有代码以及我们的代码需要的所有类路径依赖项的 JAR 文件。

刚刚创建的模板项目已设置好为您创建一个 uberjar。我们现在不会检查它,因为在 第四章 中,我们将更深入地探讨一个生成 Lambda ZIP 文件的更好方法(“组装 ZIP 文件”)。

要构建 JAR 文件,请从项目的工作目录运行 mvn package。这应该在结束时成功完成,并显示以下行:

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

这也应该创建我们的 uberjar。运行 jar tf target/lambda.jar 来列出 JAR 文件的内容。输出应包括 book/HelloWorld.class,这是我们应用程序代码,嵌入在工件中。

创建 Lambda 函数

本章早些时候,我们通过 Web 控制台向您展示了如何创建 Lambda 函数。现在,我们将从终端执行同样的操作。我们将使用**sam**命令来完成这两个进一步的操作。

在此之前,我们需要在S3 AWS 服务中创建或识别一个暂存桶,以存储临时构建构件。如果您按照 AWS 的 SAM CLI 安装说明或已知道当前 AWS 账户中有这样一个存储桶可用,请随意使用。否则,您可以使用以下命令创建一个,将自己的名称替换为bucketname。请注意,S3 存储桶名称需要在所有 AWS 账户中全局唯一,因此您可能需要尝试几个名称以找到一个可用的。

$ aws s3 mb s3://bucketname

完成这些步骤后,请记录下这个存储桶名称——我们在本书的后续部分会经常使用它,并将其称为$CF_BUCKET

注意

从现在开始,无论何时看到$CF_BUCKET,请使用刚刚创建的存储桶名称。为什么叫CF?这代表CloudFormation,我们将在第 4 章中详细解释。

或者,如果您更熟悉 Shell 脚本,可以将此存储桶名称分配给名为CF_BUCKET的 Shell 变量,然后您可以直接使用对$CF_BUCKET的引用。

准备好 S3 存储桶后,我们可以创建 Lambda 函数。运行以下命令(在运行**mvn package**之后):

$ sam deploy \
  --s3-bucket $CF_BUCKET \
  --stack-name HelloWorldLambdaJava \
  --capabilities CAPABILITY_IAM

目前不要过多关注这些内容的含义——我们稍后会进行解释。如果操作正确,控制台输出应以以下内容结尾(尽管您的区域可能不同):

Successfully created/updated stack—HelloWorldLambdaJava in us-west-2

这意味着您的函数已部署并准备就绪,现在让我们运行它。

运行 Lambda 函数

返回 Lambda Web 控制台中的函数列表,您现在应该可以看到列出了两个函数:原始的HelloWorld和一个新的函数,其名称可能类似于HelloWorldLambdaJava-HelloWorldLambda-YF5M2KZHXZF5。如果没有看到新的 Java 函数,请确保您的终端和 Web 控制台的区域设置是同步的。

点击进入新的函数,查看配置屏幕。您会发现,由于函数是使用编译后的构件创建的,因此源代码已不再可用。

要测试此函数,我们需要创建一个新的测试事件。再次点击Test,在配置测试事件屏幕上(图 2-11),给事件命名为HelloWorldJavaEvent。在实际事件主体部分,输入以下内容:

"Java World!"

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0211.png

图 2-11. 配置 Java Lambda 函数的测试事件

点击创建以保存测试事件。

这将带您回到主要的 Lambda 屏幕,并选择新的测试事件(如果没有,请手动选择)。点击Test,您的 Lambda 函数将被执行!(参见图 2-12。)

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0212.png

图 2-12. Java 中 Hello World 的结果

概要

在本章中,您学习了如何登录 AWS Web 控制台并选择一个区域。然后,通过 Web 控制台创建并运行了您的第一个 Lambda 函数。

你还通过设置 AWS CLI、Java、Maven 和 AWS SAM CLI 为 Lambda 开发准备了本地环境。你通过在开发环境中创建项目、构建项目并使用 Amazon 的 SAM 工具部署项目,学习了用 Java 开发 Lambda 函数的基础知识。最后,你现在了解如何通过模拟事件使用 Web 控制台的测试事件机制来进行 Lambda 函数的简单测试。

在下一章中,我们将开始研究 Lambda 的工作原理以及这些原理对 Lambda 代码编写方式的影响。

练习

  1. 如果您还没有按照本章的逐步说明进行操作,那么现在进行操作是很值得的,因为这是验证您环境的好方法。

  2. sam deploy时,通过使用不同的stack-name值,创建一个具有稍微不同代码的新版本 Java Lambda 函数。注意您如何在 Web 控制台中选择这些函数之间的区别。

第三章:编程 AWS Lambda 函数

本章内容涉及构建 Lambda 函数的含义—它们是什么样子的,如何配置它们的运行方式,以及如何指定自己的环境配置。通过检查 Lambda 执行环境的核心概念、输入和输出、超时、内存和 CPU,最后,Lambda 如何使用环境变量进行应用程序配置来学习这些主题。

首先,让我们看看 Lambda 函数是如何执行的。系好您的登山靴—是时候探索一番了。

核心概念:运行时模型、调用

在第二章中,您创建了一个 Java 类,将其上传到某个位于“云”中的 Lambda 服务,并神奇地能够执行该代码。您不必考虑操作系统、容器、启动脚本、代码部署到实际主机或 JVM 设置。也不必考虑那些讨厌的“服务器”。那么您的代码是如何执行的呢?

要理解这一点,您首先需要了解 Lambda 执行环境的基础知识,如图 3-1 所示。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0301.png

图 3-1. Lambda 执行环境

Lambda 执行环境

正如我们在第二章中提到的(参见“安装 AWS CLI”),AWS 的管理和函数操作(通常称为控制平面数据平面)都广泛使用 API。Lambda 也不例外,为函数的管理和执行提供 API。

每当调用 AWS Lambda API 的invoke命令时,函数都会被执行或调用。这发生在以下时间:

  • 当函数由事件源触发时

  • 当您在 Web 控制台中使用测试工具包时

  • 当您自己调用 Lambda API 的invoke命令时,通常通过 CLI 或 SDK,从您自己的代码或脚本中。

首次调用函数将启动以下一系列活动链,最终导致您的代码被执行。

首先,Lambda 服务将创建一个主机 Linux 环境—一个轻量级微虚拟机。通常您不需要担心它究竟是何种环境(什么内核,什么发行版等),但如果您关心,亚马逊会公布这些信息。但不要依赖它保持不变—亚马逊可能会频繁更改 Lambda 函数的操作系统,通常是为了您自己的利益,包括自动安全补丁。

一旦主机环境被创建,Lambda 将在其中启动语言运行时—在我们的例子中是 Java 虚拟机。在撰写本文时,JVM 版本将始终为 Java 8 或 Java 11。您必须提供与您选择的 Java 版本兼容的代码给 Lambda。JVM 是以一组环境标志启动的,我们无法更改。

当我们编写代码时,您可能已经注意到没有“main”方法——顶级 Java 应用程序是亚马逊自己的 Java 应用程序服务器,我们将其称为 Lambda Java 运行时;这是下一个要启动的组件。运行时负责顶级错误处理、日志记录等。

当然,Lambda Java 运行时的主要任务是执行我们的代码。调用链的最后几步是:(a)加载我们的 Java 类,并(b)调用我们在部署过程中指定的处理方法。

调用类型

很好——我们的代码已经运行!接下来会发生什么?

为了探索这个问题,让我们开始使用 AWS CLI。在 第二章 中,我们使用了更高级别的 SAM CLI 工具——AWS CLI 更接近 AWS 机器的内部。具体来说,我们将使用 AWS CLI 中用于调用 Lambda 函数的命令:aws lambda invoke

假设您在 第二章 中运行了示例,让我们从一个小更新开始。打开 template.yaml 文件(我们将从现在开始偶尔称为 SAM 模板),在属性部分中添加一个名为 FunctionName 的新属性,值为 HelloWorldJava,以使资源部分如下所示:

HelloWorldLambda:
  Type: AWS::Serverless::Function
  Properties:
    FunctionName: HelloWorldJava
    Runtime: java8
    MemorySize: 512
    Handler: book.HelloWorld::handler
    CodeUri: target/lambda.jar

重新从 第二章 运行 sam deploy 命令。几分钟后应该完成。如果回到 Lambda 控制台,你会看到你的奇怪命名的 Java 函数现在已经重命名为 HelloWorldJava。在大多数实际用例中,我们喜欢使用 AWS 提供的生成名称,但当我们学习 Lambda 时,能够引用更简洁名称的函数会更好。

注意

要使用 Java 11 运行时而不是 Java 8,只需在 SAM 模板中将 Runtime: 属性从 java8 更改为 java11

让我们回到调用。从终端运行以下命令:

$ aws lambda invoke \
  --invocation-type RequestResponse \
  --function-name HelloWorldJava \
  --payload \"world\" outputfile.txt

这应该返回以下内容:

{
  "StatusCode": 200,
  "ExecutedVersion": "$LATEST"
}

您可以通过 StatusCode200 来确认一切正常。

通过执行以下命令,还可以查看 Lambda 函数返回的内容:

$ cat outputfile.txt && echo
"Hello, world"

当我们执行 invoke 命令时,Lambda 函数首先被实例化,正如我们在前一节中描述的那样。实例化完成后,Lambda Java 运行时在 JVM 内部调用我们的 Lambda 函数,使用我们传递给 payload 参数的数据——在本例中是字符串 "world"

我们的代码然后运行。作为提醒,这里是代码:

public String handler(String s) {
  return "Hello, " + s;
}

它接受我们的输入("world"),并返回 "Hello, world"

这里有一个重要但微妙的点。当我们调用 invoke 时,我们指定了 --invocation-type RequestResponse——这意味着我们 同步 调用函数(即 Lambda 运行时调用我们的代码并等待结果)。我们在 “Lambda 应用程序是什么样子?” 中解释了这一点。同步行为 对于像 Web API 这样的场景非常有用。

因为我们同步调用了函数,Lambda 运行时能够将响应返回给我们的终端,这就是保存到 outputfile.txt 的内容。

现在让我们稍微不同地调用函数:

$ aws lambda invoke \
  --invocation-type Event \
  --function-name HelloWorldJava \
  --payload \"world\" outputfile.txt

注意我们已将 --invocation-type 标志更改为 Event。现在的结果如下所示:

{
  "StatusCode": 202
}

StatusCode202,而不是 200。在 HTTP 术语中,202 意味着已接受。如果您查看 outputfile.txt,您会发现它是空的。

这一次我们以异步方式调用函数。Lambda 运行时像以前一样调用我们的代码,但它不等待或使用我们代码返回的值——该值会被丢弃。使用异步执行的关键在于我们可以对某些其他函数或服务执行“副作用”。在 “Lambda 应用程序是什么样子?” 中的异步示例中,副作用是将照片的新调整大小版本上传到亚马逊的 S3 服务中。

当您开始使用 Lambda 时,您会发现大多数 Lambda 函数类使用异步调用,支持 Lambda 是一个事件驱动平台的理念。我们将在本书稍后的章节中进一步探讨这一点,当我们开始研究 “Lambda 事件来源” 时。

我们在前两个示例中使用了相同的代码;然而,如果您知道您的 Lambda 函数永远不会被同步使用,则不需要返回值——该方法可以具有 void 返回类型。让我们看一个例子。

首先,将函数的方法更改为以下内容:

public void handler(String s) {
  System.out.println("Hello, " + s);
}

注意我们已将返回类型更改为 void,并且现在正在向 System.out 写入消息。

现在我们需要重建和重新部署我们的代码。要做到这一点,请运行与 第二章 中相同的两个命令:

  • mvn package

  • sam deploy…

其中 **…** 指的是您之前使用的相同参数。您会经常运行这些命令,所以可能想把它们放入一个脚本中。

现在以 Event 调用类型再次调用代码,您应该会收到另一个 "StatusCode": 202 的响应。但是 System.out 中的消息去哪里了?要理解这一点,让我们快速看一下日志记录。

注意

您现在已经了解足够的关于 mvnsamaws 命令的知识,可以运行本章剩余的示例。如果出现异常情况,请转到 AWS Web 控制台中的 CloudFormation,删除 HelloWorldLambdaJava 栈,然后重新部署。

日志简介

Lambda 运行时捕获我们的函数写入的任何内容,无论是标准输出还是标准错误流。在 Java 中,这些对应于 System.outSystem.err。一旦 Lambda 运行时捕获到这些数据,它会将其发送到 CloudWatch Logs。如果您是 AWS 的新手,这需要更详细的解释!

CloudWatch Logs 由几个组件组成。其中主要的是日志捕获服务。它便宜、可靠、易于使用,并且可以处理您可能遇到的所有规模。

一旦 CloudWatch Logs 捕获到日志消息,您有几种方法可以查看或处理它们。最简单的方法是使用 AWS Web 控制台中的 CloudWatch Logs 日志查看器。

有多种方法可以实现这一点,但现在请在 AWS Web 控制台中打开您 Lambda 函数页面(如我们在 “运行 Lambda 函数” 中所示)。如果您点击该页面的监控选项卡,您应该能够看到一个 在 CloudWatch 中查看日志 按钮—点击它,如 图 3-2 所示。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0302.png

图 3-2. 访问 Lambda 日志

接下来您将看到的内容将取决于 CloudWatch 控制台的工作方式,但如果您尚未看到日志输出,请点击蓝色的 搜索日志组 按钮并向下滚动至最近的日志行。然后您应该能够看到类似 图 3-3 中的内容。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0303.png

图 3-3. Lambda 日志

注意第二行就是我们从 Lambda 函数中编写的输出。

没有一个优秀的、自重的 Java 程序员会真正使用 System.out.println 进行生产日志记录—日志记录框架提供了更多的灵活性和控制日志行为的功能。我们将在 “日志记录” 中详细讨论日志记录实践。

输入,输出

当执行 Lambda 函数时,它总是会传递一个输入参数,通常称为 事件。在 Lambda 执行环境中,此事件始终是一个 JSON 值,在我们到目前为止的示例中,我们一直在手动创建一个字符串—这个字符串本身就是有效的 JSON。

在实际使用中,Lambda 函数的输入将是一个表示来自某些其他组件或系统的事件的 JSON 对象。例如,它可能是表示 HTTP 请求的详细信息,或者是上传到 S3 存储服务的图像的一些元数据。在本书的后面章节中我们将详细讨论如何将事件源与 Lambda 函数绑定—参见 “Lambda 事件源”。

我们在测试事件中创建的 JSON,或者来自事件源的 JSON,会传递给 Lambda Java 运行时。在大多数用例中,Lambda Java 运行时会自动为我们反序列化此 JSON 负载,并且我们有几种选项来指导此过程。

正如您在前一节中看到的,当我们同步调用一个函数时,我们可以将一个有用的值返回给环境。Lambda Java 运行时会自动将此返回值序列化为 JSON。

Java 运行时如何执行这些序列化和反序列化操作取决于我们在函数签名中指定的类型,因此现在是时候深入了解使 Lambda 函数在静态上有效的因素了。

Lambda 函数方法签名

有效的 Java Lambda 方法必须符合以下四种签名之一:

  • output-type handler-name(input-type input)

  • output-type handler-name(input-type input, Context context)

  • void handler-name(InputStream is, OutputStream os)

  • void handler-name(InputStream is, OutputStream os, Context context)

其中:

  • output-type 可以是 void、Java 原始类型或可 JSON 序列化的类型。

  • input-type 是 Java 原始类型或可 JSON 序列化的类型。

  • Context 指的是 com.amazonaws.services.lambda.runtime.Context(我们将在本章稍后详细描述)。

  • InputStreamOutputStream 是指 java.io 包中的相应类型。

  • handler-name 可以是任何有效的 Java 方法名称,并且我们在应用程序的配置中引用它。

Java Lambda 方法可以是实例方法也可以是静态方法,但必须是公共的。

包含 Lambda 函数的类不能是抽象的,并且必须具有无参数构造函数——可以是默认构造函数(即未指定任何构造函数)或显式的无参数构造函数。考虑使用构造函数的主要原因之一是在 Lambda 调用之间缓存数据,这是我们稍后会详细讨论的高级主题—参见 “Caching”。

除了这些限制外,Java Lambda 函数没有静态类型要求。您不需要实现任何接口或基类,尽管如果希望可以这样做。AWS 提供了一个 RequestHandler 接口,如果您想非常明确地指定 Lambda 类的类型,但我们从未发现有必要使用这个接口。此外,您可以扩展自己的类(符合构造函数规则),但我们同样发现这种能力很少有用。

您可以在一个类中定义多个具有不同名称的 Lambda 函数,但我们通常不建议这种风格。由于两个不同的 Lambda 函数永远不会在同一个执行环境中运行,我们发现将每个函数的代码清晰地分开可以让后续的工程师更容易理解。

Lambda 函数在静态上与某些其他应用程序框架相比较简单。前面列出的前两个签名是 Java Lambda 最常见的签名,接下来我们将看看它们。

在 SAM 模板中配置处理函数

到目前为止,我们仅对 SAM 模板文件 template.yaml 进行了一次更改,即更改函数的名称。在我们继续之前,我们需要查看该文件中的另一个属性:Handler

打开 template.yaml 文件,您会看到 Handler 当前设置为 book.HelloWorld::handler。这意味着对于此 Lambda 函数,Lambda 平台将尝试在名为 book 的包中的名为 HelloWorld 的类中找到一个名为 handler 的方法。

如果你在名为old.macdonald.farm的包中创建了一个名为Cow的新类,并且你有一个名为moomoo的方法作为你的 Lambda 函数,那么你应该将Handler设置为old.macdonald.farm.Cow::moomoo

有了这些信息,你就可以创建一些新的 Lambda 处理程序了!

基本类型

示例 3-1 展示了一个具有三个不同 Lambda 处理函数的类(是的,我们刚才说过在实际使用中我们不倾向于在一个类中使用多个 Lambda 函数—这里为了简洁起见这样做了!)

示例 3-1. 基本类型的序列化和反序列化
package book;

public class StringIntegerBooleanLambda {
  public void handlerString(String s) {
    System.out.println("Hello, " + s);
  }

  public boolean handlerBoolean(boolean input) {
    return !input;
  }

  public boolean handlerInt(int input) {
    return input > 100;
  }
}

要尝试这段代码,请将新的类StringIntegerBooleanLambda添加到你的源代码树中,更改template.yaml文件中的Handler(例如,改为book.StringIntegerBooleanLambda::handlerString),然后运行你的打包和部署命令。

第一个函数与我们在前一节中描述的相同。我们可以通过使用 JSON 对象"world"调用它进行测试,由于它有一个void返回类型,它适用于异步使用。

提示

从现在开始,当我们在示例中说要调用一个函数时,假设我们是指在没有特别指定的情况下以同步方式调用它。你可以通过在终端调用时使用--invocation-type RequestResponse标志或在 AWS Web 控制台中使用测试功能来实现这一点。

第二个函数可以用布尔值调用—任何 JSON 值truefalse"true""false"—它也会返回一个布尔值,在这种情况下是输入的反向。

最后一个函数接受一个整数(可以是 JSON 整数或 JSON 字符串中的数字,例如5"5"),并返回一个布尔值。

在第二和第三个示例中,我们使用了一个原始类型,但如果你愿意,你可以使用装箱类型。例如,你可以使用java.lang.Integer而不是简单的int

在所有这些情况中发生的情况是 Lambda Java 运行时代表我们将 JSON 输入反序列化为简单类型。如果传递的事件无法反序列化为指定的参数类型,你将收到一个失败消息,消息开头如下:

An error occurred during JSON parsing: java.lang.RuntimeException

字符串、整数和布尔值是唯一明确记录为支持的基本类型,但通过一些实验,我们发现其他基本类型,如双精度和浮点数,也包括在内。

列表和映射

JSON 还包括数组和对象/属性(参见示例 3-2)。Lambda Java 运行时将自动将其反序列化为 Java ListMap,并且还会将输出的ListMap序列化为 JSON 数组和对象。

示例 3-2. 列表和映射的序列化和反序列化
package book;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;

public class ListMapLambda {
  public List<Integer> handlerList(List<Integer> input) {
    List<Integer> newList = new ArrayList<>();
    input.forEach(x -> newList.add(100 + x));
    return newList;
  }

  public Map<String,String> handlerMap(Map<String,String> input) {
    Map<String, String> newMap = new HashMap<>();
    input.forEach((k, v) -> newMap.put("New Map -> " + k, v));
    return newMap;
  }

  public Map<String,Map<String, Integer>>
    handlerNestedCollection(List<Map<String, Integer>> input) {
    Map<String, Map<String, Integer>> newMap = new HashMap<>();
    IntStream.range(0, input.size())
          .forEach(i -> newMap.put("Nested at position " + i, input.get(i)));
    return newMap;
  }
}

使用 JSON 数组[ 1, 2, 3 ]调用函数handlerList()返回[ 101, 102, 103 ]。使用 JSON 对象{ "a" : "x", "b" : "y"}调用函数handlerMap()返回{ "New Map → a" : "x", "New Map → b" : "y" }

此外,您可以如预期般使用嵌套的集合;例如,通过调用 handlerNestedCollection() 来执行

[
  { "m" : 1, "n" : 2 },
  { "x" : 8, "y" : 9 }
]

返回

{
  "Nested at position 0": { "m" : 1, "n" : 2},
  "Nested at position 1": { "x": 8, "y" : 9}
}

最后,您也可以只使用 java.lang.Object 作为输入参数的类型。虽然在生产中很少有用(除非您不关心输入参数的值,有时这是一个有效的用途),但在开发时,如果不知道事件的确切格式,这可能很方便。例如,您可以在参数上使用 .getClass() 来查找它的实际类型,打印出 .toString() 的值等等。稍后我们会展示另一种更好的方法来获取事件的 JSON 结构。

POJO 和生态系统类型

对于非常简单的输入类型,前面的输入类型效果很好。对于更复杂的类型,另一种选择是使用 Lambda Java 运行时的自动 POJO(普通的 Java 对象)序列化。示例 3-3 展示了我们如何同时用于输入和输出。

示例 3-3. POJO 序列化和反序列化
package book;

public class PojoLambda {
  public PojoResponse handlerPojo(PojoInput input) {
    return new PojoResponse("Input was " + input.getA());
  }

  public static class PojoInput {
    private String a;

    public String getA() {
      return a;
    }

    public void setA(String a) {
      this.a = a;
    }
  }

  public static class PojoResponse {
    private final String b;

    PojoResponse(String b) {
      this.b = b;
    }

    public String getB() {
      return b;
    }
  }
}

显然,这只是一个非常简单的案例,但它展示了 POJO 序列化的实际效果。我们可以使用输入 { "a" : "Hello Lambda" } 执行此 Lambda,并返回 { "b" : "Input was Hello Lambda" }。让我们仔细看一下代码。

首先,我们有我们的处理函数 handlerPojo()。它以 PojoInput 类型作为输入,这是我们定义的 POJO 类。POJO 输入类可以是静态嵌套类,就像我们在这里写的一样,也可以是常规(外部)类。重要的是,它们需要有一个空的构造函数,并且必须有字段的 setter,这些 setter 遵循从输入 JSON 中反序列化预期字段的命名规则。如果找不到与 setter 同名的 JSON 字段,则 POJO 字段将保持为 null。输入的 POJO 对象需要是可变的,因为运行时会在实例化后修改它们。

我们的处理函数会检查 POJO 对象,并创建 PojoResponse 类的新实例,然后将其传回 Lambda 运行时。Lambda 运行时通过反射所有的 get… 方法将其序列化为 JSON。POJO 输出类的限制较少——由于它们不是由 Lambda 运行时创建或变异的,因此您可以根据自己的意愿构建它们,也可以使它们成为不可变对象。与输入类一样,POJO 输出类可以是静态嵌套类或常规(外部)类。

对于 POJO 输入和输出类,您可以进一步嵌套 POJO 类,使用相同的规则来序列化/反序列化嵌套的 JSON 对象。此外,您可以在输入和输出中混合使用我们讨论过的 POJO 和集合类型(ListMap)。

我们之前给出的示例基本上遵循了您在线上看到的大部分文档:为字段使用 JavaBean 约定。然而,如果您不想在输入类中使用 setter 或在输出类中使用 getter,您也可以使用公共字段。例如,示例 3-4 展示了另一个例子。

示例 3-4. POJO 序列化和反序列化的备选定义
package book;

public class PojoLambda {
  public PojoResponse handlerPojo(PojoInput input) {
    return new PojoResponse("Input was " + input.c);
  }

  public static class PojoInput {
    public String c;
  }

  public static class PojoResponse {
    public final String d;

    PojoResponse(String d) {
      this.d = d;
    }
  }
}

我们可以使用输入 { "c" : "Hello Lambda" } 来执行这个 Lambda 函数,它将返回 { "d" : "Input was Hello Lambda" }

POJO 输入反序列化的主要用途之一是将 Lambda 函数与 AWS 生态系统 Lambda 事件源之一绑定。以下是一个示例,展示了如何处理上传到 S3 存储服务的对象事件的处理函数:

public void handler(S3Event input) {
  // …
}

S3Event是你可以从 AWS 库依赖中访问的一种类型——我们将在“示例:构建无服务器数据流水线”中进一步讨论此问题。你也可以自由构建自己的 POJO 类来处理 AWS 事件。

到目前为止,我们讨论的输入/输出类型在实际使用 Lambda 中将对你非常有用,甚至可能涵盖所有场景。但是如果你有一个相当动态和/或复杂的结构,而你不能或不想使用先前的反序列化方法的话,该怎么办?

答案是使用前述列表的选项 3 或 4 的有效签名,利用java.io.InputStream作为事件参数。这使你可以访问传递给 Lambda 函数的原始字节。

使用InputStream的 Lambda 的签名略有不同,因为它始终具有void返回类型。如果将InputStream作为参数,你还必须将java.io.OutputStream作为第二个参数。要从这样的处理函数中返回结果,你需要向OutputStream写入内容。

示例 3-5 展示了一个可以处理流的处理程序。

示例 3-5. 使用流作为处理参数
package book;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class StreamLambda {
  public void handlerStream(InputStream inputStream, OutputStream outputStream)
    throws IOException {
    int letter;
    while((letter = inputStream.read()) != -1)
    {
      outputStream.write(Character.toUpperCase(letter));
    }
  }
}

如果我们使用输入 "Hello World" 执行这个处理程序,它将将 "HELLO WORLD" 写入输出流,这将成为函数的结果。

如果你正在使用InputStream,你可能会想要使用自己的 JSON 操作代码,但我们会将这留给读者作为练习。此外,你应该保持流的良好处理习惯——错误检查、关闭等。

想要了解更多相关内容,请参阅官方文档中关于在处理函数中使用流的说明。

一种特别方便的 Lambda 函数使用场景是在开发时,当你不知道编写代码的事件结构时。示例 3-6 将事件记录到 CloudWatch Logs 中,以便你查看其内容。

示例 3-6. 记录接收到的事件到 CloudWatch Logs 中
package book;

import java.io.InputStream;
import java.io.OutputStream;

public class WhatIsMyLambdaEvent {
  public void handler(InputStream is, OutputStream os) {
    java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A");
    System.out.println(s.hasNext() ? s.next() : "No input detected");
  }
}

上下文

到目前为止,我们已经涵盖了前述列表中的签名格式 1 和 3,那么 2 和 4 是什么呢?Context对象又是什么?

在我们到目前为止的所有示例中,Lambda 处理程序函数所接收的唯一输入是发生的事件。但这并不是处理程序在处理时唯一能接收的信息。此外,您可以在任何处理程序参数列表的末尾添加一个com.amazonaws.services.lambda.runtime.Context参数,运行时将传入一个您可以使用的有趣对象。让我们看一个例子(示例 3-7)。

示例 3-7. 检查 Context 对象
package book;

import com.amazonaws.services.lambda.runtime.Context;

import java.util.HashMap;
import java.util.Map;

public class ContextLambda {
  public Map<String,Object> handler (Object input, Context context) {
    Map<String, Object> toReturn = new HashMap<>();
    toReturn.put("getMemoryLimitInMB", context.getMemoryLimitInMB() + "");
    toReturn.put("getFunctionName",context.getFunctionName());
    toReturn.put("getFunctionVersion",context.getFunctionVersion());
    toReturn.put("getInvokedFunctionArn",context.getInvokedFunctionArn());
    toReturn.put("getAwsRequestId",context.getAwsRequestId());
    toReturn.put("getLogStreamName",context.getLogStreamName());
    toReturn.put("getLogGroupName",context.getLogGroupName());
    toReturn.put("getClientContext",context.getClientContext());
    toReturn.put("getIdentity",context.getIdentity());
    toReturn.put("getRemainingTimeInMillis",
                   context.getRemainingTimeInMillis() + "");
    return toReturn;
  }
}

这是我们第一个需要使用 Java 标准库之外类型的完整示例。我们将在下一章节更详细地讨论依赖关系和打包,但现在请在您的pom.xml文件的根元素下的任何位置添加以下部分:

<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-lambda-java-core</artifactId>
    <version>1.2.0</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

当您现在运行mvn package时,它将使用 AWS 提供的核心 Lambda 库来编译您的代码,使您能够使用Context接口。

Context对象为我们提供了有关当前 Lambda 调用的信息。在 Lambda 事件处理过程中,我们可以使用这些信息。当我们调用该示例(传入任何输入事件——它不会被使用)时,我们将得到类似以下结果:

{
  "getFunctionName": "ContextLambda",
  "getLogStreamName": "2019/07/24/[$LATEST]0f1b1111111111111111111111111111",
  "getInvokedFunctionArn":
    "arn:aws:lambda:us-west-2:181111111111:function:ContextLambda",
  "getIdentity": {
    "identityId": "",
    "identityPoolId": ""
  },
  "getRemainingTimeInMillis": "2967",
  "getLogGroupName": "/aws/lambda/ContextLambda",
  "getLogger": {},
  "getFunctionVersion": "$LATEST",
  "getMemoryLimitInMB": "512",
  "getClientContext": null,
  "getAwsRequestId": "2108d0a2-a271-11e8-8e33-cdbf63de49d2"
}

所有不同的Context字段都在AWS 文档中有描述。

在特定事件处理期间,这些字段中的大多数都将保持不变,但getRemainingTimeInMillis()是一个显著的例外。它与超时相关,这是我们接下来要看的内容。

超时

Lambda 函数受可配置的超时限制。您可以在创建函数时指定此超时时间,或者稍后在函数的配置中更新它。

在撰写本文时,最大超时时间为 15 分钟。这意味着单次 Lambda 函数调用的最长运行时间为 15 分钟。这个限制是 AWS 未来可能会增加的,之前也曾增加过——长期以来,最大超时时间为 5 分钟。

到目前为止,我们的示例中没有指定超时设置,因此默认为 3 秒。这意味着如果我们的函数在 3 秒内没有完成执行,则 Lambda Java 运行时将中止它。稍后您将在一个示例中看到这个情况。

在前面的部分中,我们查看了Context对象。调用context.getRemainingTimeInMillis()将告诉您在执行期间的任何给定点剩余多少时间可以运行,然后函数将由运行时中止。后续调用将提供更新的持续时间。如果您正在编写一个相当长寿的 Lambda 并希望在超时发生之前保存任何状态,则这将非常有用。

您可能会问自己一个问题——为什么不总是将超时配置为最大的 900 秒?正如我们将在下一节中进一步探讨的那样,Lambda 的成本主要基于函数运行的时间——如果您的函数最多只能运行 10 秒,那么您不希望十亿次调用花费 90 倍的时间,因为您将被收取 90 倍的费用。

超时 包括函数实例化时的时间——换句话说,超时期间不会在函数的 冷启动 期间启动。或者更准确地说,超时仅适用于 Lambda 调用我们的 handler 方法后的时间。我们在 “冷启动” 中进一步讨论了冷启动。

15 分钟的最大超时对 Lambda 函数来说是一个重要的约束——如果您正在编写需要超过 15 分钟的功能,您需要将其拆分为多个协调的 Lambda 函数,或者根本不使用 Lambda。

理论足够了,让我们看看超时是如何发挥作用的。

示例 3-8 展示了一个 Lambda 函数,该函数将查询剩余时间,最终由于超时而失败。

示例 3-8. 使用 Context.getRemainingTimeInMillis() 查看超时
package book;

import com.amazonaws.services.lambda.runtime.Context;

public class TimeoutLambda {
  public void handler (Object input, Context context) throws InterruptedException {
    while(true) {
      Thread.sleep(100);
      System.out.println("Context.getRemainingTimeInMillis() : " +
        context.getRemainingTimeInMillis());
    }
  }
}

更新您的 template.yaml 文件,在函数的 Properties 部分添加一个名为 Timeout 的新属性。将值设置为 2——这意味着函数的超时现在是两秒。还要记得更新您的 Handler 属性。

然后按照通常步骤运行您的包和部署步骤。

如果我们在 Web 控制台中使用测试功能执行此操作,它将因“任务在 2.00 秒后超时”而失败。日志输出将如下所示:

START RequestId: 6127fe67-a406-11e8-9030-69649c02a345 Version: $LATEST
Context.getRemainingTimeInMillis() : 1857
Context.getRemainingTimeInMillis() : 1756
... Cut for brevity ...
Context.getRemainingTimeInMillis() : 252
Context.getRemainingTimeInMillis() : 152
Context.getRemainingTimeInMillis() : 51
END RequestId: 6127fe67-a406-11e8-9030-69649c02a345
REPORT RequestId: 6127fe67-a406-11e8-9030-69649c02a345	Duration: 2001.52 ms
  Billed Duration: 2000 ms 	Memory Size: 512 MB	Max Memory Used: 51 MB
2019-07-24T21:22:30.076Z 444e6ae0-9217-4cd2-8568-7585ca3fafee
  Task timed out after 2.00 seconds

这里我们可以看到 getRemainingTimeInMillis() 方法被查询,正如我们预期的那样,然后函数最终由于 Lambda 的超时而失败。

内存和 CPU

Lambda 函数没有无限量的 RAM,实际上每个函数都配置有一个 memory-size 设置。默认设置为 128MB,但对于生产环境的 Java Lambda 函数来说很少足够,因此您应该将 memory-size 视为每个函数都需要认真考虑的内容。

memory-size 可以小至 64MB,尽管对于 Java Lambda 函数,您可能应该至少使用 256MB。memory-size 必须是 64MB 的倍数。

memory-size 设置非常重要,不仅决定函数可以使用多少内存——它也指定了函数可以获取多少 CPU 力量。实际上,Lambda 函数的 CPU 力量从 64MB 线性扩展到 1792MB。因此,配置为 1024MB RAM 的 Lambda 函数比配置为 512MB RAM 的函数具有两倍的 CPU 力量。

1792MB RAM 的 Lambda 函数获得一个完整的虚拟 CPU 核心——比该设置大的 RAM 设置允许秒级虚拟核心的分数。这值得知道,如果您的代码根本没有多线程,您可能在这种情况下看不到内存设置高于 1792MB 时的 CPU 改进。

注意

我们将讨论 Lambda 执行环境如何与多个线程交互在 “Lambda and Threading”。

但为什么你应该关心这个——为什么不总是将memory-size设置为其最大值 3008MB?原因在于成本。AWS 根据两个主要因素收取 Lambda 函数的费用:

  • 函数运行时间,四舍五入到最接近的 100 毫秒

  • 函数指定使用的内存量

换句话说,给定相同的执行时间,具有 2GB RAM 的 Lambda 函数的执行成本是具有 1GB RAM 的两倍。或者,具有 512MB RAM 的 Lambda 函数的成本是具有 3008MB 的 17%。这在规模上可能会有很大的差异。

这意味着您应该尽可能使用最少的内存吗?不,那并不总是最好的选择。由于具有两倍于较小函数的内存的函数也具有两倍的 CPU 功率,因此它可能需要一半的时间来执行,这意味着成本是相同的,并且可以更快地完成工作。

调整 Lambda 函数的大小有点艺术性。我们建议您从 512MB 到 1GB 之间进行选择,然后随着函数的增大或需要扩展它们而开始调整。

环境变量

前两节都是关于 Lambda 自己的系统配置——如果您想为自己的应用程序使用配置,该怎么办?

我们可以为我们的 Lambda 函数指定 环境变量。这允许我们在相同代码的不同上下文中更改函数运行方式。例如,通过环境变量指定外部进程的连接设置或安全配置是非常典型的。

让我们试试这个。示例 3-9 显示了一个使用 Java 标准方法读取环境的函数。

示例 3-9. 使用环境变量
package book;

public class EnvVarLambda {
  public void handler(Object event) {
    String databaseUrl = System.getenv("DATABASE_URL");
    if (databaseUrl == null || databaseUrl.isEmpty())
      System.out.println("DATABASE_URL is not set");
    else
      System.out.println("DATABASE_URL is set to: " + databaseUrl);
  }
}

更新 template.yaml 文件以指向此新类并执行打包和部署过程。

如果我们运行此函数(使用我们喜欢的任何测试输入),日志输出将包括以下内容:

DATABASE_URL is not set

现在再次更新 template.yaml 文件,使 HelloWorldLambda 部分如下所示(注意你的 YAML 缩进!):

HelloWorldLambda:
  Type: AWS::Serverless::Function
  Properties:
    FunctionName: HelloWorldJava
    Runtime: java8
    MemorySize: 512
    Handler: book.EnvVarLambda::handler
    CodeUri: target/lambda.jar
    Environment:
      Variables:
        DATABASE_URL: my-database-url

打包和部署后,如果我们现在测试函数,日志输出将包括这个代替:

DATABASE_URL is set to: my-database-url

我们可以随意更新环境配置。

在使用环境变量时,通常希望存储敏感数据,例如远程服务的访问密钥。有许多安全的 Lambda 使用方式,并在亚马逊的文档中有所解释。

概要

AWS Lambda 的编程模型与您可能习惯的其他模型显着不同。

在本章中,您探讨了编写 Lambda 函数的含义——运行环境是什么,函数如何被调用,以及您可以输入和输出函数的不同方式。

然后,您了解了 Lambda 函数的一些配置方面——超时和内存——以及这些设置的含义。最后,您看到了如何通过环境变量应用自己的应用程序配置。

现在您已经知道如何编程 Lambda 函数,在下一章中,我们将研究 Lambda 操作——打包、部署、安全、监控等内容。

练习

  1. 花些时间逐步完成本章中的描述——Lambda 与您以前构建和运行 Java 应用程序的方式非常不同。

  2. 尝试使用 System.err——标准错误流——而不是 System.out 记录一些内容。日志输出与 System.out 有何不同?它是否改变了调用函数的结果,无论是异步还是同步?

  3. 故意使用无效输入调用一个函数,以查看前面描述的解析异常:An error occurred during JSON parsing。您在哪里看到这个错误?它如何影响调用函数的结果,无论是异步还是同步?

  4. 尝试构建自己的 POJO 类型,并使用它们的 JSON 版本调用 Lambda。您更喜欢JavaBean风格,还是公共字段?

  5. 尝试使用之前描述的 StreamLambda 在 Lambda web 控制台中输出整个输入事件与提供的测试事件模板对象之一。

  6. 尝试将您的一个类转换为使用静态处理器方法,而不是实例方法,以确认它是否同样有效。

第四章:运行 AWS Lambda 函数

本章将介绍一种更高级的构建和打包基于 Java 的 AWS Lambda 函数的方法。我们还将更详细地介绍面向无服务器的 AWS 基础设施即代码工具 SAM 的版本,您在第二章中首次使用过。最后,我们将讨论 Lambda 函数和无服务器应用如何受 AWS 安全模型的影响,以及如何使用 SAM 自动实施无服务器应用的最小特权安全模型。

在继续之前,我们建议您如果尚未这样做,请下载本书的代码示例

构建和打包

Lambda 平台期望所有用户提供的代码以 ZIP 归档文件的形式提供。根据您使用的运行时和实际业务逻辑,该 ZIP 文件可能包含源代码,或代码和库,或者在 Java 的情况下,已编译的字节码(类文件)和库。

在 Java 生态系统中,我们经常将代码打包成 JAR(Java ARchive)文件,通过 java -jar 命令运行,或者被其他应用程序用作库。事实证明,JAR 文件只是带有一些附加元数据的 ZIP 文件。Lambda 平台不会对 JAR 文件执行任何特殊处理——它将它们视为 ZIP 文件,就像对其他 Lambda 语言运行时一样。

使用像 Maven 这样的工具,我们可以指定代码依赖的其他库,并让 Maven 下载这些库的正确版本(以及它们可能具有的任何传递依赖关系),将我们的代码编译成 Java 类文件,并将所有内容打包到一个单独的 JAR 文件中(通常称为uberjar)。

超级 JAR

尽管在第二章和第三章中使用了超级 jar 方法,但在我们继续之前,值得指出一些它存在的问题。

首先,超级 jar 方法会在目标超级 jar 文件中解压并叠加库。在以下示例中,库 A 包含一个类文件和一个属性文件。库 B 包含一个不同的类文件和一个与库 A 的属性文件同名的属性文件。

$ jar tf LibraryA.jar
book/
book/important.properties
book/A.class

$ jar tf LibraryB.jar
book/
book/important.properties
book/B.class

如果这些 JAR 文件用于创建超级 jar(正如我们在之前的章节中所做的那样),则结果将包含两个类文件和一个属性文件——但是该属性文件来源于哪个源 JAR?

$ jar tf uberjar.jar
book/
book/important.properties # Which properties file is this?
book/A.class
book/B.class

因为 JAR 文件被解压和叠加,所以只有一个属性文件会进入最终的超级 JAR 文件,而且如果不深入了解 Maven 资源转换器的黑暗艺术,很难知道哪一个会获胜。

超级 JAR 方法的第二个主要问题与创建 JAR 文件有关——事实上,从 Maven 构建过程的角度来看,JAR 文件也是可以被 Lambda 运行时使用的 ZIP 文件的一个附属品。从这个 JAR 与 ZIP 的角度来看,有两个特定的问题。其中一个是 JAR 特定的元数据在 Lambda 运行时是无用的(实际上会被忽略)。例如,MANIFEST.MF文件中的Main-Class属性——这是 JAR 文件常见的元数据,在 Lambda 函数的上下文中是毫无意义的。

此外,JAR 文件创建过程本身会在构建过程中引入一定程度的非确定性。例如,工具版本和构建时间戳记录在MANIFEST.MFpom.properties文件中,这使得无法每次都从相同的源代码可重现地构建相同的 JAR 文件。这种不可重复性会对下游的缓存、部署和安全流程造成严重影响,因此我们希望在可能的情况下避免这种情况。

由于我们实际上并不关心超级 JAR 文件的 JAR 特性,所以考虑根本不使用超级 JAR 过程对我们来说是有意义的。当然,超级 JAR 过程本身并不一定是构建过程中唯一的非确定性源,但我们将稍后处理其余部分。

尽管存在这些缺点,对于简单情况,特别是当 Lambda 函数几乎没有(或没有)第三方依赖时,超级 JAR 过程更简单配置和使用。这在第二章和第三章的示例中就是这种情况,这也是我们在这一点上使用超级 JAR 技术的原因,但对于任何规模较大的 Java 和 Lambda 的实际用途,我们建议采用接下来我们描述的 ZIP 文件方法。

组装 ZIP 文件

因此,在 Java 世界中,我们使用 ZIP 文件作为超级 JAR 文件的替代方案。在这种情况下,归档布局会有所不同,但我们将看到如何通过谨慎的方法避免超级 JAR 的问题,并为 Lambda 平台提供一个可用的工件。我们将讨论如何使用 Maven 来实现这一点,但当然,你可以随意将这种方法翻译成你喜欢的构建工具——结果比过程本身更重要。

为了举一个更有趣的例子,首先我们将在我们的 Maven 构建中为 Lambda 函数添加对 AWS SDK for DynamoDB 的依赖,参见“Lambda Hello World (the Proper Way)”。

pom.xml文件添加一个dependencies部分:

    <dependencies>
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-java-sdk-dynamodb</artifactId>
        <version>1.11.319</version>
      </dependency>
    </dependencies>

有了这个依赖项,对于我们简单的 Lambda 函数及其依赖项,期望的 ZIP 文件布局如下:

$ zipinfo -1 target/lambda.zip
META-INF/
book/
book/HelloWorld.class
lib/
lib/aws-java-sdk-core-1.11.319.jar
lib/aws-java-sdk-dynamodb-1.11.319.jar
lib/aws-java-sdk-kms-1.11.319.jar
lib/aws-java-sdk-s3-1.11.319.jar
lib/commons-codec-1.10.jar
lib/commons-logging-1.1.3.jar
lib/httpclient-4.5.5.jar
lib/httpcore-4.4.9.jar
lib/ion-java-1.0.2.jar
lib/jackson-annotations-2.6.0.jar
lib/jackson-core-2.6.7.jar
lib/jackson-databind-2.6.7.1.jar
lib/jackson-dataformat-cbor-2.6.7.jar
lib/jmespath-java-1.11.319.jar
lib/joda-time-2.8.1.jar

除了我们的应用程序代码(book/HelloWorld.class)之外,我们还看到一个包含多个 JAR 文件的lib目录,其中包括 AWS DynamoDB SDK 的一个文件以及每个传递依赖项的文件。

我们可以使用 Maven Assembly 插件构建这个 ZIP 输出。这个插件允许我们向 Maven 构建的特定部分(在这种情况下是package阶段,在这个阶段中,Java 编译过程的结果会与其他资源一起打包成一组输出文件)添加一些特殊的行为。

首先,我们在项目的pom.xml文件中为 Maven Assembly 插件进行了配置,在build部分:

<build>
  <plugins>
    <plugin>
      <artifactId>maven-assembly-plugin</artifactId>
      <version>3.1.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals>
            <goal>single</goal>
          </goals>
        </execution>
      </executions>
      <configuration>
        <appendAssemblyId>false</appendAssemblyId>
        <descriptors>
          <descriptor>src/assembly/lambda-zip.xml</descriptor>
        </descriptors>
        <finalName>lambda</finalName>
      </configuration>
    </plugin>
  </plugins>
</build>

这个配置的两个最重要的部分是装配descriptor,它是项目中另一个 XML 文件的路径,以及finalName,它指示插件将我们的输出文件命名为lambda.zip而不是其他名称。稍后我们会看到,选择一个简单的finalName将有助于快速迭代我们的项目,特别是在我们开始使用 Maven 子模块之后。

我们 ZIP 文件的大部分配置实际上位于装配descriptor文件中,这在之前的pom.xml文件中已经引用过。这个assembly配置描述了确切要包含在输出文件中的内容:

<assembly>
  <id>lambda-zip</id> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/1.png>
  <formats>
    <format>zip</format> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/2.png>
  </formats>
  <includeBaseDirectory>false</includeBaseDirectory> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/3.png>
  <dependencySets>
    <dependencySet> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/4.png>
      <includes>
        <include>${project.groupId}:${project.artifactId}</include>
      </includes>
      <unpack>true</unpack>
      <unpackOptions>
        <excludes>
          <exclude>META-INF/MANIFEST.MF</exclude>
          <exclude>META-INF/maven/**</exclude>
        </excludes>
      </unpackOptions>
    </dependencySet>
    <dependencySet> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/5.png>
      <useProjectArtifact>false</useProjectArtifact>
      <unpack>false</unpack>
      <scope>runtime</scope>
      <outputDirectory>lib</outputDirectory> <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/6.png>
    </dependencySet>
  </dependencySets>
</assembly>

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-1

我们给这个装配取了一个唯一的名称,lambda-zip

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-2

输出格式本身将是zip类型。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-3

输出文件将不会有基本目录 — 这意味着当我们解压缩时,ZIP 文件的内容将被解压到当前目录而不是新的子目录中。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-4

第一个dependencySet部分明确包含了我们的应用代码,通过引用项目的groupIdartifactId属性。当我们开始使用 Maven 子模块时,这将需要进行修改。我们的应用代码将会被“解包”。也就是说,它不会被包含在一个 JAR 文件中;而是普通的目录结构和 Java 的.class文件。我们还明确地排除了不必要的META-INF目录。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-5

第二个dependencySet部分处理我们应用的依赖项。我们排除了项目的构件(因为它在第一个dependencySet部分已经处理过了)。我们只包括runtime范围内的依赖项。我们不会解包依赖项,而是将它们作为 JAR 文件保留。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO1-6

最后,我们不会在输出文件的根目录中包含所有的 JAR 文件,而是将它们全部放入一个lib目录中。

那么,这种复杂的新 Maven 配置如何帮助我们避免与 uberjar 相关的问题?

首先,我们剥离了一些不必要的 META-INF 信息。你会注意到我们有点选择性地做了一些处理 — 有些情况下保留 META-INF 信息(比如“services”)仍然很有价值,因此我们不希望完全摆脱它。

其次,我们已经包含了所有的依赖项,但是作为一个 lib 目录中的独立 JAR 文件。这样可以完全避免文件和路径覆盖问题。每个依赖 JAR 保持自包含。根据 AWS Lambda 的最佳实践文档,这种方法在某种程度上还带来了性能的提升,因为 Lambda 平台解压 ZIP 文件更快,JVM 从 JAR 文件加载类也更快。

可重现的构建

当我们的源代码或依赖关系发生变化时,我们期望部署包(uberjar 或 ZIP 文件)的内容也会随之变化(在运行构建和打包过程后)。然而,当我们的源代码和依赖关系不变时,即使再次执行构建和打包过程,部署包的内容也应保持不变。构建的输出应该是可重复的(例如,确定性的)。这一点很重要,因为下游过程(如部署流水线)通常根据内容的 MD5 哈希是否改变来触发,我们希望避免不必要地触发这些过程。

尽管我们使用 lambda-zip 组件描述符已经去除了自动生成的 MANIFEST.MFpom.properties 文件,但我们仍然没有消除构建过程中所有潜在的不确定性来源。例如,当我们构建我们的应用代码(例如 HelloWorld)时,编译的 Java 类文件的时间戳可能会更改。这些更改后的时间戳会传播到 ZIP 文件中,然后 ZIP 文件内容的哈希值会更改,即使源代码没有变化。

幸运的是,我们的构建过程存在一个简单的 Maven 插件,可以消除这些源中的不确定性。reproducible-build-maven-plugin 可以在构建过程中执行,并且将我们的输出 ZIP 文件完全变为确定性的。它可以配置为 pom.xml 文件中 build 部分的一个 plugin

<plugin>
  <groupId>io.github.zlika</groupId>
  <artifactId>reproducible-build-maven-plugin</artifactId>
  <version>0.10</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals>
        <goal>strip-jar</goal>
      </goals>
    </execution>
  </executions>
</plugin>

现在,当我们多次使用相同的未更改源代码重新构建部署包时,哈希值始终保持不变。您将在下一节中看到这如何影响部署过程。

部署

有许多部署 Lambda 代码的选项。然而,在我们深入探讨之前,值得澄清一下我们所说的 部署 是什么意思。在这种情况下,我们仅仅是指通过 API 或其他服务更新特定 Lambda 函数或一组 Lambda 函数及相关 AWS 资源的代码或配置。我们没有将其定义扩展到包括部署编排(如 AWS CodeDeploy)。

Lambda 代码的部署方法无特定顺序,如下所示:

  • AWS Lambda Web 控制台

  • AWS CloudFormation/Serverless Application Model(SAM)

  • AWS CLI(使用 AWS API)

  • AWS Cloud 开发工具包(CDK)

  • 其他由 AWS 开发的框架,如 Amplify 和 Chalice

  • 针对主要基于 CloudFormation 构建的无服务器组件的第三方框架,例如 Serverless Framework

  • 针对主要基于 AWS API 构建的无服务器组件的第三方工具和框架,例如 Claudia.js 和 lambda-maven-plugin(来自 Maven)

  • 像 Ansible 或 Terraform 这样的通用第三方基础设施工具

在本书中,我们将讨论前两者(事实上,在第二章和第三章中我们已经涉及了 AWS Lambda Web 控制台和 SAM)。我们还使用 AWS CLI,尽管不是作为部署工具。通过对这些方法有坚实的了解,您应该能够评估其他选项,并决定其中之一是否更适合您的环境和用例。

基础设施即代码

当我们通过 Web 控制台或 CLI 与 AWS 进行交互时,我们是手动创建、更新和销毁基础设施。例如,如果我们使用 AWS Web 控制台创建一个 Lambda 函数,下次我们想要使用相同参数创建 Lambda 函数时,我们仍然必须通过 Web 控制台执行相同的手动操作。这一特性也适用于 CLI。

对于初步开发和实验,这是一个合理的方法。但是,当我们的项目开始积累动力时,手动管理基础设施的方法将成为一种障碍。解决这个问题的一个经过良好验证的方法称为基础设施即代码

我们不必通过 Web 控制台或 CLI 手动与 AWS 交互,而是可以在 JSON 或 YAML 文件中声明性地指定我们期望的基础设施,并将该文件提交给 AWS 的基础设施即代码服务:CloudFormation。CloudFormation 服务接受我们的输入文件,并代表我们对 AWS 基础设施进行必要的更改,考虑资源依赖关系、先前部署的应用程序版本的当前状态以及各种 AWS 服务的特殊要求和特性。从 CloudFormation 模板文件创建的一组 AWS 资源称为堆栈

CloudFormation 是 AWS 的专有基础设施即代码服务,但这并不是该领域的唯一选择。与 AWS 兼容的其他热门选择包括 Terraform、Ansible 和 Chef。每个服务都有自己的配置语言和模式,但都实现了基本相同的结果——从配置文件中提供云基础设施。

使用配置文件(而不是在控制台上点点点)的一个关键好处是,这些文件代表了我们的应用基础设施,可以与我们的应用源代码一起进行版本控制。我们可以使用与应用的其他部分相同的版本控制工具,查看我们基础设施的完整变更时间线。此外,我们可以将这些配置文件纳入我们的持续部署流水线中,因此当我们对应用基础设施进行更改时,这些更改可以使用行业标准工具安全地部署,与我们的应用代码一起。

CloudFormation 和 Serverless 应用程序模型

尽管基础设施即代码方法有明显的好处,但 CloudFormation 本身以冗长、笨重和不灵活而闻名。即使是最简单的应用架构的配置文件也很容易超过数百或数千行的 JSON 或 YAML。当处理一个这样大小的现有 CloudFormation 堆栈时,不可避免地会有一种诱惑,即退回到使用 AWS Web 控制台或 CLI。

幸运的是,作为 AWS 无服务器开发人员,我们有幸能够使用称为 Serverless 应用程序模型(SAM)的 CloudFormation 的不同“口味”,我们在第二章和第三章中使用过。这本质上是 CloudFormation 的一个超集,允许我们使用一些特殊的资源类型和快捷方式来表示常见的无服务器组件和应用架构。它还包括一些特殊的 CLI 命令,以简化开发、测试和部署。

这是我们首次在“创建 Lambda 函数”中使用的 SAM 模板,已更新为使用我们的新 ZIP 部署包(请注意,CodeUri后缀已从.jar更改为.zip):

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4

Resources:
  HelloWorldLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.HelloWorld::handler
      CodeUri: target/lambda.zip

我们可以使用第二章中学到的相同 SAM 命令部署新的基于 ZIP 的 Lambda 函数:

$ sam deploy \
  --s3-bucket $CF_BUCKET \
  --stack-name chapter4-sam \
  --capabilities CAPABILITY_IAM

sam deploy首先将我们的部署包上传到 S3,但仅在该包的内容发生更改时才执行此操作。在本章的早些时候,我们花了一些时间设置可重现的构建,以便像此上传过程这样的操作不必在实际上没有发生更改时执行。

在幕后,sam deploy还创建了我们模板的修改版本(也存储在 S3 中),以引用新上传的 S3 位置的工件,而不是本地位置。这一步是必要的,因为 CloudFormation 要求模板中引用的任何工件在部署时都可在 S3 中使用。

小贴士

存储在 S3 中的s3 deploy文件应仅视为部署过程中的临时版本,而不是要保留的应用程序工件。因此,如果您的 SAM S3 存储桶没有用于其他用途,我们建议您在其中设置“生命周期策略”,以便在一段时间后自动删除部署工件——通常我们将其设置为一周。

在上传步骤之后,如果在此 AWS 账户和区域中尚不存在具有提供名称的 CloudFormation 堆栈,则sam deploy命令将创建一个新的 CloudFormation 堆栈。如果堆栈已经存在,sam deploy命令将创建一个 CloudFormation 变更集,其中列出了在执行操作之前将创建、更新或删除的资源。然后,sam deploy命令将应用变更集以更新 CloudFormation 堆栈。

列出堆栈资源时,我们可以看到 CloudFormation 不仅创建了我们的 Lambda 函数,还创建了支持的 IAM 角色和策略(稍后我们将进一步探讨),而无需显式指定它们:

$ aws cloudformation list-stack-resources --stack-name chapter4-sam
{
  "StackResourceSummaries": [
    {
      "LogicalResourceId": "HelloWorldLambda",
      "PhysicalResourceId": "chapter4-sam-HelloWorldLambda-1HP15K6524D2E",
      "ResourceType": "AWS::Lambda::Function",
      "LastUpdatedTimestamp": "2019-07-26T19:16:34.424Z",
      "ResourceStatus": "CREATE_COMPLETE",
      "DriftInformation": {
        "StackResourceDriftStatus": "NOT_CHECKED"
      }
    },
    {
      "LogicalResourceId": "HelloWorldLambdaRole",
      "PhysicalResourceId":
        "chapter4-sam-HelloWorldLambdaRole-1KV86CI9RCXY0",
      "ResourceType": "AWS::IAM::Role",
      "LastUpdatedTimestamp": "2019-07-26T19:16:30.287Z",
      "ResourceStatus": "CREATE_COMPLETE",
      "DriftInformation": {
        "StackResourceDriftStatus": "NOT_CHECKED"
      }
    }
  ]
}

除了 Lambda 函数外,SAM 还包括用于 DynamoDB 表(AWS::Serverless::SimpleTable)和 API 网关(AWS::Serverless::Api)的资源类型。这些资源类型专注于流行的使用案例,可能无法适用于所有应用程序架构。然而,由于 SAM 是 CloudFormation 的超集,我们可以在 SAM 模板中使用普通的 CloudFormation 资源类型。这意味着我们可以在架构中混合和匹配无服务器和“普通”AWS 组件,从而获得两种方法的好处,以及 SAM 的sam deploy命令的幂等 CLI 语义。您将在第五章中看到将 SAM 和 CloudFormation 资源结合在一个模板中的示例。

安全性

安全问题贯穿 AWS 的各个方面。正如您在第二章中学到的那样,我们必须从一开始就处理 AWS 的安全层,称为身份和访问管理(IAM)。然而,我们不打算简单地以最广泛、最不安全的 IAM 权限集运行所有内容来概述细节,而是在本节中稍微深入讲解 Lambda 平台如何由 IAM 控制,以及这如何影响我们的函数与其他 AWS 资源的交互,以及 SAM 如何使构建安全应用程序变得更加简单。

最小权限原则

与传统的单体应用程序不同,在无服务器应用程序中,可能会有数百个独立的 AWS 组件,每个组件具有不同的行为和访问不同的信息的能力。如果我们简单地应用最广泛的安全权限,则每个组件都可以访问 AWS 账户中的每个其他组件和信息。我们在安全策略中留下的每一个漏洞都是信息泄露、丢失、修改或应用程序行为改变的机会。而且,如果一个组件被入侵,整个 AWS 账户(以及其中部署的任何其他应用程序)都面临风险。

我们可以通过将“最小权限”原则应用于我们的安全模型来解决这种风险。简而言之,该原则指出每个应用程序,实际上是每个应用程序的组成部分都应该具有执行其功能所需的最少访问权限。例如,让我们考虑一个从 DynamoDB 表中读取数据的 Lambda 函数。最广泛的权限将允许该 Lambda 函数读取、写入或以其他方式与 AWS 账户中的每个其他组件和信息进行交互。它可以从 S3 存储桶中读取数据,创建新的 Lambda 函数,甚至启动 EC2 实例。如果 Lambda 代码存在错误或漏洞(例如,在解析用户输入时),其行为可能会被改变,并且不受其 IAM 角色的限制。

将最小权限原则应用于此特定 Lambda 函数,将会导致一个 IAM 角色,该角色只允许函数访问 DynamoDB 服务。进一步地,我们可能只允许该函数从 DynamoDB 中读取数据,并移除其写入数据或创建或删除表格的能力。在这种情况下,我们甚至可以进一步限制函数只能基于执行该函数的用户读取单个 DynamoDB 表中的哪些条目。

将最小权限原则应用到我们的 Lambda 函数上后,我们现在将其访问权限限制为仅能执行其工作所需的特定资源。如果 Lambda 函数在某种方式上被攻击或者被入侵,其安全策略仍会限制它仅能读取单个 DynamoDB 表中的特定条目。然而,最小权限原则不仅适用于防止妥协。它也是限制应用程序代码中错误“爆炸半径”的有效手段。

假设我们的 Lambda 函数存在一个 bug,例如使用错误的值来删除数据。在一个开放的安全模型中,这个 bug 可能导致 Lambda 函数删除错误用户的数据!然而,通过为我们的 Lambda 函数应用最小权限原则,我们已经限制了 bug 的“爆炸半径”,因此这个特定问题可能会导致它仅仅无作为或抛出错误。

身份与访问管理

对于成功在 AWS 上构建任何类型的应用程序来说,IAM 的工作知识至关重要。正如我们在前一节讨论的那样,在构建无服务器应用程序时,有效地应用最小权限原则更为重要。IAM 是一个复杂且多方面的服务,在这里我们不可能详尽覆盖所有内容。相反,在本节中,我们只是从构建无服务器应用程序的角度深入探讨 IAM。IAM 在无服务器应用程序中最常见和频繁地发挥作用的地方是执行角色、附加到这些角色的策略,以及附加到特定 AWS 资源的策略。

角色与策略

IAM 角色是可以被 AWS 组件(如 Lambda 函数)扮演的身份。与 IAM 用户不同,角色可以被任何需要它的人(或事物)扮演,并且角色没有长期访问凭证。基于此,我们可以定义 IAM 角色为一个可扮演的身份,并附加一组权限。

“可扮演的身份”这个短语可能让人觉得任何人或任何事物都可以扮演 IAM 角色。如果是这样的话,使用角色就不会真正提供任何好处,因为对于扮演角色或任何给定用户或组件可以承担的操作不会有任何限制。幸运的是,IAM 角色并不是任何人都可以扮演的。在构建角色时,我们必须明确指定谁(或什么)可以扮演该角色。例如,如果我们正在为 Lambda 函数构建一个角色,我们必须明确授予 Lambda 服务(在这种情况下是数据平面)扮演该角色的权限,通过指定以下“信任关系”:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

此声明指定了一个效果(Allow),适用于一个操作(sts:AssumeRole)。然而,更重要的是,它指定了一个主体,即被允许扮演该角色的身份。在这种情况下,我们允许 Lambda 服务的数据平面(lambda.amazonaws.com)扮演这个角色。如果我们尝试将此角色与不同的服务,如 EC2 或 ECS,一起使用,除非我们更改主体,否则将无法正常工作。

现在我们已经确定了谁可以承担角色,我们需要添加权限。IAM 角色本身不具备访问资源或执行操作的任何权限。此外,IAM 的默认行为是拒绝权限,除非在策略中显式允许。这些权限在策略中使用以下结构说明:

  • 一个效果(如 AllowDeny)。

  • 一组操作,通常是命名空间到特定的 AWS 服务(比如 logs:PutLogEvents)。

  • 一组资源,通常是定义特定 AWS 组件的 Amazon 资源名称(ARN)。不同的服务支持不同级别的资源特定性。例如,DynamoDB 策略可以应用到表级别。

这里是一个允许一组操作针对“logs”服务(即 CloudWatch Logs)的策略示例,并且不限制这些操作对任何特定的“logs”资源:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }
  ]
}

我们之前确定了谁可以承担角色(Lambda 服务的数据平面,由主体标识符 lambda.amazonaws.com 指定),以及角色具有的权限。然而,这个角色本身直到附加到 Lambda 函数时才会被使用,我们需要显式配置。也就是说,我们需要告诉 Lambda 服务在执行特定 Lambda 函数时使用这个角色。

Lambda 资源策略。

就像安全和 IAM 的世界还不够复杂一样,AWS 偶尔也使用应用于资源(而不是身份)的 IAM 策略来控制操作和访问。资源策略与基于身份的 IAM 策略相比反转了控制:资源策略说明了其他主体可以对所涉及的资源做什么。特别是,这对于允许不同账户中的主体访问某些资源(如 Lambda 函数或 S3 存储桶)非常有用。

Lambda 函数调用资源策略由一系列语句组成,每个语句指定了一个主体、一组操作和一组资源。这些策略由 Lambda 数据平面使用,用于确定是否允许调用者(例如主体)成功调用函数。这里是一个示例 Lambda 资源策略(也称为函数策略),允许 API 网关服务调用特定函数:

{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Sid": "Stmt001",
      "Effect": "Allow",
      "Principal": {
        "Service": "apigateway.amazonaws.com"
      },
      "Action": "lambda:invokeFunction",
      "Resource":
        "arn:aws:lambda:us-east-1:555555555555:function:MyLambda",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:execute-api:us-east-1:
 555555555555:xxx/*/GET/locations"
        }
      }
    }
  ]
}

在这个策略中,我们还添加了一个条件,更具体地限制了允许的操作来源,只允许具有 ID “xxx” 的 API 网关部署包含 “/GET/locations” 路径。条件是服务特定的,取决于调用者提供的信息。

让我们通过 API 网关调用 Lambda 函数的场景来详细讨论,使用 图 4-1。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0401.png

图 4-1. Lambda 和 IAM 安全概述。
  1. 调用者是否有权限调用 API?对于这种情况,我们假设答案是肯定的。有关更多信息,请参阅 API Gateway 文档

  2. API Gateway API 正试图调用 Lambda 函数。Lambda 服务允许这样吗?这由 Lambda 函数调用资源策略控制。

  3. 当 Lambda 执行时,函数代码应具有什么权限?这由 Lambda 执行角色控制,并且该角色通过与 Lambda 服务的信任关系来假定。

  4. Lambda 代码正在尝试将项目放入 DynamoDB 表中。它可以做到吗?这由一个权限控制,它来自附加到 Lambda 执行角色的 IAM 策略。

  5. DynamoDB 不使用资源策略,因此任何人(包括 Lambda 函数)的调用都是允许的,只要它们的角色(例如 Lambda 执行角色)允许。

SAM IAM

不幸的是,IAM 的复杂性使其在快速原型设计工作流程中的有效使用有些不协调。将无服务器应用架构加入其中,难怪如此多的 Lambda 执行角色完全开放策略,允许对 AWS 账户中的每个资源进行各种形式的访问。尽管我们很容易认同最小权限原则提供了宝贵的好处,但面对使用 IAM 为数十甚至数百个 AWS 资源实施它的任务,许多本来很有责任心的工程师选择为简单起见放弃安全性。

自动创建的执行角色和资源策略

幸运的是,Serverless Application Model 通过几种不同的方式解决了这个问题。在最简单的情况下,它将根据 SAM 基础设施模板中配置的各种函数和事件源自动创建适当的 Lambda 执行角色和函数策略。这样一来,能够执行 Lambda 函数并允许其他 AWS 服务调用它们的权限问题就能很好地解决。

例如,如果您配置了一个没有触发器的单个 Lambda 函数,SAM 将自动生成一个 Lambda 执行角色,使该函数能够写入 CloudWatch 日志。如果然后将 API Gateway 触发器添加到该 Lambda 函数中,SAM 将生成一个 Lambda 函数调用资源策略,允许 API Gateway 平台调用 Lambda 函数。这将在下一章中为我们的生活带来一些便利!

常见的策略模板

当然,如果您的 Lambda 函数需要在代码中与其他 AWS 服务交互(例如向 DynamoDB 表写入数据),它可能需要额外的权限。对于这些情况,SAM 提供了一些常见的 IAM 策略模板,允许我们简明地指定权限和资源。这些模板在 SAM 部署过程中会扩展,并成为完全指定的 IAM 策略语句。在这里,我们在 SAM 模板中添加了一个 DynamoDB 表。我们使用了一个 SAM 策略模板来允许我们的 Lambda 函数对该 DynamoDB 表执行创建、读取、更新和删除操作(也称为 CRUD 操作)。

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4

Resources:

  HelloWorldLambda:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: java8
      MemorySize: 512
      Handler: book.HelloWorld::handler
      CodeUri: target/lambda.zip
      Policies:DynamoDBCrudPolicy:
          TableName: !Ref HelloWorldTable <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/1.png>

  HelloWorldTable:
    Type: AWS::Serverless::SimpleTable

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/#co_operating_aws_lambda_functions_CO2-1

在这里,我们使用了 CloudFormation 内部函数 Ref,它允许我们使用资源的逻辑 ID(在本例中为 HelloWorldTable)作为资源的物理 ID 的占位符(例如 stack-name-HelloWorldTable-ABC123DEF)。CloudFormation 服务将在创建或更新堆栈时解析逻辑 ID 为物理 ID。

摘要

在本章中,我们介绍了以可复制、确定性方式构建和打包 Lambda 代码及其依赖项。我们开始使用 AWS 的 SAM 来以 YAML 代码指定基础设施(例如 Lambda 函数,稍后是 DynamoDB 表)——我们将在第五章中进一步探讨这一点。然后,我们探讨了影响 Lambda 函数的两种不同 IAM 构造:执行角色和资源策略。最后,使用 SAM 而不是原始 CloudFormation 意味着我们不必添加太多额外的 YAML 代码来将最小权限原则应用于 Lambda 函数的 IAM 角色和策略。

现在,我们几乎已经准备好使用 Lambda 和相关工具创建完整的应用程序的基本构建模块。在第五章中,我们将展示如何将 Lambda 函数与事件源绑定,然后构建两个示例应用程序。

练习

  1. 在本章中,通过将Handler属性设置为book.HelloWorld::foo来故意配置 Lambda 函数。当函数部署时会发生什么?当您调用函数时会发生什么?

  2. 阅读IAM 参考指南以了解哪些 AWS 服务(和操作)可以具有细粒度的 IAM 权限。

  3. 如果您想要额外的挑战,在template.yaml文件中将AWS::Serverless::Function替换为AWS::Lambda::Function。为了 CloudFormation 能够部署您的函数,您还需要进行哪些其他更改?如果遇到困难,您可以通过 CloudFormation Web 控制台查看原始堆栈的后转换模板。

第五章:构建无服务器应用程序

到目前为止,我们已经大量讨论了 Lambda 函数——如何编写程序,如何打包和部署它们,如何处理输入和输出等等。然而,Lambda 的一个重要方面,到目前为止我们还没有涉及太多,那就是 Lambda 函数很少直接从我们在不同系统中编写的代码中被调用。相反,对于 Lambda 的绝大多数用法,我们会配置一个事件源触发器,它是另一个 AWS 服务,然后让 AWS 代替我们调用我们的 Lambda 函数。

我们在“一个 Lambda 应用程序是什么样子?”中看了一些示例:

  • 为了实现 HTTP API,我们将 AWS API Gateway 配置为事件源。

  • 为了实现文件处理,我们将 S3 配置为事件源。

有许多不同的 AWS 服务直接与 Lambda 集成,甚至还有更多间接集成的服务。这意味着我们可以构建使用 Lambda 作为计算平台的无服务器应用程序,可以执行广泛范围的任务。

在本章中,我们将介绍如何将事件源与 Lambda 绑定,然后探讨如何使用这种技术构建特定类型的应用程序。在这个过程中,你将学到更多关于如何从前一章的知识构建、打包和部署基于 Lambda 的应用程序的架构知识。

如果你还没有这样做,你可能希望在尝试本章中的任何示例之前下载示例源代码

Lambda 事件源

正如你刚刚学到的那样,Lambda 的典型使用模式是将函数绑定到事件源。在本节中,我们描述了构建 Lambda 函数以与特定上游服务集成时要遵循的工作流程。

编写代码以处理事件源的输入和输出

当编写 Lambda 函数以响应特定事件源时,你通常首先要做的事情是了解 Lambda 函数将接收到的事件的格式。

我们已经使用过的 SAM CLI 工具有一个有趣的命令可以帮助我们进行这个练习——sam local generate-event。如果你运行这个命令,sam会列出它可以为其生成存根事件的所有服务,然后你可以检查并使用这些事件来驱动你的代码。例如,sam local generate-event的部分输出如下所示:

Commands:
  alexa-skills-kit
  alexa-smart-home
  apigateway
  batch
  cloudformation
  cloudfront
  cloudwatch
  codecommit
  codepipeline

假设我们有兴趣构建一个无服务器的 HTTP API。在这种情况下,我们使用 AWS API Gateway 作为我们的上游事件源。如果我们运行sam local generate-event apigateway,输出将包括以下内容:

Commands:
  authorizer  Generates an Amazon API Gateway Authorizer Event
  aws-proxy   Generates an Amazon API Gateway AWS Proxy Event

原来 API Gateway 可以以多种方式与 Lambda 集成。我们通常从列表中想要的是 aws-proxy 事件,其中 API Gateway 充当 Lambda 函数前面的代理服务器,所以让我们试试这个。

$ sam local generate-event apigateway aws-proxy

{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "foo": "bar"
  },
  ....

这个 JSON 对象是 Lambda 函数从 API Gateway 接收到的典型事件的完整示例。换句话说,当您设置 API Gateway 作为 Lambda 函数的触发器时,传递给 Lambda 函数的事件参数具有此结构。

这个示例事件并不一定帮助您理解与 API Gateway 集成的语义,但它确实给出了您的 Lambda 函数接收到的事件的结构,从而为编写代码提供了坚实的起点。您可以将此 JSON 对象作为灵感,或者更进一步,实际将其嵌入到一个测试中——详见第六章!

因为您现在知道了您的 Lambda 函数接收到的数据格式,所以可以创建一个处理此格式的处理程序签名。还记得“POJOs 和生态系统类型”吗?现在正要发挥作用了。

设置处理程序的一种选项是创建自己的 POJO 输入类型,以适合传入事件的结构,但仅创建您关心的属性字段。例如,如果您只关心 aws-proxy 事件的 pathqueryStringParameters 属性,则可以创建如下的 POJO:

package book.api;

import java.util.Map;

public class APIGatewayEvent {
  public String path;
  public Map<String, String> queryStringParameters;
}

第二个选项是使用 AWS 专门为此目的提供的类型库——“AWS Lambda Java Events Library”。如果使用此库,请参阅文档,并查找 Maven Central 中的最新版本。

如果您想要使用此库来处理 aws-proxy 事件,那么您需要首先在 Maven 依赖项中包含一个库。如果尚未包含,请将 <dependencies> 部分添加到您的 pom.xml 文件的根部。否则,请将此 <dependency> 子部分添加到现有的 <dependencies> 部分中:

<dependencies>
  <dependency>
    <groupId>com.amazonaws</groupId>
    <artifactId>aws-lambda-java-events</artifactId>
    <version>2.2.6</version>
  </dependency>
</dependencies>

通过进行这些更新,我们可以使用APIGatewayProxyRequestEvent作为我们的输入 POJO。

现在我们有一个代表我们的 Lambda 函数将接收的事件的类。接下来,让我们看看如何为将成为函数响应的事件执行相同的活动。正如您从“输入、输出”中所知,这里再次涉及到 POJOs。

SAM CLI 这次帮不上忙,因此您可以查阅AWS 文档来查找有效的输出事件结构并生成自己的输出 POJO 类型,或者您可以再次使用 AWS Lambda Java Events Library。这次,如果要响应 API Gateway 代理事件,请使用 APIGatewayProxyResponseEvent 类(参见“API Gateway Proxy Events”)。

假设您想要构建自己的 POJO 类,并且只想在 HTTP 响应中返回一个 HTTP 状态码和 body。在这种情况下,您的 POJO 可能如下所示:

package book.api;

public class APIGatewayResponse {
  public final int statusCode;
  public final String body;

  public APIGatewayResponse(int statusCode, String body) {
    this.statusCode = statusCode;
    this.body = body;
  }
}

是否使用 AWS 提供的 POJO 类型或自行编码并没有一个特别明确的选择。目前,出于几个原因,我们默认使用 AWS 库:

  • 虽然过去这个库在 Lambda 平台上实际可用的内容上落后很多,但现在 AWS 在保持更新方面做得相当不错。

  • 类似地,这个库过去引入了大量的 SDK 依赖项,因此会显著增加您的 artifact 大小。现在这方面得到了很大改进,基础 JAR 文件(对包括 API Gateway 和 SNS 在内的很多事件源都足够)不到 100KB。

尽管如此,编写自己的 POJOs 是一个完全合理的方法——这意味着您部署的 artifact 将会更小,减少了代码的库依赖数量(包括传递依赖),并且增加了代码的简洁性,有助于以后的可维护性。在本章中,我们给出了这两种方法的示例。

一旦你编写好基本的 Lambda 函数,就该进行下一步了——配置事件源以便部署。

配置 Lambda 事件源

就像有多种部署和配置 Lambda 函数的方式(还记得来自“部署”的长列表吗?),配置事件源也有多种方式。然而,由于本书中我们使用 SAM 来部署代码,因此尽可能多地使用 SAM 来配置我们的事件源是有道理的。

让我们继续我们的 API Gateway 示例。在 SAM 中定义 API Gateway 事件源的最简单方法是在您的 template.yaml 中更新 Lambda 函数定义如下:

HelloAPIWorldLambda:
  Type: AWS::Serverless::Function
  Properties:
    Runtime: java8
    MemorySize: 512
    Handler: book.HelloWorldAPI::handler
    CodeUri: target/lambda.zip
    Events:
      MyApi:
        Type: Api
        Properties:
          Path: /foo
          Method: get

看看 Events 键——那里就是魔法发生的地方。在这种情况下,SAM 所做的事情包括创建一堆资源,包括一个全局可访问的 API 端点(我们在本章后面会详细讨论),但它还配置了 API Gateway 来触发您的 Lambda 函数。

SAM 可以直接配置许多不同的事件源。然而,如果它对您的需求不足够,您总是可以降低到更低级别的 CloudFormation 资源。

理解不同的事件源语义

在第一章中我们描述了 Lambda 函数可以以两种方式被调用——同步和异步,并展示了这些不同的调用类型在不同场景中的应用。

不出所料,这意味着至少有两种不同类型的事件源——像 API Gateway 这样的,同步调用 Lambda 函数并等待回复(“同步事件源”),以及异步调用 Lambda 函数并且不等待回复的其他事件源(“异步事件源”)。

在前一组的情况下,您的 Lambda 函数需要返回适当类型的响应,就像我们之前在 API 网关中所做的那样。对于后一组,您的处理函数可以具有 void 返回类型,表明您不返回响应。

实际上,可以方便地说,所有事件源都适合这两种类型中的一种,但不幸的是,有一个小复杂性 —— 还有第三种类型,即流/队列事件源,例如:

  • Kinesis 数据流

  • DynamoDB Streams

  • 简单队列服务(SQS)

在这三种情况下,我们都配置 Lambda 平台 以从上游服务中轮询事件,与其他所有事件源不同,其中我们直接从上游服务配置 Lambda 触发器以推送事件到 Lambda。

对于流/队列源的这种反向操作对 Lambda 处理程序编程模型没有影响 —— 方法签名完全相同。例如,以下是 SQS 的 Lambda 处理程序事件格式(请注意 Records 数组):

{
  "Records": [
    {
      "messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78",
      "receiptHandle": "MessageReceiptHandle",
      "body": "Hello from SQS!",
      "attributes": {
        "ApproximateReceiveCount": "1",
        "SentTimestamp": "1523232000000",
        "SenderId": "123456789012",
        "ApproximateFirstReceiveTimestamp": "1523232000001"
      },
      "messageAttributes": {},
      "md5OfBody": "7b270e59b47ff90a553787216d55d91d",
      "eventSource": "aws:sqs",
      "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue",
      "awsRegion": "us-east-1"
    }
  ]
}

表 5-1. Lambda 事件源类型

事件源类型事件源
同步

API 网关,Amazon CloudFront(Lambda@Edge),弹性负载均衡(应用程序负载均衡器),Cognito,Lex,Alexa,Kinesis 数据 Firehose

|

异步

S3,SNS,Amazon SES,CloudFormation,CloudWatch 日志,CloudWatch 事件,CodeCommit,Config

|

流/队列

Kinesis 数据流,DynamoDB Streams,简单队列服务(SQS)

|

流/队列事件源在错误处理方面也有一些不同(参见 “错误处理”)。但目前,我们已经了解了足够的关于事件源的信息,可以探索一些详细的示例。让我们深入研究我们的无服务器 HTTP API。

示例:构建无服务器 API

在第 1 章中,我们简要讨论了 Lambda 如何作为 Web API 的一部分使用。在本节中,我们将展示这是如何构建的。

行为

此应用程序允许客户端向 API 上传天气数据,然后允许其他客户端检索该数据(图 5-2)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0105.png

图 5-2. 使用 AWS Lambda 的 Web API

写入路径包括向端点 /events 发出 HTTP POST 请求,并在请求的 body 中包含以下 JSON 数据结构:

{
  "locationName":"Brooklyn, NY",
  "temperature":91,
  "timestamp":1564428897,
  "latitude": 40.70,
  "longitude": -73.99
}

读取路径包括向端点 /locations 发出 GET 请求,该端点返回我们已保存数据的每个位置的最新天气数据。此数据的格式是一个 JSON 对象列表,格式与写入路径相同。可以添加可选的查询字符串参数 limit 到 GET 请求中,以指定返回的最大记录数。

架构

我们使用 AWS API Gateway 来实现此应用程序的所有 HTTP 元素。 读路径和写路径使用两个不同的 Lambda 函数实现。 这些由 API Gateway 触发。 我们将数据存储在 DynamoDB 表中。 DynamoDB 是亚马逊的“NoSQL”数据库服务。 对于许多无服务器系统来说,它是一个很好的选择,因为:

  • 它提供与 Lambda 相同的“轻量级操作”模型——我们配置我们想要的表结构,亚马逊处理所有运行时考虑因素。

  • 它可以在全“按需”缩放模式下使用,根据实际使用情况进行上下调整,就像 Lambda 一样。

因为 DynamoDB 是一种 NoSQL 技术,它并不适合所有应用程序,但它绝对是快速入门的一种方式。

在我们这个示例中的 DynamoDB 表中,我们声明了一个名为locationName的主键,并使用“按需”容量控制。

我们将所有这些资源——一个 API 网关定义、两个 Lambda 函数和一个 DynamoDB 表——视为一个统一的“无服务器应用程序”。 我们将代码、配置和基础设施定义作为一个整体部署单元。 尽管这不是一个新的想法,但将数据库封装在服务中是微服务架构的一个相当普遍的想法。

除了添加一个有用的分组外,使用无服务器应用程序的想法还有助于解决一些人在考虑他们在组织中可能拥有的 Lambda 函数数量时的担忧——已经足够困难组织成百上千个微服务,但一家公司可能最终会拥有数千或数万个 Lambda 函数。 我们如何管理所有这些函数? 通过在无服务器应用程序内命名空间化函数,并通过标记或定位这些应用程序的部署版本来按环境/阶段进行分类,我们可以开始为混乱带来一些秩序。 无服务器应用程序的这个概念不仅仅是设计时的考虑——AWS 实际上直接支持它(参见“部署”)。

Lambda Code

注意

在本书的这一点上,我们不讨论错误检查或测试——我们为了例子的清晰性已经做过了。 别担心——这两个重要的主题稍后会在本书中讨论!

我们之前提到,当使用 Lambda 实现应用程序时,你需要做的第一件事情之一就是理解 Lambda 函数将接收的事件格式以及 Lambda 函数应返回的响应格式(如果有)。

我们之前已经检查了 API Gateway 的代理类型。 在这个天气 API 中,我们编写自己的类来进行 POJO 序列化和反序列化,而不是使用 AWS 提供的库。 例子 5-1 和 5-2 足以满足我们对两个 Lambda 函数的需求。

示例 5-1. 用于反序列化 API 请求
package book.api;

import java.util.HashMap;
import java.util.Map;

public class ApiGatewayRequest {
  public String body;
  public Map<String, String> queryStringParameters = new HashMap<>();
}
示例 5-2. 用于序列化 API 响应
package book.api;

public class ApiGatewayResponse {
  public Integer statusCode;
  public String body;

  public ApiGatewayResponse(Integer statusCode, String body) {
    this.statusCode = statusCode;
    this.body = body;
  }
}

总体上,我们并不推荐一般情况下采用这种方法——请参见前文有关是否使用 AWS POJO 类型库的讨论“编写用于事件源输入和输出的代码”——但我们希望展示两种方法的示例。本章的第二个示例使用了 AWS 库。当您使用 Lambda 构建自己的 HTTP API 的生产实现时,可以将com.amazonaws.services.lambda.runtime.events包中的APIGatewayProxyRequestEventAPIGatewayProxyResponseEvent类替换为这些 DIY 类。

现在让我们详细查看实现此应用程序所需的代码。我们从写入路径开始。

使用 WeatherEventLambda 上传天气数据

我们知道,处理上传数据的代码大致的骨架如下:

package book.api;

public class WeatherEventLambda {
  public ApiGatewayResponse handler(ApiGatewayRequest request) {
    // process request

    // send response
    return new ApiGatewayResponse(200, ..).;
  }
}

我们首先需要捕获事件的输入。Lambda 反序列化已经为我们开始了这项工作,而传递给我们函数的ApiGatewayRequest对象的结构如下:

{
  "body": "{\"locationName\":\"Brooklyn, NY\", \"temperature\":91,...",
  "queryStringParameters": {}
}

在这个 Lambda 函数中,我们并不关心queryStringParameters字段——那将在查询函数中使用——因此我们现在可以忽略它。

那个body字段有点棘手——客户端上传的 JSON 对象仍然序列化为字符串值。这是因为 Lambda 仅对 API Gateway 创建的事件进行了反序列化;它也不能反序列化天气数据的“下一层级”。

不管怎样,我们可以对body进行自己的反序列化,其中一种方法是使用Jackson 库

一旦我们反序列化了天气数据,我们就可以将其保存到数据库中。示例 5-3 展示了 Lambda 函数的完整代码——您可能还想打开chapter5-api目录中的示例代码。

示例 5-3. WeatherEventLambda 处理程序类
package book.api;

import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.document.DynamoDB;
import com.amazonaws.services.dynamodbv2.document.Item;
import com.amazonaws.services.dynamodbv2.document.Table;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;

public class WeatherEventLambda {
  private final ObjectMapper objectMapper =
      new ObjectMapper()
          .configure(
              DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
              false);
  private final DynamoDB dynamoDB = new DynamoDB(
      AmazonDynamoDBClientBuilder.defaultClient());
  private final String tableName = System.getenv("LOCATIONS_TABLE");

  public ApiGatewayResponse handler(ApiGatewayRequest request)
    throws IOException {

    final WeatherEvent weatherEvent = objectMapper.readValue(
        request.body,
        WeatherEvent.class);

    final Table table = dynamoDB.getTable(tableName);
    final Item item = new Item()
        .withPrimaryKey("locationName", weatherEvent.locationName)
        .withDouble("temperature", weatherEvent.temperature)
        .withLong("timestamp", weatherEvent.timestamp)
        .withDouble("longitude", weatherEvent.longitude)
        .withDouble("latitude", weatherEvent.latitude);
    table.putItem(item);

    return new ApiGatewayResponse(200, weatherEvent.locationName);
  }
}

首先,您可以看到我们在处理程序函数外创建了一些实例变量。我们在“扩展”中讨论了为什么要这样做,但总结一下,Lambda 平台通常会多次使用同一个 Lambda 函数实例(虽然不会同时),因此我们可以通过仅为 Lambda 函数实例的生命周期创建某些东西来优化性能。

第一个实例变量是 Jackson 的ObjectMapper,第二个是 DynamoDB SDK。第三个也是最后一个实例变量是我们想要使用的 DynamoDB 中的表名。其精确值来自我们的基础设施模板,因此我们使用环境变量来配置我们的 Lambda 函数,就像我们在“环境变量”中讨论的那样。

类的剩余部分是我们的 Lambda 处理函数。首先,您可以看到签名,与我们正在处理的事件源所期望的类型相符。不过,这里有一个小的额外声明,即我们的 Lambda 处理程序声明可能会抛出异常——这是完全有效的,我们在 “错误处理” 中进一步讨论错误处理。

处理程序的第一行对原始 HTTP 请求的 body 字段中嵌入的天气事件进行反序列化处理。WeatherEvent 在其自己的类中定义,详情见 示例 5-4。

示例 5-4. WeatherEvent 类
package book.api;

public class WeatherEvent {
  public String locationName;
  public Double temperature;
  public Long timestamp;
  public Double longitude;
  public Double latitude;

  public WeatherEvent() {
  }

  public WeatherEvent(String locationName, Double temperature,
            Long timestamp, Double longitude, Double latitude) {

    this.locationName = locationName;
    this.temperature = temperature;
    this.timestamp = timestamp;
    this.longitude = longitude;
    this.latitude = latitude;
  }
}

在这种情况下,Jackson 使用无参构造函数,并根据原始 Lambda 事件的 body 字段中的值填充对象的字段。

现在我们已经捕获了完整的天气事件,我们可以将其保存到数据库中。我们不打算在这里详细介绍如何使用 DynamoDB,但从代码中可以看出:

  • 我们使用表名的环境变量来连接到我们想要的表。

  • 我们使用 DynamoDB Java SDK 的“文档模型”将数据保存到表中,使用位置名称作为主键。

最后,我们需要返回一个响应。由于到目前为止一切正常(目前为止!),返回 HTTP 200(“OK”)响应是正确的做法,为了让客户端更清楚我们实际做了什么,我们返回保存的位置名称。

这就是我们处理 API 写路径所需的所有代码。现在让我们看看读路径。

使用 WeatherQueryLambda 读取天气数据

如您所料,WeatherQueryLambda 类似于 WeatherEventLambda,但相反。代码详见 示例 5-5。

示例 5-5. WeatherQueryLambda 处理程序类
package book.api;

import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.model.ScanRequest;
import com.amazonaws.services.dynamodbv2.model.ScanResult;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

public class WeatherQueryLambda {
  private final ObjectMapper objectMapper = new ObjectMapper();
  private final AmazonDynamoDB dynamoDB =
      AmazonDynamoDBClientBuilder.defaultClient();
  private final String tableName = System.getenv("LOCATIONS_TABLE");

  private static final String DEFAULT_LIMIT = "50";

  public ApiGatewayResponse handler(ApiGatewayRequest request)
    throws IOException {

    final String limitParam = request.queryStringParameters == null
        ? DEFAULT_LIMIT
        : request.queryStringParameters.getOrDefault(
            "limit", DEFAULT_LIMIT);
    final int limit = Integer.parseInt(limitParam);

    final ScanRequest scanRequest = new ScanRequest()
        .withTableName(tableName)
        .withLimit(limit);
    final ScanResult scanResult = dynamoDB.scan(scanRequest);

    final List<WeatherEvent> events = scanResult.getItems().stream()
        .map(item -> new WeatherEvent(
            item.get("locationName").getS(),
            Double.parseDouble(item.get("temperature").getN()),
            Long.parseLong(item.get("timestamp").getN()),
            Double.parseDouble(item.get("longitude").getN()),
            Double.parseDouble(item.get("latitude").getN())
        ))
        .collect(Collectors.toList());

    final String json = objectMapper.writeValueAsString(events);

    return new ApiGatewayResponse(200, json);
  }
}

我们看到一组类似的实例变量。DynamoDB 的变量略有不同,因为 DynamoDB SDK 的 API,但 Jackson 的变量是相同的,并且再次捕获指定表名的环境变量。

WeatherEventLambda 处理程序中,我们关注输入事件的 body 字段。这次我们关注 queryStringParameters 字段,特别是 limit 参数,如果设置了的话。如果设置了,我们就使用它。否则,默认情况下,我们从 DynamoDB 中检索的最大记录数为 50。

接下来的几个语句从 DynamoDB 中读取数据,在此之后,我们将 DynamoDB 结果转换回 WeatherEvent 对象。获取了天气事件之后,我们再次使用 Jackson 创建一个 JSON 字符串响应返回给客户端。

最后,我们发送我们的 API 响应——再次设置 200 OK 作为状态码,但这次将有用的响应放在 body 字段中。

这就是全部的代码了!即使使用 Java 的冗长,我们也有一个完整的 HTTP API,可以读取和写入数据库的值。但是,当然,定义应用程序不仅仅是我们的代码。正如我们在第四章中看到的,我们还需要构建和打包我们的代码。而且我们实际上还需要定义我们的基础设施。

接下来我们来看构建和打包。

使用 AWS SDK BOM 进行构建和打包

在第四章中,我们展示了如何使用 Maven 构建和打包 Lambda 应用程序。在这个示例中,我们将使用我们在那里描述的 ZIP 格式,所以我们需要一个pom.xml文件和一个组件描述文件。后者与我们之前看到的没有什么不同,所以我们在这里忽略它。

让我们快速看一下pom.xml文件,为了简洁起见稍微减少了一些内容:

示例 5-6. HTTP API 的部分 Maven POM 文件
<project>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-java-sdk-bom</artifactId>
        <version>1.11.600</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-core</artifactId>
      <version>1.2.0</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-java-sdk-dynamodb</artifactId>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.10.1</version>
    </dependency>
  </dependencies>

  <!-- Other sections would follow -->
</project>

我们在这里添加的一个元素是自第四章以来的<dependencyManagement>部分。在这个标签中,我们引用了一个名为aws-java-sdk-bom的依赖关系。这个有用的元素是 Maven 的一个特性,称为“材料清单”(BOM),实质上它将一组库的版本依赖项分组。我们在这里使用它是为了确保我们使用的任何 AWS Java SDK 依赖项在版本上保持同步。

在这个特定项目中,我们实际上只使用了一个 AWS Java SDK 库——aws-java-sdk-dynamodb,因此对于这个示例来说使用 BOM 不是很必要。但是许多 Lambda 应用程序使用多个 AWS SDK,因此从稳定的基础开始是很有用的。

您还可以看到我们在<dependency>部分没有定义aws-java-sdk-dynamodb的版本,因为它使用 BOM 中定义的版本。但我们仍然需要声明aws-lambda-java-core的版本,因为它不是 AWS Java SDK 的一部分,因此不在 BOM 中——您可以从其名称中看出来它没有“sdk”。您可以在这篇博客文章中了解更多关于 AWS Java SDK BOM 的信息。

在这个示例中,我们将两个不同的 Lambda 函数的代码收集到一个压缩包中。在本章后面的下一个示例中,我们展示如何将此包拆分为单独的构件。

定义了依赖项更新后,我们可以像往常一样使用mvn package来构建和打包我们的应用程序。

基础设施

我们仍然需要定义的一个元素是我们的基础设施模板。

到目前为止,在本书中我们只定义了 Lambda 资源。现在我们需要定义我们的 API Gateway 和我们的数据库。我们应该如何做?示例 5-7 展示了template.yaml

示例 5-7. HTTP API 的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter5-api

Globals:
  Function:
    Runtime: java8
    MemorySize: 512
    Timeout: 25
    Environment:
      Variables:
        LOCATIONS_TABLE: !Ref LocationsTable
  Api:
    OpenApiVersion: '3.0.1'

Resources:
  LocationsTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: locationName
        Type: String

  WeatherEventLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: target/lambda.zip
      Handler: book.api.WeatherEventLambda::handler
      Policies:DynamoDBCrudPolicy:
           TableName: !Ref LocationsTable
      Events:
        ApiEvents:
          Type: Api
          Properties:
            Path: /events
            Method: POST

  WeatherQueryLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: target/lambda.zip
      Handler: book.api.WeatherQueryLambda::handler
      Policies:DynamoDBReadPolicy:
           TableName: !Ref LocationsTable
      Events:
        ApiEvents:
          Type: Api
          Properties:
            Path: /locations
            Method: GET

让我们从头开始过一遍。

首先我们有我们的 CloudFormation 和 SAM 头文件——这与我们之前见过的没有什么不同。

接下来是一个名为Globals的新顶级部分。Globals是 SAM 的一个代码优化特性,允许我们在应用程序中定义所有相同类型资源的一些常见属性。我们在这里主要用它来定义稍后在文件中声明的两个 Lambda 函数共同的一些属性。我们已经看到了RuntimeMemorySizeTimeout,但我们在Environment键中声明LOCATIONS_TABLE的方式,使用了!Ref字符串,这是新的——我们稍后会回到这一点。请注意,并非所有函数定义的属性都适用于Globals部分,这就是为什么您在Globals中没有看到CodeUri定义的原因。

最后,在Globals部分是 API Gateway 设置的小配置,以使用 SAM 的 API 配置的最新版本。

然后我们进入模板的其余部分,其中包含Resources元素。

第一个是新的——它是AWS::Serverless::SimpleTable类型。这是 SAM 定义 DynamoDB 数据库的方式。对于简单的配置,这在我们的示例中是可以的。

请注意,我们这里所做的并不仅仅是指向一个已经存在的数据库——我们实际上声明要求 CloudFormation 为我们创建一个数据库,并在与我们的 Lambda 函数等组件相同的堆栈中进行管理。我们所做的就是指定我们希望主键字段命名为什么,AWS 将为我们管理表的一切。

我们甚至不给表一个物理名称——CloudFormation 为我们基于堆栈名称、表的逻辑名称LocationsTable以及一些随机生成的唯一性生成一个唯一名称。这一切都很好,但如果我们不知道表的名称,我们怎么能从我们的 Lambda 函数中使用它呢?

这就是我们之前看到的!Ref LocationsTable值的作用。CloudFormation 用该字符串替换 DynamoDB 表的物理名称,因此我们的 Lambda 函数具有指向正确位置的环境变量。

离开 DynamoDB 表后,我们看到了我们两个 Lambda 函数的定义。这些元素包含了我们已经涵盖过的许多概念。我们在 第四章 中看到了Policies部分——请注意,我们通过以下方式支持最小权限原则:

  • 仅允许我们的函数访问一个特定的 DynamoDB 表(见再次使用的!Ref

  • 仅为查询数据的 Lambda 函数提供只读访问(通过声明DynamoDBReadPolicy策略)

我们还在每个 Lambda 函数中看到了Events部分,我们在本章稍作介绍。正如我们当时提到的,这里发生的是 SAM 正在定义一个隐式的 API Gateway,并且将我们的 Lambda 函数与Events部分定义的PathMethod属性附加到该 Gateway。

在许多实际场景中,隐式 API Gateway 配置可能不够满足您的需求,在这种情况下,您可以定义显式的 SAM API Gateway 资源(使用AWS::Serverless::Api类型的资源),或者基础 CloudFormation API Gateway 资源类型。如果您使用这些选项中的第一个选项,您可以在 Lambda 函数的 API Event属性中添加一个 RestApiId属性,以将它们与您自定义的 API 绑定在一起。

您还可以在 CloudFormation/SAM 定义的 API Gateway 中使用 Swagger/Open API。这样,您将获得更好的文档,以及一定程度上的“无需代码”输入验证的机会——但绝对不要依赖 Swagger/API Gateway 作为完整的输入验证器。另外,有些 API Gateway 配置方面的内容只能使用 AWS 自己的OpenAPI 扩展来定义。如果需要的话,我们可以撰写一整本小书,但现在就让你去探索 AWS 文档吧!

这些都有点理论性,但幸运的是,我们已经完成了对模板的查看,所以现在是部署和测试我们的应用程序的时候了!

部署

警告

在此示例中,API 是公开可访问的。虽然这对于实验(因为完整的 API 名称不容易被发现)来说是可以的,但这不是你想永远保留的东西,因为任何人都可以读取和写入这个 API。在生产环境中,您至少希望在写入路径周围添加一些安全性,但这超出了我们将在此处涵盖的范围。

部署应用程序时,请使用与之前完全相同的 sam deploy 命令(如果需要刷新记忆,请查看“CloudFormation 和 Serverless 应用程序模型”)。唯一可能想要更改的是 stack-name,这样你就可以将其部署到一个新的堆栈(例如,ChapterFiveApi)。

一旦 SAM 和 CloudFormation 完成,您就会在 CloudFormation 部分的 AWS Web 控制台中部署一个新的堆栈。我们可以在 CloudFormation 部分看到这一点(参见图 5-3)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0503.png

图 5-3. HTTP API 的 CloudFormation 堆栈

CloudFormation 有点低级,因此 AWS 还提供了一种称为Serverless Application的视图,可以在此视图中查看此部署,就像我们之前在“架构”中设计的那样。您可以通过 Lambda 控制台的应用程序选项卡访问此视图(参见图 5-4)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0504.png

图 5-4. HTTP API 的无服务器应用程序视图

在此视图中,您可以看到 DynamoDB 表、API Gateway(在 AWS 术语中称为 RestAPI)以及我们的两个 Lambda 函数。如果您点击其中任何资源,您将被带到正确的服务控制台,并进入该资源 — 尝试点击ServerlessRestApi资源。这将带您进入 API Gateway 控制台。在左侧点击 Stages,然后点击 Prod — 您应该会看到类似于 Figure 5-5 的内容。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0505.png

Figure 5-5. HTTP API 的 API Gateway 视图

Invoke URL 值是您的 API 的公共访问 URL — 记下来,因为您一会儿会需要它。

您还可以在无服务器应用程序视图中看到资源的物理名称具有部分生成/部分随机的结构,正如我们之前讨论的那样。例如,在这种情况下,我们的 DynamoDB 表实际上被命名为 ChapterFiveApi-LocationsTable-WFRRTZNM7JTF。确实,如果我们在 Lambda 控制台中查看此应用程序的两个函数之一,我们可以看到LOCATIONS_TABLE环境变量已正确设置为此值(参见 Figure 5-6)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0506.png

Figure 5-6. HTTP API 的 API Gateway 视图

最后,让我们通过调用两个 API 路径来测试我们的部署。为此,您需要从一会儿前的 URL 获取。

首先,让我们发送一些数据。URL 的基础是来自 API Gateway 控制台的 URL,但我们附加 /events。例如,我们可以使用 curl 调用我们的 API,如下所示(请替换为您的 URL):

$ curl -d '{"locationName":"Brooklyn, NY", "temperature":91,
  "timestamp":1564428897, "latitude": 40.70, "longitude": -73.99}' \
  -H "Content-Type: application/json" \
  -X POST https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/events
Brooklyn, NY
$ curl -d '{"locationName":"Oxford, UK", "temperature":64,
  "timestamp":1564428898, "latitude": 51.75, "longitude": -1.25}' \
  -H "Content-Type: application/json" \
  -X POST https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/events
Oxford, UK

这将两个新事件保存到 DynamoDB。您可以通过从无服务器应用程序控制台点击 DynamoDB 表,然后在进入 DynamoDB 控制台后点击Items选项卡来验证这一点(参见 Figure 5-7)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0507.png

Figure 5-7. HTTP API 的 DynamoDB 表

现在我们可以使用我们应用程序的最后部分 — 从 API 读取。例如,我们可以再次使用 curl,将 /locations 添加到 API Gateway 控制台的 URL 中:

$ curl https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/locations
[{"locationName":"Oxford, UK","temperature":64.0,"timestamp":1564428898,
  "longitude":-1.25,"latitude":51.75},
  {"locationName":"Brooklyn, NY","temperature":91.0,
  "timestamp":1564428897,"longitude":-73.99,"latitude":40.7}]

正如预期的那样,这将返回我们已存储天气信息的位置列表。

恭喜!您已经构建了您的第一个完整的无服务器应用程序!虽然它只有一个简单的功能,但想象一下它具有的所有非功能能力 — 它可以自动扩展以处理大量负载,然后在不使用时自动缩减,它跨多个可用区具有容错能力,其基础设施会自动更新以包括关键安全补丁,并且除此之外,还有很多其他功能。

现在让我们看一个不同类型的应用程序,使用其他几个不同的 AWS 服务。

示例:构建无服务器数据流水线

在 第一章 中,我们列出了 Lambda 的两个用例(“Lambda 应用是什么样子?”)。第一个是我们刚刚详细描述的 HTTP API——Lambda 的同步使用示例。第二个用例是文件处理——将文件上传到 S3,然后使用 Lambda 处理该文件。

在这个示例中,我们在第二个想法的基础上构建了一个 数据流水线。数据流水线是一种模式,其中我们将多个异步阶段和数据处理分支串在一起。这是一种流行的模式,云资源的可伸缩性为批处理系统提供了实时的替代方案。

此示例的另一个重要元素是,我们将改变应用程序的构建和打包阶段,以创建每个 Lambda 函数的隔离输出工件。随着 Lambda 函数中代码的增加——无论是特定于函数的代码还是作为库导入的代码——部署和启动将变慢。分解打包工件是减轻这种问题的一种有效技术。

让我们开始吧。

行为

这个示例是我们在前一个示例中开始的另一种天气事件系统。这次,一个应用程序将一个 JSON 文件中的“天气事件”列表上传到 S3。数据流水线将处理这个文件,目前的副作用只是将事件记录到 AWS CloudWatch Logs 中(图 5-8)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0508.png

图 5-8. 数据流水线示例行为

架构

我们刚刚展示的是此应用程序的 行为 ——架构 还有一些更多的细节(图 5-9)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0509.png

图 5-9. 数据流水线示例架构

我们从一个 S3 存储桶开始这个应用程序。将文件上传到 S3,或者按 S3 的术语说是一个 对象,将会(异步地)触发一个 Lambda 函数。这个第一个函数(BulkEventsLambda)将读取天气事件的 JSON 列表,将它们分开成单个事件,并且将每个事件发布到一个 SNS 主题上。这反过来会(再次异步地)触发第二个 Lambda 函数(SingleEventLambda),这个函数将处理每个天气事件。在我们的案例中,这仅仅意味着记录事件。

显然,这种架构对于仅记录上传文件的内容来说过于复杂了!然而,这个示例的重要之处在于它提供了一个应用程序的“行走骨架”,具有完整、可部署的、多阶段数据流水线。您可以将其作为添加有趣处理逻辑的起点。

所有这些组件都被视为一个统一部署的无服务器应用程序,就像我们在 HTTP API 示例中所做的那样。

现在我们将进一步深入讨论架构的每个阶段。

S3

S3 是 AWS 中历史最悠久的服务之一,正如我们在“云的增长”中所描述的。虽然它经常在系统的应用架构中使用,但在部署和操作 AWS 应用程序时也很普遍——在本书中,我们在部署基于 Lambda 的应用程序时已多次使用了 S3。

此外,我们认为 S3 至少在 AWS 上是最早的无服务器 BaaS 产品之一。如果我们回顾第一章中“区分”无服务器的因素,我们可以看到它符合所有标准:

不需要管理长期运行的主机或应用实例

是的——当我们使用 S3 时,我们没有“文件服务器”或其他需要管理的内容。

自动按负载自动扩展和自动供应

是的,我们不需要手动配置 S3 的容量——它会自动扩展总存储空间和流量。

其费用基于精确的使用量,从零使用到高使用

是的!如果您有一个空的存储桶,您不需要支付任何费用。或者,您的费用将取决于存储的字节数量、流量量和存储类别(请参阅下一点)。

以除主机大小/数量以外的术语定义的性能能力

是的,再次确认!S3 的性能能力是您选择的存储类别——您需要多快访问数据。您希望能够更快地访问数据,您就需要支付更多费用。

具有隐式高可用性

是的。S3 在一个区域内的多个可用区之间复制数据。如果一个可用区出现问题,您仍然可以访问所有数据。

由于 S3 是无服务器的,它与 Lambda 是极好的伙伴,尤其是因为它们具有类似的扩展能力。此外,S3 通过允许 Lambda 函数在 S3 存储桶中的数据更改时触发 Lambda 函数,与 Lambda 直接集成。这种以事件驱动方式自动响应 S3 中的变化,而不是从长时间运行的传统进程中轮询 S3 查找变化,从基础设施成本的角度来看更清晰、更易于理解和更高效。

在这两个示例中使用的所有非 Lambda 服务——API 网关、DynamoDB、S3 和 SNS——都是 AWS 生态系统中的无服务器 BaaS 服务。

现在,我们不会在示例中提供将“上传客户端”到 S3,而是使用 AWS 工具来处理上传。在真实应用中,您可以选择允许您的最终用户客户端通过“签名 URL”直接上传到 S3——这是一种“纯”无服务器方法,因为您不仅不运行服务器,实际上还将行为推送到客户端,这可能是您以前在服务器端应用程序中实现的行为。

Lambda 函数

当您稍后查看 Lambda 函数的代码时,您不会遇到任何新东西,因为您已经学到了所有的知识。与第一个示例不同的唯一真正区别是,这些函数不需要返回任何值,因为它们是异步调用的。

也许你心中会有一个问题,为什么我们要将每个事件的处理分别调用到单独的 Lambda 函数中呢?这种模式我们通常称为扇出。或者说,它是“映射-减少”系统中的“映射”部分,使用 Lambda 的原因有几点。

第一个原因是引入并行性。每个 SNS 消息将触发我们的SingleEventLambda函数的新调用。对于 Lambda 函数的每次调用,如果前一次调用未完成,Lambda 平台将自动创建 Lambda 函数的新实例,并调用该实例。在我们的示例应用程序中,如果您上传一个包含一百个事件的文件,而每个事件单独需要至少几秒钟来处理,那么 Lambda 将创建一百个SingleEventLambda实例,并并行处理每个天气事件(图 5-10)。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0510.png

图 5-10. 数据管道扇出

Lambda 的这种可扩展性非常有价值,我们将在第八章进一步讨论(“扩展”](ch08.html#lambda-scaling))。

引入扇出的第二个原因是,如果每个单独事件的处理时间较长——比如几分钟。在这种情况下,处理一百个天气事件将超过 Lambda 的最大 15 分钟超时限制,但是将每个事件放入其自己的 Lambda 调用中意味着我们可能可以避免超时问题。

还有其他解决 Lambda 超时限制的方法。一种替代方法(有些危险——请参阅以下警告!)是在 Lambda 函数中使用递归调用。在第三章(“超时”](ch03.html#lambda-timeout))中,我们看到可以使用传递给 Lambda 处理程序的Context对象的getRemainingTimeInMillis()方法来跟踪函数直到超时的剩余时间。使用此值的策略是异步直接调用当前正在运行的相同 Lambda 函数,但仅使用剩余要处理的数据。

如果您的数据需要按线性顺序处理,这比“扇出”更好的选择。

警告

当递归调用 Lambda 函数时要小心,因为很容易出现无法停止的情况,可能会出现两种情况:(a) 永远不会停止,和/或 (b) 扩展函数到数百或数千个实例宽度。这两种情况都会严重影响您的 AWS 账单!由于情况 (b),我们建议在极少数情况下,递归 Lambda 调用有意义时,使用低“保留并发”配置(见“保留并发”)。

SNS

SNS 是 AWS 的消息服务之一。一方面,SNS 提供了一个简单的publish-subscribe 消息总线;另一方面,它还提供了发送SMS文本消息和类似的面向人类的消息的能力。在我们的示例中,我们只关心第一个!

SNS 是另一个无服务器服务。您需要负责请求 AWS 创建一个主题,然后 AWS 在幕后处理该主题的所有扩展和操作。

使用 SNS SDK 发布带有字符串内容的消息到主题非常简单,我们稍后会看到。SNS 还有多种订阅类型,但在这个例子中,我们(毫不意外地)只使用 Lambda 订阅类型。其工作原理是,当消息发布到主题时,该主题的所有订阅者都将收到消息。对于 Lambda 来说,Lambda 平台将接收消息,然后异步调用我们与订阅关联的 Lambda 函数。

在我们的示例中,我们希望每次上传文件中的天气事件时都会异步调用 Lambda 函数。我们本可以直接从 Lambda SDK 调用Invoke方法,直接(但异步地)从BatchEventsLambda调用SingleEventLambda,但我们选择了使用 SNS 作为中介——为什么呢?

这是因为我们希望减少两个 Lambda 函数之间的结构耦合。我们希望BatchEventsLambda知道它的责任是分割一批天气事件,但我们不一定希望它涉及接下来这些天气事件的处理。如果稍后决定改变我们的架构,使每个事件由多个消费者处理,或者可能用 AWS Step Functions 服务替代SingleEventLambda,那么BatchEventsLambda的代码就不需要改变。

最后,我们选择了 SNS,因为它在 Lambda 应用程序中简单且普遍存在。AWS 提供了许多其他的消息系统——SQS、Kinesis 和 Event Bridge 就是其中一些例子,你甚至可以使用 S3!选择哪种服务实际上取决于你的应用程序具体的需求,以及每种服务的不同能力。为应用程序选择正确的消息服务可能有些棘手,因此进行适当的研究是值得的。

Lambda 代码

我们的代码由三个类组成。

第一个与我们在第一个示例中相同的WeatherEvent,但复制到一个新的包中,原因稍后将会更加清晰。

使用 BulkEventsLambda 处理批处理

接下来的类是我们的BulkEventsLambda代码。

正如我们已经讨论过的,首先要做的是了解输入事件的格式。

如果我们运行sam local generate-event s3,我们可以看到 S3 可以生成“puts”(创建和更新)和“deletes”事件。我们关心前者,示例事件如下(为了简洁起见做了一些修剪):

{
  "Records": [
    {
      "eventSource": "aws:s3",
      "awsRegion": "us-east-1",
      "eventTime": "1970-01-01T00:00:00.000Z",
      "eventName": "ObjectCreated:Put",
      "s3": {
        "bucket": {
          "name": "example-bucket",
          "arn": "arn:aws:s3:::example-bucket"
        },
        "object": {
          "key": "test/key",
          "size": 1024
        }
      }
    }
  ]
}

首先要注意的是,事件包含一个 Records 数组。实际上,S3 只会发送一个包含正好一个元素的数组,但是如果容易这样做,为此进行防御性编码是一个好的实践。

接下来要注意的是,我们知道是哪个对象引起了这个事件——在存储桶 example-bucket 中的 test/key。重要的是要记住,尽管我们经常将其视为文件系统,但 S3 实际上不是文件系统,它是一个键值存储,其中键可以看作是文件系统中的路径。

最后要注意的是,我们并不接收上传对象的内容,我们只知道对象的 位置。在我们的示例应用程序中,我们需要内容,因此我们需要自己从 S3 加载对象。

在这个示例中,我们将使用 aws-lambda-java-events 库中的 S3Event 类作为我们的输入事件 POJO。这个类引用了 aws-java-sdk-s3 SDK 库中的其他类型,因此我们也需要在我们的库依赖中加入它。不过,从希望尽量减少库依赖的角度来看,因为我们在这个类中直接调用了 S3 SDK,所以这是可以接受的。

S3Event 对象及其字段包含了输入事件所需的一切,由于这个函数是异步的,所以没有返回类型。这意味着我们已经完成了 POJO 定义阶段,可以开始编写代码了。

我们将 Example 5-8 的 packageimport 行省略了,因为它们太多了,但如果你有兴趣看到它们,请下载本书的示例代码。

示例 5-8. BulkEventsLambda.java
public class BulkEventsLambda {
  private final ObjectMapper objectMapper =
      new ObjectMapper()
          .configure(
              DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
              false);
  private final AmazonSNS sns = AmazonSNSClientBuilder.defaultClient();
  private final AmazonS3 s3 = AmazonS3ClientBuilder.defaultClient();
  private final String snsTopic = System.getenv("FAN_OUT_TOPIC");

  public void handler(S3Event event) {
    event.getRecords().forEach(this::processS3EventRecord);
  }

  private void processS3EventRecord(
      S3EventNotification.S3EventNotificationRecord record) {

    final List<WeatherEvent> weatherEvents = readWeatherEventsFromS3(
        record.getS3().getBucket().getName(),
        record.getS3().getObject().getKey());

    weatherEvents.stream()
        .map(this::weatherEventToSnsMessage)
        .forEach(message -> sns.publish(snsTopic, message));

    System.out.println("Published " + weatherEvents.size()
              + " weather events to SNS");
  }

  private List<WeatherEvent> readWeatherEventsFromS3(String bucket, String key) {
    try {
      final S3ObjectInputStream s3is =
          s3.getObject(bucket, key).getObjectContent();
      final WeatherEvent[] weatherEvents =
          objectMapper.readValue(s3is, WeatherEvent[].class);
      s3is.close();
      return Arrays.asList(weatherEvents);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  private String weatherEventToSnsMessage(WeatherEvent weatherEvent) {
    try {
      return objectMapper.writeValueAsString(weatherEvent);
    } catch (JsonProcessingException e) {
      throw new RuntimeException(e);
    }
  }
}

处理方法循环处理 S3Event 中的每个记录。我们知道应该只有一个记录,但如果不是这样,这段代码也能保险地处理。

代码的其余部分的要求相当简单:

  1. 从 S3 中读取上传的 JSON 对象。

  2. 将 JSON 对象反序列化为 WeatherEvent 对象列表。

  3. 对于每个 WeatherEvent 对象,将其重新序列化为 JSON…

  4. …然后将其发布到 SNS。

如果您查看代码,您会看到所有这些都得到了表达。我们像在第一个示例中一样使用 Jackson 进行序列化/反序列化。我们两次使用 AWS SDK——一次从 S3 中读取 (s3.getObject()),一次发布到 SNS (sns.publish())。虽然这些是不同的 SDK,每个都需要自己的库依赖,但它们在使用上感觉与之前的 DynamoDB SDK 大致相同。

值得注意的一点是,就像第一个例子中一样,我们在创建与 AWS SDK 的连接时从未提供任何凭据:当我们在AmazonSNSClientBuilderAmazonS3ClientBuilder上调用defaultClient()时,没有用户名或密码。这是因为在 Lambda 中运行时,Java AWS SDK 默认使用我们为 Lambda 配置的 Lambda 执行角色(我们在“身份和访问管理”中讨论过)。这意味着没有密码可以从我们的源代码中泄漏!

处理单个天气事件使用 SingleEventLambda

进入我们的最后一个类。你现在应该已经掌握了,所以让我们快速过一遍!

首先是输入事件。运行 sam local generate-event sns notification 给我们以下结果,再次略作修整:

{
  "Records": [
    {
      "EventSubscriptionArn": "arn:aws:sns:us-east-1::ExampleTopic",
      "Sns": {
        "Type": "Notification",
        "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
        "TopicArn": "arn:aws:sns:us-east-1:123456789012:ExampleTopic",
        "Subject": "example subject",
        "Message": "example message",
        "Timestamp": "1970-01-01T00:00:00.000Z",
        }
      }
  ]
}

与 S3 类似,我们的输入事件由单元素记录列表Records组成。在Record内部,以及其中的Sns对象中,有许多字段。在这个例子中,我们关心的是Message,但 SNS 消息还提供了一个Subject字段。

我们再次使用 aws-lambda-java-events 库,就像我们与 BulkEventsLambda 一样,但这次我们要使用 SNSEvent 类。 SNSEvent 不需要任何其他 AWS SDK 类,因此无需向我们的 Maven 依赖中添加任何进一步的库。

同样,这是一种异步事件类型,因此没有需要担心的返回类型。

现在看代码(参见示例 5-9)!这里再次省略了packageimport语句,但如果你想看到它们,可以在书的可下载代码中找到。

示例 5-9. SingleEventLambda Handler 类
public class SingleEventLambda {
  private final ObjectMapper objectMapper =
      new ObjectMapper()
          .configure(
              DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
              false);

  public void handler(SNSEvent event) {
    event.getRecords().forEach(this::processSNSRecord);
  }

  private void processSNSRecord(SNSEvent.SNSRecord snsRecord) {
    try {
      final WeatherEvent weatherEvent = objectMapper.readValue(
          snsRecord.getSNS().getMessage(),
          WeatherEvent.class);
      System.out.println("Received weather event:");
      System.out.println(weatherEvent);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }
}

这次我们的代码更简单了:

  1. 再次对多个 SNSRecord 事件进行防御性编码(尽管应该只有一个)。

  2. 从 SNS 事件中反序列化 WeatherEvent

  3. 记录 WeatherEvent 的日志(我们将在第七章更详细地讨论日志记录)。

这次没有提及 SDK,因为输入事件包含了我们关心的所有数据。

使用多模块和隔离的构建和打包

所有代码编写完毕,现在是构建和打包我们的应用程序的时候了。

从流程角度来看,这个例子与我们之前覆盖的内容没有任何不同——我们将在运行 sam deploy 之前运行 mvn package

不过,这个例子有一个重要的结构性差异——我们为每个 Lambda 函数创建单独的 ZIP 文件构件。每个 ZIP 文件仅包括一个 Lambda 处理程序的类及其所需的库依赖关系。

虽然对于这样大小的应用程序来说做这些有些不必要,但随着你的应用程序变得更大,考虑分解构件是有价值的几个原因:

  • 冷启动时间将会缩短(我们将在“冷启动”中详细讨论冷启动)。

  • 由于每次部署只上传与更改函数相关的工件(假设使用我们在第四章中介绍的可复制构建插件),因此从本地机器部署的时间通常会减少。

  • 为了避免 Lambda 的工件大小限制,您可能需要这样做。

最后一点涉及 Lambda 中(未压缩)函数工件的 250MB 大小限制。如果您有 10 个 Lambda 函数,每个函数都有不同的依赖关系,并且它们的组合(未压缩)工件大小超过 250MB,那么您需要为每个函数分割工件,以确保可以进行部署。

那么我们该如何实现这一点呢?

一种思考方法是,我们实际上正在为我们的无服务器应用程序构建一个非常小的单库。也许你可以将它想象成一个“无服务器应用程序 MiniMono”。常规的单库包含一个仓库中的多个项目;我们的 MiniMono 将包含一个 Maven 项目中的多个 Maven 模块。尽管 Maven 有其缺点,但作为声明多个组件之间的依赖关系及其对外部库依赖的方式,它确实表现得非常好。而 IntelliJ 在解析多模块 Maven 项目方面表现得非常出色。

正确配置多模块 Maven 项目有点繁琐,因此我们将在此逐步进行。我们强烈建议您下载示例代码并在 IntelliJ 中打开它,因为这样更容易理解。

顶层项目

我们的顶层pom.xml文件将类似于示例 5-10。我们已经剪切了一些内容以清楚解释。

示例 5-10。数据管道应用程序的父项目 pom.xml
<project>
  <groupId>my.groupId</groupId>
  <artifactId>chapter5-Data-Pipeline</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <modules>
    <module>common-code</module>
    <module>bulk-events-stage</module>
    <module>single-event-stage</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-java-sdk-bom</artifactId>
        <version>1.11.600</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>com.amazonaws</groupId>
        <artifactId>aws-lambda-java-events</artifactId>
        <version>2.2.6</version>
      </dependency>
      <!-- etc -->
    </dependencies>
  </dependencyManagement>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <artifactId>maven-assembly-plugin</artifactId>
          <version>3.1.1</version>
          <executions>
            <execution>
              <id>001-make-assembly</id>
              <phase>package</phase>
              <goals>
                <goal>single</goal>
              </goals>
            </execution>
          </executions>
          <configuration>
            <appendAssemblyId>false</appendAssemblyId>
            <descriptors>
              <descriptor>src/assembly/lambda-zip.xml</descriptor>
            </descriptors>
            <finalName>lambda</finalName>
          </configuration>
        </plugin>
        <plugin>
          <groupId>io.github.zlika</groupId>
          <artifactId>reproducible-build-maven-plugin</artifactId>
          <version>0.10</version>
          <executions>
            <execution>
              <id>002-strip-jar</id>
              <phase>package</phase>
              <goals>
                <goal>strip-jar</goal>
              </goals>
            </execution>
          </executions>
          <configuration>
            <outputDirectory>${project.build.directory}</outputDirectory>
          </configuration>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

这里有几个要点:

  • 我们在顶层添加了<packaging>pom</packaging>标签——这表明这是一个多模块项目。

  • 我们在<modules>部分包含模块列表。

  • 注意,此时我们并不声明任何模块间的依赖关系。

  • 所有我们的外部依赖项(不仅仅是 AWS SDK BOM)都移到了<dependencyManagement>部分。在此声明整个项目中的所有依赖关系会让生活更轻松,并且保证依赖版本在整个项目中是统一的,但您也不必这样做。

  • 我们很快就会看到,模块将声明它们需要哪些外部依赖关系。

  • 请注意,我们仍然有我们在第一个示例中讨论过的 AWS SDK BOM。我们将构建插件定义移动到<pluginManagement>部分,以便模块可以使用它们。

  • 组装插件的配置仍然在src/assembly/lambda-zip.xml中,或者您可以使用我们在 Maven Central 为您创建的版本。

  • 这里有很多其他“Maven 魔法”的细节我们就不深入讨论了!

有了我们的顶层项目,现在我们可以创建我们的模块了。

这些模块

我们为每个模块创建一个子目录,其名称与项目 pom.xml 中模块列表的各元素相同。

在每个模块子目录中,我们创建一个新的 pom.xml。我们从 common-code 开始,这让我们可以编写被 Lambda 构件共享的代码。在我们的示例中,它包含 WeatherEvent 类。

再次强调,所有这些 Maven 示例都稍作裁剪,请查看书籍源代码获取完整版本。

示例 5-11. common-code 的模块 pom.xml
<project>
  <parent>
    <groupId>my.groupId</groupId>
    <artifactId>chapter5-Data-Pipeline</artifactId>
    <version>1.0-SNAPSHOT</version>
  </parent>

  <artifactId>common-code</artifactId>

  <build>
    <plugins>
      <plugin>
        <artifactId>reproducible-build-maven-plugin</artifactId>
        <groupId>io.github.zlika</groupId>
      </plugin>
    </plugins>
  </build>
</project>

我们声明我们的父级,我们模块的 artifactId(为了明智起见,应与模块名称相同),然后我们声明要使用的构建插件。对于这个模块,我们只创建一个常规的 JAR 文件,只包含模块本身的代码。这意味着我们不需要组装 ZIP 文件,但我们仍然希望利用可重复生成的构建插件。插件的配置来自父 bom 中 <pluginManagement> 部分的定义。

注意,由于此模块目前没有任何依赖项,因此没有 <dependencies> 部分。

接下来,在 bulk-events-stage 子目录中,我们按照 示例 5-12 中所示创建 pom.xml

示例 5-12. bulk-events-stage 的模块 pom.xml
<project>
  <parent>
    <groupId>my.groupId</groupId>
    <artifactId>chapter5-Data-Pipeline</artifactId>
    <version>1.0-SNAPSHOT</version>
  </parent>

  <artifactId>bulk-events-stage</artifactId>

  <dependencies>
    <dependency>
      <groupId>my.groupId</groupId>
      <artifactId>common-code</artifactId>
      <version>${project.parent.version}</version>
    </dependency>
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-events</artifactId>
    </dependency>
    <!-- etc. -->
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-assembly-plugin</artifactId>
      </plugin>
      <plugin>
        <artifactId>reproducible-build-maven-plugin</artifactId>
        <groupId>io.github.zlika</groupId>
      </plugin>
    </plugins>
  </build>
</project>

<parent> 部分与 common-code 相同,<artifactId> 遵循之前的规则。

这次我们确实有依赖项。第一个是我们如何声明一个模块间的依赖,本例中是对 common-code 模块的依赖。请注意,我们从父模块中获取版本。然后我们声明所有外部依赖项。请注意,这些依赖项没有版本号—版本号来自父 pom.xml 中的 <dependency-management> 部分(或者从 AWS SDK BOM 中传递获取)。

最后,在 <build> 部分中,我们声明我们的构建插件。这次我们需要创建一个 ZIP 文件(这将是仅用于 BulkEventsLambda 函数的 ZIP 文件),因此我们包含对 maven-assembly-plugin 的引用。再次强调,插件的配置在父 pom.xml 中定义。

single-event-stage pom.xml 看起来几乎与 bulk-events-stage pom.xml 相同,但依赖项较少。

Maven POM 文件完成后,我们在每个模块中创建 src 目录。项目目录树的最终结果如下所示:

.
+--> bulk-events-stage
|    +--> src/main/java/book/pipeline/bulk
|    |                                +--> BulkEventsLambda.java
|    +--> pom.xml
+--> common-code
|    +--> src/main/java/book/pipeline/common
|    |                                +--> WeatherEvent.java
|    +--> pom.xml
+--> single-event-stage
|    +--> src/main/java/book/pipeline/single
|    |                                +--> SingleEventLambda.java
|    +--> pom.xml
+--> src/assembly
|        +--> lambda-zip.xml
+--> pom.xml
+--> template.yaml

运行 mvn package 以创建此多模块项目中每个 Lambda 模块目录中的单独 lambda.zip 文件。

由于我们有互不依赖的并行模块,实际上我们可以微调 Maven 的使用以增加构建性能。运行 mvn package -T 1C 将使 Maven 在可以时使用多个操作系统线程,每个核心一个。

基础设施

尽管我们的 Java 项目结构发生了显著变化,但我们的 SAM 模板并没有变化很多。让我们看看它是如何变化的,以及我们在示例 5-13 中使用的其他 AWS 资源。

示例 5-13. 数据流水线的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter5-data-pipeline

Globals:
  Function:
    Runtime: java8
    MemorySize: 512
    Timeout: 10

Resources:
  PipelineStartBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start

  FanOutTopic:
    Type: AWS::SNS::Topic

  BulkEventsLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: bulk-events-stage/target/lambda.zip
      Handler: book.pipeline.bulk.BulkEventsLambda::handler
      Environment:
        Variables:
          FAN_OUT_TOPIC: !Ref FanOutTopic
      Policies:S3ReadPolicy:
           BucketName: !Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start
       — SNSPublishMessagePolicy:
           TopicName: !GetAtt FanOutTopic.TopicName
      Events:
        S3Event:
          Type: S3
          Properties:
            Bucket: !Ref PipelineStartBucket
            Events: s3:ObjectCreated:

  SingleEventLambda:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: single-event-stage/target/lambda.zip
      Handler: book.pipeline.single.SingleEventLambda::handler
      Events:
        SnsEvent:
          Type: SNS
          Properties:
            Topic: !Ref FanOutTopic

首先,在我们的记忆中仍然清晰时,让我们看看多模块 Maven 项目引起的差异。唯一的更新是 Lambda 函数的CodeUri属性——在 API 示例中,我们曾经对两个函数都使用相同的target/lambda.zip值,现在对于BulkEventsLambdabulk-events-stage/target/lambda.zip,对于SingleEventLambdasingle-event-stage/target/lambda.zip

好了,现在让我们回到顶部。

Globals部分这次稍微小了些。这是因为 Lambda 函数之间没有共享的环境变量,而且我们也不需要任何 API 配置。

Resources下,首先声明了我们的 S3 存储桶。您可以在这里添加很多属性——与访问控制相关的属性尤其受欢迎。我们通常喜欢添加的一件事是服务器端加密以及生命周期策略。但在这里,我们保持默认设置。这里有一件事是显式声明的名称。通常情况下,我们不希望这样做,而是让 CloudFormation 为我们生成一个唯一的名称,但由于 CloudFormation 的 S3 资源的一个恼人的特性,如果我们不声明一个名称,那么我们将与文件的一些其他元素产生循环依赖。

S3 存储桶名称在所有 AWS 区域和账户中必须是全局唯一的。如果您在 us-east-1 区域创建一个名为sheep的存储桶,那么您不能在 us-west-2 中再创建另一个名为sheep的存储桶(除非您首先删除 us-east-1 中的存储桶),并且我根本无法创建名为“sheep”的存储桶。这意味着当您通过像 CloudFormation 这样的自动化工具显式创建存储桶名称时,您需要包含各种上下文唯一的方面,以避免命名冲突。

例如,我们使用以下声明的存储桶名称:

!Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start

这里涉及一些 CloudFormation 的智能操作,所以让我们来详细解析一下。

首先,!Sub是另一个内部函数,就像第一个示例中的!Ref一样。!Sub用于替换字符串中的变量。通常您会使用模板参数中声明的变量,但在这种情况下,我们使用 CloudFormation 的伪参数——由 CloudFormation 代表我们定义的变量。假设我创建了一个名为my-stack的堆栈,我们的账户 ID 是 123456,并且我们在 us-west-2 中创建了该堆栈,那么该堆栈中的存储桶名称将是my-stack-123456-us-west-2-start

下一个资源是我们的 SNS 主题。看——没有属性!SNS 部分可配置,但也可以完全不配置就使用。

然后我们有我们的两个 Lambda 函数。

BulkEventsLambda 具有一个环境变量,引用了 SNS 主题的 Amazon 资源名称 (ARN)。SNS 主题 CloudFormation 文档 告诉我们,在 Topic 资源上调用 !Ref 返回其 ARN。

对于这个 Lambda 函数的安全性,我们需要从 S3 存储桶中读取数据(我们在首次声明存储桶时使用相同的名称),并且我们需要写入(或发布)到 SNS 主题。对于 SNS 主题,安全策略不需要 ARN(这是当我们在主题资源上调用 !Ref 时返回的内容),它需要主题的名称。为了获得该名称,我们使用第三个内置函数 !GetAtt!GetAtt 允许我们从 CloudFormation 资源中读取次要返回值。同样地,在查看 SNS 文档时,我们可以看到在请求 TopicName 时返回的名称,因此值为 !GetAtt FanOutTopic.TopicName

最后,对于 BulkEventsLambda,我们需要声明事件源。这是 S3 存储桶,并且我们在 Events 字段中声明我们关心的 S3 事件类型。如果您愿意,您可以在这里进行更详细的描述,例如包括过滤模式,以仅触发特定 S3 键的事件。

正如您所预期的那样,SingleEventLambda 更简单,因为它不调用任何 AWS 资源。对于这个函数,我们只需要声明事件源,即 SNS 主题,它通过主题的 ARN 引用。

部署

部署类似于您之前看到的内容。再次,我们使用无服务器应用程序的原则,将所有组件集体部署。

部署此应用程序有一个小的更改。因为我们在手动定义的 S3 存储桶名称中使用了堆栈名称,所以必须仅使用小写字母(因为 S3 存储桶不能以大写字母命名):

$ sam deploy \
  --s3-bucket $CF_BUCKET \
  --stack-name chapter-five-data-pipeline \
  --capabilities CAPABILITY_IAM

应用程序部署后,您可以通过 Lambda 应用程序控制台或 CloudFormation 控制台探索已部署的组件。图 5-11 展示了 Lambda 应用程序中的外观。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/prog-aws-lmd/img/awsl_0511.png

图 5-11. 数据管道的无服务器应用程序视图

单击资源将带您进入 AWS 控制台的各自部分。要测试此应用程序,我们需要将文件上传到 S3。其中一种选项是通过 Web 控制台手动执行此操作。

一个更加自动化的方法如下所示。

首先,查询 CloudFormation 获取 S3 存储桶的名称,并将其分配给一个 shell 变量:

$ PIPELINE_BUCKET="$(aws cloudformation describe-stack-resource \
  --stack-name chapter-five-data-pipeline \
  --logical-resource-id PipelineStartBucket \
  --query 'StackResourceDetail.PhysicalResourceId' \
  --output text)"

现在使用 AWS CLI 来上传示例文件:

$ aws s3 cp sampledata.json s3://${PIPELINE_BUCKET}/sampledata.json

现在查看 SingleEventLambda 函数的日志,您会看到,几秒钟后,每个天气事件都将分别记录。

恭喜!您已经构建了第二个无服务器应用程序!

想象一下,通过 AWS 提供的大量服务,可以构建无数种不同类型的无服务器应用程序。而且,这还没有考虑到从 Lambda 调用 AWS 之外的服务的完全有效能力!

我们希望本章为您展示了可能性。仅凭几个文本文件,几分钟或几秒钟就能部署完整的、多组件的应用程序,然后再将其拆除,这构建了一个非常有价值的“应用程序沙盒”环境,也能扩展到真正的生产使用。

摘要

我们从学习如何从其他 AWS 服务触发 Lambda 函数开始本章。理解这一点是接受无服务器架构的重要第一步。

接着,我们探讨了两个示例无服务器应用程序——完全包含的 AWS 资源组。第一个例子是一个基于数据库的 HTTP API,使用了两个同步调用的 Lambda 函数,以及 AWS 服务 API Gateway 和 DynamoDB。

第二个例子是一个由两个异步处理阶段组成的无服务器数据流水线,包括扇出设计。这个例子使用了 Lambda、S3 和 SNS。在这个例子中,我们还探讨了使用多模块 Maven 项目创建“无服务器应用 MiniMono”的方法。

您现在已经掌握了构建无服务器 AWS 应用程序的框架:

  1. 确定您希望您的应用程序具有的行为

  2. 通过选择哪些服务来实现系统的不同方面,并定义这些服务之间的交互,设计您应用程序的架构

  3. 编写Lambda 代码来:

    • 处理正确的事件类型。

    • 在下游服务上执行必要的副作用。

    • 在相关情况下,返回正确的响应。

  4. 使用 CloudFormation/SAM 模板配置您的基础设施

  5. 使用正确的 AWS 工具进行部署

到目前为止,我们所有的测试都是非常手动的。我们如何利用自动化测试技术来做得更好?这是我们在下一章中要探讨的内容。

练习

  1. 另一个很好的 Lambda“入门”事件源是 CloudWatch 定时事件,我们可以使用它来构建“无服务器定时任务”。我们在“示例:Lambda‘定时任务’”中描述了 Lambda 的这种使用方式。建立一个 Lambda 函数,每分钟运行一次,并且暂时只在调用时写出一个日志声明。请参阅SAM 文档了解如何设置此触发器。

  2. 更新上一个练习中的定时事件 Lambda,以将消息发布到 SNS,类似于本章前面所做的BulkEventsLambda。更新您的 SNS 主题,以向您的手机发送 SMS 或文本消息(请参阅AWS 文档以了解如何操作)。

  3. 重新实现本章的数据管道示例,使用 SQS 队列而不是 SNS 主题,在两个 Lambda 之间传递消息。关于此,可以参考 Lambda 文档中的 这里这里

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值