用Python做一个模板引擎玩具

本文翻译自 Alex Michael 的博客,原文链接在此

文中 斜体部分 表示我自身对原文的理解表达的意思没有完全理解或者自认翻译有问题,望各位英文达人不吝指正,我也会根据大家的提议及时对译文内容进行修正。


如果你曾经好奇模板引擎是怎样工作的,那么现在和我们一起来构建一个简单的模板引擎,探索它的工作流程吧。

如果你想更加深入的了解代码细节,请访问本项目的 Github页面

语言设计

我们的模板引擎语言非常简单,只有两种标签:变量(variables)代码块(blocks)


<!-- 变量由 `{{` 和 `}}` 包裹 -->
<div>{{my_var}}</div>

<!-- 代码块由 `{%` 和 `%}` 包裹 -->
{% each items %}
    <div>{{it}}</div>
{% end %}

几乎所有的代码块都如上例所示,是关闭的,而用于关闭的标签就是 {% end %}

我们的模板引擎需要支持基本的 循环(loops)条件(conditionals)。我们还将在代码块中,提供 调用(callables) 的功能——从我的角度来看,我发现它能够方便地在我的模板里调用任意Python函数使用。

循环——Loops

循环允许对集合或可迭代对象进行迭代


{% each people %}
    <div>{{it.name}}</div>
{% end %}

{% each [1, 2, 3] %}
    <div>{{it}}</div>
{% end %}

{% each records %}
    <div>{{..name}}</div>
{% end %}

在上例中,people 是集合,而 it 则是迭代中的当前项。我们使用 .. 符号来获取变量名的父级上下文,而变量名所带的点号(Dotted)路径将解决字典项中嵌套属性问题。

条件——Conditionals

条件则不需要过多阐述,我们的模板语言支持 if..else.. 结构,以及 ==, <=, >=, !=, is, >, < 几个比较运算符。


{% if num > 5 %}
    <div>more than 5</div>
{% else %}
    <div>less than or equal to 5</div>
{% end %}

调用——Callables

调用可以通过传递模板的上下文,调用位置或模板中的关键字参数完成。进行调用的代码块不需要关闭。


<!-- 支持位置参数... -->
<div class='date'>{% call prettify date_created %}</div>
<!-- ...和关键字参数 -->
<div>{% call log 'here' verbosity='debug' %}</div>

原理

在深入细节去了解我们的模板引擎将怎样编译和渲染模板之前,我们必须说一说,我们如何将一个模板编译后存放在内存中。

编译器使用 抽象语法树(AST) 来展示一个计算机程序的结构。 AST 是对代码进行词法分析后的产物。相比源代码,AST 拥有诸多优势,比如它剔除了分隔符等不必要的文本元素。不止如此,语法树中的节点可以由属性来增强而不必修改实际的源代码。

我们将解析并分析模板,并为其创建一颗这样的树来表示编译后的模板。我们将遍历这棵树,将每个节点对应到正确的上下文,并输出HTML进行渲染。

将模板标记化

在解析模板中,我们首先要将模板内容拆分成片段。每一个片段可以是任意的内容,既可能是HTML,也可能是模板标签。我们用正则表达式split()函数来拆分模板内容。


VAR_TOKEN_START = '{{'
VAR_TOKEN_END = '}}'
BLOCK_TOKEN_START = '{%'
BLOCK_TOKEN_END = '%}'
TOK_REGEX = re.compile(r"(%s.*?%s|%s.*?%s)" % (
    VAR_TOKEN_START,
    VAR_TOKEN_END,
    BLOCK_TOKEN_START,
    BLOCK_TOKEN_END
))

我们来分析一下 TOK_REGEX,可以看到,他有一个可选的变量标记和码块标记,这样做的原因是,我们希望把变量和代码块分割开。我们用捕获括号把含有模式选项的匹配文本包裹起来用于生成 TOKEN,其中模式选项里的 ? 是用于重复进行 非贪婪模式(non-greedy)。我们希望我们的正则表达式是惰性(lazy)的,在匹配到符合条件的第一项时就停止,这样一来,举个例子,我们就可以从代码块中提取变量。这里有一个很好的例子解释了如何控制正则表达式的惰性方式。

接下来的这个例子展示了我们的正则表达式是如何工作的:


>>> TOK_REGEX.split('{% each vars %}<i>{{it}}</i>{% endeach %}')
['{% each vars %}', '<i>', '{{it}}', '</i>', '{% endeach %}']

我们将用 片段对象(Fragment object) 封装每一个 片段(Fragment)。这个对象会确定片段的类型并准备片段提供给编译函数使用。片段有可能是以下四种中的一种:


VAR_FRAGMENT = 0
OPEN_BLOCK_FRAGMENT = 1
CLOSE_BLOCK_FRAGMENT = 2
TEXT_FRAGMENT = 3

构造AST

当我们将模板文本标签化之后,就该遍历每个片段来建立语法树了。我们将用 节点类(Node class) 来作为树节点的基类,并为每个可能的节点类型创建具体的子类。一个子类应该提供 process_fragment()render() 方法的实现。process_fragment() 方法用于进一步解析片段的内容并将必要的属性存储在 节点类 中。render() 方法负责根据提供的上下文,将节点转换为对应的HTML。

一个子类可以根据情况提供 enter_scope()exit_scope() 钩子(hook) 的实现,由编译器在编译时调用以进一步提供初始化和清理功能。enter_scope() 在节点创建新作用域时被调用(之后有详述),exit_scope()在节点作用域从作用域栈中被pop出来时被调用。

如下是我们的节点基类:


class _Node(object):
    def __init__(self, fragment=None):
        self.children = []
        self.creates_scope = False
        self.process_fragment(fragment)

    def process_fragment(self, fragment):
        pass

    def enter_scope(self):
        pass

    def render(self, context):
        pass

    def exit_scope(self):
        pass

    def render_children(self, context, children=None):
        if children is None:
            children = self.children
        def render_child(child):
            child_html = child.render(context)
            return '' if not child_html else str(child_html)
        return ''.join(map(render_child, children))

我们用变量节点作为例子,来展示一个具体子类:


class _Variable(_Node):
    def process_fragment(self, fragment):
        self.name = fragment

    def render(self, context):
        return resolve_in_context(self.name, context)

我们将根据片段的类型和内容确定节点的类型(用于正确的实例化节点类)。文本和变量片段直接转换为文本节点和变量节点,代码块片段需要稍多一点步骤——他们的类型由代码块命令中的第一个词确定。比如下面这个片段:


{% each items %}

就是一个 each 类型的代码块节点。

一个节点也可以创建一个作用域。在编译过程中,我们持续的追踪当前作用域,并将新节点添加到这个作用域下作为子节点。一旦我们遇到一个正确的关闭标签,我们就关闭该作用域,将其从作用域栈pop出来,然后返回到父级作用域并将父级作用域作为新的当前作用域。


def compile(self):
    root = _Root()
    scope_stack = [root]
    for fragment in self.each_fragment():
        if not scope_stack:
            raise TemplateError('nesting issues')
        parent_scope = scope_stack[-1]
        if fragment.type == CLOSE_BLOCK_FRAGMENT:
            parent_scope.exit_scope()
            scope_stack.pop()
            continue
        new_node = self.create_node(fragment)
        if new_node:
            parent_scope.children.append(new_node)
            if new_node.creates_scope:
                scope_stack.append(new_node)
                new_node.enter_scope()
    return root

渲染——Rendering

整个流水线的最后一步,是将AST渲染成HTML。我们遍历AST中所有结点,并调用 render()方法 作为参数传递给模版的上下文。在渲染过程中,我们需要推断我们是在处理字面常量还是根据上下文确定的变量的名字。为了解决该问题,我们使用 ast.literal_eval() 来安全地执行包含 Python代码 的字符串:


def eval_expression(expr):
    try:
        return 'literal', ast.literal_eval(expr)
    except ValueError, SyntaxError:
        return 'name', expr

如果我们是在处理上下文变量的名字,那么没我们需要通过搜索的方式,来确定他在上下文中的值。我们需要注意点号(dotted)后的名称和关联到父级上下文的名称。下例是我们解决这个问题的函数:


def resolve(name, context):
    if name.startswith('..'):
        context = context.get('..', {})
        name = name[2:]
    try:
        for tok in name.split('.'):
            context = context[tok]
        return context
    except KeyError:
        raise TemplateContextError(name)

结语——Conclusion

我希望这篇文章能让你大概明白模版引擎内部工作机制。这虽然离一个成熟产品的质量要求还很远,但它至少可以作为一个基础来创造更好的产品。

你可以在 Github 找到全部的代码实现,你也可以在 Hacker News 上针对本文发表更进一步的意见和建议。

向对本文原稿进行校审的 Nassos Hadjipapas, Alex Loizou, Panagiotis Papageorgiou Gearoid O’Rourke 诸位表示最诚挚的感谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值