Zsens Admin 插件选项序列化陷阱:我因 `update_option` 自动序列化对象后手动 `unserialize` 却丢了 `__wakeup`,导致依赖注入容器"假死"三小时
昨晚重构配置模块时踩了个极其隐蔽的坑,跟弱类型无关(那篇已经写过了),是 PHP 序列化机制跟 WordPress 选项存储的"默契配合"出了岔子。直接上代码。
❌ 错误写法:我最初的"优雅"封装
我造了个简单的 DI 容器,想把它整个塞进选项里,下次直接取出来用:
class Zsens_Container {
private array $bindings = [];
private array $resolved = [];
public function bind(string $abstract, callable $concrete): void {
$this->bindings[$abstract] = $concrete;
}
public function make(string $abstract) {
if (isset($this->resolved[$abstract])) {
return $this->resolved[$abstract];
}
$this->resolved[$abstract] = ($this->bindings[$abstract])($this);
return $this->resolved[$abstract];
}
// 故意没写 __sleep / __wakeup,觉得 PHP 默认行为够用了
}
// 初始化时存入
$container = new Zsens_Container();
$container->bind('logger', fn() => new Zsens_Logger());
update_option('zsens_container', $container); // WordPress 会自动序列化
// 后续读取时
$container = get_option('zsens_container'); // 自动反序列化
$logger = $container->make('logger'); // 💥 Fatal error: bindings 是空的?!
诡异的是:$container 能取出来,类型也对,但 make() 一跑就报 "Undefined array key" 或者返回 null。我一度怀疑是 Redis 对象缓存把未完整序列化的数据吞了,排查半小时才发现根本不是缓存的问题。
🔍 根因:默认序列化只抓属性,闭包直接阵亡
PHP 的 serialize() 遇到 callable(尤其是闭包)会直接报错或跳过。WordPress 的 update_option 底层走 maybe_serialize(),它不会帮你检查对象里有什么不可序列化的东西——闭包被静默丢弃了。bindings 数组里存的是 callable,序列化后变成空数组或者残缺结构,反序列化回来自然啥也找不到。
更坑的是,我本地开发环境没开 serialize_precision 相关报错,线上才炸。
✅ 正确写法:拆分存储 + 延迟重建
别存容器实例,存"配方",用的时候重新烘焙:
class Zsens_Container_Registry {
private static array $blueprints = [];
// 只存可序列化的"配方描述",拒绝闭包
public static function register(string $abstract, string $factory_class, string $factory_method): void {
self::$blueprints[$abstract] = [
'class' => $factory_class,
'method' => $factory_method,
];
}
// 运行时重建容器
public static function rebuild(): Zsens_Container {
$container = new Zsens_Container();
foreach (self::$blueprints as $abstract => $recipe) {
$factory = [$recipe['class'], $recipe['method']];
$container->bind($abstract, fn() => call_user_func($factory));
}
return $container;
}
}
// 初始化注册(这部分可以序列化存选项)
Zsens_Container_Registry::register('logger', Zsens_Logger_Factory::class, 'create');
// 存的是纯数组,闭包在重建时才生成
update_option('zsens_container_blueprints', Zsens_Container_Registry::get_blueprints());
// 读取时
$blueprints = get_option('zsens_container_blueprints');
Zsens_Container_Registry::set_blueprints($blueprints);
$container = Zsens_Container_Registry::rebuild(); // 闭包在这里新鲜出炉
如果确实要存对象且含闭包,上 opis/closure 库做闭包序列化,或者干脆别存对象——WordPress 选项表不是对象池。
一个快速自检的野路子
在存选项前加道安检:
function zsens_assert_serializable($data): void {
$serialized = serialize($data);
$roundtrip = unserialize($serialized);
if ($roundtrip !== $data) { // 严格比较,对象用 == 会翻车
// 或者对对象逐个属性校验
throw new RuntimeException('数据无法完整序列化往返');
}
}
闭包、资源句柄、SplFileInfo 这类东西会直接让 serialize() 抛异常,但有些类型(比如某些 DateTime 子类)能序列化却丢状态,严格比较能逮住。
你们有没有遇到过序列化后"看着像活的,其实已经死了"的对象?我这次是真被 var_dump 输出的正常类名骗了,直到用 ReflectionClass 扫属性才发现 bindings 是空的。