将一个 ASP.NET Core Web API 项目迁移到 Azure Function
点击上方蓝字关注“汪宇杰博客”
导语
前段时间我成功将一个ASP.NET Core Web API项目迁移到了最新的Azure Function V3,从而利用Azure平台serverless服务的特性将运维成本降低了10倍,妈妈再也不用担心我落魄街头了。本文将介绍迁移过程中的关键步骤及相关注意事项,帮助大家迁移类似的ASP.NET Core Web API项目。
该 Web API 项目是我博客系统的一部分,名为Moonglade.Notification,用于发送 Email 通知给管理员及用户。它在博客整体架构中和博客主网站分离,以独立的服务运行于 Azure App Service 上,其认证采用自定义的API Key 方式,Email 账号密码保存在 Azure Key Vault 中,后端无数据库支持。当博客主网站需要推送通知时,会通过 REST 请求传递通知本身的 payload 以及网站实例专有的 API Key 给 Moonglade.Notification API 完成 Email 发送。
尽管该 Web API 不像专业通知系统那样具备事件及队列支持,但这个通知系统一直运行良好,能够满足业务需求。然而最显著的问题在于成本开销,包括运维成本和开发成本。
首先,App Service 背后承载 Web API 项目的 App Service Plan 本身是一个巨大的开销。对于一个 ASP.NET Core 应用,目前还没办法选择 Consumption Plan,因此API 即使空闲,也会处于计费状态。而根据业务规律,该API 每天只会被调用十次左右,总共处理耗时不到2分钟。而我每天都需要为其余1,438分钟的空闲时间付费。就算是有充足的 Azure 额度,也不应该这样浪费。更好的选择有很多,比如把计算资源腾出,给在疫情中需要使用的人,减少碳排放,帮助改善地球环境。
https://news.microsoft.com/climate/
其次,再简单不过的Email通知功能,也需要一套完整的基础框架去承载,尽管 ASP.NET Core 提供了非常灵活自由的基础框架,但 Azure Function 可以将这部分工作完全省去。Azure Function 只关心业务代码,而不是基础设施。也就是说,在一般情况下,开发者只需要写函数处理的逻辑,而不需要写如何启动、路由、分配 API Key 等代码。
最后,Azure Function 能让我继续使用原来的.NET技术栈,这样一来大大降低了需要特意学习其他语言所消耗的时间成本。而 Azure Function 支持.NET、Java、Python和Node.js,甚至 PowerShell 编写业务逻辑。这就意味着我的代码只需要进行少量的修改就能运行在全新的平台上,如此easy!
我理解社区的所谓的“微软原罪文化”,每当推广一个微软的技术大家都会有或多或少有一些抵触情绪。在Azure Function上大家最关心的问题可能就是用了Azure Function是不是意味着你的应用从此只能跑在Azure上?答案是否定的。Azure Function 的基础架构早已开源,和其应用本身都可以做到容器化,并部署到任何(即使是竞争对手的)云,包括国内的阿里云等平台,瞬间帮竞争对手实现世界一流serverless平台。即时你不想上云,也可以将整套架构运行在本地数据中心。因此不必不存在被微软套牢的担忧。
消除顾虑后,我们来看看迁移过程中的步骤和关键点。
本文不讨论 Azure Function 的入门,如果你还没有接触过Azure Function,建议去官网及微软免费在线学习平台Microsoft Learn上先恶补基础知识。
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 基础框架的一部分,我通过别人的博客文章自主研发了自定义的 API Key 认证来确保 API 不会被匿名调用。
https://josefottosson.se/asp-net-core-protect-your-api-with-api-keys/
(图:API Key 认证基础框架代码)
而 Azure Function 直接帮我们省去了这部分自定义代码,它本身提供非常类似的 App Key 的概念去管理认证,而这一切,都不需要写一行代码,只要点点鼠标就完成了!
(图:在Azure Function中分配App Key)
这些 App Key 可以通用 query string 传递给函数终端进行认证。因此迁移应用的时候,我直接删除了之前辛辛苦苦写的 API Key 的全部逻辑,这部分代码再也不会产生维护成本了。
(图:在Azure Portal中测试Function Key)
读取 Key Vault
原先的应用采用 Microsoft.Azure.KeyVault 包以及App Service 的 System assigned Identity 读取 Azure Key Vault中的密钥数据。这显然才是真的耦合了Azure。
var keyVaultClient = new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback));
builder.AddAzureKeyVault(
$"https://{builtConfig["AzureKeyVault:Name"]}.vault.azure.net/",
keyVaultClient,
new DefaultKeyVaultSecretManager());
而Azure Function中,我决定采用环境变量的方式传递配置,这样即能做到代码不耦合Azure,也能做到在Azure上点点鼠标就将特定配置项设定为从Key Vault读取,而读取的逻辑对应用本身是透明的,应用依然认为这是个环境变量。
具体来说,就是在Azure Function 的Configuration 页面,将需要从Key Vault中取值的配置项改为下面这种格式:
@Microsoft.KeyVault(SecretUri=
例如我的 EmailAccountPassword 环境变量,配置成功后,Source 会显示为 Key vault Reference。
(图:从Azure Key Vault 读取配置)
应用中读取该环境变量的代码和读取普通环境变量完全一致:
Environment.GetEnvironmentVariable("EmailAccountPassword", EnvironmentVariableTarget.Process)
因此,你依然可以保持本地开发环境或其他云上的部署不耦合Azure Key Vault,非常灵活自由。
需要注意的是,你的Function App本身需要开启System assigned Identity。
(图:Azure Function System assigned Identity)
并且在 Azure Key Vault 中也得给该 Function App 配置Get, List的权限。
(图:Azure Key Vault Access policies)
这一切都可以点点鼠标来完成。如果你觉得点鼠标low,可以参考我这个项目的GitHub仓库,使用Azure CLI敲命令方式执行,不管你是鼠标派还是命令派,微软技术总有一款适合你。
https://github.com/EdiWang/Moonglade.Notification/blob/master/Azure-Deployment/Deploy.ps1#L86
Consumption Plan
这可能是整个Function里最让(穷)人激动的功能了。在你创建 Azure Function 的时候,可以选择 Consumption Plan。该Plan只收取Function执行时间的费用,在闲时没人调用Function就不会计费。对于我的博客通知系统,一天调用不了几次的业务压力下,每月计费不到5美元。而之前一个标准型S1的App Service Plan每月计费69.35美元,使用Consumption Plan 直接节省了60多美元。
(图:Function App Consumption Plan)
代码迁移
终于说到.NET程序员最关心的一项了,代码和原来有什么区别?
首先,你已经不需要Program.cs和Startup.cs了,Controller也没了,甚至appsettings.json也没了,取而代之的只有你的业务代码。现在我的通知系统应用部分只有一个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);
}
}
}
现 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
将一个业务压力不大的简单 Web API 迁移到 Azure Function,能够显著节省开发和运维成本,并更大程度利用云,将关注点从基础架构转移到业务逻辑本身,继续使用你熟悉的编程语言编写代码,同时保持一定的灵活性和安全性。
汪宇杰博客
.NET | Azure | 微软MVP
长按二维码获取我的最新技术分享
喜欢本篇内容请点个在看