首頁

目前文章總數:157 篇

  

最後更新:2024年 12月 07日

0063. SignalR 橫向擴展部署 Server - Database Backplane 解決方案

日期:2024年 01月 28日

標籤: C# Asp.net Core Web MVC Nginx SignalR MySQL DataBase Chat Room

摘要:C# 學習筆記


應用所需:1. Visual Studio 2022 以上,支援.net Core 6 WebSite
     2. Mysql 資料庫 (自己本地)
解決問題:1. 當使用SignalR時,解決 SLB + Session Sticky(黏著),導致每個 Server 無法互通問題
範例檔案:SignalR Database Backplane 代碼
基本介紹:本篇分為三大部分。
第一部分:專案代碼實現說明
第二部分:Mysql資料表
第三部分:Demo成果






第一部分:專案代碼實現說明

Step 1:前言

上篇上篇 完成 SLB + Session Sticky 配置,最後遺留下 2台Web機器的 SignalR 無法互通問題。
此問題MSDN提出了建議解法,常態性解決 Server 實際部署受到環境影響的問題

※假設使用 SignalR Server 註冊到另一台 SignalR Server 會無法通過 SLB + Session Sticky 配置。
  因為 SLB 會動態分配到 Server,有可能自己連到自己造成 Crash

我們目標是讓 SignalR Server 會觀察 Database 的 Table 資料變化,自己完成推播訊息

Step 2:範例專案架構


1. 資料庫存取 使用 Dapper ,實現 IDbconnection 對 Mysql 的存取接口
2. 資料表存取 實現 SignalR Message 資料表的保存,包含讀取、更新、寫入聊天室訊息
3. 背景輪詢服務 每個 SignalR Server 都會自行對資料庫讀取,監控是否資料有變化,進行推播
4. Hub 實現客戶端發送訊息的接收,寫入資料庫,並且擴充JWT接收機制
5. 前端代碼 實現客戶端對SignalR Server 訂閱(Subscribe)
6. 配置 每個 Server 為了辨識,增加自己的代號、使用的 Port 號
7. 初始化配置 對資料庫存取的 Configure 配置、依賴注入上述所需功能



Step 3:資料庫存取

新建 MyDb 與 IMyDb ,透過 IDbconnection 擴充 Mysql 連接,以Master 提供存取

public class MyDb : IMyDb
{
    IDbConnection _master;
    public MyDb(IDbConnection dbConnection)
    {
        _master = dbConnection;
    }
    public IDbConnection Master
    {
        get { return _master; }
    }
}



Step 4:資料表存取

新建 SignalRMessagesRepository 與介面,主要有3個 Method,取得、更新、寫入聊天室

    public class SignalRMessagesRepository : ISignalRMessagesRepository
    {
        private IMyDb _myDb;
        public SignalRMessagesRepository(IMyDb myDb)
        {
            _myDb = myDb;
        }
        
        /// 1. 取得資料庫聊天室訊息變化
        public async Task<IEnumerable<SignalRMessagesEntity>> GetMessage(int siteNumber)
        {
            var sql = $@"
 SELECT	SignalRMessagesId,
	    UserId,
	    Message,
	    SiteValues,
	    CreateTime,
	    UpdateTime
  FROM signalrmessages
 WHERE ( SiteValues & @Number ) = 0

";
            return await _myDb.Master.QueryAsync<SignalRMessagesEntity>(
                sql, new { Number = siteNumber });
            
        }

        /// 2. SignalR Server 發送後,更新自己的紀錄
        public async Task UpdateSended(string ids, int siteNumber)
        {
            try
            {
                if (_myDb.Master.State == System.Data.ConnectionState.Closed)
                {
                    _myDb.Master.Open();
                }
                
                using (var transaction = _myDb.Master.BeginTransaction())
                {
                    try
                    {
                        var sql = $@"
 UPDATE signalrmessages
    SET SiteValues = SiteValues + @Number ,
        UpdateTime = NOW()
  WHERE SignalRMessagesId IN (
  {ids}
)
";
                        await _myDb.Master.ExecuteAsync(sql, new { Number = siteNumber });

                        // 提交事務
                        transaction.Commit();
                    }
                    catch (Exception ex)
                    {
                        // 如果有異常發生,進行回滾
                        transaction.Rollback();
                    }
                }
            }
            catch (Exception ex)
            { 
            }
        }

        /// 3. 聊天室發送訊息時,產生一筆紀錄到資料表中
        public async Task InsertMessage(string userId, string message)
        {
            var sql = $@"
INSERT INTO signalrmessages (UserId, Message) 
VALUES (@USERID, @MESSAGE); 
";
            await _myDb.Master.ExecuteAsync(sql, 
                new {
                    USERID = userId,
                    MESSAGE = message
                }
             );
        }
    }



Step 5:背景輪詢服務

背景服務,先取得 Server 設定檔案中的編號,然後從 ExecuteAsync() 中不斷觀察資料是否有變化
※使用 SignalR 一定要用非同步,才符合即時通訊,不做等待

public class PageBackroundUpdaterService : BackgroundService
{
    private readonly IHubContext<UpdateHub> _hubContext;        
    private readonly IConfiguration _configure;
    private readonly IServiceProvider _serviceProvider;
    private readonly IMemoryCache _memoryCache;
    // 1. 配置變數,版本號、間隔時間
    private int _siteNumber = 0;
    private readonly int _second = 2;//2秒        
    public PageBackroundUpdaterService(IHubContext<UpdateHub> hubContext,
        IServiceProvider serviceProvider, IMemoryCache memoryCache)                
    {
        _memoryCache = memoryCache;
        _serviceProvider = serviceProvider;
        _hubContext = hubContext;
        _configure = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory())
                                  .AddJsonFile("appsettings.json")
                                  .Build();
        //2. 求出自己站點編號的 2 ^ (SiteNumber-1) 值 EX: 編號1=1 / 編號2=2 / 編號3=4
        _siteNumber = (int)Math.Pow(2, (_configure.GetValue("SiteNumber", 1) - 1));  
    }
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                // 3-1. Singalton 的方式避免不斷 create scope 浪費資源
                if (!_memoryCache.TryGetValue("SignalRMessagesRepository", out ISignalRMessagesRepository _signalRMessages))
                {
                    using (var scope = _serviceProvider.CreateScope())
                    {
                        _signalRMessages = scope.ServiceProvider.GetRequiredService<ISignalRMessagesRepository>();
                        // 3-2. 將服務存入快取,可以自行調整快取的過期時間
                        _memoryCache.Set("SignalRMessagesRepository", _signalRMessages, TimeSpan.FromSeconds(60));
                    }
                }
                // 4. 讀取資料庫是否有未處裡的資料
                var data = await _signalRMessages.GetMessage(_siteNumber);
                if (data.Any())
                {
                    foreach (var message in data)
                    {
                        // 5-1. 組成回傳給用戶 ※如果有需要
                        message.Message = $"siteNumber: [{_siteNumber}]" + message.Message;
                        // 5-2. 推播訊息給客戶端
                        await _hubContext.Clients.All.SendAsync("SendUpdate", message);
                    }
                    // 5-3. 回傳成功寫回資料庫更新
                    var ids = string.Join(", ", data.Select(item => item.SignalRMessagesId));
                    await _signalRMessages.UpdateSended(ids, _siteNumber);
                }
                // 6. 增加區隔時間,避免CPU無法處理                
                await Task.Delay(_second, stoppingToken);
            }
        }
        catch (Exception ex)
        {
        }
    }
}



Step 6:Hub

UpdateHub 調整原本接收客戶端傳送資訊來的 SendMessage Method,只響應收到訊息
推播一律由 SendUpdate 處理

public class UpdateHub : Microsoft.AspNetCore.SignalR.Hub
{        
    private readonly IConfiguration _configure;
    private static string _Site = string.Empty;
    private int _siteNumber = 0;
    private readonly ISignalRMessagesRepository _signalRMessagesRepository;
    public UpdateHub(ISignalRMessagesRepository signalRMessagesRepository)
    {
        _signalRMessagesRepository = signalRMessagesRepository;
        _configure = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory())
                                              .AddJsonFile("appsettings.json")
                                              .Build();
        _Site = _configure.GetValue("Site", string.Empty);
        //1. 求出自己站點編號的 2 ^ (SiteNumber-1) 值 EX: 編號1=1 / 編號2=2 / 編號3=4
        _siteNumber = (int)Math.Pow(2, (_configure.GetValue("SiteNumber", 1) - 1));
    }

    //事件名稱SendUpdate 行為:回傳message
    public async Task SendUpdate(string message)
    {
        await Clients.All.SendAsync("SendUpdate", message);
    }

    /// <summary>
    /// 接收前端傳送訊息
    /// </summary>                
    public async Task SendMessage(string user, string message)
    {
        //2. 接收前端傳來的聊天訊息
        var connectionId = Context.ConnectionId;
        var jwtToken = Context.GetHttpContext()?.Request.Query["access_token"];
        //3. 寫入資料庫 觸發SignalR 的 Database Backplane
        await _signalRMessagesRepository.InsertMessage(connectionId, $@"{user}:{message}");
        //4. 回報前端,後端 Server 有收到訊息了
        await Clients.All.SendAsync("ReceiveMessage", user, $@"寫入Mysql資料庫成功:" + message);
    }
}



Step 7:前端代碼

前端在 JavaScript 代碼中 => 監聽 SendUpdate 事件,調整為後端傳來的格式

<script>
        // 3. 預設頁面值
        var updateContainer = document.getElementById("updateContainer");
        updateContainer.innerHTML = `<p>New update: 初始化 </p>`;

        // 4. 創建 SignalR 連接
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("UpdateHub", { accessTokenFactory: () => "I am jwtToken" })
            .build();

        // 5. 監聽 SendUpdate 事件
        connection.on("SendUpdate", (message) => {
            debugger;
            const updateContainer = document.getElementById("updateContainer");
            updateContainer.innerHTML += `<p>${message.message}</p>`;
        });

         // 6. 訂閱可接收訊息
        connection.on("ReceiveMessage", function (user, message) {
            //接收到後端SignalR Server告知呼叫成功,讓 Backplane發送到 => 5. 監聽 SendUpdate 事件
            console.log(user + " says: " + message);           
        });

        // 7. 啟動
        connection.start()
            .then(() => {
                console.log("連接 SignalR 成功");
            })
            .catch((error) => {
                console.log("錯誤訊息:" + error);
            });

         // 8. 發送訊息到Hub 伺服器上
         function sendMessage() {
             var user = document.getElementById("userInput").value;
             var message = document.getElementById("messageInput").value;
             connection.invoke("SendMessage", user, message).catch(function (err) {
                 console.error("Error invoking SendMessage: " + err.toString());
             });
         }
</script>



Step 8:配置

appsettings.json 增加 SiteNumber、ConnectionStrings 完成辨識 Server 與 Mysql連線字串的工作

// 4. 增加注入,配置 Mysql 連線字串 / SignalR 
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "SiteNumber": "1",
  "Site": "WebStie Port: XXXX",
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=127.0.0.1;Port=3306;Database=signalrdb;User={您的帳號};Password={您的密碼};"
  }
}



Step 9:初始化配置

Program.cs 增加注入、Confgiure 配置

// 4. 增加注入,配置 Mysql 連線字串 / SignalR 
builder.Services.AddScoped<IDbConnection>(provider =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    return new MySqlConnection(connectionString);
});
builder.Services.AddScoped<IMyDb, MyDb>();
builder.Services.AddScoped<ISignalRMessagesRepository, SignalRMessagesRepository>();





第二部分:Mysql資料表

Step 1:Table Schema

實現 Database Backplane 的基本聊天室,只需要一張表即可完成
SiteValues:每個機器會有編號使用2進制做自己的編號,用於辨識自己是否發送訊息

CREATE TABLE `signalrmessages` (
	`SignalRMessagesId` INT(11) NOT NULL AUTO_INCREMENT COMMENT '訊息Id',
	`UserId` VARCHAR(200) NOT NULL DEFAULT '0' COMMENT '使用者Id' COLLATE 'utf8mb4_general_ci',
	`Message` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '聊天內容' COLLATE 'utf8mb4_general_ci',
	`SiteValues` INT(11) NOT NULL DEFAULT '0' COMMENT '站點發送紀錄',
	`CreateTime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
	`UpdateTime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新時間',
	PRIMARY KEY (`SignalRMessagesId`) USING BTREE
)
COMMENT='SignalR聊天訊息'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB;




第三部分:驗證結果

Step 1:部署代碼

將範例代碼部署到2個 Web 資料夾中

Step 2:調整配置 - Web1

Web1的 appsetting.json 如下

Step 3:調整配置 - Web2

Web2的 appsetting.json 如下

Step 4:啟動Web

對 Web1 輸入指令

dotnet SingalRWebsiteUseScaleOutAndBackPlateDatabaseExample.dll --urls=http://localhost:6001


對 Web2 輸入指令

dotnet SingalRWebsiteUseScaleOutAndBackPlateDatabaseExample.dll --urls=http://localhost:6002



Step 5:開啟 Nginx

啟動 Nginx
這邊的配置是基於上一篇 SLB + Session Sticky

Step 6:開啟聊天室 - 完成

進入配置好的首頁,並且開啟兩個分頁(第1個分頁連 web1 ; 第2個分頁連 web2)
輸入一些簡單文字,可以發現完成 SLB + Session Sticky 下的 SignalR 聊天室互動
未來要部署多個機器時,只要改一下 appsetting.json 的代號,即可部署完成


資料表的變化如下: