5.04. SignalR
SignalR
SignalR — это библиотека, разработанная компанией Microsoft как часть платформы ASP.NET Core. Она предоставляет высокоуровневую абстракцию над транспортными механизмами реального времени и позволяет создавать приложения с двусторонней связью между клиентом и сервером. Основное назначение SignalR — упрощение реализации взаимодействия в режиме реального времени без необходимости глубокого погружения в низкоуровневые протоколы или сложные архитектурные решения.
Модель взаимодействия и архитектурные основы
В основе SignalR лежит модель Publisher–Subscriber, которая является одной из ключевых парадигм реактивного программирования. Сервер выступает в роли издателя, способного отправлять сообщения всем подписанным клиентам или их подмножеству. Клиенты, в свою очередь, могут не только получать данные, но и инициировать события на сервере, что делает связь полностью двунаправленной.
Такой подход кардинально отличается от классической модели HTTP-запросов, где клиент всегда инициирует взаимодействие, а сервер лишь отвечает. В случае с SignalR сервер может инициировать передачу данных в любой момент, не дожидаясь запроса. Это открывает возможности для создания интерактивных систем, таких как чаты, игровые приложения, панели мониторинга, системы голосования, торговые площадки и картографические сервисы.
SignalR инкапсулирует сложность выбора и управления транспортным протоколом. Он автоматически определяет наиболее подходящий механизм связи на основе возможностей клиента и сервера. Поддерживаемые транспорты включают:
- WebSocket — современный протокол, обеспечивающий полнодуплексную связь поверх TCP. Он обеспечивает минимальную задержку и максимальную эффективность.
- Server-Sent Events (SSE) — односторонний поток данных от сервера к клиенту через HTTP. Подходит для сценариев, где клиенту не требуется часто отправлять данные.
- Long Polling — эмуляция постоянного соединения через повторяющиеся HTTP-запросы. Используется как fallback-механизм, когда другие протоколы недоступны.
SignalR выбирает WebSocket в первую очередь, если он поддерживается обеими сторонами. При его отсутствии применяется SSE, а затем — long polling. Этот процесс происходит прозрачно для разработчика, что позволяет сосредоточиться на бизнес-логике, а не на деталях сетевого взаимодействия.
Хаб как центральная абстракция
Основной строительный блок любого приложения на SignalR — это хаб (hub). Хаб представляет собой класс, унаследованный от базового класса Hub, предоставляемого фреймворком. Он служит точкой входа для клиентских вызовов и центром управления рассылкой сообщений.
Каждый публичный метод хаба становится доступным для вызова со стороны клиента. Когда клиент вызывает такой метод, выполнение происходит на сервере в контексте текущего подключения. Внутри метода хаба разработчик может обращаться к свойству Clients, которое предоставляет доступ к различным группам подключённых клиентов:
Clients.All— все подключённые клиенты.Clients.Caller— клиент, инициировавший вызов.Clients.Others— все клиенты, кроме инициатора.Clients.Group("groupName")— клиенты, принадлежащие определённой группе.Clients.User("userId")— клиенты, аутентифицированные под указанным идентификатором.
Эти возможности позволяют гибко управлять доставкой сообщений, организовывать чаты по комнатам, персонализировать уведомления и реализовывать сложные сценарии взаимодействия.
Конфигурация и интеграция в ASP.NET Core
Для использования SignalR в приложении ASP.NET Core необходимо зарегистрировать соответствующие сервисы в контейнере зависимостей. Это делается вызовом метода AddSignalR() в конфигурации сервисов. После этого хаб регистрируется в маршрутизации с помощью метода MapHub<THub>("/path"), который связывает URL-путь с конкретным типом хаба.
Такая интеграция позволяет SignalR работать в рамках стандартной экосистемы ASP.NET Core, включая middleware, зависимости, логирование и обработку ошибок. Хабы участвуют в жизненном цикле приложения и могут использовать внедрение зависимостей для получения сервисов, таких как репозитории, кэши или внешние API.
Клиентская сторона: универсальность и кроссплатформенность
SignalR поддерживает широкий спектр клиентских платформ. Наиболее распространённый вариант — JavaScript-клиент, работающий в современных браузерах. Он предоставляет простой API для установки соединения, вызова методов на сервере и регистрации обработчиков событий.
Помимо браузерного JavaScript, существуют официальные клиентские библиотеки для:
- .NET — включая WPF, Windows Forms, MAUI, консольные приложения и службы.
- Java — для Android-приложений и серверных решений на JVM.
- Node.js — для создания серверных компонентов, взаимодействующих с SignalR-сервером.
Экспериментальная поддержка также доступна для C++ и Swift, что расширяет возможности интеграции с мобильными и встраиваемыми системами.
Клиентская библиотека управляет состоянием соединения, автоматически восстанавливает его при обрыве и обеспечивает согласованность данных. Разработчик получает единый интерфейс для работы с SignalR независимо от платформы.
Аутентификация и авторизация
SignalR интегрируется с системой аутентификации ASP.NET Core. Это означает, что можно использовать любые механизмы, поддерживаемые фреймворком: cookie-based аутентификацию, JWT-токены, OpenID Connect и другие. Контекст подключения содержит информацию о текущем пользователе, включая его идентификатор и роли.
Это позволяет применять политики авторизации к методам хаба с помощью атрибутов [Authorize], точно так же, как в контроллерах MVC или страницах Razor. Можно ограничить доступ к определённым методам только для аутентифицированных пользователей или пользователей с конкретными ролями.
Кроме того, SignalR предоставляет события подключения и отключения клиента (OnConnectedAsync и OnDisconnectedAsync), которые можно использовать для логирования, очистки ресурсов или обновления состояния системы.
Масштабирование и отказоустойчивость
Одно из важнейших преимуществ SignalR — возможность масштабирования на несколько серверов. По умолчанию каждое подключение существует только на одном сервере, что затрудняет рассылку сообщений всем клиентам в распределённой среде. Для решения этой задачи используется механизм backplane — внешнее хранилище, которое синхронизирует сообщения между всеми экземплярами приложения.
Поддерживаемые backplane-решения включают:
- SQL Server — использует Service Broker для передачи сообщений между серверами.
- Redis — высокопроизводительное in-memory хранилище, идеально подходящее для временных сообщений.
- Azure SignalR Service — управляемый облачный сервис, полностью абстрагирующий инфраструктурную сложность.
Выбор backplane зависит от требований к производительности, стоимости и инфраструктурных предпочтений. Все они обеспечивают прозрачную работу приложения при горизонтальном масштабировании.
Практические сценарии применения
SignalR находит применение в самых разных доменах:
- Чаты и мессенджеры — мгновенная доставка сообщений, индикаторы набора текста, онлайн-статусы.
- Совместное редактирование — синхронизация изменений в документах в реальном времени.
- Игровые приложения — передача состояния игры, позиций игроков, событий.
- Финансовые системы — обновление котировок, торговых сигналов, портфелей.
- Мониторинг и аналитика — отображение метрик, логов, событий безопасности.
- Образовательные платформы — интерактивные тесты, опросы, совместные доски.
В каждом из этих случаев SignalR устраняет необходимость в постоянных опросах сервера, снижает нагрузку на сеть и обеспечивает мгновенную реакцию на изменения.
SignalR как часть современной архитектуры приложений
SignalR не существует изолированно — он органично встраивается в современные архитектурные подходы, такие как микросервисы, event-driven systems и реактивные приложения. Его использование особенно уместно в тех случаях, когда система должна реагировать на события немедленно, без задержек, связанных с опросом или периодической синхронизацией.
В контексте микросервисной архитектуры SignalR может выступать как отдельный сервис реального времени, который получает события от других микросервисов через шину сообщений (например, RabbitMQ, Kafka или Azure Service Bus) и транслирует их подключённым клиентам. Такой подход позволяет отделить логику обработки данных от логики доставки уведомлений, соблюдая принцип единственной ответственности.
В монолитных приложениях SignalR часто используется напрямую внутри основного веб-процесса, что упрощает разработку и отладку. Однако при росте нагрузки такая модель требует перехода к выделенному сервису или использованию облачного решения, такого как Azure SignalR Service, которое полностью берёт на себя управление соединениями и масштабированием.
Группы и пользовательские подключения
Одной из ключевых возможностей SignalR является работа с группами подключений. Группа — это динамический набор клиентских соединений, которым можно отправлять сообщения одновременно. Группы создаются и управляются программно: разработчик вызывает метод AddToGroupAsync для добавления текущего подключения в группу и RemoveFromGroupAsync для удаления.
Группы не сохраняются между перезапусками сервера. Это означает, что если приложение завершится, все группы будут потеряны, и клиенты должны повторно присоединиться к ним после восстановления соединения. Такое поведение упрощает управление состоянием и делает систему более отказоустойчивой, но требует от клиента реализации логики повторного подписания.
Помимо групп, SignalR поддерживает адресацию по идентификатору пользователя. Если клиент прошёл аутентификацию, его подключения автоматически ассоциируются с ClaimsPrincipal.Identity.Name (или другим идентификатором, если используется кастомная схема). Это позволяет отправлять сообщения конкретному пользователю, даже если он подключён с нескольких устройств одновременно:
await Clients.User("alice@example.com").SendAsync("NewMessage", content);
Такой механизм особенно полезен в персонализированных системах: уведомления, оповещения о платежах, обновления статуса заказа и т.п.
Жизненный цикл подключения и обработка событий
Каждое клиентское подключение проходит через чётко определённый жизненный цикл, управляемый хабом:
- Подключение — вызывается метод
OnConnectedAsync. Здесь можно выполнить инициализацию: загрузить данные пользователя, добавить его в нужные группы, зафиксировать факт входа в систему. - Активное взаимодействие — клиент вызывает методы хаба, сервер отправляет сообщения.
- Отключение — вызывается метод
OnDisconnectedAsync. Причина отключения передаётся в параметре (Exception), что позволяет различать плановое завершение соединения и аварийный разрыв.
Эти точки расширения позволяют реализовать сложную логику: от онлайн-статусов до распределённых блокировок ресурсов. Например, при входе пользователя в чат можно увеличить счётчик онлайн-участников, а при выходе — уменьшить и уведомить остальных.
Обработка ошибок и устойчивость к сбоям
SignalR предоставляет механизмы для обработки исключений как на стороне сервера, так и на клиенте. На сервере любое необработанное исключение в методе хаба приведёт к завершению вызова, но не к падению всего приложения. Для централизованной обработки ошибок можно переопределить метод OnDisconnectedAsync или использовать middleware ASP.NET Core.
На клиентской стороне JavaScript-библиотека SignalR автоматически пытается восстановить соединение при потере связи. Разработчик может подписаться на события:
onreconnecting— начало попытки переподключения.onreconnected— успешное восстановление соединения.onclose— окончательное закрытие соединения.
Это позволяет реализовать UX-паттерны: показывать индикатор временной недоступности, кэшировать сообщения для отправки после восстановления, предлагать пользователю обновить страницу.
Производительность и ограничения
SignalR оптимизирован для высокой частоты сообщений. В тестовых сценариях один сервер способен обрабатывать десятки тысяч одновременных подключений при умеренной нагрузке. Однако производительность зависит от множества факторов:
- Тип транспорта (WebSocket значительно эффективнее long polling).
- Размер и частота сообщений.
- Сложность логики в методах хаба.
- Наличие backplane (Redis обычно быстрее SQL Server).
Для высоконагруженных систем рекомендуется:
- Минимизировать объём передаваемых данных (использовать компактные форматы, например, MessagePack вместо JSON).
- Избегать синхронных операций в методах хаба.
- Использовать пул потоков и асинхронные вызовы.
- Выносить тяжёлые вычисления за пределы хаба.
SignalR не предназначен для передачи больших файлов или потокового видео. Его сила — в передаче структурированных событий малого размера с минимальной задержкой.
Интеграция с другими технологиями .NET
SignalR тесно интегрирован с экосистемой .NET:
- Entity Framework Core — можно отправлять уведомления при изменении данных в базе.
- MediatR — использовать SignalR как обработчик событий в паттерне «публикация-подписка» на уровне приложения.
- Background Services — фоновые службы могут получать доступ к контексту хаба через
IHubContext<T>и инициировать рассылку без входящего запроса от клиента. - Blazor Server — использует SignalR как транспорт для двусторонней связи между браузером и сервером.
Пример получения IHubContext в фоновой службе:
public class NotificationService : BackgroundService
{
private readonly IHubContext<ChatHub> _hubContext;
public NotificationService(IHubContext<ChatHub> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _hubContext.Clients.All.SendAsync("SystemMessage", "Системное уведомление");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
Такой подход позволяет создавать гибридные системы, где бизнес-логика отделена от канала доставки.
Транспортные механизмы: внутренняя архитектура и выбор протокола
SignalR не является самостоятельным сетевым протоколом. Он представляет собой абстракцию над существующими механизмами передачи данных в реальном времени. Эта абстракция позволяет разработчику писать код один раз, не задумываясь о том, как именно данные будут доставлены до клиента. Выбор конкретного транспорта происходит автоматически на этапе установления соединения.
Процесс согласования (negotiation) начинается с того, что клиент отправляет HTTP-запрос к специальному эндпоинту /negotiate. Сервер отвечает JSON-объектом, содержащим информацию о поддерживаемых транспортах и, в случае использования Azure SignalR Service, временный URL для подключения. Клиент анализирует этот ответ и выбирает наиболее предпочтительный транспорт из доступных.
Порядок предпочтения фиксирован и оптимизирован для производительности:
- WebSocket — это идеальный транспорт для SignalR. Он обеспечивает постоянное, полнодуплексное соединение поверх одного TCP-соединения. Данные передаются в виде фреймов без накладных расходов HTTP-заголовков. WebSocket требует поддержки как на стороне сервера (ASP.NET Core полностью его поддерживает), так и на стороне клиента (все современные браузеры, .NET-клиенты, Node.js). Если обе стороны поддерживают WebSocket, он будет выбран.
- Server-Sent Events (SSE) — это односторонний поток данных от сервера к клиенту через обычное HTTP-соединение. Клиент открывает соединение и держит его открытым, а сервер по мере необходимости отправляет данные в формате
text/event-stream. SSE проще в реализации, чем WebSocket, и хорошо работает через большинство прокси и балансировщиков нагрузки. Однако он не позволяет клиенту отправлять данные серверу через тот же канал — для этого используются обычные HTTP-запросы. SSE поддерживается всеми основными браузерами, кроме Internet Explorer. - Long Polling — это самый старый и универсальный метод. Клиент отправляет HTTP-запрос к серверу и не получает немедленного ответа. Сервер удерживает запрос открытым, пока не появится сообщение для отправки или не истечёт таймаут. После получения ответа клиент немедленно отправляет новый запрос. Этот цикл создаёт иллюзию постоянного соединения. Long Polling работает везде, где есть HTTP, но имеет высокие накладные расходы (новые заголовки для каждого запроса) и большую задержку по сравнению с другими методами.
Такой многоуровневый подход гарантирует, что приложение на SignalR будет работать в максимально широком спектре сетевых условий, от современных корпоративных сетей до ограничений мобильных операторов.
Безопасность и защита соединений
Безопасность является критически важным аспектом любого приложения реального времени. SignalR наследует все механизмы безопасности ASP.NET Core, что обеспечивает единый и надёжный подход к защите.
Аутентификация
Аутентификация в SignalR происходит на этапе установления соединения. Для JavaScript-клиентов в браузере это обычно означает использование cookie, установленных при входе в систему. Поскольку SignalR-соединение использует те же домен и порт, что и основное веб-приложение, браузер автоматически отправляет аутентификационные cookie вместе с запросом на согласование.
Для других клиентов (например, .NET или Java) чаще используется токен-based аутентификация. Клиент получает JWT-токен от сервера авторизации и передаёт его в качестве параметра строки запроса или в заголовке Authorization при вызове withUrl.
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chat", {
accessTokenFactory: () => {
return getAccessToken(); // функция, возвращающая токен
}
})
.build();
Сервер проверяет токен с помощью стандартного middleware JWT Bearer, и если он действителен, контекст соединения заполняется информацией о пользователе.
Авторизация
После успешной аутентификации применяются правила авторизации. К методам хаба можно применять атрибут [Authorize], точно так же, как к контроллерам MVC. Можно указывать роли или политики:
[Authorize(Roles = "Admin")]
public async Task BroadcastAdminMessage(string message)
{
await Clients.All.SendAsync("AdminMessage", message);
}
Это гарантирует, что только пользователи с ролью Admin смогут вызвать этот метод.
Защита от атак
SignalR также предоставляет средства для защиты от распространённых атак:
- Cross-Site WebSocket Hijacking (CSWSH): поскольку WebSocket-соединение не отправляет Origin-заголовок, важно проверять источник запроса на этапе согласования. Это делается с помощью CORS-политик в ASP.NET Core.
- Отказ в обслуживании (DoS): можно ограничить количество одновременных подключений от одного IP-адреса с помощью middleware или инфраструктурных средств (например, Application Gateway в Azure).
- Подделка запросов: все вызовы методов хаба проходят через строгую типизацию и проверку параметров, что снижает риск инъекций.
Масштабирование: переход от одного сервера к кластеру
Приложение, работающее на одном сервере, может эффективно использовать встроенную память для хранения информации о подключениях. Однако при горизонтальном масштабировании возникает проблема: клиент A подключён к серверу 1, а клиент B — к серверу 2. Если сервер 1 захочет отправить сообщение всем клиентам, он не знает о существовании клиента B.
Для решения этой проблемы используется шина сообщений — backplane. Backplane — это внешнее хранилище, которое выступает в роли посредника между всеми экземплярами приложения. Когда любой сервер хочет отправить сообщение, он публикует его в backplane. Все остальные серверы подписываются на эту шину и, получив сообщение, пересылают его своим локальным клиентам.
Microsoft официально поддерживает три типа backplane для SignalR:
- Azure SignalR Service — это управляемый облачный сервис. Разработчик не управляет инфраструктурой, а просто добавляет одну строку кода
AddAzureSignalR(). Сервис берёт на себя всё: согласование, управление соединениями, масштабирование и отказоустойчивость. Это самый простой и надёжный способ для приложений, размещённых в Azure. - Redis — популярное in-memory хранилище, которое отлично подходит для передачи временных сообщений. Для его использования требуется настроить Redis-сервер и добавить пакет
Microsoft.AspNetCore.SignalR.StackExchangeRedis. SignalR использует Redis Pub/Sub для обмена сообщениями между серверами. - SQL Server — использует Service Broker SQL Server для передачи сообщений. Этот вариант подходит для организаций, уже имеющих развитую инфраструктуру на базе SQL Server, но он менее производителен, чем Redis.
Выбор backplane — это компромисс между простотой управления, производительностью и стоимостью. Для большинства новых проектов рекомендуется начинать с Azure SignalR Service или Redis.
Продвинутые сценарии: IHubContext и фоновые службы
Одна из самых мощных возможностей SignalR — это возможность инициировать рассылку сообщений не из хаба, а из любого места в приложении. Это достигается через интерфейс IHubContext<T>.
IHubContext<T> — это служба, которую можно внедрить через DI-контейнер. Она предоставляет тот же API, что и свойство Clients внутри хаба, но без привязки к конкретному клиентскому вызову.
Это открывает множество сценариев:
- Фоновые задачи: служба, выполняющаяся по расписанию, может уведомлять пользователей о завершении длительной операции.
- Обработка событий: при получении события из внешней системы (например, через очередь сообщений) можно мгновенно оповестить всех заинтересованных клиентов.
- Интеграция с бизнес-логикой: репозиторий или сервисный слой могут напрямую отправлять уведомления, не вовлекая контроллеры или хабы.
Пример использования в фоновой службе уже был приведён ранее. Важно понимать, что IHubContext — это «окно» в мир подключённых клиентов из любого уголка приложения.
SignalR в контексте слоистой архитектуры приложения
SignalR не должен становиться центром бизнес-логики. Его роль — транспортный слой для доставки событий. В правильно спроектированном приложении хаб выступает исключительно как адаптер между клиентским соединением и внутренними сервисами системы.
Типичная архитектура выглядит следующим образом:
- Клиент вызывает метод хаба, например,
SendMessage(message). - Хаб получает вызов, но не обрабатывает его самостоятельно. Он делегирует управление зарегистрированному сервису через внедрение зависимостей.
- Сервисный слой выполняет всю необходимую логику: валидацию, сохранение в базу данных, применение бизнес-правил.
- После завершения операции сервис может опубликовать событие (например, через MediatR или простой делегат).
- Хаб или фоновая служба, подписавшись на это событие, использует
IHubContextдля отправки уведомления всем заинтересованным клиентам.
Такой подход обеспечивает чёткое разделение ответственности:
- Хаб отвечает только за коммуникацию.
- Сервисы отвечают за логику.
- Модели данных и репозитории остаются независимыми от деталей передачи сообщений.
Пример реализации:
// Хаб
public class ChatHub : Hub
{
private readonly IChatService _chatService;
public ChatHub(IChatService chatService)
{
_chatService = chatService;
}
public async Task SendMessage(string message)
{
var userId = Context.UserIdentifier;
await _chatService.ProcessMessageAsync(userId, message);
}
}
// Сервис
public class ChatService : IChatService
{
private readonly IMessageRepository _repository;
private readonly IHubContext<ChatHub> _hubContext;
public ChatService(IMessageRepository repository, IHubContext<ChatHub> hubContext)
{
_repository = repository;
_hubContext = hubContext;
}
public async Task ProcessMessageAsync(string userId, string content)
{
// Валидация, преобразование, сохранение
var message = new Message { AuthorId = userId, Content = content, Timestamp = DateTime.UtcNow };
await _repository.SaveAsync(message);
// Рассылка события
await _hubContext.Clients.All.SendAsync("NewMessage", message.AuthorId, message.Content);
}
}
Эта модель позволяет легко тестировать бизнес-логику без запуска SignalR-соединений и упрощает поддержку кода.
Группы: динамическая организация клиентов
Группы в SignalR — это временные, неустойчивые коллекции подключений. Они не имеют встроенной персистентности и не связаны с понятием «пользователь» или «роль». Группа существует только до тех пор, пока в ней есть хотя бы одно активное подключение.
Управление группами полностью лежит на разработчике. Типичный сценарий — добавление пользователя в группу при входе в чат-комнату:
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("UserJoined", Context.UserIdentifier);
}
И удаление при выходе:
public async Task LeaveRoom(string roomName)
{
await Clients.Group(roomName).SendAsync("UserLeft", Context.UserIdentifier);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
}
Важно помнить: если клиент теряет соединение (например, из-за потери сети), он автоматически удаляется из всех групп. При восстановлении соединения он должен повторно присоединиться к нужным группам. Это требует от клиента реализации логики повторного подписания, обычно в обработчике события onreconnected.
Группы идеально подходят для:
- Чат-комнат
- Спортивных трансляций (группа по матчу)
- Аукционов (группа по лоту)
- Совместного редактирования документа
Они не подходят для долгосрочных подписок, таких как «получать все уведомления о заказах». Для таких случаев лучше использовать адресацию по UserId.
Обработка состояния подключения и жизненного цикла
Каждое подключение имеет уникальный идентификатор ConnectionId, который действителен только в течение одного сеанса. Этот идентификатор не следует путать с UserId — последний привязан к аутентифицированной личности и может быть связан с несколькими ConnectionId одновременно (например, пользователь зашёл с телефона и компьютера).
Хаб предоставляет два ключевых метода для управления жизненным циклом:
-
OnConnectedAsync()— вызывается сразу после установки соединения. Здесь можно:- Загрузить профиль пользователя
- Добавить его в глобальную группу «все пользователи»
- Отправить приветственное сообщение
- Зафиксировать факт входа в систему мониторинга
-
OnDisconnectedAsync(Exception exception)— вызывается при любом разрыве соединения. Параметрexceptionсодержит информацию о причине:null— клиент корректно закрыл соединение.- Объект исключения — произошла ошибка (таймаут, сетевой сбой и т.д.).
Эти методы позволяют поддерживать актуальное состояние онлайн-пользователей, управлять лицензиями (например, ограничение числа одновременных сессий) и выполнять очистку ресурсов.
Интеграция с фоновыми службами и внешними системами
Одна из самых сильных сторон SignalR — возможность интеграции с системами, которые не инициируют взаимодействие с пользователем. Например, служба обработки платежей, работающая в фоне, может уведомить веб-интерфейс о успешном завершении транзакции.
Для этого используется IHubContext<T>, внедряемый в фоновую службу:
public class PaymentNotificationService : BackgroundService
{
private readonly IHubContext<NotificationHub> _hubContext;
private readonly IPaymentQueue _paymentQueue;
public PaymentNotificationService(
IHubContext<NotificationHub> hubContext,
IPaymentQueue paymentQueue)
{
_hubContext = hubContext;
_paymentQueue = paymentQueue;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var payment in _paymentQueue.ReadAsync(ct))
{
await _hubContext.Clients.User(payment.UserId)
.SendAsync("PaymentCompleted", payment.Id, payment.Amount);
}
}
}
Такой подход превращает веб-приложение в реактивную систему, способную мгновенно реагировать на события из любого источника: очередей, баз данных, внешних API.
SignalR и реактивные паттерны: от событий к потокам
SignalR естественным образом ложится в парадигму реактивного программирования. Каждое входящее сообщение от клиента — это событие, на которое система может реагировать. Каждое исходящее сообщение — это реакция на внутреннее или внешнее изменение состояния.
В современных .NET-приложениях эту модель можно усилить с помощью библиотеки System.Reactive (Rx.NET). Хотя SignalR сам по себе не является реактивной библиотекой, его можно легко интегрировать с Rx-паттернами для создания сложных цепочек обработки событий.
Например, можно представить входящие сообщения от клиента как IObservable<T>, применить к ним операторы фильтрации, дебаунсинга, объединения с другими потоками, а затем отправить результат обратно через IHubContext. Это особенно полезно в сценариях высокой частоты событий, таких как совместное редактирование текста или отслеживание перемещений курсора.
Такой подход позволяет отделить:
- Источник событий (хаб, очередь, таймер)
- Логику преобразования (реактивные операторы)
- Потребителя (рассылка через SignalR)
Это повышает тестируемость и читаемость кода, особенно в сложных доменах.
Типизация и контракты: безопасность на уровне компиляции
Одна из сильных сторон SignalR в экосистеме .NET — это строгая типизация. Методы хаба определяются как обычные C#-методы с чёткими сигнатурами. Клиентские вызовы проверяются на соответствие этим сигнатурам.
Для JavaScript-клиента эта безопасность обеспечивается на уровне соглашений и документации. Однако для .NET-клиентов (включая Blazor и MAUI) можно использовать строго типизированные хабы.
Строго типизированный хаб определяется через интерфейс:
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
}
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string message)
{
await Clients.All.ReceiveMessage(Context.UserIdentifier, message);
}
}
Теперь свойство Clients.All имеет тип IChatClient, и любой вызов метода, не определённого в интерфейсе, приведёт к ошибке компиляции. Это предотвращает опечатки в именах методов и гарантирует согласованность между сервером и клиентом.
Аналогично, на стороне .NET-клиента можно определить интерфейс для методов, вызываемых на сервере:
public interface IChatServer
{
Task SendMessage(string message);
}
var connection = new HubConnectionBuilder()
.WithUrl("...")
.Build();
var hubProxy = connection.CreateHubProxy<IChatServer>();
await hubProxy.SendMessage("Hello");
Такой подход превращает взаимодействие с SignalR в полностью типобезопасный процесс, что особенно ценно в крупных проектах с долгим жизненным циклом.
Отладка и диагностика: инструменты для разработчика
Разработка приложений реального времени сопряжена с уникальными вызовами: подключения могут обрываться, сообщения теряться, клиенты — восстанавливать соединение в неожиданный момент. SignalR предоставляет несколько уровней диагностики.
Логирование
SignalR интегрирован с системой логирования ASP.NET Core. Включение подробного логирования позволяет отслеживать:
- Этапы согласования (
Negotiate) - Установление соединения (
Connected) - Вызовы методов (
Invoking hub method) - Отправку сообщений (
Sending message) - Разрывы соединения (
Disconnected)
Для включения достаточно настроить уровень логирования в appsettings.json:
{
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
}
}
Инструменты разработчика в браузере
JavaScript-клиент SignalR использует стандартный console для вывода диагностических сообщений. При включении логирования в конструкторе можно увидеть все этапы жизненного цикла соединения прямо в DevTools:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chat")
.configureLogging(signalR.LogLevel.Information)
.build();
Мониторинг в production
В рабочей среде важно отслеживать метрики:
- Количество активных подключений
- Частота сообщений
- Время отклика
- Ошибки подключения
Если используется Azure SignalR Service, все эти метрики доступны в портале Azure и могут быть интегрированы с Application Insights. Для self-hosted решений можно экспортировать метрики через IHubContext или использовать middleware для сбора статистики.
Практическое руководство: многопользовательский чат на SignalR
Ниже представлено пошаговое руководство по созданию полнофункционального чата с поддержкой комнат, истории сообщений, приватных сообщений и онлайн-статусов. Архитектура следует принципам разделения ответственности, масштабируемости и безопасности.
Шаг 1. Создание проекта и базовой структуры
Создайте новый проект ASP.NET Core:
dotnet new web -n ChatApp
cd ChatApp
Добавьте необходимые пакеты:
dotnet add package Microsoft.AspNetCore.SignalR
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.InMemory # для упрощённой демонстрации
Шаг 2. Модели данных
Определите основные сущности:
// Models/ChatMessage.cs
public class ChatMessage
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string? TargetUserId { get; set; } // null — публичное, не null — приватное
public string RoomName { get; set; } = "General";
}
// Models/UserStatus.cs
public class UserStatus
{
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public bool IsOnline { get; set; }
public DateTime LastSeen { get; set; }
}
Шаг 3. Хранилище (упрощённое)
Для демонстрации используем in-memory хранилище. В production замените на EF Core + SQL Server.
// Services/IChatStore.cs
public interface IChatStore
{
Task<List<ChatMessage>> GetMessagesAsync(string roomName, int limit = 50);
Task SaveMessageAsync(ChatMessage message);
Task<List<UserStatus>> GetOnlineUsersAsync();
Task SetUserOnlineAsync(string userId, string userName);
Task SetUserOfflineAsync(string userId);
}
// Services/InMemoryChatStore.cs
public class InMemoryChatStore : IChatStore
{
private readonly List<ChatMessage> _messages = new();
private readonly Dictionary<string, UserStatus> _userStatuses = new();
public async Task<List<ChatMessage>> GetMessagesAsync(string roomName, int limit = 50)
{
return _messages
.Where(m => m.RoomName == roomName)
.OrderByDescending(m => m.Timestamp)
.Take(limit)
.Reverse()
.ToList();
}
public async Task SaveMessage侃Async(ChatMessage message)
{
_messages.Add(message);
}
public async Task<List<UserStatus>> GetOnlineUsersAsync()
{
return _userStatuses.Values.Where(u => u.IsOnline).ToList();
}
public async Task SetUserOnlineAsync(string userId, string userName)
{
_userStatuses[userId] = new UserStatus
{
UserId = userId,
UserName = userName,
IsOnline = true,
LastSeen = DateTime.UtcNow
};
}
public async Task SetUserOfflineAsync(string userId)
{
if (_userStatuses.TryGetValue(userId, out var status))
{
status.IsOnline = false;
status.LastSeen = DateTime.UtcNow;
}
}
}
Шаг 4. Хаб чата
// Hubs/ChatHub.cs
using Microsoft.AspNetCore.SignalR;
public class ChatHub : Hub
{
private readonly IChatStore _chatStore;
public ChatHub(IChatStore chatStore)
{
_chatStore = chatStore;
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier ?? Context.ConnectionId;
var userName = Context.User?.Identity?.Name ?? "Anonymous";
await _chatStore.SetUserOnlineAsync(userId, userName);
await Clients.All.SendAsync("UserStatusChanged", new UserStatus
{
UserId = userId,
UserName = userName,
IsOnline = true,
LastSeen = DateTime.UtcNow
});
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier ?? Context.ConnectionId;
await _chatStore.SetUserOfflineAsync(userId);
await Clients.All.SendAsync("UserStatusChanged", new UserStatus
{
UserId = userId,
IsOnline = false,
LastSeen = DateTime.UtcNow
});
await base.OnDisconnectedAsync(exception);
}
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
var history = await _chatStore.GetMessagesAsync(roomName);
await Clients.Caller.SendAsync("ReceiveHistory", history);
}
public async Task LeaveRoom(string roomName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
}
public async Task SendMessage(string content, string roomName = "General")
{
var userId = Context.UserIdentifier ?? Context.ConnectionId;
var userName = Context.User?.Identity?.Name ?? "Anonymous";
var message = new ChatMessage
{
UserId = userId,
UserName = userName,
Content = content,
Timestamp = DateTime.UtcNow,
RoomName = roomName
};
await _chatStore.SaveMessageAsync(message);
await Clients.Group(roomName).SendAsync("ReceiveMessage", message);
}
public async Task SendPrivateMessage(string content, string targetUserId)
{
var userId = Context.UserIdentifier ?? Context.ConnectionId;
var userName = Context.User?.Identity?.Name ?? "Anonymous";
var message = new ChatMessage
{
UserId = userId,
UserName = userName,
Content = content,
Timestamp = DateTime.UtcNow,
TargetUserId = targetUserId
};
await _chatStore.SaveMessageAsync(message);
await Clients.User(targetUserId).SendAsync("ReceivePrivateMessage", message);
await Clients.Caller.SendAsync("ReceivePrivateMessage", message); // эхо
}
}
Шаг 5. Конфигурация приложения
// Program.cs
using ChatApp.Hubs;
using ChatApp.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatStore, InMemoryChatStore>();
builder.Services.AddSignalR();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapHub<ChatHub>("/chathub");
app.Run();
Шаг 6. Клиентская часть (JavaScript)
Создайте wwwroot/index.html с интерфейсом для комнат, истории, приватных сообщений и списка онлайн-пользователей. Реализация включает:
- Подключение к
/chathub - Вызов
JoinRoom("General")при старте - Обработка
ReceiveHistory,ReceiveMessage,ReceivePrivateMessage - Отображение
UserStatusChangedв списке пользователей - Форма отправки с выбором: публичное / приватное
Полный HTML/JS-код опущен для сохранения фокуса на архитектуре, но он напрямую следует из API хаба.
Интеграция SignalR с Blazor
Blazor предоставляет два режима: Server и WebAssembly. SignalR интегрируется с ними по-разному.
Blazor Server: SignalR как транспорт
Blazor Server уже использует SignalR для двусторонней связи между браузером и сервером. Каждый компонент работает на сервере, а UI-изменения передаются через SignalR-соединение.
Важно: вы не создаёте отдельный хаб для Blazor Server. Вместо этого:
- Используйте
IHubContext<T>для отправки событий внутрь Blazor-компонента. - Подпишите компонент на события через
OnInitializedAsync.
Пример:
@page "/chat"
@inject IHubContext<ChatHub> HubContext
@implements IDisposable
<ul>
@foreach (var msg in messages)
{
<li>@msg.UserName: @msg.Content</li>
}
</ul>
@code {
private List<ChatMessage> messages = new();
private IDisposable? subscription;
protected override async Task OnInitializedAsync()
{
// Подписка на события из внешнего хаба
subscription = HubContext.On<ChatMessage>("ReceiveMessage", msg =>
{
messages.Add(msg);
InvokeAsync(StateHasChanged);
});
}
public void Dispose()
{
subscription?.Dispose();
}
}
Blazor WebAssembly: клиентский SignalR
Blazor WebAssembly работает в браузере. Для взаимодействия с SignalR-сервером используется JavaScript-клиент или нативный .NET-клиент.
Вариант A: Использование HubConnection напрямую
@page "/chat"
@using Microsoft.AspNetCore.SignalR.Client
<ul>
@foreach (var msg in messages) { <li>@msg</li> }
</ul>
@code {
private HubConnection? connection;
private List<string> messages = new();
protected override async Task OnInitializedAsync()
{
connection = new HubConnectionBuilder()
.WithUrl(NavigationManager.ToAbsoluteUri("/chathub"))
.Build();
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
messages.Add($"{user}: {message}");
InvokeAsync(StateHasChanged);
});
await connection.StartAsync();
}
public async ValueTask DisposeAsync()
{
if (connection is not null)
{
await connection.DisposeAsync();
}
}
}
Вариант B: Инкапсуляция в сервис
Создайте ChatService, внедрите его через DI, и используйте в компонентах. Это предпочтительный подход для сложных приложений.