其中最底層就是實現複雜業務的後端微服務(Backend)。然後 FaaS 層通過一系列函數實現業務邏輯,並為前端直接提供服務。對於前端開發者來說,前端可以通過編寫函數的方式來實現服務端的邏輯。對於後端開發者來說,後端變得更靠後了。如果業務比較較淡,FaaS 層能夠實現,甚至也不需要微服務這一層了。
同時不管是在後端、FaaS 還是前端,我們都可以去調用雲計算平台提供的 BaaS 服務,大大降低開發難度、減少開發成本。小程序雲開發,就是直接在前端調用 BaaS 服務的例子。
基於 Serverless 開發模式和傳統開發模式最大的不同,就是傳統開發中,我們是基於應用的開發。開發完成後,我們需要對應用進行單元測試和集成測試。而基於 Serverless,開發的是一個個函數,那麼我們應該如何對 Serverless 函數進行測試?Serverless 函數的測試和普通的單元測試又有什麼區別?
Serverless 函數是分散式的,我們不知道也無需知道函數是部署或運行在哪台機器上,所以我們需要對每個函數進行單元測試。Serverless 應用是由一組函數組成的,函數內部可能依賴了一些別的後端服務(BaaS),所以我們也需要對 Serverless 應用進行集成測試。
運行函數的 FaaS 和 BaaS 在本地也難以模擬。除此之外,不同平台提供的 FaaS 環境可能不一致,不平台提供的 BaaS 服務的 SDK 或介面也可能不一致,這不僅給我們的測試帶來了一些問題,也增加了應用遷移成本。
根據 Mike Cohn 提出的測試金字塔,單元測試的成本最低,效率最高;UI 測試(集成)測試的成本最高,效率最低,所以我們要儘可能多的進行單元測試,從而減少集成測試。這對 Serverless 的函數測試同樣適用。
圖片來源: https:// martinfowler.com/bliki/ TestPyramid.html 為了能更簡單對函數進行單元測試,我們需要做的就是將業務邏輯和函數依賴的 FaaS(如函數計算) 和 BaaS (如雲資料庫)分離 。當 FaaS 和 BaaS 分離出去之後,我們就可以像編寫傳統的單元測試一樣,對函數的業務邏輯進行測試。然後再編寫集成測試,驗證函數和其他服務的集成是否正常工作。
一個糟糕的例子
下面是一個使用 Node.js 實現的函數的例子。該函數做的事情就是,首先將用戶信息存儲到資料庫中,然後給用戶發送郵件。
const db = require ( db ). connect ();
const mailer = require ( mailer );
module . exports . saveUser = ( event , context , callback ) => {
const user = {
email : event . email ,
created_at : Date . now ()
}
db . saveUser ( user , function ( err ) {
if ( err ) {
callback ( err );
} else {
mailer . sendWelcomeEmail ( event . email );
callback ();
}
});
};
這個例子主要存在兩個問題:
業務邏輯和 FaaS 耦合在一起。主要就是業務邏輯都在 saveUser
這個函數里,而 saveUser
參數的 event
和 conent
對象,是 FaaS 平台提供的。
業務邏輯和 BaaS 耦合在一起。具體來說,就是函數內使用了 db
和 mailer
這兩個後端服務,測試函數必須依賴於 db
和 mailer
。
編寫可測試的函數
基於將業務邏輯和函數依賴的 FaaS 和 BaaS 分離 的原則,對上面的代碼進行重構。
class Users {
constructor ( db , mailer ) {
this . db = db ;
this . mailer = mailer ;
}
save ( email , callback ) {
const user = {
email : email ,
created_at : Date . now ()
}
this . db . saveUser ( user , function ( err ) {
if ( err ) {
callback ( err );
} else {
this . mailer . sendWelcomeEmail ( email );
callback ();
}
});
}
}
module . exports = Users ;
const db = require ( db ). connect ();
const mailer = require ( mailer );
const Users = require ( users );
let users = new Users ( db , mailer );
module . exports . saveUser = ( event , context , callback ) => {
users . save ( event . email , callback );
};
在重構後的代碼中,我們將業務邏輯全都放在了 Users
這個類裡面,Users
不依賴任何外部服務。測試的時候,我們也可以不傳入真實的 db
或 mailer
,而是傳入模擬的服務。
下面是一個模擬 mailer
的例子。
// 模擬 mailer
const mailer = {
sendWelcomeEmail : ( email ) => {
console . log ( `Send email to ${ email } success!` );
},
};
這樣只要對 Users
進行充分的單元測試,就能確保業務代碼如期運行。
然後再傳入真實的 db
和 mailer
,進行簡單的集成測試,就能知道整個函數是否能夠正常工作。
重構後的代碼還有一個好處是方便函數的遷移。當我們想要把函數從一個平台遷移到另一個平台的時候,只需要根據不同平台提供的參數,修改一下 Users
的調用方式就可以了,而不用再去修改業務邏輯。
小結
綜上所述,對函數進行測試,就需要牢記金字塔原則,並遵循以下原則:
將業務邏輯和函數依賴的 FaaS 和 BaaS 分離
對業務邏輯進行充分的單元測試
將函數進行集成測試驗證代碼是否正常工作
函數的性能
使用 Serverless 進行開發,還有一個大家都關心的問題就是函數的性能怎麼樣。
對於傳統的應用,我們的程序啟動起來之後,就常駐在內存中;而 Serverless 函數則不是這樣。
當驅動函數執行的事件到來的時候,首先需要下載代碼,然後啟動一個容器,在容器裡面再啟動一個運行環境,最後才是執行代碼。前幾步統稱為冷啟動(Cold Start)。傳統的應用沒有冷啟動的過程。
下面是函數生命周期的示意圖:
圖片來源: https://www. youtube.com/watch? v=oQFORsso2go&feature=youtu.be&t=8m5s 冷啟動時間的長短,就是函數性能的關鍵因素。優化函數的性能,也就需要從函數生命周期的各個階段去優化。
不同編程語言對冷啟動時間的影響
在此之前,已經有很多人測試過不同編程語言對冷啟動時間的影響,比如:
Compare coldstart time with different languages, memory and code sizes -by Yan Cui
Cold start / Warm start with AWS Lambda - by Erwan Alliaume
Serverless: Cold Start War - by Mikhail Shilkov
圖片來源: Cold start / Warm start with AWS Lambda從這些測試中能夠得到一些統一的結論:
增加函數的內存可以減少冷啟動時間
C#、Java 等編程語言的能啟動時間大約是 Node.js、Python 的 100 倍
基於上述結論,如果想要 Java 的冷啟動時間達到 Node.js 那麼小,可以為 Java 分配更大的內存。但更大的內存意味著更多的成本。
函數冷啟動的時機
剛開始接觸 Serverless 的開發者可能有一個誤區,就是每次函數執行,都需要冷啟動。其實並不是這樣。
當第一次請求(驅動函數執行的事件)來臨,成功啟動運行環境並執行函數之後,運行環境會保留一段時間,以便用於下一次函數執行。這樣就能減少冷啟動的次數,從而縮短函數運行時間。當請求達到一個運行環境的限制時,FaaS 平台會自動擴展下一個運行環境。
以 AWS Lambda 為例,在執行函數之後,Lambda 會保持執行上下文一段時間,預期用於另一次 Lambda 函數調用。其效果是,服務在 Lambda 函數完成後凍結執行上下文,如果再次調用 Lambda 函數時 AWS Lambda 選擇重用上下文,則解凍上下文供重用。
下面以兩個小測試來說明上述內容。
我使用阿里雲的函數計算實現了一個 Serverless 函數,並通過 HTTP 事件來驅動。然後使用不同並發數向函數發起 100 個請求。
首先是一個並發的情況:
可以看到第一個請求時間為 302ms,其他請求時間基本都在 50ms 左右。基本就能確定,第一個請求對應的函數是冷啟動,剩餘 99 個請求,都是熱啟動,直接重複利用了第一個請求的運行環境。
接下來是並發數為 10 的情況:
可以發現,前 10 個請求,耗時基本在 200ms-300ms,其餘請求耗時在 50ms 左右。於是可以得出結論,前 10 個並發請求都是冷啟動,同時啟動了 10 個運行環境;後面 90 個請求都是熱啟動。
這也就印證了之前的結論,函數不是每次都冷啟動,而是會在一定時間內復用之前的運行環境。
執行上下文重用
上面的結論對我們提高函數性能有什麼幫助呢?當然是有的。既然運行環境能夠保留,那就意味著我們能對運行環境中的執行上下文進行重複利用。
來看一個例子:
const mysql = require ( mysql );
module . exports . saveUser = ( event , context , callback ) => {
// 初始化資料庫連接
const connection = mysql . createConnection ({ /* ... */ });
connection . connect ();
connection . query ( ... );
};
上面例子實現的功能就是在 saveUser
函數中初始化一個資料庫連接。這樣的問題就是,每次函數執行的時候,都會重新初始化資料庫連接,而連接資料庫又是一個比較耗時的操作。顯然這樣對函數的性能是沒有好處的。
既然在短時間內,函數的執行上下文可以重複利用,那麼我們就可以將資料庫連接放在函數之外:
const mysql = require ( mysql );
// 初始化資料庫連接
const connection = mysql . createConnection ({ /* ... */ });
connection . connect ();
module . exports . saveUser = ( event , context , callback ) => {
connection . query ( ... );
};
這樣就只有第一次運行環境啟動的時候,才會初始化資料庫連接。後續請求來臨、執行函數的時候,就可以直接利用執行上下文中的 connection
,從而提後續高函數的性能。
大部分情況下,通過犧牲一個請求的性能,換取大部分請求的性能,是完全可以夠接受的。
給函數預熱
既然函數的運行環境會保留一段時間,那麼我們也可以通過主動調用函數的方式,隔一段時間就冷啟動一個運行環境,這樣就能使得其他正常的請求都是熱啟動,從而避免冷啟動時間對函數性能的影響。
這是目前比較有效的方式,但也需要有一些注意的地方:
不要過於頻繁調用函數,至少頻率要大於 5 分鐘
直接調用函數,而不是通過網關等間接調用
創建專門處理這種預熱調用的函數,而不是正常業務函數
這種方案只是目前行之有效且比較黑科技的方案,可以使用,但如果你的業務允許「犧牲第一個請求的性能換取大部分性能」,那也完全不必使用該方案,
小結
總體而言,優化函數的性能就是優化冷啟動時間。上述方案都是開發者方面的優化,當然還一方面主要是 FaaS 平台的性能優化。
總結一下上述方案,主要是以下幾點:
選用 Node.js / Python 等冷啟動時間短的編程語言
為函數分配合適的運行內存
執行上下文重用
為函數預熱
總結
作為前端工程師,我們一直在探討前端的邊界是什麼。現在的前端開發早已不是以往的前端開發,前端不僅可以做網頁,還可以做小程序,做 APP,做桌面程序,甚至做服務端。而前端之所以在不斷拓展自己的邊界、不斷探索更多的領域,則是希望自己能夠產生更大的價值。最好是用我們熟悉的工具、熟悉的方式來創造價值。
而 Serverless 架構的誕生,則可以最大程度幫助前端工程師實現自己的理想。使用 Serverless,我們不需要再過多關注服務端的運維,不需要關心我們不熟悉的領域,我們只需要專註於業務的開發、專註於產品的實現。我們需要關心的事情變少了,但我們能做的事情更多了。
Serverless 也必將對前端的開發模式產生巨大的變革,前端工程師的職能也將再度回歸到應用工程師的職能。
如果要用一句話來總結 Serverless,那就是 Less is More。
推薦閱讀: