WebSocket

需求:Web聊天、站内通知
传统HTTP只能客户端主动发送请求
传统长轮询方式性能差,不推荐使用
基于Tcp协议,支持二进制通信,双工通信
性能和并发能力强
WebSocket独立于HTTP协议,不过我们一般仍然把WebSocket服务器端部署到Web服务器,因为可以借助HTTP协议完成初始的握手(可选),并且共享HTTP服务器的端口(主要),通过请求的特点决定将请求交给HTTP处理程序或者WebSocket处理程序。

ASP.NET Core SignalR

1、SignalR是对.NET Core平台下对WebSocket的封装
2、Hub(集线器)类似于路由器,是数据交换中心

使用

创建web api项目,创建一个继承Hub的类

1
2
3
4
5
6
7
8
9
public class MyHub : Hub{
//这个方法名由客户端调用
public Task SendPublicMsgAsync(string msg) {
string connId = this.Context.ConnectionId;
string msgToSend = $"{connId} {DateTime.Now}:{msg}";
//双方规定好用"PublicMsgReceived"作为消息名,在前端会有名为"PublicMsgReceived"的事件
return this.Clients.All.SendAsync("PublicMsgReceived", msgToSend);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Program.cs
builder.Services.AddSignalR();
//记得允许跨域
builder.Services.AddCors(options => {
options.AddDefaultPolicy(
policy => {
policy.WithOrigins("http://127.0.0.1:5173")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
...
app.UseCors();
app.UseAuthorization();
app.MapHub<MyHub>("/MyHub");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//前端代码
//记得npm下载npm i @microsoft/signalr
<script>
import {reactive,onMounted} from 'vue';
import * as signalR from '@microsoft/signalr';
export default{
setup(){
let connection;
const state = reactive({userMsg:"",message:[]});
const textMsgOnKeyPress = async function(e){
if(e.keyCode!=13) return;
//参数1:调用的signalR方法名,参数2:传递的参数
await connection.invoke("SendPublicMsgAsync",state.userMsg);
state.userMsg = "";
}
onMounted(async()=>{
connection = new signalR.HubConnectionBuilder()
.withUrl("https://localhost:7020/MyHub")
.withAutomaticReconnect()
.build();
await connection.start();
//参数1:约定的消息名,参数2:回调
connection.on("PublicMsgReceived",rcvMsg=>{
state.message.push(rcvMsg);
})
})
return {state,textMsgOnKeyPress}
}
}
</script>
<template>
<input type="text" v-model="state.userMsg" v-on:keypress="textMsgOnKeyPress">
<div>
<ul>
<li v-for="(msg,index) in state.message" :key="index">{{msg}}</li>
</ul>
</div>
</template>

<style scoped>
</style>

协议协商

signalR不仅支持WebSocket还支持ServerSent Events、长轮询。
浏览器会先使用http请求通过nagotiate告诉服务器自己支持哪种方式,默认按照WebSocket、ServerSent Events、长轮询的顺序尝试。
IE浏览器支持WebSocket但signalR的javascript客户端不支持IE浏览器。

协商带来的问题

1、集群中协议协商的问题:“协商”请求被服务器A处理,但接下来建立WebSocket请求却被服务器B处理。
2、解决方法:粘性会话和禁用协商。
3、“粘性会话”:把来自同一客户端的请求都转发到同一服务器上。缺点:因为共享公网IP等造成请求无法被平均的分配到服务器集群;扩容的自适应性不强。
4、“禁用协商”:直接向服务器发送WebSocket请求,WebSocket一旦建立,就形成了服务器和客户端持久连接的通道,在该连接中的后续往返WebSocket通信都是由同一台服务器处理。缺点:无法降级到ServerSent Events和长轮询,但是问题不大。
5、如果需要使用禁用协商,请参考微软文档,在javascript创建WebSocket时传入所需的配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
onMounted(async()=>{
connection = new signalR.HubConnectionBuilder()
.withUrl("https://localhost:7020/MyHub",{
skipNegotiation:true,
transport:signalR.HttpTransportType.WebSockets
})
.withAutomaticReconnect()
.build();
await connection.start();
//参数1:约定的消息名,参数2:回调
connection.on("PublicMsgReceived",rcvMsg=>{
state.message.push(rcvMsg);
})
})

SignalR的分布式部署

问题:如果客户端连接到了不同的服务器,那么通过SignalR发送的消息很可能因为服务器不同的原因而接受不到。
解决方案:分布式部署,所有服务器连接到同一个消息中间件。在粘性会话或者禁用协商的模式下,分布式部署才有意义。
官方方案:redis backplane
Nuget:安装Microsoft.AspNetCore.SignalR.StackExchangeRedis。
使用:

1
2
3
builder.Services.AddSignalR().AddStackExchangeRedis("127.0.0.1(redis服务器地址)", opt => {
opt.Configuration.ChannelPrefix = "localhost1_";//避免和其他连接到这个服务端的redis冲突,添加前缀(非必须)
});

SignalR的身份认证

ebsocket不支持自定义报文头,所以我们需要把JWT通过url中的QueryString传递,然后再服务器端的OnMessageReceived中,把QueryString中的jwt读取出来赋值给context.Token,后续中间件会从context.Token中解析jwt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(opt => {
var jwtSettings = builder.Configuration.GetSection("JWT").Get<JwtSetting>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtSettings.SecKey);
var secKey = new SymmetricSecurityKey(keyBytes);
opt.TokenValidationParameters = new() {
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = secKey
};
opt.Events = new JwtBearerEvents {
OnMessageReceived = context => {
//websocket不支持自定义报文头,所以我们需要把JWT通过url中的QueryString传递,然后再服务器端的OnMessageReceived中,把QueryString中的jwt读取出来赋值给context.Token
var accessToken = context.Request.Query["access_token"];
var path = context.Request.Path;
if(!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/MyHub")) {
context.Token=accessToken;
}
return Task.CompletedTask;
}
};
});
......
//在app.UseAuthorization之前添加app.UseAuthentication
app.UseAuthentication();
app.UseAuthorization();

在登录的时候向客户端返回jwt,然后在需要登录才能访问的集线器类上或者方法上添加[Authorize]。也支持角色等设置,可以设置到Hub或者方法上

1
2
3
4
5
6
7
//[Authorize(Roles ="admin")]
[Authorize]
public class MyHub:Hub {
public async Task SendMessage(string user, string message) {
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}

前端代码需要传递access_token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//前端代码
//记得npm下载npm i @microsoft/signalr
<script>
import { reactive, onMounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import axios from "axios"
export default {
setup() {
let connection;
const state = reactive({ userMsg: "", message: [], userName: "", password: "" });
const textMsgOnKeyPress = async function (e) {
if (e.keyCode != 13) return;
//参数1:调用的signalR方法名,参数2:传递的参数
await connection.invoke("SendPublicMsgAsync", state.userMsg);
state.userMsg = "";
}
let login = async () => {
let payload = { userName: state.userName, password: state.password }
axios.get("https://localhost:7020/Test/Login", payload).then(async res => {
let token = res.data;
var opt = {};
opt.accessTokenFactory = () => token;
connection = new signalR.HubConnectionBuilder()
.withUrl("https://localhost:7020/MyHub",opt)
.withAutomaticReconnect()
.build();
await connection.start();
//参数1:约定的消息名,参数2:回调
connection.on("PublicMsgReceived", rcvMsg => {
state.message.push(rcvMsg);
})
})
}
return { state, textMsgOnKeyPress, login }
}
}
</script>
<template>
<div>
<input type="text" v-model="state.userMsg" v-on:keypress="textMsgOnKeyPress">
用户名:<input type="text" v-model="state.userName" />
密码:<input type="password" v-model="state.password" />
<button @click="login">登录</button>
<div>
<ul>
<li v-for="(msg, index) in state.message" :key="index">{{ msg }}</li>
</ul>
</div>
</div>
</template>

<style scoped></style>