ASP.NET Core SignalR 实战:从实时推送到底层协议与高可用部署

你有没有做过这样的需求:"后台数据一变,前端页面要立刻更新。"

最初的方案通常是前端 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",而是它把传输协商、连接管理、序列化、心跳保活、断线重连、横向扩展这些和业务无关的基础设施全部做好了。你只需要写 HubClients.All.SendAsync(),剩下的交给框架。

在 PMS 船岸卫星网络中使用 SignalR 的经验告诉我们:默认配置适用于 90% 的场景,但在极端环境下必须深入理解每个参数的含义。心跳间隔、超时时间、传输方式选择、重连策略------这些参数的调整不是凭感觉,而是基于对底层 TCP 连接行为和网络特征的理解。

最后,实时通信不是目的,它只是让用户体验更好的手段。在引入 SignalR 之前,先问自己:用户真的需要毫秒级的实时性吗?如果 5 秒轮询就能满足需求,就不要为了"技术先进"而增加系统复杂度。但如果你的场景确实需要实时推送------审批通知、设备报警、位置追踪------那 SignalR 是 .NET 技术栈中最成熟、最高效的选择。

💬 互动一下: 你的项目中有用到实时通信吗?是用 SignalR、原生 WebSocket,还是还在轮询?在使用 SignalR 过程中踩过什么坑?欢迎在评论区分享。


至此,PMS 技术博客系列已经覆盖了 DI、API 性能、中间件、后台任务、配置系统、认证授权、线上排障(并发/数据一致性/内存/网络)、EF Core、可观测性、IoC、AOP、K8s、缓存、消息队列、测试实战、SignalR 实时通信等 28 篇博客。如果你有特定想让我写的主题,随时告诉我。

相关推荐
JasmineWr1 小时前
Spring BeanDefinition 与 Bean 生命周期、循环依赖解析
java·后端·spring
QQ_21696290961 小时前
【项目编号:project86564】SpringBoot供应链管理系统:采购、供应商、库存、销售、统计报表一体化实战
java·spring boot·后端
Lost of 程序猿1 小时前
ASP.NET Core 测试实战:从单元测试到集成测试的全链路质量保障
单元测试·asp.net·集成测试
灯澜忆梦1 小时前
【基于GO的Web开发10】gin获取HTML-Form表单提交参数
前端·后端·golang·html·gin
SomeB1oody10 小时前
【RustyML入门】6.3. 并行归约
开发语言·后端·机器学习·rust·教程
Profile排查笔记11 小时前
指纹浏览器哪个好?从 Profile、代理、权限和自动化能力判断是否适合
前端·人工智能·后端·自动化
用户9385156350711 小时前
用 AI 结对编程从 0 搭一个"单词后台管理系统":Next.js + Supabase + Drizzle + shadcn/ui 全记录
后端·postgresql·next.js
爱学习的小邓同学11 小时前
Golang --- (1)第一个Golang程序
开发语言·后端·golang
小灰灰搞电子12 小时前
Rust+Slint 实现的“DNA双螺旋”加载动画源码分享
后端·rust·slint·加载动画