Tales from the .NET Migration Trenches - Middleware
Posts in this series:
- Intro
- Cataloging
- Empty Proxy
- Shared Library
- Our First Controller
- Migrating Initial Business Logic
- Our First Views
- Session State
- Hangfire
- Authentication
- Middleware
- Turning Off the Lights
In the last post, we looked at tackling probably the most important pieces of middleware - authentication. But many ASP.NET MVC 5 applications will have lots of middleware, but not all of the middleware should be migrated without some analysis on whether or not that middleware is actually needed anymore.
This part is entirely dependent on your application - you might have little to no middleware, or lots. Middleware can also exist in a number of different places:
- Web.config (you WILL forget this)
- Global.asax (probably calling into other classes with the middleware configuration)
- OWIN Startup
When choosing the first controller to migrate, I'm also looking at which controllers have the least amount of middleware, just to minimize the heavy first lift.
Let's look at our various middleware, and see what makes sense to move over, starting with our web.config
.
Migrating Web.Config
I think I forget the web.config middleware mainly because I've tried to burn most things ASP.NET from my brain. But we'll find lots of important hosting configuration settings in our web.config, from custom middleware to error handling, application configuration, server configuration and more. Luckily for us, nearly all out-of-the-box configuration has a direct analog in Kestrel. We mostly need to worry about anything custom here. My sample app doesn't have a lot going on:
<system.web>
<compilation debug="true" targetFramework="4.8.1" />
<httpRuntime targetFramework="4.8.1" />
<customErrors mode="RemoteOnly" redirectMode="ResponseRewrite">
<error statusCode="404" redirect="/404Error.aspx" />
</customErrors>
<!-- Glimpse: This can be commented in to add additional data to the Trace tab when using WebForms
<trace writeToDiagnosticsTrace="true" enabled="true" pageOutput="false"/> -->
<httpModules>
<add name="Glimpse" type="Glimpse.AspNet.HttpModule, Glimpse.AspNet" />
</httpModules>
<httpHandlers>
<add path="glimpse.axd" verb="GET" type="Glimpse.AspNet.HttpHandler, Glimpse.AspNet" />
</httpHandlers>
</system.web>
We only have one set of custom modules/handlers and it's the now-dead (and much missed) Glimpse project. In the rest of the configuration, we only see custom errors redirecting to an .ASPX page, which we can easily port over using custom errors in ASP.NET Core. Otherwise there's not much going on here.
In a typical application, the things I've needed to migrate over might include such settings as:
- Authentication
- Authorization
- Cookies
- Session state
- Data protection
- Static files
- HTTP request methods
- Initialization
- Custom headers
- Caching
Each of these has some analog in ASP.NET Core Kestrel configuration. But luckily for us, we don't have any custom handlers/modules to worry about, only porting ASP.NET runtime features to ASP.NET Core.
ASP.NET MVC 5 Middleware
Next up is ASP.NET MVC 5 middleware, which is typically set up in the Global.asax.cs
file, something like:
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
The global filters registered are:
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
filters.Add(new HandleErrorAttribute());
filters.Add(new ValidatorActionFilter());
filters.Add(new MvcTransactionFilter());
}
The first filter here is a built-in one from ASP.NET MVC to provide global error handling (with no extra configuration), but the second two are custom. The first custom filter provides some customization around handling validation errors and providing a common error result back to the UI:
public void OnActionExecuting(ActionExecutingContext filterContext)
{
if (!filterContext.Controller.ViewData.ModelState.IsValid)
{
if (filterContext.HttpContext.Request.HttpMethod == "GET")
{
var result = new HttpStatusCodeResult(HttpStatusCode.BadRequest);
filterContext.Result = result;
}
else
{
var result = new ContentResult();
string content = JsonConvert.SerializeObject(filterContext.Controller.ViewData.ModelState,
new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
});
result.Content = content;
result.ContentType = "application/json";
filterContext.HttpContext.Response.StatusCode = 400;
filterContext.Result = result;
}
}
}
The front end does still need this, so we want to port this over. The second filter provides automatic transaction handling:
public class MvcTransactionFilter : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
// Logger.Instance.Verbose("MvcTransactionFilter::OnActionExecuting");
var context = StructuremapMvc.ParentScope.CurrentNestedContainer.GetInstance<SchoolContext>();
context.BeginTransaction();
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
// Logger.Instance.Verbose("MvcTransactionFilter::OnActionExecuted");
var instance = StructuremapMvc.ParentScope.CurrentNestedContainer.GetInstance<SchoolContext>();
instance.CloseTransaction(filterContext.Exception);
}
}
I might not do automatic transactions like this in a normal project but because the application code expects it, we'll need to migrate this over as well. The transaction filter is interesting because it highlights the shortcomings of ASP.NET MVC 5's dependency injection capabilities - namely there wasn't anything built in for filters. Instead of migrating this filter as-is, we need to translate to the equivalent ASP.NET Core filter:
public class DbContextTransactionFilter : IAsyncActionFilter
{
private readonly SchoolContext _dbContext;
public DbContextTransactionFilter(SchoolContext dbContext)
{
_dbContext = dbContext;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
try
{
_dbContext.BeginTransaction();
var actionExecuted = await next();
if (actionExecuted.Exception != null && !actionExecuted.ExceptionHandled)
{
_dbContext.CloseTransaction(actionExecuted.Exception);
}
else
{
_dbContext.CloseTransaction();
}
}
catch (Exception ex)
{
_dbContext.CloseTransaction(ex);
throw;
}
}
}
And we register our filter:
builder.Services.AddControllersWithViews(opt =>
{
opt.Filters.Add<DbContextTransactionFilter>();
});
Now our filter will have its DbContext
injected instead of going out to a custom extension to mimic per-request service lifetimes.
Finally, let's look at the OWIN middleware.
OWIN Middleware
OWIN middleware can be found in classes with the OwinStartup
attribute configured for them. Usually this is a "Startup" class but it could be anything. In my sample app, we have:
[assembly: OwinStartup(typeof(Startup))]
namespace ContosoUniversity
{
public partial class Startup
{
public void Configuration(IAppBuilder app)
{
app.MapSignalR();
GlobalConfiguration.Configuration
.UseSqlServerStorage("SchoolContext")
.UseStructureMapActivator(IoC.Container)
;
app.UseHangfireDashboard();
app.UseHangfireServer(new BackgroundJobServerOptions
{
Queues = new[] { Queues.Default }
});
ConfigureAuth(app);
}
}
}
Basically, it's:
- SignalR
- Hangfire
- Authentication
Authentication might differ slightly than the ASP.NET authentication, so we'll want to port settings there. SignalR and Hangfire can be dealt with individually, but otherwise we don't have any custom OWIN middleware. This is fairly typical unless your application wholly relies on OWIN instead of say, IIS.
Middleware isn't the most exciting code to port over, but critical for ensuring our new application preserves the existing behavior of the .NET Framework application.
In our last post, we'll cover finishing up our migration and "turning off the lights".