用PHP 8.4属性钩子消灭样板代码:一个用户模型的现代化重构实录

2026-07-02 0 931

上周把公司内部一个跑了三年的用户模块升到了PHP 8.4,顺手用属性钩子和非对称可见性把模型层整个翻新了一遍。改完之后代码量少了将近三分之一,可读性反而上了一个台阶。趁热打铁,把重构的思路和踩过的几个小坑记下来,给同样在犹豫要不要尝鲜的朋友一点参考。

为什么以前的写法让人烦躁

先看一眼重构前的User模型,为了不贴出全部代码,只拎几个典型特征:

                
class User {
    private string $firstName;
    private string $lastName;
    private string $email;
    private ?string $phone = null;

    public function getFirstName(): string {
        return $this->firstName;
    }
    public function setFirstName(string $name): void {
        $this->firstName = trim($name);
    }

    public function getLastName(): string {
        return $this->lastName;
    }
    public function setLastName(string $name): void {
        $this->lastName = trim($name);
    }

    public function getEmail(): string {
        return $this->email;
    }
    public function setEmail(string $email): void {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('邮箱格式错误');
        }
        $this->email = $email;
    }

    public function getFullName(): string {
        return $this->firstName . ' ' . $this->lastName;
    }

    public function getPhone(): ?string {
        return $this->phone;
    }
    public function setPhone(?string $phone): void {
        $this->phone = $phone;
    }
}
                
            

这段代码一眼扫过去全是 getter 和 setter,真正包含业务约束的只有 setEmail 里的验证。更别扭的是,外部想获取全名还得显式调用 $user->getFullName(),而不是直接 $user->fullName,语义上就差了一截。项目迭代了两年,类似的模型类越来越多,每次加个属性都要复制粘贴一堆方法,团队里也开始有人抱怨“像在写 Java 1.4”。

PHP 8.4 带来的两把新工具

PHP 8.4 去年底发布的时候,属性钩子(Property Hooks)和非对称可见性(Asymmetric Visibility)是讨论得最热闹的两个特性。用最简单的话解释:

  • 属性钩子:让你可以在属性上直接定义 getset 的逻辑,访问的时候还像普通属性一样,不用再写 getXxx()setXxx()
  • 非对称可见性:可以给属性的读和写分别设定不同的访问权限,比如 public private(set) 表示谁都能读,但只能在类内部修改。

这两个特性加在一起,基本上能把传统模型层里的样板代码清掉大半。

开始重构:先拿邮箱开刀

最迫切需要改造的就是带验证逻辑的属性。以 $email 为例,原来的写法必须记住调用 setEmail(),而现在可以直接写成:

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

这里有一个很容易踩进去的坑:钩子内部不能直接操作自身属性名。如果你写成 set => $this->email = $value;,那恭喜你,无限递归马上就来。所以必须用另一个私有属性作为实际存储,官方文档里管它叫“影子属性”。习惯之后其实并不麻烦,命名上简单加个下划线前缀就行。

经过这一改,外部赋值变成了 $user->email = 'test@example.com';,验证自动触发,而且完全不影响读取时的自然感。团队里几个写前端的同事当场就表示“这才是人用的东西”。

计算属性直接挂在类上

原来的 getFullName() 方法也被我用一个只读的属性钩子替代了:

                
public string $fullName {
    get => $this->firstName . ' ' . $this->lastName;
}
                
            

因为只需要拼接两个已有的属性,这里甚至不需要引入额外的存储。使用时直接 $user->fullName,比之前舒服太多。更妙的是,这个属性没有定义 set,尝试给它赋值会直接抛出错误,等于天然防止了误修改。

用非对称可见性把敏感字段锁住

用户模型里还有一些字段希望外部可读,但修改只能在内部进行,比如用户ID或注册时间。以前的做法要么是干脆不提供 setter,要么是在 setter 里判断调用栈,都很别扭。现在可以这么写:

                
public private(set) string $userId;
public private(set) DateTimeImmutable $createdAt;

public function __construct(string $userId) {
    $this->userId = $userId;
    $this->createdAt = new DateTimeImmutable();
}
                
            

public private(set) 这个组合非常直观,一眼就知道这个属性外表可以随便读,但要改只能通过类内部的方法(或者构造函数)。而且它跟属性钩子可以混用,比如想让 $createdAt 在外部获取时自动格式化成字符串:

                
public private(set) string $createdAt {
    get => $this->createdAt->format('Y-m-d H:i:s');
}
                
            

