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:从组中移出