首頁

目前文章總數:255 篇

  

最後更新:2026年 10月 24日

0066. SignalR 橫向擴展部署 Server - Redis Backplane 解決方案

日期:2024年 06月 02日

標籤: C# Nginx SignalR Microsoft Azure Redis Asp.net Core Web MVC

摘要:C# 學習筆記


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






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

Step 1:前言

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

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

我們目標是啟用 Redis Backplane 模式,管理所有連接的 SignalR Server,當需要推播時,由 Redis Server 完成推播給所有用戶的方法

Step 2:範例專案架構


1. Web資源檔 : 將 SignalR 8.0.0.js 下載,讓 .cshtml 引用,使前端網站可以註冊 SignalR 服務
2. 資料模型 : 保存於 Redis 的 DataEntity
3. Redis 業務邏輯 : 獨立 Redis 的業務邏輯,並且在初始化時注入為 SingalTon 使其全域共用
4. 初始化配置 : 依賴注入 Reids、SignalR 等配置
5. 配置 : 每個 Server 為了辨識,增加自己的代號、使用的 Port 號、Redis Server 位置
6. SignalR Hub : Web伺服器實現 SignalR ,並且提供前端 Publish 與後端 Subscribe
7. 前端頁面 : 提供聊天室註冊 SignalR 並且可發送訊息,與接收訊息功能



Step 3:Web資源檔

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

Step 4:資料模型

保存於 Redis 中的資料結構,紀錄用戶發送紀錄
※在 Database Backplane 中是存在 Table 中

public class SignalRMessagesEntity
{
public string UserName { get; set; } = string.Empty;

public string Message { get; set; } = string.Empty;

public int SiteValues { get; set; }

public long CreateTime { get; set; }
}



Step 5:Redis 業務邏輯

關鍵在 GetDb 實現了從 Redis 中指定資料庫進行存、取

public class RedisService
{
    private readonly IConnectionMultiplexer _redis;

    private readonly ISubscriber _redisSubscriber;
    
    public RedisService(IConnectionMultiplexer redis)
    {
        _redis = redis;
        _redisSubscriber = _redis.GetSubscriber();
    }

    public IDatabase GetDb(int dbIndex = 0) => _redis.GetDatabase(dbIndex);

    public ISubscriber GetSubscriber() => _redis.GetSubscriber();

    public void Publish(RedisChannel channel, RedisValue message)
    {
        _redisSubscriber.Publish(channel, message);
    }
}



Step 6:初始化配置

在 Program.cs 初始化配置中,將 Redis 連線設定
在 1-2. 中配置 Prefix 做隔離,避免其他服務被影響
※AddStackExchangeRedis 配置時,就自動啟用 Redis Backplane

var builder = WebApplication.CreateBuilder(args);

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

// 1. 添加 SignalR - 並且啟用 Redis BackPlane (AddStackExchangeRedis 已經內建)
var redisConnection = builder.Configuration.GetConnectionString("RedisConnection");
builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(redisConnection));
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnection, options => {
    //1-2. 重要:為了讓 Redis 某個DB內可以辨識彼此的 Channel 可增加 Prefix 做隔離
    options.Configuration.ChannelPrefix = "MyApp";
});

builder.Services.AddControllers();
// 1-3. 注入RedisService 為 Singleton 使其持久化 
builder.Services.AddSingleton<RedisService, RedisService>();

var app = builder.Build();

...... 略


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

...... 略

app.Run();




Step 7:配置

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

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "SiteNumber": "1",
  "Site": "WebStie Port: XXXX",
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "RedisConnection": "127.0.0.1:6379,abortConnect=False,connectRetry=3,connectTimeout=3000,defaultDatabase=0,syncTimeout=3000,responseTimeout=3000"
  }
}



Step 8:SignalR Hub

依序將 1. ~ 6. 項 SignalR 的推播、資料保存的工作

public class UpdateHub : Microsoft.AspNetCore.SignalR.Hub 
{
    private readonly RedisService _redisService;        
    private readonly IConfiguration _configure;        
    private static string _Site = string.Empty;
    private static string _RedisKey = "MyRadisSignalR";
    private int _siteNumber = 0;        

    public UpdateHub(RedisService redisService)
    {
        _redisService = redisService;
        _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()
    {
        int startIndex = 0;
        int endIndex = -1;

        // 2. 從 Redis 中獲取聊天室的歷史訊息列表
        var chatHistory = await _redisService.GetDb(0).SortedSetRangeByRankAsync(_RedisKey, startIndex, endIndex);

        // 3. 發送聊天室的歷史訊息給新連接的用戶
        foreach (var message in chatHistory)
        {
            await Clients.Caller.SendAsync("ReceiveMessage", message.ToString());
        }
        await base.OnConnectedAsync();
    }

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

    /// <summary>
    /// 接收前端傳送訊息
    /// </summary>                
    public async Task SendMessage(string user, string message)
    {
        //4. 將前端傳來的訊息轉為 Json
        var dataEntity = new SignalRMessagesEntity() {
            Message = message,
            SiteValues = _siteNumber,
            UserName = user,
            CreateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
        };

        var jsonData= JsonConvert.SerializeObject(dataEntity);

        //5. 寫入 Redis 保存資料
        await _redisService.GetDb(0).SortedSetAddAsync(_RedisKey, jsonData, dataEntity.CreateTime);

        //6. 這裡只要直接推播即可, Redis 的 Stack已經BackPlane 
        await Clients.All.SendAsync("ReceiveMessage", jsonData.ToString());
    } 
}




Step 9:前端頁面

前端實現基本的 SignalR 連線工作,在 “ReciveMessage” 訂閱工作中
取得 Redis 的聊天室歷史資料,並且轉換日期格式

    <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) => {
            const updateContainer = document.getElementById("updateContainer");
            updateContainer.innerHTML += `<p>${message.message}</p>`;
        });

         // 6. 訂閱可接收訊息
        connection.on("ReceiveMessage", function (jsonMessage) {
            const updateContainer = document.getElementById("updateContainer");                   
            // 6-2. 接收到後端SignalR Server 回傳聊天室歷史訊息
            var message = JSON.parse(jsonMessage);
            // 6-3. 將資料時間轉為可讀格式 格式為 yyyy-MM-ddTHH:mm:ss
            var date = new Date(message.CreateTime * 1000); //※乘以 1000 將秒轉換為毫秒    
            var dateString = date.toISOString().replace("T", " ").substr(0, 19);
            updateContainer.innerHTML += `<p>[站點${message.SiteValues} ${dateString}] ${message.UserName}:${message.Message}</p>`;
        });

        // 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>





第二部分:Redis 資料結構

Step 1:檢視 Redis 資料

選擇一個自己熟悉的 Redis 工具,這邊用 AnotherRedisDesktopManager 檢視 Redis 內的資料
比對 Step 4:資料模型 中的資料結構,在 Redis 中保存 Json 格式的字串



第三部分:驗證結果

Step 1:部署代碼

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

Step 2:調整配置 - Web1

Web1的 appsetting.json 如下

Step 3:調整配置 - Web2

Web2的 appsetting.json 如下

Step 4:啟動Web

對 Web1 輸入指令

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


對 Web2 輸入指令

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



Step 5:開啟 Nginx

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

Step 6:開啟聊天室 - 完成

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


資料表的變化如下: