<返回更多

“神奇”的 AWS 无服务器开发体验

2022-04-27    InfoQ
加入收藏
“神奇”的 AWS 无服务器开发体验

 

当开发者想要开发无服务器和云原生应用时,一个常见的问题是:这方面的开发体验到底怎么样呢?这个问题很很重要,因为良好的开发体验和快捷的反馈通道会让开发者更开心、更有生产力,从而能够快速交付特性。

 

由于我们在建立 Plain 时有意缩小规模,所以我们必须有出色的开发体验。我们需要确保公司聘请的少数几位工程师能够在保持高质量的同时快速交付产品特性,产生最大的影响力。

 

2021 年我们有了时间去思考这个问题的解决方案,因为 Plain 是从头开始建立的。在我们选择常用的技术栈时,一方面要考虑到公司每天都会做变更,另一方面我们希望基础平台能够支持未来 5-10 年的业务成长与成就。这意味着平台应该能以较低的成本大规模运行我们的服务,而不需要专门划分出一个部门来管理自主研发的基础设施。

 

作出这些决策背后的理由肯定需要单独写文章来谈了,不过我们最终决定完全投入无服务器和云原生、全栈 TypeScript,并使用 AWS 作为我们的云供应商,因为它足够成熟也非常流行。我们认为,使用 AWS 的专有服务是一种可以接受的供应商锁定权衡,因为相比更换云供应商的自由度来说,从这一决策中获得的价值是更高的。我确实看到过一些公司花了大量精力去尝试做跨云(cloud-agnostic,云不可知),但实际上并没有从中得到任何现实收益。

无服务器开发的独特之处

 

无服务器应用程序的开发和测试工作有一些独特的要素。与传统开发相比的一大区别在于,你最终会使用大量云服务,并会尽量把责任卸载给无服务器解决方案。

 

就 AWS Lambda 而言,这意味着你最后往往会使用 API Gateway、DynamoDB、SQS、SNS、S3、EventBridge、ElastiCache 等来构建你的应用。使用这么多服务需要开发、测试和部署大量配置、权限和基础设施。如果你只关心你的 lambda 代码的测试工作,那么就会略过很大一部分特性。如果你不验证你的基础设施,可能会遇到以下情况:

 

 

你要回答的一个最重要的问题是:你想要什么时候发现这些错误

 

  1. 在编写和运行你的测试时?
  2. 在进行特性开发和开发人员手动尝试他们的特性时?
  3. 在你通过一些 E2E 集成测试套件运行的持续集成中?
  4. 在一个共享的部署环境中,如开发或暂存?
  5. 或者在最坏的情况下:在生产环境中?

 

我们的选择是越早越好:在编写和运行测试时。这意味着,“你应该 mock 云的依赖项还是拥抱云“这个争论其实并不是一个问题。如果让我们的 Lambda 使用 AWS mock 或一些 localhost 仿真,在部署时还是很难做到无条件正常运行。Gareth McCumskey 的“为什么无服务器的本地部署是一种反模式”这篇博文为“模拟还是上云“的争论给出了很好的答案,我强烈推荐大家阅读。

 

云端开发带来的最大影响是需要互联网接入来编写代码。虽然这对某些公司或人们来说可能是一个不可接受的权衡,但对我们这家远程优先的公司来说,我们本来就需要互联网接入来与同事沟通,因此很少会出现无法接入网络的情况。

 

本着我们希望进行云端开发,而们开始评估各种工具和技术,以找出适合我们的方法。

神奇的堆栈

 

那么,我们神奇的 AWS 无服务器开发体验是什么样子的呢?从高层来看,以下内容构成了开发体验的关键部分:

 

 

采用这些技术和实践产生了相当出色的开发体验。

个人 AWS 账户

 

