Moving to .NET Core: HttpModules to Middleware
Quite often, we need to inject custom behavior into the request processing pipeline. There are a number of ways to do this in a .NET Framework project, depending on where in the pipeline your behavior needs to take place, and how that behavior affects the rest of the pipeline. One of the most versatile ways to inject behavior was by building a custom HttpModule. While powerful, HttpModules are hard to test, and don’t integrate well with the rest of the code for your project.
With .NET Core, Microsoft introduced a new way to build pipeline behavior—middleware. Middleware solves many of the challenges with HttpModules, and makes building a custom request pipeline easy. Converting our custom behavior to middleware was fairly easy, but there were a few surprises waiting for us. In this post, we’ll convert an HttpModule that does custom request logging to custom middleware, and discuss the benefits and potential pitfalls that this powerful new tool brings.
Our Starting Point
First off, let’s start by taking a look at this sample HttpModule. This module listens to all requests and responses, and logs the calling IP, the request path, the response status code, and the length of the response in bytes.
public class WebRequestLoggerModule : IHttpModule
{
private readonly ILog logger = LogManager.GetLogger("WebRequest");
public void Init(HttpApplication context)
{
context.BeginRequest += AddContentLengthFilter;
context.EndRequest += LogResponse;
}
private void AddContentLengthFilter(object sender, EventArgs e)
{
var application = (HttpApplication)sender;
application.Response.Filter = new ContentLengthCountingStream(application.Response.Filter);
}
private void LogResponse(object sender, EventArgs eventArgs)
{
var application = (HttpApplication)sender;
var request = application.Request;
var response = application.Response;
var responseStream = response.Filter as ContentLengthCountingStream;
logger.Info($"Calling IP: {request.UserHostAddress} Path: {request.Url.PathAndQuery} Status Code: {response.StatusCode} Length: { responseStream.Length }");
}
public void Dispose() { }
}
Omitted from this code is the ContentLengthCountingStream
.
It turns out that the only way to get the length of a response (in both WebAPI and .NET Core) is to override Stream
and count the number of bytes that are written to the buffer.
It’s also worth noting that UserHostAddress
is probably not going to be the IP Address of your actual user, especially if you’re running a reverse proxy in front of Kestrel.
This is sample code from the internet–please don’t use it in production!
There are a couple of obvious deficiencies to note as we look at the way HttpModules interact with the request pipeline.
First and most glaring is the lack of decent typing.
BeginRequest
and EndRequest
are both EventHandler
s, which means we get the downright terrible signature of (object, EventArgs)
.
That little gift from C# 1.0 cannot be deprecated soon enough.
In addition to the use of EventHandler
, both BeginRequest
and EndRequest
are events, which means we don’t have any way to define the order in which events are handled.
In practice, that’s usually fine, but it does mean that if you need something to happen at the very beginning or end of processing, you’re mostly out of luck.
Middleware basics
Middleware is the basic building block of the .NET core request pipeline. At it’s most basic, middleware is just a series of nested delegates that end by calling your controller.
One obvious benefit this provides over the old HttpModule
style of pipeline extensibility is a much greater degree of control.
Instead of just hooking up event handlers, you can control the exact order in which middleware executes, and can easily short-circuit execution based on the state of the request.
There are some limitations to this approach, which we’ll discuss later, but on the whole I’ve been very happy with the power and control afforded by middleware.
The first example of middleware shown on Microsoft’s documentation (which is otherwise very good) looks like this:
public class Startup
{
public void Configure(IApplicationBuilder app)
{
app.Use(async (context, next) =>
{
// Do work that doesn't write to the Response.
await next.Invoke();
// Do logging or other work that doesn't write to the Response.
});
app.Run(async context =>
{
await context.Response.WriteAsync("Hello from 2nd delegate.");
});
}
}
The lambda defined in app.Use
is the simplest possible middleware, because it doesn’t actually do anything.
context
is a HttpContext
, and contains information about the request and response, not unlike the HttpApplication
in our HttpModule.
next
contains a delegate that either points to the next middleware (if it exists), or the controller.
What they don’t tell you (but they should) is that while it is possible to write middleware like this, it is a terrible idea. There are three major problems with using inline lambdas to write your middleware. First, this makes the middleware basically impossible to test, since the only way to execute it is to actually run a request through the whole pipeline. Second, it adds logic to your Startup, which is already too long and complicated, so adding more configuration than you strictly need is a bad idea. Finally, it prevents you from taking advantage of your IoC container to inject dependencies.
Converting our HttpModule to Middleware
Fortunately, there’s a different way to build middleware that solves all of these problems! Let’s use that to convert our HttpModule into middleware that performs the same function. First, we’ll make a middleware class:
public class WebLoggingMiddleware
{
private readonly RequestDelegate next;
private readonly ILog log;
public WebLoggingMiddleware(RequestDelegate next, ILog log)
{
this.next = next;
this.log = log;
}
public async Task InvokeAsync(HttpContext context)
{
var wrappedContentStream = new ContentLengthCountingStream(context.Response.Body);
context.Response.Body = wrappedContentStream;
await next(context);
log.Info($"Calling IP: {context.Connection.RemoteIpAddress} Path: {context.Request.Path} Status Code: {context.Response.StatusCode} Length: { wrappedContentStream.Length }");
}
}
Let’s dig into a couple of things that are going on here.
First, you’ll notice that we’re injecting both a RequestDelegate
and an ILog
in the constructor.
This gives us the ability to test our middleware in isolation, using test doubles to simulate the behavior of our dependencies.
It’s important to note, however, that middleware is constructed once during application startup, so you can’t put any dependencies that are tied to the request in the constructor.
Fortunately, the InvokeAsync
method is also invoked using the IoC container, so if you have any scoped dependencies, you can add them as parameters to the method signature and things will work out just fine.
Next, looking at InvokeAsync
, you’ll notice that we change the response before calling await next(context);
.
If we wanted to add custom headers to the response, this is a great place to do it.
You do need to make those changes before calling next
, though, since by the time that call returns the request has already been serialized, so the response is read-only at that point.
Finally, a happy consequence of having full control of the request pipeline.
We still need to wrap the body with a ContentLengthCountingStream
(the implementation is the same as with .NET framework) to get the length of the response in .NET core.
On the other hand, because we add it to the response in the same method where we read the value from it, we can avoid casting the response body as a ContentLengthCountingStream
.
While admittedly not the most important consequence of having full control, it is still a nice benefit.
Now that we’ve built and tested our middleware, it’s time to configure it.
By adding an app.UseMiddleware<T>()
to our configure method in Startup
, we can add typed middleware to our pipeline.
One of the things that I really appreciate about using middleware is that the order of middleware operations is explicit and easy to configure.
Each call to app.UseMiddleware
is executed in order, so if some of your middleware needs to short-circuit the execution pipeline, it’s easy to understand what is being skipped.
Because we’re doing logging, and want to make sure that even requests that generate exceptions are logged, we’ll want to make sure that WebLoggingMiddleware
is the first middleware we add to our application.
Other than logging, you’ll probably want exception handling to be your first middleware, because you want to make sure that any exceptions thrown by your middleware are handled correctly.
Middleware: The Better Way to Build Request Pipelines
Middleware may be my favorite change in moving to .NET Core. Suddenly, the most esoteric and untestable part of building request pipelines in ASP.NET is transformed into a simple, ordinary code. It’s especially liberating to be able to take code that was tied so tightly to the framework, and let it stand on its own merits instead. It reminds me of the transition from highly invasive ORMs and base classes for data to micro-ORMs and POCOs (Plain Old Class Objects). In this instance, at least, the future is great!
This is part three of an ongoing series about our transition to .NET Core. For more information, see part 1 and part 2