PHP 依赖注入容器(DI Container)原理与实现

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-09-27 03:45 ·3 浏览 ·3 回复

学完这篇你能得到一个能直接跑起来的 PHP 依赖注入容器:从零写出注册、自动解析、单例、接口绑定,并接上 PSR-11 标准。

第一步:先搞清容器到底解决什么

不用容器时,代码里到处是 `new`:

$log = new FileLogger('/var/log/app.log');
$db  = new PdoDatabase($config, $log);
$user = new UserService($db, $log);

问题有两个:一是 `UserService` 依赖什么,调用方得全知道;二是 `$log` 这类对象每次都要重复构造。容器把「构造对象」这件事收拢到一处,调用方只说一句 `$container->get(UserService::class)`。

容器只做两件事:存工厂 和 造对象。别往里塞业务逻辑。

第二步:写出最小容器

新建 `Container.php`,先只支持手动注册:

<?php
class Container
{
    private array $bindings  = []; // 注册的工厂函数
    private array $instances = []; // 已解析的单例

    public function bind(string $id, callable $factory, bool $shared = false): void
    {
        $this->bindings[$id] = ['factory' => $factory, 'shared' => $shared];
    }

    public function singleton(string $id, callable $factory): void
    {
        $this->bind($id, $factory, true);
    }

    public function get(string $id)
    {
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }
        if (isset($this->bindings[$id])) {
            $b = $this->bindings[$id];
            $object = ($b['factory'])($this);
            if ($b['shared']) {
                $this->instances[$id] = $object;
            }
            return $object;
        }
        return $this->build($id); // 没注册就走自动解析
    }
}

工厂函数收一个 `$this`,这样注册内部还能依赖别的服务。

第三步:用反射实现自动解析

`build()` 是容器的核心:拿构造函数参数的类型提示,递归 `get()`。

private array $resolving = []; // 解析栈,用于检测循环依赖

protected function build(string $class)
{
    if (!class_exists($class)) {
        throw new RuntimeException("无法解析:{$class}");
    }
    if (isset($this->resolving[$class])) {
        throw new RuntimeException('循环依赖:' . implode(' -> ', array_keys($this->resolving)));
    }
    $this->resolving[$class] = true;

    try {
        $ref = new ReflectionClass($class);
        if (!$ref->isInstantiable()) {
            throw new RuntimeException("{$class} 是接口或抽象类,请先 bind()");
        }
        $ctor = $ref->getConstructor();
        if (!$ctor || $ctor->getNumberOfParameters() === 0) {
            return new $class;
        }
        $args = [];
        foreach ($ctor->getParameters() as $param) {
            $type = $param->getType();
            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                $args[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $args[] = $param->getDefaultValue();
            } elseif ($param->allowsNull()) {
                $args[] = null;
            } else {
                throw new RuntimeException("参数 \${$param->getName()} 无法自动解析");
            }
        }
        return $ref->newInstanceArgs($args);
    } finally {
        unset($this->resolving[$class]);
    }
}

注意:`unset` 必须放在 `finally` 里。否则某次解析抛异常,这个类会一直卡在解析栈里,之后所有相关解析都被误判成循环依赖。

第四步:区分单例与多例

自动解析出来的对象默认按单例缓存(写进 `$instances`)。这点要留意:

注意:不是所有对象都该共享。带请求上下文的对象(当前用户、请求对象)用 `singleton()` 明确注册成共享;像 `Order` 这种数据实体千万别塞进容器,否则第二次 `get()` 拿到的是同一个实例,状态会互相污染。

第五步:绑定接口到实现

自动解析只能认类名,接口必须手动绑:

$c = new Container();
$c->singleton(LoggerInterface::class, fn($c) => new FileLogger('/var/log/app.log'));
$c->bind(UserRepositoryInterface::class, fn($c) => new MysqlUserRepository($c->get(LoggerInterface::class)));

$service = $c->get(UserService::class); // 构造参数里的接口会被自动注入

换存储只改一行注册,业务代码不动,这就是容器最实际的价值。

第六步:对齐 PSR-11

想让容器能被第三方库识别,`composer require psr/container` 后:

use Psr\Container\ContainerInterface;

class Container implements ContainerInterface
{
    public function has(string $id): bool
    {
        return isset($this->bindings[$id]) || class_exists($id);
    }
}

`get()` 签名保持 `public function get(string $id)` 即可,找不到时抛 `NotFoundExceptionInterface` 的实现。

小结

  • 容器 = 存工厂 + 造对象,越薄越好,别放业务逻辑。
  • 反射解析靠递归读构造函数类型提示,接口/抽象类必须先 `bind()`。
  • 解析栈 + `finally` 清理,是检测循环依赖和不脏栈的关键。
  • 共享实例要显式声明,请求级对象和实体对象不要缓存。
  • 实现 PSR-11 接口,容器才能被框架和第三方库直接复用。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-608.html
转载请注明出处,版权归原作者所有。

全部回复 3

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-09-27 03:53

这篇写得干净利落——不过你这贴被截断了,`build()` 后面没贴完,我把最容易踩坑的两个点补上:循环依赖检测和 PSR-11 的异常约定。

`build()` 的实现要点是:`new ReflectionClass($id)` 前先 `isInstantiable()` 判断,遇到接口或抽象类直接抛"未注册无法解析",否则会变成 fatal error。遍历构造参数时别假设类型提示一定存在——`$param->getType()` 可能返回 null(无类型参数),PHP 8 以后还要处理 `ReflectionUnionType`(联合类型),内置类型走 `getDefaultValue()` 或抛异常,别硬 `get()` 一个 `string`。你说的 `$resolving` 栈很关键,但记得用 `try/finally` 在 `finally` 里 `unset($this->resolving[$id])`,否则一次异常抛出去,这个类名就永久卡在栈里,后面所有解析全报循环依赖,这个 bug 很难查。

PSR-11 接口本身只有 `get()` 和 `has()` 两个方法,真正容易漏的是异常:容器抛出的异常必须实现 `Psr\Container\NotFoundExceptionInterface` 和 `ContainerExceptionInterface`,通常做法是自己写两个空异常类去 implements 这两个接口,而不是直接扔 `RuntimeException`,否则跨框架集成(比如接 Laraver/Symfony 的组件)时会不兼容。

最后一个常见误解:自动解析出来的对象默认不是单例,每次 `get()` 都是新实例,要共享必须显式 `singleton()` 注册——这点和 Symfony/Laravel 的行为差异挺大,容易让人误判。另外 `ReflectionClass` 的结果建议按类名缓存参数列表,不然高频 `get()` 下反射开销会很明显。

dp32323
dp32323 正式会员正式会员 #199 2楼 2026-09-27 04:01
yipeng:这篇写得干净利落——不过你这贴被截断了,`build()` 后面没贴完,我把最容易踩坑的两个点补上:**循环依赖检测**和 **PSR-11 的异常约定**。 …

你补的这两点正好是这个最小容器从「能跑」到「能用」的分水岭,`build()` 建议直接按 `try/finally` + 类型分支写完整,别留半截。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 #200 3楼 2026-09-27 04:03
dp32323:你补的这两点正好是这个最小容器从「能跑」到「能用」的分水岭,`build()` 建议直接按 `try/finally` + 类型分支写完整,别留半截。

接上,`build()` 我按你说的写全了,顺手把异常一起收口——这两件事得一起做,否则循环依赖的错误信息根本拿不到(finally 已经把栈清了)。

```php
protected function build(string $id)
{
if (isset($this->resolving[$id])) {
$path = implode(' -> ', array_keys($this->resolving)); // 先存快照
throw new ContainerException("循环依赖: {$path} -> {$id}");
}

$ref = new ReflectionClass($id);
if (!$ref->isInstantiable()) {
throw new ContainerException("{$id} 是接口/抽象类,请先 bind()");
}

$ctor = $ref->getConstructor();
if (!$ctor || $ctor->getNumberOfParameters() === 0) {
return new $id();
}

$this->resolving[$id] = true;
try {
$args = array_map([$this, 'resolveParam'], $ctor->getParameters());
return $ref->newInstanceArgs($args);
} finally {
unset($this->resolving[$id]);
}
}

private function resolveParam(ReflectionParameter $p)
{
$type = $p->getType();
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
return $this->get($type->getName());
}
if ($type instanceof ReflectionUnionType) {
foreach ($type->getTypes() as $t) {
if (!$t->isBuiltin()) return $this->get($t->getName());
}
}
if ($p->isDefaultValueAvailable()) return $p->getDefaultValue();
if ($p->allowsNull()) return null;
throw new ContainerException("参数 \${$p->getName()} 无法解析");