はじめに
この記事は、フォトクリエイト 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-caching
のCachable
トレイトを拡張
GeneaLabs/laravel-model-caching
のCachable
トレイトをuse
すると、全てのクエリがキャッシュされるようになります。
要するに、デフォルトはキャッシュONで、必要に応じてキャッシュOFFにするイメージです。
導入を検討していた頃は、だいぶ慎重になっていたこともあり、デフォルトはキャッシュOFFで、必要に応じてキャッシュONにできるよう、Cachable
トレイトを拡張しました。
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
は下記のようになりました。
'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
を実行してしまうのを避けるためにそうしています。
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'
を削除しています。
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
へ移行するかもしれません。)
'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
オプションを定義すると、接続を分けることができるようになる -
'alias' => 'master'
は書き込み、それ以外は読み込みになる
tymon/jwt-auth
スナップスナップでは、tymon/jwt-authを用いて、APIの認証を行なっています。
デフォルトではクエリパラメータ、リクエストボディにあるtoken
もJWT
トークンだと認識してしまう
下記のコードを見てもらえれば、一目瞭然なのですが、QueryString
、InputSource
、RouteParams
などもTymon\JWTAuth\Http\Parser\Parser
のchain
として登録されます。
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
しました。
リクエストのたびに、ログインユーザーの情報をデータベースから取得するので、負荷軽減のためにキャッシュした
前述の問題も含めて、専用のサービスプロバイダを作成して、そこで再設定するようにしました。
最終的には、以下のようなコードになりました。
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
というよりもSymfony
のRequest
クラスにenableHttpMethodParameterOverride
という関数があり、Laravel
ではこれを呼び出すことで、HTTP method
の上書きを有効にしています。
これは、HTML
フォームからでもPUT
、PATCH
、DELETE
メソッドを指定したい場合に、擬似的にHTTP method
を上書きできる仕組みで、_method
にHTTP method
をセットします3。
APIではこの仕組みは不要なので無効にします。
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
の上書きは有効になってしまいますので注意してください。
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 へお知らせください。
ではでは。