"Controllerless" views in asp.net core
I'm always looking for ways to make dev teams more cohesive.
I had a front-end dev, who typically worked on a mac with sublime, and some .net devs - who worked on PC in visual studio.
In the past I might've gone straight to react or angular and built APIs, but with .net core being cross-platform, and new features such as tag helpers that make mountains of confusing (for a front-end developer) razor syntax a thing of the past, it's now much easier to go vanilla.
The front-end developer would work on sublime and use "dotnet watch run" to auto-recompile on git pull, but never actually open a C# source code file. In fact, due to the new controller discovery feature, the entire front-end project would consist only of razor views and some webpack-built (again, using a watcher) front-end resources. They'd be able to create views at will, without controllers, and you'd still be able to do anything you can usually do from a view ( which is also expanded in asp.net core
The .net devs would be able to add controllers and actions at will, to add logic to existing views.
Turns out asp.net core ships with the perfect way for us to achieve our goal - Middleware.
Middleware constitutes software components that are daisy-chained together and invoked on each request. MVC itself is just another middleware. (Actually, MVC is just a route handler.)
Let's take a quick look at a simple Startup.cs in asp.net core:
What's going on here?
- "ConfigureServices" registers services with the built in IOC container. AddLogging and AddMvc are both extension methods that simply register a bunch of services under the hood. For example AddMvc is registering things such as the view engine, tag helpers, etc. Check out the source code here
- "Configure" is setting up the request pipeline. Order matters... This is why logging and developer exception page come first. Logging needs to be available to all middleware below so it's first, and the developer exception page can't catch exceptions unless it's already been invoked by the time the exception occurs. Each middleware takes part in the request and either passes onto the next middleware in the chain, or short-circuits it by handling it on it's own.
- UseStaticFiles just looks for a static file, if present it serves that file and short-circuits the rest of the chain
- UseMvcWithDefaultRoute looks for a controller and action that matches the default route, and if found, handles the request.
Each middleware looks something like this:
This gives us total control over the request pipeline... so how does this help us? Well, MVC is just a route handler attached to a Router Middleware. So for us to take over this process, all we need to do is write our own route handler, throw our own logic in, and use that in place of the MVC route handler. Sounds easy? It is!
The MVC Route Handler
The MVC route handler (found here is pretty straight forward - RouteAsync is what we need to concern ourselves with:
As you can see above, all it does is see if there is a matching controller and action, and if found, it executes the matching action. If we were to execute a default controller instead of giving up if we couldn't find a match, we'd achieve our goal. We simply need to change the
if (candidates == null || candidates.Count == 0) logic.
Controllerless Route Handler
Create a new class that implements IRouter
Hang on, we just copied the MVC route handler and threw our own logic in? Yep we sure did. If we can't find a matching controller and action, we simply rewrite the route data to some default value and store the old controller and action as new route values in case we need them later. Here is the full version
We need a couple more classes to finish this off. We need our builder extensions firstly, so we can "use" our middleware. Here it is in all it's glory, note that this is also unashamedly ripped off from the UseMvc builder extensions, found here
Above, the route builder uses the controllerless route handler in-place of the MVC one, other than that it's almost identical.
We'll also want a default controller, designed to take a view name as a parameter. Not much to this one:
Wire it all up
And lastly, add our middleware into the chain, in Startup.cs:
Bam, controllerless views. Also we can package up our default controller with our route handler and builder extensions in a separate assembly because "controller discovery" looks for candidates in the loaded assemblies. Nice and neat.
It all just works... if the controller and action exist, it'll execute it, if they don't, it'll look for a view that matches anyway, and serve that. Unlike rendering a view to a string (which is an alternative method), it will inherit the default behaviour of recompiling and serving the latest view if it's changed on disk, instead of serving a cached version, avoiding the need to compile to get changes.
Thanks to .net core it's easier than ever to have front-end developers mix it up with the .net team.