在关系中使用存储库模式(和查询范围)


Using the Repository Pattern (and Query Scopes) with Relations

在Laravel 4中,查询作用域在所有查询中都是可用的(包括由关系查询生成的)。这意味着对于以下(示例)模型:

Customer.php:

<?php
class Customer extends Eloquent {
    public function order() { return $this->hasMany('Order'); }
}

Order.php:

<?php
class Order extends Eloquent {
   public function scopeDelivered($query) { return $query->where('delivered', '=', true); }
   public function customer() { return $this->belongsTo('Customer'); }
}

下面两个都可以:

var_dump(Order::delivered()->get()); // All delivered orders
var_dump(Customer::find(1)->orders()->delivered()->get()); // only orders by customer #1 that are delivered

这在控制器中很有用,因为查找已交付订单的查询逻辑不必重复。

最近,我确信储存库模式不仅是关注点分离的最佳选择,而且对于ORM/DB切换的可能性或添加中间件(如缓存)的必要性也是最佳选择。存储库感觉非常自然,因为现在没有让我的模型膨胀的作用域,而相关的查询是存储库的一部分(这更有意义,因为这自然是集合的方法而不是项)。

例如,

<?php
class EloquentOrderRepository {
    protected $order;
    public function __construct(Order $order) { $this->order = $order; }
    public function find($id) { /* ... */ }
    /* etc... */
    public function allDelievered() { return $this->order->where('delivered', '=', true)->get(); }
}

然而,现在我重复了交付的作用域,因此为了避免违反DRY,我从模型中删除了它(根据上面的理由,这似乎是合乎逻辑的)。但是现在,我不能再在关系上使用作用域了(比如$customer->orders()->delivered())。这里我看到的唯一解决方法是在Relation基类中以某种方式用预先制作的查询实例化Repository(类似于传递给模型中的作用域的查询)。但这涉及到更改(和重写)大量代码和默认行为,似乎使事情比它们应该的更加耦合。

考虑到这个困境,这是对存储库的误用吗?如果不是,我的解决方案是恢复我想要的功能的唯一方法吗?还是模型中的作用域耦合不够紧密,不足以证明这些额外的代码是合理的?如果作用域不是紧密耦合的,那么是否有一种方法可以同时使用存储库模式和作用域,同时仍然是DRY?

注意:我知道在类似的主题上有一些类似的问题,但它们都没有解决这里提出的由关系生成的查询的问题,关系不依赖于Repository

我已经找到了解决办法。它相当粗俗,我不确定我是否认为它是可以接受的(它用了很多东西,它们可能不应该被使用)。总而言之,该解决方案允许您将范围移动到存储库。每个存储库(在实例化时)启动一次,在此过程中,通过Illuminate'Database'Eloquent'ScopeInterface提取所有范围方法并将其添加到雄辩模型(通过宏)创建的每个查询中。

(Hack-y)解

存储库模式实现

应用程序/lib/PhpMyCoder/仓库/Repository.php:

<?php namespace PhpMyCoder'Repository;
interface Repository {
    public function all();
    public function find($id);
}

应用程序/lib/PhpMyCoder/仓库/订单/OrderRepository.php:

<?php namespace PhpMyCoder'Repository'Order;
interface OrderRepository extends PhpMyCoder'Repository'Repository {}

添加雄辩存储库(和一个hack)

应用程序/lib/PhpMyCoder/仓库/订单/EloquentOrderRepository.php:

<?php namespace PhpMyCoder'Repository'Order;
use PhpMyCoder'Repository'EloquentBaseRepository;
class EloquentOrderRepository extends EloquentBaseRepository implements OrderRepository {
    public function __construct('Order $model) {
        parent::__construct($model);
    }
    public function finished() {
        return $this->model->finished()->get();
    }
    public function scopeFinished($query) {
        return $query->where('finished', '=', true);
    }
}

注意存储库如何包含通常存储在Order模型类中的范围。在数据库中(本例中),Order需要有一个布尔列finished。我们将在下面介绍EloquentBaseRepository的细节。

应用程序/lib/PhpMyCoder/仓库/EloquentBaseRepository.php:

<?php namespace PhpMyCoder'Repository;
use Illuminate'Database'Eloquent'Model;
abstract class EloquentBaseRepository implements Repository {
    protected $model;
    // Stores which repositories have already been booted
    protected static $booted = array();
    public function __construct(Model $model) {
        $this->model = $model;
        $this->bootIfNotBooted();
    }
    protected function bootIfNotBooted() {
        // Boot once per repository class, because we only need to
        // add the scopes to the model once
        if(!isset(static::$booted[get_class($this)])) {
            static::$booted[get_class($this)] = true;
            $this->boot();
        }
    }
    protected function boot() {
        $modelScope = new ModelScope();  // covered below
        $selfReflection = new 'ReflectionObject($this);
        foreach (get_class_methods($this) as $method) {
            // Find all scope methods in the repository class
            if (preg_match('/^scope(.+)$/', $method, $matches)) {
                $scopeName = lcfirst($matches[1]);
                // Get a closure for the scope method
                $scopeMethod = $selfReflection->getMethod($method)->getClosure($this)->bindTo(null);
                $modelScope->addScope($scopeName, $scopeMethod);
            }
        }
        // Attach our special ModelScope to the Model class
        call_user_func([get_class($this->model), 'addGlobalScope'], $modelScope);
    }
    public function __call($method, $arguments) {
        // Handle calls to scopes on the repository similarly to
        // how they are handled on Eloquent models
        if(method_exists($this, 'scope' . ucfirst($method))) {
            return call_user_func_array([$this->model, $method], $arguments)->get();
        }
    }
    /* From PhpMyCoder'Repository'Order'OrderRepository (inherited from PhpMyCoder'Repository'Repository) */
    public function all() {
        return $this->model->all();
    }
    public function find($id) {
        return $this->model->find($id);
    }
}

每次第一次实例化存储库类的实例时,我们启动存储库。这涉及到将存储库上的所有"范围"方法聚合到一个ModelScope对象中,然后将其应用到模型中。ModelScope将我们的作用域应用于模型创建的每个查询(如下所示)。

应用程序/lib/PhpMyCoder/仓库/ModelScope.php:

<?php namespace PhpMyCoder'Repository;
use Illuminate'Database'Eloquent'ScopeInterface;
use Illuminate'Database'Eloquent'Builder;
class ModelScope implements ScopeInterface {
    protected $scopes = array(); // scopes we need to apply to each query
    public function apply(Builder $builder) {
        foreach($this->scopes as $name => $scope) {
            // Add scope to the builder as a macro (hack-y)
            // this mimics the behavior and return value of Builder::callScope()
            $builder->macro($name, function() use($builder, $scope) {
                $arguments = func_get_args();
                array_unshift($arguments, $builder->getQuery());
                return call_user_func_array($scope, $arguments) ?: $builder->getQuery();
            });
        }
    }
    public function remove(Builder $builder) {
        // Removing is not really possible (no Builder::removeMacro),
        // so we'll just overwrite the method with one that throws a
        // BadMethodCallException
        foreach($this->scopes as $name => $scope) {
            $builder->macro($name, function() use($name) {
                $className = get_class($this);
                throw new 'BadMethodCallException("Call to undefined method {$className}::{$name}()");
            });
        }
    }
    public function addScope($name, 'Closure $scope) {
        $this->scopes[$name] = $scope;
    }
}

ServiceProvider和Composer文件

应用程序/lib/PhpMyCoder/仓库/RepositoryServiceProvider.php:

<?php namespace PhpMyCoder'Repository;
use Illuminate'Support'ServiceProvider;
use PhpMyCoder'Repository'Order'EloquentOrderRepository;
class RepositoryServiceProvider extends ServiceProvider {
    public function register() {
        // Bind the repository interface to the eloquent repository class
        $this->app->bind('PhpMyCoder'Repository'Order'OrderRepository', function() {
            return new EloquentOrderRepository(new 'Order);
        });
    }
}

确保将此服务提供者添加到app.php配置中的providers数组:

'PhpMyCoder'Repository'RepositoryServiceProvider',

然后添加app/lib到composer的自动加载

"autoload": {
    "psr-0": {
        "PhpMyCoder''": "app/lib" 
    },
    /* etc... */
},

这将需要一个composer.phar dump-autoload

的模型

应用程序/模型/Customer.php:

<?php
class Customer extends Eloquent {
    public function orders() {
        return $this->hasMany('Order');
    }
}

请注意,为简洁起见,我没有为Customer编写存储库,但在实际应用程序中,您应该这样做。

应用程序/模型/Order.php:

<?php
class Order extends Eloquent {
    public function customer() {
        return $this->belongsTo('Customer');
    }
}

注意范围不再存储在Order模型中。这在结构上更有意义,因为集合级别(存储库)应该负责应用于所有订单的范围,而Order应该只关注特定于一个订单的细节。为了使这个演示工作,order必须有一个到customers.id的整数外键customer_id和一个布尔标志finished

控制器使用情况

app/controllers/OrderController.php:

<?php
// IoC will handle passing our controller the proper instance
use PhpMyCoder'Repository'Order'OrderRepository;
class OrderController extends BaseController {
    protected $orderRepository;
    public function __construct(OrderRepository $orderRepository) {
        $this->orderRepository = $orderRepository;
    }
    public function test() {
        $allOrders = $this->orderRepository->all();
        // Our repository can handle scope calls similarly to how
        // Eloquent models handle them
        $finishedOrders = $this->orderRepository->finished();
        // If we had made one, we would instead use a customer repository
        // Notice though how the relation query also has order scopes
        $finishedOrdersForCustomer = Customer::find(1)->orders()->finished();
    }
}

我们的存储库不仅包含子模型的作用域,这是更坚实的。它们还具有处理对作用域调用的能力,就像真正的Eloquent模型一样。并且它们将所有范围添加到由模型创建的每个查询中,以便您在检索相关模型时可以访问它们。

这种方法的问题

  • 大量的代码实现很少的功能;可以说是太多了,无法实现预期的结果
  • 这是黑客:宏在Illuminate'Database'Eloquent'BuilderIlluminate'Database'Eloquent'ScopeInterface(与Illuminate'Database'Eloquent'Model::addGlobalScope结合)可能以他们不打算的方式使用
  • 它需要实例化存储库(主要问题):如果你在CustomerController内,你只有实例化的CustomerRepository, $this->customerRepository->find(1)->orders()->finished()->get()不会像预期的那样工作(finished()宏/范围不会被添加到每个Order查询,除非你实例化OrderRepository)。

我将调查是否有一个更优雅的解决方案(补救上面列出的问题),但这是迄今为止我能找到的最好的解决方案。

存储库模式的相关资源

  • 在Laravel 4中使用repository创建灵活的控制器
  • 更好的存储库的雄辩技巧