我们做的是一个多租户 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 到中文的映射表,那 smsProvider 和 smsProviderLabel 就对不上了——但你只看数据显示是正常的,因为 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
这个坑是我们做后台管理界面的时候发现的,当时在做配置编辑表单的自动生成。
思路很简单:拿到 TenantConfig 的 ReflectionClass,遍历所有属性,把属性名和当前值填进表单。代码如下:
$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,别指望默认行为能帮你处理好。

