はじめに
Laravelで開発した検索APIを、blackfireというプロファイラーを用いてパフォーマンスチューニングをしたので、その知見を共有します。
blackfireには有料プランが存在しますが、本記事で扱う内容は全て無料プランの範囲内です。
この記事で扱うこと
- 僕のプロジェクトを高速化した方法
 
この記事で扱わないこと
- blackfireのインストール方法
 - blackfireでの計測方法
 
- あなたのプロジェクトを高速化する方法
 
→ 自力で見つけて下さい。本記事はそのヒントとなることを目指しています。
前提条件
- PHP: 7.3
 - Apache: 2.4
 - MySQL: 5.7
 - Laravel: 5.6.1
 - memcached (本記事内で導入)
 - 検索API
 - ページングなし
 - 検索結果は最大で1,000件程度
 - 検索結果1行ごとに、Laravelのrouteヘルパー関数で生成した、それ自身のURLを含む
 
1,000件検索
画面左上の表示から、1.4sかかっていることが分かります。
画面右のツリーを見て処理が重そうな箇所を探しましょう。
route()
改善前
まず、MasterDatumViewModel::document()という関数が気になりました。これは検索結果のViewModelクラスで、検索結果1行ごとのURLを返すメソッドです。ソースコードを下記します。
    public function document(): string
    {
        return route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
    }
この関数は検索結果の数だけ呼び出されます。1,000件検索では1,000回呼び出され、トータルで542msかかっています。これは全体の実行時間の40%弱を占め、そのほとんどがroute()呼び出しによるものです。
1行ごとのURLが変わることは滅多にないので、検索結果のIDをキーに含めてキャッシュしてしまいましょう。
改善後
    public function document(): string
    {
        $key = $this->documentCacheKey();
        $cache = cache()->store();
        return $cache->get($key, function () use ($key, $cache) {
            $route = route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
            $cache->put($key, $route);
            return $route;
        });
    }
    public function documentCacheKey(): string
    {
        $id = $this->masterDatum->id()->toInt();
        return "master_datum:document:route:{$id}";
    }
改善後のソースコードです。キャッシュドライバはmemcachedを使用しました。
1.57sと、却って遅くなってしまいました。画面右のツリーを見ると、キャッシュリポジトリを取得するcache()とCacheManager::store()の呼び出しがかなりの割合を占めます。
ViewModelクラスですし、インスタンス毎に異なるキャッシュリポジトリを使うことも考えにくいので、ここは思い切ってstatic変数にしてしまいましょう。
再改善後
    public function document(): string
    {
        $key = $this->documentCacheKey();
        // 呼び出し回数が多いため初回呼び出し時にstaticとして保持
        static $cache;
        $cache = $cache ?? cache()->store();
        return $cache->get($key, function () use ($key, $cache) {
            $route = route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
            $cache->put($key, $route);
            return $route;
        });
    }
    public function documentCacheKey(): string
    {
        $id = $this->masterDatum->id()->toInt();
        return "master_datum:document:route:{$id}";
    }
キャッシュリポジトリをstatic変数とし、クラス内で初回のみ取得するよう変更しました。
結果は1.1sとなり、0.3sの高速化ができました。
余談
デプロイ時にはキャッシュを忘れずに削除しましょう。
\Illuminate\Database\Eloquent\Builder\get()
改善前
次に気になったのは、SearchMasterDataAdapter::findMasterData()という関数です。
これは、入力された検索条件をクエリビルダーに適用し、検索結果をドメインモデルに変換して返すメソッドです。ソースコードを下記します。
    public function findMasterData(SearchQuery $query): iterable
    {
        $builder = $this->eloquentMasterData->newQuery();
        $keyword = $query->keyword();
        $sort = $query->sort();
        if ($keyword !== null) {
            $builder->ofKeyword($keyword);
        }
        foreach ($query->businessCategories() as $businessCategory) {
            $builder->ofBusinessCategory($businessCategory);
        }
        if ($sort !== null) {
            $builder->sort($sort);
        }
        return $builder->get()->map(static function (MasterData $masterDatum) {
            return $masterDatum->toDomainModel();
        });
    }
この中で大きな比率を占めるのが$builder->get()です。
ツリーを見ると、Eloquentが検索結果をMasterDataモデルにhydrateする処理に時間がかかっているようです。
適切にデータがセットされたMasterDataモデルのtoDomainModel()さえ呼べれば用は足りるので、PDOで取得した検索結果をMasterDataモデルにセットしてみましょう。
改善後
    public function findMasterData(SearchQuery $query): iterable
    {
        // 省略
        $pdo = $builder->getConnection()->getPdo();
        $bindings = $builder->getBindings();
        $stmt = $pdo->prepare($builder->toSql());
        $results = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        return \array_map(static function (array $row) {
            static $masterData;
            $masterData = $masterData ?? new MasterData();
            return $masterData->setRawAttributes($row)->toDomainModel();
        }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }
まずはクエリビルダーをSQLとパラメータに変換して、PDOで取得します。それをMasterData::setRawAttributes()に渡すことでMasterDataモデルに値をセットし、ドメインモデルに変換します。
先程の1.1sから、911msまで高速化できました。
1,000件検索のプログラム改善はここまでとします。
gzip
改善前
次はブラウザでAPI呼び出しを見てみましょう。
1,000件ものデータとなるとその容量も小さくなく、433kbとなっています。その転送にはおよそ456msかかっています。
これをWebサーバーで圧縮して、転送速度を改善しましょう。
改善後
<IfModule mod_deflate.c>
    <IfModule mod_filter.c>
        FilterDeclare COMPRESS
        FilterProvider COMPRESS DEFLATE "%{Content_Type} =~ m#^application/json#"
        FilterChain COMPRESS
        FilterProtocol COMPRESS DEFLATE change=yes;byteranges=no
    </IfModule>
</IfModule>
コンテンツの圧縮の方法はここでは扱いませんが、Content-Typeがapplication/jsonで始まるレスポンスを圧縮するよう設定しました。
(ちなみにm#^application/jsonの後に$を付けて完全一致にすると、Content-Type: application/json; charset=utf-8のようなケースで圧縮されません。)
データ量が73.5kbまで低減しました。転送時間は大差ありませんが、これは開発環境がローカルであることが原因です。
本番環境では転送時間が大きく改善していることを確認できました。
1件検索
次に1件検索時のプロファイルをします。1,000件検索時とは違って、もっとアプリケーションのベース部分の問題が判るはずです。
サードパーティ製ライブラリ
改善前
1件検索時の結果は478msとなりました。
この内、およそ50%程度をサードパーティ製ライブラリの処理が占めていました。ソースコードを読むと改善の余地が十分にありそうですが、vendor以下のコードを変更する訳にはいきません。
ライブラリの修正を待つのも一つの手ですが、今回は時間的猶予がありませんでした。そのため、自分で改善したクラスをサービスコンテナに登録する形で対応しました。
改善後
use App\Aop\AspectManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Ytake\LaravelAspect\AnnotationConfiguration;
final class AspectServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton('aspect.manager', static function (Application $app) {
            /** @var AnnotationConfiguration $annotationConfiguration */
            $annotationConfiguration = $app->make(AnnotationConfiguration::class);
            $annotationConfiguration->ignoredAnnotations();
            // ↓自分で作成したクラス
            return new AspectManager($app);
        });
    }
    public function boot()
    {
    }
}
詳細は省きます。重要なのは、サービスコンテナを利用することで既存の処理も置換可能ということです。
Laravelドキュメントのサービスコンテナのページを読んで下さい。
https://readouble.com/laravel/6.x/ja/container.html
284msとなり、およそ200msもの改善ができました。
まとめ
- 1,000件検索では1.4s→911ms。約35%の改善。
 - 1件検索では478ms→284ms。約40%の改善。
 
本記事で行なった施策が、そのままあなたのプロジェクトに適用できることは稀だと思います。
あなたのプロジェクトに最適な施策を見つけて下さい。
推測するな、計測せよ。
よきパフォーマンスチューニングを。









