A Template Engine - 简易模板引擎实现
本文内容主要来源于 A Template Engine。
支持的语法
首先来看一下这个模板引擎所支持的语法。
变量
使用 {{ variable }} 来表示变量,例如:
1 | <p>Welcome, {{user_name}}!</p> |
如果 user_name 是 Tom,则最后渲染的结果为:
1 | <p>Welcome, Tom!</p> |
对象属性和方法
除了字面量外,模板引擎的变量还支持复杂对象,可以通过点操作符来访问对象的属性或方法,例如:
1 | <p>The price is: {{product.price}}, with a {{product.discount}}% discount.</p> |
注意如果访问的是对象的方法,则不需要在方法名后添加 (),模板引擎会自动解析并调用方法。
同时,还可以使用管道操作符来链式调用过滤器,从而改变所渲染的变量值,例如:
1 | <p>Short name: {{story.subject|slugify|lower}}</p> |
条件判断
使用 {% if condition %} body {% endif %} 来表示条件判断,例如:
1 | {% if user.is_logged_in %} |
循环
使用 {% for item in list %} body {% endfor %} 来表示循环,例如:
1 | <p>Products:</p> |
注释
使用 `` 来表示注释,例如:
1 | {# This is the best template ever! #} |
实现
一般来说,一个模板引擎主要做两件事:模板解析和渲染。这里要实现的模板引擎的渲染包括:
- 管理动态数据
- 执行逻辑语句,例如
if,for - 实现点操作符访问和过滤器执行
类似于编程语言的实现,模板引擎的解析也可以分为解释型和编译型两种。对于解释型来说,模板解析阶段需要生成某个特定的数据结构,然后在渲染阶段遍历该数据结构并执行所遇到的每一条指令;而对于编译型来说,模板解析阶段直接生成可执行代码,而渲染阶段则大大简化,直接执行代码即可。
本文描述的模板引擎采用编译型的方式,原文的作者将模板编译为了 Python 代码,这里为了进一步加深理解,实现了 .NET Core 版本的简单编译。
编译为 C# 代码
在介绍模板引擎实现之前,先来看一下模板引擎编译出的 C# 代码示例,对于如下的模板:
1 | <p>Welcome, {{userName}}!</p> |
模板引擎会生成类似于下面的代码:
1 | public string Render(Dictionary<string, object> Context Context, Func<object, string[], object> ResolveDots) |
其中 Context 表示全局上下文,用于获取渲染需要的动态数据,例如例子中的 userName,Render 方法会先从 Context 中提取出模板中所有需要的变量;ResolveDots 是一个函数指针,用于执行点操作符调用;而变量的值都会通过 Convert.ToString 转为字符串。
模板引擎的最终产物是一个字符串,所以在 Render 中先使用一个 List 保存每一行的渲染结果,最后再将 List 转换为字符串。
.NET 编译器提供了 Microsoft.CodeAnalysis.CSharp.Scripting 包来将某段字符串当做 C# 代码执行,所以最终模板引擎生成的代码将通过如下方式执行:
1 | var code = "some code"; |
模板引擎编写
Template
Template 是整个模板引擎的核心类,它首先通过模板和全局上下文初始化一个实例,然后调用 Render 方法来渲染模板:
1 | var context = new Dictionary<string, object>() |
这里将 text 传入 Template 的构造函数后,会在构造函数中完成模板解析,后续的 Render 调用都不需要再执行模板解析。
CodeBuilder
在介绍 Template 的实现之前,需要先了解下 CodeBuilder,CodeBuilder 用于辅助生成 C# 代码,Template 通过 CodeBuilder 添加代码行,以及管理缩进(原文的作者使用 Python 作为编译的目标语言所以这里需要维护正确的缩进,C# 则不需要),并最终通过 CodeBuilder 得到可执行代码。
CodeBuilder 内部维护了一个类型为 List<object> 的变量 Codes 来表示代码行,这里的 List 容器类型不是字符串是因为 CodeBuilder 间可以嵌套,一个 CodeBuilder 可以作为一个完整的逻辑单元添加到另一个 CodeBuilder 中,并最终通过自定义的 ToString 方法来生成可执行代码:
1 | public class CodeBuilder |
CodeBuilder 的 AddLine 方法非常简单,即根据缩进层级补齐空格后添加一行代码(这里 C# 版本保留了 Python 版本缩进的功能):
1 | public void AddLine(string line) |
Indent 和 Dedent 用于管理 Python 代码的缩进层级:
1 | public void Indent() |
AddSection 用于创建一个新的 CodeBuilder 对象,并将其添加到当前 CodeBuilder 的代码行中,后续对子 CodeBuilder 的修改都会反应到父 CodeBuilder 中:
1 | public CodeBuilder AddSection() |
最后重写了 ToString() 方法来生成可执行代码:
1 | public override string ToString() |
Template 实现
编译
模板引擎的模板解析阶段发生在 Template 的构造函数中:
1 | public Template(string text, Dictionary<string, object> context) |
Python 版本的代码支持多个 context,会由构造函数统一合并为一个上下文对象,这里只简单实现仅支持一个 context;AllVariables 用于记录模板 text 中需要用到的变量名,例如 userName,然后在代码生成阶段就可以遍历 AllVariables 并通过 var someName = Context[someName]; 生成局部变量;不过由于模板中的变量可能还会有循环语句用到的临时变量,这些变量会记录到 LoopVariables 中,最终代码生成阶段用到的变量为 AllVariables - LoopVariables。
接着我们再来看 Initialize 方法:
1 | private void Initialize(string text) |
Initialize 首先会通过 CodeBuilder 分配一个 List 保存所有的代码行,然后新建一个子 CodeBuilder 来保存所有的局部变量,接着解析 text,在完成 text 的解析后就能知道模板中使用了哪些变量,从而根据 AllVariables - LoopVariables 生成局部变量,最后将所有的代码行转成字符串。
同时,原文作者在这里有一个优化,相比于在生成的代码中不断的调用 result.Add(xxx),从性能上考虑可以将多个操作合并为一个即 result.AddRange(new List<string> { xxx }),从而引出了辅助变量 buffered 和辅助方法 FlushOutput:
1 | var buffered = new List<string>(); |
在解析 text 时,并不会处理完一个 token 就执行一次 this.CodeBuilder.AddLine,而是将多个 token 的处理结果批量的追加到最终的可执行代码中。
接着,再回到 Initialize 方法中,由于模板中 if,for 可能存在嵌套,为了正确处理嵌套语句,这里引入一个栈 var operationStack = new Stack<string>(); 来处理嵌套关系。例如,假设模板中存在 {% if xxx %} {% if xxx %} {% endif %} {% endif %},每次遇到 if 时则执行入栈,遇到 endif 时则执行出栈,如果出栈时栈为空则说明 if 语句不完整,并抛出语法错误。
那么,如何解析 text 呢?这里使用正则表达式来将 text 分割为 token:
1 | private static Regex tokenPattern = new Regex("(?s)({{.*?}}|{%.*?%}|{#.*?#})", RegexOptions.Compiled); |
其中正则表达式中的 (?s) 使得 . 能够匹配换行符。
例如对于模板:
1 | <ol>{% for number in numbers %}<li>{{ number }}</li>{% endfor %}</ol> |
分割后的 tokens 为:
1 | [ |
然后我们就可以遍历 tokens 处理了,每种 token 对应一种策略,如果是注释,则忽略:
1 | if (token.StartsWith("{#")) |
如果是变量,则解析变量的表达式(表达式解析会在后面介绍)的值,然后再将其转为字符串:
1 | else if (token.StartsWith("{{")) |
而如果是 `