jeudi 23 décembre 2021

Should I plumb CancellationTokens through all of my class methods in an asp.net core 3.1 web api?

I've been building a web server in asp.net core 3.1 for a little while and I've started to notice (and frankly, dislike) that Im plumbing cancellation tokens through my application. I came up with a solution to this "problem" (it is and it isn't) and am seeking opinons.

First, just so we're all on the same page - let's recap the request pipeline:

  • A request hits the server and the asp.net core framework instantiates an HttpContext
  • The framework instantiates a CancellationToken and attaches it to the HttpContext.RequestAborted property, (which, yes lol, has the type CancellationToken)
  • Some mapping happens behind the scenes in the framework, and a controller is resolved from the DI container (the controllers are registered as services)
  • The controller method is called, decorated by a pipeline of middleware through which the HttpContext instance is passed
  • The framework performs model binding on the HttpContext instance (taking from the route params, query params, body, header, etc ) and then attempts to pass the results to the method discovered during mapping
  • Model binding results that fit the signature are injected (or passed, whichever word you prefer) as well as the CancellationToken taken from the HttpContext.RequestAborted property.

Second - here is some context on my problem:

I use Autofac for dependency injection, and I learned at some point that you can give dependencies (class instances) Lifetime Scopes, which will dictate how long lived a class instance is. I also developed a practical or intuitive perspective, which is a little different, and also differs by scope type. Examples probably illustrate this best:

  • a single instance for the lifetime of the application (where you can store data, and then resolve it anywhere else in the application at any time),
  • a unique instance each time the type is injected, so no state is shared between injected instances
  • others!

Since this is an asp.net core 3.1 mvc/web api, a lifetimeScope is available from Autofac that allows you to resolve the same instance from the container from within the context of the current request (in other words, any code executing for any one given request will resovle the same instance. Different request? Different instance). This is the .InstancePerLifetimeScope() scope type in Autofac 6.

Using this information, I realized that I could write a piece of middleware that assignes the httpContext.RequestAborted cancellation token to a transport class that has a lifetime scope of InstancePerLifetimeScope and then inject that transport into the places that directly consume the token. In the codebase, this is not very many places (certainly far fewer than all of the plumbed locations).

Here is a minimal set of code that demonstrates what such a no-plumb scenario might be set up like - imagine each class is in its own file:

public class SetCancellationTokenTransportMiddleware
{
    private readonly RequestDelegate next;

    public SetCancellationTokenTransport(RequestDelegate next)
    {
        this.next = next;
    }

    public async Task InvokeAsync(HttpContext context, ITransportACancellationToken transport)
    {
        transport.Assign(context.RequestAborted);
        await next(context);
    }
}



// Autoface module registers
public class GeneralModule : Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterType<SomeDependency>().As<ISomeDependency>();
        builder.RegisterType<Repository>().As<IRepository>();
        builder.RegisterType<CancellationTokenTransport>().As<ITransportACancellationToken>().InstancePerLifetimeScope();
    }
}

// mvc controller - cancellation token is not injected
public class Controller
{
    private readonly ISomeDependency dep;

    public Controller(ISomeDependency dep)
    {
        this.dep = dep;
    }

    [HttpGet()]
    public async Task<Thing> Get()
    {
        return await dep.Handle();
    }
}

// This thing gets bypassed -- the cancellation token isn't plumbed through here
public class SomeDependency : ISomeDependency
{
    private readonly Repository repository;
    
    public SomeDependency(IRepository repository)
    {
        this.repository = repository;
    {

    public async Task Handle() // no token is passed
    {
        return await repository.GetThing(); // no token is passed
    }
}

public class Repository : IRepository
{
    private readonly ITransportACancellationToken transport;
    private readonly DbContext context;

    public Repository(ITransportACancellationToken transport, DbContext context)
    {
        this.transport = transport;
        this.context = context;
    }

    public async Task<Thing> GetThing()
    {
        // The transport passes the token here - bypassing the calling class[es]

        return await context.Things.ToListAsync(transport.CancellationToken); 
    }
}

So my question generally is - what do you think about this approach to providing Cancellation tokens directly to token consumers (and not their intermediaries)? What Pros and Cons you can think of? Have you ever been burnt by this approach? Does anyone use this in their applications or those they've worked on?

One argument against this pattern I can think of are cases where you need explicitely deprive a code path of a cancellation token. However, the repository here could also technically be designed in a such a way that allows one to choose if cancellation is allowed. Using a Unit of Work pattern, this is also partially mitigates (and the transport class would be injected into the unit of work).

I would very much like to hear some experienced perspectives on this one. General thoughts are of course welcome as well.

Aucun commentaire:

Enregistrer un commentaire