unoh.github.com

symfony/Doctrineのキャッシュ機能

Sun May 16 20:07:19 -0700 2010

こんばんは、五十川です。

symfony 1.3/1.4で、それまでのPropelに代わってデフォルトのORMとなったDoctrineには、組み込みのキャッシュ機能があり、クエリーの実行結果データを手軽にキャッシュすることができます(これは「結果キャッシュ」と呼ばれます)。また、実行結果だけではなく、DQLに基づいて生成されるSQLをキャッシュする仕組みもあり、これを利用することで、DQL解析のオーバーヘッドを回避して処理の高速化を図ることも可能です(こちらは「 クエリーキャッシュ」という紛らわしい名前で呼ばれます)。

Propelの場合はそれ自体にはキャッシュ機能がなく、必要な場合は自力で頑張る必要がありました(Propel 1.5で、query_cacheビヘイビアが提供されるようになりました)。Doctrineの場合には当初から用意されていたキャッシュ機能が、特にsymfony 1.3/1.4で採用されたDoctrine 1.2において様々な改良が施され、十分採用検討に値するものになっています。

Doctrine 1.2のキャッシュ機能に関する公式のドキュメントは以下にあります。

利用可能なキャッシュドライバ

Doctrine 1.2には以下のキャッシュドライバが用意されています。

なお、この他にDoctrine_Cache_Arrayというドライバもあります。このドライバでは、キャッシュはドライバのプロパティに“保存”され、従ってリクエスト毎に破棄されてしまいますが、機能拡張やキャッシュストレージの準備が必要がないので、テスト用途などで気軽に利用できます。

キャッシュドライバの設定

Doctrineのキャッシュ機能を利用するには、Doctrine_ManagerやDoctine_ConnectionのsetAttribute()メソッドで、適切な属性値を設定します。Doctrine_Managerでの設定はすべてのコネクションでのデフォルトになります。Doctine_Connectionでの設定によってコネクション毎の設定を変更できます。

キャッシュ機能に関する属性には以下のものがあります。

属性キー 属性値
Doctrine_Core::ATTR_RESULT_CACHE 結果キャッシュ用のキャッシュドライバオブジェクト
Doctrine_Core::ATTR_RESULT_CACHE_LIFESPAN 結果キャッシュのデフォルトの存続秒数
Doctrine_Core::ATTR_QUERY_CACHE クエリーキャッシュ用のキャッシュドライバオブジェクト
Doctrine_Core::ATTR_QUERY_CACHE_LIFESPAN クエリーキャッシュのデフォルトの存続秒数

symfonyプロジェクト全体で共通で利用される設定は、ProjectConfigurationクラス(config/ProjectConfiguration.phpファイル)のconfigureDoctrine()メソッド内で行うとよいでしょう。

以下の例では、クエリーキャッシュと結果キャッシュの両方にmemcachedドライバを設定しています。また、それぞれのデフォルトの存続時間を1時間に設定しています。

class ProjectConfiguration extends sfProjectConfiguration
{
  public function setup()
  {
    $this->enablePlugins('sfDoctrinePlugin');
  }

  public function configureDoctrine(Doctrine_Manager $manager)
  {
    // 設定の配列を追加して、利用するmemcachedサーバを追加できます
    $servers = array(
        array(
          'host' => 'localhost',
          'port' => 11211,
          'persistent' => true),
        ); 
    $cacheDriver = new Doctrine_Cache_Memcache(array(
          'servers' => $servers,
          'compression' => false));
    $manager->setAttribute(Doctrine_Core::ATTR_QUERY_CACHE, $cacheDriver);
    $manager->setAttribute(Doctrine_Core::ATTR_QUERY_CACHE_LIFESPAN, 3600);
    $manager->setAttribute(Doctrine_Core::ATTR_RESULT_CACHE, $cacheDriver);
    $manager->setAttribute(Doctrine_Core::ATTR_RESULT_CACHE_LIFESPAN, 3600);
  }
}

クエリーキャッシュはキャッシュドライバが設定されていれば利用され、プログラマーがそれ以上留意しなければならない点はありません。一方結果キャッシュの場合は、プログラムにその利用を明示するコードを追加しない限り利用はされません。

結果キャッシュを利用する

結果キャッシュを利用するには、Doctrine_Query::useResultCache()メソッドを使用します。

$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true);
$user = $q->fetchOne('jonwage');

Doctrine_Query::useResultCache()メソッドには3ヶのパラメータがあります。

  1. キャッシュドライバ
    • TRUE: 既定のキャッシュドライバを使う
    • キャッシュドライバオブジェクト: そのキャッシュドライバを使う
    • NULL: キャッシュを利用しない
  2. キャッシュの存続秒数: 省略時はドライバの既定の秒数
  3. キャッシュのキー: 省略時は自動生成されるMD5ハッシュ値

なお、上の例のような単純なクエリーはDoctrine_Tableのファインダーメソッドで置き換え可能で、例えば上のクエリーの実行結果と同じデータは「UserTable::getInstance()->findByUsername('jonwage')」でも得られますが、メソッドをオーバーライドしたりイベントなどを利用する場合を除いて、今のことろファインダーメソッドで直接結果キャッシュを利用する方法は用意されていません。

結果キャッシュを削除する

キャッシュはキャッシュドライバのdelete()メソッドで、キャッシュキーを指定して削除できます。キーが既知の場合、つまりDoctrine_Query::useResultCache()メソッドの3番目のパラメータでキーを指定している場合には、この方法でキャッシュを削除できます。

$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true, null, 'user:jonwage');
$user = $q->fetchOne('jonwage');

// キーを指定してキャッシュを削除する
$cacheDriver = UserTable::getInstance()->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);
$cacheDriver->delete('user:jonwage');

キャッシュドライバには単一のキャッシュを削除するdelete()メソッドの他に、複数のキャッシュをまとめて削除する、deleteByPrefix()メソッドとdeleteByRegex()メソッドがあります。deleteByPrefix()メソッドでは指定の文字列に前方一致するキーのキャッシュが削除されます。deleteByRegex()メソッドでは指定の正規表現に合致するキーのキャッシュが削除されます。

Doctrine_Query::useResultCache()メソッドでキャッシュキーの指定が省略された場合は、DQLの内容に基づくMD5ハッシュ値がキーとなります。キャッシュキーはDoctrine_Query::getResultCacheHash()メソッドで取得できるので、キーの指定を省略した場合でも、キャッシュの生成時と同じDQLが再現できる場合は、以下のようなコードでキャッシュを削除できます。

$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true);
$user = $q->fetchOne('jonwage');

// DQLのキャッシュを削除する
$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?');
$q->getResultCacheDriver()->delete(
    $q->getResultCacheHash('jonwage'));

なお、Doctrine_Queryにはこれを簡便に行うclearResultCache()メソッドも用意されていますが、このメソッドはgetResultCacheHash()メソッドと異なり、今のところメソッドのパラメータでプレースホルダーの値を指定できません。従って、例えば、以下でclearResultCache()メソッドを実行している2つのコードの前者には、プレースホルダーの値(u.username = 'jonwage')がどこにも指定されていないため、正しいキャッシュを削除できません。後者ではWHERE句でプレースホルダーの値を指定しているので、正しいキャッシュが削除されます。

$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true);
$user = $q->fetchOne('jonwage');

// 正しいキャッシュを削除できません
$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?');
$q->clearResultCache();

// 正しいキャッシュが削除されます
$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?', 'jonwage');
$q->clearResultCache();

また、Doctrineではクエリー結果をハイドレートする方法が指定できますが、同じクエリーでもハイドレーションモードによってキャッシュキーは異なる点に注意してください。

// 以下の2つのクエリーのキャッシュキーは異なります

// オブジェクトにハイドレート
$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true);
$user = $q->fetchOne('jonwage');

// 配列にハイドレート
$q = Doctrine_Query::create()
  ->from('User u')
  ->where('u.username = ?')
  ->useResultCache(true);
$user = $q->fetchOne('jonwage', Doctrine_Core::HYDRATE_ARRAY);

イベントフックを利用する

Doctrineのイベントフックを利用すると、レコードの更新時にキャッシュを自動的に削除することができます。

以下の例は、userテーブルのクエリーの実行結果データが、「user:」で始まるキャッシュキーでキャッシュされているという想定で、このテーブルのレコードが更新/削除される際にキャッシュをまとめて削除します。

class User extends BaseUser
{   
  public function postSave(Doctrine_Event $event)
  {
    $this->clearResultCaches();
  }

  public function postDelete(Doctrine_Event $event)
  {
    $this->clearResultCaches();
  }

  protected function clearResultCaches()
  {
    $cacheDriver = $this->getTable()->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);
    if ($cacheDriver instanceOf Doctrine_Cache_Interface) {
      $cacheDriver->deleteByPrefix('user:');
    }
  }
}

以下の例は、userテーブルに関するすべてのクエリーの実行結果をキャッシュします。

class User extends BaseUser
{
  public function preDqlSelect(Doctrine_Event $event)
  {
    $cacheDriver = $this->getTable()->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);
    if ($cacheDriver instanceOf Doctrine_Cache_Interface) {
      $event->getQuery()->useResultCache(true);
    }
  }
}

ただし、このコードには注意すべき点があります。このコードでは、クエリー対象にこのテーブルが含まれるすべてのクエリーの実行結果がもれなくキャッシュされます。JOINなどによってこのテーブル以外にもクエリーの対象テーブルが存在するクエリーでは、結果キャッシュを利用したくないテーブルがその中に含まれないように配慮する必要があります。

なお、このコードではpreDqlSelect()フックを利用していますが、preDql*()フック(DQLコールバック)はDoctrine::ATTR_USE_DQL_CALLBACKS属性がTRUEの場合にのみ有効です。この属性値はデフォルトではFALSEで、TRUEに変更することで実行時に多少のオーバーヘッドが生じる点にも注意してください。

// ProjectConfiguration::configureDoctrine()メソッド内などで
$manager->setAttribute(Doctrine::ATTR_USE_DQL_CALLBACKS, true);

イベントリスナーを利用する

複数のテーブルのクラスに、共通のイベントフックコードをコピーペーストしてまわるのはDRYではありません。Doctrineではイベントリスナーやビヘイビアを利用することで、複数のテーブルクラスに共通の動作を一箇所で定義できます。

以下は、上で例に挙げた、あるテーブルに関するすべてのクエリーの実行結果をキャッシュするイベントフックをリスナーに仕立てたものです。

class myAlwaysUseResultCacheListener extends Doctrine_Record_Listener
{
  public function preDqlSelect(Doctrine_Event $event)
  {
    $cacheDriver = $event->getInvoker()->getTable()->getAttribute(Doctrine_Core::ATTR_RESULT_CACHE);
    if ($cacheDriver instanceOf Doctrine_Cache_Interface) {
      $event->getQuery()->useResultCache(true);
    }
  }
}

テーブルのクラスにイベントリスナーを設定するには、schema.ymlでlistenersにリスナーのクラス名を指定します。

User:
  listeners:
    myAlwaysUseResultCacheListener:
  actAs:
    Timestampable:
  columns:
    username: { type: string(20),  notnull: true, unique: true, regexp: "/^[0-9a-z]{3,20}$/" }
    password: { type: string(50),  notnull: true, minlength: 6 }
    email:    { type: string(255), notnull: true, unique: true, email: true }

Doctrine_Queryをオーバーライドする

これまでに見てきたように、Doctrineのキャッシュ機能の中核はDoctrine_Queryクラスに実装されています。Doctineには、Doctrine_Queryをその継承クラスに容易に切り替える手段が用意されており、必要であればその動作を変更することが可能です。

