polymorphine/container

PSR-11 容器,适用于库和配置

1.0.2 2022-10-05 00:44 UTC

This package is auto-updated.

Last update: 2024-09-05 05:07:31 UTC


README

Latest stable release Build status Coverage status PHP version LICENSE

PSR-11 容器,适用于库和配置

概念特性

  • 不可变 PSR-11 实现
  • 封装配置(更多
  • 获取存储值的抽象策略,以便通过一些内置的原始策略扩展功能(更多
  • 与子容器的组合
  • 可选路径表示法访问配置值(更多
  • 开发模式,用于完整性检查、调用堆栈跟踪和循环引用保护(更多
  • 默认情况下显式配置 - 可以通过自定义策略解决自动装配的依赖项(不包括在内)
  • 适用于通用内容的预期有限用途:库、配置和其他独立于上下文的对象或函数(更多

使用 Composer 安装

composer require polymorphine/container

容器设置

此示例将展示如何设置简单容器。它从实例化 Setup 类型对象开始,并使用其方法设置容器的条目

use Polymorphine\Container\Setup;

$setup = Setup::production();
$setup->set('value')->value('Hello world!');
$setup->set('domain')->value('http://api.example.com');
$setup->set('direct.object')->value(new ClassInstance());
$setup->set('deferred.object')->callback(function (ContainerInterface $c) {
    return new DeferredClassInstance($c->get('value'));
});
$setup->set('composed.factory')->instance(ComposedClass::class, 'direct.object', 'deferred.object');
$setup->set('factory.product')->product('composed.factory', 'create', 'domain');

$container = $setup->container();

$container->has('composed.factory'); // true
$container->get('factory.product'); // return result of ComposedClass::create() call

而不是使用构建器方法配置每个条目,您可以向 Setup 构造函数之一传递 Record 实例的数组

$setup = Setup::production([
   'value'            => new Record\ValueRecord('Hello world!'),
   'domain'           => new Record\ValueRecord('http://api.example.com'),
   'direct.object'    => new Record\ValueRecord(new ClassInstance()),
   'deferred.object'  => new Record\CallbackRecord(function (ContainerInterface $c) {
                             return new DeferredClassInstance($c->get('env.value'));
                         }),
   'composed.factory' => new Record\InstanceRecord(Factory::class, 'direct.object', 'deferred.object'),
   'factory.product'  => new Record\ProductRecord('composed.factory', 'create', 'domain')
]);

// add more entries here with set() methods
// and instantiate container...

$container = $setup->container();

当然,如果所有条目都将通过构造函数添加,则甚至不需要实例化 Setup,并且直接 实例化容器 可能是更好的想法。

Setup::container() 可以在添加更多条目后再次调用,但该调用将返回新的独立容器实例。还建议将 Setup 封装在受控作用域中,如 读写分离 部分所述。

记录决定其内部工作方式

从容器返回的值最初被包装在 Record 抽象中,该抽象允许采用不同的策略来生成它们 - 它可以直接返回或通过调用其(延迟)初始化过程在内部创建。以下是该包 Record 实现的简要说明

  • ValueRecord:直接值,将按原样返回(回调将不进行评估即可返回)。要将映射到给定字符串 identifier 的值记录推送到包含设置对象的容器中,请使用 Entry::value() 方法
    $setup->set('identifier')->value($anything);
  • CallbackRecord:延迟调用和缓存的值。它接受一个回调,该回调将容器作为参数传递,并且该调用的值将被缓存并在后续调用中返回。记录通过 Entry::callback() 方法添加到设置中
    $setup->set('identifier')->callback(function ($container) { return ... });
  • InstanceRecord:延迟实例化(并缓存)给定类的对象。构造函数参数作为解析的别名传递给其他容器条目。通过 Entry::instance() 方法设置
    $setup->set('identifier')->instance(Namespace\ClassName::class, 'dependency-identifier', 'another', ...);
  • ProductRecord:类似于实例方法,但是通过在提供的工厂实例上调用字符串形式的调用方法来创建(并缓存)对象,并且容器标识符将解析为该方法的参数。使用Entry::product()方法设置。
    $setup->set('identifier')->create('factory.id', 'createMethod', 'container.param1', 'container.param2', ...);
  • ComposedInstanceRecord:使用Entry::wrappedInstance()方法,可以在链式调用中构建单个条目,允许组合多层封装(装饰)实例条目结构(了解更多

自定义Record实现可能是可变的,在后续调用中返回不同的值或引入各种副作用,但这是不建议的。

组合条目

使用包装器进行记录组合

条目可以通过在链式调用中的多个实例描述符(与InstanceRecord使用的参数相同)中创建来构建,这些描述符是在Wrapper调用中创建的(Wrapper

$setup->set('A')
      ->wrappedInstance(SomeClass::class, 'B', 'C')
      ->with(AnotherClass::class, 'A', 'D')
      ->with(AndAnother::class, 'A')
      ->compose();

注意,包装器定义包含对封装条目的引用,作为其依赖项之一。如果没有它,将会抛出异常,因为它不会构成组合,而是定义了不同的实例,这些实例应该使用Entry::instance()方法定义。这种自引用不会导致循环调用,因为它不作为独立的容器条目(作为其他依赖项的标识符)使用,而是在组合过程中指向封装实例的占位符。

复合容器

Entry::container()方法可以用来添加另一个ContainerInterface实例,并通过包装多个子容器来创建复合容器,这些子容器的值(或容器本身)可以通过容器的ID前缀(点表示法)访问

$subContainer = new PSRContainerImplementation();
$setup->set('env')->container($subContainer);

$container = $setup->container();
$container->get('env') === $subContainer; //true
$container->has('env.some.id') === $subContainer->has('some.id'); //true

将包含ContainerInterface实例的数组与记录Setup一起传递也将构建复合容器

$setup = Setup::production($records, ['env' => new PSRContainerImplementation()]);

安全设置 & 循环引用检测

安全设置被设计为一种开发工具,它有助于设置调试。它可以使用development静态构造函数实例化

$setup = Setup::development($records, $containers);

或者使用传递给默认构造函数的ValidatedBuild实例

$setup = new Setup(new Setup\Build\ValidatedBuild($records, $containers));
命名规则和不可访问的条目

由于封装容器访问的方式以及它们与记录实例分开存储,因此需要一些命名约束

子容器标识符必须是字符串,不得包含分隔符(默认为.),且不得用作存储的Record的ID前缀。

将容器存储为foo标识符会使foo.bar记录不可访问,因为此值会被认为是来自foo容器。在多个条目和子容器中,这些规则可能难以遵循,因此实现了运行时检查。

基本(生产)Setup可以直接实例化或使用Setup::production()方法实例化,它不会检查给定的标识符是否已经定义,或者是否会导致名称冲突,这会使某些条目不可访问(使用记录条目前缀的标识符的子容器)。

通过实例化经过验证的Setup::development()或直接使用ValidatedBuild实例,将启用容器配置的运行时完整性检查,并确保所有定义的标识符都可以通过ContainerInterface::get()方法访问。

循环引用

由于记录可能引用要构建(实例化)的其他容器条目,因此可能会引入一个难以发现的错误,其中条目A在解析过程中需要检索自身,从而导致循环调用并最终堆栈溢出。例如

$setup->set('A')->instance(SomeClass::class, 'B');
$setup->set('B')->instance(AnotherClass::class, 'C', 'A');

条目AB相互引用,因此实例化B将需要A,这将在嵌套上下文中尝试实例化B。由于其依赖项无法完全解析(目前正在更高上下文级别上解析),因此这两个类都无法实例化 - 而没有检测,实例化过程将继续,直到调用堆栈溢出。

容器能够检测循环引用,并将调用栈信息附加到抛出的异常中(无论是循环引用还是缺失条目),这是开发环境附带的一个功能。《ContainerInterface::get()`》在更深层级的上下文中进行递归容器调用后,会立即抛出《CircularReferenceException》,以尝试检索当前已解析的记录,这将允许退出无限循环。

这些检查不包括在《Setup::production()`》中,因为在生产环境中通常不需要它们。尽管在开发过程中推荐使用它们。

在开发过程中进行集成测试是必要的,因为配置错误的容器很可能会使应用程序崩溃,而且无法通过代码以可靠的方式控制。开发环境无法防止所有可能发生的错误,因此在生产环境中,它成为不必要的性能开销。然而,值得注意的是,在开发阶段使用这些检查导致的性能下降很可能是容器过度使用的结果——请参阅《推荐使用》部分。

直接实例化 & 容器组合

《Setup》提供辅助方法来创建《Record》实例并将它们收集在一起,可选地带有子容器条目和额外的验证检查,以创建不可变的容器组合。也可以直接创建容器,例如,只包含《Record》条目的简单容器将作为扁平的《Record[]》数组(在此存储在《$records》变量中)实例化

$container = new RecordContainer(new Records($records));

当容器需要循环引用检查并将存储在《$containers》变量中的某些子容器封装为扁平的《ContainerInterface[]》数组时,其实例化会变为这种组合

$container = new CompositeContainer(new TrackedRecords($records), $containers);

配置容器

本包附带的《ConfigContainer》是一个方便的方式,可以使用路径表示法从多维关联数组中存储和检索值。此容器通过将数组传递给构造函数直接实例化,可以通过点分隔的键在连续嵌套级别上访问其值。示例

$container = new ConfigContainer([
    'value' => 'Hello World!',
    'domain' => 'http://api.example.com',
    'pdo' => [
        'dsn' => 'mysql:dbname=testdb;host=localhost',
        'user' => 'root',
        'pass' => 'secret',
        'options' => [
            PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
        ]
    ]
]);

$container->get('pdo'); // ['dsn => 'mysql:dbname=testdb;host=localhost', 'user' => 'root', ...]
$container->get('pdo.user'); // root
$container->get('pdo.options'); // [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', ... ]

如《组合容器》部分所述,您可以使用基于记录的配置容器作为单个容器使用《Entry::container()`》方法。定义了上述《$container》后,可以重新创建使用其《value》和《domain》条目的主要示例

...
$setup = new Setup();
$setup->set('env')->container($conatiner);
$setup->set('direct.object')->value(new ClassInstance());
$setup->set('deferred.object')->callback(function (ContainerInterface $c) {
  return new DeferredClassInstance($c->get('env.value'));
});
$setup->set('factory.object')->instance(FactoryClass::class, 'direct.object', 'deferred.object');
$setup->set('factory.product')->product('factory.object', 'create', 'env.domain');

$container = $setup->container();

请注意,与原始示例中使用的记录相比,《value》和《domain》在《deferred.object》和《factory.product》定义中有额外的路径前缀。这些值仍然从《ConfigContainer》中获取,但通过使用《env》前缀通过组合容器访问。这样就可以检索组合容器内封装的配置和记录容器中的值

...
echo $container->get('env.value'); // Hello world!
echo $container->get('env.pdo.user'); // root
$object = $container->get('factory.product');

使用《$container->get('factory.product')》创建的对象将与《#user-content-containers-vs-direct-instantiation》部分中直接使用《new》操作符实例化的对象相同,该部分对主题进行了扩展。

推荐使用

读写分离

类似构建器的《Setup》API允许设置容器并创建其实例,但将容器封装并仍能从外部作用域配置它将产生更干净的设计。这可以通过仅公开设置方法的代理对象来实现。

调用《Setup::set()`》返回只读的《Entry》辅助对象。除了提供定义配置《container》的《Record》或子容器实现的方法外,它还允许通过单个方法实现代理,而不是通过多个设置方法污染其接口。例如,如果您有一个类似于...

class App
{
    private $setup;
    ...
    public function config(string $name): Entry
    {
        return $this->setup->set($name);
    }
    ...
}

...您仍然可以使用由 Entry 对象提供的所有辅助方法。现在您可以从 App 类对象的作用域中向容器中推送值,但不能之后访问容器。《App》控制《Setup》,并将调用《Setup::container()`》以根据其自身条款使用它。

$app = new App(parse_ini_file('pdo.ini'));
$app->config('database')->callback(function (ContainerInterface $c) {
    return new PDO(...$c->get('env.pdo'));
});

外部作用域中没有任何内容能够使用在《App》中创建的容器实例。虽然通过一些配置工作可以实现,但这是不建议的,所以这里不会解释细节。

容器的真正优势

容器与直接实例化

使用在 主示例 中使用的设置命令实例化容器并获取 factory.product 对象将与直接使用 new 操作符实例化工厂并对其调用 create() 方法,并带有 http://api.example.com 参数等效。

$factory = new ComposedClass(new ClassInstance(), new DeferredClassInstance('Hello world!'));
$object  = $factory->create('http://api.example.com');

如您所见,容器在这里并没有比直接创建对象任何明显的优势,并且假设此对象仅用于单次使用场景,那么将不会有任何。

对于在各种请求上下文中使用的库或在需要传递相同实例的结构(如数据库连接)中重用的库,将其配置在一个地方可以节省很多麻烦和重复。假设这个类是某些需要配置和组合的 api 库,这些组合被您的应用程序的一些端点使用 - 您将不得不为每个端点重复此实例化。您仍然可以通过在硬编码的工厂类中封装实例化并替换 $container->get() 为单个(静态)调用(以及类型提示结果!)来解决这个问题。

容器与分解的工厂和 Singleton 模式

提到的工厂引入了另一个问题。您可能无法预先知道创建的对象的哪些个别组件可能在其他地方需要,并且需要将其从现有的工厂中提取到另一个工厂中并在两个地方调用它 - 从提取它的工厂和需要此组件的其他部分。当需要相同的实例时,这种工厂在某些情况下可能需要缓存创建的对象 - 最有可能使用 Singleton 模式

Singleton 模式 在需要在不同代码作用域中提供相同对象时需要。当只有一个工厂使用它时,不需要 singleton。注入点的数量并不重要,因为可以将其作为之前创建的局部变量传递给所有它们。Singleton 模式对象在全局作用域中对于代码的任何部分都是可用的,这使得它们难以维护,因为您实际上不知道它们在哪里或将来在哪里使用。

例如,身份验证服务可能使用会话,所以仅编写 auth 服务工厂是不够的,还需要一个会话工厂,因为会话可能不仅用于许多不同的上下文,而且还用于不同的应用程序作用域(构建中间件和使用情况组合)。在多个地方调用多个 singleton 工厂会导致难以理解的代码,即使它们在使用时是纪律性的 - 即仅在组合层(“主要”分区)中。

容器解决了分解和作用域控制问题,因为所有组件也可以是容器条目,并且其使用范围严格限制在注入它的地方。这种灵活性是(标准)容器的唯一优势,不能轻易地以其他方式替代。然而,也需要对容器进行一些纪律。

容器与 Service locator 反模式

容器不应作为包装器注入,提供直接(在同一作用域内将被调用的对象)依赖项,因为这会暴露对容器的依赖,同时隐藏我们真正依赖的对象类型。虽然我们可以自由地注入延迟调用的对象,并且有不用它们的可能性,但这些未使用的对象在绝大多数情况下应该表明我们的对象作用域过于宽泛。在提前处理导致跳过对依赖项之一的方法调用(面向对象的消息发送)的分支,会使我们的类更容易测试和阅读。为了简化实现而做出的例外很快就会变成标准做法(尤其是在大型或远程工作团队中),因为一致性似乎在涉及不良实践的情况下也是合理的。健康的约束比预期的推理更可靠。

工厂中的容器是无害的

依赖注入容器应帮助进行依赖注入,而不是取代它。在框架控制的范围内,将容器注入到主工厂对象中是可以的,因为工厂本身不会调用容器提供的对象,并且工厂耦合到什么对象并不重要。将应用程序对象的组合视为一种配置形式。

为什么没有自动装配(目前还没有)?

无论是直接实例化还是通过容器间接实例化,显式硬编码的类组合可能会被方便的自动装配所取代,但在我看来,它的成本包括重要的多态性部分,即解决先决条件。这并不是你预先付出的代价,虽然债务本身并不 inherently bad,但直到你无法偿还它时才意识到你有债务肯定是不好的。