手写 PHP 依赖注入容器:反射工厂与缓存池

发布于
1

依赖注入容器的本质是一台对象装配机。基于 PHP 反射 API 读取构造函数签名,即可完成参数的自动解析与递归注入;再配合由实例缓存、定义缓存、反射元数据缓存组成的三级缓存池,可将反射开销压缩到可忽略区间。以下内容基于 PHP 8.1 与 PSR-11 标准,给出一个不足 150 行、可直接用于生产环境的容器实现,覆盖服务注册、单例绑定、闭包工厂、循环依赖检测等完整能力。

依赖注入容器是现代 PHP 框架的基础设施。Laravel 的 Illuminate\Container、Symfony 的 DependencyInjection 组件、PHP-DI 都围绕同一核心运转:集中管理对象的创建方式,调用方只声明所需类型。理解并手写一个容器,是掌握自动装配原理的最短路径。

一、为什么需要依赖注入容器

1、控制反转与依赖注入的关系

依赖注入(Dependency Injection,DI)是控制反转(Inversion of Control,IoC)的一种落地形式:类不再自行 new 出依赖,而是在构造函数中声明所需类型,由外部装配者提供实例。当依赖链变长——例如 UserService 依赖 LoggerPDOLogger 又依赖配置对象——手工逐层实例化会产生大量重复样板代码,且替换实现时必须改动每一处调用点。

2、容器承担的职责

依赖注入容器把“如何创建对象”这一知识集中管理:服务如何注册、如何解析构造参数、是否共享单例,全部收敛到容器内部。业务代码只面对 $container->get(Service::class) 一个入口,依赖图由容器自动展开。对象之间的耦合从编译期转移到了配置期,单元测试中替换 mock 实现也只需改动一处绑定。

二、容器的核心结构设计

1、服务定义表

定义表是服务标识与创建方式的映射。创建方式通常支持三种形态:类名字符串(交给反射工厂自动装配)、闭包(自定义工厂函数)、已创建实例(直接托管给容器)。接口到实现的绑定同样记录在定义表中,例如 LoggerInterface::class => FileLogger::class

2、反射工厂

反射工厂负责在缺少显式工厂时自动构建对象:通过 ReflectionClass 读取构造函数签名,逐个参数判断类型——类类型递归调用容器的 get() 方法,标量类型回退到默认值,无法解析时抛出明确异常。PHP 8.0 引入的构造器属性提升让签名声明更紧凑,反射的读取方式保持不变。

3、三级缓存池

缓存池决定容器的运行效率,通常分为三层:

  • 实例缓存:标记为单例的服务只创建一次,后续 get() 直接返回同一实例,这是容器的共享语义基础。

  • 定义缓存:绑定关系在注册期写入、解析期只读,避免每次解析重复归一化创建方式。

  • 反射元数据缓存ReflectionClass 实例与构造参数列表的创建涉及类结构扫描,按类名缓存后每个类只反射一次;需要跨进程共享时可再桥接 PSR-6 缓存池实现。

三、环境准备与 PSR-11 约定

运行环境要求 PHP 8.1 及以上版本,代码用到一等可调用语法与构造器属性提升。接口层面遵循 PHP-FIG 发布的 PSR-11(Container Interface)标准,该标准仅定义 get(string $id)has(string $id) 两个方法,异常分为 ContainerExceptionInterfaceNotFoundExceptionInterface 两类。通过 Composer 引入接口包:

composer require psr/container

实现 PSR-11 的价值在于可互换性:任何兼容该标准的框架或组件都能直接替换此容器,业务代码不发生改动。

四、实现容器核心代码

1、注册与单例绑定

set() 负责写入定义表,singleton() 在注册的同时打上共享标记。重复注册同名服务时清除旧实例缓存,保证后续解析拿到最新定义。

<?php
declare(strict_types=1);

namespace App\Container;

use Closure;
use Psr\Container\ContainerInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionNamedType;
use ReflectionParameter;
use RuntimeException;

class ContainerException extends RuntimeException implements ContainerExceptionInterface {}

class NotFoundException extends RuntimeException implements NotFoundExceptionInterface {}

final class Container implements ContainerInterface
{
    /** @var array<string, mixed> 服务定义:类名、闭包或实例 */
    private array $definitions = [];

    /** @var array<string, object> 单例实例缓存 */
    private array $instances = [];

    /** @var array<string, true> 单例标记 */
    private array $shared = [];

    /** @var array<string, ReflectionClass> 反射元数据缓存 */
    private array $reflections = [];

    /** @var array<string, array<int, ReflectionParameter>> 构造签名缓存 */
    private array $signatures = [];

    /** @var array<int, string> 解析栈,用于循环依赖检测 */
    private array $resolving = [];

    public function set(string $id, mixed $concrete = null, bool $shared = false): void
    {
        $this->definitions[$id] = $concrete ?? $id;

        if ($shared) {
            $this->shared[$id] = true;
        }

        unset($this->instances[$id]);
    }

    public function singleton(string $id, mixed $concrete = null): void
    {
        $this->set($id, $concrete, true);
    }

    public function has(string $id): bool
    {
        return isset($this->definitions[$id]) || class_exists($id);
    }
}

2、反射工厂解析构造参数

get() 是解析入口:先查实例缓存,再按定义分发——闭包交给 call(),类名交给 build()build() 内部利用反射缓存读取构造签名,逐参数解析后通过 newInstanceArgs() 完成实例化,命中单例标记的对象写入实例缓存。

    public function get(string $id): mixed
    {
        if (isset($this->instances[$id])) {
            return $this->instances[$id];
        }

        $concrete = $this->definitions[$id] ?? $id;

        // 已创建实例直接托管
        if (is_object($concrete) && !$concrete instanceof Closure) {
            return $this->instances[$id] = $concrete;
        }

        try {
            $object = $concrete instanceof Closure
                ? $this->call($concrete)
                : $this->build(is_string($concrete) ? $concrete : $id);
        } catch (ReflectionException $e) {
            throw new NotFoundException("服务 [{$id}] 未找到", 0, $e);
        }

        if (isset($this->shared[$id])) {
            $this->instances[$id] = $object;
        }

        return $object;
    }

    private function build(string $class): object
    {
        $reflection = $this->reflections[$class] ??= new ReflectionClass($class);

        if (!$reflection->isInstantiable()) {
            throw new ContainerException(
                "[{$class}] 不可实例化,接口与抽象类需要先绑定具体实现"
            );
        }

        $constructor = $reflection->getConstructor();
        if ($constructor === null) {
            return $reflection->newInstance();
        }

        $this->assertNotCircular($class);
        $this->resolving[] = $class;

        try {
            $parameters = $this->signatures[$class] ??= $constructor->getParameters();

            $arguments = [];
            foreach ($parameters as $parameter) {
                if ($parameter->isVariadic()) {
                    break; // 变参无显式绑定时终止注入
                }
                $arguments[] = $this->resolveParameter($parameter);
            }

            return $reflection->newInstanceArgs($arguments);
        } finally {
            array_pop($this->resolving);
        }
    }

    private function resolveParameter(ReflectionParameter $parameter): mixed
    {
        $type = $parameter->getType();

        // 类类型:递归交给容器解析
        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
            return $this->get($type->getName());
        }

        // 标量类型:回退默认值
        if ($parameter->isDefaultValueAvailable()) {
            return $parameter->getDefaultValue();
        }

        throw new ContainerException(
            "无法解析参数 \${$parameter->getName()}:标量缺少默认值,联合类型不参与自动装配"
        );
    }

3、循环依赖检测

构造器注入场景下循环依赖无法被自动满足,唯一正确的处理是快速失败。解析栈 resolving 记录当前递归链,目标类已存在于栈中即判定成环,抛出携带完整依赖链的异常,定位成本接近于零。

    private function assertNotCircular(string $class): void
    {
        if (in_array($class, $this->resolving, true)) {
            $chain = implode(' -> ', [...$this->resolving, $class]);
            throw new ContainerException("检测到循环依赖:{$chain}");
        }
    }

4、闭包工厂与参数注入

闭包定义的服务由 call() 执行。借助 ReflectionFunction,闭包声明的类型化参数同样享受自动注入,工厂函数因此可以同时依赖容器中已注册的其他服务:

    public function call(Closure $factory): mixed
    {
        $reflection = new ReflectionFunction($factory);

        $arguments = [];
        foreach ($reflection->getParameters() as $parameter) {
            $arguments[] = $this->resolveParameter($parameter);
        }

        return $factory(...$arguments);
    }

五、完整使用示例

以下示例覆盖接口绑定、单例、闭包工厂与自动装配四种典型场景:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use App\Container\Container;

interface LoggerInterface
{
    public function log(string $message): void;
}

final class FileLogger implements LoggerInterface
{
    public function __construct(private string $path = '/tmp/app.log') {}

    public function log(string $message): void
    {
        file_put_contents($this->path, $message . PHP_EOL, FILE_APPEND);
    }
}

final class UserService
{
    public function __construct(
        private LoggerInterface $logger,
        private PDO $db,
    ) {}

    public function register(string $name): void
    {
        $this->logger->log("register: {$name}");
        // 数据库写入逻辑……
    }
}

$container = new Container();

// 接口绑定到实现,单例共享
$container->singleton(LoggerInterface::class, FileLogger::class);

// 闭包工厂:带配置的 PDO 连接,同样注册为单例
$container->singleton(PDO::class, fn(): PDO => new PDO(
    'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4',
    'app',
    'secret',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
));

// UserService 未注册,反射工厂自动解析 LoggerInterface 与 PDO
$userService = $container->get(UserService::class);

// 单例验证:两次解析返回同一实例
var_dump($container->get(PDO::class) === $container->get(PDO::class)); // bool(true)

UserService 从未显式注册,get(UserService::class) 依然完成了整个依赖图的装配:反射工厂读取构造签名,LoggerInterface 命中定义表中的绑定,PDO 命中闭包工厂,标量参数 $path 回退到默认值。

六、缓存池的性能与内存权衡

1、反射元数据缓存的收益

ReflectionClass 的实例化涉及类结构扫描,单次开销在微秒量级,低并发下可以忽略;但在高并发服务中同一服务被解析成千上万次,无缓存的容器会反复支付这笔成本。按类名缓存反射对象与构造签名后,每个类在整个进程生命周期内只反射一次。Symfony DI 在此基础上更进一步:把解析结果编译为纯 PHP 文件,运行时完全绕开反射,这是大型项目值得参考的方向。

2、单例缓存的内存代价

单例的生命周期与容器一致。在 PHP-FPM 模型下请求结束即整体释放,无需特别关注;在 Swoole、Laravel Octane 等常驻内存模型中,单例会持续驻留内存,请求级数据(当前用户、请求 ID)一旦写入单例就会造成跨请求污染,此类状态应通过工厂每次新建,或放入协程上下文。

3、OPcache 与预加载配合

开启 OPcache 后,容器代码以共享内存字节码形态存在,省去每次请求的编译开销;PHP 7.4 起提供的 opcache.preload 可在服务启动时把容器类与定义表预热进内存,进一步压缩首个请求的解析成本。容器自身的数组结构对 OPcache 友好,无需特殊适配。

七、生产环境注意事项

  • 接口必须先绑定再解析ReflectionClass::isInstantiable() 对接口与抽象类返回 false,容器抛出异常提示绑定,避免含糊的运行时错误。

  • 标量参数不猜测:配置值应通过闭包工厂或参数绑定显式提供,容器只对带默认值的标量做回退,拒绝“猜配置”的隐式行为。

  • 联合类型不参与自动装配A|B 形式的参数无法唯一确定目标,按异常处理并在提示中说明,与 PHP-DI 的默认行为一致。

  • 检测循环依赖后快速失败:构造器注入成环没有合法解,应修改设计(如引入中间层或改为 setter 注入),容器职责是给出清晰的依赖链报告。

  • 长生命周期进程注意清理:常驻内存模型中,请求结束可调用清理方法清空 instancesresolving,防止内存无界增长。

常见问题(FAQ)

依赖注入容器与服务定位器有什么区别?
依赖注入要求类在构造函数中显式声明依赖,依赖关系对外可见、可被静态分析工具检查;服务定位器模式则是在类内部主动调用 $container->get(),依赖被隐藏。两者可共存于同一容器,注入用于业务类装配,定位仅适合作为框架入口或兼容旧代码的过渡手段。
反射解析的性能开销大吗?
单次 ReflectionClass 实例化为微秒级开销,配合元数据缓存后每个类只反射一次,实际影响可以忽略。对性能极端敏感的场景可采用编译式容器方案,把依赖图在构建期固化为纯 PHP 代码,运行时零反射。
接口没有绑定实现时容器会怎样?
反射读取到接口类型后会尝试实例化,isInstantiable() 返回 false,容器抛出 ContainerException 并提示先绑定实现。这一行为属于快速失败设计,比运行到一半再报模糊错误更容易定位问题。
构造函数中的标量参数如何注入?
容器对带默认值的标量自动回退默认值;无默认值的标量(如数据库 DSN、API 密钥)应通过闭包工厂显式构建,或扩展参数绑定机制按名称映射配置值。自动装配只处理类类型参数是业界通行的安全边界。
循环依赖能自动解决吗?
构造器注入形成的环无法自动解决,任何容器都一样。正确做法是重新设计依赖方向,或将其中一环改为 setter 注入、事件通知等延迟解析方案。容器通过解析栈检测成环并输出完整依赖链,可大幅降低排查成本。
0 讨论
热门最新
总结
暂无总结
0 / 600
嗨,下午好!
所有的成功,都源自一个勇敢的开始
¥10.00
10元抵扣券
已过期