21日目: キャッシュ

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

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

新しい環境を作成する

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

[yml]
prod:
  .settings:
    cache: true

dev:
  .settings:
    cache: false

test:
  .settings:
    cache: false

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

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

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

[php]
// 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://www.jobeet.com.localhost/frontend_cache.php/

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

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

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

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

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

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

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

$ php symfony cc

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

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

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

[yml]
default:
  enabled:     false
  with_layout: false
  lifetime:    86400

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

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

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

ページのキャッシュ

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

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

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

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

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

新しいキャッシュ

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

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

キャッシュ

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

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

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

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

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

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

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

[yml]
# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
new:
  enabled:     true

index:
  enabled:     true

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 モジュールのキャッシュコンフィギュレーションを次のように変更します:

[yml]
# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
new:
  enabled:     true

index:
  enabled:     true

all:
  with_layout: false

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

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

$ php symfony cc

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

アクションキャッシュ

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

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

~パーシャル|パーシャルテンプレート~と~コンポーネント~のキャッシュ

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

パーシャルキャッシュ

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

[yml]
# plugins/sfJobeetPlugin/modules/sfJobeetLanguage/config/cache.yml
_language:
  enabled: true

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

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

SIDEBAR ~コンテキスト依存|コンテキスト上のキャッシュ~であるか?

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

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

[yml]
_sidebar:
  enabled:    true
  contextual: true

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

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

CSRF とキャッシュ

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

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

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

[php]

// plugins/sfJobeetPlugin/lib/form/JobeetJobForm.class.php class JobeetJobForm extends BaseJobeetJobForm // plugins/sfJobeetPlugin/lib/form/doctrine/PluginJobeetJobForm.class.php abstract PluginJobeetJobForm extends BaseJobeetJobForm { public function configure() { $this->disableLocalCSRFProtection(); } }

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

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

[php]
// 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')));
    $this->form->disableLocalCSRFProtection();
  }
}

// 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')));
    $form->disableLocalCSRFProtection();

    // ...
  }
}

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

~キャッシュを削除する|キャッシュの削除~

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

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

[yml]
# plugins/sfJobeetPlugin/modules/sfJobeetJob/config/cache.yml
index:
  enabled:  true
  lifetime: 600

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

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

[php]
// 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 ブロックでキャッシュの削除処理を囲みました。

~キャッシュをテストする|テスト (キャッシュ)~

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

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

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

[php]
// test/functional/frontend/jobActionsTest.php
$browser->
  info('  7 - Job creation page')->

  get('/fr/')->
  with('view_cache')->isCached(true, false)->

createJob(array('categoryid' => $browser->getProgrammingCategory()->getId()), true)-> createJob(array('categoryid' => Doctrine_Core::getTable('CategoryTranslation')->findOneBySlug('programming')->getId()), true)->

  get('/fr/')->
  with('view_cache')->isCached(true, false)->
  with('response')->checkElement('.category_programming .more_jobs', '/23/')
;

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

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

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

また明日

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

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

ORM