0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Laravel]「これN+1じゃね?」ってなりたい

Posted at

はじめに

最近よく知らず知らずのうちにN+1に入り込んでしまい、その解消工数が発生する

これが地味に頻度が多く、根本的にN+1を発生せずにコードを書いていきたいと思った

結局はN+1はクエリが発行されるから起こることなので、LaravelのどのタイミングでN+1が発生するのか、を抑えれば解決するのではないかと思った

なので、頻繁に使うクエリを発行するメソッド(クエリビルダメソッド)をピックアップ

メソッド一覧

get()

  • クエリビルダに対して使うことで、クエリを発行しDBで取得した値をコレクションに格納する
  • get()の定義元はたくさんあるが、こんな感じでHasOne経由で取得してきたときの定義元を確認
$users = User::with('userAttribute')->get();
vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php
public function get($columns = ['*'])
    {
        $builder = $this->applyScopes();

        // If we actually found models we will also eager load any relationships that
        // have been specified as needing to be eager loaded, which will solve the
        // n+1 query issue for the developers to avoid running a lot of queries.
        if (count($models = $builder->getModels($columns)) > 0) {
            $models = $builder->eagerLoadRelations($models);
        }

        return $builder->getModel()->newCollection($models);
    }

1個づつ見ていく

①クエリの発行

vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php
        if (count($models = $builder->getModels($columns)) > 0) {
            $models = $builder->eagerLoadRelations($models);
        }
  • ここでは、$columns = ['*']を引数にとったgetModelsメソッドがクエリを発行してそうなので見て見る
vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php
public function getModels($columns = ['*'])
    {
        return $this->model->hydrate(
            $this->query->get($columns)->all()
        )->all();
    }
  • さらに$columns = ['*']を引数にとったgetがあり、これはBuider.phpのgetにつながっている
vendor\laravel\framework\src\Illuminate\Database\Query\Builder.php
public function get($columns = ['*'])
    {
        return collect($this->onceWithColumns(Arr::wrap($columns), function () {
            return $this->processor->processSelect($this, $this->runSelect());
        }));
    }
  • いかにもクエリ発行してそうなrunSelectメソッドを確認
vendor\laravel\framework\src\Illuminate\Database\Query\Builder.php

/**
* Run the query as a "select" statement against the connection.
*
* @return array
*/
protected function runSelect()
    {
        return $this->connection->select(
            $this->toSql(), $this->getBindings(), ! $this->useWritePdo
        );
    }
  • メソッドのDocに書いてある通り、ここでSELECT文のSQLが発行されるということ

②取得結果をコレクションで返却

  • 基本的に、ビルダで取得できたモデル(コレクションに変換前)が1以上であればif文に入る
  • eagerLoadRelationsは名前の通りモデルごとにeagerloadをしてリレーションを張り、出来上がった複数モデルを配列として返す
vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php
public function eagerLoadRelations(array $models)
    {
        foreach ($this->eagerLoad as $name => $constraints) {
            // For nested eager loads we'll skip loading them here and they will be set as an
            // eager load on the query to retrieve the relation so that they will be eager
            // loaded on that query, because that is where they get hydrated as models.
            if (strpos($name, '.') === false) {
                $models = $this->eagerLoadRelation($models, $name, $constraints);
            }
        }

        return $models;
    }

そして、配列で取得した複数モデルをコレクションに変換して返している

        return $builder->getModel()->newCollection($models);

all()

  • 結論、上記のget()を内部で利用している
  • よく使われるModelに対してのall()
$users = User::all();
  • 定義元は以下
vendor\laravel\framework\src\Illuminate\Database\Eloquent\Model.php
/**
     * Get all of the models from the database.
     *
     * @param  array|mixed  $columns
     * @return \Illuminate\Database\Eloquent\Collection|static[]
     */
    public static function all($columns = ['*'])
    {
        return static::query()->get(
            is_array($columns) ? $columns : func_get_args()
        );
    }
  • ここで使っているget()は先ほどの\Eloquent\Builder.phpのget()なので、最終的にはrunSelectメソッドしてクエリ発行

first()

例えば、以下のようなソースコード

$users = User::whereBetween('id', [1, 10])->first();

ここでのfirst()はPHP Intelephenseによるジャンプができないが、少なくともdumpするとデバック結果が表示されたので、以下を通る

vendor\laravel\framework\src\Illuminate\Database\Concerns\BuildsQueries.php
/**
     * Execute the query and get the first result.
     *
     * @param  array|string  $columns
     * @return \Illuminate\Database\Eloquent\Model|object|static|null
     */
    public function first($columns = ['*'])
    {
        return $this->take(1)->get($columns)->first();
    }
  • Docを見てわかる通り、クエリを発行しているんだな、と分かったが、もうちょい深追いしてみる
  • ここでのgetもPHP Intelephenseでジャンプできなかったが、dumpでデバッグすると上述の\Eloquent\Builder.phpのget()に入っていることが分かったので、最終的にはrunSelectメソッドでクエリを発行している
  • getとかと一緒で、カラム名を指定できることは知らなかった…
  • firstOrFail()とかも一緒

find()

例えば以下のようなソースコード

$users = User::whereBetween('id', [1, 10])->find(4);

ここのfindはvender配下の\Eloquent\Builder.phpを通っていた

vendor\laravel\framework\src\Illuminate\Database\Eloquent\Builder.php
public function find($id, $columns = ['*'])
    {
        if (is_array($id) || $id instanceof Arrayable) {
            return $this->findMany($id, $columns);
        }

        return $this->whereKey($id)->first($columns);
    }
  • ここで使っているfirstは上述のvender配下にある\Concerns\BuildsQueries.phpのfirstと同じなので、結局はget()→runSelect()につながる
  • 第1引数を配列で渡せること、第2引数にカラム名を指定できることは知らなかったです…
  • FindOrFail()とかも一緒

HasOneなどのリレーションメソッド

https://readouble.com/laravel/8.x/ja/eloquent-relationships.html#eager-loading

  • 無意識的に使ってたけど、コレで結構詰まった
    • ここでクエリ発行しているとは思わなかった

サンプルコード

例えばこんな感じでModel、Controller or Middleware、Viewを記述する

↓Model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Authenticatable
{
	public function userAttribute(){
      return $this->hasOne(UserAttribute::class);
  }
}

↓Contoroller or Middleware

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;

class EagerLoadingController extends Controller
{
    public function index() {
        $users = User::all(); // ※1回目クエリ発行!!
        $data = [
            'users' => $users,
        ];
        return view('eager_loading.index', $data);
    }
}

↓View

<table>
    <tr>
        <th>id</th>
        <th>name</th>
        <th>age</th>
    </tr>
    @foreach($users as $user)
    <tr>
        <td>{{$user->id}}</td>
        <td>{{$user->name}}</td>
        <td>{{$user->userAttribute->age}}</td> // ※n回(レコード)分のクエリが発行!!
    </tr>
    @endforeach
</table>

これでN+1が発生する、典型的な例。発生しているクエリはたくさん。


select * from `users`; // +1のクエリ
// 以降はn回分のクエリ
select * from `user_attributes` where `user_attributes`.`user_id` = 1 and `user_attributes`.`user_id` is not null limit 1
select * from `user_attributes` where `user_attributes`.`user_id` = 2 and `user_attributes`.`user_id` is not null limit 1
select * from `user_attributes` where `user_attributes`.`user_id` = 3 and `user_attributes`.`user_id` is not null limit 1
...
...
select * from `user_attributes` where `user_attributes`.`user_id` = 20 and `user_attributes`.`user_id` is not null limit 1

では、Viewで{{$user->userAttribute->age}}のようにuseAttribureにアクセスしたときに、どこでクエリが発行されているか。

これについては正解が見つからなかった。

とは言え、冷静に考えれば、1対1ではなく、1対多のリレーション貼っているときに

↓Model

public function orders() {
        return $this->hasMany(Order::class);
}

以下のようにordersにアクセスすると、戻り値はCollectionである。

↓Controller

// 1個のUserモデル取得
$user = User::find($id)
// リレーション先のordersにアクセス。出力はCollection
$orders = $user->orders

1対多(hasManyとか)も1対1(hasOneとか)も内部的には同じことをしているという前提に立つと、上記で「Conllectionが取得できている」ってことは、内部的には上述のvender配下にある\Eloquent\Builder.phpのget()使うのと同じなので、クエリが発行されていることも納得できる。

おわりに

  • 結局はこれらのメソッドでコレクションにしてからデータいじるわけなので、十分かなと思った
  • こんな感じでクエリがどの行で発行されているか、がわかれば「ここN+1じゃね?」ってソースコード見ただけで気づけそうだ
  • ただ、リレーションメソッド(hasOneなど)を使ってリレーションを張った時に
// 1個のUserモデル取得
$user = User::find($id)
// リレーション先のordersにアクセス。出力はCollection
$orders = $user->orders

こんな感じでリレーション先にアクセスした際のクエリの発行場所が特定できなかったので、今後必要に応じて調べる。
(hasOneメソッドを追ったんですが、自分の力不足で見つけられませんでした…)

  • あと、別でN+1関連でつまづいた事例があったのでこれはまた今度

最後までお読みいただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?