PHP依赖注入容器从零实现思路剖析

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-08-04 12:46 ·187 浏览 ·0 回复
内容摘要

PHP依赖注入容器的从零实现核心是构建可递归创建对象的工厂,利用 ReflectionClass 解析构造函数参数,对类类型依赖递归调用 make,对内置类型借助绑定或上下文参数处理,并通过 bindings 数组将接口映射到具体实现,从而自动建好整棵依赖树。

依赖注入容器(Dependency Injection Container)在现代 PHP 开发中几乎成了标配,Laravel 的 app()、Symfony 的 service container,乃至各种轻量框架都有自己的容器实现。很多人天天用,却未必清楚它底层到底做了什么。今天我们就抛开框架,从零开始推演一个最小可用容器的实现思路,弄懂之后,你会发现它并不神秘。

从一个最简单的需求说起

假设我们有一个类 UserService,它依赖 UserRepository,而 UserRepository 又依赖一个数据库连接 PDO。传统写法是这样:

$pdo = new PDO('sqlite::memory:');
$repository = new UserRepository($pdo);
$service = new UserService($repository);

问题立即出现:如果 UserService 还依赖日志、缓存、事件分发器,每加一个依赖,手动 new 的链条就会越来越长,而且调用方被迫知道所有内部依赖的构造函数细节。

依赖注入容器的核心使命就是:**你只需要告诉它“我要一个 UserService”,它就能自动帮你把整棵依赖树都建好**。所以容器本质上是一个工厂,一个能够递归创建对象的工厂。

第一步:用反射看穿构造函数

要自动创建对象,前提是知道这个类需要哪些参数。PHP 内置的 ReflectionClass 就是我们的“透视镜”。对所有依赖注入容器来说,反射是地基。

假设容器有一个 make($class) 方法,第一步自然是:

$reflection = new ReflectionClass($class);

如果这个类不可实例化(比如接口或抽象类),那就要抛出异常或走绑定映射。但先处理最普通的情况:通过反射获取构造函数:

$constructor = $reflection->getConstructor();

如果构造函数不存在,说明类没有依赖,直接 newInstance() 即可。如果存在,就遍历构造函数的每个参数:

foreach ($constructor->getParameters() as $param) {
    $type = $param->getType();
    // 需要根据类型得到实例
}

这里要处理三种情况:

  • 参数有类类型提示(比如 UserRepository $repository):说明这是需要一个依赖对象,递归调用 make($type->getName()) 即可。
  • 参数是内置类型(如 string $dsn):容器无法凭空猜出该传什么,这通常意味着需要走“绑定”或“上下文参数”。
  • 参数有默认值:直接使用默认值兜底。

于是 make 方法变成了一个递归解析器,沿依赖链一路向下,直到所有叶子节点(无依赖对象或已绑定值)被创建。

第二步:处理接口和别名

实际项目中,构造函数里往往依赖的是接口而不是具体类。比如 UserRepositoryInterface $repository,容器没法直接 new 一个接口。这时就需要绑定机制——把接口映射到具体实现。

容器的核心数据结构其实就是一个数组:

protected $bindings = [];

public function bind($abstract, $concrete)
{
    $this->bindings[$abstract] = $concrete;
}

当调用 make($abstract) 时,先检查 $bindings 里有没有映射。如果有且是闭包,就执行闭包;如果是类名,就递归解析这个类。如果是接口且没有绑定,那就只能报错“Target [X] is not instantiable”。

像 Laravel 里常用的:

$this->app->bind(UserRepositoryInterface::class, UserRepository::class);

本质上就是往这个数组里塞了一条映射。

第三步:共享实例与单例

每次 make 都重新创建一个对象,在一些场景下是合理的,但很多时候我们希望某些对象全局唯一,比如数据库连接、配置管理器、日志器。于是容器需要支持“共享”。

实现起来也不复杂:准备一个 instances 数组,当 singleton 方法被调用时,先创建实例,然后存入数组;以后再 make 就直接从数组返回。

注意:singleton 和 bind 的区别仅在于是否复用实例,解析逻辑本身可以统一。我们可以在解析完成后,根据“是否共享”来决定要不要缓存结果。

第四步:自动装配与显式参数结合

如果整个容器只支持“靠反射自动猜测”,那遇到带字符串配置的类就束手无策了。比如:

new HttpClient('https://api.example.com', 30);

容器不知道 URL 和超时时间从哪来,这时候就需要让 make 方法接收额外参数。设计上可以允许传入一个关联数组,当参数类型是内置类型时,优先从传入数组中提取:

public function make($class, $parameters = [])
{
    // 反射解析时,如果参数名在 $parameters 中存在,就直接使用
}

这样既保留了自动装配的便利,又允许在特殊场景下手动控制输入。这也是大多数容器提供的 make(Class::class, ['url' => '...']) 的底层逻辑。

第五步:从代码组织到设计取舍

当上面的几步完成,你已经拥有一个约一百行代码、却具备核心功能的依赖注入容器。但实际框架的容器会更复杂,比如支持:

  • 上下文绑定(同一个接口在不同类中注入不同实现)
  • 参数标签(@Inject 注解或属性)
  • 延迟加载和代理
  • 循环依赖的检测

这些都属于锦上添花的工程优化,核心思想始终没变:**用反射获取构造函数信息,用递归解析依赖,用绑定数组解决抽象到具体的映射,用缓存处理单例**。

自己动手实现一遍,最大的收获不是代码能力,而是理解“约定优于配置”究竟是怎样被程序实现的。当你下次遇到框架报错“Target [X] is not resolvable”时,你能一眼看出是哪里没有绑好,哪里写错了构造函数参数类型,而不是对着错误信息满头雾水。

依赖注入容器不是魔法,它只是一个比你更勤奋的递归工厂。搞清楚这些,即使不依赖框架,你也能在手写代码时设计出更清晰的依赖关系。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-100.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~