[译] 在 ASP.NET Core 中创建自定义模板引擎

原文来自 Creating a New View Engine in ASP.NET Core

原作者保留一切权利

疯狂的想法

几个月前,Taylor Mullen 在”The Monsters Weekly” 中谈到了关于 ASP.NET Core 里的 Razor 引擎。在采访中的某个时刻有人指出,在 MVC 的设计中,我们可以轻易地向框架内插入一个新的模板引擎。还有人指出,实现一个模板引擎实际上是一件非常复杂的事情。这令我们去思考,如果我们有一个现有的模板引擎的话应该怎么办?向 MVC 框架中插入一个新的模板引擎到底有多简单?

寻找一个代替品

我们想选择和 Razor 不太一样的东西,就像是 Simon 推荐的在 Express 框架中非常流行的 Pug。而在语法方面,Pug 和 Razor 之间略有不同,Pug 使用空格来表示嵌套的元素并略去了尖括号,就像下面这样:

1
2
div
a(href='google.com') Google

这一段将会生成这种 HTML:

1
2
3
<div>
<a href="google.com">Google</a>
</div>

在 ASP.NET Core 中使用 Pug

我们所面临的第一个问题是如何在 ASP.NET Core 程序中编译 Pug 模板,而 Pug 是一个基于 JavaScript 的模板引擎,但是我们只有一天的时间,所以把 Pug 移植到 C# 是不太可能的。

我们的第一个想法是使用 Edgejs 来调用 Pug 的 Compile 方法,一些快速原型向我们展示出这是可以做到的,但是 Edgejs 并不支持.NET Core,这将引导我们去使用由 ASP.NET Core 团队编写的包 “JavaScriptServices”,特别是那个可以让我们在 ASP.NET Core 程序中轻易调用 JavaScript 模块的 “Node Services” 包。

令我们惊喜的是,这个包不仅仅能够工作,而且非常易用!先创建一个叫做 pugcompile.js 的文件。

1
2
3
4
5
6
var pug = require('pug');

module.exports = function (callback, viewPath, model) {
var pugCompiledFunction = pug.compileFile(viewPath);
callback(null, pugCompiledFunction(model));
};

多亏了 Node Services,在 C# 中调用 JavaScript 是如此简单。假设 model 是我们想绑定到模板的 ViewModel,mytemplate.pug 是包含 Pug 模板的文件。

1
var html = await _nodeServices.InvokeAsync<string>("pugcompile", "mytemplate.pug", model);

现在我们已经证明这么做是可行的,是时候创建一个模板引擎并将其与 MVC 框架整合的时候的了。

创建 Pugzor 模板引擎

我们出于好玩决定把我们的模板引擎命名为 Pug 和 Razor 的组合:Pugzor。当然了这并不是很有意义,因为它和 Razor 并没有关系。

始终要记得,我们的目标是在一天之内实现一个模板引擎,我们希望敏捷开发。在花了点时间看了一下 MVC 的源代码之后,我们确定下来需要实现 IViewEngine 接口和自定义的 IView

IViewEngine 负责根据 ActionContextViewName 来确定 View 的位置。当 Controller 返回一个 View 时,它实际上是 IViewEngine 中一个用于根据约定来返回 ViewFindView 方法。FindView 方法返回一个 ViewEngineResultViewEngineResult 是一个拥有两个属性的简单的类,其中一个是 bool Success 属性,用于表明是否找到了一个 View,另一个是包含该 View(如果找到)的 IView View 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// 定义模板引擎契约。
/// </summary>
public interface IViewEngine
{
/// <summary>
/// 使用<paramref name="context"/>中视图的位置信息来查找指定<paramref name="viewName"/>的视图。
/// </summary>
/// <param name="context">The <see cref="ActionContext"/>.</param>
/// <param name="viewName">视图的名字。</param>
/// <param name="isMainPage">确定找到的页面是否是操作的主页面。</param>
/// <returns>找到的视图的<see cref="ViewEngineResult"/></returns>
ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage);

/// <summary>
/// Gets the view with the given <paramref name="viewPath"/>, relative to <paramref name="executingFilePath"/>
/// unless <paramref name="viewPath"/> is already absolute.
/// </summary>
/// <param name="executingFilePath">The absolute path to the currently-executing view, if any.</param>
/// <param name="viewPath">The path to the view.</param>
/// <param name="isMainPage">Determines if the page being found is the main page for an action.</param>
/// <returns>The <see cref="ViewEngineResult"/> of locating the view.</returns>
ViewEngineResult GetView(string executingFilePath, string viewPath, bool isMainPage);
}

我们决定使用与 Razor 相同的 View 位置约定,也就是说,View 位于 Views/{ControllerName}/{ActionName}.pug. 中。
以下是 PugzorViewEngine 的 FindView 方法的简化版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public ViewEngineResult FindView(
ActionContext actionContext,
string viewName,
bool isMainPage)
{
var controllerName = GetNormalizedRouteValue(actionContext, ControllerKey);

var checkedLocations = new List<string>();
foreach (var location in _options.ViewLocationFormats)
{
var view = string.Format(location, viewName, controllerName);
if(File.Exists(view))
return ViewEngineResult.Found("Default", new PugzorView(view, _nodeServices));
checkedLocations.Add(view);
}
return ViewEngineResult.NotFound(viewName, checkedLocations);
}

你可以在 Github 上找到完整的实现。

接下来,创建一个 PugzorView 类来实现 IView 接口,PugzorView 接受一个 pub 模板的路径和一个 INodeServices 实例。当 MVC 框架需要渲染视图时,它会调用 IViewRenderAsync 方法,在这个方法中,调用 pugcompile 并将生成的 HTML 写入视图上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class PugzorView : IView
{
private string _path;
private INodeServices _nodeServices;

public PugzorView(string path, INodeServices nodeServices)
{
_path = path;
_nodeServices = nodeServices;
}

public string Path
{
get
{
return _path;
}
}

public async Task RenderAsync(ViewContext context)
{
var result = await _nodeServices.InvokeAsync<string>("./pugcompile", Path, context.ViewData.Model);
context.Writer.Write(result);
}
}

唯一剩下的就是配置 MVC 来使用我们的新模板引擎。一开始,我们认为在将 MVC 添加到服务集合中时可以很方便地使用 AddViewOptions 扩展方法来添加一个新的模板引擎。

1
2
3
4
5
6
// Startup.cs
services.AddMvc()
.AddViewOptions(options =>
{
options.ViewEngines.Add(new PugzorViewEngine(nodeServices));
});

但是这也是令我们困惑的地方,因为我们无法在 Startup.ConfigureServices 方法中为 ViewEngines 集合添加 PugzorViewEngine 的具体实例,因为模板引擎的构造函数需要依赖注入。PugzorViewEngine 依赖了 INodeServices 并且我们希望这个参数能够由 ASP.NET 的 DI 框架注入。不过幸运的是,对 Razor 无所不知的大师 Taylor Mullen 会向我们展示注册模板引擎的正确方法。

将模板引擎添加到 MVC 中的推荐方法是创建一个实现了 IConfigureOptions <MvcViewOptions> 接口的 “安装” 类,这个类通过构造器注入得到了我们的 IPugzorViewEngine 实例对象,而在 ConfigureServices 方法中,这个实例对象会被添加到 MvcViewOptions 的模板引擎列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class PugzorMvcViewOptionsSetup : IConfigureOptions<MvcViewOptions>
{
private readonly IPugzorViewEngine _pugzorViewEngine;

/// <summary>
/// 初始化一个<see cref="PugzorMvcViewOptionsSetup"/>的新实例。
/// </summary>
/// <param name="pugzorViewEngine">The <see cref="IPugzorViewEngine"/>.</param>
public PugzorMvcViewOptionsSetup(IPugzorViewEngine pugzorViewEngine)
{
if (pugzorViewEngine == null)
{
throw new ArgumentNullException(nameof(pugzorViewEngine));
}

_pugzorViewEngine = pugzorViewEngine;
}

/// <summary>
/// 配置<paramref name="options"/>来调用<see cref="PugzorViewEngine"/>
/// </summary>
/// <param name="options">The <see cref="MvcViewOptions"/> to configure.</param>
public void Configure(MvcViewOptions options)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}

options.ViewEngines.Add(_pugzorViewEngine);
}
}

现在我们需要做的是在 Startup.ConfigureServices 方法中注册 “安装” 类和模板引擎。

