分享程式代碼相關筆記
目前文章總數:157 篇
最後更新:2024年 12月 07日
上篇上篇 完成 SLB + Session Sticky 配置,最後遺留下 2台Web機器的 SignalR 無法互通問題。
此問題MSDN提出了建議解法,常態性解決 Server 實際部署受到環境影響的問題
※假設使用 SignalR Server 註冊到另一台 SignalR Server 會無法通過 SLB + Session Sticky 配置。
因為 SLB 會動態分配到 Server,有可能自己連到自己造成 Crash
我們目標是讓 SignalR Server 會觀察 Database 的 Table 資料變化,自己完成推播訊息
1. 資料庫存取 | : | 使用 Dapper ,實現 IDbconnection 對 Mysql 的存取接口 |
2. 資料表存取 | : | 實現 SignalR Message 資料表的保存,包含讀取、更新、寫入聊天室訊息 |
3. 背景輪詢服務 | : | 每個 SignalR Server 都會自行對資料庫讀取,監控是否資料有變化,進行推播 |
4. Hub | : | 實現客戶端發送訊息的接收,寫入資料庫,並且擴充JWT接收機制 |
5. 前端代碼 | : | 實現客戶端對SignalR Server 訂閱(Subscribe) |
6. 配置 | : | 每個 Server 為了辨識,增加自己的代號、使用的 Port 號 |
7. 初始化配置 | : | 對資料庫存取的 Configure 配置、依賴注入上述所需功能 |
新建 MyDb 與 IMyDb ,透過 IDbconnection 擴充 Mysql 連接,以Master 提供存取
public class MyDb : IMyDb
{
IDbConnection _master;
public MyDb(IDbConnection dbConnection)
{
_master = dbConnection;
}
public IDbConnection Master
{
get { return _master; }
}
}
新建 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
}
);
}
}
背景服務,先取得 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)
{
}
}
}
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);
}
}
前端在 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>
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={您的密碼};"
}
}
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>();
實現 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;
將範例代碼部署到2個 Web 資料夾中
Web1的 appsetting.json 如下
Web2的 appsetting.json 如下
對 Web1 輸入指令
dotnet SingalRWebsiteUseScaleOutAndBackPlateDatabaseExample.dll --urls=http://localhost:6001
對 Web2 輸入指令
dotnet SingalRWebsiteUseScaleOutAndBackPlateDatabaseExample.dll --urls=http://localhost:6002
啟動 Nginx
※這邊的配置是基於上一篇 SLB + Session Sticky
進入配置好的首頁,並且開啟兩個分頁(第1個分頁連 web1 ; 第2個分頁連 web2)
輸入一些簡單文字,可以發現完成 SLB + Session Sticky 下的 SignalR 聊天室互動
未來要部署多個機器時,只要改一下 appsetting.json 的代號,即可部署完成
資料表的變化如下: