理想情况下,我们应该总是在我们的 web 服务器中安装最新版本的 PHP。现在,这就是 PHP 8.0。
然而,在很多情况下,这是不可能的。考虑到我们的客户正在运行与最新的 PHP 版本不兼容的遗留软件的情况。或者我们不能控制环境,比如在为WordPress建立一个面向大众的插件。
在这些情况下,转写PHP代码是有意义的,因为它使我们能够在开发中使用最新的PHP特性,但在发布软件时却将其代码转换为较早的PHP版本用于生产。
在这篇文章中,我们将学习几个从 PHP 8.0 转译到 7.1 的技巧。
PHP 7.1足够好吗?
降级是通过Rector,即 PHP 重构工具来完成的。PHP 7.1 是降级的目标,因为这是目前 Rector 所能处理的最低的 PHP 版本的降级。(在未来,我们可能会降级到 7.0 和 5.6)。
由于PHP 7.1 已经过期,这对大多数情况来说应该是足够了。毕竟,我们应该始终只运行积极维护的 PHP 版本,也就是 PHP 7.3 及以上版本。否则,我们有可能使用含有未修补漏洞的 PHP。
不幸的是,情况并非总是如此。例如,WordPress仍然支持PHP 5.6,因此,一个使用PHP 7.1的插件将不能被运行在PHP 5.6和7.0上的WordPress用户所使用,而这些用户目前在所有WordPress用户中约占16.4%。
如果你的用户依赖传统的软件,而你目前正在使用一个非常老的PHP版本,比如5.6,那么你应该考虑跳到PHP 7.1是否值得。如果值得,那么由于转编译,可以直接跳到使用 PHP 8.0。
在我的情况下,由于只有现代的应用程序会运行GraphQL,我的插件GraphQL API for WordPress应该不会因为撇开运行在WordPress 5.6和7.0上的用户而受到很大的影响,所以它是值得的。
不过,在Yoast的情况下,影响会很大:因为它有超过500万的活跃安装,排除16.4%可能意味着大约100万用户。这是不值得的。
通过转译PHP代码,我们可以实现什么?
在为我的插件引入转码后,我已经能够将其最低要求的PHP版本提高到8.0(用于开发)。
回报是很大的:通过访问PHP 8.0的联合类型,加上PHP 7.4的类型属性,我已经能够在插件的代码库中完全添加严格类型(包括所有的函数参数、返回语句和类属性),这意味着更少的错误和更容易理解的代码。
我对我现在能产生的这段代码感到很兴奋。
interface CustomPostTypeAPIInterface
{
public function createCustomPost(array $data): string | int | null | Error;
}
这个函数的返回类型表示发生了这些情况中的一种。
- 新的自定义帖子对象通过返回其ID成功创建,该ID的类型是
string或int - 由于验证失败,新对象没有被创建,返回值是
null - 由于过程中出了问题(例如,连接到所需的第三方API失败),新对象没有被创建,返回类型为
Error的自定义对象,其中也包含一个错误信息
因此,转译使我有机会成为一个更好的开发者,产生具有更高的质量的代码。
转译后的代码在生产中的表现如何
将上面的代码转写到PHP7.1后,返回类型将被删除。
interface CustomPostTypeAPIInterface
{
public function createCustomPost(array $data);
}
现在,如果这个函数的返回类型和它被调用的地方出现了类型不匹配,在开发过程中我就已经意识到了,并解决了这个问题。
因此,删除生产中的返回类型并不会产生任何后果。
哪些新功能变得可用?
能够用PHP 8.0编码并不意味着可以使用PHP 8.0、7.4、7.3和7.2版本的所有功能。相反,只有那些在Rector中有降级规则的功能可以使用,再加上那些被Symfony的polyfill包所回传的功能([polyfill-php80](https://packagist.org/packages/symfony/polyfill-php80), [polyfill-php74](https://packagist.org/packages/symfony/polyfill-php74), [polyfill-php73](https://packagist.org/packages/symfony/polyfill-php73), , 和 [polyfill-php72](https://packagist.org/packages/symfony/polyfill-php72)).
例如,目前没有办法降级PHP 8.0的属性,所以我们不能使用这个功能。截至目前,用 PHP 8.0 编码的应用程序降级到 7.1 的可用 PHP 特性列表如下。
| PHP版本 | 特性 |
|---|---|
| 7.1 | 一切 |
| 7.2 | object type |
PREG_UNMATCHED_AS_NULL flag inpreg_match | |
[spl_object_id](https://php.net/spl_object_id)[utf8_encode](https://php.net/utf8_encode)[utf8_decode](https://php.net/utf8_decode)
常量。
[PHP_FLOAT_*](https://php.net/reserved.constants#constant.php-float-dig)[PHP_OS_FAMILY](https://php.net/reserved.constants#constant.php-os-family)
|
| 7.3 |
list() 中的引用赋值/数组析构=>[&$a, [$b, &$c]] = $d 除了在foreach (#4376)
灵活的Heredoc和Nowdoc语法
函数调用中的尾部命令
set(raw)cookie 接受$option 参数
函数。
[array_key_first](https://php.net/array_key_first)[array_key_last](https://php.net/array_key_last)[hrtime](https://php.net/function.hrtime)[is_countable](https://php.net/is_countable)
异常情况。
[JsonException](https://php.net/JsonException)
|
| 7.4 | 类型化的属性
箭头函数
空值凝聚赋值运算符=>
??=
数组内部的解包=>
$nums = [3, 4]; $merged = [1, 2, ...$nums, 5];
数字字面分隔符=>
1_000_000
strip_tags() 带有标签名称的数组=>strip_tags($str, ['a', 'p'])
协变量返回类型和禁变量参数类型
函数。
[get_mangled_object_vars](https://php.net/get_mangled_object_vars)[mb_str_split](https://php.net/mb_str_split)[password_algos](https://php.net/password_algos)
|
| 8.0 | 联合类型
mixed 伪类型
static 返回类型
::class 对象上的魔法常数
match 表达式
catch 异常只由类型
Null-safe 操作符
类构造器属性推广
参数列表和闭合
use 列表中的尾部逗号
接口。
Stringable
Classes:
ValueErrorUnhandledMatchError
常量。
FILTER_VALIDATE_BOOL
函数。
[fdiv](https://php.net/fdiv)[get_debug_type](https://php.net/get_debug_type)[preg_last_error_msg](https://php.net/preg_last_error_msg)[str_contains](https://php.net/str_contains)[str_starts_with](https://php.net/str_starts_with)[str_ends_with](https://php.net/str_ends_with)[get_resource_id](https://php.net/get_resource_id)
|
执行转码
将代码从 PHP 8.0 一路转到 PHP 7.1 的 Rector 配置是这样的。
return static function (ContainerConfigurator $containerConfigurator): void {
// get parameters
$parameters = $containerConfigurator->parameters();
// here we can define, what sets of rules will be applied
$parameters->set(Option::SETS, [
DowngradeSetList::PHP_80,
DowngradeSetList::PHP_74,
DowngradeSetList::PHP_73,
DowngradeSetList::PHP_72,
]);
}
转译仅用于生产的代码
我们需要转译组成我们项目的所有代码,其中包括我们的源代码和它所依赖的所有第三方包。
关于包,我们不需要转译所有的包;只需要转译那些将成为交付品一部分的包。换句话说,只有PROD的包,没有DEV的包。
这是个好消息,因为。
- 在代码库上运行Rector会花费一些时间,所以删除所有不需要的包(如PHPUnit、PHPStan、Rector本身和其他)会减少运行时间
- 这个过程很可能不会完全顺利(有些文件可能会产生错误,需要一些自定义的解决方案)。因此,需要转译的文件越少,需要的努力就越少。
我们可以在Composer中找出哪些是PROD的依赖项。
composer info --name-only --no-dev
下面的Bash脚本会计算出所有需要降级的路径列表(即项目的源代码和它的PROD依赖项),并对它们应用Rector。
# Get the paths for all PROD dependencies
# 1. `composer`: Get the list of paths, in format "packageName packagePath"
# 2. `cut`: Remove the packageNames
# 3. `sed`: Remove all empty spaces
# 4. `tr`: Replace newlines with spaces
paths="$(composer info --path --no-dev | cut -d' ' -f2- | sed 's/ //g' | tr '\n' ' ')"
# Execute the downgrade
# 1. Project's source folder as "src"
# 2. All the dependency paths
vendor/bin/rector process src $paths --ansi
该配置必须排除在所有测试案例上运行Rector。否则,Rector将抛出一个错误,因为在PROD中缺少PHPUnit\Framework\TestCase 。不同的依赖关系可能把它们放在不同的位置,这就是我们需要微调我们的Rector配置的原因。要知道,我们可以检查它们的源代码或运行Rector,看看它是否/如何失败。
对于我的插件,要跳过的文件夹(包括来自插件源代码及其依赖的文件夹)是这些。
$parameters->set(Option::SKIP, [
// Skip tests
'*/tests/*',
'*/test/*',
'*/Test/*',
]);
注意依赖性的不一致
有时,依赖项可能引用一些为DEV加载的外部类。当Rector分析这个依赖关系时,它会抛出一个错误,因为被引用的代码对PROD来说不存在。
例如,Symfony的Cache组件的 EarlyExpirationHandler,实现了Messenger组件的接口MessageHandlerInterface。
class EarlyExpirationHandler implements MessageHandlerInterface
{
//...
}
然而,symfony/cache'对symfony/messenger 的依赖是在require-dev ,而不是在require 。因此,如果我们的项目有对symfony/cache 的依赖,而我们用Rector分析它,它就会抛出一个错误。
[ERROR] Could not process "vendor/symfony/cache/Messenger/EarlyExpirationHandler.php" file, due to:
"Analyze error: "Class Symfony\Component\Messenger\Handler\MessageHandlerInterface not found.". Include your files in "$parameters->set(Option::AUTOLOAD_PATHS, [...]);" in "rector.php" config.
See https://github.com/rectorphp/rector#configuration".
要解决这个问题,首先要检查这是否是依赖关系的 repo 中的一个错误。在这种情况下,symfony/messenger 应该被添加到require 的部分symfony/cache ?如果你不知道答案,你可以在他们的 repo 上通过一个问题来询问。
如果这是一个bug,它将有希望被修复,你可以等待这一变化的发生(甚至可以直接贡献它)。否则,你需要考虑你的生产项目是否使用了产生该错误的类。
如果它使用了,那么你可以通过Option::AUTOLOAD_PATHS ,在Rector的配置上加载缺少的依赖性。
$parameters->set(Option::AUTOLOAD_PATHS, [
__DIR__ . '/vendor/symfony/messenger',
]);
如果它不使用它,那么你可以直接跳过这个文件,这样Rector就不会处理它。
$parameters->set(Option::SKIP, [
__DIR__ . '/vendor/symfony/cache/Messenger/EarlyExpirationHandler.php',
]);
优化转编译过程
我们前面看到的 Bash 脚本很简单,因为它是将所有 PROD 依赖关系从 PHP 8.0 降级到 7.1。
现在,如果有任何依赖关系已经在PHP 7.1或以下,会发生什么?在其代码上运行Rector不会产生副作用,但这是在浪费时间。如果有大量的代码,那么浪费的时间就会变得很重要,使我们在测试/合并PR时要等待更长的时间来完成CI过程。
每当这种情况发生时,我们宁愿只在那些含有必须降级的代码的包上运行Rector,而不是所有的包。我们可以通过Composer查出这些包是哪些。由于依赖关系通常指定它们需要的PHP版本,我们可以像这样推断出哪些是需要PHP 7.2及以上版本的软件包。
composer why-not php "7.1.*" | grep -o "\S*\/\S*"
由于某些原因,composer why-not 与--no-dev 标志不起作用,所以我们只需要安装 PROD 依赖关系来获得这些信息。
# Switch to production, to calculate the packages
composer install --no-dev --no-progress --ansi
# Obtain the list of packages needing PHP 7.2 and above
packages=$(composer why-not php "7.1.*" | grep -o "\S*\/\S*")
# Switch to dev again
composer install --no-progress --ansi
有了软件包名称的列表,我们像这样计算它们的路径。
for package in $packages
do
# Obtain the package's path from Composer
# Format is "package path", so extract everything after the 1st word with cut to obtain the path
path=$(composer info $package --path | cut -d' ' -f2-)
paths="$paths $path"
done
最后,我们对所有的路径(以及项目的源文件夹)运行Rector。
vendor/bin/rector process src $paths --ansi
注意链式规则
在某些情况下,我们可能会遇到连锁规则:应用一个降级规则产生的代码本身需要被另一个降级规则所修改。
我们可能期望按照预期的执行顺序来定义规则,这样就可以处理连锁的规则。不幸的是,这并不总是有效,因为我们不能控制PHP-Parser如何遍历节点。
这种情况发生在我的项目上:symfony/cache 有文件vendor/symfony/cache/CacheItem.php ,函数tag ,返回ItemInterface 。
final class CacheItem implements ItemInterface
{
public function tag($tags): ItemInterface
{
// ...
return $this;
}
}
实现的接口ItemInterface ,反而在函数tag 上返回self。
interface ItemInterface extends CacheItemInterface
{
public function tag($tags): self;
}
PHP 7.4的降级集包含以下两条规则,按这个顺序定义。
$services = $containerConfigurator->services();
$services->set(DowngradeCovariantReturnTypeRector::class);
$services->set(DowngradeSelfTypeDeclarationRector::class);
当降级类CacheItem ,函数tag 应该被修改两次。
DowngradeCovariantReturnTypeRector必须首先将返回类型从ItemInterface转变为selfDowngradeSelfTypeDeclarationRector,然后应该删除self的返回类型。
但是第二步没有发生。结果是,在运行降级后,函数tag 返回self ,这对 PHP 7.3 及以下版本是不起作用的。
我想出的解决这个问题的方法包括两个步骤。
- 找出何时发生此类问题(这将是例外)。
- 通过运行第二个Rector进程来 "手动 "解决这个问题,该进程有自己的配置,专门用来解决这个问题。
让我们看看它们是如何工作的。
1.找出何时发生此类问题
通常情况下,我们期望运行一次Rector,让它执行所有需要的修改。然后,如果我们第二次运行Rector(在第一次执行的输出上),我们希望没有代码被修改。如果在第二次执行时有任何代码被修改,这意味着在第一次执行时有什么地方不顺利。最有可能的是,它是一个没有被应用的链式规则。
Rector接受标志--dry-run ,这意味着它将在屏幕上打印出修改的内容,但不在代码上实际应用。方便的是,用这个标志运行Rector,只要有修改,就会返回一个错误。
然后,我们可以运行rector process --dry-run ,作为CI的第二道程序。每当CI过程失败时,控制台的输出将显示哪条规则被应用于第二遍,从而指出哪条是第一遍没有应用的连锁规则。
运行第二遍有一个额外的好处:如果产生的PHP代码有错误(偶尔会发生,就像这个例子一样),那么Rector的第二遍就会失败。换句话说,我们正在使用Rector来测试Rector本身的输出。
2. "手动 "修复问题
一旦我们发现某个规则没有在某个节点上执行,我们必须引入一种方法,在Rector第一次通过后立即应用它。我们可以再次运行相同的Rector过程,但这是低效的,因为这个过程涉及到在成千上万的文件上应用几十条规则,需要几分钟时间才能完成。
但这个问题很可能只涉及一个规则和一个类。因此,我们宁愿创建第二个Rector配置,这只需要几秒钟的时间来执行。
return static function (ContainerConfigurator $containerConfigurator): void {
$parameters = $containerConfigurator->parameters();
$parameters->set(Option::PATHS, [
__DIR__ . '/vendor/symfony/cache/CacheItem.php',
]);
$services = $containerConfigurator->services();
$services->set(DowngradeSelfTypeDeclarationRector::class);
};
为了支持必须处理不止一个额外的Rector配置,我们可以将一个Rector配置的列表传递给Bash脚本。
# Execute additional rector configs
# They must be self contained, already including all the src/ folders to downgrade
if [ -n "$additional_rector_configs" ]; then
for rector_config in $additional_rector_configs
do
vendor/bin/rector process --config=$rector_config --ansi
done
fi
结论
转译PHP代码本身就是一门艺术,需要花点功夫来设置。更有可能的是,我们需要对Rector的配置进行微调,使其与我们的项目完美配合,因为它需要哪些依赖,以及这些依赖使用了哪些PHP特性。
然而,转译代码是一种令人难以置信的强大体验,我衷心推荐。在我自己的例子中,我能够为我公开的WordPress插件使用PHP 8.0的特性(这在其他地方是闻所未闻的),允许我在它的代码库中添加严格的类型,从而降低了出现错误的可能性并改进了它的文档。
帖子《将代码从PHP 8.0转译到7.1的技巧》首次出现在LogRocket博客上。