PHP 依赖注入容器手写实战:100 行实现 PSR-11 兼容容器
学完这篇,你能在自己项目里落地一个 100 行上下、真正实现 `Psr\Container\ContainerInterface` 的依赖注入容器,并且搞明白构造函数自动装配、单例缓存和循环依赖检测是怎么写出来的。
第一步:装上 PSR-11 接口
容器不自己造接口标准,直接用官方的:
composer require psr/container
会得到三个东西:`ContainerInterface`(只有 `get($id)` 和 `has($id)` 两个方法)、`NotFoundExceptionInterface`、`ContainerExceptionInterface`。两个异常接口是空标记接口,作用是让调用方可以精确地区分「找不到」和「构建失败」,所以你必须实现它们,不能随便抛个 `RuntimeException` 了事。
注意:如果你的老项目跑不起来 Composer,直接建三个文件手写接口内容也行,PSR-11 接口本身只有几行,不算侵权也不算投机。
第二步:定义异常 + 容器骨架
新建 `src/Container.php`:
<?php
declare(strict_types=1);
namespace App;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Container\ContainerExceptionInterface;
class NotFoundException extends \RuntimeException implements NotFoundExceptionInterface {}
class ContainerException extends \RuntimeException implements ContainerExceptionInterface {}
class Container implements ContainerInterface
{
/** @var array<string, mixed> 注册的绑定 */
private array $definitions = [];
/** @var array<string, mixed> 已构建的单例 */
private array $instances = [];
/** @var array<string, bool> 解析中标记,用于循环依赖检测 */
private array $resolving = [];
/** 注册:$concrete 可以是类名、闭包或现成对象 */
public function set(string $id, $concrete): void
{
$this->definitions[$id] = $concrete;
}
public function has(string $id): bool
{
return isset($this->definitions[$id]) || isset($this->instances[$id]);
}
public function get(string $id)
{
if (isset($this->instances[$id])) {
return $this->instances[$id]; // 命中单例缓存
}
if (isset($this->resolving[$id])) {
throw new ContainerException("检测到循环依赖:{$id}");
}
$this->resolving[$id] = true;
try {
$obj = $this->build($id);
} finally {
unset($this->resolving[$id]); // 无论成败都要清理,否则下次同样的 ID 会被误判
}
return $this->instances[$id] = $obj;
}
private function build(string $id)
{
$concrete = $this->definitions[$id] ?? $id;
if ($concrete instanceof \Closure) {
return $concrete($this); // 闭包工厂:$this 就是容器,方便内部再取依赖
}
if (is_object($concrete)) {
return $concrete; // 直接塞进来的实例
}
if (is_string($concrete) && class_exists($concrete)) {
return $this->autowire($concrete);
}
throw new NotFoundException("无法解析:{$id}");
}
}
这里两个细节值得停一下看清楚:`finally` 里的 `unset` 不能省,否则一次失败会让这个 ID 永久被标记为「正在解析」;`has()` 故意不返回 `class_exists($id)`,PSR-11 规定 `has()` 返回 true 就必须能 `get()` 成功,把类名算进 `has()` 容易给出误导性答案。
第三步:用反射做构造函数自动装配
补上 `autowire()` 方法:
private function autowire(string $class)
{
$ref = new \ReflectionClass($class);
if (!$ref->isInstantiable()) {
throw new ContainerException("{$class} 是抽象类或接口,无法实例化");
}
$ctor = $ref->getConstructor();
if ($ctor === null) {
return new $class(); // 无构造函数,直接 new
}
$args = [];
foreach ($ctor->getParameters() as $param) {
$type = $param->getType();
// 只处理类类型提示(ReflectionNamedType 且非内置类型)
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$args[] = $this->get($type->getName()); // 递归解析依赖
continue;
}
if ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue(); // 标量参数走默认值
continue;
}
throw new ContainerException(
"{$class} 的参数 \${$param->getName()} 无法自动注入,请显式注册"
);
}
return $ref->newInstanceArgs($args);
}
注意:自动装配只能处理类类型提示。`__construct(string $dsn)` 这种标量参数反射不出来,必须要么给默认值,要么在 `set()` 里用闭包把配置传进去。这是新手最常见的报错来源。另外 PHP 8 的联合类型(`Foo|Bar`)是 `ReflectionUnionType`,会被上面的判断跳过,同样需要显式注册。
第四步:跑起来验证
interface MailerInterface {}
class SmtpMailer implements MailerInterface {}
class UserService
{
public function __construct(private MailerInterface $mailer) {}
}
$c = new Container();
$c->set(MailerInterface::class, SmtpMailer::class); // 接口 → 实现
$c->set('dsn', fn() => 'mysql:host=127.0.0.1');
$svc = $c->get(UserService::class);
var_dump($svc instanceof UserService); // true
var_dump($c->get(UserService::class) === $svc); // true,单例
`UserService` 没注册过,容器靠反射发现它需要 `MailerInterface`,回头查绑定表拿到 `SmtpMailer`,这就是所谓「自动装配」。全部代码加起来 90 行左右。
小结
- PSR-11 只有 `get`/`has` 两个方法,加两个异常标记接口,先 `composer require psr/container`。
- 解析顺序:单例缓存 → 循环依赖检查 → 绑定表 → 闭包/实例/类名 → 反射自动装配。
- 循环依赖必须靠「解析中标记 + `finally` 清理」来拦,否则会栈溢出且报错信息极难读。
- 标量参数、联合类型、抽象类这四种情况反射搞不定,一律显式 `set()` 注册。
- 想让某个服务每次 `get` 都新建实例(transient),就在 `get()` 里改成不入 `$this->instances`,加个 `$shared` 开关即可。
转载请注明出处,版权归原作者所有。
正式会员
认证极客





