ryunosuke/castella

PHP 依赖注入容器

v2.0.1 2024-08-03 09:20 UTC

This package is auto-updated.

Last update: 2024-09-03 09:33:27 UTC


README

描述

基于配置文件的 DI 容器。具有以下功能:

  • 构造函数注入
  • 字段注入
  • 自动绑定
  • 循环引用解决

安装

{
  "require": {
    "ryunosuke/castella": "dev-master"
  }
}

概念

  • 基于配置文件
    • “DI 容器(+配置文件)”而不是“配置文件(+DI 容器)”
    • 也就是说,“想将 DI 容器当作配置文件使用”而不是“想将配置文件当作 DI 容器使用”是基本概念
  • 最小化简洁
    • 消除复杂依赖,完全无依赖(除 psr11)+ 单一文件组成
    • 最坏情况下,假设可以通过“粘贴复制代码来更改代码库”(如果依赖项很多且文件超过100个,则无法这样做)
  • 简单
    • “支持所有注入”而不是“根据 DI 容器设计类”。不是那种随便引入外部对象的松散设计。
    • 支持的特性是“构造函数注入”、“字段注入”、“自动绑定”
    • “设置器注入”、“注解”、“方法调用”等尚未实现,且没有计划实现
    • 编译、转换、缓存等都没有。速度足够快
  • 简洁
    • 配置文件的语法是“写值”或“写闭包”
    • 值的情况下直接使用;闭包的情况下延迟执行

规范

  • 设置到容器中的数据源是数组(或返回数组的文件)
    • 虽然提供了 set,但内部是数组
  • 数组按键合并(覆盖)
    • 没有区分关联数组或索引数组。因此,索引数组的合并可能会产生意外的结果
    • 要定义完全覆盖的数组,则需要使用闭包包装或使用数组方法
  • “值”指的是“除闭包以外的所有内容”
  • 闭包总是延迟执行。也就是说,“在需要的时候”才执行
    • 因此,如果要在条目中设置闭包,则需要设置“返回闭包的闭包”
    • 闭包的值类型是“类型声明返回值的类型”。这样可以在不执行闭包的情况下进行类型检查
    • 如果没有指定返回值类型,则默认为 void,成为依赖关系解决的对象
    • 静态闭包将每次返回相同的实例。非静态普通闭包将每次生成并返回
  • 闭包的参数是 (容器, 键逆序) 固定
    • 这意味着闭包参数中没有类型检测
    • 此规范适用于设置上下文中的所有闭包

用法

基本

简而言之,此包是“可以解决依赖关系的配置库”。设想以下用例:环境或开发者之间的设置。

首先,假设有以下默认设置。

<?php
# $container->include('default.php');

/**
 * @var ryunosuke\castella\Container $this
 */

return [
    'env'      => [
        'name'      => 'local',
        'origin'    => 'https://',
        'loglevel'  => LOG_INFO,
        'logdir'    => '/var/log/app',
        'rundir'    => '/var/run/app',
        'datadir'   => '/var/opt/app',
        'extension' => ['js', 'es', 'ts'],
    ],
    'database' => [
        'driver'        => 'pdo_mysql',
        'host'          => '127.0.0.1',
        'port'          => 3306,
        'dbname'        => 'app',
        'user'          => 'user',
        'password'      => 'password',
        'charset'       => 'utf8mb4',
        'driverOptions' => [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ],
    ],
    's3'       => [
        'config' => [
            'region'  => 'ap-northeast-1',
            'version' => 'latest',
        ],
        'client' => $this->static(S3Client::class),
    ],
    'storage'  => [
        'private' => static fn($c, $key) => new Storage($c['s3.client'], $key),
        'protect' => static fn($c, $key) => new Storage($c['s3.client'], $key),
        'public'  => static fn($c, $key) => new Storage($c['s3.client'], $key),
    ],
];

这是“应用程序运行所需的最基本操作和开发人员参考/模式”之类的文件。这种类型的文件将在所有环境中都必读,如基础设置。仅此不足以有意义,通常无法正常工作。

然后,根据环境变量或主机名等设置以下个别设置。

<?php
# $container->include('myconfig.php');

/**
 * @var ryunosuke\castella\Container $this
 */

return [
    'env'      => [
        'origin'    => 'http://myself',
        'loglevel'  => LOG_DEBUG,
        'extension' => $this->array(['php']),
    ],
    'database' => [
        'host'          => 'docker-mysql',
        'driverOptions' => [
            \PDO::ATTR_EMULATE_PREPARES => false,
        ],
    ],
    's3'       => [
        'config' => [
            'credentials' => [
                'key'    => 'minio',
                'secret' => 'minio123',
            ],
            'endpoint'    => 'http://minio.localhost',
        ],
    ],
];

由于数组将被合并,因此仅指定“要覆盖默认设置的条目”。在上面的示例中,尊重默认设置,仅覆盖“希望日志级别为 DEBUG”、“希望使用 docker 的 mysql 作为 DB”、“希望使用 minio 作为 S3”。这就是“基于配置文件”的原因。

此外,存储设置或 S3 客户端等使用简单的语法解决依赖关系。延迟执行+键覆盖机制确保不执行不必要的操作,且实例将被重用。这就是“想将配置文件当作 DI 容器使用”的原因。

请注意,由于 env.extension 的值是 $this->array,因此其值为 ['php']。由于数组将被合并,如果没有此 $this->array,则 0 级别的值将变为 ['php', 'es', 'ts']。要完全覆盖数组,则需要使用 $this->array

此外,传递给 storage 以下闭包的是其自身键的逆序。通过利用参数,可以避免在键值使用时重复相同的描述。

构造函数

使用构造函数设置各种选项。这里设置的选项不能更改。所有选项都是可省略的,以下是默认值。

$container = new \ryunosuke\castella\Container([
    'debugInfo'            => null,
    'delimiter'            => '.',
    'autowiring'           => true,
    'constructorInjection' => true,
    'propertyInjection'    => true,
    'resolver'             => $container->resolve(...),
]);

debugInfo: ?string

指定 __debugInfo 的行为。

为未来的扩展而设置为 string 类型,但当前仅支持 null|"settled" 的两种形式。提供 null 将导致 var_dump(所有成员都显示)成为标准,提供 "settled" 将导致仅输出条目。当前的默认值是 null,但 "settled" 通常很有用,因此此默认值可能会在将来更改。此外,此默认值的更改将包含在兼容性保证中。也就是说,“使用 ob_start 捕获 var_dump 的输出结果”之类的处理可能会破坏兼容性。

当前的默认值是 null,但通常 "settled" 很有用,因此此默认值可能会在将来更改。此外,此默认值的更改将包含在兼容性保证中。也就是说,“使用 ob_start 捕获 var_dump 的输出结果”之类的处理可能会破坏兼容性。

delimiter: string

指定访问时数组的分隔符。

$container->extends([
    'a' => [
        'b' => [
            'c' => 'X',
        ],
    ],
]);

在这种情况下,可以像 $container->get('a.b.c') 一样访问。

autowiring: bool

指定在尝试访问未注册条目时是否使用类型名自动解决。

设置为 true 时,即使是非条目,如果存在类,也可以获取其实例。在这种情况下,将递归地解决依赖关系。设置为 false 时,无法获取非条目。

constructorInjection: bool

指定在解决依赖关系时是否查看构造函数的参数类型和名称以进行注入。

设置为 true 时,将在后续的 resolver 中尝试解决。设置为 false 时,必须使用 $arguments 参数完全覆盖必需的参数。

propertyInjection: bool

指定在解决依赖关系时是否查看属性的类型和名称以进行注入。

