PHP 8.4 属性钩子实战:把订单校验从控制器收回到对象里

2026-11-13 0 612

年初接手一个商城后台的导入模块,代码不长,但读起来很累。一个 OrderItem 类,十几行属性声明,加二十几个 getter 和 setter,每个 setter 里塞两三行校验,再加几个方法负责把校验错误攒成数组返回。控制器里拿到数据后先调一遍 validate(),通过了才敢拿去生成订单。

问题出在某次批量导入。运营传了一份两千行的 CSV,其中十几行金额写成了负数,代码一路跑到生成订单才炸,报出来的堆栈指向支付模块,跟 CSV 一点都不像。排查花了半小时,最后发现是某个 setter 忘了写校验——准确地说,是没人想起那个字段也需要校验。

这类”校验散落”的毛病,PHP 8.4 的属性钩子正好能治。这篇文章记录我用它重构这个模块的完整过程,包括几个让我调试到半夜的细节。

先看看老写法到底哪里别扭

重构前的一部分代码,大概是这个形状:

<?php
final class OrderItem
{
private string $sku;
private int $quantity;
private int $unitPrice;

public function setSku(string $sku): void
{
$sku = trim($sku);
if ($sku === '') {
throw new InvalidArgumentException('SKU 不能为空');
}
$this->sku = $sku;
}

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

public function setQuantity(int $quantity): void
{
if ($quantity < 1 || $quantity > 999) {
throw new InvalidArgumentException('数量超范围');
}
$this->quantity = $quantity;
}

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

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

// ... unitPrice 还有一堆
}
</pre>

这些代码本身没错,问题在于它有三个口头约定,全靠人记:一,所有写入必须走 setter;二,所有校验必须在 setter 里;三,所有派生值必须走 get 方法不能直接算。三个人维护这个类,总有人会绕过其中一条。

PHP 8.4 把这三条约定变成了语言级别的规则。

钩子的基本形态

属性声明后面跟一对花括号,里面写 get 和 set,这两个就是钩子。最常见的写法是箭头简写,适合只有一行逻辑的场景:

<?php
final class Tag
{
public string $label {
set => trim($value);
}
}
</pre>

这段代码的效果是:每次外部给 $label 赋值,trim 都会自动跑一遍。注意 set => 表达式 这种简写的语义——表达式的返回值会被写进属性。你不用手动赋值。

需要多行逻辑就用花括号。这时候必须自己写赋值语句,而且赋值的目标就是属性本身:

<?php
final class Tag
{
public string $label {
set {
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('标签不能为空');
}
$this->label = $value;
}
}
}
</pre>

这里有个容易看懵的地方:钩子里写 $this->label = $value 为什么不会无限递归?因为 PHP 规定,在钩子内部对同一属性的读写直接落到后备存储上,不再触发钩子。如果不这么设计,钩子根本没法正常写。第一次看到这段代码时我反复确认了这个规则,才敢放心用。

动手:一个合法的金额对象

订单里金额是最不该出错的东西,先把它单独拎出来做值对象。这里同时用上 8.4 的两个特性:钩子处理归一化与校验,不对称可见性管住写入入口。

<?php

final class Money
{
public private(set) int $amount;
public private(set) string $currency;

public function __construct(int $amount, string $currency = 'CNY')
{
$this->currency = $currency;
$this->amount = $amount;
}

public int $amount {
set {
if ($value < 0) {
throw new InvalidArgumentException("金额不能为负数,收到 {$value}");
}
$this->amount = $value;
}
}

public string $currency {
set {
$code = strtoupper(trim($value));
if (preg_match('/^[A-Z]{3}$/', $code) !== 1) {
throw new InvalidArgumentException("币种代码非法:{$value}");
}
$this->currency = $code;
}
}

public string $display {
get => number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
</pre>

几个点值得展开。

public private(set) 的意思是"读公开、写私有"。外部代码可以随便读 $money->amount,但一旦尝试写入就会报错。写入权限只留给类自身。这就把老代码里"必须走 setter"的口头约定变成了编译期事实——想绕过都没有入口。

构造函数里的两行赋值,看起来像无视了 private(set),其实是合法的:类内部作用域,本来就允许写。而且这两行赋值会正常触发钩子,所以 new Money(-100) 会立刻抛出异常,不是一个非法对象悄悄流到下游。

最后那个 $display 是另一种形态。它只有 get 没有 set,PHP 称之为虚拟属性,没有后备存储,值每次访问时现算。你可以把它当成一个不用写括号的 getter。

为什么校验要放钩子而不是构造函数

这个问题我在代码评审时被问过。构造函数也会跑校验,为什么还要在钩子里重复一遍?

区别在于生命周期。构造函数一辈子只跑一次,而对象在生命周期里可能被修改。假设有个"补发差价"的业务要调 $item->quantity += 1,如果校验只在构造函数里,这一行就绕过了所有检查。钩子则不同,它跟着属性走,无论什么时候被写、从哪一行代码写,都会跑一遍。

换句话说,构造函数保证了"出生时合法",钩子保证了"永远合法"。这两件事不是重复,是不同层面的保障。

把订单行组装起来

有了 Money,订单行就清爽了:

<?php

final class OrderItem
{
public private(set) string $sku;
public private(set) Money $unitPrice;
public private(set) int $quantity;

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

public string $sku {
set {
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('SKU 不能为空');
}
$this->sku = $value;
}
}

public int $quantity {
set {
if ($value < 1 || $value > 999) {
throw new InvalidArgumentException("数量必须在 1 到 999 之间,收到 {$value}");
}
$this->quantity = $value;
}
}

public Money $subtotal {
get => new Money(
$this->unitPrice->amount * $this->quantity,
$this->unitPrice->currency
);
}

public bool $isBulk {
get => $this->quantity >= 50;
}
}
</pre>

现在这个类有个很好的性质:只要你手里有一个 OrderItem 实例,它的所有字段一定合法。下游代码不再需要写防御性的判断,也不用担心某天冒出来一条负数量。

顺带说一句关于虚拟属性的注意事项。$subtotal 每次读取都会 new Money,成本很低,无所谓。但如果有人在钩子里查数据库、读配置文件、调远程接口,那就是灾难——这种开销藏在一次属性读取里,从调用处完全看不出来。虚拟属性适合做纯粹的派生计算,不适合做 I/O。

从 JSON 水合就麻烦了

重构推进到一半,遇到真正的阻力:导入模块是从客户端上传的 JSON 解析出来的,字段类型和行为层传入的不完全一致。比如客户端传的是 "quantity": "12" 这样一个字符串,或者金额传的是浮点的 12.5 而不是分成单位的整数。

以前的做法是在控制器里做一遍宽松转换,现在钩子是严格的,遇到不匹配的类型会直接抛 TypeError。而且批量导入的需求是"尽量导入,报告所有问题行",不是"第一行出错就整体失败"。

所以要有一个专门的转换层,把这层脏活拦在外面,让领域对象保持严格:

<?php

final class ImportReport
{
public array $items = [];
public array $errors = [];

public function accept(OrderItem $item): void
{
$this->items[] = $item;
}

public function reject(int $line, string $reason): void
{
$this->errors[$line] = $reason;
}

public function isEmpty(): bool
{
return $this->items === [];
}
}

function toCents(mixed $raw): int
{
if (is_int($raw)) {
return $raw;
}
if (is_string($raw) && preg_match('/^d+(.d{1,2})?$/', $raw) === 1) {
return (int) round((float) $raw * 100);
}
throw new InvalidArgumentException('金额格式无法识别');
}
</pre>

转换层负责类型宽容,领域对象负责值合法,边界清晰。批量导入的主循环就好写了:

<?php

function importRows(iterable $rows): ImportReport
{
$report = new ImportReport();
$line = 0;

foreach ($rows as $row) {
$line++;

try {
$report->accept(new OrderItem(
sku: (string) ($row['sku'] ?? ''),
unitPrice: new Money(toCents($row['unit_price'] ?? 0), (string) ($row['currency'] ?? 'CNY')),
quantity: (int) ($row['quantity'] ?? 0),
));
} catch (InvalidArgumentException | TypeError $e) {
$report->reject($line, $e->getMessage());
}
}

return $report;
}
</pre>

异常在构造的过程中被捕获,坏行不会污染 $report->items。运营拿到的是"第 37 行金额格式无法识别、第 128 行数量超范围"这种精确反馈,而不是一个模糊的堆栈。

九个坑,按踩到的顺序记

一、readonly 不能带钩子。我一开始想把 Money::$amount 写成 public readonly int $amount 再加钩子,直接语法报错。8.4 明确禁止这两种修饰组合。想要不可变,用 private(set) 加上不开放写方法,效果一样。

二、静态属性没有钩子。public static int $count { get => ... } 是不合法的。静态成员在 PHP 里本来就没有 $this 的上下文,钩子挂不上去。这条限制我理解,但项目里有个用静态属性做缓存计数的类,只能保持原样。

三、钩子里别写 $this->prop = $this->prop。不会无限递归,但也不会做任何事——读的是后备值,写的也是后备值。这类手误编译器不报错,只能靠测试发现。我现在的习惯是 set 钩子里永远用 $value 做右侧,用 $this->prop 做左侧,一眼能看出有没有写反。

四、虚拟属性和 json_encode。虚拟属性没有后备存储,序列化时行为跟普通属性不完全一样,不同版本可能有差异。我的做法是给对外出口的类显式写一个 toArray(),接口返回什么字段由这个方法决定,不依赖序列化器的默认行为。

五、clone 不走 set 钩子。克隆本质是浅拷贝,属性直接复制,钩子不会执行。如果钩子里做了归一化(比如自动 trim),克隆出来的对象是带着已归一化的值的,这没问题;但如果属性本身是对象,克隆后新旧对象共享同一个引用,改一个另一个也变。这个坑其实 PHP 早就有,只是用了钩子之后容易误以为"什么都被管住了"。

六、魔术方法会被钩子顶掉。如果某个属性既有钩子,类里又定义了 __get / __set,访问该属性时钩子优先,魔术方法不会被调用。迁移老代码时这个细节很要命——原来靠 __get 实现懒加载的属性,加上钩子之后就再也不懒加载了。

七、ORM 水合要单独测。主流 ORM 普遍用反射或者直接写属性来填充实体,跟钩子的交互在不同版本、不同驱动下表现未必一致。我是在实体上加钩子之前,先写了一个最小用例跑通读、写、批量水合三条路径,确认没问题才铺开。这一步看着多余,但省掉了后面几天的排查。

八、钩子里的异常类型要收敛。钩子抛 InvalidArgumentException 没问题,但别在钩子里抛业务异常或者 HTTP 异常。一个属性赋值能抛出 HttpResponseException,下游捕获逻辑会乱成一锅粥。领域对象只管值合不合法,不管 HTTP 状态码。

九、性能其实不用担心。钩子是编译期就确定的调用,8.4 的实现里能被内联,开销跟手写方法基本持平,换来的可读性提升是实打实的。我做过一次压测,十万次属性写入,带钩子和带 setter 方法耗时差距在 3% 以内,属噪声级别。

老代码怎么迁过去

两千行的 CSV 模块不能停,所以迁移是分三步做的。

第一步,给现有类加钩子,但保留所有 getter 和 setter,让它们转发。这样新旧两条路径都能跑,测试全绿,先合并进去:

<?php

final class OrderItem
{
private string $sku;

public string $sku {
set {
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('SKU 不能为空');
}
$this->sku = $value;
}
}

// 老接口保留,内部走属性赋值,钩子照样生效
public function setSku(string $sku): void
{
$this->sku = $sku;
}

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

第二步,全局搜 getSku() 这类调用,逐个换成属性访问。这个搜索不是一次性能换完的,我是按模块推进,每换完一个模块就跑一次回归。

第三步,把没人调用的 getter / setter 删掉,同时把 private 改成 private(set),封死绕过的可能。

整个过程大概两周,中间没有一次因为迁移本身导致的线上问题。真要感谢第一步的保守——钩子和老接口并存那段时间,确实发现过两处钩子逻辑跟 setter 逻辑不一致的地方,如果直接一把换掉,那两处就是线上故障。

回头看

重构完最大的变化不是代码变短了,而是新同事上手不用问"改这个字段要走哪个方法"。类本身就是契约:写进去的东西一定经过检查,读出来的东西一定是对的,不用翻文档也不用读实现。

PHP 8.4 这两个特性组合起来,把过去一堆靠自觉维持的约定变成了语言强制。值对象和 DTO 是它们最舒服的落点,往领域实体上用时留个心眼测一下 ORM,至于普通的值类型和配置对象,可以放心大胆地用。

PHP 8.4 属性钩子实战:把订单校验从控制器收回到对象里
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:把订单校验从控制器收回到对象里 https://www.taomawang.com/server/php/3016.html

常见问题

相关文章

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

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