[原文地址:http://www.dualface.com/blog/?p=357]
许多开发者很疑惑为什么 FleaPHP 以高效开发为目标,却没有提供 Active Record 模式。本文尝试详细阐述这个问题。
Active Record 是什么?
Active Record 模式中文名为“活动记录”,在《企业应用架构模式》(PoEAA)一书中定义如下:
活动记录(Active Record):一个对象,它包装数据库表或视图中的某一行,封装数据库访问,并在这些数据上增加了领域逻辑。
举个例子来说,一个图书数据表,每一条记录就是一本图书的信息。那么采用 Active Record 时,每一本图书就是一个 Active Record 对象实例。
Active Record 因 Ruby On Rails 而流行
Active Record 之所以现在这么炙手可热,甚至许多人将 Active Record 和 ORM 划等号,完全是 Ruby On Rails 的原因。
在 Ruby On Rails 中,Active Record 除了最基本的将数据记录和一个对象互相映射外,还提供了数据(而不是对象)间关联关系的处理。例如:
一本图书有一个或者多个作者,所以每一个图书对象都和多个作者对象关联。反过来一个作者可以写多本书,所以一个作者对象也和多个图书对象关联。
在 RoR 中,我们获取一个图书对象时,自动就获得了该图书对象所对应的作者对象(本质上是图书数据对应的作者数据)。更进一步,通过图书对象关联的作者对象,我们 可以获取该作者所写的所有图书的对象实例。而这些工作,在 RoR 中只需要几行代码而已,以前我们需要写上一大段代码才能实现同样的效果。
RoR 中,对 Active Record 模式的实现完全利用了 Ruby 语言的灵活性,简短几行代码就可以定义一个关联。并且通过复杂的 ActiveRecord:Base 对象,提供了 CRUD(创建、读取、更新、删除)操作的默认处理。所以使用 RoR 时,绝大部分常见的数据库操作只需要很少量的代码就可以完成,大大提高了开发效率。
但 Active Record 模式也不是完美的,Active Record 存在不少缺点。
- Active Record 模式需要数据表结构和对象属性一一对应(至少是大部分对应),否则将难以使用 Active Record 模式;
- Active Record 模式并不能够真正适合完全面向对象的应用程序。因为 Active Record 模式本质上就要求一个对象必须和一个数据表对应。但在完全面向对象的应用程序中,数据和操作数据的方法很可能分布在各个不同的对象中,这些对象却并没有和 某一个数据表完全对应,而且 Active Record 无法很好的处理对象的继承、聚合等面向对象常见的对象间关系;
- 随着逐渐向 Active Record 添加业务逻辑,Active Record 对象中会混入越来越多的 SQL 语句,这在更复杂的项目中显然是一个不利因素。
如果在 Active Record 模式中添加了对数据关系(注意,不是对象关系)的处理,那么还要注意性能问题:
假如一个 Active Record 对象有多个关联。那么我取出一个对象时,很可能就连带取出了其他不少对象。但这些对象可能根本就是本次操作用不上的。其次,将对象更新到数据库时,也需要对关联的对象进行处理,否则对关联对象的修改就会丢失。
虽然可以用各种技巧来避免这些情况,但毫无疑问需要开发者对 RoR 的 Active Record 很熟悉才行。否则看上去很简单的代码,背后则会是噩梦般的数据库操作。
其次,假设我们要将数据库中每本书的单价减半,那么采用 Active Record 模式时,就必须首先读取所有的记录并实例化为对象,然后更新对象属性,再写回数据库。可想而知这样会有多差的效率。
当然了,实际开发中没有人会这样做。开发者会编写一个单独的方法,用一条 SQL 语句完成对批量数据的更新。但也说明 Active Record 模式不适合批量处理数据,而现实世界中,批量处理数据的需求随处可见。
不过由于 RoR 对开发效率戏剧性的提高,所以对于追求开发效率的项目,RoR 是一个很不错的选择。而且性能上的不足可以通过更新硬件或者配合其他技术手段来改善(例如 FastCGI 通常是运行 RoR 应用的首选)。因此在现实世界中,37signals.com 公司的所有基于 RoR 开发的应用,都获得了良好的性能表现(但是同等的硬件,跑 PHP 开发的同样功能应用是更好还是更差呢?这个问题没有答案)。
Active Record 与 ORM
许多人将 Active Record 与 ORM 划等号,这是错误的。ORM(对象关系映射)是将对象及对象间的关系(继承、聚合等)映射到关系式数据库中。由于面向对象和关系式数据库天生的不匹配,所以这种映射是相当复杂的。
而 Active Record 原本只是将一个数据行记录包装为一个对象,只是在 RoR 中由于添加了对关系的处理,而具有了一些 ORM 的特征。所以可以简单的将 RoR 中的 Active Record 看作 ORM 的一种实现方式。但本质上,RoR 中的 Active Record 是处理数据间的关系而不是对象间的关系(但支持对象继承),因为每一个 Active Record 对象都是和数据表一一对应的。
那为什么在 Java 世界中,没有大量采用 Active Record 模式呢?
在 Java 世界中,绝大部分 ORM 都是作为中间件存在的。由于 Java 与 Ruby、PHP 等脚本语言截然不同的运行机制。所以即便是很复杂的中间层,只要能够在运行时提供良好的性能,那就能够被开发者接受。而 Hibernate 这样的 ORM 中间件能够提供比 Active Record 多得多的功能和灵活性,所以 Active Record 模式在 Java 世界不受欢迎就可以理解了。
而在 .NET 世界中,大量使用的都是表数据入口(Table Data Gateway)和表模块(Table Module)。这两种模式由于有 Microsoft 出色的 IDE 支持,所以能够获得很高的开发效率,自然 .NET 开发者对 Active Record 模式也不感兴趣了。
如果将 Active Record 或者 ORM 照搬到 PHP 中呢?
许多开发者都很羡慕 Hibernate 的强大功能和 RoR 中 Active Record 的快速开发能力,但是这些东西如果照搬到 PHP 中,会遇到一个相当大的麻烦:
PHP 本质上是解释执行的脚本语言,所以对于每一次 HTTP 请求,PHP 执行环境都会将请求的 .php 文件编译为 opcode,然后执行 opcode,再清理所有的资源(内存、数据库连接、文件句柄等等)。在这种环境中,应用程序应该花尽可能少的时间去初始化底层框架,而是把大部分资源用 在业务逻辑的执行上。
但 Ruby 也是解释执行,为什么就可以用 Active Record,而 PHP 就不应该呢?
简单点说就是因为 PHP 在面向对象支持上的缺陷使得要实现和 RoR 同等功能的 Active Record 模式变得非常艰难。也许你对此不以为然,那么可以实际尝试一下使用 PHP on Trax(一个 RoR 的 PHP 克隆)。看看一次简单的读取操作需要载入多少文件并调用多少对象和方法。
所以有些 PHP 框架提供的 Active Record 模式实现非常简单,根本不考虑关联问题,但这样一来使用 Active Record 能获得的开发效率提升就太小了。
至于更为复杂的 ORM,目前 PHP 领域还没有一个真正的成功项目。虽然 Propel 是目前 PHP 领域唯一一个具有实际工作能力的 ORM。但由于其自身的复杂性和执行效率问题,一直没有得到广泛使用。即便是 Symfony 也是对 Propel 进行裁剪后才用于处理数据库操作。
虽然在国内 PHP 社区中常看到有人说自己做的 ORM 如何如何先进,既有高级特征,又有好的效率。但自始至终没有看到过有人公布代码。至于不公布的原因不外乎:还不够成熟,成熟后再公布;我是最领先的,除非 有了同水平的,不然我不会公布;商业产品,不能泄露。而且别说是代码,就算问问实现原理通常也只能得到几句无关痛痒的回答。
所以如果你看到这篇文章后,觉得你实现了我认为很难实现的东西,请拿出实际证据。不要再搬出诸如此类的理由,没有论据的辩论是毫无意义的。
那么 PHP 就注定和 Active Record 和 ORM 无源吗?
如果这个问题的潜在意思是问:PHP 就不能找到和 Active Record 一样好用的数据库访问方法吗?那么答案是否定的。
Table Data Gateway 是一个更合理的选择
我仔细研究了 PoEAA 中关于表数据入口、表模块的内容后,又做了大量实际测试。最终决定在 FleaPHP 中采用 Table Data Gateway(表数据入口)模式来提供数据库服务。并在此基础上实现对关联数据的自动处理。
表数据入口(Table Data Gateway):充当数据表访问入口的对象,一个实例处理表中所有的行。
表模块(Table Module):处理某一数据库表或视图中所有行的业务逻辑的一个实例。
表数据入口是封装一个数据表的操作,而不是一个记录行。这样一来,表数据入口可以很方便的处理针对单个记录和多个记录的操作,而操作的数据就是 PHP 中的数组。实际上我初期还写了一些对象来封装记录集(也就是多行记录),不过后来发现完全是多此一举。PHP 的数组功能非常强大,再专门用对象包装一下弊大于利。
针对数据表提供单纯的 CRUD 操作吸引力还不够,所以我在表数据入口的基础上增加了对 HasOne、HasMany、ManyToMany 以及 BelongsTo 关联的处理。这四种关联,基本上满足了常见的数据关联操作。
不过有了自动化的关联,类似 RoR ActiveRecord 中加载过量数据的问题依然存在,所以 FleaPHP 的表数据入口对象 FLEA_Db_TableDataGateway 也提供了针对关联的方法,让开发者可以细粒度的控制数据库操作。
而且由于表数据入口是针对纯数据进行操作,而不是针对包装了数据的对象。所以开发者可以很容易的优化数据库操作,例如无需读取即可更新数据或者一次性处理大批量的数据。
相对于 Active Record 模式,Table Data Gateway 模式有下列优势:
- 表数据入口针对一个表封装数据库操作,这更接近传统 PHP 开发的思维模式;
- 处理批量数据时,表数据入口更方便,常见操作无需额外编写处理方法;
- 数据以数组的形式保存和传递,比将每个记录行实例化为对象具有好得多的性能;
- 实现比 Active Record 简单,每个操作执行更少的代码;
- 可以很好的与表模块(Table Module)模式配合来封装业务逻辑。从而避免了 Active Record 中将数据库操作和业务逻辑写在一起的问题。
当然,表数据入口也有相对于 Active Record 不足的地方:
- 由于表数据入口总是传递纯数据,所以无法像 Active Record 一样以属性的形式封装对数据的操作。不过这种操作即便使用 Active Record 也要多写不少处理代码,而使用表数据入口时,这部分代码只不过是转移到了表模块中;
- 看上去更没有那么面向对象。可惜的是即便采用 Active Record,大多数应用程序从设计思想上也不是面向对象的,只不过用了一个对象来传递数据而已。
而且 Active Record 存在的一些问题,Table Data Gateway 依然无法避免。最主要的就是表数据入口和表模块都是和数据表一一对应,因此不适用于持久化细粒度对象。不过熟悉 .NET 的开发者应该很容易找到解决办法,那就是以表模块完成大部分业务操作,而细粒度对象仅用于部分操作。这是因为 Microsoft 的开发环境一向都对表数据入口和表模块有着偏好和最好的支持。
不过使用表数据入口,相对于 Active Record 最大的好处就是能够很容易的将业务逻辑操作从表数据入口对象分离到表模块对象中,因此对于更大更复杂的项目,表数据入口配合表模块的方式具有更高的可维护性。
表数据入口和表模块的配合
表数据入口封装了针对数据表的操作,而表模块则封装了针对数据表的业务逻辑,两者怎么配合呢?我们就以操作图书记录为例,看看具体如何做。
首先,从 FLEA_Db_TableDataGateway 派生一个类,作为图书表的表数据入口对象,例如 TableBooks。接下来建立一个空白的类,名为 ModuleBooks。
- class TableBooks extends FLEA_Db_TableDataGateway
- {
- // 只需要指明数据表名称和主键字段名即可,CRUD 操作已经有了默认实现
- var $tableName = 'books';
- var $primaryKey = 'book_id';
- }
- class ModuleBooks
- {
- var $table;
- }
现在我们要统计指定年份的出版的图书。
Step1:在 TableBooks 中增加一个方法 countBooksRange():
- class TableBooks extends FLEA_Db_TableDataGateway
- {
- ......
- /**
- * 统计指定时间区间的图书总数
- */
- function countBooksRange($begin, $end)
- {
- // 对参数进行转义,确保不会存在 SQL 攻击漏洞
- $begin = $this->_dbo->qstr($begin);
- $end = $this->_dbo->qstr($end);
- return $this->findCount("publish_date >= {$begin} AND publish_date <= {$end}");
- }
- }
countBooksRange() 方法可以统计指定区间的图书总数,所以我们再给 ModuleBooks 增加一个 countBooksByYear() 方法来统计指定年份的图书。
- class ModuleBooks
- {
- ......
- function countBooksByYear($year)
- {
- $begin = date("{$year}/1/1");
- $end = date("{$year}/12/31");
- return $this->table->countBooksRange($begin, $end);
- }
- }
上面的例子虽然简单,但是很清晰的描述了表数据入口如何封装具体的数据库操作,而表模块又如何利用表数据入口的方法提供更高层的接口。如果需要 可运行的示例程序,可以参考 FleaPHP 的 SHOP 示例。这个示例中,Model 目录下就是表模块,而 Table 目录下就是表数据入口。