.net SignalR的使用

364 阅读5分钟

SignalR

SignalR最主要是.NET Core平台下对WebSocket的封装。可以利用SingnalR方便我们去写功能复杂的websocket程序

SignalR的客户端支持网页、安卓app、ios的app、客户端程序

集线器Hub

SignalR框架中最核心的类是Hub(集线器),是一个数据交换中心,多个客户端可以连接一个Hub,进行相互通信

Hub也可以主动向连接的客户端发送消息

SignalR中客户端给服务器端消息传递的默认超时时间为30s钟,Hub的连接资源很宝贵,不应该被处理业务逻辑的时间占据

注意,Hub只应该做消息分发,不要把业务逻辑写到Hub中

协议协商

SignalR支持多种服务器推送方式:Websocket、Server-Sent Events(本质还是长轮询)、长轮询。默认按顺序尝试,浏览器若不支持Websocket,则尝试使用Server-Sent Events,若还不支持则采用长轮询。这就是协议协商。

初始采用http协议协进行协议商

协议协商的问题及解决

集群中协议协商的问题:“协商”请求被服务器A处理,而接下来的WebSocket请求却被服务器B处理

解决方法:粘性会话和禁止协商

1、粘性会话(Sticky Session):在负载均衡服务器上做配置,把来自同一个客户端的请求都转发给同一台服务器上

缺点:因为共享公网IP等造成请求无法被平均的分配到服务器集群,扩容的自适应性不强

2、禁用协商(推荐):直接向服务器发出WebSocket请求。WebSocket连接一旦建立后,在客户端和服务器端直接就建立了持续的网络连接通道,在这个WebSocket连接中的后续往返WebSocket通信都是由同一台服务器来处理

缺点:无法降级到“服务器发送事件”或“长轮询”,不过不是大问题

禁用协议协商的方式

//在客户端
const options = { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets  };
connection = new signalR.HubConnectionBuilder()
	.withUrl('https://localhost:7047/Hubs/ChatRoomHub', options)
	.withAutomaticReconnect().build();

SignalR的分布式问题

多台服务器集群部署时,同一个Hub下的四个客户端,可能c1 c2客户端连接到服务器1,c3 c4客户端连接到服务器2。而服务器1和服务器2不做通讯,这样可能c1发送的消息只能被广播到c2

解决办法:所有服务器连接到同一个消息中间件,必须启用粘性会话或跳过协商

微软提供了一个用Redis做服务器之间通讯的消息中间件技术Redis backplane,使用很简单,借用Redis内部的消息队列由一个服务器将消息推送给别的服务器

Redis backplane使用步骤

Microsoft.AspNetCore.SignalR.StackExchangeRedis

1、在Program.cs中

builder.Services.AddSignalR().AddStackExchangeRedis("127.0.0.1", options => {
        options.Configuration.ChannelPrefix = "Test1_";
});

使用步骤

原生用法

注意:这种没有引入JWT框架的SignalR,谁都能连

后端

1、创建Web API项目,创建一个Hub派生类。在里面编写向客户端发送公共消息或私信的方法

public class MyHub : Hub {
    ...
}

2、注册SignalR服务

builder.Services.AddSignalR();
//下面的方式用于解决分布式中,多台服务器消息同步的问题,一台服务器不需要
builder.Services.AddSignalR().AddStackExchangeRedis("127.0.0.1", options => {
    options.Configuration.ChannelPrefix = "Test1_";
});

3、配置SignalR的Hub管道的路径

注:要写在app.MapControllers()之前

app.MapHub<MyHub>("/MyHub");//启用hub中间件
app.MapControllers();

4、启用CORS,允许跨域访问

前端

//安装SignalR的包

npm install @microsoft/signalr

1、和服务端建立websocket连接通道

//一般在onMounted中执行
const options = {
    skipNegotiation: true,//禁用协议协商
    transport: signalR.HttpTransportType.WebSockets,//强制使用websocket通讯
};
connection = new signalR.HubConnectionBuilder()
    //配置options
    .withUrl("https://localhost:7269/MyHub", options) //后端配置的Hub路径,必须完整路径
    .withAutomaticReconnect() //自动失败重连
    .build();
await connection.start();

2、监听服务端推送的消息

当服务端推送消息时,on中的代码就会执行。on函数中方法名、回调的参数和后端SendAsync的参数对应

//一般在onMounted中执行
connection.on("PublicMessageReceived", (msg) => {
    state.messages.push(msg);
}); 
connection.on("PrivateMsgRecevied", (fromUserName,msg) => {
    state.messages.push(fromUserName+"私聊说:"+msg);
});

3、向服务端的Hub类发送请求消息

在客户端点击向别的用户发送消息的时候执行

//与服务端的Hub类中的方法对应
await connection.invoke("SendPublicMsg", state.userMessage);

在集线器外部使用SignalR

总结:在外部注入服务即可使用,但是在外部无法判断自己、别人

如何在MVC控制器、托管服务等集线器外部向客户端推送消息?

很简单,可以通过注入IHubContext<MyHub>来获取对集线器进行操作的服务,通过泛型指定连接哪个集线器

//通过构造方法注入服务
private readonly IHubContext<MyHub> myHubContext;
//直接使用
await myHubContext.Clients.All.SendAsync("PublicMessageReceived", $"欢迎{req.UserName}加入我们");

局限性

IHubContext.Clients是IHubClients类型、Hub.Clients是IHubCallerClients类型,IHubCallerClients继承自IHubClients,多了Caller、Others这些功能,因为http协议和websocket是独立的,只是可以共用一个统一的接口,Hub是知道自己和客户端的连接的,而Controller是不知道自己的连接的。

在控制器等集线器的外部调用的IHubContext服务,这些请求并不在一个SignalR连接中,因此也就没有了“当前SignalR连接”的概念。所以通过注入的IHubContext不能向“当前连接的客户端”、“其他客户端”推送消息

加上JWT身份认证的SignalR

1)按照配置JWT的步骤配置JWT

2)在Program.cs中,注册JWT服务时添加把QueryString中的JWT读出来

JWT的token是通过报文头设置的,而webscocket是不支持自定义报文头的,所以客户端需要把JWT通过url中的QueryString传递,然后在服务端的OnMessageReceived中,把QueryString中的jwt读出来,然后赋值给context.Token。后续的中间件会从中解析出token

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        //这里省略jwt里面的代码...
        //下面是为了使用SignalR
        options.Events = new JwtBearerEvents {
            //配置jwt时再OnMessageReceived方法中读出jwt,赋值给context.Token
            //这个context是MessageReceivedContext类型
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                //下面这行保证了只有访问集线器时,才会将值给context.Token,不会影响其他类
                if (!string.IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/MyHub"))) 
                    context.Token = accessToken;
                return Task.CompletedTask;
            }
        };
    });

3)在需要登录才能访问的Hub类上或者方法上添加[Authorize],也支持角色等设置

注意,角色区分大小写

[Authorize]//登录才能访问,也可以加到方法头上
//[Authorize(Roles ="admin")]
[Authorize(Roles ="admin,user")]
public class MyHub : Hub {
    //...
}

推送消息

发送公共消息

//方法是异步方法,但是为了方便客户端调用,不加async
public Task SendPublicMsg(string message) {
    var claim=this.Context.User.FindFirst(ClaimTypes.Name);//拿到用户名
    string connId = this.Context.ConnectionId;//客户端id
    //实际项目中不要将ConnectionId广播出去
    string msg = $"{connId} {DateTime.Now}:{message}{claim.Value}";
    return Clients.All.SendAsync("PublicMessageReceived", msg);
}

筛选客户端的3种方式

1、ConnectionId(客户端ID)

//this指Hub的派生类对象
this.Context.ConnectionId

2、组

this.Groups

3、用户Id:对应JWT中的ClaimTypes.NameIdentifier

发送私信

public async Task<string> SendPrivateMessage(string toUserName, string message) {
    var user =await userManager.FindByNameAsync(toUserName);
    long userId=user.Id;
    string currentUserName = this.Context.UserIdentifier;
    //this.Clients.User(userId.ToString()):根据收信人的id筛选出该用户
    await this.Clients.User(userId.ToString()).SendAsync("PrivateMsgRecevied",currentUserName,message);
    return "ok";
}

API

Hub的Clients属性为IHubCallerClients类型,可以对连接到当前集线器的客户端进行筛选,筛选出的客户端返回IClientProxy

IHubCallerClients

继承自IHubClients

属性名含义
All所有的客户端
Caller自己
Others除自己之外的其他人
方法名含义
AllExcept除了在list中指定的客户端之外的客户端
Clients若干个ConnectionId
Group指定名字的组中的客户端,为IGroupManager类型,可以对组成员进行管理
OthersInGroup在组中的其他人
User指定用户ID的
Users指定多个用户ID的

IClientProxy

含有SendCoreAsync方法,但是我们一般使用它的扩展方法SendAsync。无法知道具体有哪些客户端调用SendAsync()方法向筛选的客户端发送消息

IHubClients

相比于IHubCallerClients,没有Caller、Others属性,即不知道自己和别人

IGroupManager

AddToGroupAsync:加入组

//将当前用户加入名字为dev的组
await this.Groups.AddToGroupAsync(this.Context.ConnectionId,"dev")

RemoveFromGroupAsync:从组中移出