DbDataReader ile Akış Tabanlı JSON ve XML Serileştirme
DbDataReader sonuçlarını DataTable, satır başına sözlük veya DTO grafiği oluşturmadan; şema önbelleği, türe özgü okuma, Utf8JsonWriter ve XmlWriter ile doğrudan akışa yazan C# yaklaşımı.
Bu çalışma, eski C# yardımcı sınıflarımdaki küçük bir performans kararından doğdu. Veritabanı sonucunu JSON ya da XML'e aktarırken sorun çoğu zaman yazıcının kendisi değildi; DbDataReader ile çıktı akışı arasına eklenen DataTable, DTO listesi veya satır başına sözlük gibi ara temsillerdi. Sorgu zaten satırları ileri yönlü veriyorsa, aynı sonucu ikinci kez bellekte kurmanın bedelini ayrıca ödemek gerekmiyordu.
Özellikle büyük sonuç kümelerinde bu bedel yalnız toplam bellek değildir. Her satır için yeni nesne üretimi, sözlük anahtarlarının dolaşılması, kolon adlarının yeniden çözülmesi ve sonunda oluşan nesne grafiğinin bir serileştirici tarafından tekrar ziyaret edilmesi sık çalışan yürütme yolunu uzatır. DataTable, DataSet, List<Dictionary<string, object>> ve benzeri yapılar bazı işlerde gereklidir; burada ise hedef yalnız çalışan bir sorgunun sonucunu dışarı aktarmaktır.
Eski kodumda DbRow, okuyucunun o anda işaret ettiği satırı IDictionary<string, object> benzeri bir görünümle açıyordu. Kolon adlarını başlangıçta topluyor, yield return sırasında her kayıt için ayrı sözlük kurmak yerine aynı hafif satır görünümünü kullanıyordum. IEnumerable tabanlı CSV çalışmamda da aynı düşünce vardı: değişmeyen üst veriyi bir kez hazırla; tekrar edilen döngüde yalnız gerçekten satıra özgü işi bırak.
Yayın için hazırladığım sürüm bu fikri iki farklı kullanım yoluna ayırıyor:
DbReaderEnumerable, okuyucunun mevcut satırını kopyalamadan yeniden kullanılan birDbRowViewile dolaştırıyor.DbReaderSerializer, yalnız JSON/XML dışa aktarımı isteniyorsaIEnumerablegörünümüne bile uğramıyor;Read() -> türe özgü okuma -> yazıcızincirini doğrudan işletiyor.
Bu ayrım bilinçli. Burada ORM, yansıma (reflection) tabanlı eşleyici veya genel amaçlı bir serileştirme çerçevesi kurmaya çalışmıyorum. Sözleşme daha dar: **hazır bir DbDataReader al, mevcut ileri yönlü akışı bozma ve sonucu mümkün olduğunca az ara durumla hedef Stream'e yaz.**
DataReader zaten doğru başlangıç noktası
ADO.NET DataReader için önemli özellik, sonuçların tamamını önce bellekte oluşturma zorunluluğu olmamasıdır. Okuma ileri yönlüdür; sonuçlar sorgu yürüdükçe tüketilebilir. Microsoft belgelerindeki “ileri yönlü / salt okunur” (forward-only / read-only) sözleşmesini burada doğrudan mimari sınır olarak kullanıyorum.
Büyük bir sorguyu önce şu zincire çevirmek:
DbDataReader
-> DataTable
-> List<Row>
-> JSON/XML serileştiriciçoğu dışa aktarma senaryosunda gereksiz bir ara model oluşturur.
Benim tercih ettiğim yol şöyledir:
DbDataReader
-> şema önbelleği
-> geçerli satır
-> JSON/XML yazıcısıİkinci zincirde amaç, satır sayısı arttıkça yardımcı veri yapılarının da aynı oranda büyümemesidir. Yazıcının kendi tamponu, sağlayıcının ağ tamponu ve o anda okunan satır elbette bellektedir; fakat uygulama tarafında sonuç kümesinin tamamını temsil eden ikinci bir koleksiyon kurulmaz.
Tarihsel Db IEnumerable fikri
İlk sürümde DbRow, okuyucunun kolon adlarını kurucuda alıyordu. Numaralandırıcı (Enumerator) her satırda yeni bir Dictionary üretmek yerine aynı DbRow nesnesini döndürüyordu. Böylece satır değerleri ancak serileştirici veya çağıran kod istediğinde doğrudan okuyucudan alınıyordu.
Tarihsel gerçeklenimin zayıf yanı, iyi olan fikrin sağlayıcı ayrıntısına gömülmüş olmasıydı. DbRow doğrudan OleDbDataReader taşıdığı halde ODBC, MySQL ve Oracle için yazılmış IEnumerable sınıfları da aynı satır tipine yaslanıyordu; bu, ortak bir soyutlama görüntüsü verse de tip sözleşmesini gerçekten ortaklaştırmıyordu. Üstelik IDictionary seçimi, ileri yönlü bir satır görünümünde anlamı olmayan ekleme/silme/değiştirme üyelerini de API'ye taşıyor ve bunlar NotSupportedException ile kapatılıyordu.
Yayın sürümünde aynı davranışı daha açık bir sözleşmeyle kurdum:
- sağlayıcıya özgü okuyucu yerine ortak
DbDataReaderkullanılıyor; - satır görünümü
IReadOnlyDictionary<string, object?>sözleşmesine sahip; IEnumerablekatmanı açıkça tek geçişli;- aynı
DbRowViewnesnesi satırlar arasında yeniden kullanılıyor; - kolon adı -> sıra numarası (
ordinal) sözlüğü yalnız bir kez kuruluyor; - yinelenen kolon adları sessizce ezilmek yerine reddediliyor ve SQL takma adı kullanılması isteniyor.
Çekirdeğin sağlayıcı paketlerinden bağımsız kalması burada teorik bir soyutlama hedefi değil, doğrudan kullanım sınırıdır. SQL Server, Oracle, MySQL, PostgreSQL, ODBC veya başka bir sürücü çağıran tarafta kalır; sonuç DbDataReader tabanından geliyorsa aynı IEnumerable ve serileştirici yolu kullanılabilir.
Şemayı bir kez çıkarmak
Bir sonuç kümesinde okuyucu açıldıktan sonra kolon sıra numaraları, adlar ve sağlayıcının bildirdiği CLR tipleri satırdan satıra yeniden tanımlanmaz. Kodda bu özelliği varsayım olarak bırakmak yerine açıkça bir DbReaderSchema planına dönüştürdüm.
Başlangıçta her kolon için aşağıdaki üst veri hazırlanır:
sıra numarası (ordinal)
kolon adı
JSON için önceden kodlanmış özellik adı
XML'e uygun öğe adı
CLR alan tipi
değer yazma kategorisiJSON yolunda özellik adları daha ilk aşamada JsonEncodedText olarak hazırlanır. Böylece aynı kolon adı her kayıt için yeniden UTF-8 kodlama/kaçış işleminden geçirilmez; Utf8JsonWriter için bilinen özellik adlarının önceden kodlanması Microsoft'un performans önerisiyle de uyumludur.
XML tarafında aynı kolon adı XmlConvert.EncodeLocalName ile yalnız bir kez XML'e uygun biçime çevrilir. Boşluk, noktalama veya XML ad kurallarına uymayan karakterlerin maliyeti böylece satır döngüsüne taşınmaz.
Şema oluşturulurken bir başka karar daha verilir: kolon adı -> sıra numarası (ordinal) tablosu StringComparer.OrdinalIgnoreCase ile bir kez kurulur. Aynı sonuç kümesinde yinelenen kolon adı çıkarsa sonuncuyu sessizce ezmek yerine işlem InvalidOperationException ile durdurulur. Bu davranış özellikle birleştirme (JOIN) sonuçlarında önemlidir; çözüm serileştiricinin rastgele seçim yapması değil, SQL tarafında açık bir takma ad vermektir.
Şema hazırlama maliyeti yaklaşık olarak:
O(C)seviyesindedir. Burada C kolon sayısıdır.
Sonraki sık çalışan yol:
while (reader.Read())
her kolon için
null?
türe özgü okuma
yazıcışeklindedir.
JSON: ara nesne olmadan UTF-8 çıktısı
Utf8JsonWriter, .NET'in ileri yönlü ve önbellek oluşturmayan UTF-8 JSON yazıcısıdır. Genel JsonSerializer da kendi alt katmanında bu yazıcıyı kullanır; fakat veritabanı sonucu gibi şeması zaten belli bir veri kaynağında nesne grafiği kurmaya gerek yoktur.
Güncel akış doğrudan şu biçimdedir:
using DbDataReader reader = command.ExecuteReader(CommandBehavior.SequentialAccess);
DbReaderSerializer.WriteJson(reader, outputStream);Çıktı standart bir JSON dizisidir:
[
{"id":1,"name":"..."},
{"id":2,"name":"..."}
]Bu yolda satır başına Dictionary<string, object> kurulmaz, DTO özellikleri yansıma (reflection) ile keşfedilmez ve tamamı bellekte duran büyük bir JSON string oluşturulup sonradan UTF-8'e çevrilmez. Utf8JsonWriter doğrudan hedef Stream üzerinde ilerler; metodun dönüş değeri de yazılan satır sayısıdır. leaveReaderOpen varsayılan olarak true olduğu için okuyucunun yaşam döngüsü çağıran kodun kontrolünde kalır; istenirse serileştirme tamamlandığında belirli bir sırayla kapatılabilir.
Tiplerin korunması
Veritabanı değerini her durumda ToString() ile yazmak kolaydır ancak JSON anlamını bozar. Sayılar, mantıksal değerler ve null metne dönüşür.
Bu nedenle şema hazırlanırken yaygın CLR tipleri sınıflandırılır:
stringbool- işaretli/işaretsiz tamsayı tipleri
float,double,decimalDateTime,DateTimeOffsetDateOnly,TimeOnly,TimeSpanGuidbyte[]
Sıcak yolda Type karşılaştırması yerine önceden hesaplanmış küçük bir enum üzerinden türe özgü okuma yöntemi seçilir. Sayılar JSON sayısı, mantıksal değerler JSON boolean, DBNull ise null olarak yazılır. byte[] Base64 temsil edilir.
Sağlayıcıya özgü bilinmeyen bir CLR tipiyle karşılaşılırsa yalnız o alan kültürden bağımsız bir metin geri dönüş yoluna gider. Bu davranış, bütün kolonları en baştan string'e çevirmekten daha sınırlı bir uyumluluk katmanıdır.
XML: DOM kurmadan ileri yönlü yazma
XML için de aynı prensip geçerlidir. XmlDocument veya başka bir DOM ağacı kurmak yerine XmlWriter kullanılır. Microsoft XmlWriter'ı hızlı, önbellek oluşturmayan ve ileri yönlü XML üretme API'si olarak tanımlar.
using DbDataReader reader = command.ExecuteReader(CommandBehavior.SequentialAccess);
DbReaderSerializer.WriteXml(reader, outputStream);Üretilen yapı kavramsal olarak şöyledir:
<rows xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<row>
<id>1</id>
<name>...</name>
<optional_note xsi:nil="true" />
</row>
</rows>Burada NULL ile boş metin aynı şey değildir. Bu nedenle veritabanı NULL değeri XML'de xsi:nil="true" ile ayrıştırılır.
IEnumerable ne zaman gerekli?
Doğrudan JSON/XML dışa aktarımında en kısa yol DbReaderSerializer'dır. Buna rağmen IEnumerable katmanı yararlıdır:
using var rows = reader.AsRows();
foreach (DbRowView row in rows)
{
Console.WriteLine(row["id"]);
}Bu yapı özellikle:
- satır bazlı filtreleme,
- istatistik çıkarma,
- özel format üretimi,
- mevcut
IEnumerabletabanlı kodla entegrasyon
için kullanışlıdır.
Buradaki en kolay yanlış kullanım DbRowView'ı kalıcı kayıt sanmaktır. Değildir. Numaralandırıcı ilerlediğinde aynı görünüm nesnesi okuyucunun yeni geçerli satırına bakar. Birkaç DbRowView referansını listeye koyup daha sonra okumak, farklı anlık görüntüler biriktirmek anlamına gelmez.
Bu yüzden geçici görünüm davranışı API sözleşmesinin görünür parçasıdır. Kalıcı veri gerekiyorsa anlık görüntü tam ihtiyaç duyulan sınırda bilinçli biçimde alınmalıdır. Nesne tahsisini azaltmayı “ücretsiz optimizasyon” gibi sunmak yerine karşılığındaki kullanım kuralını açık tutmayı tercih ettim.
Neden genel amaçlı serileştirici yerine özel yazıcı?
Genel amaçlı bir serileştirici, aldığı nesnenin yapısını çalışma zamanında çözmek zorundadır. Sözlükte anahtar/değer çiftleri dolaşılır; DTO'da özellik üst verisi çözümlenir; farklı dönüştürücüler ve adlandırma ilkeleri devreye girebilir.
Veritabanı okuyucusunda ise elimizde zaten şunlar vardır:
kolon sıra numarası (`ordinal`)
kolon adı
kolon CLR tipi
mevcut değerBu veri kaynağında serileştiricinin ihtiyaç duyduğu şemanın önemli bir bölümü zaten okuyucuda mevcut. Ek bir nesne grafiği kurmak yerine mevcut üst veriyi başlangıçta küçük bir yazma planına dönüştürmek, burada daha kısa ve daha öngörülebilir bir yürütme yolu veriyor.
IEnumerable tabanlı CSV çalışmamla bağ da tam bu noktada. CSV'de başlık ile model özelliklerini bir kez eşleyip satır döngüsünü konumsal hale getiriyordum. Bu çalışmada model katmanı da ortadan kalkıyor; okuyucu şeması doğrudan JSON/XML yazma planına dönüşüyor.
Karmaşıklık ve bellek davranışı
N satır, C kolon olduğunu kabul edelim.
Şema hazırlama:
O(C)Satırların serileştirilmesi:
O(N * C)JSON/XML çıktısının kendisini yazmak zaten en az bu kadar alanı ziyaret etmeyi gerektirir. Asıl amaç asimptotik karmaşıklığı değiştirmek değil, N * C sık çalışan yolundaki gereksiz işlemleri azaltmaktır.
Yardımcı bellek yaklaşık olarak:
O(C + yazıcı tamponu + geçerli okuyucu satırı)şeklindedir.
Şunlar sonuç kümesinin tamamı kadar büyümez:
DataTableList<T>- satır başına sözlük koleksiyonu
- DOM
- tam JSON/XML metni
Elbette gerçek bellek tüketiminde ADO.NET sağlayıcısının ağ tamponu, sürücü davranışı ve büyük LOB kolonları da etkilidir.
Büyük BLOB/CLOB sınırı
Bu gerçeklenim temel olarak tablo biçimli skaler sonuçlar ve normal büyüklükte byte[] alanlar için tasarlanmıştır. byte[] alan Base64 yazılırken sağlayıcıdan bütün değer alınır.
Çok büyük BLOB/CLOB alanları için ayrı bir akış üzerinden kopyalama yolu daha doğru olabilir:
DbDataReader.GetStream / GetTextReader
-> sınırlı tampon
-> hedef yazıcıBu optimizasyonu genel çekirdeğe otomatik eklemedim. Birkaç kilobaytlık ikili alan ile birkaç gigabaytlık LOB aynı kullanım sözleşmesine sahip değildir.
Veritabanı güvenliği başka bir katmandır
İlk tarihsel sınıflar bağlantı dizesi ve SQL metnini de taşıyordu. Yayın sürümünde bu sorumluluk özellikle çıkarıldı. Kütüphane **hazır bir DbDataReader alır**.
Bunun iki yararı vardır:
- sağlayıcı paketleri ve bağlantı yaşam döngüsü çekirdeğe bağlanmaz;
- SQL'in nasıl oluşturulduğu serileştiricinin sorumluluğu haline gelmez.
Parametreli sorgu, en az yetki, kimlik bilgisi saklama ve işlem yönetimi politikası yine çağıran uygulamanın sorumluluğundadır. Hızlı serileştirici, güvenli sorgu oluşturmanın alternatifi değildir.
Kıyaslama ölçümünde neden sayı vermiyorum?
Bu mimarinin hangi maliyetleri ortadan kaldırdığı kod üzerinden açıkça gösterilebilir. Ancak "X kat hızlı" demek için:
- aynı ADO.NET sağlayıcısı,
- aynı sorgu,
- aynı ağ koşulu,
- aynı kolon tipleri,
- aynı veri büyüklüğü,
- aynı JSON/XML formatı,
- aynı GC ve çalışma zamanı sürümü
ile tekrarlanabilir bir kıyaslama ölçümü gerekir.
Bu nedenle makaleye “X kat hızlı” gibi kaynağı olmayan bir oran koymadım. Koddan kesin olarak görülebilen şey; sonuç kümesinin tamamını ikinci kez bellekte oluşturmadığı, satır başına sözlük oluşturmadığı ve değişmeyen üst veriyi sıcak döngüden çıkardığıdır. Gerçek aktarım hızı karşılaştırması eklenecekse aynı sağlayıcı, sorgu, veri dağılımı, çalışma zamanı ve GC ayarlarıyla ayrıca ölçülmelidir.
Önceki CSV Enumerable çalışmasıyla ilişki
Bu yapı, daha önce yayımladığım Enumerable CSV İşleme Algoritması ile aynı performans düşüncesini farklı veri kaynağına uygular.
CSV tarafında:
özellik üst verisi -> bir kez
başlık eşleme -> bir kez
satırlar -> konumsal sık çalışan yolBu çalışmada:
okuyucu şeması -> bir kez
JSON/XML alan planı -> bir kez
satırlar -> türe özgü yazma yoluİki yaklaşımın ortak noktası karmaşık bir çerçeve kurmak değil, değişmeyen bilgiyi tekrar hesaplamamaktır.
Sonuç
Bu gerçeklenimde performans kararının merkezi serileştirici seçimi değil, veri akışının biçimidir. Okuyucudan çıkan satırı önce başka bir koleksiyon modeline dönüştürmediğimde hem yaşam süresi hem bellek davranışı daha kolay gerekçelendirilebilir.
DbDataReader ileri yönlü tüketilir; Utf8JsonWriter ve XmlWriter da ileri yönlü üretir. Aradaki katmanın görevi yalnız şemayı bir kez hazırlamak ve her alanı doğru yazma yoluna göndermektir:
DB -> okuyucu -> yazıcı -> akışBenim açımdan asıl kazanç yalnız nesne tahsisi sayısını düşürmek değil. Sonuç kümesi büyüdüğünde uygulama tarafındaki yardımcı belleğin hangi yapılar nedeniyle büyüyebileceği açık kalıyor; okuyucunun yaşam döngüsü çağıranın elinde tutuluyor; yinelenen kolon adı gibi belirsiz durumlar sessizce kabul edilmiyor ve farklı ADO.NET sağlayıcıları aynı serileştirme çekirdeğine bağlanabiliyor. Performans iyileştirmesi, davranışın daha belirgin hale gelmesiyle birlikte geliyor.
Kaynakça
- Microsoft. DataAdapters and DataReaders - ADO.NET. https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/dataadapters-and-datareaders
- Microsoft. How to use Utf8JsonWriter in System.Text.Json. https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/use-utf8jsonwriter
- Microsoft. XmlWriter Class. https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlwriter
- Muhammet Ali Köker. Enumerable CSV İşleme Algoritması. https://alikoker.com.tr/enumerable-csv-isleme-algoritmasi
- Muhammet Ali Köker. db-reader-streaming-serialization-csharp. GitHub. https://github.com/alikoker/db-reader-streaming-serialization-csharp
Açık Kaynak Kod
Makalede incelenen güncel gerçeklenim açık kaynak olarak GitHub üzerinde yayımlanmıştır:
Kaynak kod: https://github.com/alikoker/db-reader-streaming-serialization-csharp
Depo, DbReaderEnumerable, DbReaderSerializer, şema planı, türe özgü yazma yolları, örnek kullanım ve testleri içerir. Makale teknik gerekçeyi ve tasarım sınırlarını açıklarken GitHub deposu çalıştırılabilir gerçeklenimi taşır. Kaynak arşivinde makale kopyaları tutulmaz; depo üst verisi İngilizce kanonik makaleye streaming-json-xml-serialization-from-dbdatareader slug'ı üzerinden bağlanır.