【ASP.NET CORE】 11. SignalR

本系列专栏基于杨中科老师的《ASP.NET Core技术内幕与项目 实战》,本人记录梳理的学习笔记,有部分的增补和省略。更全面系统的讲解,请看杨老师的视频课:【.NET教程,.Net Core视频教程,杨中科主讲】

一、服务器向客户端推送数据

1. 传统方案:长轮询

  • 原理:客户端发起请求→服务器阻塞请求,直到有数据 / 超时→客户端收到响应后立即发起新请求,循环往复。
  • 缺点:频繁 HTTP 请求 / 响应,开销大、延迟高、并发能力弱,仅作为降级方案。

2. WebSocket:现代实时通信标准

  • 基于TCP 协议 ,支持二进制 + 文本 双工通信,客户端和服务器可双向主动发送数据
  • 性能优势:一次握手,持久连接,开销远小于长轮询,并发能力大幅提升。
  • 部署特点:独立于 HTTP 协议,但复用 HTTP 服务器端口 + 借助 HTTP 完成初始握手(无需单独端口,兼容性更强)。
  • 局限:原生 API 繁琐,需手动处理连接、重连、序列化、兼容降级等问题。

3. ASP.NET Core SignalR:WebSocket 高级封装

  • 定位:.NET Core 平台下实时通信的一站式解决方案,自动适配通信方式(优先 WebSocket,降级 SSE / 长轮询)。
  • 核心概念:Hub(集线器) → 实时通信的数据交换中心,统一管理客户端连接、消息分发、组管理。
  • 优势:简化 API、自动重连、依赖注入友好、支持分布式部署、集成身份认证。

二、SignalR 项目搭建

核心流程

创建 Web API 服务端 → 配置 SignalR 与跨域 → 编写 Hub 集线器 → Vue 前端接入 → 实现全员消息推送。

1. 服务端开发

(1)环境准备

创建**ASP.NET Core Web API** 项目,无需额外安装基础 NuGet 包(ASP.NET Core 内置 SignalR)。

(2)编写 Hub 集线器

Hub 是消息处理核心,继承自Hub基类,定义客户端可调用的方法:

cs 复制代码
/// <summary>
/// 聊天室集线器,客户端通过此Hub与服务端通信
/// </summary>
public class ChatRoomHub : Hub
{
    /// <summary>
    /// 发送公共消息(全员接收)
    /// 客户端通过Invoke调用此方法
    /// </summary>
    /// <param name="message">客户端发送的消息内容</param>
    public Task SendPublicMessage(string message)
    {
        // 获取当前客户端的唯一连接ID
        string connId = this.Context.ConnectionId;
        string msg = $"{connId} {DateTime.Now}: {message}";
        
        // 向所有连接的客户端推送消息(客户端监听ReceivePublicMessage事件)
        return Clients.All.SendAsync("ReceivePublicMessage", msg);
    }
}
(3)注册 SignalR 与跨域配置

SignalR 基于浏览器跨域策略,必须配置 CORS,否则前端无法连接:

cs 复制代码
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

// 1. 注册SignalR服务
builder.Services.AddSignalR();

// 2. 配置跨域策略(允许前端地址访问)
string[] urls = new[] { "http://localhost:3000" }; // 前端Vue运行地址
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins(urls) // 允许的源
              .AllowAnyMethod()  // 允许所有请求方法
              .AllowAnyHeader()  // 允许所有请求头
              .AllowCredentials(); // 允许携带凭据(Cookie/Token,必选!)
    });
});

var app = builder.Build();

// 启用中间件(顺序不能错)
app.UseCors(); // 启用跨域
app.UseHttpsRedirection();
app.UseAuthorization();

// 3. 映射Hub端点(客户端通过此地址连接SignalR)
app.MapHub<ChatRoomHub>("/Hubs/ChatRoomHub");
app.MapControllers();

app.Run();

2. 前端开发(Vue3)

(1)安装客户端 SDK
bash 复制代码
npm install @microsoft/signalr
(2)编写实时聊天组件
javascript 复制代码
<template>
  <div>
    <!-- 消息输入框(回车发送) -->
    <input 
      type="text" 
      v-model="state.userMessage" 
      @keypress="handleKeyPress" 
      placeholder="输入消息,回车发送"
    />
    <!-- 消息列表 -->
    <ul>
      <li v-for="(msg, index) in state.messages" :key="index">
        {{ msg }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { reactive, onMounted } from 'vue';
import * as signalR from '@microsoft/signalr';

// 响应式数据
const state = reactive({
  userMessage: '', // 输入框消息
  messages: []     // 消息列表
});

let connection; // SignalR连接对象

// 页面挂载后建立SignalR连接
onMounted(async () => {
  // 1. 配置连接
  connection = new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:7112/Hubs/ChatRoomHub') // 服务端Hub地址
    .withAutomaticReconnect() // 自动重连(断线自动恢复)
    .build();

  // 2. 监听服务端推送的公共消息
  connection.on('ReceivePublicMessage', (msg) => {
    state.messages.push(msg);
  });

  // 3. 启动连接
  try {
    await connection.start();
    console.log('SignalR连接成功');
  } catch (err) {
    console.error('连接失败:', err);
  }
});

// 回车发送消息
const handleKeyPress = async (e) => {
  if (e.keyCode !== 13) return; // 非回车直接返回
  if (!state.userMessage.trim()) return;

  // 调用服务端Hub的SendPublicMessage方法
  await connection.invoke('SendPublicMessage', state.userMessage);
  state.userMessage = ''; // 清空输入框
};
</script>

三、集群部署

1. 协议协商问题

当 SignalR 部署在服务器集群 时,会出现协议协商失败

  • 第一步:客户端发起协商请求,被服务器 A 处理;
  • 第二步:后续 WebSocket 请求被负载均衡转发到服务器 B;
  • 结果:连接丢失,通信失败。

2. 解决方案

方案 1:粘性会话
  • 原理:负载均衡配置会话绑定 ,同一客户端的所有请求强制转发到同一台服务器
  • 缺点:公网 IP 共享会导致负载不均;集群扩容适应性差,无法灵活弹性伸缩。
方案 2:禁用协商(推荐)

直接强制使用 WebSocket 协议,跳过协商步骤,一次连接绑定单台服务器,无需依赖负载均衡配置。

  • 优点:简单高效,无粘性会话缺陷;
  • 缺点:无法降级到 SSE / 长轮询(现代浏览器均支持 WebSocket,几乎无影响)。

前端修改代码

cs 复制代码
// 配置:跳过协商 + 仅使用WebSocket
const transportOptions = {
  skipNegotiation: true,
  transport: signalR.HttpTransportType.WebSockets
};

connection = new signalR.HubConnectionBuilder()
  .withUrl('https://localhost:7047/Hubs/ChatRoomHub', transportOptions)
  .withAutomaticReconnect()
  .build();

四、分布式部署

1. 跨服务器消息同步问题

集群中 4 个客户端分别连接服务器 A、B,A 服务器的消息无法推送给连接 B 的客户端(消息仅在单服务器内广播)。

2. 解决方案:Redis 背板

所有服务器连接同一个 Redis,通过 Redis 做消息中转,实现跨服务器消息同步

实施步骤
  1. 安装 NuGet 包

    bash 复制代码
    Install-Package Microsoft.AspNetCore.SignalR.StackExchangeRedis
  2. 注册 Redis 背板

    cs 复制代码
    builder.Services.AddSignalR()
           // 配置Redis连接字符串 + 通道前缀(区分项目)
           .AddStackExchangeRedis("127.0.0.1:6379", options => 
           {
               options.Configuration.ChannelPrefix = "MyChatApp_";
           });
  3. **原理:**服务端发送消息→写入 Redis→所有服务器订阅 Redis 消息→分发给自身连接的客户端,实现全局广播。

五、身份认证

1. JWT集成问题

默认 SignalR 无权限控制,任何人可连接 Hub,需集成 JWT 实现登录后访问。

2. 服务端配置

(1)定义 JWT 配置类
cs 复制代码
/// <summary>
/// JWT配置参数(从appsettings.json读取)
/// </summary>
public class JWTOptions
{
    public string SigningKey { get; set; } // 密钥
    public int ExpireSeconds { get; set; } // 过期时间
}
(2)appsettings.json 配置
复制代码
"JWT": {
  "SigningKey": "YourSecretKey1234567890123456", // 长度≥16位
  "ExpireSeconds": 86400 // 1天过期
}
(3)注册 JWT 认证
cs 复制代码
// 绑定JWT配置
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));

// 注册JWT认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(x =>
        {
            var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTOptions>();
            var keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey);
            var secKey = new SymmetricSecurityKey(keyBytes);

            // Token验证规则
            x.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = false,
                ValidateAudience = false,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = secKey
            };

            // 关键:SignalR通过URL参数传递Token(WebSocket不支持请求头)
            x.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    // 从URL的access_token参数获取Token
                    var accessToken = context.Request.Query["access_token"];
                    var path = context.HttpContext.Request.Path;
                    
                    // 匹配Hub地址则赋值Token
                    if (!string.IsNullOrEmpty(accessToken) && 
                        path.StartsWithSegments("/Hubs/ChatRoomHub"))
                    {
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;
                }
            };
        });
(4)启用认证中间件
cs 复制代码
// 顺序:认证 → 授权
app.UseAuthentication(); // 新增
app.UseAuthorization();
(5)Hub 添加权限校验

在 Hub 类上添加[Authorize]特性,未登录用户无法连接

cs 复制代码
[Authorize] // 必须登录才能访问此Hub
public class ChatRoomHub : Hub
{
    // ...
}
(6)编写登录接口
cs 复制代码
[ApiController]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
    private readonly IOptions<JWTOptions> _jwtOptions;

    public AccountController(IOptions<JWTOptions> jwtOptions)
    {
        _jwtOptions = jwtOptions;
    }

    [HttpPost("login")]
    public IActionResult Login(string username, string password)
    {
        // 省略用户名密码校验
        var userId = "1001"; // 实际从数据库获取
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId), // 用户唯一ID(定向推送用)
            new Claim(ClaimTypes.Name, username)           // 用户名
        };

        // 生成Token
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Value.SigningKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            expires: DateTime.Now.AddSeconds(_jwtOptions.Value.ExpireSeconds),
            signingCredentials: creds,
            claims: claims
        );

        return Ok(new { Token = new JwtSecurityTokenHandler().WriteToken(token) });
    }
}

3. 前端携带 Token 连接

javascript 复制代码
// 登录后获取Token
const startConnection = async (token) => {
  const options = {
    skipNegotiation: true,
    transport: signalR.HttpTransportType.WebSockets,
    accessTokenFactory: () => token // 自动携带Token到URL
  };

  connection = new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:7047/Hubs/ChatRoomHub', options)
    .withAutomaticReconnect()
    .build();

  await connection.start();
};

六、定向推送

SignalR 支持三种筛选方式,满足私聊、分组、全员推送场景:

  1. ConnectionId:单个客户端连接 ID;
  2. Groups:客户端分组(如房间、部门);
  3. UserId:用户唯一 ID(对应ClaimTypes.NameIdentifier)。

实战:用户私聊

1. 服务端 Hub 方法
cs 复制代码
/// <summary>
/// 发送私信(指定用户接收)
/// </summary>
/// <param name="destUserName">目标用户名</param>
/// <param name="message">消息内容</param>
public async Task<string> SendPrivateMessage(string destUserName, string message)
{
    // 1. 根据用户名查询目标用户ID(实际从数据库获取)
    string destUserId = await _userService.GetUserIdByNameAsync(destUserName);
    
    // 2. 获取当前登录用户信息
    string srcUserName = this.Context.User.FindFirst(ClaimTypes.Name)!.Value;
    string time = DateTime.Now.ToShortTimeString();

    // 3. 向指定用户ID推送消息
    await Clients.User(destUserId).SendAsync("ReceivePrivateMessage", 
        srcUserName, time, message);
    
    return "发送成功";
}
2. 前端调用与监听
javascript 复制代码
// 发送私信
const sendPrivate = async (destUserName, msg) => {
  await connection.invoke("SendPrivateMessage", destUserName, msg);
};

// 监听私信
connection.on('ReceivePrivateMessage', (srcUser, time, msg) => {
  state.messages.push(`${srcUser} ${time} 私信:${msg}`);
});

核心 API

服务端 API 作用
Clients.All 全员推送
Clients.User(userId) 指定用户推送
Clients.Client(connId) 指定连接推送
Clients.Others 排除自己,全员推送
Groups.AddToGroupAsync 加入分组
Groups.RemoveFromGroupAsync 退出分组
Clients.Group(groupName) 分组推送

总结

  1. SignalR:封装 WebSocket 细节,自动适配通信方式,零成本实现实时推送;Hub 为核心,配置跨域 + 映射端点,前端通过 SDK 连接;支持用户、连接、分组三种维度,满足私聊 / 分组 / 全员场景。
  2. 集群 / 分布式:禁用协商解决协议问题,Redis 背板实现跨服务器消息同步。
  3. 安全控制 :JWT 集成,通过 URL 参数传递 Token,[Authorize]校验权限。
相关推荐
程序边界2 小时前
从MySQL到国产数据库的真实迁移笔记:那些坑爹的坑和意外的爽点
后端
qq5680180762 小时前
一个基于Spring Boot的简单网吧管理系统
java·spring boot·后端
hashiqimiya2 小时前
spring报错
java·后端·spring
毕设源码-朱学姐2 小时前
【开题答辩全过程】以 基于Springboot的养老服务管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
包包5552 小时前
WxJava微信公众号开发实战
后端
qingwufeiyang_5302 小时前
Nacos学习笔记
java·笔记·学习·spring cloud·服务发现
陈随易2 小时前
向日葵+AI,远程操控又进化了
前端·后端·程序员
Spanless2 小时前
mybatis实现子母表树型列表查询
后端