4 种风格的模板引擎

Grammar and closure

4 种风格的模板引擎. 带基准测试!

这一次在博客上,我将告诉你如何编写自己的模板引擎 - 根据需要为你量身定制语法和行为。 我们将以四种不同的方式来分析每种方法的优缺点,以及代码速度和复杂性。 我们今天的示例任务是为用户撰写密码提醒文本,然后可以通过电子邮件发送。

use v6;

my $template = q{
    Hi [VARIABLE person]!

    You can change your password by visiting [VARIABLE link] .
    
    Best regards.
};

my %fields = (
    'person' => 'John',
    'link' => 'http://example.com'
);

所以由我们决定我们的模板语法应该是什么样子的,对于初学者,我们会有一些小的变量(虽然这不是很精确的名称,因为模板中的变量几乎总是不变的)。 我们还有用于填充模板字段的数据。 让我们开始吧!

Substitutions

sub substitutions ( $template is copy, %fields ) {
    for %fields.kv -> $key, $value {
        $template ~~ s:g/'[VARIABLE ' $key ']'/$value/;
    }
    return $template;
}

say substitutions($template, %fields);

输出:

Hi John!

You can change your password by visiting http://example.com .

Best regards.

现在是时候进行基准测试了,以获得不同方法的基准:

use Bench;

my $template_short = $template;
my %fields_short = %fields;

my $template_long = join(
    ' lorem ipsum ', map( { '[VARIABLE ' ~ $_ ~ ']' }, 'a' .. 'z')
) x 100;
my %fields_long = ( 'a' .. 'z' ) Z=> ( 'lorem ipsum' xx * );

my $b = Bench.new;
$b.timethese(
    1000,
    {
        'substitutions_short' => sub {
            substitutions( $template_short, %fields_short )
        },
        'substitutions_long' => sub {
            substitutions( $template_long, %fields_long )
        },
    }
);

这篇文章中的基准测试会在两种情况下测试每种方法。 一种情况是"短"模板。一种是"长"模板。 长模板大小为62KB, 包含 2599 个文本片段和 2600 个变量,由26个字段填充。 所以这里的结果为:

Timing 1000 iterations of substitutions_long, substitutions_short...
substitutions_long: 221.1147 wallclock secs @ 4.5225/s (n=1000)
substitutions_short: 0.1962 wallclock secs @ 5097.3042/s (n=1000)

哇! 长模板拖后腿了。 原因是因为这段代码有三个严重的缺陷 - 原始模板在变量求值期间被销毁,因此每次我们想要重用它时必须复制它,因此模板文本被解析多次,并且每次填充每个变量之后输出被重写。 但我们可以做得更好…

Substitution

sub substitution ( $template is copy, %fields ) {
    $template ~~ s:g/'[VARIABLE ' (\w+) ']'/{ %fields{$0} }/;
    return $template;
}

这次我们只有单个替换。变量名被捕获,我们可以用它来动态获取字段值。 基准测试:

Timing 1000 iterations of substitution_long, substitution_short...
substitution_long: 71.6882 wallclock secs @ 13.9493/s (n=1000)
substitution_short: 0.1359 wallclock secs @ 7356.3411/s (n=1000)

速度没有提升多少。 长模板的解析时间下降了不少,因为文本不会被多次解析。 然而,来自先前方法的剩余缺陷仍然适用,并且正则表达式引擎仍然必须对被替换的每个模板文本执行大量的内存重分配。

此外,在未来它不会允许我们的模板引擎获得新的功能,如条件或循环,因为它很难在单个正则表达式中解析嵌套标签。 是时候另辟蹊径了..

Grammars 和 Actions

如果你不熟悉 Raku 的 Grammar 和抽象语法树概念,你应该首先学习官方文档

grammar Grammar {
  regex TOP      {^ [ | ]* $ }
  regex text     { <-[[]]>+ }
  regex variable { '[VARIABLE ' $=(\w+) ']' }
}

class Actions {
  has %.fields is required;
  
  method TOP ($/) {
    make [~]( map {.made}, $/{'chunk'} );
  }
  
  method text($/) { 
    make ~$/; 
  }
  
  method variable ($/) { 
    make %.fields{$/{'name'}} 
  }
}

sub grammar_actions_direct ( $template, %fields ) {
    my $actions = Actions.new( fields => %fields );
    return Grammar.parse($template, :$actions).made;
}

最重要的是将我们的模板语法定义为 grammar。Grammar 只是一组可以互相调用的命名正则表达式。在"TOP"(解析开始的地方)里,我们看到我们的模板是由块组成的。每个块可以是文本或变量。 text 这个正则表达式匹配一切,直到它命中变量 开头('[‘字符,让我们假设它在 text 中是禁止的以使事情变得更简单)。variable 这个正则表达式看起来应该和之前的方法类似,但是现在我们以命名方式而不是位置方式捕获变量名。

每当相应名字的正则表达式匹配时, Action 类中的同名方法就会被调用。当被调用时,方法从这个正则表达式获得匹配对象($ /),并可以从该匹配对象中合成(“make”)某些东西。上层方法在被调用时可以看到这个被合成(“made”)的某个东西。例如我们的 “TOP” regexp调 用 “text” regexp 匹配模板的 “Hi” 部分和调用 “text” 方法。这个 “text” 方法只是 “make” 这个匹配的字符串供以后使用。然后 “TOP” regexp 调用与模板的 “[VARIABLE name]” 部分匹配的 “variable” regexp。然后调用 “variable” 方法,并在匹配对象中检查变量名称,并从 %fields 散列值中"make"这个变量的值供以后使用。这将持续到模板字符串结束。然后匹配 “TOP” 正则表达式,并调用 “TOP” 方法。这个 “TOP” 方法可以访问匹配对象中的文本或变量"块"的数组,并查看先前为那些块 “make” 了什么。所以它所要做的就是把(“make”)这些值连接在一起。最后,我们从 “parse” 方法中获得这个 “made” 模板。让我们来看看基准测试:

Timing 1000 iterations of grammar_actions_direct_long, grammar_actions_direct_short...
grammar_actions_direct_long: 149.5412 wallclock secs @ 6.6871/s (n=1000)
grammar_actions_direct_short: 0.2405 wallclock secs @ 4158.1981/s (n=1000)

我们摆脱了以前方法的两个缺陷。 填充字段时原始模板不会被损坏,这意味着更少的内存复制。 在每个字段的替换期间也没有重新分配内存,因为现在每个 action 方法只是让(“make”)字符串在稍后被连接。 我们可以轻松通过添加循环,条件和更多的功能来扩展我们的模板语法,只需将一些正则表达式引入 grammar 并在 action 中定义相应的行为。 不幸的是,我们看到一些性能回归,这是因为每次解析模板时,都会创建匹配对象,构建解析树,它必须跟踪所有那些 “make”/“made” 值,当它折叠到最终输出。 但这还没完..

Grammars 和 closure Actions

最后,我们达到"boss级",我们必须消灭最后和最大的缺陷 - 重新解析。 我的想法是使用之前方法中的 grammars 和 actions,但这一次,我们想生成可执行和可重用的代码,而不是得到直接输出,工作原理如下:

sub ( %fields ) {
    return join '',
        sub ( %fields ) { return "Hi "}.( %fields ),
        sub ( %fields ) { return %fields{'person'} }.( %fields ),
        ...
}

没错,我们将把我们的模板主体转换为子程序级联。 每次调用这个级联时,它将获取并把%fields传播到更深的子程序中。 并且每个子例程负责处理由 grammar 中的单个正则表达式匹配的模板片段。 我们可以重用之前方法中的 grammar,只修改 actions:

class Actions {
    
    method TOP ( $/ ) {
        my @chunks = $/{'chunk'};
        make sub ( %fields ) {
           return [~]( map { .made.( %fields ) }, @chunks );
        };
    }
    method text ( $/ ) {
        my $text = ~$/;
        make sub ( %fields ) {
            return $text;
        };
    }
    method variable ( $/ ) {
        my $name = $/{'name'};
        make sub ( %fields  ) {
            return %fields{$name}
        };
    }
    
}

sub grammar_actions_closures ( $template, %fields ) {
    state %cache{Str};
    my $closure = %cache{$template} //= Grammar.parse(
        $template, actions => Actions.new
    ).made;
    return $closure( %fields );
}

现在每个 action 方法使子程序获得 %fields 并在稍后执行最终输出而不是直接最终输出。 要生成这个子程序级联,模板必须只解析一次。 一旦我们得到它,我们可以用不同的 %fields 组来填充我们的模板变量。 注意如何使用 Object Hash %cache来确定对于给定的 $ template 我们是否已经有子程序树。 再罗嗦一句,我们来做下基准测试:

Timing 1000 iterations of grammar_actions_closures_long, grammar_actions_closures_short...
grammar_actions_closures_long: 22.0476 wallclock secs @ 45.3563/s (n=1000)
grammar_actions_closures_short: 0.0439 wallclock secs @ 22778.8885/s (n=1000)

很好的结果! 我们有可扩展的模板引擎,对于短模板它快了4倍,对于长模板它快了10倍。 是的,登峰造极了…

Grammars and closure Actions in parallel

最后一种方法开辟了一种新的优化可能性。 如果我们有了生成模板的子程序那么为什么不并行运行它们呢? 因此,让我们修改我们的 action “TOP” 方法来同时处理文本和变量块:

method TOP ( $/ ) {
    my @chunks = $/{'chunk'};
    make sub ( %fields ) {
       return [~]( @chunks.hyper.map( {.made.( %fields ) } ).list );
    };
}

如果你的模板引擎必须做一些冗长的操作来生成最终的输出块,例如执行重型数据库查询或调用一些API,这样的优化会诱人。在运行时要求数据填充模板是完全正确的,因为在功能丰富的模板引擎中,您可能无法预测并生成完整的数据集,就像我们对 %fields 所做的那样。明智地使用此优化 - 对于快速子程序,您将看到性能下降,因为只是在单核上串行执行它们发送和检索块到/从线程的成本将更高。

我应该使用哪种方法来实现我自己的模板引擎?

这取决于你可以重用模板的程度。例如,如果您每天发送一个密码提醒 - 就使用简单的 substitution , 如果你需要更复杂的功能就使用带有直接 actions 操作的 grammar。但是如果你在PSGI进程中使用模板来为不同的用户每秒显示数百页,那么 grammar 和 closure actions 会更适合。

你可以在这里下载所有方法与基准测试的单个文件。

未完待续?

如果你喜欢这个简单的模板引擎介绍,并希望看到更复杂的功能,如条件循环的实现, 请在 blogs.perl.org 的这篇文章下留言或发送私人消息到 irc.freenode.net#raku 通道(昵称:bbkr)。

Raku 

comments powered by Disqus