Alternative view engine for ASP.NET Core: Hooking into the ASP.NET Core architecture

elfalem

elfalem

Posted on August 9, 2019

Alternative view engine for ASP.NET Core: Hooking into the ASP.NET Core architecture

ASP.NET Core is a great open-source framework for building web apps. One of its advantages is its modular architecture, which makes it possible to replace many of the default components with alternative ones. In this post, we'll look at how we can create and use a new view engine with ASP.NET.

Creating a Razor View

Let's first create an ASP.NET Core MVC app with a Razor view. You'll need the .NET Core SDK. Using the terminal on your OS or in your favorite IDE, such as VS Code (a great, cross-platform IDE), create a project called Foo and initialize it with the default template for an MVC app.

mkdir foo
cd foo/
dotnet new mvc

You can now run the app with dotnet run and navigate to https://localhost:5001/ to access the home page displaying a welcome message.

Now we'll add an action named Bar to the Home controller as well a corresponding Razor view.

Edit HomeController.cs to add the following:

public IActionResult Bar(){
    ViewData["Message"] = "Hello World!";

    return View();
}

Create Views/Home/Bar.cshtml and add the following:

Your Message: @ViewData["Message"]

If you stop and run the app again, you should see the text Your Message: Hello World! when accessing https://localhost:5001/Home/Bar

An alternative view engine

As we saw above, the Razor view engine that comes with ASP.NET uses markup files with the extension .cshtml. The Razor syntax uses the @ symbol to transition to C# and evaluate expressions. As an alternative, what if we use the mustache syntax for markup files? We can use braces to denote variables that will need to be treated as C# expressions. In our case, the above Bar view will look like this in the new template system:

Your Message: {{Message}}

We'll use a similar convention as Razor for locating views - i.e. Views/[controller]/[action] and utilize the file extension .stache. Let's see how we can implement the Stache View Engine.

Creating a view engine

A view engine needs to implement two interfaces: IViewEngine and IView from the namespace Microsoft.AspNetCore.Mvc.ViewEngines. In our project, create a top level directory named Stache containing the files StacheViewEngine.cs and StacheView.cs. We'll walk through the key parts of these two interfaces below. You can find the full contents of the two files on this GitHub gist.

IViewEngine

IViewEngine requires implementing two methods:

public ViewEngineResult GetView (string executingFilePath, string viewPath, bool isMainPage);

public ViewEngineResult FindView (ActionContext context, string viewName, bool isMainPage);

Both methods return a ViewEngineResult which encapsulates ViewEngineResult.Found, indicating an instance of the view was found, and ViewEngineResult.NotFound, indicating that the view was not found and containing the list of locations searched.

It's helpful to discuss how a view can be referenced when it's returned from a controller to understand the roles these two methods play in the process of rendering a view. In our Bar controller, we just write return View(); which references the view with the same name as the action (i.e. Bar). We could have also directly named the view: return View("Bar");. Alternatively it's possible to explicitly provide the full path to a view file: return View("~/Views/Home/Bar.stache");. In all cases, the framework first invokes the GetView() method. If a view is not found, it will then invoke FindView().

In cases where the view path is explicitly given, we expect GetView() to return an instance of that view. In cases where we only have the view name, we fallback to FindView() to search various paths that we've defined by convention to be possible locations where a view could exist.

In our implementation of GetView(), we first check if viewPath is an actual file path or just the name of the action. We'll get the latter if the view is referenced by name only. In that case, we'll return ViewEngineResult.NotFound so the call can proceed to FindView().

If it's a file path, we'll pass it to a private method GetAbsolutePath() to ensure it's well formatted. executingFilePath will only have a value if there is another view that's currently executing (e.g. the parent view when processing a Razor partial view). If it has a value, it will get combined with the view path to generate a path relative to the parent executing view. Once we have the path, we check if a template exists at that path and return appropriately.

public ViewEngineResult GetView(string executingFilePath, string viewPath, bool isMainPage)
{
  if(string.IsNullOrEmpty(viewPath) || !viewPath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)){
    return ViewEngineResult.NotFound(viewPath, Enumerable.Empty<string>());  
  }

  var appRelativePath = GetAbsolutePath(executingFilePath, viewPath);

  if(File.Exists(appRelativePath)){
    return ViewEngineResult.Found(viewPath, new StacheView(appRelativePath));
  }

  return ViewEngineResult.NotFound(viewPath, new List<string>{ appRelativePath});
}

For our implementation of FindView(), we're provided the view name as well as the action context from which we can get the controller name. Based on our convention for locating views, we'll plug in those two values into each of the _viewLocationFormats (e.g. Views/[controller]/[action].stache) and check for a template file until one is found. If none are found, we return ViewEngineResult.NotFound with all the paths we checked.

public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage)
{
  if(context.ActionDescriptor.RouteValues.TryGetValue("controller", out var controllerName)){
    var checkedLocations = new List<string>();

    foreach(var locationFormat in _viewLocationFormats){
      var possibleViewLocation = string.Format(locationFormat, viewName, controllerName);

      if(File.Exists(possibleViewLocation)){
        return ViewEngineResult.Found(viewName, new StacheView(possibleViewLocation));
      }
      checkedLocations.Add(possibleViewLocation);
    }

    return ViewEngineResult.NotFound(viewName, checkedLocations);
  }
  throw new Exception("Controller route value not found.");
}

IView

IView requires implementing the Path property and the RenderAsync() method. Path is the location of the view template. We populate the Path property when instantiating the view in GetView() or FindView().

The RenderAsync method is responsible for rendering the view given a ViewContext and is where the magic happens. In this grossly oversimplified example, we just look for the string {{Message}} in the template and replace it with the value provided in the ViewData. In a more fleshed out version, this is where your custom syntax parser would do it's work.

public Task RenderAsync(ViewContext context)
{
  var template = File.ReadAllText(Path);

  var processedOutput = template.Replace("{{Message}}", context.ViewData["Message"]?.ToString());

  return context.Writer.WriteAsync(processedOutput);
}

Using the alternative view engine

Let's create an alternative Bar view using the new Stache template syntax. Create the file Views/Home/Bar.stache with the contents:

Your Message: {{Message}}

The last step is to tell MVC to use the new view engine. In Startup.cs modify the MVC configuration to be:

services.AddMvc()
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
    .AddViewOptions(options => {
        options.ViewEngines.Insert(0, new StacheViewEngine());
    });

Note that we're adding the Stache view engine as the first item in the list. This will ensure that the Stache template is used if both .cshtml and .stache markup files are found for a given view. If you'd like to default to Razor views instead, you can add Stache to the end of the list: options.ViewEngines.Add(new StacheViewEngine());.

If you run the app and navigate to the Bar action https://localhost:5001/Home/Bar, you should now see the view rendered with Stache!

Summary

In the above example, we saw that it's relatively simple to plug in an alternative view engine in place of Razor. It illustrates the possibility of using a variety of markup templates, even simultaneously, with ASP.NET.

Although we have the foundation on which can build upon, this rudimentary example is quite limited:

  • There is only one variable named Message that we can bind to. We should be able to use as many variables as needed for a given template.
  • We need support for more advanced expressions instead of just variables (e.g. inline expressions such as {{1 + 1}}), loops to iterate through lists, and if-else conditions for control flow.
  • We're missing some of the more advanced concepts of Razor such as layouts, partials and tag helpers.

It will be interesting to explore some of these missing features and develop our alternative view engine to implement them. Perhaps we'll do that in future posts.

Thanks to @sohjsolwin for providing feedback on this post.
Also a special shout-out to the blog entry "Creating a New View Engine in ASP.NET Core" by Dave Paquette for putting together some of the key puzzle pieces.
Cover photo by Eryk via Unsplash

💖 💪 🙅 🚩
elfalem
elfalem

Posted on August 9, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related