Caution: You are browsing the legacy symfony 1.x part of this website.

22日目: キャッシュ

1.2 / Propel

今日は、キャッシュの話をします。symfonyフレームワークには、多くのキャッシュ機構が組み込まれています。 たとえば、YAML設定ファイルは最初にPHPに変換され、ファイルシステムにキャッシュされます。 adminジェネレーターによって生成されたモジュールが、パフォーマンスを向上させるためにキャッシュされることもすでに見てきました。

しかし今日は、別のキャッシュ: HTMLキャッシュについて話します。 Webサイトのパフォーマンスを改善するために、HTMLページ全体もしくは一部だけをキャッシュできます。

新しい環境を作成する

symfonyのテンプレートキャッシュ機能は、デフォルトのsettings.yml設定ファイルのprod環境では有効ですが、testdev環境では有効になっていません:

prod:
  .settings:
    cache: on
 
dev:
  .settings:
    cache: off
 
test:
  .settings:
    cache: off

運用に移行する前にキャッシュ機能をテストする必要があるので、dev環境用のキャッシュを有効にするか新しい環境を作ります。 環境は名前(文字列)、関連するフロントコントローラー、オプションとして固有の設定値のセットによって定義されることを思い出しましょう。

Jobeetでキャッシュシステムを試すために、cache環境を作ります。 cache環境はprod環境と似ていますが、dev環境で利用可能なログとデバッグ情報も有効にします。

dev環境のフロントコントローラーであるweb/frontend_dev.phpweb/frontend_cache.phpにコピーして、新しいcache環境用のフロントコントローラーを作ります:

// web/frontend_cache.php
if (!in_array(@$_SERVER['REMOTE_ADDR'], array('127.0.0.1', '::1')))
{
  die('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.');
}
 
require_once(dirname(__FILE__).'/../config/ProjectConfiguration.class.php');
 
$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'cache', true);
sfContext::createInstance($configuration)->dispatch();

作業はこれだけです。 これで新しいcache環境を利用できます。 唯一の違いは、getApplicationConfiguration()メソッドの2番目の引数で環境の名前がcacheになっていることです。

ブラウザーでこのフロントコントローラーを呼び出すことでcache環境をテストできます:

http://jobeet.localhost/frontend_cache.php/

note

フロントコントローラースクリプトの先頭には、ローカルのIPアドレスからのみ呼び出されることを保証するコードがあります。 このセキュリティ対策は運用サーバーでフロントコントローラーが呼び出されないようにするためです。 明日のチュートリアルでより詳しく話します。

現在は、cache環境はデフォルトのコンフィギュレーションを継承しています。 settings.yml設定ファイルを編集して、cache環境固有のコンフィギュレーションを追加します:

# apps/frontend/config/settings.yml
cache:
  .settings:
    error_reporting: <?php echo (E_ALL | E_STRICT)."\n" ?>
    web_debug:       on
    cache:           on
    etag:            off

この設定では、cache設定でsymfonyのテンプレートキャッシュ機能を有効にし、web_debug設定でWebデバッグツールバーを有効にしています。

また、SQLステートメントのロギングを行うために、データベースのコンフィギュレーションを変更します。 databases.ymlを編集してファイルの始めに次の設定を追加します:

# config/databases.yml
cache:
  propel:
    class: sfPropelDatabase
    param:
      classname: DebugPDO

デフォルトのコンフィギュレーションでは、すべての設定をキャッシュするので、キャッシュをクリアするまで設定の変更が有効になりません:

$ php symfony cc

ブラウザーでページを更新すると、dev環境と同じようにWebデバッグツールバーがページ右上に表示されます。

キャッシュのコンフィギュレーション

symfonyのテンプレートキャッシュはcache.yml設定ファイルで設定できます。 アプリケーションごとのデフォルトコンフィギュレーションはapps/frontend/config/cache.ymlにあります:

default:
  enabled:     off
  with_layout: false
  lifetime:    86400

symfonyで扱うすべてのページは動的な情報を持つことができるので、デフォルトでは、グローバルなキャッシュは無効に設定されています(enabled: off)。 ページごとにキャッシュを有効にする予定なので、今回はグローバルな設定を変更する必要はありません。

lifetime設定には、サーバーサイドのキャッシュの有効期間を秒単位で定義します(86400秒は1日に等しい)。

tip

次善策があります: グローバルなキャッシュを有効にして、キャッシュさせたくない特定のページでのみキャッシュを無効にします。 開発するアプリケーションで、作業が少なくて済む方法を選択してください。

ページのキャッシュ

JobeetのホームページはWebサイトの中でおそらくもっとも訪問されるページになりますので、ユーザーがアクセスするたびにデータベースにデータをリクエストする代わりにページをキャッシュしましょう。

sfJobeetJobモジュール用のcache.ymlファイルを作ります:

# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
index:
  enabled:     on
  with_layout: true

tip

cache.yml設定ファイルには、view.ymlと同じようなsymfonyの設定ファイルのプロパティがあります。 例えば、特別なallキーを使うと、モジュールのすべてのアクションに対してキャッシュを有効にできます。

ブラウザーでページを更新すると、コンテンツがキャッシュされたことを示すボックスが表示されているのがわかります:

新しいキャッシュ

ボックスには、キャッシュの有効期間や経過時間など、キャッシュキーに関するデバッグのための貴重な情報が表示されます。

ページを再度更新すると、ボックスの色が緑から黄色に変わります。 これはページがキャッシュから読み込まれたことを示します:

キャッシュ

2番目のケースでは、Webデバッグツールバーで示されるように、データベースへのリクエストが行われなかったことがわかります。

tip

言語をユーザーごとに変更できる場合でも、言語をURLに埋め込むようにすればキャッシュは機能します。

ページがキャッシュ可能で、キャッシュがまだ存在しない場合、symfonyはリクエスト処理の最後でレスポンスオブジェクトをキャッシュに保存します。 これ以降のすべてのリクエストでは、symfonyはコントローラーを呼び出さずにキャッシュされたレスポンスを送信します:

ページキャッシュのフロー

JMeterのようなツールを利用することで、パフォーマンスが大きく変化することを自分自身で測定できます。

note

パラメーターを伴っているGETリクエストや、POSTPUTDELETEメソッドで投稿されるリクエストは、コンフィギュレーションにかかわらずsymfonyではキャッシュされません。

求人作成ページもキャッシュできます:

# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
new:
  enabled:     on
 
index:
  enabled:     on
 
all:
  with_layout: true

2つのページともにレイアウト付きでキャッシュできるので、sfJobeetJobモジュールのすべてのアクション用のデフォルトコンフィギュレーションを定義するallセクションを作りました。

キャッシュをクリアする

ページのキャッシュをクリアしたい場合、cache:clearタスクを使います:

$ php symfony cc

cache:clearタスクでは、メインのcache/ディレクトリに保存されたすべてのキャッシュがクリアされます。 このタスクには、キャッシュの一部を指定してクリアするためのオプションもあります。 cache環境用のテンプレートキャッシュのみをクリアするには、--type--envオプションを指定します:

$ php symfony cc --type=template --env=cache

変更を行うたびにキャッシュをクリアする代わりに、クエリ文字列をURLに追加するか、Webデバッグツールバーから"Ignore cache"ボタンを使うことでキャッシュを無効にできます:

Webデバッグツールバー

アクションキャッシュ

ページ全体のキャッシュはできなくても、アクションテンプレートのキャッシュが可能な場合もあります。 それでは、レイアウト以外のすべてをキャッシュする例をみてみましょう。

Jobeetアプリケーションでは、"history job"バーがあるためにページ全体をキャッシュできません。

jobモジュールのキャッシュコンフィギュレーションを次のように変更します:

# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
new:
  enabled:     on
 
index:
  enabled:     on
 
all:
  with_layout: false

with_layout設定をfalseに変更することで、レイアウトのキャッシュを無効にしました。

キャッシュをクリアします:

$ php symfony cc

違いを確認するためにブラウザーのページを更新します:

アクションキャッシュ

簡略化されたダイアグラムではリクエストのフローがレイアウトありの場合とよく似ていますが、レイアウトなしのキャッシュでは、はるかにリソースを集約します。

アクションキャッシュのフロー

パーシャルとコンポーネントのキャッシュ

高度に動的なサイトでは、アクションテンプレート全体をキャッシュできない場合があります。 これらのケースでは、キャッシュをきめ細かく設定する方法が必要になります。 ありがたいことに、パーシャルとコンポーネントもキャッシュできます。

パーシャルキャッシュ

languageコンポーネントをキャッシュできるように、sfJobeetLanguageモジュール用のcache.ymlファイルを作りましょう:

# plugins/sfJobeetPlugin/modules/sfJobeetLanguage/config/cache.yml
_language:
  enabled: on

パーシャルもしくはコンポーネントに対してキャッシュを設定するには、設定ファイルに名前つきのエントリーを追加します。 この種類のキャッシュでは、with_layoutオプションは意味がないので指定しても無視されます:

パーシャルとコンポーネントキャッシュのフロー

sidebar

コンテキスト依存であるか?

多くの異なるテンプレートから、同じコンポーネントもしくはパーシャルを使うことができます。 たとえばjobの_list.phpパーシャルはsfJobeetJobsfJobeetCategoryモジュールで使われます。 レンダリングは常に同じなので、パーシャルは使われるコンテキストに依存せず、キャッシュはすべてのテンプレートに対して同じです(キャッシュは異なるパラメーターに対して明らかに異なります)。

しかし、パーシャルもしくはコンポーネントの出力は、含まれるアクションに基づいて異なる場合があります。 たとえばblogのサイドバーは、ホームページとblog投稿ページでは微妙に違います。 このような場合パーシャルもしくはコンポーネントはコンテキスト依存なので、キャッシュの設定でcontextualオプションをtrueにセットする必要があります:

_sidebar:
  enabled:    on
  contextual: true

フォームにおけるキャッシュ

求人作成ページにはフォームが含まれるので、キャッシュに保存することには問題があります。 問題をよりわかりやすくするために、ブラウザーで"Post a Job"ページに移動してキャッシュを生成させます。 それから、セッションCookieをクリアし、求人の投稿を試します。 "CSRF攻撃"を警告するエラーメッセージが表示されます:

CSRFとキャッシュ

なぜでしょうか? フロントエンドアプリケーションを作成したときCSRF用の秘密の文字列を設定したので、symfonyはすべてのフォームにCSRFトークンを埋め込みます。 CSRF攻撃を防ぐために、このトークンはユーザーとフォームに対してユニークです。

最初にページが表示されるときに、生成されたHTMLフォームは現在のユーザーのトークンとともにキャッシュに保存されます。 次に別のユーザーがフォームにアクセスすると、キャッシュからのページは最初のユーザーのCSRFトークンのまま表示されます。 フォームを投稿すると、トークンが一致せずエラーが表示されます。

フォームをキャッシュに保存するのは適切だと思われますが、この問題を修正するにはどうしたらよいでしょうか? 求人作成フォームはユーザーに依存せず、現在のユーザーに対して何も変更しません。 このような場合、CSRFの防御が不要なので、CSRFトークンを完全に削除できます:

// plugins/sfJobeetPlugin/lib/form/JobeetJobForm.class.php
class JobeetJobForm extends BaseJobeetJobForm
{
  public function __construct(BaseObject $object = null, $options = array(), $CSRFSecret = null)
  {
    parent::__construct($object, $options, false);
  }
 
  // ...
}

この変更の後で、期待通りに動作するか検証するために、キャッシュをクリアして上記のシナリオを繰り返してください。

レイアウトには言語フォームも含まれておりキャッシュに保存されるので、言語フォームにも同じコンフィギュレーションを適用する必要があります。 デフォルトのsfLanguageFormを使い、新しいクラスを作る代わりにCSRFトークンを削除します。 sfJobeetLanguageモジュールのアクションとコンポーネントで次のように変更します:

// plugins/sfJobeetPlugin/modules/sfJobeetLanguage/actions/components.class.php
class sfJobeetLanguageComponents extends sfComponents
{
  public function executeLanguage(sfWebRequest $request)
  {
    $this->form = new sfFormLanguage($this->getUser(), array('languages' => array('en', 'fr')));
    unset($this->form[$this->form->getCSRFFieldName()]);
  }
}
 
// plugins/sfJobeetPlugin/modules/sfJobeetLanguage/actions/actions.class.php
class sfJobeetLanguageActions extends sfActions
{
  public function executeChangeLanguage(sfWebRequest $request)
  {
    $form = new sfFormLanguage($this->getUser(), array('languages' => array('en', 'fr')));
    unset($form[$form->getCSRFFieldName()]);
 
    // ...
  }
}

getCSRFFieldName()メソッドはCSRFトークンを含むフィールドの名前を返します。 このフィールドをunsetすると、ウィジェットと関連するバリデーターは削除されます。

キャッシュを削除する

ユーザーが求人を投稿してアクティベートするたびに、新しい求人を一覧に表示するためにホームページをリフレッシュしなければなりません。

リアルタイムで求人をホームページに表示させる必要はないので、ベストな戦略はキャッシュの有効期間を短くすることです:

# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
index:
  enabled:  on
  lifetime: 600

デフォルトの設定の1日ではなく10分ごとに、ホームページのキャッシュは自動的に削除されます。

ユーザーが新しい求人をアクティベートしたらすぐにホームページを更新したい場合は、sfJobeetJobモジュールのexecutePublish()メソッドを編集してキャッシュをクリアする機能を追加します:

// plugins/sfJobeetPlugin/modules/sfJobeetJob/actions/actions.class.php
public function executePublish(sfWebRequest $request)
{
  $request->checkCSRFProtection();
 
  $job = $this->getRoute()->getObject();
  $job->publish();
 
  if ($cache = $this->getContext()->getViewCacheManager())
  {
    $cache->remove('sfJobeetJob/index?sf_culture=*');
    $cache->remove('sfJobeetCategory/show?id='.$job->getJobeetCategory()->getId());
  }
 
  $this->getUser()->setFlash('notice', sprintf('Your job is now online for %s days.', sfConfig::get('app_active_days')));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

キャッシュはsfViewCacheManagerクラスによって管理されます。 remove()メソッドは、引数で指定した内部URIに関連するキャッシュを削除します。 変数の可能なすべてのパラメーターに対するキャッシュを削除するには、*を値として使います。 上記のコードで使ったsf_culture=*は、symfonyが英語とフランス語のホームページ用のキャッシュを削除することを意味します。

キャッシュが無効なときキャッシュマネージャーはnullなので、ifブロックでキャッシュの削除処理を囲みました。

sidebar

sfContextクラス

sfContextオブジェクトはリクエスト、レスポンス、ユーザーなどのsymfonyのコアオブジェクトへの参照を保持します。 sfContextはSingletonのようにふるまいます。 sfContext::getInstance()ステートメントを使って任意の場所から取得でき、symfonyコアオブジェクトにアクセスできます:

$user = sfContext::getInstance()->getUser();

自分のクラスの1つでsfContext::getInstance()を使いたくなったときは、密結合になってしまうためよく考え直すべきです。 必要なオブジェクトを引数として渡すほうがよいでしょう。

sfContextをレジストリとして利用し、set()メソッドを使ってオブジェクトを追加することもできます。 このメソッドは引数として名前とオブジェクトを受け取ります。 この名前は、後でget()メソッドを使ってオブジェクトを読み取るために使います:

sfContext::getInstance()->set('job', $job);
$job = sfContext::getInstance()->get('job');

キャッシュをテストする

テストを始める前に、キャッシュレイヤーを有効にするためにtest環境のコンフィギュレーションを変更します:

# apps/frontend/config/settings.yml
test:
  .settings:
    error_reporting: <?php echo ((E_ALL | E_STRICT) ^ E_NOTICE)."\n" ?>
    cache:           on
    web_debug:       off
    etag:            off

求人作成ページをテストしましょう:

// test/functional/frontend/jobActionsTest.php
$browser->
  info('  7 - Job creation page')->
 
  get('/fr/')->
  with('view_cache')->isCached(true, false)->
 
  createJob(array('category_id' => $browser->getProgrammingCategory()->getId()), true)->
 
  get('/fr/')->
  with('view_cache')->isCached(true, false)->
  with('response')->checkElement('.category_programming .more_jobs', '/23/')
;

view_cacheテスターはキャッシュをテストするために使います。 isCached()メソッドは2つのbooleanを受け取ります:

  • ページがキャッシュされているかどうか
  • レイアウト付きのキャッシュかどうか

tip

機能テストフレームワークによって提供されるすべてのツールがあるにせよ、ブラウザーで問題を診断するほうが簡単であることがあります。 これを実行するのは簡単でtest環境用のフロントコントローラーを作るだけです。 log/frontend_test.logに保存されるログも非常に役立ちます。

また明日

他の多くのsymfonyの機能のように、symfonyのキャッシュサブフレームワークはとても柔軟なので、開発者はキャッシュをきめ細かく設定できます。

明日は、アプリケーションのライフサイクルの一番最後のステップ: 運用サーバーへのデプロイについて話します。