Start Debugging

Native AOT mit ASP.NET Core Minimal APIs verwenden

Eine vollständige .NET-11-Anleitung zum Ausliefern einer ASP.NET Core Minimal API mit Native AOT: PublishAot, CreateSlimBuilder, quellgenerierte JSON-Serialisierung, die AddControllers-Einschränkung, IL2026-/IL3050-Warnungen und EnableRequestDelegateGenerator für Bibliotheksprojekte.

Um eine ASP.NET Core Minimal API mit Native AOT auf .NET 11 auszuliefern, setzen Sie <PublishAot>true</PublishAot> in der .csproj, bauen Sie den Host mit WebApplication.CreateSlimBuilder statt CreateBuilder und registrieren Sie einen JsonSerializerContext-Source-Generator über ConfigureHttpJsonOptions, sodass jeder Anfrage- und Antworttyp ohne Reflection erreichbar ist. Alles, was nicht Minimal API oder gRPC ist, einschließlich AddControllers, Razor, SignalR-Hubs und EF-Core-Querytrees über POCO-Graphen, erzeugt beim Publish IL2026- oder IL3050-Warnungen und verhält sich zur Laufzeit unvorhersehbar. Diese Anleitung läuft den vollständigen Weg auf Microsoft.NET.Sdk.Web mit .NET 11 SDK und C# 14 ab, einschließlich der Teile, die das neue Projekt-Template vor Ihnen verbirgt, und endet mit einer Checkliste, mit der Sie bestätigen können, dass das veröffentlichte Binary tatsächlich keinen JIT braucht.

Die zwei Projektflags, die alles ändern

Eine Native-AOT-Minimal-API ist ein normales ASP.NET-Core-Projekt mit zwei zusätzlichen MSBuild-Eigenschaften. Die erste schaltet den Publish-Pfad von CoreCLR auf ILC, den AOT-Compiler. Die zweite weist den Analyzer an, Ihren Build in dem Moment zum Scheitern zu bringen, in dem Sie nach einer API greifen, die Codegenerierung zur Laufzeit erfordert.

<!-- .NET 11, C# 14 -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

PublishAot macht die Schwerstarbeit. Es aktiviert die Native-AOT-Kompilierung während dotnet publish und schaltet vor allem auch die Analyse für dynamischen Code im Build und im Editor an, sodass IL2026 (RequiresUnreferencedCode) und IL3050 (RequiresDynamicCode) bereits in der IDE aufleuchten, bevor Sie überhaupt zum Publish kommen. Microsoft dokumentiert das in der Native-AOT-Deployment-Übersicht.

InvariantGlobalization ist nicht zwingend nötig, aber ich lasse es bei neuen Projekten aktiv. Native AOT bündelt die ICU-Datendatei unter Linux standardmäßig nicht, und ein kulturabhängiger Stringvergleich über einen Anfrage-Payload wirft in Produktion CultureNotFoundException, wenn man es vergisst. Liefern Sie Globalisierung explizit aus, wenn Sie sie tatsächlich brauchen.

Das neue Projekt-Template (dotnet new webapiaot) fügt außerdem <StripSymbols>true</StripSymbols> und <TrimMode>full</TrimMode> hinzu. TrimMode=full ist durch PublishAot=true impliziert, also redundant, aber harmlos zu behalten.

CreateSlimBuilder ist nicht CreateBuilder mit kleinerem Namen

Die größte Verhaltensänderung zwischen einer normalen Minimal API und einer AOT-Variante ist der Host-Builder. WebApplication.CreateBuilder verdrahtet jedes gängige ASP.NET-Core-Feature: HTTPS, HTTP/3, Hosting-Filter, ETW, umgebungsvariablenbasierte Konfigurationsanbieter und einen Standard-JSON-Serializer mit Reflection-Fallback. Vieles davon ist nicht Native-AOT-kompatibel, daher verwendet das AOT-Template CreateSlimBuilder, dokumentiert in der Referenz ASP.NET Core support for Native AOT und in .NET 11 unverändert.

// .NET 11, C# 14
// PackageReference: Microsoft.AspNetCore.OpenApi 11.0.0
using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

var app = builder.Build();

var todos = app.MapGroup("/todos");
todos.MapGet("/", () => Todo.Sample);
todos.MapGet("/{id:int}", (int id) =>
    Todo.Sample.FirstOrDefault(t => t.Id == id) is { } t
        ? Results.Ok(t)
        : Results.NotFound());

app.Run();

public record Todo(int Id, string Title, bool Done)
{
    public static readonly Todo[] Sample =
    [
        new(1, "Try Native AOT", true),
        new(2, "Profile cold start", false),
    ];
}

[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(Todo[]))]
internal partial class AppJsonContext : JsonSerializerContext;

Drei Dinge an dem Beispiel sind wichtig und leicht zu übersehen:

  1. CreateSlimBuilder registriert standardmäßig kein HTTPS und kein HTTP/3. Der Slim-Builder enthält JSON-Datei-Konfiguration für appsettings, User Secrets, Konsolen-Logging und Logging-Konfiguration, lässt aber bewusst Protokolle weg, die typischerweise von einem TLS-Termination-Proxy übernommen werden. Wenn Sie das ohne Nginx, Caddy oder YARP davor laufen lassen, fügen Sie explizit Kestrel.Endpoints-Konfiguration hinzu.
  2. MapGroup("/todos") ist in derselben Datei wie Program.cs in Ordnung. Verschieben Sie es in eine andere Datei desselben Projekts, und Sie sehen IL3050, sofern Sie nicht zusätzlich den Request-Delegate-Generator einschalten. Dazu gleich mehr.
  3. Der JSON-Context fügt sich an Index 0 der Resolver-Kette ein, hat also Vorrang vor dem reflection-basierten Standard-Resolver. Ohne Insert(0, ...) kann der Antwort-Writer von ASP.NET Core für Typen, die Sie nicht registriert haben, weiterhin auf Reflection zurückfallen, was zur Laufzeit im AOT-Modus eine NotSupportedException erzeugt.

JSON: Der einzige Serializer ist der, den Sie generieren

System.Text.Json hat zwei Modi. Der Reflection-Modus läuft zur Laufzeit über jede Property, was sowohl mit Trimming als auch mit AOT inkompatibel ist. Der Source-Generation-Modus emittiert zur Compile-Zeit Metadaten für jeden registrierten Typ und ist vollständig AOT-sicher. Native AOT erfordert Source Generation für jeden Typ, den Sie in einen HTTP-Request-Body hinein- oder aus ihm herausreichen. Das ist die größte Quelle für “kompiliert sauber, wirft zur Laufzeit”-Bugs.

Der minimal lebensfähige JsonSerializerContext:

// .NET 11, C# 14
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(Todo[]))]
[JsonSerializable(typeof(List<Todo>))]
[JsonSerializable(typeof(ProblemDetails))]
internal partial class AppJsonContext : JsonSerializerContext;

Jeder Typ, der über die Leitung geht, muss in dieser Klasse stehen, einschließlich der T[]- und List<T>-Formen, die Sie tatsächlich aus Minimal-API-Endpunkten zurückgeben. Der Antwort-Writer von ASP.NET Core wickelt IEnumerable<T> im AOT-Modus nicht für Sie aus. Wenn Sie Enumerable.Range(...).Select(...) zurückgeben, registrieren Sie IEnumerable<Todo> mit oder materialisieren Sie zuerst in ein Array.

Drei Fallen, die selbst sorgfältige Autoren beißen:

Andrew Locks Tour durch den Minimal-API-Source-Generator und Martin Costellos Walkthrough zu JSON-Source-Generatoren mit Minimal APIs decken das ursprüngliche .NET-8-Design ab, das .NET 11 unverändert übernimmt.

Bibliotheksprojekte brauchen EnableRequestDelegateGenerator

Der Minimal-API-Source-Generator verwandelt jeden MapGet(...), MapPost(...) und so weiter zur Compile-Zeit in ein streng typisiertes RequestDelegate. Wenn PublishAot=true gesetzt ist, aktiviert das SDK diesen Generator automatisch für das Webprojekt. Es aktiviert ihn nicht für Bibliotheksprojekte, die Sie referenzieren, auch wenn diese Bibliotheken über Erweiterungsmethoden selbst MapGet aufrufen.

Das Symptom sind IL3050-Warnungen beim Publish, die auf Ihre Bibliothek zeigen und sich beklagen, dass MapGet Reflection auf einem Delegate ausführt. Der Fix ist eine MSBuild-Eigenschaft in der Bibliothek:

<!-- Library project that defines endpoint extension methods -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <IsAotCompatible>true</IsAotCompatible>
    <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
  </PropertyGroup>
</Project>

