Twigでカスタムタグを追加する

あけましておめでとうございます。小川です。
本日はTwigでカスタムタグを追加する方法を紹介します。

■Twig

TwigはPHPで実装されたテンプレートエンジンの1つで、Python製のテンプレートエンジンのJinjaとほぼ同じ構文を持っています。
Webアプリケーションフレームワークsymfonyの次期バージョンでもあるSymfony2で採用されることが決定しており、Symfony界隈を中心に注目を浴びているテンプレートエンジンです。


<h1>Categories</h1>
{% if categories|length > 0 %}
<ul>
  {% for category in categories %}
  <li>{{ category.name }}</li>
  {% endfor %}
</ul>
{% endif %}

上記がTwigを使ったサンプルコードです。「{% %}」や「{{ }}」で囲まれている部分がTwigの構文になります。

{{ }} で囲まれた部分は値の出力を行う構文です。{{ name }} ならば <?php echo $name ?> とほぼ同じです。

{% %} で囲まれた部分がタグです。ifやforなど、出力以外の処理を行います。今回はこのタグの部分を拡張するための方法の解説と、それに関するTwigの内部処理の解説を行います。なお、Twigは現在最新の1.0.0-RC2を前提としています。0.9.6など古いバージョンと比べると結構変わっている部分もありますので注意してください。バージョンの確認はTwig_Environment::VERSION定数を参照してください。

■Twigの処理の流れ

Twigではテンプレートを読み込むと、いくつかの処理をおこなってPHPのソースコードに変換されます。ソースコードができた段階でTwigの構文がすべてPHPプログラムに変換されており、Twig_Templateクラスを継承したクラスとして定義されています。このクラスのdisplay()メソッドを呼び出すとPHPプログラムが実行して変数の展開などが行われた上で出力されるようになっています。

ここではテンプレートを読み込んでPHPのソースコードにするまでの流れを説明します。その後、その中からタグを拡張するに当たって必要な部分を特定し、実装していきます。

次の図のような流れになりますので、適宜参考にしていただければと思います。

** レクサー(Lexer)

テンプレートを読み込むとまず、内部でレクサーと呼ばれるオブジェクトがテンプレートをばらばらのトークンに展開します。レクサーはTwig_Lexer_Interfaceを実装したクラスで、デフォルトではTwig_Lexerクラスです。

** トークン(Token)

トークンはテンプレートの構成要素を表すオブジェクトで、Twig_Tokenオブジェクトになります。Twig_Tokenには実際の値の他に、それぞれが何を意味する値なのかを表す「種類」が定義されています。トークンには次の種類があります。

– TEXT_TYPE
 - Twigの構文以外のテキスト(通常のHTMLの部分)
– VAR_START_TYPE
 - 出力の開始({{)
– VAR_END_TYPE
 - 出力の終了(}})
– BLOCK_START_TYPE
 - タグの開始({%)
– BLOCK_END_TYPE
 - タグの終了(%})
– NAME_TYPE
 - クオートされていない文字列(if、for、変数名 など)
– STRING_TYPE
 - クオートされている文字列(‘foo’ など)
– NUMBER_TYPE
 - 数字(2011 など)
– OPERATOR_TYPE
 - 演算子(+、&&、or など)
– PUNCTUATION_TYPE
 - 配列やハッシュ([、]、{、: など)
– EOF_TYPE
 - ファイルの終端

テンプレートは全体が1つの長い文字列ですが、Lexerによってトークンの連続に展開されます。トークンの連続は配列ではなく、トークンストリーム(Twig_TokenStream)オブジェクトに格納されます。

** パーサー(Parser)

Lexerが展開したトークンをパースして、後述するノードに変換します。トークンストリームから順番にトークンを読み込み、1つずつ評価してノードに変換を行います。その際トークンがBLOCK_START_TYPEであった場合、つまりタグが見つかった場合、トークンパーサーと呼ばれるオブジェクトが呼び出され、タグの展開を行います。

** トークンパーサー(TokenParser)

トークンパーサーはトークンストリームから情報を抜き出してパースし、適切なノードを作成します。トークンパーサーはタグごとに用意する必要があり、たとえばTwigがデフォルトで用意しているifタグはTwig_TokenParser_Ifクラスが処理します。つまり、タグを拡張する場合はこのトークンパーサーを新たに作成する必要があります。

トークンパーサーの具体的な作成方法は後述します。

** ノード(Node)

ノードはトークンの情報をPHPプログラムに変換するためのオブジェクトです。出力したいPHPプログラムごとにNodeを作成します。たとえば出力を行うTwig_Node_Printクラスの場合、コンストラクタに変数などの値を表すTwig_Node_Expressionオブジェクトを受け取り、その内容をechoします。


<?php
// lib/Twig/Node/Print.php
class Twig_Node_Print extends Twig_Node implements Twig_NodeOutputInterface
{
    public function __construct(Twig_Node_Expression $expr, $lineno, $tag = null)
    {   
        parent::__construct(array('expr' => $expr), array(), $lineno, $tag);
    }   
    public function compile(Twig_Compiler $compiler)
    {   
        $compiler
            ->addDebugInfo($this)
            ->write('echo ')
            ->subcompile($this->getNode('expr'))
            ->raw(";\n")
        ;
    }   
}

ノードには値を保持しているものや特定の関数やメソッドを表すもの、出力をするものなど様々な役割のものがあり、パーサーやトークンパーサーで好きなように組み合わせて使います。ノードも必要に合わせて実装する必要があります。

ノードはTwig_Node_Interfaceを実装したオブジェクトです。また、ノードは最終的に1つのTwig_Node_Moduleオブジェクトにまとめられます。これもTwig_Node_Interfaceを実装したノードです。トークンのように1次元配列構造ではなく、ネストする部分もあります。

** コンパイラー(Compiler)

コンパイラーは、パーサーによって作成されたノード(Twig_Node_Moduleオブジェクト)を1つのPHPソースコードにコンパイルします。各ノードはコンパイラーからソースコードに書き込みを行うメソッドを呼び出してプログラムを生成していきます。コンパイラーはTwig_Compilerオブジェクトになります。

Twig_Compilerクラスには次のようなメソッドがあります。

– compile(Twig_NodeInterface $node, $indentation = 0)
 - 指定したノードをコンパイルします。これは一番最初にTwigから呼び出されるメソッドで、基本的には使いません。
– subcompile(Twig_NodeInterface $node, $raw = true)
 - 指定したノードをコンパイルします。これはコンパイル実行中に別のノードをコンパイルする際に使います。
– raw($string)
 - 指定した文字列をそのまま出力します。
– function write($string, $string, …)
 - 指定した文字列を出力します。その際、文字列の先頭はインデントされます。
– string($value)
 - 指定した文字列を文字列として(ダブルクオートで囲んだ状態で)出力します。
– repr($value)
 - 指定した値をPHPの要素として出力します。たとえばrepr(array(1, 2, 3))とした場合は、ソースコード側にarray(1, 2, 3)と出力されます。
– addDebugInfo(Twig_NodeInterface $node)
 - 指定したノードの行数をコメントとして出力します。
– indent($step = 1)
 - インデントします。
– outdent($step = 1)
 - アウトデントします。

** エクステンション(Extension)

タグの拡張に当たってトークンパーサーの追加が必要となりますが、トークンパーサーを追加する処理はエクステンションを使って行います。エクステンションの作成はTwig_Extensionクラスを継承したクラスを作成し、任意の識別子を返すgetName()メソッドを最低限実装する必要があります。トークンパーサーを追加するにはgetTokenParsers()メソッドを実装し、トークンパーサーオブジェクトの配列を返します。この他にも様々な拡張が可能ですので、興味がある方は調べてみるとよいでしょう。

** 追加にあたって必要なこと

さて、ここまでで一通りの流れは説明しました。タグを追加する上で、次の3つのクラスを新たに作成する必要があります。それでは順番に作成を行います。

– トークンパーサー
– ノード
– エクステンション

■タグを追加する

今回はサンプルとして、挨拶をするだけのhelloタグを作ります。次のような使い方をします。


{% hello 'world' %}

タグの引き数として文字列を受け取ります。実行すると、次のような文字列を出力します。


Hello world!

今回はTwigの環境を整えるにあたって、Symfony2のサンドボックスを使用します。
インストール方法は、適当にGitHubからクローンして、apps/{cache,logs}にWebサーバーからの書き込み権限を追加して、webディレクトリをドキュメントルートに設定すればよいので、詳細は割愛します。

デフォルトでHelloBundleが定義されており、この中にあるHelloControllerのindexアクションの処理をTwigのタグを使って置き換えます。

■トークンパーサーの作成

src/Application/HelloBundleに移動し、この中にTwig/TokenParserディレクトリを作成します。

作成したディレクトリにトークンパーサーを定義します。今回はHelloTokenParserクラスを作成します。getTag()メソッドはタグの名称を指定するメソッドです。パーサーはこの値を元にトークンパーサーの選択を行います。分かりやすいよう、クラス名はタグ名をベースにつけるとよいでしょう。
parse()メソッドはトークンをパースしてノードを返すメソッドです。今回は引き数として変数や文字列などを受け取ります。Twigではこれらのことをエクスプレッションとよび、parseExpression()メソッドを呼び出している行はエクスプレッションを抜き出してTwig_Node_Expressionオブジェクトとして取得します。
このメソッドの戻り値として、今回はHelloNodeを作成します。コンストラクタの引き数にエクスプレッションを渡します。


<?php
namespace Application\HelloBundle\Twig\TokenParser;
use Application\HelloBundle\Twig\Node\HelloNode;
class HelloTokenParser extends \Twig_TokenParser
{
    public function parse(\Twig_Token $token)
    {   
        // 引き数を取得
        $expr = $this->parser->getExpressionParser()->parseExpression();
             
        // トークンが閉じタグかどうか判定
        $this->parser->getStream()->expect(\Twig_Token::BLOCK_END_TYPE);
        return new HelloNode($expr, $token->getLine(), $this->getTag());
    }   
    public function getTag()
    {   
        return 'hello';
    }   
}

■ノードの作成

ノードを配置するTwig/Nodeディレクトリを作成し、ここにHelloNodeクラスを定義します。


<?php
namespace Application\HelloBundle\Twig\Node;
class HelloNode extends \Twig_Node
{
    public function __construct(\Twig_Node_Expression $expr, $lineno, $tag = null)
    {   
        parent::__construct(array('expr' => $expr), array(), $lineno, $tag);
    }   
    public function compile(\Twig_Compiler $compiler)
    {   
        $compiler
            ->addDebugInfo($this)
            ->write("echo 'Hello ' . ")
            ->subcompile($this->getNode('expr'))
            ->raw(" . '!';\n")
        ;   
    }   
}

親クラスのコンストラクタの第1引き数はノードの連想配列を受け取るようになっています。Nodeを作成するときは、自分自身のコンストラクタでは必要なノードを引き数として順番に受け取るようにして、それを連想配列にまとめて親クラスのコンストラクタに渡すのが流儀です。

compile()メソッドではコンパイラーを受け取ってPHPプログラムを出力します。HelloNodeの場合は次のようなPHPプログラムが生成されます(実際は変数の取得処理はもっと複雑になります)。


// line ...
echo 'Hello ' . $expr . '!';

これでタグの実装ができました。後はエクステンションを作ってトークンパーサーをTwigに登録します。

■トークンパーサーの登録

エクステンションを配置するTwig/Extensionディレクトリを作成し、HelloExtensionクラスを作成します。


<?php
namespace Application\HelloBundle\Twig\Extension;
use Application\HelloBundle\Twig\TokenParser\HelloTokenParser;
class HelloExtension extends \Twig_Extension
{
    public function getTokenParsers()
    {   
        return array(
            new HelloTokenParser(),
        );  
    }   
    public function getName()
    {   
        return 'hello.hello';
    }   
}

エクステンションのgetName()メソッドの戻り値はTwig内での識別子となる文字列を返します。Symfony上で使う場合、「バンドル名.エクステンション名」でつけると基本的に被ることはないでしょう。

あとはSymfonyの機構を使って、エクステンションをTwigに登録します。SymfonyではDIコンテナに登録する際に、twig.extensionというタグを付けることで自動的にTwig側に登録されます。本題とはあまり関係がないので、作ったファイルだけ書いておきます。

src/Application/HelloBundle/DependencyInjection/HelloExtension.php


<?php
namespace Application\HelloBundle\DependencyInjection;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
class HelloExtension extends Extension
{
    public function configLoad($config, ContainerBuilder $container)
    {   
        if (!$container->hasDefinition('hello')) {
            $loader = new XmlFileLoader($container, __DIR__.'/../Resources/config');
            $loader->load('hello.xml');
        }   
    }   
    public function getXsdValidationBasePath()
    {   
        return __DIR__.'/../Resources/config/schema';
    }   
    public function getNamespace()
    {   
        return '...';
    }   
    public function getAlias()
    {   
        return 'hello';
    }   
}

src/Application/HelloBundle/Resources/config/hello.xml


<?xml version="1.0" ?>
<container xmlns="http://www.symfony-project.org/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.symfony-project.org/schema/dic/services http://www.symfony-project.org/schema/dic/services/services-1.0.xsd">
    <services>
        <service id="hello.twig.extension.hello" class="Application\HelloBundle\Twig\Extension\HelloExtension" public="false">
            <tag name="twig.extension" />
        </service>
    </services>
</container>

app/config/config.yml


...
hello.config: ~

ここまでで、TwigにHelloExtensionが登録されました。あとはテンプレートを修正してタグを呼び出すようにします。

■タグの呼び出し

テンプレートを修正してタグを呼び出します。テンプレートはResources/views/Hello/index.twigをそのまま使い、部分的に修正します。
もともとは次のようになっていると思います。


Hello {{ name }}

ここを次のように置き換えます。


{% hello name %}

これでカスタムタグが呼び出されました。ここまでで作業は完了です。

ちょっと前のバージョンまではTwig_SimpleTokenParserというクラスがあり、もっと簡単にトークンの拡張が可能だったのですが、どういう経緯があったのか知りませんが今のバージョンではなくなっているようです。
もっと本格的なトークンパーサーを作りたい場合は、ドキュメントを読むなりソースコードを読むなりしてみるとよいと思います。ifやforといったコアのタグも同じ仕組みで実装されていますし、Symfonyで拡張しているような部分もあります。自分がやりたいことにもっとも近いタグを選択し、まねしながら使うのが苦労しなくてよいと思います。

適度にタグを拡張したほうがテンプレートの可読性の向上にもつながると思いますので、ぜひ機会があれば使ってみてください。