PHP 8.4 属性钩子实战:重构订单模型,删掉一半样板代码

2026-10-01 0 320

PHP 8.4 带来的新东西不少,但真正会改变日常写法的,我觉得只有一个:属性钩子(property hooks)。

以前给属性加校验,路径基本是固定的——属性设成 private,另外写一对 getX / setX,然后再满项目找直接访问属性的地方全改掉。钩子把这套流程压缩成了在属性声明后面加一段代码块。听起来像小改动,但写到第五十个实体类的时候,差别就出来了。

下面直接拿一个电商订单模型动手改。命令行的东西不多,重点是代码前后的对比。

先看看重构前的样子

这是一个很典型的订单行类,数量必须大于 0,单价不能为负,小计是算出来的:

<?php

declare(strict_types=1);

namespace Shop;

use InvalidArgumentException;

final class OrderLine
{
    private int $quantity;
    private int $unitPriceCents;

    public function __construct(
        private readonly string $sku,
        int $quantity,
        int $unitPriceCents,
    ) {
        $this->setQuantity($quantity);
        $this->setUnitPriceCents($unitPriceCents);
    }

    public function getSku(): string
    {
        return $this->sku;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }

    public function setQuantity(int $quantity): void
    {
        if ($quantity < 1) {
            throw new InvalidArgumentException("数量必须大于 0,收到 {$quantity}");
        }

        $this->quantity = $quantity;
    }

    public function getUnitPriceCents(): int
    {
        return $this->unitPriceCents;
    }

    public function setUnitPriceCents(int $cents): void
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('单价不能为负数');
        }

        $this->unitPriceCents = $cents;
    }

    public function getSubtotalCents(): int
    {
        return $this->quantity * $this->unitPriceCents;
    }
}

数一下,五十行上下。这里面真正表达业务规则的只有两个 if 和一个乘法,剩下的全是搬运工。

set 钩子:把校验挪到属性声明上

先把数量这个属性改掉:

public int $quantity {
    set (int $value) {
        if ($value < 1) {
            throw new InvalidArgumentException("数量必须大于 0,收到 {$value}");
        }

        $this->quantity = $value;
    }
}

属性类型还是 int,公开可见,外部依然可以写 $line->quantity = 3;。但任何一次写入都会先进到 set 块里。校验没丢,只是位置变了。

这里有个细节得说清楚:在 set 块内部给 $this->quantity 赋值,不会再次触发钩子。PHP 知道当前正在执行这个属性的 set 逻辑,会直接把值写进底层存储。否则这段代码自己就把自己递归死了。刚上手的时候容易在这句话上纠结半天,记住结论就行。

同样的写法套到单价上:

public int $unitPriceCents {
    set (int $value) {
        if ($value < 0) {
            throw new InvalidArgumentException('单价不能为负数');
        }

        $this->unitPriceCents = $value;
    }
}

get 钩子:把计算方法变成“虚拟属性”

小计这种东西最尴尬。它不该被单独存一份,因为数量或单价一变它就过期了;但当方法写又得记成 getSubtotalCents(),读起来跟普通取值没区别,调用方总得留神有没有括号。

属性钩子给了第三种选择——只定义 get,不给存储:

public int $subtotalCents {
    get => $this->quantity * $this->unitPriceCents;
}

这种没有底层存储、每次读取时现算的属性,官方叫虚拟属性(virtual property)。上面用的是箭头写法,表达式的结果就是返回值,适合一行能算完的场景。

稍微复杂一点的,用花括号块:

public int $taxCents {
    get {
        $rate = 0.13;

        return (int) round($this->subtotalCents * $rate);
    }
}

注意这里读的是 $this->subtotalCents,也就是读另一个虚拟属性。虚拟属性之间互相当积木,链子拉多长都行,只要别搞成循环引用。

还有一点值得单拎出来:虚拟属性默认就是只读的,外部写不了。这句话不是靠什么开关实现的,而是因为它压根没有存储槽——你没定义 set,PHP 就不知道这个值该往哪搁。真试着写的话,会直接抛错。

非对称可见性:对外只读,对内可写

这是和属性钩子同批落地的另一个特性,配合起来用特别顺手。

设想订单状态:外部只能看,只有类自己内部的流程方法能改。以前怎么做?private 属性加一个 public 的 getter,又是一坨样板。现在直接在可见性上分家:

public private(set) string $status = 'draft';

private(set) 的意思是:读取按 public 走,写入按 private 走。类外面能拿到状态,但改不了;类内部的方法随便赋值。

把它和虚拟属性拼到一起,就凑出了一个完整的订单聚合类:

<?php

declare(strict_types=1);

namespace Shop;

use LogicException;

final class Order
{
    /** @var list<OrderLine> */
    private array $lines = [];

    public private(set) string $status = 'draft';

    public int $totalCents {
        get {
            $sum = 0;

            foreach ($this->lines as $line) {
                $sum += $line->totalCents;
            }

            return $sum;
        }
    }

    public function addLine(OrderLine $line): void
    {
        if ($this->status !== 'draft') {
            throw new LogicException('订单已提交,不能再追加订单行');
        }

        $this->lines[] = $line;
    }

    public function submit(): void
    {
        if ($this->lines === []) {
            throw new LogicException('空订单不能提交');
        }

        $this->status = 'submitted';
    }

    public function lines(): array
    {
        return $this->lines;
    }
}

调用端跑一遍:

$order = new Order();
$order->addLine(new OrderLine('KEYB-87', 2, 12900));
$order->addLine(new OrderLine('MOUS-11', 1, 4900));

$order->submit();

echo $order->totalCents;

$order->status = 'paid';

前几行都没问题。最后一行会直接报错,提示这个属性的写入权限是 private。以前为了拿到这种保护,得写一个 public 的 getStatus() 加一个 private 的 markStatus(),现在两行声明解决。

重构后的完整订单行

把前面拆开讲的片段收回来,这就是最终的 OrderLine:

<?php

declare(strict_types=1);

namespace Shop;

use InvalidArgumentException;

final class OrderLine
{
    public int $quantity {
        set (int $value) {
            if ($value < 1) {
                throw new InvalidArgumentException("数量必须大于 0,收到 {$value}");
            }

            $this->quantity = $value;
        }
    }

    public int $unitPriceCents {
        set (int $value) {
            if ($value < 0) {
                throw new InvalidArgumentException('单价不能为负数');
            }

            $this->unitPriceCents = $value;
        }
    }

    public int $subtotalCents {
        get => $this->quantity * $this->unitPriceCents;
    }

    public int $taxCents {
        get => (int) round($this->subtotalCents * 0.13);
    }

    public int $totalCents {
        get => $this->subtotalCents + $this->taxCents;
    }

    public function __construct(
        public readonly string $sku,
        int $quantity,
        int $unitPriceCents,
    ) {
        $this->quantity = $quantity;
        $this->unitPriceCents = $unitPriceCents;
    }
}

对比一下开头那版:属性、校验、计算全在同一处,中间没有任何 getXxx / setXxx 介入。构造函数里那两行赋值照旧会走钩子,所以非法数据在对象刚出生的时候就被拦下了,不用等到第一次调用 setter。

顺手补两个测试

行为完全没变,所以原有测试应该原样通过。不过钩子带来了一些以前测不到的角度,值得补上:

use InvalidArgumentException;
use ShopOrderLine;
use PHPUnitFrameworkTestCase;

final class OrderLineTest extends TestCase
{
    public function testConstructorAlsoRunsValidation(): void
    {
        $this->expectException(InvalidArgumentException::class);

        new OrderLine('SKU-1', 0, 100);
    }

    public function testQuantityCanBeChangedThroughPublicWrite(): void
    {
        $line = new OrderLine('SKU-1', 1, 100);

        $line->quantity = 5;

        self::assertSame(5, $line->quantity);
    }

    public function testVirtualPropertiesRecomputeOnEveryRead(): void
    {
        $line = new OrderLine('SKU-1', 3, 1999);

        self::assertSame(5997, $line->subtotalCents);

        $line->quantity = 4;

        self::assertSame(7996, $line->subtotalCents);
    }

    public function testTotalIsNotWritable(): void
    {
        $line = new OrderLine('SKU-1', 1, 100);

        $this->expectException(Error::class);

        $line->totalCents = 999;
    }
}

第三个测试是重点:它验证了小计不是快照,而是每次读取现算。如果哪天有人手贱把它改成普通属性,这条测试会立刻炸。

几个必须提前知道的坑

虚拟属性不进序列化结果。 因为没有底层存储,json_encode($line) 和 get_object_vars($line) 都不会带上 subtotalCents、taxCents、totalCents。做 API 输出的时候不能指望它们自动出现,得在 jsonSerialize() 或者 DTO 组装那一步显式写出来。这一点从传统方法迁移过来的人最容易翻车。

构造函数里的赋值照样走钩子。 这既是好事也是坑。好事是校验覆盖完整;坑在于,如果你的钩子里引用了另一个还没赋值的属性,就会读到未初始化的状态。赋值顺序需要自己心里有数。

低于 8.4 的环境连解析都过不去。 这不是运行时才报错的问题,而是语法层面的东西。老版本 PHP 读到 public int $quantity { 这一行会直接抛语法错误。所以 composer.json 里的 php 约束要改成 "^8.4",CI 镜像也得同步升上去,否则部署时才炸就晚了。

别在 get 钩子里干重活。 每次读取都会重新执行一遍。你要是往里塞个数据库查询或者远程调用,循环里读几次就查几次。真需要缓存,就在类内部自己存一份,钩子里返回缓存值。

什么时候别用钩子

属性钩子解决的是「读写时带一点轻量规则」的问题。如果你的 set 里要发消息、要写日志、要联动另外三个对象的状态,那它就不是一个属性了,是一个业务动作,老老实实写个正经方法,起个能说明意图的名字。

我的建议是,先挑下一个值对象或者实体试,别一上来就大规模翻新。等团队里所有人都习惯了 $line->quantity = 0 会抛异常这件事,再往外铺。

PHP 8.4 属性钩子实战:重构订单模型,删掉一半样板代码
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:重构订单模型,删掉一半样板代码 https://www.taomawang.com/server/php/2845.html

常见问题

相关文章

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

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