それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

PHPでheaders already sentを解消するためだけに雑にテンプレートエンジンを作った話

はじめに

どうもよく訓練されたJavaer、けんつです。
PHPの勉強がてらオレオレTwitterクライアントを作ろうとしていたら次のエラーにぶち当たった。

Warning: Cannot modify header information - headers already sent by
(output started at /some/file.php:12) in /some/file.php on line 23

調べると

このエラーは HTTP ヘッダーを変更する関数(後述)の呼び出しより前に、すでに何かがアウトプットされているために発生します

とのことで、ならMVCモデルのViewでレンダリングするときはどうすれば良いのだろうかと疑問に思った。
今回はそれを理解するためだけに、最小構成オレオレテンプレートエンジンを雑に作ってみたのでそれについて書いて見ようと思う。

github.com

2018/05/13の段階でとりあえずレンダリングはできる状態にあるが、細かい所や設計面を作りこめていないので今後改良していく。
composerをつかっている環境であれば以下のコマンドで導入できる。

$ composer require lrf141/rook

packagist.org

エラーの原因と解決策

まず原因はHTTPヘッダーを変更する関数をコールするまえに、何かしらを出力していることである。
なのでよくある解決策としてはレンダリングしたいものを

<?
ob_start();

などのように内部バッファに溜め込んで最終的にヘッダーに追加することが挙げられる。

もしくは、サーバ側の設定でアウトプットバッファリングを有効にすることでも回避できる。
今回は前者でやってみる。

テンプレートエンジンの実装

今回自作したテンプレートエンジンのソースは次のような構成になっている。

./src/
├── Directory.php
├── Engine.php
├── FileExtension.php
├── Name.php
└── Template.php

何をやったかというと、雑に説明するなら
テンプレート内で使用したいデータ群を配列として受け取ったらそれを変数として展開し
テンプレートとして利用するファイル(.php)をテンプレートエンジン内でincludeしてレンダリングするというもの。
その過程で、 バッファを利用している。

まず基礎となるEngineクラスは次のように実装した。

<?php
namespace Lrf141\Rook;
class Engine
{

    /**
     * Default template directory
     * @var Directory
     */
    private $directory;

    /**
     * Template file extension
     * @var FileExtension
     */
    private $fileExtension;

    /**
     * create new Engine Instance
     * @param string $base_dir
     * @param string $ext file extension
     */
    public function __construct(string $base_dir, string $ext = 'php')
    {
        $this->directory = new Directory($base_dir);
        $this->fileExtension = new FileExtension($ext);
    }

    /**
     * generate Template
     * 
     * @param string $name template name
     * @return Template
     *
     */
    public function make(string $name): Template
    {
        return new Template($this, $name);
    }

    /**
     * render based on template
     *
     * @param string $template template name
     * @param array $data wanna use in template
     * @return string rendering result
     */
    public function render(string $template, array $data = []): string
    {
        return $this->make($template)->render($data);
    }

    /**
     * get path to templates Directory
     * @return Directory
     */
    public function getDirectory(): Directory
    {
        return $this->directory;
    }

    /**
     * get templates file extension
     * @return FileExtension
     */
    public function getFileExtension(): FileExtension
    {
        return $this->fileExtension;
    }
}

このクラスのインスタンスを生成した時点で基準となるディレクトリと拡張子(ここではphp)を指定してそれらを管理するクラスのインスタンスを保持している。
Engineクラスのインスタンスからrenderメソッドをコールするとテンプレートが生成されて、レンダリング結果のhtmlがstringで返ってくる仕組みとなっている。

諸々の関連クラスのあれこれは省略するとして、一番重要なのがTemplateクラス。

<?php
namespace Lrf141\Rook;
use LogicException;
use Throwable;
use Exception;
class Template
{

    /**
     * @var Engine
     */
    private $engine;

    /**
     * @var Name
     */
    private $name;

    /**
     * @var array
     */
    private $sections = array();

    /**
     * @var array
     */
    private $data = array();

    /**
     * generate new Template Instance
     * @param Engine $engine
     * @param string $name
     */
    public function __construct(Engine $engine, string $name)
    {
        $this->engine = $engine;
        $this->name = new Name($name);
    }

    /**
     * rendering based on template 
     * @param array $data
     * @return string
     */
    public function render(array $data = []): string
    {
        //expands array as var
        $this->data($data);
        unset($data);
        extract($this->data);
        try {
            $level = ob_get_level();
            //dump buffer
            ob_start();
            include $this->path();
            
            $content = ob_get_clean();
            
            return $content;
        } catch (Throwable $e) {
            while (ob_get_level() > $level) {
                ob_end_clean();
            }
            throw $e;
        } catch (Exception $e) {
            while (ob_get_level() > $level) {
                ob_end_clean();
            }
            throw $e;
        }
    }

    /**
     * add data
     * @param array $data
     * @return array|none
     */
    public function data(array $data = null)
    {
        if (is_null($data)) {
            return $this->data;
        }
        $this->data = array_merge($this->data, $data);
    }

    /**
     * generate path
     * @return string
     */
    public function path(): string
    {
        $dir = $this->engine->getDirectory()->get();
        $ext = $this->engine->getFileExtension()->get();
        $name = $this->name->get();
        $path = $dir . '/' . $name . '.' . $ext;
        if (!$this->isExist($path)) {
            throw LogicException(
                `the template path "` . $path . '" does not exist.'
            );
        }
        return $path;
    }

    /**
     * check the path is true
     * @param string $path
     * @return bool
     */
    public function isExist(string $path): bool
    {
        return file_exists($path);
    }
}

この中で重要なのがrenderメソッドで、次の部分で配列を変数として展開している。

<?php

//expands array as var
$this->data($data);
unset($data);
extract($this->data);

特にextract関数がそれをやっている。

その後に

<?php

ob_start();

で、出力で内部バッファの利用を開始する。
その後に

<?php
 include $this->path();
 $content = ob_get_clean();

指定したパスにあるphpファイルをincludeして
ob_get_clean();でバッファの内容をstringで取得して、クリアしている。
これによって、直接出力しなくてもバッファに溜め込んで取得できるため上記のエラーが起きなくなる。
ただ最終的には取得した内容を吐き出す必要が出てくるのでその段階で工夫がまた必要にはなってくる。

それはPSR-7で制定されているHTTPメッセージ辺りの扱いを上手くやっていく必要があるのだと思う。
ただ出力を乱立させないで、テンプレートエンジンを利用することである程度まとめられるのでエラーは起こしにくくなるだろう。

おわりに

エラーを潰すためにこれだけやったがやったことをざっくりとまとめると内部バッファを使っただけなので
その辺り、PSR-7辺りを上手く使って行こうと思う。