PHP 8.1在2021年11月25日正式发布了,这是PHP 8系列的第二个次要版本,带来了很多令人期待的新特性:枚举(Enums)、Fiber(纤程)、只读属性(Readonly Properties)、交叉类型(Intersection Types)、never返回类型、数组解包支持字符串键名、first-class可调用语法等。

我们团队一直关注PHP的新版本,PHP 8.1发布后,我们决定把一个中等规模的项目从PHP 7.4升级到PHP 8.1,顺便用新特性重构一些代码。本来以为升级很简单,结果踩了很多坑,熬了好几个夜才搞定。

今天这篇文章,分享升级过程中遇到的问题和解决方案,以及新特性的使用心得。希望能帮到打算升级PHP 8.1的朋友,少踩坑少熬夜。

一、为什么升级到PHP 8.1

先说说为什么要升级。我们的项目之前一直用PHP 7.4,PHP 7.4在2022年11月就会停止安全支持,继续用有安全风险。而且PHP 8.0和8.1带来了很多新特性和性能提升,升级后代码可以更简洁,性能也更好。

PHP 8.1的主要新特性:

  • 枚举(Enums):终于有原生枚举了,不用再用类常量或者第三方库
  • Fiber(纤程):原生的协程支持,可以做异步编程
  • 只读属性(Readonly Properties):属性只能赋值一次,不可变对象更方便
  • 交叉类型(Intersection Types):参数必须同时满足多个类型约束
  • never返回类型:函数永远不会返回(抛异常或exit)
  • 数组解包支持字符串键名:之前只能解包数字键名的数组
  • first-class可调用语法:可以用$fn = strlen(...)的方式获取可调用对象
  • 性能提升:PHP 8.1比7.4快大概10%到15%

这些特性都很吸引人,尤其是枚举和只读属性,能让代码质量提升不少。于是我们决定升级。

二、升级前的准备

升级前我们做了一些准备工作。

第一,检查扩展兼容性。PHP 8.1是新发布的版本,很多扩展还没来得及适配。我们项目用了大概二十多个扩展,逐个检查是否支持PHP 8.1。大部分主流扩展(PDO、redis、memcached、gd、mbstring等)都已经支持了,但有几个冷门扩展还没支持,我们要么等更新,要么找替代方案。

第二,检查代码兼容性。用PHP_CodeSniffer加PHPCompatibility规则,扫描代码中可能不兼容PHP 8.1的写法。比如被废弃的语法、被移除的函数等。扫描出来几十个问题,大部分是小问题,改起来不难。

第三,搭建测试环境。在测试服务器上装PHP 8.1,部署项目,跑单元测试和集成测试。这一步发现了很多问题,下面详细说。

三、踩坑一:废弃的特性导致报错

PHP 8.1废弃了一些特性,用了就会报Deprecated warning。如果项目里把warning当错误处理(很多框架默认这样),就会直接报错。

我们遇到的主要有这些:

1. 传null给非空参数

PHP 8.1开始,给非空类型的参数传null会报Deprecated。比如:

function foo(string $bar) {}
foo(null); // Deprecated: Passing null to parameter #1 ($bar) of type string is deprecated

我们项目里有很多地方从数据库或者接口取数据,可能是null,然后直接传给了非空参数的函数。在PHP 7.4里这是允许的(会有warning但不明显),PHP 8.1里就报Deprecated了。

解决方案:要么给参数加?string可空类型,要么在传参前做null检查和默认值处理。我们花了不少时间改这些,大概改了一百多处。

2. 自动转换的精度丢失

PHP 8.1对浮点数转整数的处理更严格了。比如(int) "12.5"之前会得到12,现在会报Deprecated,因为字符串转整数有精度丢失。

解决方案:用intval()或者先转float再转int,明确处理。

3. 其他废弃特性

还有一些小的废弃,比如$a == $b其中一个是null另一个是字符串时的比较规则变了,htmlentities()默认编码变了等。这些都比较好改,看报错信息改就行。

四、踩坑二:枚举(Enum)的使用问题

枚举是PHP 8.1最让人期待的特性之一。我们项目里之前用类常量模拟枚举,升级后打算改成原生枚举。结果用的时候踩了一些坑。

坑一:枚举不能继承

PHP的枚举不能继承其他类,也不能被继承。我们之前有一个基础的枚举类,里面有一些通用方法,然后各个具体枚举继承它。改成原生枚举后,不能继承了,通用方法只能用trait或者在每个枚举里重复写。

解决方案:把通用方法放到trait里,枚举use这个trait。

trait EnumHelper {
    public static function values(): array {
        return array_column(self::cases(), 'value');
    }
}

enum Status: string {
    use EnumHelper;
    case Active = 'active';
    case Inactive = 'inactive';
}

坑二:枚举值的类型

枚举分两种:纯枚举(Unit Enum)和 Backed Enum(有标量值的枚举)。纯枚举没有value属性,只有name。Backed Enum必须指定类型(int或string),每个case必须有唯一的值。

我们最开始写枚举的时候,有的加了类型有的没加,导致使用的时候混乱。后来统一规定:需要存数据库或者传值的用Backed Enum,纯状态标记用纯枚举。

坑三:枚举和数据库的交互

用枚举后,存数据库的时候要存枚举的value,取出来的时候要转成枚举对象。我们用的是Laravel框架,Laravel 8还不支持原生枚举的类型转换(Laravel 9才支持),所以需要自己写访问器和修改器。

public function getStatusAttribute($value): Status {
    return Status::from($value);
}

public function setStatusAttribute(Status $status) {
    $this->attributes['status'] = $status->value;
}

这个有点麻烦,但也能接受。如果用的是支持枚举的ORM就方便很多。

坑四:枚举的序列化

枚举不能直接json_encode,会得到一个空对象。需要实现JsonSerializable接口,或者手动转成value。

enum Status: string implements JsonSerializable {
    case Active = 'active';
    case Inactive = 'inactive';
    
    public function jsonSerialize(): string {
        return $this->value;
    }
}

五、踩坑三:只读属性(Readonly)的问题

只读属性也是PHP 8.1的新特性,属性只能在构造函数里赋值一次,之后不能修改。这对不可变对象很有用。

但用的时候也有坑:

坑一:只读属性不能有默认值

只读属性不能在声明时给默认值,必须在构造函数里赋值。这是因为只读属性的设计是"构造时赋值,之后不可变",如果有默认值就违背了这个设计。

class Foo {
    public readonly string $bar = 'default'; // Fatal error
}

解决方案:在构造函数里给默认值。

class Foo {
    public function __construct(
        public readonly string $bar = 'default'
    ) {}
}

坑二:只读属性不能在构造函数外赋值

这个是当然的,但我们遇到了一个问题:用工厂方法创建对象的时候,工厂方法里不能给只读属性赋值,必须通过构造函数。所以工厂方法只能调用构造函数,不能先new再赋值。

解决方案:工厂方法里调用构造函数传参,或者用命名参数。

坑三:克隆对象时只读属性也不能改

克隆对象的时候,__clone方法里也不能修改只读属性。如果需要克隆后修改某些属性,那些属性就不能设为readonly。

六、踩坑四:Fiber(纤程)的使用问题

Fiber是PHP 8.1引入的原生协程,能让函数暂停和恢复,是异步编程的基础。我们很兴奋地试了一下,结果发现坑不少。

坑一:Fiber不是完整的异步方案

Fiber只是提供了协程的基础能力,本身没有事件循环、IO调度、网络异步等功能。要做真正的异步编程,还需要基于Fiber的框架(比如Swoole、Revolt、AMP等)。单纯用Fiber,只能做一些手动的协程切换,实用性有限。

我们最开始以为用了Fiber就能像Go那样写异步代码,结果发现不是那么回事,还得配合事件循环。

坑二:Fiber的API比较底层

Fiber的API很底层,需要手动start、resume、suspend。写起来比较繁琐,也容易出错。比如:

$fiber = new Fiber(function(): void {
    Fiber::suspend('first');
    Fiber::suspend('second');
});

echo $fiber->start(); // first
echo $fiber->resume(); // second
echo $fiber->resume(); // null

这种写法比Go的goroutine繁琐很多,也不如Python的async/await直观。

坑三:扩展兼容性

很多PHP扩展还不支持Fiber,在Fiber里调用某些扩展的函数可能会出问题。比如数据库驱动、缓存驱动等,需要确认是否支持Fiber。

我们试了一下,在Fiber里用PDO查询数据库,是阻塞的,并没有异步效果。因为PDO本身不支持异步。要真正异步,得用Swoole的协程客户端。

所以,Fiber目前更像是一个基础设施,为以后的异步生态打基础。普通项目暂时不用急着用,等基于Fiber的异步框架成熟了再用也不迟。

七、踩坑五:其他新特性的问题

交叉类型(Intersection Types)

交叉类型要求参数同时满足多个类型约束,比如function foo(A&B $obj),$obj必须同时实现A和B接口。这个特性用得不多,但在某些场景很有用。

坑是:交叉类型不能和联合类型一起用,也不能用在属性上(只能用在参数和返回值)。而且如果两个接口有同名方法,会有冲突。

never返回类型

never表示函数永远不会返回,要么抛异常,要么调用exit/die。这个类型很有用,能让静态分析更准确。

坑是:如果函数声明了never返回类型,但实际上有return语句,会报错。而且never类型的函数不能有返回值,连return;都不行。

数组解包支持字符串键名

PHP 7.4开始支持数组解包([...$a, ...$b]),但之前只支持数字键名的数组,字符串键名会报错。PHP 8.1开始支持字符串键名了。

坑是:如果两个数组有相同的字符串键名,后面的会覆盖前面的,和array_merge的行为一样。这个要注意,不要以为会像数字键名那样追加。

first-class可调用语法

可以用$fn = strlen(...)获取可调用对象,比Closure::fromCallable('strlen')简洁多了。

坑是:这个语法只能用于可调用的东西,不能用于普通函数表达式。而且有些IDE还不支持这个语法的自动补全。

八、性能提升和其他变化

升级到PHP 8.1后,性能确实有提升。我们做了基准测试,同样的代码,PHP 8.1比7.4快大概12%左右,内存占用也少了一些。对于我们这种Web应用,响应时间平均快了几十毫秒,虽然不是质的飞跃,但也不错。

PHP 8.1还有一些其他变化:

  • MySQLi:默认错误模式改为异常。之前MySQLi默认是静默错误,PHP 8.1改成了抛异常。如果代码里没有处理异常,可能会报错。我们的项目用的是PDO,不受影响,但如果用MySQLi要注意。
  • fileinfo扩展:finfo函数的默认选项变了。影响不大。
  • GD库:一些函数的参数变化。如果用GD处理图片要注意。
  • 各种废弃和移除。一些很老的函数和特性被移除了,比如MySQL扩展早就没了,PHP 8.1又移除了一些。

九、升级的建议和经验

最后总结一些升级的建议和经验。

第一,不要急着升。 PHP 8.1刚发布的时候,很多扩展和框架还没适配。建议等一两个月,等生态成熟了再升。我们是PHP 8.1发布后两周升的,遇到了一些扩展不兼容的问题,等了一周才解决。如果不急,可以等8.1.1或者8.1.2再升,更稳定。

第二,先在测试环境充分测试。 不要直接在生产环境升级。先在测试环境跑通所有功能,跑单元测试和集成测试,确认没问题了再上生产。我们在测试环境测了两周,改了很多问题,才敢上生产。

第三,逐步升级,不要一步到位。 如果项目很大,可以先升级PHP版本但不用新特性,确保代码能跑起来。然后再逐步用新特性重构代码。不要一边升版本一边大改代码,出了问题不好排查。

第四,关注废弃警告。 PHP 8.1的Deprecated warning很多,虽然不影响运行,但最好都改掉,因为未来版本可能会变成Fatal error。可以把Deprecated级别暂时调低,但要记录下来,慢慢改。

第五,新特性按需使用。 不要为了用新特性而用新特性。枚举适合状态管理,只读属性适合不可变对象,Fiber适合异步框架。用对了能提升代码质量,用错了反而增加复杂度。

第六,做好回滚方案。 万一升级后出问题,要能快速回滚到旧版本。我们的做法是生产环境先灰度,切10%流量到PHP 8.1,观察没问题再全量。有问题随时切回PHP 7.4。

十、写在最后

以上就是我们升级PHP 8.1的踩坑记录。从废弃特性到枚举、只读属性、Fiber,再到性能和升级建议,尽量写得全面。

总的来说,PHP 8.1是一个值得升级的版本。新特性很实用,性能有提升,代码可以写得更简洁更安全。但升级过程中确实会遇到一些坑,需要耐心解决。

PHP这几年发展很快,从PHP 7的性能革命,到PHP 8的JIT和类型系统,再到PHP 8.1的枚举和Fiber,PHP越来越现代化了。虽然很多人调侃PHP是"最好的语言",但不可否认,PHP在不断进步,依然是Web开发的重要选择。

2021年了,PHP 8.1已经发布,PHP 8.2也在开发中。希望PHP越来越好,也希望更多开发者能享受到新版本带来的好处。

如果你也在考虑升级PHP 8.1,希望这篇文章能帮你少踩坑。如果有其他问题或者经验,欢迎在评论区交流。

祝大家升级顺利,代码越写越优雅。