เพิ่มประสิทธิภาพ Laravel Eloquent Queries ด้วย Eager Loading

31 มกราคม 2564
เพิ่มประสิทธิภาพ Laravel Eloquent Queries ด้วย Eager Loading

Object Relational Mapping หรือ ORM ช่วยให้การติดต่อกับฐานข้อมูลทำได้อย่างง่ายดาย การกำหนด Relationships ในแต่ละตารางแบบ Object-Oriented ทำให้เราสามารถสร้าง query ตาม Model ที่เกี่ยวข้องได้ โดยที่เราไม่ต้องสนใจการติดต่อกับฐานข้อมูลในเบื้องหลัง

Eager Loading คืออะไร?

Eager Loading คือการบอกให้ Eloquent รู้ว่าเราต้องการดึงข้อมูล Model โดยมี Relation ที่เกี่ยวข้องที่เราต้องการดึงมาด้วย ทำให้ framework สามารถสร้าง query ที่มีประสิทธิภาพในการดึงข้อมูลทั้งหมดที่เราต้องการได้ เราสามารถลดการสร้าง queries ที่ต้องเรียกไปที่ database จริง ๆ หรือเพียง 1-2 queries เท่านั้นเมื่อมีการใช้งาน Eager Loading

ในบทเรียนนี้ เราจะมาลองสร้างตัวอย่าง Relation แล้วลองดูว่า queries จะเป็นยังไงเมื่อมีการใช้และไม่ใช้ Eager Loading โดยจะมีตัวอย่าง code และทดลองการเขียนหลาย ๆ แบบจะได้ลองดูว่า Eager Loading ทำงานได้อย่างไรตามตัวอย่างที่จะช่วยให้เราเข้าใจการปรับปรุง queries ให้ทำงานได้ดีขึ้น

บทนำ

โดยพื้นฐานแล้ว ORM จะทำการ "Lazy Loading" Relation ของ Model เพราะ ORM คงไม่รู้ว่าเราต้องการจะใช้ Relation อะไรบ้าง? บางทีเราอาจจะไม่ได้ต้องการดึง Relation ที่เกี่ยวข้องที่เราไม่ได้ใช้งาน ปัญหาการเขียน query ที่ไม่ได้ระวังเรื่อง relation เราเรียกว่า "N+1" เราอาจจะไม่รู้ตัวด้วยซ้ำว่าเบื้องหลังมีการสร้าง query ต่อ database เมื่อเรามีการเขียนโปรแกรมแบบ Object เพื่อจำลอง Model

สมมุติว่าเรามี 100 objects ที่อ่านมาได้จาก database แล้วแต่ละเรคอร์ด มีอีก 1 model ที่เกี่ยวข้อง (เช่น belongsTo) พอเราใช้ ORM ตามปกติแล้วจะมีการสร้าง query ทั้งหมด 101 queries คือ หนึ่ง query สำหรับ 100 เรคอร์ดแรกและอีกหนึ่ง query สำหรับแต่ละเรคอร์ดที่มีการดึง relation มาด้วย ในตัวอย่าง code นี้ สมมติว่าเราต้องการแสดง Author ทั้งหมดที่มีการเขียน Post ขึ้นมา สำหรับแต่ละ Post (แต่ละ Post มีหนึ่ง Author) เราอาจจะเรียกรายการของ author ได้ด้วย code ประมาณนี้

$posts = Post::published()->get(); // หนึ่ง query

$authors = array_map(function($post) {
    // สร้าง Query บน Model Author
    return $post->author->name;
}, $posts);

เราไม่ได้บอก Post Model ให้รู้ว่าเราต้องการดึงข้อมูล author มาใช้ด้วย ทำให้มี query เพิ่มขึ้นทุกครั้งที่เราดึงชื่อ author สำหรับแต่ละ post ที่เราเรียกขึ้นมาก

Eager Loading

ทีนี้เหมือนที่ได้บอกไว้ข้างบนแล้วว่า ORM จะทำการ "lazy loading" relation ที่เกี่ยวข้อง ถ้าเราต้องการใช้ข้อมูล relation ที่เกี่ยวข้องด้วยเราสามารถลด 101 query นั้นให้เหลือเพียง 2 query ได้ด้วยการใช้ Eager Loading เราเพียงแค่บอก Model ให้รู้ว่าต้องทำการ "Eager Loading"

Laravel ได้ตัวอย่างการเขียน ORM มาจาก Rails Active Record วิธีการเขียนเป็นแบบนี้

# Rails
posts = Post.includes(:author).limit(100)

# Laravel
$posts = Post::with('author')->limit(100)->get();

หากต้องการเข้าใจเบื้องหลังการทำงานแบบ Active Record มากขึ้นลองดูตัวอย่างตาม document จะช่วยให้เห็นตัวอย่างและเข้าใจ concept ได้มากขึ้น

Laravel Eloquent ORM

ORM ใน Laravel เราจะเรียกว่า Eloquent ช่วยให้เราสามารถทำ Eager Loading ได้ง่ายรวมไปถึงการ eager load relation ซ้อน relation ก็ทำได้ ลองดูตัวอย่างต่อจาก Post Model แล้วดูว่าเราจะ Eager Loading ยังไงได้บ้างในโปรเจค Laravel

เราจะลองมาสร้างโปรจคแล้วลองดูตัวอย่าง Eager Loading ให้ลึกมากยิ่งขึ้น

Setup

เริ่มจากสร้าง database migrations, Model, และ database seeding เพื่อทดสอบการทำ Eager Loading ถ้าอยากจะลองทำตามอย่างน้อยต้องมี database แล้วสามารถ ติดตั้ง Laravel Project ได้ก่อน

เราสามารถสร้างโปรเจค Laravel โดยใช้ Laravel installer

laravel new blog-example

แก้ไขค่าใน .env ให้ตรงกับ database ที่เราใช้งาน

ต่อมาเราจะมาลองสร้าง Model 3 อันเพื่อลอง Eager Loading แบบมี relation ซ้อนกัน ในตัวอย่างนี้เราจะสนใจเฉพาะเรื่อง Eager Loading โดยเราจะไม่พูดถึงเรื่อง Index หรือพวก Foreign key (ที่ควรมีไว้ในการใช้งานกับโปรเจคจริง)

php artisan make:model -m Post
php artisan make:model -m Author
php artisan make:model -m Profile

flag -m เป็นการบอกให้รู้ว่าเราต้องการสร้างไฟล์ migration จาก Model ที่เราต้องการขึ้นมาด้วยเพื่อใช้สร้าง table schema

Model จะมีความเกี่ยวข้องกันแบบนี้

Post -> belongsTo -> Author
Author -> hasMany -> Post
Author -> hasOne -> Profile

Migrations

ลองมาสร้าง schema สำหรับแต่ละ table กัน เราจะเขียนเฉพาะ method up() เพราะว่า Laravel จะสร้าง method down() ให้เราโดยอัตโนมัติอยู่แล้วสำหรับตารางใหม่ ไฟล์ migration จะอยู่ใน folder database/migrations/

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('author_id');
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAuthorsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('authors', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->text('bio');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('authors');
    }
}
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateProfilesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('profiles', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('author_id');
            $table->date('birthday');
            $table->string('city');
            $table->string('state');
            $table->string('website');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('profiles');
    }
}

Model

เราจะมานิยาม Model relation เพื่อทดลองการทำ Eager Loading ตอนที่เรา run คำสั่ง php artisan make:model  ไฟล์ model จะถูกสร้างขึ้นมาให้เรา

Model แรก app/Models/Post.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}

ถัดมา app\Models\Author.php มี 2 relations

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Author extends Model
{
    use HasFactory;

    public function profile()
    {
        return $this->hasOne(Profile::class);
    }

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

พอเรามี Model และ Migration พร้อมแล้ว เราสามารถ สั่ง php artisan migrate แล้วไปทดลอง Eager Loading ต่อด้วยการ Seed ข้อมูลลง Model

php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table
Migrating: 2017_08_04_042509_create_posts_table
Migrated:  2017_08_04_042509_create_posts_table
Migrating: 2017_08_04_042516_create_authors_table
Migrated:  2017_08_04_042516_create_authors_table
Migrating: 2017_08_04_044554_create_profiles_table
Migrated:  2017_08_04_044554_create_profiles_table

ลองไปดูใน database จะเห็นตารางถูกสร้างขึ้นตาม migration ที่เราเขียนไว้

Model Factories

เราจะใช้ Model Factory เพื่อสร้างข้อมูลตัวอย่างสำหรับ query ที่เราจะเขียนขึ้น เราจะ seed ข้อมูลลง database ด้วย test data วิธีการสร้าง Model Factory ทำได้ดังนี้

ใช้คำสั่ง php artisan make:factory เพื่อสร้าง Model Factory

php artisan make:factory PostFactory
php artisan make:factory AuthorFactory
php artisan make:factory ProfileFactory

ไปยังไฟล์ database/factories/PostFactory.php แล้วแก้ไข method definition() ด้วย attributes ต่อไปนี้

<?php

namespace Database\Factories;

use App\Models\Author;
use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Post::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'title' => $this->faker->sentence,
            'author_id' => function () {
                return Author::factory()->create()->id;
            },
            'body' => $this->faker->paragraphs($this->faker->numberBetween(3, 10), true),
        ];
    }
}

ไฟล์ database/factories/AuthorFactory.php

<?php

namespace Database\Factories;

use App\Models\Author;
use Illuminate\Database\Eloquent\Factories\Factory;

class AuthorFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Author::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'name' => $this->faker->name,
            'bio' => $this->faker->paragraph,
        ];
    }
}

ไฟล์ database/factories/ProfileFactory.php

<?php

namespace Database\Factories;

use App\Models\Author;
use App\Models\Profile;
use Illuminate\Database\Eloquent\Factories\Factory;

class ProfileFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Profile::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'birthday' => $this->faker->dateTimeBetween('-100 years', '-18 years'),
            'author_id' => function () {
                return Author::factory()->create()->id;
            },
            'city' => $this->faker->city,
            'state' => $this->faker->state,
            'website' => $this->faker->domainName,
        ];
    }
}

ไฟล์ Factory จะช่วยให้เราจำลองข้อมูลจำนวนมากเกี่ยวกับ Post ที่เราต้องการ query ได้ง่าย เราสามารถใช้ factory เพื่อสร้าง model data ที่เกี่ยวข้องกันโดยใช้ database seeding

ไปยังไฟล์ database/seeds/DatabaseSeeder.php file แล้วแก้ไข method run() ดังนี้

<?php

namespace Database\Seeders;

use App\Models\Author;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        Author::factory()
            ->count(5)
            ->hasProfile()
            ->hasPosts(10)
            ->create();
    }
}

เราทำการสร้าง Author ทั้งหมด 5 record โดยแต่ละ author มี 1 Profile และ 10 Post สำหรับ author แต่ละคน

เราได้ทำการสร้าง migration, model, factory, และ database seeder เสร็จเรียบร้อย เราสามารถ run migration และ seeder ในคำสั่งเดียวเพื่อจะได้ลองเรียกซ้ำ ๆ ได้ด้วยคำสั่งนี้

php artisan migrate:fresh --seed

ลองไปดูใน database จะเห็นตารางและข้อมูลถูก seed ลงตารางตามที่เราได้เขียนไว้

ทดลองใช้ Eager Loading

เราพร้อมที่จะใช้งาน Eager Loading กันแล้ว วิธีการหนึ่งที่เราจะมองภาพการใช้ Eager loading ออก เราจะลอง log query ลงในไฟล์ storage/logs/laravel.log

เราสามารถใช้ Eloquent log query ให้เราได้โดยการเพิ่ม code ต่อไปนี้ใน method boot() ในไฟล์ app/Providers/AppServiceProvider.php

namespace App\Providers;

use DB;
use Log;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        DB::listen(function($query) {
            Log::info(
                $query->sql,
                $query->bindings,
                $query->time
            );
        });
    }

    // ...
}

เราใช้ DB::listen() เพื่อดัก event query ที่ถูกส่งไปยัง database แล้วทำการ log ลงไฟล์ laravel.log หรือเราสามารถใช้ Laravel Debugbar เพื่อดูข้อมูล Query ได้เช่นกัน

ลองมาดูกันว่าถ้าเราไม่ใช้ Eager loading ในการถึงข้อมูลแล้วจะเป็นอย่างไร ลองเคลียร์ข้อมูลในไฟล์ storage/log/laravel.log ให้เรียบร้อยก่อน แล้ว run คำสั่ง Tinker command

php artisan tinker

>>> use App\Models\Post;
>>> $posts = App\Post::all();
>>> $posts->map(fn($post) => post->author);

ลองดูในไฟล์ laravel.log เราจะเห็นคำสั่ง SQL จำนวนมากดึงข้อมูลที่เกี่ยวข้องกับ Author ขึ้นมา

[2021-01-31 06:24:26] local.INFO: select * from `posts`  
[2021-01-31 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1] 
[2021-01-31 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1] 
[2021-01-31 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1] 
....

ลองเคลียร์ไฟล์ laravel.log อีกครั้ง คราวนี้ลองใช้ with() เพื่อ Eager Loading ข้อมูล author

php artisan tinker

>>> use App\Models\Post;
>>> $posts = Post::with('author')->get();
>>> $posts->map(fn($post) => post->author);

คราวนี้เราน่าจะเห็นเพียงแค่สอง query เท่านั้นใน log ไฟล์ query แรกสำหรับดึง post ทั้งหมดกับอีก query นึงเพื่อดึง author ที่เกี่ยวข้องกับ post

[2021-01-31 06:26:54] local.INFO: select * from `posts`  
[2021-01-31 06:26:54] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)

ถ้าเรามีหลาย ๆ relation เราสามารถ eager loading พร้อม ๆ กันได้ด้วยการเขียนแบบ array

If you had multiple related associations, you can eager load them with an array:

$posts = Post::with(['author', 'comments'])->get();

การ Eager Loading แบบ relation ซ้อน relation

การ Eager loading ซ้อน relation สามารถทำได้คล้าย ๆ กัน จากตัวอย่างของเรา author จะมี หนึ่ง profile (hasOne) ดังนั้น query จะถูกประมวลผลสำหรับแต่ละ profile

เคลียร์ไฟล์ laravel.log แล้วลองตาม code นี้

php artisan tinker

>>> use App\Models\Post;
>>> $posts = Post::with('author')->get();
>>> $posts->map(fn($post) => $post->author->profile );

ลองดูในไฟล์ laravel.log

[2021-01-31 06:32:35] local.INFO: select * from `posts`  
[2021-01-31 06:32:35] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)  
[2021-01-31 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [1] 
[2021-01-31 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [2] 
[2021-01-31 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [3] 
[2021-01-31 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [4] 
[2021-01-31 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [5] 

เราจะเห็นได้ว่ามี query เกิดขึ้นทั้งหมด 7 ครั้ง สองครั้งแรกสำหรับ Eager load และอีกแต่ละหนึ่งครั้ง สำหรับ profile ที่เราดึงมาทุกครั้งของแต่ละ author

เราสามารถใช้ Eager loading เพื่อลด query ที่เกิดขึ้นซ้ำ ๆ ใน relation ที่ซ้อนกันได้เช่นกัน เคลียร์ไฟล์ laravel.log อีกครั้งสุดท้ายแล้วลอง run tinker ตามนี้

>>> $posts = Post::with('author.profile')->get();
>>> $posts->map(fn($post) => $post->author->profile);

ทีนี้เราน่าจะเห็นเพียงแค่ 3 query เกิดขึ้นเท่านั้น

[2021-01-31 06:35:47] local.INFO: select * from `posts`  
[2021-01-31 06:35:47] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)  
[2021-01-31 06:35:47] local.INFO: select * from `profiles` where `profiles`.`author_id` in (1, 2, 3, 4, 5)  

Lazy Eager Loading

ในบางครั้งเราอาจต้องการดึง Model ที่เกี่ยวข้องเฉพาะบางเงื่อนไขเท่านั้น ในกรณีนี้เราสามารถ Lazy เรียกคำสั่ง query เฉพาะเวลาที่เราต้องการเท่านั้นได้

php artisan tinker

>>> use App\Models\Post;
>>> $posts = Post::all();
...
>>> $posts->load('author.profile');
>>> $posts->first()->author->profile;
...

เราควรจะเห็นเพียงแค่ 3 query ถูกเรียกขึ้นเท่านั้นถ้าเราเรียก $posts->load()

สรุป

ในบทเรียนนี้ได้สอนเกี่ยวกับ Eager Loading Model และได้อธิบายการทำงานในระดับที่ลึกขึ้น ใน Laravel doucument ได้อธิบายเรื่อง eager loading ไว้ค่อนข้างละเอียด ผมคาดว่าการได้ฝึกตามตัวอย่างนี้จะช่วยให้เราเข้าใจการ query relation ใน Laravel ได้มีประสิทธิภาพมากยิ่งขึ้น

Phattarachai Chaimongkol

เกี่ยวกับ phattarachai.dev

มองหาคนช่วยทำ Web App ใช้ภายในธุรกิจอยู่มั้ยครับ
มีความชำนาญในการพัฒนา Web Application ด้วย Laravel รับพัฒนาโปรเจคให้ผู้ประกอบการ ธุรกิจ SME ทั้งขนาดเล็กและขนาดใหญ่ พัฒนาระบบใช้ในองค์กรทั้งภาครัฐและเอกชน เป็นพาร์ทเนอร์กับบริษัททางด้าน Digital Agency เพื่อพัฒนาโปรเจคให้แก่ลูกค้า ทักเข้ามาพูดคุยกันก่อนได้เลยครับ

เรื่องล่าสุด

Backup ฐานข้อมูลด้วย Laravel อย่างง่าย ๆ ด้วย Spatie DB Snapshots
31 มกราคม 2564
Backup ฐานข้อมูลด้วย Laravel อย่างง่าย ๆ ด้วย Spatie DB Snapshots
วิธีการให้ git จดจำ password โดยไม่ต้องระบุใหม่ทุกครั้ง
31 มกราคม 2564
วิธีการให้ git จดจำ password โดยไม่ต้องระบุใหม่ทุกครั้ง
วิธีการเช็ค Detect Browser ผู้ใช้จาก Laravel
31 มกราคม 2564
วิธีการเช็ค Detect Browser ผู้ใช้จาก Laravel