yiisoft / di

PSR-11 compatible DI container and injector
https://www.yiiframework.com
BSD 3-Clause "New" or "Revised" License
182 stars 46 forks source link

[WIP] Proposal: DI configuration for application #61

Closed SamMousa closed 5 years ago

SamMousa commented 5 years ago

This is a proposal for how we could configure DI in a Yii application.

$composite = new \yii\di\CompositeContextContainer();

// Create a container using a 3rd party library as well. This container should support delegated lookup
$thirdParty = new ThirdPartyContainer($thirdPartyConfig, $composite);
$composite->attach($thirdParty);

$appConfig = [
    'id' => 'app1',
    'di' => [
        EngineInterface::class => EngineMarkOne::class,
        'specificEngine' => EngineMarkTwo::class,
        ServiceXInterface::class => ConcreteX::class
    ],
    'components' => [
        // Expose a service from the DI container as a component so it can be used in the service locator pattern. This is not a recommended pattern.
        'serviceX' => Reference::to(ServiceXInterface::class),
        'carFactory' => [
            '__class' => CarFactory::class,
            '__construct()' => [
                Reference::to('specificEngine')
            ]
        ]
    ],
    'modules' => [
        'moduleA' => [
            '__class' => A\Module::class,
            'di' => [
                'specificEngine' => EngineMarkOne::class
            ],
            'components' => [
                'carFactory' => [
                    '__class' => CarFactory::class,
                    '__construct()' => [
                        Reference::to('specificEngine')
                    ]
                ]
            ]

        ]

    ]
];

// If not provided the application could instantiate its own composite container.
$app = new Application($appConfig, $composite);

The module then interprets the configuration;

// This is all pseudo-code / a PoC implementation
/**
 * @param string $uniqueId The full path to the module, for example `/moduleA/moduleB`.
 * @param mixed $config
 */
private function configure(string $uniqueId = '', array $config, CompositeContextContainer $root) {
    // Used for retrieval
    $this->container = $root;

    $container = new Container($appConfig['di'], $composite);
    $composite->attach($container);

    $moduleContainer = new Container($config['modules'], $composite);

    // This is so that our modules will get the correct root container injected.
    $moduleContainer->set(CompositeContextContainer::class, $root);
    $composite->attach($moduleContainer, $uniqueId . '/_modules');
    $this->componentContainer = new Container($config['components'], $composite);
    $composite->attach($this->componentContainer, $uniqueId . '/_components')
}

public function getModule(string $id) 
{
    return $this->container->getFromContext($id, $this->uniqueId . '/_modules');
}

public function getComponent(string $id) 
{
    return $this->container->getFromContext($id, $this->uniqueId . '/);
}

public function setComponent(string $id, $config) 
{
    $this->componentContainer->set($id, $config);
}

This will allow us to have module specific DI configurations; provide default configurations from extensions. As in Yii2 the getters on module essentially implement the service locator pattern when used directly. In this case we use the DI container to back them.

hiqsol commented 5 years ago

Looking at the first code sample:

  1. app configuration should be part of DI config (it is done this way in 3.0) and not vice versa.
  2. there are no more components in 3.0 app. App simply gets components from DI by name, please see https://github.com/yiisoft/yii-core/blob/aa1c38c3e0533c4def419ecdaf53d6a5f8167514/src/base/Module.php#L796

IMHO these 2 things simplify configuration a lot. Please take a closer look at the basic config: https://github.com/yiisoft/yii-core/blob/master/config/common.php

The goal you want to achieve (module specific DI configurations) can be achieved in 2 ways.

First, simply by merging configs. Every package (extension) provides own configuration that is merged by config plugin in a way that allows to have default configuration in package and tune (override) it in user project. Unfortunately, there is no good documentation for this practice but you can look config folders in 3.0 packages and understand how it works.

A second way, with a configuration like this:


return [
    'app' => [
        'modules' => [
            'mymodule' => [
                '__class' => My\Module::class,
                'container' => Reference::to('mycontainer'),
            ],
        ],
    ],
    'mycontainer' => [
        '__class' => \yii\di\Container::class,
        '__construct()' => [
            'definitions' => [
            'specificEngine' => EngineMarkOne::class,
            ],
            'parent' => Reference::to('container'),
        ],
    ],
];

This configuration can work right now only Module::setContainer() setter is needed to be added.

Personally, I prefer config merging. And it works right now out of the 3.0 box. I think that config merging is a very effective practice both from development and performance points of view. We have very big projects and I consider it more than enough even for complex cases.

SamMousa commented 5 years ago

app configuration should be part of DI config (it is done this way in 3.0) and not vice versa.

I don't think this necessarily makes sense (even though it is what I already use in some production apps). The DI container is not injecting any dependencies into the application, basically we're misusing the DI configuration syntax as a way to configure the application.

there are no more components in 3.0 app. App simply gets components from DI by name,

Why? What does this improve? This means that anything configured in DI is available via the application. The application then becomes a global service locator containing anything in the application. Even if we decide to go that way (which I would not recommend) it makes my proposal even more relevant! Since modules may have different configurations for services we can use the contextual container approach for this.

Configuration merging

The approach that I propose is more flexible and doesn't require the extension to use a Yii DI container, any PSR-11 container with delegated lookup will work.

This configuration can work right now only Module::setContainer() setter is needed to be added.

Modules can be nested, the suggested configuration forces users to repeat configuration for every module. Instead a module should always get a DI container injected since it needs a DI container to instantiate its stuff; this proposal gives a module a scoped container that will resolve the things a module needs transparently.

I think that config merging is a very effective practice both from development and performance points of view.

Configuration merging is effective, but not necessarily easy to use. From a performance perspective I think the scoped approach will have better performance and will be more practical.

Consider a nested structure like this: C2 <- B2 <- A -> B1 -> C1 -> D1 ->E1 -> F1, how would you configure everything below C1 to use a different implementation for some service? If I'm inside module C2 and try to get a service via $module->get how will you guarantee I'm not getting services that are defined in B1.

The example configuration you linked to is really verbose; it for some reason defines an alias for every class, giving me the fear that the application then uses that alias to get the actual implementation from the DI container. This is exactly the opposite of where we need to go; this is just using the service locator pattern again.

I think there are lots of use cases that the configuration merging approach does not yet support; and when it does, it will become more messy and complex. Putting all definitions in a DI container and flattening the tree doesn't improve things, it makes them worse!

SamMousa commented 5 years ago

I've been reading a bit more about the config plugin you created @hiqsol (https://hiqdev.com/pages/articles/app-organization)

I think the idea breaks encapsulation in a big way. Consider the example:

<?php

return [
    'components' => [
        'i18n' => [
            'translations' => [
                'my-category' => [
                    'class' => \yii\i18n\PhpMessageSource::class,
                    'basePath' => '@myvendor/myplugin/messages',
                ],
            ],
        ],
    ],
];

What if I my i18n component / service is not called i18n; or what if it doesn't have a translations property? Basically you've created a non-code interface that our code should now conform to...

hiqsol commented 5 years ago

Looking at your first example again:

    $appConfig = [
    'id' => 'app1',
    'di' => [
        EngineInterface::class => EngineMarkOne::class,
        'specificEngine' => EngineMarkTwo::class
    ],
    ];

Where are you going to process 'di' config option? In application? Then it will be an explicit use of DI container...

SamMousa commented 5 years ago

Yes definitely. The application must use the DI container explicitly, since we only know which classes we will need to instantiate after the request has been parsed and we've identified which module / controller / action we need to execute.

The idea is as follows:

  1. yii-core depends on yii-di.
  2. Application depends on CompositeContextContainer.
  3. We support any PSR-11 container that supports delegated lookup by attaching it to the composite container.
  4. The 'di' configuration entry just allows you to configure a DI container that uses "our own" implementation; you don't have to use it and can provide us with any PSR-11 container or completely omit it. This is the same for the application as well as module level.

The fact is that you cannot do pure DI if you do not know what code will run (it's not feasible or desirable to instantiate all modules / controllers / actions). So there is a line where the core is allowed to know about DI and use it, and where the consuming code is not. The Module will know about the DI, everything else will get their dependencies injected (since they are constructed directly or indirectly via the module).

In my example the Module also has a service locator part, that is explicitly configured to allow for some services to be obtainable outside of DI, but only if explicitly configured. This prevents a wild growth of using the service locator pattern when it's not needed. The goal of this part is not ease of use, the goal is to make sure a developer explicitly decides which services must be reachable via DI. // Updated OP to illustrate a service that is exposed via DI as well as SL.

samdark commented 5 years ago

We have eliminated "components" concept in regards to config because components are essentially any classes registered as singletons. Usually these are called services. We want no difference between Yii components and third party services. There should be no need to write as many wrapper extensions as we did for 1.1 and 2.0.

samdark commented 5 years ago

While composite container is theoretically quite cool, there hardly ever be a third party container involved along with native container. That makes little sense for the application to do it. For extensions and modules it makes perfect sense though.

samdark commented 5 years ago

Using container as service locator isn't necessary.

SamMousa commented 5 years ago

Then you won't have a getter on the application?

SamMousa commented 5 years ago

While composite container is theoretically quite cool, there hardly ever be a third party container involved along with native container.

Actually during transitional phases this can be quite common. Note that there is no overhead in supporting it since we are coding to the PSR-11 interface.

Using container as service locator isn't necessary.

How do we solve this for things like AR? https://github.com/yiisoft/active-record/issues/16

samdark commented 5 years ago

Then you won't have a getter on the application?

I didn't say that we should get rid of it. I said it's not necessary. At least for majority of cases because of reflection-based injection.

Actually during transitional phases this can be quite common. Note that there is no overhead in supporting it since we are coding to the PSR-11 interface.

Transitional phases?

How do we solve this for things like AR?

Probably we should not.