S2Container.PHP5 & S2Dao.PHP5のキャッシュ機能を強化して高速化する

S2Container.PHP5 & S2Dao.PHP5を使ってる人ってどの位居るんでしょうね?ずっとsymfonyと組み合わせて使ってるけど、本当に便利なんだけどな。symfony始める時にPropelをそのまま使うか考えたんだけど、ドキュメントを軽く読み流した感じだと、確かに簡単なCRUDやったり、関連を辿って取得するのには便利で良さそうだと思ったものの、複雑なクエリを発行した場合に結果セットをO/Rマッピングするのが面倒そうな気がした。(複雑なクエリの場合は、Criteriaは使わずにCreole使うか普通にプリペアド・ステートメント作ってクエリ発行後、手でマッピングする感じになる?もしくはマッピングしないとか?)

S2DaoならEntityやDtoに適当にアノテーション記述しさえすればどんなクエリでも簡単にオブジェクトにマッピングできてとてもシンプル。僕は元々C#Seasarシリーズを使ってたのでPHP版を使うのも簡単だったし(Java/.NET/PHP版があるので、どれか使ったことがあれば他の言語版を使うために覚えることが少ないのもS2Container & S2Daoの大きなメリットだと思う。)、Propelを捨てることでsymfonyで提供される幾つかのpluginの恩恵を受けられないというデメリットはあるものの、S2Daoの使い易さとDIコンテナの便利さはそれを補って余りあると思えた。(でも、Propelをちゃんと使った訳でも無いので較べることも出来ないけど。)

そんな訳でずっと使ってきたけど、リクエスト内で操作する必要があるテーブルが増えると、コンテナに登録するDaoの数が増える。するとどんどんコンテナの生成に時間が掛かるようになってしまうようだ。コンテナに関してはS2Containerのキャッシュサポートによりキャッシングが可能なんだけど、やはりちょっと遅過ぎると思う時があるので、とりあえず調べてみた。

Xdebugでプロファイルを取って、WinCacheGrindで表示してボトルネックを調べる。ちなみにプロファイルを取ったリクエスト内では、15程度のテーブルを使用してデータの取得や追加を行っている。

キャッシング機能の強化

まず、気になったのはS2Dao_DaoMetaDataImpl->createBeanMetaDataの実行時間。これはEntityのアノテーションからO/Rマッピング用に各種情報の準備を行っているのだけど、これがリクエスト毎に行われている。ざっくり見た感じだと、BeanMetaDataにはDBコネクションの情報も含まれないし、アノテーションが変更されなければ毎回同じ内容のクラスが生成されるようだったので、これをシリアライズしてキャッシュすることが出来れば、一気に高速化することが見込まれるように思えた。

  • S2Dao_DaoMetaDataImpl->createBeanMetaDataの実行時間

S2Container.PHP5には既にキャッシングの機能があるものの、原状ではコンテナへのクラスの登録と、AOPで生成されたコードしかキャッシュ出来ないので、これに独自に機能追加して、BeanMetaDataをキャッシュすることを考えた。この機能はS2Containerとは直接関係しないので、S2Dao側に追加することにする。S2ContainerのキャッシュサポートのコードをほぼそのままコピペしてS2Daoのキャッシュサポート用クラスを用意してしまい、あとはBeanMetaData生成部のコードを修正すれば良さそう。実際には、S2Dao_CacheSupport, S2Dao_CacheSupportFactory, S2Dao_PearCacheLiteSupportを追加して、Cache_Liteの設定に追加設定を記述する。また、S2Dao_DaoMetaDataImplの一部を修正する。

interface S2Dao_CacheSupport
{
    /**
     * @return boolean
     */
    public function isBeanMetaDataCaching();

    /**
     * @param string $beanClass
     * @return string|boolean serialized BeanMetaData source or boolean false.
     */
    public function loadBeanMetaDataCache($beanClass);

    /**
     * @param string $serializedBeanMetaData
     * @param string $beanClass
     */
    public function saveBeanMetaDataCache($serializedBeanMetaData, $beanClass);
}
  • S2Dao_CacheSupportFactory
final class S2Dao_CacheSupportFactory
{
    private static $support = null;
    const DEFAULT_CACHE_SUPPORT_CLASS = 'S2Dao_PearCacheLiteSupport';
    private function __construct(){}

    /**
     * @retrun S2Dao_CacheSupport singleton
     */
    public static function create() {
        if (self::$support === null) {
            $supportClassName = self::getSupportClassName();
            self::$support = new $supportClassName();
        }
        return self::$support;
    }

    private static function getSupportClassName() {
        return defined('S2DAO_PHP5_CACHE_SUPPORT_CLASS') ?
               S2DAO_PHP5_CACHE_SUPPORT_CLASS :
               self::DEFAULT_CACHE_SUPPORT_CLASS;
    }
}
  • S2Dao_PearCacheLiteSupport
class S2Dao_PearCacheLiteSupport implements S2Dao_CacheSupport
{
    private $cacheLite4BeanMetaData = null;
    private $beanMetaDataOptions    = array();
    private $beanMetaDataInited     = false;

    public function __construct() {
        if (defined('S2CONTAINER_PHP5_CACHE_LITE_INI')) {
            if (is_readable(S2CONTAINER_PHP5_CACHE_LITE_INI)) {
                $this->initialize();
            } else {
                S2Container_S2Logger::getLogger(__CLASS__)->
                    info('can not read ini file. [ ' . S2CONTAINER_PHP5_CACHE_LITE_INI . ' ]',__METHOD__);
            }
        }
    }

    /**
     * Initialize with ini format file, defined S2CONTAINER_PHP5_CACHE_LITE_INI
     *
     * INI format sections
     *   [default]
     *     defined options are applied to [beanMetaData] and others default value.
     *   [beanMetaData]
     *     for beanMetaData options.
     * Available options
     *   @see http://pear.php.net/manual/ja/package.caching.cache-lite.cache-lite.cache-lite.php
     *
     * Example
     *   [beanMetaData]
     *   caching = "true"
     *   lifeTime = "3600"
     */
    private function initialize() {
        $option = parse_ini_file(S2CONTAINER_PHP5_CACHE_LITE_INI, true);
        if (isset($option['default'])) {
            $this->beanMetaDataOptions = $option['default'];
            $this->beanMetaDataInited  = true;
        }

        if (isset($option['beanMetaData'])) {
            $this->beanMetaDataInited = true;
            foreach ($option['beanMetaData'] as $key => $val) {
                $this->beanMetaDataOptions[$key] = $val;
            }
        }

        if ($this->beanMetaDataInited) {
            if (isset($this->containerOptions['cacheDir'])) {
                $this->beanMetaDataOptions['cacheDir'] =
                    S2Container_StringUtil::expandPath($this->beanMetaDataOptions['cacheDir']);
            }
            require_once('Cache/Lite.php');
            $this->cacheLite4BeanMetaData = new Cache_Lite($this->beanMetaDataOptions);
        }
    }

    /**
     * @see S2Dao_CacheSupport::isBeanMetaDataCaching()
     */
    public function isBeanMetaDataCaching() {
        if ($this->beanMetaDataInited) {
            if (isset($this->beanMetaDataOptions['caching'])) {
                return $this->beanMetaDataOptions['caching'] == 'true' ? true : false;
            }
            return true;
        }
        else {
            return false;
        }
    }

    /**
     * @see S2Dao_CacheSupport::loadBeanMetaDataCache()
     */
    public function loadBeanMetaDataCache($beanClass) {
        if (!$this->beanMetaDataInited) {
            throw new Exception('BeanMetaData caching not initialized.');
        }
        return $this->cacheLite4BeanMetaData->get($beanClass);
    }

    /**
     * @see S2Dao_CacheSupport::saveBeanMetaDataCache()
     */
    public function saveBeanMetaDataCache($serializedBeanMetaData, $beanClass) {
        if (!$this->beanMetaDataInited) {
            throw new Exception('BeanMetaData caching not initialized.');
        }
        return $this->cacheLite4BeanMetaData->save($serializedBeanMetaData, $beanClass);
    }
}
  • S2Dao_DaoMetaDataImpl($cacheSupport_を追加し、createBeanMetaDataメソッドを置き換え)
    protected static $cacheSupport_;
    protected function createBeanMetaData(ReflectionClass $beanClass, PDO $conn){
        if (self::$cacheSupport_ === null) self::$cacheSupport_ = S2Dao_CacheSupportFactory::create();
        $support = self::$cacheSupport_;

        if (!$support->isBeanMetaDataCaching()) {
            S2Container_S2Logger::getLogger(__CLASS__)->
                    debug("set caching off.", __METHOD__);
            return new S2Dao_BeanMetaDataImpl($beanClass,
                                              $conn,
                                              $this->dbms_,
                                              $this->annotationReaderFactory_);
        }
        elseif ($data = $support->loadBeanMetaDataCache($beanClass->getName())) {
            S2Container_S2Logger::getLogger(__CLASS__)->
                    info("cached BeanMetaData \"" . $beanClass->getName() . "\" found. ",__METHOD__);
            $beanMetaData = unserialize($data);
            if (is_object($beanMetaData) and
                $beanMetaData instanceof S2Dao_BeanMetaData) {
                return $beanMetaData;
            } else {
                 throw new Exception("invalid cache found.");
            }
        }
        else {
            S2Container_S2Logger::getLogger(__CLASS__)->
                info("create BeanMetaData \"" . $beanClass->getName() . "\" and cache it.",__METHOD__);
            $beanMetaData = new S2Dao_BeanMetaDataImpl($beanClass,
                                                       $conn,
                                                       $this->dbms_,
                                                       $this->annotationReaderFactory_);
            $support->saveBeanMetaDataCache(serialize($beanMetaData), $beanClass->getName());
            return $beanMetaData;
        }
    }
  • キャッシュの設定に下記追加
[beanMetaData]
caching  = "true"
lifeTime = "7200"

これでOKかと思ったが、キャッシュをunserializeした後に、ReflectionClassのnewInstance()が使用出来なくなり、S2Dao_AbstractBeanMetaDataResultSetHandler->createRow()でEntityクラスを生成する時点でエラーとなってしまう。どうやらシリアライズの制限のようだが、getName()は使えるようだ。(なぜnewInstance()が使えなくなるのか詳しくは分からなかった・・・。)getName()さえ使えればインスタンス生成は出来るので、newInstance()してる部分はgetName()して得た$classNameを使ってnew $className()するように置き換える。

  • s2dao.core.classes.phpS2Dao_AbstractBeanMetaDataResultSetHandlerを修正
    protected function createRow(array $resultSet){
        $className = $this->beanMetaData_->getBeanClass()->getName();
        $row = new $className();
        $columnNames = new S2Dao_ArrayList(array_keys($resultSet));
    protected function createRelationRow(S2Dao_RelationPropertyType $rpt,
                                         array $resultSet = null,
                                         S2Dao_HashMap $relKeyValues = null){
        if($resultSet == null && $relKeyValues == null){
            $className = $rpt->getPropertyDesc()->getPropertyType()->getName();
            return new $className();
        }

もう一つ、S2Dao_AbstractIdentifierGenerator::$resultSetHandler_はstaticメンバーなのでシリアライズされない。S2Dao_AbstractIdentifierGenerator->executeSql()時点で$resultSetHandler_が設定されておらず、これもエラーとなってしまう。これを回避する為、executeSql()を下記の様に変更する。

  • s2dao.core.classes.phpS2Dao_AbstractIdentifierGeneratorを修正
    protected function executeSql(S2Container_DataSource $ds, $sql, $args) {
        if (self::$resultSetHandler_ === null) self::$resultSetHandler_ = new S2Dao_ObjectResultSetHandler();

これで動くようになった。と言っても一切テストとかしてないけど。キャッシュを導入した結果は以下の通りで、BeanMetaDataの生成は瞬殺となった。

  • S2Dao_DaoMetaDataImpl->createBeanMetaDataの実行時間(キャッシュ導入後)


preg_replaceをsubstrに変更

次に気になったのが、S2Dao_SqlParserImpl->__construct()内でのpreg_replace。クエリをtrimし、最後に";"があったら除去しているだけの処理なんだけども、クエリ文字列が長いとpreg_replaceに非常に時間がかかっていた。下記例は176msとなっているが、他の場合では500msを超えている事まであった。

  • S2Dao_SqlParserImpl->__construct()と、内部でのpreg_replaceの実行時間

ここはsubstrに変更。

  • S2Dao_SqlParserImpl->__construct()を修正
class S2Dao_SqlParserImpl implements S2Dao_SqlParser {

    private $tokenizer = null;
    private $nodeStack = array();

    public function __construct($sql) {
        $sql = trim($sql);
        if (substr($sql, -1) === ";") $sql = substr($sql, 0, -1);
        //$sql = preg_replace('/(.+);$/s', '\1', trim($sql));
        $this->tokenizer = new S2Dao_SqlTokenizerImpl($sql);
    }


結果はこの通り。細かいけど、何度もクエリを発行してると結構効いてくる。

  • S2Dao_SqlParserImpl->__construct()と内部の実行時間(変更後)

マッピングカラムの存在判定アルゴリズムを変更

最後に、S2Dao_AbstractBeanMetaDataResultSetHandler->createRow()およびcreateRelationRow()内で呼び出されるcolumnContains()の実装部分。結果セットのカラム名のリストに、マッピングするEntityの対応するカラム名が存在するかどうかを調べてるのだけど、リストを走査して調べててO(n)になってる。わざわざ結果セットのキーからリストを作ってるけど、これを止めて単純に元の結果セットのキーを使ってarray_key_existsとすれば定数時間。ただし、columnContains()はケース非依存でチェックしてるので、結果セットの配列のキーは小文字に変換して比較する。この部分の処理、元々一瞬ではあるけれど、カラム数や取得行数が増えると馬鹿にならないし、結果セットを得る度に必要な処理なので効果は大きい。ついでに、$pd->setValue($row, $value)となっている部分はS2Container_PropertyDescImpl->setValue()で、この処理の無駄がやや大きかったので、メソッド名を取得して$row->$method($value)とするようにした。(この変更はそんなに高速化にならなかったけど。)

  • s2dao.core.classes.phpS2Dao_AbstractBeanMetaDataResultSetHandlerを修正
abstract class S2Dao_AbstractBeanMetaDataResultSetHandler implements S2Dao_ResultSetHandler {
    private $beanMetaData_;
    public function __construct(S2Dao_BeanMetaData $beanMetaData) {
        $this->beanMetaData_ = $beanMetaData;
    }
    public function getBeanMetaData() {
        return $this->beanMetaData_;
    }
    protected function createRow(array $resultSet){
        $className = $this->beanMetaData_->getBeanClass()->getName();
        $row = new $className();
        $columnNames = array_change_key_case($resultSet, CASE_LOWER);
        //$columnNames = new S2Dao_ArrayList(array_keys($resultSet));
        $c = $this->beanMetaData_->getPropertyTypeSize();
        for($i = 0; $i < $c; ++$i) {
            $pt = $this->beanMetaData_->getPropertyType($i);
            $cn = strtolower($pt->getColumnName());
            $pn = strtolower($pt->getPropertyName());
            //if ($columnNames->contains($pt->getColumnName())) {
            if (array_key_exists($cn, $columnNames)) {
                //$value = $resultSet[$pt->getColumnName()];
                $value = $columnNames[$cn];
                $pd = $pt->getPropertyDesc();
                //$pd->setValue($row, $value);
                $method = $pd->getWriteMethod()->getName();
                $row->$method($value);
            //} else if ($columnNames->contains($pt->getPropertyName())) {
            } else if (array_key_exists($pn, $columnNames)) {
                //$value = $resultSet[$pt->getPropertyName()];
                $value = $resultSet[$pn];
                $pd = $pt->getPropertyDesc();
                //$pd->setValue($row, $value);
                $method = $pd->getWriteMethod()->getName();
                $row->$method($value);
            } else if (!$pt->isPersistent()) {
                //$iter = $columnNames->iterator();
                foreach ($columnNames as $key => $val) {
                //for (; $iter->valid(); $iter->next()) {
                    //$columnName = $iter->current();
                    $columnName = $key;
                    $columnName2 = str_replace('_', '', $columnName);
                    if (strcasecmp($columnName2, $pt->getColumnName()) == 0) {
                        $value = $resultSet[$pt->getColumnName()];
                        $pd = $pt->getPropertyDesc();
                        //$pd->setValue($row, $value);
                        $method = $pd->getWriteMethod()->getName();
                        $row->$method($value);
                        break;
                    }
                }
            }
        }
        return $row;
    }
    protected function createRelationRow(S2Dao_RelationPropertyType $rpt,
                                         array $resultSet = null,
                                         S2Dao_HashMap $relKeyValues = null){
        if($resultSet == null && $relKeyValues == null){
            $className = $rpt->getPropertyDesc()->getPropertyType()->getName();
            return new $className();
        }
        $row = null;
        $columnNames = array_change_key_case($resultSet, CASE_LOWER);
        //$columnNames = new S2Dao_ArrayList(array_keys($resultSet));
        $bmd = $rpt->getBeanMetaData();
        for ($i = 0; $i < $rpt->getKeySize(); ++$i) {
            $columnName = $rpt->getMyKey($i);
            $cn = strtolower($columnName);
            //if($this->columnContains($columnNames, $columnName)) {
            if (array_key_exists($cn, $columnNames)) {
                if ($row === null) {
                    $row = $this->createRelationRow($rpt);
                }
                if ($relKeyValues != null && $relKeyValues->containsKey($columnName)) {
                    $value = $relKeyValues->get($columnName);
                    $pt = $bmd->getPropertyTypeByColumnName($rpt->getYourKey($i));
                    $pd = $pt->getPropertyDesc();
                    if ($value !== null) {
                        //$pd->setValue($row, $value);
                        $method = $pd->getWriteMethod()->getName();
                        $row->$method($value);
                    }
                }
            }
            continue;
        }
        $c = $bmd->getPropertyTypeSize();
        $existColumn = 0;
        for ($i = 0; $i < $c; ++$i) {
            $pt = $bmd->getPropertyType($i);
            $columnName = $pt->getColumnName() . '_' . $rpt->getRelationNo();
            $cn = strtolower($columnName);
            //if(!$this->columnContains($columnNames, $columnName)){
            if (!array_key_exists($cn, $columnNames)) {
                continue;
            }
            $existColumn++;
            if ($row === null) {
                $row = $this->createRelationRow($rpt);
            }
            $value = null;
            if ($relKeyValues !== null && $relKeyValues->containsKey($columnName)) {
                $value = $relKeyValues->get($columnName);
            } else {
                $value = $resultSet[$columnName];
            }
            $pd = $pt->getPropertyDesc();
            if ($value !== null) {
                //$pd->setValue($row, $value);
                $method = $pd->getWriteMethod()->getName();
                $row->$method($value);
            }
        }
        if($existColumn == 0){
            return null;
        }
        return $row;
    }
    private function columnContains($columnNames, $column){
        if(is_string($columnNames)){
            if(strcasecmp($columnNames, $column) == 0){
                return true;
            }
            return false;
        }
        $c = count($columnNames);
        for($i = 0; $i < $c; $i++){
            if($this->columnContains($columnNames[$i], $column)){
                return true;
            }
        }
        return false;
    }
}
  • createRow()とcreateRelationRow()の実行時間(変更前)


  • createRow()とcreateRelationRow()の実行時間(変更後)

以上。特にBeanMetaDataキャッシュの効果は絶大なので、S2Dao.PHP5の次のバージョン(出るなら)に追加されてくれたらいいのにな。特にPRGパターンを多用したりしてると、事実上リクエスト毎に2回コンテナ生成してるので・・・。