將一個 ASP.NET Core Web API 項目遷移到 Azure Function
點擊上方藍字關(guān)注“汪宇杰博客”

導(dǎo)語
前段時間我成功將一個ASP.NET Core Web API項目遷移到了最新的Azure Function V3,從而利用Azure平臺serverless服務(wù)的特性將運維成本降低了10倍,媽媽再也不用擔(dān)心我落魄街頭了。本文將介紹遷移過程中的關(guān)鍵步驟及相關(guān)注意事項,幫助大家遷移類似的ASP.NET Core Web API項目。
該 Web API 項目是我博客系統(tǒng)的一部分,名為Moonglade.Notification,用于發(fā)送 Email 通知給管理員及用戶。它在博客整體架構(gòu)中和博客主網(wǎng)站分離,以獨立的服務(wù)運行于 Azure App Service 上,其認證采用自定義的API Key 方式,Email 賬號密碼保存在 Azure Key Vault 中,后端無數(shù)據(jù)庫支持。當(dāng)博客主網(wǎng)站需要推送通知時,會通過 REST 請求傳遞通知本身的 payload 以及網(wǎng)站實例專有的 API Key 給 Moonglade.Notification API 完成 Email 發(fā)送。
盡管該 Web API 不像專業(yè)通知系統(tǒng)那樣具備事件及隊列支持,但這個通知系統(tǒng)一直運行良好,能夠滿足業(yè)務(wù)需求。然而最顯著的問題在于成本開銷,包括運維成本和開發(fā)成本。
首先,App Service 背后承載 Web API 項目的 App Service Plan 本身是一個巨大的開銷。對于一個 ASP.NET Core 應(yīng)用,目前還沒辦法選擇 Consumption Plan,因此API 即使空閑,也會處于計費狀態(tài)。而根據(jù)業(yè)務(wù)規(guī)律,該API 每天只會被調(diào)用十次左右,總共處理耗時不到2分鐘。而我每天都需要為其余1,438分鐘的空閑時間付費。就算是有充足的 Azure 額度,也不應(yīng)該這樣浪費。更好的選擇有很多,比如把計算資源騰出,給在疫情中需要使用的人,減少碳排放,幫助改善地球環(huán)境。
https://news.microsoft.com/climate/
其次,再簡單不過的Email通知功能,也需要一套完整的基礎(chǔ)框架去承載,盡管 ASP.NET Core 提供了非常靈活自由的基礎(chǔ)框架,但 Azure Function 可以將這部分工作完全省去。Azure Function 只關(guān)心業(yè)務(wù)代碼,而不是基礎(chǔ)設(shè)施。也就是說,在一般情況下,開發(fā)者只需要寫函數(shù)處理的邏輯,而不需要寫如何啟動、路由、分配 API Key 等代碼。
最后,Azure Function 能讓我繼續(xù)使用原來的.NET技術(shù)棧,這樣一來大大降低了需要特意學(xué)習(xí)其他語言所消耗的時間成本。而 Azure Function 支持.NET、Java、Python和Node.js,甚至 PowerShell 編寫業(yè)務(wù)邏輯。這就意味著我的代碼只需要進行少量的修改就能運行在全新的平臺上,如此easy!
我理解社區(qū)的所謂的“微軟原罪文化”,每當(dāng)推廣一個微軟的技術(shù)大家都會有或多或少有一些抵觸情緒。在Azure Function上大家最關(guān)心的問題可能就是用了Azure Function是不是意味著你的應(yīng)用從此只能跑在Azure上?答案是否定的。Azure Function 的基礎(chǔ)架構(gòu)早已開源,和其應(yīng)用本身都可以做到容器化,并部署到任何(即使是競爭對手的)云,包括國內(nèi)的阿里云等平臺,瞬間幫競爭對手實現(xiàn)世界一流serverless平臺。即時你不想上云,也可以將整套架構(gòu)運行在本地數(shù)據(jù)中心。因此不必不存在被微軟套牢的擔(dān)憂。
消除顧慮后,我們來看看遷移過程中的步驟和關(guān)鍵點。
本文不討論 Azure Function 的入門,如果你還沒有接觸過Azure Function,建議去官網(wǎng)及微軟免費在線學(xué)習(xí)平臺Microsoft Learn上先惡補基礎(chǔ)知識。
https://azure.microsoft.com/en-us/services/functions/
https://docs.microsoft.com/en-us/learn/paths/create-serverless-applications/
API Key 認證
這是原本 ASP.NET Core Web API 基礎(chǔ)框架的一部分,我通過別人的博客文章自主研發(fā)了自定義的 API Key 認證來確保 API 不會被匿名調(diào)用。
https://josefottosson.se/asp-net-core-protect-your-api-with-api-keys/

(圖:API Key 認證基礎(chǔ)框架代碼)
而 Azure Function 直接幫我們省去了這部分自定義代碼,它本身提供非常類似的 App Key 的概念去管理認證,而這一切,都不需要寫一行代碼,只要點點鼠標就完成了!

(圖:在Azure Function中分配App Key)
這些 App Key 可以通用 query string 傳遞給函數(shù)終端進行認證。因此遷移應(yīng)用的時候,我直接刪除了之前辛辛苦苦寫的 API Key 的全部邏輯,這部分代碼再也不會產(chǎn)生維護成本了。

(圖:在Azure Portal中測試Function Key)
讀取 Key Vault
原先的應(yīng)用采用 Microsoft.Azure.KeyVault 包以及App Service 的 System assigned Identity 讀取 Azure Key Vault中的密鑰數(shù)據(jù)。這顯然才是真的耦合了Azure。
var keyVaultClient = new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback));
builder.AddAzureKeyVault(
??? $"https://{builtConfig["AzureKeyVault:Name"]}.vault.azure.net/",
??? keyVaultClient,
new DefaultKeyVaultSecretManager());
而Azure Function中,我決定采用環(huán)境變量的方式傳遞配置,這樣即能做到代碼不耦合Azure,也能做到在Azure上點點鼠標就將特定配置項設(shè)定為從Key Vault讀取,而讀取的邏輯對應(yīng)用本身是透明的,應(yīng)用依然認為這是個環(huán)境變量。
具體來說,就是在Azure Function 的Configuration 頁面,將需要從Key Vault中取值的配置項改為下面這種格式:
@Microsoft.KeyVault(SecretUri=
例如我的 EmailAccountPassword 環(huán)境變量,配置成功后,Source 會顯示為 Key vault Reference。

(圖:從Azure Key Vault 讀取配置)
應(yīng)用中讀取該環(huán)境變量的代碼和讀取普通環(huán)境變量完全一致:
Environment.GetEnvironmentVariable("EmailAccountPassword", EnvironmentVariableTarget.Process)
因此,你依然可以保持本地開發(fā)環(huán)境或其他云上的部署不耦合Azure Key Vault,非常靈活自由。
需要注意的是,你的Function App本身需要開啟System assigned Identity。

(圖:Azure Function System assigned Identity)
并且在 Azure Key Vault 中也得給該 Function App 配置Get, List的權(quán)限。

(圖:Azure Key Vault Access policies)
這一切都可以點點鼠標來完成。如果你覺得點鼠標low,可以參考我這個項目的GitHub倉庫,使用Azure CLI敲命令方式執(zhí)行,不管你是鼠標派還是命令派,微軟技術(shù)總有一款適合你。
https://github.com/EdiWang/Moonglade.Notification/blob/master/Azure-Deployment/Deploy.ps1#L86
Consumption Plan
這可能是整個Function里最讓(窮)人激動的功能了。在你創(chuàng)建 Azure Function 的時候,可以選擇 Consumption Plan。該Plan只收取Function執(zhí)行時間的費用,在閑時沒人調(diào)用Function就不會計費。對于我的博客通知系統(tǒng),一天調(diào)用不了幾次的業(yè)務(wù)壓力下,每月計費不到5美元。而之前一個標準型S1的App Service Plan每月計費69.35美元,使用Consumption Plan 直接節(jié)省了60多美元。

(圖:Function App Consumption Plan)
代碼遷移
終于說到.NET程序員最關(guān)心的一項了,代碼和原來有什么區(qū)別?
首先,你已經(jīng)不需要Program.cs和Startup.cs了,Controller也沒了,甚至appsettings.json也沒了,取而代之的只有你的業(yè)務(wù)代碼。現(xiàn)在我的通知系統(tǒng)應(yīng)用部分只有一個class,邏輯和原來的Controller非常像。
原API Controller
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class NotificationController : ControllerBase
{
??? private readonly ILogger
?
??? private readonly IMoongladeNotification _notification;
?
??? public AppSettings Settings { get; set; }
?
??? public NotificationController(
??????? ILogger
??????? IOptions
??????? IMoongladeNotification notification)
??? {
??????? _logger = logger;
??????? Settings = settings.Value;
??????? _notification = notification;
??? }
?
??? [HttpPost]
??? public async Task
??? {
??????? T GetModelFromPayload
??????? {
??????????? var json = request.Payload.ToString();
?????????? ?return JsonSerializer.Deserialize
??????? }
?
??????? try
??????? {
??????????? if (!Settings.EnableEmailSending)
??????????? {
??????????????? return new FailedResponse((int)ResponseFailureCode.EmailSendingDisabled, "Email Sending is disabled.");
??????????? }
?
??????????? _notification.AdminEmail = request.AdminEmail;
??????????? _notification.EmailDisplayName = request.EmailDisplayName;
??????????? switch (request.MessageType)
??????????? {
??????????????? case MailMesageTypes.TestMail:
??????????????????? await _notification.SendTestNotificationAsync();
??????????????????? return new SuccessResponse();
????????????????????????????
????????????? ?// 省略部分代碼...
?
??????????????? default:
??????????????????? throw new ArgumentOutOfRangeException();
?? ?????????}
??????? }
??????? catch (Exception e)
??????? {
??????????? _logger.LogError(e, $"Error sending notification for type '{request.MessageType}'. Requested by '{User.Identity.Name}'");
??????????? Response.StatusCode = StatusCodes.Status500InternalServerError;
??????????? return new FailedResponse((int)ResponseFailureCode.GeneralException, e.Message);
??????? }
??? }
}
現(xiàn) Function Class
public class EmailSendingFunction
{
??? [FunctionName("EmailSending")]
??? public async Task
??????? [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] NotificationRequest request,
??????? ILogger log, ExecutionContext executionContext)
??? {
??????? T GetModelFromPayload
??????? {
??????????? var json = request.Payload.ToString();
??????????? return JsonSerializer.Deserialize
??????? }
?
??????? log.LogInformation("EmailSending HTTP trigger function processed a request.");
?
??????? try
??????? {
?
??????????? var configRootDirectory = executionContext.FunctionAppDirectory;
??????????? AppDomain.CurrentDomain.SetData(Constants.AppBaseDirectory, configRootDirectory);
??????????? log.LogInformation($"Function App Directory: {configRootDirectory}");
?
??????????? IMoongladeNotification notification = new EmailHandler(log)
??????????? {
??????????????? AdminEmail = request.AdminEmail,
??????????????? EmailDisplayName = request.EmailDisplayName
??????????? };
?
??????????? switch (request.MessageType)
??????????? {
??????????????? case MailMesageTypes.TestMail:
??????????????????? await notification.SendTestNotificationAsync();
??????????????????? return new OkObjectResult("TestMail Sent");
?
??????????????? // 省略部分代碼...
?
??????????????? default:
??????????????????? throw new ArgumentOutOfRangeException();
??????????? }
??????? }
??????? catch (Exception e)
??????? {
??????????? log.LogError(e, e.Message);
??????????? return new ConflictObjectResult(e.Message);
??????? }
??? }
}
而如果你想做一些框架方面的修改,比如想使用DI,也不是不行:
[assembly: FunctionsStartup(typeof(MyNamespace.Startup))]
?
namespace MyNamespace
{
??? public class Startup : FunctionsStartup
??? {
??????? public override void Configure(IFunctionsHostBuilder builder)
??????? {
??????????? builder.Services.AddHttpClient();
?
??????????? builder.Services.AddSingleton
??????????????? return new MyService();
??????????? });
?
??????????? builder.Services.AddSingleton
??????? }
??? }
}
詳情可參考微軟文檔:
https://docs.microsoft.com/en-us/azure/azure-functions/functions-dotnet-dependency-injection
將一個業(yè)務(wù)壓力不大的簡單 Web API 遷移到 Azure Function,能夠顯著節(jié)省開發(fā)和運維成本,并更大程度利用云,將關(guān)注點從基礎(chǔ)架構(gòu)轉(zhuǎn)移到業(yè)務(wù)邏輯本身,繼續(xù)使用你熟悉的編程語言編寫代碼,同時保持一定的靈活性和安全性。
汪宇杰博客
.NET | Azure |?微軟MVP
長按二維碼獲取我的最新技術(shù)分享
喜歡本篇內(nèi)容請點個在看

