前阵子同事发来一段代码找我排查,他说在控制器里用 app(UserService::class) 拿到的对象,和自己 new 出来的 UserService 行为完全对不上。我反问他,你有没有翻过容器源码?他说没有,平时都在调用,但确实不知道底下的逻辑。这个现象不算少见,我刚接触 ThinkPHP 的时候也是这个状态。
今天就花点时间,把 ThinkPHP 8 的容器机制从头到尾讲清楚,特别是“从调用一个类到拿到一个对象,中间到底发生了什么”。如果你能把这篇文章看完,以后再遇到跟容器相关的疑难杂症,排查思路基本就出来了。
先说容器在替我们解决什么问题
很多同学其实已经写过很多年依赖注入了,但你要让他解释一下“这个概念到底解决了什么”,他又支支吾吾。
用一个例子说清楚。你有个下单服务类 OrderService,它依赖订单仓库和支付网关:
class OrderService
{
protected $repository;
protected $payment;
public function __construct(OrderRepository $repository, PaymentGateway $payment)
{
$this->repository = $repository;
$this->payment = $payment;
}
public function createOrder(array $data)
{
// 业务代码
}
}
没有容器的时候,你得手动组装这些依赖才能得到一个能用的 OrderService:
$service = new OrderService(new OrderRepository(), new PaymentGateway());
如果 OrderRepository 的构造函数又依赖别的服务,那还要继续一层层往下 new。代码多了以后,你会发现所有组装对象的逻辑散落在项目各个角落,改一个依赖,十来个地方要跟着改。
依赖注入做的事情,是把“对象的创建和组装”从调用方那里拿掉。而容器就是那个帮我们统一完成组装的工厂。你只要跟容器说“给我一个 OrderService”,它会自动根据构造函数需要的参数,一层一层地创建依赖并返回最终对象。
容器在 ThinkPHP 8 里的核心结构
ThinkPHP 8 的容器逻辑主要集中在 thinkContainer 类中,同时 App 类继承它,所以你在框架里见到的 $app 本质就是一个容器。
我观察它主要靠下面三样东西在工作:
一个缓存实例的数组 $instances,记录已经生成过的对象;一个绑定关系数组 $bind,记录服务别名或接口对应的实现;一套创建对象的方法:make()、invokeClass()、bindParams()。
用一句话概括就是:先从绑定表里找找有没有对应的配置,找不到就直接通过反射自动装配。生成出来的对象会顺手扔进缓存,下次再要就直接给同一个。
下面是我根据源码归纳出来的核心流程,不是逐行复制,不过关键步骤都在:
public function make(string $abstract, array $vars = [], bool $newInstance = false)
{
// 1. 别名解析
$abstract = $this->getAlias($abstract);
// 2. 命中缓存就直接返回
if (isset($this->instances[$abstract]) && !$newInstance) {
return $this->instances[$abstract];
}
// 3. 如果绑定的是闭包,执行闭包
if (isset($this->bind[$abstract]) && $this->bind[$abstract] instanceof Closure) {
$object = $this->invokeFunction($this->bind[$abstract], $vars);
} else {
// 否则走反射装配
$object = $this->invokeClass($this->bind[$abstract] ?? $abstract, $vars);
}
// 4. 写缓存
if (!$newInstance) {
$this->instances[$abstract] = $object;
}
return $object;
}
这段代码虽然看着不长,但已经把容器的核心策略说得差不多了。其中有两个点,很多人在第一次读完源码的时候会忽略:
第一,容器默认是单例模式。第二次 make 同一个东西,不会重新执行构造函数,而是直接返回第一次生成的对象。这也就是我同事那个问题的直接原因——他修改的是自己 new 出来的对象,而框架里 get 到的始终是缓存里的那个旧对象。
第二,$bind 表里可以放闭包,也可以放类名。闭包这种写法非常适合“这个类怎么创建由我这段定义代码说了算”的场景。
invokeClass 的反射装配是怎么实现的
当容器决定走 invokeClass() 时,其实是在利用 PHP 的反射机制动态读取类的构造函数,然后挨个分析参数。
我把这一段的逻辑简化成下面这样:
protected function invokeClass(string $class, array $vars = [])
{
// 拿到类的反射信息
$reflect = new ReflectionClass($class);
// 没有构造函数就直接 new
$constructor = $reflect->getConstructor();
if (is_null($constructor)) {
return new $class();
}
// 逐个解析构造函数参数
$args = $this->bindParams($constructor, $vars);
return $reflect->newInstanceArgs($args);
}
关键在于 bindParams() 怎么处理参数。看这个伪代码:
protected function bindParams(ReflectionFunctionAbstract $reflect, array $vars = [])
{
$args = [];
foreach ($reflect->getParameters() as $param) {
$type = $param->getType();
// 参数是类类型,递归去容器里取
if ($type && !$type->isBuiltin()) {
$args[] = $this->getObjectParam($param, $vars);
continue;
}
// 有指定的变量就用指定的
if (isset($vars[$param->getName()])) {
$args[] = $vars[$param->getName()];
continue;
}
// 有默认值就用默认值
if ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue();
}
}
return $args;
}
如果参数是一个类,容器就递归调用 make() 去解析那个类;如果参数是内置类型比如 string、int,就看看能不能从 $vars 里取一个值;还没有就尝试用默认值。所有依赖的依赖,就这样一层层被容器捋顺了。
这里有一个细节:容器对类的解析顺序依赖构造函数签名,所以不要把构造函数的参数顺序写得乱七八糟,否则调试的时候会比较痛苦。
实战:让容器自动选择支付实现
原理说了不少,来一个能落地的场景。
假设你在做一套订单系统,支持支付宝和微信,OrderService 里的支付逻辑不想用 if…else 来区分端别。你当然可以写一个抽象出来的支付接口,然后通过容器绑定动态决定具体实现。
先定义接口和两个驱动:
<?php
namespace apppaycontracts;
interface Payable
{
public function pay(int $orderId, float $amount): bool;
}
<?php
namespace apppaydriver;
use apppaycontractsPayable;
class Alipay implements Payable
{
public function pay(int $orderId, float $amount): bool
{
// 这里写支付宝接口对接逻辑
return true;
}
}
<?php
namespace apppaydriver;
use apppaycontractsPayable;
class WechatPay implements Payable
{
public function pay(int $orderId, float $amount): bool
{
// 这里写微信支付接口对接逻辑
return true;
}
}
在 OrderService 里不直接依赖某个具体类,而是依赖 Payable 接口:
<?php
namespace apporderservice;
use apppaycontractsPayable;
class OrderService
{
public function __construct(protected Payable $payer)
{
}
public function createAndPay(int $orderId, float $amount)
{
// 订单创建逻辑略
$this->payer->pay($orderId, $amount);
}
}
接下来在服务提供者中做一个绑定:
<?php
namespace appprovider;
use thinkService;
use apppaycontractsPayable;
use apppaydriverAlipay;
use apppaydriverWechatPay;
class PayServiceProvider extends Service
{
public function register()
{
$this->app->bind(Payable::class, function () {
if (request()->header('X-Pay-Channel') === 'wechat') {
return new WechatPay();
}
return new Alipay();
});
}
}
这个绑定的意思是:当容器里任何地方出现 Payable 类型依赖时,执行这个闭包,根据当前请求的 header 决定给支付宝实现还是微信实现。
因为容器默认会缓存实例,同一个请求里 OrderService 第一次解析 Payable 后,后续再解析同样拿到的是同一个支付对象。如果某个特殊场景你希望每次 make 都是全新的,比如状态会被外部修改,那就传 true:
app(Payable::class, [], true);
或者干脆在绑定时用工厂方法每次返回新对象,但同时记得别让它走缓存。
这个案例本身不算复杂,但很能说明问题:当你把依赖的创建职责交出来以后,原本分散在各处的支付逻辑判断就集中到了一个地方。以后如果要新增一种支付渠道,只需要扩展 Payable 的实现,然后在绑定里加上判断,业务层代码一行都不用动。
几个真实踩过的坑
1. 容器缓存带来的状态污染
容器默认缓存所有 make 出来的对象,所以同一个类在同一个请求生命周期内只有一份。如果你在类上设置了公共属性,某一次请求把它改掉了,后面所有读取这个对象的地方都会受影响。这种问题不好捉,因为它往往需要特定的调用顺序才会出现。如果你明确知道这个对象不应该被共享,应该在 make 时强制 newInstance。
2. 构造函数里不要做重活
容器反射装配时,每个依赖都需要先保证构造函数能顺利执行。如果你在构造函数里写了一些网络请求或者数据库查询,那这个类每被解析一次都会触发这些副作用,一旦依赖关系复杂,性能账单会很难看。构造函数就做赋值,别搞别的。
3. bind 的键名不统一,解析结果对不上
有人习惯在绑定时写短别名,比如 bind(‘pay.driver’, …),但使用的时候又用 app(Payable::class) 来取。两者不是同一个键,容器会当成两个服务来处理。建议要么统一用类名,要么统一用短别名,别混着来。这个习惯养成之后能省掉很多无谓的排查时间。
4. 使用门面(Facade)不等于脱离容器
ThinkPHP 8 里的门面底层依然是容器。比如 thinkfacadeConfig 看起来是个静态类,实际上它内部通过 __callStatic 把调用转发到容器里绑定的 config 实例上。你绕不开容器的,所以理解容器依然是理解框架的前提。
结语
容器在 ThinkPHP 8 里并不是一个高高在上的抽象概念,它的核心就是 bind 表、instances 缓存和反射装配。你可能已经写了很长时间框架代码而不需要手改容器,但当你遇到缓存对象状态怪异、依赖注入拿到非预期实例、或者要给既有系统加一个动态替换实现时,理解容器内部机制能帮你少走很多弯路。
下次再遇到“为什么 app() 拿到的对象和我 new 的不一样”,你就可以直接回答他:因为容器默认缓存了实例,而且你的构造函数依赖已经被自动解析过了。

