CClara轻量论坛系统

PHP 依赖注入原理:容器实现与反射解析

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-11-01 12:18 ·4 浏览 ·0 回复
内容摘要

帖子围绕 PHP 依赖注入容器的实现原理展开:先指出在类内直接 new 依赖会导致硬编码、耦合和难以测试,再用 ReflectionClass 解析构造函数参数类型,区分内置类型与类类型,最后用不到 100 行代码实现一个支持 bind、singleton、make 的最小容器,以此说明 Laravel、ThinkPHP 中 app()->make() 背后的自动解析与实例化机制。

学完这篇,你能用不到 100 行 PHP 写一个可用的依赖注入容器,并看懂 Laravel、ThinkPHP 里 app()->make() 背后到底做了什么。

第一步:先看清要解决的问题

假设有个类这样写:

class OrderController
{
    public function __construct()
    {
        $this->db = new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '123456');
    }
}

三个问题:连接参数硬编码在类里、想换实现就得改源码、写单元测试时没法塞一个假数据库进去。改造方式是把依赖从“自己 new”变成“外面传进来”:

class OrderController
{
    public function __construct(private PDO $db) {}
}

这就是依赖注入。但问题随之而来——谁负责传?如果每个调用点都得手动 new PDO 再 new OrderController,只是把耦合从类里搬到了调用点。容器就是替你做这件事的工厂。

第二步:用反射看清一个类“想要什么”

反射的用途是:给容器一个类名,让容器自己问出来“构造函数需要哪些参数”。核心 API 就四个:

$ref  = new ReflectionClass(OrderController::class);
$ctor = $ref->getConstructor();              // 构造函数,可能是 null
$params = $ctor->getParameters();            // 参数列表
$type = $params[0]->getType();               // 参数类型声明

拿到的 $type 有两种情况需要区分:内置类型(int、string、array)返回 ReflectionNamedType 且 isBuiltin() 为真;类类型返回同样的对象但 isBuiltin() 为假,此时 getName() 就是类名——这正是容器需要递归创建的那一层。PHP 8 的联合类型返回的是 ReflectionUnionType,跟 ReflectionNamedType 不是同一个类。

第三步:写出最小可用容器

class Container
{
    protected array $bindings = [];   // 抽象 => 实现
    protected array $instances = [];  // 单例实例
    protected array $singletons = [];

    public function bind(string $abstract, $concrete = null): void
    {
        $this->bindings[$abstract] = $concrete ?? $abstract;
    }

    public function singleton(string $abstract, $concrete = null): void
    {
        $this->bind($abstract, $concrete);
        $this->singletons[$abstract] = true;
    }

    public function make(string $abstract)
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }
        $concrete = $this->bindings[$abstract] ?? $abstract;
        $object = $concrete instanceof Closure
            ? $concrete($this)
            : $this->build($concrete);

        if (isset($this->singletons[$abstract])) {
            $this->instances[$abstract] = $object;
        }
        return $object;
    }

    protected function build(string $class)
    {
        if (!class_exists($class)) {
            throw new RuntimeException("无法解析:{$class}");
        }
        $ref = new ReflectionClass($class);
        if (!$ref->isInstantiable()) {
            throw new RuntimeException("{$class} 是接口或抽象类,请先 bind 到具体实现");
        }

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

        $args = [];
        foreach ($ctor->getParameters() as $param) {
            $args[] = $this->resolveParameter($param);
        }
        return $ref->newInstanceArgs($args);
    }

    protected function resolveParameter(ReflectionParameter $param)
    {
        $type = $param->getType();

        if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
            return $this->make($type->getName());   // 类类型:递归解析
        }
        if ($param->isDefaultValueAvailable()) {
            return $param->getDefaultValue();       // 有默认值就用默认值
        }
        if ($param->allowsNull()) {
            return null;
        }
        throw new RuntimeException("无法解析参数 \${$param->getName()}");
    }
}

用法:

$c = new Container();
$c->singleton(PDO::class, fn() => new PDO('mysql:host=127.0.0.1;dbname=test', 'root', '123456'));
$c->bind(OrderServiceInterface::class, OrderService::class);

$controller = $c->make(OrderController::class);

容器发现 OrderController 的构造函数需要一个 OrderServiceInterface,查绑定表得知实现是 OrderService,于是递归去创建 OrderService;OrderService 又需要一个 PDO,命中单例直接返回。整条依赖链一次 make 全部打通。

第四步:三个必须补的细节

接口绑定:容器不能 new 一个接口,isInstantiable() 会返回 false。所以接口必须先 bind,这是容器存在的最大理由——换实现只改一行配置。

单例缓存:数据库连接、配置对象这类东西每次 make 都新建会出事。上面用 singletons 数组做了标记,instances 存实例。

Closure 绑定:bind(PDO::class, fn() => new PDO(...)) 这种闭包形式允许你把不能自动反射的标量参数(主机名、密码)手动塞进去,这是绕开反射限制的标准做法。

第五步:几个容易踩的坑

注意:反射只能拿到类型声明,拿不到标量的值。__construct(private string $dsn) 这种参数,反射只能看到“它是个 string”,容器猜不出内容,必须用闭包绑定显式提供,或者给参数写默认值。

注意:循环依赖会让容器无限递归直到爆栈。上生产前在 make 里加一个 $resolving 数组记录当前解析栈,重复出现同名类就抛异常,比内存耗尽好排查得多。

注意:ReflectionClass 的开销不低。容器本身几乎不会成为瓶颈(一个请求通常几次 make),但如果你在循环里几千次 make,就考虑缓存 ReflectionClass 对象或提前把解析结果缓存下来。别一上来就优化,先测量。

注意:不要拿容器当全局注册表。$container->make('config') 散落在业务代码各处,等于把 new 换成了更隐蔽的耦合。容器的正确用法是只在入口(控制器、命令、路由处理器)解析一次,类内部依然通过构造函数接收依赖。

小结

  • 依赖注入的本质是“依赖由外部传入”,容器只是自动完成传递的工厂,两者是不同概念。
  • 反射三步走:ReflectionClass → getConstructor() → getParameters(),靠 getType() 判断是类类型还是内置类型。
  • 容器核心只有两个方法:bind 登记抽象到实现的映射,make 递归解析并组装对象。
  • 类类型递归 make,有默认值用默认值,可空返回 null,其余抛异常——这四行判断决定了容器的健壮度。
  • 接口必须显式绑定,闭包绑定用来处理反射拿不到的标量参数。
  • 循环依赖要有检测,容器只在入口使用。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1061.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 0

还没有回复,来抢沙发~