.net6 Signalr+Vue3 的运用(上) 电脑版发表于:2023/1/31 17:37 ![.netcore](https://img.tnblog.net/arcimg/hb/c857299a86d84ee7b26d181a31e58234.jpg ".netcore") >#.net6 Signalr+Vue3 的运用(上) [TOC] 什么是 SignalR? ------------ tn2>ASP.NET Core SignalR 是一个开放源代码库,可用于简化向应用添加实时 Web 功能。 实时 Web 功能使服务器端代码能够将内容推送到客户端。 什么是 Hubs? ------------ tn2>SignalR 使用Hubs在客户端和服务器之间通信。 Hub 是一种高级管道,允许客户端和服务器相互调用方法。 SignalR 自动处理跨计算机边界的调度,并允许客户端调用服务器上的方法,反之亦然。 可以将强类型参数传递给方法,从而支持模型绑定。 SignalR 提供两种内置中心协议:基于 JSON 的文本协议和基于 MessagePack 的二进制协议。 也叫做集线器。 SignalR后端项目 ------------ ### 创建项目 tn>创建signalrtest项目,我在创建时选择了OpenAPI,没有选择Https。 ![](https://img.tnblog.net/arcimg/hb/a258006a64a44de3a3aa4e82e6613da3.png) ### 创建强类型Hubs tn2>这样做的好处是:方便客户端调用服务器的方法时,拼音不会报错。 接下来我们在`HubClients`目录下定义客户端的调用方法的接口`IChatClient`,并且只定义`SendAll`方法。 ```csharp public interface IChatClient { Task SendAll(object message); } ``` tn2>然后我们在`Hubs`的目录下创建`ChatHub`集线器。 并定义了一个`SendMessage`的方法向所有的用户发送消息,并对客户端连接和断开状态做了一个日志记录。 ```csharp public class ChatHub : Hub<IChatClient> { ILogger<ChatHub> _logger; public ChatHub(ILogger<ChatHub> logger, CommonService common) { _logger = logger; _common = common; } readonly CommonService _common; /// <summary> /// 客户端连接服务端 /// </summary> /// <returns></returns> public override Task OnConnectedAsync() { var id = Context.ConnectionId; _logger.LogInformation($"Client ConnectionId=> [[{id}]] Already Connection Server!"); return base.OnConnectedAsync(); } /// <summary> /// 客户端断开连接 /// </summary> /// <param name="exception"></param> /// <returns></returns> public override Task OnDisconnectedAsync(Exception exception) { var id = Context.ConnectionId; _logger.LogInformation($"Client ConnectionId=> [[{id}]] Already Close Connection Server!"); return base.OnDisconnectedAsync(exception); } /** * 测试 * */ /// <summary> /// 给所有客户端发送消息 /// </summary> /// <returns></returns> public async Task SendMessage(string data) { Console.WriteLine("Have one Data!"); await Clients.All.SendAll(_common.SendAll(data)); } } ``` tn2>这里有一个`CommonService`它定义在`HubService`的目录下面,里面只有一个`SendAll`方法,该方法只是在原有的消息基础上添加`Hello`和随机数。 内容如下所示: ```csharp public class CommonService { internal object SendAll(string data) { return $"Hello {new Random().Next(0, 100)} {data} "; } } ``` ### 配置SignalR tn2>我们可以通过`AddSignalR`方法来注册SignalR相关服务,并通过`AddJsonProtocol`启用SignalR 的 JSON 协议。 `PayloadSerializerOptions`是一个`System.Text.JsonJsonSerializerOptions`对象,`PropertyNamingPolicy`属性为`null`表示保持属性名称不变(是否区分大小写,无所谓)。 ```csharp builder.Services .AddSignalR() .AddJsonProtocol(options => { options.PayloadSerializerOptions.PropertyNamingPolicy = null; }) ; builder.Services.TryAddSingleton(typeof(CommonService)); ``` tn>如果想使用`NewtonsoftJson`,可以将`AddJsonProtocol`方法改为`AddNewtonsoftJsonProtocol`。 tn2>然后我们通过`MapHub`方法,加载路由路径`/ChatHub`由`ChatHub`处理,并设置传输的方式可以使用`WebSockets`与`LongPolling`。 ```csharp app.UseDefaultFiles(); app.UseStaticFiles(); app.MapHub<ChatHub>("/ChatHub", options => { options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling; }); ``` SignalR前端地址项目 ------------ tn2>首先我们创建一个Vue3.0的项目,并安装`@aspnet/signalr`包。 ```bash vue create signalrtestvue cd signalrtestvue npm install @aspnet/signalr ``` tn>目前`aspnet/signalr`包已经弃用了,推荐使用`@microsoft/signalr`包,<a href="https://learn.microsoft.com/zh-cn/aspnet/core/signalr/javascript-client?view=aspnetcore-6.0&tabs=visual-studio">更多请参考</a> tn2>然后我们在`src/utils`目录下编写`signalR.js`文件。 请修改你本地的signalr服务器的连接地址。 ```javascript import * as signalR from '@aspnet/signalr' const url = "http://localhost:5102/ChatHub" const signal = new signalR.HubConnectionBuilder() .withUrl(url, { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets }) .configureLogging(signalR.LogLevel.Information) .build() signal.on('SendAll', (res) => { console.log(res, '收到消息') }) signal.start().then(() => { if (window.Notification) { if (Notification.permission === 'granted') { console.log('允许通知') } else if (Notification.permission !== 'denied') { console.log('需要通知权限') Notification.requestPermission((permission) => { console.log("权限通知",permission) }) } else if (Notification.permission === 'denied') { console.log('拒绝通知') } } else { console.error('浏览器不支持Notification') } console.log('连接成功') }) signal.onclose((err)=>{ console.log("连接已经断开 执行函数onclose",err) }) export default { signal } ``` tn2>通过`HubConnectionBuilder`来连接到我们的Hubs。 `withUrl`设置连接地址。 `configureLogging`设置日志记录的级别。 接下来讲将相关的事件方法。 | 事件名 | 描述 | | ------------ | ------------ | | `on` | 接收服务器返回的方法进行处理。 | | `start` | 启动Hubs的服务器连接。 | | `onclose` | 服务器关闭触发的事件回调。 | | `stop` | 关闭Hubs的服务器连接。 | | `invoke` | 调用服务器的方法。 | tn2>然后我们在`main.js`下进行全局启用`signalr`。 ```javascript import { createApp } from 'vue' import App from './App.vue' import signalr from './utils/signalR' const app = createApp(App) app.config.globalProperties.$signalr = signalr.signal; app.mount('#app') ``` tn2>在`components`目录下找到`HellloWorld.vue`并添加一个按钮向服务器端的`SendMessage`发送消息。 ```javascript <template> <div class="hello"> <h1>{{ msg }}</h1> <button @click="onClickButton" >获取</button> </div> </template> <script> export default { name: 'HelloWorld', props: { msg: String }, data() { return { }; }, methods: { onClickButton() { console.log(this.$signalr) this.$signalr .invoke('SendMessage', "hmy") .catch(function(err) {return console.error(err) }) } } } </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> h3 { margin: 40px 0 0; } ul { list-style-type: none; padding: 0; } li { display: inline-block; margin: 0 10px; } a { color: #42b983; } </style> ``` 运行测试 ------------ ```bash npm run serve ``` tn2>通过点击获取按钮来触发事件。 ![](https://img.tnblog.net/arcimg/hb/bb9e994765cf4d94a8d2b3618982e7f1.png) 由服务器端发向客户端 ------------ tn2>我们这里添加一个`ClientHubController`控制器并通过请求发送`SendMessage`消息。 ```csharp [ApiController] [Route("[controller]")] public class ClientHubController : ControllerBase { private readonly ILogger<ClientHubController> _logger; public ClientHubController( ILogger<ClientHubController> logger ) { _logger = logger; } [HttpGet(Name = "SendMessage")] public async Task SendMessage(string date, [FromServices] IHubContext<ChatHub, IChatClient> hubContext) { await hubContext.Clients.All.SendAll(date); } } ``` tn>这里我使用的是强类型`IHubContext<,>`,如果是弱类型直接`IHubContext`也是可以的。 tn2>运行测试。 ![](https://img.tnblog.net/arcimg/hb/5af5ceef786f499c807203a1e8b07639.png) ![](https://img.tnblog.net/arcimg/hb/dd5365b6bb964abf8b59ceb61dafc129.png) tn2>如果我们希望在第一个页面点击获取按钮,并返回一个发送消息的结果,我们可以通过`Clients.Caller`来将消息返回给调用方。 首先在`CommonService`中创建一个发送给调用方的消息。 ```csharp internal object SendCaller() => "Send Successful!"; ``` tn2>再在SendMessage中进行调用。 ```csharp public async Task SendMessage(string data) { Console.WriteLine("Have one Data!"); await Clients.All.SendAll(_common.SendAll(data)); await Clients.Caller.SendAll(_common.SendCaller(data)); } ``` ![](https://img.tnblog.net/arcimg/hb/c5e885e6b8b94bf183c6a44da80b61d6.png) 指定客户端发送消息 ------------ tn2>如果我们想给指定的客户端发送消息,首先我们需要获取所有连接服务器的ID,这里我做一个简易的集合进行存储,并且在`OnDisconnectedAsync`与`OnConnectedAsync`事件中进行增加与删除。 ```csharp public static class UserIdsStore { static HashSet<string> Ids = new HashSet<string>(); } ``` ```csharp /// <summary> /// 客户端连接服务端 /// </summary> /// <returns></returns> public override Task OnConnectedAsync() { var id = Context.ConnectionId; // 添加用户ID UserIdsStore.Ids.Add(id); _logger.LogInformation($"Client ConnectionId=> [[{id}]] Already Connection Server!"); return base.OnConnectedAsync(); } /// <summary> /// 客户端断开连接 /// </summary> /// <param name="exception"></param> /// <returns></returns> public override Task OnDisconnectedAsync(Exception exception) { var id = Context.ConnectionId; // 删除用户ID UserIdsStore.Ids.Remove(id); _logger.LogInformation($"Client ConnectionId=> [[{id}]] Already Close Connection Server!"); return base.OnDisconnectedAsync(exception); } ``` tn2>然后我们可以在`IChatClient`中添加`SendCustomUserMessage`接口方便客户端接收。 ```csharp Task SendCustomUserMessage(object message); ``` tn2>在`ClientHubController`中添加两个接口。 ```csharp /// <summary> /// 获取所有的用户 /// </summary> /// <returns></returns> [HttpGet("GetAllUserIds", Name = "GetAllUserIds")] public string[] GetAllUserIds() { return UserIdsStore.Ids.ToArray(); } /// <summary> /// 发送指定的消息给指定的客户端 /// </summary> /// <param name="userid"></param> /// <param name="date"></param> /// <param name="hubContext"></param> /// <returns></returns> [HttpGet("SendCustomUserMessage", Name = "SendCustomUserMessage")] public async Task<IActionResult> SendCustomUserMessage( string userid, string date, [FromServices] IHubContext<ChatHub, IChatClient> hubContext ) { await hubContext.Clients.Client(userid).SendCustomUserMessage(date); return Ok("Send Successful!"); } ``` tn2>最后我们需要在我们的前端客户端中的`SignalR.js`文件,创建`SendCustomUserMessage`事件的处理。 ```javascript signal.on('SendCustomUserMessage', (res) => { console.log(res, '收到消息') }) ``` tn2>接下来运行项目,重新启动一下前端项目进行测试。 首先获取一下所有的ID。 ![](https://img.tnblog.net/arcimg/hb/e55fd4343d4e4b5693905432886a804d.png) tn2>然后我们只取其中的ID调用`SendCustomUserMessage`接口来进行发送消息。 ![](https://img.tnblog.net/arcimg/hb/c97bd2cb913244a8864049392f121ddd.png) ![](https://img.tnblog.net/arcimg/hb/e97781fe437e4f1cb4962d901a13ddb4.png) 授权访问服务器 ------------ tn2>在我们将SignalR的用户之前,首先需要授权,这里我们可以搞一个JWT的方便。 首先安装`Microsoft.AspNetCore.Authentication.JwtBearer`包 ![](https://img.tnblog.net/arcimg/hb/8dcb520ade654427adf4d4e5bda19f08.png) tn2>添加`MyJWTBearer`类,自定义我们的JWT的Token生成。 ![](https://img.tnblog.net/arcimg/hb/6d9ad8046f064754872631fa5ae37fb6.png) ```csharp public static class MyJWTBearer { public static readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray()); public static readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler(); public static string GenerateToken(HttpContext httpContext) { // 请求时传入的用户参数为NameIdentifier claim的值 var claims = new[] { new Claim(ClaimTypes.NameIdentifier, httpContext.Request.Query["user"]) }; // 签名凭据 var credentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.HmacSha256); // 生成JWT Token var token = new JwtSecurityToken("SignalRTestServer", "SignalRTests", claims, expires: DateTime.UtcNow.AddSeconds(60), signingCredentials: credentials); return JwtTokenHandler.WriteToken(token); } public static void AddMyJWTBearerAuth(this IServiceCollection services) { // 添加自定义授权 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { LifetimeValidator = (before, expires, token, parameters) => expires > DateTime.UtcNow, ValidateAudience = false, ValidateIssuer = false, ValidateActor = false, ValidateLifetime = true, IssuerSigningKey = MyJWTBearer.SecurityKey }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { // 当我们收到消息时,去获取请求中的access_token字段 var accessToken = context.Request.Query["access_token"]; // 如果没有就去头上找,找到了就放入我们context.token中 if (!string.IsNullOrEmpty(accessToken)) { context.Token = accessToken; } return Task.CompletedTask; } }; }); } } ``` tn>这里在生成的时候多了一个`NameIdentifier`Claim Type的类型,这个类型是当我们发消息调用`User()`方法时需要验证的时候需要的,当然后续我们可以自定义其中的逻辑进行判断。(待会再讲) tn2>接下来我们配置一下自定义的授权。 ```csharp // 添加授权服务 builder.Services.AddMyJWTBearerAuth(); ... app.UseAuthentication(); app.UseAuthorization(); // 授权路径 app.MapGet("generatetoken", c => c.Response.WriteAsync(MyJWTBearer.GenerateToken(c))); ``` tn2>测试一下 http://localhost:5102/generatetoken?user=bob ![](https://img.tnblog.net/arcimg/hb/5c944d84411647048768cb33b62c6f40.png) ![](https://img.tnblog.net/arcimg/hb/ea7b9274fb8d4d0d9fa43e09ae2caba3.png) tn2>接下来我们修改一下前端项目,我们想在连接Signalr调用前先调用Token,再使用Token进行连接我们的服务器。 所以首先安装一下`axios`。 ```bash npm install axios ``` tn2>然后我们需要修改一下`main.js`,删除以前自动连接的`signalR.js`的引用,并且的添加`axios`的引用。 ```javascript import { createApp } from 'vue' import App from './App.vue' import axios from 'axios' const app = createApp(App) axios.defaults.baseURL = "http://localhost:5102" app.config.globalProperties.$http = axios; app.mount('#app') ``` tn2>然后修改`HelloWorld.vue`页面,在填写好用户名后登录。 ```javascript <template> <div class="hello"> <h1>{{ msg }}</h1> <div> UserName: <input type="text" v-model="username" > <button @click="onConnectionClickButton" >连接</button> </div> <div> Message: <input type="text" v-model="message" > <button @click="onClickButton" >发送</button> </div> </div> </template> <script> import * as signalR from '@aspnet/signalr' const url = "http://localhost:5102/ChatHub" const signal = new signalR.HubConnectionBuilder() .withUrl(url, { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets, accessTokenFactory: () => "" }) .configureLogging(signalR.LogLevel.Information) .build() signal.on('SendCustomUserMessage', (res) => { console.log(res, '收到消息') }) signal.on('SendAll', (res) => { console.log(res, '收到消息') }) export default { name: 'HelloWorld', props: { msg: String }, data() { return { username: "", message: "" } }, methods: { onClickButton() { var e = this signal .invoke('SendMessage', e.message) .catch(function(err) {return console.error(err) }) }, async onConnectionClickButton() { // 首先我们去获取Token let name = this.username let result = await this.$http.get(`generatetoken?user=${name}`) if (result.status !== 200) { console.error("Token 请求失败") return } var token = result.data console.log("获得Token",token) // 放入token signal.connection.options.accessTokenFactory = () => token // 然后我们请求连接signalr signal.start().then(() => { console.log('连接成功') }) }, } } </script> <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> h3 { margin: 40px 0 0; } ul { list-style-type: none; padding: 0; } li { display: inline-block; margin: 0 10px; } a { color: #42b983; } </style> ``` ### 跨域问题 tn2>这里涉及到跨域问题。 ![](https://img.tnblog.net/arcimg/hb/deb3196980c4492eb5b4dbc80c5b433a.png) ```csharp string MyAllowSpecificOrigins = "_signalrtestcores"; builder.Services.AddCors(options => { options.AddPolicy(MyAllowSpecificOrigins, builder => builder.AllowAnyOrigin() .AllowAnyHeader() .WithMethods("GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS") ) ; }); ... app.UseCors(MyAllowSpecificOrigins); ``` tn2>然后再为`ChatHub`添加授权特性`Authorize`,表示访问该资源需要进行授权。 ```csharp [Authorize] public class ChatHub : Hub<IChatClient> ``` tn2>接下来我们运行测试一下,输入用户名后,点击连接,同时也是可以发送消息的。 ![](https://img.tnblog.net/arcimg/hb/d9df067bb2bf481d9a87ad8fb9e434df.png) tn2>可以看到我们已经成功的进行了授权。 ### 断开后重连 tn2>我们与Signalr服务器断开连接后,我们希望进行重新连接,不用每次都刷新页面。可以在`onclose`事件里面进行设置。 ```javascript <script> import * as signalR from '@aspnet/signalr' const url = "http://localhost:5102/ChatHub" const signal = new signalR.HubConnectionBuilder() .withUrl(url, { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets, accessTokenFactory: () => "" }) .configureLogging(signalR.LogLevel.Information) .build() signal.on('SendCustomUserMessage', (res) => { console.log(res, '收到消息') }) signal.on('SendAll', (res) => { console.log(res, '收到消息') }) export default { name: 'HelloWorld', props: { msg: String }, data() { return { username: "bob", message: "", timer: null, connectionstatus: "init" } }, mouted() { this.timer = setInterval(()=>{},500) }, destoryed() { this.clearInterval(this.timer) }, methods: { onClickButton() { var e = this signal .invoke('SendMessage', e.message) .catch(function(err) {return console.error(err) }) }, async onConnectionClickButton() { try{ // 首先我们去获取Token let name = this.username let result = await this.$http.get(`generatetoken?user=${name}`) if (result.status !== 200) { console.error("Token 请求失败") return } var token = result.data console.log("获得Token",token) var e = this // onClose的定义 if (e.connectionstatus == "init") { signal.onclose(() => { e.connectionstatus = "close" signal.stop() console.log("连接已关闭") e.retryConnection() }); } // 放入token signal.connection.options.accessTokenFactory = () => token // 然后我们请求连接signalr signal.start().then(() => { if (e.connectionstatus == "close") { clearInterval(e.timer) } e.connectionstatus = "start" console.log('连接成功') }) }catch(e){ if (e.code == "ERR_NETWORK") { console.log("Token 请求失败") } } }, retryConnection() { var e = this if (this.connectionstatus == "init" || this.connectionstatus == "start") { return }else if(this.connectionstatus == "close"){ console.log("正在重试连接...") this.timer = setInterval(()=>{ e.onConnectionClickButton() },10000) return } } } } </script> ``` ![](https://img.tnblog.net/arcimg/hb/8eff0dfa3d024135a4b6b5dace6b15ae.png) tn2>这样就不用担心服务器挂了还是没挂了。