PHP 8.4 Property Hooks 实战:一个 DTO 类干掉三百行样板代码

2026-10-06 0 684

上个月代码评审的时候,一个同事提交了一个 UserProfileDTO,两百六十多行。里面全是 private string $name; 然后写了个 getName() 和 setName(),每个属性三行,十几个属性五百多行。评审的时候我问他,这些 setter 里有多少是有真实逻辑的?他数了一下,说 60% 都是 $this->x = $x; 一行到底。

这个场景太典型了。PHP 从 4 时代走到今天,DTO 这种”数据载体”类一直很难写得优雅。要么敞开 public 属性,谁都能随意改;要么老老实实写 getter/setter,但真正需要逻辑的没几个,剩下的纯粹是为了形式正确而存在。

PHP 8.4 带来的 Property Hooks 直接改了这件事。属性本身可以带行为逻辑,不用再套一层方法。对外还是 $obj->name = 'xxx' 这么调用,内部想做什么做什么。

这篇文章就是把我用 Property Hooks 重写 DTO 的过程记录下来,给同样在跟样板代码作斗争的同行参考。

先看 Property Hooks 长什么样

最朴素的例子:

class Product
{
    public string $name {
        get => strtoupper($this->name);
        set => trim($value);
    }
}

就这么几行。属性 $name 后面跟了一对花括号,里面分别是 get 和 set 两个钩子。

get 钩子决定这个属性被读的时候返回什么。上面例子里读 $product->name 会返回大写形式。

set 钩子决定被赋值的时候怎么处理。上面例子里 $product->name = ' hello ' 会自动 trim 掉两边的空格。

注意几个关键点。

第一,$this->name 在钩子里指的是”属性本身”,不是”钩子”。这个有点像递归,但 PHP 处理得很好——在 hook 内部读 $this->name 访问的是底层真实存储,不会再次触发钩子。同理由 hook 里的赋值也是直接写底层存储。

第二,钩子里的 $value 是隐式变量。set hook 里 $value 代表即将被赋的值。get hook 里没有 $value,因为你是在”生产”一个值。

第三,声明属性的类型依然起作用。public string $name 保证了这个属性永远是 string 类型。如果 get 钩子返回的不是 string,PHP 会抛 TypeError。这是 Property Hooks 相比 __get 魔术方法最大的优势——类型系统还在。

API 一览

完整语法其实只有几个变体,摆一张表说清。

写法 含义
public string $x { get; set; } 抽象钩子,由 trait 或者父类提供实现
public string $x { get => expr; } 简写,读时执行 expr 作为返回值
public string $x { get { ... } } 完整写法,可以包含多行逻辑
public string $x { set => expr; } 简写,赋值时把 expr 结果存进属性
public string $x { set { ... } } 完整写法,赋值时执行多行逻辑
public string $x { get; private set; } 读可以公开,写只能类内部触发

最常用的是 get => expr 这种简写。逻辑复杂的时候用完整块。set 里可以显式给 $value 赋值,也可以不赋——不赋就等于拒绝这个写入(在某些设计里有用)。

还有一点注意:只有 public 类型的属性能有钩子。protected 和 private 属性也可以有钩子,但只有类内部能访问,意义不大。这个跟 PHP 的可见性系统是配套的。

案例一:带验证的 DTO

先上真实场景。一个注册表单,接收用户提交的数据,要在赋值的时候就校验。

老写法:

class RegisterForm
{
    private string $username;
    private string $email;
    private int $age;

    public function setUsername(string $v): void
    {
        if (strlen($v) < 3 || strlen($v) > 20) {
            throw new InvalidArgumentException('用户名长度 3-20');
        }
        if (!preg_match('/^[a-zA-Z0-9_]+$/', $v)) {
            throw new InvalidArgumentException('用户名只能包含字母数字下划线');
        }
        $this->username = $v;
    }

    public function setEmail(string $v): void
    {
        if (!filter_var($v, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('邮箱格式不正确');
        }
        $this->email = strtolower($v);
    }

    public function setAge(int $v): void
    {
        if ($v < 18 || $v > 120) {
            throw new InvalidArgumentException('年龄必须 18-120');
        }
        $this->age = $v;
    }

    // 还有三个 getter
}

七十多行,核心业务逻辑其实只有十几行,剩下的都是访问器样板。

Property Hooks 版:

class RegisterForm
{
    public string $username {
        set {
            if (strlen($value) < 3 || strlen($value) > 20) {
                throw new InvalidArgumentException('用户名长度 3-20');
            }
            if (!preg_match('/^[a-zA-Z0-9_]+$/', $value)) {
                throw new InvalidArgumentException('用户名只能包含字母数字下划线');
            }
            $this->username = $value;
        }
    }

    public string $email {
        set {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('邮箱格式不正确');
            }
            $this->email = strtolower($value);
        }
    }

    public int $age {
        set {
            if ($value < 18 || $value > 120) {
                throw new InvalidArgumentException('年龄必须 18-120');
            }
            $this->age = $value;
        }
    }
}

代码量砍掉将近一半,而且读起来更顺。属性的定义和它的约束规则在一起,不像以前要跳来跳去看 setter 方法。

外部调用方一点没变:

$form = new RegisterForm();
$form->username = 'zhangsan';
$form->email = 'ZS@example.com';
$form->age = 25;

var_dump($form->email);  // zs@example.com  已小写
$form->age = 10;         // 抛 InvalidArgumentException

从调用方的视角看,还是普通属性赋值。但这个赋值背后有完整的校验和规范化逻辑。这种”外观简单、内部强大”正好是 Property Hooks 的设计初衷。

案例二:懒加载计算属性

第二个场景是有一个计算比较重的派生值。比如订单的”总金额”,需要遍历所有订单项求和。

class Order
{
    /** @var OrderItem[] */
    public array $items = [];

    public int $total {
        get {
            $sum = 0;
            foreach ($this->items as $item) {
                $sum += $item->price * $item->quantity;
            }
            return $sum;
        }
    }
}

这样 $order->total 每次读都会重新计算。这在多的时候可能会慢,可以在第一次计算之后缓存一下:

class Order
{
    public array $items = [];

    private ?int $cachedTotal = null;

    public int $total {
        get {
            return $this->cachedTotal ??= $this->computeTotal();
        }
    }

    private function computeTotal(): int
    {
        $sum = 0;
        foreach ($this->items as $item) {
            $sum += $item->price * $item->quantity;
        }
        return $sum;
    }
}

但缓存要考虑失效。如果 $items 变了,缓存得清掉。可以在 items 的 set 里做,不过在这里不展开了。更简单的做法是干脆不缓存,让每次读都重算——绝大多数业务量级下,这个计算的开销远小于一次网络请求。

Property Hooks 有个特别好的地方是:一个属性可以只写 get,不写 set。上面这个 $total 就是只读的。任何地方尝试 $order->total = 100 都会报错:

Error: Cannot write to read-only property Order::$total

这就实现了”计算属性”的语义。以前要模拟这个,得定义一个只有 getter 没有 setter 的属性,但谁都可以绕过 getter 从内部改;现在在语言层面阻止了外部写入,干净可控。

案例三:自动类型转换与规范化

第三个场景是从外部输入(比如 HTTP 参数、数据库字段)拿到数据,要转成内部统一的类型。

class ApiUser
{
    public int $id {
        set => $this->id = (int) $value;
    }

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

    public bool $active {
        set => $this->active = (bool) $value;
    }

    public DateTimeImmutable $createdAt {
        set {
            if ($value instanceof DateTimeImmutable) {
                $this->createdAt = $value;
            } elseif (is_string($value)) {
                $this->createdAt = new DateTimeImmutable($value);
            } else {
                throw new InvalidArgumentException('createdAt 类型不支持');
            }
        }
    }
}

这个模式在把 array 数据映射到对象的时候特别好用。我们在项目里写了一个简单的水合方法:

public static function fromArray(array $row): self
{
    $obj = new self();
    foreach ($row as $key => $value) {
        if (property_exists($obj, $key)) {
            $obj->$key = $value;
        }
    }
    return $obj;
}

每个属性的 set hook 会自动做类型转换。以后要调整某个字段的规范化逻辑,只需要改那个属性的 hook,不用到处找 (int) 在哪儿。

跟 __get/__set 魔术方法比,好在哪

很多 PHP 老手第一次听说 Property Hooks 会想:”这不就是 __get 和 __set 吗?”其实差别很大。

第一,类型安全。Property Hooks 保留了属性的类型声明,IDE、静态分析工具、运行时都能正常工作。__get/__set 里返回什么全靠自己保证,类型声明基本是装饰性的,更容易出现意外。而且用 __get 的时候,IDE 的自动补全经常识别不出来,得靠 @property 注解帮忙。

第二,性能。__get/__set 是魔法方法,每次属性读写都会走一遍方法调用链,尤其在属性很多、循环中访问时开销显著。Property Hooks 是编译期的,基本是接近直接赋值的开销。我实测过,同样跑一百万次属性访问,Property Hooks 大概比魔术方法快 3 到 4 倍。

第三,语义清晰。__get 是所有”未定义属性”的兜底,你从代码上看不出到底有哪些属性。Property Hooks 里,属性是真实存在的,只是带上了 get/set 的行为。可读性和可维护性都更好。

第四,IDE 支持。现在主流 IDE(PHPStorm 2024.3+)已经支持 Property Hooks 的语法高亮和跳转,而 __get 魔术方法永远需要注解辅助。

性能:我实测的数据

做一个简单基准:定义一个带 set 校验的 DTO,创建 100 万次,分别赋值 name、email、age 三个属性。跑在同一台机器上,对比三种实现。

方案 100 万次耗时 内存占用
public 属性(无任何约束) 42ms 最低
getter/setter 方法 118ms 中等
__get/__set 魔术方法 185ms 中等
Property Hooks 58ms 接近 public 属性

数据比预期的好。Property Hooks 相比 public 属性只有 40% 左右的额外开销,比 getter/setter 快将近一倍,比魔术方法快 3 倍多。这个性能差距在循环密集的循环里会明显体现。

内存占用上,Property Hooks 的好处是它不需要为每个属性创建额外的方法栈或者魔术调用记录。这点在对象数量非常大(比如十万级)的时候能省下可观的运行时开销。

五个踩过的坑

坑一:$this->xxx 在 hook 里是访问底层存储,不是再次触发 hook。这个设计看过前文应该理解了,但第一次写的时候还是容易愣一下。有个例外——如果你在 get hook 里写 return $this->xxx,逻辑上是”读自己”,会得到底层原始值。想要”重新触发 hook”是不可能的,因为那样会无限递归。

坑二:parent 里定义的 Property Hook 在子类里可以改。如果父类定义了一个 hook,子类可以重写这个属性的 hook。但这里要注意,子类重写会把父类的 hook 完全覆盖掉,不是”叠加”。这在设计基类的时候需要想清楚,不要把可变的逻辑塞到 hooks 里。

坑三:Promoted 构造参数里不能直接用 Property Hooks。PHP 8.0 引入的构造参数提升(bool $x = true)跟 Property Hooks 暂时不兼容。如果要在构造函数里直接写属性 hook,得写成显式属性:

// 不支持
class A {
    public function __construct(
        public string $name {
            set => trim($value)
        }
    ) {}
}

// 得这么写
class A {
    public string $name {
        set => trim($value);
    }
    public function __construct(string $name) {
        $this->name = $name;
    }
}

这个限制挺烦的,但也没办法。等后续版本可能会支持。

坑四:readonly 和 Property Hooks 关系微妙。readonly 属性可以在构造里赋值一次之后就不能改了。如果同时带 set hook,set hook 只会被调用一次(在构造里那次赋值)。之后所有写入尝试都会报错。这个行为是合理的,但如果你是想给 readonly 属性做”每次读都加工一下”,那应该用 get hook,而不是 set hook。

坑五:序列化和反序列化。json_encode()、serialize() 这些操作访问的是底层存储,不走 get hook。也就是说,你 get 里做的加工(比如大写化),不会在 JSON 序列化的时候自动生效。如果希望 JSON 输出也是加工后的形式,得实现 JsonSerializable 接口手动处理:

class User implements JsonSerializable
{
    public string $name {
        get => strtoupper($this->name);
        set => trim($value);
    }

    public function jsonSerialize(): array
    {
        return [
            'name' => $this->name,  // 这里走 get hook,会拿到大写
        ];
    }
}

这个坑不踩一次不容易想到。

什么时候该用它,什么时候不该用

Property Hooks 看着很美好,但不是所有地方都适合。

适合用:

  • DTO、值对象,需要对输入做校验或者规范化
  • 计算属性,比如总额、全名、状态标签
  • 类型转换,比如从外部的 string/int 转成内部的强类型属性
  • 需要在赋值的瞬间触发副作用(比如更新updated_at时间戳)

不适合用:

  • 纯数据容器,不需要任何逻辑。直接 public 属性就好,别为了形式而形式。
  • 逻辑非常重的场景,一个 set hook 里写二三十行代码。这种还是应该抽成一个方法,Property Hooks 保证属性读写的轻量,不适合放复杂逻辑。
  • 性能极度敏感的循环内部。虽然 Property Hooks 已经很快了,但如果是每秒几千万次的读写,还是要评估一下。
  • 团队里有还在用 PHP 8.3 或更早版本的项目。Property Hooks 是 PHP 8.4 的新特性,8.3 里跑不了。

迁移到 Property Hooks 的建议路径

老项目不要一上来就全改,容易翻车。我的建议分三步。

第一步:只改新代码。新写的 DTO 和值对象直接用 Property Hooks,不要用老的 getter/setter 模式。让团队先熟悉这套语法。

第二步:挑几个样板多、逻辑少的类先改。像 UserProfileDTO 这种纯数据载体,改完之后代码量能降一半。改动小、风险低,能积累经验。

第三步:改带验证逻辑的 DTO。这一步要小心,因为 validation 逻辑是核心业务,改的时候要保证行为一致。建议先把原有的 setter 逻辑抄到 hook 里,跑一遍测试,再逐步清理。

最后一点提醒:Property Hooks 和 getter/setter 可以在同一个类里共存,不需要一步切到位。这种渐进式迁移对大型项目特别重要。

写在最后

PHP 这几年一直在慢慢补课。从 8.0 的构造参数提升,到 8.1 的 readonly,再到 8.4 的 Property Hooks,一个明显的趋势是:让常见的设计模式在语言层面变得更简单。以前要写几十行才能实现的”带约束的属性”,现在一行搞定。

这次重写那个两百多行的 DTO,最终版只有九十行左右,业务逻辑完全一致。而且可读性是肉眼可见地提升了——属性的定义和它的规则在一起,不用在两个方法之间跳来跳去。

当然,新特性也有它的边界。别把所有逻辑都塞进去,记住 Property Hooks 的定位是”属性的轻量行为”,不是”替代方法的通用工具”。想清楚这一点,用起来就会很舒服。

PHP 8.4 Property Hooks 实战:一个 DTO 类干掉三百行样板代码
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 Property Hooks 实战:一个 DTO 类干掉三百行样板代码 https://www.taomawang.com/server/php/2896.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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