第十四章 动态记录模式
到目前为止,您所看到的这些设计模式大大提高了代码的可读性与可维护性。然而,在WEB应用设计与开发中一个基本的需求与挑战:数据库应用,这些设计模式都没有涉及到。本章与接下来的两章—表数据网关与表数据映射,提供了三种设计模式使您能更好的组织你的应用程序与数据库进行交互。
问题
大多数WEB应用将信息持续保存在数据库中。有将数据库操作抽象化,以达到简化表数据存取和对业务逻辑的集成存取方法吗?
解决方案
理论上,动态记录模式是最简化的有关数据库的设计模式。动态记录模式包含了如何在类中直接实现与数据库交互的相关知识。
动态记录模式在程序代码与数据库结构之间产生了一种很高的结合度,在一些相对简单的应用环境中,就能比采用别的复杂方案更容易解决这种因结合所产生的一些固有问题。动态记录模式也能满足许多初级的数据库项目。只有当复杂性增加而难以用动态记录模式处理时,你才有必要使用表数据网关模式(参见15章),或是数据地图模式(参见16章)或是别的数据库设计模式
企业级应用架构模式
根据Martin Fowler’s的著作《企业级应用架构模式》,所谓企业级应用就是与别的应用集成化,包含了重要的业务逻辑(或如应用需求所呈现的非逻辑的东西),并且具有许多并发存取和保存从各种接口取得的数据。有趣的是,web应用正好具备了上述多个特点,这正好能解释为什么Martin Fowler’s的著作能引起PHP程序员的强烈反响。
PHP数据对象
PDO是一个高性能的数据库通道接口(并非数据库抽象)。PDO是一个由C语言构成的本地驱动包,因此其速度是很快的。PDO为所有的PDO驱动提供了申明,增强了脚本使用库时的安全性。
样本代码
任何对数据库连接的讨论都依赖于对数据库系统与对数据库访问层的选择。本章与随后两章都使用MYSQL(http://www.mysql.com/)这个流行的开源数据库及ADOdb (http://adodb.sf.net/)作为数据库访问层。我将ADOdb作为我自己工作室的标准是因为它优异的性能,且抽象了Oracle OCI接口,并提供了统一的访问PostgreSQL, Sybase, MySQL和其它数据库的接口,而成为了易于使用的PHP API,让你专注于程序与业务逻辑的处理。
放心的替换你的自己的数据库与访问层,因为这里提出的许多概念也适合于别的解决方案。
在研究t动态记录模式之前,让我们从基本的数据库连接开始。有一个集中的,简单的方式去指定连接参数(主机名,用户名,密码,数据库)并建立一个数据库连接对象是很理想的。一个单一模式对象(参见第四章)就非常适合了。
这是一个DB类,其conn()方法返回一个单一模式的ADOConnection类的实例。
// PHP5
require_once ‘adodb/adodb.inc.php’;
class DB {
//static class, we do not need a constructor private function __construct() {}
public static function conn() {
static $conn;
if (!$conn) {
$conn = adoNewConnection(‘mysql’);
$conn->connect(‘localhost’, ‘username’, ‘passwd’, ‘database’);
$conn->setFetchMode(ADODB_FETCH_ASSOC);
}
return $conn;
}
}
DB类允许你设定数据库的类型与连接参数。第一行代码将ADOdb库包含进来(你可能需要根据你的实际环境来调整路径);因为没有必要每次都实例化DB,所以DB的构造函数是私有的; 行$conn->setFetchMode(ADODB_FETCH_ASSOC)设定对象返回的记录集是以(字段名=>值)形式的关联数组。与数据库打交道中采用关联数组是非常重要的经验习惯,这样您的代码就不会受到因SQL语句中字段排序而产生的影响。
作为示例程序,让我们建立一个Active Record对象来维护一个超链接表。以下是一个在MySQL数据库中建立这个超链接表的SQL。
define(‘BOOKMARK_TABLE_DDL’, <<<EOS
CREATE TABLE `bookmark` (
`id` INT NOT NULL AUTO_INCREMENT ,
`url` VARCHAR( 255 ) NOT NULL ,
`name` VARCHAR( 255 ) NOT NULL ,
`description` MEDIUMTEXT,
`tag` VARCHAR( 50 ) ,
`created` DATETIME NOT NULL ,
`updated` DATETIME NOT NULL , PRIMARY KEY ( `id` )
) EOS
);
实验的独立性
各个实验间应是相互独立的;否则,仅仅是运行了某一个实验就会影响到后续实验的结果。
为了避免这些都基于同一数据库的实验间相互干扰,最好是在每个测试开始前删除并重建相关表。以下简单的实验为后续实验提供了一种标准的setup方法。
以下代码演示如何在每个实验开始前重置你的数据库:
class ActiveRecordTestCase extends UnitTestCase {
protected $conn;
function __construct($name=’’) {
$this->UnitTestCase($name);
$this->conn = DB::conn();
}
function setup() {
$this->conn->execute(‘drop table bookmark’);
$this->conn->execute(BOOKMARK_TABLE_DDL);
}
}
这段代码用一个标准的ADOConnection对象来给$conn的属性赋值,并且使用了Connection的execute()方法来执行SQL删除与重建表的操作。因为这些代码在一个名为setup()的方法中,使得每一个实验都能在一个新的数据库环境中工作。
更进一步,你应做一些对setup()方法的较为全面的测试(并多学习一些关于ADOConnection API的实例)
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testSetupLeavesTableEmptyWithCorrectStructure() {
$rs = $this->conn->execute(‘select * from bookmark’);
$this->assertIsA($rs, ‘ADORecordSet’);
$this->assertEqual(0,$rs->recordCount());
foreach(array(
‘id’,
‘url’,
‘name’,
‘description’,
‘tag’,
‘created’,
‘updated’) as $i => $name) {
$this->assertEqual($name, $rs->fetchField($i)->name);
}
}
}
即使你不太熟悉ADOdb,你也能正确的认识到execute()方法在成功执行后将返回一个ADORecordSet 对象,这个对象有一个recordCount()方法,这个方法可以验证表是否为空。记录集对象还有一些方法去浏览记录集元数据和fetchField()方法来效验表结构。
记录建立
在连接到数据库后,您的“创建,读取,更新,删除”(CRUD)程序就能在数据库中进行相关的行操作。
CRUD
CRUD 是创建,读取,更新,删除的缩写. 这些都是数据库交互式应用系统的基础
许多PHP WEB使用都是CRUD界面应用的示例
(http://c2.com/cgi/wiki?CrudScreen).
下面的示例应用是将一些书签存入数据库,因此我们将这个动态记录类命名为Bookmark。要建立一个新的书签,可使用new方法建立一个该类的实例,并设定实例的相关属性。当所有的属性都已设定(强制性),我们使用save()方法将其存入到数据库中。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testNew() {
$link = new Bookmark;
$link->url = ‘http://simpletest.org/’;
$link->name = ‘SimpleTest’;
$link->description = ‘SimpleTest project homepage’;
$link->tag = ‘testing’;
$link->save();
$this->assertEqual(1, $link->getId());
}
}
从这个实验中我们看到,Bookmark类有一些公共的属性与一个save()方法。当实例被保存到数据库后,getid()方法可以返回指定给它的数据库记录号(ID)。
下面是Bookmark类的属性定义
class Bookmark {
public $url;
public $name;
public $description;
public $tag;
}
让我们转向研究save()方法。它需要一个数据库连接,因此我们在构造函数中用DB::conn()连接工厂实现。
class Bookmark {
protected $id;
protected $conn;
// ...
public function __construct() {
$this->conn = DB::conn();
}
}
$conn 现在就成为适合save()方法的数据库连接了。
class Bookmark {
// ...
const INSERT_SQL = “
insert into bookmark (url, name, description, tag, created, updated)
values (?, ?, ?, ?, now(), now())
“;
protected function save() {
$rs = $this->conn->execute(
self::INSERT_SQL
,array($this->url, $this->name,
$this->description, $this->tag));
if ($rs) {
$this->id = (int)$this->conn->Insert_ID();
} else {
trigger_error(‘DB Error: ‘.$this->conn->errorMsg());
}
}
}
ADOdb 的 MySQL 驱动支持按位置的参数替换功能,并且能正确的引用(按类型加引号)参数。SQL的形参以问号(?)的形式指定,你可以在execute()方法的第二个参数的位置,以数组的形式传递实参值到SQL语句中来替换以问号点位的形参。
Insert_ID()方法可能已引起你的注意:它返回最后一次执行插入操作时自增长列的值。
到目前为止,这些实验已证明了以下事实:属性可以被设置,save()能正常工作,$ID属性已被置为1。让我们进一步的深入到数据表去验证其它的属性值也被正确的保存。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testNew() {
$link = new Bookmark;
$link->url = ‘http://simpletest.org/’;
$link->name = ‘SimpleTest’;
$link->description = ‘SimpleTest project homepage’;
$link->tag = ‘testing’;
$link->save();
$this->assertEqual(1, $link->getId());
// fetch the table as an array of hashes
$rs = $this->conn->getAll(‘select * from bookmark’);
$this->assertEqual(1, count($rs), ‘returned 1 row’);
foreach(array(‘url’, ‘name’, ‘description’, ‘tag’) as $key) {
$this->assertEqual($link->$key, $rs[0][$key]);
}
}
}
以上突出显示代码的功能是获取整个书签表数据。GetAll()方法执行查询并返回结果集,该结果集是以数组形式存放的记录的哈稀表。AssertEqual()方法验证只有一条记录存在于结果集中。通过foreach循环比较从数据表中取得记录的字段与$link对象的属性值是否一致。
上述代码已能正常工作,但通过手工的方法设定属性值去增加书签表数据的方法还是略显繁琐。因此,为上述的案例增加一个方便(通用)的方法,来实现增加的新建书签对象。
The ActiveRecordTestCase::add()方法带有(处理)四个参数,可建立与插入一个新的ActiveRecord书签对象。如果你在后续实验中要用到新创建的书签对象,add()方法还可以在创建成功后返回它。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function add($url, $name, $description, $tag) {
$link = new Bookmark;
$link->url = $url;
$link->name = $name;
$link->description = $description;
$link->tag = $tag;
$link->save();
return $link;
}
}
你完全可以在本实验案例中写一个测试方法来证明其可用性。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testAdd() {
$this->add(‘http://php.net’, ‘PHP’,
‘PHP Language Homepage’, ‘php’);
$this->add(‘http://phparch.com’, ‘php|architect’,
‘php|arch site’, ‘php’);
$rs = $this->conn->execute(‘select * from bookmark’);
$this->assertEqual(2,$rs->recordCount());
$this->assertEqual(2,$this->conn->Insert_ID());
}
}
既然书签可以创建并存储于数据库中,让我们给Active Record书签对象增加一个方法,可以简单的从数据库中获取数据并在实例的属性中保存所获取的值。一种通用的建立动态记录对象的技术是通过传递一个标示符,如书签号(或是别的什么标准)到它的构造函数中,并且从数据库中取出与这个ID相关联的行数据。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testCreateById() {
$link = $this->add(
‘http://blog.casey-sweat.us/’,
‘My Blog’,
‘Where I write about stuff’,
‘php’);
$this->assertEqual(1, $link->getId());
$link2 = new Bookmark(1);
$this->assertIsA($link2, ‘Bookmark’);
$this->assertEqual($link, $link2);
}
}
这个实验传递了一个ID到构造函数,这是前面的实验所没有出现过的。是否传递ID是可选的,如果没有传递ID,则前述试验中建立新的空书签实例的功能将依然正常工作。
这儿是一些实现上述功能要求的代码。
class Bookmark {
// ...
const SELECT_BY_ID = ‘select * from bookmark where id = ?’;
public function __construct($id=false) {
$this->conn DB::conn();
if ($id) {
$rs = $this->conn->execute(
self::SELECT_BY_ID
,array((int)$id));
if ($rs) {
$row = $rs->fetchRow();
foreach($row as $field => $value) {
$this->$field = $value;
}
} else {
trigger_error(‘DB Error: ‘.$this->conn->errorMsg());
}
}
}
// ...
}
构造函数允许一个名为$id的参数,它的默认为假。如果传来的参数不为假,则BOOKmark则用此ID为关键字查询数据库中BOOKmark表的相关行,如果该行存在,则用获取的数据来设定对象属性的值。
数据错误测试
Mock::generate(‘ADOConnection’);
class ActiveRecordTestCase extends UnitTestCase {
//...
function testDbFailure() {
$conn = new MockADOConnection($this);
$conn->expectOnce(‘execute’, array(‘*’,’*’));
$conn->setReturnValue(‘execute’,false);
$conn->expectOnce(‘errorMsg’);
$conn->setReturnValue(‘errorMsg’,
‘The database has exploded!!!!’);
}
}
这段代码调用了Mock::generate() 来生成一个MockADOConnection 类,并生成一个模拟连接的实例,同时设定一些基本的返回值来指明错误,和定义在这些环境中可能会出现的意外。
然而,因为书签类的构造函数调用了静态方法DB:conn()来获取数据库连接,要注入模拟连接到其中就很困难了。这儿有一些可能的实现方法:增加一个方法来改变$this->conn,增加一个可选参数到每一个方法中,或是增加一个参数到构造函数中。让我们选用最后一种方法:给Bookmark的构造函数增加一个可选的参数。
class Bookmark {
// ...
public function __construct($id=false, $conn=false) {
$this->conn = ($conn) ? $conn : DB::conn();
// ...
}
}
现在,新的Bookmark依然能正常工作,但新的Bookmark(1, $connection)用参数中的$connection对象代替正常的ADOConnection对象。
当这段代码完成后,你就能方便的将正常的数据库连接对象用模拟的连接对象进行替换,并且能进行数据库错误的检测。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testDbFailure() {
$conn = new MockADOConnection($this);
$conn->expectOnce(‘execute’, array(‘*’,’*’));
$conn->setReturnValue(‘execute’,false);
$conn->expectOnce(‘errorMsg’);
$conn->setReturnValue(‘errorMsg’,
‘The database has exploded!!!!’);
$link = new Bookmark(1,$conn);
$this->assertErrorPattern(‘/exploded/i’);
$conn->tally();
}
动态记录实例ID
在前面的例子中,大多数属性都是公共的,然而,书签ID是受保护的,以其值被免意外更改(如果其值被意外更改,当你想更新书签数据的时候问题就出现了)。因为$ID是受保护的,因此增加一个辅助方法来获取其值。
class Bookmark {
protected $id;
//...
public function getId() {
return $this->id;
}
}
怎样来测试它呢?
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testGetId() {
$this->add(‘http://php.net’, ‘PHP’,
‘PHP Language Homepage’, ‘php’);
// second bookmark, id=2
$link = $this->add(‘http://phparch.com’,
‘php|architect’, ‘php|arch site’, ‘php’);
$this->assertEqual(2, $link->getId());
}
}
如上,add()方法生并成保存数据,并通过getid()方法获取生成数据的ID值并验证其是相匹配的。
但是,如果你想用别的条件来验证所生成的数据而不仅仅是用书签的ID,或是你如何确保从数据库中返回的ID是正确的?用select语句根据给定的属性条件取得数据,并验证返回行的ID值是一个好的技术方法。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testGetId() {
$this->add(‘http://php.net’, ‘PHP’,
‘PHP Language Homepage’, ‘php’);
// second bookmark, id=2
$link = $this->add(‘http://phparch.com’,
‘php|architect’, ‘php|arch site’, ‘php’);
$this->assertEqual(2, $link->getId());
$alt_test = $this->conn->getOne(
“select id from bookmark where url = ‘http://phparch.com’”);
$this->assertEqual(2, $alt_test);
//alternatively
$this->assertEqual($link->getId(), $alt_test);
}
}
注意到这个试验类似于你用手工执行一个SQL查询来验证数据是否正确插入到书签表中。通过本次实验所实现代码,还能用于你后续实验中来验证数据的正确性,而不是仅仅简单的去执行它。
记录搜索
现在,我们已能实现保存书签对象到数据库,并且能根据书签ID从数据库中获取相应数据来重建书签对象。但是当ID值并不知道(通常情况也是这样)时会发生什么?或是你想通过如部分名称或是URL等相关值来搜索数据库,则更常见的解决方法是增加一个”finder”方法。
例如,你也许想使用findByUrl()方法查找与给定参数相类似的书签,下面的实验则能实现上述的要求。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testFindByUrl() {
$this->add(‘http://blog.casey-sweat.us/’, ‘My Blog’,
‘Where I write about stuff’, ‘php’);
$this->add(‘http://php.net’, ‘PHP’,
‘PHP Language Homepage’, ‘php’);
$this->add(‘http://phparch.com’, ‘php|architect’,
‘php|arch site’, ‘php’);
$result = Bookmark::findByUrl(‘php’);
$this->assertIsA($result, ‘array’);
$this->assertEqual(2, count($result));
$this->assertEqual(2, $result[0]->getId());
$this->assertEqual(‘php|architect’, $result[1]->name);
}
}
该实验生成一些数据,查找URL中包含有“PHP”字样的行,并校检返回的书签对象数组中的字符。FindByUrl()之所以是一个静态方法,是因为你有可能在没的书签对象实例化的情况下进行该操作。(当然你也能将“查找“方法放到每一个对象中,但目前 “查找”方法仍然是书签类中的一个方法。)
以下代码实现上述实验的要求。
class Bookmark {
// ...
const SELECT_BY_URL = “
select id
from bookmark
where url like ?”;
public static function findByUrl($url) {
$rs = DB::conn()->execute(
self::SELECT_BY_URL
,array(“%$url%”));
$ret = array();
if ($rs) {
foreach ($rs->getArray() as $row) {
$ret[] = new Bookmark($row[‘id’]);
}
}
return $ret;
}
}
更新记录
CRUD操作中的建立与读取部分介绍完毕。何如更新数据呢?当然用save()方法来更新activate record对象是合理的,但目前save()方法只能完成插入数据,其代码如下
class Bookmark{
// ...
const INSERT_SQL = “
insert into bookmark (url, name, description, tag, created, updated)
values (?, ?, ?, ?, now(), now())
“;
protected function save() {
$rs = $this->conn->execute(
self::INSERT_SQL
,array($this->url, $this->name,
$this->description, $this->tag));
if ($rs) {
$this->id = (int)$this->conn->Insert_ID();
} else {
trigger_error(‘DB Error: ‘.$this->conn->errorMsg());
}
}
}
然而,如果你已有一个有效的书签实例,则你应该希望看到如下代码
class Bookmark {
// ...
const UPDATE_SQL = “
update bookmark set url = ?,
name = ?, description = ?, tag = ?,
updated = now()
where id = ?
“;
public function save() {
$this->conn->execute(
self::UPDATE_SQL
,array(
$this->url,
$this->name,
$this->description,
$this->tag,
$this->id));
}
}
要区别INSERT与UPDATE,你应该测试书签数据是新建的还是从数据库中获取得的。
首先,重新制作两个版本的save()方法,分别命令为insert()与update()。
class Bookmark {
// ...
protected function insert() {
$rs = $this->conn->execute(
self::INSERT_SQL
,array($this->url, $this->name,
$this->description, $this->tag));
if ($rs) {
$this->id = (int)$this->conn->Insert_ID();
}
}
protected function update() {
$this->conn->execute(
self::UPDATE_SQL
,array(
$this->url,
$this->name,
$this->description,
$this->tag,
$this->id));
}
}
现在你新的save()方法的代码就如下所示了。
class Bookmark {
const NEW_BOOKMARK = -1;
protected $id = Bookmark::NEW_BOOKMARK;
// ...
public function save() {
if ($this->id == Bookmark::NEW_BOOKMARK) {
$this->insert();
} else {
$this->update();
}
}
}
最后一个问题:当你插入或是更新记录时,时间戳总是要改变的。如果不采取从数据库中获取时间戳的手段,则没有更好的方法在书签对象中记录准确的时间戳了。因为在插入与修改中都要应用到,所以要更改Activate Record类,当save()方法完成后,就更新时间戳(实例的相关属性值),以避免后来产生的不同步。
class Bookmark {
// ...
public function save() {
if ($this->id == self::NEW_BOOKMARK) {
$this->insert();
} else {
$this->update();
}
$this->setTimeStamps();
}
protected function setTimeStamps() {
$rs = $this->conn->execute(
self::SELECT_BY_ID
,array($this->id));
if ($rs) {
$row = $rs->fetchRow();
$this->created = $row[‘created’];
$this->updated = $row[‘updated’];
}
}
}
书签对象已具有了动态记录模式的核心:save()方法知道如何处理更新与插入的SQL请求,知道对象的当前状态,并且能组装所需的参数来代替由原由对象属性所构成的数组。让我们来测试一下。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testSave() {
$link = Bookmark::add(
‘http://blog.casey-sweat.us/’,
‘My Blog’,
‘Where I write about stuff’,
‘php’);
$link->description =
‘Where I write about PHP, Linux and other stuff’;
$link->save();
$link2 = Bookmark($link->getId());
$this->assertEqual($link->getId(), $link2->getId());
$this->assertEqual($link->created, $link2->updated);
}
}
现在,让我们转向如何处理删除操作。在16章――数据地图模式中有一个例子,但是你可以方便的从insert()和update()方法中推导出来。
总结
正如大多数初次尝试由面向过程到面向对象编程所表现的那样,动态记录模式在概念与执行上都较为简单。将你所有的SQL代码都组织在一起是非常好的,并且动态记录模式给了你一个非常好的将业务逻辑与数据库操作相结合来持续保存对象的方法。
本章的例子用了一个真实的数据库来开发测试代码。另一个测试简单数据库代码的方法是使用模拟对象(参见第6章)来模拟数据库连接。不幸的是,这个方法并是广泛有效。SQL是一个复杂的语言,模拟的每个语句都与数据库的细节实验密切相关。而用新建的,实际的表进行实验则令人觉得舒服得多,没有模拟SQL时的副作用了。
如果动态记录模式还有不利的方面,则是其复杂性了。一个动态记录类可能迅速的变大,就像一个块磁铁。例如,书签类现在只有一个findById()方法,但你很有可能想要findByDescription()方法,或是findByGroup(),findRecentlyCreated()等方法。
另一个问题是对象会变得“重复”,这在save()方法中可能会看到。例如,$link与$link2在实验用例表示的是不同的对象,但事实上它们都是指同一个书签ID。你可以用下面的实验来证明。
class ActiveRecordTestCase extends UnitTestCase {
// ...
function testSave() {
// ...
$this->assertNotIdentical($link, $link2);
}
}
如果认为解决这个问题是重要的,你有可能要增加一个内部的注册机制(参见第五章)确保Bookmark(1)返回的所有对象的实例应是同一个对象。因为你实际上用的是new操作来建立一个对象而不是用工厂方法,作为一种实际的Active Record类,你应该将Bookmark修改为代理(参见11章)来真正解决这个问题。
另一个方面,Active Record模式被设计成一次处理一行记录的方式。这种模式是典型的”管理”式应用的界面,如编辑一篇文章,一个链接,一个注释等。但是大数的网页要处理的是多数据集或是多行数据,这正是我们下一章要讨论的主要内容――表数据网关模式。