zz log

zaininnari Blog

MySQLでデータベース名に「-(ハイフン)」を使うとクエリキャッシュされない(Qcache_not_cachedになる)

概要

MySQLでデータベース名またはテーブル名に「-(ハイフン)」(「$」も)が含まれてると、クエリキャッシュを有効にしているにも関わらず、Qcache_not_cachedになってしまいます。

対策は、

  • MySQL 5.6.9, 5.7.0 以降のバージョンを使用する
    • これらのリリースノートに記載がある
  • 5.5系は、5.5.33はNGで、5.5.37(現時点での最新版)はOKだった
    • リリースノートには記載がない
  • 0-9a-zA-Z_ のみの名前にリネームする

手順

  • have_query_cache YES
  • query_cache_size 0より大きく
  • query_cache_type 1

  • ハイフンを含まないデータベース名でSELECT文を実行

    • Qcache_insertsが増え、もう一度実行すると、Qcache_hitsが増えます
  • ハイフンを含むデータベース名でSELECT文を実行
    • Qcache_not_cachedが増えます。Qcache_insertsQcache_hitsは変化なし

参考

まとめ

  • MySQLのデータベース名またはテーブル名に特殊文字が含まれていると、クエリキャッシュされないバグがありました。
    • 通常はcreate database db-testは通りません(バッククォートが必要です)が、ツールから作成するとバッククォートしてくれるので、特殊文字と気づきませんでした。
  • 最新版では修正されています。

CakePHP cookbook の日本語PDFを作成する

f:id:zainin:20140208203006p:plain

sphinx の日本語PDFの作成方法を調べたので作成しました。

環境

  • CentOS release 6.4 (Final) (cat /etc/centos-release)
  • Python 2.6.6 (python --version)
  • Sphinx 1.2 (python -c 'import sphinx; print sphinx.__version__')
    • 日本語化パッチが取り込まれた 1.2以降を利用するのが楽です。
  • pdfTeX 3.1415926-2.5-1.40.14 (TeX Live 2013) (latex -v)
    • TeX Live 2011 の記事のやり方で通用しました。

対象ドキュメント

  • Cookbook 2.x の日本語版

手順

Sphinx のインストール

基本は以下を参考にしました。
Sphinxをはじめよう — Python製ドキュメンテーションビルダー、Sphinxの日本ユーザ会

# easy-installという、外部ライブラリをインストールするのに便利なコマンドをインストール
python ez_setup.py

# Sphinxのインストール
easy_install sphinx

TeX Live のインストール

ISOとネットワーク経由のインストール方法がありますが、
今回は以下から、TeX Live のネットワークインストーラーを入手してインストールしました。
Installing TeX Live over the Internet - TeX Users Group

※2G以上の容量を使用するので、空き容量に注意

# インストーラーの取得
http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz

# 解凍
tar xvzf install-tl-unx.tar.gz

# install-tl-以降の日付は適宜修正
cd install-tl-20140208

# インストーラーの実行
perl install-tl

最新の Cookbook を入手

git clone git@github.com:cakephp/docs.git . 

日本語PDFを作成するためのひと手間

以下を参考にしました。
LaTeX経由でのPDF作成 — Python製ドキュメンテーションビルダー、Sphinxの日本ユーザ会

日本語PDFの作成には、make all-pdf ではなく make all-pdf-ja を実行する必要があるため、ja/Makefileの編集を行います。

vi ja/Makefile

make -C $(BUILDDIR)/latex/$(LANG) all-pdfmake -C $(BUILDDIR)/latex/$(LANG) all-pdf-jaに書き換えます。

[修正後]

latexpdf:
        $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex/$(LANG)
        @echo "Running LaTeX files through pdflatex..."
        make -C $(BUILDDIR)/latex/$(LANG) all-pdf-ja
        @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex/$(LANG)."

PDFの作成

Cookbook のPDF作成用のコマンドを実行します。

# 特定の言語を指定する場合は、「-[言語]」を指定します。
make pdf-ja

作成が完了すると、build/latex/ja/CakePHPCookbook.pdf が生成されます。

まとめ

CakePHP で実行箇所をSQLのコメントに埋め込む

概要

CakePHP + DebugKit で開発しているときに、
「このSQLどこで発行されているの?」となることがあるので、
ActiveRecordのSQLの実行箇所をSQLのコメントに入れる - I sort my thought...
を参考にして簡単なプログラムを作成しました。

但し、DebugKit で使うには、DebugKit の仕様上中途半端になってしまいました。

環境

  • PHP 5.4.11
    • debug_backtrace の 第2引数 limit は 5.4.0 で追加され、このパラメータを使ってスタックフレームの数を制限できます。
    • 今回はこれを利用しているので、PHP5.4以上である必要があります
  • CakePHP 2.4.8
    • 2.4以降なら動作可能
    • 2.3未満は未確認

仕様

  • DboSource::execute を拡張
  • SQLの最後に、debug_print_backtrace の結果を追加
    • 先頭に追加すると、DebugKit で不具合で発生する
      • HtmlToolbarHelper::explainLink で、文頭がSELECTで始まるものだけ、Explain ボタンが生成されるため。
  • データベースは問わない
    • 今回はMySQLで作成

コード

[app/Config/database.php]

Database/Mysql を拡張したDatabase/DebugMysqlで debug が 0より大きい場合にデータソースの書き換えを行います。

<?php

class DATABASE_CONFIG {

    public $default = array(
        // 略
    );

    public $test = array(
        // 略
    );

    public function __construct() {
        $debugDataSources = [
            'Database/Mysql' => 'Database/DebugMysql',
        ];

        if (CakePlugin::loaded('DebugKit') && Configure::read('debug') > 0) {
            $datasource = $this->default['datasource'];
            if (isset($debugDataSources[$datasource])) {
                $config['datasource'] = $debugDataSources[$datasource];
            }
        }
    }
}

[app/Model/Datasource/Database/DebugMysql.php]

execute メソッドを拡張して、SQL文の後ろにバックトレースの内容を追加します。

<?php

App::uses('Mysql', 'Model/Datasource/Database');

class DebugMysql extends Mysql {

    public function execute($sql, $options = array(), $params = array()) {
        ob_start();
        // options
        //     DEBUG_BACKTRACE_IGNORE_ARGS "args" インデックスを無視してすべての関数/メソッドの引数をメモリに格納するかどうか。
        // limit
        //     5.4.0 以降、このパラメータを使ってスタックフレームの数を制限できるようになりました。
        //     デフォルト (limit=0) は、すべてのスタックフレームを返します。
        debug_print_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
        // 最後にある改行コードを削除
        $out = trim(ob_get_clean());

        // 改行コードで分割して配列に変換
        $backtrace = explode("\n", $out);
        // 最後の実行箇所(DebugMysql::execute)は自明なため削除
        array_shift($backtrace);

        // 各要素の最初の文字は`#` で開始されており、
        // これはMySQLではコメントとして解釈されるが、
        // 一般的なコメント構文にする
        // 実行されるファイル名がフルパスなため、冗長な部分を削除する
        foreach ($backtrace as &$line) {
            $line = '-- ' . str_replace(ROOT, '', $line);
        }

        // 既存のSQL分の最後に、バックトレースの結果を追加する
        // "\r\n\r\n" は見やすくするための改行
        // "\r\n" を使用しているのは、
        // DebugKit で、Explain を行う際、
        // セキュリティコンポーネントでPostValidate に引っかからないようにするため
        $sql = trim($sql) . "\r\n\r\n" . join("\r\n", $backtrace);
        return parent::execute($sql, $options, $params);
    }
}

DebugKit で、Explain を行う際、セキュリティコンポーネントでPostValidate に引っかからないようにするために、改行コードを\r\nにしています。これは、\nだけの場合、POST時に改行コードがブラウザで\r\nに強制され、\nハッシュ値と異なってしまい、PostValidate でブラックホールに吸い込まれてしまうのを防ぐためです。

DebugKit上では整形できない見づらい欠点

これでSql Logsのパネルを見ると以下のようになります。 f:id:zainin:20131023214529p:plain 見てわかる通り、整形されていないため見づらいです。
brタグを挿入しても、SQLの表示には、ToolbarHelper::getQueryLogs内で h関数でエスケープ処理されてしまうので、どうすることもできません。 ただ、ファイルに書き出したりする場合は、改行が活きてきます。

DebugKit の仕様上で中途半端になってしまいましたが、実行場所を特定はしやすくはなったと思います。

エクセル(VBA) と イラストレーターで表を作成する

概要

エクセル上で作成した表のテキストデータをイラストレーターのスレッドテキストに流し込むVBAプログラムを作成しました。

環境

  • Microsoft Windows 8 Pro
    • 今回はOS関係なし
  • Adobe Illustrator CS6 64bit
    • スレッドテキストをサポートしていればOK
  • Microsoft Excel 2013 64bit
    • Microsoft Excel 2007, Microsoft Excel 2010 でも動作OK
    • Microsoft Excel 2003 以前は未確認

仕様

  • MIT License
  • 選択したエクセルのセルの書式適応済みテキスト(cell.text)を縦1列にして、クリップボードにコピーを行う

注意点

  • エクセルからイラストレーターへはテキスト情報(書式適応済み)をコピー
    • フォントやフォントサイズはコピーされません
  • スレッドテキストの作成方向によってVBAを使い分ける必要があります
  • テキストの段組みの設定はイラストレーターで行う必要がります
  • 結合セルは考慮されていません

スレッドテキストで表の元を作成

f:id:zainin:20131020173255p:plain

エリアテキストで文字を作成

f:id:zainin:20131020173928p:plain

Ctrl + Shift + M でコピーを行い、Ctrl + D で動作を繰り返すと楽に作成できます。

f:id:zainin:20131020173909p:plain

f:id:zainin:20131020173916p:plain

テキストを選択して、 「書式」→「スレッドテキスト」→「作成」を行いスレッドテキストを作成します。

f:id:zainin:20131020173920p:plain

スレッドテキストが作成されました。

エクセルで表を準備

f:id:zainin:20131020174535p:plain

りんご バナナ
1個 200 ¥100
2個 400 ¥200
3個 600 ¥300
4個 800 ¥400
5個 1,000 ¥500

価格のカンマや円マークは書式でスタイルをつけています。

VBA

エクセルを起動して、Alt + F11 で、VBE を起動します。 メニューより、「挿入」 → 「標準モジュール」を追加します

Option Explicit
' MIT License

Sub illustratorスレッドテキスト用コピー縦方向()

    Dim beginPosRow As Long, beginPosCol As Long
    Dim endPosRow As Long, endPosCol As Long
    Dim index As Long, i As Long, j As Long
    index = 0
    Dim Cb As New DataObject
    Dim currentCell As Range, currentValue As String, buffer As String
    buffer = ""

    Dim Wb As Workbook
    Set Wb = ThisWorkbook
    Dim Ws As Worksheet
    Set Ws = Selection.Parent

    ' --------------------

    Application.ScreenUpdating = False
    Application.Calculation = xlCalculationManual

    ' --------------------

    beginPosRow = Selection(1).Row
    beginPosCol = Selection(1).Column
    endPosRow = Selection(Selection.Count).Row
    endPosCol = Selection(Selection.Count).Column

    With Ws
        ' 列を縦に並べるため、列からループを開始
        For i = beginPosCol To endPosCol
            For j = beginPosRow To endPosRow
                Set currentCell = .Cells(j, i)
                currentValue = currentCell.Text
                buffer = buffer + currentValue
                If i <> endPosCol Or j <> endPosRow Then
                    buffer = buffer + vbCrLf
                End If
                index = index + 1
            Next j
        Next i
    End With

    With Cb
        ' 変数の値をDataObjectに格納する
        .SetText buffer
        ' DataObjectのデータをクリップボードに格納する
        .PutInClipboard
    End With


    Application.ScreenUpdating = True
    Application.Calculation = xlCalculationAutomatic
    MsgBox index & " 件コピーしました"
End Sub

Sub illustratorスレッドテキスト用コピー横方向()

    Dim beginPosRow As Long, beginPosCol As Long
    Dim endPosRow As Long, endPosCol As Long
    Dim index As Long, i As Long, j As Long
    index = 0
    Dim Cb As New DataObject
    Dim currentCell As Range, currentValue As String, buffer As String
    buffer = ""

    Dim Wb As Workbook
    Set Wb = ThisWorkbook
    Dim Ws As Worksheet
    Set Ws = Selection.Parent

    ' --------------------

    Application.ScreenUpdating = False
    Application.Calculation = xlCalculationManual

    ' --------------------

    beginPosRow = Selection(1).Row
    beginPosCol = Selection(1).Column
    endPosRow = Selection(Selection.Count).Row
    endPosCol = Selection(Selection.Count).Column

    With Ws
        ' 列を横に並べるため、行からループを開始
        For i = beginPosRow To endPosRow
            For j = beginPosCol To endPosCol
                Set currentCell = .Cells(i, j)
                currentValue = currentCell.Text
                buffer = buffer + currentValue
                If j <> endPosCol Or i <> endPosRow Then
                    buffer = buffer + vbCrLf
                End If
                index = index + 1
            Next j
        Next i
    End With

    With Cb
        ' 変数の値をDataObjectに格納する
        .SetText buffer
        ' DataObjectのデータをクリップボードに格納する
        .PutInClipboard
    End With


    Application.ScreenUpdating = True
    Application.Calculation = xlCalculationAutomatic
    MsgBox index & " 件コピーしました"
End Sub

実行

f:id:zainin:20131020175436p:plain

表を選択して、マクロの表示から先ほど記述したillustratorスレッドテキスト用コピー縦方向を実行します。 コピーが完了すると下記のようなダイアログが表示されますので、OKを押して消してください。

f:id:zainin:20131020175827p:plain

イラストレーターへ移動して、スレッドテキスト内に既にあるテキストを削除、または、すべて選択します。

f:id:zainin:20131020180217p:plain

Ctrl + V で貼り付けを行います。

f:id:zainin:20131020180221p:plain

エクセルの書式を維持したままイラストレーターにコピーすることができました。

CakePHP + CacheHelper + html-minifier を CacheHelper と併用して使用する

概要

CakePHP + CacheHelper + html-minifier を併用できるようにしました。
エレメントとレイアウトのビューファイルの圧縮済みファイルを相対パスで保存し、かつ、読み込み時には、App::build で View パスの先頭にキャッシュパスを追加しています。

キャッシュファイルからのViewクラスでは、エレメントのビューファイルを App::pasth('View') に従い、検索します。
この時、App::build で View パスの先頭にキャッシュパスを追加しているので、キャッシュファイルがあればキャッシュファイルを、キャッシュファイルがなければ、通常のビューファイルを使ってレンダリングを行います。

環境

  • PHP 5.4.17
  • CakePHP 2.3.10
    • CacheHelper でビューキャッシュを有効
  • html-minifier 0.3.0

機能

  • キャッシュファイルがあればキャッシュファイルを優先して使用をする
    • プラグイン経由でビューもしっかり考慮
    • ビューキャッシュ化を回避するnocacheコメントは残す
  • _getViewFileName で検索されるビューファイルは相対パスでキャッシュをしない
    • 絶対パス等での読み込みが可能なため、View のパスが複数経路がある場合、後にあるファイルを読み込むことができ、意図しないファイルを読み込む可能性がある
    • エレメントやレイアウトのビューの検索は、絶対パス等での読み込みが不可能で、Viewパスの先頭から行われるので、後方にある同名のファイル名を読み込むことはできない
  • Emails/text にあるビューファイルはHTML圧縮しない
    • テキストが想定されているので、改行や空白に意味がある

コード

[composer.json]

{
    "require": {
        "zaininnari/html-minifier": "*"
    }
}
curl -sS https://getcomposer.org/installer | php
php composer.phar install

# HTML圧縮後のビューファイルの置場への書き込み権限を付加する

[app/Config/boostrap.php]

<?php
// パスは適宜変更
require(dirname(ROOT) . DS . 'vendor' . DS . 'autoload.php');

// ビューパスにHTML圧縮済みのキャッシュパスを追加する
// App::PREPEND は既存の先頭への追加
App::build(['View' => [CACHE . 'cached' . DS]], App::PREPEND);
<?php

App::uses('View', 'View');
App::uses('Folder', 'Utility');

class MyView extends View {

    protected function _getElementFileName($name) {
        $path = parent::_getElementFileName($name);
        $cacheRelativePath = $this->_getCacheRelativePath($name, $path);
        if ($cacheRelativePath === false) {
            return $path;
        }
        return $this->minifyHtml($path, $cacheRelativePath);
    }

    protected function _getViewFileName($name = null) {
        $path = parent::_getViewFileName($name);
        // View のテンプレートファイルは、
        // 絶対パスや相対パスの指定で任意のファイルが読み込めるので、
        // 相対パスの生成は行わない
        return $this->minifyHtml($path);
    }

    protected function _getLayoutFileName($name = null) {
        $path = parent::_getLayoutFileName($name);
        $cacheRelativePath = $this->_getCacheRelativePath($name, $path);
        if ($cacheRelativePath === false) {
            return $path;
        }
        return $this->minifyHtml($path, $cacheRelativePath);
    }

    protected function _getCacheRelativePath($name, $path) {
        // HTML圧縮済みのテンプレートパスからの読み込みの場合は、何もしない
        if (strpos($path, CACHE . 'cached' . DS) === 0) {
            return false;
        }

        $relative_path = null;
        list($plugin) = $this->pluginSplit($name);
        // 定義済みのパスから相対パスを生成
        foreach ($this->_paths($plugin) as $_path) {
            if (strpos($path, $_path) === 0) {
                $relative_path = substr($path, strlen($_path));
                break;
            }
        }

        if ($relative_path === null) {
            return false;
        }

        // プラグインの場合は混在しないよう'Plugin'ディレクトリに保存
        if ($plugin !== null) {
            $relative_path = 'Plugin' . DS . $plugin . DS . $relative_path;
        }
        return $relative_path;
    }

    protected function isHTMLTemplate($fileName) {
        $noHtmlTemplatePaths = [
            DS . 'Emails' . DS . 'text' . DS,
        ];

        foreach ($noHtmlTemplatePaths as $noHtmlTemplatePath) {
            if (strpos($fileName, $noHtmlTemplatePath) !== false) {
                return false;
            }
        }
        return true;
    }

    /**
     * テンプレートファイルのHTML部分を minify して、キャッシュしたファイルパスを返す。
     * 失敗した場合、元のファイルパスを返す。
     * @param string $fileName テンプレートファイルのフルパス
     * @param string|null $relative_path 相対パス
     * @return string テンプレートファイルのフルパス
     */
    protected function minifyHtml($fileName, $relative_path = null) {
        if (!$this->isHTMLTemplate($fileName)) {
            return $fileName;
        }

        if ($relative_path) {
            $fileNameCached = CACHE . 'cached' . DS . $relative_path;
            $fileNamePath = dirname($fileNameCached);
            $Folder = new Folder();
            if (!$Folder->create($fileNamePath, 0777)) {
                return $fileName;
            }
        } else {
            $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' => ['/<!--\/?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_WHITESPACE) {
                    $minify .= ' ';
                } 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 でビューキャッシュを使用時、独自Viewクラスを引き継ぐ(不完全)

概要

ビューキャッシュは、コアの CacheHelper によって作成され、CacheDispatcher で読み込みが行われます。
但し、現時点では、viewClass を引き継ぐ方法がないため、View クラスによるレンダリングが行われます。

通常は、View クラスのレンダリングで問題はないのですが、『素のPHPまたはCakePHPの テンプレートのHTML部分を圧縮(minify) して通信量を削減により、高速化を図る』で、nocacheコメントで囲まれた部分で、動的にエレメントの表示を制御している場合に問題があります。テンプレートファイルのHTML圧縮時に呼ばれないエレメントファイルは、圧縮されず、ビューキャッシュからのレンダリングからされる場合は、View クラスが使用されるため、HTML部分を圧縮する機会を失ってしまいます。

解決策として、

  1. デプロイ時に、テンプレートのHTML圧縮を行う
  2. ビューキャッシュを作成する際、独自Viewクラスを引き継がせる

があります。

事前圧縮だけでなく、オンデマンドでのテンプレート圧縮も提供したいので、ビューキャッシュを作成する際、独自Viewクラスを引き継がせる方法を考えました。

但し、
今回考えた方法は、適応できる状況に制限があり、問題なく独自Viewクラスを引き継ぐには CakePHP コアによるサポートが必須になります。

環境

CakePHP 2.4 や CakePHP 3 でも CacheHelper や CacheDispatcher は変化していません。

処理の流れ

まずは、CacheDispatcher の処理の流れをおさらいします。

CacheDispatcher の処理の流れ

  1. beforeDispatch イベントで発火
  2. Configure::read('Cache.check')が有効かチェック
  3. CakeRequest::here からキャッシュファイルパスを生成
  4. キャッシュファイルが存在するかチェック
  5. View クラス(決め打ち)のインスタンスを生成
  6. View::renderCache にキャッシュファイルパスを渡す
    • ob_start で出力のバッファリングを有効にしつつ、include でキャッシュファイルを読み込み、PHPコードを実行
    • バッファリング内容からcachetimeの部分を正規表現で抜き出し、キャッシュ切れかどうかをチェック
    • cachetime 以外の部分を返す
  7. View::renderCache が成功した場合、キャッシュ内容をレスポンスとして返す

改造方針の経緯

  1. ビュークラスのインスタンス生成
  2. キャッシュファイルの読み込み

の処理の流れを、

  1. ビュークラスの特定
  2. ビュークラスのインスタンス生成
  3. キャッシュファイルの読み込み

という流れで作成しようとして、キャッシュファイルの生成と同時に、キャッシュファイル名.view.php のようなビュークラスを格納したファイルを生成させました。しかし、キャッシュファイル数が2倍になるので、スマートではないと感じました。

また、キャッシュファイルパスをキーして、1つのファイルに使用するビュークラスの一覧をまとめようとしましたが、キャッシュファイル数が多くなると、オーバーヘッドが無視できなくなりました。

そんな時、
PHPコアから読み解く定石の嘘ホント(Ustream) を見ていて、

2:40以降の説明で、

include, include_once, require ,require_once, eval も同じ一つのオペコードとして定義されています。

とあって、
以下のような処理になりました。

CacheHelper を継承したクラスで、
App::uses($className, $location) に必要な $className, $location を cachetime のように書き出します。

<!--cachetime:1388384352--><!--viewClass:MyView--><!--location:MyPlugin.View--><?php

CacheDispatcher を継承したクラスで、

  1. キャッシュファイルの存在をチェック
  2. file_get_contents で読み込み(この時点ではPHPとして評価していない)
  3. 正規表現で、cachetime、viewClass、location を抜き出す
    • 抜き出し後は、「<!--cachetime ... <?php」までを除外
    • eval には最初の「<?php」は不要
  4. viewClass がデフォルト以外ならば、App::usesで読み込み
  5. viewClass でインスタンスを生成
  6. 独自Viewクラスに キャッシュファイル名からではなく、文字列からレンダリングを行う renderCacheString($string, $timeStart) を定義して、文字列を渡す
    • ob_start で出力のバッファリングを有効にしつつ、今度はeval でキャッシュファイルを読み込み、PHPコードを実行
  7. キャッシュ内容をレスポンスとして返す

という流れになりました。

実装

<?php

App::uses('CacheHelper', 'View/Helper');

class ZzCacheHelper extends CacheHelper {
    protected function _writeFile($content, $timestamp, $useCallbacks = false) {
        $now = time();

        if (is_numeric($timestamp)) {
            $cacheTime = $now + $timestamp;
        } else {
            $cacheTime = strtotime($timestamp, $now);
        }
        $path = $this->request->here();
        if ($path === '/') {
            $path = 'home';
        }
        $prefix = Configure::read('Cache.viewPrefix');
        if ($prefix) {
            $path = $prefix . '_' . $path;
        }
        $cache = strtolower(Inflector::slug($path));

        if (empty($cache)) {
            return;
        }
        $cache = $cache . '.php';
        $viewClass = get_class($this->_View);
        $location = App::location($viewClass);
        $file = '<!--cachetime:' . $cacheTime . '--><!--viewClass:' . $viewClass . '--><!--location:' . $location . '--><?php';

        if (empty($this->_View->plugin)) {
            $file .= "
          App::uses('{$this->_View->name}Controller', 'Controller');
          ";
        } else {
            $file .= "
          App::uses('{$this->_View->plugin}AppController', '{$this->_View->plugin}.Controller');
          App::uses('{$this->_View->name}Controller', '{$this->_View->plugin}.Controller');
          ";
        }

        $file .= '
              $request = unserialize(base64_decode(\'' . base64_encode(serialize($this->request)) . '\'));
              $response->type(\'' . $this->_View->response->type() . '\');
              $controller = new ' . $this->_View->name . 'Controller($request, $response);
              $controller->plugin = $this->plugin = \'' . $this->_View->plugin . '\';
              $controller->helpers = $this->helpers = unserialize(base64_decode(\'' . base64_encode(serialize($this->_View->helpers)) . '\'));
              $controller->layout = $this->layout = \'' . $this->_View->layout . '\';
              $controller->theme = $this->theme = \'' . $this->_View->theme . '\';
              $controller->viewVars = unserialize(base64_decode(\'' . base64_encode(serialize($this->_View->viewVars)) . '\'));
              Router::setRequestInfo($controller->request);
              $this->request = $request;';

        if ($useCallbacks) {
            $file .= '
              $controller->constructClasses();
              $controller->startupProcess();';
        }

        $file .= '
              $this->viewVars = $controller->viewVars;
              $this->loadHelpers();
              extract($this->viewVars, EXTR_SKIP);
      ?>';
        $content = preg_replace("/(<\\?xml)/", "<?php echo '$1'; ?>", $content);
        $file .= $content;
        return cache('views' . DS . $cache, $file, $timestamp);
    }

}
<?php
App::uses('CacheDispatcher', 'Routing/Filter');
class ZzCacheDispatcher extends CacheDispatcher {
    public function beforeDispatch(CakeEvent $event) {
        if (Configure::read('Cache.check') !== true) {
            return;
        }

        $path = $event->data['request']->here();
        if ($path === '/') {
            $path = 'home';
        }
        $prefix = Configure::read('Cache.viewPrefix');
        if ($prefix) {
            $path = $prefix . '_' . $path;
        }
        $path = strtolower(Inflector::slug($path));

        $filename = CACHE . 'views' . DS . $path . '.php';

        if (!file_exists($filename)) {
            $filename = CACHE . 'views' . DS . $path . '_index.php';
        }
        if (!file_exists($filename)) {
            return null;
        }
        $out = file_get_contents($filename);
        if (!preg_match('/^<!--cachetime:(\\d+)--><!--viewClass:(.+?)--><!--location:(.+?)--><\?php/', $out, $match)) {
            return null;
        }
        if (time() >= $match['1']) {
            //@codingStandardsIgnoreStart
            @unlink($filename);
            //@codingStandardsIgnoreEnd
            unset($out);
            return null;
        }
        $viewClass = $match[2];
        if ($viewClass !== 'View') {
            App::uses($viewClass, $match[3]);
        }
        $out = substr($out, strlen($match[0]));

        $view = new $viewClass(null);
        $view->response = $event->data['response'];
        if ($viewClass === 'View' || !method_exists($view, 'renderCacheString')) {
            return null;
        } else {
            $out = $view->renderCacheString($out, microtime(true));
        }
        $event->stopPropagation();
        $event->data['response']->body($out);
        return $event->data['response'];
    }
}
<?php

App::uses('View', 'View');

class ZzView extends View {

    public function renderCacheString($string, $timeStart) {
        $response = $this->response;
        ob_start();
        eval($string);

        $type = $response->mapType($response->type());
        if (Configure::read('debug') > 0 && $type === 'html') {
            echo "<!-- Cached Render Time: " . round(microtime(true) - $timeStart, 4) . "s -->";
        }
        $out = ob_get_clean();
        return $out;
    }
}

問題点

この方法ではいくつか問題点があります。

  • CacheHelper を継承したクラスで書き出されるキャッシュファイルは、継承元の CacheHelper のと互換性がないため、View::renderCacheが必ず失敗してしまう
    • viewClass と location の埋め込み位置をキャッシュファイルの最後部に配置すれば、互換性を確保できるが、HTML以外の場合、コメント部分が不正な文字列になってしまう。
  • JsonView 等の Viewクラスを継承したクラスを使用する場合、用途によっては、独自Viewクラスを継承して再定義したものを使用しないといけない。
    • ビューのクラス には、AppController のような継承元を再定義する構造がない

素の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%程度の削減ができました。