slaveOftime

dotnet native AOT 加 minimal API 加 blazor 初体验

2023-06-26

最近 dotnet 8 preview 5 发布,想尝试一下在 native AOT 模式下,asp.net core minimal API + blazor 的效果。

先看结果:

dotnet 8 preview 5 AOT

生成单个可执行文件5.9MB,内存占用 33MB(加载了所有菜单数据)。

网站的运行效果如下(因为菜谱都是从AKitchen爬的,所以就不提供我的菜谱的链接了,仅我个人使用):

recipe home page

目前 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正式版的发布!

Do you like this post?