PHP 8.4 惰性对象实战:让 ORM 实体和 DI 容器按需加载

2026-09-27 0 252

先说一个查了很久的问题。

项目里有个用户列表页,一次展示 100 条记录,每条只显示头像和昵称。但那个页面加载特别慢,慢到需要 1.8 秒。SQL 日志打开一看,每条记录都在查完整的用户表——包括签名、地址、注册来源、行为标签这些字段,加起来十几个。

真正用到的只有两个。剩下那些字段查出来,序列化进数组,塞进模板,然后在模板里被扔掉。

当时的处理是在 Repository 里加一个 select(),只挑需要的列。改完确实快了,但代价是所有调用方都得知道”我要哪几个字段”。有人调 findForList(),有人调 findForDetail(),方法名越来越长,方法数量也越来越多。三个月之后这个 Repository 长到了六百多行。

问题的本质其实不在查询优化,而在于对象一旦被构造,它的所有字段就必须已经是可用的。你没法表达”这个对象现在只有 id 和昵称,其他字段等你真要用的时候再查”。

PHP 8.4 引入的惰性对象(Lazy Objects)就是来解决这件事的。

一、以前是怎么做懒加载的

在惰性对象出现之前,PHP 里做懒加载基本只有一条路:魔术方法。

class User
{
    private ?array $data = null;

    public function __get(string $name): mixed
    {
        if ($this->data === null) {
            $this->data = $this->repo->fetchFull($this->id);
        }
        return $this->data[$name] ?? null;
    }
}

这套写法能用,但问题一串。属性不再是真实属性,IDE 补全失效,静态分析工具识别不出来,json_encode 拿到的是空对象,isset() 的行为也变得反直觉。而且它只能覆盖”不存在的属性”,如果类里已经声明了真实属性,__get 根本不会被调用。

Doctrine 这类 ORM 用的是另一条路:生成代理子类。每个实体在运行时会得到一个 UserProxy 子类,重写所有 getter,在里面判断是否已初始化。这套方案有效,但需要写文件、需要缓存目录、类名变了之后老缓存会失效,部署时经常要清一遍。

PHP 8.4 的惰性对象把这件事下沉到了引擎层。不需要魔术方法,不需要代码生成,只要几行反射调用。

二、newLazyGhost:对象还在,只是还没填

先看最基础的用法。

class RemoteConfig
{
    private array $data = [];

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }

    public function loadFromApi(): void
    {
        $this->data = json_decode(
            file_get_contents('https://config.internal/api/settings'),
            true
        ) ?: [];
    }
}

这是一个从内网配置中心拉数据的类。构造它本身很便宜,但调用 loadFromApi() 要发一次 HTTP 请求,大概 80 毫秒。

用惰性对象的写法是这样的:

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

$config = $reflector->newLazyGhost(function (RemoteConfig $self) {
    $self->loadFromApi();
});

// 到这为止,一次 HTTP 请求都没有发生
echo "服务已启动n";

// 下面这行才会真的去拉数据
$name = $config->get('site_name', '未配置');

这段代码里 $config 是一个货真价实的 RemoteConfig 实例,instanceof、get_class() 都正常。区别在于它内部的属性槽位处于”未初始化”状态,第一次有人读或者写属性的时候,引擎才回头调用你在 newLazyGhost() 里传进去的那个闭包。

读和写都会触发初始化

这一点很重要,也很容易搞错。

$config = $reflector->newLazyGhost(fn(RemoteConfig $c) => $c->loadFromApi());

$config->get('key');     // 第一次读 → 触发初始化
$config->get('key2');    // 已经初始化了,直接走内存

但如果你这样写:

$config = $reflector->newLazyGhost(fn(RemoteConfig $c) => $c->loadFromApi());

// 想先手动设置一个字段?不行
$config->timeout = 5;   // 这行就会触发初始化

写入也会初始化。原因不难理解——如果不这样的话,就会出现”对象一半是新的、一半是旧的”这种状态,谁也说不清读到的应该是哪一份。引擎选择了一个保守但清晰的规则:只要有人碰属性,就先老实把对象填好。

强制标记为已初始化

有些场景下你不需要跑初始化逻辑,但需要让对象进入”已初始化”状态。比如你已经从别的地方拿到了完整数据:

$reflector->markLazyObjectAsInitialized($config);
$reflector->isUninitializedLazyObject($config);   // false

反过来,想看一个对象当前是不是还处于惰性状态:

if ($reflector->isUninitializedLazyObject($user)) {
    // 还没触发加载
}

这两个方法在写调试工具或者测试的时候会用到。

三、案例一:ORM 的关联对象

把惰性对象用在数据层,收益最直接。

假设有订单和用户两张表:

class User
{
    public int $id;
    public string $nickname;
    public string $email;
    public ?string $avatar = null;
    public string $bio = '';
    public string $createdAt;
}

class Order
{
    public int $id;
    public string $orderNo;
    public int $userId;
    public string $amount;

    private ?User $user = null;

    public function setUser(User $user): void
    {
        $this->user = $user;
    }

    public function user(): User
    {
        if ($this->user === null) {
            throw new RuntimeException('用户未加载');
        }
        return $this->user;
    }
}

Repository 里加一个专门返回”惰性用户”的方法:

class UserRepository
{
    public function __construct(private PDO $pdo) {}

    public function findLazy(int $id): User
    {
        $reflector = new ReflectionClass(User::class);

        return $reflector->newLazyGhost(function (User $user) use ($id) {
            $row = $this->fetchFull($id);

            if ($row === null) {
                throw new RuntimeException("用户 {$id} 不存在");
            }

            $user->id        = (int) $row['id'];
            $user->nickname  = $row['nickname'];
            $user->email     = $row['email'];
            $user->avatar    = $row['avatar'];
            $user->bio       = $row['bio'];
            $user->createdAt = $row['created_at'];
        });
    }

    private function fetchFull(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nickname, email, avatar, bio, created_at
             FROM users WHERE id = ?'
        );
        $stmt->execute([$id]);
        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }
}

然后订单查询里,把用户对象挂上去的时候用 findLazy:

$orders = $pdo->query(
    'SELECT id, order_no, user_id, amount FROM orders ORDER BY id DESC LIMIT 50'
)->fetchAll(PDO::FETCH_ASSOC);

$userRepo = new UserRepository($pdo);

$list = [];
foreach ($orders as $row) {
    $order = new Order();
    $order->id      = (int) $row['id'];
    $order->orderNo = $row['order_no'];
    $order->userId  = (int) $row['user_id'];
    $order->amount  = $row['amount'];
    $order->setUser($userRepo->findLazy($order->userId));

    $list[] = $order;
}

现在看效果:

// 只打印订单号,50 条记录,0 次用户表查询
foreach ($list as $order) {
    echo $order->orderNo . PHP_EOL;
}

// 访问某一条的用户昵称,才会为那一条发一次查询
echo $list[0]->user()->nickname . PHP_EOL;

如果这个列表页只展示订单号,那用户表就一次都不会被查。如果只展示前三条订单的用户信息,那就只有三次查询。

这一点比”一次性全部加载”要省得多,也比”在模板里判断要不要加载”要干净——业务代码里看不到任何 if 判断,调用方像访问普通属性一样访问 nickname,剩下的交给引擎。

初始化里要注意递归

上面那个 fetchFull() 方法里,如果因为某种原因又去构造了这个用户的惰性对象,就会无限递归。写的时候心里要有数:初始化闭包里只做最底层的数据获取,不要再走一遍 Repository 的惰性路径。

一些 ORM 框架会在初始化时加一个递归深度计数,超了就抛异常。自己在项目里实现的话,可以简单用一个静态标记配合 try-finally。

四、newLazyProxy:对象本身也是延迟的

Ghost 的思路是”对象已经存在,只是属性还没填”。Proxy 是另一种思路:先给你一个空壳,真正干活的对象在你第一次用的时候才创建。

class ReportService
{
    public function __construct(
        private Database $db,
        private Cache $cache,
        private LoggerInterface $logger,
    ) {
        // 假设构造函数里有一堆重活
        $this->db->warmUp();
        $this->cache->preloadTemplates();
    }

    public function generate(int $month): array { /* ... */ }
}

这个类的构造函数会做预热,代价不低。如果它在容器里被注册了,但实际上这次请求根本用不到它,就白干了。

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

$service = $reflector->newLazyProxy(function () {
    return new ReportService(
        Container::get(Database::class),
        Container::get(Cache::class),
        Container::get(LoggerInterface::class),
    );
});

// 到这里为止,ReportService 的构造函数还没跑

// 调用方法时才会创建真实对象
$data = $service->generate(3);

注意两个细节。

第一,newLazyProxy 的回调必须返回一个对象,而且类型要和代理的类兼容。它不像 ghost 那样接收一个现成对象去填属性。

第二,代理对象和真实对象是两个不同的实例。$service !== $realService,spl_object_id() 也不一样。这一点在做对象标识比较的时候要注意。

怎么选 ghost 还是 proxy

给一个简单的判断方式:如果你能拿到需要的数据,只是暂时不想花那个代价去填属性——用 ghost。如果你连怎么构造这个对象都还没决定,或者构造过程本身很贵——用 proxy。

还有一条硬性条件:类里如果有只读属性,ghost 基本用不了。因为初始化闭包定义在类外面,没有权限写 readonly。这种情况下 proxy 是唯一的选项,因为它走的是”调用构造函数创建一个新对象”这条路,只读属性在构造函数里正常赋值就行。

给 Service 容器加一层包装

class Container
{
    private array $definitions = [];
    private array $instances = [];

    public function set(string $id, callable $factory): void
    {
        $this->definitions[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }

        $factory = $this->definitions[$id]
            ?? throw new RuntimeException("未注册的服务:{$id}");

        $reflector = new ReflectionClass($id);

        $this->instances[$id] = $reflector->newLazyProxy(
            fn() => $factory($this)
        );

        return $this->instances[$id];
    }
}

注册的时候只是把一个工厂函数存起来,真正的实例化被推迟到了第一次实际使用。整个应用的启动开销可以下降不少——特别是那些只在个别路由里用到的重型服务。

这段代码当然省略了很多东西(循环依赖检测、单例/多例区分、参数注入、生命周期回调),但它说明了一件事:惰性对象让”延迟实例化”从框架黑魔法变成了一个可以自己写的十几行工具。

五、七个实际踩过的坑

1. var_dump 会意外触发加载

调试的时候顺手 var_dump($user),然后就发现本来应该懒加载的查询全部跑了一遍,性能分析图彻底乱掉。原因是 var_dump 要读属性值才能打印,读属性就触发初始化。

想避开这个问题,用 isUninitializedLazyObject() 先判断一下,或者用 markLazyObjectAsInitialized() 在看数据之前先标记掉。

同样的道理,json_encode()、get_object_vars()、以及大部分日志库的属性序列化都会触发。

2. instanceof 和 get_class 不会触发

这两件事不会触发初始化,是好事也是坑。

好处是 if ($user instanceof User) 这种判断很便宜,可以在类型检查之后再决定要不要真的去读字段。

坑在于,如果某个地方依赖 get_class() 来做分支判断,那么不管对象初始化没初始化,返回的都是同一个类名。想区分的话需要显式调用 isUninitializedLazyObject()。

3. 初始化抛异常不会锁定对象

如果你的初始化闭包里发了请求,而这次请求失败了:

$user = $repo->findLazy(42);

try {
    echo $user->nickname;
} catch (RuntimeException $e) {
    // 加载失败
}

// 稍后再试一次
echo $user->nickname;   // 会重新执行初始化闭包

对象会保持在未初始化状态,下一次访问会再跑一次。这个行为有时候正合心意(比如网络抖动之后自动重试),有时候又会造成麻烦(比如初始化闭包里有副作用,被跑了两次)。

需要”失败一次就永久失败”的语义时,在初始化闭包外面加一层状态标记就好。

4. 序列化默认会触发初始化

把一个惰性对象丢进缓存或者队列之前,如果不做处理,序列化过程会把整个对象加载出来。

// 这样写会触发初始化
$payload = serialize($lazyUser);

// 想保留未初始化状态,创建时加这个选项
$user = $reflector->newLazyGhost(
    $initializer,
    ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE
);

反过来,从缓存里 unserialize() 出来的对象也是惰性的,它的初始化闭包是原来那个——如果闭包捕获了 PDO 连接这类不可序列化的东西,那个闭包就没法正确还原,访问时会出错。

跨进程传递惰性对象这件事,很难做对,不如干脆放弃。传 id,接收方重新构造。

5. 克隆行为需要想清楚

对一个还没初始化的 ghost 调用 clone,得到的新对象是”未初始化的副本”。两个对象共享同一个初始化闭包,但各自有独立的属性槽。

这意味着如果你克隆了一个已经初始化的惰性对象,副本是初始化的;克隆了未初始化的,副本也是未初始化的。看起来合理,但实际用的时候容易忘记这一点——特别是那种”先克隆一份,改改再用”的写法。

6. 惰性不等于批量

这一点最容易被误解。惰性对象让”用不到就不查”变成默认行为,但它没有解决 N+1。

如果 50 条订单你都要展示用户昵称,那就是 50 次查询,一次不少。

批量加载需要的是另一套机制:先把所有 userId 收起来,一次查询拉回来,然后分发。(Doctrine 的做法是用一个 DataLoader 在事务提交前统一处理。)

惰性对象的定位是”减少不必要的加载”,不是”合并加载”。搞清楚这一点,才不会对它有不切实际的期待。

7. 类的限制

不是所有类都能做惰性对象。interface、trait、enum、abstract 类自然不行。部分 internal 类也不行,因为引擎需要能控制它们的属性布局。

只读属性的问题前面提过了,ghost 基本用不了。至于 final 类,行为在不同版本上可能有一些变化,上线前在自己项目的 PHP 版本上实测一遍最稳。

六、该不该用

惰性对象解决的是一个具体的性能问题:对象的构造代价(或者它的数据加载代价)在某些使用路径上是浪费的。如果你的项目里符合下面任何一条,值得试一试。

一是列表页和详情页共用同一套实体类,但列表页只用到很少的字段。二是应用里有几个重型服务在很多请求里根本用不到。三是数据加载涉及网络请求或者复杂计算,而你希望”不用就不算”。四是自己写 ORM 或者 DI 容器,不想再维护代码生成的代理类文件。

不适合的场景也很清楚。

如果你的数据本来就来自一次查询,加载代价为零,那惰性对象只是增加了复杂度。如果团队里对反射还不熟悉,引入之后调试成本可能比收益大。如果是老项目,大量代码依赖 get_object_vars() 或者 json_encode 来序列化对象,惰性对象的引入会带来一堆难查的副作用。

另外有一点值得留意:惰性对象是运行时优化,它不改变任何对外行为——至少设计上如此。所以它可以在项目已经稳定之后,从一两个热点开始慢慢引入,不需要一次性铺开。

七、写在最后

回头看开头那个用户列表页。

用惰性对象改完之后,SQL 从 101 条降到了 1 条——只有列表本身那次查询,用户表一次都没碰。因为那个页面只展示头像,而头像是在订单表里冗余存储的。

更重要的变化其实不在这里。原来的代码需要小心翼翼地维护”这个页面用哪些字段”,加了新字段就可能出问题,改了布局就可能让缓存失效。现在这套判断完全交给引擎和运行时,业务代码里没有一处 if 判断要为了性能而写。这一点带来的长期收益,比那 1.8 秒要值钱得多。

PHP 这些年的更新里,很多特性都是这个路数:把过去只能靠框架黑魔法或者代码生成来做的事情,变成语言内置的能力。属性钩子是这样,惰性对象也是这样。每次都要重新学一遍新的 API 有点累,但那些因为方案不统一而写出来的绕过代码,确实少了很多。

PHP 8.4 惰性对象实战:让 ORM 实体和 DI 容器按需加载
收藏 (0) 打赏

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

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

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

淘吗网 php PHP 8.4 惰性对象实战:让 ORM 实体和 DI 容器按需加载 https://www.taomawang.com/server/php/2825.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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