.NET 6'dan .NET 10'a I/O ve Serialization'da Gelenler
.NET 6'dan .NET 10'a I/O ve serialization'da gelenlerin çoğu, projelerde elle yazılmış döngülerin, naming policy'lerin ve kontrollerin yerini alan hazır API'ler: RandomAccess, ReadExactly, File.ReadLinesAsync, SnakeCaseLower, JsonUnmappedMemberHandling, JsonSerializerOptions.Web ve Strict. Her sürümün yeniliklerini çalışan önce ve sonra örnekleriyle ve yükseltmede davranışı sessizce değişen noktalarla topluyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; "önce" örnekleri de aynı projede derlenip çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) müşteri profilinde etiketler salt okunur bir property:
public sealed class CustomerProfile
{
public string Name { get; set; } = "";
public List<string> Tags { get; } = [];
}
{"Name":"Ada","Tags":["institutional"]} JSON'unu varsayılan ayarlarla deserialize edersen Tags.Count kaç olur?
İkinci soru: her satırında bir sipariş olan 1.000 satırlık bir NDJSON (Newline Delimited JSON; her satırında ayrı bir JSON değeri bulunan metin formatı) dosyasını JsonSerializer.DeserializeAsyncEnumerable<Order>(stream) ile okursan kaç sipariş gelir?
Üçüncüsü: JsonSerializerOptions.Web.Converters.Add(new JsonStringEnumConverter()) satırı ne yapar?
Seri boyunca .NET 10'un sunduğu API'leri kullandık. Ama gerçek projelerin çoğu .NET 6 ya da 8'den geliyor ve kodun önemli bir kısmı o günlerin çözümlerini taşıyor. Bu yazıda .NET 6'dan .NET 10'a I/O ve serialization'da gelenleri sürüm sürüm, her biri için kısa bir önce ve sonra örneğiyle topluyoruz. Kaynak her zaman Microsoft'un ilgili sürümün what's new sayfası; ayrıntısını serinin önceki yazılarında anlattığımız konularda o yazıya yönlendiriyoruz. Örnekler Bookstore.IO.WhatsNew console uygulamasında; eski yöntemler de Before.cs'te derlenip çalışıyor:
dotnet run --project src/Bookstore.IO.WhatsNew -- net6
dotnet run --project src/Bookstore.IO.WhatsNew -- before
Beş Sürümün Özeti
| Sürüm | I/O | Serialization |
|---|---|---|
| .NET 6 | FileStream yeniden yazımı, FileStreamOptions, RandomAccess, ZLibStream, CompressionLevel.SmallestSize | Source generator, JsonNode, IAsyncEnumerable desteği |
| .NET 7 | ReadExactly/ReadAtLeast, System.Formats.Tar, File.ReadLinesAsync | Contract customization, polymorphism, required |
| .NET 8 | Stream alan ZipFile overload'ları | Snake/kebab case, JsonUnmappedMemberHandling, populate, interface hiyerarşileri, reflection'ı kapatma |
| .NET 9 | zlib-ng, ZLibCompressionOptions/BrotliCompressionOptions | JsonSerializerOptions.Web, JsonSchemaExporter, nullable annotation'lar, indent ayarları, birden çok üst düzey değer |
| .NET 10 | Async zip API'leri, ZipArchive performansı, art arda GZip stream'leri | Strict, AllowDuplicateProperties, source generation'da ReferenceHandler, PipeReader desteği |
.NET 6: FileStream'in Yeniden Yazımı ve İlk Source Generator
FileStream .NET 6'da yeniden yazıldı. Microsoft'un notuna göre amaç özellikle Windows'ta performans ve güvenilirlikti; async açılmış bir FileStream artık Windows'ta hiç bloklamıyor. Yeniden yazımın getirdiği iki API, constructor parametre listesini bir option object'ine taşıyan FileStreamOptions ve Position kullanmayan, offset tabanlı RandomAccess:
// .NET 5 ve öncesi
await using var old = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true);
// .NET 6 ve sonrası
var options = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Options = FileOptions.Asynchronous,
PreallocationSize = 1_000_000
};
await using var file = new FileStream(path, options);
PreallocationSize diskte yer ayırır ama dosyanın boyutunu değiştirmez: 1 MB ön ayırmayla açtığımız dosyaya 337.659 byte yazdık ve dosya tam o boyutta kaldı. Aynı dosyanın iki farklı yerini okumak için eskiden paylaşılan stream'in Position'ını bir lock içinde değiştirmek gerekiyordu:
// .NET 6 öncesi: paylaşılan FileStream'de Position + Read, lock ile.
lock (file)
{
file.Position = offset;
return file.Read(buffer, 0, buffer.Length);
}
// .NET 6 ve sonrası
using SafeFileHandle handle = File.OpenHandle(path);
await Task.WhenAll(
RandomAccess.ReadAsync(handle, first, fileOffset: 0).AsTask(),
RandomAccess.ReadAsync(handle, second, fileOffset: 1_000).AsTask());
RandomAccess @0: {"OrderNumber":"BS-0000001","C
RandomAccess @1000: 0141439471","Title":"Frankenst
İki okuma aynı handle üzerinde aynı anda çalışıyor, kimse Position'a dokunmuyor. Ayrıntılar ve Windows ile Linux arasındaki async I/O farkı serinin FileStream yazısında; RandomAccess ile dosyayı paralel okumak büyük dosyalar yazısında.
System.Text.Json (STJ) tarafında .NET 6'nın üç büyük katkısı var: JsonSerializerContext ile source generator, salt okunur JsonDocument'in yanına gelen ve değiştirilebilen JsonNode DOM'u (Document Object Model; belgeyi bellekte ağaç olarak tutan model) ve IAsyncEnumerable desteği (DeserializeAsyncEnumerable):
JsonNode node = JsonNode.Parse(File.ReadLines(path).First())!;
node["Status"] = "Paid";
node.AsObject().Remove("Items");
JsonNode: {"OrderNumber":"BS-0000001","CustomerName":"Linus","CreatedAtUtc":"2026-11-01T00:00:07Z","Status":"Paid","Currency":"TRY","Total":1116.40}
Compression'da .NET 6, ZLib formatını yazan ZLibStream'i ve CompressionLevel.SmallestSize'ı ekledi. Source generation ve DOM seçimi serinin System.Text.Json yazılarının ikincisinde, compression seviyeleri arşivler yazısında.
.NET 7: ReadExactly, Tar ve Contract Customization
Stream.Read'in istenenden az byte dönebilmesi, serinin ilk yazısının konusuydu. .NET 7'den önce bunu her projede aynı döngüyle çözerdik:
// .NET 7 öncesi: Read istenenden az dönebilir, döngü şart.
int total = 0;
while (total < buffer.Length)
{
int read = stream.Read(buffer, total, buffer.Length - total);
if (read == 0) throw new EndOfStreamException();
total += read;
}
// .NET 7 ve sonrası
stream.ReadExactly(buffer);
Aynı sürüm ReadAtLeast'i, tar arşivleri için System.Formats.Tar'ı ve satırları async okuyan File.ReadLinesAsync'i getirdi:
// .NET 7 öncesi
using var reader = new StreamReader(path);
while (await reader.ReadLineAsync() is not null) lines++;
// .NET 7 ve sonrası
await foreach (string line in File.ReadLinesAsync(path)) lines++;
STJ'de .NET 7 üç şey ekledi: contract customization (DefaultJsonTypeInfoResolver modifier'ları ile property'leri kodla değiştirmek), attribute tabanlı polymorphism ve C# 11'in required member'larına destek. Polymorphism'den önce türetilmiş tipleri taşımak için discriminator'ı elle yazan bir custom converter gerekirdi; şimdi iki attribute yetiyor:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "bank-transfer")]
public abstract record PaymentMethod;
public sealed record CardPayment(string Last4) : PaymentMethod;
public sealed record BankTransfer(string Reference) : PaymentMethod;
Polymorphism: [{"type":"card","Last4":"4242"},{"type":"bank-transfer","Reference":"BS-0000042"}]
required: JsonException: JSON deserialization for type 'Bookstore.IO.WhatsNew.Net7+BookRequest' was missing required properties including: 'Isbn'.
İkinci satır required string Isbn property'si olan bir request'i Isbn'siz deserialize etmenin sonucu. Bu konuların hepsini PaymentMethod hiyerarşisiyle System.Text.Json yazılarının ilkinde ayrıntılı işledik.
.NET 8: Naming Policy'ler, Unmapped Member'lar ve Populate
.NET 8'den önce snake_case JSON üreten ya da okuyan her proje kendi JsonNamingPolicy'sini yazıyordu:
// .NET 8 öncesi: snake_case için kendi naming policy'n.
public sealed class SnakeCasePolicy : JsonNamingPolicy
{
public override string ConvertName(string name) =>
string.Concat(name.Select((c, i) => i > 0 && char.IsUpper(c) ? "_" + char.ToLowerInvariant(c) : char.ToLowerInvariant(c).ToString()));
}
// .NET 8 ve sonrası
var snake = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
SnakeCaseLower: {"order_number":"BS-0000042","created_at_utc":"1970-01-01T00:00:00Z"}
Kendi yazdığın policy'ler kısaltmalarda ve rakamlarda kolayca hata yapar; yerleşik SnakeCaseLower, SnakeCaseUpper, KebabCaseLower ve KebabCaseUpper bu durumları tutarlı ele alır. Bilinmeyen alanları reddetmek için eskiden [JsonExtensionData] ile bir sözlük toplayıp elle kontrol ederdik; .NET 8 bunu bir ayara çevirdi:
// .NET 8 öncesi: bilinmeyen alanları yakalamak için JsonExtensionData.
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; set; }
// .NET 8 ve sonrası
var disallow = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
Disallow: JsonException: The JSON property 'Price' could not be mapped to any .NET member contained in type 'Bookstore.IO.WhatsNew.BookDto'.
Read-only Tags: default 0, Populate 1
İkinci satır ilk sorunun cevabı. Varsayılan ayarlarla STJ setter'ı olmayan Tags'e dokunmaz ve JSON'daki değer sessizce kaybolur: sonuç 0. .NET 8'in PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate ayarı (ya da property üzerindeki [JsonObjectCreationHandling] attribute'u) var olan collection'ı doldurur: sonuç 1. Aynı sürüm interface hiyerarşilerinin property'lerini serialize etmeyi, JsonSerializerIsReflectionEnabledByDefault MSBuild property'si ile reflection tabanlı varsayılanı kapatmayı ve dosya yolu yerine stream alan ZipFile.CreateFromDirectory/ExtractToDirectory overload'larını getirdi.
.NET 9: Web Varsayılanları, Şema ve zlib-ng
ASP.NET Core'un varsayılan JSON ayarlarını (camelCase, büyük/küçük harf duyarsız eşleştirme, string'den sayı okuma) kendi kodunda kullanmak için eskiden bir static instance tanımlardık. .NET 9 bunu hazır bir singleton yaptı:
// .NET 9 öncesi: web varsayılanları için kendi static instance'ın.
public static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
// .NET 9 ve sonrası
JsonSerializer.Serialize(book, JsonSerializerOptions.Web);
Web: {"isbn":"9780441172719","title":"Dune","subtitle":null}
Web.Converters.Add: InvalidOperationException: This JsonSerializerOptions instance is read-only or has already been used in serialization or deserialization.
Üçüncü sorunun cevabı ikinci satırda: JsonSerializerOptions.Web salt okunur, değiştirmeye çalışmak exception verir. Bir converter eklemek istiyorsan new JsonSerializerOptions(JsonSerializerOptions.Web) ile bir kopya oluşturup onu değiştir. Aynı kural .NET 10'daki Strict için de geçerli.
.NET 9'un diğer STJ eklemeleri: tiplerden JSON Schema üreten JsonSchemaExporter, C#'ın nullable annotation'larına uyan RespectNullableAnnotations, eksik constructor parametresini hata sayan RespectRequiredConstructorParameters, girinti için IndentCharacter ve IndentSize, enum üyelerinin JSON adını değiştiren [JsonStringEnumMemberName]. Bir de ikinci sorunun cevabıyla ilgili olan: birden çok üst düzey JSON değerini tek stream'den okumak.
// .NET 9 öncesi: NDJSON'u satır satır okuyup her satırı ayrı deserialize etmek.
await foreach (string line in File.ReadLinesAsync(path))
{
if (JsonSerializer.Deserialize(line, OrderContext.Default.Order) is not null) count++;
}
// .NET 9 ve sonrası
await foreach (Order? order in JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, topLevelValues: true)) count++;
topLevelValues: 1,000 orders
Without topLevelValues: JsonException: The JSON value could not be converted to System.Collections.Generic.List`1[Bookstore.Ordering.Order]. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
İkinci sorunun cevabı: hiç sipariş gelmez, exception gelir. topLevelValues olmadan DeserializeAsyncEnumerable girdinin tek bir JSON array'i olmasını bekler; NDJSON'un ilk satırı bir object olduğu için ilk byte'ta hata verir. topLevelValues: true girdiyi boşlukla ayrılmış değerler dizisi olarak okur ve satır başına string üretmez. Düşük seviyede aynı özellik Utf8JsonReader için JsonReaderOptions.AllowMultipleValues.
I/O tarafında .NET 9'un değişikliği görünmez ama geniş kapsamlı: DeflateStream, ZLibStream, GZipStream ve ZipArchive zlib yerine zlib-ng kullanıyor ve seviye/strateji ayarı için ZLibCompressionOptions ile BrotliCompressionOptions geldi; ölçümleri arşivler yazısında yaptık. Seri dışından bir not: IHttpClientFactory .NET 9'dan beri varsayılan olarak SocketsHttpHandler kullanıyor ve bağlantı ömrünü factory'nin handler rotasyonuyla eşliyor. Bunun dayanıklılık tarafı External Service Integration ve Resilience serisinde.
.NET 10: Strict, PipeReader ve Async ZIP
.NET 10'un STJ eklemeleri güvenli varsayılanlar üzerine. JSON standardı aynı property'nin iki kez gelmesine ne yapılacağını söylemez ve STJ varsayılan olarak sonuncuyu alır. AllowDuplicateProperties = false bunu reddeder; JsonSerializerOptions.Strict ise bunu, bilinmeyen alanları reddetmeyi, büyük/küçük harf duyarlı eşleştirmeyi ve .NET 9'un iki Respect* ayarını tek bir hazır preset'te toplar:
// .NET 10 öncesi: her ayarı tek tek açmak (duplicate kontrolü yoktu).
var strict = new JsonSerializerOptions
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true
};
// .NET 10 ve sonrası
JsonSerializer.Deserialize<BookDto>(json, JsonSerializerOptions.Strict);
Default: BookDto { Isbn = 9780441172719, Title = Emma, Subtitle = }
Strict: JsonException: Duplicate property 'Title' encountered during deserialization of type 'Bookstore.IO.WhatsNew.BookDto'.
Duplicate property'nin neden bir güvenlik riski olduğunu System.Text.Json yazılarının ilkinde anlattık. İkinci ekleme, source generation'da referans döngüleri: eskiden source-gen context'i döngüde exception veriyordu, şimdi ReferenceHandler attribute'tan verilebiliyor:
// .NET 10: source generation ile ReferenceHandler.
[JsonSourceGenerationOptions(ReferenceHandler = JsonKnownReferenceHandler.Preserve)]
[JsonSerializable(typeof(Category))]
public partial class CategoryContext : JsonSerializerContext;
ReferenceHandler: {"$id":"1","Name":"Fiction","Parent":null,"Children":{"$id":"2","$values":[{"$id":"3","Name":"Science Fiction","Parent":{"$ref":"1"},"Children":{"$id":"4","$values":[]}}]}}
Üçüncüsü PipeReader desteği. PipeWriter'a serialize etmek zaten mümkündü; okumak için ise PipeReader'ı stream'e çevirmek gerekiyordu:
// .NET 10 öncesi: PipeReader'ı Stream'e çevirmek.
Order? order = await JsonSerializer.DeserializeAsync(reader.AsStream(), OrderContext.Default.Order);
// .NET 10 ve sonrası
Order? read = await JsonSerializer.DeserializeAsync(pipe.Reader, OrderContext.Default.Order);
PipeReader: BS-0000001, 3 items, 1,116.40 TRY
Pipelines yazısında bunun Kestrel'in Request.BodyReader'ıyla nasıl birleştiğini gördük. I/O tarafında .NET 10 ZIP (dosyaları sıkıştırıp tek arşivde toplayan format) arşivleri için async API'leri (ZipArchive.CreateAsync, ZipArchiveEntry.OpenAsync, ZipFile.ExtractToDirectoryAsync ve diğerleri), Update modunda bütün entry'leri belleğe almayan bir ZipArchive'i ve Microsoft'un notuna göre art arda eklenmiş çok sayıda küçük GZip stream'ini açarken yaklaşık yüzde 35'e varan hızlanmayı getirdi. Async ZIP API'lerinin Create modunda her entry'nin sonunu hâlâ senkron yazdığını ve bunun Kestrel'de nasıl patladığını arşivler yazısında ölçtük; düzeltme .NET 11'de.
Üretimde Dikkat
Yükseltme derleme hatası vermeyen ama davranış değiştiren yerlerde risklidir. .NET 6'dan 10'a geçen bir projede şu kontrol listesi işe yarar:
- Elle yazılmış okuma döngülerini
ReadExactly/ReadAtLeastile değiştir; döngüdeki küçük hatalar (sıfır dönüşü kontrol etmemek) en sık bulunan I/O bug'ları. - Sıkıştırılmış çıktının byte'larını karşılaştıran testleri bul; .NET 9'daki zlib-ng geçişiyle bu byte'lar değişebilir.
- Kendi naming policy'lerini ve
JsonExtensionDataile yapılan "bilinmeyen alan" kontrollerini yerleşik ayarlarla değiştirmeden önce çıktıları karşılaştır; özellikle kısaltma içeren property adlarında sonuç farklı olabilir. - Dışarıdan JSON alan endpoint'lerde
JsonSerializerOptions.Strict'i dene; ama önce mevcut istemcilerin gerçekten gönderdiği payload'larla test et, çünkü fazladan alan gönderen bir istemci artık hata alır. - Async ZIP API'lerine geçerken senkron yazmayı reddeden stream'lerle (Kestrel response body'si) test et.
Sık Düşülen Tuzaklar
Hazır singleton'ları değiştirmeye çalışmak. JsonSerializerOptions.Web, Default ve Strict salt okunurdur; kopyasını al.
Her istekte yeni JsonSerializerOptions oluşturmak. Bu yeni bir sorun değil ama .NET 9 öncesindeki "kendi static instance'ını tanımla" alışkanlığının nedeni buydu: option object'i kendi metadata cache'ini taşır, her seferinde yenisini yaratmak bu cache'i her seferinde yeniden kurar.
NDJSON'u array sanmak. DeserializeAsyncEnumerable'ın varsayılanı tek bir JSON array'i bekler; NDJSON için topLevelValues: true şart.
Salt okunur collection'ların sessizce boş kalması. Setter'ı olmayan bir collection property'si varsayılan ayarlarla doldurulmaz ve hiçbir hata alınmaz. Ya setter ekle ya da populate davranışını aç.
"Async API var, öyleyse her şey async" demek. .NET 10'un ZIP API'leri bunun tersini gösterdi; yeni bir async API'ye geçtiğinde onu gerçek ortamdaki stream'lerle test et.
Küçük Bir Deneme
★☆☆ Before.SnakeCasePolicy ile JsonNamingPolicy.SnakeCaseLower'ı ISBN13, HTTPStatus ve OrderV2Id property adlarıyla karşılaştır. Hangisi hangi sonucu veriyor?
★★☆ CustomerProfile.Tags'i varsayılan ayarlarla ama yalnız o property'de populate edecek şekilde [JsonObjectCreationHandling] attribute'u ile işaretle ve aynı JSON'u tekrar deserialize et. Attribute'u class seviyesine taşıyınca ne değişiyor?
★★★ JsonSerializerOptions.Strict'i Bookstore.Api'deki bir endpoint'e uygula: ConfigureHttpJsonOptions içinde Strict'in ayarlarını kopyala ve TypeInfoResolverChain'i koru. Fazladan alan ve duplicate property içeren iki istek gönder; dönen status code'u ve yanıt gövdesini incele.
Bu yazıda .NET 10'a kadar gelenleri topladık. Bir sonraki ve son yazıda 10 Kasım 2026'da çıkan .NET 11'e bakıyoruz: C# union tiplerinin serialization'ı, JSON Lines yazma desteği, yeni in-memory stream'ler, ZstandardStream ve boş payload'da GZip'in davranış değişikliği.