完全转向无服务器后,每位开发人员就必须有自己的个人沙盒 AWS 账户。如前所述,构建大多数特性时,仅仅编写代码是不够的,还有大量的基础设施需要开发、修改和测试。拥有个人的 AWS 账户让每位开发人员都可以进行实验和开发工作,而不会影响其他工程师或像开发或暂存这样的共享环境。再结合我们强大的基础设施即代码,每个人都可以拥有一份生产环境的个人克隆版本。

 

你可能会想:这不是很贵吗?我们是不是要向 AWS 支付数百美元?不,无服务器解决方案不是这样的!真正的无服务器解决方案都是按使用量付费的,所以如果你的 AWS 账户没有任何活动(例如在工程师不工作的晚上和周末),那么你就不会支付一分钱。这方面有一些例外,如 S3 存储、DynamoDB 存储、RDS 存储、Route53 托管区等费用,但它们一般没几个钱。

 

例如,Plain 公司 1 月份为我们的 7 个开发者账户支付的账单总计 150 美元,而我们每个人都有自己的生产环境克隆,因此开发速度大幅提升,相比之下这点费用真不算什么。通常情况下,每位开发人员涉及的最大成本是我们的关系数据库:Amazon Aurora Serverless v1 PostgreSQL。在开发过程中,当它收到请求时会自动扩展,并在无活动 30 分钟后降至零。

“神奇”的 AWS 无服务器开发体验

 

每个开发者账户的 AWS 用量总账单。

“神奇”的 AWS 无服务器开发体验

 

我的 AWS 账户的使用量明细

(注意:CloudWatch 的高额费用是由于在 1 月份评估了可观察性工具和平台)

AWS CDK 和 SST

 

由于我们的所有特性都相当依赖云资源,因此将我们的基础设施定义为代码和版本控制是一个硬性要求。我们最初研究了 Terraform、Pulumi、Serverless Framework、AWS SAM 等工具,但它们要么要求我们学习新的编程或模板语言,要么开发者对整个特性生命周期的体验达不到我们的期望。

 

2021 年 3 月,我们偶然发现了Serverless Stack(SST),当时它还是 0.9.11 版本。他们的实时 lambda 重载特性和建立在 AWS CDK 上的特点一下子就吸引了我们。SST 和 AWS CDK 原生支持 TypeScript,所以它很好地满足了我们对 TypeScript 全栈的渴望。

 

实时lambda开发允许我们编写 Lambda 代码,并使用实时的 AWS 服务运行我们的集成测试,反馈循环只需 2-3 秒。SST 将你的 lambda 替换成一个垫片,通过 Websockets 将所有 Lambda 调用代理到你的本地开发者机器上,它可以调用其他 AWS 服务并返回响应。本地运行时使用 AWS Lambda 执行角色的权限,并对真正的服务调用 AWS API,所以当变更部署到生产环境时我们会很有信心它能正常运行。总的来说,这意味着与 mocking 或仿真相比,我们能极快地发现基础设施问题。

“神奇”的 AWS 无服务器开发体验

 

实时 lambda 开发架构概述。(来源:docs.serverless-stack.com)

 

这种设置的好处是我们可以轻松做到真正的全栈开发。我们可以将 React 前端应用指向个人 AWS 账户部署的 API Gateway URL,并同时改变前端和后端,两个代码库都可以实时重载。鉴于一切部署都在使用与生产环境相同的 AWS 服务,我们的前端应用程序不需要调整就能完全正常工作。

 

虽然选择在一个(当时)相对未知的工具上建立我们的后端堆栈是有一点风险的,但我们知道我们有 AWS CDK 这个逃生舱口。如果我们遇到 SST 不支持或我们不喜欢的东西,还可以使用非常成熟的 AWS CDK 构造。这使我们在 SST 的奇妙开发体验与 AWS CDK 的成熟度、特性丰富度和第一方支持之间取得了最佳平衡。

 

Serverless Stack 也有一些非常棒的特性,比如说:

 

 

每当我们遇到问题、有疑问或特性请求时,SST 的Slack社区都能提供很大帮助。Frank、Jay、Dax和社区总是很乐意帮助我们。我强烈建议大家尝试一下 SST,因为很难找到如此好用的东西了。

测试

 

一开始我们就有一个野心,就是对我们的测试套件能有充分的信心。如果我们的 CI 是绿色的,那么应该就可以安全地将该变更部署到生产环境中——这正是我们在合并到主分支时所做的事情。为了实现这一目标,我们决定将测试工作集中在一个强大的集成测试套件上,而不是对单个 lambda 函数或小代码块分别进行单元测试。这似乎是不好的实践,或者是违背了传统的测试金字塔原则。但当我们遇到像无服务器这样的阶梯式创新时,有必要对现有的实践提出质疑,看看这些实践是否仍那么有意义。

 

要明确的是:我们确实会在有意义的地方写单元测试。如果我们有一些业务逻辑或计算,那么就会写一个详尽的单元测试套件。一个例子是我们的核心客户状态机对所有可能的状态和状态转换都有单元测试。但是像 SQL 查询、AWS API 调用或我们的 GraphQL 请求这样的单元测试是绝对不可能写的,因为它不会带来什么实际的保证。你最终要测试的是大量的实现细节,而维护高质量的 mock 或仿真需要投入很大资源,并不值得。

 

拿数字说话,我们目前的测试套件比例是 30%单元测试和 70%集成测试用例。

“神奇”的 AWS 无服务器开发体验

 

我们的集成测试是以一种合理的方式设计和编写的,它们速度够快,主要测试行为而非实现。这意味着我们尽量避免断言内部实现细节,例如 DynamoDB 或 RDS 中存储的数据。相反,我们专注于验证外部(从 Lambda 的角度)可见的行为,如 API 响应或正在发布的事件。对于我们的事件,我们的原则是只测试一个已经发布的事件,而不是断言所有下游消费者。我们为每个消费者编写单独的集成测试。这也要求我们在代码中保持合理的领域边界,以确保每个领域都可以独立测试。

“神奇”的 AWS 无服务器开发体验

 

集成测试的边界

 

这种编写测试的方式也有一个好处,就是能够针对共享环境运行。我们目前有一个完整的集成测试套件,在部署后合并到主环境时针对我们的开发环境运行,并按计划检测 flaky 测试。没有什么能阻止我们在生产环境中也运行这些完全一样的测试。理论上,我们可以删除 100%的代码,用 Delphi 重写所有的 Lambda,只要我们的集成测试套件通过就可以把它发布到生产环境。(注意:我们还没有尝试过这件事,也不打算在短时间内这样做)。

 

一个典型的 GraphQL API 查询或突变的集成测试大致上会做以下工作:

 

  1. 从认证的用户池中请求一个用户(我们遇到了一些配额和身份提供者的限制)
  2. 创建一个新的工作区,以便有一个干净的状态
  3. 设置测试的状态,如创建一个客户、发送一个聊天信息等
  4. 进行 GraphQL 查询
  5. 断言 GraphQL 响应
  6. 在突变的情况下:断言任何应该被发布的事件

 

describe('create issue mutation', () => {
  it('should create an issue', async () => {
    // Given: workspace + customer + issue type
    const testWorkspace = await testData.newWorkspace();
    const ctx = await testData.testAggregateContext({ testWorkspace });
    const issueType = await issueAggregate.createIssueType(ctx, {
      publicName: 'Run of the mill issues',
    });
    const customer = await customerAggregate.createCustomer(ctx, factories.newCustomer());
    // When we make GraphQL Mutation
    const res = await testWorkspace.owner.graphqlClient.request(CREATE_ISSUE_GQL_MUTATION, {
      input: { issueTypeId: issueType.id, customerId: customer.id },
    });
    // Then:
    // 1. Expect a successful response:
    expect(res).toStrictEqual({
      createIssue: {
        issue: {
          id: jestExpecters.isId('i'),
          issueType: { id: issueType.id },
          customer: { id: customer.id },
          status: IssueStatus.Open,
          issueKey: 'I-1',
        },
        error: null,
      },
    });
    // 2. Expect an event to be published:
    await testEvents.expectEvents(testWorkspace, [
      jestExpecters.standardEventStructure({
        actor: testWorkspace.owner,
        payload: {
          eventType: 'domain.issue.issue_created',
          version: 1,
          issue: res.createIssue.issue,
        },
      }),
    ]);
  });
});

复制代码

 

一个典型的 EventBridge 事件监听器集成测试会:

 

  1. 设置任何所需的状态(这在很大程度上取决于具体的 Lambda)。
  2. 在总线上发布一个 EventBridge 事件
  3. 等待并期待副作用的出现,这可能是:

另一个 EventBridge 事件被发布

数据存储中的状态被更新(如 DynamoDB、RDS、S3)

 

如果你曾写过任何集成测试,一定会在脑子里大喊:运行这些东西一定很慢!它们肯定比单元测试慢,但也不是慢得让人无法忍受。由于我们使用的所有服务都是无服务器的,而且我们确保集成测试有 0 个共享状态,所以我们有能力并行运行所有的测试。我们还没有达到这样的优化程度,但举例来说,我们的 CI 并行度为 40,在 2 分钟内就能在 110 个测试套件中运行 656 个测试用例,对我们应用的每个角落进行详尽的集成测试。

“神奇”的 AWS 无服务器开发体验

 

来自我们 CI 的集成测试套件结果

 

集成测试的不稳定性是我们积极解决的另一个问题,为此我们在工作周内按计划运行测试。一旦遇到测试失败,我们就会跳出来,追踪问题的根源。这也需要我们重新思考,并把某些东西(如 GraphQL 订阅)的测试调整成一种稳健和可靠的方式。

 

我们才刚刚开始研究我们的集成测试设置,这个话题绝对值得另起一篇文章。也就是说,鉴于我们的 API 是产品的一个关键部分,对每一个 GraphQL 查询和突变的整合进行测试是至关重要的。我们认为,就算测试套件稍慢一些,但对特性或变更能正确运行有更高的信心就足够值得了。

全栈 TypeScript

 

虽然使用全栈 TypeScript 并不是在 AWS 上拥有良好开发体验的严格必要条件,但它确实让我们的团队获得了更高的效率。无需学习新的语言就能在前端、后端和基础设施代码之间来回切换,这对团队的每位成员来说都是非常宝贵的体验。

 

在开发后端代码时你仍然需要学习 AWS 服务,但这在使用任何东西时都是很自然的需求。你同样需要了解 css/html 来开发前端 Web 应用。有了 TypeScript 中的 SST 和 CDK,在你弄清楚自己想使用哪些 AWS 服务后,TypeScript 类型和编辑器的自动完成特性会引导你定义正确的基础设施。

 

我们的大部分后端代码库都在一个单一的单体仓库中,并使用了一些库,如pnpm、zod、true-myth、swc,来让我们的代码更容易编写——未来的文章中会有更多介绍。

实践

 

那么,这在实践中是什么样子的呢?让我们来看看一个变更该怎么做:

“神奇”的 AWS 无服务器开发体验

 

(视频见原文)

 

在这个例子中,我们通过我们的核心 GraphQL API 在 Plain 中创建了一个工作空间。这验证了 E2E 的 API 调用是有效的:

 

总结

 

有了这些技术和实践,我们就可以专注于发布特性了:

 

 

我们还能做的更好吗?改进的余地肯定还有,但我认为这已经是相当神奇的体验了!如果你有任何问题,或者知道如何让我们的堆栈变得更好,请在 Twitter 上 @builtwithplain 或我 @akoskrivachy,与我们联系。

 

如果你对我们的神奇技术栈感兴趣,请在 Plain 的工作页面上查看我们目前的职位空缺。

 

原文链接:
https://journal.plain.com/posts/2022-02-08-a-magical-aws-serverless-developer-experience/

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>