翻译自这篇博文,原作者Mirza Pasic。
(前言:懵懵懂懂的用了一段时间的 Repository 模式,想更深入学习一下的时候,看了一些资料,感觉这篇不错,翻译一下作为自己的笔记)
简介
如果你真的理解了 Repository 模式,你会发现不管你用什么框架或者语言 Repository 模式都是很有用的。重要的是理解 Repository 模式背后的原理。然后你就可以用各种各样的技术实现它。所以,让我们从 Repository 模式的定义开始:
A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction. Objects can be added to and removed from the Repository, as they can from a simple collection of objects, and the mapping code encapsulated by the Repository will carry out the appropriate operations behind the scenes.
(注:还是英文的定义比较准确,就不翻译了)
Repository 模式把存取数据的逻辑从业务逻辑中分离了出来。使得业务逻辑和数据存取逻辑之间可以通过接口进行通信。
简单地说,Repository 模式就是一种用来保存数据存取逻辑的容器。它把数据存取逻辑包装了起来,使得业务逻辑不用关心数据存取逻辑的细节。换句话说,我们在写业务逻辑的时候,可以不必了解底层的数据存储逻辑的细节,直接去调用 Repository 层提供的接口来获取数据对象。
将数据存取逻辑与业务逻辑分离有许多好处,比如:
- 把数据存储逻辑集中起来管理可以使我们的代码更易于维护
- 业务逻辑和数据存储逻辑可以互不干扰的进行测试
- 重用代码
- 减少犯错的可能性
这一切都和 Interface 有关
当我们在谈论 Repository 模式的时候,大部分时间我们都在谈论 interface 。interface 就像一个契约,它指定一个具体的类必须实现的一些东西。我们设想一下,如果我们有两个对象 Actor 和 Film , 那么对于这两个对象的集合来说,有什么方法是通用的呢。一般情况下,我们应该有以下这几种方法:
- 获取全部的记录
- 获取分页的记录
- 创建一个新记录
- 通过主键获取记录
- 通过其他的属性获取记录
- 修改一条记录
- 删除一条记录
如果我们为每一个对象都实现一遍这些方法,你会发现我们写了非常多的重复的代码。对于小项目来说,这没有什么关系,但对一个大项目来说这绝对是一个坏消息。我们已经确定了我们需要用到的通用的方法,现在。可以创建一个 interface:
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('*')); }
目录结构
在我们编写具体的 repository 类来实现上面的 interface 之前,先思考一下应该如何组织这些代码。通常,当我造轮子的时候,我喜欢把他们整理为一个组件,因为我希望能在另一个项目当中重用这些代码。那么,一个简单的 repositories 组件的目录结构大概像下面这样的:
当然了,这不是唯一的结构,如果你有别的需求你也可以使用别的结构。比如,组件里还要包含一些设置选项,迁移,之类的东西。
在 src 目录下,还有三个目录,分别是:Contracts, Eloquent 和 Exceptions。顾名思义,从文件夹的名字我们就能知道要把什么东西放进去。在 Contracts 里我们放 interface 或者别的一些契约。Eloquent 文件夹下放一些 抽象repository 类 或者具体的 repository 类来实现 interface 。在 Exceptions 文件下放和 exception 相关的类。
当我们创建了一个 package 的时候 我们还需要创建一个 composer.json 文件来说明一些东西比如:命名空间到特定的目录的映射,声明一些依赖,还有其他的元数据。以下是这个 package 的 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 目录下。在我们开始编写 repository 类的 interface 之前还有一件事,由于这个文件将会位于 Contracts 文件夹下 ,它的命名空间应该是这样的:
<?php namespace Bosnadev\Repositories\Contracts; interface RepositoryInterface { ... }
我们现在已经准备好去实现这个契约了。
一个 Repository 的实现
通过 repositories 层我们可以从数据源那得到数据,把数据映射到实例上,或保存对实例的更改回到数据源上。
当然了,每一个具体的 repository 子类都应该继承我们实现了 RepositoryInterface 的 repository 抽象类。
我们要写的第一个方法的名称是:all() 它的任务是获取所有的记录。它只接受一个参数: $columns 而且它必须是一个数组。顾名思义,它表示我们希望从数据源中获得哪些列,默认情况下我们将获得所有的列。
对一个特殊的实例来说,它的实现可能是这样的:
public function all($columns = array('*')) { return Bosnadev\Models\Actor::get($columns); }
但是,我们希望它是通用的,只有这样,我们才能在任何地方使用它:
public function all($columns = array('*')) { return $this->model->get($columns); }
上面的那个例子里,$this->model 替代了 Bosnadev\Models\Actor 。因此,我们需要在 repository 类里创建一个方法来实例化给定的 model ,下面这段代码演示了如何实现这个方法:
<?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'; } }
现在,我们可以接着去实现 interface 里剩下的方法:
<?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 中,或者别的东西里,就像这样:
<?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()); } }
条件查询
你可以发现,这些基本的操作只满足了最简单的查询需求。对于较大的项目,你肯定会需要进行一些自定义查询条件来获得满足某些特定条件的数据集。为了实现这一点,我们将创建一个只有一个方法的抽象类。
<?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); }
这个方法将被用来保存 应用于Repository 类的实例上的查询条件。我们需要扩展我们的 Repository 类上的方法,来实现一些使用 criteria 类的查询。我们先给 Repository 类 创建一个新的 interface 。
<?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(); }
现在,我们可以通过实现 CriteriaInterface 来扩展 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 类
现在,你可以更容易的组织你的 repositories ,而不再需要编写几千行代码。
你的 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; } }
在 Controller 中使用 criteria 类
我们有了一些简单的查询条件类,现在来看看如何使用它们。有两种方式可以把 criteria 应用到 repository 上。
第一种是使用 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()); } }
安装 repository 包
如果你不想自己造这些轮子的话,你可以在 composer require 部分添加如下的代码,然后执行 composer update 来安装这个造好的轮子。
"bosnadev/repositories": "0.*"
结论
在你的应用中使用 repository 模式有很多的好处。
基本的比如减少重复的代码,减少犯错的几率,并能使你的程序容易扩展,测试,和维护。
从结构的角度上说,你设法分离了你的程序的逻辑,你的控制器不需要再去关心数据存取的细节。简单,美丽,抽象。
你可以在 GitHub 上找到这个包,你也可以获取最新的更新。我还计划添加一些功能比如,渴求式加载,缓存,还有一些配置选项。如果你想贡献一些代码,可以给我提交PR。
文章评论