将PHP8.0的代码转译到7.1的技巧

468 阅读6分钟

理想情况下,我们应该总是在我们的 web 服务器中安装最新版本的 PHP。现在,这就是 PHP 8.0。

然而,在很多情况下,这是不可能的。考虑到我们的客户正在运行与最新的 PHP 版本不兼容的遗留软件的情况。或者我们不能控制环境,比如在为WordPress建立一个面向大众的插件。

在这些情况下,转写PHP代码是有意义的,因为它使我们能够在开发中使用最新的PHP特性,但在发布软件时却将其代码转换为较早的PHP版本用于生产。

A Meme Making Light of Coding with PHP 8.0 and Deploying as PHP 7.1

在这篇文章中,我们将学习几个从 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的类型是stringint
  • 由于验证失败,新对象没有被创建,返回值是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
✅ Parameter type widening
✅ PREG_UNMATCHED_AS_NULL flag inpreg_match
✅ Functions:
  • [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:

  • ValueError
  • UnhandledMatchError

✅ 常量。

  • 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 应该被修改两次。

  1. DowngradeCovariantReturnTypeRector 必须首先将返回类型从ItemInterface 转变为self
  2. DowngradeSelfTypeDeclarationRector ,然后应该删除self 的返回类型。

但是第二步没有发生。结果是,在运行降级后,函数tag 返回self ,这对 PHP 7.3 及以下版本是不起作用的。

我想出的解决这个问题的方法包括两个步骤。

  1. 找出何时发生此类问题(这将是例外)。
  2. 通过运行第二个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博客上。