【ASP.NET Core】使用SignalR推送伺服器日誌

东邪独孤發表於2024-01-01

一個多月前接手了一個產線機器人專案,上位機以讀寫暫存器的方式控制機器人,伺服器就是用 ASP.NET Core 寫的 Web API。由於前一位開發者寫的程式碼質量問題,導致上位機需要16秒才能啟動。經過我近一個月的改造,除了保留業務邏輯程式碼,其他的基本重寫。如今上位機的啟動時間在網路狀態良好的條件下可以秒啟動。原上位機啟動慢的原因:

1、啟動時使用同步方式訪問 Web API,在網路較弱時需要等待很長時間。我改為導步請求,並且不等待請求結果,直接顯示視窗;如果前面的請求失敗,在視窗顯示後再次發出非同步請求,並且不等待。如果再失敗才提示使用者。

2、原專案在 Main 方式處就連線PLC,而產線的PLC壓根就沒插電源。我改為在連線機器人之後才連線,同樣是非同步不等待。如果連不上直接忽略。

3、原專案是一個視窗一個專案,然後把這些視窗生成 .dll,放到一個目錄下,主程式啟動時從目錄下掃描 .dll,透過反射動態例項化視窗。這根本不需要的,一個上位機不可能有幾百個視窗吧,何必呢。我改為使用服務容器的方式管理視窗,主介面透過依賴注入自動獲取子視窗列表,再新增到主介面上。每個子視窗實現 IPage 介面用於識別,介面裡面定義標題和頁面索引即可。

4、幹掉 Log4Net,使用官方的 Logging 庫。

5、通訊用的 JSON 資料全改用 System.Text.Json,而不是某 Newton,修改後速度快了一個次元。

由於 Web API 程式是執行在伺服器的 IIS 中的,上一位開發者沒有實現日誌功能(僅僅用 ASP.NET Core 應用程式預設開啟的控制檯等日誌功能),問題是日誌沒有儲存。

我原來的計劃是把日誌寫到系統中,這樣就能儲存下來,用“事件檢視器”就能欣賞。後來想想這方案不行,工廠那夥人肯定找不到日誌在哪。寫資料庫裡面?想想似乎沒這個必要。簡單粗暴,直接自定義一個 ILogger,把日誌輸出到檔案中,然後加一個 Web API 讀取檔案,上位機那裡就可以呼叫,返回日誌內容。

後經過現場除錯發現,其實也不需要這樣。時間長了,會存下很多日誌檔案,就算用日期標識檔名也是很亂。實際上他們並不要求儲存日誌,只是在運作過程中實時監控機器人(應該叫機械臂)的工作狀態而已。如果不出問題,他們甚至連日誌都不看。上面用檔案實現的日誌方式,主要缺點是不能實時推到上位機。就算他們不看,那我現場除錯也方便我自己。

於是,我又想到了另一方案:用 SignalR 實時向上位機推送日誌。

----------------------------------------------------------------------------------------------------------------------------------------

上面都是大話,現在開始主題。

原理是這樣的:上位機作為 SignalR 客戶端,發起連線後,不用主動呼叫伺服器上的方法,而是等伺服器呼叫回撥方法。

 第一步,我們們要自定義一個 ILogger。

public class KingkingLogger : ILogger
{
    private readonly string cateName;
    
    public KingkingLogger(string cate)
    {
        cateName = cate;
    }

    public IDisposable? BeginScope<TState>(TState state) where TState : notnull
    {
        return default;
    }

    public bool IsEnabled(LogLevel logLevel)
    {
        return logLevel != LogLevel.None;
    }

    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        if(IsEnabled(logLevel) == false)
        {
            return;
        }
        // 獲取格式化後的文字
        string fstr = formatter(state, exception);
        // 顯示訊息型別
        string head = logLevel switch
        {
            LogLevel.Information => "訊息",
            LogLevel.Warning => "警告",
            LogLevel.Error => "錯誤",
            _ => "未知"
        };
        // 加個日期
        string currdate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        // 連線字串
        fstr = $"[{head}:{cateName}][{currdate}]{fstr}";
        // 觸發事件
        TransferLog?.Invoke(fstr);
    }

    // 靜態屬性
    public static Action<string>? TransferLog {  get; set; }
}

我暫時想不到叫啥名字,就暫且叫它 Kingking 日誌記錄器吧,我在專案中的類是叫 WTFLogger 的,什麼內涵你懂的,反正現在這專案只有我一個人在寫,取這個名字也無所謂。這個類不復雜,我解釋一下你就明白了。

1、字串 cateName 是類別名稱。就是記錄日誌時它屬於哪個名錄下的,比如我們常見的 Microsoft.Hosting.Lifetime、Microsoft.Hosting.Lifetime 等這些就是。在 Logging 庫中有兩種方式指定:一是用字串,二是用 ILogger<T> ,這個型別T將作為日誌類別的名稱。這裡我採用的是字串方式,所以不使用 ILogger<T>。

2、BeginScope 方法的用處是當你要把 logger 用在 using 語句塊時才會實現。正因為用在 using 塊中,所以它要求是實現 IDisposable 介面。這個實現 IDisposable 的類一般不用公開。這方法會接收一個泛型引數 TState state。這個看你的需要了,執行庫內部呼叫經常會用字典型別傳遞一些額外資料。這個 TState 你可以自定義。此處我不需要把 logger 用在 using 語句塊中,所以直接返回 default(或null)。

3、IsEnabled 方法的功能是分析一下 logLevel 引數指定的日誌級別當前是否要輸出日誌。如果需要輸出日誌,返回 true;不想輸出日誌返回 false。後面實現的 Log 方法中也會用到它,如果返回 false,那就不必去處理怎麼輸出日誌了。

4、Log 方法是核心。在此方法中你盡情發揮吧,你想怎樣輸出日誌就在這裡完成。比如你要用 Debug 類輸出,那就呼叫 Debug 類的成員輸出;你用控制檯輸出就呼叫 Console 類的成員。我這裡是要把日誌傳給 SignalR Hub 物件,讓其傳回給客戶端,故要呼叫靜態的 TransferLog 屬性。此屬性是委託型別,可以與方法繫結,因為我們們不能在這裡呼叫 Hub,Hub 是由 SignalR 元件自動啟用的。所以要用委託來間接實現傳遞。這個和事件的作用一樣,只是我不用事件成員罷了。

順便說一下,我專案中的類是同時把日誌寫入資料庫的(不寫檔案了,寫資料庫裡好清理),這裡老周為了讓示例簡單,沒有加上寫入資料的程式碼。其實也沒啥難度的,就是在資料庫中加個表,用 EF Core 往表裡 INSERT 一條記錄。

 

第二步,實現 Provider。ILogger 我們們定義好了,但這個 Kingking 日誌記錄器可不是直接扔進服務容器,而是透過叫 ILoggerProvider 的物件來建立例項。就相當於一個工廠類。

public class KingkingLoggerProvider : ILoggerProvider
{
    public ILogger CreateLogger(string categoryName)
    {
        return new KingkingLogger(categoryName);
    }

    public void Dispose()
    {
        return;
    }
}

程式碼很簡單,沒啥玄機。不過,為了呼叫方便,我們們可以封裝一個擴充套件方法。

public static class CustLoggerExtensions
{
    public static ILoggingBuilder AddKingkingLogger(this ILoggingBuilder builder)
    {
        builder.Services.AddSingleton<ILoggerProvider, KingkingLoggerProvider>();
        return builder;
    }
}

這樣就做到了像官方 API 那樣,用 AddXXX 的方法新增日誌功能,用法如下:

var builder = WebApplication.CreateBuilder(args);
// 配置日誌
builder.Services.AddLogging(o =>
{
    // 清空所有日誌提供者
    o.ClearProviders();
    // 新增控制檯日誌輸出
    o.AddConsole();
    // 新增我們們自己寫的日誌記錄器
    o.AddKingkingLogger();
});

 

第三步,實現 Hub。Hub 是 SignalR 通訊的“中心”類,當訪問的 URL 匹配時就會啟用我們們的 Hub。自定義 Hub 只要從 Hub 類派生即可。

public class MyHub : Hub
{
    public MyHub() {
        // 這裡關聯的就是日誌記錄類中的靜態委託
        KingkingLogger.TransferLog = KingkingLogger_TransferLog;
    }

    private void KingkingLogger_TransferLog(string obj)
    {
        // 向所有客戶端發日誌
        Clients.All.SendAsync("onLogged", obj);
    }

    protected override void Dispose(bool disposing)
    {
        if(disposing)
        {
            // 例項釋放時移除關聯
            KingkingLogger.TransferLog = null;
        }
        base.Dispose(disposing);
    }
}

邏輯很簡單,就是有日誌了就推送給客戶端。Clients.All 是把訊息發給所有連線的客戶端。

這裡順便提一下:Hub 是支援依賴注入的,即你可以在 MyHub 的建構函式里注入你要用的元件,如 DBContext 等。這裡我用不到其他元件,所以沒有注入。

在Web應用程式初始化時要啟用 SignalR 相關服務。

var builder = WebApplication.CreateBuilder(args);
……
builder.Services.AddSignalR();
var app = builder.Build();

還要 Map 一下終結點,以繫結請求 Hub 的地址。

var builder = WebApplication.CreateBuilder(args);
……
var app = builder.Build();

……

// 記得這個
app.MapHub<MyHub>("/hub");

app.Run();

這裡我設定的地址是 http://localhost/hub。

 

不要以為這樣就完事了,當你執行後用客戶端一測試,你會發現連毛都接收不到。這是因為 Hub 物件的預設生命週期太短了,僅在用的時候例項化,然後馬上 Dispose 了。然後你會想,那我重寫 OnConnectedAsync 方法,關聯 TransferLog 委託;再重寫 OnDisConnectedAsync 方法,把 TransferLog 委託設定為 null。這個也是不行的,原因還是那個—— Hub 物件生命週期太短。

有什麼辦法讓 Hub 長壽一點呢?還真有,直接把 Hub 型別註冊進伺服器中,並使用單例項。

var builder = WebApplication.CreateBuilder(args);
……
// 把Hub註冊為單例項
builder.Services.AddSingleton<MyHub>();
builder.Services.AddSignalR();
var app = builder.Build();

 

第四步,客戶端程式。客戶端並不是只能用 JS 來寫,.NET 團隊也做了相關的 Nuget 包。在專案中引用一下。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.0" />
  </ItemGroup>

</Project>

在主視窗中放一個文字框,兩個按鈕。文字框顯示收到的日誌,按鈕用來請求連線和斷開連線。

using Microsoft.AspNetCore.SignalR.Client;

namespace TestClient;

public partial class Form1 : Form
{
    // 連線物件
    HubConnection hubConn;
    public Form1()
    {
        InitializeComponent();
        // 初始化連線
        var connBuilder = new HubConnectionBuilder()
            .WithUrl("http://localhost:6225/hub")
            .WithAutomaticReconnect();
        hubConn = connBuilder.Build();
        // 關聯方法
        hubConn.On<string>("onLogged", OnLogRecv);
    }

    private void OnLogRecv(string msg)
    {
        // 伺服器回撥,顯示收到的日誌
        textBox1.Invoke(() =>
        {
            textBox1.AppendText(msg + Environment.NewLine);
        });
    }

    private async void btnConn_Click(object sender, EventArgs e)
    {
        try
        {
            await hubConn.StartAsync();
            lbMessage.Text = "已建立連線";
        }
        catch(Exception ex) {
            lbMessage.Text = ex.Message;
        }
    }

    private async void btnDisconn_Click(object sender, EventArgs e)
    {
        if(hubConn.State == HubConnectionState.Connected)
        {
            await hubConn.StopAsync();
            lbMessage.Text = "已斷開連線";
        }
    }
}

注意,在呼叫 On 方法時,onLogged 要與伺服器上指定的一致,否則伺服器回撥無效

/*---------------- 伺服器端 ------------------*/
private void KingkingLogger_TransferLog(string obj)
{
    // 向所有客戶端發日誌
    Clients.All.SendAsync("onLogged", obj);
}

/*--------------------- 客戶端 -------------------*/
hubConn.On<string>("onLogged", OnLogRecv);

 

為了測試能否真的傳遞了日誌,我們們在服務端寫幾個 Mini-API 來驗證。

app.MapGet("/", (ILoggerFactory logFact) =>
{
    ILogger logger = logFact.CreateLogger("MINI Main");
    logger.LogInformation("歡迎來到圓環世界");
    return "Hello Guy";
});
app.MapGet("/start", (ILoggerFactory logFact) =>
{
    ILogger logger = logFact.CreateLogger("MINI Go Go Go");
    logger.LogWarning("遊戲開始了,你必須先和QB簽訂契約");
    return "圓神啟動";
});
app.MapGet("/shot", (ILoggerFactory loggerFact) =>
{
    ILogger logger = loggerFact.CreateLogger("MINI Wind");
    logger.LogInformation("幹得好,三發入魂");
    return "第一局完勝";
});

 

同時啟動服務端和客戶端試試吧。為了使測試更真實,我啟動了三個客戶端。觸發日誌記錄,請呼叫任意一個 API。

依次點選三個視窗上的“連線”按鈕,確認全部都連上。

然後依次呼叫那幾個 mini API 試試。

可以看到,三個客戶端都收到日誌推送了。

為了演示,沒有資料儲存,所以如果客戶端沒有及時連線,會丟失前面的日誌。老周的實際專案中是用資料庫存起來,用的時候再取出來發給客戶端。預設是發最近的 100 條。如果上位機要看全部,就呼叫一下 Hub 的方法,Hub 的程式碼會 select 整個日誌表再發回。

 

相關文章