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_inserts
やQcache_hits
は変化なし
参考
- MySQL Bugs: #64821: Query Cache not used when table name contains dollar sign and engine is innodb
- MySQLのクエリーキャッシュが効かない場合 - ウィリアムのいたずらの開発日記
- mysql - query cache doesn't work - Stack Overflow
- MySQL :: MySQL 5.6 Release Notes :: Changes in MySQL 5.6.9 (2012-12-11)
- 下から3番目
- The server failed to use the query cache for queries in which a database or table name contained special characters and the table storage engine was InnoDB. (Bug #64821, Bug #13919851)
- 下から3番目
- データベース名の規則 MySQL :: MySQL 5.5 Reference Manual :: 9.2 Schema Object Names
$
は、OKとなっているのにクエリキャッシュされなく落とし穴があった
まとめ
CakePHP cookbook の日本語PDFを作成する
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-pdf
を make -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
が生成されます。
まとめ
- 今回作成した 2014年2月8日現在の日本語版PDFはこちら
- 日本語化パッチが取り込まれた Sphinx のインストールは簡単
- Cookbook の日本語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以上である必要があります
- debug_backtrace の 第2引数
- CakePHP 2.4.8
- 2.4以降なら動作可能
- 2.3未満は未確認
仕様
- DboSource::execute を拡張
- SQLの最後に、debug_print_backtrace の結果を追加
- 先頭に追加すると、DebugKit で不具合で発生する
- HtmlToolbarHelper::explainLink で、文頭がSELECTで始まるものだけ、Explain ボタンが生成されるため。
- 先頭に追加すると、DebugKit で不具合で発生する
- データベースは問わない
- 今回は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のパネルを見ると以下のようになります。
見てわかる通り、整形されていないため見づらいです。
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を使い分ける必要があります
- テキストの段組みの設定はイラストレーターで行う必要がります
- 結合セルは考慮されていません
スレッドテキストで表の元を作成
エリアテキストで文字を作成
Ctrl + Shift + M でコピーを行い、Ctrl + D で動作を繰り返すと楽に作成できます。
テキストを選択して、 「書式」→「スレッドテキスト」→「作成」を行いスレッドテキストを作成します。
スレッドテキストが作成されました。
エクセルで表を準備
りんご | バナナ | |
---|---|---|
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
実行
表を選択して、マクロの表示から先ほど記述したillustratorスレッドテキスト用コピー縦方向
を実行します。
コピーが完了すると下記のようなダイアログが表示されますので、OKを押して消してください。
イラストレーターへ移動して、スレッドテキスト内に既にあるテキストを削除、または、すべて選択します。
Ctrl + V で貼り付けを行います。
エクセルの書式を維持したままイラストレーターにコピーすることができました。
CakePHP + CacheHelper + html-minifier を CacheHelper と併用して使用する
概要
CakePHP + CacheHelper + html-minifier を併用できるようにしました。
エレメントとレイアウトのビューファイルの圧縮済みファイルを相対パスで保存し、かつ、読み込み時には、App::build で View
パスの先頭にキャッシュパスを追加しています。
キャッシュファイルからのViewクラスでは、エレメントのビューファイルを App::pasth('View')
に従い、検索します。
この時、App::build で View
パスの先頭にキャッシュパスを追加しているので、キャッシュファイルがあればキャッシュファイルを、キャッシュファイルがなければ、通常のビューファイルを使ってレンダリングを行います。
環境
機能
- キャッシュファイルがあればキャッシュファイルを優先して使用をする
- プラグイン経由でビューもしっかり考慮
- ビューキャッシュ化を回避するnocacheコメントは残す
- _getViewFileName で検索されるビューファイルは相対パスでキャッシュをしない
- 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部分を圧縮する機会を失ってしまいます。
解決策として、
- デプロイ時に、テンプレートのHTML圧縮を行う
- ビューキャッシュを作成する際、独自Viewクラスを引き継がせる
があります。
事前圧縮だけでなく、オンデマンドでのテンプレート圧縮も提供したいので、ビューキャッシュを作成する際、独自Viewクラスを引き継がせる方法を考えました。
但し、
今回考えた方法は、適応できる状況に制限があり、問題なく独自Viewクラスを引き継ぐには CakePHP コアによるサポートが必須になります。
環境
- CakePHP 2.3.10
CakePHP 2.4 や CakePHP 3 でも CacheHelper や CacheDispatcher は変化していません。
処理の流れ
まずは、CacheDispatcher の処理の流れをおさらいします。
CacheDispatcher の処理の流れ
- beforeDispatch イベントで発火
Configure::read('Cache.check')
が有効かチェックCakeRequest::here
からキャッシュファイルパスを生成- キャッシュファイルが存在するかチェック
- View クラス(決め打ち)のインスタンスを生成
View::renderCache
にキャッシュファイルパスを渡すView::renderCache
が成功した場合、キャッシュ内容をレスポンスとして返す
改造方針の経緯
- ビュークラスのインスタンス生成
- キャッシュファイルの読み込み
の処理の流れを、
- ビュークラスの特定
- ビュークラスのインスタンス生成
- キャッシュファイルの読み込み
という流れで作成しようとして、キャッシュファイルの生成と同時に、キャッシュファイル名.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 を継承したクラスで、
- キャッシュファイルの存在をチェック
- file_get_contents で読み込み(この時点ではPHPとして評価していない)
- 正規表現で、cachetime、viewClass、location を抜き出す
- viewClass がデフォルト以外ならば、
App::uses
で読み込み - viewClass でインスタンスを生成
- 独自Viewクラスに キャッシュファイル名からではなく、文字列からレンダリングを行う
renderCacheString($string, $timeStart)
を定義して、文字列を渡すob_start
で出力のバッファリングを有効にしつつ、今度はeval
でキャッシュファイルを読み込み、PHPコードを実行
- キャッシュ内容をレスポンスとして返す
という流れになりました。
実装
<?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%程度の削減ができました。