パフォーマンス向上のためにキャッシュする
パフォーマンスに関する問題はよく訪れます。典型的な例だと、データベースのインデックスの欠如や、ページあたりの大量のSQL発行などです。空のデータベースでは発生しませんが、トラフィックが増えデータが増加すると、発生する可能性があります。
HTTPキャッシュヘッダーの追加
HTTPキャッシュを利用することは、ほとんど労力をかけずにエンドユーザーへのパフォーマンス向上を最大限に引き出せるすぐれた戦略です。本番環境でリバースプロキシキャッシュを追加して有効にし、`CDN`_ を使用してキャッシュすることで、さらに優れたパフォーマンスを実現します。
ホームページを1時間キャッシュしてみましょう:
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
@@ -33,9 +33,12 @@ class ConferenceController extends AbstractController
#[Route('/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
- 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;
}
#[Route('/conference/{slug}', name: 'conference')]
setSharedMaxAge()
メソッドはリバースプロキシのキャッシュ有効期限を設定します。ブラウザのキャッシュを制御するには setMaxAge()
メソッドを使用します。時間は秒単位で設定します。(1時間 = 60分 = 3,600秒)
カンファレンスページのキャッシュはより動的なため、難しいです。誰でもいつでもコメントを追加できますが、誰もが1時間待つことを望んでいません。そのような場合は、 HTTP validation を利用します。
Symfony HTTP Cache Kernelをアクティベートする
Symfony HTTP リバースプロキシを有効化して、HTTPキャッシュをテストできますが、 "開発" 環境のみで利用してください。("プロダクション" 環境ではもっと強力な方法を使います) :
1 2 3 4 5 6 7 8 9 10
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -22,3 +22,7 @@ when@test:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
+
+when@dev:
+ framework:
+ http_cache: true
本格的な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の一部のみを返すコントローラーを作成する:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -41,6 +41,14 @@ class ConferenceController extends AbstractController
return $response;
}
+ #[Route('/conference_header', name: 'conference_header')]
+ public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
+ {
+ return new Response($this->twig->render('conference/header.html.twig', [
+ 'conferences' => $conferenceRepository->findAll(),
+ ]));
+ }
+
#[Route('/conference/{slug}', name: 'conference')]
public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
{
対応するテンプレートを作成する:
/conference_header
にアクセスし、全てが正常に動作しているか確認しましょう。
トリックを明らかにする時が来ました!先ほど作成したControllerで呼び出しているTwigテンプレートを更新しましょう。
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
@@ -16,11 +16,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 %}
それだけです。ページを更新しても引き続き同じものが表示されます。
Tip
Symfony プロファイラの"Request / Response" を開き、メインリクエストとサブリクエストの詳細を確認します。
これで、ブラウザでページにアクセスするたびに、ヘッダー用とメインページ用の2つのHTTPリクエストが実行されます。 パフォーマンスが低下しました。 おめでとう!
現在、カンファレンスヘッダーのHTTPコールはSymfonyによって内部的に行われています。よってHTTPラウンドトリップは関係ありません。これはHTTPキャッシュヘッダーを使う方法がないことも意味します。
ESIを使って リアルな HTTPコールへ変換しましょう。
まずは、ESIサポートを有効化します:
1 2 3 4 5 6 7 8 9 10 11
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -12,7 +12,7 @@ framework:
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
- #esi: true
+ esi: true
#fragments: true
php_errors:
log: true
つぎに、 render
のかわりに render_esi
を使用します :
1 2 3 4 5 6 7 8 9 10 11
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -16,7 +16,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時間キャッシュしましょう:
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
@@ -44,9 +44,12 @@ class ConferenceController extends AbstractController
#[Route('/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository $conferenceRepository): Response
{
- 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;
}
#[Route('/conference/{slug}', name: 'conference')]
キャッシュは両方のリクエストで有効になりました:
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をキャッシュ無効にしてみましょう。
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
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -36,3 +36,5 @@ services:
tags:
- { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
- { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}
+
+ Symfony\Component\HttpKernel\HttpCache\StoreInterface: '@http_cache.store'
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
+use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
@@ -52,4 +54,16 @@ class AdminController extends AbstractController
'comment' => $comment,
]));
}
+
+ #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+ public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
+ {
+ if ('prod' === $kernel->getEnvironment()) {
+ return new Response('KO', 400);
+ }
+
+ $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+ return new Response('Done');
+ }
}
新しいコントローラーは PURGE
HTTPメソッドに制限されています。このメソッドはHTTPの標準メソッドではありませんが、キャッシュを無効にするために広く使われています。
デフォルトでは、ルーティングのパラメーターに /
を含めることはできません。最後のルーティングパラメーターに uri
などで ( .*
) を設定することで、この制限をオーバーライドすることができます。
HttpCache
インスタンスを取得する方法も少し奇妙に見えます。"実際の"クラスにアクセスできないため、匿名クラスを使います。 HttpCache
インスタンスはカーネルをラップしており、これは本来であればキャッシュレイヤーを認識しません。
cURLでの呼び出しを介して、ホームページとカンファレンスヘッダーを無効にします:
1 2
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -s -I -X PURGE -u admin:admin `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header
symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL
サブコマンドはローカルWebサーバーの現在のURLを返します。
Note
コントローラーはコードで参照されることがないため、ルート名がありません。
プレフィクスで同様のルーティングをグループ化する
管理画面コントローラーには同じ /admin
プレフィクスを持ったルートがあります。全てのルートで繰り返さず、クラス自体にプレフィクスの設定を行ってルートをリファクタリングします。
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
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,7 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;
+#[Route('/admin')]
class AdminController extends AbstractController
{
private $twig;
@@ -28,7 +29,7 @@ class AdminController extends AbstractController
$this->bus = $bus;
}
- #[Route('/admin/comment/review/{id}', name: 'review_comment')]
+ #[Route('/comment/review/{id}', name: 'review_comment')]
public function reviewComment(Request $request, Comment $comment, Registry $registry): Response
{
$accepted = !$request->query->get('reject');
@@ -55,7 +56,7 @@ class AdminController extends AbstractController
]));
}
- #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+ #[Route('/http-cache/{uri<.*>}', methods: ['PURGE'])]
public function purgeHttpCache(KernelInterface $kernel, Request $request, string $uri, StoreInterface $store): Response
{
if ('prod' === $kernel->getEnvironment()) {
CPU/メモリ集中操作をキャッシュする
CPUやメモリーを集中的に使用するアルゴリズムはこのWebサイト上にはありません。 ローカルキャッシュ について説明するために、現在作業中のステップ(正確には、現在のGitコミットにつけられたGitタグ名)を表示するコマンドを作りましょう。
Symfony Processコンポーネントはコマンドを実行して結果を取得できます(標準出力およびエラー出力)。
コマンドを実装します:
Note
make:command
を使ってコマンドを作成することもできます。
1
$ symfony console make:command app:step:info
コマンドの出力を数分間キャッシュするにはどうすればよいでしょうか?Symfony Cacheを利用します。
キャッシュのロジックでコードをラップします:
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リバースプロキシを利用します。
Platform.shにVarnishを追加します:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
--- a/.platform/services.yaml
+++ b/.platform/services.yaml
@@ -2,3 +2,12 @@
database:
type: postgresql:13
disk: 1024
+
+varnish:
+ type: varnish:6.0
+ relationships:
+ application: 'app:http'
+ configuration:
+ vcl: !include
+ type: string
+ path: config.vcl
ルーティングのメインエントリーポイントとしてVarnishを利用します:
1 2 3 4 5 6
--- a/.platform/routes.yaml
+++ b/.platform/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
を作成します:
VarnishでESIサポートを有効にする
Varnish上でESIサポートを有効にするにはリクエストごとに明示的に設定する必要があります。Symfonyは標準の Surrogate-Capability
および Surrogate-Control
ヘッダーを使ってESIサポートを要求します。
Varnishキャッシュを削除する
本番環境でキャッシュを無効化することは、緊急時か master
以外のブランチをのぞいて、必要になることはないでしょう。キャッシュを頻繁に削除する必要があるのであれば、(TTLを下げるか有効期限の代わりにバリデーションを使用して)キャッシュの設定を微調整する必要があることを意味します。
ともあれ、キャッシュを無効化するためのVarnishの設定を見てみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
--- a/.platform/config.vcl
+++ b/.platform/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 cloud:env:url --pipe --primary`
$ curl -X PURGE -H 'x-purge-token: PURGE_NOW' `symfony cloud:env:url --pipe --primary`conference_header
env:url
で返されるURLは /
で終わっているため、少し奇妙に感じるかもしれません。