PHP 8.4 Property Hooks 实战:重构多租户配置中心,顺带踩了 4 个坑

2026-09-15 0 750

我们做的是一个多租户 SaaS,每个租户的第三方服务配置——短信服务商、对象存储 endpoint、支付渠道、限流配额这些——都塞在一张表的一个 JSON 字段里。前端改配置走的是后台表单,后端读配置走的是一个叫 TenantConfig 的类。

这个类从项目第一期就在,写法非常传统:私有数组存原始数据,二十多个 getter/setter 方法。前两年相安无事,直到最近做配置审计,发现数据库里有几条脏数据——sms_provider 字段的值是 aliyun ,末尾带空格,还有一个租户的 rate_limit 存成了字符串 "500"。查了一圈,结论是这些数据不是从我们的接口写进去的,而是后来有人直接改数据库改出来的。也就是说,setter 里的校验逻辑挡不住绕过 API 的写入。

重构的契机正好碰上 PHP 8.4 发布。这个版本给了三个我一直很想要的东西:Property Hooks、不对称可见性、还有 Lazy Objects。这篇文章记录的是把 TenantConfig 从传统 getter/setter 迁移到新写法的完整过程,也包括中间遇到的几个不那么明显的问题。

先看看原来长什么样

改造前的代码没什么好说的,就是那种写了三遍的重复:

<?php

declare(strict_types=1);

final class TenantConfig
{
    /** @var array<string, mixed> */
    private array $data;

    public function __construct(array $data)
    {
        $this->data = $data;
    }

    public function getSmsProvider(): string
    {
        return $this->data['sms_provider'] ?? 'aliyun';
    }

    public function setSmsProvider(string $value): void
    {
        if (!in_array($value, ['aliyun', 'tencent', 'huawei'], true)) {
            throw new InvalidArgumentException("未知的短信服务商: {$value}");
        }
        $this->data['sms_provider'] = $value;
    }

    public function getSmsDailyQuota(): int
    {
        return (int) ($this->data['sms_daily_quota'] ?? 1000);
    }

    public function setSmsDailyQuota(int $value): void
    {
        if ($value < 0 || $value > 1_000_000) {
            throw new InvalidArgumentException("配额必须在 0 到 1000000 之间");
        }
        $this->data['sms_daily_quota'] = $value;
    }

    // ... 后面还有十几个字段,写法一模一样
}

用起来大概是这样:

$config = new TenantConfig($row['config_json']);
$config->setSmsProvider('tencent');
$provider = $config->getSmsProvider();

这个方法能用,但有几个我很不爽的地方,而且重构的时候它们全都变成了障碍:

  • 每加一个字段,要在三个地方改代码:属性注释、getter、setter。加校验的时候还要想一下是放在 setter 里还是外面。
  • $data 是个混合数组,IDE 完全补全不出来,$config->getSmsDailyQuota() 这种还好,但内部访问 $this->data['sms_daily_quota'] 的时候类型全是 mixed
  • 想做”配置项被修改时打审计日志”这个需求,得挨个 setter 去加。
  • 序列化到 Redis 缓存的时候,getter 返回的是转换后的值,比如 int,但内部 $data 里存的可能是字符串,反序列化回来两边对不上。

Property Hooks:把 getter/setter 折叠进属性声明

PHP 8.4 的 Property Hooks 允许在一个属性声明后面直接写 get 和 set 逻辑。语法是这样的:

public string $smsProvider = 'aliyun' {
    set (string|SmsProvider $value) {
        $enum = $value instanceof SmsProvider ? $value : SmsProvider::tryFrom($value);
        if ($enum === null) {
            throw new InvalidArgumentException("未知的短信服务商: {$value}");
        }
        $this->smsProvider = $enum->value;
    }
}

有几个语法上的点得说明一下,第一次看容易懵:

set hook 内部的 $this->smsProvider = ... 不会递归触发 hook。PHP 在 hook 内部会把对同名属性的赋值当作写底层存储,这是语言层面的规定,不是编译器优化。所以不需要像 Java 那样搞个 this.field = field 去区分内外。

属性的默认值写在 hook 外面。上面 = 'aliyun' 的位置不能挪到 hook 里面去。

set hook 的参数类型可以放宽。我写的是 string|SmsProvider,让调用方既能传枚举也能传字符串。这个在迁移期特别有用,因为老代码里一堆地方写的是字符串字面量。

顺手加个枚举

既然都要改了,索性把短信服务商做成 enum,这样校验逻辑本身就能被编译器管一部分:

enum SmsProvider: string
{
    case Aliyun = 'aliyun';
    case Tencent = 'tencent';
    case Huawei = 'huawei';
}

配上 hooks 之后,赋值 $config->smsProvider = 'aliyun'$config->smsProvider = SmsProvider::Aliyun 都能跑,但存进去的永远是规范化后的字符串。原来那种末尾带空格的值,现在会在 tryFrom 那一步被拦下来——因为 tryFrom(' aliyun') 返回 null,直接抛异常。

坑一:unserialize 会绕过 set hook

这是我在写单元测试的时候发现的,而且差点让它上了线。

改造完之后,我顺手把配置对象序列化到 Redis 的逻辑也改了,想的是”反正 set hook 会做校验,缓存里存什么格式都行”。测试的时候我手动把一个缓存内容改成了一个非法值 smsProvider: "fake-vendor",然后从缓存反序列化,期望它抛异常。

结果没抛。对象拿出来了,smsProvider 就是字符串 "fake-vendor",set hook 根本没被执行。

翻了一下 RFC 才知道这是设计使然:unserialize()ReflectionProperty::setValue() 都是直接写底层存储,不经过 hook。理由是性能——序列化一个包含几百个对象的大状态时,如果每个属性都跑一遍 hook,开销会很难接受。而且从语义上说,反序列化本来就是”恢复状态”,不是”设置属性”。

这个决定在语言设计上说得通,但对我们这种”用属性 set hook 做业务校验”的场景来说就是个坑:校验只能保证从代码路径进来的数据合法,缓存、数据库读出来的数据它管不着

我们的处理方式是加一个显式的 validate() 方法,在构造和反序列化之后都调一次:

final class TenantConfig
{
    // ... 属性定义

    /**
     * 反序列化之后不会触发 set hook,必须手动调用一次。
     */
    public function validate(): void
    {
        // 用一个临时值走一遍 set hook 的校验逻辑
        // 注意:这里故意触发 hook,所以不能直接写属性
        $this->smsProvider = $this->smsProvider;
        $this->smsDailyQuota = $this->smsDailyQuota;
        // ...
    }
}

看起来有点蠢,但确实有效。$this->smsProvider = $this->smsProvider 读的时候走 get hook,写的时候走 set hook,等于把校验逻辑跑了一遍。更好的做法是把校验逻辑抽成一个静态方法,两边都调用,但对这个项目来说上面这种写法改动最小。

后来我们干脆放弃了 serialize/unserialize,改用 json_encode + json_decode 配合一个 fromArray() 构造函数。fromArray() 里对每个字段用属性赋值,走的是正常 set hook 路径,校验自然生效。代价是从缓存恢复对象要重新解析一次 JSON,但配置对象不大,这点开销可以接受。

坑二:readonly 和 hooks 不能同时用

我想把 tenantId 做成只读的,第一反应是加 readonly

// 会报 Fatal error
public readonly string $tenantId {
    set (string $value) {
        // ...
    }
}

PHP 直接拒绝了:Cannot declare hooked property as readonly。理由其实也能理解——readonly 的语义是”只能赋值一次,之后任何写入都报错”,而 hook 的语义是”写入时执行一段逻辑”,两者叠加起来语义会很别扭,PHP 团队干脆禁掉了。

替代方案是不对称可见性,这个是 PHP 8.4 的另一个新特性:

public private(set) string $tenantId;

翻译成人话就是:读权限是 public,写权限是 private。类内部的代码可以赋值,外部代码读得到但改不了。这比 readonly 灵活,因为 readonly 连构造函数之后的一次性赋值都不允许(严格说是只能在声明的作用域里赋一次值),而不对称可见性只是限制访问范围。

