让我们从第一个应该仔细研究的有关其简单性的话题开始:持久化模型对象。正如你可能知道的,我们有一些竞争性的解决方案,它们分为两类:它们将遵循Active Record(AR)或Data Mapper模式(DM)(正如Martin Fowler的 "企业应用架构模式 "中所描述的,简称。PoEAA)。
活动记录
我们如何认识AR模式呢?就是当你实例化一个模型对象,然后对其调用save() 。
$user = new User('Matthias');
$user->save();
从客户的角度看,在简单性方面,这是很惊人的。我们无法想象还有什么比这更容易使用的了。但让我们看看幕后的情况。如果我们要创建我们自己的AR实现,那么save() 函数看起来是这样的。
final class User
{
public function __construct(
private string $name
) {
}
public function save(): void
{
// get the DB connection
$connection->execute(
'INSERT INTO users SET name = ?',
[
$this->name
]
);
}
}
为了使save() ,我们需要以某种方式将数据库连接对象注入到save() ,这样它就可以运行必要的INSERT 的SQL语句。有两个选择。
其一,我们让save() 来获取连接。
use ServiceLocator\Database;
final class User
{
// ...
public function save(): void
{
$connection = Database::getConnection();
$connection->execute(
'INSERT INTO users SET name = ?',
[
$this->name
]
);
}
}
获取依赖关系的做法被称为服务定位,它经常被人诟病,但现在这可以解决这个问题。然而,简单性得分下降了,因为我们必须导入服务定位器,并对其调用一个方法(-2分)。
第二个选择是以某种方式将连接传递给User 对象。这种做法是错误的。
use ServiceLocator\Database;
final class User
{
// ...
public Connection $connection;
public function save(): void
{
$this->connection->execute(
// ...
);
}
}
这是因为提供Connection 的负担现在由实例化User 的调用站点承担。
$user = new User();
$user->connection = /* ... get the connection */;
// ...
这肯定会在 "易用性 "类别中失分。一个更好的主意是以某种方式在框架的引导代码中提供连接。
final class User
{
// ...
public static Connection $connection;
public function save(): void
{
self::$connection->execute(
// ...
);
}
}
// Somewhere in the framework boot phase:
User::$connection = /* get the connection from the container */;
因为我们不想为每个模型类做这个设置步骤,因为我们很可能在每个模型的save() 函数中做类似的事情,也因为我们希望每个模型类都有一个save() 函数,每个AR实现最终都会有一个更通用、可重用的方法。做到这一点的方法是去掉具体的细节(例如,表和列的名字),并定义一个可以做所有事情的父类。这个父类定义了一些抽象的方法,所以模型被迫填补了这些细节。
abstract class Model
{
public static Connection $connection;
abstract protected function tableName(): string;
/**
* @return array<string, string>
*/
abstract protected function dataToSave(): array;
public function save(): void
{
$dataToSave = $this->dataToSave();
$columnsAndValues = /* turn into column = ? */;
$values = /* values for parameter binding */;
$this->connection->execute(
'INSERT INTO ' . $this->tableName()
. ' SET ' . $columnsAndValues,
$values
);
}
}
// Pass the connection to all models at once:
Model::$connection = /* get the connection from the container */;
在可重用性方面,我们应该给自己打几个简单的分数!AR模型类现在是可移植的,在不同的环境下都很有用。我们可以一次又一次地使用相同的简单解决方案。然而,我们也得到了很多负分。因为我们引入了一个父类,而每个模型类都必须提供一些抽象方法,所以元素的数量(函数、类等)以及代码行数(LoC)都会急剧增加。
这个Model 类的某种风险是它会有很多额外的行为,而这些行为是所有从Model 延伸出来的模型类所不需要的。每个模型的API变得非常大,包含像find() 、delete() 、以及用于动态创建或加载相关模型对象的方法。
事实上,与其自己实现AR,不如导入一个能解决我们当前和未来所有需求的库。说实话,我们已经有(至少)一个DB的Connection 类的依赖关系,但现在我们又增加了一个,它本身又包括了很多,这使得我们的解决方案在简单程度上下降了很多点。
数据映射器
现在让我们考虑一下数据映射器模式。你可以通过一个被实例化的模型对象来认识这种模式,然后交给一个为你持久化该对象的服务。这个服务通常被称为 "存储库",混合在存储库模式中(也来自PoEAA,但在建模领域可能更有名,因为Eric Evans、Vaughn Vernon等人写的领域驱动设计书籍)。
final class UserRepository
{
public function __construct(
private Connection $connection
) {
}
public function save(User $user): void
{
$this->connection->executeQuery(
'INSERT INTO users SET name = ?',
[
// how does it get the name?
]
);
}
}
由于UserRepository 是一个服务,我们不必担心 "获得 "数据库连接,它就在那里。所以这个类对服务定位器没有依赖性,但当然它对Connection 类本身有依赖性。所以就依赖关系而言,UserRepository::save() 比User::save() 简单。然而,从客户的角度来看,它就不那么简单了,因为客户不能再调用$user->save() ,而是要把User 传递给UserRepository 。
// assuming $this->userRepository is a `UserRepository`:
$user = new User('Matthias');
$this->userRepository->save($user);
这意味着每一个想要保存User 的客户端都需要一个额外的依赖,所以依赖管理的总点数对AR和DA来说可能是相等的。然而,我认为我们有充分的理由对采用静态服务定位与(构造函数)依赖注入进行更多的惩罚。我们将把这个问题留到另一篇文章中讨论。
有一点需要注意的是,User 并没有从一个基础的Model 类中扩展出来。事实上,它永远都不需要这样做。在这一点上,它没有特殊的能力,也没有任何方法。我们可以自由地对这个对象做我们想做的事情,这就是为什么数据映射器模式自然地与用对象建立的领域模型更匹配。
final class User
{
public function __construct(
private string $name
) {
}
}
先前我跳过了UserRepository::save() 实现中的一个重要步骤,这将使我们现在陷入困境:存储库如何从User 对象中获取数据,以便在SQLINSERT 查询中使用?我在前面的ORMless中写过这个问题;一个类似Memento的对象持久化模式,但让我们在这里重复我们的选择。
- 我们可以为每个需要持久化的属性在
User中添加getters。这将极大地拓宽API,将所有的内部状态暴露给User。 - 我们可以使用反射来从
User的私有属性中提取数据。这是实现DM的ORM会做的,但它需要动态编程,让大部分的 "映射 "逻辑隐含在其中,没有类型安全,同时完全打破了对象的封装,允许模型完全不保留自己。 - 我们可以给
User添加一个单一的方法,一次性公开所有的可持久化数据,例如:asDatabaseRecord(): array。
我认为最后一个选项是最有意义的,至少在我们目前的例子中是如此。我们将添加一个方法到User 。
final class User
{
public function __construct(
private string $name
) {
}
/**
* @return array<string,string>
*/
public function asDatabaseRecord(): array
{
return [
'name' => $this->name
];
}
}
资源库用它来建立SQL查询。
final class UserRepository
{
public function __construct(
private Connection $connection
) {
}
public function save(User $user): void
{
$data = $user->asDatabaseRecord();
$columnsAndValues = /* turn into column = ? */;
$values = /* values for parameter binding */;
$this->connection->executeQuery(
'INSERT INTO users SET ' . $columnsAndValues,
$values
);
}
}
当你像这样用DM模式接近模型对象时,除了包含Connection 类的包外,没有必要立即将通用功能提取到一个包中,或在项目中引入一个第三方包。如果你还想这么做,解决方案又会变得不那么简单,失去一些简单点。与AR相比,引入一个支持DM的包并不会改变模型类本身的API,因为它添加了很多不需要的方法(或一般的代码),但它肯定会引入所谓的意外复杂性。这是你不想处理的复杂性,但你却继承了它,因为这个DM-支持包(ORM)必须解决任何潜在的与持久性有关的问题,而不仅仅是你的问题。
总结
在这篇文章中,我试图分析两种相互竞争的对象持久化模式的 "简单性"。Active Record和Data Mapper。关于依赖性管理和易用性,两者都有一些积极和消极的观点,结果是相似的分数。然而,AR引入了许多比需要的更多的代码元素,主要是因为它依靠继承来给模型提供它所需要的权力来持久化自己。当使用DM时,模型对象并不继承任何东西。它们是普通的对象。DM的一个复杂问题是如何从对象中获取数据,这对AR来说不是一个问题。你可以用一个简单的状态暴露方法来解决这个问题,就像我们看到的那样,但许多项目可能会引入一个额外的DM支持包,这使得解决方案变得非常复杂。最后,为你的持久性需求导入一个额外的ORM包是使AR和DM解决方案复杂化的原因。