QeePHP 数据库架构(1)
时间:2008-04-12 来源:qeeify
这篇系列文章是《QeePHP架构与实现》一书的部分章节摘录。
《QeePHP架构与实现》一书将是QeePHP配套书籍中的第二本,第一本是《QeePHP实战》。
《QeePHP实战》偏重入门和实践应用,而《QeePHP架构与实现》则是详细阐述QeePHP开发框架的设计思想、具体实现机制等等。
目前两本书都在紧张撰写中,这里先放出《QeePHP架构与实现》初稿的部分章节。
QeePHP的数据库架构为QeePHP应用程序提供了全方位的数据库服务。
整个数据库架构的概貌如下图:
主要组件概述
QeePHP数据库架构包含的主要组件分为三个级别。
最底层的是适配器、事务等组件,提供直接操作数据库的能力,并且为更上层的组件提供服务;位于中间层次的则是表数据入口。表数据入口封装了对数据表的操作,既可以处理单独的记录,也可以高效得完成批量数据处理;最高层的ActiveRecord实际上应该算作领域层组件。但由于ActiveRecord和数据库操作紧密相关,所以还是把ActiveRecord放到数据库架构中来介绍。
从上图可以看出,ActiveRecord依赖表数据入口提供的数据库操作功能,而表数据入口由借助更底层的组件来操作数据库。这种分层可以让开发者根据不同的需求选择合适的组件来解决问题,而不是用一个all in one的怪物来试图应付所有需求。
- 适配器
适配器是最底层的对象,用于为不同的数据库提供一个统一的接口。适配器好封装了结果集,以便上层组件能够利用统一的接口对查询结果集进行遍历。
- 事务
QeePHP提供异常安全的事务管理机制。当启用一个事务后,该事务对象会处理未被应用程序捕获的异常,从而在异常发生时自动回滚事务。
- 表达式
表达式用于封装一个数据库表达式,以便在查询对象以及其他数据库操作中使用。
- 表关联
表关联封装了数据表之间的关联关系。表数据入口和ActiveRecord利用表关联对象来处理数据和对象间的关联操作。
- 查询对象
查询对象封装了一次数据库查询。查询对象提供方法链风格的接口,让开发者可以创建复杂的查询。
- 表数据入口
表数据入口封装针对一个数据表的操作,是一个高层的数据库接口。表数据入口为ActiveRecord提供数据库的CRUD服务。表数据入口支持扩展插件,从而在表操作的层次透明的改变应用程序和框架行为。
- ActiveRecord
ActiveRecord表面上看封装了一个数据库记录。但ActiveRecord实际上是一个领域对象。ActiveRecord封装了领域对象的属性和方法,并利用表数据入口来完成领域对象的持久化。
数据库适配器详解
QeePHP的适配器由两部分组成:QDB_Adapter_Abstract继承类和QDB_Result_Abstract继承类。
QDB_Adapter_Abstract为不同的数据库系统定义了一个统一的接口,而QDB_Result_Abstract则定义了查询结果集的统一接口。QDB_Adapter_Abstract继承类和QDB_Result_Abstract继承类会根据特定的数据库系统,实现抽象类中定义的方法。
设计思想
虽然数据库适配器的主要目标是为更上层的组件提供一个访问数据库的统一接口。但数据库适配器还有一个重要任务就是要能充分发挥不同数据库系统的能力。例如带有参数绑定的查询操作,在使用PHP的mysql扩展时,参数占位符将被替换为实际的参数值。但使用oracle、pgsql等扩展时,则会使用数据库系统支持的参数绑定模式来进行查询。同样,对于CLOB/BLOB、日期、元数据、事务都有不同的处理方式。
过去,更高层次的组件通常完成大部分工作,然后利用数据库适配器提供的查询接口来执行查询。这种方式实现容易,但是很难发挥数据库的自身能力。所以,在QeePHP的数据库架构中,许多工作转移到数据库适配器来完成。这样针对不同的数据库,可以采用最具效率的解决方法。
此外,各个数据库数据库适配器除了实现统一的接口,也为不同的数据库提供了特别的方法,以便开发者可以在应用程序中更直接的操作数据库来完成特别的需求。
规划接口
实现数据库适配器最大问题是如何确定一个统一的接口。例如mysql扩展不支持参数绑定,但mysqli和pdo都支持参数绑定。那参数绑定是否应该加入数据库适配器的统一接口中呢?而且数据库的不同版本,支持的特征也各不相同。例如mysql早期版本就不支持子查询,pgsql的早期版本则没有序列字段类型。那我们的统一接口又将如何规划这些特征呢?
通常,人们按照不同数据库功能的交集来设计统一接口。但这样做的结果就是连参数绑定这样的基本功能也从接口中消失了。所以在设计QeePHP的数据库适配器接口时,我们转变了设计思想。
首先,我们将过去没有包含在其他组件中的功能部分迁移到了数据库适配器中。例如对SQL查询语句的分析、SQL查询语句的构造等。这样做以后,虽然数据库适配器变得庞大了,但是也降低了高层组件的复杂性。并且可以在数据库适配器中针对不同数据库做出最优化的实现。
接下来,我们让数据库适配器能够更多的“感知”当前的“上下文”。举例而言,过去我们要获得一个新的序列值,仅仅是传递一个序列名称给适配器。现在则还要传递需要这个序列值的数据表、对应字段等信息。由于数据库适配器获得的信息更多,也就更容易针对特定情况进行优化。
第三步,我们需要为一些广泛使用的早期数据库系统(或者PHP扩展)模拟出一些新数据库才具备的特征。这里面的典型就是使用量最大的mysql扩展不支持参数绑定,所以我们必须模拟实现这个特征,并且尽可能保持较高的性能。好在QeePHP面向的用户通常都能够利用最新的软件和技术,所以这个问题不算突出。
最后,我们必须仔细斟酌,从而决定要为不同的数据库适配器添加数据库的哪些独有功能。这些功能如果太多,利用率则会下降,除了变成包袱没有任何意义。如果太少,那么开发者又很难发挥出数据库系统的极限能力。
实现
实现一个稳定可靠、高性能、具备良好兼容性的数据库适配器是不小的挑战。在具体实现时,我们参考了市面上几乎所有的数据库抽象层实现,尤其是Creole这个出色的数据库抽象层。不过对于QeePHP而言,数据库适配器应该小而强,不是大而全,因为更多复杂的功能将由数据库架构中更高层的组件来实现。
QDB_Adapter_Abstract、QDB_Result_Abstract等抽象类定义了数据库适配器的统一接口,并且实现了所有的公用操作。
QDB_Adapter_Abstract接口包含下列主要方法:
- 适配器状态
- connect() 连接数据库
- setSchema() 选择要使用的数据库
- nextID() 获得指定序列的下一个值
- insertID() 获得最后一次 INSERT 插入操作的主键值
- affectedRows() 获得最后一个操作影响的记录总数
- connect() 连接数据库
- 转义
- qstr() 对值进行转义
- qfield() 获得字段名的完全限定名
- qtable() 获得数据表名称的完全限定名
- qstr() 对值进行转义
- 查询
- execute() 执行一个查询
- selectLimit() 执行一个限定结果集的查询
- getAll()、getRow()、getCol()、getOne() 查询并返回结果
- execute() 执行一个查询
- 事务
- beginTrans() 开始事务,并返回一个QDB_Transaction对象
- startTrans() 开始事务
- completeTrans() 完成事务(提交或回滚)
- failTrans() 将事务标识为失败
- hasFailedQuery() 确定事务过程中是否有失败的查询
- beginTrans() 开始事务,并返回一个QDB_Transaction对象
- 元数据
- metaTabls() 获得匹配指定模式的数据表名称
- metaColumns() 获得指定数据表的字段元数据
- metaTabls() 获得匹配指定模式的数据表名称
- SQL构造
- getInsertSQL() 生成INSERT语句
- getUpdateSQL() 生成UPDATE语句
- getReplaceSQL() 生成REPLACE语句
- getInsertSQL() 生成INSERT语句
QDB_Result_Abstract的接口很简单,除了提取数据的fetch方法,还有一些遍历数据集的方法。
事务详解
QeePHP的数据库架构对事务具有完善的支持。除了一般的事务提交和回滚,还支持嵌套的子事务(利用SAVEPOINT来实现)和异常安全的自动化事务。
开始事务和完成事务
调用数据库适配器的startTrans()和completeTrans()方法即可开启事务和完成事务。completeTrans()有一个$commit_on_no_errors参数。该参数为true时,则尝试提交事务,否则回滚事务。
在调用startTrans()后,如果抛出了未被捕获的异常(这会导致后续的completeTrans()没有机会执行),那么事务在应用程序结束后是自动提交还是自动回滚,将由数据库系统的设置决定。一般来说,如果一个数据库连接关闭时(PHP应用程序执行完毕后,通常会关闭数据库连接)存在未提交的事务,则会视为放弃,从而自动回滚该事务。
在调用startTrans()后,不管应用程序是否捕获异常,只要调用completeTrans()之前进行的数据库操作发生了错误,则事务都会进行回滚。在事务期间,还可以通过failTrans()来强制要求事务完成时回滚。
但是,采用startTrans()和completeTrans()管理事务在遇到复杂领域逻辑时,必须书写嵌套多层的代码。这样的代码不但难以理解,而且很容易出现逻辑上的疏漏。例如一个典型的领域逻辑实现代码如下:
function method_a()
{
// 开始事务
$dbo->startTrans();
// 用一个状态变量决定事务是提交还是回滚
$commit_on_no_errors = true;
// 必须捕获异常,因为不能自动提交或回滚
try {
... 执行数据库操作的代码 ...
... 其他代码 ...
} catch (Exception $ex) {
// 修改状态变量
$commit_on_no_errors = false;
}
// 提交或回滚事务
$dbo->completeTrans($commit_on_no_errors);
}
在上述实现代码中,用于捕获异常的try...catch仅仅是为了设置状态变量,以便在出现异常时能够回滚事务。虽然PHP能够在数据库操作出错时回滚事务,但这也是因为数据库系统本身提供了这种能力。所以对于存在复杂领域逻辑的应用程序,我们需要一种异常安全的事务机制。
在QeePHP中,QDB_Transaction实现了事务的自动提交和异常安全的事务机制。利用QDB_Transaction,我们的领域逻辑代码可以简化为:
function method_a()
{
$tran_a = $dbo->beginTrans();
... 执行数据库操作的代码 ...
... 其他代码 ...
}
其中不但不需要completeTrans()来明确完成事务,也不需要try...catch来捕获领域逻辑异常。
QDB_Transaction的自动提交
对比前面的两段代码,可以发现使用QDB_Transaction可以显著降低编码复杂度和代码量。只要将startTrans()换成beginTrans()就可以开启一个自动化的事务,并且确保这个事务在method_a()执行完毕时自动提交或回滚。
function method_a()
{
$tran_a = $dbo->beginTrans();
... 执行数据库操作的代码 ...
... 其他代码 ...
}
当进入method_a()函数进行执行时,调用$dbo->beginTrans()获得一个QDB_Transaction对象实例,并保存到$tran_a变量中。当method_a()函数执行完毕返回时,$tran_a将被自动销毁。这会导致QDB_Transaction对象的析构函数被PHP调用。在析构函数中,将会把QDB_Transaction对象生存期间(构造到销毁)执行的数据库操作作为一个事务进行提交。具体的事务提交操作是委托给数据库适配器进行的。
利用QDB_Transaction的自动提交事务功能,我们可以在需要开始事务的方法中调用beginTrans()获得一个事务对象,然后就不用担心因为方法结束而忘记提交事务的问题。
如何处置事务
当QDB_Transaction对象被销毁时,需要决定如何处置事务。
从逻辑上看,如果在QDB_Transaction对象生存期间没有出现数据库操作错误,也没有抛出未被捕获的异常,那么事务应该算作成功完成,因此会提交该事务。反之,如果期间出现了数据库操作错误或者抛出了未被捕获的异常,则应该将事务视为失败,从而回滚事务。
异常安全的事务机制
下面的代码method_a()调用了method_b()。为了看上去更直观,我们把method_b()的代码直接缩进写入method_a(),这样可以清楚的看到执行流程。
function method_a()
{
$tran = $dbo->beginTrans();
... 执行代码 ...
function method_b()
{
$tran = $dbo->beginTrans();
... 执行代码 ...
}
}
上述代码中,不管是在method_a()中发生了异常,还是method_b()中发生了异常。QDB_Transaction都能确保两次嵌套构造的QDB_Transaction对象能够回滚事务。
为了实现这个机制,QDB_Transaction必须拦截到未被应用程序捕获的异常。因此,QDB_Transaction在构造函数中调用set_exception_handler()设置自身的一个方法作为异常处理方法。当异常抛出后,QDB_Transaction对象的异常处理方法会强制回滚事务。
现在来看看不同位置的异常抛出会导致什么情况。
情况一:
function method_a()
{
$tran = $dbo->beginTrans();
... 执行代码 ...
function method_b()
{
$tran = $dbo->beginTrans();
... 执行代码 ...
}
}
当method_b()中抛出异常时,$tran_b会拦截到这个异常,然后调用rollback()进行回滚操作。$tran_b的异常处理函数调用rollback()后,再重新抛出异常。重新抛出的异常会被$tran_a拦截到,从而引起$tran_a也回滚事务。
情况二:
function method_a()
{
$tran_a = $dbo->beginTrans();
... 执行代码 ...
function method_b()
{
$tran_b = $dbo->beginTrans();
... 执行代码 ...
}
throw 抛出异常
}
在情况二中,异常出现在调用method_b()之后。这个异常会被$tran_a的异常处理方法拦截到,并导致$tran_a封装的事务被回滚。由于$tran_b封装的事务是$tran_a事务的子事务,所以$tran_a回滚时,$tran_b封装的事务也会被回滚。
情况三:
function method_a()
{
$tran_a = $dbo->beginTrans();
... 执行代码 ...
throw 抛出异常
function method_b()
{
$tran_b = $dbo->beginTrans();
... 执行代码 ...
}
}
在情况三中,异常出现在调用method_b()之前。很显然由于异常已经抛出,所以method_b()根本不会被调用。只需要在$tran_a中拦截异常并回滚就可以了。
情况四:
function method_a()
{
$tran_a = $dbo->beginTrans();
... 执行代码 ...
try
{
function method_b()
{
$tran_b = $dbo->beginTrans();
... 执行代码 ...
throw 抛出异常
}
}
catch
{
// 捕获了 method_b() 抛出的异常,并对异常进行了处理
}
在这个例子中,由于在method_a()中捕获了method_b()抛出的异常。所以$tran_b销毁时会提交$tran_b封装的事务。method_a()捕获异常后,将有多种选择:
- 如果try...catch捕获异常后重新抛出异常或者抛出其他异常,都将被$tran_a捕获,从而引起事务的回滚。逻辑上,这可以视为一个异常没能被“解决”,需要更外层的代码来捕获并解决这个异常。
- 如果是因为数据库操作失败而引发的异常,那么不管该异常是否已经被“解决”。$tran_a都会进行事务的回滚。因为失败的数据库操作会将事务的状态设置为“需要回滚”。如果要避免出现这种情况,我们唯一的选择就是明确的调用$tran_a->commit()来提交事务。
原文地址:
http://qeeify.com/index.php/2008/04/12/qeephp-architecture-01.html
更多信息,请访问 FleaPHP/QeePHP 开源开发框架官方网站:
http://www.fleaphp.org/
。
[url=http://qeeify.com/index.php/2008/04/12/qeephp-architecture-01.html][/url]
相关阅读 更多 +
排行榜 更多 +