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 ofMetadataReference
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 onMicrosoft.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.
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.
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.
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.
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