Reflection Taramasından Source Generator’a: Anka’ya AOT Dostu Routing Eklemek

Önceki yazıyı şu cümleyle bitirmiştim: bir sonraki yazıda reflection taramasını source generator ile değiştirmeyi adım adım ele alacağım. Bu yazı o sözün karşılığı.

Konuyu soyut bir “handler registry” üzerinden değil, Anka’nın en çok sorulan eksikliği üzerinden anlatacağım: routing. Anka’da routing yok ve bu bilinçli bir karar. Ama “attribute ile route tanımlamak istiyorum” diyen birinin bunu Anka’nın çekirdeğine dokunmadan ve Native AOT’yi bozmadan nasıl yapabileceği, source generator’ın neden var olduğunu anlatmak için bulabileceğim en iyi örnek.

Önceki yazıda olduğu gibi burada da her çıktı gerçekten alındı: .NET 8 SDK (8.0.203), osx-arm64, Native AOT publish. Tahmin yok. Demo’nun tamamı, yazıdaki ölçümleri tekrar üretmek için gereken araçlarla birlikte GitHub’da: anka-source-generated-routing.

Senaryo: attribute routing

Hedeflediğimiz kullanım şu:

[Route("/ping")]
public sealed class PingHandler : IRouteHandler
{
    private static readonly byte[] Body = "pong"u8.ToArray();

    public ValueTask HandleAsync(HttpRequest req, HttpResponseWriter res, CancellationToken ct)
        => res.WriteAsync(200, Body, "text/plain"u8.ToArray(), req.IsKeepAlive, ct);
}

RouteAttribute ve IRouteHandler, Anka’yı referans alan küçük bir kütüphanede duruyor. Anka’nın kendisinde hiçbir şey değişmiyor; o hâlâ tek bir RequestHandler delegesi alıyor. Demo’da üç handler var: /ping, /health ve /version.

Klasik çözüm: assembly’yi tara

.NET’te bu problemin refleks cevabı belli. Assembly’yi tara, [Route] taşıyan tipleri bul, Activator ile üret:

var routes = new Dictionary<string, IRouteHandler>(StringComparer.Ordinal);
foreach (var type in typeof(Program).Assembly.GetTypes())
{
    var route = type.GetCustomAttribute<RouteAttribute>();
    if (route is null || !typeof(IRouteHandler).IsAssignableFrom(type))
    {
        continue;
    }

    routes[route.Path] = (IRouteHandler)Activator.CreateInstance(type)!;
}

var server = new Server(async (req, res, ct) =>
{
    if (routes.TryGetValue(req.Path, out var handler))
    {
        await handler.HandleAsync(req, res, ct);
        return;
    }

    await res.WriteAsync(404, keepAlive: req.IsKeepAlive, cancellationToken: ct);
}, port: port);

JIT’te sorunsuz çalışıyor:

[routes] discovered=3 elapsed_us=1490

Aynı kodu PublishAot=true ile publish ettiğimde ILC dört uyarı verdi:

KodKaynakTetikleyenSöylediği
IL2026Roslyn analyzerAssembly.GetTypes()Metot [RequiresUnreferencedCode] ile işaretli; trimming sonrası tipler silinmiş olabilir.
IL2026ILCAssembly.GetTypes()Aynı uyarı, bu kez ILC’nin derlenmiş IL üzerindeki kendi analizinden.
IL2072Roslyn analyzerActivator.CreateInstance(type)type değeri PublicParameterlessConstructor gereksinimini taşımıyor.
IL2062ILCActivator.CreateInstance(type)Değer statik olarak belirlenemiyor; gereksinim karşılanamayabilir.

İki kaynaktan gelmeleri önceki yazıdaki #pragma tuzağının canlı örneği: #pragma ile analyzer satırlarını susturursanız, ILC satırları publish’te yine gelir.

Uyarıları görmezden gelip binary’yi çalıştırdım:

$ ./App.Reflection --probe
[routes] discovered=0 elapsed_us=346

$ curl -s -o /dev/null -w "%{http_code}\n" localhost:18101/ping
404

Sıfır route. Exception yok, crash yok, log’da tek bir hata satırı yok. Sunucu ayağa kalkıyor, health check’iniz / üzerinden bakıyorsa yeşil bile görünebilir, ama her istek 404 dönüyor. Önceki yazıda Type.GetType‘ın AOT’de sessizce null döndüğünü göstermiştim; bu onun toplu hâli.

Sebebi basit. Handler sınıflarına kodun hiçbir yerinden statik bir referans yok; onlara sadece GetTypes() üzerinden, çalışma zamanında ulaşılıyor. ILC uygulamanın tamamını analiz ederken bu sınıfları kullanılmayan kod olarak görüyor ve binary’ye almıyor. GetTypes() da binary’de olmayan bir tipi döndüremez.

Neden [DynamicallyAccessedMembers] burada kurtarmıyor?

Önceki yazıdaki Desen 3’ü hatırlayan biri şunu sorabilir: Type parametresini annotasyonla işaretlersek olmaz mı?

Olmaz, çünkü annotasyon compiler’a “bu tipin constructor’ını koru” der ama hangi tip olduğunu söylemez. O bilgiyi çağıran noktadaki typeof(PingHandler) sağlar. GetTypes() ise tanımı gereği “hangi tipler varsa hepsini ver” demek. Compiler’ın hangi tipleri koruması gerektiğini bilebileceği bir nokta yok. Annotasyon sorumluluğu çağırana taşır, ama burada çağıran da bilmiyor.

Tek çıkış, tip listesini derleme zamanında açık hâle getirmek. Önceki yazıdaki açık kayıt deseni tam olarak buydu:

var routes = new Dictionary<string, IRouteHandler>
{
    ["/ping"]    = new PingHandler(),
    ["/health"]  = new HealthHandler(),
    ["/version"] = new VersionHandler(),
};

Üç handler için bu yeterli, hatta bence en doğru cevap. Ama handler sayısı yüzlere çıktığında bu listeyi elle tutmak hem sıkıcı hem hataya açık. Yeni bir handler yazıp listeye eklemeyi unutmak, yukarıdaki 404 problemini bu kez JIT’te de yaşatır. Source generator’ın işi, bu listeyi sizin yerinize ve derleme zamanında yazmak.

Önce hedef: üretilecek kodu elle yazın

Generator yazmaya başlamadan önce kendime hep aynı soruyu soruyorum: bu kodu elle yazsaydım nasıl yazardım? Generator’ın çıktısı, iyi bir mühendisin elle yazacağı koddan farklı olmamalı. Demo’daki generator’ın ürettiği dosya birebir şu:

// <auto-generated/>
#nullable enable
namespace Anka.Routing.Generated;

internal static class RouteTable
{
    private static readonly global::Demo.Handlers.PingHandler s_h0 = new();
    private static readonly global::Demo.Handlers.HealthHandler s_h1 = new();
    private static readonly global::Demo.Handlers.VersionHandler s_h2 = new();

    public const int Count = 3;

    public static bool TryDispatch(global::Anka.HttpRequest request, global::Anka.HttpResponseWriter response,
                                   global::System.Threading.CancellationToken ct, out global::System.Threading.Tasks.ValueTask task)
    {
        var path = request.PathBytes;
        switch (path.Length)
        {
            case 5:
                if (path.SequenceEqual("/ping"u8)) { task = s_h0.HandleAsync(request, response, ct); return true; }
                break;
            case 7:
                if (path.SequenceEqual("/health"u8)) { task = s_h1.HandleAsync(request, response, ct); return true; }
                break;
            case 8:
                if (path.SequenceEqual("/version"u8)) { task = s_h2.HandleAsync(request, response, ct); return true; }
                break;
        }

        task = default;
        return false;
    }
}

Burada üç bilinçli tercih var:

  • Önce uzunluğa göre switch, sonra byte karşılaştırması. Bu, Anka’nın HttpMethodParser‘ında HTTP method’larını çözerken kullandığım desenin aynısı.
  • Karşılaştırma PathBytes üzerinden, UTF-8 literal’lerle yapılıyor. Reflection versiyonu req.Path kullanıyordu ve o property her istekte path’i string‘e çeviriyor. Yani reflection versiyonu yalnızca AOT’yi değil, Anka’nın steady-state’te sıfır allocation hedefini de bozuyordu. Üretilen kodda istek başına allocation yok.
  • Handler’lar static readonly alanlarda tek sefer üretiliyor. Çalışma zamanında Activator yok, sözlük yok, keşif yok.

Kullanım tarafı da tek satıra iniyor:

var server = new Server((req, res, ct) =>
    RouteTable.TryDispatch(req, res, ct, out var task)
        ? task
        : res.WriteAsync(404, keepAlive: req.IsKeepAlive, cancellationToken: ct),
    port: port);

Bu kod compiler için tamamen şeffaf. Hangi handler’ın var olduğunu ILC artık kendisi görüyor, çünkü new PingHandler() ifadesi gerçekten kodda duruyor. Sadece onu biz yazmadık.

Adım 1: Generator projesi

Generator ayrı bir proje ve compiler’ın içinde çalıştığı için netstandard2.0 hedeflemek zorunda:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>
</Project>

Uygulama projesi generator’ı normal bir referans olarak değil, analyzer olarak alıyor:

<ProjectReference Include="../Anka.Routing.Generator/Anka.Routing.Generator.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

ReferenceOutputAssembly="false" önemli: generator derleme zamanında çalışır, çalışma zamanında binary’de yeri yoktur. AOT binary’ye tek bir byte’ı bile girmez.

Microsoft.CodeAnalysis.CSharp sürümü tüketicinin SDK’sındaki Roslyn’den yeni olmamalı. Demo’da .NET 8 SDK kullandığım için 4.8.0 seçtim. Daha yeni bir paket seçerseniz generator eski SDK’larda hiç yüklenmez; üstelik bunu hata olarak değil, bir uyarı ve eksik kod olarak görürsünüz.

Adım 2: Incremental pipeline

Source generator yazarken en sık gördüğüm hata, generator’ı “her tuşa basışta tüm projeyi yeniden tarayan” bir şeye dönüştürmek. IIncrementalGenerator bunun önüne geçmek için var, ama doğru kullanılırsa:

[Generator]
public sealed class RouteGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var routes = context.SyntaxProvider.ForAttributeWithMetadataName(
            "Anka.Routing.RouteAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) => ToModel(ctx));

        context.RegisterSourceOutput(routes.Collect(), static (spc, models) => Emit(spc, models));
    }
}

Burada iki karar performansı belirliyor.

ForAttributeWithMetadataName, CreateSyntaxProvider ile her syntax node’una bakmaktan çok daha ucuz. Roslyn önce syntax düzeyinde attribute adına göre hızlı bir eleme yapıyor, semantic modele yalnızca adaylar için gidiyor. predicate‘e de yalnızca [Route] taşıyan sınıflar geliyor. Reflection versiyonunun çalışma zamanında yaptığı taramanın derleme zamanındaki karşılığı bu, ama compiler’ın zaten tuttuğu bilgi üzerinden.

Model eşitlenebilir olmalı. transform içinde ISymbol ya da SyntaxNode döndürmek cazip ama yanlış. Bunlar her derlemede yeniden oluşturulur, eşitlik karşılaştırması tutmaz ve pipeline’ın cache’i hiç çalışmaz. Üstelik tüm compilation’ı bellekte tutarlar. Demo’da sadece ihtiyacım olan değerleri bir record‘a kopyalıyorum:

internal sealed record RouteModel(
    string FullName,
    string Name,
    string Path,
    bool ImplementsHandler,
    bool HasParameterlessCtor,
    string FilePath,
    TextSpan Span,
    LinePositionSpan LineSpan);

record değer eşitliği sağladığı için, handler’ları değiştirmeyen bir düzenlemede pipeline bir önceki çıktıyı olduğu gibi kullanıyor.

