遇到要寫小工具微服務,我現在幾乎都是用 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 需登入才能存取:

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

本機 IP 連上時可操作:

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

成功!

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

Example project for setting up Hangfire on ASP.NET Core Minimal API.


Comments

# by 熊寶

黑大您好: 想請教,我有一 Recurring Job,這個 Job 每次執行大約需耗費一個小時才能做完。 在 Hangfire 搭配 SQLite 下(*註),每次 Job 執行三十分鐘,系統就會自己 Cancel 這個 Job,然後Hangfire重啟,並自動重新執行這個 Job。 網路上討論此議題的人也很多,但我始終找不到解決(可以延長這30 分鐘)的答案。 不知您是否遇過此問題?可有無良方解決? *註:網路上有多人反應,儲存Oracle/SQL Server/Memory... 也都有此問題,但LiteDB沒有問題....

# by Jeffrey

to 熊寶,我的 Hangfire 排程都是一兩分鐘內做完,沒遇過這狀況。https://stackoverflow.com/a/59052561/288936 依這篇的說法,似與使用資料庫有關(換言之,可視為在某些DB上有Bug),有幾則留言提到 MSSQL 沒問題。

# by Nobody

程式不轉, 人轉. Hangfire + MessageQueue (Rebus, Coravel, SlimMessage...) Hangfire 只負責控制啟動時間. 啟動時執行將參數DTO傳給真正處理的 MessageQueue. Hangire就沒30分鐘逾時未完成自動重新執行的問題.

Post a comment