Issues

How to Set up Cultiv.Hangfire with AppSettings Configuration

The "Problem"

With a recent project for ProWorks I've finally gotten the opportunity to use Sebastiaan's awesome Cultiv.Hangfire package. If you haven't had a chance to check it out, you should go give the details a peek on the marketplace. Not only does it seamlessly install Hangfire onto your site, but Seb also has super handy instructions to quickly get a job running via a Composer!

This worked very well for me, right out of the box, until I decided to pull some data from my appSettings.json file. I am using Hangfire to do a content import from a third party API into Umbraco for this project, and the client I am working with has their own development team. Rather than setting the CRON expression in the Composer like Seb has in his example, I wanted to set it in the appSettings - and therefore the Environment Variables - so that way if they wanted to change how frequently this runs, they don't have to dig into the C# code to update it.

If you've ever tried to pull settings into a Composer, you may have noticed that the Composers fire before the configuration is finalized in startup, which means you can't easily call directly from the appSettings (if someone knows a way around this - please feel free to give me the tip!). This means I needed to find a different way than the documentation to access my appSettings - no more Composer for me 🥹

The Solution: An IHostedService

After a bit of research and pouring through Umbraco documentation to discover if there was a good way to re-order when the Composers were registered so it would be after the Settings were fully configured, I ended up deciding to use a Hosted Service to get around the issue.

Honestly, the setup wasn't any more complex than the Composer, so hopefully I can get you going as quickly as Seb's documentation does.

Set up your AppSettings

First, you need the CRON expression in your appSettings. I'm storing mine in a custom area for my client. It looks similar to this:

"Umbraco": {
    // All my Umbraco settings are in her like normal
},
"ClientSettings": {
    "ImportCRON": "0 0 * * *"
}

NOTE: This particular CRON expression means "Run every day at midnight". If you're looking for different settings, then the Cronhub expression generator will get you sorted.

Then you need to register this sucker in it's own model like so:

namespace Client.Core.Models.Config
{
    public class ClientSettings
    {
        public string? PublicArtImportCRON { get; set; }
    }
}

And make sure that you register the configuration in your startup. I use a custom UmbracoBuilderExtensions file where I add these so I can keep it tidy. We'll be referencing this again in the future.

using Client.Core.Models.Config;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;

namespace Client.Core.Extensions
{
    public static class UmbracoBuilderExtensions
    {
        public static IUmbracoBuilder AddClient(this IUmbracoBuilder builder)
        {
            var cfg = new ClientSettings();
            var configSection = builder.Config.GetSection("ClientSettings");
            configSection.Bind(cfg);

            builder.Services.Configure<ClientSettings>(configSection);
            builder.Build()

            return builder;
        }
    }
}

And then I call this in my Program.cs file.

builder.CreateUmbracoBuilder()
    .AddDeliveryApi()
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .AddClient() // This is where we call our extension
.Build();

Set up the Import Worker

Instead of doing the actual work in the Component, we're moving the code to a Worker instead, just like if we were calling a Service from the component. I have a basic example set up here:

using Hangfire.Console;
using Hangfire.Server;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;

namespace Client.Core.Startup
{
    public class ImportWorker
    {
        private readonly ILogger<ImportWorker> _logger;

        public ImportWorker(
            ILogger<Worker> logger)
        {
            _logger = logger;
        }

        public async Task Import(PerformContext? context)
        {
            var progressBar = context.WriteProgressBar();
            context.WriteLine("Starting the Hangfire job");
            
            // Do all the stuff that you need to do here to actually
            // run your import or other re-occuring task
            // If you need more information about how to move the progress
            // bar then I recommend reading Seb's starter documentation
        }
    }
}

Set up your Hosted Service

Now that you have the CRON expression in your AppSettings and available in your Core project and your Worker set up to actually run the job, you can set up your hosted service and trigger it there.

using Hangfire;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Client.Core.Models.Config;

namespace Client.Core.Startup
{
    public class ImportJobStartup : IHostedService
    {
        private readonly ClientSettings _settings;
        private readonly ILogger<ImportJobStartup> _logger;

        public ImportJobStartup(
            IOptions<ClientSettings> settings,
            ILogger<ImportJobStartup> logger)
        {
            _settings = settings.Value;
            _logger = logger;
        }

        public Task StartAsync(CancellationToken cancellationToken)
        {
            // Get the CRON expression from your settings, 
            // or fall back to 1am every day
            var cron = string.IsNullOrWhiteSpace(_settings.PublicArtImportCRON)
                ? "0 1 * * *"
                : _settings.PublicArtImportCRON;

            // Log that you've started up the job for easy tracking
            _logger.LogInformation("Registering Hangfire recurring job with CRON: {Cron}", cron);

            // This is where we run our ImportWorker we created above
            RecurringJob.AddOrUpdate<ImportWorker>(
                "Import",
                job => job.Import(null),
                cron
            );

            return Task.CompletedTask;
        }

        public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
    }
}

Register the Worker and Hosted Service in startup

We're going to go back to the extension file now and add a new method that will set up our services. I did mine like this:

public static IUmbracoBuilder AddHangFireServices(this IUmbracoBuilder builder)
{
    // It's important this is scoped if you are accessing any scoped services inside it.
    builder.Services.AddScoped<ImportWorker>();
    builder.Services.AddHostedService<ImportJobStartup>();
    return builder;
}

And then in the AddClient method above, we need to call these to register them to Umbraco before we .Build().

    builder
        .AddHangFireServices()
        .Build();

Conclusion

Using a HostedService to run your Hangfire job makes it easy to access any settings that you want to add via your appSettings.json file and by extension your Environment Variables. This gives you the flexibility to update the CRON expression without having to dig into the code (although it will require you to still redeploy the site) if you ever want to change the timing for when the job runs.

You could extend this to add the ability to turn the job on and off entirely if you wanted or apply any additional settings that you like!

And of course if you have any magic tips for how I can actually do this inside a Composer, I would love to hear it. But for the moment, this worked perfectly for me and I successfully tested it and ran my job with no trouble! I can also see the CRON expression being applied from the appSettings.json file in the dashboard in the backoffice. Peerfection!

Janae Cram

Janae Cram is a co-creator and the code behind Skrift. She's a bit of a perfectionist and likes her code to look as pretty as the front-facing applications she creates as a senior developer for ProWorks Corporation. She has an odd collection of My Little Ponies, loves to play video games and dress up like a geek for LARP on Friday nights, and is a VTuber on twitch. Her D&D class of choice is Bard, because if you can kick ass and sing, why wouldn't you?

comments powered by Disqus