BEAR.SundayとFastlyで無期限キャッシュのコンテンツを配信する

# はじめに
前日までは @koriym さんのキャッシュ設計に関する投稿でした。
この記事ではBEAR.Sundayのキャッシュ、イベントドリブンキャッシュ、失効済みコンテンツの配信(Stale)について具体的な実装内容を紹介します。

# アプリケーションキャッシュを設定する
## キャッシュの指定方法について
現在フレームワークが標準で提供するキャッシュの指定方法は以下の二つです。

### #[Cacheable]
* `#[Cacheable]` は従来よりあるキャッシュ指定アトリビュート（アノテーション）です。
* TTLの指定 (`#[Cacheable(expiry="medium")]`) 、もしくは無期限(`#[Cacheable]`)で指定できます。
* 無期限でキャッシュを設定した場合、同一Resource内の `on(Put|Post|Delete)` の実装か、キャッシュを消す処理（ `#[Refresh]` や `#[Purge]` 、もしくは `QueryRepository` の直接操作など）が必要です。

```php
<?php
/**
 * 単純なTTLキャッシュの例
 */
#[Cacheable(expiry="short")]
class Article extends ResourceObject
{
    public function __construct(private ArticleInterface $article)
    {
    }

    public function onGet(int $id): static
    {
        $this->body = ['article' => $this->article->getArticle($id)];

        return $this;
    }

    public function onPut(int $id, string $title): static
    {
        $this->article->updateArticle($id, $title);

        return $this;
    }
}
```

### キャッシュを消すことの難しさ
* キャッシュの生成は容易ですが、それを適切に削除するのは容易ではありません。
* `#[Cacheable]` はキャッシュ対象のURIを把握していればPurgeやRefreshが可能です。
* 単体の記事をCMSで更新して、それに対応する記事ページのキャッシュを更新する場合などです。
```php
$this->queryRepository->purge('app://self/article?id=1');
```
* 対象を完全に特定することが難しい場合もあります。例えばPagination付きの記事リストなどはどうでしょうか？不特定多数のページを持つ記事リストページや、カテゴリやタグなどリストの表示ルールが細かく存在する場合もあると思います。それらのURIを全て把握するのは困難です。
```php
$this->queryRepository->purge('app://self/article-list?page=1');
$this->queryRepository->purge('app://self/article-list?page=2');
$this->queryRepository->purge('app://self/article-list?page=3');
$this->queryRepository->purge('app://self/article-list?page=4');
$this->queryRepository->purge('app://self/article-list?page=1&category=php&tag=bearsunday');
...
```

### #[CacheableResponse]と#[DonutCache]
* `#[CacheableResponse]` と `#[DonutCache]` は[BEAR.QueryRepositoryの1.8.0](https://github.com/bearsunday/BEAR.QueryRepository/releases/tag/1.8.0)からサポートされた新機能です。
* DonutCacheについては[公式ドキュメント](http://bearsunday.github.io/manuals/1.0/ja/cache.html)を参照してください。
* 双方の主な違いは、 `#[CacheableResponse]` はコンテンツ全体をキャッシュし、 `#[DonutCache]` はコンテンツの一部（ドーナツの穴）を除きキャッシュする機能です。
  * Do-nut-Cache => Do-not-Cache と考えると覚えやすいです。

```php
<?php
/**
 * DonutCacheの例
 * ドーナツの穴（EmbedされているResource）はキャッシュされない
 */
#[DonutCache]
class Article extends ResourceObject
{
    public function __construct(private PaidArticleInterface $paidArticle)
    {
    }

    #[Embed(rel="paidArea", src="app://self/article/paid-area{?id}")]
    public function onGet(int $id): static
    {
        $this->body['freeArea'] = $this->paidArticle->getFreeArea($id);

        return $this;
    }
}
```

#### キャッシュのタグ(Surrogate-Key)
* これらのアトリビュートを指定すると、保存されるキャッシュに対して内部的にタグが付与されます。
* 例えば以下の `php` カテゴリをまとめたIndexページは次のようなタグが付与されます。

```php
<?php
#[CacheableResponse]
class Index extends ResourceObject
{
    #[Embed(rel="news", src="app://self/articles{?category}")]
    #[Embed(rel="ranking", src="app://self/articles/ranking{?category}")]
    #[Embed(rel="recommend", src="app://self/articles/recommend")]
    public function onGet(string $category): static
    {
        return $this;
    }
}
```
`_articles_category=php` `_articles_ranking_category=php` `_articles_recommend_`

* カテゴリ `php` の記事が追加された場合、以下のような処理で依存する全てのキャッシュを消すことが可能です。
```php
<?php
class ArticleCms extends ResourceObject
{
    public function __construct(private readonly DonutRepositoryInterface $repository)
    {
    }

    public function onPut(int $id, string $title, string $body, string $category): void
    {
        $this->updateArticle($id, $title, $body, $category);

        $this->repository->invalidateTags(['_articles_category=' . $category]);
    }

}
```

* タグは自動で（URIから）付与されるものの他に、自分で好きなものを付与することも可能です。

```php
$this->headers[BEAR\QueryRepository\Header::SURROGATE_KEY] = 'article_list';
```

* また、内部的なキャッシュデータへのタギングと同時に、HTTPレスポンスに `Surrogate-Key` ヘッダが含まれるようになります。
* `Surrogate-Key` はそのコンテンツの構成をタグのような形でメタ表現することができる機能です。
* 一部のCDNはページをキャッシュする際、この `Surrogate-Key` ヘッダをキャッシュデータと共に保持する機能があり、アプリケーションはその `Surrogate-Key` をCDNのキャッシュを削除する際に利用することが可能です。

# Fastlyについて
* Fastlyはタグ（`Surrogate-Key`)によるキャッシュのパージに対応したCDNです。
* 前項でタグによるアプリケーションキャッシュの削除を紹介しましたが、同じ仕組みで簡単にFastlyのキャッシュも消すことが可能です。
https://developer.fastly.com/reference/api/purging/#purge-tag

## CDNキャッシュのパージに対応したフレームワークの拡張ポイント
* `BEAR.QueryRepository`にはあらかじめCDNのキャッシュパージを想定した拡張ポイントが用意されています。
* [`PurgerInterface`](https://github.com/bearsunday/BEAR.QueryRepository/blob/1.x/src/PurgerInterface.php)を実装することでFastlyをはじめ、対応するCDNサービスとキャッシュをシームレスに連携することが可能です。

### FastlyCachePurger
```php
<?php

final class FastlyCachePurger implements PurgerInterface
{
    protected string $fastlyServiceId;
    private bool $enableSoftPurge;

    public function __construct(
        private PurgeApi $purgeApi,
        #[ServiceId] string $fastlyServiceId,
        #[SoftPurge] bool $enableSoftPurge,
    ) {
        $this->fastlyServiceId = $fastlyServiceId;
        $this->enableSoftPurge = $enableSoftPurge;
    }

    public function __invoke(string $tag): void
    {
        $this->purgeApi->bulkPurgeTag([
            'fastly_soft_purge' => (int) $this->enableSoftPurge,
            'service_id' => $this->fastlyServiceId,
            'purge_response' => ['surrogate_keys' => explode(' ', $tag)],
        ]);
    }
}
```

### FastlyPurgeModule

```php
<?php

final class FastlyPurgeModule extends AbstractModule
{
    /** @SuppressWarnings("PHPMD.BooleanArgumentFlag") */
    public function __construct(
        private string $fastlyApiKey,
        private string $fastlyServiceId,
        private bool $enableSoftPurge = true,
        AbstractModule|null $module = null,
    ) {
        parent::__construct($module);
    }

    /** {@inheritdoc} */
    protected function configure(): void
    {
        $this->bind(Configuration::class)->annotatedWith(Configuration::class)->toInstance(
            Configuration::getDefaultConfiguration()->setApiToken($this->fastlyApiKey),
        );
        $this->bind(PurgeApi::class)->toConstructor(PurgeApi::class, [
            'config' => Configuration::class,
        ])->in(Scope::SINGLETON);
        $this->bind()->annotatedWith(ServiceId::class)->toInstance($this->fastlyServiceId);
        $this->bind()->annotatedWith(SoftPurge::class)->toInstance($this->enableSoftPurge);
        $this->bind(ClientInterface::class)->annotatedWith(FastlyApi::class)
            ->toConstructor(Client::class, ['config' => 'fastly_http_client_options']);
        $this->bind(PurgerInterface::class)->to(FastlyCachePurger::class);
    }
}
```

* 上記Module等をまとめた[BEAR.FastlyModule](https://github.com/bearsunday/BEAR.FastlyModule)を近日中にPackagistに公開予定です。

## キャッシュのソフトパージ
* FastlyではキャッシュをPurgeする際に `fastly-soft-purge` フラグを指定して「ソフトパージ」することができます。
* ソフトパージされたコンテンツは予め定められた一定の期間中、オリジンサーバに問い合わせる代わりに失効済みコンテンツを配信します。失効済みコンテンツにアクセスがあった場合、Fastlyはバックグラウンドで（非同期で）コンテンツの更新を試みます。
* また、オリジン（コンテンツ）サーバに問題があり応答がない場合にも、一定期間は失効済みコンテンツの配信を許可することも可能です。
* 詳しくは[公式ドキュメント](https://docs.fastly.com/ja/guides/soft-purges)を参照してください。
* 以下のように`CdnCacheControlHeaderSetterInterface`を実装し、Staleコンテンツ配信の許可に関するヘッダを出力します。
* 以下の例ではキャッシュ失効済みコンテンツの代わりにStaleコンテンツの配信を許可する時間を3分、オリジンサーバに応答がない場合のStaleコンテンツの配信許可時間を1日で設定しています。

```php
<?php
class FastlyCacheControlHeaderSetter implements CdnCacheControlHeaderSetterInterface
{
    public const CDN_CACHE_CONTROL_HEADER = 'Surrogate-Control';
    public const MAX_AGE = 31_536_000;
    public const STALE_WHILE_REVALIDATE = 180;
    public const STALE_IF_ERROR = 86_400;

    public function __invoke(ResourceObject $ro, int|null $sMaxAge): void
    {
        $sMaxAge ??= self::MAX_AGE;
        if (isset($ro->headers[self::CDN_CACHE_CONTROL_HEADER])) {
            return;
        }

        $ro->headers[self::CDN_CACHE_CONTROL_HEADER] = sprintf(
            'max-age=%s, stale-while-revalidate=%s, stale-if-error=%s',
            (string) $sMaxAge,
            self::STALE_WHILE_REVALIDATE,
            self::STALE_IF_ERROR,
        );
    }
}
```

```php
$this->bind(CdnCacheControlHeaderSetterInterface::class)->to(FastlyCacheControlHeaderSetter::class);
```


## ESIの利用
* ページを構成する要素の中で、ある一部分だけキャッシュの更新頻度や性質が異なる場合や、多くのページで共通して利用される場合など、[ESI](https://developer.fastly.com/reference/vcl/statements/esi/)の利用が有効です。
* [TwigModule](https://github.com/madapaja/Madapaja.TwigModule)を利用している場合、以下のようなカスタム関数を作成することで開発環境（Fastlyなし）では `ResourceObject` の結果がそのままレンダリングされ、本番環境（Fastlyあり）ではESIタグを出力することが可能です。

```php
<?php
final class TwigProvider
{
    public function __construct(
        private bool $esiEnableFlag,
        private Environment $twig,
    ) {
    }

    public function get(): Environment
    {
        $this->twig->addFunction([
            new TwigFunction('esi', [$this, 'esi']),
        ]);

        return $this->twig;
    }

    public function esi(string $uri, array $query = []): string
    {
        if ($this->esiEnableFlag) {
            $path = str_replace('page://self/', '', $uri);
            if (! empty($query)) {
                $path .= '?' . http_build_query($query);
            }
    
            return "<esi:include src=\"$path\" />";
        }
    
        $ro = $this->resource->uri($uri)->withQuery($query);
        if ($link) {
            $ro->linkCrawl($link);
        }
    
        return (string) $ro;
    }
}
```

```twig
{{ esi('page://self/esi/daily-ranking')|raw }}
```
* このように簡潔に記述できるのは、データの特定(URI)とアクセス(Query)が行えて、コンテキストに応じてインジェクトされたレンダラーが適切な表現を返す `BEAR.Resource` の優れた点の一つです。

# 実例
* [SPUR.JP](https://spur.hpplus.jp)では上記のテクニックを活用して構築されています。
* 記事ページを例に具体例を紹介します。

## ページの構成
* ページは一つの大枠(Pageリソース)と複数の中身(PageにEmbedされたAppリソース)で構成されています。
* それぞれのアプリケーションキャッシュは独立していますが、いずれも基本的にキャッシュ期間は無期限で、イベント（コンテンツの更新作業）によって即座にキャッシュが破棄されます。逆に更新がなければキャッシュは永遠に続きます。
* 記事そのものと更新タイミングが異なるものはESIとして組み込まれています。
  * Rankingは毎日更新されるため、初回アクセス時間から次回更新時間を差し引いた秒数が `Surrogate-Control: max-age=1234` といった形でヘッダに出力されます。定期的に更新される記事ランキングのようなコンテンツは適切な値をTTLとして与えることで、無駄なくキャッシュを行います。当然その期間内にランキングに入っている記事が更新されれば即座に破棄されます。（Surrogate-KeyによるPurge）
  * HeaderやFooterはすべてのページで共通利用されます。何も考えずにそのような共通部品を全ページに組み込むと、それらの表示要素の更新があった場合、すべてのキャッシュが破棄されてしまうことになります。ESIとして独立してキャッシュに組み込むことでキャッシュ効率の向上を図っています。
![spur.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17837/0a28e3e0-3ba2-a3bb-5044-ef7d1daf8594.jpeg)



# 最後に
* 今回紹介した`BEAR.QueryRepository`の一連の新キャッシュシステムはそれだけで`BEAR.Sunday`を採用する理由になり得るほどのパワーを持った機能だと思います。
* アプリケーションやCDNのキャッシュ削除問題に対して、ここまで高度に抽象化されたソリューションを提供するフレームワークは他に類を見ません。
* この素晴らしい機能を開発された@koriymさんに最大限の賛辞を送って記事を締めたいと思います。