顺便说一句,不对称可见性可以和 hooks 组合,这点很有用。比如 API Key 这个字段,要求外部只读,但内部赋值的时候要校验长度:

public private(set) string $apiKey {
    set (string $value) {
        if (strlen($value) < 16) {
            throw new InvalidArgumentException('API Key 长度不能少于 16 位');
        }
        $this->apiKey = $value;
    }
}

有一点需要注意:private(set) 限制的是访问范围,不是”只能赋值一次”。类内部的任何方法都能反复赋值,每次都会触发 set hook。如果你想要”只能设一次”的语义,得自己加个守卫标志位。

坑三:virtual property 的 get hook 会被 serialize 触发

文章前面提到有个属性 smsProviderLabel,我是用纯 get hook 写的:

public string $smsProviderLabel {
    get => match ($this->smsProvider) {
        'aliyun'  => '阿里云短信',
        'tencent' => '腾讯云短信',
        'huawei'  => '华为云短信',
    };
}

这个属性在 hook 体内没有访问 $this->smsProviderLabel,所以 PHP 编译器会把它识别成一个 virtual property,不分配存储空间。用起来和普通属性完全一样:$config->smsProviderLabel 直接拿中文字符串。

问题出现在序列化上。原来的 serialize($config) 会遍历所有属性,遇到 virtual property 就调用 get hook 拿值,然后把这个值序列化进去。也就是说,smsProviderLabel 这个本应由 smsProvider 推导出来的东西,被固化进了缓存文件。

这带来一个很隐蔽的后果:如果缓存文件是三天前写的,那三天后反序列化出来的 smsProviderLabel 还是三天前的中文名。假设这三天里我们改过 smsProvider 到中文的映射表,那 smsProvidersmsProviderLabel 就对不上了——但你只看数据显示是正常的,因为 smsProvider 本身没问题。

解决办法是把 virtual property 排除序列化。用 __serialize() 显式指定要写什么:

public function __serialize(): array
{
    return [
        'tenantId'       => $this->tenantId,
        'smsProvider'    => $this->smsProvider,
        'smsDailyQuota'  => $this->smsDailyQuota,
        'apiKey'         => $this->apiKey,
        // 派生属性不写进去
    ];
}

public function __unserialize(array $data): void
{
    $this->tenantId      = $data['tenantId'];
    $this->smsProvider   = $data['smsProvider'];
    $this->smsDailyQuota = $data['smsDailyQuota'];
    $this->apiKey        = $data['apiKey'];
}

注意 __unserialize 里的赋值走的是正常属性路径,会触发 set hook,所以校验是生效的。前面说 unserialize 绕过 hook 的问题,如果用了 __unserialize 就不存在了——因为绕过的只是自动属性填充,手动赋值还是正常的。

Lazy Objects:把用不上的初始化往后推

TenantConfig 里有一块配置是定价策略,需要调外部服务拉数据、算缓存。这个操作本身要花几百毫秒,但绝大多数请求根本用不到定价——比如只是查一下短信配额。原来的做法是在构造函数里判断”如果调用方标记了 $needPricing 才初始化”,用起来很别扭。

PHP 8.4 的 Lazy Objects 正好解决这个问题。它有两种模式,鬼影对象(Ghost)和代理对象(Proxy)。我们用的是 Ghost,也就是”对象先造出来,但属性没填,第一次访问属性时才真正初始化”:

$reflector = new ReflectionClass(PricingStrategy::class);

$pricing = $reflector->newLazyGhost(function (PricingStrategy $obj) use ($tenantId) {
    $obj->__construct(
        client: PricingApiClient::forTenant($tenantId),
        cache: new RedisCache(...),
    );
});

// 此刻什么也没发生,构造逻辑没跑
// 直到第一次读到某个属性,才会真正触发初始化
$strategy->discountRate;

用起来和普通对象完全一样,调用方感知不到它是 lazy 的。真正触发初始化的时机是访问任何一个属性,包括读、写、isset() 等等。对于这个定价对象来说,很多请求从头到尾都不会碰它,初始化成本就被彻底省掉了。

