TL;DR: Build a scheduling service using Topshelf, Quartz.net, Autofac and Serilog, code for part one available.

Update: Part two is on the way!

Update 2: Part two is now available

Windows comes with a perfectly good built-in scheduler. But sometimes you might want something more within your own control. In my case I needed to run three jobs one after the other (a data update, an extract and finally a report on the extract). And these are the technologies I settled on to implement this wonder of the scheduling world:

  • Topshelf - a rather fantastic little piece of software that removes the pain from creating a Windows Service
  • Quartz.Net - a port of the popular Java Quartz, an enterprise grade scheduling component
  • Autofac - a popular .NET IoC container, and the one we have standardised on at work due to it’s breadth of support and depth of examples
  • Serilog - a flexible logger, built for structured data, that makes outputting to a console really pretty

The first step step is to get Autofac setup. This might seem to be counter intuitive, but makes life easier later on. (Plus when you’re doing this for reals you’re doing TDD right?) So in the Package Manager Console run the command Install-Package Autofac (my personal preference is to use -DependencyVersion Highest to get the highest versions of any dependencies). Setting up Autofac is very straightforward, the best way is to use one or more modules:

var builder = new ContainerBuilder();
builder.RegisterModule<ConventionModule>();
return builder.Build();

That code lives in the main Program class But what is the ConventionModule?

public class ConventionModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterAssemblyTypes(GetType().Assembly)
            .AsImplementedInterfaces()
            .AsSelf()
            .InstancePerLifetimeScope();
    }
}

This goes through all the types in the specified Assembly (or Assemblies), and registers all of the available types. If the type implements an interface it is registered as the interface, but types are also registered as themselves (this is important later on).

Next we’ll get Serilog in. This is easily installed with the command Install-Package Serilog. Recently Serilog has been reorganised so you’ll need to bring some sinks with you:

  • Serilog.Sinks.Trace - writes to .NET’s trace infrastructure, so unless you configure it otherwise messages will show up in DbgView and/or VS Output Window when debugging
  • Serilog.Sinks.Literate - writes to the console in colour!

Gif of a lovely console

There are plenty of other sinks available for Serilog (we make extensive use of Serilog.Sinks.RollingFile which works really well for us). Once again, the most straightforward way to get is to create an Autofac Module:

public class LoggingModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        var config = new LoggerConfiguration().MinimumLevel.Verbose()
            .WriteTo.Trace()
            .WriteTo.LiterateConsole();

        var logger = config.CreateLogger();
        Log.Logger = logger;

        builder.RegisterInstance(logger).As<ILogger>();
    }
}

Serilog is setup using a fluent builder, we set the minimum level of logging we are interested in, and then tell it we want to write to Trace and the Console, finally building the logger. The Log.Logger = logger; line adds the created logger to a static property which makes it available anywhere. This is used in integrating Serilog with Topshelf and Quartz. We won’t actually use this, preferring instead the injected (and testable) ILogger. Finally we register the built logger with Autofac as a singleton.

Next we get Topshelf installed: Install-Package Topshelf. The Topshelf quickstart really is excellent, as are the rest of the docs, and are worth a few minutes of your time to read. In order to integrate Topshelf with Serilog and Autofac we need another couple of packages:

  • Topshelf.Autofac
  • Topshelf.Serilog

Bringing everything together is really very straightforward:

static void Main(string[] args)
{
    var container = BuildContainer();

    HostFactory.Run(configurator =>
    {
        configurator.UseSerilog();
        configurator.UseAutofacContainer(container);

        configurator.Service<ITestService>(serviceConfigurator =>
        {
            serviceConfigurator.ConstructUsingAutofacContainer();
            serviceConfigurator.WhenStarted(service => service.Start());
            serviceConfigurator.WhenStopped(service => service.Stop());
        });

        configurator.RunAsLocalSystem();
        configurator.StartAutomaticallyDelayed();

        configurator.SetDescription("Sample Topshelf/Quartz scheduler");
        configurator.SetDisplayName("Topshelf Quartz Scheduler");
        configurator.SetServiceName("TQScheduler");
    });
}

You are using Topshelf to host a service, hence HostFactory.Run. There are other ways of doing this, but this is still my preferred method.

The first two lines within the configurator (configurator.UseSerilog(); and configurator.UseAutofacContainer(container);) tell Topshelf how to do different things; in this case where to log to, and how to build instances of the service.

The next section (configurator.Service...) tells Topshelf what service to run. Importantly inside the configurator we also tell Topshelf how to build our service (serviceConfigurator.ConstructUsingAutofacContainer();), and how to start and stop it. If you’re happy taking further dependecies on Topshelf you can implement ServiceControl. But we’ve taken the route of building a custom service.

Finally we specify the service should run as the Local System account, should start automatically after a delay; and provide a name, display name and service description. If you use Octopus Deploy then you should be aware that the Service Name specified in the deployment step must match that specified within the service, or you will not be able to start it.

The last step is implement the TestService itself:

public class TestService : ITestService
{
    private readonly ILogger _logger;
    private readonly Timer _timer;

    public TestService(ILogger logger)
    {
        _logger = logger;
        _timer = new Timer(1000) {AutoReset = true};
        _timer.Elapsed += (sender, args) => _logger.Information($"Timer fired {DateTime.UtcNow:G}");
    }

    public void Start()
    {
        _timer.Start();
    }

    public void Stop()
    {
        _timer.Stop();
    }
}

This instantiates a timer with a one second (1000ms) interval, starts it on service start and stops it on service stop.