在 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": "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 {
    ...
}

下面我们准备正式开始实现这个契约。

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.*"

然后运行 composer update 来安装这个 Repository 包。

声明:本文为英文译文,原文:Using Repository Pattern in Laravel 5

学院君

学院君 has written 548 articles

资深PHP工程师,Laravel学院院长