Laravel 容器的单例与绑定作用域

解读

在国内一线/二线互联网公司的 PHP 面试中,面试官提出“单例与绑定作用域”通常不是单纯考察记忆 API,而是想确认候选人是否真正理解 IoC 容器在 Laravel 生命周期中的位置、是否能在高并发场景下避免“隐式状态污染”、是否具备定位“内存泄漏”与“重复实例化”问题的能力。回答时务必结合 FPM 进程模型、Laravel 请求生命周期、CLI 命令行模式差异,给出可落地的代码示例与排坑经验,才能拿到高分。

知识点

  1. 容器四要素:bind/singleton/instance/extend 的源码级区别
  2. 单例(shared=true)在 FPM 下“跨请求仍单例”的误区:仅保证“一次请求”内单例,请求结束进程仍存活,但 Laravel 会在 Kernel::terminate() 阶段对 scopedsingletonforgetScopedInstances(),防止下次请求复用。
  3. 作用域分类:
    • singleton:进程级单例(默认)
    • scoped:请求级单例(Laravel 7+ 引入)
    • transient:每次解析都新实例(默认 bind)
  4. 绑定方式:
    • 闭包绑定:App::singleton(Interface::class, fn() => new Impl)
    • 上下文绑定:$this->app->when(Controller::class)->needs(Interface::class)->give(Impl::class)
    • 标签与作用域联动:$this->app->tag([Cache::class], 'cache.driver')
  5. 源码位置:Illuminate\Container\Container::singleton() 最终把 shared 属性置为 true;scopedSingleton() 额外把 scoped 属性置为 true,并在 flush() 时清掉。
  6. 常见坑:
    • 在 ServiceProvider 的 register() 里使用了请求级参数(如 Request::ip())导致 CLI 下报错;
    • 把含 PDO 连接的单例注册到 scoped,造成连接池频繁重建;
    • 单元测试中使用 RefreshDatabase 时,忘记 flush() 单例,导致旧连接持有事务。

答案

“Laravel 容器通过 singletonscoped 两种绑定作用域来解决‘实例复用范围’问题。

  1. singleton:在单次 FPM 进程生命周期内只实例化一次,适合无状态、可复用的服务,如配置仓库、转换器。
    $this->app->singleton(PaymentGateway::class, function ($app) {
        return new AlipayGateway(config('pay.alipay'));
    });
    
  2. scoped:在‘一次请求’内单例,请求结束由框架自动销毁,适合持有请求级状态的对象,如当前登录用户装饰器、请求追踪 ID。
    $this->app->scoped(CurrentUser::class, function ($app) {
        return new CurrentUser(auth()->user());
    });
    
  3. 默认 bind:每次 app()->make() 都返回新对象,适合轻量级、无共享需求的数据对象。
  4. 验证方式:
    • 在控制器连续两次 resolve(PaymentGateway::class),singleton 返回同一对象 (spl_object_id 相等);scoped 在同一请求内相等,新请求则不同。
    • 使用 php artisan tinker + --repeat=2 可快速验证进程级单例。
  5. 线上排坑:
    • 若发现内存持续增长,优先检查自定义 singleton 是否持有闭包引用或 Eloquent 大集合;
    • 若出现‘旧请求数据残留’,先确认是否误把 scoped 注册成了 singleton
    • 在 Octane/Swoole 环境下,需额外使用 octane()->tick()Scope::flush() 手动清理 scoped,防止协程间污染。

一句话总结:singleton 保进程,scoped 保请求,bind 保隔离;选错作用域,轻则性能掉底,重则数据串户。”

拓展思考

  1. 在 Laravel Octane 中,Swoole/RoadRunner 的常驻内存模式打破了“请求结束即销毁”的假设,scoped 单例会变成“Worker 级”单例。此时需要借助 Octane::flush() 事件或自定义 ScopedInstanceInterface 来重置状态,否则会出现用户 A 的上下文泄漏给用户 B 的严重安全事故。
  2. 结合 PHP8 的 readonly 属性与构造函数属性提升,可以写出“不可变 scoped 对象”,既享受请求级复用,又杜绝状态被篡改。
  3. 对于需要“集群级单例”的场景(如分布式锁、限流计数器),应把实例放到 Redis 或 Consul,而不是依赖容器作用域;容器只负责封装客户端,避免把“分布式一致性”误当成“进程单例”来实现。