چرا سکان آکادمی؟
روابط پویا در لاراول با استفاده از  Subquery ها

روابط پویا در لاراول با استفاده از Subquery ها

مقدمه

هنگام ساخت برنامه های وب که با یک پایگاه داده ارتباط برقرار می کنند، من همیشه دو هدف را در نظر دارم:

  1. اجرای کمترین تعداد کوئری ممکن
  2. به حداقل رساندن استفاده از حافظه

این اهداف می توانند تاثیر قابل توجهی بر عملکرد برنامه شما داشته باشند.

توسعه دهند ها معمولا در اولین هدف بسیار خوب هستند. ما از مشکلاتی با سبک N + 1 آگاه هستیم و از تکنیک هایی مانند eager-loading برای محدود کردن کوئری های پایگاه داده استفاده می کنیم. با این حال، ما همیشه در دومین هدف، استفاده بهینه از حافظه، بهترین نیستیم. در حقیقت، ما گاهی اوقات با تلاش برای کاهش query های پایگاه داده آسیب بیشتری به هزینه استفاده از حافظه وارد می کنیم.

چالش

نمونه زیر را در نظر بگیرید. شما صفحه ای از کاربرها را در برنامه خود دارید که برخی از اطلاعات مربوط به آنها از جمله آخرین تاریخ ورود آنها را نشان می دهد. در این صفحه به ظاهر ساده در واقع برخی از پیچیدگی های جالبی ارائه شده است.

در این برنامه ما لاگ ورود به سیستم کاربر را در یک جدول به نام logins ذخیره می کنیم، بنابراین ما می توانیم گزارش آماری در مورد آن انجام دهیم.

طرح اولیه پایگاه داده که به صورت زیر است:

Schema::create('users', function (Blueprint $table) {
   $table->increments('id');
   $table->string('name');
   $table->string('email');
   $table->timestamps();
});

Schema::create('logins', function (Blueprint $table) {
   $table->increments('id');
   $table->integer('user_id');
   $table->string('ip_address');
   $table->timestamp('created_at');
});

و در اینجا مدل های مربوطه برای این جدول ها با روابط آن ها هستند:

class User extends Model
{
    public function logins()
    {
        return $this->hasMany(Login::class);
    }
}

class Login extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

پس چگونه می توانیم صفحه کاربرانی که در بالا گفته شد را ایجاد کنیم؟ به طور خاص، چگونه آخرین تاریخ ورود را دریافت می کنیم؟ پاسخ آسان به این سوال ها در زیر آمده است:

$users = User::all();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($lastLogin = $user->logins()->latest()->first())
                {{ $lastLogin->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

اما اگر ما یک توسعه دهنده خوب هستیم (که هستیم)، ما در اینجا یک مشکل را متوجه خواهیم شد. ما در اینجا مشکل N + 1 را ایجاد کردیم. ما در حال حاضر یک پرس و جو اضافی را برای دریافت آخرین ورود اجرا می کنیم. اگر صفحه ما 50 کاربر را نمایش دهد، ما در حال حاضر 51 پرس و جو را در کل اجرا می کنیم.

select * from "users";

select * from "logins" where "logins"."user_id" = 1 and "logins"."user_id" is not null 
order by "created_at" desc limit 1;

select * from "logins" where "logins"."user_id" = 2 and "logins"."user_id" is not null 
order by "created_at" desc limit 1;

// ...

select * from "logins" where "logins"."user_id" = 49 and "logins"."user_id" is not null 
order by "created_at" desc limit 1;

select * from "logins" where "logins"."user_id" = 50 and "logins"."user_id" is not null 
order by "created_at" desc limit 1;

بیایید ببینیم آیا می توانیم بهتر عمل کنیم؟ این بار بیایید تمام سوابق ورود را eager-load کنیم:

$users = User::with('logins')->get();

@foreach ($users as $user)
   <tr>
       <td>{{ $user->name }}</td>
       <td>{{ $user->email }}</td>
       <td>
           @if ($user->logins->isNotEmpty())
                {{ $user->logins->sortByDesc('created_at')->first()->created_at->format('M j, Y \a\t g:i a') }}
           @else
                Never
           @endif
       </td>
   </tr>
@endforeach

این راه حل فقط با اجرا دو کوئری در پایگاه داده نیاز ما را برطرف می کند. یکی برای کاربران و دیگری برای سوابق ورود به سیستم مربوطه. موفقیت آمیز بود!

خب، دقیقا نه. این جایی است که مشکل استفاده از حافظه به وجود می آید. مطمئنا، ما از مشکل N + 1 اجتناب کردیم، اما ما واقعا یک مسئله بسیار بزرگتر برای حافظه ایجاد کرده ایم:

ما اکنون 12500 سابقه ی ورود را load می کنیم. این نه تنها حافظه را مصرف می کند، بلکه نیاز به محاسبات اضافی نیز دارد، زیرا هر رکورد باید به عنوان یک مدل Eloquent مقدار دهی شود. و این یک مثال بسیار محدود و کوچک است. شما به راحتی می توانید به موقعیت های مشابهی که میلیون ها رکورد load می شود، اجرا کنید.

Cache کردن

شما ممکن است در این نقطه فکر کنید، "این مشکل بزرگی نیست، من فقط Last_Login_ID را در جدول کاربران ذخیره می کنم". مثلا:

Schema::create('users', function (Blueprint $table) {
  $table->integer('last_login_id');
});

در حال حاضر زمانی که یک کاربر وارد سیستم می شود، ما ردیف ورود به سیستم جدید را ایجاد می کنیم و سپس کلید خارجی Last_Login_ID را برای کاربر به روز می کنیم. سپس یک رابطه Lastlogin در مدل کاربر ایجاد می کنیم و با آن رابطه را eager-load می کنیم.

$users = User::with('lastLogin')->get();

و این یک راه حل کاملا معتبر است. اما آگاه باشید، cache کردن اغلب به این سادگی نیست. بله، شرایطی وجود دارد که denormalization مناسب است، اما ما می توانیم این کار را بهتر انجام دهیم.

معرفی subquery ها

راه دیگری برای حل این مشکل وجود دارد، و آن استفاده از subquery هاست. subquery ها به ما اجازه می دهند ستون های اضافه تری را در کوئری به پایگاه داده خود انتخاب کنیم (query کاربران در مثال ما). بیایید نگاه کنیم که چگونه می توانیم این کار را انجام دهیم.

$users = User::query()
   ->addSelect(['last_login_at' => Login::select('created_at')
       ->whereColumn('user_id', 'users.id')
       ->latest()
       ->take(1)
    ])
   ->withCasts(['last_login_at' => 'datetime'])
   ->get();

@foreach ($users as $user)
   <tr>
       <td>{{ $user->name }}</td>
       <td>{{ $user->email }}</td>
       <td>
           @if ($user->last_login_at)
               {{ $user->last_login_at->format('M j, Y \a\t g:i a') }}
           @else
                Never
           @endif
       </td>
   </tr>
@endforeach

در این مثال  ما هنوز در واقع یک رابطه پویا را بارگیری نمی کنیم. آنچه که ما انجام دادیم، استفاده از یک subquery برای دریافت آخرین تاریخ ورود برای هر کاربر به عنوان یک attribute است. ما همچنین از  query time casting استفاده می کنیم تا ویژگی last_login_at را به یک نمونه Carbon تبدیل کنیم. بیایید به query ای که در پایگاه داده اجرا می شود نگاه کنیم:

select
   "users".*,
    (
       select "created_at" from "logins"
       where "user_id" = "users"."id"
       order by "created_at" desc
       limit 1
    ) as "last_login_at"
from "users"

با استفاده از یک subquery به این ترتیب ما می توانیم تمام اطلاعاتی که برای صفحه کاربران نیاز داریم را در یک query دریافت کنیم.این تکنیک عملکرد فوق العاده ای را فراهم می کند، زیرا ما به هر دو هدف خود که کاهش query های پایگاه داده و رساندن مصرف حافظه به کم ترین حالت ممکن است می رسیم، به علاوه از cache کردن هم اجتناب کردیم.

Scope (محدوده)

قبل از رفتن به مرحله ی بعدی، اجازه دهید subquery جدید خود را به یک scope بر روی مدل کاربر تبدیل کنیم:

class User extends Model
{
   public function scopeWithLastLoginDate($query)
    {
       $query->addSelect(['last_login_at' => Login::select('created_at')
           ->whereColumn('user_id', 'users.id')
           ->latest()
           ->take(1)
       ])->withCasts(['last_login_at' => 'datetime']);
    }
}

$users = User::withLastLoginDate()->get();

من به تبدیل کردن کدهای query builder به scop های مدل مانند مثال قبل علاقه دارم. نه تنها controller ها را ساده تر می کند، بلکه امکان استفاده مجدد از این query ها را نیز فراهم می کند. به علاوه، این امر به ما کمک می کند تا در گام بعدی، روابط پویا را از طریق subquery ها load کنیم.

روابط پویا از طریق subquery ها

خب، بخشی که تا حالا ساختیم با استفاده از یک subquery برای دریافت آخرین تاریخ ورود به سیستم عالی است، اما اگر ما می خواستیم اطلاعات اضافی ای در مورد آخرین ورود را داشته باشیم چطور؟ به عنوان مثال، شاید بخواهیم آدرس IP این ورود را نیز نشان دهیم. این کار را چگونه انجام می دهیم؟

یک گزینه این است که به سادگی یک scop دیگر را ایجاد کنید:

$users = User::withLastLoginDate()
   ->withLastLoginIpAddress()
   ->get();

{{ $user->last_login_at->format('M j, Y \a\t g:i a') }}

({{ $user->last_login_ip_address }})

و این قطعا کار خواهد کرد.

آیا نمی توان با یک نمونه از مدل واقعی Login کار کرد؟ به ویژه اگر این مدل دارای قابلیت های اضافی در خود، مانند روابط باشد. چیزی مثل این:

$users = User::withLastLogin()->get();

{{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }}

({{ $user->lastLogin->ip_address }})

ما با تعریف یک رابطه ی جدید از نوع belongs-to به نام Lastlogin شروع می کنیم. در حال حاضر به طور معمول برای کار با رابطه ی belongs-to، جدول شما نیاز به یک ستون برای کلید خارجی است. در مثال ما، این به معنای داشتن یک ستون last_login_id در جدول کاربران ما است. با این حال، از آنجایی که ما در حال تلاش برای جلوگیری از داشتن denormalization [j22] و ذخیره آن داده ها در جدول کاربران هستیم، از یک Subquery برای انتخاب کلید خارجی استفاده می کنیم. بیایید به کد نگاه کنیم:

class User extends Model
{
   public function lastLogin()
    {
       return $this->belongsTo(Login::class);
    }

   public function scopeWithLastLogin($query)
    {
       $query->addSelect(['last_login_id' => Login::select('id')
           ->whereColumn('user_id', 'users.id')
           ->latest()
           ->take(1)
       ])->with('lastLogin');
    }
}

$users = User::withLastLogin()->get();

<table>
   <tr>
       <th>Name</th>
       <th>Email</th>
       <th>Last Login</th>
   </tr>
   @foreach ($users as $user)
       <tr>
           <td>{{ $user->name }}</td>
           <td>{{ $user->email }}</td>
           <td>
                @if ($user->lastLogin)
                    {{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }}
                @else
                    Never
                @endif
           </td>
       </tr>
   @endforeach
</table>

نتیجه نهایی در اینجا دو query به پایگاه داده است. بیایید نگاهی به آنها داشته باشیم:

select
   "users".*,
    (
       select "id" from "logins"
       where "user_id" = "users"."id"
       order by "created_at" desc
       limit 1
    ) as "last_login_id"
from "users"

این query به طور کلی دقیقا همان query است که ما قبلا دیدیم، به جز اینکه به جای انتخاب آخرین تاریخ ورود، ما آخرین شناسه ورود به سیستم را انتخاب کردیم. ما اساسا ستون last_login_id را بدون نیاز به ذخیره و cache کردن آن دریافت کردیم.

حالا بیایید به query دوم نگاه کنیم. این query ای است که در Laravel به طور خودکار اجرا می شود زمانی که آخرین ورودی ها را با استفاده از with(‘lastLogin’)، eager-load می کنیم،که شما می توانید ببینید که آن را در scope مان صدا کردیم.

select * from "logins" where "logins"."id" in (1, 3, 5, 13, 20 ... 676, 686)

Subquery ما اجازه داده است که فقط آخرین ورود برای هر کاربر را انتخاب کنیم. به علاوه، از آنجا که ما از یک رابطه استاندارد Laravel برای Lastlogin استفاده می کنیم، همچنین این سوابق را به عنوان یک مدل Eloquent، Login مناسب دریافت می کنیم. در نهایت، ما دیگر نیازی به query time casting نداریم، زیرا مدل Login به طور خودکار این را در attribute های ایجاد شده انجام می دهد.

Lazy-loading روابط پویا

یکی از چیزهایی که باید در این تکنیک به آن توجه کنید این است که شما نمی توانید روابط پویا را با lazy-loading بدست آورید. این به این دلیل است که scope ما به طور پیش فرض اضافه نخواهد شد.

$lastLogin = User::first()->lastLogin; //خواهد بود null خروجی

اگر شما می خواهید از lazy-loading استفاده کنید، هنوز هم می توانید این کار را با اضافه کردن global scope به مدل خود انجام دهید:

class User extends Model
{
   protected static function booted()
    {
       static::addGlobalScope('with_last_login', function ($query) {
           $query->withLastLogin();
       });
    }
}

من تمایل ندارم این کار را خیلی زیاد انجام دهم، زیرا به طور کلی ترجیح می دهم زمانی که به روابط پویا نیاز دارم آن ها را به صراحت eager-load کنم.

آیا می توان این کار را با یک رابطه ی has-one انجام داد؟

ممکن است تعجب کنید اگر می توانستیم با استفاده از یک رابطه has-one از تمام این کارها اجتناب کنیم. جواب کوتاه، نه است. بیایید به دلیل آن بپردازیم.

اولین راهی که ممکن است به آن فکر کنید، مرتب کردن یک کوئری، has-one است:

class User extends Model
{
   public function lastLogin()
    {
       return $this->hasOne(Login::class)->latest();
    }
}

$lastLogin = User::first()->lastLogin;

در ابتدا، به نظر می رسد که این کار نتیجه مورد نظر را ارائه می دهد. دسترسی به رابطه Lastlogin در کاربران ما آخرین نمونه Login را ارائه می دهد. با این حال، اگر به query تولید شده نگاه کنیم، یک مشکل را خواهیم دید:

select * from "logins"
where "logins"."user_id" in (1, 2, 3...99, 100)
order by "created_at" desc

این eager-loading برای login ها توسط user_id است، اما هیچ محدودیتی یا فیلتری وجود ندارد. به این معنی که این تنها آخرین login را load نمی کند، ، همه ی ردیف های login را برای همه کاربران load می کند.

یک محدودیت اضافه می کنیم:

public function lastLogin()
{
   return $this->hasOne(Login::class)->latest()->take(1);
}

به نظر می رسد که باید کار کند، اما اجازه دهید به کوئری تولید شده نگاه کنیم:

select * from "logins"
where "logins"."user_id" in (1, 2, 3...99, 100)
order by "created_at" desc
limit 1

با اضافه کردن limit 1 یک ردیف را برای همه ی کاربران دریافت می کنیم. این ردیف برای آخرین کاربر login شده است. برای کاربران دیگر یک رابطه  lastLogin با مقدار null ایجاد می شود.

جمع بندی

امیدوارم که یک دید کلی خوب از چگونگی استفاده از subquery ها برای ایجاد روابط پویا در Laravel بدست آورده باشید. این یک تکنیک قدرتمند است که اجازه می دهد تا شما بیشتر فشار کار را به لایه پایگاه داده از برنامه خود منتقل کنید. این تکنیک با کاهش تعداد query ها و کاهش مصرف حافظه می تواند تاثیر زیادی در بهره وری برنامه شما داشته باشد.

منبع

https://reinink.ca/articles/dynamic-relationships-in-laravel-using-subqueries[j27]