web-dev-qa-db-ja.com

ASP.NET CoreでQuartzを起動する方法は?

次のクラスがあります

_ 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パッケージ をインストールしました。これは、別個のSQL ServerデータベースなしでWebアプリに軽量のスケジューラーが必要だったためです。

メソッドをスケジュールする必要があります

  • SendUserEmailsは、毎週月曜日17:00、火曜日17:00および水曜日17:00に実行されます
  • SendAdminEmailsは毎週木曜日09:00、金曜日9:00に実行します

ASP.NET CoreのQuartzを使用してこれらのメソッドをスケジュールするには、どのコードが必要ですか?また、インターネット上のすべてのコードサンプルは以前のバージョンのASP.NETを参照しているため、ASP.NET CoreでQuartzを起動する方法を知る必要があります。

コードサンプルを見つける 以前のバージョンのASP.NETについてはできますが、ASP.NET CoreでQuartzを起動してテストを開始する方法がわかりません。 ASP.NET CoreのJobScheduler.Start();はどこに配置しますか?

19
dev2go

TL; DR(完全な回答は以下にあります)

想定されるツール:Visual Studio 2017 RTM、.NET Core 1.1、.NET Core SDK 1.0、SQL Server Express 2016 LocalDB。

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>

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用のQuartz

まず、 この発表 に従って、.NET Coreをターゲットとするため、Quartzのv3を使用する必要があります。

現在、v3パッケージのアルファバージョンのみが NuGetで利用可能 です。チームは、.5.0 Coreを対象としない2.5.0のリリースに多大な努力を払ったようです。それにもかかわらず、GitHubリポジトリでは、masterブランチはすでにv3専用であり、基本的に、 v3リリースの未解決の問題 は重要ではないようです。最近の commit activity は非常に低いため、v3のリリースは数か月間、または半年後になると予想していますが、誰も知りません。

ジョブとIISリサイクル

WebアプリケーションをIISでホストする場合、ワーカープロセスのリサイクル/アンロード動作を考慮する必要があります。 ASP.NET Core Webアプリは、w3wp.exeとは別に、通常の.NET Coreプロセスとして実行されます-IISはリバースプロキシとしてのみ機能します。それでも、w3wp.exeのインスタンスがリサイクルまたはアンロードされると、関連する.NET Coreアプリプロセスも終了するように通知されます( this に従って)。

Webアプリケーションは、IIS以外のリバースプロキシ(NGINXなど)の背後で自己ホストすることもできますが、IISを使用していると想定し、それに応じて回答を絞り込みます。

リサイクル/アンロードがもたらす問題は、 @ darin-dimitrovが参照する投稿 で詳しく説明されています。

  • たとえば、金曜日の9:00にプロセスがダウンした場合、数時間前に非アクティブのためIISによってアンロードされたため、プロセスが再び起動するまで管理者のメールは送信されません。これを回避するには、IISを構成して、アンロード/リサイクルを最小限に抑えます( この回答を参照 )。
    • 私の経験から、上記の構成では、IISがアプリケーションをアンロードしないという100%の保証はまだありません。プロセスが稼働していることを100%保証するために、アプリケーションにリクエストを定期的に送信するコマンドを設定して、アプリケーションを有効に保つことができます。
  • ホストプロセスがリサイクル/アンロードされると、データの破損を防ぐために、ジョブを適切に停止する必要があります。

スケジュールされたジョブをWebアプリでホストする理由

上記の問題にもかかわらず、これらの電子メールジョブをWebアプリでホストすることの正当化を考えることができます。 1種類のアプリケーションモデル(ASP.NET)のみを使用することが決定されました。このようなアプローチにより、学習曲線、展開手順、生産監視などが簡素化されます。

バックエンドマイクロサービス(電子メールジョブを移動するのに適した場所)を導入したくない場合は、IISリサイクル/アンロード動作を克服し、Webアプリ内でQuartzを実行するのが理にかなっています。

または、他の理由があるかもしれません。

永続的なジョブストア

シナリオでは、ジョブ実行のステータスをプロセス外で保持する必要があります。したがって、デフォルトのRAMJobStoreは適合せず、ADO.NET Job Store

質問でSQL Serverに言及したので、SQL Serverデータベースのセットアップ例を示します。

スケジューラーを開始(および正常に停止)する方法

Visual Studio 2017と.NET Coreツールの最新/最新バージョンを使用すると仮定します。私のものは.NET Core Runtime 1.1および.NET Core SDK 1.0です。

DBセットアップの例では、SQL Server 2016 Express LocalDBでQuartzという名前のデータベースを使用します。 DBセットアップスクリプトは こちらにあります です。

まず、必要なパッケージ参照をWebアプリケーションの.csprojに追加します(またはVisual StudioのNuGetパッケージマネージャーGUIで行います)。

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

Migration GuideV3 Tutorial の助けを借りて、スケジューラーを開始および停止する方法を見つけることができます。これを別のクラスにカプセル化することを好みます。名前を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.上記の例では、SendUserEmailsJobSendAdminEmailsJobIJobを実装するクラスです。 IJobインターフェイスは、Task<bool>ではなくvoid IMyEmailServiceを返すため、Taskとは少し異なります。両方のジョブクラスは、IMyEmailServiceを依存関係として取得する必要があります(おそらくコンストラクター注入)。

注2.長時間実行されるジョブがタイムリーに終了できるようにするには、IJob.ExecuteメソッドでIJobExecutionContext.CancellationTokenのステータスを監視する必要があります。メソッドがIMyEmailServiceパラメータを受け取るようにするには、CancellationTokenインターフェイスの変更が必要になる場合があります。

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

スケジューラーを開始および停止するタイミングと場所

ASP.NET Coreでは、アプリケーションbootstrapコードはクラスProgramに存在します。これはコンソールアプリとよく似ています。 Mainメソッドは、Webホストを作成して実行し、終了するまで待機するために呼び出されます。

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

        Host.Run();
    }
}

最も簡単なことは、TL; DRで行ったように、MainメソッドでQuartzStartup.Startを呼び出すだけです。しかし、プロセスのシャットダウンも適切に処理する必要があるため、起動コードとシャットダウンコードの両方をより一貫した方法でフックすることを好みます。

この行:

.UseStartup<Startup>()

visual Studioで新しいASP.NET Core Web Applicationプロジェクトを作成するときに足場となるStartupという名前のクラスを参照します。 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クラスのメソッドの1つに挿入する必要があることは明らかです。問題は、QuartzStartup.Stopをフックする場所です。

従来の.NET Frameworkでは、ASP.NETはIRegisteredObjectインターフェイスを提供していました。 この投稿 、および ドキュメント によると、ASP.NET CoreではIApplicationLifetimeに置き換えられました。ビンゴ。 IApplicationLifetimeのインスタンスは、パラメーターを介してStartup.Configureメソッドに注入できます。

一貫性を保つために、QuartzStartup.StartQuartzStartup.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パラメーターで拡張していることに注意してください。 documentation によれば、ApplicationStoppingは登録されたコールバックが完了するまでブロックします。

IIS ExpressおよびASP.NET Coreモジュールの正常なシャットダウン

最新のASP.NET CoreモジュールがインストールされているIISでのみ、IApplicationLifetime.ApplicationStoppingフックの予想される動作を観察することができました。 IIS Express(Visual Studio 2017 Community RTMと共にインストール)と、ASP.NET Coreモジュールの古いバージョンのIISの両方が、一貫してIApplicationLifetime.ApplicationStoppingを呼び出しませんでした。 このバグ が原因で修正されたと思います。

ASP.NET Coreモジュールの最新バージョンをインストールできます from here「最新のASP.NETコアモジュールのインストール」セクションの指示に従ってください。

クォーツvs. FluentScheduler

また、@ Brice Molestiが代替ライブラリとして提案したFluentSchedulerも調べました。私の第一印象では、FluentSchedulerはQuartzと比較して非常に単純で未熟なソリューションです。たとえば、FluentSchedulerは、ジョブステータスの永続化やクラスター化された実行などの基本的な機能を提供しません。

67
felix-b

@ felix-bの答えに加えて。ジョブにDIを追加します。 QuartzStartup Startを非同期にすることもできます。

この回答に基づいて: https://stackoverflow.com/a/42158004/123539

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

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

ドキュメントはここにあります GitHubのFluentScheduler

2
Hayha

受け入れられた回答はこのトピックを非常によくカバーしていますが、最新のQuartzバージョンではいくつかの点が変更されています。以下は この記事 に基づいています。Quartz3.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
}
0
Alexei