PHP V5 新的面向对象编程特性显著提升了这个流行语言中的功能层次。学习如何用 PHP V5 动态特性创建可以满足需求的对象。
PHP V5 中新的面向对象编程(OOP)特性的引入显著提升了这个编程语言的功能层次。现在不仅有了私有的、受保护的和公共的成员变量和函数 —— 就像在 Java™、 C++ 或 C# 编程语言中一样 —— 但是还可以创建在运行时变化的对象,即动态地创建新方法和成员变量。而使用 Java、C++ 或 C# 语言是做不到这件事的。这种功能使得超级快速的应用程序开发系统(例如 Ruby on Rails)成为可能。
但是,在进入这些之前,有一点要注意:本文介绍 PHP V5 中非常高级的 OOP 特性的使用,但是这类特性不是在每个应用程序中都需要的。而且,如果不具备 OOP 的坚实基础以及 PHP 对象语法的初步知识,这类特性将会很难理解。
动态的重要性
对象是把双刃剑。一方面,对象是封装数据和逻辑并创建更容易维护的系统的重大方式。但另一方面,它们会变得很繁琐,需要许多冗余的代码,这时可能最希望做到的就是不要犯错。这类问题的一个示例来自数据库访问对象。一般来说,想用一个类代表每个数据库表,并执行以下功能:对象从数据库读出数据行;允许更新字段,然后用新数据更新数据库或删除行。还有一种方法可以创建新的空对象,设置对象的字段,并把数据插入数据库。
如果在数据库中有一个表,名为 Customers,那么就应当有一个对象,名为 Customer,它应当拥有来自表的字段,并代表一个客户。而且 Customer 对象应当允许插入、更新或删除数据库中对应的记录。现在,一切都很好,而且有也很多意义。但是,有许多代码要编写。如果在数据库中有 20 个表,就需要 20 个类。
有三个解决方案可以采用。第一个解决方案就是,坐在键盘前,老老实实地录入一段时间。对于小项目来说,这还可以,但是我很懒。第二个解决方案是用代码生成器,读取数据库模式,并自动编写代码。这是个好主意,而且是另一篇文章的主题。第三个解决方案,也是我在本文中介绍的,是编写一个类,在运行时动态地把自己塑造成指定表的字段。这个类执行起来比起特定于表的类可能有点慢 —— 但是把我从编写大量代码中解脱出来。这个解决方案在项目开始的时候特别有用,因为这时表和字段不断地变化,所以跟上迅速的变化是至关重要的。
所以
在这里,把类的名称从 Book 改成 DBObject。然后,把构造函数修改成接受表的名称和表中字段的名称。之后,大多数变化发生在类的方法中,过去使用一些硬编码结构化查询语言(SQL),现在则必须用表和字段的名称动态地创建 SQL 字符串。
代码的惟一假设就是只有一个主键字段,而且这个字段的名称是表名加上 _id。所以,在 book 表这个示例中,有一个主键字段叫做 book_id。主键的命名标准可能不同;如果这样,需要修改代码以符合标准。
这个类比最初的 Book 类复杂得多。但是,从类的客户的角度来看,这个类用起来仍很简单。也就是说,我认为这个类能更简单。具体来说,我不愿意每次创建图书的时候都要指定表和字段的名称。如果我四处拷贝和粘贴这个代码,然后修改了 book 表的字段结构,那么我可能就麻烦了。在清单 6 中,通过创建一个继承自 DBObject 的简单 Book 类,我解决了这个问题。
清单 6. 新的 Book 类
,如何才能编写一个能够弯曲 的类呢?
回页首
写一个柔性的类
对象有两个方面:成员变量 和方法。在编译语言(例如 Java)中,如果想调用不存在的方法或引用不存在的成员变量,会得到编译时错误。但是,在非编译语言,例如 PHP 中,会发生什么?
在 PHP 中的方法调用是这样工作的。首先,PHP 解释器在类上查找方法。如果方法存在,PHP 就调用它。如果没有,那么就调用类上的魔法方法 __call(如果这个方法存在的话)。如果 __call 失败,就调用父类方法,依此类推。
魔法方法
魔法方法是有特定名称的方法,PHP 解释器在脚本执行的特定点上会查找魔法方法。最常见的魔法方法就是对象创始时调用的构造函数。
__call 方法有两个参数:被请求的方法的名称和方法参数。如果创建的 __call 方法接受这两个参数,执行某项功能,然后返回 TRUE,那么调用这个对象的代码就永远不会知道在有代码的方法和 __call 机制处理的方法之间的区别。通过这种方式,可以创建这样的对象,即动态地模拟拥有无数方法的情况。
除了 __call 方法,其他魔法方法 —— 包括 __get 和 __set —— 调用它们的时候,都是因为引用了不存在的实例变量。脑子里有了这个概念之后,就可以开始编写能够适应任何表的动态数据库访问类了。
回页首
经典的数据库访问
先从一个简单的数据库模式开始。清单 1 所示的模式针对的是单一的数据表数据库,容纳图书列表。
清单 1. MySQL 数据库模式
DROP TABLE IF EXISTS book;
CREATE TABLE book (
book_id INT NOT NULL AUTO_INCREMENT,
title TEXT,
publisher TEXT,
author TEXT,
PRIMARY KEY( book_id )
);
请把这个模式装入到名为 bookdb 的数据库。
接下来,编写一个常规的数据库类,然后再把它修改成动态的。清单 2 显示了图书表的简单的数据库访问类。
清单 2. 基本的数据库访问客户机
<?php require_once("DB.php"); $dsn = 'mysql://root:password@localhost/bookdb'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } class Book { private $book_id; private $title; private $author; private $publisher; function __construct() { } function set_title( $title ) { $this->title = $title; } function get_title( ) { return $this->title; } function set_author( $author ) { $this->author = $author; } function get_author( ) { return $this->author; } function set_publisher( $publisher ) { $this->publisher = $publisher; } function get_publisher( ) { return $this->publisher; } function load( $id ) { global $db; $res = $db->query( "SELECT * FROM book WHERE book_id=?", array( $id ) ); $res->fetchInto( $row, DB_FETCHMODE_ASSOC ); $this->book_id = $id; $this->title = $row['title']; $this->author = $row['author']; $this->publisher = $row['publisher']; } function insert() { global $db; $sth = $db->prepare( 'INSERT INTO book ( book_id, title, author, publisher ) VALUES ( 0, ?, ?, ? )' ); $db->execute( $sth, array( $this->title, $this->author, $this->publisher ) ); $res = $db->query( "SELECT last_insert_id()" ); $res->fetchInto( $row ); return $row[0]; } function update() { global $db; $sth = $db->prepare( 'UPDATE book SET title=?, author=?, publisher=? WHERE book_id=?' ); $db->execute( $sth, array( $this->title, $this->author, $this->publisher, $this->book_id ) ); } function delete() { global $db; $sth = $db->prepare( 'DELETE FROM book WHERE book_id=?' ); $db->execute( $sth, array( $this->book_id ) ); } function delete_all() { global $db; $sth = $db->prepare( 'DELETE FROM book' ); $db->execute( $sth ); } } $book = new Book(); $book->delete_all(); $book->set_title( "PHP Hacks" ); $book->set_author( "Jack Herrington" ); $book->set_publisher( "O'Reilly" ); $id = $book->insert(); echo ( "New book id = $id\n" ); $book2 = new Book(); $book2->load( $id ); echo( "Title = ".$book2->get_title()."\n" ); $book2->delete( ); ?>
为了保持代码简单,我把类和测试代码放在一个文件中。文件首先得到数据库句柄,句柄保存在一个全局变量中。然后定义 Book 类,用私有成员变量代表每个字段。还包含了一套用来从数据库装入、插入、更新和删除行的方法。
底部的测试代码先删除数据库中的所有条目。然后,代码插入一本书,输出新记录的 ID。然后,代码把这本书装入另一个对象并输出书名。
清单 3 显示了在命令行上用 PHP 解释器运行代码的效果。
清单 3. 在命令行运行代码
% php db1.php
New book id = 25
Title = PHP Hacks
%
不需要看太多,就已经得到重点了。Book 对象代表图书数据表中的行。通过使用上面的字段和方法,可以创建新行、更新行和删除行。
回页首
初识动态
下一步是让类变得稍微动态一些:动态地为每个字段创建 get_ 和 set_ 方法。清单 4 显示了更新后的代码。
清单 4. 动态 get_ 和 set_ 方法
<?php require_once("DB.php"); $dsn = 'mysql://root:password@localhost/bookdb'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } class Book { private $book_id; private $fields = array(); function __construct() { $this->fields[ 'title' ] = null; $this->fields[ 'author' ] = null; $this->fields[ 'publisher' ] = null; } function __call( $method, $args ) { if ( preg_match( "/set_(.*)/", $method, $found ) ) { if ( array_key_exists( $found[1], $this->fields ) ) { $this->fields[ $found[1] ] = $args[0]; return true; } } else if ( preg_match( "/get_(.*)/", $method, $found ) ) { if ( array_key_exists( $found[1], $this->fields ) ) { return $this->fields[ $found[1] ]; } } return false; } function load( $id ) { global $db; $res = $db->query( "SELECT * FROM book WHERE book_id=?", array( $id ) ); $res->fetchInto( $row, DB_FETCHMODE_ASSOC ); $this->book_id = $id; $this->set_title( $row['title'] ); $this->set_author( $row['author'] ); $this->set_publisher( $row['publisher'] ); } function insert() { global $db; $sth = $db->prepare( 'INSERT INTO book ( book_id, title, author, publisher ) VALUES ( 0, ?, ?, ? )' ); $db->execute( $sth, array( $this->get_title(), $this->get_author(), $this->get_publisher() ) ); $res = $db->query( "SELECT last_insert_id()" ); $res->fetchInto( $row ); return $row[0]; } function update() { global $db; $sth = $db->prepare( 'UPDATE book SET title=?, author=?, publisher=? WHERE book_id=?' ); $db->execute( $sth, array( $this->get_title(), $this->get_author(), $this->get_publisher(), $this->book_id ) ); } function delete() { global $db; $sth = $db->prepare( 'DELETE FROM book WHERE book_id=?' ); $db->execute( $sth, array( $this->book_id ) ); } function delete_all() { global $db; $sth = $db->prepare( 'DELETE FROM book' ); $db->execute( $sth ); } } ..
要做这个变化,需要做两件事。首先,必须把字段从单个实例变量修改成字段和值组合构成的散列表。然后必须添加一个 __call 方法,它只查看方法名称,看方法是 set_ 还是 get_ 方法,然后在散列表中设置适当的字段。
注意,load 方法通过调用 set_title、set_author 和 set_publisher方法 —— 实际上都不存在 —— 来实际使用 __call 方法。
回页首
走向完全动态
删除 get_ 和 set_ 方法只是一个起点。要创建完全动态的数据库对象,必须向类提供表和字段的名称,还不能有硬编码的引用。清单 5 显示了这个变化。
清单 5. 完全动态的数据库对象类
<?php require_once("DB.php"); $dsn = 'mysql://root:password@localhost/bookdb'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } class DBObject { private $id = 0; private $table; private $fields = array(); function __construct( $table, $fields ) { $this->table = $table; foreach( $fields as $key ) $this->fields[ $key ] = null; } function __call( $method, $args ) { if ( preg_match( "/set_(.*)/", $method, $found ) ) { if ( array_key_exists( $found[1], $this->fields ) ) { $this->fields[ $found[1] ] = $args[0]; return true; } } else if ( preg_match( "/get_(.*)/", $method, $found ) ) { if ( array_key_exists( $found[1], $this->fields ) ) { return $this->fields[ $found[1] ]; } } return false; } function load( $id ) { global $db; $res = $db->query( "SELECT * FROM ".$this->table." WHERE ". $this->table."_id=?", array( $id ) ); $res->fetchInto( $row, DB_FETCHMODE_ASSOC ); $this->id = $id; foreach( array_keys( $row ) as $key ) $this->fields[ $key ] = $row[ $key ]; } function insert() { global $db; $fields = $this->table."_id, "; $fields .= join( ", ", array_keys( $this->fields ) ); $inspoints = array( "0" ); foreach( array_keys( $this->fields ) as $field ) $inspoints []= "?"; $inspt = join( ", ", $inspoints ); $sql = "INSERT INTO ".$this->table." ( $fields ) VALUES ( $inspt )"; $values = array(); foreach( array_keys( $this->fields ) as $field ) $values []= $this->fields[ $field ]; $sth = $db->prepare( $sql ); $db->execute( $sth, $values ); $res = $db->query( "SELECT last_insert_id()" ); $res->fetchInto( $row ); $this->id = $row[0]; return $row[0]; } function update() { global $db; $sets = array(); $values = array(); foreach( array_keys( $this->fields ) as $field ) { $sets []= $field.'=?'; $values []= $this->fields[ $field ]; } $set = join( ", ", $sets ); $values []= $this->id; $sql = 'UPDATE '.$this->table.' SET '.$set. ' WHERE '.$this->table.'_id=?'; $sth = $db->prepare( $sql ); $db->execute( $sth, $values ); } function delete() { global $db; $sth = $db->prepare( 'DELETE FROM '.$this->table.' WHERE '. $this->table.'_id=?' ); $db->execute( $sth, array( $this->id ) ); } function delete_all() { global $db; $sth = $db->prepare( 'DELETE FROM '.$this->table ); $db->execute( $sth ); } } $book = new DBObject( 'book', array( 'author', 'title', 'publisher' ) ); $book->delete_all(); $book->set_title( "PHP Hacks" ); $book->set_author( "Jack Herrington" ); $book->set_publisher( "O'Reilly" ); $id = $book->insert(); echo ( "New book id = $id\n" ); $book->set_title( "Podcasting Hacks" ); $book->update(); $book2 = new DBObject( 'book', array( 'author', 'title', 'publisher' ) ); $book2->load( $id ); echo( "Title = ".$book2->get_title()."\n" ); $book2->delete( ); ? >
在这里,把类的名称从 Book 改成 DBObject。然后,把构造函数修改成接受表的名称和表中字段的名称。之后,大多数变化发生在类的方法中,过去使用一些硬编码结构化查询语言(SQL),现在则必须用表和字段的名称动态地创建 SQL 字符串。
代码的惟一假设就是只有一个主键字段,而且这个字段的名称是表名加上 _id。所以,在 book 表这个示例中,有一个主键字段叫做 book_id。主键的命名标准可能不同;如果这样,需要修改代码以符合标准。
这个类比最初的 Book 类复杂得多。但是,从类的客户的角度来看,这个类用起来仍很简单。也就是说,我认为这个类能更简单。具体来说,我不愿意每次创建图书的时候都要指定表和字段的名称。如果我四处拷贝和粘贴这个代码,然后修改了 book 表的字段结构,那么我可能就麻烦了。在清单 6 中,通过创建一个继承自 DBObject 的简单 Book 类,我解决了这个问题。
清单 6. 新的 Book 类
.. class Book extends DBObject { function __construct() { parent::__construct( 'book', array( 'author', 'title', 'publisher' ) ); } } $book = new Book( ); $book->delete_all(); $book->{'title'} = "PHP Hacks"; $book->{'author'} = "Jack Herrington"; $book->{'publisher'} = "O'Reilly"; $id = $book->insert(); echo ( "New book id = $id\n" ); $book->{'title'} = "Podcasting Hacks"; $book->update(); $book2 = new Book( ); $book2->load( $id ); echo( "Title = ".$book2->{'title'}."\n" ); $book2->delete( ); ?>
现在,Book 类真的是简单了。而且 Book 类的客户也不再需要知道表或字段的名称了。
回页首
改进的空间
对这个动态类我想做的最后一个改进,是用成员变量访问字段,而不是用笨重的 get_ 和 set_ 操作符。清单 7 显示了如何用 __get 和 __set 魔法方法代替 __call。
清单 7. 使用 __get 和 __set 方法
<?php require_once("DB.php"); $dsn = 'mysql://root:password@localhost/bookdb'; $db =& DB::Connect( $dsn, array() ); if (PEAR::isError($db)) { die($db->getMessage()); } class DBObject { private $id = 0; private $table; private $fields = array(); function __construct( $table, $fields ) { $this->table = $table; foreach( $fields as $key ) $this->fields[ $key ] = null; } function __get( $key ) { return $this->fields[ $key ]; } function __set( $key, $value ) { if ( array_key_exists( $key, $this->fields ) ) { $this->fields[ $key ] = $value; return true; } return false; } function load( $id ) { global $db; $res = $db->query( "SELECT * FROM ".$this->table." WHERE ". $this->table."_id=?", array( $id ) ); $res->fetchInto( $row, DB_FETCHMODE_ASSOC ); $this->id = $id; foreach( array_keys( $row ) as $key ) $this->fields[ $key ] = $row[ $key ]; } function insert() { global $db; $fields = $this->table."_id, "; $fields .= join( ", ", array_keys( $this->fields ) ); $inspoints = array( "0" ); foreach( array_keys( $this->fields ) as $field ) $inspoints []= "?"; $inspt = join( ", ", $inspoints ); $sql = "INSERT INTO ".$this->table. " ( $fields ) VALUES ( $inspt )"; $values = array(); foreach( array_keys( $this->fields ) as $field ) $values []= $this->fields[ $field ]; $sth = $db->prepare( $sql ); $db->execute( $sth, $values ); $res = $db->query( "SELECT last_insert_id()" ); $res->fetchInto( $row ); $this->id = $row[0]; return $row[0]; } function update() { global $db; $sets = array(); $values = array(); foreach( array_keys( $this->fields ) as $field ) { $sets []= $field.'=?'; $values []= $this->fields[ $field ]; } $set = join( ", ", $sets ); $values []= $this->id; $sql = 'UPDATE '.$this->table.' SET '.$set. ' WHERE '.$this->table.'_id=?'; $sth = $db->prepare( $sql ); $db->execute( $sth, $values ); } function delete() { global $db; $sth = $db->prepare( 'DELETE FROM '.$this->table.' WHERE '. $this->table.'_id=?' ); $db->execute( $sth, array( $this->id ) ); } function delete_all() { global $db; $sth = $db->prepare( 'DELETE FROM '.$this->table ); $db->execute( $sth ); } } class Book extends DBObject { function __construct() { parent::__construct( 'book', array( 'author', 'title', 'publisher' ) ); } } $book = new Book( ); $book->delete_all(); $book->{'title'} = "PHP Hacks"; $book->{'author'} = "Jack Herrington"; $book->{'publisher'} = "O'Reilly"; $id = $book->insert(); echo ( "New book id = $id\n" ); $book->{'title'} = "Podcasting Hacks"; $book->update(); $book2 = new Book( ); $book2->load( $id ); echo( "Title = ".$book2->{'title'}."\n" ); $book2->delete( ); ?>
底部的测试代码只演示了这个语法干净了多少。要得到图书的书名,只需得到 title 成员变量。这个变量会调用对象的 __get 方法,在散列表中查找 title 条目并返回。
现在就得到了单个动态的数据库访问类,它能够让自己适应到数据库中的任何表。
回页首
动态类的更多用途
编写动态类不仅限于数据库访问。请看清单 8 中的 Customer 对象这个例子。
清单 8. 简单的 Customer 对象
<?php class Customer { private $name; function set_name( $value ) { $this->name = $value; } function get_name() { return $this->name; } } $c1 = new Customer(); $c1->set_name( "Jack" ); $name = $c1->get_name(); echo( "name = $name\n" ); ?>
这个对象足够简单。但是如果我想在每次检索或设置客户名称时都记录日志,会发生什么呢?我可以把这个对象包装在一个动态日志对象内,这个对象看起来像 Customer 对象,但是会把 get 或 set 操作的通知发送给日志。清单 9 显示了这类包装器对象。
清单 9. 动态包装器对象
<?php class Customer { private $name; function set_name( $value ) { $this->name = $value; } function get_name() { return $this->name; } } class Logged { private $obj; function __call( $method, $args ) { echo( "$method( ".join( ",", $args )." )\n" ); return call_user_func_array(array(&$this->obj, $method), $args ); } function __construct( $obj ) { $this->obj = $obj; } } $c1 = new Logged( new Customer() ); $c1->set_name( "Jack" ); $name = $c1->get_name(); echo( "name = $name\n" ); ?>
调用日志版本的 Customer 的代码看起来与前面相同,但是这时,对 Customer 对象的任何访问都被记入日志。清单 10 显示了运行这个日志版代码时输出的日志。
清单 10. 运行日志版对象
% php log2.php
set_name( Jack )
get_name( )
name = Jack
%
在这里,日志输出表明用参数 Jack 调用了set_name 方法。然后,调用 get_name 方法。最后,测试代码输出 get_name 调用的结果。
回页首
结束语
如果这个动态对象素材对您来说理解起来有点难,我不会责备您。因为我自己也花了不少时间研究它并使用代码才理解它并看出它的好处。
动态对象有许多功能,但是也有相当的风险。首先,在刚开始编写魔法方法时,类的复杂性显著增加。这些类更难理解、调试和维护。另外,因为集成开发环境(IDE)变得越来越智能,所以在处理动态类时它们也会遇到这类问题,因为当它们在类上查找方法时会找不到方法。
现在,并不是说应当避免编写这类代码。相反。我非常喜欢 PHP 的设计者这么有想法,把这些魔法方法包含在语言中,这样我们才能编写这类代码。但是重要的是,既要理解优点,也要理解不足。
当然,对于应用程序(例如数据库访问)来说,在这里介绍的技术 —— 与广泛流行的 Ruby on Rails 系统上使用的技术类似 —— 能够极大地减少用 PHP 实现数据库应用程序所需要的时间。节约时间总不是坏事。