在ASP.NET Core中建立基於Quartz.NET託管服務輕鬆實現作業排程
阿新 • • 發佈:2020-04-07
在這篇文章中,我將介紹如何使用[ASP.NET Core託管服務](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1)執行Quartz.NET作業。這樣的好處是我們可以在應用程式啟動和停止時很方便的來控制我們的Job的執行狀態。接下來我將演示如何建立一個簡單的 `IJob`,一個自定義的 `IJobFactory`和一個在應用程式執行時就開始執行的`QuartzHostedService`。我還將介紹一些需要注意的問題,即在單例類中使用作用域服務。
> 作者:依樂祝
>
> 首發地址:https://www.cnblogs.com/yilezhu/p/12644208.html
>
> 參考英文地址:https://andrewlock.net/creating-a-quartz-net-hosted-service-with-asp-net-core/
## 簡介-什麼是Quartz.NET?
在開始介紹什麼是Quartz.NET前先看一下下面這個圖,這個圖基本概括了Quartz.NET的所有核心內容。
> 注:此圖為百度上獲取,旨在學習交流使用,如有侵權,聯絡後刪除。
![Quartz.NET](https://img2020.cnblogs.com/blog/1377250/202004/1377250-20200406204033814-1955496131.png)
以下來自[他們的網站](https://www.quartz-scheduler.net/)的描述:
> Quartz.NET是功能齊全的開源作業排程系統,適用於從最小型的應用程式到大型企業系統。
對於許多ASP.NET開發人員來說它是首選,用作在計時器上以可靠、叢集的方式執行後臺任務的方法。將Quartz.NET與ASP.NET Core一起使用也非常相似-因為Quartz.NET支援.NET Standard 2.0,因此您可以輕鬆地在應用程式中使用它。
Quartz.NET有兩個主要概念:
- **Job**。這是您要按某個特定時間表執行的後臺任務。
- **Scheduler**。這是負責基於觸發器,基於時間的計劃執行作業。
ASP.NET Core通過[託管服務](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services)對執行“後臺任務”具有良好的支援。託管服務在ASP.NET Core應用程式啟動時啟動,並在應用程式生命週期內在後臺執行。通過建立Quartz.NET託管服務,您可以使用標準ASP.NET Core應用程式在後臺執行任務。
雖然可以建立[“定時”後臺服務](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1#timed-background-tasks)(例如,每10分鐘執行一次任務),但Quartz.NET提供了更為強大的解決方案。通過使用[Cron觸發器](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontriggers.html),您可以確保任務僅在一天的特定時間(例如,凌晨2:30)執行,或僅在特定的幾天執行,或任意組合執行。它還允許您以叢集方式執行應用程式的多個例項,以便在任何時候只能執行一個例項(高可用)。
在本文中,我將介紹建立Quartz.NET作業的基本知識並將其排程為在託管服務中的計時器上執行。
## 安裝Quartz.NET
Quartz.NET是.NET Standard 2.0 NuGet軟體包,因此非常易於安裝在您的應用程式中。對於此測試,我建立了一個ASP.NET Core專案並選擇了Empty模板。您可以使用`dotnet add package Quartz`來安裝Quartz.NET軟體包。這時候檢視該專案的*.csproj*,應如下所示:
```xml
netcoreapp3.1
```
## 建立一個IJob
對於我們正在安排的實際後臺工作,我們將通過向注入的`ILogger<>`中寫入“ hello world”來進行實現進而向控制檯輸出結果)。您必須實現包含單個非同步`Execute()`方法的Quartz介面`IJob`。請注意,這裡我們使用依賴注入將日誌記錄器注入到建構函式中。
```csharp
using Microsoft.Extensions.Logging;
using Quartz;
using System;
using System.Threading.Tasks;
namespace QuartzHostedService
{
[DisallowConcurrentExecution]
public class HelloWorldJob : IJob
{
private readonly ILogger _logger;
public HelloWorldJob(ILogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Hello world by yilezhu at {0}!",DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
return Task.CompletedTask;
}
}
}
```
我還用`[DisallowConcurrentExecution]`屬性裝飾了該作業。該屬性[可防止Quartz.NET嘗試同時運行同一作業](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-jobs.html#job-state-and-concurrency)。
## 建立一個IJobFactory
接下來,我們需要告訴Quartz如何建立`IJob`的例項。預設情況下,Quartz將使用`Activator.CreateInstance`建立作業例項,從而有效的呼叫`new HelloWorldJob()`。不幸的是,由於我們使用建構函式注入,因此無法正常工作。相反,我們可以提供一個自定義的`IJobFactory`掛鉤到ASP.NET Core依賴項注入容器(`IServiceProvider`)中:
```csharp
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.Spi;
using System;
namespace QuartzHostedService
{
public class SingletonJobFactory : IJobFactory
{
private readonly IServiceProvider _serviceProvider;
public SingletonJobFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
{
return _serviceProvider.GetRequiredService(bundle.JobDetail.JobType) as IJob;
}
public void ReturnJob(IJob job)
{
}
}
}
```
該工廠將一個`IServiceProvider`傳入建構函式中,並實現`IJobFactory`介面。這裡最重要的方法是`NewJob()`方法。在這個方法中工廠必須返回Quartz排程程式所請求的`IJob`。在此實現中,我們直接委託給`IServiceProvider`,並讓DI容器找到所需的例項。由於`GetRequiredService`的非泛型版本返回的是一個物件,因此我們必須在末尾將其強制轉換成`IJob`。
該`ReturnJob`方法是排程程式嘗試返回(即銷燬)工廠建立的作業的地方。不幸的是,使用內建的`IServiceProvider`沒有這樣做的機制。我們無法建立適合Quartz API所需的新的`IScopeService`,因此我們只能建立單例作業。
> 這個很重要。使用上述實現,僅對建立**單例**(或瞬態)的`IJob`實現是安全的。
## 配置作業
我在`IJob`這裡僅顯示一個實現,但是我們希望Quartz託管服務是適用於任何數量作業的通用實現。為了解決這個問題,我們建立了一個簡單的DTO `JobSchedule`,用於定義給定作業型別的計時器計劃:
```csharp
using System;
using System.ComponentModel;
namespace QuartzHostedService
{
///
/// Job排程中間物件
///
public class JobSchedule
{
public JobSchedule(Type jobType, string cronExpression)
{
this.JobType = jobType ?? throw new ArgumentNullException(nameof(jobType));
CronExpression = cronExpression ?? throw new ArgumentNullException(nameof(cronExpression));
}
///
/// Job型別
///
public Type JobType { get; private set; }
///
/// Cron表示式
///
public string CronExpression { get; private set; }
///
/// Job狀態
///
public JobStatus JobStatu { get; set; } = JobStatus.Init;
}
///
/// Job執行狀態
///
public enum JobStatus:byte
{
[Description("初始化")]
Init=0,
[Description("執行中")]
Running=1,
[Description("排程中")]
Scheduling = 2,
[Description("已停止")]
Stopped = 3,
}
}
```
這裡的`JobType`是該作業的.NET型別(在我們的例子中就是`HelloWorldJob`),並且`CronExpression`是一個[Quartz.NET的Cron表達](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontriggers.html)。Cron表示式允許複雜的計時器排程,因此您可以設定下面複雜的規則,例如“每月5號和20號在上午8點至10點之間每半小時觸發一次”。只需確保[檢查文件](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontriggers.html)即可,因為並非所有作業系統所使用的Cron表示式都是可以互換的。
我們將作業新增到DI並在`Startup.ConfigureServices()`中配置其時間表:
```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Quartz;
using Quartz.Impl;
using Quartz.Spi;
namespace QuartzHostedService
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
//新增Quartz服務
services.AddSingleton();
services.AddSingleton();
//新增我們的Job
services.AddSingleton();
services.AddSingleton(
new JobSchedule(jobType: typeof(HelloWorldJob), cronExpression: "0/5 * * * * ?")
);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
......
}
}
}
```
此程式碼將四個內容作為單例新增到DI容器:
- `SingletonJobFactory` 是前面介紹的,用於建立作業例項。
- 一個`ISchedulerFactory`的實現,使用內建的`StdSchedulerFactory`,它可以處理排程和管理作業
- 該`HelloWorldJob`作業本身
- 一個型別為`HelloWorldJob`,幷包含一個五秒鐘執行一次的Cron表示式的`JobSchedule`的例項化物件。
現在我們已經完成了大部分基礎工作,只缺少一個將他們組合在一起的、`QuartzHostedService`了。
## 建立QuartzHostedService
該`QuartzHostedService`是`IHostedService`的一個實現,設定了Quartz排程程式,並且啟用它並在後臺執行。由於Quartz的設計,我們可以在`IHostedService`中直接實現它,而不是[從基`BackgroundService`類派生更常見的方法](https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/background-tasks-with-ihostedservice)。該服務的完整程式碼在下面列出,稍後我將對其進行詳細描述。
```csharp
using Microsoft.Extensions.Hosting;
using Quartz;
using Quartz.Spi;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace QuartzHostedService
{
public class QuartzHostedService : IHostedService
{
private readonly ISchedulerFactory _schedulerFactory;
private readonly IJobFactory _jobFactory;
private readonly IEnumerable _jobSchedules;
public QuartzHostedService(ISchedulerFactory schedulerFactory, IJobFactory jobFactory, IEnumerable jobSchedules)
{
_schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory));
_jobFactory = jobFactory ?? throw new ArgumentNullException(nameof(jobFactory));
_jobSchedules = jobSchedules ?? throw new ArgumentNullException(nameof(jobSchedules));
}
public IScheduler Scheduler { get; set; }
public async Task StartAsync(CancellationToken cancellationToken)
{
Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
Scheduler.JobFactory = _jobFactory;
foreach (var jobSchedule in _jobSchedules)
{
var job = CreateJob(jobSchedule);
var trigger = CreateTrigger(jobSchedule);
await Scheduler.ScheduleJob(job, trigger, cancellationToken);
jobSchedule.JobStatu = JobStatus.Scheduling;
}
await Scheduler.Start(cancellationToken);
foreach (var jobSchedule in _jobSchedules)
{
jobSchedule.JobStatu = JobStatus.Running;
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await Scheduler?.Shutdown(cancellationToken);
foreach (var jobSchedule in _jobSchedules)
{
jobSchedule.JobStatu = JobStatus.Stopped;
}
}
private static IJobDetail CreateJob(JobSchedule schedule)
{
var jobType = schedule.JobType;
return JobBuilder
.Create(jobType)
.WithIdentity(jobType.FullName)
.WithDescription(jobType.Name)
.Build();
}
private static ITrigger CreateTrigger(JobSchedule schedule)
{
return TriggerBuilder
.Create()
.WithIdentity($"{schedule.JobType.FullName}.trigger")
.WithCronSchedule(schedule.CronExpression)
.WithDescription(schedule.CronExpression)
.Build();
}
}
}
```
該`QuartzHostedService`有三個依存依賴項:我們在`Startup`中配置的`ISchedulerFactory`和`IJobFactory`,還有一個就是`IEnumerable`。我們僅向DI容器中添加了一個`JobSchedule`物件(即`HelloWorldJob`),但是如果您在DI容器中註冊更多的工作計劃,它們將全部注入此處(當然,你也可以通過資料庫來進行獲取,再加以UI控制,是不是就實現了一個視覺化的後臺排程了呢?自己想象吧~)。
`StartAsync`方法將在應用程式啟動時被呼叫,因此這裡就是我們配置Quartz的地方。我們首先一個`IScheduler`的例項,將其分配給屬性以供後面使用,然後將注入的`JobFactory`例項設定給排程程式:
```csharp
public async Task StartAsync(CancellationToken cancellationToken)
{
Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
Scheduler.JobFactory = _jobFactory;
...
}
```
接下來,我們迴圈注入作業計劃,併為每一個作業使用在類的結尾處定義的`CreateJob`和`CreateTrigger`輔助方法在建立一個Quartz的`IJobDetail`和`ITrigger`。如果您不喜歡這部分的工作方式,或者需要對配置進行更多控制,則可以通過按需擴充套件`JobSchedule`DTO 來輕鬆自定義它。
```csharp
public async Task StartAsync(CancellationToken cancellationToken)
{
// ...
foreach (var jobSchedule in _jobSchedules)
{
var job = CreateJob(jobSchedule);
var trigger = CreateTrigger(jobSchedule);
await Scheduler.ScheduleJob(job, trigger, cancellationToken);
jobSchedule.JobStatu = JobStatus.Scheduling;
}
// ...
}
private static IJobDetail CreateJob(JobSchedule schedule)
{
var jobType = schedule.JobType;
return JobBuilder
.Create(jobType)
.WithIdentity(jobType.FullName)
.WithDescription(jobType.Name)
.Build();
}
private static ITrigger CreateTrigger(JobSchedule schedule)
{
return TriggerBuilder
.Create()
.WithIdentity($"{schedule.JobType.FullName}.trigger")
.WithCronSchedule(schedule.CronExpression)
.WithDescription(schedule.CronExpression)
.Build();
}
```
最後,一旦所有作業都被安排好,您就可以呼叫它的`Scheduler.Start()`來在後臺實際開始Quartz.NET計劃程式的處理。當應用程式關閉時,框架將呼叫`StopAsync()`,此時您可以呼叫`Scheduler.Stop()`以安全地關閉排程程式程序。
```csharp
public async Task StopAsync(CancellationToken cancellationToken)
{
await Scheduler?.Shutdown(cancellationToken);
}
```
您可以使用`AddHostedService()`擴充套件方法在託管服務`Startup.ConfigureServices`中注入我們的後臺服務:
```csharp
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddHostedService();
}
```
如果執行該應用程式,則應該看到每隔5秒執行一次後臺任務並寫入控制檯中(或配置日誌記錄的任何地方)
![image-20200406151153107](https://img2020.cnblogs.com/blog/1377250/202004/1377250-20200406202537681-940719845.png)
## 在作業中使用作用域服務
這篇文章中描述的實現存在一個大問題:您只能建立Singleton或Transient作業。這意味著您不能使用註冊為作用域服務的任何依賴項。例如,您將無法將EF Core的 `DatabaseContext`注入您的`IJob`實現中,因為您會遇到[Captive Dependency](http://blog.ploeh.dk/2014/06/02/captive-dependency/)問題。
解決這個問題也不是很難:您可以注入`IServiceProvider`並建立自己的作用域。例如,如果您需要在`HelloWorldJob`中使用作用域服務,則可以使用以下內容:
```csharp
public class HelloWorldJob : IJob
{
// 注入DI provider
private readonly IServiceProvider _provider;
public HelloWorldJob( IServiceProvider provider)
{
_provider = provider;
}
public Task Execute(IJobExecutionContext context)
{
// 建立一個新的作用域
using(var scope = _provider.CreateScope())
{
// 解析你的作用域服務
var service = scope.ServiceProvider.GetService();
_logger.LogInformation("Hello world by yilezhu at {0}!",DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
}
return Task.CompletedTask;
}
}
```
這樣可以確保在每次執行作業時都建立一個新的作用域,因此您可以在`IJob`中檢索(並處理)作用域服務。糟糕的是,這樣的寫法確實有些混亂。在下一篇文章中,我將展示另一種比較優雅的實現方式,它更簡潔,有興趣的可以關注下“DotNetCore實戰”公眾號第一時間獲取更新。
## 總結
在這篇文章中,我介紹了Quartz.NET,並展示瞭如何使用它在ASP.NET Core中的`IHostedService`中來排程後臺作業。這篇文章中顯示的示例最適合單例或瞬時作業,這並不理想,因為使用作用域服務顯得很笨拙。在下一篇文章中,我將展示另一種比較優雅的實現方式,它更簡潔,並使得使用作用域服務更容易,有興趣的可以關注下“DotNetCore實戰”公眾號第一時間獲取更新。