استخدام نمط المستودع في Laravel 5


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

يكثر الحديث هذه الأيام عن أنماط تصميم البرمجيات، ومن الأسئلة الأكثر شيوعًا "كيف يمكنني استخدام النمط الفلاني مع التقنية (التكنولوجيا) الفلانيّة؟". أما في حالة Laravel ونمط المستودع (Repository pattern)، فكثيرًا أرى أسئلة من قبيل "كيف يمكنني أن أستخدم نمط المستودع في Laravel 4؟" أو في هذه الأيام "مع Laravel 5؟". من المهم أن تتذكر أنّ أنماط التصميم لا تعتمد على تقنيّة محدّدة، أو إطار برمجي محدد، أو لغة برمجة محددة.

laravel-repository-pattern.thumb.png.37e

إذا كنت قد فهمت نمط المستودع بالفعل، فلا يهم الإطار البرمجي أو اللغة البرمجة التي ستستخدمها. المهم أن تفهم المبدأ الذي يقف خلف نمط المستودع، ومن ثم يمكنك استخدامه في أيّ تقنية تريد. آخذين هذا بعين الاعتبار، لنبدأ بتعريف نمط المستودع:

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

يفصل نمط المستودع منطق الوصول إلى البيانات (data access logic) ويربطه مكانيًّا بكيانات الأعمال (business entities) في المنطق الأعمال (business logic). ويتم التواصل بين منطق الوصول إلى البيانات ومنطق الأعمال باستخدام واجهات (interfaces).

repository_pattern.thumb.png.50c63014965

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

ولفصل الوصول إلى البيانات عن منطق الأعمال فوائد عدّة، منها:

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

الأمر كلّه يتعلق بالواجهات

كلّ ما في نمط المستودع ذو علاقة بالواجهات. تعمل الواجهة كاتفاقية تحدّد ما على فئة concrete تنفيذه.لنفكّر قليلًا. إذا كان لدينا كائني بيانات، الممثل والفلم، فما هي مجموعة العمليات الشائعة التي يمكن تطبيقها على هذين العنصرين؟ سنرغب في غالب الأحيان بالقيام بالعمليات التالية:

  1. الحصول على جميع السجلات.
  2. الحصول على مجموعة السجلّات مرقّمة.
  3. إنشاء سجلّ جديد.
  4. الحصول على سجل باستخدام المفتاح الرئيسيّ.
  5. الحصول على سجل باستخدام خواص (attributes) أخرى.
  6. تحديث سجلّ.
  7. حذف سجلّ.

هل يمكنك الآن أن ترى كميّة النصوص البرمجيّة المكررة التي ستكون لدينا إذا قمنا بذلك مع كلّ كائن بيانات؟ حسنًا، هذه ليست مشكلة كبيرة بالنسبة للمشاريع الصغيرة، ولكنها أمر سيّء بالنسبة للمشاريع الكبيرة.

الآن، وبعد أن عرّفنا العمليات الشائعة، يمكننا إنشاء واجهة:

interface RepositoryInterface {
  public function all($columns = array('*'));
  public function paginate($perPage = 15, $columns = array('*'));
  public function create(array $data);
  public function update(array $data, $id);
  public function delete($id);
  public function find($id, $columns = array('*'));
  public function findBy($field, $value, $columns = array('*'));
}

هيكليّة الدليل Directory Structure

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

repository_directory_structure.thumb.png

ولكنها يمكن أن تكون مختلفة، كأن يكون للمكونات خيارات ضبط مثلًا.

لدي في داخل الدليل src ثلاثة أدلّة أخرى، هي: Contracts وEloquent وexceptions. كما ترى، أسماء المجلدات مناسبة للغاية لما نريد وضعه فيها. ففي Contracts نضع الواجهات، أو الاتفاقيات – كما سميناها أعلاه - . ويحوي مجلد Eloquent مستودعي abstract و concrete تنفّذ الاتفاقات. ونضع في المجيد Exceptions فئات الاستثناءات.

وحيث أننا ننشئ حزم، فعلينا أن ننشئ ملف composer.json، حيث نجد فيه ربطًا مكانيًّا لنطاق الأسماء (namespaces) لأدلّة معيّنة، واعتماديات حزم، وبيانات وصفيّة أخرى للحزم. فيما يلي محتوى composer.json لهذه الحزمة:

{
  "name": "bosnadev/repositories",
  "description": "Laravel Repositories",
  "keywords": [
    "laravel",
    "repository",
    "repositories",
    "eloquent",
    "database"
  ],
  "licence": "MIT",
  "authors": [
    {
      "name": "Mirza Pasic",
      "email": "mirza.pasic@edu.fit.ba"
    }
  ],
  "require": {
    "php": ">=5.4.0",
    "illuminate/support": "5.*",
    "illuminate/database": "5.*"
  },
  "autoload": {
    "psr-4": {
      "Bosnadev\\Repositories\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Bosnadev\\Tests\\Repositories\\": "tests/"
    }
  },
  "extra": {
    "branch-alias": {
      "dev-master": "0.x-dev"
    }
  },
  "minimum-stability": "dev",
  "prefer-stable": true
}

كما ترى، فقد ربطنا Bosnadev\Repository إلى الدليل src. وهناك شيء آخر علينا أخذه بعين الاعتبار قبل البدء بتنفيذ RepositoryInterface، فحيث أنها موجودة في المجلد Contracts، فعلينا أن نحدّد نطاق الاسم لها:

<?php namespace Bosnadev\Repositories\Contracts;

interface RepositoryInterface {...}

نحن الآن مستعدون لبدء تنفيذ المحتوى.

تنفيذ المستودع

على كل مستودع فرعيّ في concrete أن يزيد من مستودع abstract لدينا، مما يعني تنفيذ اتفاقية RepositoryInterface. أما الآن، فكيف تنفّذ هذه الاتفاقيّة؟ ألقِ نظرة على method الأولى. ما الذي يمكنك قوله عنها بمجرد النظر إليها؟

تُسمى method الأولى في اتفاقيتنا بالاسم ()all للتوضيح. مهمتها هي جلب كلّ السجلات لهيئة concrete. تستقبل معامِلاً واحدًا، وهو columns$ الذي يجب أن يكون مصفوفة (array). يُستخدّم هذا المعامِل – كما هو واضح من اسمه – لتحديد الأعمدة التي ترغب بجلبها من مصدر البيانات، ومبدئيًّا نقوم بجلبها كلّها.

لهئيات محدّدة، يمكن أن تبدو كالتالي:

<?php namespace Bosnadev\Repositories\Contracts;

interface RepositoryInterface {...}

لكننا نريدها أن تكون عامّة أكثر لكي نتمكن من استخدامها متى أردنا:

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

في هذه الحالة this->model$ هي نسخة من Bosnadev\Models\Actor، ولهذا، فعلينا إنشاء نسخة جديدة للنموذج الموجود في مكان ما في المستودع. هنا أحد الحلول التي يمكنك استخدامها لتنفيذ هذا الأمر:

<?php namespace Bosnadev\Repositories\Eloquent;
 
use Bosnadev\Repositories\Contracts\RepositoryInterface;
use Bosnadev\Repositories\Exceptions\RepositoryException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Container\Container as App;
 
/**
 * Class Repository
 * @package Bosnadev\Repositories\Eloquent
 */
abstract class Repository implements RepositoryInterface {
 
    /**
     * @var App
     */
    private $app;
 
    /**
     * @var
     */
    protected $model;
 
    /**
     * @param App $app
     * @throws \Bosnadev\Repositories\Exceptions\RepositoryException
     */
    public function __construct(App $app) {
        $this->app = $app;
        $this->makeModel();
    }
 
    /**
     * Specify Model class name
     * 
     * @return mixed
     */
    abstract function model();
 
    /**
     * @return Model
     * @throws RepositoryException
     */
    public function makeModel() {
        $model = $this->app->make($this->model());
 
        if (!$model instanceof Model)
            throw new RepositoryException("Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model");
 
        return $this->model = $model;
    }
}

وحيث أننا عرفنا الفئة كـ abstract، فهذا يعني أن علينا زيادتها باستخدام فئة متفرّعة عن concrete. فعند تعريف method المسمّى ()model كـ abstract، فإننا نجبر المستخدم على تنفيذ هذه الطريقة (method) في فئة متفرعة عن concrete. فمثلًا:

<?php namespace App\Repositories;
use Bosnadev\Repositories\Contracts\RepositoryInterface;
use Bosnadev\Repositories\Eloquent\Repository;

class ActorRepository extends Repository {

  /**
  * Specify Model class name
  *
  * @return mixed
  */
  function model(){
    return 'Bosnadev\Models\Actor';
  }
}

يمكننا الآن أن ننفذ بقية طرق (methods) الاتفاقات:

<?php namespace Bosnadev\Repositories\Eloquent;
 
use Bosnadev\Repositories\Contracts\RepositoryInterface;
use Bosnadev\Repositories\Exceptions\RepositoryException;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Container\Container as App;
 
/**
 * Class Repository
 * @package Bosnadev\Repositories\Eloquent
 */
abstract class Repository implements RepositoryInterface {
 
    /**
     * @var App
     */
    private $app;
 
    /**
     * @var
     */
    protected $model;
 
    /**
     * @param App $app
     * @throws \Bosnadev\Repositories\Exceptions\RepositoryException
     */
    public function __construct(App $app) {
        $this->app = $app;
        $this->makeModel();
    }
 
    /**
     * Specify Model class name
     *
     * @return mixed
     */
    abstract function model();
 
    /**
     * @param array $columns
     * @return mixed
     */
    public function all($columns = array('*')) {
        return $this->model->get($columns);
    }
 
    /**
     * @param int $perPage
     * @param array $columns
     * @return mixed
     */
    public function paginate($perPage = 15, $columns = array('*')) {
        return $this->model->paginate($perPage, $columns);
    }
 
    /**
     * @param array $data
     * @return mixed
     */
    public function create(array $data) {
        return $this->model->create($data);
    }
 
    /**
     * @param array $data
     * @param $id
     * @param string $attribute
     * @return mixed
     */
    public function update(array $data, $id, $attribute="id") {
        return $this->model->where($attribute, '=', $id)->update($data);
    }
 
    /**
     * @param $id
     * @return mixed
     */
    public function delete($id) {
        return $this->model->destroy($id);
    }
 
    /**
     * @param $id
     * @param array $columns
     * @return mixed
     */
    public function find($id, $columns = array('*')) {
        return $this->model->find($id, $columns);
    }
 
    /**
     * @param $attribute
     * @param $value
     * @param array $columns
     * @return mixed
     */
    public function findBy($attribute, $value, $columns = array('*')) {
        return $this->model->where($attribute, '=', $value)->first($columns);
    }
 
    /**
     * @return \Illuminate\Database\Eloquent\Builder
     * @throws RepositoryException
     */
    public function makeModel() {
        $model = $this->app->make($this->model());
 
        if (!$model instanceof Model)
            throw new RepositoryException("Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model");
 
        return $this->model = $model->newQuery();
    }
}

الأمر سهل للغاية، صح؟ الشيء الوحيد المتبقي الآن هو حقن ActorRepository في ActorsController، أو الجزء المتعلق بالأعمال (business logic) من تطبيقنا.

<?php namespace App\Http\Controllers;
use App\Repositories\ActorRepository as Actor;

class ActorsController extends Controller {

  /**
   * @var Actor
   */
  private $actor;

  public function __construct(Actor $actor) {
    $this->actor = $actor;
  }
  
  public function index() {
    return \Response::json($this->actor->all());
  }
}

استعلامات المعايير Criteria Queries

كما يمكنك أن تتخيل، فهذه الأعمال البسيطة كافية فقط لاستعلامات البسيطة. أما بالنسبة للتطبيقات الكبيرة، فستحتاج بالتأكيد لعمل بعض الاستعلامات الخاصّة لاستدعاء مجموعة بيانات محدّدة أكثر باستخدام معايير محدّدة.

للقيام بذلك، نبدأ بتحديد ما على معايير الفروع (child ، أو العميل client) القيام به. وبعبارة أخرى، سننشئ فئة abstract non-instantiable باستخدام method واحدة فقط :

<?php namespace Bosnadev\Repositories\Criteria;

use Bosnadev\Repositories\Contracts\RepositoryInterface as Repository;
use Bosnadev\Repositories\Contracts\RepositoryInterface;

abstract class Criteria {
  /**
   * @param $model 
   * @param RepositoryInterface $repository 
   * @return mixed 
   */
   public abstract function apply($model, Repository $repository);
}

ستحوي هذه الطريقة (method) استعلام معايير يتم تطبيقه في فئة Repository ضمن هيئة concrete. علينا أيضًا أن نزيد على فئة Repository قليلًا لتغطية استعلامات المعايير. لكن قبل ذلك، هيّا ننشئ اتفاقية أخرى لفئة Repository.

<?php namespace Bosnadev\Repositories\Contracts;
use Bosnadev\Repositories\Criteria\Criteria;

/**
 * Interface CriteriaInterface
 * @package Bosnadev\Repositories\Contracts 
 */

interface CriteriaInterface {
  /*** @param bool $status 
   * @return $this 
   */
   public function skipCriteria($status = true);
  /*** @return mixed*/public function getCriteria();
  /*** @param Criteria $criteria* @return $this*/public function getByCriteria(Criteria $criteria);
  /*** @param Criteria $criteria* @return $this*/public function pushCriteria(Criteria $criteria);
  /*** @return $this*/public function applyCriteria();
}

يمكننا أن نزيد فاعلية فئة Repository بتنفيذ اتفاقية CriteriaInterface:

<?php namespace Bosnadev\Repositories\Eloquent;
 
use Bosnadev\Repositories\Contracts\CriteriaInterface;
use Bosnadev\Repositories\Criteria\Criteria;
use Bosnadev\Repositories\Contracts\RepositoryInterface;
use Bosnadev\Repositories\Exceptions\RepositoryException;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Container\Container as App;
 
/**
 * Class Repository
 * @package Bosnadev\Repositories\Eloquent
 */
abstract class Repository implements RepositoryInterface, CriteriaInterface {
 
    /**
     * @var App
     */
    private $app;
 
    /**
     * @var
     */
    protected $model;
 
    /**
     * @var Collection
     */
    protected $criteria;
 
    /**
     * @var bool
     */
    protected $skipCriteria = false;
 
    /**
     * @param App $app
     * @param Collection $collection
     * @throws \Bosnadev\Repositories\Exceptions\RepositoryException
     */
    public function __construct(App $app, Collection $collection) {
        $this->app = $app;
        $this->criteria = $collection;
        $this->resetScope();
        $this->makeModel();
    }
 
    /**
     * Specify Model class name
     *
     * @return mixed
     */
    public abstract function model();
 
    /**
     * @param array $columns
     * @return mixed
     */
    public function all($columns = array('*')) {
        $this->applyCriteria();
        return $this->model->get($columns);
    }
 
    /**
     * @param int $perPage
     * @param array $columns
     * @return mixed
     */
    public function paginate($perPage = 1, $columns = array('*')) {
        $this->applyCriteria();
        return $this->model->paginate($perPage, $columns);
    }
 
    /**
     * @param array $data
     * @return mixed
     */
    public function create(array $data) {
        return $this->model->create($data);
    }
 
    /**
     * @param array $data
     * @param $id
     * @param string $attribute
     * @return mixed
     */
    public function update(array $data, $id, $attribute="id") {
        return $this->model->where($attribute, '=', $id)->update($data);
    }
 
    /**
     * @param $id
     * @return mixed
     */
    public function delete($id) {
        return $this->model->destroy($id);
    }
 
    /**
     * @param $id
     * @param array $columns
     * @return mixed
     */
    public function find($id, $columns = array('*')) {
        $this->applyCriteria();
        return $this->model->find($id, $columns);
    }
 
    /**
     * @param $attribute
     * @param $value
     * @param array $columns
     * @return mixed
     */
    public function findBy($attribute, $value, $columns = array('*')) {
        $this->applyCriteria();
        return $this->model->where($attribute, '=', $value)->first($columns);
    }
 
    /**
     * @return \Illuminate\Database\Eloquent\Builder
     * @throws RepositoryException
     */
    public function makeModel() {
        $model = $this->app->make($this->model());
 
        if (!$model instanceof Model)
            throw new RepositoryException("Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model");
 
        return $this->model = $model->newQuery();
    }
 
    /**
     * @return $this
     */
    public function resetScope() {
        $this->skipCriteria(false);
        return $this;
    }
 
    /**
     * @param bool $status
     * @return $this
     */
    public function skipCriteria($status = true){
        $this->skipCriteria = $status;
        return $this;
    }
 
    /**
     * @return mixed
     */
    public function getCriteria() {
        return $this->criteria;
    }
 
    /**
     * @param Criteria $criteria
     * @return $this
     */
    public function getByCriteria(Criteria $criteria) {
        $this->model = $criteria->apply($this->model, $this);
        return $this;
    }
 
    /**
     * @param Criteria $criteria
     * @return $this
     */
    public function pushCriteria(Criteria $criteria) {
        $this->criteria->push($criteria);
        return $this;
    }
 
    /**
     * @return $this
     */
    public function  applyCriteria() {
        if($this->skipCriteria === true)
            return $this;
 
        foreach($this->getCriteria() as $criteria) {
            if($criteria instanceof Criteria)
                $this->model = $criteria->apply($this->model, $this);
        }
 
        return $this;
    }
}

إنشاء معايير جديدة، حيث يمكنك الآن أن ترتب مستودعاتك بسهولة أكبر. لست بحاجة لأن تكون مستودعاتك مكونة من آلاف الأسطر.

criteria_structure.thumb.png.6a2a0a4aa99

يمكن أن تبدو فئة المعايير لديك كالآتي:

<?php namespace App\Repositories\Criteria\Films;
use Bosnadev\Repositories\Contracts\CriteriaInterface;
use Bosnadev\Repositories\Contracts\RepositoryInterface as Repository;
use Bosnadev\Repositories\Contracts\RepositoryInterface;

class LengthOverTwoHours implements CriteriaInterface {
  /**
   * @param $model 
   * @param RepositoryInterface $repository 
   * @return mixed 
   */

  public function apply($model, Repository $repository){
    $query = $model->where('length', '>', 120);
    return $query;
  }
}

استخدام للمعايير (Criteria) في المتحكّمات (Controllers)

الآن وقد صارت لدينا معايير بسيطة، لنرَ كيف يمكننا استخدامها. هناك طريقتان يمكنك اتباعهما لتطبيق المعايير على مستودع. الأولى باستخدام طريقة ()pushCriteria:

<?php namespace App\Http\Controllers;
use App\Repositories\Criteria\Films\LengthOverTwoHours;
use App\Repositories\FilmRepository as Film;

class FilmsController extends Controller {
  /*** @var Film*/private $film;

  public function __construct(Film $film) {
    $this->film = $film;
  }

  public function index() {
    $this->film->pushCriteria(new LengthOverTwoHours());
    return \Response::json($this->film->all());
  }
}

هذه الطريقة مفيدة إذا كنت ترغب بتطبيق أكثر من معيار في نفس الوقت، حيث يمكنك رصّها معًا كما تشاء، ولكن إذا رغب بتطبيق معيار واحد فقط، فيمكنك استخدام طريقة ()getByCriteria:

<?php namespace App\Http\Controllers;
use App\Repositories\Criteria\Films\LengthOverTwoHours;
use App\Repositories\FilmRepository as Film;

class FilmsController extends Controller {
  /**
   * @var Film 
   */
  private $film;
  
  public function __construct(Film $film) {
    $this->film = $film;
  }
  
  public function index() {
    $criteria = new LengthOverTwoHours();
    return \Response::json($this->film->getByCriteria($criteria)->all());
  }
}

تثبيت الحزم

يمكنك تثبيت هذه الحزمة بإضافة الاعتمادية التالية إلى قسم require في ملف composer لديك:

"bosnadev/repositories": "0.*"

وتنفيذ composer update بعد ذلك،

الخلاصة

لاستخدام المستودعات في تطبيقك العديد من الفوائد. من أشياء بسيطة كتقليل تكرار الأكواد ومنعك من ارتكاب أخطاء برمجيّة وجعل تطبيقك أسهل في التكبير والتوسعة، والاختبار، والصيانة.

من وجهة نظر هندسيّة، فقد تمكنت من عزل الاهتمامات. المتحكّم (controller) لديك ليس بحاجة لأن يعرف كيف وأين تخزَّن البيانات. بسيط وجميل. تجريديّ (Abstract).

ترجمة وبتصرف للمقال: Using Repository Pattern in Laravel 5.





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


لا توجد أيّة تعليقات بعد



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

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

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


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

تسجيل الدخول

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


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