.NET 8'den .NET 11'e Real-time'da Gelenler: Stateful Reconnect, Native AOT, SSE ve SignalR Authentication Refresh
.NET 8 stateful reconnect'i, .NET 9 polymorphic hub parametrelerini, ActivitySource'u ve SignalR için Native AOT'yi, .NET 10 TypedResults.ServerSentEvents'i getirdi; .NET 11 ise bağlantıyı koparmadan token yenileyen authentication refresh'i ekliyor. AOT'de 28 MB'a karşı 82 MB ölçümü, strongly typed hub'ın AOT uyarısı, iptal edilebilir hub çağrıları, OpenAPI 3.2'de SSE ve eski ASP.NET SignalR.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve @microsoft/signalr 10.0.11 ile derlenip denenmiştir; .NET 11 özellikleri yazı yazılırken RC (Release Candidate) aşamasındaki dokümantasyondan anlatılmıştır, kod olarak denenmemiştir.
Önce Tahmin Et
Küçük bir SignalR hub'ını Native AOT (Ahead-of-Time) ile yayımlıyorsun: tek dosyalık, .NET runtime'ı gerektirmeyen bir native çalıştırılabilir. Sence dosya kaç MB olur? İlk negotiate isteğine JIT (Just-in-Time) ile çalışan aynı uygulamadan ne kadar hızlı cevap verir? Bir de: serinin dördüncü yazısında önerdiğimiz strongly typed hub (Hub<IClient>) AOT ile çalışır mı?
Bu yazı serinin sürüm yazısı. .NET 8'den .NET 11'e kadar real-time tarafına gelenleri, önceki yazılarda gördüklerimize bağlayarak sıralıyoruz; .NET 9'un AOT desteğini de gerçek bir ölçümle deniyoruz.
.NET 8: Stateful Reconnect
.NET 8'in real-time tarafındaki büyük yeniliği stateful reconnect'ti: sunucuda AllowStatefulReconnects, istemcide withStatefulReconnect(). İki taraf onaylanmamış mesajları bir buffer'da tutuyor ve transport kısa süre koptuğunda aynı connection id ile devam ediyor. Serinin yedinci yazısında ölçtüğümüz sonucu tekrar edelim: 100 mesajlık hızlı bir yayında anlık bir kopuşta normal istemci bir mesaj kaybetti, stateful istemci hiç kaybetmedi; ama 2 saniyelik bir kesintide JavaScript client'ı aynı bağlantıya yalnızca bir kez yeniden bağlanmayı denediği için ikisi de yeni bir bağlantıyla devam etti ve aynı mesajları kaybetti. Stateful reconnect kısa transport kopuşları için; uzun kesintilerde uygulama seviyesinde telafi hâlâ senin işin.
.NET 8 ayrıca ASP.NET Core'a yerleşik metric'leri getirdi; sekizinci yazıda okuduğumuz signalr.server.active_connections ve signalr.server.connection.duration bu sürümden beri var.
.NET 9: Polymorphic Parametreler, ActivitySource ve KeepAliveTimeout
// .NET 9: hub method'u base tip alabiliyor; tip bilgisi JSON'daki "$type" alanından geliyor
[JsonPolymorphic]
[JsonDerivedType(typeof(OrderShipped), "shipped")]
[JsonDerivedType(typeof(OrderCancelled), "cancelled")]
public abstract record OrderEvent(int OrderId);
public sealed record OrderShipped(int OrderId, string TrackingNumber) : OrderEvent(OrderId);
public sealed record OrderCancelled(int OrderId, string Reason) : OrderEvent(OrderId);
public sealed class OrderEventsHub : Hub
{
public string Report(OrderEvent orderEvent) => orderEvent switch
{
OrderShipped shipped => $"order {shipped.OrderId} shipped with {shipped.TrackingNumber}",
OrderCancelled cancelled => $"order {cancelled.OrderId} cancelled: {cancelled.Reason}",
_ => "unknown event"
};
}
.NET 9'dan önce hub method parametresi somut bir tip olmak zorundaydı; base tip yazarsan alt tipin alanları kaybolurdu. Artık System.Text.Json'ın polymorphism attribute'larıyla işaretlenmiş bir base tip alabiliyor. Node istemcisinden:
console.log(await c.invoke('Report', { $type: 'shipped', orderId: 42, trackingNumber: 'TRK-42-001' }));
console.log(await c.invoke('Report', { $type: 'cancelled', orderId: 43, reason: 'Out of stock' }));
try { await c.invoke('Report', { orderId: 44 }); } catch (e) { console.log('no $type:', e.message); }
order 42 shipped with TRK-42-001
order 43 cancelled: Out of stock
no $type: Failed to invoke 'Report' due to an error on the server.
İstemcinin $type alanını göndermesi şart; göndermeyen çağrı sunucuda hata veriyor ve detaylı hatalar kapalıyken istemci yalnızca genel mesajı görüyor. Bu, TypeScript tarafında discriminated union'larla doğal bir eşleşme: { $type: 'shipped', ... } | { $type: 'cancelled', ... }.
.NET 9'un diğer iki eklentisini önceki yazılarda kullandık. Hub method çağrıları için Microsoft.AspNetCore.SignalR.Server adlı bir ActivitySource ve .NET client'ı için Microsoft.AspNetCore.SignalR.Client; sekizinci yazıda her hub çağrısının span'ini rpc.system=signalr tag'iyle gördük. Çıplak WebSocket'te ise KeepAliveTimeout: ping'e pong gelmezse bağlantıyı koparan, varsayılanı kapalı olan ayar; ikinci yazıda KeepAliveInterval ile birlikte kullandık.
.NET 9: SignalR için Trimming ve Native AOT
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddSignalR();
builder.Services.Configure<JsonHubProtocolOptions>(options =>
options.PayloadSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
WebApplication app = builder.Build();
app.MapHub<OrderEventsHub>("/hubs/order-events");
app.Run();
[JsonSerializable(typeof(OrderEvent))]
[JsonSerializable(typeof(string))]
internal partial class AppJsonContext : JsonSerializerContext;
AOT'de reflection tabanlı JSON (JavaScript Object Notation) serialization çalışmadığı için hub'ın kullandığı bütün tipleri bir JsonSerializerContext source generator'ına bildiriyor ve onu hub protocol'ünün resolver zincirinin başına koyuyoruz. dotnet publish -c Release macOS arm64'te 6 saniyede bitti ve tek bir dosya üretti:
-rwxr-xr-x@ 1 mertozen wheel 10496712 Nov 8 05:59 AotHub
Mach-O 64-bit executable arm64
10,5 MB'lık bu dosya .NET runtime'ı olmayan bir makinede çalışıyor. Yukarıdaki polymorphic testin çıktıları da bu native dosyadan alındı. Uygulamayı başlatıp ilk negotiate isteği cevaplanana kadar geçen süreyi ve o anki fiziksel belleği, aynı projenin JIT ile çalışan Release çıktısıyla karşılaştırdım (her biri iki kez):
native first negotiate after 59 ms, rss 27936 KB
jit first negotiate after 869 ms, rss 82768 KB
native first negotiate after 57 ms, rss 27936 KB
jit first negotiate after 107 ms, rss 81696 KB
Native sürüm iki koşuda da yaklaşık 58 ms'de cevap verdi ve 28 MB bellek kullandı. JIT sürümünün ilk koşusu 869 ms (dosyalar diskten ilk kez okunuyor), ikincisi 107 ms; bellek iki koşuda da 82 MB civarı. Boşta bekleyen bir hub için bellekte üç kata yakın fark var; çok sayıda küçük real-time servisi container'da çalıştırıyorsan ya da hızlı ölçeklenmen gerekiyorsa bu anlamlı.
Baştaki son sorunun cevabı: hayır. Aynı hub'ı Hub<IOrderEventsClient> yaptığımda publish şu uyarıları verdi:
ILC : AOT analysis warning IL3050: OrderEventsHub.OrderEventsHub(): Using member 'Microsoft.AspNetCore.SignalR.Hub`1<IOrderEventsClient>.Hub`1()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating a proxy instance requires generating code at runtime. [AotTyped.csproj]
Program.cs(23,21): warning IL3050: Using member 'Microsoft.AspNetCore.SignalR.Hub<T>.Hub()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating a proxy instance requires generating code at runtime. [AotTyped.csproj]
Strongly typed hub, istemci interface'i için çalışma zamanında bir proxy class'ı üretiyor; Native AOT'de bu mümkün değil. Microsoft'un dokümantasyonu da sınırlamaları açıkça listeliyor: AOT'de yalnızca JSON protocol'ü (MessagePack yok), strongly typed hub yok (trimming ile var), değer tipi (struct) öğeli IAsyncEnumerable<T> ve ChannelReader<T> parametreleri yok, async dönüş tipi olarak yalnızca Task, Task<T>, ValueTask ve ValueTask<T>. AOT'ye geçeceksen klasik Hub ile SendAsync("MethodName", ...) kullan ve method adlarını sabitlerde tut. Bu arada derleme sırasında SDK'nin (Software Development Kit) getirdiği Microsoft.DotNet.ILCompiler 10.0.13 paketi için NuGet audit'in bir güvenlik uyarısı (NU1903) verdiğini de gördüm; AOT ile yayımlıyorsan SDK'yi güncel tut.
.NET 10: TypedResults.ServerSentEvents
.NET 10'un real-time tarafındaki katkısı SSE (Server-Sent Events) oldu: TypedResults.ServerSentEvents bir IAsyncEnumerable<T> ya da IAsyncEnumerable<SseItem<T>> alıp doğru header'larla bir event stream'e çeviriyor ve System.Net.ServerSentEvents'teki SseItem<T>'ye EventId ve ReconnectionInterval property'leri eklendi. Girişini ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 10 Yenilikleri" yazısında yaptık; bu serinin üçüncü yazısında Last-Event-ID ile kaldığı yerden devam, ilk byte'ın geç gitmesi yüzünden proxy'nin 504 dönmesi ve Content-Encoding: identity sayesinde Nginx gzip'inin akışı bozamaması gibi ayrıntıları denedik. Kısa özet: tek yönlü bildirimler için .NET 10'da artık SignalR'a ihtiyaç duymadan, framework'ün desteğiyle düz HTTP (Hypertext Transfer Protocol) kullanabiliyorsun.
.NET 11: SignalR Authentication Refresh
Serinin altıncı yazısında SignalR'ın en can sıkıcı güvenlik boşluğunu görmüştük: kullanıcı bağlanırken bir kez doğrulanıyor ve token'ın süresi dolsa bile bağlantı açık kalıyor; CloseOnAuthenticationExpiration ile kapatırsan da kullanıcı bir kopuş, kaçan mesajlar ve kaybolan gruplarla ödüyor. .NET 11 bunun üçüncü bir yolunu getiriyor: bağlantıyı koparmadan token'ı yenilemek.
.NET 11 dokümantasyonuna göre (sayfanın tarihi 19 Ekim 2026, RC dönemi) mekanizma şöyle: MapHub'ın options'ında EnableAuthenticationRefresh açılıyor ve Microsoft bunu CloseOnAuthenticationExpiration ile birlikte açmayı öneriyor; yenilenmeyen bağlantı süresi dolunca kapanıyor. Sunucu /negotiate'in yanında bir /refresh endpoint'i açıyor ve negotiate yanıtında token'ın kalan ömrünü bildiriyor. İstemci, süre dolmadan önce yeni bir token ile bu endpoint'e gidiyor; bağlantıyı connection token ile tanımlıyor ve başarılı olursa hub'ın Context.User'ı yeni kimlikle güncelleniyor. Hub'da OnAuthenticationRefreshedAsync override edilebiliyor, options'taki OnAuthenticationRefresh callback'i ise eski ve yeni kimliği karşılaştırıp yenilemeyi reddedebiliyor; varsayılan olarak yeni kimliğin aynı SignalR kullanıcısına karşılık gelmesi gerekiyor, aksi hâlde 403.
İstemci tarafında .NET client'ında otomatik yenileme varsayılan olarak açık ve WithAuthenticationRefresh ile ayarlanıyor (ör. süre dolmadan ne kadar önce yenileneceği); AuthenticationRefreshed ve AuthenticationRefreshFailed event'leri ve elle tetiklemek için RefreshAuthenticationAsync var. TypeScript client'ında karşılıkları withAuthenticationRefresh, onAuthenticationRefreshed, onAuthenticationRefreshFailed ve refreshAuthentication. Başarısız bir yenileme bağlantıyı tek başına kapatmıyor ve kendiliğinden tekrar denenmiyor; token süresi dolunca bağlantı kapanıyor. Dokümantasyonun altını çizdiği iki pratik nokta: reverse proxy, WAF (Web Application Firewall) ve CORS (Cross-Origin Resource Sharing) kurallarına /refresh route'unu eklemek (negotiate'i geçirip refresh'i engelleyen bir proxy her yenilemeyi başarısız yapar) ve onAuthenticationRefreshFailed'da kullanıcıyı yeniden login'e yönlendirmek. Bu yazı yazılırken npm'deki @microsoft/signalr'ın next etiketi 11.0.0-preview.7 ve latest 10.0.11'di; API adları GA (General Availability) sürümünde doğrulanmalı.
.NET 11: İptal Edilebilir Hub Çağrıları ve OpenAPI 3.2'de SSE
.NET 10'a kadar istemci yalnızca streaming çağrılarını iptal edebiliyordu (yedinci yazıda dispose() ile sunucudaki CancellationToken'ı tetiklemiştik). Normal bir InvokeAsync çağrısına verilen token istemcinin beklemeyi bırakmasını sağlıyor, ama sunucudaki method çalışmaya devam ediyordu. .NET 11 dokümantasyonuna göre artık istemci InvokeAsync'e verilen token iptal edildiğinde sunucuya bir iptal mesajı gönderiyor ve hub method'unun CancellationToken parametresi tetikleniyor. Uzun süren bir rapor ya da arama method'unda kullanıcı "vazgeç"e bastığında sunucu tarafındaki işin de durması demek; hub method'larına şimdiden CancellationToken parametresi eklemek, .NET 11'e geçtiğinde bu davranışı bedavaya almanı sağlar.
SSE tarafında .NET 11, OpenAPI 3.2'yi varsayılan belge sürümü yapıyor ve SseItem<T> dönen endpoint'leri text/event-stream yanıtı için 3.2'nin itemSchema alanıyla tarif ediyor. Önceki sürümlerde belge yalnızca "bir string akışı" diyordu; artık her event'in data alanının şeması (ör. #/components/schemas/Todo) ile event ve id alanları belgede yer alıyor ve istemci üreten araçlar akışın tipini bilebiliyor. Dokümantasyondaki önemli not: stream TypedResults.ServerSentEvents ile döndürülmeli; handler doğrudan IAsyncEnumerable<SseItem<T>> dönerse SSE değil JSON olarak serialize ediliyor.
Eski ASP.NET SignalR'ı Tanımak ve Geçiş Notları
Bir şirkette bakımını devraldığın bir .NET Framework uygulamasında "SignalR" görürsen büyük ihtimalle bu yazı dizisindeki SignalR değil, 2013'ten kalma ASP.NET SignalR'dır. İkisi aynı adı taşıyor ama protokolleri uyumsuz: ASP.NET Core SignalR istemcisi eski sunucuya, eski istemci de yeni sunucuya bağlanamıyor. Tanımak için:
| Konu | ASP.NET SignalR (.NET Framework) | ASP.NET Core SignalR |
|---|---|---|
| NuGet paketi | Microsoft.AspNet.SignalR | ASP.NET Core'un parçası |
| Sunucu kaydı | OWIN (Open Web Interface for .NET) app.MapSignalR() | AddSignalR() + MapHub<T>() |
| JavaScript client | jQuery tabanlı $.connection, otomatik üretilen /signalr/hubs proxy'si | @microsoft/signalr, jQuery yok |
| Hub dışından gönderim | GlobalHost.ConnectionManager.GetHubContext<T>() | DI (Dependency Injection) ile IHubContext<T> |
| Yeniden bağlanma | Otomatik, sunucu mesajları tamponlar | withAutomaticReconnect, isteğe bağlı stateful reconnect |
| Scale-out | Redis, SQL (Structured Query Language) Server, Service Bus backplane'leri | Redis backplane, Azure SignalR Service |
Geçişte en çok zorlayan farklar davranışsal. Eski sürüm yeniden bağlanmayı ve mesaj tamponlamayı kendisi yapıyordu; yenisinde bunları serinin yedinci yazısındaki gibi bilinçli olarak kuruyorsun. Eski sürümde istemci hub method'larını sunucuda tanımlı proxy'ler üzerinden çağırıyordu; yenisinde invoke('MethodName') ile string olarak. İki sürüm aynı anda çalışmak zorundaysa (mobil uygulamaların eski sürümleri gibi) ayrı endpoint'lerde paralel çalıştırıp eski istemcileri zamanla emekliye ayırmak en az riskli yol.
.NET 8, 9 ve 10 arasında geçişte ise real-time tarafında kırıcı bir değişiklik yaşamadım; bu serinin bütün örnekleri .NET 10 üzerinde, varsayılan ayarlarla çalıştı. Geçişte bakılacak yer davranış varsayılanları: KeepAliveTimeout gibi yeni ayarlar varsayılan olarak kapalı geliyor, açmak senin kararın.
Üretimde Dikkat
Sürüm yükseltirken istemci ve sunucu paketlerini birlikte düşün. Stateful reconnect, authentication refresh gibi özellikler iki tarafın da desteklemesini gerektiriyor; sunucuyu yükseltip mobil uygulamanın eski @microsoft/signalr sürümüyle kalması, özelliğin sessizce devreye girmemesi demek. Negotiate yanıtı hangi özelliklerin anlaşıldığını söylüyor; sorun araştırırken ilk bakılacak yer orası.
AOT'yi bir performans düğmesi gibi değil bir kısıt seti gibi değerlendir. 28 MB'a karşı 82 MB ve milisaniyeler süren başlangıç gerçek kazançlar, ama strongly typed hub'dan, MessagePack'ten ve reflection kullanan library'lerden vazgeçmeyi gerektiriyor. Build'deki IL3050 ve IL2026 uyarılarını hata gibi ele al; AOT'de çalışma zamanında patlayacak kodun tek ön uyarısı onlar.
.NET 11'in authentication refresh'ini kullanmayı planlıyorsan, proxy ve WAF kurallarını, CORS policy'lerini ve rate limit'leri /refresh için şimdiden gözden geçir; token ömrü kısaldıkça bu endpoint'e gelen istek sayısı artacak.
Sık Düşülen Tuzaklar
Stateful reconnect'i açıp mesaj kaybının bittiğini sanmak. Yalnızca anında toparlanan kısa kopuşları kapsıyor.
Native AOT'ye geçip strongly typed hub'ı korumak. Publish uyarı verir, çalışma zamanında hata alırsın.
Polymorphic hub parametresinde istemcinin $type göndermesini unutmak. Çağrı sunucuda başarısız olur.
.NET 11 özelliklerini RC dokümantasyonuna göre production'a almak. API adları ve davranışlar GA'da değişebilir; GA sürüm notlarıyla doğrula.
Eski ASP.NET SignalR istemcisiyle yeni sunucuya bağlanmaya çalışmak. Protokoller uyumsuz; iki tarafı birlikte taşımak gerekiyor.
.NET 11'in iptal desteğine güvenip hub method'larına CancellationToken eklememek. İptal mesajı gelir, ama dinleyen bir token yoksa iş devam eder.
Küçük Bir Deneme
★☆☆ OrderEventsHub'a [JsonDerivedType(typeof(OrderDelayed), "delayed")] ile üçüncü bir event tipi ekle ve AOT ile yeniden yayımla. AppJsonContext'e bir şey eklemen gerekti mi?
★★☆ Aynı hub'ı <PublishTrimmed>true</PublishTrimmed> ile (AOT olmadan) yayımla ve Hub<IOrderEventsClient> ile bu kez uyarı alıp almadığını, dosya boyutunu ve başlangıç süresini Native AOT çıktısıyla karşılaştır.
★★★ .NET 11 GA çıktığında altıncı yazıdaki örneği EnableAuthenticationRefresh ve TypeScript client'ındaki withAuthenticationRefresh ile güncelle: 30 saniyelik token'larla iki dakika boyunca bağlantının hiç kopmadığını ve WhoAmI'nin döndürdüğü token bitiş zamanının ilerlediğini doğrula.
Seri burada bitiyor. Dokuz yazıda polling'den başlayıp çıplak WebSocket'e, SSE'ye, SignalR'ın hub, grup, güvenlik ve dayanıklılık katmanlarına ve birden fazla sunucuya kadar geldik. Bir sonraki adım bu parçaları kendi uygulamanda birleştirmek: önce "bu özellik gerçekten real-time olmalı mı?" sorusunu sor, sonra ihtiyacına uyan en basit tekniği seç.