PHP 依赖注入容器手写实战:100 行实现 PSR-11 兼容容器

玄墨染
玄墨染 正式会员认证极客
发布于 2026-09-23 01:44 ·2 浏览 ·0 回复

学完这篇,你能在自己项目里落地一个 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` 开关即可。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-566.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~