Pruning Models

تا به حال در پروژه‌ای نیاز به این داشته‌اید که رکوردهای قدیمی دیتابیس را حذف کنید؟ در مواردی نیاز داریم که همه‌چیز باقی بماند، مثل تیکت‌های پشتیبانی که سوابق ضروری سرویس‌گیرنده هستند. اما شرایطی را در نظر بگیرید (اپلیکیشنی مثل دیوار که نیازی به آرشیو آگهی‌هایش ندارد، یا شاید هم دارد؟) که واقعا نیازی به رکوردهای قدیمی نداشته باشیم و بخواهیم آنها را با شروط خاصی حذف کنیم.

لاراول برای این هم راه‌حل دارد!

آقای Nuno Maduro در یک Pull Request پیشنهاد کرد که چنین قابلیتی به لاراول اضافه شود تا با پیامی زمان‌بندی‌شده، رکوردها را به صورت اتوماتیک حذف کنیم و این پیشنهاد بسیار مورد استقبال قرار گرفت. در مستندات لاراول، این خاصیت را به این شیوه تعریف کرده‌اند:

Sometimes you may want to periodically delete models that are no longer needed.

شاید گاهی بخواهید که به صورت دوره‌ای، مدل‌هایی که دیگر نیازی به آنها نیست را حذف کنید.

فعل Prune در انگلیسی به معنای هرس‌کردن است. انگار با حذف‌کردن رکوردهایی که نیاز نداریم، جدول مربوط به آن مدل‌ها را هرس می‌کنیم.

چگونه از آن استفاده کنیم؟

ابتدا Trait مربوط به آن یعنی Prunable را به مدلی که می‌خواهیم اضافه می‌کنیم:

class Ticket extends Model
{
    use Prunable;

اما لاراول از کجا بداند که ما به چه رکوردهایی نیاز نداریم؟! در Prunable که به عنوان Trait اضافه شد، متدی به نام ()prunable وجود دارد که خروجی آن از جنس Builder است و اگر در مدل پیاده‌سازی نشود، بعدها یک Exception بر خواهد گرداند. من چنین شرطی گذاشتم:

public function prunable()
{
    return static::where('created_at', '<', now()->subMonth());
}

پس هر رکوردی که تاریخ ایجاد آن از یک ماه پیش قدیمی‌تر باشد، حذف خواهد شد. می‌توانیم شروطی دیگری هم اضافه کنیم:

public function prunable()
{
    return static::where('created_at', '<', now()->subDay())
        ->where('is_vip', false);
}

حالا می‌توانیم با دستور model:prune به آزمایش آنچه انجام داده‌ایم بپردازیم:

λ php artisan model:prune
571 [App\Models\Ticket] records have been pruned.

عالی شد! با این دستور، مدل‌هایی که Prunable (قابل هرس) بودند به طور خودکار شناسایی شده و رکوردهای غیر ضروریشان (بر اساس متد prunable که خودمان تعریف کردیم) حذف خواهد شد.

البته برای آسان‌ترشدن کار، از متد schedule کلاس App\Console\Kernel برای زمان‌بندی استفاده می‌کنیم تا این اتفاق به صورت روزانه تکرار شود:

protected function schedule(Schedule $schedule)
{
    $schedule->command('model:prune')->daily();
}

گزینه‌ی pretend هم روی میز است!

گاهی قصد جدی برای حذف رکوردها نداریم و فقط می‌خواهیم بدانیم که اگر دستور model:prune را اجرا کنیم، چند رکورد حذف خواهد شد. در این صورت از یک option به نام pretend استفاده می‌کنیم:

λ php artisan model:prune --pretend                                                                               
58 [App\Models\Password] records will be pruned.

گزینه‌ی chunk

دستور model:prune یک گزینه‌ی دیگر به نام chunk دارد که توضیحش را در مستندات لاراول ندیدم. با این گزینه مشخص می‌کنیم که در هر chunk، چند مدل برای حذف‌شدن برگردانده شود و مقدار پیشفرضش 1000 است:

λ php artisan model:prune --chunk=100

متد ()pruning

یکی از متدهای تریت Prunable، متدی به نام pruning است که مشخص می‌کند قبل از حذف یک مدل چه اتفاقی بیفتد. مثلاً بلیتی که ما تولید می‌کنیم یک فایل pdf روی سرور دارد و باید با حذف رکوردهای قدیمی، آن فایل‌ها هم حذف شوند یا دیوار را در نظر بگیرید که باید تصاویر، گزارشات و دیگر موارد مربوط به آگهی را هم حذف کند. در این صورت از متد pruning در کلاس مربوط به مدل بلیت استفاده می‌کنیم و کدهای دلخواهمان را در آن می‌نویسیم:

public function pruning()
{
    $this->file()->delete();
}

حتما می‌دانید که this$ به نمونه‌ای از کلاس فعلی اشاره دارد و اگر باور نمی‌کنید، با ()dd چیزی که گفتم را آزمایش کنید!

کمی عمیق‌تر!

دستور model:prune چگونه کار می‌کند؟ اگر با Commandها آشنایی ندارید، توصیه می‌کنم بخش مربوط به Console از مستندات لاراول را مطالعه کنید.

کلاس PruneCommand یک Command است:

Illuminate\Database\Console\PruneCommand

با اجرای دستور model:prune متد handle این کلاس اجرا می‌شود كه کد تمیزی دارد:

$models = $this->models();

مدل‌هایی که تریت Prunable را استفاده کرده‌اند در متغیر models$ جمع می‌شوند.

if ($models->isEmpty()) {
    $this->info('No prunable models found.');
    return;
}

اگر هیچ مدل Prunableای در پروژه نباشد، یک پیام برگردانده می‌شود و همه‌چیز تمام می‌شود!

if ($this->option('pretend')) {
    $models->each(function ($model) {
        $this->pretendToPrune($model);
    });
    return;
}

اگر گزینه‌ی pretend وارد شده باشد، متد pretendToPrune را برای تک‌تک آنها اجرا می‌کند. اما اگر اینطور نبود؟

$events->listen(ModelsPruned::class, function ($event) {
    $this->info(&quot{$event->count} [{$event->model}] records have been pruned.&quot);
});

یک Event Listener برای رویداد ModelsPruned ثبت می‌کند. این رویداد در متد ()pruneAll تریت Prunable بعد از حذف‌کردن مدل‌ها و شمارش آنها اتفاق خواهد افتاد. پیامی که در ترمینال خواهیم دید، توسط این Event Listener تولید می‌شود.

$models->each(function ($model) {
    $instance = new $model;

    $chunkSize = property_exists($instance, 'prunableChunkSize')
        ? $instance->prunableChunkSize
        : $this->option('chunk');

    $total = $this->isPrunable($model)
        ? $instance->pruneAll($chunkSize)
        : 0;

    if ($total == 0) {
        $this->info(&quotNo prunable [$model] records found.&quot);
    }
});

به ازای هر مدل، این کارها انجام می‌شود: یک نمونه به نام instance ساخته می‌شود. سپس اگر روی مدل، مشخصه‌ای به نام prunableChunkSize تعریف شده باشد، آن را به عنوان سایز هر قطعه در نظر می‌گیرد (این نکته در مستندات لاراول نیست) وگرنه از گزینه‌ی chunk که در خط فرمان تعیین شده باشد استفاده می‌کند. اگر خواستید مقدار آن را در مدل تعیین کنید، کار سختی در پیش ندارید:

class Ticket extends Model
{
    public $prunableChunkSize = 200;

بگذریم... بعد از آن تعداد مدل‌های حذف‌شده در total$ قرار داده می‌شود و اگر total$ برابر صفر باشد (یعنی رکوردی برای حذف نباشد)، پیام مناسبی نمایش داده خواهد شد. صدازدن متد ()pruneAll روی instance$ بخش مهم کار را انجام می‌دهد:

public function pruneAll(int $chunkSize = 1000)
{
    $total = 0;
    $this->prunable()
        ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) {
        $query->withTrashed();
    })->chunkById($chunkSize, function ($models) use (&$total) {
        $models->each->prune();
        $total += $models->count();
        event(new ModelsPruned(static::class, $total));
    });

    return $total;
}

این متد در تریت Prunable قرار دارد و می‌توانید dispatchشدن رویداد ModelsPruned را هم در آن ببینید. ماجرا با متد prunable آغاز می‌شود که خودمان آن را در مدل تعریف کردیم و یک نمونه‌ی Builder را return کرد.


امیدوارم که از مقاله استفاده کرده باشید. این مقاله شکوه لاراول را نشان می‌دهد و همچنین اهمیت مطالعه‌ی کدها و اتفاقاتی که پشت پرده رخ می‌دهند. با خواندن کدهای ابزاری که با آن کار می‌کنیم، درک عمیق‌تری از آن خواهیم داشت و این روی خروجی کارمان تاثیرات به‌سزایی خواهد گذاشت.

کد تمیز بخوانید و کد تمیز بنویسید. ?