Mert Özen Her Satırda Daha İleri
Veritabanı Dapper — #16

Dapper.AOT: Source Generator, Interceptor'lar ve Native AOT ile Dapper

Mert Özen 6 Kas 2026 10 dk 3 görüntülenme
Dapper.AOT: Source Generator, Interceptor'lar ve Native AOT ile Dapper

PublishAot açıldığında vanilla Dapper neden dotnet run'da bile patlar? Dapper.AOT 1.1.0 ile kodu build sırasında üretmek: [module: DapperAot], InterceptorsNamespaces, üretilen interceptor kodu, Native AOT publish ve 49,8 ms'den 4,3 ms'ye başlangıç süresi, attribute ile type handler, DAP analyzer'ları ve QueryMultiple gibi kısıtlar.

Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.

Önce Tahmin Et

Serinin en sade programlarından biri: bir tablo, dört kitap, parameter'lı tek bir sorgu. Tek farkı, proje dosyasına Native AOT için bir satır eklemiş olmamız:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
  <PublishAot>true</PublishAot>
</PropertyGroup>
using Dapper;
using Microsoft.Data.Sqlite;

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("""
    create table books (id integer primary key, title text not null, price real not null);
    insert into books (title, price) values
        ('Dune', 12.5), ('Dune Messiah', 10.0),
        ('The Left Hand of Darkness', 11.0), ('The Dispossessed', 13.0);
    """);

var cheap = conn.Query<Book>(
    "select id, title, price from books where price < @maxPrice order by price",
    new { maxPrice = 12.0 });

foreach (var b in cheap)
    Console.WriteLine($"{b.Id} | {b.Title} | {b.Price}");

public class Book
{
    public long Id { get; set; }
    public string Title { get; set; } = "";
    public double Price { get; set; }
}

Henüz publish etmiyoruz, sadece her zamanki gibi dotnet run diyoruz. Ne olur?

Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
   at System.Reflection.Emit.AssemblyBuilder.ThrowDynamicCodeNotSupported()
   ...
   at Dapper.SqlMapper.CreateParamInfoGenerator(Identity identity, Boolean checkForDuplicates, Boolean removeUnused, IList`1 literals) in /_/Dapper/SqlMapper.cs:line 2577

JIT ile çalışan bir programda bile patladı. PublishAot açıldığında SDK, uygulamanın runtimeconfig.json'ına IsDynamicCodeSupported: false yazıyor; böylece Native AOT'ta çıkacak sorunları geliştirirken görüyorsun. Patlayan yer de tanıdık: Dapper'ın parameter kodunu runtime'da IL olarak ürettiği method. Serinin son yazısı bu kodu build sırasına taşıyan Dapper.AOT hakkında.

Dapper Runtime'da Ne Üretiyor?

15. yazıda gördük: Dapper bir sorguyu ilk kez gördüğünde parameter'ları command'a yazan kodu ve satırları object'e dolduran deserializer'ı System.Reflection.Emit ile üretir ve cache'ler. Esnek ve hızlı bir tasarım, ama iki bedeli var. Birincisi soğuk başlangıç: ilk çağrıda reflection ve IL üretimi zaman alır. İkincisi Native AOT ve trimming: derleyici runtime'da üretilecek kodu göremez, Native AOT'ta da IL üretmek zaten mümkün değil.

Aynı programı gerçekten dotnet publish -r osx-arm64 -c Release ile yayınladığımda derleyici uyardı ve çıkan native binary aynı yerde patladı:

warning IL2104: Assembly 'Dapper' produced trim warnings. For more information see https://aka.ms/il2104
warning IL3053: Assembly 'Dapper' produced AOT analysis warnings.
Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
   at System.Reflection.Emit.ReflectionEmitThrower.ThrowPlatformNotSupportedException() + 0x30
   at Dapper.SqlMapper.CreateParamInfoGenerator(SqlMapper.Identity, Boolean, Boolean, IList`1) + 0x148

Dapper.AOT: Kodu Build Sırasında Üretmek

Dapper.AOT aynı kodu build sırasında, bir source generator ile üretir. Ürettiği method'ları senin conn.Query<Book>(...) çağrılarının yerine geçirmek için de .NET 8 SDK'da deneysel olarak gelen, sonraki SDK'larda kararlı hâle gelen interceptor'ları kullanır: derleyici, belirli bir dosyanın belirli satır ve kolonundaki çağrıyı, generator'ın yazdığı başka bir method'a yönlendirir. Kaynak kodun değişmez; değişen, derlenmiş çağrının hedefi. Bu yazıdaki örnekler Dapper.AOT 1.1.0 (Eylül 2026) ile çalıştı. Paket yalnızca C# içindir; analyzer'ları kod üretmeden isteyenler için Dapper.Advisor adında kardeş bir paket var.

Kurulum üç adım ve her adımın kendi uyarısı var. Önce paketi ekliyoruz:

dotnet add package Dapper.AOT --version 1.1.0
Program.cs(6,6): warning DAP005: 2 candidate Dapper methods detected, but none have Dapper.AOT enabled (https://aot.dapperlib.dev/rules/DAP005)

Paket kendiliğinden hiçbir şeyi değiştirmiyor; sadece yardım edebileceği iki çağrı bulduğunu söylüyor. İkinci adım açıkça izin vermek. [module: DapperAot] bütün projeyi açar; istersen type ya da method düzeyinde [DapperAot] ve [DapperAot(false)] ile daraltabilirsin. Attribute using'lerden sonra gelmeli:

using Dapper;
using Microsoft.Data.Sqlite;

[module: DapperAot]
obj/Debug/net10.0/Dapper.AOT.Analyzers/Dapper.CodeAnalysis.DapperInterceptorGenerator/Bookstore.Aot.generated.cs(8,10): error CS9137: The 'interceptors' feature is not enabled in this namespace. Add '<InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>' to your project.

Bu hata Dapper.AOT'tan değil derleyiciden geliyor: interceptor'lar her namespace için ayrıca izin istiyor. Üçüncü adım, mesajın söylediğini yapmak:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>
  <PublishAot>true</PublishAot>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

.NET 8 SDK bu property'yi tanımaz, onun adı InterceptorsPreviewNamespaces. Projen hâlâ .NET 8 SDK ile derleniyorsa o satırı da ekle; ikisini birden yazmak zararsız. Dapper.AOT'un build için .NET 8 SDK veya üstüne ihtiyacı var, ama hedef framework herhangi biri olabilir: paket net48 ve netstandard2.0'dan net10.0'a kadar hedefliyor. EmitCompilerGeneratedFiles zorunlu değil; üretilen kodu diskte görmek için ekledim.

Üretilen Koda Bakmak

Build'den sonra obj/ altında generator'ın yazdığı dosya duruyor. 217 satırlık dosyadan sorgumuza karşılık gelen kısmı kısaltarak gösteriyorum (dosya yolunu ve gövdedeki doğrulama satırlarını ... ile kestim):

[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(".../Program.cs", 18, 18)]
internal static global::System.Collections.Generic.IEnumerable<global::Book> Query1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType)
{
    ...
    return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, RowFactory0.Instance);
}

InterceptsLocation, Program.cs'in 18. satır 18. kolonundaki Query çağrısını bu method'a bağlıyor. CommandFactory0 anonymous object'teki maxPrice'ı parameter'a yazan class, RowFactory0 da id, title, price kolonlarını Book'a dolduran class. 15. yazıda runtime'da üretildiğini söylediğimiz iki parça artık düz C# olarak dosyada; okunabiliyor, debug edilebiliyor ve trimmer tarafından görülebiliyor.

Native AOT Publish

Bundan sonrası tek komut:

dotnet publish -r osx-arm64 -c Release -o out

Bu kez ne IL2104 ne IL3053 uyarısı geldi: bütün çağrılar interceptor'a gittiği için Dapper'ın reflection kodu kullanılmıyor ve trimmer onu güvenle atabiliyor. out/ klasöründe 3,2 MB'lık bir Mach-O binary ve SQLite'ın native library'si libe_sqlite3.dylib var; .NET runtime'ı kurulu olmayan bir makinede de çalışır. Microsoft.Data.Sqlite bu testte Native AOT ile sorunsuz çalıştı; provider'ının AOT uyumunu kendin doğrula, Dapper.AOT bunu senin yerine garanti edemez.

Başlangıç süresine bakalım. Aynı programın üç hâlini 31'er kez çalıştırıp sürecin toplam süresinin medyanını aldım:

vanilla jit 49.8
aot jit     34.0
aot native  4.3

Değerler milisaniye. Vanilla Dapper'la JIT'te 49,8 ms; aynı kod Dapper.AOT ile JIT'te 34,0 ms, çünkü ilk sorguda IL üretimi yok; native binary ise 4,3 ms. Serverless fonksiyonlar, CLI araçları ve sık ölçeklenen container'lar için bu fark soğuk başlangıcın kendisi.

Type Handler'lar Artık Attribute

11. yazıdaki type handler'ları hatırla: SqlMapper.AddTypeHandler ile runtime'da, süreç genelinde kaydediliyordu. Generator build sırasında çalıştığı için o çağrıyı göremez ve bunu açıkça söylüyor:

warning DAP053: SqlMapper.AddTypeHandler for 'Guid' is invisible to Dapper.AOT, so generated code binds the value without it; declare it instead with [module: TypeHandler(typeof(Guid), typeof(GuidHandler))] (https://aot.dapperlib.dev/rules/DAP053)

Çözüm kaydı bildirime çevirmek. 1.1.0'da vanilla SqlMapper.TypeHandler<T> handler'ını olduğu gibi verebiliyorsun; generated code onu Dapper.AOT'un IDbValueHandler<T> interface'ine uyarlıyor:

using System.Data;
using Dapper;
using Microsoft.Data.Sqlite;

[module: DapperAot]
[module: TypeHandler(typeof(Guid), typeof(GuidHandler))]

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("""
    create table books (id integer primary key, isbn_id text not null, title text not null, price real not null);
    insert into books (isbn_id, title, price) values
        ('3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13', 'Dune', 12.5),
        ('9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48', 'Dune Messiah', 10.0),
        ('c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60', 'The Left Hand of Darkness', 11.0);
    """);

var cheap = conn.Query<Book>(
    "select id, isbn_id, title, price from books where price < @maxPrice order by price",
    new { maxPrice = 12.0 });

foreach (var b in cheap)
    Console.WriteLine($"{b.Id} | {b.IsbnId} | {b.Title} | {b.Price}");

public class Book
{
    public long Id { get; set; }
    public Guid IsbnId { get; set; }
    public string Title { get; set; } = "";
    public double Price { get; set; }
}

public sealed class GuidHandler : SqlMapper.TypeHandler<Guid>
{
    public override Guid Parse(object value) => Guid.Parse((string)value);
    public override void SetValue(IDbDataParameter parameter, Guid value) => parameter.Value = value;
}
2 | 9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48 | Dune Messiah | 10
3 | c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60 | The Left Hand of Darkness | 11

Bu çıktı native binary'den. Bildirimsel kayıt bir derleme (assembly) için geçerli; süreç geneli değil, açılış sırasına bağlı değil ve kod incelemesinde görünür. 11. yazıdaki "handler'ı ilk sorgudan sonra kaydetmek" tuzağı burada var olamıyor.

Bir davranış farkına dikkat: isbn_id kolonu, MatchNamesWithUnderscores hiç açılmadığı hâlde IsbnId'ye eşlendi. Dapper.AOT'un ürettiği mapping kodu, kaynağındaki StringHashing yardımcısıyla isimleri alt çizgi ve boşlukları atıp küçük harfe çevirerek karşılaştırıyor. Vanilla Dapper'da bu ayar kapalıyken aynı kolon boş kalırdı. Geçiş yaparken sessizce dolmaya başlayan property'ler olabilir; testlerin bunu yakalasın.

Analyzer'lar: DAP Kuralları

Dapper.AOT'un ikinci yüzü DAP önekli analyzer'lar. Bir kısmı AOT'la ilgili, bir kısmı doğrudan SQL'inle. SQL analizi yalnızca dili bildiğinde derinleşiyor; şu an gelişmiş analiz sadece SQL Server için var ve connection tipi SqlConnection olduğunda kendiliğinden devreye giriyor. 14. yazıda Dommel'in ürettiği select *'e takılmıştık; aynı kalıbı bir SQL Server sorgusunda yazınca:

public static Book? Find(SqlConnection conn, int id) =>
    conn.QueryFirstOrDefault<Book>("select * from dbo.books where id = @id", new { id });
BookQueries.cs(9,48): warning DAP219: SELECT columns should be specified explicitly (https://aot.dapperlib.dev/rules/DAP219)

Desteklenmeyen kalıplar için de kurallar var. QueryMultiple ve CommandDefinition alan overload'lar şu an intercept edilmiyor. Normal bir JIT projesinde bunlar yalnızca bilgi seviyesinde bildiriliyor, build çıktısında görünmüyor bile. PublishAot açıldığında uyarıya yükseliyorlar, çünkü artık gerçek bir çökme riski taşıyorlar:

warning DAP001: The Dapper method 'SqlMapper.QueryMultiple(IDbConnection, string, object?, IDbTransaction?, int?, CommandType?)' is not currently supported by Dapper.AOT (https://aot.dapperlib.dev/rules/DAP001)
warning DAP057: 'QuerySingle' passes its SQL inside a CommandDefinition, which Dapper.AOT cannot read at build time; use the overload that takes the SQL directly. This call-site is left on vanilla Dapper, which will not work under native AOT (https://aot.dapperlib.dev/rules/DAP057)

DAP057'nin sebebi mantıklı: generator SQL'i build sırasında okuyarak karar veriyor, CommandDefinition ise SQL'i runtime'da oluşturulan bir struct'ın içinde taşıyor. 06. yazıda CancellationToken için CommandDefinition kullanmıştık; AOT'a geçecek kodda bu bir takas demek.

Kısıtlar: Neler Henüz Yok?

DAP001 uyarısını ciddiye almazsan ne olacağını denedim. Aynı programa bir QueryMultiple ekleyip native publish ettim:

using var grid = conn.QueryMultiple("select count(*) from books; select id, isbn_id, title, price from books");
Console.WriteLine(grid.ReadSingle<long>());
Console.WriteLine(grid.Read<Book>().Count());
2 | 9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48 | Dune Messiah | 10
3 | c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60 | The Left Hand of Darkness | 11
3
Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.

Intercept edilen sorgu çalıştı, QueryMultiple vanilla Dapper'a düştü. İlk grid basit bir long olduğu için IL gerektirmedi ve okundu; ikinci grid Book için deserializer üretmeye çalışınca patladı. Publish de IL2104 ve IL3053 uyarılarını yeniden verdi, çünkü artık Dapper'ın reflection kodu kullanılıyor.

Dokümantasyona ve kaynak koda göre diğer kısıtlar şunlar. SqlMapper.Settings ve AddTypeHandler gibi runtime ayarları yok sayılıyor. Query(Type, ...) gibi tip'i runtime'da alan API'ler desteklenmiyor (DAP056). Private tipler desteklenmiyor. Yalnızca doğrudan, satır içinde yazılmış çağrılar intercept ediliyor; Dapper'ı saran kendi yardımcı method'un içindeki çağrı, generator'ın SQL'i görebildiği yerde değilse vanilla'ya düşer. Dokümantasyonun SSS sayfası tam kapsam sorusuna hâlâ "Not yet - not by a long way" diye cevap veriyor ve büyük harflerle kodunu dikkatle test etmeni istiyor. Ben de Dapper.AOT'u yerinde bir değişim değil, eksikleri olan, prod'da kullanılabilir bir eklenti olarak görüyorum.

Sık Düşülen Tuzaklar

Paketi ekleyip bittiğini sanmak. [module: DapperAot] ve InterceptorsNamespaces olmadan hiçbir şey değişmez; DAP005 uyarısı tam olarak bunu söyler.

JIT'te çalışıyor diye AOT'ta da çalışacağını varsaymak. PublishAot kapalıyken vanilla'ya düşen her çağrı sorunsuz çalışır; sorun ancak native binary'de çıkar. Geçiş sırasında PublishAot'u erkenden aç: hem dotnet run AOT kısıtlarıyla çalışır hem de DAP001 ve DAP057 uyarıya yükselir.

Uyarıları bastırmak. IL2104, IL3053, DAP001, DAP053 ve DAP057 bir AOT projesinde "sonra bakarız" uyarıları değil; her biri belirli bir çağrının native binary'de patlayabileceğini söylüyor. Mümkünse TreatWarningsAsErrors ile bu kodları hataya çevir.

İsim mapping'indeki farkı gözden kaçırmak. Dapper.AOT alt çizgileri yok sayarak eşliyor; vanilla Dapper ise ayara bağlı. Aynı sorgu iki modda farklı property'leri doldurabilir.

Küçük Bir Deneme

★☆☆ [module: DapperAot] satırını [module: DapperAot(false)] yap ve yalnızca Book'u okuyan method'u [DapperAot] ile işaretle. obj/ altındaki generated dosyada kaç interceptor kaldığını say.

★★☆ Kısıtlar bölümündeki QueryMultiple örneğini iki ayrı Query çağrısına böl ve native binary'nin artık çalıştığını, IL2104/IL3053 uyarılarının da kaybolduğunu doğrula. Binary boyutunun nasıl değiştiğine bak.

★★★ 11. yazıdaki DateTimeOffsetHandler'ı Dapper.AOT'un DbValueHandler<T> class'ından türeterek yeniden yaz ve [module: TypeHandler(...)] ile kaydet. Native binary'de hem okuma hem parameter yazma yönünü test et; 11. yazıdaki "SetValue neden çağrılmadı" sorusunun burada cevabı değişiyor mu?

Bu yazıyla Dapper serisinin ilk turu tamamlandı: ADO.NET üzerindeki ince katmandan başlayıp type handler'lara, provider'a özgü konulara, performansa ve build sırasında üretilen koda kadar geldik. Seri burada bitmiyor; Dapper'ın ve Dapper.AOT'un yeni sürümleri çıktıkça neyin değiştiğini aynı Bookstore örneği üzerinden yeni yazılarla eklemeye devam edeceğim.