Hangfire 初學

以下範例使用 dotnet core mvc 專案,參考 hangfire 官網文件:ASP.NET Core Applications及其他參考資料練習,相關連結隨附於後不再贅述

Sample Code:Github

參考連結

  1. ASP.NET Core Applications
  2. 使用 HANGFIRE 處理 ASP.NET MVC/WEB API 長時間與排程工作
  3. ASP.NET Core 使用 Hangfire 做排程
  4. Hangfire: Writing unit tests
  5. 如何對 Hangfire Job 撰寫測試

OverView

從上圖可以看到 hangfire 的三種腳色

  1. Client:負責建立各種任務,可藉由 hangfire 提供的方法建立即時、延遲、重複任務。由 Client 負責將其序列化之後儲存於Storage
  2. Storage:儲存任務資料用,有很多 Storage 套件可供選擇,支援 SQL、Redis 等常見主流的 storage 方案
  3. Server:從storage中取得任務並執行

How to Start

建立 dotnet core mvc 專案

1
dotnet new mvc

安裝 nuget 套件

  1. Hangfire.AspNetCore
  2. Hangfire.MemoryStorage

此處因為練習而採用 memory storage,production 記得要改用持久化的 storage 解決方案

啟用 hangfire dashboard

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
26
27
28
29
30
31
32
33
34
//startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
//...略
// 加入 hangfire 的server實體,可重複此行加入多個實體
app.UseHangfireServer();

// 加入 hangfire 控制面板
app.UseHangfireDashboard(
pathMatch: "/hangfire",
options: new DashboardOptions() {
// 進入 hangfire dashboard 的授權規則 (有沒有權限看 dashboard 就看這個邏輯怎麼設定)
Authorization = new[] { new MyAuthFilter() }
}
);

app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}

public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddHangfire(config => {
// 使用 memory storage
config.UseMemoryStorage();
// 使用 console
config.UseConsole();
});
}
1
2
3
4
5
6
7
public class MyAuthFilter : IDashboardAuthorizationFilter {
public bool Authorize([NotNull] DashboardContext context) {
// 後面可以改別的邏輯,例如判斷session是否存在,或是identity claim等等
// 此處測試直接讓任何人都可以瀏覽
return true;
}
}

設定 dashboard 並建立第一個 job

1
2
3
4
5
6
7
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IBackgroundJobClient backgroundJobs)
{
// ...略...
// 測試需要,直接在startup.cs裡面加入即時任務
backgroundJobs.Enqueue(() => Console.WriteLine("Hello world from Hangfire!"));
// ...略...
}

這樣一來就會可以在網站路由/hangfire底下看到剛剛建立的Console.WriteLine已經完成

Job 類型

Fire-and-forget jobs

依照Calling methods in background一文說明,我們在上一個步驟執行的就是背景調用方法

1
var jobId = BackgroundJob.Enqueue(() => Console.WriteLine("Fire-and-forget!"));

實際上他並不是當下就執行那個任務,而是轉為下列行為:

  1. 序列化方法及參數
  2. 根據序列化的內容建立一個新的背景執行任務
  3. 將這個背景執行任務儲存於我們所設定的持久化儲存機制內,也就是storage
  4. 背景執行任務放在 Queue 的執行隊列中排隊

做完了這些事情後,程式返回原先的地方依序往下執行

上面這一段有點饒舌,簡單來說就是,當我們使用Enqueue這個方法執行 Console.WriteLine 的時候,其實是把這個東西轉變成一個背景任務,放到 hangfire 的 Queue 裡面排隊。

那他什麼時候才會執行呢?這就要靠另外一個腳色hangfire server來檢查 Queue 裡面還有沒有任務要執行,有的話它會先把這個任務隱藏,這樣其他的人就沒辦法看到這個任務,這個是為了避免同一個任務被多次執行,接著就是執行任務了,等到執行完成,他才會把這個任務從 Queue 裡面刪除

Delayed jobs

中文有點難理解,英文原文反而很清楚,所以其實學 IT 的人應該還是要習慣看英文啦~

官網文件介紹這個情境是,假設有新會員註冊,也許你會希望在會員註冊的第二天發送一封 Email 給他們。我覺得這樣的介紹很不錯,看其他人的 Blog 都說有好幾種排程,我就在想那什麼時候會用到這些東西呢?果然官網沒有讓我失望啊。正好呼應了學新東西最好的方式,就是看官網文件

下面就是建立一個延遲調用的語法範例,看起來很好懂啊

1
var jobId = BackgroundJob.Schedule(() => Console.WriteLine("Delayed!"), TimeSpan.FromDays(7));

但是其實這邊暴露了另外一個重要資訊喔,那就是hangfire server要多久來檢查定期任務呢?所以其實是有提供設定方式的

1
2
3
4
5
6
var options = new BackgroundJobServerOptions
{
SchedulePollingInterval = TimeSpan.FromMinutes(1)
};

var server = new BackgroundJobServer(options);

這裡還有特別提到如果是在 ASP.NET 應用程式的話,還需要做一些額外的事情,詳情就看一下官網說明

Recurring jobs

官網範例只有一行,Cron 類別看來是跟 linux crontab 一樣的東西,可以看一下鳥哥的文章第十五章、例行性工作排程(crontab),或者是wiki 說明

1
2
3
RecurringJob.AddOrUpdate(() => Console.WriteLine("Recurring!"), Cron.Daily);
// 或是使用Cron表達式
RecurringJob.AddOrUpdate(() => Console.Write("Powerful!"), "0 12 * */2");

加入識別 ID 語法如下

1
2
// 給予周期性任務一個識別ID
RecurringJob.AddOrUpdate("some-id", () => Console.WriteLine("wow!!"), Cron.Hourly);

識別 ID 在某些 Storage 可能會區分大小寫;識別 ID 應為 Unique 值

實際上會在 dashboard 顯示

那為甚麼要有識別 ID 咧?因為要方便我們操作啊。我們可以透過識別 ID 去觸發執行,或是移除

1
2
3
4
5
// 移除某個周期性任務,如果不存在也不會報錯
RecurringJob.RemoveIfExists("some-id");

// 觸發執行某個周期性任務,不影響原先設定的執行任務時間間隔
RecurringJob.Trigger("some-id");

想看原文的請 follow這裡

Batches & Batch Continuations

付費功能,有興趣請自行瀏覽官網說明

加入預設儀錶板資訊

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void ConfigureServices(IServiceCollection services)
{
//...略...
services.AddHangfire(config => {
// 使用 memory storage
config.UseMemoryStorage();
// 使用 console
config.UseConsole();
config.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.ServerCount) //服务器数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.RecurringJobCount) //任务数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.RetriesCount) //重试次数
//.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.EnqueuedCountOrNull)//队列数量
//.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.FailedCountOrNull)//失败数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.EnqueuedAndQueueCount) //队列数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.ScheduledCount) //计划任务数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.ProcessingCount) //执行中的任务数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.SucceededCount) //成功作业数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.FailedCount) //失败数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.DeletedCount) //删除数量
.UseDashboardMetric(Hangfire.Dashboard.DashboardMetrics.AwaitingCount); //等待任务数量
});
//...略...
}

單元測試

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
26
27
28
29
30
31
32
33
public class MyJob
{
protected IBackgroundJobClient JobClient
{
get => this._jobClient ?? (this._jobClient = new BackgroundJobClient());
set => this._jobClient = value;
}
private IBackgroundJobClient _jobClient;
public MyJob() : this(new BackgroundJobClient())
{
}

public MyJob(IBackgroundJobClient jobClient)
{
this.JobClient = jobClient;
}

[Hangfire.Dashboard.Management.Support.Job]
[DisplayName("呼叫內部方法")]
public void SomeWork01(PerformContext context = null, IJobCancellationToken cancellationToken = null)
{
if (cancellationToken.ShutdownToken.IsCancellationRequested)
{
return;
}
context.WriteLine($"測試用,Now:{DateTime.Now}");
Thread.Sleep(30000);
}
public void EnqueueJob()
{
this.JobClient.Enqueue(() => this.SomeWork01(null, JobCancellationToken.Null));
}
}

production 的部分,MyJob 的 EnqueueJob 方法,負責的事情就是將某一件任務進行排程;而我們的單元測試,也只是要驗證這一件事情。

因此在單元測試的部分,我們首先建立一個 Mock,並透過建構式注入,稍後才可以透過 mock 物件檢查是否有接收到參數,檢查的部分我們僅驗證任務的名稱是否正確

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[TestClass]
public class UnitTest1
{
[TestMethod]
public void Enqueue驗證有呼叫Create方法()
{
//arrange
var mockJobClient = Substitute.For<IBackgroundJobClient>();
var demoJob = new MyJob(mockJobClient);
//act
demoJob.EnqueueJob();
//assert
mockJobClient.Received().Create(
Arg.Is<Job>(p => p.Method.Name == nameof(MyJob.SomeWork01)),
Arg.Any<EnqueuedState>()
);
}
}