首頁

目前文章總數:157 篇

  

最後更新:2024年 12月 07日

0068. SignalR 橫向擴展部署 Server - RabbitMQ Backplane 解決方案

日期:2024年 06月 16日

標籤: C# Nginx SignalR RabbitMQ Linux Ubuntu Asp.net Core Web MVC

摘要:C# 學習筆記


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






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

Step 1:前言

上篇上篇 完成 SLB + Session Sticky 配置,最後遺留下 2台Web機器的 SignalR 無法互通問題。 基於 .Net 提供 3 種解法,常態性解決 Server 實際部署受到環境影響的問題,這篇是第 4 種即使非 .Net 也常常使用的一種做法 RabbitMQ Backplane

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

我們目標是啟用 RabbitMQ Backplane 模式,管理所有連接的 SignalR Server,當需要推播時,由 RabbitMQ Exchange 的 FanOut 模式完成推播給所有用戶。
※RabbitMQ Windows 安裝可參考此篇

Step 2:範例專案架構


1. Web資源檔 將 SignalR 8.0.0.js 下載,讓 .cshtml 引用,使前端網站可以註冊 SignalR 服務
2. 背景服務 註冊 RabbitMQ 的接收事件,進行推播 SignalR 資訊
3. RabbitMQ RabbitMQ 實際邏輯、釋放資源時的事件
4. SignalR Hub SignalR Hub 開出的接口,並且提供前端 Publish 與後端 Subscribe
5. 前端頁面 提供聊天室註冊 SignalR 並且可發送訊息,與接收訊息功能
6. 配置 每個 Server 為了辨識,增加自己的代號、使用的 Port 號、RabbitMQ Server 位置
7. 初始化配置 依賴注入 SiganlR、RabbitMQ、背景服務



Step 3:Web資源檔

可從微軟給定的CDN SignalR 8.0.0 的位置下載,並放進自己的專案中,可在不對外,並且只能在內部網路的狀況下使用 SignalR
SignalR 8.0.0 下載

Step 4:背景服務

建立一個 PageBackroundUpdaterService ,在初始化配置時引用
關鍵在 _rabbitMqService.StartReceiving() 註冊了事件,當有 RabbitMQ 消息推送時可以收到資料。

public class PageBackroundUpdaterService : BackgroundService
{
    private readonly IHubContext<UpdateHub> _hubContext;
    private readonly IConfiguration _configure;        
    private readonly RabbitMqService _rabbitMqService;
    // 1. 配置變數,版本號、間隔時間
    private int _siteNumber = 0;

    public PageBackroundUpdaterService(IHubContext<UpdateHub> hubContext,
        RabbitMqService rabbitMqService)
    {
        _rabbitMqService = rabbitMqService;            
        _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));

        //3-1. 設定 RabbitMQ 消費者(Consumer)的工作
        _rabbitMqService.StartReceiving(publishMessage => {
            //3-2. 從 RabbitMQ 收到訊息後,推播給自己 Server 下的所有用戶
            Task.Run(() => _hubContext.Clients.Group("groupName").SendAsync("ReceiveMessage", publishMessage));
        });
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
    }
}



Step 5:RabbitMQ

RabbitMqService.cs 檔案實現了以下:

SendMessage 生產者:發送訊息到 RabbitMQ 的 Exchange 上
StartReceiving 消費者:從 RabbitMQ 接收生產者推送的資料
Dispose 結束網站生命週期時釋放資源,並且呼叫 RabbitMqServiceHostedService.cs 的方法


public class RabbitMqService
{
    private readonly IConnection _connection;
    private readonly IModel _channel;        
    private readonly string _exchangeName = "my-fanout-exchange";

    /// <summary>
    /// 1-1. 建立基本資訊
    /// </summary>
    public RabbitMqService()
        {
            var factory = new ConnectionFactory
            {
                HostName = "127.0.0.1", // RabbitMQ 主機名
                UserName = "admin",
                Password = "123fff",
                Port = 5672 // RabbitMQ 連接埠
            };
            // 1-2. 初始化 RabbitMQ 連線
            _connection = factory.CreateConnection();
            _channel = _connection.CreateModel();

            // 1-3. 選擇 Fanout 類型的 Exchange ,註冊此 Exchange 的 RabbitMQ 客戶端(Queue),都會收到廣播訊息
            _channel.ExchangeDeclare(exchange: _exchangeName, type: ExchangeType.Fanout);
        }

    /// <summary>
    /// 2-1. 增加一個接口,可以將資料發送到 RabbitMQ
    /// </summary>        
    public void SendMessage(string message)
    {
        // 2-2. 配置傳送到 RabbitMQ 上 Queue 的內容            
        var body = Encoding.UTF8.GetBytes(message);            

        // 2-3. FanOut 基本配置 (關注 Exchange 位置)
        _channel.BasicPublish(exchange: _exchangeName,
                              routingKey: string.Empty,
                              basicProperties: null,
                              body: body);
    }

    /// <summary>
    /// 3-1. 增加一個接口,可以接收RabbitMQ 訊息
    /// </summary>        
    public void StartReceiving(Action<string> handleMessage)
    {   
        // 3-2. 建立一個匿名委派
        var consumer = new EventingBasicConsumer(_channel);
        consumer.Received += (sender, ea) =>
        {
            // 每當從 Exchange 上收到資料時都會自動觸發 Received 事件
            var message = Encoding.UTF8.GetString(ea.Body.ToArray());
            // 並將資料回傳給 BackgroundService 上的註冊事件
            handleMessage(message);
        };

        // 3-3. 創建一個臨時隊列,並將其與設定的 FanOut Exchange 綁定
        // ※臨時佇列 - 釋放資源即刪除,可以動態擴容站點
        var queueName = _channel.QueueDeclare().QueueName;
        _channel.QueueBind(queue: queueName, exchange: _exchangeName, routingKey: string.Empty);
        _channel.BasicConsume(queue: queueName,
                              autoAck: true,// True: 消費者收到視為處理完畢
                              consumer: consumer);// 訂閱的結果事件傳給消費者
    }

    public void Dispose()
    {
        _channel.Dispose();
        _connection.Dispose();
    }
}



Step 6:SignalR Hub

實現 SignalR Hub 這邊關注於 RabbitMQ 的 Backplane 省略資料庫取歷史數據
關鍵在 _rabbitMqService.SendMessage(combineMessage); 收到前端資料是往 RabbitMQ 發送

public class UpdateHub : Microsoft.AspNetCore.SignalR.Hub
{
    private readonly IConfiguration _configure;
    private readonly RabbitMqService _rabbitMqService;
    private static string _Site = string.Empty;
    private int _siteNumber = 0;

    public UpdateHub(RabbitMqService rabbitMqService)
    {
        _rabbitMqService = rabbitMqService;
        _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));
    }
    /// <summary>
    /// 建立連接時,將歷史訊息回傳
    /// </summary>
    /// <returns></returns>
    public override async Task OnConnectedAsync()
    {
        var getData = FakeHistoryMessage();
        foreach (var message in getData)
        {
            await Clients.Caller.SendAsync("ReceiveMessage", message);
        }
        await base.OnConnectedAsync();
    }

    /// <summary>
    /// 提供前端讓用戶添加到群組
    /// </summary>        
    public async Task AddToGroup(string groupName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
    }       

    /// <summary>
    /// 接收前端傳送訊息
    /// </summary>                
    public async Task SendMessage(string user, string message)
    {
        var dateTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        var combineMessage = $@"[站點{_siteNumber} {dateTime}] {user}: {message}";
        _rabbitMqService.SendMessage(combineMessage);            
    }

    /// <summary>
    /// 偽造歷史資料 ※實務資料源可為 Redis / Mysql / SqlServer / MongoDB .....
    /// </summary>        
    private List<string> FakeHistoryMessage()
    {
        return new List<string>()
        {
            "[站點1 2024-6-16 05:32:35] Louis: History 1",
            "[站點2 2024-6-16 05:32:35] MilkTeaGreen: History 2",
            "[站點1 2024-6-16 05:33:17] Louis: History 3",
            "[站點2 2024-6-16 05:33:52] MilkTeaGreen: History 4",
        };
    }
}



Step 7:前端頁面

進入頁面時,與 Server 的 SignalR 連結,並且加入到正確的群組上

<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. 訂閱可接收訊息
    connection.on("ReceiveMessage", function (message) {            
        const updateContainer = document.getElementById("updateContainer");
        updateContainer.innerHTML += `<p>${message}</p>`;            
    });

    // 6. 啟動
    connection.start()
        .then(() => {
            //7. 連接群組 "groupName"
            connection.invoke("AddToGroup", "groupName").catch(function (err) {
                  return console.error(err.toString());
            });
            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:配置

系統的參數化配置,在初始化配置時會使用以下設定值。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "SiteNumber": "1",
  "Site": "WebStie Port: *",
  "AllowedHosts": "*",
  "RabbitMQ": {
    "HostName": "127.0.0.1",
    "UserName": "admin",
    "Password": "123fff"
  }
}



Step 9:初始化配置

依賴注入背景服務、RabbitMQ 並且啟用 SignalR

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// 1. 添加 SignalR
builder.Services.AddSignalR();

// 2-1. 注入-增加背景服務 - 輪詢 Push SignalR 讓每台 Server 完成 Backplane 工作
builder.Services.AddHostedService<PageBackroundUpdaterService>();

// 2-2. 注入-RabbitMQ 單例模式
builder.Services.AddSingleton<RabbitMqService>();

// 2-3. 應用程式結束時,釋放 RabbitMQ 連線
builder.Services.AddSingleton<IHostedService, RabbitMqServiceHostedService>();

var app = builder.Build();

...... 略

app.UseEndpoints(endpoints =>
{
    // 3. 配置 SignalR 路由
    endpoints.MapHub<UpdateHub>("UpdateHub");
});

...... 略

app.Run();





第二部分:RabbitMQ 補充

Step 1:官方代碼說明

關於 RabbitMQ 的發布/訂閱 模式,可以參考官網,官網文件寫的很詳盡
這篇範例是基於官方文件實現 Asp.net Core WEB 下 RabbitMQ Backplane 方法

Step 2:RabbitMQ Exchange

如果成功實現代碼,可以在自己的 RabbitMQ UI Manage 上,看到自定義的 Exchange
為了方便辨識,範例代碼定義以下:

my-fanout-exchange





第三部分:Demo成果

Step 1:部署代碼

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

Step 2:調整配置 - Web1

Web1的 appsetting.json 如下

Step 3:調整配置 - Web2

Web2的 appsetting.json 如下

Step 4:啟動Web

對 Web1 輸入指令

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


對 Web2 輸入指令

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



Step 5:開啟 Nginx

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

Step 6:開啟聊天室 - 完成

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