首頁

目前文章總數:157 篇

  

最後更新:2024年 12月 07日

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


資料表的變化如下: