9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FuelPHPのrenderを爆速にした話

Last updated at Posted at 2018-12-14

この記事はHamee Advent Calendar 2018の15日目の記事です。

やりたかったこと

リスト形式の画面にしたかったので、1行を別のviewファイルにして、ループしつつ1行づつrenderしたい(最大1000行)

環境

PHP 7.1
FuelPHP 1.8

発生した問題

renderする際のループ回数が増えるとめちゃくちゃ遅い!
ちょっと計測してみました

row.php
<div>hoge</div>
main.php
<?= "ループ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している

Fuel/Core/View.php
	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()で毎回グローバル変数をサニタイズしている

Fuel/Core/View.php
...
		$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として渡すグローバル変数のサニタイズ処理になっています
グローバル変数については、ビューによって変わる値ではないので、これもキャッシュしたいですね

対策

以上の原因より、キャッシュが有効そうというのがわかったので、以下のようなクラスを作成しました

fuel/app/classes/view.php
<?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に以下を追加します

fuel/app/bootstrap.php
\Autoloader::add_classes(array(
    'View' => APPPATH.'classes/view.php',
));

これでviewファイルで$this->fast_renderとして使うことができます
renderとインターフェースは同じなのでそのまま置き換えできます

9
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?