Zsens Admin 插件选项序列化陷阱:我因 `update_option` 自动序列化对象后手动 `unserialize` 却丢了 `__wakeup`,导致依赖注入容器"假死"三小时

插件开发 37 浏览 0 回复 返回上级

昨晚重构配置模块时踩了个极其隐蔽的坑,跟弱类型无关(那篇已经写过了),是 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 是空的。

评论0
回复 · 0
还没有回复
微信客服 微信客服