網頁

簡單五步驟:ASP.NET Core整合Hangfire來排程更新口罩剩餘數量資料

簡單五步驟:ASP.NET Core整合Hangfire來排程更新口罩剩餘數量資料

口罩API系列()()()就資料面而言,已經處理的差不多了,不過,專案還有改善的空間。

  1. 專案第一次啟動時,需要初始化資料庫資料。目前實作的程式碼而言,我們每次請求都會重覆檢查一次
  2. 每次請求都會重覆檢查一次資料源是否有更新

關注點分離來看,目前的 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 * * * *");

EnqueueAddOrUpdate 都是靜態方法。不過我們盡量能依照 .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 文件。

小結

  1. 安裝 HangFire 套件
  2. 重構程式碼
  3. 設定 Hangfire 組態
  4. 設定背景作業
  5. 設定 /hangfire 路由
  • 專案第一次啟動,可以採用 Fire-and-forget jobs 或 Delayed jobs 來解決。
  • 重覆檢查與更新,可以採用 Recurring jobs。

一開始的 SQLite 套件讓我試了不少時間,原因是我選錯套件,選到 Hangfire.SQLite 這一套,它已經很久沒維護了,而且現在的 Hangfire 有點問題,例如,背景程式會連續觸發多次。重構花我最多時間。本來已經是注入架構的程式碼,所以一直在測試 static 能不能走注入這條路線,但後面發現實在有難度,在建構式就卡住了,最後只好走回 new 路線來進行作業,這是導入 Hangfire 後所做的妥協。並且在 static 含資料庫相關作業,也不是好的處理。最後,採用 SQLite 來架設有個類似 InMemory 的優點,測試過程可以隨時刪除 SQLite 資料庫檔案,啟動過程它會自動重建資料庫,這比 SQL Server 方便多了。

原始碼:QueryMaskSqliteHangfireSample

沒有留言:

張貼留言

感謝您的留言,如果我的文章你喜歡或對你有幫助,按個「讚」或「分享」它,我會很高興的。