SignalR Realtime — Архитектура и Практическое Руководство
Назначение
Документ описывает архитектуру realtime push-уведомлений в Pin через SignalR в условиях multi-replica Kubernetes deployment. Описывает выбор backplane, пакеты, интерфейсы и паттерны публикации уведомлений из background workers и других микросервисов.
Проблема: Multi-Replica Kubernetes
В k8s у каждого сервиса может быть несколько pod-ов (replicas). SignalR Connection хранится только на одном pod-е, к которому подключён браузер игрока. Если уведомление о пополнении баланса приходит в другой pod — оно потеряется.
Browser ──────WebSocket──── pod-1 (has connection)
pod-2 (NO connection — message lost without backplane)
pod-3 (NO connection)
Решение: Redis Backplane
Browser ──WebSocket── pod-1 ──publish──► Redis PubSub
pod-2 ──subscribe──► Redis PubSub ──► broadcast к своим connections
pod-3 ──subscribe──► Redis PubSub
AddSignalR().AddStackExchangeRedis(connectionString) — все pod-ы подписываются на Redis channel; при публикации сообщение доставляется всем.
Пакеты
src/building-blocks/
Talent.Common.Realtime.Core/ ← IRealtimeNotificationPublisher, message contracts
Talent.Common.Realtime.Hub/ ← PinHubBase<T>, backplane setup, DI extensions
Talent.Common.Realtime.Client/ ← IRealtimeProducer для worker/other-service → MQ
Talent.Common.Realtime.Core
NuGet dependencies: только BuildingBlocks.Application (нет ASP.NET, нет Redis прямых зависимостей).
Talent.Common.Realtime.Core/
Contracts/
IRealtimeNotificationPublisher.cs — отправить уведомление игроку/партнёру по ID
IRealtimeGroupPublisher.cs — отправить в группу (tenant, brand)
RealtimeMessage.cs — typed message contract
RealtimeChannel.cs — константы channel names
Messages/
BalanceUpdatedMessage.cs
WithdrawalStatusChangedMessage.cs
IncentiveActivatedMessage.cs
CompetitionLeaderboardUpdatedMessage.cs
SystemNotificationMessage.cs
Talent.Common.Realtime.Hub
NuGet dependencies: Microsoft.AspNetCore.SignalR, Microsoft.AspNetCore.SignalR.StackExchangeRedis, Realtime.Core.
Talent.Common.Realtime.Hub/
PinHubBase.cs — base Hub с auth, tenant scope, rate limit
SignalRNotificationPublisher.cs — реализация IRealtimeNotificationPublisher через IHubContext
BackplaneOptions.cs
DependencyInjection/
AddPinSignalR.cs — регистрация + Redis backplane
AddPinHubs.cs
Talent.Common.Realtime.Client
NuGet dependencies: MassTransit (или только StackExchange.Redis), Realtime.Core.
Используется в background workers и других сервисах, у которых нет SignalR Hub.
Talent.Common.Realtime.Client/
MqRealtimeProducer.cs — публикует RealtimeEnvelope в Redis PubSub / RabbitMQ
IRealtimeProducer.cs
DependencyInjection/
AddPinRealtimeClient.cs
Интерфейсы
IRealtimeNotificationPublisher
// Talent.Common.Realtime.Core/Contracts/IRealtimeNotificationPublisher.cs
/// <summary>
/// Публикует realtime push-сообщение конкретному пользователю или группе.
/// Используется только в сервисах, у которых есть SignalR Hub (т.е. в public-api / user-api).
/// Для других сервисов и background workers — используй <see cref="IRealtimeProducer"/>.
/// </summary>
public interface IRealtimeNotificationPublisher
{
/// <summary>Отправить конкретному игроку по UserId (на все его devices).</summary>
Task SendToUser<TMessage>(
UserId userId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class;
/// <summary>Отправить всем подключённым игрокам данного бренда.</summary>
Task SendToBrand<TMessage>(
BrandId brandId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class;
/// <summary>Отправить конкретному admin/partner пользователю.</summary>
Task SendToUser<TMessage>(
string userId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class;
}
IRealtimeProducer (для worker / другой сервис → MQ → Hub)
// Talent.Common.Realtime.Client/IRealtimeProducer.cs
/// <summary>
/// Отправляет realtime-уведомление через MQ (Redis PubSub или RabbitMQ).
/// Используется в background workers и микросервисах без SignalR Hub.
/// User-api / public-api подписывается на MQ и доставляет через Hub.
/// </summary>
public interface IRealtimeProducer
{
Task NotifyUser<TMessage>(
UserId userId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class;
Task NotifyBrand<TMessage>(
BrandId brandId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class;
}
PinHubBase — Базовый Hub
// Talent.Common.Realtime.Hub/PinHubBase.cs
/// <summary>
/// Базовый SignalR Hub. Обеспечивает:
/// - JWT-аутентификацию и извлечение UserId/BrandId из claims.
/// - Автоматическое добавление в группы при Connect (user-{id}, brand-{id}, tenant-{id}).
/// - Rate limiting подключений.
/// - Structured logging connect/disconnect.
/// </summary>
public abstract class PinHubBase<T> : Hub<T>
where T : class
{
private readonly IIdentityContextProvider _identityProvider;
private readonly ILogger _logger;
public override async Task OnConnectedAsync()
{
var identity = _identityProvider.TryGetCurrent();
if (identity is null)
{
Context.Abort();
return;
}
// Добавляем в typed groups для таргетированной отправки
if (identity.ActorType == ActorType.User && identity.ActorId is not null)
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{identity.ActorId}");
if (identity.BrandId is not null)
await Groups.AddToGroupAsync(Context.ConnectionId, $"brand:{identity.BrandId}");
if (identity.TenantId is not null)
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{identity.TenantId}");
_logger.LogInformation(
"SignalR connected actorType={ActorType} actorId={ActorId} connectionId={ConnectionId}",
identity.ActorType, identity.ActorId, Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation(
"SignalR disconnected connectionId={ConnectionId} error={Error}",
Context.ConnectionId, exception?.Message);
await base.OnDisconnectedAsync(exception);
}
}
// Конкретный Hub в user-api:
public sealed class UserNotificationHub : PinHubBase<IUserNotificationClient>
{
public UserNotificationHub(
IIdentityContextProvider identityProvider,
ILogger<UserNotificationHub> logger)
: base(identityProvider, logger) { }
}
// Typed client interface (методы, которые браузер может получить):
public interface IUserNotificationClient
{
Task BalanceUpdated(BalanceUpdatedMessage message);
Task WithdrawalStatusChanged(WithdrawalStatusChangedMessage message);
Task IncentiveActivated(IncentiveActivatedMessage message);
Task SystemNotification(SystemNotificationMessage message);
}
Регистрация в DI
// user-api / Pin.User.Api/Startup/UserWebStartup.cs
protected override void ConfigureSignalR(IPinApiBuilder builder)
{
builder.AddPinSignalR(options =>
{
// Redis backplane — обязателен в k8s multi-replica
options.UseRedisBackplane(
connectionString: Configuration.GetConnectionString("Redis")!);
// Настройки Hub
options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
builder.AddHub<UserNotificationHub>("/hubs/user");
}
Typed Message Contracts
// Talent.Common.Realtime.Core/Messages/
public sealed record BalanceUpdatedMessage
{
public required string AccountingAccountId { get; init; }
public required decimal NewBalance { get; init; }
public required short CurrencyId { get; init; } // ссылка на справочник handbook
public required string CurrencyCode { get; init; } // display only (из CurrencySnapshot)
public required string ChangeReason { get; init; } // "deposit", "withdrawal", "incentive", "bet"
public required DateTimeOffset UpdatedAt { get; init; }
}
public sealed record WithdrawalStatusChangedMessage
{
public required string WithdrawalId { get; init; }
public required string NewStatus { get; init; }
public required string? ReasonCode { get; init; }
public required DateTimeOffset ChangedAt { get; init; }
}
public sealed record IncentiveActivatedMessage
{
public required string UserIncentiveId { get; init; }
public required string IncentiveName { get; init; }
public required decimal IncentiveAmount { get; init; }
public required short CurrencyId { get; init; } // ссылка на справочник handbook
public required decimal WagerRequired { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
public sealed record SystemNotificationMessage
{
public required string NotificationId { get; init; }
public required string Category { get; init; } // "info", "warning", "promo"
public required string TitleKey { get; init; } // i18n key
public required string BodyKey { get; init; }
public Dictionary<string, string> Params { get; init; } = [];
public DateTimeOffset? ExpiresAt { get; init; }
}
// Константы channel names — избегаем magic strings
public static class RealtimeEvents
{
public const string BalanceUpdated = "balance_updated";
public const string WithdrawalStatusChanged = "withdrawal_status_changed";
public const string IncentiveActivated = "incentive_activated";
public const string IncentiveExpiringSoon = "incentive_expiring_soon";
public const string CompetitionLeaderboard = "competition_leaderboard_updated";
public const string SystemNotification = "system_notification";
public const string GameSessionExpired = "game_session_expired";
}
Поток: Background Worker → SignalR Push
Пример: accounting-worker завершает депозит и уведомляет игрока через user-api.
accounting-worker
→ DepositCallbackConsumer.Handle()
→ _depositService.CompleteDeposit(...) // обновляет balance
→ _realtimeProducer.NotifyUser( // публикует в Redis PubSub
userId,
RealtimeEvents.BalanceUpdated,
new BalanceUpdatedMessage { ... })
Redis PubSub channel: "Pin:realtime:user:{userId}"
↓ (все pod-ы user-api подписаны)
user-api pod-1 (has connection for userId)
→ RealtimeNotificationRelay (IHostedService)
→ _hubContext.Clients.Group($"user:{userId}")
.SendAsync(RealtimeEvents.BalanceUpdated, message)
Browser WebSocket → BalanceUpdated event
MqRealtimeProducer Implementation
// Talent.Common.Realtime.Client/MqRealtimeProducer.cs
public sealed class MqRealtimeProducer : IRealtimeProducer
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<MqRealtimeProducer> _logger;
public async Task NotifyUser<TMessage>(
UserId userId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class
{
var channel = $"Pin:realtime:user:{userId.Value}";
var envelope = JsonSerializer.Serialize(new RealtimeEnvelope
{
Channel = channel,
EventName = eventName,
Target = RealtimeTarget.User,
TargetId = userId.Value.ToString(),
Payload = JsonSerializer.SerializeToElement(message),
});
await _redis.GetSubscriber()
.PublishAsync(RedisChannel.Literal(channel), envelope);
_logger.LogDebug(
"RealtimeProducer published {EventName} to user {UserId}",
eventName, userId.Value);
}
public async Task NotifyBrand<TMessage>(
BrandId brandId,
string eventName,
TMessage message,
CancellationToken ct = default)
where TMessage : class
{
var channel = $"Pin:realtime:brand:{brandId.Value}";
var envelope = JsonSerializer.Serialize(new RealtimeEnvelope
{
Channel = channel,
EventName = eventName,
Target = RealtimeTarget.Brand,
TargetId = brandId.Value.ToString(),
Payload = JsonSerializer.SerializeToElement(message),
});
await _redis.GetSubscriber()
.PublishAsync(RedisChannel.Literal(channel), envelope);
}
}
public sealed record RealtimeEnvelope
{
public required string Channel { get; init; }
public required string EventName { get; init; }
public required RealtimeTarget Target { get; init; }
public required string TargetId { get; init; }
public required JsonElement Payload { get; init; }
}
public enum RealtimeTarget { User, Brand, Tenant, User }
RealtimeNotificationRelay (HostedService в user-api)
// Pin.User.Api/Realtime/RealtimeNotificationRelay.cs
public sealed class RealtimeNotificationRelay : BackgroundService
{
private readonly IConnectionMultiplexer _redis;
private readonly IHubContext<UserNotificationHub, IUserNotificationClient> _hubContext;
private readonly ILogger<RealtimeNotificationRelay> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var sub = _redis.GetSubscriber();
// Подписываемся на все user и brand channels через pattern
await sub.SubscribeAsync(
RedisChannel.Pattern("Pin:realtime:*"),
async (channel, value) =>
{
try
{
var envelope = JsonSerializer.Deserialize<RealtimeEnvelope>(value.ToString());
if (envelope is null) return;
await DispatchToHub(envelope, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error dispatching realtime message on channel {Channel}", channel);
}
});
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private async Task DispatchToHub(RealtimeEnvelope envelope, CancellationToken ct)
{
switch (envelope.Target)
{
case RealtimeTarget.User:
await _hubContext.Clients
.Group($"user:{envelope.TargetId}")
.SendCoreAsync(envelope.EventName, [envelope.Payload], ct);
break;
case RealtimeTarget.Brand:
await _hubContext.Clients
.Group($"brand:{envelope.TargetId}")
.SendCoreAsync(envelope.EventName, [envelope.Payload], ct);
break;
}
}
}
Правила
- Redis backplane обязателен в staging и production (k8s multi-replica).
- Локально (Docker Compose, один pod) backplane опционален — конфигурируется через
REALTIME_BACKPLANE_ENABLED. - Каждое Hub-сообщение содержит только безопасные, non-PII данные (Id, статус, сумма).
- Realtime не является source of truth — клиент должен перезапросить REST API при reconnect.
IRealtimeProducerиспользуется в worker/background;IRealtimeNotificationPublisher— только в сервисе с Hub.- Rate limit: не более 10 push-сообщений в секунду на одного игрока.
- Все push проходят через
IRealtimeProducerилиIRealtimeNotificationPublisher— прямое использованиеIHubContextв бизнес-коде запрещено. - Тип сообщения (eventName) —
snake_caseконстанта изRealtimeEvents.
Ссылки
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен)
- API (5): POST /api/messenger/calls — Начать звонок, POST /api/messenger/calls/{callId}/answer — Принять звонок, POST /api/messenger/calls/{callId}/decline — Отклонить звонок, POST /api/messenger/calls/{callId}/end — Завершить звонок (hangup, call-лог), POST /api/messenger/calls/{callId}/media-token — Обновить токен входа в звонок
- adr (1): ADR-0043: SignalR Realtime и Redis Backplane
- index.md (1): Backend SDK — Руководство Разработчика
- backend-shared-standards-index.md (1): Backend Shared Standards Index