PHP 8.4 属性钩子实战:让 DTO 自己完成校验、脱敏与惰性计算

2026-09-23 0 638

很长一段时间里,PHP 的对象属性只有两种活法:要么是裸的 public 属性,读写都快,但任何约束都放不进去;要么换成 getter / setter,约束是有了,可调用方得从 $user->name 改成 $user->getName(),一改就是全局搜索替换。

中间那条路是 __get / __set 魔术方法。它确实保留了属性语法,但代价是把所有属性塞进同一个入口,IDE 补全归零、静态分析器基本失明,排查问题时断点还得先猜是哪个属性触发的。用过的人大多不想再用第二次。

PHP 8.4 引入的 Property Hooks属性钩子)把这条路补上了:属性本身可以挂 getset 两段逻辑,外部调用语法完全不变,内部却能插入任意代码。这篇文章不讲语法糖有多甜,直接用一个真实场景把它跑一遍。

一、先花两分钟把语法摸清楚

最小形态长这样:

final 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;
        }
    }
}

几个必须记住的点:

  • get => 表达式 是简写形式,逻辑长了就换成花括号代码块。
  • set 的参数名可以自定义,不写类型时默认沿用属性的类型。写宽一点的类型也是允许的,比如属性是 string,set 参数收 string|Stringable
  • 只定义 set 不定义 get,读取时会落到属性自己的存储上;两边都不定义具体实现、只引用别的字段,那这个属性就是纯虚拟属性,不占内存。
  • 钩子内部访问 $this->同一个属性名,拿到的是底层存储值,不会递归调用钩子。这是整个特性最关键的一条规则,后面还会反复用到。

二、案例背景:一个总是被写坏的用户资料对象

假设我们在做一个 API 项目,需要一个 UserProfile。业务上对它有一堆零散要求:

  • 用户名、邮箱在赋值时就必须合法,不能等到落库才报错;
  • 密码只允许写入,读取应当被明确拒绝,避免日志里打出明文或哈希;
  • 显示名没设置过就从邮箱前缀推导,但推导结果要缓存,不能每次读都算一遍;
  • 头像地址依赖邮箱,邮箱一变就作废重算;
  • 用户 ID 外部只读,只有类内部能改。

传统写法大概率会变成一堆 setter 加两个私有缓存字段,再配一个 refreshDerivedFields() 到处调用。我们看看用属性钩子能压成什么样。

完整实现

<?php
declare(strict_types=1);

final class UserProfile
{
    /** 对外只读,仅类内可写 */
    public private(set) int $id;

    private string  $passwordHash    = '';
    private ?string $displayNameCache = null;
    private ?string $gravatarCache    = null;

    public function __construct(int $id, string $email)
    {
        $this->id    = $id;
        $this->email = $email;
    }

    public string $email {
        set (string $value) {
            $normalized = strtolower(trim($value));

            if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) {
                throw new InvalidArgumentException("邮箱格式不合法:{$value}");
            }

            $this->email = $normalized;

            // 依赖邮箱的缓存整体作废
            $this->displayNameCache = null;
            $this->gravatarCache    = null;
        }
    }

    public string $displayName {
        get => $this->displayNameCache ??= $this->deriveDisplayName();

        set (string $value) {
            $trimmed = trim($value);

            if ($trimmed === '') {
                throw new InvalidArgumentException('显示名不能为空白');
            }

            $this->displayNameCache = $trimmed;
        }
    }

    public string $gravatarUrl {
        get => $this->gravatarCache ??= sprintf(
            'https://www.gravatar.com/avatar/%s?d=identicon&s=80',
            hash('sha256', $this->email)
        );
    }

    public string $password {
        get => throw new LogicException('密码是只写属性,不允许读取');

        set (string $value) {
            if (mb_strlen($value) < 8) {
                throw new InvalidArgumentException('密码长度至少 8 位');
            }

            $this->passwordHash = password_hash($value, PASSWORD_ARGON2ID);
        }
    }

    public function verifyPassword(string $plain): bool
    {
        return password_verify($plain, $this->passwordHash);
    }

    private function deriveDisplayName(): string
    {
        $local = strstr($this->email, '@', true);

        return ($local === false || $local === '') ? 'anonymous' : $local;
    }
}

逐块拆解

id 的只读约束用了一句话:public private(set) int $id;。这是 PHP 8.4 的不对称可见性——读是 public,写是 private。外部写它会直接抛 Error,不需要任何 readonly 技巧,也不需要把整个属性私有化再补一个 getter。

email 只写了 set 钩子,没有写 get。读取时 PHP 直接返回底层存储的值,也就是归一化之后的邮箱。set 钩子里做了三件事:trim + 转小写、格式校验、清空下游缓存。注意最后那两行——这正是过去最容易漏掉的:邮箱变了,用邮箱派生的显示名和头像必须一起失效。把这段逻辑放在 set 钩子里,意味着只要有人改邮箱,缓存就一定跟着清,靠的是语言机制而不是开发者自觉。

displayName 是纯虚拟属性,真正的数据存在 $displayNameCache 里。get 里用 ??= 实现了惰性计算 + 缓存:第一次读会推导并写回缓存,第二次读直接命中。set 里做了空白校验,顺手把推导默认值的可能性关掉。

gravatarUrl 只有 get 钩子,完全是派生数据。它同样做了缓存,并且缓存由 email 的 setter 负责清空。调用方拿到的永远是最新的地址,但只有在真正需要时才算一次 sha256。

password 是只写属性。get 钩子直接 throw,这在 PHP 8 里是合法的,因为 throw 是表达式。set 钩子承担长度校验和哈希。这里有个实际收益:任何试图把 password 打印出来的代码——包括 var_dump、日志序列化、json_encode——都会明确报错,而不是悄悄泄露哈希。同时对象自己保管 $passwordHash,外部拿不到。

三、跑一遍看看效果

$profile = new UserProfile(42, '  Alice@Example.COM ');

echo $profile->email;        // alice@example.com
echo $profile->displayName;  // alice
echo $profile->displayName;  // alice(第二次走缓存)
echo $profile->gravatarUrl;  // https://www.gravatar.com/avatar/...

$profile->displayName = 'Alice Wang';
echo $profile->displayName;  // Alice Wang

$profile->email = 'alice.wang@example.com';
echo $profile->displayName;  // alice.wang(缓存已随邮箱失效)
echo $profile->gravatarUrl;  // 头像地址已更新

$profile->password = 'correct-horse-battery';
var_dump($profile->verifyPassword('correct-horse-battery')); // true

// 下面三行都会抛异常
$profile->email    = 'not-an-email';   // InvalidArgumentException
$profile->password = '123';            // InvalidArgumentException
echo $profile->password;               // LogicException

// 这一行是 Error:set 可见性为 private
$profile->id = 100;

所有约束都在赋值那一刻生效,而且调用方看到的是普通属性语法,没有任何 setXxx() 的痕迹。

四、几个特别容易踩的坑

1. 钩子里访问自身属性拿到的是存储值

这是最反直觉的一条,也是最容易写错的一条。看下面这个错误示范:

public string $email {
    set (string $value) {
        // 这里 $this->email 拿到的是底层存储,不是又一次进入 set
        // 所以这一行是安全的
        $this->email = strtolower(trim($value));
    }
}

很多人的第一反应是”这不就无限递归了吗”。不会。PHP 在钩子内部对同名属性的访问会绕过钩子,直接读写底层存储。理解这点之后,你会发现可以放心地在钩子里读写自己。

但反过来,这也意味着你没法在钩子里调用”另一个钩子”。如果你需要复用逻辑,就老老实实抽一个 private 方法出来。

2. 虚拟属性需要一个真实的私有字段来落数据

只要钩子内部碰了 $this->同名属性,这个属性就有存储;只碰别的字段,它就是虚拟属性。displayNamegravatarUrl 都属于后者,它们的真实数据在 $displayNameCache$gravatarCache 里。命名上建议明确区分,避免读代码的人误以为 $this->displayName 有存储。

3. 序列化和调试工具的行为要亲自验证

虚拟属性不出现在 get_object_vars() 的结果里,而 json_encode 是否包含它、var_export 会不会触发 get 钩子,跟具体版本和工具链有关。如果你的 DTO 要直接返回给前端,建议在项目里固定一套序列化方式,比如显式实现 JsonSerializable,把要暴露的字段列清楚,而不是依赖默认行为。

4. 缓存失效的责任要写清楚

属性钩子的好处是把校验和派生绑在了属性上,但缓存失效这件事,语言帮不了你。上面的例子里,email 的 setter 主动清空了 displayNameCachegravatarCache。如果以后新增了 mailDomain 这样的派生属性,一定要记得回到 email 的 setter 里补一行。这不是属性钩子独有的问题,但把逻辑集中在一个 setter 里,至少比散落在三个地方要好找得多。

五、什么时候该用,什么时候别用

属性钩子适合的场景很明确:约束和派生逻辑天然属于某一个字段。校验邮箱格式、把密码转成哈希、从邮箱推导头像地址,都属于这一类。把逻辑写在字段旁边,比写在三十行之外的 setter 里更好维护。

不该用的场景同样明确:

  • 需要多个字段联动的约束,比如”开始时间必须早于结束时间”。这类逻辑跨字段,写在任何一个钩子里都会显得别扭,老老实实写一个 validate() 方法更清楚。
  • 会触发 IO 的 get 钩子。比如一个 get 里查数据库。属性读写在调用方看来是零成本的,一旦里面藏了 IO,调试时会非常痛苦。惰性计算和惰性加载是两回事,后者请用显式方法。
  • 需要出现在序列化结果里的热字段。如果这个字段每秒被读几万次,而你只是想加个类型转换,那用 set 钩子在写入时转换一次就够了,不要给读取路径加钩子。

另外提醒一句:属性钩子是 PHP 8.4 起才有的语法,老项目升级前先确认 CI 里的 PHP 版本、以及像 PHPStan / Psalm 这类静态分析工具是否已经跟上了对该语法的解析,否则你的类型检查会在钩子处直接失效。

六、小结

Property Hooks 解决的不是”能不能做到”的问题——用 getter / setter 什么都能做到。它解决的是把约束放回它该在的地方。校验逻辑贴在字段上,调用方用属性语法,IDE 和静态分析仍然认得出类型,缓存失效的触发点也集中在同一处。

上面这个 UserProfile 如果改成传统写法,至少会多出四个 setter、两个 getter、一个私有的缓存刷新方法,以及若干处”改完邮箱记得调用 refresh”的口头约定。现在这些全都收进了属性声明里。这大概是 PHP 8.4 里最值得在业务代码中立刻用起来的特性。

PHP 8.4 属性钩子实战:让 DTO 自己完成校验、脱敏与惰性计算
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 属性钩子实战:让 DTO 自己完成校验、脱敏与惰性计算 https://www.taomawang.com/server/php/2803.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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