Как начать Quartz в ASP.NET Core?

У меня есть следующий класс

 public class MyEmailService
 {
    public async Task<bool> SendAdminEmails()
    {
        ...
    }
    public async Task<bool> SendUserEmails()
    {
        ...
    }

 }
 public interface IMyEmailService
 {
    Task<bool> SendAdminEmails();
    Task<bool> SendUserEmails();
 }

Я установил последний Quartz 2.4.1 Nuget package, поскольку мне нужен легкий планировщик в моем веб-приложении без отдельной базы данных SQL Server.

Мне нужно запланировать методы

  • SendUserEmails для запуска каждую неделю по понедельникам 17: 00, по вторникам 17:00 и по средам 17:00.
  • SendAdminEmails для запуска каждую неделю по четвергам 09:00, по пятницам 9:00.

Какой код мне нужно для планирования этих методов с помощью Quartz в ASP.NET Core? Мне также нужно знать, как запустить Quartz в ASP.NET Core, поскольку все примеры кода в Интернете по-прежнему относятся к предыдущим версиям ASP.NET.

Я могу найти образец кода для предыдущей версии ASP.NET, но я не знаю, как запустить Quartz в ASP.NET Core для начала тестирования. Где я могу поместить JobScheduler.Start(); в ядро ​​ASP.NET?

Ответ 1

TL; DR (полный ответ можно найти ниже)

Предполагаемое оснащение: RTM для Visual Studio 2017,.NET Core 1.1,.NET Core SDK 1.0, SQL Server Express 2016 LocalDB.

В веб-приложении .csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <!-- .... existing contents .... -->

  <!-- add the following ItemGroup element, it adds required packages -->
  <ItemGroup>
    <PackageReference Include="Quartz" Version="3.0.0-alpha2" />
    <PackageReference Include="Quartz.Serialization.Json" Version="3.0.0-alpha2" />
  </ItemGroup>

</Project>

В классе Program (по умолчанию для Visual Studio по умолчанию):

public class Program
{
    private static IScheduler _scheduler; // add this field

    public static void Main(string[] args)
    {
        var host = new WebHostBuilder()
            .UseKestrel()
            .UseContentRoot(Directory.GetCurrentDirectory())
            .UseIISIntegration()
            .UseStartup<Startup>()
            .UseApplicationInsights()
            .Build();

        StartScheduler(); // add this line

        host.Run();
    }

    // add this method
    private static void StartScheduler()
    {
        var properties = new NameValueCollection {
            // json serialization is the one supported under .NET Core (binary isn't)
            ["quartz.serializer.type"] = "json",

            // the following setup of job store is just for example and it didn't change from v2
            // according to your usage scenario though, you definitely need 
            // the ADO.NET job store and not the RAMJobStore.
            ["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz",
            ["quartz.jobStore.useProperties"] = "false",
            ["quartz.jobStore.dataSource"] = "default",
            ["quartz.jobStore.tablePrefix"] = "QRTZ_",
            ["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz",
            ["quartz.dataSource.default.provider"] = "SqlServer-41", // SqlServer-41 is the new provider for .NET Core
            ["quartz.dataSource.default.connectionString"] = @"Server=(localdb)\MSSQLLocalDB;Database=Quartz;Integrated Security=true"
        };

        var schedulerFactory = new StdSchedulerFactory(properties);
        _scheduler = schedulerFactory.GetScheduler().Result;
        _scheduler.Start().Wait();

        var userEmailsJob = JobBuilder.Create<SendUserEmailsJob>()
            .WithIdentity("SendUserEmails")
            .Build();
        var userEmailsTrigger = TriggerBuilder.Create()
            .WithIdentity("UserEmailsCron")
            .StartNow()
            .WithCronSchedule("0 0 17 ? * MON,TUE,WED")
            .Build();

        _scheduler.ScheduleJob(userEmailsJob, userEmailsTrigger).Wait();

        var adminEmailsJob = JobBuilder.Create<SendAdminEmailsJob>()
            .WithIdentity("SendAdminEmails")
            .Build();
        var adminEmailsTrigger = TriggerBuilder.Create()
            .WithIdentity("AdminEmailsCron")
            .StartNow()
            .WithCronSchedule("0 0 9 ? * THU,FRI")
            .Build();

        _scheduler.ScheduleJob(adminEmailsJob, adminEmailsTrigger).Wait();
    }
}

Пример класса задания:

public class SendUserEmailsJob : IJob
{
    public Task Execute(IJobExecutionContext context)
    {
        // an instance of email service can be obtained in different ways, 
        // e.g. service locator, constructor injection (requires custom job factory)
        IMyEmailService emailService = new MyEmailService();

        // delegate the actual work to email service
        return emailService.SendUserEmails();
    }
}

Полный ответ

Кварц для .NET Core

Во-первых, вам нужно использовать v3 Quartz, поскольку он нацелен на .NET Core, согласно это объявление.

В настоящее время только альфа-версии пакетов v3 доступны в NuGet. Похоже, команда приложила немало усилий для выпуска 2.5.0, который не нацелен на .NET Core. Тем не менее, в своем репо GitHub ветвь master уже посвящена v3, и в основном открытые проблемы для версии v3, похоже, не быть критическим, главным образом старыми предметами списка желаний, ИМХО. Поскольку недавняя фиксация активности довольно низкая, я ожидал бы, что выпуск v3 через несколько месяцев или, может быть, полгода - но никто не знает.

Работа и переработка IIS

Если веб-приложение будет размещено в IIS, вы должны принять во внимание поведение в процессе переработки/разгрузки рабочих процессов. Веб-приложение ASP.NET Core работает как обычный процесс .NET Core, отдельно от w3wp.exe - IIS работает только как обратный прокси. Тем не менее, когда экземпляр w3wp.exe перерабатывается или выгружается, связанный с ним процесс приложения .NET Core также сигнализируется о выходе (согласно this),

Веб-приложение также может быть самообслуживанием за обратным прокси-сервером, отличным от IIS (например, NGINX), но я предполагаю, что вы используете IIS и соответствующим образом сужаете свой ответ.

Проблемы, связанные с внедрением/разгрузкой, хорошо объясняются в должности, на которую ссылается @darin-dimitrov:

  • Если, например, в пятницу 9:00 процесс не работает, поскольку за несколько часов до этого он был выгружен IIS из-за бездействия - электронные письма администратора не будут отправлены до тех пор, пока процесс не будет снова запущен. Чтобы этого избежать, настройте IIS, чтобы минимизировать выгрузку/рециркуляцию (см. Этот ответ).
    • По моему опыту, приведенная выше конфигурация по-прежнему не дает 100% гарантии того, что IIS никогда не выгрузит приложение. Для 100% гарантии, что ваш процесс завершен, вы можете настроить команду, которая периодически отправляет запросы вашему приложению и, таким образом, сохраняет его в живых.
  • Когда хост-процесс перезагружается/выгружается, задания должны быть изящно остановлены, чтобы избежать повреждения данных.

Зачем вам назначать запланированные задания в веб-приложении

Я могу думать об одном обосновании того, что эти задания электронной почты размещены в веб-приложении, несмотря на проблемы, перечисленные выше. Это решение иметь только одну модель приложения (ASP.NET). Такой подход упрощает кривую обучения, процедуру развертывания, мониторинг производства и т.д.

Если вы не хотите вводить бэкэнд-микросервисы (что было бы хорошим местом для перемещения заданий по электронной почте), тогда имеет смысл преодолеть поведение IIS по переработке/разгрузке и запустить Quartz внутри веб-приложения.

Или, может быть, у вас есть другие причины.

Постоянное хранилище заданий

В вашем сценарии статус выполнения задания должен быть сохранен вне процесса. Поэтому по умолчанию RAMJobStore не подходит, и вам нужно использовать хранилище заданий ADO.NET.

Поскольку вы упомянули SQL Server в вопросе, я приведу пример настройки базы данных SQL Server.

Как запустить (и изящно остановить) планировщик

Предполагаю, что вы используете Visual Studio 2017 и последнюю/последнюю версию инструментария .NET Core. Mine - это .NET Core Runtime 1.1 и .NET Core SDK 1.0.

В примере установки DB я использую базу данных с именем Quartz в SQL Server 2016 Express LocalDB. Сценарии установки DB могут быть найдены здесь.

Сначала добавьте требуемые ссылки на пакеты для веб-приложения .csproj(или сделайте это с помощью графического интерфейса менеджера пакетов NuGet в Visual Studio):

<Project Sdk="Microsoft.NET.Sdk.Web">

  <!-- .... existing contents .... -->

  <!-- the following ItemGroup adds required packages -->
  <ItemGroup>
    <PackageReference Include="Quartz" Version="3.0.0-alpha2" />
    <PackageReference Include="Quartz.Serialization.Json" Version="3.0.0-alpha2" />
  </ItemGroup>

</Project>

С помощью Руководство по миграции и Учебное пособие по V3, мы можем выяснить, как запустить и остановить планировщик. Я предпочитаю инкапсулировать это в отдельный класс, назовите его QuartzStartup.

using System;
using System.Collections.Specialized;
using System.Threading.Tasks;
using Quartz;
using Quartz.Impl;

namespace WebApplication1
{
    // Responsible for starting and gracefully stopping the scheduler.
    public class QuartzStartup
    {
        private IScheduler _scheduler; // after Start, and until shutdown completes, references the scheduler object

        // starts the scheduler, defines the jobs and the triggers
        public void Start()
        {
            if (_scheduler != null)
            {
                throw new InvalidOperationException("Already started.");
            }

            var properties = new NameValueCollection {
                // json serialization is the one supported under .NET Core (binary isn't)
                ["quartz.serializer.type"] = "json",

                // the following setup of job store is just for example and it didn't change from v2
                ["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz",
                ["quartz.jobStore.useProperties"] = "false",
                ["quartz.jobStore.dataSource"] = "default",
                ["quartz.jobStore.tablePrefix"] = "QRTZ_",
                ["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz",
                ["quartz.dataSource.default.provider"] = "SqlServer-41", // SqlServer-41 is the new provider for .NET Core
                ["quartz.dataSource.default.connectionString"] = @"Server=(localdb)\MSSQLLocalDB;Database=Quartz;Integrated Security=true"
            };

            var schedulerFactory = new StdSchedulerFactory(properties);
            _scheduler = schedulerFactory.GetScheduler().Result;
            _scheduler.Start().Wait();

            var userEmailsJob = JobBuilder.Create<SendUserEmailsJob>()
                .WithIdentity("SendUserEmails")
                .Build();
            var userEmailsTrigger = TriggerBuilder.Create()
                .WithIdentity("UserEmailsCron")
                .StartNow()
                .WithCronSchedule("0 0 17 ? * MON,TUE,WED")
                .Build();

            _scheduler.ScheduleJob(userEmailsJob, userEmailsTrigger).Wait();

            var adminEmailsJob = JobBuilder.Create<SendAdminEmailsJob>()
                .WithIdentity("SendAdminEmails")
                .Build();
            var adminEmailsTrigger = TriggerBuilder.Create()
                .WithIdentity("AdminEmailsCron")
                .StartNow()
                .WithCronSchedule("0 0 9 ? * THU,FRI")
                .Build();

            _scheduler.ScheduleJob(adminEmailsJob, adminEmailsTrigger).Wait();
        }

        // initiates shutdown of the scheduler, and waits until jobs exit gracefully (within allotted timeout)
        public void Stop()
        {
            if (_scheduler == null)
            {
                return;
            }

            // give running jobs 30 sec (for example) to stop gracefully
            if (_scheduler.Shutdown(waitForJobsToComplete: true).Wait(30000)) 
            {
                _scheduler = null;
            }
            else
            {
                // jobs didn't exit in timely fashion - log a warning...
            }
        }
    }
}

Примечание 1. В приведенном выше примере SendUserEmailsJob и SendAdminEmailsJob являются классами, реализующими IJob. Интерфейс IJob немного отличается от IMyEmailService, потому что он возвращает void Task, а не Task<bool>. Оба класса заданий должны получать IMyEmailService как зависимость (возможно, инъекцию конструктора).

Примечание 2. Для долговременного задания, которое может быть выполнено своевременно, в методе IJob.Execute он должен следить за состоянием IJobExecutionContext.CancellationToken. Это может потребовать изменения в интерфейсе IMyEmailService, чтобы его методы получили параметр CancellationToken:

public interface IMyEmailService
{
    Task<bool> SendAdminEmails(CancellationToken cancellation);
    Task<bool> SendUserEmails(CancellationToken cancellation);
}

Когда и где запускать и останавливать планировщик

В ASP.NET Core код начальной загрузки приложения находится в классе Program, что очень похоже на консольное приложение. Метод Main вызывается для создания веб-хоста, запуска его и ожидания до его завершения:

public class Program
{
    public static void Main(string[] args)
    {
        var host = new WebHostBuilder()
            .UseKestrel()
            .UseContentRoot(Directory.GetCurrentDirectory())
            .UseIISIntegration()
            .UseStartup<Startup>()
            .UseApplicationInsights()
            .Build();

        host.Run();
    }
}

Самое простое - просто поместить вызов QuartzStartup.Start прямо в метод Main, как и в TL; DR. Но так как мы также должны должным образом обрабатывать отключение процесса, я предпочитаю более точно использовать код запуска и завершения работы.

Эта строка:

.UseStartup<Startup>()

относится к классу с именем Startup, который подкрашивается при создании нового проекта ASP.NET Core Web Application в Visual Studio. Класс Startup выглядит следующим образом:

public class Startup
{
    public Startup(IHostingEnvironment env)
    {
        // scaffolded code...
    }

    public IConfigurationRoot Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        // scaffolded code...
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        // scaffolded code...
    }
}

Ясно, что вызов QuartzStartup.Start должен быть вставлен в один из методов класса Startup. Вопрос в том, где QuartzStartup.Stop должен быть подключен.

В старой .NET Framework ASP.NET предоставил интерфейс IRegisteredObject. В соответствии с этот пост и документация в ASP.NET Core был заменен на IApplicationLifetime. Бинго. Экземпляр IApplicationLifetime можно ввести в метод Startup.Configure через параметр.

Для согласованности я привяжу оба QuartzStartup.Start и QuartzStartup.Stop к IApplicationLifetime:

public class Startup
{
    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(
        IApplicationBuilder app, 
        IHostingEnvironment env, 
        ILoggerFactory loggerFactory, 
        IApplicationLifetime lifetime) // added this parameter
    {
        // the following 3 lines hook QuartzStartup into web host lifecycle
        var quartz = new QuartzStartup();
        lifetime.ApplicationStarted.Register(quartz.Start);
        lifetime.ApplicationStopping.Register(quartz.Stop);

        // .... original scaffolded code here ....
    }

    // ....the rest of the scaffolded members ....
}

Обратите внимание, что я расширил сигнатуру метода Configure с помощью дополнительного параметра IApplicationLifetime. Согласно документации, ApplicationStopping будет блокироваться до тех пор, пока не будут завершены зарегистрированные обратные вызовы.

Изящное завершение работы в IIS Express и ядро ​​ASP.NET Core

Мне удалось наблюдать ожидаемое поведение IApplicationLifetime.ApplicationStopping только для IIS, с установленным последним модулем ASP.NET Core. И IIS Express (установленный с RTM для Visual Studio 2017), и IIS с устаревшей версией модуля ASP.NET Core не вызывали последовательно IApplicationLifetime.ApplicationStopping. Я считаю, что из-за эта ошибка была исправлена.

Вы можете установить последнюю версию модуля ASP.NET Core здесь. Следуйте инструкциям в разделе "Установка последнего основного модуля ASP.NET".

Кварц против FluentScheduler

Я также взглянул на FluentScheduler, поскольку он был предложен в качестве альтернативной библиотеки @Brice Molesti. К моему первому впечатлению, FluentScheduler - довольно упрощенное и незрелое решение по сравнению с Quartz. Например, FluentScheduler не предоставляет таких фундаментальных функций, как сохранение статуса работы и кластерное выполнение.

Ответ 2

В дополнение к ответу @felix-b. Добавление DI на работу. Также QuartzStartup Start можно сделать асинхронным.

На основании этого ответа: fooobar.com/questions/6357611/...

public class QuartzStartup 
{
    public QuartzStartup(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task Start()
    {
        // other code is same
        _scheduler = await schedulerFactory.GetScheduler();
        _scheduler.JobFactory = new JobFactory(_serviceProvider);

        await _scheduler.Start();
        var sampleJob = JobBuilder.Create<SampleJob>().Build();
        var sampleTrigger = TriggerBuilder.Create().StartNow().WithCronSchedule("0 0/1 * * * ?").Build();
        await _scheduler.ScheduleJob(sampleJob, sampleTrigger);
    }
}

Класс JobFactory

public class JobFactory : IJobFactory
{
    private IServiceProvider _serviceProvider;
    public JobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        return _serviceProvider.GetService(bundle.JobDetail.JobType) as IJob;
    }

    public void ReturnJob(IJob job)
    {
        (job as IDisposable)?.Dispose();
    }
}

Класс запуска:

public void ConfigureServices(IServiceCollection services)
{
     // other code is removed for brevity
     // need to register all JOBS by their class name
     services.AddTransient<SampleJob>();
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime applicationLifetime)
{
    var quartz = new QuartzStartup(_services.BuildServiceProvider());
    applicationLifetime.ApplicationStarted.Register(() => quartz.Start());
    applicationLifetime.ApplicationStopping.Register(quartz.Stop);

    // other code removed for brevity
}

Класс SampleJob с внедрением зависимостей в конструктор:

public class SampleJob : IJob
{
    private readonly ILogger<SampleJob> _logger;

    public SampleJob(ILogger<SampleJob> logger)
    {
        _logger = logger;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogDebug("Execute called");
    }
}

Ответ 3

Я не знаю, как это сделать с Quartz, но я экспериментировал с тем же сценарием с другой библиотекой, которая работает очень хорошо. Вот как я это делаю

  • Установить FluentScheduler

    Install-Package FluentScheduler
    
  • Используйте его так:

    var registry = new Registry();
    JobManager.Initialize(registry);
    
    JobManager.AddJob(() => MyEmailService.SendAdminEmails(), s => s
          .ToRunEvery(1)
          .Weeks()
          .On(DayOfWeek.Monday)
          .At(17, 00));
    JobManager.AddJob(() => MyEmailService.SendAdminEmails(), s => s
          .ToRunEvery(1)
          .Weeks()
          .On(DayOfWeek.Wednesday)
          .At(17, 00));
    
     JobManager.AddJob(() => MyEmailService.SendUserEmails(), s => s
           .ToRunEvery(1)
           .Weeks()
           .On(DayOfWeek.Thursday)
           .At(09, 00));
    
     JobManager.AddJob(() => MyEmailService.SendUserEmails(), s => s
           .ToRunEvery(1)
           .Weeks()
           .On(DayOfWeek.Friday)
           .At(09, 00));
    

Документацию можно найти здесь FluentScheduler на GitHub

Ответ 4

Принятый ответ очень хорошо охватывает тему, но некоторые вещи изменились с последней версией Quartz. Далее на основе этой статьи показан быстрый старт с Quartz 3.0.x и ASP.NET Core 2.2:

Работа фабрики

public class QuartzJobFactory : IJobFactory
{
    private readonly IServiceProvider _serviceProvider;

    public QuartzJobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        var jobDetail = bundle.JobDetail;

        var job = (IJob)_serviceProvider.GetService(jobDetail.JobType);
        return job;
    }

    public void ReturnJob(IJob job) { }
}

Пример работы, который также связан с выходом из корзины/выхода из пула приложений

[DisallowConcurrentExecution]
public class TestJob : IJob
{
    private ILoggingService Logger { get; }
    private IApplicationLifetime ApplicationLifetime { get; }

    private static object lockHandle = new object();
    private static bool shouldExit = false;

    public TestJob(ILoggingService loggingService, IApplicationLifetime applicationLifetime)
    {
        Logger = loggingService;
        ApplicationLifetime = applicationLifetime;
    }

    public Task Execute(IJobExecutionContext context)
    {
        return Task.Run(() =>
        {
            ApplicationLifetime.ApplicationStopping.Register(() =>
            {
                lock (lockHandle)
                {
                    shouldExit = true;
                }
            });

            try
            {
                for (int i = 0; i < 10; i ++)
                {
                    lock (lockHandle)
                    {
                        if (shouldExit)
                        {
                            Logger.LogDebug($"TestJob detected that application is shutting down - exiting");
                            break;
                        }
                    }

                    Logger.LogDebug($"TestJob ran step {i+1}");
                    Thread.Sleep(3000);
                }
            }
            catch (Exception exc)
            {
                Logger.LogError(exc, "An error occurred during execution of scheduled job");
            }
        });
    }
}

Конфигурация Startup.cs

private void ConfigureQuartz(IServiceCollection services, params Type[] jobs)
{
    services.AddSingleton<IJobFactory, QuartzJobFactory>();
    services.Add(jobs.Select(jobType => new ServiceDescriptor(jobType, jobType, ServiceLifetime.Singleton)));

    services.AddSingleton(provider =>
    {
        var schedulerFactory = new StdSchedulerFactory();
        var scheduler = schedulerFactory.GetScheduler().Result;
        scheduler.JobFactory = provider.GetService<IJobFactory>();
        scheduler.Start();
        return scheduler;
    });
}

protected void ConfigureJobsIoc(IServiceCollection services)
{
    ConfigureQuartz(services, typeof(TestJob), /* other jobs come here */);
}

public void ConfigureServices(IServiceCollection services)
{
    ConfigureJobsIoc(services);

    // other stuff comes here
    AddDbContext(services);
    AddCors(services);

    services
        .AddMvc()
        .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}


protected void StartJobs(IApplicationBuilder app, IApplicationLifetime lifetime)
{
    var scheduler = app.ApplicationServices.GetService<IScheduler>();
    //TODO: use some config
    QuartzServicesUtilities.StartJob<TestJob>(scheduler, TimeSpan.FromSeconds(60));

    lifetime.ApplicationStarted.Register(() => scheduler.Start());
    lifetime.ApplicationStopping.Register(() => scheduler.Shutdown());
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory,
    ILoggingService logger, IApplicationLifetime lifetime)
{
    StartJobs(app, lifetime);

    // other stuff here
}