この記事はHamee Advent Calendar 2018の15日目の記事です。
やりたかったこと
リスト形式の画面にしたかったので、1行を別のviewファイルにして、ループしつつ1行づつrender
したい(最大1000行)
環境
PHP 7.1
FuelPHP 1.8
発生した問題
render
する際のループ回数が増えるとめちゃくちゃ遅い!
ちょっと計測してみました
<div>hoge</div>
<?= "ループ1回の場合<br>" ?>
<?php $time_start = microtime(true); ?>
<?= render('top/row') ?>
<?= (microtime(true) - $time_start) . "秒(render)<br>" ?>
<?= "ループ10回の場合 : " ?>
<?php $time_start = microtime(true); ?>
<?php for ($i = 0; $i < 10; $i++) { ?>
<?= render('top/row') ?>
<?php } ?>
<?= (microtime(true) - $time_start) . "秒<br>" ?>
<?= "ループ100回の場合 : " ?>
<?php $time_start = microtime(true); ?>
<?php for ($i = 0; $i < 100; $i++) { ?>
<?= render('top/row') ?>
<?php } ?>
<?= (microtime(true) - $time_start) . "秒<br>" ?>
<?= "ループ1000回の場合 : " ?>
<?php $time_start = microtime(true); ?>
<?php for ($i = 0; $i < 1000; $i++) { ?>
<?= render('top/row') ?>
<?php } ?>
<?= (microtime(true) - $time_start) . "秒<br>" ?>
ループ1回の場合 : 0.0084600448608398秒
ループ10回の場合 : 0.049752950668335秒
ループ100回の場合 : 0.48847103118896秒
ループ1000回の場合 : 4.3633110523224秒
大した内容ではないviewをrender
しているにもかかわらず、かなり時間がかかっています
10回くらいならまだ我慢できますが、100回を超えるとかなりきつい・・・
原因調査
FuelPHPのcoreのソースから原因を調査してみました
原因1. Fuel\Core\View::process_file()で毎回viewファイルをincludeしている
protected function process_file($file_override = false)
{
$clean_room = function($__file_name, array $__data)
{
extract($__data, EXTR_REFS);
// Capture the view output
ob_start();
try
{
// Load the view within the current scope
include $__file_name;
}
catch (\Exception $e)
{
// Delete the output buffer
ob_end_clean();
// Re-throw the exception
throw $e;
}
// Get the captured output and close the buffer
return ob_get_clean();
};
...
毎回includeしてしまっています
同じファイルであればキャッシュを使えるようにしたいですね
原因2. Fuel\Core\View::get_data()で毎回グローバル変数をサニタイズしている
...
$data = array();
if ( ! empty($this->data) and ($scope === 'all' or $scope === 'local'))
{
$data += $clean_it($this->data, $this->local_filter, $this->auto_filter);
}
if ( ! empty(static::$global_data) and ($scope === 'all' or $scope === 'global'))
{
$data += $clean_it(static::$global_data, static::$global_filter, $this->auto_filter);
}
return $data;
}
1つ目のifの処理は、renderの第二引数で渡す値のサニタイズ処理で
2つ目のifの処理は、コントローラーで$this->template->set_global
として渡すグローバル変数のサニタイズ処理になっています
グローバル変数については、ビューによって変わる値ではないので、これもキャッシュしたいですね
対策
以上の原因より、キャッシュが有効そうというのがわかったので、以下のようなクラスを作成しました
<?php
class View extends \Fuel\Core\View
{
/**
* キャッシュしたviewデータ
*
* @var array
*/
private static $_view_cache = null;
/**
* キャッシュしたグローバル変数
*
* @var array
*/
private static $_data_cache = [];
/**
* キャッシュしたサニタイズしたグローバル変数
*
* @var array
*/
private static $_data_sanitize_cache = [];
/**
* viewファイルをレンダーする
* ファイルをキャッシュするためrenderよりも高速
* viewから$this->fast_renderで呼ぶことができます
*
* @param string $file_name viewファイルのパス(renderするときと同様)
* @param array $data viewへ渡す値(renderするときと同様)
* @param bool $filter サニタイズするかどうか(何も渡さない場合はconfigの設定を使用する。renderするときと同様)
* @return string
*/
public function fast_render(string $file_name, ?array $data = [], $filter = null) : string {
// キャッシュがあればキャッシュからviewを取得する。なければファイルから取得しキャッシュする
if (isset(self::$_view_cache[$file_name])) {
$code = self::$_view_cache[$file_name];
}else{
$code = file_get_contents(APPPATH . 'views/' . $file_name . '.php');
self::$_view_cache[$file_name] = $code;
}
$auto_filter = is_null($filter) ? $this->auto_filter : $filter;
if (!empty($data)) {
// viewに渡す値をサニタイズする(この値はあまりキャッシュの意味が無いためあえてキャッシュしない)
$data = self::_sanitaize($data, [], $auto_filter, $this->filter_closures);
}else{
$data = [];
}
if (!empty(static::$global_data)) {
// グローバル変数をサニタイズする(基本的に一度渡されたグローバル変数が変わることは無いためキャッシュを使う)
$data = array_merge($data, self::_sanitaize(static::$global_data, static::$global_filter, $auto_filter, $this->filter_closures, true));
}
extract($data, EXTR_REFS);
ob_start();
eval( "?>" . $code );
$result = ob_get_contents();
ob_end_clean();
return $result;
}
/**
* 値をサニタイズする
*
* @param array $data サニタイズしたい値の連想配列
* @param array $rules キーごとにサニタイズしたい場合のフラグ
* @param bool $auto_filter 引数の値全体に対してサニタイズするかどうかのフラグ
* @param bool $filter_closures クロージャだった場合に対するフラグ
* @param bool $is_cache キャッシュ機能をつかうかどうか
* @return array
*/
private static function _sanitaize(array $data, array $rules, bool $auto_filter, bool $filter_closures, bool $is_cache = false) : array {
$is_found_cache = false;
if ($is_cache) {
// キャッシュを探す
foreach (self::$_data_cache as $key => $cache) {
if ($data === $cache && isset(self::$_data_sanitize_cache[$key])) {
$cache_key = $key;
$is_found_cache = true;
break;
}
}
if (!$is_found_cache) {
// キャッシュが無い場合保存する
$cache_key = count(self::$_data_cache);
self::$_data_cache[$cache_key] = $data;
}
}
$result = [];
foreach ($data as $key => $value) {
$filter = array_key_exists($key, $rules) ? $rules[$key] : null;
$filter = is_null($filter) ? $auto_filter : $filter;
if ($filter) {
if ($is_found_cache && isset(self::$_data_sanitize_cache[$cache_key][$key])) {
// キャッシュしたした値(サニタイズ後の値)を取得する
$value = self::$_data_sanitize_cache[$cache_key][$key];
} else {
if ($filter_closures and $value instanceOf \Closure) {
$value = $value();
}
$value = \Security::clean($value, null, 'security.output_filter');
if ($is_cache) {
// サニタイズ後の値をキャッシュする
self::$_data_sanitize_cache[$cache_key][$key] = $value;
}
}
}
$result[$key] = $value;
}
return $result;
}
}
解説
基本的にはもともとのrenderの処理を参考にしていますがキャッシュ処理を追加しています
if (isset(self::$_view_cache[$file_name])) {
$code = self::$_view_cache[$file_name];
}else{
$code = file_get_contents(APPPATH . 'views/' . $file_name . '.php');
self::$_view_cache[$file_name] = $code;
}
ここでviewファイルをキャッシュself::$_view_cache
し、キャッシュにあればそこから使います
なので、いちいちファイルを取得する必要がなくなります
グローバル変数については、サニタイズ前の値と後の値で両方キャッシュしています
$is_found_cache = false;
if ($is_cache) {
// キャッシュを探す
foreach (self::$_data_cache as $key => $cache) {
if ($data === $cache && isset(self::$_data_sanitize_cache[$key])) {
$cache_key = $key;
$is_found_cache = true;
break;
}
}
if (!$is_found_cache) {
// キャッシュが無い場合保存する
$cache_key = count(self::$_data_cache);
self::$_data_cache[$cache_key] = $data;
}
}
サニタイズ前の値をキャッシュしている理由は、キャッシュは連想配列で持たせたかったので
self::$_data_sanitize_cache['サニタイズ前の値'] = 'サニタイズ後の値'
にしたかったのですが、サニタイズ前の値は配列だったりオブジェクトだったりすることもあり、単純にこの形にはできませんでした
そのため、いったんサニタイズ前の値のキャッシュを連想配列ではなく配列で作成し、そのindexをサニタイズ後のキャッシュのキーとしています
なので、ここでやりたいことは単純にサニタイズ後の値を取得するためのキーを取得するということだけです
また、オリジナルの値のハッシュをキャッシュのキーとして使うという方法もありますが、ハッシュを作る処理が遅く、高速化には向きませんでした
そして出力部分
extract($data, EXTR_REFS);
ob_start();
eval( "?>" . $code );
$result = ob_get_contents();
ob_end_clean();
見慣れない関数が並んでますが
extract
は引数で渡した連想配列のキーと同じ名前を変数として使えるようにする関数です(その値は連想配列の対応する値)
ob_start
ob_end_clean
はここの間で行われる標準出力をしないようにして、その値を内部でバッファするようになります
そしてob_get_contents
でそのバッファした値を取得できます
eval
は引数の文字列をPHPのコードとして評価させることができます("?>"
を渡している理由はこちらに詳しく説明されています)
つまり
extract
でサニタイズした値を変数として使えるようにした後
file_get_contents
で取得したviewファイルの文字列を
eval
に渡してPHPとして評価できるようにすることでviewファイルに定義していた変数がサニタイズした値として代入され
ob_get_contents
でその結果を取得している
ということになります
再計測
キャッシュなし(初回のみ)
0.0119309425354秒
キャッシュあり
ループ1回の場合 : 2.6941299438477E-5秒
ループ10回の場合 : 3.6954879760742E-5秒
ループ100回の場合 : 0.00034499168395996秒
ループ1000回の場合 : 0.0021770000457764秒
爆速です!
このファイルを自分のプロジェクトで使いたい場合
このファイルをプロジェクト内の適当な場所に置き(ここではfuel/app/classes/view.phpとしています)、bootstrap.php
に以下を追加します
\Autoloader::add_classes(array(
'View' => APPPATH.'classes/view.php',
));
これでviewファイルで$this->fast_render
として使うことができます
render
とインターフェースは同じなのでそのまま置き換えできます