你有没有做过这样的需求:"后台数据一变,前端页面要立刻更新。"
最初的方案通常是前端 setInterval 每 5 秒轮询一次。简单粗暴,但服务器要承受大量无效请求,数据也不够"实时"。于是你开始研究 WebSocket,自己实现连接管理、心跳保活、断线重连、消息序列化、跨实例广播......写着写着发现,你已经在手写一个消息框架了。
SignalR 就是微软帮你写好的这个框架。它抽象了传输层(自动在 WebSocket、Server-Sent Events、Long Polling 之间协商降级),提供了 Hub 编程模型、自动重连、流式传输、横向扩展等能力。
本文从 SignalR 的核心概念讲起,覆盖 Hub 设计、类型安全客户端、流式传输、断线重连、Redis 横向扩展,以及在 PMS 船岸卫星网络中使用 SignalR 的真实经验和坑点。
一、SignalR 解决了什么问题
1.1 实时通信的演进
方案 实时性 服务器开销 实现复杂度 双向通信
─────────────────────────────────────────────────────────────
短轮询 (Polling) 差(秒级) 极高 低 否
长轮询 (Long Poll) 中 高 中 半双工
SSE 好 中 中 单向(推)
WebSocket 极好(ms级) 低 高(自建) 全双工
SignalR 极好 低 低 全双工
SignalR 的核心价值:在保持 WebSocket 高性能的同时,把连接管理、传输协商、序列化这些基础设施全部封装好了。
1.2 PMS 中的实时场景
csharp
// PMS 系统中需要实时通信的场景
public enum RealtimeScenario
{
// 岸端:船舶位置实时更新(地图上船舶图标移动)
ShipPositionTracking,
// 岸端:审批通知推送到审批人桌面
ApprovalNotification,
// 船端:同步进度实时显示(进度条)
SyncProgress,
// 岸端:设备报警实时弹窗
DeviceAlarm,
// 船岸:即时消息(船员和岸基人员沟通)
InstantMessaging,
// 大屏:全局运营数据仪表盘
Dashboard
}
二、Hub 设计:服务端核心
2.1 定义 Hub
csharp
// 审批通知 Hub
public class NotificationHub : Hub
{
private readonly ILogger<NotificationHub> _logger;
public NotificationHub(ILogger<NotificationHub> logger)
{
_logger = logger;
}
// 客户端连接时
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
var shipId = Context.User?.FindFirst("ship_id")?.Value;
// 加入用户组(一个用户可能有多个连接)
if (!string.IsNullOrEmpty(userId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
}
// 加入船舶组
if (!string.IsNullOrEmpty(shipId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"ship:{shipId}");
}
_logger.LogInformation(
"SignalR 连接建立: ConnectionId={ConnId}, UserId={UserId}, ShipId={ShipId}",
Context.ConnectionId, userId, shipId);
await base.OnConnectedAsync();
}
// 客户端断开时
public override async Task OnDisconnectedAsync(Exception exception)
{
_logger.LogInformation(
"SignalR 连接断开: ConnectionId={ConnId}, Reason={Reason}",
Context.ConnectionId, exception?.Message ?? "正常断开");
await base.OnDisconnectedAsync(exception);
}
// 客户端可以调用的方法:加入特定审批组
public async Task SubscribeToApprovals(string department)
{
await Groups.AddToGroupAsync(
Context.ConnectionId, $"approvals:{department}");
}
public async Task UnsubscribeFromApprovals(string department)
{
await Groups.AddToGroupAsync(
Context.ConnectionId, $"approvals:{department}");
}
}
2.2 注册与配置
csharp
// Program.cs
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
// 消息大小上限
options.MaximumReceiveMessageSize = 64 * 1024; // 64KB
// 心跳:每 15 秒发一次 Ping
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
// 客户端必须在 30 秒内发送任何消息(包括 Ping 回应)
// 否则判定为超时断开
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
// 握手超时
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
})
.AddJsonProtocol(options =>
{
// 统一 JSON 序列化配置
options.PayloadSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.CamelCase;
options.PayloadSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
})
.AddStackExchangeRedis(options =>
{
// Redis Backplane:多实例间消息同步
options.Configuration.Configuration =
builder.Configuration.GetConnectionString("Redis");
options.Configuration.ChannelPrefix = "PmsSignalR";
});
// 配置端点
app.MapHub<NotificationHub>("/hubs/notifications", options =>
{
// 可以在这里配置传输方式
options.Transports =
HttpTransportType.WebSockets |
HttpTransportType.ServerSentEvents |
HttpTransportType.LongPolling;
});
2.3 类型安全的 Hub
直接用字符串调用客户端方法容易出错(拼错方法名、参数类型不匹配)。使用 Hub<T> 实现类型安全:
csharp
// 定义客户端方法接口
public interface INotificationClient
{
Task ReceiveNotification(NotificationMessage message);
Task SyncProgressChanged(SyncProgress progress);
Task ShipPositionUpdated(ShipPosition position);
Task DeviceAlarmTriggered(DeviceAlarm alarm);
Task ForceLogout(string reason);
}
// Hub 继承 Hub<T>
public class NotificationHub : Hub<INotificationClient>
{
// 服务端可以强类型调用客户端方法
public async Task SendNotificationToUser(string userId,
NotificationMessage message)
{
await Clients.User(userId)
.ReceiveNotification(message);
}
}
三、从服务端主动推送:IHubContext
Hub 实例只在连接期间存在,不能在 Hub 外部直接调用。要在控制器、后台服务中推送消息,使用 IHubContext<T>:
3.1 从 Controller 推送
csharp
[ApiController]
[Route("api/[controller]")]
public class RequisitionsController : ControllerBase
{
private readonly IHubContext<NotificationHub, INotificationClient> _hub;
[HttpPost("{id}/approve")]
public async Task<IActionResult> Approve(
Guid id, [FromBody] ApproveDto dto)
{
await _service.ApproveAsync(id, dto);
// ✅ 审批完成后实时通知申请人
var notification = new NotificationMessage
{
Type = "ApprovalResult",
Title = "审批结果通知",
Content = $"您的申领单已批准",
Timestamp = DateTime.UtcNow
};
await _hub.Clients
.User(dto.ApplicantId)
.ReceiveNotification(notification);
return Ok();
}
}
3.2 从后台服务推送
csharp
// 船舶位置后台推送服务
public class ShipPositionBroadcastService : BackgroundService
{
private readonly IHubContext<NotificationHub, INotificationClient> _hub;
private readonly IShipPositionService _positionService;
private readonly ILogger<ShipPositionBroadcastService> _logger;
public ShipPositionBroadcastService(
IHubContext<NotificationHub, INotificationClient> hub,
IShipPositionService positionService,
ILogger<ShipPositionBroadcastService> logger)
{
_hub = hub;
_positionService = positionService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
try
{
var positions = await _positionService
.GetActivePositionsAsync(ct);
// 广播到所有订阅了位置更新的客户端
foreach (var pos in positions)
{
await _hub.Clients
.Group($"ship:{pos.ShipId}")
.ShipPositionUpdated(pos);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "推送船舶位置失败");
}
}
}
}
3.3 推送模型选择
csharp
// 1. 发给所有连接的客户端
_hub.Clients.All.ReceiveNotification(msg);
// 2. 发给指定连接
_hub.Clients.Client(connectionId).ReceiveNotification(msg);
// 3. 发给指定用户(一个用户可能有多连接)
_hub.Clients.User(userId).ReceiveNotification(msg);
// 4. 发给指定组
_hub.Clients.Group("approvals:engineering").ReceiveNotification(msg);
// 5. 发给多个组
_hub.Clients.Groups(groupList).ReceiveNotification(msg);
// 6. 排除某些连接
_hub.Clients.AllExcept(excludedConnectionIds).ReceiveNotification(msg);
// 7. 只发给调用者(在 Hub 内部)
await Clients.Caller.ReceiveNotification(msg);
// 8. 发给同一用户的其他连接(不发给自己)
await Clients.OthersInGroup($"user:{userId}").ReceiveNotification(msg);
四、.NET 客户端:WPF/WinForms/控制台
4.1 基本用法
csharp
// 安装:Microsoft.AspNetCore.SignalR.Client
var connection = new HubConnectionBuilder()
.WithUrl("https://shore.pms-api.com/hubs/notifications", options =>
{
// 携带 JWT Token
options.AccessTokenProvider = async () =>
{
var token = await _authService.GetValidTokenAsync();
return token;
};
// 优先 WebSocket,自动降级
options.Transports = HttpTransportType.WebSockets
| HttpTransportType.ServerSentEvents;
// 卫星网络配置
options.CloseTimeout = TimeSpan.FromSeconds(30);
})
.WithAutomaticReconnect(new[]
{
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(60)
})
.ConfigureLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Information);
})
.Build();
// 注册服务端推送的处理器
connection.On<NotificationMessage>("ReceiveNotification", msg =>
{
Dispatcher.Invoke(() =>
{
// WPF 中切回 UI 线程
NotificationWindow.Show(msg);
});
});
connection.On<SyncProgress>("SyncProgressChanged", progress =>
{
Dispatcher.Invoke(() =>
{
ProgressBar.Value = progress.Percentage;
StatusText.Text = progress.Status;
});
});
// 启动连接
await connection.StartAsync();
4.2 强类型客户端
csharp
// 定义服务端接口(客户端可以调用的 Hub 方法)
public interface INotificationHub
{
Task SubscribeToApprovals(string department);
Task UnsubscribeFromApprovals(string department);
}
// 使用强类型 HubConnection
var hub = connection.CreateHubProxy<INotificationHub>();
// 编译时检查方法名和参数
await hub.SubscribeToApprovals("engineering");
4.3 连接生命周期管理
csharp
public class SignalRConnectionManager : IAsyncDisposable
{
private HubConnection _connection;
private readonly IAuthService _auth;
private readonly ILogger<SignalRConnectionManager> _logger;
public event Action<NotificationMessage> OnNotification;
public event Action<SyncProgress> OnSyncProgress;
public event Action<ConnectionState> StateChanged;
public ConnectionState State { get; private set; }
public async Task StartAsync(CancellationToken ct = default)
{
_connection = BuildConnection();
// 注册重连事件
_connection.Reconnecting += OnReconnecting;
_connection.Reconnected += OnReconnected;
_connection.Closed += OnClosed;
await _connection.StartAsync(ct);
State = ConnectionState.Connected;
StateChanged?.Invoke(State);
}
private HubConnection BuildConnection()
{
return new HubConnectionBuilder()
.WithUrl("https://shore.pms-api.com/hubs/notifications",
options =>
{
options.AccessTokenProvider = async () =>
await _auth.GetValidTokenAsync();
})
.WithAutomaticReconnect(new PmsRetryPolicy())
.Build();
}
private Task OnReconnecting(Exception ex)
{
State = ConnectionState.Reconnecting;
StateChanged?.Invoke(State);
_logger.LogWarning(ex, "SignalR 正在重连...");
return Task.CompletedTask;
}
private Task OnReconnected(string connectionId)
{
State = ConnectionState.Connected;
StateChanged?.Invoke(State);
_logger.LogInformation("SignalR 重连成功: {ConnId}", connectionId);
return Task.CompletedTask;
}
private async Task OnClosed(Exception ex)
{
if (ex != null)
{
State = ConnectionState.Disconnected;
StateChanged?.Invoke(State);
_logger.LogError(ex, "SignalR 连接异常关闭");
// 自动重启(WithAutomaticReconnect 只重连有限次)
// 完全断开后手动重启
await Task.Delay(TimeSpan.FromSeconds(30));
await StartAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_connection != null)
{
await _connection.DisposeAsync();
}
}
}
// 自定义重连策略
public class PmsRetryPolicy : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
// 卫星网络:指数退避,最大 5 分钟
var delay = retryContext.PreviousRetryCount switch
{
0 => TimeSpan.FromSeconds(5),
1 => TimeSpan.FromSeconds(15),
2 => TimeSpan.FromSeconds(30),
3 => TimeSpan.FromMinutes(1),
4 => TimeSpan.FromMinutes(3),
_ => TimeSpan.FromMinutes(5)
};
// 加随机抖动,避免重连风暴
var jitter = Random.Shared.Next(0, 5000);
return delay.Add(TimeSpan.FromMilliseconds(jitter));
}
}
public enum ConnectionState
{
Disconnected,
Connected,
Reconnecting
}
五、流式传输:大数据量实时返回
普通 Hub 方法只能返回一个完整结果。对于大数据量(如报表导出、日志实时读取),流式传输更合适。
5.1 服务端流式(Server → Client)
csharp
public class SyncHub : Hub
{
// 服务端流:逐块返回数据
public async IAsyncEnumerable<SyncChunk> StreamSyncData(
string shipId,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var chunk in _syncService.ReadChunksAsync(shipId, ct))
{
yield return chunk; // 逐块推送给客户端
}
}
// 服务端流:实时日志
public async IAsyncEnumerable<string> StreamLogs(
string level,
[EnumeratorCancellation] CancellationToken ct)
{
using var reader = _logService.Subscribe(level);
while (!ct.IsCancellationRequested)
{
if (reader.TryRead(out var logLine))
{
yield return logLine;
}
else
{
await Task.Delay(100, ct);
}
}
}
}
5.2 客户端调用流式方法
csharp
// .NET 客户端
var stream = connection.StreamAsync<SyncChunk>(
"StreamSyncData", "SHIP001");
await foreach (var chunk in stream)
{
ProgressBar.Value = chunk.Progress;
StatusText.Text = $"正在接收: {chunk.CurrentTable}";
}
5.3 客户端流式(Client → Server)
csharp
// 客户端逐块上传数据
public async Task UploadLogs(IAsyncEnumerable<LogEntry> logs)
{
await foreach (var log in logs)
{
await _logService.IngestAsync(log);
}
}
// 客户端
var channel = Channel.CreateBounded<LogEntry>(100);
_ = connection.SendAsync("UploadLogs", channel.Reader);
// 逐块写入
for (int i = 0; i < 10000; i++)
{
await channel.Writer.WriteAsync(new LogEntry { ... });
}
channel.Writer.Complete();
六、JavaScript/TypeScript 客户端
6.1 基本配置
typescript
import * as signalR from "@microsoft/signalr";
class NotificationService {
private connection: signalR.HubConnection;
constructor(token: string) {
this.connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", {
accessTokenFactory: () => token,
transport:
signalR.HttpTransportType.WebSockets |
signalR.HttpTransportType.ServerSentEvents,
skipNegotiation: false,
})
.withAutomaticReconnect([2000, 5000, 10000, 30000, 60000])
.configureLogging(signalR.LogLevel.Information)
.build();
this.registerHandlers();
}
private registerHandlers() {
this.connection.on("ReceiveNotification", (msg: NotificationMessage) => {
this.showToast(msg);
});
this.connection.on("SyncProgressChanged", (progress: SyncProgress) => {
this.updateProgressBar(progress);
});
this.connection.onreconnecting((error) => {
console.warn("SignalR 重连中...", error);
this.updateConnectionStatus("reconnecting");
});
this.connection.onreconnected((connectionId) => {
console.log("SignalR 重连成功:", connectionId);
this.updateConnectionStatus("connected");
this.resubscribeGroups();
});
this.connection.onclose((error) => {
console.error("SignalR 连接关闭:", error);
this.updateConnectionStatus("disconnected");
});
}
async start() {
try {
await this.connection.start();
console.log("SignalR 已连接");
} catch (err) {
console.error("SignalR 连接失败:", err);
// 5 秒后重试
setTimeout(() => this.start(), 5000);
}
}
async subscribeToApprovals(department: string) {
await this.connection.invoke("SubscribeToApprovals", department);
}
private showToast(msg: NotificationMessage) {
// 使用 Element Plus / Ant Design 等组件库弹出通知
ElNotification({
title: msg.title,
message: msg.content,
type: msg.type === "error" ? "error" : "success",
duration: 5000,
});
}
}
6.2 Vue 3 Composable 封装
typescript
// composables/useSignalR.ts
export function useSignalR() {
const connectionState = ref<"disconnected" | "connecting" | "connected">(
"disconnected"
);
const notifications = ref<NotificationMessage[]>([]);
let connection: signalR.HubConnection | null = null;
const connect = async (token: string) => {
connectionState.value = "connecting";
connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", { accessTokenFactory: () => token })
.withAutomaticReconnect()
.build();
connection.on("ReceiveNotification", (msg: NotificationMessage) => {
notifications.value.unshift(msg);
if (notifications.value.length > 50) notifications.value.pop();
});
connection.onreconnected(() => {
connectionState.value = "connected";
});
await connection.start();
connectionState.value = "connected";
};
const disconnect = async () => {
await connection?.stop();
connection = null;
connectionState.value = "disconnected";
};
onUnmounted(() => {
disconnect();
});
return { connectionState, notifications, connect, disconnect };
}
七、横向扩展:Redis Backplane
7.1 为什么需要 Backplane
当应用部署多个实例时,SignalR 连接分散在不同服务器上:
┌─────────────┐
Client A → │ Server 1 │
Client B → │ │
└─────────────┘
┌─────────────┐
Client C → │ Server 2 │
Client D → │ │
└─────────────┘
↑
如果 Server 1 要给
Client C 发消息?
Redis Backplane 让所有 SignalR 实例通过 Redis Pub/Sub 同步消息:
┌─────────────┐
Client A → │ Server 1 │
Client B → │ ↕ Pub/Sub│
└──────┬──────┘
│ Redis
┌──────┴──────┐
Client C → │ Server 2 │
Client D → │ ↕ Pub/Sub│
└─────────────┘
7.2 配置
csharp
builder.Services.AddSignalR()
.AddStackExchangeRedis(options =>
{
options.Configuration = new ConfigurationOptions
{
EndPoints = { "redis:6379" },
Password = builder.Configuration["Redis:Password"],
ConnectTimeout = 5000,
SyncTimeout = 5000,
KeepAlive = 60
};
options.Configuration.ChannelPrefix = RedisChannel.Literal("PmsSignalR");
});
7.3 注意事项
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 消息延迟 | Redis Pub/Sub 是异步的,增加 ~1ms | 可接受 |
| 消息丢失 | Redis Pub/Sub 不持久化,订阅者断线期间消息丢失 | 关键消息用持久化队列兜底 |
| 大消息 | Redis Pub/Sub 不适合大消息 | 消息 >1KB 时只发通知,数据走 HTTP 拉取 |
| 连接数 | Redis 连接数 = 实例数 × 2(订阅+发布) | 配置连接池 |
7.4 云端扩展方案
除了 Redis Backplane,Azure SignalR Service 和 AWS Managed Streaming for SignalR 提供了完全托管的横向扩展:
csharp
// Azure SignalR Service
builder.Services.AddSignalR()
.AddAzureSignalR(builder.Configuration["Azure:SignalR:ConnectionString"]);
八、卫星网络实战:PMS 船岸实时通信
8.1 卫星网络对 SignalR 的挑战
卫星网络特征:
RTT: 600ms - 2000ms(地面网络通常 <50ms)
丢包率: 5% - 20%
带宽: 128kbps - 2Mbps
连接中断: 频繁(船舶转向、天气、切换卫星)
费用: 按流量计费,极其昂贵
默认的 SignalR 配置在这种环境下会出各种问题。
8.2 适配配置
csharp
// 服务端:针对高延迟网络优化
builder.Services.AddSignalR(options =>
{
// 卫星 RTT 高,心跳间隔要放大
// 默认 15 秒,卫星环境设为 60 秒
options.KeepAliveInterval = TimeSpan.FromSeconds(60);
// 客户端超时也要放大
// 默认 30 秒,设为 180 秒
options.ClientTimeoutInterval = TimeSpan.FromSeconds(180);
// 握手超时
options.HandshakeTimeout = TimeSpan.FromSeconds(30);
// 减小消息大小上限,避免大消息阻塞
options.MaximumReceiveMessageSize = 16 * 1024; // 16KB
// 禁用详细错误(节省带宽)
options.EnableDetailedErrors = false;
})
.AddJsonProtocol(options =>
{
// 紧凑序列化
options.PayloadSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingDefault;
});
csharp
// 船端客户端:激进的重连策略
var connection = new HubConnectionBuilder()
.WithUrl("https://shore.pms-api.com/hubs/sync", options =>
{
options.AccessTokenProvider = () => GetTokenAsync();
// 卫星网络只使用 WebSocket
// SSE 和 Long Polling 在高延迟下效率极低
options.Transports = HttpTransportType.WebSockets;
options.CloseTimeout = TimeSpan.FromSeconds(60);
})
.WithAutomaticReconnect(new SatelliteRetryPolicy())
.Build();
// 卫星重连策略:快速重试 → 指数退避 → 长期等待
public class SatelliteRetryPolicy : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext context)
{
return context.PreviousRetryCount switch
{
0 => TimeSpan.FromSeconds(10),
1 => TimeSpan.FromSeconds(30),
2 => TimeSpan.FromMinutes(1),
3 => TimeSpan.FromMinutes(5),
4 => TimeSpan.FromMinutes(15),
_ => TimeSpan.FromMinutes(30) // 最终每 30 分钟尝试一次
};
}
}
8.3 消息压缩
卫星带宽极其昂贵,对消息进行压缩:
csharp
// 自定义 SignalR 协议:使用 MessagePack 二进制序列化
builder.Services.AddSignalR()
.AddMessagePackProtocol(options =>
{
options.SerializerOptions = MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4BlockArray)
.WithResolver(ContractlessStandardResolver.Options);
});
// MessagePack vs JSON 体积对比:
// JSON: {"shipId":"SHIP001","lat":22.5,"lng":114.2,"speed":12.5} ≈ 65 bytes
// MsgPack: 82 A6 736869704964 A7 53484950303031 A3 6C6174 CB... ≈ 42 bytes
// 节省约 35% 带宽
8.4 消息合并:减少微小包
csharp
// 船舶位置更新:每 5 秒可能有上百条,合并成一条广播
public class BatchedPositionPublisher
{
private readonly BatchChannel<ShipPosition> _channel;
private readonly IHubContext<SyncHub, ISyncClient> _hub;
public BatchedPositionPublisher(
IHubContext<SyncHub, ISyncClient> hub)
{
_hub = hub;
_channel = new BatchChannel<ShipPosition>(
maxBatchSize: 100,
maxWait: TimeSpan.FromSeconds(5),
ProcessBatchAsync);
}
public void Publish(ShipPosition position)
{
_channel.Write(position);
}
private async Task ProcessBatchAsync(List<ShipPosition> batch)
{
// 按船舶分组,一次发送
var grouped = batch.GroupBy(p => p.ShipId);
foreach (var group in grouped)
{
await _hub.Clients
.Group($"ship:{group.Key}")
.PositionsBatchUpdated(group.ToArray());
}
}
}
8.5 离线消息兜底
卫星网络断开期间,服务端产生的消息不能丢。用消息队列做兜底:
csharp
public class OfflineMessageStore
{
private readonly IDistributedCache _cache;
// 连接恢复时,拉取离线期间的消息
public async Task<List<NotificationMessage>> GetOfflineMessagesAsync(
string userId, DateTime since)
{
var key = $"offline:{userId}";
var data = await _cache.GetAsync<List<NotificationMessage>>(key);
return data?.Where(m => m.Timestamp > since).ToList() ?? new();
}
// 重连成功后,客户端调用此方法
public async Task AcknowledgeAsync(string userId, DateTime upTo)
{
var key = $"offline:{userId}";
var data = await _cache.GetAsync<List<NotificationMessage>>(key);
if (data != null)
{
var remaining = data.Where(m => m.Timestamp > upTo).ToList();
await _cache.SetAsync(key, remaining,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
}
}
}
九、安全考虑
9.1 JWT 认证
csharp
// 服务端配置 JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "PmsApi",
ValidAudience = "PmsClient",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
// ✅ 关键:让 SignalR 从 query string 读取 token
// 因为浏览器 WebSocket API 不能设置自定义 Header
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
9.2 授权
csharp
// Hub 级授权
[Authorize]
public class NotificationHub : Hub { }
[Authorize(Roles = "Admin,Manager")]
public class AdminHub : Hub { }
// 方法级授权
[Authorize(Policy = "CanApprove")]
public async Task Approve(Guid id) { ... }
9.3 跨域配置
csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("SignalRPolicy", policy =>
{
policy.WithOrigins("https://pms.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // SignalR 必须
});
});
十、性能与监控
10.1 关键指标
csharp
// 自定义 SignalR 指标过滤器
public class SignalRMetricsFilter : IHubFilter
{
private readonly IMetricsService _metrics;
public async ValueTask<object> InvokeMethodAsync(
HubInvocationContext context, Func<HubInvocationContext,
ValueTask<object>> next)
{
var sw = Stopwatch.StartNew();
var hubName = context.Hub.GetType().Name;
var method = context.TargetMethod;
try
{
var result = await next(context);
sw.Stop();
_metrics.Increment("signalr.invocation.success",
tag: ("hub", hubName), ("method", method));
_metrics.Histogram("signalr.invocation.duration",
sw.ElapsedMilliseconds,
tag: ("hub", hubName), ("method", method));
return result;
}
catch (Exception ex)
{
sw.Stop();
_metrics.Increment("signalr.invocation.error",
tag: ("hub", hubName),
("method", method),
("exception", ex.GetType().Name));
throw;
}
}
public async Task OnConnectedAsync(HubConnectionContext context,
Func<HubConnectionContext, Task> next)
{
_metrics.Increment("signalr.connection.connected");
_metrics.Gauge("signalr.connections.active", 1);
await next(context);
}
public async Task OnDisconnectedAsync(HubConnectionContext context,
Exception exception,
Func<HubConnectionContext, Exception, Task> next)
{
_metrics.Increment("signalr.connection.disconnected");
_metrics.Gauge("signalr.connections.active", -1);
await next(context, exception);
}
}
10.2 性能建议
1. 消息保持小而精
- SignalR 不是文件传输通道
- 大消息走 HTTP 分块下载/上传
- 单条消息建议 < 16KB
2. 合理使用 Group
- 避免为每个用户创建大量 Group
- Group 名用前缀分类:"user:{id}", "ship:{id}"
3. 避免 All 广播
- Clients.All 会推给所有连接,包括无关客户端
- 尽量用 Group 精确推送
4. 状态管理
- Hub 是短暂的,每次方法调用创建新实例
- 不要在 Hub 中存储状态
- 用 IHubContext 从外部推送
5. 连接数容量规划
- 一台 8C16G 服务器可承载 5000-10000 个 WebSocket 连接
- 超过需要横向扩展 + Redis Backplane
十一、Checklist:SignalR 上线检查
功能:
□ Hub 方法有错误处理,异常不会导致连接断开
□ 客户端注册了 Reconnecting/Reconnected/Closed 事件
□ 重连后自动重新加入 Group
□ 离线消息有兜底存储
□ 消息大小在限制范围内
可靠性:
□ 配置了合理的 KeepAliveInterval 和 ClientTimeoutInterval
□ 重连策略有指数退避和最大重试限制
□ 多实例部署配置了 Redis Backplane
□ WebSocket 不可用时自动降级到 SSE/Long Polling
□ 卫星/移动网络环境调整了心跳和超时参数
安全:
□ Hub 有 [Authorize]
□ JWT 从 query string 读取(OnMessageReceived)
□ CORS 正确配置 AllowCredentials
□ 生产环境禁用 EnableDetailedErrors
□ 消息内容做了用户权限过滤
性能:
□ 用 MessagePack 压缩(带宽敏感场景)
□ 高频消息做批量合并
□ 避免 Clients.All 广播
□ 监控连接数、消息量、调用延迟
□ 连接数容量评估完成
十二、写在最后
SignalR 是 .NET 生态中被低估的一个框架。它做的事情------实时双向通信------在现代 Web 应用中越来越普遍:协作编辑、实时通知、数据大屏、物联网设备通信、游戏------但很多团队还在用轮询凑合。
SignalR 最大的价值不是"它能做 WebSocket",而是它把传输协商、连接管理、序列化、心跳保活、断线重连、横向扩展这些和业务无关的基础设施全部做好了。你只需要写 Hub 和 Clients.All.SendAsync(),剩下的交给框架。
在 PMS 船岸卫星网络中使用 SignalR 的经验告诉我们:默认配置适用于 90% 的场景,但在极端环境下必须深入理解每个参数的含义。心跳间隔、超时时间、传输方式选择、重连策略------这些参数的调整不是凭感觉,而是基于对底层 TCP 连接行为和网络特征的理解。
最后,实时通信不是目的,它只是让用户体验更好的手段。在引入 SignalR 之前,先问自己:用户真的需要毫秒级的实时性吗?如果 5 秒轮询就能满足需求,就不要为了"技术先进"而增加系统复杂度。但如果你的场景确实需要实时推送------审批通知、设备报警、位置追踪------那 SignalR 是 .NET 技术栈中最成熟、最高效的选择。
💬 互动一下: 你的项目中有用到实时通信吗?是用 SignalR、原生 WebSocket,还是还在轮询?在使用 SignalR 过程中踩过什么坑?欢迎在评论区分享。
至此,PMS 技术博客系列已经覆盖了 DI、API 性能、中间件、后台任务、配置系统、认证授权、线上排障(并发/数据一致性/内存/网络)、EF Core、可观测性、IoC、AOP、K8s、缓存、消息队列、测试实战、SignalR 实时通信等 28 篇博客。如果你有特定想让我写的主题,随时告诉我。