PHP 8.4 属性钩子重构实战:620 行订单实体砍到 240 行,附 6 个文档没写全的坑

2026-10-29 0 224

去年底接到一个老电商项目的维护活,代码规模不大,但订单实体那块看着就头疼。`app/Entity/Order.php` 六百二十行,三十多个字段,每个字段一套 getter/setter,剩下的就是各种 `getXxxText()`、`setXxxByYyy()` 之类的辅助方法。

同事交接的时候跟我说:”这文件别动,动了容易出事。”我当时没太在意,直到一周后真的出了事。

那笔从 1 元变 100 元的订单

仓库里 `order_amount` 字段存的是「分」,整数。实体类里有个 `getAmount()` 会除以 100 转成元返回,`setAmount()` 反过来乘以 100。这个设计本身没毛病,问题在于总有地方绕过去。

那次是新写的对账脚本,直接 `$order->amount = 1.35;`,注意是直接访问属性——实体类里根本没有声明这个属性,PHP 的动态属性就这么默默接住了,1.35 原封不动进了内存。脚本执行完后走了一个批量更新,1.35 被原样写进了 `order_amount` 字段。第二天早上财务对账,这笔订单显示金额 1.35 元,但它实付了 135 元(正确的分单位应该是 135)。再一查,类似的问题订单有四十多笔。

这事追根到底不是谁的疏忽,是「同一个数据,有两套访问路径」这个结构本身就在邀请 bug 发生。只要 `$order->amount` 能绕过换算逻辑,早晚有人会踩。

所以这次动手,第一优先级就是把这个口子彻底堵上。正好那段时间在跟进 PHP 8.4 的迁移,属性钩子(Property Hooks)算是 8.4 里最实用的一个特性,就用它了。

属性钩子到底长什么样

给不大熟悉这个特性的同学扫个盲。简单说,属性钩子允许你把 get/set 逻辑直接写在属性声明里,PHP 在执行读写时自动触发:

<?php

class Temperature
{
    public float $celsius = 0.0;

    public float $fahrenheit {
        get => $this->celsius * 9 / 5 + 32;
        set (float $value) {
            $this->celsius = ($value - 32) * 5 / 9;
        }
    }
}

$t = new Temperature();
$t->fahrenheit = 212;
echo $t->celsius;      // 100
echo $t->fahrenheit;   // 212

这里 `$fahrenheit` 就是一个**虚拟属性**——它没有自己的存储空间,读写都直接落到 `$celsius` 上。判断标准很简单:钩子内部有没有引用 `$this->属性名`。没有就是虚拟的,有就是带底层存储的。

另外 8.4 一起进来的还有不对称可见性(Asymmetric Visibility):

class Foo
{
    public private(set) string $bar = 'baz';
}

$f = new Foo();
echo $f->bar;     // OK
$f->bar = 'x';   // Fatal error: Cannot modify private(set) property

`public private(set)` 读可见性是 public,写只在类内部可用。以前这种需求要么写 `private $bar` 加个 `getBar()`,要么干脆放任外部随便改。现在一行搞定。

两个特性配合起来,正好能对症下药。下面这堆代码是重构之后的实际产物,为了便于阅读我删掉了几个不相关的字段。

第一步:把金额锁死

先处理事故源头。目标很明确:外部能读、能写,但不管怎么写,底层存的永远是「分」这个整数,容不得半点闪失。

<?php
declare(strict_types=1);

namespace AppEntity;

use InvalidArgumentException;

class Order
{
    // 真正的存储字段,类外部无法直接接触
    protected int $amountCents = 0;

    public float $amount {
        get => $this->amountCents / 100;

        set (float|int $value) {
            if ($value < 0) {
                throw new InvalidArgumentException('订单金额不能为负数');
            }
            if ($value > 99999999.99) {
                throw new InvalidArgumentException('订单金额超出上限');
            }
            $this->amountCents = (int) round($value * 100);
        }
    }
}

注意 `amount` 是虚拟属性,`get` 和 `set` 都没碰 `$this->amount`,所以它没有自己的底层存储。外部 `$order->amount = 1.35`,set 钩子被触发,`round(1.35 * 100)` 得到 135。想绕过?绕不了——属性访问就这么一条路。

有个细节值得说:`round()` 不能省。浮点乘法会有精度问题,`19.99 * 100` 在某些平台上会得到 `1998.9999999999998`,直接 `(int)` 转就是 1998,少一分。这种一分钱的差额在会计系统里是要出人命的,我们之前老代码就有这个 bug,只是没人发现。

当然,如果你能改表结构,我强烈建议把对外接口也改成用整数分表示,浮点数在金额场景下就不该出现。但这是遗留项目,那么多个调用方,改接口的成本比用钩子兜住高得多。

第二步:状态字段和它的文案

以前状态字段是这样的:

// 老代码,几十处这种写法
$statusText = match ($order->order_status) {
    0 => '待付款',
    1 => '已付款',
    2 => '已发货',
    3 => '已完成',
    4 => '已退款',
    default => '未知',
};

改起来就顺手多了。先上个枚举:

enum OrderStatus: int
{
    case Unpaid    = 0;
    case Paid      = 1;
    case Shipped   = 2;
    case Completed = 3;
    case Refunded  = 4;

    public function label(): string
    {
        return match ($this) {
            self::Unpaid    => '待付款',
            self::Paid      => '已付款',
            self::Shipped   => '已发货',
            self::Completed => '已完成',
            self::Refunded  => '已退款',
        };
    }
}

实体里:

class Order
{
    public OrderStatus $status = OrderStatus::Unpaid;

    public string $statusText {
        get => $this->status->label();
    }
}

`$statusText` 是只读虚拟属性,想读 `$order->statusText` 随时随地都能拿到「已发货」这种中文,不用再去调什么 `getStatusText()`。而且因为它只有 get 钩子没有 set 钩子,外部想给 `$order->statusText = ‘xxx’` 写东西会被直接拒绝,编不过去。

这种「只有 get 钩子的虚拟属性」几乎是重构旧 getter 方法最标准的路径。

第三步:订单号的写入控制

订单号生成后绝不允许被改,以前的写法是 `private $orderNo;` 加 `getOrderNo()`。改完之后:

class Order
{
    public private(set) string $orderNo;

    public function __construct(string $orderNo)
    {
        $this->orderNo = $orderNo;
    }
}

外部可以 `$order->orderNo` 读,但写的话直接抛错。类内部(构造函数里)照常赋值。

这里有个注意事项——**`private(set)` 和 set 钩子不能共存**。你想给写入加逻辑,就得用 set 钩子;想限制可见性,就用 `private(set)`。二选一,不能既 `public private(set)` 又定义一个 `set` 块。我们有个字段一开始想「既限制外部写、又在内部写入时做格式化」,试了半天才发现只能改用 set 钩子 + 内部私有方法的形式。

第四步:金额的展示格式

class Order
{
    protected int $amountCents = 0;

    public float $amount {
        // ...
    }

    public string $amountDisplay {
        get => number_format($this->amountCents / 100, 2, '.', '');
    }
}

以前是 `getAmountDisplay()`,现在是属性访问,模板里直接 `{$order.amountDisplay}` 就行。PHP 模板引擎对这种访问方式支持都比方法调用好。

到这里核心部分就改完了,`Order.php` 从 620 行降到了 240 行左右——主要省下来的是三十多组 getter/setter 的方法签名和花括号。

不过事还没完。真正的麻烦在后面。

坑 1:虚拟属性在 `get_object_vars()` 里消失了

他们项目里有一套自己的序列化逻辑,大致是这样的:

public function toArray(AppEntityOrder $order): array
{
    return get_object_vars($order);
}

第一版改完之后,接口返回里 `amount`、`statusText`、`amountDisplay` 全都不见了,前端立刻报错。原因很简单:`get_object_vars()` 只返回**有底层存储的下标存在**的属性,虚拟属性不在其中,因为对象内部压根没有那个槽位。

解法有两种,我选了后者:

// 方案一:把需要序列化的字段改成带底层存储的属性(但如果是为了兼容 JSON 就没必要)
// 方案二:实现 JsonSerializable

class Order implements JsonSerializable
{
    public function jsonSerialize(): array
    {
        return [
            'order_no'       => $this->orderNo,
            'status'         => $this->status->value,
            'status_text'    => $this->statusText,
            'amount'         => $this->amount,
            'amount_display' => $this->amountDisplay,
        ];
    }
}

其实这反而更好,白名单列出了哪些字段可以对外,不会因为内部新增一个字段就莫名其妙地被曝出去。

坑 2:`&$order->amount` 不能用了

重构后第一次 CI 挂了一片,报错是 `Cannot take a reference to a property with hooks`。看了下是排序逻辑里的:

usort($orders, function (&$a, &$b) {
    return $a->amount <=> $b->amount;    // 这里没问题
});

// 真正出问题的是用一个老工具函数:
function sortByAmount(array &$list) {
    usort($list, fn($a, $b) => $a->amount <=> $b->amount);
}
// 但某处代码写成了:
$tmp = &$order->amount;      // 直接对带钩子的属性取引用,致命错误

PHP 官方立场很明确:带钩子的属性不能取引用,因为钩子本质上是个函数,没有稳定的内存地址。需要处理值的地方,先赋值到局部变量,或者直接改成方法调用。

这个限制不太好绕过。如果某个字段确实需要频繁引用传递,要么放弃对它用钩子,要么把整个访问改造成方法调用,接受一个 `getAmount()`。

坑 3:带钩子的属性不能 `readonly`

我们原本有好几个字段想做成不可变的:

// 这样写会报错
public readonly string $orderNo {
    get => $this->internalNo;
}

PHP 8.4 明确规定:定义过钩子的属性不能同时是 `readonly`。因为 `readonly` 的语义是「初始化后不可写」,而钩子允许你在 set 里做各种事情,两者语义冲突。

替代方案:用 `private(set)` 加上构造函数赋值,或者干脆创建一个专用的值对象。说实话,实现不可变数据最好的方式还是从结构上解决——把 Order 的字段迁到 readonly 的值对象里,Order 本身只持有这些对象的引用。这个改动大,我暂时没动,但后面肯定会做。

坑 4:接口里能声明的属性,只能是虚拟的

我们之前有个 `HasDisplayName` 接口:

interface HasDisplayName
{
    public function getDisplayName(): string;
}

想改成属性声明,直觉上想这么写:

// 错的
interface HasDisplayName
{
    public string $displayName = '';
}

会被拒绝。接口里的属性声明不能有默认值,也不能有底层存储,只能声明钩子:

interface HasDisplayName
{
    public string $displayName { get; }
}

翻译成人话就是:”任何实现这个接口的类,都必须有一个可以从外部读到的 $displayName 属性。”实现类可以是虚拟属性(get 钩子返回计算值),也可以是普通有底层存储的属性,都行,只要外部能读、返回 string 就可以。这条规则设计得挺好——接口本来就不该约束存储方式。

坑 5:反射写入会触发钩子,不是所有 ORM 都准备好了

他们用的是自研 ORM,hydrate 的时候用反射逐字段赋值:

$rp = new ReflectionProperty(Order::class, 'amount');
$rp->setValue($order, $row['amount']);

PHP 8.4 起,`ReflectionProperty::setValue()` 会触发属性钩子。也就是说,从数据库读出来的是 135(分),但 setValue 把 135 当成了 135 元传进去,钩子乘以 100 变成了 13500 分。

这个坑非常隐蔽,因为只在金额大于 0 的订单上出错,测试环境数据稀疏,跑一遍没发现,上线第二天就被财务群 @ 了。

8.4 专门为此加了 `setRawValue()` 和 `getRawValue()`,绕过钩子直接读写底层:

$rp = new ReflectionProperty(Order::class, 'amountCents');
$rp->setRawValue($order, (int) $row['amount']);

注意这时候用的是内部字段 `amountCents`,不是虚拟的 `amount`。虚拟属性没有底层存储,`setRawValue()` 用不上。所以 hydrate 逻辑必须明确知道:哪个字段是真实的存储列,哪个是虚拟属性。

如果你们用的是 Doctrine 或者 Eloquent,我建议先别急着上钩子——查一下当前 ORM 版本对 8.4 的支持情况。至少在我动手的时候,Doctrine 2.x 那套基于反射的 hydrate 是有问题的,3.x 改了做法。Eloquent 因为内部实现大量用 `__get` / `__set` 魔术方法,跟钩子的兼容性也要单独测。

坑 6:`isset()`、`empty()` 和 `unset()` 的行为会变

对于虚拟属性:

$order->statusText = 'x';    // 报错,因为只有 get 钩子
isset($order->statusText);   // 等价于判断 $order->statusText !== null,会调 get 钩子
empty($order->statusText);   // 同上
unset($order->statusText);   // 报错,没有定义 unset 钩子

最后那条坑了我半小时。有段老代码做内存清理时调了 `unset($order->xxx)`,这个操作以前对一个普通属性是合法的,但改成虚拟属性后直接抛致命错误。得改成不 unset,或者把字段重新定义成带底层存储的属性。

另外 `isset()` 对虚拟属性其实意义不大——因为你调的是 get 钩子,返回值非 null 就 true。别指望它表达任何「有没有被赋过值」的语义。

迁移到 8.4 的实操节奏

上面这些坑如果一股脑撞上去,很容易把整个团队劝退。我们实际是这样推进的:

第一阶段(一周):只升级 PHP 到 8.4,不改任何业务代码。跑测试、跑压测、盯着所有报错日志。这一步主要是先确认 8.4 本身对现有代码的兼容性,别两件事混在一起做。我们踩到了 `E_STRICT` 之类的边缘问题,但都不算严重。

第二阶段(两周):引入 PHPStan(level 6),让它扫描整个项目。属性钩子的类型推导 phpstan 那边支持得比较完整,能提前找出「接口属性没实现」「虚拟属性和 getter 方法冲突」这类静态问题。这步做完,实际改代码时心里有底了。

第三阶段(持续):新代码统一用属性钩子写,老代码的 getter/setter 保留不动。两者共存没问题——`$order->getAmount()` 和 `$order->amount` 可以同时存在,钩子不会影响同名的普通方法。等某个类因为其他原因需要改动时,顺手把它们改造掉。

这样做的好处是节奏可控。一次性全改必然出事,光那六百二十行的 Order 就够喝一壶了,何况项目里还有几十个类似的实体类。

到底快了还是慢了

性能这块我专门在压测环境做了一组对比:同一份订单列表,1000 条,分别用旧版(getter 方法)和新版(属性钩子)遍历、取金额、取状态文案。

结果差异在噪声范围内,几轮下来新旧两边有的轮次快有的轮次慢,最大偏差 3% 左右。属性钩子和普通方法调用在引擎层面其实很接近,钩子不需要走完整的参数栈和方法分派流程,理论上还略快一点,但被 JIT 一优化就基本抵消了。

真正拉开差距的是「有没有把虚拟属性放进热循环」。虚拟属性的每一次读都要执行一遍钩子函数,如果在百万级循环里读,累积开销会明显。遇到这种场景,在循环外取值缓存到局部变量就行,这个优化跟以前把 getter 提到循环外是一个道理。

回头看

重构上线后,那笔「1 元变 100 元」的事故从原理上就不可能再发生了——因为外部已经没有绕过换算逻辑的路径。写脚本的时候误用 `$order->amount = 135`(本来想存分),也会经过 set 钩子,变成 135 元而不是 135 分。当然这可能造成另一种语义错误,但至少数据本身是自洽的。

更重要的是代码量。原来那个 620 行的实体类,现在 240 行,剩下的全是业务方法。任何新同事翻到这个文件,不会先被三十组 getter/setter 淹没,能直接看到字段和它们的语义约束。

如果你们也在考虑升 8.4 用这些特性,我的建议是:先在**最干净的那个数据模型**上试水,挑一个字段少、调用方少的实体,跑完一轮上线观察。别像我一样拿订单这种核心实体当试验田——话虽如此,也确实因为踩了这些坑,才把该注意的点都摸清楚了。后面改 `OrderItem`、`Refund` 这些类的时候,一套模板套下去,一小时一个,顺得很。

最后提醒一句:把 `composer.json` 里的 `php` 版本约束改到 `>=8.4`,让所有开发者本地环境对齐。我见过不止一次因为某个开发人员的 Docker 镜像没更新,本地跑得动 CI 挂一片的。这类基础工作不起眼,但比任何设计模式都重要。

PHP 8.4 属性钩子重构实战:620 行订单实体砍到 240 行,附 6 个文档没写全的坑
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.4 属性钩子重构实战:620 行订单实体砍到 240 行,附 6 个文档没写全的坑 https://www.taomawang.com/server/php/2981.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务