Wednesday, Mar 27, 2024
Decoupling Schedulers in C#
Intro
Here’s a motivation for today’s blog post: you have a very large application and you have a NuGet package referenced all around the project. One day, the only library contributor decides it’s time to stop. What you are left with is an unmaintained library, and a ton of scheduled code refactoring (pun intended). This scenario is rather uncommon, but nevertheless it’s a good practice to decouple from any concrete implementations.
Today’s example will showcase how to decouple from the popular library called Quartz.NET
! We’ll start by defining our interfaces.
Interfaces
public interface IJob
{
Task Execute(IJobContext context);
}
public interface IJobContext : IJobMetadata
{
CancellationToken CancellationToken { get; }
}
public interface IJobAdapter;
Adapter
The idea is to isolate Quartz.NET
dependencies in one place (DLL) and use your interfaces in other places in the app. This way, you become decoupled from the implementation, and you don’t need to worry about who is actually implementing them.
This will be our adapter:
[DisallowConcurrentExecution]
public class QuartzJobAdapter<TJob> : IJobAdapter, Quartz.IJob
where TJob : IJob
{
private readonly TJob _job;
[ActivatorUtilitiesConstructor]
public QuartzJobAdapter(TJob job)
{
_job = job;
}
public async Task Execute(IJobExecutionContext context)
{
try
{
await _job.Execute(new JobContext
{
CancellationToken = context.CancellationToken,
NextFireTimeUtc = context.NextFireTimeUtc,
PreviousFireTimeUtc = context.PreviousFireTimeUtc
});
}
catch (Exception ex)
{
throw new JobExecutionException(ex);
}
}
}
We want to be able to create our own concrete IJob
and call its Execute
method inside Quartz.NET’s Execute
method.
[DisallowConcurrentExecution]
is added here if you don’t want to have multiple executions for the sameJobKey
.[ActivatorUtilitiesConstructor]
will be used by theActivatorUtilities
later on.
Job factory
The next step would be to implement a custom job factory capable of creating both our IJob
and Quartz.IJob
instances.
public class QuartzJobFactory : PropertySettingJobFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly JobActivatorCache activatorCache = new();
public QuartzJobFactory(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
// Omitted for brevity:
// public override void ReturnJob(Quartz.IJob job);
// public override void SetObjectProperties(object obj, JobDataMap data);
// private sealed class ScopedJob : Quartz.IJob, IDisposable
// ** Link to the code at the end of the blog post :) **
protected override Quartz.IJob InstantiateJob(TriggerFiredBundle bundle, Quartz.IScheduler scheduler)
{
var serviceScope = _serviceProvider.CreateScope();
var (innerJob, flag) = CreateJob(bundle, serviceScope.ServiceProvider);
return new ScopedJob(serviceScope, innerJob, !flag);
}
private (Quartz.IJob Job, bool FromContainer) CreateJob(TriggerFiredBundle bundle, IServiceProvider serviceProvider)
{
var innerJobType = bundle.JobDetail.JobType.GetGenericArguments().SingleOrDefault();
if (
(innerJobType?.IsAssignableTo(typeof(IJob)) ?? false)
&& !serviceProvider.GetRequiredService<IServiceProviderIsService>().IsService(innerJobType)
)
{
throw new Exception($"Register all {nameof(IJob)} implementations directly, i.e. they should be resolvable through service provider.");
}
else if (
!bundle.JobDetail.JobType.IsAssignableTo(typeof(IJobAdapter))
&& serviceProvider.GetService(bundle.JobDetail.JobType) is Quartz.IJob quartzJob
)
{
return (quartzJob, true);
}
return (activatorCache.CreateInstance(serviceProvider, bundle.JobDetail.JobType), false);
}
}
Let’s delve into some generic coding. InstantiateJob
is called by Quartz.NET
so we have to override that method. This implementation utilizes Microsoft.DependencyInjection
but can be adapted for use with various other frameworks (Autofac, DryIoc…).
-
InstantiateJob
- We need to create a scope - why? This way we can control the disposition of activated services.
- The
ReturnJob
method disposesIJob
, this action will dispose of everything created within our scope.
-
CreateJob
- The initial step involves checking for a generic type argument,
SingleOrDefault
can be replaced by something else, depending on your implementation. innerJobType
should be our concreteIJob
, but a check is performed just to be sure, as one could register aQuartz.NET
job directly.- Note the use of
IServiceProviderIsService
(Microsoft, what is this naming? 😶). This interface exposes a method that checks whether or not ourIServiceProvider
can resolve the given type. Read more about it here. - If everything is alright, we will either resolve a
Quartz.IJob
or our job adapter through theactivatorCache
.
- The initial step involves checking for a generic type argument,
internal sealed class JobActivatorCache
{
private readonly ConcurrentDictionary<Type, ObjectFactory> activatorCache = new();
public Quartz.IJob CreateInstance(IServiceProvider serviceProvider, Type jobType)
{
ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(jobType);
var orAdd = activatorCache.GetOrAdd(jobType, ActivatorUtilities.CreateFactory, Type.EmptyTypes);
return (Quartz.IJob)orAdd(serviceProvider, null);
}
}
JobActivatorCache
leverages a great tool called ActivatorUtilities
(you can find more information here). Essentially, this utility offers methods used for object creation and dependency injection in a more flexible and customizable way, providing a sophisticated alternative to using Activator
directly. ActivatorUtilities.CreateFactory
creates a delegate that instantiates a type with constructor arguments provided directly and/or from an IServiceProvider
.
var services = new ServiceCollection();
services.AddQuartz(cfg =>
{
cfg.UseInMemoryStore();
cfg.UseJobFactory<QuartzJobFactory>();
cfg.UseTimeZoneConverter();
});
- Ensure that you only reference your custom interfaces outside of the isolated DLL.
- Remember to register concrete implementations directly (e.g.
AddTransient<TestJob>()
).
The last step is to implement your custom IScheduler
, write some tests, and we’re done! If you wish to explore the full code example, the link is provided below.
Now, if you ever wish to change your implementation down the line, you can do so with considerably less effort!
💾 See the full example on Github!.
Thanks for reading!