Generating HTML emails with RazorEngine - Part 03 - Caching, VS integration & namespace config
This is the third part of a 10-part blog series. You'll find a list of all the posts in this series in the introductory post. Make sure to review the Before we start section in the introductory post.
All the source code is on GitHub. Comment? Bug? Open an issue on GitHub.
Caching templates with RazorEngine
When generating our email in the previous post, we happily ignored the cacheName
parameter of the TemplateService.Parse()
method. This was wrong.
Not using RazorEngine's cache will result in both dreadful performances and memory leaks.
Generating a document from a Razor template involves some very expensive operations. We'll cover this in more details in the next post but in a nutshell, TemplateService.Parse()
will:
- Parse your template and generate the source code of a class that can generate the final document.
- Compile that class on the fly into its own assembly.
- Load that newly created assembly into your app domain.
- Instantiate the new class.
- Use that new class to generate the final document.
Steps 2 and 3 are the expensive ones. Even with a trivially simple template like the one we used in the previous post, the whole process takes more 100ms on my machine. Throw in a layout and a few partials and the process could easily take more than a second.
It also leads to a memory leak as assemblies can't be unloaded from the primary app domain.
Thankfully, RazorEngine makes it trivial to fix this. Simply make sure to always specify a cache name when calling TemplateService.Parse()
:
var emailHtmlBody = templateService.Parse(welcomeEmailTemplate, model, null, "WelcomeEmail");
RazorEngine will then keep the generated class instance in its cache and re-use it whenever you call Parse()
with the same cache name instead of parsing your template again for every call to Parse()
. In practice, once a template has been cached, calling TemplateService.Parse()
with that same template and cache name is pretty much instant.
IMPORTANT: In the current version of RazorEngine (3.4), each TemplateService
instance maintain their own cache. You therefore must use the same instance of TemplateService
throughout the lifetime of your application. If you're using a DI container, configure TemplateService
to be created with a Singleton lifetime.
The project for Part 03 on GitHub includes a quick benchmark demonstrating the effect of using the cache in RazorEngine.
Visual Studio integration
Fixing the Razor intellisense
When we edited the Razor template in the previous post, the Visual Studio editor complained about @model
with:
Cannot resolve symbol 'model'
It also didn't provide any intellisense when working with the model. This is because the Visual Studio editor relies on classes in the System.Web.Razor
assembly (and a few others) to parse Razor files and provide Razor intellisense. And we didn't have these assemblies referenced in our project.
This is easy to fix:
PM> Install-Package Microsoft.AspNet.Mvc
This will reference all the necessary assemblies.
If you're curious about how the Visual Studio editor works behind the scenes with Razor templates, Andrew Nurse, the main developer of the ASP.NET Razor View Engine, has left a few hints in this blog post.
Getting rid of the squiggly blue lines
The VS editor also threw a rather cryptic error:
ASP.NET runtime error: There is no build provider registered for the extension '.cshtml'. You can register one in the <compilation><buildProviders> section in machine.config or web.config. Make sure is has (sic) a BuildProviderAppliesToAttribute attribute which includes the value 'Web' or 'All'.
This is easily fixed by adding a Web.config file at the root of your project containing:
Yes, a Web.config file, even if you're within a console, desktop or Windows Service project. The file will only be used at design-time by the Visual Studio editor and will be ignored at runtime.
Getting the VS editor to use the RazorEngine base class instead of the one used by ASP.NET MVC
By default, the Visual Studio editor assumes that a Razor file will be used to generate an ASP.NET MVC view. So its intellisense assumes that all the C# code in the file will be part of a class deriving from the System.Web.Mvc.WebViewPage
class.
RazorEngine however provides its own base class RazorEngine.Templating.TemplateBase
instead. Although TemplateBase
implements many of the same properties and methods that WebViewPage
has, there are some differences. For example, TemplateBase
has an Include()
method (used to render partials) that WebViewPage
doesn't have. If you were to use the Include()
method in your email template, the VS editor would highlight it as a syntax error even though it is a perfectly legal call if the template is meant to be parsed and compiled by RazorEngine.
You can ask the VS editor to use a different base class. Add a Web.config file at the root of your project if you haven't already done so (and yes, again, a Web.config file even in a console, desktop or Windows Service project). Add this to it:
You might have to close and re-open your .cshtml file after having done this for the VS editor to take the config into consideration.
Configuring namespaces
In the previous post, we mentioned that it was mandatory to always use fully-qualified type names for any class used in a Razor template (or explicitely import namespace with the @using
directive). Failure to do so would result in a TemplateCompilationException
at runtime.
This can become tiring, especially if there are namespaces that you use in every single template (e.g. the namespace containing your model classes).
But you can easily configure your TemplateService
instance to include additional namespaces into the generated template classes by default:
templateService.AddNamespace("ConsoleApplication.Models");
Annoyingly, since the Visual Studio editor won't know about these default namespaces, it will still complain about unknown types if you don't excplicitely import all the namespaces in your template files.
You can work around this by adding the extra namespaces in the <system.web.webPages.razor><pages><namespaces>
section of the Web.config file (not App.config) as you would do with the ASP.NET MVC Razor View Engine:
Note: RazorEngine also has a App.config-based configuration model. This would in theory allow you to define the default namespaces in your App.config instead of hardcoding them in code. In practice, RazorEngine's XML confguration code seems to be quite broken making it more trouble than it's worth to go down that route.
A better way to configure default namespaces
Having to configure the default namespaces twice, once in code and once in the Web.config file as described above, is hardly ideal. Wouldn't it be nice if we could define the default namespaces just once in the ASP.NET MVC configuration section in Web.config as shown above and simply use this list to configure our TemplateService
instance?
Well, it's possible and it's quite easy too.
First, we need to ensure that our Web.config file gets deployed alongside the application. If you're within a console, desktop or Windows Service project, the Web.config file is ignored by MSBuild and not deployed by default. All we need to do to fix this is set the Copy to Output Directory
property of our Web.config file to Copy if newer
:
We can now just parse this configuration file at runtime, extract the list of namespaces and add them to our TemplateService
instance:
var webConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Web.config");
var fileMap = new ExeConfigurationFileMap() { ExeConfigFilename = webConfigPath };
var configuration = ConfigurationManager.OpenMappedExeConfiguration(fileMap, ConfigurationUserLevel.None);
var razorConfig = configuration.GetSection("system.web.webPages.razor/pages") as RazorPagesSection;
foreach (NamespaceInfo namespaceInfo in razorConfig.Namespaces)
{
templateService.AddNamespace(namespaceInfo.Namespace);
}
And we're done. The default namespaces are now defined in a single location.
In the next post, we'll take a more detailled look at how the Razor parser and RazorEngine work behind the scene.