.NET 11 ile I/O ve Serialization: Union Serialization, JSON Lines, In-Memory Stream'ler ve Zstandard
.NET 11'de System.Text.Json C# 15 union'larını sarmalayıcı olmadan serialize ediyor ve SerializeAsyncEnumerable ile JSON Lines yazıyor; System.IO.Compression'a Zstandard, ZIP'e parola ve CRC32 doğrulaması geldi. Yeni in-memory stream'leri, closed hierarchy polymorphism'ini ve eski testleri kırabilecek iki davranış değişikliğini (boş GZip artık 20 byte, bozuk zip entry'si artık exception) .NET 10 ile karşılaştırarak gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 11 (SDK 11.0.100) ve C# 15 ile derlenip denenmiştir; karşılaştırmalar ayrıca .NET 10 ile çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) export job'ı boş bir günün sipariş dosyasını da gzip'liyor:
var output = new MemoryStream();
using (new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) { }
Console.WriteLine($"Runtime {Environment.Version}: empty GZip payload -> {output.Length} bytes");
Bu kod .NET 10'da kaç byte yazar, .NET 11'de kaç byte?
İkinci soru: sıkıştırılmadan saklanan bir zip entry'sinde tek bir byte'ı bozuyoruz (fatura numarasındaki B harfi X oluyor) ve entry'yi okuyoruz. .NET 10 ne yapar?
Üçüncüsü: C# 15'in union tipiyle public union OrderReference(int, string); tanımlayıp 42 değerini serialize edersen JSON'da ne görürsün: 42 mi, {"$type":"int","value":42} gibi bir sarmalayıcı mı?
.NET 11 ve C# 15 10 Kasım 2026'da çıktı. Bu yazıda I/O ve serialization tarafındaki yeniliklere bakıyoruz; kaynak, GA (General Availability; sürümün herkese açık kararlı yayını) için güncellenmiş Microsoft what's new sayfası. Serinin tabanı .NET 10 olduğu için bu yazının kodu tek bir projede, Bookstore.IO.Net11'de; proje net11.0 hedefliyor ve .NET 11 SDK'sı istiyor. Bu proje eklendiği için çözümün tamamını derlemek artık .NET 11 SDK'sı gerektiriyor; .NET 10 SDK'sı NETSDK1045 hatası verir, diğer projeleri tek tek derlemeye devam edebilirsin.
dotnet run --project src/Bookstore.IO.Net11 -- json
Union Serialization
C# 15'in union tipleri, bir değerin birkaç tipten biri olabileceğini dilin kendisinde ifade ediyor. Derleyici her case tipinden union'a örtük dönüşüm sağlıyor ve switch'in bütün case'leri kapsadığını kontrol ediyor. System.Text.Json (STJ) .NET 11'de union'ları doğrudan serialize ediyor:
// C# 15 union: bir sipariş referansı ya sayısal ID ya da "BS-..." numarası.
public union OrderReference(int, string);
OrderReference byId = 42, byNumber = "BS-0000042";
Console.WriteLine($"Union: {JsonSerializer.Serialize(byId)} and {JsonSerializer.Serialize(byNumber)}");
OrderReference read = JsonSerializer.Deserialize<OrderReference>("\"BS-0000007\"");
string text = read switch
{
int id => $"id {id}",
string number => $"number {number}"
};
Union: 42 and "BS-0000042"
Union read: number BS-0000007
Üçüncü sorunun cevabı: 42. Serializer aktif case'i sarmalayıcı ya da $type olmadan doğrudan yazıyor. Okurken case'i JSON token'ının türüne göre seçiyor: sayı gelirse int, string gelirse string. Bu, bir API'nin aynı alanda bazen sayı bazen metin kabul ettiği durumlar için custom converter ihtiyacını kaldırıyor.
İki case'in ikisi de object olursa token türü yetmez. Ödeme service'inin sonucu ya onay ya da red:
public sealed record PaymentApproved(string AuthorizationCode, decimal Amount);
public sealed record PaymentDeclined(string Reason, int RetryAfterSeconds);
public union PaymentResultByKind(PaymentApproved, PaymentDeclined);
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union PaymentResult(PaymentApproved, PaymentDeclined);
Without classifier: JsonException: JSON value type 'Object' is ambiguous for union type 'Bookstore.IO.Net11.PaymentResultByKind' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
Structural classifier: PaymentDeclined { Reason = insufficient_funds, RetryAfterSeconds = 3600 }
Union write: {"AuthorizationCode":"A-1001","Amount":349.90}
Classifier'sız union belirsizliği açıkça söylüyor. JsonUnionTypeStructuralClassifier ise kök seviyedeki property adlarına bakıp case'i seçiyor: Reason ve RetryAfterSeconds yalnız PaymentDeclined'da var. Dokümantasyona göre bu classifier property değerlerine ve iç içe içeriğe bakmıyor; payload sıfır ya da birden çok case'e uyarsa JsonException veriyor. Daha karmaşık kurallar için JsonTypeClassifierFactory'den türetip kendi classifier'ını yazabilirsin. Yazarken de discriminator yok; JSON'u alan taraf case'i yine şekilden çıkarmak zorunda. Bu, sözleşmesini sen belirlemediğin dış API'lerde union'ı rahat kılar; kendi API'nde ise serinin System.Text.Json yazılarının ilkinde gördüğümüz, discriminator taşıyan polymorphism daha açık bir sözleşme olabilir.
Closed Hierarchy, JSON Lines ve Diğer STJ Yenilikleri
C# 15'in closed hierarchy'leri türetilmiş tiplerin listesini derleme anında sabitliyor. STJ bunu polymorphism için kullanabiliyor: [JsonDerivedType] attribute'larını tek tek yazmak yerine InferClosedTypePolymorphism açılıyor:
// C# 15 closed hierarchy: türetilmiş tipler derleme anında bilinir.
[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record ShippingOption;
public sealed record StandardShipping(int Days) : ShippingOption;
public sealed record ExpressShipping(DateOnly DeliverBy) : ShippingOption;
Closed hierarchy: [{"$type":"StandardShipping","Days":3},{"$type":"ExpressShipping","DeliverBy":"2026-11-16"}]
Discriminator olarak tip adı kullanılıyor. Bunun bir sonucu var: class'ın adını değiştirirsen JSON sözleşmen de değişir. Dışarıya açık bir API'de discriminator'ları [JsonDerivedType(typeof(StandardShipping), "standard")] ile açıkça vermek hâlâ daha güvenli; açık kayıtlar varsa çıkarım listesi yerine onlar kullanılıyor.
.NET 9'da DeserializeAsyncEnumerable'a gelen topLevelValues parametresi .NET 11'de yazma tarafına da geldi. SerializeAsyncEnumerable ile topLevelValues: true, JSON Lines (her satırda bir JSON değeri olan format) üretiyor; bu, serinin önceki yazılarındaki NDJSON (Newline Delimited JSON; her satırında ayrı bir JSON değeri bulunan metin formatı) ile aynı yapı:
await using (var file = File.Create(path))
{
await JsonSerializer.SerializeAsyncEnumerable(file, Orders(1_000), topLevelValues: true);
}
JSON Lines: 337,659 bytes, last byte 0x0A
{"OrderNumber":"BS-0000001","CustomerName":"Linus","CreatedA...
read back 1,000 orders
Her değer tek satırda ve kompakt yazılıyor, son değerin ardından da \n geliyor. Dokümantasyona göre WriteIndented ve NewLine ayarları bu modda yok sayılıyor; her değer bir satır olmak zorunda. Serinin Pipelines ve System.Text.Json yazılarında NDJSON'u elle satır satır yazıyorduk; artık iki yön de tek çağrı.
Diğer STJ eklemeleri daha küçük ama günlük işe dokunuyor. [JsonNamingPolicy] ve [JsonIgnore] artık tip seviyesinde verilebiliyor; JsonNamingPolicy.PascalCase eklendi; Utf8JsonWriter.Reset yazıcıyı başka bir stream ve başka ayarlarla yeniden kullanmaya izin veriyor:
[JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public sealed class StockAlert
{
public string Isbn { get; set; } = "";
public int QuantityOnHand { get; set; }
public string? WarehouseNote { get; set; }
}
// Aynı writer başka bir hedef ve başka ayarlarla yeniden kullanılır.
writer.Reset(second, new JsonWriterOptions { Indented = false });
Type-level attributes: {"isbn":"9780441172719","quantity_on_hand":2}
Utf8JsonWriter.Reset: 29 bytes indented, then {"isbn":"9780141439587"}
WarehouseNote null olduğu için yazılmadı, isimler options'a dokunmadan snake_case oldu. Source generator da daha çok şekli destekliyor: [JsonInclude] ile işaretli private member'lar, erişilemeyen [JsonConstructor]'lar ve JSON'da gelmeyen init property'lerin initializer değerini koruması bunlardan bazıları.
In-Memory Stream'ler
Elindeki veri bir string, bir ReadOnlyMemory<byte> ya da Pipelines'tan gelen bir ReadOnlySequence<byte> ise ve onu Stream isteyen bir API'ye vermen gerekiyorsa, şimdiye kadar çözüm önce bir byte[] üretip MemoryStream'e sarmaktı. .NET 11 bu kopyayı gereksiz kılan dört tip getirdi:
// string'den byte[] üretmeden Stream.
using (var stream = new StringStream(json, Encoding.UTF8))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
using (var stream = new ReadOnlyMemoryStream(payload))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
// Pipe'tan gelen çok segmentli bir ReadOnlySequence, tek bir array'e kopyalanmadan stream olur.
using (var stream = new ReadOnlySequenceStream(result.Buffer))
{
Order order = (await JsonSerializer.DeserializeAsync<Order>(stream))!;
}
byte[] fixedBuffer = new byte[64];
using (var stream = new WritableMemoryStream(fixedBuffer))
{
JsonSerializer.Serialize(stream, SampleOrders.Generate(1).Single());
}
StringStream: BS-0000001, CanSeek False
ReadOnlyMemoryStream: BS-0000001, Length 430, CanWrite False
ReadOnlySequenceStream: BS-0000001, single segment False
WritableMemoryStream (64 bytes): NotSupportedException: Unable to expand length of this stream beyond its capacity.
Bilmen gereken birkaç ayrıntı var. StringStream string'i okundukça encode ediyor ve seek edilemiyor; uzunluğu önceden bilen bir API'ye vereceksen bunu hesaba kat. ReadOnlySequenceStream System.IO'da değil, System.Buffers namespace'inde; what's new sayfası namespace'i belirtmiyor, ilk denemede bulamadık. O da seek edilemiyor. WritableMemoryStream verdiğin buffer'ın boyutunda sabit kalıyor ve büyümüyor: 430 byte'lık bir siparişi 64 byte'lık buffer'a yazmak exception verdi. Stack'te ya da pool'dan alınmış bir buffer'a yazıp taşmayı bir hata olarak görmek istediğin yerde işe yarar.
Düşük seviye I/O'da iki ekleme var. SafeFileHandle.Type, handle'ın dosya mı pipe mı socket mı olduğunu platforma özel kod yazmadan söylüyor; RandomAccess.Read/Write artık pipe gibi seek edilemeyen handle'larla da çalışıyor (offset bu durumda kullanılmıyor):
SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readEnd, out SafeFileHandle writeEnd, asyncRead: false, asyncWrite: false);
// .NET 11: RandomAccess seek edilemeyen handle'larla da çalışır; offset yok sayılır.
RandomAccess.Write(writeEnd, "BS-0000042\n"u8, fileOffset: 0);
int read = RandomAccess.Read(readEnd, received, fileOffset: 0);
SafeFileHandle.Type: file RegularFile, pipe Pipe
RandomAccess over a pipe: 11 bytes, BS-0000042
System.IO.Pipelines tarafında API değişmedi; Microsoft'un notuna göre çok sayıda yazıcı ve okuyucunun aynı anda çalıştığı yüklerde lock contention azaltıldı.
Compression: Zstandard ve Span Tabanlı Encoder'lar
Zstandard (Facebook'un geliştirdiği, hızlı ve yüksek oranlı sıkıştırma algoritması) artık System.IO.Compression'da, GZipStream ve BrotliStream'in yanında: ZstandardStream, ZstandardEncoder/ZstandardDecoder ve sözlük eğitimi için ZstandardDictionary. Arşivler yazısındaki 6,8 MB'lık NDJSON ile, bir geliştirici makinesinde karşılaştırdık:
Input: 6,808,653 bytes NDJSON
GZip Optimal 326,007 bytes 14.5 ms
Brotli quality 9 294,010 bytes 61.4 ms
Zstandard Fastest 513,109 bytes 3.8 ms
Zstandard Optimal 490,731 bytes 4.0 ms
Zstandard quality 19 238,690 bytes 1,392.9 ms
ZLibEncoder.TryCompress: True, 325,840 bytes, no Stream
İki uç da ilginç. CompressionLevel.Optimal ile Zstandard GZip'in neredeyse dört katı hızlı ama dosya yüzde 50 büyük; bu enum'un Zstandard'da hız tarafına eşlendiğini gösteriyor. Quality 19 ise Brotli'nin quality 11'inden (önceki yazıda 242.577 byte, 7 saniye) biraz küçük dosyayı beş kat kısa sürede üretti. Brotli'de olduğu gibi burada da enum yerine ZstandardCompressionOptions.Quality ile kendi verine göre seviye seç. Zstandard HTTP'de de bir Content-Encoding olarak kullanılabiliyor, ama istemci desteğini kontrol etmeden response compression'a ekleme.
Son satır yeni span tabanlı API'lerden: DeflateEncoder, ZLibEncoder, GZipEncoder ve decoder'ları, BrotliEncoder'ın modelini Deflate ailesine getiriyor. Bir buffer'ı stream oluşturmadan tek çağrıyla sıkıştırmak, protokol parser'ları ve zaten span'lerle çalışan middleware'ler için allocation'sız bir yol.
ZIP ve Tar: Parola, Erişim Modları ve Format Seçimi
.NET 11 ZIP (dosyaları sıkıştırıp tek arşivde toplayan format) entry'lerine parola desteği getirdi. CreateEntry'nin yeni overload'ları parola ve şifreleme yöntemini alıyor; okurken Open'ın parola alan overload'ı kullanılıyor:
using (var archive = new ZipArchive(encrypted, ZipArchiveMode.Create, leaveOpen: true))
using (var writer = new StreamWriter(archive.CreateEntry("invoice.xml", "example-password", ZipEncryptionMethod.Aes256).Open()))
{
writer.Write("<Invoice />");
}
using var reader = new StreamReader(entry.Open(FileAccess.Read, "example-password"));
Encrypted: True, Aes256
Open without password: InvalidDataException: A password is required for encrypted entries.
Open with password: <Invoice />
ZipEncryptionMethod, AES (Advanced Encryption Standard; yaygın simetrik şifreleme algoritması) 128, 192, 256 ve eski ZipCrypto'yu içeriyor. ZipCrypto zayıf kabul edilen eski bir yöntem; yalnız onu bekleyen eski araçlarla uyum gerekiyorsa kullan. Parolayı kodda tutma; bu örnekteki sabit metin yalnız gösterim için. Entry'ler artık Open(FileAccess) ile açık bir erişim moduyla açılabiliyor ve CompressionMethod property'si entry'nin Stored, Deflate ya da Deflate64 olduğunu söylüyor.
Tar tarafında TarFile.CreateFromDirectory artık format alıyor; önceden hep Pax üretiyordu:
TarFile.CreateFromDirectory(dir, tar, includeBaseDirectory: false, format: TarEntryFormat.Gnu);
Tar format: Gnu
Arşivler yazısında ölçtüğümüz async ZIP hatası da .NET 11'de düzeldi: aynı sayaç stream'i .NET 11'de sıfır senkron yazma gösterdi. .NET 11'e geçtiğinde /api/invoices/zip endpoint'indeki AllowSynchronousIO satırını silebilirsin.
Davranış Değişiklikleri: Boş Payload ve CRC32
İki değişiklik derleme hatası vermeden eski testleri kırabilir. Birincisi ilk sorunun cevabı. Aynı kodu iki runtime'da çalıştırdık:
Runtime 10.0.13: empty GZip payload -> 0 bytes
Runtime 11.0.0: empty GZip payload -> 20 bytes
.NET 11'de DeflateStream ve GZipStream hiç veri yazılmasa da header ve footer yazıyor; boş bir GZip artık 20 byte, boş bir Deflate 2 byte. Microsoft'un breaking change notuna göre sebep, boş çıktıyı geçerli bir sıkıştırılmış stream olarak kabul etmeyen araçlar. Boş export'un dosya boyutunun 0 olduğunu kontrol eden testler ya da "dosya boşsa gönderme" kuralı bu değişiklikle bozulur. Eski davranış gerekiyorsa boş içeriği sıkıştırmadan önce kendin ayır.
İkincisi ikinci sorunun cevabı. Bozuk byte'lı entry'yi iki runtime'da okuduk:
Corrupted entry read: <Invoice><ID>XSI20260000042</ID></Invoice>
Runtime 11.0.0: method Stored
Corrupted entry: InvalidDataException: The CRC32 checksum of the extracted data does not match the expected value from the archive.
İlk satır .NET 10: bozuk veri hiçbir uyarı olmadan okundu ve fatura numarası XSI... oldu. .NET 11 ise entry'nin CRC32 (32 bitlik döngüsel artıklık kontrolü; verinin bozulmadığını doğrulayan checksum) checksum'ını doğruluyor ve tutarsızlıkta InvalidDataException veriyor. Bu bir güvenlik ve veri bütünlüğü iyileştirmesi, ama daha önce "çalışan" bozuk arşivler artık exception verecek. Yüklenen zip'leri işleyen kodda bu exception'ı yakalayıp kullanıcıya anlamlı bir hata dönmek gerekiyor.
Üretimde Dikkat
.NET 11 bir STS (Standard Term Support; 24 ay destek alan ara sürüm) sürümü; .NET 10 ise LTS (Long Term Support; 3 yıl destek alan sürüm). Bu yüzden birçok ekip üretimde .NET 10'da kalıp .NET 11'i yeni projelerde ya da kendi hızına göre deneyecek. Yükseltirken I/O tarafında şunlara bak:
Boş çıktı varsayımları. Boş bir sıkıştırılmış dosyanın 0 byte olduğunu varsayan testleri ve kontrolleri bul.
Bozuk arşivler. ZIP okuyan her kodda InvalidDataException'ın artık daha sık geleceğini düşün; özellikle kullanıcı upload'larında bunu bir 400 yanıtına çevir.
Union ve closed hierarchy'leri dış sözleşmelerde kullanmadan önce JSON şeklini sabitle. Discriminator'sız union'ları okuyan istemcilerin case'i şekilden çıkarabildiğinden ve closed hierarchy'deki tip adı discriminator'larının yeniden adlandırmaya dayanıklı olduğundan emin ol.
Seviye seçimini ölç. Zstandard'ın Optimal'i ile GZip'in Optimal'i aynı anlama gelmiyor; enum değerlerini algoritmalar arasında taşırken boyut ve süreyi kendi verinle karşılaştır.
Sık Düşülen Tuzaklar
İki object case'li union'ı classifier'sız bırakmak. Serialize sorunsuz çalışır, ilk deserialize'da exception gelir. Testlerde iki yönü de dene.
ReadOnlySequenceStream'i System.IO'da aramak. System.Buffers namespace'inde.
WritableMemoryStream'in büyüyeceğini sanmak. Verdiğin buffer kadar yer var; taşma NotSupportedException.
Bütün çözümü .NET 10 SDK'sıyla derlemeye çalışmak. net11.0 hedefleyen tek bir proje bile çözüm derlemesini NETSDK1045 ile durdurur; CI'daki SDK sürümünü güncelle ya da projeyi ayrı derle.
Boş payload ve CRC32 değişikliklerini "bizi etkilemez" diye geçmek. İkisi de derlemede değil, çalışırken ortaya çıkar.
Küçük Bir Deneme
★☆☆ OrderReference'a üçüncü bir case ekle: Guid. Serialize ve deserialize nasıl davranıyor? Guid ile string aynı JSON token türünü kullandığı için ne oluyor?
★★☆ Serinin Pipelines yazısındaki NDJSON üretimini SerializeAsyncEnumerable(..., topLevelValues: true) ile yeniden yaz ve çıktının byte byte aynı olduğunu doğrula. Fark varsa nerede?
★★★ Arşivler yazısındaki SafeZipExtractor'ı .NET 11'e taşı: parolalı entry'ler için bir parola parametresi ekle, CRC32 hatasını entry adını içeren anlamlı bir hataya çevir ve Open(FileAccess.Read) overload'ını kullan.
Böylece .NET'te I/O İşlemleri serisini bitirdik: stream modelinden Zstandard'a kadar dosyalar, stream'ler ve serialization. Message ve cache verisi için hangi serialization formatının seçileceği Message Queue'larla Çalışmak ve Caching serilerinde anlatılıyor; bu seride yaptığımız serialization ve compression ölçümleri, o seçimleri kendi verinle değerlendirirken işine yarar.