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>
|