Mert Özen Her Satırda Daha İleri
Backend Real-time Uygulamalar — #05

SignalR'da Kullanıcılar, Gruplar ve Connection Yaşam Döngüsü: IHubContext ile Sunucunun Her Yerinden Mesaj Göndermek

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
SignalR'da Kullanıcılar, Gruplar ve Connection Yaşam Döngüsü: IHubContext ile Sunucunun Her Yerinden Mesaj Göndermek

Clients.User bir kullanıcının bütün sekmelerine gider, grup üyeliği ise bağlantıya aittir ve yeniden bağlanmada kaybolur. IUserIdProvider, Caller/Others/OthersInGroup, OnConnectedAsync ile OnDisconnectedAsync'in beklenmedik sırası, Redis'te sayaçla presence ve IHubContext ile endpoint'ten, BackgroundService'ten ve consumer'dan bildirim göndermek.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Redis 8.2 ve StackExchange.Redis 3.4.31 ile derlenip denenmiştir.

Önce Tahmin Et

Ada, Bookstore'u hem laptop'unda hem telefonunda açık tutuyor; iki ayrı SignalR bağlantısı. Laptop'taki sekme 42 numaralı siparişin grubuna katılmış. Sunucu Clients.User("ada") ile bir mesaj gönderiyor: kaç bağlantıya gider? Sonra laptop'un Wi-Fi'ı bir anlığına kopuyor ve SignalR yeniden bağlanıyor. Sunucu Clients.Group("order-42") ile bir not gönderdiğinde laptop onu alır mı?

İkinci sorunun cevabı production'da en çok "bildirimler bazen gelmiyor" diye rapor edilen hatanın sebebi. Bu yazıda mesajın kime gittiğini, bağlantının ne zaman doğup öldüğünü ve sunucunun her yerinden nasıl mesaj gönderildiğini gerçek bir çoklu istemci testiyle inceliyoruz.

Kullanıcı Kimdir? IUserIdProvider

SignalR'da üç adres türü var: bağlantı (connection id), kullanıcı (user id) ve grup. Bağlantı id'si her bağlantıda yeniden üretiliyor; kullanıcı id'si ise authentication'dan geliyor. Authentication'ın ayrıntısını (JWT (JSON Web Token), access_token query string'i) bir sonraki yazıya bırakıp burada kimliği taklit eden, yalnızca bu yazıya özel bir handler kullanıyoruz: ?user=ada&role=warehouse query parametrelerinden claim'ler üretiyor. Production'da asla böyle bir şey kullanma; amaç kimliğin SignalR'a nasıl aktığını göstermek.

Claim[] claims =
[
    new(ClaimTypes.NameIdentifier, $"u-{user}"),
    new("customer_id", user),
    new(ClaimTypes.Role, Request.Query["role"].ToString() is { Length: > 0 } role ? role : "customer")
];

SignalR varsayılan olarak kullanıcı id'sini ClaimTypes.NameIdentifier claim'inden alıyor; yani burada u-ada olurdu. Bizim sistemimizde ise sipariş event'leri müşteri numarasını taşıyor ve gönderimi ona göre yapmak istiyoruz. IUserIdProvider bunu değiştirmenin yolu:

public sealed class CustomerIdUserIdProvider : IUserIdProvider
{
    // varsayılan NameIdentifier claim'i; biz Clients.User("ada") ile customer_id üzerinden adresliyoruz
    public string? GetUserId(HubConnectionContext connection) =>
        connection.User.FindFirst("customer_id")?.Value;
}

builder.Services.AddSingleton<IUserIdProvider, CustomerIdUserIdProvider>();

Bu method bağlantı başına bir kez, bağlantı kurulurken çağrılıyor ve sonucu Context.UserIdentifier oluyor. Hangi claim'i seçeceğin önemli: değişebilen bir değer (e-posta gibi) seçersen kullanıcı e-postasını değiştirdiğinde eski bağlantıları yeni id ile bulamazsın. Sabit ve benzersiz bir id kullan.

Clients.User, Group, Caller ve Others

[Authorize]
public sealed class NotificationHub(PresenceTracker presence, ILogger<NotificationHub> logger)
    : Hub<INotificationClient>
{
    public override async Task OnConnectedAsync()
    {
        string userId = Context.UserIdentifier!;
        if (Context.User!.IsInRole("warehouse"))
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, "warehouse");
        }

        int connections = await presence.ConnectedAsync(userId);
        logger.LogInformation("{User} connected ({ConnectionId}), open connections: {Count}",
            userId, Context.ConnectionId, connections);
        if (connections == 1)
        {
            await Clients.Others.UserOnline(userId);
        }

        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        string userId = Context.UserIdentifier!;
        int connections = await presence.DisconnectedAsync(userId);
        logger.LogInformation("{User} disconnected ({ConnectionId}), open connections: {Count}, error: {Error}",
            userId, Context.ConnectionId, connections, exception?.Message ?? "none");
        if (connections == 0)
        {
            await Clients.Others.UserOffline(userId);
        }

        await base.OnDisconnectedAsync(exception);
    }

    public Task WatchOrder(int orderId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");

    public Task UnwatchOrder(int orderId) =>
        Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order-{orderId}");

    public Task SendNote(int orderId, string text) =>
        Clients.OthersInGroup($"order-{orderId}").OrderNote(orderId, Context.UserIdentifier!, text);
}

Clients üzerindeki seçenekler: All herkes, Caller çağıran bağlantı, Others çağıran hariç herkes, User(id) o kullanıcının bütün bağlantıları, Users(ids) birden fazla kullanıcı, Group(name) grubun bütün bağlantıları, OthersInGroup(name) grupta çağıran hariç herkes, AllExcept(connectionIds) ve Client(connectionId). Grup, kullanıcı ve bağlantı listeleri yok; SignalR "bu gruba kim üye?" sorusuna cevap vermiyor. Böyle bir listeye ihtiyacın varsa kendin tutacaksın.

Test için Node 24 üzerinde üç istemci açtım: Ada'nın laptop'u ve telefonu (?user=ada), depo çalışanı Grace (?user=grace&role=warehouse). Ada'nın laptop'u ve Grace 42 numaralı siparişi izliyor. Çıktının ilk kısmı:

grace      <- UserOnline("ada")
presence: {"grace":1,"ada":2}
-- grace sends a note to order 42
ada/laptop <- OrderNote(42, "grace", "Packing now")
-- POST /orders/42/ship?customerId=ada
ada/laptop <- OrderShipped(42, "TRK-42-001")
ada/laptop <- OrderStatusChanged(42, "Shipped")
ada/phone  <- OrderShipped(42, "TRK-42-001")
grace      <- OrderStatusChanged(42, "Shipped")

Baştaki ilk sorunun cevabı: Clients.User("ada") iki bağlantıya da gitti (OrderShipped hem laptop'ta hem telefonda). Kullanıcı bir kişi, bağlantı bir sekme ya da cihaz. OrderStatusChanged ise gruba gönderildi; grupta Ada'nın yalnızca laptop'u ve Grace var, telefon almadı. Grace'in notu OthersInGroup ile gittiği için Grace kendi notunu almadı.

Bir ayrıntı: Grace'e giden UserOnline("ada") yalnızca bir kez geldi, çünkü onu sayaç 1 olduğunda, yani Ada'nın ilk bağlantısında gönderiyoruz. Clients.Others'ın "bu kullanıcı hariç" değil "bu bağlantı hariç" demek olduğunu da unutma; Ada'nın telefonu açıkken laptop bağlanırsa telefon da "Ada çevrimiçi" mesajını alır.

Connection Yaşam Döngüsü: OnConnectedAsync ve OnDisconnectedAsync

Sunucu log'u aynı testin bağlantı tarafını gösteriyor:

info: Bookstore.Realtime.NotificationHub[0]
      grace connected (IdXGEg0LC_K0BfbIl2cvSw), open connections: 1
info: Bookstore.Realtime.NotificationHub[0]
      ada connected (gj9G5JBr15L7TrlU4Tea9w), open connections: 1
info: Bookstore.Realtime.NotificationHub[0]
      ada connected (NlMf5lv_Ckje2OrePMoJSg), open connections: 2
info: Bookstore.Realtime.OrderShippedConsumer[0]
      Order 42 shipped, notified ada
info: Bookstore.Realtime.NotificationHub[0]
      ada connected (lQYAclDbQDhyStG8A42zTg), open connections: 2
info: Bookstore.Realtime.NotificationHub[0]
      ada disconnected (gj9G5JBr15L7TrlU4Tea9w), open connections: 1, error: none
info: Bookstore.Realtime.NotificationHub[0]
      ada disconnected (NlMf5lv_Ckje2OrePMoJSg), open connections: 1, error: none
info: Bookstore.Realtime.NotificationHub[0]
      ada disconnected (lQYAclDbQDhyStG8A42zTg), open connections: 0, error: none

Beşinci satıra dikkat. Test script'i laptop bağlantısını stop() ile kapatıp hemen start() ile yeniden açtı. Sunucuda yeni bağlantının OnConnectedAsync'i, eski bağlantının OnDisconnectedAsync'inden önce çalıştı. İstemcide stop()'un tamamlanması, sunucunun o bağlantıyı temizlemeyi bitirdiği anlamına gelmiyor. Presence'ı "çevrimiçi/çevrimdışı" diye bir boolean ile tutsaydık sıralama şöyle olurdu: yeni bağlantı "çevrimiçi" yazar, sonra eski bağlantı "çevrimdışı" yazar ve Ada açık bir bağlantısı olduğu hâlde çevrimdışı görünür. Sayaç kullandığımız için sonuç doğru: 2'ye çıkıp 1'e indi.

OnDisconnectedAsync'in exception parametresi bağlantının nasıl bittiğini söylüyor: istemci kibarca kapattıysa null, ağ koptuysa ya da sunucu tarafında bir hata olduysa dolu. İkinci durum "kullanıcı geri gelebilir" demek; presence'ta hemen "çevrimdışı" demek yerine birkaç saniyelik bir tolerans tanımak, ekranda yanıp sönen yeşil noktaları engeller.

Bağlantı kapanınca SignalR o bağlantıyı bütün gruplardan kendisi çıkarıyor; OnDisconnectedAsync'te RemoveFromGroupAsync çağırmana gerek yok.

Gruplar Kalıcı Değildir

Testin devamı, laptop yeniden bağlandıktan sonra:

-- ada/laptop reconnects (stop + start)
grace      <- OrderNote(42, "support", "Courier picked it up")

Baştaki ikinci sorunun cevabı: hayır. Grup üyeliği bağlantıya ait ve bağlantı id'si yeniden bağlanmada değişiyor (log'daki gj9G5... yerine lQYAc...). Yeni bağlantı hiçbir gruba üye değil; destek ekibinin notunu yalnızca Grace aldı. İstemcinin otomatik yeniden bağlanması da (bir sonraki yazılarda göreceğimiz withAutomaticReconnect) yeni bir bağlantı id'si üretiyor.

Bunu çözmenin iki yolu var. Birincisi istemcide: onreconnected callback'inde izlenen grupları yeniden WatchOrder ile katılmak. İkincisi sunucuda: grup üyeliklerini kullanıcı bazında kalıcı bir yerde tutup OnConnectedAsync'te yeniden kurmak (depo çalışanlarının warehouse grubuna her bağlantıda otomatik katılması gibi). Kural şu: grup, bağlantıya bağlı geçici bir abonelik; kalıcı olması gereken her şeyin gerçek kaynağı başka bir yerde.

Presence: Çevrimiçi Takibini Redis'te Tutmak

public sealed class PresenceTracker(IConnectionMultiplexer redis)
{
    private const string OnlineKey = "bookstore:presence";

    // azalt ve sıfıra inince sil: iki adım tek atomik script'te
    private const string DecrementScript = """
        local count = redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
        if count <= 0 then redis.call('HDEL', KEYS[1], ARGV[1]) end
        return count
        """;

    public async Task<int> ConnectedAsync(string userId) =>
        (int)await redis.GetDatabase().HashIncrementAsync(OnlineKey, userId);

    public async Task<int> DisconnectedAsync(string userId)
    {
        RedisResult result = await redis.GetDatabase().ScriptEvaluateAsync(DecrementScript, [OnlineKey], [userId]);
        return Math.Max((int)result, 0);
    }

    public async Task<Dictionary<string, int>> GetOnlineAsync() =>
        (await redis.GetDatabase().HashGetAllAsync(OnlineKey))
            .ToDictionary(e => e.Name.ToString(), e => (int)e.Value);
}

Presence'ı neden bellekte değil Redis'te tutuyoruz? Çünkü ikinci sunucu eklediğin an bellek içi bir sözlük yalnızca o sunucuya bağlı kullanıcıları bilir. Redis'teki tek bir hash, bütün sunucuların bağlantılarını sayıyor. HINCRBY atomik; azaltma tarafında "azalt, sıfıra indiyse sil" iki ayrı komut olsaydı arada gelen bir bağlantı sayacı yanlış bırakabilirdi, bu yüzden Lua script'iyle tek atomik adımda yapıyoruz. Testin son kısmı:

-- ada closes the phone tab, then the laptop
presence: {"grace":1,"ada":1}
grace      <- UserOffline("ada")

Telefon kapanınca Ada hâlâ çevrimiçi (1 bağlantı), laptop da kapanınca Grace'e UserOffline gitti ve Redis'teki hash'ten Ada'nın satırı silindi.

Bu tasarımın bilinen bir zayıflığı var: sunucu süreci çökerse OnDisconnectedAsync hiç çalışmaz ve o sunucudaki bağlantıların sayaçları Redis'te sonsuza kadar kalır. Production'da sayaçları sunucu bazında ayrı key'lerde tutup her sunucunun periyodik olarak TTL'ini (Time To Live) uzatması, çöken sunucunun key'inin süre dolunca kendiliğinden silinmesi daha sağlam bir yol.

IHubContext ile Sunucunun Her Yerinden Mesaj Göndermek

Gerçek uygulamada mesajların çoğu hub'dan değil, başka yerlerden çıkıyor: bir HTTP (Hypertext Transfer Protocol) endpoint'i, bir background job, bir message queue consumer'ı. Hepsi aynı aracı kullanıyor: DI (Dependency Injection) ile alınan IHubContext.

05-hubcontext-fanout-tr.png

// 1) HTTP endpoint'ten: doğrudan IHubContext
app.MapPost("/orders/{id:int}/notes", async (int id, OrderNoteRequest note, IHubContext<NotificationHub, INotificationClient> hub) =>
{
    await hub.Clients.Group($"order-{id}").OrderNote(id, "support", note.Text);
    return TypedResults.Accepted((string?)null);
});

// 2) event yayınla, consumer IHubContext ile bildirsin
app.MapPost("/orders/{id:int}/ship", async (int id, string customerId, Channel<OrderShipped> bus) =>
{
    await bus.Writer.WriteAsync(new OrderShipped(id, customerId, $"TRK-{id}-001"));
    return TypedResults.Accepted((string?)null);
});
public sealed class OrderShippedConsumer(
    Channel<OrderShipped> bus,
    IHubContext<NotificationHub, INotificationClient> hub,
    ILogger<OrderShippedConsumer> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (OrderShipped message in bus.Reader.ReadAllAsync(stoppingToken))
        {
            // müşterinin bütün sekmelerine/cihazlarına
            await hub.Clients.User(message.CustomerId).OrderShipped(message.OrderId, message.TrackingNumber);
            // siparişi izleyen herkese (müşteri hizmetleri dahil)
            await hub.Clients.Group($"order-{message.OrderId}").OrderStatusChanged(message.OrderId, "Shipped");
            logger.LogInformation("Order {OrderId} shipped, notified {Customer}", message.OrderId, message.CustomerId);
        }
    }
}

public sealed class LowStockMonitor(IHubContext<NotificationHub, INotificationClient> hub) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        int remaining = 5;
        using PeriodicTimer timer = new(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(stoppingToken) && remaining > 0)
        {
            remaining--;
            if (remaining < 3)
            {
                await hub.Clients.Group("warehouse").LowStock("Clean Code", remaining);
            }
        }
    }
}

Örneği kendi içinde çalışır tutmak için broker yerine bir Channel<OrderShipped> kullandık; consumer'ın şekli RabbitMQ ya da Kafka consumer'ında da aynı: mesajı al, kime gideceğine karar ver, IHubContext ile gönder. Broker tarafını ilerideki Message Queue'larla Çalışmak serisinde, BackgroundService'in kendisini Background Job'lar serisinin ikinci yazısında anlatıyoruz. Stok uyarısı yalnızca warehouse grubuna gitti, Ada'nın bağlantıları bunu hiç görmedi:

grace      <- LowStock("Clean Code", 2)
grace      <- LowStock("Clean Code", 1)
grace      <- LowStock("Clean Code", 0)

IHubContext ile hub arasındaki fark: hub'ın içinde Context var (kim çağırdı, hangi bağlantı); IHubContext'te yok. Bu yüzden IHubContext üzerinde Caller ya da Others bulunmuyor; adresi her zaman açıkça veriyorsun. IHubContext singleton olarak kayıtlı, her yere enjekte edilebilir.

Üretimde Dikkat

Gönderim "teslim edildi" demek değil. Clients.User("ada").OrderShipped(...) await edildiğinde mesaj, Ada'nın o anki bağlantılarının gönderim kuyruğuna yazılmış oluyor; Ada'nın hiç bağlantısı yoksa hiçbir şey olmuyor ve hata da almıyorsun. Kullanıcı çevrimdışıyken önemli bir bildirim kaçmamalıysa onu veritabanına yaz, ekranda bir bildirim listesi göster ve SignalR'ı yalnızca "yeni bir şey var" sinyali olarak kullan.

Grup adlarını sen üretiyorsun ve SignalR onları doğrulamıyor. İstemci WatchOrder(43) çağırırsa başkasının siparişinin grubuna katılır; kullanıcının yalnızca kendi siparişinin grubuna katılabilmesini sağlamak (resource-based authorization) bir sonraki yazının konusu.

Ölçek tarafında: Clients.User ve Clients.Group tek sunucuda bellekteki bağlantı listesine bakıyor. İkinci sunucu eklediğinde Ada'nın telefonu diğer sunucuya bağlıysa mesajı almaz. Presence'ı Redis'e taşıdık ama gönderimi taşımadık; bunun çözümü (Redis backplane) serinin sekizinci yazısında.

Sık Düşülen Tuzaklar

Connection id'yi kullanıcı id'si gibi saklamak. Her yeniden bağlanmada değişiyor; kullanıcıya göndermek için Clients.User var.

Grup üyeliğinin yeniden bağlanmadan sonra sürdüğünü varsaymak. Yeni bağlantı hiçbir grupta değil; onreconnected'da ya da OnConnectedAsync'te yeniden katıl.

Presence'ı boolean ile tutmak. Birden fazla sekme ve OnConnectedAsync/OnDisconnectedAsync'in beklenmedik sırası, kullanıcıyı açıkken çevrimdışı gösterir; sayaç kullan.

Presence'ı bellekte tutmak. Tek sunucuda çalışır, ikincisinde yarısını görür.

Clients.Others'ı "kullanıcının diğer cihazları hariç" sanmak. Yalnızca çağıran bağlantıyı hariç tutar.

Kullanıcıya gitmesi şart olan bildirimi yalnızca SignalR ile göndermek. Çevrimdışı kullanıcıya giden mesaj sessizce kaybolur.

Küçük Bir Deneme

★☆☆ CustomerIdUserIdProvider'ı kaldır ve testi tekrar çalıştır. Clients.User("ada") neden artık hiçbir bağlantıya ulaşmıyor? Hangi id ile gönderirsen ulaşır?

★★☆ Kullanıcının izlediği sipariş numaralarını Redis'te bir set'te tut (bookstore:watch:{userId}) ve OnConnectedAsync'te bu set'teki her sipariş için gruba yeniden katıl. Testteki "laptop yeniden bağlanır" adımından sonra notun laptop'a da gittiğini doğrula.

★★★ Presence'ı sunucu bazında key'lere böl (bookstore:presence:{serverId}), her sunucu 10 saniyede bir kendi key'inin TTL'ini 30 saniyeye uzatsın. Sunucuyu kill -9 ile öldür ve 30 saniye sonra kullanıcıların çevrimdışı göründüğünü doğrula.

Bir sonraki yazıda kimliği gerçek hâline getiriyoruz: SignalR'da JWT ile authentication, WebSocket'in neden header gönderemediği ve access_token query string'i, hub ve method seviyesinde policy'ler, kullanıcının yalnızca kendi grubuna katılabilmesi ve token süresi dolduğunda ne olduğu.