Нотифікації

SignalR: Абстракція над транспортами реального часу

Вивчаємо SignalR — бібліотеку Microsoft для real-time комунікації. Переробляємо WebSockets чат на SignalR, вивчаємо Hub, Groups, типізовані підключення та автентифікацію.

SignalR: Абстракція над транспортами реального часу

У попередній статті ми побудували чат на WebSockets і побачили, скільки boilerplate-коду потрібно: ручне управління буфером, збирання фреймів, ConcurrentDictionary, серіалізація вручну. І ми навіть не торкнулися груп, автентифікації чи масштабування.

SignalR — бібліотека Microsoft, яка бере на себе всю цю інфраструктуру. За лаштунками вона автоматично обирає найкращий транспорт: спочатку пробує WebSockets, якщо не підтримується — SSE, якщо й це не працює — Long Polling. Розробник цього не бачить і не турбується.

Але головне — SignalR надає абстракцію Hub (центр/вузол): клас, методи якого можна викликати з клієнта, і навпаки — методи клієнта можна викликати з серверного коду.

Що ми побудуємо: переробимо чат зі статті 04 на SignalR. Ви побачите, наскільки менше коду потрібно для тієї самої (і більшої) функціональності. Також додамо кімнати (Groups) та базову автентифікацію.

Концепція Hub

Hub — це клас на сервері, що є «точкою зустрічі» всіх клієнтів. Через Hub:

  • Клієнт може викликати метод сервера (як звичайний виклик функції, але через мережу)
  • Сервер може викликати метод клієнта (або одного, або групи, або всіх)

Це нагадує RPC (Remote Procedure Call — виклик віддаленої процедури), але в обидва боки.

Клієнт A                    Hub (сервер)                  Клієнт B, C
    |                           |                              |
    |--- SendMessage("hello") ->|                              |
    |                           |--- ReceiveMessage("hello") ->|
    |                           |--- ReceiveMessage("hello") ->|
    |                           |
    |<-- ReceiveMessage("hi") --|  ← від Клієнта B

Реалізація

Крок 1: Нові залежності

cd WebChat   # Продовжуємо той самий проєкт, що і у попередній статті
dotnet add package Microsoft.AspNetCore.SignalR.Client  # Потрібен якщо ми будуємо C#-клієнт

SignalR вже вбудований у ASP.NET Core — окремий пакет не потрібен на стороні сервера.

Крок 2: Типізований Hub

Для типізованого Hub нам потрібен інтерфейс, що описує методи клієнта, які сервер може викликати:

Hubs/IChatClient.cs
using WebChat.Models;

namespace WebChat.Hubs;

// Цей інтерфейс описує методи, які EXISTS на клієнті (у JavaScript або C# клієнті).
// Сервер буде їх "викликати" через SignalR.
// Перевага типізованого підходу: компілятор перевіряє правильність назв методів і типів аргументів.
public interface IChatClient
{
    // Отримати нове повідомлення — цей метод клієнт повинен реалізувати
    Task ReceiveMessage(ChatMessage message);

    // Отримати системне сповіщення про кількість підключених
    Task UserCountChanged(int count);
}

Тепер визначаємо сам Hub:

Hubs/ChatHub.cs
using Microsoft.AspNetCore.SignalR;
using WebChat.Models;

namespace WebChat.Hubs;

// ChatHub<IChatClient> — типізований Hub.
// Успадковуємо від Hub<IChatClient>, де IChatClient описує методи клієнта.
public class ChatHub : Hub<IChatClient>
{
    // SignalR автоматично відстежує підключення.
    // Context.ConnectionId — унікальний рядковий ID поточного підключення
    // Context.UserIdentifier — userId з токена автентифікації (якщо налаштована)

    // Цей метод клієнт викликає на сервері, коли хоче надіслати повідомлення в загальний чат
    public async Task SendMessage(string username, string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return;

        var message = ChatMessage.CreateChat(username, text);

        // Clients.All — надіслати ВСІМ підключеним клієнтам
        // ReceiveMessage — це метод з IChatClient, який клієнт має реалізувати
        await Clients.All.ReceiveMessage(message);
    }

    // Надіслати повідомлення в конкретну «кімнату» (group)
    public async Task SendMessageToRoom(string roomName, string username, string text)
    {
        var message = ChatMessage.CreateChat(username, text);

        // Clients.Group — лише підписники цієї групи отримають повідомлення
        await Clients.Group(roomName).ReceiveMessage(message);
    }

    // Приєднатися до кімнати
    public async Task JoinRoom(string roomName, string username)
    {
        // AddToGroupAsync — SignalR додає поточне з'єднання до названої групи
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);

        // Інформуємо учасників кімнати
        await Clients.Group(roomName).ReceiveMessage(
            ChatMessage.CreateSystem($"{username} увійшов до кімнати «{roomName}»"));
    }

    // Покинути кімнату
    public async Task LeaveRoom(string roomName, string username)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).ReceiveMessage(
            ChatMessage.CreateSystem($"{username} покинув кімнату «{roomName}»"));
    }

    // Автоматично викликається SignalR при новому підключенні
    public override async Task OnConnectedAsync()
    {
        // Сповіщаємо всіх про нового учасника
        await Clients.All.ReceiveMessage(
            ChatMessage.CreateSystem($"Новий учасник підключився. Онлайн: {GetConnectedCount()}"));

        await Clients.All.UserCountChanged(GetConnectedCount());
        await base.OnConnectedAsync();
    }

    // Автоматично викликається при відключенні (exception != null якщо аварійне)
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await Clients.All.ReceiveMessage(
            ChatMessage.CreateSystem($"Учасник від'єднався. Онлайн: {GetConnectedCount() - 1}"));

        await Clients.All.UserCountChanged(GetConnectedCount() - 1);
        await base.OnDisconnectedAsync(exception);
    }

    // Для демонстрації — SignalR сам відстежує підключення,
    // але не надає прямого доступу до їх кількості без власного лічильника
    private int GetConnectedCount()
    {
        // У реальному застосунку — використовуйте власний Singleton-лічильник
        return 0; // Спрощено для прикладу
    }
}

Зверніть увагу на методи OnConnectedAsync та OnDisconnectedAsync — вони є аналогами нашого ручного коду з AddConnection/RemoveConnection у попередній статті. SignalR викликає їх автоматично.

Крок 3: Реєстрація в Program.cs

Program.cs
using Microsoft.AspNetCore.SignalR;
using WebChat.Hubs;

var builder = WebApplication.CreateBuilder(args);

// Реєструємо SignalR — один рядок замість ручного налаштування WebSockets і ConnectionManager
builder.Services.AddSignalR();

var app = builder.Build();

app.UseStaticFiles();

// Підключаємо Hub за маршрутом /hub/chat
// Порівняйте з попереднім: тепер у нас немає ручного маршруту /ws з await ReceiveLoop()
app.MapHub<ChatHub>("/hub/chat");

app.Run();

Крок 4: Клієнтська сторона з librarySignalR

Для JavaScript-клієнта потрібна бібліотека SignalR. Отримаємо її через CDN — без npm та збірника:

wwwroot/index.html
<!DOCTYPE html>
<html lang="uk">
<head>
    <meta charset="UTF-8">
    <title>WebChat SignalR</title>
    <!-- Бібліотека SignalR для браузера з CDN -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0;
               display: grid; grid-template-columns: 200px 1fr; height: 100vh; }
        #sidebar { background: #1e293b; padding: 16px; border-right: 1px solid #334155; }
        #sidebar h3 { font-size: 13px; color: #64748b; text-transform: uppercase; margin-bottom: 12px; }
        .room-btn { display: block; width: 100%; text-align: left; background: none; border: none;
                    color: #94a3b8; padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; }
        .room-btn:hover, .room-btn.active { background: #334155; color: #e2e8f0; }
        #main { display: flex; flex-direction: column; }
        header { background: #1e293b; padding: 16px 24px; display: flex;
                 justify-content: space-between; border-bottom: 1px solid #334155; }
        #online { font-size: 13px; color: #22c55e; }
        #messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
        .msg { max-width: 70%; padding: 10px 14px; border-radius: 12px; }
        .msg.chat { background: #1e293b; align-self: flex-start; }
        .msg.system { background: transparent; align-self: center; font-size: 12px; color: #64748b; font-style: italic; }
        .msg .username { font-size: 11px; color: #94a3b8; margin-bottom: 4px; }
        footer { background: #1e293b; padding: 16px; display: flex; gap: 10px; border-top: 1px solid #334155; }
        input { flex: 1; background: #0f172a; border: 1px solid #334155; border-radius: 8px;
                padding: 10px 14px; color: #e2e8f0; font-size: 14px; }
        button.send { background: #3b82f6; color: white; border: none; border-radius: 8px;
                      padding: 10px 20px; cursor: pointer; }
    </style>
</head>
<body>
    <div id="sidebar">
        <h3>Кімнати</h3>
        <button class="room-btn active" onclick="switchRoom('general')">💬 Загальна</button>
        <button class="room-btn" onclick="switchRoom('tech')">💻 Tech</button>
        <button class="room-btn" onclick="switchRoom('random')">🎲 Random</button>
    </div>

    <div id="main">
        <header>
            <span id="room-title">💬 Загальна</span>
            <span id="online">● 0 онлайн</span>
        </header>
        <div id="messages"></div>
        <footer>
            <input id="msg-input" type="text" placeholder="Повідомлення..."
                   onkeydown="if(event.key==='Enter') sendMessage()" />
            <button class="send" onclick="sendMessage()">Надіслати</button>
        </footer>
    </div>

    <script>
        const username = prompt("Ваше ім'я:") || "Анонім";
        let currentRoom = 'general';

        // Будуємо з'єднання
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/hub/chat")   // URL Hub'а
            .withAutomaticReconnect()  // Автоматичне перепідключення при обриві
            .build();

        // Підписуємося на серверні методи
        // Назви методів — точно такі самі, як в IChatClient
        connection.on("ReceiveMessage", (message) => {
            displayMessage(message);
        });

        connection.on("UserCountChanged", (count) => {
            document.getElementById('online').textContent = `● ${count} онлайн`;
        });

        // Запускаємо з'єднання
        connection.start()
            .then(() => {
                console.log("SignalR підключено");
                // Приєднуємося до початкової кімнати
                return connection.invoke("JoinRoom", currentRoom, username);
            })
            .catch(err => console.error("Помилка підключення:", err));

        async function sendMessage() {
            const input = document.getElementById('msg-input');
            const text = input.value.trim();
            if (!text) return;

            // invoke — викликаємо метод хаба на сервері
            // Перший аргумент: назва методу Hub (точно як у C#)
            // Далі: аргументи методу
            await connection.invoke("SendMessageToRoom", currentRoom, username, text);
            input.value = '';
        }

        async function switchRoom(newRoom) {
            if (newRoom === currentRoom) return;

            // Покидаємо поточну кімнату і приєднуємося до нової
            await connection.invoke("LeaveRoom", currentRoom, username);
            currentRoom = newRoom;
            await connection.invoke("JoinRoom", newRoom, username);

            // Оновлюємо UI
            document.getElementById('messages').innerHTML = '';
            document.getElementById('room-title').textContent = `💬 ${newRoom}`;
            document.querySelectorAll('.room-btn').forEach(btn => btn.classList.remove('active'));
            event.target.classList.add('active');
        }

        function displayMessage(message) {
            const container = document.getElementById('messages');
            const el = document.createElement('div');
            el.className = `msg ${message.type === 0 ? 'chat' : 'system'}`;

            if (message.type === 0) { // Chat
                el.innerHTML = `<div class="username">${message.username}</div>${message.text}`;
            } else {
                el.textContent = message.text;
            }

            container.appendChild(el);
            container.scrollTop = container.scrollHeight;
        }
    </script>
</body>
</html>

Порівняння сирих WebSockets і SignalR

Порівняємо обсяг коду для тієї ж функціональності:

ЗавданняWebSockets (стаття 04)SignalR
Управління підключеннямиConcurrentDictionary вручнуАвтоматично
BroadcastTask.WhenAll(SendAsync...)Clients.All.Method()
Групи (кімнати)Власний Dictionary<string, List<WebSocket>>Groups.AddToGroupAsync()
СеріалізаціяJsonSerializer.Serialize + Encoding.UTF8.GetBytesАвтоматично
Збирання фреймівdo-while з MemoryStreamАвтоматично
Перепідключення клієнтаВласна логіка.withAutomaticReconnect()
Фалбек-транспортТільки WebSocketWebSockets → SSE → Long Polling
Рядків коду (сервер)~120~60

Хто може отримати повідомлення

Clients.All

Усі підключені клієнти, незалежно від груп або userId.

await Clients.All.ReceiveMessage(msg);

Clients.Caller

Лише той клієнт, який викликав метод Hub.

await Clients.Caller.ReceiveMessage(msg);

Clients.Others

Усі, крім поточного клієнта.

await Clients.Others.ReceiveMessage(msg);

Clients.Group

Лише підписники вказаної групи.

await Clients.Group("room-name").ReceiveMessage(msg);

Clients.User

Усі з'єднання конкретного автентифікованого користувача.

await Clients.User(userId).ReceiveMessage(msg);

Clients.Client

Конкретне з'єднання за connectionId.

await Clients.Client(connectionId).ReceiveMessage(msg);

Автентифікація в SignalR

SignalR прозоро інтегрується з ASP.NET Core Authentication. Якщо JWT вже налаштований у вашому застосунку:

Hubs/ChatHub.cs
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;

[Authorize] // Лише автентифіковані користувачі можуть підключитися
public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string text)
    {
        // Context.User — ClaimsPrincipal (як у будь-якому контролері ASP.NET Core)
        var username = Context.User?.FindFirstValue(ClaimTypes.Name) ?? "Анонім";
        var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);

        // Context.UserIdentifier автоматично заповнюється з nameidentifier клейму
        // Clients.User(userId) надсилає ВСІМ з'єднанням цього користувача
    }
}

На клієнті — JWT передається через query string (бо браузер не може додати заголовки до WebSocket):

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hub/chat", {
        accessTokenFactory: () => localStorage.getItem("jwt_token")
    })
    .build();

SignalR автоматично примет токен і заповнить Context.User у Hub.


Обмеження: Масштабування на кілька серверів

SignalR зберігає стан з'єднань в пам'яті сервера. Якщо у вас два сервери за балансувальником навантаження — клієнт, підключений до сервера A, не отримає повідомлення, надіслане через сервер B.

Рішення — Redis Backplane: SignalR публікує повідомлення в Redis, і всі сервери підписані на Redis-канал. Кожен сервер надсилає повідомлення своїм локальним клієнтам.

// Встановлення: dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR().AddStackExchangeRedis("localhost:6379");

Для поточного навчального проєкту один сервер — не проблема.


Підсумок

SignalR суттєво спрощує розробку real-time функціоналу:

  • Hub — чистий, зрозумілий API замість ручного управління WebSocket-фреймами
  • Автоматичний фалбек на SSE та Long Polling при відсутності WebSocket-підтримки
  • Groups — вбудована система «кімнат» без власних структур даних
  • Пряма інтеграція з ASP.NET Core Authentication

Наступна стаття присвячена Background Services — фоновим задачам, які виконуються незалежно від HTTP-запитів. Це необхідна основа для Web Push та Email нотифікацій, де потрібно надсилати повідомлення асинхронно, поза HTTP-циклом запит-відповідь.

Copyright © 2026