Pratik not: netstandard2.0‘da record kullanmak için küçük bir polyfill gerekiyor, internal static class IsExternalInit;. Bunu bilmeyince alınan derleme hatası ilk seferde kafa karıştırıcı.

Adım 3: Runtime sürprizlerini derleme hatasına çevirmek

Generator’ın bence en az konuşulan değeri kod üretmesi değil, hata üretmesi. Reflection versiyonunda şu üç durumun hepsi runtime’da, çoğu zaman production’da ortaya çıkar:

  • [Route] konmuş ama IRouteHandler implemente etmeyen bir sınıf. Tarama bu sınıfı sessizce atlar.
  • Parametresiz constructor’ı olmayan bir handler. Activator.CreateInstance bunun için MissingMethodException fırlatır.
  • Aynı path’i iki handler’ın tanımlaması. Sözlükte sonra gelen öncekini ezer; hangisinin kazandığı GetTypes()‘ın sırasına bağlıdır.

Generator bu üçünü de derleme sırasında görüyor. Demo’ya bilerek hatalı üç sınıf ekleyip dotnet build çalıştırdığımda:

Bad.cs(10,1): error ANKA001: 'ReportsHandler' has [Route] but does not implement IRouteHandler
Bad.cs(13,1): error ANKA002: 'UsersHandler' must have a public parameterless constructor to be registered
Handlers.cs(6,1): error ANKA003: Route '/ping' is declared by both 'AnotherPing' and 'PingHandler'

Bunlar uyarı değil, hata. Proje derlenmiyor. “AOT’nin sözleşmesi: çalışma zamanında neye ihtiyacın olacağını derleme zamanında söyle” cümlesinin öbür yüzü de bu: compiler’a yeterince bilgi verirseniz, o da size çalışma zamanında öğreneceğiniz şeyleri derleme zamanında söyler.

Diagnostic’ler Emit aşamasında raporlanıyor:

if (!m.HasParameterlessCtor)
{
    spc.ReportDiagnostic(Diagnostic.Create(NoParameterlessCtor, m.Location, m.Name));
    continue;
}

Sonuçlar

İki versiyonu da aynı makinede, .NET 8 SDK (8.0.203) ile Native AOT olarak publish ettim. Keşif süresi, --probe modunda yedi çalıştırmanın ortancası:

Reflection taramasıSource generator
JIT’te bulunan route33
AOT’de bulunan route03
AOT’de GET /ping404200 pong
Publish uyarısı (IL2xxx)40
Binary boyutu (osx-arm64)3.43 MB2.67 MB
Keşif süresi, AOT~78 µs (0 route için)~6 µs
Keşif süresi, JIT~1.5 ms—
İstek başına path allocationvar (req.Path)yok (PathBytes)

Bu tabloyu okurken dürüst olmam gereken bir nokta var: mikrosaniye farkları üç handler’lık bir demo’da anlamlı değil. Generator versiyonundaki ~6 µs keşif bile değil, Stopwatch ve konsol çıktısının maliyeti; ortada keşfedilecek bir şey yok. Tablonun asıl satırları ilk dördü. Reflection versiyonu yanlış çalışıyor, generator versiyonu doğru çalışıyor. Hız bunun yan etkisi.

Bu 764 KB nereden geliyor?

Yazının ilk taslağında bu farkı “büyük ihtimalle reflection altyapısı” diye geçiştirmiştim. Tahmin yok dediğim bir yazıda tahmin bırakmak olmazdı, o yüzden ölçtüm.

Yöntem basit. Aynı uygulamanın, her adımda tek bir reflection API’si eklenmiş varyantlarını Native AOT ile publish ettim, sonra ILC’nin IlcGenerateMapFile=true ile ürettiği map dosyalarını sembol bazında karşılaştırdım. Map dosyası, binary’ye giren her metodu, tipi ve veri bloğunu byte cinsinden boyutuyla listeliyor. Varyantları, dil sürümünü karşılaştırabilmek için .NET 10 SDK ile (hedef yine net8.0) derledim. Mutlak boyutlar 8.0.203’e göre birkaç KB oynuyor, farklar aynı kalıyor: SDK 8.0.203’teki uçtan uca fark da 763.6 KB.

AdımBoyut farkı
Generator → açık Dictionary<string, IRouteHandler> + req.Path + async lambda (reflection yok)+16.6 KB
+ Assembly.GetTypes()+72 byte
+ type.GetCustomAttribute<RouteAttribute>()+763.6 KB
+ IsAssignableFrom ve Activator.CreateInstance~0
Yalnızca Activator.CreateInstance(Type), bilinen tiplerle (GetTypes ve attribute yok)+747 KB

İki sonuç beni şaşırttı.

Birincisi, GetTypes() neredeyse bedava: 72 byte. Mantıklı da, çünkü yalnızca compiler’ın binary’de tuttuğu tipleri döndürebiliyor; kendi başına bir şey getirmiyor.

İkincisi, maliyet API başına değil, eşik şeklinde. GetCustomAttribute ya da Activator, hangisi önce gelirse, reflection’ın çalışma zamanı altyapısını topluca binary’ye çekiyor ve bu ~750 KB tutuyor. Ondan sonra eklenen reflection çağrıları neredeyse hiçbir şey eklemiyor. Yani “biraz reflection” diye bir şey yok: ya hiç yok, ya da altyapının tamamı var.

Map dosyasına göre bu 764 KB’ın dağılımı kabaca şöyle:

BileşenPay
Reflection metadata ve dehydrated tablolar%29
Reflection runtime (System.Reflection.Runtime, Internal.Reflection.*)%24
Runtime type loader (System.Private.TypeLoader)%23
Public reflection yüzeyi (MethodInfo, CustomAttributeData, DefaultBinder…)%9
Diğer%8
Concurrent koleksiyonlar (reflection cache’leri)%4
Sayı parse etme (System.Number, attribute blob’ları ve binder için)%3

Ölçerken düştüğüm tuzak: args.Contains

İlk ölçümde fark 764 KB değil ~530 KB çıkmıştı. Aradaki ~230 KB’ın izini sürünce sebebin reflection’la hiç ilgisi olmayan bir satır olduğunu gördüm:

if (args.Contains("--probe")) return;

.NET 8’in varsayılan dil sürümü C# 12’de bu satır LINQ’in Enumerable.Contains‘ine bağlanıyor ve iki uygulamaya da runtime type loader’ı (~315 KB) çekiyordu. Type loader reflection’ın da ihtiyaç duyduğu bir parça olduğu için, reflection versiyonunun maliyetinin bir kısmı zaten iki tarafta da ödenmiş görünüyordu. Aynı satırı C# 14 ile derlediğimde MemoryExtensions.Contains(ReadOnlySpan<T>)‘e bağlandı ve type loader binary’den tamamen çıktı. Kod değişmeden, sadece dil sürümüyle generator versiyonu 2.98 MB’tan 2.68 MB’a indi.

Buradan çıkardığım ders şu: AOT binary boyutu toplanabilir bir şey değil. Bir özelliğin maliyeti, uygulamanın binary’ye zaten aldığı şeylerle örtüşüyor. “X kütüphanesi 500 KB ekliyor” gibi bir cümle ancak ölçüldüğü uygulama için doğru. Demo’da bu belirsizliği kaldırmak için satırı dil sürümünden bağımsız args is ["--probe"] ile değiştirdim.

Ölçümü kendi projenizde tekrarlamak için kullandığım map karşılaştırma script’i de repoda, tools/mapdiff.py.

Tuzaklar

  • Generator yalnızca mevcut compilation’ı görür. ForAttributeWithMetadataName, referans verdiğiniz başka bir assembly’deki [Route] sınıflarını bulmaz. Handler’larınız birden fazla projeye dağılmışsa ya her projede generator çalıştırıp çıktıları birleştirmeniz ya da referans assembly’lerin metadata’sına ayrıca bakmanız gerekir. Reflection taramasının “birden fazla assembly’yi tara” alışkanlığının bir karşılığı var, ama bedava değil.
  • Üretilen kodu görmeden debug etmeyin. <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> ile çıktı obj/.../generated/ altına yazılır. Yukarıdaki RouteTable.g.cs‘i oradan kopyaladım.
  • Location’ı modelde tutmanın bir bedeli var. Diagnostic için dosya yolu ve span’i modelde tutuyorum. Bu yüzden handler dosyasının üst kısmına bir satır eklemek bile modeli değiştiriyor ve çıktıyı yeniden ürettiriyor. Üç handler’lık bir projede önemsiz. Büyük bir kod tabanında doğrulamaları ayrı bir analyzer’a taşımak daha iyi.
  • Analyzer release tracking. Microsoft.CodeAnalysis.Analyzers, diagnostic tanımlayan her projede RS2008 uyarısı verir ve AnalyzerReleases.Shipped.md / Unshipped.md dosyalarını ister. İlk denemede bastırmıştım; repodaki sürümde bu dosyalar var ve üç kural AnalyzerReleases.Unshipped.md‘de listeleniyor.
  • Generator’ın kendisi de koddur. Test edilmesi, versiyonlanması ve bakımı gerekir. Üç handler için bu yatırım değmez.

Ne zaman yazmazdım?

Anka’nın kendisi routing’i kullanıcıya bırakıyor ve çoğu senaryo için bir if/switch zincirinin yeterli olduğunu söylüyor. Bu yazı o duruşu değiştirmiyor. Demo’daki üç handler için benim tercihim, generator’ın ürettiği kodu elle yazmak olurdu.

Generator şu durumlarda karşılığını veriyor:

  • Handler sayısı elle tutulamayacak kadar çoksa ve sürekli değişiyorsa.
  • Birden fazla ekibin aynı projeye handler eklediği ve “listeye eklemeyi unuttum” hatasının gerçek bir risk olduğu durumlarda.
  • Mevcut, reflection taramasına dayanan bir kod tabanını AOT’ye taşırken, kullanım tarafındaki API’yi ([Route]) değiştirmeden altyapıyı değiştirmek istediğinizde. Bence en güçlü argüman bu: kullanıcılar attribute yazmaya devam ediyor, arka planda reflection’ın yerini derleme zamanında üretilmiş düz kod alıyor.

Sonuç

Reflection taraması ile source generator aynı soruyu soruyor: “Bu uygulamada hangi handler’lar var?” Fark, sorunun ne zaman sorulduğunda. Reflection bunu uygulama her başladığında, compiler’ın göremeyeceği bir yerden soruyor. AOT’de cevap sessizce “hiçbiri” oluyor. Generator ise soruyu bir kez, derleme sırasında, compiler’ın zaten bildiği bilgiyle soruyor ve cevabı düz C# olarak koda yazıyor.

Önceki iki yazıda Anka’nın değerinin öngörülebilirlikte olduğunu, AOT’nin de bir derleme bayrağı değil tasarım disiplini olduğunu söylemiştim. Source generator bu ikisinin buluştuğu yer. Dinamik görünen bir API’yi koruyup, altında compiler’ın satır satır görebildiği statik bir kod bırakıyor.

Bu yazıdaki demo’nun tamamı, CI’da iki versiyonu da Native AOT ile publish edip reflection versiyonunun gerçekten 0 route bulduğunu doğrulayan bir smoke test ile birlikte GitHub’da: anka-source-generated-routing. Anka’nın kaynak kodu GitHub’da, paketi NuGet’te. Kendi projenizde reflection taramasını generator’a taşırken karşılaştığınız bir tuzak varsa yorumlarda paylaşın.

Bir Cevap Yazın

Selçuk Güral | Engineering Lab sitesinden daha fazla şey keşfedin

Okumaya devam etmek ve tüm arşive erişim kazanmak için hemen abone olun.

Okumaya Devam Edin