IsAotCompatible=true schaltet alle vier Trim- und AOT-Analyzer ein, und EnableRequestDelegateGenerator=true lenkt die Map*-Aufrufe der Bibliothek auf den generierten Pfad. Ohne Letzteres kann die Bibliothek als AOT-kompatibel markiert sein und trotzdem IL3050 emittieren, weil der Analyzer die Delegate.DynamicInvoke-artigen Aufrufstellen in RouteHandlerBuilder so sieht. Das dotnet/aspnetcore-Team verfolgt die rauen Kanten in Issue #58678.

Wenn die Bibliothek sowohl in AOT- als auch in Nicht-AOT-Projekten wiederverwendbar sein soll, lassen Sie die Eigenschaft drin. Der Generator fällt in regulären CoreCLR-Builds anmutig auf den Laufzeitpfad zurück.

Was Sie aufgeben müssen

Native AOT ist kein Schalter, den Sie an einem fertigen MVC-Monolithen umlegen. Die Liste der nicht unterstützten Subsysteme ist kurz, aber tragend.

Das Thinktecture-Team hat eine lesbare Übersicht der unterstützten und nicht unterstützten Szenarien veröffentlicht, auf die ich beim Onboarding eines Teams in Native AOT zurückgreife.

IL2026 und IL3050 wie ein Profi lesen

Die zwei Warnungen, mit denen Sie kämpfen werden, sind leicht zu verwechseln:

Beide werden vom IsAotCompatible-Analyzer aufgefangen, aber nur IL2026 wird vom reinen Trimming-Analyzer angezeigt. Ich führe während der Entwicklung immer einen einmaligen Publish nach bin\publish von der Kommandozeile aus, um beide auf einmal sichtbar zu machen:

dotnet publish -c Release -r linux-x64 -o ./publish

Eine zweite Falle: dotnet/sdk Discussion #51966 verfolgt ein wiederkehrendes Problem, bei dem Visual Studio 2026 und dotnet build IL2026 / IL3050 in manchen Konfigurationen verschlucken, dotnet format sie aber zeigt. Wenn Ihr Team Visual Studio nutzt, ergänzen Sie einen CI-Schritt, der dotnet publish gegen die AOT-Laufzeit ausführt, sodass eine übersehene Warnung die Pipeline scheitern lässt.

Wenn Sie eine Reflection nutzende API nicht vermeiden können, lässt sich die Warnung an der Aufrufstelle mit den Attributen [RequiresUnreferencedCode] und [RequiresDynamicCode] an der umhüllenden Methode unterdrücken, wodurch sich die Anforderung nach oben fortpflanzt. Tun Sie das nur, wenn Sie wissen, dass die konsumierenden Codepfade nicht auf der AOT-Publish-Oberfläche liegen. Eine Unterdrückung innerhalb eines Endpunkt-Handlers ist fast immer falsch.

Verifizieren, dass das Binary tatsächlich funktioniert

Ein sauberer Publish beweist nicht, dass die App unter AOT startet. Drei Prüfungen, die ich durchführe, bevor ich Sieg verkünde:

# 1. The output is a single static binary, not a CoreCLR loader.
ls -lh ./publish
file ./publish/MyApi
# Expected on Linux: "ELF 64-bit LSB pie executable ... statically linked"

# 2. The runtime never loads the JIT.
LD_DEBUG=libs ./publish/MyApi 2>&1 | grep -E "libcoreclr|libclrjit"
# Expected: empty output. If libclrjit.so loads, you accidentally shipped a runtime fallback.

# 3. A real request round-trips with the source generator.
./publish/MyApi &
curl -s http://localhost:5000/todos | head -c 200

Die dritte Prüfung ist die wichtige. Der klassische Fehlerfall ist “kompiliert, publisht, startet, gibt bei der ersten Anfrage 500 zurück”, weil ein Rückgabetyp im JSON-Context fehlt. Klopfen Sie jeden Endpunkt mindestens einmal mit einem repräsentativen Payload ab, bevor Sie ausliefern.

Für Container-Deployments ist Build mit --self-contained true unter PublishAot=true impliziert. Die Ausgabe ./publish/MyApi plus zugehörige .dbg-Datei ist die gesamte Deploy-Einheit. Eine typische Minimal API in .NET 11 landet bei 8-12 MB stripped, gegenüber 80-90 MB eines self-contained CoreCLR-Publish.

Verwandte Anleitungen auf Start Debugging

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück