web-dev-qa-db-fra.com

Comment démarrer Quartz dans ASP.NET Core?

J'ai la classe suivante

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

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

J'ai installé le dernier paquet Quartz 2.4.1 Nuget car je voulais un planificateur léger dans mon application Web sans base de données SQL Server séparée.

J'ai besoin de planifier les méthodes 

  • SendUserEmails à courir chaque semaine les lundis 17h00, les mardis 17h00 et les mercredis 17h00
  • SendAdminEmails à courir toutes les semaines les jeudis à 9h00 et les vendredis à 9h00

De quel code ai-je besoin pour planifier ces méthodes à l'aide de Quartz dans ASP.NET Core? J'ai également besoin de savoir comment démarrer Quartz dans ASP.NET Core, car tous les exemples de code sur Internet font toujours référence aux versions précédentes d'ASP.NET.

Je peux trouve un exemple de code pour la version précédente d’ASP.NET mais je ne sais pas comment démarrer Quartz dans ASP.NET Core pour commencer à tester . Où dois-je placer le JobScheduler.Start(); dans ASP.NET Coeur?

17
dev2go

TL; DR (la réponse complète peut être trouvée ci-dessous)

Outils supposés: Visual Studio 2017 RTM, .NET Core 1.1, Kit de développement .NET Core SDK 1.0, SQL Server Express 2016 LocalDB.

Dans l'application web .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>

Dans la classe Program (telle que scaffoldée par Visual Studio par défaut):

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

Un exemple de classe d'emploi:

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

Réponse complète

Quartz pour .NET Core

Tout d’abord, vous devez utiliser la v3 de Quartz, car elle cible .NET Core, conformément à cette annonce

Actuellement, seules les versions alpha des packages v3 sont disponibles sur NuGet . Il semble que l'équipe ait déployé beaucoup d'efforts pour la publication de la version 2.5.0, qui ne cible pas .NET Core. Néanmoins, dans leur rapport GitHub, la branche master est déjà dédiée à la v3 et, en gros, les problèmes en suspens pour la version v3 ne semblent pas être critiques, surtout les anciens éléments de liste de souhaits, IMHO. Étant donné que l'activité récente de commit est relativement faible, la version 3 devrait être publiée dans quelques mois, voire dans un an et demi - mais personne ne le sait. 

Emplois et IIS recyclage

Si l'application Web doit être hébergée sous IIS, vous devez prendre en compte le comportement de recyclage/déchargement des processus de travail. L'application Web ASP.NET Core s'exécute en tant que processus .NET Core standard, distinct de w3wp.exe - IIS ne sert que de proxy inverse. Néanmoins, lorsqu'une instance de w3wp.exe est recyclée ou déchargée, le processus d'application .NET Core associé est également signalé à quitter (selon this ). 

Une application Web peut également s'auto-héberger derrière un proxy inverse non-IIS (par exemple, NGINX), mais je supposerai que vous utilisez IIS et affine ma réponse en conséquence.

Les problèmes introduits par le recyclage/déchargement sont bien expliqués dans le post référencé par @ darin-dimitrov :

  • Si, par exemple, le processus est interrompu le vendredi à 9h00, car plusieurs heures plus tôt, il était déchargé par IIS en raison d’une inactivité - aucun courrier électronique d’administrateur ne sera envoyé tant que le processus ne sera plus opérationnel. Pour éviter cela, configurez IIS pour minimiser les déchargements/recyclages ( voir cette réponse ) .
    • D'après mon expérience, la configuration ci-dessus ne donne toujours pas une garantie à 100% que IIS ne déchargera jamais l'application. Pour garantir à 100% le bon déroulement de votre processus, vous pouvez configurer une commande qui envoie périodiquement des demandes à votre application et la maintient ainsi active.
  • Lorsque le processus hôte est recyclé/déchargé, les travaux doivent être arrêtés normalement pour éviter la corruption des données.

Pourquoi voudriez-vous héberger des travaux planifiés dans une application Web?

Je peux penser à une justification du fait que ces travaux de messagerie sont hébergés dans une application Web, malgré les problèmes énumérés ci-dessus. C'est la décision de n'avoir qu'un seul type de modèle d'application (ASP.NET). Une telle approche simplifie la courbe d’apprentissage, la procédure de déploiement, le suivi de la production, etc. 

Si vous ne souhaitez pas introduire de microservices dorsaux (ce qui serait un bon endroit pour déplacer les travaux de messagerie), il est alors logique de supprimer les comportements de recyclage/déchargement IIS et d'exécuter Quartz dans une application Web. 

Ou peut-être vous avez d'autres raisons.

Magasin de travail persistant

Dans votre scénario, le statut d'exécution du travail doit rester en dehors du processus. Par conséquent, RAMJobStore par défaut ne convient pas et vous devez utiliser le magasin de travaux ADO.NET

Puisque vous avez mentionné SQL Server dans la question, je vais vous donner un exemple d'installation pour la base de données SQL Server.

Comment démarrer (et arrêter gracieusement) le planificateur

Je suppose que vous utilisez Visual Studio 2017 et la version la plus récente/récente des outils .NET Core. Le mien est .NET Core Runtime 1.1 et .NET Core SDK 1.0. 

Pour l'exemple de configuration de base de données, j'utiliserai une base de données nommée Quartz dans SQL Server 2016 Express LocalDB. Les scripts de configuration de base de données peuvent être trouvés ici .

Tout d’abord, ajoutez les références de package requises à l’application Web .csproj (ou utilisez l’interface graphique du gestionnaire de packages de NuGet dans 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>

Avec l'aide de Guide de migration et du didacticiel V3 , nous pouvons déterminer comment démarrer et arrêter le planificateur. Je préfère encapsuler ceci dans une classe séparée, appelons-le 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...
            }
        }
    }
}

Remarque 1. Dans l'exemple ci-dessus, SendUserEmailsJob et SendAdminEmailsJob sont des classes qui implémentent IJob. L'interface IJob est légèrement différente de IMyEmailService, car elle renvoie void Task et non Task<bool>. La dépendance des deux classes de travail devrait être IMyEmailService (probablement l'injection du constructeur).Remarque 2. Pour qu'un travail de longue durée puisse se terminer rapidement, la méthode IJob.Execute doit observer l'état IJobExecutionContext.CancellationToken. Cela peut nécessiter une modification de l'interface IMyEmailService, pour que ses méthodes reçoivent le paramètre CancellationToken:.

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

Dans ASP.NET Core, le code d'amorçage d'application réside dans la classe Program, un peu comme dans l'application console. La méthode Main est appelée pour créer un hôte Web, l'exécuter et attendre jusqu'à ce qu'il se ferme:

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

Cette ligne:.

.UseStartup<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... } }

Dans l’ancien .NET Framework, ASP.NET fournissait une interface IRegisteredObject. Selon this post et la documentation , dans ASP.NET Core, il a été remplacé par IApplicationLifetime. Bingo Une instance de IApplicationLifetime peut être injectée dans la méthode Startup.Configure via un paramètre.

Par souci de cohérence, je vais associer les QuartzStartup.Start et 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 .... }

Arrêt en douceur sur IIS Express et le module ASP.NET Core.

J'ai pu observer le comportement attendu de IApplicationLifetime.ApplicationStopping hook uniquement sur IIS, avec le dernier module ASP.NET Core installé. IIS Express (installé avec Visual Studio 2017 Community RTM) et IIS avec une version obsolète du module ASP.NET Core n'a pas invoqué de manière cohérente IApplicationLifetime.ApplicationStopping. Je crois que c'est à cause de ce bogue qui a été corrigé.

Vous pouvez installer la dernière version du module ASP.NET Core d'ici . Suivez les instructions de la section "Installation du dernier module ASP.NET Core".

Quartz vs FluentScheduler.

J'ai aussi jeté un coup d'œil à FluentScheduler, car il avait été proposé comme bibliothèque alternative par @Brice Molesti. À ma première impression, FluentScheduler est une solution assez simpliste et immature, comparée à Quartz. Par exemple, FluentScheduler ne fournit pas les fonctionnalités fondamentales telles que la persistance de l’état du travail et l’exécution en cluster.

I also took a look at FluentScheduler, as it was proposed as an alternative library by @Brice Molesti. To my first impression, FluentScheduler is quite a simplistic and immature solution, compared to Quartz. For example, FluentScheduler doesn't provide such fundamental features as job status persistence and clustered execution.

49
felix-b

En plus de @ felix-b answer. Ajout de DI aux travaux. De plus, QuartzStartup Start peut être rendu asynchrone.

Sur la base de cette réponse: https://stackoverflow.com/a/42158004/1235390

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);
    }
}

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

Classe de démarrage:

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
}

Classe SampleJob avec injection de dépendance de constructeur:

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
aleha

Je ne sais pas comment faire avec Quartz, mais j’avais expérimenté le même scénario avec une autre bibliothèque qui fonctionne très bien. Voici comment je le dis

  • Installer FluentScheduler

    Install-Package FluentScheduler
    
  • Utilisez-le comme ça

    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));
    

La documentation peut être trouvée ici FluentScheduler sur GitHub

2
Hayha

La réponse acceptée couvre très bien le sujet, mais certaines choses ont changé avec la dernière version de Quartz. Ce qui suit est basé sur cet article illustre un démarrage rapide avec Quartz 3.0.x et ASP.NET Core 2.2:

Usine de travail

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

Un exemple de travail qui traite également de la sortie sur le recyclage/la sortie du pool d'applications

[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");
            }
        });
    }
}

Configuration de 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
}
0
Alexei