Help us understand the problem. What is going on with this article?

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

はじめに

この記事は、フォトクリエイト Advent Calendar 2019 12日目の記事です。
サーバーサイドエンジニアの@imunewです。

2019年8月29日、フォトクリエイトの主力サービスであるスナップスナップが全面リニューアルされました1

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

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

Model

サブクエリーをModelにする

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

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

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という記事も書いたので合わせて参考にしていただければと思います。

クエリ結果をキャッシュする

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

GeneaLabs/laravel-model-cachingの導入検討時にバグ発見

動作確認している際に、キャッシュの有効期間を設定してもデフォルトの値から変わらないということに気付き、Pull Requestを出しました。
(マージされるまでに、約2週間かかりました。)

GeneaLabs/laravel-model-cachingCachableトレイトを拡張

GeneaLabs/laravel-model-cachingCachableトレイトを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」にも記事がありますが、ローカルや開発環境ではレプリカ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にて、databaseoptions=\'--client_encoding=UTF8\を付け足しています。

'database' => env('DB_DATABASE', 'forge'). ' options=\'--client_encoding=UTF8\'',

また、デフォルトでは、'charset' => 'utf8'がありますが、あえて削除しています。
これは、LaravelPostgresConnectorがリクエストのたびに、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を参考にさせていただきました。

設定ファイルだけで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に変更になりました2。将来的には、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
        ]
    ],

以下、ポイントです。

tymon/jwt-auth

スナップスナップでは、tymon/jwt-authを用いて、APIの認証を行なっています。

デフォルトではクエリパラメータ、リクエストボディにあるtokenJWTトークンだと認識してしまう

下記のコードを見てもらえれば、一目瞭然なのですが、QueryStringInputSourceRouteParamsなどもTymon\JWTAuth\Http\Parser\Parserchainとして登録されます。

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トークンだと認識してしまうので、AuthHeadersCookiesだけ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した結果をキャッシュするようにしています。

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は明示的なログアウトなど、無効になったトークン(ブラックリスト)をキャッシュストレージに保存しているのですが、リフレッシュがかなりの頻度で実行されると、キャッシュストレージへのアクセスが増えます。
キャッシュストレージの負荷が高まり、応答が遅くなると、ユーザーがログインできなくなるなど、障害につながる可能性があります。
この問題を解消するため、トークンの有効期間が半分を過ぎるまでは、現在のトークンをそのまま返し、実際にはリフレッシュしないように実装しました。

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

    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というよりもSymfonyRequestクラスにenableHttpMethodParameterOverrideという関数があり、Laravelではこれを呼び出すことで、HTTP methodの上書きを有効にしています。
これは、HTMLフォームからでもPUTPATCHDELETEメソッドを指定したい場合に、擬似的にHTTP methodを上書きできる仕組みで、_methodHTTP methodをセットします3
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);
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 へお知らせください。

ではでは。

photocreate
ITの力で、写真を通じて感動があふれかえる社会を実現する「フォトライフ構想」を目指すWebサービスを展開しています
https://www.photocreate.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした