在.NET Core中使用Quartz.NET實現定時任務功能

如何使用ASP.NET Core託管服務運行Quartz.NET作業。這樣的好處是我們可以在應用程序啟動和停止時很方便的來控制我們的Job的運行狀態。接下來我將演示如何創建一個簡單的 IJob,一個自定義的 IJobFactory和一個在應用程序運行時就開始運行的QuartzHostedService。我還將介紹一些需要注意的問題,即在單例類中使用作用域服務。

簡介-什麼是Quartz.NET?

在開始介紹什麼是Quartz.NET前先看一下下面這個圖,這個圖基本概括了Quartz.NET的所有核心內容。

在.NET Core中使用Quartz.NET實現定時任務功能

以下來自他們的網站的描述:

Quartz.NET是功能齊全的開源作業調度系統,適用於從最小型的應用程序到大型企業系統。

對於許多ASP.NET開發人員來說它是首選,用作在計時器上以可靠、集群的方式運行後臺任務的方法。將Quartz.NET與ASP.NET Core一起使用也非常相似-因為Quartz.NET支持.NET Standard 2.0,因此您可以輕鬆地在應用程序中使用它。

Quartz.NET有兩個主要概念:

  • Job。這是您要按某個特定時間表運行的後臺任務。
  • Scheduler。這是負責基於觸發器,基於時間的計劃運行作業。

ASP.NET Core通過託管服務對運行“後臺任務”具有良好的支持。託管服務在ASP.NET Core應用程序啟動時啟動,並在應用程序生命週期內在後臺運行。通過創建Quartz.NET託管服務,您可以使用標準ASP.NET Core應用程序在後臺運行任務。

雖然可以創建“定時”後臺服務(例如,每10分鐘運行一次任務),但Quartz.NET提供了更為強大的解決方案。通過使用Cron觸發器,您可以確保任務僅在一天的特定時間(例如,凌晨2:30)運行,或僅在特定的幾天運行,或任意組合運行。它還允許您以集群方式運行應用程序的多個實例,以便在任何時候只能運行一個實例(高可用)。

在本文中,我將介紹創建Quartz.NET作業的基本知識並將其調度為在託管服務中的計時器上運行。

安裝Quartz.NET

Quartz.NET是.NET Standard 2.0 NuGet軟件包,因此非常易於安裝在您的應用程序中。對於此測試,我創建了一個ASP.NET Core項目並選擇了Empty模板。您可以使用dotnet add package Quartz來安裝Quartz.NET軟件包。這時候查看該項目的.csproj,應如下所示:

<code><project>

<propertygroup>
<targetframework>netcoreapp3.1/<targetframework>
/<propertygroup>

<itemgroup>
<packagereference>
/<itemgroup>

/<project>
/<code>

創建一個IJob

對於我們正在安排的實際後臺工作,我們將通過向注入的ILogger<>中寫入“ hello world”來進行實現進而向控制檯輸出結果)。您必須實現包含單個異步Execute()方法的Quartz接口IJob。請注意,這裡我們使用依賴注入將日誌記錄器注入到構造函數中。

<code>using Microsoft.Extensions.Logging;
using Quartz;
using System;
using System.Threading.Tasks;

namespace QuartzHostedService

{
[DisallowConcurrentExecution]
public class HelloWorldJob : IJob
{
private readonly ILogger<helloworldjob> _logger;

public HelloWorldJob(ILogger<helloworldjob> 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;
}
}
}
/<helloworldjob>/<helloworldjob>/<code>

我還用[DisallowConcurrentExecution]屬性裝飾了該作業。該屬性可防止Quartz.NET嘗試同時運行同一作業。

創建一個IJobFactory

接下來,我們需要告訴Quartz如何創建IJob的實例。默認情況下,Quartz將使用Activator.CreateInstance創建作業實例,從而有效的調用new HelloWorldJob()。不幸的是,由於我們使用構造函數注入,因此無法正常工作。相反,我們可以提供一個自定義的IJobFactory掛鉤到ASP.NET Core依賴項注入容器(IServiceProvider)中:

<code>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)
{

}
}
}
/<code>

該工廠將一個IServiceProvider傳入構造函數中,並實現IJobFactory接口。這裡最重要的方法是NewJob()方法。在這個方法中工廠必須返回Quartz調度程序所請求的IJob。在此實現中,我們直接委託給IServiceProvider,並讓DI容器找到所需的實例。由於GetRequiredService的非泛型版本返回的是一個對象,因此我們必須在末尾將其強制轉換成IJob。

該ReturnJob方法是調度程序嘗試返回(即銷燬)工廠創建的作業的地方。不幸的是,使用內置的IServiceProvider沒有這樣做的機制。我們無法創建適合Quartz API所需的新的IScopeService,因此我們只能創建單例作業。

這個很重要。使用上述實現,僅對創建單例(或瞬態)的IJob實現是安全的。

配置作業

我在IJob這裡僅顯示一個實現,但是我們希望Quartz託管服務是適用於任何數量作業的通用實現。為了解決這個問題,我們創建了一個簡單的DTO JobSchedule,用於定義給定作業類型的計時器計劃:

<code>using System;
using System.ComponentModel;

namespace QuartzHostedService
{
/// <summary>
/// Job調度中間對象
/// /<summary>
public class JobSchedule
{
public JobSchedule(Type jobType, string cronExpression)
{
this.JobType = jobType ?? throw new ArgumentNullException(nameof(jobType));
CronExpression = cronExpression ?? throw new ArgumentNullException(nameof(cronExpression));
}
/// <summary>
/// Job類型
/// /<summary>
public Type JobType { get; private set; }
/// <summary>
/// Cron表達式
/// /<summary>
public string CronExpression { get; private set; }
/// <summary>
/// Job狀態
/// /<summary>
public JobStatus JobStatu { get; set; } = JobStatus.Init;
}

/// <summary>
/// Job運行狀態
/// /<summary>
public enum JobStatus:byte
{
[Description("初始化")]
Init=0,
[Description("運行中")]
Running=1,
[Description("調度中")]

Scheduling = 2,
[Description("已停止")]
Stopped = 3,

}
}

/<code>

這裡的JobType是該作業的.NET類型(在我們的例子中就是HelloWorldJob),並且CronExpression是一個Quartz.NET的Cron表達。Cron表達式允許複雜的計時器調度,因此您可以設置下面複雜的規則,例如“每月5號和20號在上午8點至10點之間每半小時觸發一次”。只需確保檢查文檔即可,因為並非所有操作系統所使用的Cron表達式都是可以互換的。

我們將作業添加到DI並在Startup.ConfigureServices()中配置其時間表:

<code>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<ijobfactory>();
services.AddSingleton<ischedulerfactory>();
//添加我們的Job
services.AddSingleton<helloworldjob>();
services.AddSingleton(
new JobSchedule(jobType: typeof(HelloWorldJob), cronExpression: "0/5 * * * * ?")

);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
......
}
}
}
/<helloworldjob>/<ischedulerfactory>/<ijobfactory>/<code>

此代碼將四個內容作為單例添加到DI容器:

  • SingletonJobFactory 是前面介紹的,用於創建作業實例。
  • 一個ISchedulerFactory的實現,使用內置的StdSchedulerFactory,它可以處理調度和管理作業
  • 該HelloWorldJob作業本身
  • 一個類型為HelloWorldJob,幷包含一個五秒鐘運行一次的Cron表達式的JobSchedule的實例化對象。

現在我們已經完成了大部分基礎工作,只缺少一個將他們組合在一起的、QuartzHostedService了。

創建QuartzHostedService

該QuartzHostedService是IHostedService的一個實現,設置了Quartz調度程序,並且啟用它並在後臺運行。由於Quartz的設計,我們可以在IHostedService中直接實現它,而不是從基BackgroundService類派生更常見的方法。該服務的完整代碼在下面列出,稍後我將對其進行詳細描述。

<code>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<jobschedule> _jobSchedules;

public QuartzHostedService(ISchedulerFactory schedulerFactory, IJobFactory jobFactory, IEnumerable<jobschedule> 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();
}
}
}

/<jobschedule>/<jobschedule>/<code>

該QuartzHostedService有三個依存依賴項:我們在Startup中配置的ISchedulerFactory和IJobFactory,還有一個就是IEnumerable<jobschedule>。我們僅向DI容器中添加了一個JobSchedule對象(即HelloWorldJob),但是如果您在DI容器中註冊更多的工作計劃,它們將全部注入此處(當然,你也可以通過數據庫來進行獲取,再加以UI控制,是不是就實現了一個可視化的後臺調度了呢?自己想象吧~)。/<jobschedule>

StartAsync方法將在應用程序啟動時被調用,因此這裡就是我們配置Quartz的地方。我們首先一個IScheduler的實例,將其分配給屬性以供後面使用,然後將注入的JobFactory實例設置給調度程序:

<code> public async Task StartAsync(CancellationToken cancellationToken)
{

Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
Scheduler.JobFactory = _jobFactory;
...
}
/<code>

接下來,我們循環注入作業計劃,併為每一個作業使用在類的結尾處定義的CreateJob和CreateTrigger輔助方法在創建一個Quartz的IJobDetail和ITrigger。如果您不喜歡這部分的工作方式,或者需要對配置進行更多控制,則可以通過按需擴展JobScheduleDTO 來輕鬆自定義它。

<code>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();
}
/<code>

最後,一旦所有作業都被安排好,您就可以調用它的Scheduler.Start()來在後臺實際開始Quartz.NET計劃程序的處理。當應用程序關閉時,框架將調用StopAsync(),此時您可以調用Scheduler.Stop()以安全地關閉調度程序進程。

<code>public async Task StopAsync(CancellationToken cancellationToken)
{
await Scheduler?.Shutdown(cancellationToken);
}
/<code>

您可以使用AddHostedService()擴展方法在託管服務Startup.ConfigureServices中注入我們的後臺服務:

<code>public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddHostedService<quartzhostedservice>();
}
/<quartzhostedservice>/<code>

如果運行該應用程序,則應該看到每隔5秒運行一次後臺任務並寫入控制檯中(或配置日誌記錄的任何地方)

在.NET Core中使用Quartz.NET實現定時任務功能

在作業中使用作用域服務

這篇文章中描述的實現存在一個大問題:您只能創建Singleton或Transient作業。這意味著您不能使用註冊為作用域服務的任何依賴項。例如,您將無法將EF Core的 DatabaseContext注入您的IJob實現中,因為您會遇到Captive Dependency問題。

解決這個問題也不是很難:您可以注入IServiceProvider並創建自己的作用域。例如,如果您需要在HelloWorldJob中使用作用域服務,則可以使用以下內容:

<code>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<iscopedservice>();
_logger.LogInformation("Hello world by yilezhu at {0}!",DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
}

return Task.CompletedTask;
}
}
/<iscopedservice>/<code>

這樣可以確保在每次運行作業時都創建一個新的作用域,因此您可以在IJob中檢索(並處理)作用域服務。糟糕的是,這樣的寫法確實有些混亂。在下一篇文章中,我將展示另一種比較優雅的實現方式,它更簡潔,有興趣的可以關注下“DotNetCore實戰”公眾號第一時間獲取更新。


分享到:


相關文章: