PHP 依赖注入原理:容器实现与反射解析
帖子围绕 PHP 依赖注入容器的实现原理展开:先指出在类内直接 new 依赖会导致硬编码、耦合和难以测试,再用 ReflectionClass 解析构造函数参数类型,区分内置类型与类类型,最后用不到 100 行代码实现一个支持 bind、singleton、make 的最小容器,以此说明 Laravel、ThinkPHP 中 app()->make() 背后的自动解析与实例化机制。
学完这篇,你能用不到 100 行 PHP 写一个可用的依赖注入容器,并看懂 Laravel、ThinkPHP 里 app()->make() 背后到底做了什么。
第一步:先看清要解决的问题
假设有个类这样写:
class OrderController
{
public function __construct()
{
$this->db = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '123456');
}
}
三个问题:连接参数硬编码在类里、想换实现就得改源码、写单元测试时没法塞一个假数据库进去。改造方式是把依赖从“自己 new”变成“外面传进来”:
class OrderController
{
public function __construct(private PDO $db) {}
}
这就是依赖注入。但问题随之而来——谁负责传?如果每个调用点都得手动 new PDO 再 new OrderController,只是把耦合从类里搬到了调用点。容器就是替你做这件事的工厂。
第二步:用反射看清一个类“想要什么”
反射的用途是:给容器一个类名,让容器自己问出来“构造函数需要哪些参数”。核心 API 就四个:
$ref = new ReflectionClass(OrderController::class);
$ctor = $ref->getConstructor(); // 构造函数,可能是 null
$params = $ctor->getParameters(); // 参数列表
$type = $params[0]->getType(); // 参数类型声明
拿到的 $type 有两种情况需要区分:内置类型(int、string、array)返回 ReflectionNamedType 且 isBuiltin() 为真;类类型返回同样的对象但 isBuiltin() 为假,此时 getName() 就是类名——这正是容器需要递归创建的那一层。PHP 8 的联合类型返回的是 ReflectionUnionType,跟 ReflectionNamedType 不是同一个类。
第三步:写出最小可用容器
class Container
{
protected array $bindings = []; // 抽象 => 实现
protected array $instances = []; // 单例实例
protected array $singletons = [];
public function bind(string $abstract, $concrete = null): void
{
$this->bindings[$abstract] = $concrete ?? $abstract;
}
public function singleton(string $abstract, $concrete = null): void
{
$this->bind($abstract, $concrete);
$this->singletons[$abstract] = true;
}
public function make(string $abstract)
{
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
$concrete = $this->bindings[$abstract] ?? $abstract;
$object = $concrete instanceof Closure
? $concrete($this)
: $this->build($concrete);
if (isset($this->singletons[$abstract])) {
$this->instances[$abstract] = $object;
}
return $object;
}
protected function build(string $class)
{
if (!class_exists($class)) {
throw new RuntimeException("无法解析:{$class}");
}
$ref = new ReflectionClass($class);
if (!$ref->isInstantiable()) {
throw new RuntimeException("{$class} 是接口或抽象类,请先 bind 到具体实现");
}
$ctor = $ref->getConstructor();
if ($ctor === null || $ctor->getNumberOfParameters() === 0) {
return new $class;
}
$args = [];
foreach ($ctor->getParameters() as $param) {
$args[] = $this->resolveParameter($param);
}
return $ref->newInstanceArgs($args);
}
protected function resolveParameter(ReflectionParameter $param)
{
$type = $param->getType();
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
return $this->make($type->getName()); // 类类型:递归解析
}
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue(); // 有默认值就用默认值
}
if ($param->allowsNull()) {
return null;
}
throw new RuntimeException("无法解析参数 \${$param->getName()}");
}
}
用法:
$c = new Container();
$c->singleton(PDO::class, fn() => new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '123456'));
$c->bind(OrderServiceInterface::class, OrderService::class);
$controller = $c->make(OrderController::class);
容器发现 OrderController 的构造函数需要一个 OrderServiceInterface,查绑定表得知实现是 OrderService,于是递归去创建 OrderService;OrderService 又需要一个 PDO,命中单例直接返回。整条依赖链一次 make 全部打通。
第四步:三个必须补的细节
接口绑定:容器不能 new 一个接口,isInstantiable() 会返回 false。所以接口必须先 bind,这是容器存在的最大理由——换实现只改一行配置。
单例缓存:数据库连接、配置对象这类东西每次 make 都新建会出事。上面用 singletons 数组做了标记,instances 存实例。
Closure 绑定:bind(PDO::class, fn() => new PDO(...)) 这种闭包形式允许你把不能自动反射的标量参数(主机名、密码)手动塞进去,这是绕开反射限制的标准做法。
第五步:几个容易踩的坑
注意:反射只能拿到类型声明,拿不到标量的值。
__construct(private string $dsn)这种参数,反射只能看到“它是个 string”,容器猜不出内容,必须用闭包绑定显式提供,或者给参数写默认值。
注意:循环依赖会让容器无限递归直到爆栈。上生产前在
make里加一个$resolving数组记录当前解析栈,重复出现同名类就抛异常,比内存耗尽好排查得多。
注意:
ReflectionClass的开销不低。容器本身几乎不会成为瓶颈(一个请求通常几次make),但如果你在循环里几千次make,就考虑缓存ReflectionClass对象或提前把解析结果缓存下来。别一上来就优化,先测量。
注意:不要拿容器当全局注册表。
$container->make('config')散落在业务代码各处,等于把 new 换成了更隐蔽的耦合。容器的正确用法是只在入口(控制器、命令、路由处理器)解析一次,类内部依然通过构造函数接收依赖。
小结
- 依赖注入的本质是“依赖由外部传入”,容器只是自动完成传递的工厂,两者是不同概念。
- 反射三步走:
ReflectionClass→getConstructor()→getParameters(),靠getType()判断是类类型还是内置类型。 - 容器核心只有两个方法:
bind登记抽象到实现的映射,make递归解析并组装对象。 - 类类型递归
make,有默认值用默认值,可空返回null,其余抛异常——这四行判断决定了容器的健壮度。 - 接口必须显式绑定,闭包绑定用来处理反射拿不到的标量参数。
- 循环依赖要有检测,容器只在入口使用。
转载请注明出处,版权归原作者所有。
正式会员
认证极客






