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("{{")) |
而如果是 `