Running multiple independent ASP.NET Core pipelines side by side in the same application

Β· 2154 words Β· 11 minutes to read

The other day I started looking into a problem of being able to run several independent ASP.NET Core pipelines from within the same main application, running on top of the same Kestrel server. This was actually asked on MVC Github repo but closed without a real answer.

Let’s have a detailed look at the problem, and (one) possible solution to it.

The problem πŸ”—

So in the “regular” model of building an ASP.NET Core applications, you would be creating a Startup class, and exposing two methods on it - ConfigureServices(IServiceCollection services) and Configure(IApplicationBuilder app).

An empty placeholder class is shown below and should look very familiar to everyone.

class Startup

{



    public void ConfigureServices(IServiceCollection services)

    {

        // configure service provider

    }



    public void Configure(IApplicationBuilder app)

    {

        // configure pipeline

    }

}

The problem with this set up is that you get only one place to set up all of the services for the application, and then just one place to set up the application pipeline.

Now, at the pipeline level, this can be solved by using the Map() extension methods of the IApplicationBuilder, as they allow you to create independent branches in your application, with separate middleware components plugged into each of the branches. They will, however, share all of the services - the ones defined upfront in ConfigureServices(IServiceCollection services).

A more practical example. Let’s consider the code below:

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc();

    services.AddIdentityServer();

}



public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)

{

    app.Map("/openid", id => 

    {

        // use embedded identity server to issue tokens

        id.UseIdentityServer();

    });



    app.Map("/api", api => 

    {

        api.UseIdentityServerAuthentication(new IdentityServerAuthenticationOptions

        {

            Authority = "https://localhost:5000/openid"

        });



        api.UseMvc();

    });

}

By using the aforementioned Map() extension, we managed to create a very elegant split in our ASP.NET Core application. We have an /openid branch in our server side logic now, which is responsible for token issuing. We also we have an /api branch, which is responsible for consuming the token and exposing an MVC based API.

And while this is sufficient for most use cases, we have not managed to achieve a full isolation of these two branches. The services registered before in ConfigureServices(IServiceCollection services) are equally available in both the /openid branch and the /api branch.

So - in this article - when we talk about multiple parallel pipelines, we think of completely independent IServiceProvider. We’d like to have two (or more) separate branches in our application, each of which is happily using it’s own set of services.

Of course in the above example, it wouldn’t change much, since the two branches don’t really share any services anyway so there is no potential collision point. But what if you wanted to run two separate MVC pipelines, each with it’s own configuration/setup? Remember - MVC configuration is done inside AddMvc() method, so with the default approach, you can only configure one MVC runtime per application. Not to mention, MVC would discover controllers only once, meaning you cannot have a dedicated set of controllers per branch.

public void ConfigureServices(IServiceCollection services)

{

    services.AddMvc(opt => { // set up MvcOptions });

}



public void Configure(IApplicationBuilder app)

{

    app.Map("/admin", admin => 

    {

        admin.UseMvc();

    });



    app.Map("/api", api => 

    {

        api.UseMvc();

    });



    // both APIs would use the same set of MvcOptions

    // and it's impossible to configure MvcOptions

    // per branch

}

It gets even worse if you want to configure authorization - because that’s also done off the IMvcBuilder and is normally chained after AddMvc(). As a result, it’s impossible to have separate authorization profiles.

Setting up independent pipelines πŸ”—

Let’s have a look at how we could remedy that.

First of all, we will get rid of registering services in the ConfigureServices(IServiceCollection services), since - as we have already established - that adds them to the global IServiceProvider. Instead, we will leave it completely empty.

Next, we will create our own extension method, that will mimic what Map() does, except each of our “branches” will get its own independent set of services; so their setup will be also done there. The experience that we are after is like this:

public void Configure(IApplicationBuilder app)

{

    app.UseBranchWithServices("/admin",

        services =>

        {

            // set up any services needed on this branch

            services.AddMvc(opt => { // set up MvcOptions });

        },

        appBuilder =>

        {

            appBuilder.UseMvc();

        });



    app.UseBranchWithServices("/api",

        services =>

        {

            // set up any services needed on this branch

            services.AddMvc(opt => { // set up MvcOptions });

        },

        appBuilder =>

        {

            appBuilder.UseMvc();

        });



    // still use the main branch normally if needed

    app.Run(async c =>

    {

        await c.Response.WriteAsync("Nothing here!");

    });

}

Below is the definition of the UseBranchWithServices() extension method. It may look a bit convoluted at first glance, but we’ll walk through it in a moment.

public static class ApplicationBuilderExtensions

{

    public static IApplicationBuilder UseBranchWithServices(this IApplicationBuilder app, PathString path, 

        Action<IServiceCollection> servicesConfiguration, Action<IApplicationBuilder> appBuilderConfiguration)

    {

        var webHost = new WebHostBuilder().UseKestrel().ConfigureServices(servicesConfiguration).UseStartup<EmptyStartup>().Build();

        var serviceProvider = webHost.Services;

        var serverFeatures = webHost.ServerFeatures;



        var appBuilderFactory = serviceProvider.GetRequiredService<IApplicationBuilderFactory>();

        var branchBuilder = appBuilderFactory.CreateBuilder(serverFeatures);

        var factory = serviceProvider.GetRequiredService<IServiceScopeFactory>();



        branchBuilder.Use(async (context, next) =>

        {

            using (var scope = factory.CreateScope())

            {

                context.RequestServices = scope.ServiceProvider;

                await next();

            }

        });



        appBuilderConfiguration(branchBuilder);



        var branchDelegate = branchBuilder.Build();



        return app.Map(path, builder =>

        {

            builder.Use(async (context, next) =>

            {

                await branchDelegate(context);

            });

        });

    }



    private class EmptyStartup

    {

        public void ConfigureServices(IServiceCollection services) {}



        public void Configure(IApplicationBuilder app) {}

    }

}

So the extension method hangs off IApplicationBuilder. It takes a path which represents the branch in our application’s routing logic and two delegates - one to configure IServiceCollection (this is naturally for the independent IServiceProvider inside the branch) and one to configure inner IApplicationBuilder (the pipeline inside our branch).

Inside that extension method, we will first create an IWebHost instance. This is technically not necessary, but it’s the easiest way for us to obtain a “clean”, “independent” IServiceProvider. Otherwise, in order to ensure all the default services are correctly populated, we’d need to copy a bunch of framework code from here or resort to reflection. We can conveniently pass in our servicesConfiguration delegate into the builder too, so that our required services are automatically configured too.

The builder mandates also us to have a Startup class so we just use an empty one as a placeholder.

Next, we get a hold of IApplicationBuilderFactory and create a new application branch. In that branch, we will create a middleware that wraps everything in an IServiceScope. This is needed to correctly resolve dependencies scope and mimics the behavior of the regular ASP.NET Core code.

Next, we invoke our appBuilderConfiguration delegate so that our required middleware components are added to the new branch; the order here is important - it must happen after the middleware that created scope. Finally, we build this independent branch and call the regular Map() on the original “outer” branch and direct the request from there into our nested branch, and that’s it. The branch is now fully independent.

Handling MVC πŸ”—

While the branches may be fully independent now, there are still some small tweaks that need to be done on the MVC level. This is because by default, MVC discovers all the controllers from the current assembly, and we’d like to scope them to branches.

I guess that if you reach the level of isolation that we are discussing in this article, you’d probably be loading controllers from external assemblies anyway, but for the sake of an example let’s consider how we could alter the controller discovery logic so that the same controllers are not picked up in both of our stand alone branches.

Let’s imagine we have two controllers, each of which is only relevant to one of our branches.

[Route("[controller]")]

    public class AdminDataController : Controller

    {

        private IHiService _adminService;



        public AdminDataController(IHiService adminService)

        {

            _adminService = adminService;

        }



        [HttpGet]

        public string Get()

        {

            return "I'm Admin Data Controller. " + _adminService.SayHi();

        }

    }



    [Route("[controller]")]

    public class ResourceController : Controller

    {

        private IHiService _resourceService;



        public ResourceController(IHiService resourceService)

        {

            _resourceService = resourceService;

        }



        [HttpGet]

        public string Get()

        {

            return "I'm Resource Controller. " + _resourceService.SayHi();

        }

    }

They’d both use some imaginary IHiService which we can disregard for now (suffice to say, the implementation would probably be different for each type of the controller).

But how do we ensure that our /api branch only discovers ResourceController and /admin branch only discovers AdminDataController? There are a few ways to achieve that. The most straight forward approach is use a cuostmized ControllerFeatureProvider, which let’s you filter the types and decided whether a given one is a valid controller or not. This is shown below in a custom TypedControllerFeatureProvider:

public class TypedControllerFeatureProvider<TController> : ControllerFeatureProvider where TController : ControllerBase

{

    protected override bool IsController(TypeInfo typeInfo)

    {

        if (!typeof(TController).GetTypeInfo().IsAssignableFrom(typeInfo)) return false;

        return base.IsController(typeInfo);

    }

}

TypedControllerFeatureProvider, as the name suggests, is typed against TController. That generic is then used as a part of base type verification logic. In other words, we can decide that a controller is valid by checking if it inherits from a relevant base type.

We can now introduce two empty marker abstract controllers, which we’ll use as flags for which “branch” should the controller belong to.

public abstract class MyApiController : ControllerBase {}

public abstract class DashboardController : ControllerBase {}

DashboardController will be the base for everything in our /admin branch and MyApiController will be the base for everything in our /api branch.

Finally, let’s update the original controllers to use the marker controllers as base classes:

public class ResourceController : MyApiController {} // rest omitted for brevity

public class AdminDataController : DashboardController {} // rest omitted for brevity

Next step is to combine this custom controller provider with our UseBranchWithServices() extension method.

Trying it out πŸ”—

Before we use it, let’s introduce one extra building piece. It’s not really needed, but it will act as an excellent illustration of the isolation of the branches. Remember when we looked at the controllers, they used IHiService internally?

Let’s quickly look at the service interface and it’s two simple implementations:

public interface IHiService

{

    string SayHi();

}



public class AdminService : IHiService

{

    public string SayHi()

    {

        return "Hi from Admin Service";

    }

}



public class ResourceService : IHiService

{

    public string SayHi()

    {

        return "Hi from Resource Service";

    }

}

The gist here is we have a single service interface, and we’d like to use a different implementation in a different branch. This is a very straight forward example of what we could be doing with the fact that our branches are now completely stand alone.

Back to the code, cause we have all the pieces now:

public void Configure(IApplicationBuilder app)

{

    app.UseBranchWithServices("/admin",

        services =>

        {

            services.AddTransient<IHiService, AdminService>();

            services.AddMvc().ConfigureApplicationPartManager(manager =>

            {

                manager.FeatureProviders.Clear();

                manager.FeatureProviders.Add(new TypedControllerFeatureProvider<DashboardController>());

            });

        },

        appBuilder =>

        {

            appBuilder.Use(async (c, next) =>

            {

                if (c.Request.Path.ToString().Contains("foo"))

                {

                    await c.Response.WriteAsync("bar!");

                }

                else

                {

                    await next();

                }

            });



            appBuilder.UseMvc();

        });



    app.UseBranchWithServices("/api",

        services =>

        {

            services.AddTransient<IHiService, ResourceService>();

            services.AddMvc().ConfigureApplicationPartManager(manager =>

            {

                manager.FeatureProviders.Clear();

                manager.FeatureProviders.Add(new TypedControllerFeatureProvider<MyApiController>());

            });

        },

        appBuilder =>

        {

            appBuilder.UseMvc();

        });



    app.Run(async c =>

    {

        await c.Response.WriteAsync("Nothing here!");

    });

}

So what is going on here? Well, we have two branches /admin and /api. Each one has it’s own set of services, that are isolated from each other. They both register and use MVC, but with different options - one only discovers controllers inheriting from DashboardController, the other from MyApiController.

They also both register IHiService, but different implementation - one uses AdminService and the other uses ResourceService.

Finally, the /admin branch also uses an extra piece of middleware. This is just for illustration, because it could be just about anything, but in this case it’s a simple middleware that responds with “bar!” when the request path is something like /admin/foo.

If you run this application, and let’s say it starts on localhost:5000, the following happens when you request:

  • localhost:5000 - we do not enter any of our branches and “Nothing here!” prints cause we hit our middleware registered on the main branch
  • localhost:5000/admin - we enter our /admin branch. The response is 404 cause nothing is there that can handle our request
  • localhost:5000/admin/admindata - we enter our /admin branch. We then hit the AdminDataController which prints “I’m Admin Data Controller. Hi from Admin Service” meaning it uses it’s isolated services correctly
  • localhost:5000/admin/resource - we enter our /admin branch. The response is 404, because there is no controller ResourceController available for this branch
  • localhost:5000/admin/foo - we enter our /admin branch. We then hit the middleware which prints “bar!”
  • localhost:5000/api - we enter our /api branch. The response is 404 cause nothing is there that can handle our request
  • localhost:5000/api/resource - we enter our /api branch. We then hit the ResourceController which prints “I’m Admin Data Controller. Hi from Resource Service” meaning it uses it’s isolated services correctly
  • localhost:5000/api/admindata - we enter our /api branch. The response is 404, because there is no controller AdminDataController available for this branch

And that’s it! Hope this helps you when trying to build stand alone / independent branches in your web apps or when you are trying to build multi-tenancy systems.

All the source code is on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP