How to Access HttpContext and Other Services for Dependency Injection with Hangfire

Category

Performance

Published on
Authors

Introduction

When working with background jobs, you may want to access things like the HTTP context, authentication state, or other services from your main application. Data can be passed through as a parameter to the job, but if you need data to be injectable further down the pipeline, you've got to dig a bit deeper and the docs aren't currently very helpful here.

In this blog post, we'll walk through how to create a job context for Hangfire and access the HTTP context and other data in your background jobs via DI.

Let's see how it works. You can find an example repo for this post on my github.

Hangfire Setup

First, let's create a new ASP.NET Core Web Api and install the appropriate nuget packages. For simplicity, I'll just use memory storage for this example.

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.MemoryStorage
using Hangfire;
using Hangfire.MemoryStorage;
using Microsoft.Extensions.DependencyInjection.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IJobContextAccessor, JobContextAccessor>();
builder.Services.AddScoped<IJobWithUserContext, JobWithUserContext>();
builder.Services.AddHangfire(hangfireConfig => hangfireConfig
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
    .UseColouredConsoleLogProvider()
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseMemoryStorage()
    .UseActivator(new JobWithUserContextActivator(builder.Services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>()))
);
builder.Services.AddHangfireServer();

In the code above, we register two services IJobContextAccessor and IJobWithUserContext that we'll use to inject the HTTP context in our jobs. We also add Hangfire to our application's services with the AddHangfire method and use the JobWithUserContextActivator to set up our job context (more on this in a bit).

As far as the job context and accessor, they are simple classes to capture the data we want to inject into our jobs. In this case, just the user info.

public interface IJobWithUserContext
{
    public string? User { get; set; }
}
public class JobWithUserContext : IJobWithUserContext
{
    public string? User { get; set; }
}
public interface IJobContextAccessor
{
    JobWithUserContext? UserContext { get; set; }
}
public class JobContextAccessor : IJobContextAccessor
{
    public JobWithUserContext? UserContext { get; set; }
}

The CurrentUserJobFilter is a custom filter attribute that we'll use to set the current user in the job context. Here's the code for the filter:

public class CurrentUserJobFilterAttribute : JobFilterAttribute, IClientFilter
{
    public void OnCreating(CreatingContext context)
    {
        var argue = context.Job.Args.FirstOrDefault(x => x is IJobWithUserContext);
        if (argue == null)
            throw new Exception($"This job does not implement the {nameof(IJobWithUserContext)} interface");

        var jobParameters = argue as IJobWithUserContext;
        var user = jobParameters?.User;

        if(user == null)
            throw new Exception($"A User could not be established");

        context.SetJobParameter("User", user);
    }

    public void OnCreated(CreatedContext context)
    {
    }
}

In the code above, the OnCreating method extracts the IJobWithUserContext object from the job arguments and sets its User property to the current user. We also use the SetJobParameter method to store the current user in the job context.

Finally, we need to set up the JobWithUserContextActivator to inject the job context into our jobs:

public class JobWithUserContextActivator : AspNetCoreJobActivator
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public JobWithUserContextActivator([NotNull] IServiceScopeFactory serviceScopeFactory) : base(serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
    }

    public override JobActivatorScope BeginScope(JobActivatorContext context)
    {
        var user = context.GetJobParameter<string>("User");

        if (user == null)
        {
            return base.BeginScope(context);
        }

        var serviceScope = _serviceScopeFactory.CreateScope();

        var userContextForJob = serviceScope.ServiceProvider.GetRequiredService<IJobContextAccessor>();
        userContextForJob.UserContext = new JobWithUserContext {User = user};

        return new ServiceJobActivatorScope(serviceScope);
    }
}

In the code above, we override the BeginScope method of the AspNetCoreJobActivator to create a new scope for the job and inject the job context. We retrieve the current user from the job context and create a new JobWithUserContext object that we inject into the IJobContextAccessor service.

This means that, when we're running a job, the job context will have the current user information we captured.

Seeing it in Action

With all the pieces in place, let's create a simple job. We'll also inject the IJobContextAccessor service into the job to access the HTTP context. Usually this would probably be in a downstream service, but we'll leave it here for simplicity.

public class MyJob
{
    private readonly IJobContextAccessor _jobContextAccessor;

    public MyJob(IJobContextAccessor jobContextAccessor)
    {
        _jobContextAccessor = jobContextAccessor;
    }

    public class Data : IJobWithUserContext
    {
        public string User { get; set; }
        public string SpecialProp { get; set; }
    }

    [CurrentUserJobFilter]
    public void Handle(Data jobData)
    {
        Console.WriteLine($"Hello world from '{jobData?.User}' (injectable as '{_jobContextAccessor?.UserContext?.User}') using special prop: '{jobData?.SpecialProp}'");
    }
}

Here, we define a job MyJob that takes a Data object as a parameter. We also define an interface IJobWithUserContext that contains properties for the HTTP context that we'll inject into our job. Finally, we add the CurrentUserJobFilter attribute to our job's Handle method, which will be used to set the current user in the job context.

And to close it out, we can enqueue a job with the current user using the IBackgroundJobClient:

app.MapPost("EnqueueHangfireJob", (IBackgroundJobClient backgroundJobClient) =>
{
    backgroundJobClient.Enqueue<MyJob>(x => x.Handle(new MyJob.Data
    {
        User = "Test User",
        SpecialProp = "Special Value"
    }));
    return Results.Ok();
});

Summary

And that's it! With a few lines of code, we've created a job context for Hangfire to access the HTTP context in our background jobs. This approach can be extended to inject other services and state into your jobs as well. Hopefully this helps with getting your services set up with Hangfire! If you have any questions or have any ideas on additional content you'd like to see, please feel free to reach out to me on Twitter @pdevito3. Happy coding!