PHP8-编程提示(全)

PHP8 编程提示(全)

原文:zh.annas-archive.org/md5/7838a031e7678d26b84966d54ffa29dd

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

PHP 8 代表了 PHP 核心开发团队为最大化提高核心语言效率所做的工作的巅峰。只要迁移到 PHP 8,您的应用程序代码将立即看到速度提升,同时内存占用也会更小。此外,在 PHP 8 中,开发人员会注意到大量的工作已经投入到规范语法和语言使用上。简而言之,在 PHP 8 中编程对于那些重视良好编码实践的开发人员来说是一种乐趣。

然而,这不可避免地引出了一个问题:PHP 语言未来的发展方向是什么?PHP 8 也提供了这个问题的答案,即即时编译器和对 fibers 的支持。后者构成了异步编程的基础,并已宣布将在 PHP 8.1 中推出。PHP 8 让您一窥语言的未来,而这个未来看起来非常光明!

综合起来,可以清楚地看出,理解和掌握 PHP 8 中实施的新功能和更严格的编码实践对于那些希望追求 PHP 开发人员职业生涯的人来说是至关重要的。这本书正是您快速上手并运用 PHP 8 所需的工具。我们不仅介绍了新功能,还向您展示了如何避免在 PHP 8 迁移后可能导致代码失败的陷阱。此外,我们通过全面介绍 JIT 编译器和 PHP 异步编程,让您一窥 PHP 的未来。

这本书是为谁准备的

这本书适用于所有经验水平的 PHP 开发人员,他们具有 PHP 5 或更高版本的经验。如果您刚开始学习 PHP,您会发现代码示例对于更有效地学习使用该语言非常有用。在一个或多个 PHP 项目上工作了几个月的开发人员将能够将这些技巧和技术应用到手头的代码中,而那些具有多年 PHP 经验的开发人员肯定会欣赏对 PHP 8 新功能的简明介绍。

这本书涵盖了什么

[第一章],介绍新的 PHP 8 面向对象编程特性,向您介绍了针对面向对象编程(OOP)的新 PHP 8 特性。本章包含大量简短的代码示例,清晰地说明了新特性和概念。这一章对于帮助您快速利用 PHP 8 的强大功能并将代码示例适应到您自己的实践中至关重要。

[第二章],了解 PHP 8 的功能增强,涵盖了 PHP 8 在过程级别引入的重要增强和改进。它包括大量的代码示例,展示了新的 PHP 8 特性和技术,以便促进过程式编程。本章教会您如何编写更快、更干净的应用程序代码。

[第三章],利用错误处理增强功能,探讨了 PHP 8 中的一个关键改进,即其先进的错误处理能力。在本章中,您将了解哪些通知已升级为警告,以及哪些警告现在已经升级为错误。本章将帮助您更好地了解安全增强的背景和意图,从而更好地控制代码的使用。此外,了解以前只生成警告但现在生成错误的错误条件是至关重要的,这样您就可以采取措施防止在升级到 PHP 8 后应用程序失败。

第四章《进行直接的 C 语言调用》帮助您了解外部函数接口(FFI)的全部内容,它的作用以及如何使用它。本章的信息对于对使用直接 C 语言调用进行快速自定义原型设计感兴趣的开发人员非常重要。本章向您展示如何直接将 C 语言结构和函数合并到您的代码中,打开了一个迄今为止对 PHP 不可用的整个功能世界的大门。

第五章《发现潜在的面向对象编程向后兼容性破坏》向您介绍了针对面向对象编程的新 PHP 8 功能。本章包含大量清晰说明新功能和概念的简短代码示例。本章对于帮助您快速利用 PHP 8 的强大功能,并将代码示例调整到您自己的实践中非常关键。此外,本章还强调了在 PHP 8 迁移后可能导致面向对象代码中断的情况。

第六章《理解 PHP 8 的功能差异》涵盖了在 PHP 8 命令或功能级别可能出现的向后不兼容性破坏。本章提供了重要信息,突出了将现有代码迁移到 PHP 8 时可能出现的潜在陷阱。本章中提供的信息使您能够编写可靠的 PHP 代码。通过学习本章中的概念,您将更有能力编写能够产生精确结果并避免不一致性的代码。

第七章《在使用 PHP 8 扩展时避免陷阱》带您了解了对扩展所做的主要更改以及在将现有应用程序更新到 PHP 8 时如何避免陷阱。一旦您完成了对示例代码和主题的审阅,您将能够为将任何现有的 PHP 代码准备好迁移到 PHP 8。除了学习各种扩展的变化之外,您还将深入了解它们的运作方式。这将使您能够在 PHP 8 中使用扩展时做出明智的决策。

第八章《了解 PHP 8 中已弃用或移除的功能》带您了解了在 PHP 8 中已经弃用或移除的功能。在阅读了本章的材料并跟随示例应用代码之后,您将能够检测和重写已经弃用的代码。您还将学习如何为已经移除的功能开发解决方案,以及如何重构使用已移除功能的涉及扩展的代码。本章中您还将学习如何通过重写依赖于在 PHP 8 中已完全移除的功能的代码来提高应用程序的安全性。

第九章《掌握 PHP 8 最佳实践》介绍了在 PHP 8 中现在强制执行的最佳实践。它涵盖了许多重要的方法签名更改以及它们的新用法如何延续了 PHP 的一般趋势,帮助您编写更好的代码。您还将了解到关于私有方法、接口、特征和匿名类的使用变化,以及现在如何解析命名空间。掌握本章涵盖的最佳实践不仅会使您更接近编写更好的代码,还将帮助您避免可能出现的代码中断,如果您没有掌握这些新实践的话。

第十章性能改进,向您介绍了一些对性能有积极影响的新 PHP 8 功能,特别关注新的即时编译器。本章还包括对弱引用的全面介绍,正确使用弱引用可以大大减少应用程序的内存使用。通过仔细审阅本章涵盖的内容并学习代码示例,您将能够编写更快速和更高效的代码。

第十一章将现有 PHP 应用迁移到 PHP 8,介绍了一组类,这些类构成了 PHP 8 向后兼容断点扫描器的基础。在整本书中,您将看到可能在 PHP 8 更新后出现的潜在代码断点。此外,您将了解将现有客户 PHP 应用程序迁移到 PHP 8 的推荐流程。本章将使您更好地准备处理 PHP 8 迁移,让您能够更有信心地执行 PHP 8 迁移,并最大程度地减少问题。

第十二章使用异步编程创建 PHP 8 应用程序,解释了传统同步和异步编程模型之间的区别。近年来,一种令人兴奋的新技术席卷了 PHP 社区:异步编程,也称为 PHP async。此外,还涵盖了流行的 PHP 异步扩展和框架,包括 Swoole 扩展和 ReactPHP,并提供了大量示例供您开始使用。通过完成本章的学习,您将能够提高应用程序的性能,使其速度提高 5 倍甚至惊人的 40 倍!

要充分利用本书

要充分利用本书,您必须对 PHP 语法、变量、控制结构(例如,if {} else {})、循环结构(例如,for () {})、数组和函数有基本了解。您还必须对 PHP 面向对象编程有基本了解:类、继承和命名空间。

如果您没有接受过正式的 PHP 培训,或者不确定自己是否具备必要的知识,请查阅在线 PHP 参考手册的以下两个部分:

  • PHP 语言参考:

www.php.net/manual/en/langref.php

  • PHP 面向对象编程:

www.php.net/manual/en/language.oop5.php

以下是本书涵盖的软件摘要:

注意

如果您使用的是本书的数字版本,我们建议您自己输入代码,或者从书的 GitHub 存储库中访问代码(下一节中提供了链接)。这样做将帮助您避免与复制和粘贴代码相关的潜在错误。

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件:github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。如果代码有更新,将在 GitHub 存储库中更新。

我们还有来自丰富图书和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图和图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781801071871_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:“本章还教会了您如何将新的Attribute类用作 PHP DocBlocks 的最终替代品。”

代码块设置如下:

// /repo/ch01/php7_prop_reduce.php
declare(strict_types=1);
class Test {
 protected $id = 0;
 protected $token = 0;
 protected $name = '';o

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

$result = match(<EXPRESSION>) {
    <ITEM> => <EXPRESSION>,
   [<ITEM> => <EXPRESSION>,]
    default => <DEFAULT EXPRESSION>
};

任何命令行输入或输出都将按以下方式编写:

Fatal error: Uncaught TypeError: Cannot assign string to property Test::$token of type int in /repo/ch01/php8_prop_danger.php:12

提示或重要说明

显示如此。

第一部分:PHP 8 提示

本部分介绍了以前从未见过的很酷的东西,是 PHP 8 的新功能。这些章节讨论了面向对象编程中的新特性,接着是功能和扩展级别的新东西。本部分的最后一章涵盖了直接的 C 语言原型设计。

本部分包括以下章节:

  • [第一章],介绍新的 PHP 8 面向对象编程功能

  • [第二章],了解 PHP 8 的功能增强

  • [第三章],利用错误处理增强功能

  • [第四章],进行直接的 C 语言调用

第一章:介绍新的 PHP 8 OOP 特性

在本章中,您将了解到针对面向对象编程OOP)的PHP: Hypertext Preprocessor 8PHP 8)的新特性。本章介绍了一组类,可用于生成 CAPTCHA 图像(CAPTCHACompletely Automated Public Turing test to tell Computers and Humans Apart的缩写),清晰地说明了新的 PHP 8 特性和概念。本章对于帮助您快速将新的 PHP 8 特性纳入到您自己的实践中至关重要。这样做,您的代码将运行得更快、更高效,bug 更少。

本章涵盖以下主题:

  • 使用构造函数属性提升

  • 使用属性

  • 将匹配表达式纳入您的程序代码

  • 理解命名参数

  • 探索新的数据类型

  • 使用类型属性改进代码

技术要求

要检查和运行本章提供的代码示例,以下是最低推荐的硬件要求:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 GB 的可用磁盘空间

  • 4 GB 的随机存取存储器RAM

  • 500 千位每秒Kbps)或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

本书使用一个预构建的 Docker 镜像,其中包含创建和运行本书中涵盖的 PHP 8 代码示例所需的所有软件。您不需要在计算机上安装 PHP、Apache 或 MySQL:只需使用 Docker 和提供的镜像即可。

要设置一个用于运行代码示例的测试环境,请按照以下步骤进行:

  1. 安装 Docker。

如果您正在运行 Windows,请从这里开始:

docs.docker.com/docker-for-windows/install/

如果您使用 Mac,请从这里开始:

docs.docker.com/docker-for-mac/install/

如果您使用 Linux,请看这里:

docs.docker.com/engine/install/

  1. 安装 Docker Compose。对于所有操作系统,请从这里开始:

docs.docker.com/compose/install/

  1. 将与本书相关的源代码安装到您的本地计算机上。

如果您已安装 Git,请使用以下命令:

git clone https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices.git ~/repo

否则,您可以直接从以下统一资源定位器URL)下载源代码:github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices/archive/main.zip。然后解压到一个您创建的文件夹中,在本书中我们将其称为/repo

  1. 您现在可以启动 Docker 守护程序。对于 Windows 或 Mac,您只需要激活 Docker Desktop 应用程序。

如果您正在运行 Ubuntu 或 Debian Linux,请发出以下命令:

sudo service docker start

对于 Red Hat、Fedora 或 CentOS,请使用以下命令:

使用sudo systemctl start docker命令启动 Docker。

  1. 构建与本书相关的 Docker 容器并将其上线。要做到这一点,请按照以下步骤进行。

从您的本地计算机,打开命令提示符(终端窗口)。将目录更改为/repo。仅首次,发出docker-compose build命令来构建环境。请注意,您可能需要root(管理员)权限来运行 Docker 命令。如果是这种情况,要么以管理员身份运行(对于 Windows),要么在命令前加上sudo。根据您的连接速度,初始构建可能需要相当长的时间才能完成!

  1. 要启动容器,请按照以下步骤进行

  2. 从您的本地计算机,打开命令提示符(终端窗口)。将目录更改为/repo。通过运行以下命令以后台模式启动 Docker 容器:

docker-compose up -d

请注意,实际上您不需要单独构建容器。如果在发出docker-compose up命令时容器尚未构建,它将自动构建。另一方面,单独构建容器可能很方便,这种情况下只需使用docker build即可。

这是一个确保所有容器都在运行的有用命令:

docker-compose ps
  1. 要访问运行中的 Docker 容器 Web 服务器,请按照以下步骤进行。

在您的本地计算机上打开浏览器。输入此 URL 以访问 PHP 8 代码:

http://localhost:8888

输入此 URL 以访问 PHP 7 代码:

http://localhost:7777

  1. 要打开运行中的 Docker 容器的命令行,按照以下步骤进行。

从您的本地计算机上,打开命令提示符(终端窗口)。发出此命令以访问 PHP 8 容器:

docker exec -it php8_tips_php8 /bin/bash 

发出此命令以访问 PHP 7 容器:

docker exec -it php8_tips_php7 /bin/bash
  1. 当您完成与容器的工作后,要将其脱机,请从您的本地计算机上打开命令提示符(终端窗口)并发出此命令:
docker-compose down 

本章的源代码位于此处:

github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

重要提示

如果您的主机计算机使用高级精简指令集ARM)架构(例如,树莓派),您将需要使用修改后的 Dockerfile。

提示

通过查看这篇文章,快速了解 Docker 技术和术语是一个很好的主意:docs.docker.com/get-started/.

我们现在可以通过查看构造函数属性提升来开始我们的讨论。

使用构造函数属性提升

除了即时JIT)编译器之外,PHP 8 中引入的最大的新功能之一是构造函数属性提升。这个新功能将属性声明和__construct()方法签名中的参数列表以及赋默认值结合在一起。在本节中,您将学习如何大大减少属性声明和__construct()方法签名和主体中所需的编码量。

属性提升语法

调用构造函数属性提升所需的语法与 PHP 7 和之前使用的语法相同,有以下区别:

  • 您需要定义一个可见级别

  • 您不必事先显式声明属性。

  • 您不需要在__construct()方法的主体中进行赋值

这是一个使用构造函数属性提升的代码的简单示例:

// /repo/ch01/php8_prop_promo.php
declare(strict_types=1);
class Test {
    public function __construct(
        public int $id,
        public int $token = 0,
        public string $name = '')
    { }
}
$test = new Test(999);
var_dump($test);

当执行前面的代码块时,这是输出:

object(Test)#1 (3) {
  ["id"]=> int(999)
  ["token"]=> int(0)
  ["name"]=> string(0) ""
}

这表明使用默认值创建了Test类型的实例。现在,让我们看看这个功能如何可以节省大量的编码。

使用属性提升来减少代码

在传统的 OOP PHP 类中,需要完成以下三件事:

  1. 声明属性,如下所示:
/repo/src/Php8/Image/SingleChar.php
namespace Php7\Image;
class SingleChar {
    public $text     = '';
    public $fontFile = '';
    public $width    = 100;
    public $height   = 100;
    public $size     = 0;
    public $angle    = 0.00;
    public $textX    = 0;
    public $textY    = 0;
  1. __construct()方法签名中标识属性及其数据类型,如下所示:
const DEFAULT_TX_X = 25;
const DEFAULT_TX_Y = 75;
const DEFAULT_TX_SIZE  = 60;
const DEFAULT_TX_ANGLE = 0;
public function __construct(
    string $text,
    string $fontFile,
    int $width  = 100,
    int $height = 100,
    int $size   = self::DEFAULT_TX_SIZE,
    float $angle = self::DEFAULT_TX_ANGLE,
    int $textX  = self::DEFAULT_TX_X,
    int $textY  = self::DEFAULT_TX_Y)   
  1. __construct()方法的主体中,为属性赋值,就像这样:
{   $this->text     = $text;
    $this->fontFile = $fontFile;
    $this->width    = $width;
    $this->height   = $height;
    $this->size     = $size;
    $this->angle    = $angle;
    $this->textX    = $textX;
    $this->textY    = $textY;
    // other code not shown 
}

随着构造函数参数的增加,您需要做的工作量也会显著增加。当应用构造函数属性提升时,以前所需的相同代码量减少到原来的三分之一。

现在让我们看一下之前显示的同一段代码块,但是使用这个强大的新 PHP 8 功能进行重写:

// /repo/src/Php8/Image/SingleChar.php
// not all code shown
public function __construct(
    public string $text,
    public string $fontFile,
    public int    $width    = 100,
    public int    $height   = 100,
    public int    $size     = self::DEFAULT_TX_SIZE,
    public float   $angle    = self::DEFAULT_TX_ANGLE,
    public int    $textX    = self::DEFAULT_TX_X,
    public int    $textY    = self::DEFAULT_TX_Y)
    { // other code not shown }

令人惊讶的是,在 PHP 7 和之前的版本中需要 24 行代码,而使用这个新的 PHP 8 功能可以缩减为 8 行代码!

您完全可以在构造函数中包含其他代码。然而,在许多情况下,构造函数属性提升会处理__construct()方法中通常完成的所有工作,这意味着您可以将其留空({ })。

现在,在下一节中,您将了解一个称为属性的新功能。

提示

在这里查看 PHP 7 的完整 SingleChar 类的更多信息:

github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices/tree/main/src/Php7/Image

此外,等效的 PHP 8 类可以在这里找到:

github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices/tree/main/src/Php8/Image

有关此新功能的更多信息,请参阅以下内容:

wiki.php.net/rfc/constructor_promotion

使用 attributes

PHP 8 的另一个重要补充是全新的类和语言构造,称为attributes。简而言之,attributes 是传统 PHP 注释块的替代品,遵循规定的语法。当 PHP 代码编译时,这些 attributes 会在内部转换为Attribute类实例。

这个新功能不会立即影响您的代码。然而,随着各种 PHP 开源供应商开始将 attributes 纳入其代码中,它将开始变得越来越有影响力。

Attribute类解决了我们在本节讨论的一个潜在重要的性能问题,即滥用传统 PHP 注释块提供元指令。在我们深入讨论这个问题以及Attribute类实例如何解决问题之前,我们首先必须回顾一下 PHP 注释。

PHP 注释概述

这种语言构造的需求是随着对普通 PHP 注释的使用(和滥用!)的增加而产生的。正如您所知,注释有许多形式,包括以下所有形式:

# This is a "bash" shell script style comment
// this can either be inline or on its own line
/* This is the traditional "C" language style */
/**
 * This is a PHP "DocBlock"
 */

最后一项,著名的 PHP DocBlock,现在被广泛使用,已成为事实上的标准。使用 DocBlocks 并不是一件坏事。相反,这往往是开发人员能够传达有关属性、类和方法的信息的唯一 方式。问题只在于它在 PHP 解释过程中的处理方式。

PHP DocBlock 注意事项

PHP DocBlock的原始意图已经被一些非常重要的 PHP 开源项目所拉伸。一个鲜明的例子是 Doctrine 对象关系映射ORM)项目。虽然不是强制的,但许多开发人员选择使用嵌套在 PHP DocBlocks 中的annotations来定义 ORM 属性。

看一下这个部分代码示例,它定义了一个与名为events的数据库表交互的类:

namespace Php7\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Table(name="events")
 * @ORM\Entity("Application\Entity\Events")
 */
class Events {
    /**
     * @ORM\Column(name="id",type="integer",nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;
    /**
     * @ORM\Column(name="event_key", type="string", 
          length=16, nullable=true, options={"fixed"=true})
     */
    private $eventKey;
    // other code not shown

如果您要将此类用作 Doctrine ORM 实现的一部分,Doctrine 将打开文件并解析 DocBlocks,搜索@ORM注释。尽管对解析 DocBlocks 所需的时间和资源有一些担忧,但这是一种非常方便的方式来定义对象属性和数据库表列之间的关系,并且受到使用 Doctrine 的开发人员的欢迎。

提示

Doctrine 提供了许多替代方案来实现 ORM,包括可扩展标记语言XML)和本机 PHP 数组。有关更多信息,请参阅www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/annotations-reference.html#annotations-reference

与滥用 DocBlocks 相关的潜在危险

与滥用 DocBlock 的原始目的相关的另一个危险是。在php.ini文件中,有一个名为opcache.save_comments的设置。如果禁用,这将导致 OpCode 缓存引擎(OPcache忽略所有注释,包括 DocBlocks。如果此设置生效,使用@ORM注释的基于 Doctrine 的应用程序将发生故障。

另一个问题与注释的解析有关,或者更准确地说,与注释的解析有关。为了使用注释的内容,PHP 应用程序需要逐行打开文件并解析它。这在时间和资源利用方面是一个昂贵的过程。

属性类

为了解决隐藏的危险,在 PHP 8 中提供了一个新的Attribute类。开发人员可以定义等效的属性形式,而不是使用带注释的 DocBlocks。使用属性而不是 DocBlocks 的优势在于它们是语言的正式部分,因此它们与代码的其余部分一起被标记化和编译。

重要提示

在本章中,以及在 PHP 文档中,属性的引用指的是Attribute类的实例。

目前尚无实际的性能指标可比较包含 DocBlocks 的 PHP 代码的加载与包含属性的代码的加载。

尽管这种方法的好处尚未显现,但随着各种开源项目供应商开始将属性纳入其产品中,您将开始看到速度和性能的提高。

这是Attribute类的定义:

class Attribute {
    public const int TARGET_CLASS = 1;
    public const int TARGET_FUNCTION = (1 << 1);
    public const int TARGET_METHOD = (1 << 2);
    public const int TARGET_PROPERTY = (1 << 3);
    public const int TARGET_CLASS_CONSTANT = (1 << 4);
    public const int TARGET_PARAMETER = (1 << 5);
    public const int TARGET_ALL = ((1 << 6) - 1);
    public function __construct(
        int $flags = self::TARGET_ALL) {}
}

从类定义中可以看出,这个类在 PHP 8 内部使用的主要贡献是一组类常量。这些常量代表可以使用位运算符组合的位标志。

属性语法

属性使用了从Rust编程语言借鉴的特殊语法。方括号内的内容基本上由开发人员决定。以下代码段中可以看到一个示例:

#[attribute("some text")] 
// class, property, method or function (or whatever!)

回到我们的SingleChar类的示例,这是如何在传统的 DocBlocks 中出现的:

// /repo/src/Php7/Image/SingleChar.php
namespace Php7\Image;
/**
 * Creates a single image, by default black on white
 */
class SingleChar {
    /**
     * Allocates a color resource
     *
     * @param array|int $r,
     * @param int $g
     * @param int $b]
     * @return int $color
     */
    public function colorAlloc() 
    { /* code not shown */ } 

现在,看看使用属性的相同内容:

// /repo/src/Php8/Image/SingleChar.php
namespace Php8\Image;
#[description("Creates a single image")]
class SingleChar {
    #[SingleChar\colorAlloc\description("Allocates color")]
    #[SingleChar\colorAlloc\param("r","int|array")]
    #[SingleChar\colorAlloc\param("g","int")]
    #[SingleChar\colorAlloc\param("b","int")]
    #[SingleChar\colorAlloc\returns("int")]
    public function colorAlloc() { /* code not shown */ }

如您所见,除了提供更强大的编译和避免上述隐藏危险之外,它在空间使用方面也更有效。

提示

方括号内的内容确实有一些限制;例如,虽然允许#[returns("int")],但不允许这样做:#[return("int")。原因是return是一个关键字。

另一个例子涉及联合类型(在探索新数据类型部分中解释)。您可以在属性中使用#[param("int|array test")],但不允许这样做:#[int|array("test")]。另一个特殊之处是类级别的属性必须放在class关键字之前,并在任何use语句之后。

使用 Reflection 查看属性

如果您需要从 PHP 8 类获取属性信息,Reflection扩展已更新以包括属性支持。添加了一个新的getAttributes()方法,返回一个ReflectionAttribute实例数组。

在以下代码块中,显示了Php8\Image\SingleChar::colorAlloc()方法的所有属性:

<?php
// /repo/ch01/php8_attrib_reflect.php
define('FONT_FILE', __DIR__ . '/../fonts/FreeSansBold.ttf');
require_once __DIR__ . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php8\Image\SingleChar;
$char    = new SingleChar('A', FONT_FILE);
$reflect = new ReflectionObject($char);
$attribs = $reflect->getAttributes();
echo "Class Attributes\n";
foreach ($attribs as $obj) {
    echo "\n" . $obj->getName() . "\n";
    echo implode("\t", $obj->getArguments());
}
echo "Method Attributes for colorAlloc()\n";
$reflect = new ReflectionMethod($char, 'colorAlloc');
$attribs = $reflect->getAttributes();
foreach ($attribs as $obj) {
    echo "\n" . $obj->getName() . "\n";
    echo implode("\t", $obj->getArguments());
}

以下是前面代码段中显示的输出:

<pre>Class Attributes
Php8\Image\SingleChar
Php8\Image\description
Creates a single image, by default black on whiteMethod
Attributes for colorAlloc()
Php8\Image\SingleChar\colorAlloc\description
Allocates a color resource
Php8\Image\SingleChar\colorAlloc\param
r    int|array
Php8\Image\SingleChar\colorAlloc\param
g    int
Php8\Image\SingleChar\colorAlloc\param
b    int
Php8\Image\SingleChar\colorAlloc\returns
int

前面的输出显示了可以使用Reflection扩展类检测属性。最后,这段代码示例展示了实际的方法:

namespace Php8\Image;use Attribute;
use Php8\Image\Strategy\ {PlainText,PlainFill};
#[SingleChar]
#[description("Creates black on white image")]
class SingleChar {
    // not all code is shown
    #[SingleChar\colorAlloc\description("Allocates color")]
    #[SingleChar\colorAlloc\param("r","int|array")]
    #[SingleChar\colorAlloc\param("g","int")]
    #[SingleChar\colorAlloc\param("b","int")]
    #[SingleChar\colorAlloc\returns("int")]    
    public function colorAlloc(
         int|array $r, int $g = 0, int $b = 0) {
        if (is_array($r))
            [$r, $g, $b] = $r;
        return \imagecolorallocate(
              $this->image, $r, $g, $b);
    }
}

现在,您已经了解了属性的使用方式,让我们继续讨论match表达式,然后是命名参数的新功能。

提示

有关此新功能的更多信息,请查看以下网页:

wiki.php.net/rfc/attributes_v2

另请参阅此更新:

wiki.php.net/rfc/shorter_attribute_syntax_change

有关 PHP DocBlocks 的信息可以在这里找到:

phpdoc.org/

有关 Doctrine ORM 的更多信息,请查看这里:

www.doctrine-project.org/projects/orm.html

有关php.ini文件设置的文档可以在这里找到:

www.php.net/manual/en/ini.list.php

在这里阅读有关 PHP 反射的信息:

www.php.net/manual/en/language.attributes.reflection.php

有关 Rust 编程语言的信息可以在这本书中找到:www.packtpub.com/product/mastering-rust-second-edition/9781789346572

将 match 表达式合并到程序代码中

在 PHP 8 中引入的许多非常有用的功能中,match 表达式绝对脱颖而出。Match表达式是一种更准确的简写语法,可以潜在地取代直接来自 C 语言的老旧switch语句。在本节中,您将学习如何通过用match表达式替换switch语句来生成更清晰和更准确的程序代码。

Match 表达式的一般语法

Match表达式语法非常类似于数组,其中键是要匹配的项,值是一个表达式。以下是match的一般语法:

$result = match(<EXPRESSION>) {
    <ITEM> => <EXPRESSION>,
   [<ITEM> => <EXPRESSION>,]
    default => <DEFAULT EXPRESSION>
};

表达式必须是有效的 PHP 表达式。表达式的示例可以包括以下任何一种:

  • 一个特定的值(例如,"一些文本"

  • 一个操作(例如,$a + $b

  • 匿名函数或类

唯一的限制是表达式必须在一行代码中定义。matchswitch之间的主要区别在这里总结:

表 1.1 - match 和 switch 之间的区别

表 1.1 - match 和 switch 之间的区别

除了上述区别之外,matchswitch都允许案例聚合,并提供对default案例的支持。

switch 和 match 示例

这是一个简单的示例,使用switch来渲染货币符号:

// /repo/ch01/php7_switch.php
function get_symbol($iso) {
    switch ($iso) {
        case 'CNY' :
            $sym = '¥';
            break;
        case 'EUR' :
            $sym = '€';
            break;
        case 'EGP' :
        case 'GBP' :
            $sym = '£';
            break;
        case 'THB' :
            $sym = '฿';
            break;
        default :
            $sym = '$';
    }
    return $sym;
}
$test = ['CNY', 'EGP', 'EUR', 'GBP', 'THB', 'MXD'];
foreach ($test as $iso)
    echo 'The currency symbol for ' . $iso
         . ' is ' . get_symbol($iso) . "\n";

当执行此代码时,您会看到$test数组中每个国际标准化组织ISO)货币代码的货币符号。在 PHP 8 中可以获得与前面代码片段中显示的相同结果,使用以下代码:

// /repo/ch01/php8_switch.php
function get_symbol($iso) {
    return match ($iso) {
        'EGP','GBP' => '£',
        'CNY'       => '¥',
        'EUR'       => '€',
        'THB'       => '฿',
        default     => '$'
    };
}
$test = ['CNY', 'EGP', 'EUR', 'GBP', 'THB', 'MXD'];
foreach ($test as $iso)
    echo 'The currency symbol for ' . $iso
         . ' is ' . get_symbol($iso) . "\n";

两个示例产生相同的输出,如下所示:

The currency symbol for CNY is ¥
The currency symbol for EGP is £
The currency symbol for EUR is €
The currency symbol for GBP is £
The currency symbol for THB is ฿
The currency symbol for MXD is $

如前所述,这两个代码示例都会为存储在$test数组中的 ISO 货币代码列表产生货币符号列表。

复杂的 match 示例

回到我们的验证码项目,假设我们希望引入扭曲以使验证码字符更难阅读。为了实现这个目标,我们引入了许多策略类,每个类产生不同的扭曲,如下表所总结的:

表 1.2 - 验证码扭曲策略类

表 1.2 - 验证码扭曲策略类

在随机排列要使用的策略列表之后,我们使用match表达式来执行结果,如下所示:

  1. 首先我们定义一个自动加载程序,导入要使用的类,并列出要使用的潜在策略,如下所示:
// /repo/ch01/php8_single_strategies.php
// not all code is shown
require_once __DIR__ . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php8\Image\SingleChar;
use Php8\Image\Strategy\ {LineFill,DotFill,Shadow,RotateText};
$strategies = ['rotate', 'line', 'line',
               'dot', 'dot', 'shadow'];
  1. 接下来,我们生成验证码短语,如下所示:
$phrase = strtoupper(bin2hex(random_bytes(NUM_BYTES)));
$length = strlen($phrase);
  1. 然后我们循环遍历验证码短语中的每个字符,并创建一个SingleChar实例。对writeFill()的初始调用创建了白色背景画布。我们还需要调用shuffle()来随机排列扭曲策略的列表。该过程在以下代码片段中说明:
$images = [];
for ($x = 0; $x < $length; $x++) {
    $char = new SingleChar($phrase[$x], FONT_FILE);
    $char->writeFill();
    shuffle($strategies);
  1. 然后我们循环遍历策略并在原始图像上叠加扭曲。这就是match表达式发挥作用的地方。请注意,一个策略需要额外的代码行。因为match只能支持单个表达式,所以我们简单地将多行代码包装到一个匿名函数中,如下所示:
foreach ($strategies as $item) {
    $func = match ($item) {    
        'rotate' => RotateText::writeText($char),
        'line' => LineFill::writeFill(
            $char, rand(1, 10)),
        'dot' => DotFill::writeFill($char, rand(10, 20)),
        'shadow' => function ($char) {
            $num = rand(1, 8);
            $r   = rand(0x70, 0xEF);
            $g   = rand(0x70, 0xEF);
            $b   = rand(0x70, 0xEF);
            return Shadow::writeText(
                $char, $num, $r, $g, $b);},
        'default' => TRUE
    };
    if (is_callable($func)) $func($char);
}
  1. 现在要做的就是通过不带参数调用writeText()来覆盖图像。之后,我们将扭曲的图像保存为便携式网络图形PNG)文件以供显示,如下面的代码片段所示:
    $char->writeText();
    $fn = $x . '_' 
         . substr(basename(__FILE__), 0, -4) 
         . '.png';
    $char->save(IMG_DIR . '/' . $fn);
    $images[] = $fn;
}
include __DIR__ . '/captcha_simple.phtml';

这是从指向本书关联的 Docker 容器的浏览器中运行前面示例的结果:

图 1.1 - 使用匹配表达式扭曲的验证码

图 1.1 - 使用匹配表达式扭曲的验证码

接下来,我们将看一下另一个非常棒的功能:命名参数。

提示

您可以在这里看到match表达式的原始提案:wiki.php.net/rfc/match_expression_v2

理解命名参数

命名参数代表一种避免在调用具有大量参数的函数或方法时产生混淆的方法。这不仅有助于避免参数以不正确的顺序提供的问题,还有助于您跳过具有默认值的参数。在本节中,您将学习如何应用命名参数来提高代码的准确性,减少未来维护周期中的混淆,并使您的方法和函数调用更加简洁。我们首先来看一下使用命名参数所需的通用语法。

命名参数通用语法

要使用命名参数,您需要知道函数或方法签名中使用的变量的名称。然后,您可以指定该名称,不带美元符号,后跟冒号和要提供的值,如下所示:

$result = function_name( arg1 : <VALUE>, arg2 : <value>);

当调用function_name()函数时,值将传递给与arg1arg2等对应的参数。

使用命名参数调用核心函数

使用命名参数的最常见原因之一是调用具有大量参数的核心 PHP 函数。例如,这是setcookie()的函数签名:

setcookie ( string $name [, string $value = "" 
    [, int $expires = 0 [, string $path = "" 
    [, string $domain = "" [, bool $secure = FALSE 
    [, bool $httponly = FALSE ]]]]]] ) : bool

假设您真正想要设置的只是namevaluehttponly参数。在 PHP 8 之前,您需要查找默认值并按顺序提供它们,直到您到达要覆盖的值。在下面的情况下,我们希望将httponly设置为TRUE

setcookie('test',1,0,0,'','',FALSE,TRUE);

使用命名参数,在 PHP 8 中的等效方式如下:

setcookie('test',1,httponly: TRUE);

请注意,我们不需要为前两个参数命名,因为它们是按顺序提供的。

提示

在 PHP 扩展中,命名参数并不总是与您在 PHP 文档中看到的函数或方法签名的变量名称匹配。例如,函数imagettftext()在其函数签名中显示一个变量$font_filename。然而,如果您再往下滚动一点,您会在参数部分看到,命名参数是fontfile

如果遇到致命错误:未知命名参数$NAMED_PARAM。始终使用文档中参数部分列出的名称,而不是函数或方法签名中变量的名称。

顺序独立和文档

命名参数的另一个用途是提供顺序独立。此外,对于某些核心 PHP 函数来说,参数的数量之多构成了文档的噩梦。

例如,看一下imagefttext()的函数签名(请注意,这个函数是生成安全验证码图像的章节项目的核心):

imagefttext ( object $image , float $size , float $angle , 
    int $x , int $y , int $color , string $fontfile , 
    string $text [, array $extrainfo ] ) : array 

正如你可以想象的那样,在 6 个月后回顾你的工作时,试图记住这些参数的名称和顺序可能会有问题。

重要提示

在 PHP 8 中,图像创建函数(例如imagecreate())现在返回一个GdImage对象实例,而不是一个资源。GD 扩展中的所有图像函数都已经重写以适应这一变化。没有必要重写您的代码!

因此,在 PHP 8 中,使用命名参数,以下函数调用将是可接受的:

// /repo/ch01/php8_named_args.php
// not all code is shown
$rotation = range(40, -40, 10);
foreach ($rotation as $key => $offset) {
    $char->writeFill();
    [$x, $y] = RotateText::calcXYadjust($char, $offset);
    $angle = ($offset > 0) ? $offset : 360 + $offset;
    imagettftext(
        angle        : $angle,
        color        : $char->fgColor,
        font_filename : FONT_FILE,
        image        : $char->image,
        size         : 60,                
        x            : $x,
        y            : $y,
        text         : $char->text);
    $fn = IMG_DIR . '/' . $baseFn . '_' . $key . '.png';
    imagepng($char->image, $fn);
    $images[] = basename($fn);
}

刚才显示的代码示例将一串扭曲字符写成一组 PNG 图像文件。每个字符相对于其相邻图像顺时针旋转 10 度。请注意,命名参数的应用使imagettftext()函数的参数更容易理解。

命名参数也可以应用于您自己创建的函数和方法。在下一节中,我们将介绍新的数据类型。

提示

关于命名参数的详细分析可以在这里找到:

wiki.php.net/rfc/named_params

探索新的数据类型

任何初级 PHP 开发人员学到的一件事是 PHP 有哪些可用的数据类型以及如何使用它们。基本数据类型包括int(整数)、floatbool(布尔值)和string。复杂数据类型包括arrayobject。此外,还有其他数据类型,如NULLresource。在本节中,我们将讨论 PHP 8 中引入的一些新数据类型,包括联合类型和混合类型。

重要说明

非常重要的一点是不要混淆数据类型数据格式。本节描述了数据类型。另一方面,数据格式将是用作传输或存储的数据的表示方式。数据格式的示例包括 XML,JavaScript 对象表示JSON)和YAML 不是标记语言YAML)。

联合类型

intstring等其他数据类型不同,重要的是要注意,没有一个名为union的数据类型。相反,当你看到联合类型的引用时,意思是 PHP 8 引入了一种新的语法,允许您指定多种类型,而不仅仅是一种。现在让我们来看一下联合类型的通用语法。

联合类型语法

联合类型的通用语法如下:

function ( type|type|type $var) {}

type的位置,您可以提供任何现有的数据类型(例如floatstring)。然而,有一些限制,大部分都是完全有道理的。这张表总结了更重要的限制:

表 1.3 - 不允许的联合类型

表 1.3 - 不允许的联合类型

从这个例外列表中可以看出,定义联合类型主要是常识问题。

提示

最佳实践:在使用联合类型时,如果不强制执行严格类型检查,类型强制转换(PHP 内部转换数据类型以满足函数要求的过程)可能会成为一个问题。因此,最佳实践是在使用联合类型的任何文件顶部添加以下内容:declare(strict_types=1);

有关更多信息,请参阅此处的文档参考:

www.php.net/manual/en/language.types.declarations.php#language.types.declarations.strict

联合类型示例

为了简单说明,让我们回到本章中使用的SingleChar类作为示例。其中的一个方法是colorAlloc()。该方法从图像中分配颜色,利用了imagecolorallocate()函数。它接受表示红色、绿色和蓝色的整数值作为参数。

为了论证,假设第一个参数实际上可以是表示三个值的数组——分别是红色、绿色和蓝色。在这种情况下,第一个值的参数类型不能是int,否则,如果提供了一个数组,并且打开了严格类型检查,将会抛出错误。

在 PHP 的早期版本中,唯一的解决方案是从第一个参数中删除任何类型检查,并指示在相关的 DocBlock 中接受多种类型。以下是在 PHP 7 中该方法可能的样子:

/**
 * Allocates a color resource
 *
 * @param array|int $r
 * @param int $g
 * @param int $b]
 * @return int $color
 */
public function colorAlloc($r, $g = 0, $b = 0) {
    if (is_array($r)) {
        [$r, $g, $b] = $r;
    }
    return \imagecolorallocate($this->image, $r, $g, $b);
}

第一个参数$r的数据类型唯一的指示是@param array|int $r的 DocBlock 注释和没有与该参数关联的数据类型提示。在 PHP 8 中,利用联合类型,注意这里的区别:

#[description("Allocates a color resource")]
#[param("int|array r")]
#[int("g")]
#[int("b")]
#[returns("int")]
public function colorAlloc(
    int|array $r, int $g = 0, int $b = 0) {
    if (is_array($r)) {
        [$r, $g, $b] = $r;
    }
    return \imagecolorallocate($this->image, $r, $g, $b);
}

在前面的示例中,除了attribute的存在表明第一个参数可以接受arrayint类型之外,在方法签名本身中,int|array联合类型清楚地说明了这个选择。

混合类型

mixed是 PHP 8 中引入的另一种新类型。与联合类型不同,mixed是一个实际的数据类型,代表了所有类型的最终联合。它用于表示接受任何和所有数据类型。在某种意义上,PHP 已经具有了这个功能:简单地省略数据类型,它就是一个隐含的mixed类型!

提示

您将在 PHP 文档中看到对mixed类型的引用。PHP 8 通过将其作为实际数据类型来正式表示这种表示。

为什么使用混合类型?

等一下——你可能会想到:为什么要使用mixed类型呢?放心,这是一个很好的问题,没有强制使用这种类型的理由。

然而,通过在函数或方法签名中使用mixed,您清楚地*表明了您对该参数的使用意图。如果您只是留空数据类型,其他开发人员在以后使用或审查您的代码时可能会认为您忘记添加类型。至少,他们会对未命名参数的性质感到不确定。

混合类型对继承的影响

作为mixed类型代表扩宽的最终示例,它可以用于在一个类继承另一个类时扩宽数据类型定义。以下是使用mixed类型的示例,说明了这个原则:

  1. 首先,我们用更严格的数据类型object定义父类,如下所示:
// /repo/ch01/php8_mixed_type.php
declare(strict_types=1);
class High {
    const LOG_FILE = __DIR__ . '/../data/test.log';  
    protected static function logVar(object $var) {     
        $item = date('Y-m-d') . ':'
              . var_export($var, TRUE);
        return error_log($item, 3, self::LOG_FILE);
    }
}
  1. 接下来,我们定义一个Low类,它继承自High,如下所示:
class Low extends High {
    public static function logVar(mixed $var) {
        $item = date('Y-m-d') . ':'
            . var_export($var, TRUE);
        return error_log($item, 3, self::LOG_FILE);
    }
}

请注意,在Low类中,logVar()方法的数据类型已经扩宽mixed

  1. 最后,我们创建了一个Low的实例,并用测试数据执行它。从下面的代码片段中显示的结果可以看出,一切都运行正常:
if (file_exists(High::LOG_FILE)) unlink(High::LOG_FILE)
$test = [
    'array' => range('A', 'F'),
    'func' => function () { return __CLASS__; },
    'anon' => new class () { 
        public function __invoke() { 
            return __CLASS__; } },
];
foreach ($test as $item) Low::logVar($item);
readfile(High::LOG_FILE);

以下是前面示例的输出:

2020-10-15:array (
  0 => 'A',
  1 => 'B',
  2 => 'C',
  3 => 'D',
  4 => 'E',
  5 => 'F',
)2020-10-15:Closure::__set_state(array(
))2020-10-15:class@anonymous/repo/ch01/php8_mixed_type.php:28$1::__set_state(array())

前面的代码块记录了各种不同的数据类型,然后显示了日志文件的内容。在这个过程中,这向我们展示了在 PHP 8 中,当子类覆盖父类方法并用mixed代替更严格的数据类型,如object时,不存在继承问题。

接下来,我们来看一下如何使用有类型的属性。

提示

最佳实践:在定义函数或方法时,为所有参数分配特定的数据类型。如果接受几种不同的数据类型,定义一个联合类型。否则,如果没有适用上述情况,退而使用mixed类型。

关于联合类型的信息,请参阅此文档页面:

wiki.php.net/rfc/union_types_v2

有关mixed类型的更多信息,请查看这里:wiki.php.net/rfc/mixed_type_v2.

改进使用有类型属性的代码

在本章的第一部分,使用构造函数属性提升,我们讨论了如何使用数据类型来控制提供给函数或类方法的参数的数据类型。然而,这种方法未能保证数据类型永远不会改变。在本节中,您将学习如何在属性级别分配数据类型,从而更严格地控制 PHP 8 中变量的使用。

有类型属性是什么?

这个非常重要的特性是在 PHP 7.4 中引入的,并在 PHP 8 中继续。简而言之,有类型属性是一个预先分配数据类型的类属性。以下是一个简单的例子:

// /repo/ch01/php8_prop_type_1.php
declare(strict_types=1)
class Test {
    public int $id = 0;
    public int $token = 0;
    public string $name = '';
}
$test = new Test();
$test->id = 'ABC';

在这个例子中,如果我们尝试将代表int以外的数据类型的值分配给$test->id,将会抛出Fatal error。以下是输出:

Fatal error: Uncaught TypeError: Cannot assign string to property Test::$id of type int in /repo/ch01/php8_prop_type_1.php:11 Stack trace: #0 {main} thrown in /repo/ch01/php8_prop_type_1.php on line 11 

如您从上面的输出中所见,当错误的数据类型分配给类型化属性时,将会抛出Fatal error

您已经接触过一种属性类型化的形式:构造函数属性提升。使用构造函数属性提升定义的所有属性都会自动进行属性类型化!

为什么属性类型化很重要?

类型化属性是 PHP 中首次出现的一般趋势的一部分,该趋势是朝着限制和加强代码使用的语言细化发展。这导致更好的代码,意味着更少的错误。

以下示例说明了仅依赖属性类型提示来控制属性数据类型的危险:

// /repo/ch01/php7_prop_danger.php
declare(strict_types=1);
class Test {
    protected $id = 0;
    protected $token = 0;
    protected $name = '';
    public function __construct(
        int $id, int $token, string $name) {
        $this->id = $id;
        $this->token = md5((string) $token);
        $this->name = $name;
    }
}
$test = new Test(111, 123456, 'Fred');
var_dump($test);

在上面的例子中,注意在__construct()方法中,$token属性被意外转换为字符串。以下是输出:

object(Test)#1 (3) {
  ["id":protected]=>  int(111)
  ["token":protected]=>
  string(32) "e10adc3949ba59abbe56e057f20f883e"
  ["name":protected]=>  string(4) "Fred"
}

任何后续的代码如果期望$token是一个整数,可能会失败或产生意外的结果。现在,让我们看一下在 PHP 8 中使用类型化属性的相同情况:

// /repo/ch01/php8_prop_danger.php
declare(strict_types=1);
class Test {
    protected int $id = 0;
    protected int $token = 0;
    protected string $name = '';
    public function __construct(
        int $id, int $token, string $name) {        
        $this->id = $id;
        $this->token = md5((string) $token);
        $this->name = $name;
    }
}
$test = new Test(111, 123456, 'Fred');
var_dump($test);

属性类型化可以防止预分配的数据类型发生任何更改,如您在此处所见的输出所示:

Fatal error: Uncaught TypeError: Cannot assign string to property Test::$token of type int in /repo/ch01/php8_prop_danger.php:12

如您从上面的输出中所见,当错误的数据类型分配给类型化属性时,将会抛出Fatal error。这个例子表明,不仅将数据类型分配给属性可以防止在进行直接赋值时的误用,而且还可以防止在类方法中误用属性!

属性类型化可以导致代码量的减少

引入属性类型化到您的代码中的另一个有益的副作用是可能减少所需的代码量。例如,考虑当前的做法,即将属性标记为privateprotected的可见性,然后创建一系列用于控制访问的getset方法(也称为getterssetters)。

这可能如下所示:

  1. 首先,我们定义一个带有受保护属性的Test类,如下所示:
// /repo/ch01/php7_prop_reduce.php
declare(strict_types=1);
class Test {
 protected $id = 0;
 protected $token = 0;
 protected $name = '';o
  1. 接下来,我们定义一系列用于控制对受保护属性的访问的getset方法,如下所示:
    public function getId() { return $this->id; }
    public function setId(int $id) { $this->id = $id; 
    public function getToken() { return $this->token; }
    public function setToken(int $token) {
        $this->token = $token;
    }
    public function getName() {
        return $this->name;
    }
    public function setName(string $name) {
        $this->name = $name;
    }
}
  1. 然后,我们使用set方法来分配值,如下所示:
$test = new Test();
$test->setId(111);
$test->setToken(999999);
$test->setName('Fred');
  1. 最后,我们使用get方法以表格形式显示结果,如下所示:
$pattern = '<tr><th>%s</th><td>%s</td></tr>';
echo '<table width="50%" border=1>';
printf($pattern, 'ID', $test->getId());
printf($pattern, 'Token', $test->getToken());
printf($pattern, 'Name', $test->getName());
echo '</table>';

这可能如下所示:

表 1.4 - 使用 Get 方法输出

表 1.4 - 使用 Get 方法输出

通过将属性标记为protected(或private)并定义getterssetters来实现的主要目的是控制访问。通常,这意味着希望阻止属性数据类型的更改。如果是这种情况,整个基础设施可以通过分配属性类型来替换。

将可见性简单地更改为public可以减轻对getset方法的需求;但是,它并不能防止属性数据被更改!使用 PHP 8 属性类型既实现了这两个目标:它消除了getset方法的需求,也防止了数据类型被意外更改。

注意在 PHP 8 中使用属性类型化实现相同结果所需的代码量大大减少了:

// /repo/ch01/php8_prop_reduce.php
declare(strict_types=1);
class Test {
    public int $id = 0;
    public int $token = 0;
    public string  $name = '';
}
// assign values
$test = new Test();
$test->id = 111;
$test->token = 999999;
$test->name = 'Fred';
// display results
$pattern = '<tr><th>%s</th><td>%s</td></tr>';
echo '<table width="50%" border=1>';
printf($pattern, 'ID', $test->id);
printf($pattern, 'Token', $test->token);
printf($pattern, 'Name', $test->name);
echo '</table>';

上面显示的代码示例产生了与前一个示例完全相同的输出,并且还实现了对属性数据类型的更好控制。在这个例子中,使用类型化属性,我们实现了50%的代码减少来产生相同的结果!

提示

最佳实践:尽可能在可能的情况下使用类型化属性,除非您明确希望允许数据类型更改。

总结

在本章中,您学习了如何使用新的 PHP 8 数据类型:混合类型和联合类型来编写更好的代码。您还了解到使用命名参数不仅可以提高代码的可读性,还可以帮助防止意外误用类方法和 PHP 函数,同时提供了一个很好的方法来跳过默认参数。

本章还教会了您如何使用新的Attribute类作为 PHP DocBlocks 的潜在替代品,以改善代码的整体性能,同时提供了一种可靠的方式来记录类、方法和函数。

此外,我们还看到 PHP 8 如何通过利用构造函数参数提升和类型化属性大大减少了早期 PHP 版本所需的代码量。

在下一章中,您将学习有关功能和过程级别的新 PHP 8 功能。

第二章:学习 PHP 8 的功能增强

本章将带您了解在程序级别引入的PHP 8的重要增强和改进。使用的代码示例展示了新的 PHP 8 功能和技术,以便促进程序化编程。

掌握本章中新函数和技术的使用将帮助您编写更快、更干净的应用程序。尽管本章重点介绍命令和函数,但在开发类方法时,所有这些技术也很有用。

本章涵盖以下主题:

  • 使用新的 PHP 8 操作符

  • 使用箭头函数

  • 理解统一变量语法

  • 学习新的数组和字符串处理技术

  • 使用 authorizer 保护 SQLite 数据库

技术要求

要检查和运行本章提供的代码示例,以下是最低推荐的硬件要求:

  • 基于 x86_64 的台式机或笔记本电脑

  • 1 千兆字节(GB)的可用磁盘空间

  • 4 GB 的随机存取存储器(RAM)

  • 每秒 500 千位(Kbps)或更快的互联网连接

  • 另外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅第一章技术要求部分,介绍了如何构建用于演示本书中代码的 Docker 容器。在整个过程中,我们将参考您恢复本书示例代码的目录为/repo

本章的源代码位于此处:github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

我们现在可以开始讨论新的 PHP 8 操作符了。

使用新的 PHP 8 操作符

PHP 8 引入了许多新的操作符。此外,PHP 8 通常引入了一种统一和一致的方式来使用这些操作符。在本节中,我们将讨论以下操作符:

  • variadics 操作符

  • Nullsafe 操作符

  • 连接操作符

  • 三元操作符

让我们从讨论 variadics 操作符开始。

使用 variadics 操作符

variadics操作符由三个前导点(...)组成,位于普通 PHP 变量(或对象属性)之前。这个操作符实际上从 PHP 5.6 版本开始就存在了。它也被称为以下内容:

  • Splat 操作符

  • 散列操作符

  • 扩展操作符

在我们深入研究 PHP 8 使用这个操作符的改进之前,让我们快速看一下这个操作符通常的用法。

未知数量的参数

variadics 操作符最常见的用途之一是在定义具有未知数量参数的函数的情况下。

在以下代码示例中,multiVardump()函数能够接受任意数量的变量。然后连接var_export()的输出并返回一个字符串:

// /repo/ch02/php7_variadic_params.php
function multiVardump(...$args) {
    $output = '';
    foreach ($args as $var)
        $output .= var_export($var, TRUE);
    return $output;
}
$a = new ArrayIterator(range('A','F'));
$b = function (string $val) { return str_rot13($val); };
$c = [1,2,3];
$d = 'TEST';
echo multiVardump($a, $b, $c);
echo multiVardump($d);

第一次调用函数时,我们提供了三个参数。第二次调用时,我们只提供了一个参数。由于我们使用了 variadics 操作符,所以无需重写函数来适应更多或更少的参数。

提示

有一个func_get_args() PHP 函数,可以将所有函数参数收集到一个数组中。但是,variadics 操作符更受青睐,因为它必须在函数签名中声明,从而使程序开发人员的意图更加清晰。更多信息,请参阅php.net/func_get_args

吸入剩余参数

variadics 操作符的另一个用途是吸入任何剩余参数。这种技术允许您将强制参数与未知数量的可选参数混合使用。

在这个例子中,where()函数生成一个要添加到结构化查询语言SQLSELECT语句中的WHERE子句。前两个参数是必需的:没有理由生成没有参数的WHERE子句!看一下这里的代码:

// ch02/includes/php7_sql_lib.php
// other functions not shown
function where(stdClass $obj, $a, $b = '', $c = '', 
        $d = '') {
    $obj->where[] = $a;
    $obj->where[] = $b;
    $obj->where[] = $c;
    $obj->where[] = $d;
}

使用此函数的调用代码可能如下所示:

// /repo/ch02/php7_variadics_sql.php
require_once __DIR__ . '/includes/php7_sql_lib.php';
$start = '2021-01-01';
$end   = '2021-04-01';
$select = new stdClass();
from($select, 'events');
cols($select, ['id', 'event_key', 
    'event_name', 'event_date']);
limit($select, 10);
where($select, 'event_date', '>=', "'$start'");
where($select, 'AND');
where($select, 'event_date', '<', "'$end'");
$sql = render($select);
// remaining code not shown

您可能已经注意到,由于参数数量有限,必须多次调用where()。这是可变参数运算符的一个完美应用场景!以下是重写的where()函数可能会看起来:

// ch02/includes/php8_sql_lib.php
// other functions not shown
function where(stdClass $obj, ...$args) {
    $obj->where = (empty($obj->where))
                ? $args
                : array_merge($obj->where, $args);
}

因为...$args始终作为数组返回,为了确保对函数的任何额外调用不会丢失子句,我们需要执行一个array_merge()操作。以下是重写的调用程序:

// /repo/ch02/php8_variadics_sql.php
require_once __DIR__ . '/includes/sql_lib2.php';
$start = '2021-01-01';
$end   = '2021-04-01';
$select = new stdClass();
from($select, 'events');
cols($select, ['id', 'event_key', 
    'event_name', 'event_date']);
limit($select, 10);
where($select, 'event_date', '>=', "'$start'", 
    'AND', 'event_date', '<', "'$end'");
$sql = render($select);
// remaining code not shown

生成的 SQL 语句如下所示:

SELECT id,event_key,event_name,event_date 
FROM events 
WHERE event_date >= '2021-01-01' 
    AND event_date <= '2021-04-01' 
LIMIT 10

前面的输出显示了我们的 SQL 生成逻辑生成了一个有效的语句。

使用可变参数运算符作为替代

到目前为止,对于有经验的 PHP 开发人员来说,这些都不是陌生的。在 PHP 8 中的不同之处在于,可变参数运算符现在可以在可能涉及扩展的情况下使用。

为了正确描述可变参数运算符的使用方式的不同之处,我们需要简要回顾一下面向对象编程OOP)。如果我们将刚才描述的where()函数重写为类方法,它可能会像这样:

// src/Php7/Sql/Where.php
namespace Php7\Sql;
class Where {
    public $where = [];
    public function where($a, $b = '', $c = '', $d = '') {
        $this->where[] = $a;
        $this->where[] = $b;
        $this->where[] = $c;
        $this->where[] = $d;
        return $this;
    }
    // other code not shown
}

现在,假设我们有一个Select类,它扩展了Where,但使用可变参数运算符重新定义了方法签名。它可能如下所示:

// src/Php7/Sql/Select.php
namespace Php7\Sql;
class Select extends Where {
    public function where(...$args)    {
        $this->where = (empty($obj->where))
                    ? $args
                    : array_merge($obj->where, $args);
    }
    // other code not shown
}

使用可变参数运算符是合理的,因为提供给WHERE子句的参数数量是未知的。以下是使用面向对象编程重写的调用程序:

// /repo/ch02/php7_variadics_problem.php
require_once __DIR__ . '/../src/Server/Autoload/Loader.php'
$loader = new \Server\Autoload\Loader();
use Php7\Sql\Select;
$start = "'2021-01-01'";
$end   = "'2021-04-01'";
$select = new Select();
$select->from($select, 'events')
       ->cols($select, ['id', 'event_key', 
              'event_name', 'event_date'])
       ->limit($select, 10)
       ->where($select, 'event_date', '>=', "'$start'",
               'AND', 'event_date', '<=', "'$end'");
$sql = $select->render();
// other code not shown

然而,当您尝试在 PHP 7 下运行此示例时,会出现以下警告:

Warning: Declaration of Php7\Sql\Select::where(...$args) should be compatible with Php7\Sql\Where::where($a, $b = '', $c = '', $d = '') in /repo/src/Php7/Sql/Select.php on line 5 

请注意,代码仍然有效;但是,PHP 7 不认为可变参数运算符是一个可行的替代方案。以下是在 PHP 8 下运行相同代码的情况(使用/repo/ch02/php8_variadics_no_problem.php):

图 2.1-可接受扩展类中的可变参数运算符

图 2.1-可接受扩展类中的可变参数运算符

提示

以下是两个 PHP 文档引用,解释了 PHP 可变参数运算符背后的原因:

wiki.php.net/rfc/variadics

wiki.php.net/rfc/argument_unpacking

现在让我们来看看 nullsafe 运算符。

使用 nullsafe 运算符

nullsafe 运算符用于对象属性引用链。如果链中的某个属性不存在(换句话说,它被视为NULL),该运算符会安全地返回一个NULL值,而不会发出警告。

举个例子,假设我们有以下扩展标记语言XML)文件:

<?xml version='1.0' standalone='yes'?>
<produce>
      <file>/repo/ch02/includes/produce.xml</file>
    <dept>
        <fruit>
            <apple>11</apple>
            <banana>22</banana>
            <cherry>33</cherry>
        </fruit>
        <vegetable>
            <artichoke>11</artichoke>
            <beans>22</beans>
            <cabbage>33</cabbage>
        </vegetable>
    </dept>
</produce>

以下是一个扫描 XML 文档并显示数量的代码片段:

// /repo/ch02/php7_nullsafe_xml.php
$xml = simplexml_load_file(__DIR__ . 
        '/includes/produce.xml');
$produce = [
    'fruit' => ['apple','banana','cherry','pear'],
    'vegetable' => ['artichoke','beans','cabbage','squash']
];
$pattern = "%10s : %d\n";
foreach ($produce as $type => $items) {
    echo ucfirst($type) . ":\n";
    foreach ($items as $item) {
        $qty = getQuantity($xml, $type, $item);
        printf($pattern, $item, $qty);
    }
}

我们还需要定义一个getQuantity()函数,首先检查该属性是否不为空,然后再进行下一级的操作,如下所示:

function getQuantity(SimpleXMLElement $xml, 
        string $type, string $item {
    $qty = 0;
    if (!empty($xml->dept)) {
        if (!empty($xml->dept->$type)) {
            if (!empty($xml->dept->$type->$item)) {
                $qty = $xml->dept->$type->$item;
            }
        }
    }
    return $qty;
}

当您开始处理更深层次的嵌套级别时,需要检查属性是否存在的函数变得更加复杂。这正是 nullsafe 运算符可以发挥作用的地方。

看一下相同的程序代码,但不需要getQuantity()函数,如下所示:

// /repo/ch02/php8_nullsafe_xml.php
$xml = simplexml_load_file(__DIR__ . 
        '/includes/produce.xml'
$produce = [
    'fruit' => ['apple','banana','cherry','pear']
    'vegetable' => ['artichoke','beans','cabbage','squash']
];
$pattern = "%10s : %d\n";
foreach ($produce as $type => $items) {
    echo ucfirst($type) . ":\n";
    foreach ($items as $item) {
        printf($pattern, $item, 
            $xml?->dept?->$type?->$item);
    }
}

现在让我们来看看 nullsafe 运算符的另一个用途。

使用 nullsafe 运算符来短路链

nullsafe 运算符在连接的操作链中也很有用,包括对对象属性的引用、数组元素方法调用和静态引用。

举个例子,这里有一个配置文件,返回一个匿名类。它定义了根据文件类型提取数据的不同方法:

// ch02/includes/nullsafe_config.php
return new class() {
    const HEADERS = ['Name','Amt','Age','ISO','Company'];
    const PATTERN = "%20s | %16s | %3s | %3s | %s\n";
    public function json($fn) {
        $json = file_get_contents($fn);
        return json_decode($json, TRUE);
    }
    public function csv($fn) {
        $arr = [];
        $fh = new SplFileObject($fn, 'r');
        while ($node = $fh->fgetcsv()) $arr[] = $node;
        return $arr;            
    }
    public function txt($fn) {
        $arr = [];
        $fh = new SplFileObject($fn, 'r');
        while ($node = $fh->fgets())
            $arr[] = explode("\t", $node);
        return $arr;
    }
    // all code not shown
};

该类还包括一个显示数据的方法,如下面的代码片段所示:

    public function display(array $data) {
        $total  = 0;
        vprintf(self::PATTERN, self::HEADERS);
        foreach ($data as $row) {
            $total += $row[1];
            $row[1] = number_format($row[1], 0);
            $row[2] = (string) $row[2];
            vprintf(self::PATTERN, $row);
        }
        echo 'Combined Wealth: ' 
            . number_format($total, 0) . "\n"
    }    

在调用程序中,为了安全地执行 display() 方法,我们需要在执行回调之前添加一个 is_object() 的额外安全检查,以及 method_exists(),如下面的代码片段所示:

// /repo/ch02/php7_nullsafe_short.php
$config  = include __DIR__ . 
        '/includes/nullsafe_config.php';
$allowed = ['csv' => 'csv','json' => 'json','txt'
                  => 'txt'];
$format  = $_GET['format'] ?? 'txt';
$ext     = $allowed[$format] ?? 'txt';
$fn      = __DIR__ . '/includes/nullsafe_data.' . $ext;
if (file_exists($fn)) {
    if (is_object($config)) {
        if (method_exists($config, 'display')) {
            if (method_exists($config, $ext)) {
                $config->display($config->$ext($fn));
            }
        }
    }
}

与前面的例子一样,空安全运算符可以用来确认 $config 是否为对象。通过简单地在第一个对象引用中使用空安全运算符,如果对象或方法不存在,运算符将 短路 整个链并返回 NULL

以下是使用 PHP 8 空安全运算符重写的代码:

// /repo/ch02/php8_nullsafe_short.php
$config  = include __DIR__ . 
        '/includes/nullsafe_config.php';
$allowed = ['csv' => 'csv','json' => 'json',
                     'txt' => 'txt'];
$format  = $_GET['format'] ?? $argv[1] ?? 'txt';
$ext     = $allowed[$format] ?? 'txt';
$fn      = __DIR__ . '/includes/nullsafe_data.' . $ext;
if (file_exists($fn)) {
    $config?->display($config->$ext($fn));
}

如果 $config 返回为 NULL,则整个操作链将被取消,不会生成任何警告或通知,并且返回值(如果有)为 NULL。最终结果是我们省去了编写三个额外的 if() 语句!

提示

有关使用此运算符时的其他注意事项,请查看这里:wiki.php.net/rfc/nullsafe_operator

重要提示

为了将格式参数传递给示例代码文件,您需要从浏览器中运行以下代码:http://localhost:8888/ch02/php7_nullsafe_short.php?format=json

接下来,我们将看看连接运算符的更改。

连接运算符已经被降级

尽管 连接 运算符的精确用法(例如,句号(.)在 PHP 8 中没有改变,但在其 优先级顺序 中发生了极其重要的变化。在早期版本的 PHP 中,连接运算符在优先级方面被认为与较低级别的算术运算符加号(+)和减号(-)相等。接下来,让我们看看传统优先级顺序可能出现的问题:令人费解的结果。

处理令人费解的结果

不幸的是,这种安排会产生意想不到的结果。以下代码片段在使用 PHP 7 时执行时呈现出令人费解的输出:

// /repo/ch02/php7_ops_concat_1.php
$a = 11;
$b = 22;
echo "Sum: " . $a + $b;

仅仅看代码,您可能期望输出类似于 "Sum:33"。但事实并非如此!在 PHP 7.1 上运行时,请查看以下输出:

root@php8_tips_php7 [ /repo/ch02 ]# php php7_ops_concat_1.php
PHP Warning:  A non-numeric value encountered in /repo/ch02/php7_ops_concat_1.php on line 5
PHP Stack trace:
PHP   1\. {main}() /repo/ch02/php7_ops_concat_1.php:0
Warning: A non-numeric value encountered in /repo/ch02/php7_ops_concat_1.php on line 5
Call Stack:
  0.0001     345896   1\. {main}()
22

此时,您可能会想,因为代码从不说谎,那么 11 + 22 的和为 22,正如我们在前面的输出(最后一行)中看到的那样?

答案涉及优先级顺序:从 PHP 7 开始,它始终是从左到右。因此,如果我们使用括号来使操作顺序更清晰,实际发生的情况是这样的:

echo ("Sum: " . $a) + $b;

11 被连接到 "Sum: ",结果为 "Sum: 11"。作为字符串。然后将字符串转换为整数,得到 0 + 22 表达式,这给我们了结果。

如果您在 PHP 8 中运行相同的代码,请注意这里的区别:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_ops_concat_1.php 
Sum: 33

正如您所看到的,算术运算符优先于连接运算符。使用括号,这实际上是 PHP 8 中代码的处理方式:

echo "Sum: " . ($a + $b);

提示

最佳实践:使用括号来避免依赖优先级顺序而产生的复杂性。有关降低连接运算符优先级背后的原因的更多信息,请查看这里:wiki.php.net/rfc/concatenation_precedence

现在我们将注意力转向三元运算符。

使用嵌套的三元运算符

三元运算符 对于 PHP 语言来说并不新鲜。然而,在 PHP 8 中,它们的解释方式有一个重大的不同。这种变化与该运算符的传统 左关联行为 有关。为了说明这一点,让我们看一个简单的例子,如下所示:

  1. 在这个例子中,假设我们正在使用 RecursiveDirectoryIterator 类与 RecursiveIteratorIterator 类结合扫描目录结构。起始代码可能如下所示:
// /repo/ch02/php7_nested_ternary.php
$path = realpath(__DIR__ . '/..');
$searchPath = '/ch';
$searchExt  = 'php';
$dirIter    = new RecursiveDirectoryIterator($path);
$itIter     = new RecursiveIteratorIterator($dirIter);
  1. 然后我们定义一个函数,匹配包含$searchPath搜索路径并以$searchExt扩展名结尾的文件,如下所示:
function find_using_if($iter, $searchPath, $searchExt) {
    $matching  = [];
    $non_match = [];
    $discard   = [];
    foreach ($iter as $name => $obj) {
        if (!$obj->isFile()) {
            $discard[] = $name;
        } elseif (!strpos($name, $searchPath)) {
            $discard[] = $name;
        } elseif ($obj->getExtension() !== $searchExt) {
            $non_match[] = $name;
        } else {
            $matching[] = $name;
        }
    }
    show($matching, $non_match);
}
  1. 然而,一些开发人员可能会诱惑重构此函数,而不是使用if / elseif / else,而是使用嵌套三元运算符。以下是在前一步骤中使用的相同代码可能的样子:
function find_using_tern($iter, $searchPath, 
        $searchExt){
    $matching  = [];
    $non_match = [];
    $discard   = [];
    foreach ($iter as $name => $obj) {
        $match = !$obj->isFile()
            ? $discard[] = $name
            : !strpos($name, $searchPath)
                ? $discard[] = $name
                : $obj->getExtension() !== $searchExt
                    ? $non_match[] = $name
                    : $matching[] = $name;
    }
    show($matching, $non_match);
}

两个函数的输出在 PHP 7 中产生相同的结果,如下截图所示:

图 2.2 - 使用 PHP 7 进行嵌套三元输出

图 2.2 - 使用 PHP 7 进行嵌套三元输出

然而,在 PHP 8 中,不再允许使用没有括号的嵌套三元操作。运行相同代码块时的输出如下:

图 2.3 - 使用 PHP 8 进行嵌套三元输出

图 2.3 - 使用 PHP 8 进行嵌套三元输出

提示

最佳实践:使用括号避免嵌套三元操作的问题。有关三元运算符嵌套差异的更多信息,请参阅此文章:wiki.php.net/rfc/ternary_associativity

您现在对新的 nullsafe 运算符有了一个概念。您还学习了三个现有运算符——可变参数、连接和三元运算符——它们的功能略有修改。您现在可以避免升级到 PHP 8 时可能出现的潜在危险。现在让我们来看看另一个新功能,箭头函数

使用箭头函数

箭头函数实际上是在 PHP 7.4 中首次引入的。然而,由于许多开发人员并不关注每个发布更新,因此在本书中包含这一出色的新功能是很重要的。

在本节中,您将了解箭头函数及其语法,以及与匿名函数相比的优缺点。

通用语法

箭头函数是传统匿名函数的简写语法,就像三元运算符是if(){} else{}的简写语法一样。箭头函数的通用语法如下:

fn(<ARGS>) => <EXPRESSION>

<ARGS>是可选的,包括任何其他用户定义的 PHP 函数中看到的内容。<EXPRESSION>可以包括任何标准的 PHP 表达式,如函数调用、算术运算等。

现在让我们来看看箭头函数和匿名函数之间的区别。

箭头函数与匿名函数

在本小节中,您将学习箭头函数匿名函数之间的区别。为了成为一个有效的 PHP 8 开发人员,了解箭头函数何时何地可能取代匿名函数并提高代码性能是很重要的。

在进入箭头函数之前,让我们看一个简单的匿名函数。在下面的示例中,分配给$addOld的匿名函数产生了两个参数的和:

// /repo/ch02/php8_arrow_func_1.php
$addOld = function ($a, $b) { return $a + $b; };

在 PHP 8 中,您可以产生完全相同的结果,如下所示:

$addNew = fn($a, $b) => $a + $b;

尽管代码更易读,但这一新功能有其优点和缺点,总结如下表所示:

表 2.1 - 匿名函数与箭头函数

表 2.1 - 匿名函数与箭头函数

从上表中可以看出,箭头函数比匿名函数更高效。然而,缺乏间接性和不支持多行意味着您仍然需要偶尔使用匿名函数。

变量继承

匿名函数,就像任何标准的 PHP 函数一样,只有在将值作为参数传递、使用全局关键字或添加use()修饰符时,才能识别其范围外的变量。

以下是一个DateTime实例通过use()方式继承到匿名函数中的示例:

// /repo/ch02/php8_arrow_func_2.php
// not all code shown
$old = function ($today) use ($format) {
    return $today->format($format);
};

这里使用箭头函数完全相同的东西:

$new = fn($today) => $today->format($format);

正如您所看到的,语法非常易读和简洁。现在让我们来看一个结合箭头函数的实际例子。

实际例子:使用箭头函数

回到生成难以阅读的 CAPTCHA 的想法(首次在第一章中介绍,介绍新的 PHP 8 OOP 功能),让我们看看如何结合箭头函数可能提高效率并减少所需的编码量。现在我们来看一个生成基于文本的 CAPTCHA 的脚本,如下所示:

  1. 首先,我们定义一个生成由字母、数字和特殊字符随机选择组成的字符串的函数。请注意在以下代码片段中,使用了新的 PHP 8 match表达式结合箭头函数(高亮显示):
// /repo/ch02/php8_arrow_func_3.php
function genKey(int $size) {
    $alpha1  = range('A','Z');
    $alpha2  = range('a','z');
    $special = '!@#$%^&*()_+,./[]{}|=-';
    $len     = strlen($special) - 1;
    $numeric = range(0, 9);
    $text    = '';
    for ($x = 0; $x < $size; $x++) {
        $algo = rand(1,4);
        $func = match ($algo) {
            1 => fn() => $alpha1[array_rand($alpha1)],
            2 => fn() => $alpha2[array_rand($alpha2)]
            3 => fn() => $special[rand(0,$len)],
            4 => fn() => 
                       $numeric[array_rand($numeric)],
            default => fn() => ' '
        };
        $text .= $func();            
    }
    return $text;
}
  1. 然后,我们定义一个textCaptcha()函数来生成文本 CAPTCHA。我们首先定义代表算法和颜色的两个数组。然后对它们进行洗牌以进一步随机化。我们还定义超文本标记语言HTML<span>元素来产生大写和小写字符,如下面的代码片段所示:
function textCaptcha(string $text) {
    $algos = ['upper','lower','bold',
              'italics','large','small'];
    $color = ['#EAA8A8','#B0F6B0','#F5F596',
              '#E5E5E5','white','white'];
    $lgSpan = '<span style="font-size:32pt;">';
    $smSpan = '<span style="font-size:8pt;">';
    shuffle($algos);
    shuffle($color);
  1. 接下来,我们定义一系列InfiniteIterator实例。这是一个有用的标准 PHP 库SPL)类,允许您继续调用next(),而无需检查您是否已经到达迭代的末尾。这个迭代器类的作用是自动将指针移回数组的顶部,允许您无限迭代。代码可以在以下片段中看到:
    $bkgTmp = new ArrayIterator($color);
    $bkgIter = new InfiniteIterator($bkgTmp);
    $algoTmp = new ArrayIterator($algos);
    $algoIter = new InfiniteIterator($algoTmp);
    $len = strlen($text);
  1. 然后,我们逐个字符构建文本 CAPTCHA,应用适当的算法和背景颜色,如下所示:
    $captcha = '';
    for ($x = 0; $x < $len; $x++) {
        $char = $text[$x];
        $bkg  = $bkgIter->current();
        $algo = $algoIter->current();
        $func = match ($algo) {
            'upper'   => fn() => strtoupper($char),
            'lower'   => fn() => strtolower($char),
            'bold'    => fn() => "<b>$char</b>",
            'italics' => fn() => "<i>$char</i>",
            'large'   => fn() => $lgSpan 
                         . $char . '</span>',
            'small'   => fn() => $smSpan 
                         . $char . '</span>',
            default   => fn() => $char
        };
        $captcha .= '<span style="background-color:' 
            . $bkg . ';">' 
            . $func() . '</span>';
        $algoIter->next();
        $bkgIter->next();
    }
    return $captcha;
}

再次注意混合使用matcharrow函数以实现期望的结果。

脚本的其余部分只是调用这两个函数,如下所示:

$text = genKey(8);
echo "Original: $text<br />\n";
echo 'Captcha : ' . textCaptcha($text) . "\n";

以下是从浏览器中/repo/ch02/php8_arrow_func_3.php输出的样子:

图 2.4 - 来自 php8_arrow_func_3.php 的输出

图 2.4 - 来自 php8_arrow_func_3.php 的输出

提示

有关箭头函数的更多背景信息,请查看这里:wiki.php.net/rfc/arrow_functions_v2

有关InfiniteIterator的信息,请查看 PHP 文档:www.php.net/InfiniteIterator

现在让我们来看一下统一变量语法

理解统一变量语法

PHP 7.0 中引入的最激进的举措之一是努力规范化 PHP 语法。早期版本的 PHP 存在的问题是,在某些情况下,操作是从左到右解析的,而在其他情况下是从右到左解析的。这种不一致性是许多编程漏洞和困难的根本原因。因此,PHP 核心开发团队发起了一项名为统一变量语法的举措。但首先,让我们定义形成统一变量语法举措的关键要点。

定义统一变量语法

统一变量语法既不是协议也不是正式的语言构造。相反,它是一个指导原则,旨在确保所有操作以统一和一致的方式执行。

以下是这项举措的一些关键要点:

  • 变量的顺序和引用的统一性

  • 函数调用的统一性

  • 解决数组解引用问题

  • 提供在单个命令中混合函数调用和数组解引用的能力

提示

有关 PHP 7 统一变量语法的原始提案的更多信息,请查看这里:wiki.php.net/rfc/uniform_variable_syntax

现在让我们来看一下统一变量语法举措如何影响 PHP 8。

统一变量语法如何影响 PHP 8?

统一变量语法倡议在所有 PHP 7 的版本中都取得了极大的成功,过渡相对顺利。然而,有一些领域没有升级到这个标准。因此,提出了一个新的提案来解决这些问题。在 PHP 8 中,以下内容已经实现了统一性:

  • 解引用插入字符串

  • 魔术常量的不一致解引用

  • 类常量解引用的一致性

  • 增强了newinstanceof的表达式支持

在进入每个这些领域的示例之前,我们必须首先定义解引用的含义。

定义解引用

解引用是提取数组元素或对象属性的值的过程。它还指获取对象方法或函数调用的返回值的过程。这里有一个简单的例子:

// /repo/ch02/php7_dereference_1.php
$alpha = range('A','Z');
echo $alpha[15] . $alpha[7] . $alpha[15];
// output: PHP

$alpha包含 26 个元素,代表字母AZ。这个例子解引用了数组,提取了第 7 和第 15 个元素,产生了PHP的输出。解引用函数或方法调用简单地意味着执行函数或方法并访问结果。

解引用插入字符串

下一个例子有点疯狂,请仔细跟随。以下示例在 PHP 8 中有效,但在 PHP 7 或之前的版本中无效:

// /repo/ch02/php8_dereference_2.php
$alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num   = '0123456789';
$test  = [15, 7, 15, 34];
foreach ($test as $pos)
    echo "$alpha$num"[$pos];

在这个例子中,两个字符串$alpha$numforeach()循环内使用双引号进行插值。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch02 ]# php php7_dereference_2.php 
PHP Parse error:  syntax error, unexpected '[', expecting ',' or ';' in /repo/ch02/php7_dereference_2.php on line 7
Parse error: syntax error, unexpected '[', expecting ',' or ';' in /repo/ch02/php7_dereference_2.php on line 7

在 PHP 8 中相同的代码产生以下输出:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_dereference_2.php 
PHP8

结论是,PHP 7 在解引用插入字符串方面不一致,而 PHP 8 展现了改进的一致性。

魔术常量的不一致解引用

在 PHP 7 和之前的版本中,常量可以被解引用,而魔术常量则不行。下面是一个简单的例子,它产生了当前文件的最后三个字母:

// /repo/ch02/php8_dereference_3.php
define('FILENAME', __FILE__);
echo FILENAME[-3] . FILENAME[-2] . FILENAME[-1];
echo __FILE__[-3] . __FILE__[-2] . __FILE__[-1];

以下是 PHP 7 的结果:

root@php8_tips_php7 [ /repo/ch02 ]# php php7_dereference_3.php
PHP Parse error:  syntax error, unexpected '[', expecting ',' or ';' in /repo/ch02/php7_dereference_3.php on line 7
Parse error: syntax error, unexpected '[', expecting ',' or ';' in /repo/ch02/php7_dereference_3.php on line 7

以下是 PHP 8 的结果:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_dereference_3.php
phpphp

再次强调的是,PHP 8 中的解引用操作是一致的(这是一件好事!)。

类常量解引用的一致性

当尝试解引用类常量时会出现相关问题。为了最好地说明问题,想象一下我们有三个类。第一个类JsonResponseJavaScript 对象表示法JSON)格式产生数据,如下面的代码片段所示:

class JsonResponse {
    public static function render($data) {
        return json_encode($data, JSON_PRETTY_PRINT);
    }
}

第二个类SerialResponse使用内置的 PHP serialize()函数产生响应,如下面的代码片段所示:

class SerialResponse {
    public static function render($data) {
        return serialize($data);
    }
}

最后,一个Test类能够产生任何一个响应,如下面的代码片段所示:

class Test {
    const JSON = ['JsonResponse'];
    const TEXT = 'SerialResponse';
    public static function getJson($data) {
        echo self::JSON[0]::render($data);
    }
    public static function getText($data) {
        echo self::TEXT::render($data);
    }
}

正如你在本节的早期示例中所看到的,PHP 早期版本的结果是不一致的。调用Test::getJson($data)可以正常工作。然而,调用Test::getText($data)会产生错误:

root@php8_tips_php7 [ /repo/ch02 ]# php php7_dereference_4.php PHP Parse error:  syntax error, unexpected '::' (T_PAAMAYIM_NEKUDOTAYIM), expecting ',' or ';' in /repo/ch02/php7_dereference_4.php on line 26
Parse error: syntax error, unexpected '::' (T_PAAMAYIM_NEKUDOTAYIM), expecting ',' or ';' in /repo/ch02/php7_dereference_4.php on line 26

在 PHP 8 下,与之前显示的类中定义的方法调用产生了一致的结果,如下所示:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_dereference_4.php
{
    "A": 111,
    "B": 222,
    "C": 333}
a:3:{s:1:"A";i:111;s:1:"B";i:222;s:1:"C";i:333;}

总之,在 PHP 8 中,类常量现在以统一的方式进行解引用,使您能够产生更清晰的代码。现在,让我们看看 PHP 8 如何允许您在更多地方使用表达式。

增强了newinstanceof的表达式支持

与 PHP 7 编程相关的乐趣之一是能够在几乎任何地方使用任意 PHP 表达式。在这个简单的例子中,注意在引用$nav数组的方括号内使用了一个$_GET['page'] ?? 'home'任意表达式:

// /repo/ch02/php7_arbitrary_exp.php
$nav = [
    'home'     => 'home.html',
    'about'    => 'about.html',
    'services' => 'services/index.html',
    'support'  => 'support/index.html',
];
$html = __DIR__ . '/../includes/'
      . $nav[$_GET['page'] ?? 'home'];

在 PHP 7 和之前的版本中,如果表达式涉及newinstanceof关键字,则不可能做到这一点。正如你可能已经猜到的那样,这种不一致性已经在 PHP 8 中得到解决。现在可以实现以下操作:

// /repo/ch02/php8_arbitrary_exp_new.php
// definition of the JsonRespone and SerialResponse
// classes are shown above
$allowed = [
    'json' => 'JsonResponse',
    'text' => 'SerialResponse'
];
$data = ['A' => 111, 'B' => 222, 'C' => 333];
echo (new $allowed[$_GET['type'] ?? 'json'])
        ->render($data);

这个代码示例展示了在数组引用内使用任意表达式,与new关键字一起使用。

提示

有关 PHP 8 中统一变量语法更新的更多信息,请参阅此文章:wiki.php.net/rfc/variable_syntax_tweaks

现在让我们来看看 PHP 8 中可用的新的字符串和数组处理技术。

学习新的数组和字符串处理技术

PHP 8 中的数组和字符串处理技术有许多改进。虽然本书中没有足够的空间来涵盖每一个增强功能,但我们将在本节中检查更重要的改进。

使用 array_splice()

array_splice() 函数是 substr()str_replace() 的混合体:它允许您用另一个数组替换一个数组的子集。然而,当您只需要用不同的内容替换数组的最后部分时,它的使用会变得麻烦。快速查看语法会让人觉得开始变得不方便——replacement 参数在 length 参数之前,如下所示:

array_splice(&$input,$offset[,$length[,$replacement]]):array

传统上,开发人员首先在原始数组上运行 count(),然后将其用作 length 参数,如下所示:

array_splice($arr, 3, count($arr), $repl);

在 PHP 8 中,第三个参数可以是 NULL,省去了对 count() 的额外调用。如果您利用 PHP 8 的命名参数特性,代码会变得更加简洁。下面是为 PHP 8 编写的相同代码片段:

array_splice($arr, 3, replacement: $repl);

这里有另一个例子清楚地展示了 PHP 7 和 PHP 8 之间的差异:

// /repo/ch02/php7_array_splice.php
$arr  = ['Person', 'Camera', 'TV', 'Woman', 'Man'];
$repl = ['Female', 'Male'];
$tmp  = $arr;
$out  = array_splice($arr, 3, count($arr), $repl);
var_dump($arr);
$arr  = $tmp;
$out  = array_splice($arr, 3, NULL, $repl);
var_dump($arr);

如果您在 PHP 7 中运行代码,请注意最后一个 var_dump() 实例的结果,如下所示:

repo/ch02/php7_array_splice.php:11:
array(7) {
  [0] =>  string(6) "Person"
  [1] =>  string(6) "Camera"
  [2] =>  string(2) "TV"
  [3] =>  string(6) "Female"
  [4] =>  string(4) "Male"
  [5] =>  string(5) "Woman"
  [6] =>  string(3) "Man"
}

在 PHP 7 中,将 NULL 值提供给 array_splice() 的第三个参数会导致两个数组简单合并,这不是期望的结果!

现在,让我们来看一下最后一个 var_dump() 的输出,但这次是在 PHP 8 下运行的:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_array_splice.php
// some output omitted
array(5) {
  [0]=>  string(6) "Person"
  [1]=>  string(6) "Camera"
  [2]=>  string(2) "TV"
  [3]=>  string(6) "Female"
  [4]=>  string(4) "Male"
}

如您所见,在 PHP 8 下,将第三个参数设为 NULL 与在运行时将数组 count() 作为第三个参数提供给 array_splice() 具有相同的功能。您还会注意到在 PHP 8 中,数组元素的总数为 5,而在 PHP 7 中,相同代码的运行结果为 7

使用 array_slice()

array_slice() 函数在数组上的操作与 substr() 在字符串上的操作一样。PHP 早期版本的一个大问题是,在内部,PHP 引擎会顺序地遍历整个数组,直到达到所需的偏移量。如果偏移量很大,性能会直接与数组大小成正比地受到影响。

在 PHP 8 中,使用了一种不需要顺序数组迭代的不同算法。随着数组大小的增加,性能改进变得越来越明显。

  1. 在这个示例中,我们首先构建了一个大约有 600 万条目的大数组:
// /repo/ch02/php8_array_slice.php
ini_set('memory_limit', '1G');
$start = microtime(TRUE);
$arr   = [];
$alpha = range('A', 'Z');
$beta  = $alpha;
$loops = 10000;     // size of outer array
$iters = 500;       // total iterations
$drip  = 10;        // output every $drip times
$cols  = 4;
for ($x = 0; $x < $loops; $x++)
    foreach ($alpha as $left)
        foreach ($beta as $right)
            $arr[] = $left . $right . rand(111,999);
  1. 接下来,我们遍历数组,取大于 999,999 的随机偏移量。这会迫使 array_slice() 艰苦工作,并显示出 PHP 7 和 8 之间的显著性能差异,如下面的代码片段所示:
$max = count($arr);
for ($x = 0; $x < $iters; $x++ ) {
    $offset = rand(999999, $max);
    $slice  = array_slice($arr, $offset, 4);
    // not all display logic is shown
}
$time = (microtime(TRUE) - $start);
echo "\nElapsed Time: $time seconds\n";

在 PHP 7 下运行代码时的输出如下:

图 2.5 – 使用 PHP 7 的 array_slice() 示例

图 2.5 – 使用 PHP 7 的 array_slice() 示例

请注意,在 PHP 8 下运行相同代码时的显著性能差异:

图 2.6 – 使用 PHP 8 的 array_slice() 示例

图 2.6 – 使用 PHP 8 的 array_slice() 示例

重要提示

新算法只在数组不包含 NULL 值的情况下有效。如果数组包含 NULL 元素,则会触发旧算法,并进行顺序迭代。

现在让我们转向一些出色的新字符串函数。

检测字符串的开头、中间和结尾

PHP 开发人员经常需要处理的一个问题是检查字符串的开头、中间或结尾是否出现一组字符。当前一组字符串函数的问题在于它们不是设计来处理子字符串的存在或不存在。相反,当前一组函数是设计来确定子字符串的位置。然后,可以以布尔方式插值来确定子字符串的存在或不存在。

这种方法的问题可以用温斯顿·丘吉尔爵士的一句著名的引语来概括:

“高尔夫是一个旨在用极不适合这一目的的武器将一个非常小的球打入一个更小的洞的游戏。”

温斯顿·丘吉尔

现在让我们来看看三个非常有用的新字符串函数,它们解决了这个问题。

str_starts_with()

我们要检查的第一个函数是str_starts_with()。为了说明它的用法,考虑一个代码示例,我们要在开头找到https,在结尾找到login,如下面的代码片段所示:

// /repo/ch02/php7_starts_ends_with.php
$start = 'https';
if (substr($url, 0, strlen($start)) !== $start) 
    $msg .= "URL does not start with $start\n";
// not all code is shown

正如我们在本节的介绍中提到的,为了确定一个字符串是否以https开头,我们需要调用substr()strlen()。这两个函数都不是设计来给我们想要的答案的。而且,使用这两个函数会在我们的代码中引入低效,并导致不必要的资源利用增加。

相同的代码可以在 PHP 8 中编写如下:

// /repo/ch02/php8_starts_ends_with.php
$start = 'https';
if (!str_starts_with($url, $start))
    $msg .= "URL does not start with $start\n";
// not all code is shown

str_ends_with()

str_starts_with()类似,PHP 8 引入了一个新函数str_ends_with(),用于确定字符串的结尾是否与某个值匹配。为了说明这个新函数的用处,考虑使用strrev()strpos()的旧 PHP 代码,可能如下所示:

$end = 'login';
if (strpos(strrev($url), strrev($end)) !== 0)
    $msg .= "URL does not end with $end\n";

在一个操作中,$url$end都需要被反转,这个过程会随着字符串长度的增加而变得越来越昂贵。而且,正如前面提到的,strpos()的目的是返回子字符串的位置,而不是确定其存在与否。

在 PHP 8 中,可以通过以下方式实现相同的功能:

if (!str_ends_with($url, $end))
    $msg .= "URL does not end with $end\n";

str_contains()

在这个上下文中的最后一个函数是str_contains()。正如我们讨论过的,在 PHP 7 及更早版本中,除了preg_match()之外,没有特定的 PHP 函数告诉你一个子字符串是否存在于一个字符串中。

使用preg_match()的问题,正如我们一再被警告的那样,是性能下降。为了处理正则表达式preg_match()首先需要分析模式。然后,它必须执行第二次扫描,以确定字符串的哪个部分与模式匹配。这在时间和资源利用方面是一个极其昂贵的操作。

重要提示

当我们提到一个操作在时间和资源方面是昂贵时,请记住,如果您的脚本只包含几十行代码和/或您在循环中没有重复操作数千次,那么使用本节中描述的新函数和技术可能不会带来显著的性能提升。

在下面的例子中,一个 PHP 脚本使用preg_match()来搜索GeoNames项目数据库中人口超过15,000的城市,以查找包含对London的引用的任何列表:

// /repo/ch02/php7_str_contains.php
$start    = microtime(TRUE);
$target   = '/ London /';
$data_src = __DIR__ . '/../sample_data
                      /cities15000_min.txt';
$fileObj  = new SplFileObject($data_src, 'r');
while ($line = $fileObj->fgetcsv("\t")) {
    $tz     = $line[17] ?? '';
    if ($tz) unset($line[17]);
    $str    = implode(' ', $line);
    $city   = $line[1] ?? 'Unknown';
    $local1 = $line[10] ?? 'Unknown';
    $iso    = $line[8] ?? '??';
    if (preg_match($target, $str))
        printf("%25s : %12s : %4s\n", $city, $local1, 
                $iso);
}
echo "Elapsed Time: " . (microtime(TRUE) - $start) . "\n";

在 PHP 7 中运行时的输出如下:

图 2.7 - 使用 preg_match()扫描 GeoNames 文件

图 2.7 - 使用 preg_match()扫描 GeoNames 文件

在 PHP 8 中,可以通过用以下代码替换if语句来实现相同的输出:

// /repo/ch02/php8_str_contains.php
// not all code is shown
    if (str_contains($str, $target))
        printf("%25s : %12s : %4s\n", $city, $local1, 
               $iso);

以下是来自 PHP 8 的输出:

图 2.8 - 使用 str_contains()扫描 GeoNames 文件

图 2.8 - 使用 str_contains()扫描 GeoNames 文件

从两个不同的输出屏幕可以看出,PHP 8 代码运行大约需要0.14微秒,而 PHP 7 需要0.19微秒。这本身并不是一个巨大的性能提升,但正如本节前面提到的,更多的数据、更长的字符串和更多的迭代会放大你所获得的任何小的性能提升。

提示

最佳实践:实现小的代码修改可以带来小的性能提升,最终积少成多,带来整体性能的大幅提升!

有关GeoNames开源项目的更多信息,请访问他们的网站:www.geonames.org/

现在你知道了如何以及在哪里使用三个新的字符串函数。你还可以编写更高效的代码,使用专门设计用于检测目标字符串开头、中间或结尾的子字符串存在与否的函数。

最后,我们以查看新的 SQLite3 授权回调结束本章。

使用授权回调保护 SQLite 数据库

许多 PHP 开发人员更喜欢使用SQLite作为他们的数据库引擎,而不是像 PostgreSQL、MySQL、Oracle 或 MongoDB 这样的独立数据库服务器。使用 SQLite 的原因有很多,但通常归结为以下几点:

  • SQLite 是基于文件的数据库:你不需要安装单独的数据库服务器。

  • 易于分发:唯一的要求是目标服务器需要安装SQLite可执行文件。

  • SQLite 轻量级:由于没有不断运行的服务器,所需资源更少。

尽管如此,缺点是它的可扩展性不是很好。如果你有相当大量的数据要处理,最好安装一个更强大的数据库服务器。另一个潜在的主要缺点是 SQLite 没有安全性,下一小节将介绍。

提示

有关 SQLite 的更多信息,请访问他们的主要网页:sqlite.org/index.html

等等...没有安全性?

是的,你听对了:默认情况下,按照其设计,SQLite 没有安全性。当然,这就是许多开发人员喜欢使用它的原因:没有安全性使得它非常容易使用!

以下是一个连接到 SQLite 数据库并对geonames表进行简单查询的示例代码块。它返回了印度人口超过 200 万的城市列表:

// /repo/ch02/php8_sqlite_query.php
define('DB_FILE', __DIR__ . '/tmp/sqlite.db');
$sqlite = new SQLite3(DB_FILE);
$sql = 'SELECT * FROM geonames '
      . 'WHERE country_code = :cc AND population > :pop';
$stmt = $sqlite->prepare($sql);
$stmt->bindValue(':cc', 'IN');
$stmt->bindValue(':pop', 2000000);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
    printf("%20s : %2s : %16s\n", 
        $row['name'], $row['country_code'],
        number_format($row['population']));
}  // not all code is shown

大多数其他数据库扩展在建立连接时至少需要用户名和密码。如前面的代码片段所示,$sqlite实例是完全没有安全性的:没有用户名或密码。

什么是 SQLite 授权回调?

SQLite3 引擎现在允许你向 SQLite 数据库连接注册一个授权回调。当向数据库发送预编译语句进行编译时,将调用回调例程。以下是在SQLite3实例上设置授权回调的通用语法:

$sqlite3->setAuthorizer(callable $callback);

回调函数应该返回三个SQLite3类常量中的一个,每个代表一个整数值。如果回调函数返回除这三个值之外的任何值,就假定为SQLite3::DENY,操作将不会继续进行。下表列出了三个期望的返回值:

表 2.2 - 有效的 SQLite 授权回调返回值

表 2.2 - 有效的 SQLite 授权回调返回值

现在你对回调有了一些了解,让我们看看它是如何被调用的。

回调函数会接收到什么?

当您执行$sqlite->prepare($sql)时,回调被调用。在那时,SQLite3 引擎将在回调中传递一个到五个参数。第一个参数是一个操作代码,确定剩余参数的性质。因此,以下可能是您最终定义的回调的适当通用函数签名:

function NAME (int $actionCode, ...$params) 
{ /* callback code */ };

大部分情况下,操作代码与要准备的 SQL 语句相对应。以下表总结了一些更常见的操作代码:

表 2.3 – 发送到回调的常见操作代码

表 2.3 – 发送到回调的常见操作代码

现在是时候看一个使用示例了。

授权使用示例

在下面的示例中,我们被允许从 SQLite geonames表中读取,但不能插入、删除或更新:

  1. 我们首先在/repo/ch02/includes/目录中定义一个auth_callback.php包含文件。在include文件中,我们首先定义在回调中使用的常量,如下面的代码片段所示:
// /repo/ch02/includes/auth_callback.php
define('DB_FILE', '/tmp/sqlite.db');
define('PATTERN', '%-8s | %4s | %-28s | %-15s');
define('DEFAULT_TABLE', 'Unknown');
define('DEFAULT_USER', 'guest');
define('ACL' , [
    'admin' => [
        'users' => [SQLite3::READ, SQLite3::SELECT,
            SQLite3::INSERT, SQLite3::UPDATE,
            SQLite3::DELETE],
        'geonames' => [SQLite3::READ, SQLite3::SELECT,
            SQLite3::INSERT, SQLite3::UPDATE, 
            SQLite3::DELETE],
    ],
    'guest' => [
        'geonames' => [SQLite3::READ, 
                       SQLite3::SELECT],
    ],
]);

访问控制列表ACL)的工作方式是,主要外键是用户(例如adminguest);次要键是表(例如usersgeonames);值是允许该用户和表的SQLite3操作代码的数组。

在先前显示的示例中,admin用户对两个表都有所有权限,而guest用户只能从geonames表中读取。

  1. 接下来,我们定义实际的授权回调函数。函数中我们需要做的第一件事是将默认返回值设置为SQLite3::DENY。我们还检查操作代码是否为SQLite3::SELECT,如果是,则简单地返回OK。当首次处理SELECT语句并且不提供有关表或列的任何信息时,将发出此操作代码。代码可以在以下片段中看到:
function auth_callback(int $code, ...$args) {
    $status = SQLite3::DENY;
    $table  = DEFAULT_TABLE;
    if ($code === SQLite3::SELECT) {
        $status = SQLite3::OK;
  1. 如果操作代码不是SQLite3::SELECT,我们需要首先确定涉及哪个表,然后才能决定允许还是拒绝该操作。表名作为提供给我们回调的第二个参数报告。

  2. 现在是使用variadics operator的绝佳时机,因为我们不确定可能传递多少参数。但是,对于关注的主要操作(例如INSERTUPDATEDELETE),放入$args的第一个位置的是表名。否则,我们从会话中获取表名。

代码显示在以下片段中:

    } else {
        if (!empty($args[0])) {
            $table = $args[0];
        } elseif (!empty($_SESSION['table'])) {
            $table = $_SESSION['table'];
        }
  1. 同样地,我们从会话中检索用户名,如下所示:
        $user  = $_SESSION['user'] ?? DEFAULT_USER;
  1. 接下来,我们检查用户是否在 ACL 中定义,然后检查表是否为该用户分配了权限。如果给定的操作代码在与用户和表组合关联的数组中,返回SQLite3::OK

代码显示在以下片段中:

    if (!empty(ACL[$user])) {
        if (!empty(ACL[$user][$table])) {
            if (in_array($code, ACL[$user][$table])) {
                $status = SQLite3::OK;
            }
        }
    }
  1. 然后我们将表名存储在会话中并返回状态代码,如下面的代码片段所示:
  } // end of "if ($code === SQLite3::SELECT)"
  $_SESSION['table'] = $table;
  return $status;
} // end of function definition

现在我们转向调用程序。

  1. 在包含定义授权回调的 PHP 文件之后,我们通过接受命令行参数、统一资源定位符URL)参数或简单地分配admin来模拟获取用户名,如下面的代码片段所示:
// /repo/ch02/php8_sqlite_auth_admin.php
include __DIR__ . '/includes/auth_callback.php';
// Here we simulate the user acquisition:
session_start();
$_SESSION['user'] = 
    $argv[1] ?? $_GET['usr'] ?? DEFAULT_USER;
  1. 接下来,我们创建两个数组并使用shuffle()使它们的顺序随机。我们从随机数组中构建用户名、电子邮件和 ID 值,如下面的代码片段所示:
$name = ['jclayton','mpaulovich','nrousseau',
         'jporter'];
$email = ['unlikelysource.com',
          'lfphpcloud.net','phptraining.net'];
shuffle($name);
shuffle($email);
$user_name = $name[0];
$user_email = $name[0] . '@' . $email[0];
$id = md5($user_email . rand(0,999999));
  1. 然后,我们创建SQLite3实例并分配授权回调,如下所示:
$sqlite = new SQLite3(DB_FILE);
$sqlite->setAuthorizer('auth_callback');
  1. 现在 SQL INSERT语句已经定义并发送到 SQLite 进行准备。请注意,这是调用授权回调的时候。

代码显示在以下片段中:

$sql = 'INSERT INTO users '
     . 'VALUES (:id, :name, :email, :pwd);';
$stmt = $sqlite->prepare($sql);
  1. 如果授权回调拒绝操作,则语句对象为NULL,因此最好使用if()语句来测试其存在。如果是这样,我们然后继续绑定值并执行语句,如下面的代码片段所示:
if ($stmt) {
    $stmt->bindValue(':id', $id);
    $stmt->bindValue(':name', $user_name);
    $stmt->bindValue(':email', $user_email);
    $stmt->bindValue(':pwd', 'password');
    $result = $stmt->execute();
  1. 为了确认结果,我们定义了一个 SQL SELECT语句,以显示users表的内容,如下所示:
    $sql = 'SELECT * FROM users';
    $result = $sqlite->query($sql);
    while ($row = $result->fetchArray(SQLITE3_ASSOC))
        printf("%-10s : %-  10s\n",
            $row['user_name'], $row['user_email']);
}

重要提示

这里没有显示所有代码。有关完整代码,请参考/repo/ch02/php8_sqlite_auth_admin.php

如果我们运行调用程序,并将用户设置为admin,则结果如下:

图 2.9 – SQLite3 授权回调:admin 用户

图 2.9 – SQLite3 授权回调:admin 用户

前面截图的输出显示,由于我们以admin用户身份运行,并具有足够的授权权限,操作成功。当用户设置为guest时,输出如下:

图 2.10 – SQLite3 授权回调:guest 用户

图 2.10 – SQLite3 授权回调:guest 用户

输出显示,由于我们以权限不足的用户身份运行,尝试运行prepare()是不成功的。

这就结束了我们对这一期待已久的功能的讨论。您现在知道如何向一个否则不安全的数据库技术添加授权。

提示

描述添加 SQLite 授权回调的原始拉取请求:github.com/php/php-src/pull/4797

有关官方 SQLite 文档的授权回调:www.sqlite.org/c3ref/set_authorizer.html

传递给回调函数的操作代码:www.sqlite.org/c3ref/c_alter_table.html

结果代码的完整列表:www.sqlite.org/rescode.html

SQLite3类的文档:www.php.net/sqlite3

总结

在本章中,您了解了 PHP 8 在过程级别引入的一些更改。您首先了解了新的 nullsafe 运算符,它允许您大大缩短可能失败的对象引用链的任何代码。您还了解了三元运算符和可变参数运算符的使用已经得到了加强和改进,以及连接运算符在优先级顺序中已经降级。本章还涵盖了箭头函数的优缺点,以及它们如何作为匿名函数的清晰简洁的替代方案。

本章的后续部分向您展示了 PHP 8 如何继续沿着在 PHP 7 中首次引入的统一变量语法的趋势发展。您了解了 PHP 8 中如何解决剩余的不一致之处,包括插值字符串和魔术常量的解引用,以及在数组和字符串处理方面的改进,这些改进承诺使您的 PHP 8 更清洁、更简洁和更高性能。

最后,在最后一节中,您了解了一个新功能,它提供了对 SQLite 授权回调的支持,允许您在使用 SQLite 作为数据库时最终提供一定程度的安全性。

在下一章中,您将了解 PHP 8 的错误处理增强功能。

第三章:利用错误处理增强功能

如果您是 PHP 开发人员,您会注意到随着语言不断成熟,越来越多的保障措施被制定出来,最终强制执行良好的编码实践。在这方面,PHP 8 的一个关键改进是其先进的错误处理能力。在本章中,您将了解哪些Notices已升级为Warnings,哪些Warnings已升级为Errors

本章让您对安全增强的背景和意图有了很好的理解,从而使您更好地控制代码的使用。此外,了解以前只生成Warnings但现在也生成Errors的错误条件,以采取措施防止在升级到 PHP 8 后应用程序失败,也是至关重要的。

本章涵盖以下主题:

  • 理解 PHP 8 错误处理

  • 处理现在是错误的警告

  • 理解提升为警告的通知

  • 处理@错误控制运算符

技术要求

为了检查和运行本章提供的代码示例,以下是最低推荐的硬件要求:

  • 基于 x86_64 桌面 PC 或笔记本电脑

  • 1 千兆字节GB)的可用磁盘空间

  • 4 GB 的随机存取存储器RAM

  • 500 千比特每秒Kbps)或更快的互联网连接

此外,您还需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅第一章技术要求部分,介绍新的 PHP 8 OOP 功能,以及如何构建用于演示本书中所解释的代码的 Docker 容器。在本书中,我们将恢复本书示例代码的目录称为/repo

本章的源代码位于此处:

github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

我们现在可以通过检查新的 PHP 8 运算符来开始我们的讨论。

理解 PHP 8 错误处理

从历史上看,许多 PHP 错误条件被分配了远低于其实际严重性的错误级别。这给开发人员一种错误的安全感,因为他们只看到一个Notice,就认为他们的代码没有问题。许多情况以前只生成NoticeWarning,而实际上它们的严重性值得更多的关注。

在本节中,我们将看看 PHP 8 中一些错误处理的增强功能,这些功能继续执行强制执行良好编码实践的总体趋势。本章的讨论将帮助您重新审视您的代码,以便更高效地进行编码,并减少未来的维护问题。

在接下来的几个小节中,我们将看看对某些可能影响您的代码的NoticeWarning错误条件的更改。让我们首先看看 PHP 8 如何处理未定义变量的更改。

未定义变量处理

PHP 的一个臭名昭著的特性是它如何处理未定义的变量。看一下这个简单的代码块。请注意,$a$b变量没有被定义:

// /repo/ch03/php8_undef_var.php
$c = $a + $b;
var_dump($c);

在 PHP 7 下运行,这是输出:

PHP Notice:  Undefined variable: a in
/repo/ch03/php7_undef_var.php on line 3
PHP Notice:  Undefined variable: b in /repo/ch03/php7_undef_var.php on line 3
int(0)

从输出中可以看出,PHP 7 发出了一个Notice,让我们知道我们正在使用未定义的变量。如果我们使用 PHP 8 运行完全相同的代码,您可以快速看到以前的Notice已经提升为Warning,如下所示:

PHP Warning:  Undefined variable $a in /repo/ch03/php8_undef_var.php on line 3
PHP Warning:  Undefined variable $b in /repo/ch03/php8_undef_var.php on line 3
int(0)

PHP 8 中错误级别提升背后的推理是,许多开发人员认为使用未定义变量是一种无害的做法,实际上却是非常危险的! 你可能会问为什么?答案是,PHP 在没有明确指示的情况下,会将任何未定义的变量赋值为 NULL。 实际上,您的程序依赖于 PHP 的默认行为,这在将来的语言升级中可能会发生变化。

我们将在本章的接下来几节中介绍其他错误级别的提升。 但是,请注意,将 Notices 提升为 Warnings 的情况 不会影响代码的功能。 但是,它可能会引起更多潜在问题的注意,如果是这样,它就达到了产生更好代码的目的。 与未定义变量不同,未定义常量的错误现在已经进一步提升,您将在下一小节中看到。

未定义常量处理

在 PHP 8 中运行时,未定义常量的处理方式已经发生了变化。 但是,在这种情况下,以前是 Warning 的现在在 PHP 8 中是 Error。 看看这个看似无害的代码块:

// /repo/ch03/php7_undef_const.php
echo PHP_OS . "\n";
echo UNDEFINED_CONSTANT . "\n";
echo "Program Continues ... \n";

第一行回显了一个标识操作系统的 PHP_OS 预定义常量。 在 PHP 7 中,会生成一个 Notice;然而,输出的最后一行是 Program Continues ...,如下所示:

PHP Notice:  Use of undefined constant UNDEFINED_CONSTANT - assumed 'UNDEFINED_CONSTANT' in /repo/ch03/php7_undef_const.php on line 6
Program Continues ... 

同样的代码现在在 PHP 8 中运行时会产生致命错误,如下所示:

PHP Fatal error:  Uncaught Error: Undefined constant "UNDEFINED_CONSTANT" in /repo/ch03/php8_undef_const.php:6

因此,在 PHP 8 中,任何未在使用之前首先定义任何常量的糟糕代码都将崩溃和燃烧!一个好习惯是在应用程序代码的开头为所有变量分配默认值。 如果您计划使用常量,最好尽早定义它们,最好在一个地方。

重要提示

一个想法是在一个包含的文件中定义所有常量。 如果是这种情况,请确保使用这些常量的任何程序脚本已加载包含常量定义的文件。

提示

最佳实践:在程序代码使用之前,为所有变量分配默认值。 确保在使用之前定义任何自定义常量。 如果是这种情况,请确保使用这些常量的任何程序脚本已加载包含常量定义的文件。

错误级别默认值

值得注意的是,在 PHP 8 中,php.ini 文件 error_reporting 指令分配的错误级别默认值已经更新。 在 PHP 7 中,默认的 error_reporting 级别如下:

error_reporting=E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED

在 PHP 8 中,新级别要简单得多,您可以在这里看到:

error_reporting=E_ALL

值得注意的是,php.ini 文件设置 display_startup_errors 现在默认启用。 这可能会成为生产服务器的问题,因为您的网站可能会在 PHP 启动时意外地显示错误信息。

本节的关键要点是,过去,PHP 允许您通过只发出 NoticesWarnings 来逃脱某些不良实践。 但是,正如您在本节中所学到的,不解决 WarningNotice 生成背后的问题的危险在于 PHP 在您的代表上悄悄采取的行动。 不依赖 PHP 代表您做决定会减少隐藏的逻辑错误。 遵循良好的编码实践,例如在使用之前为所有变量分配默认值,有助于避免此类错误。 现在让我们更仔细地看看在 PHP 8 中将 Warnings 提升为 Errors 的错误情况。

处理现在是错误的警告

在本节中,我们将研究升级的 PHP 8 错误处理,涉及对象、数组和字符串。我们还将研究过去 PHP 发出“警告”的情况,而在 PHP 8 中现在会抛出“错误”。你必须意识到本节中描述的任何潜在错误情况。原因很简单:如果你没有解决本节描述的情况,当你的服务器升级到 PHP 8 时,你的代码将会出错。

开发人员经常时间紧迫。可能有一大堆新功能或其他必须进行的更改。在其他情况下,资源已经被调走到其他项目,意味着可用于维护的开发人员更少。由于应用程序继续运行,很多开发人员经常忽略“警告”,所以他们只是关闭错误显示,希望一切顺利。

多年来,堆积如山的糟糕代码已经积累起来。不幸的是,PHP 社区现在正在付出代价,以神秘的运行时错误的形式,需要花费数小时来追踪。通过将之前只引发“警告”的某些危险做法提升为“错误”,在 PHP 8 中很快就能显现出糟糕的编码实践,因为“错误”是致命的,会导致应用程序停止运行。

让我们从对象错误处理中的错误提升开始。

重要提示

一般来说,在 PHP 8 中,当尝试写入数据时,“警告”会升级为“错误”。另一方面,对于相同的一般情况(例如,尝试读/写不存在对象的属性),在 PHP 8 中,当尝试读取数据时,“通知”会升级为“警告”。总体上的理由是,写入尝试可能导致数据的丢失或损坏,而读取尝试则不会。

对象错误处理中的警告提升

这里是现在被视为对象处理的“警告”现在变成了“错误”的简要总结。如果你尝试做以下操作,PHP 8 会抛出一个“错误”:

  • 增加/减少非对象的属性

  • 修改非对象的属性

  • 给非对象的属性赋值

  • 从空值创建默认对象

让我们看一个简单的例子。在下面的代码片段中,一个值被赋给了一个不存在的对象$a。然后对这个值进行了递增:

// /repo/ch03/php8_warn_prop_nobj.php
$a->test = 0;
$a->test++;
var_dump($a);

这是 PHP 7 的输出:

PHP Warning:  Creating default object from empty value in /repo/ch03/php8_warn_prop_nobj.php on line 4
class stdClass#1 (1) {
  public $test =>
  int(1)
}

正如你所看到的,在 PHP 7 中,一个stdClass()实例被默默创建,并发出一个“警告”,但操作是允许继续的。如果我们在 PHP 8 下运行相同的代码,注意这里输出的差异:

PHP Fatal error:  Uncaught Error: Attempt to assign property "test" on null in /repo/ch03/php8_warn_prop_nobj.php:4

好消息是在 PHP 8 中,会抛出一个“错误”,这意味着我们可以通过实现一个try()/catch()块轻松地捕获它。例如,这里是之前显示的代码可能如何重写的示例:

try {
    $a->test = 0;
    $a->test++;
    var_dump($a);
} catch (Error $e) {
    error_log(__FILE__ . ':' . $e->getMessage());
}

正如你所看到的,这三行中的任何问题现在都安全地包裹在一个try()/catch()块中,这意味着可以进行恢复。我们现在将注意力转向数组错误处理的增强。

数组处理中的警告提升

关于数组的一些不良实践,在 PHP 7 及更早版本中是允许的,现在会抛出一个“错误”。正如前一小节所讨论的,PHP 8 数组错误处理的变化旨在对我们描述的错误情况给出更有力的响应。这些增强的最终目标是推动开发人员朝着良好的编码实践方向发展。

这是数组处理中的警告提升为错误的简要列表:

  • 无法将元素添加到数组中,因为下一个元素已经被占用

  • 无法取消非数组变量中的偏移量

  • 只有arrayTraversable类型可以被解包

  • 非法偏移类型

现在让我们逐一检查这个列表中的每个错误条件。

下一个元素已经被占用

为了说明一个可能的情况,即下一个数组元素无法被分配,因为它已经被占用,请看这个简单的代码示例:

// ch03/php8_warn_array_occupied.php
$a[PHP_INT_MAX] = 'This is the end!';
$a[] = 'Off the deep end';

假设由于某种原因,对一个数组元素进行赋值,其数字键是可能的最大大小的整数(由PHP_INT_MAX预定义常量表示)。如果随后尝试给下一个元素赋值,就会出现问题!

在 PHP 7 中运行此代码块的结果如下:

PHP Warning:  Cannot add element to the array as the next element is already occupied in
/repo/ch03/php8_warn_array_occupied.php on line 7
array(1) {
  [9223372036854775807] =>
  string(16) "This is the end!"
}

然而,在 PHP 8 中,Warning已经升级为Error,导致了这样的输出:

PHP Fatal error:  Uncaught Error: Cannot add element to the
array as the next element is already occupied in
/repo/ch03/php8_warn_array_occupied.php:7

接下来,我们将注意力转向在非数组变量中使用偏移量的情况。

非数组变量中的偏移量

将非数组变量视为数组可能会产生意外结果,但某些实现了Traversable(例如ArrayObjectArrayIterator)的对象类除外。一个例子是在字符串上使用类似数组的偏移量。

使用数组语法访问字符串字符在某些情况下可能很有用。一个例子是检查统一资源定位符URL)是否以逗号或斜杠结尾。在下面的代码示例中,我们检查 URL 是否以斜杠结尾。如果是的话,我们使用substr()将其截断:

// ch03/php8_string_access_using_array_syntax.php
$url = 'https://unlikelysource.com/';
if ($url[-1] == '/')
    $url = substr($url, 0, -1);
echo $url;
// returns: "https://unlikelysource.com"

在先前显示的示例中,$url[-1]数组语法使您可以访问字符串中的最后一个字符。

提示

您还可以使用新的 PHP 8 str_ends_with()函数来执行相同的操作!

然而,字符串绝对不是数组,也不应该被视为数组。为了避免糟糕的代码可能导致意外结果,PHP 8 中已经限制了使用数组语法引用字符串字符的滥用。

在下面的代码示例中,我们尝试在字符串上使用unset()

// ch03/php8_warn_array_unset.php
$alpha = 'ABCDEF';
unset($alpha[2]);
var_dump($alpha);

上面的代码示例实际上会在 PHP 7 和 8 中生成致命错误。同样,不要将非数组(或非Traversable对象)用作foreach()循环的参数。在接下来显示的示例中,将字符串作为foreach()的参数:

// ch03/php8_warn_array_foreach.php
$alpha = 'ABCDEF';
foreach ($alpha as $letter) echo $letter;
echo "Continues ... \n";

在 PHP 7 和早期版本中,会生成一个Warning,但代码会继续执行。在 PHP 7.1 中运行时的输出如下:

PHP Warning:  Invalid argument supplied for foreach() in /repo/ch03/php8_warn_array_foreach.php on line 6
Continues ... 

有趣的是,PHP 8 也允许代码继续执行,但Warning消息略有详细,如下所示:

PHP Warning:  foreach() argument must be of type array|object, string given in /repo/ch03/php8_warn_array_foreach.php on line 6
Continues ... 

接下来,我们将看看过去可以使用非数组/非Traversable类型进行展开的情况。

数组展开

看到这个小节标题后,您可能会问:什么是数组展开? 就像解引用的概念一样,展开数组只是一个从数组中提取值到离散变量的术语。例如,考虑以下简单的代码:

  1. 我们首先定义一个简单的函数,用于将两个数字相加,如下所示:
// ch03/php8_array_unpack.php
function add($a, $b) { return $a + $b; }
  1. 为了说明,假设数据以数字对的形式存在数组中,每个数字对都要相加:
$vals = [ [18,48], [72,99], [11,37] ];
  1. 在循环中,我们使用可变操作符(...)来展开对add()函数的调用中的数组对,如下所示:
foreach ($vals as $pair) {
    echo 'The sum of ' . implode(' + ', $pair) . 
         ' is ';
    echo add(...$pair);
}

刚才展示的示例演示了开发人员如何使用可变操作符来强制展开。然而,许多 PHP 数组函数在内部执行展开操作。考虑以下示例:

  1. 首先,我们定义一个由字母组成的数组。如果我们输出array_pop()的返回值,我们会看到输出的是字母Z,如下面的代码片段所示:
// ch03/php8_warn_array_unpack.php
$alpha = range('A','Z');
echo array_pop($alpha) . "\n";
  1. 我们可以使用implode()将数组展平为字符串来实现相同的结果,并使用字符串解引用来返回最后一个字母,如下面的代码片段所示:
$alpha = implode('', range('A','Z'));
echo $alpha[-1];
  1. 然而,如果我们尝试在字符串上使用array_pop(),就像这里所示,在 PHP 7 和早期版本中我们会得到一个Warning

echo array_pop($alpha);

  1. 在 PHP 7.1 下运行时的输出如下:
ZZPHP Warning:  array_pop() expects parameter 1 to be array, string given in /repo/ch03/php8_warn_array_unpack.php on line 14
  1. 以下是在相同的代码文件下在 PHP 8 下运行时的输出:
ZZPHP Fatal error:  Uncaught TypeError: array_pop(): Argument #1 ($array) must be of type array, string given in /repo/ch03/php8_warn_array_unpack.php:14

正如我们已经提到的,这里是另一个例子,以前会导致Warning的情况现在在 PHP 8 中导致TypeError。然而,这两组输出也说明了,尽管你可以像操作数组一样对字符串进行解引用,但字符串不能以与数组相同的方式进行解包。

接下来,我们来检查非法偏移类型。

非法偏移类型

根据 PHP 文档(www.php.net/manual/en/language.types.array.php),数组是键/值对的有序列表。数组键,也称为索引偏移,可以是两种数据类型之一:integerstring。如果一个数组只包含integer键,通常被称为数字数组。另一方面,关联数组是一个术语,用于使用string索引。非法偏移是指数组键的数据类型不是integerstring的情况。

重要提示

有趣的是,以下代码片段不会生成WarningError$x = (float) 22/7; $arr[$x] = 'Value of Pi';。在进行数组赋值之前,变量$x的值首先被转换为integer,截断任何小数部分。

举个例子,看看这段代码片段。请注意,最后一个数组元素的索引键是一个对象:

// ch03/php8_warn_array_offset.php
$obj = new stdClass();
$b = ['A' => 1, 'B' => 2, $obj => 3];
var_dump($b);

在 PHP 7 下运行的输出产生了Warningvar_dump()输出,如下所示:

PHP Warning:  Illegal offset type in /repo/ch03/php8_warn_array_offset.php on line 6
array(2) {
  'A' =>  int(1)
  'B' =>  int(2)
}

然而,在 PHP 8 中,var_dump()永远不会被执行,因为会抛出TypeError,如下所示:

PHP Fatal error:  Uncaught TypeError: Illegal offset type in /repo/ch03/php8_warn_array_offset.php:6

使用unset()时,与非法数组偏移相同的原则存在,如下面的代码示例所示:

// ch03/php8_warn_array_offset.php
$obj = new stdClass();
$b = ['A' => 1, 'B' => 2, 'C' => 3];
unset($b[$obj]);
var_dump($b);

在使用empty()isset()中的非法偏移时,对数组索引键的更严格控制也可以看到,如下面的代码片段所示:

// ch03/php8_warn_array_empty.php
$obj = new stdClass();
$obj->c = 'C';
$b = ['A' => 1, 'B' => 2, 'C' => 3];
$message =(empty($b[$obj])) ? 'NOT FOUND' : 'FOUND';
echo "$message\n";

在前面的两个代码示例中,在 PHP 7 及更早版本中,代码示例完成时会产生一个Warning,而在 PHP 8 中会抛出一个Error。除非捕获到这个Error,否则代码示例将无法完成。

提示

最佳实践:在初始化数组时,确保数组索引数据类型是integerstring

接下来,我们来看一下字符串处理中的错误提升。

字符串处理中的提升警告

关于对象和数组的提升警告也适用于 PHP 8 字符串错误处理。在这一小节中,我们将检查两个字符串处理Warning提升为Errors,如下所示:

  • 偏移不包含在字符串中

  • 空字符串偏移

  • 让我们首先检查不包含在字符串中的偏移。

偏移不包含在字符串中。

作为第一种情况的例子,看看下面的代码示例。在这里,我们首先将一个字符串分配给包含所有字母的字符串。然后,我们使用strpos()返回字母Z的位置,从偏移0开始。在下一行,我们做同样的事情;然而,偏移27超出了字符串的末尾:

// /repo/ch03/php8_error_str_pos.php
$str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
echo $str[strpos($str, 'Z', 0)];
echo $str[strpos($str, 'Z', 27)];

在 PHP 7 中,如预期的那样,返回了Z的输出,strpos()产生了一个Warning,并且产生了一个Notice,说明进行了偏移转换(关于这一点,我们将在下一节中详细介绍)。以下是 PHP 7 的输出:

Z
PHP Warning:  strpos(): Offset not contained in string in /repo/ch03/php8_error_str_pos.php on line 7
PHP Notice:  String offset cast occurred in /repo/ch03/php8_error_str_pos.php on line 7

然而,在 PHP 8 中,会抛出致命的ValueError,如下所示:

Z
PHP Fatal error:  Uncaught ValueError: strpos(): Argument #3 ($offset) must be contained in argument #1 ($haystack) in /repo/ch03/php8_error_str_pos.php:7

在这种情况下我们需要传达的关键点是,以前允许这种糟糕的编码保留在一定程度上是可以接受的。然而,在进行 PHP 8 升级后,正如你可以清楚地从输出中看到的那样,你的代码将失败。现在,让我们来看一下空字符串偏移。

空字符串偏移错误处理

信不信由你,在 PHP 7 之前的版本中,开发人员可以通过将空值赋给目标偏移来从字符串中删除字符。例如,看看这段代码:

// /repo/ch03/php8_error_str_empty.php
$str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$str[5] = '';
echo $str . "\n";

这个代码示例的目的是从由$str表示的字符串中删除字母F。令人惊讶的是,在 PHP 5.6 中,你可以从这个截图中看到,尝试是完全成功的:

图 3.1 - PHP 5.6 输出显示成功删除字符

图 3.1 - PHP 5.6 输出显示成功删除字符

请注意,我们用来演示本书中的代码的虚拟环境允许访问 PHP 7.1 和 PHP 8。为了正确展示 PHP 5 的行为,我们挂载了一个 PHP 5.6 的 Docker 镜像,并对结果进行了截图。

然而,在 PHP 7 中,这种做法是被禁止的,并且会发出一个Warning,如下所示:

PHP Warning:  Cannot assign an empty string to a string offset in /repo/ch03/php8_error_str_empty.php on line 5
ABCDEFGHIJKLMNOPQRSTUVWXYZ

正如您从前面的输出中所看到的,脚本被允许执行;然而,尝试删除字母F是不成功的。在 PHP 8 中,正如我们所讨论的,Warning被提升为Error,整个脚本中止,如下所示:

PHP Fatal error:  Uncaught Error: Cannot assign an empty string to a string offset in /repo/ch03/php8_error_str_empty.php:5

接下来,我们将研究在 PHP 8 中,以前的Notices被提升为Warnings的情况。

理解被提升为警告的通知

有许多情况被认为对 PHP 引擎在运行时的稳定性不太重要,在 PHP 7 之前的版本中被低估了。不幸的是,新的(或者可能是懒惰的!)PHP 开发人员通常会忽略Notices,以便匆忙将他们的代码投入生产。

多年来,PHP 标准已经大大收紧,这导致 PHP 核心团队将某些错误条件从Notice升级为Warning。任何错误报告级别都不会导致代码停止工作。然而,PHP 核心团队认为Notice-to-Warning的提升将使糟糕的编程实践变得更加明显。Warnings不太可能被忽视,最终会导致更好的代码。

以下是在早期版本的 PHP 中发出Notice的一些错误条件的简要列表,在 PHP 8 中,相同的条件现在会生成一个Warning

  • 尝试访问不存在的对象属性

  • 尝试访问不存在的静态属性

  • 尝试使用一个不存在的键来访问数组元素

  • 错误地将资源用作数组偏移

  • 模棱两可的字符串偏移转换

  • 不存在或未初始化的字符串偏移

首先让我们来看一下涉及对象的Notice促销活动。

不存在的对象属性访问处理

在早期的 PHP 版本中,尝试访问不存在的属性时会发出一个Notice。唯一的例外是当它是一个自定义类,你在那里定义了魔术__get()和/或__set()方法。

在下面的代码示例中,我们定义了一个带有两个属性的Test类,其中一个被标记为static

// /repo/ch03/php8_warn_undef_prop.php
class Test {
    public static $stat = 'STATIC';
    public $exists = 'NORMAL';
}
$obj = new Test();

然后我们尝试echo存在和不存在的属性,如下所示:

echo $obj->exists;
echo $obj->does_not_exist;

毫不奇怪,在 PHP 7 中,当尝试访问不存在的属性echo时,会返回一个Notice,如下所示:

NORMAL
PHP Notice:  Undefined property: Test::$does_not_exist in
/repo/ch03/php8_warn_undef_prop.php on line 14

同样的代码文件,在 PHP 8 中,现在返回一个Warning,如下所示:

NORMAL
PHP Warning:  Undefined property: Test::$does_not_exist in /repo/ch03/php8_warn_undef_prop.php on line 14

重要提示

Test::$does_not_exist错误消息并不意味着我们尝试了静态访问。它只是意味着Test类关联了一个$does_not_exist属性。

现在我们添加了尝试访问不存在的静态属性的代码行,如下所示:

try {
    echo Test::$stat;
    echo Test::$does_not_exist;
} catch (Error $e) {
    echo __LINE__ . ':' . $e;
}

有趣的是,PHP 7 和 PHP 8 现在都会发出致命错误,如下所示:

STATIC
22:Error: Access to undeclared static property Test::$does_not_exist in /repo/ch03/php8_warn_undef_prop.php:20

任何以前发出Warning的代码块现在发出Error都是值得关注的。如果可能的话,扫描你的代码,查找对静态类属性的静态引用,并确保它们被定义。否则,在 PHP 8 升级后,你的代码将失败。

现在让我们来看一下不存在的偏移处理。

不存在的偏移处理

如前一节所述,一般来说,在读取数据的地方,Notices已经被提升为Warnings,而在写入数据的地方,Warnings已经被提升为Errors(并且可能导致丢失数据)。不存在的偏移处理遵循这个逻辑。

在下面的例子中,一个数组键是从一个字符串中提取出来的。在这两种情况下,偏移量都不存在:

// /repo/ch03/php8_warn_undef_array_key.php
$key  = 'ABCDEF';
$vals = ['A' => 111, 'B' => 222, 'C' => 333];
echo $vals[$key[6]];

在 PHP 7 中,结果是一个Notice,如下所示:

PHP Notice:  Uninitialized string offset: 6 in /repo/ch03/php8_warn_undef_array_key.php on line 6
PHP Notice:  Undefined index:  in /repo/ch03/php8_warn_undef_array_key.php on line 6

在 PHP 8 中,结果是一个Warning,如下所示:

PHP Warning:  Uninitialized string offset 6 in /repo/ch03/php8_warn_undef_array_key.php on line 6
PHP Warning:  Undefined array key "" in /repo/ch03/php8_warn_undef_array_key.php on line 6

这个例子进一步说明了 PHP 8 错误处理增强的一般原理:如果你的代码写入数据到一个不存在的偏移,以前是一个警告在 PHP 8 中是一个错误。前面的输出显示了在 PHP 8 中尝试读取不存在偏移的数据时,现在会发出一个警告。下一个要检查的通知提升涉及滥用资源 ID。

滥用资源 ID 作为数组偏移

当创建到应用程序代码外部的服务的连接时,会生成一个资源。这种数据类型的一个典型例子是文件句柄。在下面的代码示例中,我们打开了一个文件句柄(从而创建了资源)到一个gettysburg.txt文件:

// /repo/ch03/php8_warn_resource_offset.php
$fn = __DIR__ . '/../sample_data/gettysburg.txt';
$fh = fopen($fn, 'r');
echo $fh . "\n";

请注意,我们在最后一行直接输出了资源。这显示了资源 ID 号。然而,如果我们现在尝试使用资源 ID 作为数组偏移,PHP 7 会生成一个通知,如下所示:

Resource id #5
PHP Notice:  Resource ID#5 used as offset, casting to integer (5) in /repo/ch03/php8_warn_resource_offset.php on line 9

如预期的那样,PHP 8 生成了一个警告,如下所示:

Resource id #5
PHP Warning:  Resource ID#5 used as offset, casting to integer (5) in /repo/ch03/php8_warn_resource_offset.php on line 9

请注意,在 PHP 8 中,许多以前生成资源的函数现在生成对象。这个主题在第七章**,避免在使用 PHP 8 扩展时陷阱中有所涉及。

提示

最佳实践:不要使用资源 ID 作为数组偏移!

现在我们将注意力转向与模糊的字符串偏移相关的通知在模糊的字符串偏移的情况下提升为警告

模糊的字符串偏移转换

将注意力转向字符串处理,我们再次回顾使用数组语法在字符串中识别单个字符的想法。如果 PHP 必须执行内部类型转换以评估字符串偏移,但在这种类型转换中并不清楚,就可能发生模糊的字符串偏移转换

在这个非常简单的例子中,我们定义了一个包含字母表中所有字母的字符串。然后我们用这些值定义了一个键的数组:NULL;一个布尔值,TRUE;和一个浮点数,22/7Pi的近似值)。然后我们循环遍历这些键,并尝试将键用作字符串偏移,如下所示:

// /repo/ch03/php8_warn_amb_offset.php
$str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$ptr = [ NULL, TRUE, 22/7 ];
foreach ($ptr as $key) {
    var_dump($key);
    echo $str[$key];
}

正如你可能预料的那样,在 PHP 7 中运行的输出产生了输出ABD,以及一系列的通知,如下所示:

NULL
PHP Notice:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
A
/repo/ch03/php8_warn_amb_offset.php:7:
bool(true)
PHP Notice:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
B
/repo/ch03/php8_warn_amb_offset.php:7:
double(3.1428571428571)
PHP Notice:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
D

PHP 8 始终产生相同的结果,但在这里,一个警告取代了通知

NULL
PHP Warning:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
A
bool(true)
PHP Warning:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
B
float(3.142857142857143)
PHP Warning:  String offset cast occurred in /repo/ch03/php8_warn_amb_offset.php on line 8
D

现在让我们来看看不存在偏移的处理。

未初始化或不存在的字符串偏移

这种类型的错误旨在捕获使用偏移访问字符串的情况,其中偏移超出了边界。下面是一个非常简单的代码示例,说明了这种情况:

// /repo/ch03/php8_warn_un_init_offset.php
$str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
echo $str[27];

在 PHP 7 中运行这段代码会产生一个通知。以下是 PHP 7 的输出:

PHP Notice:  Uninitialized string offset: 27 in /repo/ch03/php8_warn_un_init_offset.php on line 5

可以预见的是,PHP 8 的输出产生了一个警告,如下所示:

PHP Warning:  Uninitialized string offset 27 in /repo/ch03/php8_warn_un_init_offset.php on line 5

本节中的所有示例都证实了 PHP 8 朝着强制执行最佳编码实践的一般趋势。

提示

有关推广的“通知”和“警告”的更多信息,请查看这篇文章:wiki.php.net/rfc/engine_warnings

现在,我们将注意力转向(臭名昭著的)@警告抑制器。

处理@错误控制运算符

多年来,许多 PHP 开发人员一直使用@错误控制运算符来掩盖错误。当使用编写不良的 PHP 库时,这一点尤为真实。不幸的是,这种用法的净效果只会传播糟糕的代码!

许多 PHP 开发人员都在进行“一厢情愿的思考”,他们认为当他们使用@运算符来阻止错误显示时,问题似乎神奇地消失了!相信我,当我说这个时候:并没有!在这一部分,我们首先研究了@运算符的传统用法,之后我们研究了 PHP 8 中的@运算符的变化。

提示

有关传统@操作符的语法和用法的更多信息,请参阅此文档参考页面:www.php.net/manual/en/language.operators.errorcontrol.php

@操作符用法

在呈现代码示例之前,再次强调非常重要的一点是我们推广使用这种机制!相反,你应该在任何情况下避免使用它。如果出现错误消息,最好的解决方案是修复错误,而不是将其消音!

在下面的代码示例中,定义了两个函数。bad()函数故意触发错误。worse()函数包含一个文件,其中存在解析错误。请注意,当调用这些函数时,@符号在函数名之前,导致错误输出被抑制:

// /repo/ch03/php8_at_silencer.php
function bad() {
    trigger_error(__FUNCTION__, E_USER_ERROR);
}
function worse() {
    return include __DIR__ .  '/includes/
                               causes_parse_error.php';
}
echo @bad();
echo @worse();
echo "\nLast Line\n";

在 PHP 7 中,根本没有输出,如下所示:

root@php8_tips_php7 [ /repo/ch03 ]# php php8_at_silencer.php 
root@php8_tips_php7 [ /repo/ch03 ]# 

有趣的是,在 PHP 7 中程序实际上是不允许继续执行的:我们从未看到Last Line的输出。这是因为,尽管被掩盖了,但仍然生成了一个致命错误,导致程序失败。然而,在 PHP 8 中,致命错误没有被掩盖,如下所示:

root@php8_tips_php8 [ /repo/ch03 ]# php8 php8_at_silencer.php 
PHP Fatal error:  bad in /repo/ch03/php8_at_silencer.php on line 5

现在让我们来看一下 PHP 8 中关于@操作符的另一个不同之处。

@操作符和 error_reporting()

error_reporting()函数通常用于覆盖php.ini文件中设置的error_reporting指令。然而,这个函数的另一个用途是返回最新的错误代码。然而,在 PHP 8 之前的版本中存在一个奇怪的例外,即如果使用了@操作符,error_reporting()返回值为0

在下面的代码示例中,我们定义了一个错误处理程序,当它被调用时报告接收到的错误编号和字符串。此外,我们还显示了error_reporting()返回的值:

// /repo/ch03/php8_at_silencer_err_rep.php
function handler(int $errno , string $errstr) {
    $report = error_reporting();
    echo 'Error Reporting : ' . $report . "\n";
    echo 'Error Number    : ' . $errno . "\n";
    echo 'Error String    : ' . $errstr . "\n";
    if (error_reporting() == 0) {
        echo "IF statement works!\n";
    }
}

与以前一样,我们定义了一个bad()函数,故意触发错误,然后使用@操作符调用该函数,如下所示:

function bad() {
    trigger_error('We Be Bad', E_USER_ERROR);
}
set_error_handler('handler');
echo @bad();

在 PHP 7 中,你会注意到error_reporting()返回0,因此导致IF statement works!出现在输出中,如下所示:

root@root@php8_tips_php7 [ /repo/ch03 ] #
php php8_at_silencer_err_rep.php
Error Reporting : 0
Error Number    : 256
Error String    : We Be Bad
IF statement works!

另一方面,在 PHP 8 中运行,error_reporting()返回最后一个错误的值——在这种情况下是4437。当然,if()表达式失败,导致没有额外的输出。以下是在 PHP 8 中运行相同代码的结果:

root@php8_tips_php8 [ /repo/ch03 ] #
php php8_at_silencer_err_rep.php
Error Reporting : 4437
Error Number    : 256
Error String    : We Be Bad

这结束了对 PHP 8 中@操作符用法的考虑。

提示

最佳实践:不要使用@错误控制操作符!@操作符的目的是抑制错误消息的显示,但你需要考虑为什么这个错误消息首先出现。通过使用@操作符,你只是避免提供问题的解决方案!

总结

在本章中,你了解了 PHP 8 中错误处理的重大变化概述。你还看到了可能出现错误条件的情况示例,并且现在知道如何正确地管理 PHP 8 中的错误。你现在有了一个坚实的路径,可以重构在 PHP 8 下现在产生错误的代码。如果你的代码可能导致任何前述的条件,其中以前的“警告”现在是“错误”,你就有可能使你的代码崩溃。

同样地,虽然过去描述的第二组错误条件只会产生“通知”,但现在这些相同的条件会引发“警告”。新的一组“警告”给了你一个机会来调整错误的代码,防止你的应用程序陷入严重的不稳定状态。

最后,你学会了强烈不推荐使用@操作符。在 PHP 8 中,这种语法将不再掩盖致命错误。在下一章中,你将学习如何在 PHP 8 中创建 C 语言结构并直接调用 C 语言函数。

第四章:进行直接的 C 语言调用

本章介绍了外部函数接口(FFI)。在本章中,您将了解 FFI 的全部内容,它的作用以及如何使用它。本章的信息对于对使用直接 C 语言调用进行快速自定义原型设计感兴趣的开发人员非常重要。

在本章中,您不仅了解了将 FFI 引入 PHP 语言背后的背景,还学会了如何直接将 C 语言结构和函数合并到您的代码中。尽管——正如您将了解的那样——这并不是为了实现更快的速度,但它确实使您能够直接将任何 C 语言库合并到您的 PHP 应用程序中。这种能力为 PHP 打开了一个以前无法实现的功能世界。

本章涵盖的主题包括以下内容:

  • 理解 FFI

  • 学会何时使用 FFI

  • 检查 FFI 类

  • 在应用程序中使用 FFI

  • 使用 PHP 回调函数

技术要求

为了检查和运行本章提供的代码示例,以下是最低推荐的硬件要求:

  • 基于 X86_64 的台式 PC 或笔记本电脑

  • 1 千兆字节(GB)的可用磁盘空间

  • 4 GB 的随机存取内存(RAM)

  • 500 千位每秒(Kbps)或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

请参考第一章技术要求部分,了解有关 Docker 和 Docker Compose 安装的更多信息,以及如何构建用于演示本书中代码的 Docker 容器。在本书中,我们将恢复示例代码的目录称为/repo

本章的源代码位于此处:

https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

现在我们可以开始讨论 FFI 的理解。

理解 FFI

FFI 的主要目的是允许任何给定的编程语言能够将来自其他语言编写的外部库的代码和函数调用合并到其中。早期的一个例子是 20 世纪 80 年代微型计算机能够使用PEEKPOKE命令将汇编语言合并到否则笨拙的通用符号指令代码(BASIC)编程语言脚本中。与许多其他语言不同,PHP 在 PHP 7.4 之前没有这种能力,尽管自 2004 年以来一直在讨论中。

为了全面了解 PHP 8 中的 FFI,有必要偏离一下,看看为什么 FFI 在 PHP 语言中被完全采用花了这么长时间。还有必要快速了解一下 PHP 扩展,以及与 C 语言代码的工作能力。我们首先研究 PHP 和 C 语言之间的关系。

PHP 和 C 语言之间的关系

C 语言是由丹尼斯·里奇在 1972 年末在贝尔实验室开发的。自那时起,尽管引入了其面向对象的表亲 C++,这种语言仍然主导着编程语言领域。PHP 本身是用 C 编写的;因此,直接加载 C 共享库并直接访问 C 函数和数据结构的能力对于 PHP 语言来说是一个非常重要的补充。

将 FFI 扩展引入 PHP 语言使 PHP 能够加载并直接使用 C 结构和 C 函数。为了能够明智地决定何时何地使用 FFI 扩展,让我们先看一下一般的 PHP 扩展。

理解 PHP 扩展

PHP 扩展,顾名思义,扩展了 PHP 语言。每个扩展都可以添加面向对象编程OOP)类以及过程级函数。每个扩展都有一个独特的逻辑目的,例如,GD扩展处理图形图像处理,而PDO扩展处理数据库访问。

类比一下,考虑一家医院。在医院里,您有急诊、外科、儿科、骨科、心脏科、X 光等科室。每个科室都是独立的,有着不同的目的。这些科室共同构成了医院。同样地,PHP 就像医院,它的扩展就像各种科室。

并非所有扩展都是相同的。一些扩展,称为核心扩展,在安装 PHP 时始终可用。其他扩展必须手动下载、编译和启用。现在让我们来看看核心扩展。

访问 PHP 核心扩展

PHP 核心扩展直接包含在主 PHP 源代码存储库中,位于此处:https://github.com/php/php-src/tree/master/ext。如果您转到此网页,您将看到一个子目录列表,如下面的屏幕截图所示。每个子目录包含特定扩展的 C 语言代码:

图 4.1-在 GitHub 上看到的 PHP 核心扩展

图 4.1-在 GitHub 上看到的 PHP 核心扩展

因此,当 PHP 安装在服务器上时,所有核心扩展都会被编译和安装。现在我们来简要看看不属于核心的扩展。

检查非核心 PHP 扩展

不属于核心的 PHP 扩展通常由特定供应商(Microsoft就是一个例子)维护。非核心扩展通常被认为是可选的,并且使用不广泛。

一旦非核心扩展开始被越来越频繁地使用,它很可能最终会被迁移到核心中。这方面的例子很多。最近的一个是JSON扩展:它现在不仅是核心的一部分,而且在 PHP 8 中这个扩展不能再被禁用。

核心扩展也可能被移除。其中一个例子是mcrypt扩展。这在 PHP 7.1 中被弃用,因为该扩展依赖的基础库已经被遗弃了 9 年以上。在 PHP 7.2 中,它正式从核心中移除。现在我们考虑在哪里找到非核心扩展。

查找非核心扩展

在这一点上,您可能会问一个合乎逻辑的问题:您从哪里获取非核心扩展?一般来说,非核心扩展可以直接从供应商、github.com或此网站:http://pecl.php.net/获取。多年来一直有人抱怨pecl.php.net包含过时和未维护的代码。尽管这在一定程度上是真的,但同样也存在最新的、积极维护的代码在这个网站上。

例如,如果您查看 MongoDB 的 PHP 扩展,您会发现最新版本是在 2020 年 11 月底发布的。以下屏幕截图显示了此扩展的PHP 扩展社区库PECL)网站页面:

图 4.2-用于 PHP MongoDB 扩展的 pecl.php.net 页面

图 4.2-用于 PHP MongoDB 扩展的 pecl.php.net 页面

在许多情况下,供应商更倾向于保留对扩展的完全控制。这意味着您需要去他们的网站获取 PHP 扩展。一个例子是 Microsoft SQL Server 的 PHP 扩展,可以在此统一资源定位符URL)找到:https://docs.microsoft.com/en-us/sql/connect/php/download-drivers-php-sql-server?view=sql-server-ver15.

本小节的关键要点是,PHP 语言通过其扩展进行增强。这些扩展是用 C 语言编写的。因此,在 PHP 脚本中直接建模原型扩展的逻辑能力非常重要。现在让我们把注意力转向应该在哪里使用 FFI。

学习何时使用 FFI

直接将 C 库导入到 PHP 中的潜力真是令人震惊。PHP 核心开发人员中的一位实际上使用 FFI 扩展将 PHP 绑定到 C 语言TensorFlow机器学习平台!

提示

有关 TensorFlow 机器学习平台的信息,请访问此网页:https://www.tensorflow.org/。要了解 PHP 如何与此库绑定,请查看这里:github.com/dstogov/php-tensorflow

正如我们在本节中所展示的,FFI 扩展并不是解决所有需求的神奇解决方案。本节讨论了 FFI 扩展的主要优势和劣势,并为您提供了使用指南。我们在本节中揭穿的一个神话是,使用 FFI 扩展直接调用 C 语言来加速 PHP 8 程序执行。首先,让我们看看将 FFI 扩展纳入 PHP 中花费了这么长时间。

将 FFI 引入 PHP

实际上,第一个 FFI 扩展是由 PHP 核心开发人员Wez FurlongIlia Alshanetsky于 2004 年 1 月在 PECL 网站(https://pecl.php.net/)上为 PHP 5 引入的。然而,该项目从未通过 Alpha 阶段,并在一个月内停止了开发。

随着 PHP 在接下来的 14 年中的发展和成熟,人们开始意识到 PHP 将受益于在 PHP 脚本中快速原型化潜在扩展的能力。如果没有这种能力,PHP 有可能落后于其他语言,比如 Python 和 Ruby。

过去,由于缺乏快速原型能力,扩展开发人员被迫在能够在 PHP 脚本中测试之前编译完整的扩展并使用pecl安装它。在某些情况下,开发人员甚至不得不重新编译 PHP 本身来测试他们的新扩展!相比之下,FFI 扩展允许开发人员直接在PHP 脚本中放置 C 函数调用以进行即时测试。

从 PHP 7.4 开始,并持续到 PHP 8,核心开发人员 Dmitry Stogov 提出了改进版本的 FFI 扩展。在令人信服的概念验证之后(请参阅有关 PHP 绑定到 TensorFlow 机器学习平台的前面的提示框),这个 FFI 扩展版本被纳入了 PHP 语言中。

提示

原始的 FFI PHP 扩展可以在这里找到:http://pecl.php.net/package/ffi。有关修订后的 FFI 提案的更多信息,请参阅以下文章:https://wiki.php.net/rfc/ffi。

现在让我们来看看为什么不应该使用 FFI 来提高速度。

不要使用 FFI 来提高速度

因为 FFI 扩展允许 PHP 直接访问 C 语言库,人们很容易相信你的 PHP 应用程序会突然以机器语言速度运行得非常快。不幸的是,事实并非如此。FFI 扩展需要首先打开给定的 C 库,然后在执行之前解析和伪编译FFI实例。然后 FFI 扩展充当 C 库代码和 PHP 脚本之间的桥梁。

对一些读者来说,相对缓慢的 FFI 扩展性能不仅限于 PHP 8。其他语言在使用自己的 FFI 实现时也会遇到相同的限制效果。这里有一个基于Ary 3 基准的优秀性能比较,可以在这里找到:https://wiki.php.net/rfc/ffi#php_ffi_performance。

如果您查看刚刚引用的网页上显示的表格,您会发现 Python FFI 实现在 0.343 秒内完成了基准测试,而仅使用本机 Python 代码运行相同的基准测试只需 0.212 秒。

查看相同的表,PHP 7.4 FFI 扩展在 0.093 秒内运行了基准测试(比 Python 快 30 倍!),而仅使用本机 PHP 代码运行的相同基准测试在 0.040 秒内执行。

下一个逻辑问题是:为什么你应该使用 FFI 扩展? 这将在下一节中介绍。

为什么要使用 FFI 扩展?

对上一个问题的答案很简单:这个扩展主要是为了快速PHP 扩展原型。PHP 扩展是语言的命脉。没有扩展,PHP 只是另一种编程语言

当高级开发人员首次着手进行编程项目时,他们需要确定项目的最佳语言。一个关键因素是可用的扩展数量以及这些扩展的活跃程度。通常,活跃维护的扩展数量与使用该语言的项目的长期成功潜力之间存在直接关系。

因此,如果有一种方法可以加快扩展开发的速度,那么 PHP 语言本身的长期可行性就得到了改善。FFI 扩展为 PHP 语言带来的价值在于,它能够在不必经历整个编译-链接-加载-测试周期的情况下,直接在 PHP 脚本中测试扩展原型。

FFI 扩展的另一个用例,除了快速原型设计之外,是允许 PHP 直接访问模糊或专有的 C 代码的一种方式。一个例子是编写用于控制工厂机器的自定义 C 代码。为了让 PHP 运行工厂,可以使用 FFI 扩展将 PHP 直接绑定到控制各种机器的 C 库。

最后,这个扩展的另一个用例是用它来预加载 C 库,可能会减少内存消耗。在我们展示使用示例之前,让我们来看看FFI类及其方法。

检查 FFI 类

正如您在本章中所学到的,不是每个开发人员都需要使用 FFI 扩展。直接使用 FFI 扩展可以加深您对 PHP 语言内部的理解,这种加深的理解对您作为 PHP 开发人员的职业生涯可能会产生积极影响:很可能在将来的某个时候,您将被一家开发了自定义 PHP 扩展的公司雇佣。在这种情况下,了解如何操作 FFI 扩展可以让您为自定义 PHP 扩展开发新功能,同时帮助您解决扩展问题。

FFI类包括 20 个方法,分为四个广泛的类别,如下所述:

  • 创建性:此类别中的方法创建了 FFI 扩展应用程序编程接口API)中可用的类的实例。

  • 比较:比较方法旨在比较 C 数据值。

  • 信息性:这组方法为您提供有关 C 数据值的元数据,包括大小和对齐

  • 基础设施:基础设施方法用于执行后勤操作,如复制、填充和释放内存。

提示

完整的 FFI 类文档在这里:www.php.net/manual/en/class.ffi.php

有趣的是,所有FFI类方法都可以以静态方式调用。现在是时候深入了解与 FFI 相关的类的细节和用法了,首先是创建性方法。

使用 FFI 创建方法

属于创建类别的 FFI 方法旨在直接产生FFI实例或 FFI 扩展提供的类的实例。在使用 FFI 扩展提供的 C 函数时,重要的是要认识到不能直接将本地的 PHP 变量传递给函数并期望它能工作。数据必须首先被创建为FFI数据类型或导入到FFI数据类型中,然后才能将FFI数据类型传递给 C 函数。要创建FFI数据类型,请使用下面总结的函数之一,如下所示:

表 4.1 – FFI 类创建方法总结

表 4.1 – FFI 类创建方法总结

cdef()scope()方法都会产生一个直接的FFI实例,而其他方法会产生可以用来创建FFI实例的对象实例。string()用于从本地 C 变量中提取给定数量的字节。让我们来看看如何创建和使用FFI\CType实例。

创建和使用 FFI\CType 实例

非常重要的一点是,一旦创建了FFI\CType实例,不要简单地将一个值赋给它,就像它是一个本地的 PHP 变量一样。这样做只会由于 PHP 是弱类型语言而简单地覆盖FFI\CType实例。相反,要将标量值赋给FFI\CType实例,使用它的cdata属性。

下面的例子创建了一个$arr C 数组。然后用值填充本地 C 数组,直到达到最大大小,之后我们使用一个简单的var_dump()来查看它的内容。我们将按照以下步骤进行:

  1. 首先,我们使用FFI::arrayType()来创建数组。作为参数,我们提供了一个FFI::type()方法和维度。然后我们使用FFI::new()来创建FFI\Ctype实例。代码如下所示:
// /repo/ch04/php8_ffi_array.php
$type = FFI::arrayType(FFI::type("char"), [3, 3]);
$arr  = FFI::new($type);
  1. 或者,我们也可以将操作合并成一个单一的语句,如下所示:

$arr = FFI::new(FFI::type("char[3][3]"));

  1. 然后我们初始化了三个提供测试数据的变量,如下面的代码片段所示。请注意,本地的 PHPcount()函数适用于FFI\CData数组类型:
$pos   = 0;
$val   = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$y_max = count($arr);
  1. 现在我们可以用值填充它,就像用 PHP 数组一样,只是我们需要使用cdata属性来保留元素作为FFI\CType实例。代码如下所示:
for ($y = 0; $y < $y_max; $y++) {
    $x_max = count($arr[$y]);
    for ($x = 0; $x < $x_max; $x++) {
        $arr[$y][$x]->cdata = $val[$pos++];
    }
}
var_dump($arr)

在前面的例子中,我们使用嵌套的for()循环来填充二维的 3 x 3 数组,用字母表的字母。如果我们现在执行一个简单的var_dump(),我们会得到以下结果:

root@php8_tips_php8 [ /repo/ch04 ]# php 
php8_ffi_array.php 
object(FFI\CData:char[3][3])#2 (3) {
  [0]=> object(FFI\CData:char[3])#3 (3) {
    [0]=> string(1) "A"
    [1]=> string(1) "B"
    [2]=> string(1) "C"
  }
  [1]=> object(FFI\CData:char[3])#1 (3) {
    [0]=> string(1) "D"
    [1]=> string(1) "E"
    [2]=> string(1) "F"
  }
  [2]=> object(FFI\CData:char[3])#4 (3) {
    [0]=> string(1) "G"
    [1]=> string(1) "H"
    [2]=> string(1) "I"
}

从输出中要注意的第一件重要的事情是,索引都是整数。从输出中得到的第二个要点是,这显然不是一个本地的 PHP 数组。var_dump()告诉我们,每个数组元素都是一个FFI\CData实例。还要注意的是,C 语言字符串被视为数组。

因为数组的类型是char,我们可以使用FFI::string()来显示其中一行。下面是一个产生ABC响应的命令:

echo FFI::string($arr[0], 3);

任何尝试将FFI\CData实例提供给一个以数组作为参数的 PHP 函数都注定失败,即使它被定义为数组类型。在下面的代码片段中,注意如果我们将这个命令添加到前面的代码块中的输出:

echo implode(',', $arr);

从下面的输出中可以看到,因为数据类型不是arrayimplode()会发出致命错误。以下是结果输出:

PHP Fatal error:  Uncaught TypeError: implode(): Argument #2 ($array) must be of type ?array, FFI\CData given in /repo/ch04/php8_ffi_array.php:25

现在你知道如何创建和使用FFI\CType实例了。现在让我们转向创建FFI实例。

创建和使用 FFI 实例

如章节介绍中所述,FFI 扩展有助于快速原型设计。因此,使用 FFI 扩展,你可以逐个开发设计用于新扩展的 C 函数,并立即在 PHP 应用程序中进行测试。

重要提示

FFI 扩展不会编译 C 代码。为了在 FFI 扩展中使用 C 函数,您必须首先使用 C 编译器将 C 代码编译成共享库。您将在本章的最后一节“在应用程序中使用 FFI”中学习如何做到这一点。

为了在 PHP 和本地 C 库函数调用之间建立桥梁,您需要创建一个FFI实例。FFI 扩展需要您提供一个定义了 C 函数签名和您计划使用的 C 库的 C 定义。FFI::cdef()FFI::scope()都可以直接创建FFI实例。

以下示例使用FFI::cdef()绑定了两个本地 C 库函数。具体操作如下:

  1. 第一个本地方法srand()用于初始化随机化序列。另一个本地 C 函数rand()调用序列中的下一个数字。$key变量保存了随机化的最终产品。$size表示要调用的随机数的数量。代码如下所示:
// /repo/ch04/php8_ffi_cdef.php
$key  = '';
$size = 4;
  1. 然后,我们通过调用cdef()并在字符串$code中标识本地 C 函数来创建FFI实例,该字符串取自libc.so.6本地 C 库,如下所示:
$code = <<<EOT
    void srand (unsigned int seed);
    int rand (void);
EOT;
$ffi = FFI::cdef($code, 'libc.so.6');
  1. 然后我们通过调用srand()来初始化随机化。然后,在循环中,我们调用rand()本地 C 库函数来生成一个随机数。我们使用sprintf()本地 PHP 函数将生成的整数转换为十六进制,然后将其附加到$key,并将其输出。代码如下所示:
$ffi->srand(random_int(0, 999));
for ($x = 0; $x < $size; $x++)
    $key .= sprintf('%x', $ffi->rand());
echo $key

以下是前面代码片段的输出。请注意,生成的值可以用作随机密钥:

root@php8_tips_php8 [ /repo/ch04 ]# php php8_ffi_cdef.php
23f306d51227432e7d8d921763b7eedf

在输出中,您会看到一串连接的随机整数转换为十六进制的字符串。请注意,每次调用脚本时,结果值都会发生变化。

提示

对于真正的随机化,最好只使用random_int()本地 PHP 函数。openssl扩展中还有出色的密钥生成函数。这里展示的示例主要是为了让您熟悉 FFI 扩展的用法。

重要提示

FFI 扩展还包括两种额外的创建方法:FFI::load()FFI::scope()FFI::load()用于在预加载过程中直接从 C 头文件(*.h)加载 C 函数定义。FFI::scope()使预加载的 C 函数可通过 FFI 扩展使用。有关预加载的更多信息,请查看 FFI 文档中的完整预加载示例:www.php.net/manual/en/ffi.examples-complete.php

现在让我们来看看用于比较本地 C 数据类型的 FFI 扩展函数。

使用 FFI 比较数据

请记住,使用 FFI 扩展创建 C 语言数据结构时,它存在于 PHP 应用程序之外。正如您在前面的示例中看到的(请参阅创建和使用 FFI\CType 实例部分),PHP 可以在一定程度上与 C 数据交互。但是,为了比较目的,最好使用FFI::memcmp(),因为本地 PHP 函数可能返回不一致的结果。

FFI 扩展中提供的两个比较函数在表 4.2中总结如下:

表 4.2 – FFI 类比较方法总结

表 4.2 – FFI 类比较方法总结

FFI::isNull()可用于确定FFI\CData实例是否为NULL。更有趣的是FFI::memcmp()。虽然这个函数的操作方式与太空船操作符<=>)相同,但它接受一个第三个参数,表示您希望在比较中包含多少字节。以下示例说明了这种用法:

  1. 首先定义一组代表FFI\CData实例的四个变量,这些实例可以包含多达六个字符,并使用示例数据填充这些实例,如下所示:
// /repo/ch04/php8_ffi_memcmp.php
$a = FFI::new("char[6]");
$b = FFI::new("char[6]");
$c = FFI::new("char[6]");
$d = FFI::new("char[6]");
  1. 请记住,C 语言将字符数据视为数组,因此即使使用cdata属性,我们也不能直接分配字符串。因此,我们需要定义一个匿名函数,用字母填充实例。我们使用以下代码来实现这一点:
$populate = function ($cdata, $start, $offset, $num) {
    for ($x = 0; $x < $num; $x++)
        $cdata[$x + $offset] = chr($x + $offset + 
                                   $start);
    return $cdata;
};
  1. 接下来,我们使用该函数将四个FFI\CData实例填充为不同的字母集,如下所示:
$a = $populate($a, 65, 0, 6);
$b = $populate($b, 65, 0, 3);
$b = $populate($b, 85, 3, 3);
$c = $populate($c, 71, 0, 6);
$d = $populate($d, 71, 0, 6);
  1. 现在我们可以使用FFI::string()方法来显示到目前为止的内容,如下所示:
$patt = "%2s : %6s\n";
printf($patt, '$a', FFI::string($a, 6));
printf($patt, '$b', FFI::string($b, 6));
printf($patt, '$c', FFI::string($c, 6));
printf($patt, '$d', FFI::string($d, 6));
  1. 这是printf()语句的输出:
$a : ABCDEF
$b : ABCXYZ
$c : GHIJKL
$d : GHIJKL
  1. 从输出中可以看出,$c$d的值是相同的。$a$b的前三个字符相同,但最后三个字符不同。

  2. 此时,如果我们尝试使用太空船操作符(<=>)进行比较,结果将如下:

PHP Fatal error:  Uncaught FFI\Exception: Comparison of incompatible C types
  1. 同样,尝试使用strcmp(),即使数据是字符类型,结果如下:
PHP Warning:  strcmp() expects parameter 1 to be string, object given
  1. 因此,我们唯一的选择是使用FFI::memcmp()。在这组比较中,注意第三个参数是6,表示 PHP 应该比较最多六个字符:
$p = "%20s : %2d\n";
printf($p, 'memcmp($a, $b, 6)', FFI::memcmp($a, 
        $b, 6));
printf($p, 'memcmp($c, $a, 6)', FFI::memcmp($c, 
        $a, 6));
printf($p, 'memcmp($c, $d, 6)', FFI::memcmp($c, 
        $d, 6));
  1. 如预期的那样,输出与在原生 PHP 字符串上使用太空船操作符的输出相同,如下所示:
   memcmp($a, $b, 6) : -1
   memcmp($c, $a, 6) :  1
   memcmp($c, $d, 6) :  0
  1. 请注意,如果将比较限制为仅三个字符,会发生什么。这是添加到代码块中的另一个FFI::memcmp()比较,将第三个参数设置为3
echo "\nUsing FFI::memcmp() but not full length\n";
printf($p, 'memcmp($a, $b, 3)', FFI::memcmp($a, 
        $b, 3));
  1. 从这里显示的输出中可以看出,通过将memcmp()限制为仅三个字符,$a$b被视为相等,因为它们都以相同的三个字符abc开头:
Using FFI::memcmp() but not full length
   memcmp($a, $b, 3) :  0

从这个例子中最重要的是,您需要在要比较的字符数和要比较的数据性质之间找到平衡。比较的字符数越少,整体操作速度越快。然而,如果数据的性质可能导致错误的结果,您必须增加字符数,并在性能上稍微损失。

现在让我们来看看如何从 FFI 扩展数据中收集信息。

从 FFI 扩展数据中提取信息

当您使用FFI实例和原生 C 数据结构时,原生 PHP 信息方法(如strlen()ctype_digit())无法提供有用的信息。因此,FFI 扩展包括三种方法,旨在生成有关 FFI 扩展数据的信息。这三种方法在表 4.3中总结如下:

表 4.3 - FFI 类信息方法总结

表 4.3 - FFI 类信息方法总结

首先我们看看FFI::typeof(),然后再深入了解其他两种方法。

使用 FFI::typeof()确定 FFI 数据的性质

这是一个示例,说明了如何使用FFI::typeof()。该示例还演示了处理 FFI 数据时,原生 PHP 信息函数无法产生有用的结果。我们这样做:

  1. 首先,我们定义一个$char C 字符串,并用字母表的前六个字母填充它,如下所示:
// /repo/ch04/php8_ffi_typeof.php
$char = FFI::new("char[6]");
for ($x = 0; $x < 6; $x++)
    $char[$x] = chr(65 + $x);
  1. 然后我们尝试使用strlen()来获取字符串的长度。在下面的代码片段中,请注意使用$t::class:这相当于get_class($t)。此用法仅适用于 PHP 8 及以上版本:
try {
    echo 'Length of $char is ' . strlen($char);
} catch (Throwable $t) {
    echo $t::class . ':' . $t->getMessage();
}
  1. 在 PHP 7.4 中的结果是一个Warning消息。然而,在 PHP 8 中,如果将除字符串以外的任何内容传递给strlen(),将抛出致命的Error消息。这是此时的 PHP 8 输出:
TypeError:strlen(): Argument #1 ($str) must be of type string, FFI\CData given
  1. 类似地,尝试使用ctype_alnum(),如下所示:
echo '$char is ' .
    ((ctype_alnum($char)) ? 'alpha' : 'non-alpha');
  1. 以下是在步骤 4中显示的echo命令的输出:
$char is non-alpha
  1. 显然,我们无法使用原生 PHP 函数获取有关 FFI 数据的有用信息!然而,使用FFI::typeof(),如下所示,会返回更好的结果:
$type = FFI::typeOf($char);
var_dump($type);
  1. 这是var_dump()的输出:
object(FFI\CType:char[6])#3 (0) {}

从最终输出中可以看出,我们现在有了有用的信息!现在让我们来看看另外两种 FFI 信息方法。

利用 FFI::alignof()和 FFI::sizeof()

在进入展示这两种方法的实际示例之前,重要的是要理解对齐的确切含义。为了理解对齐,您需要对大多数计算机中内存的组织方式有基本的了解。

RAM 仍然是在程序运行周期内临时存储信息的最快方式。您计算机的中央处理单元CPU)在程序执行时将信息从内存中移入和移出。内存以并行数组的形式组织。alignof()返回的对齐值将是可以一次从对齐内存数组的并行切片中获取多少字节。在旧计算机中,值为 4 是典型的。对于大多数现代微型计算机,常见的值为 8 或 16(或更大)。

现在让我们来看一个示例,说明了这两种 FFI 扩展信息方法的使用以及这些信息如何产生性能改进。我们将按照以下步骤进行:

  1. 首先,我们创建一个FFI实例$ffi,在其中定义了两个标记为GoodBad的 C 结构。请注意,在下面的代码片段中,这两个结构具有相同的属性;然而,这些属性的排列顺序不同。
$struct = 'struct Bad { char c; double d; int i; }; '
        . 'struct Good { double d; int i; char c; }; 
          ';
$ffi = FFI::cdef($struct);
  1. 然后我们从$ffi中提取这两个结构,如下所示:
$bad = $ffi->new("struct Bad");
$good = $ffi->new("struct Good");
var_dump($bad, $good);
  1. var_dump()输出如下所示:
object(FFI\CData:struct Bad)#2 (3) {
  ["c"]=> string(1) ""
  ["d"]=> float(0)
  ["i"]=> int(0)
}
object(FFI\CData:struct Good)#3 (3) {
  ["d"]=> float(0)
  ["i"]=> int(0)
  ["c"]=> string(1) ""
}
  1. 然后我们使用这两个信息方法来报告这两个数据结构,如下所示:
echo "\nBad Alignment:\t" . FFI::alignof($bad);
echo "\nBad Size:\t" . FFI::sizeof($bad);
echo "\nGood Alignment:\t" . FFI::alignof($good);
echo "\nGood Size:\t" . FFI::sizeof($good);

这个代码示例的最后四行输出如下所示:

Bad Alignment:  8
Bad Size:       24
Good Alignment: 8
Good Size:      16

从输出中可以看出,FFI::alignof()的返回告诉我们对齐块的宽度为 8 字节。然而,您还可以看到,Bad结构占用的字节数比Good结构所需的空间大 50%。由于这两个数据结构具有完全相同的属性,任何理智的开发人员都会选择Good结构。

从这个例子中,您可以看到 FFI 扩展信息方法能够让我们了解如何最有效地构造我们的 C 数据。

提示

关于 C 语言中sizeof()alignof()的区别的出色讨论,请参阅这篇文章:https://stackoverflow.com/questions/11386946/whats-the-difference-between-sizeof-and-alignof。

现在您已经了解了 FFI 扩展信息方法是什么,并且已经看到了它们的使用示例。现在让我们来看看与基础设施相关的 FFI 扩展方法。

使用 FFI 基础设施方法

FFI 扩展基础类别方法可以被视为支持 C 函数绑定所需的幕后组件。正如我们在本章中一直强调的那样,如果您希望直接从 PHP 应用程序中访问 C 数据结构,则需要 FFI 扩展。因此,如果您需要执行类似于 PHP unset()语句以释放内存,或者 PHP include()语句以包含外部程序代码,FFI 扩展基础方法提供了本地 C 数据和 PHP 之间的桥梁。

表 4.4,如下所示,总结了这个类别中的方法:

表 4.4 – FFI 类基础方法

表 4.4 – FFI 类基础方法

首先,让我们先看看FFI::addr()free()memset()memcpy()

使用 FFI::addr()、free()、memset()和 memcpy()

PHP 开发人员经常通过引用给变量赋值。这允许一个变量的更改自动反映在另一个变量中。当传递参数给需要返回多个值的函数或方法时,引用的使用尤其有用。通过引用传递允许函数或方法返回无限数量的值。

FFI::addr()方法创建一个指向现有FFI\CData实例的 C 指针。就像 PHP 引用一样,对指针关联的数据所做的任何更改也将被更改。

在使用FFI::addr()方法构建示例的过程中,我们还向您介绍了FFI::memset()。这个函数很像str_repeat()PHP 函数,因为它(FFI::memset())用特定值填充指定数量的字节。在这个例子中,我们使用FFI::memset()来用字母表的字母填充 C 字符字符串。

在本小节中,我们还将介绍FFI::memcpy()。这个函数用于将数据从一个FFI\CData实例复制到另一个实例。与FFI::addr()方法不同,FFI::memcpy()创建一个克隆,与复制数据源没有任何连接。此外,我们介绍了FFI::free(),这是一个用于释放使用FFI::addr()创建的指针的方法。

让我们看看这些 FFI 扩展方法如何使用,如下所示:

  1. 首先,创建一个FFI\CData实例$arr,由六个字符的 C 字符串组成。请注意,在下面的代码片段中,使用了FFI::memset(),另一个基础设施方法,用美国信息交换标准代码ASCII)码 65:字母A填充字符串:
// /repo/ch04/php8_ffi_addr_free_memset_memcpy.php
$size = 6;
$arr  = FFI::new(FFI::type("char[$size]"));
FFI::memset($arr, 65, $size);
echo FFI::string($arr, $size);
  1. 使用FFI::string()方法的echo结果如下所示:
AAAAAA
  1. 从输出中可以看到,出现了六个 ASCII 码 65(字母A)的实例。然后我们创建另一个FFI\CData实例$arr2,并使用FFI::memcpy()将一个实例中的六个字符复制到另一个实例中,如下所示:
$arr2  = FFI::new(FFI::type("char[$size]"));
FFI::memcpy($arr2, $arr, $size);
echo FFI::string($arr2, $size);
  1. 毫不奇怪,输出与步骤 2中的输出完全相同,如下所示:
AAAAAA
  1. 接下来,我们创建一个指向$arr的 C 指针。请注意,当指针被赋值时,它们会出现在本机 PHP var_dump()函数中作为数组元素。然后我们可以改变数组元素0的值,并使用FFI::memset()将其填充为字母B。代码如下所示:
$ref = FFI::addr($arr);
FFI::memset($ref[0], 66, 6);
echo FFI::string($arr, $size);
var_dump($ref, $arr, $arr2);
  1. 以下是步骤 5中剩余代码的输出:
BBBBBB
object(FFI\CData:char(*)[6])#2 (1) {
  [0]=>   object(FFI\CData:char[6])#4 (6) {
    [0]=>  string(1) "B"
    [1]=>  string(1) "B"
    [2]=>  string(1) "B"
    [3]=>  string(1) "B"
    [4]=>  string(1) "B"
    [5]=>  string(1) "B"
  }
}
object(FFI\CData:char[6])#3 (6) {
  [0]=>  string(1) "B"
  [1]=>  string(1) "B"
  [2]=>  string(1) "B"
  [3]=>  string(1) "B"
  [4]=>  string(1) "B"
  [5]=>  string(1) "B"
}
object(FFI\CData:char[6])#4 (6) {
  [0]=>  string(1) "A"
  [1]=>  string(1) "A"
  [2]=>  string(1) "A"
  [3]=>  string(1) "A"
  [4]=>  string(1) "A"
  [5]=>  string(1) "A"
}

从输出中可以看到,我们首先有一个BBBBBB字符串。您可以看到指针的形式是一个 PHP 数组。原始的FFI\CData实例$arr现在已经改变为字母B。然而,前面的输出也清楚地显示了复制的$arr2不受对$arr或其$ref[0]指针所做的更改的影响。

  1. 最后,为了释放使用FFI::addr()创建的指针,我们使用FFI::free()。这个方法很像本机 PHP 的unset()函数,但是设计用于处理 C 指针。这是我们添加到示例的最后一行代码:

FFI::free($ref);

现在您已经了解了如何使用 C 指针以及如何使用信息填充 C 数据,让我们看看如何使用FFI\CData实例进行类型转换。

学习关于 FFI::cast()

在 PHP 中,类型转换的过程经常发生。当 PHP 被要求执行涉及不同数据类型的操作时,就会使用它。下面是一个经典的例子:

$a = 123;
$b = "456";
echo $a + $b;

在这个微不足道的例子中,$a被分配了int(整数)的数据类型,$b被分配了string的类型。echo语句要求 PHP 首先将$b强制转换为int,执行加法,然后将结果强制转换为string

本机 PHP 还允许开发人员通过在变量或表达式前面的括号中添加所需的数据类型来强制数据类型。从前面代码片段的重写示例可能如下所示:

$a = 123;
$b = "456";
echo (string) ($a + (int) $b);

强制类型转换使您的意图对其他使用您代码的开发人员非常清晰。它还保证了结果,因为强制类型转换对代码流的控制更大,并且不依赖于 PHP 的默认行为。

FFI 扩展具有类似的功能,即FFI::cast()方法。正如您在本章中看到的,FFI 扩展数据与 PHP 隔离,并且不受 PHP 类型转换的影响。为了强制数据类型,您可以使用FFI::cast()返回所需的并行FFI\CData类型。让我们看看如何在以下步骤中做到这一点:

  1. 在这个例子中,我们创建了一个int类型的FFI\CData实例$int1。我们使用它的cdata属性来赋值123,如下所示:
// /repo/ch04/php8_ffi_cast.php
// not all lines are shown
$patt = "%2d : %16s\n";
$int1 = FFI::new("int");
$int1->cdata = 123;
$bool = FFI::cast(FFI::type("bool"), $int1);
printf($patt, __LINE__, (string) $int1->cdata);
printf($patt, __LINE__, (string) $bool->cdata);
  1. 正如您从这里显示的输出中看到的,将123的整数值强制转换为bool(布尔值),在输出中显示为1
 8 :                  123
 9 :                    1
  1. 接下来,我们创建了一个int类型的FFI\CData实例$int2,并赋值123。然后我们将其强制转换为float,再转回int,如下面的代码片段所示:
$int2 = FFI::new("int");
$int2->cdata = 123;
$float1 = FFI::cast(FFI::type("float"), $int2);
$int3   = FFI::cast(FFI::type("int"), $float1);
printf($patt, __LINE__, (string) $int2->cdata);
printf($patt, __LINE__, (string) $float1->cdata);
printf($patt, __LINE__, (string) $int3->cdata);
  1. 最后三行的输出非常令人满意。我们看到我们的原始值123被表示为1.7235971111195E-43。当强制转换回int时,我们的原始值被恢复。以下是最后三行的输出:
15 :                 123
16 : 1.7235971111195E-43
17 :                 123
  1. FFI 扩展与 C 语言一般一样,不允许所有类型进行转换。例如,在上一段代码中,我们尝试将类型为floatFFI\CData实例$float2强制转换为char类型,如下所示:
try {
    $float2 = FFI::new("float");
    $float2->cdata = 22/7;
    $char1   = FFI::cast(FFI::type("char[20]"), 
        $float2);
    printf($patt, __LINE__, (string) $float2->cdata);
    printf($patt, __LINE__, (string) $char1->cdata);
} catch (Throwable $t) {
    echo get_class($t) . ':' . $t->getMessage();
}
  1. 结果是灾难性的!正如您从这里显示的输出中看到的,抛出了一个FFI\Exception
FFI\Exception:attempt to cast to larger type

在本节中,我们介绍了一系列 FFI 扩展方法,这些方法创建了 FFI 扩展对象实例,比较值,收集信息,并处理所创建的 C 数据基础设施。您了解到有一些 FFI 扩展方法在本机 PHP 语言中具有相同的功能。在下一节中,我们将回顾一个实际的例子,将一个 C 函数库整合到 PHP 脚本中,使用 FFI 扩展。

在应用程序中使用 FFI

任何共享的 C 库(通常具有*.so扩展名)都可以使用 FFI 扩展包含在 PHP 应用程序中。如果您计划使用任何核心 PHP 库或在安装 PHP 扩展时生成的库,重要的是要注意您有能力修改 PHP 语言本身的行为。

在我们研究它是如何工作之前,让我们首先看看如何将外部 C 库整合到 PHP 脚本中,使用 FFI 扩展。

将外部 C 库整合到 PHP 脚本中

为了举例说明,我们使用了一个简单的函数,可能源自计算机科学 101CS101)课程:著名的冒泡排序。这个算法在初学者的计算机科学课程中被广泛使用,因为它很容易理解。

重要提示

冒泡排序是一种极其低效的排序算法,长期以来一直被更快的排序算法(如希尔排序快速排序归并排序算法)所取代。虽然没有冒泡排序算法的权威参考,但您可以在这里阅读到一个很好的一般讨论:en.wikipedia.org/wiki/Bubble_sort

在这个小节中,我们不会详细介绍算法。相反,这个小节的目的是演示如何将现有的 C 库并入到 PHP 脚本中的一个函数。我们现在向您展示原始的 C 源代码,如何将其转换为共享库,最后如何使用 FFI 将库整合到 PHP 中。我们将做以下事情:

  1. 当然,第一步是将 C 代码编译为对象代码。以下是本例中使用的冒泡排序 C 代码:
#include <stdio.h>
void bubble_sort(int [], int);
void bubble_sort(int list[], int n) {
    int c, d, t, p;
    for (c = 0 ; c < n - 1; c++) {
        p = 0;
        for (d = 0 ; d < n - c - 1; d++) {
            if (list[d] > list[d+1]) {
                t = list[d];
                list[d] = list[d+1];
                list[d+1] = t;
                p++;
            }
        }
        if (p == 0) break;
    }
}
  1. 然后,我们使用 GNU C 编译器(包含在本课程使用的 Docker 镜像中)将 C 代码编译为对象代码,如下所示:

gcc -c -Wall -Werror -fpic bubble.c

  1. 接下来,我们将对象代码合并到一个共享库中。这一步是必要的,因为 FFI 扩展只能访问共享库。我们运行以下代码来完成这一步:

gcc -shared -o libbubble.so bubble.o

  1. 现在我们准备定义使用我们新共享库的 PHP 脚本。我们首先定义一个函数,该函数显示来自FFI\CData数组的输出,如下所示:
// /repo/ch04/php8_ffi_using_func_from_lib.php
function show($label, $arr, $max) 
{
    $output = $label . "\n";
    for ($x = 0; $x < $max; $x++)
        $output .= $arr[$x] . ',';
    return substr($output, 0, -1) . "\n";
}
  1. 接下来是关键部分:定义FFI实例。我们使用FFI::cdef()来完成这个任务,并提供两个参数。第一个参数是函数签名,第二个参数是我们新创建的共享库的路径。这两个参数都可以在以下代码片段中看到:
$bubble = FFI::cdef(
    "void bubble_sort(int [], int);",
    "./libbubble.so");
  1. 然后,我们创建了一个FFI\CData元素,作为一个包含 16 个随机整数的整数数组,使用rand()函数进行填充。代码如下所示:
$max   = 16;
$arr_b = FFI::new('int[' . $max . ']');
for ($i = 0; $i < $max; $i++)
    $arr_b[$i]->cdata = rand(0,9999);
  1. 最后,我们显示了排序之前数组的内容,执行了排序,并显示了排序后的内容。请注意,在以下代码片段中,我们使用FFI实例调用bubble_sort()来执行排序:
echo show('Before Sort', $arr_b, $max);
$bubble->bubble_sort($arr_b, $max);
echo show('After Sort', $arr_b, $max);
  1. 输出,正如您所期望的那样,在排序之前显示了一组随机整数。排序后,数值是有序的。以下是步骤 7中代码的输出:
Before Sort
245,8405,8580,7586,9416,3524,8577,4713,
9591,1248,798,6656,9064,9846,2803,304
After Sort
245,304,798,1248,2803,3524,4713,6656,7586,
8405,8577,8580,9064,9416,9591,9846

既然您已经了解了如何使用 FFI 扩展将外部 C 库集成到 PHP 应用程序中,我们转向最后一个主题:PHP 回调。

使用 PHP 回调

正如我们在本节开头提到的,可以使用 FFI 扩展来整合实际 PHP 语言(或其扩展)中的共享 C 库。这种整合很重要,因为它允许您通过访问 PHP 共享 C 库中定义的 C 数据结构来读取和写入本机 PHP 数据。

然而,本小节的目的并不是向您展示如何创建 PHP 扩展。相反,在本小节中,我们向您介绍了 FFI 扩展覆盖本机 PHP 语言功能的能力。这种能力被称为PHP 回调。在我们深入实现细节之前,我们必须首先检查与这种能力相关的潜在危险。

理解 PHP 回调的潜在危险

重要的是要理解,PHP 共享库中定义的 C 函数通常被多个 PHP 函数使用。因此,如果您在 C 级别覆盖了其中一个低级函数,您可能会在 PHP 应用程序中遇到意外行为。

另一个已知问题是,覆盖本机 PHP C 函数很有可能会产生内存泄漏。随着时间的推移,使用这种覆盖的长时间运行的应用程序可能会失败,并且有可能导致服务器崩溃!

最后要考虑的是,PHP 回调功能并非在所有 FFI 平台上都受支持。因此,尽管代码可能在 Linux 服务器上运行,但在 Windows 服务器上可能无法运行(或可能无法以相同的方式运行)。

提示

与其使用 FFI PHP 回调来覆盖本机 PHP C 库功能,也许更容易、更快速、更安全的方法是只定义自己的 PHP 函数!

既然您已经了解了使用 PHP 回调涉及的危险,让我们来看一个示例实现。

实现 PHP 回调

在下面的示例中,使用回调覆盖了zend_write内部 PHP 共享库的 C 函数,该回调在输出末尾添加了换行符LF)。请注意,此覆盖会影响任何依赖它的本机 PHP 函数,包括echoprintprintf:换句话说,任何产生直接输出的 PHP 函数。要实现 PHP 回调,请按照以下步骤进行:

  1. 首先,我们使用FFI::cdef()定义了一个FFI实例。第一个参数是zend_write的函数签名。代码如下所示:
// /repo/ch04/php8_php_callbacks.php
$zend = FFI::cdef("
    typedef int (*zend_write_func_t)(
        const char *str,size_t str_length);
    extern zend_write_func_t zend_write;
");
  1. 然后,我们添加了代码来确认未经修改的echo不会在末尾添加额外的换行符。您可以在这里看到代码:
echo "Original echo command does not output LF:\n";
echo 'A','B','C';
echo 'Next line';
  1. 毫不奇怪,输出产生了ABCNext line。输出中没有回车或换行符,如下所示:
Original echo command does not output LF:
ABCNext line
  1. 然后,我们将指向zend_write的指针克隆到$orig_zend_write变量中。如果我们不这样做,我们将无法使用原始函数!代码如下所示:

$orig_zend_write = clone $zend->zend_write;

  1. 接下来,我们以匿名函数的形式生成一个 PHP 回调,覆盖原始的zend_write函数。在函数中,我们调用原始的zend_write函数并在其输出中添加一个 LF,如下所示:
$zend->zend_write = function($str, $len) {
    global $orig_zend_write;
    $ret = $orig_zend_write($str, $len);
    $orig_zend_write("\n", 1);
    return $ret;
};
  1. 剩下的代码重新运行了前面步骤中显示的echo命令,如下所示:
echo 'Revised echo command adds LF:';
echo 'A','B','C';
  1. 以下输出演示了 PHP echo 命令现在在每个命令的末尾产生一个 LF:
Revised echo command adds LF:
A
B
C

还要注意的是,修改 PHP 库 C 语言zend_write函数会影响使用这个 C 语言函数的所有 PHP 本机函数。这包括print()printf()(及其变体)等。

这结束了我们对在 PHP 应用程序中使用 FFI 扩展的讨论。您现在知道如何将外部共享库中的本机 C 函数整合到 PHP 中。您还知道如何用 PHP 回调替换本机 PHP 核心或扩展共享库,从而有可能改变 PHP 语言本身的行为。

总结

在本章中,您了解了 FFI 及其历史,以及如何使用它来促进快速的 PHP 扩展原型设计。您还了解到,虽然 FFI 扩展不应该用于提高速度,但它也可以让您的 PHP 应用程序直接调用外部 C 库的本机 C 函数。这种能力的强大之处通过一个调用外部 C 库的冒泡排序函数的示例得到了展示。这种能力也可以扩展到包括机器学习、光学字符识别、通信、加密等成千上万个 C 库,无穷无尽

在本章中,您将更深入地了解 PHP 本身在 C 语言级别的运行方式。您将学习如何创建并直接使用 C 语言数据结构,使您能够与 PHP 语言本身进行交互,甚至覆盖 PHP 语言本身。此外,您现在已经知道如何将任何 C 语言库的功能直接整合到 PHP 应用程序中。这种知识的另一个好处是,如果您找到一家计划开发自己的自定义 PHP 扩展或已经开发了自定义 PHP 扩展的公司,它将有助于增强您的职业前景。

下一章标志着书的新部分PHP 8 技巧的开始。在下一节中,您将学习升级到 PHP 8 时的向后兼容性问题。下一章具体讨论了面向对象编程方面的向后兼容性问题。

第二部分:PHP 8 的技巧

在这一部分,您将进入 PHP 8 的黑暗角落:那些存在向后兼容性破坏的地方。本部分将指导您完成将现有应用程序迁移到 PHP 8 的关键过程。

本节包括以下章节:

  • 第五章,发现潜在的面向对象编程向后兼容性破坏

  • 第六章,理解 PHP 8 的功能差异

  • 第七章,使用 PHP 8 扩展时避免陷阱

  • 第八章,了解 PHP 8 的已弃用或移除功能

第五章:发现潜在的面向对象编程向后兼容性问题

本章标志着本书第 2 部分PHP 8 技巧的开始。在这一部分,您将发现 PHP 8 的黑暗角落:向后兼容性问题存在的地方。本部分将让您了解如何在将现有应用程序迁移到 PHP 8 之前避免问题。您将学会如何查找现有代码中可能导致其在 PHP 8 升级后停止工作的问题。一旦掌握了本书这一部分介绍的主题,您将能够很好地修改现有代码,使其在 PHP 8 升级后继续正常运行。

在本章中,您将介绍与面向对象编程(OOP)相关的新的 PHP 8 特性。本章提供了大量清晰说明新特性和概念的简短代码示例。本章对帮助您快速利用 PHP 8 的强大功能至关重要,因为您可以将代码示例调整为自己的实践。本章的重点是在 PHP 8 迁移后,面向对象的代码可能会出现问题的情况。

本章涵盖的主题包括以下内容:

  • 发现核心面向对象编程的差异

  • 导航魔术方法的更改

  • 控制序列化

  • 理解扩展的 PHP 8 变异支持

  • 处理标准 PHP 库SPL)的更改

技术要求

要查看和运行本章提供的代码示例,建议的最低硬件要求如下:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 千兆字节(GB)的可用磁盘空间

  • 4GB 的 RAM

  • 每秒 500 千位(Kbps)或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 的安装以及如何构建用于演示本书中解释的代码的 Docker 容器的更多信息,请参阅第一章技术要求部分,介绍新的 PHP 8 面向对象编程特性。在本书中,我们将您为本书恢复的示例代码的目录称为/repo

本章的源代码位于此处:https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。

我们现在可以开始讨论核心面向对象编程的差异。

发现核心面向对象编程的差异

在 PHP 8 中,您可以以不同的方式编写面向对象的代码。在本节中,我们重点关注可能会导致潜在向后兼容性问题的三个关键领域。本节我们将讨论与进行静态方法调用、处理对象属性和 PHP 自动加载相关的常见不良实践。

阅读本节并完成示例后,您将更好地发现面向对象的不良实践,并了解 PHP 8 如何对此类用法进行限制。在本章中,您将学习良好的编码实践,这将最终使您成为更好的程序员。您还将能够解决 PHP 自动加载中的更改,这可能会导致迁移到 PHP 8 的应用程序失败。

让我们首先看看 PHP 8 如何加强静态调用。

在 PHP 8 中处理静态调用

令人惊讶的是,PHP 7 及以下版本允许开发人员对未声明为static的类方法进行静态调用。乍一看,任何未来审查您代码的开发人员立即会假设该方法已被定义为static。这可能会导致意外行为,因为未来的开发人员在错误的假设下开始误用您的代码。

在这个简单的例子中,我们定义了一个带有nonStatic()方法的Test类。在类定义后的程序代码中,我们输出了这个方法的返回值,然而,在这样做时,我们进行了一个静态调用:

// /repo/ch05/php8_oop_diff_static.php
class Test {
    public function notStatic() {
        return __CLASS__ . PHP_EOL;
    }
}
echo Test::notStatic();

当我们在 PHP 7 中运行此代码时,结果如下:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_oop_diff_static.php
PHP Deprecated:  Non-static method Test::notStatic() should not be called statically in /repo/ch05/php8_oop_diff_static.php on line 11
Test

从输出中可以看出,PHP 7 会发出弃用通知,但允许调用!然而,在 PHP 8 中,结果是致命的Error,如下所示:

root@php8_tips_php8 [ /repo/ch05 ]#
php php8_oop_diff_static.php
PHP Fatal error:  Uncaught Error: Non-static method Test::notStatic() cannot be called statically in /repo/ch05/php8_oop_diff_static.php:11

使用静态方法调用非静态方法的语法是一种不良实践,因为良好编写的代码使代码开发人员的意图变得清晰明了。如果您没有将方法定义为静态,但后来以静态方式调用它,未来负责维护您代码的开发人员可能会感到困惑,并可能对代码的原始意图做出错误的假设。最终结果将是更糟糕的代码!

在 PHP 8 中,您不能再使用静态方法调用非静态方法。现在让我们再看看另一个涉及将对象属性视为键的不良实践。

处理对象属性处理的变化

数组一直是 PHP 的一个核心特性,一直延续到最早的版本。另一方面,面向对象编程直到 PHP 4 才被引入。在面向对象编程的早期,数组函数经常被扩展以适应对象属性。这导致对象和数组之间的区别变得模糊,从而产生了一些不良实践。

为了保持数组处理和对象处理之间的清晰分离,PHP 8 现在限制array_key_exists()函数只接受数组作为参数。为了说明这一点,考虑以下示例:

  1. 首先,我们定义一个带有单个属性的简单匿名类:
// /repo/ch05/php8_oop_diff_array_key_exists.php
$obj = new class () { public $var = 'OK.'; };
  1. 然后我们运行三个测试,分别使用isset()property_exists()array_key_exists()来检查$var的存在:
// not all code is shown
$default = 'DEFAULT';
echo (isset($obj->var)) 
    ? $obj->var : $default;
echo (property_exists($obj,'var')) 
    ? $obj->var : $default;
echo (array_key_exists('var',$obj)) 
    ? $obj->var : $default;

当我们在 PHP 7 中运行这段代码时,所有测试都成功,如下所示:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_oop_diff_array_key_exists.php
OK.OK.OK.

然而,在 PHP 8 中,会发生致命的TypeError,因为array_key_exists()现在只接受数组作为参数。PHP 8 的输出如下所示:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_oop_diff_array_key_exists.php
OK.OK.PHP Fatal error:  Uncaught TypeError: array_key_exists(): Argument #2 ($array) must be of type array, class@anonymous given in /repo/ch05/php8_oop_diff_array_key_exists.php:10

最佳实践是使用property_exists()isset()。现在我们将注意力转向 PHP 自动加载的变化。

使用 PHP 8 自动加载

在 PHP 8 中首次引入的基本自动加载类机制与 PHP 8 中的工作方式相同。主要区别在于,全局函数__autoload()在 PHP 7.2 中已弃用,并在 PHP 8 中已完全删除。从 PHP 7.2 开始,开发人员被鼓励使用spl_autoload_register()注册其自动加载逻辑,该函数自 PHP 5.1 起可用于此目的。另一个主要区别是如果无法注册自动加载程序,spl_autoload_register()的反应方式。

了解使用spl_autoload_register()时自动加载过程的工作原理对于作为开发人员的工作至关重要。不理解 PHP 如何自动定位和加载类将限制您作为开发人员的能力,并可能对您的职业道路产生不利影响。

在深入研究spl_autoload_register()之前,让我们先看一下__autoload()函数。

理解 __autoload()函数

__autoload()函数被许多开发人员用作自动加载逻辑的主要来源。这个函数的行为方式类似于魔术方法,这就是为什么它根据上下文自动调用。会触发自动调用__autoload()函数的情况包括创建新类实例时,但类定义尚未加载的时刻。此外,如果类扩展另一个类,则还会调用自动加载逻辑,以便在创建扩展它的子类之前加载超类。

使用__autoload()函数的优点是它非常容易定义,并且通常在网站的初始index.php文件中定义。缺点包括以下内容:

  • __autoload()是一个 PHP 过程函数;不是使用面向对象编程原则定义或控制的。例如,在为应用程序定义单元测试时,这可能会成为一个问题。

  • 如果你的应用程序使用命名空间,__autoload()函数必须在全局命名空间中定义;否则,在定义__autoload()函数的命名空间之外的类将无法加载。

  • __autoload()函数与spl_autoload_register()不兼容。如果你同时使用__autoload()函数和spl_autoload_register()定义自动加载逻辑,__autoload()函数的逻辑将被完全忽略。

为了说明潜在的问题,我们将定义一个OopBreakScan类,更详细地讨论在第十一章**,将现有的 PHP 应用迁移到 PHP 8中:

  1. 首先,我们定义并添加一个方法到OopBreakScan类中,该方法扫描文件内容以查找__autoload()函数。请注意,错误消息是在Base类中定义的一个类常量,只是警告存在__autoload()函数:
namespace Migration;
class OopBreakScan extends Base {
    public static function scanMagicAutoloadFunction(
        string $contents, array &$message) : bool {
        $found  = 0;
        $found += (stripos($contents, 
            'function __autoload(') !== FALSE);
        $message[] = ($found)
                   ? Base::ERR_MAGIC_AUTOLOAD
                   : sprintf(Base::OK_PASSED,
                       __FUNCTION__);
        return (bool) $found;
    }
    // remaining methods not shown

这个类扩展了一个Migration\Base类(未显示)。这很重要,因为任何自动加载逻辑都需要找到子类和它的超类。

  1. 接下来,我们定义一个调用程序,在其中定义了一个魔术__autoload()函数:
// /repo/ch05/php7_autoload_function.php
function __autoLoad($class) {
    $fn = __DIR__ . '/../src/'
        . str_replace('\\', '/', $class)
        . '.php';
    require_once $fn;
}
  1. 然后我们通过让调用程序扫描自身来使用这个类:
use Migration\OopBreakScan;
$contents = file_get_contents(__FILE__);
$message  = [];
OopBreakScan::
    scanMagicAutoloadFunction($contents, $message);
var_dump($message);

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_autoload_function.php
/repo/ch05/php7_autoload_function.php:23:
array(1) {
  [0] =>  string(96) "WARNING: the "__autoload()" function is removed in PHP 8: replace with "spl_autoload_register()""
}

从输出中可以看到,Migration\OopBreakScan类被自动加载了。我们知道这是因为scanMagicAutoloadFunction方法被调用了,我们有它的结果。此外,我们知道Migration\Base类也被自动加载了。我们知道这是因为输出中出现的错误消息是超类的常量。

然而,在 PHP 8 中运行相同的代码会产生这样的结果:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php7_autoload_function.php 
PHP Fatal error:  __autoload() is no longer supported, use spl_autoload_register() instead in /repo/ch05/php7_autoload_function.php on line 4

这个结果并不奇怪,因为在 PHP 8 中移除了对魔术__autoload()函数的支持。在 PHP 8 中,你必须使用spl_autoload_register()。现在我们转向spl_autoload_register()

学习使用 spl_autoload_register()

spl_autoload_register()函数的主要优点是它允许你注册多个自动加载器。虽然这可能看起来有些多余,但想象一下一个噩梦般的情景,你正在使用许多不同的开源 PHP 库...它们都定义了自己的自动加载器!只要所有这些库都使用spl_autoload_register(),拥有多个自动加载器回调就不会有问题。

使用spl_autoload_register()注册的每个自动加载器都必须是可调用的。以下任何一种都被认为是可调用

  • 一个 PHP 过程函数

  • 一个匿名函数

  • 一个可以以静态方式调用的类方法

  • 定义了__invoke()魔术方法的任何类实例

  • 一个这样的数组:[$instance, 'method']

提示

Composer维护着自己的自动加载器,它又依赖于spl_autoload_register()。如果你正在使用 Composer 来管理你的开源 PHP 包,你可以简单地在应用程序代码的开头包含/path/to/project/vendor/autoload.php来使用 Composer 的自动加载器。要让 Composer 自动加载你的应用程序源代码文件,可以在composer.json文件的autoload : psr-4键下添加一个或多个条目。更多信息,请参见getcomposer.org/doc/04-schema.md#psr-4

一个相当典型的自动加载器类可能如下所示。请注意,这是我们在本书中许多 OOP 示例中使用的类:

  1. __construct()方法中,我们分配了源目录。随后,我们使用上面提到的数组可调用语法调用spl_auto_register()
// /repo/src/Server/Autoload/Loader.php
namespace Server\Autoload;
class Loader {
    const DEFAULT_SRC = __DIR__ . '/../..';
    public $src_dir = '';
    public function __construct($src_dir = NULL) {
        $this->src_dir = $src_dir 
            ?? realpath(self::DEFAULT_SRC);
        spl_autoload_register([$this, 'autoload']);
    }
  1. 实际的自动加载代码与我们上面__autoload()函数示例中显示的类似。以下是执行实际自动加载的方法:
    public function autoload($class) {
        $fn = str_replace('\\', '/', $class);
        $fn = $this->src_dir . '/' . $fn . '.php';
        $fn = str_replace('//', '/', $fn);
        require_once($fn);
    }
}

现在你已经了解了如何使用spl_auto_register()函数,我们必须检查在运行 PHP 8 时可能出现的代码中断。

PHP 8 中潜在的 spl_auto_register()代码中断

spl_auto_register()函数的第二个参数是一个可选的布尔值,默认为FALSE。如果将第二个参数设置为TRUE,则在 PHP 7 及以下版本中,如果自动加载程序注册失败,spl_auto_register()函数会抛出一个Exception。然而,在 PHP 8 中,如果第二个参数的数据类型不是callable,则无论第二个参数的值如何,都会抛出致命的TypeError

下面显示的简单程序示例说明了这种危险。在这个例子中,我们使用spl_auto_register()函数注册一个不存在的 PHP 函数。我们将第二个参数设置为TRUE

// /repo/ch05/php7_spl_spl_autoload_register.php
try {
    spl_autoload_register('does_not_exist', TRUE);
    $data = ['A' => [1,2,3],'B' => [4,5,6],'C' => [7,8,9]];
    $response = new \Application\Strategy\JsonResponse($data);
    echo $response->render();
} catch (Exception $e) {
    echo "A program error has occurred\n";
}

如果我们在 PHP 7 中运行这个代码块,这是结果:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_spl_spl_autoload_register.php 
A program error has occurred

从输出中可以确定,抛出了一个Exceptioncatch块被调用,出现了消息发生了程序错误。然而,当我们在 PHP 8 中运行相同的程序时,会抛出一个致命的Error

root@php8_tips_php8 [ /repo/ch05 ]# 
php php7_spl_spl_autoload_register.php 
PHP Fatal error:  Uncaught TypeError: spl_autoload_register(): Argument #1 ($callback) must be a valid callback, no array or string given in /repo/ch05/php7_spl_spl_autoload_register.php:12

显然,catch块被绕过,因为它设计用于捕获Exception,而不是Error。简单的解决方法是让catch块捕获Throwable而不是Exception。这允许相同的代码在 PHP 7 或 PHP 8 中运行。

重写后的代码可能如下所示。输出没有显示,因为它与在 PHP 7 中运行相同的示例相同:

// /repo/ch05/php8_spl_spl_autoload_register.php
try {
    spl_autoload_register('does_not_exist', TRUE);
    $data = ['A' => [1,2,3],'B' => [4,5,6],'C' => [7,8,9]];
    $response = new \Application\Strategy\JsonResponse($data);
    echo $response->render();
} catch (Throwable $e) {
    echo "A program error has occurred\n";
}

现在您对 PHP 8 自动加载有了更好的理解,以及如何发现和纠正潜在的自动加载向后兼容性问题。现在让我们来看看 PHP 8 中与魔术方法相关的变化。

导航魔术方法的变化

PHP 的魔术方法是预定义的钩子,它们中断了 OOP 应用程序的正常流程。每个魔术方法,如果定义了,都会改变应用程序的行为,从对象实例创建的那一刻开始,直到实例超出范围的那一刻。

重要提示

对象实例在被取消或被覆盖时会超出范围。当在函数或类方法中定义对象实例时,对象实例也会超出范围,并且该函数或类方法的执行结束。最终,如果没有其他原因,当 PHP 程序结束时,对象实例会超出范围。

本节将让您充分了解 PHP 8 中引入的魔术方法使用和行为的重要变化。一旦您了解了本节描述的情况,您就能够进行适当的代码修改,以防止您的应用程序代码在迁移到 PHP 8 时失败。

让我们首先看一下对象构造方法的变化。

处理构造函数的变化

理想情况下,类构造函数是一个在对象实例创建时自动调用的方法,用于执行某种对象初始化。这种初始化通常涉及使用作为参数提供给该方法的值填充对象属性。初始化还可以执行任何必要的任务,如打开文件句柄、建立数据库连接等。

在 PHP 8 中,类构造函数被调用的方式发生了一些变化。这意味着当您将应用程序迁移到 PHP 8 时,可能会出现向后兼容性问题。我们将首先检查的变化与使用与类相同名称的方法作为类构造函数的方法有关。

处理具有相同名称的方法和类的变化

在 PHP 4 版本中引入的第一个 PHP OOP 实现中,确定了与类相同名称的方法将承担类构造函数的角色,并且在创建新对象实例时将自动调用该方法。

鲜为人知的是,即使在 PHP 8 中,函数、方法甚至类名都是不区分大小写的。因此$a = new ArrayObject();等同于$b = new arrayobject();。另一方面,变量名是区分大小写的。

从 PHP 5 开始,随着一个新的更加健壮的 OOP 实现,魔术方法被引入。其中之一是__construct(),专门用于类构造,旨在取代旧的用法。通过 PHP 5 的剩余版本,一直到所有的 PHP 7 版本,都支持使用与类同名的方法作为构造函数。

在 PHP 8 中,删除了与类本身相同名称的类构造方法的支持。如果也定义了__construct()方法,你就不会有问题:__construct()优先作为类构造函数。如果没有__construct()方法,并且检测到一个与class ()相同名称的方法,你就有可能失败。请记住,方法和类名都是不区分大小写的!

看一下以下的例子。它在 PHP 7 中有效,但在 PHP 8 中无效:

  1. 首先,我们定义了一个Text类,它有一个同名的类构造方法。构造方法基于提供的文件名创建了一个SplFileObject实例:
// /repo/ch05/php8_oop_bc_break_construct.php
class Text {
    public $fh = '';
    public const ERROR_FN = 'ERROR: file not found';
    public function text(string $fn) {
        if (!file_exists($fn))
            throw new Exception(self::ERROR_FN);
        $this->fh = new SplFileObject($fn, 'r');
    }
    public function getText() {
        return $this->fh->fpassthru();
    }
}
  1. 然后我们添加了三行过程代码来使用这个类,提供一个包含葛底斯堡演说的文件的文件名:
$fn   = __DIR__ . '/../sample_data/gettysburg.txt';
$text = new Text($fn);
echo $text->getText();
  1. 首先在 PHP 7 中运行程序会产生一个弃用通知,然后是预期的文本。这里只显示了输出的前几行:
root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_bc_break_construct.php
PHP Deprecated:  Methods with the same name as their class will not be constructors in a future version of PHP; Text has a deprecated constructor in /repo/ch05/php8_bc_break_construct.php on line 4
Fourscore and seven years ago our fathers brought forth on this continent a new nation, conceived in liberty and dedicated to the proposition that all men are created equal. ... <remaining text not shown>
  1. 然而,在 PHP 8 中运行相同的程序会抛出一个致命的Error,如你从这个输出中看到的:
root@php8_tips_php8 [ /repo/ch05 ]# php php8_bc_break_construct.php 
PHP Fatal error:  Uncaught Error: Call to a member function fpassthru() on string in /repo/ch05/php8_bc_break_construct.php:16

重要的是要注意,在 PHP 8 中显示的错误并没有告诉你程序失败的真正原因。因此,非常重要的是要扫描你的 PHP 应用程序,特别是旧的应用程序,看看是否有一个与类同名的方法。因此,最佳实践就是简单地将与类同名的方法重命名为__construct()

现在让我们看看在 PHP 8 中如何解决类构造函数中处理Exceptionexit的不一致性。

解决类构造函数中的不一致性

PHP 8 中解决的另一个问题与类构造方法中抛出Exception或执行exit()有关。在 PHP 8 之前的版本中,如果在类构造函数中抛出Exception,则不会调用__destruct()方法(如果定义了)。另一方面,如果在构造函数中使用exit()die()(这两个 PHP 函数是等效的),则会调用__destruct()方法。在 PHP 8 中,这种不一致性得到了解决。现在,在任何情况下,__destruct()方法都不会被调用。

你可能想知道为什么这很重要。你需要意识到这个重要的改变的原因是,你可能有逻辑存在于__destruct()方法中,而这个方法在你可能调用exit()die()的情况下被调用。在 PHP 8 中,你不能再依赖这段代码,这可能导致向后兼容性的破坏。

在这个例子中,我们有两个连接类。ConnectPdo使用 PDO 扩展来提供查询结果,而ConnectMysqli使用 MySQLi 扩展:

  1. 我们首先定义一个接口,指定一个查询方法。这个方法需要一个 SQL 字符串作为参数,并且期望返回一个数组作为结果:
// /repo/src/Php7/Connector/ConnectInterface.php
namespace Php7\Connector;
interface ConnectInterface {
    public function query(string $sql) : array;
}
  1. 接下来,我们定义一个基类,其中定义了一个__destruct()魔术方法。因为这个类实现了ConnectInterface但没有定义query(),所以它被标记为abstract
// /repo/src/Php7/Connector/Base.php
namespace Php7\Connector;
abstract class Base implements ConnectInterface {
    const CONN_TERMINATED = 'Connection Terminated';
    public $conn = NULL;
    public function __destruct() {
        $message = get_class($this)
                 . ':' . self::CONN_TERMINATED;
        error_log($message);
    }
}
  1. 接下来,我们定义ConnectPdo类。它继承自Base,它的query()方法使用PDO语法来产生结果。__construct()方法如果创建连接时出现问题,则抛出PDOException
// /repo/src/Php7/Connector/ConnectPdo.php
namespace Php7\Connector;
use PDO;
class ConnectPdo extends Base {
    public function __construct(
        string $dsn, string $usr, string $pwd) {
        $this->conn = new PDO($dsn, $usr, $pwd);
    }
    public function query(string $sql) : array {
        $stmt = $this->conn->query($sql);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}
  1. 以类似的方式,我们定义了ConnectMysqli类。它继承自Base,它的query()方法使用MySQLi语法来产生结果。__construct()方法如果创建连接时出现问题,则执行die()
// /repo/src/Php7/Connector/ConnectMysqli.php
namespace Php7\Connector;
class ConnectMysqli extends Base {
    public function __construct(
        string $db, string $usr, string $pwd) {
        $this->conn = mysqli_connect('localhost', 
            $usr, $pwd, $db) 
            or die("Unable to Connect\n");
    }
    public function query(string $sql) : array {
        $result = mysqli_query($this->conn, $sql);
        return mysqli_fetch_all($result, MYSQLI_ASSOC);
    }
}
  1. 最后,我们定义一个调用程序,使用先前描述的两个连接类,并为连接字符串、用户名和密码定义无效值:
// /repo/ch05/php8_bc_break_destruct.php
include __DIR__ . '/../vendor/autoload.php';
use Php7\Connector\ {ConnectPdo,ConnectMysqli};
$db  = 'test';
$usr = 'fake';
$pwd = 'xyz';
$dsn = 'mysql:host=localhost;dbname=' . $db;
$sql = 'SELECT event_name, event_date FROM events';
  1. 接下来,在调用程序中,我们调用两个类,并尝试执行查询。连接故意失败,因为我们提供了错误的用户名和密码:
$ptn = "%2d : %s : %s\n";
try {
    $conn = new ConnectPdo($dsn, $usr, $pwd);
    var_dump($conn->query($sql));
} catch (Throwable $t) {
    printf($ptn, __LINE__, get_class($t), 
           $t->getMessage());
}
$conn = new ConnectMysqli($db, $usr, $pwd);
var_dump($conn->query($sql));
  1. 正如您从上面的讨论中所了解的,PHP 7 中运行的输出显示了在创建ConnectPdo实例时从类构造函数抛出PDOException。另一方面,当ConnectMysqli实例失败时,将调用die(),并显示消息无法连接。您还可以在输出的最后一行看到来自__destruct()方法的错误日志信息。以下是该输出:
root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_bc_break_destruct.php 
15 : PDOException : SQLSTATE[28000] [1045] Access denied for user 'fake'@'localhost' (using password: YES)
PHP Warning:  mysqli_connect(): (HY000/1045): Access denied for user 'fake'@'localhost' (using password: YES) in /repo/src/Php7/Connector/ConnectMysqli.php on line 8
Unable to Connect
Php7\Connector\ConnectMysqli:Connection Terminated
  1. 在 PHP 8 中,__destruct()方法在任何情况下都不会被调用,导致如下所示的输出。正如您在输出中所看到的,PDOException被捕获,然后发出die()命令。__destruct()方法没有任何输出。PHP 8 的输出如下:
root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_bc_break_destruct.php 
15 : PDOException : SQLSTATE[28000] [1045] Access denied for user 'fake'@'localhost' (using password: YES)
PHP Warning:  mysqli_connect(): (HY000/1045): Access denied for user 'fake'@'localhost' (using password: YES) in /repo/src/Php7/Connector/ConnectMysqli.php on line 8
Unable to Connect

现在您已经知道如何发现与__destruct()方法以及对die()exit()的调用有关的潜在代码中断,让我们将注意力转向__toString()方法的更改。

处理对 __toString()的更改

当对象被用作字符串时,将调用__toString()魔术方法。一个经典的例子是当您简单地 echo 一个对象时。echo命令期望一个字符串作为参数。当提供非字符串数据时,PHP 执行类型转换将数据转换为string。由于对象不能直接转换为string,因此 PHP 引擎会查看是否定义了__toString(),如果定义了,则返回其值。

这个魔术方法的主要变化是引入了Stringable,一个全新的接口。新接口定义如下:

interface Stringable {
   public function __toString(): string;
}

在 PHP 8 中运行的任何类,如果定义了__toString()魔术方法,都会静默实现Stringable接口。这种新行为并不会导致严重的潜在代码中断。然而,由于类现在实现了Stringable接口,您将不再允许修改__toString()方法的签名。

以下是一个简短的示例,揭示了与Stringable接口的新关联:

  1. 在这个例子中,我们定义了一个定义了__toString()Test类:
// /repo/ch05/php8_bc_break_magic_to_string.php
class Test {
    public $fname = 'Fred';
    public $lname = 'Flintstone';
    public function __toString() : string {
        return $this->fname . ' ' . $this->lname;
    }
}
  1. 然后我们创建类的一个实例,然后是一个ReflectionObject实例:
$test = new Test;
$reflect = new ReflectionObject($test);
echo $reflect;

在 PHP 7 中运行的输出的前几行(如下所示)只是显示它是Test类的一个实例:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_bc_break_magic_to_string.php
Object of class [ <user> class Test ] {
  @@ /repo/ch05/php8_bc_break_magic_to_string.php 3-12

然而,在 PHP 8 中运行相同的代码示例,揭示了与Stringable接口的静默关联:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_bc_break_magic_to_string.php
Object of class [ <user> class Test implements Stringable ] {
  @@ /repo/ch05/php8_bc_break_magic_to_string.php 3-12

输出显示,即使您没有显式实现Stringable接口,也会在运行时创建关联,并由ReflectionObject实例显示。

提示

有关魔术方法的更多信息,请参阅此文档页面:www.php.net/manual/en/language.oop5.magic.php

现在您已经了解了 PHP 8 代码涉及魔术方法可能导致代码中断的情况,让我们来看看序列化过程中的更改。

控制序列化

有许多时候,需要将本机 PHP 数据存储在文件中,或者存储在数据库表中。当前技术的问题在于,直接存储复杂的 PHP 数据,如对象或数组,是不可能的,除了一些例外。

克服这种限制的一种方法是将对象或数组转换为字符串。JSON(JavaScript 对象表示)通常因此而被选择。一旦数据被转换为字符串,它就可以轻松地存储在任何文件或数据库中。然而,使用 JSON 格式化对象存在问题。尽管 JSON 能够很好地表示对象属性,但它无法直接恢复原始对象的类和方法。

为了解决这个缺陷,PHP 语言包括两个原生函数serialize()unserialize(),可以轻松地将对象或数组转换为字符串,并将它们恢复到原始状态。尽管听起来很棒,但与原生 PHP 序列化相关的问题有很多。

在我们能够正确讨论现有 PHP 序列化架构的问题之前,我们需要更仔细地了解原生 PHP 序列化的工作方式。

了解 PHP 序列化

当 PHP 对象或数组需要保存到非面向对象编程环境(如平面文件或关系数据库表)时,可以使用serialize()将对象或数组“扁平化”为适合存储的字符串。相反,unserialize()会恢复原始对象或数组。

以下是演示这个概念的一个简单示例:

  1. 首先,我们定义一个具有三个属性的类:
// /repo/ch05/php8_serialization.php
class Test  {
    public $name = 'Doug';
    private $key = 12345;
    protected $status = ['A','B','C'];
}
  1. 然后我们创建一个实例,对该实例进行序列化,并显示生成的字符串:
$test = new Test();
$str = serialize($test);
echo $str . "\n";
  1. 以下是序列化对象的样子:
O:4:"Test":3:{s:4:"name";s:4:"Doug";s:9:"Testkey"; i:12345;
s:9:"*status";a:3:{i:0;s:1:"A";i:1;s:1:"B";i:2;s:1:"C";}}

从序列化字符串中可以看出,字母O代表对象a代表数组s代表字符串i代表整数

  1. 然后我们将对象反序列化为一个新变量,并使用var_dump()来检查这两个变量:
$obj = unserialize($str);
var_dump($test, $obj);
  1. var_dump()的输出并排放置,您可以清楚地看到恢复的对象与原始对象是相同的:

现在让我们来看一下支持旧版 PHP 序列化的魔术方法:__sleep()__wakeup()

了解__sleep()魔术方法

__sleep()魔术方法的目的是提供一个过滤器,用于防止某些属性出现在序列化字符串中。以用户对象为例,您可能希望排除敏感属性,如国民身份证号码、信用卡号码或密码。

以下是使用__sleep()魔术方法来排除密码的示例:

  1. 首先,我们定义一个具有三个属性的Test类:
// /repo/ch05/php8_serialization_sleep.php
class Test  {
    public $name = 'Doug';
    protected $key = 12345;
    protected $password = '$2y$10$ux07vQNSA0ctbzZcZNA'
         . 'lxOa8hi6kchJrJZzqWcxpw/XQUjSNqacx.';
  1. 然后我们定义一个__sleep()方法来排除$password属性:
    public function __sleep() {
        return ['name','key'];
    }
}
  1. 然后我们创建这个类的一个实例并对其进行序列化。最后一行输出序列化字符串的状态:
$test = new Test();
$str = serialize($test)
echo $str . "\n";
  1. 在输出中,您可以清楚地看到$password属性不存在。以下是输出:
O:4:"Test":2:{s:4:"name";s:4:"Doug";s:6:"*key";i:12345;}

这一点很重要,因为在大多数情况下,您需要对对象进行序列化的原因是希望将其存储在某个地方,无论是在会话文件中还是在数据库中。如果文件系统或数据库随后受到损害,您就少了一个安全漏洞需要担心!

了解__sleep()方法中潜在的代码中断

__sleep()魔术方法涉及潜在的代码中断。在 PHP 8 之前的版本中,如果__sleep()返回一个包含不存在属性的数组,它们仍然会被序列化并赋予一个NULL值。这种方法的问题在于,当对象随后被反序列化时,会出现一个额外的属性,这不是设计时存在的属性!

在 PHP 8 中,__sleep()魔术方法中不存在的属性会被静默忽略。如果您的旧代码预期旧的行为并采取步骤删除不需要的属性,或者更糟糕的是,如果您的代码假设不需要的属性存在,最终会出现错误。这样的假设非常危险,因为它们可能导致意外的代码行为。

为了说明问题,让我们看一下以下代码示例:

  1. 首先,我们定义一个Test类,该类定义了__sleep()来返回一个不存在的变量:
class Test {
    public $name = 'Doug';
    public function __sleep() {
        return ['name', 'missing'];
    }
}
  1. 接下来,我们创建一个Test的实例并对其进行序列化:
echo "Test instance before serialization:\n";
$test = new Test();
var_dump($test);
  1. 然后我们将字符串反序列化为一个新实例$restored
echo "Test instance after serialization:\n";
$stored = serialize($test);
$restored = unserialize($stored);
var_dump($restored);
  1. 理论上,两个对象实例$test$restored应该是相同的。然而,看一下在 PHP 7 中运行的输出:
root@php8_tips_php7 [ /repo/ch05 ]#
php php8_bc_break_sleep.php
Test instance before serialization:
/repo/ch05/php8_bc_break_sleep.php:13:
class Test#1 (1) {
  public $name =>  string(4) "Doug"
}
Test instance after serialization:
PHP Notice:  serialize(): "missing" returned as member variable from __sleep() but does not exist in /repo/ch05/php8_bc_break_sleep.php on line 16
class Test#2 (2) {
  public $name =>  string(4) "Doug"
  public $missing =>  NULL
}
  1. 从输出中可以看出,这两个对象显然相同!然而,在 PHP 8 中,不存在的属性被忽略。看一下在 PHP 8 中运行相同脚本的情况:
root@php8_tips_php8 [ /repo/ch05 ]# php php8_bc_break_sleep.php 
Test instance before serialization:
object(Test)#1 (1) {
  ["name"]=>  string(4) "Doug"
}
Test instance after serialization:
PHP Warning:  serialize(): "missing" returned as member variable from __sleep() but does not exist in /repo/ch05/php8_bc_break_sleep.php on line 16
object(Test)#2 (1) {
  ["name"]=>  string(4) "Doug"
}

您可能还注意到,在 PHP 7 中,会发出一个Notice,而在 PHP 8 中,相同的情况会产生一个Warning。在这种情况下,对潜在代码中断的预迁移检查是困难的,因为您需要确定魔术方法__sleep()是否被定义,以及是否在列表中包含了一个不存在的属性。

现在让我们来看看对应的方法__wakeup()

学习 __wakeup()

__wakeup()魔术方法的目的主要是在反序列化对象时执行额外的初始化。例如,恢复数据库连接或恢复文件句柄。下面是一个使用__wakeup()魔术方法重新打开文件句柄的非常简单的例子:

  1. 首先,我们定义一个在实例化时打开文件句柄的类。我们还定义一个返回文件内容的方法:
// /repo/ch05/php8_serialization_wakeup.php
class Gettysburg {
    public $fn = __DIR__ . '/gettysburg.txt';
    public $obj = NULL;
    public function __construct() {
        $this->obj = new SplFileObject($this->fn, 'r');
    }
    public function getText() {
        $this->obj->rewind();
        return $this->obj->fpassthru();
    }
}
  1. 要使用这个类,创建一个实例,并运行getText()。(这假设$this->fn引用的文件存在!)
$old = new Gettysburg();
echo $old->getText();
  1. 输出(未显示)是葛底斯堡演说。

  2. 如果我们现在尝试对这个对象进行序列化,就会出现问题。下面是一个可能序列化对象的代码示例:

$str = serialize($old);

  1. 到目前为止,在原地运行代码,这是输出:
PHP Fatal error:  Uncaught Exception: Serialization of 'SplFileObject' is not allowed in /repo/ch05/php8_serialization_wakeup.php:19
  1. 为了解决这个问题,我们返回到类中,添加一个__sleep()方法,防止SplFileObject实例被序列化:
    public function __sleep() {
        return ['fn'];
    }
  1. 如果我们重新运行代码来序列化对象,一切都很好。这是反序列化和调用getText()的代码:
$str = serialize($old);
$new = unserialize($str);
echo $new->getText();
  1. 然而,如果我们尝试对对象进行反序列化,就会出现另一个错误:
PHP Fatal error:  Uncaught Error: Call to a member function rewind() on null in /repo/ch05/php8_serialization_wakeup.php:13

问题当然是,在序列化过程中文件句柄丢失了。当对象被反序列化时,__construct()方法没有被调用。

  1. 这正是__wakeup()魔术方法存在的原因。为了解决错误,我们定义一个__wakeup()方法,调用__construct()方法:
    public function __wakeup() {
        self::__construct();
    }
  1. 如果我们重新运行代码,现在我们会看到葛底斯堡演说出现两次(未显示)。

现在您已经了解了 PHP 原生序列化的工作原理,也了解了__sleep()__wakeup()魔术方法,以及潜在的代码中断。现在让我们来看一下一个旨在促进对象自定义序列化的接口。

介绍 Serializable 接口

为了促进对象的序列化,从 PHP 5.1 开始,语言中添加了Serializable接口。这个接口的想法是提供一种识别具有自我序列化能力的对象的方法。此外,该接口指定的方法旨在在对象序列化过程中提供一定程度的控制。

只要一个类实现了这个接口,开发人员就可以确保两个方法被定义:serialize()unserialize()。这是接口定义:

interface Serializable {
    public serialize () : string|null
    public unserialize (string $serialized) : void
}

任何实现了这个接口的类,在本地序列化或反序列化过程中,其自定义serialize()unserialize()方法会自动调用。为了说明这种技术,考虑以下示例:

  1. 首先,我们定义一个实现Serializable接口的类。该类定义了三个属性 - 两个是字符串类型,另一个表示日期和时间:
// /repo/ch05/php8_bc_break_serializable.php
class A implements Serializable {
    private $a = 'A';
    private $b = 'B';
    private $u = NULL;
  1. 然后我们定义一个自定义的serialize()方法,在序列化对象的属性之前初始化日期和时间。unserialize()方法将值恢复到所有属性中:
    public function serialize() {
        $this->u = new DateTime();
        return serialize(get_object_vars($this));
    }
    public function unserialize($payload) {
        $vars = unserialize($payload);
        foreach ($vars as $key => $val)
            $this->$key = $val;
    }
}
  1. 然后我们创建一个实例,并使用var_dump()检查其内容:
$a1 = new A();
var_dump($a1);
  1. var_dump()的输出显示u属性尚未初始化:
object(A)#1 (3) {
  ["a":"A":private]=> string(1) "A"
  ["b":"A":private]=> string(1) "B"
  ["u":"A":private]=> NULL
}
  1. 然后我们对其进行序列化,并将其恢复到一个变量$a2中:
$str = serialize($a1);
$a2 = unserialize($str);
var_dump($a2);
  1. 从下面的var_dump()输出中,您可以看到对象已经完全恢复。此外,我们知道自定义的serialize()方法被调用,因为u属性被初始化为日期和时间值。以下是输出:
object(A)#3 (3) {
  ["a":"A":private]=> string(1) "A"
  ["b":"A":private]=> string(1) "B"
  ["u":"A":private]=> object(DateTime)#4 (3) {
    ["date"]=> string(26) "2021-02-12 05:35:10.835999"
    ["timezone_type"]=> int(3)
    ["timezone"]=> string(3) "UTC"
  }
}

现在让我们来看一下实现Serializable接口的对象在序列化过程中可能出现的问题。

检查 PHP 可序列化接口问题

早期序列化方法存在一个整体问题。如果要序列化的类定义了一个__wakeup()魔术方法,它不会立即在反序列化时被调用。相反,任何定义的__wakeup()魔术方法首先被排队,整个对象链被反序列化,然后才执行队列中的方法。这可能导致对象的unserialize()方法看到的与其排队的__wakeup()方法看到的不一致。

这种架构缺陷可能导致处理实现Serializable接口的对象时出现不一致的行为和模棱两可的结果。许多开发人员认为Serializable接口由于在嵌套对象序列化时需要创建反向引用而严重破损。这种需要出现在嵌套序列化调用的情况下。

例如,当一个类定义了一个方法,该方法反过来调用 PHP 的serialize()函数时,可能会发生这样的嵌套调用。在 PHP 8 之前,PHP 序列化中预设了创建反向引用的顺序,可能导致一系列级联的失败。

解决方案是使用两个新的魔术方法来完全控制序列化和反序列化的顺序,接下来将进行描述。

控制 PHP 序列化的新魔术方法

控制序列化的新方法首先在 PHP 7.4 中引入,并在 PHP 8 中继续使用。为了利用这项新技术,您只需要实现两个魔术方法:__serialize()__unserialize()。如果实现了,PHP 将完全将序列化的控制权交给__serialize()方法。同样,反序列化完全由__unserialize()魔术方法控制。如果定义了__sleep()__wakeup()方法,则会被忽略。

作为一个进一步的好处,PHP 8 完全支持以下 SPL 类中的两个新的魔术方法:

  • ArrayObject

  • ArrayIterator

  • SplDoublyLinkedList

  • SplObjectStorage

最佳实践

为了完全控制序列化,实现新的__serialize()__unserialize()魔术方法。您不再需要实现Serializable接口,也不需要定义__sleep()__wakeup()。有关Serializable接口最终停用的更多信息,请参阅此 RFC:wiki.php.net/rfc/phase_out_serializable

作为新的 PHP 序列化用法的示例,请考虑以下代码示例:

  1. 在示例中,一个Test类在实例化时使用一个随机密钥进行初始化:
// /repo/ch05/php8_bc_break_serialization.php
class Test extends ArrayObject {
    protected $id = 12345;
    public $name = 'Doug';
    private $key = '';
    public function __construct() {
        $this->key = bin2hex(random_bytes(8));
    }
  1. 我们添加一个getKey()方法来显示当前的关键值:
    public function getKey() {
        return $this->key;
    }
  1. 在序列化时,关键点被过滤出结果字符串:
    public function __serialize() {
        return ['id' => $this->id, 
                'name' => $this->name];
    }
  1. 在反序列化时,生成一个新的关键点:
    public function __unserialize($data) {
        $this->id = $data['id'];
        $this->name = $data['name'];
        $this->__construct();
    }
}
  1. 现在我们创建一个实例,并揭示关键点:
$test = new Test();
echo "\nOld Key: " . $test->getKey() . "\n";

关键点可能会出现如下:

Old Key: mXq78DhplByDWuPtzk820g==
  1. 我们添加代码来序列化对象并显示字符串:
$str = serialize($test);
echo $str . "\n";

这是序列化字符串可能的样子:

O:4:"Test":2:{s:2:"id";i:12345;s:4:"name";s:4:"Doug";}

从输出中可以看到,秘密不会出现在序列化的字符串中。这很重要,因为如果序列化字符串的存储位置受到损害,可能会暴露安全漏洞,使攻击者有可能侵入您的系统。

  1. 然后我们添加代码来反序列化字符串并显示关键点:
$obj = unserialize($str);
echo "New Key: " . $obj->getKey() . "\n";

这是最后一部分输出。请注意,生成了一个新的关键点:

New Key: kDgU7FGfJn5qlOKcHEbyqQ==

正如您所看到的,使用新的 PHP 序列化功能并不复杂。任何时间问题现在完全在您的控制之下,因为新的魔术方法是按照对象序列化和反序列化的顺序执行的。

重要说明

PHP 7.4 及以上版本能够理解来自旧版本 PHP 的序列化字符串,但是由 PHP 7.4 或 8.x 序列化的字符串可能无法被旧版本的 PHP 正确反序列化。

提示

有关完整讨论,请参阅有关自定义序列化的 RFC:

https://wiki.php.net/rfc/custom_object_serialization

您现在已经完全了解了 PHP 序列化和两种新的魔术方法提供的改进支持。现在是时候转变思路,看看 PHP 8 如何扩展方差支持了。

理解 PHP 8 扩展的方差支持

方差的概念是面向对象编程的核心。方差是一个涵盖各种子类型相互关系的总称。大约 20 年前,早期计算机科学家 Wing 和 Liskov 提出了一个重要的定理,它是面向对象编程子类型的核心,现在被称为Liskov 替换原则

不需要进入精确的数学,这个原则可以被解释为:

如果您能够在类 Y 的实例的位置替换 X 的实例,并且应用程序的行为没有任何改变,那么类 X 可以被认为是类 Y 的子类型。

提示

首次描述并提供了 Liskov 替换原则的精确数学公式定义的实际论文可以在这里找到:子类型的行为概念,ACM 编程语言和系统交易,由 B. Liskov 和 J. Wing,1994 年 11 月(https://dl.acm.org/doi/10.1145/197320.197383)。

在本节中,我们将探讨 PHP 8 如何以协变返回逆变参数的形式提供增强的方差支持。对协变和逆变的理解将增强您编写良好稳固代码的能力。如果没有这种理解,您的代码可能会产生不一致的结果,并成为许多错误的根源。

让我们首先讨论协变返回。

理解协变返回

PHP 中的协变支持旨在保留从最具体到最一般的类型的顺序。这在try / catch块的构造中经典地体现出来:

  1. 在这个例子中,PDO实例是在try块内创建的。接下来的两个catch块首先寻找PDOException。接着是一个第二个catch块,它捕获任何实现Throwable的类。因为 PHP 的ExceptionError类都实现了Throwable,所以第二个catch块最终成为除了PDOException之外的任何错误的后备:
try {
    $pdo = new PDO($dsn, $usr, $pwd, $opts);
} catch (PDOException $p) {
    error_log('Database Error: ' . $p->getMessage());
} catch (Throwable $t) {
    error_log('Unknown Error: ' . $t->getMessage());
}
  1. 在这个例子中,如果PDO实例由于无效参数而失败,错误日志将包含条目数据库错误,后面跟着从PDOException中获取的消息。

  2. 另一方面,如果发生了其他一般错误,错误日志将包含条目未知错误,后面跟着来自其他ExceptionError类的消息。

  3. 然而,在这个例子中,catch块的顺序是颠倒的:

try {
    $pdo = new PDO($dsn, $usr, $pwd, $opts);
} catch (Throwable $t) {
    error_log('Unknown Error: ' . $t->getMessage());
} catch (PDOException $p) {
    error_log('Database Error: ' . $p->getMessage());
}
  1. 由于 PHP 协变支持的工作方式,第二个catch块永远不会被调用。相反,所有源自此代码块的错误日志条目将以未知错误开头。

现在让我们看看 PHP 协变支持如何适用于对象方法返回数据类型:

  1. 首先,我们定义一个接口FactoryIterface,它标识一个make()方法。此方法接受一个array作为参数,并且预期返回一个ArrayObject类型的对象:
interface FactoryInterface {
    public function make(array $arr): ArrayObject;
}
  1. 接下来,我们定义一个ArrTest类,它扩展了ArrayObject
class ArrTest extends ArrayObject {
    const DEFAULT_TEST = 'This is a test';
}
  1. ArrFactory类实现了FactoryInterface并完全定义了make()方法。但是,请注意,此方法返回ArrTest数据类型而不是ArrayObject
class ArrFactory implements FactoryInterface {
    protected array $data;
    public function make(array $data): ArrTest {
        $this->data = $data;
        return new ArrTest($this->data);
    }
}
  1. 在程序调用代码块中,我们创建了一个ArrFactory的实例,并两次运行其make()方法,理论上产生了两个ArrTest实例。然后我们使用var_dump()来显示所产生的两个对象的当前状态:
$factory = new ArrFactory();
$obj1 = $factory->make([1,2,3]);
$obj2 = $factory->make(['A','B','C']);
var_dump($obj1, $obj2);
  1. 在 PHP 7.1 中,由于它不支持协变返回数据类型,会抛出致命的Error。下面显示的输出告诉我们,方法返回类型声明与FactoryInterface中定义的不匹配:
root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_variance_covariant.php
PHP Fatal error:  Declaration of ArrFactory::make(array $data): ArrTest must be compatible with FactoryInterface::make(array $arr): ArrayObject in /repo/ch05/php8_variance_covariant.php on line 9
  1. 当我们在 PHP 8 中运行相同的代码时,您会看到对返回类型提供了协变支持。执行继续进行,如下所示:
root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_variance_covariant.php
object(ArrTest)#2 (1) {
  ["storage":"ArrayObject":private]=>
  array(3) {
    [0]=>    int(1)
    [1]=>    int(2)
    [2]=>    int(3)
  }
}
object(ArrTest)#3 (1) {
  ["storage":"ArrayObject":private]=>
  array(3) {
    [0]=>    string(1) "A"
    [1]=>    string(1) "B"
    [2]=>    string(1) "C"
  }
}

ArrTest扩展了ArrayObject,是一个明显符合里氏替换原则定义的条件的子类型。正如您从最后的输出中看到的那样,PHP 8 比之前的 PHP 版本更全面地接受了真正的面向对象编程原则。最终结果是,在使用 PHP 8 时,您的代码和应用架构可以更直观和逻辑合理。

现在让我们来看看逆变参数。

使用逆变参数

而协变关注的是从一般到特定的子类型的顺序,逆变关注的是相反的顺序:从特定到一般。在 PHP 7 及更早版本中,对逆变的完全支持是不可用的。因此,在 PHP 7 中,实现接口或扩展抽象类时,参数类型提示是不变的。

另一方面,在 PHP 8 中,由于对逆变参数的支持,您可以在顶级超类和接口中自由地具体化。只要子类型是兼容的,您就可以修改扩展或实现类中的类型提示为更一般的类型。

这使您在定义整体架构时更自由地定义接口或抽象类。在使用您的接口或超类的开发人员在实现后代类逻辑时,PHP 8 在实现时提供了更多的灵活性。

让我们来看看 PHP 8 对逆变参数的支持是如何工作的:

  1. 在这个例子中,我们首先定义了一个IterObj类,它扩展了内置的ArrayIterator PHP 类
// /repo/ch05/php8_variance_contravariant.php
class IterObj extends ArrayIterator {}
  1. 然后我们定义一个抽象的Base类,规定了一个stringify()方法。请注意,它唯一参数的数据类型是IterObj
abstract class Base {
    public abstract function stringify(IterObj $it);
}
  1. 接下来,我们定义一个IterTest类,它扩展了Base并为stringify()方法提供了实现。特别值得注意的是,我们覆盖了数据类型,将其更改为iterable
class IterTest extends Base {
    public function stringify(iterable $it) {
        return implode(',', 
            iterator_to_array($it)) . "\n";
    }
}
class IterObj extends ArrayIterator {}
  1. 接下来的几行代码创建了IterTestIterObjArrayIterator的实例。然后我们调用stringify()方法两次,分别将后两个对象作为参数提供:
$test  = new IterTest();
$objIt = new IterObj([1,2,3]);
$arrIt = new ArrayIterator(['A','B','C']);
echo $test->stringify($objIt);
echo $test->stringify($arrIt);
  1. 在 PHP 7.1 中运行此代码示例会产生预期的致命Error,如下所示:
root@php8_tips_php7 [ /repo/ch05 ]#
php php8_variance_contravariant.php
PHP Fatal error:  Declaration of IterTest::stringify(iterable $it) must be compatible with Base::stringify(IterObj $it) in /repo/ch05/php8_variance_contravariant.php on line 11

因为 PHP 7.1 不支持逆变参数,它将其参数的数据类型视为不变,并简单地显示一条消息,指示子类的数据类型与父类中指定的数据类型不兼容。

  1. 另一方面,PHP 8 提供了对逆变参数的支持。因此,它认识到IterObj,在Base类中指定的数据类型,是与iterable兼容的子类型。此外,提供的两个参数也与iterable兼容,允许程序执行继续进行。以下是 PHP 8 的输出:
root@php8_tips_php8 [ /repo/ch05 ]# php php8_variance_contravariant.php
1,2,3
A,B,C

PHP 8 对协变返回和逆变参数的支持带来的主要优势是能够覆盖方法逻辑以及方法签名。您会发现,尽管 PHP 8 在执行良好的编码实践方面要严格得多,但增强的变异支持使您在设计继承结构时拥有更大的自由度。在某种意义上,至少在参数和返回值数据类型方面,PHP 8 是不受限制的!

提示

要了解 PHP 7.4 和 PHP 8 中如何应用方差支持的完整解释,请查看这里:https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters。

现在我们将看一下 SPL 的更改以及这些更改如何影响迁移到 PHP 8 后的应用程序性能。

处理标准 PHP 库(SPL)更改

SPL是一个包含实现基本数据结构和增强面向对象功能的关键类的扩展。它首次出现在 PHP 5 中,现在默认包含在所有 PHP 安装中。涵盖整个 SPL 超出了本书的范围。相反,在本节中,我们讨论了在运行 PHP 8 时 SPL 发生了重大变化的地方。此外,我们还为您提供了有可能导致现有应用程序停止工作的 SPL 更改的提示和指导。

我们首先检查SplFileObject类的更改。

了解 SplFileObject 的更改

SplFileObject是一个很好的类,它将大部分独立的f*()函数(如fgets()fread()fwrite()等)合并到一个类中。SplFileObject ::__construct()方法的参数与fopen()函数提供的参数相同。

PHP 8 中的主要区别是,一个相对不常用的方法fgetss()已从SplFileObject类中删除。SplFileObject::fgetss()方法在 PHP 7 及以下版本中可用,它将fgets()strip_tags()结合在一起。

为了举例说明,假设您已经创建了一个网站,允许用户上传文本文件。在显示文本文件内容之前,您希望删除任何标记。以下是使用fgetss()方法实现此目的的示例:

  1. 我们首先定义一个获取文件名的代码块:
// /repo/ch05/php7_spl_splfileobject.php
$fn = $_GET['fn'] ?? '';
if (!$fn || !file_exists($fn))
    exit('Unable to locate file');
  1. 然后我们创建SplFileObject实例,并使用fgetss()方法逐行读取文件。最后,我们输出安全内容:
$obj = new SplFileObject($fn, 'r');
$safe = '';
while ($line = $obj->fgetss()) $safe .= $line;
echo '<h1>Contents</h1><hr>' . $safe;
  1. 假设要读取的文件是这个:
<h1>This File is Infected</h1>
<script>alert('You Been Hacked');</script>
<img src="http://very.bad.site/hacked.php" />
  1. 以下是在 PHP 7.1 中使用此 URL 运行的输出:

http://localhost:7777/ch05/php7_spl_splfileobject.php? fn=includes/you_been_hacked.html

从接下来显示的输出中可以看出,所有 HTML 标记都已被删除:

图 5.1 - 使用 SplFileObject::fgetss()读取文件后的结果

图 5.1 - 使用 SplFileObject::fgetss()读取文件后的结果

在 PHP 8 中实现相同的功能,之前显示的代码需要通过用fgets()替换fgetss()来进行修改。我们还需要在连接到$safe的行上使用strip_tags()。修改后的代码可能如下所示:

// /repo/ch05/php8_spl_splfileobject.php
$fn = $_GET['fn'] ?? '';
if (!$fn || !file_exists($fn))
    exit('Unable to locate file');
$obj = new SplFileObject($fn, 'r');
$safe = '';
while ($line = $obj->fgets())
    $safe .= strip_tags($line);
echo '<h1>Contents</h1><hr>' . $safe;

修改后的代码的输出与图 5.1中显示的相同。现在我们将注意力转向另一个 SPL 类的更改:SplHeap

检查 SplHeap 的更改

SplHeap是一个基础类,用于表示二叉树结构的数据。另外还有两个类建立在SplHeap基础上。SplMinHeap将树组织为顶部是最小值。SplMaxHeap则相反,将最大值放在顶部。

堆结构在数据无序到达的情况下特别有用。一旦插入堆中,项目会自动按正确的顺序放置。因此,在任何给定时刻,您可以显示堆,确保所有项目都按顺序排列,而无需运行 PHP 排序函数之一。

保持自动排序顺序的关键是定义一个抽象方法compare()。由于这个方法是抽象的,SplHeap不能直接实例化。相反,您需要扩展该类并实现compare()

在 PHP 8 中,使用SplHeap可能会导致向后兼容的代码中断,因为compare()的方法签名必须完全如下:SplHeap::compare($value1, $value2)

让我们现在来看一个使用SplHeap构建按姓氏组织的亿万富翁列表的代码示例:

  1. 首先,我们定义一个包含亿万富翁数据的文件。在这个例子中,我们只是从这个来源复制并粘贴了数据:https://www.bloomberg.com/billionaires/。

  2. 然后,我们定义一个BillionaireTracker类,从粘贴的文本中提取信息到有序对的数组中。该类的完整源代码(未在此处显示)可以在源代码存储库中找到:/repo/src/Services/BillionaireTracker.php

这是该类生成的数据的样子:

array(20) {
  [0] =>  array(1) {
    [177000000000] =>    string(10) "Bezos,Jeff"
  }
  [1] =>  array(1) {
    [157000000000] =>    string(9) "Musk,Elon"
  }
  [2] =>  array(1) {
    [136000000000] =>    string(10) "Gates,Bill"
  }
  ... remaining data not shown

正如你所看到的,数据以降序呈现,其中键表示净值。相比之下,在我们的示例程序中,我们计划按姓氏的升序产生数据。

  1. 然后,我们定义一个常量,用于标识亿万富翁数据源文件,并设置一个自动加载程序:
// /repo/ch05/php7_spl_splheap.php
define('SRC_FILE', __DIR__ 
    . '/../sample_data/billionaires.txt');
require_once __DIR__ 
    . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
  1. 接下来,我们创建一个BillionaireTracker类的实例,并将结果赋给$list
use Services\BillionaireTracker;
$tracker = new BillionaireTracker();
$list = $tracker->extract(SRC_FILE);
  1. 现在来看最感兴趣的部分:创建堆。为了实现这一点,我们定义了一个扩展SplHeap的匿名类。然后,我们定义了一个compare()方法,执行必要的逻辑将插入的元素放在适当的位置。PHP 7 允许你改变方法签名。在这个例子中,我们以数组的形式提供参数:
$heap = new class () extends SplHeap {
    public function compare(
        array $arr1, array $arr2) : int {
        $cmp1 = array_values($arr2)[0];
        $cmp2 = array_values($arr1)[0];
        return $cmp1 <=> $cmp2;
    }
};

你可能还注意到$cmp1的值是从第二个数组中赋值的,而$cmp2的值是从第一个数组中赋值的。这种切换的原因是因为我们希望按升序产生结果。

  1. 然后,我们使用SplHeap::insert()将元素添加到堆中:
foreach ($list as $item)
    $heap->insert($item);
  1. 最后,我们定义了一个BillionaireTracker::view()方法(未显示)来遍历堆并显示结果:
$patt = "%20s\t%32s\n";
$line = str_repeat('-', 56) . "\n";
echo $tracker->view($heap, $patt, $line);
  1. 以下是我们在 PHP 7.1 中运行的小程序产生的输出:
root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_spl_splheap.php
--------------------------------------------------------
           Net Worth                                Name
--------------------------------------------------------
      84,000,000,000                       Ambani,Mukesh
     115,000,000,000                     Arnault,Bernard
      83,600,000,000                       Ballmer,Steve
      ... some lines were omitted to save space ...
      58,200,000,000                          Walton,Rob
     100,000,000,000                     Zuckerberg,Mark
--------------------------------------------------------
                                       1,795,100,000,000
--------------------------------------------------------

然而,当我们尝试在 PHP 8 中运行相同的程序时,会抛出错误。以下是在 PHP 8 中运行相同程序的输出:

root@php8_tips_php8 [ /repo/ch05 ]# php php7_spl_splheap.php 
PHP Fatal error:  Declaration of SplHeap@anonymous::compare(array $arr1, array $arr2): int must be compatible with SplHeap::compare(mixed $value1, mixed $value2) in /repo/ch05/php7_spl_splheap.php on line 16

因此,为了使其正常工作,我们必须重新定义扩展SplHeap的匿名类。以下是代码的部分修改版本:

$heap = new class () extends SplHeap {
    public function compare($arr1, $arr2) : int {
        $cmp1 = array_values($arr2)[0];
        $cmp2 = array_values($arr1)[0];
        return $cmp1 <=> $cmp2;
    }
};

唯一的变化在于compare()方法的签名。执行时,结果(未显示)是相同的。PHP 8 的完整代码可以在/repo/ch05/php8_spl_splheap.php中查看。

这结束了我们对SplHeap类的更改的讨论。请注意,相同的更改也适用于SplMinHeapSplMaxHeap。现在让我们来看看SplDoublyLinkedList类中可能有重大变化的地方。

处理SplDoublyLinkedList中的更改

SplDoublyLinkedList类是一个迭代器,能够以FIFO(先进先出)或LIFO(后进先出)的顺序显示信息。然而,更常见的是说你可以以正向或反向顺序遍历列表。

这是任何开发者库中非常强大的一个补充。要使用ArrayIterator做同样的事情,例如,至少需要十几行代码!因此,PHP 开发者喜欢在需要随意在列表中导航的情况下使用这个类。

不幸的是,由于push()unshift()方法的返回值不同,可能会出现潜在的代码中断。push()方法用于在列表的末尾添加值。另一方面,unshift()方法则在列表的开头添加值。

在 PHP 7 及以下版本中,如果成功,这些方法返回布尔值TRUE。如果方法失败,它返回布尔值FALSE。然而,在 PHP 8 中,这两种方法都不返回任何值。如果你查看当前文档中的方法签名,你会看到返回数据类型为void。可能会出现代码中断的情况,即在继续之前检查返回push()unshift()的值。

让我们看一个简单的例子,用一个简单的五个值的列表填充一个双向链表,并以 FIFO 和 LIFO 顺序显示它们:

  1. 首先,我们定义一个匿名类,它继承了SplDoublyLinkedList。我们还添加了一个show()方法来显示列表的内容:
// /repo/ch05/php7_spl_spldoublylinkedlist.php
$double = new class() extends SplDoublyLinkedList {
    public function show(int $mode) {
        $this->setIteratorMode($mode);
        $this->rewind();
        while ($item = $this->current()) {
            echo $item . '. ';
            $this->next();
        }
    }
};
  1. 接下来,我们定义一个样本数据的数组,并使用push()将值插入到链表中。请注意,我们使用if()语句来确定操作是否成功或失败。如果操作失败,将抛出一个Exception
$item = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
foreach ($item as $key => $value)
    if (!$double->push($value))
        throw new Exception('ERROR');

这是潜在代码中断存在的代码块。在 PHP 7 及更低版本中,push()返回TRUEFALSE。在 PHP 8 中,没有返回值。

  1. 然后我们使用SplDoublyLinkedList类的常量将模式设置为 FIFO(正向),并显示列表:
echo "**************** Foward ********************\n";
$forward = SplDoublyLinkedList::IT_MODE_FIFO
         | SplDoublyLinkedList::IT_MODE_KEEP;
$double->show($forward);
  1. 接下来,我们使用SplDoublyLinkedList类的常量将模式设置为 LIFO(反向),并显示列表:
echo "\n\n************* Reverse *****************\n";
$reverse = SplDoublyLinkedList::IT_MODE_LIFO
         | SplDoublyLinkedList::IT_MODE_KEEP;
$double->show($reverse);

这是在 PHP 7.1 中运行的输出:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_spl_spldoublylinkedlist.php
**************** Foward ********************
Person. Woman. Man. Camera. TV. 
**************** Reverse ********************
TV. Camera. Man. Woman. Person. 
  1. 如果我们在 PHP 8 中运行相同的代码,这是结果:
root@php8_tips_php8 [ /home/ch05 ]# 
php php7_spl_spldoublylinkedlist.php 
PHP Fatal error:  Uncaught Exception: ERROR in /home/ch05/php7_spl_spldoublylinkedlist.php:23

如果push()没有返回任何值,在if()语句中 PHP 会假定为NULL,然后被插入为布尔值FALSE!因此,在第一个push()命令之后,if()块会导致抛出一个Exception。因为Exception没有被捕获,会生成一个致命的Error

要将这个代码块重写为在 PHP 8 中工作,您只需要删除if()语句,并且不抛出Exception。重写后的代码块(在步骤 2中显示)可能如下所示:

$item = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
foreach ($item as $key => $value)
    $double->push($value);

现在,如果我们执行重写后的代码,结果如下所示:

root@php8_tips_php7 [ /home/ch05 ]# 
php php8_spl_spldoublylinkedlist.php 
**************** Foward ********************
Person. Woman. Man. Camera. TV. 
**************** Reverse ********************
TV. Camera. Man. Woman. Person. 

现在您已经了解如何使用SplDoublyLinkedList,并且也知道关于push()unshift()可能出现的潜在代码中断。您还了解了在 PHP 8 中使用各种 SPL 类和函数可能出现的潜在代码中断。这就结束了本章的讨论。

总结

在本章中,您学到了在迁移到 PHP 8 时面向对象编程代码可能出现的问题。在第一节中,您了解到在 PHP 7 和之前的版本中允许许多不良实践,但现在在 PHP 8 中可能导致代码中断。有了这些知识,您将成为一个更好的开发人员,并能够提供高质量的代码来造福您的公司。

在下一节中,您学到了在使用魔术方法时的良好习惯。由于 PHP 8 现在强制实施了在早期版本中没有看到的一定程度的一致性,因此可能会出现代码中断。这些不一致性涉及类构造函数的使用和魔术方法使用的某些方面。接下来的部分教会了您关于 PHP 序列化以及 PHP 8 中所做的更改如何使您的代码更具弹性,并在序列化和反序列化过程中更不容易出现错误或受攻击。

在本章中,您还了解了 PHP 8 对协变返回类型和逆变参数的增强支持。了解了协变的知识,以及在 PHP 8 中支持的改进,使您在开发 PHP 8 中的类继承结构时更具创造性和灵活性。现在您知道如何编写在早期版本的 PHP 中根本不可能的代码。

最后一节涵盖了 SPL 中的许多关键类。您学到了关于如何在 PHP 8 中实现基本数据结构,比如堆和链表的重要知识。该部分的信息对帮助您避免涉及 SPL 的代码问题至关重要。

下一章将继续讨论潜在的代码中断。然而,下一章的重点是过程式而不是对象代码。

第六章:了解 PHP 8 的功能差异

在本章中,您将了解 PHP 8 命令或功能级别可能出现的向后兼容性破坏。本章提供了重要信息,突出了将现有代码迁移到 PHP 8 时可能出现的潜在问题。本章中提供的信息对于您了解如何编写可靠的 PHP 代码至关重要。通过学习本章中的概念,您将更好地编写能够产生精确结果并避免不一致性的代码。

本章涵盖的主题包括以下内容:

  • 学习关键的高级字符串处理差异

  • 了解 PHP 8 中字符串到数字比较的改进

  • 处理算术、位和连接操作的差异

  • 利用地区独立性

  • 处理 PHP 8 中的数组

  • 掌握安全功能和设置的变化

技术要求

为了检查和运行本章提供的代码示例,最低推荐的硬件配置如下:

  • 基于 x86_64 的台式机或笔记本电脑

  • 1 千兆字节(GB)的可用磁盘空间

  • 4 GB 的 RAM

  • 每秒 500 千位(Kbps)或更快的互联网连接

此外,您还需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 的安装以及如何构建用于演示本书中所解释的代码的 Docker 容器的更多信息,请参阅第一章 中的 技术要求 部分。在本书中,我们将您为本书恢复的示例代码所在的目录称为 /repo

本章的源代码位于此处:

https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。

我们现在可以开始讨论,通过检查 PHP 8 中引入的字符串处理差异来了解。

学习关键的高级字符串处理差异

总的来说,PHP 8 中的字符串函数已经在安全性和规范性上得到加强。您会发现在 PHP 8 中使用更受限制,这最终迫使您编写更好的代码。我们可以说,在 PHP 8 中,字符串函数参数的性质和顺序更加统一,这就是为什么我们说 PHP 核心团队已经规范了使用。

这些改进在处理数字字符串时尤为明显。PHP 8 字符串处理的其他变化涉及参数的轻微更改。在本节中,我们向您介绍 PHP 8 处理字符串的关键变化。

重要的是要了解 PHP 8 中引入的处理改进,也要了解 PHP 8 之前字符串处理的不足之处。

让我们首先看一下 PHP 8 中字符串处理的一个方面,即搜索嵌入字符串的函数。

处理针参数的更改

许多 PHP 字符串函数搜索较大字符串中子字符串的存在。这些函数包括 strpos()strrpos()stripos()strripos()strstr()strchr()strrchr()stristr()。所有这些函数都有两个共同的参数:needlehaystack

区分针和草堆

为了说明针和草堆之间的差异,看一下 strpos() 的函数签名:

strpos(string $haystack,string $needle,int $pos=0): int|false

$haystack 是搜索的目标。$needle 是要查找的子字符串。strpos() 函数返回搜索目标中子字符串的位置。如果未找到子字符串,则返回布尔值 FALSE。其他 str*() 函数产生不同类型的输出,我们在这里不详细介绍。

PHP 8 处理 needle 参数的两个关键变化可能会破坏迁移到 PHP 8 的应用程序。这些变化适用于 needle 参数不是字符串或 needle 参数为空的情况。让我们先看看如何处理非字符串 needle 参数。

处理非字符串 needle 参数

您的 PHP 应用程序可能没有采取适当的预防措施,以确保这里提到的str*()函数的 needle 参数始终是一个字符串。如果是这种情况,在 PHP 8 中,needle 参数现在将始终被解释为字符串而不是 ASCII 码点。

如果需要提供 ASCII 值,必须使用chr()函数将其转换为字符串。在以下示例中,使用LF"\n")的 ASCII 值代替字符串。在 PHP 7 或更低版本中,strpos()在运行搜索之前执行内部转换。在 PHP 8 中,该数字只是简单地转换为字符串,产生意想不到的结果。

以下是搜索字符串中LF存在的代码示例。但请注意,提供的参数不是字符串,而是一个值为10的整数:

// /repo/ch06/php8_num_str_needle.php
function search($needle, $haystack) {
    $found = (strpos($haystack, $needle))
           ? 'contains' : 'DOES NOT contain';
    return "This string $found LF characters\n";
}
$haystack = "We're looking\nFor linefeeds\nIn this 
             string\n";
$needle = 10;         // ASCII code for LF
echo search($needle, $haystack);

以下是在 PHP 7 中运行代码示例的结果:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_num_str_needle.php
This string contains LF characters

以下是在 PHP 8 中运行相同代码块的结果:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_num_str_needle.php
This string DOES NOT contain LF characters

如您所见,比较 PHP 7 中的输出与 PHP 8 中的输出,相同的代码块产生了截然不同的结果。这是一个极其难以发现的潜在代码破坏,因为没有生成WarningsErrors

最佳实践是对任何包含 PHP str*()函数之一的函数或方法的 needle 参数应用string类型提示。如果我们重写前面的例子,输出在 PHP 7 和 PHP 8 中是一致的。以下是使用类型提示重写的相同示例:

// /repo/ch06/php8_num_str_needle_type_hint.php
declare(strict_types=1);
function search(string $needle, string $haystack) {
    $found = (strpos($haystack, $needle))
           ? 'contains' : 'DOES NOT contain';
    return "This string $found LF characters\n";
}
$haystack = "We're looking\nFor linefeeds\nIn this 
             string\n";
$needle   = 10;         // ASCII code for LF
echo search($needle, $haystack);

现在,在任何版本的 PHP 中,这是输出:

PHP Fatal error:  Uncaught TypeError: search(): Argument #1 ($needle) must be of type string, int given, called in /repo/ch06/php8_num_str_needle_type_hint.php on line 14 and defined in /repo/ch06/php8_num_str_needle_type_hint.php:4

通过声明strict_types=1,并在$needle参数之前添加string类型提示,任何错误使用你的代码的开发人员都会清楚地知道这种做法是不可接受的。

现在让我们看看当 needle 参数丢失时,PHP 8 会发生什么。

处理空 needle 参数

str*()函数的另一个重大变化是,needle 参数现在可以为空(例如,任何使empty()函数返回TRUE的内容)。这对向后兼容性破坏具有重大潜力。在 PHP 7 中,如果 needle 参数为空,strpos()的返回值将是布尔值FALSE,而在 PHP 8 中,空值首先被转换为字符串,从而产生完全不同的结果。

如果您计划将 PHP 版本更新到 8,那么意识到这种潜在的代码破坏是非常重要的。在手动审查代码时,很难发现空的 needle 参数。这是需要一组可靠的单元测试来确保平稳的 PHP 迁移的情况。

为了说明潜在的问题,请考虑以下示例。假设 needle 参数为空。在这种情况下,传统的if()检查strpos()结果是否与FALSE不相同,在 PHP 7 和 8 之间产生不同的结果。以下是代码示例:

  1. 首先,我们定义一个函数,使用strpos()报告针值是否在 haystack 中找到。注意对布尔值FALSE进行严格类型检查:
// php7_num_str_empty_needle.php
function test($haystack, $search) {
    $pattern = '%15s | %15s | %10s' . "\n";
    $result  = (strpos($haystack, $search) !== FALSE)
             ? 'FOUND' :  'NOT FOUND';
    return sprintf($pattern,
           var_export($search, TRUE),
           var_export(strpos($haystack, $search), 
             TRUE),
           $result);
};
  1. 然后我们将 haystack 定义为一个包含字母和数字的字符串。needle 参数以所有被视为空的值的数组形式提供:
$haystack = 'Something Anything 0123456789';
$needles = ['', NULL, FALSE, 0];
foreach ($needles as $search) 
    echo test($haystack, $search);

在 PHP 7 中的输出如下:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php7_num_str_empty_needle.php
PHP Warning:  strpos(): Empty needle in /repo/ch06/php7_num_str_empty_needle.php on line 5
// not all Warnings are shown ...
             '' |           false |  NOT FOUND
           NULL |           false |  NOT FOUND
          false |           false |  NOT FOUND
              0 |           false |  NOT FOUND

一系列Warnings之后,最终的输出出现了。从输出中可以看出,在 PHP 7 中,strpos($haystack, $search)的返回值始终是布尔值FALSE

然而,在 PHP 8 中运行相同的代码的输出却截然不同。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php7_num_str_empty_needle.php
             '' |               0 |      FOUND
           NULL |               0 |      FOUND
          false |               0 |      FOUND
              0 |              19 |      FOUND

在 PHP 8 中,空的 needle 参数首先被悄悄地转换为字符串。没有一个 needle 值返回布尔值FALSE。这导致函数报告找到了 needle。这显然不是期望的结果。然而,对于数字0,它包含在 haystack 中,导致返回值为19

让我们看看如何解决这个问题。

使用 str_contains()解决问题

在前一节中显示的代码块的目的是确定 haystack 是否包含 needle。strpos()不是完成此任务的正确工具!看看使用str_contains()的相同函数:

// /repo/ch06/php8_num_str_empty_needle.php
function test($haystack, $search) {
    $pattern = '%15s | %15s | %10s' . "\n";
    $result  = (str_contains($search, $haystack) !==  
                FALSE)  
                 ? 'FOUND'  : 'NOT FOUND';
    return sprintf($pattern,
           var_export($search, TRUE),
           var_export(str_contains($search, $haystack), 
             TRUE),
           $result);
};

如果我们在 PHP 8 中运行修改后的代码,我们会得到与从 PHP 7 收到的结果类似的结果:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_num_str_empty_needle.php
             '' |           false |  NOT FOUND
           NULL |           false |  NOT FOUND
          false |           false |  NOT FOUND
              0 |           false |  NOT FOUND

您可能会问为什么数字0在字符串中找不到?答案是str_contains()进行了更严格的搜索。整数0与字符串"0"不同!现在让我们看看v*printf()系列函数;PHP 8 中对其参数施加更严格的控制的另一个字符串函数系列。

处理 v*printf()的变化

v*printf()系列函数是printf()系列函数的一个子集,包括vprintf()vfprintf()vsprintf()。这个子集与主要系列之间的区别在于,v*printf()函数被设计为接受一个数组作为参数,而不是无限系列的参数。以下是一个简单的示例,说明了这种区别:

  1. 首先,我们定义一组参数,这些参数将被插入到一个模式$patt中:
// /repo/ch06/php8_printf_vs_vprintf.php
$ord  = 'third';
$day  = 'Thursday';
$pos  = 'next';
$date = new DateTime("$ord $day of $pos month");
$patt = "The %s %s of %s month is: %s\n";
  1. 然后,我们使用一系列参数执行一个printf()语句:
printf($patt, $ord, $day, $pos, 
       $date->format('l, d M Y'));
  1. 然后,我们将参数定义为一个数组$arr,并使用vprintf()来产生相同的结果:
$arr  = [$ord, $day, $pos, $date->format('l, d M 
           Y')];vprintf($patt, $arr);

以下是在 PHP 8 中运行程序的输出。在 PHP 7 中运行的输出相同(未显示):

root@php8_tips_php8 [ /repo/ch06 ]#
php php8_printf_vs_vprintf.php
The third Thursday of next month is: Thursday, 15 Apr 2021
The third Thursday of next month is: Thursday, 15 Apr 2021

如您所见,两个函数的输出是相同的。唯一的使用区别是vprintf()以数组形式接受参数。

PHP 的早期版本允许开发人员在v*printf()系列函数中玩得快速和松散。在 PHP 8 中,参数的数据类型现在受到严格执行。这只在代码控制不存在以确保提供数组的情况下才会出现问题。另一个更重要的区别是,PHP 7 允许ArrayObjectv*printf()一起使用,而 PHP 8 则不允许。

在这里显示的示例中,PHP 7 会发出一个“警告”,而 PHP 8 会抛出一个“错误”:

  1. 首先,我们定义模式和源数组:
// /repo/ch06/php7_vprintf_bc_break.php
$patt = "\t%s. %s. %s. %s. %s.";
$arr  = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
  1. 然后,我们定义一个测试数据数组,以测试vsprintf()接受哪些参数:
$args = [
    'Array' => $arr, 
    'Int'   => 999,
    'Bool'  => TRUE, 
    'Obj'   => new ArrayObject($arr)
];
  1. 然后,我们定义一个foreach()循环,遍历测试数据并使用vsprintf()
foreach ($args as $key => $value) {
    try {
        echo $key . ': ' . vsprintf($patt, $value);
    } catch (Throwable $t) {
        echo $key . ': ' . get_class($t) 
             . ':' . $t->getMessage();
    }
}

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php7_vprintf_bc_break.php
Array:     Person. Woman. Man. Camera. TV.
PHP Warning:  vsprintf(): Too few arguments in /repo/ch06/php8_vprintf_bc_break.php on line 14
Int: 
PHP Warning:  vsprintf(): Too few arguments in /repo/ch06/php8_vprintf_bc_break.php on line 14
Bool: 
Obj:     Person. Woman. Man. Camera. TV.

从输出中可以看出,在 PHP 7 中,数组和ArrayObject参数都被接受。以下是在 PHP 8 中运行相同代码示例的结果:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php7_vprintf_bc_break.php
Array:     Person. Woman. Man. Camera. TV.
Int: TypeError:vsprintf(): Argument #2 ($values) must be of type array, int given
Bool: TypeError:vsprintf(): Argument #2 ($values) must be of type array, bool given
Obj: TypeError:vsprintf(): Argument #2 ($values) must be of type array, ArrayObject given

正如预期的那样,PHP 8 的输出更加一致。在 PHP 8 中,v*printf()函数被严格类型化,只接受数组作为参数。不幸的是,您可能一直在使用ArrayObject。这可以通过简单地在ArrayObject实例上使用getArrayCopy()方法来解决,该方法返回一个数组。

以下是在 PHP 7 和 PHP 8 中都有效的重写代码:

    if ($value instanceof ArrayObject)
        $value = $value->getArrayCopy();
    echo $key . ': ' . vsprintf($patt, $value);

现在您知道在使用v*printf()函数时可能出现代码中断的地方,让我们将注意力转向 PHP 8 中空长度参数的字符串函数的工作方式的差异。

在 PHP 8 中处理空长度参数

在 PHP 7 及更早版本中,NULL长度参数导致空字符串。在 PHP 8 中,NULL长度参数现在被视为与省略长度参数相同。受影响的函数包括以下内容:

  • substr()

  • substr_count()

  • substr_compare()

  • iconv_substr()

在接下来的示例中,PHP 7 返回空字符串,而 PHP 8 返回字符串的其余部分。如果操作的结果用于确认或否定子字符串的存在,则可能会导致代码中断:

  1. 首先,我们定义一个 haystack 和 needle。然后,我们运行 strpos() 来获取 needle 在 haystack 中的位置:
// /repo/ch06/php8_null_length_arg.php
$str = 'The quick brown fox jumped over the fence';
$var = 'fox';
$pos = strpos($str, $var);
  1. 接下来,我们提取子字符串,故意不定义长度参数:
$res = substr($str, $pos, $len);
$fnd = ($res) ? '' : ' NOT';
echo "$var is$fnd found in the string\n";

PHP 7 中的输出如下:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_null_length_arg.php
PHP Notice:  Undefined variable: len in /repo/ch06/php8_null_length_arg.php on line 8
Result   : fox is NOT found in the string
Remainder: 

如预期的那样,PHP 7 发出“注意”。然而,由于 NULL 长度参数返回空字符串,搜索结果是不正确的。以下是在 PHP 8 中运行相同代码的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_null_length_arg.php
PHP Warning:  Undefined variable $len in /repo/ch06/php8_null_length_arg.php on line 8
Result   : fox is found in the string
Remainder: fox jumped over the fence

PHP 8 发出“警告”并返回字符串的其余部分。这与完全省略长度参数的行为一致。如果您的代码依赖于返回空字符串,则在 PHP 8 更新后可能存在潜在的代码中断。

现在让我们看看另一种情况,在这种情况下,PHP 8 使 implode() 函数中的字符串处理更加统一。

检查 implode() 的更改

两个广泛使用的 PHP 函数执行数组到字符串的转换和反向转换:explode() 将字符串转换为数组,而 implode() 将数组转换为字符串。然而,implode() 函数隐藏着一个深不可测的秘密:它的两个参数可以以任何顺序表达!

请记住,当 PHP 在 1994 年首次推出时,最初的目标是尽可能地易于使用。这种方法取得了成功,以至于根据 w3techs 最近进行的服务器端编程语言调查,PHP 是今天所有 Web 服务器中的首选语言,占比超过 78%。(https://w3techs.com/technologies/overview/programming_language)

然而,为了保持一致性,将 implode() 函数的参数与其镜像函数 explode() 对齐是有意义的。因此,现在必须按照这个顺序提供给 implode() 的参数:

implode(<GLUE STRING>, <ARRAY>);

以下是调用 implode() 函数的代码示例,参数可以以任何顺序传递:

// /repo/ch06/php7_implode_args.php
$arr  = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
echo __LINE__ . ':' . implode(' ', $arr) . "\n";
echo __LINE__ . ':' . implode($arr, ' ') . "\n";

如下所示,从 PHP 7 的输出中可以看到,两个 echo 语句都产生了结果:

root@php8_tips_php7 [ /repo/ch06 ]# php php7_implode_args.php
5:Person Woman Man Camera TV
6:Person Woman Man Camera TV

在 PHP 8 中,只有第一条语句成功,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php7_implode_args.php
5:Person Woman Man Camera TV
PHP Fatal error:  Uncaught TypeError: implode(): Argument #2 ($array) must be of type ?array, string given in /repo/ch06/php7_implode_args.php:6

很难发现 implode() 接收参数的顺序错误的地方。在进行 PHP 8 迁移之前,最好的方法是记录所有使用 implode() 的 PHP 文件类别。另一个建议是利用 PHP 8 的命名参数功能(在第一章中介绍了PHP 8 新的面向对象特性)。

学习 PHP 8 中常量的使用

在 PHP 8 之前的一个真正令人震惊的功能是能够定义不区分大小写的常量。在 PHP 刚推出时,许多开发人员写了大量 PHP 代码,但明显缺乏任何编码标准。当时的目标只是让它工作

与强制执行良好的编码标准的一般趋势一致,这种能力在 PHP 7.3 中已被弃用,并在 PHP 8 中移除。如果您将 define() 的第三个参数设置为 TRUE,则可能会出现向后兼容的中断。

这里显示的示例在 PHP 7 中有效,但在 PHP 8 中并非完全有效:

// /repo/ch06/php7_constants.php
define('THIS_WORKS', 'This works');
define('Mixed_Case', 'Mixed Case Works');
define('DOES_THIS_WORK', 'Does this work?', TRUE);
echo __LINE__ . ':' . THIS_WORKS . "\n";
echo __LINE__ . ':' . Mixed_Case . "\n";
echo __LINE__ . ':' . DOES_THIS_WORK . "\n";
echo __LINE__ . ':' . Does_This_Work . "\n";

在 PHP 7 中,所有代码行都按原样工作。输出如下:

root@php8_tips_php7 [ /repo/ch06 ]# php php7_constants.php
7:This works
8:Mixed Case Works
9:Does this work?
10:Does this work?

请注意,PHP 7.3 中的 define() 的第三个参数已被弃用。因此,如果您在 PHP 7.3 或 7.4 中运行此代码示例,则输出与添加“弃用”通知相同。

然而,在 PHP 8 中,产生了完全不同的结果,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]# php php7_constants.php
PHP Warning:  define(): Argument #3 ($case_insensitive) is ignored since declaration of case-insensitive constants is no longer supported in /repo/ch06/php7_constants.php on line 6
7:This works
8:Mixed Case Works
9:Does this work?
PHP Fatal error:  Uncaught Error: Undefined constant "Does_This_Work" in /repo/ch06/php7_constants.php:10

正如您可能期望的那样,第 7、8 和 9 行产生了预期的结果。然而,最后一行会抛出致命的“错误”,因为 PHP 8 中的常量现在区分大小写。此外,第三个 define() 语句会发出“警告”,因为在 PHP 8 中忽略了第三个参数。

您现在对 PHP 8 中引入的关键字符串处理差异有了了解。接下来,我们将关注数字字符串与数字的比较方式的变化。

了解 PHP 8 中字符串转换为数值的改进

在 PHP 中比较两个数值从来都不是问题。比较两个字符串也不是问题。问题出现在字符串和数值数据(硬编码数字,或包含floatint类型数据的变量)之间的非严格比较中。在这种情况下,如果执行非严格比较,PHP 将始终将字符串转换为数值。

字符串转换为数值的唯一成功情况是当字符串只包含数字(或数字值,如加号、减号或小数点)时。在本节中,您将学习如何防止涉及字符串和数值数据的不准确的非严格比较。如果您希望编写具有一致和可预测行为的代码,掌握本章介绍的概念至关重要。

在我们深入了解字符串转换为数值的比较细节之前,我们首先需要了解什么是非严格比较。

学习严格和非严格比较

类型转换的概念是 PHP 语言的一个重要部分。这种能力从语言诞生的第一天起就内置在语言中。类型转换涉及在执行操作之前执行内部数据类型转换。这种能力对语言的成功至关重要。

PHP 最初是为在 Web 环境中执行而设计的,并且需要一种处理作为 HTTP 数据包的一部分传输的数据的方式。HTTP 头部和正文以文本形式传输,并由 PHP 作为存储在一组超全局变量中的字符串接收,包括$_SERVER$_GET$_POST等。因此,PHP 语言在执行涉及数字的操作时需要一种快速处理字符串值的方式。这就是类型转换过程的工作。

严格比较首先检查数据类型。如果数据类型匹配,则进行比较。触发严格比较的运算符包括===!==等。某些函数有选项来强制使用严格数据类型。in_array()就是一个例子。如果第三个参数设置为TRUE,则进行严格类型搜索。以下是in_array()的方法签名:

in_array(mixed $needle, array $haystack, bool $strict = false)

非严格比较是指在比较之前不进行数据类型检查。执行非严格比较的运算符包括==!=<>等。值得注意的是,switch {}语言结构在其case语句中执行非严格比较。如果进行涉及不同数据类型的操作数的非严格比较,将执行类型转换。

现在让我们详细看一下数字字符串。

检查数字字符串

数字字符串是只包含数字或数字字符的字符串,例如加号(+)、减号(-)和小数点。

重要提示

值得注意的是,PHP 8 内部使用句点字符(.)作为小数点分隔符。如果您需要在不使用句点作为小数点分隔符的区域呈现数字(例如,在法国,逗号(,)被用作小数点分隔符),请使用number_format()函数(请参阅 https://www.php.net/number_format)。有关更多信息,请查看本章中关于利用区域独立性部分。

数字字符串也可以使用工程表示法(也称为科学表示法)来组成。非格式良好的数字字符串是包含除数字、加号、减号或小数分隔符之外的值的数字字符串。前导数字字符串以数字字符串开头,但后面跟着非数字字符。PHP 引擎认为任何既不是数字也不是前导数字的字符串都被视为非数字

在以前的 PHP 版本中,类型转换不一致地解析包含数字的字符串。在 PHP 8 中,只有数字字符串可以被干净地转换为数字:不能存在前导或尾随空格或其他非数字字符。

例如,看一下 PHP 7 和 8 在此代码示例中处理数字字符串的差异:

// /repo/ch06/php8_num_str_handling.php
$test = [
    0 => '111',
    1 => '   111',
    2 => '111   ',
    3 => '111xyz'
];
$patt = "%d : %3d : '%-s'\n";
foreach ($test as $key => $val) {
    $num = 111 + $val;
    printf($patt, $key, $num, $val);
}

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_num_str_handling.php
0 : 222 : '111'
1 : 222 : '   111'
PHP Notice:  A non well formed numeric value encountered in /repo/ch06/php8_num_str_handling.php on line 11
2 : 222 : '111   '
PHP Notice:  A non well formed numeric value encountered in /repo/ch06/php8_num_str_handling.php on line 11
3 : 222 : '111xyz'

从输出中可以看出,PHP 7 认为带有尾随空格的字符串是非格式良好的。然而,带有前导空格的字符串被认为是格式良好的,并且可以通过而不生成Notice。包含非空白字符的字符串仍然会被处理,但会产生一个Notice

以下是在 PHP 8 中运行的相同代码示例:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_num_str_handling.php
0 : 222 : '111'
1 : 222 : '   111'
2 : 222 : '111   '
PHP Warning:  A non-numeric value encountered in /repo/ch06/php8_num_str_handling.php on line 11
3 : 222 : '111xyz'

PHP 8 在这一点上更加一致,包含前导或尾随空格的数字字符串被平等对待,并且不会生成NoticesWarnings。然而,最后一个字符串,在 PHP 7 中曾经是一个Notice,现在会生成一个Warning

提示

您可以在 PHP 文档中阅读有关数字字符串的内容:

https://www.php.net/manual/en/language.types.numeric-strings.php

有关类型转换的更多信息,请查看以下网址:

https://www.php.net/manual/en/language.types.type-juggling.php

现在您已经知道什么是格式良好和非格式良好的数字字符串,让我们把注意力转向在 PHP 8 中处理数字字符串时可能出现的更严重的向后兼容中断问题。

检测涉及数字字符串的向后兼容中断

您必须了解在 PHP 8 升级后,您的代码可能会出现潜在的中断。在本小节中,我们向您展示了一些极其微妙的差异,这些差异可能会产生重大后果。

任何时候都可能出现潜在的代码中断,当使用非格式良好的数字字符串时:

  • 使用is_numeric()

  • 在字符串偏移量中(例如,$str['4x']

  • 使用位运算符

  • 在增加或减少值为非格式良好的数字字符串的变量时

以下是一些修复代码的建议:

  • 考虑在可能包含前导或尾随空格的数字字符串上使用trim()(例如,嵌入在发布的表单数据中的数字字符串)。

  • 如果您的代码依赖以数字开头的字符串,请使用显式类型转换来确保数字被正确插入。

  • 不要依赖空字符串(例如,$str = '')干净地转换为 0。

在以下代码示例中,将一个带有尾随空格的非格式良好字符串分配给$age

// /repo/ch06/php8_num_str_is_numeric.php
$age = '77  ';
echo (is_numeric($age))
     ? "Age must be a number\n"
     : "Age is $age\n";

当我们在 PHP 7 中运行这段代码时,is_numeric()返回TRUE。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_num_str_is_numeric.php
Age is 77  

另一方面,当我们在 PHP 8 中运行这段代码时,is_numeric()返回FALSE,因为该字符串不被视为数字。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_num_str_is_numeric.php
Age must be a number

正如您所看到的,PHP 7 和 PHP 8 之间的字符串处理差异可能导致应用程序的行为不同,可能会产生灾难性的结果。现在让我们看一下涉及格式良好字符串的不一致结果。

处理不一致的字符串到数字比较结果

为了完成涉及字符串和数字数据的非严格比较,PHP 引擎首先执行类型转换操作,将字符串在内部转换为数字,然后执行比较。然而,即使是格式良好的数字字符串,也可能产生从人类角度看起来荒谬的结果。

例如,看一下这个代码示例:

  1. 首先,我们对一个变量$zero(值为零)和一个变量$string(值为 ABC)进行了非严格比较:
$zero   = 0;
$string = 'ABC';
$result = ($zero == $string) ? 'is' : 'is not';
echo "The value $zero $result the same as $string\n"2
  1. 以下非严格比较使用in_array()$array数组中查找零值:
$array  = [1 => 'A', 2 => 'B', 3 => 'C'];
$result = (in_array($zero, $array)) 
        ? 'is in' : 'is not in';
echo "The value $zero $result\n" 
     . var_export($array, TRUE)3
  1. 最后,我们对一个以数字开头的字符串42abc88和一个硬编码数字42进行了非严格比较:
$mixed  = '42abc88';
$result = ($mixed == 42) ? 'is' : 'is not';
echo "\nThe value $mixed $result the same as 42\n";

在 PHP 7 中运行的结果令人难以理解!以下是 PHP 7 的结果:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php7_compare_num_str.php
The value 0 is the same as ABC
The value 0 is in
array (1 => 'A', 2 => 'B', 3 => 'C')
The value 42abc88 is the same as 42

从人类的角度来看,这些结果都毫无意义!然而,从计算机的角度来看,这是完全合理的。字符串ABC在转换为数字时,最终的值为零。同样,当进行数组搜索时,每个只有字符串值的数组元素最终都被插值为零。

以数字开头的字符串的情况有点棘手。在 PHP 7 中,插值算法会将数字字符转换为第一个非数字字符出现之前。一旦发生这种情况,插值就会停止。因此,字符串42abc88在比较目的上变成了整数42。现在让我们看看 PHP 8 如何处理字符串到数字的比较。

理解 PHP 8 中的比较变化

在 PHP 8 中,如果将字符串与数字进行比较,只有数字字符串才被视为有效比较。指数表示法中的字符串也被视为有效比较,以及具有前导或尾随空格的数字字符串。非常重要的是要注意,PHP 8 在转换字符串之前就做出了这一决定。

看一下在上一小节中描述的相同代码示例的输出(处理不一致的字符串到数字比较结果),在 PHP 8 中运行:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php7_compare_num_str.php
The value 0 is not the same as ABC
The value 0 is not in
array (1 => 'A', 2 => 'B', 3 => 'C')
The value 42abc88 is not the same as 42

因此,从输出中可以看出,您的应用程序在进行 PHP 8 升级后有巨大的潜力改变其行为。在 PHP 8 字符串处理的最后说明中,让我们看看如何避免升级问题。

避免在 PHP 8 升级期间出现问题

您面临的主要问题是 PHP 8 如何处理涉及不同数据类型的非严格比较的差异。如果一个操作数是intfloat,另一个操作数是string,那么在升级后可能会出现问题。如果字符串是有效的数字字符串,则非严格比较将进行而不会出现任何问题。

以下运算符受到影响:<=>==!=>>=<<=。如果选项标志设置为默认值,则以下函数会受到影响:

  • in_array()

  • array_search()

  • array_keys()

  • sort()

  • rsort()

  • asort()

  • arsort()

  • array_multisort()

提示

有关 PHP 8 中改进的数字字符串处理的更多信息,请参阅以下链接:https://wiki.php.net/rfc/saner-numeric-strings。相关的 PHP 8 变化在此处记录:wiki.php.net/rfc/string_to_number_comparison

最佳实践是通过为函数或方法提供类型提示来最小化 PHP 类型转换。您还可以在比较之前强制数据类型。最后,考虑使用严格比较,尽管这在所有情况下可能并不适用。

现在您已经了解了如何在 PHP 8 中正确处理涉及数字字符串的比较,现在让我们看看涉及算术、位和连接操作的 PHP 8 变化。

处理算术、位和连接操作的差异

算术、位和连接操作是任何 PHP 应用程序的核心。在本节中,您将了解在 PHP 8 迁移后这些简单操作可能出现的隐藏危险。您必须了解 PHP 8 中的更改,以便避免应用程序出现潜在的代码错误。因为这些操作是如此普通,如果没有这些知识,您将很难发现迁移后的错误。

让我们首先看看 PHP 在算术和位操作中如何处理非标量数据类型。

处理算术和位操作中的非标量数据类型

从历史上看,PHP 引擎对在算术或位操作中使用混合数据类型非常“宽容”。我们已经看过涉及数字前导数字非数字字符串和数字的比较操作。正如您所了解的,当使用非严格比较时,PHP 会调用类型转换将字符串转换为数字,然后执行比较。当 PHP 执行涉及数字和字符串的算术操作时,也会发生类似的操作。

在 PHP 8 之前,非标量数据类型(除了stringintfloatboolean之外的数据类型)允许在算术操作中使用。PHP 8 已经严格限制了这种不良做法,不再允许arrayresourceobject类型的操作数。当非标量操作数用于算术操作时,PHP 8 始终会抛出TypeError。这一般变化的唯一例外是,您仍然可以执行所有操作数都是array类型的算术操作。

提示

有关算术和位操作中重要变化的更多信息,请参阅此处:https://wiki.php.net/rfc/arithmetic_operator_type_checks。

以下是一个代码示例,用于说明 PHP 8 中算术运算符处理的差异:

  1. 首先,我们定义样本非标量数据以在算术操作中进行测试:
// /repo/ch06/php8_arith_non_scalar_ops.php
$fn  = __DIR__ . '/../sample_data/gettysburg.txt';
$fh  = fopen($fn, 'r');
$obj = new class() { public $val = 99; };
$arr = [1,2,3];
  1. 然后,我们尝试将整数99添加到资源、对象,并对数组执行模数运算:
echo "Adding 99 to a resource\n";
try { var_dump($fh + 99); }
catch (Error $e) { echo $e . "\n"; }
echo "\nAdding 99 to an object\n";
try { var_dump($obj + 99); }
catch (Error $e) { echo $e . "\n"; }
echo "\nPerforming array % 99\n";
try { var_dump($arr % 99); }
catch (Error $e) { echo $e . "\n"; }
  1. 最后,我们将两个数组相加:
echo "\nAdding two arrays\n";
try { var_dump($arr + [99]); }
catch (Error $e) { echo $e . "\n"; }

当我们运行代码示例时,请注意 PHP 7 如何执行静默转换并允许操作继续进行:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_arith_non_scalar_ops.php 
Adding 99 to a resource
/repo/ch06/php8_arith_non_scalar_ops.php:10:
int(104)
Adding 99 to an object
PHP Notice:  Object of class class@anonymous could not be converted to int in /repo/ch06/php8_arith_non_scalar_ops.php on line 13
/repo/ch06/php8_arith_non_scalar_ops.php:13:
int(100)
Performing array % 99
/repo/ch06/php8_arith_non_scalar_ops.php:16:
int(1)
Adding two arrays
/repo/ch06/php8_arith_non_scalar_ops.php:19:
array(3) {
  [0] =>  int(1)
  [1] =>  int(2)
  [2] =>  int(3)
}

特别令人惊讶的是我们如何对数组执行模数运算!在 PHP 7 中,向对象添加值会生成一个Notice。但是,在 PHP 中,对象被类型转换为具有值1的整数,从而使算术操作的结果为100

在 PHP 8 中运行相同的代码示例的输出非常不同:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_arith_non_scalar_ops.php
Adding 99 to a resource
TypeError: Unsupported operand types: resource + int in /repo/ch06/php8_arith_non_scalar_ops.php:10
Adding 99 to an object
TypeError: Unsupported operand types: class@anonymous + int in /repo/ch06/php8_arith_non_scalar_ops.php:13
Performing array % 99
TypeError: Unsupported operand types: array % int in /repo/ch06/php8_arith_non_scalar_ops.php:16
Adding two arrays
array(3) {
  [0]=>  int(1)
  [1]=>  int(2)
  [2]=>  int(3)
}

从输出中可以看出,PHP 8 始终会抛出TypeError,除非添加两个数组。在两个输出中,您可能会观察到当添加两个数组时,第二个操作数被忽略。如果目标是合并两个数组,则必须使用array_merge()

现在让我们关注 PHP 8 中与优先级顺序相关的字符串处理的潜在重大变化。

检查优先级顺序的变化

优先级顺序,也称为操作顺序运算符优先级,是在 18 世纪末和 19 世纪初确立的数学概念。PHP 还采用了数学运算符优先级规则,并增加了一个独特的内容:连接运算符。PHP 语言的创始人假设连接运算符具有与算术运算符相等的优先级。直到 PHP 8 的到来,这一假设从未受到挑战。

在 PHP 8 中,算术操作的优先级高于连接。连接运算符的降级现在将其置于位移运算符(<<>>)之下。在任何不使用括号明确定义混合算术和连接操作的地方,都存在潜在的向后兼容性中断。

这种变化本身不会引发Error,也不会生成WarningsNotices,因此可能导致潜在的代码中断。

提示

有关此更改的原因的更多信息,请参阅以下链接:

https://wiki.php.net/rfc/concatenation_precedence

以下示例最清楚地显示了这种变化的影响:

echo 'The sum of 2 + 2 is: ' . 2 + 2;

以下是在 PHP 7 中对这个简单语句的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php -r "echo 'The sum of 2 + 2 is: ' . 2 + 2;"
PHP Warning:  A non-numeric value encountered in Command line code on line 1
2

在 PHP 7 中,因为连接运算符的优先级与加法运算符相等,字符串The sum of 2 + 2 is:首先与整数值2连接。然后将新字符串类型转换为整数,生成一个Warning。新字符串的值计算为0,然后加到整数2上,产生输出2

然而,在 PHP 8 中,首先进行加法,然后将结果与初始字符串连接。这是在 PHP 8 中运行的结果:

root@php8_tips_php8 [ /repo/ch06 ]# 
php -r "echo 'The sum of 2 + 2 is: ' . 2 + 2;"
The sum of 2 + 2 is: 4

正如您从输出中看到的,结果更接近人类的期望!

再举一个例子,说明降级连接运算符可能产生的差异。看看这行代码:

echo '1' . '11' + 222;

这是在 PHP 7 中运行的结果:

root@php8_tips_php7 [ /repo/ch06 ]# 
php -r "echo '1' . '11' + 222;"
333

PHP 7 首先进行连接,产生一个字符串111。这被类型转换并加到整数222上,产生最终值整数333。这是在 PHP 8 中运行的结果:

root@php8_tips_php8 [ /repo/ch06 ]# 
php -r "echo '1' . '11' + 222;"
1233

在 PHP 8 中,第二个字符串11被类型转换并加到整数222上,产生一个中间值233。这被类型转换为字符串,并以1开头,最终产生一个字符串值1233

现在您已经了解了 PHP 8 中算术、位和连接操作的变化,让我们来看看 PHP 8 中引入的一个新趋势:区域设置独立性。

利用区域设置独立性

在 PHP 8 之前的版本中,几个字符串函数和操作与区域设置相关。其净效果是,根据区域设置的不同,数字在内部存储方式不同。这种做法引入了微妙的不一致,极其难以检测。在阅读本章介绍的材料后,您将更好地了解在 PHP 8 升级后检测潜在应用程序代码更改的潜力,从而避免应用程序失败。

了解与区域设置依赖相关的问题

在早期的 PHP 版本中,区域设置依赖的不幸副作用是从floatstring的类型转换,然后再次转换时产生不一致的结果。当将float值连接到string时,也会出现不一致。由OpCache执行的某些优化操作导致连接操作发生在设置区域设置之前,这是产生不一致结果的另一种方式。

在 PHP 8 中,易受攻击的操作和函数现在与区域设置无关。这意味着所有浮点值现在都使用句点作为小数分隔符进行存储。默认区域设置不再默认从环境中继承。如果需要设置默认区域设置,现在必须显式调用setlocale()

审查受区域设置独立性影响的函数和操作

大多数 PHP 函数不受区域设置独立性切换的影响,因为该函数或扩展与区域设置无关。此外,大多数 PHP 函数和扩展已经是区域设置独立的。例如PDO扩展,以及var_export()json_encode()等函数,以及printf()系列。

受区域设置独立性影响的函数和操作包括以下内容:

  • (string) $float

  • strval($float)

  • print_r($float)

  • var_dump($float)

  • debug_zval_dump($float)

  • settype($float, "string")

  • implode([$float])

  • xmlrpc_encode($float)

这是一个示例代码,说明了由于区域设置独立性而产生的差异的处理:

  1. 首先,我们定义一个要测试的区域设置数组。所选的区域设置使用不同的方式来表示数字的小数部分:
// /repo/ch06/php8_locale_independent.php
$list = ['en_GB', 'fr_FR', 'de_DE'];
$patt = "%15s | %15s \n";
  1. 然后我们循环遍历区域设置,设置区域设置,并执行从浮点数到字符串的转换,然后再从字符串到浮点数的转换,同时在每一步打印结果:
foreach ($list as $locale) {
    setlocale(LC_ALL, $locale);
    echo "Locale          : $locale\n";
    $f = 123456.789;
    echo "Original        : $f\n";
    $s = (string) $f;
    echo "Float to String : $s\n";
    $r = (float) $s;
    echo "String to Float : $r\n";
}

如果我们在 PHP 7 中运行这个例子,请注意结果:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_locale_independent.php
Locale          : en_GB
Original        : 123456.789
Float to String : 123456.789
String to Float : 123456.789
Locale          : fr_FR
Original        : 123456,789
Float to String : 123456,789
String to Float : 123456
Locale          : de_DE
Original        : 123456,789
Float to String : 123456,789
String to Float : 123456

从输出中可以看出,对于en_GB,数字在内部使用句点作为小数分隔符存储,而对于fr_FRde_DE等地区,逗号用于分隔。然而,当将字符串转换回数字时,如果小数分隔符不是句点,字符串将被视为前导数字字符串。在两个地区中,逗号的存在会停止转换过程。其结果是小数部分被丢弃,精度丢失。

在 PHP 8 中运行相同代码示例的结果如下:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_locale_independent.php
Locale          : en_GB
Original        : 123456.789
Float to String : 123456.789
String to Float : 123456.789
Locale          : fr_FR
Original        : 123456.789
Float to String : 123456.789
String to Float : 123456.789
Locale          : de_DE
Original        : 123456.789
Float to String : 123456.789
String to Float : 123456.789

在 PHP 8 中,没有丢失精度,无论地区如何,数字都会一致地使用句点作为小数分隔符来表示。

请注意,您仍然可以使用number_format()函数或使用NumberFormatter类(来自Intl扩展)根据其地区表示数字。有趣的是,NumberFormatter类以与地区无关的方式在内部存储数字!

提示

更多信息,请查看这篇文章:https://wiki.php.net/rfc/locale_independent_float_to_string。

有关国际数字格式化的更多信息,请参阅以下链接:https://www.php.net/manual/en/class.numberformatter.php

现在你已经了解了 PHP 8 中存在的与地区无关的方面,我们需要看一下数组处理的变化。

在 PHP 8 中处理数组

除了性能的改进之外,PHP 8 数组处理的两个主要变化涉及处理负偏移和花括号({})的使用。由于这两个变化可能导致在 PHP 8 迁移后应用代码中断,因此重要的是在这里进行介绍。了解这里提出的问题可以让你更有机会在短时间内使中断的代码重新运行。

让我们先看一下负数组偏移处理。

处理负偏移

在 PHP 中为数组分配值时,如果不指定索引,PHP 会自动为您分配一个。以这种方式选择的索引是一个整数,表示比当前分配的整数键高一个值。如果尚未分配整数索引键,自动索引分配算法将从零开始。

然而,在 PHP 7 及更低版本中,对于负整数索引,这种算法并不一致。如果一个数字数组以负数作为索引开始,自动索引会跳到零(0),而不管下一个数字通常是什么。另一方面,在 PHP 8 中,自动索引始终以+1的值递增,无论索引是负数还是正数。

如果你的代码依赖于自动索引,并且起始索引是负数,那么可能会出现向后兼容的代码中断。检测这个问题很困难,因为自动索引会在没有任何警告通知的情况下悄悄发生。

以下代码示例说明了 PHP 7 和 PHP 8 之间行为差异:

  1. 首先,我们定义一个只有负整数作为索引的数组。我们使用var_dump()来显示这个数组:
// /repo/ch06/php8_array_negative_index.php
$a = [-3 => 'CCC', -2 => 'BBB', -1 => 'AAA'];
var_dump($a);
  1. 然后我们定义第二个数组,并将第一个索引初始化为-3。然后我们添加额外的数组元素,但没有指定索引。这会导致自动索引发生:
$b[-3] = 'CCC';
$b[] = 'BBB';
$b[] = 'AAA';
var_dump($b);
  1. 如果我们在 PHP 7 中运行程序,注意第一个数组被正确渲染。在 PHP 7 及更早版本中,只要直接分配,就可以有负数组索引。以下是输出:
root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_array_negative_index.php 
/repo/ch06/php8_array_negative_index.php:6:
array(3) {
  [-3] =>  string(3) "CCC"
  [-2] =>  string(3) "BBB"
  [-1] =>  string(3) "AAA"
}
/repo/ch06/php8_array_negative_index.php:12:
array(3) {
  [-3] =>  string(3) "CCC"
  [0] =>  string(3) "BBB"
  [1] =>  string(3) "AAA"
}
  1. 然而,正如你从第二个var_dump()输出中看到的,自动数组索引会跳过零,而不管先前的高值是多少。

  2. 另一方面,在 PHP 8 中,你可以看到输出是一致的。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_array_negative_index.php 
array(3) {
  [-3]=>  string(3) "CCC"
  [-2]=>  string(3) "BBB"
  [-1]=>  string(3) "AAA"
}
array(3) {
  [-3]=>  string(3) "CCC"
  [-2]=>  string(3) "BBB"
  [-1]=>  string(3) "AAA"
}
  1. 从输出中可以看出,数组索引是自动分配的,递增了1,使得两个数组相同。

提示

有关此增强功能的更多信息,请参阅此文章:https://wiki.php.net/rfc/negative_array_index。

既然你已经意识到了涉及负值自动赋值索引的潜在代码中断,让我们把注意力转向另一个感兴趣的领域:花括号的使用。

处理花括号使用变化

花括号({})对于创建 PHP 代码的任何开发人员来说都是一个熟悉的视觉。PHP 语言是用 C 语言编写的,广泛使用 C 语法,包括花括号。众所周知,花括号用于在控制结构(例如,if {})、循环(例如,for () {})、函数(例如,function xyz() {})和类中界定代码块。

然而,在本小节中,我们将把对花括号的使用的研究限制在与变量相关的方面。PHP 8 中一个可能重大的变化是使用花括号来标识数组元素。在 PHP 8 中,使用花括号来指定数组偏移已经被弃用。

鉴于以下原因,旧的用法一直备受争议:

  • 它的使用很容易与双引号字符串中的花括号的使用混淆。

  • 花括号不能用于进行数组赋值。

因此,PHP 核心团队需要使花括号的使用与方括号([ ])一致...或者干脆摒弃这种花括号的使用。最终决定是移除对数组的花括号支持。

提示

有关更改背后的背景信息,请参阅以下链接:https://wiki.php.net/rfc/deprecate_curly_braces_array_access。

这是一个说明这一点的代码示例:

  1. 首先,我们定义一个回调函数数组,说明了已删除或非法使用花括号的情况:
// /repo/ch06/php7_curly_brace_usage.php
$func = [
    1 => function () {
        $a = ['A' => 111, 'B' => 222, 'C' => 333];
        echo 'WORKS: ' . $a{'C'} . "\n";},
    2 => function () {
        eval('$a = {"A","B","C"};');
    },
    3 => function () {
        eval('$a = ["A","B"]; $a{} = "C";');
    }
];
  1. 然后我们使用try/catch块循环遍历回调函数以捕获抛出的错误:
foreach ($func as $example => $callback) {
    try {
        echo "\nTesting Example $example\n";
        $callback();
    } catch (Throwable $t) {
        echo $t->getMessage() . "\n";
    }
}

如果我们在 PHP 7 中运行这个例子,第一个回调函数可以工作。第二个和第三个会抛出ParseError

root@php8_tips_php7 [ /repo/ch06 ]# 
php php7_curly_brace_usage.php 
Testing Example 1
WORKS: 333
Testing Example 2
syntax error, unexpected '{'
Testing Example 3
syntax error, unexpected '}'

然而,当我们在 PHP 8 中运行相同的例子时,没有一个例子能工作。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php7_curly_brace_usage.php 
PHP Fatal error:  Array and string offset access syntax with curly braces is no longer supported in /repo/ch06/php7_curly_brace_usage.php on line 8

这种潜在的代码中断很容易检测到。然而,由于你的代码中有许多花括号,你可能不得不等待致命的Error被抛出来捕获代码中断。

现在你已经了解了 PHP 8 中数组处理的变化,让我们来看看与安全相关函数的变化。

掌握安全函数和设置的变化

任何对 PHP 安全功能的更改都值得注意。不幸的是,鉴于当今世界的状况,对任何面向网络的代码的攻击是必然的。因此,在本节中,我们将讨论 PHP 8 中与安全相关的函数的几处变化。受影响的变化函数包括以下内容:

  • assert()

  • password_hash()

  • crypt()

此外,PHP 8 对于在php.ini文件中使用disable_functions指令定义的任何函数的处理方式也发生了变化。让我们首先看一下这个指令。

了解禁用函数处理的变化。

Web 托管公司通常提供大幅折扣的共享托管套餐。一旦客户注册,托管公司的 IT 工作人员会在共享服务器上创建一个帐户,分配一个磁盘配额来控制磁盘空间的使用,并在 Web 服务上创建一个虚拟主机定义。然而,这些托管公司面临的问题是,允许对 PHP 的无限制访问对共享托管公司以及同一服务器上的其他用户构成安全风险。

为了解决这个问题,IT 工作人员经常将一个逗号分隔的函数列表分配给php.ini指令disable_functions。这样做,列表中的任何函数都不能在运行在该服务器上的 PHP 代码中使用。通常会出现在这个列表上的函数是那些允许操作系统访问的函数,比如system()shell_exec()

只有内部 PHP 函数才会出现在这个列表上。内部函数是指包括在 PHP 核心中以及通过扩展提供的函数。用户定义的函数不受此指令影响。

检查禁用函数处理的差异

在 PHP 7 及更早版本中,禁用的函数无法重新定义。在 PHP 8 中,禁用的函数被视为从未存在过,这意味着重新定义是可能的。

重要说明

在 PHP 8 中重新定义禁用的函数并不意味着原始功能已经恢复!

为了说明这个概念,我们首先将这行添加到php.ini文件中:disable_functions=system.

请注意,我们需要将此内容添加到两个 Docker 容器(PHP 7 和 PHP 8)中,以完成说明。更新php.ini文件的命令如下所示:

root@php8_tips_php7 [ /repo/ch06 ]# 
echo "disable_functions=system">>/etc/php.ini
root@php8_tips_php8 [ /repo/ch06 ]# 
echo "disable_functions=system">>/etc/php.ini

如果我们尝试使用system()函数,则在 PHP 7 和 PHP 8 中都会失败。这里,我们展示了 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch06 ]# 
php -r "system('ls -l');"
PHP Fatal error:  Uncaught Error: Call to undefined function system() in Command line code:1

然后我们定义一些重新定义被禁止函数的程序代码:

// /repo/ch06/php8_disabled_funcs_redefine.php
function system(string $cmd, string $path = NULL) {
    $output = '';
    $path = $path ?? __DIR__;
    if ($cmd === 'ls -l') {
        $iter = new RecursiveDirectoryIterator($path);
        foreach ($iter as $fn => $obj)
            $output .= $fn . "\n";
    }
    return $output;
}
echo system('ls -l');

从代码示例中可以看出,我们创建了一个模仿ls -lLinux 系统调用行为的函数,但只使用安全的 PHP 函数和类。然而,如果我们尝试在 PHP 7 中运行这个函数,会抛出致命的Error。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_disabled_funcs_redefine.php 
PHP Fatal error:  Cannot redeclare system() in /repo/ch06/php8_disabled_funcs_redefine.php on line 17

然而,在 PHP 8 中,我们的函数重新定义成功,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_disabled_funcs_redefine.php 
/repo/ch06/php8_printf_vs_vprintf.php
/repo/ch06/php8_num_str_non_wf_extracted.php
/repo/ch06/php8_vprintf_bc_break.php
/repo/ch06/php7_vprintf_bc_break.php
... not all output is shown ...
/repo/ch06/php7_curly_brace_usage.php
/repo/ch06/php7_compare_num_str_valid.php
/repo/ch06/php8_compare_num_str.php
/repo/ch06/php8_disabled_funcs_redefine.php

现在你已经知道如何处理禁用的函数了。接下来,让我们看看对重要的crypt()函数的更改。

了解crypt()函数的更改

crypt()函数自 PHP 4 版本以来一直是 PHP 哈希生成的重要组成部分。它之所以如此坚固,是因为它有很多选项。如果你的代码直接使用crypt(),你会高兴地注意到,在 PHP 8 中,如果提供了一个不可用的salt值,那么防御加密标准(DES),长期以来被认为是破解的,不再是 PHP 8 的回退!盐有时也被称为初始化向量IV)。

另一个重要的变化涉及rounds值。round就像洗牌一副牌:洗牌的次数越多,随机化程度就越高(除非你在和拉斯维加斯的牌手打交道!)。在密码学中,块类似于卡片。在每一轮中,密码函数被应用于每个块。如果密码函数很简单,哈希可以更快地生成;然而,需要更多的轮次来完全随机化块。

SHA-1(安全哈希算法)系列使用快速但简单的算法,因此需要更多的轮次。另一方面,SHA-2 系列使用更复杂的哈希函数,需要更多的资源,但更少的轮次。

当在 PHP 8 中与CRYPT_SHA256(SHA-2 系列)一起使用 PHP crypt()函数时,crypt()将不再默默解析rounds参数到最接近的限制。相反,crypt()将以*0返回失败,与glibc的行为相匹配。此外,在 PHP 8 中,第二个参数(盐)现在是强制性的。

以下示例说明了在使用crypt()函数时 PHP 7 和 PHP 8 之间的差异:

  1. 首先,我们定义了代表不可用盐值和非法轮次数的变量:
// /repo/ch06/php8_crypt_sha256.php
$password = 'password';
$salt     = str_repeat('+x=', CRYPT_SALT_LENGTH + 1);
$rounds   = 1;
  1. 然后我们使用crypt()函数创建两个哈希。在第一种用法中,提供了一个无效的盐参数后,$default是结果。第二种用法中,$sha256提供了一个有效的盐值,但是一个无效的轮次数:
$default  = crypt($password, $salt);
$sha256   = crypt($password, 
    '$5$rounds=' . $rounds . '$' . $salt . '$');
echo "Default : $default\n";
echo "SHA-256 : $sha256\n";

以下是在 PHP 7 中运行代码示例的输出:

root@php8_tips_php7 [ /repo/ch06 ]# 
php php8_crypt_sha256.php 
PHP Deprecated:  crypt(): Supplied salt is not valid for DES. Possible bug in provided salt format. in /repo/ch06/php8_crypt_sha256.php on line 7
Default : +xj31ZMTZzkVA
SHA-256 : $5$rounds=1000$+x=+x=+x=+x=+x=+
$3Si/vFn6/xmdTdyleJl7Rb9Heg6DWgkRVKS9T0ZZy/B

请注意,PHP 7 会默默修改原始请求。在第一种情况下,crypt()回退到DES(!)。在第二种情况下,PHP 7 会默默地将rounds值从1修改为最接近的限制值1000

另一方面,在 PHP 8 中运行相同的代码会失败并返回*0,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]# 
php php8_crypt_sha256.php 
Default : *0
SHA-256 : *0

正如我们在本书中一再强调的,当 PHP 为您做出假设时,最终您会得到产生不一致结果的糟糕代码。在刚刚展示的代码示例中,最佳实践是定义一个类方法或函数,对其参数施加更大的控制。通过这种方式,您可以验证参数,避免依赖 PHP 的假设。

接下来,我们来看看password_hash()函数的变化。

处理password_hash()的变化

多年来,许多开发人员错误使用了crypt(),因此 PHP 核心团队决定添加一个包装函数password_hash()。这被证明是一个巨大的成功,现在是最广泛使用的安全函数之一。这是password_hash()的函数签名:

password_hash(string $password, mixed $algo, array $options=?) 

目前支持的算法包括bcryptArgon2iArgon2id。建议您使用预定义的算法常量:PASSWORD_BCRYPTPASSWORD_ARGON2IPASSWORD_ARGON2IDPASSWORD_DEFAULT算法当前设置为bcrypt。选项根据算法而异。如果您使用PASSWORD_BCRYPTPASSWORD_DEFAULT算法,选项包括costsalt

传统智慧认为最好使用password_hash()函数创建的随机生成的salt。在 PHP 7 中,salt选项已被弃用,并且在 PHP 8 中被忽略。这不会造成向后兼容的断裂,除非您因其他原因依赖salt

在这个代码示例中,使用了一个非随机的 salt 值:

// /repo/ch06/php8_password_hash.php
$salt = 'xxxxxxxxxxxxxxxxxxxxxx';
$password = 'password';
$hash = password_hash(
    $password, PASSWORD_DEFAULT, ['salt' => $salt]);
echo $hash . "\n";
var_dump(password_get_info($hash));

在 PHP 7 的输出中,发出了一个弃用的Notice

root@php8_tips_php7 [ /repo/ch06 ]# php php8_password_hash.php PHP Deprecated:  password_hash(): Use of the 'salt' option to password_hash is deprecated in /repo/ch06/php8_password_hash.php on line 6
$2y$10$xxxxxxxxxxxxxxxxxxxxxuOd9YtxiLKHM/l98x//sqUV1V2XTZEZ.
/repo/ch06/php8_password_hash.php:8:
array(3) {
  'algo' =>  int(1)
  'algoName' =>  string(6) "bcrypt"
  'options' =>   array(1) { 'cost' => int(10) }
}

您还可以从 PHP 7 的输出中注意到非随机的salt值是清晰可见的。还有一件事要注意的是,当执行password_get_info()时,algo键显示一个整数值,对应于预定义的算法常量之一。

PHP 8 的输出有些不同,如下所示:

root@php8_tips_php8 [ /repo/ch06 ]# php php8_password_hash.php PHP Warning:  password_hash(): The "salt" option has been ignored, since providing a custom salt is no longer supported in /repo/ch06/php8_password_hash.php on line 6
$2y$10$HQNRjL.kCkXaR1ZAOFI3TuBJd11k4YCRWmtrI1B7ZDaX1Jngh9UNW
array(3) {
  ["algo"]=>  string(2) "2y"
  ["algoName"]=>  string(6) "bcrypt"
  ["options"]=>  array(1) { ["cost"]=> int(10) }
}

您可以看到salt值被忽略,而是使用随机的salt。PHP 8 不再发出Notice,而是发出关于使用salt选项的Warning。从输出中还要注意的一点是,当调用password_get_info()时,algorithm键返回的是一个字符串,而不是 PHP 8 中的整数。这是因为预定义的算法常量现在是与在crypt()函数中使用时对应的字符串值。

我们将在下一小节中检查的最后一个函数是assert()

了解assert()的变化

assert()函数通常与测试和诊断相关联。我们在本小节中包含它,因为它经常涉及安全性问题。开发人员有时在尝试跟踪潜在的安全漏洞时使用这个函数。

要使用assert()函数,您必须首先通过添加php.ini文件设置zend.assertions=1来启用它。一旦启用,您可以在应用程序代码的任何地方放置一个或多个assert()函数调用。

理解assert()的用法变化

从 PHP 8 开始,不再可能向assert()提供要评估的字符串参数:相反,您必须提供一个表达式。这可能会导致代码断裂,因为在 PHP 8 中,该字符串被视为一个表达式,因此总是解析为布尔值TRUE。此外,assert.quiet_evalphp.ini指令和与assert_options()一起使用的ASSERT_QUIET_EVAL预定义常量在 PHP 8 中已被移除,因为它们现在没有效果。

为了说明潜在的问题,我们首先通过设置php.ini指令zend.assertions=1来激活断言。然后我们定义一个示例程序如下:

  1. 我们使用ini_set()来导致assert()抛出一个异常。我们还定义了一个变量$pi
// /repo/ch06/php8_assert.php
ini_set('assert.exception', 1);
$pi = 22/7;
echo 'Value of 22/7: ' . $pi . "\n";
echo 'Value of M_PI: ' . M_PI . "\n";
  1. 然后我们尝试一个断言作为一个表达式,$pi === M_PI
try {
    $line    = __LINE__ + 2;
    $message = "Assertion expression failed ${line}\n";
    $result  = assert($pi === M_PI, 
        new AssertionError($message));
    echo ($result) ? "Everything's OK\n"
                   : "We have a problem\n";
} catch (Throwable $t) {
    echo $t->getMessage() . "\n";
}
  1. 在最后的try/catch块中,我们尝试一个断言作为一个字符串:
try {
    $line    = __LINE__ + 2;
    $message = "Assertion string failed ${line}\n";
    $result  = assert('$pi === M_PI', 
        new AssertionError($message));
    echo ($result) ? "Everything's OK\n" 
                   : "We have a problem\n";
} catch (Throwable $t) {
    echo $t->getMessage() . "\n";
}
  1. 当我们在 PHP 7 中运行程序时,一切都按预期工作:
root@php8_tips_php7 [ /repo/ch06 ]# php php8_assert.php 
Value of 22/7: 3.1428571428571
Value of M_PI: 3.1415926535898
Assertion as expression failed on line 18
Assertion as a string failed on line 28
  1. M_PI的值来自数学扩展,比简单地将 22 除以 7 要准确得多!因此,两个断言都会引发异常。然而,在 PHP 8 中,输出显著不同:
root@php8_tips_php8 [ /repo/ch06 ]# php php8_assert.php 
Value of 22/7: 3.1428571428571
Value of M_PI: 3.1415926535898
Assertion as expression failed on line 18
Everything's OK

将字符串作为断言解释为表达式。因为字符串不为空,布尔结果为TRUE,返回了一个错误的结果。如果您的代码依赖于将字符串作为断言的结果,它注定会失败。然而,从 PHP 8 的输出中可以看出,作为表达式的断言在 PHP 8 中与 PHP 7 中的工作方式相同。

提示

最佳实践:不要在生产代码中使用assert()。如果您使用assert(),请始终提供一个表达式,而不是一个字符串。

现在您已经了解了与安全相关函数的更改,我们结束本章。

摘要

在本章中,您了解了 PHP 8 和早期版本之间字符串处理的差异,以及如何开发解决字符串处理差异的解决方法。正如您所了解的,PHP 8 对字符串函数参数的数据类型施加了更大的控制,并且在参数缺失或为空时引入了一致性。正如您所了解的,早期版本的 PHP 存在一个大问题,即在您的代表下悄悄地做出了几个假设,导致了意想不到的结果的巨大潜力。

在本章中,我们还强调了涉及数字字符串和数字数据之间比较的问题。您不仅了解了数字字符串、类型转换和非严格比较,还了解了 PHP 8 如何纠正早期版本中存在的数字字符串处理中的缺陷。本章还涵盖了关于 PHP 8 中几个运算符行为不同的潜在问题。您学会了如何发现潜在问题,并获得了改进代码弹性的最佳实践。

本章还解决了许多 PHP 函数保留对区域设置的依赖性的问题,以及在 PHP 8 中如何解决了这个问题。您了解到,在 PHP 8 中,浮点表示现在是统一的,不再依赖于区域设置。您还了解了 PHP 8 如何处理数组元素以及几个与安全相关的函数的更改。

本章涵盖的技巧和技术提高了对早期版本 PHP 中不一致行为的认识。有了这种新的认识,您将更好地控制 PHP 代码的使用。您现在也更有能力检测可能导致在 PHP 8 迁移后出现潜在代码中断的情况,这使您比其他开发人员更具优势,并最终使您编写的 PHP 代码能够可靠且一致地运行。

下一章将向您展示如何避免涉及对 PHP 扩展进行更改的潜在代码中断。

第七章:在使用 PHP 8 扩展时避免陷阱

PHP:超文本预处理器PHP)语言的主要优势之一是它的扩展。在 PHP 8 中引入的对 PHP 语言的更改也要求扩展开发团队同时更新他们的扩展。在本章中,您将了解对扩展所做的主要更改以及如何避免在将现有应用程序更新到 PHP 8 时出现陷阱。

一旦您完成了对本章中提供的示例代码和主题的审阅,您将能够准备好将任何现有的 PHP 代码迁移到 PHP 8。除了了解各种扩展的变化外,您还将深入了解它们的操作。这将使您能够在使用 PHP 8 中的扩展时做出明智的决策。

本章涵盖的主题包括以下内容:

  • 理解从资源到对象的转变

  • 学习有关可扩展标记语言XML)扩展的变化

  • 避免更新的mbstring扩展出现问题

  • 处理gd扩展的变化

  • 发现Reflection扩展的变化

  • 处理其他扩展的陷阱

技术要求

要查看并运行本章提供的代码示例,以下是最低推荐的硬件要求:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 千兆字节GB)的免费磁盘空间

  • 4 GB 的随机存取存储器RAM

  • 500 千位每秒Kbps)或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅第一章技术要求部分,介绍新的 PHP 8 面向对象编程功能,以及如何构建一个类似于用于演示本书中使用的代码的 Docker 容器。在本书中,我们将您为本书恢复的示例代码的目录称为/repo

本章的源代码位于此处:

[github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices/tree/main/ch07](https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices/tree/main/ch07

)

我们现在可以开始讨论,在 PHP 8 中向对象而不是资源的整体趋势。

理解从资源到对象的转变

PHP 语言一直与资源有着不稳定的关系。资源代表着与外部系统的连接,比如文件句柄或使用客户端 URLcURL)扩展连接到远程网络服务。然而,资源的一个大问题是,它们无法进行数据类型的区分。无法区分文件句柄和cURL连接——它们都被标识为资源。

在 PHP 8 中,已经进行了大力的努力,摆脱资源并用对象替换它们。在 PHP 8 之前这种趋势的最早例子之一是PDO类。当您创建一个PDO实例时,它会自动创建一个数据库连接。从 PHP 8 开始,许多以前产生资源的函数现在产生对象实例。让我们开始讨论一下现在产生对象而不是资源的扩展函数。

PHP 8 扩展资源到对象的迁移

重要的是要知道在 PHP 8 中哪些函数现在产生对象而不是资源。好消息是扩展函数也已经被重写,以适应对象作为参数而不是资源。坏消息是,在初始化资源(现在是对象)并使用is_resource()函数进行成功测试时,可能会出现向后兼容的代码中断。

以下表格总结了以前返回资源但现在返回对象实例的函数:

表 7.1 – PHP 8 资源到对象的迁移

表 7.1 - PHP 8 资源到对象的迁移

表 7.1是一个宝贵的指南,列出了现在产生对象而不是资源的函数。在将任何现有应用程序迁移到 PHP 8 之前,请参考此表。接下来的部分将详细介绍潜在的向后兼容代码中断,并指导您如何调整有问题的代码,然后再介绍其优势。

涉及 is_resource()的潜在代码中断

您可能会遇到的问题是,PHP 8 之前编写的代码假定表 7.1中列出的函数返回一个资源。因此,聪明的开发人员习惯于使用is_resource()来测试连接是否成功建立。

虽然这是一个非常明智的检查方式,但在 PHP 8 升级后,这种技术现在引入了一个向后兼容的代码中断。以下示例演示了这个问题。

在这个代码示例中,为一个外部网站初始化了一个cURL连接。接下来的几行代码使用is_resource()函数测试成功与否:

// //repo/ch07/php7_ext_is_resource.php
$url = 'https://unlikelysource.com/';
$ch  = curl_init($url);
if (is_resource($ch))
    echo "Connection Established\n"
else
    throw new Exception('Unable to establish connection');

以下是来自 PHP 7 的输出,显示成功:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php7_ext_is_resource.php 
Connection Established

在 PHP 8 中运行相同代码的输出并不成功,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php7_ext_is_resource.php 
PHP Fatal error:  Uncaught Exception: Unable to establish connection in /repo/ch07/php7_ext_is_resource.php:9

从 PHP 8 的输出来看,连接已经建立了!但是,由于程序代码正在检查cURL句柄是否是一个资源,因此代码会抛出一个Exception错误。失败的原因是因为返回的是一个CurlHandle实例,而不是一个资源。

在这种情况下,您可以通过在is_resource()的位置使用!empty()(非空)来避免代码中断,并使代码在 PHP 8 和任何早期的 PHP 版本中成功运行,如下所示:

// //repo/ch07/php8_ext_is_resource.php
$url = 'https://unlikelysource.com/';
$ch  = curl_init($url);
if (!empty($ch))
    echo "Connection Established\n";
else
    throw new Exception('Unable to establish connection');
var_dump($ch);

以下是在 PHP 7 中运行代码示例的输出:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php8_ext_is_resource.php 
Connection Established
/repo/ch07/php8_ext_is_resource.php:11:
resource(4) of type (curl)

以下是在 PHP 8 中运行相同代码示例的输出:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php8_ext_is_resource.php 
Connection Established
object(CurlHandle)#1 (0) {}

从这两个输出中可以看到,代码都成功运行了:在 PHP 7 中,$ch是一个资源。在 PHP 8 中,$ch是一个CurlHandle实例。现在您已经了解了关于is_resource()的潜在问题,让我们来看看这种变化带来的优势。

对象相对于资源的优势

在 PHP 8 之前,没有办法在将资源传递到函数或方法中或从函数或方法中返回资源时提供数据类型。产生对象而不是资源的明显优势是,您可以利用对象类型提示。

为了说明这个优势,想象一组实现策略软件设计模式超文本传输协议HTTP)客户端类。其中一种策略涉及使用cURL扩展来发送消息。另一种策略使用 PHP 流,如下所示:

  1. 我们首先定义一个Http/Request类。类构造函数将给定的 URL 解析为其组成部分,如下所示的代码片段所示:
// /repo/src/Http/Request.php
namespace Http;
class Request {
    public $url      = '';
    public $method   = 'GET';
    // not all properties shown
    public $query    = '';
    public function __construct(string $url) {
        $result = [];
        $parsed = parse_url($url);
        $vars   = array_keys(get_object_vars($this));
        foreach ($vars as $name)
            $this->$name = $parsed[$name] ?? '';
        if (!empty($this->query))
            parse_str($this->query, $result);
        $this->query = $result;
        $this->url   = $url;
    }
}
  1. 接下来,我们定义一个CurlStrategy类,它使用cURL扩展来发送消息。请注意,__construct()方法使用了构造函数参数推广。您可能还注意到,我们为$handle参数提供了一个CurlHandle数据类型。这是 PHP 8 中独有的巨大优势,它确保了创建此策略类实例的任何程序都必须提供正确的资源数据类型。代码如下所示:
// /repo/src/Http/Client/CurlStrategy.php
namespace Http\Client;
use CurlHandle;
use Http\Request;
class CurlStrategy {
    public function __construct(
        public CurlHandle $handle) {}
  1. 然后我们定义了用于发送消息的实际逻辑,如下所示:
    public function send(Request $request) {
        // not all code is shown
        curl_setopt($this->handle, 
            CURLOPT_URL, $request->url);
        if (strtolower($request->method) === 'post') {
            $opts = [CURLOPT_POST => 1,
                CURLOPT_POSTFIELDS =>
                    http_build_query($request->query)];
            curl_setopt_array($this->handle, $opts);
        }
        return curl_exec($this->handle);
    }
}
  1. 然后我们可以使用StreamsStrategy类做同样的事情。再次注意下面的代码片段中如何使用类作为构造函数参数类型提示,以确保正确使用该策略:
// /repo/src/Http/Client/StreamsStrategy.php
namespace Http\Client;
use SplFileObject;
use Exception;
use Http\Request;
class StreamsStrategy {
    public function __construct(
        public ?SplFileObject $obj) {}
    // remaining code not shown
  1. 然后我们定义一个调用程序,调用两种策略并提供结果。在设置自动加载后,我们创建一个新的Http\Request实例,并提供一个任意的 URL 作为参数,如下所示:
// //repo/ch07/php8_objs_returned.php
require_once __DIR__ 
    . '/../src/Server/Autoload/Loader.php';
$autoload = new \Server\Autoload\Loader();
use Http\Request;
use Http\Client\{CurlStrategy,StreamsStrategy};
$url = 'https://api.unlikelysource.com/api
    ?city=Livonia&country=US';
$request = new Request($url);
  1. 接下来,我们定义一个StreamsStrategy实例并发送请求,如下所示:
$streams  = new StreamsStrategy();
$response = $streams->send($request);
echo $response;
  1. 然后我们定义一个CurlStrategy实例并发送相同的请求,如下所示的代码片段所示:
$curl     = new CurlStrategy(curl_init());
$response = $curl->send($request);
echo $response;

两种策略的输出是相同的。这里显示了部分输出(请注意,此示例仅适用于 PHP 8!):

root@php8_tips_php8 [ /repo/ch07 ]# 
php php8_objs_returned.php 
CurlStrategy Results:
{"data":[{"id":"1227826","country":"US","postcode":"14487","city":"Livonia","state_prov_name":"New York","state_prov_code":"NY","locality_name":"Livingston","locality_code":"051","region_name":"","region_code":"","latitude":"42.8135","longitude":"-77.6635","accuracy":"4"},{"id":"1227827","country":"US","postcode":"14488","city":"Livonia Center","state_prov_name":"New York","state_prov_code":"NY","locality_name":"Livingston","locality_code":"051","region_name":"","region_code":"","latitude":"42.8215","longitude":"-77.6386","accuracy":"4"}]}

现在让我们来看看资源到对象迁移的另一个方面:它对迭代的影响。

Traversable 到 IteratorAggregate 的迁移

Traversable接口首次在 PHP 5 中引入。它没有方法,主要是为了允许对象使用简单的foreach()循环进行迭代。随着 PHP 的发展不断演进,通常需要获取内部迭代器。因此,在 PHP 8 中,许多以前实现Traversable的类现在改为实现IteratorAggregate

这并不意味着增强的类不再支持Traversable接口固有的能力。相反,IteratorAggregate扩展了Traversable!这一增强意味着您现在可以在任何受影响的类的实例上调用getIterator()。这可能是巨大的好处,因为在 PHP 8 之前,没有办法访问各种扩展中使用的内部迭代器。以下表总结了受此增强影响的扩展和类:

表 7.2 - 现在实现 IteratorAggregate 而不是 Traversable 的类

表 7.2 - 现在实现 IteratorAggregate 而不是 Traversable 的类

在本节中,您了解了 PHP 8 中引入的一个重大变化:向对象而不是资源的趋势。您学到的一个优势是,与资源相比,对象允许您更好地控制。本节涵盖的另一个优势是,PHP 8 中向IteratorAggregate的转变允许访问以前无法访问的内置迭代器。

现在我们将注意力转向基于 XML 的扩展的变化。

学习关于 XML 扩展的变化

XML 版本 1.0 于 1998 年作为万维网联盟W3C)规范引入。XML 与超文本标记语言HTML)有些相似;然而,XML 的主要目的是提供一种使数据对机器和人类都可读的格式化方式。XML 仍然被广泛使用的原因之一是因为它易于理解,并且在表示树形数据方面表现出色。

PHP 提供了许多扩展,允许您消耗和生成 XML 文档。在 PHP 8 中,对许多这些扩展进行了一些更改。在大多数情况下,这些更改都很小;然而,如果您希望成为一个全面了解的 PHP 开发人员,了解这些更改是很重要的。

让我们首先看一下对XMLWriter扩展的变化。

检查 XMLWriter 扩展的差异

所有XMLWriter扩展的过程式函数现在接受并返回XMLWriter对象,而不是资源。然而,如果您查看XMLWriter扩展的官方 PHP 文档,您将看不到有关过程式函数的引用。原因有两个:首先,PHP 语言正在逐渐摆脱离散的过程式函数,转而支持面向对象编程OOP)。

第二个原因是,XMLWriter过程式函数实际上只是XMLWriter OOP 方法的包装!例如,xmlwriter_open_memory()XMLWriter::openMemory()的包装,xmlwriter_text()XMLWriter::text()的包装,依此类推。

如果您真的打算使用过程式编程技术使用XMLWriter扩展,xmlwriter_open_memory()在 PHP 8 中创建一个XMLWriter实例,而不是一个资源。同样,所有XMLWriter扩展的过程式函数都使用XMLWriter实例而不是资源。

与本章中提到的任何扩展一样,现在产生对象实例而不是资源的潜在向后兼容性破坏是可能的。这种破坏的一个例子是,当您使用XMLWriter过程函数和is_resource()来检查是否已创建资源时。我们在这里没有向您展示一个例子,因为问题和解决方案与前一节中描述的相同:使用!empty()而不是is_resource()

使用XMLWriter扩展的 OOP 应用程序编程接口API)而不是过程 API 是一种最佳实践。幸运的是,OOP API 自 PHP 5.1 以来就已经可用。以下是下一个示例中要使用的示例 XML 文件:

<?xml version="1.0" encoding="UTF-8"?>
<fruit>
    <item>Apple</item>
    <item>Banana</item>
</fruit>

这里显示的示例在 PHP 7 和 8 中都可以工作。此示例的目的是使用XMLWriter扩展来构建先前显示的 XML 文档。以下是完成此操作的步骤:

  1. 我们首先创建一个XMLWriter实例。然后打开到共享内存的连接,并初始化 XML 文档类型,如下所示:
// //repo/ch07/php8_xml_writer.php
$xml = new XMLWriter();
$xml->openMemory();
$xml->startDocument('1.0', 'UTF-8');
  1. 接下来,我们使用startElement()来初始化fruit根节点,并添加一个值为Apple的子节点项,如下所示:
$xml->startElement('fruit');
$xml->startElement('item');
$xml->text('Apple');
$xml->endElement();
  1. 接下来,我们添加另一个值为Banana的子节点项,如下所示:
$xml->startElement('item');
$xml->text('Banana');
$xml->endElement();
  1. 最后,我们关闭fruit根节点并结束 XML 文档。以下代码片段中的最后一个命令显示当前的 XML 文档:
$xml->endElement();
$xml->endDocument();
echo $xml->outputMemory();

以下是在 PHP 7 中运行的示例程序的输出:

root@php8_tips_php7 [ /repo/ch07 ]# php php8_xml_writer.php 
<?xml version="1.0" encoding="UTF-8"?>
<fruit><item>Apple</item><item>Banana</item></fruit>

如您所见,生成了所需的 XML 文档。如果我们在 PHP 8 中运行相同的程序,结果是相同的(未显示)。

现在我们将注意力转向SimpleXML扩展的更改。

处理 SimpleXML 扩展的更改

SimpleXML扩展是面向对象的,被广泛使用。因此,了解在 PHP 8 中对该扩展进行的一些重大更改是至关重要的。好消息是,您不需要重写任何代码!更好的消息是,这些更改大大改善了SimpleXML扩展的功能。

从 PHP 8 开始,SimpleXMLElement类现在实现了标准 PHP 库SPLRecursiveIterator接口,并包括SimpleXMLIterator类的功能。在 PHP 8 中,SimpleXMLIterator现在是SimpleXMLElement的一个空扩展。这个看似简单的更新在考虑到 XML 通常用于表示复杂的树形数据时具有重大意义。

例如,看一下温莎王室家族树的部分视图,如下所示:

图 7.1 - 复杂树形数据的示例

图 7.1 - 复杂树形数据的示例

如果我们要使用 XML 对其进行建模,文档可能如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<!-- /repo/ch07/tree.xml -->
<family>
  <branch name="Windsor">
    <descendent gender="M">George V</descendent>
    <spouse gender="F">Mary of Treck</spouse>
    <branch name="George V">
      <descendent gender="M">George VI</descendent>
      <spouse gender="F">Elizabeth Bowes-Lyon</spouse>
      <branch name="George VI">
        <descendent gender="F">Elizabeth II</descendent>
        <spouse gender="M">Prince Philip</spouse>
        <branch name="Elizabeth II">
          <descendent gender="M">Prince Charles</descendent>
          <spouse gender="F">Diana Spencer</spouse>
          <spouse gender="F">Camilla Parker Bowles</spouse>
          <branch name="Prince Charles">
            <descendent gender="M">William</descendent>
            <spouse gender="F">Kate Middleton</spouse>
          </branch>
          <!-- not all nodes are shown -->
        </branch>
      </branch>
    </branch>
  </branch>
</family>

然后,我们编写代码来解析树。然而,在 PHP 8 之前的版本中,我们需要定义一个递归函数来解析整个树。为此,我们将按照以下步骤进行:

  1. 我们首先定义一个递归函数,显示后代的姓名和配偶(如果有),如下面的代码片段所示。该函数还识别后代的性别,并检查是否有子女。如果后者为true,则函数会调用自身:
function recurse($branch) {
    foreach ($branch as $node) {
        echo $node->descendent;
        echo ($node->descendent['gender'] == 'F')
             ? ', daughter of '
             : ', son of ';
        echo $node['name'];
        if (empty($node->spouse)) echo "\n";
        else echo ", married to {$node->spouse}\n";
        if (!empty($node->branch)) 
            recurse($node->branch);
    }
}
  1. 然后我们从外部 XML 文件创建一个SimpleXMLElement实例,并调用递归函数,如下所示:
// //repo/ch07/php7_simple_xml.php
$fn = __DIR__ . '/includes/tree.xml';
$xml = simplexml_load_file($fn);
recurse($xml);

这段代码块在 PHP 7 和 PHP 8 中都可以工作。以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch07 ]# php php7_simple_xml.php
George V, son of Windsor, married to Mary of Treck
George VI, son of George V, married to Elizabeth Bowes-Lyon
Elizabeth II, daughter of George VI, married to Philip
Prince Charles, son of Elizabeth II, married to Diana Spencer
William, son of Prince Charles, married to Kate Middleton
Harry, son of Prince Charles, married to Meghan Markle
Princess Anne, daughter of Elizabeth II, married to M.Phillips
Princess Margaret, daughter of George VI, married to A.Jones
Edward VIII, son of George V, married to Wallis Simpson
Princess Mary, daughter of George V, married to H.Lascelles
Prince Henry, son of George V, married to Lady Alice Montegu
Prince George, son of George V, married to Princess Marina
Prince John, son of George V

然而,在 PHP 8 中,由于SimpleXMLElement现在实现了RecursiveIterator,生成相同结果的代码更简单了。

  1. 与之前显示的示例一样,我们从外部文件定义了一个SimpleXMLElement实例。但是,我们无需定义递归函数,我们只需要定义一个RecursiveIteratorIterator实例,如下所示:
// //repo/ch07/php8_simple_xml.php
$fn = __DIR__ . '/includes/tree.xml';
$xml = simplexml_load_file($fn);
$iter = new RecursiveIteratorIterator($xml,
    RecursiveIteratorIterator::SELF_FIRST);
  1. 之后,我们只需要一个简单的foreach()循环,内部逻辑与前面的示例相同。无需检查分支节点是否存在,也不需要递归 - 这由RecursiveIteratorIterator实例处理!您需要的代码如下所示:
foreach ($iter as $branch) {
    if (!empty($branch->descendent)) {
        echo $branch->descendent;
        echo ($branch->descendent['gender'] == 'F')
             ? ', daughter of '
             : ', son of ';
        echo $branch['name'];
        if (empty($branch->spouse)) echo "\n";
        else echo ", married to {$branch->spouse}\n";
    }
}

在 PHP 8 中运行此代码示例的输出如下所示。如您所见,输出完全相同:

root@php8_tips_php8 [ /repo/ch07 ]# php php8_simple_xml.php 
George V, son of Windsor, married to Mary of Treck
George VI, son of George V, married to Elizabeth Bowes-Lyon
Elizabeth II, daughter of George VI, married to Philip
Prince Charles, son of Elizabeth II, married to Diana Spencer
William, son of Prince Charles, married to Kate Middleton
Harry, son of Prince Charles, married to Meghan Markle
Princess Anne, daughter of Elizabeth II, married to M.Phillips
Princess Margaret, daughter of George VI, married to A.Jones
Edward VIII, son of George V, married to Wallis Simpson
Princess Mary, daughter of George V, married to H.Lascelles
Prince Henry, son of George V, married to Lady Alice Montegu
Prince George, son of George V, married to Princess Marina
Prince John, son of George V

重要提示

请注意,在使用 Docker 容器运行这些示例时,这里显示的输出已经稍作修改以适应页面宽度。

现在让我们来看看其他 XML 扩展的更改。

了解其他 XML 扩展的更改

其他 PHP 8 XML 扩展已经进行了一些更改。在大多数情况下,这些更改都很小,并且不会对向后兼容的代码造成重大潜在破坏。然而,如果我们不解决这些额外的更改,那就不尽职了。我们建议您查看本小节中的其余更改,以提高您的意识。使用这些 XML 扩展将使您能够在 PHP 8 更新后更有效地排除应用程序代码的不一致行为。

libxml 扩展的更改

libxml扩展利用Expat C 库,提供了各种 PHP XML 扩展使用的 XML 解析功能(libexpat.github.io/)。

您的服务器上安装的libxml版本有新的要求。在运行 PHP 8 时,最低版本必须为 2.9.0(或更高)。此更新要求的主要好处之一是增加对XML 外部实体XXE)处理攻击的保护。

推荐的libxml最低版本禁用了依赖libxml扩展加载外部 XML 实体的 PHP XML 扩展的能力。这反过来减少了对 XXE 攻击的昂贵和耗时的额外步骤的需求。

提示

有关 XXE 攻击的更多信息,请参阅开放式 Web 应用安全项目OWASPowasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing

XMLReader 扩展的更改

XMLReader扩展补充了XMLWriter扩展。XMLWriter扩展旨在生成 XML 文档,而XMLReader扩展旨在读取。

现在,XMLReader::open()XMLReader::xml()两个方法被定义为静态方法。您仍然可以创建XMLReader实例,但如果您扩展XMLReader并覆盖其中任何一个方法,请确保将它们声明为静态方法。

XMLParser 扩展的更改

XMLParser扩展是最古老的 PHP XML 扩展之一。因此,它几乎完全由过程函数组成,而不是类和方法。然而,在 PHP 8 中,这个扩展遵循了向生成对象而不是资源的趋势。因此,当您运行xml_parser_create()xml_parser_create_ns()时,将创建一个XMLParser实例,而不是一个资源。

如在涉及 is_resource()的潜在代码破坏部分中所述,您只需要用!empty()替换任何使用is_resource()的检查。资源到对象迁移的另一个副作用是使xml_parser_free()函数变得多余。要停用解析器,只需使用XmlParser对象。

现在您已经了解了与 XML 扩展相关的更改,这将帮助您更有效地解析和管理 XML 数据。通过利用本节中提到的新功能,您可以编写比在 PHP 8 之前更高效并且性能更好的代码。现在让我们来看看mbstring扩展。

避免与更新后的 mbstring 扩展出现问题

mbstring扩展首次引入于 PHP 4,并且自那时以来一直是语言的活跃部分。该扩展的最初目的是为各种日语字符编码系统提供支持。自那时以来,已添加了对各种其他编码的支持,其中最显着的是对基于通用编码字符集 2UCS-2)、UCS-4Unicode 转换格式 8UTF-8)、UTF-16UTF-32Shift 日本工业标准SJIS)和国际标准化组织 8859ISO-8859)等编码的支持。

如果您不确定服务器支持哪些编码,只需运行mb_list_encodings()命令,如下所示(显示部分输出):

root@php8_tips_php7 [ /repo/ch07 ]# 
php -r "var_dump(mb_list_encodings());"
Command line code:1:
array(87) {
  ... only selected output is shown ...
  [14] =>  string(7) "UCS-4BE"
  [16] =>  string(5) "UCS-2"
  [19] =>  string(6) "UTF-32"
  [22] =>  string(6) "UTF-16"
  [25] =>  string(5) "UTF-8"
  [26] =>  string(5) "UTF-7"
  [27] =>  string(9) "UTF7-IMAP"
  [28] =>  string(5) "ASCII"
  [29] =>  string(6) "EUC-JP"
  [30] =>  string(4) "SJIS"
  [31] =>  string(9) "eucJP-win"
  [32] =>  string(11) "EUC-JP-2004"
  [76] =>  string(6) "KOI8-R"
  [78] =>  string(9) "ArmSCII-8"
  [79] =>  string(5) "CP850"
  [80] =>  string(6) "JIS-ms"
  [81] =>  string(16) "ISO-2022-JP-2004"
  [86] =>  string(7) "CP50222"
}

从前面的输出中可以看出,在我们用于本书的 PHP 7.1 Docker 容器中,支持 87 种编码。在 PHP 8.0 Docker 容器中(未显示输出),支持 80 种编码。现在让我们来看一下 PHP 8 中引入的更改,首先是mb_str*()函数。

发现mb_str*()函数中的 needle-argument 差异

第六章中,了解 PHP 8 的功能差异,您了解到 PHP 8 如何改变了核心str*pos()str*str()str*chr()函数中的needle-argument 处理。两个主要的 needle-argument 差异是能够接受空的 needle 参数和严格的类型检查,以确保 needle 参数只是一个字符串。为了保持一致性,PHP 8 在相应的mb_str*()函数中引入了相同的更改。

让我们首先看一下空的 needle-argument 处理。

mb_str*()函数空 needle-argument 处理

为了使mbstring扩展与核心字符串函数的更改保持一致,以下mbstring扩展函数现在允许空的 needle 参数。重要的是要注意,这并不意味着参数可以被省略或是可选的!这个更改的意思是,作为 needle 参数提供的任何值现在也可以包括被认为是的值。了解 PHP 认为什么是空的一个好而快速的方法可以在empty()函数的文档中找到(www.php.net/empty)。以下是现在允许空的 needle-argument 值的mbstring函数列表:

  • mb_strpos()

  • mb_strrpos()

  • mb_stripos()

  • mb_strripos()

  • mb_strstr()

  • mb_stristr()

  • mb_strrchr()

  • mb_strrichr()

提示

这里提到的八个mbstring扩展函数与其核心 PHP 对应函数完全相同。有关这些函数的更多信息,请参阅此参考文档:www.php.net/manual/en/ref.mbstring.php

接下来的简短代码示例说明了上述八个函数中的空 needle 处理。以下是导致这一步的步骤:

  1. 首先,我们初始化一个多字节文本字符串。在下面的示例中,这是快速的棕色狐狸跳过了篱笆的泰语翻译。needle 参数设置为NULL,并初始化要测试的函数数组:
// /repo/ch07/php8_mb_string_empty_needle.php
$text   = 'สุนัขจิ้งจอกสีน้ำตาลกระโดดข้ามรั้วอย่างรวดเร็ว';
$needle = NULL;
$funcs  = ['mb_strpos',   'mb_strrpos', 'mb_stripos',
           'mb_strripos', 'mb_strstr', 'mb_stristr',
           'mb_strrchr',  'mb_strrichr'];
  1. 然后我们定义一个printf()模式,并循环遍历要测试的函数。对于每个函数调用,我们提供文本,然后是一个空的 needle 参数,如下所示:
$patt = "Testing: %12s : %s\n";
foreach ($funcs as $str)
    printf($patt, $str, $str($text, $needle));

PHP 7 的输出如下所示:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php8_mb_string_empty_needle.php
PHP Warning:  mb_strpos(): Empty delimiter in /repo/ch07/php8_mb_string_empty_needle.php on line 12
Testing:    mb_strpos : 
Testing:   mb_strrpos : 
PHP Warning:  mb_stripos(): Empty delimiter in /repo/ch07/php8_mb_string_empty_needle.php on line 12
Testing:   mb_stripos : 
Testing:  mb_strripos : 
PHP Warning:  mb_strstr(): Empty delimiter in /repo/ch07/php8_mb_string_empty_needle.php on line 12
Testing:    mb_strstr : 
PHP Warning:  mb_stristr(): Empty delimiter in /repo/ch07/php8_mb_string_empty_needle.php on line 12
Testing:   mb_stristr : 
Testing:   mb_strrchr : 
Testing:  mb_strrichr : 

正如您所看到的,输出为空,并且在某些情况下会发出Warning消息。PHP 8 中的输出与预期的完全不同,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php8_mb_string_empty_needle.php
Testing:    mb_strpos : 0
Testing:   mb_strrpos : 46
Testing:   mb_stripos : 0
Testing:  mb_strripos : 46
Testing:    mb_strstr : สุนัขจิ้งจอกสีน้ำตาลกระโดดข้ามรั้วอย่างรวดเร็ว
Testing:   mb_stristr : สุนัขจิ้งจอกสีน้ำตาลกระโดดข้ามรั้วอย่างรวดเร็ว
Testing:   mb_strrchr : 
Testing:  mb_strrichr : 

有趣的是,当这段代码在 PHP 8 中运行时,空的针参数对于mb_strpos()mb_stripos()返回整数0,对于mb_strrpos()mb_strripos()返回整数46。在 PHP 8 中,空的针参数在这种情况下被解释为字符串的开头或结尾。对于mb_strstr()mb_stristr()的结果是整个字符串。

mb_str*()函数数据类型检查

为了与核心str*()函数保持一致,相应的mb_str*()函数中的针参数必须是字符串类型。如果你提供的是美国信息交换标准代码(ASCII)值而不是字符串,受影响的函数现在会抛出ArgumentTypeError错误。本小节不提供示例,因为[第六章](B16992_06_Final_JC_ePub.xhtml#_idTextAnchor129),理解 PHP 8 的功能差异,已经提供了核心str*()函数中这种差异的示例。

mb_strrpos()的差异

在早期的 PHP 版本中,你可以将字符编码作为mb_strrpos()的第三个参数而不是偏移量。这种不良做法在 PHP 8 中不再支持。相反,你可以将0作为第三个参数,或者考虑使用 PHP 8 的命名参数(在[第一章](B16992_01_Final_JC_ePub.xhtml#_idTextAnchor013),介绍新的 PHP 8 面向对象特性理解命名参数部分讨论)来避免必须提供一个可选参数的值。

让我们现在看一个代码示例,演示了 PHP 7 和 PHP 8 处理方式的差异。按照以下步骤进行:

  1. 我们首先定义一个常量来表示我们希望使用的字符编码。分配一个代表The quick brown fox jumped over the fence泰语翻译的文本字符串。然后我们使用mb_convert_encoding()来确保使用正确的编码。代码如下所示:
// /repo/ch07/php7_mb_string_strpos.php
define('ENCODING', 'UTF-8');
$text    = 'สุนัขจิ้งจอกสีน้ำตาลกระโดดข้ามรั้วอย่างรวดเร็ว';
$encoded = mb_convert_encoding($text, ENCODING);
  1. 然后我们将fence的泰语翻译分配给$needle,并输出字符串的长度和$needle在文本中的位置。然后我们调用mb_strrpos()来找到$needle的最后一次出现。请注意在以下代码片段中,我们故意遵循了使用编码作为第三个参数而不是偏移量的不良做法:
$needle  = 'รั้ว';
echo 'String Length: ' 
    . mb_strlen($encoded, ENCODING) . "\n";
echo 'Substring Pos: ' 
    . mb_strrpos($encoded, $needle, ENCODING) . "\n";

这个代码示例在 PHP 7 中完美运行,如下所示:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php7_mb_string_strpos.php
String Length: 46
Substring Pos: 30

从前面的输出中可以看到,多字节字符串的长度为46,针的位置为30。然而,在 PHP 8 中,我们得到了一个致命的Uncaught TypeError消息,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php7_mb_string_strpos.php
String Length: 46
PHP Fatal error:  Uncaught TypeError: mb_strrpos(): Argument #3 ($offset) must be of type int, string given in /repo/ch07/php7_mb_string_strpos.php:14

从 PHP 8 的输出中可以看到,mb_strrpos()的第三个参数必须是一个整数形式的偏移值。重写这个例子的一个简单方法是利用 PHP 8 的命名参数。以下是重写的代码行:

echo 'Substring Pos: ' 
    . mb_strrpos($encoded, $needle, encoding:ENCODING) . "\n";

输出与 PHP 7 示例相同,这里不再显示。现在让我们转向mbstring扩展的正则表达式regex)处理差异。

检查 mb_ereg*()函数的变化

mb_ereg*()函数族允许对使用多字节字符集编码的字符串进行regex处理。相比之下,核心 PHP 语言提供了现代和更为更新的功能的Perl 兼容正则表达式PCRE)函数族。

当使用 PCRE 函数时,如果在正则表达式模式中添加u(小写字母 U)修饰符,则接受任何 UTF-8 编码的多字节字符串。然而,UTF-8 是唯一被接受的多字节字符编码。如果你处理其他字符编码并希望执行正则表达式功能,你需要将其转换为 UTF-8,或者使用mb_ereg*()函数族。现在让我们看看mb_ereg*()函数族的一些变化。

PHP 8 中需要 Oniguruma 库

这一系列函数的一个变化是你的 PHP 安装是如何编译的。在 PHP 8 中,你的操作系统必须提供libonig库。这个库提供了Oniguruma功能。(更多信息请参见 https://github.com/kkos/oniguruma。)旧的--with-onigPHP 源码编译配置选项已经被移除,取而代之的是使用pkg-config来检测libonig

mb_ereg_replace()的变化

以前,你可以将整数作为mb_ereg_replace()的参数。这个参数被解释为ASCII 码点。在 PHP 8 中,这样的参数现在被强制转换为string。如果你需要 ASCII 码点,你需要使用mb_chr()。由于强制转换为string是静默进行的,这可能会导致向后兼容的代码中断,因为你不会看到任何NoticeWarning消息。

以下程序代码示例说明了 PHP 7 和 PHP 8 之间的区别。我们将按照以下步骤进行:

  1. 首先,我们定义要使用的编码,并将“Two quick brown foxes jumped over the fence”的泰语翻译作为多字节字符串赋给$text。接下来,我们使用mb_convert_encoding()来确保使用正确的编码。然后,我们使用mb_regex_encoding()mb_ereg*设置为所选的编码。代码如下所示:
// /repo/ch07/php7_mb_string_strpos.php
define('ENCODING', 'UTF-8');
$text = 'สุนัขจิ้งจอกสีน้ำตาล 2 ตัวกระโดดข้ามรั้ว';
$str  = mb_convert_encoding($text, ENCODING);
mb_regex_encoding(ENCODING);
  1. 然后我们调用mb_ereg_replace(),并将整数值50作为第一个参数,并用字符串"3"替换它。原始字符串和修改后的字符串都被输出。你可以在这里查看代码:
$mod1 = mb_ereg_replace(50, '3', $str);
echo "Original: $str\n";
echo "Modified: $mod1\n";

请注意,mb_ereg_replace()的第一个参数应该是一个字符串,但我们却提供了一个整数。在 PHP 8 之前的mbstring扩展版本中,如果提供整数作为第一个参数,它会被视为 ASCII 码点。

如果我们在 PHP 7 中运行这个代码示例,数字50会被解释为"2"的 ASCII 码点值,正如我们所期望的那样,如下所示:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php7_mb_string_ereg_replace.php 
Original: สุนัขจิ้งจอกสีน้ำตาล 2 ตัวกระโดดข้ามรั้ว
Modified: สุนัขจิ้งจอกสีน้ำตาล 3 ตัวกระโดดข้ามรั้ว

从前面的输出中可以看到,数字2被数字3替换。然而,在 PHP 8 中,数字50被强制转换为字符串。由于源字符串不包含数字50,所以没有进行替换,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php7_mb_string_ereg_replace.php 
Original: สุนัขจิ้งจอกสีน้ำตาล 2 ตัวกระโดดข้ามรั้ว
Modified: สุนัขจิ้งจอกสีน้ำตาล 2 ตัวกระโดดข้ามรั้ว

这里的危险在于,如果你的代码依赖于这种静默解释过程,你的应用可能会失败或表现出不一致的行为。你还会注意到缺少NoticeWarning消息。PHP 8 依赖于开发人员提供正确的参数!

最佳实践,如果你确实需要使用 ASCII 码点,就要使用mb_chr()来生成所需的搜索字符串。修改后的代码示例可能如下所示:

$mod1 = mb_ereg_replace(mb_chr(50), '3', $str);

现在你已经知道了mbstring扩展中的变化。没有这些信息,你可能会轻易地写出错误的代码。不了解这些信息的开发人员可能会在 PHP 8 中犯错,比如假设mbstring的别名仍然存在。这样错误的理解很容易导致在 PHP 8 迁移后花费数小时来追踪程序代码中的错误。

现在是时候看另一个有重大变化的扩展了:GD 扩展。

处理 GD 扩展的变化

GD 扩展是一个图像处理扩展,利用了GD库。GD 最初代表GIF Draw。奇怪的是,GD库在 Unisys 撤销生成 GIF 时使用的压缩技术的开源许可证后,不得不撤回对Graphics Interchange FormatGIF)的支持。然而,2004 年之后,Unisys 对这项技术的专利已经过期,GIF 支持得以恢复。截至目前,PHP GD扩展支持Joint Photographic Experts GroupJPEGJPG)、Portable Network GraphicPNG)、GIFX BitMapXBM)、X PixMapXPM)、Wireless BitmapWBMP)、WebPBitmapBMP)格式。

提示

有关 GD 库的更多信息,请参阅libgd.github.io/

现在让我们看看资源到对象迁移对GD扩展的影响。

GD 扩展资源到对象迁移

与先前使用资源的其他 PHP 扩展一样,GD扩展也主要从resource迁移到object。如PHP 8 扩展资源到对象迁移部分所述,所有imagecreate*()函数现在产生GdImage对象而不是资源。

举个例子,这可能在 PHP 8 迁移后导致代码中断,可以在两个不同的浏览器标签中运行这些示例(在本地计算机上),并比较差异。首先,我们使用此 URL 运行 PHP 7 示例:172.16.0.77/ch07/php7_gd_is_resource.php。这是结果:

图 7.2 - PHP 7 GD 图像资源

图 7.2 - PHP 7 GD 图像资源

从上述输出中可以看出,识别出了一个resource扩展,但没有描述性信息。现在,让我们使用此 URL 运行 PHP 8 示例:http://172.16.0.88/ch07/php8_gd_is_resource.php。这是结果:

图 7.3 - PHP 8 GD 图像对象实例

图 7.3 - PHP 8 GD 图像对象实例

从 PHP 8 的输出中不仅可以识别返回类型为GdImage实例,还可以在图像下方显示描述性信息。

现在我们将注意力转向其他GD扩展的变化。

GD 扩展编译标志更改

GD扩展不仅利用 GD 库,还利用一些支持库。这些库需要提供对各种图形格式的支持。以前,在从源代码编译自定义版本的 PHP 时,您需要指定JPEGPNGXPMVPX格式的库位置。此外,由于压缩是减少最终文件大小的重要方面,因此还需要ZLIB的位置。

在从源代码编译 PHP 8 时,有一些重要的配置标志更改,这些更改首先出现在 PHP 7.4 中,随后被带入 PHP 8。主要变化是您不再需要指定库所在的目录。PHP 8 现在使用pkg-config操作系统等效工具来定位库。

以下表总结了编译标志的更改。这些标志与configure实用程序一起使用,就在实际编译过程之前:

表 7.3 - GD 编译选项更改

表 7.3 - GD 编译选项更改

您会注意到表中,大部分--with-*-dir选项都被替换为--with-*。此外,PNGZLIB支持现在是自动的;但是,您必须在操作系统上安装libpngzlib

我们现在将看看GD扩展的其他较小变化。

其他 GD 扩展变化

除了前一节描述的主要变化外,还发生了一些其他较小的变化,包括函数签名的变化和一个新函数。让我们从查看imagecropauto()函数开始讨论。

这是imagecropauto()的旧函数签名:

imagecropauto(resource $image , int $mode = -1, 
              float $threshold = .5 , int $color = -1 )

在 PHP 8 中,$image参数现在是GdImage类型。$mode参数现在默认为IMG_CROP_DEFAULT预定义常量。

另一个变化影响了imagepolygon()imageopenpolygon()imagefilledpolygon()函数。这是imagepolygon()的旧函数签名:

imagepolygon(resource $image, array $points, 
             int $num_points, int $color)

在 PHP 8 中,$num_points参数现在是可选的。如果省略,点数将计算如下:count($points)/2。但是,这意味着$points数组中的元素数量必须是偶数!

最后一个重要的变化是添加了一个新函数imagegetinterpolation()。这是它的函数签名:

imagegetinterpolation(GdImage $image) : int

返回值是一个整数,本身并不是非常有用。但是,如果您查看imagesetinterpolation()函数的文档(https://www.php.net/manual/en/function.imagesetinterpolation.php),您将看到一系列插值方法代码以及解释。

现在您已经了解了GD扩展引入的更改。接下来我们将检查Reflection扩展的更改。

发现 Reflection 扩展的更改

Reflection 扩展用于对对象、类、方法和函数等进行内省ReflectionClassReflectionObject分别提供有关类或对象实例的信息。ReflectionFunction提供有关过程级函数的信息。此外,Reflection扩展还有一组由刚才提到的主要类产生的辅助类。这些辅助类包括ReflectionMethod,由ReflectionClass::getMethod()产生,ReflectionProperty,由ReflectionClass::getProperty()产生,等等。

您可能会想:谁使用这个扩展?答案是:任何需要对外部一组类执行分析的应用。这可能包括执行自动代码生成测试文档生成的软件。执行hydration(从数组中填充对象)的类也受益于Reflection扩展。

提示

我们在书中没有足够的空间来涵盖每一个Reflection扩展类和方法。如果您希望获得更多信息,请查看这里的文档参考:www.php.net/manual/en/book.reflection.php

现在让我们来看一个Reflection扩展的使用示例。

Reflection 扩展的使用

我们将展示一个代码示例,演示了如何使用Reflection扩展来生成docblocksdocblock是使用特殊语法来表示方法目的、其传入参数和返回值的 PHP 注释)。以下是导致这一步的步骤:

  1. 我们首先定义一个__construct()方法,创建目标类的ReflectionClass实例,如下所示:
// /repo/src/Services/DocBlockChecker.php
namespace Services;
use ReflectionClass;
class DocBlockChecker {
    public $target = '';    // class to check
    public $reflect = NULL; // ReflectionClass instance
    public function __construct(string $target) {
        $this->target = $target;
        $this->reflect = new ReflectionClass($target);
    }
  1. 然后我们定义一个check()方法,获取所有类方法,返回一个ReflectionMethod实例数组,如下所示:
    public function check() {
        $methods = [];
        $list = $this->reflect->getMethods();
  1. 然后我们循环遍历所有方法,并使用getDocComment()来检查是否已经存在docblock,如下所示:
      foreach ($list as $refMeth) {
          $docBlock = $refMeth->getDocComment();
  1. 如果docblock不存在,我们将开始一个新的docblock,然后调用getParameters(),它返回一个ReflectionParameter实例数组,如下面的代码片段所示:
          if (!$docBlock) {
              $docBlock = "/**\n * " 
                  . $refMeth->getName() . "\n";
              $params = $refMeth->getParameters();
  1. 如果我们有参数,我们收集用于显示的信息,如下所示:
            if ($params) {
              foreach ($params as $refParm) {
                $type = $refParm->getType() 
                      ?? 'mixed';
                $type = (string) $type;
                $name = $refParm->getName();
                $default = '';
                if (!$refParm->isVariadic() 
                 && $refParm->isOptional()) {
                  $default=$refParm->getDefaultValue(); }
                if ($default === '') {
                  $default = "(empty string)"; }
                $docBlock .= " * @param $type "
                  . "\${$name} : $default\n";
              }
          }
  1. 然后我们设置返回类型,并将docblock分配给一个$methods数组,然后返回,如下所示:
           if ($refMeth->isConstructor())
               $return = 'void';
            else
                $return = $refMeth->getReturnType() 
                          ?? 'mixed';
            $docBlock .= " * @return $return\n";
            $docBlock .= " */\n";
        }
        $methods[$refMeth->getName()] = $docBlock;
    }
    return $methods;
  }
}
  1. 现在新的docblock检查类已经完成,我们定义一个调用程序,如下面的代码片段所示。调用程序针对/repo/src/Php7/Reflection/Test.php类(此处未显示)。这个类具有一些带有参数和返回值的方法的混合:
// //repo/ch07/php7_reflection_usage.php
$target = 'Php7\Reflection\Test';
require_once __DIR__ 
    . '/../src/Server/Autoload/Loader.php';
use Server\Autoload\Loader;
use Services\DocBlockChecker;
|$autoload = new Loader();
$checker = new DocBlockChecker($target);
var_dump($checker->check());

调用程序的输出如下所示:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php7_reflection_usage.php 
/repo/ch07/php7_reflection_usage.php:10:
array(4) {
  '__construct' =>  string(75) 
"/**
 * __construct
 * @param PDO $pdo : (empty string)
 * @return void
 */"
  'fetchAll' =>  string(41) 
"/**
 * fetchAll
 * @return Generator
 */"
  'fetchByName' =>  string(80) 
"/**
 * fetchByName
 * @param string $name : (empty string)
 * @return array
 */"
  'fetchLastId' =>  string(38) 
"/**
 * fetchLastId
 * @return int
 */"
}

正如您所看到的,这个类构成了潜在的自动文档或代码生成应用的基础。

现在让我们来看一下Reflection扩展的改进。

了解 Reflection 扩展的改进

Reflection扩展还进行了一些改进,这些改进可能对您很重要。请记住,虽然使用Reflection扩展的开发人员数量有限,但您可能有一天会发现自己在处理使用此扩展的代码的情况。如果您在 PHP 8 升级后注意到异常行为,本节介绍的内容将让您在故障排除过程中提前了解情况。

ReflectionType 修改

在 PHP 8 中,ReflectionType类现在是抽象的。当您使用ReflectionProperty::getType()ReflectionFunction::getReturnType()方法时,您可能会注意到返回一个ReflectionNamedType实例。这种变化不会影响您程序代码的正常运行,除非您依赖于返回ReflectionType实例。但是,ReflectionNamedType扩展了ReflectionType,因此instanceof操作不会受到影响。

值得注意的是,isBuiltIn()方法已经从ReflectionType移动到ReflectionNamedType。同样,由于ReflectionNamedType扩展了ReflectionType,这不应该在您当前的代码中造成任何向后兼容的问题。

ReflectionParameter::DefaultValue方法增强

在早期版本的 PHP 中,关于默认值的ReflectionParameter方法无法反映内部 PHP 函数。在 PHP 8 中,以下ReflectionParameter方法现在也能够从内部函数返回默认值信息:

  • getDefaultValue()

  • getDefaultValueConstantName()

  • isDefaultValueAvailable()

  • isDefaultValueConstant()

从列表中可以看出,方法名称是不言自明的。我们现在将展示一个使用这些增强功能的代码示例。以下是导致这一步的步骤:

  1. 首先,我们定义一个函数,接受一个ReflectionParameter实例,并返回一个包含参数名称和默认值的数组,如下所示:
// /repo/ch07/php8_reflection_parms_defaults.php
$func = function (ReflectionParameter $parm) {
    $name = $parm->getName();
    $opts = NULL;
    if ($parm->isDefaultValueAvailable())
        $opts = $parm->getDefaultValue();
  1. 接下来,我们定义一个switch()语句来清理选项,如下所示:
    switch (TRUE) {
        case (is_array($opts)) :
            $tmp = '';
            foreach ($opts as $key => $val)
                $tmp .= $key . ':' . $val . ',';
            $opts = substr($tmp, 0, -1);
            break;
        case (is_bool($opts)) :
            $opts = ($opts) ? 'TRUE' : 'FALSE';
            break;
        case ($opts === '') :
            $opts = "''";
            break;
        default :
            $opts = 'No Default';
    }
    return [$name, $opts];
};
  1. 然后我们确定要反射的函数并提取其参数。在下面的例子中,我们反射setcookie()
$test = 'setcookie';
$ref = new ReflectionFunction($test);
$parms = $ref->getParameters();
  1. 然后,我们循环遍历ReflectionParameter实例的数组并产生输出,如下所示:
$patt = "%18s : %s\n";
foreach ($parms as $obj)
    vprintf($patt, $func($obj));

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php8_reflection_parms_defaults.php 
Reflecting on setcookie
         Parameter : Default(s)
      ------------ : ------------
              name : No Default
             value : No Default
           expires : No Default
              path : No Default
            domain : No Default
            secure : No Default
          httponly : No Default

结果始终是No Default,因为在 PHP 7 及更早版本中,Reflection扩展无法读取内部 PHP 函数的默认值。另一方面,PHP 8 的输出要准确得多,正如我们在这里所看到的:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php8_reflection_parms_defaults.php 
Reflecting on setcookie
         Parameter : Default(s)
      ------------ : ------------
              name : No Default
             value : ''
expires_or_options : No Default
              path : ''
            domain : ''
            secure : FALSE
          httponly : FALSE

从输出中可以看出,PHP 8 中的Reflection扩展能够准确报告内部函数的默认值!

现在让我们看看其他Reflection扩展的变化。

其他反射扩展的更改

在 PHP 8 之前的 PHP 版本中,ReflectionMethod::isConstructor()ReflectionMethod::isDestructor()无法反映在接口中定义的魔术方法。在 PHP 8 中,这两个方法现在对接口中定义的相应魔术方法返回TRUE

在使用ReflectionClass::getConstants()ReflectionClass::getReflectionConstants()方法时,现在添加了一个新的$filter参数。该参数允许您按可见性级别过滤结果。因此,新参数可以接受以下任何新添加的预定义常量之一:

  • ReflectionClassConstant::IS_PUBLIC

  • ReflectionClassConstant::IS_PROTECTED

  • ReflectionClassConstant::IS_PRIVATE

现在您已经了解了如何使用Reflection扩展以及在 PHP 8 迁移后可以期待什么。现在是时候看看在 PHP 8 中发生了变化的其他扩展了。

处理其他扩展的注意事项

PHP 8 引入了一些其他 PHP 扩展的值得注意的变化,除了本章已经讨论过的扩展。正如我们在本书中一再强调的那样,了解这些变化对于您作为 PHP 开发人员的未来职业非常重要。

让我们首先看看数据库扩展的变化。

新的数据库扩展操作系统库要求

任何使用MySQLMariaDBPostgreSQLPHP 数据对象PDO)的开发人员都需要注意支持操作系统库的新要求。以下表格总结了 PHP 8 中所需的新最低版本:

表 7.4 – PHP 8 数据库库要求

表 7.4 – PHP 8 数据库库要求

从上表可以看出,有两个主要的库更改。libpq影响了PostgreSQL扩展和PDO扩展的驱动程序。libmysqlclientMySQL Improved (MySQLi)扩展和PDO扩展的 MySQL 驱动程序使用的库。还应该注意,如果您使用的是 MariaDB,MySQL 的一个流行的开源版本,新的最低MySQL库要求也适用于您。

既然您已经了解了数据库扩展的变化,接下来我们将把注意力转向 ZIP 扩展。

审查 ZIP 扩展的变化

ZIP 扩展用于以编程方式创建和管理压缩的存档文件,利用libzip操作系统库。还存在其他压缩扩展,如Zlibbzip2LZFPHP Archive Format (phar)和Roshal Archive Compressed (RAR);然而,其他扩展都没有ZIP扩展提供的丰富功能范围。此外,大多数情况下,其他扩展都是专用的,通常不适用于通用 ZIP 文件管理。

让我们首先看一下这个扩展最显著的变化。

处理 ZIP 扩展的 OOP 迁移

ZIP 扩展最大的变化是可能会在未来引入一个巨大的向后兼容的代码破坏。从 PHP 8 开始,过程 API(所有过程函数)已被弃用!尽管目前不会影响任何代码,但所有 ZIP 扩展函数最终将从语言中移除。

最佳实践是将任何ZIP扩展的过程代码迁移到使用ZipArchive类的 OOP API。以下代码示例说明了如何从过程代码迁移到对象代码,打开一个test.zip文件并生成条目列表:

// /repo/ch07/php7_zip_functions.php
$fn  = __DIR__ . '/includes/test.zip';
$zip = zip_open($fn);
$cnt = 0;
if (!is_resource($zip)) exit('Unable to open zip file');
while ($entry = zip_read($zip)) {
    echo zip_entry_name($entry) . "\n";
    $cnt++;
}
echo "Total Entries: $cnt\n";

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch07 ]# 
php php7_zip_functions.php 
ch07/includes/
ch07/includes/test.zip
ch07/includes/tree.xml
ch07/includes/test.png
ch07/includes/kitten.jpg
ch07/includes/reflection.html
ch07/php7_ext_is_resource.php
ch07/php7_gd_is_resource.php
... not all entries shown ...
ch07/php8_simple_xml.php
ch07/php8_xml_writer.php
ch07/php8_zip_oop.php
Total Entries: 27

从上面的输出可以看出,一共找到了27个条目。(还要注意并非所有 ZIP 文件条目都显示。)然而,如果我们在 PHP 8 中尝试相同的代码示例,结果会大不相同,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php7_zip_functions.php 
PHP Deprecated:  Function zip_open() is deprecated in /repo/ch07/php7_zip_functions.php on line 5
PHP Deprecated:  Function zip_read() is deprecated in /repo/ch07/php7_zip_functions.php on line 8
PHP Deprecated:  Function zip_entry_name() is deprecated in /repo/ch07/php7_zip_functions.php on line 9
ch07/includes/
Deprecated: Function zip_entry_name() is deprecated in /repo/ch07/php7_zip_functions.php on line 9
... not all entries shown ...
ch07/php8_zip_oop.php
PHP Deprecated:  Function zip_read() is deprecated in /repo/ch07/php7_zip_functions.php on line 8
Total Entries: 27

从上面的 PHP 8 输出可以看出,代码示例可以工作,但会发出一系列弃用的Notice消息。

以下是您需要在 PHP 8 中编写相同代码示例的方式:

// /repo/ch07/php8_zip_oop.php
$fn  = __DIR__ . '/includes/test.zip';
$obj = new ZipArchive();
$res = $obj->open($fn);
if ($res !== TRUE) exit('Unable to open zip file');
for ($i = 0; $entry = $obj->statIndex($i); $i++) {
    echo $entry['name'] . "\n";
}
echo "Total Entries: $i\n";

输出(未显示)与之前的示例完全相同。有趣的是,重写的示例在 PHP 7 中也可以工作!还值得注意的是,在 PHP 8 中,您可以使用ZipArchive::count()来获取总条目数(每个目录)。您可能还注意到,在 PHP 8 中,您不能再使用is_resource()来检查 ZIP 存档是否正确打开。

新的 ZipArchive 类方法

除了从资源到对象的迁移,ZipArchive类还进行了一些改进。其中一个改进是添加了以下新方法:

  • setMtimeName()方法

  • setMtimeIndex()

  • registerProgressCallback()

  • registerCancelCallback()

  • replaceFile()

  • isCompressionMethodSupported()

  • isEncryptionMethodSupported()

方法名称不言自明。Mtime指的是修改时间

addGlob()addPattern()的新选项

ZipArchive::addGlob()ZipArchive::addPattern()方法有一组新的选项。这两种方法都用于向存档中添加文件。不同之处在于addGlob()使用与核心 PHP 的glob()命令相同的文件模式,而addPattern()使用正则表达式过滤文件。这里总结了一组新的选项:

  • flags:让您使用位运算符组合适当的类常量

  • comp_method:使用任何ZipArchive::CM_*常量作为参数指定压缩方法

  • comp_flags:使用所需的ZipArchive::FL_*常量来指定压缩标志

  • enc_method:允许您指定所需的字符编码(使用任何 ZipArchive::FL_ENC_* 标志)

  • enc_password:允许您指定 ZIP 存档的加密密码(如果设置了)

在这里还值得一提的是,在 PHP 8 之前的 remove_path 选项必须是一个有效的目录路径。从 PHP 8 开始,这个选项是一个简单的字符串,表示要删除的字符。这使您能够删除文件名前缀以及不需要的目录路径。

虽然我们仍在研究选项,值得注意的是添加了两个新的编码方法类常量:ZipArchive::EM_UNKNOWNZipArchive::EM_TRAD_PKWARE。此外,添加了一个新的 lastId 属性,以便您能够确定最后一个 ZIP 存档条目的索引值。

其他 ZipArchive 方法的更改

除了前面提到的更改之外,PHP 8 中还有一些其他 ZipArchive 方法已经改变。在本节中,我们总结了其他 ZipArchive 方法的更改,如下:

  • ZipArchive::extractTo() 以前使用当前日期和时间作为修改时间。从 PHP 8 开始,这个方法恢复了原始文件的修改时间。

  • ZipArchive::getStatusString() 在调用 ZipArchive::close() 后仍然返回结果。

  • ZipArchive::addEmptyDir()ZipArchive::addFile()ZipArchive::addFromString() 方法都有一个新的 flags 参数。您可以使用任何适当的 ZipArchive::FL_* 类常量,并使用位运算符进行组合。

  • ZipArchive::open() 现在可以打开一个空的(零字节)文件。

现在您已经了解了引入到 ZIP 扩展中的更改和改进,让我们来看看正则表达式领域的更改。

检查 PCRE 扩展的更改

PCRE 扩展包含了一些设计用于使用 正则表达式 进行模式匹配的函数。术语 regular expression 通常被缩写为 regexregex 是描述另一个字符串的字符串。以下是 PCRE 扩展中需要注意的一些更改。

模式中的无效转义序列不再被解释为文字。过去,您可以使用 X 修饰符;然而,在 PHP 8 中,该修饰符被忽略。令人高兴的是,为了帮助您处理内部 PCRE 模式分析错误,添加了一个新的 preg_last_error_msg() 函数,当遇到 PCRE 错误时返回一个人类可读的消息。

preg_last_error() 函数允许您确定在模式分析期间是否发生了内部 PCRE 错误。然而,这个函数只返回一个整数。在 PHP 8 之前,开发人员需要查找代码并找出实际的错误。

提示

preg_last_error() 返回的错误代码列表可以在这里找到:

www.php.net/manual/en/function.preg-last-error.php#refsect1-function.preg-last-error-returnvalues

接下来是一个简短的代码示例,说明了前面提到的问题。以下是导致这一问题的步骤:

  1. 首先,我们定义一个执行匹配并检查是否发生任何错误的函数,如下:
$pregTest = function ($pattern, $string) {
    $result  = preg_match($pattern, $string);
    $lastErr = preg_last_error();
    if ($lastErr == PREG_NO_ERROR) {
        $msg = 'RESULT: ';
        $msg .= ($result) ? 'MATCH' : 'NO MATCH';
    } else {
        $msg = 'ERROR : ';
        if (function_exists('preg_last_error_msg'))
            $msg .= preg_last_error_msg();
        else
            $msg .= $lastErr;
    }
    return "$msg\n";
};
  1. 然后我们创建一个故意包含 \8+ 无效转义序列的模式,如下:
$pattern = '/\8+/';
$string  = 'test 8';
echo $pregTest($pattern, $string);
  1. 接下来,我们定义一个故意导致 PCRE 超出回溯限制的模式,如下:
$pattern = '/(?:\D+|<\d+>)*[!?]/';
$string  = 'test ';
echo $pregTest($pattern, $string);

以下是 PHP 7.1 中的输出:

root@php8_tips_php7 [ /repo/ch07 ]# php php7_pcre.php 
RESULT: MATCH
ERROR : 2

从前面的输出中可以看到,无效的模式被视为文字值 8。因为 8 存在于字符串中,所以被认为找到了匹配项。至于第二个模式,回溯限制被超出;然而,PHP 7.1 无法报告这个问题,迫使您自行查找。

在 PHP 8 中的输出是完全不同的,如下所示:

root@php8_tips_php8 [ /repo/ch07 ]# php php7_pcre.php 
PHP Warning:  preg_match(): Compilation failed: reference to non-existent subpattern at offset 1 in /repo/ch07/php7_pcre.php on line 5
ERROR : Internal error
ERROR : Backtrack limit exhausted

从前面的输出中可以看到,PHP 8 产生了一个 Warning 消息。您还可以看到 preg_last_error_msg() 产生了一个有用的消息。现在让我们来看看 Internationalization (Intl) 扩展。

处理 Intl 扩展的变化

Intl 扩展由几个类组成,处理可能根据区域设置而变化的一些应用方面。各种类处理国际化数字和货币格式化、文本解析、日历生成、时间和日期格式化以及字符集转换等任务。

PHP 8 中引入到 Intl 扩展的主要更改是以下新的日期格式:

  • IntlDateFormatter::RELATIVE_FULL

  • IntlDateFormatter::RELATIVE_LONG

  • IntlDateFormatter::RELATIVE_MEDIUM

  • IntlDateFormatter::RELATIVE_SHORT

接下来是一个代码示例,显示了新的格式。以下是导致这一步的步骤:

  1. 首先,我们定义一个DateTime实例和一个包含新格式代码的数组,如下所示:
$dt = new DateTime('tomorrow');
$pt = [IntlDateFormatter::RELATIVE_FULL,
    IntlDateFormatter::RELATIVE_LONG,
    IntlDateFormatter::RELATIVE_MEDIUM,
    IntlDateFormatter::RELATIVE_SHORT
];
  1. 然后我们循环遍历格式并输出结果,如下所示:
foreach ($pt as $fmt) 
    echo IntlDateFormatter::formatObject($dt, $fmt)."\n";

这个例子在 PHP 7 中不起作用。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch07 ]# 
php php8_intl_date_fmt.php 
tomorrow at 12:00:00 AM Coordinated Universal Time
tomorrow at 12:00:00 AM UTC
tomorrow, 12:00:00 AM
tomorrow, 12:00 AM

正如你所看到的,新的相对日期格式运行得相当不错!现在我们简要地回到cURL扩展。

了解 cURL 扩展的变化

cURL扩展利用libcurlhttp://curl.haxx.se/)提供强大和高效的 HTTP 客户端功能。在 PHP 8 中,你必须在服务器的操作系统上安装版本为 7.29(或更高版本)的libcurl

PHP 8 中的另一个不同之处是这个扩展现在使用对象而不是资源。这个变化在本章前面已经描述过,在表 7.1PHP 8 资源到对象的迁移中。在潜在的涉及 is_resource()的代码中断部分展示了一个例子。这个变化的一个副作用是任何curl*close()函数都是多余的,因为当对象未设置或者超出范围时连接会被关闭。

现在让我们来看看COM扩展的变化。

审查 COM 扩展的变化

组件对象模型COM)是一个仅适用于 Windows 的扩展,它使得用一种语言编写的编程代码能够调用和与用任何其他支持 COM 的编程语言编写的代码进行交互。对于计划在 Windows 服务器上运行的 PHP 开发人员来说,这些信息非常重要。

COM扩展的最重要的变化是现在自动强制大小写敏感性。因此,你不能再从类型库中导入任何大小写不敏感的常量。此外,你也不能再将$case_insensitive作为FALSE作为com_load_typelib()函数的第二个参数。

在这方面,处理大小写敏感性的COM扩展php.ini设置已经发生了变化。这些包括以下内容:

  • com.autoregister_casesensitive:在 PHP 8 中永久启用。

  • com.typelib_file:任何类型库的名称以#cis#case_insensitive结尾的不再导致常量被视为大小写不敏感。

一个变化是一个新的php.ini设置,com.dotnet_version。这个设置用于设置要用于 dotnet 对象的.NET版本。我们现在来检查其他值得注意的扩展变化。

检查其他扩展的变化

还有一些其他 PHP 扩展的变化值得一提。接下来显示的表 7.5总结了这些变化:

表 7.5 – PHP 8 数据库库要求

表 7.5 – PHP 8 数据库库要求

现在你对 PHP 8 中扩展的变化有了一个概念。这就结束了本章。现在,是时候进行总结了!

摘要

在本章中,你学到的最重要的概念之一是从资源向对象的一般趋势。你学会了在本章涵盖的各种 PHP 扩展中注意到这种趋势的地方,以及如何开发解决方案来避免依赖资源的代码中出现问题。你还学会了如何检测和开发代码来解决 XML 扩展中的变化,特别是在SimpleXMLXMLWriter扩展中。

本章还涵盖了一个重要的扩展,即mbstring扩展,其中有重大变化。您学会了检测依赖于已更改的mbstring功能的代码。正如您所了解的,mbstring扩展的更改在很大程度上反映了对等核心 PHP 字符串函数的更改。

您还了解了GDReflectionZIP扩展的重大变化。在本章中,您还了解了对一些数据库扩展的更改,以及需要注意的其他扩展变化。总的来说,通过阅读本章并学习示例,您现在更有能力在进行 PHP 8 升级后防止应用程序出现故障。

在下一章中,您将了解到在 PHP 8 中已被弃用或移除的功能。

第八章:了解 PHP 8 的弃用或已删除功能

本章将介绍在PHP 超文本预处理器 8PHP 8)中已弃用或删除的功能。对于任何开发人员来说,这些信息都非常重要。任何使用已删除功能的代码在升级到 PHP 8 之前必须进行重写。同样,任何弃用都是向您发出明确信号,您必须重写依赖于此类功能的任何代码,否则将来可能会出现问题。

阅读本章的材料并跟随示例应用代码后,您可以检测和重写已弃用的代码。您还可以为已删除的功能开发解决方案,并学习如何重构涉及扩展的已删除功能的代码。从本章中您还将学习到另一个重要技能,即通过重写依赖于已删除功能的代码来提高应用程序安全性。

本章涵盖的主题包括以下内容:

  • 发现核心中已删除的内容

  • 检查核心弃用

  • 在 PHP 8 扩展中使用已删除的功能

  • 处理已弃用或已删除的与安全相关的功能

技术要求

为了检查和运行本章提供的代码示例,下面概述了最低推荐硬件要求:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 千兆字节(GB)的可用磁盘空间

  • 4 GB 的随机存取存储器(RAM)

  • 500 千比特每秒(Kbps)或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅第一章技术要求部分,介绍了如何构建用于演示本书中解释的代码的 Docker 容器。在本书中,我们将恢复了书籍样本代码的目录称为/repo

本章的源代码位于此处:

github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

我们现在可以开始讨论在 PHP 8 中已删除的核心功能。

发现核心中已删除的内容

在本节中,我们不仅考虑了从 PHP 8 中删除的函数和类,还将查看已删除的用法。然后,我们将查看仍然存在但由于 PHP 8 中的其他更改而不再提供任何有用功能的类方法和函数。了解已删除的函数非常重要,以防止在 PHP 8 迁移后出现潜在的代码中断。

让我们从检查在 PHP 8 中已删除的功能开始。

检查在 PHP 8 中已删除的功能

PHP 语言中有许多函数仅保留下来以保持向后兼容性。然而,维护这些功能会消耗核心语言开发的资源。此外,大多数情况下,这些功能已被更好的编程构造所取代。因此,随着证据表明这些功能不再被使用,这些命令已经在语言中慢慢被删除。

提示

PHP 核心团队偶尔会对基于 GitHub 的 PHP 存储库进行统计分析。通过这种方式,他们能够确定 PHP 核心中各种命令的使用频率。

接下来显示的表总结了在 PHP 8 中已删除的功能以及用于替代它们的内容:

表 8.1 – PHP 8 已删除的功能和建议的替代品

表 8.1 – PHP 8 已删除的功能和建议的替代品

在本节的其余部分,我们将介绍一些重要的已删除函数,并为您提供建议,以便重构代码以实现相同的结果。让我们首先来看看each()

使用 each()进行操作

each()是在 PHP 4 中引入的一种遍历数组的方法,每次迭代产生键/值对。each()的语法和用法非常简单,面向过程的。我们将展示一个简短的代码示例,演示each()的用法,如下所示:

  1. 在此代码示例中,我们首先打开到包含来自 GeoNames(geonames.org)项目的城市数据的数据文件的连接,如下所示:
// /repo/ch08/php7_each.php
$data_src = __DIR__ 
    . '/../sample_data/cities15000_min.txt';
$fh       = fopen($data_src, 'r');
$pattern  = "%30s : %20s\n";
$target   = 10000000;
$data     = [];
  1. 然后,我们使用fgetcsv()函数将数据行拉入$line,并将纬度和经度信息打包到$data数组中。请注意在以下代码片段中,我们过滤掉人口少于$target(在本例中少于 1000 万)的城市的数据行:
while ($line = fgetcsv($fh, '', "\t")) {
    $popNum = $line[14] ?? 0;
    if ($popNum > $target) {
        $city = $line[1]  ?? 'Unknown';
        $data[$city] = $line[4]. ',' . $line[5];
    }
}
  1. 然后,我们关闭文件句柄并按城市名称对数组进行排序。为了呈现输出,我们使用each()遍历数组,生成键/值对,其中城市是键,纬度和经度是值。代码如下所示:
fclose($fh);
ksort($data);
printf($pattern, 'City', 'Latitude/Longitude');
printf($pattern, '----', '--------------------');
while ([$city, $latLon] = each($data)) {
    $city = str_pad($city, 30, ' ', STR_PAD_LEFT);
    printf($pattern, $city, $latLon);
}

以下是在 PHP 7 中的输出:

root@php8_tips_php7 [ /repo/ch08 ]# php php7_each.php 
                          City :   Latitude/Longitude
                          ---- : --------------------
                       Beijing :    39.9075,116.39723
                  Buenos Aires :  -34.61315,-58.37723
                         Delhi :    28.65195,77.23149
                         Dhaka :     23.7104,90.40744
                     Guangzhou :      23.11667,113.25
                      Istanbul :    41.01384,28.94966
                       Karachi :      24.8608,67.0104
                   Mexico City :   19.42847,-99.12766
                        Moscow :    55.75222,37.61556
                        Mumbai :    19.07283,72.88261
                         Seoul :      37.566,126.9784
                      Shanghai :   31.22222,121.45806
                      Shenzhen :    22.54554,114.0683
                    São Paulo :   -23.5475,-46.63611
                       Tianjin :   39.14222,117.17667

然而,此代码示例在 PHP 8 中不起作用,因为each()已被移除。最佳实践是向面向对象编程OOP)方法迈进:使用ArrayIterator代替each()。下一个代码示例产生的结果与以前完全相同,但使用对象类而不是过程函数:

  1. 我们不使用fopen(),而是创建一个SplFileObject实例。您还会注意到在以下代码片段中,我们不是创建一个数组,而是创建一个ArrayIterator实例来保存最终数据:
// /repo/ch08/php8_each_replacements.php
$data_src = __DIR__ 
    . '/../sample_data/cities15000_min.txt';
$fh       = new SplFileObject($data_src, 'r');
$pattern  = "%30s : %20s\n";
$target   = 10000000;
$data     = new ArrayIterator();
  1. 然后,我们使用fgetcsv()方法循环遍历数据文件以检索一行,并使用offsetSet()追加到迭代中,如下所示:
while ($line = $fh->fgetcsv("\t")) {
    $popNum = $line[14] ?? 0;
    if ($popNum > $target) {
        $city = $line[1]  ?? 'Unknown';
        $data->offsetSet($city, $line[4]. ',' .             $line[5]);
    }
}
  1. 最后,我们按键排序,倒回到顶部,并在迭代仍具有更多值时循环。我们使用key()current()方法检索键/值对,如下所示:
$data->ksort();
$data->rewind();
printf($pattern, 'City', 'Latitude/Longitude');
printf($pattern, '----', '--------------------');
while ($data->valid()) {
    $city = str_pad($data->key(), 30, ' ', STR_PAD_LEFT);
    printf($pattern, $city, $data->current());
    $data->next();
}

这个代码示例实际上可以在任何版本的 PHP 中工作,从 PHP 5.1 到 PHP 8 都可以!输出与前面的 PHP 7 输出完全相同,这里不再重复。

现在让我们来看看create_function()

使用 create_function()进行操作

在 PHP 5.3 之前,将函数分配给变量的唯一方法是使用create_function()。从 PHP 5.3 开始,首选方法是定义匿名函数。匿名函数虽然在技术上是过程式编程应用程序编程接口API)的一部分,但实际上是Closure类的实例,因此也属于 OOP 领域。

提示

如果您需要的功能可以简化为单个表达式,在 PHP 8 中,您还可以使用箭头函数

当由create_function()定义的函数执行时,PHP 在内部执行eval()函数。然而,这种架构的结果是笨拙的语法。匿名函数在性能上是等效的,并且更直观。

以下示例演示了create_function()的用法。此示例的目标是扫描 Web 服务器访问日志,并按Internet ProtocolIP)地址对结果进行排序:

  1. 我们首先记录以微秒为单位的开始时间。稍后,我们将使用此值来确定性能。以下是您需要的代码:
// /repo/ch08/php7_create_function.php
$start = microtime(TRUE);
  1. 接下来,使用create_function()定义一个回调函数,将每行开头的 IP 地址重新组织为确切三位数的统一段。我们需要这样做才能执行正确的排序(稍后定义)。create_function()的第一个参数是表示参数的字符串。第二个参数是要执行的实际代码。代码如下所示:
$normalize = create_function(
    '&$line, $key',
    '$split = strpos($line, " ");'
    . '$ip = trim(substr($line, 0, $split));'
    . '$remainder = substr($line, $split);'
    . '$tmp = explode(".", $ip);'
    . 'if (count($tmp) === 4)'
    . '    $ip = vsprintf("%03d.%03d.%03d.%03d", $tmp);'
    . '$line = $ip . $remainder;'
);

请注意大量使用字符串。这种笨拙的语法很容易导致语法或逻辑错误,因为大多数代码编辑器不会解释嵌入字符串中的命令。

  1. 接下来,我们定义一个用于usort()的排序回调,如下所示:
$sort_by_ip = create_function(
    '$line1, $line2',
    'return $line1 <=> $line2;' );
  1. 然后,我们使用file()函数将访问日志的内容提取到一个数组中。我们还将$sorted移动到一个文件中,以保存排序后的访问日志条目。代码如下所示:
$orig   = __DIR__ . '/../sample_data/access.log';
$log    = file($orig);
$sorted = new SplFileObject(__DIR__ 
    . '/access_sorted_by_ip.log', 'w');
  1. 然后,我们能够使用array_walk()规范化 IP 地址,并使用usort()进行排序,如下所示:
array_walk($log, $normalize);
usort($log, $sort_by_ip);
  1. 最后,我们将排序后的条目写入备用日志文件,并显示开始和结束之间的时间差,如下所示:
foreach ($log as $line) $sorted->fwrite($line);
$time = microtime(TRUE) - $start;
echo "Time Diff: $time\n";

我们没有展示完整的备用访问日志,因为它太长而无法包含在书中。相反,这里是从列表中间提取出的十几行,以便让您了解输出的情况:

094.198.051.136 - - [15/Mar/2021:10:05:06 -0400]    "GET /courses HTTP/1.0" 200 21530
094.229.167.053 - - [21/Mar/2021:23:38:44 -0400] 
   "GET /wp-login.php HTTP/1.0" 200 34605
095.052.077.114 - - [10/Mar/2021:22:45:55 -0500] 
   "POST /career HTTP/1.0" 200 29002
095.103.103.223 - - [17/Mar/2021:15:48:39 -0400] 
   "GET /images/courses/php8_logo.png HTTP/1.0" 200 9280
095.154.221.094 - - [25/Mar/2021:11:43:52 -0400] 
   "POST / HTTP/1.0" 200 34546
095.154.221.094 - - [25/Mar/2021:11:43:52 -0400] 
   "POST / HTTP/1.0" 200 34691
095.163.152.003 - - [14/Mar/2021:16:09:05 -0400] 
   "GET /images/courses/mongodb_logo.png HTTP/1.0" 200 11084
095.163.255.032 - - [13/Apr/2021:15:09:40 -0400] 
   "GET /robots.txt HTTP/1.0" 200 78
095.163.255.036 - - [18/Apr/2021:01:06:33 -0400] 
   "GET /robots.txt HTTP/1.0" 200 78

在 PHP 8 中,为了完成相同的任务,我们定义匿名函数而不是使用create_function()。以下是重写的代码示例在 PHP 8 中的样子:

  1. 同样,我们首先记录开始时间,就像刚才描述的 PHP 7 代码示例一样。以下是您需要完成此操作的代码:
// /repo/ch08/php8_create_function.php
$start = microtime(TRUE);
  1. 接下来,我们定义一个回调函数,将 IP 地址规范化为四个三位数的块。我们使用与前一个示例完全相同的逻辑;但是,这次我们以匿名函数的形式定义命令。这利用了代码编辑器的帮助,并且每一行都被代码编辑器视为实际的 PHP 命令。代码如下所示:
$normalize = function (&$line, $key) {
    $split = strpos($line, ' ');
    $ip = trim(substr($line, 0, $split));
    $remainder = substr($line, $split);
    $tmp = explode(".", $ip);
    if (count($tmp) === 4)
        $ip = vsprintf("%03d.%03d.%03d.%03d", $tmp);
    $line = $ip . $remainder;
};

因为匿名函数中的每一行都被视为定义普通 PHP 函数一样,所以你不太可能出现拼写错误或语法错误。

  1. 以类似的方式,我们以箭头函数的形式定义了排序回调,如下所示:
$sort_by_ip = fn ($line1, $line2) => $line1 <=> $line2;

代码示例的其余部分与前面描述的完全相同,这里不再展示。同样,输出也完全相同。性能时间也大致相同。

现在我们将注意力转向money_format()

使用money_format()

money_format()函数最早是在 PHP 4.3 中引入的,旨在使用国际货币显示货币值。如果您维护一个有任何财务交易的国际 PHP 网站,在 PHP 8 更新后可能会受到这种变化的影响。

后者是在 PHP 5.3 中引入的,因此不会导致您的代码出错。让我们看一个涉及money_format()的简单示例,以及如何重写以在 PHP 8 中运行,如下所示:

  1. 首先,我们将一个金额分配给$amt变量。然后,我们将货币区域设置为en_US(美国),并使用money_format()输出该值。我们使用%n格式代码进行国家格式化,然后是%i代码进行国际渲染。在后一种情况下,显示国际标准化组织ISO)货币代码(美元,或USD)。代码如下所示:
// /repo/ch08/php7_money_format.php
$amt = 1234567.89;
setlocale(LC_MONETARY, 'en_US');
echo "Natl: " . money_format('%n', $amt) . "\n";
echo "Intl: " . money_format('%i', $amt) . "\n";
  1. 然后,我们将货币区域设置为de_DE(德国),并以国家和国际格式输出相同的金额,如下所示:
setlocale(LC_MONETARY, 'de_DE');
echo "Natl: " . money_format('%n', $amt) . "\n";
echo "Intl: " . money_format('%i', $amt) . "\n";

以下是在 PHP 7.1 中的输出:

root@php8_tips_php7 [ /repo/ch08 ]# php php7_money_format.php
Natl: $1,234,567.89
Intl: USD 1,234,567.89
Natl: 1.234.567,89 EUR
Intl: 1.234.567,89 EUR

您可能会注意到从输出中,money_format()没有呈现欧元符号,只有 ISO 代码(EUR)。但是,它确实正确格式化了金额,使用逗号作为千位分隔符,点作为en_US区域的小数分隔符,de_DE区域则相反。

最佳实践是用NumberFormatter::formatCurrency()替换任何使用money_format()的用法。以下是前面的示例,在 PHP 8 中重写的方式。请注意,相同的示例也将在从 5.3 版本开始的任何 PHP 版本中运行!我们将按以下步骤进行:

  1. 首先,我们将金额分配给$amt,并创建一个NumberFormatter实例。在创建这个实例时,我们提供了指示区域设置和数字类型(在本例中是货币)的参数。然后我们使用formatCurrency()方法来产生这个金额的国家表示,如下面的代码片段所示:
// /repo/ch08/php8_number_formatter_fmt_curr.php
$amt = 1234567.89;
$fmt = new NumberFormatter('en_US',
    NumberFormatter::CURRENCY );
echo "Natl: " . $fmt->formatCurrency($amt, 'USD') . "\n";
  1. 为了生成 ISO 货币代码——在本例中是USD——我们需要使用setSymbol()方法。否则,默认情况下会产生$货币符号,而不是USD的 ISO 代码。然后我们使用format()方法来呈现输出。请注意以下代码片段中USD后面的空格。这是为了防止 ISO 代码在输出时与数字相连!:
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL,'USD ');
echo "Intl: " . $fmt->format($amt) . "\n";
  1. 然后我们使用de_DE区域设置格式化相同的金额,如下所示:
$fmt = new NumberFormatter( 'de_DE',
    NumberFormatter::CURRENCY );
echo "Natl: " . $fmt->formatCurrency($amt, 'EUR') . "\n";
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, 'EUR');
echo "Intl: " . $fmt->format($amt) . "\n";

以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch08 ]# 
php php8_number_formatter_fmt_curr.php 
Natl: $1,234,567.89
Intl: USD 1,234,567.89
Natl: 1.234.567,89 €
Intl: 1.234.567,89 EUR

从输出中可以看到,在en_USde_DE区域设置之间逗号的位置是相反的,这是预期的。您还可以看到货币符号以及 ISO 货币代码都被正确地呈现出来。

现在您已经了解了如何替换money_format(),让我们看看 PHP 8 中已经移除的其他编程代码使用。

发现其他 PHP 8 使用变化

在 PHP 8 中有一些程序代码使用变化需要注意。我们将从不再允许的两种类型转换开始。

已移除的类型转换

开发人员经常使用强制类型转换来确保变量的数据类型适合特定的用途。例如,在处理超文本标记语言HTML)表单提交时,假设表单元素中的一个表示货币金额。对这个数据元素进行快速简单的方法是将其类型转换为float数据类型,如下所示:

$amount = (float) $_POST['amount'];

然而,一些开发人员不愿意将类型转换为float,而更喜欢使用realdouble。有趣的是,这三种方法产生完全相同的结果!在 PHP 8 中,类型转换为real已被移除。如果您的代码使用了这种类型转换,最佳实践是将其更改为float

unset类型转换也已被移除。这种类型转换的目的是取消变量的设置。在下面的代码片段中,$obj的值变为NULL

$obj = new ArrayObject();
/* some code (not shown) */
$obj = (unset) $obj;

在 PHP 8 中的最佳实践是使用以下任一种:

$obj = NULL; 
// or this:
unset($obj);

现在让我们把注意力转向匿名函数。

从类方法生成匿名函数的更改

在 PHP 7.1 中,添加了一个新的Closure::fromCallable()方法,允许您将类方法返回为Closure实例(例如,匿名函数)。还引入了ReflectionMethod::getClosure(),它也能够将类方法转换为匿名函数。

为了说明这一点,我们定义了一个类,该类返回能够使用不同算法进行哈希的Closure实例。我们将按以下步骤进行:

  1. 首先,我们定义一个类和一个公共$class属性,如下所示:
// /repo/src/Services/HashGen.php
namespace Services;
use Closure;
class HashGen {
    public $class = 'HashGen: ';
  1. 然后我们定义一个方法,产生三种不同的回调之一,每种回调都设计为产生不同类型的哈希,如下所示:
    public function makeHash(string $type) {
        $method = 'hashTo' . ucfirst($type);
        if (method_exists($this, $method))
            return Closure::fromCallable(
                [$this, $method]);
        else
            return Closure::fromCallable(
                [$this, 'doNothing']);
        }
    }
  1. 接下来,我们定义三种不同的方法,每种方法产生不同形式的哈希(未显示):hashToMd5()hashToSha256()doNothing()

  2. 为了使用这个类,首先设计一个调用程序,包括类文件并创建一个实例,如下所示:

// /repo/ch08/php7_closure_from_callable.php
require __DIR__ . '/../src/Services/HashGen.php';
use Services\HashGen;
$hashGen = new HashGen();
  1. 然后执行回调,然后使用var_dump()查看有关Closure实例的信息,如下面的代码片段所示:
$doMd5 = $hashGen->makeHash('md5');
$text  = 'The quick brown fox jumped over the fence';
echo $doMd5($text) . "\n";
var_dump($doMd5);
  1. 为了结束这个示例,我们创建并绑定一个匿名类到Closure实例,如下面的代码片段所示。理论上,如果匿名类真正绑定到$this,输出显示应该以Anonymous开头:
$temp = new class() { public $class = 'Anonymous: '; };
$doMd5->bindTo($temp);
echo $doMd5($text) . "\n";
var_dump($doMd5);

以下是在 PHP 8 中运行此代码示例的输出:

root@php8_tips_php8 [ /repo/ch08 ]# 
php php7_closure_from_callable.php 
HashGen: b335d9cb00b899bc6513ecdbb2187087
object(Closure)#2 (2) {
  ["this"]=>  object(Services\HashGen)#1 (1) {
    ["class"]=>    string(9) "HashGen: "
  }
  ["parameter"]=>  array(1) {
    ["$text"]=>    string(10) "<required>"
  }
}
PHP Warning:  Cannot bind method Services\HashGen::hashToMd5() to object of class class@anonymous in /repo/ch08/php7_closure_from_callable.php on line 16
HashGen: b335d9cb00b899bc6513ecdbb2187087
object(Closure)#2 (2) {
  ["this"]=>  object(Services\HashGen)#1 (1) {
    ["class"]=>    string(9) "HashGen: "
  }
  ["parameter"]=>  array(1) {
    ["$text"]=>    string(10) "<required>"
  }

从输出中可以看出,Closure简单地忽略了绑定另一个类的尝试,并产生了预期的输出。此外,还生成了一个Warning消息,通知您非法的绑定尝试。

现在让我们来看一下注释处理的差异。

注释处理的差异

PHP 传统上支持一些符号来表示注释。其中一个符号是井号(#)。然而,由于引入了一个称为Attributes的新语言结构,紧随着一个开放的方括号(#[)的井号不再允许表示注释。紧随着一个开放的方括号的井号继续作为注释分隔符。

这是一个简短的示例,在 PHP 7 和更早版本中有效,但在 PHP 8 中无效:

// /repo/ch08/php7_hash_bracket_ comment.php
test = new class() {
    # This works as a comment
    public $works = 'OK';
    #[ This does not work in PHP 8 as a comment]
    public $worksPhp7 = 'OK';
};
var_dump($test);

当我们在 PHP 7 中运行这个示例时,输出如预期的那样。

root@php8_tips_php7 [ /repo/ch08 ]# 
php php7_hash_bracket_comment.php 
/repo/ch08/php7_hash_bracket_comment.php:10:
class class@anonymous#1 (2) {
  public $works =>  string(2) "OK"
  public $worksPhp7 =>  string(2) "OK"
}

然而,在 PHP 8 中,相同的示例会抛出一个致命的Error消息,如下所示:

root@php8_tips_php8 [ /repo/ch08 ]# 
php php7_hash_bracket_comment.php 
PHP Parse error:  syntax error, unexpected identifier "does", expecting "]" in /repo/ch08/php7_hash_bracket_comment.php on line 7

请注意,如果我们正确地构建了Attribute实例,示例可能在 PHP 8 中意外地起作用。然而,由于使用的语法符合注释的语法,代码失败了。

现在您已经了解了从 PHP 8 中移除的函数和用法,我们现在来检查核心弃用。

检查核心弃用

在本节中,我们将检查在 PHP 8 中弃用的函数和用法。随着 PHP 语言的不断成熟,PHP 社区能够建议 PHP 核心开发团队移除某些函数、类甚至语言用法。如果有三分之二的 PHP 开发团队投票赞成一个提案,它就会被采纳并包含在未来版本的语言中。

在要移除的功能的情况下,它不会立即从语言中移除。相反,函数、类、方法或用法会生成一个Deprecation通知。此通知用作一种通知开发人员的方式,即该函数、类、方法或用法将在尚未指定的 PHP 版本中被禁止使用。因此,您必须密切关注Deprecation通知。不这样做将不可避免地导致未来代码的中断。

提示

从 PHP 5.3 开始,官方启动了一个请求评论RFC)的过程。任何提案的状态都可以在wiki.php.net/rfc上查看。

让我们首先检查参数顺序中弃用的用法。

参数顺序中弃用的用法

术语“用法”指的是在应用程序代码中调用函数和类方法的方式。您将发现,在 PHP 8 中,以前允许的用法现在被认为是不良实践。了解 PHP 8 如何强制执行代码用法的最佳实践有助于您编写更好的代码。

如果您定义一个带有必填和可选参数混合的函数或方法,大多数 PHP 开发人员都同意可选参数应该跟在必填参数后面。在 PHP 8 中,如果不遵循这种用法最佳实践,将会生成一个Deprecation通知。弃用此用法的决定背后的理由是避免潜在的逻辑错误。

这个简单的示例演示了这种用法的差异。在下面的示例中,我们定义了一个接受三个参数的简单函数。请注意,$op可选参数夹在两个必填参数$a$b之间:

// /repo/ch08/php7_usage_param_order.php
function math(float $a, string $op = '+', float $b) {
    switch ($op) {
        // not all cases are shown
        case '+' :
        default :
            $out = "$a + $b = " . ($a + $b);
    }
    return $out . "\n";
}

如果我们在 PHP 7 中回显 add 操作的结果,就没有问题,就像我们在这里看到的那样:

root@php8_tips_php7 [ /repo/ch08 ]# 
php php7_usage_param_order.php
22 + 7 = 29

然而,在 PHP 8 中,有一个Deprecation通知,之后允许继续操作。以下是在 PHP 8 中运行的输出:

root@php8_tips_php8 [ /repo/ch08 ]# 
php php7_usage_param_order.php
PHP Deprecated:  Required parameter $b follows optional parameter $op in /repo/ch08/php7_usage_param_order.php on line 4
22 + 7 = 29

Deprecation通知是对开发人员的信号,表明这种用法被认为是一种不良实践。在这种情况下,最佳做法是修改函数签名,并首先列出所有必填参数。

以下是重写的示例,适用于所有版本的 PHP:

// /repo/ch08/php8_usage_param_order.php
function math(float $a, float $b, string $op = '+') {
    // remaining code is the same
}

重要的是要注意,在 PHP 8 中仍然允许以下用法:

function test(object $a = null, $b) {}

然而,编写相同的函数签名并仍然保持列出必需参数的最佳实践的更好方法是重新编写这个签名,如下所示:

function test(?object $a, $b) {}

您现在已经了解了从 PHP 8 核心中移除的功能。现在让我们来看一下 PHP 8 扩展中已移除的功能。

使用 PHP 8 扩展中已移除的功能

在这一部分,我们将看一下 PHP 8 扩展中已移除的功能。这些信息非常重要,以避免编写在 PHP 8 中无法运行的代码。此外,了解已移除的功能有助于您为 PHP 8 迁移准备现有代码。

以下表格总结了扩展中已移除的功能:

Table 8.2 – Functions removed from PHP 8 extensions

Table 8.2 – Functions removed from PHP 8 extensions

上表提供了一个有用的已移除函数列表。在进行 PHP 8 迁移之前,使用此列表检查您的现有代码。

现在让我们来看一下 mbstring 扩展中可能严重的变化。

发现 mbstring 扩展的变化

mbstring扩展已经有了两个重大变化,对向后兼容的代码产生了巨大的潜在影响。第一个变化是移除了大量的便利别名。第二个重大变化是移除了对 mbstring PHP 函数重载功能的支持。让我们首先看一下已移除的别名。

处理 mbstring 扩展中已移除的别名

在一些开发人员的要求下,负责这个扩展的 PHP 开发团队慷慨地创建了一系列别名,用mb_*()替换mb*()。然而,对于这个请求的确切理由已经随着时间的流逝而失去了。然而,支持如此大量的别名每次扩展需要更新时都会浪费大量时间。因此,PHP 开发团队投票决定在 PHP 8 中从 mbstring 扩展中移除这些别名。

以下表格提供了已移除的别名列表,以及应使用哪个函数代替它们:

Table 8.3 – Removed mbstring aliases

Table 8.3 – Removed mbstring aliases

现在让我们来看一下与函数重载相关的字符串处理的另一个重大变化。

使用 mbstring 扩展函数重载

函数重载功能允许标准的 PHP 字符串函数(例如substr())在php.ini指令mbstring.func_overload被赋予一个值时,被其mbstring扩展等效函数(例如mb_substr())悄悄地替换。这个指令被赋予的值采用位标志的形式。根据这个标志的设置,mail()str*()substr()split()函数可能会受到重载。这个功能在 PHP 7.2 中已被弃用,并在 PHP 8 中被移除。

此外,与这个功能相关的三个mbstring扩展常量也已被移除。这三个常量分别是MB_OVERLOAD_MAILMB_OVERLOAD_STRINGMB_OVERLOAD_REGEX

提示

有关此功能的更多信息,请访问以下链接:

www.php.net/manual/en/mbstring.overload.php

依赖于这个功能的任何代码都将中断。避免严重的应用程序故障的唯一方法是重写受影响的代码,并用预期的mbstring扩展函数替换悄悄替换的 PHP 核心字符串函数。

在下面的示例中,当启用mbstring.func_overload时,PHP 7 对strlen()mb_strlen()报告相同的值:

// /repo/ch08/php7_mbstring_func_overload.php
$str  = '';
$len1 = strlen($str);
$len2 = mb_strlen($str);
echo "Length of '$str' using 'strlen()' is $len1\n";
echo "Length of '$str' using 'mb_strlen()' is $len2\n";

以下是在 PHP 7 中的输出:

root@php8_tips_php7 [ /repo/ch08 ]# 
php php7_mbstring_func_overload.php 
Length of '' using 'strlen()' is 45
Length of '' using 'mb_strlen()' is 15
root@php8_tips_php7 [ /repo/ch08 ]# 
echo "mbstring.func_overload=7" >> /etc/php.ini
root@php8_tips_php7 [ /repo/ch08 ]# 
php php7_mbstring_func_overload.php 
Length of '' using 'strlen()' is 15
Length of '' using 'mb_strlen()' is 15

从前面的输出中可以看出,一旦在 php.ini 文件中启用了 mbstring.func_overload 设置,strlen()mb_strlen() 报告的结果是相同的。这是因为对 strlen() 的调用被悄悄地转到了 mb_strlen()。在 PHP 8 中,输出(未显示)显示了两种情况下的结果,因为 mbstring.func_overload 设置被忽略了。strlen() 报告长度为 45mb_strlen() 报告长度为 15

要确定您的代码是否容易受到这种向后兼容性的破坏,请检查您的 php.ini 文件,并查看 mbstring.func_overload 设置是否为零以外的值。

现在您已经知道在 mbstring 扩展中寻找潜在的代码破坏的地方。现在,我们将注意力转向 Reflection 扩展中的变化。

重新设计使用 Reflection*::export() 的代码

在 Reflection 扩展中,PHP 8 和早期版本之间的一个关键区别是所有的 Reflection*::export() 方法都已经被移除了!这个变化的主要原因是简单地回显 Reflection 对象会产生与使用 export() 完全相同的结果。

如果您的代码目前使用了任何 Reflection*::export() 方法,您需要重写代码以使用 __toString() 方法。

发现其他已弃用的 PHP 8 扩展功能

在这一部分,我们将回顾 PHP 8 扩展中其他一些重要的弃用功能。首先,我们来看看 XML-RPC。

XML-RPC 扩展的变化

在 PHP 8 之前的 PHP 版本中,XML-RPC 扩展是核心的一部分并且始终可用。从 PHP 8 开始,这个扩展已经悄悄地移动到了 PHP Extension Community Library (PECL) (pecl.php.net/),并且不再默认包含在标准的 PHP 发行版中。您仍然可以安装和使用这个扩展。这个变化可以通过扫描 PHP 核心中的扩展列表来轻松确认:github.com/php/php-src/tree/master/ext

这不会导致向后兼容的代码破坏。但是,如果您执行了标准的 PHP 8 安装,然后迁移包含对 XML-RPC 的引用的代码,您的代码可能会生成一个致命的 Error 消息,并显示一个消息,指出 XML-RPC 类和/或函数未定义。在这种情况下,只需使用 pecl 或其他通常用于安装非核心扩展的方法安装 XML-RPC 扩展。

现在我们将注意力转向 DOM 扩展。

DOM 扩展的变化

自 PHP 5 以来,文档对象模型 (DOM) 扩展在其源代码存储库中包含了一些从未实现的类。在 PHP 8 中,决定支持 DOM 作为一个 生活标准(就像 HTML 5 一样)。生活标准是指没有一系列发布版本,而是连续发布一系列版本,以跟上网络技术的发展。

提示

有关拟议的 DOM 生活标准的更多信息,请参阅此参考资料:dom.spec.whatwg.org/。有关将 PHP DOM 扩展移至生活标准基础的讨论,请参阅 第九章使用接口和特性 部分,掌握 PHP 8 最佳实践

主要是由于向生活标准迈进,以下未实现的类已经从 PHP 8 的 DOM 扩展中移除:

  • DOMNameList

  • DOMImplementationList

  • DOMConfiguration

  • DOMError

  • DOMErrorHandler

  • DOMImplementationSource

  • DOMLocator

  • DOMUserDataHandler

  • DOMTypeInfo

这些类从未被实现,这意味着您的源代码不会遭受任何向后兼容性的破坏。

现在让我们来看看 PHP PostgreSQL 扩展中的弃用情况。

PostgreSQL 扩展的变化

除了表 8.5中指示的弃用功能之外,您还需要注意 PHP 8 PostgreSQL 扩展中已弃用了几十个别名。与从mbstring扩展中删除的别名一样,我们在本节中涵盖的别名在别名名称的后半部分没有下划线字符。

此表总结了已删除的别名,以及用于替代它们的函数:

Table 8.4 – PostgreSQL 扩展中的弃用功能

表 8.4 – PostgreSQL 扩展中的弃用功能

请注意,很难找到有关弃用的文档。在这种情况下,您可以在此处查看 PHP 7.4 到 PHP 8 迁移指南:www.php.net/manual/en/migration80.deprecated.php#migration80.deprecated。否则,您可以随时在 C 源代码 docblocks 中查找@deprecation注释,链接在此处:github.com/php/php-src/blob/master/ext/pgsql/pgsql.stub.php。以下是一个示例:

/**
 * @alias pg_last_error
 * @deprecated
 */
function pg_errormessage(
    ?PgSql\Connection $connection = null): string {}

在本节的最后部分,我们总结了 PHP 8 扩展中的弃用功能。

PHP 8 扩展中的弃用功能

最后,为了让您更容易识别 PHP 8 扩展中的弃用功能,我们提供了一个总结。以下表总结了 PHP 8 扩展中弃用的功能:

Table 8.5 – PHP 8 扩展中的弃用功能

表 8.5 – PHP 8 扩展中的弃用功能

我们将使用 PostgreSQL 扩展来说明已弃用的功能。在运行代码示例之前,您需要在 PHP 8 Docker 容器内执行一些设置。请按照以下步骤进行:

  1. 打开 PHP 8 Docker 容器中的命令 shell。从命令 shell 启动 PostgreSQL,使用以下命令:

/etc/init.d/postgresql start

  1. 接下来,切换到su postgres用户。

  2. 提示符变为bash-4.3$。在这里,键入psql进入 PostgreSQL 交互式终端。

  3. 接下来,从 PostgreSQL 交互式终端,发出以下一组命令来创建和填充一个示例数据库表:

CREATE DATABASE php8_tips;
\c php8_tips;
\i /repo/sample_data/pgsql_users_create.sql
  1. 这里是整个命令链的重播:
root@php8_tips_php8 [ /repo/ch08 ]# su postgres
bash-4.3$ psql
psql (10.2)
Type "help" for help.
postgres=# CREATE DATABASE php8_tips;
CREATE DATABASE
postgres=# \c php8_tips;
You are now connected to database "php8_tips" 
    as user "postgres".
php8_tips=# \i /repo/sample_data/pgsql_users_create.sql
CREATE TABLE
INSERT 0 4
CREATE ROLE
GRANT
php8_tips=# \q
bash-4.3$ exit
exit
root@php8_tips_php8 [ /repo/ch08 ]# 
  1. 我们现在定义一个简短的代码示例来说明刚才讨论的弃用概念。请注意,在以下代码示例中,我们为一个不存在的用户创建了一个结构化查询语言SQL)语句:
// /repo/ch08/php8_pgsql_changes.php
$usr = 'php8';
$pwd = 'password';
$dsn = 'host=localhost port=5432 dbname=php8_tips '
      . ' user=php8 password=password';
$db  = pg_connect($dsn);
$sql = "SELECT * FROM users WHERE user_name='joe'";
$stmt = pg_query($db, $sql);
echo pg_errormessage();
$result = pg_fetch_all($stmt);
var_dump($result);
  1. 以下是前面代码示例的输出:
root@php8_tips_php8 [ /repo/ch08 ]# php php8_pgsql_changes.php Deprecated: Function pg_errormessage() is deprecated in /repo/ch08/php8_pgsql_changes.php on line 22
array(0) {}

从输出中需要注意的两个主要事项是pg_errormessage()已弃用,并且当查询未返回结果时,不再返回FALSE布尔值,而是返回一个空数组。不要忘记使用此命令停止 PostgreSQL 数据库:

/etc/init.d/postgresql stop

现在您已经了解了各种 PHP 8 扩展中的弃用功能,我们将把注意力转向与安全相关的弃用功能。

处理已弃用或已删除的与安全相关的功能

任何影响安全性的功能更改都非常重要。忽视这些更改不仅很容易导致代码中断,还可能使您的网站面临潜在攻击者的威胁。在本节中,我们将介绍 PHP 8 中存在的各种与安全相关的功能更改。让我们从检查过滤器开始讨论。

检查 PHP 8 流过滤器更改

PHP 的输入/输出(I/O)操作依赖于一个称为流的子系统。这种架构的一个有趣方面是能够将流过滤器附加到任何给定的流上。您可以附加的过滤器可以是使用stream_filter_register()注册的自定义定义的流过滤器,也可以是与您的 PHP 安装一起包含的预定义过滤器。

您需要注意的一个重要变化是,在 PHP 8 中,所有mcrypt.*mdecrypt.*过滤器都已被移除,以及string.strip_tags过滤器。如果您不确定您的 PHP 安装中包含哪些过滤器,您可以运行phpinfo()或者更好的是stream_get_filters()

以下是在本书中使用的 PHP 7 Docker 容器中运行的stream_get_filters()输出:

root@php8_tips_php7 [ /repo/ch08 ]# 
php -r "print_r(stream_get_filters());"
Array (
    [0] => zlib.*
    [1] => bzip2.*
    [2] => convert.iconv.*
    [3] => mcrypt.*
    [4] => mdecrypt.*
    [5] => string.rot13
    [6] => string.toupper
    [7] => string.tolower
    [8] => string.strip_tags
    [9] => convert.*
    [10] => consumed
    [11] => dechunk
)

在 PHP 8 Docker 容器中运行相同的命令:

root@php8_tips_php8 [ /repo/ch08 ]# php -r "print_r(stream_get_filters());"
Array (
    [0] => zlib.*
    [1] => bzip2.*
    [2] => convert.iconv.*
    [3] => string.rot13
    [4] => string.toupper
    [5] => string.tolower
    [6] => convert.*
    [7] => consumed
    [8] => dechunk
)

您会注意到从 PHP 8 的输出中,之前提到的过滤器都已被移除。任何使用列出的三个过滤器中的任何一个的代码在 PHP 8 迁移后都将中断。我们现在来看一下自定义错误处理所做的更改。

处理自定义错误处理的变化

从 PHP 7.0 开始,大多数错误现在都是抛出的。这种情况的例外是 PHP 引擎不知道存在错误条件的情况,比如内存耗尽,超出时间限制,或者发生分段错误。另一个例外是程序故意使用trigger_error()函数触发错误。

使用trigger_error()函数来捕获错误并不是最佳实践。最佳实践是开发面向对象的代码并将其放在try/catch结构中。然而,如果您被指派管理一个使用这种实践的应用程序,那么传递给自定义错误处理程序的内容发生了变化。

在 PHP 8 之前的版本中,传递给自定义错误处理程序的第五个参数$errorcontext是有关传递给函数的参数的信息。在 PHP 8 中,此参数被忽略。为了说明区别,让我们看一下下面显示的简单代码示例。以下是导致这一变化的步骤:

  1. 首先,我们定义一个自定义错误处理程序,如下所示:
// /repo/ch08/php7_error_handler.php
function handler($errno, $errstr, $errfile, 
    $errline, $errcontext = NULL) {
    echo "Number : $errno\n";
    echo "String : $errstr\n";
    echo "File   : $errfile\n";
    echo "Line   : $errline\n";
    if (!empty($errcontext))
        echo "Context: \n" 
            . var_export($errcontext, TRUE);
    exit;
}
  1. 然后,我们定义一个触发错误、设置错误处理程序并调用函数的函数,如下所示:
function level1($a, $b, $c) {
    trigger_error("This is an error", E_USER_ERROR);
}
set_error_handler('handler');
echo level1(TRUE, 222, 'C');

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch08 ]#
php php7_error_handler.php 
Number : 256
String : This is an error
File   : /repo/ch08/php7_error_handler.php
Line   : 17
Context: 
array (
  'a' => true,
  'b' => 222,
  'c' => 'C',
)

从前面的输出中可以看出,$errorcontext提供了有关函数接收到的参数的信息。相比之下,让我们看一下 PHP 8 产生的输出,如下所示:

root@php8_tips_php8 [ /repo/ch08 ]# 
php php7_error_handler.php 
Number : 256
String : This is an error
File   : /repo/ch08/php7_error_handler.php
Line   : 17

如您所见,输出是相同的,只是没有关于$errorcontext的信息。现在让我们来看一下生成回溯。

处理回溯变化

令人惊讶的是,在 PHP 8 之前,通过回溯可以更改函数参数。这是因为debug_backtrace()Exception::getTrace()产生的跟踪提供了对函数参数的引用访问。

这是一个极其糟糕的做法,因为它允许您的程序继续运行,尽管可能处于错误状态。此外,当审查这样的代码时,不清楚参数数据是如何提供的。因此,在 PHP 8 中,不再允许这种做法。debug_backtrace()Exception::getTrace()仍然像以前一样运行。唯一的区别是它们不再通过引用传递参数变量。

现在让我们来看一下PDO错误处理的变化。

PDO 错误处理模式默认更改

多年来,初学者 PHP 开发人员对使用PDO扩展的数据库应用程序未能产生结果感到困惑。在许多情况下,这个问题的原因是一个简单的 SQL 语法错误没有被报告。这是因为在 PHP 8 之前的 PHP 版本中,默认的PDO错误模式是PDO::ERRMODE_SILENT

SQL 错误不是 PHP 错误。因此,这种错误不会被普通的 PHP 错误处理捕获。相反,PHP 开发人员必须明确将PDO错误模式设置为PDO::ERRMODE_WARNINGPDO::ERRMODE_EXCEPTION。PHP 开发人员现在可以松一口气了,因为从 PHP 8 开始,PDO 默认的错误处理模式现在是PDO::ERRMODE_EXCEPTION

在下面的例子中,PHP 7 允许不正确的 SQL 语句静默失败:

// /repo/ch08/php7_pdo_err_mode.php
$dsn = 'mysql:host=localhost;dbname=php8_tips';
$pdo = new PDO($dsn, 'php8', 'password');
$sql = 'SELEK propertyKey, hotelName FUM hotels '
     . "WARE country = 'CA'";
$stm = $pdo->query($sql);
if ($stm)
    while($hotel = $stm->fetch(PDO::FETCH_OBJ))
        echo $hotel->name . ' ' . $hotel->key . "\n";
else
    echo "No Results\n";

在 PHP 7 中,唯一的输出是No Results,这既具有欺骗性又没有帮助。这可能会让开发人员相信没有结果,而实际上问题是 SQL 语法错误。

在 PHP 8 中运行的输出如下所示,非常有帮助:

root@php8_tips_php8 [ /repo/ch08 ]# php php7_pdo_err_mode.php 
PHP Fatal error:  Uncaught PDOException: SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'SELEK propertyKey, hotelName FUM hotels WARE country = 'CA'' at line 1 in /repo/ch08/php7_pdo_err_mode.php:10

从前面的 PHP 8 输出中可以看出,实际问题得到了清晰的识别。

提示

关于这一变化的更多信息,请参阅此 RFC:

wiki.php.net/rfc/pdo_default_errmode

接下来我们来看一下track_errors php.ini指令。

检查 track_errors php.ini 设置

从 PHP 8 开始,track_errors php.ini指令已被移除。这意味着不再可用自动创建的变量$php_errormsg。在大多数情况下,PHP 8 之前引起错误的任何内容现在都已转换为抛出Error消息。但是,对于 PHP 8 之前的版本,您仍然可以使用error_get_last()函数。

在以下简单的代码示例中,我们首先设置了track_errors指令。然后我们调用strpos()而没有任何参数,故意引发错误。然后我们依赖$php_errormsg来显示真正的错误:

// /repo/ch08/php7_track_errors.php
ini_set('track_errors', 1);
@strpos();
echo $php_errormsg . "\n";
echo "OK\n";

以下是 PHP 7 中的输出:

root@php8_tips_php7 [ /repo/ch08 ]# php php7_track_errors.php 
strpos() expects at least 2 parameters, 0 given
OK

如您从前面的输出中所见,$php_errormsg显示了错误,并且代码块可以继续执行。当然,在 PHP 8 中,我们不允许在没有任何参数的情况下调用strpos()。以下是输出:

root@php8_tips_php8 [ /repo/ch08 ]# php php7_track_errors.php PHP Fatal error:  Uncaught ArgumentCountError: strpos() expects at least 2 arguments, 0 given in /repo/ch08/php7_track_errors.php:5

如您所见,PHP 8 会抛出一个Error消息。最佳实践是使用try/catch块并捕获可能抛出的任何Error消息。您还可以使用error_get_last()函数。以下是一个在 PHP 7 和 PHP 8 中都可以工作的重写示例(未显示输出):

// /repo/ch08/php8_track_errors.php
try {
    strpos();
    echo error_get_last()['message'];
    echo "\nOK\n";
} catch (Error $e) {
    echo $e->getMessage() . "\n";
}

现在您对 PHP 8 中已弃用或已删除的功能有了一定的了解。本章到此结束。

摘要

在本章中,您了解了已弃用和已删除的 PHP 功能。本章的第一节涉及已删除的核心功能。解释了变更的原因,并且您了解到,本章描述的删除功能的主要原因不仅是让您使用遵循最佳实践的代码,而且是让您使用更快速和更高效的 PHP 8 功能。

在下一节中,您了解了弃用功能。本节的重点是强调弃用的函数、类和方法如何导致不良实践和错误代码。您还将获得关于在许多关键 PHP 8 扩展中已删除或弃用的功能的指导。

您学会了如何定位和重写已弃用的代码,以及如何为已删除的功能开发解决方法。本章中您学到的另一个技能包括如何重构使用已删除功能的代码,涉及扩展,最后但同样重要的是,您学会了如何通过重写依赖已删除函数的代码来提高应用程序安全性。

在下一章中,您将学习如何通过掌握最佳实践来提高 PHP 8 代码的效率和性能。

第三部分:PHP 8 最佳实践

在本节中,您将了解 PHP 8 的最佳实践。您将学习 PHP 8 引入了许多额外的控制,以强制执行某些最佳实践。此外,还涵盖了在 PHP 8 中编写代码的最佳方法。

在本节中,包括以下章节:

  • 第九章,掌握 PHP 8 最佳实践

  • 第十章,提高性能

  • 第十一章,将现有 PHP 应用迁移到 PHP 8

  • 第十二章,使用异步编程创建 PHP 8 应用程序

第九章:精通 PHP 8 最佳实践

在本章中,您将了解到目前在 PHP 8 中强制执行的最佳实践。我们将涵盖几个重要的方法签名更改,以及它们的新用法如何延续了 PHP 的一般趋势,帮助您编写更好的代码。我们还将看一下私有方法、接口、特征和匿名类的使用方式发生了什么变化。最后,我们将讨论命名空间解析的重要变化。

掌握本章将涵盖的最佳实践不仅会让您更接近编写更好的代码,还会让您了解如何避免如果未掌握这些新实践可能导致的潜在代码破坏。此外,本章讨论的技术将帮助您编写比过去更高效的代码。

在本章中,我们将涵盖以下主题:

  • 发现方法签名更改

  • 处理私有方法

  • 使用接口和特征

  • 控制匿名类的使用

  • 理解命名空间的变化

技术要求

要查看并运行本章提供的代码示例,建议的最低硬件配置如下:

  • 基于 X86_64 的台式 PC 或笔记本电脑

  • 1 GB 的可用磁盘空间

  • 4 GB 的 RAM

  • 500 Kbps 或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关如何安装 Docker 和 Docker Compose 以及构建用于演示本书中代码的 Docker 容器的更多信息,请参阅第一章技术要求部分,介绍新的 PHP 8 面向对象编程特性。在本书中,我们将把您恢复示例代码的目录称为/repo

本章的源代码位于此处:github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。我们将首先检查重要的方法签名更改。

发现方法签名更改

PHP 8 引入了几个方法签名更改。如果您的代码扩展了本节描述的任何类或实现了任何方法,了解这些签名更改是很重要的。只要您了解了这些更改,您的代码将正常运行,从而减少错误。

PHP 8 中引入的签名更改反映了更新的最佳实践。因此,如果您编写使用正确方法签名的代码,您就是在遵循这些最佳实践。我们将从回顾 PHP 8 对魔术方法签名的更改开始我们的讨论。

管理魔术方法签名

在 PHP 8 中,魔术方法的定义和使用已经朝着标准化迈出了重要的一步。这是通过引入严格的参数和返回数据类型的精确魔术方法签名来实现的。与 PHP 8 中的大多数改进一样,这次更新旨在防止魔术方法的误用。总体结果是更好的代码,更少的错误。

这种增强的缺点是,如果您的代码提供了不正确的参数或返回值类型,将会抛出Error。另一方面,如果您的代码提供了正确的参数数据类型和返回值数据类型,或者如果您的代码根本不使用参数或返回值数据类型,这种增强将不会产生不良影响。

以下代码块总结了 PHP 8 及以上版本中魔术方法的新参数和返回值数据类型:

__call(string $name, array $arguments): mixed;
__callStatic(string $name, array $arguments): mixed;
__clone(): void;
__debugInfo(): ?array;
__get(string $name): mixed;
__invoke(mixed $arguments): mixed;
__isset(string $name): bool;
__serialize(): array;
__set(string $name, mixed $value): void;
__set_state(array $properties): object;
__sleep(): array;
__unserialize(array $data): void;
__unset(string $name): void;
__wakeup(): void;

现在,让我们看一下三个简单的例子,说明魔术方法签名更改的影响:

  1. 第一个例子涉及NoTypes类,该类定义了__call()但没有定义任何数据类型:
// /repo/ch09/php8_bc_break_magic.php
class NoTypes {
    public function __call($name, $args) {
        return "Attempt made to call '$name' "
             . "with these arguments: '"
             . implode(',', $args) . "'\n";
    }
}
$no = new NoTypes();
echo $no->doesNotExist('A','B','C');
  1. 以下示例(与前面的示例在同一个文件中)是MixedTypes类的示例,它定义了__invoke(),但使用的是array数据类型而不是mixed
class MixedTypes {
    public function __invoke(array $args) : string {
        return "Arguments: '"
             . implode(',', $args) . "'\n";
    }
}
$mixed= new MixedTypes();
echo $mixed(['A','B','C']);

这是在前面步骤中显示的代码示例的 PHP 7 输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php8_bc_break_magic.php 
Attempt made to call 'doesNotExist' with these arguments: 'A,B,C'
Arguments: 'A,B,C'

这是在 PHP 8 下运行的相同代码示例:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_bc_break_magic.php 
Attempt made to call 'doesNotExist' with these arguments: 'A,B,C'
Arguments: 'A,B,C'

正如您所看到的,这两组输出是相同的。第一个显示的类NoTypes之所以有效,是因为没有定义数据类型提示。有趣的是,MixedTypes类在 PHP 8 及以下版本中都可以工作,因为新的mixed数据类型实际上是所有类型的联合。因此,您可以安全地在mixed的位置使用任何特定的数据类型。

  1. 在我们的最后一个例子中,我们将定义WrongType类。在这个类中,我们将定义一个名为__isset()的魔术方法,使用一个与 PHP 8 要求不匹配的返回数据类型。在这里,我们使用的是string,而在 PHP 8 中,它的返回类型需要是bool
// /repo/ch09/php8_bc_break_magic_wrong.php
class WrongType {
    public function __isset($var) : string {
        return (isset($this->$var)) ? 'Y' : '';
    }
}
$wrong = new WrongType();
echo (isset($wrong->nothing)) ? 'Set' : 'Not Set';

这个例子在 PHP 7 中可以工作,因为它依赖于这样一个事实:如果变量未设置,则在这个例子中返回空字符串,然后被插入为FALSE布尔值。这是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php8_bc_break_magic_wrong.php 
Not Set

然而,在 PHP 8 中,由于魔术方法签名现在是标准化的,所以这个例子失败了,如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_bc_break_magic_wrong.php
PHP Fatal error:  WrongTypes::__isset(): Return type must be bool when declared in /repo/ch09/php8_bc_break_magic_wrong.php on line 6

正如您所看到的,PHP 8 严格执行其魔术方法签名。

提示

最佳实践:修改任何使用魔术方法的代码,以遵循新的严格方法签名。有关严格魔术方法签名的更多信息,请访问wiki.php.net/rfc/magic-methods-signature

现在您已经知道要查找什么以及如何纠正涉及魔术方法的潜在代码中断。现在,让我们看一下 Reflection 扩展方法签名的更改。

检查 Reflection 方法签名的更改

如果您的应用程序使用invoke()newInstance() Reflection 扩展方法,则可能会发生向后兼容的代码中断。在 PHP 7 及以下版本中,下面列出的三种方法都接受无限数量的参数。但是,在方法签名中,只列出了一个参数,如下所示:

  • ReflectionClass::newInstance($args)

  • ReflectionFunction::invoke($args)

  • ReflectionMethod::invoke($args)

在 PHP 8 中,方法签名准确地反映了现实,即$args之前有variadics运算符。以下是新的方法签名:

  • ReflectionClass::newInstance(...$args)

  • ReflectionFunction::invoke(...$args)

  • ReflectionMethod::invoke($object, ...$args)

只有当您的自定义类扩展了这三个类中的任何一个,并且您的自定义类还覆盖了前面列出的三种方法时,这种更改才会破坏您的代码。

最后,isBuiltin()方法已从ReflectionType移动到ReflectionNamedType。如果您使用ReflectionType::isBuiltIn(),这可能会导致潜在的代码中断。

现在,让我们看看 PDO 扩展中的方法签名更改。

处理 PDO 扩展签名更改

PDO 扩展有两个重要的方法签名更改。这些更改是为了解决在应用不同的获取模式时方法调用的不一致性。这是PDO::query()的新方法签名:

PDO::query(string $query, 
    ?int $fetchMode = null, mixed ...$fetchModeArgs)

这是PDOStatement::setFetchMode()的新签名:

PDOStatement::setFetchMode(int $mode, mixed ...$args)

提示

PDO::query()方法签名更改在 PHP 7.4 到 PHP 8 迁移指南中有所提及,链接在这里:https://www.php.net/manual/en/migration80.incompatible.php#migration80.incompatible.pdo。

这两个新的方法签名比旧的方法签名更加统一,并且完全涵盖了在使用不同的获取模式时的语法差异。一个简单的代码示例,使用两种不同的获取模式执行PDO::query(),说明了为什么需要规范化方法签名:

  1. 让我们从包含一个包含数据库连接参数的配置文件开始。从这个文件,我们将创建一个PDO实例:
// /repo/ch09/php8_pdo_signature_change.php
$config = include __DIR__ . '/../src/config/config.php';
$db_cfg = $config['db-config'];
$pdo    = new PDO($db_cfg['dsn'], 
    $db_cfg['usr'], $db_cfg['pwd']);
  1. 现在,让我们定义一个 SQL 语句并发送它以便进行准备:
$sql    = 'SELECT hotelName, city, locality, '
        . 'country, postalCode FROM hotels '
        . 'WHERE country = ? AND city = ?';
$stmt   = $pdo->prepare($sql);
  1. 接下来,我们将执行准备好的语句,并将获取模式设置为PDO::FETCH_ASSOC。请注意,当我们使用此获取模式时,setFetchMode()方法只提供一个参数:
$stmt->execute(['IN', 'Budhera']);
$stmt->setFetchMode(PDO::FETCH_ASSOC);
while ($row = $stmt->fetch()) var_dump($row);
  1. 最后,我们将再次执行相同的预处理语句。这次,我们将将获取模式设置为PDO::FETCH_CLASS。请注意,当我们使用此获取模式时,setFetchMode()方法提供了两个参数:
$stmt->execute(['IN', 'Budhera']);
$stmt->setFetchMode(
    PDO::FETCH_CLASS, ArrayObject::class);
while ($row = $stmt->fetch()) var_dump($row);

第一个查询的输出是一个关联数组。第二个查询产生一个ArrayObject实例。以下是输出:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_pdo_signature_change.php 
array(5) {
  ["hotelName"]=>  string(10) "Rose Lodge"
  ["city"]=>  string(7) "Budhera"
  ["locality"]=>  string(7) "Gurgaon"
  ["country"]=>  string(2) "IN"
  ["postalCode"]=>  string(6) "122505"
}
object(ArrayObject)#3 (6) {
  ["hotelName"]=>  string(10) "Rose Lodge"
  ["city"]=>  string(7) "Budhera"
  ["locality"]=>  string(7) "Gurgaon"
  ["country"]=>  string(2) "IN"
  ["postalCode"]=>  string(6) "122505"
  ["storage":"ArrayObject":private]=>  array(0) {  }
}

重要的是要注意,即使方法签名已经改变,您可以保持现有的代码不变:这不会导致向后兼容的代码中断!

现在,让我们来看一下被声明为static的方法。

处理新定义的静态方法

在 PHP 8 中看到的另一个可能重大的变化是,现在有几个方法被声明为static。如果您已经使用这里描述的类和方法作为直接对象实例,那么您就没有问题。

以下方法现在被声明为静态:

  • tidy::repairString()

  • tidy::repairFile()

  • XMLReader::open()

  • XMLReader::xml()

如果您覆盖了之前提到的类中的一个方法,可能会导致代码中断。在这种情况下,您必须将被覆盖的方法声明为static。以下是一个简单的示例,说明了可能的问题:

  1. 首先,让我们定义一个具有不匹配的<div>标签的字符串:
// /repo/ch08/php7_tidy_repair_str_static.php
$str = <<<EOT
<DIV>
    <Div>Some Content</div>
    <Div>Some Other Content
</div>
EOT;
  1. 然后,定义一个匿名类,该类扩展了tidy,修复了字符串,并返回所有 HTML 标签都是小写的字符串:
$class = new class() extends tidy {
    public function repairString($str) {
        $fixed = parent::repairString($str);
        return preg_replace_callback(
            '/<+?>/',
            function ($item) { 
                return strtolower($item); },
            $fixed);
    }
};
  1. 最后,输出修复后的字符串:
echo $class->repairString($str);

如果我们在 PHP 7 中运行此代码示例,输出将如下所示:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_tidy_repair_str_static.php 
<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<div>
<div>Some Content</div>
<div>Some Other Content</div>
</div>
</body>
</html>

正如您所看到的,不匹配的<div>标签已经修复,生成了一个格式正确的 HTML 文档。您还会注意到所有标签都是小写的。

然而,在 PHP 8 中,出现了一个方法签名问题,如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php7_tidy_repair_str_static.php 
PHP Fatal error:  Cannot make static method tidy::repairString() non static in class tidy@anonymous in /repo/ch09/php7_tidy_repair_str_static.php on line 11

正如您在 PHP 8 中所看到的,repairString()方法现在被声明为static。我们之前定义的匿名类中repairString()的方法签名需要重写,如下所示:

public static function repairString(
    string $str, 
    array|string|null $config = null, 
    ?string $encoding = null) { // etc.

重写后的输出与之前显示的 PHP 7 输出相同(未显示)。还要注意,最后一行现在也可以写成如下形式:

echo $class::repairString($str);

现在您已经了解了新定义为静态的方法,让我们来看一个相关主题;即,静态返回类型。

处理静态返回类型

在 PHP 中,static关键字在几个上下文中使用。它的基本用法超出了本讨论的范围。在本节中,我们将重点关注static作为返回数据类型的新用法。

由于static被认为是self的子类型,它可以用于扩展self的较窄返回类型。然而,static关键字不能用作类型提示,因为这将违反Liskov 替换原则。这也会让开发人员感到困惑,因为static已经在太多其他上下文中使用了。

提示

以下文章描述了在引入静态返回类型之前的背景讨论:wiki.php.net/rfc/static_return_type。以下文档引用了延迟静态绑定:www.php.net/manual/en/language.oop5.late-static-bindings.phpLiskov 替换原则第五章发现潜在的 OOP 向后兼容性中断中有讨论,在理解扩展的 PHP 8 方差支持部分。

这种新的返回数据类型最常见的用法是在使用流畅接口的类中。后者是一种技术,其中对象方法返回当前对象状态的实例,从而允许以流畅(可读)的方式使用一系列方法调用。在下面的例子中,请注意对象如何构建 SQL SELECT语句:

  1. 首先,我们必须定义一个Where类,它接受无限数量的参数来形成 SQL WHERE子句。注意static的返回数据类型:
// /src/Php8/Sql/Where.php
namespace Php8\Sql;
class Where {
    public $where = [];
    public function where(...$args) : static {
        $this->where = array_merge($this->where, $args);
        return $this;
    }
    // not all code is shown
}
  1. 现在,让我们定义主类Select,它提供了构建 SQL SELECT语句部分的方法。再次注意,所示的方法都返回当前类实例,并且返回数据类型为static
// /src/Php8/Sql/Select.php
namespace Php8\Sql;
class Select extends Where {
    public $from  = '';
    public $limit  = 0;
    public $offset  = 0;
    public function from(string $table) : static {
        $this->from = $table;
        return $this;
    }
    public function order(string $order) : static {
        $this->order = $order;
        return $this;
    }
    public function limit(int $num) : static {
        $this->limit = $num;
        return $this;
    }
    // not all methods and properties are shown
}
  1. 最后,我们必须定义一个调用程序,提供构建 SQL 语句所需的值。请注意,echo语句使用流畅接口,使得以编程方式创建 SQL 语句更容易理解:
// /repo/ch09/php8_static_return_type.php
require_once __DIR__ 
    . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php8\Sql\Select;
$start = "'2021-06-01'";
$end   = "'2021-12-31'";
$select = new Select();
echo $select->from('events')
           ->cols(['id', 'event_name', 'event_date'])
           ->limit(10)
           ->where('event_date', '>=', $start)
           ->where('AND', 'event_date', '<=', $end)
           ->render();

这是在 PHP 8 中运行代码示例的输出:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_static_return_type.php 
SELECT id,event_name,event_date FROM events WHERE event_date >= '2021-06-01' AND event_date <= '2021-12-31' LIMIT 10

当然,这个例子在 PHP 7 中不起作用,因为static关键字不可用作返回数据类型。接下来,让我们来看看特殊的::class常量的扩展使用。

扩展使用::class 常量

特殊的::class常量是一个非常有用的构造,因为它可以悄悄地扩展为完整的命名空间加类名字符串。了解它的用法,以及它在 PHP 8 中的扩展用法,可以节省大量时间。它的使用也可以使你的代码更易读,特别是当你处理冗长的命名空间和类名时。

特殊的::class常量是作用域解析操作符(::)和class关键字的组合。然而,与::parent::self::static不同,::class构造可以在类定义之外使用。在某种意义上,::class构造是一种魔术常量,因为它会导致与其关联的类在编译时魔术般地扩展为完整的命名空间加类名。

在我们深入探讨它在 PHP 8 中的扩展使用之前,让我们先看看它的常规用法。

常规::class 常量使用

特殊的::class常量经常在你有一个冗长的命名空间并且希望不仅节省大量不必要的输入,而且保持源代码的可读性的情况下使用。

在这个简单的例子中,使用Php7\Image\Strategy命名空间,我们希望创建一个策略类的列表:

  1. 首先,让我们确定命名空间并设置自动加载程序:
// /repo/ch09/php7_class_normal.php
namespace Php7\Image\Strategy;
require_once __DIR__ 
    . '/../src/Server/Autoload/Loader.php';
$autoload = new \Server\Autoload\Loader();
  1. 在特殊的::class常量被引入之前,要生成完整命名空间类名的列表,你必须将其全部写成字符串,如下所示:
$listOld = [
    'Php7\Image\Strategy\DotFill',
    'Php7\Image\Strategy\LineFill',
    'Php7\Image\Strategy\PlainFill',
    'Php7\Image\Strategy\RotateText',
    'Php7\Image\Strategy\Shadow'
];
print_r($listOld);
  1. 使用特殊的::class常量,你可以减少所需的输入量,也可以使代码更易读,如下所示:
$listNew = [
    DotFill::class,
    LineFill::class,
    PlainFill::class,
    RotateText::class,
    Shadow::class
];
print_r($listNew);

如果我们运行这个代码示例,我们会看到两个列表在 PHP 7 和 PHP 8 中是相同的。这是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_class_normal.php 
Array (
    [0] => Php7\Image\Strategy\DotFill
    [1] => Php7\Image\Strategy\LineFill
    [2] => Php7\Image\Strategy\PlainFill
    [3] => Php7\Image\Strategy\RotateText
    [4] => Php7\Image\Strategy\Shadow
)
Array (
    [0] => Php7\Image\Strategy\DotFill
    [1] => Php7\Image\Strategy\LineFill
    [2] => Php7\Image\Strategy\PlainFill
    [3] => Php7\Image\Strategy\RotateText
    [4] => Php7\Image\Strategy\Shadow
)

正如你所看到的,特殊的::class常量会导致类名在编译时扩展为完整的命名空间加类名,从而使两个列表包含相同的信息。

现在,让我们来看看 PHP 8 中特殊的::class常量的用法。

扩展特殊::class 常量的使用

与 PHP 8 中看到的其他语法统一改进一致,现在可以在活动对象实例上使用特殊的::class常量。虽然效果与使用get_class()相同,但使用特殊的::class常量作为远离过程化向面向对象编程的一般最佳实践的一部分是有意义的。

在这个例子中,扩展的::class语法用于确定抛出的错误类型:

  1. 当抛出ErrorException时,最佳实践是在错误日志中记录条目。在这个例子中,在 PHP 7 和 PHP 8 中都有效,这个ErrorException的类名被包含在日志消息中:
// /repo/ch09/php7_class_and_obj.php
try {
    $pdo = new PDO();
    echo 'No problem';
} catch (Throwable $t) {
    $msg = get_class($t) . ':' . $t->getMessage();
    error_log($msg);
}
  1. 在 PHP 8 中,您可以通过重新编写示例来实现相同的结果,如下所示:
// /repo/ch09/php8_class_and_obj.php
try {
    $pdo = new PDO();
    echo 'No problem';
} catch (Throwable $t) {
    $msg = $t::class . ':' . $t->getMessage();
    error_log($msg);
}

从第二个代码块中可以看出,语法更加简洁,避免了使用过程函数。然而,我们必须强调,在这个例子中,并没有性能上的提升。

现在您已经了解了特殊::class常量使用的变化,让我们快速看一下逗号。

利用尾随逗号

长期以来,PHP 允许在定义数组时使用尾随逗号。例如,下面显示的语法并不罕见:

$arr = [1, 2, 3, 4, 5,];

然而,在函数或方法签名中做同样的事情是不允许的:

function xyz ($fn, $ln, $mid = '',) { /* code */ }

虽然这并不是什么大不了的事,但在定义数组时能够添加尾随逗号,但在函数或方法签名中却不允许同样的自由,这是令人恼火的!

PHP 8 现在允许您在函数和方法签名中都使用尾随逗号。新规则也适用于与匿名函数相关的use()语句。

为了说明这种变化,考虑以下例子。在这个例子中,定义了一个匿名函数来渲染一个全名:

// /repo/ch09/php8_trailing_comma.php
$full = function ($fn, $ln, $mid = '',) {
    $mi = ($mid) ? strtoupper($mid[0]) . '. ' : '';
    return $fn . ' ' . $mi . $ln;
};
echo $full('Fred', 'Flintstone', 'John');

如您所见,匿名函数的第三个参数后面有一个逗号。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php8_trailing_comma.php 
PHP Parse error:  syntax error, unexpected ')', expecting variable (T_VARIABLE) in /repo/ch09/php8_trailing_comma.php on line 4

在 PHP 8 中,允许使用尾随逗号,并且预期的输出如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_trailing_comma.php 
Fred J. Flintstone

虽然在函数或方法定义中使用尾随逗号不一定是最佳实践,但它确实使 PHP 8 在对待尾随逗号的整体处理上保持一致。

现在,让我们把注意力转向仍然存在但不再有任何用处的方法。

学习不再需要的方法

主要是由于 PHP 8 资源到对象的迁移,许多函数和方法不再需要。在撰写本文时,它们并没有被弃用,但这些函数不再具有任何实际用途。

类比一下,在 PHP 8 之前的版本中,您会使用fopen()来打开一个文件句柄资源。完成文件操作后,您通常会使用fclose()关闭文件句柄资源的连接。

现在,假设您使用SplFileObject而不是fopen()。当文件操作完成后,您可以简单地取消对象。这与使用fclose()达到了相同的效果,使fclose()变得多余。

下表总结了存在但在 PHP 8 中不再具有任何实际价值的函数。带有星号标记的函数也已被弃用:

表 9.1 - 不再有用的函数

表 9.1 - 不再有用的函数

现在您已经了解了 PHP 8 中主要方法签名和用法的变化,让我们来看看在使用接口和特征时需要考虑的最佳实践。

使用接口和特征

PHP 8 特性实现在几个方面得到了扩展。还有一些新的接口可能会改变您使用 DOM 和 DateTime 扩展的方式。在大多数情况下,这些变化提高了这两个扩展的功能。然而,由于某些情况下方法签名已经改变,可能会出现潜在的代码中断。因此,重要的是要密切关注本节中提出的讨论,以确保现有和未来的 PHP 代码保持功能正常。

首先,让我们来看看新的 DOM 扩展接口。

发现新的 DOM 扩展接口

生活成本经济统计数据每年由许多世界政府发布。它描述了一个普通公民每年生活的成本。随着网络技术的成熟,类似的原则已被应用 - 首先是 HTML,现在是 DOM。DOM Living StandardWeb Hypertext Application Technology Working GroupWHATWG)维护(whatwg.org/)。

这些信息对 PHP 开发人员重要的原因是,在 PHP 8 中,决定将 PHP DOM 扩展移动到 DOM Living Standard。因此,从 PHP 8 开始,将根据最新标准的变化对这个扩展进行一系列增量和持续的更改。

在很大程度上,这些变化是向后兼容的。然而,由于一些方法签名的变化以保持符合标准,可能会导致代码中断。在 PHP 8 中对 DOM 扩展最重要的变化是引入了两个新接口。让我们来检查这些接口,然后讨论它们对 PHP 开发的影响。

检查新的 DOMParentNode 接口

两个新接口中的第一个是DOMParentNode。以下类在 PHP 8 中实现了这个接口:

  • DOMDocument

  • DOMElement

  • DOMDocumentFragment

这是接口定义:

interface DOMParentNode {
    public readonly ?DOMElement $firstElementChild;
    public readonly ?DOMElement $lastElementChild;
    public readonly int $childElementCount;
    public function append(
        ...DOMNode|string|null $nodes) : void;
    public function prepend(
        ...DOMNode|string|null $nodes) : void;
}

重要的是要注意,对于 PHP 开发人员来说,没有readonly属性可用。然而,接口规范将属性显示为只读,因为它们是内部生成的,不能被更改。

提示

实际上,2014 年曾提出了一个 PHP RFC,建议为类属性添加readonly属性。然而,这个提案被撤回,因为可以通过定义常量或简单地将属性标记为private来实现相同的效果!有关此提案的更多信息,请参见wiki.php.net/rfc/readonly_properties

以下表总结了新的DOMParentNode接口的属性和方法:

表 9.2 – DOMParentNode 接口方法和属性

表 9.2 – DOMParentNode 接口方法和属性

新接口所代表的功能并没有为现有的 DOM 功能增加任何新内容。它的主要目的是使 PHP DOM 扩展符合最新的标准。

提示

将来,对 DOM 扩展进行架构上的改造还有另一个目的。在未来的 PHP 版本中,DOM 扩展将有能力操作整个 DOM 树的一个分支。例如,在未来,当你发出append()时,你将能够附加不仅仅是一个节点,还有它的所有子节点。有关更多信息,请参见以下 RFC:wiki.php.net/rfc/dom_living_standard_api

现在,让我们看看第二个新接口。

检查新的 DOMChildNode 接口

两个新接口中的第二个是DOMChildNodeDOMElementDOMCharacterData类在 PHP 8 中实现了这个接口。

这是接口定义:

interface DOMChildNode {
    public readonly ?DOMElement $previousElementSibling;
    public readonly ?DOMElement $nextElementSibling;
    public function remove() : void;
    public function before(
        ...DOMNode|string|null $nodes) : void;
    public function after(
        ...DOMNode|string|null $nodes) : void;
    public function replaceWith(
        ...DOMNode|string|null $nodes) : void;
}

以下表总结了DOMChildNode的方法和属性:

表 9.3 – DOMChildNode 接口方法和属性

表 9.3 – DOMChildNode 接口方法和属性

在这种情况下,功能与现有的 DOM 功能略有不同。最重要的变化是DOMChildNode::remove()。在 PHP 8 之前,要删除一个节点,你必须访问它的父节点。假设$topic是一个DOMElement实例,PHP 7 或更早的代码可能如下所示:

$topic->parentNode->removeChild($topic);

在 PHP 8 中,相同的代码可以写成如下形式:

$topic->remove();

除了前面显示的两个表中提到的新方法之外,DOM 功能保持不变。现在,让我们看看如何在 PHP 8 中重写移动子节点以利用新接口。

DOM 使用示例 – 比较 PHP 7 和 PHP 8

为了说明新接口的使用,让我们看一个代码示例。在本节中,我们将呈现一段使用 DOM 扩展来将代表Topic X的节点从一个文档移动到另一个文档的代码块:

  1. 这是一个包含一组嵌套的<div>标签的 HTML 片段:
<!DOCTYPE html>
<!-- /repo/ch09/dom_test_1.html -->
<div id="content">
<div id="A">Topic A</div>
<div id="B">Topic B</div>
<div id="C">Topic C</div>
<div id="X">Topic X</div>
</div>
  1. 第二个 HTML 片段包括主题 D、E 和 F:
<!DOCTYPE html>
<!-- /repo/ch09/dom_test_2.html -->
<div id="content">
<div id="D">Topic D</div>
<div id="E">Topic E</div>
<div id="F">Topic F</div>
</div>
  1. 从这两个片段中创建DOMDocument实例,我们可以进行静态调用;也就是loadHTMLFile。请注意,这种用法在 PHP 7 中已经被弃用,并在 PHP 8 中被移除了。
$doc1 = DomDocument::loadHTMLFile( 'dom_test_1.html');
$doc2 = DomDocument::loadHTMLFile('dom_test_2.html');
  1. 然后,我们可以将Topic X提取到$topic中,并将其导入到第二个文档中作为$new。接下来,检索目标节点;也就是content
$topic = $doc1->getElementById('X');
$new = $doc2->importNode($topic);
$new->textContent= $topic->textContent;
$main = $doc2->getElementById('content');
  1. 这是 PHP 7 和 PHP 8 开始有所不同的地方。在 PHP 7 中,要移动节点,代码必须如下所示:
// /repo/ch09/php7_dom_changes.php
$main->appendChild($new);
$topic->parentNode->removeChild($topic);
  1. 然而,在 PHP 8 中,当使用新接口时,代码更加紧凑。在 PHP 8 中,不需要引用父节点来移除主题。
// /repo/ch09/php8_dom_changes.php
$main->append($new);
$topic->remove();
  1. 对于 PHP 7 和 PHP 8,我们可以这样查看生成的 HTML:
echo $doc1->saveHTML();
echo $doc2->saveHTML();
  1. 另一个不同之处是如何提取$main的新最后一个子元素的值。在 PHP 7 中可能如下所示:
// /repo/ch09/php7_dom_changes.php
echo $main->lastChild->textContent . "\n";
  1. 在 PHP 8 中也是一样的:
// /repo/ch09/php8_dom_changes.php
echo $main->lastElementChild->textContent . "\n";

这两个代码示例的输出略有不同。在 PHP 7 中,您将看到一个弃用通知,如下所示:

root@php8_tips_php7 [ /repo/ch09 ]# php php7_dom_changes.php 
PHP Deprecated:  Non-static method DOMDocument::loadHTMLFile() should not be called statically in /repo/ch09/php7_dom_changes.php on line 6

如果我们尝试在 PHP 8 中运行 PHP 7 代码,将会抛出致命的Error,因为loadHTMLFile()方法的静态使用不再被允许。否则,如果我们运行纯 PHP 8 示例,输出将如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# php php8_dom_changes.php 
<!DOCTYPE html>
<html><body><div id="content">
<div id="A">Topic A</div>
<div id="B">Topic B</div>
<div id="C">Topic C</div>
</div>
</body></html>
<!DOCTYPE html>
<html><body><div id="content">
<div id="D">Topic D</div>
<div id="E">Topic E</div>
<div id="F">Topic F</div>
<div id="X">Topic X</div></div>
</body></html>
Last Topic in Doc 2: Topic X

如您所见,在任何情况下,Topic X都从第一个 HTML 片段移动到了第二个片段中。

在未来的 PHP 版本中,预计 DOM 扩展将继续增长,同时遵循 DOM 的生存标准。此外,它的使用将变得更加容易,提供更多的灵活性和效率。

现在,让我们关注DateTime扩展中的变化。

使用新的 DateTime 方法

在处理日期和时间时,通常很有用创建DateTimeImmutable实例。DateTimeImmutable对象与DateTime对象相同,只是它们的属性值不能被改变。知道如何在DateTimeDateTimeImmutable之间来回切换是一种有用的技巧,可以避免许多隐藏的逻辑错误。

在讨论 PHP 8 中的改进之前,让我们先看看DateTimeImmutable解决的潜在问题。

DateTimeImmutable 的用例

在这个简单的例子中,将创建一个包含今天之后 30、60 和 90 天的三个实例的数组。这些将被用来形成一个 30-60-90 天应收账款老化报告的基础。

  1. 首先,让我们初始化一些代表间隔、日期格式和保存最终值的数组的关键变量:
// /repo/ch09/php7_date_time_30-60-90.php
$days  = [0, 30, 60, 90];
$fmt   = 'Y-m-d';
$aging = [];
  1. 现在,让我们定义一个循环,将间隔添加到DateTime实例中,以产生(希望如此!)表示 0、30、60 和 90 天的数组。经验丰富的开发人员很可能已经发现了问题!
$dti = new DateTime('now');
foreach ($days as $span) {
    $interval = new DateInterval('P' . $span . 'D');
    $item = $dti->add($interval);
    $aging[$span] = clone $item;
}
  1. 接下来,显示已生成的日期集合:
echo "Day\tDate\n";
foreach ($aging as $key => $obj)
    echo "$key\t" . $obj->format($fmt) . "\n";
  1. 这里显示的输出是一场完全的灾难:
root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_date_time_30-60-90.php 
Day    Date
0    2021-11-20
30    2021-06-23
60    2021-08-22
90    2021-11-20

如您所见,问题在于DateTime类不是不可变的。因此,每次添加DateInterval时,原始值都会被改变,导致显示的日期不准确。

  1. 然而,通过进行一个简单的改变,我们可以解决这个问题。我们只需要创建一个DateTimeImmutable实例,而不是最初创建一个DateTime实例:
$dti = new DateTimeImmutable('now');
  1. 然而,要用DateTime实例填充数组,我们需要将DateTimeImmutable转换为DateTime。在 PHP 7.3 中,引入了DateTime::createFromImmutable()方法。因此,当值被赋给$aging时,修改后的代码可能如下所示:
$aging[$span] = DateTime::createFromImmutable($item);
  1. 否则,您将被困在创建一个新的DateTime实例中,如下所示:
$aging[$span] = new DateTime($item->format($fmt));

通过这一个改变,正确的输出将如下所示:

Day    Date
0    2021-05-24
30    2021-06-23
60    2021-07-23
90    2021-08-22

现在你知道了DateTimeImmutable可能如何被使用,也知道了如何转换成DateTime。你会高兴地知道,在 PHP 8 中,通过引入createFromInterface()方法,这两种对象类型之间的转换变得更加容易了。

检查 createFromInterface()方法

在 PHP 8 中,转换 DateTimeDateTimeImmutable 之间变得更加容易。两个类都添加了一个名为 createFromInterface() 的新方法。该方法的签名只是要求一个 DateTimeInterface 实例,这意味着 DateTimeDateTimeImmutable 的实例都是这个方法的可接受参数。

以下简短的代码示例演示了在 PHP 8 中将一个类型转换为另一个类型是多么容易:

  1. 首先,定义一个 DateTimeImmutable 实例并 echo 其类和日期:
// /repo/ch09/php8_date_time.php
$fmt = 'l, d M Y';
$dti = new DateTimeImmutable('last day of next month');
echo $dti::class . ':' . $dti->format($fmt) . "\n";
  1. 然后,从 $dti 创建一个 DateTime 实例,并添加 90 天的间隔,显示其类和当前日期:
$dtt = DateTime::createFromInterface($dti);
$dtt->add(new DateInterval('P90D'));
echo $dtt::class . ':' . $dtt->format($fmt) . "\n";
  1. 最后,从 $dtt 创建一个 DateTimeImmutable 实例并显示其类和日期:
$dtx = DateTimeImmutable::createFromInterface($dtt);
echo $dtx::class . ':' . $dtx->format($fmt) . "\n";

以下是在 PHP 8 中运行此代码示例的输出:

root@php8_tips_php8 [ /repo/ch09 ]# php php8_date_time.php 
DateTimeImmutable:Wednesday, 30 Jun 2021
DateTime:Tuesday, 28 Sep 2021
DateTimeImmutable:Tuesday, 28 Sep 2021

如您所见,我们使用相同的 createFromInterface() 方法来创建实例。当然,请记住,我们实际上并没有将类实例转换为另一个类实例。相反,我们创建了克隆实例,但是属于不同的类类型。

您现在知道为什么您可能希望使用 DateTimeImmutable 而不是 DateTime。您还知道在 PHP 8 中,一个名为 createFromInterface() 的新方法提供了一种统一的方式来创建一个类的实例。接下来,我们将看一下在 PHP 8 中如何改进 traits 的处理方式。

理解 PHP 8 中 traits 处理的改进

Traits 的实现首次出现在 PHP 版本 5.4 中。从那时起,不断进行了一系列的改进。PHP 8 继续这一趋势,通过提供一种明确识别多个 traits 具有冲突方法的方法来清楚地标识使用哪些方法。此外,除了消除可见性声明中的不一致性之外,PHP 8 还解决了 traits 处理(或未处理!)抽象方法的问题。

作为开发人员,完全掌握 traits 的使用能力使您能够编写更高效、更易于维护的代码。Traits 可以帮助您避免生成冗余的代码。它们解决了需要在命名空间之间或不同类继承结构之间使用相同逻辑的问题。本节中提供的信息使您能够在 PHP 8 下正确使用 traits。

首先,让我们看看 PHP 8 中如何解决 traits 之间的冲突。

解决 traits 之间的方法冲突

多个 traits 可以通过简单地用逗号分隔的 trait 名称列表来使用。然而,可能会出现一个问题,如果两个 traits 定义了相同的方法。为了解决这种冲突,PHP 提供了 as 关键字。在 PHP 7 及更低版本中,为了避免两个同名方法之间的冲突,您可以简单地重命名其中一个方法。执行重命名的代码可能如下所示:

use Trait1, Trait2 { <METHOD> as <NEW_NAME>; }

然而,这种方法的问题在于,PHP 做出了一个悄悄的假设:METHOD 被假定来自 Trait1!为了继续执行良好的编码实践,PHP 8 不再允许这种假设。在 PHP 8 中的解决方案是更具体地使用 insteadof 而不是 as

以下是说明这个问题的一个微不足道的例子:

  1. 首先,让我们定义两个定义相同方法 test() 的 traits,但返回不同的结果:
// /repo/ch09/php7_trait_conflict_as.php
trait Test1 {
    public function test() {
        return '111111';
    }
}
trait Test2 {
    public function test() {
        return '222222';
    }
}
  1. 然后,定义一个匿名类,使用两个 traits 并将 test() 指定为 otherTest() 以避免命名冲突:
$main = new class () {
    use Test1, Test2 { test as otherTest; }
    public function test() { return 'TEST'; }
};
  1. 接下来,定义一个代码块来 echo 两个方法的返回值:
echo $main->test() . "\n";
echo $main->otherTest() . "\n";

以下是在 PHP 7 中的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_trait_conflict_as.php 
TEST
111111

如您所见,PHP 7 默默地假设我们的意思是将 Trait1::test() 重命名为 otherTest()。然而,从示例代码中,程序员的意图并不清楚!

在 PHP 8 中运行相同的代码示例,我们得到了不同的结果:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php7_trait_conflict_as.php 
PHP Fatal error:  An alias was defined for method test(), which exists in both Test1 and Test2\. Use Test1::test or Test2::test to resolve the ambiguity in /repo/ch09/php7_trait_conflict_as.php on line 6

显然,PHP 8 不会做出这样的悄悄假设,因为它们很容易导致意外行为。在这个例子中,最佳实践是使用作用域解析 (::) 运算符。以下是重写后的代码:

$main = new class () {
    use Test1, Test2 { Test1::test as otherTest; }
    public function test() { return 'TEST'; }
};

如果我们在 PHP 8 中重新运行代码,输出将与 PHP 7 中显示的输出相同。作用域解析运算符确认Trait1test()方法的源 trait,从而避免了任何歧义。现在,让我们看看 PHP 8 traits 如何处理抽象方法签名。

处理 trait 抽象签名检查

正如 API 开发人员所知,将方法标记为abstract是向 API 用户发出方法是必需的信号,但尚未定义。这种技术允许 API 开发人员不仅规定方法名称,还规定其签名。

然而,在 PHP 7 及更低版本中,在 trait 中定义的抽象方法会忽略签名,从而打败了使用抽象方法的初衷的一部分!当您在 PHP 8 中使用带有抽象方法的 trait 时,其签名将与使用该 trait 的类中的实现进行检查。

以下示例在 PHP 7 中有效,但在 PHP 8 中失败,因为方法签名不同:

  1. 首先,让我们声明严格类型检查并定义一个带有抽象方法add()的 trait。请注意,方法签名要求所有数据类型都是整数:
// /repo/ch09/php7_trait_abstract_signature.php
declare(strict_types=1);
trait Test1 {
    public abstract function add(int $a, int $b) : int;
}
  1. 接下来,定义一个使用 trait 并定义add()的匿名类。请注意,类的数据类型都是float
$main = new class () {
    use Test1;
    public function add(float $a, float $b) : float {
        return $a + $b;
    }
};
  1. 然后,输出添加111.111222.222的结果:
echo $main->add(111.111, 222.222) . "\n";

在 PHP 7 中运行的这个小代码示例的结果令人惊讶:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_trait_abstract_signature.php 
333.333

从结果中可以看出,在 trait 中抽象定义的方法签名完全被忽略了!然而,在 PHP 8 中,结果大不相同。以下是在 PHP 8 中运行代码的输出:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php7_trait_abstract_signature.php 
PHP Fatal error:  Declaration of class@anonymous::add(float $a, float $b): float must be compatible with Test1::add(int $a, int $b): int in /repo/ch09/php7_trait_abstract_signature.php on line 9

前面的 PHP 8 输出向我们展示了良好的编码实践是如何被强制执行的,无论抽象方法定义的来源如何。

本节的最后一个主题将向您展示如何处理 trait 中的抽象私有方法。

处理 trait 中的私有抽象方法

一般来说,在 PHP 中,您无法对抽象私有方法在抽象超类中施加控制,因为它不会被继承。然而,在 PHP 8 中,您可以在 trait 中定义一个抽象私有方法!当您进行 API 开发时,这可以用作代码执行机制,其中using类需要定义指定的私有方法。

请注意,尽管您可以在 PHP 8 trait 中将抽象方法指定为私有,但 trait 方法的可见性可以很容易地在使用 trait 的类中被覆盖。因此,在本节中我们不会展示任何代码示例,因为私有抽象 trait 方法的效果与在其他可见性级别使用抽象 trait 方法完全相同。

提示

有关 PHP 8 中处理 trait 抽象方法的更多信息,请查看此 RFC:wiki.php.net/rfc/abstract_trait_method_validation

现在,让我们来看看私有方法的一般用法变化。

处理私有方法

开发人员创建超类的原因之一是对子类的方法签名施加一定程度的控制。在解析阶段,PHP 通常会确认方法签名是否匹配。这导致其他开发人员正确使用您的代码。

同样地,如果将方法标记为private,让 PHP 执行同样严格的方法签名检查是没有意义的。私有方法的目的是对扩展类不可见。如果您在扩展类中定义了同名方法,您应该可以自由定义它。

为了说明这个问题,让我们定义一个名为Cipher的类,其中包含一个名为encrypt()的私有方法。OpenCipher子类重新定义了这个方法,在 PHP 7 下运行时会导致致命错误:

  1. 首先,让我们定义一个Cipher类,其构造函数为$key$salt生成随机值。它还定义了一个名为encode()的公共方法,该方法调用了私有的encrypt()方法:
// /repo/src/Php7/Encrypt/Cipher.php
namespace Php7\Encrypt;
class Cipher {
    public $key  = '';
    public $salt = 0;
    public function __construct() {
        $this->salt  = rand(1,255);
        $this->key   = bin2hex(random_bytes(8));
    }
    public function encode(string $plain) {
        return $this->encrypt($plain);
    }
  1. 接下来,让我们定义一个名为encrypt()的私有方法,该方法使用str_rot13()生成加密文本。请注意,该方法标记为final。尽管这没有任何意义,但为了说明这一点,请假设这是有意的:
    final private function encrypt(string $plain) {       
        return base64_encode(str_rot13($plain));
    }
}
  1. 最后,让我们定义一个简短的调用程序,创建类实例并调用已定义的方法:
// /repo/ch09/php7_oop_diffs_private_method.php
include __DIR__ . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php7\Encrypt\{Cipher,OpenCipher};
$text = 'Super secret message';
$cipher1 = new Cipher();
echo $cipher1->encode($text) . "\n";
$cipher2 = new OpenCipher();
var_dump($cipher2->encode($text));

如果我们在 PHP 7 中运行调用程序,将得到以下输出:

oot@php8_tips_php7 [ /repo/ch09 ]# 
php php7_oop_diffs_private_method.php 
RmhjcmUgZnJwZXJnIHpyZmZudHI=
PHP Fatal error:  Cannot override final method Php7\Encrypt\Cipher::encrypt() in /repo/src/Php7/Encrypt/OpenCipher.php on line 21

在这里,您可以看到Cipher的输出已正确生成。但是,还抛出了一个致命的Error,并附带一条消息,说明我们无法覆盖final方法。理论上,私有方法应该对子类完全不可见。但是,从输出中可以清楚地看到,情况并非如此。Cipher超类的私有方法签名影响了我们在子类中重新定义相同方法的能力。

然而,在 PHP 8 中,这个悖论已经得到解决。以下是在 PHP 8 中运行相同代码的输出:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php7_oop_diffs_private_method.php
PHP Warning:  Private methods cannot be final as they are never overridden by other classes in /repo/src/Php7/Encrypt/Cipher.php on line 17
RmhjcmUgZnJwZXJnIHpyZmZudHI=
array(2) {
  ["tag"]=>  string(24) "woD6Vi73/IXLaKHFGUC3aA=="
  ["cipher"]=>  string(28) "+vd+jWKqo8WFPd7SakSvszkoIX0="

从前面的输出中可以看到,应用程序成功,并且父类和子类的输出都显示出来。我们还可以看到一个Warning,告诉我们私有方法不能标记为final

提示

有关私有方法签名的背景讨论的更多信息,请参阅此文档参考:

wiki.php.net/rfc/inheritance_private_methods

现在您已经了解了 PHP 8 如何阻止子类看到超类中的私有方法,让我们将注意力转向 PHP 8 中匿名类的差异。

控制匿名类的使用

匿名类根据其定义,没有名称。但是,为了提供信息,PHP 信息函数(如var_dump()var_export()get_class()和 Reflection 扩展中的其他类)将匿名类简单地报告为class@anonymous。但是,当匿名类扩展另一个类或实现接口时,让 PHP 信息函数反映这一事实可能会有所用处。

在 PHP 8 中,扩展类或实现接口的匿名类现在通过更改分配给匿名类的标签来反映这一事实,标签为Xyz@anonymous,其中Xyz是类或接口的名称。如果匿名类实现多个接口,则只会显示第一个接口。如果匿名类扩展了一个类并且还实现了一个或多个接口,则其标签中将显示扩展的类的名称。以下表格总结了这些可能性:

表 9.4 - 匿名类提升

表 9.4 - 匿名类提升

请记住,PHP 已经可以测试匿名类是否属于某一继承线。例如,instanceof运算符可以用于此目的。以下示例说明了如何测试匿名类的继承关系,以及如何查看其新名称:

  1. 对于这个示例,我们将定义一个DirectoryIterator实例,从当前目录中获取文件列表:
// /repo/ch09/php8_oop_diff_anon_class_renaming.php
$iter = new DirectoryIterator(__DIR__);
  1. 然后,我们将定义一个匿名类,该类扩展了FilterIterator。在这个类中,我们将定义accept()方法,该方法产生布尔结果。如果结果为TRUE,则该项将出现在最终迭代中:
$anon = new class ($iter) extends FilterIterator {
    public $search = '';
    public function accept() {
        return str_contains(
            $this->current(), $this->search);
    }
};
  1. 接下来,我们将生成一个包含名称中包含bc_break的文件列表:
$anon->search = 'bc_break';
foreach ($anon as $fn) echo $fn . "\n";
  1. 在接下来的两行中,我们将使用instanceof来测试匿名类是否实现了OuterIterface
if ($anon instanceof OuterIterator)
    echo "This object implements OuterIterator\n";
  1. 最后,我们将使用var_dump()简单地转储匿名类的内容:
echo var_dump($anon);

这是在 PHP 8 下运行的输出。我们无法在 PHP 7 中运行此示例,因为该版本缺少str_contains()函数!

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_oop_diff_anon_class_renaming.php
php8_bc_break_construct.php
php8_bc_break_serialization.php
php8_bc_break_destruct.php
php8_bc_break_sleep.php
php8_bc_break_scanner.php
php8_bc_break_serializable.php
php8_bc_break_magic_wrong.php
php8_bc_break_magic.php
php8_bc_break_magic_to_string.php
This object implements OuterIterator
object(FilterIterator@anonymous)#2 (1) {
  ["search"]=>  string(8) "bc_break"
}

正如你所看到的,instanceof正确报告匿名类实现了OuterInterface(因为它扩展了FilterIterator,而FilterIterator又实现了OuterInterface)。你还可以看到,var_dump()报告了匿名类的名称为FilterIterator@anonymous

现在你已经了解了 PHP 8 中匿名类命名的变化,让我们来看看命名空间处理的变化。

理解命名空间的变化

命名空间的概念在 PHP 5.3 中被引入,作为隔离类层次结构的一种手段。不幸的是,用于解析命名空间名称的原始算法存在一些缺陷。除了过于复杂外,命名空间和类名在内部的标记化方式也是以一种不一致的方式进行的,导致意外的错误。

在我们深入讨论好处和潜在的向后兼容性问题之前,让我们看看命名空间标记化过程发生了什么变化。

发现标记化的差异

标记化过程是解释过程的重要部分,在执行 PHP 代码时进行。在生成字节码的过程中,PHP 程序代码被 PHP 解析引擎分解成标记。

以下表格总结了命名空间标记化的变化:

表 9.5 - PHP 8 中的命名空间标记化差异

表 9.5 - PHP 8 中的命名空间标记化差异

正如你所看到的,PHP 8 的命名空间标记化要简单得多。

提示

有关解析器标记的更多信息,请参阅以下文档参考:www.php.net/manual/en/tokens

这个改变的影响产生了非常积极的结果。首先,你现在可以在命名空间中使用保留关键字。此外,在将来,当语言引入新关键字时,PHP 不会强制你在应用程序中更改命名空间。新的标记化过程还促进了在命名空间中使用Attributes

首先,让我们看看在命名空间中使用关键字。

在命名空间中使用保留关键字

在 PHP 7 中,命名空间标记化过程产生了一系列字符串(T_STRING)和反斜杠(T_NS_SEPARATOR)标记。这种方法的问题在于,如果其中一个字符串恰好是 PHP 关键字,解析过程中会立即抛出语法错误。然而,PHP 8 只产生一个单一标记,如前面所示,在表 9.5中。最终,这意味着你几乎可以在命名空间中放任何东西,而不必担心保留关键字的冲突。

以下代码示例说明了 PHP 8 和早期版本之间命名空间处理的差异。在这个例子中,一个 PHP 关键字被用作命名空间的一部分。在 PHP 7 中,由于其不准确的标记化过程,List被视为关键字而不是命名空间的一部分:

// /repo/ch09/php8_namespace_reserved.php
namespace List\Works\Only\In\PHP8;
class Test {
    public const TEST = 'TEST';
}
echo Test::TEST . "\n";

以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php8_namespace_reserved.php 
PHP Parse error:  syntax error, unexpected 'List' (T_LIST),
expecting '{' in /repo/ch09/php8_namespace_reserved.php on line 3

在 PHP 8 中,程序代码片段按预期工作,如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php8_namespace_reserved.php 
TEST

现在你已经了解了 PHP 8 和早期 PHP 版本之间标记化过程的差异,以及它的潜在好处,让我们来看看潜在的向后兼容代码中的可能断裂。

暴露不良的命名空间命名惯例

PHP 8 的标记化过程使你不必担心关键字冲突,但也可能暴露不良的命名空间命名惯例。任何包含空格的命名空间现在都被视为无效。然而,在命名空间中包含空格本来就是一种不良习惯!

以下简单的代码示例说明了这个原则。在这个例子中,你会注意到命名空间包括了空格:

// /repo/ch09/php7_namespace_bad.php
namespace Doesnt \Work \In \PHP8;
class Test {
    public const TEST = 'TEST';
}
echo Test::TEST . "\n";

如果我们在 PHP 7 中运行这段代码,它可以正常工作。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch09 ]# 
php php7_namespace_bad.php
TEST

然而,在 PHP 8 中,会抛出一个ParseError,如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# 
php php7_namespace_bad.php
PHP Parse error:  syntax error, unexpected fully qualified name "\Work", expecting "{" in /repo/ch09/php7_namespace_bad.php on line 3

空格在标记化过程中由解析器用作分隔符。在这个代码示例中,PHP 8 假定命名空间是Doesnt。下一个标记是\Work,标志着一个完全限定的类名。然而,在这一点上并不是预期的,这就是为什么会抛出错误。

这结束了我们对 PHP 8 中命名空间处理变化的讨论。您现在可以更好地创建 PHP 8 中的命名空间名称,不仅遵循最佳实践,而且还可以利用其独立于关键字命名冲突的优势。

总结

正如您所了解的,PHP 8 在定义魔术方法方面要严格得多。在本章中,您了解了方法签名的变化以及如何通过正确使用魔术方法来减少潜在的错误。您还了解了 Reflection 和 PDO 扩展中的方法签名变化。有了本章中所学到的知识,您可以避免在迁移到 PHP 8 时出现潜在问题。此外,您还了解了静态方法调用方式的变化,以及新的静态返回类型。

然后,您学会了如何最好地使用私有方法,以及如何更好地控制匿名类。您还学到了一些关于新语法可能性以及由于语言变化而现在已经过时的方法的技巧。

您还学会了如何正确使用接口和特征来促进代码的高效使用。您了解了为了使 DOM 扩展符合新的 DOM Living 标准而引入的新接口。此外,您还深入了解了在 DateTime 扩展中引入的新方法。

最后,您学会了如何清理命名空间的使用并生成更紧凑的代码。您现在对命名空间标记化过程的不准确性有了更好的理解,以及在 PHP 8 中如何改进。

下一章将为您介绍每个开发人员都在努力追求的内容:改进性能的技巧和技术。

第十章:提高性能

PHP 8.x 引入了许多新功能,对性能产生了积极影响。此外,许多 PHP 8 最佳实践涵盖的内容可以提高效率并降低内存使用。在本章中,您将了解如何优化您的 PHP 8 代码以实现最佳性能。

PHP 8 包括一种称为弱引用的技术。通过掌握本章最后一节讨论的这项技术,您的应用程序将使用更少的内存。通过仔细审查本章涵盖的材料并研究代码示例,您将能够编写更快,更高效的代码。这种掌握将极大地提高您作为 PHP 开发人员的地位,并带来满意的客户,同时提高您的职业潜力。

本章涵盖的主题包括以下内容:

  • 使用即时(JIT)编译器

  • 加速数组处理

  • 实现稳定排序

  • 使用弱引用来提高效率

技术要求

为了检查和运行本章提供的代码示例,最低推荐的硬件如下:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 GB 的可用磁盘空间

  • 4 GB 的 RAM

  • 每秒 500 千比特(Kbps)或更快的互联网连接

此外,您还需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅第一章技术要求部分,以及如何构建用于演示本书中解释的代码的 Docker 容器。在本书中,我们将存储本书示例代码的目录称为/repo

本章的源代码位于此处:https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。现在我们可以开始讨论了,看看备受期待的 JIT 编译器。

使用 JIT 编译器

PHP 8 引入了备受期待的 JIT 编译器。这是一个重要的步骤,对 PHP 语言的长期可行性有重要影响。尽管 PHP 已经有能力生成和缓存字节码,但在引入 JIT 编译器之前,PHP 没有直接缓存机器码的能力。

实际上,自 2011 年以来就有几次尝试为 PHP 添加 JIT 编译器功能。PHP 7 中看到的性能提升是这些早期努力的直接结果。由于它们并没有显著提高性能,因此以前的 JIT 编译器努力都没有被提议为 RFC(请求评论)。核心团队现在认为,只有使用 JIT 才能实现进一步的性能提升。作为一个附带的好处,这打开了 PHP 作为非 Web 环境语言的可能性。另一个好处是 JIT 编译器打开了使用其他语言(而不是 C)开发 PHP 扩展的可能性。

在本章中非常重要的是要仔细阅读给出的细节,因为正确使用新的 JIT 编译器有可能极大地提高 PHP 应用程序的性能。在我们深入实现细节之前,首先需要解释 PHP 在没有 JIT 编译器的情况下如何执行字节码。然后我们将向您展示 JIT 编译器的工作原理。之后,您将更好地理解各种设置以及如何对其进行微调,以产生最佳的应用程序代码性能。

让我们现在关注 PHP 在没有 JIT 编译器的情况下是如何工作的。

了解 PHP 在没有 JIT 的情况下如何工作

当在服务器上安装 PHP(或在 Docker 容器中),除了核心扩展之外,实际安装的主要组件是一个通常被称为Zend 引擎虚拟机VM)。这个虚拟机的运行方式与VMwareDocker等虚拟化技术大不相同。Zend 引擎更接近于Java 虚拟机JVM),它接受字节码并产生机器码

这引出了一个问题:什么是字节码什么是机器码?让我们现在来看一下这个问题。

理解字节码和机器码

机器码,或机器语言,是 CPU 直接理解的一组硬件指令。每条机器码都是一条指令,会导致 CPU 执行特定的操作。这些低级操作包括在寄存器之间移动信息,在内存中移动指定字节数,加法,减法等等。

机器码通常通过使用汇编语言来使其在一定程度上可读。以下是一个以汇编语言呈现的机器码示例:

JIT$Mandelbrot::iterate: ;
        sub $0x10, %esp
        cmp $0x1, 0x1c(%esi)
        jb .L14
        jmp .L1
.ENTRY1:
        sub $0x10, %esp
.L1:
        cmp $0x2, 0x1c(%esi)
        jb .)L15
        mov $0xec3800f0, %edi
        jmp .L2
.ENTRY2:
        sub $0x10, %esp
.L2:
        cmp $0x5, 0x48(%esi)
        jnz .L16
        vmovsd 0x40(%esi), %xmm1
        vsubsd 0xec380068, %xmm1, %xmm1

尽管大部分命令不容易理解,但您可以从汇编语言表示中看到指令包括比较(cmp),在寄存器和/或内存之间移动信息(mov),以及跳转到指令集中的另一个点(jmp)。

字节码,也称为操作码,是原始程序代码的大大简化的符号表示。字节码是由一个解析过程(通常称为解释器)产生的,该过程将可读的程序代码分解为称为标记的符号,以及值。值可以是程序代码中使用的任何字符串,整数,浮点数和布尔数据。

以下是基于后面显示的示例代码创建 Mandelbrot 所产生的字节码片段的一个示例:

图 10.1 - PHP 解析过程产生的字节码片段

图 10.1 - PHP 解析过程产生的字节码片段

现在让我们来看一下 PHP 程序的传统执行流程。

理解传统的 PHP 程序执行

在传统的 PHP 程序运行周期中,PHP 程序代码通过一个称为解析的操作进行评估并分解为字节码。然后将字节码传递给 Zend 引擎,Zend 引擎将字节码转换为机器码。

当 PHP 首次安装在服务器上时,安装过程会启动必要的逻辑,将 Zend 引擎定制为特定服务器的 CPU 和硬件(或虚拟 CPU 和硬件)。因此,当您编写 PHP 代码时,您并不知道最终运行代码的实际 CPU 的具体情况。正是 Zend 引擎提供了硬件特定的意识。

接下来显示的图 10.2说明了传统的 PHP 执行方式:

图 10.2 - 传统的 PHP 程序执行流程

图 10.2 - 传统的 PHP 程序执行流程

尽管 PHP,特别是 PHP 7,非常快,但获得额外的速度仍然很有意义。出于这个目的,大多数安装也启用了 PHP OPcache扩展。在继续讨论 JIT 编译器之前,让我们快速了解一下 OPcache。

理解 PHP OPcache 的操作

顾名思义,PHP OPcache 扩展在首次运行 PHP 程序时缓存了操作码(字节码)。在后续的程序运行中,字节码将从缓存中获取,消除了解析阶段。这节省了大量时间,是一个在生产环境中启用的非常理想的功能。PHP OPcache 扩展是核心扩展集的一部分;但是,默认情况下它并未启用。

在启用此扩展之前,您必须首先确认您的 PHP 版本是否使用了--enable-opcache配置选项进行编译。您可以通过在运行在 Web 服务器上的 PHP 代码中执行phpinfo()命令来检查这一点。从命令行中,输入php -i命令。以下是在本书使用的 Docker 容器中运行php -i的示例:

root@php8_tips_php8 [ /repo/ch10 ]# php -i
phpinfo()
PHP Version => 8.1.0-dev
System => Linux php8_tips_php8 5.8.0-53-generic #60~20.04.1-Ubuntu SMP Thu May 6 09:52:46 UTC 2021 x86_64
Build Date => Dec 24 2020 00:11:29
Build System => Linux 9244ac997bc1 3.16.0-4-amd64 #1 SMP Debian 3.16.7-ckt11-1 (2015-05-24) x86_64 GNU/Linux
Configure Command =>  './configure'  '--prefix=/usr' '--sysconfdir=/etc' '--localstatedir=/var' '--datadir=/usr/share/php' '--mandir=/usr/share/man' '--enable-fpm' '--with-fpm-user=apache' '--with-fpm-group=apache'
// not all options shown
'--with-jpeg' '--with-png' '--with-sodium=/usr' '--enable-opcache-jit' '--with-pcre-jit' '--enable-opcache'

从输出中可以看出,OPcache 已包含在此 PHP 安装的配置中。要启用 OPcache,请添加或取消注释以下php.ini文件设置:

  • zend_extension=opcache

  • opcache.enable=1

  • opcache.enable_cli=1

最后一个设置是可选的。它确定是否还要处理从命令行执行的 PHP 命令。一旦启用,还有许多其他php.ini文件设置会影响性能,但这超出了本讨论的范围。

提示

有关影响 OPcache 的 PHP php.ini文件设置的更多信息,请查看这里:https://www.php.net/manual/en/opcache.configuration.php。

现在让我们来看看 JIT 编译器的运行方式,以及它与 OPcache 的区别。

使用 JIT 编译器发现 PHP 程序执行

当前方法的问题在于,无论字节码是否被缓存,Zend 引擎仍然需要每次程序请求时将字节码转换为机器代码。JIT 编译器提供的是将字节码编译成机器代码并且缓存机器代码的能力。这个过程是通过一个跟踪机制来实现的,它创建请求的跟踪。跟踪允许 JIT 编译器确定哪些块的机器代码需要被优化和缓存。使用 JIT 编译器的执行流程总结在图 10.3中:

图 10.3 - 带有 JIT 编译器的 PHP 执行流

图 10.3 - 带有 JIT 编译器的 PHP 执行流

从图表中可以看出,包含 OPcache 的正常执行流仍然存在。主要区别在于请求可能会调用一个 trace,导致程序流立即转移到 JIT 编译器,有效地绕过了解析过程和 Zend 引擎。JIT 编译器和 Zend 引擎都可以生成准备直接执行的机器代码。

JIT 编译器并非凭空产生。PHP 核心团队选择移植了高性能和经过充分测试的DynASM预处理汇编器。虽然 DynASM 主要是为Lua编程语言使用的 JIT 编译器开发的,但其设计非常适合作为任何基于 C 的语言(如 PHP!)的 JIT 编译器的基础。

PHP JIT 实现的另一个有利方面是它不会产生任何中间表示IR)代码。相比之下,用于使用 JIT 编译器技术运行 Python 代码的PyPy VM必须首先产生图结构中的 IR 代码,用于流分析和优化,然后才能产生实际的机器代码。PHP JIT 中的 DynASM 核心不需要这一额外步骤,因此比其他解释性编程语言可能实现的性能更高。

提示

有关 DynASM 的更多信息,请查看此网站:https://luajit.org/dynasm.html。这是关于 PHP 8 JIT 操作的出色概述:https://www.zend.com/blog/exploring-new-php-jit-compiler。您还可以在这里阅读官方的 JIT RFC:https://wiki.php.net/rfc/jit。

现在您已经了解了 JIT 编译器如何适应 PHP 程序执行周期的一般流程,是时候学习如何启用它了。

启用 JIT 编译器

因为 JIT 编译器的主要功能是缓存机器代码,它作为 OPcache 扩展的独立部分运行。OPcache 既可以作为启用 JIT 功能的网关,也可以从自己的分配中为 JIT 编译器分配内存。因此,为了启用 JIT 编译器,您必须首先启用 OPcache(请参阅前一节,理解 PHP OPcache 的操作)。

为了启用 JIT 编译器,您必须首先确认 PHP 已经使用--enable-opcache-jit配置选项进行编译。然后,您可以通过简单地将非零值分配给php.ini文件的opcache.jit_buffer_size指令来启用或禁用 JIT 编译器。

值可以指定为整数——在这种情况下,该值表示字节数;值为零(默认值)会禁用 JIT 编译器;或者您可以分配一个数字,后面跟着以下任何一个字母:

  • K:千字节

  • M:兆字节

  • G:千兆字节

您为 JIT 编译器缓冲区大小指定的值必须小于您为 OPcache 分配的内存分配,因为 JIT 缓冲区是从 OPcache 缓冲区中取出的。

以下是一个示例,将 OPcache 内存消耗设置为 256 M,JIT 缓冲区设置为 64 M。这些值可以放在php.ini文件的任何位置:

opcache.memory_consumption=256
opcache.jit_buffer_size=64M

现在您已经了解了 JIT 编译器的工作原理,以及如何启用它,了解如何正确设置跟踪模式非常重要。

配置跟踪模式

php.ini设置opcache.jit控制 JIT 跟踪器的操作。为了方便起见,可以使用以下四个预设字符串之一:

  • opcache.jit=disable

完全禁用 JIT 编译器(不考虑其他设置)。

  • opcache.jit=off

禁用 JIT 编译器,但(在大多数情况下)您可以使用ini_set()在运行时启用它。

  • opcache.jit=function

将 JIT 编译器跟踪器设置为功能模式。此模式对应于CPU 寄存器触发优化(CRTO)数字 1205(下面解释)。

  • opcache.jit=tracing

将 JIT 编译器跟踪器设置为跟踪模式。此模式对应于 CRTO 数字 1254(下面解释)。在大多数情况下,此设置可以获得最佳性能。

  • opcache.jit=on

这是跟踪模式的别名。

提示

依赖运行时 JIT 激活是有风险的,并且可能产生不一致的应用程序行为。最佳实践是使用tracingfunction设置。

这四个便利字符串实际上解析为一个四位数。每个数字对应 JIT 编译器跟踪器的不同方面。这四个数字不像其他php.ini文件设置那样是位掩码,并且按照这个顺序指定:CRTO。以下是每个四位数的摘要。

C(CPU 优化标志)

第一个数字代表 CPU 优化设置。如果将此数字设置为 0,则不会进行 CPU 优化。值为 1 会启用高级矢量扩展AVX指令的生成。AVX 是针对英特尔和 AMD 微处理器的 x86 指令集架构的扩展。自 2011 年以来,AVX 已在英特尔和 AMD 处理器上得到支持。大多数服务器型处理器(如英特尔至强)都支持 AVX2。

R(寄存器分配)

第二位数字控制 JIT 编译器如何处理寄存器。寄存器类似于 RAM,只是它们直接驻留在 CPU 内部。CPU 不断地在寄存器中移动信息,以执行操作(例如,加法、减法、执行逻辑 AND、OR 和 NOT 操作等)。与此设置相关的选项允许您禁用寄存器分配优化,或者在本地或全局级别允许它。

T(JIT 触发器)

第三位数字决定 JIT 编译器何时触发。选项包括在加载脚本时首次操作 JIT 编译器或在首次执行时操作。或者,您可以指示 JIT 何时编译热函数。热函数是最常被调用的函数。还有一个设置,告诉 JIT 只编译带有@jit docblock注释的函数。

O(优化级别)

第四位数字对应优化级别。选项包括禁用优化、最小化和选择性。您还可以指示 JIT 编译器根据单个函数、调用树或内部过程分析的结果进行优化。

提示

要完全了解四个 JIT 编译器跟踪器设置,请查看此文档参考页面:https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit。

现在让我们来看看 JIT 编译器的运行情况。

使用 JIT 编译器

在这个例子中,我们使用一个经典的基准测试程序来生成Mandelbrot。这是一个非常消耗计算资源的优秀测试。我们在这里使用的实现是来自 PHP 核心开发团队成员Dmitry Stogov的实现代码。您可以在这里查看原始实现:gist.github.com/dstogov/12323ad13d3240aee8f1

  1. 我们首先定义 Mandelbrot 参数。特别重要的是迭代次数(MAX_LOOPS)。较大的数字会产生更多的计算并减慢整体生产速度。我们还捕获开始时间:
// /repo/ch10/php8_jit_mandelbrot.php
define('BAILOUT',   16);
define('MAX_LOOPS', 10000);
define('EDGE',      40.0);
$d1  = microtime(1);
  1. 为了方便多次运行程序,我们添加了一个捕获命令行参数-n的选项。如果存在此参数,则 Mandelbrot 输出将被抑制:
$time_only = (bool) ($argv[1] ?? $_GET['time'] ?? FALSE);
  1. 然后,我们定义一个名为iterate()的函数,直接从 Dmitry Stogov 的 Mandelbrot 实现中提取。实际代码在此未显示,可以在前面提到的 URL 中查看。

  2. 接下来,我们通过EDGE确定的 X/Y 坐标运行,生成 ASCII 图像:

$out = '';
$f   = EDGE - 1;
for ($y = -$f; $y < $f; $y++) {
    for ($x = -$f; $x < $f; $x++) {
        $out .= (iterate($x/EDGE,$y/EDGE) == 0)
              ? '*' : ' ';
    }
    $out .= "\n";
}
  1. 最后,我们生成输出。如果通过 Web 请求运行,则输出将包含在<pre>标签中。如果存在-n标志,则只显示经过的时间:
if (!empty($_SERVER['REQUEST_URI'])) {
    $out = '<pre>' . $out . '</pre>';
}
if (!$time_only) echo $out;
$d2 = microtime(1);
$diff = $d2 - $d1;
printf("\nPHP Elapsed %0.3f\n", $diff);
  1. 我们首先在 PHP 7 Docker 容器中使用-n标志运行程序三次。以下是结果。请注意,在与本书配合使用的演示 Docker 容器中,经过的时间很容易超过 10 秒:
root@php8_tips_php7 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 10.320
root@php8_tips_php7 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 10.134
root@php8_tips_php7 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 11.806
  1. 现在我们转向 PHP 8 Docker 容器。首先,我们调整php.ini文件以禁用 JIT 编译器。以下是设置:
opcache.jit=off
opcache.jit_buffer_size=0
  1. 以下是在使用-n标志的 PHP 8 中运行程序三次的结果:
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 1.183
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 1.192
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 1.210
  1. 立即可以看到切换到 PHP 8 的一个很好的理由!即使没有 JIT 编译器,PHP 8 也能在 1 秒多一点的时间内执行相同的程序:1/10 的时间量!

  2. 接下来,我们修改php.ini文件设置,以使用 JIT 编译器function跟踪器模式。以下是使用的设置:

opcache.jit=function
opcache.jit_buffer_size=64M
  1. 然后我们再次使用-n标志运行相同的程序。以下是在使用 JIT 编译器function跟踪器模式的 PHP 8 中运行的结果:
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.323
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.322
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.324
  1. 哇!我们成功将处理速度提高了 3 倍。速度现在不到 1/3 秒!但是如果我们尝试推荐的 JIT 编译器tracing模式会发生什么呢?以下是调用该模式的设置:
opcache.jit=tracing
opcache.jit_buffer_size=64M
  1. 以下是我们上一组程序运行的结果:
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.132
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.132
root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
PHP Elapsed 0.131

正如输出所示,最后的结果真是令人震惊。我们不仅可以比没有 JIT 编译器的 PHP 8 运行相同的程序快 10 倍,而且比 PHP 7 运行快 100 倍

重要提示

重要的是要注意,时间会根据您用于运行与本书相关的 Docker 容器的主机计算机而变化。您将看不到与此处显示的完全相同的时间。

现在让我们来看看 JIT 编译器调试。

使用 JIT 编译器进行调试

当使用 JIT 编译器时,使用XDebug或其他工具进行常规调试效果不佳。因此,PHP 核心团队添加了一个额外的php.ini文件选项opcache.jit_debug,它会生成额外的调试信息。在这种情况下,可用的设置采用位标志的形式,这意味着您可以使用按位运算符(如ANDORXOR等)将它们组合起来。

表 10.1总结了可以分配为opcache.jit_debug设置的值。请注意,标有内部常量的列不显示 PHP 预定义常量。这些值是内部 C 代码引用:

表 10.1 - opcache.jit_debug 设置

表 10.1 - opcache.jit_debug 设置

例如,如果您希望为ZEND_JIT_DEBUG_ASMZEND_JIT_DEBUG_PERFZEND_JIT_DEBUG_EXIT启用调试,可以在php.ini文件中进行如下分配:

  1. 首先,您需要将要设置的值相加。在这个例子中,我们将添加:

1 + 16 + 32768

  1. 然后将总和应用于php.ini设置:

opcache.jit_debug=32725

  1. 或者,使用按位OR表示这些值:

opcache.jit_debug=1|16|32768

根据调试设置,您现在可以使用诸如 Linux perf命令或 Intel VTune之类的工具来调试 JIT 编译器。

以下是在运行前一节讨论的 Mandelbrot 测试程序时的部分调试输出示例。为了说明,我们使用了php.ini文件设置opcache.jit_debug=32725

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_jit_mandelbrot.php -n
---- TRACE 1 start (loop) iterate() /repo/ch10/php8_jit_mandelbrot.php:34
---- TRACE 1 stop (loop)
---- TRACE 1 Live Ranges
#15.CV6($i): 0-0 last_use
#19.CV6($i): 0-20 hint=#15.CV6($i)
... not all output is shown
---- TRACE 1 compiled
---- TRACE 2 start (side trace 1/7) iterate()
/repo/ch10/php8_jit_mandelbrot.php:41
---- TRACE 2 stop (return)
TRACE-2$iterate$41: ; (unknown)
    mov $0x2, EG(jit_trace_num)
    mov 0x10(%r14), %rcx
    test %rcx, %rcx
    jz .L1
    mov 0xb0(%r14), %rdx
    mov %rdx, (%rcx)
    mov $0x4, 0x8(%rcx)
...  not all output is shown

输出显示的是用汇编语言呈现的机器代码。如果在使用 JIT 编译器时遇到程序代码问题,汇编语言转储可能会帮助您找到错误的源头。

但是,请注意,汇编语言不具有可移植性,完全面向使用的 CPU。因此,您可能需要获取该 CPU 的硬件参考手册,并查找正在使用的汇编语言代码。

现在让我们来看看影响 JIT 编译器操作的其他php.ini文件设置。

发现额外的 JIT 编译器设置

表 10.2提供了php.ini文件中尚未涵盖的所有其他opcache.jit*设置的摘要:

表 10.2 - 附加的 opcache.jit* php.ini 文件设置

表 10.2 - 附加的 opcache.jit* php.ini 文件设置

从表中可以看出,您对 JIT 编译器的操作有很高的控制度。总体而言,这些设置代表了控制 JIT 编译器做出决策的阈值。如果正确配置这些设置,JIT 编译器可以忽略不经常使用的循环和函数调用。现在我们将离开 JIT 编译器的激动人心的世界,看看如何提高数组性能。

加速数组处理

数组是任何 PHP 程序的重要组成部分。实际上,处理数组是不可避免的,因为您的程序每天处理的大部分现实世界数据都以数组的形式到达。一个例子是来自 HTML 表单提交的数据。数据最终以数组的形式出现在$_GET$_POST中。

在本节中,我们将向您介绍 SPL 中包含的一个鲜为人知的类:SplFixedArray类。将数据从标准数组迁移到SplFixedArray实例不仅可以提高性能,而且还需要更少的内存。学习如何利用本章涵盖的技术可以对当前使用大量数据的数组的任何程序代码的速度和效率产生重大影响。

在 PHP 8 中使用 SplFixedArray

SplFixedArray类是在 PHP 5.3 中引入的,它实际上是一个像数组一样操作的对象。然而,与ArrayObject不同,这个类要求您对数组大小设置一个硬限制,并且只允许整数索引。您可能想要使用SplFixedArray而不是ArrayObject的原因是,SplFixedArray占用的内存明显更少,并且性能非常好。事实上,SplFixedArray实际上比具有相同数据的标准数组占用更少的内存

将 SplFixedArray 与数组和 ArrayObject 进行比较

一个简单的基准程序说明了标准数组、ArrayObjectSplFixedArray之间的差异:

  1. 首先,我们定义了代码中稍后使用的一对常量:
// /repo/ch10/php7_spl_fixed_arr_size.php
define('MAX_SIZE', 1000000);
define('PATTERN', "%14s : %8.8f : %12s\n");
  1. 接下来,我们定义一个函数,该函数添加了 100 万个由 64 个字节长的字符串组成的元素:
function testArr($list, $label) {
    $alpha = new InfiniteIterator(
        new ArrayIterator(range('A','Z')));
    $start_mem = memory_get_usage();
    $start_time = microtime(TRUE);
    for ($x = 0; $x < MAX_SIZE; $x++) {
        $letter = $alpha->current();
        $alpha->next();
        $list[$x] = str_repeat($letter, 64);
    }
    $mem_diff = memory_get_usage() - $start_mem;
    return [$label, (microtime(TRUE) - $start_time),
        number_format($mem_diff)];
}
  1. 然后,我们调用该函数三次,分别提供arrayArrayObjectSplFixedArray作为参数:
printf("%14s : %10s : %12s\n", '', 'Time', 'Memory');
$result = testArr([], 'Array');
vprintf(PATTERN, $result);
$result = testArr(new ArrayObject(), 'ArrayObject');
vprintf(PATTERN, $result);
$result = testArr(
    new SplFixedArray(MAX_SIZE), 'SplFixedArray');
vprintf(PATTERN, $result);
  1. 以下是我们的 PHP 7.1 Docker 容器的结果:
root@php8_tips_php7 [ /repo/ch10 ]# 
php php7_spl_fixed_arr_size.php 
               :       Time :       Memory
         Array : 1.19430900 :  129,558,888
   ArrayObject : 1.20231009 :  129,558,832
 SplFixedArray : 1.19744802 :   96,000,280
  1. 在 PHP 8 中,所花费的时间显著减少,如下所示:
root@php8_tips_php8 [ /repo/ch10 ]# 
php php7_spl_fixed_arr_size.php 
               :       Time :       Memory
         Array : 0.13694692 :  129,558,888
   ArrayObject : 0.11058593 :  129,558,832
 SplFixedArray : 0.09748793 :   96,000,280

从结果中可以看出,PHP 8 处理数组的速度比 PHP 7.1 快 10 倍。两个版本使用的内存量是相同的。无论使用哪个版本的 PHP,SplFixedArray使用的内存量都明显少于标准数组或ArrayObject。现在让我们来看看在 PHP 8 中SplFixedArray的使用方式发生了哪些变化。

在 PHP 8 中使用 SplFixedArray 的变化

您可能还记得在第七章中对Traversable接口的简要讨论,在使用 PHP 8 扩展时避免陷阱Traversable to IteratorAggregate migration部分。在该部分提出的相同考虑也适用于SplFixedArray。虽然SplFixedArray没有实现Traversable,但它实现了Iterator,而Iterator又扩展了Traversable

在 PHP 8 中,SplFixedArray不再实现Iterator。相反,它实现了IteratorAggregate。这种变化的好处是,PHP 8 中的SplFixedArray更快,更高效,并且在嵌套循环中使用也更安全。不利之处,也是潜在的代码中断,是如果您正在与以下任何方法一起使用SplFixedArraycurrent()key()next()rewind()valid()

如果您需要访问数组导航方法,现在必须使用SplFixedArray::getIterator()方法来访问内部迭代器,从中可以使用所有导航方法。下面的简单代码示例说明了潜在的代码中断:

  1. 我们首先从数组构建一个SplFixedArray实例:
// /repo/ch10/php7_spl_fixed_arr_iter.php
$arr   = ['Person', 'Woman', 'Man', 'Camera', 'TV'];$fixed = SplFixedArray::fromArray($arr);
  1. 然后,我们使用数组导航方法来遍历数组:
while ($fixed->valid()) {
    echo $fixed->current() . '. ';
    $fixed->next();
}

在 PHP 7 中,输出是数组中的五个单词:

root@php8_tips_php7 [ /repo/ch10 ]# 
php php7_spl_fixed_arr_iter.php 
Person. Woman. Man. Camera. TV.

在 PHP 8 中,结果却大不相同,如下所示:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php7_spl_fixed_arr_iter.php 
PHP Fatal error:  Uncaught Error: Call to undefined method SplFixedArray::valid() in /repo/ch10/php7_spl_fixed_arr_iter.php:5

为了使示例在 PHP 8 中工作,您只需要使用SplFixedArray::getIterator()方法来访问内部迭代器。代码的其余部分不需要重写。以下是为 PHP 8 重新编写的修订后的代码示例:

// /repo/ch10/php8_spl_fixed_arr_iter.php
$arr   = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
$obj   = SplFixedArray::fromArray($arr);
$fixed = $obj->getIterator();
while ($fixed->valid()) {
    echo $fixed->current() . '. ';
    $fixed->next();
}

现在输出的是五个单词,没有任何错误:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_spl_fixed_arr_iter.php
Person. Woman. Man. Camera. TV. 

现在您已经了解了如何提高数组处理性能,我们将把注意力转向数组性能的另一个方面:排序。

实现稳定排序

在设计数组排序逻辑时,最初的 PHP 开发人员为了速度而牺牲了稳定性。当时,这被认为是一个合理的牺牲。然而,如果在排序过程中涉及复杂对象,则需要稳定排序

在本节中,我们将讨论稳定排序是什么,以及为什么它很重要。如果您可以确保数据被稳定排序,您的应用代码将产生更准确的输出,从而提高客户满意度。在我们深入了解 PHP 8 如何实现稳定排序之前,我们首先需要定义什么是稳定排序。

理解稳定排序

当用于排序目的的属性的值相等时,在稳定排序中保证了元素的原始顺序。这样的结果更接近用户的期望。让我们看一个简单的数据集,并确定什么构成了稳定排序。为了说明,让我们假设我们的数据集包括访问时间和用户名的条目:

2021-06-01 11:11:11    Betty
2021-06-03 03:33:33    Betty
2021-06-01 11:11:11    Barney
2021-06-02 02:22:22    Wilma
2021-06-01 11:11:11    Wilma
2021-06-03 03:33:33    Barney
2021-06-01 11:11:11    Fred

如果我们希望按时间排序,您会立即注意到2021-06-01 11:11:11存在重复。如果我们对这个数据集执行稳定排序,预期的结果将如下所示:

2021-06-01 11:11:11    Betty
2021-06-01 11:11:11    Barney
2021-06-01 11:11:11    Wilma
2021-06-01 11:11:11    Fred
2021-06-02 02:22:22    Wilma
2021-06-03 03:33:33    Betty
2021-06-03 03:33:33    Barney

您会注意到从排序后的数据集中,重复时间2021-06-01 11:11:11的条目按照它们最初的输入顺序出现。因此,我们可以说这个结果代表了一个稳定的排序。

在理想的情况下,相同的原则也应该适用于保留键/值关联的排序。稳定排序的一个额外标准是,它在性能上不应该与无序排序有任何差异。

提示

有关 PHP 8 稳定排序的更多信息,请查看官方 RFC:https://wiki.php.net/rfc/stable_sorting。

在 PHP 8 中,核心的*sort*()函数和ArrayObject::*sort*()方法已经被重写以实现稳定排序。让我们看一个代码示例,说明在 PHP 的早期版本中可能出现的问题。

对比稳定和非稳定排序

在这个例子中,我们希望按时间对Access实例的数组进行排序。每个Access实例有两个属性,$name$time。样本数据集包含重复的访问时间,但用户名不同:

  1. 首先,我们定义Access类:
// /repo/src/Php8/Sort/Access.php
namespace Php8\Sort;
class Access {
    public $name, $time;
    public function __construct($name, $time) {
        $this->name = $name;
        $this->time = $time;
    }
}
  1. 接下来,我们定义一个样本数据集,其中包含一个 CSV 文件,/repo/sample_data/access.csv,共有 21 行。每一行代表不同的姓名和访问时间的组合:
"Fred",  "2021-06-01 11:11:11"
"Fred",  "2021-06-01 02:22:22"
"Betty", "2021-06-03 03:33:33"
"Fred",  "2021-06-11 11:11:11"
"Barney","2021-06-03 03:33:33"
"Betty", "2021-06-01 11:11:11"
"Betty", "2021-06-11 11:11:11"
"Barney","2021-06-01 11:11:11"
"Fred",  "2021-06-11 02:22:22"
"Wilma", "2021-06-01 11:11:11"
"Betty", "2021-06-13 03:33:33"
"Fred",  "2021-06-21 11:11:11"
"Betty", "2021-06-21 11:11:11"
"Barney","2021-06-13 03:33:33"
"Betty", "2021-06-23 03:33:33"
"Barney","2021-06-11 11:11:11"
"Barney","2021-06-21 11:11:11"
"Fred",  "2021-06-21 02:22:22"
"Barney","2021-06-23 03:33:33"
"Wilma", "2021-06-21 11:11:11"
"Wilma", "2021-06-11 11:11:11"

您会注意到,扫描样本数据时,所有具有11:11:11作为入口时间的日期都是重复的,但是您还会注意到,任何给定日期的原始顺序始终是用户FredBettyBarneyWilma。另外,请注意,对于时间为03:33:33的日期,Betty的条目总是在Barney之前。

  1. 然后我们定义一个调用程序。在这个程序中,首先要做的是配置自动加载和use Access类:
// /repo/ch010/php8_sort_stable_simple.php
require __DIR__ . 
'/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php8\Sort\Access;
  1. 接下来,我们将样本数据加载到$access数组中:
$access = [];
$data = new SplFileObject(__DIR__ 
    . '/../sample_data/access.csv');
while ($row = $data->fgetcsv())
    if (!empty($row) && count($row) === 2)
        $access[] = new Access($row[0], $row[1]);
  1. 然后我们执行usort()。请注意,用户定义的回调函数执行每个实例的time属性的比较:
usort($access, 
    function($a, $b) { return $a->time <=> $b->time; });
  1. 最后,我们循环遍历新排序的数组并显示结果:
foreach ($access as $entry)
    echo $entry->time . "\t" . $entry->name . "\n";

在 PHP 7 中,请注意虽然时间是有序的,但是姓名并不反映预期的顺序FredBettyBarneyWilma。以下是 PHP 7 的输出:

root@php8_tips_php7 [ /repo/ch10 ]# 
php php8_sort_stable_simple.php 
2021-06-01 02:22:22    Fred
2021-06-01 11:11:11    Fred
2021-06-01 11:11:11    Wilma
2021-06-01 11:11:11    Betty
2021-06-01 11:11:11    Barney
2021-06-03 03:33:33    Betty
2021-06-03 03:33:33    Barney
2021-06-11 02:22:22    Fred
2021-06-11 11:11:11    Barney
2021-06-11 11:11:11    Wilma
2021-06-11 11:11:11    Betty
2021-06-11 11:11:11    Fred
2021-06-13 03:33:33    Barney
2021-06-13 03:33:33    Betty
2021-06-21 02:22:22    Fred
2021-06-21 11:11:11    Fred
2021-06-21 11:11:11    Betty
2021-06-21 11:11:11    Barney
2021-06-21 11:11:11    Wilma
2021-06-23 03:33:33    Betty
2021-06-23 03:33:33    Barney

从输出中可以看出,在第一组11:11:11日期中,最终顺序是FredWilmaBettyBarney,而原始的入口顺序是FredBettyBarneyWilma。您还会注意到,对于日期和时间2021-06-13 03:33:33BarneyBetty之前,而原始的入口顺序是相反的。根据我们的定义,PHP 7 没有实现稳定排序!

现在让我们看一下在 PHP 8 中运行相同代码示例的输出。以下是 PHP 8 的输出:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_sort_stable_simple.php
2021-06-01 02:22:22    Fred
2021-06-01 11:11:11    Fred
2021-06-01 11:11:11    Betty
2021-06-01 11:11:11    Barney
2021-06-01 11:11:11    Wilma
2021-06-03 03:33:33    Betty
2021-06-03 03:33:33    Barney
2021-06-11 02:22:22    Fred
2021-06-11 11:11:11    Fred
2021-06-11 11:11:11    Betty
2021-06-11 11:11:11    Barney
2021-06-11 11:11:11    Wilma
2021-06-13 03:33:33    Betty
2021-06-13 03:33:33    Barney
2021-06-21 02:22:22    Fred
2021-06-21 11:11:11    Fred
2021-06-21 11:11:11    Betty
2021-06-21 11:11:11    Barney
2021-06-21 11:11:11    Wilma
2021-06-23 03:33:33    Betty
2021-06-23 03:33:33    Barney

从 PHP 8 的输出中可以看出,对于所有的11:11:11条目,原始的输入顺序FredBettyBarneyWilma都得到了尊重。您还会注意到,对于日期和时间2021-06-13 03:33:33Betty始终在Barney之前。因此,我们可以得出结论,PHP 8 执行了稳定排序。

现在您已经看到了 PHP 7 中的问题,并且现在知道了 PHP 8 如何解决这个问题,让我们来看看稳定排序对键的影响。

检查稳定排序对键的影响

稳定排序的概念也影响使用asort()uasort()或等效的ArrayIterator方法时的键/值对。在接下来展示的示例中,ArrayIterator被填充了 20 个元素,每隔一个元素是重复的。键是一个按顺序递增的十六进制数:

  1. 首先,我们定义一个函数来生成随机的 3 个字母组合:
// /repo/ch010/php8_sort_stable_keys.php
$randVal = function () {
    $alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    return $alpha[rand(0,25)] . $alpha[rand(0,25)] 
           . $alpha[rand(0,25)];};
  1. 接下来,我们使用示例数据加载了一个ArrayIterator实例。每隔一个元素是重复的。我们还记录了开始时间:
$start = microtime(TRUE);
$max   = 20;
$iter  = new ArrayIterator;
for ($x = 256; $x < $max + 256; $x += 2) {
    $key = sprintf('%04X', $x);
    $iter->offsetSet($key, $randVal());
    $key = sprintf('%04X', $x + 1);
    $iter->offsetSet($key, 'AAA'); // <-- duplicate
}
  1. 然后我们执行ArrayIterator::asort()并显示结果的顺序以及经过的时间:
// not all code is shown
$iter->asort();
foreach ($iter as $key => $value) echo "$key\t$value\n";
echo "\nElapsed Time: " . (microtime(TRUE) - $start);

以下是在 PHP 7 中运行此代码示例的结果:

root@php8_tips_php7 [ /repo/ch10 ]# 
php php8_sort_stable_keys.php 
0113    AAA
010D    AAA
0103    AAA
0105    AAA
0111    AAA
0107    AAA
010F    AAA
0109    AAA
0101    AAA
010B    AAA
0104    CBC
... some output omitted ...
010C    ZJW
Elapsed Time: 0.00017094612121582

从输出中可以看出,尽管值是有序的,但在重复值的情况下,键是以混乱的顺序出现的。相比之下,看一下在 PHP 8 中运行相同程序代码的输出:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_sort_stable_keys.php 
0101    AAA
0103    AAA
0105    AAA
0107    AAA
0109    AAA
010B    AAA
010D    AAA
010F    AAA
0111    AAA
0113    AAA
0100    BAU
... some output omitted ...
0104    QEE
Elapsed Time: 0.00010395050048828

输出显示,任何重复条目的键都按照它们原始的顺序出现在输出中。输出表明,PHP 8 不仅对值实现了稳定排序,而且对键也实现了稳定排序。此外,从经过的时间结果来看,PHP 8 已经成功地保持了与以前相同(或更好)的性能。现在让我们将注意力转向 PHP 8 中直接影响数组排序的另一个不同之处:处理非法排序函数。

处理非法排序函数

PHP 7 及更早版本允许开发人员在使用usort()uasort()(或等效的ArrayIterator方法)时使用非法函数。您非常重要的是要意识到这种不良实践。否则,当您将代码迁移到 PHP 8 时,可能存在潜在的向后兼容性问题。

在接下来展示的示例中,创建了与“对比稳定和非稳定排序”部分中描述的示例相同的数组。非法排序函数返回一个布尔值,而u*sort()回调需要返回两个元素之间的相对位置。从字面上讲,用户定义的函数或回调需要在第一个操作数小于第二个操作数时返回-1,相等时返回0,第一个操作数大于第二个操作数时返回1。如果我们重写定义usort()回调的代码行,一个非法函数可能如下所示:

usort($access, function($a, $b) { 
    return $a->time < $b->time; });

在这段代码片段中,我们没有使用太空船操作符(<=>),而是使用了小于符号(<)。在 PHP 7 及更低版本中,返回布尔返回值的回调是可以接受的,并且会产生期望的结果。但实际发生的是,PHP 解释器需要添加额外的操作来弥补缺失的操作。因此,如果回调只执行这个比较:

op1 > op2

PHP 解释器添加了一个额外的操作:

op1 <= op2

在 PHP 8 中,非法排序函数会产生一个弃用通知。以下是在 PHP 8 中运行的重写代码:

root@php8_tips_php8 [ /repo/ch10 ]#
php php8_sort_illegal_func.php 
PHP Deprecated:  usort(): Returning bool from comparison function is deprecated, return an integer less than, equal to, or greater than zero in /repo/ch10/php8_sort_illegal_func.php on line 30
2021-06-01 02:22:22    Fred
2021-06-01 11:11:11    Fred
2021-06-01 11:11:11    Betty
2021-06-01 11:11:11    Barney
... not all output is shown

从输出中可以看出,PHP 8 允许操作继续进行,并且在使用正确的回调时结果是一致的。但是,您还可以看到发出了一个Deprecation通知。

提示

您也可以在 PHP 8 中使用箭头函数。之前展示的回调可以重写如下:

usort($array, fn($a, $b) => $a <=> $b)

现在您对稳定排序是什么以及为什么它很重要有了更深入的了解。您还能够发现由于 PHP 8 和早期版本之间处理差异而可能出现的潜在问题。现在我们将看一下 PHP 8 中引入的其他性能改进。

使用弱引用来提高效率

随着 PHP 的不断发展和成熟,越来越多的开发人员开始使用 PHP 框架来促进快速应用程序开发。然而,这种做法的一个必然副产品是占用内存的对象变得越来越大和复杂。包含许多属性、其他对象或大型数组的大对象通常被称为昂贵的对象

这种趋势引起的潜在内存问题的加剧是,所有 PHP 对象赋值都是自动通过引用进行的。没有引用,第三方框架的使用将变得非常麻烦。然而,当您通过引用分配一个对象时,对象必须保持在内存中,直到所有引用被销毁。只有在取消设置或覆盖对象之后,对象才会完全被销毁。

在 PHP 7.4 中,弱引用支持以解决这个问题的潜在解决方案首次引入。PHP 8 通过添加弱映射类扩展了这种新能力。在本节中,您将学习这项新技术的工作原理,以及它如何对开发有利。让我们先看看弱引用。

利用弱引用

弱引用 首次在 PHP 7.4 中引入,并在 PHP 8 中得到改进。这个类作为对象创建的包装器,允许开发人员以一种方式使用对象的引用,使得超出范围(例如 unset())的对象不受垃圾回收的保护。

目前有许多 PHP 扩展驻留在 pecl.php.net,提供对弱引用的支持。大多数实现都是通过入侵 PHP 语言核心的 C 语言结构,要么重载对象处理程序,要么操纵堆栈和各种 C 指针。在大多数情况下,结果是丧失可移植性和大量的分段错误。PHP 8 的实现避免了这些问题。

如果您正在处理涉及大型对象并且程序代码可能运行很长时间的程序代码,那么掌握 PHP 8 弱引用的使用是非常重要的。在深入使用细节之前,让我们先看一下类的定义。

审查 WeakReference 类的定义

WeakReference 类的正式定义如下:

WeakReference {
    public __construct() : void
    public static create (object $object) : WeakReference
    public get() : object|null
}

正如您所看到的,类的定义非常简单。该类可用于提供任何对象的包装器。这个包装器使得完全销毁一个对象变得更容易,而不必担心可能会有残留的引用导致对象仍然驻留在内存中。

提示

有关弱引用的背景和性质的更多信息,请查看这里:https://wiki.php.net/rfc/weakrefs。文档参考在这里:www.php.net/manual/en/class.weakreference.php

现在让我们看一个简单的例子来帮助您理解。

使用弱引用

这个例子演示了如何使用弱引用。您将在这个例子中看到,当通过引用进行普通对象赋值时,即使原始对象被取消设置,它仍然保留在内存中。另一方面,如果您使用 WeakReference 分配对象引用,一旦原始对象被取消设置,它就会完全从内存中删除。

  1. 首先,我们定义了四个对象。请注意,$obj2 是对 $obj1 的普通引用,而 $obj4 是对 $obj3 的弱引用:
// /repo/ch010/php8_weak_reference.php
$obj1 = new class () { public $name = 'Fred'; };
$obj2 = $obj1;  // normal reference
$obj3 = new class () { public $name = 'Fred'; };
$obj4 = WeakReference::create($obj3); // weak ref
  1. 然后我们显示 $obj1 在取消设置之前和之后的 $obj2 的内容。由于 $obj1$obj2 之间的连接是一个普通的 PHP 引用,所以由于创建了强引用,$obj1 仍然保留在内存中:
var_dump($obj2);
unset($obj1);
var_dump($obj2);  // $obj1 still loaded in memory
  1. 然后我们对 $obj3$obj4 做同样的操作。请注意,我们需要使用 WeakReference::get() 来获取关联的对象。一旦取消设置了 $obj3,与 $obj3$obj4 相关的所有信息都将从内存中删除:
var_dump($obj4->get());
unset($obj3);
var_dump($obj4->get()); // both $obj3 and $obj4 are gone

以下是在 PHP 8 中运行此代码示例的输出:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_weak_reference.php 
object(class@anonymous)#1 (1) {
  ["name"]=>  string(4) "Fred"
}
object(class@anonymous)#1 (1) {
  ["name"]=>  string(4) "Fred"
}
object(class@anonymous)#2 (1) {
  ["name"]=>  string(4) "Fred"
}
NULL

输出告诉我们一个有趣的故事!第二个 var_dump() 操作向我们展示了,即使 $obj1 已经取消设置,由于与 $obj2 创建的强引用,它仍然像僵尸一样存在。如果您正在处理昂贵的对象和复杂的应用程序代码,为了释放内存,您需要首先找到并销毁所有引用,然后才能释放内存!

另一方面,如果你真的需要内存,而不是直接进行对象赋值,在 PHP 中是自动引用的,可以使用WeakReference::create()方法创建引用。弱引用具有普通引用的所有功能。唯一的区别是,如果它引用的对象被销毁或超出范围,弱引用也会被自动销毁。

从输出中可以看出,最后一个var_dump()操作的结果是NULL。这告诉我们对象确实已经被销毁。当主对象取消引用时,它的所有弱引用也会自动消失。现在你已经了解了如何使用弱引用以及它们解决的潜在问题,是时候来看看一个新类WeakMap了。

使用 WeakMap

在 PHP 8 中,添加了一个新类WeakMap,它利用了弱引用支持。这个新类在功能上类似于SplObjectStorage。以下是官方的类定义:

final WeakMap implements Countable,
    ArrayAccess, IteratorAggregate {
    public __construct ( )
    public count ( ) : int
    abstract public getIterator ( ) : Traversable
    public offsetExists ( object $object ) : bool
    public offsetGet ( object $object ) : mixed
    public offsetSet ( object $object , mixed $value ) :     void
    public offsetUnset ( object $object ) : void
}

就像SplObjectStorage一样,这个新类看起来像一个对象数组。因为它实现了IteratorAggregate,你可以使用getIterator()方法来访问内部迭代器。因此,这个新类不仅提供了传统的数组访问,还提供了面向对象的迭代器访问,两全其美!在深入了解如何使用WeakMap之前,你需要了解SplObjectStorage的典型用法。

使用 SplObjectStorage 实现容器类

SplObjectStorage类的一个潜在用途是将其用作依赖注入DI)容器的基础(也称为服务定位器控制反转容器)。DI 容器类旨在创建和保存对象实例,以便轻松检索。

在这个例子中,我们使用一个包含从Laminas\Filter\*类中提取的昂贵对象数组的容器类。然后我们使用容器来清理样本数据,之后我们取消过滤器数组:

  1. 首先,我们基于SplObjectStorage定义一个容器类。(稍后,在下一节中,我们将开发另一个执行相同功能并基于WeakMap的容器类。)这是UsesSplObjectStorage类。在__construct()方法中,我们将配置的过滤器附加到SplObjectStorage实例:
// /repo/src/Php7/Container/UsesSplObjectStorage.php
namespace Php7\Container;
use SplObjectStorage;
class UsesSplObjectStorage {
    public $container;
    public $default;
    public function __construct(array $config = []) {
        $this->container = new SplObjectStorage();
        if ($config) foreach ($config as $obj)
            $this->container->attach(
                $obj, get_class($obj));
        $this->default = new class () {
            public function filter($value) { 
                return $value; }};
    }
  1. 然后,我们定义一个get()方法,遍历SplObjectStorage容器并返回找到的过滤器。如果找不到,则返回一个简单地将数据直接传递的默认类:
    public function get(string $key) {
        foreach ($this->container as $idx => $obj)
            if ($obj instanceof $key) return $obj;
        return $this->default;    
    }
}

请注意,当使用foreach()循环来迭代SplObjectStorage实例时,我们返回$obj),而不是键。另一方面,如果我们使用WeakMap实例,我们需要返回而不是值!

然后,我们定义一个调用程序,使用我们新创建的UsesSplObjectStorage类来包含过滤器集:

  1. 首先,我们定义自动加载并使用适当的类:
// /repo/ch010/php7_weak_map_problem.php
require __DIR__ . '/../src/Server/Autoload/Loader.php';
loader = new \Server\Autoload\Loader();
use Laminas\Filter\ {StringTrim, StripNewlines,
    StripTags, ToInt, Whitelist, UriNormalize};
use Php7\Container\UsesSplObjectStorage;
  1. 接下来,我们定义一个样本数据数组:
$data = [
    'name'    => '<script>bad JavaScript</script>name',
    'status'  => 'should only contain digits 9999',
    'gender'  => 'FMZ only allowed M, F or X',
    'space'   => "  leading/trailing whitespace or\n",
    'url'     => 'unlikelysource.com/about',
];
  1. 然后,我们分配了对所有字段($required)和对某些字段特定的过滤器($added):
$required = [StringTrim::class, 
             StripNewlines::class, StripTags::class];
$added = ['status'  => ToInt::class,
          'gender'  => Whitelist::class,
          'url'     => UriNormalize::class ];
  1. 之后,我们创建一个过滤器实例数组,用于填充我们的服务容器UseSplObjectStorage。请记住,每个过滤器类都带有很大的开销,可以被认为是一个昂贵的对象:
$filters = [
    new StringTrim(),
    new StripNewlines(),
    new StripTags(),
    new ToInt(),
    new Whitelist(['list' => ['M','F','X']]),
    new UriNormalize(['enforcedScheme' => 'https']),
];
$container = new UsesSplObjectStorage($filters);
  1. 现在我们使用我们的容器类循环遍历数据文件,以检索过滤器实例。filter()方法会产生特定于该过滤器的经过清理的值:
foreach ($data as $key => &$value) {
    foreach ($required as $class) {
        $value = $container->get($class)->filter($value);
    }
    if (isset($added[$key])) {
        $value = $container->get($added[$key])
                            ->filter($value);
    }
}
var_dump($data);
  1. 最后,我们获取内存统计信息,以便比较SplObjectStorageWeakMap的使用情况。我们还取消了$filters,理论上应该释放大量内存。我们运行gc_collect_cycles()来强制 PHP 垃圾回收过程,将释放的内存重新放入池中。
$mem = memory_get_usage();
unset($filters);
gc_collect_cycles();
$end = memory_get_usage();
echo "\nMemory Before Unset: $mem\n";
echo "Memory After  Unset: $end\n";
echo 'Difference         : ' . ($end - $mem) . "\n";
echo 'Peak Memory Usage : ' . memory_get_peak_usage();

这是在 PHP 8 中运行的调用程序的结果:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php7_weak_map_problem.php 
array(5) {
  ["name"]=>  string(18) "bad JavaScriptname"
  ["status"]=>  int(0)
  ["gender"]=>  NULL
  ["space"]=>  string(30) "leading/trailing whitespace or"
  ["url"]=>  &string(32) "https://unlikelysource.com/about"
}
Memory Before Unset: 518936
Memory After  Unset: 518672
Difference          :    264
Peak Memory Usage  : 780168

从输出中可以看出,我们的容器类完美地工作,让我们可以访问存储的任何过滤器类。另一个有趣的地方是,在执行unset($filters)命令后释放的内存是264字节:并不多!

现在你已经了解了SplObjectStorage类的典型用法。现在让我们来看看SplObjectStorage类可能存在的问题,以及WeakMap是如何解决的。

了解 WeakMap 相对于 SplObjectStorage 的优势

SplObjectStorage的主要问题是,当分配的对象被取消分配或者超出范围时,它仍然保留在内存中。原因是当对象附加到SplObjectStorage实例时,是通过引用进行的。

如果你只处理少量对象,可能不会遇到严重的问题。如果你使用SplObjectStorage并为存储分配大量昂贵的对象,这可能最终会导致长时间运行的程序内存泄漏。另一方面,如果你使用WeakMap实例进行存储,垃圾回收可以移除对象,从而释放内存。当你开始将WeakMap实例整合到你的常规编程实践中时,你会得到更高效的代码,占用更少的内存。

提示

有关WeakMap的更多信息,请查看原始 RFC:https://wiki.php.net/rfc/weak_maps。还请查看文档:https://www.php.net/weakMap。

现在让我们重新编写前一节的示例(/repo/ch010/php7_weak_map_problem.php),但这次使用WeakMap

  1. 如前面的代码示例所述,我们定义了一个名为UsesWeakMap的容器类,其中包含我们昂贵的过滤器类。这个类和前一节中显示的类的主要区别在于UsesWeakMap使用WeakMap而不是SplObjectStorage进行存储。以下是类设置和__construct()方法:
// /repo/src/Php7/Container/UsesWeakMap.php
namespace Php8\Container;
use WeakMap;
class UsesWeakMap {
    public $container;
    public $default;
    public function __construct(array $config = []) {
        $this->container = new WeakMap();
        if ($config)
            foreach ($config as $obj)
                $this->container->offsetSet(
                    $obj, get_class($obj));
        $this->default = new class () {
            public function filter($value) { 
                return $value; }};
    }
  1. 两个类之间的另一个区别是WeakMap实现了IteratorAggregate。然而,这仍然允许我们在get()方法中使用简单的foreach()循环:
    public function get(string $key) {
        foreach ($this->container as $idx => $obj)
            if ($idx instanceof $key) return $idx;
        return $this->default;
    }
}

请注意,当使用foreach()循环来迭代WeakMap实例时,我们返回的是($idx),而不是值!

  1. 然后,我们定义一个调用程序,调用自动加载程序并使用适当的过滤器类。这个调用程序和上一节的程序最大的区别在于我们使用基于WeakMap的新容器类:
// /repo/ch010/php8_weak_map_problem.php
require __DIR__ . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Laminas\Filter\ {StringTrim, StripNewlines,
    StripTags, ToInt, Whitelist, UriNormalize};
use Php8\Container\UsesWeakMap;
  1. 与前一个示例一样,我们定义了一个样本数据数组并分配过滤器。这段代码没有显示,因为它与前一个示例的步骤 23相同。

  2. 然后,我们在一个数组中创建过滤器实例,该数组作为参数传递给我们的新容器类。我们使用过滤器数组作为参数来创建容器类实例:

$filters = [
    new StringTrim(),
    new StripNewlines(),
    new StripTags(),
    new ToInt(),
    new Whitelist(['list' => ['M','F','X']]),
    new UriNormalize(['enforcedScheme' => 'https']),
];
$container = new UsesWeakMap($filters);
  1. 最后,就像前一个示例中的步骤 6一样,我们循环遍历数据并应用容器类中的过滤器。我们还收集并显示内存统计信息。

这是在 PHP 8 中运行的输出,使用WeakMap进行修订的程序:

root@php8_tips_php8 [ /repo/ch10 ]# 
php php8_weak_map_problem.php 
array(5) {
  ["name"]=>  string(18) "bad JavaScriptname"
  ["status"]=>  int(0)
  ["gender"]=>  NULL
  ["space"]=>  string(30) "leading/trailing whitespace or"
  ["url"]=>  &string(32) "https://unlikelysource.com/about"
}
Memory Before Unset: 518712
Memory After  Unset: 517912
Difference          :    800
Peak Memory Usage  : 779944

正如你所期望的,总体内存使用略低。然而,最大的区别在于取消分配$filters后的内存差异。在前一个示例中,差异是264字节。而在这个示例中,使用WeakMap产生了800字节的差异。这意味着使用WeakMap有可能释放的内存量是使用SplObjectStorage的三倍以上!

这结束了我们对弱引用和弱映射的讨论。现在你可以编写更高效、占用更少内存的代码了。存储的对象越大,节省的内存就越多。

总结

在本章中,您不仅了解了新的 JIT 编译器的工作原理,还了解了传统的 PHP 解释-编译-执行循环。使用 PHP 8 并启用 JIT 编译器有可能将您的 PHP 应用程序加速三倍以上。

在下一节中,您将了解什么是稳定排序,以及 PHP 8 如何实现这一重要技术。通过掌握稳定排序,您的代码将以一种理性的方式产生数据,从而带来更大的客户满意度。

接下来的部分介绍了一种可以通过利用SplFixedArray类大大提高性能并减少内存消耗的技术。之后,您还了解了 PHP 8 对弱引用的支持以及新的WeakMap类。使用本章涵盖的技术将使您的应用程序执行速度更快,运行更高效,并且使用更少的内存。

在下一章中,您将学习如何成功迁移到 PHP 8。

第十一章:将现有 PHP 应用迁移到 PHP 8

在整本书中,您已经被警告可能出现代码断裂的情况。不幸的是,目前没有真正好的工具可以扫描您现有的代码并检查潜在的代码断裂。在本章中,我们将带您了解一组类的开发过程,这些类构成了 PHP 8 向后兼容BC)断裂扫描器的基础。此外,您还将学习将现有客户 PHP 应用迁移到 PHP 8 的推荐流程。

阅读本章并仔细研究示例后,您将更好地掌握 PHP 8 迁移。了解整体迁移过程后,您将更加自信,并能够以最少的问题执行 PHP 8 迁移。

本章涵盖的主题包括以下内容:

  • 了解开发、暂存和生产环境

  • 学习如何在迁移之前发现 BC(向后兼容)断裂

  • 执行迁移

  • 测试和故障排除迁移

技术要求

为了检查和运行本章提供的代码示例,最低推荐的硬件配置如下:

  • 基于 x86_64 的台式 PC 或笔记本电脑

  • 1 千兆字节GB)的可用磁盘空间

  • 4GB 的 RAM

  • 500 千比特每秒Kbps)或更快的互联网连接

此外,您还需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,以及如何构建用于演示本书中解释的代码的 Docker 容器,请参阅第一章技术要求部分,介绍新的 PHP 8 面向对象编程特性。在本书中,我们将您为本书恢复示例代码的目录称为/repo

本章的源代码位于github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices。我们现在可以开始讨论使用作为整体迁移过程的一部分的环境。

了解开发、暂存和生产环境

网站更新的最终目标是以尽可能无缝的方式将更新的应用程序代码从开发环境移动到生产环境。这种应用程序代码的移动被称为部署。在这种情况下,移动涉及将应用程序代码和配置文件从一个环境复制到另一个环境。

在我们深入讨论将应用程序迁移到 PHP 8 之前,让我们先看看这些环境是什么。了解不同环境可能采用的形式对于您作为开发人员的角色至关重要。有了这种理解,您就能更好地将代码部署到生产环境,减少错误的发生。

定义环境

我们使用环境一词来描述包括操作系统、Web 服务器、数据库服务器和 PHP 安装在内的软件堆栈的组合。过去,环境等同于服务器。然而,在现代,服务器这个术语是具有误导性的,因为它暗示着一个金属箱子中的物理计算机,放置在某个看不见的服务器房间的机架上。如今,鉴于云服务提供商和高性能的虚拟化技术(例如 Docker)的丰富,这更有可能不是真实情况。因此,当我们使用环境这个术语时,请理解它指的是物理或虚拟服务器。

环境通常分为三个不同的类别:开发暂存生产。一些组织还提供单独的测试环境。让我们先看看所有环境中的共同点。

常见组件

重要的是要注意,所有环境中的内容都受生产环境的驱动。生产环境是应用程序代码的最终目的地。因此,所有其他环境应尽可能与操作系统、数据库、Web 服务器和 PHP 安装匹配。因此,例如,如果生产环境启用了 PHP OPCache 扩展,所有其他环境也必须启用此扩展。

所有环境,包括生产环境,至少需要安装操作系统和 PHP。根据应用程序的需求,安装 Web 服务器和数据库服务器也是非常常见的。Web 和数据库服务器的类型和版本应尽可能与生产环境匹配。

一般来说,开发环境与生产环境越接近,部署后出现错误的几率就越小。

现在我们来看看开发环境需要什么。

开发环境

开发环境是您最初开发和测试代码的地方。它具有应用程序维护和开发所需的工具。这包括存储源代码的存储库(例如 Git),以及启动、停止和重置环境所需的各种脚本。

通常,开发环境会有触发自动部署过程的脚本。这些脚本可以取代提交钩子,设计用于在提交到源代码存储库时激活。其中一个例子是Git Hooks,即可放置在.git/hooks目录中的脚本文件。

提示

有关 Git Hooks 的更多信息,请查看此处的文档:git-scm.com/book/en/v2/Customizing-Git-Git-Hooks

传统的开发环境包括个人计算机、数据库服务器、Web 服务器和 PHP。这种传统范式未能考虑到目标生产环境可能存在的变化。例如,如果您经常与 12 个客户合作,那么这 12 个客户几乎不可能拥有完全相同的操作系统、数据库服务器、Web 服务器和 PHP 版本!最佳实践是尽可能模拟生产环境,可以采用虚拟机或 Docker 容器的形式。

因此,代码编辑器或IDE(集成开发环境)并不位于开发环境内。相反,您在开发环境之外进行代码创建和编辑。然后,您可以通过直接将文件复制到虚拟开发环境的共享目录,或者通过提交更改到源代码存储库,然后从开发环境虚拟机内拉取更改来本地推送更改。

在开发环境进行单元测试也是合适的。开发单元测试不仅可以更好地保证您的代码在生产环境中运行,而且还是发现应用程序开发早期阶段的错误的好方法。当然,您需要在本地环境中尽可能多地进行调试!在开发中捕获和修复错误通常只需要在生产中发现错误所需的十分之一的时间!

现在让我们来看看暂存环境。

暂存环境

大型应用程序开发项目通常会有多个开发人员共同在同一代码库上工作。在这种情况下,使用版本控制存储库至关重要。暂存环境是所有开发人员在开发环境测试和调试阶段完成后上传其代码的地方。

暂存环境必须是生产环境的精确副本。你可以把暂存环境想象成汽车工厂装配线上的最后一步。这是所有来自一个或多个开发环境的各种部件安装到位的地方。暂存环境是生产应该出现的原型。

重要的是要注意,暂存服务器通常可以直接访问互联网;然而,它通常位于一个需要密码才能访问的安全区域。

最后,让我们来看看生产环境。

生产环境

生产环境通常由客户直接维护和托管。这个环境也被称为现场环境。打个比方,如果开发环境是练习,暂存环境是彩排,那么生产环境就是现场演出(也许没有歌唱和舞蹈!)。

生产环境可以直接访问互联网,但受到防火墙的保护,通常还受到入侵检测和防御系统的保护(例如,snort.org/)。此外,生产环境可能被隐藏在运行在面向互联网的 Web 服务器上的反向代理配置后面。否则,至少在理论上,生产环境应该是暂存环境的精确克隆

现在你已经对应用程序代码从开发到生产的环境有了一个概念,让我们来看看 PHP 8 迁移的一个关键第一步:发现潜在的 BC 代码中断。

学习如何在迁移前发现 BC 中断

理想情况下,你应该带着一个行动计划进入 PHP 8 迁移。这个行动计划的关键部分包括了解当前代码库中存在多少潜在的 BC 中断。在本节中,我们将向您展示如何开发一个自动化查找潜在 BC 中断的 BC 中断嗅探器。

首先,让我们回顾一下到目前为止关于 PHP 8 中可能出现的 BC 问题学到的东西。

获取 BC 中断概述

您已经知道,通过阅读本书的前几章,潜在的代码中断源自几个方面。让我们简要总结一下可能导致迁移后代码失败的一般趋势。请注意,我们在本章中不涵盖这些主题,因为这些主题在本书的早期章节中都已经涵盖过了:

  • 资源到对象的迁移

  • 支持 OS 库的最低版本

  • IteratorIteratorAggregate的迁移

  • 已删除的函数

  • 使用变化

  • 魔术方法签名强制执行

许多变化可以通过添加基于preg_match()strpos()的简单回调来检测。使用变化更难以检测,因为乍一看,自动断点扫描器无法在不广泛使用eval()的情况下检测使用结果。

现在让我们来看看一个中断扫描配置文件可能是什么样子。

创建一个 BC 中断扫描配置文件

配置文件允许我们独立于 BC 中断扫描器类开发一组搜索模式。使用这种方法,BC 中断扫描器类定义了用于进行搜索的实际逻辑,而配置文件提供了一系列特定条件以及警告和建议的补救措施。

通过简单查找已在 PHP 8 中删除的函数的存在来检测到许多潜在的代码中断。为此,简单的strpos()搜索就足够了。另一方面,更复杂的搜索可能需要我们开发一系列回调。让我们首先看看如何基于简单的strpos()搜索开发配置。

定义一个简单的 strpos()搜索配置

在简单的strpos()搜索的情况下,我们只需要提供一个键/值对数组,其中键是被移除的函数的名称,值是它的建议替代品。BC 破坏扫描器类中的搜索逻辑可以这样做:

$contents = file_get_contents(FILE_TO_SEARCH);
foreach ($config['removed'] as $key => $value)
    if (str_pos($contents, $key) !== FALSE)  echo $value;

我们将在下一节中介绍完整的 BC 破坏扫描器类实现。现在,我们只关注配置文件。以下是前几个strpos()搜索条目可能出现的方式:

// /repo/ch11/bc_break_scanner.config.php
use Migration\BreakScan;
return [
    // not all keys are shown
    BreakScan::KEY_REMOVED => [
        '__autoload' => 'spl_autoload_register(callable)',
        'each' => 'Use "foreach()" or ArrayIterator',
        'fgetss' => 'strip_tags(fgets($fh))',
        'png2wbmp' => 'imagebmp',
        // not all entries are shown
    ],
];

不幸的是,一些 PHP 8 向后不兼容性可能超出了简单的strpos()搜索的能力。我们现在将注意力转向检测由 PHP 8 资源到对象迁移引起的潜在破坏。

检测与is_resource()相关的 BC 破坏

第七章在使用 PHP 8 扩展时避免陷阱,在PHP 8 扩展资源到对象迁移部分,您了解到 PHP 中存在一种从资源到对象的普遍趋势。您可能还记得,这种趋势本身并不构成任何 BC 破坏的威胁。然而,如果您的代码在确认连接已建立时使用了is_resource(),就有可能发生 BC 破坏。

为了考虑这种 BC 破坏的潜在性,我们的 BC 破坏扫描配置文件需要列出以前产生资源但现在产生对象的任何函数。然后我们需要在 BC 破坏扫描类中添加一个使用此列表的方法(下面讨论)。

这是受影响函数潜在配置键可能出现的方式:

// /repo/ch11/bc_break_scanner.config.php
return [    // not all keys are shown
    BreakScan::KEY_RESOURCE => [
        'curl_init',
        'xml_parser_create',
        // not all entries are shown
    ],
];

在破坏扫描类中,我们只需要首先确认是否调用了is_resource(),然后检查BreakScan::KEY_RESOURCE数组下列出的任何函数是否存在。

我们现在将注意力转向魔术方法签名违规。

检测魔术方法签名违规

PHP 8 严格执行魔术方法签名。如果您的类使用宽松的定义,即不对方法签名进行数据类型定义,并且对于魔术方法不定义返回值数据类型,那么您就不会受到潜在代码破坏的威胁。另一方面,如果您的魔术方法签名包含数据类型,并且这些数据类型与 PHP 8 中强制执行的严格定义集不匹配,那么您就有可能出现代码破坏!

因此,我们需要创建一组正则表达式,以便检测魔术方法签名违规。此外,我们的配置应包括正确的签名。通过这种方式,如果检测到违规,我们可以在生成的消息中呈现正确的签名,加快更新过程。

这是一个魔术方法签名配置可能出现的方式:

// /repo/ch11/bc_break_scanner.config.php
use Php8\Migration\BreakScan;
return [    
    BreakScan::KEY_MAGIC => [
    '__call' => [ 'signature' => 
        '__call(string $name, array $arguments): mixed',
        'regex' => '/__call\s*\((string\s)?'
            . '\$.+?(array\s)?\$.+?\)(\s*:\s*mixed)?/',
        'types' => ['string', 'array', 'mixed']],
    // other configuration keys not shown
    '__wakeup' => ['signature' => '__wakeup(): void',
        'regex' => '/__wakeup\s*\(\)(\s*:\s*void)?/',
        'types' => ['void']],
    ]
    // other configuration keys not shown
];

您可能注意到我们包含了一个额外的选项types。这是为了自动生成一个正则表达式。负责此操作的代码没有显示。如果您感兴趣,可以查看/path/to/repo/ch11/php7_build_magic_signature_regex.php

让我们看看在简单的strpos()搜索不足以满足的情况下,您可能如何处理复杂的破坏检测。

解决复杂的 BC 破坏检测

在简单的strpos()搜索不足以证明的情况下,我们可以开发另一组键/值对,其中值是一个回调函数。举个例子,考虑一个可能的 BC 破坏,一个类定义了一个__destruct()方法,但也在__construct()方法中使用了die()exit()。在 PHP 8 中,可能在这些情况下__destruct()方法不会被调用。

在这种情况下,简单的strpos()搜索是不够的。相反,我们必须开发逻辑来执行以下操作:

  • 检查是否定义了__destruct()方法。如果是,则无需继续,因为在 PHP 8 中不会出现破坏的危险。

  • 检查是否在__construct()方法中使用了die()exit()。如果是,则发出潜在 BC 破坏的警告。

在我们的 BC 断点扫描配置数组中,回调采用匿名函数的形式。它接受文件内容作为参数。然后我们将回调分配给数组配置键,并包括如果回调返回TRUE时要传递的警告消息:

// /repo/ch11/bc_break_scanner.config.php
return [
    // not all keys are shown
   BreakScan::KEY_CALLBACK => [
    'ERR_CONST_EXIT' => [
      'callback' => function ($contents) {
        $ptn = '/__construct.*?\{.*?(die|exit).*?}/im';
        return (preg_match($ptn, $contents)
                && strpos('__destruct', $contents)); },
      'msg' => 'WARNING: __destruct() might not get '
               . 'called if "die()" or "exit()" used '
               . 'in __construct()'],
    ], // etc.
    // not all entries are shown
];

在我们的 BC 断点扫描器类(下面讨论)中,调用回调所需的逻辑可能如下所示:

$contents = file_get_contents(FILE_TO_SEARCH);
$className = 'SOME_CLASS';
foreach ($config['callbacks'] as $key => $value)
    if ($value'callback') echo $value['msg'];

如果检测到额外的潜在 BC 断点的要求超出了回调的能力,那么我们将在 BC 断点扫描类中定义一个单独的方法。

正如你所看到的,我们可以开发一个支持不仅简单的strpos()搜索,还支持使用回调数组进行更复杂搜索的配置数组。

现在你已经对配置数组中会包含什么有了一个概念,是时候定义执行断点扫描的主要类了。

开发 BC 断点扫描类

BreakScan类是针对单个文件的。在这个类中,我们定义了利用刚刚覆盖的各种断点扫描配置的方法。如果我们需要扫描多个文件,调用程序会生成一个文件列表,并将它们逐个传递给BreakScan

BreakScan类可以分为两个主要部分:定义基础设施的方法和定义如何进行给定扫描的方法。后者主要由配置文件的结构来决定。对于每个配置文件部分,我们将需要一个BreakScan类方法。

让我们先看看基础方法。

定义 BreakScan 类基础方法

在这一部分,我们看一下BreakScan类的初始部分。我们还涵盖了执行基础相关活动的方法:

  1. 首先,我们设置类基础设施,将其放在/repo/src/Php8/Migration目录中:
// /repo/src/Php8/Migration/BreakScan.php
declare(strict_types=1);
namespace Php8\Migration;
use InvalidArgumentException;
use UnexpectedValueException;
class BreakScan {
  1. 接下来,我们定义一组类常量,用于表示任何给定的后扫描失败的性质的消息:
    const ERR_MAGIC_SIGNATURE = 'WARNING: magic method '
        . 'signature for %s does not appear to match '
        . 'required signature';
    const ERR_NAMESPACE = 'WARNING: namespaces can no '
        . 'longer contain spaces in PHP 8.';
    const ERR_REMOVED = 'WARNING: the following function'
        . 'has been removed: %s.  Use this instead: %s';
    // not all constants are shown
  1. 我们还定义了一组表示配置数组键的常量。我们这样做是为了在配置文件和调用程序中保持键定义的一致性(稍后讨论):
    const KEY_REMOVED         = 'removed';
    const KEY_CALLBACK        = 'callbacks';
    const KEY_MAGIC           = 'magic';
    const KEY_RESOURCE        = 'resource';
  1. 然后我们初始化关键属性,表示配置,要扫描的文件的内容和任何消息:
    public $config = [];
    public $contents = '';
    public $messages = [];
  1. __construct()方法接受我们的断点扫描配置文件作为参数,并循环遍历所有键以确保它们存在:
    public function __construct(array $config) {
        $this->config = $config;
        $required = [self::KEY_CALLBACK,
            self::KEY_REMOVED,
            self::KEY_MAGIC, 
            self::KEY_RESOURCE];
        foreach ($required as $key) {
            if (!isset($this->config[$key])) {
                $message = sprintf(
                    self::ERR_MISSING_KEY, $key);
                throw new Exception($message);
            }
        }
    }
  1. 然后我们定义一个方法,读取要扫描的文件的内容。请注意,我们删除回车("\r")和换行符("\n"),以便通过正则表达式更容易处理扫描:
    public function getFileContents(string $fn) {
        if (!file_exists($fn)) {
            self::$className = '';
            $this->contents  = '';
            throw new  Exception(
                sprintf(self::ERR_FILE_NOT_FOUND, $fn));
        }
        $this->contents = file_get_contents($fn);
        $this->contents = str_replace(["\r","\n"],
            ['', ' '], $this->contents);
        return $this->contents;
    }
  1. 一些回调需要一种方法来提取类名或命名空间。为此,我们定义了静态的getKeyValue()方法:
    public static function getKeyValue(
        string $contents, string $key, string $end) {
        $pos = strpos($contents, $key);
        $end = strpos($contents, $end, 
            $pos + strlen($key) + 1);
        return trim(substr($contents, 
            $pos + strlen($key), 
            $end - $pos - strlen($key)));
    }

这个方法寻找关键字(例如,class)。然后找到关键字后面的内容,直到分隔符(例如,';')。所以,如果你想要获取类名,你可以执行以下操作:\(name = BreakScan::geyKeyValue(\)contents,'class',';')`。

  1. 我们还需要一种方法来检索和重置$this->messages。以下是这两种方法:
    public function clearMessages() : void {
        $this->messages = [];
    }
    public function getMessages(bool $clear = FALSE) {
        $messages = $this->messages;
        if ($clear) $this->clearMessages();
        return $messages;
    }
  1. 然后我们定义一个运行所有扫描的方法(在下一节中涵盖)。这个方法还会收集检测到的潜在 BC 断点的数量并报告总数:
    public function runAllScans() : int {
        $found = 0;
        $found += $this->scanRemovedFunctions();
        $found += $this->scanIsResource();
        $found += $this->scanMagicSignatures();
        $found += $this->scanFromCallbacks();
        return $found;
    }

现在你已经对基本的BreakScan类基础设施可能是什么有了一个概念,让我们来看看单独的扫描方法。

检查单独的扫描方法

四个单独的扫描方法直接对应于断点扫描配置文件中的顶级键。每个方法都应该累积关于潜在 BC 断点的消息在$this->messages中。此外,每个方法都应该返回一个表示检测到的潜在 BC 断点总数的整数。

现在让我们按顺序检查这些方法:

  1. 我们首先检查的方法是scanRemovedFunctions()。在这个方法中,我们搜索函数名称,后面直接跟着开括号'(',或者是空格和开括号' ('。如果找到函数,我们递增$found,并将适当的警告和建议的替换添加到$this-> messages中。如果没有发现潜在的破坏,我们添加一个成功消息并返回0
public function scanRemovedFunctions() : int {
    $found = 0;
    $config = $this->config[self::KEY_REMOVED];
    foreach ($config as $func => $replace) {
        $search1 = ' ' . $func . '(';
        $search2 = ' ' . $func . ' (';
        if (
            strpos($this->contents, $search1) !== FALSE
            || 
            strpos($this->contents, $search2) !== FALSE)
        {
            $this->messages[] = sprintf(
                self::ERR_REMOVED, $func, $replace);
            $found++;
        }
    }
    if ($found === 0)
        $this->messages[] = sprintf(
            self::OK_PASSED, __FUNCTION__);
    return $found;
}

这种方法的主要问题是,如果函数没有在空格之前,则不会检测到其使用。但是,如果我们在搜索中不包括前导空格,我们可能会得到错误的结果。例如,没有前导空格,每个foreach()的实例在寻找each()时都会触发破坏扫描器的警告!

  1. 接下来,我们看一下扫描is_resource()使用的方法。如果找到引用,此方法将遍历不再生成资源的函数列表。如果同时找到is_resource()和其中一个这些方法,将标记潜在的 BC 破坏:
public function scanIsResource() : int {
    $found = 0;
    $search = 'is_resource';
    if (strpos($this->contents, $search) === FALSE)
        return 0;
    $config = $this->config[self::KEY_RESOURCE];
    foreach ($config as $func) {
        if ((strpos($this->contents, $func) !== FALSE)){
            $this->messages[] =
                sprintf(self::ERR_IS_RESOURCE, $func);
            $found++;
        }
    }
    if ($found === 0)
        $this->messages[] = 
            sprintf(self::OK_PASSED, __FUNCTION__);
    return $found;
}
  1. 然后我们看一下需要通过我们的回调列表的内容。您还记得,我们需要在简单的strpos()无法满足的情况下使用回调。因此,我们首先收集所有回调子键并依次循环遍历每个子键。如果没有底层键callback,我们会抛出一个Exception。否则,我们运行回调,提供$this->contents作为参数。如果发现任何潜在的 BC 破坏,我们添加适当的错误消息,并递增$found
public function scanFromCallbacks() {
    $found = 0;
    $list = array_keys($this-config[self::KEY_CALLBACK]);
    foreach ($list as $key) {
        $config = $this->config[self::KEY_CALLBACK][$key] 
            ?? NULL;
        if (empty($config['callback']) 
            || !is_callable($config['callback'])) {
            $message = sprintf(self::ERR_INVALID_KEY,
                self::KEY_CALLBACK . ' => ' 
                . $key . ' => callback');
            throw new Exception($message);
        }
        if ($config'callback') {
            $this->messages[] = $config['msg'];
            $found++;
        }
    }
    return $found;
}
  1. 最后,我们转向迄今为止最复杂的方法,该方法扫描无效的魔术方法签名。主要问题是方法签名差异很大,因此我们需要构建单独的正则表达式来正确测试有效性。正则表达式存储在 BC 破坏配置文件中。如果检测到魔术方法,我们检索其正确的签名并将其添加到$this->messages中。

  2. 首先,我们检查是否有任何魔术方法,通过查找与function __匹配的内容:

public function scanMagicSignatures() : int {
    $found   = 0;
    $matches = [];
    $result  = preg_match_all(
        '/function __(.+?)\b/', 
        $this->contents, $matches);
  1. 如果匹配数组不为空,我们循环遍历匹配集并将魔术方法名称分配给$key
   if (!empty($matches[1])) {
        $config = $this->config[self::KEY_MAGIC] ?? NULL;
        foreach ($matches[1] as $name) {
            $key = '__' . $name;
  1. 如果未设置与假定魔术方法匹配的配置键,我们假设它既不是魔术方法,也不在配置文件中,因此无需担心。否则,如果存在键,我们提取表示分配给$sub的方法调用的子字符串:
            if (empty($config[$key])) continue;
            if ($pos = strpos($this->contents, $key)) {
                $end = strpos($this->contents, 
                    '{', $pos);
            $sub = (empty($sub) || !is_string($sub))
                 ? '' : trim($sub);
  1. 然后,我们从配置中提取正则表达式并将其与子字符串匹配。该模式表示该特定魔术方法的正确签名。如果preg_match()返回FALSE,我们知道实际签名不正确,并将其标记为潜在的 BC 破坏。我们检索并存储警告消息并递增$found
            $ptn = $config[$key]['regex'] ?? '/.*/';
            if (!preg_match($ptn, $sub)) {
                $this->messages[] = sprintf(
                  self::ERR_MAGIC_SIGNATURE, $key);
                $this->messages[] = 
                  $config[$key]['signature'] 
                  ?? 'Check signature'
                $found++;
    }}}}
    if ($found === 0)
        $this->messages[] = sprintf(
            self::OK_PASSED, __FUNCTION__);
     return $found;
}

这结束了我们对BreakScan类的审查。现在我们将注意力转向定义调用程序,该程序需要运行BreakScan类中编程的扫描。

构建一个调用程序的 BreakScan 类

调用BreakScan类的程序的主要工作是接受一个路径参数,并递归构建该路径中的 PHP 文件列表。然后,我们循环遍历列表,依次提取每个文件的内容,并运行 BC 破坏扫描。最后,我们提供一个报告,可以是简洁的或详细的,取决于所选的详细级别。

请记住,BreakScan类和我们即将讨论的调用程序都是设计用于在 PHP 7 下运行。我们不使用 PHP 8 的原因是因为我们假设开发人员希望在进行 PHP 8 更新之前运行 BC 破坏扫描器:

  1. 我们首先通过配置自动加载程序并从命令行($argv)或 URL($_GET)获取路径和详细级别。此外,我们提供了一个选项,将结果写入 CSV 文件,并接受此类文件的名称作为参数。您可能注意到我们还进行了一定程度的输入消毒,尽管理论上 BC 破坏扫描器只会在开发服务器上直接由开发人员使用:
// /repo/ch11/php7_bc_break_scanner.php
define('DEMO_PATH', __DIR__);
require __DIR__ . '/../src/Server/Autoload/Loader.php';
$loader = new \Server\Autoload\Loader();
use Php8\Migration\BreakScan;
// some code not shown
$path = $_GET['path'] ?? $argv[1] ?? NULL;
$show = $_GET['show'] ?? $argv[2] ?? 0;
$show = (int) $show;
$csv  = $_GET['csv']  ?? $argv[3] ?? '';
$csv  = basename($csv);
  1. 接下来我们确认路径。如果找不到,我们将退出并显示使用信息($usage未显示):
if (empty($path)) {
    if (!empty($_SERVER['REQUEST_URI']))
        echo '<pre>' . $usage . '</pre>';
    else
        echo $usage;
    exit;
}
  1. 然后我们抓取 BC 破坏配置文件并创建BreakScan实例:
$config  = include __DIR__ 
. '/php8_bc_break_scanner_config.php';
$scanner = new BreakScan($config);
  1. 为了构建文件列表,我们使用RecursiveDirectoryIterator,包装在RecursiveIteratorIterator中,从给定路径开始。然后,这个列表通过FilterIterator进行过滤,限制扫描仅限于 PHP 文件:
$iter = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($path));
$filter = new class ($iter) extends FilterIterator {
    public function accept() {
        $obj = $this->current();
        return ($obj->getExtension() === 'php');
    }
};
  1. 如果开发人员选择 CSV 选项,将创建一个SplFileObject实例。与此同时,我们还输出了一个标题数组。此外,我们定义了一个写入 CSV 文件的匿名函数:
if ($csv) {
    $csv_file = new SplFileObject($csv, 'w');
    $csv_file->fputcsv(
        ['Directory','File','OK','Messages']);
}
$write = function ($dir, $fn, $found, $messages) 
    use ($csv_file) {
    $ok = ($found === 0) ? 1 : 0;
    $csv_file->fputcsv([$dir, $fn, $ok, $messages]);
    return TRUE;
};
  1. 我们通过循环遍历FilterIterator实例呈现的文件列表来启动扫描。由于我们是逐个文件扫描,所以在每次通过时$found都被清零。但是,我们确实保持$total,以便在最后给出潜在 BC 破坏的总数。您可能还注意到我们区分文件和目录。如果目录发生变化,其名称将显示为标题:
$dir   = '';
$total = 0;
foreach ($filter as $name => $obj) {
    $found = 0;
    $scanner->clearMessages();
    if (dirname($name) !== $dir) {
        $dir = dirname($name);
        echo "Processing Directory: $name\n";
    }
  1. 我们使用SplFileObject::isDir()来确定文件列表中的项目是否是目录。如果是,我们将继续处理列表中的下一个项目。然后我们将文件内容推送到$scanner并运行所有扫描。然后以字符串形式检索消息:
    if ($obj->isDir()) continue;
    $fn = basename($name);
    $scanner->getFileContents($name);
    $found    = $scanner->runAllScans();
    $messages = implode("\n", $scanner->getMessages());
  1. 我们使用switch()块根据$show表示的显示级别采取行动。级别0仅显示发现潜在 BC 破坏的文件。级别1显示此外还有消息。级别2显示所有可能的输出,包括成功消息:
    switch ($show) {
        case 2 :
            echo "Processing: $fn\n";
            echo "$messages\n";
            if ($csv) 
                $write($dir, $fn, $found, $messages);
            break;
        case 1 :
            if (!$found) break;
            echo "Processing: $fn\n";
            echo BreakScan::WARN_BC_BREAKS . "\n";
            printf(BreakScan::TOTAL_BREAKS, $found);
            echo "$messages\n";
            if ($csv) 
                $write($dir, $fn, $found, $messages);
            break;
        case 0 :
        default :
            if (!$found) break;
            echo "Processing: $fn\n";
            echo BreakScan::WARN_BC_BREAKS . "\n";
            if ($csv) 
                $write($dir, $fn, $found, $messages);
    }
  1. 最后,我们累积总数并显示最终结果:
    $total += $found;
}
echo "\n" . str_repeat('-', 40) . "\n";
echo "\nTotal number of possible BC breaks: $total\n";

现在您已经了解了调用可能的外观,让我们来看一下测试扫描的结果。

扫描应用程序文件

为了演示目的,在与本书相关的源代码中,我们包含了一个较旧版本的phpLdapAdmin。您可以在/path/to/repo/sample_data/phpldapadmin-1.2.3找到源代码。对于此演示,我们打开了 PHP 7 容器的 shell,并运行了以下命令:

root@php8_tips_php7 [ /repo ]# 
php ch11/php7_bc_break_scanner.php \
    sample_data/phpldapadmin-1.2.3/ 1 |less

这是运行此命令的部分结果:

Processing: functions.php
WARNING: the code in this file might not be 
compatible with PHP 8
Total potential BC breaks: 4
WARNING: the following function has been removed: function __autoload.  
Use this instead: spl_autoload_register(callable)
WARNING: the following function has been removed: create_function.  Use this instead: Use either "function () {}" or "fn () => <expression>"
WARNING: the following function has been removed: each.  Use this instead: Use "foreach()" or ArrayIterator
PASSED this scan: scanIsResource
PASSED this scan: scanMagicSignatures
WARNING: using the "@" operator to suppress warnings 
no longer works in PHP 8.

从输出中可以看出,尽管functions.php通过了scanMagicSignaturesscanIsResource扫描,但这个代码文件使用了在 PHP 8 中已删除的三个函数:__autoload()create_function()each()。您还会注意到这个文件使用@符号来抑制错误,在 PHP 8 中不再有效。

如果您指定了 CSV 文件选项,您可以在任何电子表格程序中打开它。以下是在 Libre Office Calc 中的显示方式:

图 11.1-在 Libre Office Calc 中打开的 CSV 文件

图 11.1-在 Libre Office Calc 中打开的 CSV 文件

现在您已经了解了如何创建自动化程序来检测潜在的 BC 破坏。请记住,代码远非完美,并不能涵盖每一个可能的代码破坏。为此,您必须在仔细审阅本书材料后依靠自己的判断。

现在是时候将我们的注意力转向实际迁移本身了。

执行迁移

执行从当前版本到 PHP 8 版本的实际迁移,就像部署新功能集到现有应用程序的过程一样。如果可能的话,您可以考虑并行运行两个网站,直到您确信新版本按预期工作为止。许多组织为此目的并行运行暂存环境和生产环境。

在本节中,我们提供了一个十二步指南来执行成功的迁移。虽然我们专注于迁移到 PHP 8,但这十二个步骤可以适用于您可能希望执行的任何 PHP 更新。仔细理解并遵循这些步骤对于您的生产网站的成功至关重要。在这十二个步骤中,有很多地方可以在遇到问题时恢复到早期版本。

在我们从旧版本的 PHP 迁移到 PHP 8 的十二步迁移过程中,这是一个概述:

  1. 仔细阅读 PHP 文档附录中的适当迁移指南。在我们的情况下,我们选择Migrating from PHP 7.4x to PHP 8.0x。(www.php.net/manual/en/appendices.php)。

  2. 确保您当前的代码在当前版本的 PHP 上运行正常。

  3. 备份数据库(如果有),所有源代码和任何相关的文件和资产(例如,CSS,JavaScript 或图形图像)。

  4. 在您的版本控制软件中为即将更新的应用程序代码创建一个新分支。

  5. 扫描 BC 中断(可能使用前一节中讨论的BreakScan类)。

  6. 更新任何不兼容的代码。

  7. 根据需要重复步骤 56

  8. 将您的源代码上传到存储库。

  9. 在尽可能模拟生产服务器的虚拟环境中测试源代码。

  10. 如果虚拟化模拟不成功,请返回到步骤 5

  11. 将暂存服务器(或等效的虚拟环境)更新到 PHP 8,确保可以切换回旧版本。

  12. 运行您能想象到的每一个测试。如果不成功,请切换回主分支并返回到步骤 5。如果成功,克隆暂存环境到生产环境。

现在让我们依次看看每一步。

第 1 步 - 查看迁移指南

随着每个 PHP 的主要发布,PHP 核心团队都会发布一个迁移指南。我们在本书中主要关注的指南是Migrating from PHP 7.4.x to PHP 8.0.x,位于www.php.net/manual/en/migration80.php。这个迁移指南分为四个部分:

  • 新功能

  • 向后不兼容的更改

  • 弃用功能

  • 其他更改

如果您正在从 PHP 7.4 以外的版本迁移到 PHP 8.0,您还应该查看您当前 PHP 版本的所有过去迁移指南,直到 PHP 8。我们现在将看看迁移过程中的其他推荐步骤。

第 2 步 - 确保当前代码正常工作

在开始对当前代码进行更改以确保其在 PHP 8 中正常工作之前,确保它绝对正常工作是非常关键的。如果现在代码不起作用,那么一旦迁移到 PHP 8,它肯定也不会起作用!运行任何单元测试以及任何黑盒测试,以确保代码在当前版本的 PHP 中正常运行。

如果在迁移之前对当前代码进行了任何更改,请确保这些更改反映在您版本控制软件的主分支(通常称为主分支)中。

第 3 步 - 备份所有内容

下一步是备份所有内容。这包括数据库、源代码、JavaScript、CSS、图像等。还请不要忘记备份重要的配置文件,如php.ini文件、web 服务器配置和与 PHP 和 web 通信相关的任何其他配置文件。

第 4 步 - 创建版本控制分支

在这一步中,您应该在您的版本控制系统中创建一个新的分支并检出该分支。在主分支中,您应该只有当前有效的代码。

这是使用 Git 进行此类命令的方式:

$ git branch php8_migration
$ git checkout php8_migration
Switched to branch 'php8_migration'

所示的第一条命令创建了一个名为php8_migration的分支。第二条命令使git切换到新分支。在这个过程中,所有现有的代码都被移植到了新分支。主分支现在是安全的,并且在新分支中进行任何更改都得到了保留。

有关使用 Git 进行版本控制的更多信息,请查看这里:git-scm.com/

第 5 步 - 扫描 BC 破坏

现在是时候充分利用BreakScan类了。运行调用程序,并作为参数提供项目的起始目录路径以及详细级别(012)。您还可以指定一个 CSV 文件作为第三个选项,就像图 11.1中早些时候所示的那样。

第 6 步 - 修复不兼容性

在这一步中,知道破坏的位置,您可以继续修复不兼容性。您应该能够以这样的方式进行修复,使得代码在当前版本的 PHP 中继续运行,同时也可以在 PHP 8 中运行。正如我们在整本书中一直指出的那样,BC 破坏在很大程度上源自糟糕的编码实践。通过修复不兼容性,您同时改进了您的代码。

第 7 步 - 根据需要重复步骤 5 和 6

有一句名言在许多好莱坞电影中反复出现,医生对焦虑的病人说,“服用两片阿司匹林,明天早上给我打电话”。同样的建议也适用于解决 BC 破坏的过程。您必须要有耐心,继续修复和扫描,修复和扫描。一直这样做,直到扫描不再显示潜在的 BC 破坏为止。

第 8 步 - 将更改提交到存储库

一旦您相对确信没有进一步的 BC 破坏,就是时候将更改提交到您在版本控制软件中创建的新 PHP 8 迁移分支。现在可以推送更改。然后,您可以在生产服务器上解决 PHP 更新后,从该分支检索更新的代码。

请记住这一重要点:您当前的工作代码安全地存储在主分支中。您只是在这个阶段保存到 PHP 8 迁移分支,所以您随时可以切换回去。

第 9 步 - 在模拟虚拟环境中进行测试

将这一步看作是真正事情的彩排。在这一步中,您创建一个虚拟环境(例如,使用 Docker 容器),最接近模拟生产服务器。在这个虚拟环境中,然后安装 PHP 8。一旦创建了虚拟环境,您可以打开一个命令行进入其中,并从 PHP 8 迁移分支下载您的源代码。

然后,您可以运行单元测试和任何其他您认为必要的测试,以测试更新后的代码。希望在这一步中能够捕获任何额外的错误。

第 10 步 - 如果测试不成功,则返回第 5 步

如果在虚拟环境中进行的单元测试、黑盒测试或其他测试显示您的应用程序代码失败,您必须返回到第 5 步。在面对明显的失败时继续前往实际生产站点将是极不明智的!

第 11 步 - 在暂存环境中安装 PHP 8

下一步是在暂存环境中安装 PHP 8。您可能还记得我们在本章第一部分讨论中提到的,传统流程是从开发环境到暂存环境,然后再到生产环境。一旦在暂存环境上完成了所有测试,您就可以将暂存克隆到生产环境。

PHP 的安装在主php.net网站上有详细的文档,因此这里不需要进一步的细节。相反,在本节中,我们为您提供了 PHP 安装的简要概述,重点是能够在 PHP 8 和当前 PHP 版本之间切换的能力。

提示

有关在各种环境中安装 PHP 的信息,请参阅此文档页面:www.php.net/manual/en/install.php

为了举例说明,我们选择讨论两个主要 Linux 分支上的 PHP 8 安装:Debian/Ubuntu 和 Red Hat/CentOS/Fedora。让我们从 Debian/Ubuntu Linux 开始。

在 Debian/Ubuntu Linux 上安装 PHP 8

安装 PHP 8 的最佳方法是使用现有的一组预编译的二进制文件。较新的 PHP 版本往往比发布日期晚得多,并且 PHP 8 也不例外。在这种情况下,建议您使用(Personal Package Archive(PPA)。托管在launchpad.net/~ondrej的 PPA 是最全面和广泛使用的。

如果您想在自己的计算机上模拟以下步骤,请使用以下命令运行一个预先安装了 PHP 7.4 的 Ubuntu Docker 镜像:

docker run -it \
  unlikelysource/ubuntu_focal_with_php_7_4:latest /bin/bash

为了在 Debian 或 Ubuntu Linux 上安装 PHP 8,打开一个命令行到生产服务器(或演示容器)上,并以root用户的身份进行如下操作。或者,如果没有root用户访问权限,可以在每个显示的命令前加上sudo

从命令行安装 PHP 8,请按照以下步骤进行:

  1. 使用apt实用程序更新和升级当前的软件包集。可以使用任何软件包管理器;但是,我们展示了使用apt来保持这里涵盖的安装步骤之间的一致性:
apt update
apt upgrade
  1. Ondrej PPA存储库添加到您的apt源中:
add-apt-repository ppa:ondrej/php
  1. 安装 PHP 8。这只安装了 PHP 8 核心和基本扩展:
apt install php8.0
  1. 使用以下命令扫描存储库以获取额外的扩展,并使用apt根据需要安装它们:
apt search php8.0-*
  1. 进行 PHP 版本检查,以确保您现在正在运行 PHP 8:
php --version

以下是版本检查输出:

root@ec873e16ee93:/# php --version
PHP 8.0.7 (cli) (built: Jun  4 2021 21:26:10) ( NTS )
Copyright (c) The PHP Group
Zend Engine v4.0.7, Copyright (c) Zend Technologies
with Zend OPcache v8.0.7, Copyright (c), by Zend Technologies

现在您已经对 PHP 8 安装可能进行的基本步骤有了基本的了解,让我们看看如何在当前版本和 PHP 8 之间切换。为了举例说明,我们假设在安装 PHP 8 之前,PHP 7.4 是当前的 PHP 版本。

在 Debian 和 Ubuntu Linux 之间切换 PHP 版本

如果您检查 PHP 的位置,您会注意到在 PHP 8 安装后,较早的版本 PHP 7.4 仍然存在。您可以使用whereis php来实现这一目的。我们模拟的 Ubuntu Docker 容器上的输出如下:

root@ec873e16ee93:/# whereis php
php: /usr/bin/php /usr/bin/php8.0 /usr/bin/php7.4 /usr/lib/php /etc/php /usr/share/php7.4-opcache /usr/share/php8.0-opcache /usr/share/php8.0-readline /usr/share/php7.4-readline /usr/share/php7.4-json /usr/share/php8.0-common /usr/share/php7.4-common

如您所见,我们现在安装了 7.4 和 8.0 版本的 PHP。要在两者之间切换,请使用此命令:

update-alternatives --config php

然后会出现一个选项屏幕,让您选择哪个 PHP 版本应该处于活动状态。以下是 Ubuntu Docker 镜像上输出屏幕的样子:

root@ec873e16ee93:/# update-alternatives --config php 
There are 2 choices for the alternative php 
(providing /usr/bin/php).
  Selection    Path             Priority   Status
------------------------------------------------------------
* 0            /usr/bin/php8.0   80        auto mode
  1            /usr/bin/php7.4   74        manual mode
  2            /usr/bin/php8.0   80        manual mode
Press <enter> to keep the current choice[*], or type selection number:

切换后,您可以再次执行php --version来确认另一个 PHP 版本是否处于活动状态。

现在让我们把注意力转向 Red Hat Linux 及其衍生产品上的 PHP 8 安装。

在 Red Hat、CentOS 或 Fedora Linux 上安装 PHP 8

Red Hat、CentOS 或 Fedora Linux 上的 PHP 安装遵循一系列与 Debian/Ubuntu 安装过程相似的命令。主要区别在于,您很可能会使用dnfyum的组合来安装预编译的 PHP 二进制文件。

如果您想跟随本节中我们概述的安装步骤,可以使用一个已经安装了 PHP 7.4 的 Fedora Docker 容器进行模拟。以下是运行模拟的命令:

docker run -it unlikelysource/fedora_34_with_php_7_4 /bin/bash

与前一节描述的 PPA 环境非常相似,在 Red Hat 世界中,Remi's RPM Repository项目(rpms.remirepo.net/)以Red Hat Package ManagementRPM)格式提供预编译的二进制文件。

要在 Red Hat、CentOS 或 Fedora 上安装 PHP 8,请打开一个命令行到生产服务器(或演示环境)上,并以root用户的身份进行如下操作:

  1. 首先,确认您正在使用的操作系统版本和发行版是一个好主意。为此,使用uname命令,以及一个简单的cat命令来查看发行版(存储在/etc目录中的文本文件):
[root@9d4e8c93d7b6 /]# uname -a
Linux 9d4e8c93d7b6 5.8.0-55-generic #62~20.04.1-Ubuntu
SMP Wed Jun 2 08:55:04 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
[root@9d4e8c93d7b6 /]# cat /etc/fedora-release 
Fedora release 34 (Thirty Four)
  1. 在开始之前,请确保更新dnf并安装配置管理器:
dnf upgrade  
dnf install 'dnf-command(config-manager)'
  1. 然后,您可以将 Remi 的存储库添加到您的软件包源中,使用您喜欢的版本号替换NN
dnf install \
  https://rpms.remirepo.net/fedora/remi-release-NN.rpm
  1. 此时,您可以使用dnf module list确认已安装的 PHP 版本。我们还使用grep来限制显示的模块列表仅为 PHP。[e]表示已启用
[root@56b9fbf499d6 /]# dnf module list |grep php
php                    remi-7.4 [e]     common [d] [i],
devel, minimal    PHP scripting language                        php                    remi-8.0         common [d], devel, minimal        PHP scripting language
  1. 然后我们检查当前的 PHP 版本:
[root@d044cbe477c8 /]# php --version
PHP 7.4.20 (cli) (built: Jun  1 2021 15:41:56) (NTS)
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
  1. 接下来,我们重置 PHP 模块,并安装 PHP 8:
dnf -y module reset php
dnf -y module install php:remi-8.0
  1. 另一个快速的 PHP 版本检查显示我们现在使用的是 PHP 8 而不是 PHP 7:
[root@56b9fbf499d6 /]# php -v
PHP 8.0.7 (cli) (built: Jun  1 2021 18:43:05) 
( NTS gcc x86_64 ) Copyright (c) The PHP Group
Zend Engine v4.0.7, Copyright (c) Zend Technologies
  1. 要切换回较早版本的 PHP,请按照以下步骤进行,其中X.Y是您打算使用的版本:
dnf -y module reset php
dnf -y module install php:remi-X.Y

这完成了 Red Hat、CentOS 或 Fedora 的 PHP 安装说明。在本演示中,我们只向您展示了 PHP 命令行安装。如果您计划与 Web 服务器一起使用 PHP,还需要安装适当的 PHP Web 服务器包和/或安装 PHP-FPM(FastCGI 处理模块)包。

现在让我们来看看最后一步。

第 12 步 – 测试并将暂存环境克隆到生产环境

在最后一步中,您将从 PHP 8 迁移分支下载源代码到暂存环境,并运行各种测试以确保一切正常。一旦您确保成功,然后将暂存环境克隆到生产环境。

如果您使用虚拟化,克隆过程可能只涉及创建一个相同的 Docker 容器或虚拟磁盘文件。否则,如果涉及实际硬件,您可能最终会克隆硬盘,或者根据您的设置选择适当的方法。

这完成了我们关于如何执行迁移的讨论。现在让我们来看看测试和故障排除。

测试和故障排除迁移

在理想的情况下,迁移故障排除将在上线服务器或模拟的虚拟环境上进行,远在实际上线之前。然而,正如经验丰富的开发人员所知,我们需要抱最好的希望,但做最坏的准备!在本节中,我们将涵盖一些可能被轻易忽视的测试和故障排除的其他方面。

在本节中,如果您正在遵循 Debian/Ubuntu 或 Red Hat/CentOS/Fedora 安装过程,可以退出临时 shell。返回用于本课程的 Docker 容器,并打开 PHP 8 容器的命令 shell。如果您不确定如何操作,请参阅第一章技术要求部分,了解更多信息。

测试和故障排除工具

这里有太多优秀的测试和故障排除工具可用,无法在此处一一列举,因此我们将重点放在一些开源工具上,以帮助测试和故障排除。

使用 Xdebug

Xdebug 是一个工具,提供诊断、分析、跟踪和逐步调试等功能。它是一个 PHP 扩展,因此能够在您遇到无法轻松解决的问题时提供详细信息。主要网站是xdebug.org/

要启用 Xdebug 扩展,您可以像安装任何其他 PHP 扩展一样安装它:使用pecl命令,或者从pecl.php.net/package/xdebug下载并编译源代码。

此外,至少应设置以下/etc/php.ini设置:

zend_extension=xdebug.so
xdebug.log=/repo/xdebug.log
xdebug.log_level=7
xdebug.mode=develop,profile

图 11.2显示了从/repo/ch11/php8_xdebug.php调用的xdebug_info()命令的输出:

图 11.2 – xdebug_info()输出

图 11.2 – xdebug_info()输出

现在让我们来看看另一个从外部视角检查您的应用程序的工具。

使用 Apache JMeter

用于测试 Web 应用程序的一个非常有用的开源工具是Apache JMeter(jmeter.apache.org/)。它允许您开发一系列测试计划,模拟来自浏览器的请求。您可以模拟数百个用户请求,每个请求都有自己的 cookie 和会话。尽管主要设计用于 HTTP 或 HTTPS,但它还能够处理其他十几种协议。除了出色的图形用户界面外,它还有一个命令行模式,可以将 JMeter 纳入自动部署过程中。

安装非常简单,只需从jmeter.apache.org/download_jmeter.cgi下载一个文件。在运行 JMeter 之前,您必须安装Java 虚拟机JVM)。测试计划的执行超出了本书的范围,但文档非常详尽。另外,请记住,JMeter 设计为在客户端上运行,而不是在服务器上运行。因此,如果您希望在本书的 Docker 容器中测试网站,您需要在本地计算机上安装 Apache JMeter,然后构建一个指向 Docker 容器的测试计划。通常,PHP 8 容器的 IP 地址是172.16.0.88

图 11.3显示了在本地计算机上运行的 Apache JMeter 的开屏幕:

图 11.3 – Apache JMeter

图 11.3 – Apache JMeter

从这个屏幕上,您可以开发一个或多个测试计划,指示要访问的 URL,模拟GETPOST请求,设置用户数量等。

提示

如果您在尝试运行jmeter时遇到此错误:“无法加载库:/usr/lib/jvm/java-11-openjdk-amd64/lib/ libawt_xawt.so”,请尝试安装OpenJDK 8。然后,您可以使用前面部分提到的技术来在不同版本的 Java 之间切换。

现在让我们看看在 PHP 8 升级后可能出现的 Composer 问题。

处理 Composer 的问题

在迁移到 PHP 8 后,开发人员可能面临的一个常见问题是与第三方软件有关。在本节中,我们讨论了使用流行的Composer包管理器为 PHP 可能遇到的潜在问题。

您可能会遇到的第一个问题与 Composer 本身的版本有关。在 2020 年,Composer 2 版本发布了。然而,并非所有驻留在主要打包网站(packagist.org/)上的 30 万多个软件包都已更新到版本 2。因此,为了安装特定软件包,您可能需要在 Composer 2 和 Composer 1 之间切换。每个版本的最新发布都在这里:

另一个更严重的问题与您可能使用的各种 Composer 软件包的平台要求有关。每个软件包都有自己的composer.json文件,具有自己的要求。在许多情况下,软件包提供者可能会添加 PHP 版本要求。

问题在于,虽然大多数 Composer 软件包现在在 PHP 7 上运行,但要求是以一种排除 PHP 8 的方式指定的。在 PHP 8 更新后,当您使用 Composer 更新第三方软件包时,会出现错误并且更新失败。具有讽刺意味的是,大多数 PHP 7 软件包也可以在 PHP 8 上运行!

例如,我们安装了一个名为laminas-api-tools的 Composer 项目。在撰写本文时,尽管软件包本身已准备好用于 PHP 8,但其许多依赖软件包尚未准备好。在运行安装 API 工具的命令时,会遇到以下错误:

root@php8_tips_php8 [ /srv ]# 
composer create-project laminas-api-tools/api-tools-skeleton
Creating a "laminas-api-tools/api-tools-skeleton" project at "./api-tools-skeleton"
Installing laminas-api-tools/api-tools-skeleton (1.3.1p1)
  - Downloading laminas-api-tools/api-tools-skeleton (1.3.1p1)
  - Installing laminas-api-tools/api-tools-skeleton (1.3.1p1):
Extracting archiveCreated project in /srv/api-tools-skeleton
Loading composer repositories with package information
Updating dependencies
Your requirements could not be resolved to an installable set of packages.
  Problem 1
    - Root composer.json requires laminas/laminas-developer-tools dev-master, found laminas/laminas-developer-tools[dev-release-1.3, 0.0.1, 0.0.2, 1.0.0alpha1, ..., 1.3.x-dev, 2.0.0, ..., 2.2.x-dev] but it does not match the constraint.
  Problem 2
    - zendframework/zendframework 2.5.3 requires php ⁵.5     || ⁷.0 -> your php version (8.1.0-dev) does not satisfy     that requirement.

刚刚显示的输出的最后部分突出显示的核心问题是,其中一个依赖包需要 PHP ⁷.0。在 composer.json 文件中,这表示从 PHP 7.0 到 PHP 8.0 的一系列版本。在这个特定的例子中,使用的 Docker 容器运行的是 PHP 8.1,所以我们有问题。

幸运的是,在这种情况下,我们有信心,如果这个包在 PHP 8.0 中运行,它也应该在 PHP 8.1 中运行。因此,我们只需要添加 --ignore-platform-reqs 标志。当我们重新尝试安装时,如下输出所示,安装成功了:

root@php8_tips_php8 [ /srv ]# 
composer create-project --ignore-platform-reqs \
    laminas-api-tools/api-tools-skeleton
Creating a "laminas-api-tools/api-tools-skeleton" project at "./api-tools-skeleton"
Installing laminas-api-tools/api-tools-skeleton (1.6.0)
  - Downloading laminas-api-tools/api-tools-skeleton (1.6.0)
  - Installing laminas-api-tools/api-tools-skeleton (1.6.0):
Extracting archive
Created project in /srv/api-tools-skeleton
Installing dependencies from lock file (including require-dev)
Verifying lock file contents can be installed on current
platform.
Package operations: 109 installs, 0 updates, 0 removals
- Downloading laminas/laminas-zendframework-bridge (1.3.0)
- Downloading laminas-api-tools/api-tools-asset-manager
(1.4.0)
- Downloading squizlabs/php_codesniffer (3.6.0)
- Downloading dealerdirect/phpcodesniffer-composer-installer
(v0.7.1)
- Downloading laminas/laminas-component-installer (2.5.0)
... not all output is shown

刚刚显示的输出中,没有出现平台要求错误,我们可以继续使用应用程序。

现在让我们把注意力转向单元测试。

使用单元测试

使用 PHPUnit 进行单元测试是确保应用程序在添加新功能或进行 PHP 更新后能够运行的关键因素。大多数开发人员至少创建一组单元测试,以至少执行最低要求,以证明应用程序的预期性能。测试是一个类中的方法,该类扩展了 PHPUnit\Framework\TestCase。测试的核心是所谓的“断言”。

提示

本书不涵盖如何创建和运行测试的说明。但是,您可以在主要 PHPUnit 网站的出色文档中找到大量示例:phpunit.de/

在进行 PHP 迁移后,您可能会遇到的问题是 PHPUnit(phpunit.de/)本身可能会失败!原因是因为 PHPUnit 每年都会发布一个新版本,对应于当年的 PHP 版本。较旧的 PHPUnit 版本是基于官方支持的 PHP 版本。因此,您的应用程序当前安装的 PHPUnit 版本可能是不支持 PHP 8 的较旧版本。最简单的解决方案是使用 Composer 进行更新。

为了说明可能的问题,让我们假设应用程序的测试目录当前包括 PHP unit 5。如果我们在运行 PHP 7.1 的 Docker 容器中运行测试,一切都按预期工作。以下是输出:

root@php8_tips_php7 [ /repo/test/phpunit5 ]# php --version
PHP 7.1.33 (cli) (built: May 16 2020 12:47:37) (NTS)
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2018 Zend Technologies
    with Xdebug v2.9.1, Copyright (c) 2002-2020, by Derick
Rethans
root@php8_tips_php7 [ /repo/test/phpunit5 ]#
vendor/bin/phpunit 
PHPUnit 5.7.27 by Sebastian Bergmann and contributors.
........                                                          8 / 8 (100%)
Time: 27 ms, Memory: 4.00MB
OK (8 tests, 8 assertions)

然而,如果我们在运行 PHP 8 的 Docker 容器中运行相同的版本,结果会大不相同:

root@php8_tips_php8 [ /repo/test/phpunit5 ]# php --version
PHP 8.1.0-dev (cli) (built: Dec 24 2020 00:13:50) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.0-dev, Copyright (c) Zend Technologies
    with Zend OPcache v8.1.0-dev, Copyright (c), 
    by Zend Technologies
root@php8_tips_php8 [ /repo/test/phpunit5 ]#
vendor/bin/phpunit 
PHP Warning:  Private methods cannot be final as they are never overridden by other classes in /repo/test/phpunit5/vendor/ phpunit/phpunit/src/Util/Configuration.php on line 162
PHPUnit 5.7.27 by Sebastian Bergmann and contributors.
........                                                            8 / 8 (100%)
Time: 33 ms, Memory: 2.00MB
OK (8 tests, 8 assertions)

从输出中可以看出,PHPUnit 本身报告了一个错误。当然,简单的解决方案是,在 PHP 8 升级后,您还需要重新运行 Composer,并更新您的应用程序及其使用的所有第三方包。

这就结束了我们对测试和故障排除的讨论。您现在知道可以使用哪些额外工具来帮助您进行测试和故障排除。请注意,这绝不是所有测试和故障排除工具的全面列表。还有许多其他工具,有些是免费开源的,有些提供免费试用期,还有一些只能通过购买获得。

总结

在本章中,您了解到术语“环境”是指“服务器”,因为如今许多网站使用虚拟化服务。然后,您了解到在部署阶段使用了三种不同的环境:开发、暂存和生产。

介绍了一种自动化工具,能够扫描您的应用程序代码,以寻找潜在的代码错误。正如您在该部分学到的那样,一个扫描应用程序可能包括一个配置文件,用于处理已删除的功能、方法签名的更改、不再生成资源的函数,以及用于复杂用法检测的一组回调,一个扫描类,以及一个收集文件名的调用程序。

接下来,您将看到一个典型的十二步 PHP 8 迁移过程,确保在最终准备升级生产环境时成功的机会更大。每个步骤都旨在发现潜在的代码错误,并在出现问题时有备用程序。您还学会了如何在两个常见平台上安装 PHP 8,以及如何轻松地恢复到旧版本。最后,您了解了一些可以帮助测试和故障排除的免费开源工具。

总的来说,仔细阅读本章并学习示例后,您现在不仅可以使用现有的测试和故障排除工具,还可以想到如何开发自己的扫描工具,大大降低 PHP 8 迁移后潜在代码错误的风险。您现在也对 PHP 8 迁移涉及的内容有了很好的了解,并且可以进行更顺畅的过渡,而不必担心失败。您新的预期和解决迁移问题的能力将减轻您可能会遇到的任何焦虑。您还可以期待拥有快乐和满意的客户。

下一章将介绍 PHP 编程中的新潮流和令人兴奋的趋势,可以进一步提高性能。

第十二章:使用异步编程创建 PHP 8 应用程序

近年来,一项令人兴奋的新技术席卷了 PHP 社区:异步编程,也被称为 PHP async。异步编程模型解决了使用传统同步编程模式编写的应用程序代码中存在的问题:您的应用程序在提供结果之前被迫等待某些任务完成。服务器的中央处理单元(CPU)(或 CPU)在执行乏味的输入/输出(I/O)任务时处于空闲状态。PHP async 允许您的应用程序暂停阻塞的 I/O 任务直到以后。其净效果是性能大幅提升,以及处理更多用户请求的能力。

阅读本章并仔细研究示例后,您将能够开发 PHP 异步应用程序。此外,您将能够利用选定的 PHP 扩展和框架的异步能力。在完成本章的工作后,您将能够提高应用程序的性能,速度提高 5 倍甚至高达 40 倍!

本章涵盖的主题包括以下内容:

  • 了解 PHP 异步编程模型

  • 使用 Swoole 扩展

  • 在异步模式下使用选定的 PHP 框架

  • 学习 PHP 8.1 纤程

技术要求

本章提供的代码示例所需的最低硬件要求如下:

  • 基于 x86_64 的台式电脑或笔记本电脑

  • 1GB 的免费磁盘空间

  • 4GB 的随机存取存储器(RAM)

  • 500 千位每秒或更快的互联网连接

此外,您需要安装以下软件:

  • Docker

  • Docker Compose

有关 Docker 和 Docker Compose 安装的更多信息,请参阅《第一章》《引入新的 PHP 8 OOP 功能》中的《技术要求》部分,以及如何构建用于演示本书中所解释的代码的 Docker 容器。在本书中,我们将您为本书恢复样本代码的目录称为/repo

本章的源代码位于此处:github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices

我们现在可以开始讨论 PHP 异步。

了解 PHP 异步编程模型

在深入了解如何使用异步库开发 PHP 应用程序之前,重要的是退后一步,看看 PHP 异步编程模型。了解这一点与传统的同步编程模型之间的区别,将为您在开发 PHP 应用程序时利用高性能的新世界打开大门。让我们先看看同步编程模型,然后再深入了解异步。

开发同步编程代码

在传统的 PHP 编程中,代码是按线性方式执行的。一旦代码被编译成机器代码,CPU 就会按顺序逐行执行代码,直到代码结束。这对于 PHP 过程式编程来说是正确的。令一些人感到惊讶的是,这对于面向对象编程(OOP)也是正确的!无论您是否在代码中使用对象,OOP 代码都会被编译成第一字节代码,然后是机器代码,并以与过程式代码完全相同的同步方式进行处理。

使用 OPcache 和即时JIT)编译器对代码是否以同步方式运行没有影响。OPcache 和 JIT 编译器带来的唯一好处是能够比以前更快地运行同步代码。

重要提示

请不要认为使用同步编程模型编写代码有什么问题!这种方法不仅经过验证,而且非常成功。此外,许多辅助工具(如 PHPUnit、Xdebug、许多框架等)都支持同步代码。

然而,同步编程模型存在一个主要缺点。使用这种模型,CPU 必须不断等待某些任务完成,然后程序才能继续进行。在很大程度上,这些任务包括访问外部资源,例如进行数据库查询,写入日志文件或发送电子邮件。这些任务被称为阻塞操作(阻塞进度的操作)。

以下图表为您提供了一个应用程序流程的可视化表示,其中涉及写入日志文件和发送电子邮件通知的阻塞操作:

图 12.1 - 同步编程模型

图 12.1 - 同步编程模型

正如您从图 12.1中所看到的,当应用程序写入日志文件时,CPU 会暂停程序代码执行,直到操作系统OS)发出信号表示日志文件的写入操作已经完成。稍后,代码可能会发送电子邮件通知。同样,CPU 会暂停代码执行,直到电子邮件发送操作结束。尽管每个等待间隔本身可能微不足道,但当您将所有这些阻塞操作的等待间隔相加时,特别是如果涉及较长的循环时,性能就会开始下降。

一个解决方案是大量实施缓存解决方案。另一个解决方案,你可能已经猜到了,是使用异步编程模型编写应用程序。让我们现在来看看。

理解异步编程模型

异步操作的理念已经存在了相当长的时间。一个非常著名的例子是 Apache Web 服务器,以其多处理模块MPMs)而闻名。MaxRequestWorkers指令允许您指定 Web 服务器可以处理多少个同时请求(有关更多信息,请参见httpd.apache.org/docs/current/mod/mpm_common.html#maxrequestworkers)。

异步编程模型通常涉及设置管理节点,称为工作节点。这允许程序执行继续进行,而无需等待任何给定任务完成。性能的提升可能会相当显著,特别是在大量阻塞操作(例如,文件系统访问或数据库查询)发生的情况下。

以下图表可视化了使用异步编程模型完成写入日志文件和发送电子邮件的任务:

图 12.2 - 异步编程模型

图 12.2 - 异步编程模型

总等待时间减少了分配的工作节点数量的倍数。图 12.2中显示的程序流程将比图 12.1中显示的程序流程等待时间减少一半。随着分配给处理阻塞操作的工作节点数量的增加,整体性能会提高。

重要提示

异步编程模型*不应与并行编程混淆。在并行编程中,任务实际上是同时执行的,通常分配给不同的 CPU 或 CPU 核心。另一方面,异步编程是顺序操作,但允许顺序代码在等待阻塞操作的结果(例如文件系统请求或数据库查询)时继续执行。

现在您已经了解了 PHP 异步编程模型的工作原理,让我们来看看协程支持。

使用异步协程支持

协程类似于线程,但在用户空间中运行,而不是内核空间,因此不需要涉及操作系统。如果支持协程支持,协程支持组件会检测阻塞操作(例如读取或写入文件)并有效地暂停该操作,直到收到结果。这释放了 CPU 以继续执行其他任务,直到从阻塞过程中返回结果。这个过程在机器码级别运行,因此对我们来说是不可检测的,除了我们的代码运行得更快之外。

理论上,使用提供协程支持的扩展或框架可能会提高性能,即使您的代码是使用同步编程模型编写的。请注意,并非所有 PHP 异步框架或扩展都提供此支持,这可能会影响您选择未来开发中要使用的框架或扩展。

Swoole 扩展(www.swoole.co.uk/)提供协程支持。另一方面,ReactPHP(reactphp.org/)是最受欢迎的 PHP 异步框架之一,但如果不使用 Swoole 扩展(下文讨论)或 PHP fibers(在学习 PHP 8.1 fibers部分讨论),则不提供协程支持。然而,ReactPHP 如此受欢迎的原因之一是因为不需要 Swoole 扩展。如果您在无法控制 PHP 安装的托管环境中操作,您仍然可以使用 ReactPHP 并实现显著的性能提升,而无需触及 PHP 安装。

现在我们将注意力转向为异步模型编写代码。

创建 PHP 异步应用程序

现在是困难的部分!不幸的是,使用同步编程模型编写的应用程序无法充分利用异步模型所提供的优势。即使您使用提供协程支持的框架和/或扩展,除非重构代码以遵循异步编程模型,否则无法实现最大性能提升。

大多数 PHP 异步框架和扩展都提供多种方法来分离任务。以下是常用方法的简要总结。

事件循环

在某种意义上,事件循环是一段代码的重复块,它持续运行直到发生指定的事件。所有 PHP 异步扩展和框架都以某种形式提供此功能。以回调形式的监听器被添加到事件循环中。当事件触发时,将调用监听器的逻辑。

Swoole 事件循环利用 Linux epoll_waitlinux.die.net/man/2/epoll_wait)功能。由于基于硬件的事件通过伪文件句柄报告给 Linux,Swoole 事件循环允许开发人员基于实际文件的属性以及产生文件描述符FD)的任何硬件过程来启动和停止事件循环。

ReactPHP 框架提供了相同的功能,但默认情况下使用 PHP 的stream_select()函数,而不是操作系统的epoll_wait功能。这使得 ReactPHP 事件循环应用程序编程接口API)在服务器之间可移植,尽管反应时间会较慢。ReactPHP 还提供了基于ext-eventext-evext-uvext-libevent PHP 扩展定义事件循环的能力。利用这些扩展使 ReactPHP 能够访问硬件,就像 Swoole 一样。

Promises

Promise是一种软件构造,允许您推迟任务的处理直到以后。这个概念最初是作为CommonJS项目的一部分提出的(wiki.commonjs.org/wiki/Promises/A)。它被设计为同步和异步编程世界之间的桥梁。

在同步编程中,函数(或类方法)通常要么成功,要么失败。在 PHP 中,失败被处理为故意抛出的异常或致命错误。在异步模型中,promise被识别为三种状态:fulfilledfailedunfulfilled。因此,当创建一个promise实例时,您需要提供三个处理程序,根据它们表示的状态采取行动。

在 ReactPHP 中,当您创建一个React\Promise\Promise实例时,您需要提供一个解析器作为第一个构造函数参数。解析器本身需要三个标记为$resolve$reject$notify的回调函数。这三个对应于 promise 的三种可能状态:fulfilled,failed 或 unfulfilled。

许多异步框架为 PHP streams提供了包装器。PHP 流最常用于处理涉及文件系统的操作。文件访问是一个阻塞操作,会导致程序执行暂停,直到操作系统返回结果。

为了避免文件访问阻塞异步应用程序的进度,使用了一个streams组件。例如,ReactPHP 提供了React\Stream命名空间下实现ReadableStreamInterfaceWritableStreamInterface的类。这些类用作普通 PHP 流函数(如fopen()fread()fwrite(),以及file_get_contents()file_put_contents())的包装器。ReactPHP 类使用内存来避免阻塞,并推迟实际的读取或写入,从而允许异步活动继续进行。

定时器

定时器是可以在给定间隔后运行的单独任务。在这方面,定时器类似于 JavaScript 的setTimeout()函数。使用定时器安排的任务可以设置为仅运行一次,或者在指定间隔连续运行。

大多数 PHP 异步框架或扩展中的定时器实现通常避免使用 PHP 的pcntl_alarm()函数。后者允许开发人员在一定秒数后向进程发送SIGALRM信号。然而,pcntl_alarm()函数一次只允许设置一个,最低时间间隔以秒为单位。相比之下,PHP 异步框架和扩展允许您准确地设置多个定时器,精确到毫秒。PHP 异步定时器实现的另一个区别是它不依赖于declare(ticks=1)语句。

定时器有许多潜在的用途,例如,定时器可以检查包含Completely Automated Public Turing test to tell Computers and Humans ApartCAPTCHA)图像的目录,并删除旧图像。另一个潜在用途是定期刷新缓存。

通道

通道是在并发进程之间进行通信的一种方式。当前的通道实现是基于查尔斯·安东尼·霍尔爵士在 1978 年提出的代数模型。他的提议经过多年的完善,并演变成了他在 1985 年出版的书中描述的模型Communicating Sequential Processes。通道和通信顺序进程CSP)模型是许多当前流行语言的特性,如Go

与其他更复杂的方法相比,使用通道时 CSP 进程是匿名的,而通道是明确命名的。通道方法的另一个方面是,发送方在接收方准备好接收之前被阻止发送。这个简单的原则减轻了实现过多共享锁逻辑的负担。例如,在 Swoole 扩展中,通道被用来实现连接池或作为调度并发任务的一种方式。

现在你已经对 PHP 异步理论有了基本的了解,是时候将理论付诸实践了。我们首先来看一下如何使用 Swoole 扩展。

使用 Swoole 扩展

PHP Swoole 扩展首次在 PHP 扩展 C 库网站(pecl.php.net/)上于 2013 年 12 月发布。自那时起,它引起了相当大的关注。随着 PHP 8 中引入了 JIT 编译器,Swoole 扩展受到了相当多的关注,因为它快速、稳定,并且有望使 PHP 应用程序运行得更快。总下载量接近 600 万次,平均每月下载量约为 5 万次。

在这一部分,你将学习有关该扩展的信息,它是如何安装和使用的。让我们首先对该扩展进行概述。

检查 Swoole 扩展

因为该扩展是用 C 语言编写的,一旦编译、安装和启用,一组函数和类将被添加到你当前的 PHP 安装中。然而,该扩展利用了一些仅在 UNIX 衍生的操作系统中可用的低级特性。这意味着如果你运行的是 Windows 服务器,你唯一能够运行使用 Swoole 扩展的 PHP 异步应用的方法是安装Windows Services for LinuxWSL)或者在 Windows 服务器上设置你的应用程序在 Docker 容器中运行。

提示

如果你想在 Windows 服务器上尝试 PHP 异步,请考虑使用 ReactPHP(在使用 ReactPHP部分讨论),它不需要 Swoole 扩展所需的操作系统依赖项。

PHP 异步的一个重要优势是,初始的代码块会立即加载并保留在内存中,直到异步服务器实例停止。这是使用 Swoole 扩展时的情况。在你的代码中,你创建一个异步服务器实例,有效地将 PHP 转换为一个持续运行的守护程序,监听指定的端口。然而,这也是一个缺点,因为如果你对程序代码进行更改,这些更改在重新加载之前不会被异步服务器实例识别。

Swoole 扩展的一个伟大特性是它的协程支持。这意味着,我们不必对使用同步编程模型编写的应用程序进行重大改动。Swoole 将自动挑选出像文件系统访问和数据库查询这样的阻塞操作,并允许这些操作被暂停,而应用程序的其余部分继续进行。由于这种支持,你通常可以简单地使用 Swoole 运行同步应用程序,从而立即提高性能。

Swoole 扩展的另一个非常棒的功能是Swoole\Table。这个功能让您可以在内存中创建一个数据库表的等效内容,可以在多个进程之间共享。这样的构造有许多可能的用途,潜在的性能提升真是令人震惊。

Swoole 扩展具有监听用户数据报协议UDP)传输而不是传输控制协议TCP)的能力。这是一个非常有趣的可能性,因为 UDP 比 TCP 快得多。Swoole 还包括一个毫秒级准确的定时器实现,以及用于 MySQL、PostgreSQL、Redis 和 cURL 的异步客户端。Swoole 扩展还可以使用Golang风格的通道设置进程间通信IPC)。现在让我们来看一下安装 Swoole。

安装 Swoole 扩展

Swoole 扩展可以使用与安装 C 语言编写的任何 PHP 扩展相同的技术来安装。一种方法是简单地使用您的操作系统包管理器。例如,对于 Debian 或 Ubuntu Linux,可以使用apt(或其不太友好的表亲apt-get),对于 Red Hat、CentOS 或 Fedora,可以使用yumdnf。在使用操作系统包管理器时,Swoole 扩展以预编译的二进制形式提供。

然而,推荐的方法是使用pecl命令。如果您的安装中没有此命令,可以在 Ubuntu 或 Debian 操作系统上(以 root 用户身份登录)如下安装pecl命令:apt install php-pear。对于 Red Hat、CentOS 或 Fedora 安装,可以使用以下命令:yum install php-pear

在使用pecl安装 Swoole 扩展时,可以指定一些选项。这些选项在这里总结如下:

表 12.1 – Swoole 扩展 pecl 安装选项

表 12.1 – Swoole 扩展 pecl 安装选项

有关这些选项的更多信息以及安装过程的概述,请查看这里:

www.swoole.co.uk/docs/get-started/installation

现在让我们来看一个包括对套接字、JavaScript 对象表示法JSON)和 cURL 的 Swoole 支持的示例安装,如下所示:

  1. 我们需要做的第一件事是更新pecl通道。这是 PHP 扩展源代码仓库和函数的列表,就像aptyum包管理器使用的sources列表一样。以下是执行此操作的代码:
pecl channel-update pecl.php.net
  1. 接下来,我们指定安装命令并使用-D标志添加选项,如下所示:
pecl install -D \
    'enable-sockets="yes" \
     enable-openssl="no" \
     enable-http2="no" \
     enable-mysqlnd="no" \
     enable-swoole-json="yes" \
     enable-swoole-curl="yes"' \
     swoole
  1. 这开始了扩展安装过程。然后,您会看到各种 C 语言代码文件和头文件被下载,之后会使用本地 C 编译器来编译扩展。以下是编译过程的部分视图:
root@php8_tips_php8 [ / ]# pecl install swoole
downloading swoole-4.6.7.tgz ...
Starting to download swoole-4.6.7.tgz (1,649,407 bytes)
.....................................................................................................................................................................................................................................................................................................................................done: 1,649,407 bytes
364 source files, building
running: phpize
Configuring for:
PHP Api Version:         20200930
Zend Module Api No:      20200930
Zend Extension Api No:   420200930
building in /tmp/pear/temp/pear-build-defaultuserQakGt8/swoole-4.6.7
running: /tmp/pear/temp/swoole/configure --with-php-config=/usr/bin/php-config --enable-sockets=no --enable-openssl=no --enable-http2=no --enable-mysqlnd=yes --enable-swoole-json=yes --enable-swoole-curl=yes
...
Build process completed successfully
Installing '/usr/include/php/ext/swoole/config.h'
Installing '/usr/lib/php/extensions/no-debug-non-zts-20200930/swoole.so'
install ok: channel://pecl.php.net/swoole-4.6.7
configuration option "php_ini" is not set to php.ini location
You should add "extension=swoole.so" to php.ini
  1. 如果找不到 C 编译器,将会收到警告。此外,您可能需要为您的操作系统安装 PHP 开发库。如果是这种情况,警告消息会给出进一步的指导。

  2. 完成后,您需要启用扩展。这可以通过将extension=swoole添加到php.ini文件中来实现。如果您不确定其位置,可以使用php -i命令并查找php.ini文件的位置。以下是您可以从命令行发出的添加此指令的命令:

echo "extension=swoole" >>/etc/php.ini
  1. 然后,您可以使用以下命令来确认 Swoole 扩展的可用性:
php --ri swoole

这就完成了 Swoole 扩展的安装。如果您正在自定义编译 PHP,还可以在运行configure之前添加--enable-swoole选项。这将导致 Swoole 扩展与核心 PHP 安装一起被编译和启用(并允许您跳过刚刚概述的安装步骤)。现在我们将看一下从文档中摘取的一个简短的Hello World示例,以测试安装。

测试安装

Swoole 文档提供了一个简单的示例,您可以用来快速测试安装是否成功。示例代码显示在主 Swoole 文档页面上(www.swoole.co.uk/docs/)。由于版权原因,我们在这里不再重复。以下是运行Hello World测试的步骤:

  1. 首先,我们将Hello World示例从www.swoole.co.uk/docs/复制到/path/to/repo/ch12/php8_swoole_hello_world.php文件中。

接下来,我们修改了演示程序,并将$server = new Swoole\HTTP\Server("127.0.0.1", 9501);更改为$server = new Swoole\HTTP\Server("0.0.0.0", 9501);

这个变化允许 Swoole 服务器监听端口9501,对任何Internet Protocol (IP) 地址。

  1. 然后我们修改了/repo/ch12/docker-compose.yml文件,使端口9501在 Docker 容器外可用,如下所示:
version: "3"
services:
  ...
  php8-tips-php8:
    ...
    ports:
     - 8888:80
     - 9501:9501
    ...
  1. 为了使这个变化生效,我们不得不关闭并重新启动服务。从本地计算机的命令提示符/终端窗口上,使用以下两个命令:
/path/to/repo/init.sh down
/path/to/repo/init.sh up
  1. 请注意,如果您正在运行 Windows,请删除.sh

  2. 然后我们打开了 PHP 8 Docker 容器的 shell,并运行了Hello World程序,如下所示:

$ docker exec -it php8_tips_php8 /bin/bash
# cd /repo/ch12
# php php8_swoole_hello_world.php
  1. 最后,从 Docker 容器外部,我们打开了一个浏览器到这个 IP 地址和端口:http://172.16.0.88:9501

以下屏幕截图显示了 Swoole Hello World程序的结果:

图 12.3 - Swoole 演示 Hello World 程序输出

图 12.3 - Swoole 演示 Hello World 程序输出

在深入了解 Swoole 扩展如何提高应用程序性能之前,我们需要检查一个适合 PHP 异步模型的样本应用程序。

检查一个 I/O 密集型应用程序

为了说明,我们创建了一个样本应用程序,编写为在 PHP 8 中运行的REpresentational State Transfer (REST) API。样本应用程序提供了以下简单功能的聊天或即时消息 API:

  • 使用HyperText Transfer Protocol (HTTP) POST方法将消息发布到特定用户或所有用户。成功发布后,API 返回刚刚发布的消息。

  • 一个带有from=username参数的 HTTP GET方法返回该用户名的所有消息以及发送给所有用户的消息。如果设置了all=1参数,它将返回所有用户名的列表。

  • 一个 HTTP DELETE方法从messages表中删除所有消息。

本节仅显示了应用程序代码的部分内容。如果您对整个Chat应用程序感兴趣,源代码位于/path/to/repo/src/Chat下。主要的 API 端点在这里提供:http://172.16.0.81/ch12/php8_chat_ajax.php

接下来的示例是在 PHP 8.1 Docker 容器中执行的。请确保从 Windows 计算机的命令提示符中按以下方式关闭现有容器:C:\path\to\repo\init down。对于 Linux 或 Mac,从终端窗口:/path/to/repo/init.sh down。要从 Windows 计算机上启动 PHP 8.1 容器:C:\path\to\repo\ch12\init up。从 Linux 或 Mac 终端窗口:/path/to/repo/ch12/init.sh up

接下来的示例是在 PHP 8.1 Docker 容器中执行的。请确保从 Windows 计算机的命令提示符中按以下方式关闭现有容器:C:\path\to\repo\init down

对于 Linux 或 Mac,从终端窗口:

/path/to/repo/init.sh down

要从 Windows 计算机上启动 PHP 8.1 容器:

C:\path\to\repo\ch12\init up

从 Linux 或 Mac 终端窗口:

/path/to/repo/ch12/init.sh up

现在我们来看一下核心 API 程序的源代码,如下所示:

  1. 首先,我们定义了一个Chat\Message\Pipe类,标识了我们需要使用的所有外部类,如下所示:
// /repo/src/Chat/Messsage/Api.php;
namespace Chat\Message;
use Chat\Handler\ {GetHandler, PostHandler,
    NextHandler,GetAllNamesHandler,DeleteHandler};
use Chat\Middleware\ {Access,Validate,ValidatePost};
use Chat\Message\Render;
use Psr\Http\Message\ServerRequestInterface;
class Pipe {
  1. 然后我们定义一个exec()静态方法,调用一组符合PHP 标准建议 15PSR-15)的处理程序。我们还通过调用Chat\Middleware\Access中间件类的process方法来调用管道的第一阶段。NextHandler的返回值被忽略:
public static function exec(
    ServerRequestInterface $request) {
    $params   = $request->getQueryParams();
    $method   = strtolower($request->getMethod());
    $dontcare = (new Access())
        ->process($request, new NextHandler());
  1. 在同一个方法中,我们使用match()结构来检查 HTTP 的GETPOSTDELETE方法调用。如果方法是POST,我们使用Chat\Middleware\ValidatePost验证中间件类来验证POST参数。如果验证成功,经过清理的数据然后传递给Chat\Handler\PostHandler。如果 HTTP 方法是DELETE,我们直接调用Chat\Handler\DeleteHandler
    $response = match ($method) {
        'post' => (new ValidatePost())
            ->process($request, new PostHandler()),
        'delete' => (new DeleteHandler())
            ->handle($request),
  1. 如果 HTTP 方法是GET,我们首先检查all参数是否已设置。如果是,我们调用Chat\Handler\GetAllNamesHandler。否则,default子句通过Chat\MiddleWare\Validate传递数据。如果验证成功,经过清理的数据将传递给Chat\Handler\GetHandler
        'get'    => (!empty($params['all'])
        ? (new GetAllNamesHandler())->handle($request)
        : (new Validate())->process($request, 
                new GetHandler())),
        default => (new Validate())
            ->process($request, new GetHandler())};
        return Render::output($request, $response);
    }
}
  1. 然后可以使用一个简短的传统程序调用核心 API 类,如下所示。在这个调用程序中,我们使用Laminas\Diactoros\ServerRequestFactory构建一个符合 PSR-7 的Psr\Http\Message\ServerRequestInterface实例。然后将请求通过Pipe类,产生一个响应:
// /repo/ch12/php8_chat_ajax.php
include __DIR__ . '/vendor/autoload.php';
use Laminas\Diactoros\ServerRequestFactory;
use Chat\Message\Pipe;
$request  = ServerRequestFactory::fromGlobals();
$response = Pipe::exec($request);
echo $response;

我们还创建了一个测试程序(/repo/ch12/php8_chat_test.php—未显示),该程序调用 API 端点一定次数(默认为 100 次)。在每次迭代中,测试程序会发布一个随机消息,包括随机的接收者用户名、随机日期和来自/repo/sample_data/geonames.db数据库的顺序条目。测试程序需要两个参数。第一个参数是代表 API 的 URL。第二个(可选)参数代表迭代次数。

这里是从命令行运行/ch12/php8_chat_test.php在 PHP 8.1 Docker 容器中的示例结果:

root@php8_tips_php8_1 [ /repo/ch12 ]# php php8_chat_test.php \
     http://localhost/ch12/php8_chat_ajax.php 10000  bboyer :          Dubai:AE:2956587 : 2021-01-01 00:00:00
1 fcompton :       Sharjah:AE:1324473 : 2022-02-02 01:01:01
...
998 hrivas : Caloocan City:PH:1500000 : 2023-03-19 09:54:54
999  lpena :         Budta:PH:1273715 : 2021-04-20 10:55:55
From User: dwallace
Elapsed Time: 3.3177478313446

从输出中,注意经过的时间。在下一节中,使用 Swoole,我们能够将这个时间减少一半!然而,在使用 Swoole 之前,公平地加入 JIT 编译器是必要的。我们使用以下命令启用 JIT:

# php /repo/ch10/php8_jit_reset.php on 

在 PHP 8.0.0 中,可能会遇到一些错误,可能会出现分段错误。然而,在 PHP 8.1 中,启用 JIT 编译器后,API 应该可以正常工作。然而,JIT 编译器是否会提高性能是非常可疑的,因为频繁的 API 调用会导致应用程序等待。任何频繁阻塞 I/O 操作的应用程序都是异步编程模型的绝佳候选。然而,在我们继续之前,我们需要关闭 JIT,使用与之前相同的实用程序,如下所示:

# php /repo/ch10/php8_jit_reset.php off 

现在让我们看看 Swoole 扩展如何用于改进这个 I/O 密集型应用程序的性能。

使用 Swoole 扩展来提高应用程序性能

鉴于 Swoole 提供了协程支持,为了提高Chat应用程序的性能,我们实际上只需要重写/repo/ch12/php8_chat_ajax.php调用程序,将其转换为一个在端口9501上作为 Swoole 服务器实例监听的 API。以下是重写主 API 调用程序的步骤:

  1. 首先,我们启用自动加载并识别所需的外部类:
// /repo/ch12/php8_chat_swoole.php
include __DIR__ . '/vendor/autoload.php';
use Chat\Message\Pipe;
use Chat\Http\SwooleToPsr7;
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
  1. 接下来,我们启动一个 PHP 会话,并创建一个监听端口9501上任何 IP 地址的Swoole\HTTP\Server实例:
session_start();
$server = new Swoole\HTTP\Server('0.0.0.0', 9501);
  1. 然后我们调用on()方法并将其与start事件关联。在这种情况下,我们记录一个日志条目以标识 Swoole 服务器启动的时间。其他服务器事件在这里有文档记录:www.swoole.co.uk/docs/modules/swoole-http-server-doc
$server->on("start", function (Server $server) {
    error_log('Swoole http server is started at '
        . 'http://0.0.0.0:9501');
});
  1. 最后,我们定义了一个主服务器事件,$server->on('request', function () {}),用于处理传入的请求。以下是实现这一点的代码:
$server->on("request", function (
    Request $swoole_request, Response $swoole_response){
    $request  = SwooleToPsr7::
        swooleRequestToServerRequest($swoole_request);
    $swoole_response->header(
        "Content-Type", "text/plain");
    $response = Pipe::exec($request);
    $swoole_response->end($response);
}); 
$server->start();

不幸的是,传递给on()方法关联的回调的Swoole\Http\Request实例不符合 PSR-7!因此,我们需要定义一个Chat\Http\SwooleToPsr7类和一个swooleRequestToServerRequest()方法,使用静态调用执行转换。然后我们在Swoole\Http|Response实例上设置头,并从管道返回一个值以完成电路。

非常重要的一点是,标准的 PHP 超全局变量,如$_GET$_POST,无法从运行的 Swoole 服务器实例中按预期工作。入口点是您用于从命令行启动 Swoole 服务器的初始程序。唯一的传入请求参数是实际的初始程序文件名。任何后续输入必须通过传递给on()函数的Swoole\Http\Request实例来捕获。

php.net/swoole找到的文档并未显示Swoole\HTTP\RequestSwoole\HTTP\Response类中的所有可用方法。然而,在 Swoole 网站本身,您可以找到相关的文档,这里也列出了:

值得注意的是,Swoole\HTTP\Request对象属性大致对应于 PHP 超全局变量,如下所示:

表 12.2 – Swoole 请求映射到 PHP 超全局变量

表 12.2 – Swoole 请求映射到 PHP 超全局变量

另一个考虑因素是,在 Swoole 协程中使用 Xdebug 可能会导致分段错误和其他问题,甚至包括核心转储。最佳实践是在首次使用pecl安装 Swoole 时使用--enable-debug标志启用 Swoole 调试。为了测试应用程序,我们进行如下操作:

  1. 从命令行进入 PHP 8.1 Docker 容器,我们运行我们的Chat API 的 Swoole 版本,如下所示。立即显示的消息是由$server->on("start", function() {})产生的:
# cd /repo/ch12
# php php8_chat_swoole.php 
Swoole http server is started at http://0.0.0.0:9501
  1. 然后我们在主机计算机上打开另一个终端窗口,并在 PHP 8.1 Docker 容器中打开另一个 shell。从那里,我们可以运行/repo/ch12/php8_chat_test.php测试程序,如下所示:
# cd /repo/ch12
# php php8_chat_test.php http://localhost:9501 1000
  1. 注意两个额外的参数。第一个参数告诉测试程序使用 API 的 Swoole 版本,而不是使用 Apache Web 服务器的旧版本。最后的参数告诉测试程序运行 1,000 次迭代。

现在让我们来看一下输出,如下所示:

root@php8_tips_php8_1 [ /repo/ch12 ]# php php8_chat_test.php \
     http://localhost:9501 1000
0    coconnel :      Dubai:AE:2956587 :  2021-01-01 00:00:00
1      htyler :    Sharjah:AE:1324473 :  2022-02-02 01:01:01
...
998  cvalenci : Caloocan City:PH:1500 :  2023-03-19 09:54:54
999  smccormi :      Budta:PH:1273715 :  2021-04-20 10:55:55
From User: ajenkins
Elapsed Time: 1.8595671653748

输出的最显着特征是经过的时间。如果您回顾前一节,您会注意到作为传统 PHP 应用程序在 Apache 上运行的 API 大约需要 3.35 秒才能完成 1,000 次迭代,而在 Swoole 下运行的相同 API 大约需要 1.86 秒:几乎是一半的时间!

请注意,这是没有任何额外优化的情况。Swoole 还有许多其他功能可供我们使用,包括定义内存表、在额外的工作线程中生成任务以及使用事件循环来促进缓存,还有其他可能性。正如您所看到的,Swoole 立即提供了性能提升,并且非常值得调查,作为获得现有应用程序更多性能的可能途径。

现在您已经对 Swoole 如何用于提高应用程序性能有了一个概念,让我们来看看其他潜在的 PHP 异步解决方案。

在异步模式下使用选定的 PHP 框架

还有许多其他 PHP 框架实现了异步编程模型。在本节中,我们将介绍 ReactPHP,这是最流行的 PHP 异步框架,以及 Amp,另一个流行的 PHP 异步框架。此外,我们还会向您展示如何在异步模式下使用选定的 PHP 框架。

需要注意的是,许多能够在异步模式下运行的 PHP 框架都依赖于 Swoole 扩展。没有这种依赖性的是接下来要介绍的 ReactPHP。

使用 ReactPHP

ReactPHP (reactphp.org/) 是Reactor 软件设计模式的一种实现,受到了非阻塞异步Node.js框架(nodejs.org/en/)等的启发。

尽管 ReactPHP 不会像 Swoole 扩展那样自动提高性能,但它的一个重要优势在于它不依赖于 UNIX 或 Linux 的特性,因此可以在 Windows 服务器上运行。ReactPHP 的另一个优势是,它除了标准扩展之外,没有对 PHP 扩展的特定依赖性。

任何 ReactPHP 应用程序的核心都是React\EventLoop\Loop类。顾名思义,Loop实例启动后实际上是一个无限循环。大多数 PHP 无限循环都会给您的应用程序带来灾难!然而,在这种情况下,循环与一个服务器实例一起使用,该服务器实例不断监听给定端口上的请求。

ReactPHP 的另一个关键组件是React\Socket\Server。这个类在给定端口上打开一个套接字,使得 ReactPHP 应用程序可以直接监听 HTTP 请求,而无需涉及 Web 服务器。

ReactPHP 的其他特性包括监听 UDP 请求的能力,非阻塞缓存以及异步承诺的实现。ReactPHP 还具有一个Stream组件,允许您推迟文件系统的读写操作,大大提高性能,因为您的应用程序不再需要等待这样的文件 I/O 请求完成。

使用 ReactPHP 的另一个优势是它完全符合 PSR-7(HTTP 消息)。我们现在将查看之前描述的Chat API 的示例程序,使用 ReactPHP 进行重写。以下是重写程序的步骤:

  1. 从命令提示符进入 Docker PHP 8 容器,使用 Composer 安装必要的 ReactPHP 组件:
cd /repo/ch12
composer require --ignore-platform-reqs react/event-loop
composer require --ignore-platform-reqs react/http
composer require --ignore-platform-reqs react/socket
  1. 然后我们将/repo/ch12/php8_chat_swoole.php重写为/repo/ch12/php8_chat_react.php。我们需要更改的第一件事是use语句:
// /repo/ch12/php8_chat_react.php
include __DIR__ . '/vendor/autoload.php';
use Chat\Message\Pipe;
use React\EventLoop\Factory;
use React\Http\Server;
use React\Http\Message\Response as ReactResponse;
use Psr\Http\Message\ServerRequestInterface;
  1. 然后我们启动一个会话并创建一个React\EventLoop\Loop实例,如下所示:
session_start();
$loop = Factory::create();
  1. 我们现在定义一个处理程序,它接受一个 PSR-7 ServerRequestInterface实例作为参数,并返回一个React\Http\Message\Response实例:
$server = new Server($loop, 
function (ServerRequestInterface $request) {
    return new ReactResponse(200,
        ['Content-Type' => 'text/plain'],
        <8 SPACES>Pipe::exec($request)
    );
});
  1. 然后我们设置一个React\Socker\Server实例来监听端口9501并执行一个循环,就像这样:
$socket = new React\Socket\Server(9501, $loop);
$server->listen($socket);
echo "Server running at http://locahost:9501\n";
$loop->run();

然后我们在 PHP 8.1 容器中打开一个单独的命令行,并启动 ReactPHP 服务器,如下所示:

root@php8_tips_php8_1 [ /repo/ch12 ]# php php8_chat_react.php

从另一个命令行进入 PHP 8.1 容器,然后可以运行测试程序如下:root@php8_tips_php8_1 [ /repo/ch12 ]# php php8_chat_test.php \

http://localhost:9501

输出(未显示)与使用 Swoole 扩展时所见的类似。

接下来,我们来看另一个流行的 PHP 异步框架:Amp。

使用 Amp 实现 PHP 异步

Amp 框架https://amphp.org/),类似于 ReactPHP,提供了定时器、promise 和流的实现。Amp 还提供了协程支持,以及一个异步迭代器组件。后者非常有趣,因为迭代对大多数 PHP 应用程序至关重要。如果可以将迭代移入异步处理模式,虽然可能需要进行大量重构,但可能会极大地提高应用程序的性能。另一个有趣的变化是 Amp 可以直接使用任何 ReactPHP 组件!

要安装 Amp,请使用 Composer。各种 Amp 组件都在单独的存储库中可用,因此您不必安装整个框架,只需安装您需要的部分。PHP Amp 服务器的实际实现非常类似于 ReactPHP 中显示的示例。

现在让我们看看另一个可以在 PHP 异步模式下运行的框架:Mezzio,以前称为Zend Expressive

使用 Mezzio 与 Swoole

Mezzio框架(docs.mezzio.dev/)是 Matthew Weier O'Phinney(mwop.net/)的心血结晶,代表了一个较旧的框架Zend Framework和一个较新的框架Zend Expressive的延续。Mezzio 属于相对较新的微框架类别。微框架不依赖于老化的模型-视图-控制器MVC)软件设计模式,主要面向RESTful API 开发。在实际应用中,微框架支持 PHP 中间件的原则,并且具有较少的开销和相应的更快速度。

要使用 Swoole 的 Mezzio 应用程序,只需要以下三件事:

  1. 安装 Swoole 扩展(在本章前面描述)。

  2. 安装mezzio-swoole组件,就像这样:

composer require mezzio/mezzio-swoole
  1. 然后需要使用 Swoole 服务器实例运行 Mezzio。可以使用以下命令完成此操作:
/path/to/project/vendor/bin/laminas mezzio:swoole:start
  1. 在 Mezzio 应用程序的配置文件中,您需要添加以下密钥:
return [
    'mezzio-swoole' => [
        'swoole-http-server' => [
            'host' => '0.0.0.0',    // all IP addresses
            'port' => 9501,
        ]
    ],
];

为了进一步提高性能,当然还应该重写代码的适当部分,以利用 PHP 异步功能。接下来,我们来看一下超越异步的 PHP 扩展。

使用并行扩展

parallel扩展(www.php.net/parallel)是为了与 PHP 7.2 及以上版本一起使用而引入的。它的目的是在 PHP 异步之外进入全面并行处理的世界。parallel扩展提供了五个关键的低级类,可以构成并行处理应用程序的基础。使用此扩展允许 PHP 开发人员编写类似于Go语言的并行代码。让我们从parallel\Runtime开始。

parallel\Runtime 类

每个parallel\Runtime实例都会生成一个新的 PHP 线程。然后可以使用parallel\Runtime::run()来安排任务。run()的第一个参数是Closure(匿名函数)。可选的第二个参数是$argv,表示在运行时传递给任务的输入参数。parallel\Runtime::close()用于优雅地关闭线程。当出现错误条件时,可以使用parallel\Runtime::kill()立即退出线程。

parallel\Future 类

parallel\Future实例是从parallel\Runtime::run()的返回值创建的。它的作用很像 PHP 异步promise(在本章前面描述)。该类有三种方法,列在这里,执行以下操作:

  • parallel\Future::value()

返回任务的完成值

  • parallel\Future::cancel()

取消代表失败状态的任务

  • parallel\Future::cancelled()|done()

如果任务状态仍未实现,则返回任务状态

parallel\Channel 类

parallel\Channel类允许开发人员在任务之间共享信息。使用__construct()方法或make()来创建一个通道。如果__construct()没有提供参数,或者make()的第二个参数没有提供,通道被认为是无缓冲的。如果向__construct()提供一个整数,或者向make()的第二个参数提供一个整数,该值表示通道的容量。然后,您可以使用parallel\Channel::send()parallel\Channel::recv()方法通过通道发送和接收数据。

无缓冲通道会阻塞对send()的调用,直到有接收者,反之亦然。另一方面,缓冲通道在达到容量之前不会阻塞。

并行事件类

parallel\Events类类似于本章第一节中描述的事件循环。该类具有addChannel()addFuture()方法,用于添加要监视的通道和/或未来实例。setBlocking()方法允许事件循环以阻塞或非阻塞模式监视事件。使用setTimeout()方法设置整体控制周期(以毫秒为单位),指定循环允许继续的时间。最后,poll()方法导致事件循环轮询下一个事件。

安装并行扩展

parallel扩展可以使用pecl命令或使用预编译的二进制文件进行安装,就像安装任何其他非标准的 PHP 扩展一样。然而,非常重要的是,这个扩展只能在Zend 线程安全ZTS)的 PHP 安装上工作。因此,如果使用 Docker,您需要获取一个 PHP ZTS 镜像,或者如果自定义编译 PHP,您需要使用--enable-zts(Windows)或--enable-maintainer-zts(非 Windows)configure实用程序标志。

现在您已经了解了如何在异步模式下使用多个选择的 PHP 扩展和框架,我们将展望未来,并讨论 PHP 8.1 纤程。

了解 PHP 8.1 纤程

请求评论RFC)于 2021 年 3 月由 PHP 核心团队开发人员 Aaron Piotrowski 和 Niklas Keller 发布,概述了在 PHP 语言核心中包含对纤程支持的情况。该 RFC 在月底得到批准,并已在即将推出的 PHP 8.1 版本中实施。

纤程实现是低级的,这意味着它主要设计用作 PHP 异步框架(如 ReactPHP 或 Amp)或扩展(如 Swoole 扩展)的一部分。因为从 PHP 8.1 开始,这将成为语言的核心部分,开发人员不必太担心加载哪些扩展。此外,这极大地增强了 PHP 异步框架,因为它们现在在语言核心中直接获得低级支持,大大提高了性能。现在让我们来看一下Fiber类本身。

发现 Fiber 类

PHP 8.1 的Fiber类提供了一个基本的实现,异步框架和扩展开发人员可以在此基础上构建定时器、事件循环、承诺和其他异步工件。

以下是正式的类定义:

final class Fiber {
    public function __construct(callable $callback) {}
    public function start(mixed ...$args): mixed {}
    public function resume(mixed $value = null): mixed {}
    public function throw(Throwable $exception): mixed {}
    public function isStarted(): bool {}
    public function isSuspended(): bool {}
    public function isRunning(): bool {}
    public function isTerminated(): bool {}
    public function getReturn(): mixed {}
    public static function this(): ?self {}
    public static function suspend(
        mixed $value = null): mixed {}
}

以下是Fiber类方法的摘要:

表 12.3 - Fiber 类方法摘要

表 12.3 - Fiber 类方法摘要

如您从表 12.3中所见,创建Fiber实例后,使用start()来运行与纤程关联的回调。之后,您可以自由地挂起、恢复或导致纤程失败,使用throw()。您也可以让回调在自己的纤程中运行,并使用getReturn()来检索返回的信息。您还可以注意到,is*()方法可以用来确定纤程在任何给定时刻的状态。

提示

有关 PHP 8.1 纤程实现的更多信息,请参阅以下 RFC:wiki.php.net/rfc/fibers

现在让我们看一个示例,说明如何使用纤程。

使用纤程

PHP 8.1 fibers 构成了 PHP 异步应用程序的基础。尽管 fibers 的主要受众是框架和扩展开发人员,但任何 PHP 开发人员都可以从这个类中受益。为了说明 PHP fibers 可以解决的问题,让我们看一个简单的例子。

定义一个执行阻塞操作的示例程序

在这个示例中,使用同步编程模型,我们执行三个操作,如下:

  • 执行 HTTP GET 请求。

  • 执行数据库查询。

  • 将信息写入访问日志。

已经具有一些异步编程知识的你意识到,所有三个任务都代表着 阻塞 操作。我们将采取以下步骤:

  1. 首先,我们定义一个要包含的 PHP 文件,其中定义了回调:
// /repo/ch12/php8_fibers_include.php
define('WAR_AND_PEACE',
    'https://www.gutenberg.org/files/2600/2600-0.txt');
define('DB_FILE', __DIR__ 
    . '/../sample_data/geonames.db');
define('ACCESS_LOG', __DIR__ . '/access.log');
$callbacks = [
    'read_url' => function (string $url) {
        return file_get_contents($url); },
    'db_query' => function (string $iso2) {
        $pdo = new PDO('sqlite:' . DB_FILE);
        $sql = 'SELECT * FROM geonames '
             . 'WHERE country_code = ?'
        $stmt = $pdo->prepare($sql);
        $stmt->execute([$iso2]);
        return var_export(
            $stmt->fetchAll(PDO::FETCH_ASSOC), TRUE);
        },
    'access_log' => function (string $info) {
        $info = date('Y-m-d H:i:s') . ": $info\n";
        return file_put_contents(
            ACCESS_LOG, $info, FILE_APPEND);
        },
];
return $callbacks;
  1. 接下来,我们定义一个包含回调定义并按顺序执行它们的 PHP 程序。我们使用 PHP 8 的 match {} 结构来为适当的回调分配不同的参数。最后,我们通过简单返回一个字符串并运行 strlen() 来返回回调生成的字节数:
// /repo/ch12/php8_fibers_blocked.php
$start = microtime(TRUE);
$callbacks = include __DIR__ . '/php8_fibers_include.php';
foreach ($callbacks as $key => $exec) {
    $info = match ($key) {
        'read_url' => WAR_AND_PEACE,
        'db_query' => 'IN',
        'access_log' => __FILE__,
        default => ''
    };
    $result = $exec($info);
    echo "Executing $key" . strlen($result) . "\n";
}
echo "Elapsed Time:" . (microtime(TRUE) - $start) . "\n";

如果我们按原样运行程序,结果可预见地糟糕,就像我们在这里看到的一样:

root@php8_tips_php8_1 [ /repo/ch12 ]# 
php php8_fibers_blocked.php 
Executing read_url:     3359408
Executing db_query:     23194
Executing access_log:     2
Elapsed Time:6.0914640426636

统一资源定位符URL)请求下载托尔斯泰的《战争与和平》花费了最长的时间,产生了超过 300 万字节的字节数。总共经过的时间略超过 6 秒。

现在让我们看看如何使用 fibers 重写调用程序。

使用 fibers 的示例程序

从 PHP 8.1 Docker 容器中,我们可以定义一个使用 fibers 的调用程序。以下是这样做的步骤:

  1. 首先,我们像之前一样包含回调,像这样:
// /repo/ch12/php8_fibers_unblocked.php
$start = microtime(TRUE);
$callbacks = include __DIR__ 
    . '/php8_fibers_include.php';
  1. 接下来,我们创建一个 Fiber 实例来包装每个回调。然后使用 start() 启动回调,提供适当的信息:
$fibers = [];
foreach ($callbacks as $key => $exec) {
    $info = match ($key) {
        'read_url' => WAR_AND_PEACE,
        'db_query' => 'IN',
        'access_log' => __FILE__,
        default => ''
    };
    $fibers[$key] = new Fiber($exec);
    $fibers[$key]->start($info);
}
  1. 然后我们设置一个循环,并检查每个回调是否已经完成。如果是,我们从 getReturn() 中输出结果并取消 fiber:
$count  = count($fibers);
$names  = array_keys($fibers);
while ($count) {
    $count = 0;
    foreach ($names as $name) {
        if ($fibers[$name]->isTerminated()) {
           $result = $fibers[$name]->getReturn();
            echo "Executing $name: \t" 
                . strlen($result) . "\n";
            unset($names[$name]);
        } else {
            $count++;
        }
    }
}
echo "Elapsed Time:" . (microtime(TRUE) - $start) . "\n";

请注意,此示例仅用于说明。更有可能的是,您会使用现有的框架,如 ReactPHP 或 Amp,它们都已重写以利用 PHP 8.1 fibers。还要注意,即使多个 fibers 同时运行,您可以实现的最短运行时间也与最长运行任务所花费的时间成正比。现在让我们看看 fibers 对 ReactPHP 和 Swoole 的影响。

检查 fibers 对 ReactPHP 和 Swoole 的影响

为了说明,您需要在 PHP 8.1 Docker 容器中打开两个单独的命令 shell。按照上一节中给出的说明操作,但打开两个命令 shell 而不是一个。然后我们将使用 /repo/ch12/php8_chat_test.php 程序来测试 fibers 的影响。让我们运行第一个测试,使用内置的 PHP web 服务器作为对照。

使用内置的 PHP web 服务器进行测试

在第一个测试中,我们使用内置的 PHP web 服务器和传统的 /repo/ch12/php8_chat_ajax.php 实现。我们将采取以下步骤:

  1. 在两个命令 shell 中,切换到 /repo/ch12 目录,像这样:
# cd /repo/ch12
  1. 在第一个命令 shell 中,使用内置的 PHP web 服务器运行标准的 HTTP 服务器,使用以下命令:
# php -S localhost:9501 php8_chat_ajax.php
  1. 在第二个命令 shell 中,执行测试程序,如下所示:
php php8_chat_test.php http://localhost:9501 1000 --no

结果输出应该是这样的:

root@php8_tips_php8_1 [ /repo/ch12 ]# 
php php8_chat_test.php http://localhost:9501 1000 --no
From User: pduarte
Elapsed Time: 1.687940120697

如您所见,使用同步编程编写的传统代码,在 1000 次迭代中大约需要 1.7 秒。现在让我们看看使用 ReactPHP 运行相同测试的情况。

使用 ReactPHP 进行测试

在第二个测试中,我们使用我们的 /repo/ch12/php8_chat_react.php ReactPHP 实现。我们将采取以下步骤:

  1. 在第一个命令 shell 中,按下 Ctrl + C 退出内置的 PHP web 服务器。

  2. 使用 exit 退出第一个命令 shell,然后使用 init shell(Windows)或 ./init.sh shell(Linux 或 Mac)重新进入。

  3. 使用以下命令启动 ReactPHP 服务器:

# php php8_chat_react.php
  1. 在第二个命令行窗口中,执行测试程序,就像这样:
php php8_chat_test.php http://localhost:9501 1000 --no

输出应如下所示:

root@php8_tips_php8_1 [ /repo/ch12 ]# 
php php8_chat_test.php http://localhost:9501 1000 --no
From User: klang
Elapsed Time: 1.2330160140991

从输出中,您可以看到 ReactPHP 从 fibers 中受益匪浅。1000 次迭代的总耗时令人印象深刻,为 1.2 秒!

这结束了我们对 PHP 8.1 fibers 的讨论。您现在知道了 fibers 是什么,以及它们如何直接在您的程序代码中使用,以及它们如何使外部 PHP 异步框架受益。

总结

在本章中,您学会了传统同步编程和异步编程之间的区别。涵盖了事件循环、定时器、承诺和通道等关键术语。这些知识使您能够确定何时使用异步编程模型编写代码块,以及如何重写现有同步模型应用程序的部分以利用异步特性。

然后,您了解了 Swoole 扩展以及如何将其应用于现有应用程序代码以实现性能改进。您还了解了一些其他以异步方式运行的框架和扩展。您回顾了具体的代码示例,现在可以开始编写异步代码了。

在最后一节中,您了解了 PHP 8.1 fibers。然后,您回顾了一个代码示例,向您展示了如何使用 PHP 8.1 fibers 创建协作多任务函数和类方法。您还看到了选择的 PHP 异步框架如何能够从 PHP 8.1 fibers 支持中受益,提供了更多的性能改进。

这是本书的最后一章。我们希望您喜欢回顾 PHP 8 中提供的各种新功能和好处。您现在对面向对象和过程式代码中要避免的潜在陷阱有了更深入的了解,以及对 PHP 8 扩展的各种更改。有了这些知识,您不仅能够写出更好的代码,而且还有一个坚实的行动计划,最大程度地减少了 PHP 8 迁移后应用代码失败的可能性。

posted @ 2024-05-05 00:11  绝不原创的飞龙  阅读(26)  评论(0编辑  收藏  举报