设置为 true 时,将在后续的 resolver 中尝试解决。在这种情况下,将忽略可空或已初始化的属性。换句话说,使用 notnull 将解决未初始化的属性。设置为 false 时,将完全停止触摸属性。

resolver: callable

指定依赖关系解决的 callable。

callable 的签名是 (ReflectionParameter|ReflectionProperty $reflection): mixed。此处返回的值作为依赖值获取。

默认情况下,按以下顺序尝试解决。

  1. $type 为标量值时,将 _ 替换为 delimiter 的参数名或属性名条目
  2. $type 的类型表示条目
  3. 与类型匹配的条目(全搜索)

“与类型匹配”意味着唯一性。如果找不到匹配的类型或存在多个,则无法检测。

例如,__construct(TypeName $this_is_arg) 的案例,首先检查条目中是否注册了 TypeName 名称,如果不存在,则从所有条目中搜索 TypeName 实例。如果仍然无法解决,则依赖关系解决失败,并引发异常。

条目设置

在以下设置方法中,无法覆盖已经获取过的动态、静态或延迟获取的条目。如果尝试覆盖获取过的条目,则引发异常。

extends(array $values): self

将指定的数组作为条目导入。指定过的键将被递归地合并(覆盖)。

使用空格分隔键,左侧是条目名称,右侧用作别名。如果指定别名,则可以通过该别名名称进行访问。

$container->extends([
    'a' => [
        'b' => [
            'c abc' => 'X',
        ],
    ],
]);

像这样,可以通过 a.b.cabc 进行访问。

include(string $filename): self

在容器的上下文中读取文件并将返回值导入。实际上,它与 $container->extends((fn() => include $filename)->call($container)) 几乎相同。

区别在于可以使用 $this['entry']。在 include 的上下文中,直接引用 $this['entry'] 会被转换为特殊的 delay evaluation object,即使其值在引用时未定义,也可以使用该值。也就是说,以下 fuga 和 piyo 是同义的。

<?php return [
    'hoge' => 1,
    'fuga' => $this['hoge'],
    'piyo' => static fn($c) => $c['hoge'],
];

因为它是指向自己的条目,所以是静态的。

mount(string $directory, ?array $pathes = null, ?string $user = null): self

根据 $pathes 指定的路径读取指定目录内的所有文件。扩展名固定为 .php

“根据 $pathes 指定”是指简单地表示文件系统的层次结构。在这种情况下,目录内的 .php(无名称的 php 文件)将始终被读取。

./mount
├── .php
├── com
│   ├── .php
│   └── example
│        ├── .php
│        └── host.php
├── net.example.host.php
├── net.example.php
├── net.php
└── org.example
    ├── .php
    └── host.php

在这种情况下,使用 mount 目录指定读取的文件如下。

  • []:
    • mount/.php:无名称的 php 文件,因此始终被读取
  • ['com']:
    • mount/.php:同上
    • mount/com/.php:无名称的 php 文件,因此始终被读取
  • ['com', 'example']:
    • mount/.php:同上
    • mount/com/.php:同上
    • mount/com/example/.php:无名称的 php 文件,因此始终被读取
  • ['com', 'example', 'host']:
    • mount/.php:同上
    • mount/com/.php:同上
    • mount/com/example/.php:同上
    • mount/com/example/host.php:因为 basename 一致,所以被读取
  • ['net']:
    • mount/.php:同上
    • mount/net.php:无名称的 php 文件,因此始终被读取
  • ['net', 'example']:
    • mount/.php:同上
    • mount/net.php:同上
    • mount/net.example.php:无名称的 php 文件,因此始终被读取
  • ['net', 'example', 'host']:
    • mount/.php:同上
    • mount/net.php:同上
    • mount/net.example.php:同上
    • mount/net.example.host.php:因为 basename 一致,所以被读取
  • ['org']:
    • mount/.php:同上
  • ['org', 'example']:
    • mount/.php:同上
    • mount/org.example/.php:无名称的 php 文件,因此始终被读取
  • ['org', 'example', 'host']:
    • mount/.php:同上
    • mount/org.example/.php:同上
    • mount/org.example/host.php:因为 basename 一致,所以被读取

简而言之,类似于 apache 的 htaccess 的关系,读取指定层级的文件和在其通过过程中的目录的 .php。读取优先级是目录。

以上是 com 是完全嵌套结构、net 是完全平坦结构、org 是其中间(如果没有必要,则可以合并目录)的示例。请注意,当这些配置混合在一起时,其行为是未规定的(可能所有内容都会被读取。虽然不会有损害,但会变得非常低效)。

$pathes 默认情况下是主机名的逆序。如果提供了参数,则不变地使用它。通常,会提供来自环境变量的值。

如果提供了 $user,则还会读取最终文件名为 basename@{$user}.php 的文件。这是为了使现有设置文件具有变体而提供的功能,user 这个参数没有特别的意义(如果硬要说的话,它使用默认值作为用户名)。如果想要读取这样的文件,只需在 $pathes 中指定即可,这种功能的用途非常有限。

set(string $id, $value): self

使用 id 指定设置条目。id 可以通过 delimiter 隐藏。实际上,$container->set('a.b.c', 'X')$container->extends(['a' => ['b' => ['c' => 'X']]]) 同义。

条目获取

has(string $id): bool

返回条目是否存在,并返回布尔值。

get(string $id): mixed

获取条目。获取的条目是解析后的,可以获取完整的值。

例如,$container->get('') 使用空字符串时,可以以数组的形式获得所有条目,但并不推荐这样做。通过这种方式获得的价值是“所有子节点都已解析的数组”,这是禁用的获取方法,因为它会导致所有延迟获取都无效。

为了有效地利用延迟获取,建议尽可能小地使用 get。

fn(string $id): Closure

返回获取条目的闭包。实际上,与 fn() => $container->get($id) 同义。

可以作为“还不确定是否需要使用,因此想用闭包包装起来获取”的糖衣语法来使用。

MagicAccess

实现了属性的重载方法,也可以进行类似属性的访问。如下处理。

  • isset($container->key) => $container->has('key')
  • $val = $container->key => $val = $container->get('key')
  • $container->key = $val => $container->set('key', $val)
  • unset($container->key) => 不支持

ArrayAccess

实现了 ArrayAccess,也可以进行类似数组的访问。如下处理。

  • isset($container['key']) => $container->has('key')
  • $val = $container['key'] => $val = $container->get('key')
  • $container['key'] = $val => $container->set('key', $val)
  • unset($container['key']) => 不支持

工具

unset(): object

从最终结果中删除其条目。

用于“在父级别中定义,但在子级别中需要删除”的情况。换句话说,以下设置的最终结果不包含 hoge

<?php
$container->extends([
    'array' => [
        'hoge  => 'HOGE',
        'fuga' => 'FUGA',
        'nest' => [
            'hoge  => 'HOGE',
            'fuga' => 'FUGA',
        ],
    ],
]);
$container->extends([
    'array' => [
        'hoge  => $container->unset(),
        'fuga' => 'FUGA',
        'nest' => [
            'hoge  => $container->unset(),
            'fuga' => 'FUGA',
        ],
    ],
]);

const($value, ?string $name = null): Closure

返回其值。

此方法中提供的值将在后续的 define 调用中被定义为常量。定义位置无关。const 被用作将值定义为一个常量的快捷方式。这就像记住 const 的位置,然后用 define 一键注册一样。

define(): array

const 返回的值将一括定义。

它的好处在于

  • 不需要管理哪些是常量
  • 可以使用延迟定义

<?php return [
    'hoge  => $this->const('HOGE', 'CONST_NAME'),
    'fuga' => 'FUGA',
    'nest' => [
        'hoge  => $this->const('HOGE'),
        'fuga' => 'FUGA',
    ],
];

上述内容是设置文件作为值不使用 const 的情况与完全相同(hoge'HOGE'nest.hoge'HOGE')。在这种情况下,调用 define

  • define("CONST_NAME", "HOGE");
  • define("NEST\\HOGE", "HOGE");

产生相同的效果。即使不使用 constdefine,也可以逐个使用 define("CONST_NAME", $container->get("key.to.const")); 来定义常量,但很容易发生遗漏。在设置文件中使用 const 可以保证“它是一个常量”和“它将被定义为常量”,因此可以避免遗漏。

此外,如上所述,常量名是可选的。如果没有指定,则将条目的键转换为大写命名空间定义。

env(string $name): ?string

getenv 的包装器,返回环境变量的值。

getenv 的第二个参数是 true。如果dotenv 中设置了 putenv(dotenv),则返回它。

接受可变参数,返回找到的第一个环境变量。如果所有环境变量都不存在,则返回null(而不是false)。也就是说,可以使用??来获取默认值,如下所示。

<?php return [
    'tmpbyenv' => $this->env('TMP', 'TEMP', 'TMPDIR') ?? '/path/to/default',
];

new(string $classname, array $arguments = []): object

生成给定类名的实例。

依赖关系将自动解决,但如果使用$arguments提供,则优先使用。$arguments支持命名参数和位置参数。

实际上相当于“可以省略依赖参数的new操作符”。也就是说,下面的hoge1和hoge2是等价的。

<?php return [
    'hoge_arg1' => 1,
    'hoge_arg2' => 2,
    'hoge1'     => new Hoge(1, 2),
    'hoge2'     => $this->new(Hoge::class),
];

yield(string $classname, array $arguments = []): Closure

返回一个类型安全的闭包,用于执行$this->new

这是在设置文件中自动解决实例时的糖衣语法。也就是说,下面的hoge1、hoge2和hoge3是等价的。

<?php return [
    'hoge_arg1' => 1,
    'hoge_arg2' => 2,
    'hoge1'     => fn($c): Hoge => new Hoge($c['hoge_arg1'], $c['hoge_arg2']),
    'hoge2'     => $this->yield(Hoge::class),
    'hoge3'     => $this->yield(Hoge::class, [1 => $this['hoge_arg2']]),
];

如果Hoge类的构造函数参数是$hoge_arg1或者$hoge_arg2可以类型安全地自动解决,则将以这种方式生成Hoge实例。hoge3在yield的同时延迟执行参数。

如果依赖关系完全封闭,那么使用yield自动解决会更方便。另外,我经常忘记返回Hoge的声明。没有返回值类型的闭包不是依赖关系解决的目标,所以这种语法在“只想获取该类型的实例”的情况下很有用。

static(string $classname, array $arguments = []): Closure

yield的静态版本。其他方面完全相同。也就是说,下面的hoge1和hoge2是等价的。

<?php return [
    'hoge_arg1' => 1,
    'hoge_arg2' => 2,
    'hoge1'     => static fn($c): Hoge => new Hoge($c['hoge_arg1'], $c['hoge_arg2']),
    'hoge2'     => $this->static(Hoge::class),
];

parent(callable $callback): Closure

返回一个接受最近父值并将其转换为值的闭包。在需要利用父设置进行轻微更改时使用。

# parent.php
<?php return [
    'array'  => ['a', 'b'],
    'object' => new Something(),
];
# child.php
<?php return [
    'array'  => $this->parent(fn($parent) => array_merge($parent, ['c'])),
    'object' => $this->parent(fn($parent) => $parent->setSomething()),
];

上面的array是['a', 'b', 'c']。object是调用父的new Something()实例的setSomething。

callable(callable $entry): Closure

返回一个将给定的callable转换为闭包的闭包。根据规范,为了将闭包作为值设置,需要定义一个“返回闭包的闭包”,但这是一种糖衣语法。也就是说,下面的callable1和callable2是等价的。

<?php return [
    'callable1' => static fn(): Closure => fn() => 'something',
    'callable2' => $this->callable(fn() => 'something'),
];

自动添加static。这是基于“返回闭包的闭包不需要每次都生成”的假设。

array(array $array): Closure

返回一个直接返回给定数组的闭包。但是,数组内的闭包将在运行时解决。

当需要完全覆盖数组而不是合并时,这是一种糖衣语法。也就是说,下面的array1和array2是等价的。

<?php return [
    'array1' => fn(): array => ['php', 'js'],
    'array2' => $this->array(['php', 'js']),
];

看起来几乎相同,但和yield/static一样,容易忘记类型声明,并且合并数组可能会导致不希望的情况发生,所以这种语法在这种情况下很有用。

annotate(?string $filename): array

基于设置的实际情况,以phpstorm.meta.php格式输出字符串。

此功能是开发支持功能,因此所有延迟闭包都将解决。本运行时不打算调用。如果提供$filename,则将在该文件中创建一个map数组,以便使用$container['hoge']$container->get('hoge')进行代码补全,并利用类型进行映射。返回值是一个具有名称的类型数组。

typehint(?string $filename): array

输出具有设置的实际情况的类型属性的类定义。

此功能是开发支持功能,因此所有延迟闭包都将解决。本运行时不打算调用。如果提供$filename,则将在该文件中创建一个类定义,以便使用$container->hoge进行代码补全,并利用类型进行映射。返回值是一个具有名称的类型数组。

dump(string $id = ''): string

基于设置的实际情况,根据指定id进行转储。

此功能是开发支持功能,因此所有延迟闭包都将解决。本运行时不打算调用。用于检查对象唯一性或检查一些小值。

Q&A

  • Q.“为什么基于设置文件?”
    • A. 因为在设置文件中描述的也倾向于在容器中描述,所以没有找到分开的意义。
  • Q.“有设置注入或注释吗?”
    • A. 我只是不喜欢设置注入。那只是DI中应该避免的简单方法调用。
    • A. 注释是过时的,因为它会增加大量的依赖,所以如果要做,我会用属性来处理。
  • Q.“为什么方法名中有关键词绑定?”
    • A. 当我首先实现了include和extends时,它意外地变成了这样。
  • Q.“为什么非静态的工厂行为?”
    • A. 因为我想结束于值或闭包。static关键字只是简单的引用。
  • Q.“为什么是卡斯特拉?”

Lisence

MIT

Release

版本号遵循Romantic Versioning

2.0.1

  • [refactor] 将SplObjectStorage更改为WeakMap
  • [merge] 1.0.8

2.0.0

  • [change] php>=8.0
    • 没有不兼容性,但由于管理上的原因,进行了版本升级

1.0.8

  • [feature] 支持常量定义

1.0.7

  • [feature] 在mount方法中添加了user参数

1.0.6

  • [feature] 添加了可以批量读取指定目录的mount方法
  • [feature] 添加了可以更改父值的parent方法
  • [feature] 添加了返回环境变量的env方法

1.0.5

  • [fixbug] 在某些IDE中,如果不给ArrayShape的键加引号,则会出现notice的不合
  • [fixbug] 在typehint类中没有构造函数时会发出notice的不合

1.0.4

  • [tests] 由于引入了lazy访问,因此应该不再需要yield的fn,因此添加了测试用例
  • [refactor] 废弃eval并将其作为元数据保留
  • [feature] 实现了魔法访问及其类型提示输出功能
  • [fixbug] 修正了类名不完整的不合

1.0.3

  • [feature] 实现了可以隐藏父值的unset

1.0.2

  • [feature] 在include的上下文中直接引用时,实现了延迟求值的功能
  • [feature] 实现了debugInfo

1.0.1

  • [feature] 在include的上下文中直接引用时,将其作为闭包处理
  • [fixbug] 修正了preg_replace中元字符丢失的不合

1.0.0

  • publish