At my day job, we are working on a large enterprise system. One of the feature areas of this product is an array of different charts of performance metrics. All of these charts accept a common set of parameters, and generate some json that is used by our client side to render a chart for the requested metric.
At the moment, we are using a standard MVC controller to define the actions necessary to calculate and collate these stats. The other day, we were kicking around the idea of how we would be able to add new charts in for a client, after they already have the product installed. With the current design we are using, there isn’t really an easy way to do that without dropping in the updated build that has the updated controller. That’s not really an ideal situation, since reinstalling and reconfiguring the application is kind of a heavy-handed approach when all we want to do is add a couple of new charts in, without impacting the rest of the application. It also doesn’t give us many options if our customer, for whatever reason, wants to disable certain charts.
Probably, what we’ll end up doing, is using the database to define the chart calculation SQL, either using by storing the SQL statements to retrieve the metrics in a table, or via stored procedures. I’ll admit, I’m not crazy about this approach, since I’ve worked on a couple of other products that used this method, and I have some slight PTSD from trying to troubleshoot convoluted SQL that was poorly written and not checked into source control other than in the master database creation script. With a sane process, this will probably be an effective option; one of the reasons we are likely going to need to go this route is that we are developing parallel .NET and Java versions, due to API restrictions of the underlying technology stacks that we are targeting, while trying to use a common UI and database.
This did get me thinking about how it would be possible to dynamically populate the actions of an MVC controller. I had come across some posts on CSScript, which is a library which allows you to run arbitrary C# code interpretively. Depending on how you use it, you can load up entire classes, methods or even statements, and invoke them at run-time, without the script code being compiled and embedded in your .dll. So, theoretically, it should be possible to define the controller action that calculates the metrics for a given chart in a CSScript file, load that up, execute it using the CSScript interpreter, passing any parameters needed in, and return the results.
The other piece of this is figuring out how to get an MVC controller to accept action routes that are not explicitly defined for it. Fortunately, with the latest version of MVC, you are able to tweak the default behavior of the controller to a considerable degree, if you are willing to delve into overriding some of the virtual methods the the Controller class gives you access to. So, after a little digging, I was able to figure out a slightly crazy way to wrestle MVC into doing what I wanted it to do – accepting dynamic controller actions, executing a scripted action, and then returning the result to the webpage.
This is a very quick and dirty example, really just a proof of concept prototype, so I make no promises as to its performance or robustness. But, it was cool to figure out that such a thing was possible, and I figured that I would share it as a jumping-off point in case anyone else encounters a similar crazy requirement. The code is available on my GitHub repository, at https://github.com/ericrrichards/MVCScriptedController.git.
As a simple example, let’s assume that we know that we are going to have to support controller actions with a signature like this:
ActionResult Action( string i )
An example implementation of an action like this would look like this (from Scripts/Reports/Foo.cs in the project):
public ActionResult Foo(string i) {
return new JsonResult() {
Data = "Foo - " + i,
JsonRequestBehavior = JsonRequestBehavior.AllowGet
};
}
We’ll start with an empty MVC5 project, and then add a new empty Controller to the project. Deleting the auto-generated Index action, that leaves us with this:
using System.Web.Mvc;
namespace ScriptedController.Controllers {
public class ReportController : Controller {
}
}
The Controller class offers us a virtual method, void HandleUnknownAction(string actionName), which is fired when a request is routed to the controller and the requested action does not match any of the defined controller actions. By default, this method just throws an HTTPException with a 404 error code. Interestingly enough, there must be something else in the Controller class which catches this error, because if you were to actually hit a non-existent controller action without overriding HandleUnknownAction, you’ll get a 200 response with no content. However, we can override this method to allow us to do something else, which in our case will be to look for a dynamically loaded action method delegate and execute that instead.
To support this, we are going to add a delegate to match the action signature that we are expecting, and a dictionary to map action names to these delegates to our controller. We’ll make this dictionary static, so that it can be shared between all instances of the controller that we are creating – this will save us some time on each request, since MVC creates a new Controller instance for each incoming request, so that we would otherwise need to load up our dynamic Actions for every request.
private volatile static Dictionary<string, MvcAction> _actions;
private delegate ActionResult MvcAction(string i);
We can now overload the HandleUnknownAction method, so that it checks this dictionary for an action which matches the passed actionName. If the action is found, we will execute it, and write the result to the http response, using the ActionResult.ExecuteResult() method. Otherwise, we will attempt to invoke the base HandleUnknownAction method. For whatever reason, when we do this, the resulting exception is not caught, so we need to handle that and set the response code to 404.
protected override void HandleUnknownAction(string actionName) {
if (_actions.ContainsKey(actionName)) {
_actions[actionName](HttpContext.Request.Params["i"]).ExecuteResult(ControllerContext);
} else {
try {
base.HandleUnknownAction(actionName);
} catch (Exception ex) {
HttpContext.Response.StatusCode = 404;
}
}
}
Getting the parameters to the action is somewhat hacky. We can access any of the parameters passed along with the HTTP request by using the HTTPContext.Request.Params dictionary. In this situation, where we know that all of the dynamic actions for this controller will share a common set of parameters, we can hard-code the parameters that we extract – at the moment, I’m not totally sure how I would handle a more general case, so that bears some thought.
At this point, we have a controller that can handle actions requested of it that are not defined explicitly in the class, assuming that they are contained in its dictionary of dynamic actions. The next step is figuring out how to populate that dictionary with the dynamic actions. For that, we need to use CSScript. We are going to add another method to our controller, named EnsureActionsLoaded(), which will handle this for us.
What this method will do, is check whether the actions dictionary has already been loaded, and if not, load the actions from a directory which contains a series of script files, where each file contains a single action method, with the filename matching the action name. We’ll use CSScript to dynamically load each method as a delegate, which we can then push into our dictionary.
private void EnsureActionsLoaded() {
if (_actions != null) {
return;
}
lock (SyncRoot) {
if (_actions == null) {
_actions = new Dictionary<string, MvcAction>();
var path = Server.MapPath(ScriptsDirectory);
foreach (var file in Directory.GetFiles(path)) {
try {
var action = CSScript.Evaluator.LoadDelegate<MvcAction>(System.IO.File.ReadAllText(file));
var actionName = Path.GetFileNameWithoutExtension(file);
if (!string.IsNullOrEmpty(actionName)) {
_actions[actionName] = action;
}
} catch (Exception ex) {
Console.WriteLine(ex.Message);
}
}
}
}
}
If you have dealt with the singleton pattern in C#, you may have noticed that we are using a similar double-check locking method to determine if the action dictionary has already been loaded. Since the dictionary is static, and because of the multi-threaded nature of web applications, it is possible that two or more simultaneous requests will arrive at once, and if both attempt to load the actions dictionary at the same time, they have the potential to stomp on each other.
Since we are using the Server.MapPath() function to determine the location of our script directory, we need to also override the Initialize() method of the controller to call our EnsureActionsLoaded method. Ideally, we could do this in the constructor, but at that point, the Server variable is not yet initialized.
protected override void Initialize(RequestContext requestContext) {
base.Initialize(requestContext);
EnsureActionsLoaded();
}
At this point, we have a working controller which can load CSScript files as controller actions, and execute them. There are a couple of simple examples of these scripts in the Scripts/Report folder of the project, like the following Fizz.cs:
using System.Web.Mvc;
public ActionResult Fizz(string i) {
return new JsonResult() {
Data = "Fizz - " + i,
JsonRequestBehavior = JsonRequestBehavior.AllowGet
};
}
One thing to note is that you will either need to use fully-qualified type names in your scripts, or else include the necessary using statements for CSScript to properly parse and compile your scripts. I haven’t tried anything more complicated yet using assemblies that are not referenced in the project itself, so be aware that there may be some gotchas there.
Altogether, our dynamic controller looks like this:
using System;
using System.Collections.Generic;
using System.Web.Mvc;
using System.IO;
namespace ScriptedController.Controllers {
using System.Web.Routing;
using CSScriptLibrary;
public class ReportController : Controller {
private const string ScriptsDirectory = "~/Scripts/Reports";
private static readonly object SyncRoot = new object();
private volatile static Dictionary<string, MvcAction> _actions;
private delegate ActionResult MvcAction(string i);
protected override void Initialize(RequestContext requestContext) {
base.Initialize(requestContext);
EnsureActionsLoaded();
}
private void EnsureActionsLoaded() {
if (_actions != null) {
return;
}
lock (SyncRoot) {
if (_actions == null) {
_actions = new Dictionary<string, MvcAction>();
var path = Server.MapPath(ScriptsDirectory);
foreach (var file in Directory.GetFiles(path)) {
try {
var action = CSScript.Evaluator.LoadDelegate<MvcAction>(System.IO.File.ReadAllText(file));
var actionName = Path.GetFileNameWithoutExtension(file);
if (!string.IsNullOrEmpty(actionName)) {
_actions[actionName] = action;
}
} catch (Exception ex) {
Console.WriteLine(ex.Message);
}
}
}
}
}
protected override void HandleUnknownAction(string actionName) {
if (_actions.ContainsKey(actionName)) {
_actions[actionName](HttpContext.Request.Params["i"]).ExecuteResult(ControllerContext);
} else {
try {
base.HandleUnknownAction(actionName);
} catch (Exception ex) {
HttpContext.Response.StatusCode = 404;
}
}
}
}
}
If you run the project, you should be able to hit both /Report/Foo and /Report/Fizz, and see results like the below:
Attempting to hit an unregistered controller action will give you the lovely ASP.NET 404 error page:
I’m not sure if I will ever get a chance to actually use this, but it was a fun little experiment. Hopefully if any of you find yourself with a similar problem, this will give you a starting point to work from.
No comments :
Post a Comment