前言

你有排程相關的需求嗎? 需要定期呼叫指定的方法來做事嗎? 相信大家多多少少都有接過類似的需求,不過除了使用 Windows 的排程來做或是使用 cron job 這些傳統工具來做到之外你還有更現代的選擇 - Hangfire

內文

其實 .NET solution 的排程選擇不只 Hangfire 還有老牌的 Quartz.NET 也是非常好的選擇。單純以排程作業來說 Quartz 可以做到的控制是比 Hangfire 還要來的更細緻,不過 Hangfire 就優勢來說大概就是比較沒有歷史包袱,管理介面也是本身內建,不像 Quartz 是需要另外找第三方來幫助 (ex. GitHub - CrystalQuartz)。

好啦….其實兩個都很好我只是雞蛋裡挑骨頭…只是最近有用到 Hangfire 所以就用這個來練習排程吧~

Initialize your project for Hangfire

Hangfire 最吸引人的地方除了他學習成本較 Quartz.NET 低,另外就是他自帶漂亮 Dashboard 而且設定還很容易。想要套用 Dashboard 的話先引用套件

1
dotnet add package Hangfire.AspNetCore

這個套件會相依 Hangfire.Core 所以可以先裝這個就好,然後在 Configure 設定區塊裡補上以下這段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    //...
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHangfireDashboard(
            new DashboardOptions
            {
                StatsPollingInterval = 3600000
            });
    }
    //...
}

StatsPollingInterval 這個參數的預設值是 2000 它的單位是毫秒,也就是 2秒它就會去背景檢查 Job 執行的狀態,這個數值在測試環境基本上不用太擔心什麼,不過若是正式環境的話建議要調整,不然會讓背後儲存 Job 的位置壓力很大 (若是存在關聯式資料庫的話就更要小心了)。

而 ConfigureServices 的部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public void ConfigureServices(IServiceCollection services)
{
    services.AddHangfire(
                options =>
                {
                    options.UseDefaultTypeResolver()
                           .UseDefaultTypeSerializer()
                           .UseSimpleAssemblyNameTypeSerializer()
                           .UseRecommendedSerializerSettings();
                });
}

Fire-and-forget Jobs

一個最簡單的 Fire-and-forget Job 的使用方式如下

1
IBackgroundJobClient.Enqueue(()=> Console.WriteLine("Hello World"));

但有一個要點需要特別注意,Enqueue 方法並不會真的立即執行,他會先執行以下步驟:

  1. 將執行方法與他的所需參數全都序列化
  2. 依上述序列化的資訊產生一個新的背景作業
  3. 將背景作業儲存起來
  4. 將此背景作業排入 Queue 裡

做完上述動作後 Enqueue 方法會立即返回,然後由 Hangfire 的另外一個 component,Hangfire Server 接手,Hangfire Server 會去執行以下動作:

  1. 取得下一個任務,成功後會讓他在隱藏在其他 worker 的視野裡
  2. 執行該任務與所有該任務的過濾器
  3. 將任務從 Queue 裡移除

至此動作完成,才會是你看到的任務在背景裡完成。而且要注意到,任務必須要是處理完成才會被從 Queue 移除,所以若任務拋出例外且沒有 handle 的話那他就會放在 Queue 直到處理完成。

Hangfire 會處理所有拋出來的 exception,所以你的 Job 若本身不處理 exception 的話也不至於讓 Hangfire 整個 crash。預設來說 Hangfire 會將失敗的任務狀態標記為 Failed 並且會嘗試重作 10 次(每次重作之間都會有一個延遲,不會馬上執行)。

Delayed Jobs

1
IBackgroundJobClient.Schedule(() => Console.WriteLine("Hello, world"), TimeSpan.FromDays(1));

若你的任務並非要立刻執行的話,使用 Schedule 就可以很方便的設定一個延遲時間再去執行。

Recurring Jobs

若是用 Recurring Job 的話則要用另外的 API ,雖然可以用 IRecurringJobManager 但是因為這個介面能提供的功能跟作用實在有點薄弱,可以的話會推薦用 RecurringJobManager ,不過若基本功能就適用的話還是可以用介面來呼叫就好。

1
IRecurringJobManager.AddOrUpdate("easyjob", () => Console.Write("Easy!"), Cron.Daily);

這邊要注意的是 AddOrUpdate 的 JobId 是需要傳進去參數,不像 Enqueue 或是 Schedule 是回傳值。這也意味著若傳入的 Id 一樣的話,就會把任務內容給複寫掉。

Trigger Job

若是希望手動觸發 Recurring Job 的話則有提供 Trigger 方法可以使用,一樣,對於 Recurring Job 這個系列的 API 來說, JobId 是很重要的元素,他幾乎每一個 API 都會需要用到這個數值。

1
IRecurringJobManager.Trigger(string recurringJobId);

Using SQL Server

雖然用 SQL Server 當作儲存資料的地方,以效能來講並不算好的選擇,但是就方便性、學習成本、管理成本、資料正確性、資料安全性來講,依然是不錯的選擇。

1
Install-Package Hangfire.SqlServer

使用 SQL Server 來儲存資料的話有一些設定也是最好要調整一下,不然也會讓 SQL Server 的壓力有點大。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void ConfigureServices(IServiceCollection services)
{
    //...
    services.AddHangfire(
        options =>
        {
            options.UseDefaultTypeResolver()
                   .UseDefaultTypeSerializer()
                   .UseSimpleAssemblyNameTypeSerializer()
                   .UseRecommendedSerializerSettings()
                   .UseSqlServerStorage(Configuration.GetConnectionString("your.connection.string"),
                       new Hangfire.SqlServer.SqlServerStorageOptions
                       {
                           // Default value is 15 sec.
                           // This setting is for fire-and-forget jobs.
                           SlidingInvisibilityTimeout = TimeSpan.FromMinutes(25),
                           QueuePollInterval = TimeSpan.FromMinutes(1),
                           UseRecommendedIsolationLevel = true,
                           DisableGlobalLocks = true,
                           CommandTimeout = TimeSpan.FromMinutes(30),
                           SchemaName = "MyHangfire" // default will be 'Hangfire'
                       });
        });
    //...
}

Config your background server

若你使用 Hangfire 都用預設值其實有機會無法推上正式環境,或者是說你會讓正式環境的壓力有點大。像是 SchedulePollingIntervalHeartbeatIntervalServerCheckInterval 這些數值都能很直接影響到你的儲存媒介的壓力。請一定要視你的正式環境的狀況去做調整。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public void ConfigureServices(IServiceCollection services)
{
    //...
    services.AddHangfireServer(backgroundJobServerOptions =>
            {
                backgroundJobServerOptions.WorkerCount = Environment.ProcessorCount * 5;
                // the order is defined by alphanumeric order and array index is ignored.
                backgroundJobServerOptions.Queues = new string[] { "alpha", "beta", "lower" };
                backgroundJobServerOptions.StopTimeout = TimeSpan.Zero;
                backgroundJobServerOptions.ShutdownTimeout = TimeSpan.FromSeconds(15);
                // this setting is for Schedule job, background server will check undo jobs every minutes. This value default is 15 sec.
                backgroundJobServerOptions.SchedulePollingInterval = TimeSpan.FromMinutes(1);
                backgroundJobServerOptions.HeartbeatInterval = TimeSpan.FromMinutes(1);
                backgroundJobServerOptions.ServerTimeout = TimeSpan.FromMinutes(5);
                backgroundJobServerOptions.ServerCheckInterval = TimeSpan.FromHours(24);
                backgroundJobServerOptions.CancellationCheckInterval = TimeSpan.FromSeconds(5);
            });
    //...
}

關於 Job 的 Queue 可看官方的指引 “Configuring Job Queues — Hangfire Documentation 當中最重要的是提示我想就是它指明了它的執行順序是相依於用什麼方式儲存方式。若用 SQL Server 儲存資料的話他會按照字母順序來執行,陣列的索引反而是會被忽略的。若你使用 Queue Attribute 來做為 Job 執行的優先序的話這點不可不察。實做細節可以參考 Source Code 的這兩處地方的寫法:

這邊 看 Table 的索引如何建立,知道查詢出來的結果在沒有 Order 條件下資料是如何呈現的。從這邊 看它利用 QueueFetchedAt 兩個當作查詢條件且沒有多做排序知道應用端怎麼操作。

從上面這兩個地方就知道目前 API 看起來是沒有提供客製化排序的方式,若你也是選擇 SqlServer 當作除存媒介,你需要從 Queue 的名稱下手。

Reference