zz log

zaininnari Blog

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 のような継承元を再定義する構造がない