Posted at

Phalcon\Mvc\Viewで独自のビューエンジンを利用する

More than 3 years have passed since last update.

複数のテンプレートエンジンを同じインタフェースで扱う自作ライブラリを使っていたのですが、 Phalcon\Mvc\View にはその機能が備わっていることが分かりました。

自分で Phalcon\Mvc\View\EngineInterface を実装したアダプタを書けば、標準で対応していないテンプレートエンジンを扱えることも分かったため、試しに PHPTAL 用エンジンと Smarty 用エンジンを書いてみました。

なお、コードは Phalcon IncubatorPhalcon\Mvc\View\Engine を参考に書きました。

以下、動作を確認した環境です。


  • Windows 7

  • PHP 5.6.7 ビルトインWebサーバ

  • Phalcon 1.3.4

  • PHPTAL 1.3.0

  • Smarty 3.1.21


Phalcon\Mvc\View\Simple で PHPTAL を使う

PHPTAL 用のビューエンジンです。

Phalcon\Mvc\View\EngineInterface を実装といっても、抽象クラス Phalcon\Mvc\View\Engine を継承するのが作法のようなので、これに従いました。

src/Acme/Phalcon/Mvc/View/Engine/PhpTal.php

<?php

namespace Acme\Phalcon\Mvc\View\Engine;

use Phalcon\Mvc\View\Engine;
use Phalcon\Mvc\View\EngineInterface;

/**
* Phalcon\MVC\View\Engine\PHPTAL
*
* Adapter to use PHPTAL library as templating engine
*/

class PhpTal extends Engine implements EngineInterface
{
/**
* @var \PHPTAL
*/

private $phptal;

/**
* {@inheritdoc}
*
* @param \Phalcon\Mvc\ViewInterface
* @param \Phalcon\DiInterface
* @param \PHPTAL
*/

public function __construct($view, \Phalcon\DiInterface $di = null, \PHPTAL $phptal = null)
{
if ($phptal !== null) {
$this->phptal = $phptal;
}

parent::__construct($view, $di);
}

/**
* {@inheritdoc}
*
* @param string $path
* @param array $params
* @param boolean $mustClean
*/

public function render($path, $params, $mustClean = false)
{
if ($this->phptal === null) {
throw new \RuntimeException('PHTAL is not set.');
}

if (!isset($params['content'])) {
$params['content'] = $this->_view->getContent();
}

foreach ($params as $name => $value) {
$this->phptal->set($name, $value);
}

$content = $this->phptal->setTemplate($path)->execute();
if ($mustClean) {
$this->_view->setContent($content);
} else {
echo $content;
}
}

/**
* Set PHPTAL
*
* @param \PHPTAL
*/

public function setPhpTal(\PHPTAL $phptal)
{
$this->phptal = $phptal;
}
}

コンストラクタの第3引数を PHPTAL にしたのは自分の趣味です。(一応初期値は NULL にして setPhpTal() メソッドを用意しましたが)

render() メソッドの第1引数にはテンプレートファイルのフルパスが渡ってきますので、テンプレートエンジン側でテンプレートファイルの格納先ディレクトリを指定している場合、物によってはおかしなことになるかもしれません。(PHPTALの場合は特に問題なかったです)

render() メソッドの第2引数には View オブジェクトの setVar() 等でセットしたテンプレート変数と、 View オブジェクトの render() で指定された変数が配列で渡されてきます。

利用側のコードです。例のごとく Phalcon\Mvc\Micro で、 Phalcon\Mvc\View ではなく機能縮小版(?)の Phalcon\Mvc\View\Simple を使ってます。

また、設定の保持には Phalcon\Config を使ってます。(これが使いやすかったので Phalcon では俺々Configライブラリを捨てることにしました)

public/index.php

<?php

$loader = include realpath(__DIR__ . '/../vendor/autoload.php');

$config = new \Phalcon\Config([
'debug' => false,

'phptal' => [
'outputMode' => \PHPTAL::XHTML,
'encoding' => 'UTF-8',
'templateRepository' => __DIR__,
'phpCodeDestination' => sys_get_temp_dir(),
'forceReparse' => true,
],

]);

$di = new \Phalcon\DI();

$di->setShared('router', function() use ($di) {
$router = new \Phalcon\Mvc\Router();
$router->setDI($di);
return $router;
});

$di->setShared('request', function() use ($di) {
$request = new \Phalcon\Http\Request();
$request->setDI($di);
return $request;
});

$di->setShared('response', function() use ($di) {
$response = new \Phalcon\Http\Response();
$response->setDI($di);
return $response;
});

$di->setShared('view', function () use ($di, $config) {

$view = new \Phalcon\Mvc\View\Simple();

// テンプレートディレクトリを設定 (末尾にディレクトリ区切り文字が必要)
$view->setViewsDir(__DIR__ . DIRECTORY_SEPARATOR);

$view->registerEngines([
// .html の場合は PHPTAL
'.html' => function($view, $di) use ($config) {
$phptal = new \PHPTAL();
$phptal->setOutputMode($config->phptal->outputMode);
$phptal->setEncoding($config->phptal->encoding);
$phptal->setTemplateRepository($config->phptal->templateRepository); // Viewクラスの設定が使われるため、Viewクラスからのみ利用する場合は不要
$phptal->setPhpCodeDestination($config->phptal->phpCodeDestination);
$phptal->setForceReparse($config->phptal->forceReparse);
return new \Acme\Phalcon\Mvc\View\Engine\PhpTal($view, $di, $phptal);
},
]);

$view->setDi($di);
$view->setVar('config', $config);
$view->setVar('SERVER', $_SERVER);
$view->setVar('hostName', php_uname('n'));
return $view;
});

$app = new \Phalcon\Mvc\Micro($di);

$app->get('/', function () use ($app, $config) {

// クエリーストリングに "debug" があればデバッグ設定を有効にする
if ($app->request->hasQuery('debug')) {
$config->debug = true;
}

return $app->response->setContent(
$app->view->render('index', [
'title' => 'ホーム',
'XSS' => '<script>alert("XSS");</script>',
])
);

});

$app->handle();

$view->setViewsDir() では末尾にディレクトリ区切り文字を付与しないとテンプレートファイルを見つけられません。

$view->registerEngines() では拡張子ごとに利用するエンジンを指定します。

上記の例のように無名関数で定義することもできますが、これは前述の通りエンジンのコンストラクタを独自の仕様にしたためです。

たとえばエンジンのコンストラクタで PHPTAL を生成したり、第2引数の DI から $di->get('phptal') みたいにして取得すれば、無名関数ではなく単にクラス名を文字列で指定するだけで動作します。

今回のようにエンジンの設定を Phalcon\Config クラスに定義して評価を遅らせる場合でも、同様に $di->get('config') みたいにすれば対応できると思います。(気持ち悪いのでやりたくありませんが…)

$view->registerEngines() で ".html" を指定しているので、render('index') することで、$view->setViewsDir() に設定したディレクトリに対して "index.html" を探しにいき、ファイルがあれば拡張子に対応した PHPTAL エンジンで出力されます。

存在しないファイル、たとえば render('hoge') を指定すると、以下のような例外がスローされます。

'Phalcon\Mvc\View\Exception' with message 'View 'C:\Users\k_horii\Dropbox\Projects\phalcon\test\public\hoge' was not found in the views directory'

PHPTAL のテンプレートファイルです。

public/index.html

<!DOCTYPE html>

<html lang="ja">
<head>
<meta charset="utf-8" />
<title><span tal:replace="title|default">ページタイトル</span> | テストアプリケーション</title>
</head>
<body>

<header>
<h1><span tal:replace="title|default">ページタイトル</span>@<span tal:replace="SERVER/HTTP_HOST|default">example.com</span></h1>
</header>

<ul>
<li><a href="/">ホーム</a></li>
<li><a href="/?debug">ホーム (debug有効)</a></li>
</ul>

<h2><span tal:replace="XSS">XSS</span></h2>

<div tal:condition="config/debug">
<h2>$_SERVER環境変数</h2>
<table tal:condition="exists:SERVER">
<tbody>
<tr tal:repeat="var SERVER">
<th tal:content="repeat/var/key">環境変数名</th>
<td tal:content="var">環境変数値</td>
</tr>
</tbody>
</table>
</div>

<footer>
<p>
Copyright &copy; k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a>
Deployed on <strong tal:content="hostName|default">example.com</strong>
</p>
</footer>

</body>
</html>

設定値 debug が有効な場合のみビュー変数 SERVER ($_SERVER)の中身を表示しています。

PHPTAL はテンプレート変数を自動でHTMLエスケープする仕様なので、 XSS 変数を出力しても大丈夫です。

出力結果はこんな感じ

GET /

GET /?debug

ちゃんと期待通り表示されています。


Phalcon\Mvc\View\Simple で PHPTAL と一緒に Smarty を使う

次に Smarty を使ってみますが、せっかくなので PHPTAL のコードに書き加えて、両方のエンジンを併用してみます。

Smarty 用のビューエンジンは Phalcon Incubator にもありましたが、あえて書きました。

src/Acme/Phalcon/Mvc/View/Engine/Smarty.php

<?php

namespace Acme\Phalcon\Mvc\View\Engine;

use Phalcon\Mvc\View\Engine;
use Phalcon\Mvc\View\EngineInterface;

/**
* Phalcon\MVC\View\Engine\Smarty
*
* Adapter to use Smarty library as templating engine
*/

class Smarty extends Engine implements EngineInterface
{
/**
* @var \Smarty
*/

private $smarty;

/**
* {@inheritdoc}
*
* @param \Phalcon\Mvc\ViewInterface
* @param \Phalcon\DiInterface
* @param \Smarty
*/

public function __construct($view, \Phalcon\DiInterface $di = null, \Smarty $smarty = null)
{
if ($smarty !== null) {
$this->smarty = $smarty;
}

parent::__construct($view, $di);
}

/**
* {@inheritdoc}
*
* @param string $path
* @param array $params
* @param boolean $mustClean
*/

public function render($path, $params, $mustClean = false)
{
if ($this->smarty === null) {
throw new \RuntimeException('Smarty is not set.');
}

if (!isset($params['content'])) {
$params['content'] = $this->_view->getContent();
}

$template = $this->smarty->createTemplate($path);

foreach ($params as $name => $value) {
$template->assign($name, $value);
}

$content = $template->fetch();
if ($mustClean) {
$this->_view->setContent($content);
} else {
echo $content;
}
}

/**
* Set Smarty
*
* @param \Smarty
*/

public function setSmarty(\Smarty $smarty)
{
$this->smarty = $smarty;
}
}

処理内容は PHPTAL の場合とほとんど同じです。テンプレート変数のセットや出力の仕方を Smarty3 に合わせて変えているくらいです。

利用側のコードです。

public/index.php

<?php

$loader = include realpath(__DIR__ . '/../vendor/autoload.php');

$config = new \Phalcon\Config([
'debug' => false,

'phptal' => [
'outputMode' => \PHPTAL::XHTML,
'encoding' => 'UTF-8',
'templateRepository' => __DIR__,
'phpCodeDestination' => sys_get_temp_dir(),
'forceReparse' => true,
],

'smarty' => [
'template_dir' => __DIR__,
'compile_dir' => realpath(__DIR__ . '/../app/templates_c'),
'left_delimiter' => '{{',
'right_delimiter' => '}}',
'caching' => false,
'force_compile' => true,
'use_sub_dirs' => true,
'escape_html' => true,
],

]);

$di = new \Phalcon\DI();

$di->setShared('router', function() use ($di) {
$router = new \Phalcon\Mvc\Router();
$router->setDI($di);
return $router;
});

$di->setShared('request', function() use ($di) {
$request = new \Phalcon\Http\Request();
$request->setDI($di);
return $request;
});

$di->setShared('response', function() use ($di) {
$response = new \Phalcon\Http\Response();
$response->setDI($di);
return $response;
});

$di->setShared('view', function () use ($di, $config) {

$view = new \Phalcon\Mvc\View\Simple();

// テンプレートディレクトリを設定 (末尾にディレクトリ区切り文字が必要)
$view->setViewsDir(__DIR__ . DIRECTORY_SEPARATOR);

$view->registerEngines([
// .html の場合は PHPTAL
'.html' => function($view, $di) use ($config) {
$phptal = new \PHPTAL();
$phptal->setOutputMode($config->phptal->outputMode);
$phptal->setEncoding($config->phptal->encoding);
$phptal->setTemplateRepository($config->phptal->templateRepository); // Viewクラスの設定が使われるため、Viewクラスからのみ利用する場合は不要
$phptal->setPhpCodeDestination($config->phptal->phpCodeDestination);
$phptal->setForceReparse($config->phptal->forceReparse);
return new \Acme\Phalcon\Mvc\View\Engine\PhpTal($view, $di, $phptal);
},
// .tpl の場合は Smarty
'.tpl' => function($view, $di) use ($config) {
$smarty = new \Smarty();
$smarty->setTemplateDir($config->smarty->template_dir); // Viewクラスの設定が使われるため、Viewクラスからのみ利用する場合は不要
$smarty->setCompileDir($config->smarty->compile_dir);
$smarty->left_delimiter = $config->smarty->left_delimiter;
$smarty->right_delimiter = $config->smarty->right_delimiter;
$smarty->caching = $config->smarty->caching;
$smarty->force_compile = $config->smarty->force_compile;
$smarty->use_sub_dirs = $config->smarty->use_sub_dirs;
$smarty->escape_html = $config->smarty->escape_html;
return new \Acme\Phalcon\Mvc\View\Engine\Smarty($view, $di, $smarty);
},
]);

$view->setDi($di);
$view->setVar('config', $config);
$view->setVar('SERVER', $_SERVER);
$view->setVar('hostName', php_uname('n'));
return $view;
});

$app = new \Phalcon\Mvc\Micro($di);

$app->get('/', function () use ($app, $config) {

$title = 'ホーム';

// Viewオブジェクトの変数は全てのエンジンで共有される
$app->view->setVar('XSS', '<script>alert("XSS");</script>');

// クエリーストリングに "debug" があればデバッグ設定を有効にする
if ($app->request->hasQuery('debug')) {
$config->debug = true;
$title = 'ホーム (debug有効)';
}

// クエリーストリングに "no-escape" があればSmartyの自動エスケープを無効にする
if ($app->request->hasQuery('no-escape')) {
$config->smarty->escape_html = false;
$title = 'ホーム (Smartyエスケープ無効)';
}

$app->view->setVar('title', $title);

// ヘッダーのみ Smarty で出力 (header.tpl) し、全体のHTMLは PHTAL で出力 (index.html)
return $app->response->setContent(
$app->view->render('index', [
'header' => $app->view->render('header'),
])
);

});

$app->handle();

header要素の部分のみ Smarty で出力させています。

Smarty のテンプレート変数も escape_html オプションを有効にすることで自動でHTMLエスケープしてくれるのですが、これを無効にした場合の表示も確認できるようにしました。

PHPTAL 用テンプレートです。

public/index.html

<!DOCTYPE html>

<html lang="ja">
<head>
<meta charset="utf-8" />
<title><span tal:replace="title|default">ページタイトル</span> | テストアプリケーション</title>
</head>
<body>

<header tal:replace="structure header">
<h1>ページタイトル@example.com</h1>
</header>

<ul>
<li><a href="/">ホーム</a></li>
<li><a href="/?debug">ホーム (debug有効)</a></li>
<li><a href="/?no-escape">ホーム (Smartyエスケープ無効)</a></li>
</ul>

<h2><span tal:replace="XSS">XSS</span></h2>

<div tal:condition="config/debug">
<h2>$_SERVER環境変数</h2>
<table tal:condition="exists:SERVER">
<tbody>
<tr tal:repeat="var SERVER">
<th tal:content="repeat/var/key">環境変数名</th>
<td tal:content="var">環境変数値</td>
</tr>
</tbody>
</table>
</div>

<footer>
<p>
Copyright &copy; k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a>
Deployed on <strong tal:content="hostName|default">example.com</strong>
</p>
</footer>

</body>
</html>

<header tal:replace="structure header"> の部分で "structure" キーワードによって Smarty の出力内容をエスケープせず出力させています。

Smarty 用テンプレートです。

public/header.tpl

<header>

<h1>{{$title}}@{{$SERVER.HTTP_HOST}} by Smarty</h1>
<h2>{{$XSS}}</h2>
</header>

出力結果はこんな感じ

GET /

GET /?debug

GET /?no-escape

JavaScript が実行されてしまいました。期待通りです。