Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Using Quartz.NET with ASP.NET Core and worker services

$
0
0

This is an update to a post from 18 months ago in which I described how to use Quartz.NET to run background tasks by creating an an ASP.NET Core hosted service.

There's now an official package, Quartz.Extensions.Hosting from Quartz.NET to do that for you, so adding Quartz.NET to your ASP.NET Core or generic-host-based worker service is much easier. This post shows how to use that package instead of the "manual" approach in my old post.

I also discuss Quartz.NET in the second edition of my book, ASP.NET Core in Action. For now you can even get a 40% discount by entering the code bllock2 into the discount code box at checkout at manning.com.

I show how to add the Quartz.NET HostedService to your app, how to create a simple IJob, and how to register it with a trigger.

Introduction - what is Quartz.NET?

As per their website:

Quartz.NET is a full-featured, open source job scheduling system that can be used from smallest apps to large scale enterprise systems.

It's an old staple of many ASP.NET developers, used as a way of running background tasks on a timer, in a reliable, clustered, way. Using Quartz.NET with ASP.NET Core is pretty similar - Quartz.NET supports .NET Standard 2.0, so you can easily use it in your applications.

Quartz.NET has three main concepts:

  • A job. This is the background tasks that you want to run.
  • A trigger. A trigger controls when a job runs, typically firing on some sort of schedule.
  • A scheduler. This is responsible for coordinating the jobs and triggers, executing the jobs as required by the triggers.

ASP.NET Core has good support for running "background tasks" via way of hosted services. Hosted services are started when your ASP.NET Core app starts, and run in the background for the lifetime of the application. Quartz.NET version 3.2.0 introduced direct support for this pattern with the Quartz.Extensions.Hosting package. Quartz.Extensions.Hosting can be used either with ASP.NET Core applications, or with "generic host" based worker-services.

There is also a Quartz.AspNetCore package that builds on the Quartz.Extensions.Hosting. It primarily adds health-check integration, though health-checks can also be used with worker-services too!

While it's possible to create a "timed" background service, (that runs a tasks every 10 minutes, for example), Quartz.NET provides a far more robust solution. You can ensure tasks only run at specific times of the day (e.g. 2:30am), or only on specific days, or any combination of these by using a Cron trigger. Quartz.NET also allows you to run multiple instances of your application in a clustered fashion, so that only a single instance can run a given task at any one time.

The Quartz.NET hosted service takes care of the scheduler part of Quartz. It will run in the background of your application, checking for triggers that are firing, and running the associated jobs as necessary. You need to configure the scheduler initially, but you don't need to worry about starting or stopping it, the IHostedService manages that for you.

In this post I'll show the basics of creating a Quartz.NET job and scheduling it to run on a timer in a hosted service.

Installing Quartz.NET

Quartz.NET is a .NET Standard 2.0 NuGet package, so it should be easy to install in your application. For this test I created a worker service project. You can install the Quartz.NET hosting package using dotnet add package Quartz.Extensions.Hosting. If you view the .csproj for the project, it should look something like this:

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <UserSecretsId>dotnet-QuartzWorkerService-9D4BFFBE-BE06-4490-AE8B-8AF1466778FD</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
    <PackageReference Include="Quartz.Extensions.Hosting" Version="3.2.3" />
  </ItemGroup>
</Project>

This adds the hosted service package, which brings in the main Quartz.NET package with in. Next we need to register the Quartz services and the Quartz IHostedService in our app.

Adding the Quartz.NET hosted service

You need to do two things to register the Quartz.NET hosted service:

  • Register the Quartz.NET required services with the DI container
  • Register the hosted service

In ASP.NET Core applications you would typically do both of these in the Startup.ConfigureServices() method. Worker services don't use Startup classes though, so we register them in the ConfigureServices method on the IHostBuilder in Program.cs:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                // Add the required Quartz.NET services
                services.AddQuartz(q =>  
                {
                    // Use a Scoped container to create jobs. I'll touch on this later
                    q.UseMicrosoftDependencyInjectionScopedJobFactory();
                });

                // Add the Quartz.NET hosted service

                services.AddQuartzHostedService(
                    q => q.WaitForJobsToComplete = true);

                // other config
            });
}

There's a couple of points of interest here:

  • UseMicrosoftDependencyInjectionScopedJobFactory: this tells Quartz.NET to register an IJobFactory that creates jobs by fetching them from the DI container. The Scoped part means that your jobs can use scoped services, not just singleton or transient services, which is a common requirement.
  • WaitForJobsToComplete: When shutdown is requested, this setting ensures that Quartz.NET waits for the jobs to end gracefully before exiting.

You might be wondering why you need to call AddQuartz() and AddQuartzHostedService(). That's because Quartz.NET itself isn't tied to the hosted service implementation. You're free to run the Quartz scheduler yourself, as shown in the documentation.

If you run your application now, you'll see the Quartz service start up, and dump a whole lot of logs to the console:

info: Quartz.Core.SchedulerSignalerImpl[0]
      Initialized Scheduler Signaller of type: Quartz.Core.SchedulerSignalerImpl
info: Quartz.Core.QuartzScheduler[0]
      Quartz Scheduler v.3.2.3.0 created.
info: Quartz.Core.QuartzScheduler[0]
      JobFactory set to: Quartz.Simpl.MicrosoftDependencyInjectionJobFactory
info: Quartz.Simpl.RAMJobStore[0]
      RAMJobStore initialized.
info: Quartz.Core.QuartzScheduler[0]
      Scheduler meta-data: Quartz Scheduler (v3.2.3.0) 'QuartzScheduler' with instanceId 'NON_CLUSTERED'
  Scheduler class: 'Quartz.Core.QuartzScheduler' - running locally.
  NOT STARTED.
  Currently in standby mode.
  Number of jobs executed: 0
  Using thread pool 'Quartz.Simpl.DefaultThreadPool' - with 10 threads.
  Using job-store 'Quartz.Simpl.RAMJobStore' - which does not support persistence. and is not clustered.

info: Quartz.Impl.StdSchedulerFactory[0]
      Quartz scheduler 'QuartzScheduler' initialized
info: Quartz.Impl.StdSchedulerFactory[0]
      Quartz scheduler version: 3.2.3.0
info: Quartz.Core.QuartzScheduler[0]
      Scheduler QuartzScheduler_$_NON_CLUSTERED started.
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
...

At this point you now have Quartz running as a hosted service in your application, but you don't have any jobs for it to run. In the next section, we'll create and register a simple job.

Creating an IJob

For the actual background work we are scheduling, we're just going to use a "hello world" implementation that writes to an ILogger<T> (and hence to the console). You should implement the Quartz.NET interface IJob which contains a single asynchronous Execute() method. Note that we're using dependency injection here to inject the logger into the constructor.

using Microsoft.Extensions.Logging;
using Quartz;
using System.Threading.Tasks;

[DisallowConcurrentExecution]
public class HelloWorldJob : IJob
{
    private readonly ILogger<HelloWorldJob> _logger;
    public HelloWorldJob(ILogger<HelloWorldJob> logger)
    {
        _logger = logger;
    }

    public Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Hello world!");
        return Task.CompletedTask;
    }
}

I also decorated the job with the [DisallowConcurrentExecution] attribute. This attribute prevents Quartz.NET from trying to run the same job concurrently.

Now we've created the job, we need to register it with the DI container along with a trigger.

Configuring the Job

Quartz.NET has some simple schedules for running jobs, but one of the most common approaches is using a Quartz.NET Cron expression. Cron expressions allow complex timer scheduling so you can set rules like "fire every half hour between the hours of 8 am and 10 am, on the 5th and 20th of every month". Just make sure to check the documentation for examples as not all Cron expressions used by different systems are interchangeable.

The following example shows how to register the HelloWorldJob with a trigger that runs every 5 seconds:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((hostContext, services) =>
        {
            services.AddQuartz(q =>  
            {
                q.UseMicrosoftDependencyInjectionScopedJobFactory();

                // Create a "key" for the job
                var jobKey = new JobKey("HelloWorldJob");

                // Register the job with the DI container
                q.AddJob<HelloWorldJob>(opts => opts.WithIdentity(jobKey));
                
                // Create a trigger for the job
                q.AddTrigger(opts => opts
                    .ForJob(jobKey) // link to the HelloWorldJob
                    .WithIdentity("HelloWorldJob-trigger") // give the trigger a unique name
                    .WithCronSchedule("0/5 * * * * ?")); // run every 5 seconds

            });
            services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
            // ...
        });

In this code we:

  • Create a unique JobKey for the job. This is used to link the job and its trigger together. There are other approaches to link jobs and trigger, but I find this is as good as any.
  • Register the HelloWorldJob with AddJob<T>. This does two things - it adds the HelloWorldJob to the DI container so it can be created, and it registers the job with Quartz internally.
  • Add a trigger to run the job every 5 seconds. We use the JobKey to associate the trigger with a job, and give the trigger a unique name (not necessary for this example, but important if you run quartz in clustered mode, so is best practice). Finally, we set a Cron schedule for the trigger for the job to run every 5 seconds.

And that's it! No more creating a custom IJobFactory or worrying about supporting scoped services. The default package handles all that for you—you can use scoped services in your IJob and they will be disposed when the job finishes.

If you run you run your application now, you'll see the same startup messages as before, and then every 5 seconds you'll see the HelloWorldJob writing to the console:

Background service writing Hello World to console repeatedly

That's all that's required to get up and running, but there's a little too much boilerplate in the ConfigureServices method for adding a job for my liking. It's also unlikely you'll want to hard code the job schedule in your app. If you extract that to configuration, you can use different schedules in each environment, for example.

Extracting the configuration to appsettings.json

At the most basic level, we want to extract the Cron schedule to configuration. For example, you could add the following to appsettings.json:

{
  "Quartz": {
    "HelloWorldJob": "0/5 * * * * ?"
  }
}

You can then easily override the trigger schedule for the HelloWorldJob in different environments.

For ease of registration, we could create an extension method to encapsulate registering an IJob with Quartz, and setting it's trigger schedule. This code is mostly the same as the previous example, but it uses the name of the job as a key into the IConfiguration to load the Cron schedule.

public static class ServiceCollectionQuartzConfiguratorExtensions
{
    public static void AddJobAndTrigger<T>(
        this IServiceCollectionQuartzConfigurator quartz,
        IConfiguration config)
        where T : IJob
    {
        // Use the name of the IJob as the appsettings.json key
        string jobName = typeof(T).Name;

        // Try and load the schedule from configuration
        var configKey = $"Quartz:{jobName}";
        var cronSchedule = config[configKey];

        // Some minor validation
        if (string.IsNullOrEmpty(cronSchedule))
        {
            throw new Exception($"No Quartz.NET Cron schedule found for job in configuration at {configKey}");
        }

        // register the job as before
        var jobKey = new JobKey(jobName);
        quartz.AddJob<T>(opts => opts.WithIdentity(jobKey));

        quartz.AddTrigger(opts => opts
            .ForJob(jobKey)
            .WithIdentity(jobName + "-trigger")
            .WithCronSchedule(cronSchedule)); // use the schedule from configuration
    }
}

Now we can clean up our application's Program.cs to use the extension method:

public class Program
{
    public static void Main(string[] args) => CreateHostBuilder(args).Build().Run();

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                services.AddQuartz(q =>
                {
                    q.UseMicrosoftDependencyInjectionScopedJobFactory();

                    // Register the job, loading the schedule from configuration
                    q.AddJobAndTrigger<HelloWorldJob>(hostContext.Configuration);
                });

                services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
            });
}

This is essentially identical to our configuration, but we've made it easier to add new jobs, and move the details of the schedule into configuration. Much better!

Note that although ASP.NET Core allows "on-the-fly" reloading of appsettings.json, this will not change the schedule for a job unless you restart the application. Quartz.NET loads all its configuration on app startup, so will not detect the change.

Running the application again gives the same output: the job writes to the output every 5 seconds.

Background service writing Hello World to console repeatedly

Summary

In this post I introduced Quartz.NET and showed how you can use the new Quartz.Extensions.Hosting library to easily add an ASP.NET Core HostedService which runs the Quartz.NET scheduler. I showed how to implement a simple job with a trigger and how to register that with your application so that the hosted service runs it on a schedule. For more details, see the Quartz.NET documentation. I also discuss Quartz in the second edition of my book, ASP.NET Core in Action.


Viewing all articles
Browse latest Browse all 743

Trending Articles