Derinlemesine yazılım eğitimleri için kanalımı takip edebilirsiniz...

.NET 10 & Asp.NET Core İle Server-Sent Events (SSE): Gerçek Zamanlı İletişimin Yeni Yüzü

Merhaba,

Günümüz modern web uygulamalarında, server ile client arasındaki gerçek zamanlı (real-time) iletişim artık bir lüks olmaktan çıkıp bir zorunluluk haline gelmiş bulunmaktadır. Özellikle; borsa takibi, sosyal medya akışları, izleme panelleri gibi canlı bildirim gerektiren veya uzun süren işlemlerin ilerleme durumlarının anlık olarak yansıtılması gereken senaryolarda, kullanıcı deneyimi doğrudan bu yeteneğe bağımlılık göstermektedir.

Geleneksel yaklaşımlarda bu ihtiyacı karşımak için genellikle polling (client’ın belirli aralıklarla sunucuya request atması) veya long polling (client’un uzun süreli bir istekte beklemesi) yöntemleri kullanılıyordu. Bu yöntemler basit gibi görünüyor olsa da önemli dezavantajlar barındırmaktadır. Şöyle ki; polling, gereksiz ağ trafiği yaratmakta, sunucu yükünü artırmakta ve ister istemez gecikmelere yol açmaktadır. Long polling ise bağlantıları uzun süre açık tutarak kaynak tüketimini yükseltmekte, aynı zamanda bağlantı kopmaları durumlarında yeniden kurulum karmaşası getirmektedir. Yani anlayacağınız bu yöntemler ciddi maliyetli süreçlerle bu ihtiyaca eşlik etmekteydiler.

Daha gelişmiş senaryolarda ise WebSockets veya SignalR gibi çift yönlü protokoller devreye sokulup ilerlenmekteydi. Ancak bunlar da genellikle veri akışı süreçlerinde fazlasıyla karmaşıklık oluşturabilmekte ve yoğun kaynak tüketimleri söz konusu olabilmekteydi.

İşte tam bu noktada Server-Sent Events (SSE) imdadımıza yetişmekte ve oldukça çağdaş bir yaklaşımla server ile client arasındaki real-time haberleşme ihtiyacını gidermemizi sağlamaktadır. Peki nasıl bunu yapmaktadır? sorunuzu duyar gibiyim… SSE, HTML5 standardının bir parçası olarak, server’ın tek yönlü (unidirectional) olarak client’a Events Based güncellemelerini HTTP üzerinden iletmesini sağlayan hafif bir mekanizmadır. Tarayıcılar tarafından yerel olarak desteklenen EventSource API’si sayesinde, bağlantı otomatik olarak yeniden kurulabilmekte, Events ID ile kayıp veri telafisi rahatlıkla sağlanabilmekte ve tüm bu süreç standart HTTP altyapısı üzerinde çalışabilmektedir. SSE, WebSockets’in karmaşıklığı olmaksızın SignalR’ın gücüne yaklaşan bir alternatif sunmakta ve özellikle sadece server’dan client’a veri göndermenin gerektiği uygulamalarda oldukça ideal bir altyapı sağlamaktadır.

.NET 10 ile birlikte Asp.NET Core, SSE’ye destek getirerek bu teknolojiyi daha erişilebilir hale getirmiş ve böylece Minimal API’ler yahut controller’lar üzerinde IAsyncEnumerable ile kolayca SSE stream’i döndürebilecek bir yetenekle karşımıza gelmiştir. Bunun yanında TypedResults.ServerSentEvents gibi API’ler sayesinde flushing ve iptal yönetimi gibi türlü ayarlar otomatize olarak halledilebilmektedir. Anlayacağınız, bu yenilik sayesinde geliştiriciler, manuel response stream yapılandırmalarıyla uğraşmaksızın, temiz ve gayet performanslı bir şekilde real-time süreçlerini rahatlıkla yürütebilecek bir imkan elde edebilmektedirler.

Bizler bu içeriğimizde, .NET 10’un getirdiği bu güçlü ve önemli özelliği derinlemesine inceleyecek SSE’nin nasıl çalıştığından tutun, geleneksel yöntemlerle karşılaştırılmasına kadar detaylı bir değerlendirmede bulunuyor olacağız. Hazırsanız, başlayalım…

Server-Sent Events (SSE) Nedir?

Server-Sent Events (SSE), server’ın client’a tek yönlü olarak sürekli veri akışı (stream) yapmasını sağlayan HTTP tabanlı bir standarttır. SSE, uzun süre açık kalabilen tek bir HTTP bağlantısı sağlayabilmekte ve bu bağlantı üzerinden text tabanlı veri formatını kullanarak, tarayıcıdaki dahili olan EventSource API aracılığıyla server ile client arasındaki tek yönlü iletişimi sağlamaktadır.

SSE ne amaçla geliştirilmiştir?
Genellikle, sunucu tarafında olan gelişmelere karşın istemciyi anında haberdar etmemiz gerektiği durumlarda WebSocket, SignalR yahut gRPC kullanıyorduk. Bunlar, bu tarz ihtiyaçlara karşın çoğunlukla fazla ağır ve teferruat barındıran davranışa sahip özelliklerdir. Misal olarak; bu teknolojilerde sunucu çoğu zaman istemciden gelen bir mesaj ya da invoke sonrası akış başlatmaktadır. Oysaki basit seviyede bildirim akışlarının olduğu çalışmalarda SSE ile client’ın server’a herhangi bir şey göndermesine gerek kalmaksızın tek yönlü veri iletişimini rahatlıkla gerçekleştirebilmekteyiz. Yani, sunucu istediği anda istediği sayıda event’i push edebilmekte ve bizler de bunu client’tan edinerek, işlevsellik gösterebilmekteyiz.

SSE bunu nasıl yapmaktadır?
Şöyle; WebSocket yahut SignalR teknolojilerinde, client’ın server’a subscribe olması beklenir… SSE’de ise client sadece dinleme pozisyonunda seyr halindedir. Dolayısıyla radyo mantığında olduğu gibi SSE’de, radyoyu açarsın ve dinlersin, spiker konuşur, sen susarsın. O mantıkta süreç işlemektedir.

Peki SSE gerçekten bir stream midir?
Evet, SSE bir stream’dir. Ama nasıl bir stream olduğu çok önemlidir; server’dan client’a tek yönlü olacak şekilde, parça parça verinin aktığı ve sürekli olan bir stream’dir.


Tabi! Stream deyince bazen kafalar karışabilmektedir. Çünkü stream Byte / IO, Protocol-level ve Application-level olmak üzere üç farklı katmanda kullanılan davranışsal bir süreçtir. Bir başka deyişle üç çeşit stream var da diyebiliriz. Bu katmanları özetlememiz gerekirse eğer;

  • Byte / IO Stream (en alt katman)
    Bu stream türü ilgili verinin harf mi, resim mi, video mu olup olmadığını umursamaksızın ham bir şekilde stream edilmesini sağlamaktadır. TCP stream, .NET Stream, File stream, Network stream bu türe örnek olarak verilebilir.

    Borunun içindeki su üzerinden bir benzetimde bulunabiliriz. Ne olduğu bilinmez, sadece akar…

  • Protocol-level Stream (taşıma katmanı)
    Bu stream’de ise byte’lar kurallara göre akış gösterir ve akışın nerede başlayacağı ve nerede biteceği bellidir. HTTP chunked response, HTTP/2 stream ve WebSocket frame stream buna örnek olarak verilebilir.

    Boruya vanalar, basınç ölçerler ve yön dirsekleri eklenmiş hali bu stream türü için güzel bir örnektir…

  • Application-level Stream
    Artık byte değil, anlamlı mesajlar akışta varlık göstermektedir. gRPC streaming buna tam tadında örnek olacaktır. Ayrıca SSE’de bu tür stream’e girmektedir. Genellikle biz developer’lara, business logic süreçlerinde ve bu içeriğimizde ele aldığımız gibi client ile server’ın haberleşmesi durumlarında oldukça hitap etmektedir.

SignalR Yerine Neden SSE?

Aslında bu sorunun cevabını önceki satırlarda yüzeysel vermiş bulunmaktayız. Daha da detaylandırırsak, ihtiyaç tek yönlü ve sürekli veri akışı gerektiriyorsa eğer SignalR yapısal olarak fazlasıyla karmaşık kalabilmektedir. Şöyle ki; client ile server arasındaki haberleşme sürecinde SignalR, çift yönlü bir iletişim sağlayacaktır ve bunun yanında bu iletişimin sağlanabilmesi için de Hub’larla belirli iletişim protokolleri gerektirecektir. Ee bizler sadece tek yönlü ve sürekli bir akış istiyorsak bu durum için SignalR fazlasıyla külfettir diyebiliriz. Ayrıca, client için de SignalR ile kontağı sağlayacak bir kütüphaneye bağımlılık mevzu bahis olacaktır ve bunun yanında, özellikle ölçeklendirme süreçlerinde client’ları sürekli aynı server’lara yönlendirebilmek için de sticky session davranışlarının da yapılandırılma ihtiyacı söz konusu olacaktır. Amiyane tabirle -ölme eşşŞeeğim ölmee-…

