ログ日記

作業ログと日記とメモ

PHPの小規模用テンプレートエンジンを作った

今までPHPで何かを作るときはLaizを使っていたわけだけれども、レンタルサーバー的な構成だと使いづらい。
数ページのフォームとかだとPATH_INFOに対応するためにApache設定ファイルにAliasMatchを書いたりフレームワーク設定ファイルを書いたりするのが面倒になる。それにPHP5.3用に作っていたのでPHP5.2以下では動かない。
ちょっとしたフォームを作ときに、HTMLに直接PHPのコードを書くよりは少し便利な程度の単純なテンプレートエンジンが欲しくなった。


60行で作るPHP用テンプレートエンジンのようなものを作った。
最初は同じようにHTMLにPHPを書くつもりでやっていたけど、オブジェクトを使うとエディタ上で -> が閉じタグに解釈されて色がバグったりする。なのでHTMLを解析するバージョンも作った。
それからLaizのデータベース接続部分のコードを単体でも使えるように移動した。

テンプレートエンジン+α

https://github.com/nishimura/Yokaze
ここに置いた。
Template.php は70行なのでここにも貼っとく。このファイルだけ単体でも使える。

<?php

class Yokaze_Template
{
    protected $templateDir = 'template';
    protected $cacheDir = 'cache';
    private $ext = 'html';
    private $vars;
    public function __construct($templateDir = null, $cacheDir = null)
    {
        if ($templateDir)
            $this->templateDir = $templateDir;
        if ($cacheDir)
            $this->cacheDir;
        $this->vars = new StdClass();
    }
    public function setExtension($ext)
    {
        $this->ext = $ext;
    }
    public function setVars($vars)
    {
        $this->vars = $vars;
    }
    public function show($vars = null)
    {
        if ($vars === null)
            $vars = $this->vars;
        $file = basename($_SERVER['SCRIPT_FILENAME'], '.php') . '.' . $this->ext;
        $tmplFile = $this->templateDir . '/' . $file;
        $cacheFile = $this->cacheDir . '/' . $file;

        $this->compile($tmplFile, $cacheFile);

        $this->showCache($cacheFile, $vars);
    }
    protected function compile($tmplFile, $cacheFile)
    {
        if (file_exists($cacheFile) && filemtime($tmplFile) <= filemtime($cacheFile)){
            return;
        }

        $tmpl = file_get_contents($tmplFile);

        // include feature
        $incPattern = '|{include:([[:alnum:]/]+\.html)}|';
        if (preg_match($incPattern, $tmpl)){
            $incReplace =
                '<?php $this->compile(\'' .
                $this->templateDir . '/$1\', \'' .
                $this->cacheDir . '/$1\'' . ');' .
                ' include \'' . $this->cacheDir . '/' . '$1\'; ?>';
            $tmpl = preg_replace($incPattern, $incReplace, $tmpl);
        }

        // simple variables
        $tmpl = preg_replace('/(\{[[:alnum:]]+)\.([[:alnum:]]+(:[a-z]+)?\})/', '$1->$2', $tmpl);
        $tmpl = preg_replace('/\{([[:alnum:]_>-]*):h\}/', '<?php echo $$1; ?>', $tmpl);
        $tmpl = preg_replace('/\{([[:alnum:]_>-]*):b\}/', '<?php echo nl2br(htmlspecialchars($$1)); ?>', $tmpl);
        $tmpl = preg_replace('/\{([[:alnum:]_>-]*)\}/', '<?php echo htmlspecialchars($$1); ?>', $tmpl);

        file_put_contents($cacheFile, $tmpl);
    }
    private function showCache($__cacheFile__, $__vars__)
    {
        foreach ($__vars__ as $k => $v)
            $$k = $v;
        include $__cacheFile__;
    }
}

特に目新しいことはしていない。Flexyに慣れているのでそんな感じに。
テンプレートに{var}と書けば htmlspecialchars($var)を表示、{var:h}と書けばそのまま表示、{var:b}と書けばnl2brを挟む。オブジェクトは{obj.prop}と書く。ただし多段{obj.obj.prop}には対応していない。正規表現がすぐ思い付かなかったので。
あと{include:header.html}などと書くとheader.htmlを読み込む。
ディレクトリの階層には非対応。というか階層分けが必要な規模なら別のちゃんとしたフレームワークを使うと思う。


使い方の例。

public_html/
    cache/
       .htaccess
    config/
       Yokaze/
       .htaccess
       config.php
    template/
       .htaccess
       form.html
       result.html
    form.php
    result.php

.htaccess にはアクセス拒否の設定を書く。またはcacheやtemplateディレクトリをpublic_htmlの外に出してもいい。


config.php

<?php
ini_set('display_errors', 'On');
error_reporting(E_ALL | E_STRICT);

require_once 'Yokaze/Template.php';
$t = new Yokaze_Template();

require_once 'Yokaze/Session.php';
$s = new Yokaze_Session();

require_once 'Yokaze/Request.php';
$r = new Yokaze_Request();
$r->message = $s->remove('message');

SessionやRequestは使わなくてもいいけど、使った方が簡単に書ける。


form.php

<?php
require_once 'config/config.php';

$r->subject = '';
$r->body = '';
$r->initPost();

function success(){
    global $r, $s;
    $s->set('message', '送信しました。');
    // ここでDB登録、メール送信など
    $r->redirect('result.php');
}
if ($r->isPost()){
    if (!$r->get('subject'))
        $r->message = 'タイトルを入力してください。';
    else if (!$r->get('body'))
        $r->message = '本文を入力してください。';
    else
        success();
}
$t->show($r);

$r->subject = ''; などが若干めんどくさいが、データベースを使う場合はより簡単になる。それは後述。
$r->initPost() は$_POSTをRequestの変数として展開する。$r->initPost($obj) と書くと $objの中に展開する。


form.html

<!doctype>
<html>
  <head>
    <meta charset="UTF-8">
    <title>フォーム</title>
  </head>
  <body>
    <div style="color:red;">{message}</div>
    <form action="form.php" method="POST">
      タイトル:<input type="text" name="subject" value="{subject}">
      <br>
      本文:<textarea name="body">{body}</textarea>
      <br>
      <input type="submit" value="送信">
    </form>
  </body>
</html>


result.php

<?php
require_once 'config/config.php';
$t->show($r);

特に何もしない。その場合でもphpは必須。

result.html

<!doctype>
<html>
  <head>
    <meta charset="UTF-8">
    <title>送信完了</title>
  </head>
  <body>
    <div style="color:red;">{message}</div>
  </body>
</html>

O/Rマッピングツール + α

O/RマッピングツールはLaizから移植した。
https://github.com/nishimura/YokazeDb
プライマリキーはテーブルごとに一つ必要。名前は何でも良い。joinには非対応。その場合はsqlファイルに書く。
PostgreSQLSqliteに対応。Sqliteの動作確認はあまりしていない。テーブルのメタ情報を取得する関数を書けば他のDBでも対応可能。


2005年からつぎはぎしているのでコードは汚いがそれは置いておく。メソッドや引数も綺麗じゃないので気が向いたらs2jdbc風に書き直すつもり。
さきほどのフォームから送信されたデータをDBに登録するように変更する。


まずデータベースを以下のように作る。
config/tables.sql

create table form(
  form_id    serial primary key,
  subject    text not null,
  body       text not null,
  created_at timestamp not null
);
createdb yokaze_sample
psql -f config/tables.sql yokaze_sample


データベース用のコードを config.php追記

<?php
// ・・・省略
require_once 'YokazeDb/Factory.php';
$factory = new YokazeDb_Factory();
$dsn = 'pgsql:host=localhost;dbname=yokaze_sample;user=user;password=pass';
$factory->setDsn($dsn);


form.php を変更する。

<?php
require_once 'config/config.php';

$orm = $factory->create('form');
$form = $orm->createVo();
$r->initPost($form);
$r->form = $form;

function success(){
    global $r, $s, $form, $orm;
    if (!$orm->save($form)){
        $r->message = '送信に失敗しました。';
        return;
    }
    $s->set('message', '送信しました。');
    $r->redirect('result.php');
}
if ($r->isPost()){
    if (!$form->subject)
        $r->message = 'タイトルを入力してください。';
    else if (!$form->body)
        $r->message = '本文を入力してください。';
    else
        success();
}
$t->show($r);

$orm->createVo()はテーブル情報を読み取ってカラムに対応するフィールドを持ったクラスを生成する。
insert時にcreatedAtの値が自動挿入される。更新時はupdatedAtが自動更新される。


form.html の方は{subject}などを{form.subject}のように変更する。



登録されたデータを確認するページを作る。
list.php

<?php
require_once 'config/config.php';
$r->forms = $factory->create('forms')->setOptions(array('order' => 'createdAt'));

require_once 'config/YokazeDb/Pager.php';
$pager = new YokazeDb_Pager($r->forms, 10);
$r->pager = $pager->getHtml();
$t->show($r);

一覧表示用のイテレーター取得は1行で書ける。
表示を整形したい場合はSPLのIteratorIteratorを被せる。
このイテレーターはHTMLを表示してforeachに差し掛かったときに初めてデータベースにfetchしにいくので、HTMLに echo $PDOStatement->fetch()['name']; と書く場合に比べてそれほど遅くならないはず。


list.html

<!doctype>
<html>
  <head>
    <meta charset="UTF-8">
    <title>入力値一覧</title>
  </head>
  <body>
    <table>
      <tr>
        <th>タイトル</th>
        <th>登録時刻</th>
        <th>本文</th>
      </tr>
      <?php foreach ($forms as $form): ?>
      <tr>
        <td>{form.subject}</td>
        <td>{form.createdAt}</td>
        <td>{form.body:b}</td>
      </tr>
      <?php endforeach ?>
    </table>
    <div>
      {pager:h}
    </div>
  </body>
</html>

foreachはPHPで書く。
HTMLにPHPでforeachを書くのが嫌だという場合は new Yokaze_Template() の代わりに new Yokaze_Parser() する。
そうすると

    <table>
      <tr>
        <th>タイトル</th>
        <th>登録時刻</th>
        <th>本文</th>
      </tr>
      <tr class="loop:forms:form">
        <td>{form.subject}</td>
        <td>{form.createdAt}</td>
        <td>{form.body:b}</td>
      </tr>
    </table>

classやstyle、その他適当な属性値にloopを書ける。


TODOなど

  • Request は $r->message = 'foo'; なのに、Session は $s->set('message', 'foo'); になっている。どちらかに統一する。
  • タグの属性にif文が書ける。書式は class="if:$var" や style="if:$iterator.count()" や foo="if:$item.status===1" など。ちょっと見にくいので書き方を考える。
  • メール送信用ユーティリティクラスを追加する。
    • エラー表示をオフにしてエラーをメールで送信するユーティリティ的関数を書く。
  • ORMを全体的に変更