PHP 8.4 Property Hooks 落地:订单价格模型重构与六个坑点复盘

2026-10-18 0 464

PHP 8.4 在 2024 年 11 月正式发布,Property Hooks 是三件大事之一(另外两件是新的 array 函数和不对称可见性)。发布之后我一直在找机会用上它,但业务代码改起来涉及面太大,一直没敢动。

年底项目组做复盘的时候,负责订单模块的同事吐槽了一句:”Order 这个类的 getter 已经 500 行了,我看一眼就想关掉编辑器。”我当时就决定,把这块拿来练手。

这篇文章记录的就是重构的全过程,包括几个我一开始没预料到的坑。如果你正准备把项目升到 PHP 8.4,或者只是想看看 property hooks 到底能干什么、不能干什么,可以往下看。

老代码到底长什么样

Order 类核心是四个字段:订单金额、优惠金额、税率、应付总额。围绕这四个字段,有十几组 getter/setter 和七八个派生方法。精简之后大概是这样:

final class Order
{
    private int $amountCents = 0;
    private int $discountCents = 0;
    private float $taxRate = 0.06;

    public function getAmount(): float
    {
        return $this->amountCents / 100;
    }

    public function setAmount(float|int $amount): void
    {
        if ($amount < 0) {
            throw new InvalidArgumentException('订单金额不能为负');
        }
        $this->amountCents = (int) round($amount * 100);
    }

    public function getDiscount(): float
    {
        return $this->discountCents / 100;
    }

    public function setDiscount(float|int $discount): void
    {
        if ($discount < 0) {
            throw new InvalidArgumentException('优惠金额不能为负');
        }
        if ($discount > $this->getAmount()) {
            throw new InvalidArgumentException('优惠金额不能超过订单金额');
        }
        $this->discountCents = (int) round($discount * 100);
    }

    public function getPayable(): float
    {
        $base = $this->amountCents - $this->discountCents;
        return $base / 100;
    }

    public function getTax(): float
    {
        $base = $this->amountCents - $this->discountCents;
        return round($base / 100 * $this->taxRate, 2);
    }

    public function getTotal(): float
    {
        return $this->getPayable() + $this->getTax();
    }

    // ... 其他十几个派生方法(展示字段、导出字段、日志字段)
}

痛点在哪儿?

一是”金额”这个概念有两种表达——元(float)和分(int)。所有 getter 都是元,所有数据库写入都用分,业务层调用方经常要除以 100 或乘以 100,一不小心就少一位。

二是校验散在 setter 里,但 taxRate 没有 setter,是通过构造函数注入的公有属性,改它完全不受控制。

三是派生方法(getPayable、getTax、getTotal)都是”查一次算一次”,如果调用方在一个方法里连用三次 getTotal,就要算三次。

改造目标很明确:让所有金额都走同一套规则,让派生值像属性一样访问,让外部只能读不能写。

先别急着改,先草拟新结构

我在纸上画了一遍新结构,思路是这样的:

四个字段对外全部用 hook 暴露,内部 backing store 保留分的整数表示。金额和优惠的 set hook 里做校验并转换。派生字段(应付、税费、总额)用虚拟属性——只写 get hook,不写 set hook,也没有 backing store。所有 public 属性加 private(set) 让外部只读。

草拟完之后我先写了一个最小可用的 demo 在 REPL 里跑通,确认语法和语义都能对得上,再动真代码。

基础:Property Hooks 写法速览

先看一个最简单的例子。以前写 setter 是这样的:

private string $name = '';

public function setName(string $name): void
{
    $this->name = trim($name);
}

public function getName(): string
{
    return $this->name;
}

现在可以压成一段:

public string $name = '' {
    set => trim($value);
}

这里有个语法设计值得注意:set => trim($value) 中的表达式返回值,会作为存入 backing store 的最终值。等价于手写:

public string $name = '' {
    set {
        $this->name = trim($value);
    }
}

两种写法在你需要多行逻辑(比如抛异常)的时候才用大括号,一行表达式用简写更干净。

get hook 的写法类似:

public string $formattedName {
    get => strtoupper($this->name);
}

这个只写了 get 的属性就是虚拟属性——它没有 backing store,每次读的时候实时计算。虚拟属性有个硬性限制:不能有默认值,因为没地方存。如果你写 public string $formattedName = '' { get => ... } 会直接报语法错误。

