7

ASP.NET Core Minimal API Hangfire 設定範例

 1 year ago
source link: https://blog.darkthread.net/blog/hangfire-example/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

ASP.NET Core Minimal API Hangfire 設定範例-黑暗執行緒

遇到要寫小工具微服務,我現在幾乎都是用 ASP.NET Core Minimal API 開發,程式碼力求精簡扼要,以符合我愛的極簡風格。

遇到邏輯再稍微複雜一點需要定期排程作業,Hangfire 則是我的首選,除了資料庫可以記憶體 / SQLite / SQL 三種隨便切,Hangfire 內建的網頁管理介面,查線上問題超方便。

我的 Hangfire 文章是 ASP.NET MVC / .NET Framework 時代寫的,套用到 ASP.NET Core 做法已有所出入,每次參考需另外爬文跟轉換。想了想,還是花點時間寫個 ASP.NET Core 版,整理出在 ASP.NET Core Minimal API 中使用 Hangfire 的範例專案,未來要參考比較方便。

先說明我假想的應用情境及程式範例的重點。

  • Hangfire 及 DbConext 資料庫都使用 SQLite (但改幾行即可切成 MSSQL),為求測試單純,每次啟動 EnsureDeleted() 刪舊資料庫、EnsureCreated() 自動建立資料表。(參考:EF Core 測試小技巧 - 快速建立資料表EF Core 筆記 4 - 跨資料庫能力展示)
  • 為貼近真實應用,排程作業不只是 Console.WriteLine 就算了,而是要透過 DI 取得 DbContext 寫資料庫。因此不能像以前弄個靜態方法打發,必須建立實體,從建構式參數設法取得 DbContext。 而排程作業類別一般會註冊成 Singleton,無法在建構時拿到 DbContext 這種 Scoped 生命週期物件,處理上需要一點技巧。
    參考:在 ASP.NET Core Singleton 生命週期服務引用 Scoped 物件
  • DI 註冊排程作業類別及設定排程的動作,我特別包進 builder.AddSchTaskWorker()、app.SetSchTasks() 集中邏輯並讓程式看起來更專業一些。
  • Hangfire 的管理網頁要加權限控管,範例使用 Windows 整合驗證,使用者需登入才能看到 Dashboard,只有從 localhost 存取才能手動執行或重跑排程。 這裡有個小眉角:若網站採部分 URL 匿名、部分需登入,簡單做法是 builder.Services.AddAuthorization(options => options.FallbackPolicy = options.DefaultPolicy;); 預設要求登入,可匿名存取部分再 app.MapGet("/", ...).AllowAnonymous()
    參考:在 ASP.NET Core 中設定 Windows 驗證
    但我的範例專案打算設計成全網站可匿名存取只有 Dashboard 要登入,由於無法針對 /hangfire 路徑設 .RequireAuthorization(),我找到的解法是在 自訂 IDashboardAuthorizationFilter,並需呼叫 context.GetHttpContext().ChallengeAsync()... 回傳 401 觸發登入視窗
    參考:Authentication and authorization in minimal APIs
  • Hangfire 預設會輸出多國語系資源檔,搞出一堆用不到且礙眼的目錄與檔案,我在 csproj 加入 SatelliteResourceLanguages 避免
  • 之前參照的 Hangfire.SQLite 套件已停止維護,我改用 Hangfire.Storage.SQLite。

Minimal API Program.cs 如下:

using Hangfire;
using Hangfire.Dashboard;
using Hangfire.Storage.SQLite;
using HangfireExample;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// SQLite 資料庫連線字串
var dbPath = "demo.db";
var cs = "data source=" + dbPath;

// 註冊 DbContext
builder.Services.AddDbContext<MyDbContext>(options => 
    options.UseSqlite(cs)
        .LogTo(Console.WriteLine, LogLevel.Critical)
    );

// 註冊 Hangfire,使用 SQLite 儲存
// 注意:UseSQLiteStorage() 參數為資料庫路徑,不是連線字串
builder.Services.AddHangfire(configuration => configuration
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSQLiteStorage(dbPath));
builder.Services.AddHangfireServer();

// 使用擴充方法註冊排程工作元件
builder.AddSchTaskWorker();

// 設定 Windows 整合式驗證
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
    .AddNegotiate();
builder.Services.AddAuthorization(options =>
{
    // 以下可設全站需登入才能使用,匿名 MapGet/MapPost 加 AllowAnonymous() 排除
    //options.FallbackPolicy = options.DefaultPolicy;
});

var app = builder.Build();

// 測試環境專用:刪除並重建資料庫
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
    db.Database.EnsureDeleted();
    db.Database.EnsureCreated();
}

// 加入認證及授權中介軟體
app.UseAuthentication();
app.UseAuthorization();

app.UseHangfireDashboard(options: new DashboardOptions
{
    IsReadOnlyFunc = (DashboardContext context) =>
        DashboardAccessAuthFilter.IsReadOnly(context),
    Authorization = new[] { new DashboardAccessAuthFilter() }
});

// 使用擴充方法設定排程工作
app.SetSchTasks();

app.MapGet("/", (MyDbContext dbctx) =>
    string.Join("\n",
        dbctx.LogEntries.Select(le => $"{le.LogTime:HH:mm:ss} {le.Message}").ToArray()));

app.Run();

public class DashboardAccessAuthFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        //依據來源IP、登入帳號決定可否存取
        //例如:已登入者可存取
        var userId = context.GetHttpContext().User.Identity;
        var isAuthed = userId?.IsAuthenticated ?? false;
        if (!isAuthed)
        {
            // 未設 options.FallbackPolicy = options.DefaultPolicy 的話要加這段
            // 發 Challenge 程序,ex: 回傳 401 觸發登入視窗、導向登入頁面..
            context.GetHttpContext().ChallengeAsync()
                .ConfigureAwait(false).GetAwaiter().GetResult();
            return false;
        }
        // 檢查登入者
        return true;
    }
    public static bool IsReadOnly(DashboardContext context)
    {
        var clientIp = context.Request.RemoteIpAddress.ToString();
        var isLocal = "127.0.0.1,::1".Split(',').Contains(clientIp);
        //依據來源IP、登入帳號決定可否存取
        //例如:非本機存取只能讀取
        return !isLocal;
    }
}

排程作業類別 SchTaskWorker.cs 如下:

using System.Linq.Expressions;
using Hangfire;

namespace HangfireExample
{
    public class SchTaskWorker
    {
        private readonly IServiceProvider _services;

        int _counter = 0;

        // 取得 IServiceProvider 稍後建立 Scoped 範圍的  DbContext
        // https://blog.darkthread.net/blog/aspnetcore-use-scoped-in-singleton/
        public SchTaskWorker(IServiceProvider services)
        {
            _services = services;
        }
        // 設定定期排程工作
        public void SetSchTasks()
        {
            SetSchTask("InsertLogEveryMinute", () => InsertLog(), "* * * * *");
        }

        // 先刪再設,避免錯過時間排程在伺服器啟動時執行
        // https://blog.darkthread.net/blog/missed-recurring-job-in-hangfire/
        void SetSchTask(string id, Expression<Action> job, string cron)
        {
            RecurringJob.RemoveIfExists(id);
            RecurringJob.AddOrUpdate(id, job, cron, TimeZoneInfo.Local);
        }
        // 每分鐘寫入一筆 Log 到資料庫
        public void InsertLog()
        {
            using (var scope = _services.CreateScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
                db.LogEntries.Add(new LogEntry { Message = $"Test {_counter++}" });
                db.SaveChanges();
            }
        }
    }
    // 擴充方法,註冊排程工作元件以及設定排程
    public static class SchTaskWorkerExtensions
    {
        public static WebApplicationBuilder AddSchTaskWorker(this WebApplicationBuilder builder)
        {
            builder.Services.AddSingleton<SchTaskWorker>();
            return builder;
        }

        public static void SetSchTasks(this WebApplication app)
        {
            var worker = app.Services.GetRequiredService<SchTaskWorker>();
            worker.SetSchTasks();
        }
    }
}

實測一下,/hangfire 需登入才能存取:

Fig1_638087345953338263.png

從外部 IP 連上時只能唯讀:

Fig2_638087345956762142.png

本機 IP 連上時可操作:

Fig3_638087345958696624.png

排程順利執行,資料順利寫入 SQLite 資料庫:

Fig4_638087345960450365.png

專案已上傳至 GitHub,需要的同學請自取。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK