簡單五步驟:ASP.NET Core整合Hangfire來排程更新口罩剩餘數量資料
口罩API系列(一)(二)(三)就資料面而言,已經處理的差不多了,不過,專案還有改善的空間。
- 專案第一次啟動時,需要初始化資料庫資料。目前實作的程式碼而言,我們每次請求都會重覆檢查一次。
- 每次請求都會重覆檢查一次資料源是否有更新。
以關注點分離來看,目前的 MaskController
工作有點雜且職責不夠單一。第一個問題,如果從 EF Core 下手,可能是實作 SeedData 方式來解決,但比較麻煩的事情是資料來源是網路上,而且資料內容不固定。第二點比較麻煩。正在構思時,剛好好友 Demo 貼文說 dotblogs 改版採用 Hangfire 來處理排程事件。疑,Hangfire 不就是這個需求的最佳解嗎!
「使用Hangfire處理ASP.NET MVC/Web API長時間與排程工作」多年前已經用的非常開心。這次讓我們在 ASP.NET Core Web API 來整合 Hangfire 來解決我們碰到的問題。
安裝 Hangfire + Sqlite 套件
# PowerShell
Install-Package HangFire.Core -Version 1.7.10
Install-Package Hangfire.AspNetCore -Version 1.7.10
Install-Package Hangfire.Storage.SQLite -Version 0.2.4
Install-Package sqlite-net-pcl -Version 1.7.302-beta
# .NET CLI
dotnet add package HangFire.Core --version 1.7.10
dotnet add package Hangfire.AspNetCore --version 1.7.10
dotnet add package Hangfire.Storage.SQLite --version 0.2.4
dotnet add package sqlite-net-pcl --version 1.7.302-beta
官方文件 ASP.NET Core Applications 會教你安裝 SQL Server 套件,但我們希望整合 SQLite,還好很快在 Extensions 找到 SQLite 延伸套件。
原本 Hangfire.Storage.SQLite + Microsoft.EntityFrameworkCore.Sqlite 兩個套件光安裝在一起就會打架產生 Exception 衝突。感謝 Hangfire.Storage.SQLite 裡 Issue #24 幫忙偵錯 CallMeOzz 朋友,這才讓口罩系列文件最後一篇寫得出來。
Exception thrown: 'System.MissingMethodException' in SQLite-net.dll An exception of type 'System.MissingMethodException' occurred in SQLite-net.dll but was not handled in user code Method not found: 'System.String SQLitePCL.raw.sqlite3_column_name(SQLitePCL.sqlite3_stmt, Int32)'.
重構程式碼
設定 ASP.NET Core 的 Hangfire 之前我們需要先重構我們的程式碼,將之前區域函式(Local Function)的程式碼獨立出來,以方便 Hangfire 設定使用。
public static class MaskSchedule
{
public static async Task InitialDb()
{
var db = new MaskContext();
if (db.MedicalMasks.FirstOrDefault() == null)
{
var service = new MaskService(new HttpClient());
var maskInfos = await service.GetMaskInfo();
foreach (var maskInfo in maskInfos)
{
db.Add(new MedicalMask()
{
Code = maskInfo.醫事機構代碼,
Name = maskInfo.醫事機構名稱,
Address = maskInfo.醫事機構地址,
Tel = maskInfo.醫事機構電話,
AdultNum = int.Parse(maskInfo.成人口罩剩餘數),
KidNum = int.Parse(maskInfo.兒童口罩剩餘數),
UpdateTime = DateTime.Parse(maskInfo.來源資料時間)
});
}
await db.SaveChangesAsync();
}
}
public static async Task MaskDataUpdate()
{
var db = new MaskContext();
var updateTime = db.MedicalMasks.First().UpdateTime;
var nextTime = updateTime.AddSeconds(600);
var nowTime = DateTime.Now;
if (nextTime < nowTime)
{
var service = new MaskService(new HttpClient());
var maskInfos = await service.GetMaskInfo();
var sourceTime = DateTime.Parse(maskInfos.First().來源資料時間);
// 0 is same as value
// Ref: https://docs.microsoft.com/en-us/dotnet/api/system.datetime.compareto?view=netcore-3.1#remarks
if (sourceTime.CompareTo(updateTime) != 0)
{
db.RemoveRange(db.MedicalMasks);
foreach (var maskInfo in maskInfos)
{
db.Add(new MedicalMask()
{
Code = maskInfo.醫事機構代碼,
Name = maskInfo.醫事機構名稱,
Address = maskInfo.醫事機構地址,
Tel = maskInfo.醫事機構電話,
AdultNum = int.Parse(maskInfo.成人口罩剩餘數),
KidNum = int.Parse(maskInfo.兒童口罩剩餘數),
UpdateTime = DateTime.Parse(maskInfo.來源資料時間)
});
}
await db.SaveChangesAsync();
}
}
}
}
這裡的重構其實有點麻煩,可以參考官方 Passing dependencies 的說明。
整理過後的 MaskController
好香完全沒有壞味道:
public class MaskController : ControllerBase
{
private readonly MaskContext _maskContext;
public MaskController(MaskContext maskContext)
{
_maskContext = maskContext;
}
public IActionResult Get()
{
try
{
return Ok(_maskContext.MedicalMasks.AsNoTracking());
}
catch (HttpRequestException ex)
{
return Problem(ex.Message);
}
}
}
加入 Hangfire 組態
在 Startup.ConfigureServices()
加入以下組態:
services.AddHangfire(configuration => configuration
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSQLiteStorage());
services.AddHangfireServer();
設定背景作業
在 Startup.Configure()
加入兩組介面到方法中:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IBackgroundJobClient backgroundJobs, IRecurringJobManager recurringJob)
Hangfire 會依介面注入執行個體。
app.UseHangfireDashboard();
//BackgroundJob.Enqueue(() => Console.WriteLine("Hello backgroundjobs."));
//RecurringJob.AddOrUpdate("Hello", () => Console.WriteLine("Hello, recurringJob."), Cron.Minutely);
backgroundJobs.Enqueue(() => MaskSchedule.InitialDb());
recurringJob.AddOrUpdate("MaskDataUpdate", () => MaskSchedule.MaskDataUpdate(), "1/10 * * * *");
Enqueue
與 AddOrUpdate
都是靜態方法。不過我們盡量能依照 .NET Core 的注入設計。可以先測試 Console.WriteLine()
再測試我們所要的方法。 以我們的情境,在 Startup.Configure()
去加入背景作業是非常合適的地方。也就是希望應用程式一開始啟動就設定好相關更新作業。
recurringJob
重點在 Cron
設定,你要它排程多久執行一次,它本身也支援 CRON expressions 語法。其實在 Cron 原始碼裡,它也是轉換為 CRON expressions 語法來傳入執行。
https://crontab.guru 可以快速測試出所需的 Cron 設定。
設定 /hangfire
路由
基本到這裡 Hangfire 的設定就已經算完成了。但當您啟動應用程式要查詢 /hangfire
的儀表板時會發現無法存取。這是因為 Hangfire 走的是 MVC 路由,而我們開的是 Web API 專案,預設情況下吃不到 MVC 路由。
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapControllers();
});
將 MapControllerRoute
組態設定回去就能正常看到 /hangfire
的儀表板。
注意,
/hangfire
的儀表板預設只能本機查看。如果需要開放外網查看,可以參考 Dashboard Authorization 文件。
小結
- 安裝 HangFire 套件
- 重構程式碼
- 設定 Hangfire 組態
- 設定背景作業
- 設定 /hangfire 路由
- 專案第一次啟動,可以採用 Fire-and-forget jobs 或 Delayed jobs 來解決。
- 重覆檢查與更新,可以採用 Recurring jobs。
一開始的 SQLite 套件讓我試了不少時間,原因是我選錯套件,選到 Hangfire.SQLite 這一套,它已經很久沒維護了,而且現在的 Hangfire 有點問題,例如,背景程式會連續觸發多次。重構花我最多時間。本來已經是注入架構的程式碼,所以一直在測試 static
能不能走注入這條路線,但後面發現實在有難度,在建構式就卡住了,最後只好走回 new
路線來進行作業,這是導入 Hangfire 後所做的妥協。並且在 static
含資料庫相關作業,也不是好的處理。最後,採用 SQLite 來架設有個類似 InMemory 的優點,測試過程可以隨時刪除 SQLite 資料庫檔案,啟動過程它會自動重建資料庫,這比 SQL Server 方便多了。
沒有留言:
張貼留言
感謝您的留言,如果我的文章你喜歡或對你有幫助,按個「讚」或「分享」它,我會很高興的。