什么是WebSocket和SignalR
WebSocket基于TCP协议,支持二进制通信,双工通信。
性能和并发能力更强。
WebSocket服务器端部署到Web服务器上,因为可以借助HTTP协议完成初始的握手(可选),并且共享HTTP服务器的端口(主要)
虽然WebSocket是独立于HTTP的,但是我们一般仍然把WebSocket服务器端部署到Web服务器上,因为我们需要借助HTTP完成初始的握手,并且共享HTTP服务器的端口,这样就可以避免为WebSocket单独打开新的服务器端口。因此,SignalR的服务器端一般运行在ASP.NET Core项目中。
SignIR
ASP.NET Core SignalR(以下简称SignalR),是.NET Core平台下对WebSocket的封装。
Hub(集线器),数据交换中心。
SignalR基本使用 第一步,创建一个继承自Hub类的ChatRoomHub类,所有的客户端和服务器端都通过这个集线器进行通信.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class ChatRoomHub :Hub { public Task SendPublicMessage (string message ) { string connId = this .Context.ConnectionId; string msg = $"{connId} {DateTime.Now} :{message} " ; return Clients.All.SendAsync("ReceivePulicMessage" , msg); } }
ChatRoomHub类中定义的方法可以被客户端调用,也就是客户端可以向服务器端发送请求,方法的参数就是客户端向服务器端传送的消息,参数的个数原则上来讲不受限制,而且参数的类型支持string、bool、int等常用的数据类型。 在ChatRoomHub类中,我们定义了一个方法SendPublicMessage,方法的参数message为客户端传递过来的消息。在第5行代码中,我们获得了当前发送消息的客户端连接的唯一标识ConnectionId;在第6行代码中拼接出一个包含连接ID、当前时间、客户端消息的字符串;随后我们把msg字符串以名字为“ReceivePublicMessage”的消息发送到所有连接到集线器的客户端上。
第二步,编辑Program.cs,在builder.Build之前调用builder.Services.AddSignalR注册所有SignalR的服务,在app.MapControllers之前调用app.MapHub<ChatRoomHub>(“/hubs/ChatRoomHub”)启用SignalR中间件,并且设置当客户端通过SignalR请求“/Hubs/ChatRoomHub”这个路径的时候,由ChatRoomHub进行处理。
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 var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddSignalR(options => { options.EnableDetailedErrors = true ; options.ClientTimeoutInterval = TimeSpan.FromSeconds(30 ); }); string [] urls = new [] { "http://localhost:5173" };builder.Services.AddCors(options => options.AddDefaultPolicy(builder => builder.WithOrigins(urls).AllowAnyMethod() .AllowAnyHeader().AllowCredentials()) ); var app = builder.Build();if (app.Environment.IsDevelopment()){ app.UseSwagger(); app.UseSwaggerUI(); } app.UseCors(); app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapHub<ChatRoomHub>("/Hub/ChatRoomHub" ); app.MapControllers(); app.Run();
第三步,我们需要编写一共静态HTML页面提供交互界面。
执行如下命令安装SignalR的JavaScript客户端SDK:npm install @microsoft/signalr
在SignalR的JavaScript客户端中,我们使用HubConnectionBuilder来创建从客户端到服务器端的连接;通过withUrl方法来设置服务器端集线器的地址,该地址必须是包含域名等的重连机制。虽然withAutomaticReconnect不是必须设置的,但是设置这个选项之后,如果连接被断开,客户端就会尝试重连,因此使用起来更方便。需要注意的是,客户端重连之后,由于这是个新的连接,因此在服务器端获得的ConnectionId是一个新的值。对HubConnectionBuilder设置完成后,我们调用build就可以构建完成一个客户端到集线器的连接。 通过build获得的到集线器的连接只能是逻辑上的连接,还需要调用start方法来实际启动连接。一旦连接建立完成,我们就可以通过连接对象的invoke函数来调用集线器中的方法,我们可以通过on函数来注册监听服务器适用SendAsync发送的消息的代码。
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 <template> <input type ="text" v-model ="state.userMessage" v-on:keypress ="textMsgOnkeypress" /> <div > <ul > <li v-for ="(msg,index) in state.messages" :key ="index" > {{msg}}</li > </ul > </div > </template> <script > import { reactive, onMounted } from 'vue' ;import * as signalR from "@microsoft/signalr" let connection;export default { name : "Login" , setup ( ) { const state = reactive ({ userMessage : "" , messages : [] }); const textMsgOnkeypress = async function (e ) { if (e.keyCode != 13 ) return ; await connection.invoke ("SendPublicMessage" , state.userMessage ); state.userMessage = "" ; }; onMounted (async function ( ) { connection = new signalR.HubConnectionBuilder () .withUrl ("https://localhost:7122/Hub/ChatRoomHub" ) .withAutomaticReconnect () .build (); await connection.start (); connection.on ("ReceivePulicMessage" , (msg ) => { state.messages .push (msg); }); }); return { state, textMsgOnkeypress }; }, }; </script >
功能实现 SignalR 连接初始化:
1 2 3 4 5 connection = new signalR.HubConnectionBuilder () .withUrl ("https://localhost:7122/Hub/ChatRoomHub" ) .withAutomaticReconnect () .build (); await connection.start ();
创建 SignalR 连接并配置自动重连 连接到服务器端的 ChatRoomHub 集线器 组件挂载完成后立即启动连接 消息接收处理:
1 2 3 connection.on ("ReceivePulicMessage" , (msg ) => { state.messages .push (msg); });
注册事件监听器,监听服务器端发送的ReceivePulicMessage事件 接收到消息后将其添加到messages数组,触发视图更新 消息发送处理:
1 2 3 4 5 const textMsgOnkeypress = async function (e ) { if (e.keyCode != 13 ) return ; await connection.invoke ("SendPublicMessage" , state.userMessage ); state.userMessage = "" ; };
监听输入框的按键事件,仅处理 Enter 键 (13) 通过connection.invoke调用服务器端的SendPublicMessage方法 发送成功后清空输入框
协议协商 SignalR其实并不只是对WebSocket的封装,它支持多种服务推送的实现方式,包括WebSocket、服务器发送事件(server-sent events)和长轮询。SiganlR的JavaScript客户端会先尝试用WebSocket连接服务器;如果失败了,他再用服务器发送事件方式连接服务器;如果又失败了,它再用长轮询方式连接服务器。因此SignalR会自适应复杂的客户端、网络、服务器环境来支持服务器端推送的实现。
协议协商的问题
集群中协议协商的问题:“协商”请求被服务器A处理,而接下来的WebSocket请求却被服务器B处理。
解决方法:粘性会话和禁用协商。
粘性会话(Sticky Session):把来自同一个客户端的请求都转发给同一台服务器上。缺点:因此共享公网IP等造成请求无法被平均的分配到服务器集群;扩容的自适应性不强。
禁用协商:直接向服务器发出WebSocket请求。WebSocket连接一旦建立后,在客户端和服务器端直接就建立了持续的网络连接通道,在这个WebSocket连接中的后续往返WebSocket通信都是有同一台服务器来处理。缺点:无法降级到“服务器发送事件”或“长轮询”,不过不是大问题。
1 2 3 4 5 6 7 8 const options = { skipNegotiation: true , transport: signalR.HttpTransportType.WebSockets, }; connection = new signalR.HubConnectionBuilder() .withUrl("https://localhost:7122/Hubs/ChatRoomHub" ,options) .withAutomaticReconnect() .build();
SignalR分布式部署 在多台服务器组成的分布式环境中,我们可以采用粘性会话或者禁用协商的方式来保证来自同一个客户端的请求被同一台服务器处理,但是在分布式环境中,还有其他问题需要解决。
客户端1发消息3、4收不到,客户端3发消息1、2收不到。因为这两台服务器之间的ChatRoomHub没有通信。为了解决这个问题,我们可以让多台服务器上的集线器连接到一个消息队列中,通过这个消息队列完成跨服务器的消息投递。
微软官方提供了用Redis服务器来解决SignalR部署在分布式环境中数据同步的方案————Redis backplane。
所有服务器连接到同一个消息中间件。必须使用粘性会话或者跳过协商。
安装NuGet: Micrsosft.AspNetCore.SignalR.StackExchangeRedis
1 2 3 4 builder.Services.AddSignalR().AddStackExchangeRedis("127.0.0.1" ,options => { options.Configuration.ChannelPrefix = "SignalRChat" ; });
如果有多个SignalR应用程序连接同一台Redis服务器,那么我们需要为每一个应用程序配置唯一的ChannelPrefix。
SignalR身认证 配置JWT的代码
在配置系统中配置一个名字为JWT的节点,并在节点下创建SigningKey、ExpireSeconds两个配置项,分别代表JWT的密钥和过期时间(单位为秒)。我们在创建一个对应JWT节点的配置类JWTOptions,类中包含SigningKey、ExpireSwconds这两个属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "JWT" : { "SigningKey" : "jlkerujoigsnvjlkasjfwehiojkgnsgss" , "ExpireSeconds" : "3600" } , "ConnectionStrings" : { "Default" : "Server=localhost;Database=T_youxianyu;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True;Encrypt=True;" } , "Logging" : { "LogLevel" : { "Default" : "Information" , "Microsoft.AspNetCore" : "Warning" } } , "AllowedHosts" : "*" }
1 2 3 4 5 public class JWTOptions { public string ? SigningKey { get ; set ; } public int ExpirSeconds { get ; set ; } }
安装: Microsoft.AspNetCore.Authentication.JwtBearer
编写代码对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 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 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 var builder = WebApplication.CreateBuilder(args);builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { var scheme = new OpenApiSecurityScheme() { Description = "Authorization header.\r\nExample:'Bearer 12345abcdef'" , Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Authorization" }, Scheme = "oauth2" , Name = "Authorization" , In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }; c.AddSecurityDefinition("Authorization" , scheme); var requirement = new OpenApiSecurityRequirement(); requirement[scheme] = new List<string >(); c.AddSecurityRequirement(requirement); }); IServiceCollection services = builder.Services; services.AddDbContext<IdDbContext>(opt => { string ? connStr = builder.Configuration.GetConnectionString("Default" ); opt.UseSqlServer(connStr); }); services.AddDataProtection(); services.AddIdentityCore<User>(options => { options.Password.RequireDigit = false ; options.Password.RequireLowercase = false ; options.Password.RequireNonAlphanumeric = false ; options.Password.RequireUppercase = false ; options.Password.RequiredLength = 6 ; options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider; options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider; }); var idBuilder = new IdentityBuilder(typeof (User), typeof (Role), services);idBuilder.AddEntityFrameworkStores<IdDbContext>() .AddDefaultTokenProviders() .AddRoleManager<RoleManager<Role>>() .AddUserManager<UserManager<User>>(); services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT" )); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(x => { var jwtOpt = builder.Configuration.GetSection("JWT" ).Get<JWTOptions>(); byte [] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey); var secKey = new SymmetricSecurityKey(keyBytes); x.TokenValidationParameters = new TokenValidationParameters() { ValidateIssuer = false , ValidateAudience = false , ValidateLifetime = false , ValidateIssuerSigningKey = true , IssuerSigningKey = secKey }; x.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token" ]; var path = context.HttpContext.Request.Path; if (!string .IsNullOrEmpty(accessToken) && (path.StartsWithSegments("/Hub/ChatRoomHub" ))) { context.Token = accessToken; } return Task.CompletedTask; } }; }); builder.Services.AddSignalR(options => { options.EnableDetailedErrors = true ; options.ClientTimeoutInterval = TimeSpan.FromSeconds(30 ); }); string [] urls = new [] { "http://localhost:5173" };builder.Services.AddCors(options => options.AddDefaultPolicy(builder => builder.WithOrigins(urls).AllowAnyMethod() .AllowAnyHeader().AllowCredentials()) ); var app = builder.Build();if (app.Environment.IsDevelopment()){ app.UseSwagger(); app.UseSwaggerUI(); } app.UseCors(); app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapHub<ChatRoomHub>("/Hub/ChatRoomHub" ); app.MapControllers(); app.Run();
在app.UseAuthorization();之前添加app.UseAuthentication();
在控制器类Test1Controller中增加登录并且创建JWT的操作方法Login。
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 private static string BuildToken (IEnumerable<Claim> claims, JWTOptions options ) { DateTime expires = DateTime.Now.AddSeconds(options.ExpirSeconds); byte [] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey); var secKey = new SymmetricSecurityKey(keyBytes); var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature); var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims); return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); } [HttpPost ] public async Task<IActionResult> Login (LoginRequest loginRequest, [FromServices] IOptions<JWTOptions> jwtOptions ){ string userName = loginRequest.UserName; string password = loginRequest.Password; var user = await _userManager.FindByNameAsync(userName); var success = await _userManager.CheckPasswordAsync(user, password); if (!success&&user==null ) { return BadRequest("用户名或者密码错误" ); } var claims = new List<Claim>(); claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); claims.Add(new Claim(ClaimTypes.Name, user.UserName)); var roles = await _userManager.GetRolesAsync(user); foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role)); } string jwtToken = BuildToken(claims, jwtOptions.Value); return Ok(jwtToken); }
在需要登录才能访问的集线器类上或者方法上添加[Authorize]。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 [Authorize ] public class ChatHub : Hub { private readonly UserManager<User> _userManager; public ChatHub (UserManager<User> userManager ) { _userManager = userManager; } public Task SendPublicMessage (string message ) { string userName = Context.User.Identity.Name; string time = DateTime.Now.ToString("HH:mm" ); string msg = $"{userName} ({Context.ConnectionId} ) {time} : {message} " ; return Clients.All.SendAsync("ReceivePublicMessage" , msg); } }
前端代码
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 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 <template> <fieldset > <legend > 登录</legend > <div > 用户名:<input type ="text" v-model ="state.loginData.userName" /> </div > <div > 密码:<input type ="password" v-model ="state.loginData.password" > </div > <div > <input type ="button" value ="登录" v-on:click ="loginClick" /> </div > </fieldset > 公屏:<input type ="text" v-model ="state.userMessage" v-on:keypress ="txtMsgOnkeypress" /> <div > <ul > <li v-for ="(msg,index) in state.messages" :key ="index" > {{msg}}</li > </ul > </div > </template > <script > import { reactive, onMounted } from 'vue' ; import * as signalR from '@microsoft/signalr' ; import axios from 'axios' ; let connection; export default {name : 'Login' , setup ( ) { const state = reactive ({ accessToken :"" ,userMessage : "" , messages : [], loginData : { userName : "" , password : "" }, privateMsg : { destUserName :"" ,message :"" }, }); const startConn = async function ( ) { const transport = signalR.HttpTransportType .WebSockets ; const options = { skipNegotiation : true , transport : transport }; options.accessTokenFactory = () => state.accessToken ; connection = new signalR.HubConnectionBuilder () .withUrl ('https://localhost:7122/Hubs/ChatRoomHub' , options) .withAutomaticReconnect ().build (); try { await connection.start (); } catch (err) { alert (err); return ; } connection.on ('ReceivePublicMessage' , msg => { state.messages .push (msg); }); alert ("登陆成功可以聊天了" ); }; const loginClick = async function ( ) { const resp = await axios.post ('https://localhost:7122/Test/Login' , state.loginData ); state.accessToken = resp.data ; startConn (); }; const txtMsgOnkeypress = async function (e ) { if (e.keyCode != 13 ) return ; try { await connection.invoke ("SendPublicMessage" , state.userMessage ); }catch (err) { alert (err); return ; } state.userMessage = "" ; }; const txtPrivateMsgOnkeypress = async function (e ) { if (e.keyCode != 13 ) return ; const destUserName = state.privateMsg .destUserName ; const msg = state.privateMsg .message ; try { const ret = await connection.invoke ("SendPublicMessage" , destUserName, msg); if (ret != "ok" ) { alert (ret);}; } catch (err) { alert (err); return ; } state.privateMsg .message = "" ; }; return { state, loginClick, txtMsgOnkeypress, txtPrivateMsgOnkeypress}; }, } </script >
SignalR:针对部分客户端的消息推送
在我们进行客户端筛选的时候,有3个筛选参数:ConnectionId、组合用户ID。ConnectionionId是SignalR为每个连接分配的唯一标识,我们可以通过集线器的Context属性中的ConnectionId属性获取当前连接的ConnectionId:每个组有唯一的名字,对于连接到同一个集线器中的客户端,我们可以把它们分组;用户ID是登录用户的ID,它对应的是类型为ClaimTypes.NameIdentifier的Claim的值,如果使用用户ID进行筛选,我们需要在客户端登录的时候设定类型为ClaimTypes.NameIdentifier的Claim。
Hub类 Hub类的Groups属性为IGroupManager类型,它可以用来对组成员进行管理,IGroupManager类包含如下所示的方法。
1 2 3 4 await this .Groups.AddToGroupAsync(connId, message);await this .Groups.RemoveFromGroupAsync(connId, message);
我们在把连接加入组中的时候,如果指定名字的组不存在,SignalR会自动创建组。因为连接和组的关系是通过ConnectionId建立的,所以客户端重连之后,我们就需要把连接重新加入组。
Hub类的Clients属性为IHubCallerClients类型,它可以用来对连接到当前集线器的客户端进行筛选。IHubCallerClients类包含如下所示的成员。
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 T Caller { get ; } T Others { get ; } T OthersInGroup (string groupName ) ; T All { get ; } T AllExcept (IReadOnlyList<string > excludedConnectionIds ) ;T Client (string connectionId ) ;T Clients (IReadOnlyList<string > connectionIds ) ;T Group (string groupName ) ;T GroupExcept (string groupName, IReadOnlyList<string > excludedConnectionIds ) ;T Groups (IReadOnlyList<string > groupNames ) ;T User (string userId ) ;T Users (IReadOnlyList<string > userIds ) ;
发送私聊消息
下面我们来为之前编写的Web聊天室增加“发送私聊消息”的功能。
在ChatRoomHub中增加一个发送私聊消息的SendPrivateMessage方法。
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 [Authorize ] public class ChatHub : Hub { private readonly UserManager<User> _userManager; public ChatHub (UserManager<User> userManager ) { _userManager = userManager; } public Task SendPublicMessage (string message ) { string userName = Context.User.Identity.Name; string time = DateTime.Now.ToString("HH:mm" ); string msg = $"{userName} ({Context.ConnectionId} ) {time} : {message} " ; return Clients.All.SendAsync("ReceivePublicMessage" , msg); } public async Task<string > SendPrivateMessage (string destUserName, string message ) { var destUser = await _userManager.FindByNameAsync(destUserName); if (destUser == null ) { return "目标用户不存在" ; } string srcUserName = Context.User.Identity.Name; string time = DateTime.Now.ToString("HH:mm" ); await Clients.User(destUser.Id.ToString()) .SendAsync("ReceivePrivateMessage" , srcUserName, time, message); return "ok" ; } }
需要注意的是,SignalR不会对消息进行持久化,因此即使目标用户当前不在线,SendAsync方法的调用也不会出错,而且用户上线后也不会收到离线期间的消息。同样的道理也适用于分组发送消息,用户在上线后才能加入一个分组,因此用户也无法收到离线期间该组内的消息。
如果我们的系统需要实现接收离线期间的消息的功能,就需要再自行额外开发消息的持久化功能,比如服务器端在向客户端发送消息的同时,也要把消息保存到数据库中;在用户上线时,程序要先到数据库中查询历史消息。
在前端页面增加私聊功能的界面和代码。
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 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 <template> <div> <div v-if ="!state.isConnected" > <fieldset> <legend>登录</legend> <div> 用户名:<input type="text" v-model="state.loginData.userName" /> </div> <div> 密码:<input type="password" v-model="state.loginData.password" > </div> <div> <input type="button" value ="登录" v-on :click="loginClick" /> </div> </fieldset> </div> <div v-else > <div> 公屏:<input type="text" v-model="state.userMessage" v-on :keypress="txtMsgOnkeypress" /> </div> <div> 私聊给:<input type="text" v-model="state.privateMsg.destUserName" /> 说:<input type="text" v-model="state.privateMsg.message" v-on :keypress="txtPrivateMsgOnkeypress" /> </div> <div> <h3>消息列表</h3> <ul> <li v-for ="(msg, index) in state.messages" :key="index" :class ="{ 'private-message': msg.isPrivate }" > {{ msg.content }} </li> </ul> </div> </div> </div> </template> <script> import { reactive, onMounted } from 'vue' ; import * as signalR from '@microsoft/signalr' ; import axios from 'axios' ; export default { name: 'ChatComponent' , setup() { const state = reactive({ accessToken: "" , userMessage: "" , messages: [], loginData: { userName: "" , password: "" }, privateMsg: { destUserName: "" , message: "" }, isConnected: false }); let connection = null ; const startConn = async function () { if (connection) { try { await connection.stop(); } catch (err) { console.error('停止连接时出错:' , err); } } const options = { skipNegotiation: true , transport: signalR.HttpTransportType.WebSockets, accessTokenFactory: () => state.accessToken }; connection = new signalR.HubConnectionBuilder() .withUrl('https://localhost:7122/Hubs/ChatHub' , options) .withAutomaticReconnect().build(); connection.onclose(error => { console.log('连接已关闭:' , error); state.isConnected = false ; }); connection.onreconnected(connectionId => { console.log('已重新连接,ID:' , connectionId); state.isConnected = true ; }); try { await connection.start(); state.isConnected = true ; connection.on ('ReceivePublicMessage' , (msg) => { console.log('收到公共消息:' , msg); state.messages.push({ content: msg, isPrivate: false }); }); connection.on ('ReceivePrivateMessage' , (srcUser, time, msg) => { console.log('收到私信:' , srcUser, time, msg); state.messages.push({ content: `${srcUser}在${time}发来私信: ${msg}`, isPrivate: true }); }); alert("登录成功,可以开始聊天了" ); } catch (err) { console.error('连接失败:' , err); alert('连接服务器失败: ' + err.message); state.isConnected = false ; } }; const loginClick = async function () { try { const resp = await axios.post('https://localhost:7122/Test/Login' , state.loginData); state.accessToken = resp.data; startConn(); } catch (err) { alert('登录失败: ' + err.message); } }; const ensureConnection = () => { if (!connection || connection.state !== signalR.HubConnectionState.Connected) { throw new Error('连接未建立或已断开,请先登录' ); } }; const txtMsgOnkeypress = async function (e ) { if (e.keyCode !== 13 ) return ; try { ensureConnection(); await connection.invoke("SendPublicMessage" , state.userMessage); state.userMessage = "" ; } catch (err) { alert(err.message); } }; const txtPrivateMsgOnkeypress = async function (e ) { if (e.keyCode !== 13 ) return ; const destUserName = state.privateMsg.destUserName; const msg = state.privateMsg.message; if (!destUserName || !msg) { alert('请输入目标用户和消息内容' ); return ; } try { ensureConnection(); const ret = await connection.invoke("SendPrivateMessage" , destUserName, msg); if (ret !== "ok" ) { alert(ret); } else { const time = new Date().toLocaleTimeString(); state.messages.push({ content: `你在${time}给${destUserName}发送了私信: ${msg}`, isPrivate: true }); } state.privateMsg.message = "" ; } catch (err) { alert(err.message); } }; onMounted(() => { return () => { if (connection) { connection.stop().catch (err => { console.error('关闭连接时出错:' , err); }); } }; }); return { state, loginClick, txtMsgOnkeypress, txtPrivateMsgOnkeypress }; } } </script> <style scoped > .private -message { color: blue; font-weight: bold; } </style>
在外部向集线器推送消息 不要再Hub做事务,违法单一原则。
可以在MVC控制器、托管服务等外部向客户端推送消息。 可以通过注入IHubContext<THub>来获取对集线器进行操作的服务。
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 private readonly ILogger<TestController> _logger; private readonly UserManager<User> _userManager; private readonly RoleManager<Role> _roleManager; private readonly IHubContext<ChatHub> hubContext; public TestController ( ILogger<TestController> logger, UserManager<User> userManager, RoleManager<Role> roleManager , IHubContext<ChatHub> hubContext ) { _logger = logger; _userManager = userManager; _roleManager = roleManager; this .hubContext = hubContext; } [HttpPost ] public async Task<ActionResult> AddUser (string userName,string password ) { User user = new User { UserName = userName }; await _userManager.CreateAsync(user,password); hubContext.Clients.All.SendAsync("PublicMsgReceived" , "欢迎" + userName + "加入我们" ); return Ok("创建成功" ); }