ASP.NET Core Middleware
Introduction
This post is another of those "back to basics", still, there was one thing or two that I feel were not properly discussed elsewhere, hence this post.
The ASP.NET Core Pipeline and Middleware
You may know that ASP.NET Core defines a pipeline. This is where the incoming requests are processed and the response submitted. Each component in the pipeline - a middleware - can do things with the message (the request), such as check if it is authenticated and have rights to access the requested resource, turn the request into an MVC-style controller and action invocation, log it, and what not. You can read more about it here.
The ASP.NET Core pipeline, from Microsoft |
The order by which the middleware is added to the pipeline matters. For example, the authorisation middleware must come after the authentication one, static files must come before routing, and exception handling must come before everything.
The middleware order, from Microsoft |
A middleware is just a piece of code that takes a context (HttpContext) and a pointer to the next middleware in the pipeline (simplified). What it does is entirely up to us, some typical usages include:
- Logging
- Caching a response and retrieving from cache
- Exception handling by wrapping the next middleware (and the others) on the pipeline
- Timing the execution
- Adding items to the request (headers, others)
- Modifying the request somehow (redirecting, for example)
- Etc, etc
Some caveats:
- You cannot write to the response if the response has already been sent, must be careful as this results in an exception being thrown
- Unless you want to short-circuit the pipeline (more later), you should always call the next middleware
The pipeline is declared as an IApplicationBuilder, and any middleware needs to be added to it before it is built (Build).
Some included middleware components:
- AuthenticationMiddleware (UseAuthentication)
- AuthorizationMiddleware (UseAuthorization)
- EndpointRoutingMiddleware (UseRouting)
- ExceptionHandlerMiddleware (UseExceptionHandler)
- HttpsRedirectionMiddleware (UseHttpsRedirection)
- RateLimitingMiddleware (UseRateLimiter)
- RewriteMiddleware (UseRewriter)
- StaticFileMiddleware (UseStaticFiles)
- OutputCacheMiddleware (UseOutputCache)
As I said, the order by which a middleware component is added matters. Just thing, for example, that the static files middleware retrieves existing files (real URLs) before endpoint mapping (virtual URLs) steps in, and authorization needs authentication first.
Middleware vs Filters
One common question is: why use middleware if we have filters? Well, middleware applies to the request pipeline, and is therefore applicable to all requests, whereas filters belong to MVC, and in general, apply to an action method or controller (although we can have global filters too). Filters apply to an MVC specific request, and have access, for example, to an action method's model, whereas middleware is more high level.
Inline Middleware
The easiest way to add middleware to the pipeline is through the Use extension method over IApplicationBuilder. This is before the pipeline is actually built, and, remember, the order matters!
app.Use(static async (context, next) =>
{
await context.Response.WriteAsync("Hello world from an inline middleware!");
await next(context);
});
In a moment, I'll show you other ways to add middleware with just a delegate.
Convention-based Middleware
Another option is to have a class that contains the logic we wish to add to the pipeline, which is arguably a better option for more complex code. If we so want it, any POCO class will do, ASP.NET Core will use a convention to invoke it. This kind of middleware is added by the UseMiddleware method, again, before the pipeline is built:
internal class ConventionalMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IMyType myType)
{
//do something
await next(context);
}
}
//...
app.UseMiddleware<ConventionalMiddleware>(new MyType());
As you can see, there is no base class or interface that needs to be implemented, just some vanilla class, its only requirements are;
- That it is not abstract
- Has a constructor that receives a RequestDelegate object, along with possibly others
- Has a public method called InvokeAsync that returns a Task, and takes as its first (and possibly only) parameter an HttpContext
To make it more dynamic, there is an overload to UseMiddleware that takes a Type:
app.UseMiddleware(typeof(ConventionalMiddleware), new MyType());
As you can see, you can also pass any additional parameter here.
Now, you can do things before and after calling the next middleware component:
internal class StopWatchMiddleware(RequestDelegate next, ILogger<StopWatchMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var timer = new StopWatch();
timer.Start();
await next(context);
logger.LogDebug("The processing of the pipeline took {Elapsed}", timer.Elapsed);
}
}
Some common usages include logging, exception handling, and timing.
Now, because a middleware component is constructed at application startup, it essentially is a singleton, which means its constructor cannot take scoped services. For that, you have the option to pass them in the InvokeAsync method, this is the essential difference between passing services in the constructor versus InvokeAsync.
Factory-Activated Middleware
There is another kind of middleware type that you can register, only this time it needs to implement an interface. This interface is IMiddleware, and it only defines an InvokeAsync method, this time, with two fixed parameters:
internal class FactoryActivatedMiddleware(IMyType myType) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
//do something
await next(context);
}
}
//...
builder.Services.AddSingleton<FactoryActivatedMiddleware>();
app.UseMiddleware<FactoryActivatedMiddleware>();
Registration is done the same way, using UseMiddleware, the only difference is that the IMiddleware class must be registered with the DI container and will be instantiated by it. All the rest, including the possibility to inject values from the constructor (not from the InvokeAsync method!) is the same.
Convention vs Factory-Activated Middleware
In general, convention-based middleware (no base interface) is created once when starting the ASP.NET Core application, while factory-activated (IMiddleware) is created with each request, but, one can select the lifetime for it, as it must be registered with DI. Factory-based middleware supports Scoped lifetime, if we so register them, which means we can inject scoped services into its constructor, but in general they should be registered as Singleton. IMiddleware is also, of course, strongly typed, other than that, they are pretty much similar.
Middleware Factories
Factory-activated (IMiddleware) middleware, as its name says, is built by a middleware factory. A middleware factory is just an implementation of IMiddlewareFactory that is registered on the DI container. The default implementation just retrieves the middleware from DI, but you can change this. Just register your own implementation of IMiddlewareFactory with the Scoped lifetime, and implement it any way you want. Convention-based are instantiated using ActivatorUtilities.CreateInstance.
Middleware Dependencies
From the Microsoft documentation: Middleware should follow the Explicit Dependencies Principle by exposing its dependencies in its constructor. Middleware is constructed once per application lifetime.
Middleware components can resolve their dependencies from dependency injection (DI) through constructor parameters. UseMiddleware can also accept additional parameters directly, which will be passed to InvokeAsync.
Because dependencies are instantiated at app startup, the only way to inject scoped dependencies is to add them as parameters to the InvokeAsync method, for convention-based middleware.
For both kinds of middleware, you can obviously retrieve services from the DI container from HttpContext.RequestServices.
Adding Middleware from a Startup Filter
Another way to add a middleware component is from a startup filter (IStartupFilter). We can do this by calling ConfigureServices on the host before the pipeline is built:
internal class MiddlewareStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(static async (ctx, next) =>
{
await next(ctx);
});
next(app);
};
}
}
//..
builder.Host.ConfigureServices((ctx, services) =>
{
services.AddSingleton<IStartupFilter, MiddlewareStartupFilter>();
});
//...
var app = builder.Build();
app.Run();
This will add to the beginning of the pipeline, as startup filters execute very early in the pipeline build process.
Adding Middleware from a Filter Attribute
Another option to add some middleware to the pipeline is through the [MiddlewareFilter] attribute. This allows adding to the beginning of the pipeline, in pretty much the same way as calling ConfigureServices on the host:
[MiddlewareFilter<MiddlewarePipeline>]
//[MiddlewareFilter(typeof(MiddlewarePipeline))]
public class HomeController : Controler { ... }
internal class MiddlewarePipeline
{
public void Configure(IApplicationBuilder appBuilder)
{
appBuilder.UseMiddleware<SomeMiddleware>();
}
}
Short-circuiting Middleware
If we want to short-circuit (stop the execution of all) the next middleware components on the pipeline, we can skip calling the next delegate. For example:
internal class ShortCircuitingMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
//do something
if (/*NormalCondition*/) await next(context);
//pipeline ends here if some condition is not met
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.WriteAsync("Something happened - unavailable");
}
}
When you short-circuit a response, make sure you return the appropriate status code and possibly some response, otherwise, the calling party won't know what happened.
Sending Responses
Besides doing server-side operations, you can also modify the response from a middleware component - send contents (HttpResponse.Body), modify headers (HttpResponse.Headers). You just need to be certain that the response hasn't yet begun - in which case, you cannot modify the headers - or if it has been sent altogether.
In order to find out if the message has begun to be sent, you can check the HttpResponse.HasStarted property, and also in the IHttpResponseFeature request feature HasStarted property:
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!context.Response.HasStarted)
{
context.Response.Headers["From"] = "Middleware";
await context.Response.WriteAsync("Hello, World!");
}
await next(context);
}
Passing Data Between Middleware Components
A common request is to pass, share, data between middleware components. This can be useful, for example, to know if some middleware has been executed. The way I see it, there are three ways to do this:
- Using a Scoped service that can store data
- Using a request feature that also stores data
- Using HttpContext.Items to store keyed data
//Middleware M1
public const string MyKey = "__MyKey";
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
ctx.Items[MyKey] = "Some Secret Value";
ctx.Items["M1"] = true;
//...
}
//Middleware M2
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next){if (ctx.Items[MyKey] is string key){//...}//...}
Use, Map, Run Extension Methods
In the beginning, I mentioned the Use extension method over IApplicationBuilder, as a way to add inline middleware, but there are other options. Let me explain the differences.
Use
The Use extension method is used to add a new middleware component to the request pipeline. We've seen an example in the beginning of this post.
Map
The Map extension method is used to add the middleware component to the pipeline but bind it to a specific local URL pattern:
app.Map("/SomeUrl", builder =>
{
builder.UseMiddleware<SomeMiddleware>();
});
This is called branching the pipeline. For some URL, the pipeline will have different middleware components, starting on the position where we call Map.
The MapGet, MapPost, MapDelete, MapPut, MapPatch, and MapGroup, are extensions over Map and are part of the Minimal API, as it doesn't use controllers and offers a simplified model. More about it in a future post.
Run
The Run extension method is used to add a middleware component to end the execution of the ASP.NET Core request pipeline, which means that it should be the last middleware to be added and one which will not call the next one. Any other middleware added after Run will not be called and will thus be ignored. An example:
app.Run(static async (ctx) =>
{
await ctx.Response.WriteAsync("Hello, World!)";
});
You may have noticed that in the ASP.NET Core standard templates, the pipeline starts with a call to Run, this is where all the fun begins.
Adding Conditional Middleware
The final option I'm going to cover is about adding middleware that executes conditionally. We've seen how we can do it for a specific local URL pattern using Map, but there's more. Enter UseWhen and MapWhen:
app.UseWhen(ctx => ctx.Request.Query["test"] == "true", builder =>
{
builder.UseMiddleware<TestMiddleware>();
});
MapWhen also works the same as described earlier, just conditionally, it creates a pipeline branch only if the conditions are met. The condition to use can be arbitrarily complex, this is just a simple example, which I think should be easy to follow.
Conclusion
I hope you found something of use here. Middleware is an important and powerful concept, and ASP.NET Core makes heavy use of it. Let me know if you need some clarification!
Comments
Post a Comment