SignalR: Абстракція над транспортами реального часу
SignalR: Абстракція над транспортами реального часу
У попередній статті ми побудували чат на WebSockets і побачили, скільки boilerplate-коду потрібно: ручне управління буфером, збирання фреймів, ConcurrentDictionary, серіалізація вручну. І ми навіть не торкнулися груп, автентифікації чи масштабування.
SignalR — бібліотека Microsoft, яка бере на себе всю цю інфраструктуру. За лаштунками вона автоматично обирає найкращий транспорт: спочатку пробує WebSockets, якщо не підтримується — SSE, якщо й це не працює — Long Polling. Розробник цього не бачить і не турбується.
Але головне — SignalR надає абстракцію Hub (центр/вузол): клас, методи якого можна викликати з клієнта, і навпаки — методи клієнта можна викликати з серверного коду.
Концепція 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 нам потрібен інтерфейс, що описує методи клієнта, які сервер може викликати:
using WebChat.Models;
namespace WebChat.Hubs;
// Цей інтерфейс описує методи, які EXISTS на клієнті (у JavaScript або C# клієнті).
// Сервер буде їх "викликати" через SignalR.
// Перевага типізованого підходу: компілятор перевіряє правильність назв методів і типів аргументів.
public interface IChatClient
{
// Отримати нове повідомлення — цей метод клієнт повинен реалізувати
Task ReceiveMessage(ChatMessage message);
// Отримати системне сповіщення про кількість підключених
Task UserCountChanged(int count);
}
Тепер визначаємо сам Hub:
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
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 та збірника:
<!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 вручну | Автоматично |
| Broadcast | Task.WhenAll(SendAsync...) | Clients.All.Method() |
| Групи (кімнати) | Власний Dictionary<string, List<WebSocket>> | Groups.AddToGroupAsync() |
| Серіалізація | JsonSerializer.Serialize + Encoding.UTF8.GetBytes | Автоматично |
| Збирання фреймів | do-while з MemoryStream | Автоматично |
| Перепідключення клієнта | Власна логіка | .withAutomaticReconnect() |
| Фалбек-транспорт | Тільки WebSocket | WebSockets → 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 вже налаштований у вашому застосунку:
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-циклом запит-відповідь.
WebSockets: Двостороннє з'єднання в реальному часі
Вивчаємо WebSockets — протокол для повнодуплексної комунікації між клієнтом і сервером. Будуємо чат реального часу з ASP.NET Minimal API, управлінням підключеннями та broadcast-розсилкою.
Background Services: Фонові задачі в ASP.NET Core
Вивчаємо IHostedService та BackgroundService — механізми ASP.NET Core для виконання коду поза HTTP-запитами. Реалізуємо фонові задачі, Channel-черги та правильну роботу з DI в фоні.