最近 dotnet 8 preview 5 发布,想尝试一下在 native AOT 模式下,asp.net core minimal API + blazor 的效果。
先看结果:
生成单个可执行文件5.9MB,内存占用 33MB(加载了所有菜单数据)。
网站的运行效果如下(因为菜谱都是从AKitchen爬的,所以就不提供我的菜谱的链接了,仅我个人使用):
目前 blazor component 对 native AOT 的支持不是很好,但是因为我主要用作服务端渲染,也没有什么js交互,所以我主要用 RenderFragment 来组织界面,而尽量避免component的使用,比如首页:
// Recipes.razor 文件中
public static RenderFragment Create(RecipeService recipeService, string? query) =>
@<section class="mx-3">
<div class="mt-3 mb-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<h1 class="flex items-center justify-center">
<img class="h-10 w-10 mr-3 animate-pulse" src="favicon.svg"/>
<a href="/" class="text-lg font-bold border-b-2 border-transparent hover:border-yellow-400">COOLKING</a>
</h1>
<div class="text-xs opacity-50 flex flex-col items-center mb-3 sm:mb-0">
<p>be patient with the 🍵🍭🍹 you are cooking</p>
<p>be patient with the 👶👵👧 you are lovinggg</p>
</div>
@CreateFilter(query)
</div>
<div id="recipes" hx-indicator="#recipes-indicator" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 2xl:grid-cols-7 gap-4">
@CreateList(recipeService, query, 0)
</div>
<progress id="recipes-indicator" class="progress progress-primary h-1 htmx-indicator my-4"></progress>
@Utils.Footer()
</section>;
其中 CreateFilter,CreateList 等也都是类似的方式,都是反回一个 RenderFragment。
接着就是使用minimal api来响应相应的路由,还是拿首页来看:
app.MapGet("/", (RecipeService recipeService, string? query) => Results.Extensions.RazorFragment(
Layout.Create(Recipes.Create(recipeService, query))))
.RequireAuthorization();
Layout.Create 和上面提到的的 Recipes.Create 类似,也是返回 RenderFragment:
// Layout.razor 文件中
@code {
public static RenderFragment Create(RenderFragment body, string title = "Coolking 菜谱") =>
new RenderFragment(builder => {
RenderFragment main =
@<html data-theme="luxury" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="favicon.svg" />
<link rel="icon" sizes="32x32" href="https://coolking.slaveoftime.fun/favicon_32x32.png" />
<link rel="icon" sizes="48x48" href="https://coolking.slaveoftime.fun/favicon_48x48.png" />
<link rel="icon" sizes="96x96" href="https://coolking.slaveoftime.fun/favicon_96x96.png" />
<link rel="icon" sizes="144x144" href="https://coolking.slaveoftime.fun/favicon_144x144.png" />
<link rel="stylesheet" href="tailwind-generated.css" />
<title>@title</title>
</head>
<body>
<script suppress-error="BL9992"></script>
@body
<script suppress-error="BL9992" src="htmx.org@1.8.5.js"></script>
@Utils.UpdateLazyImageJs()
</body>
</html>;
builder.AddMarkupContent(0, "<!DOCTYPE html>");
main.Invoke(builder);
});
}
而对于 Results.Extensions.RazorFragment,则是基于在 dotnet 8 中新引入的 HtmlRenderer 来实现的:
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
namespace Microsoft.Extensions.DependencyInjection;
public static class ResultsExtensions {
public static IResult RazorFragment(this IResultExtensions _, RenderFragment fragment) =>
new RazorFragmentResult(fragment);
struct RazorFragmentResult : IResult {
private readonly RenderFragment renderFragment;
public RazorFragmentResult(RenderFragment renderFragment) {
this.renderFragment = renderFragment;
}
public async Task ExecuteAsync(HttpContext httpContext) {
var serviceProvider = httpContext.RequestServices.GetService<IServiceProvider>()!;
var loggerFactory = httpContext.RequestServices.GetService<ILoggerFactory>()!;
using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
// 此处还是得用component,虽然用以来代码来仅渲染无状态(component)的 RenderFragment 效率肯定不好,
// 但是等以后 component 支持Native AOT了,就可以引入比较复杂的业务组件逻辑。
var ps = ParameterView.FromDictionary(new Dictionary<string, object?>() {
{ nameof(Entry.Child), renderFragment }
});
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () => {
var output = await htmlRenderer.RenderComponentAsync<Entry>(ps);
return output.ToHtmlString();
});
await Results.Text(html, "text/html; charset=utf-8").ExecuteAsync(httpContext);
}
}
}
public class Entry : ComponentBase {
[Parameter]
public required RenderFragment Child { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder) {
Child.Invoke(builder);
}
}
但是其中 output.ToHtmlString() 以后还是可以再优化一下,比如直接写入 Response.BodyWriter 而不是先生成字符串再返回。
因为,我爬下来的菜谱都保存成 json 文件,只有3000个左右,所以我都是直接全部加载在内存中操作,方便写一些不方便在 LiteDb/Sqlite 里写的查询。所以,还需要对 json 的序列化做一些兼容 Native AOT 的配置:
// 只需要把根对象标记在此即可,source generator会自动把引用到的所有对象自动处理
[JsonSerializable(typeof(RecipeData))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
这样我就可以如下使用了:
var recipe = JsonSerializer.Deserialize(File.ReadAllText(f), AppJsonSerializerContext.Default.RecipeData).data
csproj文件的配置如下:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Coolking</RootNamespace>
<ServerGarbageCollection>false</ServerGarbageCollection>
<PublishAot>true</PublishAot> // 重点
<PublishLzmaCompressed>true</PublishLzmaCompressed>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
<ItemGroup>
<None Include="Recipes\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="PublishAotCompressed" Version="1.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
</ItemGroup>
</Project>
最后
总体的效果还是符合预期的,在VSCode 里装好 htmx, tailwindcss 和 csharp 的插件,开发起来也挺顺,不过关于很多 AOT 的 warning 还是挺烦的,因为你看见它就说明可能生成的最终程序不能用,所以需要尽量避免调用有这种提示的方法。期待dotnet 8正式版的发布!