在ASP.NET Core中创建基于Quartz.NET托管服务轻松实现作业调度

2020-04-09 15:10:16 浏览数 (1)

在这篇文章中,我将介绍如何使用ASP.NET Core托管服务运行Quartz.NET作业。这样的好处是我们可以在应用程序启动和停止时很方便的来控制我们的Job的运行状态。接下来我将演示如何创建一个简单的 IJob,一个自定义的 IJobFactory和一个在应用程序运行时就开始运行的QuartzHostedService。我还将介绍一些需要注意的问题,即在单例类中使用作用域服务。

作者:依乐祝 首发地址:https://cloud.tencent.com/developer/article/1611659 参考英文地址:https://andrewlock.net/creating-a-quartz-net-hosted-service-with-asp-net-core/

简介-什么是Quartz.NET?

在开始介绍什么是Quartz.NET前先看一下下面这个图,这个图基本概括了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,应如下所示:

代码语言:javascript复制
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Quartz" Version="3.0.7" />
  </ItemGroup>

</Project>

创建一个IJob

对于我们正在安排的实际后台工作,我们将通过向注入的ILogger<>中写入“ hello world”来进行实现进而向控制台输出结果)。您必须实现包含单个异步Execute()方法的Quartz接口IJob。请注意,这里我们使用依赖注入将日志记录器注入到构造函数中。

代码语言:javascript复制
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;
        }
    }
}

我还用[DisallowConcurrentExecution]属性装饰了该作业。该属性可防止Quartz.NET尝试同时运行同一作业。

创建一个IJobFactory

接下来,我们需要告诉Quartz如何创建IJob的实例。默认情况下,Quartz将使用Activator.CreateInstance创建作业实例,从而有效的调用new HelloWorldJob()。不幸的是,由于我们使用构造函数注入,因此无法正常工作。相反,我们可以提供一个自定义的IJobFactory挂钩到ASP.NET Core依赖项注入容器(IServiceProvider)中:

代码语言:javascript复制
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,用于定义给定作业类型的计时器计划:

代码语言:javascript复制
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,

    }
}

这里的JobType是该作业的.NET类型(在我们的例子中就是HelloWorldJob),并且CronExpression是一个Quartz.NET的Cron表达。Cron表达式允许复杂的计时器调度,因此您可以设置下面复杂的规则,例如“每月5号和20号在上午8点至10点之间每半小时触发一次”。只需确保检查文档即可,因为并非所有操作系统所使用的Cron表达式都是可以互换的。

我们将作业添加到DI并在Startup.ConfigureServices()中配置其时间表:

代码语言:javascript复制
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, SingletonJobFactory>();
            services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();
            //添加我们的Job
            services.AddSingleton<HelloWorldJob>();
            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

QuartzHostedServiceIHostedService的一个实现,设置了Quartz调度程序,并且启用它并在后台运行。由于Quartz的设计,我们可以在IHostedService中直接实现它,而不是从基BackgroundService类派生更常见的方法。该服务的完整代码在下面列出,稍后我将对其进行详细描述。

代码语言:javascript复制
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();
        }
    }
}

QuartzHostedService有三个依存依赖项:我们在Startup中配置的ISchedulerFactoryIJobFactory,还有一个就是IEnumerable<JobSchedule>。我们仅向DI容器中添加了一个JobSchedule对象(即HelloWorldJob),但是如果您在DI容器中注册更多的工作计划,它们将全部注入此处(当然,你也可以通过数据库来进行获取,再加以UI控制,是不是就实现了一个可视化的后台调度了呢?自己想象吧~)。

StartAsync方法将在应用程序启动时被调用,因此这里就是我们配置Quartz的地方。我们首先一个IScheduler的实例,将其分配给属性以供后面使用,然后将注入的JobFactory实例设置给调度程序:

代码语言:javascript复制
 public async Task StartAsync(CancellationToken cancellationToken)
        {
            Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
            Scheduler.JobFactory = _jobFactory;
            ...
        }

接下来,我们循环注入作业计划,并为每一个作业使用在类的结尾处定义的CreateJobCreateTrigger辅助方法在创建一个Quartz的IJobDetailITrigger。如果您不喜欢这部分的工作方式,或者需要对配置进行更多控制,则可以通过按需扩展JobScheduleDTO 来轻松自定义它。

代码语言:javascript复制
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()以安全地关闭调度程序进程。

代码语言:javascript复制
public async Task StopAsync(CancellationToken cancellationToken)
{
    await Scheduler?.Shutdown(cancellationToken);
}

您可以使用AddHostedService()扩展方法在托管服务Startup.ConfigureServices中注入我们的后台服务:

代码语言:javascript复制
public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddHostedService<QuartzHostedService>();
}

如果运行该应用程序,则应该看到每隔5秒运行一次后台任务并写入控制台中(或配置日志记录的任何地方)

在作业中使用作用域服务

这篇文章中描述的实现存在一个大问题:您只能创建Singleton或Transient作业。这意味着您不能使用注册为作用域服务的任何依赖项。例如,您将无法将EF Core的 DatabaseContext注入您的IJob实现中,因为您会遇到Captive Dependency问题。

解决这个问题也不是很难:您可以注入IServiceProvider并创建自己的作用域。例如,如果您需要在HelloWorldJob中使用作用域服务,则可以使用以下内容:

代码语言:javascript复制
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;
    }
}

这样可以确保在每次运行作业时都创建一个新的作用域,因此您可以在IJob中检索(并处理)作用域服务。糟糕的是,这样的写法确实有些混乱。在下一篇文章中,我将展示另一种比较优雅的实现方式,它更简洁,有兴趣的可以关注下“DotNetCore实战”公众号第一时间获取更新。

总结

在这篇文章中,我介绍了Quartz.NET,并展示了如何使用它在ASP.NET Core中的IHostedService中来调度后台作业。这篇文章中显示的示例最适合单例或瞬时作业,这并不理想,因为使用作用域服务显得很笨拙。在下一篇文章中,我将展示另一种比较优雅的实现方式,它更简洁,并使得使用作用域服务更容易,有兴趣的可以关注下“DotNetCore实战”公众号第一时间获取更新。

0 人点赞