为什么要在 Laravel 中使用存储库模式(Repository)?
在我 上一篇博客文章 中,我解释了什么是存储库模式,它与Active Record模式有何不同,以及如何在Laravel中实现它。现在我想深入了解一下为什么应该使用存储库模式。
我在上一篇文章的评论中注意到,Repository模式在Laravel社区中是一个有争议的话题。有些人认为没有理由使用它,并坚持使用内置的Active Record模式。其他人则倾向于使用其他方法将数据访问从逻辑域中分离出来。请注意,我尊重这些意见,并将在接下来的博客文章中专门讨论此主题。
有了这个免责声明,让我们来了解一下使用存储库模式的优点。
单一责任原则
单一责任原则是主要鉴别器来区分Active Record模式和存储库模式。模型类已经保存数据并提供域对象的方法。当使用Active Record模式时,数据访问是额外引入的责任。这是我想在以下示例中说明的东西:
/**
* @property string $first_name
* @property int $company_id
*/
class Employee extends Model {}
$jack = new Employee();
$jack->first_name = 'Jack';
$jack->company_id = $twitterId;
$jack->save();
虽然域模型和数据访问技术的职责混合,但它直观上看还说得过去。在我们的应用程序中,员工必须以某种方式存储在数据库中,因此为什么不调用对象上的save()
。单个对象被转化成单个数据行并存储。
但是,让我们更进一步,看看我们还能对员工做些什么:
$jack->where('first_name', 'John')->firstOrFail()->delete();
$competition = $jack->where('company_id', $facebookId)->get();
现在,它变得不直观,甚至违背了我们的域模型。 为什么 Jack 会突然删除另一个甚至可能在不同公司工作的员工? 或者他为什么能把 Facebook 的员工拉过来?
当然,这个例子是人为设计的,但它仍然显示了 Active Record 模式如何不允许有意的域模型。 员工与所有员工列表之间的界限变得模糊。 您始终必须考虑该员工是被用作实际员工还是作为访问其他员工的机制。
仓库模式通过强制执行这个基本分区来解决这个问题。它的唯一用途是标识域对象的合集,而不是域对象的本身。
要点:
- 👉 通过将所有域对象的集合与单个域对象分离, 仓库模式体现了单一责任原则 。
不要重复自己 (DRY)
一些项目将数据库查询洒遍了整个项目。下面是一个例子,我们从数据库中获取列表,并在 Blade 视图中显示他们。
class InvoiceController {
public function index(): View {
return view('invoices.index', [
'invoices' => Invoice::where('overdue_since', '>=', Carbon::now())
->orderBy('overdue_since')
->paginate()
]);
}
}
当这样的查询遍得更加复杂并且在多个地方使用时,考虑将其提取到 Repository 方法中。
存储库模式通过将重复查询打包到表达方法中来帮助减少重复查询。如果必须调整查询,只需更改一次即可。
class InvoiceController {
public __construct(private InvoiceRepository $repo) {}
public function index(): View {
return view('invoices.index', [
'invoices' => $repo->paginateOverdueInvoices()
]);
}
}
现在查询只实现一次,可以单独测试并在其他地方使用。此外,单一责任原则再次发挥作用,因为控制器不负责获取数据,而只负责处理HTTP请求和返回响应。
Takeaway:
- 👉 存储库模式有助于减少重复查询
依赖反转
解释 Dependency Inversion Principle 值得发表自己的博客文章。我只是想说明存储库可以启用依赖项反转。
在对组件进行分层时,通常较高级别的组件依赖于较低级别的组件。 例如,控制器将依赖模型类从数据库中获取数据:
class InvoiceController {
public function index(int $companyId): View {
return view(
'invoices.index',
['invoices' => Invoice::where('company_id', $companyId)->get()]
);
}
}
依赖关系是自上而下的,紧密耦合的。 InvoiceController
取决于具体的 Invoice
类。 很难将这两个类解耦,例如单独测试它们或替换存储机制。 通过引入 Repository 接口,我们可以实现依赖倒置:
interface InvoiceRepository {
public function findByCompanyId($companyId): Collection;
}
class InvoiceController {
public function __construct(private InvoiceRepository $repo) {}
public function index(int $companyId): View {
return view(
'invoices.index',
['invoices' => $this->repo->findByCompanyId($companyId)]
);
}
}
class EloquentInvoiceRepository implements InvoiceRepository {
public function findByCompanyId($companyId): Collection {
// 使用 Eloquent 查询构造器实现该方法
}
}
Controller 现在只依赖于 Repository 接口, 和 Repository 实现一样. 这两个类现在只依赖于一个抽象, 从而减少耦合. 正如我将在下一节中解释的那样,这会带来更多优势.
Takeaway:
- 👉 存储库模式作为一种抽象类,支持依赖反转.
抽象类
存储库 提高了可读性 因为复杂的操作被具有表达性名称的高级方法隐藏了.
访问存储库的代码与底层数据访问技术分离. 如有必要,您可以切换实现,甚至可以省略实现,仅提供 Repository 接口。 这对于旨在与框架无关的库来说非常方便。
OAuth2 服务包 —— league/oauth2-server
也用到这个抽象类机制。 Laravel Passport 也通过 实现这个库的接口 集成 league/oauth2-server 包。
正如 @bdelespierre 在 评论 里回应我之前的一篇博客文章时向我指出的那样,你不仅可以切换存储库实现,还可以将它们组合在一起。大致以他的示例为基础,您可以看到一个存储库如何包装另一个存储库以提供附加功能:
interface InvoiceRepository {
public function findById(int $id): Invoice;
}
class InvoiceCacheRepository implements InvoiceRepository {
public function __construct(
private InvoiceRepository $repo,
private int $ttlSeconds
) {}
public function findById(int $id): Invoice {
return Cache::remember(
"invoice.$id",
$this->ttlSeconds,
fn(): Invoice => $this->repo->findById($id)
);
}
}
class EloquentInvoiceRepository implements InvoiceRepository {
public function findById(int $id): Invoice { /* 从数据库中取出 $id */ }
}
// --- 用法:
$repo = new InvoiceCacheRepository(
new EloquentInvoiceRepository();
);
要点:
- 👉 存储库模式抽象了有关数据访问的详细信息。
- 👉 存储库将客户端与数据访问技术分离。
- 👉 这允许切换实现,提高可读性并实现可组合性。
可测试性
存储库模式提供的抽象也有助于测试。
如果你有一个 Repository 接口,你可以提供一个替代的测试实现。 您可以使用数组支持存储库,而不是访问数据库,将所有对象保存在数组中:
class InMemoryInvoiceRepository implements InvoiceRepositoryInterface {
private array $invoices;
// implement the methods by accessing $this->invoices...
}
// --- Test Case:
$repo = new InMemoryInvoiceRepository();
$service = new InvoiceService($repo);
通过这种方法,您将获得一个现实的实现,它速度很快并且在内存中运行。 但是您必须为测试提供正确的 Repository 实现,这 ** 本身可能需要大量工作**。 在我看来,这在两种情况下是合理的:
- 您正在开发一个(与框架无关的)库,它本身不提供存储库实现。
- 测试用例复杂,Repository 的状态很重要。
另一种方法是“模仿”,要使用这种技术,你不需要适当的接口。你可以模仿任何 non-final 类。
使用 PHPUnit API ,您可以明确规定如何调用存储库以及应该返回什么。
$companyId = 42;
/** @var InvoiceRepository&MockObject */
$repo = $this->createMock(InvoiceRepository::class);
$repo->expects($this->once())
->method('findInvoicedToCompany')
->with($companyId)
->willReturn(collect([ /* invoices to return in the test case */ ]));
$service = new InvoiceService($repo);
$result = $service->calculateAvgInvoiceAmount($companyId);
$this->assertEquals(1337.42, $result);
有了 mock,测试用例就是一个适当的单元测试。上面示例中测试的唯一代码是服务。没有数据库访问,这使得测试用例的设置和运行非常快速。
另外:
- 👉 仓库模式允许进行适当的单元测试,这些单元测试运行快并且是隔离的。
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
高认可度评论:
可以参考一下l5-repository包。
其实DI依赖注入,IOC控制反转等概念,在很多PHPer中并未有很好的理解,怎么实现的注入,如何管理注入等等。
并且repository pattern其实也并非是一种很多么陌生的技术,其实就是一种分层思想。文章里提到的依赖注入,实际上是一个核心设计原则,体现的是面向接口编程。在这个设计原则里,不可或缺的体现出单一职责原则、里氏代换原则等等。应该说最根本的是掌握设计原则,理解核心思想,然后对于设计模式就会有更好的理解和体验。
设计模式的核心目的在于四个词:可复用、可拓展、可维护、灵活性好。设计原则、设计模式都是围绕着四个目的来的。我们常看的教学文档,举例比较简单,需要自己结合实际细细体会,多写多用,在解决问题时非常香,看源码也是会变得很顺畅。
尽量不要凹词儿,在乎术语不如在乎思想,究其思想,多多善用。
早就想使用一下存储模式了,终于有篇指导文章了
讲得非常好啊!
Mark 一直好奇mock是怎么用的,有机会试试
Mark
可以参考一下l5-repository包。
其实DI依赖注入,IOC控制反转等概念,在很多PHPer中并未有很好的理解,怎么实现的注入,如何管理注入等等。
并且repository pattern其实也并非是一种很多么陌生的技术,其实就是一种分层思想。文章里提到的依赖注入,实际上是一个核心设计原则,体现的是面向接口编程。在这个设计原则里,不可或缺的体现出单一职责原则、里氏代换原则等等。应该说最根本的是掌握设计原则,理解核心思想,然后对于设计模式就会有更好的理解和体验。
设计模式的核心目的在于四个词:可复用、可拓展、可维护、灵活性好。设计原则、设计模式都是围绕着四个目的来的。我们常看的教学文档,举例比较简单,需要自己结合实际细细体会,多写多用,在解决问题时非常香,看源码也是会变得很顺畅。
尽量不要凹词儿,在乎术语不如在乎思想,究其思想,多多善用。
是不是类似java的DAO(data access object)?理解不到repository这个词或者翻译出来存储层的意思。会不会用DAO更好理解?
@redfish 在
存储库模式
中,用 Hyperf 实现更方便、简洁。下面是 Demo 演示:
app/Service/Dao/UserDao.php
app/Service/UserService.php
这里是一个简单的 Demo,想了解更多,看 Hyperf 官方文档 吧。