在 Laravel 5 中使用 Repository 模式實現業務邏輯和數據訪問的分離

1、概述

首先需要聲明的是設計模式和使用的框架以及語言是無關的,關鍵是要理解設計模式背後的原則,這樣才能不管你用的是什麼技術,都能夠在實踐中實現相應的設計模式。

按照最初提出者的介紹,Repository 是銜接數據映射層和領域層之間的一個紐帶,作用相當於一個在內存中的域對象集合。客戶端對象把查詢的一些實體進行組合,並把它 們提交給 Repository。對象能夠從 Repository 中移除或者添加,就好比這些對象在一個 Collection 對象上進行數據操作,同時映射層的代碼會對應的從數據庫中取出相應的數據。

從概念上講,Repository 是把一個數據存儲區的數據給封裝成對象的集合並提供了對這些集合的操作。

Repository 模式將業務邏輯和數據訪問分離開,兩者之間通過 Repository 接口進行通信,通俗點說,可以把 Repository 看做倉庫管理員,我們要從倉庫取東西(業務邏輯),只需要找管理員要就是了(Repository),不需要自己去找(數據訪問),具體流程如下圖所示:

Respository原理圖

這種將數據訪問從業務邏輯中分離出來的模式有很多好處:

  • 集中數據訪問邏輯使代碼易於維護
  • 業務和數據訪問邏輯完全分離
  • 減少重複代碼
  • 使程序出錯的機率降低

2、只是接口而已

要實現 Repository 模式,首先需要定義接口,這些接口就像 Laravel 中的契約一樣,需要具體類去實現。現在我們假定有兩個數據對象 Actor 和 Film。這兩個數據對象上可以進行哪些操作呢?一般情況下,我們會做這些事情:

  • 獲取所有記錄
  • 獲取分頁記錄
  • 創建一條新的記錄
  • 通過主鍵獲取指定記錄
  • 通過屬性獲取相應記錄
  • 更新一條記錄
  • 刪除一條記錄

現在你已經意識到如果我們爲每個數據對象實現這些操作要編寫多少重複代碼!當然,對小型項目而言,這不是什麼大問題,但如果對大型應用而言,這顯然是個壞主意。

現在,如果我們要定義這些操作,需要創建一個 Repository 接口:

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('*'));
}

3、目錄結構

在我們繼續創建具體的 Repository 實現類之前,讓我們先想想要如何組織我們要編寫的代碼,通常,當我要創建一些類的時候,我喜歡以組件的方式來組織代碼,因爲我希望這些代碼可以很方便地在其他項目中被複用。我爲 Repository 組件定義的目錄結構如下:

Respository目錄結構

但是這也不是一成不變的,要視具體情況來定。比如如果組件包括配置項,或者遷移之類的話,目錄結構會有所不同。

src 目錄下,我創建了三個子目錄:ContractsEloquentExceptions。這樣命令的原因是顯而易見的,一眼就能看出裏面存放的是什麼類。我們會將接口放在 Contracts 目錄下,Eloquent 目錄用於存放實現Repository 接口的抽象類和具體類,而 Exceptions 目錄用於存放異常處理類。

由於我們創建的是一個擴展包,需要創建一個 composer.json 文件用於定義命名空間映射目錄,包依賴以及其它的元數據。下面是我的composer.json 文件內容:

{
    "name": "bosnadev/repositories",
    "description": "Laravel Repositories",
    "keywords": [
        "laravel",
        "repository",
        "repositories",
        "eloquent",
        "database"
    ],
    "licence": "MIT",
    "authors": [
        {
            "name": "Mirza Pasic",
            "email": "[email protected]"
        }
    ],
    "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 {
    ...
}

下面我們準備正式開始實現這個契約。

4、Repository 實現

使用 Repository 允許我們在數據源(Data Source)中查詢數據,並將這些數據返回給業務邏輯(Business Entity),同時也能夠將業務邏輯中的數據修改持久化到數據源中:

Respository實現

當然,每一個具體的子 Repository 都繼承自抽象的 Repository 父類,這個抽像的 Repository 父類則實現了 RepositoryInterface 契約。現在,我們正式開始實現這個契約。

契約中的第一個方法是 all(),用於爲具體業務邏輯獲取所有記錄,該方法只接收一個數組參數 $columns,該參數用於指定從數據源中返回的字段,默認返回所有字段:

public function all($columns = array('*')) {
    return Bosnadev\Models\Actor::get($columns);
}

但是這樣還不夠,我們想讓它成爲一個更通用的方法:

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

其中 $this->modelBosnadev\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;
   }
}

我們在該抽象類中定義了一個抽象方法 model(),強制在實現類中實現該方法已獲取當前實現類對應的模型:

<?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';
    }
}

現在我們在抽象類中實現其它契約方法:

<?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();
    }
}

很簡單,是吧?現在剩下唯一要做的就是在 ActorsController 中依賴注入 ActorRepository

<?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());
    }
}

5、Criteria 查詢實現

上面的實現對簡單查詢而言已經足夠了,但是對大型項目而言,有時候需要通過 Criteria 創建一些自定義查詢獲取一些更加複雜的查詢結果集。

要實現這一功能,我們首先定義如下這個抽象類:

<?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);
}

該抽象類中聲明瞭一個抽象方法 apply,在繼承該抽象類的具體實現類中需要實現這個方法實現 Criteria 查詢。在定義實現該抽象類的具體類之前,我們先爲 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 的抽象類如下:

<?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

有了 Criteria 查詢,你現在可以更簡單的組織 Repository 代碼:

Criteria

你可以這樣定義 Criteria 類:

<?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

現在我們已經定義了一些簡單的 Criteria,現在我們來看看如何使用它們。在 Repository 中有兩種方式使用 Criteria,第一種個是使用 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());
    }
}

這個方法在你需要多個 Criteria 時很有用。然而,如果你只想使用一個 Criteria,可以使用 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());
    }
}

6、安裝依賴包

本教程提及的 Repository 實現在 GitHub 上有對應擴展包:https://github.com/Bosnadev/Repositories

你可以通過在項目根目錄下的 composer.json 中添加如下這行依賴:

"bosnadev/repositories": "0.*"
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章