當開發者想要開發無服務器和云原生應用時,一個常見的問題是:這方面的開發體驗到底怎么樣呢?這個問題很很重要,因為良好的開發體驗和快捷的反饋通道會讓開發者更開心、更有生產力,從而能夠快速交付特性。
由于我們在建立 Plain 時有意縮小規模,所以我們必須有出色的開發體驗。我們需要確保公司聘請的少數幾位工程師能夠在保持高質量的同時快速交付產品特性,產生最大的影響力。
2021 年我們有了時間去思考這個問題的解決方案,因為 Plain 是從頭開始建立的。在我們選擇常用的技術棧時,一方面要考慮到公司每天都會做變更,另一方面我們希望基礎平臺能夠支持未來 5-10 年的業務成長與成就。這意味著平臺應該能以較低的成本大規模運行我們的服務,而不需要專門劃分出一個部門來管理自主研發的基礎設施。
作出這些決策背后的理由肯定需要單獨寫文章來談了,不過我們最終決定完全投入無服務器和云原生、全棧 TypeScript,并使用 AWS 作為我們的云供應商,因為它足夠成熟也非常流行。我們認為,使用 AWS 的專有服務是一種可以接受的供應商鎖定權衡,因為相比更換云供應商的自由度來說,從這一決策中獲得的價值是更高的。我確實看到過一些公司花了大量精力去嘗試做跨云(cloud-agnostic,云不可知),但實際上并沒有從中得到任何現實收益。
無服務器開發的獨特之處
無服務器應用程序的開發和測試工作有一些獨特的要素。與傳統開發相比的一大區別在于,你最終會使用大量云服務,并會盡量把責任卸載給無服務器解決方案。
就 AWS Lambda 而言,這意味著你最后往往會使用 API Gateway、DynamoDB、SQS、SNS、S3、EventBridge、ElastiCache 等來構建你的應用。使用這么多服務需要開發、測試和部署大量配置、權限和基礎設施。如果你只關心你的 lambda 代碼的測試工作,那么就會略過很大一部分特性。如果你不驗證你的基礎設施,可能會遇到以下情況:
- 缺少一個 SQS 或 Lambda 函數的 S3 trigger
- 缺少一個將事件路由到正確目標的 EventBridge 規則
- 使用一個新的 AWS 服務時缺少 Lambda IAM 角色更新
- API Gateway 中的 CORS 或授權器配置不正確
你要回答的一個最重要的問題是:你想要什么時候發現這些錯誤?
- 在編寫和運行你的測試時?
- 在進行特性開發和開發人員手動嘗試他們的特性時?
- 在你通過一些 E2E 集成測試套件運行的持續集成中?
- 在一個共享的部署環境中,如開發或暫存?
- 或者在最壞的情況下:在生產環境中?
我們的選擇是越早越好:在編寫和運行測試時。這意味著,“你應該 mock 云的依賴項還是擁抱云“這個爭論其實并不是一個問題。如果讓我們的 Lambda 使用 AWS mock 或一些 localhost 仿真,在部署時還是很難做到無條件正常運行。Gareth McCumskey 的“為什么無服務器的本地部署是一種反模式”這篇博文為“模擬還是上云“的爭論給出了很好的答案,我強烈推薦大家閱讀。
云端開發帶來的最大影響是需要互聯網接入來編寫代碼。雖然這對某些公司或人們來說可能是一個不可接受的權衡,但對我們這家遠程優先的公司來說,我們本來就需要互聯網接入來與同事溝通,因此很少會出現無法接入網絡的情況。
本著我們希望進行云端開發,而們開始評估各種工具和技術,以找出適合我們的方法。
神奇的堆棧
那么,我們神奇的 AWS 無服務器開發體驗是什么樣子的呢?從高層來看,以下內容構成了開發體驗的關鍵部分:
- 每位開發人員都有自己的個人 AWS 賬戶
- 用 AWS CDK 來開發我們的基礎設施,用無服務器棧(SST)來獲得非??焖俚姆答佂ǖ?/li>
- 編寫明顯多于單元測試的集成測試
- 全棧 TypeScript
采用這些技術和實踐產生了相當出色的開發體驗。
個人 AWS 賬戶
完全轉向無服務器后,每位開發人員就必須有自己的個人沙盒 AWS 賬戶。如前所述,構建大多數特性時,僅僅編寫代碼是不夠的,還有大量的基礎設施需要開發、修改和測試。擁有個人的 AWS 賬戶讓每位開發人員都可以進行實驗和開發工作,而不會影響其他工程師或像開發或暫存這樣的共享環境。再結合我們強大的基礎設施即代碼,每個人都可以擁有一份生產環境的個人克隆版本。
你可能會想:這不是很貴嗎?我們是不是要向 AWS 支付數百美元?不,無服務器解決方案不是這樣的!真正的無服務器解決方案都是按使用量付費的,所以如果你的 AWS 賬戶沒有任何活動(例如在工程師不工作的晚上和周末),那么你就不會支付一分錢。這方面有一些例外,如 S3 存儲、DynamoDB 存儲、RDS 存儲、Route53 托管區等費用,但它們一般沒幾個錢。
例如,Plain 公司 1 月份為我們的 7 個開發者賬戶支付的賬單總計 150 美元,而我們每個人都有自己的生產環境克隆,因此開發速度大幅提升,相比之下這點費用真不算什么。通常情況下,每位開發人員涉及的最大成本是我們的關系數據庫:Amazon Aurora Serverless v1 PostgreSQL。在開發過程中,當它收到請求時會自動擴展,并在無活動 30 分鐘后降至零。
每個開發者賬戶的 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,所以當變更部署到生產環境時我們會很有信心它能正常運行??偟膩碚f,這意味著與 mocking 或仿真相比,我們能極快地發現基礎設施問題。
實時 lambda 開發架構概述。(來源:docs.serverless-stack.com)
這種設置的好處是我們可以輕松做到真正的全棧開發。我們可以將 React 前端應用指向個人 AWS 賬戶部署的 API Gateway URL,并同時改變前端和后端,兩個代碼庫都可以實時重載。鑒于一切部署都在使用與生產環境相同的 AWS 服務,我們的前端應用程序不需要調整就能完全正常工作。
雖然選擇在一個(當時)相對未知的工具上建立我們的后端堆棧是有一點風險的,但我們知道我們有 AWS CDK 這個逃生艙口。如果我們遇到 SST 不支持或我們不喜歡的東西,還可以使用非常成熟的 AWS CDK 構造。這使我們在 SST 的奇妙開發體驗與 AWS CDK 的成熟度、特性豐富度和第一方支持之間取得了最佳平衡。
Serverless Stack 也有一些非常棒的特性,比如說:
- 在 Lambda 代碼中加入斷點,在本地 IDE 中調試。這得益于-increase-timeout 標志,該標志將所有 Lambda 超時時間增加到 15 分鐘。如果你對此感興趣可以查看這里的文檔或視頻。
- 檢測基礎設施變更并提示你部署它們,即盡可能地接近實時重載。部署仍然需要一些時間,因為在底層它是 Cloudformation。
- 一個基于 Web 的控制臺(SST控制臺),可以可視化你的堆棧、Lambda、S3 桶,還有回放單個 Lambda 事件的能力。
- 自動導出已刪除的Cloudformation堆棧輸出:我們以前曾多次遇到這種情況,有時我們注意到的時候已經太晚了,所以很麻煩。
- 一個不斷增長的構造庫
每當我們遇到問題、有疑問或特性請求時,SST 的Slack社區都能提供很大幫助。Frank、Jay、Dax和社區總是很樂意幫助我們。我強烈建議大家嘗試一下 SST,因為很難找到如此好用的東西了。
測試
一開始我們就有一個野心,就是對我們的測試套件能有充分的信心。如果我們的 CI 是綠色的,那么應該就可以安全地將該變更部署到生產環境中——這正是我們在合并到主分支時所做的事情。為了實現這一目標,我們決定將測試工作集中在一個強大的集成測試套件上,而不是對單個 lambda 函數或小代碼塊分別進行單元測試。這似乎是不好的實踐,或者是違背了傳統的測試金字塔原則。但當我們遇到像無服務器這樣的階梯式創新時,有必要對現有的實踐提出質疑,看看這些實踐是否仍那么有意義。
要明確的是:我們確實會在有意義的地方寫單元測試。如果我們有一些業務邏輯或計算,那么就會寫一個詳盡的單元測試套件。一個例子是我們的核心客戶狀態機對所有可能的狀態和狀態轉換都有單元測試。但是像 SQL 查詢、AWS API 調用或我們的 GraphQL 請求這樣的單元測試是絕對不可能寫的,因為它不會帶來什么實際的保證。你最終要測試的是大量的實現細節,而維護高質量的 mock 或仿真需要投入很大資源,并不值得。
拿數字說話,我們目前的測試套件比例是 30%單元測試和 70%集成測試用例。
我們的集成測試是以一種合理的方式設計和編寫的,它們速度夠快,主要測試行為而非實現。這意味著我們盡量避免斷言內部實現細節,例如 DynamoDB 或 RDS 中存儲的數據。相反,我們專注于驗證外部(從 Lambda 的角度)可見的行為,如 API 響應或正在發布的事件。對于我們的事件,我們的原則是只測試一個已經發布的事件,而不是斷言所有下游消費者。我們為每個消費者編寫單獨的集成測試。這也要求我們在代碼中保持合理的領域邊界,以確保每個領域都可以獨立測試。
集成測試的邊界
這種編寫測試的方式也有一個好處,就是能夠針對共享環境運行。我們目前有一個完整的集成測試套件,在部署后合并到主環境時針對我們的開發環境運行,并按計劃檢測 flaky 測試。沒有什么能阻止我們在生產環境中也運行這些完全一樣的測試。理論上,我們可以刪除 100%的代碼,用 Delphi 重寫所有的 Lambda,只要我們的集成測試套件通過就可以把它發布到生產環境。(注意:我們還沒有嘗試過這件事,也不打算在短時間內這樣做)。
一個典型的 GraphQL API 查詢或突變的集成測試大致上會做以下工作:
- 從認證的用戶池中請求一個用戶(我們遇到了一些配額和身份提供者的限制)
- 創建一個新的工作區,以便有一個干凈的狀態
- 設置測試的狀態,如創建一個客戶、發送一個聊天信息等
- 進行 GraphQL 查詢
- 斷言 GraphQL 響應
- 在突變的情況下:斷言任何應該被發布的事件
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 事件監聽器集成測試會:
- 設置任何所需的狀態(這在很大程度上取決于具體的 Lambda)。
- 在總線上發布一個 EventBridge 事件
- 等待并期待副作用的出現,這可能是:
另一個 EventBridge 事件被發布
數據存儲中的狀態被更新(如 DynamoDB、RDS、S3)
如果你曾寫過任何集成測試,一定會在腦子里大喊:運行這些東西一定很慢!它們肯定比單元測試慢,但也不是慢得讓人無法忍受。由于我們使用的所有服務都是無服務器的,而且我們確保集成測試有 0 個共享狀態,所以我們有能力并行運行所有的測試。我們還沒有達到這樣的優化程度,但舉例來說,我們的 CI 并行度為 40,在 2 分鐘內就能在 110 個測試套件中運行 656 個測試用例,對我們應用的每個角落進行詳盡的集成測試。
來自我們 CI 的集成測試套件結果
集成測試的不穩定性是我們積極解決的另一個問題,為此我們在工作周內按計劃運行測試。一旦遇到測試失敗,我們就會跳出來,追蹤問題的根源。這也需要我們重新思考,并把某些東西(如 GraphQL 訂閱)的測試調整成一種穩健和可靠的方式。
我們才剛剛開始研究我們的集成測試設置,這個話題絕對值得另起一篇文章。也就是說,鑒于我們的 API 是產品的一個關鍵部分,對每一個 GraphQL 查詢和突變的整合進行測試是至關重要的。我們認為,就算測試套件稍慢一些,但對特性或變更能正確運行有更高的信心就足夠值得了。
全棧 TypeScript
雖然使用全棧 TypeScript 并不是在 AWS 上擁有良好開發體驗的嚴格必要條件,但它確實讓我們的團隊獲得了更高的效率。無需學習新的語言就能在前端、后端和基礎設施代碼之間來回切換,這對團隊的每位成員來說都是非常寶貴的體驗。
在開發后端代碼時你仍然需要學習 AWS 服務,但這在使用任何東西時都是很自然的需求。你同樣需要了解 css/html 來開發前端 Web 應用。有了 TypeScript 中的 SST 和 CDK,在你弄清楚自己想使用哪些 AWS 服務后,TypeScript 類型和編輯器的自動完成特性會引導你定義正確的基礎設施。
我們的大部分后端代碼庫都在一個單一的單體倉庫中,并使用了一些庫,如pnpm、zod、true-myth、swc,來讓我們的代碼更容易編寫——未來的文章中會有更多介紹。
實踐
那么,這在實踐中是什么樣子的呢?讓我們來看看一個變更該怎么做:
(視頻見原文)
在這個例子中,我們通過我們的核心 GraphQL API 在 Plain 中創建了一個工作空間。這驗證了 E2E 的 API 調用是有效的:
- 用戶從我們的身份提供者那里獲取了一個有效的 JWT
- AWS API Gateway 處理了 GraphQL 請求并驗證了 JWT 的有效性。
- GraphQL Lambda 在我們的 Aurora Serverless PostgreSQL 數據庫中創建了一個新的工作區,并向 EventBridge 發布了一個事件
- 這驗證了 Lambda 具有正確的 IAM 權限,可以從 PostgreSQL 中讀/寫并發布到 EventBridge
- 一個成功的響應被返回到客戶端
總結
有了這些技術和實踐,我們就可以專注于發布特性了:
- 由于每個人都有自己的 AWS 賬戶,所以不會影響到其他工程師
- 有了 SST 和實時 lambda 開發,我們可以使用實時的 AWS 服務實現快速的反饋循環,知道它在部署時可以正常工作
- 利用 CDK 輕松開發無服務器基礎設施
- 因為有我們的集成測試,所以我們對正確性有很高的信心
- 在前端、后端和基礎設施之間切換時,不必學習不同的編程或模板語言
我們還能做的更好嗎?改進的余地肯定還有,但我認為這已經是相當神奇的體驗了!如果你有任何問題,或者知道如何讓我們的堆棧變得更好,請在 Twitter 上 @builtwithplain 或我 @akoskrivachy,與我們聯系。
如果你對我們的神奇技術棧感興趣,請在 Plain 的工作頁面上查看我們目前的職位空缺。
原文鏈接:
https://journal.plain.com/posts/2022-02-08-a-magical-aws-serverless-developer-experience/