LaravelでAPIリソースを使う場合はN+1問題に注意する
LaravelのAPIリソースはレスポンスを整形するのに便利な機能ですが、使用する場合はN+1問題に注意する必要があります。
環境
PHP 8.1.4
Laravel 9.7.0
リポジトリ: tekihei2317/laravel-api-resource-n-plus-one
APIリソースを使う例
タスクとタスクの状態を表すtasksテーブルとtask_statusesテーブルがあるとします。taskテーブルにはtask_status_idがあり、task_statusesテーブルを参照しています。
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('task_status_id');
$table->string('name')->comment('タスク名');
$table->string('description')->comment('説明');
$table->timestamps();
$table->foreign('task_status_id')->references('id')->on('task_statuses');
});
Schema::create('task_statuses', function (Blueprint $table) {
$table->id();
$table->string('name', 16)->comment('ステータス名');
$table->string('description')->comment('説明');
$table->timestamps();
});
フロントでタスクを表示する場合は、ステータスのIDではなくステータスの文字列が欲しいです。これは、モデルにリレーションを設定して、APIリソースからリレーション経由でアクセスすることで簡単に実装できます。
class Task extends Model
{
use HasFactory;
public function status(): BelongsTo
{
return $this->belongsTo(TaskStatus::class, 'task_status_id'); // Taskモデルにリレーションを設定
}
}
class TaskResource extends JsonResource
{
public function toArray($request)
{
/** @var \App\Models\Task */
$task = $this->resource;
return [
'id' => $task->id,
'name' => $task->name,
'description' => $task->description,
'status' => $task->status->name, // リレーションを使ってステータス名を取得
];
}
}
class TaskController extends Controller
{
public function __construct(
private Task $taskModel,
) {
}
public function index()
{
return TaskResource::collection($this->taskModel->all());
}
}
ログを取ってみる
ただ上記の実装だとN+1問題が起きてしまっています。確認するために、タスクに10件データを入れてタスク一覧APIを実行し、ログを取ってみます。
class TaskController extends Controller
{
public function index()
{
\DB::enableQueryLog(); // 追記
return TaskResource::collection($this->taskModel->all());
}
}
$app = require_once __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
)->send();
\Log::debug(\DB::getQueryLog()); // 追記
$kernel->terminate($request, $response);
curl localhost:8000/api/tasks | jq .
ログを確認すると、tasksテーブルをselectするクエリが1回と、task_statusesをselectするクエリが10回実行されていることが分かりました。
array (
0 =>
array (
'query' => 'select * from "tasks"',
'bindings' =>
array (
),
'time' => 4.44,
),
1 =>
array (
'query' => 'select * from "task_statuses" where "task_statuses"."id" = ? limit 1',
'bindings' =>
array (
0 => 2,
),
'time' => 2.0,
),
2 =>
array (
'query' => 'select * from "task_statuses" where "task_statuses"."id" = ? limit 1',
'bindings' =>
array (
0 => 4,
),
'time' => 0.35,
),
// (省略)
)
なぜこのような結果になっているかというと、Eloquentモデルがリレーション先が存在しない場合クエリを発行して取得するからです。
このように、1回SELECTしてNレコード取得したあとに、ループを回してN回クエリを発行してしまう問題を、N+1問題といいます。
N+1問題を防ぐ方法
Eloquentに用意されているEager Loading(先読み込み)機能を使うことで防げます。Eager Loadingをするにはwithメソッドを使います。
class TaskController extends Controller
{
public function index()
{
return TaskResource::collection($this->taskModel->with('status')->get());
}
}
こうすることで、以下のようにN回実行されていたクエリを1件にまとめることができます。
array (
0 =>
array (
'query' => 'select * from "tasks"',
'bindings' =>
array (
),
'time' => 5.97,
),
1 =>
array (
'query' => 'select * from "task_statuses" where "task_statuses"."id" in (1, 2, 3, 4, 5)',
'bindings' =>
array (
),
'time' => 1.91,
),
)
APIリソースでN+1問題が起きないようにするための対策
2つの場合に分けて考えてみます。
- 関連を必ずロードする場合
- 関連をロードするときとロードしないときがある場合
関連を必ずロードする場合
タスクを取得するときは基本的にステータスも一緒に取得するはずです。このような場合は、リレーションがロードされていることをアサートするのがよいと思います。
class TaskResource extends JsonResource
{
public function toArray($request)
{
/** @var \App\Models\Task */
$task = $this->resource;
assert($task->relationLoaded('status')); // ロードされていないとAssertionError
return [
'id' => $task->id,
'name' => $task->name,
'description' => $task->description,
'status' => $task->status->name,
];
}
}
ただ、この場合はタスクを1つ取得するAPIでもEager Loadする必要があります。
class TaskController extends Controller
{
public function show(Task $task)
{
$task->load('status'); // ロードする
return TaskResource::make($task);
}
}
関連をロードするときとロードしないときがある場合
例えば、ユーザーが投稿するSNSアプリを考えます。ユーザーの詳細画面では投稿が必要ですが、ユーザー一覧画面では投稿が不要だったとします。
このような場合のために、APIリソースにwhenLoadedというメソッドが用意されています。whenLoadedメソッドはリレーションがロードされている場合のみリレーションを返してくれます。
リレーションがロードされていない場合はwhenLoadedメソッドはMissingValueを返します(whenLoadedに第一引数のみ指定した場合)。そのため、レスポンスからはキーごと削除されるようです。
protected function whenLoaded($relationship, $value = null, $default = null)
{
if (func_num_args() < 3) {
$default = new MissingValue;
}
if (! $this->resource->relationLoaded($relationship)) {
return value($default);
}
if (func_num_args() === 1) {
return $this->resource->{$relationship};
}
if ($this->resource->{$relationship} === null) {
return;
}
return value($value);
}
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'posts' => PostResource::collection($this->whenLoaded('posts')), // ロードされている場合のみ返す
];
}
ただ、APIから返ってくる値がEager Loadしているかどうかで決まるというのは少し分かりにくいかと思いました。
そのため、UserResourceとは別にUserWithPostsResourceを作って、そちらでアサーションするのもよいのかなと思いました。
public function toArray($request)
{
/** @var \App\Models\User */
$user = $user->resource;
assert($user->relationLoaded('posts'));
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'posts' => PostResource::collection($user->posts),
'created_at' => $user->created_at,
'updated_at' => $user->updated_at,
];
}
こうしたほうがいいよ等あれば教えていただけると幸いです。
まとめ
- APIリソースでリレーションを使う場合は、N+1問題を防ぐために、アサーションまたはwhenLoadedメソッドを使う
Discussion