بهره وری دیتابیس پروژه لاراولی - ایجاد روابط dynamic توسط sub query

بهره وری دیتابیس پروژه لاراولی - ایجاد روابط dynamic توسط sub query

ایجاد روابط dynamic توسط sub query

در قسمت قبلی یاد گرفتیم که چگونه تنها یک رکورد از رابطه ی has many را به بهترین روش ممکن دریافت کنیم. در این قسمت این روش را با ایجاد یک رابطه ی dynamic، یک درجه بهتر خواهیم کرد. استفاده از sub query برای گرفتن تاریخ آخرین ورود کاربر عالی بود اما اگر بخواهیم علاوه بر تاریخ، ip کاربر را هم نمایش دهیم، چگونه این کار را انجام دهیم؟

یک راه این است که به سادگی یک scope دیگر برای دریافت ip کاربر بنویسیم.

به مدل user می رویم و این کار را با کپی کردن scope قبلی و تغییر آن انجام می دهیم.

public function scopeWithLastLoginIpAddress($query)

{
    $query->addSelect(['last_login_ip_address' => Login::select('ip_address')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->take(1)
    ]);

}

در تکه کد بالا، چون مقدار دریافتی تاریخ نیست cast آن را حذف کردیم.

اکنون scope جدید را در کنترلر UserController صدا می زنیم.

public function index()

{
    $users = User::query()
        ->withLastLoginAt()
        ->withLastLoginIpAddress()
        ->orderBy('name')
        ->paginate();

    return view('users', ['users' => $users]);
}

و به فایل blade لیست کاربر ها هم ip را اضافه می کنیم.

<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
  {{ $user->last_login_at->diffForHumans() }}
    <span class="text-xs text-gray-400">
    ({{ $user->last_login_ip_address }})
  </span>

</td>

حال اگر به صفحه کاربر ها برویم آدرس Ip هم دیده می شود.

با اینکه این روش به طور قطع کار می کند اما اگر یک ستون دیگر از جدول logins خواستیم چه می شود؟ برای مثال شاید به مقدار id برای ایجاد یک لینک نیاز داشته باشیم. ایجاد یک scope جدید برای هر مقداری که نیاز داریم کمی اذیت کننده است. همچنین ممکن است در نهایت مقدار زیادی scope در پروژه داشته باشیم که می تواند گیج کننده باشد. کار ما خیلی راحت تر می شد اگر می توانستیم مثل رابطه ای عادی با یک مدل Login کار کنیم. به ویژه اگر آن مدل رفتار های خاص خود را داشته باشد. مثل متد های کمکی یا روابط دیگر.

با کمک رابطه های dynamic این کار شدنی است. برای شروع ابتدا scope هایی که در مدل user داشتیم را حذف می کنیم و به جای آن رابطه ی belongsTo ای با نام lastLogin می سازیم.

public function lastLogin()

{
    return $this->belongsTo(Login::class);

}

به طور عادی برای اینکه یک رابطه belongsTo درست کار کند، لازم است یک ستون به عنوان کلید خارجی در جدول وجود داشته باشد. یعنی در مثال ما باید ستون last_login_id در جدول users وجود داشته باشد. البته از آن جایی که جدول Users ما چنین ستونی ندارد، از یک Sub query استفاده می کنیم تا آن را select کنیم. یک scope جدید می سازیم که این کار را انجام دهد.

public function scopeWithLastLogin($query)

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

}

در این scope ما به وسیله ی sub query یک select اضافه می کنیم که ستون last_login_id ای که رابطه ی ما نیاز دارد را بخواند. این کوئری مشابه همانی است که برای گرفتن تاریخ آخرین ورود نوشتیم اما این بار در انتهای کوئری رابطه ی lastLogin ای که نوشته ایم را eagerLoad می کنیم. با استفاده از این تکنیک eloquent نمی داند که ستون last_login_id در جدول ما وجود ندارد و همه چیز به طور عادی کار می کند.

اکنون کوئری موجود در کنترلر UserController را به روز می کنیم.

public function index()

{
    $users = User::query()
        ->withLastLogin()
        ->orderBy('name')
        ->paginate();

    return view('users', ['users' => $users]);

}

دو scope موجود را پاک کردیم و withLastLogin را صدا زدیم.

در نهایت فایل blade کاربر ها را به روز می کنیم.

<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
  {{ $user->lastLogin->created_at->diffForHumans() }}
  <span class="text-xs text-gray-400">
    ({{ $user->lastLogin->ip_address }})
  </span>

</td>

به جای استفاده از last_login_at از رابطه lastLogin استفاده می کنیم تا به created_at و ip_address روی مدل login دسترسی پیدا کنیم. اکنون صفحه ی کاربر ها را refresh می کنیم تا نتیجه را ببینیم.

همه چیز درست کار می کند. نگاه نزدیک تری به کوئری ها می اندازیم.

اولین کوئری تعداد کاربر ها را برای صفحه بندی (pagination) به دست می آورد. کوئری دوم کاربر ها را از دیتابیس فراخوانی می کند که شامل sub query ای برای به دست آوردن id آخرین ورود آنها نیز است. در نهایت هم یک کوئری داریم که تمام Login های مورد نیاز را برای کابران می خواند.

همچنین اگر به سربرگ مدل ها نگاه کنید می بینید که 15 مدل user و 15 مدل login فراخوانی شده اند.

یک نکته ای که باید هنگام استفاده از این تکنیک بدانید این است که هرگز نمی توان از این رابطه به شکل lazy load استفاده کرد. دلیل این محدودیت این است که باید حتما scope ای که با نام withLastLogin نوشتیم را صدا بزنیم تا ستون last_login_id روی مدل ما حاضر باشد. البته به نظر من این محدودیت اهمیتی ندارد چرا که زمانی از این تکنیک استفاده می کنیم که می خواهیم یک مشکل بهره وری را حل کنیم و در چنین مواقعی lazy load کردن راه کار خوبی نیست.

ممکن است برای شما سوال شده باشد که آیا می توانستیم کاری که انجام شد را با ایجاد یک رابطه has one و order by هم انجام دهیم؟ پاسخ کوتاه به این سوال منفی است اما برای اینکه مطمئن شویم امتحان می کنیم.

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

رابطه ی lastLogin موجود در مدل User را به hasOne تغییر داده ایم و سپس order by انجام می دهیم. برای اینکه بتوانیم این کار را آزمایش کنیم در کنترلر UserController باید scope ای که نوشته بودیم را، کامنت کنیم.

public function index()
{
    $users = User::query()
      // ->withLastLogin()
        ->orderBy('name')
        ->paginate();

    return view('users', ['users' => $users]);
}

به مروگر بر می گردیم و refresh می کنیم.

همان طور که می بینید، صفحه ی کاربر ها هنوز به درستی کار می کند. اگر به سربرگ مدل ها نگاه کنید می بینید که هنوز 15 مدل login و 15 مدل user فراخوانی شده اند که درست است. اما اگر به کوئری ها نگاه کنید می بینید که مشکل n+1 دوباره به وجود آمده است. حال به کنترلر UserController بر می گردیم تا با eager load کردن رابطه ی lastLogin این مشکل را از بین ببریم.

public function index()
{
    $users = User::query()
        //->withLastLogin()
        ->with('lastLogin')
        ->orderBy('name')
        ->paginate();

    return view('users', ['users' => $users]);
}

اگر صفحه ی کاربر ها را refresh کنیم می بینیم که مشکل n+1 کوئری ها حل شده است.

به وجود آمدن مشکل فراخوانی مدل ها

اما اگر به سربرگ مدل ها برویم می بینید که مشکل فراخوانی شدن 7500 مدل برگشته است. این اتفاق کاملا منطقی است، زیرا از آن جایی که لاراول باید برای هر کاربر آخرین ورود را بخواند، نمی تواند هنگام eager load کردن رابطه last login، آن را محدود کند. برای بهتر دیدن این موضوع، limit را به رابطه lastLogin اضافه می کنیم.

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

اکنون اگر مرورگر را refresh کنید این خطا دریافت می شود.

Facade\Ignition\Exceptions\ViewException

Trying to get property 'created_at' of non-object

از پیام این خطا مشخص است که سعی داشتیم property ای با نام created_at را روی متغیری که object نیست صدا بزنیم. دلیل این اتفاق چیست؟

این اتفاق به این دلیل رخ می دهد که ما در فایل view انتظار داشتیم که هر کاربر یک رکورد از lastLogin داشته باشد. برای شفاف تر شدن موضوع شرطی به فایل blade مان اضافه می کنیم تا بهتر متوجه اتفاق رخ داده شویم.

<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
    @if($user->lastLogin)
       {{ $user->lastLogin->created_at->diffForHumans() }}
       <span class="text-xs text-gray-400">
           ({{ $user->lastLogin->ip_address }})
       </span>
    @endif
</td>

حال وقتی مرورگر را refresh کنیم، می بینیم که تاریخ آخرین ورود تنها برای یک کاربر از دیتابیس خوانده شده است.

اگر کوئری مربوط به login ها را نگاه کنیم دلیل این موضوع مشخص می شود. همانطور که می بینید، login های 15 کاربر موجود در صفحه select می شوند اما در انتهای کوئری limit 1 باعث می شود تنها یک رکورد خوانده شود.

بنابراین با اینکه استفاده از hasOne کمی ما را به نتیجه نزدیک می کند، اما مشکلات بهره وری را از بین نمی برد و این یکی از نشانه های قدرت استفاده از sub query ها است.

شایان توجه است که فریم ورک لاراول رابطه ای با نام hasOneOfMany را در جدیدترین نسخه ی خود معرفی کرده است که می توان برای حل مشکلی که در این مقاله مطرح شد از آن استفاده کرد. البته این رابطه، از راه حلی به جز sub query استفاده می کند. امیدوارم این مقاله برای شما مفید بوده باشد و با قدرت sub query ها و روابط dynamic آشنا شده باشید.

نظرات
اگر login نکردی برامون ایمیلت رو بنویس: