PHP-秘籍-全-

PHP 秘籍(全)

原文:zh.annas-archive.org/md5/d1b4d02fe0aae6124b8adac684f7cca8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

几乎每一个构建现代 Web 应用程序的开发者对 PHP 都有自己的看法。有些人喜欢这门语言,有些人则厌恶它。大多数人对这门语言及其应用程序的影响都很熟悉。这是因为 PHP 支持超过 75%的已知编写网站的语言。鉴于互联网的庞大规模,这是相当多的 PHP 代码在广泛使用中。¹

诚然,并非所有的 PHP 代码都是优秀的代码。任何写过 PHP 代码的人都见过语言所呈现的好与坏,以及不堪入目的一面。这是一门非常易于操作的语言,这也是其在市场上占主导地位背后强大力量的原因,也是许多工程师在写出质疑代码时犯下的错误。

与完全编译语言不同,这些语言强制执行严格的类型和内存管理,PHP 是一种解释语言,对编程错误宽容度极高。在许多情况下,即使出现严重的编程错误,PHP 也会发出警告,但仍然可以继续执行程序。对于学习新语言的开发者来说,这非常有利,因为无辜的错误不会必然导致应用程序崩溃。但这种宽容的本质在某种程度上是一把双刃剑。因为即使是“糟糕代码”也会运行,许多开发者发布了这些代码,然后被毫无戒心的初学者轻易地复用了。

这本书的目标是帮助你理解如何避免前人犯过的错误,从而防止糟糕代码的重复使用。它还旨在建立任何开发者都可以遵循以解决 PHP 常见问题的模式和示例。本书中的实例将帮助你快速识别和解决复杂问题,无需重新发明轮子,也不会被诱惑复制粘贴通过额外研究发现的“糟糕代码”。

本书适合对象

本书适合任何曾经构建或维护过使用 PHP 构建的 Web 应用程序或网站的工程师。它旨在温和地介绍 PHP 开发中的特定概念。它并非是语言中所有功能的全面概述。理想情况下,您已经尝试过 PHP,构建了一个简单的应用程序,或者至少按照互联网上无数的“Hello, world!”示例之一进行了尝试。

如果你对 PHP 不熟悉,但对其他编程语言很了解,这本书将帮助你顺利将技能转移到新的技术栈上。PHP Cookbook详细说明了如何在 PHP 中完成特定任务。请将每个示例代码块与您最熟悉的语言解决相同问题的方式进行比较;这将有助于深入了解该语言与 PHP 之间的差异。

导读本书

我不指望任何人能一口气读完这本书。相反,这些内容旨在在您构建或设计新应用程序时作为经常参考的资源。无论您选择一次阅读一个章节来掌握一个概念,还是查看一个或多个特定的代码示例来解决特定问题,都完全取决于您。

每个示例都是独立的,并包含您可以在日常工作中利用的完整实现的代码解决方案,以解决类似的问题。每章最后都有一个具体的示例程序,演示了整章讨论的概念,并在您已经阅读的示例基础上进行扩展。

本书首先介绍了任何语言的基本构建模块:变量、运算符和函数。第一章介绍了变量和基本数据处理。第二章通过详细讲解 PHP 本地支持的各种运算符和操作来扩展这一基础。第三章通过建立更高级的函数和创建基本程序将这两个概念结合起来。

接下来的五章介绍了 PHP 的类型系统。第四章涵盖了您想了解的关于 PHP 中字符串处理的一切内容,以及一些您不知道自己不知道的东西。第五章解释了整数和浮点(小数)算术,并介绍了复杂功能所需的进一步构建模块。第六章介绍了 PHP 处理日期、时间和日期时间的方式。第七章介绍了开发人员可能希望将数据分组到列表中的每一种方式。最后,第八章解释了开发人员如何通过引入自己的类和更高级对象来扩展 PHP 的原始类型。

在这些基本构建模块之后,第九章讨论了 PHP 的加密和安全功能,帮助构建真正安全、现代化的应用程序。第十章介绍了 PHP 的文件处理和操作功能。由于文件基本上是基于流构建的,这些知识将通过第十一章更加丰富,该章介绍了 PHP 中更高级的流接口。

接下来的三章涵盖了 Web 开发中的关键概念。第十二章介绍了 PHP 的错误处理和异常接口。第十三章直接将错误与交互式调试和单元测试联系起来。最后,第十四章说明了如何为 PHP 应用程序进行适当的调优,以提高速度、可伸缩性和稳定性。

PHP 本身是开源的,核心语言的大部分起源于社区的扩展系统。接下来的第十五章涵盖了 PHP 的本地扩展(用 C 语言编写并编译以与语言本身并行运行)以及第三方 PHP 包,这些包可以扩展您自己应用的功能。接着,第十六章介绍了数据库及其管理所使用的扩展。

我将第十七章致力于介绍 PHP 8.1 引入的新线程模型以及一般的异步编码。最后,第十八章通过介绍命令行的强大功能及针对命令作为接口编写的应用程序来总结 PHP 的调查。

本书使用的约定

本书使用以下约定:

编程约定

本书中的所有编程示例都是为至少 PHP 8.0.11 编写并测试的(除非另有说明,一些较新的功能需要 8.2 或更新版本)。示例代码在容器化的 Linux 环境中进行了测试,但在裸机 Linux,Microsoft Windows 或 Apple macOS 上同样能很好运行。

排版约定

本书使用以下排版约定:

斜体

表示新术语,URL,电子邮件地址,文件名和文件扩展名。

固定宽度

用于程序列表以及段落内部引用程序元素,例如变量或函数名,数据库,数据类型,环境变量,语句和关键字。

固定宽度加粗

显示用户应直接输入的命令或其他文本。

固定宽度斜体

显示应由用户提供值或根据上下文确定值的文本。

提示

这个元素表示一个提示或建议。

注释

这个元素表示一般注释。

警告

这个元素指示警告或注意事项。

奥莱利在线学习

注释

40 多年来,O’Reilly Media提供技术和商业培训,知识和见解,帮助企业成功。

我们独特的专家和创新者网络通过书籍,文章和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问实时培训课程,深入学习路径,交互式编码环境以及奥莱利和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • CA 95472 Sebastopol

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为这本书设有一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/phpCookbook

欲了解我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 找到我们:https://linkedin.com/company/oreilly-media

关注我们的 Twitter 账号:https://twitter.com/oreillymedia

观看我们的 YouTube 频道:https://www.youtube.com/oreillymedia

致谢

首先要感谢我美妙的妻子,鼓励我踏上撰写又一本书的旅程。老实说,如果没有你的持续爱、支持和鼓励,无论在职业上还是个人生活中,我都不可能达到现在的位置。

还要感谢我的了不起的孩子们,他们忍受了撰写这本手稿所需的长时间工作。我欠你们整个世界!

还要特别感谢 Chris Ling、Michal Špaček、Matthew Turland 和 Kendra Ash,在撰写本书过程中,他们出色的技术评审帮助我保持诚实,并且完善了一些你将阅读到的最关键的配方和主题的覆盖范围。

¹ 截至 2023 年 3 月,W3Techs 使用统计显示 PHP 被 77.5%的所有网站使用。

第一章:变量

灵活应用的基础是变量——程序在不同上下文中提供多种用途的能力。变量是任何编程语言中构建此类灵活性的常见机制。这些命名占位符引用程序希望使用的特定值。这可以是一个数字、一个原始字符串,甚至是一个具有自己属性和方法的更复杂的对象。关键在于变量是程序(以及开发者)引用该值并将其从程序的一部分传递到另一部分的方式。

变量默认情况下不需要设置——定义一个占位符变量而不给它分配任何值是完全合理的。可以将其视为架子上的空盒子,准备好等待圣诞节礼物。您可以轻松找到这个盒子——即变量——但因为里面什么也没有,所以您不能做太多事情。

例如,假设变量称为 $giftbox。如果您现在尝试检查此变量的值,它将是空的,因为它尚未设置。实际上,empty($giftbox) 将返回 true,而 isset($giftbox) 将返回 false。这个盒子既是空的,也还未设置。

注意

重要的是要记住,任何未显式定义(或设置)的变量在 PHP 中将被视为 empty()。一个实际定义(或设置)的变量可以是空的或非空的,具体取决于其值,因为任何评估为 false 的实际值将被视为空。

广义上讲,编程语言可以是强类型或松散类型。强类型语言要求明确标识所有变量、参数和函数返回类型,并强制确保每个值的类型完全符合预期。而松散类型语言(如 PHP)在使用时对值进行动态类型化。例如,开发者可以将整数(如 42)存储在变量中,然后在其他地方将该变量用作字符串(即 "42"),PHP 将在运行时将该变量从整数透明地转换为字符串。

松散类型的优势在于,开发者在定义变量时不需要确定其如何使用,因为解释器可以在运行时很好地识别。其主要缺点在于,当解释器从一种类型强制转换为另一种类型时,某些值将如何处理并不总是清楚。

PHP 以松散类型语言而闻名。这使得该语言与众不同,开发者在创建或调用特定变量时无需明确标识其类型。PHP 解释器会在变量使用时识别正确的类型,并且在许多情况下,会在运行时将变量透明地转换为不同的类型。表格 1-1 展示了各种表达式,截至 PHP 8.0 版本,无论其底层类型如何,都被评估为“空”。

表格 1-1 PHP 空表达式

表达式 empty($x)
$x = "" true
$x = null true
$x = [] true
$x = false true
$x = 0 true
$x = "0" true

注意,其中一些表达式虽然不是真正的空值,但在 PHP 中被视为空值。在一般对话中,它们被视为falsey,因为它们被视为等同于false,尽管它们与false并非完全相同。因此,在应用程序中明确检查预期值如nullfalse0比依赖于像empty()这样的语言结构更为重要。在这种情况下,您可能希望检查变量是否为空,并对已知的固定值进行显式的等式检查。¹

本章的示例介绍了 PHP 中变量定义、管理和利用的基础知识。

1.1 定义常量

问题

您希望在程序中定义一个特定的变量,使其具有固定的值,不受任何其他代码的突变或更改影响。

解决方案

下面的代码块使用define()来显式定义一个全局作用域常量的值,其他代码无法更改它:

if (!defined('MY_CONSTANT')) {
    define('MY_CONSTANT', 5);
}

作为替代方法,以下代码块使用类内的const指令来定义一个仅在该类内部作用域中有效的常量:²

class MyClass
{
    const MY_CONSTANT = 5;
}

讨论

如果在应用程序中定义了一个常量,函数defined()将返回true,告诉您可以直接在代码中访问该常量。如果常量尚未定义,PHP 会尝试猜测您的意图,并将常量引用转换为字符串字面量。

提示

将常量名称全部大写并非必需。但是,PHP 基础编码规范(PHP 标准推荐 1,或 PSR-1)中定义的这一约定被 PHP 框架互操作性组(PHP-FIG)强烈推荐。Basic Coding Standard (PHP Standard Recommendation 1, or PSR-1)中有详细的标准。

例如,以下代码块只有在定义了常量时才会将MY_CONSTANT的值赋给变量$x。在 PHP 8.0 之前,未定义的常量会导致$x保存字面字符串"MY_CONSTANT"

$x = MY_CONSTANT;

如果MY_CONSTANT的预期值不是字符串,则 PHP 的回退行为会导致应用程序出现意外副作用。解释器不一定会崩溃,但在期望整数的位置出现"MY_​CON⁠STANT"将会引起问题。从 PHP 8.0 开始,引用未定义的常量会导致致命错误。

解决方案示例演示了定义常量的两种模式:define()const。使用 define() 将创建一个全局常量,在应用程序的任何地方都可以通过常量的名称直接访问。在类定义中使用 const 定义常量将常量作用域限定为该类。与在第一个解决方案中引用 MY_CONSTANT 不同,类作用域的常量被引用为 MyClass::MY_CONSTANT

警告

PHP 定义了几个默认常量,不能被用户代码覆盖。总体上,常量是固定的,不能被修改或替换,因此始终在尝试定义常量之前检查它是否已定义。尝试重新定义常量将导致通知。有关处理错误和通知的更多信息,请参见第 12 章。

类常量默认为公共可见,这意味着应用程序中可以引用 MyClass 的任何代码也可以引用其公共常量。但是,自 PHP 7.1.0 起,可以对类常量应用可见性修饰符,并将其私有化为类的实例。

参见

PHP 中的常量文档defined()define()类常量 的文档。

1.2 创建变量变量

问题

您希望动态引用特定变量,而不知道程序将需要哪个相关变量。

解决方案

PHP 的变量语法以 $ 开头,后跟要引用的变量名称。您可以使变量名称本身成为一个变量。以下程序将通过使用变量变量打印 #f00

$red = '#f00';
$color = 'red';

echo $$color;

讨论

当 PHP 解析您的代码时,它将看到以 $ 字符开头的内容作为变量的标识,并且紧随其后的文本表示该变量的名称。在解决方案示例中,该文本本身就是一个变量。PHP 将从右向左评估变量变量,将一个评估的结果作为左侧评估使用的名称,然后再将任何数据打印到屏幕上。

换句话说,示例 1-1 显示了两行代码,在功能上是等效的,只是第二行使用大括号显式标识首先评估的代码。

Example 1-1. 评估变量变量
$$color;
${$color};

最右边的 $color 首先评估为文字 "red",这反过来意味着 $$color$red 最终引用相同的值。引入大括号作为显式评估分隔符,可以提供更复杂的应用程序。

示例 1-2 假设应用程序希望出于搜索引擎优化(SEO)目的对标题进行 A/B 测试。 市场团队提供了两个选项,开发人员希望针对不同的访问者返回不同的标题—但当访问者返回网站时返回相同的标题。 您可以通过利用访问者的 IP 地址并创建一个变量变量来实现这一目的,选择基于访问者 IP 地址的标题。

示例 1-2. A/B 测试标题
$headline0 = 'Ten Tips for Writing Great Headlines';
$headline1 = 'The Step-by-Step to Writing Powerful Headlines';

echo ${'headline' . (crc32($_SERVER['REMOTE_ADDR']) % 2)};

在上述示例中,crc32() 函数是一个方便的实用工具,用于以确定性方式计算给定字符串的 32 位校验和—它将字符串转换为整数。 % 操作符对结果整数执行模运算,如果校验和为偶数则返回 0,为奇数则返回 1。然后将结果连接到你的动态变量 headline 中,以使函数能够选择其中一个标题。

注意

$_SERVER 数组是一个系统定义的超全局变量,包含有关运行代码的服务器以及触发 PHP 运行的传入请求的有用信息。 这个特定数组的确切内容会因服务器而异,特别是根据您在 PHP 前端使用的是 NGINX 还是 Apache HTTP 服务器(或其他 Web 服务器),但它通常包含有用的信息,如请求头、请求路径和当前执行脚本的文件名。

示例 1-3 逐行展示了 crc32() 的使用,进一步说明了如何利用像 IP 地址这样的用户相关值来确定性地标识用于 SEO 目的的标题。

示例 1-3. IP 地址检验和演练
$_SERVER['REMOTE_ADDR'] = '127.0.0.1'; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
crc32('127.0.0.1') = 3619153832; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
3619153832 % 2 = 0; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
'headline' . 0 = 'headline0' ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
${'headline0'} = 'Ten Tips for Writing Great Headlines'; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

IP 地址从 $_SERVER 超全局变量中提取。 还请注意,仅在通过 Web 服务器而不是通过 CLI 使用 PHP 时,REMOTE_ADDR 键才会存在。

2

crc32() 将字符串 IP 地址转换为整数校验和。

3

模运算符 (%) 确定校验和是偶数还是奇数。

4

此模运算的结果附加到 headline 中。

5

最终字符串 headline0 被用作变量变量,用于标识正确的 SEO 标题值。

甚至可以嵌套多层次的可变变量。使用三个 $ 字符,例如 $$$name,同样有效,就像 $$${*some_function*()} 一样。在变量名中限制变化水平既有助于代码审查的简化,也有助于一般维护。变量变量的使用案例本来就很少见,但如果出现问题,多层间接将使您的代码难以跟踪、理解、测试或维护。

另请参见

关于可变变量的文档。

1.3 在原地交换变量

问题

你想要在不定义任何额外变量的情况下交换两个变量存储的值。

解决方案

下面的代码块使用 list() 语言结构来直接在原地重新分配变量的值:

list($blue, $green) = array($green, $blue);

更简洁版本的前述解决方案是同时使用 PHP 7.1 提供的短列表和短数组语法,如下所示:

[$blue, $green] = [$green, $blue];

讨论

PHP 中的 list 关键字并不是指一个函数,虽然看起来像一个。它是一个语言结构,用于将值分配给一组变量而不是一次只给一个变量。这使开发人员可以一次性从另一个类似列表的值集合(如数组)中设置多个变量。它还允许将数组解构为单独的变量。

现代 PHP 利用方括号([])来简化数组语法,允许更简洁的数组字面量。写 [1, 4, 5] 在功能上等同于 array(1, 4, 5),但在使用的上下文中更清晰一些。

注意

list 一样,array 关键字在 PHP 中指的是一个语言结构。语言结构是硬编码到语言中的关键字,是使系统工作的关键字。像 ifelseecho 这样的关键字很容易与用户代码区分开来。像 listarrayexit 这样的语言结构看起来像函数,但像关键字样的结构内建于语言中,与典型函数的行为稍有不同。PHP 手册的保留关键字列表更好地说明了现有的结构,并交叉引用了每个关键字在实践中的使用方式。

自 PHP 7.1 起,开发人员可以使用相同的短方括号语法来替换 list() 的使用,创建更简洁和可读的代码。考虑到解决此问题的方案是将数组的值分配给一组变量数组,使用赋值运算符 (=) 两边类似的语法是合理的并且可以澄清您的意图。

解决方案示例明确交换了变量$green$blue中存储的值。这是工程师在部署应用程序时可能会做的事情,以从一个 API 版本切换到另一个版本。滚动部署通常将当前的生产环境称为绿色部署,将新的、潜在的替代环境称为蓝色,指示负载均衡器和其他依赖应用程序从绿色/蓝色切换并验证连接和功能,然后确认部署是否健康。

在一个更详细的示例中(示例 1-4),假设应用程序消耗一个以部署日期为前缀的 API。该应用程序跟踪正在使用的 API 版本($green),并尝试切换到新环境以验证连接。如果连接检查失败,应用程序将自动切换回旧环境。

示例 1-4. 蓝/绿环境切换
$green = 'https://2021_11.api.application.example/v1';
$blue = 'https://2021_12.api.application.example/v1';

[$green, $blue] = [$blue, $green];

if (connection_fails(check_api($green))) {
    [$green, $blue] = [$blue, $green];
}

list()结构也可以用来从任意元素组中提取特定值。示例 1-5 说明了如何将作为数组存储的地址,在不同上下文中提取所需的特定值。

示例 1-5. 使用list()来提取数组元素
$address = ['123 S Main St.', 'Anywhere', 'NY', '10001', 'USA'];

// Extracting each element as named variables
[$street, $city, $state, $zip, $country] = $address;

// Extracting and naming only the city
[,,$state,,] = $address;

// Extracting only the country
[,,,,$country] = $address;

前面示例中的每次提取都是独立的,只设置了必要的变量。³ 对于像这样的简单示例,不需要担心提取每个元素并设置一个变量,但对于操作数据规模显著较大的更复杂应用程序,设置不必要的变量可能会导致性能问题。虽然list()是一个用于解构类似数组的集合的强大工具,但它只适用于前面示例中讨论的简单情况。

参见

list()array()的文档,以及 PHP RFC 关于short list() syntax

¹ 相等运算符在 Recipe 2.3 中有所涵盖,其中提供了一个示例和对相等性检查的深入讨论。

² 在第八章中了解更多关于类和对象的内容。

³ 在本章介绍中回顾一下,变量引用如果没有明确设置将会被评估为“空”。这意味着你只能设置你需要使用的值和变量。

第二章:运算符

虽然 第一章 介绍了 PHP 的基础构建模块——用于存储任意值的变量,但是这些构建模块如果没有某种粘合剂来将它们连接在一起将毫无用处。这个粘合剂就是 PHP 确立的 运算符 集合。运算符是告诉 PHP 如何处理特定值的方式——具体来说是如何将一个或多个值转换为新的、离散的值。

在几乎所有情况下,PHP 中的运算符都由单个字符或重复使用该字符来表示。在少数情况下,运算符也可以用文字的英文单词来表示,这有助于消除运算符试图实现的功能的歧义。

本书并不试图覆盖 PHP 利用的每一个运算符;关于每个运算符的详尽解释,请参阅PHP 手册本身。相反,接下来的几节将介绍一些最重要的逻辑、位和比较运算符,然后深入探讨更具体的问题、解决方案和示例。

逻辑运算符

逻辑操作 是 PHP 中创建真值表并定义基本的与/或/非分组条件的组成部分。表 2-1 枚举了 PHP 支持的所有基于字符的逻辑运算符。

表 2-1. 逻辑运算符

表达式 运算符名称 结果 示例
$x && $y and 如果 $x$y 都为 true,则为 true true && true == true
$x || $y or 如果 $x$y 有一个为 true,则为 true true || false == true
!\(x | not | 如果 `\)xfalse,则为 true`(反之亦然) !true == false

逻辑运算符 &&|| 有其英文单词对应形式:分别是 andor。语句 ($x and $y) 在功能上等同于 ($x && $y)。单词 or 同样可以用来替代 || 运算符,而不会改变表达式的功能。

单词 xor 也可以用来表示 PHP 中的特殊 异或 运算符,如果表达式中的两个值中有一个为 true,则结果为 true,但当两者都为 true 时结果为 false。不幸的是,逻辑异或操作在 PHP 中没有字符等效项。

位运算符

PHP 支持对整数进行特定位的操作,这一特性使得该语言非常灵活。支持位运算意味着 PHP 不仅仅局限于 Web 应用程序,而是可以轻松地在二进制文件和数据结构上进行操作!值得一提的是,这些运算符与 andorxor 在术语上看起来有些相似,因此将它们放在前面逻辑运算符的同一节中是合理的。

而逻辑运算符基于两个整数值之间的比较返回truefalse,位运算符实际上对整数执行位运算,并返回提供的整数或整数的完整计算结果。关于这可以如何有用的具体示例,请跳转至配方 2.6。

表 2-2 展示了 PHP 中各种位运算符的功能,以及它们在简单整数上的快速示例。

表 2-2. 位运算符

表达式 运算符名称 结果 示例
$x & $y 返回同时在$x$y中设置的位 5 & 1 == 1
$x | $y 返回在$x$y中设置的位 4 | 1 == 5
$x ^ $y 异或 返回仅在$x$y中设置的位 5 ^ 3 == 6
~ $x | 非 | 反转$x中被设置的位 ~ 4 == -5
$x << $y 左移 $x的位向左移动$y 4 << 2 == 16
$x >> $y 右移 $x的位向右移动$y 4 >> 2 == 1

在 PHP 中,您可以拥有的最大整数取决于运行应用程序的处理器大小。在任何情况下,常量PHP_INT_MAX将告诉您整数可以有多大——在 32 位机器上为 2147483647,在 64 位机器上为 9223372036854775807。在这两种情况下,这个数字以二进制表示,长长的是比位大小少一位的 1。在 32 位机器上,2147483647 由 31 个 1 表示。前导位(默认为 0)用于标识整数的符号。如果位为0,整数为正数;如果位为1,整数为负数。

在任何机器上,数字 4 的二进制表示为100,左边有足够的 0 填充处理器的位大小。在 32 位系统上,这将是 29 个 0。要使整数为负数,您应该将其表示为 1 后跟 28 个 0,再跟100

简单起见,考虑一个 16 位系统。整数 4 表示为0000000000000100。同样,负数 4 表示为1000000000000100。如果在 16 位系统中对正数 4 应用位非运算符(~),所有的 0 将变成 1,反之亦然。这将把你的数字变成1111111111111011,在 16 位系统中表示为−5。

比较运算符

任何编程语言的核心是语言根据特定条件进行分支控制的能力。在 PHP 中,许多分支逻辑是通过比较两个或更多值来控制的。这是由 PHP 提供的比较运算符,提供了用于构建复杂应用程序的大多数高级分支功能。

表 2-3 列出了被认为是最重要的 PHP 标量比较运算符。其他运算符(大于、小于和变体)在编程语言中有些标准,并且对本章中的任何配方都不是必需的。

表 2-3. 比较运算符

表达式 操作 结果
$x == $y 相等 如果两个值在强制转换为相同类型后相同则返回true
$x === $y 相同 如果两个值相同且类型相同则返回true
$x <=> $y 太空船 如果两个值相等则返回0,如果$x大则返回1,如果$y大则返回-1

在处理对象时,相等性和身份运算符的工作方式略有不同。如果两个对象具有相同的内部结构(相同的属性和值)并且是相同类型(类),则认为它们相等(==)。仅当它们是对同一个类实例的引用时,才认为对象是相同的(===)。这些要求比比较标量值的要求更严格。

类型转换

虽然类型的名称在形式上不是运算符,但您可以使用它来将一个值明确转换为该类型。只需在值之前的括号中写入类型的名称,即可强制转换。示例 2-1 在使用该值之前将一个简单的整数值转换为各种其他类型。

示例 2-1. 将值转换为其他类型
$value = 1;

$bool = (bool) $value;
$float = (float) $value;
$string = (string) $value;

var_dump([$bool, $float, $string]);

// array(3) {
//   [0]=>
//   bool(true)
//   [1]=>
//   float(1)
//   [2]=>
//   string(1) "1"
// }

PHP 支持以下类型转换:

(整数)

转换为整数

(布尔)

转换为布尔

(浮点数)

转换为浮点数

(字符串)

转换为字符串

(数组)

转换为数组

(对象)

转换为对象

也可以使用(整数)作为(int)的别名,(布尔)作为(bool)的别名,(实数)(双精度)作为(float)的别名,(二进制)作为(字符串)的别名。这些别名将执行与前面列表中相同的类型转换,但由于它们不使用您要转换的类型的名称,因此不建议使用这种方法。

本章中的配方介绍了如何利用 PHP 最重要的比较和逻辑运算符。

2.1 使用三元运算符代替 if-else 块

问题

您希望在一行代码中提供一个二选一的分支条件,以将特定值分配给一个变量。

解决方案

使用三元运算符(*a* ? *b* : *c*)允许在一个语句中嵌套一个二选一条件和两个可能的分支值。以下示例展示了如何定义一个变量,其值来自$_GET超全局变量,并在为空时返回默认值:

$username = isset($_GET['username']) ? $_GET['username'] : 'default';

讨论

三元表达式有三个参数,并从左到右进行评估,检查最左侧语句的真值(不考虑表达式中涉及的类型)并返回true时的下一个值,或者false时的最终值。您可以通过以下图示来可视化这种逻辑流程:

$_value_ = (_expression to evaluate_) ? (if true) : (if false);

三元模式是在检查系统值或来自 Web 请求的参数时返回默认值的简单方法(这些存储在$_GET$_POST超全局变量中)。它还是基于特定函数调用的页面模板中切换逻辑的强大方法。

以下示例假设一个 Web 应用程序,通过姓名欢迎已登录用户(通过调用is_logged_in()检查其认证状态)或者欢迎未经验证的访客。由于此示例直接编码到 Web 页面的 HTML 标记中,使用更长的if/else语句将不合适:

<h1>Welcome, <?php echo is_logged_in() ? $_SESSION['user'] : 'Guest'; ?>!</h1>

如果正在检查的值既是真值(在强制转换为布尔值时评估为true)又是您默认希望的值,则可以简化三元操作。解决方案示例检查用户名是否已设置,并在这种情况下将该值分配给给定变量。由于非空字符串会评估为true,因此可以将解决方案缩短为以下形式:

$username = $_GET['username'] ?: 'default';

当将一个三元缩短为简单的*a* ?: *c*格式时,PHP 将评估表达式以检查*a*是否为布尔值。如果为真,则 PHP 仅返回表达式本身。如果为假,则 PHP 返回替代值*c*

注意

PHP 类似于空值比较地比较真值,正如在第一章中所讨论的那样。已设置的字符串(非空或null)、非零整数和非空数组通常都被认为是真值,这意味着它们在布尔转换时会评估为true。您可以在PHP 手册类型比较部分中了解更多关于类型混合和等价判定的信息。

三元运算符是一种高级的比较运算符,尽管它提供了简洁的代码,但有时会被过度使用,以至于创建的逻辑过于复杂难以理解。考虑嵌套一个三元操作在另一个中的示例 2-2。

示例 2-2. 嵌套三元表达式
$val = isset($_GET['username']) ? $_GET['username'] : (isset($_GET['userid'])
       ? $_GET['user_id'] : null);

此示例应重写为一个简单的if/else语句,以提供更清晰的代码分支信息。代码功能上没有问题,但嵌套的三元操作可能难以阅读或理解,并且往往会在日后导致逻辑错误。前面的三元操作可以重写如示例 2-3 所示:

示例 2-3. 多个if/else语句
if (isset($_GET['username'])) {
    $val = $_GET['username'];
} elseif (isset($_GET['userid'])) {
    $val = $_GET['userid'];
} else {
    $val = null;
}

尽管示例 2-3 比示例 2-2 更冗长,但你可以更轻松地跟踪逻辑分支的位置。代码也更易于维护,因为可以在必要时添加新的逻辑分支。向示例 2-2 添加另一个逻辑分支将进一步复杂化已经复杂的三元操作符,并使长期维护程序变得更加困难。

参见

文档关于三元运算符及其变体。

2.2 合并潜在的空值

问题

如果要仅在变量设置且不为null时为其分配特定值,否则使用静态默认值。

解决方案

使用空值合并运算符(??)如下,仅在设置并且不为null时使用第一个值:

$username = $_GET['username'] ?? 'not logged in';

讨论

PHP 的空值合并运算符是 PHP 7.0 引入的新功能。它被称为语法糖,用于替换 PHP 的三元运算符简写形式 ?:,详见配方 2.1。

注意

语法糖 是指在代码中执行常见且冗长操作的简写。语言的开发人员引入这些功能是为了节省击键,并通过更简单、更简洁的语法呈现常见但经常重复的代码块。

下面两行代码在功能上是等效的,但三元形式在评估表达式未定义时将触发一个通知:

$a = $b ?: $c;
$a = $b ?? $c;

尽管这两个前述示例在功能上相同,但在评估值($b)未定义时,它们的行为有显著差异。使用空值合并运算符,一切都很完美。而使用三元简写形式,在执行过程中,PHP 会触发一个通知,表明在返回回退值之前该值未定义。

对于离散变量,这些运算符的不同功能并不完全明显,但当评估组件是一个索引数组时,潜在影响就显得更加明显了。假设,与离散变量不同,你正在尝试从超全局变量 $_GET 中提取一个元素,该变量保存请求参数。在下面的例子中,三元运算符和空值合并运算符都将返回回退值,但三元版本会抱怨未定义的索引:

$username = $_GET['username'] ?? 'anonymous';
$username = $_GET['username'] ?: 'anonymous'; // Notice: undefined index ...

如果在执行过程中抑制了错误和通知,¹ 那么在这两种操作符选项之间就没有功能上的区别。然而,最佳实践是避免编写会触发错误或通知的代码,因为这些可能会在生产中意外引发警报,或者可能会填充系统日志,并使查找代码的真正问题更加困难。虽然简写的三元运算符非常有用,但空值合并运算符是专门用于这种操作的,几乎总是应该使用它。

参见

新操作符的公告,当它首次添加到 PHP 7.0 中时

2.3 比较相同值

问题

您想要比较两个相同类型的值以确保它们是相同的。

解决方案

使用三个等号来比较值,而不会动态转换它们的类型:

if ($a === $b) {
    // ...
}

讨论

在 PHP 中,等号有三个功能。单个等号(=)用于赋值,即设置变量的值。两个等号(==)在表达式中用于确定两侧的值是否相等。表 2-4 展示了由于 PHP 在评估语句时将一个类型强制转换为另一个类型,因此某些值被视为相等。最后,三个等号(===)在表达式中用于确定两侧的值是否完全相同

表 2-4. PHP 中的值相等性

表达式 结果 解释
0 == "a" false (仅适用于 PHP 8.0 及以上)字符串"a"被转换为整数,这意味着它被转换为0
"1" == "01" true 表达式两侧都被转换为整数,1 == 1
100 = "1e2" true 表达式右侧被评估为100的指数形式,并转换为整数。
注意

表 2-4 中的第一个示例在 PHP 版本低于 8.0 时评估为true。在这些早期版本中,比较字符串(或数值字符串)与数字会首先将字符串转换为数字(在本例中,将"a"转换为0)。PHP 8.0 中此行为已更改,现在只有数值字符串会被转换为数字,因此第一个表达式的结果现在是false

PHP 在运行时动态转换类型的能力可能很有用,但在某些情况下,这并不是您希望发生的。布尔字面值false被一些方法返回以表示错误或失败,而整数0可能是函数的有效返回。考虑函数示例 2-4,它返回特定类别书籍的计数,或者如果连接到包含这些数据的数据库失败,则返回false

示例 2-4. 计算数据库中的项或返回false
function count_books_of_type($category)
{
    $sql = "SELECT COUNT(*) FROM books WHERE category = :category";

    try {
        $dbh = new PDO(DB_CONNECTION_STRING, DB_LOGIN, DB_PASS);
        $statement = $dbh->prepare($sql);

        $statement->execute(array(':category' => $category));
        return $statement->fetchColumn();
    } catch (PDOException $e) {
        return false;
    }
}

如果示例 2-4 中的一切按预期运行,那么代码将返回一个特定类别中书籍数量的整数计数。示例 2-5 可能利用此函数在网页上打印标题。

示例 2-5. 使用与数据库绑定函数的结果
$books_found = count_books_of_type('fiction');

switch ($books_found) {
    case 0:
        echo 'No fiction books found';
        break;
    case 1:
        echo 'Found one fiction book';
        break;
    default:
        echo 'Found ' . $books_found . ' fiction books';
}

在内部,PHP 的switch语句使用宽松类型比较(我们的==操作符)。如果count_books_of_type()返回false而不是实际结果,此switch语句将打印出未找到虚构书籍,而不是报告错误。在这种特定用例中,这可能是可接受的行为——但是当您的应用程序需要反映false0之间的实质性差异时,宽松相等比较是不够的。

PHP 允许使用三个等号(===)来检查评估中的两个值是否相同——即它们是相同的值和相同的类型。即使整数5和字符串"5"具有相同的值,评估5 === "5"将导致false,因为这两个值不是相同的类型。因此,虽然0 == false评估为true0 === false将始终评估为false

警告

在处理对象时,确定两个值是否相同变得更加复杂,无论是使用自定义类定义还是 PHP 提供的类。对于两个对象$obj1$obj2,只有当它们实际上是同一个类的实例时,它们才会被评估为相同。有关对象实例化和类的更多信息,请参阅第八章。

另请参阅

PHP 关于比较运算符的文档。

2.4 使用宇宙飞船操作符排序值

问题

您想要提供一个自定义排序函数,以通过PHP 的原生usort()对任意对象列表进行排序。

解决方案

假设您要按照对象列表的多个属性进行排序,可以使用 PHP 的宇宙飞船操作符(<=>)定义自定义排序函数,并将其作为回调提供给usort()

考虑为您的应用程序中的人员提供以下类定义,允许只使用名字和姓氏创建记录:

class Person {
    public $firstName;
    public $lastName;

    public function __construct($first, $last)
    {
        $this->firstName = $first;
        $this->lastName = $last;
    }
};

您可以使用此类创建人员列表,例如美国总统,并依次将每个人添加到您的列表中,就像示例 2-6 中所示。

示例 2-6. 向列表添加多个对象实例
$presidents = [];

$presidents[] = new Person('George', 'Washington');
$presidents[] = new Person('John', 'Adams');
$presidents[] = new Person('Thomas', 'Jefferson');
// ...
$presidents[] = new Person('Barack', 'Obama');
$presidents[] = new Person('Donald', 'Trump');
$presidents[] = new Person('Joseph', 'Biden');

假设您想按姓氏首先,然后按名字排序数据,您可以利用宇宙飞船操作符来实现,如示例 2-7 所示。

示例 2-7. 使用宇宙飞船操作符对总统进行排序
function presidential_sorter($left, $right)
{
    return [$left->lastName, $left->firstName]
        <=>
        [$right->lastName, $right->firstName];
}

usort($presidents, 'presidential_sorter');

调用usort()之前的结果是,$presidents数组将正确地原地排序并准备就绪。

讨论

宇宙飞船操作符是 PHP 7.0 的特殊添加,有助于确定其两侧值之间的关系:

  • 如果第一个值小于第二个值,则表达式评估为-1

  • 如果第一个值大于第二个值,则表达式评估为+1

  • 如果两个值相同,则表达式评估为0

注意

类似于 PHP 的等号操作符,太空船操作符会尝试将比较中每个值的类型强制转换为相同类型。支持一个值为数字,另一个值为字符串,并获得有效结果是可能的。在使用这种特殊操作符进行类型强制转换时,需要注意风险。

太空船操作符最简单的用法是将简单类型相互比较,这使得对简单数组或原始值列表(如字符、整数、浮点数或日期)进行排序变得容易。如果使用usort(),则需要像下面这样的排序函数:

function sorter($a, $b) {
    return ($a < $b) ? -1 : (($a > $b) ? 1 : 0);
}

太空船操作符通过完全用return $a <=> $b替换前面代码中的嵌套三元运算符的return语句,但不改变排序函数的功能。

更复杂的示例,如解决方案中用于基于自定义对象定义的多个属性进行排序,将需要相当冗长的排序函数定义。太空船操作符简化了比较逻辑,使开发人员能够在一行代码中指定复杂的逻辑。

参见

PHP 太空船操作符的原始 RFC

2.5 使用操作符抑制诊断错误

问题

您希望显式忽略或抑制应用程序中特定表达式触发的错误。

解决方案

在表达式前加上@操作符可临时将错误报告级别设置为 0,用于屏蔽与直接尝试打开丢失文件相关的错误,例如下面的示例:

$fp = @fopen('file_that_does_not_exist.txt', 'r');

讨论

解决方案示例尝试打开不存在的文件file_that_does_not_exist.txt进行读取。在正常操作中,调用fopen()会因为文件不存在而返回false,同时为了诊断问题会发出 PHP 警告。在表达式前加上@操作符不会改变返回值,但会完全抑制发出的警告。

警告

@操作符会抑制应用于其之后的代码行的错误报告。如果开发人员试图在include语句上抑制错误,他们将很容易隐藏由于包含的文件不存在(或访问控制不当)而导致的任何警告、通知或错误。抑制还会应用于包含文件内的所有代码行,这意味着包含代码中的任何错误(包括语法相关或其他)都将被忽略。因此,虽然@include('some-file.php')是完全有效的代码,但应避免在include语句上抑制错误!

这种特定的操作符在抑制文件访问操作中的错误或警告时非常有用(如示例中的解决方案)。在数组访问操作中抑制通知同样有用,比如下面的情况,其中请求中可能未设置特定的GET参数:

$filename = @$_GET['filename'];

如果设置了请求的filename查询参数,则$filename变量将被设置为其值。否则,它将是字面量null。如果开发人员省略@操作符,则$filename的值仍将是null,但 PHP 将发出通知,指出数组中不存在filename索引。

PHP 8.0起,此运算符将不再抑制 PHP 中的致命错误,否则会停止脚本执行。

参见

PHP 官方文档中关于错误控制操作符的详细信息。

2.6 比较整数中的位

问题

您希望在应用程序中使用简单的标志来标识状态和行为,其中一个成员可能应用了多个标志。

解决方案

使用位掩码来指定可用的标志,并在后续标志上使用位操作符来确定哪些标志已设置。以下示例通过使用每个标志的整数二进制表示来定义四个离散标志,并将它们组合以指示同时设置了多个标志。然后使用 PHP 的位操作符来确定哪个标志被设置,并执行相应的条件逻辑分支:

const FLAG_A = 0b0001; // 1
const FLAG_B = 0b0010; // 2
const FLAG_C = 0b0100; // 4
const FLAG_D = 0b1000; // 8

// Set a composite flag for an application
$application = FLAG_A | FLAG_B; // 0b0011 or 3

// Set a composite flag for a user
$user = FLAG_B | FLAG_C | FLAG_D; // 0b1110 or 14

// Switch based on the user's applied flags
if ($user & FLAG_B) {
    // ...
} else {
    // ...
}

讨论

位掩码通过将每个标志配置为常量整数的 2 的幂来构造。这样做的好处是在二进制表示中仅设置一个位,从而通过设置哪些位来识别组合标志。在解决方案示例中,每个标志都明确写成二进制数字,以说明哪些位被设置为(1),哪些位未设置为(0),在行尾还有同一个数字的整数表示。

我们例子中的FLAG_B是整数 2,其二进制表示为0010(第三位被设置)。同样,FLAG_C是整数 4,其二进制表示为0100(第二位被设置)。要指定两个标志都被设置,您将两者相加以设置第二位和第三位:0110或整数 6。

对于这个特定的例子,加法是一个容易记住的模型,但并不完全符合实际情况。要组合标志,您只需组合已设置的位,而不一定要将它们加在一起。将FLAG_A与其自身组合应该仅产生仅有的 FLAG_A;将整数表示(1)与自身相加会完全改变标志的含义。

不要使用加法,而要使用位操作符|)和&)来同时组合位并筛选分配的标志。将两个标志组合在一起需要使用|运算符创建一个新的整数,其中包含要使用的任一标志中设置的位。请参考表 2-5 以创建FLAG_A | FLAG_C的组合。

表 2-5. 使用位或组合的复合二进制标志

标志 二进制表示 整数表示
FLAG_A 0001 1
FLAG_C 0100 4
FLAG_A &#124; FLAG_C 0101 5

然后需要使用&运算符比较复合标志与您的定义,它返回一个新数字,该数字在操作的两侧都设置了位。将标志与自身比较将始终返回 1,在条件检查中强制类型转换为true。比较具有任何相同位的两个值将返回一个大于 0 的值,这在条件检查中也将被强制类型转换为true。考虑在表 2-6 中评估FLAG_A & FLAG_C的简单情况。

表 2-6. 使用按位组合的复合二进制标志

标志 二进制表示 整数表示
FLAG_A 0001 1
FLAG_C 0100 4
FLAG_A & FLAG_C 0000 0

您可以构建复合值然后将其与您的标志集进行比较,而不是将基元标志相互比较。以下示例演示了用于发布新闻文章的内容管理系统的基于角色的访问控制。用户可以查看文章、创建文章、编辑文章或删除文章;他们的访问级别由程序本身和授予其用户帐户的权限确定:

const VIEW_ARTICLES   = 0b0001;
const CREATE_ARTICLES = 0b0010;
const EDIT_ARTICLES   = 0b0100;
const DELETE_ARTICLES = 0b1000;

典型的匿名访客将永远不会被登录,因此将被授予默认权限以查看内容。登录用户可能能够创建文章,但没有编辑权限的情况下不能编辑它们。同样,编辑人员可以审核和修改内容(或删除),但不能独立创建文章。最后,管理员可能被允许做任何事情。每个角色都是从前面的权限基元组合而成:

const ROLE_ANONYMOUS = VIEW_ARTICLES;
const ROLE_AUTHOR    = VIEW_ARTICLES | CREATE_ARTICLES;
const ROLE_EDITOR    = VIEW_ARTICLES | EDIT_ARTICLES | DELETE_ARTICLES;
const ROLE_ADMIN     = VIEW_ARTICLES | CREATE_ARTICLES | EDIT_ARTICLES
                       | DELETE_ARTICLES;

一旦从基元权限定义了复合角色,应用程序就可以围绕检查用户的活动角色构建逻辑。虽然权限是使用|运算符组合在一起的,但&运算符将允许您基于这些标志进行切换,正如在示例 2-8 中定义的函数所示。

示例 2-8. 利用位掩码标志进行访问控制
function get_article($article_id)
{
    $role = get_user_role();

    if ($role & VIEW_ARTICLES) {
        // ...
    } else {
        throw new UnauthorizedException();
    }
}

function create_article($content)
{
    $role = get_user_role();

    if ($role & CREATE_ARTICLES) {
        // ...
    } else {
        throw new UnauthorizedException();
    }
}

function edit_article($article_id, $content)
{
    $role = get_user_role();

    if ($role & EDIT_ARTICLES) {
        // ...
    } else {
        throw new UnauthorizedException();
    }
}

function delete_article($article_id)
{
    $role = get_user_role();

    if ($role & DELETE_ARTICLES) {
        // ...
    } else {
        throw new UnauthorizedException();
    }
}

位掩码是在任何语言中实现简单标志的强大方式。不过,如果计划增加所需标志的数量,请小心,因为每个新标志表示的是 2 的次方,这意味着所有标志的值会迅速增长。然而,位掩码在 PHP 应用程序和语言本身中广泛使用。PHP 的自身错误报告设置在第十二章中进一步讨论,利用位操作值来识别引擎本身使用的错误报告级别。

参见

PHP 文档关于位运算符

¹ 长篇讨论错误处理和抑制错误、警告和通知在第十二章中进行。

第三章:函数

每种语言中的每个计算机程序都是通过将各种业务逻辑组件绑定在一起来构建的。通常,这些组件需要有些可重用性,封装常见功能,这些功能需要在应用程序的多个地方引用。将这些组件的业务逻辑封装到函数中是使其模块化和可重用的最简单方式,函数是应用程序中的特定构造,可以在其他地方引用。

示例 3-1 说明了如何编写一个简单的程序来将字符串的第一个字符大写。编写不使用函数的代码被视为命令式编程,因为您逐行定义程序需要完成的确切任务(或代码行)。

示例 3-1。命令式(无函数)字符串大写
$str = "this is an example";

if (ord($str[0]) >= 97 && ord($str[0]) <= 122) {
    $str[0] = chr(ord($str[0]) - 32);
}

echo $str . PHP_EOL; // This is an example

$str = "and this is another";

if (ord($str[0]) >= 97 && ord($str[0]) <= 122) {
    $str[0] = chr(ord($str[0]) - 32);
}

echo $str . PHP_EOL; // And this is another

$str = "3 examples in total";

if (ord($str[0]) >= 97 && ord($str[0]) <= 122) {
    $str[0] = chr(ord($str[0]) - 32);
}

echo $str . PHP_EOL; // 3 examples in total
注意

函数ord()chr()是对 PHP 本身定义的本地函数的引用。ord()函数将字符的二进制值作为整数返回。类似地,chr()将二进制值(表示为整数)转换为其对应的字符。

当您编写没有定义函数的代码时,由于必须在应用程序中复制和粘贴相同的代码块,您的代码会变得非常重复。这违反了软件开发的关键原则之一:DRY,即不要重复自己

描述此原则的相反方式是WET,或写两次一切。重复编写相同的代码块会导致两个问题:

  • 您的代码变得相当冗长且难以维护。

  • 如果重复代码块内的逻辑需要更改,则必须每次更新多个程序部分。

与以命令式方式重复逻辑不同,如示例 3-1,您可以定义一个包装此逻辑的函数,并直接调用该函数,如示例 3-2。定义函数是从命令式到过程式编程的演变,通过这种方式增强语言本身提供的函数与应用程序定义的函数。

示例 3-2。过程式字符串大写
function capitalize_string($str)
{
    if (ord($str[0]) >= 97 && ord($str[0]) <= 122) {
        $str[0] = chr(ord($str[0]) - 32);
    }

    return $str;
}

$str = "this is an example";

echo capitalize_string($str) . PHP_EOL; // This is an example

$str = "and this is another";

echo capitalize_string($str) . PHP_EOL; // And this is another

$str = "3 examples in total";

echo capitalize_string($str) . PHP_EOL; // 3 examples in total

用户定义的函数非常强大且非常灵活。在示例 3-2 中的capitalize_string()函数相对简单——它接受一个字符串参数并返回一个字符串。但在函数定义中没有指示$str参数必须是一个字符串——您可以很容易地传递一个数字甚至一个数组,如下所示:

$out = capitalize_string(25); // 25

$out = capitalize_string(['a', 'b']); // ['A', 'b']

回想一下,来自第一章关于 PHP 宽松类型系统的讨论--默认情况下,当你将参数传递给capitalize_string()时,PHP 会尝试推断你的意图,并且在大多数情况下返回一些有用的东西。如果传递一个整数,在访问数组元素时,PHP 将触发警告,表示你正在错误地访问数组元素,但仍然会返回一个整数而不会崩溃。

更复杂的程序可以在函数参数和返回类型中添加显式类型信息,以便对这种用法进行安全检查。其他函数可以返回多个值而不是单个项。强类型在食谱 3.4 中明确说明。

接下来的示例涵盖了 PHP 中函数的多种使用方式,并开始探讨构建完整应用程序的基础。

3.1 访问函数参数

问题

当在程序的其他地方调用函数时,你想要访问传递给函数的值。

解决方案

在函数体内可以使用函数签名中定义的变量名来使用函数签名中定义的变量名如下:

function multiply($first, $second)
{
    return $first * $second;
}

multiply(5, 2); // 10

$one = 7;
$two = 5;

multiply($one, $two); // 35

讨论

函数签名中定义的变量名仅在函数本身的作用域内有效,并将包含与调用函数时传入的数据匹配的值。在定义函数的花括号内部,你可以像自己定义它们一样使用这些变量。只需知道,对这些变量进行的任何更改将在函数内部可用,并且默认情况下不会影响应用程序中的其他任何内容。

示例 3-3 说明了如何在函数内部和外部同时使用特定变量名,同时引用两个完全独立的值。换句话说,更改函数内部$number的值只会影响函数内部的值,而不会影响父应用程序中的值。

示例 3-3. 本地函数作用域
function increment($number)
{
    $number += 1;

    return $number;
}

$number = 6;

echo increment($number); // 7
echo $number; // 6

默认情况下,PHP 将值传递到函数中,而不是传递变量的引用。在示例 3-3 中,这意味着 PHP 将值6传递给函数内部的新变量$number,执行计算并返回结果。函数外部的$number变量完全不受影响。

警告

PHP 默认按值传递简单值(字符串、整数、布尔值、数组)。然而,更复杂的对象总是按引用传递。对于对象,函数内部的变量指向与函数外部变量相同的对象,而不是它的副本。

在某些情况下,您可能希望显式地通过引用传递变量,而不仅仅传递其值。 在这种情况下,您需要修改函数签名,因为这是对其定义的更改,而不是在调用函数时可以修改的内容。 示例 3-4 说明了如何修改increment()函数,以通过引用而不是值传递$number

示例 3-4. 通过引用传递变量
function increment(&$number)
{
    $number += 1;

    return $number;
}

$number = 6;

echo increment($number); // 7
echo $number; // 7

实际上,在函数内外,变量名称不需要完全匹配。 我在这里两种情况下都使用$number来说明作用域的差异。 如果在$a中存储了一个整数并将该变量作为increment($a)传递,则结果与示例 3-4 中的结果相同。

参见

PHP 参考文档上的用户定义函数通过引用传递变量

3.2 设置函数的默认参数

问题

您希望为函数的参数设置默认值,以便调用无需传递它。

解决方案

在函数签名内部分配默认值。例如:

function get_book_title($isbn, $error = 'Unable to query')
{
    try {
        $connection = get_database_connection();
        $book = query_isbn($connection, $isbn);

        return $book->title;
    } catch {
        return $error;
    }
}

get_book_title('978-1-098-12132-7');

讨论

解决方案中的示例尝试基于其 ISBN 查询书籍的标题。 如果由于任何原因查询失败,则函数将返回传递给$error参数的字符串。

为了使该参数可选,函数签名分配了一个默认值。 当使用单个参数调用get_book_title()时,将自动使用默认的$error值。 您还可以选择在调用函数时将自己的字符串传递给此变量,例如get_book_title​(*978-1-098-12132-7*, *Oops!*);

在定义具有默认参数的函数时,最佳做法是在函数签名中将所有具有默认值的参数放在最后。 虽然可以按任何顺序定义参数,但这样做会使正确调用函数变得困难。

示例 3-5 说明了在必填参数之后放置可选参数可能会出现的问题类型。

警告

可以按任意顺序定义具有特定默认值的函数参数。 但是,自 PHP 8.0 起,声明必填参数在可选参数之后已弃用。 继续这样做可能会导致未来版本的 PHP 中出现错误。

示例 3-5. 错误的默认参数顺序
function brew_latte($flavor = 'unflavored', $shots)
{
    return "Brewing a {$shots}-shot, {$flavor} latte!";
}

brew_latte('vanilla', 2); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
brew_latte(3); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

适当执行。 返回Brewing a 2-shot, vanilla latte!

2

引发ArgumentCountError异常,因为$shots未定义。

在某些情况下,将参数按特定顺序放置可能更符合逻辑(例如使代码更易读)。请注意,如果需要任何参数,则它们左侧的每个参数即使尝试定义默认值也是必需的。

参见

PHP 手册中有默认参数的示例。

3.3 使用命名函数参数

问题

您希望根据参数的名称而不是位置将参数传递给函数。

解决方案

在调用函数时,请使用命名参数语法如下:

array_fill(start_index: 0, count: 100, value: 50);

讨论

默认情况下,PHP 在函数定义中使用位置参数。解决方案示例引用了原生的array_fill()函数,具有以下函数签名:

array_fill(int $start_index, int $count, mixed $value): array

基本的 PHP 编码必须按照定义的顺序向array_fill()提供参数——先是$start_index,然后是$count,最后是$value。顺序本身并不是问题,但是在通过代码进行视觉扫描时理解每个值的含义可能会有挑战。使用基本的有序参数,解决方案示例将写成以下形式,需要对函数签名深入了解,以知道哪个整数代表哪个参数:

array_fill(0, 100, 50);

命名函数参数可以消除对内部变量分配的歧义。当您调用函数时,命名函数参数还允许任意重新排序参数。

命名参数的另一个关键优势是在函数调用时可以完全跳过可选参数。考虑一个冗长的活动日志函数,例如示例 3-6,其中多个参数被视为可选参数,因为它们设置了默认值。

示例 3-6. 冗长的活动日志函数
activity_log(
    string    $update_reason,
    string    $note           = '',
    string    $sql_statement  = '',
    string    $user_name      = 'anonymous',
    string    $ip_address     = '127.0.0.1',
    ?DateTime $time           = null
): void

在内部,示例 3-6 将在仅使用一个参数调用时使用其默认值;如果$timenull,则该值将被默默替换为代表“现在”的新DateTime实例。然而,有时您可能希望填充其中一个可选参数,而不希望显式设置所有可选参数。

假设您想要从静态日志文件中重新播放先前观察到的事件。用户活动是匿名的(因此$user_name$ip_address的默认值是足够的),但您需要显式设置事件发生的日期。没有命名参数的情况下,此类调用看起来类似于示例 3-7。

示例 3-7. 调用冗长的activity_log()函数
activity_log(
    'Testing a new system',
    '',
    '',
    'anonymous',
    '127.0.0.1',
    new DateTime('2021-12-20')
);

使用命名参数,您可以跳过设置参数为其默认值,并仅显式设置您需要的参数。前面的代码可以简化为以下形式:

activity_log('Testing a new system', time: new DateTime('2021-12-20'));

除了极大地简化activity_log()的使用之外,命名参数还有一个额外的好处,即保持代码 DRY。参数的默认值直接存储在函数定义中,而不是在每次调用函数时都复制一遍。如果以后需要更改默认值,只需编辑函数定义即可。

另请参阅

最初的 RFC提出命名参数

3.4 强制函数参数和返回类型

问题

您希望强制程序实现类型安全性,并避免 PHP 的本机松散类型比较。

解决方案

为函数定义添加输入和返回类型。可选地,在每个文件的顶部添加严格的类型声明,以强制值匹配类型注解(如果不匹配则发出致命错误)。例如:

declare(strict_types=1);

function add_numbers(int $left, int $right): int
{
    return $left + $right;
}

add_numbers(2, 3); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
add_numbers(2, '3'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

这是一个完全有效的操作,将返回整数5

2

虽然2 + '3'是有效的 PHP 代码,但字符串'3'违反了函数的类型定义,将触发致命错误。

讨论

PHP 本身支持多种标量类型,并允许开发者声明函数的输入参数和返回值,以确定每种值的允许性。此外,开发者还可以将自定义类和接口作为类型,或在类型系统中利用类继承。¹

在定义函数时,通过在参数名称前直接注释类型来标注参数类型。类似地,通过在函数签名后附加:和函数可能返回的类型来指定返回类型,如下所示:

function name(type $parameter): return_type
{
    // ...
}

表 3-1 列举了 PHP 利用的最简单类型。

表 3-1. PHP 中的简单单类型

类型 描述
array 值必须是一个数组(包含任何类型的值)。
callable 值必须是可调用函数。
bool 值必须是布尔值。
float 值必须是浮点数。
int 值必须是整数。
string 值必须是一个字符串。
iterable 值必须是一个数组或实现了Traversable接口的对象。
mixed 对象可以是任意值。
void 表示函数不返回任何值。
never 表示函数不返回任何值;它要么调用exit,抛出异常,或故意是一个无限循环。

此外,无论是内置还是自定义类都可以用来定义类型,如表 3-2 所示。

表 3-2. PHP 中的对象类型

类型 描述
类/接口名称 值必须是指定类的实例或接口的实现。
self 值必须是与声明使用的同一类的实例。
parent 值必须是在声明使用的类的父类的实例。
object 值必须是一个对象的实例。

PHP 还允许将简单标量类型扩展为可为空,或者将它们组合为联合类型。要使特定类型可为空,必须在类型注释前面加上 ?。这将指示编译器允许值为指定类型或null,例如在 示例 3-8 中。

示例 3-8. 使用可空参数的函数
function say_hello(?string $message): void
{
    echo 'Hello, ';

    if ($message === null) {
        echo 'world!';
    } else {
        echo $message . '!';
    }
}

say_hello('Reader'); // Hello, Reader!
say_hello(null); // Hello, world!

联合类型声明通过使用管道字符(|)将多个类型组合成单个声明。如果您在 Solution 示例中重新编写类型声明,将字符串和整数组合成联合类型,通过传递字符串进行加法操作时引发的致命错误将解决。考虑在 示例 3-9 中可能的重写,允许整数或字符串作为参数。

示例 3-9. 重新编写 Solution 示例以利用联合类型
function add_numbers(int|string $left, int|string $right): int
{
    return $left + $right;
}

add_numbers(2, '3'); // 5

这种替代方案的最大问题是,使用 + 运算符将字符串连接在一起在 PHP 中没有意义。如果两个参数都是数字(整数或表示为字符串的整数),则函数将正常工作。如果其中一个是非数字字符串,PHP 将抛出 TypeError,因为它不知道如何将两个字符串“加”在一起。通过为代码添加类型声明并强制执行严格类型化,可以避免这类错误。类型声明形式化了您希望代码支持的契约,并鼓励自然防御编码错误的编程实践。

默认情况下,PHP 使用其类型系统来提示哪些类型允许进入函数并从函数返回。这对于防止将坏数据传递给函数很有用,但它在很大程度上依赖于开发人员的勤奋或额外的工具²来强制执行类型。与依赖于人类检查代码不同,PHP 允许在每个文件中静态声明,以便所有调用都遵循严格的类型化。

declare(strict_types=1); 放置在文件顶部告诉 PHP 编译器,您打算让该文件中的所有调用遵守参数和返回类型声明。请注意,此指令适用于文件内部的调用,而不适用于该文件中函数的定义。如果从另一个文件调用函数,PHP 也会遵守该文件中的类型声明。然而,将此指令放置在您的文件中不会强制要求引用您的函数的其他文件遵循类型系统。

参见

PHP 关于 类型声明declare 结构 的文档。

3.5 定义具有可变数量参数的函数

问题

您想要定义一个函数,该函数接受一个或多个参数,而不知道预先传入多少个值。

解决方案

使用 PHP 的扩展操作符(…​)来定义可变数量的参数:

function greatest(int ...$numbers): int
{
    $greatest = 0;
    foreach ($numbers as $number) {
        if ($number > $greatest) {
            $greatest = $number;
        }
    }

    return $greatest;
}

greatest(7, 5, 12, 2, 99, 1, 415, 3, 7, 4);
// 415

讨论

展开运算符会自动将传递给特定位置或之后的所有参数添加到一个数组中。可以通过在展开运算符前面添加类型声明来为此数组指定类型(详见示例 3.4 了解更多关于类型的内容),因此需要确保数组的每个元素都与特定类型匹配。调用解决方案示例中定义的函数如 greatest(2, "five"); 将抛出 TypeError,因为您已经明确声明了 $numbers 数组的每个成员为 int 类型。

您的函数可以接受多个位置参数,同时利用展开运算符接受无限数量的额外参数。在示例 3-10 中定义的函数将向屏幕上无限数量的个人打印问候语。

示例 3-10. 利用展开运算符
function greet(string $greeting, string ...$names): void
{
    foreach($names as $name) {
        echo $greeting . ', ' . $name . PHP_EOL;
    }
}

greet('Hello', 'Tony', 'Steve', 'Wanda', 'Peter');
// Hello, Tony
// Hello, Steve
// Hello, Wanda
// Hello, Peter

greet('Welcome', 'Alice', 'Bob');
// Welcome, Alice
// Welcome, Bob

展开运算符不仅在函数定义时更有用。虽然它可以用于将多个参数打包到一个数组中,但也可以用于将数组解包为多个参数,以便进行更传统的函数调用。示例 3-11 通过使用展开运算符将数组传递给一个不接受数组的函数提供了展示如何工作的简单示例。

示例 3-11. 使用展开运算符解包数组
function greet(string $greeting, string $name): void
{
    echo $greeting . ', ' . $name  . PHP_EOL;
}

$params = ['Hello', 'world'];
greet(...$params);
// Hello, world

在某些情况下,更复杂的函数可能会返回多个值(如下一篇章节所讨论的),因此将一个函数的返回值传递给另一个函数在使用展开运算符时变得非常简单。事实上,任何实现 PHP 的可遍历接口的数组或变量都可以以这种方式解包到函数调用中。

参见

PHP 关于可变长度参数列表的文档。

3.6 返回多个值

问题

您希望从单个函数调用中返回多个值。

解决方案

而不是返回单个值,可以通过在函数外部使用 list() 来返回多个值的数组:

function describe(float ...$values): array
{
    $min = min($values);
    $max = max($values);
    $mean = array_sum($values) / count($values);

    $variance = 0.0;
    foreach($values as $val) {
        $variance += pow(($val - $mean), 2);
    }
    $std_dev = (float) sqrt($variance/count($values));

    return [$min, $max, $mean, $std_dev];
}

$values = [1.0, 9.2, 7.3, 12.0];
list($min, $max, $mean, $std) = describe(...$values);

讨论

PHP 只能从函数调用中返回一个值,但该值本身可以是包含多个值的数组。与 PHP 的 list() 结构配对时,可以轻松地将此数组解构为单独的变量,以供程序进一步使用。

尽管需要返回许多不同的值并不常见,但在需要时能够这样做确实非常方便。一个例子是在 Web 身份验证中。许多现代系统今天使用 JSON Web Tokens(JWT),这些是以 Base64 编码的数据的期限分隔字符串。JWT 的每个组件代表一个单独的离散事物:描述所使用算法的标头,令牌有效负载中的数据以及该数据上的可验证签名。

在将 JWT 作为字符串读取时,PHP 应用程序通常利用内置的explode()函数来在每个组件的句点上拆分字符串。简单使用explode()可能如下所示:

$jwt_parts = explode('.', $jwt);
$header = base64_decode($jwt_parts[0]);
$payload = base64_decode($jwt_parts[1]);
$signature = base64_decode($jwt_parts[2]);

前述代码可以正常工作,但数组内的重复引用位置在开发期间和后期调试时可能难以跟踪。此外,开发人员必须手动分别解码 JWT 的每个部分;忘记调用base64_decode()可能对程序的运行造成致命影响。

另一种方法是在函数内部解包并自动解码 JWT,并以数组形式返回各个组件,如示例 3-12 所示。

示例 3-12. 解码 JWT
function decode_jwt(string $jwt): array
{
    $parts = explode('.', $jwt);

    return array_map('base64_decode', $parts);
}

list($header, $payload, $signature) = decode_jwt($jwt);

使用函数解包 JWT 而不是直接分解每个元素的另一个优点是,你可以在其中构建自动签名验证或甚至根据头部声明的加密算法过滤 JWT。虽然这种逻辑在处理 JWT 时可以被逐步应用,但将所有内容保持在单个函数定义中会导致更清晰、更易维护的代码。

在一个函数调用中返回多个值的最大缺点在于类型。这些函数具有array返回类型,但 PHP 本身不允许指定数组中元素的类型。我们有潜在的解决方法来解决这个限制,比如文档化函数签名并集成静态分析工具如PsalmPHPStan,但语言本身不支持数组类型。因此,如果你在使用严格类型(你应该使用),从单个函数调用返回多个值应该是一个少见的情况。

参见

食谱 3.5 关于传递可变数量的参数和食谱 1.3 更多关于 PHP 的list()构造的信息。还可以参考像phpDocumentor 关于类型数组的文档,可以通过 Psalm 等工具强制执行。

3.7 从函数内访问全局变量

问题

你的函数需要引用应用程序其他地方定义的全局变量。

解决方案

在函数的范围内使用global关键字前缀来访问任何全局变量:

$counter = 0;

function increment_counter()
{
    global $counter;

    $counter += 1;
}

increment_counter();

echo $counter; // 1

讨论

PHP 根据变量定义的上下文将操作分为不同的范围。对于大多数程序,单个范围覆盖所有已包含或所需的文件。定义在全局范围内的变量在任何地方都可用,无论当前执行的是哪个文件,如示例 3-13 中所示。

示例 3-13. 在全局范围内定义的变量可供包含的脚本使用
$apple = 'honeycrisp';

include 'someotherscript.php'; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

1

$apple变量也在此脚本中定义并可供使用。

然而,用户定义的函数定义了它们自己的作用域。在用户定义的函数外定义的变量不可用于函数体内。同样,函数内定义的任何变量在函数外部也是不可用的。示例 3-14 说明了程序中父作用域和函数作用域的边界。

示例 3-14. 本地与全局作用域
$a = 1; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

function example(): void
{
    echo $a . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    $a = 2; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

    $b = 3; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
}

example();

echo $a . PHP_EOL; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
echo $b . PHP_EOL; ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

变量$a最初是在父作用域中定义的。

2

在函数作用域内,$a尚未定义。尝试echo其值将导致警告。

3

在函数内定义名为$a的变量将不会覆盖函数外部相同名称变量的值。

4

在函数中定义一个名为$b的变量,使其在函数内可用,但此值不会逃逸函数的作用域。

5

即使在调用example()后,在函数外部也会打印你设置的初始值的$a

6

由于$b是在函数内定义的,因此在父应用程序的作用域中未定义。

注意

如果函数定义为接受这种方式的变量,那么可以通过引用将变量传递给函数调用。然而,这是函数定义的决定,而不是在调用该函数后可用于利用该函数的程序的运行时标志。示例 3-4 展示了传递引用可能的效果。

要引用在其作用域外定义的变量,函数需要在其自身作用域内将这些变量声明为全局变量。要引用父作用域,可以将示例 3-14 重写为示例 3-15。

示例 3-15. 本地与全局作用域的再访问
$a = 1;

function example(): void
{
    global $a, $b; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    echo $a . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    $a = 2; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

    $b = 3; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
}

example();

echo $a . PHP_EOL; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
echo $b . PHP_EOL; ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

声明$a$b为全局变量后,函数将使用父作用域的值而不是自己的作用域。

2

通过对全局变量$a的引用,你现在可以将其打印到输出中。

3

同样,在函数作用域内对$a的任何更改都将影响父作用域中的变量。

4

类似地,你现在定义了$b,但由于它是全局的,此定义也将向上冒泡到父作用域中。

5

现在,echo $a将反映在example()作用域内所做的更改,因为你将变量设置为全局的。

6

同样,$b现在已全局定义,并且也可以打印到输出中。

除了系统可用的内存外,PHP 可以支持的全局变量数量没有限制。此外,可以通过枚举 PHP 定义的特殊$GLOBALS 数组来列出所有全局变量。这个关联数组对于想要在全局范围内引用特定变量而不声明为全局变量的情况非常有用,例如例子 3-16。

例子 3-16. 使用关联的$GLOBALS 数组
$var = 'global';

function example(): void
{
    $var = 'local';

    echo 'Local variable: ' . $var . PHP_EOL;
    echo 'Global variable: ' . $GLOBALS['var'] . PHP_EOL;
}

example();
// Local variable: local
// Global variable: global
警告

从 PHP 8.1 开始,不再可能完全覆盖$GLOBALS 数组。在以前的版本中,你可以将其重置为空数组(例如,在代码的测试运行期间)。从现在开始,你只能编辑数组的内容,而不能再整体操作集合了。

全局变量是在应用程序中引用状态的方便方式,但如果过度使用可能会导致混乱和可维护性问题。一些大型应用程序大量使用全局变量——WordPress 是一个基于 PHP 的项目,驱动着超过 40%的互联网[³],在其代码库中广泛使用全局变量。然而,大多数应用程序开发人员都同意,应尽可能少地使用全局变量,以帮助保持系统的清洁和易维护性。

参见

PHP 文档中关于变量作用域和特殊的$GLOBALS数组。

3.8 在多次调用之间管理函数内部的状态

问题

你的函数需要随着时间的推移跟踪其状态变化。

解决方案

使用static关键字定义一个在函数调用之间保持状态的本地作用域变量:

function increment()
{
    static $count = 0;

    return $count++;
}

echo increment(); // 0
echo increment(); // 1
echo increment(); // 2

讨论

静态变量仅存在于其声明的函数作用域内。然而,与普通的局部变量不同的是,它在每次返回函数作用域时保留其值。通过这种方式,函数可以变得有状态,并在独立调用之间跟踪某些数据(如被调用的次数)。

在典型的函数中,使用=运算符将值分配给变量。当应用static关键字时,此赋值操作仅在首次调用该函数时发生。后续调用将引用变量的先前状态,并允许程序使用或修改存储的值。

静态变量的最常见用例之一是跟踪递归函数的状态。例子 3-17 展示了一个在退出之前递归调用自身固定次数的函数。

例子 3-17. 使用静态变量限制递归深度
function example(): void
{
    static $count = 0;

    if ($count >= 3) {
        $count = 0;
        return;
    }

    $count += 1;

    echo 'Running for loop number ' . $count . PHP_EOL;
    example();
}

static 关键字还可以用于跟踪可能需要多次函数调用但您可能只想要单个实例的昂贵资源。考虑一个将消息记录到数据库的函数:您可能无法将数据库连接传递给函数本身,但希望确保该函数只打开一个单一数据库连接。这样的记录函数可以实现如 示例 3-18 中所示。

示例 3-18. 使用静态变量保存数据库连接
function logger(string $message): void
{
    static $dbh = null;
    if ($dbh === null) {
        $dbh = new PDO(DATABASE_DSN, DATABASE_USER, DATABASE_PASSWORD);
    }

    $sql = 'INSERT INTO messages (message) VALUES (:message)';
    $statement = $dbh->prepare($sql);

    $statement->execute([':message', $message]);
}

logger('This is a test'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
logger('This is another message');![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

第一次调用 logger() 时,它将定义静态变量 $dbh 的值。在此情况下,它将通过PHP 数据对象(PDO)接口连接到数据库。此接口是 PHP 提供的用于访问数据库的标准对象。

2

每次调用 logger() 都将利用存储在 $dbh 中的初始数据库连接。

请注意,PHP 自动管理其内存使用情况,并在变量离开作用域时从内存中自动清除变量。对于函数内的常规变量,这意味着变量在函数完成后从内存中释放。静态和全局变量永远不会在程序本身退出之前清除,因为它们始终在作用域中。在使用 static 关键字时要小心,确保不会在内存中存储不必要的大数据块。在 示例 3-18 中,您打开一个连接到数据库的连接,该连接永远不会被您创建的函数自动关闭。

虽然 static 关键字可以是在函数调用之间重用状态的强大方式,但应谨慎使用以确保您的应用程序不会执行意外操作。在许多情况下,明确传递表示状态的变量到函数中可能更好。更好的方法是将函数的状态封装为一个全局对象的一部分,这在第八章中有详细介绍。

参见

PHP 文档关于变量作用域,包括 static 关键字

3.9 定义动态函数

问题

您希望定义一个匿名函数,并将其作为变量引用到应用程序中,因为您只想使用或调用该函数一次。

解决方案

定义一个可以分配给变量并根据需要传递到另一个函数中的闭包:

$greet = function($name) {
    echo 'Hello, ' . $name . PHP_EOL;
};

$greet('World!');
// Hello, World!

讨论

虽然 PHP 中的大多数函数都有定义的名称,该语言支持创建无名(所谓的匿名)函数,也称为闭包lambda。这些函数可以封装简单或复杂的逻辑,并可以直接分配给变量以供程序中其他地方引用。

在内部,匿名函数使用 PHP 的原生Closure类实现。该类声明为final,这意味着没有类可以直接扩展它。然而,匿名函数都是这个类的实例,可以直接用作函数或作为对象使用。

默认情况下,闭包不会继承任何父应用程序的作用域,并且像普通函数一样,在其自己的作用域内定义变量。可以通过在定义函数时利用use指令,直接将父作用域的变量传递给闭包。示例 3-19 演示了如何动态地将一个作用域的变量传递到另一个作用域中。

示例 3-19. 使用use()在不同作用域之间传递变量
$some_value = 42;

$foo = function() {
    echo $some_value;
};

$bar = function() use ($some_value) {
    echo $some_value;
};

$foo(); // Warning: Undefined variable

$bar(); // 42

匿名函数用于许多项目中,以封装应用于数据集合的逻辑片段。下一个示例正好涵盖了这种用例。

注意

PHP 的旧版本使用create_function()实现类似的效果。开发人员可以将匿名函数作为字符串创建,并将该代码传递给create_function(),将其转换为闭包实例。不幸的是,这种方法在底层使用了eval()来评估字符串,这种做法被认为是非常不安全的。虽然一些旧项目可能仍在使用create_function(),但该函数在 PHP 7.2 中已被弃用,并在版本 8.0 中从语言中完全删除。

参见

PHP 文档中关于匿名函数的说明。

3.10 将函数作为参数传递给其他函数

问题

您希望定义函数实现的一部分,并将该实现作为参数传递给另一个函数。

解决方案

定义一个实现所需逻辑部分的闭包,并将其直接传递到另一个函数中,就像任何其他变量一样:

$reducer = function(?int $carry, int $item): int {
    return $carry + $item;
};

function reduce(array $array, callable $callback, ?int $initial = null): ?int
{
    $acc = $initial;
    foreach ($array as $item) {
        $acc = $callback($acc, $item);
    }

    return $acc;
}

$list = [1, 2, 3, 4, 5];
$sum = reduce($list, $reducer); // 15

讨论

许多认为 PHP 是功能型语言,因为函数在语言中是一级元素,可以绑定到变量名,作为参数传递,甚至从其他函数中返回。PHP 通过语言中实现的callable类型支持函数作为变量。许多核心函数(如usort()array_map()array_reduce())支持传递可调用参数,内部使用它来定义函数的整体实现。

解决方案示例中定义的reduce()函数是 PHP 本地array_reduce()函数的用户自定义实现。两者行为相同,解决方案可以重写,直接将$reducer传递到 PHP 本地实现中,结果不变:

$sum = array_reduce($list, $reducer); // 15

由于函数可以像任何其他变量一样传递,PHP 可以定义函数的部分实现。通过定义一个函数,然后返回另一个函数,可以在程序的其他地方使用它。

例如,您可以定义一个函数来设置一个基本的乘法器例程,该例程将任意输入乘以一个固定基数,就像示例 3-20 中那样。主函数每次调用都返回一个新函数,因此您可以创建加倍或三倍任意值的函数,并根据需要使用它们。

示例 3-20. 部分应用的乘法器函数
function multiplier(int $base): callable
{
    return function(int $subject) use ($base): int {
        return $base * $subject;
    };
}

$double = multiplier(2);
$triple = multiplier(3);

$double(6);  // 12
$double(10); // 20
$triple(3);  // 9
$triple(12); // 36

将函数分解成这样的形式称为https://oreil.ly/-a4l[_currying]。这是将具有多个输入参数的函数改为一系列每个只接受一个单一参数的函数,并且其中大多数参数本身也是函数的做法。为了充分说明这在 PHP 中的工作原理,让我们看看示例 3-21 并逐步重写multiplier()函数。

示例 3-21. PHP 中柯里化的演示
function multiply(int $x, int $y): int ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
{
    return $x * $y;
}

multiply(7, 3); // 21 
function curried_multiply(int $x): callable ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
{
    return function(int $y) use ($x): int { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        return $x * $y; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    };
}

curried_multiply(7)(3); // 21 ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

这个函数的最基本形式接受两个值,将它们相乘并返回最终结果。

2

当你柯里化函数时,你希望每个组件函数只接受一个值。新的curried_multiply()例如,只接受一个参数,但返回一个在内部利用该参数的函数。

3

内部函数自动引用前一个函数调用传递的值(使用use指令)。

4

结果函数实现了与基本形式相同的业务逻辑。

5

调用柯里化函数看起来像是按顺序调用多个函数,但结果是相同的。

就像在示例 3-21 中的柯里化函数一样,柯里化的最大优势是部分应用的函数可以作为变量传递并在其他地方使用。类似于使用multiplier()函数,您可以通过以下方式部分应用您的柯里化乘法器来创建一个加倍或三倍的函数:

$double = curried_multiply(2);
$triple = curried_multiply(3);

部分应用的柯里化函数本身是可调用的函数,但可以作为变量传递给其他函数,并在稍后完全调用。

另请参阅

有关匿名函数的详细信息,请参见 Recipe 3.9。

3.11 使用简洁的函数定义(箭头函数)

问题

您希望创建一个简单的匿名函数,它引用父作用域而不需要冗长的use声明。

解决方案

使用 PHP 的短匿名函数(箭头函数)语法自动定义一个函数,该函数自动继承其父作用域:

$outer = 42;

$anon = fn($add) => $outer + $add;

$anon(5); // 47

讨论

箭头函数在 PHP 7.4 中作为编写更简洁匿名函数的一种方式引入,就像在 Recipe 3.9 中一样。箭头函数自动捕获任何引用的变量并(按值而非按引用)导入到函数的作用域中。

可以以更详细的方式编写解决方案示例,如示例 3-22,同时仍然实现相同级别的功能。

示例 3-22. 匿名函数的长格式
$outer = 42;

$anon = function($add) use ($outer) {
    return $outer + $add;
};

$anon(5);

箭头函数总是返回一个值——不可能隐式或显式返回void。这些函数遵循非常特定的语法,并始终返回其表达式的结果:*fn* (*arguments*) => *expression*。这种结构使得箭头函数在各种情况下都非常有用。

一个例子是通过 PHP 的本地array_map()来应用于数组中所有元素的内联函数的简明定义。假设输入用户数据是表示整数值的字符串数组,并且您希望将字符串数组转换为整数数组以强制执行适当的类型安全性。这可以通过示例 3-23 轻松实现。

示例 3-23. 将数值字符串数组转换为整数数组
$input = ['5', '22', '1093', '2022'];

$output = array_map(fn($x) => intval($x), $input);
// $output = [5, 22, 1093, 2022]

箭头函数仅允许单行表达式。如果您的逻辑复杂到需要多个表达式,请使用标准匿名函数(参见配方 3.9)或在代码中定义一个命名函数。尽管如此,箭头函数本身就是一个表达式,因此一个箭头函数实际上可以返回另一个箭头函数。

将箭头函数作为另一个箭头函数的表达式返回的能力,导致可以在柯里化或部分应用函数中使用箭头函数以促进代码重用。假设您希望在程序中传递一个函数,该函数使用固定的模数执行模数算术。您可以通过定义一个箭头函数来执行计算并将其包装在另一个函数中以指定模数,将最终的柯里化函数分配给可以在其他地方使用的变量,例如示例 3-24。

注意

模数算术用于创建时钟函数,无论输入的整数是什么,它们总是返回一组特定的整数值。通过将两个整数取模,即将它们相除并返回整数余数来完成。例如,“12 模 3”写作 12 % 3 并返回 12/3 的余数,即 0。类似地,“15 模 6”写作 15 % 6 并返回 15/6 的余数,即 3。模运算的返回值永远不会大于模数本身(在前两个示例中分别为 36)。模数算术通常用于将大量输入值分组或用于支持加密操作,有关详细信息,请参阅第九章。

示例 3-24. 使用箭头函数进行函数柯里化
$modulo = fn($x) => fn($y) => $y % $x;

$mod_2 = $modulo(2);
$mod_5 = $modulo(5);

$mod_2(15); // 1
$mod_2(20); // 0
$mod_5(12); // 2
$mod_5(15); // 0

最后,就像常规函数一样,箭头函数也可以接受多个参数。与传递单个变量(或隐式引用父作用域中定义的变量)不同,您可以轻松地定义一个具有多个参数并在表达式中自由使用它们的函数。一个简单的相等函数可以使用箭头函数如下所示:

$eq = fn($x, $y) => $x == $y;

$eq(42, '42'); // true

参见

在 3.9 节匿名函数的详细信息和 PHP 手册文档中的箭头函数

3.12 创建一个没有返回值的函数

问题

您需要定义一个函数,它在完成后不向程序的其余部分返回数据。

解决方案

使用显式类型声明,并引用void返回类型:

const MAIL_SENDER = 'wizard@oz.example';
const MAIL_SUBJECT = 'Incoming from the Wonderful Wizard';

function send_email(string $to, string $message): void
{
    $headers = ['From' => MAIL_SENDER];

    $success = mail($to, MAIL_SUBJECT, $message, $headers);

    if (!$success) {
        throw new Exception('The man behind the curtain is on break.');
    }
}

send_email('dorothy@kansas.example', 'Welcome to the Emerald City!');

讨论

解决方案示例使用 PHP 的本机mail()函数将静态主题的简单消息发送到指定的接收者。PHP 的mail()在成功时返回true,出现错误时返回false。在解决方案示例中,当出现问题时您仅想抛出异常,但在其他情况下希望静默返回。

注意

在许多情况下,当函数完成时,您可能希望返回一个标志 —— 布尔值、字符串或null —— 以指示发生了什么,这样您程序的其余部分可以适当地行事。返回nothing的函数相对较少,但当您的程序与外部进行通信且通信结果不影响程序的其余部分时,它们确实会出现。将消息队列发送到火并忘连接或记录到系统错误日志是返回void的函数的常见用例。

PHP 中的void返回类型在编译时强制执行,这意味着如果函数体返回任何内容,即使您尚未执行任何操作,您的代码也将触发致命错误。示例 3-25 展示了void的有效和无效使用。

示例 3-25. void 返回类型的有效和无效使用
function returns_scalar(): void
{
    return 1; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
}

function no_return(): void
{
    ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
}

function empty_return(): void
{
    return; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
}

function returns_null(): void
{
    return null; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
}

1

返回标量类型(如字符串、整数或布尔值)将触发致命错误。

2

在函数中省略任何返回是有效的。

3

明确地返回没有数据是有效的。

4

即使null是“空的”,它仍然算作返回值,并将触发致命错误。

与 PHP 中的大多数其他类型不同,void类型仅适用于返回。它不能用作函数定义的参数类型;尝试这样做将导致编译时致命错误。

参见

PHP 7.1 中引入void返回类型的原始 RFC

3.13 创建一个不返回值的函数

问题

您需要定义一个显式退出的函数,并确保应用程序的其他部分知道它永远不会返回。

解决方案

使用显式类型注释并引用 never 返回类型。例如:

function redirect(string $url): never
{
    header("Location: $url");
    exit();
}

讨论

PHP 中的一些操作意图是在退出当前进程之前引擎执行的最后一个操作。调用 header() 定义特定响应头必须在打印响应主体或处理其他操作之前完成。具体来说,调用 header() 触发重定向通常是应用程序执行的最后一步 —— 在告知请求客户端重定向至其他位置后打印任何主体文本或处理任何其他操作没有意义或价值。

never 返回类型向 PHP 和代码的其他部分都表明函数通过 exit()die() 或抛出异常“保证”停止程序执行。

如果使用 never 返回类型的函数仍然隐式返回,例如示例 3-26,PHP 将抛出 TypeError 异常。

示例 3-26. 一个本不应该返回的函数中的隐式返回
function log_to_screen(string $message): never
{
    echo $message;
}

同样,如果 never 类型的函数 显式 返回一个值,PHP 也会抛出 TypeError 异常。无论是隐式还是显式返回,这个异常都是在调用时(函数被调用时)而不是在定义时强制执行的。

参见

PHP 8.1 中引入 never 返回类型的原始 RFC

¹ 在第八章详细讨论了自定义类和对象。

² PHP CodeSniffer 是一款流行的开发者工具,用于自动扫描代码库并确保所有代码符合特定的编码标准。它可以轻松扩展以在所有文件中强制执行严格的类型声明。

³ 根据W3Techs,截至 2023 年 3 月,WordPress 的市场覆盖率约为使用内容管理系统的网站的 63%,以及所有网站的超过 43%。

第四章:字符串

字符串是 PHP 数据的基本构建块之一。每个字符串表示一个有序的字节序列。字符串可以是人类可读的文本部分(例如To be or not to be),也可以是编码为整数的原始字节序列(例如\110\145\154\154\157\40\127\157\162\154\144\41)。¹ PHP 应用程序读取或写入的每个数据元素都表示为字符串。

在 PHP 中,字符串通常编码为ASCII 值,尽管您可以根据需要在 ASCII 和其他格式(如 UTF-8)之间转换。字符串可以包含null字节,并且在 PHP 进程具有足够的内存可用的情况下,其存储几乎没有限制。

在 PHP 中创建字符串的最基本方法是使用单引号。单引号字符串被视为字面状态——没有特殊字符或任何类型的插值变量。要在单引号字符串中包含字面单引号,必须通过在其前面加上反斜杠来转义该引号,例如,\'。实际上,唯一需要转义的两个字符是单引号本身或反斜杠。例子 4-1 展示了单引号字符串及其相应的打印输出。

注意

变量插值是直接按名称引用变量的实践,在字符串中让解释器在运行时将变量替换为其值。插值允许更灵活的字符串,因为您可以编写一个字符串,但动态替换其中一些内容以适应代码中的位置上下文。

示例 4-1. 单引号字符串
print 'Hello, world!';
// Hello, world!

print 'You\'ve only got to escape single quotes and the \\ character.';
// You've only got to escape single quotes and the \ character.

print 'Variables like $blue are printed as literals.';
// Variables like $blue are printed as literals.

print '\110\145\154\154\157\40\127\157\162\154\144\41';
// \110\145\154\154\157\40\127\157\162\154\144\41

更复杂的字符串可能需要插入变量或引用特殊字符,比如换行符或制表符。对于这些更复杂的用例,PHP 需要使用双引号,并允许使用各种转义序列,如表 4-1 所示。

表 4-1. 双引号字符串转义序列

转义序列 字符 示例
\n 换行符 "这个字符串以换行符结尾。\n"
\r 回车符 "这个字符串以回车符结尾。\r"
\t 制表符 "很多\t 空格"
\\ 反斜杠 "必须转义\\字符。"
`| 转义序列 字符 示例
--- --- ---
\n 换行符 "这个字符串以换行符结尾。\n"
\r 回车符 "这个字符串以回车符结尾。\r"
\t 制表符 "很多\t 空格"
\\ 反斜杠 "必须转义\\字符。"
| 美元符号 | 电影票价为\$10。
\" 双引号 "一些引号是 \"恐怖引号\"."
\0\777 八进制字符值 "\120\110\120"
\x0\xFF 十六进制字符值 "\x50\x48\x50"

除了用前导反斜杠明确转义的特殊字符外,PHP 将自动替换任何传递到双引号字符串中的变量的值。此外,如果将整个表达式用大括号({})包裹起来并将其视为变量,PHP 将在双引号字符串中插值整个表达式。示例 4-2 展示了如何处理双引号字符串中的变量,无论是复杂还是简单的。

Example 4-2. 双引号字符串中的变量插值
print "The value of \$var is $var"; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
print "Properties of objects can be interpolated, too. {$obj->value}"; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
print "Prints the value of the variable returned by getVar(): {${getVar()}}"; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

$var 的第一个引用是转义的,但第二个将被其实际值替换。如果 $var = 'apple',则该字符串将打印 The value of $var is apple

2

在双引号字符串中,使用大括号可以直接引用对象属性,就好像这些属性是本地定义的变量一样。

3

假设 getVar() 返回一个已定义变量的名称,这行代码将执行该函数并打印分配给该变量的值。

单引号和双引号字符串都表示为单行。但通常,程序需要将多行文本(或多行编码的二进制)表示为一个字符串时,开发人员最好的工具是 Heredoc。

Heredoc 是一个以三个尖括号(<<< 运算符)开始的文本块,接着是一个命名标识符,然后是一个换行符。每个后续的文本行(包括换行符)都是字符串的一部分,直到一个仅包含 Heredoc 命名标识符和一个分号的独立行。示例 4-3 展示了代码中 Heredoc 的外观。

注意

用于 Heredoc 的标识符不需要大写。然而,在 PHP 中,通常习惯于始终将这些标识符大写,以帮助区分它们与字符串定义的文本。

Example 4-3. 使用 Heredoc 语法定义字符串
$poem = <<<POEM
To be or not to be,
That is the question
POEM;

Heredoc 函数与双引号字符串完全相同,并允许在其中进行变量插值(或者类似转义十六进制的特殊字符)。在应用程序中编码 HTML 块时,这特别有用,因为可以使用变量使字符串变得动态。

但在某些情况下,您可能希望使用字符串字面量而不是开放变量插值的形式。在这种情况下,PHP 的 Nowdoc 语法提供了单引号风格的 Heredoc 双引号字符串的替代方案。Nowdoc 看起来几乎与 Heredoc 相同,只是标识符本身用单引号括起来,如 示例 4-4 所示。

Example 4-4. 使用 Nowdoc 语法定义字符串
$poem = <<<'POEM'
To be or not to be,
That is the question
POEM;

在 Heredoc 和 Nowdoc 块中可以使用单引号和双引号,而无需额外的转义。但是,Nowdocs 不会插值或动态替换任何值,无论它们是否被转义或其他情况。

接下来的示例可以帮助进一步说明 PHP 中如何使用字符串及其解决的各种问题。

4.1 在更大的字符串中访问子字符串

问题

您希望确定一个字符串是否包含特定的子字符串。例如,您想知道一个 URL 是否包含文本 /secret/

解决方案

使用 strpos()

if (strpos($url, '/secret/') !== false) {
    // A secret fragment was detected, run additional logic
    // ...
}

讨论

PHP 的 strpos() 函数将扫描给定的字符串,并确定给定子字符串的第一次出现的起始位置。这个函数像是在干草堆中找针一样,因为函数的参数分别被命名为 $haystack$needle。如果未找到子字符串($needle),则函数返回布尔值 false

在这种情况下使用严格的相等比较很重要,因为 strpos() 如果子字符串出现在要搜索的字符串的开头,则返回 0。从 Recipe 2.3 中可以了解到,使用只有两个等号的比较将尝试重新转换类型,将整数 0 转换为布尔值 false;始终使用严格比较运算符(=== 表示相等或 !== 表示不相等)以避免混淆。

如果 $needle 在字符串中出现多次,strpos() 只会返回第一次出现的位置。您可以通过将第三个参数作为函数调用的可选位置偏移量来搜索其他出现,如在 示例 4-5 中。定义偏移量还允许您搜索字符串的后半部分,以查找您已知已在字符串中较早出现的子字符串。

示例 4-5. 计算子字符串的所有出现次数
function count_occurrences($haystack, $needle)
{
    $occurrences = 0;
    $offset = 0;
    $pos = 0; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    do {
        $pos = strpos($haystack, $needle, $offset);

        if ($pos !== false) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
            $occurrences += 1;
            $offset = $pos + 1; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        }
    } while ($pos !== false); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

    return $occurrences;
}

$str = 'How much wood would a woodchuck chuck if a woodchuck could chuck wood?';

print count_occurrences($str, 'wood'); // 4 print count_occurrences($str, 'nutria'); // 0

1

所有变量最初都设置为 0,以便跟踪新字符串的出现次数。

2

只有在找到字符串时才应计算一个出现次数。

3

如果找到了字符串,则更新偏移量,但也要增加 1,以免重复计算已经找到的出现次数。

4

一旦达到目标子字符串的最后一个出现,退出循环并返回总计数。

参见

PHP 文档中关于 strpos() 的说明。

4.2 从另一个字符串中提取一个字符串

问题

您想从一个更大的字符串中提取一个小字符串,例如从电子邮件地址中提取域名。

解决方案

使用 substr() 来选择您想要提取的字符串的部分:

$string = 'eric.mann@cookbook.php';
$start = strpos($string, '@');

$domain = substr($string, $start + 1);

讨论

PHP 的 substr() 函数基于初始偏移量(第二个参数)返回给定字符串的部分,直到可选长度。完整的函数签名如下:

function substr(string $string, int $offset, ?int $length = null): string

如果省略了 $length 参数,substr() 将返回字符串的剩余部分。如果 $offset 参数大于输入字符串的长度,则返回一个空字符串。

你也可以指定一个 偏移量,从字符串末尾而不是开头开始返回子集,就像 示例 4-6 中展示的那样。

示例 4-6. 带有负偏移量的子字符串
$substring = substr('phpcookbook', -3); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$substring = substr('phpcookbook', -2); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$substring = substr('phpcookbook', -8, 4); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

返回 ook(最后三个字符)

2

返回 ok(最后两个字符)

3

返回 cook(中间四个字符)

你应该注意一些关于 substr() 中偏移量和字符串长度的其他边界情况。偏移量可以合法地从字符串内部开始,但 $length 可能会超出字符串的末尾。PHP 会捕捉到这种不一致,并且即使最终返回的字符串长度 小于 指定的长度,也仅返回原始字符串的剩余部分。示例 4-7 细节化了基于不同指定长度的 substr() 可能的输出。

示例 4-7. 不同的子字符串长度
$substring = substr('Four score and twenty', 11, 3); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$substring = substr('Four score and twenty', 99, 3); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$substring = substr('Four score and twenty', 20, 3); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

返回 and

2

返回一个空字符串

3

返回 y

另一个边界情况是函数中提供的负 $length。当请求一个具有负长度的子字符串时,PHP 将从返回的子字符串中删除相应数量的字符,正如 示例 4-8 中所示。

示例 4-8. 带有负长度的子字符串
$substring = substr('Four score and twenty', 5); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$substring = substr('Four score and twenty', 5, -11); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

返回 score and twenty

2

返回 score

参见

PHP 文档中的 substr()strpos() 函数。

4.3 替换字符串的一部分

问题

你想要仅用另一个字符串替换字符串的一部分。例如,在将电话号码打印到屏幕之前,你想要模糊显示除最后四位数字以外的所有内容。

解决方案

使用 substr_replace() 基于其位置替换现有字符串的组成部分:

$string = '555-123-4567';
$replace = 'xxx-xxx'

$obfuscated = substr_replace($string, $replace, 0, strlen($replace));
// xxx-xxx-4567

讨论

PHP 的 substr_replace() 函数操作字符串的一部分,类似于 substr(),通过整数偏移量和特定长度定义。示例 4-9 展示了完整的函数签名。

示例 4-9. substr_replace() 的完整函数签名
function substr_replace(
    array|string $string,
    array|string $replace,
    array|int $offset,
    array|int|null $length = null
): string

不像它的 substr() 模拟函数,substr_replace() 可以操作单个字符串或字符串集合。如果将包含标量值的字符串数组传递给 $replace$offset,函数将对每个字符串执行替换,就像 示例 4-10 中展示的那样。

示例 4-10. 同时替换多个子字符串
$phones = [
    '555-555-5555',
    '555-123-1234',
    '555-991-9955'
];

$obfuscated = substr_replace($phones, 'xxx-xxx', 0, 7);

// xxx-xxx-5555
// xxx-xxx-1234
// xxx-xxx-9955

总的来说,开发人员在这个函数的参数上有很大的灵活性。类似于substr(),以下内容是正确的:

  • 如果$offset为负数,则从字符串的末尾开始进行替换。

  • $length可以为负数,表示从字符串末尾开始停止替换的字符数。

  • 如果$lengthnull,它将内部变为输入字符串本身的长度。

  • 如果length0$replace将被插入到给定的$offset处的字符串中,并且根本不会进行替换。

最后,如果$string被提供为数组,那么所有其他参数也可以作为数组提供。每个元素将代表在$string中相同位置的字符串的设置,如示例 4-11 所示。

Example 4-11. 使用数组参数替换多个子字符串
$phones = [
    '555-555-5555',
    '555-123-1234',
    '555-991-9955'
];

$offsets = [0, 0, 4];

$replace = [
    'xxx-xxx',
    'xxx-xxx',
    'xxx-xxxx'
];

$lengths = [7, 7, 8];

$obfuscated = substr_replace($phones, $replace, $offsets, $lengths);

// xxx-xxx-5555
// xxx-xxx-1234
// 555-xxx-xxxx
注意

不要求传递给$string$replace$offset$length的数组具有相同的大小。如果您传递具有不同维度的数组,PHP 不会抛出错误或警告。但这将导致在替换操作期间产生意外的输出,例如截断字符串而不是替换其内容。验证这四个数组的每个维度是否匹配是个好主意。

如果您确切地知道在字符串中需要替换字符的位置,substr_replace()函数是很方便的。在某些情况下,您可能不知道需要替换的子字符串的位置,但您希望替换特定子字符串的出现。在这种情况下,您会希望使用str_replace()str_ireplace()

这两个函数将搜索指定的字符串以查找指定子字符串的出现(或多个出现),并将其替换为其他内容。这两个函数在调用模式上是相同的,但str_ireplace()中的额外的i表示它以不区分大小写的方式搜索模式。示例 4-12 展示了这两个函数的使用。

示例 4-12. 在字符串内搜索和替换
$string = 'How much wood could a Woodchuck chuck if a woodchuck could chuck wood?';

$beaver = str_replace('woodchuck', 'beaver', $string); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$ibeaver = str_ireplace('woodchuck', 'beaver', $string); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

如果一只海狸可以扔木头,它能扔多少木头?

2

如果一只海狸可以扔木头,它能扔多少木头?

str_replace()str_ireplace()都接受一个可选的$count参数,通过引用传递。如果指定了此变量,函数执行的替换次数将更新到此变量中。在示例 4-12 中,由于Woodchuck的大写,返回值分别为12

参见

PHP 文档:substr_replace()str_replace()str_ireplace()

4.4 逐字节处理字符串

问题

你需要从头到尾处理一个由单字节字符组成的字符串,逐个字符处理。

解决方案

像循环遍历数组一样遍历字符串的每个字符。示例 4-13 将计算字符串中大写字母的数量。

示例 4-13. 计算字符串中的大写字母数量
$capitals = 0;

$string = 'The Carriage held but just Ourselves - And Immortality';
for ($i = 0; $i < strlen($string); $i++) {
    if (ctype_upper($string[$i])) {
        $capitals += 1;
    }
}

// $capitals = 5

讨论

在 PHP 中,字符串不是数组,因此不能直接对它们进行循环。但是,它们提供了类似数组的访问方式,可以根据它们在字符串中的位置访问单个字符。你可以通过它们的整数偏移量(从 0 开始)引用单个字符,甚至可以通过偏移量从字符串的末尾开始。

类似数组的访问并非只读。你也可以根据其位置轻松替换字符串中的单个字符,正如示例 4-14 所示。

示例 4-14. 替换字符串中的单个字符
$string = 'A new recipe made my coffee stronger this morning';
$string[31] = 'a';

// A new recipe made my coffee stranger this morning

也可以通过使用str_split()将字符串直接转换为数组,然后迭代结果数组中的所有项目来实现。这将作为更新到解决方案示例,如示例 4-15 中所示。

示例 4-15. 将字符串直接转换为数组
$capitals = 0;

$string = 'The Carriage held but just Ourselves - And Immortality';
$stringArray = str_split($string);
foreach ($stringArray as $char) {
    if (ctype_upper($char)) {
        $capitals += 1;
    }
}

// $capitals = 5

示例 4-15 的缺点在于 PHP 现在必须维护两份数据副本:原始字符串和结果数组。在处理像示例中那样的小字符串时,这不是问题;但如果你的字符串代表磁盘上的整个文件,你将迅速耗尽 PHP 可用的内存。

PHP 使得在不改变数据类型的情况下相对容易地访问字符串中的单个字节(字符)。将字符串拆分为数组是可行的,但除非你确实需要一个字符数组,否则可能是不必要的。示例 4-16 重新构想了示例 4-15,使用了数组缩减技术,而不是直接计算字符串中的大写字母数量。

示例 4-16. 使用数组缩减技术计算字符串中的大写字母数量
$str = 'The Carriage held but just Ourselves - And Immortality';

$caps = array_reduce(str_split($str), fn($c, $i) => ctype_upper($i) ? $c+1: $c, 0);
注意

虽然示例 4-16 在功能上等效于示例 4-15,但它更为简洁,因此更难理解。虽然将复杂逻辑重新构想为单行函数很诱人,但为了简洁而不必要地重构代码可能是危险的。代码可能看起来优雅,但随着时间的推移,会变得更难维护。

在示例 4-16 中引入的简化缩减是功能上准确的,但仍然需要将字符串拆分为数组。它在程序中节省了代码行数,但仍然会导致创建数据的第二份副本。如前所述,如果你迭代的字符串很大(例如大型二进制文件),这将迅速消耗 PHP 可用的内存。

参见

PHP 关于string 访问和修改的文档,以及关于ctype_upper()的文档。

4.5 生成随机字符串

问题

您希望生成一串随机字符。

解决方案

使用 PHP 的本地random_int()函数:

function random_string($length = 16)
{
    $characters = '0123456789abcdefghijklmnopqrstuvwxyz';

    $string = '';
    while (strlen($string) < $length) {
        $string .= $characters[random_int(0, strlen($characters) - 1)];
    }
    return $string;
}

讨论

PHP 拥有强大的、密码学安全的伪随机生成函数,适用于整数和字节。它没有原生函数生成随机的可读文本,但可以通过利用人类可读字符列表来创建这样的随机文本串,示例见解决方案部分。

注意

密码学安全的伪随机数生成器是一种函数,它返回没有可区分或可预测模式的数字。即使是法庭鉴定也无法区分随机噪声和密码学安全生成器的输出。

生成随机字符串的一个有效且可能更简单的方法是利用 PHP 的random_bytes()函数,并将二进制输出编码为 ASCII 文本。Example 4-17 展示了两种可能的使用随机字节作为字符串的方法。

示例 4-17. 创建一串随机字节
$string = random_bytes(16); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$hex = bin2hex($string); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$base64 = base64_encode($string); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

因为二进制字节串将以不同的格式进行编码,所以产生的字节数量将不会与最终字符串的长度匹配。

2

将随机字符串编码为十六进制格式。请注意,这种格式会使字符串长度加倍——16 字节相当于 32 个十六进制字符。

3

利用Base64 编码将原始字节转换为可读字符。请注意,这种格式会使字符串长度增加 33%至 36%。

参见

PHP 关于random_int()random_bytes()的文档。还有关于生成随机数的 Recipe 5.4。

4.6 在字符串中插入变量

问题

您希望在静态字符串中包含动态内容。

解决方案

使用双引号包裹字符串并直接在字符串中插入变量、对象属性甚至函数/方法调用:

echo "There are {$_POST['cats']} cats and {$_POST['dogs']} outside.";
echo "Your username is {strlen($username)} characters long.";
echo "The car is painted {$car->color}.";

讨论

与单引号字符串不同,双引号字符串允许作为字面量使用复杂的动态值。任何以$字符开头的单词都会被解释为变量名,除非该前导字符被正确转义。²

虽然解决方案示例中用花括号包裹动态内容,但在 PHP 中这不是必需的。简单变量可以直接在双引号字符串中写入并正确解析。然而,对于更复杂的序列,如果没有花括号,将会使读取变得困难。强烈推荐的最佳实践是始终用花括号括起要插入的任何值,以使字符串更易读。

不幸的是,字符串插值也有其局限性。解决方案示例展示了从超全局$_POST数组中提取数据并直接插入字符串的操作。这是潜在危险的,因为该内容由用户直接生成,并且该字符串可能在敏感环境中被利用。事实上,这种类似插值的字符串操作是应用程序中最大的注入攻击向量之一。

注意

在注入攻击中,第三方可以传递(或注入)可执行或恶意输入到您的应用程序中,并导致其行为异常。更复杂的防护方法可以在第九章中找到。

为了保护您的字符串免受潜在恶意用户生成的输入的影响,最好使用 PHP 的sprintf()函数通过格式字符串来过滤内容。示例 4-18 重写了解决方案示例的部分,以防止恶意的$_POST数据。

示例 4-18. 使用格式化字符串生成插值字符串
echo sprintf('There are %d cats and %d dogs.', $_POST['cats'], $_POST['dogs']);

格式化字符串在 PHP 中是一种基本的输入清理形式。在示例 4-18 中,您明确假定提供的$_POST数据是数字。格式字符串中的%d标记将被用户提供的数据替换,但 PHP 在替换期间将显式地将这些数据强制转换为整数。

例如,如果此字符串正在插入数据库,则格式化将保护免受针对 SQL 接口的注入攻击的威胁。更完整的用户输入过滤和清理方法在第九章中讨论。

参见

PHP 关于双引号内变量解析和 Heredoc 以及sprintf()函数的文档。

4.7 合并多个字符串

问题

您需要从两个较小的字符串创建一个新的字符串。

解决方案

使用 PHP 的字符串连接运算符:

$first = 'To be or not to be';
$second = 'That is the question';

$line = $first . ' ' . $second;

讨论

PHP 使用单个.字符来连接两个字符串。此运算符还会利用类型强制转换,确保操作中的两个值在串联之前都是字符串,如示例 4-19 所示。

示例 4-19. 字符串连接
print 'String ' . 2; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
print 2 . ' number'; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
print 'Boolean ' . true; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
print 2 . 3; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

打印String 2

2

打印2 number

3

打印Boolean 1因为布尔值被转换为整数然后转换为字符串

4

打印23

字符串连接运算符是将简单字符串快速组合的一种方式,但如果用它来组合多个带有空格的字符串,可能会显得有些冗长。考虑示例 4-20,在这个示例中,您试图将一组单词组合成一个字符串,每个单词之间用空格分隔。

示例 4-20. 连接大量字符串时的冗长性
$words = [
    'Whose',
    'woods',
    'these',
    'are',
    'I',
    'think',
    'I',
    'know'
];

$option1 = $words[0] . ' ' . $words[1] . ' ' . $words[2] . ' ' . $words[3] .
         ' ' . $words[4] . ' ' . $words[5] . ' ' . $words[6] .
         ' ' . $words[7]; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$option2 = '';
foreach ($words as $word) {
    $option2 .= ' ' . $word; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
}
$option2 = ltrim($option2); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

一种选择是逐个将集合中的每个单词与空格分隔符连接起来。随着单词列表的增长,这很快变得难以管理。

2

相反,您可以循环遍历集合并构建一个连接的字符串,而无需逐个访问集合中的每个项。

3

在使用循环时,可能会出现不必要的空白。您需要记住从字符串开头修剪多余的空格。

大型重复的连接例程可以被本机 PHP 函数如implode()所取代。特别是此函数接受要连接的数据数组以及要在数据元素之间使用的字符(或字符)的定义。它返回最终的连接字符串。

注意

有些开发者更喜欢使用join()而不是implode(),因为它被认为是操作的更描述性名称。事实上,join()implode()的别名,PHP 编译器不关心您使用哪个。

重新编写示例 4-20 以使用implode()使整个操作变得简单得多,正如示例 4-21 所示。

示例 4-21. 字符串连接的简洁方法
$words = [
    'Whose',
    'woods',
    'these',
    'are',
    'I',
    'think',
    'I',
    'know'
];

$string = implode(' ', $words);

注意记住implode()的参数顺序。字符串分隔符首先出现,然后是您想要迭代的数组。PHP 的早期版本(PHP 8.0 之前)允许参数以相反的顺序指定。在 PHP 7.4 中,此行为(先指定数组,然后是分隔符)已弃用。从 PHP 8.0 开始,这将引发TypeError

如果您在使用 PHP 8.0 之前编写的库,请确保在将项目部署到生产环境之前测试它没有错误使用implode()join()

参见

PHP 文档关于implode()

4.8 管理存储在字符串中的二进制数据

问题

您希望直接将数据编码为二进制,而不是作为 ASCII 格式的表示,或者您希望读取作为二进制数据明确编码的数据到您的应用程序中。

解决方案

使用unpack()从字符串中提取二进制数据:

$unpacked = unpack('S1', 'Hi'); // [1 => 26952]

使用pack()将二进制数据写入字符串:

$packed = pack('S13', 72, 101, 108, 108, 111, 44, 32, 119, 111,
               114, 108, 100, 33); // 'Hello, world!'

讨论

pack()unpack()都使您能够操作原始二进制字符串,假设您知道您正在使用的二进制字符串的格式。每个函数的第一个参数是格式规范。此规范由特定的格式代码确定,如表 4-2 中定义的。

表 4-2. 二进制格式字符串代码

代码 描述
a 以空字符填充的字符串
A 以空格填充的字符串
h 十六进制字符串,低半字节优先
H 十六进制字符串,高半字节优先
c 有符号字符
C 无符号字符
s 有符号短整型(始终为 16 位,机器字节顺序)
S 无符号短整型(始终为 16 位,机器字节顺序)
n 无符号短整型(始终为 16 位,大端字节顺序)
v 无符号短整型(始终为 16 位,小端字节顺序)
i 有符号整型(机器相关大小和字节顺序)
I 无符号整型(机器相关大小和字节顺序)
l 有符号长整型(始终为 32 位,机器字节顺序)
L 无符号长整型(始终为 32 位,机器字节顺序)
N 无符号长整型(始终为 32 位,大端字节顺序)
V 无符号长整型(始终为 32 位,小端字节顺序)
q 有符号长长整型(始终为 64 位,机器字节顺序)
Q 无符号长长整型(始终为 64 位,机器字节顺序)
J 无符号长长整型(始终为 64 位,大端字节顺序)
P 无符号长长整型(始终为 64 位,小端字节顺序)
f 浮点数(机器相关大小和表示)
g 浮点数(机器相关大小,小端字节顺序)
G 浮点数(机器相关大小,大端字节顺序)
d 双精度浮点数(机器相关大小和表示)
e 双精度浮点数(机器相关大小,小端字节顺序)
E 双精度浮点数(机器相关大小,大端字节顺序)
x 空字节
X 向后移动一个字节
Z 空字节填充字符串
@ 到绝对位置的空字节填充

在定义格式字符串时,可以单独指定每个字节类型,或者利用可选的重复字符。在解决方案示例中,通过整数明确指定字节数。您也可以轻松地使用星号(*)来指定字节类型重复到字符串末尾,如下所示:

$unpacked = unpack('S*', 'Hi'); // [1 => 26952]
$packed = pack('S*', 72, 101, 108, 108, 111, 44, 32, 119, 111,
               114, 108, 100, 33); // 'Hello, world!'

PHP 通过unpack()能够简单地在不同的字节编码类型之间进行转换,也提供了一种将 ASCII 字符与它们的二进制等效物进行转换的简单方法。ord()函数将返回特定字符的值,但如果要依次解包每个字符,则需要循环遍历字符串,正如在示例 4-22 中演示的那样。

示例 4-22. 使用ord()检索字符值
$ascii = 'PHP Cookbook';

$chars = [];
for ($i = 0; $i < strlen($ascii); $i++) {
    $chars[] = ord($ascii[$i]);
}

var_dump($chars);

由于unpack()的帮助,您不需要显式地迭代字符串中的每个字符。c格式字符引用有符号字符,C引用无符号字符。您可以直接利用unpack()来获得等效结果,而无需构建循环,如下所示:

$ascii = 'PHP Cookbook';
$chars = unpack('C*', $ascii);

var_dump($chars);

先前的unpack()示例以及示例 4-22 中的原始循环实现都产生以下数组:

array(12) {
  [1]=>
  int(80)
  [2]=>
  int(72)
  [3]=>
  int(80)
  [4]=>
  int(32)
  [5]=>
  int(67)
  [6]=>
  int(111)
  [7]=>
  int(111)
  [8]=>
  int(107)
  [9]=>
  int(98)
  [10]=>
  int(111)
  [11]=>
  int(111)
  [12]=>
  int(107)
}

参见

PHP 文档关于pack()unpack()

¹ 此字符串是“Hello World!”的八进制表示形式的字节表示。

² 详见表 4-1 了解更多双字符转义序列。

第五章:数字

PHP 数据的另一个基本构建块是数字。在我们周围的世界中很容易找到不同类型的数字。书中的页码通常打印在页脚上。你的智能手表显示当前时间,也许还有你今天走的步数。一些数字可能非常大,另一些可能非常小。数字可以是整数、分数,或者是像 π 这样的无理数。

在 PHP 中,数字可以以两种格式之一本地表示:作为整数(int 类型)或浮点数(float 类型)。两种数值类型都非常灵活,但你可以使用的值的范围取决于你系统的处理器架构 — 32 位系统比 64 位系统有更严格的边界。

PHP 定义了几个常量,帮助程序理解系统中可用数字的范围。考虑到 PHP 的能力因编译方式不同(32 位或 64 位)而有显著差异,最好使用在 表 5-1 中定义的常量,而不是尝试在程序中确定这些值将会是什么。最安全的做法是遵循操作系统和语言的默认设置。

表 5-1. PHP 中的常量数值

常量 描述
PHP_INT_MAX PHP 支持的最大整数值。在 32 位系统上,这个值是 2147483647。在 64 位系统上,这个值是 9223372036854775807
PHP_INT_MIN PHP 支持的最小整数值。在 32 位系统上,这个值是 -2147483648。在 64 位系统上,这个值是 -9223372036854775808
PHP_INT_SIZE 此 PHP 构建中整数的字节大小。
PHP_FLOAT_DIG 可以在 float 中往返舍入的位数,而不会丢失精度。
PHP_FLOAT_​EPSI⁠LON 最小可表示的正数 *x*,使得 *x* + 1.0 !== 1.0
PHP_FLOAT_MIN 可以表示的最小正浮点数。
PHP_FLOAT_MAX 可以表示的最大浮点数。
-PHP_FLOAT_MAX 不是单独的常量,但是表示最小负浮点数的方式。

PHP 数字系统的不幸限制在于非常大或非常小的数字无法本地表示。相反,你需要利用像 BCMathGNU 多精度算术库 (GMP) 这样的扩展,两者都包装了操作系统原生的数字操作。我将在 Recipe 5.10 中具体介绍 GMP。

接下来的配方涵盖了 PHP 中开发者需要解决的许多与数字相关的问题。

5.1 在变量中验证一个数字

问题

你想要检查一个变量是否包含一个数字,即使该变量明确被声明为字符串类型。

解决方案

使用 is_numeric() 来检查一个变量是否可以成功转换为数值,例如:

$candidates = [
    22,
    '15',
    '12.7',
    0.662,
    'infinity',
    INF,
    0xDEADBEEF,
    '10e10',
    '15 apples',
    '2,500'
];

foreach ($candidates as $candidate) {
    $numeric = is_numeric($candidate) ? 'is' : 'is NOT';

    echo "The value '{$candidate}' {$numeric} numeric." . PHP_EOL;
}

在控制台中运行时,上述示例将打印以下内容:

The value '22' is numeric.
The value '15' is numeric.
The value '12.7' is numeric.
The value '0.662' is numeric.
The value 'infinity' is NOT numeric.
The value 'INF' is numeric.
The value '3735928559' is numeric.
The value '10e10' is numeric.
The value '15 apples' is NOT numeric.
The value '2,500' is NOT numeric.

讨论

在其核心,PHP 是一种动态类型语言。您可以轻松地将字符串与整数(反之亦然)进行交换,并且 PHP 将尝试推断您的意图,根据需要动态地将值从一种类型转换为另一种类型。虽然您可以(并且可能应该)像配方 3.4 中讨论的那样强制执行严格类型,但通常您需要显式地将数字编码为字符串。

在这些情况下,您将失去利用 PHP 类型系统识别数值字符串的能力。将一个作为string传递到函数中的变量,在没有显式强制转换为数值类型(intfloat)的情况下,将无法进行数学运算。不幸的是,并非每个包含数字的字符串都是数值的。

字符串15 apples包含一个数字但不是数值。字符串10e10包含非数值字符但是有效的数值表示。

数字字符串和真正的数值字符串之间的差异可以通过 PHP 的本地is_numeric()函数的用户空间实现来最好地说明,如示例 5-1 所定义。

示例 5-1. 用户空间is_numeric()实现
function string_is_numeric(string $test): bool
{
    return $test === (string) floatval($test);
}

应用于与解决方案示例中相同的$candidates数组,示例 5-1 将准确验证几乎所有的数值字符串,除了字面上的INF常量和10e10指数缩写。这是因为在将其转换为浮点数之前,floatval() 将完全去除字符串中的任何非数值字符,同时进行(string)强制类型转换。

用户空间实现并不适用于每种情况,因此您应该使用本机实现以确保安全。is_numeric()的目标是指示给定的字符串是否可以安全地转换为数值类型而不会丢失信息。

参见

PHP is_numeric()的文档。

5.2 比较浮点数

问题

您想要测试两个浮点数的相等性。

解决方案

定义一个合适的误差界限(称为epsilon),表示两个数之间的最大可接受差异,并按以下方式评估它们的差异:

$first = 22.19348234;
$second = 22.19348230;

$epsilon = 0.000001;

$equal = abs($first - $second) < $epsilon; // true

讨论

现代计算机的浮点运算由于机器内部表示数字的方式而不那么精确。您可能手工计算并假设是精确的不同操作可能会让您依赖的机器出现问题。

例如,数学运算1 - 0.83显然是0.17。这足够简单,在头脑中计算甚至在纸上工作出来。但是让计算机计算这个将产生一个奇怪的结果,正如在示例 5-2 中演示的那样。

示例 5-2. 浮点数减法
$a = 0.17;
$b = 1 - 0.83;

var_dump($a == $b); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
var_dump($a); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
var_dump($b); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

bool(false)

2

float(0.17)

3

float(0.17000000000000004)

在涉及浮点运算时,计算机能够做到的最好结果是在可接受误差范围内的近似结果。因此,将此结果与预期值进行比较需要明确定义该误差(epsilon)并与该误差进行比较,而不是与精确值进行比较。

与其利用 PHP 的任一等式运算符(双或三个等号),不如定义一个函数来检查两个浮点数的相对相等性,如示例 5-3 所示。

示例 5-3. 比较浮点数的相等性
function float_equality(float $epsilon): callable
{
    return function(float $a, float $b) use ($epsilon): bool
    {
        return abs($a - $b) < $epsilon;
    };
}

$tight_equality = float_equality(0.0000001);
$loose_equality = float_equality(0.01);

var_dump($tight_equality(1.152, 1.152001)); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
var_dump($tight_equality(0.234, 0.2345)); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
var_dump($tight_equality(0.234, 0.244)); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
var_dump($loose_equality(1.152, 1.152001)); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
var_dump($loose_equality(0.234, 0.2345)); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
var_dump($loose_equality(0.234, 0.244)); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

bool(false)

2

bool(false)

3

bool(false)

4

bool(true)

5

bool(true)

6

bool(true)

参见

PHP 文档中关于浮点数的介绍。

5.3 浮点数四舍五入

问题

您希望将浮点数要么舍入到指定的小数位数,要么舍入到整数。

解决方案

要将浮点数舍入到指定小数位数,使用 round() 并指定小数位数:

$number = round(15.31415, 1);
// 15.3

要明确地向上舍入到最接近的整数,使用 ceil()

$number = ceil(15.3);
// 16

要明确地向下舍入到最接近的整数,使用 floor()

$number = floor(15.3);
// 15

讨论

解决方案示例中提到的三个函数——round()ceil()floor()——旨在对任何数值进行操作,但在操作后将返回一个浮点数。默认情况下,round() 将四舍五入到小数点后零位,但仍将返回一个浮点数。

若要将 float 转换为 int 以便应用这些函数中的任何一个,请将函数本身包装在 intval() 中以转换为整数类型。

在 PHP 中进行四舍五入比仅仅向上或向下舍入更为灵活。默认情况下,round() 函数在数字正好处于中间值时,会向远离 0 的方向进行舍入。这意味着像 1.4 这样的数字会向下舍入,而 1.5 则会向上舍入。负数同样适用:−1.4 会向 0 舍入到 −1,而 −1.5 则会远离 0 舍入到 −2。

通过传递可选的第三个参数(或像 Recipe 3.3 中显示的使用命名参数),可以更改 round() 的行为以指定舍入模式。该参数接受 PHP 定义的四个默认常量之一,如表 5-2 所列。

表 5-2. 舍入模式常量

常量 描述
PHP_ROUND_HALF_UP 当数字正好处于中间值时,远离 0 进行舍入,使得 1.5 变成 2,−1.5 变成 −2
PHP_ROUND_HALF_DOWN 当数字正好处于中间值时,向 0 舍入,使得 1.5 变成 1,−1.5 变成 −1
PHP_ROUND_HALF_EVEN 当值处于中间时,将其向最接近的偶数值舍入,使 1.5 和 2.5 都变为 2
PHP_ROUND_HALF_ODD 当值处于中间时,将其向最接近的奇数值舍入,使 1.5 变为 1,2.5 变为 3

Example 5-4 阐明了在应用于相同数字时每个舍入模式常量的效果。

Example 5-4. 使用不同模式在 PHP 中对浮点数进行四舍五入
echo 'Rounding on 1.5' . PHP_EOL;
var_dump(round(1.5, mode: PHP_ROUND_HALF_UP));
var_dump(round(1.5, mode: PHP_ROUND_HALF_DOWN));
var_dump(round(1.5, mode: PHP_ROUND_HALF_EVEN));
var_dump(round(1.5, mode: PHP_ROUND_HALF_ODD));

echo 'Rounding on 2.5' . PHP_EOL;
var_dump(round(2.5, mode: PHP_ROUND_HALF_UP));
var_dump(round(2.5, mode: PHP_ROUND_HALF_DOWN));
var_dump(round(2.5, mode: PHP_ROUND_HALF_EVEN));
var_dump(round(2.5, mode: PHP_ROUND_HALF_ODD));

上述示例将以下内容打印到控制台:

Rounding on 1.5
float(2)
float(1)
float(2)
float(1)
Rounding on 2.5
float(3)
float(2)
float(2)
float(3)

参见

PHP 文档关于 浮点数round() 函数,ceil() 函数和 floor() 函数。

5.4 生成真正随机数

问题

您希望在特定边界内生成随机整数。

解决方案

使用 random_int() 如下所示:

// Random integer between 10 and 225, inclusive
$random_number = random_int(10, 225);

讨论

当您需要随机性时,通常需要显式真正完全不可预测的随机性。在这些情况下,您可以依赖于内置于机器本身的加密安全伪随机数生成器。PHP 的 random_int() 函数依赖于这些操作系统级数生成器,而不是实现其自己的算法。

在 Windows 上,PHP 将根据使用的语言版本利用 CryptGenRandom()Cryptography API: Next Generation (CNG)。在 Linux 上,PHP 利用系统调用 getrandom(2)。在任何其他平台上,PHP 将回退到系统级 /dev/urandom 接口。所有这些 API 都经过充分测试,被证明是密码学安全的,意味着它们生成具有足够随机性的数字,几乎与噪声不可区分。

注意

在罕见情况下,您可能希望随机数生成器生成可预测的伪随机值序列。在这些情况下,您可以依赖于像 Mersenne Twister 这样的算法生成器,如 Recipe 5.5 进一步讨论的那样。

PHP 并不原生支持创建随机浮点数的方法(即在 0 和 1 之间选择随机小数)。相反,您可以使用 ran⁠dom_​int() 和您在 PHP 中整数的知识来创建自己的函数来实现这一点,如 Example 5-5 所示。

Example 5-5. 生成随机浮点数的用户空间函数
function random_float(): float
{
    return random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}

由于此 random_float() 实现缺乏边界,因此它将始终生成一个介于 0 和 1 之间的数字。这可能对创建随机百分比或随机选择数组的样本大小非常有用。更复杂的实现可能会像 Example 5-6 中展示的那样包含边界,但通常选择在 0 和 1 之间进行选择就足够实用了。

Example 5-6. 生成处于边界内的随机 float 的用户空间函数
function random_float(int $min = 0, int $max = 1): float
{
    $rand = random_int(0, PHP_INT_MAX) / PHP_INT_MAX;

    return ($max - $min) * $rand + $min;
}

这个新版本的random_float()函数仅仅是将原始定义按照新的边界进行缩放。如果你保持默认边界不变,函数就会退化成原始定义。

参见

PHP 文档关于random_int()

5.5 生成可预测的随机数

问题

想要预测随机数,使得每次生成的数列都相同。

解决方案

在调用mt_srand()并传入预定义种子后,可以使用mt_rand()函数,例如:

function generate_sequence(int $count = 10): array
{
    $array = [];

    for ($i = 0; $i < $count; $i++) {
        $array[] = mt_rand(0, 100);
    }

    return $array;
}

mt_srand(42);
$first = generate_sequence();

mt_srand(42);
$second = generate_sequence();

print_r($first);
print_r($second);

在前面示例中的两个数组都将具有以下内容:

Array
(
    [0] => 38
    [1] => 32
    [2] => 94
    [3] => 55
    [4] => 2
    [5] => 21
    [6] => 10
    [7] => 12
    [8] => 47
    [9] => 30
)

讨论

当讨论真正的随机数示例时,任何人都只能展示输出的可能样子。但是当涉及到mt_rand()的输出时,只要使用相同的种子,输出在任何计算机上都将相同。

注意

PHP 默认情况下会随机种子mt_rand()。除非目标是从函数中获取确定性输出,否则不需要指定自己的种子。

输出相同是因为mt_rand()使用了名为Mersenne Twister的算法伪随机数生成器。这是一个广为人知和广泛使用的算法,首次引入于 1997 年;它还被用在 Ruby 和 Python 等语言中。

给定一个初始种子值,算法创建一个初始状态,然后通过在该状态上执行“扭转”操作生成看似随机的数字序列。这种方法的优势在于它是确定性的——给定相同的种子,算法每次都会生成相同的“随机”数序列。

警告

随机数的可预测性对于某些计算操作可能具有危害,特别是在密码学中。需要确定性伪随机数序列的用例并不常见,因此应尽量避免使用mt_rand()。如果需要生成随机数,应该利用像random_int()random_bytes()这样的真实随机源。

创建一个伪随机但可预测的数列可能在为数据库创建对象 ID 时很有用。通过多次运行代码并验证输出,可以轻松测试代码的正确运行。缺点是像 Mersenne Twister 这样的算法可能会受到外部方的操纵。

如果知道算法并给定足够长的看似随机数序列,反向操作识别原始种子将变得非常容易。一旦攻击者知道种子值,他们就能生成系统未来将使用的每一个“随机”数。

参见

PHP 文档关于mt_rand()mt_srand()

5.6 生成加权随机数

问题

您希望生成随机数以随机选择集合中的特定项目,但希望某些项目被选中的机会更高。例如,您希望在活动中选择特定挑战的获胜者,但有些参与者赚取的积分比其他人多,因此需要更大的被选中机会。

解决方案

将选择和权重的映射传递给weighted_​ran⁠dom_choice()的实现,就像在示例 5-7 中演示的那样。

示例 5-7. 加权随机选择的实现
$choices = [
    'Tony'  => 10,
    'Steve' => 2,
    'Peter' => 1,
    'Wanda' => 4,
    'Carol' => 6
];

function weighted_random_choice(array $choices): string
{
    arsort($choices);

    $total_weight = array_sum(array_values($choices));
    $selection = random_int(1, $total_weight);

    $count = 0;
    foreach ($choices as $choice => $weight) {
        $count += $weight;
        if ($count >= $selection) {
            return $choice;
        }
    }

    throw new Exception('Unable to make a choice!');
}

print weighted_random_choice($choices);

讨论

在解决方案示例中,每个可能的选择都被分配了一个权重。为了选择最终选项,您可以按权重排序每个选项,权重最高的选项在列表中排在第一位。然后,您在总可能权重范围内确定一个随机数。该随机数选择您选择的选项之一。

这在数轴上最容易进行可视化。在解决方案示例中,Tony 的权重为 10,Peter 的权重为 1。这意味着 Tony 比 Peter 有可能赢得的机会多 10 倍,但仍然有可能两者都不会被选择。图 5-1 说明了如果按权重排序并在数轴上打印可能的选择,每个选择的相对权重。

在连续线上可视化的加权随机选择

图 5-1. 按权重排序和可视化的潜在选择

weighted_random_choice()中定义的算法将检查所选随机数是否在每个可能选择的范围内,如果不在,则继续下一个候选项。如果由于任何原因无法进行选择,函数将抛出异常。²

可以通过执行一千次随机选择并绘制每个选择被选中的相对次数来验证这种选择的加权性质。示例 5-8 展示了如何对这样的重复选择进行制表,而图 5-2 说明了结果。两者都清楚地展示了 Tony 被选中的可能性比候选数组中的任何其他选项更高。

示例 5-8. 重复选择加权随机选择
$output = fopen('output.csv', 'w');
fputcsv($output, ['selected']);

foreach (range(0, 1000) as $i) {
    $selection = weighted_random_choice($choices);
    fputcsv($output, [$selection]);
}
fclose($output);

1000 次加权随机选择的结果。

图 5-2. 饼图说明每个选择被选中的相对次数

这个在 1,000 次迭代后的结果清楚地表明,Tony 被选择的频率大约是 Peter 的 10 倍。这与他的权重为 10,Peter 的权重为 1 完美契合。同样,Wanda 的权重为 4 可靠地表明她被选择的频率是 Steve 的两倍,后者的权重为 2。

鉴于这里的选择是随机的,再次运行相同的实验将导致每个候选人的百分比略有不同。然而,每个候选人的整数权重始终会转化为大致相同的选择分布。

参见

PHP 文档关于random_int()arsort(),以及在实践中进一步使用random_int()的 Recipe 5.4 的例子。

5.7 计算对数

问题

您想要计算一个数的对数。

解决方案

对于自然对数(使用基数e),使用log()如下:

$log = log(5);
// 1.6094379124341

对于任意基数的对数,将基数作为第二个可选参数指定:

$log2 = log(16, 2);
// 4.0

讨论

PHP 通过其本机 Math 扩展支持对数计算。当您调用log()而不指定基数时,PHP 将回退到默认的M_E常量,其编码为e的值,约为 2.718281828459。

如果尝试对负值取对数,PHP 将始终返回NAN,一个表示不是一个数字的常量(类型为float)。如果尝试传递一个负的底数,PHP 将返回一个ValueError并触发警告。

log()支持任何正的非零基数。许多应用程序如此频繁地使用基数 10,以至于 PHP 支持一个单独的log10()函数专门用于此基数。这在功能上等同于将整数10作为基数传递给log()

参见

PHP 文档对Math 扩展支持的各种功能的说明,包括log()log10()

5.8 计算指数

问题

您想将一个数提升到任意幂。

解决方案

使用 PHP 的pow()函数如下:

// 2⁵
$power = pow(2, 5); // 32

// 3⁰.5
$power = pow(3, 0.5); // 1.7320508075689

// e²
$power = pow(M_E, 2); // 7.3890560989306

讨论

PHP 的pow()函数是将任意数提升到任意幂并返回整数或浮点结果的有效方法。除了函数形式外,PHP 还提供了一个特殊的运算符来将一个数提升到幂:**

下面的代码等效于解决方案示例中使用pow()的使用:

// 2⁵
$power = 2 ** 5; // 32

// 3⁰.5
$power = 3 ** 0.5; // 1.7320508075689

// e²
$power = M_E ** 2; // 7.3890560989306
警告

虽然数学上指数运算的简写通常是插入符(^),但在 PHP 中,此字符保留为异或运算符。请查看第二章以获取更多关于此及其他运算符的详细信息。

当对常数e进行任意幂运算时,可以通过pow()实现,PHP 还内置了专门用于此用途的函数:exp()。表达式pow(M_E, 2)exp(2)在功能上是等效的。它们通过不同的代码实现,并且由于 PHP 内部浮点数的表示方式,它们返回的结果略有不同。³

参见

PHP 文档关于pow()exp()

5.9 将数字格式化为字符串

问题

您希望打印一个带有千位分隔符的数字,以使您的应用程序的最终用户更容易阅读。

解决方案

使用number_format()在将数字转换为字符串时自动添加千位分隔符。例如:

$number = 25519;
print number_format($number);
// 25,519

$number = 64923.12
print number_format($number, 2);
// 64,923.12

讨论

PHP 的本机number_format()函数将自动将千位分组并将小数位四舍五入到指定的精度。您还可以选择更改小数和千位分隔符,以匹配给定的语言环境或格式。

例如,假设您希望使用句点分隔千位组,并使用逗号分隔小数位(如丹麦数值格式中常见)。要实现这一点,您可以利用number_format()如下所示:

$number = 525600.23;

print number_format($number, 2, ',', '.');
// 525.600,23

PHP 的本机NumberFormatter类提供类似的实用功能,但允许您显式定义语言环境,而无需记住特定的区域格式。⁴您可以重写前面的例子,使用NumberFormatter特定于da_DK语言环境来识别丹麦格式,如下所示:

$number = 525600.23;

$fmt = new NumberFormatter('da_DK', NumberFormatter::DEFAULT_STYLE);
print $fmt->format($number);
// 525.600,23

参见

PHP 关于number_format()NumberFormatter的文档。

5.10 处理非常大或非常小的数字

问题

当你需要使用超出 PHP 本机整数和浮点类型处理能力的数字时。

解决方案

使用 GMP 库:

$sum = gmp_pow(4096, 100);
print gmp_strval($sum);
// 17218479456385750618067377696052635483579924745448689921733236816400
// 74069124174561939748453723604617328637091903196158778858492729081666
// 10249916098827287173446595034716559908808846798965200551239064670644
// 19056526231345685268240569209892573766037966584735183775739433978714
// 57858778270138079724077247764787455598671274627136289222751620531891
// 4435913511141036261376

讨论

PHP 支持两个扩展来处理超出本机类型范围的数字。BCMath 扩展是一个接口,连接到一个支持任意精度数学的系统级基本计算器实用程序。与本机 PHP 类型不同,BCMath 支持使用高达 2,147,483,647 位小数,只要系统内存足够。

不幸的是,BCMath 在 PHP 中将所有数字编码为常规字符串,这使得在目标为严格类型强制的现代应用程序中使用它有些困难。⁵

GMP 扩展也是 PHP 的一种有效替代方案,它没有这个缺点。在内部,数字被存储为字符串。但是,当与 PHP 的其他部分接触时,它们被包装为GMP对象。这种区别有助于澄清一个函数是在一个被编码为字符串的小数字上操作,还是在需要使用扩展的大数字上操作。

注意

BCMath 和 GMP 操作和返回整数值,而不是浮点数。如果需要对浮点数进行操作,您可能需要将数字大小增加一个数量级(即乘以 10),然后在计算完成后再次减少,以考虑小数或分数。

PHP 默认情况下不包含 GMP,尽管许多发行版都可以很容易地提供它。如果您从源代码编译 PHP,可以使用--with-gmp选项来自动添加支持。如果您使用软件包管理器安装 PHP(例如在 Linux 机器上),您可能可以直接安装php-gmp软件包以添加这种支持。⁶

一旦可用,GMP 将使您能够在无限大小的数字上执行任何数学操作。但问题是,您不能再使用 PHP 的原生运算符,必须使用扩展本身定义的函数格式。Example 5-9 展示了从原生运算符到 GMP 函数调用的一些转换。请注意,每个函数调用的返回类型都是一个GMP对象,因此您必须使用gmp_intval()gmp_strval()将其转换回数字或字符串。

示例 5-9。各种数学操作及其 GMP 函数等效项
$add = 2 + 5;
$add = gmp_add(2, 5);

$sub = 23 - 2;
$sub = gmp_sub(23, 2);

$div = 15 / 4;
$div = gmp_div(15, 4);

$mul = 3 * 9;
$mul = gmp_mul(3, 9);

$pow = 4 ** 7;
$pow = gmp_pow(4, 7);

$mod = 93 % 4;
$mod = gmp_mod(93, 4);

$eq = 42 == (21 * 2);
$eq = gmp_cmp(42, gmp_mul(21, 2));

Example 5-9 中的最后一个示例介绍了gmp_cmp()函数,它允许您比较两个 GMP 封装的值。如果第一个参数大于第二个参数,则此函数将返回一个正值,如果它们相等,则返回 0,如果第二个参数大于第一个参数,则返回一个负值。这与 PHP 的太空船操作符(在 Recipe 2.4 中引入)实际上是相同的,而不是一个相等比较,这可能提供了更多的实用性。

参见

PHP 文档上的GMP部分。

5.11 数字之间的进制转换

问题

您希望将一个数字从一种基数转换为另一种基数。

解决方案

使用base_convert()函数如下:

// Base 10 (decimal) number
$decimal = '240';

// Convert from base 10 to base 16
// $hex = 'f0'
$hex = base_convert($decimal, 10, 16);

讨论

base_convert()函数尝试将一个数字从一种基数转换为另一种基数,这在处理十六进制或二进制数据字符串时特别有用。PHP 仅支持 2 到 36 之间的基数。高于 10 的基数将使用字母字符表示额外的数字——a等于10b等于11,一直到z等于35

请注意,解决方案示例将一个字符串传递给base_convert()而不是一个整数或浮点数值。这是因为 PHP 将尝试将输入字符串转换为具有适当基数的数字,然后将其转换为另一种基数并返回一个字符串。在 PHP 中,字符串是表示十六进制或八进制数的最佳方式,但它们足够通用,可以表示任何基数的数字。

除了更通用的base_convert()外,PHP 还支持几种基数特定的转换函数。这些额外的函数在 Table 5-3 中列出。

警告

PHP 支持两个函数用于二进制数据和其十六进制表示之间的相互转换:bin2hex()hex2bin()。这些函数不用于将二进制的字符串表示(例如11111001)转换为十六进制,而是操作该字符串的二进制字节

表 5-3. 特定的基数转换函数

函数名称 从基数 到基数
bindec() 二进制(以string编码) 十进制(以int或由于大小原因而是float
decbin() 十进制(以int编码) 二进制(以string编码)
hexdec() 十六进制(以string编码) 十进制(以int或由于大小原因而是float
dechex() 十进制(以int编码) 十六进制(以string编码)
octdec() 八进制(以string编码) 十进制(以int或由于大小原因而是float
decoct() 十进制(以int编码) 八进制(以string编码)

请注意,与base_convert()不同,专用的基数转换函数通常直接与数值类型一起工作。如果使用严格的类型检查,这将避免在改变基数之前需要从数值类型显式转换为string,这在使用base_convert()时是必需的。

参见

PHP 关于base_convert()的文档。

¹ 更多关于类型转换的信息,请查阅“类型转换”。

² 异常和错误处理在第十二章中有详细讨论。

³ 关于浮点数之间可接受差异的更多信息,请参阅 Recipe 5.2。

NumberFormatter类本身是 PHP 的intl扩展的一部分。这个模块不是默认内置的,可能需要安装或启用该扩展才能使用该类。

⁵ 更多关于 PHP 严格类型的信息,请参阅“类型转换”。

⁶ 本地扩展在第十五章中有详细介绍。

第六章:日期与时间

操纵日期和时间是任何语言中最复杂的任务之一,更不用说在 PHP 中了。这只是因为时间是相对的——现在对于每个用户都可能不同,并且可能会触发应用程序中不同的行为。

对象导向

PHP 开发人员主要通过DateTime对象来处理代码。这些对象通过包装特定时间实例提供广泛的功能。您可以计算两个DateTime对象之间的差异,转换任意时区,或者从静态对象中添加/减去时间窗口。

此外,PHP 还支持DateTimeImmutable对象,它在功能上与DateTime相同,但不能直接修改。DateTime对象上的大多数方法既返回同一对象又改变其内部状态。DateTimeImmutable上的相同方法保持内部状态不变,但返回表示更改结果的新实例

注意

两个日期/时间类都扩展自抽象的DateTimeInterface基类,使得这两个类在 PHP 的日期和时间功能中几乎可以互换使用。在本章中,无论何处看到DateTime,您都可以改用DateTimeImmutable实例,并实现类似甚至相同的功能。

时区

任何开发人员面临的最大挑战之一是处理时区,特别是涉及夏令时时。一方面,简化并假设应用程序中的每个时间戳引用相同的时区是很容易的。但这种情况很少发生。

幸运的是,PHP 使得处理时区变得非常容易。每个DateTime对象自动嵌入一个时区,通常基于 PHP 运行系统中定义的默认值。您还可以在创建DateTime时显式设置时区,从而使您引用的区域和时间变得完全明确。在第 6.9 节中也详细介绍了时区之间的转换,简单而强大。

Unix 时间戳

许多计算机系统在内部使用 Unix 时间戳来表示日期和时间。这些时间戳表示从 Unix 纪元(1970 年 1 月 1 日 00:00:00 GMT)到给定时间经过的秒数。它们在内存效率上很高,并经常被数据库和编程接口使用。然而,计算自固定日期/时间以来的秒数并不完全用户友好,因此您需要一种可靠的方法在应用程序中在 Unix 时间戳和人类可读日期/时间表示之间进行转换。

PHP 的原生格式化功能使得这一切变得简单直接。类似time()这样的附加函数直接生成 Unix 时间戳。

下面的示例详细介绍了这些主题,以及其他几个常见的日期/时间相关任务。

6.1 查找当前日期和时间

问题

您想要知道当前日期和时间。

解决方案

要按特定格式打印当前日期和时间,请使用date()。例如:

$now = date('r');

date()的输出取决于其运行所在系统和当前实际时间。使用r作为格式字符串,该函数会返回类似以下的内容:

Wed, 09 Nov 2022 14:15:12 -0800

同样,新实例化的DateTime对象也将表示当前日期和时间。该对象上的::format()方法展现出与date()相同的行为,这意味着以下两个语句在功能上是相同的:

$now = date('r');

$now = (new DateTime())->format('r');

讨论

PHP 的date()函数以及不带参数实例化的DateTime对象将自动继承其运行系统的当前日期和时间。在这两者中额外传入的r是一个格式字符,定义了如何将给定的日期/时间信息转换为字符串——在本例中,特别是按照RFC 2822格式化的日期。您可以在 Recipe 6.2 中了解更多关于日期格式化的信息。

利用 PHP 的getdate()函数,您可以获取当前系统日期和时间的所有部分的关联数组,这个数组将包含在表 6-1 中的键和值。

表 6-1. getdate()返回的关键元素

值的描述 示例
seconds 秒数 059
minutes 分钟 059
hours 小时 023
mday 月中的天数 131
wday 周几 0(星期日)到 6(星期六)
mon 月份 112
year 完整的四位数年份 2023
yday 年中的天数 0365
weekday 星期几 SundaySaturday
month 年中的月份 JanuaryDecember
0 Unix 时间戳 02147483647

在某些应用中,您可能只需要星期几而不是完整操作的DateTime对象。考虑示例 6-1,它展示了如何使用DateTimegetdate()来实现这一目标。

示例 6-1. 比较DateTimegetdate()
print (new DateTime())->format('l') . PHP_EOL;

print getdate()['weekday'] . PHP_EOL;

这两行代码在功能上是等效的。对于像“打印今天的日期”这样的简单任务,任何一种都足够完成工作。DateTime对象提供了转换时区或预测未来日期的功能(这些都在其他配方中进一步介绍)。而getdate()返回的关联数组则缺乏这种功能,但通过其简单易识别的数组键来弥补这一缺点。

参见

PHP 文档关于date 和时间函数DateTime,以及getdate()函数

6.2 日期和时间的格式化

问题

您想要将日期打印为特定格式的字符串。

解决方案

使用给定的DateTime对象的::format()方法来指定返回字符串的格式,如示例 6-2 所示。

示例 6-2. 日期和时间格式示例
$birthday = new DateTime('2017-08-01');

print $birthday->format('l, F j, Y') . PHP_EOL; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
print $birthday->format('n/j/y') . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
print $birthday->format(DateTime::RSS) . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

Tuesday, August 1, 2017

2

8/1/17

3

Tue, 01 Aug 2017 00:00:00 +0000

讨论

date()函数和DateTime对象的::format()方法都接受多种输入字符串,最终定义了 PHP 生成的字符串的结构。每个格式字符串由表示日期或时间值特定部分的单个字符组成,正如你可以在表 6-2 中看到的那样。

表格 6-2. PHP 格式字符

字符 描述 示例值
日期
d 月份中的日期,带有前导零 0131
D 一周中某天的文本表示,三个字母 MonSun
j 月份中的日期,不带前导零 131
l 一周中某天的名称 SundaySaturday
N ISO 8601 表示的星期几 1(代表星期一)到7(代表星期日)
S 日期的英文序数后缀,两个字符 st, nd, rd, 或 th。与j结合使用
w 一周中的日期表示,数字形式 0(代表星期日)到6(代表星期六)
z 一年中的第几天(从 0 开始) 0365
月份
F 月份的完整名称 JanuaryDecember
m 月份的数字表示,带有前导零 0112
M 月份的文本表示,三个字母 JanDec
n 月份的数字表示,不带前导零 112
t 给定月份的天数 2831
年份
L 是否为闰年 如果是闰年则为1,否则为0
o ISO 8601 周年份。其值与Y相同,但如果 ISO 周属于前一年或后一年,则使用该年份 19992003
Y 年份的完整数字表示,四位数 19992003
y 年份的两位数表示 9903
时间
a 小写的上午或下午 ampm
A 大写的上午或下午 AMPM
g 带有前导零的 12 小时制小时数 112
G 24 小时制小时数,不带前导零 023
h 带有前导零的 12 小时制小时数 0112
H 带有前导零的 24 小时制小时数 0023
i 带有前导零的分钟 0059
s 带有前导零的秒数 0059
u 微秒 654321
v 毫秒 654
时区
e 时区标识符 UTCGMTAtlantic/Azores
I 日期是否处于夏令时 夏令时为 1,否则为 0
O 与格林尼治时间(GMT)的差异,小时和分钟之间不用冒号分隔 +0200
P 与格林尼治时间(GMT)的差异,小时和分钟之间用冒号分隔 +02:00
p 与 P 相同,但返回 Z 而不是 +00:00 +02:00
T 如果已知则为时区缩写;否则为 GMT 偏移量。 ESTMDT+05
Z 时间偏移秒数 -4320050400
其他
U Unix 时间戳 02147483647

将这些字符组合成格式字符串确定了 PHP 如何将给定的日期/时间构造转换为字符串。

类似地,PHP 定义了几个预定义常量,表示众所周知且广泛使用的格式。 表 6-3 显示了一些最有用的常量。

表 6-3. 预定义日期常量

常量 类常量 格式字符 示例
DATE_ATOM DateTime::ATOM Y-m-d\TH:i:sP 2023-08-01T13:22:14-08:00
DATE_COOKIE DateTime::COOKIE l, d-M-Y H:i:s T Tuesday, 01-Aug-2023 13:22:14 GMT-0800
``DATE_ISO8601footnote:[不幸的是,DATE_ISO8601不兼容 ISO 8601 标准。如果需要该级别的兼容性,请改用DATE_ATOM`。] DateTime::ISO8601 Y-m-d\TH:i:sO 2013-08-01T21:21:14\+0000
DATE_RSS DateTime::RSS D, d M Y H:i:s O Tue, 01 Aug 2023 13:22:14 -0800

参见

有关 格式字符预定义 DateTime 常量 的完整文档。

6.3 转换日期和时间为 Unix 时间戳

问题

您想要将特定日期或时间转换为 Unix 时间戳,并将给定的 Unix 时间戳转换为本地日期或时间。

解决方案

要将给定的日期/时间转换为时间戳,请使用 U 格式字符(参见 表 6-2),使用 DateTime::format() 如下所示:

$date = '2023-11-09T13:15:00-0700';
$dateObj = new DateTime($date);

echo $dateObj->format('U');
// 1699560900

要将给定的时间戳转换为 DateTime 对象,同样使用 U 格式字符,但是要用 DateTime::createFromFormat() 如下所示:

$timestamp = '1648241792';
$dateObj = DateTime::createFromFormat('U', $timestamp);

echo $dateObj->format(DateTime::ISO8601);
// 2022-03-25T20:56:32+0000

讨论

::createFromFormat() 方法是 DateTime::format() 方法的静态逆过程。这两个函数使用相同的格式字符串来指定使用的格式¹,但表示格式化字符串和 DateTime 对象的基础状态之间的相反转换。解决方案示例明确利用 U 格式字符告知 PHP 输入数据为 Unix 时间戳。

如果输入字符串实际上与您的格式不匹配,PHP 将返回一个字面上的 false,如以下示例所示:

$timestamp = '2023-07-23';
$dateObj = DateTime::createFromFormat('U', $timestamp);

echo gettype($dateObj); // false

在解析用户输入时,明智的做法是显式检查::createFromFormat()的返回值,以确保日期输入是有效的。有关日期验证的更多信息,请参见食谱 6.7。

与其使用完整的DateTime对象,不如直接操作日期/时间的部分。PHP 的mktime()函数始终返回 Unix 时间戳,唯一必需的参数是小时。

例如,假设您希望表示 2023 年 7 月 4 日中午在 GMT 时区(无时区偏移)的 Unix 时间戳。您可以通过两种方式实现,如示例 6-3 中所示。

示例 6-3. 直接创建时间戳
$date = new DateTime('2023-07-04T12:00:00');
$timestamp = $date->format('U'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$timestamp = mktime(month: 7, day: 4, year: 2023, hour: 12); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

此输出将完全是1688472000

2

此输出将接近1688472000,但在最后三位数上会有所变化。

虽然这个简单的例子看起来优雅,避免了只为了再转换为数字而实例化对象的问题,但它有一个重要问题。未指定参数(在本例中为分钟或秒)将导致mktime()默认使用当前系统值作为这些参数。如果在下午 3 点 05 分运行此示例代码,则输出可能是1688472300

此 Unix 时间戳在转换回DateTime时转换为 12:05:00,而不是应用程序期望的 12:00:00(可能是可忽略的差异)。

重要的是要记住,如果选择利用mktime()的函数接口,要么为日期/时间的每个组件提供值,要么构建应用程序,以便预期和处理轻微偏差。

另请参见

文档中的DateTime::createFromFormat()

6.4 从 Unix 时间戳转换为日期和时间部分

问题

您希望从 Unix 时间戳中提取特定的日期或时间部分(天或小时)。

解决方案

将 Unix 时间戳作为参数传递给getdate(),并引用生成的关联数组中的所需键。例如:

$date = 1688472300;
$time_parts = getdate($date);

print $time_parts['weekday'] . PHP_EOL; // Tuesday
print $time_parts['hours'] . PHP_EOL;   // 12

讨论

您可以向getdate()提供的唯一参数是 Unix 时间戳。如果省略此参数,PHP 将利用当前系统日期和时间。当提供时间戳时,PHP 在内部解析该时间戳,并允许提取所有预期的日期和时间元素。

或者,您可以通过两种方式将时间戳传递到DateTime实例的构造函数中以构建一个完整的对象:

  1. 在时间戳前加上@字符告诉 PHP 将输入解释为 Unix 时间戳,例如,new DateTime('@1688472300')

  2. 当导入时间戳到DateTime对象时,可以使用U格式字符,例如,DateTime::createFromFormat('U', '1688472300')

无论如何,一旦您的时间戳被正确解析并加载到 DateTime 对象中,您可以使用其 ::format() 方法提取任何所需的组件。示例 6-4 是一个使用 DateTime 而不是 getdate() 的解决方案示例的替代实现。

示例 6-4. 从 Unix 时间戳中提取日期和时间部分
$date = '1688472300';

$parsed = new DateTime("@{$date}");
print $parsed->format('l') . PHP_EOL;
print $parsed->format('g') . PHP_EOL;

$parsed2 = DateTime::createFromFormat('U', $date);
print $parsed2->format('l') . PHP_EOL;
print $parsed2->format('g') . PHP_EOL;

示例 6-4 中的任一方法都可以有效替代 getdate(),同时还提供了一个完全可用的 DateTime 实例。您可以以任何格式打印日期(或时间),直接操作底层值,或者根据需要在时区之间进行转换。DateTime 的这些潜在进一步用途在后续的配方中都有涵盖。

参见

食谱 6.1 进一步讨论 getdate()。请继续阅读 食谱 6.8 以了解如何操作 DateTime 对象,以及 食谱 6.9 中如何直接管理时区。

6.5 计算两个日期之间的差异

问题

您想知道两个日期或时间之间经过了多少时间。

解决方案

将每个日期/时间封装在 DateTime 对象中。利用其中一个对象上的 ::diff() 方法计算它与另一个 DateTime 之间的相对差异。结果将是一个 DateInterval 对象,如下所示:

$firstDate = new DateTime('2002-06-14');
$secondDate = new DateTime('2022-11-09');

$interval = $secondDate->diff($firstDate);

print $interval->format('%y years %d days %m months');
// 20 years 25 days 4 months

讨论

DateTime 对象的 ::diff() 方法有效地从另一个日期/时间(作为方法参数传递的日期/时间)中减去另一个日期/时间(对象本身表示的日期/时间)。其结果是两个对象之间时间相对持续的表示。

警告

::diff() 方法忽略夏令时。为了正确考虑到该系统固有的潜在一小时差异,首先将两个日期/时间对象转换为 UTC 是一个好主意。

还需要注意的是,尽管解决方案示例中看起来可能类似,但 DateInterval 对象的 ::format() 方法使用的格式字符完全不同于 DateTime 使用的格式字符。每个格式字符都必须以字面 % 字符为前缀,但格式字符串本身可以包含非格式化字符(例如解决方案示例中的 )。

可用的格式字符在 表 6-4 中列出。除了 ar 格式字符外,使用小写格式字符将返回一个数字值,没有前导 0。列出的大写格式字符将返回至少两位数,带有前导 0。记住,每个格式字符前面必须加上字面量 %

表 6-4. DateInterval 格式字符

字符 描述 示例
% 字面 % %
Y 03
M 02
D 09
H 小时 08
I 分钟 01
S 04
F 微秒 007705
R 在负数时显示-,在正数时显示+ -+
r 在负数时显示-,在正数时为空 -
a 总天数 548

参见

完整文档请参见DateInterval

6.6 从任意字符串解析日期和时间

问题

你需要将任意用户定义的字符串转换为有效的 DateTime 对象,以便进一步使用或操作。

解决方案

使用 PHP 强大的 strtotime() 函数将文本输入转换为 Unix 时间戳,然后传递给新 DateTime 对象的构造函数。例如:

$entry = strtotime('last Wednesday');
$parsed = new DateTime("@{$entry}");

$entry = strtotime('now + 2 days');
$parsed = new DateTime("@{$entry}");

$entry = strtotime('June 23, 2023');
$parsed = new DateTime("@{$entry}");

讨论

strtotime() 的强大之处来自语言支持的底层日期和时间导入格式。这些包括你可能期望计算机使用的格式(如 YYYY-MM-DD 表示年、月、日)。但它还扩展到相对指定器和复杂的复合格式。

注释

将 Unix 时间戳作为前缀的文本直接传递给 DateTime 构造函数的约定,来源于 PHP 支持的复合日期/时间格式。

相对格式是最强大的,支持像这样的人类可读字符串:

  • yesterday

  • first day of

  • now

  • ago

掌握这些格式后,你几乎可以用 PHP 解析任何想象得到的字符串。然而,也有一些限制。在解决方案示例中,我使用了 now + 2 days 来指定“从现在开始的 2 天后”。示例 6-5 显示,尽管在英语中读起来很好,但在 PHP 中却会导致解析错误。

示例 6-5. strtotime() 解析的限制
$date = strtotime('2 days from now');

if ($date === false) {
    throw new InvalidArgumentException('Error parsing the string!');
}

应该始终注意,无论你如何使计算机聪明,你始终受到最终用户提供的输入质量的限制。没有办法预见每种指定日期或时间的可能方式;strtotime() 接近,但你也需要处理输入错误。

另一个解析用户提供的日期的潜在方式是 PHP 的 date_parse() 函数。与 strtotime() 不同,此函数期望一个合理格式化的输入字符串。它也不能完全像相对时间一样处理。示例 6-6 说明了几个可以由 date_parse() 解析的字符串。

示例 6-6. date_parse() 示例
$first = date_parse('January 4, 2022'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$second = date_parse('Feb 14'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$third = date_parse('2022-11-12 5:00PM'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$fourth = date_parse('1-1-2001 + 12 years'); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

解析 2022 年 1 月 4 日

2

解析 2 月 14 日,但年份为 null

3

解析日期和时间,但没有时区

4

解析日期并存储额外的相对字段

date_parse() 不会返回时间戳,而是从输入字符串中提取相关的日期/时间部分,并将它们存储在带有以下键的关联数组中:

  • year

  • month

  • day

  • hour

  • minute

  • second

  • fraction

此外,将字符串中的时间相对规范(例如 Example 6-6 中的+ 12 years)将在数组中添加一个relative键,其中包含有关相对偏移的信息。

所有这些都有助于确定用户提供的日期是否作为实际日期有用。如果date_parse()函数遇到任何解析问题,它还将返回警告和错误,这样更容易检查日期是否有效。要了解更多关于检查日期有效性的信息,请阅读 Recipe 6.7。

重新访问 Example 6-5 并利用date_parse()显示了为什么 PHP 在解析2 days from now作为相对日期时遇到困难的更多信息。考虑以下例子:

$date = date_parse('2 days from now');

if ($date['error_count'] > 0) {
    foreach ($date['errors'] as $error) {
        print $error . PHP_EOL;
    }
}

前面的代码将打印The time zone could not be found in the database,这表明 PHP 正在尝试解析日期,但无法确定语句from now中的from实际上意味着什么。事实上,检查$date数组本身将显示它返回了一个relative键。这个相对偏移正确表示了指定的两天,这意味着date_parse()(甚至strtotime())能够读取相对日期偏移(2 days),但在最后一部分上却无法处理。

这个额外的错误提供了进一步的调试上下文,可能会为应用程序提供给最终用户的某种错误消息提供信息。无论如何,这比strtotime()单独返回的简单false更有帮助。

参见

date_parse()strtotime()的文档。

6.7 验证日期

问题

你想确保一个日期是有效的。例如,你想确保用户定义的生日是日历上的真实日期,而不是像 2022 年 11 月 31 日这样的日期。

解决方案

使用 PHP 的checkdate()函数如下:

$entry = 'November 31, 2022';
$parsed = date_parse($entry);

if (!checkdate($parsed['month'], $parsed['day'], $parsed['year'])) {
    throw new InvalidArgumentException('Specified date is invalid!');
}

讨论

date_parse()函数已经在 Recipe 6.6 中讨论过,但与checkdate()一起使用是新的。这第二个函数尝试验证日期是否根据日历有效。

它检查月份(第一个参数)是否在 1 到 12 之间,年份(第三个参数)是否在 1 到 32,767 之间(PHP 中 2 字节整数的最大值),以及该给定月份和年份的天数是否有效。

checkdate()函数正确处理具有 28、30 或 31 天的月份。Example 6-7 显示它还考虑了闰年,验证了二月 29 日是否存在于适当的年份中。

示例 6-7. 验证闰年
$valid = checkdate(2, 29, 2024); // true

$invalid = checkdate(2, 29, 2023); // false

参见

PHP 文档关于checkdate()

6.8 添加或减去日期

问题

你想对一个固定日期应用特定的偏移量(无论是加法还是减法)。例如,你想通过将天数添加到今天的日期来计算未来的日期。

解决方案

使用给定DateTime对象的::add()::sub()方法分别添加或减去DateInterval,如示例 6-8 所示。

示例 6-8. 简单的DateTime添加
$date = new DateTime('December 25, 2023');

// When do the 12 days of Christmas end?
$twelve_days = new DateInterval('P12D');
$date->add($twelve_days);

print 'The holidays end on ' . $date->format('F j, Y');

// The holidays end on January 6, 2024

讨论

DateTime对象上,::add()::sub()方法会分别通过添加或减去给定的间隔来修改对象本身。间隔使用的周期标识指定了该间隔表示的时间量。表 6-5 展示了用于表示间隔的格式字符。

表 6-5. DateInterval使用的周期标识

字符 描述
周期标识符
Y
M
D
W
时间标识符
H 小时
M 分钟
S

每个格式化的日期间隔周期都以字母P开头。接着是该周期中的年/月/日/周数。持续时间中的任何时间元素都以字母T作为前缀。

警告

月份和分钟的周期标识都是字母M。在时间标识中,这可能导致在识别 15 分钟与 15 个月时产生混淆。如果你打算使用分钟,请确保你的持续时间已正确使用了T前缀,以避免在应用程序中出现令人沮丧的错误。

例如,3 周零 2 天的期间将表示为P3W2D。4 个月、2 小时和 10 秒的期间将表示为P4MT2H10S。同样,1 个月、2 小时和 30 分钟的期间将表示为P1MT2H30M

可变性

注意,在示例 6-8 中,调用::add()时会修改原始的DateTime对象本身。在简单的示例中,这是可以接受的。如果你尝试从相同的起始日期计算多个日期偏移量,则DateTime对象的可变性会导致问题。

取而代之,你可以利用几乎相同的DateTimeImmutable对象。该类实现了与DateTime相同的接口,但是::add()::sub()方法会返回该类的新实例,而不是改变对象本身的内部状态。

在示例 6-9 中考虑两种对象类型的比较。

示例 6-9. 比较DateTimeDateTimeImmutable
$date = new DateTime('December 25, 2023');
$christmas = new DateTimeImmutable('December 25, 2023');

// When do the 12 days of Christmas end? $twelve_days = new DateInterval('P12D');
$date->add($twelve_days); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$end = $christmas->add($twelve_days); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

print 'The holidays end on ' . $date->format('F j, Y') . PHP_EOL;
print 'The holidays end on ' . $end->format('F j, Y') . PHP_EOL;

// When is next Christmas? $next_year = new DateInterval('P1Y');
$date->add($next_year);
$next_christmas = $christmas->add($next_year);

print 'Next Christmas is on ' . $date->format('F j, Y') . PHP_EOL;
print 'Next Christmas is on ' . $next_christmas->format('F j, Y') . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

print 'This Christmas is on ' . $christmas->format('F j, Y') . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

由于$date是一个可变对象,调用其::add()方法将直接修改对象。

2

由于$christmas是不可变的,调用::add()将返回一个新对象,必须将其存储在变量中。

3

将时间添加到DateTimeImmutable后,打印结果对象的数据将呈现正确的数据,因为这个对象是用正确的日期和时间创建的。

4

即使调用:add()后,DateTimeImmutable对象始终包含相同的数据,因为它实际上是不可变的。

不可变对象的优点在于您可以将它们视为常量,并放心地知道没有人在您不注意时会重写日历。唯一的缺点是内存利用率。由于DateTime修改单个对象,随着您不断进行更改,内存不一定会增加。然而,每次“修改”DateTimeImmutable对象时,PHP 都会创建一个新对象并消耗额外的内存。

在典型的 Web 应用程序中,这里的内存开销几乎可以忽略不计。没有理由使用DateTimeImmutable对象。

更简单的修改

在类似的路径中,DateTimeDateTimeImmutable都实现了一个::modify()方法,该方法使用人类可读的字符串而不是间隔对象。这允许您从给定对象中查找相对日期,如“上个星期五”或“下周”。

一个很好的例子是美国的感恩节,它在每年 11 月的第四个星期四。您可以使用示例 6-10 中定义的函数轻松计算给定年份中的确切日期。

示例 6-10. 使用DateTime找到感恩节
function findThanksgiving(int $year): DateTime
{
    $november = new DateTime("November 1, {$year}");
    $november->modify('fourth Thursday');

    return $november;
}

可以使用不可变日期对象实现相同的功能,如示例 6-11 所示。

示例 6-11. 使用DateTimeImmutable找到感恩节
function findThanksgiving(int $year): DateTimeImmutable
{
    $november = new DateTimeImmutable("November 1, {$year}");
    return $november->modify('fourth Thursday');
}

参见

DateInterval上的文档。

6.9 在时区之间计算时间

问题

您想要跨多个时区确定特定时间。

解决方案

使用DateTime类的::setTimezone()方法来更改时区如下:

$now = new DateTime();
$now->setTimezone(new DateTimeZone('America/Los_Angeles'));

print $now->format(DATE_RSS) . PHP_EOL;

$now->setTimezone(new DateTimeZone('Europe/Paris'));

print $now->format(DATE_RSS) . PHP_EOL;

讨论

时区是应用程序开发者最令人沮丧的事情之一。幸运的是,PHP 允许相对轻松地从一个时区转换到另一个时区。解决方案示例中使用的::setTimezone()方法说明了如何仅通过指定所需的时区将任意DateTime转换为另一个时区。

注意

请注意,DateTimeDateTimeImmutable都实现了::setTimezone()方法。它们之间的区别在于,DateTime会修改底层对象的状态,而DateTimeImmutable始终会返回一个对象。

了解代码中可用的时区非常重要。列出所有可用的命名时区过长,但开发人员可以利用DateTimeZone::listIdentifiers()列出所有可用的命名时区。如果您的应用程序只关心特定地区,您可以进一步使用该类提供的预定义组常量来简化列表。

例如,DateTimeZone::listIdentifiers(DateTimeZone::AMERICA) 返回一个数组,列出了所有在美洲可用的时区。在特定的测试系统上,这个数组列出了 145 个时区,每个时区指向一个主要的本地城市,以帮助识别它们代表的时区。您可以为以下每个地区常量生成可能的时区标识符列表:

  • DateTimeZone::AFRICA

  • DateTimeZone::AMERICA

  • DateTimeZone::ANTARCTICA

  • DateTimeZone::ARCTIC

  • DateTimeZone::ASIA

  • DateTimeZone::ATLANTIC

  • DateTimeZone::AUSTRALIA

  • DateTimeZone::EUROPE

  • DateTimeZone::INDIAN

  • DateTimeZone::PACIFIC

  • DateTimeZone::UTC

  • DateTimeZone::ALL

类似地,您可以使用位运算符从这些常量中构建联合,以检索跨两个或更多地区的所有时区列表。例如,DateTimeZone::ANTARCTICA | DateTimeZone::ARCTIC 将表示靠近南极或北极的所有时区。

基础的 DateTime 类使您能够实例化一个具有特定时区的对象,而不是接受系统默认设置。只需将一个 DateTimeZone 实例作为可选的第二个参数传递给构造函数,新对象将自动设置为正确的时区。

例如,按照 ISO 8601 格式化的日期时间 2022-12-15T17:35:53 表示 2022 年 12 月 15 日下午 5:35,但不反映具体的时区。在实例化 DateTime 对象时,您可以轻松指定这是日本东京的时间,如下所示:

$date = new DateTime('2022-12-15T17:35:53', new DateTimeZone('Asia/Tokyo'));

echo $date->format(DateTime::ATOM);
// 2022-12-15T17:35:53+09:00

如果要解析的日期时间字符串中缺少时区信息,则提供该时区会使事情更加明确。如果在前面的示例中添加时区标识符,PHP 将假定系统配置的时区。

如果日期时间字符串中存在时区信息,PHP 将忽略第二参数中指定的显式时区,并按照提供的字符串解析。

另请参阅

有关 ::setTimezone() 方法DateTimeZone 的文档。

¹ 格式字符串和可用的格式字符在 Recipe 6.2 中有介绍。

² 您可以使用 date_default_timezone_get() 检查系统当前的时区设置。

第七章:数组

数组 是有序映射——将特定值关联到易于识别的键的构造。这些映射是构建简单列表和更复杂的对象集合的有效方式。它们也很容易操作——向数组中添加或删除项是直接且通过多个功能接口支持的。

数组类型

PHP 中有两种形式的数组——数字和关联。当您在不明确设置键的情况下定义数组时,PHP 将为数组的每个成员内部分配一个整数索引。数组从 0 开始索引,自动增加 1 步骤。

关联数组的键可以是字符串或整数,但通常使用字符串。字符串键是“查找”存储在数组中特定值的有效方式。

数组在内部实现为哈希表,允许键和值之间进行有效的直接关联。例如:

$colors = [];
$colors['apple']  = 'red';
$colors['pear']   = 'green';
$colors['banana'] = 'yellow';

$numbers = [22, 15, 42, 105];

echo $colors['pear']; // green
echo $numbers[2]; // 42

与更简单的哈希表不同,PHP 数组还实现了一个可迭代接口,允许您逐个遍历它们的所有元素。当键是数字时,迭代是相当明显的,但即使是关联数组,元素也有固定的顺序,因为它们存储在内存中。 Recipe 7.3 详细介绍了在这两种类型的数组中对每个元素执行操作的不同方法。

在许多情况下,您可能还会遇到看起来和感觉像数组但实际上不是数组的对象或类。事实上,任何实现了 ArrayAccess 接口 的对象都可以被用作数组。¹ 这些更高级的实现将数组的可能性推向了超出简单列表和哈希表的极限。

语 语法

PHP 支持两种不同的语法来定义数组。那些在 PHP 中工作了一段时间的人会认识到 array() 构造,它允许在运行时字面上定义数组,如下所示:

$movies = array('Fahrenheit 451', 'Without Remorse', 'Black Panther');

另一种更简洁的语法是使用方括号来定义数组。前述示例可以重写为以下具有相同行为的形式:

$movies = ['Fahrenheit 451', 'Without Remorse', 'Black Panther'];

两种格式都可以用于创建嵌套数组(其中一个数组包含另一个数组),并且可以如下交替使用:

$array = array(1, 2, array(3, 4), [5, 6]);

虽然像前面的示例中混合和匹配语法是可能的,但强烈建议在应用程序括号)。

PHP 中的所有数组都将键映射到值。在上述示例中,数组仅指定了值,让 PHP 自动分配键。这些被视为数值数组,因为键将是整数,从 0 开始。更复杂的数组,如示例 7-1 中展示的嵌套结构,同时指定了值和键。这是通过使用双字符箭头操作符 (=>) 从键映射到值完成的。

示例 7-1. 带有嵌套值的关联数组
$array = array(
    'a' => 'A',
    'b' => ['b', 'B'],
    'c' => array('c', ['c', 'K'])
);

虽然不是语法要求,但许多编码环境和集成开发环境(IDE)会自动对齐多行数组文字中的箭头操作符。这使得代码更易读,也是本书采用的标准。

接下来的示例展示了开发者可以使用数组(包括数值和关联数组)完成 PHP 中常见任务的各种方法。

7.1 在数组中关联多个元素与键

问题

你想要将多个项目与单个数组键关联。

解决方案

让每个数组值都成为独立的数组,例如:

$cars = [
    'fast'     => ['ferrari', 'lamborghini'],
    'slow'     => ['honda', 'toyota'],
    'electric' => ['rivian', 'tesla'],
    'big'      => ['hummer']
];

讨论

PHP 对数组中值的数据类型没有要求。但是,键必须是字符串或整数。此外,数组中的每个键都必须是唯一的硬性要求。如果尝试为同一键设置多个值,将会覆盖现有数据,如示例 7-2 所示。

示例 7-2. 通过赋值覆盖数组数据
$basket = [];

$basket['color']    = 'brown';
$basket['size']     = 'large';
$basket['contents'] = 'apple';
$basket['contents'] = 'orange';
$basket['contents'] = 'pineapple';

print_r($basket);

// Array
// (
//    [color] => brown
//    [size] => large
//    [contents] => pineapple
// )

由于 PHP 在数组中只允许一个唯一键对应一个值,写入更多数据到该键会覆盖其值,就像重新分配应用程序中变量的值一样。如果需要在一个键中存储多个值,可以使用嵌套数组。

解决方案示例说明了每个键都可以指向自己的数组。然而,PHP 不要求每个键都这样做——除了一个键指向多个项目的情况外,其他键可以指向标量。在示例 7-3 中,你将使用嵌套数组存储多个项目,而不是意外地覆盖特定键中存储的单个值。

示例 7-3. 将数组写入键
$basket = [];

$basket['color']    = 'brown';
$basket['size']     = 'large';
$basket['contents'] = [];
$basket['contents'][] = 'apple';
$basket['contents'][] = 'orange';
$basket['contents'][] = 'pineapple';

print_r($basket);

// Array
// (
//    [color] => brown
//    [size] => large
//    [contents] => Array
//        (
//            [0] => apple
//            [1] => orange
//            [2] => pineapple
//        )
// )

echo $basket['contents'][2]; // pineapple

要利用嵌套数组的元素,你需要像处理父数组一样对其进行循环。例如,如果你想打印存储在$basket数组中的所有数据,可以使用两个循环,如示例 7-4 所示。

示例 7-4. 在循环中访问数组数据
foreach ($basket as $key => $value) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    if (is_array($value)) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
        echo "{$key} => " . PHP_EOL;

        foreach ($value as $item) { ![3
            echo "\t{$item}" . PHP_EOL;
        }

        echo ']' . PHP_EOL;
    } else {
        echo "{$key}: $value" . PHP_EOL;
    }
}

// color: brown // size: large // contents => [ //     apple //     orange //     pineapple // ]

1

父数组是关联的,你需要它的键和值。

2

你使用一种逻辑分支处理嵌套数组,另一种处理标量。

3

由于你知道嵌套数组是数值型的,可以忽略键,只迭代值。

参见

Recipe 7.3 中有更多关于数组迭代的例子。

7.2 用数字范围初始化数组

问题

您希望构建一个连续整数数组。

解决方案

使用range()函数如下所示:

$array = range(1, 10);
print_r($array);

// Array
// (
//     [0] => 1
//     [1] => 2
//     [2] => 3
//     [3] => 4
//     [4] => 5
//     [5] => 6
//     [6] => 7
//     [7] => 8
//     [8] => 9
//     [9] => 10
// )

讨论

PHP 的range()函数会自动迭代给定的序列,并根据该序列的定义为键分配一个值。默认情况下,如解决方案示例所示,该函数一次逐步通过序列。但这并不是该函数行为的极限——向函数传递第三个参数将改变其步长。

您可以按如下方式迭代从 2 到 100 的所有偶数整数:

$array = range(2, 100, 2);

同样,您可以通过将序列的起始点更改为 1 来迭代从 1 到 100 的所有奇数整数。例如:

$array = range(1, 100, 2);

range()的起始和结束参数(分别是前两个参数)可以是整数、浮点数,甚至字符串。这种灵活性允许您在代码中做一些非常惊人的事情。例如,您可以生成一个浮点数数组,而不是计算自然数(整数):

$array = range(1, 5, 0.25);

当将字符串字符传递给range()时,PHP 将开始枚举 ASCII 字符。您可以利用这个功能快速构建代表英语字母表的数组,如示例 7-5 所示。

注意

PHP 将根据它们的十进制表示内部使用任何和所有可打印的 ASCII 字符来完成对range()的请求。这是枚举可打印字符的高效方式,但您需要牢记特殊字符如=, ?)在 ASCII 表中的位置,特别是如果您的程序期望数组中的字母数字值。

示例 7-5. 创建一个英文字母数组
$uppers = range('A', 'Z'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$lowers = range('a', 'z'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$special = range('!', ')'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

返回从AZ的所有大写字符

2

返回从az的所有小写字符

3

返回一个特殊字符数组:[!, ", #, $, %, &, ', (, )]

参见

PHP 文档中关于range()的介绍。

7.3 遍历数组中的项目

问题

您希望对数组中的每个元素执行操作。

解决方案

对于数值数组,请按以下方式使用foreach

foreach ($array as $value) {
    // Act on each $value
}

对于关联数组,请按以下方式使用foreach()及其可选键:

foreach ($array as $key => $value) {
    // Act on each $value and/or $key
}

讨论

PHP 具有可迭代对象的概念,并且在内部,数组正是如此。其他数据结构也可以实现可迭代行为,²但任何可迭代表达式都可以提供给foreach,并将在循环中逐个返回它包含的项。

警告

当退出循环时,PHP 不会隐式地取消设置foreach循环中使用的变量。您仍然可以在循环外的程序中显式地引用$value中存储的最后值!

但要记住的最重要的事情是,foreach是一个语言结构,而不是一个函数。作为结构,它对给定的表达式进行操作,并在该表达式中的每个项上应用定义的循环。默认情况下,该循环不修改数组的内容。如果要使数组的值可变,则必须通过在变量名前加上&字符将它们传递到循环中的引用方式,如下所示:

$array = [1, 2, 3];

foreach ($array as &$value) {
    $value += 1;
}

print_r($array); // 2, 3, 4
警告

PHP 8.0 之前的版本支持each()函数,该函数会维护数组光标并在前进光标之前返回数组的当前键/值对。此函数在 PHP 7.2 中已被弃用,并在 8.0 版本中完全移除,但您可能会在书籍和在线资源中找到其使用的遗留示例。请将所有each()的出现升级为foreach的实现,以确保代码的向前兼容性。

使用for循环显式地迭代数组的键是替代foreach循环的另一种方法。数值数组最简单,因为它们的键已经是从 0 开始的增量整数。迭代数值数组相对简单,如下所示:

$array = ['red', 'green', 'blue'];

$arrayLength = count($array);
for ($i = 0; $i < $array_length; $i++) {
    echo $array[$i] . PHP_EOL;
}
提示

虽然可以调用count()来直接标识for循环的上界,但最好将数组的长度存储在表达式外部。否则,count()将在每次迭代循环时重新调用,以检查是否仍在范围内。对于小数组,这没有关系;但随着您开始处理更大的集合,重复调用count()会导致性能下降成为问题。

使用for循环迭代关联数组略有不同。与直接迭代数组元素不同,您将直接迭代数组的键。然后,使用每个键从数组中提取相应的值,如下所示:

$array = [
    'os'   => 'linux',
    'mfr'  => 'system76',
    'name' => 'thelio',
];

$keys = array_keys($array);
$arrayLength = count($keys);
for ($i = 0; $i < $arrayLength; $i++) {
    $key = $keys[$i];
    $value = $array[$key];

    echo "{$key} => {$value}" . PHP_EOL;
}

另请参阅

PHP 关于foreachfor语言结构的文档。

7.4 从关联和数字数组中删除元素

问题

您想从数组中删除一个或多个元素。

解决方案

通过直接目标化其键或数值索引,使用unset()删除一个元素:

unset($array['key']);

unset($array[3]);

通过将多个键或索引传递到unset()中一次删除多个元素,如下所示:

unset($array['first'], $array['second']);

unset($array[3], $array[4], $array[5]);

讨论

在 PHP 中,unset()实际上会销毁包含指定变量的内存引用。在此解决方案的上下文中,该变量是数组的一个元素,因此取消设置它会从数组本身中删除该元素。在关联数组中,这将导致删除指定的键及其表示的值。

在数值数组中,unset() 的作用远不止如此。它不仅会移除指定的元素,还会将数值数组有效地转换为具有整数键的关联数组。一方面,这很可能是你一开始期望的行为,正如在示例 7-6 中展示的那样。

示例 7-6. 在数值数组中取消元素
$array = range('a', 'z');

echo count($array) . PHP_EOL; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
echo $array[12] . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
echo $array[25] . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

unset($array[22]);
echo count($array) . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
echo $array[12] . PHP_EOL; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
echo $array[25] . PHP_EOL; ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

默认情况下,数组表示从az的所有英文字母,因此此行打印26

2

字母表中第 13 个字母是m。(记住,数组从索引0开始。)

3

字母表中第 26 个字母是z

4

元素被移除后,数组的大小减小到了25

5

字母表中第 13 个字母仍然是m

6

字母表中第 26 个字母仍然是z。此外,这个索引仍然有效,因为移除一个元素并不会重新索引数组。

通常可以忽略数值数组的索引,因为它们由 PHP 自动设置。这使得unset()将这些索引隐式地转换为数值键的行为有些令人惊讶。对于数值数组,尝试访问大于数组长度的索引会导致错误。然而,一旦你使用了unset()并减小了数组的大小,你往往会得到一个具有大于数组大小的数值键的数组,正如在示例 7-6 中所示。

如果您希望在移除元素后返回数值数组的世界,可以重新索引整个数组。PHP 的array_values()函数将返回一个新的、数值索引的数组,其中仅包含指定数组的值。例如:

$array = ['first', 'second', 'third', 'fourth']; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

unset($array[2]); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$array = array_values($array); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

默认数组具有数值索引:[0 => first, 1 => second, 2 => third, 3 => fourth]

2

取消元素将其从数组中移除,但索引(键)保持不变:[0 => first, 1 => second, 3 => fourth]

3

调用array_values()将为您返回一个带有全新、适当增加数值索引的数组:[0 => first, 1 => second, 2 => fourth]

从数组中移除元素的另一个选项是使用array_splice()函数。³ 此函数将从数组中移除一部分并用其他内容替换。⁴ 参见示例 7-7,其中array_splice()用于用nothing替换数组元素,从而将它们移除。

示例 7-7. 使用array_splice()移除数组元素
$celestials = [
    'sun',
    'mercury',
    'venus',
    'earth',
    'mars',
    'asteroid belt',
    'jupiter',
    'saturn',
    'uranus',
    'neptune',
    'pluto',
    'voyagers 1 & 2',
];

array_splice($celestials, 0, 1); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
array_splice($celestials, 4, 1); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
array_splice($celestials, 8); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

print_r($celestials);

// Array // ( //     [0] => mercury //     [1] => venus //     [2] => earth //     [3] => mars //     [4] => jupiter //     [5] => saturn //     [6] => uranus //     [7] => neptune // )

1

首先,移除太阳,以清理太阳系行星列表。

2

一旦太阳被移除,所有物体的索引都会发生变化。你仍然想要从列表中移除小行星带,因此使用它新的移位后的索引。

3

最后,通过从冥王星到数组末尾的移除操作来截断数组。

unset() 不同,由 array_splice() 创建的修改后的数组在数字数组中不会保留数值索引/键!这可能是在从数组中删除项后避免额外调用 array_values() 的一个好方法。这也是在无需显式指定每个元素的情况下,从数字索引数组中删除连续元素的有效方法。

另请参阅

unset()array_splice()array_values() 的文档。

7.5 改变数组的大小

问题

你想要增加或减少数组的大小。

解决方案

使用 array_push() 在数组末尾添加元素:

$array = ['apple', 'banana', 'coconut'];
array_push($array, 'grape');

print_r($array);

// Array
// (
//     [0] => apple
//     [1] => banana
//     [2] => coconut
//     [3] => grape
// )

使用 array_splice() 从数组中移除元素:

$array = ['apple', 'banana', 'coconut', 'grape'];
array_splice($array, 1, 2);

print_r($array);

// Array
// (
//     [0] => apple
//     [1] => grape
// )

讨论

与许多其他语言不同,PHP 不要求你声明数组的大小。数组是动态的,你可以随时添加或删除其中的数据而没有实际的负面影响。

第一个解决方案示例仅在数组末尾添加一个单独的元素。虽然这种方法很简单直接,但并不是最高效的。相反,你可以直接将单个项推入数组中,如下所示:

$array = ['apple', 'banana', 'coconut'];
$array[] = 'grape';

print_r($array);

// Array
// (
//     [0] => apple
//     [1] => banana
//     [2] => coconut
//     [3] => grape
// )

前面的示例与解决方案文档中记录的示例之间的关键区别在于函数调用。在 PHP 中,函数调用比语言构造(如赋值操作符)的开销更大。前面的示例略微更高效,但仅在应用程序中多次使用时才是如此。

如果你要在数组末尾添加多个项,array_push() 函数将更有效。它一次接受并追加多个项,从而避免多次赋值。示例 7-8 展示了这两种方法之间的区别。

示例 7-8. 使用 array_push() 和赋值运算符追加多个元素的比较
$first = ['apple'];
array_push($first, 'banana', 'coconut', 'grape');

$second = ['apple'];
$second[] = 'banana';
$second[] = 'coconut';
$second[] = 'grape';

echo 'The arrays are ' . ($first === $second ? 'equal' : 'different');

// The arrays are equal

如果你想要在数组前面而不是后面添加元素,可以使用 array_unshift() 将指定的项放置在数组的开头,如下所示:

$array = ['grape'];
array_unshift($array, 'apple', 'banana', 'coconut');

print_r($array);

// Array
// (
//     [0] => apple
//     [1] => banana
//     [2] => coconut
//     [3] => grape
// )
注意

当使用 array_unshift() 在目标数组的开头插入元素时,PHP 会保留元素的传递顺序。第一个参数将成为第一个元素,第二个将成为第二个元素,依此类推,直到达到数组的原始第一个元素。

请记住,在 PHP 中,数组没有固定的大小,并且可以以不同的方式轻松操作。所有前面的功能示例(array_push()array_splice()array_unshift())在数字数组上表现良好,并且不会改变它们的数值索引的顺序或结构。你可以通过直接引用新索引轻松地将元素添加到数字数组的末尾。例如:

$array = ['apple', 'banana', 'coconut'];
$array[3] = 'grape';

只要你的代码引用的索引与数组的其余部分连续,前面的例子就能无缝工作。然而,如果你的计数出现偏差,并且在索引中引入了间隙,则你实际上将你的数值数组转换为关联数组,只是键值是数字。

虽然本篇食谱中使用的所有函数都适用于关联数组,但它们主要针对数值键,并且在用于非数值键时会导致奇怪的行为。建议仅在数值数组中使用这些函数,并根据其键直接操作关联数组的大小。

参见

array_push()array_splice()array_unshift()的文档。

7.6 追加一个数组到另一个数组

问题

您想将两个数组合并成一个新的数组。

解决方案

使用array_merge()如下所示:

$first = ['a', 'b', 'c'];
$second = ['x', 'y', 'z'];

$merged = array_merge($first, $second);

此外,您还可以利用扩展操作符(…​)直接合并数组。而不是调用array_merge(),前面的示例可以这样变为:

$merged = [...$first, ...$second];

扩展操作符适用于数值数组和关联数组。

讨论

PHP 的array_merge()函数是将两个数组合并成一个的明显方法。但是,对于数值数组和关联数组,它的行为稍有不同。

警告

任何关于合并数组的讨论都将不可避免地使用“合并”这个术语。请注意,array_combine()本身是 PHP 中的一个函数。然而,它不像本篇食谱中展示的那样将两个数组合并。相反,它通过使用两个指定的数组——第一个用于新数组的,第二个用于新数组的——来创建一个新数组。这是一个有用的函数,但不是您可以用来合并两个数组的东西。

对于数值数组(如解决方案示例中的那些),第二个数组的所有元素都将追加到第一个数组的元素后面。该函数忽略两个数组的索引,并且新产生的数组具有连续的索引(从0开始),就像直接构建它一样。

对于关联数组,第二个数组的键(和值)将添加到第一个数组的键(和值)中。如果两个数组具有相同的键,则第二个数组的值将覆盖第一个数组的值。示例 7-9 说明了一个数组中的数据如何覆盖另一个数组的数据。

示例 7-9. 使用array_merge()覆盖关联数组数据
$first = [
    'title'  => 'Practical Handbook',
    'author' => 'Bob Mills',
    'year'   => 2018
];
$second = [
    'year'   => 2023,
    'region' => 'United States'
];

$merged = array_merge($first, $second);
print_r($merged);

// Array
// (
//     [title] => Practical Handbook
//     [author] => Bob Mills
//     [year] => 2023
//     [region] => United States
// )

可能存在这样的情况,您希望在合并两个或多个数组时保留重复键中保存的数据。在这种情况下,请使用array_merge_recursive()。与前面的示例不同,此函数将创建一个包含重复键中定义的数据的数组,而不是将一个值覆盖为另一个值。示例 7-10 重新编写了前面的示例,以说明这是如何发生的。

示例 7-10. 带有重复键的数组合并
$first = [
    'title'  => 'Practical Handbook',
    'author' => 'Bob Mills',
    'year'   => 2018
];
$second = [
    'year'   => 2023,
    'region' => 'United States'
];

$merged = array_merge_recursive($first, $second);
print_r($merged);

// Array
// (
//     [title] => Practical Handbook
//     [author] => Bob Mills
//     [year] => Array
//         (
//             [0] => 2018
//             [1] => 2023
//         )
//
//     [region] => United States
// )

虽然前面的示例仅合并了两个数组,但你可以使用 array_merge()array_merge_recursive() 合并任意数量的数组。在开始合并超过两个数组时,请记住这两个函数如何处理重复键,以避免潜在数据丢失。

第三种将两个数组合并为一个的方法是使用字面添加运算符 + :在纸上,这看起来是将两个数组相加。但实际上它是将第二个数组中的任何新键添加到第一个数组的键中。与 array_merge() 不同的是,此操作不会覆盖数据。如果第二个数组的键与第一个数组中的任何键重复,那么这些键将被忽略,并且使用第一个数组中的数据。

此运算符还可以明确与数组键一起使用,这意味着它不适合于数值数组。两个相同大小的数值数组,如果像关联数组一样处理,将具有完全相同的键,因为它们具有相同的索引。这意味着第二个数组的数据将被完全忽略!

参见

array_merge()array_merge_recursive() 的文档。

7.7 从现有数组片段创建数组

问题

您希望选择现有数组的子集并将其独立使用。

解决方案

使用 array_slice() 来从现有数组中选择一系列元素,如下所示:

$array = range('A', 'Z');
$slice = array_slice($array, 7, 4);

print_r($slice);

// Array
// (
//     [0] => H
//     [1] => I
//     [2] => J
//     [3] => K
// )

讨论

array_slice() 函数根据定义的偏移量(在数组中的位置)和要检索的元素长度,快速从给定的数组中提取连续的项序列。与 array_splice() 不同的是,它复制数组中的项序列,而不改变原始数组。

要理解完整的函数签名,以理解这个函数的强大之处非常重要:

array_slice(
    array $array,
    int   $offset,
    ?$int $length = null,
    $bool $preserve_keys = false
): array

仅需要前两个参数——目标数组和初始偏移量。如果偏移量为正数(或 0 ),新序列将从数组的开头该位置开始。如果偏移量为负数,则序列将从数组的末尾向前偏移该数目的位置开始。

注意

数组偏移明确地参考了数组内的位置,而不是键或索引。array_slice() 函数易于在关联数组上使用,就像在数值数组上一样,因为它使用数组中元素的相对位置来定义一个新序列,并忽略数组的实际键。

当您定义可选的 $length 参数时,这定义了新序列中的最大项数。请注意,新序列受原始数组中项数的限制,因此如果长度超出了数组的末尾,您的序列将比预期的短。示例 7-11 展示了这种行为的快速示例。

示例 7-11. 使用 array_slice() 处理一个太短的数组
$array = range('a', 'e');
$newArray = array_slice($array, 4, 100);

print_r($newArray);

// Array
// (
//     [0] => e
// )

如果指定的长度为负数,则序列将停在目标数组末尾的指定元素之前。如果未指定长度(或为null),则序列将包括从原始偏移到目标数组末尾的所有内容。

最后一个参数$preserve_keys告诉 PHP 是否重置数组切片的整数索引。默认情况下,PHP 将返回一个重新索引的数组,其整数键从0开始。Example 7-12 展示了该函数基于此参数的行为差异。

注意

array_slice()函数将始终在关联数组中保留字符串键,而不管$preserve_keys的值如何。

Example 7-12. 在array_slice()中的键保留行为
$array = range('a', 'e');

$standard = array_slice($array, 1, 2);
print_r($standard);

// Array
// (
//     [0] => b
//     [1] => c
// )

$preserved = array_slice($array, 1, 2, true);
print_r($preserved);

// Array
// (
//     [1] => b
//     [2] => c
// )

请记住,PHP 中的数字数组可以被视为具有从0开始连续递增的整数键的关联数组。有了这个理解,很容易看出array_slice()在具有字符串和整数键的关联数组上的行为方式——它基于位置而不是键,正如 Example 7-13 所示。

Example 7-13. 对具有混合键的数组使用array_slice()
$array = ['a' => 'apple', 'b' => 'banana', 25 => 'cola', 'd' => 'donut'];
print_r(array_slice($array, 0, 3));

// Array
// (
//     [a] => apple
//     [b] => banana
//     [0] => cola
// )

print_r(array_slice($array, 0, 3, true));

// Array
// (
//     [a] => apple
//     [b] => banana
//     [25] => cola
// )

在 Recipe 7.4 中,您已经了解了用于从数组中删除一系列元素的array_splice()。方便地,此函数使用与array_slice()类似的方法签名:

 array_splice(
    array &$array,
    int   $offset,
    ?int  $length = null,
    mixed $replacement = []
): array

这些函数之间的关键区别在于一个修改源数组,而另一个则不会。您可能会使用array_slice()在隔离的较大序列的子集上操作,或者完全将两个序列彻底分开。在任何情况下,这些函数表现出类似的行为和用例。

另请参阅

array_slice()array_splice()的文档。

7.8 在数组和字符串之间转换

问题

您希望将字符串转换为数组或将数组元素组合成字符串。

解决方案

使用str_split()将字符串转换为数组:

$string = 'To be or not to be';
$array = str_split($string);

使用join()将数组元素组合成字符串:

$array = ['H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'];
$string = join('', $array);

讨论

str_split()函数是将任何字符的字符串转换为大小相同的块数组的强大方法。默认情况下,它将字符串分解为单字符块,但您也可以轻松地将字符串分解为任意数量的字符。序列中的最后一个块仅保证最多达到指定的长度。例如,Example 7-14 试图将字符串分解为五字符块,但请注意最后一个块的长度少于五个字符。

Example 7-14. 使用任意块大小的str_split()
$string = 'To be or not to be';
$array = str_split($string, 5);
var_dump($array);

// array(4) {
//   [0]=>
//   string(5) "To be"
//   [1]=>
//   string(5) " or n"
//   [2]=>
//   string(5) "ot to"
//   [3]=>
//   string(3) " be"
// }
警告

请记住,str_split()适用于字节。当处理多字节编码的字符串时,您需要使用mb_​str_​split()

在某些情况下,您可能希望将字符串分割成单独的单词而不是单个字符。PHP 的explode()函数允许您指定分隔符来分割内容。这对于将句子分割成其组成单词的数组非常方便,正如示例 7-15 所示。

示例 7-15. 将字符串拆分为单词数组
$string = 'To be or not to be';
$words = explode(' ', $string);

print_r($words);

// Array
// (
//     [0] => To
//     [1] => be
//     [2] => or
//     [3] => not
//     [4] => to
//     [5] => be
// )
注意

虽然explode()似乎与str_split()功能类似,但它不能使用空分隔符(函数的第一个参数)来分解字符串。如果尝试传递空字符串,则会遇到ValueError。如果想要处理字符数组,请坚持使用str_split()

将字符串数组合并成单个字符串需要使用join()函数,它本身只是implode()的别名。但它比str_split()的倒转功能强大得多,因为你可以选择定义一个分隔符,用于在新连接的代码块之间放置。

分隔符是可选的,但 PHP 中implode()的长期遗留已导致了以下两个有些不直观的函数签名:

implode(string $separator, array $array): string

implode(array $array): string

如果只需将字符数组合并成字符串,可以使用等效的方法,如示例 7-16 所示。

示例 7-16. 从字符数组创建字符串
$array = ['H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'];

$option1 = implode($array);

$option2 = implode('', $array);

echo 'The two are ' . ($option1 === $option2 ? 'identical' : 'different');

// The two are identical

因为您可以明确指定分隔符——用于连接每个文本块的粘合剂,所以implode()允许您做的事情几乎没有限制。假设您的数组是一个单词列表而不是字符列表。您可以使用implode()将它们连接在一起作为可打印的逗号分隔列表,如下例所示:

$fruit = ['apple', 'orange', 'pear', 'peach'];

echo implode(', ', $fruit);

// apple, orange, pear, peach

参见

implode()explode()str_split()的文档。

7.9 反转数组

问题

您想要反转数组中元素的顺序。

解决方法

使用array_reverse()如下:

$array = ['five', 'four', 'three', 'two', 'one', 'zero'];

$reversed = array_reverse($array);

讨论

array_reverse()函数创建一个新数组,其中每个元素是输入数组的倒序。默认情况下,此函数不保留源数组的数值键,而是重新为每个元素编制索引。非数值键(在关联数组中)通过此重新索引保持不变;然而,它们的顺序仍然按预期反转。示例 7-17 演示了如何通过array_reverse()重新排序关联数组。

示例 7-17. 反转关联数组
$array = ['a' => 'A', 'b' => 'B', 'c' => 'C'];
$reversed = array_reverse($array);

print_r($reversed);

// Array
// (
//     [c] => C
//     [b] => B
//     [a] => A
// )

由于关联数组可能以数值键开始,重新索引行为可能会产生意外结果。幸运的是,可以通过在反转数组时传递一个可选的布尔参数作为第二个参数来禁用此行为。示例 7-18 展示了这种索引行为如何影响这样的数组(以及如何禁用它)。

示例 7-18. 反转具有数值键的关联数组
$array = ['a' => 'A', 'b' => 'B', 42 => 'C', 'd' => 'D'];
print_r(array_reverse($array)); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

// Array // ( //     [d] => D //     [0] => C //     [b] => B //     [a] => A // ) 
print_r(array_reverse($array, true)); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

// Array // ( //     [d] => D //     [42] => C //     [b] => B //     [a] => A // )

1

第二个参数的默认值为 false,这意味着在数组反转后将不保留数字键。

2

true 作为第二个参数传递仍然允许数组反转,但会在新数组中保留数字键。

另请参阅

array_reverse() 的文档

7.10 对数组进行排序

问题

您希望对数组元素进行排序。

解决方案

要使用 PHP 中默认的比较规则对项目进行排序,请使用如下所示的 sort()

$states = ['Oregon', 'California', 'Alaska', 'Washington', 'Hawaii'];
sort($states);

讨论

PHP 的原生排序系统建立在快速排序算法 Quicksort 的基础上,这是一种常见且相对快速的排序算法。默认情况下,它使用 PHP 比较运算符定义的规则来确定数组中每个元素的顺序。⁵ 然而,你可以通过将一个标志作为 sort() 的可选第二参数来使用不同的规则进行排序。可用的排序标志在 表 7-1 中描述。

表 7-1. 排序类型标志

Flag 描述
SORT_REGULAR 使用默认的比较操作正常比较项目
SORT_NUMERIC 数值比较项目
SORT_STRING 将项目作为字符串进行比较
SORT_LOCALE_STRING 使用当前系统区域设置按字符串比较项目
SORT_NATURAL 使用“自然顺序”比较项目
SORT_FLAG_CASE 结合 SORT_STRINGSORT_NATURAL 使用位或运算符比较字符串时不区分大小写

当默认排序比较产生不合理的排序数组时,排序类型标志非常有用。例如,将整数数组按字符串排序会导致排序错误。使用 SORT_NUMERIC 标志将确保整数按正确的顺序排序。Example 7-19 展示了这两种排序类型的差异。

示例 7-19. 使用常规和数值排序类型对整数进行排序
$numbers = [1, 10, 100, 5, 50, 500];
sort($numbers, SORT_STRING);
print_r($numbers);

// Array
// (
//     [0] => 1
//     [1] => 10
//     [2] => 100
//     [3] => 5
//     [4] => 50
//     [5] => 500
// )

sort($numbers, SORT_NUMERIC);
print_r($numbers);

// Array
// (
//     [0] => 1
//     [1] => 5
//     [2] => 10
//     [3] => 50
//     [4] => 100
//     [5] => 500
// )

sort() 函数忽略数组键和索引,仅按其值对数组元素进行排序。因此,尝试使用 sort() 对关联数组进行排序会破坏该数组中的键。如果希望在保留数组键的同时按值排序,可以使用 asort()

要做到这一点,调用 asort() 的方式与 sort() 完全相同;甚至可以使用与 表 7-1 中定义的相同标志。然而,生成的数组将保留与之前相同的键,即使元素的顺序不同。例如:

$numbers = [1, 10, 100, 5, 50, 500];
asort($numbers, SORT_NUMERIC);
print_r($numbers);

// Array
// (
//     [0] => 1
//     [3] => 5
//     [1] => 10
//     [4] => 50
//     [2] => 100
//     [5] => 500
// )

sort()asort() 都会生成按升序排序的数组。如果要按降序获取数组,有两种选择:

  • 将数组按升序排序,然后像 Recipe 7.9 中演示的那样将其反转。

  • 利用 rsort()arsort() 对数字和关联数组进行排序。

为了减少总体代码复杂性,通常更倾向于后一种选项。这些函数的签名与 sort()asort() 相同,但仅仅颠倒了元素在结果数组中的位置顺序。

参见

arsort()asort()rsort()sort()的文档。

7.11 基于函数对数组进行排序

问题

您想根据用户定义的函数或比较器对数组进行排序。

解决方案

使用 usort() 和自定义排序回调如下所示:

$bonds = [
    ['first' => 'Sean',    'last' => 'Connery'],
    ['first' => 'Daniel',  'last' => 'Craig'],
    ['first' => 'Pierce',  'last' => 'Brosnan'],
    ['first' => 'Roger',   'last' => 'Moore'],
    ['first' => 'Timothy', 'last' => 'Dalton'],
    ['first' => 'George',  'last' => 'Lazenby'],
];

function sorter(array $a, array $b) {
    return [$a['last'], $a['first']] <=> [$b['last'], $b['first']];
}

usort($bonds, 'sorter');

foreach ($bonds as $bond) {
    echo "{$bond['last']}. {$bond['first']} {$bond['last']}" . PHP_EOL;
}

讨论

usort() 函数利用用户定义的函数作为其排序算法背后的比较操作。您可以将任何可调用对象作为第二个参数传递,并通过此函数检查数组的每个元素以确定其适当的顺序。解决方案示例引用了一个回调函数的名称,但您也可以轻松地传递匿名函数。

解决方案示例进一步利用了 PHP 的新太空船操作符,对数组元素进行了复杂的比较。⁶ 在这种特定情况下,您希望首先按姓氏,然后按名字对詹姆斯·邦德的演员进行排序。同样的功能可以用于任何姓名集合。

PHP 中应用自定义排序到日期的一个更强大的例子。日期相对容易排序,因为它们是连续系列的一部分。但可以定义打破这些预期的自定义行为。示例 7-20 尝试根据星期几、年份和月份依次对日期数组进行排序。

示例 7-20. 应用于日期的用户定义排序
$dates = [
    new DateTime('2022-12-25'),
    new DateTime('2022-04-17'),
    new DateTime('2022-11-24'),
    new DateTime('2023-01-01'),
    new DateTime('2022-07-04'),
    new DateTime('2023-02-14'),
];

function sorter(DateTime $a, DateTime $b) {
    return
        [$a->format('N'), $a->format('Y'), $a->format('j')]
        <=>
        [$b->format('N'), $b->format('Y'), $b->format('j')];
}

usort($dates, 'sorter');

foreach ($dates as $date) {
    echo $date->format('l, F jS, Y') . PHP_EOL;
}

// Monday, July 4th, 2022
// Tuesday, February 14th, 2023
// Thursday, November 24th, 2022
// Sunday, April 17th, 2022
// Sunday, December 25th, 2022
// Sunday, January 1st, 2023

像本章讨论的许多其他数组函数一样,usort() 忽略数组键/索引,并在其操作中重新索引数组。如果需要保留元素的索引或键关联,请改用 uasort()。此函数与 usort() 具有相同的签名,但在排序后保持数组键不变。

数组键通常包含有关数组内数据的重要信息,因此在排序操作期间保留它们有时可能至关重要。此外,您可能实际上想按数组的键而不是每个元素的值进行排序。在这种情况下,可以利用 uksort()

uksort() 函数将使用您定义的函数按键对数组进行排序。像 uasort() 一样,它尊重键并在数组排序后保持它们的位置。

参见

usort()uasort()uksort()的文档。

7.12 随机化数组元素

问题

您希望随机打乱数组元素的顺序,使其完全随机。

解决方案

使用 shuffle() 如下所示:

$array = range('a', 'e');
shuffle($array);

讨论

函数shuffle()作用于传入的现有数组的引用。它完全忽略数组的键,并随机对元素值排序,以原地更新数组。洗牌后,数组键将从 0 重新索引。

警告

虽然洗牌关联数组不会导致错误,但在操作期间将丢失所有关于键的信息。您应仅对数值数组执行洗牌操作。

内部使用Mersenne Twister伪随机数生成器来识别数组中每个元素的新顺序。当需要真正的随机性时(如加密或安全场景),此伪随机数生成器并不合适,但它是快速洗牌数组内容的有效方法。

参见

shuffle()文档

7.13 对数组的每个元素应用函数

问题

您希望通过将函数应用于依次修改数组的每个元素来转换数组

解决方案

要在原地修改数组,请使用以下方式调用array_walk()

$values = range(2, 5);

array_walk($values, function(&$value, $key) {
    $value *= $value;
});

print_r($values);

// Array
// (
//     [0] => 4
//     [1] => 9
//     [2] => 16
//     [3] => 25
// )

讨论

遍历数据集合是 PHP 应用程序的常见需求。例如,您可能希望使用集合定义重复任务。或者,您可能希望对集合中的每个项目执行特定操作,如解决方案示例中所示的平方值。

函数array_walk()既能定义要应用的转换,也能将其应用到数组每个元素的值上。回调函数(第二个参数)接受三个参数:数组中元素的值和键,以及可选的$arg参数。在初始调用array_walk()时定义此最终参数,并在每次回调使用时传递。这是向回调传递常量值的有效方式,正如示例 7-21 所示。

示例 7-21. 带额外参数调用array_walk()
function mutate(&$value, $key, $arg)
{
    $value *= $arg;
}

$values = range(2, 5);

array_walk($values, 'mutate', 10);

print_r($values);

// Array
// (
//     [0] => 20
//     [1] => 30
//     [2] => 40
//     [3] => 50
// )

使用array_walk()在原地修改数组需要将数组值通过引用传递给回调(请注意参数名前的额外&)。此函数也可用于仅遍历数组中的每个元素并执行某些其他函数,而不修改源数组。实际上,这是此函数最常见的用法。

除了遍历数组的每个元素外,还可以通过使用array_walk_recursive()遍历嵌套数组中的叶子节点。与前面的示例不同,array_walk_recursive()将在应用您指定的回调函数之前遍历嵌套数组直到找到非数组元素。示例 7-22 巧妙地展示了递归和非递归函数调用在处理嵌套数组时的区别。具体来说,如果处理嵌套数组,array_walk()将抛出错误并完全无法操作。

示例 7-22 比较 array_walk()array_walk_recursive()
$array = [
    'even' => [2, 4, 6],
    'odd'  => 1,
];

function mutate(&$value, $key, $arg)
{
    $value *= $arg;
}

array_walk_recursive($array, 'mutate', 10);
print_r($array);

// Array
// (
//     [even] => Array
//         (
//             [0] => 20
//             [1] => 40
//             [2] => 60
//         )
//
//     [odd] => 10
// )

array_walk($array, 'mutate', 10);

// PHP Warning: Uncaught TypeError: Unsupported operand types: array * int

在许多情况下,您可能希望创建一个经过变异的数组的新副本,同时不会丢失其原始状态的跟踪。在这些情况下,array_map()可能比array_walk()更安全。与修改源数组不同,array_map()使您能够对源数组中的每个元素应用函数,并返回一个全新的数组。其优点在于,您可以进一步使用原始数组和修改后的数组。以下示例利用与解决方案示例相同的逻辑,在更改源数组的情况下:

$values = range(2, 5);

$mutated = array_map(function($value) {
    return $value * $value;
}, $values);

print_r($mutated);

// Array
// (
//     [0] => 4
//     [1] => 9
//     [2] => 16
//     [3] => 25
// )

以下是需要注意的这两个数组函数家族之间的一些关键区别:

  • array_walk() 期望数组在前,回调函数在后。

  • array_map() 期望回调函数在前,数组在后。

  • array_walk() 返回一个布尔标志,而array_map()返回一个新数组。

  • array_map() 不会将键传递给回调函数。

  • array_map() 不会将附加参数传递给回调函数。

  • array_map() 没有递归形式。

另请参见

array_map()的文档array_walk()的文档,以及array_walk_recursive()的文档

7.14 将数组减少为单个值

问题

您想要将一系列值迭代地减少到单个值。

解决方案

使用以下回调函数array_reduce()

$values = range(0, 10);

$sum = array_reduce($values, function($carry, $item) {
    return $carry + $item;
}, 0);

// $sum = 55

讨论

array_reduce() 函数遍历数组的每个元素,并修改其内部状态,最终得出一个单一的答案。解决方案示例遍历数字列表的每个元素,并将它们全部添加到初始值0,返回所讨论的所有数字的最终总和。

回调函数接受两个参数。第一个是您从上次操作中传递过来的值。第二个是您正在迭代的数组中当前项目的值。无论回调函数返回什么,都将作为下一个数组元素的$carry参数传递到回调函数中。

当您开始时,您可以将一个可选的初始值(默认为null)作为$carry参数传递到回调函数中。如果您正在应用于数组的减少操作是直接的,您通常可以提供更好的初始值,就像解决方案示例中所做的那样。

array_reduce() 的最大缺点是它不处理数组键。为了利用数组中的任何键作为减少操作的一部分,您需要定义自己版本的函数。

示例 7-23 展示了你可以通过迭代 array_keys() 返回的数组来利用元素的键和值进行减少。你将数组和回调传递到由 array_reduce() 处理的闭包中,因此你可以引用该键定义的数组中的元素,并将你的自定义函数应用于它。在主程序中,你可以像减少数值数组一样减少关联数组——除了你的回调中有一个额外的参数包含每个元素的键。

示例 7-23. array_reduce() 的关联替代方法
function array_reduce_assoc(
    array $array,
    callable $callback,
    mixed $initial = null
): mixed
{
    return array_reduce(
        array_keys($array),
        function($carry, $item) use ($array, $callback) {
            return $callback($carry, $array[$item], $item);
        },
        $initial
    );
}

$array = [1 => 10, 2 => 10, 3 => 5];

$sumMultiples = array_reduce_assoc(
    $array,
    function($carry, $item, $key) {
        return $carry + ($item * $key);
    },
    0
);

// $sumMultiples = 45

上述代码将返回 $array 键的总和乘以它们对应的值——具体来说,1 * 10 + 2 * 10 + 3 * 5 = 45

另请参阅

array_reduce() 的文档

7.15 迭代无限或非常大/昂贵的数组

问题

如果你希望迭代一个列表项,该列表项过大以至于无法存储在内存中或者生成速度过慢。

解决方案

使用生成器一次生成一个数据块到你的程序中,如下:

function weekday()
{
    static $day = 'Monday';

    while (true) {
        yield $day;

        switch($day) {
            case 'Monday':
                $day = 'Tuesday';
                break;
            case 'Tuesday':
                $day = 'Wednesday';
                break;
            case 'Wednesday':
                $day = 'Thursday';
                break;
            case 'Thursday':
                $day = 'Friday';
                break;
            case 'Friday':
                $day = 'Monday';
                break;
        }
    }
}

$weekdays = weekday();
foreach ($weekdays as $day) {
    echo $day . PHP_EOL;
}

讨论

生成器是在 PHP 中处理大量数据的内存高效方式。在解决方案示例中,生成器按顺序产生工作日(从周一到周五)作为一个无限序列。无限序列无法适应 PHP 可用的内存,但生成器结构允许你逐步构建它。

不同于实例化一个过大的数组,你生成数据的第一部分并通过 yield 关键字将其返回给调用生成器的程序。这样可以冻结生成器的状态,并将执行控制权返回给主应用程序。与一般仅返回数据一次的函数不同,生成器可以多次提供数据,只要它仍然有效。

在解决方案示例中,yield 出现在一个无限的 while 循环中,因此它将无限枚举工作日。如果你希望生成器退出,可以在结尾使用一个空的 return 语句(或者简单地中断循环并隐式返回)。

提示

从生成器返回数据与通常的函数调用不同。你通常使用 yield 关键字返回数据,并使用空的 return 语句退出生成器。然而,如果生成器确实有最终返回,你必须在生成器对象上调用 ::getReturn() 来访问该数据。这种额外的方法调用通常看起来有些奇怪,因此除非你的生成器有理由在其典型的 yield 操作之外返回数据,否则应尽量避免这样做。

由于生成器可以无限提供数据,你可以通过使用标准的 foreach 循环来迭代这些数据。同样,你可以利用有限的 for 循环来避免无限序列。以下代码利用了这样的有限循环和解决方案的原始生成器:

$weekdays = weekday();
for ($i = 0; $i < 14; $i++) {
    echo $weekdays->current() . PHP_EOL;
    $weekdays->next();
}

虽然生成器被定义为一个函数,但在 PHP 内部被识别为生成器并转换为Generator的实例。该类提供了::current()::next()方法,允许您逐个访问生成的数据。

应用程序内的控制流在主程序和生成器的yield语句之间来回传递。第一次访问生成器时,它会在内部运行到yield,然后将控制(及可能的数据)返回给主应用程序。对生成器的后续调用从yield关键字之后开始。循环需要强制生成器从头开始以便再次yield

另请参见

概述关于生成器的内容。

¹ 类继承在第八章中有详细讨论,对象接口则在 Recipe 8.7 中有明确介绍。

² 请查看 Recipe 7.15,了解大型可迭代数据结构的示例。

³ 注意不要混淆array_splice()array_slice()。这两个函数有着完全不同的用途,后者在#slicing_arrays 中有详细介绍。

array_splice()函数还将从目标数组中返回提取的元素,以便在需要时用于其他操作。有关此行为的进一步讨论,请参阅 Recipe 7.7。

⁵ 详细了解“Comparison Operators”以及比较运算符的用法。

⁶ 长篇解释了太空船操作符在 Recipe 2.4 中,其中还介绍了usort()的一个使用示例。

第八章:类和对象

PHP 的早期版本不支持类定义或面向对象。PHP 4 是第一个真正尝试实现对象接口的版本。¹ 直到 PHP 5,开发者才拥有今天所知道和使用的复杂对象接口。

类使用 class 关键字定义,后跟类中固有的常量、属性和方法的完整描述。示例 8-1 在 PHP 中引入了一个基本的类结构,包括作用域常量值、属性和可调用方法。

示例 8-1. 带有属性和方法的基本 PHP 类
class Foo
{
    const SOME_CONSTANT = 42;

    public string $hello = 'hello';

    public function __construct(public string $world = 'world') {}

    public function greet(): void
    {
        echo sprintf('%s %s', $this->hello, $this->world);
    }
}

可以使用 new 关键字和类的名称实例化对象;实例化看起来有点像函数调用。任何传递到此实例化的参数都会透明地传递到类构造函数(__construct() 方法),以定义对象的初始状态。示例 8-2 演示了如何从 示例 8-1 中的类定义实例化类,无论是使用还是不使用默认属性值。

示例 8-2. 实例化一个基本的 PHP 类
$first = new Foo; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$second = new Foo('universe'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$first->greet(); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$second->greet(); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

echo Foo::SOME_CONSTANT; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

在不传递参数实例化对象时,仍会调用构造函数,但会利用其默认参数。如果函数签名中没有提供默认值,将导致错误。

2

在实例化期间传递参数将向构造函数提供该参数。

3

这会通过使用构造函数的默认值打印 hello world

4

这会在控制台打印 hello universe

5

常量直接从类名引用。这会在控制台打印字面值 42

构造函数和属性在第 8.1 和 8.2 节中有详细说明。

过程式编程

大多数开发者对 PHP 的第一次接触是通过其更多的过程式接口。示例例程、简单脚本、教程——所有这些通常都利用在全局范围内定义的函数和变量。这并不是坏事,但它限制了您可以生成的程序的灵活性。

过程式编程经常导致无状态应用程序。在函数调用之间,几乎没有能力跟踪之前发生的事情,因此需要通过代码中的某些参考来传递应用程序的状态。再次强调,这并不一定是坏事。唯一的缺点是复杂应用程序变得难以分析或理解。

面向对象编程

另一种范式是利用对象作为状态的容器。一个常见的实际例子是将对象视为定义事物的方式。汽车是一个对象。公共汽车也是。自行车也是。它们是具有特征(如颜色、轮子数量和驱动类型)和功能(如前进、停止和转弯)的离散事物

在编程世界中,这是描述对象最简单的方式之一。在 PHP 中,你首先通过定义class来创建对象,以描述该类型的对象将具有的属性(特征)和方法(能力)。

就像现实世界中的事物一样,编程空间中的对象可以继承自更原始的类型描述。汽车、公共汽车和自行车都是车辆的类型,因此它们都可以从特定类型继承。示例 8-3 展示了 PHP 可能如何构建这种对象继承。

示例 8-3. PHP 中的类抽象
abstract class Vehicle
{
    abstract public function go(): void;

    abstract public function stop(): void;

    abstract public function turn(Direction $direction): void;
}

class Car extends Vehicle
{
    public int $wheels = 4;
    public string $driveType = 'gas';

    public function __construct(public Color $color) {}

    public function go(): void
    {
        // ...
    }

    public function stop(): void
    {
        // ...
    }

    public function turn(Direction $direction): void
    {
        // ...
    }
}

class Bus extends Vehicle
{
    public int $wheels = 4;
    public string $driveType = 'diesel';

    public function __construct(public Color $color) {}

    public function go(): void
    {
        // ...
    }

    public function stop(): void
    {
        // ...
    }

    public function turn(Direction $direction): void
    {
        // ...
    }
}

class Bicycle extends Vehicle
{
    public int $wheels = 2;
    public string $driveType = 'direct';

    public function __construct(public Color $color) {}

    public function go(): void
    {
        // ...
    }

    public function stop(): void
    {
        // ...
    }

    public function turn(Direction $direction): void
    {
        // ...
    }
}

实例化对象将创建一个类型化变量,表示初始状态和用于操作该状态的方法。对象继承提供了在其他代码中使用一个或多个类型作为替代品的可能性。示例 8-4 说明了由于继承关系,介绍的三种车辆类型可以互换使用。

示例 8-4. 具有类似继承的类可以互换使用
function commute(Vehicle $vehicle) ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
{
    // ... }

function exercise(Bicycle $vehicle) ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
{
    // ... }

1

三种车辆子类型都可以作为函数调用中Vehicle的有效替换。这意味着你可以选择乘坐公共汽车、轿车或自行车,任何选择都同样有效。

2

有时候,您可能需要更精确,并直接使用子类型。除了BicycleBusCar或任何Vehicle类的其他子类都不会被视为有效练习。

类继承在“Recipes”中更深入地介绍,包括 8.6、8.7 和 8.8。

多范式语言

PHP 被视为多范式语言,因为你可以按照前述的任一范式编写代码。一个有效的 PHP 程序可以是纯过程化的。或者它可以严格依赖于对象定义和自定义类。该程序最终可能会混合使用这两种范式。

开源的 WordPress 内容管理系统(CMS)是互联网上最流行的 PHP 项目之一。² 它被编码为大量利用对象来实现常见抽象,如数据库对象或远程请求。然而,WordPress 也源自长期的过程化编程历史 —— 其代码库的很大一部分仍受到这种风格的深刻影响。WordPress 不仅是 PHP 本身成功的关键例子,也展示了语言对多种范式支持的灵活性。

没有一种固定的方法来组装应用程序。大多数都是多范式支持的混合体,受益于 PHP 对多种编程范式的强力支持。即使在绝大多数程序化应用程序中,您也可能会看到一些对象,因为这是语言标准库实现其大部分功能的方式

第六章展示了 PHP 日期系统的功能和面向对象接口的使用。错误处理在第十三章中有更详细的讨论,重点利用内部的ExceptionError类。在一个否定程序化实现中,yield关键字会自动创建Generator类的实例。

即使您在程序中从未直接定义类,您很可能会使用 PHP 本身定义的类或程序所需的第三方库定义的类。³

可见性

PHP 中类还引入了可见性的概念。属性、方法甚至常量都可以使用可选的可见性修饰符进行定义,以改变它们在应用程序其他部分中的访问级别。声明为public的内容可以被应用程序中的任何其他类或函数访问。方法和属性可以声明为protected,使它们仅对类的实例或从它继承的类的实例可见。最后,private声明意味着类的成员只能被类本身的实例访问。

注意

默认情况下,未显式声明为私有或受保护的内容默认为公共,因此您可能会看到一些开发人员跳过声明成员可见性的步骤。

虽然成员可见性可以通过反射直接覆盖,⁴ 但通常通过明确声明类的接口的哪些部分可供其他代码元素使用,是一个明智的方式。 示例 8-5 演示了如何利用每种可见性修饰符来构建复杂应用程序。

示例 8-5. 类成员可见性概述
class A
{
    public    string $name  = 'Bob';
    public    string $city  = 'Portland';
    protected int    $year  = 2023;
    private   float  $value = 42.9;

    function hello(): string
    {
        return 'hello';
    }

    public function world(): string
    {
        return 'world';
    }

    protected function universe(): string
    {
        return 'universe';
    }

    private function abyss(): string
    {
        return 'the void';
    }
}

class B extends A
{
    public function getName(): string
    {
        return $this->name;
    }

    public function getCity(): string
    {
        return $this->city;
    }

    public function getYear(): int
    {
        return $this->year;
    }

    public function getValue(): float
    {
        return $this->value;
    }
}

$first = new B;
echo $first->getName() . PHP_EOL; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
echo $first->getCity() . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
echo $first->getYear() . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
echo $first->getValue() . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

$second = new A;
echo $second->hello() . PHP_EOL; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
echo $second->world() . PHP_EOL; ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)
echo $second->universe() . PHP_EOL; ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)
echo $second->abyss() . PHP_EOL; ![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)

1

打印Bob

2

打印Portland

3

打印2023

4

返回一个Warning,因为::$value属性是私有且不可访问的。

5

打印hello

6

打印world

7

抛出一个Error,因为::universe()方法是受保护的且在类实例外部不可访问。

8

由于前一行抛出了错误,这一行甚至不会执行。如果前一行没有抛出错误,则这一行会抛出错误,因为 ::abyss() 方法是私有的,无法在类实例外部访问。

下面的示例进一步说明了前述概念,并涵盖了 PHP 中对象的一些最常见用例和实现。

8.1 从自定义类实例化对象

问题

你希望定义一个自定义类并从中创建一个新的对象实例。

解决方案

使用 class 关键字定义类及其属性和方法,然后使用 new 创建实例,如下所示:

class Pet
{
    public string $name;
    public string $species;
    public int $happiness = 0;

    public function __construct(string $name, string $species)
    {
        $this->name = $name;
        $this->species = $species;
    }

    public function pet()
    {
        $this->happiness += 1;
    }
}

$dog = new Pet('Fido', 'golden retriever');
$dog->pet();

讨论

解决方案示例展示了对象的几个关键特征:

  • 对象可以具有定义对象内部状态的属性。

  • 这些对象可以具有特定的可见性。在解决方案示例中,对象是 public 的,这意味着可以被应用程序中的任何代码访问。

  • 魔术 ::__construct() 方法只能在首次实例化对象时接受参数。这些参数可用于定义对象的初始状态。

  • 方法可以具有与对象属性类似的可见性。

自 PHP 5 开始,这种特定版本的类定义是许多开发者使用的默认方式,当时首次引入了真正的面向对象基元。然而,示例 8-6 展示了一种更新且更简单的定义简单对象的方式。与独立声明并直接赋值对象状态的属性不同,PHP 8(及更高版本)允许在构造函数中定义所有内容。

示例 8-6. PHP 8 中的构造函数提升
class Pet
{
    public int $happiness = 0;

    public function __construct(
        public string $name,
        public string $species
    ) {}

    public function pet()
    {
        $this->happiness += 1;
    }
}

$dog = new Pet('Fido', 'golden retriever');
$dog->pet();

解决方案示例和 示例 8-6 在功能上是等效的,将导致在运行时创建具有相同内部结构的对象。然而,PHP 能够将构造函数参数提升为对象属性,大大减少了在定义类时需要输入的重复代码量。

每个构造函数参数也允许与对象属性相同的可见性(public/protected/private)。简写语法意味着您不需要在实例化对象时先声明属性,然后定义参数,然后将参数映射到这些属性。

参见

类和对象的文档构造函数提升的原始 RFC 的文档。

8.2 构造对象以定义默认值

问题

你希望为对象的属性定义默认值。

解决方案

为构造函数的参数定义默认值如下:

class Example
{
    public function __construct(
        public string $someString = 'default',
        public int    $someNumber = 5
    ) {}
}

$first = new Example;
$second = new Example('overridden');
$third = new Example('hitchhiker', 42);
$fourth = new Example(someNumber: 10);

讨论

类定义中的构造函数行为与 PHP 中的任何其他函数基本相同,除了它不返回值。你可以定义默认参数,类似于标准函数的方式。你甚至可以引用构造函数参数的名称,在函数签名中接受一些参数的默认值,同时在后续定义其他参数时。

解决方案示例通过使用构造函数提升明确定义了类属性,以简洁为目标,但旧式冗长的构造函数定义同样有效,如下所示:

class Example
{
    public string $someString;
    public int $someNumber;

    public function __construct(
        string $someString = 'default',
        int    $someNumber = 5
    )
    {
        $this->someString = $someString;
        $this->someNumber = $someNumber;
    }
}

同样地,如果使用构造函数提升,可以在定义时直接给对象属性赋默认值。这样做时,通常会在构造函数中省略这些参数,并在程序的其他地方进行操作,如下例所示:

class Example
{
    public string $someString = 'default';
    public int $someNumber = 5;
}

$test = new Example;
$test->someString = 'overridden';
$test->someNumber = 42;
警告

正如将在 Recipe 8.3 中讨论的那样,你不能直接使用默认值初始化readonly类属性。这与类常量等效,因此语法不允许。

参见

Recipe 3.2 详细说明了默认函数参数,Recipe 3.3 则介绍了命名函数参数,以及关于构造函数和析构函数的文档。

8.3 在类中定义只读属性

问题

你希望以某种方式定义类,以便在对象存在后无法更改已定义的属性。

解决方案

在已定义类型的属性上使用readonly关键字:

class Book
{
    public readonly string $title;

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

$book = new Book('PHP Cookbook');

如果使用构造函数提升,请将关键字与属性类型一起放在构造函数中:

class Book
{
    public function __construct(public readonly string $title) {}
}

$book = new Book('PHP Cookbook');

讨论

readonly关键字在 PHP 8.1 中引入,旨在减少原本需要更多冗长变通方法来实现相同功能的需求。使用此关键字,属性只能在对象实例化时初始化一次

注意

只读属性不能有默认值。这将使它们在功能上等同于类常量,这已经存在,因此该功能不可用,语法不支持。然而,提升的构造函数属性可以利用参数定义中的默认值,因为这些值在运行时进行评估。

该关键字仅对已定义类型的属性有效。在 PHP 中,类型通常是可选的(除非使用严格类型检查⁷),以增强灵活性,因此可能不能将类的属性设置为一个或另一个类型。在这些情况下,请使用mixed类型,以便可以设置一个只读属性而不受其他类型约束。

注意

此刻,只读声明支持静态属性。

由于只读属性只能实例化一次,因此不能由后续代码取消或修改。在示例 8-7 中的所有代码将导致抛出Error异常。

示例 8-7. 修改只读属性的错误尝试
class Example
{
    public readonly string $prop;
}

class Second
{
    public function __construct(public readonly int $count = 0) {}
}

$first = new Example; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$first->prop = 'test'; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$test = new Second;
$test->count += 1; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$test->count++; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
++$test->count; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
unset($test->count); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

Example 对象将具有未初始化的 ::$prop 属性,无法访问(在初始化之前访问属性会抛出 Error 异常)。

2

由于对象已经实例化,尝试写入只读属性会抛出 Error

3

::$count 属性是只读的,因此您无法为其分配新值,会导致 Error

4

由于 ::$count 属性是只读的,因此无法直接对其进行增量操作。

5

不能通过只读属性进行增量递增或递减。

6

不能取消只读属性。

类中的属性可能是其他类自身。在这些情况下,属性上的只读声明意味着该属性无法被重写或取消,但不影响子类的属性。例如:

class First
{
    public function __construct(public readonly Second $inner) {}
}

class Second
{
    public function __construct(public int $counter = 0) {}
}

$test = new First(new Second);
$test->inner->counter += 1; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$test->inner = new Second; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

由于 ::$counter 属性本身未声明为只读,因此内部计数器的增量将成功。

2

::$inner 属性是只读的,不能被覆盖。尝试这样做将导致 Error 异常。

参见

只读属性文档

8.4 拆解对象以在不再需要对象时进行清理

问题

类定义封装了一个昂贵的资源,在对象超出范围时必须小心清理。

解决方案

定义一个类析构函数,在对象从内存中移除后进行清理:

class DatabaseHandler
{
    // ...

    public function __destruct()
    {
        dbo_close($this->dbh);
    }
}

讨论

当对象超出范围时,PHP 将自动回收该对象用于表示的任何内存或其他资源。然而,在对象超出范围时,可能希望强制执行特定操作。例如释放数据库句柄,就像解决方案示例中那样。或者显式记录事件到文件中。或者从系统中删除临时文件,正如示例 8-8 所示。

示例 8-8. 在析构函数中删除临时文件
class TempLogger
{
    private string $filename;
    private mixed  $handle;

    public function __construct(string $name)
    {
        $this->filename = sprintf('tmp_%s_%s.tmp', $name, time());
        $this->handle = fopen($this->filename, 'w');
    }

    public function writeLog(string $line): void
    {
        fwrite($this->handle, $line . PHP_EOL);
    }

    public function getLogs(): Generator
    {
        $handle = fopen($this->filename, 'r');
        while(($buffer = fgets($handle, 4096)) !== false) {
            yield $buffer;
        }
        fclose($handle);
    }

    public function __destruct()
    {
        fclose($this->handle);
        unlink($this->filename);
    }
}

$logger = new TempLogger('test'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$logger->writeLog('This is a test'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$logger->writeLog('And another');

foreach($logger->getLogs() as $log) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    echo $log;
}

unset($logger); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

对象将自动在当前目录中创建一个文件,其名称类似于 tmp_test_1650837172.tmp

2

每个新的日志条目都作为临时日志文件中的新行写入。

3

访问日志将创建文件的第二个句柄,但用于读取。对象通过生成器公开此句柄,该生成器枚举文件中的每一行。

4

当记录器从作用域中删除(或显式取消引用)时,析构函数将关闭打开的文件句柄,并自动删除文件。

这个更复杂的示例展示了析构函数的编写方式以及在何时调用它。当 PHP 在对象超出范围时会查找 ::__destruct() 方法,并在那时调用它。此析构函数通过调用 unset() 明确取消引用对象来删除对象。您也可以通过将引用对象的变量设置为 null 来达到同样的结果。

与对象构造函数不同,析构函数不接受任何参数。如果您的对象在清理自身之后需要处理任何外部状态,请确保该状态通过对象本身的属性引用。否则,您将无法访问该信息。

另请参阅

构造函数和析构函数的文档。

8.5 使用魔术方法提供动态属性

问题

您希望定义一个自定义类,而不预先定义其支持的属性。

解决方案

使用魔术获取器和设置器来处理动态定义的属性如下:

class Magical
{
    private array $_data = [];

    public function __get(string $name): mixed
    {
        if (isset($this->_data[$name])) {
            return $this->_data[$name];
        }

        throw new Error(sprintf('Property `%s` is not defined', $name));
    }

    public function __set(string $name, mixed $value)
    {
        $this->_data[$name] = $value;
    }
}

$first = new Magical;
$first->custom = 'hello';
$first->another = 'world';

echo $first->custom . ' ' . $first->another . PHP_EOL;

echo $first->unknown; // Error

讨论

当您引用不存在的对象属性时,PHP 将回退到一组魔术方法来填补实现中的空白。当尝试引用属性时,自动使用 getter,而在向属性分配值时使用相应的 setter。

注意

通过魔术方法进行属性重载仅适用于已实例化的对象。它不适用于静态类定义。

在内部,您可以控制获取和设置数据的行为。解决方案示例将其数据存储在私有关联数组中。您可以通过完全实现处理isset()unset()的魔术方法来进一步完善此示例。示例 8-9 演示了如何使用魔术方法完全复制标准类定义,而无需预先声明所有属性。

Example 8-9. 完整对象定义与魔术方法
class Basic
{
    public function __construct(
        public string $word,
        public int $number
    ) {}
}

class Magic
{
    private array $_data = [];

    public function __get(string $name): mixed
    {
        if (isset($this->_data[$name])) {
            return $this->_data[$name];
        }

        throw new Error(sprintf('Property `%s` is not defined', $name));
    }

    public function __set(string $name, mixed $value)
    {
        $this->_data[$name] = $value;
    }

    public function __isset(string $name): bool
    {
        return array_key_exists($name, $this->_data);
    }

    public function __unset(string $name): void
    {
        unset($this->_data[$name]);
    }
}

$basic = new Basic('test', 22);

$magic = new Magic;
$magic->word = 'test';
$magic->number = 22;

在 示例 8-9 中,如果仅当 Magic 实例上使用的动态属性是 Basic 已定义的属性时,这两个对象功能上是等价的。这种动态特性使得这种方法非常有价值,即使类定义非常冗长。您可能选择在实现魔术方法的类中封装远程 API,以便以面向对象的方式向应用程序公开该 API 的数据。

另请参阅

魔术方法的文档。

8.6 扩展类以定义额外功能

问题

您希望定义一个类来为现有类定义添加功能。

解决方案

使用 extends 关键字定义额外方法或覆盖现有功能如下:

class A
{
    public function hello(): string
    {
        return 'hello';
    }
}

class B extends A
{
    public function world(): string
    {
        return 'world';
    }
}

$instance = new B();
echo "{$instance->hello()} {$instance->world()}";

讨论

对象继承是任何高级语言的常见概念;它是在其他、通常更简单的对象定义之上构建新对象的一种方式。解决方案示例展示了一个类如何从父类继承方法定义,这是 PHP 继承模型的核心功能。

警告

PHP 不支持从多个父类继承。为了从多个来源引入代码实现,PHP 使用特性,这在 配方 8.13 中有所涉及。

实际上,子类会继承其父类(扩展的类)的每个公共和受保护方法、属性和常量。私有方法、属性和常量永远不会被子类继承。⁸

子类也可以覆盖其父类对特定方法的实现。在实践中,您可能会这样做来改变特定方法的内部逻辑,但子类暴露的方法签名必须与父类定义的相匹配。示例 8-10 展示了一个子类如何覆盖其父类方法的实现。

示例 8-10. 覆盖父方法实现
class A
{
    public function greet(string $name): string
    {
        return 'Good morning, ' . $name;
    }
}

class B extends A
{
    public function greet(string $name): string
    {
        return 'Howdy, ' . $name;
    }
}

$first = new A();
echo $first->greet('Alice'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$second = new B();
echo $second->greet('Bob'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

打印 Good morning, Alice

2

打印 Howdy, Bob

覆盖的子方法并不完全失去父类实现的意义。在类内部,您可以使用 $this 变量引用对象的特定实例。同样,您可以使用 parent 关键字引用函数的父类实现。例如:

class A
{
    public function hello(): string
    {
        return 'hello';
    }
}

class B extends A
{
    public function hello(): string
    {
        return parent::hello() . ' world';
    }
}

$instance = new B();
echo $instance->hello();

参见

PHP 的 对象继承模型 的文档和讨论。

8.7 强制类表现特定行为

问题

您希望在一个类上定义方法,而将实际的方法实现留给其他人完成。

解决方案

定义对象接口并在应用程序中利用该接口如下:

interface ArtifactRepository
{
    public function create(Artifact $artifact): bool;
    public function get(int $artifactId): ?Artifact;
    public function getAll(): array;
    public function update(Artifact $artifact): bool;
    public function delete(int $artifactId): bool;
}

class Museum
{
    public function __construct(
        protected ArtifactRepository $repository
    ) {}

    public function enumerateArtifacts(): Generator
    {
        foreach($this->repository->getAll() as $artifact) {
            yield $artifact;
        }
    }
}

讨论

接口看起来类似于类定义,但它只定义了特定方法的签名,而不是它们的实现。然而,接口确实定义了可以在应用程序中其他地方使用的类型——只要一个类直接实现给定接口,该类的实例就可以像接口本身的类型一样使用。

警告

有几种情况下,您可能会有两个类实现了相同的方法并向应用程序公开了相同的签名。但是,除非这些类显式地实现了相同的接口(通过implements关键字作为证据),否则它们不能在严格类型的应用程序中互换使用。

实现必须使用implements关键字告诉 PHP 编译器发生了什么。解决方案示例说明了如何定义一个接口以及代码的另一部分如何利用该接口。Example 8-11 演示了如何使用内存数组实现ArtifactRepository接口。

Example 8-11. 显式接口实现
class MemoryRepository implements ArtifactRepository
{
    private array $_collection = [];

    private function nextKey(): int
    {
        $keys = array_keys($this->_collection);
        $max = array_reduce($keys, function($c, $i) {
            return max($c, $i);
        }, 0);

        return $max + 1;
    }

    public function create(Artifact $artifact): bool
    {
        if ($artifact->id === null) {
            $artifact->id = $this->nextKey();
        }

        if (array_key_exists($artifact->id, $this->_collection)) {
            return false;
        }

        $this->_collection[$artifact->id] = $artifact;
        return true;
    }
    public function get(int $artifactId): ?Artifact
    {
        return $this->_collection[$artifactId] ?? null;
    }
    public function getAll(): array
    {
        return array_values($this->_collection);
    }
    public function update(Artifact $artifact): bool
    {
        if (array_key_exists($artifact->id, $this->_collection)) {
            $this->_collection[$artifact->id] = $artifact;
            return true;
        }

        return false;
    }
    public function delete(int $artifactId): bool
    {
        if (array_key_exists($artifactId, $this->_collection)) {
            unset($this->_collection[$artifactId]);
            return true;
        }

        return false;
    }
}

在你的应用程序中,任何方法都可以通过使用接口本身在参数上声明类型。解决方案示例的Museum类将ArtifactRepository的具体实现作为唯一参数。然后,这个类可以操作,知道仓库暴露的 API 将是什么样子。代码并不关心 每个 方法是如何实现的,只关心它是否与接口定义的签名完全匹配。

类定义可以同时实现多个不同的接口。这允许一个复杂的对象在不同的代码片段中被用于不同的情境。请注意,如果两个或更多接口定义了相同的方法名,则它们定义的签名必须完全相同,正如 Example 8-12 所示。

Example 8-12. 一次实现多个接口
interface A
{
    public function foo(): int;
}

interface B
{
    public function foo(): int;
}

interface C
{
    public function foo(): string;
}

class First implements A, B
{
    public function foo(): int ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    {
        return 1;
    }
}

class Second implements A, C
{
    public function foo(): int|string ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    {
        return 'nope';
    }
}

1

由于AB定义了相同的方法签名,因此此实现是有效的。

2

由于AC定义了不同的返回类型,即使使用联合类型也无法定义一个同时实现这两个接口的类。试图这样做会导致致命错误。

还要记住,接口看起来有点像类,因此像类一样,它们也可以被扩展。⁹ 这通过相同的extends关键字完成,结果是一个由两个或更多接口组成的接口,如 Example 8-13 所示。

Example 8-13. 复合接口
interface A ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
{
    public function foo(): void;
}

interface B extends A ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
{
    public function bar(): void;
}

class C implements B
{
    public function foo(): void
    {
        // ... actual implementation
    }

    public function bar(): void
    {
        // ... actual implementation
    }
}

1

任何实现A的类必须定义foo()方法。

2

任何实现B的类必须从A中同时实现bar()foo()方法。

参见

有关对象接口的文档。

8.8 创建抽象基类

问题

您想让一个类实现特定的接口,但也想定义一些其他特定的功能。

解决方案

而不是实现一个接口,定义一个抽象基类可以扩展如下:

abstract class Base
{
    abstract public function getData(): string;

    public function printData(): void
    {
        echo $this->getData();
    }
}

class Concrete extends Base
{
    public function getData(): string
    {
        return bin2hex(random_bytes(16));
    }
}

$instance = new Concrete;
$instance->printData(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

1

打印类似于6ec2aff42d5904e0ccef15536d8548dc的内容。

讨论

抽象类看起来有点像接口和常规类定义的结合体。它具有一些未实现的方法和与之相对应的具体实现。与接口一样,您不能直接实例化抽象类——您必须首先扩展它并实现其定义的任何抽象方法。但是,与类一样,在子类实现中,您将自动访问基类的任何公共或受保护成员。¹⁰

接口和抽象类之间的一个关键区别在于后者可以将属性和方法定义捆绑在一起。抽象类实际上是仅部分实现的类。接口不能有属性——它仅定义了任何实现对象必须遵循的功能接口。

另一个区别是您可以同时实现多个接口,但一次只能扩展一个类。这一限制本身有助于确定何时使用抽象基类而不是接口——但您也可以混合使用两者!

抽象类也可以定义私有成员(这些成员不会被任何子类继承),否则这些成员将通过可访问的方法进行操作,如下例所示:

abstract class A
{
    private string $data = 'this is a secret'; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    abstract public function viewData(): void;

    public function getData(): string
    {
        return $this->data; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    }
}

class B extends A
{
    public function viewData(): void
    {
        echo $this->getData() . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    }
}

$instance = new B();
$instance->viewData(); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

通过使您的数据私有,它只能在A的上下文中访问。

2

因为::getData()是由A定义的,所以$data属性仍然是可访问的。

3

虽然::viewData()B的作用域中定义,但它正在访问A的公共方法。B中的任何代码都无法直接访问A的私有成员。

4

这将在控制台打印this is a secret

参见

类抽象的文档和讨论。

8.9 防止类和方法的更改

问题

你希望阻止任何人修改你的类的实现或者用子类扩展它。

解决方案

使用final关键字来指示一个类不可扩展,如下所示:

final class Immutable
{
    // Class definition
}

或者使用final关键字标记特定方法为不可更改,如下所示:

class Mutable
{
    final public function fixed(): void
    {
        // Method definition
    }
}

讨论

final关键字是显式阻止对象扩展的一种方式,类似于前两个示例中讨论的机制。在想要确保方法的特定实现或整个类在整个代码库中被使用时非常有用。

将方法标记为final意味着任何类扩展都无法覆盖该方法的实现。以下示例将由于Child类尝试覆盖Base类中的final方法而抛出致命错误:

class Base
{
    public function safe()
    {
        echo 'safe() inside Base class' . PHP_EOL;
    }

    final public function unsafe()
    {
        echo 'unsafe() inside Base class' . PHP_EOL;
    }
}

class Child extends Base
{
    public function safe()
    {
        echo 'safe() inside Child class' . PHP_EOL;
    }

    public function unsafe()
    {
        echo 'unsafe() inside Child class' . PHP_EOL;
    }
}

在前面的例子中,如果仅仅从子类中省略了unsafe()方法的定义,代码将按预期执行。但是,如果你想要阻止任何类扩展基类,你可以在类定义本身添加final关键字,如下所示:

final class Base
{
    public function safe()
    {
        echo 'safe() inside Base class' . PHP_EOL;
    }

    public function unsafe()
    {
        echo 'unsafe() inside Base class' . PHP_EOL;
    }
}

在你的代码中,唯一需要利用final的时机是当重写特定方法或类实现会破坏你的应用程序时。这在实践中有些罕见,但在创建灵活接口时非常有用。一个具体的例子是,当你的应用程序引入一个接口及其接口的具体实现时。¹¹ 你的 API 将被构建为接受任何有效的接口实现,但你可能希望阻止子类化自己的具体实现(这样做可能会破坏你的应用程序)。示例 8-14 展示了这些依赖项如何在真实应用程序中构建。

示例 8-14. 接口和具体类
interface DataAbstraction ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
{
    public function save();
}

final class DBImplementation implements DataAbstraction ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
{
    public function __construct(string $databaseConnection)
    {
        // Connect to a database
    }

    public function save()
    {
        // Save some data
    }
}

final class FileImplementation implements DataAbstraction ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
{
    public function __construct(string $filename)
    {
        // Open a file for writing
    }

    public function save()
    {
        // Write to the file
    }
}

class Application
{
    public function __construct(
        protected DataAbstraction $datalayer ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    ) {}
}

1

应用程序描述了任何数据抽象层必须实现的接口。

2

具体的一个实现将数据显式存储在数据库中。

3

另一种实现方式是使用平面文件进行数据存储。

4

应用程序不关心你使用哪种实现,只要它实现了基础接口。你可以使用提供的(final)类,也可以定义自己的实现方式。

在某些情况下,你可能会遇到需要扩展的final类。在这种情况下,你唯一可以使用的方式是利用装饰器。装饰器是一个以另一个类作为构造函数属性并“装饰”其方法的类。

注意

在某些情况下,装饰器不允许你绕过类的final属性。如果类型提示和严格类型要求将该类的实例传递给应用程序中的函数或另一个对象,则会发生这种情况。

假设,例如,你的应用程序中的一个库定义了一个 Note 类,该类实现了一个 ::publish() 方法,用于将给定的数据发布到社交媒体(比如 Twitter)。你希望这个方法同时生成给定数据的静态 PDF 文档,通常会扩展该类本身,就像在 示例 8-15 中所示。

示例 8-15. 典型的类扩展,没有使用final关键字
class Note
{
    public function publish()
    {
        // Publish the note's data to Twitter ...
    }
}

class StaticNote extends Note
{
    public function publish()
    {
        parent::publish();

        // Also produce a static PDF of the note's data ...
    }
}

$note = new StaticNote(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$note->publish(); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

而不是实例化Note对象,你可以直接实例化StaticNote

2

当你调用对象的 ::publish() 方法时,同时使用了这两个类定义。

如果Note类被标记为final,你将无法直接扩展该类。示例 8-16 演示了如何创建一个新的类,装饰Note类并间接扩展其功能。

示例 8-16. 使用装饰器自定义final类的行为
final class Note
{
    public function publish()
    {
        // Publish the note's data to Twitter ...
    }
}

final class StaticNote
{
    public function __construct(private Note $note) {}

    public function publish()
    {
        $this->note->publish();

        // Also produce a static PDF of the note's data ...
    }
}

$note = new StaticNote(new Note()); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$note->publish(); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

而不是直接实例化StaticNote,你可以使用这个类来包装(或装饰)一个常规的Note实例。

2

当调用对象的::publish()方法时,两个类定义都被使用。

参见

final 关键字的文档

8.10 对象克隆

问题

想要创建一个对象的独立副本。

解决方案

使用clone关键字创建对象的第二个副本,例如:

$dolly = clone $roslin;

讨论

默认情况下,当对象分配给新变量时,PHP 会通过引用复制对象。这个引用意味着新变量实际上指向内存中的同一对象。示例 8-17 说明了,尽管看起来你创建了对象的副本,但实际上只是处理了两个对同一数据的引用。

示例 8-17. 赋值操作符通过引用复制对象
$obj1 = (object)  ![1
    'propertyOne' => 'some',
    'propertyTwo' => 'data',
];
$obj2 = $obj1; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$obj2->propertyTwo = 'changed'; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

var_dump($obj1); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
var_dump($obj2);

1

此特定语法是 PHP 5.4 以来的简写,可以动态将新的关联数组转换为内置的stdClass类的实例。

2

尝试通过赋值操作符将第一个对象复制到一个新实例。

3

修改“复制”的对象的内部状态。

4

检查原始对象显示其内部状态已更改。$obj1$obj2都指向内存中的同一空间;你只是复制了对象的引用,而不是对象本身!

而不是复制对象引用,clone 关键字通过值将对象复制到新变量中。这意味着所有属性都复制到同一类的新实例中,并具有原始对象的所有方法。示例 8-18 说明了这两个对象现在完全解耦。

示例 8-18. clone 关键字通过值复制对象
$obj1 = (object) [
    'propertyOne' => 'some',
    'propertyTwo' => 'data',
];
$obj2 = clone $obj1; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$obj2->propertyTwo = 'changed'; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

var_dump($obj1); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
var_dump($obj2); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

而不是使用严格的赋值,利用clone关键字创建对象的按值副本。

2

再次更改副本的内部状态。

3

检查原始对象的状态显示没有变化。

4

然而,克隆并更改的对象展示了之前进行的属性修改。

这里的一个重要注意事项是要理解,在前面的示例中使用的clone是数据的浅克隆。此操作不会深入到像嵌套对象这样的更复杂属性中。即使使用适当的clone,可能仍然会留下两个不同的变量引用同一个内存中的对象。示例 8-19 说明了如果要复制的对象本身包含一个更复杂的对象会发生什么。

示例 8-19. 复杂数据结构的浅克隆
$child = (object) [
    'name' => 'child',
];
$parent = (object) [
    'name'  => 'parent',
    'child' => $child
];

$clone = clone $parent;

if ($parent === $clone) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    echo 'The parent and clone are the same object!' . PHP_EOL;
}

if ($parent == $clone) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    echo 'The parent and clone have the same data!' . PHP_EOL;
}

if ($parent->child === $clone->child) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    echo 'The parent and the clone have the same child!' . PHP_EOL;
}

1

在比较对象时,严格比较只有在比较符号两侧引用同一个对象时才评估为true。在这种情况下,您已经正确克隆了对象并创建了一个全新的实例,因此这个比较是false

2

在对象之间进行宽松类型比较时,当操作符两侧的相同时,即使是不同的实例,也会评估为true。这个语句评估为true

3

因为clone是一种浅操作,所以在两个对象上的::$child属性指向内存中相同的子对象。这个语句评估为true

要支持更深层次的克隆,被克隆的类必须实现一个__clone()魔术方法,告诉 PHP 在使用clone时该做什么。如果这个方法存在,PHP 会在关闭类的实例时自动调用它。示例 8-20 明确展示了这个过程,同时还在处理动态类。

注意

不能动态地在stdClass的实例上定义方法。如果您希望在应用程序中支持对象的深度克隆,必须直接定义一个类或利用匿名类,就像示例 8-20 所演示的那样。

示例 8-20. 对象的深度克隆
$parent = new class {
    public string $name = 'parent';
    public stdClass $child;

    public function __clone()
    {
        $this->child = clone $this->child;
    }
};
$parent->child = (object) [
    'name' => 'child'
];

$clone = clone $parent;

if ($parent === $clone) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    echo 'The parent and clone are the same object!' . PHP_EOL;
}

if ($parent == $clone) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    echo 'The parent and clone have the same data!' . PHP_EOL;
}

if ($parent->child === $clone->child) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    echo 'The parent and the clone have the same child!' . PHP_EOL;
}

if ($parent->child == $clone->child) { ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    echo 'The parent and the clone have the same child data!' . PHP_EOL;
}

1

这些对象是不同的引用,因此评估为false

2

父对象和克隆对象具有相同的数据,因此这评估为true

3

::$child属性也被内部克隆了,因此属性引用了不同的对象实例。这评估为false

4

两个::$child属性包含相同的数据,因此这评估为true

在大多数应用程序中,您通常会使用自定义类定义,而不是匿名类。在这种情况下,您仍然可以实现魔术__clone()方法,在必要时告诉 PHP 如何克隆对象的更复杂属性。

参见

关于clone关键字的文档。

8.11 定义静态属性和方法

问题

您希望为一个类定义一个方法或属性,这些方法或属性对该类的所有实例都可用。

解决方案

使用static关键字来定义可以在对象实例外部访问的属性或方法,例如:

class Foo
{
    public static int $counter = 0;

    public static function increment(): void
    {
        self::$counter += 1;
    }
}

讨论

类的静态成员可以在代码的任何部分(假设可见性正确)直接从类定义中访问,而不管该类是否作为对象存在。静态属性非常有用,因为它们的行为几乎类似于全局变量,但作用域仅限于特定的类定义。Example 8-21 说明了在另一个函数中调用全局变量与静态类属性的区别。

示例 8-21. 静态属性与全局变量
class Foo
{
    public static string $name = 'Foo';
}

$bar = 'Bar';

function demonstration()
{
    global $bar; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    echo Foo::$name . $bar; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
}

1

要在另一个作用域内访问全局变量,必须显式引用全局作用域。鉴于您可以在较窄的作用域内拥有与全局变量名称匹配的单独变量,这在实践中可能会变得令人困惑。

2

然而,类范围的属性可以根据类本身的名称直接访问。

更有用的是,静态方法提供了在直接实例化该类对象之前调用与类绑定的实用功能的方法。一个常见的例子是构造应该表示序列化数据的值对象,在这种情况下,直接从头开始构造对象可能会很困难。

Example 8-22 演示了一个不允许直接实例化的类。相反,您必须通过反序列化一些固定数据来创建一个实例。构造函数在类的内部作用域之外是不可访问的,因此静态方法是创建对象的唯一手段。

示例 8-22. 静态方法中的对象实例化
class BinaryString
{
    private function __construct(private string $bits) {} ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    public static function fromHex(string $hex): self
    {
        return new self(hex2bin($hex)); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    }

    public static function fromBase64(string $b64): self
    {
        return new self(base64_decode($b64));
    }

    public function __toString(): string ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    {
        return bin2hex($this->bits);
    }
}

$rawData = '48656c6c6f20776f726c6421';
$binary = BinaryString::fromHex($rawData); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

私有构造函数只能从类本身内部访问。

2

在静态方法中,您仍然可以通过利用特殊的self关键字来引用类,从而创建一个新的对象实例。这允许您访问私有构造函数。

3

PHP 的神奇__toString()方法在 PHP 尝试直接将对象强制转换为字符串时调用(例如,当您尝试将其echo到控制台时)。

4

与其使用new关键字创建对象,不如利用一个专门的静态反序列化方法。

静态方法和属性与它们的非静态同行一样受到可见性约束。请注意,将它们标记为private意味着它们只能被彼此或类本身内部的非静态方法引用。

由于静态方法和属性并不直接绑定到对象实例,因此无法使用常规的对象绑定访问器来访问它们。而是直接使用类名和作用域解析操作符(双冒号,或::)——例如,对于属性可以使用Foo::$bar,对于方法可以使用Foo::bar()。在类定义内部,您可以使用self作为类名的简写,或者使用parent作为父类名的简写(如果使用继承)。

注意

如果您可以访问类的对象实例,您可以使用该对象的名称而不是类名来访问其静态成员。例如,您可以使用$foo::bar()来访问命名为$foo的对象的类定义中的静态方法bar()。虽然这样可以工作,但可能会让其他开发人员更难理解您正在使用哪个类定义,因此如果可能的话,应该尽量少使用这种语法。

参见

static 关键字的文档。

8.12 检查对象内部的私有属性或方法

问题

您希望枚举对象的属性或方法,并利用其私有成员。

解决方案

使用 PHP 的反射 API 枚举属性和方法。例如:

$reflected = new ReflectionClass('SuperSecretClass');

$methods = $reflected->getMethods(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$properties = $reflected->getProperties(); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

讨论

PHP 的反射 API 赋予开发人员检查应用程序所有元素的强大能力。您可以枚举方法、属性、常量、函数参数等等。您还可以随意忽略每个元素的隐私性,并直接调用对象上的私有方法。示例 8-23 演示了如何使用反射 API 直接调用显式私有方法。

示例 8-23. 使用反射违反类的私有性
class Foo
{
    private int $counter = 0; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    public function increment(): void
    {
        $this->counter += 1;
    }

    public function getCount(): int
    {
        return $this->counter;
    }
}

$instance = new Foo;
$instance->increment(); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$instance->increment(); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

echo $instance->getCount() . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

$instance->counter = 0; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

$reflectionClass = new ReflectionClass('Foo');
$reflectionClass->getProperty('counter')->setValue($instance, 0); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

echo $instance->getCount() . PHP_EOL; ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)

1

示例类具有单个私有属性,用于维护内部计数器。

2

您希望将计数器增加到稍超过其默认值。现在它是1

3

另一个增量将计数器设置为2

4

此时,打印出计数器的状态将确认它是2

5

尝试直接与计数器进行交互将导致抛出Error,因为该属性是私有的。

6

通过反射,您可以与对象成员进行交互,而不受其隐私设置的限制。

7

现在您展示计数器确实已重置为0

Reflection 是绕过类公开 API 中可见性修饰符的强大方式。然而,在生产应用程序中使用它很可能表明接口或系统设计不当。如果您的代码需要访问类的私有成员,要么该成员应该从一开始就是公开的,要么您需要创建一个适当的访问器方法。

Reflection 的唯一合法用途是检查和修改对象的内部状态。在应用程序中,此行为应限制于类的公开 API。然而,在测试中,可能需要以 API 不支持的方式在测试运行之间修改对象的状态。¹² 这些罕见情况可能需要重置内部计数器或调用类中私有清理方法。Reflection 在这时显得非常有用。

在常规应用程序开发中,Reflection 结合像 var_dump() 这样的功能调用有助于澄清导入供应商代码中定义的类的内部操作。它可能对检查序列化对象或第三方集成有用,但要注意不要将这种内省应用到生产环境中。

另请参阅

PHP 的 Reflection API 概述

8.13 在类之间重用任意代码

问题

您希望在多个类之间共享特定功能而不是利用类扩展。

解决方案

使用 use 语句导入 Trait,例如:

trait Logger
{
    public function log(string $message): void
    {
        error_log($message);
    }
}

class Account
{
    use Logger; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    public function __construct(public int $accountNumber)
    {
        $this->log("Created account {$accountNumber}.");
    }
}

class User extends Person
{
    use Logger; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

    public function authenticate(): bool
    {
        // ...
        $this->log("User {$userId} logged in.");
        // ...
    }
}

1

Account 类从您的 Logger 特性导入日志记录功能,并可以像其本身定义的原生方法一样使用其方法。

2

同样地,User 类可以本地级别访问 Logger 的方法,即使它扩展了一个带有附加功能的基础 Person 类。

讨论

如 8.6 节 中所讨论的,PHP 中的类最多可以从一个其他类继承。这被称为单继承,也是除 PHP 外其他语言的特征。幸运的是,PHP 还提供了一种称为Traits 的代码重用机制。Traits 允许在单独的类似定义中封装某些功能,可以轻松导入而不会破坏单继承。

Trait 看起来有点像类,但不能直接实例化。相反,Trait 定义的方法通过 use 语句导入到另一个类定义中。这允许在不共享继承树的类之间进行代码重用。

特性使您能够定义共享的常见方法(具有不同的方法可见性)和属性。您还可以在导入特性的类中覆盖特性中的默认可见性。示例 8-24 展示了如何将特性中原本为公共方法的方法导入为受保护甚至私有方法的方法。

示例 8-24. 覆盖特性中方法的可见性
trait Foo
{
    public function bar()
    {
        echo 'Hello World!';
    }
}

class A
{
    use Foo { bar as protected; } ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
}

class B
{
    use Foo { bar as private; } ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
}

1

此语法将导入Foo中定义的每个方法,但会在类A的范围内显式地将其::bar()方法设为受保护。这意味着只有类A(或其子类)的实例才能调用该方法。

2

同样,类B将其导入的::foo()方法的可见性更改为私有,因此只有B的实例可以直接访问该方法。

特性可以深度组合在一起,这意味着特性可以像类一样轻松地use另一个特性。同样,导入特性的数量没有限制,可以由其他特性或类定义导入。

如果导入特性(或多个特性)的类也定义了与特性中同名的方法,则类的版本将优先使用,并成为默认的方法。示例 8-25 说明了这种优先级是如何默认工作的。

示例 8-25. 特性中方法的优先级
trait Foo
{
    public function bar(): string
    {
        return 'FooBar';
    }
}

class Bar
{
    use Foo;
    public function bar(): string
    {
        return 'BarFoo';
    }
}

$instance = new Bar;
echo $instance->bar(); // BarFoo

在某些情况下,您可能会导入多个特性,它们都定义了相同的方法。在这些情况下,您可以在定义use语句时明确指定要在最终类中利用的方法版本,如下所示:

trait A
{
    public function hello(): string
    {
        return 'Hello';
    }

    public function world(): string
    {
        return 'Universe';
    }
}

trait B
{
    public function world(): string
    {
        return 'World';
    }
}

class Demonstration
{
    use A, B {
        B::world insteadof A;
    }
}

$instance = new Demonstration;
echo "{$instance->hello()} {$instance->world()}!"; // Hello World!

与类定义类似,特性(Traits)也可以定义属性甚至静态成员。它们为您提供了一种有效的方式,将操作逻辑定义为可重用的代码块,并在应用程序中的类之间共享该逻辑。

另请参阅

有关特性的文档

¹ PHP 3 包含了一些原始的对象功能,但直到 4.0 的发布之前,大多数开发人员并没有真正将该语言视为面向对象的语言。

² 在撰写本文时,WordPress 用于驱动所有网站的 43%

³ 长篇章节中详细讨论了库和扩展功能,请参见第十五章。

⁴ 详见配方 8.12,了解有关反射 API 的更多信息。

⁵ 回顾“可见性”,以获取有关类内可见性的更多背景信息。

⁶ 详见配方 8.3,了解如何进一步将这些属性设置为只读。

⁷ 想要了解更多关于严格类型强制的内容,请参阅 Recipe 3.4。

⁸ 关于属性和方法可见性的更多信息,请查看“可见性”。

⁹ 想要了解更多关于类继承和扩展的内容,请参阅 Recipe 8.6。

¹⁰ 想要了解更多关于类继承的内容,请参阅 Recipe 8.6。

¹¹ 想要了解更多关于接口的内容,请查看 Recipe 8.7。

¹² 测试和调试内容详细讨论见第十三章。

第九章:安全和加密

PHP 是一个非常易于使用的语言,因为它的运行时宽容性。即使你犯了一个错误,PHP 也会尝试推断你想要做什么,并且通常会继续正常执行你的程序。不幸的是,这种特性也被一些开发者视为一个关键弱点。由于宽容性,PHP 允许大量“坏”代码继续像正确代码一样运行。

更糟糕的是,许多这样的“坏”代码出现在教程中,导致开发者将其复制粘贴到自己的项目中,并延续这一循环。PHP 的这种宽容的运行时和悠久历史使得人们普遍认为 PHP 本身不安全。事实上,任何编程语言都可能被不安全地使用。

PHP 本地支持快速、简便地过滤恶意输入和清理用户数据的能力。在 Web 环境中,这种实用性对于保护用户信息免受恶意输入或攻击至关重要。PHP 还提供了明确定义的函数,用于在认证过程中安全地哈希和验证密码。

注意

PHP 的默认密码哈希和验证函数都利用了安全的哈希算法和常数时间的安全实现。这保护了你的应用免受使用时间信息来尝试提取信息等特定攻击的影响。试图自己实现哈希(或验证)可能会使你的应用程序面临 PHP 已经考虑过的风险。

PHP 还为开发者简化了加密(包括加密和签名)的过程。本地的高级接口保护你免受在其他地方容易犯的错误的影响。¹ 事实上,PHP 是其中一个最简单的语言,开发者可以在其中利用强大、现代的加密,而无需依赖第三方扩展或实现!

旧版加密

PHP 的早期版本附带一个名为 mcrypt 的扩展。这个扩展暴露了底层的 mcrypt 库,允许开发者轻松利用各种分组密码和哈希算法。在 PHP 7.2 中,为了更加现代化,这个扩展被移除,并推荐使用更新的 sodium 扩展,但你仍然可以通过 PHP 扩展社区库(PECL)手动安装它。²

警告

虽然 mcrypt 库仍然可用,但在过去十多年里没有更新,不应在新项目中使用。对于任何新的加密需求,请使用 PHP 对 OpenSSL 的绑定或本地的 sodium 扩展。

PHP 还支持通过扩展直接使用 OpenSSL 库。在构建必须与旧的加密库进行互操作的系统时,这非常有用。但是,该扩展未将 OpenSSL 的全部功能提供给 PHP;有必要查看所暴露的函数和特性,以确定 PHP 的实现是否对您的应用程序有用。

无论如何,较新的钠接口支持 PHP 中广泛的加密操作,应优先于 OpenSSL 或 mcrypt。

关于钠及其暴露的接口唯一的问题是缺乏简洁性。每个函数都以sodium_为前缀,每个常量都以SODIUM_为前缀。在函数和常量名称中的高度描述性使得理解代码发生的事情变得容易。但是,这也导致了极长且潜在分散注意力的函数名称,例如sodium_​crypto_​sign_​keypair_​from_​secretkey_​and_​publickey()

PHP 在 2017 年末发布的 7.2 版本中正式添加了sodium扩展(也称为Libsodium)。该库支持高级抽象的加密、密码签名、密码哈希等功能。它本身是早期项目Networking and Cryptography Library (NaCl)的开源分支,由 Daniel J. Bernstein 创建。

这两个项目为需要处理加密的开发者添加了易于使用、高速的工具。它们暴露的接口具有明确的倾向性,旨在使密码学安全,并主动避免其他低级工具可能带来的问题。随着明确定义的倾向性接口,开发者可以在算法实现和默认选择方面做出正确的选择,因为这些选择(和潜在的错误)完全被抽象化,并以安全、简单的功能呈现供日常使用。

注意

虽然钠作为 PHP 的核心扩展捆绑在一起,它还为几种其他语言提供了绑定。它与从.NET 到 Go 再到 Java 到 Python 的所有内容都是完全可互操作的。

与许多其他加密库不同,钠主要专注于认证加密。每个数据片段都自动与身份验证标签配对,该库随后可以使用它来验证底层明文的完整性。如果此标签丢失或无效,库将抛出错误,以警示开发者关联的明文不可靠。

此身份验证的使用并非独特——高级加密标准(AES)的 Galois/Counter Mode (GCM) 实际上做了同样的事情。然而,其他库通常将身份验证和验证认证标签作为开发者的练习留给了开发者。有许多教程、书籍和 Stack Overflow 讨论指出了 AES 的正确实现,但忽略了附加到消息上的 GCM 标签的验证!钠扩展将身份验证和验证抽象化,并提供了清晰、简洁的实现,正如示例 9-1 中所示。

示例 9-1. 钠中的身份验证加密和解密
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$message = 'This is a super secret communication!';

$ciphertext = sodium_crypto_secretbox($message, $nonce, $key); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$output = bin2hex($nonce . $ciphertext); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

// Decoding and decryption reverses the preceding steps $bytes = hex2bin($input); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
$nonce = substr($bytes, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($bytes, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

if ($plaintext === false) { ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)
    throw new Exception('Unable to decrypt!');
}

1

加密算法是确定性的——相同的输入总是产生相同的输出。为了确保使用相同密钥加密相同数据返回不同输出,您需要每次使用足够随机的nonce来初始化算法。

2

对称加密中,您利用单个共享密钥来加密和解密数据。在本例中,密钥是随机的,但您可能会将此加密密钥存储在应用程序之外的某个地方以供安全保存。

3

加密非常简单。钠选择算法和密码模式,您只需提供消息、随机的一次性数(nonce)和对称密钥即可。底层库会处理其余部分!

4

在导出加密值(无论是发送给另一方还是存储在磁盘上),您需要跟踪随机 nonce 和随后的密文。nonce 本身不是秘密,因此将其与加密值一起以明文形式存储是安全的(并且是鼓励的)。将二进制的原始字节转换为十六进制是准备数据进行 API 请求或存储在数据库字段中的有效方式。

5

由于加密输出是十六进制编码的,因此必须先将其解码为原始字节,然后在进行解密之前分离 nonce 和密文组件。

6

要从加密字段中提取回原文值,需要提供带有其关联 nonce 和原始加密密钥的密文。库会将原文字节重新提取出来并返回给您。

7

在内部,加密库还在每条加密消息上添加(并验证)认证标签。如果在解密过程中认证标签验证失败,sodium 将返回一个字面量false而不是原始明文。这表明消息已被篡改(无论是有意还是无意),不可信。

Sodium 还引入了一个有效的方式来处理公钥密码学。在这种范式中,加密使用一个密钥(已知或公开的密钥),而解密则使用仅为消息接收者所知的完全不同的密钥。在通过可能不受信任的介质(如通过公共互联网交换用户与其银行之间的银行信息)之间交换数据时,这种两部分密钥系统非常理想。事实上,现代互联网上大多数网站使用的 HTTPS 连接在浏览器内部也利用公钥密码学。

在 RSA 等传统系统中,为了安全地交换信息,您需要跟踪相对较大的加密密钥。在 2022 年,RSA 的最小推荐密钥大小为 3072 位;在许多情况下,开发人员将默认为 4096 位,以保证密钥对未来计算增强的安全性。在某些情况下,处理这么大的密钥可能会很困难。此外,传统的 RSA 只能加密 256 字节的数据。如果要加密更大的消息,则需要执行以下操作:

  1. 创建一个 256 位的随机密钥。

  2. 使用那个 256 位密钥对消息进行对称加密。

  3. 使用 RSA 加密对称密钥。

  4. 分享加密消息和保护其的加密密钥。

这是一个可行的解决方案,但所涉及的步骤很容易变得复杂,并为构建包含加密项目的开发团队引入不必要的复杂性。幸运的是,sodium 几乎完全解决了这个问题!

Sodium 的公钥接口利用椭圆曲线密码学(ECC)而不是 RSA。 RSA 使用素数和指数运算来创建用于加密的已知(公共)和未知(私有)组件的双密钥系统。 ECC 则利用几何和特定形式的算术对定义明确的椭圆曲线进行操作。而 RSA 的公共和私有组件是用于指数运算的数字,ECC 的公共和私有组件是几何曲线上的字面上的xy坐标。

使用 ECC,256 位密钥具有与 3072 位 RSA 密钥等效的强度。此外,sodium 选择的加密原语意味着其密钥只是数字(而不是大多数其他 ECC 实现的xy坐标)—sodium 的 256 位 ECC 密钥只是一个 32 字节的整数!

此外,钠完全将“创建一个随机对称密钥并单独加密它”的工作流抽象出来,使得 PHP 中的非对称加密与对称加密一样简单,正如 示例 9-1 对对称加密所示范的那样。示例 9-2 明确展示了这种加密形式的工作方式,以及参与者之间所需的密钥交换。

示例 9-2. 钠中的非对称加密与解密
$bobKeypair = sodium_crypto_box_keypair(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$bobPublic = sodium_crypto_box_publickey($bobKeypair); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$bobSecret = sodium_crypto_box_secretkey($bobKeypair);

$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$message = 'Attack at dawn.';

$alicePublic = '...'; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

$keyExchange = sodium_crypto_box_keypair_from_secretkey_and_publickey( ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
    $bobSecret,
    $alicePublic
);

$ciphertext = sodium_crypto_box($message, $nonce, $keyExchange); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

$output = bin2hex($nonce . $ciphertext); ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)

// Decrypting the message reverses the key exchange process $keyExchange2 = sodium_crypto_box_keypair_from_secretkey_and_publickey( ![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)
    $aliceSecret,
    $bobPublic
);

$plaintext = sodium_crypto_box_open($ciphertext, $nonce, $keyExchange2); ![9](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/9.png)

if ($plaintext === false) { ![10](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/10.png)
    throw new Exception('Unable to decrypt!');
}

1

在实际应用中,双方都会在本地生成其公共/私有密钥对,并直接分发其公共密钥。sodium_crypto_box_keypair() 函数每次都创建一个随机密钥对,因此理论上,只需执行一次,只要保证私钥保持私密即可。

2

密钥对的公共和私有组件可以分别提取。这使得仅向第三方提取和传输公钥变得容易,同时也使得私钥分别可用于后续的密钥交换操作。

3

与对称加密一样,每次非对称加密操作都需要一个随机 nonce。

4

Alice 的公钥可以通过直接的通信渠道分发,或者已经被知晓。

5

这里的密钥交换并非用于协商新密钥;它只是简单地将 Bob 的私钥与 Alice 的公钥结合,以便 Bob 加密一条只能由 Alice 读取的消息。

6

再次,钠选择所涉及的算法和密码模式。你只需要提供数据、随机 nonce 和密钥,库就会完成其余的工作。

7

发送消息时,将 nonce 和密文连接在一起,然后将原始字节编码为更易于通过 HTTP 渠道发送的内容。十六进制是常见选择,但 Base64 编码同样有效。

8

在接收端,Alice 需要将自己的私钥与 Bob 的公钥结合起来,以解密只能由 Bob 加密的消息。

9

提取明文就像步骤 6 中的加密一样简单!

10

与对称加密一样,此操作也是经过认证的。如果由于任何原因(例如,Bob 的公钥无效)导致加密失败,或者认证标签无法验证,钠会返回一个字面上的 false,表明消息不可信任。

随机性

在加密领域中,利用适当的随机源对保护任何类型的数据至关重要。较旧的教程大量引用了基于 Mersenne Twister 算法的 PHP 的mt_rand()函数,这是一个伪随机数生成器。

不幸的是,尽管此函数的输出对于偶然观察者似乎是随机的,但它不是密码学上安全的随机源。相反,对于关键任务,请使用 PHP 的random_bytes()random_int()函数。这两个函数利用了您本地操作系统中内置的密码学安全随机源。

警告

密码学安全伪随机数生成器(CSPRNG)的输出与随机噪声无法区分。像 Mersenne Twister 这样的算法足以愚弄人类认为它们是安全的。但实际上,它们对计算机来说很容易预测甚至破解,只要给定一系列先前的输出。如果攻击者能够可靠地预测您的随机数生成器的输出,他们理论上可以解密基于该生成器的任何您试图保护的内容!

以下示例涵盖了 PHP 中一些最重要的安全和加密相关概念。您将学习有关输入验证、适当的密码存储以及使用 PHP 的钠接口的内容。

9.1 过滤、验证和清理用户输入

问题

您希望在将其用于应用程序中的其他地方之前验证来自不受信任用户的特定值。

解决方案

使用filter_var()函数验证值是否符合特定期望,如下所示:

$email = $_GET['email'];

$filtered = filter_var($email, FILTER_VALIDATE_EMAIL);

讨论

PHP 的过滤扩展使您能够验证数据是否符合特定格式或类型,或者对未通过验证的数据进行清理。两种选项之间的微妙差别在于,清理会从值中删除无效字符,而验证会明确返回false,如果最终的清理后输入不是有效类型。

在解决方案示例中,明确验证了不受信任的用户输入是否为有效的电子邮件地址。 示例 9-3 演示了此形式验证在多个潜在输入中的行为。

示例 9-3. 测试电子邮件验证
function validate(string $data): mixed
{
    return filter_var($data, FILTER_VALIDATE_EMAIL);
}

validate('blah@example.com'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
validate('1234'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
validate('1234@example.com<test>'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

返回blah@example.com

2

返回false

3

返回false

与前述示例的替代方法是对用户输入进行清理,以便从条目中去除无效字符。这种清理的结果保证与特定字符集匹配,但不能保证结果是有效输入。例如,示例 9-4 即使两个结果均为无效电子邮件地址,也会正确地清理每个可能的输入字符串。

示例 9-4. 测试电子邮件清理
function sanitize(string $data): mixed
{
    return filter_var($data, FILTER_SANITIZE_EMAIL);
}

sanitize('blah@example.com'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
sanitize('1234'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
sanitize('1234@example.com<test>'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

返回 blah@example.com

2

返回 1234

3

返回 1234@example.comtest

根据您希望对输入数据进行的操作,您可能需要对其进行清理或验证。如果您只是希望在数据存储引擎中保持无效字符之外的数据,那么清理可能是正确的方法。如果您希望确保数据既在预期的字符集范围内,是有效的条目,则数据验证是更安全的工具。

基于 PHP 中各种类型的过滤器,filter_var() 同样支持这两种方法。具体而言,PHP 支持验证过滤器(见表 9-1)、清理过滤器(见表 9-2)以及不属于这两类的过滤器(见表 9-3)。filter_var() 函数还支持第三个可选参数,用于启用更精细的控制,以控制过滤操作的整体输出。

表 9-1. PHP 支持的验证过滤器

ID 选项 标志 描述
FILTER_​VALI⁠DATE_​BOOLEAN default FILTER_NULL_ON_FAILURE 对真值(1trueonyes)返回 true,否则返回 false
FILTER_​VALI⁠DATE_​DOMAIN default FILTER_FLAG_HOSTNAME, FIL⁠TER_NULL_ON_FAILURE 根据各种 RFC 验证域名长度是否有效
FILTER_​VALI⁠DATE_​EMAIL default FILTER_FLAG_EMAIL_UNICODE, FIL⁠TER_​NULL_ON_FAILURE 根据RFC 822的语法验证电子邮件地址
FILTER_​VALI⁠DATE_​FLOAT default, decimal, min_range, max_range FIL⁠TER_​FLAG_ALLOW_THOUSANDS, FILTER_NULL_ON_FAILURE 验证值是否为浮点数,可选地从指定范围内验证,并在成功时转换为该类型
FILTER_​VALI⁠DATE_​INT default, max_range, min_range FILTER_FLAG_ALLOW_OCTAL, FIL⁠TER_FLAG_ALLOW_HEX, FIL⁠TER_NULL_ON_FAILURE 验证值是否为整数,可选地从指定范围内验证,并在成功时转换为该类型
FILTER_​VALI⁠DATE_​IP default FILTER_FLAG_IPV4, FILTER_FLAG_IPV6, FIL⁠TER_FLAG_NO_PRIV_RANGE, FIL⁠TER_FLAG_NO_RES_RANGE, FIL⁠TER_NULL_ON_FAILURE 验证值是否为 IP 地址
FILTER_​VALI⁠DATE_​MAC default FILTER_NULL_ON_FAILURE 将值验证为 MAC 地址
FILTER_​VALI⁠DATE_​REGEXP defaultregexp FILTER_NULL_ON_FAILURE 根据 Perl 兼容的正则表达式验证值
FILTER_​VALI⁠DATE_​URL default FILTER_FLAG_SCHEME_REQUIREDFIL⁠TER_FLAG_HOST_REQUIREDFIL⁠TER_FLAG_PATH_REQUIREDFIL⁠TER_FLAG_QUERY_REQUIREDFIL⁠TER_NULL_ON_FAILURE 根据RFC 2396验证为 URL

表 9-2. PHP 支持的净化过滤器

ID 标志 描述
FILTER_​SANI⁠TIZE_EMAIL 移除除了字母、数字和+ ! # $ % & ' * + - = ? ^ _ ` { \ | } ~ @ . [ ] 之外的所有字符
FILTER_​SANI⁠TIZE_​ENCODED FILTER_FLAG_STRIP_LOWFIL⁠TER_FLAG_STRIP_HIGHFIL⁠TER_FLAG_STRIP_BACKTICKFIL⁠TER_FLAG_ENCODE_HIGHFIL⁠TER_FLAG_ENCODE_LOW 对字符串进行 URL 编码,可选择剥离或编码特殊字符
FILTER_​SANI⁠TIZE_ADD_SLASHES 应用 addslashes()
FILTER_​SANI⁠TIZE_​NUM⁠BER_FLOAT FILTER_FLAG_ALLOW_FRACTIONFILTER_FLAG_ALLOW_THOUSANDSFILTER_FLAG_ALLOW_SCIENTIFIC 移除除了数字、加号和减号以及可选的句点、逗号和大写和小写 E 之外的所有字符
FILTER_​SANI⁠TIZE_​NUM⁠BER_INT 移除除了数字、加号和减号之外的所有字符
FILTER_​SANI⁠TIZE_​SPE⁠CIAL_CHARS FILTER_FLAG_STRIP_LOWFIL⁠TER_FLAG_STRIP_HIGHFIL⁠TER_FLAG_STRIP_BACKTICKFIL⁠TER_FLAG_ENCODE_HIGH HTML 编码 ' " < > & 和 ASCII 值低于 32 的字符,可选择剥离或编码其他特殊字符
FILTER_​SANI⁠TIZE_FULL_​SPECIAL_CHARS FILTER_FLAG_NO_ENCODE_QUOTES 等同于使用设置了 ENT_QUOTEShtmlspecialchars()
FILTER_​SANI⁠TIZE_URL 移除除了在 URL 中有效的字符之外的所有字符

表 9-3. PHP 支持的杂项过滤器

ID 选项 标志 描述
FILTER_CALLBACK callable 函数或方法 所有标志被忽略。 调用用户定义的函数来过滤数据

验证过滤器还接受运行时的选项数组。这使您能够编写特定范围(用于数字检查)甚至是特定用户输入未通过验证时的默认值。

例如,假设您正在构建一个允许用户指定他们想购买的商品数量的购物车。显然,这必须是大于零且小于您所拥有的总库存的值。像示例 9-5 中所示的方法将强制该值为介于某些边界之间的整数,否则该值将回退到 1。通过这种方式,用户无法意外地订购超过库存、负数商品、部分商品或某些非数值数量。

示例 9-5. 验证带有界限和默认值的整数值
function sanitizeQuantity(mixed $orderSize): int
{
    return filter_var(
        $orderSize,
        FILTER_VALIDATE_INT,
        [
            'options' => [
                'min_range' => 1,
                'max_range' => 25,
                'default'   => 1,
            ]
        ]
    );
}

echo sanitizeQuantity(12) . PHP_EOL; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
echo sanitizeQuantity(-5) . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
echo sanitizeQuantity(100) . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
echo sanitizeQuantity('banana') . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

数量核对并返回 12

2

负整数验证失败,因此返回默认值 1

3

输入超出最大范围,因此返回默认值 1

4

非数字输入将始终返回默认值 1

参见

PHP 的数据过滤扩展的文档

9.2 将敏感凭据从应用程序代码中排除

问题

您的应用程序需要利用密码或 API 密钥,并且希望避免将敏感凭据写入代码或提交到版本控制。

解决方案

将凭据存储在运行应用程序的服务器暴露的环境变量中。然后在代码中引用该环境变量。例如:

$db = new PDO($database_connection, getenv('DB_USER'), getenv('DB_PASS'));

讨论

许多开发者在早期职业生涯中常犯的一个错误是将敏感系统的凭据硬编码到常量或应用程序代码的其他位置。虽然这样可以让这些凭据容易被应用逻辑访问,但也会严重增加应用程序的风险。

你可能会意外地从开发账户中使用生产凭据。攻击者可能会在意外公开的代码库中找到索引的凭据。员工可能会滥用他们对凭据的知识,超出预期的使用范围。

在生产环境中,最好的凭据是那些未知且未被人为操作的。最好将这些凭据仅保留在生产环境中,并为开发和测试使用单独的账户。在代码中利用环境变量使得应用程序足够灵活,能够在任何地方运行,因为它使用的是环境本身而不是硬编码的凭据。

警告

PHP 内置的信息系统 phpinfo() 将自动列举所有环境变量,用于调试目的。一旦开始利用系统环境存放敏感凭据,请特别注意避免在应用程序的公开可访问部分使用诸如 phpinfo() 这样详细的诊断工具!

填充环境变量的方法会因系统而异。在使用 Apache 的系统中,可以通过在 <VirtualHost> 指令内使用 SetEnv 关键字来设置环境变量,如下所示:

<VirtualHost myhost>
...
SetEnv DB_USER "database"
SetEnv DB_PASS "password1234"
...
</VirtualHost>

在由 NGINX 驱动的系统中,只有在 PHP 作为 FastCGI 进程运行时,才能设置环境变量。类似于 Apache 的 SetEnv,这可以通过在 NGINX 配置的 location 指令内使用关键字来完成,如下所示:

location / {
    ...
    fastcgi_param DB_USER database
    fastcgi_param DB_PASS password1234
    ...
}

Docker 驱动的系统分别在其 Compose 文件(用于 Docker Swarm)或系统部署配置(用于 Kubernetes)中设置环境变量。在所有这些情况下,您都是在环境本身而不是应用程序内部定义凭据。

另一个选择是使用 PHP dotenv⁴。这个第三方包允许您在名为 .env 的平面文件中定义您的环境配置,并自动填充环境变量和 $_SERVER 超全局数组。这种方法的最大优势在于 dotfiles(以 . 开头的文件)很容易从版本控制中排除,并且通常在服务器上是隐藏的。您可以在本地使用一个 .env 文件来定义开发凭据,并在服务器上保留一个单独的 .env 文件来定义生产凭据。

在这两种情况下,您都无需直接管理任何 Apache 或 NGINX 配置文件!

一个 .env 文件定义的数据库凭据,用于解决方案示例,看起来类似以下内容:

DB_USER=database
DB_PASS=password1234

在您的应用程序代码中,您可以加载库依赖项并按如下方式调用其加载器:

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

一旦加载了库,您可以利用 getenv() 在需要访问环境变量的任何地方引用它们。

参见

getenv()文档

9.3 哈希和验证密码

问题

您希望仅通过用户知道的密码进行身份验证,并防止您的应用程序存储敏感数据。

解决方案

使用 password_hash() 来存储密码的安全哈希:

$hash = password_hash($password, PASSWORD_DEFAULT);

使用 password_verify() 来验证一个明文密码是否产生了给定的存储哈希,如下所示:

if (password_verify($password, $hash)) {
    // Create a valid session for the user ...
}

讨论

存储明文密码始终是一个坏主意。如果您的应用程序或数据存储曾被入侵过,那些明文密码将会被攻击者滥用。为了保护用户安全并在遭遇入侵时防止滥用,您必须始终在存储密码时对其进行哈希处理。

幸运的是,PHP 集成了一个原生功能来实现这一点——password_hash()。此函数接受明文密码,并自动从该数据生成一个确定性但看似随机的哈希。与其存储明文密码,您存储这个哈希。稍后,当用户选择登录您的应用程序时,您可以比较明文密码与存储的哈希(使用像 hash_equals() 这样的安全比较函数),并断言它们是否匹配。

PHP 通常支持三种哈希算法,在表 9-4 中列举。在撰写本文时,默认算法是 bcrypt(基于 Blowfish 密码),但您可以通过向 password_hash() 传递第二个参数来在运行时选择特定的算法。

表 9-4. 密码哈希算法

常量 描述
PASSWORD_DEFAULT 使用默认的 bcrypt 算法。默认算法可能会在将来的版本中更改。
PASSWORD_BCRYPT 使用CRYPT_BLOWFISH算法。
PASSWORD_ARGON2I 使用 Argon2i 散列算法。(仅在 PHP 已编译为支持 Argon2 时可用。)
PASSWORD_ARGON2ID 使用 Argon2id 散列算法。(仅在 PHP 已编译为支持 Argon2 时可用。)

每个散列算法支持一组选项,这些选项可以确定服务器上计算散列的难度。默认(或 bcrypt)算法支持一个整数“成本”因子——数字越高,操作的计算成本就越高。Argon2 系列算法支持两个成本因子——一个用于内存成本,另一个用于计算散列所需的时间量。

注意

增加计算散列成本是一种保护应用程序免受暴力破解身份验证攻击的手段。如果计算一个散列需要 1 秒钟,那么合法用户进行身份验证将至少需要 1 秒钟(这很简单)。但是,攻击者每秒最多只能尝试进行一次身份验证。这使得暴力破解攻击在时间和计算能力上都相对昂贵。

在首次构建应用程序时,建议测试将运行该应用程序的服务器环境,并适当设置成本因子。识别成本因子需要在现场环境中测试password_hash()的性能,正如在示例 9-6 中所演示的。该脚本将测试系统使用越来越大的成本因子时的散列性能,并确定达到期望时间目标的成本因子。

示例 9-6。测试password_hash()的成本因子
$timeTarget = 0.5; // 500 milliseconds

$cost = 8;
do {
    $cost++;
    $start = microtime(true);
    password_hash('test', PASSWORD_BCRYPT, ['cost' => $cost]);
    $end = microtime(true);
} while(($end - $start) < $timeTarget);

echo "Appropriate cost factor: {$cost}" . PHP_EOL;

password_hash()的输出旨在完全向前兼容。该函数不仅生成散列,还会内部生成一个盐以使散列唯一化。然后该函数返回表示以下内容的字符串:

  • 使用的算法

  • 成本因子或选项

  • 生成的随机盐

  • 生成的散列

图 9-1 显示了此字符串输出的示例。

注意

PHP 在每次调用password_hash()时会内部生成一个唯一的随机盐。这样做的效果是,对于相同的明文密码,产生不同的散列。通过这种方式,散列值不能用于识别哪些帐户使用相同的密码。

Illustration of the output of +password_hash()+

图 9-1。password_hash()的输出示例

将所有这些信息编码到pass⁠word_​hash()的输出中的优势在于,您无需在应用程序中维护这些数据。在将来的某个日期,您可能会更改散列算法或修改用于散列的成本因素。通过编码最初用于生成散列的设置,PHP 可以可靠地重新创建用于比较的散列。

当用户登录时,他们只提供明文密码。应用程序需要重新计算此密码的哈希值,并将新计算的值与存储在数据库中的值进行比较。鉴于需要计算哈希的信息存储在哈希旁边,这变得相对简单。

而不是自己实现比较,您可以利用 PHP 的password_verify()函数以安全可靠的方式完成所有操作。

参见

有关password_hash()password_​verify()的文档。

9.4 加密和解密数据

问题

您希望使用加密保护敏感数据,并能够在稍后可靠地解密这些信息。

解决方案

使用 sodium 的sodium_crypto_secretbox()来使用已知的对称(也称为共享)密钥加密数据,如示例 9-7 中所示。

示例 9-7。Sodium 对称加密示例
$key = hex2bin('faae9fa60060e32b3bbe5861c2ff290f' .
               '2cd4008409aeb7c59cb3bad8a8e89512'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$message = 'Look to my coming on the first light of ' .
           'the fifth day, at dawn look to the east.';

$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$ciphertext = sodium_crypto_secretbox($message, $nonce, $key); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$output = bin2hex($nonce . $ciphertext); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

密钥必须是长度为SODIUM_CRYPTO_SECRETBOX_KEYBYTES(32 字节)的随机值。如果您正在创建一个新的随机字符串,可以使用sodium_crypto_secretbox_keygen()来创建一个。只需确保将其存储在某个地方,以便稍后解密消息。

2

每个加密操作都应使用唯一的、随机的一次性数字。PHP 的ran⁠dom_​bytes()可以可靠地创建适当长度的随机数,使用内置常量。

3

加密操作利用随机一次性数字和固定的秘密密钥来保护消息,并作为结果返回原始字节。

4

通常希望通过网络协议交换加密信息,因此将原始字节编码为十六进制可以使其更易于移植。您还需要一次性数字来解密交换的数据,因此将其存储在密文旁边。

当您需要解密数据时,使用sodium_crypto_secretbox_open()来提取和验证数据,如示例 9-8 中所示。

示例 9-8。Sodium 对称解密
$key = hex2bin('faae9fa60060e32b3bbe5861c2ff290f' .
               '2cd4008409aeb7c59cb3bad8a8e89512'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$encrypted = '8b9225c935592a5e95a9204add5d09db' .
             'b7b6473a0aa59c107b65f7d5961b720e' .
             '7fc285bd94de531e05497143aee854e2' .
             '918ba941140b70c324efb27c86313806' .
             'e04f8e79da037df9e7cb24aa4bc0550c' .
             'd7b2723cbb560088f972a408ffc973a6' .
             '2be668e1ba1313e555ef4a95f0c1abd6' .
             'f3d73921fafdd372'; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$raw = hex2bin($encrypted);

$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$ciphertext = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
if ($plaintext === false) { ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    echo 'Error decrypting message!' . PHP_EOL;
} else {
    echo $plaintext . PHP_EOL;
}

1

使用解密时所用的相同密钥,该密钥用于加密。

2

从示例 9-7 得到的结果密文在此处被重复使用,并且从十六进制编码的字符串中提取。

3

因为您将密文和一次性数字串联在一起,所以必须将这两个组件分开以便与sodium_crypto_secretbox_open()一起使用。

4

整个加密/解密操作都是经过验证的。如果底层数据发生任何变化,验证步骤将失败,并且函数会返回字面值false来标记篡改。如果返回其他内容,则解密成功,您可以信任输出!

讨论

由 sodium 公开的secretbox函数系列通过使用固定的对称密钥实现了经过验证的加密/解密。每次加密消息时,都应该使用一个随机的随机数来完全保护加密消息的隐私。

警告

在加密中使用的随机数(nonce)本身不是秘密或敏感值。然而,您应该注意,永远不要在相同的对称加密密钥下重复使用随机数。随机数是“一次性使用”的数字,旨在为加密算法增加随机性,使得使用相同密钥加密两次的相同值可以产生不同的密文。重复使用随机数会损害您希望保护的数据的安全性。

这里的对称性在于同一个密钥用于加密和解密消息。当同一个系统负责这两个操作时,这是最有价值的——例如,当 PHP 应用程序需要加密要存储在数据库中的数据,然后在读取时解密该数据。

加密利用XSalsa20 流密码来保护数据。这种密码使用 32 字节(256 位)密钥和 24 字节(192 位)随机数。尽管开发者不必关心这些信息,因为它们被安全地抽象在secretbox函数和常量后面。与跟踪密钥大小和加密模式不同,您只需使用匹配的密钥和消息随机数来创建或打开一个盒子。

这种方法的另一个优点是身份验证。每个加密操作也将生成一个消息身份验证标签,利用Poly1305 算法。解密时,sodium 将验证身份验证标签是否与受保护数据匹配。如果不匹配,可能是消息意外损坏或有意篡改。在任何一种情况下,密文都是不可靠的(解密后的明文也是如此),并且 open 函数将返回一个布尔值false

非对称加密

当同一个方负责加密和解密数据时,对称加密最为简单。在许多现代技术环境中,这些参与方可能是独立的,并通过不太可信的媒体进行通信。这需要一种不同形式的加密,因为两个参与方不能直接共享对称加密密钥。相反,每个参与方可以创建公钥和私钥对,并利用密钥交换协议来就加密密钥达成一致。

密钥交换是一个复杂的话题。幸运的是,sodium 为在 PHP 中执行此操作提供了简单的接口。在接下来的示例中,两个参与方将执行以下操作:

  1. 创建公钥/私钥对

  2. 直接交换他们的公钥

  3. 利用这些非对称密钥达成对称密钥协议

  4. 交换加密数据

示例 9-9 说明了每一方如何创建自己的公钥/私钥对。尽管代码示例是一个单独的块,但每一方都会独立创建他们的密钥,并且只与对方交换他们的公钥。

示例 9-9. 非对称密钥创建
$aliceKeypair = sodium_crypto_box_keypair();
$alicePublic = sodium_crypto_box_publickey($aliceKeypair); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$alicePrivate = sodium_crypto_box_secretkey($aliceKeypair); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$bethKeypair = sodium_crypto_box_keypair(); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$bethPublic = sodium_crypto_box_publickey($bethKeypair);
$bethPrivate = sodium_crypto_box_secretkey($bethKeypair);

1

Alice 创建一个密钥对,并从中分别提取她的公钥和私钥。她与 Beth 分享她的公钥。

2

Alice 保持她的私钥保密。这是她用来为 Beth 加密数据和从她那里解密数据的密钥。类似地,Alice 将使用她的私钥为任何与她分享其公钥的人加密数据。

3

Beth 像 Alice 一样独立执行相同的操作,并分享她自己的公钥。

一旦 Alice 和 Beth 分享了他们的公钥,他们就可以自由地进行私密通信。sodium 中的cryptobox函数族利用这些非对称密钥计算可以用于机密通信的对称密钥。这个对称密钥不会直接暴露给任何一方,但允许双方轻松地进行通信。

请注意,Alice 为 Beth 加密的任何内容只能被 Beth 解密。即使是 Alice 也无法解密这些消息,因为她没有 Beth 的私钥!示例 9-10 说明了 Alice 如何利用她自己的私钥和 Beth 公开的公钥加密简单消息。

示例 9-10. 非对称加密
$message = 'Follow the white rabbit';
$nonce = random_bytes(SODIUM_CRYPTO_BOX_NONCEBYTES);
$encryptionKey = sodium_crypto_box_keypair_from_secretkey_and_publickey(
    $alicePrivate,
    $bethPublic
); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$ciphertext = sodium_crypto_box($message, $nonce, $encryptionKey); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$toBeth = bin2hex($nonce . $ciphertext); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

此处使用的加密密钥实际上是由 Alice 和 Beth 各自密钥对的信息组成的。

2

与对称加密类似,您可以利用一次性密码本引入加密输出的随机性。

3

您将随机 nonce 和加密的密文串联起来,以便 Alice 可以同时将两者发送给 Beth。

涉及的关键对是椭圆曲线上的点,具体来说是 Curve25519。sodium 用于非对称加密的初始操作是两个这些点之间的密钥交换,以定义一个固定但保密的数字。这个操作使用X25519 密钥交换算法,并基于 Alice 的私钥和 Beth 的公钥生成一个数字。

然后将该数字用作 XSalsa20 流密码(与对称加密使用的相同)的密钥,以加密消息。类似于对称加密中讨论的 secret box 函数族,cryptobox 将利用 Poly1305 消息认证标签保护消息,以防篡改或损坏。

在这一点上,Beth 利用她自己的私钥、Alice 的公钥和消息的 nonce,可以独立地重现所有这些步骤来解密消息。她执行类似的 X25519 密钥交换以推导相同的共享密钥,然后用于解密。

幸运的是,sodium 为我们抽象了密钥交换和推导,使得非对称解密相对简单。参见 Example 9-11。

Example 9-11. 非对称解密
$fromAlice = hex2bin($toBeth);
$nonce = substr($fromAlice, 0, SODIUM_CRYPTO_BOX_NONCEBYTES); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$ciphertext = substr($fromAlice, SODIUM_CRYPTO_BOX_NONCEBYTES);

$decryptionKey = sodium_crypto_box_keypair_from_secretkey_and_publickey(
    $bethPrivate,
    $alicePublic
); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$decrypted = sodium_crypto_box_open($ciphertext, $nonce, $decryptionKey); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
if ($decrypted === false) { ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    echo 'Error decrypting message!' . PHP_EOL;
} else {
    echo $decrypted . PHP_EOL;
}

1

类似于 secretbox 操作,你首先从 Alice 提供的十六进制编码的负载中提取 nonce 和密文。

2

Beth 使用她自己的私钥以及 Alice 的公钥来创建一个适合解密消息的密钥对。

3

密钥交换和解密操作被简化为一个简单的“打开”接口来读取消息。

4

与对称加密一样,sodium 的非对称接口在解密和返回明文之前会验证消息的 Poly1305 认证标签。

不管是对称还是非对称加密机制,sodium 的功能接口都得到了很好的支持和清晰的抽象。这有助于避免在实现旧的机制(如 AES 或 RSA)时常见的错误。Libsodium(支持 sodium 扩展的 C 级库)在其他语言中也得到了广泛支持,提供了 PHP、Ruby、Python、JavaScript 甚至低级语言如 C 和 Go 之间的稳固互操作性。

参见

关于 sodium_​crypto_​secretbox()sodium_​crypto_​secret⁠box_​open()sodium_​crypto_​box()sodium_crypto_box_open() 的文档。

9.5 将加密数据存储在文件中

问题

你希望加密(或解密)一个太大而无法装入内存的文件。

解决方案

使用 sodium 提供的 push 流接口,逐块加密文件,如 Example 9-12 中所示。

Example 9-12. Sodium 流加密
define('CHUNK_SIZE', 4096);

$key = hex2bin('67794ec75c56ba386f944634203d4e86' .
               '37e43c97857e3fa482bb9dfec1e44e70');

[$state, $header] = sodium_crypto_secretstream_xchacha20poly1305_init_push($key); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$input = fopen('plaintext.txt', 'rb'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$output = fopen('encrypted.txt', 'wb');

fwrite($output, $header); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$fileSize = fstat($input)['size']; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

for ($i = 0; $i < $fileSize; $i += (CHUNK_SIZE - 17)) { ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
    $plain = fread($input, (CHUNK_SIZE - 17));
    $cipher = sodium_crypto_secretstream_xchacha20poly1305_push($state, $plain); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

    fwrite($output, $cipher);
}

sodium_memzero($state); ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)

fclose($input);![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)
fclose($output);

1

初始化流操作会产生两个值——一个头部和流的当前状态。头部本身包含一个随机 nonce,并且在解密任何使用该流加密的内容时是必需的。

2

将输入和输出文件都作为二进制流打开。在处理文件时,唯一不使用十六进制或 Base64 编码来封装加密输出的情况之一是。

3

要确保您可以解密文件,首先存储固定长度的头部以供稍后检索。

4

在开始迭代文件字节块之前,您需要确定输入文件的实际大小。

5

与其他钠操作一样,流加密密码建立在 Poly1305 认证标签之上(长度为 17 字节)。因此,您读取比标准的 4,096 字节块少 17 字节,因此输出将是写入文件的总计 4,081 字节。

6

钠 API 加密明文并自动更新状态变量($state通过引用传递并在原地更新)。

7

加密完成后,显式清空状态变量的内存。PHP 的垃圾回收器会清理引用,但您需要确保系统中的其他编码错误不会无意中泄漏此值。

8

最后,由于加密完成,关闭文件句柄。

要解密文件,请使用钠的pull流接口,如示例 9-13 所示。

示例 9-13. 钠流解密
define('CHUNK_SIZE', 4096);

$key = hex2bin('67794ec75c56ba386f944634203d4e86' .
               '37e43c97857e3fa482bb9dfec1e44e70');

$input = fopen('encrypted.txt', 'rb');
$output = fopen('decrypted.txt', 'wb');

$header = fread($input, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$state = sodium_crypto_secretstream_xchacha20poly1305_init_pull($header, $key); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$fileSize = fstat($input)['size'];
try {
    for (
        $i = SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES;
        $i < $fileSize;
        $i += CHUNK_SIZE
    ) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        $cipher = fread($input, CHUNK_SIZE);

        [$plain, ] = sodium_crypto_secretstream_xchacha20poly1305_pull(
            $state,
            $cipher
        ); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

        if ($plain === false) { ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
            throw new Exception('Error decrypting file!');
        }
        fwrite($output, $plain);
    }
} finally {
    sodium_memzero($state); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

    fclose($input);
    fclose($output);
}

1

在开始解密之前,您必须显式从您首次加密的文件中检索头部值。

2

拥有加密头部和密钥后,您可以按需初始化流状态。

3

请记住文件以头部为前缀,因此您需要跳过这些字节,然后每次提取 4,096 字节的块。这将包括 4,079 字节的密文和 17 字节的认证标签。

4

实际的流解密操作返回明文的元组和一个可选的状态标签(例如,用于标识需要旋转密钥的情况)。

5

如果加密消息上的认证标签未能验证通过,此函数将返回false表示认证失败。如果发生这种情况,请立即停止解密。

6

操作完成后,应该清零存储流状态的内存,并关闭文件句柄。

讨论

Sodium 暴露给 PHP 的流密码接口并不是实际的流。⁵ 具体来说,它们是像块密码一样工作的流密码,具有内部计数器。XChaCha20 是sodium_crypto_secret⁠stream_​xcha⁠cha​20​poly1305_*()函数族使用的密码,用于在此示例中将数据推送到加密流中并从中拉取数据。在 PHP 中,实现显式地将长消息(文件)分成一系列相关消息。每个消息都使用底层密码进行加密,并以特定顺序单独标记。

这些消息不能截断、移除、重新排序或以任何方式操纵,否则解密操作会检测到篡改。此流的一部分可以加密的消息总数没有实际限制,这意味着通过解决方案示例传递的文件大小没有限制。

解决方案使用 4,096 字节(4 KB)的块大小,但其他示例可以使用 1,024、8,096 或任何其他字节数。这里唯一的限制是 PHP 可用的内存量——在加密和解密过程中迭代较小的文件块将使用更少的内存。示例 9-12 展示了sodium_​crypto_​secretstream_​xchacha20​poly1305_​push()如何一次加密一个数据块,通过加密算法“推送”该数据,并更新算法的内部状态。配对的sodium_​crypto_​secretstream_​xcha⁠cha20poly1305_​pull()在反向操作中做同样的事情,从流中拉取对应的明文并更新算法的状态。

另一种查看此操作的方法是使用低级原语sodium_​crypto_​stream_xchacha20_xor()函数。此函数直接利用 XChaCha20 加密算法,根据给定的密钥和随机 nonce 生成一系列似乎随机的字节流。然后,它在该字节流和给定消息之间执行异或操作,以产生一个密文。⁶ 示例 9-14 展示了此函数可能使用的一种方式——在数据库中加密电话号码。

示例 9-14。用于数据保护的简单流加密
function savePhoneNumber(int $userId, string $phone): void
{
    $db = getDatabase();

    $statement = $db->prepare(
        'INSERT INTO phones (user, number, nonce) VALUES (?, ?, ?)';
    );

    $key = hex2bin(getenv('ENCRYPTION_KEY'));
    $nonce = random_bytes(SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES);

    $encrypted = sodium_crypto_stream_xchacha20_xor($phone, $nonce, $key);

    $statement->execute([$userId, bin2hex($encrypted), bin2hex($nonce)]);
}

使用这种方式的加密流的优势在于密文的长度与明文完全匹配。然而,这也意味着没有可用的认证标签(这意味着密文可能会被第三方损坏或篡改,从而危及任何解密后的可靠性)。

因此,示例 9-14 可能不会直接使用。然而,它确实展示了更加冗长的 sodium_​crypto_​secretstream_​xcha⁠cha20poly1305_push() 在幕后如何运作。这两个函数使用相同的算法,但是“秘密流”变体生成自己的随机数并跟踪其内部状态以重复使用(用于加密多个数据块)。在使用更简单的 XOR 版本时,您需要手动管理该状态并重复调用!

参见

有关 sodium_crypto_secretstream_xchacha20poly1305_init_​push()sodium_​crypto_​secretstream_​xcha⁠cha20poly1305_​init_​pull()sodium_​crypto_​stream_​xchacha20_​xor() 的文档。

9.6 对发送到另一个应用程序的消息进行加密签名

问题

您希望在将消息或数据片段发送到另一个应用程序之前对其进行签名,以便另一个应用程序可以验证您对数据的签名。

解决方案

使用 sodium_crypto_sign() 将加密签名附加到明文消息,如示例 9-15 所示。

示例 9-15. 消息上的密码签名
$signSeed = hex2bin('eb656c282f46b45a814fcc887977675d' .
                    'c627a5b1507ae2a68faecee147b77621'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$signKeys = sodium_crypto_sign_seed_keypair($signSeed);

$signSecret = sodium_crypto_sign_secretkey($signKeys);
$signPublic = sodium_crypto_sign_publickey($signKeys);

$message = 'Hello world!';
$signed = sodium_crypto_sign($message, $signSecret);

1

在实践中,您的签名种子应该是由您保密的随机值。它也可以是从已知密码派生的安全哈希值。

讨论

密码签名是验证特定消息(或数据字符串)来源于给定源的一种方法。只要用于签署消息的私钥保持秘密,任何拥有公开已知密钥访问权限的人都可以验证信息来自密钥所有者。

同样,只有密钥的保管人可以对数据进行签名。这有助于验证保管人对消息的签名。它还构成不可否认性的基础:密钥所有者无法声称其他人使用了他们的密钥,否则将使其密钥(及其创建的任何签名)无效。

在解决方案示例中,根据秘密密钥和消息内容计算签名。然后,将签名的字节前置到消息本身,并将这两个元素一起传递给希望验证签名的任何一方。

也可以生成一个独立的签名,有效地生成签名的原始字节而不将其连接到消息中。如果消息和签名打算独立发送到第三方验证者(例如,在 API 请求中作为不同的元素),这将非常有用。

注意

虽然原始字节非常适合存储在磁盘或数据库中的信息,但在远程 API 中可能会引发问题。当将它们发送到远程方时,对整个负载(签名和消息)进行 Base64 编码是有意义的。否则,当同时发送两个组件时,你可能希望单独编码签名(例如,十六进制编码)。

不要像 Solution 示例中使用 sodium_crypto_sign() 那样,可以使用 sodium_crypto_sign_detached(),如示例 9-16。

示例 9-16. 创建分离的消息签名
$signSeed = hex2bin('eb656c282f46b45a814fcc887977675d' .
                    'c627a5b1507ae2a68faecee147b77621');
$signKeys = sodium_crypto_sign_seed_keypair($signSeed);

$signSecret = sodium_crypto_sign_secretkey($signKeys);
$signPublic = sodium_crypto_sign_publickey($signKeys);

$message = 'Hello world!';
$signature = sodium_crypto_sign_detached($message, $signSecret);

签名总是 64 字节长,无论它们是否附加到它们签名的明文上。

另请参阅

sodium_crypto_sign() 的文档。

9.7 验证加密签名

问题

你想验证由第三方发送给你的数据上的签名。

解决方案

使用 sodium_crypto_sign_open() 来验证消息上的签名,如示例 9-17 所示。

示例 9-17. 加密签名验证
$signPublic = hex2bin('d58c47ddb986dcb2632aa5395e8962d3' .
                      'e636ee236b38a8dc880e409c19374a5f');

$message = sodium_crypto_sign_open($signed, $signPublic); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

if ($message === false) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    throw new Exception('Invalid signature on message!');
}

1

$signed 中的数据是一个连接的原始签名和明文消息,就像 sodium_crypto_sign() 的返回值一样。

2

如果签名无效,函数会返回 false 作为错误。如果签名有效,函数会返回明文消息。

讨论

当涉及到 sodium_crypto_sign() 的字面返回时,签名验证非常简单。只需将数据和签名方的公钥传递给 sodium_​crypto_​sign_open(),你将得到一个布尔错误或者原始明文。

如果你在处理 web API,消息和签名很可能是分开传递给你的(例如,如果有人使用 sodium_​crypto_​sign_​detached())。在这种情况下,你需要在将它们传递到 sodium_​crypto_​sign_​open() 之前将签名和消息连接在一起,如示例 9-18。

示例 9-18. 验证分离的签名
$signPublic = hex2bin('d58c47ddb986dcb2632aa5395e8962d3' .
                      'e636ee236b38a8dc880e409c19374a5f');

$signature = hex2bin($_POST['signature']);
$payload = $signature . $_POST['message'];

$message = sodium_crypto_sign_open($payload, $signPublic);

if ($message === false) {
    throw new Exception('Invalid signature on message!');
}

另请参阅

sodium_crypto_sign_open() 的文档。

¹ 2022 年的 OpenJDK Psychic Signatures bug 展示了加密认证中的错误如何使应用程序甚至整个语言实现容易受到恶意行为的威胁。这个 bug 是由于实现错误造成的,进一步强调在使用加密系统时依赖于可靠、经过充分测试的基本原语的重要性。

² 欲知更多关于本地扩展的内容,请参阅第十五章。

³ PHP 核心中添加此扩展的详细过程,请参考原始 RFC

⁴ 通过 Composer 加载 PHP 包的详细讨论,请看第 15.3 节。

⁵ PHP 流,在第十一章中有详细介绍,展示了在不耗尽系统可用内存的情况下处理大数据块的有效方法。

⁶ 操作符及特别是异或运算的更多内容,请查阅第二章。

第十章:文件处理

Unix 和 Linux 周围最常见的设计哲学之一是“一切皆文件”。这意味着,无论您正在交互的资源是什么,操作系统都会将其视为本地磁盘上的文件。这包括对其他系统的远程请求以及对正在运行的进程输出的处理。

PHP 将请求、进程和资源类似地处理,但语言不认为一切皆文件,而是将一切视为流资源。第十一章详细介绍了流,但对于本章重要的一点是 PHP 在内存中如何处理流。

在访问文件时,PHP 不一定会将文件的所有数据读入内存。相反,它会在内存中创建一个引用文件位置的 resource,并有选择地从文件中缓冲字节。然后 PHP 直接访问或操作这些缓冲的字节作为流。然而,这章节的配方中并不需要了解流的基础知识。

PHP 的文件方法——fopen()file_get_contents() 等——都在底层使用 file:// 流包装器。但请记住,如果 PHP 中的一切都是流,您同样可以轻松地使用其他流协议,包括 php://http://

Windows 与 Unix

PHP 可供在 Windows 和 Unix 风格的操作系统(包括 Linux 和 macOS)上使用。重要的是要理解,Windows 背后的底层文件系统与 Unix 风格系统非常不同。Windows 并不认为“一切皆文件”,有时对文件和目录名称的大小写敏感性会以意想不到的方式起作用。

如您将在配方 10.6 中看到的那样,操作系统范式之间的差异也会导致函数行为上的细微差异。具体来说,如果您的程序在 Windows 上运行,由于底层操作系统调用的差异,文件锁定将有所不同。

接下来的示例涵盖了在 PHP 中可能遇到的最常见的文件系统操作,从打开和操作文件到阻止其他进程触及它们。

10.1 创建或打开本地文件

问题

您需要在本地文件系统上打开文件进行读取或写入。

解决方案

使用 fopen() 打开文件并返回一个资源引用,以供进一步使用:

$fp = fopen('document.txt', 'r');

讨论

在 PHP 内部,打开的文件被表示为一个流。您可以根据当前文件指针的位置从流中读取数据或向流中写入数据。在解决方案示例中,您已经打开了一个只读流(尝试向该流写入将失败),并将指针定位在文件的开头。

示例 10-1 展示了如何从文件中读取任意数量的字节,然后通过将其引用传递给 fclose() 函数关闭流。

示例 10-1. 从缓冲区中读取字节
while (($buffer = fgets($fp, 4096)) !== false) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    echo $buffer; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
}

fclose($fp); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

fgets() 函数从指定的资源中读取一行,停止条件为遇到换行符或者已从底层流中读取了指定的字节数(4,096)。如果没有数据可读,则函数返回 false

2

一旦将数据缓冲到变量中,您可以随意处理它。在这种情况下,将该单行打印到控制台。

3

使用文件内容后,应显式关闭和清理您创建的资源。

除了读取文件外,fopen() 还允许任意写入、文件追加、覆盖或截断。每个操作由作为第二参数传递的模式确定——解决方案示例传递 r 以指示只读模式。附加模式在 表 10-1 中描述。

表 10-1. fopen() 可用的文件模式

Mode 描述
r 仅用于读取;将文件指针放在文件开头。
w 仅用于写入;将文件指针放在文件开头并截断文件为 0 长度。如果文件不存在,则尝试创建。
a 仅用于写入;将文件指针放在文件末尾。如果文件不存在,则尝试创建。在此模式下,fseek() 不起作用,写入总是追加的。
x 创建并仅用于写入;将文件指针放在文件开头。如果文件已存在,则 fopen() 调用将失败并返回 false,生成 E_WARNING 级别的错误。如果文件不存在,则尝试创建。
c 仅用于写入;打开文件。如果文件不存在,则创建。如果存在,则不截断(与 w 相反),并且调用此函数不会失败(与 x 相反)。文件指针放在文件开头。
e 在打开的文件描述符上设置执行时关闭标志。

对于文档 表 10-1 中记录的所有文件模式 除了 e,您可以在模式末尾添加字面上的 + 号以打开文件以进行读取 写入,而不是单一操作。

fopen() 函数不仅适用于本地文件。默认情况下,该函数假定您想要使用本地文件系统,因此不需要显式指定 file:// 协议处理程序。但是,您也可以轻松地通过使用 http://ftp:// 处理程序引用远程文件,如下所示:

$fp = fopen('https://eamann.com/', 'r');
注意

尽管可以包含远程文件,但在许多情况下可能存在风险,因为您可能无法始终控制远程文件系统返回的内容。通常建议通过切换系统配置中的 allow_url_include 来禁用远程文件访问。请参阅PHP 运行时配置文档以了解如何实施此更改。

可选的第三个参数允许 fopen() 在需要时在系统包含路径中搜索文件。默认情况下,PHP 仅在本地目录中搜索(或在指定时使用绝对路径)。从系统包含路径加载文件有助于代码重用,因为您可以指定单独的类或配置文件,而无需在整个项目中复制它们。

参见

PHP 文件系统的文档,特别是 fopen()

10.2 读取文件到字符串

问题

您希望将整个文件读入变量,以便在应用程序的其他位置使用。

解决方案

使用 file_get_contents() 如下:

$config = file_get_contents('config.json');

if ($config !== false) {
    $parsed = json_decode($config);

    // ...
}

讨论

file_get_contents() 函数打开一个文件进行读取,将该文件的所有数据读入变量,然后关闭文件,允许您将该数据作为字符串使用。这在功能上等同于手动使用 fread() 将文件读入字符串,如示例 10-2 所示。

示例 10-2. 使用 fread() 手动实现 file_get_contents()
function fileGetContents(string $filename): string|false
{
    $buffer = '';
    $fp = fopen($filename, 'r');

    try {
        while (!feof($fp)) {
            $buffer .= fread($fp, 4096);
        }
    } catch(Exception $e) {
        $buffer = false;
    } finally {
        fclose($fp);
    }

    return $buffer;
}

$config = fileGetContents('config.json');

尽管可以手动将文件读入内存,如示例 10-2 所示,但专注于编写简单程序并利用语言提供的函数处理复杂操作是一个更好的主意。 file_get_contents() 函数是用 C 语言实现的,为您的应用程序提供了高性能。它是二进制安全的,并利用操作系统提供的内存映射功能来实现最佳性能。

fread() 一样,file_get_contents() 可以将本地和远程文件读入内存。如果将可选的第二个参数设置为 true,它还可以在系统包含路径中搜索文件。

fread() 的并行操作 fwrite() 一样,还有一个称为 file_put_contents() 的自动写入等效函数。此函数封装了打开文件并用变量中的字符串数据覆盖其内容的复杂性。以下示例演示了如何将对象编码为 JSON 并写入静态文件:

$config = new Config(/** ... **/);
$serialized = json_encode($config);

file_put_contents('config.json', $serialized);

参见

file_​get_​contents()file_​put_​con⁠tents() 的文档。

10.3 读取文件的特定片段

问题

您希望从文件中的特定位置读取一组特定字节。

解决方案

使用 fopen() 创建资源,使用 fseek() 在文件中重新定位指针,并使用 fread() 从该位置读取数据如下:

$fp = fopen('document.txt', 'r');
fseek($fp, 32, SEEK_SET);

$data = fread($fp, 32);

讨论

默认情况下,以读取模式打开的fopen()将文件作为资源打开,并将其指针放在文件开头。当你开始从文件中读取字节时,指针将向前移动,直到达到文件末尾。你可以使用fseek()来将指针设置到资源内的任意位置,默认情况是文件的开头。

在解决方案示例中的第三个参数——SEEK_SET——告诉 PHP 在何处添加偏移量。你有三个选项:

  • SEEK_SET(默认)从文件开头设置指针。

  • SEEK_CUR 将偏移量添加到当前指针位置。

  • SEEK_END 在文件末尾添加偏移量。这对于通过将负偏移量设置为第二个参数来读取文件中的最后几个字节非常有用。

假设你想从 PHP 内部读取长日志文件的最后几个字节。你可以像解决方案示例中读取任意字节一样进行,但是使用负偏移量,如下所示:

$fp = fopen('log.txt', 'r');
fseek($fp, -4096, SEEK_END);

echo fread($fp, 4096);

fclose($fp);

注意,即使在前面片段中的日志文件少于 4,096 字节长,PHP 也不会读取超出文件开头的部分。解释器将会把指针放在文件开头并从那个位置开始读取字节。同样地,无论你在调用fread()时指定多少字节,都不能超过文件末尾。

另请参阅

Recipe 10.1 了解更多关于fopen(),以及关于fread()fseek()的文档。

10.4 修改文件

问题

你想要修改文件的特定部分。

解决方案

使用fopen()打开文件进行读取和写入,然后使用fseek()将指针移动到要更新的位置并覆盖从该位置开始的一定数量字节。例如:

$fp = fopen('resume.txt', 'r+');
fseek($fp, 32);

fwrite($fp, 'New data', 8);

fclose($fp);

讨论

如 Recipe 10.3 中所示,fseek()函数被利用来将指针移动到文件中的任意位置。然后,使用fwrite()在关闭资源之前将特定的一组字节写入文件。

传递给fwrite()的第三个参数告诉 PHP 要写入多少字节。默认情况下,系统将写入第二个参数传递的所有数据,但你可以通过指定字节计数来限制写入的数据量。在解决方案示例中,写入长度设置为数据长度是多余的。这种功能的更现实的例子可能如下所示。

$contents = 'the quick brown fox jumped over the lazy dog';
fwrite($fp, $contents, 9);

注意,Solution 示例中的解决方案会在典型的读取模式上添加一个加号;这会同时打开文件以进行读取写入。以其他模式打开文件会导致非常不同的行为:

  • w(写入模式),无论是否具有读取能力,都会在你对文件进行其他任何操作之前截断文件!

  • a(追加模式),无论是否具有读取能力,都会将文件指针强制移到文件末尾。对 fseek() 的调用将按预期移动文件指针,并且您的新数据将始终追加到文件中。

参见

配方 10.3 获取关于在 PHP 中使用文件进行随机 I/O 的更多信息。

10.5 同时向多个文件写入

问题

您希望同时将数据写入多个文件。例如,您希望同时将数据写入本地文件系统和控制台。

解决方案

使用 fopen() 打开多个资源引用并在循环中写入它们:

$fps = [
    fopen('data.txt', 'w'),
    fopen('php://stdout', 'w')
];

foreach ($fps as $fp) {
    fwrite($fp, 'The wheels on the bus go round and round.');
}

讨论

PHP 通常是一个单线程系统,必须逐个执行操作。¹ 虽然解决方案示例将为两个文件引用产生输出,但它将首先写入一个文件,然后再写入另一个文件。实际上,这足够快以被接受,但并非真正同时进行。

即使存在这种限制,知道您可以轻松地将相同的数据写入多个文件,使得同时处理多个潜在输出变得相对简单。与在解决方案示例中制定有限文件数量的过程化方法不同,您甚至可以将此类操作抽象成一个类,如 示例 10-3 所示:

示例 10-3. 用于抽象多个文件操作的简单类
class MultiFile
{
    private array $handles = [];

    public function open(
        string $filename,
        string $mode = 'w',
        bool $use_include_path = false,
        $context = null
        ): mixed
    {
        $fp = fopen($filename, $mode, $use_include_path, $context);

        if ($fp !== false) {
            $this->handles[] = $fp;
        }

        return $fp;
    }

    public function write(string $data, ?int $length = null): int|false
    {
        $success = true;
        $bytes = 0;

        foreach($this->handles as $fp) {
            $out = fwrite($fp, $data, $length);
            if ($out === false) {
                $success = false;
            } else {
                $bytes = $out;
            }
        }

        return $success ? $bytes : false;
    }

    public function close(): bool
    {
        $return = true;

        foreach ($this->handles as $fp) {
            $return = $return && fclose($fp);
        }

        return $return;
    }
}

由 示例 10-3 定义的类允许您轻松地将写操作绑定到多个文件句柄,并在需要时进行清理。与逐个打开每个文件并手动迭代它们不同,您只需实例化该类,添加您的文件,然后进行操作。例如:

$writer = new MultiFile();
$writer->open('data.txt');
$writer->open('php://stdout');

$writer->write("Row, row, row your boat\nGently down the stream.");

$writer->close();

PHP 对资源指针的内部处理非常高效,并使您能够使用最小的开销写入任意数量的文件或流。像 示例 10-3 这样的抽象类似地使您能够专注于应用程序的业务逻辑,同时 PHP 为您处理资源句柄(及相关的内存分配)。

参见

关于 PHP 的 stdout 的文档。

10.6 锁定文件以防止其他进程访问或修改

问题

您希望在运行脚本时防止另一个 PHP 进程操作文件。

解决方案

使用 flock() 锁定文件如下:

$fp = fopen('myfile.txt', 'r');

if (flock($fp, LOCK_EX)) {
    // ... Do whatever reading you need

    flock($fp, LOCK_UN);
} else {
    echo 'Could not lock file!';
    exit(1);
}

讨论

通常情况下,您需要打开一个文件来读取其数据或向其写入内容,但要确保在您操作文件时没有其他脚本会操纵该文件。最安全的做法是显式锁定文件。

警告

在 Windows 上,PHP 利用操作系统本身强制执行的强制锁定。一旦文件被锁定,其他进程就无法打开该文件。在基于 Unix 的系统上(包括 Linux 和 macOS),PHP 使用咨询锁定。在这种模式下,操作系统可以选择忽略不同进程之间的锁。虽然多个 PHP 脚本通常会尊重锁定,但其他进程可能完全忽略它。

显式文件锁定会阻止其他进程读取或写入同一文件,具体取决于锁的类型。PHP 支持两种锁定方式:共享锁(LOCK_SH)仍然允许读取,独占锁(LOCK_EX)则完全阻止其他进程访问文件。

如果在一台机器上运行解决方案示例代码两次(在解锁文件之前调用类似sleep()的长时间阻塞操作),第二个进程会暂停并等待锁释放后再执行。更具体的示例见示例 10-4。

示例 10-4. 长时间运行的文件锁演示
$fp = fopen('myfile.txt', 'r');

echo 'Getting a lock ...' . PHP_EOL;
if (flock($fp, LOCK_EX)) {
    echo 'Sleeping ...' . PHP_EOL;
    for($i = 0; $i < 3; $i++) {
        sleep(10);
        echo '  Zzz ...' . PHP_EOL;
    }

    echo 'Unlocking ...' . PHP_EOL;
    flock($fp, LOCK_UN);
} else {
    echo 'Could not lock file!';
    exit(1);
}

在两个分开的终端中并排运行上述程序,可以说明锁定的工作原理,如图 10-1 所示。第一次执行将获取文件锁,并继续按预期操作。第二次将等待直到锁可用,并在获取锁之后继续操作。

两个进程无法在单个文件上获取相同的锁。

图 10-1. 两个进程无法在单个文件上获取相同的锁

参见

flock()文档的文档。

¹ 第十七章详细介绍了并行和异步操作,以解释如何打破单线程范式。

第十一章:流

PHP中的代表了可以按线性、连续方式写入或读取的数据资源的常见接口。在内部,每个流都由称为的对象集合表示。每个桶代表来自底层流的一块数据,这类似于数字重现了传统的水桶链,就像图 11-1 中所示的那样。

水桶链将数据从一个传递到另一个

图 11-1. 水桶链将数据从一个传递到另一个

水桶链经常用于将水从河流、溪流、湖泊或井中运输到火源。当无法使用软管移动水时,人们排成队,依次传递水桶以灭火。一个人在水源处装满水桶,然后将水桶传递给队列中的下一个人。排队的人不动,但水桶逐个传递,直到最后一个人能将水泼在火上。这个过程将继续,直到火被扑灭或水源耗尽。

尽管你没有使用 PHP 来灭火,但流的内部结构与水桶链有些相似,因为数据被逐块(桶)地通过处理它的代码组件传递。

生成器也类似于这种模式。¹ 与一次性将整个数据集加载到内存中不同,生成器提供了一种将其缩减为较小块并逐个数据块操作的方式。这使得 PHP 应用程序能够处理连续数据,而不是离散数据点的集合或数组。

包装器和协议

在 PHP 中,流是使用包装器实现的,这些包装器在系统中注册以操作特定协议。你可能会与最常见的包装器互动,例如文件访问或 HTTP URL,分别注册为file://http://。每个包装器针对不同类型的数据操作,但它们都支持相同的基本功能。表 11-1 列出了 PHP 本地公开的包装器和协议。

表 11-1. PHP 本地流包装器和协议

协议 描述
file:// 访问本地文件系统
http:// 通过 HTTP(S)访问远程 URL
ftp:// 通过 FTP(S)访问远程文件系统
php:// 访问各种本地 I/O 流(内存、stdinstdout等)
zlib:// 压缩
data:// 原始数据(根据RFC 2397
glob:// 查找匹配模式的路径名
phar:// 操作 PHP 归档
ssh2:// 通过安全外壳连接
rar:// RAR 压缩
ogg:// 音频流

每个包装器产生一个 stream 资源,使你能够以线性方式读取或写入数据,并额外具有“寻找”到流内任意位置的能力。例如,file:// 流允许对磁盘上的字节进行任意访问。类似地,php:// 协议提供了对本地系统内存中各种字节流的读取/写入访问权限。

过滤器

PHP 的流过滤器提供了一种构造,允许在读取或写入期间动态操作流中的字节。一个简单的例子是自动将字符串中的每个字符转换为大写或小写。这可以通过创建一个自定义类,该类扩展了 php_user_filter 类,并将该类注册为编译器要使用的过滤器来实现,如 示例 11-1 所示。

示例 11-1. 用户定义的过滤器
class StringFilter extends php_user_filter
{
    private string $mode;

    public function filter($in, $out, &$consumed, bool $closing): int
    {
        while ($bucket = stream_bucket_make_writeable($in)) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
            switch($this->mode) {
                case 'lower':
                    $bucket->data = strtolower($bucket->data);
                    break;
                case 'upper':
                    $bucket->data = strtoupper($bucket->data);
                    break;
            }

            $consumed += $bucket->datalen; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
            stream_bucket_append($out, $bucket); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        }

        return PSFS_PASS_ON; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    }

    public function onCreate(): bool
    {
        switch($this->filtername) { ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
            case 'str.tolower':
                $this->mode = 'lower';
                return true;
            case 'str.toupper':
                $this->mode = 'upper';
                return true;
            default:
                return false;
        }
    }
}

stream_filter_register('str.*', 'StringFilter'); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

$fp = fopen('document.txt', 'w');
stream_filter_append($fp, 'str.toupper'); ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)

fwrite($fp, 'Hello' . PHP_EOL); ![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)
fwrite($fp, 'World' . PHP_EOL);

fclose($fp);

echo file_get_contents('document.txt'); ![9](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/9.png)

1

传递给过滤器的 $in 资源必须首先被设置为可写状态,然后才能进行任何操作。

2

在使用数据时,请务必更新 $consumed 输出变量,以便 PHP 能够跟踪你操作了多少字节。

3

$out 资源最初为空,你需要向其中写入数据块,以便其他过滤器(或仅 PHP 本身)继续在流上操作。

4

PSFS_PASS_ON 标志告诉 PHP 过滤器成功,并且数据在 $out 定义的资源中可用。

5

这个特定过滤器可以作用于任何 str. 标志,但故意只读取两个过滤器名称,用于将文本转换为大写或小写。通过打开定义的过滤器名称开关,你可以拦截和过滤你想要的操作,同时允许其他过滤器定义它们自己的 str. 函数。

6

定义过滤器是不够的;你必须显式注册过滤器,这样 PHP 才知道在过滤流时实例化哪个类。

7

一旦定义并注册了过滤器,你必须将自定义过滤器附加(或前置)到当前流资源的过滤器列表中。

8

带有附加过滤器后,写入流的任何数据都将通过过滤器。

9

再次打开文件表明你的输入数据确实被转换为大写。请注意,file_get_contents() 会将整个文件读入内存,而不是作为流进行操作。

在内部,任何自定义过滤器的 filter() 方法必须返回三个标志中的一个:

PSFS_PASS_ON

表明处理成功完成,并且输出桶列($out)包含准备传递给下一个过滤器的数据

PSFS_FEED_ME

演示过滤器成功完成,但输出旅行队中没有可用数据。您必须从基础流或堆栈中的前一个过滤器提供更多数据才能获得任何输出。

PSFS_ERR_FATAL

指示过滤器遇到错误

onCreate() 方法公开了底层 php_user_filter 类的三个内部变量,就像它们是子类自身的属性一样:

::filtername

如在 stream_filter_append()stream_​fil⁠ter_​pre⁠pend() 中指定的过滤器名称

::params

在将其附加或预置到过滤器堆栈时,传递给过滤器的额外参数

::stream

正在被过滤的实际流资源

流过滤器是操作数据在系统中流入或流出的强大方式。以下示例涵盖了 PHP 中流的各种用法,包括流封装器和过滤器。

11.1 流数据到/从临时文件

问题

要在程序中的其他地方使用临时文件来存储数据。

解决方案

要存储数据,请像操作文件一样使用 php://temp 流:

$fp = fopen('php://temp', 'rw');

while (true) {
    // Get data from some source

    fputs($fp, $data);

    if ($endOfData) {
        break;
    }
}

要再次检索数据,请将流倒回到开始位置,然后按以下方式再次读取数据:

rewind($fp);

while (true) {
    $data = fgets($fp);

    if ($data === false) {
        break;
    }

    echo $data;
}

fclose($fp);

讨论

通常情况下,PHP 支持两种不同的临时数据流。解决方案示例利用了 php://temp 流,但同样可以使用 php://memory 来达到相同效果。对于完全适合内存的数据流,这两个包装器是可互换的。默认情况下,两者都将使用系统内存来存储流数据。然而,一旦流的数据超过应用程序可用内存的量,php://temp 将把数据路由到磁盘上的临时文件中。

在这两种情况下,写入流的数据被认为是短暂的。一旦关闭流,这些数据将不再可用。同样地,你不能创建指向相同数据的流资源。示例 11-2 说明了 PHP 在使用相同流封装器时,如何为流使用不同的临时文件。

示例 11-2. 临时流是唯一的
$fp = fopen('php://temp', 'rw');

fputs($fp, 'Hello world!'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

rewind($fp); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
echo fgets($fp) . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$fp2 = fopen('php://temp', 'rw'); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
fputs($fp2, 'Goodnight moon.'); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

rewind($fp); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)
rewind($fp2);

echo fgets($fp2) . PHP_EOL; ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)
echo fgets($fp) . PHP_EOL; ![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)

1

向临时流写入一行数据。

2

将流句柄倒回,以便可以从中重新读取数据。

3

从流中读取数据会在控制台上打印Hello world!

4

创建新的流句柄会创建一个完全新的流,尽管协议包装器相同。

5

向这个新流写入一些唯一的数据。

6

为了保险起见,将两个流都倒回。

7

首先打印第二个流来证明它是唯一的。这会在控制台上打印Goodnight moon

8

Hello world!重新打印到控制台以证明原始流仍按预期工作。

在任一情况下,临时流在您运行应用程序并且不明确希望将其持久存储到磁盘时非常有用。

参见

fopen()函数PHP I/O 流包装器的文档。

11.2 从 PHP 输入流中读取

问题

您希望从 PHP 中读取原始输入。

解决方案

利用php://stdin流来读取标准输入流(stdin,如下所示:

$stdin = fopen('php://stdin', 'r');

讨论

与任何其他应用程序一样,PHP 可以直接访问由命令和其他上游应用程序传递给它的输入。在控制台环境中,这可能是另一个命令,终端中的文字输入,或者是来自其他应用程序的数据管道。在 Web 上下文中,您可以使用php://input+来访问通过 Web 服务器传递的文字内容,并通过 PHP 应用程序前面的任何 Web 服务器访问。

注意

在命令行应用程序中,您还可以直接使用预定义的STDIN常量。 PHP 本地为您打开一个流,这意味着您根本不需要创建新的资源变量。

简单的命令行应用程序可以从输入中获取数据,处理该数据,然后将其存储到文件中。在食谱 9.5 中,您学习了如何使用 Libsodium 和对称密钥来加密和解密文件。假设您有一个加密密钥(以十六进制编码),该密钥作为环境变量公开,示例 11-3 中的程序将使用该密钥加密传入的任何数据并将其存储在输出文件中。

示例 11-3. 使用 Libsodium 加密stdin
if (empty($key = getenv('ENCRYPTION_KEY'))) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    throw new Exception('No encryption key provided!');
}

$key = hex2bin($key);
if (strlen($key) !== SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    throw new Exception('Invalid encryption key provided!');
}

$in = fopen('php://stdin', 'r'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$filename = sprintf('encrypted-%s.bin', uniqid()); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
$out = fopen($filename, 'w'); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

[$state, $header] = sodium_crypto_secretstream_xchacha20poly1305_init_push($key); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

fwrite($out, $header);

while (!feof($in)) {
    $text = fread($in, 8175);

    if (strlen($text) > 0) {
        $cipher = sodium_crypto_secretstream_xchacha20poly1305_push($state, $text);

        fwrite($out, $cipher);
    }
}

sodium_memzero($state);

fclose($in);
fclose($out);

echo sprintf('Wrote %s' . PHP_EOL, $filename);

1

由于您希望使用环境变量存储加密密钥,请首先检查该变量是否存在。

2

在使用加密前,还应对密钥的大小进行合理性检查。

3

在此示例中,直接从stdin读取字节。

4

使用动态命名的文件存储加密数据。请注意,在实践中,uniqid()使用时间戳,并且在高度使用的系统上可能会出现竞态条件和名称冲突。在真实世界的环境中,您将希望使用更可靠的随机源生成文件名。

5

输出可以返回到控制台,但由于此加密生成原始字节,将输出流到文件更安全。在这种情况下,文件名将根据系统时钟动态生成。

6

其余的加密步骤与食谱 9.5 相同。

前面的示例让您可以通过使用标准输入缓冲区将数据从文件直接传递给 PHP。这样的管道操作可能看起来像cat plaintext-file.txt | php encrypt.php

鉴于加密操作将生成一个文件,您可以通过类似的脚本反向操作,并类似地利用cat将原始二进制数据管道传回 PHP,如示例 11-4 所示。

示例 11-4. 使用 Libsodium 解密stdin
if (empty($key = getenv('ENCRYPTION_KEY'))) {
    throw new Exception('No encryption key provided!');
}

$key = hex2bin($key);
if (strlen($key) !== SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES) {
    throw new Exception('Invalid encryption key provided!');
}

$in = fopen('php://stdin', 'r');
$filename = sprintf('decrypted-%s.txt', uniqid());
$out = fopen($filename, 'w');

$header = fread($in, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES);
$state = sodium_crypto_secretstream_xchacha20poly1305_init_pull($header, $key);

try {
    while (!feof($in)) {
        $cipher = fread($in, 8192);

        [$plain, ] = sodium_crypto_secretstream_xchacha20poly1305_pull(
            $state,
            $cipher
        );

        if ($plain === false) {
            throw new Exception('Error decrypting file!');
        }

        fwrite($out, $plain);
    }
} finally {
    sodium_memzero($state);

    fclose($in);
    fclose($out);

    echo sprintf('Wrote %s' . PHP_EOL, $filename);
}

由于 PHP 的 I/O 流包装器,任意输入流与本地磁盘上的原生文件一样易于操作。

参见

PHP I/O 流包装器的文档请参考PHP I/O 流包装器

11.3 写入 PHP 输出流

问题

您希望直接输出数据。

解决方案

使用php://output将数据直接推送到标准输出(stdout)流如下:

$stdout = fopen('php://stdout', 'w');
fputs($stdout, 'Hello, world!');

讨论

PHP 向用户空间代码公开了三个标准 I/O 流——stdinstdoutstderr。默认情况下,您在应用程序中打印的任何内容都会发送到标准输出流(stdout),这使得以下两行代码在功能上是等效的:

fputs($stdout, 'Hello, world!');
echo 'Hello, world!';

许多开发人员学会使用echoprint语句作为调试应用程序的简单方法;在您的代码中添加指示器可以方便地确定编译器失败的确切位置或输出一个隐藏变量的值。然而,这并不是管理输出的唯一方法。stdout流是许多应用程序共有的,直接向其写入数据(而不是隐式的print语句)可以使您的应用程序集中在需要完成的任务上。

类似地,一旦您开始直接利用php://stdout向客户端打印输出,您就可以开始利用php://stderr流来发出关于错误的消息。这两个流在操作系统中处理方式不同,您可以用它们来区分有用消息和错误状态之间的消息。

注意

在命令行应用程序中,您还可以直接使用预定义的STDOUTSTDERR常量。PHP 本地为您打开这些流,这意味着您根本不需要创建新的资源变量。

示例 11-4 允许您从php://stdin读取加密数据,解密后将解密内容存储到文件中。一个更有用的示例将显示解密的数据直接发送到php://stdout(以及任何错误到php://stderr),如示例 11-5 所示。

示例 11-5. 将stdin解密为stdout
if (empty($key = getenv('ENCRYPTION_KEY'))) {
    throw new Exception('No encryption key provided!');
}

$key = hex2bin($key);
if (strlen($key) !== SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES) {
    throw new Exception('Invalid encryption key provided!');
}

$in = fopen('php://stdin', 'r');
$out = fopen('php://stdout', 'w'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$err = fopen('php://stderr', 'w'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$header = fread($in, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES);
$state = sodium_crypto_secretstream_xchacha20poly1305_init_pull($header, $key);

while (!feof($in)) {
    $cipher = fread($in, 8192);

    [$plain, ] = sodium_crypto_secretstream_xchacha20poly1305_pull(
        $state,
        $cipher
    );

    if ($plain === false) {
        fwrite($err, 'Error decrypting file!'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        exit(1);
    }

    fwrite($out, $plain);
}

sodium_memzero($state);

fclose($in);
fclose($out);
fclose($err);

1

您可以直接写入标准输出流,而不是创建一个中间文件。

2

在这期间,您也应该掌握标准错误流。

3

而不是触发异常,您可以直接写入错误流。

参见

关于 PHP I/O 流包装器 的文档。

11.4 从一个流读取并写入另一个流

问题

想要连接两个流,将一个流的字节传递给另一个流。

解决方案

使用stream_copy_to_stream()从一个流复制数据到另一个流,操作如下:

$source = fopen('document1.txt', 'r');
$dest = fopen('destination.txt', 'w');

stream_copy_to_stream($source, $destination);

讨论

PHP 中的流机制提供了处理相当大数据块的高效方式。通常,您可能会在 PHP 应用程序中使用太大而无法放入可用内存的文件。大多数文件可能会通过 Apache 或 NGINX 直接向用户公开并发送。另外,例如,您可能希望使用 PHP 编写的脚本保护大文件下载(如 zip 文件或视频),以验证用户的身份。

在 PHP 中可以实现这种情况,因为系统不需要将整个流保存在内存中,而是可以从一个流读取字节时立即将其写入另一个流。 示例 11-6 假设您的 PHP 应用程序直接验证用户并验证他们对特定文件的权限后,流式传输其内容。

示例 11-6. 通过链接流将大文件复制到stdout
if ($user->isAuthenticated()) {
    $in = fopen('largeZipFile.zip', 'r'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    $out = fopen('php://stdout', 'w');

    stream_copy_to_stream($in, $out); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    exit; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
}

1

打开流的操作仅仅是获取底层数据的句柄。系统尚未读取任何字节。

2

将一个流复制到另一个流将直接复制字节,而无需在内存中保留任一流的全部内容。请记住,流类似于一个桶链,因此在任何给定时间内只会在内存中保存必要字节的子集。

3

在复制流后始终使用exit;否则,您可能会错误地附加杂项字节。

类似地,当需要时,可以编程方式构建一个大流并将其复制到另一个流中。例如,某些 Web 应用程序可能需要在需要时编程方式构建大数据块(例如非常大的单页 Web 应用程序)。可以将这些大数据元素写入 PHP 的临时内存流,然后在需要时复制字节。 示例 11-7 具体说明了这样做的方式。

示例 11-7. 将临时流复制到stdout
$buffer = fopen('php://temp', 'w+'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
fwrite($buffer, '<html><head>');

// ... Several hundred fwrite()s later ... 
fwrite($buffer, '</body></html>');
rewind($buffer); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$output = fopen('php://stdout', 'w');
stream_copy_to_stream($buffer, $output); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
exit; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

临时流利用磁盘上的临时文件。您受限于操作系统分配给临时文件的可用空间,而不是 PHP 可用的内存。

2

将整个 HTML 文档写入临时文件后,请将流倒回到开头,以便将所有这些字节复制到stdout

3

即使这两个流都不指向特定的磁盘文件,复制一个流到另一个的机制仍然保持不变。

4

总是在所有字节都复制到客户端后才退出,以避免意外错误。

参见

stream_copy_to_stream() 的文档。

11.5 组合不同的流处理程序在一起

问题

您希望在一个代码片段中结合多个流概念,例如包装器和过滤器。

解决方案

根据需要追加过滤器并使用适当的包装器协议。示例 11-8 使用file://协议来访问本地文件系统,并使用两个额外的过滤器来处理 Base64 编码和文件解压缩。

示例 11-8. 将多个过滤器应用到流
$fp = fopen('compressed.txt', 'r'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
stream_filter_append($fp, 'convert.base64-decode'); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
stream_filter_append($fp, 'zlib.inflate'); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

echo fread($fp, 1024) . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

假设此文件存在于磁盘上并包含字面内容80jNycnXUS​jPL8pJUQQA

2

添加到堆栈的第一个流过滤器将从 Base64 编码的 ASCII 文本转换为原始字节。

3

第二个过滤器将利用 Zlib 压缩来解压(或解压缩)原始字节。

4

如果您从步骤 1 中的字面内容开始,这很可能会在控制台打印Hello, world!

讨论

当涉及流时,思考层次是很有帮助的。基础始终是用于实例化流的协议处理程序。在解决方案示例中没有显式协议,这意味着 PHP 将默认使用file://协议。在这个基础之上是任意数量的流过滤器层。

解决方案示例利用 Zlib 压缩和 Base64 编码来压缩文本并对原始(压缩的)字节进行编码。要创建这样的压缩/编码文件,您需要执行以下操作:

$fp = fopen('compressed.txt', 'w');

stream_filter_append($fp, 'zlib.deflate');
stream_filter_append($fp, 'convert.base64-encode');

fwrite($fp, 'Goodnight, moon!');

前面的示例利用与解决方案示例相同的协议包装器和过滤器。但请注意它们添加的顺序是相反的。这是因为流过滤器的工作方式类似于豆腐花上的层次,就像图 11-2 中的插图一样。协议包装器位于核心位置,数据从该核心流向外部世界,通过每个后续层次以特定顺序传递。

数据进出 PHP 流过滤器

图 11-2. 数据进出 PHP 流过滤器

PHP 中已经内置了几种可以应用于流的过滤器。但是,您也可以定义自己的过滤器。将原始字节编码为 Base64 很有用,但有时将字节编码/解码为十六进制也很有用。PHP 中没有这样的过滤器,但您可以通过类似于本章介绍中示例 11-1 中所做的方式扩展php_​user_​filter类来定义它。考虑示例 11-9 中的类。

示例 11-9. 使用过滤器进行十六进制编码/解码
class HexFilter extends php_user_filter
{
    private string $mode;

    public function filter($in, $out, &$consumed, bool $closing): int
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            switch ($this->mode) {
                case 'encode':
                    $bucket->data = bin2hex($bucket->data);
                    break;
                case 'decode':
                    $bucket->data = hex2bin($bucket->data);
                    break;
                default:
                    throw new Exception('Invalid encoding mode!');
            }

            $consumed += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }

        return PSFS_PASS_ON;
    }

    public function onCreate(): bool
    {
        switch($this->filtername) {
            case 'hex.decode':
                $this->mode = 'decode';
                return true;
            case 'hex.encode':
                $this->mode = 'encode';
                return true;
            default:
                return false;
        }
    }
}

在任意流应用为过滤器后,可以使用示例 11-9 中定义的类来任意编码和解码十六进制。只需像注册其他过滤器一样注册它,然后将其应用于需要转换的流。

在解决方案示例中使用的 Base64 编码可以完全替换为十六进制,如示例 11-10 所示。

示例 11-10. 结合十六进制流过滤器和 Zlib 压缩
stream_filter_register('hex.*', 'HexFilter'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

// Writing data $fp = fopen('compressed.txt', 'w');

stream_filter_append($fp, 'zlib.deflate');
stream_filter_append($fp, 'hex.encode');

fwrite($fp, 'Hello, world!' . PHP_EOL);
fwrite($fp, 'Goodnight, moon!');

fclose($fp); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$fp2 = fopen('compressed.txt', 'r');
stream_filter_append($fp2, 'hex.decode');
stream_filter_append($fp2, 'zlib.inflate');

echo fread($fp2, 1024); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

一旦过滤器存在,必须将其注册,以便 PHP 知道如何使用它。在注册过程中利用*通配符允许同时注册编码和解码。

2

此时compressed.txt的内容将是f348cdc9c9d75128cf2fca4951e472cfcf 4fc9cb4ccf28d151c8cdcfcf530400

3

解码和解压后,Hello world! Goodnight, moon! 将被打印到控制台(两个语句之间有换行)。

参见

支持的协议和包装器以及可用过滤器的列表。另请参考用户定义的流过滤器的示例 11-1。

11.6 编写自定义流包装器

问题

您想定义自己的自定义流协议。

解决方案

创建一个遵循streamWrapper原型的自定义类,并将其注册到 PHP 中。例如,VariableStream类可以提供一种类似流的接口,用于读取或写入特定全局变量,如下所示:²

class VariableStream
{
    private int $position;
    private string $name;
    public $context;

    function stream_open($path, $mode, $options, &$opened_path)
    {
        $url = parse_url($path);
        $this->name = $url['host'];
        $this->position = 0;

        return true;
    }

    function stream_write($data)
    {
        $left = substr($GLOBALS[$this->name], 0, $this->position);
        $right = substr($GLOBALS[$this->name], $this->position + strlen($data));
        $GLOBALS[$this->name] = $left . $data . $right;
        $this->position += strlen($data);
        return strlen($data);
    }
}

在 PHP 中,可以如下注册和使用上述类:

if (!in_array('var', stream_get_wrappers())) {
    stream_wrapper_register('var', 'VariableStream');
}

$varContainer = '';

$fp = fopen('var://varContainer', 'w');

fwrite($fp, 'Hello' . PHP_EOL);
fwrite($fp, 'World' . PHP_EOL);
fclose($fp);

echo $varContainer;

讨论

PHP 中的streamWrapper构造函数是一个类的原型。不幸的是,它既不是可以扩展的类,也不是可以具体实现的接口。相反,它是任何用户定义的流协议必须遵循的文档格式。

虽然可以将类注册为不同接口的协议处理程序,但强烈建议任何潜在的协议类实现streamWrapper接口中定义的所有方法(从 PHP 文档中复制为示例 11-11 伪接口定义),以满足 PHP 对流行为的预期要求。

示例 11-11。streamWrapper接口定义
 class streamWrapper {
    public $context;

    public __construct()

    public dir_closedir(): bool

    public dir_opendir(string $path, int $options): bool

    public dir_readdir(): string

    public dir_rewinddir(): bool

    public mkdir(string $path, int $mode, int $options): bool

    public rename(string $path_from, string $path_to): bool

    public rmdir(string $path, int $options): bool

    public stream_cast(int $cast_as): resource

    public stream_close(): void

    public stream_eof(): bool

    public stream_flush(): bool

    public stream_lock(int $operation): bool

    public stream_metadata(string $path, int $option, mixed $value): bool

    public stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$opened_path
    ): bool

    public stream_read(int $count): string|false

    public stream_seek(int $offset, int $whence = SEEK_SET): bool

    public stream_set_option(int $option, int $arg1, int $arg2): bool

    public stream_stat(): array|false

    public stream_tell(): int

    public stream_truncate(int $new_size): bool

    public stream_write(string $data): int

    public unlink(string $path): bool

    public url_stat(string $path, int $flags): array|false

    public __destruct()
}

某些特定功能,例如mkdirrenamermdirunlink,如果协议没有特定的使用场景,应完全实现。否则,系统将无法为您(或构建在您库之上的开发人员)提供有用的错误消息,并且表现会出乎意料。

虽然大多数您日常使用的协议都与 PHP 原生集成,但可以编写新的协议处理程序或利用其他开发人员构建的处理程序。

在引用使用专有协议的云存储时很常见(例如 Amazon Web Services 的s3://),而不是其他地方常见的更通用的https://file://前缀。AWS 实际上发布了一个公共 SDK,该 SDK 在内部使用stream_​wrap⁠per_​register()为其他应用程序代码提供s3://协议,使您能够像处理本地文件一样轻松地处理托管在云中的数据。

另请参阅

streamWrapper文档。

¹ 欲了解更多有关生成器的信息,请参阅 Recipe 7.15。

² PHP 手册提供了一个名字类似的类,其功能比本解决方案示例中演示的更广泛更完整,点击此处查看

第十二章:错误处理

鼠辈和人类的美好计划往往会遭遇波折。

改编自罗伯特·彭斯

如果你从事编程或软件开发工作,你可能非常熟悉错误和调试的过程。你可能甚至花费了与编写代码同等甚至更多的时间来追踪错误。这是软件的一个不幸定律——无论一个团队多么努力地构建正确的软件,总会不可避免地出现需要识别和纠正的故障。

幸运的是,PHP 使得查找错误相对简单。语言的宽容性通常也使得错误更像是一个讨厌而不是致命的缺陷。

以下的技巧介绍了识别和处理代码中错误的最快最简单的方法。它们还详细说明了如何编写和处理由第三方 API 输出无效数据或其他不正确系统行为抛出的自定义异常。

12.1 寻找和修复解析错误

问题

PHP 编译器在你的应用程序中未能解析脚本;你希望尽快找到并纠正问题。

解决方案

在文本编辑器中打开有问题的文件,并查看解析器标记的语法错误行。如果问题不明显,逐行向后查找代码,直到找到问题并在文件中进行修正。

讨论

PHP 是一种相对宽容的语言,通常会尝试让即使是不正确或有问题的脚本运行完成。但在许多情况下,解析器无法正确解释一行代码来确定应该做什么,而是会返回一个错误。

例如,编写一个虚构的示例,循环遍历美国西部的州:

$states = ['Washington', 'Oregon', 'California'];
foreach $states as $state {
    print("{$state} is on the West coast.") . PHP_EOL;
}

当在 PHP 解释器中运行时,这段代码将在第二行抛出一个Parse error

PHP Parse error:  syntax error, unexpected variable "$states", expecting "("
in php shell code on line 2

仅根据这个错误消息,你就可以准确定位到有问题的行。请记住,尽管foreach是一种语言结构,但它仍然类似于带有括号的函数调用。遍历数组状态的正确方法如下:

$states = ['Washington', 'Oregon', 'California'];
foreach ($states as $state) {
    print("{$state} is on the West coast.") . PHP_EOL;
}

这种特定错误——在使用语言结构时省略括号——在经常在不同语言之间移动的开发人员中很常见。例如,在 Python 中,同样的机制看起来几乎一样,但在省略foreach调用的括号时是语法正确的。例如:

states = ['Washington', 'Oregon', 'California']
for state in states:
    print(f"{state} is on the West coast.")

这两种语言的语法非常相似,这种相似性令人困惑。不过,它们之间的差异足够大,以至于每种语言的解析器都会捕捉到这些差异,并在你在项目之间切换时警告你,如果你犯了这样的错误。

幸运的是,像Visual Studio Code这样的集成开发环境会自动解析你的脚本,并为你突出显示任何语法错误。图 12-1 说明了这种突出显示如何相对容易地跟踪和纠正问题,使得在你的应用程序运行之前就能发现问题。

Visual Studio Code 在应用程序运行之前识别和突出显示语法错误

图 12-1. 在应用程序运行之前,Visual Studio Code 将识别和突出显示语法错误

另请参阅

令牌列表,PHP 解析器使用的源代码的各个部分。

12.2 创建和处理自定义异常

问题

当事情出错时,您希望您的应用程序抛出(并捕获)一个自定义异常。

解决方案

扩展基础 Exception 类以引入自定义行为,然后利用 try/catch 块来捕获和处理异常。

讨论

PHP 定义了一个基本的 Throwable 接口,由语言中任何错误或异常实现。内部问题由 Error 类及其后代表示,而用户端的问题由 Exception 类及其后代表示。

通常情况下,您只会在应用程序中扩展 Exception 类,但您可以在标准的 try/catch 块中捕获任何 Throwable 实现。

例如,假设您正在实现一个带有非常精确自定义功能的除法函数:

  1. 不允许被零除。

  2. 所有的小数值将向下舍入。

  3. 整数 42 作为分子是无效的。

  4. 分子必须是整数,但分母也可以是浮点数。

这样的函数可能利用内置错误如 ArithmeticErrorDivisionByZeroError。但在前述规则列表中,第三个需要自定义异常的规则显著。在定义您的函数之前,您将像 示例 12-1 中那样定义一个自定义异常。

示例 12-1. 简单的自定义异常定义
class HitchhikerException extends Exception
{
    public function __construct(int $code = 0, Throwable $previous = null)
    {
        parent::__construct('42 is indivisible.', $code, $previous);
    }

    public function __toString()
    {
        return __CLASS__ . ": [{$this->code}]: {$this->message}\n";
    }
}

一旦自定义异常存在,您可以在自定义的除法函数中使用 throw 来抛出它,如下所示:

function divide(int $numerator, float|int $denominator): int
{
    if ($denominator === 0) {
        throw new DivisionByZeroError;
    } elseif ($numerator === 42) {
        throw new HitchhikerException;
    }

    return floor($numerator / $denominator);
}

一旦您定义了自定义功能,就可以在应用程序中利用该代码。您知道该函数 可能 会抛出错误,因此重要的是在 try 语句中包装任何调用,并适当处理该错误。示例 12-2 将在四对数字上迭代,尝试每个的除法,并处理任何随后抛出的错误/异常。

示例 12-2. 处理自定义除法中的错误
$pairs = [
    [10, 2],
    [2, 5],
    [10, 0],
    [42, 2]
];

foreach ($pairs as $pair) {
    try {
        echo divide($pair[0], $pair[1]) . PHP_EOL;
    } catch (HitchhikerException $he) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
        echo 'Invalid division of 42!' . PHP_EOL;
    } catch (Throwable $t) { ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
        echo 'Look, a rabid marmot!' . PHP_EOL;
    }
}

1

如果 42 被作为分子传入,divide() 函数将抛出 Hitchhi⁠ker​Exception 并且无法恢复。捕获此异常允许您向应用程序或用户提供反馈,并继续执行。

2

由函数抛出的任何其他 ErrorException 都将作为 Throwable 的实现捕获。在这种情况下,您会丢弃该错误并继续进行。

另请参阅

关于以下的文档:

12.3 隐藏最终用户的错误消息

问题

你已经修复了所有已知的错误,并准备好在生产环境中启动你的应用程序。但是你也希望防止任何新错误被显示给最终用户。

解决方案

要在生产环境完全抑制错误,请将php.ini中的error_reportingdis⁠play_​errors指令都设置为Off,如下所示:

; Disable error reporting
error_reporting = Off
display_errors  = Off

讨论

解决方案示例中提出的配置更改将影响你的整个应用程序。错误将完全被抑制,即使它们被抛出,也不会显示给最终用户。直接向用户显示错误或未处理的异常被认为是一种不良做法。如果堆栈跟踪直接显示给应用程序的最终用户,这可能还会导致安全问题。

然而,如果你的应用程序行为异常,开发团队将无法记录任何信息以进行诊断和处理。

对于生产环境实例,将display_errors设置为Off仍将隐藏来自最终用户的错误,但是恢复到默认的error_reporting级别将可靠地将任何错误发送到日志中。

虽然可能有一些特定页面存在已知的错误(由于遗留代码、编写不良的依赖项或已知的技术债务),你希望在这些情况下省略它们。在这种情况下,你可以通过使用 PHP 中的error_​reporting()函数来编程设置错误报告级别。该函数接受一个新的错误报告级别,并返回先前设置的级别(如果之前没有配置,则使用默认级别)。

因此,您可以使用对error_reporting()的调用来包装有问题的代码块,并防止在日志中显示太多的错误信息。例如:

$error_level = error_reporting(E_ERROR); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

// ... Call your other application code here. 
error_reporting($error_level); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

将错误级别设置为绝对最小值,只包括导致脚本执行停止的致命运行时错误。

2

将错误级别返回到其先前的状态。

默认错误级别是E_ALL,它会显示所有错误、警告和通知。¹ 您可以使用整数报告级别来覆盖这一设置,但 PHP 提供了几个命名常量,代表每个潜在的设置。这些常量在表 12-1 中枚举。

注意

在 PHP 8.0 之前,默认的错误报告级别从E_ALL开始,然后显式地移除了诊断通知(E_NOTICE)、严格类型警告(E_STRICT)和已弃用通知(E_DEPRECATED)。

表 12-1. 错误报告级别常量

整数值 常量 描述
1 E_ERROR 导致脚本执行停止的致命运行时错误。
2 E_WARNING 运行时警告(非致命错误),不会停止脚本执行。
4 E_PARSE 解析器生成的编译时错误。
8 E_NOTICE 运行时通知,指示脚本遇到可能表明错误但也可能在正常运行脚本过程中发生的情况。
16 E_CORE_ERROR 在 PHP 初始启动期间发生的致命错误。这类似于E_ERROR,但由 PHP 核心生成。
32 E_CORE_WARNING 在 PHP 初始启动期间发生的警告(非致命错误)。这类似于E_WARNING,但是由 PHP 核心生成。
64 E_COMPILE_ERROR 致命的编译时错误。这类似于E_ERROR,但由 Zend 脚本引擎生成。
128 E_COMPILE_​WARN⁠ING 编译时警告(非致命错误)。这类似于E_WARNING,但由 Zend 脚本引擎生成。
256 E_USER_ERROR 用户生成的错误消息。这类似于E_ERROR,但通过使用 PHP 函数 trigger_error() 在 PHP 代码中生成。
512 E_USER_WARNING 用户生成的警告消息。这类似于E_WARNING,但通过使用 PHP 函数 trigger_error() 在 PHP 代码中生成。
1024 E_USER_NOTICE 用户生成的通知消息。这类似于E_NOTICE,但通过使用 PHP 函数 trigger_error() 在 PHP 代码中生成。
2048 E_STRICT 启用以使 PHP 建议更改您的代码,以确保最佳的代码互操作性和向前兼容性。
4096 E_RECOVERA⁠BLE_​ERROR 可捕获的致命错误。发生了一个危险错误,但 PHP 并不不稳定,可以恢复。如果错误未被用户定义的处理程序捕获(参见 Recipe 12.4),则应用程序会像处理 E_ERROR 一样中止。
8192 E_DEPRECATED 运行时通知。启用此选项以接收有关将来版本中将不起作用的代码的警告。
16384 E_USER_​DEPRE⁠CATED 用户生成的警告消息。这类似于E_DEPRECATED,但通过使用 PHP 函数 trigger_error() 在 PHP 代码中生成。
32767 E_ALL 所有错误、警告和通知。

注意你可以通过二进制操作结合错误级别,创建一个位掩码。一个简单的错误报告级别可能包括单独的错误、警告和解析器错误(忽略核心、用户错误和通知)。以下内容将足够地设置这个级别:

error_reporting(E_ERROR | E_WARNING | E_PARSE);

参见

有关 error_reporting()error_reporting 指令display_​errors 指令 的文档。

12.4 使用自定义错误处理程序

问题

您想自定义 PHP 处理和报告错误的方式。

解决方案

在 PHP 中将您的自定义处理程序定义为可调用函数,然后按以下方式将该函数传递给 set_error_handler()

function my_error_handler(int $num, string $str, string $file, int $line)
{
    echo "Encountered error $num in $file on line $line: $str" . PHP_EOL;
}

set_error_handler('my_error_handler');

讨论

PHP 将在大多数可恢复错误的情况下利用您的自定义处理程序。致命错误、核心错误和编译时问题(如解析器错误)要么会停止要么完全阻止程序执行,并且不能通过用户函数处理。具体来说,E_ERRORE_PARSEE_CORE_ERRORE_CORE_WARNINGE_COMPILE_ERRORE_COMPILE_WARNING错误永远不能被捕获。此外,在调用set_error_handler()的文件中大多数E_STRICT错误也不能被捕获,因为这些错误会在正确注册自定义处理程序之前被抛出。

如果您定义了一个与解决方案示例中一致的自定义错误处理程序,任何可捕获的错误都将调用此函数并将数据打印到屏幕上。如在示例 12-3 中所示,尝试echo一个未定义的变量将会导致一个E_WARNING错误。

示例 12-3. 捕获可恢复的运行时错误
echo $foo;

使用解决方案示例中定义并注册的my_error_handler(),在示例 12-3 中的错误代码将打印以下文本到屏幕上,引用了E_WARNING错误类型的整数值:

Encountered error 2 in php shell code on line 1: Undefined variable $foo

一旦您捕获了一个错误以处理它,如果该错误会导致应用程序不稳定,您有责任调用die()来停止执行。PHP 不会在处理程序外部为您执行此操作,而是会继续处理应用程序,就像没有抛出错误一样。

如果您在应用程序的某个部分处理错误后,希望恢复原始(默认)错误处理程序,则应通过调用restore_error_​han⁠dler()来执行。这仅仅是还原了您之前注册的错误处理程序,并恢复了以前注册的任何错误处理程序。

类似地,PHP 允许您注册(和恢复)自定义异常处理程序。它们与自定义错误处理程序的操作相同,但不同的是它们会捕获在try/catch块之外抛出的任何异常。与错误处理程序不同的是,程序执行在调用自定义异常处理程序后将会停止。

要了解更多关于异常的信息,请查看配方 12.2 以及set_exception_handler()restore_exception_&#x200b;han&#x2060;dler()的文档。

参见

关于set_error_handler()restore_error_handler()的文档。

12.5 记录错误到外部流

问题

您希望将应用程序错误记录到文件或外部某种资源以便未来调试。

解决方案

使用error_log()将错误写入默认日志文件,方法如下:

$user_input = json_decode($raw_input);
if (json_last_error() !== JSON_ERROR_NONE) {
    error_log('JSON Error #' . json_last_error() . ': ' . $raw_input);
}

讨论

默认情况下,error_log()会将错误记录到由php.ini中的error_log指令指定的位置。在类 Unix 系统上,通常会是/var/log内的文件,但可以自定义到系统中的任何位置。

error_log()的可选第二个参数允许您在必要时路由错误消息。如果服务器已设置发送电子邮件,则可以指定消息类型为1,并为可选的第三个参数提供电子邮件地址以通过电子邮件发送错误。例如:

error_log('Some error message', 1, 'developer@somedomain.tld');
注意

在底层,error_log()将使用与mail()相同的功能来通过电子邮件发送错误。在许多情况下,出于安全目的,可能已禁用此功能。在依赖此功能之前,请务必验证任何邮件系统的功能,特别是在生产环境中。

或者,您可以指定一个不同于默认日志位置的文件作为目标,并将整数3作为消息类型传递。PHP 将消息直接追加到该文件而不是写入默认日志。例如:

error_log('Some error message', 3, 'error_log.txt');
警告

当使用error_log()直接记录到文件时,系统不会自动追加换行符。您需要负责在任何字符串后附加PHP_EOL或编码\r\n换行文字。

第十一章详细介绍了文件协议以及 PHP 公开的其他流。请记住,直接引用文件路径实际上是透明地利用了file://协议,因此,实际上您是在前面的代码块中将错误记录到文件。只要正确引用流协议,您可以轻松地引用任何其他类型的流。以下示例直接将错误记录到控制台的标准错误流:

error_log('Some error message', 3, 'php://stderr');

参见

错误日志函数(error_log())的文档以及配方 13.5 中对 Monolog 的覆盖,Monolog 是用于 PHP 应用程序的更全面的日志记录库。

¹ 默认错误级别可以直接在php.ini中设置,在许多环境中可能已经设置为E_ALL以外的某些值。确认您自己环境的配置以确保准确。

第十三章:调试和测试

尽管开发人员尽力而为,但没有任何代码是完美的。您将不可避免地引入一个会影响应用程序生产行为或在某些操作未按预期方式运行时导致最终用户烦恼的错误。

在应用程序中正确处理错误是至关重要的¹。然而,并非应用程序抛出的每个错误都是预期的或者可以捕获的。在这些情况下,您必须了解如何正确调试应用程序——即如何跟踪到有问题的代码行,以便进行修复。

PHP 工程师在调试其代码时使用的第一步之一是echo语句。在没有正式调试器的情况下,通常会看到开发代码中散布着echo "Here!";语句,以便团队可以跟踪可能出现问题的地方。

Laravel 框架通过公开一个名为dd()(简称“dump and die”)的函数,使得在新项目上工作时流行且易于访问类似的功能。此函数实际上是由 Symfony var-dumper模块提供,并在 PHP 的本地命令行界面和使用交互式调试器时都能有效工作。该函数的定义如下:

function dd(...$vars): void
{
    if (!in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) && !headers_sent()) {
        header('HTTP/1.1 500 Internal Server Error');
    }

    foreach ($vars as $v) {
        VarDumper::dump($v);
    }

    exit(1);
}

在 Laravel 应用程序中使用上述函数将打印出您传递给它的任何变量的内容,并立即停止程序的执行。与使用echo一样,这并不是调试应用程序的最优雅方式。然而,它快速、可靠,并且是开发人员在赶时间调试系统时常用的方式。

通过单元测试是预防性调试代码的最佳方法。通过将代码分解为最小的逻辑单元,您可以编写额外的代码,自动测试和验证这些逻辑单元的功能。然后将这些测试连接到集成和部署管道中,可以确保在部署之前应用程序中没有任何问题。

开源项目PHPUnit项目使得仪器化整个应用程序并自动测试其行为变得简单明了。所有的测试都是用 PHP 编写的,直接加载您应用程序的函数和类,并明确记录应用程序的正确行为。

注意

PHPUnit 的一个替代品是开源Behat库。虽然 PHPUnit 专注于测试驱动开发(TDD),Behat 专注于另一种行为驱动开发(BDD)范式。两者对于测试代码同样有用,您的团队应选择采取哪种方法。然而,PHPUnit 是一个更成熟的项目,并将在本章中被引用。

毫无疑问,调试代码的最佳方法是使用交互式调试器。 Xdebug 是 PHP 的一个调试扩展,它改善了错误处理,支持跟踪或分析应用程序的行为,并与像 PHPUnit 这样的测试工具集成,以展示应用程序代码的测试覆盖率。更重要的是,Xdebug 还支持交互式逐步调试您的应用程序。

在使用 Xdebug 和兼容的 IDE 的情况下,您可以在代码中放置称为 断点 的标志。当应用程序运行并命中这些断点时,它会暂停执行并允许您交互地检查应用程序的状态。这意味着您可以查看所有作用域内的变量、它们的来源,并逐条执行程序以寻找错误。作为 PHP 开发人员,这绝对是您武器库中最强大的工具!

以下配方介绍了调试 PHP 应用程序的基础知识。您将学习如何设置交互式调试、捕获错误、正确测试代码以防止回归,并快速确定引入断裂更改的时间和位置。

13.1 使用调试器扩展

问题

您希望利用强大的外部调试器来检查和管理应用程序,以便在业务逻辑中识别、分析和消除错误。

解决方案

安装 Xdebug,这是一个用于 PHP 的开源调试扩展。例如,在 Linux 操作系统上,可以通过使用默认的包管理器直接安装 Xdebug。例如,在 Ubuntu 上,可以使用 apt 安装 Xdebug:

$ sudo apt install php-xdebug

由于包管理器有时会安装项目的过时版本,您也可以直接使用 PECL 扩展管理器安装:

$ pecl install xdebug

一旦在您的系统上启用了 Xdebug,它将自动为您美化错误页面,并提供丰富的堆栈跟踪和调试信息,以便在出现问题时更轻松地识别错误。

讨论

Xdebug 是一个强大的 PHP 扩展。它使您能够全面测试、分析和调试应用程序,这是语言本身不支持的。默认情况下,它带来的最有用的功能之一是大幅改进了错误报告。

默认情况下,Xdebug 将自动捕获应用程序抛出的任何错误,并展示关于以下内容的附加信息:

  • 调用堆栈(如 图 13-1 所示),包括时间和内存使用数据。这帮助您准确地确定程序失败的时间以及函数调用发生的代码位置。

  • 来自局部作用域的变量,这样您就不需要猜测错误抛出时内存中的数据。

Xdebug 丰富并格式化错误发生时呈现的信息

图 13-1. Xdebug 丰富并格式化错误发生时呈现的信息

Webgrind 等工具的高级集成还允许你动态地分析应用程序的性能。Xdebug 可以(可选地)记录每个函数调用的执行时间,并将该时间和函数调用的“成本”记录到磁盘上。然后,Webgrind 应用程序会呈现一个便捷的可视化界面,帮助你识别代码中的瓶颈,以便根据需要优化程序。

你甚至可以直接将 Xdebug 与你的开发环境配对,进行逐步调试。通过将你的环境(例如 Visual Studio Code)与 Xdebug 配置配对,你可以在代码中设置断点,并在 PHP 解释器到达这些点时暂停执行。

注意

PHP Debug 扩展使得 Xdebug 和 Visual Studio Code 之间的集成变得非常简单。它直接向你的 IDE 添加了所有你期望的额外接口,包括断点和环境内省。它也直接由 Xdebug 社区维护,所以你可以确保它与整个项目保持同步。

在逐步调试模式下,当应用程序到达断点时会暂停,并且你可以直接访问程序范围内的所有变量。你可以检查和修改这些变量以测试你的环境。此外,在断点暂停时,你可以完全访问应用程序的控制台,进一步识别可能发生的情况。调用堆栈也直接暴露出来,因此你可以深入了解导致断点的哪个函数或方法对象,并在必要时进行更改。

在断点中,你可以逐行逐步执行程序,或选择“继续”执行,直到下一个断点或程序抛出的第一个错误。断点也可以在不从 IDE 中移除的情况下禁用,因此你可以根据需要继续执行,但随时回顾特定的问题点。

警告

Xdebug 是任何 PHP 开发团队的极其强大的开发工具。然而,已知即使在最小的应用程序中,它也会显著增加性能开销。确保你只在本地开发或在带有测试部署的受保护环境中启用此扩展。永远不要在生产环境中部署带有 Xdebug 的应用程序!

参见

Xdebug 的文档和主页。

13.2 编写单元测试

问题

你希望验证一段代码的行为,以确保将来的重构不会改变应用程序的功能。

解决方案

编写一个扩展 PHPUnit 的 TestCase 的类,显式测试应用程序的行为。例如,如果你的函数旨在从电子邮件地址中提取域名,你可以定义如下:

function extractDomain(string $email): string
{
    $parts = explode('@', $email);

    return $parts[1];
}

然后创建一个类来测试和验证此代码的功能。这样的测试看起来将类似于以下示例:

use PHPUnit\Framework\TestCase;

final class FunctionTest extends TestCase
{
    public function testSimpleDomainExtraction()
    {
        $this->assertEquals('example.com', extractDomain('php@example.com'));
    }
}

讨论

PHPUnit 最重要的一点是如何组织您的项目。首先,项目需要利用 Composer 来自动加载您的应用程序代码以及加载任何依赖项(包括 PHPUnit 本身)。² 通常,您将把应用程序代码放在项目根目录下的src/目录中,而所有测试代码将与之相邻,位于tests/目录中。

在解决方案示例中,您应将您的extractDomain()函数放置在src/functions.php中,将FunctionTest类放置在tests/FunctionTest.php中。假设通过 Composer 正确配置了自动加载,您可以使用 PHPUnit 捆绑的命令行工具来运行测试,如下所示:

$ ./vendor/bin/phpunit tests

默认情况下,前述命令将通过 PHPUnit 自动识别和运行位于您的tests/目录中定义的每个测试类。为了更全面地控制 PHPUnit 的运行方式,您可以利用本地配置文件来描述测试套件、文件允许列表,并配置测试期间需要的任何特定环境变量。

基于 XML 的配置通常仅用于复杂或复杂的项目,但项目文档详细说明了如何配置它。可与此示例或其他类似简单项目一起使用的基本phpunit.xml文件看起来类似于示例 13-1。

示例 13-1. 基本 PHPUnit XML 配置
<?xml version="1.0" encoding="UTF-8"?>

<phpunit bootstrap="vendor/autoload.php"
         backupGlobals="false"
         backupStaticAttributes="false"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">

  <coverage>
    <include>
      <directory suffix=".php">src/</directory>
    </include>
  </coverage>

  <testsuites>
    <testsuite name="unit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>

  <php>
    <env name="APP_ENV" value="testing"/>
  </php>

</phpunit>

借助项目中前述的phpunit.xml文件,您只需调用 PHPUnit 本身即可运行您的测试。您不再需要指定tests/目录,因为这已由应用程序配置中的testsuite定义提供。

类似地,您还可以为不同场景指定多个测试套件。也许一个测试集由开发团队主动编写代码时(在前述示例中是unit)构建。另一个测试集可能由质量保证(QA)团队编写,以复制用户报告的错误(回归)。第二个测试套件的优势在于,您可以重构应用程序直到测试通过(即修复了错误),同时确保未修改应用程序的整体行为。

您还可以确保旧的错误不会在以后出现!

此外,当运行 PHPUnit 时,通过传递可选的--testsuite标志,你可以选择在哪个时间运行哪个测试套件。大多数测试都会很快,这意味着它们可以频繁运行而不会为开发团队增加额外的时间成本。快速测试应该在开发过程中尽可能频繁地运行,以确保代码正常工作,并且没有新(或旧)的错误已经进入代码库。不过,有时候,你可能需要编写一个成本太高而不太频繁运行的测试。这些测试应该保留在单独的测试套件中,以便你可以绕过它们进行测试。这些测试保留在那里,可以在部署之前使用,但在标准测试频繁运行时不会减慢日常开发。

如解决方案示例中的函数测试那样,函数测试非常简单。对象测试类似于在测试中实例化对象并调用其方法。然而,最困难的部分是模拟特定函数或方法的多种可能输入。PHPUnit 通过数据提供者解决了这个问题。

作为一个简单的例子,考虑示例 13-2 中的add()函数。该函数明确使用宽松的类型来将两个值(无论其类型如何)相加。

示例 13-2. 简单加法函数
function add($a, $b): mixed
{
    return $a + $b;
}

由于前述函数中的参数可以是不同类型的(int/intint/floatstring/float等),你应该测试各种组合,以确保没有任何问题。这样的测试结构看起来像示例 13-3 中的类。

示例 13-3. PHP 加法简单测试
final class FunctionTest extends TestCase
{
    // ...

    /**
 * @dataProvider additionProvider
 */
    public function testAdd($a, $b, $expected): void
    {
        $this->assertSame($expected, add($a, $b));
    }

    public function additionProvider(): array
    {
        return [
            [2, 3, 5],
            [2, 3.0, 5.0],
            [2.0, '3', 5.0],
            ['2', 3, 5]
        ];
    }
}

@dataProvider注解告诉 PHPUnit 测试类中应使用的函数的名称,该函数用于提供测试数据。现在,你不需要显式编写四个单独的测试,而是为 PHPUnit 提供了一次以不同输入和期望输出运行一次测试的能力。最终结果是一样的——对add()函数的四个单独的测试,但无需显式编写这些额外的测试。

考虑到在示例 13-2 中定义的add()函数的结构,你可能会遇到 PHP 中某些类型限制的问题。虽然可以将数值字符串传递给函数(它们在相加之前会被转换为数值),但传递非数值数据将导致 PHP 警告。在将用户输入传递给该函数的情况下,这种问题可能会出现并将出现。最好通过显式使用is_numeric()检查输入值并抛出一个可以在其他地方捕获的已知异常来防范这种问题。

为了实现这一点,首先编写一个新的测试来期望异常,并验证它是否被适当地抛出。这样的一个测试看起来像示例 13-4。

示例 13-4. 测试代码中预期异常的存在
final class FunctionTest extends TestCase
{
    // ...

    /**
 * @dataProvider invalidAdditionProvider
 */
    public function testInvalidInput($a, $b, $expected): void
    {
        $this->expectException(InvalidArgumentException::class);
        add($a, $b);
    }

    public function invalidAdditionProvider(): array
    {
        return [
            [1, 'invalid', null],
            ['invalid', 1, null],
            ['invalid', 'invalid', null]
        ];
    }
}
警告

在修改代码之前编写测试非常有价值,因为它为您在重构时提供了精确的目标。然而,在您对应用程序代码进行更改之前,此新测试将失败。请注意不要将失败的测试提交到项目的版本控制中,否则将影响团队实施持续集成的能力!

在上述测试放置后,由于函数不符合文档或预期的行为,测试套件现在失败了。请花时间将适当的 is_numeric() 检查添加到函数中,如下所示:

function add($a, $b): mixed
{
    if (!is_numeric($a) || !is_numeric($b)) {
        throw new InvalidArgumentException('Input must be numeric!');
    }

    return $a + $b;
}

单元测试是文档您的应用程序期望和适当行为的有效方式,因为它们是可执行代码,也验证应用程序是否正常运行。您可以测试成功 失败条件,甚至可以在代码中模拟各种依赖关系。

PHPUnit 项目还提供了主动识别应用程序代码的百分比 通过单元测试覆盖率 的能力。更高的覆盖率百分比不能保证免受错误的影响,但是是一种可靠的方法,可以确保快速发现和纠正错误,对最终用户的影响最小。

另请参阅

使用 PHPUnit 的文档。

13.3 自动化单元测试

问题

您希望项目的单元测试在提交到版本控制之前,无需用户交互地频繁运行。

解决方案

利用 Git 提交钩子在本地提交之前自动运行单元测试 之前。例如,示例 13-5 中的 pre-commit 钩子将在用户运行 git commit 时自动运行 PHPUnit,但在实际数据写入仓库之前。

示例 13-5. 用于 PHPUnit 的简单 Git pre-commit 钩子
#!/usr/bin/env php
<?php

echo "Running tests.. ";
exec('vendor/bin/phpunit', $output, $returnCode);

if ($returnCode !== 0) {
  echo PHP_EOL . implode($output, PHP_EOL) . PHP_EOL;
  echo "Aborting commit.." . PHP_EOL;
  exit(1);
}

echo array_pop($output) . PHP_EOL;

exit(0);

讨论

Git 是目前最流行的分布式版本控制系统,并且也是核心 PHP 开发团队使用的系统。它是开源的,并且在托管存储库的方式以及如何自定义工作流程和项目结构方面非常灵活,以适应您的开发周期。

具体而言,Git 允许通过钩子进行自定义。您的钩子位于项目中的 .git/hooks 目录中,与 Git 用于跟踪项目状态的其他信息一起。即使是空的 Git 仓库默认也包含几个示例钩子,如 图 13-2 所示。

Git initializes even an empty repository with sample hooks

图 13-2. Git 初始化即使是空仓库也会带有示例钩子

每个示例钩子都带有 .sample 扩展名,以默认禁用它们。如果您希望使用示例钩子,只需删除该扩展名,钩子将在该操作上运行。

在自动化测试的情况下,您需要显式使用 pre-commit 钩子,并应创建一个名为 Example 13-5 的文件,其中包含钩子的内容。使用该钩子后,Git 将始终在提交代码之前运行此脚本。

脚本末尾的0退出状态告诉 Git 一切正常,可以继续提交。如果您的单元测试中有任何失败,1退出状态将标记出现了问题,提交将在不修改您的仓库的情况下中止。

如果您绝对确定自己知道在做什么,并且出于任何原因需要覆盖钩子,您可以在提交代码时添加--no-verify标志来绕过钩子。

警告

pre-commit 钩子完全在客户端运行,并且存在于您的代码库之外。每个开发者都需要单独安装该钩子。除了团队指南或公司政策外,没有有效的方法来强制使用该钩子(或者防止某人使用--no-verify绕过它)。

如果您的团队正在使用 Git 进行版本控制,那么很有可能您也在使用 GitHub 托管您的存储库的某个版本。如果是这样,您可以利用 GitHub Actions 在 GitHub 的服务器上运行 PHPUnit 测试,作为集成和部署管道的一部分。

本地运行测试有助于防止意外地将回归(重新引入已知错误的代码)或其他错误提交到代码库中。在云中运行相同的测试功能更强大,因为可以跨可能的配置矩阵执行测试。开发者通常只会在本地运行单个版本的 PHP,但您可以在服务器上的容器中运行应用程序代码和测试,并利用各种版本的 PHP 或不同的依赖版本。

使用 GitHub Actions 运行测试还提供以下好处:

  • 如果新开发者还没有设置他们的 Git pre-commit 钩子并提交了错误的代码,操作运行程序将立即标记该提交为错误,并防止开发者意外地发布错误到生产环境。

  • 在云中使用确定性环境可以保护您的团队免受“在我的机器上可以运行”的问题影响,即代码在一个本地环境中可以工作,但在具有不同配置的生产环境中则会失败。

  • 您的集成和部署工作流程应该从每次提交中构建新的部署工件。将此构建过程与您的测试连接起来,确保每个构建工件不受已知缺陷的影响,并且确实可以部署。

参见

使用钩子自定义 Git 的文档。

13.4 使用静态代码分析

问题

您希望利用外部工具确保代码在运行之前尽可能少的错误。

解决方案

使用像PHPStan这样的静态代码分析工具。

讨论

PHPStan 是一个用于 PHP 的静态代码分析工具,通过在应用程序发布之前标记错误来帮助减少生产代码中的错误。与严格类型一起使用效果最佳,有助于团队编写更易于管理和理解的应用程序。³

像许多其他开发工具一样,PHPStan 可以通过 Composer 安装到您的项目中,方法如下:

$ composer require --dev phpstan/phpstan

然后可以针对您的项目运行 PHPStan,直接分析您的应用程序代码和测试。例如:

$ ./vendor/bin/phpstan analyze src tests

默认情况下,PHPStan 运行在级别 0,这是可能的静态分析最宽松的级别。您可以通过在命令行传递--level标志并使用大于 0 的数字来指定更高级别的扫描。表格 13-1 列举了可用的各种级别。对于维护良好、严格类型的应用程序,级别 9 的分析是确保代码质量的最佳方法。

表格 13-1. PHPStan 规则级别

级别 描述
0 对未知类、函数或类方法进行基本检查。还将检查函数调用中的参数数量以及从未定义的任何变量。
1 检查可能未定义的变量、未知的魔术方法和通过魔术 getters 检索到的动态属性。
2 验证所有表达式上的未知方法,并验证功能文档(代码中的文档块)。
3 检查返回类型和属性类型分配。
4 检查死代码(例如,永远为 false 的条件)和无法访问的代码路径。
5 检查参数类型。
6 报告丢失的类型提示。
7 报告部分不正确的联合类型。^(a)
8 检查在可为 null 的类型上的任何方法调用或属性访问。
9 mixed类型的严格检查。
^(a) 关于联合类型的示例,请参阅示例 3-9 的讨论。

运行分析工具后,您可以开始更新您的应用程序,以修复基本缺陷和验证错误。您还可以自动化静态分析的使用,类似于您在食谱 13.3 中自动化测试以确保团队定期运行分析并修复识别的错误。

参见

PHPStan 项目主页和文档

13.5 记录调试信息

问题

当程序出现问题时,您希望记录关于您的程序的信息,以便稍后调试可能的错误。

解决方案

利用开源 Monolog 项目在您的应用程序中实现全面的日志记录接口。首先通过 Composer 安装该包如下:

$ composer require monolog/monolog

然后将日志记录器集成到您的应用程序中,以便在必要时发出警告和错误。例如:

use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$logPath  = getenv('LOG_PATH')  ?? '/var/log/php/error.log';
$logLevel = getenv('LOG_LEVEL') !== false
            ? Level::from(intval(getenv('LOG_LEVEL')))
            : Level::Warning;

$logger = new Logger('default');
$logger->pushHandler(new StreamHandler($logPath, $logLevel));

$log->warning('Hello!');
$log->error('World!');

讨论

从 PHP 中记录信息的最简单方法是通过其内置的error_log()函数。这将根据php.ini文件的配置,将错误记录到服务器错误日志或平面文件中。唯一的问题是该函数明确记录应用程序中的错误

结果是,任何由error_log()记录的内容都被任何解析日志文件的系统视为错误。这使得很难区分真正的错误(例如用户登录失败)和为调试目的记录的消息。真正错误和调试语句的混合可以使得运行时配置变得困难,特别是当你希望在不同环境中关闭某些日志记录时。一种解决方法是在调用error_log()时包装一个对当前日志级别的检查,如示例 13-6 所示。

示例 13-6. 使用error_log()选择性记录错误
enum LogLevel: int ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
{
    case Debug   = 100;
    case Info    = 200;
    case Warning = 300;
    case Error   = 400;
}

$logLevel = getenv('LOG_LEVEL') !== false ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
            ? LogLevel::from(intval(getenv('LOG_LEVEL')))
            : LogLevel::Debug;

// Some application code ... if (user_session_expired()) {
    if ($logLevel >= LogLevel::Info) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        error_log('User session expired. Logging out ...');
    }

    logout();
    exit;
}

1

在 PHP 中列出日志级别的最简单方法是使用字面量enum类型。

2

应该能从系统环境中获取日志级别。如果没有提供,则应退回到合理的硬编码默认值。

3

每当你调用error_log()时,都需要显式检查当前的日志级别,并决定是否实际输出错误。

示例 13-6 的问题不在于使用enum,也不在于需要从环境动态加载日志级别。问题在于你必须在每次调用error_log()之前显式检查日志级别,以确保程序确实应该输出错误。这种频繁检查导致大量的混乱代码,并使你的应用既难以阅读又难以维护。

有经验的开发人员会意识到,在这里包装所有日志逻辑(包括日志级别检查)到一个功能接口中,以保持应用程序的清晰。这绝对是正确的方法,也是 Monolog 包存在的全部原因!

注意

虽然 Monolog 是一个流行的 PHP 应用程序日志记录包,但并不是唯一的选择。Monolog 实现了PHP 的标准 Logger 接口;任何实现相同接口的包都可以替换 Monolog,提供类似的功能。

Monolog 远不止于仅仅将字符串打印到错误日志中。它还支持通道、各种处理程序、处理器和日志级别。

在实例化新记录器时,您首先为该对象定义一个通道。这使您可以并行创建多个日志记录实例,保持它们的内容分离,甚至将它们路由到不同的输出方式。默认情况下,记录器需要比通道更多的东西来操作,因此您还必须将处理程序推送到调用堆栈上。

处理程序 定义 Monolog 应如何处理传入特定通道的任何消息。它可以将数据路由到文件中,将消息存储在数据库中,通过电子邮件发送错误,通知 Slack 上的团队或频道有问题,甚至与 RabbitMQ 或 Telegram 等系统进行通信。

注意

Monolog 还支持不同的格式化程序,可以附加到各种处理程序上。每个格式化程序定义了消息将如何序列化并发送到定义的处理程序——例如,作为单行字符串、JSON blob 或 Elasticsearch 文档。除非您使用需要特定格式数据的处理程序,否则默认的格式化程序通常就足够了。

处理器是一个可选的额外步骤,可以向消息添加数据。例如,IntrospectionProcessor 将自动向日志中添加调用日志的行、文件、类和/或方法。用于带内省的基本 Monolog 设置,以将日志记录到一个平面文件中,看起来像是 示例 13-7。

示例 13-7. 带有内省的 Monolog 配置
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\IntrospectionProcessor;

$logger = new Logger('default');
$logger->pushHandler(new StreamHandler('/var/log/app.log', Level::Debug));
$logger->pushProcessor(new IntrospectionProcessor());

// ...

$logger->debug('Something happened ...');

示例 13-7 的最后一行调用了您配置的记录器,并通过处理器发送一个字面字符串到您已连接的处理程序。此外,作为可选的第二个参数,您还可以选择传递关于执行上下文或错误本身的附加数据数组。

即使没有额外的上下文,如果整个代码块位于名为 /src/app.php 的文件中,它将在应用程序日志中生成类似以下内容:

[2023-01-08T22:02:00.734710+00:00] default.DEBUG: Something happened ...
[] {"file":"/src/app.php","line":15,"class":null,"callType":null,"function":null}

您只需要创建一行文本(Something happened ...),Monolog 就会自动捕获事件时间戳、错误级别以及由于已注册的处理器而获取的调用堆栈详细信息。所有这些信息使得调试和潜在的错误修正对您和您的开发团队更加容易。

Monolog 还抽象出了在每个调用上检查错误级别的负担。相反,您在两个位置定义正在进行的错误级别:

  • 当向记录器实例本身注册处理程序时。只有该错误级别或更高级别的错误将被处理程序捕获。

  • 当向记录器通道发送消息时,您明确标识分配给它的错误级别。例如,::debug() 发送一个带有显式错误级别 Debug 的消息。

Monolog 支持 表 13-2 中列出的八个错误级别,所有这些错误级别都由 RFC 5424 描述的 syslog 协议进行说明。

表 13-2. Monolog 错误级别

错误级别 记录器方法 描述
Level::Debug ::debug() 详细的调试信息。
Level::Info ::info() 像 SQL 日志或信息应用事件等正常事件。
Level::Notice ::notice() 比信息消息具有更重要意义的正常事件。
Level::Warning ::warning() 如果不采取行动,可能会成为将来错误的应用程序警告。
Level::Error ::error() 需要立即关注的应用程序错误。
Level::Critical ::critical() 影响应用程序运行的关键条件。例如,关键组件的不稳定或缺乏可用性。
Level::Alert ::alert() 由于关键系统故障需要立即采取行动。在关键应用程序中,此错误级别应该页一个值班工程师。
Level::Emergency ::emergency() 应用程序无法使用。

通过 Monolog,您可以智能地将错误消息包装在适当的记录器方法中,并确定这些错误何时根据创建日志记录器时使用的错误级别发送到处理程序。如果仅为Error级别消息及以上实例化记录器,则对::debug()的任何调用都不会导致日志记录。在生产环境与开发环境中离散控制日志输出的能力对构建稳定和记录完善的应用程序至关重要。

另请参阅

Monolog 包的使用说明。

13.6 将变量内容转储为字符串

问题

您希望检查复杂变量的内容。

解决方案

使用var_dump()将变量转换为人类可读的格式并将其打印到当前输出流(例如命令行控制台)。例如:

$info = new stdClass;
$info->name = 'Book Reader';
$info->profession = 'PHP Developer';
$info->favorites = ['PHP', 'MySQL', 'Linux'];

var_dump($info);

运行在 CLI 中时,上述代码将在控制台打印以下内容:

object(stdClass)#1 (3) {
  ["name"]=>
  string(11) "Book Reader"
  ["profession"]=>
  string(13) "PHP Developer"
  ["favorites"]=>
  array(3) {
    [0]=>
    string(3) "PHP"
    [1]=>
    string(5) "MySQL"
    [2]=>
    string(5) "Linux"
  }
}

讨论

PHP 中的每种数据形式都有一些字符串表示形式。对象可以枚举它们的类型、字段和方法。数组可以列举它们的成员。标量类型可以公开它们的类型和值。开发者可以以三种略微不同但同样有价值的方式之一访问任何变量的内部内容。

首先,var_dump()在解决方案示例中直接打印变量的内容到控制台。这个字符串表示详细说明了涉及的类型、字段名称以及内部成员的值。它作为快速检查变量内部内容的方式很有用,但在此之外并没有太大用处。

警告

要注意确保var_dump()不要进入生产环境。此函数不会转义数据,并且可能会将未经过滤的用户输入渲染到应用程序的输出中,从而引入严重的安全漏洞。⁴

更有帮助的是 PHP 的 var_export() 函数。默认情况下,它也会打印传递进来的任何变量的内容,但输出格式本身是可执行的 PHP 代码。来自解决方案示例的相同 $info 对象将打印如下:

(object) array(
   'name' => 'Book Reader',
   'profession' => 'PHP Developer',
   'favorites' =>
  array (
    0 => 'PHP',
    1 => 'MySQL',
    2 => 'Linux',
  ),
)

var_dump() 不同,var_export() 接受一个可选的第二个参数,该参数指示函数返回其输出,而不是直接打印到屏幕上。这将产生一个字符串字面量,表示将要返回的变量内容,随后可以存储在其他地方供将来参考。

第三种最终的选择是使用 PHP 的 print_r() 函数。与前两个函数一样,它生成变量内容的易读表示。与 var_export() 一样,您可以传递可选的第二个参数来返回其输出,而不是直接打印到屏幕上。

尽管 print_r() 不直接暴露所有类型信息,与前两个函数不同。例如,来自解决方案示例的相同 $info 对象将打印如下:

stdClass Object
(
    [name] => Book Reader
    [profession] => PHP Developer
    [favorites] => Array
        (
            [0] => PHP
            [1] => MySQL
            [2] => Linux
        )
)

每个函数显示与所讨论变量相关的不同信息量。哪个版本对您最有效取决于您打算如何使用生成的信息。在调试或记录上下文中,var_export()print_r() 能够返回字符串表示而不是直接打印到控制台,特别是与类似 第 13.5 节 中描述的 Monolog 工具配对时将非常有价值。

如果您希望以便于直接重新导入到 PHP 中的方式导出变量内容,var_export() 的可执行输出将最适合您。如果您正在调试变量内容并需要深层次的类型和大小信息,则 var_dump() 的默认输出可能是最具信息性的,即使它不能直接导出为字符串。

如果您确实需要利用 var_dump() 并希望将其输出作为字符串导出,您可以利用 PHP 中的 输出缓冲 来实现这一点。具体而言,在调用 var_dump() 之前创建输出缓冲区,然后将该缓冲区的内容存储在变量中以备将来使用,如 示例 13-8 所示。

示例 13-8. 输出缓冲以捕获变量内容
ob_start(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
var_dump($info); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$contents = ob_get_clean(); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

创建输出缓冲区。此调用后打印到控制台的任何代码将被缓冲区捕获。

2

将相关变量的内容转储到控制台/缓冲区。

3

获取缓冲区的内容并在此之后删除它。

前面示例代码的结果将是存储在 $contents 中用于将来参考的 $info 转储内容的字符串表示。继续转储 $contents 本身的内容将得到以下结果:

string(244) "object(stdClass)#1 (3) {
 ["name"]=>
 string(11) "Book Reader"
 ["profession"]=>
 string(13) "PHP Developer"
 ["favorites"]=>
 array(3) {
 [0]=>
 string(3) "PHP"
 [1]=>
 string(5) "MySQL"
 [2]=>
 string(5) "Linux"
 }
}
"

参见

var_dump()var_export()print_r()的文档。

13.7 使用内置 Web 服务器快速运行应用程序

问题

您希望在本地启动 Web 应用程序,而无需配置实际的 Web 服务器,如 Apache 或 NGINX。

解决方案

使用 PHP 的内置 Web 服务器快速启动脚本,以便可以从 Web 浏览器访问。例如,如果您的应用程序位于public_html/目录中,请从该目录启动 Web 服务器如下所示:

$ cd ~/public_html
$ php -S localhost:8000

然后在浏览器中访问http://localhost:8000以查看位于该目录中的任何文件(静态 HTML、图像,甚至可执行 PHP)。

讨论

PHP CLI 提供了一个内置的 Web 服务器,可以轻松在受控的本地环境中测试或演示应用程序或脚本。CLI 支持运行 PHP 脚本并从请求路径返回静态内容。

静态内容可以包括渲染的 HTML 文件或以下标准 MIME 类型/扩展名中的任何内容:

.3gp, .apk, .avi, .bmp, .css, .csv, .doc, .docx, .flac, .gif, .gz, .gzip, .htm,
.html, .ics, .jpe, .jpeg, .jpg, .js, .kml, .kmz, .m4a, .mov, .mp3, .mp4, .mpeg,
.mpg, .odp, .ods, .odt, .oga, .ogg, .ogv, .pdf, .png, .pps, .pptx, .qt, .svg,
.swf, .tar, .text, .tif, .txt, .wav, .webm, .wmv, .xls, .xlsx, .xml, .xsl, .xsd,
and .zip.
警告

内置 Web 服务器用于开发和调试目的。不应在生产环境中使用。出于生产目的,始终利用完整的 Web 服务器作为 PHP 的前端。NGINX 或 Apache 在 PHP-FPM 前端都是合理的选择。

此外,您可以将特定脚本作为路由器脚本传递给 Web 服务器,使 PHP 将每个请求重定向到该脚本。这种方法的优点是模仿使用流行 PHP 框架的路由器的使用。缺点是您需要手动处理静态资源的路由。

在 Apache 或 NGINX 环境中,浏览器对图像、文档或其他静态内容的请求会直接提供而不调用 PHP。在利用 CLI Web 服务器时,您必须首先检查这些资产并返回显式的false,以便开发服务器正确处理它们。

框架路由器脚本必须检查是否在 CLI 模式下运行,如果是,则相应地路由内容。例如:

if (php_sapi_name() === 'cli-server') {
    if (preg_match('/\.(?:png|jpg|jpeg|gif)$/', $_SERVER["REQUEST_URI"])) {
        return false;
    }
}

// Continue router execution

上述router.php文件然后可以用以下方式启动本地 Web 服务器:

$ php -S localhost:8000 router.php

当调用时,可以通过传递0.0.0.0而不是localhost使开发 Web 服务器可访问任何接口(在本地网络上可用)。但请记住,该服务器不适用于生产使用,并且结构不足以保护应用程序免受不良行为者的滥用。不要在公共网络上使用此 Web 服务器!

参见

PHP 的内置 Web 服务器的文档。

13.8 使用单元测试和 git-bisect 在版本控制项目中检测回归

问题

您希望快速确定版本控制应用程序中引入特定错误的提交,以便修复它。

解决方案

使用git bisect来跟踪源代码树中的第一个错误提交,如下所示:

  1. 在项目上创建一个新分支。

  2. 编写一个失败的单元测试(即当前复现 bug 的测试,但如果修复了 bug,它将通过)。

  3. 将该测试提交到新分支。

  4. 利用 git rebase 将引入新测试的提交移动到项目历史的较早位置。

  5. 从历史的较早点使用 git bisect 自动运行单元测试,以找到测试失败的第一个提交。

一旦重新基础项目的提交历史,所有提交的哈希值都会更改。记住单元测试的 提交哈希,以便正确地定位 git bisect。例如,假设这个提交在移动后的提交历史中具有哈希值 48cc8f0。在 示例 13-9 中显示的情况下,你将将此提交标识为“good”,并将项目中的 HEAD(最新提交)标识为“bad”。

示例 13-9。在重新基础测试用例后的 git bisect 导航示例。
$ git bisect start
$ git bisect good 48cc8f0 ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$ git bisect bad HEAD ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
$ git bisect run vendor/bin/phpunit ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

必须告诉 Git 它需要查看的第一个好的提交。

2

由于不确定损坏的提交在哪里,传递 HEAD 常量,Git 将查看之前好的提交之后的每个提交。

3

Git 可以针对每个可疑的提交运行特定命令。在这种情况下,运行你的测试套件。Git 将继续查看项目提交历史,直到找到第一个使测试套件失败的提交。

一旦 Git 确定了第一个坏的提交(例如 16c43d7),使用 git diff 查看该提交实际更改的内容,如 示例 13-10 所示。

示例 13-10。比较已知坏的 Git 提交。
$ git diff 16c43d7 HEAD

一旦确定了有问题的地方,运行 git bisect reset 将仓库恢复到正常操作。此时返回到主分支(可能也要删除测试分支),以便开始修复确定的 bug。

讨论

Git 的 bisect 工具是追踪和识别项目中坏提交的强大方法。在较大且活跃的项目中尤其有用,因为在已知好和已知坏状态之间可能存在多个提交。对于较大的项目来说,通过逐个提交来测试其有效性在开发者时间上往往是成本高昂的。

git bisect 命令采用二分搜索方法。它找到介于已知好和已知坏之间的提交中点,并测试该提交。然后根据测试输出,靠近已知好或已知坏的提交。

默认情况下,git bisect希望你手动测试每个可疑的提交,直到找到“第一个坏”提交。然而,git bisect run子命令使你能够将此检查委托给像 PHPUnit 这样的自动化系统。如果测试命令返回默认状态0(或成功),则假定提交是良好的。这很有效,因为当所有测试通过时,PHPUnit 会以错误代码0退出。

如果测试失败,PHPUnit 将返回错误代码1,而git bisect则会将其解释为一个不良提交。通过这种方式,你可以快速轻松地自动检测到成千上万个潜在提交中的不良提交。

在解决方案示例中,你首先创建了一个新分支。这仅是为了保持项目清晰,以便在确定坏提交后可以丢弃任何潜在的测试提交。在这个分支上,你提交了一个单独的测试来复制在项目中发现的错误。利用git log,你可以快速可视化项目的历史记录,包括这个测试提交,如图 13-3 所示。

Git 日志显示具有单个提交的主分支和测试分支

图 13-3. Git 日志显示具有单个提交的主分支和测试分支

此日志非常有用,因为它为项目中的测试提交和每个其他提交提供了短哈希。如果你知道一个已知良好的历史提交,你可以使用git rebase重新安排项目,将测试提交移动到紧随该已知提交之后。

在图 13-3 中,测试提交哈希是d442759,而最后一个已知“良好”提交是916161c。要重新排序项目,从项目的初始提交(8550717)开始,使用git rebase交互式移动测试提交到项目的较早位置。确切的命令如示例 13-11 所示。

示例 13-11. 交互式git rebase以重新排序提交
$ git rebase -i 8550717

Git 将打开一个文本编辑器,并为每个可能的提交呈现相同的 SHA 哈希。你希望保留提交历史记录(因此保留pick关键字不变),但将测试提交移动到已知良好提交的后面,如图 13-4 所示。

交互式 Git 重新基础允许根据需要修改或重新排序提交

图 13-4. 交互式 Git 重新基础允许根据需要修改或重新排序提交

保存文件后,Git 将根据移动的提交重建项目历史。如果有冲突,请先在本地解决冲突并提交结果。然后利用git rebase --continue继续移动。完成后,你的项目将重组,以使新的测试用例立即出现在已知良好提交之后。

警告

已知的良好提交将具有相同的提交哈希,之前的所有提交也是如此。然而,移动的提交和随后的所有提交将被应用新的提交哈希。务必确保在任何后续的 Git 命令中使用正确的提交哈希!

重新基础完成后,使用git log --oneline再次查看你的提交历史,并参考的单元测试所属的新提交。接着,你可以从那个提交开始运行git bisect直到项目的HEAD,就像在例子 13-9 中一样。Git 将在每个可疑的提交上运行 PHPunit,直到找到第一个“坏”提交,生成类似于图 13-5 的输出。

Git bisect 在树中找到第一个“坏”提交

图 13-5. Git bisect 在树中找到第一个“坏”提交

有了第一个“坏”提交的信息,你可以查看那一点的差异,准确地找出错误是如何潜入你的项目的。此时,回到主分支并开始准备你的修复。

还是把你的新单元测试拉进来是个好主意。

注意

虽然你可以再次利用git rebase将你的测试提交移回原位,但是重新基础操作可能会改变项目历史的修改状态。相反,返回到main分支并为实际修复问题创建一个分支。拉入你的测试提交(也许通过git cherry-pick),并进行必要的更改。

另请参阅

git bisect的文档。

¹ 想了解更多关于错误处理的内容,请查阅第十二章。

² 关于 Composer 的更多信息,请参见第 15.1 节。

³ 关于严格类型的讨论详见第 3.4 节。

⁴ 想了解更多关于数据净化的内容,请参见第 9.1 节。

第十四章:性能调优

PHP 等动态解释语言以其灵活性和易用性而闻名,但不一定因为它们的速度。这部分是因为它们的类型系统工作方式的原因。当类型在运行时推断时,解析器无法准确知道如何执行某个操作,直到提供了数据。

考虑以下松散类型的 PHP 函数来将两个项目相加:

function add($a, $b)
{
    return $a + $b;
}

由于此函数未声明传入变量的类型或返回类型,它可能表现出多个签名。在 Example 14-1 中的所有方法签名都是调用前述函数的同样有效方式。

Example 14-1. 同一个函数定义的各种签名
add(int $a,    int $b):   int ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
add(float $a,  float $b): float ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
add(int $a,    float $b): float ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
add(float $a,  int $b):   float ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
add(string $a, int $b):   int ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
add(string $a, int $b):   float ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)

1

add(1, 2) 返回 int(3)

2

add(1., 2.) 返回 float(3)

3

add(1, 2.) 返回 float(3)

4

add(1., 2) 返回 float(3)

5

add("1", 2) 返回 int(3)

6

add("1.", 2) 返回 float(3)

上述示例说明了您可以编写一个单一函数,而 PHP 内部需要以多种方式处理它。直到它看到您提供的数据,语言才知道您实际需要函数的哪个版本,并且会在必要时内部将某些值转换为其他类型。在运行时,实际函数被编译为操作码(opcode),通过专用虚拟机在处理器上运行——PHP 将需要生成同一函数的多个版本的操作码来处理不同的输入和返回类型。

注释

PHP 利用的松散类型系统是使其易于学习但也容易出现致命编程错误的因素之一。本书已经在可能的地方利用严格类型来避免这些确切的陷阱。查阅 Recipe 3.4 了解更多关于在您自己的代码中使用严格类型的信息。

对于编译语言来说,这里表达的关于松散类型的问题将是微不足道的——只需将程序编译成多个 opcode 的分支即可。不幸的是,PHP 更像是一种解释语言;它根据应用程序的加载情况在需求时重新加载和重新编译您的脚本。幸运的是,多个代码路径的性能损耗可以通过两个现代特性来解决,这些特性内置于语言本身中:即时编译(JIT)和 opcode 的缓存。

即时编译

截至版本 8.0,PHP 配备了即时编译器(JIT compiler),能够立即提高程序执行速度,使应用性能更佳。它通过利用传递给处理脚本执行的虚拟机(VM)的实际指令的轨迹来实现这一点。当特定轨迹频繁调用时,PHP 将自动识别操作的重要性,并评估代码是否从编译中获益。

对同一段代码的后续调用将使用编译后的字节码而不是动态脚本,从而显著提升性能。基于Zend 在发布 PHP 8.0 时发布的度量数据,JIT 编译器的引入使得 PHP 基准测试套件的性能提高了最多三倍!

需要记住的一点是,JIT 编译主要有利于低级算法。这包括数字计算和原始数据操作。除了 CPU 密集型操作(例如图形处理或大量数据库集成)外,其他任何操作对这些更改的受益都不会太大。但是,知道 JIT 编译器的存在,您可以利用它以及以新的方式使用 PHP。

操作码缓存

提高性能的最简单方法之一,事实上 JIT 编译器所做的方法之一,是缓存昂贵的操作并引用结果,而不是一遍又一遍地执行这些操作。自版本 5.5 起,PHP 附带了一个可选的内存中缓存预编译字节码的扩展,称为OPcache。¹

请记住,PHP 主要是动态脚本解释器,并在程序启动时读取您的脚本。如果您频繁停止和启动应用程序,PHP 将需要重新编译您的脚本以将代码转换为计算机可读的字节码,以便正确执行。频繁的停止/启动可能导致频繁重新编译脚本,从而降低性能。然而,OPcache 允许您选择性地编译脚本,以在应用程序其余部分运行之前向 PHP 提供字节码。这样就无需每次加载和解析脚本了!

注意

自 PHP 8 及以上版本起,JIT 编译器只能在服务器上启用了 OPcache 时才能启用,因为它使用缓存作为其共享内存后端。然而,您不需要使用 JIT 编译器来使用 OPcache 本身。

JIT 编译和操作码缓存都是语言的低级性能改进,您可以在运行时轻松利用它们,但这并不是全部。还要了解如何计时执行用户定义的函数是至关重要的。这使得相对容易地识别业务逻辑中的瓶颈。全面对应用程序进行基准测试也有助于评估在新环境部署、语言新版本发布或后续依赖更新时的性能变化。

以下示例描述了如何对用户应用程序代码进行计时/基准测试,并如何利用语言级别的操作码缓存来优化应用程序和环境的性能。

14.1 计时函数执行

问题

您希望了解特定函数执行所需的时间,以识别优化的潜在机会。

解决方案

利用 PHP 内置的 hrtime() 函数在函数执行前后来确定函数运行的时间。例如:

$start = hrtime(true);

doSomethingComputationallyExpensive();

$totalTime = (hrtime(true) - $start) / 1e+9;

echo "Function took {$totalTime} seconds." . PHP_EOL;

讨论

hrtime() 函数将返回系统内置的高分辨率时间,从系统定义的任意时间点开始计数。默认情况下,它返回一个包含两个整数的数组——分别是秒和纳秒。将 true 传递给函数将返回总纳秒数,需要通过 1e+9 进行除法运算,将原始输出转换回人类可读的秒数。

一个稍微高级的方法是将计时机制抽象为装饰器对象。如第八章所述,装饰器是一种编程设计模式,允许您通过包装它在另一个类实现中来扩展单个函数调用(或整个类)的功能。在这种情况下,您希望触发使用 hrtime() 来计时函数的执行时间,而不改变函数本身。示例 14-2 中的装饰器将完全做到这一点。

示例 14-2. 用于测量函数调用性能的定时装饰器对象
class TimerDecorator
{
    private int $calls = 0;
    private float $totalRuntime = 0.;

    public function __construct(public $callback, private bool $verbose = false) {}

    public function __invoke(...$args): mixed ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    {
        if (! is_callable($this->callback)) {
            throw new ValueError('Class does not wrap a callable function!');
        }

        $this->calls += 1;
        $start = hrtime(true); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

        $value = call_user_func($this->callback, ...$args); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

        $totalTime = (hrtime(true) - $start) / 1e+9;
        $this->totalRuntime += $totalTime;

        if ($this->verbose) {
            echo "Function took {$totalTime} seconds." . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
        }

        return $value; ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
    }

    public function getMetrics(): array ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)
    {
        return [
            'calls'   => $this->calls,
            'runtime' => $this->totalRuntime,
            'avg'     => $this->totalRuntime / $this->calls
        ];
    }
}

1

__invoke() 魔术方法使类实例可调用,就像它们是函数一样。使用 ... 扩展运算符将捕获运行时传递的任何参数,以便稍后传递给包装方法。

2

装饰器使用的实际计时机制与解决方案示例中使用的相同。

3

假设包装函数可调用,PHP 将调用该函数并由于 ... 扩展运算符传递所有必要的参数。

4

此装饰器的实现可以实例化为具有打印运行时间到控制台的详细标志。

5

由于包装函数可能返回数据,您需要确保装饰器也返回该输出。

6

由于装饰的函数本身是一个对象,您可以直接公开额外的属性和方法。在这种情况下,装饰器跟踪可以直接检索的聚合指标。

假设与解决方案示例中相同的doSomethingComputationallyExpensive()函数是您要测试的函数,则上述装饰器可以包装该函数并像示例 14-3 中显示的那样生成指标。

示例 14-3. 利用装饰器计时函数执行时间
$decorated = new TimerDecorator('doSomethingComputationallyExpensive');

$decorated(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

var_dump($decorated->getMetrics()); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

1

由于装饰器类实现了__invoke()魔术方法,您可以像使用函数本身一样使用类的实例。

2

结果指标数组将包括调用次数计数,所有调用的总运行时间(秒),以及所有调用的平均运行时间(秒)。

同样地,您可以多次测试相同的包装函数,并从所有调用中获取汇总的运行时指标,如下所示:

$decorated = new TimerDecorator('doSomethingComputationallyExpensive');

for ($i = 0; $i < 10; $i++) {
    $decorated();
}

var_dump($decorated->getMetrics());

由于TimerDecorator类可以包装任何可调用函数,您可以像使用本地函数一样轻松地用它来装饰类方法。示例 14-4 中的类定义了一个静态方法和一个实例方法,两者都可以被装饰器包装。

示例 14-4. 用于测试装饰器的简单类定义
class DecoratorFriendly
{
    public static function doSomething()
    {
        // ...
    }

    public function doSomethingElse()
    {
        // ...
    }
}

示例 14-5 展示了在 PHP 中如何在运行时将类方法(静态和实例绑定的)作为可调用对象引用。任何可以表示为可调用接口的内容都可以被装饰器包装。

示例 14-5. 任何可调用接口都可以被装饰器包装
$decoratedStatic = new TimerDecorator(['DecoratorFriendly', 'doSomething']); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
$decoratedStatic(); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

var_dump($decoratedStatic->getMetrics());

$instance = new DecoratorFriendly();

$decoratedMember = new TimerDecorator([$instance, 'doSomethingElse']); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
$decoratedMember(); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

var_dump($decoratedMember->getMetrics());

1

通过传递类名和其静态方法的名称数组,可以将静态类方法用作可调用对象。

2

创建后,装饰的静态方法可以像调用任何其他函数一样调用,并以相同方式生成指标。

3

通过传递实例化对象和方法字符串名称的数组,可以将类实例的方法用作可调用对象。

4

类似于装饰的静态方法,装饰的实例方法可以像调用任何其他函数一样调用,以在装饰器内部填充指标。

一旦你知道一个函数运行多长时间,你可以专注于优化其执行。这可能涉及重构逻辑或使用替代方法来定义算法。

使用hrtime()最初需要 PHP 的 HRTime 扩展,但现在已默认作为核心函数捆绑。如果您使用的是早于 7.3 版本的 PHP 或者显式省略了扩展的预构建分发版本,则该函数本身可能会丢失。在这种情况下,您可以通过 PECL 自行安装扩展,或者改用类似的microtime()函数。²

microtime() 函数不是从任意时间点开始计算秒数,而是返回自 Unix 纪元以来的微秒数。此函数可用于代替 hrtime() 来评估函数执行时间,如下所示:

$start = microtime(true);

doSomethingComputationallyExpensive();

$totalTime = microtime(true) - $start;

echo "Function took {$totalTime} seconds." . PHP_EOL;

无论您是像解决方案示例中使用 hrtime(),还是像上面的代码片段中使用 microtime(),都要确保在读取结果数据时保持一致。这两种机制返回不同精度级别的时间概念,如果在任何输出格式化中混用,可能会导致混淆。

参见

PHP 文档关于 hrtime()microtime()

14.2 应用程序性能基准测试

问题

您希望对整个应用程序的性能进行基准测试,以便评估代码库、依赖项和底层语言版本的变化(例如性能退化)。

解决方案

利用像 PHPBench 这样的自动化工具来为您的代码进行仪表化,并定期进行性能基准测试。例如,下面的类被构建用来测试所有可用的哈希算法在不同字符串大小下的性能。³

/**
 * @BeforeMethods("setUp")
 */
class HashingBench
{
    private $string = '';

    public function setUp(array $params): void
    {
        $this->string = str_repeat('X', $params['size']);
    }

    /**
 * @ParamProviders({
 *     "provideAlgos",
 *     "provideStringSize"
 * })
 */
    public function benchAlgos($params): void
    {
        hash($params['algo'], $this->string);
    }

    public function provideAlgos()
    {
        foreach (array_slice(hash_algos(), 0, 20) as $algo) {
            yield ['algo' => $algo];
        }

    }

    public function provideStringSize() {
        yield ['size' => 10];
        yield ['size' => 100];
        yield ['size' => 1000];
    }

}

要运行默认的前述示例基准测试,首先克隆 PHPBench,然后安装 Composer 依赖项,最后运行以下命令:

$ ./bin/phpbench run --profile=examples --report=examples --filter=HashingBench

一旦基准测试完成,生成的输出将类似于 图 14-1 中的图表。

PHPBench 示例哈希基准测试的输出指标

图 14-1. PHPBench 示例哈希基准测试的输出指标

讨论

PHPBench 是一种有效的方法,用于在各种情况下对用户定义的代码进行性能基准测试。它可以在开发环境中使用,以评估新代码的性能水平,并可以直接集成到持续集成环境中。

PHPBench 自己的 GitHub Actions 配置 在每次拉取请求和更改时都运行应用程序的完整基准测试套件。这使项目维护者可以确保项目在支持的多个 PHP 版本的广泛矩阵中,每次引入更改时都能如预期地继续执行。

任何旨在包含自动化基准测试的项目必须首先使用 Composer。⁴ 您需要利用 Composer 的自动加载功能,使 PHPBench 知道从哪里获取类,但一旦设置完成,您可以按照您希望的方式构建您的项目。

假设您正在构建一个项目,利用值对象和哈希技术来保护它们存储的敏感数据。您的初始 composer.json 文件可能看起来像以下内容:

{
    "name": "phpcookbook/valueobjects",
    "require-dev": {
        "phpbench/phpbench": "¹.0"
    },
    "autoload": {
        "psr-4": {
            "Cookbook\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Cookbook\\Tests\\": "tests/"
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}

当然,您的项目代码将位于 src/ 目录中,任何测试、基准测试或其他内容将位于单独的 tests/ 目录中。仅供基准测试,您还需要创建一个专用的 tests/Benchmark/ 目录来跟踪名称空间和可筛选的代码。

您的第一个类,即您想要进行基准测试的类,是一个值对象,接受电子邮件地址并且可以像处理字符串一样轻松操作。但是,当它将其内容转储到像 var_dump()print_r() 这样的调试上下文时,它会自动对值进行哈希处理。

警告

电子邮件是足够常见的格式,即使对数据进行哈希处理也不足以保护其免受真正专注的攻击者。本文中的插图旨在演示如何使用哈希对数据进行混淆。这不应被视为全面的安全教程。

在您的新 src/ 目录中创建如示例 14-6 所定义的类,命名为 ProtectedString.php。此类包含很多内容——主要是几个实现的魔术方法,以确保没有办法意外序列化对象并访问其内部值。相反,一旦实例化 ProtectedString 对象,唯一访问其内容的方法是使用 ::getValue() 方法。其他任何方法将返回内容的 SHA-256 哈希。

示例 14-6. 受保护的字符串包装类定义
namespace Cookbook;

class ProtectedString implements \JsonSerializable
{
    protected bool $valid = true;

    public function __construct(protected ?string $value) {}

    public function getValue(): ?string
    {
        return $this->value;
    }

    public function equals(ProtectedString $other): bool
    {
        return $this->value === $other->getValue();
    }

    protected function redacted(): string
    {
        return hash('sha256', $this->value, false);
    }

    public function isValid(): bool
    {
        return $this->valid;
    }

    public function __serialize(): array
    {
        return [
            'value' => $this->redacted()
        ];
    }

    public function __unserialize(array $serialized): void
    {
        $this->value = null;
        $this->valid = false;
    }

    public function jsonSerialize(): mixed
    {
        return $this->redacted();
    }

    public function __toString()
    {
        return $this->redacted();
    }

    public function __debugInfo()
    {
        return [
            'valid' => $this->valid,
            'value' => $this->redacted()
        ];
    }
}

您希望验证所选哈希算法的性能。SHA-256 是绝对合理的选择,但是您希望通过性能对所有可能的序列化方式进行基准测试,以便在需要更改到不同哈希算法时,可以确保系统中没有性能退化。

要实际开始对此类进行基准测试,请在项目根目录创建以下 phpbench.json 文件:

{
    "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json",
    "runner.bootstrap": "vendor/autoload.php"
}

最后,创建一个实际的基准测试来计时用户可以序列化字符串的各种方式。在 tests/Benchmark/ProtectedStringBench.php 中定义的示例 14-7 应该存在。

示例 14-7. 对 ProtectedString 类进行基准测试
namespace Cookbook\Tests\Benchmark;

use Cookbook\ProtectedString;

class ProtectedStringBench
{
    public function benchSerialize()
    {
        $data = new ProtectedString('testValue');
        $serialized = serialize($data);
    }

    public function benchJsonSerialize()
    {
        $data = new ProtectedString('testValue');
        $serialized = json_encode($data);
    }

    public function benchStringTypecast()
    {
        $data = new ProtectedString('testValue');
        $serialized = '' . $data;
    }

    public function benchVarExport()
    {
        $data = new ProtectedString('testValue');
        ob_start();
        var_dump($data);
        $serialized = ob_end_clean();
    }
}

最后,您可以使用以下 shell 命令运行您的基准测试:

$ ./vendor/bin/phpbench run tests/Benchmark --report=default

此命令将生成与 图 14-2 中类似的输出,详细说明每个序列化操作的内存使用和运行时间。

PHPBench 对带哈希值对象序列化的输出。

图 14-2. PHPBench 输出,用于带哈希值对象序列化

您的应用程序的每个元素都可以且应该内置基准测试。这将大大简化在新环境(如新的服务器硬件或新发布的 PHP 版本下)中测试应用程序性能的过程。尽可能地将这些基准测试集成到持续集成运行中,并确保测试能够经常运行和记录。

另请参阅

PHPBench 项目的官方文档。

14.3 加速应用程序的 Opcode 缓存

问题

想要在你的环境中利用 opcode 缓存来提升应用程序的整体性能。

解决方案

在你的环境中安装共享的 OPcache 扩展并在php.ini中配置它。⁵ 由于它是一个默认的扩展程序,你只需更新你的配置来启用缓存。以下设置通常建议用于良好的性能,但应根据你特定的应用程序和基础设施进行测试:

opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.enable=1
opcache.enable_cli=1

讨论

当 PHP 运行时,解释器会读取你的脚本,并将你友好的 PHP 代码编译成机器易于理解的形式。不幸的是,由于 PHP 不是正式编译的语言,每次加载脚本时都必须进行这种编译。对于简单的应用程序,这并不是什么问题。但对于复杂的应用程序,这可能导致加载时间缓慢和重复请求的高延迟。

环绕这个特定问题优化应用程序的最简单方法是缓存编译后的字节码,以便在后续请求中重复使用。

为了在本地测试和验证 opcode 缓存的功能,你可以在启动脚本时使用命令行的-d标志。 -d标志在使用时会明确地覆盖配置值,否则会被php.ini设置(或保持其默认)。具体来说,示例 14-8 中的命令行标志将利用本地 PHP 开发服务器来完全禁用OPcache 运行应用程序。

示例 14-8. 启动不带 OPcache 支持的本地 PHP Web 服务器
$ php -S localhost:8080 -t public/ -dopcache.enable_cli=0 -dopcache.enable=0

类似地,你可以几乎使用完全相同的命令来显式启用opcode 缓存,以直接比较你的应用程序的行为和性能,如示例 14-9 所示。

示例 14-9. 启动带 OPcache 支持的本地 PHP Web 服务器
$ php -S localhost:8080 -t public/ -dopcache.enable_cli=1 -dopcache.enable=1

为了全面展示其工作原理,花些时间安装一个使用开源 Symfony 框架的演示应用程序。以下两条命令将在本地将演示应用程序克隆到/demosite/目录,并使用 Composer 安装所需的依赖项:

$ composer create-project symfony/symfony-demo demosite
$ cd demosite && composer install

接下来,使用内置的 PHP Web 服务器启动应用程序本身。使用示例 14-8 中的命令启动时没有 opcache 支持。该应用程序将在 8080 端口上可用,并且看起来像图 14-3。

Symfony 演示应用程序的加载页面。

图 14-3. Symfony 演示应用程序的加载页面

默认应用程序在本地运行,使用轻量级的 SQLite 数据库,因此应该加载得相当快。正如示例 14-10 中所示,你可以在终端中使用 cURL 命令有效地测试加载时间。

示例 14-10. 用于测量 Web 应用响应时间的简单 cURL 命令
curl -s -w "\nLookup time:\t%{time_namelookup}\
    \nConnect time:\t%{time_connect}\
    \nPreXfer time:\t%{time_pretransfer}\
    \nStartXfer time:\t%{time_starttransfer}\
    \n\nTotal time:\t%{time_total}\n" -o /dev/null \
    http://localhost:8080

如果未启用 opcode 缓存,Symfony 演示应用加载总时间约为 ~0.3677 秒。这非常快速,但是请注意,该应用完全在本地环境中运行。在生产环境中,使用远程数据库可能会慢一些,但这是一个坚实的基准。

现在,停止应用并启用 opcode 缓存,使用 示例 14-9 中定义的命令重新启动。然后从 示例 14-10 重新运行 cURL 性能测试。启用 opcode 缓存后,应用现在加载总时间约为 ~0.0371 秒。

这是一个相对简单的默认应用程序,但性能提升了 10 倍对系统性能来说是一个巨大的提升。应用加载得越快,您的系统在同一时间段内就能服务更多客户!

另请参阅

PHP 扩展 OPcache 的文档。

¹ PHP 8.0 中发布的新 JIT 编译器在底层使用了 OPcache,但即使 JIT 编译不可用,您仍然可以手动利用缓存来控制系统。

² 更多关于 PECL 和扩展管理的信息,请参考 15.4 配方。

³ 此特定示例来自默认随 PHPBench 发布的 示例基准

⁴ 更多关于使用 Composer 初始化项目的信息,请参阅 15.1 配方。

⁵ OPcache 是一个共享扩展,如果您的 PHP 是使用 --disable-all 标志编译以禁用默认扩展,则此扩展不存在。在这种情况下,您只能重新编译 PHP 并设置 --enable-opcache 标志,或者安装一个新编译并设置了此标志的 PHP 引擎版本。

第十五章:包和扩展

PHP 是一种高级语言,采用动态类型和内存管理,使软件开发对最终用户更加简便。不幸的是,计算机并不擅长处理高级概念,因此任何高级系统都必须建立在更低级的构建块之上。在 PHP 的情况下,整个系统都是用 C 语言编写并建立的。

由于 PHP 是开源的,您可以直接从 GitHub 下载整个语言的源代码。然后,您可以在自己的系统上从源代码构建语言,对其进行更改,或编写自己的本机(C 级)扩展。

在任何环境中,构建 PHP 源代码所需的其他各种软件包都是必需的。在 Ubuntu Linux 上,这些软件包包括:

pkg-config

用于返回有关已安装库的信息的 Linux 包

build-essential

包括 GNU 调试器、g++ 编译器和其他用于处理 C/C++ 项目的工具的元包

autoconf

用于生成配置代码包的脚本的宏包

bison

通用解析生成器

re2c

用于 C 和 C++ 的正则表达式编译器和开源词法分析器

libxml2-dev

用于 XML 处理的 C 级开发头文件

libsqlite3-dev

用于 SQLite 和相关绑定的 C 级开发头文件

您可以使用以下 apt 命令安装它们:

$ sudo apt install -y pkg-config build-essential autoconf bison re2c \
                      libxml2-dev libsqlite3-dev

一旦依赖项可用,您可以使用 buildconf 脚本生成配置脚本,然后 configure 本身将准备好构建环境。可以直接传递到 configure 控制如何设置环境的 多个选项。表 15-1 列出了一些最有用的选项。

表 15-1. PHP configure 选项

选项标志 描述
--enable-debug 编译时包含调试符号。对于开发核心 PHP 或编写新扩展非常有用。
--enable-libgcc 允许代码显式链接到 libgcc
--enable-php-streams 激活对实验性 PHP 流的支持。
--enable-phpdbg 启用交互式 phpdbg 调试器。
--enable-zts 启用线程安全性。
--disable-short-tags 禁用 PHP 短标签支持(例如 <?)。

理解如何构建 PHP 本身并不是使用它的先决条件。在大多数环境中,您可以直接从标准软件包管理器安装二进制发行版。例如,在 Ubuntu 上,您可以直接安装 PHP,方法如下:

$ sudo apt install -y php

然而,了解如何从源代码构建 PHP 对于希望更改语言行为、包括非捆绑扩展或将来编写自己的本机模块的人来说是重要的。

标准模块

默认情况下,PHP 使用其自己的扩展系统来支持语言的核心功能。除了核心模块外,各种扩展也直接捆绑在 PHP 中。¹ 这些包括以下内容:

  • BCMath 用于任意精度数学计算

  • FFI(外部函数接口)用于加载共享库并调用其中的函数

  • PDO(PHP 数据对象)用于抽象化各种数据库接口

  • SQLite3 用于直接与 SQLite 数据库交互

标准模块已与 PHP 捆绑,并可通过更改您的php.ini配置立即包含。外部扩展,例如对 Microsoft SQL Server 的 PDO 支持,也可用,但必须单独安装和激活。像 PECL 这样的工具,在配方 15.4 中讨论,使得在任何环境中安装这些模块变得简单。

库/Composer

除了语言的本机扩展之外,您还可以利用Composer,这是 PHP 最流行的依赖管理器。任何 PHP 项目都可以(而且可能应该)定义为 Composer 模块,方法是包含一个描述项目及其结构的composer.json文件。即使您不利用 Composer 将第三方代码拉入项目中,包含这样一个文件也有两个关键优势:

  • 您(或其他开发人员)可以将您的项目作为另一个项目的依赖项包含进去。这使得您的代码可移植,并鼓励函数和类定义的重用。

  • 一旦您的项目有了composer.json文件,您可以利用 Composer 的自动加载功能在项目中动态包含类和函数,而无需直接使用require()来直接加载它们。

本章中的配方解释了如何将您的项目配置为 Composer 包,以及如何利用 Composer 查找和包含第三方库。您还将学习如何通过 PHP 扩展社区库(PECL)和 PHP 扩展与应用程序库(PEAR)找到并包含语言的本机扩展。

15.1 定义 Composer 项目

问题

您想要启动一个使用 Composer 动态加载代码和依赖项的新项目。

解决方案

在命令行上使用 Composer 的init命令启动一个新项目,并带有一个composer.json文件。例如:

$ composer init --name ericmann/cookbook --type project --license MIT

在经过交互式提示(请求描述、作者、最小稳定性等)后,您将得到一个为您的项目定义良好的composer.json文件。

讨论

Composer 通过在 JSON 文档中定义项目信息,并利用这些信息构建额外的脚本加载器和集成来工作。新初始化的项目在这个文档中一开始并没有太多详细信息。在解决方案示例中使用 init 命令生成的 composer.json 文件最初看起来如下:

{
    "name": "ericmann/cookbook",
    "type": "project",
    "license": "MIT",
    "require": {}
}

这个配置文件没有定义任何依赖项、额外的脚本或自动加载。为了不仅仅用于标识项目和许可证,还需要开始添加内容。首先,需要定义自动加载器以引入项目代码。

对于这个项目,使用默认命名空间 Cookbook 并将所有代码放在名为 src/ 的目录中。然后,更新你的 composer.json 将该命名空间映射到该目录,如下所示:

{
    "name": "ericmann/cookbook",
    "type": "project",
    "license": "MIT",
    "require": {},
    "autoload": {
        "psr-4": {
            "Cookbook\\": "src/"
        }
    }
}

更新了 Composer 配置之后,可以在命令行上运行 composer dumpautoload 命令,强制 Composer 重新加载配置并定义自动化的源映射。完成后,Composer 将在项目中创建一个新的 vendor/ 目录。它包含两个关键组件:

  • 一个 autoload.php 脚本,在加载应用程序时需要 require()

  • 一个包含 Composer 代码加载例程的 composer 目录,用于动态引入你的脚本

为了进一步说明自动加载的工作原理,创建两个新文件。首先,在 src/ 目录下创建一个名为 Hello.php 的文件,其中包含 示例 15-1 中定义的 Hello 类。

示例 15-1. Composer 自动加载的简单类定义
<?php
namespace Cookbook;

class Hello
{
    public function __construct(private string $greet) {}

    public function greet(): string
    {
        return "Hello, {$this->greet}!";
    }
}

然后,在项目的根目录创建一个 app.php 文件,其内容如下,用于启动前述片段的执行:

<?php

require_once 'vendor/autoload.php';

$intro = new Cookbook\Hello('world');

echo $intro . PHP_EOL;

最后,回到命令行。由于向项目中添加了一个新类,需要再次运行 composer dumpautoload,以便 Composer 知道这个类的存在。然后,可以运行 php app.php 直接调用应用程序并生成以下输出:

$ php app.php
Hello, world!
$

你的项目或应用程序需要的任何类定义都可以采用同样的方式定义。基础的 Cookbook 命名空间将始终是 src/ 目录的根目录。如果希望为对象定义嵌套命名空间,比如 Cookbook\Recipes,则在 src/ 中创建一个同名目录(例如 Recipes/),以便 Composer 知道在应用程序中后续使用这些类定义时在哪里找到它们。

同样地,可以利用 Composer 的 require 命令将第三方依赖项导入应用程序。² 这些依赖项将在运行时像自定义类一样加载到应用程序中。

另请参见

关于 init 命令PSR-4 自动加载 的 Composer 文档。

15.2 查找 Composer 包

问题

您想找到一个库来完成特定任务,这样您就不需要花时间重新发明轮子,编写自己的实现。

解决方案

使用 PHP 包仓库 Packagist 来找到适合的库,并使用 Composer 将其安装到您的应用程序中。

讨论

许多开发人员发现他们花费大部分时间重新实现他们以前构建的逻辑或系统。不同的应用程序服务于不同的目的,但通常利用相同的基本构建块和基础来运行。

这是诸如面向对象编程等范式背后的关键驱动因素之一,在这些范式中,您将应用程序中的逻辑封装在可以单独操作、更新或甚至重复使用的对象中。与反复编写相同代码不同,您将其封装在一个可以在应用程序内重复使用甚至传输到下一个项目中的对象中。³

在 PHP 中,这些可重用的代码组件通常作为独立的库重新分发,可以使用 Composer 导入。就像 Recipe 15.1 展示了如何定义一个 Composer 项目并自动导入您的类和函数定义一样,同样的系统也可以用来将第三方逻辑添加到您的系统中。⁴

首先,确定特定操作或逻辑片段的需求。例如,假设您的应用程序需要与基于时间的一次性密码(TOTP)系统(如 Google Authenticator)集成。您需要一个 TOTP 库来完成这项工作。要找到它,请在浏览器中导航到 packagist.org,即 PHP 包仓库。主页看起来会有点像 Figure 15-1,突出显示了标题中的搜索栏。

Packagist 是通过 Composer 可安装的 PHP 包的免费分发方法

图 15-1. Packagist 是通过 Composer 可安装的 PHP 包的免费分发方法

然后搜索您需要的工具——在本例中是 TOTP。您将获得一个按热门程度排序的可用项目列表。您还可以利用包类型和各种标签,将搜索结果缩减到几个可能的库。

注意

在 Packagist 上,受欢迎程度由包下载量和 GitHub 星标定义。这是衡量项目在实际使用中频繁程度的一个好方法,但绝不是您应该依赖的唯一指标。许多开发人员仍然将第三方代码复制粘贴到其系统中,因此 Packagist 指标可能没有反映的数百万“下载”。同样,仅仅因为一个包很受欢迎或被广泛使用,并不意味着它对您的项目是安全的或合适的选择。请花时间仔细审查每个潜在的库,确保它不会为您的应用程序引入不必要的风险。

此外,如果你知道一个特定模块的作者,你信任他们的作品,你可以通过添加他们的用户名直接搜索。例如,搜索Eric Mann totp将得到由本书作者原创的特定 TOTP 实现。

一旦你已经确认并仔细审查了扩展你的应用程序所需的可用包,在 Recipe 15.3 中查看安装和管理它们的说明。

另请参阅

Packagist.org:PHP 包仓库。

15.3 安装和更新 Composer 包

问题

你已经发现了一个你想要包含在项目中的 Packagist 包。

解决方案

安装包通过 Composer(假设版本为 1.0)如下:

composer require "vendor/package:1.0"

讨论

Composer 与你本地文件系统中的两个文件一起工作:composer.jsoncomposer.lock。第一个用于描述你的项目、自动加载和许可证。具体来说,你在 Recipe 15.1 中定义的原始composer.json文件如下:

{
    "name": "ericmann/cookbook",
    "type": "project",
    "license": "MIT",
    "require": {},
    "autoload": {
        "psr-4": {
            "Cookbook\\": "src/"
        }
    }
}

运行示例解决方案中的require语句后,Composer 会更新你的composer.json文件,以添加指定的供应商依赖项。你的文件现在会如下所示:

{
    "name": "ericmann/cookbook",
    "type": "project",
    "license": "MIT",
    "require": {
        "vendor/package": "1.0"
    },
    "autoload": {
        "psr-4": {
            "Cookbook\\": "src/"
        }
    }
}

当你require一个包时,Composer 会执行三件事:

  1. 它检查确保包存在并获取最新版本(如果未指定版本)或你请求的版本。然后更新composer.json以将包存储在require键中。

  2. 默认情况下,Composer 会下载并安装你的包到项目中的vendor/目录中。它还更新自动加载程序脚本,因此该包将立即对项目中的其他代码可用。

  3. Composer 还在你的项目中维护一个composer.lock文件,明确标识你安装的每个包的版本。

在解决方案示例中,你明确指定了一个版本为 1.0 的包。如果你没有指定版本,Composer 将获取最新版本并在composer.json文件中使用。如果 1.0 确实是最新版本,Composer 将使用¹.0作为版本指示器,这将安装可能的维护版本(如 1.0.1 版本)。composer.lock文件跟踪安装的确切版本,因此即使你删除整个vendor/目录,通过composer install重新安装包时仍将获取相同的版本。

Composer 还会尽力找到适合您本地环境的最佳版本。它通过比较您的环境所需的 PHP 版本(用于运行工具的版本)与请求的包支持的版本来实现这一点。Composer 还尝试调和通过其他地方声明的传递依赖项显式声明的任何依赖关系。如果系统未能找到兼容的版本以包含,它将报告错误,以便您手动调和 composer.json 文件中列出的版本号。

警告

Composer 在其版本约束中遵循语义化版本。¹.0的要求将仅允许安装维护版本(例如,1.0.1、1.0.2)。大于等于的约束(例如,>=1.0)将安装任何稳定版本,版本号大于或等于 1.0。跟踪您如何定义版本约束非常重要,以防止意外导入由主要版本引入的破坏性包更改。有关如何定义版本约束的更多背景信息,请参考Composer 文档

在 Packagist 托管的具有公共代码的库不是唯一可以通过 Composer 包含的东西。此外,您还可以将系统指向 GitHub 等版本控制系统中托管的公共或私有项目。

要将 GitHub 仓库添加到您的项目中,首先在 composer.json 中添加一个repositories键,这样系统就知道从哪里查找。然后更新您的require键以拉取您需要的项目。运行composer update将从 GitHub 直接拉取包并将其包含在您的项目中,就像任何其他库一样。

例如,假设您想使用特定的 TOTP 库,但发现了一个小错误。首先,将 GitHub 仓库分叉到您自己的账户中。然后,在 GitHub 上创建一个分支来保存您的更改。最后,更新 composer.json 指向您的自定义分支和分支,如 Example 15-2 所示。

Example 15-2. 使用 Composer 从 GitHub 仓库拉取项目
{
    "name": "ericmann/cookbook",
    "type": "project",
    "license": "MIT",
    "repositories": 
        {
            "type": "vcs",
            "url": "\https://github.com/phpcookbookreader/package" ![1
        }
    ],
    "require": {
        "vendor/package": "dev-bugfix" ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    },
    "minimum-stability": "dev", ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    "autoload": {
        "psr-4": {
            "Cookbook\\": "src/"
        }
    }
}

1

确保您要包含的包是您可以访问的。此存储库可以是公共的也可以是私有的。如果是私有的,那么您将需要将 GitHub 个人访问令牌公开为环境变量,以便 Composer 具有拉取代码所需的适当凭据。

2

一旦定义了仓库,将一个新的分支规范添加到您的require块中。由于这不是标记或发布版本,请在分支名称前加上dev-,这样 Composer 就知道要拉取哪个分支。

3

要在项目中包含开发分支,您应该注明项目所需的最低稳定性,以避免在包含过程中可能出现的任何问题。

无论库以公共包、存储库的形式进入您的项目,还是作为硬编码的 ZIP 文件,都取决于您的开发团队。无论如何,任何可重复使用的包都可以轻松通过 Composer 加载,并向应用程序的其他部分公开。

参见

Composer 的require命令的文档。

15.4 安装本地 PHP 扩展

问题

您希望安装 PHP 的一个公共可用本地扩展,比如APC 用户缓存(APCu)

解决方案

在 PECL 存储库中查找扩展,并通过使用 PEAR 将其安装到系统中。例如,安装 APCu 的方法如下:

$ pecl install apcu

讨论

PHP 社区使用两种技术来向语言本身分发本地扩展:PEAR 和 PECL。它们之间的主要区别在于它们用于的包类型。

PEAR 本身可以捆绑几乎任何东西——它分发的包是由 PHP 代码组成的 gzip 压缩的 TAR 存档。因此,PEAR 类似于 Composer,可用于管理、安装和更新应用程序中使用的其他 PHP 库。⁵ 不过,PEAR 包与 Composer 包的加载方式有所不同,因此如果选择在两个包管理器之间混合使用,请注意。

PECL 是用 C 语言编写的 PHP 本地扩展库,与 PHP 本身相同的基础语言。PECL 使用 PEAR 来处理扩展的安装和管理;通过扩展引入的新功能可以像访问语言本身的本地函数一样访问。

实际上,现代版本的 PHP 中引入的许多 PHP 包最初是 PECL 扩展,开发人员可以选择安装以进行测试和初始集成。例如,钠加密库最初作为 PECL 扩展引入,后来在 PHP 核心分发的 7.2 版本中被添加。⁶

某些数据库(例如MongoDB)将它们的核心驱动程序作为本地 PECL 扩展发布。还提供各种网络、安全、多媒体和控制台操作库。所有这些都是用高效的 C 代码编写的,并且由于 PECL 和与 PHP 的绑定,表现得就像它们是语言本身的一部分一样。

不同于像 Composer 这样传递用户空间 PHP 代码的工具,PECL 直接将原始 C 代码交付给您的环境。install命令将执行以下操作:

  1. 下载扩展源码

  2. 编译源代码以适应您的系统,利用本地环境、其配置和系统架构以确保兼容性

  3. 在由您的环境定义的扩展目录内创建一个编译后的.so文件用于扩展

提示

尽管一些扩展看起来是自动启用的,但很可能你需要修改你系统的php.ini文件来显式包含该扩展。建议随后重新启动你的 Web 服务器(如 Apache、NGINX 或类似)以确保 PHP 按预期加载新扩展。

在 Linux 系统上,你甚至可能希望利用系统的软件包管理器安装预编译的本地扩展。在 Ubuntu Linux 系统上安装 APCu 通常就像这样简单:

$ sudo apt install php-apcu

无论是利用 PECL 直接构建扩展还是通过包管理器使用预编译的二进制文件,扩展 PHP 都是高效且简单的。这些扩展扩展了语言的功能,使你的最终应用程序显著更加实用。

另请参见

关于PECL 仓库PEAR 扩展打包系统的文档。

¹ 完整的捆绑和外部扩展列表可以在PHP 手册中找到。

² 想要了解如何使用 Composer 安装第三方库,请参见 Recipe 15.3。

³ 想要更深入地讨论面向对象编程和代码重用,请查看第八章。

⁴ 实际上关于第三方 Composer 包的安装将在 Recipe 15.3 中讨论。

⁵ 想要了解如何通过 Composer 安装包,请参见 Recipe 15.3。

⁶ 长篇讨论钠扩展将在第九章中进行。

第十六章:数据库

现代软件应用程序,特别是在 Web 上,使用状态以便正常运行。状态是表示应用程序当前状态的一种方式,针对特定请求——谁登录了、他们在哪个页面上、任何他们配置的偏好等等。

通常,代码是编写为基本上是无状态的。无论用户会话的状态如何,它都会以相同的方式运行(这就是使系统行为对于多个用户在应用程序中可预测的原因)。Web 应用程序部署时,也是以无状态方式进行的。

但是状态对于跟踪用户活动并根据用户继续与其交互的方式演变应用程序行为至关重要。为了使本来无状态的代码能够意识到状态,必须从某处检索该状态。

通常,这是通过使用数据库来完成的。数据库是存储结构化数据的高效方式。在 PHP 中,您通常会使用四种类型的数据库:关系数据库、键-值存储、图形数据库和文档数据库。

16.1 关系数据库

关系数据库将数据分解为对象及其相互关系。特定条目——比如一本书——被表示为表中的一行,其中的列包含有关书籍的数据。这些列可能包括标题、ISBN 和主题等。关于关系数据库的关键是要记住不同的数据类型存储在不同的表中。

book表中,一列可能是作者的名字,但更可能会有一个完全独立的author表。这个表会包含作者的姓名,也许还有他们的传记和邮箱地址。然后这两个表会有各自的ID列,而book表可能会有一个author_id列,用来引用author表。图 16-1 展示了这种数据库中表之间的关系。

关系数据库通过表和每个条目之间的引用来定义

图 16-1 关系数据库通过表和每个条目之间的引用来定义

关系数据库的例子包括MySQLSQLite

16.2 键-值存储

键-值存储比关系数据库简单得多——实际上就是一个将一个标识符(键)映射到某个存储值的单表。许多应用程序使用键-值存储作为简单的缓存工具,在高效的、通常是内存中的查找系统中跟踪原始值。

与关系数据库一样,存储在键-值系统中的数据可以进行类型化。如果你处理的是数字数据,大多数键-值系统会公开额外的功能来直接操作这些数据,例如,你可以在不需要先读取底层数据的情况下递增整数值。图 16-2 展示了这种数据存储中键和值之间的一对一关系。

键值存储被结构化为在离散标识符之间的查找,映射到可选类型的值

图 16-2. 键值存储被结构化为在离散标识符之间的查找,映射到可选类型的值

键值存储的示例包括RedisAmazon DynamoDB

16.3 图数据库

与专注于数据本身结构不同,图数据库专注于建模数据之间的关系(称为)。数据元素由节点封装,节点之间的边将它们连接在一起,并为系统中的数据提供语义上下文。

由于对数据之间关系的高度重视,图数据库非常适合进行诸如图 16-3 所示的可视化,展示这种结构中的边和节点。它们还提供了对数据关系高效查询的功能,使其成为高度互连数据的可靠选择。

图数据库优先考虑并说明数据(节点)之间的关系(边)

图 16-3. 图数据库优先考虑并说明数据(节点)之间的关系(边)。

图数据库的示例包括Neo4jAmazon Neptune

16.4 文档数据库

也可以将数据专门存储为非结构化或半结构化的文档。文档可以是结构良好的数据片段(如字面 XML 文档)或自由形式的字节块(如 PDF)。

文档存储与本章介绍的其他数据库类型之间的关键区别是结构—文档存储通常是非结构化的,并利用动态模式引用数据。在某些情况下,它们非常有用,但在使用上更加微妙。要深入了解基于文档的方法论,请阅读 Shannon Bradshaw 等人编写的MongoDB 权威指南(O’Reilly)。

以下的示例主要关注关系数据库及其在 PHP 中的使用。您将学习如何连接本地和远程数据库,如何在测试期间利用固定数据,甚至如何使用更复杂的对象关系映射(ORM)库处理您的数据。

16.5 连接到 SQLite 数据库

问题

您希望使用 SQLite 数据库的本地副本来存储应用程序数据。您的应用程序需要适当地打开和关闭数据库。

解决方案

根据需要使用基础 SQLite 类来打开和关闭数据库。为了效率,可以通过以下方式扩展基础类,自定义构造函数和析构函数:

class Database extends SQLite3
{
    public function __construct(string $databasePath)
    {
        $this->open($databasePath);
    }

    public function __destruct()
    {
        $this->close();
    }
}

然后,使用你的新类来打开数据库,运行一些查询,并在完成时自动关闭连接。例如:

$db = new Database('example.sqlite');

$create_query = <<<SQL
CREATE TABLE IF NOT EXISTS users (
 user_id INTEGER PRIMARY KEY,
 first_name TEXT NOT NULL,
 last_name TEXT NOT NULL,
 email TEXT NOT NULL UNIQUE
);
SQL;

$db->exec($create_query);

$insert_query = <<<SQL
INSERT INTO users (first_name, last_name, email)
VALUES ('Eric', 'Mann', 'eric@phpcookbook.local')
ON CONFLICT(email) DO NOTHING;
SQL;

$db->exec($insert_query);

$results = $db->query('SELECT * from users;');
while ($row = $results->fetchArray()) {
    var_dump($row);
}

讨论

SQLite 是一个快速完全自包含的数据库引擎,将其所有数据存储在单个磁盘文件中。PHP 自带一个扩展(在大多数发行版中默认启用),可以直接与该数据库进行接口,让你能够随意创建、写入和读取数据库。

默认情况下,open() 方法将会在指定路径下创建一个数据库文件(如果不存在)。此行为可以通过传递给方法调用的第二个参数中的标志来更改。默认情况下,PHP 将传递 SQLITE3_​OPEN_​READWRITE | SQLITE3_OPEN_CREATE,这将打开数据库以进行读取 写入,并在不存在时创建它。

Table 16-1 列出了三个可用的标志。

表 16-1. 用于打开 SQLite 数据库的可选标志

标志 描述
SQLITE3_OPEN_READONLY 仅打开一个数据库用于读取操作
SQLITE3_OPEN_READWRITE 打开一个数据库用于读写操作
SQLITE3_OPEN_CREATE 如果不存在则创建数据库

解决方案示例包括一个类,透明地在特定路径上打开一个 SQLite 数据库,如果不存在则创建一个。由于该类扩展了基础的 SQLite 类,因此可以在不同于标准 SQLite 实例的地方使用它来创建表、插入数据并直接查询该数据。类析构函数在实例移出作用域时自动关闭数据库连接。

注意

通常情况下,不需要显式关闭 SQLite 连接,因为 PHP 将在程序退出时自动关闭连接。但是,如果应用程序(或线程)可能会继续运行,建议关闭连接以释放系统资源。尽管这不会对本地基于文件的数据连接产生太大影响,但对于像 MySQL 这样的远程关系数据库来说,这是一个关键的组成部分。在数据库管理中保持一致性是一个良好的习惯。

SQLite 数据库在指定路径上以二进制文件的形式存在于磁盘上。如果你使用像 Visual Studio Code 这样的开发环境,你可以使用专门的扩展如 SQLite Viewer 来连接和可视化你的本地数据库。拥有多种查看数据库模式和数据的方式是验证你的代码是否按照预期工作的快速有效方法。

参见

PHP 关于 SQLite3 数据库扩展 的文档。

16.6 使用 PDO 连接到外部数据库提供者

问题

您希望使用 PDO 作为抽象层连接并查询远程 MySQL 数据库。

解决方案

首先,定义一个扩展核心PDO定义的类,处理创建和关闭连接,如下所示:

class Database extends PDO
{
    public function __construct($config = 'database.ini')
    {
        $settings = parse_ini_file($config, true);

        if (!$settings) {
            throw new RuntimeException("Error reading config: `{$config}`.");
        } else if (!array_key_exists('database', $settings)) {
            throw new RuntimeException("Invalid config: `{$config}`.");
        }

        $db = $settings['database'];
        $port = $db['port'] ?? 3306;
        $driver = $db['driver'] ?? 'mysql';
        $host = $db['host'] ?? '';
        $schema = $db['schema'] ?? '';
        $username = $db['username'] ?? null;
        $password = $db['password'] ?? null;

        $port = empty($port) ? '' : ";port={$port}";
        $dsn = "{$driver}:host={$host}{$port};dbname={$schema}";

        parent::__construct($dsn, $username, $password);
    }
}

前述类的配置文件需要采用 INI 格式。例如:

[database]
driver = mysql
host = 127.0.0.1
port = 3306
schema = cookbook
username = root
password = toor

配置文件一旦配置完成,您可以通过 PDO 提供的抽象直接查询数据库,如下所示:

$db = new Database();

$create_query = <<<SQL
CREATE TABLE IF NOT EXISTS users (
 user_id int NOT NULL AUTO_INCREMENT,
 first_name varchar(255) NOT NULL,
 last_name varchar(255) NOT NULL,
 email varchar(255) NOT NULL UNIQUE,
 PRIMARY KEY (user_id)
);
SQL;

$db->exec($create_query);

$insert_query = <<<SQL
INSERT IGNORE INTO users (first_name, last_name, email)
VALUES ('Eric', 'Mann', 'eric@phpcookbook.local');
SQL;

$db->exec($insert_query);

foreach($db->query('SELECT * from users;') as $row) {
    var_dump($row);
}

讨论

解决方案示例利用与 Recipe 16.5 相同的表结构和数据,只是它使用了 MySQL 数据库引擎。MySQL是一个由 Oracle 维护的流行的免费开源数据库引擎。据维护者称,它支持许多流行的 Web 应用程序,包括像 Facebook、Netflix 和 Uber 这样的大型平台。事实上,MySQL 如此普及,以至于许多系统维护者默认在 PHP 中包含 MySQL 扩展,使连接系统变得更加简单,免去了自己安装新驱动程序的麻烦。

注意

与 Recipe 16.5 中的解决方案示例不同,PHP 没有明确关闭 PDO 连接的方法。相反,将数据库句柄(例如解决方案示例中的$db)的值设置为null,使对象超出范围并触发 PHP 关闭连接。

在解决方案示例中,首先定义了一个类来包装 PDO 本身,并抽象连接到 MySQL 数据库。虽然这不是必需的,但就像 Recipe 16.5 一样,这是一个保持数据连接清洁的好方法。一旦建立了连接,您可以高效地创建表格、插入数据并读取数据。

警告

解决方案示例假设在连接到的数据库中已经存在cookbook模式。除非您已经直接创建了该模式,否则这种隐式连接将失败,并显示PDOException complaining about an unknown database。在尝试操作之前,您必须在 MySQL 数据库中首先创建该模式至关重要。

与 SQLite 不同,MySQL 数据库需要一个完全独立的应用程序来托管数据库并连接到您的应用程序。通常,此应用程序将在完全不同的服务器上运行,并且您的应用程序将通过 TCP 连接到指定端口(通常是 3306)。对于本地开发和测试,仅需使用Docker在应用程序旁边创建一个本地 MySQL 数据库就足够了。以下一行命令将在 Docker 容器内创建一个本地 MySQL 数据库,监听默认端口 3306,并允许通过root用户和密码toor进行连接:

$ docker run --name db -e MYSQL_ROOT_PASSWORD=toor -p 0.0.0.0:3306:3306 -d mysql
注意

无论是在本地使用 Docker 中的 MySQL 还是在生产环境中使用 MySQL,官方容器镜像详细介绍了各种可用于自定义和安全环境的配置设置。

当容器首次启动时,不会有任何可用于查询的模式(这意味着示例解决方案的其余部分尚不可用)。要创建一个默认的cookbook模式,需要连接到数据库并创建模式。在示例 16-1 中,$字符表示 shell 命令,mysql>提示表示在数据库本身内运行的命令。

示例 16-1. 使用 MySQL CLI 创建数据库模式
$ mysql --host 127.0.0.1 --user root --password=toor ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

mysql> create database `cookbook`; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
mysql> exit ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

Docker 容器在本地环境通过 TCP 暴露 MySQL,这需要您指定一个本地主机 IP 地址。如果未这样做,默认情况下 MySQL 会尝试通过 Unix 套接字连接,但在这种情况下会失败。您还必须传递用户名和密码以便连接。

2

连接到数据库引擎后,可以在其中创建新的模式。

3

要断开 MySQL 连接,只需输入exitquit并按 Enter 键。

如果您没有安装 MySQL 命令行,还可以利用 Docker 连接到运行的数据库容器并使用命令行界面。示例 16-2 展示了如何利用 Docker 容器封装 MySQL CLI 来创建数据库模式。

示例 16-2. 使用托管在 Docker 中的 MySQL CLI 创建数据库模式
$ docker exec -it db bash ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$ mysql --user root --password=toor ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

mysql> create database `cookbook`; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
mysql> exit

$ exit ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

1

由于 MySQL 已作为名为db的容器在本地运行,您可以通过引用相同的名称在容器内部交互地执行命令。Docker 的it标志指示您希望在交互式终端会话中执行命令。bash命令是您明确要执行的命令;其结果是您像直接连接到其中一样在容器内部获得了一个交互式终端会话。

2

在容器内部连接到数据库就像使用 MySQL CLI 一样简单。您无需引用主机名,因为在容器内部,可以直接连接到公开的 Unix 套接字。

3

创建表并退出 MySQL CLI 的过程与前面的示例完全相同。

4

退出 CLI 后,仍然需要退出 Docker 容器中的交互式bash会话,以返回到主终端。

使用 PDO 连接数据库而不是直接使用驱动程序的主要优点有两个:

  1. PDO 接口在每种数据库技术中都是相同的。虽然您可能需要重构特定查询以适应一个或另一个数据库引擎(比较这个解决方案中的 CREATE TABLE 语法与 Recipe 16.5 中的语法),但无需重构围绕连接、语句执行或查询处理的 PHP 代码。PDO 是一个数据访问抽象层,无论您在应用程序中使用的数据库是什么,都能提供相同的访问和管理方式。

  2. PDO 支持通过在打开连接时将一个真值传递给 PDO::ATTR_PERSISTENT 键作为选项来使用持久连接。持久连接将在 PDO 实例超出范围并且脚本执行完成后仍然保持打开状态。当 PHP 尝试重新打开连接时,系统将查找现有的连接并在存在时重用它。这有助于提高长时间运行的多租户应用程序的性能,否则打开多个冗余连接将对数据库本身造成伤害。(有关持久数据库连接的更多信息,请参阅 PHP 手册的全面文档。)

除了这两个优点之外,PDO 还支持准备语句的概念,有助于减少恶意 SQL 注入的风险。有关准备语句的更多信息,请参阅 Recipe 16.7。

参见

有关 PDO 扩展的完整文档

16.7 为数据库查询净化用户输入

问题

您希望将用户输入传递到数据库查询中,但不完全信任用户输入不会有恶意行为。

解决方案

在 PDO 中利用准备语句自动清理用户输入之前将其传递到查询中如下:

$db = new Database();

$insert_query = <<<SQL
INSERT IGNORE INTO users (first_name, last_name, email)
VALUES (:first_name, :last_name, :email);
SQL;

$statement = $db->prepare($insert_query);

$statement->execute([
    'first_name' => $_POST['first'],
    'last_name'  => $_POST['last'],
    'email'      => $_POST['email']
]);

foreach($db->query('SELECT * from users;') as $row) {
    var_dump($row);
}

讨论

清理用户输入的概念在早期作为 Recipe 9.1 的一部分进行了讨论,该部分使用显式过滤器清理/验证潜在的不受信任的输入。虽然这种方法非常有效,但在未来更新时开发人员很容易忘记在用户输入时包含清理过滤器。因此,显式准备查询以防止恶意 SQL 注入是更安全的做法。

考虑一个用于查找用户数据以显示个人资料信息的查询。这样的查询可能利用用户电子邮件地址作为索引,以区分试图仅显示当前用户信息的另一个用户。例如:

SELECT * FROM users WHERE email = ?;

在 PHP 中,您需要传递当前用户的电子邮件地址,以便查询有效运行。使用 PDO 的一个天真的方法可能看起来像 Example 16-3。

Example 16-3. 使用字符串插值的简单查询
$db = new Database();

$statement = "SELECT * FROM users WHERE email = '{$_POST['email']}';";

$results = $db->query($statement);
var_dump($results);

如果用户只提交他们自己的用户名(例如,eric@phpcookbook.local),那么这个查询将返回该用户的相应数据。不过,不能保证最终用户是可信的,他们可能会提交一个恶意语句,希望将任意语句注入到您的数据库引擎中。了解提交的电子邮件地址如何插入到 SQL 语句中,攻击者可以提交 ' OR 1=1;-- 替代。

这个字符串将完成引号(WHERE email = ''),添加一个复合布尔语句来匹配任何结果(OR 1=1),并明确注释掉后续的任何字符。其结果是,您的查询将返回所有用户的数据,而不是仅仅是发出请求的单个用户。

同样,恶意用户可以使用相同的方法来注入任意的INSERT语句(写入新数据),而您只期望读取信息。他们还可以非法更新现有数据,删除字段,或者以其他方式损害数据存储的可靠性。

SQL 注入非常危险。它在软件世界中也是异常常见的——以至于注入被公认为是开放全球应用安全项目(OWASP)十大安全风险中的第三位最常遇到的应用安全风险

幸运的是,在 PHP 中,防止注入也很容易!

解决方案示例介绍了 PDO 的预处理语句接口。与使用用户提供数据插入字符串不同,您在查询中插入命名占位符。这些占位符应以单个冒号开头,并且可以是您能想象到的任何有效名称。当查询运行到数据库时,PDO 将这些占位符替换为运行时传递的字面值。

注意

也可以使用问号字符作为占位符,并根据它们在简单数组中的位置传递值到准备好的语句。然而,在以后的重构过程中很容易混淆元素的位置,因此高度不建议使用这种简单的方法。请务必始终在准备语句时使用命名参数,以避免混淆并未来保护您的代码。

预处理语句适用于数据操作语句(插入、更新、删除)和任意查询。使用预处理语句,可以将 示例 16-3 中的简单查询重写为 示例 16-4。

示例 16-4. 使用预处理语句的简单查询
$db = new Database();

$query = "SELECT * FROM users WHERE email = :email;";
$statement = $db->prepare($query);

$statement->execute(['email' => $_POST['email']]);

$results = $statement->fetch();
var_dump($results);

此代码利用 PDO 自动转义用户输入,并将该值作为字面值传递给数据库引擎。如果用户确实提交了他们的电子邮件地址,查询将如预期般运行并返回预期的结果。

如果用户提交恶意有效负载(例如,' OR 1=1;--,如前所述),语句准备将明确转义传递给数据库的引号字符。这将导致查找一个完全匹配恶意有效负载的电子邮件地址(并且不存在),从而产生零用户数据的结果。

参见

有关 PDO 的prepare()方法的文档

16.8 模拟数据用于集成测试与数据库

问题

你希望在生产环境中利用数据库进行存储,但在运行自动化测试时模拟该数据库接口。

解决方案

使用存储库模式作为业务逻辑与数据库持久性之间的抽象。例如,定义一个存储库接口,如示例 16-5 所示。

示例 16-5. 数据存储库接口定义
interface BookRepository
{
    public function getById(int $bookId): Book;
    public function list(): array;
    public function add(Book $book): Book;
    public function delete(Book $book): void;
    public function save(Book $book): Book;
}

然后,使用上述接口定义具体的数据库实现(例如利用 PDO)。同时使用相同的接口定义模拟实现,返回可预测的静态数据而不是远程系统的实时数据。参见示例 16-6。

示例 16-6. 带有模拟数据的存储库接口实现
class MockRepository implements BookRepository
{
    private array $books;

    public function __construct()
    {
        $this->books = [
            new Book(id: 0),
            new Book(id: 1),
            new Book(id: 2)
        ];
    }

    public function getById(int $bookId): Book
    {
        return $this->books[$bookId];
    }

    public function list(): array
    {
        return $this->books;
    }

    public function add(Book $book): Book
    {
        $book->id = end(array_keys($this->books)) + 1;
        $this->books[] = $book;

        return $book;
    }

    public function delete(Book $book): void
    {
        unset($this->books[$book->id]);
    }

    public function save(Book $book): Book
    {
        $this->books[$book->id] = $book;
    }
}

讨论

解决方案示例介绍了一种通过抽象来将业务逻辑与数据层分离的简单方法。通过利用数据存储库来包装数据库层,你可以发布同一接口的多个实现。在生产应用中,你的实际存储库可能看起来类似于示例 16-7。

示例 16-7. 存储库接口的具体数据库实现
class DatabaseRepository implements BookRepository
{
    private PDO $dbh;

    public function __construct($config = 'database.ini')
    {
        $settings = parse_ini_file($config, true);

        if (!$settings) {
            throw new RuntimeException("Error reading config: `{$config}`.");
        } else if (!array_key_exists('database', $settings)) {
            throw new RuntimeException("Invalid config: `{$config}`.");
        }

        $db = $settings['database'];
        $port = $db['port'] ?? 3306;
        $driver = $db['driver'] ?? 'mysql';
        $host = $db['host'] ?? '';
        $schema = $db['schema'] ?? '';
        $username = $db['username'] ?? null;
        $password = $db['password'] ?? null;

        $port = empty($port) ? '' : ";port={$port}";
        $dsn = "{$driver}:host={$host}{$port};dbname={$schema}";

        $this->dbh = new PDO($dsn, $username, $password);
    }

    public function getById(int $bookId): Book
    {
        $query = 'Select * from books where id = :id;';

        $statement = $this->dbh->prepare($query);
        $statement->execute(['id' => $bookId]);

        $record = $statement->fetch();
        if ($record) {
            return Book::fromRecord($record);
        }

        throw new Exception('Book not found');
    }

    public function list(): array
    {
        $books = [];

        $records = $this->dbh->query('select * from books;');
        foreach($record as $book) {
            $books[] = Book::fromRecord($book);
        }

        return $books;
    }

    public function add(Book $book): Book
    {
        $query = 'insert into books (title, author) values (:title, :author);';

        $this->dbh->beginTransaction();
        $statement = $this->dbh->prepare($query);
        $statement->execute([
            'title'  => $book->title,
            'author' => $book->author,
        ]);
        $this->dbh->commit();

        $book->id = $this->dbh->lastInsertId();

        return $book;
    }

    public function delete(Book $book): void
    {
        $query = 'delete from books where id = :id';

        $this->dbh->beginTransaction();
        $statement = $this->dbh->prepare($query);
        $statement->execute(['id' => $book->id]);
        $this->dbh->commit();
    }

    public function save(Book $book): Book
    {
        $query =
            'update books set title = :title, author = :author where id = :id;';

        $this->dbh->beginTransaction();
        $statement = $this->dbh->prepare($query);
        $statement->execute([
            'title' => $book->title,
            'author' => $book->author,
            'id' => $book->id
        ]);
        $this->dbh->commit();

        return $book;
    }
}

示例 16-7 实现与解决方案示例中的模拟存储库相同的接口,但连接到实时 MySQL 数据库并在该独立系统中操作数据。实际情况下,你的生产代码将使用实现而不是模拟实例。但在测试运行时,只要你的业务逻辑期望实现BookRepository接口的类,你可以轻松地将DatabaseRepository替换为MockRepository实例。

假设你正在使用Symfony 框架。你的应用将建立在控制器之上,利用依赖注入处理外部集成。例如,对于管理多本书的库 API,你可能会定义一个BookController,大致如下:

class BookController extends AbstractController
{
    #[Route('/book/{id}', name: 'book_show')]
    public function show(int $id, BookRepository $repo): Response
    {
        $book = $repo->getById($id);

        // ...
    }
}

前面代码的优点在于控制器不关心您是传递 MockRepository 实例还是 DataRepository 实例。这两个类都实现了相同的 BookRepository 接口,并公开了具有相同签名的 getByID() 方法。对于业务逻辑来说,功能是相同的 —— 除了一个会让您的应用程序访问远程数据库以检索(和可能操纵)数据,而另一个则使用一组静态、完全确定性的虚假数据。

注意

Symfony 默认的数据抽象层称为 Doctrine,并默认使用仓储模式。Doctrine 提供了一个丰富的抽象层,支持多个 SQL 方言,包括 MySQL,无需通过 PDO 手动连接查询。它还附带了一个命令行实用程序,可以自动为您编写存储对象(称为 entities)和仓储的 PHP 代码!

在编写测试时,确定性和虚假数据是优越的,因为它们始终保持一致,这意味着您的测试非常可靠。这也意味着,如果有人在本地做了轻微的配置错误,您不会意外地覆盖真实数据库中的数据。

一个额外的优势是您的测试运行速度会更快。模拟数据接口消除了在应用程序和独立数据库之间发送数据的需求,显著缩短了与任何数据相关功能调用的延迟。尽管如此,您可能仍希望完善一个单独的集成测试套件来测试那些远程集成,并且您将需要一个真实的数据库来使这个独立的测试套件可用。

参见

查看 Recipe 8.7 以获取有关类、接口和继承的更多信息。查看 Symfony 文档以获取有关 controllersdependency injection 的更多信息。

16.9 使用 Eloquent ORM 查询 SQL 数据库

问题

您希望管理数据库架构及其包含的数据,而不是手动编写 SQL。

解决方案

使用 Laravel 默认的 ORM,Eloquent,动态定义您的数据对象和架构,如 Example 16-8 所示。

示例 16-8. Laravel 使用的表定义
Schema::create('books', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('author');
});

该代码可用于动态创建一个用于存储书籍的表,无论使用何种 SQL 与 Eloquent 结合使用。一旦表存在,Eloquent 可以使用以下类来对其中的数据进行建模 Example 16-9。

示例 16-9. Eloquent 模型定义
use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    use HasFactory;

    public $timestamps = false;
}

讨论

Doctrine ORM 在 Recipe 16.8 中简要提到,利用仓储模式将存储在数据库中的对象映射到业务逻辑中的表示形式。这在 Symfony 框架中很有效,但只是在实际应用程序中建模数据的一种方法。

开源 Laravel 框架本身是建立在 Symfony 和其他组件之上,而是使用Eloquent ORM来建模数据。与 Doctrine 不同,Eloquent 基于活动记录设计模式,其中数据库中的表直接与用于表示该表的对应模型相关联。与通过单独的存储库创建/读取/更新/删除模型不同,建模对象通过其自身的方法进行直接操作。

Tip

有些开发团队对项目中接受的设计模式可能有很强的意见。尽管 Laravel 框架很流行,但许多开发者认为活动记录方法在数据建模中是反模式,即应该避免的方法。请务必确保您的开发团队在您项目中使用的抽象方面达成一致,因为混合多种数据访问模式可能会令人困惑,并且会导致严重的维护问题。

由 Eloquent 公开的模型类非常简单,如解决方案示例中简洁的示例所示。但它们非常动态——模型类本身不需要直接定义模型的实际属性。相反,Eloquent 在实例化模型类时会自动从底层表中读取和解析任何列和数据类型,并将这些添加为模型类的属性。

例如,Example 16-8 中的表定义了三列:

  • 一个整数 ID

  • 一个字符串标题

  • 一个字符串作者名字

当 Eloquent 直接读取这些数据时,它会在 PHP 中有效地创建对象,看起来类似于以下内容:

class Book
{
    public int    $id;
    public string $title;
    public string $author;
}

实际类将提供各种其他方法,如save(),但其余部分包含数据在 SQL 表中的直接表示。要在数据库中创建新记录,而不是直接编辑 SQL,您只需创建一个新对象并保存,如 Example 16-10 所示。

Example 16-10. 使用 Eloquent 创建数据库对象
$book = new Book;
$book->title = 'PHP Cookbook';
$book->author = 'Eric Mann';

$book->save();

更新数据同样简单:使用 Eloquent 检索要更改的对象,在 PHP 中进行更改,然后调用对象的save()方法直接持久化更新。Example 16-11 演示了在数据库中更新对象以将特定字段中的一个值替换为另一个值。

Example 16-11. 使用 Eloquent 在原地更新元素
Book::where('author', 'Eric Mann')
    ->update(['author', 'Eric A Mann']);

使用 Eloquent 的主要优势在于,您可以像操作本机 PHP 对象一样处理数据对象,而无需手动编写、管理或维护 SQL 语句。ORM 的更强大特性是,它会帮助您转义用户输入,这意味着不再需要像 Recipe 16.7 中介绍的额外步骤。

虽然直接利用 SQL 连接(带或不带 PDO)是开始与数据库工作的快速有效方式,但全功能 ORM 的强大功能将使您的应用程序更易于处理。这一点在初始开发和重构时都是如此。

参见

Eloquent ORM 的文档。

第十七章:异步 PHP

许多基本的 PHP 脚本处理操作是同步的——意味着脚本从开始到结束运行一个单体过程,并且一次只做一件事情。然而,在 PHP 世界中,更复杂的应用程序已经变得司空见惯,因此也需要更先进的操作模式。特别是,异步编程迅速成为 PHP 开发者的新兴概念。学习如何在你的脚本中同时执行两个(或多个)任务对于构建现代化应用程序至关重要。

在讨论异步编程时经常会涉及两个词汇:并发并行。当大多数人谈论并行编程时,他们实际上指的是并发编程。通过并发,您的应用程序可以同时做两件事情,但不一定是同时进行。可以想象一个咖啡师同时为多位顾客提供服务——咖啡师在多任务处理和制作多种饮品,但实际上只能同时制作一种饮品。

使用并行操作时,您可以同时执行两个不同的任务。想象在咖啡店的柜台上安装了一个滴漏咖啡机。一些顾客仍然由咖啡师服务,但其他顾客可以从另一台机器中并行地得到他们的咖啡因。图 17-1 通过咖啡师类比描绘了并发和并行操作。

并行与并发操作模式

图 17-1. 并行与并发操作模式
注意

还有第三个并行并发操作的概念,即两个工作流同时进行(并行),同时还在各自的工作流中进行多任务处理(并发)。虽然这种复合概念很有用,但本章节专注于单独讨论这两个概念。

在野外找到的大多数 PHP,无论是现代的还是传统的,都是为了利用单线程执行而编写的。代码既不是并发的也不是并行的。事实上,许多开发人员在想要利用并发或并行概念时完全避开 PHP,转而使用像 JavaScript 或 Go 这样的语言。不过,现代 PHP 完全支持执行模式——无论是否使用额外的库。

库和运行时

PHP 对并行和并发操作的原生支持相对较新,并且在实践中使用起来具有一定的难度。然而,有几个库可以抽象出并行工作的难度,使得构建真正的异步应用程序变得更加简单。

AMPHP

AMPHP 项目是一个可以通过 Composer 安装的框架,为 PHP 提供事件驱动的并发支持。AMPHP 提供了丰富的函数和对象,使您能够完全掌握异步 PHP。具体而言,AMPHP 提供了完整的事件循环以及有效的 promise、协程、异步迭代器和流的抽象。

ReactPHP

与 AMPHP 类似,ReactPHP 是一个可以通过 Composer 安装的库,提供事件驱动功能和对 PHP 的抽象。它提供了事件循环,同时还提供了完全功能的异步服务器组件,如套接字客户端和 DNS 解析器。

Open Swoole

Open Swoole 是一个可以通过 PECL 安装的低级 PHP 扩展。类似于 AMPHP 和 ReactPHP,Open Swoole 提供了一个异步框架,并实现了承诺和协程。由于它是一个编译扩展(而不是 PHP 库),Open Swoole 的性能显著优于各种替代方案。它还支持在代码中实现真正的并行性,而不仅仅是任务的并发执行。

RoadRunner

RoadRunner 项目 是一个用 Go 实现的替代 PHP 运行时环境。它提供了与您熟悉的相同 PHP 接口,但提供了自己的应用服务器和异步进程管理器。RoadRunner 赋予您能力,可以在内存中保留整个应用程序,并在需要时并行调用应用程序执行的原子进程。

Octane

2021 年,Web 应用程序框架 Laravel 推出了一个名为 Octane 的新项目,利用 Open Swoole 或 Roadrunner 来“大幅提升您的应用性能”。与像 AMPHP 或 ReactPHP 这样的框架级工具允许您有意识地编写异步代码不同,Octane 利用 Open Swoole 或 RoadRunner 的异步基础来加速现有基于 Laravel 的应用程序的操作。¹

理解异步操作

要完全理解异步 PHP,您至少需要理解两个具体概念:承诺(promises)和协程(coroutines)。

承诺

在软件中,承诺 是由异步函数返回的对象。不同于表示离散值,承诺代表操作的整体状态。当函数首次返回时,承诺将没有固有值,因为操作本身尚未完成。相反,它将处于待定状态,指示程序在后台完成异步操作时应执行其他操作。

当操作完成时,承诺将被满足或拒绝。当事情顺利进行并返回一个具体值时,承诺将处于满足状态;当某些事情失败并返回错误时,承诺将处于拒绝状态。

AMPHP 项目通过使用生成器实现承诺,并将满足和拒绝状态捆绑到承诺对象的 onResolve() 方法中。例如:

function doSomethingAsync(): Promise
{
    // ...
}

doSomethingAsync()->onResolve(function (Throwable $error = null, $value = null) {
    if ($error) {
        // ...
    } else {
        // ...
    }
});

另外,ReactPHP 项目实现了与 JavaScript 相同的 承诺规范,使您可以使用可能对 Node.js 程序员熟悉的 then() 结构。例如:

function doSomethingAsync(): Promise
{
    // ...
}

doSomethingAsync()->then(function ($value) {
    // ...
}, function ($error) {
    // ...
});
注意

虽然 AMPHP 和 ReactPHP 针对 promises 提供的 API 有些独特,但它们之间是相当可互操作的。AMPHP 明确不遵循 JavaScript 风格的 promise 抽象,以便充分利用 PHP 生成器。然而,它确实接受 ReactPHP 的PromiseInterface实例,在与其自身的Promise实例一起使用时。

这两个 API 都非常强大,两个项目都为 PHP 提供了高效的异步抽象。然而,为了简单起见,本书重点介绍了 AMPHP 实现的异步代码,因为它们更符合核心 PHP 功能。

协程

协程是一个可以被中断以允许其他操作继续的函数。特别是在 PHP 中,使用 AMPHP 框架,协程是通过利用yield关键字实现的生成器。

虽然传统生成器使用yield关键字作为迭代器的一部分返回值,但在 AMPHP 中,它使用相同的关键字作为协程中的功能性中断。值仍然被返回,但协程本身的执行被中断以允许其他操作(如其他协程)继续。当在协程中返回一个 promise 时,协程会跟踪 promise 的状态,并在解决时自动恢复执行。

例如,你可以直接在 AMPHP 中利用协程实现异步服务器请求。以下代码示例说明了如何使用协程来获取页面并解码其响应体,生成一个在代码中其他地方有用的 promise 对象:

$client = HttpClientBuilder::buildDefault();

$promise = Amp\call(function () use ($client) {
    try {
        $response = yield $client->request(new Request("https://eamann.com"));

        return $response->getBody()->buffer();
    } catch (HttpException $e) {
        // ...
    }
});

$promise->onResolve(function ($error = null, $value = null) {
    if ($error) {
        // ...
    } else {
        var_dump($value);
    }
});

Fibers

作为 PHP 8.1 版本的最新并发特性,Fiber 是一个全新的功能。在内部,Fiber 抽象了一个完全独立的操作线程,可以由应用程序的主进程控制。Fiber 不会与主应用程序并行运行,但会展示一个具有自己变量和状态的单独执行栈。

通过 Fibers,你可以在主应用程序内运行一个完全独立的子应用程序,并明确控制每个并发操作的处理方式。

当一个 Fiber 启动时,它会一直运行,直到执行完成或调用suspend()将控制权返回给父进程(线程),并向其返回一个值。然后可以通过父进程使用resume()来重新启动它。官方文档中的示例清楚地说明了这个概念:

$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('fiber');
    echo "Value used to resume fiber: ", $value, "\n";
});

$value = $fiber->start();

echo "Value from fiber suspending: ", $value, "\n";

$fiber->resume('test');

Fiber 并不打算被开发者直接使用,而是一个低级接口,对于像 AMPHP 和 ReactPHP 这样的框架非常有用。这些框架可以利用 Fiber 完全抽象协程的执行环境,保持应用程序状态清晰,并更好地管理并发。

接下来的配方涵盖了在 PHP 中处理并发和并行代码的各个方面。您将看到如何管理多个并发请求,如何构造异步协程,甚至如何利用 PHP 的原生 Fiber 实现。

17.1 异步从远程 API 获取数据

问题

您希望同时从多个远程服务器获取数据,并在它们全部返回数据后对结果进行操作。

解决方案

使用 AMPHP 项目中的http-client模块,将多个并发请求作为单独的 promise 发出,然后一旦所有请求都返回,就采取行动。例如:

use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;

use function Amp\Promise\all;
use function Amp\Promise\wait;

$client = HttpClientBuilder::buildDefault();
$promises = [];

$apiUrls = ['\https://github.com', '\https://gitlab.com', '\https://bitbucket.org'];

foreach($apiUrls as $url) {
    $promises[$url] = Amp\call(static function() use ($client, $url) {
        $request = new Request($url);

        $response = yield $client->request($request);

        $body = yield $response->getBody()->buffer();

        return $body;
    });
}

$responses = wait(all($promises));

讨论

在典型的同步 PHP 应用程序中,您的 HTTP 客户端会一次发出一次请求,并在继续之前等待服务器的响应。这种顺序模式对于大多数实现来说足够快,但在同时管理大量请求时变得繁琐。

AMPHP 框架的http-client模块支持以并发方式发出请求。³ 所有请求都通过使用 promise 将请求的状态和最终结果进行包装而以非阻塞方式分派。这种方法背后的魔法不仅仅是 AMPHP 客户端的并发性质;它还包括用于将所有请求捆绑在一起的Amp\call()包装器。

通过用Amp\call()封装一个匿名函数,你把它变成了一个协程。⁴ 在协程的主体内部,yield关键字指示协程等待异步函数的响应;协程的整体结果作为一个Promise实例而不是标量值返回。在解决方案示例中,你的协程为每个 API 请求创建一个新的Promise实例,并将它们一起存储在一个单一的数组中。

然后,AMPHP 框架暴露了两个有用的函数,允许你等待直到所有的 promise 都被解决:

all()

此函数接受一个 promise 数组,并返回一个单一的 promise,一旦数组中的所有 promise 都被解决,就会解决。由这个新 promise 包装的值将是其包装的 promise 值的数组。

wait()

这个函数就像它的名字听起来的那样:强制你的应用程序等待一个本来是异步的过程完成。它有效地将异步代码转换为同步代码,并解包你传递给它的 promise 中包含的值。

因此,解决方案示例同时向不同的 API 发出了多个并发的异步请求,然后将它们的响应捆绑到一个数组中,以便在你的其余同步应用程序中使用。

警告

当你按特定顺序发出请求时,它们可能不会按照你发出它们的顺序完成。在解决方案示例中,这三个请求可能总是按照你发送它们的顺序完成。然而,如果增加请求的数量,结果数组可能会与你预期的顺序不同。建议跟踪离散索引(例如使用关联数组),这样当 API 响应按不同顺序返回时,你不会感到惊讶。

参见

AMPHP 项目的 http-client 模块文档

17.2 等待多个异步操作的结果

问题

你希望同时处理多个并行操作,然后根据所有操作的总体结果执行操作。

解决方案

使用 AMPHP 框架的 parallel-functions 模块来真正并行执行你的操作,然后根据所有操作的最终响应进行操作,如 示例 17-1 中所示。

示例 17-1. 并行数组映射示例
use Amp\Promise;
use function Amp\ParallelFunctions\parallelMap;

$values = Promise\wait(parallelMap([3, 1, 5, 2, 6], function ($i) {
    echo "Sleeping for {$i} seconds." . PHP_EOL; ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

    \sleep($i); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

    echo "Slept for {$i} seconds." . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

    return $i ** 2; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
}));

print_r($values); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

第一个 echo 语句仅用于演示并行映射操作发生的顺序。你将在控制台中看到与最初传递给 parallelMap() 的数组顺序相同的语句,具体为 [3, 1, 5, 2, 6]

2

PHP 核心的 sleep() 函数是阻塞的,这意味着它将暂停程序的执行,直到经过指定秒数为止。此函数调用可以被任何其他具有类似效果的阻塞操作替换。本示例的目标是演示每个操作确实是并行运行的。

3

应用程序完成等待 sleep() 后,将再次打印一条消息,以演示并行操作完成的顺序。注意,这将与最初调用它们的顺序不同!具体来说,数字将按升序打印,因为每次调用 sleep() 完成的时间不同。

4

你的函数的任何返回值最终都将被 Promise 对象包装,直到异步操作完成。

5

Promise\wait() 之外,所有收集到的 promise 都将被解析,并且最终的变量将包含一个标量值。在本例中,该最终变量将是输入数组元素的平方值数组,顺序与原始输入一致。

讨论

parallel-functions 模块实际上是 AMPHP 的 parallel 模块之上的一个抽象层。这两者都可以通过 Composer 安装,并且都不需要任何特殊的扩展来运行。然而,两者都将在 PHP 中提供真正的并行操作。

没有任何扩展,parallel将会生成额外的 PHP 进程来处理您的异步操作。它会为您处理子进程的创建和收集,让您可以专注于代码的实际实现。在使用parallel扩展的系统上,该库会使用轻量级线程来运行您的应用程序。

但在任何情况下,您的代码看起来都一样。无论系统在幕后使用进程还是线程,AMPHP 都将其抽象化了。这使您能够编写一个仅仅利用Promise级别抽象的应用程序,并相信一切都会按预期工作。

在示例 17-1 中,您定义了一个包含一些昂贵的阻塞 I/O 调用的函数。这个示例特别使用了sleep(),但也可能是远程 API 调用、一些昂贵的哈希操作或者长时间运行的数据库查询。无论如何,这种函数会导致您的应用程序冻结,直到完成,有时您可能需要多次运行它。

而不是使用同步代码,其中您逐个将集合的每个元素传递到函数中,您可以利用 AMPHP 框架来同时处理多个调用。

parallelMap() 函数的行为类似于 PHP 的原生array_map(),但是以并行方式执行(参数顺序相反)。⁵ 它将指定的函数应用于数组的每个成员,但是在一个单独的进程或线程中执行。由于操作本身是异步的,parallelMap() 返回一个Promise来包装函数的最终结果。

您得到一个代表后台完全并行计算的Promise数组。为了回到同步代码的领域,像在配方 17.1 中那样利用 AMPHP 的wait()函数。

参见

有关 AMPHP 框架中parallelparallel-functions模块的文档。

17.3 中断一个操作以运行另一个

问题

您希望在同一个线程上运行两个独立的操作,并在它们之间来回切换。

解决方案

在 AMPHP 框架中使用协程,显式地在操作之间让出执行控制,就像示例 17-2 中所示。

示例 17-2. 使用协程进行并发for循环
use Amp\Delayed;
use Amp\Loop;
use function Amp\asyncCall;

asyncCall(function () {
    for ($i = 0; $i < 5; $i++) { ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
        print "Loop A - " . $i . PHP_EOL;
        yield new Delayed(1000); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    }
});

asyncCall(function () {
    for ($i = 0; $i < 5; $i++) { ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
        print "Loop B - " . $i . PHP_EOL;
        yield new Delayed(400); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    }
});

Loop::run(); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

第一个循环仅仅是从 0 到 4 计数,每次步进 1。

2

AMPHP 框架的Delayed()对象是一个承诺,它在给定的毫秒数后解析自身——在本例中是一整秒。

3

第二个循环也是从 0 到 4,步长为 1。

4

第二个循环在 0.4 秒后解析其承诺。

5

两个asyncCall()调用将立即触发并打印0到屏幕。然而,循环在正式启动事件循环之前不会继续增加(因此Delayed承诺实际上可以解析)。

讨论

解决方案示例介绍了两个在思考异步 PHP 时理解重要的关键概念:事件循环和协程。

事件循环是 AMPHP 处理并发操作的核心。没有事件循环,PHP 将不得不从头到尾顺序执行应用程序或脚本。然而,事件循环使解释器能够循环回头部,并以不同的方式运行额外的代码。具体来说,Loop::run()函数将持续执行,直到事件循环中没有要处理的内容,或应用程序本身接收到SIGINT信号(例如从键盘上按下 Ctrl+C)。

在 AMPHP 框架中有两个创建协程的函数:call()asyncCall()。这两个函数都会立即调用传递给它们的回调函数;call()会返回一个Promise实例,而asyncCall()则不会。在回调函数内部,任何使用yield关键字的地方都会创建一个协程——一个可以被中断并在解析Promise对象之后继续的函数。

在解决方案示例中,这个承诺是一个Delayed对象。这是 AMPHP 中导致例程暂停执行类似于原始 PHP 中sleep()的方式。不过,Delayed对象是非阻塞的。它实质上会“睡眠”一段时间,然后在事件循环的下一次通过时恢复执行。在例程延迟(或“睡眠”)时,PHP 可以自由处理其他操作。

在您的 PHP 控制台中运行解决方案示例将产生以下输出:

% php concurrent.php
Loop A - 0
Loop B - 0
Loop B - 1
Loop B - 2
Loop A - 1
Loop B - 3
Loop B - 4
Loop A - 2
Loop A - 3
Loop A - 4

前述输出表明,PHP 无需等待一个循环完成(及其“sleep”或Deferred调用链)才能运行另一个循环。两个循环都会并发执行。

还要注意,如果两个循环同步执行,这整个脚本至少需要 7 秒才能执行(第一个循环每次等待 1 秒进行五次循环,第二个循环每次等待 0.4 秒进行五次循环)。同时运行这些循环仅需要总共 5 秒。为了充分证明这一点,在进程启动时将microtime(true)存储在一个变量中,并在循环完成后与系统时间进行比较。例如:

use Amp\Delayed;
use Amp\Loop;
use function Amp\asyncCall;

$start = microtime(true);

// ...

Loop::run();

$time = microtime(true) - $start;
echo "Executed in {$time} seconds" . PHP_EOL;

创建事件循环需要一些小的开销,但在对前述更改后反复执行解决方案示例将可可靠地产生大约 5 秒钟的结果。而且,您还可以将第二个asyncCall()调用中的循环计数器从 5 增加到 10。该循环总共仍然只需要 4 秒钟才能完成。再次强调,同步执行这两个循环需要 9 秒钟才能完成,但由于通过协程调度执行上下文,脚本仍可可靠在约 5 秒钟内完成。图 17-2 通过视觉方式说明了同步和并发执行的差异。

同时执行两个协程

图 17-2. 同时执行两个协程

通过在 AMPHP 的事件循环中将这两个单独的循环作为协程处理,PHP 能够中断一个执行流程以进行另一个的执行。通过在协程之间进行调度,PHP 能够最大限度地利用您的 CPU,并允许应用程序比通过同步逻辑运行更快地完成工作。

解决方案示例是一个使用延迟或暂停的虚构示例;然而,它可以扩展到任何需要利用非阻塞但又较慢的过程的情况。您可以发出网络请求并利用协程,使应用程序在等待请求完成时继续处理。您可以调用数据库或其他持久层,并将非阻塞调用置于协程中。在某些系统中,您还可以外壳到其他进程(如 Sendmail 或其他系统进程),避免这些调用阻塞应用程序的整体执行。

另请参阅

AMPHP 框架的asyncCall()函数的文档以及通用协程的文档。

17.4 在单独线程中运行代码

问题

你希望在单独的线程上运行一个或多个繁重的操作,以便保持主应用程序的空闲状态,以便报告进度。

解决方案

使用 AMPHP 项目的parallel包来定义要运行的Task以及运行它的Worker实例。然后将一个或多个工作程序作为单独的线程或进程调用。示例 17-3 通过递归使用单向哈希将值数组减少到单个输出。它通过将哈希操作封装在异步Task中,作为工作池的一部分来运行。示例 17-4 然后定义了一个工作池,该工作池在单独的协程包装线程中运行多个Task操作。

示例 17-3. 使用递归哈希来将数组减少为单个值的任务
class Reducer implements Task
{
    private $array;
    private $preHash;
    private $count;

    public function __construct(
        array $array,
        string $preHash = '',
        int $count = 1000)
    {
        $this->array = $array;
        $this->preHash = $preHash;
        $this->count = $count;
    }

    public function run(Environment $environment)
    {
        $reduction = $this->preHash;

        foreach($this->array as $item) {
            $reduction = hash_pbkdf2('sha256', $item, $reduction, $this->count);
        }

        return $reduction;
    }
}
示例 17-4. 工作池可以运行多个任务
use Amp\Loop;
use Amp\Parallel\Worker\DefaultPool;

$results = [];

$tasks = [
    new Reducer(range('a', 'z'), count: 100),
    new Reducer(range('a', 'z'), count: 1000),
    new Reducer(range('a', 'z'), count: 10000),
    new Reducer(range('a', 'z'), count: 100000),
    new Reducer(range('A', 'Z'), count: 100),
    new Reducer(range('A', 'Z'), count: 1000),
    new Reducer(range('A', 'Z'), count: 10000),
    new Reducer(range('A', 'Z'), count: 100000),
];

Loop::run(function () use (&$results, $tasks) {
    require_once __DIR__ . '/vendor/autoload.php';
    use PhpAmqpLib\Connection\AMQPStreamConnection;
    use PhpAmqpLib\Message\AMQPMessage;
    $timer = Loop::repeat(200, function () {
        printf('.');
    });
    Loop::unreference($timer);

    $pool = new DefaultPool;

    $coroutines = [];

    foreach ($tasks as $index => $task) {
        $coroutines[] = Amp\call(function () use ($pool, $index, $task) {
            $result = yield $pool->enqueue($task);

            return $result;
        });
    }

    $results = yield Amp\Promise\all($coroutines);

    return yield $pool->shutdown();
});

echo PHP_EOL . 'Hash Results:' . PHP_EOL;
echo var_export($results, true) . PHP_EOL;

讨论

并行处理的优势在于您不再受限于一次只运行一个操作。具有多个核心的现代计算机可以实际上和逻辑上同时运行多个独立操作。幸运的是,现代 PHP 可以很好地利用这种功能。在 AMPHP 框架中,parallel模块有效地将其暴露出来。⁶

使用这种抽象的解决方案示例能够并行处理多个哈希值,使得父应用程序只需报告进度和最终结果。第一个组件是Reducer类,接受一个字符串数组并生成这些值的迭代哈希。具体来说,它对数组中每个值进行了一定数量的基于密码的密钥派生哈希操作,将派生的结果传递给数组下一个项目的哈希操作。

提示

哈希操作旨在快速将已知值转换为看似随机的值。它们是单向操作,这意味着您可以轻松地从种子值到哈希,但是反向哈希以检索其种子值是不切实际的。一些更强的安全立场使用多轮特定哈希算法(在许多情况下是数万轮),明确地减慢过程并防止“猜测和检查”类型的攻击来猜测特定种子。

由于这些哈希操作在时间上是昂贵的,您不希望同步运行它们。考虑到它们可能需要的时间,您甚至不希望并发运行它们。相反,您希望完全并行运行它们,以利用机器上所有可用的核心。通过将操作嵌入扩展了Task的对象中,它们在线程池中调用时可以同时运行。

AMPHP 的parallel包公开了一个带有默认配置的线程池,你可以轻松地在池中排队尽可能多的操作,只要它们实现了Task接口。池将返回一个包装任务的 Promise 实例,这意味着你可以在协程中排队你的任务,并等待它们所代表的所有 Promise 的解析。

由于所有操作都是异步的,父应用程序可以在哈希操作并行执行时继续运行代码。解决方案示例利用这一优势,设置了一个重复的printf()操作,每 200 毫秒在屏幕上写入一个小数点。这在某种程度上像是一个进度条或活性检查,为您提供了并行进程仍在后台运行的积极确认。

一旦所有并行哈希作业完成,整个操作将把哈希结果打印到屏幕上。

实际上,您可以以这种方式排队任何类型的并行作业以同时执行多个任务。AMPHP 公开了一个enqueueCallable()函数,使您能够将任何常规函数调用转换为并行操作。假设您需要从美国国家气象局(NWS)检索天气报告。与解决方案示例中排队多个哈希作业不同,您可以轻松地获取远程天气报告,如示例 17-5 中所示。

示例 17-5。异步检索天气报告
use Amp\Parallel\Worker;
use Amp\Promise;

$forecasts = [
    'Washington, DC' => 'https://api.weather.gov/gridpoints/LWX/97,71/forecast',
    'New York, NY'   => 'https://api.weather.gov/gridpoints/OKX/33,37/forecast',
    'Tualatin, OR'   => 'https://api.weather.gov/gridpoints/PQR/108,97/forecast',
];

$promises = [];
foreach ($forecasts as $city => $forecast) {
    $promises[$city] = Worker\enqueueCallable('file_get_contents', $forecast); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
}

$responses = Promise\wait(Promise\all($promises)); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

foreach($responses as $city => $forecast) {
    $forecast = json_decode($forecast); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    $latest = $forecast->properties->periods[0];

    echo "Forecast for {$city}:" . PHP_EOL;
    print_r($latest);
}

1

每个 URL 端点都可以使用file_get_contents()独立获取。使用 AMPHP 的enqueueCallable()函数将自动作为独立进程并行于主应用程序之外执行。

2

每个并行请求都包装在一个Promise对象中。为了返回到同步执行状态,您必须等待所有这些 promise 都被解决。all函数将不同的 promise 收集到一个单独的Promise对象中。wait()函数将阻塞执行,直到此 promise 被解决;然后解开包含的值供同步代码使用。

3

NWS API 返回一个表示特定气象站预报的 JSON 对象。在利用应用程序中的数据之前,您需要首先解析 JSON 编码的字符串。

警告

NWS 天气 API 完全免费使用,但需要您在请求中发送一个唯一的用户代理。默认情况下,当您使用file_get_contents()时,PHP 将发送一个简单的用户代理字符串PHP。要自定义此内容,请在您的php.ini文件中更改user_agent配置为更独特的内容。如果不进行此更改,API 可能会以403 Forbidden错误拒绝您的请求。有关更多信息,请参考有关 API 的常见问题解答

AMPHP 框架在底层使用单独的线程还是完全独立的进程取决于系统最初的配置方式。您的代码保持不变,并且在没有支持多线程 PHP 的任何扩展的情况下,可能默认使用生成的 PHP 进程。在任何情况下,enqueueCallable()函数要求您使用原生 PHP 函数或通过 Composer 可加载的用户定义函数。这是因为生成的子进程只知道系统函数、Composer 加载的函数以及父进程发送的任何序列化数据。

这最后一个细节至关重要——从父应用程序发送到后台工作程序的数据将被序列化。一些用户定义的对象在 PHP 尝试对其进行序列化和反序列化时可能会出现问题。甚至一些核心对象(如流上下文)与序列化不兼容,无法传递给子线程或进程。

在选择要在后台运行的任务时要小心,确保发送的数据与序列化和并行操作兼容。

参见

parallel的文档,来自 AMPHP 框架。

17.5 在独立线程之间发送和接收消息

问题

您希望与多个运行中的线程进行通信,以同步状态或管理这些线程正在执行的任务。

解决方案

在主应用程序和它编排的独立线程之间使用消息队列或总线,以实现无缝通信。例如,使用 RabbitMQ 作为主应用程序(如示例 17-7 所示)和独立工作线程(如示例 17-6 所示)之间的中介。

示例 17-6. 基于队列发送邮件的后台任务
use PhpAmqpLib\Connection\AMQPStreamConnection;

$connection = new AMQPStreamConnection('127.0.0.1', 5762, 'guest', 'guest'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$channel = $connection->channel();
$channel->queue_declare('default', false, false, false, false); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

echo '... Waiting for messages. To exit press CTRL+C' . PHP_EOL;

$callback = function($msg) {
    $data = json_decode($msg->body, true); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    $to = $data['to'];
    $from = $data['from'] ?? 'worker.local';
    $subject = $data['subject'];
    $message = wordwrap($data['message'], 70) . PHP_EOL;

    $headers = "From: {$from} PHP_EOL X-Mailer: PHP Worker";

    print_r([$to, $subject, $message, $headers]) . PHP_EOL; ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

    mail($to, $subject, $message, $headers);

    $msg->ack(); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
};

$channel->basic_consume('default', '', false, false, false, false, $callback); ![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/6.png)
while(count($channel->callbacks)) {
    $channel->wait(); ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)
}

1

使用默认端口和默认凭据连接到本地运行的 RabbitMQ 服务器。在生产中,这些值将不同,并且应从环境本身加载。

2

声明队列到 RabbitMQ 服务器只是打开了一条通信通道。如果队列已存在,则此操作不会执行任何操作。

3

当数据从 RabbitMQ 进入工作程序时,会将数据包装在消息对象中。您所需的实际数据位于消息体中。

4

在工作程序中打印数据是诊断正在发生的情况并检查流入数据中的任何潜在错误的有用方式。

5

一旦您的工作程序完成对消息的处理,它需要向 RabbitMQ 服务器确认消息;否则,另一个工作程序可能会接收并稍后重试该消息。

6

消息的消费是同步操作。当从 RabbitMQ 接收到消息时,系统将调用传递给此函数的回调函数,并以消息本身作为参数。

7

只要消息上有回调,此循环将永远运行,并且wait()方法将保持与 RabbitMQ 的连接打开,以便工作程序可以消耗并处理队列中的任何消息。

示例 17-7. 发送消息到队列的主应用程序
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

$connection = new AMQPStreamConnection('127.0.0.1', 5672, 'guest', 'guest'); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$channel = $connection->channel();
$channel->queue_declare('default', false, false, false, false); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$message = [
    'subject' => 'Welcome to the team!',
    'from'    => 'admin@mail.local',
    'message' => "Welcome to the team!\r\nWe're excited to have you here!"
];

$teammates = [
    'adam@eden.local',
    'eve@eden.local',
    'cain@eden.local',
    'abel@eden.local',
];

foreach($teammates as $employee) {
    $email = $message;
    $email['to'] = $employee;

    $msg = new AMQPMessage(json_encode($email)); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
    $channel->basic_publish($msg, '', 'default'); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
}

$channel->close(); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)
$connection->close();

1

与工作程序一样,您可以使用默认参数连接到本地的 RabbitMQ 服务器。

2

与工作程序一样,您也需要声明一个队列。如果此队列已存在,此方法调用将不会执行任何操作。

3

在发送消息之前,您需要对其进行编码。在本例中,负载将被序列化为 JSON 字符串。

4

对于每条消息,您可以选择发布到哪个队列,并将消息发送到 RabbitMQ。

5

发送完消息后,建议在执行其他工作之前明确关闭通道和连接。在这个示例中,并没有其他工作要做(进程会立即退出),但显式资源清理对于任何开发者来说都是一个健康的习惯。

讨论

解决方案示例使用多个显式的 PHP 进程来处理大型操作。在 示例 17-6 中定义的脚本可以命名为 worker.php,并单独多次实例化。如果您在两个独立的控制台中执行这样做,将会产生两个完全独立的 PHP 进程,它们连接到 RabbitMQ 并监听作业。

在第三个窗口中运行 示例 17-7 将启动主进程,并通过向 RabbitMQ 中托管的 default 队列发送消息来分派作业。工作程序将独立地接收这些作业,处理它们,并等待未来更多的工作。

父进程(示例 17-7)与两个完全异步的工作进程(示例 17-6)之间使用 RabbitMQ 作为消息代理器的完整交互由 图 17-3 中展示的三个独立控制台窗口说明。

多个 PHP 进程通过 RabbitMQ 进行通信

图 17-3. 多个 PHP 进程通过 RabbitMQ 进行通信

不同的进程不直接通信。要做到这一点,您需要公开一个交互式 API。相反,更简单的通信方式是利用一个中间消息代理器——在本例中是 RabbitMQ

RabbitMQ 是一个开源工具,直接与多种不同的编程语言接口。它允许创建多个队列,然后由一个或多个专用工作程序读取消息内容进行处理。在解决方案示例中,您使用了工作程序和 PHP 的本机 mail() 函数来发送电子邮件消息。一个更复杂的工作程序可能会更新数据库记录,与远程 API 进行接口交互,甚至处理像在 第 17.4 节 中执行的哈希操作这样的计算密集型操作。

提示

由于 RabbitMQ 支持多种语言,您在实现中不仅限于 PHP。如果您想在不同语言中使用特定库,可以将您的工作程序写成该语言,并导入库,然后从主 PHP 应用程序向工作程序派发工作。

在生产环境中,您的 RabbitMQ 服务器将利用用户名/密码身份验证,或者甚至明确允许列出可以与其通信的服务器。不过,在开发过程中,您可以有效地利用本地环境、默认凭据和诸如Docker之类的工具,在本地机器上运行 RabbitMQ 服务器。要通过默认端口和默认身份验证直接公开 RabbitMQ,请使用以下 Docker 命令:

$ docker run -d -h localhost -p 127.0.0.1:5672:5672 --name rabbit rabbitmq:3

服务器运行后,您可以注册尽可能多的队列来管理应用程序群中的数据流。

参见

官方的文档教程,用于配置和与 RabbitMQ 交互。

17.6 使用 Fiber 管理流内容

问题

您希望使用 PHP 的最新并发功能来部分读取和操作流中的数据,而不是一次缓冲其所有内容。

解决方案

使用 Fiber 封装流并逐块读取其内容。示例 17-8 每次以 50 字节的块读取网页的整体内容,并跟踪读取的总字节数。

示例 17-8. 通过 Fiber 每次读取一个块来读取远程流资源
$fiber = new Fiber(function($stream): void {
    while (!feof($stream)) {
        $contents = fread($stream, 50); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
        Fiber::suspend($contents); ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    }
});

$stream = fopen('https://www.eamann.com/', 'r');
stream_set_blocking($stream, false); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

$output = fopen('cache.html', 'w'); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)

$contents = $fiber->start($stream); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

$num_bytes = 0;
while (!$fiber->isTerminated()) {
        echo chr(27) . "0G"; ![6        $num_bytes += strlen($contents);        fwrite($output, $contents); ![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/7.png)

        echo str_pad("Wrote {$num_bytes} bytes ...", 24, ' ', STR_PAD_RIGHT);
        usleep(500); ![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/8.png)

        $contents = $fiber->resume(); ![9](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/9.png)
}

echo chr(27) . "0G";
echo "Done writing {$num_bytes} bytes to cache.html!" . PHP_EOL;

fclose($stream); ![10fclose($output);```![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-1)

Fiber 自身在启动时接受流资源作为唯一参数。只要流没有结束,Fiber 将从当前位置读取下一个 50 字节到应用程序中。

![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-2)

一旦 Fiber 从流中读取了数据,它将暂停操作并将控制返回给父应用程序堆栈。由于 Fiber 可以将数据发送回父堆栈,因此该 Fiber 在暂停执行时将发送从流中读取的 50 字节。

![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-3)

在父应用程序堆栈中,流被打开并设置为不阻塞应用程序的执行。在非阻塞模式下,任何对 `fread()` 的调用都将立即返回,而不是等待流上的数据。

![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-4)

在父应用程序中,您还可以打开其他资源,比如本地文件,您可以将远程资源的内容缓存到其中。

![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-5)

启动 Fiber 时,将主流资源作为参数传递,以便它在 Fiber 的调用堆栈中可用。一旦 Fiber 暂停执行,它还将返回从流中读取的 50 字节。

![6](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-6)

要覆盖控制台输出的上一行,请传递 `ESC` 字符 (`chr(27)`) 和 ANSI 控制序列以将光标移动到终端的第一列 (`[0G]`)。现在屏幕上打印的任何后续文本都将覆盖先前显示的内容。

![7](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-7)

一旦远程流中有数据可用,您可以直接将该数据写入本地缓存文件。

![8](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-8)

对于该应用程序,休眠语句并非必需,但用于说明当 Fiber 被挂起时,父应用程序堆栈中可以发生其他计算。

![9](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-9)

恢复 Fiber 将从远程流资源中检索接下来的 50 个字节,假设还有字节可供检索。如果没有剩余内容可供检索,则 Fiber 将终止,并且您的程序将退出其`while`循环。

![10](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/#co_asynchronous_php_CO6-10)

一旦执行完成并且 Fiber 被清理,确保关闭您打开的所有流或其他资源。

## 讨论

Fibers 类似于协程和生成器,其执行可以被中断,使得应用程序在返回控制之前可以执行其他逻辑。与其他结构不同,Fibers 具有独立于应用程序其余部分的调用堆栈。这种方式使得它们能够在嵌套函数调用中暂停执行,而无需更改触发暂停的函数的返回类型。

对于使用`yield`命令暂停执行的生成器,必须返回一个`Generator`实例。对于使用`::suspend()`方法的 Fiber,您可以返回任何您需要的类型。

一旦 Fiber 被挂起,您可以从父应用程序的任何位置恢复其执行,以重新启动其独立的调用堆栈。这使您能够有效地在多个执行上下文之间跳转,而无需过多关注控制应用程序状态的问题。

您还可以有效地向 Fiber 传递数据。当 Fiber 自我挂起时,它可以选择向父应用程序发送数据——无论您需要什么类型的数据。当您恢复 Fiber 时,您可以传递任何您想要的值,甚至不传递任何值。您还可以选择通过使用`::throw()`方法将异常抛入 Fiber,然后在 Fiber 本身内部处理该异常。 示例 17-9 明确展示了如何从 Fiber 内部处理异常的情况。

##### 示例 17-9\. 处理来自 Fiber 内部的异常

```php
$fiber = new Fiber(function(): void {
    try {
        Fiber::suspend(); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)
    }
    catch (Exception $e) {
        echo $e->getMessage() . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
    }

    echo 'Finished within Fiber' . PHP_EOL; ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)
});

$fiber->start(); ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
$fiber->throw(new Exception('Error')); ![5](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/5.png)

1

一旦 Fiber 启动,它将立即暂停执行并返回控制给父应用程序堆栈。

2

当 Fiber 被恢复时,如果遇到可捕获的Exception,它将提取并打印出错误消息。

3

一旦 Fiber 执行完成,它将在结束其并发执行并将控制返回给主应用程序之前打印一条有用的消息。

4

仅启动 Fiber 仅创建其调用堆栈,并且由于 Fiber 立即挂起,执行从父堆栈的角度继续。

5

将父级抛出的异常传递给 Fiber 将触发catch条件,并将Error消息打印到控制台。

Fiber 是在调用堆栈之间处理执行上下文的有效方法,但在 PHP 中仍然属于相对低级别的。虽然它们在像解决方案示例中那样简单操作中使用起来可能很直接,但是在更复杂的计算中可能会变得难以管理。了解 Fiber 的工作原理对于有效使用它们至关重要,但同样重要的是选择适当的抽象来管理您的 Fiber。来自 ReactPHP 的Async 包提供了有效的异步操作抽象,包括 Fiber,使得工程化复杂的并发应用相对容易。

另请参阅

PHP 手册涵盖了Fibers

¹ Octane 的承诺是,它将提高大多数应用程序的性能,而无需更改它们的代码。然而,在生产环境中可能会出现一些边缘情况,需要进行更改,因此在依赖项目作为生产环境中的即插即用运行时替代之前,请彻底测试您的代码。

² 有关生成器和yield关键字的更多信息,请参阅 Recipe 7.15。

³ 与任何模块及 AMPHP 框架本身一样,您可以通过 Composer 安装http-client包。有关 Composer 包的更多信息,请参阅 Recipe 15.3。

⁴ 有关匿名函数或 Lambda 表达式的更多信息,请参阅 Recipe 3.9。

⁵ 有关array_map()的更多信息,请参阅 Recipe 7.13。

⁶ AMPHP 框架还发布了一个parallel-functions包,公开了几个有用的辅助函数,包装了较低级别的parallel包。有关这些函数及其使用方法的更多信息,请参阅 Recipe 17.2。

第十八章:PHP 命令行

开发者们来自各种背景,具有不同水平的软件开发经验,他们选择 PHP。无论你是一名新的计算机科学毕业生,一名经验丰富的开发者,还是来自非编程领域并希望学习新技能的人,这种语言的宽容性使得入门变得容易。话虽如此,对于这些非编程初学者来说,最大的障碍可能是 PHP 的命令行界面。

非编程初学者可能习惯于使用图形用户界面,并通过鼠标和图形显示器进行导航。给这样的用户一个命令行终端,他们可能会对界面感到困惑或者感到害怕。

作为后端语言,PHP 经常在命令行中操作。这可能使得对于不习惯基于文本的界面的开发者来说,这是一种令人生畏的语言。幸运的是,基于 PHP 的命令行应用相对简单构建,使用起来非常强大。

一个应用程序可能会暴露一个类似于其默认 RESTful 接口的命令面板,从而使得从终端进行交互类似于通过浏览器或通过 API 进行交互。另一个应用程序可能会将其管理工具隐藏在 CLI 中,以防止不太技术的最终用户意外损坏应用程序。

当今市场上最流行的 PHP 应用之一是WordPress,这是一个开源的博客和网络平台。大多数用户通过其图形化网络界面与平台互动,但 WordPress 社区还维护着一个丰富的命令行界面:WP-CLI。这个工具允许用户通过可脚本化的文本终端界面管理图形工具已经暴露的一切。它还提供了用于管理用户角色、系统配置、数据库状态甚至系统缓存的命令。所有这些功能都不存在于默认的网络界面中!

今天构建 PHP 应用程序的任何开发者都可以并且应该了解命令行的功能,无论是关于 PHP 本身的功能,还是关于您的应用程序如何通过相同的界面暴露其功能。一个真正丰富的 Web 应用程序最终将在可能不会暴露任何图形界面的服务器上运行,因此能够从终端控制应用程序不仅仅是一种强大的举措,而且是一种必要性。

以下示例揭示了参数解析、管理输入和输出的复杂性,甚至利用扩展构建在控制台中运行的完整应用程序。

18.1 解析程序参数

问题

当用户调用您的脚本时,您希望用户传递一个参数,以便在应用程序内部解析。

解决方案

使用 $argc 整数和 $argv 数组直接在脚本中检索参数的值。例如:

<?php
if ($argc !== 2) {
    die('Invalid number of arguments.');
}

$name = htmlspecialchars($argv[1]);

echo "Hello, {$name}!" . PHP_EOL;

讨论

假设在示例解决方案中将脚本命名为 script.php,则可以通过以下命令在终端会话中调用它:

% php script.php World

在内部,$argc 变量包含了执行 PHP 脚本时传递的参数数量计数。在示例解决方案中,确切地有两个参数:

  • 脚本本身的名称(script.php

  • 无论你在脚本名称后传递了什么字符串值

注意

可以通过在 php.ini 文件中将 reg⁠ister_argc_argv 标志 设置为 false 来在运行时禁用 $argc$argv。如果启用了这些参数,它们将包含传递给脚本的参数或者从 Web 服务器转发的 GET 请求的信息。

第一个参数将始终是正在执行的脚本或文件的名称。除此之外的所有参数都是通过空格分隔的。如果需要传递复合参数(例如带有空格的字符串),请用双引号括起来。例如:

% php script.php "dear reader"

更复杂的实现可能会利用 PHP 的 getopt() 函数而不是直接操作参数变量。此函数将解析短选项和长选项,并将它们的内容传递给你的应用程序可以利用的数组中。

短选项是在命令行上用单个破折号表示的每个单字符选项,例如 -v。每个选项可以仅仅是存在(作为标志)或者后面跟着数据(作为选项)。

长选项以双破折号开头,但在其他方面与它们的短选项同样有效。你可以假设应用程序中存在任何一种或两种样式的选项,并根据需要使用它们。

注意

通常,命令行应用程序将为同一功能提供长选项和单字符快捷方式。例如,-v--verbose 经常用于控制脚本的输出级别。使用 getopt(),你可以轻松获取两者,但 PHP 不会将它们关联起来。如果你支持两种不同的方法来提供相同的选项值或标志,你需要在脚本中手动协调它们。

getopt() 函数接受三个参数,并返回表示 PHP 解释器已解析的选项的数组:

  • 第一个参数是一个单个字符串,在其中每个字符表示一个短选项或标志。

  • 第二个参数是一个字符串数组,每个字符串是一个长选项名称。

  • 最后一个参数,这个参数是通过引用传递的,是一个整数,表示在 PHP 遇到非选项时停止解析的 $argv 索引。

短选项和长选项都接受修饰符。如果仅传递一个选项,PHP 将不接受该选项的值,但会将其视为标志。如果在选项后添加一个冒号,PHP 将要求一个值。如果添加两个冒号,PHP 将将值视为可选的。

作为例子,Table 18-1 列出了短选项和长选项如何利用这些附加元素。

表 18-1. PHP getopt() 参数

参数 参数类型 描述
a 短选项 无值的单个标志:-a
b: 短选项 需要值的单个标志:-b value
c:: 短选项 可选值的单个标志:-c value-c
ab:c 短选项 组合三个标志,其中 ac 没有值,但 b 需要一个值:-a -b value -c
verbose 长选项 无值的选项字符串:--verbose
name: 长选项 带有必需值的选项字符串:--name Alice
output:: 长选项 带有可选值的选项字符串:--output file.txt--output

为了说明选项解析的实用性,定义一个程序,如 Example 18-1,它暴露了短选项和长选项,但也利用了标志后的自由形式(非选项)输入。以下脚本将期望如下:

  • 控制输出是否大写的标志(-c

  • 用户名(--name

  • 一些额外的、随意的文本选项后

示例 18-1. 直接演示getopt()与多个选项的情况
<?php
$optionIndex = 0;

$options = getopt('c', ['name:'], $optionIndex); ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

$firstLine = "Hello, {$options['name']}!" . PHP_EOL; ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)

$rest = implode(' ', array_slice($argv, $optionIndex)); ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

if (array_key_exists('c', $options)) { ![4](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/4.png)
    $firstLine = strtoupper($firstLine);
    $rest = strtoupper($rest);
}

echo $firstLine;
echo $rest . PHP_EOL;

1

使用 getopt() 定义脚本期望的短选项和长选项。第三个可选参数通过引用传递,并将被解析器用于解析选项的索引。

2

有值的选项可以轻松从结果关联数组中提取。

3

getopt() 的结果索引可用于通过从 $argv 数组中提取未解析值来快速提取任何附加数据。

4

无值的选项仍会在关联数组中设置一个键,但其值将是布尔值 false。检查键是否存在,但不要依赖其值,因为结果的直观性质可能会产生误导。

假设您将脚本命名为 Example 18-1 定义的 getopt.php,您可以期望看到如下结果:

% php getopt.php -c --name Reader This is fun
HELLO, READER!
THIS IS FUN
%

参见

关于$argc$argv,以及getopt() 函数的文档。

18.2 读取交互式用户输入

问题

您希望提示用户输入并将其响应读入变量。

解决方案

使用 STDIN 文件句柄常量从标准输入流中读取数据。例如:

echo 'Enter your name: ';

$name = trim(fgets(STDIN, 1024));

echo "Welcome, {$name}!" . PHP_EOL;

讨论

标准输入流使您可以轻松地读取请求中提供的任何数据。在程序中使用 fgets() 直接从流中读取数据将暂停程序的执行,直到最终用户向您提供该输入为止。

解决方案示例利用了简写常量STDIN来引用输入流。您也可以像在 Example 18-2 中演示的那样,使用流的完全限定名称(以及显式的fopen())。

Example 18-2. 从stdin读取用户输入
echo 'Enter your name: ';

$name = trim(fgets(fopen('php://stdin', 'r'), 1024));

echo "Welcome, {$name}!" . PHP_EOL;
注意

特殊的STDINSTDOUT简称仅在应用程序中可访问。如果像 Recipe 18.5 中使用交互式终端 REPL,这些常量将不会被定义或者无法访问。

另一种方法是在 PHP 中使用GNU Readline 扩展,这在您的安装中可能有也可能没有。这个扩展包装了许多手动提示、检索和修剪用户输入的工作。整个解决方案示例可以重写为 Example 18-3 中所示。

Example 18-3. 从 GNU Readline 扩展读取输入
$name = readline('Enter your name: ');

echo "Welcome, {$name}!" . PHP_EOL;

Readline 扩展提供的其他函数,如readline_add_​history(),允许高效地操作系统命令行历史。如果该扩展可用,这是处理用户输入的强大方式。

注意

PHP 的某些发行版(如 Windows 版)默认启用了 Readline 支持。在其他情况下,您可能需要显式编译 PHP 以包含此支持。有关原生 PHP 扩展的更多信息,请查看 Recipe 15.4。

参见

进一步讨论标准输入,请参阅 Recipe 11.2。

18.3 给控制台输出着色

问题

您希望在控制台中以不同颜色显示文本。

解决方案

使用正确转义的控制台颜色代码。例如,以以下方式在红色背景上以蓝色文本打印字符串Happy Independence Day

echo "\e0;34;41mHappy Independence Day!\e[0m" . PHP_EOL;

讨论

类 Unix 终端支持 ANSI 转义序列,这些序列允许程序对诸如光标位置和字体样式之类的细节进行精细控制。特别是,您可以使用这个转义序列为终端后续的所有文本定义颜色:

\e[{foreground};{background}m

前景色有两种变体——普通和粗体(由颜色定义中的额外布尔标志确定)。背景色缺乏这种区分。所有颜色都由[Table 18-2 中的这些代码标识。

Table 18-2. ANSI 颜色代码

颜色 普通前景色 亮前景色 背景色
黑色 0;30 1;30 40
红色 0;31 1;31 41
绿色 0;32 1;32 42
黄色 0;33 1;33 43
蓝色 0;34 1;34 44
洋红色 0;35 1;35 45
青色 0;36 1;36 46
白色 0;37(真正的浅灰色) 1;37 47

要将终端颜色重置为正常状态,请在任何颜色定义的位置使用简单的0。代码\e[0m将重置所有属性。

参见

Wikipedia 涵盖了ANSI 转义码

18.4 使用 Symfony Console 创建命令行应用程序

问题

想要创建一个完整的命令行应用程序,而不必手动编写所有的参数解析和处理代码。

解决方案

使用 Symfony Console 组件定义你的应用程序及其命令。例如,示例 18-4 定义了一个 Symfony 命令,用于在控制台上向用户打招呼。然后,示例 18-5 使用该命令对象创建一个在终端中向用户打招呼的应用程序。

示例 18-4. 一个基本的 hello world 命令
namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:hello-world')]
class HelloWorldCommand extends Command
{
    protected static $defaultDescription = 'Greets the user.';

    // ...
    protected function configure(): void
    {
        $this
            ->setHelp('This command greets a user...')
            ->addArgument('name', InputArgument::REQUIRED, 'User name');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln("Hello, {$input->getArgument('name')}");
        return Command::SUCCESS;
    }
}
示例 18-5. 创建实际的控制台应用程序
#!/usr/bin/env php
<?php
// application.php

require __DIR__.'/vendor/autoload.php';

use Symfony\Component\Console\Application;

$application = new Application();

$application->add(new App\Command\HelloWorldCommand());

$application->run();

然后按如下命令运行:

% ./application.php app:hello-world User

讨论

Symfony 项目为 PHP 提供一套强大的可重复使用组件集合。它作为一个框架简化了 Web 应用程序的开发,并显著提高了开发速度。它文档详尽、功能强大,最重要的是完全免费和开源。

注意

开源的Laravel 框架,其数据模块被涵盖在 Recipe 16.9 中,本身是 Symfony 组件的一个元包。它自己的Artisan 控制台工具建立在 Symfony Console 组件之上,为 Laravel 项目提供丰富的命令行控制,包括其配置和运行环境。

与任何其他 PHP 扩展一样,Symfony 组件通过 Composer 安装。¹ Console 组件本身可以如下安装:

% composer require symfony/console

前置命令require将更新你项目的composer.json文件,包括 Console 组件,并将其(及其依赖项)安装在你项目的vendor/目录中。

注意

如果你的项目还没有使用 Composer,安装任何包将自动为你创建一个新的composer.json文件。你应该花些时间更新它,以便自动加载项目所需的所有类或文件,以确保一切无缝协作。有关 Composer、扩展和自动加载的更多信息,请参阅第十五章。

安装完库之后,你可以立即开始利用它。各种命令的业务逻辑可以存在于应用程序的其他位置(例如,在 RESTful API 后面),但也可以通过命令行界面导入并公开。

默认情况下,每个继承自Command的类都可以处理用户提供的参数,并将内容显示回终端。选项和参数通过该类的addArgument()addOption()方法创建,并可以直接在其configure()方法中进行操作。

输出非常灵活。你可以使用ConsoleOutputInterface类中列出的任何方法直接将内容打印到屏幕上,详见表 18-3。

表格 18-3. Symfony 控制台输出方法

方法 描述
writeln() 将单行文本写入控制台。相当于在某些文本后面使用echo,然后显式使用PHP_EOL换行符。
write() 在控制台中写入文本,不添加换行符。
section() 创建一个新的输出区域,可以像独立的输出缓冲区一样进行原子控制。
overwrite() 仅对部分有效——使用给定内容覆盖部分中的内容。
clear() 仅对部分有效——清除部分的所有内容。

除了在表格 18-3 中介绍的文本方法之外,Symfony Console 还可以在控制台中创建动态表格。每个Table实例都绑定到一个输出接口,可以具有所需的任意行数、列数和分隔符。示例 18-6 演示了如何在从数组中提取内容填充之前构建一个简单的表格,并将其渲染到控制台。

示例 18-6. 使用 Symfony 在控制台中渲染表格
// ...

#[AsCommand(name: 'app:book')]
class BookCommand extends Command
{
    public function execute(InputInterface $input, OutputInterface $output): int
    {
        $table = new Table($output);
        $table
            ->setHeaders(['ISBN', 'Title', 'Author'])
            ->setRows([
                [
                    '978-1-940111-61-2',
                    'Security Principles for PHP Applications',
                    'Eric Mann'
                ],
                ['978-1-098-12132-7', 'PHP Cookbook', 'Eric Mann'],
            ])
        ;
        $table->render();

        return Command::SUCCESS;
    }
}

Symfony Console 自动解析传递到Table对象的内容,并为您渲染包含网格线的表格。前面的命令在控制台中生成以下输出:

+-------------------+------------------------------------------+-----------+
| ISBN              | Title                                    | Author    |
+-------------------+------------------------------------------+-----------+
| 978-1-940111-61-2 | Security Principles for PHP Applications | Eric Mann |
| 978-1-098-12132-7 | PHP Cookbook                             | Eric Mann |
+-------------------+------------------------------------------+-----------+

组件内进一步模块帮助控制和渲染动态的进度条和交互式的用户提示和问题

控制台组件甚至可以直接帮助着色终端输出。与 18.3 节讨论的复杂 ANSI 转义序列不同,Console 允许您直接使用命名标签和样式来控制内容。

警告

在撰写本文时,Console 组件默认在 Windows 系统上禁用输出着色。有各种免费的终端应用程序(如Cmder)可供 Windows 作为标准终端的替代品,支持输出着色。

终端是您的用户非常强大的界面。Symfony Console 使得在应用程序中定位这个界面变得容易,无需手动解析参数或手工制作丰富的输出。

参见

Symfony Console 组件的完整文档

18.5 使用 PHP 的本地读取-评估-打印循环

问题

您希望测试一些 PHP 逻辑,而不需要创建完整的应用程序来托管它。

解决方案

利用 PHP 的交互式 shell 如下:

% php -a

讨论

PHP 交互式 shell 提供了一个读取-评估-打印循环(REPL),可以有效地测试 PHP 中的单个语句,并在可能的情况下直接打印到终端。在 shell 中,您可以定义函数和类,甚至直接执行命令式代码,而无需创建磁盘上的脚本文件。

这个 shell 是在完整应用程序的上下文之外测试特定行代码或逻辑的有效方法。

交互式 shell 还允许在 shell 会话运行时对所有 PHP 函数或变量以及您定义的任何函数或变量进行全面的 Tab 补全。只需键入名称的前几个字符,按 Tab 键,shell 将自动为您完成名称。如果存在多个可能的完成项,请按两次 Tab 键查看所有可能性的列表。

您可以在 php.ini 配置文件中控制 shell 的两个特定设置:cli.pager 允许外部程序处理输出而不是直接显示到控制台,并且 cli.prompt 允许您控制默认的 php > 提示符。

例如,您可以通过在 shell 会话中将任意字符串传递给 #cli.prompt 来替换提示符本身:

% php -a ![1](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/1.png)

php > #cli.prompt=repl ~> ![2](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/2.png)
repl ~> ![3](https://gitee.com/OpenDocCN/ibooker-php-zh/raw/master/docs/php-cb/img/3.png)

1

初始调用 PHP 启动交互式 shell。

2

直接设置 cli.prompt 配置将覆盖默认配置,直到会话关闭。

3

一旦您覆盖了默认提示符,您将看到新版本直到退出。

警告

使用反引号可以在提示符本身内执行任意 PHP 代码。PHP 文档中的一些示例使用这种方法在提示符前添加当前时间。然而,在不同系统之间,这可能不会始终正常工作,并且可能在执行 PHP 代码时引入不必要的不稳定性。

您可以使用在 Table 18-2 中定义的 ANSI 转义序列为输出添加颜色。在许多情况下,这提供了更愉快的界面,并且允许您在需要时提供附加信息。CLI 提示符本身引入了四个额外的转义序列,如 Table 18-4 中定义的那样。

表 18-4. CLI 提示符转义序列

Sequence Description
\e 使用在 Recipe 18.3 中引入的 ANSI 代码为提示符添加颜色。
\v 打印 PHP 的版本。
\b 指示包含解释器的逻辑块。默认情况下,这将是 php,但可能是 /* 表示多行注释。
\> 表示默认情况下为 > 的提示字符。当解释器位于另一个未终止的块或字符串内时,此字符将更改以指示 shell 的位置。可能的字符包括 ' " { ( >

通过同时使用 ANSI 转义序列定义颜色和为提示符本身定义的特殊序列,您可以定义一个提示符,以显示 PHP 的版本和解释器的位置,并使用友好的前景色,如下所示:

php > #cli.prompt=\e032m\v \e031m\b \e[34m\> \e[0m

上述设置导致在 [Figure 18-1 中显示。

![使用着色更新的 PHP 控制台

图 18-1. 使用着色更新的 PHP 控制台
警告

并非所有控制台都支持通过 ANSI 控制序列进行着色。如果这是您打算使用的模式,请务必在要求其他人使用系统之前彻底测试您的序列。虽然正确渲染的控制台既吸引人又易于使用,但未渲染的转义序列可能使控制台几乎无法使用。

参见

PHP 交互命令行的文档。

¹ 要了解更多关于 Composer 的信息,请参阅 Recipe 15.3。

posted @ 2024-06-18 18:15  绝不原创的飞龙  阅读(11)  评论(0编辑  收藏  举报