MVC, Razor і SignalR
Серверний UI на ASP.NET Core та оновлення в реальному часі: шлях запиту від контролера до HTML, Razor, захист форм від CSRF і SignalR на одному та кількох серверах.
Перед цією темою: Хостинг і конвеєр.
Як влаштований MVC в ASP.NET Core і який шлях проходить запит до View()?
Section titled “Як влаштований MVC в ASP.NET Core і який шлях проходить запит до View()?”Що це
- Модель — дані та бізнес-логіка: сутності, сервіси, правила.
- Представлення (View) — файл
.cshtmlз HTML і Razor, який показує дані. - Контролер — клас із методами-actions: приймає запит, викликає модель, обирає представлення.
- ViewModel — клас під конкретну сторінку: лише поля, які вона показує або приймає.
Як проходить запит
- Шаблон
{controller=Home}/{action=Index}/{id?}перетворює/Orders/Details/42на викликOrdersController.Details(42). - Action отримує дані й збирає ViewModel.
return View(vm)шукаєViews/Orders/Details.cshtml, потімViews/Shared/Details.cshtml.- Razor підставляє дані в розмітку, і браузер отримує готовий HTML.
Типова помилка
Писати бізнес-логіку просто в контролері. Тоді її не перевикористати й не протестувати без HTTP. Контролер лише пов’язує запит, сервіс і представлення.
Як влаштовано
- Запит проходить middleware конвеєра, і маршрутизація обирає endpoint — конкретний action.
- MVC створює контролер. Сам клас не зареєстрований у DI, але його залежності беруться зі scope запиту.
- Фільтри й model binding готують виклик: параметри заповнюються з маршруту, query і форми (докладно — у темі Web API).
- Action повертає
IActionResult.View(vm)створюєViewResult— опис результату, а не HTML. - Після виходу з action
ViewResultвиконується: рушій Razor знаходить представлення, запускає_ViewStart, рендерить представлення та layout.
Коли що обирати
| Спосіб передати дані | Коли |
|---|---|
ViewModel і @model |
основний спосіб: типізація та IntelliSense |
ViewData, ViewBag |
дрібниці для layout, наприклад заголовок сторінки |
TempData |
повідомлення, яке переживає один редирект |
Пастки
- Лінивий рендеринг. Представлення виконується після action.
IQueryableабо сутності з lazy loading у моделі — це запити до бази під час рендерингу, часто N+1. - Регістр імен. Представлення шукається за ім’ям файлу. На Linux файлова система розрізняє регістр, і
details.cshtmlне знайдеться для actionDetails. - Сутність на вході. POST, прив’язаний просто до сутності EF, дає змогу підставити поля, яких немає у формі (overposting).
Правило
Action тонкий: дані із сервісу, матеріалізована ViewModel, View. Після успішного POST — RedirectToAction (Post/Redirect/Get), щоб F5 не надсилав форму повторно.
Під капотом
- Під час старту
ApplicationPartManagerзнаходить контролери у збірках, будуєтьсяApplicationModel, застосовуються угоди (IApplicationModelConvention). Кожен action стаєActionDescriptor, а з них — endpoints маршрутизації. - На запит
ControllerActionInvokerвиконує конвеєр: фільтри авторизації та ресурсів, model binding, фільтри action, сам action, фільтри результату. - Із .NET 6 генератор Razor компілює представлення в основну збірку застосунку: кожне — клас-нащадок
RazorPage<TModel>, окремого*.Views.dllбільше немає. ViewResultExecutorзнаходить представлення (результат пошуку кешується) і рендерить його в буфер із пулу, а потім надсилає у відповідь.
Ціна
- Кожен запит — створення контролера, прив’язка, фільтри й рендеринг Razor. Для JSON-ендпоінтів Minimal API дешевший.
- Велика сторінка повністю лежить у буфері до кінця рендерингу.
await FlushAsync()у представленні віддає початок раніше, але після цього не можна змінити статус і заголовки.
Компроміси
- Microsoft рекомендує Blazor для нових проєктів. MVC і Razor Pages підтримуються й уміють вбудовувати Razor-компоненти Blazor у наявні представлення.
- Runtime-компіляція Razor (
AddRazorRuntimeCompilation) із .NET 10 застаріла, попередження ASPDEPR003. Для розробки — Hot Reload, у продакшені — компіляція під час збирання.
Як перевірити в продакшені
- Лог MVC рівня Information «Executed ViewResult - view Details executed in 312ms» показує час рендерингу окремо від action.
- Метрика
http.server.request.duration(із .NET 8) з тегомhttp.routeпоказує повільні маршрути.
Підсумок: MVC — конвеєр навколо action, і найдорожче в ньому часто не action, а рендеринг представлення.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews(); // контролери + Razor-представлення
var app = builder.Build();app.MapStaticAssets(); // .NET 9+app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}") .WithStaticAssets();app.Run();
public class OrdersController : Controller{ // GET /Orders/Details/42 → controller=Orders, action=Details, id=42 public IActionResult Details(int id) { if (id != 42) return NotFound();
var vm = new OrderDetailsViewModel(id, "Contoso", 99.5m); return View(vm); // Views/Orders/Details.cshtml, потім Views/Shared/Details.cshtml }}
// ViewModel: лише те, що потрібно сторінціpublic record OrderDetailsViewModel(int Id, string Customer, decimal Total);@* Views/Orders/Details.cshtml *@@model OrderDetailsViewModel
<h1>Order #@Model.Id</h1><p>@Model.Customer: @Model.Total.ToString("C")</p>- Чим
View()відрізняється відViewResult? —View()— помічник контролера, який створюєViewResult; рендеринг відбувається під час виконання результату. - Де MVC шукає представлення? —
Views/{Controller}/{Action}.cshtml, потімViews/Shared; правила змінюєIViewLocationExpander. - Навіщо ViewModel, якщо є сутність? — Лише потрібні поля, немає overposting і запитів до бази під час рендерингу.
- Чим
ViewDataвідрізняється відViewBag? — Це одне сховище: словник із рядковими ключами таdynamic-обгортка над ним. - Що таке Post/Redirect/Get? — Після успішного POST — редирект на GET, щоб оновлення сторінки не надсилало форму знову.
Що таке Razor, з чого складається сторінка і чим відрізняються Razor Pages?
Section titled “Що таке Razor, з чого складається сторінка і чим відрізняються Razor Pages?”Що це
Razor — розмітка .cshtml, де HTML змішаний із C#: @Model.Name, @if, @foreach. Усе, що виводиться через @, кодується: <script> із даних стане текстом.
З чого складається сторінка
| Файл або прийом | Навіщо |
|---|---|
_Layout.cshtml |
спільний каркас; @RenderBody() вставляє сторінку |
_ViewStart.cshtml |
код перед кожним представленням, зазвичай Layout = "_Layout" |
_ViewImports.cshtml |
спільні @using, @addTagHelper, @inject |
| partial view | повторюваний шматок розмітки |
| view component | шматок із власною логікою: кошик, меню |
Razor Pages
Файл Pages/Contact.cshtml з директивою @page сам відповідає на /Contact. Логіка лежить поруч, у класі ContactModel з методами OnGet і OnPost.
Типова помилка
Вивести HTML із бази через @Html.Raw, щоб «теги відображалися». Так у сторінку потрапляє чужий скрипт.
Як влаштовано
_ViewStartі_ViewImportsдіють на свою папку та вкладені._ViewStartвиконується лише для повних представлень, не для partial.- Tag helper — клас, який на сервері обробляє HTML-елемент.
<a asp-action="Details">отримує готовий URL,<input asp-for="Email">— ім’я, значення й атрибути валідації. - HTML helpers (
@Html.TextBoxFor(m => m.Email)) роблять те саме через виклики C#. Сторонні набори на кшталт Telerik UI for ASP.NET Core пропонують обидва варіанти.
Коли що обирати
| Завдання | Інструмент |
|---|---|
| Повторити розмітку з даними батька | partial view |
| Блок із власними даними: кошик, меню | view component |
| Змінити поведінку HTML-елемента | tag helper |
| Сторінка з формою та парою обробників | Razor Pages |
| Багато представлень навколо ресурсу, спільні фільтри | контролер MVC |
Пастки
@Html.Partialсинхронний: аналізатор MVC1000 попереджає про ризик дедлоку. Використовуйте<partial>абоawait Html.PartialAsync.- View component не бере участі в model binding і фільтрах: дані надходять лише аргументами
InvokeабоInvokeAsync. - Для
<vc:cart>потрібен@addTagHelper *, НазваЗбіркиу_ViewImports. Без нього тег мовчки потрапляє в HTML як є.
Правило
Запити до даних — в action або view component, а не в блоках @{ } представлення.
Під капотом
- Генератор Razor перетворює
.cshtmlна клас: статичний HTML — уWriteLiteral, вирази@x— уWrite, який кодує значення черезHtmlEncoder. - Layout виконується після тіла: спочатку рендериться представлення і зберігаються його секції, потім layout вставляє їх через
RenderBodyіRenderSectionAsync. - Tag helpers прив’язуються до елементів під час компіляції за
@addTagHelper. У рантаймі на кожен цільовий елемент створюється екземпляр і викликаєтьсяProcessAsync. - View component шукає представлення в
Views/{Controller}/Components/{Name}/іViews/Shared/Components/{Name}/.
Ціна
- Tag helper — об’єкт і контекст на кожен елемент. У таблиці на тисячі рядків це помітні алокації; гарячий фрагмент пишуть простим HTML з
@. - View component у циклі — окремий пошук представлення й рендеринг на кожну ітерацію. Дані краще завантажити один раз.
<cache>зберігає фрагмент у пам’яті процесу, і у фермі кожен сервер має власну копію. Для спільного кешу є<distributed-cache>.
Компроміси
- Razor Pages і MVC працюють на одному рушії. Pages — вертикальні зрізи за сторінками, MVC — контролери за ресурсами зі спільними фільтрами.
- Razor не дає інтерактивності на клієнті. Її додають JavaScript або Razor-компоненти Blazor через tag helper
<component>просто в представленні.
Як перевірити в продакшені
Час рендерингу видно в логах MVC («Executed ViewResult … executed in N ms»), а алокації tag helpers — у профілювальнику (dotnet-trace, PerfView) під навантаженням.
Підсумок: Razor компілюється у звичайний C#, і його ціна — об’єкти на кожен tag helper і view component під час рендерингу.
using Microsoft.AspNetCore.Mvc;using Microsoft.AspNetCore.Mvc.RazorPages;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews();builder.Services.AddRazorPages(); // MVC і Razor Pages в одному застосунку
var app = builder.Build();app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");app.MapRazorPages(); // Pages/Contact.cshtml → /Contactapp.Run();
// View component: власна логіка й представлення Views/Shared/Components/Cart/Default.cshtmlpublic class CartViewComponent : ViewComponent{ public IViewComponentResult Invoke(int maxItems) => View(new[] { "Keyboard", "Mouse", "Monitor" }.Take(maxItems).ToArray());}
// Razor Page: обробники замість actions, модель лежить поруч із розміткоюpublic class ContactModel : PageModel{ [BindProperty] public string Message { get; set; } = "";
public IActionResult OnPost() => string.IsNullOrWhiteSpace(Message) ? Page() : RedirectToPage("Thanks");}@* Views/_ViewImports.cshtml — спільні директиви для папки та вкладених *@@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers@addTagHelper *, MyShop
@* Views/_ViewStart.cshtml — виконується перед кожним повним представленням *@@{ Layout = "_Layout"; }
@* Views/Shared/_Layout.cshtml *@<main>@RenderBody()</main>@await RenderSectionAsync("Scripts", required: false)
@* Views/Home/Index.cshtml *@@model IReadOnlyList<Product>@foreach (var p in Model){ <partial name="_ProductRow" model="p" />}<vc:cart max-items="2"></vc:cart><a asp-controller="Products" asp-action="Details" asp-route-id="5">Details</a>- Чим partial view відрізняється від view component? — Partial отримує дані від батька, view component завантажує їх сам і має власну логіку.
- Де діє
_ViewImports.cshtml? — У своїй папці та вкладених; файли різних рівнів об’єднуються. - Як Razor захищає від XSS? — Кодує все, що виводиться через
@;Html.Rawкодування вимикає. - Навіщо
asp-append-version="true"? — Додає хеш файлу в URL, і браузер не тримає в кеші стару версію. - Чи можна змішувати Razor Pages і MVC? — Так:
AddControllersWithViewsіAddRazorPagesв одному застосунку, зі спільними layout і tag helpers.
Що таке SignalR і які транспорти він використовує?
Section titled “Що таке SignalR і які транспорти він використовує?”Що це
SignalR тримає постійне з’єднання між браузером і сервером. Сервер сам надсилає повідомлення — нове замовлення, статус задачі, рядок таблиці — без перезавантаження сторінки й опитування за таймером.
Як користуватися
- Сервер: клас-нащадок
Hubіapp.MapHub<NotificationsHub>("/hubs/notifications"). - Клієнт: пакет
@microsoft/signalr,HubConnectionBuilderіconnection.on(...)для вхідних повідомлень. connection.invoke("Method", args)викликає публічний метод хаба.
Транспорти, від найкращого до запасного
| Транспорт | Як працює |
|---|---|
| WebSockets | одне двостороннє з’єднання |
| Server-Sent Events | потік подій від сервера, від клієнта — окремі POST |
| Long Polling | запит висить до появи даних, потім новий |
Типова помилка
Вважати, що withAutomaticReconnect() перепідключається вічно. Без параметрів він робить чотири спроби — через 0, 2, 10 і 30 секунд — і здається.
Як влаштовано
- Клієнт надсилає
POST {hub}/negotiate?negotiateVersion=1. Сервер відповідаєconnectionId, секретнимconnectionTokenі списком транспортів із форматамиTextіBinary. - Клієнт пробує транспорти по черзі й підключається з
?id={connectionToken}. - Першим повідомленням клієнт надсилає handshake із протоколом: JSON (за замовчуванням) або MessagePack.
- Далі йдуть виклики в обидва боки. Сервер раз на 15 секунд шле ping (
KeepAliveInterval) і вважає клієнта відключеним після 30 секунд тиші (ClientTimeoutInterval).
Коли що обирати
| JSON | MessagePack | |
|---|---|---|
| Розмір повідомлень | більший | менший |
| Налагодження | читається в DevTools | двійковий формат |
| Пакети й транспорти | вбудований, усі транспорти | окремі пакети, без SSE |
Пастки
invokeчекає на відповідь і помилку сервера,send— лише на надсилання.- Повідомлення від клієнта, більше за 32 КБ, сервер відхиляє (
MaximumReceiveMessageSize). Великі дані вантажать звичайним HTTP. - Після автоматичного перепідключення з’єднання має новий
connectionId, і все, що було прив’язане до старого, втрачається. - Помилку першого
start()автоматичне перепідключення не обробляє — її ловлять самі.
Правило
SignalR — для сповіщень і живих оновлень. Важкі дані за сповіщенням клієнт дозапитує звичайним HTTP.
Під капотом
- WebSockets — повний дуплекс в одному TCP-з’єднанні після відповіді
101 Switching Protocols. - SSE і Long Polling приймають через
EventSourceабо завислий GET, а надсилають окремими POST на той самий endpoint з?id=. - SSE підтримує лише формат
Text, тому з MessagePack клієнт його пропускає. - Стан з’єднання — буфери, id, користувач — живе в пам’яті процесу, який виконав negotiate.
Ціна
- Кожне з’єднання — постійний сокет і пам’ять під буфери (за замовчуванням по 64 КБ на приймання й надсилання). Це ресурс сервера, навіть коли повідомлень немає.
- Long Polling — HTTP-запит на кожну порцію даних.
- SSE через HTTP/1.1 займає одне із шести з’єднань браузера на домен.
Компроміси
- Тихий відкат на Long Polling маскує проблеми інфраструктури: усе працює, але з більшою затримкою й навантаженням.
Transports = HttpTransportType.WebSocketsна сервері таskipNegotiationна клієнті прибирають negotiate і відкат. Ціна — клієнти за «поганими» проксі перестануть підключатися.
Як перевірити в продакшені
- Метрика
signalr.server.active_connections(із .NET 8) з тегомsignalr.transportпоказує реальний розподіл транспортів,kestrel.upgraded_connections— кількість WebSocket-з’єднань. - У DevTools на вкладці Network видно
negotiateі далі відповідь101у WebSockets.
Підсумок: транспорт визначає ціну з’єднання, тому фактичний транспорт у продакшені вимірюють, а не припускають.
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSignalR();
var app = builder.Build();app.MapHub<NotificationsHub>("/hubs/notifications"); // плюс /hubs/notifications/negotiateapp.Run();
public class NotificationsHub : Hub{ // клієнт: connection.invoke("Ping", "hello") public Task Ping(string text) => Clients.Caller.SendAsync("Pong", text.ToUpperInvariant());}const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/notifications") // negotiate, потім WebSockets → SSE → Long Polling .withAutomaticReconnect() // спроби через 0, 2, 10 і 30 секунд .build();
connection.on("Pong", text => console.log(text)); // HELLO
await connection.start();await connection.invoke("Ping", "hello");- Чому WebSockets кращий за SSE? — Повний дуплекс в одному з’єднанні; SSE надсилає від клієнта окремі POST і передає лише текст.
- Що робить negotiate? — Видає
connectionId,connectionTokenі список транспортів; без нього можна підключитися лише через WebSockets (skipNegotiation). - Що буде, якщо сервер перезапуститься? — З
withAutomaticReconnectклієнт перепідключиться з новимconnectionId, без нього спрацюєonclose. - Чим
invokeвідрізняється відsend? —invokeчекає на результат і помилку сервера,send— лише на факт надсилання. - Як SignalR помічає обрив? — Ping раз на 15 секунд; сервер вважає клієнта відключеним після 30 секунд тиші.
Що таке CSRF і як працює antiforgery в MVC і Razor Pages?
Section titled “Що таке CSRF і як працює antiforgery в MVC і Razor Pages?”Що це
- Користувач увійшов на
bank.example, браузер має cookie входу. - Він відкриває
evil.example, де схована форма зaction="https://bank.example/transfer". - Скрипт надсилає форму. Браузер додає cookie банку, і сервер бачить звичайний запит користувача, який увійшов.
Як захищає токен
- Сервер видає токен у cookie та парний токен у прихованому полі форми.
- Чужий сайт може змусити браузер надіслати cookie, але не може прочитати сторінку банку й дізнатися значення поля.
- Фільтр звіряє пару й відповідає 400, якщо токена немає або він не підходить.
<form method="post"> з tag helpers отримує токен автоматично — лишається ввімкнути перевірку атрибутом або глобальним фільтром.
Типова помилка
Змінювати дані GET-запитом. <img src="/orders/delete/5"> на чужій сторінці виконає видалення, а GET токеном не перевіряється.
Як влаштовано
- Під час рендерингу форми
IAntiforgeryстворює cookie-токен (.AspNetCore.Antiforgery.*) і request-токен, пов’язані між собою та з користувачем. - FormTagHelper вставляє request-токен у поле
__RequestVerificationToken, якщо форма маєmethod="post", аactionне задано вручну. - Під час запиту фільтр бере токен із заголовка
RequestVerificationTokenабо з форми й звіряє його з cookie.
Коли що обирати
| Атрибут | Що робить |
|---|---|
[ValidateAntiForgeryToken] |
вимагає токен для будь-яких методів, навіть GET |
[AutoValidateAntiforgeryToken] |
вимагає для всіх, крім GET, HEAD, OPTIONS, TRACE |
[IgnoreAntiforgeryToken] |
вимикає перевірку, перекриваючи рівні вище |
Документація радить AutoValidateAntiforgeryToken глобально та IgnoreAntiforgeryToken точково: забутий атрибут на POST не залишить діри.
Пастки
AddControllers()antiforgery не вмикає — потрібенAddControllersWithViews().- AJAX сам токен не надішле. Його кладуть у заголовок
RequestVerificationTokenабо у власне ім’я черезoptions.HeaderName. - Токен прив’язаний до користувача, тому після входу потрібен редирект на нову сторінку зі свіжим токеном.
- Кілька вкладок під різними користувачами не підтримуються.
Правило
Cookie-автентифікація плюс форма чи AJAX — потрібен токен. Bearer-токен, який JavaScript кладе в заголовок, — CSRF незастосовний.
Під капотом
- Токени шифруються й підписуються через Data Protection. Request-токен містить посилання на cookie-токен та ідентифікатор користувача.
- Cookie-токен за замовчуванням
HttpOnlyіSameSite=Strict. Відповідь із токеном отримуєCache-Control: no-cache, no-store, щоб проксі не роздав її іншим. - Із .NET 8 є middleware
UseAntiforgery. Він перевіряє POST, PUT і PATCH на endpoints з метаданими antiforgery і записує результат вIAntiforgeryValidationFeature. Minimal API з[FromForm]без нього падає зInvalidOperationException.
Ціна
- У фермі серверів потрібен спільний key ring Data Protection: Redis, база або Blob Storage. Інакше — 400 після перемикання вузла чи перезапуску.
- Сторінка з формою персональна, і повністю на CDN її не закешувати.
Компроміси
- SameSite=Lax не надсилає cookie в міжсайтових POST, але не рятує від атак із піддоменів того самого сайту й від GET із побічними ефектами. Тому токени лишаються.
- Bearer-токен у заголовку закриває CSRF, але токен у
localStorageдоступний будь-якому скрипту в разі XSS. - У .NET 11 (виходить у листопаді 2026) з’являється автоматичний CSRF-захист за заголовками
Sec-Fetch-SiteіOrigin. Він доповнює токени, а не замінює.
Як перевірити в продакшені
- Хвиля 400 на POST після деплою і в логах «Antiforgery token validation failed» — ознака різних ключів Data Protection на вузлах.
- Тест: POST без токена має отримувати 400.
Підсумок: токени, SameSite і заборона змін на GET працюють разом, а у фермі токени тримаються на спільному ключі Data Protection.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllersWithViews(options => // усі запити, крім GET/HEAD/OPTIONS/TRACE, вимагають токен options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));builder.Services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");
var app = builder.Build();app.UseAntiforgery(); // для Minimal API і Blazor; MVC перевіряє фільтром
// .NET 8+: endpoint з [FromForm] перевіряє токен автоматичноapp.MapPost("/feedback", ([FromForm] string text) => Results.Ok(text.Length));
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");app.Run();
public class ProfileController : Controller{ [HttpPost] // токен уже перевірено глобальним фільтром public IActionResult Save(string displayName) => RedirectToAction("Index");
[HttpPost, IgnoreAntiforgeryToken] // вебхук партнера: cookie немає, токена теж public IActionResult Webhook() => Ok();}@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@* FormTagHelper сам додасть приховане поле __RequestVerificationToken *@<form asp-controller="Profile" asp-action="Save" method="post"> <input name="displayName" /> <button>Save</button></form>
@* fetch: токен — у заголовок *@<script> fetch("/Profile/Save", { method: "POST", headers: { "X-XSRF-TOKEN": "@Antiforgery.GetAndStoreTokens(Context).RequestToken" }, body: new FormData(document.forms[0]) });</script>- Чим
ValidateAntiForgeryTokenвідрізняється відAutoValidateAntiforgeryToken? — Другий не перевіряє GET, HEAD, OPTIONS і TRACE, тому його можна ввімкнути глобально. - Чи потрібен antiforgery для API з JWT? — Ні, якщо токен іде в заголовку
Authorization: браузер сам його не додає. - Чи захищає SameSite=Lax повністю? — Ні: лишаються атаки з піддоменів і GET із побічними ефектами.
- Чому після деплою на ферму форми віддають 400? — На вузлах різні ключі Data Protection; key ring треба зробити спільним.
- Як передати токен в AJAX? —
IAntiforgery.GetAndStoreTokensі заголовокRequestVerificationTokenабо власне ім’я зHeaderName.
Як працюють хаби, групи й надсилання конкретним користувачам у SignalR?
Section titled “Як працюють хаби, групи й надсилання конкретним користувачам у SignalR?”Що це
- Хаб — нащадок
Hub. Клієнт викликає його публічні методи за ім’ям. Clients— кому надіслати:All,Caller,Others,Client(connectionId),Group(name),User(userId).- Група — іменований набір з’єднань: кімната чату, конкретне замовлення.
Context— дані поточного з’єднання:ConnectionId,User,UserIdentifier.
Як користуватися
- У методі хаба додайте з’єднання до групи:
Groups.AddToGroupAsync(Context.ConnectionId, "order-42"). - Надішліть усім у групі:
Clients.Group("order-42").SendAsync("OrderShipped", 42). - Поза хабом впровадьте
IHubContext<OrdersHub>і викличте ті саміClients.
Типова помилка
Зберігати дані в полях хаба. Новий екземпляр створюється на кожен виклик, і поле завжди порожнє.
Як влаштовано
- Хаб створюється на кожен виклик методу, scoped-залежності живуть стільки ж. Методи одного клієнта за замовчуванням виконуються по черзі (
MaximumParallelInvocationsPerClient = 1). Clients.User(id)знаходить усі з’єднання користувача: вкладки, телефон. Id даєIUserIdProvider, за замовчуванням це claimClaimTypes.NameIdentifier.- Групи живуть у пам’яті, а під час відключення з’єднання видаляється з них автоматично.
Автентифікація
| Клієнт | Як передаються облікові дані |
|---|---|
| Браузер із cookie-входом | cookie надсилається автоматично |
| Браузер із JWT | accessTokenFactory; для WebSockets і SSE — ?access_token= |
| .NET-клієнт | заголовок Authorization |
На сервері токен із query string дістають у JwtBearerEvents.OnMessageReceived і лише для шляху хаба.
Пастки
- Групи не переживають перепідключення: клієнт вступає в них знову в
onreconnected, або сервер — вOnConnectedAsync. - Група — не механізм безпеки. Під час відкликання прав з’єднання прибирають із групи явно.
- Токен в URL потрапляє в логи проксі й сервера, їх треба фільтрувати.
IHubContextне маєCallerіContext: поза хабом немає «того, хто викликав».SendAsyncбезawaitу методі хаба може не встигнути до завершення виклику.
Правило
Адресуйте через користувачів і групи, а не через збережені connectionId: вони змінюються під час кожного перепідключення.
Під капотом
DefaultHubLifetimeManager<THub>зберігає з’єднання процесу та словник груп. Надсилання групі — обхід її з’єднань із записом у кожне.- Повідомлення серіалізується один раз на протокол, а не на кожне з’єднання. Якщо запис клієнту не завершується за
TransportSendTimeout(10 секунд), з’єднання закривається. ClaimsPrincipalкешується під час підключення, навіть якщо Long Polling робить нові HTTP-запити.CloseOnAuthenticationExpirationзакриває з’єднання, коли токен спливає, а в .NET 11 з’явиться оновлення облікових даних без перепідключення.- Із .NET 7 сервер може чекати на відповідь клієнта:
Clients.Client(id).InvokeAsync<T>(...).
Ціна
- Широкомовлення на 10 000 клієнтів — 10 000 записів у сокети. Повільний клієнт накопичує повідомлення в пам’яті сервера.
- Хаб живе один виклик, і
DbContextіз DI теж. У довгих методах зручнішеIDbContextFactory.
Компроміси
Hub<T>перевіряє імена методів під час компіляції, але JS-клієнт про контракт не знає. Імена й аргументи звіряють тестами.- Cookie простіші для браузера, bearer — для мобільних і .NET-клієнтів. Токен у query string вимагає чищення логів.
Як перевірити в продакшені
- Метрики
signalr.server.active_connectionsіsignalr.server.connection.duration(із .NET 8): багато коротких з’єднань — ознака проблем з автентифікацією або проксі. EnableDetailedErrors— лише в Development. У продакшені виняток логують на сервері, а клієнту віддаютьHubExceptionз безпечним текстом.
Підсумок: хаб — тонкий фасад над менеджером з’єднань, а групи, користувачі й права живуть не довше за з’єднання.
using System.Security.Claims;using Microsoft.AspNetCore.Authorization;using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddAuthentication().AddCookie(); // із JWT токен читають з ?access_token=builder.Services.AddAuthorization();builder.Services.AddSignalR();builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();
var app = builder.Build();app.MapHub<OrdersHub>("/hubs/orders");
// Надсилання поза хабом: з endpoint, контролера або BackgroundServiceapp.MapPost("/orders/{id}/ship", async (int id, string email, IHubContext<OrdersHub, IOrdersClient> hub) =>{ await hub.Clients.User(email).OrderShipped(id); // усі вкладки й пристрої користувача await hub.Clients.Group($"order-{id}").OrderShipped(id); // усі, хто стежить за замовленням return Results.NoContent();});app.Run();
public interface IOrdersClient{ Task OrderShipped(int orderId); // на клієнті: connection.on("OrderShipped", ...)}
[Authorize]public class OrdersHub : Hub<IOrdersClient>{ public Task Watch(int orderId) => Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");}
public class EmailUserIdProvider : IUserIdProvider{ public string? GetUserId(HubConnectionContext connection) => connection.User?.FindFirst(ClaimTypes.Email)?.Value;}const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders", { accessTokenFactory: () => getAccessToken() }) .withAutomaticReconnect() .build();
connection.on("OrderShipped", id => showToast(`Order ${id} shipped`));// після перепідключення id новий, групи порожні — вступаємо зновуconnection.onreconnected(() => connection.invoke("Watch", currentOrderId));
await connection.start();await connection.invoke("Watch", currentOrderId);- Чому не можна зберігати стан у полях хаба? — Екземпляр створюється на кожен виклик методу.
- Чи зберігаються групи після перепідключення? — Ні: з’єднання має новий id, у групи вступають знову.
- Як надіслати повідомлення з контролера чи
BackgroundService? — ЧерезIHubContext<THub>або типізованийIHubContext<THub, T>. - Чому JWT для WebSockets передається в query string? — Браузерні API WebSocket і EventSource не дають задати заголовок
Authorization. - Що буде, якщо в користувача відкликали роль під час з’єднання? — Хаб бачить старий
Context.Userдо перепідключення.
Як масштабувати SignalR на кілька серверів і що ламається за балансувальником?
Section titled “Як масштабувати SignalR на кілька серверів і що ламається за балансувальником?”Що це
Сервер SignalR тримає з’єднання своїх клієнтів у пам’яті. Коли серверів кілька, сервер A нічого не знає про клієнтів сервера B, і Clients.All із сервера A дійде лише до клієнтів A.
Як розв’язують
| Спосіб | Ідея |
|---|---|
| Redis backplane | сервери обмінюються повідомленнями через Redis |
| Azure SignalR Service | клієнти підключаються до хмарного сервісу |
| Sticky sessions | балансувальник надсилає клієнта на той самий сервер |
Sticky sessions потрібні майже завжди. Обійтися без них можна з Azure SignalR Service або якщо клієнти використовують лише WebSockets і пропускають negotiate.
Типова помилка
Підключити Redis backplane і забути sticky sessions. З’єднання через SSE і Long Polling рвуться, щойно балансувальник надішле черговий запит на інший сервер.
Як влаштовано
- З Redis backplane кожен сервер підписується на канали Redis: загальний, своїх з’єднань, користувачів і груп, де має учасників.
Clients.Group("g").SendAsync(...)публікує повідомлення в канал групи.- Сервери, підписані на цей канал, надсилають його своїм з’єднанням із групи.
Коли що обирати
| Redis backplane | Azure SignalR Service | |
|---|---|---|
| Де | власна інфраструктура | Azure |
| Sticky sessions | потрібні | не потрібні |
| Хто тримає з’єднання | ваші сервери | сервіс |
Пастки
- Один Redis на кілька застосунків без власного
ChannelPrefixзмішує їхні повідомлення. - Redis недоступний — повідомлення втрачаються. SignalR їх не буферизує, але до Redis перепідключається сам.
- Кількість учасників групи не порахувати: кожен сервер бачить лише свою частину.
- Балансувальник без підтримки Upgrade переводить клієнтів на Long Polling, а короткий idle-тайм-аут рве з’єднання.
- IIS на клієнтських Windows тримає не більше 10 одночасних з’єднань — для навантажувальних тестів він не годиться.
Правило
Власна інфраструктура — Redis backplane у тому самому дата-центрі плюс sticky sessions. Хостинг в Azure — Azure SignalR Service.
Під капотом
connectionTokenіз negotiate знає лише процес, який його видав, а SSE і Long Polling надсилають кожне повідомлення окремим POST. WebSockets без negotiate — одне з’єднання, йому прив’язка не потрібна.- З Azure SignalR Service negotiate застосунку повертає клієнту редирект на URL сервісу з токеном доступу. Сервер застосунку тримає кілька постійних з’єднань із сервісом і шле через них усі повідомлення.
- Redis pub/sub не зберігає повідомлень: що не доставлено, те не повториться.
Ціна
- З backplane кожен сервер, як і раніше, тримає свої з’єднання, і масштабувати доводиться за кількістю клієнтів, навіть якщо повідомлень мало.
- Redis Cluster підвищує доступність, але знижує пропускну здатність: повідомлення йдуть на всі вузли кластера.
- Azure SignalR Service тарифікується юнітами по 1000 одночасних з’єднань.
Компроміси
- Деплой або перезапуск пулу IIS рве всі з’єднання вузла разом. Затримки
withAutomaticReconnectза замовчуванням однакові в усіх клієнтів, тому потрібенIRetryPolicyз випадковою паузою, інакше хвиля перепідключень ударить по решті вузлів. - Stateful reconnect (.NET 8+) досилає повідомлення після короткого обриву, але лише на той самий сервер і в межах буфера (за замовчуванням 100 000 байтів). Він узгоджується в negotiate, тому зі
skipNegotiationнедоступний.
Як перевірити в продакшені
signalr.server.active_connectionsза вузлами: перекіс — погане балансування, зростання часткиlong_pollingуsignalr.transport— проксі ріже WebSockets.signalr.server.connection.durationзі статусомtimeout— idle-тайм-аути балансувальника.- Навантажувальний тест на двох вузлах: повідомлення в групу з вузла A має дійти до клієнта на вузлі B.
Підсумок: масштабування SignalR — це sticky sessions плюс backplane або сервіс, а надійність доставки забезпечує сам клієнт.
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSignalR() .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options => options.Configuration.ChannelPrefix = RedisChannel.Literal("shop")); // власний префікс у кожного застосунку
// В Azure замість Redis — пакет Microsoft.Azure.SignalR:// builder.Services.AddSignalR().AddAzureSignalR();
var app = builder.Build();app.MapHub<OrdersHub>("/hubs/orders", options => options.AllowStatefulReconnects = true); // .NET 8+: досилання після короткого обривуapp.Run();
public class OrdersHub : Microsoft.AspNetCore.SignalR.Hub { }// Варіант 1: negotiate і sticky sessions, stateful reconnect доступнийconst connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders") .withStatefulReconnect() .withAutomaticReconnect({ // випадкова пауза: після деплою клієнти повертаються не всі разом nextRetryDelayInMilliseconds: ctx => ctx.previousRetryCount < 10 ? 1000 + Math.random() * 10000 : null }) .build();
// Варіант 2: без sticky sessions — лише WebSockets і без negotiate// (stateful reconnect узгоджується в negotiate, тому тут недоступний)const wsOnly = new signalR.HubConnectionBuilder() .withUrl("/hubs/orders", { transport: signalR.HttpTransportType.WebSockets, skipNegotiation: true }) .build();- Чому з Redis backplane досі потрібні sticky sessions? —
connectionTokenзнає лише сервер, який його видав, а SSE і Long Polling шлють багато окремих запитів. - Коли sticky sessions не потрібні? — Один сервер, Azure SignalR Service або лише WebSockets зі
skipNegotiation. - Що буде з повідомленнями, якщо Redis упав? — Втратяться: backplane не буферизує, до Redis SignalR перепідключиться сам.
- Навіщо
ChannelPrefix? — Ізолює застосунки, які ділять один Redis. - Як не покласти кластер хвилею перепідключень після деплою? — Випадкова пауза в
IRetryPolicyі поетапне виведення вузлів із балансування.