有一点要注意:如果对象实现了某个接口,或者被 instanceof 检查过类型,Lazy Ghost 会保留原始类型信息,不用做额外处理。但如果代码里用 get_class() 拿到类名去做反射,拿到的也是真实类名,不是代理类。

我们用 Lazy Objects 重构之后,配置加载的平均耗时从 320ms 下降到了 80ms 左右,因为省掉了定价对象的构造。这块开销在配置文件里不明显,但 QPS 起来之后累加起来相当可观。

完整的 TenantConfig

把上面几次改动合起来,最终的样子大致是这样:

<?php

declare(strict_types=1);

namespace AppTenant;

use InvalidArgumentException;

enum SmsProvider: string
{
    case Aliyun  = 'aliyun';
    case Tencent = 'tencent';
    case Huawei  = 'huawei';
}

final class TenantConfig
{
    public private(set) string $tenantId;

    public string $smsProvider = 'aliyun' {
        set (string|SmsProvider $value) {
            $enum = $value instanceof SmsProvider
                ? $value
                : SmsProvider::tryFrom($value);

            if ($enum === null) {
                throw new InvalidArgumentException("未知的短信服务商: {$value}");
            }
            $this->smsProvider = $enum->value;
        }
    }

    public int $smsDailyQuota = 1000 {
        set (int $value) {
            if ($value < 0 || $value > 1_000_000) {
                throw new InvalidArgumentException(
                    "短信配额必须在 0 到 1000000 之间,收到 {$value}"
                );
            }
            $this->smsDailyQuota = $value;
        }
    }

    public private(set) string $apiKey = '' {
        set (string $value) {
            if ($value !== '' && strlen($value) < 16) {
                throw new InvalidArgumentException('API Key 长度不能少于 16 位');
            }
            $this->apiKey = $value;
        }
    }

    // 派生属性,无底层存储
    public string $smsProviderLabel {
        get => match ($this->smsProvider) {
            'aliyun'  => '阿里云短信',
            'tencent' => '腾讯云短信',
            'huawei'  => '华为云短信',
        };
    }

    private function __construct(string $tenantId)
    {
        $this->tenantId = $tenantId;
    }

    /**
     * 从数据库 JSON 构造。所有字段此时已经做了类型转换,
     * 走属性赋值会触发 set hook,校验是生效的。
     */
    public static function fromArray(string $tenantId, array $row): self
    {
        $cfg = new self($tenantId);

        if (isset($row['sms_provider'])) {
            $cfg->smsProvider = (string) $row['sms_provider'];
        }
        if (isset($row['sms_daily_quota'])) {
            $cfg->smsDailyQuota = (int) $row['sms_daily_quota'];
        }
        if (isset($row['api_key'])) {
            $cfg->apiKey = (string) $row['api_key'];
        }

        return $cfg;
    }

    public function __serialize(): array
    {
        return [
            'tenantId'       => $this->tenantId,
            'smsProvider'    => $this->smsProvider,
            'smsDailyQuota'  => $this->smsDailyQuota,
            'apiKey'         => $this->apiKey,
        ];
    }

    public function __unserialize(array $data): void
    {
        $this->tenantId      = $data['tenantId'];
        $this->smsProvider   = $data['smsProvider'];
        $this->smsDailyQuota = $data['smsDailyQuota'];
        $this->apiKey        = $data['apiKey'];
    }
}

使用方式:

// 从数据库读
$config = TenantConfig::fromArray($tenantId, $row);

// 读属性,和普通属性一样
echo $config->smsProviderLabel;

// 写属性,触发 set hook 里的校验
$config->smsProvider = SmsProvider::Tencent;
$config->smsProvider = 'huawei';

// 下面这行会抛 InvalidArgumentException
$config->smsProvider = 'fake-vendor';

// tenantId 和 apiKey 外部不能写,如下代码会报 Error
// $config->tenantId = 'other-tenant';

坑四:反射读属性值也会触发 get hook

这个坑是我们做后台管理界面的时候发现的,当时在做配置编辑表单的自动生成。

思路很简单:拿到 TenantConfigReflectionClass,遍历所有属性,把属性名和当前值填进表单。代码如下:

$ref = new ReflectionClass(TenantConfig::class);
foreach ($ref->getProperties() as $prop) {
    if ($prop->isPublic()) {
        $fields[$prop->getName()] = $prop->getValue($config);
    }
}

跑出来的表单里多了一个输入框:smsProviderLabel,值显示”阿里云短信”。用户提交表单的时候,这个字段的值也跟着回来了,但我们没法写进配置对象——因为它只有 get hook,赋值会抛 Cannot modify readonly property

问题出在 getValue() 上:它会调用 get hook。对于 virtual property 来说,这相当于”凭空造出了一个值”,导致反射遍历看起来像是存在一个可写属性。

解决方式是在遍历的时候过滤掉”没有 set hook 的属性”。PHP 8.4 提供了对应的反射 API:

foreach ($ref->getProperties() as $prop) {
    if (!$prop->isPublic()) {
        continue;
    }
    // 过滤掉只读或 virtual 属性
    if ($prop->isVirtual() || $prop->isReadOnly()) {
        continue;
    }
    $fields[$prop->getName()] = $prop->getValue($config);
}

isVirtual() 会告诉你这个属性是不是没有底层存储的,isReadOnly() 判断是不是只读。两个都要查,因为 apiKey 是有存储的(不是 virtual),但它外部只读,也不该出现在编辑表单里。

性能到底有没有变好

很多人关心 Property Hooks 相对 getter/setter 的性能。我用一个简单的 benchmark 测过,10 万次读和写的循环,在开启 opcache 和 JIT 的环境下:

操作 getter/setter 方法 Property Hooks
10 万次读 0.0121s 0.0118s
10 万次写(带校验) 0.0234s 0.0219s

差距在噪声范围内,可以认为没有区别。Property Hooks 省掉了一次方法调用,但 hook 本身还是有执行成本的,两边抵消了。

真正省下来的不是 CPU,是代码量。TenantConfig 从 320 行降到了 130 行左右,其中一半减少的是重复的 getter/setter 声明。重构之后想加一个新字段(比如对象存储 bucket),只需要在一个地方写属性声明加校验,不用再想 getter 名字该起成什么、setter 该不该暴露。

另外那个 Lazy Objects 的收益是实打实的:配置加载的平均耗时从 320ms 降到 80ms,这个是因为省掉了一次没有必要的远程调用,跟 hooks 没关系,但顺手一起做了。

什么情况下不要用

Property Hooks 很容易让人上瘾,写完一个类之后会想给所有类都加上。我踩过的坑里有几个是通用的,值得提醒一下:

不要把 hook 用成”隐式的业务逻辑入口”。比如 setStatus() 里顺便发个消息、写个日志、调个外部接口。这类副作用在属性访问里藏得太深,调试的时候很难发现。属性 hook 最好只做纯计算的事情:类型转换、范围校验、格式化。涉及 IO 的一律用显式方法。

不要在 hook 里做重操作。哪怕只是 get,调用方也可能在循环里读几百次,而且看起来像是在读一个廉价的内存字段。同样的逻辑用 getSomething() 方法写出来,调用方至少心里有个数这是函数调用。

如果项目还跑在 PHP 8.3 或更低,别指望能悄悄升级。Property Hooks 需要 8.4,而且 private(set) 语法、new 不带括号这些也是 8.4 才有的。我们这次是先把整个项目的依赖树升级到了 8.4,才有机会用这些特性。如果你的项目还在 8.1,可以先做 Lazy Objects 之外的部分——实际上 Lazy Objects 也是 8.4 才稳定的,8.1 只能用一些第三方库的模拟实现。

别忘了 __serialize__unserialize用了 Property Hooks 又用 serialize,这是最容易出问题的一组组合。要么老老实实写序列化方法,要么换 JSON,别指望默认行为能帮你处理好。

PHP 8.4 Property Hooks 实战:重构多租户配置中心,顺带踩了 4 个坑
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 Property Hooks 实战:重构多租户配置中心,顺带踩了 4 个坑 https://www.taomawang.com/server/php/2759.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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