框架用久了容易把「容器」当黑盒。这篇从零写一个能用的 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);

问题有三个:

  1. 依赖的构造顺序要人肉维护,$logger 得在最前面
  2. $logger 被 new 了两次,如果改成单例就得手动传同一个实例
  3. 想换成 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 了两次」这类问题时,知道该看哪里

理解了原理,用别人的库才用得踏实。