1
2
services.AddTransient<IConfigureOptions<MvcViewOptions>, PugzorMvcViewOptionsSetup>();
services.AddSingleton<IPugzorViewEngine, PugzorViewEngine>();

于是就像魔法一样,我们有了一个可以正常工作的模板引擎。这里有一个演示:
Controllers/HomeController.cs

1
2
3
4
5
6
public IActionResult Index()
{
ViewData.Add("Title", "Welcome to Pugzor!");
ModelState.AddModelError("model", "An error has occurred");
return View(new { People = A.ListOf<Person>() });
}

Views/Home/Index.pug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
block body
h2 Hello
p #{ViewData.title}
table(class='table')
thead
tr
th Name
th Title
th Age
tbody
each val in people
tr
td= val.firstName
td= val.title
td= val.age

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<h2>Hello</h2>
<p>Welcome to Pugzor! </p>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr><td>Laura</td><td>Mrs.</td><td>38</td></tr>
<tr><td>Gabriel</td><td>Mr. </td><td>62</td></tr>
<tr><td>Judi</td><td>Princess</td><td>44</td></tr>
<tr><td>Isaiah</td><td>Air Marshall</td><td>39</td></tr>
<tr><td>Amber</td><td>Miss.</td><td>69</td></tr>
<tr><td>Jeremy</td><td>Master</td><td>92</td></tr>
<tr><td>Makayla</td><td>Dr.</td><td>15</td></tr>
<tr><td>Sean</td><td>Mr. </td><td>5</td></tr>
<tr><td>Lillian</td><td>Mr. </td><td>3</td></tr>
<tr><td>Brandon</td><td>Doctor</td><td>88</td></tr>
<tr><td>Joel</td><td>Miss.</td><td>12</td></tr>
<tr><td>Madeline</td><td>General</td><td>67</td></tr>
<tr><td>Allison</td><td>Mr. </td><td>21</td></tr>
<tr><td>Brooke</td><td>Dr.</td><td>27</td></tr>
<tr><td>Jonathan</td><td>Air Marshall</td><td>63</td></tr>
<tr><td>Jack</td><td>Mrs.</td><td>7</td></tr>
<tr><td>Tristan</td><td>Doctor</td><td>46</td></tr>
<tr><td>Kandra</td><td>Doctor</td><td>47</td></tr>
<tr><td>Timothy</td><td>Ms.</td><td>83</td></tr>
<tr><td>Milissa</td><td>Dr.</td><td>68</td></tr>
<tr><td>Lekisha</td><td>Mrs.</td><td>40</td></tr>
<tr><td>Connor</td><td>Dr.</td><td>73</td></tr>
<tr><td>Danielle</td><td>Princess</td><td>27</td></tr>
<tr><td>Michelle</td><td>Miss.</td><td>22</td></tr>
<tr><td>Chloe</td><td>Princess</td><td>85</td></tr>
</tbody>
</table>

现在 Pug 的所有功能都可以正常工作,包括模板继承和内联 JavaScript。来我们的演示站点看看例子。

打包

所以我们完成了在一天内改变 MVC 模板引擎这个目标,不过还剩了一点时间,所以我们可以更进一步来创建一个 NuGet 包。这里有一些挑战,具体涉及在 NuGet 包中包含所需的 node 模块。Simon 计划为此单独写一篇文章。

你可以自己试一试,添加对 pugzor.coreNuGet 包的引用,然后在 Startup.ConfigureServices 方法中的.AddMvc() 后调用.AddPugzor()

1
2
3
4
5
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc().AddPugzor();
}

Razor 仍会作为默认的模板引擎,但是如果找不到 Razor 视图文件,MVC 框架将会尝试使用 PugzorViewEngine,如果能找到相匹配的 pug 模板,这个模板就会被渲染。
Pugzor
我们在这个项目上做了初次尝试,虽然这是一次比较傻的练习,但是以一些有用的东西作收尾。我们真的很惊讶,为 MVC 创建一个新的模板引擎是这么容易。不过我们不希望 Pugzor 会受到广泛欢迎,但是既然它能够工作,我们希望把它放在那里并看看人们的想法。

我们有一些问题和一些关于扩展 PugzorViewEngine 的想法,让我们知道你的想法或是直接来参与贡献代码。我们接受 Pull Requests :-)