MVC, Razor и SignalR
Серверный UI на ASP.NET Core и обновления в реальном времени: путь запроса от контроллера до HTML, Razor, защита форм от CSRF и SignalR на одном и нескольких серверах.
Перед этой темой: Хостинг и конвейер.
Как устроен MVC в ASP.NET Core и какой путь проходит запрос до View()?
Заголовок раздела «Как устроен 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?
Заголовок раздела «Что такое 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 и какие транспорты он использует?
Заголовок раздела «Что такое 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?
Заголовок раздела «Что такое 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?
Заголовок раздела «Как работают хабы, группы и отправка конкретным пользователям в 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 на несколько серверов и что ломается за балансировщиком?
Заголовок раздела «Как масштабировать 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и поэтапный вывод узлов из балансировки.