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