Laravelで作ったらすべて解決ではないよ？

## はじめに
この記事は、[フォトクリエイト Advent Calendar 2019](https://qiita.com/advent-calendar/2019/photocreate) 12日目の記事です。
サーバーサイドエンジニアの[@imunew](https://qiita.com/imunew)です。

2019年8月29日、フォトクリエイトの主力サービスである[スナップスナップ](https://snapsnap.jp/)が全面リニューアルされました[^renewal]。

このリニューアルプロジェクトは2018年末より始動し、私は初期段階から、APIサーバーの設計・開発、AWS上での環境構築など幅広く担当させていただきました。

APIサーバーは、`Laravel`で開発しました（現時点でバージョンは`5.8`です）。
`Laravel`はWebアプリケーションに必要な機能が揃っている、よくできたフルスタックフレームワークなんですが、標準の機能だけでは対応しきれなかった部分が複数あったので、いくつか紹介します。

## `Model`
### サブクエリーを`Model`にする
複数のテーブルにまたがる複雑なクエリーをクエリービルダーで表現するのは、難しい上にコードが不必要に長くなったりします。
`Laravel`の[公式ドキュメント](https://laravel.com/docs/6.x)を検索してもヒットしないのですが、`\Illuminate\Database\Query\Builder`には、`fromSub`という関数があって、サブクエリーから`Model`を作ることができます。

スナップスナップでは、下記のように、`boot`時にSQLを設定しています。

```php
class FromSubQuery extends Model
{
    /**
     * {@inheritdoc}
     */
    protected static function boot()
    {
        parent::boot();
        static::addGlobalScope('default', function (Builder $builder) {
            $sql = 'SELECT ...'; // 長く複雑なSQL
            $builder->fromSub($sql, 'from_sub_queries');
        });
    }
```

以前、[生SQLからEloquent Modelを作る3つの方法 - Qiita](https://qiita.com/imunew/items/86cc8bd9fff67cea3bf3)という記事も書いたので合わせて参考にしていただければと思います。

### クエリ結果をキャッシュする
[GeneaLabs/laravel-model-caching](https://github.com/GeneaLabs/laravel-model-caching)を用いてデータベースの結果をキャッシュできるようにしています。
[Laravel #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/laravel-2)に投稿した記事になりますが、「[Laravelで雑にクエリ結果をキャッシュする - Qiita](https://qiita.com/imunew/items/5c1ab709a4c04f4de87e)」にかなり詳しい説明を書いたので参考にしていただければと思います。

### `GeneaLabs/laravel-model-caching`の導入検討時にバグ発見
動作確認している際に、キャッシュの有効期間を設定してもデフォルトの値から変わらないということに気付き、[Pull Request](https://github.com/GeneaLabs/laravel-model-caching/pull/211)を出しました。
（マージされるまでに、約2週間かかりました。）

### `GeneaLabs/laravel-model-caching`の`Cachable`トレイトを拡張
`GeneaLabs/laravel-model-caching`の`Cachable`トレイトを`use`すると、全てのクエリがキャッシュされるようになります。
要するに、デフォルトはキャッシュONで、必要に応じてキャッシュOFFにするイメージです。
導入を検討していた頃は、だいぶ慎重になっていたこともあり、デフォルトはキャッシュOFFで、必要に応じてキャッシュONにできるよう、`Cachable`トレイトを拡張しました。

```app/Models/Cachable.php
namespace App\Models;

use GeneaLabs\LaravelModelCaching\Traits\Cachable as BaseCachable;
use Illuminate\Database\Eloquent\Builder;

/**
 * Trait Cachable
 * @package App\Models
 *
 * @method self withCache(?int $seconds = null, array $tags = [])
 *
 * @mixin BaseCachable
 */
trait Cachable
{
    use BaseCachable;

    /** @var bool */
    private static $isAlwaysCache = false;

    /**
     * @return void
     * @see \Illuminate\Database\Eloquent\Model::initializeTraits
     */
    protected function initializeCachable()
    {
        // デフォルトのTTLを設定（1以上にしておく必要あり）
        $this->cacheCooldownSeconds = config('path.to.config.ttl');

        if (static::$isAlwaysCache) {
            // デフォルトのTTLでキャッシュする
            $this->withCache($this->cacheCooldownSeconds);
        } else {
            // withCache() を呼び出すまではキャッシュを無効にする
            $this->disableModelCaching();
        }
    }

    /**
     * @param Builder $query
     * @param int|null $seconds
     * @param array $tags
     * @return $this
     */
    public function scopeWithCache(Builder $query, ?int $seconds = null, array $tags = [])
    {
        $this->isCachable = true; // キャッシュを有効にする
        if (!empty($seconds)) {
            $this->scopeWithCacheCooldownSeconds($query, $seconds);
        }
        if (!empty($tags)) {
            $this->cache($tags);
        }
        return $this;
    }
}
```

現状、「デフォルトはキャッシュOFFで、必要に応じてキャッシュON」のパターンはそれほどなく、「デフォルトはキャッシュONで、必要に応じてキャッシュOFF」がほとんどなので、今となっては拡張の必要は、それほどなかったように思います。

## `Database`
### Read Replica & Write Master
スナップスナップでは、`PostgreSQL`データベースの負荷を分散させるために、読み取りと書き込みでデータベースの接続を分けています。
「[Read & Write Connections / Database: Getting Started - Laravel](https://laravel.com/docs/6.x/database#read-and-write-connections)」にも記事がありますが、ローカルや開発環境ではレプリカDBを用意していないので、環境変数を省略することで、読み取り・書き込みともに同じ接続を使うようにしています。

最終的に、`config/database/php`は下記のようになりました。

```config/database.php
    'connections' => [
        'pgsql' => [
            'driver' => 'pgsql',
            'read' => [
                'host' => explode(',',
                    env('DB_READ_HOSTS', env('DB_HOST', '127.0.0.1'))
                ),
                'port' => env('DB_READ_PORT', env('DB_PORT', 5432))
            ],
            'write' => [
                'host' => explode(',',
                    env('DB_WRITE_HOSTS', env('DB_HOST', '127.0.0.1'))
                ),
                'port' => env('DB_WRITE_PORT', env('DB_PORT', 5432))
            ],
            'sticky' => true,
            'database' => env('DB_DATABASE', 'forge'). ' options=\'--client_encoding=UTF8\'',
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'prefix' => '',
            'sslmode' => 'prefer',
        ],
```

### 設定ファイルだけで`set names utf8`を呼ばないで済むようにする
上記、`config/database.php`にて、`database`に`options=\'--client_encoding=UTF8\`を付け足しています。
>  'database' => env('DB_DATABASE', 'forge'). ' options=\'--client_encoding=UTF8\'',

また、デフォルトでは、`'charset' => 'utf8'`がありますが、あえて削除しています。
これは、`Laravel`の`PostgresConnector`がリクエストのたびに、`set names utf8`を実行してしまうのを避けるためにそうしています。

```vendor/laravel/framework/src/Illuminate/Database/Connectors/PostgresConnector.php
    protected function configureEncoding($connection, $config)
    {
        if (! isset($config['charset'])) {
            return;
        }

        $connection->prepare("set names '{$config['charset']}'")->execute();
    }
```

`options=\'--client_encoding=UTF8\'`は、[PHPでデータベースに接続するときのまとめ - Qiita](https://qiita.com/mpyw/items/b00b72c5c95aac573b71)を参考にさせていただきました。

### 設定ファイルだけで`set search_path to {$schema}`を呼ばないで済むようにする
`set names utf8`と同じ理由で、`config/database.php`から`'schema' => 'public'`を削除しています。

```vendor/laravel/framework/src/Illuminate/Database/Connectors/PostgresConnector.php
    protected function configureSchema($connection, $config)
    {
        if (isset($config['schema'])) {
            $schema = $this->formatSchema($config['schema']);

            $connection->prepare("set search_path to {$schema}")->execute();
        }
    }
```

これ、`isset($config['schema']`となっているので、`config/database.php`からキーごと削除しなければいけないのは、よくないですね。
`if (!empty($config['schema'] ?? null)) {`ってしてくれたら、環境変数で制御できるので良さそうです。

## Redis
### Redis も Read Replica & Write Master
データベースだけでなく、`Redis`も読み込みと書き込みの接続を分けることができます。
以下、`predis`用の設定になります。
（`Laravel` `6.0`でデフォルトの`Redis`クライアントが`predis`から`phpredis`に変更になりました[^redis-client]。将来的には、`phpredis`へ移行するかもしれません。）

```config/database.php
    'redis' => [

        'client' => 'predis',

        'default' => [
            [
                'host' => env('REDIS_HOST', '127.0.0.1'),
                'password' => env('REDIS_PASSWORD', null),
                'port' => env('REDIS_PORT', 6379),
                'database' => 0,
                'persistent' => true,
                'alias' => 'master'
            ],
            [
                'host' => env('REDIS_READONLY_HOST', env('REDIS_HOST', '127.0.0.1')),
                'password' => env('REDIS_READONLY_PASSWORD', env('REDIS_PASSWORD', null)),
                'port' => env('REDIS_READONLY_PORT', env('REDIS_PORT', 6379)),
                'database' => 0,
                'persistent' => true,
                'alias' => 'readonly'
            ],
        ],

        'options' => [
            'replication' => true
        ]
    ],
```

以下、ポイントです。

- `replication`オプションを定義すると、接続を分けることができるようになる
  - [Predis\Client::createConnection](https://github.com/nrk/predis/blob/111d100ee389d624036b46b35ed0c9ac59c71313/src/Client.php#L123-L132) を参照のこと
- `'alias' => 'master'`は書き込み、それ以外は読み込みになる
  - [Predis\Connection\Aggregate\MasterSlaveReplication::add](https://github.com/nrk/predis/blob/111d100ee389d624036b46b35ed0c9ac59c71313/src/Connection/Aggregate/MasterSlaveReplication.php#L108-L116) を参照のこと

## `tymon/jwt-auth`
スナップスナップでは、[tymon/jwt-auth](https://github.com/tymondesigns/jwt-auth)を用いて、APIの認証を行なっています。

### デフォルトではクエリパラメータ、リクエストボディにある`token`も`JWT`トークンだと認識してしまう
下記のコードを見てもらえれば、一目瞭然なのですが、`QueryString`、`InputSource`、`RouteParams`なども`Tymon\JWTAuth\Http\Parser\Parser`の`chain`として登録されます。

```vendor/tymon/jwt-auth/src/Providers/AbstractServiceProvider.php
    protected function registerTokenParser()
    {
        $this->app->singleton('tymon.jwt.parser', function ($app) {
            $parser = new Parser(
                $app['request'],
                [
                    new AuthHeaders,
                    new QueryString,
                    new InputSource,
                    new RouteParams,
                    new Cookies($this->config('decrypt_cookies')),
                ]
            );

            $app->refresh('request', $parser, 'setRequest');

            return $parser;
        });
    }
```

このままでは、例えば、`/path/to/endpoint?token={JWTじゃないトークン}`も`JWT`トークンだと認識してしまうので、`AuthHeaders`と`Cookies`だけ`setChain`しました。

### リクエストのたびに、ログインユーザーの情報をデータベースから取得するので、負荷軽減のためにキャッシュした

前述の問題も含めて、専用のサービスプロバイダを作成して、そこで再設定するようにしました。
最終的には、以下のようなコードになりました。

```app/Providers/JwtServiceProvider.php
namespace App\Providers;

use App\Extensions\Auth\EloquentUserProvider;
use App\Models\Member;
use Illuminate\Contracts\Auth\Factory as AuthFactory;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Tymon\JWTAuth\Http\Parser\AuthHeaders;
use Tymon\JWTAuth\Http\Parser\Cookies;
use Tymon\JWTAuth\JWTAuth;
use Tymon\JWTAuth\JWTGuard;

/**
 * Class AuthServiceProvider
 * @package App\Providers
 */
class JwtServiceProvider extends ServiceProvider
{
    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->resetJwtParserChain();
        $this->setCustomProvider();
    }

    /**
     * @return void
     */
    private function resetJwtParserChain()
    {
        $jwtAuth = app(JWTAuth::class);
        assert($jwtAuth instanceof JWTAuth);

        $chains = $jwtAuth->parser()->getChain();
        $resetChains = [];
        foreach ($chains as $chain) {
            if (($chain instanceof AuthHeaders) ||
                ($chain instanceof Cookies)
            ) {
                $resetChains[] = $chain;
            }
        }
        $jwtAuth->parser()->setChain($resetChains);
    }

    /**
     * @return void
     */
    private function setCustomProvider()
    {
        $authFactory = $this->app->get(AuthFactory::class);
        assert($authFactory instanceof AuthFactory);

        $jwtGuard = $authFactory->guard('api');
        assert($jwtGuard instanceof JWTGuard);

        $hasher = $this->app->get('hash');
        assert($hasher instanceof Hasher);

        $provider = new EloquentUserProvider($hasher, Member::class);
        $jwtGuard->setProvider($provider);
    }
}
```

`EloquentUserProvider`は下記のように、`retrieveById`した結果をキャッシュするようにしています。

```php
namespace App\Extensions\Auth;

use Illuminate\Auth\EloquentUserProvider as BaseEloquentUserProvider;
use Illuminate\Support\Facades\Cache;

/**
 * Class EloquentUserProvider
 * @package App\Extensions\Auth
 */
class EloquentUserProvider extends BaseEloquentUserProvider
{
    /**
     * {@inheritdoc}
     */
    public function retrieveById($identifier)
    {
        $cacheKey = 'user-'. $identifier;
        $ttl = config('path.to.config.ttl');
        
        return Cache::remember($cacheKey, $ttl, function () use ($identifier) {
            return parent::retrieveById($identifier);
        });
    }
}
```

### キャッシュストレージへのアクセスを減らすために、有効期間が半分過ぎるまでは、トークンのリフレッシュをしない
スナップスナップでは、クライアント（ブラウザ）が、`JWT`トークンの有効期間内にリフレッシュし続けることで、ユーザーが何度もログインしなくて済むようにしているため、かなりの頻度でトークンのリフレッシュAPIが呼び出されます。
`tymon/jwt-auth`は明示的なログアウトなど、無効になったトークン（ブラックリスト）をキャッシュストレージに保存しているのですが、リフレッシュがかなりの頻度で実行されると、キャッシュストレージへのアクセスが増えます。
キャッシュストレージの負荷が高まり、応答が遅くなると、ユーザーがログインできなくなるなど、障害につながる可能性があります。
この問題を解消するため、トークンの有効期間が半分を過ぎるまでは、現在のトークンをそのまま返し、実際にはリフレッシュしないように実装しました。

ソースコードは以下のようなイメージです。

```php
    public function refreshToken()
    {
        $factory = $this->jwtGuard->factory();
        assert($factory instanceof Factory);
        $claims = $factory->getCustomClaims();

        if (!$this->shouldBeRefreshed($claims)) {
            return $this->jwtGuard->getToken()->get();
        }
        return $this->jwtGuard->refresh();
    }

    /**
     * @param array $claims
     * @return bool
     */
    private function shouldBeRefreshed(array $claims)
    {
        $now = Utils::now();
        $exp = Utils::timestamp($claims['exp']);
        $nbf = Utils::timestamp($claims['nbf']);
        // 有効期限の範囲外の場合は、trueを返す（基本的にその先の処理でエラーになるはず）
        if ($now < $nbf || $now > $exp) {
            return true;
        }
        $fromStartToNow = $now->diff($nbf);
        $fromNowToEnd = $exp->diff($now);
        // 有効期間の半分を越すまでは、refreshしない
        return $fromNowToEnd < $fromStartToNow;
    }
```

## Request
### `_method`による`HTTP method`の上書きをさせないようにする
`Laravel`というよりも`Symfony`の`Request`クラスに`enableHttpMethodParameterOverride`という関数があり、`Laravel`ではこれを呼び出すことで、`HTTP method`の上書きを有効にしています。
これは、`HTML`フォームからでも`PUT`、`PATCH`、`DELETE`メソッドを指定したい場合に、擬似的に`HTTP method`を上書きできる仕組みで、`_method`に`HTTP method`をセットします[^http-method-override]。
APIではこの仕組みは不要なので無効にします。

`public/index.php`にてリクエストクラスを独自に拡張したものに差し替えます。

```public/index.php
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

$response = $kernel->handle(
    $request = \App\Extensions\Http\Request::capture()
);

$response->send();

$kernel->terminate($request, $response);
```

```php
namespace App\Extensions\Http;

use Illuminate\Http\Request as BaseRequest;

/**
 * Class Request
 * @package App\Extensions\Http
 */
class Request extends BaseRequest
{
    /**
     * {@inheritdoc}
     */
    public static function enableHttpMethodParameterOverride()
    {
    }
}
```

`Illuminate\Foundation\Http\Kernel`でも`enableHttpMethodParameterOverride`が呼ばれているので、拡張クラスに差し替えずに`public/index.php`でだけ`enableHttpMethodParameterOverride`を実行しないようにしても、`HTTP method`の上書きは有効になってしまいますので注意してください。

```vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php
    public function handle($request)
    {
        try {
            $request->enableHttpMethodParameterOverride();

            $response = $this->sendRequestThroughRouter($request);
        } catch (Exception $e) {
            $this->reportException($e);

            $response = $this->renderException($request, $e);
        } catch (Throwable $e) {
            $this->reportException($e = new FatalThrowableError($e));

            $response = $this->renderException($request, $e);
        }

        $this->app['events']->dispatch(
            new Events\RequestHandled($request, $response)
        );

        return $response;
    }
```

## おわりに
長くなってしまいましたが、いかがでしたでしょうか？
どれも、意外とあまりブログ記事になっていない印象なので、`Laravel`で開発しているみなさんのお役に立てれば幸いです。

フォトクリエイトでは、`Laravel`を使ったアプリケーション開発に興味のある方を採用しております。少しでもご興味をお持ちいただけたようでしたら、人事部門の担当者 [twitter: @tetsunosuke](https://twitter.com/tetsunosuke) へお知らせください。

ではでは。

[^renewal]: https://www.photocreate.co.jp/news/press/1031/
[^redis-client]: https://laravel.com/docs/6.x/upgrade#redis-default-client
[^http-method-override]: https://laravel.com/docs/6.x/blade#method-field