最近有很多关于设计模式的讨论,最常见的问题之一是“我该如何将一些模式与技术结合使用”。例如 Laravel 与仓库模式,我经常看到类似这样的提问“如何在 Laravel 4 中使用仓库模式”,当然现在是“在 Laravel 5 中”。你必须记住重要的一点,设计模式不依赖于特定的技术、框架或编程语言。
序言
如果你真正理解仓库模式,你会发现其对于你正在使用的框架或编程语言并不重要。了解仓库模式背后的原理更为重要。之后你可以使用任意你想要使用的技术实现它。考虑到这一点,让我们从仓库模式的定义开始:
仓库在域和数据映射层之间进行调节,其作用类似于内存中的领域对象集合。客户端对象声明构造查询规范并将它们提交到仓库以满足需求。仓库中的对象可以添加和删除,就像可以从一个简单的对象集合中添加或删除对象一样,并且由仓库类封装的映射代码将在后台执行适宜的操作。
仓库模式使数据访问逻辑分离并将其映射到业务逻辑中的业务实体。数据访问逻辑和业务逻辑之间的通信通过接口来完成。
简单来看,仓库模式是一种存储数据访问逻辑的容器。其隐藏了业务逻辑中数据访问逻辑的细节。换句话说,我们允许业务逻辑在不了解底层数据访问体系结构的情况下访问数据对象。
将数据访问与业务逻辑分离具有许多优点。其中包括:
- 集中化数据访问逻辑,使代码更易于维护
- 业务和数据访问逻辑可以单独进行测试
- 减少重复代码
- 降低编程错误可能性
关键点:接口
仓库模式的重点是接口。接口就像一个合同(contract),它指定了具体类必需实现的内容。我们想一下,如果我们有两个数据对象 Actor 和 Film,那么我们可以对这两个数据对象应用哪些通用操作?大多数情况下,我们希望进行以下操作:
- 获取所有记录
- 获取分页记录
- 新增记录
- 通过主键获取记录
- 通过其它属性获取记录
- 更新记录
- 删除记录
如果我们为每个数据对象实现这些功能,现在你能看到我们将会有多少重复代码了吗?当然,对于小型项目来说这不是一个大问题,但对于大型应用来说,这绝对很糟糕。
当我们确定了通用操作时,我们可以创建一个接口:
interface RepositoryInterface {
public function all($columns = ['*']);
public function paginate($perPage = 15, $columns = ['*']);
public function create(array $data);
public function update(array $data, $id);
public function delete($id);
public function find($id, $columns = ['*']);
public function findBy($field, $value, $columns = ['*']);
}
目录结构
在继续创建实现该接口的具体仓库类之前,让我们考虑一下如何组织代码。通常,当我创建一些东西时,我喜欢以组件的方式思考,因为我希望能够在其它项目中重用该代码。我这个简单的仓库组件目录结构如下:
如果组件具有配置选项或迁移等情况可能会有所不同。
在 src
目录中,我还有其它三个目录:Contracts
、Eloquent
和 Exceptions
。如你所见,目录名称非常便于我们(理解)放入需要放置的内容。在 Contracts
目录中我们放入接口,或在之前调用它们的“合同”(contracts)。Eloquent
目录包含了实现合同的抽象类和具体仓库类。而 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 {
// TODO:
}
现在我们准备开始实现这份“合同”。
实现仓库类
使用仓库能让我们查询数据源以获取数据,将数据映射到业务实体,并将业务实体中的更改持久化至数据源:
当然,每个具体的子仓库都应该继承于我们的抽象仓库类,其实现了 RepositoryInterface
合同(contract)。现在,你们将如何实现这个合同?看哈第一个方法。你摁是 mia 一眼豆能晓得老?(哈哈,重庆话飙出来了,原句:你仅通过观察就能知道些什么吗?)
我们合同中的第一个方法合宜地的命名为 all()
。它的职责是获取具体实体的所有记录。它只接受一个必需为数组的参数 $columns
。顾名思义,该参数用于指定我们希望从数据源获取哪些列,默认情况下我们获取所有列。
对于指定的实体,该方法看起来像这样:
public function all($columns = ['*']) {
return Bosnadev\Models\Actor::get($columns);
}
但我们想让它变得通用,这样我们就能在任意地方使用:
public function all($columns = ['*']) {
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\Container\Container as App;
use Illuminate\Database\Eloquent\Model;
/**
* Class Repository
*
* @package Bosnadev\Repositories\Eloquent
*/
abstract class Repository implements RepositoryInterface {
/**
* @var App
*/
private $app;
/**
* @var
*/
protected $model;
/**
* @param App $app
*
* @throws 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\Container\Container as App;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* Class Repository
*
* @package Bosnadev\Repositories\Eloquent
*/
abstract class Repository implements RepositoryInterface {
/**
* @var App
*/
private $app;
/**
* @var
*/
protected $model;
/**
* @param App $app
*
* @throws 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 = ['*']) {
return $this->model->get($columns);
}
/**
* @param int $perPage
* @param array $columns
*
* @return mixed
*/
public function paginate($perPage = 15, $columns = ['*']) {
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 int $id
* @param string $attribute
*
* @return mixed
*/
public function update(array $data, $id, $attribute = "id") {
return $this->model->where($attribute, '=', $id)->update($data);
}
/**
* @param int $id
*
* @return mixed
*/
public function delete($id) {
return $this->model->destroy($id);
}
/**
* @param int $id
* @param array $columns
*
* @return mixed
*/
public function find($id, $columns = ['*']) {
return $this->model->find($id, $columns);
}
/**
* @param string $attribute
* @param mixed $value
* @param array $columns
*
* @return mixed
*/
public function findBy($attribute, $value, $columns = ['*']) {
return $this->model->where($attribute, '=', $value)->first($columns);
}
/**
* @return 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;
use Response;
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;
abstract class Criteria {
/**
* @param $model
* @param RepositoryInterface $repository
*
* @return mixed
*/
public abstract function apply($model, RepositoryInterface $repository);
}
此方法将保留其将被应用在仓库类的具体实体中的条件查询,我们还需要扩展我们的仓库类来覆盖条件查询。但首先,让我们为仓库类创建一个新的合同(contract):
<?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
合同来扩展仓库类功能:
<?php
namespace Bosnadev\Repositories\Eloquent;
use Bosnadev\Repositories\Contracts\CriteriaInterface;
use Bosnadev\Repositories\Contracts\RepositoryInterface;
use Bosnadev\Repositories\Criteria\Criteria;
use Bosnadev\Repositories\Exceptions\RepositoryException;
use Illuminate\Container\Container as App;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* 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 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 = ['*']) {
$this->applyCriteria();
return $this->model->get($columns);
}
/**
* @param int $perPage
* @param array $columns
*
* @return mixed
*/
public function paginate($perPage = 1, $columns = ['*']) {
$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 int $id
* @param string $attribute
*
* @return mixed
*/
public function update(array $data, $id, $attribute = "id") {
return $this->model->where($attribute, '=', $id)->update($data);
}
/**
* @param int $id
*
* @return mixed
*/
public function delete($id) {
return $this->model->destroy($id);
}
/**
* @param int $id
* @param array $columns
*
* @return mixed
*/
public function find($id, $columns = ['*']) {
$this->applyCriteria();
return $this->model->find($id, $columns);
}
/**
* @param string $attribute
* @param mixed $value
* @param array $columns
*
* @return mixed
*/
public function findBy($attribute, $value, $columns = ['*']) {
$this->applyCriteria();
return $this->model->where($attribute, '=', $value)->first($columns);
}
/**
* @return 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;
}
}
创建新条件
使用条件查询,现在你可以更轻松的组织仓库。你的仓库类不需要长达数千行。
你的条件类可能看起来像这样:
<?php
namespace App\Repositories\Criteria\Films;
use Bosnadev\Repositories\Contracts\CriteriaInterface;
use Bosnadev\Repositories\Contracts\RepositoryInterface;
class LengthOverTwoHours implements CriteriaInterface {
/**
* @param $model
* @param RepositoryInterface $repository
*
* @return mixed
*/
public function apply($model, RepositoryInterface $repository) {
$query = $model->where('length', '>', 120);
return $query;
}
}
在控制器中使用
当我们有了简单的条件查询后,让我们看看如何使用它。有两种方法可以将条件查询应用到仓库中。首先是使用 pushCriteria()
方法:
<?php
namespace App\Http\Controllers;
use App\Repositories\Criteria\Films\LengthOverTwoHours;
use App\Repositories\FilmRepository as Film;
use Response;
class FilmsController extends Controller {
/**
* @var Film $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;
use Response;
class FilmsController extends Controller {
/**
* @var Film 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());
}
}
安装包
你可以通过在 Composer 的 require
部分中添加此依赖项来安装该包:
"bosnadev/repositories": "0.*"
末声
在应用程序中使用仓库有许多好处。例如减少代码重复和防止编程错误等基本特点到使应用程序更容易扩展、测试和维护。
从架构的角度看,你设法分离了关注点。你的控制器无需了解存储数据的方式和位置。优雅且抽象。
你可以在 GayHub(GitHub)上找到这个包,在那里你可以查看最近更新和修复的错误。我还计划添加一些新特性,如饥饿加载、缓存以及一些配置,所以请 Star 该库继续关注。话说回来,如果你想在开发中做点贡献,只需 Fork 这个库并发送 PR(Pull Request)即可。
如果你有任何看法或建议,请在下面的评论部分告诉我,告辞。
贡献
这个包很大程度上受到了 @andersao 这个很棒的包的启发。这也是我用作参考的另一个包。此外,我发现这些文章非常有帮助: