框架用久了容易把「容器」当黑盒。这篇从零写一个能用的 DI 容器,支持自动装配、单例复用、接口绑定和循环依赖检测,最后对齐 PSR-11 接口。
一、容器到底解决了什么问题
先看一段典型的「手工装配」代码:
$pdo = new PDO($dsn, $user, $pass);
$logger = new FileLogger('/var/log/app.log');
$mailer = new SmtpMailer('smtp.example.com', 465, $user, $pass);
$repo = new UserRepository($pdo, $logger);
$service = new UserService($repo, $mailer, $logger);
$service->register($data);问题有三个:
- 依赖的构造顺序要人肉维护,
$logger得在最前面 $logger被 new 了两次,如果改成单例就得手动传同一个实例- 想换成
RedisLogger得改所有new FileLogger的地方
容器的职责就一句话:你告诉它「我要 UserService」,它负责把整条依赖链拼好给你。
二、最小可用版本:反射自动装配
PHP 的反射能读到构造函数每个参数的类型提示,这就是自动装配的基础。
已实例化的共享实例 */
private array $instances = [];
/** @var array 用户注册的工厂函数 */
private array $bindings = [];
public function get(string $id): object
{
// 1. 已存在直接返回(单例)
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
// 2. 有注册的工厂就用工厂
if (isset($this->bindings[$id])) {
return $this->instances[$id] = ($this->bindings[$id])($this);
}
// 3. 否则尝试自动装配
return $this->instances[$id] = $this->build($id);
}
public function has(string $id): bool
{
return isset($this->instances[$id])
|| isset($this->bindings[$id])
|| class_exists($id);
}
public function bind(string $id, callable $factory): void
{
$this->bindings[$id] = $factory;
}
private function build(string $class): object
{
if (!class_exists($class)) {
throw new RuntimeException("无法实例化:{$class} 不存在");
}
$ref = new ReflectionClass($class);
// 接口和抽象类没法 new
if (!$ref->isInstantiable()) {
throw new RuntimeException("无法实例化:{$class} 是接口或抽象类,请先 bind()");
}
$ctor = $ref->getConstructor();
if ($ctor === null) {
return new $class();
}
$args = [];
foreach ($ctor->getParameters() as $param) {
$args[] = $this->resolveParameter($param, $class);
}
return $ref->newInstanceArgs($args);
}
private function resolveParameter(ReflectionParameter $param, string $owner): mixed
{
$type = $param->getType();
// 没有类型提示,或者是内置类型(int/string/array…),只能靠默认值
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
if ($param->isDefaultValueAvailable()) {
return $param->getDefaultValue();
}
throw new RuntimeException(
"{$owner}::__construct 的参数 \${$param->getName()} 无法自动解析,请显式 bind()"
);
}
$name = $type->getName();
// nullable 且解析失败时返回 null
if ($param->allowsNull() && !$this->has($name)) {
return null;
}
return $this->get($name);
}
} 跑一下:
class FileLogger {
public function __construct(private string $path = '/tmp/app.log') {}
}
class UserRepository {
public function __construct(private PDO $pdo, private FileLogger $logger) {}
}
$c = new Container();
$c->bind(PDO::class, fn() => new PDO('sqlite::memory:'));
$repo = $c->get(UserRepository::class);
var_dump($repo); // 依赖链自动拼好三、加上接口绑定
自动装配有个天然短板:遇到接口就断。
interface LoggerInterface { public function log(string $msg): void; }
class FileLogger implements LoggerInterface { /* ... */ }
class UserService {
public function __construct(private LoggerInterface $logger) {}
}
$c->get(UserService::class); // 报错:LoggerInterface 是接口这时候需要显式告诉容器「接口用哪个实现」:
$c->bind(LoggerInterface::class, fn(Container $c) => new FileLogger('/var/log/app.log'));注意工厂函数接收容器本身,这样工厂内部还能继续解析依赖——这是关键设计。
四、循环依赖检测
A 依赖 B、B 依赖 A,朴素实现会无限递归直到栈溢出。加一个「正在构建中」的栈:
final class Container
{
private array $instances = [];
private array $bindings = [];
private array $building = []; // 新增:正在构建的类名栈
public function get(string $id): object
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
if (isset($this->building[$id])) {
$chain = implode(' → ', array_keys($this->building)) . " → {$id}";
throw new RuntimeException("检测到循环依赖:{$chain}");
}
$this->building[$id] = true;
try {
if (isset($this->bindings[$id])) {
$obj = ($this->bindings[$id])($this);
} else {
$obj = $this->build($id);
}
} finally {
// 无论成功失败都要出栈,否则后续请求会被误判
unset($this->building[$id]);
}
return $this->instances[$id] = $obj;
}
}finally 这里很重要。如果构建过程中抛异常却没有出栈,容器状态就被污染了,下一个请求会莫名其妙报循环依赖。
五、对齐 PSR-11
PSR-11 只规定了两个方法,非常简单:
namespace Psr\Container;
interface ContainerInterface
{
public function get(string $id);
public function has(string $id): bool;
}只要你的 Container implements ContainerInterface,就能被 Symfony、Laravel 等生态的组件直接消费。这是「自己写容器」最有价值的一点——接口标准统一,随时能换实现。
六、还缺什么
一个生产级容器通常还要这几样,按需加:
延迟实例化(lazy) —— 现在 bind 的工厂是「取的时候才执行」,已经是惰性的,够用。
参数注入 —— 比如配置项:
$c->bind('db.dsn', fn() => 'mysql:host=127.0.0.1;dbname=test');但要注意:bind 的返回类型是 object,配置值不是对象。需要单独开一个 set(string $id, mixed $value):
public function set(string $id, mixed $value): void
{
$this->instances[$id] = $value;
}同时把 $instances 的类型标注从 array 放宽。
方法调用注入 —— 控制器 action 的参数也想自动解析,就需要一个 call() 方法,用 ReflectionMethod 拿参数再 invokeArgs()。
编译缓存 —— 反射有开销。高频场景可以把「类 → 构造函数参数类型」的映射缓存到 APCu 或文件里,下次直接查表。实测在中等规模项目上,反射开销通常在几百微秒量级,先测再决定要不要优化。
七、什么时候不该自己写
说实话,绝大多数项目直接用 php-di/php-di 或者框架自带的容器就行,成熟、有测试、有文档。
自己写容器的价值在于:
- 看得懂框架报的「Unresolvable dependency」到底怎么来的
- 小工具、CLI 脚本不想引入一整个框架时,一百多行够用
- 排查「为什么这个类被 new 了两次」这类问题时,知道该看哪里
理解了原理,用别人的库才用得踏实。



0 条评论