PHP 编程高级教程(三)
七、数据库集成 I
在这一章中,我们将主要讨论 NoSQL 数据库。NoSQL 数据库中最受欢迎的是 MongoDB、CouchDB、Google Big Table 和 Cassandra,但还有其他一些。顾名思义,NoSQL 数据库不是经典的 SQL 数据库,并且不实现 ACID 属性。ACID 代表原子性、一致性、隔离性和持久性,这些都是 RDBMS(关系数据库管理系统)事务的传统特征。
NoSQL 数据库没有事务管理层、提交或回滚事务的能力。它们也是无模式的,这意味着它们不符合传统的模式-表-列模式。它们拥有集合,而不是表格,集合不同于表格,因为它们可以保存各种行或文档,正如 NoSQL 数据库所称的那样。行和文档的区别在于,行有固定的结构,由关系模式定义,而文档没有。此外,NoSQL 数据库不存储传统意义上的行;他们储存文件。在 JSON (JavaScript 对象符号)符号中,文档被描述为对象。
下面是一个 JSON 文档的例子:
var= { "key1":"value1", "key2": { "key3":"value3" }, "key4":["a1","a2","a3"...], … }
这种格式是为缩短冗长的 XML 描述而开发的许多格式之一。NoSQL 数据库大多使用 JavaScript 作为内部数据库语言,并结合 JSON 对象符号用于文档操作。创建 NoSQL 数据库有两个目的:
- 原始性能和可扩展性
- 低管理费用
通常,在单个集合中进行搜索的速度非常快,但是没有连接。换句话说,连接被委托给应用。这一速度是通过使用谷歌专利的 map-reduce 算法实现的,该算法使 NoSQL 数据库在松散耦合的集群系统上高度可伸缩和可用。谷歌的算法使这些数据库能够有效地在几台除了网络连接之外不共享任何东西的机器之间分配工作。
这些数据库非常新。它们在 2009 年开始使用,并且没有标准来管理它们用来访问数据库信息的方言。通常,它们有以下命令,实现为对其 API(应用编程接口)的调用:insert、find、findOne、update 和 delete。每个调用的确切语法和可用选项因数据库而异。此外,Cake 或 Symfony 等应用生成器没有经过绝大多数数据库的良好测试,这使得应用开发变得更加困难。
让我们暂时回到酸的要求。它们如下:
- 每个事务作为一个整体成功或失败。如果事务失败,数据库的状态必须如同事务从未发生过一样(原子性)。
- 每个事务必须只能看到在事务开始之前提交的数据(一致性)。
- 在提交更改之前,用户看不到彼此的更改(隔离)。
- 一旦提交,更改将是永久的。特别是,即使数据库系统崩溃,更改也不能丢失(持久性)。
所有主要的关系数据库都遵循 ACID 要求,并模仿银行业务。关系数据库管理系统(RDBMS)世界中的数据库事务是模仿现实世界中的金融事务而建模的。以上都适用于用支票付账。如果有足够的资金,交易将更新付款人和收款人的银行账户;没有足够的资金,两个帐户都不会更新。每笔交易只能看到开始时银行账户的状态。其他用户的交易对彼此没有影响,一旦付款,就应该有永久记录。不遵守 ACID 规则会使 NoSQL 数据库不适合金融交易或任何其他具有类似需求的业务流程。此外,NoSQL 数据库的无模式特性使得它们很难与 Hibernate 这样的对象关系映射器一起使用,这会降低应用的开发速度。NoSQL 数据库最适合大型数据仓库类型的数据库,因为它们的速度和可伸缩性而大放异彩。当然,正如我之前所说的,这些数据库非常新,所以人们应该期待在调试方面的冒险。
MongoDB 简介
MongoDB 是 NoSQL 数据库中最受欢迎的,因为它易于安装,速度快,支持的特性多。为 MongoDB 安装 PHP 接口非常容易,尤其是在 Unix 或 Linux 上。一个只是执行pecl install mongo。结果如下所示:
pecl install mongo downloading mongo-1.1.3.tgz ... Starting to download mongo-1.1.3.tgz (68,561 bytes) ................done: 68,561 bytes 18 source files, building running: phpize Configuring for: PHP Api Version: 20041225 Zend Module Api No: 20060613 Zend Extension Api No: 220060519 building in /var/tmp/pear-build-root/mongo-1.1.3 …............................. (a lot of compilation messages) Build process completed successfully Installing '/usr/lib/php5/20060613+lfs/mongo.so' install ok: channel://pecl.php.net/mongo-1.1.3 configuration option "php_ini" is not set to php.ini location You should add "extension=mongo.so" to php.ini
安装完成。对于 MS Windows,这甚至更容易,因为已经链接的副本可以从[www.mongodb.org](http://www.mongodb.org)下载。所有需要做的就是把它放到正确的位置并更新 php.ini 文件。
一旦这样做了,我们就有一大堆的类供我们使用。MongoDB 不遵循 SQL 标准,所以它的数据类型有点不同。每个 MongoDB 数据类型都被定义为一个 PHP 类。MongoDB 类的参考信息可以在 PHP 网站上的[us3.php.net/manual/en/book.mongo.php](http://us3.php.net/manual/en/book.mongo.php)找到。除了数据类型,还有描述连接、集合、游标和异常的类。集合大致类似于 RDBMS 世界中的表。NoSQL 集合是文档的命名集合,不一定具有相同的结构。如果需要,可以对集合进行索引或分区(“分片”)。集合包含在名为“数据库”的物理对象中,这些对象被实现为数据库文件的集合。如果数据库或集合在插入时不存在,则会自动创建它们。这是一个完全空的 MongoDB 安装在 MongoDB 命令行 shell mongo中的样子:
`mongo MongoDB shell version: 1.6.5 connecting to: test
show dbs admin local `
show dbs命令将向我们显示可用的数据库。
这本书是关于 PHP 语言的,而不是关于 MongoDB 的,所以我不会详细介绍如何使用 MongoDB 的命令行界面。网上有很多 MongoDB 教程。最好最全的大概就是 MongoDB 网站本身的那个了。
现在,让我们看看第一个 PHP 脚本,它将创建一个名为“scott”的数据库和一个名为“emp”的集合。然后,该集合将由 14 行填充。该合集描述了一家小公司的员工。见清单 7-1 。
清单 7-1。 PHP 脚本将创建一个名为“scott”的数据库和一个名为“emp”的集合
`<?php dbname = 'scott'; $colname = "emp";
EMP = array( array("empno" => 7369, "ename" => "SMITH", "job" => "CLERK", "mgr" => 7902,"hiredate" => "17-DEC-80", "sal" => 800, "deptno" => 20),` ` array("empno" => 7499, "ename" => "ALLEN", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "20-FEB-81", "sal" => 1600, "comm" => 300,"deptno"=>30), array("empno"=>7521,"ename"=>"WARD","job"=>"SALESMAN","mgr"=>7698, "hiredate"=>"22-FEB-81","sal"=>1250,"comm"=>500, "deptno" => 30), array("empno" => 7566, "ename" => "JONES", "job" => "MANAGER", "mgr" => 7839, "hiredate" => "02-APR-81", "sal" => 2975, "deptno" => 20), array("empno" => 7654, "ename" => "MARTIN", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "28-SEP-81", "sal" => 1250, "comm" => 1400,"deptno"=>30), array("empno"=>7698,"ename"=>"BLAKE","job"=>"MANAGER","mgr"=>7839, "hiredate"=>"01-MAY-81","sal"=>2850,"deptno"=>30), array("empno"=>7782,"ename"=>"CLARK","job"=>"MANAGER","mgr"=>7839, "hiredate"=>"09-JUN-81","sal"=>2450,"deptno"=>10), array("empno"=>7788,"ename"=>"SCOTT","job"=>"ANALYST","mgr"=>7566, "hiredate"=>"19-APR-87","sal"=>3000,"deptno"=>20), array("empno"=>7839,"ename"=>"KING","job"=>"PRESIDENT", "hiredate" => "17-NOV-81", "sal" => 5000, "deptno" => 10), array("empno" => 7844, "ename" => "TURNER", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "08-SEP-81", "sal" => 1500, "comm" => 0,"deptno"=>30), array("empno"=>7876,"ename"=>"ADAMS","job"=>"CLERK","mgr"=>7788, "hiredate"=>"23-MAY-87","sal"=>1100,"deptno"=>20), array("empno"=>7900,"ename"=>"JAMES","job"=>"CLERK","mgr"=>7698, "hiredate"=>"03-DEC-81","sal"=>950,"deptno"=>30), array("empno"=>7902,"ename"=>"FORD","job"=>"ANALYST","mgr"=>7566, "hiredate"=>"03-DEC-81","sal"=>3000,"deptno"=>20), array("empno"=>7934,"ename"=>"MILLER","job"=>"CLERK","mgr"=>7782, "hiredate"=>"23-JAN-82","sal"=>1300,"deptno"=>10)); try { conn=new Mongo(db=dbname); conn->selectCollection(colname); foreach (emp) { emp, array('safe'=>true)); } } catch(MongoException e) { print "Exception:\n"; die(e->getMessage()."\n"); } ?>`
代码的结构非常简单。该代码定义了要连接的主机名和端口(localhost:27017)、数据库名(“scott”)和集合名(“emp”)。
注意没有用户名和密码,尽管可以定义它们。最初,该安装对任何想要访问它的人都是完全开放的。但是,可以保护它并要求用户和密码验证。
数组$EMP定义了这家小公司的所有雇员。该数组将嵌套数组作为元素,因为 MongoDB 文档由 PHP 关联数组表示。请注意,数组属性不是同质的;有些元素有comm属性,有些没有。另外,雇员“国王”没有mgr属性。不需要空值、空属性或其他占位符。MongoDB 集合可以存储异构元素。当第一次插入完成时,将创建数据库和集合。了解具体发生了什么的最佳地方是 MongoDB 日志文件。其位置取决于安装。在 Linux 上,它通常驻留在主 MongoDB 目录的“log”子目录中。下面是上面的脚本运行时 MongoDB 日志文件中显示的内容:
Thu Jan 6 16:15:35 [initandlisten] connection accepted from 127.0.0.1:29427 #3 Thu Jan 6 16:15:35 allocating new datafile /data/db/scott.ns, filling with zeroes... Thu Jan 6 16:15:35 done allocating datafile /data/db/scott.ns, size: 16MB, took 0 secs Thu Jan 6 16:15:35 allocating new datafile /data/db/scott.0, filling with zeroes... Thu Jan 6 16:15:35 done allocating datafile /data/db/scott.0, size: 64MB, took 0 secs Thu Jan 6 16:15:35 allocating new datafile /data/db/scott.1, filling with zeroes... Thu Jan 6 16:15:35 done allocating datafile /data/db/scott.1, size: 128MB, took 0 secs Thu Jan 6 16:15:35 [conn3] building new index on { _id: 1 } for scott.emp Thu Jan 6 16:15:35 [conn3] done for 0 records 0.001secs Thu Jan 6 16:15:35 [conn3] end connection 127.0.0.1:29427
从输出中可以看到,我们的 MongoDB 安装现在有了一个新的数据库。做那件事不需要特权。MongoDB shell 现在显示了一个不同的画面:
`> show dbs admin local scott
use scott switched to db scott show collections emp system.indexes `
“scott”数据库现在出现在输出中,show collections命令显示名为emp的集合。让我们看看 shell 还能做些什么:
`> db.emp.ensureIndex({empno:1},{unique:true});
db.emp.ensureIndex({ename:1}); db.emp.count(); 14`
这三个命令将在empno属性上创建一个惟一索引,这将防止两行具有相同的empno属性值,在ename属性上创建一个非惟一索引,并对我们的emp集合中的文档进行计数。我们在emp集合中有 14 个文档,而不是 14 行。请记住,在 NoSQL 数据库的情况下,我们讨论的是文档,而不是行。
`> db.emp.find({ename:"KING"});
{ "_id" : ObjectId("4d2630f7da50c38237000008"), "empno" : 7839, "ename" : "KING", "job" :
"PRESIDENT", "hiredate" : "17-NOV-81", "sal" : 5000, "deptno" : 10 }
`
这里我们实际上已经寻找了属性等于“KING”的ename文档,MongoDB 已经向我们返回了具有所需属性的文档。注意结果中的_id属性,它不存在于原始的$EMP数组中。这就是 object id,由 MongoDB 分配给数据库中的每个文档,并保证在整个安装中是惟一的,而不仅仅是在单个数据库中。它可用于搜索特定文档:
> db.emp.find({"_id":ObjectId("4d2630f7da50c3823700000d")}); { "_id" : ObjectId("4d2630f7da50c3823700000d"), "empno" : 7934, "ename" : "MILLER", "job" : "CLERK", "mgr" : 7782, "hiredate" : "23-JAN-82", "sal" : 1300, "deptno" : 10 }
最后,让我们看看集合中的所有文档:
> db.emp.find(); { "_id" : ObjectId("4d2630f7da50c38237000000"), "empno" : 7369, "ename" : "SMITH", "job" : "CLERK", "mgr" : 7902, "hiredate" : "17-DEC-80", "sal" : 800, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000001"), "empno" : 7499, "ename" : "ALLEN", "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "20-FEB-81", "sal" : 1600, "comm" : 300, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000002"), "empno" : 7521, "ename" : "WARD", "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "22-FEB-81", "sal" : 1250, "comm" : 500, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000003"), "empno" : 7566, "ename" : "JONES", "job" : "MANAGER", "mgr" : 7839, "hiredate" : "02-APR-81", "sal" : 2975, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000004"), "empno" : 7654, "ename" : "MARTIN", "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "28-SEP-81", "sal" : 1250, "comm" : 1400,v "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000005"), "empno" : 7698, "ename" : "BLAKE", "job" : "MANAGER", "mgr" : 7839, "hiredate" : "01-MAY-81", "sal" : 2850, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000006"), "empno" : 7782, "ename" : "CLARK", "job" : "MANAGER", "mgr" : 7839, "hiredate" : "09-JUN-81", "sal" : 2450, "deptno" : 10 } { "_id" : ObjectId("4d2630f7da50c38237000007"), "empno" : 7788, "ename" : "SCOTT", "job" : "ANALYST", "mgr" : 7566, "hiredate" : "19-APR-87", "sal" : 3000, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000008"), "empno" : 7839, "ename" : "KING", "job" : "PRESIDENT", "hiredate" : "17-NOV-81", "sal" : 5000, "deptno" : 10 } { "_id" : ObjectId("4d2630f7da50c38237000009"), "empno" : 7844, "ename" : "TURNER", "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "08-SEP-81", "sal" : 1500, "comm" : 0, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c3823700000a"), "empno" : 7876, "ename" : "ADAMS", "job" : "CLERK", "mgr" : 7788, "hiredate" : "23-MAY-87", "sal" : 1100, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c3823700000b"), "empno" : 7900, "ename" : "JAMES", "job" : "CLERK", "mgr" : 7698, "hiredate" : "03-DEC-81", "sal" : 950, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c3823700000c"), "empno" : 7902, "ename" : "FORD", "job" : "ANALYST", "mgr" : 7566, "hiredate" : "03-DEC-81", "sal" : 3000, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c3823700000d"), "empno" : 7934, "ename" : "MILLER", "job" : "CLERK", "mgr" : 7782, "hiredate" : "23-JAN-82", "sal" : 1300, "deptno" : 10 }
我们的收藏现在有了一个独特的索引。如果我们试图重新执行清单 7-1 中的脚本,结果将如下所示:
Exception: E11000 duplicate key error index: scott.emp.$empno_1 dup key: { : 7369 }
如果插入调用没有一个safe参数,就不会抛出异常。当将数据加载到已经存在的具有惟一索引的集合中时,这是一件相当实用的事情。此外,使用safe意味着每次插入都将等待,直到所有之前的插入都被物理地写入数据库。换句话说,我们的小脚本将导致每个文档至少一个 I/O,这对于大数据负载来说可能是一个不可接受的性能损失。MongoDB 最常用于数据负载非常大的数据仓库——多达数千万个文档。在这种情况下,使用safe写作可能不是一个好主意。通常的做法是使用safe插入最后一个文档,这将大大提高性能。safe参数也可以用来指定在插入被认为完成之前必须拥有信息的从机数量,但是复制和集群安装的复杂性超出了本书的范围。
查询 MongoDB
现在,让我们做一些查询。清单 7-2 是第一个也是最基本的例子。如前所述,MongoDB 不是一个 SQL 数据库,所以对于那些以前从未使用过 NoSQL 数据库的人来说,它的语法看起来很陌生。
***清单 7-2。*查询 MongoDB 的基本示例
<?php $host = 'localhost:27017'; $dbname = 'scott'; $colname = "emp"; try { $conn=new Mongo($host); $db=$conn->selectDB($dbname); $coll=$conn->selectCollection($dbname,$colname); $cursor = $coll->find(array("deptno"=>20)); $cursor->sort(array("sal"=>1)); foreach($cursor as $c) { foreach($c as $key => $val) { if ($key != "_id") { print "$val\t"; } } print "\n"; } } catch(MongoException $e) { print "Exception:\n"; die($e->getMessage()."\n"); } ?>
这个脚本引入了由find方法返回的光标对象。Cursor 只是一个迭代对象(实现接口“Iterator”),表示查询的结果,可以在foreach循环中以类似数组的方式使用。这个准数组的elements是查询返回的文档。每个文档都是一个关联数组,PHP 用它来表示 MongoDB 文档。执行该脚本时,输出将如下所示:
7369 SMITH CLERK 7902 17-DEC-80 800 20 7876 ADAMS CLERK 7788 23-MAY-87 1100 20 7566 JONES MANAGER 7839 02-APR-81 2975 20 7788 SCOTT ANALYST 7566 19-APR-87 3000 20 7902 FORD ANALYST 7566 03-DEC-81 3000 20
只有来自deptno=20的雇员被返回,因为这是我们查询的条件。然后文档按薪水排序(属性为sal)。直到foreach循环,查询才真正执行。要检索所有文档,只需使用不带参数的find()方法。
这是一个非常简单的查询,要求所有属性等于 20 的文档。MongoDB 能做的远不止这些。MongoDB 查询可以跳过指定数量的文档,并限制查询返回的文档数量。对于那些使用过开源数据库的人来说,这完全类似于 MySQL 或 PostgreSQL limit和offset查询选项。此类查询语法的一个示例如下所示:
$cursor = $coll->find()->skip(3)->limit(5);
如果将它放入清单 7-2 的脚本中,而不是放入指定deptno=20标准的那一行,结果将如下所示:
7521 WARD SALESMAN 7698 22-FEB-81 1250 500 30 7654 MARTIN SALESMAN 7698 28-SEP-81 1250 1400 30 7934 MILLER CLERK 7782 23-JAN-82 1300 10 7844 TURNER SALESMAN 7698 08-SEP-81 1500 0 30 7499 ALLEN SALESMAN 7698 20-FEB-81 1600 300 30
前三个文档被跳过,只返回了五个文档。到目前为止,我们只看到一个简单的等式条件。下一个查询将返回所有属性大于 2900 的文档:
$cursor = $coll->find(array("sal"=> array('$gt'=>2900)));
注意嵌套数组中的$gt。MongoDB 有操作符$lt、$gt、$lte、$gte和$ne,分别代表“小于”、“大于”、“小于或等于”、“大于或等于”和“不等于”。这些操作符的语法很简单:用一个带参数的关联数组代替普通值,就像上面的代码行一样。还可以使用 count()函数对光标中的文档进行计数,如下所示:
printf("%d documents were extracted.\n",$cursor->count());
请注意skip和limit选项不会改变计数。换句话说,在显示$cursor = $coll->find()->skip(3)->limit(5),的行中,光标计数仍然是 14。MongoDB 还知道如何进行in查询。以下查询将返回“deptno”等于 10 或 20 的所有文档:
$cursor = $coll->find(array("deptno"=> array('$in'=>array(10,20))));
当然,同样的语法也适用于$nin(“not in”)操作符。也可以进行exists查询。下面一行将只返回具有comm(如“佣金”)属性的文档:
$cursor = $coll->find(array("comm"=> array('$exists'=>true)));
下面正好相反的一行将只返回没有comm属性的文档:
$cursor = $coll->find(array("comm"=> array('$exists'=>false)));
MongoDB 也可以使用正则表达式进行查询。清单 7-3 将只返回 12 月份雇佣的员工的文档。
清单 7-3。 MongoDB 可以使用正则表达式进行查询
`<?php dbname = 'scott'; colname = "emp"; try { conn=new Mongo(db=dbname); conn->selectCollection(colname); coll->find(array("hiredate"=> new MongoRegex("/\d{2}-dec-\d{2}/i"))); cursor->sort(array("sal"=>1)); foreach(c) { foreach(key => val) { if (key != "_id") { print "val\t"; } } print "\n"; } printf("%d documents were extracted.\n",cursor->count());
} catch(MongoException e) { print "Exception:\n"; die(e->getMessage()."\n"); } ?>`
正则表达式/\d{2}-dec-\d{2}/i与 PHP preg的各种正则表达式具有相同的语法。这个特殊的正则表达式是这样翻译的:两个数字代表一个月中的第几天(\d{2},后面是字符串-dec-,再后面是另外两个数字,代表年份。正则表达式末尾的/i表示表达式不区分大小写。特别是dec和DEC都会匹配。该脚本执行将产生以下结果:
7369 SMITH CLERK 7902 17-DEC-80 800 20 7900 JAMES CLERK 7698 03-DEC-81 950 30 7902 FORD ANALYST 7566 03-DEC-81 3000 20 3 documents were extracted.
当然,也有可能反其道而行之,匹配所有不符合正则表达式的内容。下面的代码片段就可以做到这一点:
$cursor = $coll->find(array("hiredate"=> array('$not' => new MongoRegex("/\d{2}-dec-\d{2}/i"))));
请注意,我们正在使用 MongoRegex 类型来让 MongoDB 知道这是一个正则表达式。本章开头提到了类型的类。这是其中之一。当我们将注意力转向更新 MongoDB 时,将演示 MongoDate 类。最后,MongoDB 还有$where操作符,它使用 JavaScript 语法:
$cursor = $coll->find(array('$where'=> 'this.deptno >= 10 & this.deptno<=20'));
这个表达式中的关键字this大致类似于 PHP 中的变量$this;它指向当前处于焦点的类的当前实例。JavaScript 和 PHP 都是面向对象的,语法相似。
到目前为止,我们已经集中讨论了如何定位所需的文档。我们还可以定义哪些属性(也称为字段)将在结果集中返回。在清单 7-4 中,我们可以摆脱烦人的对象 id 检查,否则会使我们的输出看起来很难看。在清单 7-4 脚本中,不再需要检查返回的字段是否是对象 id。
***清单 7-4。*定义结果集中返回哪些属性
<?php $host = 'localhost:27017'; $dbname = 'scott'; $colname = "emp"; try { $conn=new Mongo($host); $db=$conn->selectDB($dbname); $coll=$conn->selectCollection($dbname,$colname); $cursor = $coll->find(array('$where'=> 'this.deptno >= 10 & this.deptno<=20')); $cursor->sort(array("deptno"=>1,"sal"=>1)); $cursor->fields(array("ename"=>true, "job"=>true, "deptno"=>true, "hiredate"=>true, "sal"=>true, "_id"=>false)); foreach($cursor as $c) { foreach($c as $key => $val) { print "$val\t"; } print "\n"; } ` printf("%d documents were extracted.\n",$cursor->count());
} catch(MongoException e) { print "Exception:\n"; die(e->getMessage()."\n"); } ?>`
在 MongoDB 的当前版本中,除了对象id,不能混合字段包含和排除。对象id仍然会显示,除非它被明确排除。然而,丑陋的如果($key != "_id")部分不再需要了。以下是该脚本的输出:
MILLER CLERK 23-JAN-82 1300 10 CLARK MANAGER 09-JUN-81 2450 10 KING PRESIDENT 17-NOV-81 5000 10 SMITH CLERK 17-DEC-80 800 20 ADAMS CLERK 23-MAY-87 1100 20 JONES MANAGER 02-APR-81 2975 20 SCOTT ANALYST 19-APR-87 3000 20 FORD ANALYST 03-DEC-81 3000 20 8 documents were extracted.
更新 MongoDB
本章的这一部分将向您展示如何更新 MongoDB。语法简单明了,所以我还将提到数据仓库领域的一些设计问题。我们的小收藏很好地服务了我们,但是它也有一些缺点。首先,hiredate属性存储为一个字符串,这使得按日期排序文档几乎不可能。其次,MongoDB 不能进行连接,所以我们必须将部门信息包含到我们的小集合中。部门编号远不如部门名称和位置清晰易懂。MongoDB 不是关系数据库,所以我们必须将其“反规范化”。在关系世界中,设计看起来像图 7-1 。
图 7-1。 MongoDB 采集信息设计
事实上,任何参加过 Oracle 课程的人都应该很容易认出这两个表。因为在 MongoDB 中不可能有连接,所以最好的办法就是将图 7-1 中两个表的信息放入一个集合中。这就是所谓的反规范化,在构建于各种数据库而不仅仅是 MongoDB 上的数据仓库领域,这是一种非常常见的做法。好消息是,使用 MongoDB,不需要复杂的 alter table 来实现这一点;我们所需要的只是更新文档本身。清单 7-5 显示了完成所有这些更新的脚本。
***清单 7-5。*脚本更新文档
`<?php dbname = 'scott'; colname = "emp"; try { conn=new Mongo(db=dbname); conn->selectCollection(colname); coll->find(); foreach(c) { switch(c["deptno"]) { case 10: c["dname"]="ACCOUNTING"; c["dname"]="RESEARCH"; c["dname"]="SALES"; c["dname"]="OPERATIONS"; c["loc"]="BOSTON"; break; } c["hiredate"]=new MongoDate(strtotime(coll->update(array("_id"=>c); }
} catch(MongoException e) { print "Exception:\n"; die(e->getMessage()."\n"); } ?>`
首先要注意的是,update方法属于collection类,而不属于cursor类。cursor类仅用于遍历集合并为更新准备值。更新本身接受以下参数:定位要更新的文档的标准、将在它们的位置写入的实际文档以及选项数组。update方法也支持safe选项,就像insert方法一样。如果清单 7-2 中的脚本被重新执行,它会向我们显示难以理解的大数字,代替我们曾经漂亮的hiredate属性。MongoDB 将日期存储为自纪元以来的毫秒数。纪元当然是 1970 年 1 月 1 日 00:00:00。如果我们使用 mongo shell 而不是清单 7-2 中的脚本,结果如下所示:
`> db.emp.find({"deptno":10});
{ "_id" : ObjectId("4d2630f7da50c38237000006"), "empno" : 7782, "ename" : "CLARK",
"job" : "MANAGER", "mgr" : 7839, "hiredate" : "Tue Jun 09 1981 00:00:00 GMT-0400 (EDT)",
"sal" : 2450, "deptno" : 10, "dname" : "ACCOUNTING", "loc" : "NEW YORK" }
{ "_id" : ObjectId("4d2630f7da50c38237000008"), "empno" : 7839, "ename" : "KING",
"job" : "PRESIDENT", "hiredate" : "Tue Nov 17 1981 00:00:00 GMT-0500 (EST)", "sal" : 5000,
"deptno" : 10, "dname" : "ACCOUNTING", "loc" : "NEW YORK" }
{ "_id" : ObjectId("4d2630f7da50c3823700000d"), "empno" : 7934, "ename" : "MILLER",
"job" : "CLERK", "mgr" : 7782, "hiredate" : "Sat Jan 23 1982 00:00:00 GMT-0500 (EST)",
"sal" : 1300, "deptno" : 10, "dname" : "ACCOUNTING", "loc" : "NEW YORK" }
`
mongo shell 揭示了hiredate属性具有适当日期的所有特征。我们只需要适当地格式化它,我们的小脚本将是完美的。对 www.php.net 的MongoDate类的描述表明,MongoDate 有两个公共属性:sec表示自纪元以来的秒,usec表示自纪元以来的毫秒。我们现在可以使用内置函数 strftime 来正确格式化结果,如下所示:
foreach($c as $key => $val) { if ($val instanceof MongoDate) { printf("%s\t",strftime("%m/%d/%Y",$val->sec)); } else { print "$val\t"; } }
经过这一修改,清单 7-4 中的脚本现在将产生可读的预期输出:
MILLER CLERK 01/23/1982 1300 10 CLARK MANAGER 06/09/1981 2450 10 KING PRESIDENT 11/17/1981 5000 10 SMITH CLERK 12/17/1980 800 20 ADAMS CLERK 05/23/1987 1100 20 JONES MANAGER 04/02/1981 2975 20 SCOTT ANALYST 04/19/1987 3000 20 FORD ANALYST 12/03/1981 3000 20 8 documents were extracted.
有了像正确的日期/时间类型那样存储的hiredate属性,现在可以按日期对文档进行排序,并获得正确的时间顺序。此外,我们的emp集合现在包含了关于部门的信息,这比仅仅是一个数字有用得多。我们刚刚向构建合适的数据仓库迈出了第一步。
MongoDB 中的聚合
当然,适当的数据仓库用于各种类型的趋势和聚集。我们研究了查询 MongoDB 的各种技术,但是目前还没有类似于 group by、sum 和关系数据库中的其他组函数。我们一直将 MongoDB 与关系数据库进行比较,因为 MongoDB 是这个领域的新手;这是一个数据库,其特定目的是简化数据仓库的创建。早在 MongoDB 之前,关系数据库就被用来操作数据仓库,因此对可用工具进行比较是完全合理的。传统数据仓库必须回答的一个问题是计算每个部门的工资总额。
MongoDB 不是关系数据库,所以传统的select deptno,sum(sal) from emp group by deptno答案不适用。MongoDB 使用 Google map-reduce 框架来实现同样的事情。该框架首先在“工作人员”之间划分任务(这是“映射”阶段),然后处理“工作人员”的输出以产生所请求的信息;这是“减少”阶段。MongoDB 将 JavaScript 函数传递给工作进程,这种方法甚至比固定语法组函数如SUM或COUNT更强大。当然,缺点是 map/reduce 框架的完全使用需要 JavaScript 知识。JavaScript 本身超出了本书的范围,所以只讨论模拟关系数据库中的SUM、COUNT和AVG函数的最基本的例子。此外,MongoDB 还有一个更重要的限制:到目前为止,所有现有的 JavaScript 引擎都是单线程的,这意味着,为了使用并行性,需要配置sharding,这是在无共享集群中跨多个节点将数据库划分为多个数据集的 MongoDB 版本。这个限制可能会在未来的版本中删除。
下一个脚本将检索包含在sal属性或我们的emp集合中的工资总额,以及每个部门的雇员人数和该部门的平均工资。该脚本使用了属于集合类的group方法。清单 7-6 展示了这个脚本。
***清单 7-6。*检索工资总额、每个部门的员工人数以及该部门的平均工资的脚本
`<?php dbname = 'scott'; colname = "emp"; try { conn = new Mongo(db = dbname); conn->selectCollection(colname); initial = array('sum' => 0, 'cnt' => 0); finalize= new MongoCode('function(obj) { obj.avg = obj.sum/obj.cnt; }');
coll->group(initial, finalize)); foreach (grp) { foreach (key => val) { printf("%s => %s\t", key, val); } print "\n"; } } catch(MongoException e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>`
map-reduce 算法是递归的。reduce函数有两个参数:正在处理的当前对象和带有在initial变量中指定的属性的对象的前一个值。MongoDB 将遍历数据集,并递归地计算总和以及计数。完成后,它将对结果执行finalize功能。finalize函数的参数是结果中的对象,包含deptno、count和sum。finalize函数将添加avg成员。该脚本的输出如下所示:
deptno => 20 sum => 10875 cnt => 5 avg => 2175 deptno => 30 sum => 9400 cnt => 6 avg => 1566.6666666667 deptno => 10 sum => 8750 cnt => 3 avg => 2916.6666666667
结果将存储在变量$group_by中,该变量本身是一个关联数组,不仅包含操作的结果,还包含关于组的数量、在计算聚合的过程中遍历的文档数量以及操作的最终状态的信息。结果的结构可以通过print_r这个调试最常用的函数来揭示。print_r函数将变量结构转储到标准输出。在清单 7-6 中的脚本的情况下,结果看起来像这样:
`Array ( [retval] => Array ( [0] => Array ( [deptno] => 20 [sum] => 10875 [cnt] => 5 [avg] => 2175 )
[1] => Array ( [deptno] => 30 [sum] => 9400 [cnt] => 6 [avg] => 1566.6666666667 )
[2] => Array ( [deptno] => 10 [sum] => 8750 [cnt] => 3 [avg] => 2916.6666666667 )
)
[count] => 14 [keys] => 3 [ok] => 1 )`
retval项将包含我们想要的返回值。count项将包含流程中访问过的文档的数量,而keys项将包含在数据集中发现的不同组键的数量。OK是命令的返回状态;如果有问题,这将包含 0。
另外,请注意,我们在脚本中使用了MongoCode类,类似于使用正则表达式查询部分中的 MongoRegex 或更新示例中的MongoDate。JavaScript 本身就是一种强大的面向对象语言,可以用来计算比求和、计数或平均复杂得多的集合。这里还有一个通用 map-reduce 框架:
https://github.com/infynyxx/MongoDB-MapReduce-PHP
然而,对 map-reduce 和 JavaScript 聚合的进一步讨论需要 JavaScript 知识,因此超出了本书的范围。
MongoDB 结论
MongoDB 在数据库领域是一个相对较新的东西,并且是 NoSQL 数据库中最受欢迎的。它是构建数据仓库的一个很好的工具,特别是因为它能够充分利用所谓的“无共享集群架构”它是一个开源数据库,这使它成为构建高性能数据仓库的理想选择。它也有很好的文档记录、很好的支持,并且易于安装、集成到 PHP 和测试。此外,因为它太新了,几乎每天都有更新的版本发布,所以人们必须带着一种冒险的精神来对待 MongoDB 这个项目。
今天,由于许多原因,RDBMS 软件仍然占主导地位。原因之一是标准数据操作语言 SQL 的可用性,而 NoSQL 数据库没有标准化。我们的下一个数据库将是 CouchDB,这是一个 Apache 项目,本质上类似于 MongoDB。
CouchDB 简介
CouchDB 是由 Apache 基金会领导的一个开源项目。它也是一个无模式的 NoSQL 数据库,具有多版本一致性控制(MVCC)。MVCC 是一种机制,它允许数据库中的同一文档有多个版本。安装 CouchDB 很容易;每个主要操作系统都有相应的软件包。Windows 7 上有一个二进制安装程序,还有针对各种 Linux 发行版和 Unix 系统的软件包。一般来说,安装非常简单明了。然而,CouchDB 主要是一个 Linux 数据库。
虽然 MongoDB 和 CouchDB 都是无模式的,但 CouchDB 比 MongoDB 更一贯地无模式。CouchDB 没有任何类似集合的实体。整个数据库是一个无定形的文档集合。为了使数据库的组织更容易,CouchDB 使用用户定义的视图,编写为 JavaScript 函数,利用 Google map-reduce 框架来组织文档。
和 MongoDB 一样,文档是 JSON 对象。MongoDB 驱动负责将 PHP 关联数组与 JSON 对象相互转换;CouchDB 不会这么做。CouchDB 通过使用 HTTP 协议与外界通信,并返回和接受 JSON 对象。为了方便与 CouchDB 的通信,使用 PECL 安装工具安装 PHP JSON 扩展当然是有帮助的。该扩展提供了函数json_encode和json_decode,用于在 PHP 关联数组和 JSON 对象之间进行转换。因为这样的架构,CouchDB 的 PHP 库不需要链接,比如 MongoDB 的 PHP 扩展。CouchDB 最流行的 PHP 库是 PHP-on-Couch,可以从
https://github.com/dready92/PHP-on-Couch
这个库不需要特殊安装。它可以在任何地方下载,并使用include和require命令包含在脚本中。如此简单的原因正是 CouchDB 通过使用标准的 HTTP 协议与外界通信。在 Linux 上,有用于与 HTTP 服务器通信的命令行工具。其中最受欢迎的是curl,它在使用 CouchDB 时非常有用。第一个命令只打印欢迎屏幕并检查 CouchDB 是否处于活动状态,通常如下所示:
curl http://localhost:5984 {"couchdb":"Welcome","version":"1.0.1"}
curl实用程序联系了主机localhost上的 HTTP 服务器,IP 地址为 127.0.0.1,端口为 5984,服务器回复了一个 JSON 对象,与表单一致。让我们用一个小脚本解析 JSON 对象,如下所示:
<? $a='{"couchdb":"Welcome","version":"1.0.1"}'; print_r(json_decode($a,true)); ?>
结果将如下所示:
Array ( [couchdb] => Welcome [version] => 1.0.1 )
换句话说,json_decode函数已经将 CouchDB 返回的 JSON 对象转换成了 PHP 关联数组。
使用蒲团
CouchDB 可以接受 HTTP 命令,当然也可以使用curl -X PUT [localhost:5984/dbname](http://localhost:5984/dbname)命令创建数据库;使用名为 Futon 的 CouchDB 管理界面要舒服得多。可以使用您最喜欢的网络浏览器并将其指向[localhost:5984/_utils](http://localhost:5984/_utils)来访问该界面。如果服务器不在本地主机上,您应该替换为服务器名称和端口。它是可配置的。在 Opera 中,结果看起来像图 7-2 。
图 7-2。 Futon 可以帮助您创建数据库和收藏。
创建数据库本身就很简单。在左上角,有一个创建数据库按钮。点击它,在对话框中输入 scott 作为数据库名称,发送到数据库。瞧啊。创建了名为“scott”的数据库!参见图 7-3 。
***图 7-3。*数据库名为“斯科特”
蒲团还可以帮助我们创建视图。视图是用户定义的 JavaScript 函数,实现了 Google 的 map-reduce 协议。第一次评估视图时,会对数据库中的每个文档进行计算,结果存储在 B 树索引中。这种情况只在第一次创建视图时发生。之后,只有添加或更改的文档通过view功能运行。因此,为了创建视图,让我们首先创建一些文档。是时候让我们的第一个 PHP 脚本访问 CouchDB 了。它将创建与 MongoDB 相同的“emp”结构。参见清单 7-7 。
清单 7-7。 PHP 脚本访问 CouchDB
`<?php require_once("PHP-on-Couch/couch.php"); require_once("PHP-on-Couch/couchClient.php"); require_once("PHP-on-Couch/couchDocument.php"); dbname = 'scott';
EMP = array( array("empno" => 7369, "ename" => "SMITH", "job" => "CLERK", "mgr" => 7902,"hiredate" => "17-DEC-80", "sal" => 800, "deptno" => 20,"_id" => "7369"), array("empno" => 7499, "ename" => "ALLEN", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "20-FEB-81", "sal" => 1600, "comm" => 300,"deptno"=>30,"_id" => "7499"), array("empno"=>7521,"ename"=>"WARD","job"=>"SALESMAN","mgr"=>7698, "hiredate"=>"22-FEB-81","sal"=>1250,"comm"=>500, "deptno" => 30, "_id" => "7521"), array("empno" => 7566, "ename" => "JONES", "job" => "MANAGER", "mgr" => 7839, "hiredate" => "02-APR-81", "sal" => 2975, "deptno" => 20, "_id" => "7566"), array("empno" => 7654, "ename" => "MARTIN", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "28-SEP-81", "sal" => 1250, "comm" => 1400,"deptno"=>30, "_id"=>"7654"), array("empno"=>7698,"ename"=>"BLAKE","job"=>"MANAGER","mgr"=>7839, "hiredate"=>"01-MAY-81","sal"=>2850,"deptno"=>30,"_id" => "7698"), array("empno"=>7782,"ename"=>"CLARK","job"=>"MANAGER","mgr"=>7839, "hiredate"=>"09-JUN-81","sal"=>2450,"deptno"=>10,"_id" => "7782"), array("empno"=>7788,"ename"=>"SCOTT","job"=>"ANALYST","mgr"=>7566, "hiredate"=>"19-APR-87","sal"=>3000,"deptno"=>20,"_id" => "7788"), array("empno"=>7839,"ename"=>"KING","job"=>"PRESIDENT", "hiredate" => "17-NOV-81", "sal" => 5000, "deptno" => 10, "_id" => "7839"),` ` array("empno" => 7844, "ename" => "TURNER", "job" => "SALESMAN", "mgr" => 7698, "hiredate" => "08-SEP-81", "sal" => 1500, "comm" => 0,"deptno"=>30,"_id" => "7844"), array("empno"=>7876,"ename"=>"ADAMS","job"=>"CLERK","mgr"=>7788, "hiredate"=>"23-MAY-87","sal"=>1100,"deptno"=>20,"_id" => "7876"), array("empno"=>7900,"ename"=>"JAMES","job"=>"CLERK","mgr"=>7698, "hiredate"=>"03-DEC-81","sal"=>950,"deptno"=>30,"_id" => "7900"), array("empno"=>7902,"ename"=>"FORD","job"=>"ANALYST","mgr"=>7566, "hiredate"=>"03-DEC-81","sal"=>3000,"deptno"=>20,"_id" => "7902"), array("empno"=>7934,"ename"=>"MILLER","job"=>"CLERK","mgr"=>7782, "hiredate"=>"23-JAN-82","sal"=>1300,"deptno"=>10,"_id" => "7934")); try { db=new couchClient(dbname); foreach(e) { db); e); doc->record(); } } catch(Exception e) { printf("Exception code:%d\n",e->getMessage()); exit(-1); } ?>`
提供连接的类couchClient和couchDocument由包含在include路径的PHP-on-Couch目录中的初始文件提供。目录的名称是任意的,因为没有安装过程。这里的目录被命名为PHP-on-Couch,并放入include_path参数中指定的目录中。include_path参数是 PHP 解释器的参数,通常在php.ini配置文件中指定。除了包含文件和加载数据的实际过程之外,这看起来几乎与关于 MongoDB 的清单 7-1 相同。主要区别在于,empno属性在_id属性中是重复的,它是一个字符串。CouchDB 允许我们为 ID 分配自己的字符串。当然,ID 必须是唯一的,并且必须是字符串,而不是数字。这就是为什么最初的empno列没有简单地重命名为_id。如果我们看一看我们友好的 Futon 界面,我们将看到新摄取的文档。参见图 7-4 。
***图 7-4。*蒲团界面新摄取的文件
CouchDB 通过 HTTP 协议进行通信,这意味着每条记录在浏览器中都是可见的。我们只需点击任何显示的文档就可以看到它。参见图 7-5 。
***图 7-5。*每条记录都可以在浏览器中看到。
标记为_rev的修订字段也值得注意。仅显示最后一次修订,但可以检索任何修订。如前所述,CouchDB 有一个版本控制,并且完全符合 ACID。我还提到了 CouchDB 没有即席查询功能。这意味着,为了检索一个文档,必须通过_id列查询它。清单 7-8 显示了一个检索和更新单个文档的小脚本。
***清单 7-8。*检索和更新单个文档的脚本
<?php require_once("PHP-on-Couch/couch.php"); require_once("PHP-on-Couch/couchClient.php"); require_once("PHP-on-Couch/couchDocument.php"); $host = 'http://localhost:5984'; $dbname = 'scott'; try { $db=new couchClient($host,$dbname); $doc = couchDocument::getInstance($db,'7844'); $doc->sal=1500; $doc->record(); } catch(Exception $e) { printf("Exception code:%d\n",$e->getCode()); printf("%s\n",$e->getMessage()); exit(-1); } ?>
该脚本将检索带有id='7844'的文档,将其sal属性更新为 1500,并将其存储回来。这个类对于查询文档来说并不完美;它使用一个静态的类函数getInstance,在类上下文中调用。这意味着该函数没有作为对象成员被调用;没有调用函数getInstance的对象上下文。文档类也使用__get和__set函数来设置文档的属性。
如果您将文档放回蒲团中检查,您将会看到它的修订已经增加。不幸的是,没有其他键的特别查询。要查询 CouchDB,必须创建一个文档视图。使用 map-reduce JavaScript 函数创建视图。第一次创建视图时,为数据库中的每个文档计算函数,结果存储在 B 树索引中。对于每个添加或修改的文档,索引都会发生变化。视图是使用蒲团创建的。在右上角,数据库的 Futon 视图有 View: selection 字段,它被设置为“All documents”。如果我们将选项滚动到临时视图选项,将出现创建临时视图的表单。视图创建和实现的细节超出了本书的范围。乔·列侬的优秀著作《开始 CouchDB 中对细节做了很好的描述。
出于本书的目的,我在表单中输入了以下 JavaScript 函数来创建名为deptno30的视图,并存储在名为sal的文档中。视图也是文档,存储在名为_design的特殊数据库中。我们的观点是这样的:
function(doc) { if (doc.deptno==30) { emit(doc._id, { empno:doc.empno, ename: doc.ename, job: doc.job, mgr:doc.mgr, sal:doc.sal}); } }
该视图将只提取销售部门(部门编号为 30)的雇员的信息。应该注意,该函数返回(“发出”)两个项目:密钥和一个 JSON 文档。如果键为 NULL,CouchDB 将自动分配一个。
这个函数将在数据库中的每个文档上执行,如果deptno属性等于 30,它将以 JSON 对象的形式向视图发出empno、ename、job、mgr和sal属性。视图将用id="sal"和name="deptno30"保存在文档中。现在我们有了一个可以查询的数据库结构,这个脚本本身很简单,看起来像清单 7-9 。
清单 7-9。
<?php require_once("PHP-on-Couch/couch.php"); require_once("PHP-on-Couch/couchClient.php"); require_once("PHP-on-Couch/couchDocument.php"); $host = 'http://localhost:5984'; $dbname = 'scott'; try { $db=new couchClient($host,$dbname); $deptno30=$db->asArray()->getView('sal','deptno30'); foreach ($deptno30['rows'] as $r) { foreach ($r['value'] as $key => $value) { printf("%s = %s\t",$key,$value); } print "\n"; } } catch(Exception $e) { printf("Exception code:%d\n",$e->getCode()); printf("%s\n",$e->getMessage()); exit(-1); } ?>
这个脚本调用 couchClient 类的getView方法来查询数据库。查询结果以数组的形式返回。可以包括许多其他选项来限制结果的数量、限制返回的关键字、对它们进行排序等等。这些类的文档很少,所以最好的办法是查看类源代码本身。执行该脚本时,结果如下所示:
empno = 7499 ename = ALLEN job = SALESMAN mgr = 7698 sal = 1600 empno = 7521 ename = WARD job = SALESMAN mgr = 7698 sal = 1250 empno = 7654 ename = MARTIN job = SALESMAN mgr = 7698 sal = 1250 empno = 7698 ename = BLAKE job = MANAGER mgr = 7839 sal = 2850 empno = 7844 ename = TURNER job = SALESMAN mgr = 7698 sal = 1500 empno = 7900 ename = JAMES job = CLERK mgr = 7698 sal = 950
CouchDB 结论
CouchDB 非常强大,但是缺乏特定查询功能在某种程度上限制了它的使用。它非常受欢迎,并且有据可查。PHP 接口易于使用,但也是不必要的。人们可以通过直接使用 HTTP 协议和像 curl 这样的命令行工具来利用 CouchDB 的强大功能。利用PEAR HTTP_Request或HTTP_Request2包和JSON扩展就足以与 CouchDB 通信。
我们的下一个数据库属于 SQL 数据库的范畴。它不是一个成熟的 RDBMS,但是实现了 SQL 92 标准的一个非常重要的子集。
SQLite 简介
SQLite 是一个基于 SQL 的数据库,适合放在一个文件中,用于嵌入式系统。它被 Firefox 浏览器、Thunderbird 电子邮件客户端和许多其他应用使用,这些应用运行在从手机到主机系统的所有设备上。SQLite 是一个关系数据库,这意味着它实现了 SQL 语言。SQLite 是一款开源软件([sqlite.org](http://sqlite.org))。
与 NoSQL 数据库相比,关系数据库具有相当严格的模式结构。模式是相关对象的集合,主要是表和视图。关系数据库模式的基本单位称为表。这些表仿照真实世界的表:具有列的固定结构,通常称为属性和行。每一行只能包含为表定义的列,没有额外的属性——这也与 NoSQL 数据库相反,后者没有模式,这意味着它们不会在文档上强加固定的行结构。如果某一列不存在于某一行中,该列对于该行的值被设置为 NULL,这个人工值带有一些奇怪的属性。空是关系理论的黑洞。没有什么等于零;只能使用 IS [NOT] NULL 关系运算符测试列是否为 NULL。此外,空值会修改 RDBMS 系统中的逻辑。与空值的逻辑比较总是产生空值,这是除了“真”和“假”值之外,语句逻辑检查的第三个值。是的,没错:关系数据库不使用二进制逻辑。他们使用三元逻辑,表达式求值有三种可能的结果。NULL 实际上不是一个值;这是价值的缺失。
NULL 也是 SQLite 数据类型之一。SQLite 3 支持以下自我解释的数据类型:
- 空
- 整数
- 真实的
- 文本
- 一滴
其他关系数据库也支持各种不同的日期/时间类型,如日期、时间、间隔或时间戳,但 SQLite 是一种嵌入式数据库,其类型仅限于上述类型。“小尺寸”是它的设计目标之一,拥有一个复杂的日期/时间库将显著增加它,所以它在最终版本中被省略了。下一章将描述一个名为 MySQL 的成熟的关系数据库,它对日期/时间数据类型有广泛的支持,但本章的其余部分将介绍 SQLite 及其与 PHP 的集成。
对于关系数据库,还有另外两种重要的实体:视图和约束。视图是预先打包的查询,存储在数据库中用于查询。它们可以出现在查询中允许表的任何地方。视图本质上是命名查询。
顾名思义,约束是我们从数据中需要的规则和条例。SQLite 允许声明约束,但是它不强制约束,除了主键约束,当然,主键约束是最重要的约束。
表的主键约束唯一标识表中的每一行。每一行都必须有一个值,并且所有值都必须互不相同。它有点像银行账号:银行的每个客户都必须有一个,不同的客户有不同的账号。特别是,这意味着主键的值不能为空。主键约束在关系理论中非常重要,纯粹主义者认为每个表都应该有一个主键约束。拥有一个无法唯一标识行的表有什么意义呢?我们如何判断这些行是不同的?
还存在唯一约束,要求值在它们存在的地方是唯一的。这意味着唯一键的值可以为空,与主键相反。如果允许将行插入到表中,还存在要求列有值的 NOT NULL 约束。
Check 约束是对列施加用户计算值限制的列约束。一个例子是要求列值总是正数,不允许负数的约束。最后也是最复杂的约束是外键约束。为了解释它们,让我把本章“更新 MongoDB”一节的图片带回来(见图 7-6 )。
图 7-6。 MongoDB 采集信息设计
我们有两个表:一个描述雇员,另一个描述部门。employees 表中的部门号包含在 department 表中的要求称为“外键”EMP 表中deptno列的每个值都需要出现在另一个表的主键或唯一键列中,在本例中是 DEPT 表。对于读者来说,理解这些类型的实体并不特定于 SQLite 数据库是很重要的;它们在 SQL 标准中有描述。SQL 标准的最新版本发布于 2008 年。SQL 是一种活生生的语言,在数据库领域占据着至高无上的地位,目前市场上的大多数数据库系统都实现了 SQL。这包括 Oracle、Microsoft SQL Server、IBM DB2 和 Sybase 等商业数据库,以及 MySQL、PostgreSQL、SQL Lite 或 Firebird 等开源数据库。本章前面介绍的 NoSQL 数据库非常新,仍在市场上寻找自己的位置。
这本书是关于 PHP 的,而不是关于数据库和 SQL 标准化的。然而,为了解释如何从 PHP 使用关系数据库,我将尝试解释基础知识。本书并不假定读者熟悉关系数据库系统,但是拥有它肯定会有助于理解本章和下一章的内容。
既然我们知道了在关系数据库中可以合理地预期哪些对象,我们需要说一些关于这些对象是如何操作的,数据是如何检索的,以及如何更新的。关系数据库是根据基本集合论建模的。所有 SQL 语句的主要对象都是子集。包含在SELECT语句中的查询允许用户选择一个或多个表的子集。重要的是将由SELECT语句返回的数据视为子集,而不是单个的行或记录,因为它们有时被称为。除了SELECT语句,还有INSERT、DELETE、UPDATE语句,也是对子集进行操作。
不提到索引,关于关系实体的讨论就不完整。索引不像表或视图那样是逻辑对象;索引是纯粹的物理结构,由管理员创建以加快查询速度。通常会自动创建索引来实现主键和唯一键约束,但 SQLite 不会这样。在 SQLite 中,如果要实施约束,必须手动创建唯一索引。
SQL 不是一种过程语言。SQL 语句指定它们将操作的子集,而不是如何提取该子集。每个关系数据库软件都包含一个称为查询优化器的部分,它在运行时确定 SQL 命令请求的对象的访问路径。具体来说,查询优化器决定哪些索引将用于解析查询和检索所请求的子集,以及哪种方法将用于连接表(如果需要)。
除了查询优化器,所有的关系数据库,SQLite 也不例外,都有数据字典。数据字典就是所谓的元数据——关于数据的数据。它描述数据库中的所有其他对象,对于数据库软件的运行起着至关重要的作用。SQLite 是一个嵌入式数据库,创建时考虑到占用空间小,所以数据字典的角色被委托给一个名为sqlite_master的表。此表包含以下各列:
- 名称(对象的名称)
- 类型(对象的类型)
- tbl_name(表名,对索引很重要)
- rootpage(数据库文件中对象的开头)
- sql(对象的创建语句)
大多数其他关系数据库都有一个由数百个表组成的大得多的数据字典。数据字典最好通过命令行界面来演示,这个程序名为sqlite3。它通常使用所需的数据库名称作为命令行参数来调用:sqlite3 scott.sqlite。
如果数据库scott.sqlite不存在,它将被创建。结果将如下所示:
sqlite3 scott.sqlite SQLite version 3.3.6 Enter ".help" for instructions sqlite>
这个工具有很好的帮助和相当多有用的功能。它可以用来执行 SQL 命令和验证结果,而不需要太多的脚本。然而,正如我不断提醒自己的,这本书是关于 PHP 的。SQLite 是一个嵌入式数据库,这意味着它应该从程序中使用,而不是从像 sqlite3 这样的 CLI 实用程序中使用。所以,让我们开始描述 SQLite 的 PHP 接口。任何关系数据库的任何编程接口都至少有以下组件:
- 连接例程:对于 SQLite 来说,这些非常简单,与其他关系数据库不同,其他关系数据库通常有自己的网络协议和不同的身份验证方法。
- 执行 SQL 例程:根据选项的不同,这些例程可能相对复杂。除了执行 SQL 的例程,每个编程接口通常都提供一个“绑定”变量的方法。后面会看到一些例子,详细解释绑定变量的过程。这一类别中还包括“准备”例程,它将 SQL 语句从可读的文本形式翻译成一个称为“语句句柄”的对象。
- 描述结果集的例程:关系数据库将返回结果集,结果集具有不同的列、不同的名称和数据类型。总是有一个“describe”调用,它将描述返回给调用程序的结果集。
- 将结果集提取到调用程序中的例程:不同的数据库有不同的选项来加速数据检索,因此这也不是完全无关紧要的。
如果接口有类,通常会有一个connection类、statement类和一个result set类。由于历史原因,result集合类有时也被称为cursor类。这并没有描述开发人员在编写访问关系数据库的程序时所使用的语言。
当然,这些组件存在于 SQLite 的 PHP 接口中。所以,事不宜迟,让我们看看我们的第一个 SQLite 例子(见清单 7-10 )。该脚本将创建数据库结构,包括前面显示的 emp 和 dept 表,以及一个外键和一个索引。
清单 7-10。 SQLite 示例
<?php $DDL = <<<EOT CREATE TABLE dept ( deptno integer NOT NULL, dname text, loc text, CONSTRAINT dept_pkey PRIMARY KEY (deptno) ); CREATE TABLE emp ( empno integer NOT NULL, ename text , job text , mgr integer, hiredate text, sal real, comm real, deptno integer, CONSTRAINT emp_pkey PRIMARY KEY (empno), CONSTRAINT fk_deptno FOREIGN KEY (deptno) REFERENCES dept (deptno) ON DELETE CASCADE ); CREATE UNIQUE INDEX pk_emp on emp(empno); CREATE INDEX emp_deptno on emp(deptno); CREATE UNIQUE INDEX pk_dept on dept(deptno); EOT; try { $db = new SQLite3("scott.sqlite"); @$db->exec($DDL); if ($db->lastErrorCode() != 0) { throw new Exception($db->lastErrorMsg()."\n"); } print "Database structure created successfully.\n"; } catch(Exception $e) { print "Exception:\n"; die($e->getMessage()); } ?>
这个脚本主要由 DDL 变量中的 SQL 命令组成。真正活跃的部分在try块中,它通过将这个变量传递给用于执行 SQL 语句的query方法来执行这个变量。该方法返回一个resultset或cursor类的实例,该实例可用于找出查询返回的列数、它们的名称和类型,以及检索数据。
我们的$DDL命令创建表和索引,不返回任何列。那么,我们应该如何知道命令是否成功呢?不幸的是,SQLite3 类不抛出异常:异常必须由程序员抛出。然而,SQLite 确实提供了确定最后一个错误代码和消息的方法,然后可以使用这些方法来创建和抛出一个异常。成功的代码是 0,其他的都表示错误。
当这个脚本被执行时,它将创建一个数据库scott.sqlite(如果它不存在的话),并将创建我们想要的数据库结构。您还应该注意到几个 SQL 语句被捆绑在一起:两个create table语句和三个create index语句作为一个单元执行。此外,尽管 SQLite 不强制约束,唯一索引将防止重复数据被输入到表中。它们不会阻止将空值插入主键列。
现在,我们的表已经创建好了,我们必须向其中加载一些数据。要加载的数据位于两个逗号分隔值(CSV)文件中,因此需要一个相对通用的脚本来将 CSV 文件加载到数据库中。该脚本将接受两个命令行参数,表名和文件名。这样一个脚本是一个很好的工具,可以演示所有关系数据库管理系统(RDBMS)中的许多概念。这两个文件如下所示:
`Emp.csv
7369,SMITH,CLERK,7902,17-DEC-80,800,,20 7499,ALLEN,SALESMAN,7698,20-FEB-81,1600,300,30 7521,WARD,SALESMAN,7698,22-FEB-81,1250,500,30 7566,JONES,MANAGER,7839,02-APR-81,2975,,20 7654,MARTIN,SALESMAN,7698,28-SEP-81,1250,1400,30 7698,BLAKE,MANAGER,7839,01-MAY-81,2850,,30 7782,CLARK,MANAGER,7839,09-JUN-81,2450,,10 7788,SCOTT,ANALYST,7566,19-APR-87,3000,,20 7839,KING,PRESIDENT,,17-NOV-81,5000,,10 7844,TURNER,SALESMAN,7698,08-SEP-81,1500,0,30 7876,ADAMS,CLERK,7788,23-MAY-87,1100,,20 7900,JAMES,CLERK,7698,03-DEC-81,950,,30 7902,FORD,ANALYST,7566,03-DEC-81,3000,,20 7934,MILLER,CLERK,7782,23-JAN-82,1300,,10
Dept.csv
10,ACCOUNTING,"NEW YORK" 20,RESEARCH,DALLAS 30,SALES,CHICAGO 40,OPERATIONS,BOSTON`
将数据分别加载到emp和dept表中的脚本看起来像清单 7-11 中的。
清单 7-11*。**将数据加载到emp和dept表*中的脚本
`<?php if (tname = fname = rownum = 0;
function create_insert_stmt(ncols) { table values("; foreach(range(1,i) { i,"; } /", ')', stmt); } try { res = tname"); if (db->lastErrorCode() != 0) { throw new Exception(db->lastErrorMsg()); } res->numColumns(); ins = create_insert_stmt(ncols); print "Insert stmt:res = ins); fname,"r"); while (fp->fgetcsv()) { if (strlen(implode('',ncols) as i) { res->bindValue(":row[i - 1]); } res->execute(); if (db->lastErrorCode() != 0) { print_r(row); throw new Exception(db->lastErrorMsg()); } rownum++; } print "tname.\n"; } catch(Exception e) { print "Exception:\n"; die(e->getMessage() . "\n"); } ?>`
执行时,结果如下所示:
./script7.11.php emp emp.csv Insert stmt:insert into emp values(:1,:2,:3,:4,:5,:6,:7,:8) 14 rows inserted into emp. ./script7.11.php dept dept.csv Insert stmt:insert into dept values(:1,:2,:3) 4 rows inserted into dept.
现在,我们来讨论一下剧本。这个脚本实际上是有用的,因为它很容易移植到各种数据库。正如清单 7-10 中的脚本一样,最重要的部分是在try块中的部分。首先要注意的是对try块开头的查询:
$res = $db->query("select * from $tname");
query方法执行作为字符串传递给它的查询,并返回一个statement类的实例。statement类用于找出查询返回的列的名称和类型信息,以及检索数据本身。但是,请注意,没有从表中检索到任何行;对查询结果的检查只是为了确定返回的列数。这是通过调用statement类的“numColumns”方法完成的:
$ncols = $res->numColumns();
当表中的列数已知时,可以构造 insert 语句,并使用“finalize”调用关闭查询的结果集。当不再需要游标时,最好关闭它们,这样可以防止内存泄漏和致命的混乱。现在,让我们回到构造 insert 语句。创建 insert 语句有两种可能的策略:
- 可以为需要插入的每一行构造一个单独的 insert 语句。这迫使数据库将每个这样构造的 SQL 语句作为一个单独的语句来解析并执行它。解析每个语句意味着将它传递给查询优化器。这可能是一个开销很大的操作,尤其是在更复杂的数据库中,这些数据库将对象统计信息视为查询优化的一部分。这经常(但不总是)更容易编程,尤其是当程序在已知的一组表和列上操作时。
- 我们可以构造一个语句,为每个要插入的值指定占位符,解析一次并执行多次,每次执行语句时都将新值绑定到占位符。这样做需要使用来自编程接口的“bind”调用,因此通常比使用内置字符串操作函数简单地创建 SQL 语句更复杂,但几乎总是会导致代码明显更快。
这个脚本方便地将创建的insert语句打印在标准输出上,这样我们就可以看到我们手工制作的结果。对于 emp 表,结果如下所示:
insert into emp values(:1,:2,:3,:4,:5,:6,:7,:8)
实体“:1”、“2”、“3”...“:8”称为占位符。以冒号(":")开头的任何字母数字字符串都是合法的占位符。当要求数据库准备包含占位符的语句时,它会将其解析为内部形式,但在提供占位符的实际值之前无法执行该语句。这部分是通过使用bindValue或bindParam调用完成的,它们将占位符绑定到一个值或变量。在这个脚本中,使用了bindValue调用,因为主循环每次都会返回一个新的变量$row,所以将其作为参数绑定是没有意义的。它可以在一开始就声明,使其成为全局变量,但是使用全局变量是一种不被认可的编程实践。全局变量使程序不可读,并可能导致名称冲突和错误。prepare方法返回statement类的一个实例,该类也有一个execute方法。一旦所有占位符都有了已知的值,通过绑定调用,就可以一遍又一遍地执行该语句,而不需要重新解析,只需为每次执行提供一组新的值。绑定调用如下所示:
$res->bindValue(":$i", $row[$i – 1]);
选择占位符名称的:$i形式的主要原因是为了能够在一个循环中绑定值。这就把我们带到了剧本本身的结尾。这个脚本还有一点需要注意:主循环中奇怪的“if”条件,通过使用implode和strlen检查$row是否为空。在 PHP 5.3 的某些版本中,SplFileObject对象将在文件末尾返回一个空行,如果没有这个条件,它将被插入到表中,因为 SQLite 不强制约束。其他数据库会拒绝主键为空的行,但也会回滚整个事务,从而删除所有以前插入的行,这并不是预期的结果。对于将所有错误检查内置到类本身中而言,拥有“if”语句是一个很小的代价,而不必编写类似以下的代码:
$fp = fopen($fname, "r"); if (!$fp) { die("Cannot open $fname for reading!\n"); }
这是好心的 SPL 的作者们做的。在结束本章之前,还有一件事要做。到目前为止,我们已经创建了 SQLite 表并加载了数据,但是实际上我们还没有从数据库中检索到任何东西。将要执行的查询是一个标准连接:
select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno);
这种类型的查询称为联接,因为它将两个(或更多)表中的数据联接起来,以行的形式显示出来。这种特殊的语法称为 ANSI join 语法,在数据库之间非常容易移植。这个完全相同的查询可以在任何关系数据库上执行,甚至不需要改变一个字符。
执行清单 7-10 和清单 7-11 中显示的脚本将提供数据库结构和其中的数据,因此在编写脚本之前,可以使用前面提到的sqlite3命令行工具测试该查询。只打印数据太琐碎,也没什么意思,所以脚本还会打印列标题,并相应地确定列格式。所以,在这里,在清单 7-12 中。
清单 7-12。
<?php $QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)"; $colnames = array(); $formats = array(); $ncols = 0; try { $db = new SQLite3("scott.sqlite"); $res = $db->query($QRY); if ($db->lastErrorCode() != 0) { throw new Exception($db->lastErrorMsg()); } // Get the number of columns $ncols = $res->numColumns(); // For every column, define format, based on the type foreach (range(0, $ncols - 1) as $i) { $colnames[$i] = $res->columnName($i); switch ($res->columnType($i)) { case SQLITE3_TEXT: $formats[$i] = "% 12s"; break; case SQLITE3_INTEGER: $formats[$i] = "% 12d"; break; case SQLITE3_NULL: $formats[$i] = "% 12s"; break; default: $formats[$i] = "%12s"; } } // Print column titles, converted to uppercase. foreach ($colnames as $c) { printf("%12s", strtoupper($c)); } // Print the boundary printf("\n% '-48s\n", "-"); // Print row data while ($row = $res->fetchArray(SQLITE3_NUM)) { foreach (range(0, $ncols - 1) as $i) { printf($formats[$i], $row[$i]); } print "\n"; } } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
该脚本的输出如下所示:
`/script7.12.php ENAME JOB DNAME LOC
SMITH CLERK RESEARCH DALLAS
ALLEN SALESMAN SALES CHICAGO
WARD SALESMAN SALES CHICAGO
JONES MANAGER RESEARCH DALLAS
MARTIN SALESMAN SALES CHICAGO BLAKE MANAGER SALES CHICAGO
CLARK MANAGER ACCOUNTING NEW YORK
SCOTT ANALYST RESEARCH DALLAS
KING PRESIDENT ACCOUNTING NEW YORK
TURNER SALESMAN SALES CHICAGO
ADAMS CLERK RESEARCH DALLAS
JAMES CLERK SALES CHICAGO
FORD ANALYST RESEARCH DALLAS
MILLER CLERK ACCOUNTING NEW YORK`
这个脚本包含通常的嫌疑人,一些新的调用。新方法有columnName、columnType和fetchArray。columnName()方法很琐碎;它将列号作为参数,编号从零开始,并返回列名。columnType类似于columnName,返回预定义的常数,这些常数被恰当地命名为:SQLITE3_INTEGER、SQLITE3_FLOAT、SQLITE3_TEXT、SQLITE3_BLOB和SQLITE3_NULL。类型名称是不言自明的。如果是浮点型,其他数据库也会返回像列大小和小数位数这样的信息,但是 SQLite 是嵌入式数据库,不会这样做。
最后一个方法是fetchArray,它将从数据库中以逐行的方式返回数据,将行显示为普通数组、关联数组或两者都有,这取决于模式参数,它可以取三个值之一:SQLITE3_NUM、SQLITE3_ASSOC或SQLITE3_BOTH。
SQLite 结论
SQLite 的 PHP 接口与我们在许多其他数据库(如 MySQL 或 PostgreSQL)的接口中看到的正常调用是一致的,这两者将在下一章中描述。随着无线计算的出现,SQLite 获得了极大的流行。它不是一个成熟的 RDBMS 系统,具有多版本、行级锁定、网络访问协议、参与两阶段分布式提交的能力,甚至可以强制执行基本约束。然而,它有一个非常熟悉的 SQL 接口和编程语言扩展,对于以前使用过关系数据库系统的人来说,这使得它非常容易学习。它是一个完美的数据库,可以保存 Firefox 书签、电子邮件联系人列表、电话号码,甚至是手机上的歌曲播放列表。PHP 和 Apache 也可以在许多平台上使用,包括移动平台,比如 iPhone,这使得 PHP/SQLite 组合非常适合移动应用开发。SQLite 和 PHP 的组合有一些有趣的可能性,这超出了本书的范围。可以扩展 SQLite 并注册 PHP 函数,以便在 SQL 中工作,甚至用作聚合函数。这两种产品都在快速发展,我相信这种结合只会越来越好。
总结
本章致力于 PHP 与非经典关系数据库(如 MySQL 或 Oracle)的集成。所有这些数据库都非常新。例如,本章描述的 SQLite3 只在 PHP 5.3 或更高版本中可用。MongoDB 和 CouchDB 也是非常新的技术。目前,数据库软件领域继续由关系数据库统治,毕竟,关系数据库是在考虑金融交易的情况下建模的。
在下一章,我们将研究一个成熟的关系数据库,MySQL 和两个抽象层,PDO 和 ADOdb。在这一章的最后,还会谈到 Sphinx 工具,这是一个非常流行的全文搜索软件。
八、数据库集成 II
在本章中,我们将了解如何使用成熟的 RDBMS 系统 MySQL。然后,我们将研究两个数据库抽象层,PDO 和 ADOdb。在本章的最后,我们将向您展示如何利用 Sphinx 文本搜索引擎。
对于 MySQL 数据库,有几个 PHP 扩展可供选择。最常用的是 MySQL 扩展。这个扩展很老了,没有面向对象,因为它从 PHP4 和 MySQL 4 就存在了。它还缺少一些重要的特性,比如绑定变量。还有一个更新的扩展,MySQLi,将在本章中介绍。值得一提的是,旧的、过程化的 MySQL 扩展仍然是最常用的。
MySQLi 扩展简介
MySQLi 扩展在许多方面类似于上一章讨论的 SQLite3 扩展。它也是面向对象的,不像旧的 MySQL 扩展那样是过程化的,也不像 SQLite3 那样抛出异常。除了数据库之外,组件是相同的,在这种情况下,数据库比 SQLite 强大得多,并支持全套 ANSI 标准数据库特性。出于本章的目的,MySQL 版运行在本地机器上:
`mysql -u scott --password=tiger scott Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A
Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 50 Server version: 5.1.37-1ubuntu5.5 (Ubuntu)
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> select version(); +-------------------+ | version() | +-------------------+ | 5.1.37-1ubuntu5.5 | +-------------------+ 1 row in set (0.00 sec)`
用户名是“scott”,密码是“tiger”,数据库名是“scott”数据库结构将与 SQLite3 示例中的相同:我们将拥有相同的“emp”和“dept”表。我们还将使用相同的两个脚本:一个用于将 CSV 文件加载到数据库中,另一个用于运行查询。重写相同的脚本建立了比较的基础,并使不同 MySQLi 方法的目的非常清楚。以下是 MySQL 风格的表格描述:
`mysql> describe emp; +----------+-------------+------+-----+-------------------+---------------------+ | Field | Type | Null | Key | Default | Extra | +----------+-------------+------+-----+-------------------+---------------------+ | empno | int(4) | NO | PRI | NULL | | | ename | varchar(10) | YES | | NULL | | | job | varchar(9) | YES | | NULL | | | mgr | int(4) | YES | | NULL | | | hiredate| timestamp | NO | | CURRENT_TIMESTAMP | | sal | double | YES | | NULL | | | comm | double | YES | | NULL | | | deptno | int(4) | YES | MUL | NULL | | +----------+-------------+------+-----+-----------------------+-----------------+ 8 rows in set (0.00 sec)
mysql> describe dept; +----------+-----------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra| +----------+-----------------+------+-----+---------+--------+ | deptno | int(4) | NO | PRI | NULL | | | dname | varchar(14) | YES | | NULL | | | loc | varchar(13) | YES | | NULL | | +----------+-----------------+------+-----+----------+-------+ 3 rows in set (0.00 sec)`
表是空的,还有两个 CSV 文件要加载到数据库中。CSV 代表“逗号分隔值”,是一种标准的表格文件格式,可被 SQL 数据库和电子表格程序(如 Microsoft Excel)识别。事实上,大多数数据库都有特殊规定,允许更容易地加载 CSV 文件。这适用于 MySQL,它有LOAD DATA命令,如下例所示。然而,我们的脚本仍然是一个很好的练习。下面是LOAD DATA MySQL 命令的语法描述:
mysql> help load data Name: 'LOAD DATA' Description: Syntax: LOAD DATA [LOW_PRIORITY | CONCURRENT] [LOCAL] INFILE 'file_name' [REPLACE | IGNORE] INTO TABLE tbl_name [CHARACTER SET charset_name] [{FIELDS | COLUMNS} [TERMINATED BY 'string'] [[OPTIONALLY] ENCLOSED BY 'char'] [ESCAPED BY 'char'] ]
[LINES [STARTING BY 'string'] [TERMINATED BY 'string'] ] [IGNORE number LINES] [(col_name_or_user_var,...)] [SET col_name = expr,...]
要加载的文件将被命名为 emp.csv 和 dept.csv 文件与第七章中的版本略有不同。这是因为与 MySQL 不同,SQLite 不支持日期类型。MySQL 支持完整的 ANSI 标准数据类型和数据算法。为了加载时间戳类型的数据,我们必须使用正确的日期格式,即 YYYY-MM-DD HH24:MI-SS。YYYY 部分表示四位数的年份,MM 是月份,DD 是一个月中的某一天,HH24 是 24 小时制的小时,MI 和 SS 是分钟和秒钟。以下是文件:
`emp.csv
7369,SMITH,CLERK,7902,"1980-12-17 00:00:00",800,,20 7499,ALLEN,SALESMAN,7698,"1981-02-20 00:00:00",1600,300,30 7521,WARD,SALESMAN,7698,"1981-02-22 00:00:00",1250,500,30 7566,JONES,MANAGER,7839,"1981-04-02 00:00:00",2975,,20 7654,MARTIN,SALESMAN,7698,"1981-09-28 00:00:00",1250,1400,30 7698,BLAKE,MANAGER,7839,"1981-05-01 00:00:00",2850,,30 7782,CLARK,MANAGER,7839,"1981-06-09 00:00:00",2450,,10 7788,SCOTT,ANALYST,7566,"1987-04-19 00:00:00",3000,,20 7839,KING,PRESIDENT,,"1981-11-17 00:00:00",5000,,10 7844,TURNER,SALESMAN,7698,"1981-09-08 00:00:00",1500,0,30 7876,ADAMS,CLERK,7788,"1987-05-23 00:00:00",1100,,20 7900,JAMES,CLERK,7698,"1981-12-03 00:00:00",950,,30 7902,FORD,ANALYST,7566,"1981-12-03 00:00:00",3000,,20 7934,MILLER,CLERK,7782,"1982-01-23 00:00:00",1300,,10`
“dept”文件与第七章中的版本相同:
`dept.csv
10,ACCOUNTING,"NEW YORK" 20,RESEARCH,DALLAS 30,SALES,CHICAGO 40,OPERATIONS,BOSTON`
创建表格的脚本没有任何有趣的元素。用于执行“创建表”命令的所有调用都包含在加载和查询数据的脚本中。清单 8-1 显示了将两个 CSV 文件加载到各自的 MySQL 表中的脚本。
***清单 8-1。*将两个 CSV 文件加载到各自的 MySQL 表中
<?php if ($argc != 3) { die("USAGE:script8.1 <table_name> <file name>\n"); } $tname = $argv[1]; $fname = $argv[2]; $rownum = 0; function create_insert_stmt($table, $ncols) { $stmt = "insert into $table values("; foreach (range(1, $ncols) as $i) { $stmt.= "?,"; } $stmt = preg_replace("/,$/", ')', $stmt); return ($stmt); } try { $db = new mysqli("localhost", "scott", "tiger", "scott"); $db->autocommit(FALSE); $res = $db->prepare("select * from $tname"); if ($db->errno != 0) { throw new Exception($db->error); } $ncols = $res->field_count; $res->free_result(); $ins = create_insert_stmt($tname, $ncols); $fmt = str_repeat("s", $ncols); $res = $db->prepare($ins); if ($db->errno != 0) { throw new Exception($db->error); } $fp = new SplFileObject($fname, "r"); while ($row = $fp->fgetcsv()) { if (strlen(implode('', $row)) == 0) continue; array_unshift($row, $fmt); foreach(range(1,$ncols) as $i) { $row[$i]=&$row[$i]; } call_user_func_array(array(&$res, "bind_param"), &$row); $res->execute(); if ($res->errno != 0) { print_r($row); throw new Exception($res->error); } $rownum++; } $db->commit(); if ($db->errno != 0) { throw new Exception($db->error); } print "$rownum rows inserted into $tname.\n"; } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
这个剧本里有不少有趣的元素。连接到数据库不是其中之一。创建新的 MySQLi 实例的参数是主机名、用户名、密码和要连接的数据库。此语句将关闭自动提交模式:
$db->autocommit(FALSE);
MySQL 是一个完整的关系数据库,支持事务和 ACID 需求,正如在第七章中所解释的。COMMIT语句是一个 ANSI SQL 语句,它使当前事务的效果永久化。相反的命令是ROLLBACK,它将取消当前交易的效果。在自动提交模式下,数据库会在每个 SQL 语句后发出COMMIT,比如 insert。COMMIT语句的开销非常大,因为按照 ACID 要求,会话必须等到所需的信息被物理写入磁盘后才能继续。不仅该语句非常昂贵和耗时,自动打开提交可能会导致部分加载,这通常是不希望的。好的程序员希望纠正问题,重新开始加载。
注意关闭自动提交是所有关系数据库的常见做法。当脚本包含
INSERT、UPDATE或DELETE语句时使用。自动提交是一项开销非常大的操作。
与 SQLite 的情况一样,我们将使用一个select * from table SQL 语句来找出有多少列。要执行的第一个调用是prepare:
$res = $db->prepare("select * from $tname");
这将解析 SQL 语句,并将其转换为类MYSQLI_STMT的对象,一个解析的语句。MYSQLI_STMT属性之一是包含字段的数量:
$ncols = $res->field_count;
当列数已知时,可以使用free_result调用并构造 insert 语句来关闭这个结果集。用来做这件事的函数与清单 7-9 中使用的同名函数非常相似,但并不相同。不同之处在于,插入现在看起来像这样:
insert into dept values(?,?,?)
它有问号,而不是占位符名称:1、:2和:3,如清单 7-9 中的所示。原因是 MySQLi 接口不支持命名绑定,只支持位置绑定。所有绑定必须同时完成,将一个值数组绑定到解析后的语句。语句绑定方法具有以下格式:
$res->bind_param("fmt",$var1,$var2,$var3,...,$varN);
第一个参数是格式字符串,它是由每个绑定变量的一个字符组成的字符串。格式字符告诉 MySQLi 变量的类型,该变量被绑定到与它在参数数组中相同的位置。这意味着$var1被绑定到 insert 中的第一个位置,由第一个问号标记,$var2 被绑定到第二个问号,依此类推。格式字符串是“I”表示整数,“d”表示双精度数,“s”表示字符串,“b”表示 blob。Blobs 是二进制数据集合,就像图像一样。
我们现在有一个编程问题:我们必须在一个 PHP 语句中将变量绑定到 insert 语句,而不知道我们必须绑定多少个变量。格式化字符串很简单——我们只需构造一个由所有字符串组成的字符串。PHP 等弱类型脚本语言的一个好处是类型通常不是大问题;几乎所有东西都可以转换成字符串。对于bind_param方法,我们必须使用一些诡计。幸运的是,PHP 在技巧方面非常通融。有一个 PHP 函数叫做call_user_func_array,调用第一个参数中命名的用户函数,用第二个参数中的 array 作为参数数组。如果我们有一个带三个参数($a1,$a和$a3))的函数F(),,那么表达式F($a1,$a2,$a3)将完全等价于表达式call_user_func_array("F",array($a1,$a2,$a3))。如果函数F()是对象$obj的一个方法,那么第一个参数将是array($obj,"F")而不仅仅是“f”。这将在任何 PHP 版本中解决这个问题,直到 5.3。不幸的是,在 PHP 5.3 版本中,MySQLi 期望绑定变量的引用,并且不接受值。这就是脚本中包含以下代码片段的原因:
array_unshift($row, $fmt); foreach(range(1,$ncols) as $i) { $row[$i]=&$row[$i]; }
我们确保每个绑定变量都包含对实际值的引用。这不是指格式字符串。循环中的range从 1 开始,在unshift之后,格式位于数组的开头。PHP 数组以index=0开头,而不是前面代码片段中的 1 作为我们的“范围”函数,这意味着我们跳过了这个格式,把它作为一个值。在准备好我们的参数数组之后,“神奇的”绑定就这样完成了:
call_user_func_array(array(&$res, "bind_param"), &$row);
之后,执行解析后的语句$res。对于由SplFileObject从 CSV 文件返回的每一行,都重复这一过程。当所有行都被读取时,循环结束,执行commit。这是commit的逻辑位置。当然,正如本章开头所说,MySQLi 不抛出异常;使用它的程序员负责每个关键步骤后的错误检查。然而,MySQLi 已经为此做好了准备。所有 MySQLi 类的每个对象都有errno和error属性。errno属性是错误代码,error属性包含错误的文本描述。MySQLi 系统中有三个不同的类:MYSQLi本身,描述数据库连接;MYSQLi_STMT,描述解析后的语句;以及描述结果集的MYSQLI_RESULT,由数据库返回给脚本。清单 8-1 使用了连接和声明类。要查看结果类,我们必须检索一些数据(参见清单 8-2 )。
***清单 8-2。*写一份与清单 7-10 相同的报告
<?php $QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)"; $ncols = 0; $colnames = array(); try { $db = new mysqli("localhost", "scott", "tiger", "scott"); $res = $db->query($QRY); print "\n"; if ($db->errno != 0) { throw new Exception($db->error); } ` // Get the number of columns
res->field_count;
// Get the column names while (res->fetch_field()) { info->name); }
// Print the column titles foreach (c) { printf("%-12s", $c); }
// Print the border printf("\n%s\n", str_repeat("-", 12 * $ncols));
// Print rows while (res->fetch_row()) { foreach (range(0, i) { printf("%-12s", i]); } print "\n"; } } catch(Exception e) { print "Exception:\n"; die(e->getMessage() . "\n"); } ?>`
该脚本将编写一份与清单 7-10 中的脚本相同的报告。脚本结构与清单 7-10 相同。注意,这里不需要关闭自动提交;此脚本中没有事务。
connection 类的“query”方法返回一个MYSQLI_RESULT类的对象,在本例中恰当地命名为$res。该对象的属性之一是列数:
$ncols = $res->field_count;
对于每一列,都有一个描述——一个辅助类stdClas的对象。该对象通过使用$res对象的fetch_field方法来检索。以下是相关片段:
while ($info = $res->fetch_field()) { $colnames[] = strtoupper($info->name); }
这个脚本只使用了“name”属性,但是包含在$info对象中的整个描述如下所示:
stdClass Object ( [name] => empno [orgname] => empno [table] => emp [orgtable] => emp [def] => [max_length] => 4 [length] => 4 [charsetnr] => 63 [flags] => 53251 [type] => 3 [decimals] => 0 )
“name”属性显然指的是列名。在处理视图时,orgname和orgtable很重要。SQL 标准描述了称为“视图”的对象,这些对象本质上是命名查询。允许查询重命名列,因此新名称将在“name”属性中,而原始名称和表将在orgname和orgtable属性中。除了名称和长度列之外,最重要的列是类型列。不幸的是,MySQLi 文档中没有记录这些类型的含义。然而,根据经验,我们知道 3 是整数,5 是双精度数,253 是可变字符,7 是时间戳数据类型。
就像所有其他数据库一样,有一个“fetch”调用从数据库中获取结果。在这种情况下,该方法被称为fetch_row。获取数据的循环与清单 7-10 中的 SQLite 示例完全相同:
while ($row = $res->fetch_row()) { foreach (range(0, $ncols - 1) as $i) { printf("%-12s", $row[$i]); } print "\n"; }
这个脚本的输出看起来与清单 7-10 中的输出完全一样:
`./script8.2.php ENAME JOB DNAME LOC
CLARK MANAGER ACCOUNTING NEW YORK KING PRESIDENT ACCOUNTING NEW YORK MILLER CLERK ACCOUNTING NEW YORK SMITH CLERK RESEARCH DALLAS JONES MANAGER RESEARCH DALLAS SCOTT ANALYST RESEARCH DALLAS ADAMS CLERK RESEARCH DALLAS FORD ANALYST RESEARCH DALLAS ALLEN SALESMAN SALES CHICAGO WARD SALESMAN SALES CHICAGO MARTIN SALESMAN SALES CHICAGO BLAKE MANAGER SALES CHICAGO TURNER SALESMAN SALES CHICAGO JAMES CLERK SALES CHICAGO`
MySQLi 扩展的结论
MySQL 比最初的 MySQL 扩展更加现代和强大,但它缺少一些重要的功能,如命名绑定和异常处理。许多托管公司只允许原始的 MySQL 扩展,它被更大、更好、更快的 MySQL 所取代。好在这个不是唯一的选择。还有 PDO 扩展家族,它解决了命名绑定和异常的问题。我们接下来将讨论 PDO 扩展。
PDO 简介
PDO 是 PHP 数据对象的缩写。它试图将所有数据库的扩展统一到一个单一的编程应用接口(API)中,这将简化编程并减少编写与数据库交互的应用所需的知识量。这种努力对一些数据库来说是成功的,但对其他数据库来说就不那么成功了。当一切都简化为相同的公分母时,一些特殊的功能就会丢失,例如 PostgreSQL 或数组接口中的“复制”命令以及 Oracle RDBMS 的会话池。这些功能旨在显著加快数据处理速度,但无法通过 PDO 获得。此外,数据库供应商主要维护特定于数据库的扩展,这使得 PDO 有些被忽视。
PDO 有两层。首先,有一个通用的 PDO 接口,然后有一个特定于数据库的驱动程序,它与 PDO 层合作,与数据库进行实际的交互。默认情况下启用 PDO,但是需要单独安装数据库驱动程序。PDO 接口实际上比本地接口更好的数据库之一是 MySQL。所以让我们看看用 PDO 写的 CSV 加载脚本(见清单 8-3 )。
***清单 8-3。*使用 PDO 编写的 CSV 加载脚本
<?php if ($argc != 3) { die("USAGE:script8.3 <table_name> <file name>\n"); } $tname = $argv[1]; $fname = $argv[2]; $rownum = 0; function create_insert_stmt($table, $ncols) { $stmt = "insert into $table values("; foreach (range(1, $ncols) as $i) { $stmt.= "?,"; } $stmt = preg_replace("/,$/", ')', $stmt); return ($stmt); } try { $db = new PDO('mysql:host=localhost;dbname=scott', 'scott', 'tiger'); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $res = $db->prepare("select * from $tname"); $res->execute(); $ncols = $res->columnCount(); $ins = create_insert_stmt($tname, $ncols); $res = $db->prepare($ins); $fp = new SplFileObject($fname, "r"); $db->beginTransaction(); while ($row = $fp->fgetcsv()) { if (strlen(implode('', $row)) == 0) continue; $res->execute($row); $rownum++; } $db->commit(); print "$rownum rows inserted into $tname.\n"; } catch(PDOException $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
这是迄今为止最短的版本,但功能齐全。大部分缺失的代码是错误处理代码。这个版本的脚本中明显没有这段代码。原因是数据库连接后立即进行了setAttribute调用。在这个setAttribute调用中,PDO 被指示在出现任何数据库错误时抛出一个PDOException类的对象。该异常包含错误代码和消息,可用于处理错误。这使得我们所有定制的错误处理代码变得不必要,所以它被从脚本中删除了。
将变量绑定到占位符或语句的代码也完全不存在。PDO 可以在执行时执行绑定,这是它与我们的下一个可移植数据库接口 ADOdb 共有的特性。execute方法将绑定值的数组作为参数,并在执行之前将数组绑定到解析的语句。与清单 8-1 中绑定变量所必需的可怕的call_user_func_array魔法相比。PDO 确实支持命名占位符的bindValue方法,但并不经常需要。
在这个脚本中,我们还看到了“公分母”方法的一个缺陷:PDO 无法关闭会话的自动提交模式。它可以显式启动事务,这当然会在事务期间关闭自动提交模式,而不是在会话期间。
此外,PDO 必须首先执行一个准备好的语句才能描述它。原生 MYSQLi 驱动不需要执行语句;我们能够对在清单 8-1 中准备好但没有执行的语句执行field_count。执行长时间运行的 SQL 语句可能会导致很大的延迟。如果要加载的表包含数亿条记录,执行初始 SQL 语句"select * from $table"可能需要几个小时才能完成。其原因在于酸的需求。ACID 需求向用户保证,在查询开始之前,他只能看到提交给数据库的更改。数据库必须重建查询开始后修改的行,并向用户显示查询开始前的行版本。如果底层表很大并且频繁修改,那么这可能是一个非常漫长的过程。另一种方法是锁定该表,并阻止任何人在查询期间修改它。不用说,如果访问的并发性是一个业务需求,那么这种策略是不会通过的。
人们将不得不求助于不可移植的技巧来解决这个问题。一种方法是像这样重写 SQL:"select * from $table limit 1"。这将只从数据库返回一行,因此无论表的大小如何,执行速度都要快得多。不幸的是,这不适用于 Oracle RDBMS,它不支持LIMIT选项,而是使用自己的ROWNUM结构。这个问题没有现成的解决办法。这就是使用 PDO 的风险。然而,大多数用户只使用一种或两种类型的数据库引擎(例如,只有 MySQL 和 PostgreSQL),所以这通常不是一个大问题。
现在,让我们看看第二个脚本,通过运行一个固定查询产生的小报告(见清单 8-4 )。
***清单 8-4。*运行固定查询生成的报告
<?php $QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)"; $colnames = array(); $ncols = 0; try { $db = new PDO('mysql:host=localhost;dbname=scott', 'scott', 'tiger'); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $res = $db->prepare($QRY); $res->execute(); // Get the number of columns $ncols = $res->columnCount(); // For every column, define format, based on the type foreach (range(0, $ncols - 1) as $i) { $info = $res->getColumnMeta($i); $colnames[] = $info['name']; } // Print column titles, converted to uppercase. foreach ($colnames as $c) { printf("%-12s", strtoupper($c)); } // Print the boundary printf("\n%s\n", str_repeat("-", 12 * $ncols)); // Print row data while ($row = $res->fetch(PDO::FETCH_NUM)) { foreach ($row as $r) { printf("%-12s", $r); } print "\n"; } } catch(PDOException $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
这完全是标准的—这里没什么可看的。然而,有趣的是,用于描述光标的getColumnMeta方法,在手册中仍然被标记为实验性的,并被标记为“使用风险自担”。这个方法绝对至关重要;没有它将严重限制 PDO 的用途。然而,这种方法并不适用于所有数据库。例如,它不能在 Oracle 上运行。此方法生成的列描述如下所示:
`Array ( [native_type] => VAR_STRING [flags] => Array ( )
[table] => d [name] => loc [len] => 13 [precision] => 0 [pdo_type] => 2 )`
表名是“d”,因为该方法从我们的 SQL 中选择了表别名。正在执行的查询如下:
$QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)";
在这个查询中,我们为 emp 表使用了别名“e ”,为 dept 表使用了别名“d ”,以便能够将连接条件从(emp.deptno=dept.deptno)缩短为更短且同样易于理解(数据库服务器)的形式(e.deptno = d.deptno)。getColumnMeta方法返回这个别名,而不是完整的表名。这不一定是一个错误,但是会使“table”字段变得不那么有用。此外,fetch 包含了PDO::FETCH_NUM选项,类似于我们在清单 7-10 中看到的 SQLite 示例。就像这里的情况一样,fetch 可以将 row 作为由数字、列名索引的数组返回,或者作为以列名为属性的对象返回。默认为FETCH_BOTH,它将获取关联数组和数字索引数组。
提到 SQLite,SQLite3 也有一个 PDO 驱动程序。更重要的是,getColumnMeta工作得非常好,它返回完整的表名,而不是 SQL 别名,MySQL 就是这种情况。如果我们用$db = new PDO(‘sqlite:scott.sqlite’)替换连接线,我们的两个 PDO 脚本都将完美工作。当然,开始和提交事务的命令是不需要的,但是它们也不会造成任何伤害。
PDO 的结论
PDO 有了一个良好的开端,但仍处于发展阶段。它将是 PHP 6 中唯一的数据库扩展,但这不会很快发生。对于利用标准的数据库特性来说,它已经足够了,但是它仍然不能利用几乎所有数据库中内置的专有数据库扩展,主要是因为性能原因。结合它功能不全的事实,我会建议读者把 PDO 扩展当作 beta 软件。
ADOdb 简介
本书涉及的最后一个数据库扩展是 ADOdb。它是第三方扩展,根据 BSD 许可条款免费提供。大多数 Linux 发行版都将它作为一个软件包提供,其他的可以从这里下载:
http://adodb.sourceforge.net
安装包括将源文件解压到文件系统目录中。解压缩源代码的目录应该包含在该安装的 PHP.ini 参数中。
注意需要将 ADOdb 解包的目录添加到 php.ini 文件中的
include_path PHP 参数中,如果该目录还不存在的话。
ADOdb 模仿微软流行的 ActiveX 数据对象(ADO)框架。它支持异常、遍历数据库游标以及位置绑定和命名绑定。它还支持许多数据库,就像最初的 ADO 框架一样。MySQL、PostgreSQL、SQLite、Firebird、Oracle SQL Server、DB2 和 Sybase 等都受支持。它使用原始的数据库扩展,链接到 PHP 解释器中。如果 PHP 解释器支持 MySQL,就可以使用 ADOdb。换句话说,ADOdb 只是原始驱动程序之上的一个类结构。ADOdb 根据自己的选项设置驱动程序选项,但是数据库的原始驱动程序不是由 ADOdb 的作者 John Lim 提供的。
ADOdb 有两个版本:一个是旧版本,支持 PHP4 和 PHP5;另一个是新版本,只支持 PHP 5。本书中的例子已经用后一版本测试过了。乍一看,这两个版本看起来一模一样,如果需要支持 PHP4 的版本,还得单独下载。当然,PHP4 不支持异常,所以这部分不会和 PHP4 一起工作。
ADOdb 包含两个主要的类:连接类和结果类,即 ADOdb 文档中所称的 set 或 record set 类。
为了更好地解释事情,让我们看看两个脚本中的第一个,将 CSV 文件加载到数据库中的脚本(参见清单 8-5 )。
***清单 8-5。*将 CSV 文件载入数据库
<?php require_once ('adodb5/adodb.inc.php'); require_once ('adodb5/adodb-exceptions.inc.php'); if ($argc != 3) { die("USAGE:script8.5 <table_name> <file name>\n"); } $tname = $argv[1]; $fname = $argv[2]; $rownum = 0; function create_insert_stmt($table, $ncols) { $stmt = "insert into $table values("; foreach (range(1, $ncols) as $i) { $stmt.= "?,"; } $stmt = preg_replace("/,$/", ')', $stmt); return ($stmt); } try { $db = NewADOConnection("mysql"); $db->Connect("localhost", "scott", "tiger", "scott"); $db->autoCommit = 0; $res = $db->Execute("select * from $tname"); $ncols = $res->FieldCount(); $ins = create_insert_stmt($tname, $ncols); $res = $db->Prepare($ins); $fp = new SplFileObject($fname, "r"); $db->BeginTrans(); while ($row = $fp->fgetcsv()) { if (strlen(implode('', $row)) == 0) continue; $db->Execute($res, $row); $rownum++; } $db->CompleteTrans(); print "$rownum rows inserted into $tname.\n"; } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
下面两行将把所有基本类加载到我们的脚本中。后面还会提到其他一些类:
require_once ('adodb5/adodb.inc.php'); require_once ('adodb5/adodb-exceptions.inc.php');
include 的确切位置将取决于 ADOdb 的安装。ADODB 发行版可以在系统上的任何地方解压缩,并且将同样工作,只要它由include_path PHP 指令正确指定。使用NewADOConnection函数创建新的 ADOdb 连接。它不是一个经典的 PHP 类构造函数;它只是一个返回连接类对象的函数。一旦创建了连接对象,就可以使用Connect方法连接到数据库。ADOdb 还包含在连接建立后关闭自动提交的调用。在这个脚本中这是不必要的,因为它控制它的事务——但是关闭自动提交没有任何害处,并且被认为是一个好的编程实践,如前所述。
注意,“Execute”方法属于连接类,而不属于记录集类。ADOdb 还必须执行语句,以便能够描述数据集,确定字段的数量以及它们的名称、类型和长度。使用前面显示的FieldCount方法确定字段的数量。绑定不是必需的;可以将绑定数组传递给执行调用,就像 PDO 的情况一样。再次值得注意的是,execute 方法在连接类中,而不是在结果集类中。Execute 方法其实很强大,支持数组执行。MySQL 不常执行数组,但 Oracle 或 PostgreSQL 经常执行数组,它们专门针对这种方法进行了优化。这是怎么回事?如果我们必须执行下面的 insert 语句:$INS="insert into tab values(?,?)"和一个如下所示的行数组:
$rowbatch = array( array($a1,$a2), array($b1,$b2), array($c1,$c2));
下面的调用实际上会插入所有三行,只执行一次:
$db->Execute($INS,$rowbatch);
像这样插入成批的记录会得到什么?首先,它最小化了网络通信,这仍然是应用中最慢的部分。如果有 100 行要插入,那么插入每一行本身就需要在网络上往返 100 次。如果以 20 行为一组插入行,只需要五次往返。此外,进程间的通信也大大减少了,因此数据库也不那么繁忙了。默认情况下,这个批量绑定特性是禁用的,必须通过设置“bulkBind”连接属性来激活,比如:$db->bulkBind=true。同样,这对于 MySQL 或 SQLite 来说没有多大意义,但是对于其他一些数据库来说却非常方便。
其他一切都是完全标准的,除了CompleteTrans方法,它很聪明,知道如果发生任何错误,它必须回滚事务。也有经典的提交和回滚方法,但是它们需要额外的逻辑来检查数据库错误。这是多余的,因为 ADOdb 会在出错时抛出异常,并且事务会在到达提交点之前死亡。此外,我们在 PostgreSQL 9.0 数据库上使用CompleteTrans时确实遇到了问题,当我们期望提交事务时,它执行了回滚。我们最终选择了CommitTrans()方法。有了 MySQL,就没有这些问题了。
现在,让我们看看我们的报告。SQL 现在已经广为人知;报告中唯一有趣的技巧是描述列和获取行(见清单 8-6 )。
***清单 8-6。*在此插入列表标题。
<?php require_once ('adodb5/adodb.inc.php'); require_once ('adodb5/adodb-exceptions.inc.php'); $ADODB_FETCH_MODE = ADODB_FETCH_NUM; $QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)"; $colnames = array(); $ncols = 0; try { $db = NewADOConnection("mysql"); $db->Connect("localhost", "scott", "tiger", "scott"); $res = $db->Execute($QRY); // Get the number of columns $ncols = $res->FieldCount(); // Get the column names. foreach (range(0, $ncols - 1) as $i) { $info = $res->FetchField($i); $colnames[] = $info->name; } // Print column titles, converted to uppercase. foreach ($colnames as $c) { printf("%-12s", strtoupper($c)); } // Print the boundary printf("\n%s\n", str_repeat("-", 12 * $ncols));
// Print row data while ($row = $res->FetchRow()) { foreach ($row as $r) { printf("%-12s", $r); } print "\n"; } } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
在清单 8-6 的最开始,有一行将$ADODB_FETCH_MODE变量设置为常量ADODB_FETCH_NUM。这是我们之前见过的相同机制的另一个版本。ADOdb 没有像 PDO 那样将返回值的期望形式作为参数传递,而是设置了一个特殊的全局变量,FetchRow方法会依次查询这个变量。就像 PDO 的情况一样,ADOdb 可以返回一个关联数组、一个数字索引数组,或者两者兼有。默认情况下,两者都返回。
描述列的方法是FetchField。它将列号作为参数,并返回具有以下属性的对象:name、type 和 max_length。以下是返回对象的示例:
ADOFieldObject Object ( [name] => ename [max_length] => -1 [type] => varchar )
从这个例子可以看出,max_length 字段不太准确,不应该依赖它。幸运的是,正如我们现在所知道的,PHP 是一种弱类型脚本语言,所以这不是一个大问题。
ADOdb 是一个大型库。它甚至有自己的缓存机制,虽然不如“memcached”包高效,但设置和使用起来非常简单。缓存基于文件系统缓存。结果被写入操作系统文件,以便下次请求查询时,只需从文件中读取结果。如果 web 服务器与数据库在不同的机器上,使用缓存来检索数据确实可以节省一些时间。此外,缓存是多用户的,所以如果几个用户正在执行一个类似的应用,结果文件将被缓存在内存中,性能的提升将是相当显著的。要定义缓存,只需通过设置相应的全局变量来定义缓存目录:
$ADODB_CACHE_DIR="/tmp/adodb_cache";
缓存目录可能会快速增长,应该位于通常由操作系统清理的位置,如/tmp 目录,如果系统是这样配置的,它会在系统重新启动时被完全清理。之后,通过调用CacheExecute方法而不是Execute方法来使用缓存:
$res = $db->CacheExecute(900,$QRY);
第一个参数定义了缓存失效的秒数。如果文件超过给定的秒数,将不会被使用。第二个参数是要执行的查询。这将在目录中创建一个如下所示的文件:
`ls -R /tmp/adodb_cache/ /tmp/adodb_cache/: 03
/tmp/adodb_cache/03: adodb_03b6f957459e47bab0b90eb74ffaea68.cache`
子目录“03”基于查询的哈希值,由内部哈希函数计算。然后还有另一个哈希函数来计算文件名。如果文件名中的查询与脚本中的查询相同,则结果将从文件中检索,而不是从数据库中检索。
禁止绑定变量;只能缓存没有占位符的查询结果。这是一个可以理解的规定,因为查询结果依赖于绑定变量,而这些变量是在运行时提供的,这使得缓存不可能。在频繁变化的数据库中,业务需求要求数据必须完全准确和最新,因此不能使用这种缓存机制,但是对于频繁查询的相对静态的数据,这种机制非常有用。例如,日期不太可能在 24 小时内改变,这使得今天的日期成为缓存的理想候选。
ADOdb 结论
ADOdb 还有许多其他方法和技巧,但是涵盖所有这些超出了本书的范围。我们已经描述了最常用的,但是这个库非常全面。这是迄今为止我们见过的最大的库。它也在许多开源产品中使用,有很好的文档记录,并得到很好的支持。它还支持各种各样的数据库。
使用 Sphinx 进行全文搜索
文本搜索通常被认为是独立于数据库集成的一个主题,但是每个主要的数据库都有一个全文搜索引擎。Sphinx 恰好是 MySQL 数据库的默认全文搜索引擎。然而,在本书中,我将展示如何使用 PostgreSQL 设置和使用 Sphinx 来搜索文本,因为这是我们手头的数据库。
那么,什么是全文搜索,为什么需要它们?大多数现代数据库在正则表达式方面做得很好,所以人们会认为没有必要进行全文搜索。不幸的是,正则表达式的搜索通常不能使用索引,所以它们太慢了,不实用。这就是为什么有一种技术可以创建特殊的文本索引来帮助进行全文搜索。文本索引和附带的软件可以做以下事情:
- 单词搜索。这意味着搜索包含特定单词的记录,如“鸡肉”或“沙拉”
- 短语搜索。这是为用户寻找一个短语,如“鸡肉沙拉”,谁不一定想得到像“鸡翅和土豆沙拉”,这将是基于搜索两个单词,“鸡肉”和“沙拉”返回的东西
- 邻近搜索,也称为“邻近运算符”,检索给定文本字段包含的所有行,例如,单词“hello”和“world”彼此之间的距离不超过三个单词。
- Quorum 搜索,这是一种搜索类型,其中有一个单词列表以及出现在文章中的最少数量的单词,这些单词将被标记为匹配。
- 逻辑运算符:您可以使用 AND、or 和 NOT 运算符组合搜索单词。
所有现代搜索引擎都有这样的能力。当然,不止一种文本搜索软件,既有开源的,也有商业的。开源文本引擎有 Sphinx、Lucene、Xapian 和 Tsearch2,它们各有优缺点。还有一些商业产品,如 Oracle*Text 或 Autonomy Corp .的 IDOL engine。本章的其余部分将专门介绍 Sphinx,这是一个由 Sphinx Technologies 开发的开源文本搜索引擎。公司网址为:[sphinxsearch.com](http://sphinxsearch.com)
安装很简单,该程序通常作为操作系统包提供。如果 Sphinx 还没有本机安装,那么它几乎可以在任何操作系统上构建。PHP 还需要一个额外的模块,通过使用 PECL 实用程序来安装。
Sphinx 由两部分组成:索引器,它构建所需的文本索引;和执行搜索的搜索过程。这两个组件都由名为sphinx.conf的配置文件控制。第一步是实际建立一个索引。Sphinx 索引器根据一些规则读取文档源并构建索引。文档源可以是数据库或产生 XML 的程序(“xmlpipe”)。支持的数据库有 PostgreSQL 和 MySQL。
用于演示 Sphinx 的数据库将是 PostgreSQL,这是一个非常强大的开源数据库。将用于索引的表称为 food_articles,是通过在 Google 上搜索食品文章组装而成的。有 50 篇文章,有作者、我们找到文章的 URL、文本和文章被收集的日期。所有的文章都是在 2011 年 1 月 30 日收集的,所以日期栏有点无聊。然而,这对于本书中的例子是必要的。
文章中的换行符被标记换行符的 HTML 标签替换。由于将数据加载到数据库中所使用的方法,这是一种不可避免的弊端。通过使用全能的“vi”编辑器,关于文章的所有信息被组装成一个大的 CSV 文件。然后将生成的 CSV 文件加载到数据库中。表 8-1 显示了食物 _ 物品表的样子。
文档 id 是主键,是文章的序号。它属于“bigint”类型,可以包含 64 位整数。现在,让我们继续构建我们的文本索引。文本索引是由名为“indexer”的程序构建的,它是 Sphinx 包的一部分。首先,我们需要配置文件,通常命名为sphinx.conf。文件的位置取决于您使用的操作系统。下面是我们相当典型的配置文件,由软件附带的示例文件构造而成:
***清单 8-7。*在此插入列表标题,
`###################################################
data source definition
###################################################
source food { # data source type. mandatory, no default value # known types are mysql, pgsql, mssql, xmlpipe, xmlpipe2, odbc type = pgsql sql_host = localhost sql_user = mgogala sql_pass = qwerty sql_db = mgogala sql_port = 5432
sql_query =
SELECT document_id,
date_part('epoch',published) as publ_date,
article
FROM food_articles;
sql_query_info =
SELECT document_id,
date_part('epoch',published) as publ_date,
article
FROM food_articles
WHERE document_id=$id;
sql_attr_timestamp = publ_date } index food-idx
{ source = food path = /usr/local/var/data/food docinfo = extern charset_type = utf-8 preopen = 1
} indexer
{
mem_limit = 256M
write_buffer = 8M
max_file_field_buffer = 64M
}
searchd
{
listen = 9312
log = /var/log/searchd.log
query_log = /var/log/query.log
read_timeout = 5
max_children = 30
pid_file = /var/run/searchd.pid
max_matches = 1000
seamless_rotate = 1
preopen_indexes = 0
unlink_old = 1
read_buffer = 1M
read_unhinted = 256K
subtree_docs_cache = 64M
subtree_hits_cache = 64M
}`
这个文件的结构相当简单。第一部分定义了数据的来源。每个来源都有自己的名字;这个例子的来源叫做“食物”它首先定义数据库,包括数据库的类型、数据库名称、用户名、密码和端口——所有常见的东西。在源部分要定义的第二件事是如何获取数据。有两个查询:一个获取数据,另一个获取关于特定文档 id 的信息。Sphinx 期望选择列表的第一列是主键。它还期望主键是一个整数。它可以接受 64 位整数。
注意Sphinx 数据源中查询的第一列必须是主键。主键也必须是整数。支持大的 64 位整数。
这就是将我们的 document_id 列定义为“bigint”的原因,尽管事实上只有 50 篇文章。还要注意,没有必要从表中选择所有的列。只选择需要索引的列将有助于节省时间和空间。之后,可以定义可选的属性。属性不是索引列。属性不能用于文本搜索;属性只能用于排序和范围搜索。我们可以要求 2 月份的数据,但由于样本数据的性质,我们不会得到任何数据。属性可以是数字或时间戳。时间戳被定义为从 1970 年 1 月 1 日开始的秒数。日期字段不能直接使用;它们必须被映射成纪元格式。
注意由几行组成的字段,比如我们的 SQL 字段,必须像前面的例子一样使用反斜杠字符。
下一节是索引的定义。它必须包含将用于获取数据的数据源的名称、将写入索引文件的路径以及字符集类型。我们的索引还包含可选的性能参数“preopen”,它指示搜索过程在开始时打开索引,而不是等待第一次搜索。因此,第一次搜索会更快。
之后是索引器的内存选项,该程序用于建立文本索引和执行搜索的搜索过程。搜索过程的重要选项是“max_matches”选项。它定义了搜索过程可以返回的最大命中数。它可以找到更多的匹配,但它只能返回“max_matches”个匹配。在 PHP 中,这是搜索可以返回的最大数组大小。我们的配置文件准备好了;让我们建立一个索引。
`indexer food-idx Sphinx 1.10-beta (r2420) Copyright (c) 2001-2010, Andrew Aksyonoff Copyright (c) 2008-2010, Sphinx Technologies Inc (sphinxsearch.com)
using config file '/usr/local/etc/sphinx.conf'... indexing index 'food-idx'... collected 50 docs, 0.2 MB sorted 0.0 Mhits, 100.0% done total 50 docs, 230431 bytes total 0.038 sec, 5991134 bytes/sec, 1299.98 docs/sec total 3 reads, 0.000 sec, 38.9 kb/call avg, 0.0 msec/call avg total 9 writes, 0.000 sec, 31.6 kb/call avg, 0.0 msec/call avg`
调用程序“索引器”时将索引的名称作为参数;就这么简单。唯一重要的事情是编写配置文件。Sphinx 以其最快的索引构建程序而自豪。它真的非常快,当有许多项目需要索引时,这一点非常重要。创建索引后,必须开始搜索过程,只需从命令行执行命令searchd。在 Windows 中,有一个用于启动搜索过程的菜单。如果一切正常,该过程将如下所示开始:
`searchd Sphinx 1.10-beta (r2420) Copyright (c) 2001-2010, Andrew Aksyonoff Copyright (c) 2008-2010, Sphinx Technologies Inc (sphinxsearch.com)
using config file '/usr/local/etc/sphinx.conf'... listening on all interfaces, port=9312 precaching index 'food-idx' precached 1 indexes in 0.001 sec`
现在,我们可以使用“搜索”程序来测试索引。搜索程序是一个命令行工具,它与搜索进程进行通信,并在命令行上执行传递给它的搜索。
`search "egg & wine" Sphinx 1.10-beta (r2420) Copyright (c) 2001-2010, Andrew Aksyonoff Copyright (c) 2008-2010, Sphinx Technologies Inc (sphinxsearch.com)
using config file '/usr/local/etc/sphinx.conf'... index 'food-idx': query 'egg & wine ': returned 2 matches of 2 total in 0.000 sec
displaying matches: 1. document=9, weight=1579, publ_date=Sun Jan 30 00:00:00 2011 2. document=36, weight=1573, publ_date=Sun Jan 30 00:00:00 2011
words: 1. 'egg': 8 documents, 9 hits 2. 'wine': 20 documents, 65 hits`
这个搜索寻找包含单词“egg”和“wine”的文档。它也给了我们关于它发现的文件的详细信息。现在是时候了解更多关于搜索的细节了:
- 搜索“egg | wine”将返回包含其中任何一个单词的所有文档。“|”字符是“或”逻辑运算符。
- 搜索“鸡蛋和葡萄酒”将返回包含这两个单词的文档。“&”字符是“与”逻辑运算符。
- 正在搜索“!egg”将返回所有不包含单词 egg 的文档。“!”字符是逻辑否定——“非”运算符。如果用于从命令行进行搜索,则必须在搜索文本周围使用单引号,因为感叹号对 shell 有特殊的意义,并且单引号中的字符不会被 shell 进一步解释。这只适用于 Linux 和 Unix shells,不适用于 Windows 命令行。
- 搜索“橄榄油”(双引号是表达式的一部分)将返回包含确切短语“橄榄油”的文档
- 搜索“olive oil”~ 5 将返回包含单词“olive”和“oil”的文档,单词之间的间隔不超过五个单词。
- 搜索“油醋番茄生菜沙拉”/3 将返回包含至少三个给定单词的文档。这就是所谓的“法定人数搜索”
这些是可以在复杂表达式中组合的基本运算。现在是时候写一个 PHP 脚本来搜索文本索引了。由于输出的大小和类型,这个脚本将在浏览器中使用,这意味着我们需要构建一个简单的 HTML 表单,并将输出显示为 HTML 表。这将通过使用两个 PEAR 模块来完成:HTML_Form 和 HTML_Table。HTML_Form 有点过时但是非常简单易用。该脚本如清单 8-8 所示。
***清单 8-8。*搜索文本索引(PHP 脚本)
`<?php /* ADOdb includes */ require_once ('adodb5/adodb.inc.php'); require_once ('adodb5/adodb-exceptions.inc.php'); db = ADONewConnection("postgres8"); $colheaders = array("ID", "AUTHOR", "PUBLISHED", "URL", "ARTICLE");
/* PEAR modules are used for simplicity */ require_once ('HTML/Form.php'); require_once ('HTML/Table.php'); table = new HTML_Table(table->setAutoGrow(true);
/* Set the output table headers */ foreach (range(0, count(i) { i, i]); }
/* Get the given document from the database */ srch = null; if (!empty(_POST['srch'])) { srch = trim($_POST['srch']); }
/* Display a simple form, consisting only of a single textarea field */ echo "
Sphinx Search
"; _SERVER['PHP_SELF'], "POST"); srch, 65, 12); form->display();
/* Stop if there is nothing to search */ if (empty($srch)) exit;
try {
stmt = QRY);
/* Connect to Sphinx "searchd" process /
cl->SetServer("localhost", 9312);
/ Set the extended mode search, for the phrase searches /
cl->SetSortMode(SPH_SORT_ATTR_DESC, "publ_date"); / Execute search and check for problems */
cl->Query(result === false) {
throw new Exception(cl->GetLastError());
} else {
if (cl->GetLastWarning()) {
echo "WARNING: " . $cl->GetLastWarning() . "
";
}
}
/* Get the results and use them to query the database */ foreach (doc => docinfo) { rs = stmt, array(row = table->addRow(row); } /* Display the results */ echo table->toHTML(); } catch(Exception e) { die(e->getMessage()); }`
这个脚本比本章中其他地方的命令行片段更接近程序员通常需要的脚本。这个脚本使用 ADOdb、简单的 web 模块和 Sphinx 搜索引擎组合了数据库。输出显示在图 8-1 中。
***图 8-1。*输出的脚本在清单 8-7 中
该表单用于输入搜索词。当输入术语 search 时,脚本连接到数据库和 Sphinx 搜索引擎,并通过发出以下调用来检索数据:$result=$cl->Query($search)。Sphinx 将解析查询词并返回数据。结果是一个关联数组,如下所示:
`Array ( [error] => [warning] => [status] => 0 [fields] => Array ( [0] => article )
[attrs] => Array
(
[publ_date] => 2
) [matches] => Array
(
[13] => Array
(
[weight] => 2713
[attrs] => Array
(
[publ_date] => 1296363600
)
)
)
[total] => 1 [total_found] => 1 [time] => 0 [words] => Array ( [celery] => Array ( [docs] => 3 [hits] => 4 )
[apple] => Array ( [docs] => 3 [hits] => 5 )
[soup] => Array ( [docs] => 13 [hits] => 30 )
[lentil] => Array ( [docs] => 1 [hits] => 3 )
)
)`
在 id=13 的文档中找到了与我们的搜索词匹配的内容。搜索词是“芹菜&苹果&汤&扁豆”我们在寻找包含所有这些词的文章。匹配被放在$result['matches']数组中,该数组也是一个包含带权重的文档信息的关联数组。权重是通过使用被称为“BM25”的统计函数来计算的,该函数考虑了词频。文档权重越高,匹配越好。文章本身并没有以可见的形式表现出来。为了获得文档 id=13 的行,我们需要访问数据库。这可能不太方便,但是在索引中复制数据库中的数据会浪费空间。当只有 50 条记录时,这并不重要,但是如果有数百万行,复制数据的成本会高得惊人。毕竟,数据已经驻留在数据库中,没有必要再将它存储到索引中。
Sphinx 是令人惊讶的多功能软件。它有实时索引,类似 SQL 查询的语法,它可以做“联合索引”,这意味着一个索引可以指向不同机器上的几个其他索引,它可以做 UTF-8,它可以访问不同的数据库。它的搜索语法非常灵活。Sphinx client 也内置在 MySQL 数据库中,但是如前所述,它实际上也可以与其他数据库一起工作。Sphinx 还可以模拟 MySQL,并用于将目标与 MySQL PHP 扩展、MySQL ODBC 驱动程序甚至 MySQL 命令行客户端连接起来。此外,PHP 客户端得到了很好的维护和记录。
总结
在本章中,我们讨论了以下主题:
- 关系型数据库
- 数据对象
- ADOdb(收养数据库)
- 狮身人面像
MySQL 是一个成熟的关系数据库,有很多关于它的书籍。除了最后一节,本章的所有部分都基于 MySQL 数据库。就本章而言,数据库类型并不特别重要。PDO、ADOdb 和 Sphinx 也可以在 SQLite、PostgreSQL、Oracle 或 DB2 上演示。脚本看起来是一样的。当然,对于 Sphinx,我们需要一个脚本来读取数据库并为除 MySQL 或 PostgreSQL 之外的任何数据库编写 XML 文件,但这不是一个大问题。ADOdb 可以很好地用于这个目的。
本章不是全面的参考;这只是作为一个介绍。所有这些库和软件包都有本章没有描述的选项和可能性。