Zaten farkındaysanız eğer SignalR bilindiği üzere gerçek zamanlı (real-time) etkileşime odaklıyken, SSE ise tipik -canlı yayın- mantığında varlık göstermektedir. Bu açıdan olayı değerlendirdiğimizde AI / LLM streaming durumlarında SSE daha kullanışlı gözükmektedir. Düşünsenize… Bize dakikalarca yanıt üretecek bu tarz çalışmalarda server işini yapacak, client ise sadece dinleme gerçekleştirecektir. Böylece ölçeklendirmenin olduğu senaryolarda client’a verinin hangi server’dan geldiğinin bir önemi olmayacağından oldukça kullanışlı olacaktır! Ayrıca client’ın iş yükü açısından native destek (EventSource) sayesinde herhangi bir kütüphaneye bağımlılıkta olmayacak ve olası connection kopmalarında otomatik bir şekilde reconnect sağlanıyor olacaktır.

Kurumsal ağlarda WebSocket sık sık engellendiği için SSE bu tarz durumlarda fark yaratacak ve ekstradan bir güven sağlayacaktır.

SSE’de Backpressure Problemi

Ancak SSE’de dikkat edilmesi gereken bir husus vardır! Olası Backpressure derdi… Backpressure ne la? dediğinizi de duyar gibiyim. Hemen izah edelim…


Backpressure

Backpressureİkili haberleşmelerin olduğu sistemlerde, tüketicinin işleme kapasitesi üretim hızının gerisinde kaldığında, sistemin tüketici tarafından iradeli bir şekilde ya da tüketicinin kapasitesine göre doğrudan veya dolaylı yollarla üretimi sınırlamak veya yavaşlatmak için uyguladığı kontrol mekanizmasına Backpressure denmektedir.

Eğer ki, bu mekanizmayla bir kontrol sağlanmazsa ister istemez memory şişebilmekte, thread/task kilitlenmeleri yaşanabilmekte, latency patlamaları söz konusu olabilmekte ve nihai olarak servisler arası bağlantı koparak iletişim kesilebilmektedir.

SSE ise yapısal olarak tek yönlü bir akış mekanizmasına sahip olduğu için server’dan sürekli olarak gönderilecek veri, client tarafından yalnızca dinlenmekte ve işlenmektedir. Haliyle bir darboğaz olduğu taktirde client tarafından server’a ‘yavaşla’, ‘lOo’, ‘la Dur Laaynlo’ şeklinde bir geri bildirim olmayacağından dolayı (ki biz bu geri bildirime backpressure sinyali diyoruz) SSE’de doğal bir backpressure kontrolü yoktur diyebiliriz.

Basit Server-Sent Events Çalışması

.NET 10’da SSE API’nin en güzel yanı sadeliğinden gelmektedir. Bu API’yi kullanabilmek için tek yapılması gereken aşağıdaki gibi basit bir işlemdir.

app.MapGet("/stream", (ChannelReader<int> channelReader, CancellationToken cancellationToken) =>
{
    return Results.ServerSentEvents
    (
        channelReader.ReadAllAsync(cancellationToken),
        eventType: "numbers"
    );
});

Bakın… Görüldüğü üzere, server’dan Results.ServerSentEvents API’si sayesinde IAsyncEnumerable<T> türünde kolaylıkla event stream edilebilmektedir.

Bizler burada Channel yapılanması üzerinden güzel bir örneklendirmede bulunmuş vaziyetteyiz. ReadAllAsync bir IAsyncEnumerable<T> döndürmekte ve tarayıcıya -bu bağlantıyı açık tut- demektedir…

Tabi bir yandan herhangi bir client bu endpoint’e bir istek gönderdiğinde;

  1. Server bir Content-Type: text/event-stream header’ı gönderecektir.
  2. Connection, veri beklenirken aktif ve bekleme modunda kalacaktır.
  3. Uygulamada bu Channel’ı tetikleyecek olan herhangi bir eylem neticesinde IAsyncEnumerable<T> türünde veri işlenecek ve açık bir HTTP pipe üzerinden tarayıcıya gönderilecektir.

Böylece, durum bilgisi gerektiren bir protokolün getirdiği ek yük olmaksızın ‘anlık’ bildirimleri yönetmenin inanılmaz derecede verimli bir yolu kullanılmış olacaktır.

Kayıp Veri Telafisi Nasıl Sağlanır?

Yukarıda yaptığımız basit çalışma her ne kadar harika iş çıkarıyor olsa da zayıf bir nokta barındırmaktadır! O da, dayanıklılıktan (resilience) yoksun olmasıdır.

Gerçek zamanlı akışlarla ilgili en büyük zorluklardan biri bağlantı kesintileridir. Tarayıcı otomatik olarak yeniden bağlanana kadar, birçok mesaj çoktan gönderilmiş ve haliyle kaybolmuş olabilir. Bu kayıp verileri telafi edebilmek için SSE’nin built-in olarak getirdiği bir mekanizmadan istifade ediyor olacağız. Last-Event-ID mekanizmasından.

Last-Event-ID mekanizması nasıl çalışmaktadır?
Last-Event-ID‘nin çalışma yapısı oldukça basit bir mantığa dayanmaktadır. Şöyle ki; server’da her event’e benzersiz bir ID verilir. Client aldığı son event ID’yi hatırlar ve bağlantı koptuğu taktirde otomatik reconnect sağlayarak, Last-Event-ID header’ında son event ID değerini server’a gönderir. Böylece server, bu ID’den sonrasını yeniden göndermeye çalışır.

SSE’de kayıp veri önlenemez, ancak sonradan telafi edilir.

Şimdi bu mantığa baktığımızda SSE’de kayıp verilerin önlenemeyeceğini, ancak sonradan telafi edilebileceğini söyleyebiliriz. Ne de olsa SSE reliable (güvenilir) stream değildir ve doğal olarak network kopması, client’ın refresh edilmesi, kullanılan proxy’nin ya da load balancer’ın timeout’a düşmesi vs. gibi durumlara karşı maruz kalabilecek bir teknolojidir.

SSE’de, o an gönderilen ama client’a ulaşmayan event kayıptır!

Peki nasıl yapacağız?
SSE’de, verileri olası kayıp durumlarına karşı korunaklı hale getirebilmek ve ID gibi metadata’lara sarabilmek için SseItem<T> türünü kullanabiliriz. Tabi tüm bu süreci hazır bir şekilde bizlere sunan bir yapı ne yazık ki bulunmamaktadır. Bundan kaynaklı her şeyi baştan sona kendimizin kurgulaması gerekmektedir.

Bunun için şöyle bir buffer servisi inşa edilebilir:

    public class NumberEventBufferService
    {
        private readonly ConcurrentQueue<SseItem<int>> _buffer;
        private readonly int _maxBufferSize;
        private long _currentId = 0;

        public NumberEventBufferService(int maxBufferSize = 100)
        {
            _buffer = new();
            _maxBufferSize = maxBufferSize;
        }

        public async Task<SseItem<int>> WriteToBufferAsync(int value, CancellationToken cancellationToken = default)
        {
            var sseItem = new SseItem<int>(value, "numbers")
            {
                EventId = Interlocked.Increment(ref _currentId).ToString(),
                ReconnectionInterval = TimeSpan.FromSeconds(10)
            };

            _buffer.Enqueue(sseItem);

            //Eğer buffer'ın limiti dolduysa, ilk değer kuyruktan çıkarılmaktadır.
            while (_buffer.Count > _maxBufferSize)
                _buffer.TryDequeue(out _);

            return sseItem;
        }

        public List<SseItem<int>> GetEventsSince(int lastEventId)
            => _buffer
                .Where(item => int.Parse(item.EventId) > lastEventId)
                .OrderBy(item => int.Parse(item.EventId))
                .ToList();
    }

Dikkat edilirse eğer bu serviste, stream edilen değerleri belirli sayıda (maxBufferSize) tutmak amacıyla bir buffer oluşturulmaktadır. Bu buffer içerisinde veriler SseItem türünde saklanmakta; her bir veriye ait EventId değeri ise Interlocked kullanılarak üretilmekte ve metadata olarak depolanmaktadır.

WriteToBufferAsync metodundaki 24 ile 25. satırlarda da, buffer içerisindeki veri limiti belirtilen eşiği aştığında kuyruktaki ilk kayıt çıkarılmakta ve yenisi kuyruğa eklenmektedir. Böylece yalnızca belirtilen veri limiti kadar stream verisi bellekte tutulmaktadır. Bu da, olası veri kaybı durumlarında telafi için gayet yeterli bir veri stoğu sağlamaktadır.

GetEventsSince metodunda ise client’tan gelecek olan Last-Event-ID değerinden büyük üretilmiş veri varsa eğer buffer’dan elde edilmektedir. Eğer ki, buradan veri geliyorsa bu, kayıp veri söz konusu anlamına gelmektedir. Bu aşamadan sonra stream endpoint’ini aşağıdaki gibi geliştirmek gerekmektedir:

app.MapGet("/stream", (
    NumberEventBufferService bufferService,
    ChannelReader<int> channelReader,
    [FromHeader(Name = "Last-Event-ID")] string? lastEventId,
    CancellationToken cancellationToken) =>
{
    //Tüm süreci üstlenecek local function
    async IAsyncEnumerable<SseItem<int>> StreamEvents()
    {
        //Önce kaybolan verileri telafi et!
        if (int.TryParse(lastEventId, out int _lastEventId))
        {
            var missedEvents = bufferService.GetEventsSince(_lastEventId);
            foreach (var missedEvent in missedEvents)
                yield return missedEvent;
        }

        //Sonra stream'e devam et.
        await foreach (var number in channelReader.ReadAllAsync(cancellationToken))
        {
            var item = await bufferService.WriteToBufferAsync(number);
            yield return item;
        }
    }

    //Sonra stream'e devam et.
    return TypedResults.ServerSentEvents
    (
        StreamEvents()
    );
});

Dikkat ederseniz, client’tan gelecek ilk isteğin header’ındaki ‘Last-Event-ID’ değeri alınmakta ve içeride oluşturulan StreamEvents isimli local function içerisinde, bu değer ile buffer’da kayıp veri var mı yok mu kontrol edilmektedir. Eğer kayıp veri varsa önce onlar stream edilerek telafi edilmekte, devamında ise normal stream’e verisel anlamda kalınan yerden devam edilmektedir.

JavaScript Tarafında Server-Sent Events Tüketimi

Client tarafında tek bir paket yüklemeye gerek kalmaksızın, tarayıcıların built-in olarak getirdiği EventSource API’sinden istifade edebiliriz. Bu API’yi kullanabilmek için öncelikle aşağıdaki gibi bir instance oluşturulması gerekmektedir:

const eventSource = new EventSource("/stream");

Ardından bu instance üzerinden aşağıdaki event’ler aracılığıyla tüm süreç yürütülebilmektedir:

  • onopen
    İlk bağlantı kurulduğunda tetiklenen event’tir.

            eventSource.onopen = (event) => {
                console.log('Connection opened', event);
            };
    
  • onmessage
    Server’dan eventType‘ı belirtilmemiş bir veri gönderildiyse eğer bu event tetiklenecektir.

            eventSource.onmessage = (event) => {
                console.log(event);
            }
    
  • onerror
    Bağlantı koptuğunda, network hatası alındığında veya timeout olduğunda bu event tetiklenecektir. Bunun yanında EventSource API otomatik olarak reconnect’e yeltenerek otomatik bir şekilde tekrar bağlantı kurmaya çalışacaktır.

            eventSource.onerror = () => {
                console.log("connection lost, reconnecting...");
            };
    
  • addEventListener
    Server’dan herhangi bir eventType‘da veri geldiğinde, o type’a özel bir dinleme yapıp, veriyi yakalamak istiyorsak eğer bu fonksiyonu kullanabiliriz.

    İçerik boyunca SSE’yi örneklendirirken esasında eventType‘ı özelleştirilmiş stream süreçlerini örneklendirdiğimiz için, bu akışlardan gelecek verileri haliyle bu fonksiyonla karşılamak mecburiyetinde kalmış bulunuyoruz. Hatta SseItem nesnesini kullandığımız NumberEventBufferService sınıfında bile, ilgili nesnenin constructor’ının 2. parametresine ‘numbers’ değerini vererek, ona da bir eventType tanımlamaktayız. Bundan kaynaklı o örnekte de bu fonksiyon üzerinden bir consume çalışması gerçekleştiriyoruz.

Nihai olarak;
İncelememiz boyunca, .NET 10’da gelen Server-Sent Events özelliği sayesinde, geliştirdiğimiz yazılımlarımıza gerçek zamanlı notifikasyon altyapısını tek yönlü bir şekilde rahatlıkla uygulayabileceğimizi ve böylece karmaşık yapılandırma gerektiren SignalR gibi teknolojilerden bu tarz basit ihtiyaçları soyutlayabileceğimizi görmüş olduk. Tabi bu, çift yönlü iletişimin kaçınılmaz olduğu büyük ölçekli senaryolarda SignalR’ın hala kendini kanıtlamış etkili bir seçenek olmasının önüne engel değildir! Sadece doğru yerde, doğru alternatiflere yönelmek kastedilmektedir.

İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…

Not : Örnek çalışmaya aşağıdaki GitHub adresinden erişebilirsiniz.
https://github.com/gncyyldz/Server_Sent_Events_Example

Bunlar da hoşunuza gidebilir...

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir