OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

BEAR.Sunday のリソースキャッシュを試してみた

この記事には @koriym さんによるフォローアップ記事: BEAR.Sunday のリソースキャッシュ - Qiita があります。併せてご覧ください。


しばらくぶりです。 @kalibora です。

この記事は BEAR.Sunday Advent Calendar 2017 - Qiita 20日目の記事です。

(前日の記事と少し被りますが気にしない)

リソースキャッシュとは?

BEAR.Sunday には リソースキャッシュ と呼ばれる機能があります。

リソースクラスに @Cacheable とアノテートするだけで、リソースの中身がキャッシュされるようになる。という機能です。

データベースに毎回アクセスするようなことは、負荷の観点からC(Consumer)向けのサービスではあまりしないでしょうから、とても便利な機能だと思います。

しかしながらキャッシュはパージ(削除)するタイミングが重要であり、頭を悩ませる所だと思います。

これについては REST をベースとしているため、GET 以外の各HTTPメソッドの呼び出し時に自動でパージするようになっているようです。 (もちろんAPIを介さず直接DBを変更したら意味ないですが)

それでは実際に試してみます。

と、その前に私がハマったところ

bear/skeleton から作成したプロジェクトでは、 QueryRepositoryModulePackageModule によりインストールされているため、 デフォルトでこのキャッシュ機能は有効です。

ですので、 @Cacheable とアノテートすればすぐに使えます。

と言いたい所なのですが、内部で使用されるキャッシュ機構のデフォルトが Doctrine\Common\Cache\ArrayCache であるため、一見するとキャッシュが効いていないかのような振る舞いになります。

しかしながら実際にはキャッシュ機構は動作しており、 ArrayCache が in-memory であるためリクエストをまたいで保持されないことから、このような挙動となっています。

これについては StorageMemcachedModuleStorageRedisModule をインストールすることで期待通りの挙動になります。 See: プロダクション#キャッシュ

もしくは、ちょっと試したいだけなら FilesystemCache を使ってしまうという方法 もあり、今回はこちらを使ってテストを行っています。

また、今回のテストコード一式は こちら に置いてあります。

キャッシュ有無でのレスポンスの比較

下記のような GET, POST, PUT, DELETE に対応した単純な User リソースでテストします。

また、下記のソースコード中の repository はデータベースへのアクセスだと思ってください。

<?php
namespace Kalibora\CacheTest\Resource\App;

use BEAR\Package\Annotation\ReturnCreatedResource;
use BEAR\Resource\Code;
use BEAR\Resource\Exception\ResourceNotFoundException;
use BEAR\Resource\ResourceObject;
use Kalibora\CacheTest\EntityRepository\UserRepository;

class User extends ResourceObject
{
    private $repository;

    public function __construct(UserRepository $repository)
    {
        $this->repository = $repository;
    }

    public function onGet(string $id) : ResourceObject
    {
        $user = $this->repository->find($id);

        if ($user === null) {
            throw new ResourceNotFoundException();
        }

        $this->body = $user;

        return $this;
    }

    /**
     * @ReturnCreatedResource
     */
    public function onPost(string $name) : ResourceObject
    {
        $user = [
            'name' => $name,
        ];

        $id = $this->repository->save($user);

        $this->code = Code::CREATED;
        $this->headers['Location'] = "/user?id={$id}";

        return $this;
    }

    public function onPut(string $id, string $name) : ResourceObject
    {
        $user = $this->repository->find($id);

        if ($user === null) {
            throw new ResourceNotFoundException();
        }

        $user['name'] = $name;

        $this->repository->save($user);

        $this->code = Code::NO_CONTENT;

        return $this;
    }

    public function onDelete(string $id) : ResourceObject
    {
        $user = $this->repository->find($id);

        if ($user === null) {
            throw new ResourceNotFoundException();
        }

        $this->repository->delete($id);

        $this->code = Code::NO_CONTENT;

        return $this;
    }
}

キャッシュしない場合のレスポンス

存在しない GET

$ php bootstrap/api.php get /user?id=1
404 Not Found
content-type: application/vnd.error+json

{
    "message": "Not Found",
    "logref": "64ed6b83",
    "request": "get app://self/user?id=1",
    "exceptions": "BEAR\\Resource\\Exception\\ResourceNotFoundException()",
    "file": "/Users/kalibora/work/Kalibora.CacheTest/src/Resource/App/User.php(24)"
}

新規作成の POST

$ php bootstrap/api.php post /user?name=pierre_taki
201 Created
Location: /user?id=1
content-type: application/hal+json

{
    "name": "pierre_taki",
    "id": 1,
    "_links": {
        "self": {
            "href": "/user?id=1"
        }
    }
}

存在する GET

$ php bootstrap/api.php get /user?id=1
200 OK
content-type: application/hal+json

{
    "name": "pierre_taki",
    "id": 1,
    "_links": {
        "self": {
            "href": "/user?id=1"
        }
    }
}

更新するための PUT

$ php bootstrap/api.php put /user?id=1\&name=pierre_nakano
204 No Content
content-type: application/hal+json

{
    "_links": {
        "self": {
            "href": "/user?id=1&name=pierre_nakano"
        }
    }
}

更新後の GET

$ php bootstrap/api.php get /user?id=1
200 OK
content-type: application/hal+json

{
    "name": "pierre_nakano",
    "id": 1,
    "_links": {
        "self": {
            "href": "/user?id=1"
        }
    }
}

削除するため DELETE

$ php bootstrap/api.php delete /user?id=1
204 No Content
content-type: application/hal+json

{
    "_links": {
        "self": {
            "href": "/user?id=1"
        }
    }
}

削除後の GET

$ php bootstrap/api.php get /user?id=1
404 Not Found
content-type: application/vnd.error+json

{
    "message": "Not Found",
    "logref": "64ed6b83",
    "request": "get app://self/user?id=1",
    "exceptions": "BEAR\\Resource\\Exception\\ResourceNotFoundException()",
    "file": "/Users/kalibora/work/Kalibora.CacheTest/src/Resource/App/User.php(24)"
}

この辺りの挙動は特に違和感もないかと思います。そしてすべてのリクエストでデータベースへのアクセスがあります。

キャッシュした場合のレスポンス

存在しない GET

$ php bootstrap/api.php get /cacheable-user?id=1
404 Not Found
content-type: application/vnd.error+json

{
    "message": "Not Found",
    "logref": "64ed6b83",
    "request": "get app://self/cacheable-user?id=1",
    "exceptions": "BEAR\\Resource\\Exception\\ResourceNotFoundException()",
    "file": "/Users/kalibora/work/Kalibora.CacheTest/src/Resource/App/User.php(24)"
}

新規作成の POST

$ php bootstrap/api.php post /cacheable-user?name=pierre_taki
201 Created
Location: /user?id=1
content-type: application/hal+json

{
    "name": "pierre_taki",
    "id": 1,
    "_links": {
        "self": {
            "href": "/user?id=1"
        }
    }
}

存在する GET

ここで ETagLast-Modified が出現。キャッシュが使われている。

$ php bootstrap/api.php get /cacheable-user?id=1
200 OK
ETag: 1117691016
Last-Modified: Tue, 19 Dec 2017 19:00:15 GMT
content-type: application/hal+json

{
    "name": "pierre_taki",
    "id": 1,
    "_links": {
        "self": {
            "href": "/cacheable-user?id=1"
        }
    }
}

更新するための PUT

No Content だが中身がある。これはこの挙動でよいのか?私が書いたソースコードに問題があるのかも。

$ php bootstrap/api.php put /cacheable-user?id=1\&name=pierre_nakano
204 No Content
content-type: application/hal+json

{
    "name": "pierre_nakano",
    "id": 1,
    "_links": {
        "self": {
            "href": "/cacheable-user?id=1"
        }
    }
}

更新後の GET

これは先ほどのキャッシュが使われている。

$ php bootstrap/api.php get /cacheable-user?id=1
204 No Content
content-type: application/hal+json

{
    "name": "pierre_nakano",
    "id": 1,
    "_links": {
        "self": {
            "href": "/cacheable-user?id=1"
        }
    }
}

削除するため DELETE

ここで404になるのはまだよく分かっていない。これはこの挙動でよいのか?私が書いたソースコードに問題があるのかも。

$ php bootstrap/api.php delete /cacheable-user?id=1
404 Not Found
content-type: application/vnd.error+json

{
    "message": "Not Found",
    "logref": "64ed6b83",
    "request": "delete app://self/cacheable-user?id=1",
    "exceptions": "BEAR\\Resource\\Exception\\ResourceNotFoundException()",
    "file": "/Users/kalibora/work/Kalibora.CacheTest/src/Resource/App/User.php(24)"
}

削除後の GET

$ php bootstrap/api.php get /cacheable-user?id=1
404 Not Found
content-type: application/vnd.error+json

{
    "message": "Not Found",
    "logref": "64ed6b83",
    "request": "get app://self/cacheable-user?id=1",
    "exceptions": "BEAR\\Resource\\Exception\\ResourceNotFoundException()",
    "file": "/Users/kalibora/work/Kalibora.CacheTest/src/Resource/App/User.php(24)"
}

といった具合にキャッシュが使われ、更新されるとキャッシュも新しくなります。

時間制限のあるキャッシュ

先に上げたものは時間無制限のキャッシュでした。

更新系のメソッドが呼ばれたタイミングでパージされるという、とても理にかなった挙動です。

しかしながら場合によっては有効期限付きのキャッシュを設定したい場合もあると思います。そのような設定もできるようです。

これは単純にアノテートを @Cacheable から @Cacheable(expiry="short") のように指定するようです。

expiry に指定できるものは下記の3つ。

  • short: 60秒
  • medium: 3600秒
  • long: 86400秒

ただしこれらも StorageExpiryModule を用いると設定が変更できます。

また @Cacheable(expirySecond=10) などど書けば、秒数の指定もできます。(この場合10秒でキャッシュが切れる)

なお、実装的には各種キャッシュ機構のTTLを用いているようです。

動的な有効期限の設定

アノテーションによる設定で、キャッシュが生成されたタイミングで60秒はキャッシュする。という事はできますが、

データベースのデータの内容によってこの日時まではキャッシュしてもよい。ということは出来ません。

例えばクーポンというリソースがあり、これに有効期限があるとします。

このリソースに isExpired という readonly な boolean のフィールドがあった場合、

このフィールドに変更があるのは有効期限を過ぎたタイミングです。

ですから、そのタイミングでキャッシュをパージして欲しい。

これを単純に実現するにはバッチ処理で有効期限が過ぎているもののキャッシュをパージすればよいわけですが、

こういうときってみなさんどうされてるんでしょうかね。

  1. APIでは導出項目を返さないでクライアント側に任せる
  2. 短いキャッシュ時間を設定しておき、ある程度のずれは許容する
  3. バッチ処理でパージ

さてさて、最後に質問を投げかけた所で失礼させていただきます。