Перейти к содержимому

MVC, Razor и SignalR

Серверный UI на ASP.NET Core и обновления в реальном времени: путь запроса от контроллера до HTML, Razor, защита форм от CSRF и SignalR на одном и нескольких серверах.

Перед этой темой: Хостинг и конвейер.

Junior+

Как устроен MVC в ASP.NET Core и какой путь проходит запрос до View()?

Заголовок раздела «Как устроен MVC в ASP.NET Core и какой путь проходит запрос до View()?»
Ответ

Что это

  • Модель — данные и бизнес-логика: сущности, сервисы, правила.
  • Представление (View) — файл .cshtml с HTML и Razor, который показывает данные.
  • Контроллер — класс с методами-actions: принимает запрос, вызывает модель, выбирает представление.
  • ViewModel — класс под конкретную страницу: только поля, которые она показывает или принимает.

Как проходит запрос

  1. Шаблон {controller=Home}/{action=Index}/{id?} превращает /Orders/Details/42 в вызов OrdersController.Details(42).
  2. Action получает данные и собирает ViewModel.
  3. return View(vm) ищет Views/Orders/Details.cshtml, затем Views/Shared/Details.cshtml.
  4. Razor подставляет данные в разметку, и браузер получает готовый HTML.

Частая ошибка

Писать бизнес-логику прямо в контроллере. Тогда её не переиспользовать и не протестировать без HTTP. Контроллер только связывает запрос, сервис и представление.

Как устроено

  1. Запрос проходит middleware конвейера, и маршрутизация выбирает endpoint — конкретный action.
  2. MVC создаёт контроллер. Сам класс не зарегистрирован в DI, но его зависимости берутся из scope запроса.
  3. Фильтры и model binding готовят вызов: параметры заполняются из маршрута, query и формы (подробно — в теме Web API).
  4. Action возвращает IActionResult. View(vm) создаёт ViewResult — описание результата, а не HTML.
  5. После выхода из action ViewResult выполняется: движок Razor находит представление, запускает _ViewStart, рендерит представление и layout.

Когда что выбирать

Способ передать данные Когда
ViewModel и @model основной способ: типизация и IntelliSense
ViewData, ViewBag мелочи для layout, например заголовок страницы
TempData сообщение, которое переживает один редирект

Ловушки

  • Ленивый рендеринг. Представление выполняется после action. IQueryable или сущности с lazy loading в модели — это запросы к базе во время рендеринга, часто N+1.
  • Регистр имён. Представление ищется по имени файла. На Linux файловая система различает регистр, и details.cshtml не найдётся для action Details.
  • Сущность на входе. 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>
Junior+

Что такое 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 → /Contact
app.Run();
// View component: своя логика и представление Views/Shared/Components/Cart/Default.cshtml
public 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>
Junior+

Что такое SignalR и какие транспорты он использует?

Заголовок раздела «Что такое SignalR и какие транспорты он использует?»
Ответ

Что это

SignalR держит постоянное соединение между браузером и сервером. Сервер сам отправляет сообщение — новый заказ, статус задачи, строку таблицы — без перезагрузки страницы и опроса по таймеру.

Как пользоваться

  1. Сервер: класс-наследник Hub и app.MapHub<NotificationsHub>("/hubs/notifications").
  2. Клиент: пакет @microsoft/signalr, HubConnectionBuilder и connection.on(...) для входящих сообщений.
  3. connection.invoke("Method", args) вызывает публичный метод хаба.

Транспорты, от лучшего к запасному

Транспорт Как работает
WebSockets одно двустороннее соединение
Server-Sent Events поток событий от сервера, от клиента — отдельные POST
Long Polling запрос висит до появления данных, затем новый

Частая ошибка

Считать, что withAutomaticReconnect() переподключает вечно. Без параметров он делает четыре попытки — через 0, 2, 10 и 30 секунд — и сдаётся.

Как устроено

  1. Клиент отправляет POST {hub}/negotiate?negotiateVersion=1. Сервер отвечает connectionId, секретным connectionToken и списком транспортов с форматами Text и Binary.
  2. Клиент пробует транспорты по порядку и подключается с ?id={connectionToken}.
  3. Первым сообщением клиент отправляет handshake с протоколом: JSON (по умолчанию) или MessagePack.
  4. Дальше идут вызовы в обе стороны. Сервер раз в 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/negotiate
app.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");
Middle+

Что такое CSRF и как работает antiforgery в MVC и Razor Pages?

Заголовок раздела «Что такое CSRF и как работает antiforgery в MVC и Razor Pages?»
Ответ

Что это

  1. Пользователь вошёл на bank.example, у браузера есть cookie входа.
  2. Он открывает evil.example, где спрятана форма с action="https://bank.example/transfer".
  3. Скрипт отправляет форму. Браузер прикладывает cookie банка, и сервер видит обычный запрос вошедшего пользователя.

Как защищает токен

  • Сервер выдаёт токен в cookie и парный токен в скрытом поле формы.
  • Чужой сайт может заставить браузер отправить cookie, но не может прочитать страницу банка и узнать значение поля.
  • Фильтр сверяет пару и отвечает 400, если токена нет или он не подходит.

<form method="post"> с tag helpers получает токен автоматически — остаётся включить проверку атрибутом или глобальным фильтром.

Частая ошибка

Менять данные GET-запросом. <img src="/orders/delete/5"> на чужой странице выполнит удаление, а GET токеном не проверяется.

Как устроено

  1. При рендеринге формы IAntiforgery создаёт cookie-токен (.AspNetCore.Antiforgery.*) и request-токен, связанные между собой и с пользователем.
  2. FormTagHelper вставляет request-токен в поле __RequestVerificationToken, если у формы method="post", а action не задан вручную.
  3. При запросе фильтр берёт токен из заголовка 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>
Middle+

Как работают хабы, группы и отправка конкретным пользователям в SignalR?

Заголовок раздела «Как работают хабы, группы и отправка конкретным пользователям в SignalR?»
Ответ

Что это

  • Хаб — наследник Hub. Клиент вызывает его публичные методы по имени.
  • Clients — кому отправить: All, Caller, Others, Client(connectionId), Group(name), User(userId).
  • Группа — именованный набор соединений: комната чата, конкретный заказ.
  • Context — данные текущего соединения: ConnectionId, User, UserIdentifier.

Как пользоваться

  1. В методе хаба добавьте соединение в группу: Groups.AddToGroupAsync(Context.ConnectionId, "order-42").
  2. Отправьте всем в группе: Clients.Group("order-42").SendAsync("OrderShipped", 42).
  3. Вне хаба внедрите IHubContext<OrdersHub> и вызовите те же Clients.

Частая ошибка

Хранить данные в полях хаба. Новый экземпляр создаётся на каждый вызов, и поле всегда пустое.

Как устроено

  • Хаб создаётся на каждый вызов метода, scoped-зависимости живут столько же. Методы одного клиента по умолчанию выполняются по очереди (MaximumParallelInvocationsPerClient = 1).
  • Clients.User(id) находит все соединения пользователя: вкладки, телефон. Id даёт IUserIdProvider, по умолчанию это claim ClaimTypes.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, контроллера или BackgroundService
app.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);
Senior+

Как масштабировать 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 рвутся, как только балансировщик отправит очередной запрос на другой сервер.

Как устроено

  1. С Redis backplane каждый сервер подписывается на каналы Redis: общий, своих соединений, пользователей и групп, где у него есть участники.
  2. Clients.Group("g").SendAsync(...) публикует сообщение в канал группы.
  3. Серверы, подписанные на этот канал, отправляют его своим соединениям из группы.

Когда что выбирать

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 или сервис, а надёжность доставки обеспечивает сам клиент.

Пример
Microsoft.AspNetCore.SignalR.StackExchangeRedis
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();