~ read.

Razor Email Template Parsing From a .NET Core Console Application

So how do you run Razor template parsing outside of an MVC Web App in .NET Core?

I found myself having to answer this question recently when tasked with creating a console app that could be deployed as an Azure Web Job.

TLDR; I used the Razor SDK to create a minimal dependency implementation that didn't require MVC or Roslyn compilation. See emilol/RazorMailer for a bare-bones sample.

This post will walk through the implementation, but first...

Some context

I was tasked with creating a job that could pick up messages from a storage queue, render a template and pass it off to SendGrid. The team were familiar with Razor as a templating language and weren't concerned about performance overhead of Razor compared to other parsers. Your mileage may vary.

In frameworks past, I've used RazorEngine and libraries like it to compile Razor templates outside of MVC. RazorEngine has an open issue for .NET Core support and a release candidate for those plucky enough.

A quick consultation with Google found a StackOverflow question with strategies that fall into three main camps:

The options for .NET Core

  • Use a library that supports core, like RazorLight
    My preferred option - but alas I wasn't able to find anything out of beta.

  • Parse the template using RazorProjectEngine and compile using Roslyn. (example)
    Depending on your template you will need to manage your own set of MetadataReference for compilation. Don't expect everything to work out of the box.

  • Wire up a DI container with all the services required to render an MVC ActionContext for the template (example)
    Ostensibly "It Works" (TM) but it didn't hit me right manually wiring up a DI container and taking a dependency on Microsoft.AspNetCore.Mvc outside of a web app.

The Razor SDK

The dark horse in this race is the Razor SDK, which didn't get a mention in the proposed solutions. The Razor SDK is relatively new - it's included in .NET Core 2.1 and later.

From the documentation the SDK includes a set of predefined targets, properties, and items that allow customizing the compilation of Razor files. The bit that caught my eye though was the minimum set of dependencies:

At a minimum, your project should add package references to:

  • Microsoft.AspNetCore.Razor.Design
  • Microsoft.AspNetCore.Mvc.Razor.Extensions

At last, something with the cut-down dependencies I was looking for. Unlike the other solutions, this approach compiles Razor templates at build time.

For a small hit on every build, there is a performance gain for the app, given there is no need to generate an in-memory assembly on every request using Roslyn. We also don't have to set up a full MVC controller and DI container for each request - in fact there is no need to take a dependency on Microsoft.AspNetCore.Mvc at all. Bonza.

The stuff you came here for

I've published a cut-down sample at emilol/RazorMailer. The project consists of the standalone razor views project RazorMailer.csproj, which houses the Razor templates.

RazorMailer.csproj

Using the Razor SDK, RazorMailer.csproj includes build targets that generate a separate razor views assembly with the razor templates precompiled.

RazorMailer.csproj

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <RazorCompileToolset>RazorSdk</RazorCompileToolset>
    <RazorCompileOnBuild>true</RazorCompileOnBuild>
    <EmbedRazorGenerateSources>true</EmbedRazorGenerateSources>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Razor" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.Extensions" Version="2.2.0" />
  </ItemGroup>
</Project>

This generates two assemblies at build time:

  • RazorMailer.dll which contains our services to combine a template with a model.
  • RazorMailer.Views.dll our Razor compiled templates.

views assembly

With the views precompiled we've now solved the key problem other solutions tackled with Roslyn or MVC. What's missing to complete the solution is something to retrieve the compiled Razor page from the assembly and combine it with a model.

RazorMailer includes a marker interface, to allow the assembly to be easily loaded elsewhere.

marker interface

TemplateParser builds a Razor engine, passing the marker interface to the builder so it can locate the precompiled views assembly.

TemplateParser.cs

public async Task<string> RenderAsync<TModel>(string name, TModel model)
{
    var engine = new RazorMailerRazorEngineBuilder()
        .UseEmbeddedResourcesProject(typeof(IAmARazorAssembly).Assembly)
        .Build();

    return await engine.RenderAsync(name, model);
}

RazorMailerRazorEngine takes the views assembly and loads a precompiled template based on a requested template key.

RazorMailerRazorEngine.cs

public async Task<string> RenderAsync<TModel>(string key, TModel model)
{
    var razorCompiledItem = new RazorCompiledItemLoader()
        .LoadItems(_viewAssembly)
        .FirstOrDefault(item => item.Identifier == key);

    if (razorCompiledItem == null) throw new Exception("Compilation failed");

    return await GetOutput(_viewAssembly, razorCompiledItem, model);
}

private static async Task<string> GetOutput<TModel>(
    Assembly assembly, 
    RazorCompiledItem razorCompiledItem,
    TModel model)
{
    ...
}

From here the implementation of GetOutput is similar to other solutions from stackoverflow - but without the need to set up an MVC container to get this far. See the repo for the full implementation.

For the web job, ITemplateParser is injected and invoked with the template key.

marker interface

Functions.cs

public class Functions
{
    private readonly ITemplateParser _parser;

    public Functions(ITemplateParser parser)
    {
        _parser = parser;
    }

    public async Task HelloWorldEmailHandler(
        [QueueTrigger("hello-world")] HelloWorldModel model,
        [SendGrid(
            To = "{Email}",
            Subject = "Hello from RazorMailer"
        )]
        IAsyncCollector messages,
        ILogger logger)
    {
        logger.LogInformation($"Sending email to {model.FirstName} {model.LastName}");

        var html = await _parser.RenderAsync("/Templates/HelloWorld.cshtml", model);
        var message = new SendGridMessage
        {
            HtmlContent = html
        };

        await messages.AddAsync(message);
    }
}

Full source code available at emilol/RazorMailer