超越模板引擎

网络整理 - 07-27

总体来说,模板引擎是一个"好东西"

作为一个PHP/Perl的程序员,许多模板引擎(fastTemplate, Smarty, Perl的 HTML::Template)的用户,以及我自己的(bTemplate [1] 的作者),我讲这句话很多次了。

然而,在同事进行了长时间的讨论之后,我确信了大量的模板引擎(包括我自己写的)根本是错误的。 我想唯一的例外是Smarty [2],虽然我认为它太庞大了,并且考虑到这篇文章的其余部分相当的没有观点。然而,就你为什么选择Smarty(或者类似的解决方案)有几个理由,这些将在文章后面探究。

这篇文章讨论模板的理论。我们将看到为什么大部分"模板引擎"是过于肥大,并且最终我们将回过头来看一个轻量级的,小巧快速的另类选择。


下载和授权

模板类和所有在本文中使用的例子能够在这里下载: [3]。你可以根据发布 [4]在 OSI [5] 的 MIT Open Source License使用这些文件中的代码。


一些关于模板引擎的背景知识

让我们首先研究一下模板引擎的背景知识。模板引擎被设计出来用于把商业逻辑(例如从数据库中获取数据或者计算贸易耗费)从数据的表现分离开来。模板引擎解决了两个主要问题:

  • 如何实现这种分离
  • 如何从HTML中分离"复杂"的php代码
  • 这从理论上使得没有PHP经验的HTML设计者能够不看任何PHP代码的条件下修改站点的外观。

    然而,模板系统也引入了一些复杂性。首先,我们现在有一个从多个文件得来的"页面"。典型的,你可能有一个主PHP页负责业务逻辑,一个外面的"布局"模板把整个站点的整体布局进行渲染,一个内部的内容特定的模板,一个数据库抽象层,以及模板引擎本身(这些可能是也可能不是由多个文件组成)。也有可能,一些人仅仅简单地在每个PHP页面的首尾处包含"头部"和"尾部"文件。

    这产生的单个页面的文件数量是很可观的。然而,因为PHP解析器非常快,用到的文件数量可能不是那么重要除非你的站点流量很大。
    然而,要记住模板系统引入了另外一个处理的层次。模板文件不仅仅是必须被包含,他们还必须被解析(取决于模板系统,这个行为有很多种方式来完成 —— 使用正则表达式,字符串替换,编译,词法分析,等等)。这就是为什么对模板进行测速变得流行起来:因为模板引擎使用各种方法来解析数据,它们中的一些比另外一些要快(而且,一些模板引擎提供了比其他引擎更加丰富的功能)。


    模板引擎基础知识

    简单地说,模板引擎利用了用C写的脚本语言(PHP)。在这些嵌入的脚本语言中,你有另外一个伪脚本语言(无论你的模板引擎支持何种标签)。某些提供了简单的变量改写和循环。另外一些呢,则提供了条件和嵌套循环。而再其他的呢(至少有Smarty)提供了一个PHP的比较大的子集的接口,以及一个缓冲层。

    为什么我认为Smarty最接近于正确的方向?因为Smarty的目标是"把业务逻辑从表现中分离出来"而不是"PHP代码和HTML代码的分离"。这看上去区别不大,但是它正是要点所在。任何模板引擎的最终目标不应该是从HTML移除所有的逻辑。它应该是把表现逻辑从业务逻辑中分离出来。

    有很多你仅仅需要逻辑来正确显示你的数据的例子。例如,你的业务逻辑是从你的数据库中获取一个用户列表。你的表现逻辑可能是把用户列表用3列显示。可能修改用户列表函数使得它返回3个数组是很笨的办法。毕竟函数不应该关心数据接下来要怎么处理这样的事情。然而,在你的模板文件中缺少一些逻辑,那些正是你要做的事情。

    在这点上Smarty是正确的(使得你利用PHP的很多东西),但是仍然有许多问题。基本上,它仅仅提供了一个以新语法访问PHP的接口。以那开始,它看上去不那么聪明了。是不是事实上写 {foreach --args} 比 <? foreach --args ?> 更加简单?如果你认为这样简单一些,问问你自己是不是在包含一个巨大的模板库来到成这种分离时能够看到真正的意义要更加简单一些。诚然,Smarty提供了许多其他很好的特性,但是看上去这些益处能够在不用承担包含Smarty类库的情况下也能获得。


    别样的解决方案

    我主要要鼓吹的一个解决方案是一个使用PHP代码作为它的原生脚本语言的"模板引擎"。我知道这以前有人做过。而且当我第一次看到的时候,我想,"为什么要这样做?",然而我在考虑过我同事的论据之后,并且实现了一个直接使用PHP代码仍然实现了把业务逻辑和表现逻辑分离的最终目标的模板系统时(只用了大约25行代码,不包括注释),我意识到了好处所在。

    这个系统给像我们这样的开发者提供了对PHP核心函数的访问权利,我们能够使用他们来格式化输出——像日期格式化这样的任务应该在模板中处理。而且,因为模板是普通的PHP文件,像Zend Performance Suite [6] 和PHP Accelerator [7] 这样的字节码缓存程序,能够自动缓存模板(因而,它们不需要在每次被访问时都被重新解释执行)。只要你记得把你的模板文件命名为程序能够辨认出是PHP文件的名字(通常,你仅仅需要确保它们有一个.php的后缀),这确实是一个好处。

    当我认为这种方法比经典的模板引擎要高明得多时,肯定还有一些要商榷的问题。最明显的反面意见是,PHP代码太复杂了,而且设计者不应该强迫去学习PHP。事实上,PHP代码和像Smarty这样的高级模板引擎的语法差不多简单(如果不是更简单的话)。而且,设计者能够使用像<?=$var;?>这样的简写PHP。这要比{$var}复杂很多?当然,这要长一些,但是如果你习惯了,你能够获得了PHP的威力而且不用承受解析模板文件带来的负担。

    第二,而且可能更重要的,在基于PHP的模板中没有固有的安全。Smarty提供了选项在模板文件中彻底禁用PHP代码。它使得开发者能够约束模板能够访问的函数和变量。如果你没有不怀好意的设计者,这不会是什么问题。然而,如果你允许外部的用户上传或者修改模板,我在此展示的基于PHP的解决方案绝对没有任何安全可言!任何代码都能放入模板中并且得到运行。是的,甚至是一个print_r($GLOBALS)(这将改有恶意的用户访问脚本中任何变量的权利)。

    但是,我个人或者工作上写过的项目中,绝大多数不允许最终的用户修改或者上传模板。如果是这样,问题就不存在了。因此现在让我们来看看代码吧。


    例子

    这是一个简单的用户列表页面的例子。

    <?php  
    require_once('template.php');  

    /**  
    * This variable holds the file system path to all our template files.  
    */  
    $path = './templates/';  

    /**  
    * Create a template object for the outer template and set its variables.  
    */  
    $tpl = & new Template($path);  
    $tpl->set('title', 'User List');  

    /**  
    * Create a template object for the inner template and set its variables.  The  
    * fetch_user_list() function simply returns an array of users.  
    */  
    $body = & new Template($path);  
    $body->set('user_list', fetch_user_list());  

    /**  
    * Set the fetched template of the inner template to the 'body' variable in  
    * the outer template.  
    */  
    $tpl->set('body', $body->fetch('user_list.tpl.php'));  

    /**  
    * Echo the results.  
    */  
    echo $tpl->fetch('index.tpl.php');  
    ?>

    其中有两个值得注意的重要的概念。第一个就是内部和外部模板的概念。外部模板包含定义站点主要外观的HTML代码。而内部模板包含定义站点内容区域的HTML代码。当然,你能够在任意数目的层上有任意数目的模板。因为通常我们给每个区域使用不同的模板对象,所以没有名字空间的问题。例如,我能在内部和外部模板中都有变量叫"title",而不用害怕有什么冲突。

    这是一个用来显示用户列表的模板的简单例子。注意特殊的foreach和endforeach;语法在PHP手册中有说明 [8]。它完全是可选择的。

    而且,你可能奇怪我为什么要用.php的后缀来命名我的模板文件。呵呵,许多PHP字节码缓存解决方案(比如 phpAccelerator)如果要被认成PHP文件,需要文件有一个.php后缀。因为这些模板是PHP文件,为什么不去获得这些好处?

    <table>  
       <tr>  
           <th>Id</th>  
           <th>Name</th>  
           <th>Email</th>  
           <th>Banned</th>  
       </tr>  
    <? foreach($user_list as $user): ?>  
       <tr>  
           <td align="center"><?=$user['id'];?></td>  
           <td><?=$user['name'];?></td>  
           <td><a href="mailto:<?=$user['email'];?>"><?=$user['email'];?></a></td>  
           <td align="center"><?=($user['banned'] ? 'X' : ' ');?></td>  
       </tr>  
    <? endforeach; ?>  
    </table>

    这个layout.tpl.php是一个简单的例子(定义了整个页面看上去是什么样子的模板文件)

    <html>
       <head>
           <title><?=$title;?></title>
       </head>

       <body>

           <h2><?=$title;?></h2>

    <?=$body;?>

       </body>
    </html>

    而这是解析后的输出。

    <html>
     <head>
       <title>User List</title>
     </head>

     <body>

       <h2>User List</h2>

    <table>
     <tr>
       <th>Id</th>
       <th>Name</th>
       <th>Email</th>
       <th>Banned</th>
     </tr>
     <tr>
       <td align="center">1</td>
       <td>bob</td>
       <td><a href="mailto:bob@mozilla.org">bob@mozilla.org</a></td>
       <td align="center"> </td>
     </tr>
     <tr>
       <td align="center">2</td>
       <td>judy</td>
       <td><a href="mailto:judy@php.net">judy@php.net</a></td>
       <td align="center"> </td>
     </tr>
     <tr>
       <td align="center">3</td>
       <td>joe</td>
       <td><a href="mailto:joe@opera.com">joe@opera.com</a></td>
       <td align="center"> </td>
     </tr>
     <tr>
       <td align="center">4</td>
       <td>billy</td>
       <td><a href="mailto:billy@wakeside.com">billy@wakeside.com</a></td>
       <td align="center">X</td>
     </tr>
     <tr>
       <td align="center">5</td>
       <td>eileen</td>
       <td><a href="mailto:eileen@slashdot.org">eileen@slashdot.org</a></td>
       <td align="center"> </td>
     </tr>
    </table>
     </body>
    </html>


    缓存

    因为解决方案简单如斯,实现模板缓存成为了一个非常简单的任务。为了实现缓存,我们有一个二级类,它扩展了原来的模板类。CachedTemplate类事实上使用和原来的模板类相同的API。不同点是我们必须传递缓存的设置给构造函数,并且调用fetch_cache()而不是fetch()。

    缓存的概念是简单的。简单的说,我们设置一个缓存时间来调表输出应该被保存的时长(以秒为单位)。在产生一个页面的所有工作开展之前,我们必须首先测试页面是否已经被缓存了,而且缓存是否仍然没有过期。如果缓存在这那,我们不需要在去麻烦数据库和业务逻辑来产生页面——我们可以简单地输出原先缓存地内容。

    这种方法需要解决唯一地标识缓存文件的问题。如果一个站点是被一个显示基于GET变量的中心脚本所控制,对每个PHP文件只有一个缓存不会有什么帮助。例如,如果index.php?page=about_us和用户调用index.php?page=contact_us得到的显示完全不同。

    问题是通过给每个页面产生一个唯一的cache_id来解决的。为了做到这个目的,我们把事实上被请求的文件变成REQUEST_URI(基本上就是整个URL:index.php?foo=bar&bar=foo)。当然,这个转换过程是受到CachedTemplate类控制的,但是要记住的重要的事情是你绝对要在创建CachedTemplate对象时传递一个唯一的cache_id。当然下面有例子来说明。

    使用缓存包括以下步骤。

    这个脚本假定你的缓存文件将放到./cache/中,因此你必须创建那个目录并且改变它的目录权限(chmod)使得Web服务器能够写入文件。而且还要注意如果你在编写脚本的过程中发现了错误,错误也会被缓存!因而在你开发的过程中禁用缓存是一个好主意。最好的办法是给cache的生存周期传递0——这样,缓存总是立即就失效了。

    这是一个实际的缓存的例子。

    <?php
    /**
    * Example of cached template usage.  Doesn't provide any speed increase since
    * we're not getting information from multiple files or a database, but it
    * introduces how the is_cached() method works.
    */

    /**
    * First, include the template class.
    */
    require_once('template.php');

    /**
    * Here is the path to the templates.
    */
    $path = './templates/';

    /**
    * Define the template file we will be using for this page.
    */
    $file = 'list.tpl.php';

    /**
    * Pass a unique string for the template we want to cache.  The template
    * file name + the server REQUEST_URI is a good choice because:
    *    1. If you pass just the file name, re-used templates will all
    *       get the same cache.  This is not the desired behavior.
    *    2. If you just pass the REQUEST_URI, and if you are using multiple
    *       templates per page, the templates, even though they are completely
    *       different, will share a cache file (the cache file names are based
    *       on the passed-in cache_id.
    */
    $cache_id = $file . $_SERVER['REQUEST_URI'];
    $tpl = & new CachedTemplate($path, $cache_id, 900);

    /**
    * Test to see if the template has been cached.  If it has, we don't
    * need to do any processing.  Thus, if you put a lot of db calls in
    * here (or file reads, or anything processor/disk/db intensive), you
    * will significantly cut the amount of time it takes for a page to
    * process.
    *
    * This should be read aloud as "If NOT Is_Cached"
    */
    if(!($tpl->is_cached())) {
       $tpl->set('title', 'My Title');
       $tpl->set('intro', 'The intro paragraph.');
       $tpl->set('list', array('cat', 'dog', 'mouse'));
    }

    /**
    * Fetch the cached template.  It doesn't matter if is_cached() succeeds
    * or fails - fetch_cache() will fetch a cache if it exists, but if not,
    * it will parse and return the template as usual (and make a cache for
    * next time).
    */
    echo $tpl->fetch_cache($file);
    ?>


    设置多个变量

    我们如何能够同时设置多个变量?这又一个使用由Ricardo Garcia贡献的函数的例子。

    <?php  
    require_once('template.php');  

    $tpl = & new Template('./templates/');  
    $tpl->set('title', 'User Profile');  

    $profile = array(  
       'name' => 'Frank',  
       'email' => 'frank@bob.com',  
       'password' => 'ultra_secret'  
    );  

    $tpl->set_vars($profile);  

    echo $tpl->fetch('profile.tpl.php');  
    ?>

    相关的模板是这样的:

    <table cellpadding="3" border="0" cellspacing="1">  
       <tr>  
           <td>Name</td>  
           <td><?=$name;?></td>  
       </tr>  
       <tr>  
           <td>Email</td>  
           <td><?=$email;?></td>  
       </tr>  
       <tr>  
           <td>Password</td>  
           <td><?=$password;?></td>  
       </tr>  
    </table>

    而且解析后的输出是这样的:

    <table cellpadding="3" border="0" cellspacing="1">
     <tr>
       <td>Name</td>
       <td>Frank</td>
     </tr>
     <tr>
       <td>Email</td>
       <td>frank@bob.com</td>
     </tr>
     <tr>
       <td>Password</td>
       <td>ultra_secret</td>
     </tr>
    </table>

    特别感谢Ricardo Garcia和Harry Fuecks他们的对这篇文章的贡献。


    相关的链接

    这儿是一个总体上探究模板引擎的好去处的列表。

  • Web Application Toolkit Template View [9] - 许多关于模板实现方法的信息
  • MVC Pattern [10] - 描述3层应用程序的设计
  • SimpleT [11] - 另一个使用PEAR::Cache_Lite的基于php的模板引擎
  • Templates and Template Engines [12] - 更多关于各种模板实现的信息
  • Smarty [13] - 编译型模板引擎

  • 模板类源代码

    以及最后出场的,模板类。

    <?php
    /**
    * Copyright © 2003 Brian E. Lozier (brian@massassi.net)
    *
    * set_vars() method contributed by Ricardo Garcia (Thanks!)
    *
    * Permission is hereby granted, free of charge, to any person obtaining a copy
    * of this software and associated documentation files (the "Software"), to
    * deal in the Software without restriction, including without limitation the
    * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    * sell copies of the Software, and to permit persons to whom the Software is
    * furnished to do so, subject to the following conditions:
    *
    * The above copyright notice and this permission notice shall be included in
    * all copies or substantial portions of the Software.
    *
    * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    * IN THE SOFTWARE.
    */

    class Template {
       var $vars; /// Holds all the template variables
       var $path; /// Path to the templates

       /**
        * Constructor
        *
        * @param string $path the path to the templates
        *
        * @return void
        */
       function Template($path = null) {
           $this->path = $path;
           $this->vars = array();
       }

       /**
        * Set the path to the template files.
        *
        * @param string $path path to template files
        *
        * @return void
        */
       function set_path($path) {
           $this->path = $path;
       }

       /**
        * Set a template variable.
        *
        * @param string $name name of the variable to set
        * @param mixed $value the value of the variable
        *
        * @return void
        */
       function set($name, $value) {
           $this->vars[$name] = $value;
       }

       /**
        * Set a bunch of variables at once using an associative array.
        *
        * @param array $vars array of vars to set
        * @param bool $clear whether to completely overwrite the existing vars
        *
        * @return void
        */
       function set_vars($vars, $clear = false) {
           if($clear) {
               $this->vars = $vars;
           }
           else {
               if(is_array($vars)) $this->vars = array_merge($this->vars, $vars);
           }
       }

       /**
        * Open, parse, and return the template file.
        *
        * @param string string the template file name
        *
        * @return string
        */
       function fetch($file) {
           extract($this->vars);          // Extract the vars to local namespace
           ob_start();                    // Start output buffering
           include($this->path . $file);  // Include the file
           $contents = ob_get_contents(); // Get the contents of the buffer
           ob_end_clean();                // End buffering and discard
           return $contents;              // Return the contents
       }
    }

    /**
    * An extension to Template that provides automatic caching of
    * template contents.
    */
    class CachedTemplate extends Template {
       var $cache_id;
       var $expire;
       var $cached;

       /**
        * Constructor.
        *
        * @param string $path path to template files
        * @param string $cache_id unique cache identifier
        * @param int $expire number of seconds the cache will live
        *
        * @return void
        */
       function CachedTemplate($path, $cache_id = null, $expire = 900) {
           $this->Template($path);
           $this->cache_id = $cache_id ? 'cache/' . md5($cache_id) : $cache_id;
           $this->expire   = $expire;
       }

       /**
        * Test to see whether the currently loaded cache_id has a valid
        * corrosponding cache file.
        *
        * @return bool
        */
       function is_cached() {
           if($this->cached) return true;

           // Passed a cache_id?
           if(!$this->cache_id) return false;

           // Cache file exists?
           if(!file_exists($this->cache_id)) return false;

           // Can get the time of the file?
           if(!($mtime = filemtime($this->cache_id))) return false;

           // Cache expired?
           if(($mtime + $this->expire) < time()) {
               @unlink($this->cache_id);
               return false;
           }
           else {
               /**
                * Cache the results of this is_cached() call.  Why?  So
                * we don't have to double the overhead for each template.
                * If we didn't cache, it would be hitting the file system
                * twice as much (file_exists() & filemtime() [twice each]).
                */
               $this->cached = true;
               return true;
           }
       }

       /**
        * This function returns a cached copy of a template (if it exists),
        * otherwise, it parses it as normal and caches the content.
        *
        * @param $file string the template file
        *
        * @return string
        */
       function fetch_cache($file) {
           if($this->is_cached()) {
               $fp = @fopen($this->cache_id, 'r');
               $contents = fread($fp, filesize($this->cache_id));
               fclose($fp);
               return $contents;
           }
           else {
               $contents = $this->fetch($file);

               // Write the cache
               if($fp = @fopen($this->cache_id, 'w')) {
                   fwrite($fp, $contents);
                   fclose($fp);
               }
               else {
                   die('Unable to write cache.');
               }

               return $contents;
           }
       }
    }
    ?>

    另外一个值得注意的重要的事情是这里展示的解决办法是我们传递模板的文件名给fetch()函数。如果你需要重用模板对象而不去re-set()所有的变量,这将比较有用。

    并且记住:模板引擎的要点是把你的业务逻辑从你的表现逻辑中分离出来,而不是把你的PHP代码从HTML代码中分离出来。

    本文附件下载:template.zip

    [1]
    [2]
    [3]
    [4]
    [5]
    [6]
    [7]
    [8]
    [9]
    [10]
    [11]
    [12]
    [13]

    本文英文原版地址: