CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
ASP.NET Core 实战入门/课时/SignalR 实时通信
RawBlame
课时目录12 节

ASP.NET Core 实战入门

  1. 01ASP.NET Core 基础
  2. 02C# 与 .NET 基础
  3. 03Entity Framework Core
  4. 04依赖注入
  5. 05REST / GraphQL / gRPC
  6. 06数据存储与缓存
  7. 07SignalR 实时通信
  8. 08测试与 E2E
  9. 09消息队列
  10. 10微服务与网关
  11. 11日志、任务与工具链
  12. 12综合练习

SignalR 实时通信

WebSocket、Hub 与推送模式。

30 分钟

SignalR 实时推送(对照 WebSocket/STOMP)

这一课你将能做到

  1. 建立 Hub,实现「判题完成 → 秒级推送到做题页面」的实时链路
  2. 从后台判题服务(非 HTTP 请求上下文)用 IHubContext 向指定用户/分组推送
  3. 说清 SignalR 与原生 WebSocket 的关系,以及多实例部署时为什么要 Redis backplane

为什么要学

判题结果不能靠前端轮询:用户提交后盯着页面,轮询 2 秒一次既浪费又延迟。实时推送是标配——Java 生态里你用 WebSocket + STOMP(@MessageMapping/SimpMessagingTemplate),.NET 生态里的对应物是 SignalR:一个「RPC 风格的双向通信框架」,WebSocket 只是它的传输层之一。

人话:SignalR = WebSocket 的上层封装 + 自动降级(浏览器不支持 WS 时退到 SSE/长轮询)+ 强类型客户端代理 + 断线自动重连。你可以把它理解为「Spring 里的 STOMP 全家桶被官方做成了开箱即用的产品」。

先建立图像

浏览器做题页                ASP.NET Core 服务              判题 Worker(后台)
    │  协商(negotiate)          │                            │
    │ ─────────────────────→   │  分配连接                    │
    │ ←═══ WebSocket 长连接 ═══→│                            │
    │  joinGroup("user-alice")  │                            │
    │                           │        IHubContext.Clients  │
    │ ←─ judgeResult {AC,...} ──│ ←───── 推送指令 ────────────│

关键点:推消息的人不需要持有连接。判题 Worker 是独立后台进程,它通过 IHubContext 抽象向「某个组」广播,SignalR 负责找到活着的连接。这正是 Spring 里 SimpMessagingTemplate.convertAndSend("/topic/xxx", msg) 的角色。

跟着做

步骤 1:定义 Hub(连接的驻留地)

using Microsoft.AspNetCore.SignalR;

[Authorize]                                     // JWT/cookie 鉴权后才允许连
public class JudgeHub : Hub
{
    // 连接建立:把连接挂到按用户命名的组,等价 STOMP 的 /user/queue 目的地
    public override async Task OnConnectedAsync()
    {
        var user = Context.User!.Identity!.Name!;
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{user}");
        await base.OnConnectedAsync();
    }

    // 连接断开:清理组,防止泄漏
    public override async Task OnDisconnectedAsync(Exception? ex)
    {
        var user = Context.User!.Identity!.Name!;
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{user}");
        await base.OnDisconnectedAsync(ex);
    }

    // 客户端可主动调用的方法(RPC 服务端入口),等价 @MessageMapping
    public Task WatchSubmission(long submissionId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, $"submission-{submissionId}");
}

// Program.cs
builder.Services.AddSignalR();
app.MapHub<JudgeHub>("/hubs/judge");

步骤 2:从判题 Worker 推送结果

Worker 是 BackgroundService(无 HTTP 请求上下文),注入 :

IHubContext<JudgeHub>
public class JudgeWorker(IServiceScopeFactory scopeFactory, IHubContext<JudgeHub> hub,
                         ILogger<JudgeWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var submissionId = await _queue.DequeueAsync(ct);      // 简化:内存队列
            var result = await JudgeAsync(submissionId, ct);       // 沙箱执行(省略)

            using var scope = scopeFactory.CreateScope();          // 后台无请求作用域,手动开
            var db = scope.ServiceProvider.GetRequiredService<OjDbContext>();
            await db.Submissions.Where(s => s.Id == submissionId)
                .ExecuteUpdateAsync(u => u
                    .SetProperty(s => s.Status, result.Status)
                    .SetProperty(s => s.TimeMs, result.TimeMs), ct);

            // 推给两种组:关注该提交的页面 + 该用户的所有标签页
            await hub.Clients.Group($"submission-{submissionId}")
                     .SendAsync("judgeResult", result, ct);
            await hub.Clients.Group($"user-{result.UserName}")
                     .SendAsync("submissionUpdated", result, ct);
        }
    }
}

SendAsync("judgeResult", payload) 的第一个参数是客户端要监听的事件名,第二个参数起是参数列表——SignalR 用 JSON/MessagePack 序列化传给浏览器。

步骤 3:浏览器客户端接入

npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";

const conn = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/judge", { accessTokenFactory: () => getToken() })  // WebSocket 场景经 query 传 token
    .withAutomaticReconnect()                                          // 断线自动重连(指数退避)
    .build();

conn.on("judgeResult", (r) => renderVerdict(r));     // 订阅事件,对应 STOMP 的 subscribe
await conn.start();
await conn.invoke("WatchSubmission", 1001);          // 调用服务端 Hub 方法,对应 @SendTo 前先发 STOMP 帧

步骤 4:多实例部署加 Redis backplane

两台服务器各自持有连接,A 机的 Worker 推消息时 B 机的连接收不到——加 Redis 背板让所有实例共享广播:

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
    .AddStackExchangeRedis("localhost:6379");

这与 Spring WebSocket 集群里「外部 Redis pub/sub 转发 STOMP 消息」的 broker relay 方案解决同一个问题。

讲清楚要点

  1. 连接、连接 ID、组三层抽象:ConnectionId 是物理连接,组(Group)是逻辑订阅,Users 是按身份的自动组。业务上永远面向组编程,不要在服务端长期保存 connectionId 映射表。
  2. Hub 是 RPC 不是 REST:客户端→服务端是方法调用(invoke),服务端→客户端是事件广播(on)。没有请求响应配对的 URL 概念,强类型场景可用接口约束 Hub 方法(IHub<IClient>)。
  3. 协议协商链:/hubs/judge/negotiate 先握手,优先 WebSocket,不行退 SSE,再退长轮询——老内网/代理环境也能用,这是 SignalR 相对裸 WebSocket 的最大卖点。
  4. 并发与线程安全:Hub 实例每次调用临时创建(Transient 语义),可在 Hub 构造函数注入 Scoped 服务;而 IHubContext 是 Singleton,从任何地方(Worker、控制器、消息消费者)都能拿。

你可能会懵的地方

  • SignalR 和 WebSocket 是什么关系? WebSocket 是传输协议,SignalR 是基于它的应用层框架(RPC 语义 + 重连 + 降级 + 分组)。直接用裸 WebSocket(.NET 也有 WebSockets 中间件)就像 Java 用 javax.websocket.Endpoint——能跑,但重连、分组、序列化都要自己造。

  • 横向扩展为什么必须 backplane? 连接黏在建立的实例上,A 机的 IHubContext 只能找到本机连接。Redis backplane 把「发送指令」经 Redis pub/sub 扇出到所有实例,每台再找自己持有的连接投递。也可以用 Azure SignalR Service 托管这一层。

  • 怎么只推给某一个用户? Clients.User(userId),前提是配置了 IUserIdProvider(默认取 ClaimTypes.NameIdentifier)。JWT 登录的用户天然支持,等价 STOMP 的 /user/{id}/queue 单播。

  • 消息丢了怎么办? SignalR 不保证可靠投递(本质是 pub/sub)。关键结果(判题通过)要「推送 + 持久化」双轨:前端收到推送刷新 UI,页面加载时仍以数据库为准;推送只是加速显示,不是数据源。

  • 连接数上限和资源消耗? 每连接占一个 socket 与少量内存,Kestrel 单机数万连接没问题;瓶颈通常在 CPU(序列化)与下游服务。压测用 dotnet-counters 看 SignalR 计数器。

错法对照

错法问题正解
在 Hub 里存静态字典维护 userId→connectionId断线泄漏、多实例失效用 Groups / Clients.User
Worker 直接 new JudgeHub 调方法Hub 不是这样用的,没有连接上下文注入 IHubContext,经 Clients 推送
推送当唯一数据源,前端不落库连接断开期间消息丢失数据库为准,推送只做加速
多实例部署不加 backplane一半用户收不到推送AddStackExchangeRedis 背板
在 Hub 方法里长时间 await 业务逻辑占住连接线程、超时断连Hub 只做转发,业务放服务层/队列

卡点急救

  1. 前端连不上(401/404):404 检查 MapHub 路径与客户端 withUrl 一致;401 是鉴权问题——WebSocket 握手不能带自定义 header,用 accessTokenFactory 传 query token,服务端配 JWT 事件从 query 读。
  2. 能连上但收不到消息:确认事件名大小写一致;确认双方在同一组;多实例环境确认 backplane 配置生效(Redis 里能看到 signalr 前缀的 pub/sub 通道)。
  3. 断线后重复收消息:withAutomaticReconnect 重连成功后 OnConnectedAsync 重新执行、连接 ID 变化,组需要重新加入——把组维护逻辑放在 OnConnectedAsync 里(客户端重连后重新 invoke WatchSubmission)。

面试常问

  • SignalR 相比裸 WebSocket 提供了什么? 传输协商与自动降级(WS→SSE→长轮询)、断线自动重连、基于组/用户的寻址抽象、Hub RPC 编程模型(双向方法调用而非裸帧)、客户端代理生成、与 ASP.NET Core 鉴权/DI 无缝集成。裸 WebSocket 适合单一明确的推送场景;需要多客户端、多事件、复杂订阅时 SignalR 明显省事。

  • SignalR 如何水平扩展?Spring 的 WebSocket 集群怎么解决同样问题? 两者都需要「消息扇出层」:SignalR 用 Redis backplane(AddStackExchangeRedis)或 Azure SignalR Service;Spring WebSocket 用外部消息代理(RabbitMQ STOMP relay / Redis pub/sub 手动转发)。原理一致——把「谁的连接在哪」的投递问题交给共享的广播通道。

  • 实时推送的可靠性如何设计? 定位推送为「通知加速」而非「数据传输」:业务结果先持久化,推送失败不影响正确性;客户端重连后拉一次全量状态对齐;高频事件可带版本号/时间戳让客户端去重。判题场景即「页面加载以库为准,在线期间靠推送刷新」。

自测三问

  1. Hub、ConnectionId、Group 三者分别对应什么?业务该面向哪层编程?
  2. 为什么 Worker 里用 IHubContext 而不是注入 JudgeHub?
  3. 多实例部署不加 backplane 会出现什么现象?

小结一句话

SignalR 把 WebSocket 工程化:连接管理、分组寻址、断线重连、协议降级全部内置,判题结果推送这类场景用 Hub + IHubContext 十几行代码达到 Spring STOMP 全家桶的效果。

延伸

  • 官方文档:ASP.NET Core SignalR 简介
  • 下一课:测试策略——xUnit 单元测试与 WebApplicationFactory 集成测试,对照 JUnit/Mockito/@SpringBootTest。
加载本节练习…
← 上一课数据存储与缓存下一课 →测试与 E2E