Background working scheduled task in ASP.NET Core MVC application
Using Quartz for scheduling background tasks in .NET Core MVC applications
Caching common data like lookups for example in memory of your app can increase significantly your MVC web application performances and response time. Of course this data has to be periodically refreshed.
You can use various approaches to refresh data including expiry, but this can create potential bottle necks in your application since once data expires, you will use request thread to pull the data, cache and serve the request response back. Another approach that I prefer is to create a scheduled cache refresh with the background worker which periodically refreshes the cache. This is easy to implement in .NET but you can always rely on out of the box libraries like Quartz.
Quartz is well supported and cross platform library for scheduling tasks inside your application. Because of the ASP.NET Core architecture and out of the box middleware support it is a bit different to use it in a .NET Core MVC web application. In this article I will try try to explain simple schedule of a background worker scheduled task using Quartz in ASP.NET Core application.
Project setup
I am using simple ASP.NET Core WebApi project template as a base for the example project. Since we will not focus on the actual response from the endpoint, but rather on the Startup.cs dependency injection part and the middleware, default WebApi project template is just fine.
As a first step we need to add Quartz NuGet package to our project. I will use NuGet package manager since I am using Visual Studio 2017 Community edition.
If you are doing development on a Mac or Linux, you will have to use dotnet CLI from your console
Dependency injection setup
As ASP.NET Core has out of the box dependency injection support, we need to setup our resolvers in the startup, but before we do that we need to write our implementation of some of the Quartz interfaces we are going to use to setup the scheduled task in our project.
We first need to write our task, the unit of code which will be executed on a specific schedule. For this we need to implement Quartz.IJob interface in our job class.
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Quartz; using System; using System.Threading.Tasks; namespace Schedule.WebApiCore.Sample.Schedule { public class ScheduledJob : IJob { private readonly IConfiguration configuration; private readonly ILogger<ScheduledJob> logger; public ScheduledJob(IConfiguration configuration, ILogger<ScheduledJob> logger) { this.logger = logger; this.configuration = configuration; } public async Task Execute(IJobExecutionContext context) { this.logger.LogWarning($"Hello from scheduled task {DateTime.Now.ToLongTimeString()}"); await Task.CompletedTask; } } }
Since it is just an example application I will only write the message with he current time in the output every time job executes. Instances of Microsoft.Extensions.Configuration.IConfiguration and Microsoft.Extensions.Logging.ILogger interface implementations will be injected from the Startup.cs class methods.
Next interface we need to implement is Quartz.Spi.IjobFactory.
using Quartz; using Quartz.Spi; using System; namespace Schedule.WebApiCore.Sample.Schedule { public class ScheduledJobFactory : IJobFactory { private readonly IServiceProvider serviceProvider; public ScheduledJobFactory(IServiceProvider serviceProvider) { this.serviceProvider = serviceProvider; } public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { return serviceProvider.GetService(typeof(IJob)) as IJob; } public void ReturnJob(IJob job) { var disposable = job as IDisposable; if (disposable != null) { disposable.Dispose(); } } } }
Instance of our IJobFactory interface implementation will we assigned to our IScheduler instance which will be used to instantiate a job instance on every schedule trigger. Now to setup the dependency injection resolvers in our Startup.cs
We'll need to setup resolvers for our job instances, our scheduler and our trigger. There is one one class related to job and that is IJobDetail which wraps the IJob instance and provides additional details for the IJob instance.
using System; using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Quartz; using Quartz.Impl; using Quartz.Spi; using Schedule.WebApiCore.Sample.Schedule; namespace Schedule.WebApiCore.Sample { public class Startup { public IConfiguration Configuration { get; } public IHostingEnvironment HostingEnvironment { get; } public Startup(IConfiguration configuration, IHostingEnvironment hostingEnvironment) { this.HostingEnvironment = hostingEnvironment; this.Configuration = configuration; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddLogging(); services.AddSingleton<IConfiguration>(new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .AddJsonFile($"appsettings.{this.HostingEnvironment.EnvironmentName.ToLower()}.json") .Build()); #region Configure Quartz DI services.Add(new ServiceDescriptor(typeof(IJob), typeof(ScheduledJob), ServiceLifetime.Transient)); services.AddSingleton<IJobFactory, ScheduledJobFactory>(); services.AddSingleton<IJobDetail>(provider => { return JobBuilder.Create<ScheduledJob>() .WithIdentity("Sample.job", "group1") .Build(); }); services.AddSingleton<ITrigger>(provider => { return TriggerBuilder.Create() .WithIdentity($"Sample.trigger", "group1") .StartNow() .WithSimpleSchedule (s => s.WithInterval(TimeSpan.FromSeconds(5)) .RepeatForever() ) .Build(); }); services.AddSingleton<IScheduler>(provider => { var schedulerFactory = new StdSchedulerFactory(); var scheduler = schedulerFactory.GetScheduler().Result; scheduler.JobFactory = provider.GetService<IJobFactory>(); scheduler.Start(); return scheduler; }); #endregion services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, IScheduler scheduler) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } scheduler.ScheduleJob(app.ApplicationServices.GetService<IJobDetail>(), app.ApplicationServices.GetService<ITrigger>()); app.UseMvc(); } } }
Now we have all setup for a test run. I published the sample project targeting linux-x64 platform in order to run in on my Ubuntu Server VM. Quartz as recognized as an important library is already ported for Linux host, so you can include it in your application regardless if you are going to run it on Windows or maybe Linux or Docker container.
It is easier like this to explain the whole DI setup when you have all parts in one place in Startup.cs, where you can see how all dependencies and interfaces are resolved, but for a long run, this Startup.cs is to messy and it will eventually become a nightmare to maintain. For this reason I wrapped the whole DI into the extension method.
Wrapping dependency injection into extension method
As mentioned, leaving Startup.cs like this is not a god practice, especially when you know you will probably add more middleware components to the pipeline and more dependency injection settings. For this reason, I moved whole Quartz code to a static class which will extend Microsoft.Extensions.DependencyInjection.IServiceCollection with dependency injection statements for Quartz and Microsoft.AspNetCore.Builder.IApplicationBuilder for the pipeline
using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Quartz; using Quartz.Impl; using Quartz.Spi; using System; namespace Schedule.WebApiCore.Sample.Schedule { public static class QuartzExtensions { public static void AddQuartz(this IServiceCollection services, Type jobType) { services.Add(new ServiceDescriptor(typeof(IJob), jobType, ServiceLifetime.Transient)); services.AddSingleton<IJobFactory, ScheduledJobFactory>(); services.AddSingleton<IJobDetail>(provider => { return JobBuilder.Create<ScheduledJob>() .WithIdentity("Sample.job", "group1") .Build(); }); services.AddSingleton<ITrigger>(provider => { return TriggerBuilder.Create() .WithIdentity($"Sample.trigger", "group1") .StartNow() .WithSimpleSchedule (s => s.WithInterval(TimeSpan.FromSeconds(5)) .RepeatForever() ) .Build(); }); services.AddSingleton<IScheduler>(provider => { var schedulerFactory = new StdSchedulerFactory(); var scheduler = schedulerFactory.GetScheduler().Result; scheduler.JobFactory = provider.GetService<IJobFactory>(); scheduler.Start(); return scheduler; }); } public static void UseQuartz (this IApplicationBuilder app) { app.ApplicationServices.GetService<IScheduler>() .ScheduleJob(app.ApplicationServices.GetService<IJobDetail>(), app.ApplicationServices.GetService<ITrigger>() ); } } }
Now our Startup.cs is more cleaner and a lot easier to maintain and extend
using System; using System.IO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Quartz; using Quartz.Impl; using Quartz.Spi; using Schedule.WebApiCore.Sample.Schedule; namespace Schedule.WebApiCore.Sample { public class Startup { public IConfiguration Configuration { get; } public IHostingEnvironment HostingEnvironment { get; } public Startup(IConfiguration configuration, IHostingEnvironment hostingEnvironment) { this.HostingEnvironment = hostingEnvironment; this.Configuration = configuration; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddLogging(); services.AddSingleton<IConfiguration>(new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .AddJsonFile($"appsettings.{this.HostingEnvironment.EnvironmentName.ToLower()}.json") .Build()); services.AddQuartz(typeof(ScheduledJob)); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseQuartz(); app.UseMvc(); } } }
If you want to try this code, there is complete sample project can be found on GitHub at https://github.com/dejanstojanovic/dotnetcore-mvc-quartz.
References
- https://github.com/dejanstojanovic/dotnetcore-mvc-quartz
- https://www.microsoft.com/net/download/dotnet-core/2.1
- Differences in time zones in .NET Core on Windows and Linux host OS
- Setting up .NET Core service/daemon on Linux OS
- Use different configuration based on environment value in ASP.NET Core
Disclaimer
Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.
Comments for this article