علاقات Eloquent والتحميل الحثيث في Laravel 5


عبدالرحيم فاخوري

قبل أن نذهب إلى الموضوع الرئيسيّ للمقال، سأعطيك لمحة قصيرة عن مشاكل التصميم التي قد تواجهها. لقد اشتكى لي أحد زبائني بأن بعض الصفحات تفتح ببطء شديد. وعندما أقول ببطء شديد، فإنني أعني ذلك! فقررت أن أصحح تلك الصفحة (بعمل debugging)، وما رأيته قد صدمني. لقد أظهر لي قسم الاستعلامات (queries) أن تلك الصفحة كانت تنفَّذ بعد القيام بكم هائل من الاستعلامات تعدّى 16500 استعلامًا!!

eager_loading-laravel.thumb.png.9c6712d8

 

لقد وجدت أن جزءًا من النصّ البرمجي هو سبب تلك المشكلة. لقد كانت هناك ثلاث حلقات foreach تستعلم عن خاصيّة والخواص الفرعيّة التابعة لها. لقد كانت تعمل جيدًا إلى أن صار في قاعدة البيانات 5500 عنصرًا. وفيما يلي ما كان يحدث:

$main_object = MainObject::all();

foreach($main_object as $object) {
    echo $object->some_property;
    foreach($object->related_object as $related) {
        echo $related->some_property;
        echo $related->another_property;
    }
    foreach($object->another_related as $another) {
        echo $another->some_property;
        echo $another->another_property;
    }
}

إذا كان الاستعلام ;()main_object = MainObject::all$ يعيد 5500 نتيجة، فستعيد حلقة foreach الأولى ذلك القدر أيضًا، وكذلك بالنسبة للثانية والثالثة. باستخدام ORM، كثيرًا ما يقع المبرمجون في فخّ كتابة استعلامات قواعد بيانات غير كفؤة، وتجعلها ORM أكثر صعوبة في الاكتشاف. تُسمّى هذه المشكلة بمشكلة N+1 (بالإنجليزيّة N+1 problem). وأظن المطور السابق لم يكن على علم بذلك. ولتفادي هذه المشكلة، نستخدم التحميل الحثيث (eager loading).

ما هو التحميل الحثيث؟

لتبسيط الأمر، التحميل الحثيث طريقة تُعنى بعمل كل شيء عند الطلب. وهذه الطريقة أيضًا على العكس تمامًا من التحميل الكسول (lazy loading) عندما ننفذ المهام عند الحاجة. يساعدنا التحميل الحثيث على تجنب مشكلات الأداء، كما رأيت في مثالي أعلاه. ستفهم الأمر أكثر من خلال مثال، لذا لنتخيل الوضع التالي:

pic1.thumb.png.94a71591d05847307fc3f46c8

لدينا نموذج علاقة هيئة محسّنة (بالإنجليزيّة: Enhanced Entity Relationship، واختصارًا EER)، بثلاث هيئات، كلّ منها مرتبطة بالأخرى. يمكنك أن تقرأ EER كما يلي: يمكن لكل عضو أن يملك العديد من المحلات، ولكن المحل الواحد ملك لعضو واحد فقط. يمكن للمحل الواحد أن يحوي العديد من المنتجات، ولكن المنتج الواحد لا يكون إلّا في محل واحد.

الخطوة التالية هي إنشاء نماذج Eloquent لهذه الهيئات:

العضو:

<?php 
    namespace App;
    use Illuminate\Database\Eloquent\Model;

    class Member extends Model {
        protected $fillable = ['username', 'email', 'first_name', 'last_name'];
        public function stores() {
            return $this->hasMany('App\\Store');
        }
    }

المحلّ:

<?php 
    namespace App;
    use Illuminate\Database\Eloquent\Model;

    class Store extends Model {
        protected $fillable = ['name', 'slug', 'site', 'member_id'];
        public function member() {
            return $this->belongsTo('App\\Member');
        }
        public function products() {
            return $this->hasMany('App\\Product');
        }
}

المُنتَج:

<?php 
    namespace App;
    use Illuminate\Database\Eloquent\Model;
    
    class Product extends Model {
        protected $fillable = ['name', 'short_desc', 'long_desc', 'price', 'store_id', 'member_id'];
        public function store() {
            return $this->belongsTo('App\\Store');
        }
    }

تخيّل أنك تبني تطبيقًا يسمح لمستخدميك أن يُنشئوا محالّهم التجاريّة الخاصّة. يمكن للمستخدمين –كما هو الحال بالنسبة للمحال الأخرى كلها طبعًا– أن يُنشئوا منتَجات عديدة. وأيضًا، يمكننا أن ننشئ صفحة واحدة تعرض كل المحلات وأفضل المنتجات لكل محلّ. شيء من قبيل هذا:

pic2.thumb.png.3d52edd250bc1561a13fdcd2d

يمكن أن ينتهي بك المطاف إلى الحصول على شيء كهذا في المتحكّم لديك:

<?php 
    namespace App\Http\Controllers;
    use App\Repositories\StoreRepository;

    class StoresController extends Controller {
        protected $stores;
        function __construct(StoreRepository $stores) {
            $this->stores = $stores;
        }
        public function index() {
            $stores = $this->stores->all();
            return \View::make('stores.index')->with('stores', $stores);
        }
    }

وفي العرض الذي ستقدم فيه تلك البيانات:

@foreach($stores as $store)
    <h1>{{ $store->name }}</h1>
    <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br>
    <h2>Products:</h2>
    @foreach($store->products as $product)
        <h3>{{$product->name}}</h3>
        <span>{{$product->short_desc}}</span><br/><br/>
        <span>Price: {{$product->price}}</span><br/>
        <?php Debugbar::info('Product displayed'); ?>
    @endforeach
    <br/>========================<br/>
@endforeach

والنتيجة كالتالي:

pic3.thumb.png.81f45d7561a7fe9028cc409f6

ومن اجل هذا المثال، زوّدت قاعدة البيانات بخمس مستخدمين، وثلاثة محالّ، وأربعة منتَجات. يقوم الاستعلام الأول باستدعاء كل المحال من قاعدة البيانات، وهذا هو الجزء +1 من مشكلة N+1. في هذا المثال تحديدًا، حرف N يمثّل عدد المحلات التي أرجعها لنا الاستعلام الأول، حيث أنها تمثل عدد المرات التي سنقوم فيها بالاستعلام select * from على جدولي products و members. وبما أن لدينا 3 محلات، فسنستعلم 3 مرات على جدول المستخدمين، وثلاث مرات على جدول المنتجات. وفي النهاية، قمنا بتنفيذ الاستعلامات بعدد مرات قدره 3+3+1.

تخيل الآن ما الذي يمكن أن يحدث لو كان لديك 5000 أو 10000 محل؟ سيكون لديك في تلك الحالة عشرة آلاف إلى عشرين ألف استعلام في كل مرة يقوم فيها أحد المستخدمين بزيارة الصفحة. وماذا لو كانت لديك عشرة آلاف أو مئة ألف زيارة كلّ أربع وعشرين ساعة؟ هذا كابوس! من الواضح الآن أن هذا التوجّه مدمّر للأداء. وبغض النظر عن نوع قاعدة البيانات التي تستخدمها، وعن مدى قوة الخادم الذي لديك، فستصل دائمًا إل تلك النقطة التي يقف فيها العتاد القوي لديك عاجزًا. يمكنك أن تحسّن الأداء بعمل cache لهذه الاستعلامات، باستخدام Redis على سبيل المثال. سيؤدي هذا الغرض، ولكن لبعض الوقت فقط. وبتلك الطريقة، أنت فقط تؤجل النهاية الحتميّة التي ستكلّفك الكثير من المال والوقت، وفي الغالب ستفقد بعض الزبائن، أو أنّ قاعدة بياناتك ستضعف كثيرًا.

وهنا يأتي التحميل الحثيث لينقذك من هذه الورطة. استخدام التحميل الحثيث في Laravel بسيط للغاية. العلاقات التي ترغب أن يتم تحميلها بشكل حثيث تحددها في طريقة with كما يلي:

$stores = Store::with('member','products')->get();

الآن، بدل استخدام 7 استعلامات، قلّلنا باستخدام التحميل الحثيث عدد الاستعلامات إلى 3 فقط:

pic4.thumb.png.6b36cfdc41ebbf5e138ee306e

وستكون ثلاثة استعلامات حتى ولو كانت لديك عشرة آلاف مدخلة في جدول المحلات. وكما ترى، فإن الاستخدام السليم للتحميل الحثيث يمكن أن يؤدي إلى تحسين أداء تطبيقك بقدر هائل. ولكي نحصل على تحسن للأداء بالفعل، فعلينا أن نوجد فهرسًا لحقل الهويّة id في جدولي members و products. ومع وجود كمّ هائل من السجلات، فإن تنفيذ (... ,'in( '1', '2 على حقل غير مفهرس سيأخذ وقتًا طويلًا.

وبعد هذه المقدمة عن التحميل الحثيث، هيا بنا نرى كيف يمكننا أن نستخدم العلاقات مع المستودعات.

تمديد فئة المستودع

سأريك طريقة واحدة يمكنك فيها أن تستخدم العلاقات في فئات مستودعات concrete. وهنا مثال عن النتيجة النهائيّة:

function __construct(StoreRepository $stores) {
    $this->stores = $stores;
}

public function index() {
    $stores = $this->stores->with('member', 'products')->all();
    ....
}

وكما ترى هنا، لدينا طريقة with يمكنك أن تسلسل فيها نموذج العلاقات. ستكون هذه الطريقة شبيهة بطريقة with في Laravel’s Query Builder.

public function with($relations) {
    if (is_string($relations)) $relations = func_get_args();
    $this->with = $relations;
    return $this;
}

نحتاج الآن لأن نربط كلّ علاقة من العلاقات التي قمنا بتقديمها بالنموذج:

protected function eagerLoadRelations() {
    if(!is_null($this->with)) {
        foreach ($this->with as $relation) {
            $this->model->with($relation);
        }
    }
    return $this;
}

وها هو ذا، والشيء الوحيد الذي تبقّى هو أن نحدّث طريقة مستودع ()all (وأي شيء آخر ترغب بتحديثه) لاستخدام التحميل الحثيث:

public function all($columns = array('*')) {
    $this->applyCriteria();
    $this->newQuery()->eagerLoadRelations();
    return $this->model->get($columns);
}

وكما سبق وذكرت، فيمكنك أن تضيف عدّة علاقات ضمن طريقة ()with. وفيما يلي مثال على StoresControler:

<?php 
    namespace App\Http\Controllers;
    use App\Repositories\StoreRepository;

    class StoresController extends Controller {
        protected $stores;
        function __construct(StoreRepository $stores) {
            $this->stores = $stores;
        }
        public function index() {
            $stores = $this->stores->with('member', 'products')->all();
            return \View::make('stores.index')->with('stores', $stores);
        }
    }

وفي العرض يمكنك أن تعرض البيانات بالطريقة التي تريدها، ولغرض التجربة يكفي هذا:

@foreach($stores as $store)
    <h1>{{ $store->name }}</h1>
    <span>Owner: {{ $store->member->first_name . ' ' . $store->member->last_name }}</span><br>
    <h2>Products:</h2>
    @foreach($store->products as $product) 
        <h3>{{$product->name}}</h3>
        <span>{{$product->short_desc}}</span><br/><br/>
        <span>Price: {{$product->price}}</span><br/>
        <?php Debugbar::info('Product displayed'); ?>
    @endforeach
    <br/>========================<br/>
@endforeach

وكما هو متوقع، لدينا الآن هذه الاستعلامات الثلاثة فقط:

pic5.thumb.png.61a6a34e29cafd231be676d20

الخلاصة

يمكنك باستخدام التحميل الحثيث أن تحسّن أداء تطبيقك. وأحيانًا، عندما يكبر التطبيق، حتى التحميل الحثيث ليس كافيًا للحفاظ على أعلى أداء. في الدرس التالي سأريك كيف يمكنك تجميل مستودعاتك لتقوم بعمل cache للاستعلامات من أجل أداء أفضل.

ترجمة -وبتصرف- للمقال: Using Repository Pattern in Laravel 5 - Eloquent Relations and Eager Loading.





تفاعل الأعضاء




يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن