FuelPHPのrenderを爆速にした話

この記事は[Hamee Advent Calendar 2018](https://qiita.com/advent-calendar/2018/hamee)の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;
	}
```
１つ目のifの処理は、renderの第二引数で渡す値のサニタイズ処理で
２つ目の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の処理を参考にしていますがキャッシュ処理を追加しています

```PHP
        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`し、キャッシュにあればそこから使います
なので、いちいちファイルを取得する必要がなくなります

グローバル変数については、サニタイズ前の値と後の値で両方キャッシュしています

```PHP
        $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をサニタイズ後のキャッシュのキーとしています
なので、ここでやりたいことは単純にサニタイズ後の値を取得するためのキーを取得するということだけです

また、オリジナルの値のハッシュをキャッシュのキーとして使うという方法もありますが、ハッシュを作る処理が遅く、高速化には向きませんでした

そして出力部分

```PHP
        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のコードとして評価させることができます(`"?>"`を渡している理由は[こちら](http://d.hatena.ne.jp/Fivestar/20090707/1246990567)に詳しく説明されています)

つまり
**`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`とインターフェースは同じなのでそのまま置き換えできます