10

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

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzU4Mjc4NzgyOQ%3D%3D&%3Bmid=2247484881&%3Bidx=1&%3Bsn=813d612b8dccaa73065ee64df1953e7b
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

vAVNbm6.gif

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

简介-什么是Quartz.NET?

在开始介绍什么是Quartz.NET前先看一下下面这个图,这个图基本概括了Quartz.NET的所有核心内容。

注:此图为百度上获取,旨在学习交流使用,如有侵权,联系后删除。

nyEryuA.png!web

以下来自他们的网站的描述:

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 ,应如下所示:

<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 。请注意,这里我们使用依赖注入将日志记录器注入到构造函数中。

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 )中:

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 ,用于定义给定作业类型的计时器计划:

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() 中配置其时间表:

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 类派生更常见的方法。该服务的完整代码在下面列出,稍后我将对其进行详细描述。

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 实例设置给调度程序:

 public async Task StartAsync(CancellationToken cancellationToken)
        {
            Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
            Scheduler.JobFactory = _jobFactory;
            ...
        }

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

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() 以安全地关闭调度程序进程。

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

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

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

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

AfAbuer.png!web

在作业中使用作用域服务

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

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

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实战”公众号第一时间获取更新。

首发地址:https://www.yuque.com/yilezhu/etg3w3/aspnetcore-hostservice-quartz-net-1

参考英文地址:https://andrewlock.net/creating-a-quartz-net-hosted-service-with-asp-net-core/

往期 精彩 回顾

【推荐】.NET Core开发实战视频课程   ★★★

.NET Core实战项目之CMS 第一章 入门篇-开篇及总体规划

【.NET Core微服务实战-统一身份认证】开篇及目录索引

Redis基本使用及百亿数据量中的使用技巧分享(附视频地址及观看指南)

.NET Core中的一个接口多种实现的依赖注入与动态选择看这篇就够了

10个小技巧助您写出高性能的ASP.NET Core代码

用abp vNext快速开发Quartz.NET定时任务管理界面

现身说法:实际业务出发分析百亿数据量下的多表查询优化

关于C#异步编程你应该了解的几点建议

C#异步编程看这篇就够了

给我好看

NJfYJfq.jpg!web

您看此文用

·

秒,转发只需1秒呦~

nqIBZv6.png!web

好看你就

点点


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK