首頁

目前文章總數:255 篇

  

最後更新:2026年 10月 24日

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 的代號,即可部署完成