ASP.NET Core Minimal API Hangfire 設定範例
3 |
遇到要寫小工具微服務,我現在幾乎都是用 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分鐘逾時未完成自動重新執行的問題.