重构第一步:金额字段的 hook 化

改造之后的 amount 字段大致长这样:

public private(set) int $amountCents = 0 {
    set (int|float $value) {
        $cents = is_float($value)
            ? (int) round($value * 100)
            : $value * 100;
        if ($cents < 0) {
            throw new InvalidArgumentException('订单金额不能为负');
        }
        $this->amountCents = $cents;
    }
}

三个点解释一下。

类型声明用了 int|float。因为调用方可能传 19.9,也可能传 1990(分)。收窄类型到 int 会强制调用方自己转换,反而让业务层到处都是 * 100。

backing 存”分”。浮点数做 money 计算是灾难,我们项目里所有和钱相关的内部存储都是分的整数。hook 里做一次转换,外部看到的仍然是元。

set hook 里给 $this->amountCents 赋值。这一点跟 get hook 不同,是允许的,而且不会递归——因为 set hook 里对同名属性的写入语义就是”写入 backing store”。

然后discount 类似,但多一条跨字段校验:

public private(set) int $discountCents = 0 {
    set (int|float $value) {
        $cents = is_float($value)
            ? (int) round($value * 100)
            : $value * 100;
        if ($cents < 0) {
            throw new InvalidArgumentException('优惠金额不能为负');
        }
        if ($cents > $this->amountCents) {
            throw new InvalidArgumentException('优惠金额不能超过订单金额');
        }
        $this->discountCents = $cents;
    }
}

这里我停下想了一会儿:如果先设置了 discount,后设置 amount,那么 amount 变小之后 discount 就可能非法。老代码里也存在这个问题,但因为是 setter 方法,调用方有”顺序意识”。改成 hook 之后属性访问看起来太轻量,反而更容易踩雷。

最终我在 amountCents 的 set hook 里补了一条反向校验:如果新金额小于已有优惠,直接抛异常。这样两个字段无论谁先设置,都能保持一致性。

第二步:派生值用虚拟属性表达

应付、税费、总额这三个字段老代码是 getPayable()、getTax()、getTotal(),调用方要打成方法调用。改成虚拟属性:

public float $payable {
    get => ($this->amountCents - $this->discountCents) / 100;
}

public float $tax {
    get => round(
        ($this->amountCents - $this->discountCents) / 100 * $this->taxRate,
        2
    );
}

public float $total {
    get => $this->payable + $this->tax;
}

这样调用方写 $order->total 就跟读普通属性一样,代码比 $order->getTotal() 干净很多。同时因为是虚拟属性,外部不能写,天然就是只读的,不需要在 set hook 里抛异常拦。

注意 total 那个虚拟属性内部又调用了 $this->payable 和 $this->tax,这两个也是虚拟属性,每次读都会重新计算。如果性能敏感,可以考虑缓存一份,但我们的交易场景调用频率不高,直接计算就够。

第三步:不对称可见性

PHP 8.4 新增的 public private(set) 修饰符,允许属性的读取用 public,写入用 private。

为什么这个对我们重要?原来 taxRate 是个 public 属性,外部随便改,改完了还不动声色的。现在改成:

public private(set) float $taxRate {
    set (float $value) {
        if ($value < 0 || $value > 1) {
            throw new InvalidArgumentException('税率必须在 0 到 1 之间');
        }
        $this->taxRate = $value;
    }
}

外部可以读 $order->taxRate,但尝试写会直接 fatal error(”Cannot modify private(set) property”)。只有类内部能调整税率。

这个语法比我原先想的要贴合实际。老代码里经常有一堆 public 属性最后被业务层各改各的,加了 private(set) 之后这些字段终于有了明确的所有权。

坑一:get hook 里读同名属性会无限递归

这是我踩的第一个坑,也是最容易踩的一个。

重构 amount 的虚拟展示属性时,我一开始是这么写的:

public string $amountText {
    get => number_format($this->amount / 100, 2);
}

这里的 $this->amount 是谁?当时我以为不用管,因为类里没有 amount 属性——我在重构中把 amount(浮点)改成了 amountCents(整数)。但 IDE 没报错,php -l 也没报错。

直到运行时才发现:$this->amount 不存在,PHP 会用动态属性或者触发 __get(如果定义了)。我们类里没写 __get,所以直接返回 null,然后 number_format(null / 100, 2) 得到 "0.00"。

比这更危险的是:如果我把属性名写成和虚拟属性同名会怎样?

public float $payable {
    get => $this->payable * 2;  // 无限递归
}

这个才是真的炸——get hook 里读同名虚拟属性会递归触发自己,栈溢出直接 fatal error。

PHP 8.4 里没有语法阻止这种情况,只能靠命名规范。我最终的规范是:backing store 一律加 Cents 后缀,虚拟属性一律不加;虚拟属性内部严禁引用同名标识符。

坑二:backing value 在 get hook 里拿不到

这个坑更隐蔽,因为它涉及一个我完全没预料到的语义空缺。

我需要一个 “amountCents 转成元,但保留两位小数的展示” 属性。直觉写法是:

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

这个能跑,因为 amountCents 是个真实属性,没有 hook——读它就是读 backing。

但如果我对 amount 加了 set hook(在上面第一个坑的代码里我就这么做了),同时又要提供 get hook 来返回元,那么:

public int|float $amount {
    get => $this->amount / 100;     // 递归
    set => $this->amount = $value * 100;   // 写入 backing 但读还是递归
}

这种情况PHP 8.4 没有提供任何”访问本属性 backing value”的语法。动态属性、魔术方法、反射都解决不了这个问题——只要属性标识符相同,get 就是递归。

唯一的解法是把 backing 移到另一个名字的属性上,这就是为什么最后我们用了 amountCents。这个设计选择不是为了语义清晰,是被迫的。官方 RFC 里明确说了这个语义,但一开始不会联想到自己会撞上。

坑三:反射会触发 hook

这个坑出现在我们上线之后。

订单落库之前有个”审计”环节,它会用 ReflectionClass 遍历所有属性导出快照。重构上线当天,这个审计任务开始大量报错:

InvalidArgumentException: 优惠金额不能超过订单金额

找了好久才发现问题。ReflectionProperty::setValue 在 PHP 8.4 里会触发目标属性的 set hook——不像以前那样直接写内存。而我们的快照逻辑是先反射拿到属性,然后 setValue 一个从数据库读出来的值——也就是把整个对象的值拷到新对象上。

但审计用的是一个空壳对象(new Order()),此时 amountCents 还是 0。虽然 discountCents 从数据库读出来是 100,但 setValue 触发 set hook,set hook 里检查 $cents > $this->amountCents 也就是 100 > 0,直接抛异常。

处理方式有两种。一是审计代码改成顺序赋值(先 amount 后 discount);二是可以在 hook 里用 flag 绕开校验。我选了第一种,因为审计快照本来就该按对象的合法状态构建,顺序赋值是合理的。但如果你的场景涉及更复杂的反射用法(比如 ORM 的字段填充),可能就要准备个 setWithValidation(bool $enable) 之类的开关。

这一条给我们的教训是:hook 把校验做进属性赋值里,任何依赖反射或反序列化的地方都可能被这套校验误伤。

坑四:虚拟属性不能被遍历序列化

这个坑不致命但很烦。

我们有个 toArray() 方法,本意是把订单导出成数组给前端。重构之前实现很简单:

public function toArray(): array
{
    return [
        'amount'   => $this->getAmount(),
        'discount' => $this->getDiscount(),
        'payable'  => $this->getPayable(),
        'tax'      => $this->getTax(),
        'total'    => $this->getTotal(),
    ];
}

重构完后想偷懒,改成:

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

跑了一下发现虚拟属性一个都没出来。get_object_vars 只返回真实存在的属性(backing store),虚拟属性不在其中。改回来手写 ['amount' => $this->amount, ...],问题解决。

如果用的是 json_encode 或者 (array) $obj,实测行为也差不多——虚拟属性不参与。这其实是个设计上的优点,因为虚拟属性是”计算出来的”、不是”存储的”,不参与自动序列化可以避免很多混乱。但前提是你得记住这件事。

如果确实需要序列化虚拟属性,加一个 JsonSerializable 接口实现 jsonSerialize(),手动列出所有要导出的字段。

坑五:接口里的属性声明和子类实现

PHP 8.4 有一个配套特性:接口里可以声明属性 hook 的契约。

interface MoneyView
{
    public float $payable { get; }
}

意思是任何实现 MoneyView 的类,都必须有 $payable 属性,且至少支持读取。

我们想用这个来约束多个订单类型的字段。但实际动手时发现子类实现方式很受限:

如果子类用普通属性 public float $payable = 0; 实现,虽然满足”可读”要求,但同时也允许外部写入——这和接口的本意(只读)有出入。而如果用虚拟属性 public float $payable { get => ...; },则可以完美匹配。

最后我决定:接口里的属性契约统一用虚拟属性实现,如果某个子类需要动态值,就写成 get hook;如果不需要动态,就用不对称可见性 public private(set) 加 get hook。

这个约束并不由编译器强制,只能靠代码评审。但至少接口声明把”这个字段存在且可读”这件事变成了编译期约定,比之前靠注释或父类抽象方法要好很多。

坑六:hook 的性能开销

重构完的第二天,QA 压测报了一个数据:某个走 Order->total 的高频接口 P99 涨了 3ms。

我第一反应是”不可能吧,本来就是个几微秒的操作”。做了基准测试:

$o = new Order();  // 一笔正常订单

$N = 1_000_000;
$t = hrtime(true);
for ($i = 0; $i < $N; $i++) {
    $x = $o->amountCents;   // 直接属性访问
}
$direct = (hrtime(true) - $t) / 1e9;

$t = hrtime(true);
for ($i = 0; $i < $N; $i++) {
    $x = $o->payable;       // 虚拟属性(含两次属性读 + 一次减法)
}
$virtual = (hrtime(true) - $t) / 1e9;

printf("direct: %.4fs, virtual: %.4fsn", $direct, $virtual);

跑了三遍取平均(PHP 8.4.2,普通 cloud 环境):

直接属性访问 100 万次耗时约 0.04 秒,虚拟属性访问 100 万次耗时约 0.35 秒。差了将近 10 倍,单次开销从 40ns 涨到了 350ns。

绝对数字看其实微不足道,350ns 对比一次 SQL 查询的几毫秒就是零头。但问题在于如果有循环里反复访问虚拟属性,或者一页数据里每条记录都要 toArray(),那开销就会放大。

我们的处理方式:在 P99 涨的那个接口里,把 $order->total 访问改成了提前取出一次存局部变量。这样在循环里就不用反复算了。

至于那个虚拟属性本身,我们没打算改成 backing store,因为”实时计算”就是它存在的意义。要做优化,应该在调用方做,不该在定义方做。

整体效果对比

重构之后的完整类贴在下面(字段简化了一部分):

final class Order
{
    public private(set) int $amountCents = 0 {
        set (int|float $value) {
            $cents = is_float($value) ? (int) round($value * 100) : $value * 100;
            if ($cents < 0) {
                throw new InvalidArgumentException('订单金额不能为负');
            }
            if ($cents < $this->discountCents) {
                throw new InvalidArgumentException('新金额小于已设置的优惠');
            }
            $this->amountCents = $cents;
        }
    }

    public private(set) int $discountCents = 0 {
        set (int|float $value) {
            $cents = is_float($value) ? (int) round($value * 100) : $value * 100;
            if ($cents < 0) {
                throw new InvalidArgumentException('优惠金额不能为负');
            }
            if ($cents > $this->amountCents) {
                throw new InvalidArgumentException('优惠金额不能超过订单金额');
            }
            $this->discountCents = $cents;
        }
    }

    public private(set) float $taxRate = 0.06 {
        set (float $value) {
            if ($value < 0 || $value > 1) {
                throw new InvalidArgumentException('税率必须在 0 到 1 之间');
            }
            $this->taxRate = $value;
        }
    }

    public float $payable {
        get => ($this->amountCents - $this->discountCents) / 100;
    }

    public float $tax {
        get => round(
            ($this->amountCents - $this->discountCents) / 100 * $this->taxRate,
            2
        );
    }

    public float $total {
        get => $this->payable + $this->tax;
    }

    public string $totalText {
        get => number_format($this->total, 2);
    }

    public function toArray(): array
    {
        return [
            'amount'    => $this->amountCents / 100,
            'discount'  => $this->discountCents / 100,
            'payable'   => $this->payable,
            'tax'       => $this->tax,
            'total'     => $this->total,
            'totalText' => $this->totalText,
        ];
    }
}

老代码大约 500 行(含大量派生方法和注释),新代码 70 多行公共 API。功能没有减少。调用方的代码也变短了:

// 老代码
echo $order->getTotal();
$order->setAmount(99.9);
$order->setDiscount(10);

// 新代码
echo $order->total;
$order->amountCents = 99.9;
$order->discountCents = 10;

注意 amountCents 这个名字有点丑,语义上应该叫 amount。改名的代价是要么放弃 hook 化(纯 backing),要么放弃”元/分自动换算”。我们最终妥协了:amountCents 语义明确但外部写入时仍然按”元”接受——因为 hook 会换算。

这个命名妥协值得单独说一下:hook 化之后,属性名的语义有时会变得模糊,因为名字指的是”外部看到的概念”,而底层存的内容可能不是同一个单位/格式。amountCents 精确但它不代表外部视角。真要严格,应该叫 amount,backing 叫 amountInCents,可这样就违背了上一节坑二说的机制了。

哪些 getter 不该改

不是所有 getter 都值得换成 hook。这段时间下来我总结了几条标准。

适合改的:属性语义简单、校验逻辑固定(非负、非空、范围)、派生值只依赖同类里的其他属性、不需要访问外部资源。

不适合改的:

一是需要访问数据库或外部服务的属性。把 SQL 查询塞进 hook 里,属性访问就变成了不可预期的慢操作,用的人容易踩坑。这种情况还是老样子写个 loadXxx() 或 fetchXxx() 方法,明确表达”这会触发 IO”。

二是有副作用的 getter。老代码里偶尔有 getXxx() 里插日志或者更新缓存的行为,这类绝不能用 get hook 重写——因为虚拟属性一旦被访问就会触发,调用方根本不知道会有什么后果。

三是复杂的多字段联动。如果 set 某个字段要同时更新另外三个字段,那大概率不适合用 hook,因为这样的操作更像一次”业务动作”而不是”属性赋值”。可以保留原来的 setter 方法,只是让 hook 只负责校验。

我们还留了一个 applyPromotion(Coupon $coupon) 方法,因为应用优惠券涉及”计算 + 校验 + 记录日志”多个动作,用 hook 处理反而会隐晦化。

升级前需要确认的事

PHP 8.4 不是个”改个版本号就能上”的版本。实际动手前,有几件事必须先确认:

一是序列化库的兼容性。Symfony Serializer、JMS Serializer、Laravel 的 Eloquent 都涉及反射读写属性。前面讲的”反射触发 hook”那一坑就是因为这个。升级前跑一遍完整测试,尤其是涉及对象反序列化、审计快照、批量导入的用例。

二是静态分析工具的版本。PHPStan 从 2.x 开始完整支持 hook 的类型推断。如果你的项目还在 PHPStan 1.x,会得到大量误报。Psalm 也需要跟着升级。

三是 ORM 的版本。Doctrine ORM 3.3 之后对 hook 的支持才好一些。之前如果用 ReflectionProperty 强行读属性,会触发 hook 误校验。我们项目用的是自己写的简单 ORM,反而避开了这个坑。

四是扩展。xdebug 我没试过,但听说对 hook 里下单点支持得还不太全。如果你依赖 xdebug 调试,先升级到最新版本再说。

最后

这次重构整体花了两天改代码、一天调试、一天测试。上线之后一共撞了上面六个坑,其中前两个在 demo 阶段就发现了,后面四个都是上线后才冒出来的。

现在这块代码一直在线上跑着,最近一次改动加了个优惠券字段,改动量从老代码时代的”改 4 个方法 + 补 3 处调用”变成了”加一个属性 + 改一个 toArray”。这个变化我觉得比那些具体的性能数字更值得。

但我也不建议你在项目里无脑推 Property Hooks。它是一个”让属性访问更像属性”的工具,适合用在建模清晰、职责单一的字段上。碰到复杂的业务逻辑,该写方法还是写方法。哪怕你的 getter 方法有 500 行,也不是靠 hook 来解决的最优解。

过度使用 hook 会把类变成一个隐藏着各种副作用的黑盒——因为读 $order->xxx 的时候你根本不知道内部是好几次计算还是查了一次缓存。什么时候用 hook、什么时候用方法,心里要有一条清晰的界线。

PHP 8.4 Property Hooks 落地:订单价格模型重构与六个坑点复盘
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 Property Hooks 落地:订单价格模型重构与六个坑点复盘 https://www.taomawang.com/server/php/2958.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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