zz log

zaininnari Blog

素のPHPまたはCakePHPの テンプレートのHTML部分を圧縮(minify) して通信量を削減により、高速化を図る

概要

PHPのViewテンプレートをtoken_get_all(指定したソースを PHP トークンに分割する)と自前のhtml-minifier により、事前にHTML圧縮を行い、その結果をキャッシュすることで、5~10%程度のHTML容量の削減を行うことができました。

詳細

CakePHP の View テンプレートは、テンプレートエンジンを使用せず、素のPHPを使用します。

IDE等の支援を受けながら、View テンプレートを作成しますが、人間向けに可読性を持たせようとすると、ブロック要素毎にインデントを行いながら作成することになります。インデントに使用される(半角空白やタブ)は人間の可読性のためだけにあり、デメリットとして通信量の増大(ブラウザのHTMLパースは体感できない程高速に行われる)やサーバー側のキャッシュの非効率化などがあります。

PHPのテンプレートによるレンダリング後のHTML圧縮(PageSpeed Module等)は、

  • 動的な内容(ユーザー情報を含む場合)のキャッシュの場合はリクエスト毎に圧縮を行わなればならない
  • URLによって、メインのコンテンツは変化するが、ヘッダー・サイドバー・フッターは、変化することが少ないにも関わらず、毎回圧縮が必要

など手軽に導入できる分、デメリットの部分も大きいです。

より低レベルな段階で、HTMLを圧縮するために、View テンプレートを token_get_all よってトークンに分割し、自前のhtml-minifier により、HTML部分の圧縮を行った結果をキャッシュすることで、上記の2点の問題を解決します。

APCやZend OPcacheの導入を前提としているため、PHP部分の最適化は、キャッシュの容量削減のためにコメントの削除のみを行います。 HTML部分は、

  • 2個以上の改行を1個に削除
  • タグの前後及びタブ内部の空白文字を削除(preタグやcodeタグ内の空白文字は残す)
  • タグの属性の最適化
  • コメントの削除(条件付きコメントは残す、オプションにより残すコメントを増やすことが可能)

を行います。但し、scriptやstyle要素内の最適化は実施されません。

また、 PHPでタグを生成する場合は、問題ないですが、

<div class="main-content <?php echo is_mobile() ? 'mobile' : 'pc'; ?>">

のように、属性内で使用する場合、トークンに分割されると「<div class="main-content 」「(PHP)」「">」に分割され、classの最後の空白が、html-minifier によって削除されてしまい、文脈が変わってしまう可能性があります。対策として、空白文字列だけで構成される場合、空白1個は必ず残すようにしました。

CakePHP に組み込む場合

事前準備として、composer により、html-minifier をインストールを行い、bootstrap.php 等で vendor/autoload.php を読み込みます。 また、圧縮後のファイルの保存先として、tmp/cache/cached にディレクトリを作成し、書き込みを許可します。

  • CakePHP 2.3.10
  • html-minifier 0.3.0

[composer.json]

{
    "require": {
        "zaininnari/html-minifier": "*"
    }
}

ここでは、カスタム View を作成し、レイアウト・ビュー・エレメントの各テンプレートファイルに対して、HTMLの圧縮を行っています。

<?php
App::uses('View', 'View');

class MyView extends View {

    protected function _getViewFileName($name = null) {
        $viewFileName = parent::_getViewFileName($name);
        return $this->minifyHtml($viewFileName);
    }

    protected function _getLayoutFileName($name = null) {
        $layoutFileName = parent::_getLayoutFileName($name);
        return $this->minifyHtml($layoutFileName);
    }

    protected function _getElementFilename($name = null) {
        $elementFileName = parent::_getElementFilename($name);
        return $this->minifyHtml($elementFileName);
    }

    /**
     * テンプレートファイルのHTML部分を minify して、キャッシュしたファイルパスを返す。
     * 失敗した場合、元のファイルパスを返す。
     * 
     * @param $fileName テンプレートファイルのフルパス
     * @return string テンプレートファイルのフルパス
     */
    protected function minifyHtml($fileName) {
        $fileNameCached = $fileName;
        if (strpos($fileName, ROOT) === 0) {
            $fileNameCached = substr($fileName, strlen(ROOT));
        }
        $fileNameCached = CACHE . 'cached' . DS . strtolower(str_replace(DS, '_', $fileNameCached));

        if (file_exists($fileNameCached)) {
            return $fileNameCached;
        }
        if (!is_writable(dirname($fileNameCached))) {
            return $fileName;
        }

        $content = file_get_contents($fileName);
        $tokens = token_get_all($content);
        $minify = '';
        $option = [
            'excludeComment' => array('/<!--\/?nocache-->/'),
        ];

        foreach ($tokens as $token) {
            if (is_array($token)) {
                $token_name = $token[0];
                if ($token_name === T_COMMENT || $token_name === T_DOC_COMMENT) {
                    continue;
                }
                $token_data = $token[1];
                if ($token_name === T_CLOSE_TAG) {
                    $minify .= trim($token_data);
                } elseif ($token_name === T_INLINE_HTML) {
                    // PHP で属性等を生成する場合、半角空白の区切りを削除してしまうことへの対策
                    if (preg_match('/^\s+$/', $token_data)) {
                        $minify .= ' ';
                    } else {
                        $minify .= zz\Html\HTMLMinify::minify($token_data, $option);
                    }
                } else {
                    $minify .= $token_data;
                }
            } else {
                $minify .= $token;
            }
        }
        //@codingStandardsIgnoreStart
        if (@file_put_contents($fileNameCached, $minify, LOCK_EX) !== false) {
            return $fileNameCached;
        }
        //@codingStandardsIgnoreEnd
        return $fileName;
    }
}

効果

CakePHPで構築されたサイトで試したところ、あるページでは、

最適化前 : 36,146 バイト(gzip圧縮前) 最適化後 : 33,275 バイト(gzip圧縮前)

と8%程削減でき、色々なページで試して、5~10%程度の削減ができました。