不过这里要注意,钩子和存储属性的名字会冲突。我的做法是把实际存储的 DateTimeImmutable 对象放在一个私有的 $_createdAt 里,然后公开的 $createdAt 只做格式化输出。这样既保证了不可修改,又统一了外部调用接口。

重构后的完整模型长这样

把上面几处改动拼在一起,整个 User 类变成了下面这个样子:

                
class User {
    public private(set) string $userId;
    private DateTimeImmutable $_createdAt;
    public private(set) string $createdAt {
        get => $this->_createdAt->format('Y-m-d H:i:s');
    }

    private string $_firstName = '';
    public string $firstName {
        get => $this->_firstName;
        set(string $value) => $this->_firstName = trim($value);
    }

    private string $_lastName = '';
    public string $lastName {
        get => $this->_lastName;
        set(string $value) => $this->_lastName = trim($value);
    }

    public string $fullName {
        get => $this->firstName . ' ' . $this->lastName;
    }

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

    private ?string $_phone = null;
    public ?string $phone {
        get => $this->_phone;
        set(?string $value) => $this->_phone = $value;
    }

    public function __construct(string $userId) {
        $this->userId = $userId;
        $this->_createdAt = new DateTimeImmutable();
    }
}
                
            

一眼看过去,已经没有 getXxxsetXxx 的方法名干扰视线了,所有访问都统一成了属性操作。而且像邮箱的验证、全名的拼接、创建时间的格式化,全都内聚在属性定义附近,不需要跳来跳去翻方法体。

顺带提一嘴 PHP 8.4 的新数组函数

重构过程中我还顺手把一些数组处理逻辑换成了 PHP 8.4 新增的函数。比如原来需要查用户列表里第一个邮箱包含特定域名的记录,以前用 array_filterreset,现在可以直接用 array_find

                
$targetUser = array_find(
    $users,
    fn(User $u) => str_contains($u->email, '@example.com')
);
                
            

可读性提升很明显。类似的还有 array_anyarray_all,处理条件判断时不用再写冗长的循环,这部分细节就不展开了,但它们和属性钩子搭配起来,能让整个数据操作层的代码变得非常干净。

迁移过程中碰到的几个实际问题

重构完并不是就直接上线了,测试阶段还是暴露了几个点:

  1. 序列化兼容。我们的用户对象有时会缓存到 Redis,切换到属性钩子后 serializeunserialize 的行为跟普通属性完全一致,不用特殊处理,这点比预想的顺利。
  2. 反射的影响。以前有些工具类通过 ReflectionProperty 直接读写属性,现在因为钩子的存在,setValue 会绕过钩子直接写存储属性,可能导致数据不一致。我把那几处反射调用改成了通过正规的 set 钩子赋值,问题解决。
  3. 代码提示的更新。PhpStorm 需要升级到 2024.3 以后的版本才能完美识别属性钩子和非对称可见性,不然会误报“未定义的属性”。好在这个更新可以无缝跟上去。
  4. 团队接受度。一开始有同事担心“影子属性”会容易混淆,后来我们约定所有底层存储属性统一用 _ 前缀,并且加上 private 修饰符,习惯之后反而成了代码规范的一部分。

什么时候该用,什么时候先别急

属性钩子适合的场景非常明确:需要验证、需要转换、需要计算值的属性,以及只读或只写限制清晰的字段。如果属性只是简单的存取值,没必要强行套一层钩子,直接用 public private(set) 或者普通属性就好。

另外如果你的项目还在跑 PHP 8.1 或 8.2,并且短期内没法升级,那么这篇文章就当提前看了个未来。实际上从 8.2 跳 8.4 的迁移成本并不高,框架层面 Laravel 11 和 Symfony 7 都已经完整支持,唯一需要留意的可能就是老旧扩展的兼容,这部分根据自身情况搞定就行。

写在最后

这次重构前后花了不到一个下午,但给后续开发带来的爽感是持续的。现在给 User 类增加新字段,不用再纠结要不要写 getter 还是 setter,直接根据需求选择钩子或可见性,代码量骤减,出错概率也低了不少。

属性钩子和非对称可见性算不上什么颠覆性的概念,但它们切中了日常开发里最琐碎的那部分重复劳动。如果你手头正好有 PHP 8.4 的环境,强烈建议挑一两个核心模型试试水,改完之后大概率会跟我一样,再也回不去以前那种满屏 get/set 的日子了。

用PHP 8.4属性钩子消灭样板代码:一个用户模型的现代化重构实录
收藏 (0) 打赏

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

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

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

淘吗网 php 用PHP 8.4属性钩子消灭样板代码:一个用户模型的现代化重构实录 https://www.taomawang.com/server/php/2307.html

常见问题

相关文章

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

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