SignalR WebSocket通訊機制

2023-05-23 15:00:24

1、什麼是SignalR

  ASP.NET SignalR 是一個面向 ASP.NET 開發人員的庫,可簡化嚮應用程式新增實時 Web 功能的過程。 實時 Web 功能是讓伺服器程式碼在可用時立即將內容推播到連線的使用者端,而不是讓伺服器等待使用者端請求新資料。

  SignalR使用的三種底層傳輸技術分別是Web Socket, Server Sent Events 和 Long Polling, 它讓你更好的關注業務問題而不是底層傳輸技術問題。

  WebSocket是最好的最有效的傳輸方式, 如果瀏覽器或Web伺服器不支援它的話(IE10之前不支援Web Socket), 就會降級使用SSE, 實在不行就用Long Polling。

  (現在也很難找到不支援WebSocket的瀏覽器了,所以我們一般定義必須使用WebSocket)

 

2、我們做一個聊天室,實現一下SignalR前後端通訊

  由簡入深,先簡單實現一下 

  2.1 伺服器端Net5

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading.Tasks;

namespace ServerSignalR.Models
{
    public class ChatRoomHub:Hub
    {
        public override Task OnConnectedAsync()//連線成功觸發
        {
            return base.OnConnectedAsync();
        }

        public Task SendPublicMsg(string fromUserName,string msg)//給所有client傳送訊息
        {
            string connId = this.Context.ConnectionId;
            string str = $"[{DateTime.Now}]{connId}\r\n{fromUserName}:{msg}";
            return this.Clients.All.SendAsync("ReceivePublicMsg",str);//傳送給ReceivePublicMsg方法,這個方法由SignalR機制自動建立
        }
    }
}

  Startup新增

        static string _myAllowSpecificOrigins = "MyAllowSpecificOrigins";
        public void ConfigureServices(IServiceCollection services)
        {

            services.AddControllers();
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "ServerSignalR", Version = "v1" });
            });
            services.AddSignalR();
            services.AddCors(options =>
            {
                options.AddPolicy(_myAllowSpecificOrigins, policy =>
                {
                    policy.WithOrigins("http://localhost:4200")
                    .AllowAnyHeader().AllowAnyMethod().AllowCredentials();
                });
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseSwagger();
                app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "ServerSignalR v1"));
            }
            app.UseCors(_myAllowSpecificOrigins);
            app.UseHttpsRedirection();
            app.UseRouting();
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
                endpoints.MapHub<ChatRoomHub>("/Hubs/ChatRoomHub");
            });
        }

   2.2 前端Angular

    引入包

npm i --save @microsoft/signalr

    ts:

import { Component, OnInit } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { CookieService } from 'ngx-cookie-service';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {
  msg = '';
  userName='kxy'
  public messages: string[] = [];
  public hubConnection: signalR.HubConnection;

  constructor(
    private cookie: CookieService
  ) {this.hubConnection=new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:44313/Hubs/ChatRoomHub',
      {
        skipNegotiation:true,//跳過三個協定協商
        transport:signalR.HttpTransportType.WebSockets,//定義使用WebSocket協定通訊
      }
    )
    .withAutomaticReconnect()
    .build();
    this.hubConnection.on('ReceivePublicMsg',msg=>{
      this.messages.push(msg);
      console.log(msg);
    });
  }
  ngOnInit(): void {
  }
  JoinChatRoom(){
    this.hubConnection.start()
    .catch(res=>{
      this.messages.push('連線失敗');
      throw res;
    }).then(x=>{
      this.messages.push('連線成功');
    });
  }
  SendMsg(){
    if(!this.msg){
      return;
    }
    this.hubConnection.invoke('SendPublicMsg', this.userName,this.msg);
  }
}

  這樣就簡單實現了SignalR通訊!!!

  有一點值得記錄一下

    問題:強制啟用WebSocket協定,有時候發生錯誤會被遮蔽,只是提示找不到/連線不成功

    解決:可以先不跳過協商,偵錯完成後再跳過

3、引入Jwt進行許可權驗證

安裝Nuget包:Microsoft.AspNetCore.Authentication.JwtBearer

  Net5的,注意包版本選擇5.x,有對應關係

  Startup定義如下

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using ServerSignalR.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using JwtHelperCore;

namespace ServerSignalR
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        static string _myAllowSpecificOrigins = "MyAllowSpecificOrigins";
        public void ConfigureServices(IServiceCollection services)
        {

            services.AddControllers();
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "ServerSignalR", Version = "v1" });
            });
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.RequireHttpsMetadata = false;//是否需要https
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = false,//是否驗證Issuer
                        ValidateAudience = false,//是否驗證Audience
                        ValidateLifetime = true,//是否驗證失效時間
                        ValidateIssuerSigningKey = true,//是否驗證SecurityKey
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("VertivSecurityKey001")),//拿到SecurityKey
                    };
                    options.Events = new JwtBearerEvents()//從url獲取token
                    {
                        OnMessageReceived = context =>
                        {
                            if (context.HttpContext.Request.Path.StartsWithSegments("/Hubs/ChatRoomHub"))//判斷存取路徑
                            {
                                var accessToken = context.Request.Query["access_token"];//從請求路徑獲取token
                                if (!string.IsNullOrEmpty(accessToken))
                                    context.Token = accessToken;//將token寫入上下文給Jwt中介軟體驗證
                            }
                            return Task.CompletedTask;
                        }
                    };
                }
            );

            services.AddSignalR();

            services.AddCors(options =>
            {
                options.AddPolicy(_myAllowSpecificOrigins, policy =>
                {
                    policy.WithOrigins("http://localhost:4200")
                    .AllowAnyHeader().AllowAnyMethod().AllowCredentials();
                });
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseSwagger();
                app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "ServerSignalR v1"));
            }

            app.UseCors(_myAllowSpecificOrigins);
            app.UseHttpsRedirection();

            app.UseRouting();

            //Token  授權、認證
            app.UseErrorHandling();//自定義的處理錯誤資訊中介軟體
            app.UseAuthentication();//判斷是否登入成功
            app.UseAuthorization();//判斷是否有存取目標資源的許可權

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapHub<ChatRoomHub>("/Hubs/ChatRoomHub");
                endpoints.MapControllers();
            });
        }
    }
}

  紅色部分為主要關注程式碼!!!

  因為WebSocket無法自定義header,token資訊只能通過url傳輸,由後端獲取並寫入到上下文

  認證特性使用方式和http請求一致:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace ServerSignalR.Models
{
    [Authorize]//jwt認證
    public class ChatRoomHub:Hub
    {
        
        public override Task OnConnectedAsync()//連線成功觸發
        {
            return base.OnConnectedAsync();
        }

        public Task SendPublicMsg(string msg)//給所有client傳送訊息
        {
            var roles = this.Context.User.Claims.Where(x => x.Type.Contains("identity/claims/role")).Select(x => x.Value).ToList();//獲取角色
            var fromUserName = this.Context.User.Identity.Name;//從token獲取登入人,而不是傳入(前端ts方法的傳入引數也需要去掉)
            string connId = this.Context.ConnectionId;
            string str = $"[{DateTime.Now}]{connId}\r\n{fromUserName}:{msg}";
            return this.Clients.All.SendAsync("ReceivePublicMsg",str);//傳送給ReceivePublicMsg方法,這個方法由SignalR機制自動建立
        }
    }
}

  然後ts新增

  constructor(
    private cookie: CookieService
  ) {
    var token  = this.cookie.get('spm_token');
    this.hubConnection=new signalR.HubConnectionBuilder()
    .withUrl('https://localhost:44313/Hubs/ChatRoomHub',
      {
        skipNegotiation:true,//跳過三個協定協商
        transport:signalR.HttpTransportType.WebSockets,//定義使用WebSocket協定通訊
        accessTokenFactory:()=> token.slice(7,token.length)//會自動新增Bearer頭部,我這裡已經有Bearer了,所以需要截掉
      }
    )
    .withAutomaticReconnect()
    .build();
    this.hubConnection.on('ReceivePublicMsg',msg=>{
      this.messages.push(msg);
      console.log(msg);
    });
  }

4、私聊

  Hub

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ServerSignalR.Models
{
    [Authorize]//jwt認證
    public class ChatRoomHub:Hub
    {
        private static List<UserModel> _users = new List<UserModel>();
        public override Task OnConnectedAsync()//連線成功觸發
        {
            var userName = this.Context.User.Identity.Name;//從token獲取登入人
            _users.Add(new UserModel(userName, this.Context.ConnectionId));
            return base.OnConnectedAsync();
        }
        public override Task OnDisconnectedAsync(Exception exception)
        {
            var userName = this.Context.User.Identity.Name;//從token獲取登入人
            _users.RemoveRange(_users.FindIndex(x => x.UserName == userName), 1);
            return base.OnDisconnectedAsync(exception);
        }

        public Task SendPublicMsg(string msg)//給所有client傳送訊息
        {
            var fromUserName = this.Context.User.Identity.Name;
            //var ss = this.Context.User!.FindFirst(ClaimTypes.Name)!.Value;
            string str = $"[{DateTime.Now}]\r\n{fromUserName}:{msg}";
            return this.Clients.All.SendAsync("ReceivePublicMsg",str);//傳送給ReceivePublicMsg方法,這個方法由SignalR機制自動建立
        }

        public Task SendPrivateMsg(string destUserName, string msg)
        {
            var fromUser = _users.Find(x=>x.UserName== this.Context.User.Identity.Name);
            var toUser = _users.Find(x=>x.UserName==destUserName);
            string str = $"";
            if (toUser == null)
            {
                msg = $"使用者{destUserName}不線上";
                str = $"[{DateTime.Now}]\r\n系統提示:{msg}";
                return this.Clients.Clients(fromUser.WebScoketConnId).SendAsync("ReceivePrivateMsg", str);
            }
            str = $"[{DateTime.Now}]\r\n{fromUser.UserName}-{destUserName}:{msg}";
            return this.Clients.Clients(fromUser.WebScoketConnId,toUser.WebScoketConnId).SendAsync("ReceivePrivateMsg", str);
        }
    }
}

  TS:

//加一個監聽
    this.hubConnection.on('ReceivePublicMsg', msg => {
      this.messages.push('公屏'+msg);
      console.log(msg);
    });
    this.hubConnection.on('ReceivePrivateMsg',msg=>{
      this.messages.push('私聊'+msg);
      console.log(msg);
    });

//加一個傳送
    if (this.talkType == 1)
      this.hubConnection.invoke('SendPublicMsg', this.msg);
    if (this.talkType == 3){
      console.log('11111111111111');
      this.hubConnection.invoke('SendPrivateMsg',this.toUserName, this.msg);
    }

5、在控制器中使用Hub上下文

  Hub連結預設30s超時,正常情況下Hub只會進行通訊,而不再Hub裡進行復雜業務運算

  如果涉及複雜業務計算後傳送通訊,可以將Hub上下文注入外部控制器,如

namespace ServerSignalR.Controllers
{
    //[Authorize]
    public class HomeController : Controller
    {
        private IHubContext<ChatRoomHub> _hubContext;
        public HomeController(IHubContext<ChatRoomHub> hubContext)
        {
            _hubContext = hubContext;
        }
        [HttpGet("Welcome")]
        public async Task<ResultDataModel<bool>> Welcome()
        {
            await _hubContext.Clients.All.SendAsync("ReceivePublicMsg", "歡迎");
            return new ResultDataModel<bool>(true);
        }
    }
}

 

  

  至此,感謝關注!!