避免更新的 mbstring 扩展出现问题

mbstring 扩展在 PHP 4 中首次引入,此后一直是该语言的一个活跃部分。该扩展的最初目的是为各种日文字符编码系统提供支持。从那时起,又增加了对其他各种编码的支持,其中最引人注目的是对基于通用编码字符集 2 (UTC-2)、UTC-4、Unicode 转换格式 8 (UTF-8)、UTF-16、UTF-32、Shift Japanese Industrial Standards (SJIS) 和国际标准化组织 8859 (ISO-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*() 函数中的 need-argument 差异

第 6 章 了解 PHP 8 的功能差异 中,您了解了 PHP 8 如何在核心 str*pos()str*str()str*chr() 函数中引入了对针状参数处理的更改。针参数的两个主要区别是可以接受空针参数和严格的类型检查,以确保针参数仅为字符串。为了保持一致性,PHP 8 在相应的 mb_str*() 函数中引入了相同的变化。

让我们先看看空 needle-argument 的处理。

mb_str*() 函数空 needle-argument 处理

为了使 mbstring 扩展与核心字符串函数的变化保持一致,以下 mbstring 扩展函数现在允许使用空针参数。需要注意的是,这并不意味着该参数可以省略或可有可无!这一变化的意思是,作为针形参数提供的任何值现在也可以包括被认为是空的值。了解 PHP 认为什么是空的一个快速的好方法可以在 empty() 函数的文档中找到( https://www.php.net/empty )。以下是允许空针参数值的 mbstring 函数列表:

  • mb_strpos()

  • mb_strrpos()

  • mb_stripos()

  • mb_strripos()

  • mb_strstr()

  • mb_stristr()

  • mb_strrchr()

  • mb_strrichr()

这里提到的 8 个 mbstring 扩展函数中的每一个都与 PHP 的核心对应函数完全相似。有关这些函数的更多信息,请参阅参考文档: https://www.php.net/manual/en/ref.mbstring.php

下面的简短代码示例说明了上述八个函数中的空针处理。下面是实现这一功能的步骤:

  1. 首先,我们初始化一个多字节文本字符串。在下面的示例中,这是泰语翻译的 The quick brown fox jumped over the fence。将针参数设置为 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'];
  2. 然后,我们定义一个 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 :

可以看到,输出是空白的,在某些情况下还会发出警告信息。在 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 错误。本小节没有示例,因为 第 6 章 了解 PHP 8 的功能差异 已经提供了核心 str*() 函数中这种差异的示例。

mb_strrpos() 中的差异

在早期的 PHP 版本中,允许将字符编码作为第三个参数传递给 mb_strrpos(),而不是偏移量。PHP 8 不再支持这种糟糕的做法,取而代之的是提供 0 作为第三个参数,或者考虑使用 PHP 8 命名参数(在第 1 章 "PHP 8 OOP 新特性介绍" 中的 "理解命名参数" 一节中讨论),以避免提供一个值作为可选参数。

现在我们来看一个代码示例,它演示了 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);
  2. 然后,我们将 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 函数时,如果在 regex 模式中添加 u(小写字母 U)修饰符,则可以接受任何 UTF-8 编码的多字节字符串。不过,UTF-8 是唯一可接受的多字节字符编码。如果要处理其他字符编码并执行 regex 功能,则需要转换为 UTF-8 或使用 mb_ereg*() 系列函数。现在让我们来看看 mb_ereg*() 系列函数的一些变化。

PHP 8 需要 Oniguruma 库

这一系列函数的一个变化在于 PHP 安装的编译方式。在 PHP 8 中,操作系统必须提供 libonig 库。该库提供了 Oniguruma 功能。(更多信息请参见 https://github.com/kkos/oniguruma 。)旧的 --with-onig PHP source-compile-configure 选项已被删除,转而使用 pkg-config 来检测 libonig

对 mb_ereg_replace() 的修改

以前,你可以提供一个整数作为参数给 mb_ereg_replace()。该参数被解释为 ASCII 码。在 PHP 8 中,这种参数现在被类型转换为字符串。如果需要 ASCII 码,则需要使用 mb_chr()。由于将参数类型转换为字符串是静默进行的,因此可能会出现向后兼容的代码中断,即看不到任何 "注意"(Notice)或 "警告"(Warning)信息。

下面的程序代码示例说明了 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);
  2. 然后,我们调用 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 ตัวกระโดดข้�มรั้ว

这里的危险在于,如果你的代码依赖于这种无声的解释过程,你的应用程序可能会失败或表现出不一致的行为。您还会注意到缺少 "通知" 或 "警告" 信息。PHP 8 依赖于开发人员提供正确的参数!

如果确实需要使用 ASCII 码,最好的做法是使用 mb_chr() 来生成所需的搜索字符串。修改后的代码示例如下:

$mod1 = mb_ereg_replace(mb_chr(50), '3', $str);

现在,你已经知道 mbstring 扩展中发生了哪些变化。如果没有这些信息,你很容易就会写出错误的代码。不了解这些信息的开发人员最终可能会在 PHP 8 中犯错误,比如认为 mbstring 别名仍然有效。在 PHP 8 迁移之后,这种错误的理解很容易导致花费大量时间来追踪程序代码中的错误。

现在我们来看看另一个有重大变化的扩展:GD 扩展。