在这个关于模型的系列的最后两个条目中,我介绍了使用表单作为输入过滤器并将ACL集成到模型中。在这篇文章中,我为您的模型解决了一些潜在的基础设施问题。
模型是一个复杂的主题。但是,它通常被归结为单个模型类或完整的对象关系映射(ORM)。我个人从来都不是ORM的忠实拥护者,因为它们将模型绑定到底层数据库结构;我并不总是使用数据库,也不想过分依赖ORM解决方案,以免以后需要重构以使用服务或其他类型的持久性存储。另一方面,作为单个类的模型通常过于简单。
不过,我是领域模型的粉丝。引用维基百科,
[The]域模型可以被认为是系统的概念模型,它描述了该系统中涉及的各种实体及其关系。
[The]域模型可以被认为是系统的概念模型,它描述了该系统中涉及的各种实体及其关系。
当您从这些方面思考时,您就开始将系统分解成您需要操纵的离散部分,并考虑每个部分如何与其他部分相关。这种类型的练习还可以帮助您停止根据数据库表来考虑您的模型;相反,你的数据库变成了一个容器,数据从你的模型的一次使用到下一次使用都保存在这个容器中。相反,您的模型是一个对象,可以处理传入或存储的数据—甚至完全自主。
例如,当开始使用ZendFramework时,很容易使用Zend_Db_Table
和Zend_Db_Table_Row
作为模型。然而,有一个很大的理由反对这样做:当使用表数据网关(TDG)或行数据网关(RDG)时,您将返回一个与数据存储实现相关联的对象。您基本上是在戴上眼罩并将您的模型简单地视为数据库表或单独的行,而返回的对象反映了这种狭隘的观点。此外,如果您希望通过服务层重用您的模型,许多Web服务无法使用对象,而在那些可以使用的对象中,您可能不想公开所有的属性和方法您的数据提供者返回的对象。例如,ZF中的行对象实际上将数据存储在受保护的成员中,有效地将其隐藏在服务之外,并且还包括删除行的方法、ArrayAccess方法和对表对象的访问——这使您可以完全控制表!直接通过服务公开它的安全隐患应该是显而易见的。
此外,如果将来您希望重构您的应用程序以利用memcached或网络服务,您现在不仅需要完全重写您的模型,还需要重写所有消费者代码,因为返回您模型中的值已更改。
那么,如果您不打算直接使用ORM或表数据网关,您应该如何构建模型基础架构?
你在建模什么?
要问的主要问题是“我在建模什么?”
让我们来看一个相当标准的网站问题:用户管理。通常,您会收到这样的要求:“用户应该能够在网站上注册一个帐户。一旦注册,他们应该能够使用他们提供的凭据登录。管理员应该能够禁止帐户或授予用户权限更高级别的特权。”当然,这是假设您确实获得了良好的需求文档。
大多数开发人员会立即设置一个数据库,其中包含代表用户的几个字段——全名、用户名、电子邮件、密码等——创建一个用于注册的表单和另一个用于登录的表单,编写一个例程来验证每个字段,创建一个页面列出管理屏幕的用户……你知道这个练习。但是你在建模什么?
答案是:用户。所以,现在是定义用户是什么以及用户可以做什么的时候了。我们必须决定什么构成新用户,什么构成经过身份验证的用户。我们还有一个经常被忽视的额外建模考虑因素:用户角色。还有一个问题是组用户可能是什么样子(因为管理员需要能够列出用户),以及我们可能希望如何与组一起工作。
让我们从缩小用户的定义开始:
- 用户由以下元数据组成:
- 唯一用户名
- 全名
- 电子邮件地址
- 哈希密码
- 站点内的角色
- 新用户必须提供唯一的用户名、他们的全名、有效的电子邮件地址,以及密码和密码验证。
- 通过身份验证的用户是提供了用户名和密码的匹配组合的用户>.
- 用户可以注销网站。
- 用户可以被授予新角色。
- 用户可以被标记为禁止。
注意到第五条元数据了吗?它提到了一个“角色”?这与我们的ACL有关—这意味着ACL是我们用户域的一部分。我稍后会谈到这一点。
如果您仔细查看剩余的要点,您会注意到其中涉及验证、身份验证以及用户和会话持久性。验证规则是我们模型的一部分——我们将使用Zend_Form
来完成这个角色。Web上的身份验证通常包括根据存储凭据验证提交的凭据,以及在会话<中持久经过验证的身份/em>。这意味着我们模型的其他部分包括数据持久性和会话管理。我们将使用Zend_Db_Table
进行数据持久化,使用Zend_Auth
/Zend_Session
进行身份持久化。
现在,让我们转向定义用户的列表:
- 管理员应该能够提取用户列表。这些列表应该允许:
- 按用户名、全名、电子邮件地址或角色排序
- 分页(即,从给定的偏移量中拉出一定数量的用户)
- 迭代
- 管理员应该能够指定选择要列出的用户的标准。
这些标准表明用户的列表应该是一个对象。此列表可能会以某种方式实现SPL类Traversable
。查看此标准,我们模型的另一个方面变得清晰:我们正在对用户选择进行建模,其中包括指定排序和选择标准的能力。用户选择对象将返回一个用户列表对象,该对象由用户对象组成。用户对象定义ACL角色并可以对用户进行身份验证。
我们从讨论域模型开始这篇文章,并将其定义为一个系统、它的实体以及这些实体之间的关系。我们现在已经确定了我们的领域:用户管理。各种实体包括用户、用户列表、ACL角色、用户持久层(数据库)和会话持久层(Web服务器会话)。
既然我们知道我们正在建模什么,让我们看看我们模型中的一些对象。
域网关
我们已将“用户管理”确定为我们模型的目的。这将包括检索和保存单个用户,以及选择用户组。
很明显,我们需要一个对象来表示一个用户,以及另一个对象来表示一个选择或一组用户。但可能不完全清楚的是,我们可能应该有一个对象,用于创建新的用户对象、创建用户选择,并基本上协调几个相关的对象——尤其是根ACL和数据访问。
这个对象将是我将称之为我们的域的网关。它将用于获取我们模型中的其他对象,并在这样做时将各种依赖项注入它们,例如数据访问和ACL。各种依赖项可以自己注入到网关中——或者它可以延迟加载它们。
此网关的API可能如下所示。
// Instantiate the gateway $userGateway = new Spindle_Model_UserGateway(); // configure the gateway: $userGateway->setAcl(new Spindle_Acl_Spindle()) ->setDbAdapter(Zend_Registry::get('db')); // Alternately, do it all at instantiation: $userGateway = new Spindle_Model_UserGateway(array( 'acl' => new Spindle_Acl_Spindle(), 'dbAdapter' => Zend_Registry::get('db'), )); // Grab a single user $user = $userGateway->retrieve('matthew'); // Grab many users $users = $userGateway->sort('email', 'ASC') ->criteria(array('banned' => true)) ->fetch(array('offset' => 20, 'limit' => 20)); // Better yet, add some transaction script methods with preset criteria: $users = $userGateway->fetchBannedUsers(array( 'offset' => 20, 'limit' => 20, 'sort' = array('email', 'ASC'), )); // Create a new user: $user = $userGateway->createUser(array( 'username' => 'matthew', 'fullname' => "Matthew Weier O'Phinney", 'password' => 'secret', 'email' => 'matthew@local', ));
基本思想是提供用于延迟加载必要对象的脚手架、用于指定选项(例如排序顺序、条件、限制等)的方法以及用于检索单个用户和用户组的事务方法。
值对象和记录集
我们在模型中识别的其他对象是用户和用户列表。我们应该如何定义这些?
传统的答案是作为值或数据传输对象和记录集。值对象是一种标准设计模式,用于聚合定义单个值的所有元数据。记录集是值对象的集合。
值对象
MartinFowler在他的“企业应用程序架构模式”(PoEAA)一书中区分了值对象和数据传输对象。在其中,他将值对象与语言变量类型相关联(即,值对象充当自定义变量类型),同时将数据传输对象定义为聚合相关值,以实现对象之间的序列化和数据传输。
然而,在Java中,值对象是用于存储一组特定属性的任意对象—与数据传输对象非常相似。出于讨论的目的,我将使用术语“值对象”,因为具有Java背景的人会很熟悉它,并表示我们正在聚合一个唯一的值,即多个属性的总和。
基本上,所有这些措辞都描述了一些实现起来非常简单的东西:一个具有一组特定属性或属性的对象。如果您一直在使用PHP进行任何OOP编程,那么这是您可以做的最自然和最基本的事情。
class Spindle_Model_User { protected $_data = array( 'username' => null, 'email' => null, 'fullname' => '', 'role' => 'guest', ); public function __construct($data) { $this->populate($data); if (!isset($this->username)) { throw new Exception('Initial data must contain an id'); } } public function populate($data) { if (is_object($data)) { $data = (array) $data; } if (!is_array($data)) { throw new Exception('Initial data must be an array or object'); } foreach ($data as $key => $value) { $this->$key = $value; } return $this; } public function __set($name, $value) { if (!array_key_exists($name, $this->_data)) { throw new Exception('Invalid property "' . $name . '"'); } $this->_data[$name] = $value; } public function __get($name) { if (array_key_exists($name, $this->_data)) { return $this->_data[$name]; } return null; } public function __isset($name) { return isset($this->_data[$name]); } public function __unset($name) { if (isset($this->$name)) { $this->_data[$name] = null; } } }
上面的例子相当简单,但它传达了一个想法:对象定义了有限范围的有效值,并强制只能设置这些值——以及哪些值是必需的。您当然可以添加访问器和修改器方法来强制对成员数据进行一致的访问,但以上内容肯定足以满足许多用例。(稍后我会查看数据完整性。)
您可能对类定义进行的一项添加是添加来自不同类型对象的一些转换。例如,如果您知道您将在模型中使用Zend_Db_Table
,您可能希望为您的值对象添加接受Zend_Db_Table_Row
对象的能力,并拉取它的价值来自那里:
class Spindle_Model_User { /* ... */ public function populate($data) { if ($data instanceof Zend_Db_Table_Row_Abstract) { $data = $data->toArray(); } elseif (is_object($data)) { $data = (array) $data; } if (!is_array($data)) { throw new Exception('Initial data must be an array or object'); } foreach ($data as $key => $value) { $this->$key = $value; } return $this; } /* ... */ }
这将有助于保持您的模型代码干净,因为您可能会获取数据存储操作的结果并将它们直接推送到您的值对象中,从而减少代码返工。
现在,数据完整性如何?这就是Zend_Form
发挥作用的地方。不要将Zend_Form
视为网络表单;将其视为一个输入过滤器,如果需要,它能够将自己呈现为一种形式。如果我们将其视为输入过滤器,我们可以将其用于数据完整性:
class Spindle_Model_User { /* ... */ public function __set($name, $value) { if (!array_key_exists($name, $this->_data)) { throw new Exception('Invalid property "' . $name . '"'); } $inputFilter = $this->getForm(); if ($element = $inputFilter->getElement($name)) { if (!$element->isValid($value)) { throw new Exception(sprintf( 'Invalid value provided for "%s": %s', $name, implode(', ', $element->getMessages()) ); } } $this->_data[$name] = $value; } /* ... */ protected $_form; public function getForm() { if (null === $this->_form) { $this->_form = new Spindle_Form_User(); } return $this->_form; } /* ... */ }
请注意:如果您的模型包含永远不会表示为表单一部分的元数据,您应该考虑使用Zend_Filter_Input
或自定义验证链而不是Zend_Form
。然而,这超出了本文的范围。
既然我们已经排除了输入过滤,我们应该如何解决节省用户的问题?回想一下我们对域网关的讨论,它的职责之一是将其他依赖项注入到我们的对象中。我发现将gateway注入到对象中,然后从中提取我需要的东西通常更容易。让我们看看这可能如何拯救用户。
class Spindle_Model_User { /* ... */ protected $_gateway; public function __construct($data, $gateway) { $this->setGateway($gateway); /* ... */ } public function setGateway(Spindle_Model_UserGateway $gateway) { $this->_gateway = $gateway; return $this; } public function getGateway() { return $this->_gateway; } public function save() { $gateway = $this->getGateway(); $dbTable = $gateway->getDbTable('user'); if ($row = $dbTable->find($this->username)) { foreach ($this->_data as $key => $value) { $row->$key = $value; } $row->save(); } else { $dbTable->insert($this->_data); } } /* ... */ }
请注意,构造函数现在有第二个参数——网关。这确保用户总是有一个网关实例,这进一步确保像列出的操作——从网关检索Zend_Db_Table
实例——将始终有效。在这个例子中,我们只是检查一行是否已经存在,然后相应地保存记录。
我们确定的另一个要求是用户能够对自己进行身份验证。这可以通过实现Zend_Auth_Adapter_Interface
简单地完成:
class Spindle_Model_User implements Zend_Auth_Adapter_Interface { /* ... */ public function authenticate() { $gateway = $this->getGateway(); $table = $manager->getDbTable('user'); $select = $table->select(); $select->where('username = ?', $this->username) ->where('password = ?', $this->password) ->where('date_banned IS NULL'); $user = $table->fetchRow($select); if (null === $user) { // failed $result = new Zend_Auth_Result( Zend_Auth_Result::FAILURE_UNCATEGORIZED, null ); } else { // passed $this->populate($user); unset($this->password); $result = new Zend_Auth_Result(Zend_Auth_Result::SUCCESS, $this); } return $result; } /* ... */ }
要对用户进行身份验证,您可以使用用户名和密码创建一个新的用户对象,然后尝试对其进行身份验证:
$auth = Zend_Auth::getInstance(); $user = $gateway->createUser(array( 'username' => $username, 'password' => $password, )); if ($auth->authenticate($user)) { // AUTHENTICATED! }
这也具有从持久性存储中填充用户以及在会话中存储身份的效果。
我之前介绍过ACL角色,所以这里不再赘述。但是,您现在应该非常清楚地了解此对象的工作原理,以及它如何与用户网关协调。它还应说明我们模型的这一方面不仅仅是简单的数据访问:我们正在协调身份验证、输入过滤和ACL—并提供一个相当简单的API来操纵用户本身。
记录集
记录集同样很容易创建。通常,您只希望对象是可迭代和可数的。与用户对象一样,我们在构造函数中需要一个网关实例。
class Spindle_Model_Users implements Iterator,Countable { protected $_count; protected $_gateway; protected $_resultSet; public function __construct($results, $gateway) { $this->setGateway($gateway); $this->_resultSet = $results; } public function setGateway(Spindle_Model_UserGateway $gateway) { $this->_gateway = $gateway; return $this; } public function getGateway() { return $this->_gateway; } public function count() { if (null === $this->_count) { $this->_count = count($this->_resultSet); } return $this->_count; } public function current() { if ($this->_resultSet instanceof Iterator) { $key = $this->_resultSet->key(); } else { $key = key($this->_resultSet); } $result = $this->_resultSet[$key]; if (!$result instanceof Spindle_Model_User) { $gateway = $this->getGateway(); $result = $gateway->createUser($result); $this->_resultSet[$key] = $result; } return $result; } public function key() { return key($this-_resultSet); } public function next() { return next($this->_resultSet); } public function rewind() { return reset($this->_resultSet); } public function valid() { return (bool) $this->current(); } }
这里的逻辑非常简单。在数组上使用RecordSet的主要好处是它允许您确保集合中每个项目的类型,并允许您的使用代码对RecordSet类执行类型提示。
在网关中使用值对象和记录集
在您的网关类中,您有责任确保返回新类的实例。例如,让我们看一些简单的fetch()
和fetchAll()
方法:
class Spindle_Model_UserGateway { /* ... */ public function fetch($id) { $dbTable = $this->getDbTable(); $select = $dbTable->select(); $select->where('id = ?', $id); $result = $dbTable->fetchRow($select); if (null !== $result) { $result = $this->createUser($result); } return $result; } public function fetchAll() { $result = $this->getDbTable()->fetchAll(); return new Spindle_Model_Users($result, $this); } /* ... */ }
您会立即注意到缺点:您必须引入新对象,这意味着重新转换数据。但让我们从消费者的角度来看它:消费代码正在寻找Spindle_Model_User
和Spindle_Model_Users
的返回类型。
但是网关的真正意义是什么?值对象和结果集对象不能都简单地从一个公共基础继承吗?他们当然可以。但是,我对网关的一个常见用例是提供预定义的方法来封装常见的选择标准。例如,假设您想检索所有被禁止的用户,这将是一项常见任务。为它定义一个方法:
class Spindle_Model_UserGateway { /* ... */ public function fetchBannedUsers() { $dbTable = $this->getDbTable(); $select = $dbTable->select()->where('date_banned IS NOT NULL'); $result = $dbTable->fetchAll($select); return new Spindle_Model_Users($result, $this); } /* ... */ }
诚然,这是一个微不足道的例子,但它清楚地说明了好处:我们现在有一个API方法,可以用简单的英语告诉我们我们正在执行什么操作,并提供一种可重复的方法来执行它。使用该模型的用户无需了解其幕后工作原理,只需知道在调用该模型时会得到一份被禁用户列表即可。
创建网关的另一个主要好处是在我们需要用其他东西替换数据访问层的时候。让我们重构代码以改用服务:
class Spindle_Model_UserGateway { /* ... */ public function fetch($id) { $result = $this->getService()->fetchUser($id); return $this->createUser($result); } public function fetchAll() { $result = $this->getService()->fetchAll(); return new Spindle_Model_Users($result, $this); } /* ... */ }
从消费者的角度来看,什么都没有改变;他们仍在调用相同的方法,并收到相同的响应。这绝对是创建可维护的、面向未来的代码的关键。
总结
此处提供的解决方案绝不是规范的。您可能会发现您自己的模型不需要网关类,或者您从不使用对象列表。然而,希望我已经说明模型应该清楚地提供关注点分离并由离散对象组成——无论它们与您的模型直接相关,还是与您的模型做事的方面相关,比如验证和数据持久化。你应该努力使你的模型尽可能简单,同时仍然满足你的每一个要求。最终结果应该是一个可重复使用、可测试的功能套件,并且您的解决方案的仔细架构应该使其在未来变得健壮且易于重构。
更新:
- 2009-01-04:根据Gabriel的反馈(评论#14)更新了
__unset()
- 2009-01-05:根据Falk的反馈(评论#15)更新了
current()
实现 - 2009-01-05:更新了
current()
根据Martin的反馈(评论#15.1.1)实施