ステップ 21: パフォーマンス向上のためにキャッシュする

5.0 version
Maintained

パフォーマンス向上のためにキャッシュする

パフォーマンスに関する問題はよく訪れます。典型的な例だと、データベースのインデックスの欠如や、ページあたりの大量のSQL発行などです。空のデータベースでは発生しませんが、トラフィックが増えデータが増加すると、発生する可能性があります。

HTTPキャッシュヘッダーの追加

HTTPキャッシュを利用することは、ほとんど労力をかけずにエンドユーザーへのパフォーマンス向上を最大限に引き出せるすぐれた戦略です。本番環境でリバースプロキシキャッシュを追加して有効にし、CDN を使用してキャッシュすることで、さらに優れたパフォーマンスを実現します。

ホームページを1時間キャッシュしてみましょう:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -37,9 +37,12 @@ class ConferenceController extends AbstractController
      */
     public function index(ConferenceRepository $conferenceRepository)
     {
-        return new Response($this->twig->render('conference/index.html.twig', [
+        $response = new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     /**

setSharedMaxAge() メソッドはリバースプロキシのキャッシュ有効期限を設定します。ブラウザのキャッシュを制御するには setMaxAge() メソッドを使用します。時間は秒単位で設定します。(1時間 = 60分 = 3,600秒)

カンファレンスページのキャッシュはより動的なため、難しいです。誰でもいつでもコメントを追加できますが、誰もが1時間待つことを望んでいません。そのような場合は、 HTTP validation を利用します。

Symfony HTTP Cache Kernelをアクティベートする

Symfony HTTP リバースプロキシを利用して、HTTPキャッシュをテストします:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/public/index.php
+++ b/public/index.php
@@ -1,6 +1,7 @@
 <?php

 use App\Kernel;
+use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
 use Symfony\Component\ErrorHandler\Debug;
 use Symfony\Component\HttpFoundation\Request;

@@ -21,6 +22,11 @@ if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false
 }

 $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
+
+if ('dev' === $kernel->getEnvironment()) {
+    $kernel = new HttpCache($kernel);
+}
+
 $request = Request::createFromGlobals();
 $response = $kernel->handle($request);
 $response->send();

本格的なHTTPリバースプロキシであることに加え、Symfony HTTPリバースプロキシ( HttpCache クラスを介します)はHTTPヘッダーとして有用なデバッグ情報を追加します。これは、設定したキャッシュヘッダーの検証に大いに役立ちます。

ホームページ上で確認する:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 0
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store
content-length: 50978

最初のリクエストに対して、キャッシュサーバーはキャッシュが miss で、レスポンスをキャッシュするために store を実行したことを返します。cache-control ヘッダーをチェックして、設定されたキャッシュ方法を確認しましょう。

以降のリクエストでは、レスポンスがキャッシュされています。( age も更新されています。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 143
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest: en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: fresh
content-length: 50978

ESIを使用してSQLリクエストを回避する

TwigEventSubscriber リスナーは、Twigにグローバル変数と一緒に全てのカンファレンスオブジェクトをWebサイトの各ページごとに注入します。それはおそらく、最適化のための大きな狙いどころとなります。

毎日新しいデータを追加するわけではないので、このコードはデータベースから毎回全く同じデータを取得することになります。

Symfony Cacheを使用してカンファレンスの名前とスラッグをキャッシュすることもできますが、可能であればHTTPキャッシュインフラストラクチャへ依存するのが望ましいです。

ページの一部をキャッシュしたい場合は、 サブリクエストを作成して、現在のHTTPリクエストから別にします。 ESI はこのユースケースに最適です。ESIはHTTPリクエストの結果を別のリクエストに埋め込むことができます。

カンファレンス情報のHTMLの一部のみを返すコントローラーを作成する:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -45,6 +45,16 @@ class ConferenceController extends AbstractController
         return $response;
     }

+    /**
+     * @Route("/conference_header", name="conference_header")
+     */
+    public function conferenceHeader(ConferenceRepository $conferenceRepository)
+    {
+        return new Response($this->twig->render('conference/header.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
+    }
+
     /**
      * @Route("/conference/{slug}", name="conference")
      */

対応するテンプレートを作成する:

templates/conference/header.html.twig
1
2
3
4
5
<ul>
    {% for conference in conferences %}
        <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
    {% endfor %}
</ul>

/conference_header にアクセスし、全てが正常に動作しているか確認しましょう。

トリックを明らかにする時が来ました!先ほど作成したControllerで呼び出しているTwigテンプレートを更新しましょう。

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,11 +8,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            <ul>
-            {% for conference in conferences %}
-                <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
-            {% endfor %}
-            </ul>
+            {{ render(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

それだけです。ページを更新しても引き続き同じものが表示されます。

ちなみに

Symfony プロファイラの"Request / Response" を開き、メインリクエストとサブリクエストの詳細を確認します。

これで、ブラウザでページにアクセスするたびに、ヘッダー用とメインページ用の2つのHTTPリクエストが実行されます。 パフォーマンスが低下しました。 おめでとう!

現在、カンファレンスヘッダーのHTTPコールはSymfonyによって内部的に行われています。よってHTTPラウンドトリップは関係ありません。これはHTTPキャッシュヘッダーを使う方法がないことも意味します。

ESIを使って リアルな HTTPコールへ変換しましょう。

まずは、ESIサポートを有効化します:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -10,7 +10,7 @@ framework:
         cookie_secure: auto
         cookie_samesite: lax

-    #esi: true
+    esi: true
     #fragments: true
     php_errors:
         log: true

つぎに、 render のかわりに render_esi を使用します :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,7 +8,7 @@
     <body>
         <header>
             <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
-            {{ render(path('conference_header')) }}
+            {{ render_esi(path('conference_header')) }}
             <hr />
         </header>
         {% block body %}{% endblock %}

SymfonyがESIの処理方法を知っているリバースプロキシを検出した場合、自動的にESIサポートを有効にします(そうでない場合、フォールバックしてサブリクエストを同期的にレンダリングします)。

SymfonyリバースプロキシはESIをサポートしているか、ログを確認しましょう(最初にキャッシュを削除します。次節の"キャッシュ削除" を参照ください)。

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
HTTP/2 200
age: 0
cache-control: must-revalidate, no-cache, private
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:20:05 GMT
expires: Mon, 28 Oct 2019 08:20:05 GMT
x-content-digest: en4dd846a34dcd757eb9fd277f43220effd28c00e4117bed41af7f85700eb07f2c
x-debug-token: 719a83
x-debug-token-link: https://127.0.0.1:8000/_profiler/719a83
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store; GET /conference_header: miss
content-length: 50978

数回再読み込みします。 / レスポンスはキャッシュされていますが /conference_header はキャッシュされていません。ページ全体をキャッシュしながら一部を動的に保つことを成し遂げました!

しかしながら、これは私たちが望むものではありません。他のページとは別に、ヘッダーページを1時間キャッシュしましょう:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -50,9 +50,12 @@ class ConferenceController extends AbstractController
      */
     public function conferenceHeader(ConferenceRepository $conferenceRepository)
     {
-        return new Response($this->twig->render('conference/header.html.twig', [
+        $response = new Response($this->twig->render('conference/header.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
+        $response->setSharedMaxAge(3600);
+
+        return $response;
     }

     /**

キャッシュは両方のリクエストで有効になりました:

1
$ curl -s -I -X GET https://127.0.0.1:8000/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP/2 200
age: 613
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 07:31:24 GMT
x-content-digest: en15216b0803c7851d3d07071473c9f6a3a3360c6a83ccb0e550b35d5bc484bbd2
x-debug-token: cfb0e9
x-debug-token-link: https://127.0.0.1:8000/_profiler/cfb0e9
x-robots-tag: noindex
x-symfony-cache: GET /: fresh; GET /conference_header: fresh
content-length: 50978

x-symfony-cache ヘッダーは2つの要素を含みます。メインの / リクエストとサブリクエスト(ESIの conference_header )です。両方ともキャッシュ( fresh )にあります。

キャッシュ設定は、メインページやESIとは異なる設定を行うこともできます。 "about" ページは、キャッシュに1週間保存し、1時間ごとにヘッダーを更新することもできます。

不要になったリスナーを削除します:

1
$ rm src/EventSubscriber/TwigEventSubscriber.php

テストのためのHTTPキャッシュ削除

ブラウザでのテスト、もしくは自動化したテストには、キャッシュされている箇所は多少難しくなります。

var/cache/dev/http_cache ディレクトリ内を全て消すことで、全てのHTTPキャッシュを削除することができます。

1
$ rm -rf var/cache/dev/http_cache/

一部のURLのみキャッシュを無効にする場合や、ファンクショナルテストでキャッシュを無効化したい場合、前述のやり方ではうまく機能しません。小さな管理画面を追加して、いくつかのURLをキャッシュ無効にしてみましょう。

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -6,8 +6,10 @@ use App\Entity\Comment;
 use App\Message\CommentMessage;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\KernelInterface;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
@@ -54,4 +56,19 @@ class AdminController extends AbstractController
             'comment' => $comment,
         ]);
     }
+
+    /**
+     * @Route("/admin/http-cache/{uri<.*>}", methods={"PURGE"})
+     */
+    public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri)
+    {
+        if ('prod' === $kernel->getEnvironment()) {
+            return new Response('KO', 400);
+        }
+
+        $store = (new class($kernel) extends HttpCache {})->getStore();
+        $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+        return new Response('Done');
+    }
 }

新しいコントローラーは PURGE HTTPメソッドに制限されています。このメソッドはHTTPの標準メソッドではありませんが、キャッシュを無効にするために広く使われています。

デフォルトでは、ルーティングのパラメーターに / を含めることはできません。最後のルーティングパラメーターに uri などで ( .* ) を設定することで、この制限をオーバーライドすることができます。

HttpCache インスタンスを取得する方法も少し奇妙に見えます。"実際の"クラスにアクセスできないため、匿名クラスを使います。 HttpCache インスタンスはカーネルをラップしており、これは本来であればキャッシュレイヤーを認識しません。

cURLでの呼び出しを介して、ホームページとカンファレンスヘッダーを無効にします:

1
2
$ curl -I -X PURGE -u admin:admin `symfony var:export SYMFONY_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -I -X PURGE -u admin:admin `symfony var:export SYMFONY_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header

symfony var:export SYMFONY_DEFAULT_ROUTE_URL サブコマンドはローカルWebサーバーの現在のURLを返します。

注釈

コントローラーはコードで参照されることがないため、ルート名がありません。

プレフィクスで同様のルーティングをグループ化する

管理画面コントローラーには同じ /admin プレフィクスを持ったルートがあります。全てのルートで繰り返さず、クラス自体にプレフィクスの設定を行ってルートをリファクタリングします。

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,9 @@ use Symfony\Component\Routing\Annotation\Route;
 use Symfony\Component\Workflow\Registry;
 use Twig\Environment;

+/**
+ * @Route("/admin")
+ */
 class AdminController extends AbstractController
 {
     private $twig;
@@ -29,7 +32,7 @@ class AdminController extends AbstractController
     }

     /**
-     * @Route("/admin/comment/review/{id}", name="review_comment")
+     * @Route("/comment/review/{id}", name="review_comment")
      */
     public function reviewComment(Request $request, Comment $comment, Registry $registry)
     {
@@ -58,7 +61,7 @@ class AdminController extends AbstractController
     }

     /**
-     * @Route("/admin/http-cache/{uri<.*>}", methods={"PURGE"})
+     * @Route("/http-cache/{uri<.*>}", methods={"PURGE"})
      */
     public function flushHttpCache(KernelInterface $kernel, Request $request, string $uri)
     {

CPU/メモリ集中操作をキャッシュする

CPUやメモリーを集中的に使用するアルゴリズムはこのWebサイト上にはありません。 ローカルキャッシュ について説明するために、現在作業中のステップ(正確には、現在のGitコミットにつけられたGitタグ名)を表示するコマンドを作りましょう。

Symfony Processコンポーネントはコマンドを実行して結果を取得できます(標準出力およびエラー出力)。インストールしてみましょう。

1
$ symfony composer req process

コマンドを実装します:

src/Command/StepInfoCommand.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

class StepInfoCommand extends Command
{
    protected static $defaultName = 'app:step:info';

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
        $process->mustRun();
        $output->write($process->getOutput());

        return 0;
    }
}

注釈

make:command を使ってコマンドを作成することもできます。

1
$ symfony console make:command app:step:info

コマンドの出力を数分間キャッシュするにはどうすればよいでしょうか?Symfony Cacheを利用します:

1
$ symfony composer req cache

キャッシュのロジックでコードをラップします:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/src/Command/StepInfoCommand.php
+++ b/src/Command/StepInfoCommand.php
@@ -6,16 +6,31 @@ use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Process\Process;
+use Symfony\Contracts\Cache\CacheInterface;

 class StepInfoCommand extends Command
 {
     protected static $defaultName = 'app:step:info';

+    private $cache;
+
+    public function __construct(CacheInterface $cache)
+    {
+        $this->cache = $cache;
+
+        parent::__construct();
+    }
+
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
-        $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
-        $process->mustRun();
-        $output->write($process->getOutput());
+        $step = $this->cache->get('app.current_step', function ($item) {
+            $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
+            $process->mustRun();
+            $item->expiresAfter(30);
+
+            return $process->getOutput();
+        });
+        $output->writeln($step);

         return 0;
     }

プロセスは現在、 app.current_step がキャッシュされていない時のみ呼び出せます。

パフォーマンスをプロファイリングして比較する

盲目的にキャッシュを追加しないでください。キャッシュを追加することで複雑さが増えるということを覚えておいてください。推測で速度を判断するのは大変危険で、キャッシュを使うことでアプリケーションを遅くする状況に陥る可能性があります。

Blackfire のようなプロファイリングツールで、キャッシュを追加した場合の影響を常に計測しましょう。

Blackfireを使用したデプロイ前のコードをテストする方法の詳細に関しては、 "パフォーマンス" のステップを参照してください。

本番環境でのリバースプロキシキャッシュを設定する

本番環境ではSymfonyリバースプロキシを使わないでください。インフラストラクチャ上でVarnishのようなリバースプロキシを利用するか商用のCDNを利用してください。

SymfonyCloudにVarnishを追加します:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -7,3 +7,12 @@ queue:
     type: rabbitmq:3.5
     disk: 1024
     size: S
+
+varnish:
+    type: varnish:6.0
+    relationships:
+        application: 'app:http'
+    configuration:
+        vcl: !include
+            type: string
+            path: config.vcl

ルーティングのメインエントリーポイントとしてVarnishを利用します:

patch_file
1
2
3
4
5
6
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,2 @@
-"https://{all}/": { type: upstream, upstream: "app:http" }
+"https://{all}/": { type: upstream, upstream: "varnish:http", cache: { enabled: false } }
 "http://{all}/": { type: redirect, to: "https://{all}/" }

最後に、Varnishの設定ファイル config.vcl を作成します:

.symfony/config.vcl
1
2
3
sub vcl_recv {
    set req.backend_hint = application.backend();
}

VarnishでESIサポートを有効にする

Varnish上でESIサポートを有効にするにはリクエストごとに明示的に設定する必要があります。Symfonyは標準の Surrogate-Capability および Surrogate-Control ヘッダーを使ってESIサポートを要求します。

.symfony/config.vcl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sub vcl_recv {
    set req.backend_hint = application.backend();
    set req.http.Surrogate-Capability = "abc=ESI/1.0";
}

sub vcl_backend_response {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true;
    }
}

Varnishキャッシュを削除する

本番環境でキャッシュを無効化することは、緊急時か master 以外のブランチをのぞいて、必要になることはないでしょう。キャッシュを頻繁に削除する必要があるのであれば、(TTLを下げるか有効期限の代わりにバリデーションを使用して)キャッシュの設定を微調整する必要があることを意味します。

ともあれ、キャッシュを無効化するためのVarnishの設定を見てみましょう。

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
--- a/.symfony/config.vcl
+++ b/.symfony/config.vcl
@@ -1,6 +1,13 @@
 sub vcl_recv {
     set req.backend_hint = application.backend();
     set req.http.Surrogate-Capability = "abc=ESI/1.0";
+
+    if (req.method == "PURGE") {
+        if (req.http.x-purge-token != "PURGE_NOW") {
+            return(synth(405));
+        }
+        return (purge);
+    }
 }

 sub vcl_backend_response {

実際には、 Varnish docs に記載されているように、IPアドレスによって制限することになるでしょう。

いくつかのURLを削除します:

1
2
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --first`
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --first`conference_header

env:urls で返されるURLは / で終わっているため、少し奇妙に感じるかもしれません。


  • « Previous ステップ 20: 管理者へメールを送信する
  • Next » ステップ 22: Webpack でユーザーインタフェースにスタイリングする

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.