Laravelで雑にクエリ結果をキャッシュする

## はじめに
本記事は[Laravel #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/laravel-2)の8日目の記事です。

## TL;DR
- 端的に言うと`GeneaLabs/laravel-model-caching`[^GeneaLabs/laravel-model-caching]を用いたデータベースの結果キャッシュのススメ
- データベースへのクエリ数を何とか減らしたい場合に有効
- 追加実装することなく、クエリ結果をキャッシュしてくれる
- 「雑にキャッシュする」とは、`GeneaLabs/laravel-model-caching`が複雑なリレーションをキャッシュしてくれないから
- サンプルアプリを作ったので、とにかく手を動かして覚えたい方は、こちら[^Example]を参照のこと

## Laravelのキャッシュといえば
`Laravel`公式のドキュメント[^caching]にあるように、データベースから取得した結果を特定のキーに紐づけてキャッシュする方法を思い浮かべると思います。

```php
$value = Cache::get('key', function () {
    return DB::table(...)->get();
});
```

ただ、この方法では、以下の問題があります。

- 全てのデータ取得箇所に追加の実装が必要
- ルートパラメータやバリデーション（`exists`）など、フレームワークやライブラリ側でデータ取得している箇所については（簡単に）キャッシュできない

## `GeneaLabs/laravel-model-caching`のススメ
私なりのオススメポイントは以下のとおり。

- インストールして、少し設定するだけで、（それなりに）動いてくれる
- フレームワークやライブラリ側で呼び出されるクエリもキャッシュしてくれる

## 注意点
- `GeneaLabs/laravel-model-caching`は、`WHERE句`や`ORDER BY句`などの条件をキャッシュキーにする
- 条件が複雑になればなるほど、キャッシュ効率（ヒット率）は下がっていく（同じ条件のクエリが続けて実行されればその限りではないものの）
- `Eager Loading`[^Eager Loading]などによるクエリ効率化（＝`N+1問題`解消）と`GeneaLabs/laravel-model-caching`は相性が悪い
  - データが多いと`IN`句が長くなり、結果的にキャッシュ効率が下がる
  - 後述の`HasOneThrough`や`HasManyThrough`なリレーションモデルはキャッシュしないという問題がある
- 複雑なクエリの場合は、オーソドックスなキャッシング[^caching]を使ったほうが良さそう

## サンプルアプリ実装上のポイント
サンプルアプリ実装上のポイントは以下のとおりです。

- DBはSQLite
- データベースファイルは、[SQLite Sample Database And Its Diagram (in PDF format)](https://www.sqlitetutorial.net/sqlite-sample-database/)からダウンロード
- `albums`、`artists`、`tracks`の３つのリソースについて、いくつかのAPIを実装
- キャッシュストレージは`APCu`

セットアップ方法については、[imunew/laravel-model-caching-example](https://github.com/imunew/laravel-model-caching-example)の`README`を参照のこと。

`docker-compose up -d`でアプリを起動し、`http://localhost/api/artists`にアクセスすると、以下のような`json`が返ってきます。

```json
{
    "artists": [
        {
            "id": 1,
            "name": "AC/DC",
            "albums": [
                {
                    "id": 1,
                    "title": "For Those About To Rock We Salute You"
                },
                {
                    "id": 4,
                    "title": "Let There Be Rock"
                }
            ]
        },
        {"..."},
        {
            "id": 275,
            "name": "Philip Glass Ensemble",
            "albums": [
                {
                    "id": 347,
                    "title": "Koyaanisqatsi (Soundtrack from the Motion Picture)"
                }
            ]
        }
    ]
}
```

## APC管理画面を配置
`http://localhost/apc.php`にアクセスすると、下記のような管理画面に遷移します。

![apcu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26422/58dba690-be35-4e25-8cea-296a2a52ebc6.png)

[krakjoe/apcu: APCu - APC User Cache](https://github.com/krakjoe/apcu)の`apc.php`を`public`にそのまま置きました。
キャッシュの操作などで、BASIC認証が必要となるのですが、デフォルトパスワードではログインできないので、そこだけ変更しています。

## キャッシュが効いているか確認する
[itsgoingd/clockwork](https://github.com/itsgoingd/clockwork)というプロファイリングツールをインストールしているので、ブラウザで実行されたSQLなど確認できます。
`Chrome`（と`Firefox`）用のエクステンション[^Chrome Extension]があるので、インストールしておきます。

### キャッシュなしの場合
事前に前述のAPC管理画面からキャッシュをクリアしておきます。

`http://localhost/api/artists/1`にアクセスすると、以下のように`Clockwork`の`Database`タブで２つのクエリが実行されたことを確認できます。

```sql
SELECT * FROM "artists" WHERE "ArtistId" = '1' LIMIT 1
```

```sql
SELECT * FROM "albums" WHERE "albums"."ArtistId" = '1' 
and "albums"."ArtistId" IS not NULL
```

![スクリーンショット 2019-12-07 20.53.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26422/a07d2145-dbd5-ebc9-71e8-545ac17f2f74.png)

### キャッシュありの場合
続けて、もう一度、`http://localhost/api/artists/1`にアクセスします。

すると、実行されたクエリがないため、`Database`タブが表示されません。
`Cache`タブを確認すると、キャッシュエントリの件数などを確認することができます。

![スクリーンショット 2019-12-07 21.00.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26422/f62b5499-4ea0-3e73-b933-91a9233b120a.png)

### キャッシュの詳細
APC管理画面の`User Cache Entries`ボタンからキャッシュエントリーの一覧が表示されます。

![apcu_cache_list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26422/3d8bfe94-3f06-6cd0-13f4-d3e5add4dc75.png)

リストをクリックすると、各キャッシュエントリの詳細を確認することができます。

![apcu_cache_entry.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/26422/75306bff-5999-f108-469d-8380f2053e63.png)

## キャッシュされないケース
`GeneaLabs/laravel-model-caching`の`issues`[^Github Issues]にもあるように、`HasOneThrough`や`HasManyThrough`なリレーションモデルはキャッシュしない。

試しに、`http://localhost/api/artists/1/tracks`に複数回アクセスしても、下記のSQLが毎回実行されます。

```sql
SELECT "tracks".*, "albums"."ArtistId" as "laravel_through_key" 
FROM "tracks" 
inner join "albums" on "albums"."AlbumId" = "tracks"."AlbumId" 
WHERE "albums"."ArtistId" = '1'
```

尚、`Lazy Loading`でも`Eager Loading`でも同じくキャッシュされなかった。

## おわりに
`2019-12-08`時点で`v0.7.3`とまだまだ発展途上な`GeneaLabs/laravel-model-caching`ですが、
今後のアップデートを注視しつつ、上手に活用していきたいと思います。
興味ある方は、サンプルアプリを使って研究してみてください。
ではでは。

[^GeneaLabs/laravel-model-caching]: https://github.com/GeneaLabs/laravel-model-caching
[^Example]: https://github.com/imunew/laravel-model-caching-example
[^caching]: https://laravel.com/docs/6.x/cache
[^Eager Loading]: https://laravel.com/docs/6.x/eloquent-relationships#eager-loading
[^Chrome Extension]: https://chrome.google.com/webstore/detail/clockwork/dmggabnehkmmfmdffgajcflpdjlnoemp
[^Github Issues]: https://github.com/GeneaLabs/laravel-model-caching/issues/279#issuecomment-531597866