Doctrine_Queryを独自のクラスに切り替えるには、Doctrine_Core::ATTR_QUERY_CLASS属性にクラス名を設定します。Doctrine_Query::create()メソッドは、Doctrine_Core::ATTR_QUERY_CLASS属性に設定されたクラス名があれば、Doctrine_Queryではなく、設定されているクラスのオブジェクトを返します。

// ProjectConfiguration::configureDoctrine()メソッド内などで
$manager->setAttribute(Doctrine_Core::ATTR_QUERY_CLASS, 'myDoctrineQuery');

以下の例では、Doctrine_Query::getResultCacheHash()メソッドをオーバーライドして、キャッシュキーの先頭にクエリー対象のテーブル名を自動的に追加しています。なお、このコードでは、クエリー対象のテーブルが複数ある場合でも、キーに追加されるのはFROM句で指定されるテーブル名のみとなる点に注意してください。

class myDoctrineQuery extends Doctrine_Query
{
  public function getResultCacheHash($params = array())
  {
    $hash = parent::getResultCacheHash($params);
    if ( ! empty($this->_dqlParts['from'])) {
      $fromPart = $this->_dqlParts['from'];
    } elseif ( ! empty($this->_sqlParts['from'])) {
      $fromPart = $this->_sqlParts['from'];
    } else {
      return $hash;
    }   
    list($componentName) = explode(' ', trim($fromPart[0]), 2);
    $tableName = Doctrine_Inflector::tableize($componentName);
    return "$tableName:$hash";
  }
}

以下の例では、前者のキャッシュキーは「user:32bd63cf2cdef179ab2857bc357aeca7」のように、通常のMD5ハッシュ値の前に「user:」が付加されたものになります。後者の例では、useResultCache()メソッドのパラメータで「jonwage」というキャッシュキーを指定していますが、実際のキャッシュキーは「user:jonwage」となります。

// キャッシュキーが指定されていない場合
$q = Doctrine_Query::create()
  ->from('User u')
  ->leftJoin('u.Profile p')
  ->where('u.username = ?')
  ->useResultCache(true);
echo $q->getResultCacheHash('jonwage'); // user:32bd63cf2cdef179ab2857bc357aeca7

// キャッシュキーが指定されている場合
$q = Doctrine_Query::create()
  ->from('User u')
  ->leftJoin('u.Profile p')
  ->where('u.username = ?')
  ->useResultCache(true, null, 'jonwage');
echo $q->getResultCacheHash('jonwage'); // user:jonwage

イベントフックの項でuserテーブルのレコードが更新/削除される際に、キャッシュキーが「user:」で始まるキャッシュをまとめて削除する例を挙げましたが、このmyDoctrineQueryを利用することで、userテーブルのクエリーの実行結果データはもれなく「user:」で始まるキャッシュキーでキャッシュされるようになります。

ということで

ここに挙げたコードはいずれも、例としてわかりやすい簡単なものであることを優先しているため、実のところあまり実用的ではありません。例えば、キャッシュキーの設計は、キャッシュをきちんと制御する上で慎重に検討しなければならない重要な課題ですが、ここで例に選んでいるキーは安易に過ぎます。あるいは例えば、キャッシュドライバのdeleteBy*()メソッドは便利なものではあるのですが、その実装の都合上、膨大な数のキャッシュが存在し得るシステムでは、実用に耐えるパフォーマンスが発揮できるかは疑問です。実際のプログラミングで必要になるコードは、ここで挙げたものよりも多少なりとも複雑にならざるを得ないでしょうが、それでも、組み込みのキャッシュ機能と、イベントフックやビヘイビアなど、Doctrineが提供する多彩な機能を組み合わせることで、より効率的なキャッシュ処理が、より簡単に実現できる可能性があります。御用とお急ぎでない方は是非ご利用になってみてください。