了解如何在迁移前发现 BC 中断
理想情况下,您在进行 PHP 8 迁移时应该有一个行动计划。该行动计划的关键部分包括了解当前代码库中存在多少潜在的 BC 中断。在本节中,我们将向您展示如何开发一个 BC 中断嗅探器,它可以自动检查数百个代码文件,查找潜在的 BC 中断。
首先,我们将回过头来回顾一下迄今为止所学到的有关 PHP 8 中可能出现的 BC 问题的知识。
获得 BC 休息的概述
在阅读过本书前面几章之后,你已经知道潜在的代码故障来自多个方面。让我们简要总结一下迁移后可能导致代码故障的一般趋势。请注意,我们在本章中不涉及这些主题,因为这些主题在本书前面的章节中都已涉及:
-
资源到对象的迁移
-
支持操作系统库的最低版本
-
Iterator 向 IteratorAggregate 迁移
-
删除的函数
-
使用更改
-
强制执行魔法方法签名
创建 BC 中断扫描配置文件
只要在 preg_match()
或 strpos()
的基础上添加一个简单的回调,就能检测到许多变化。使用方式的变化则更难检测,因为如果不大量使用 eval()
,自动断点扫描程序根本无法检测到使用方式的变化。
现在让我们来看看断点扫描配置文件是如何显示的。
创建 BC 中断扫描配置文件
通过配置文件,我们可以开发一套独立于 BC 断点扫描器类的搜索模式。使用这种方法,BC 断点扫描类定义了用于进行搜索的实际逻辑,而配置文件则提供了特定条件列表、警告和建议的补救措施。
只需查找是否存在 PHP 8 中已删除的函数,就能检测出许多潜在的代码错误。为此,简单的 strpos()
搜索就足够了。另一方面,更复杂的搜索可能需要我们开发一系列回调。让我们先看看如何在简单的 strpos()
搜索基础上开发配置。
定义一个简单的 strpos() 搜索配置
在简单的 strpos()
搜索中,我们只需提供一个键/值对数组,其中键是被移除函数的名称,值是建议替换的函数。BC break 扫描器类中的搜索逻辑就可以做到这一点:
$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 中断
在 第 7 章 "使用 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()
搜索是不够的。
解决复杂的断裂检测问题
如果简单的 strpos()
搜索不足以解决问题,我们可以开发另一组键/值对,其中的值是一个回调。例如,如果一个类定义了一个 __destruct()
方法,但同时又在 __construct()
方法中使用了 die()
或 exit()
,就有可能出现 BC 中断。在 PHP 8 中,__destruct()
方法在这种情况下可能不会被调用。
在这种情况下,简单的 strpos()
搜索是不够的。相反,我们必须开发能完成以下工作的逻辑:
-
检查是否定义了
__destruct()
方法。如果是,则无需继续,因为在 PHP 8 中没有中断的危险。 -
检查
__construct()
方法中是否使用了die()
或exit()
。如果是,则发出 BC 可能中断的警告。
在我们的 BC break 扫描配置数组中,回调采用匿名函数的形式。它接受文件内容作为参数。然后,我们将回调函数分配给一个数组配置键,并在回调函数返回 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']($contents)) echo $value['msg'];
如果检测其他潜在 BC 中断的要求超出了回调的能力范围,我们可以直接在 BC 中断扫描类中定义一个单独的方法。
正如你所看到的,我们可以开发一个配置数组,它不仅支持简单的 strpos()
搜索,还可以使用回调数组进行更复杂的搜索。
现在你已经了解了配置数组的内容,是时候定义执行断点扫描的主类了。
开发 BC 中断扫描类
BreakScan
类面向单个文件。在该类中,我们定义了利用刚才介绍的各种断点扫描配置的方法。如果我们需要扫描多个文件,调用程序会生成一个文件列表,并一次一个地将它们传递给 BreakScan
。
BreakScan
类可分为两大部分:定义基础架构的方法和定义如何进行扫描的方法。后者主要由配置文件的结构决定。对于每个配置文件部分,我们都需要一个 BreakScan
类方法。
让我们先看看基础结构方法。
定义 BreakScan 类基础结构方法
在本节中,我们将了解 BreakScan
类的初始部分。我们还将介绍执行基础架构相关活动的方法:
-
首先,我们建立类基础架构,将其放置在 /repo/src/Php8/Migration 目录中:
// /repo/src/Php8/Migration/BreakScan.php declare(strict_types=1); namespace Php8\Migration; use InvalidArgumentException; use UnexpectedValueException; class BreakScan {
-
接下来,我们定义了一组类常量,用于呈现显示任何特定扫描后故障性质的信息:
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
-
我们还定义了一组常量来表示配置数组的键值。这样做是为了保持配置文件和调用程序(稍后讨论)中键定义的一致性:
const KEY_REMOVED = 'removed'; const KEY_CALLBACK = 'callbacks'; const KEY_MAGIC = 'magic'; const KEY_RESOURCE = 'resource';
-
然后,我们对关键属性进行初始化,这些属性代表了配置、要扫描的文件内容以及任何信息:
public $config = []; public $contents = ''; public $messages = [];
-
__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); } } }
-
然后,我们定义一个方法,读入要扫描的文件内容。请注意,我们去掉了回车符("\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; }
-
有些回调只需要提取类名或命名空间。为此,我们定义了静态
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',';')
。 -
我们还需要一种方法来检索和重置
$this->messages
。下面是实现这一目的的两种方法:public function clearMessages() : void { $this->messages = []; } public function getMessages(bool $clear = FALSE) { $messages = $this->messages; if ($clear) $this->clearMessages(); return $messages; }
-
然后,我们定义了一种运行所有扫描的方法(将在下一节中介绍)。该方法还会收集检测到的潜在 BC 断点的数量,并报告总数:
public function runAllScans() : int { $found = 0; $found += $this->scanRemovedFunctions(); $found += $this->scanIsResource(); $found += $this->scanMagicSignatures(); $found += $this->scanFromCallbacks(); return $found; }
在了解了基本的 BreakScan
类基础结构之后,让我们来看看各个扫描方法。
检查个别扫描方法
四个单独的扫描方法直接对应于断点扫描配置文件中的顶级键。每种方法都会在 $this->messages
中累积有关潜在 BC 中断的信息。此外,每个方法都会返回一个整数,代表检测到的潜在 BC 中断的总数。
现在让我们依次检查这些方法:
-
我们检查的第一个方法是
scanRemovedFunctions()
。在该方法中,我们会搜索函数名称,后跟一个开放括号"(",或一个空格和开放括号"("。如果找到该函数,我们会递增$found
,并在$this->
消息中添加相应的警告和建议替换。如果没有发现潜在的中断,我们会添加一条成功信息并返回 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()
时触发断点扫描器的警告! -
接下来,我们来看看扫描
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; }
-
然后,我们来看看在回调列表中需要做些什么。正如你所记得的,在简单的
strpos()
无法满足要求的情况下,我们需要使用回调。因此,我们首先收集所有的回调子键,然后依次循环每个子键。如果没有底层键回调,我们就抛出异常。否则,我们将运行回调,并提供$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->contents)) { $this->messages[] = $config['msg']; $found++; } } return $found; }
-
最后,我们来看看迄今为止最复杂的方法,即扫描无效的魔法方法签名。主要问题在于方法签名差异很大,因此我们需要建立单独的正则表达式来正确测试有效性。正则表达式存储在 BC break 配置文件中。如果检测到魔法方法,我们就会检索其正确签名,并将其添加到
$this->messages
中。 -
首先,我们通过查找与
function __
匹配的内容来检查是否存在任何神奇的方法:public function scanMagicSignatures() : int { $found = 0; $matches = []; $result = preg_match_all( '/function __(.+?)\b/', $this->contents, $matches);
-
如果匹配数组不为空,我们就会在匹配集合中循环,并将魔法方法名称赋值给
$key
:if (!empty($matches[1])) { $config = $this->config[self::KEY_MAGIC] ?? NULL; foreach ($matches[1] as $name) { $key = '__' . $name;
-
如果与这个假定的神奇方法相匹配的配置键没有设置,我们就认为它不是一个神奇方法,或者是一个不在配置文件中的方法,因此不用担心。否则,如果存在关键字,我们就会提取代表方法调用的子串,并将其分配给
$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);
-
然后,我们从配置中提取正则表达式,并将其与子串匹配。该模式代表了该特定魔法方法的正确签名。如果
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 断点扫描器:
-
我们首先配置自动加载器,并从命令行 (
$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);
-
接下来我们确认路径。如果未找到,我们将退出并显示使用信息(不显示
$usage
):if (empty($path)) { if (!empty($_SERVER['REQUEST_URI'])) echo '<pre>' . $usage . '</pre>'; else echo $usage; exit; }
-
然后,我们抓取 BC Break 配置文件并创建一个
BreakScan
实例:$config = include __DIR__ . '/php8_bc_break_scanner_config.php'; $scanner = new BreakScan($config);
-
为了建立文件列表,我们使用了一个
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'); } };
-
如果开发人员选择 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; };
-
我们通过循环浏览
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"; }
-
我们使用
SplFileObject::isDir()
来确定文件列表中的项目是否是目录。如果是,我们将继续扫描列表中的下一个项目。然后,我们将文件内容推入$scanner
并运行所有扫描。然后以字符串的形式获取信息:if ($obj->isDir()) continue; $fn = basename($name); $scanner->getFileContents($name); $found = $scanner->runAllScans(); $messages = implode("\n", $scanner->getMessages());
-
我们使用
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); }
-
最后,我们累加总数并显示最终结果:
$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
通过了 scanMagicSignatures
和 scanIsResource
扫描,但该代码文件使用了三个在 PHP 8 中已被删除的函数:__autoload()
、create_function()
和 each()
。您还会注意到该文件使用了 @
符号来抑制错误,这在 PHP 8 中已不再有效。
如果指定了 CSV 文件选项,就可以在任何电子表格程序中打开它。下面是它在 Libre Office Calc 中的显示方式:

现在,您已经知道如何创建一个自动程序来检测潜在的 BC 中断。请记住,代码远非完美,不能涵盖所有可能的代码破解。为此,您必须在仔细阅读本书的材料后,依靠自己的判断。
现在,我们将注意力转向实际迁移本身。