conditionalCacheFilterをより一般的に考えてみる
はじめに
symfonyでアプリケーションを作ろうとする際にキャッシュを使っていますか?キャッシュと一言に言ってもいろんなレイヤーでキャッシュを実現する方法があるので、なんとも答えに困る質問ですね。しかし、webアプリを作る際に言われているキャッシュであれば、
- apcなどでphpスクリプトをキャッシュ
- ページ出力内容を静的ファイルもしくは、メモリ上にキャッシュ
- クライアントのブラウザ側に残してもらうキャッシュ
等が一般的に考えられるのではないかと思います。おそらく、opcodeに関しては、多くの人はapcなどを使うとしても、チューニングの設定等を意識する必要はあると思いますが、実際に中でどう動いているのかを考えることが少ないと思います。「普通のやつらの下を行く」ことを目的とするユーザ(下のレイヤーで勝負するという意味)はそちらを調べてもらうとして、Webアプリ作成屋としては一番腕の見せ所は2番目ではないかと思います。つまり、どの出力をキャッシュ化して、どの出力をキャッシュ化しない、といったようなことです。そして、キャッシュを絶妙のタイミングでクリアさせることです。これによって、データベースへの接続の際のクエリーが激減させることができますので、大幅なパフォーマンスの改善が望めるからです。
今回のポストでは、この2番目の内容をもう少し詳細まで扱おうと思います。
問題
symfonyでは、cacheをサポートしており、cache.ymlで指定させることによって、次に上げるようなページキャッシュが可能となっています。
- layoutを含むキャッシュなのか、そうでないのか、という指定
- キャッシュの有効期間の指定
- partial, component等のページ全体ではなくて、一部のみのキャッシュの指定
これでだいたいの場合はキャッシュの使用として解決ができると思います。しかし実際にアプリケーションを作成していると、もう少し詳細なキャッシュ機構があったらいいな、というときがあります。たとえば、Jobeetのfeedの章であったようなことです。つまり、同じactionを使用するが、sf_formatの値によって、出力をatomにするのか、htmlにするのか、切り分ける場合です。atomの出力とhtmlの出力で同じキャッシュの指定をしたいですか?もし、同じでいいのであればいいのですが、私は分けたいと思いました。htmlの方ではログインしているかどうかも関係してくるのですが、atomの場合は、ログインしていようがいまいが同じ出力を返すからです。
同様に、ログインしているかどうかによって切り分けるページもあるでしょう。例えば、ログインしていると、ヘッダに、「hogehogeさん」って名前が出てくるようなものです。見せるコンテンツはログインしていようがいまいが同じだけども、ログインしていない際にはページ全体をキャッシュさせたいと思いますし、ログインしていたら、キャッシュ化させる内容は分けたいと思います。現在は、同じページでwith_layoutとwithout_layoutで切り分けることはできません。なので、ログインしてない際にはキャッシュを有効にさせ、ログインしている際にはページを無効にさせるという方法を取ります。
これらの内容は、cache.ymlだけでは実現が不可能ですので、他の方法が必要となります。
方法
symfonyにおいて、キャッシュを使う方法はいくつか用意されています。一番大きな方法としては、cache.ymlで指定することです。次にviewの中でキャッシュを使用するかどうかを指定することでしょう。さらにcacheManagerを通してキャッシュのロジックを変更することができます。キャッシュを自分の好きなタイミングでクリアしたり、有効にしたりすることができます。このcacheManagerを通してキャッシュを使用するには、filterが一番相性がいいようです。クリアするのは、データの変更後などになりますので、actionに書くことになってしまいますが、requestによってキャッシュを使用するしないを変更するにはfilterを使うことになります。The Definitive Guilde to symfonyのcacheの章のconditionalCacheFilterのような方法です。今回のポストでは、このconditionalCacheFilterをもう少し拡張してみることにします。
ソースコード
今回使用したsymfonyのバージョンは、1.3.0-ALPHA2です。1.2系は同じようにできると思いますが、1.0系はおそらく実装が異なると思います。
/apps/your_apps/config/filters.yml
rendering: ~
security: ~
# insert your own filters here
conditionalCacheFilter:
class: conditionalCacheFilter
param:
pages:
# - { module: post, action: index, format: [atom,xml] }
# - { module: post, action: index, format: [xml], enabled: false }
- { module: post, format: [xml], enabled: true , is_authenticated: false }
cache: ~
execution: ~
/apps/your_app/lib/conditionalCacheFilter.class.php
class conditionalCacheFilter extends sfFilter
{
protected
$cacheManager = null,
$request = null,
$user = null,
$uri = null,
$defaultLifetime = null;
public function initialize($context, $parameters = array())
{
parent::initialize($context, $parameters);
$this->cacheManager = $context->getViewCacheManager();
$this->request = $context->getRequest();
$this->user = $context->getUser();
$this->uri = $context->getRouting()->getCurrentInternalUri();
$lifetime = (isset($this->cacheManager)) ? $this->cacheManager->getLifeTime($this->uri) : 0;
$this->defaultLifetime = ($lifetime > 0) ? $lifetime : 86400;
}
public function execute($filterChain)
{
if ((!isset($this->cacheManager)) or !($this->getParameter('pages', false))) {
$filterChain->execute();
return;
}
foreach ($this->getParameter('pages') as $page) {
$module = isset($page['module']) ? $page['module'] : null;
$action = isset($page['action']) ? $page['action'] : $this->request->getParameter('action');
$format = isset($page['format']) ? $page['format'] : array();
if (!is_array($format)) {
$format = array($format);
}
$is_authenticated = isset($page['is_authenticated']) ? $page['is_authenticated'] : null;
// maybe better to throw exception?
if (is_null($module)) {
continue;
}
// skip
if ($module !== $this->request->getParameter('module')) {
continue;
}
// skip
if (!empty($format) and !in_array($this->request->getParameter('sf_format', 'html'), $format)) {
continue;
}
if (is_null($is_authenticated) or ($is_authenticated === $this->user->isAuthenticated())) {
// remove
if (isset($page['enabled']) and $page['enabled'] === false) {
$this->cacheManager->remove($this->uri);
continue;
}
//add
$this->cacheManager->addCache($module, $action, array(
'lifeTime' => isset($page['lifetime']) ? $page['lifetime'] : $this->defaultLifetime
));
}
}
$filterChain->execute();
}
}
解説
filters.ymlにて、cache filterが通る前にconditionalCacheFilterを呼びます。filterに渡すことのできるパラメターとしては、The Definitive Guilde to symfonyのcacheの章にあるようにpagesとします。そして、そこに配列として指定したいモジュール名とアクション(ない場合はそのモジュールに属すすべてのアクション)format, enabled, is_authenticated, lifetimeなどのパラメターを指定できるようにしました。is_authenticatedに関しては3つのパターンがありますね。つまり、認証済みの場合、非認証の場合、認証しているかどうかは関係のない場合です。
また、symfony1.3からは、yamlのバージョンの扱いが1.2になりますので、on/off, yes/noは使えなくなりますので、気をつけてください。true/falseで切り分けるようにしましょう。
そして、conditionalCacheFilter.class.phpにおいては、それぞれのパラメターによってキャッシュの使用について分けるようにしました。cacheManagerに関しましては、キャッシュを有効化していないとnullを持ちますので、そのチェックが必要です。デフォルトではenableとなっているページもこのfilters.ymlでenabledをfalseに指定することにより、毎回キャッシュをクリアするようにできますので、より柔軟にキャッシュの変更ができるようになっています。
まとめ
今回のポストでは、symfonyのキャッシュフレームワークを使用する際に役に立つtipsとしてconditionalCacheFilterを一般的な使用方法で使えるように拡張しました。この拡張によって、次の2つが可能となりました。
- sf_formatによってキャッシュをするかどうかを切り分ける
- ユーザが認証済みかどうかでキャッシュをするかどうかを切り分ける
sf_formatによってキャッシュをするかどうかを切り分けることによって、例えば、feedのようなxml形式のものはある程度キャッシュをしつつ、アプリケーションとしてwebで表示されるviewであるhtml形式のものはキャッシュを使用しなくするということが可能となります。xml以外にも考えられるものはjsonなどのAPI提供です。これらは特別な理由がない限りキャッシュ化されたものを返すのがいいと考えています。
また、ユーザが認証済みかどうかでキャッシュをするかどうかを切り分けることができることにより、認証をしていないユーザに返すレスポンスに速くなることでしょう。これはWebサイトに訪問してくれたユーザのみならず、Googleなどの検索エンジンのクローラーにも言えることです。クローラーによってアクセスさせるものに毎回DBアクセスをさせるのではなく、キャッシュを返して、定期的にそのキャッシュを更新させることによって、負荷を下げましょう。
拡張しようと思えば、その余地を残してくれているのがsymfonyフレームワークです。今回のconditonalCacheFilterは、特定のアプリケーションに依存するというよりは、より一般的なアプリケーションに必要な機能だと考えていますので、plugin化して、置いておくのもいいかもしれません。もちろんバグがあることもあると思いますので、それに関しては教えてください。
Shin Ohno 2003-2012