SymfonyLive ~ Dependency Injection Container!

En route pour la découverte ... (Symfony Live Paris 2013)

Introduction

Qu'est ce qu'un service ?

Un service est un objet qui permet d'altérer/créer de la donnée, il ne doit pas avoir d'état.

<?php
// the data
class RageFace
{
    protected $name;
    protected $image;
}

// The SERVICE
class RageFaceManager
{
    protected $rageFaces = array();

    public function getRageFace($name)
    {
       return new RageFace($name);
    }

    public function findRageFace($name)
    {
       // ...
    }
}
Ne pas avoir d'état évite à un service de stocker de l'information qui pourrait générer un comportement aléatoire en fonction des appels ou encore d'agréger de l'information qui pourrait augmenter la consommation mémoire.

L'injection de dépendance

Il s'agit d'un design pattern où les besoins externes d'une classe ne sont pas initialisés dans la class, mais fournis lors de la création de l'objet.

<?php

// PAS BIEN
class RageFaceManager
{
   protected $images;

   public function __construct()
   {
       $this->images = Finder::create()->name('*.jpg')->in('image_folder');
   }
}

// BIEN
class RageFaceManager
{
   protected $images;

   public function __construct(array $images)
   {
       $this->images = $images;
   }
}

new RageFaceManager(Finder::create()->name('*.jpg')->in('image_folder'));
L'injection de dépendance permet de sortir les dépendances en dehors de la class et de limiter ses responsabilités. Les tests sont également plus faciles à réaliser.

Définition d'un service

La définition d'un service se fait via le fichier app/config/config.yml.

::: yaml
services:
    rage_face.manager:
        class: RageFace\Lib\RageFaceManager
        arguments:
          - ['image1.png', 'image2.png']

Résultat: appDevDebugProjectContainer.php

::: php
<?php
class appDevDebugProjectContainer extends Container
{
    protected function getRageFace_ManagerService()
    {
        return $this->services['rage_face.manager'] = new \RageFace\Lib\RageFaceManager(array(
            'image1.png',
            'image2.png'
        ));
    }

    // + 20K lignes
}

Cette solution convient très bien pour les projets qui ne sont pas voués
à être réutilisés et est suffisante dans de nombreux cas.

Container d'injection de dépendances

Un container regroupe plusieurs services et permet de les récupérer via un identifiant unique dans le cas de Symfony2.

Le container de service peut être considéré comme tableau associatif service_id <=> service instance. Il est possible de récupérer le service de la façon suivante:

::: php
<?php
$manager = $contaner->get('rage_face.manager');

Même si le concept d'injection de dépendance est relativement "simple" à comprendre; l'implémentation et les concepts utilisés dans SF2 peuvent être compliqués à appréhender.


Cycle de vie de la création du Container

diagram.svg


La suite de la présentation va expliquer l'implémentation interne de la construction DIC dans SF2


RageFaceBundle



Bundle qui affiche des Troll Face provenant de RageFace :)

Migration de la définition

Création d'un bundle via la commande app/console generate:bundle, cette commande génére la structure suivante

╭─vagrant@debian  ~/projects/sfpot-dic  ‹master*›
╰─$ tree src/RageFace
src/RageFace
├── Lib
│   └── RageFaceManager.php
└── RageFaceBundle
    ├── DependencyInjection
    │   ├── Configuration.php
    │   └── RageFaceExtension.php
    ├── RageFaceBundle.php
    └── Resources
        └── config
            └── services.xml

Migration de la configuration YAML vers du XML (format conseillé pour les bundles).

<services>
    <service id="rage_face.manager" class="RageFace\Lib\RageFaceManager">
        <argument type="collection">
            <argument>image1.png</argument>
            <argument>image2.png</argument>
        </argument>
    </service>
</services>

Question: Comment le fichier services.xml est il chargé par Symfony2?

Le boostraping de Symfony2

Tout commence dans le front controller app.php, qui initialise le container:

<?php
$kernel = new AppKernel('prod', false);

Le kernel est responsable de la création du container d'injection dépendances via certaines méthodes clés:

<?php
class Kernel {

    // Initializes the service container.
    protected function initializeContainer()
    {}

    // Returns the kernel parameters.
    protected function getKernelParameters()
    {}

    // Builds the service container.
    protected function buildContainer()
    {}

    // Gets a new ContainerBuilder instance used to build the service container.
    protected function getContainerBuilder()
    {}

    // Dumps the service container to PHP code in the cache.
    protected function dumpContainer(ConfigCache $cache, ContainerBuilder $container, $class, $baseClass)
    {}

    // Returns a loader for the container
    protected function getContainerLoader(ContainerInterface $container)
    {}
}

Le kernel utilise les informations provenant des bundles référencés dans la fonction AppKernel::registerBundles, le bundle fournit cette information via la méthode BundleInterface::getContainerExtension. La méthode doit retourner une instance de type ExtensionInterface

Ainsi, dans le cas du RageFaceBundle l'extension RageFaceExtension sera utilisée lorsque la méthode ``Kernel::buildContainer()`` est appelée.

RageFaceExtension

<?php
class RageFaceExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');
    }
}

Question: Quelles sont les options pour configurer un service?

Configuration d'un bundle

Le code de l'extension montre deux éléments clés : l'objet Configuration et l'objet XmlLoader.

L'objet XmlLoader charge la définition des services et des paramètres présents dans le fichier cible.

<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <service id="rage_face.manager" class="RageFace\Lib\RageFaceManager">
            <argument></argument>
        </service>
    </services>
</container>
Le fichier suivant défini un service, cependant la valeur de l'argument est maintenant vide, sa valeur va être configuré dans le fichier config.yml
rage_face:
  images:
    - 'image1.png'
    - 'image2.png'

Cette configuration ne peut fonctionner sans définir les options dans l'objet Configuration. L'utilisation du fichier Configuration est optionelle, cependant en cas de non-utilisation, il faut gérer soit même les problématiques de merge.

<?php
class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();
        $rootNode = $treeBuilder->root('rage_face');

        $rootNode
            ->fixXmlConfig('extension')
            ->children()
                ->arrayNode('images')
                    ->prototype('scalar')->end()
                ->end()
            ->end()
        ;

        return $treeBuilder;
    }
}
L'objet configuration est utilisé:
  • pour merger les éléments de configuration entre eux. En effet, la configuration d'un bundle peut être définie dans plusieurs fichiers de configuration, donc l'objet Configuration définie la façon dont les éléments doivent être mergés entre eux.
  • pour normaliser les valeurs

Voici la valeur de la variable $config

<?php
array(1) {
    'images' => array(2) {
        [0] => string(10) "image1.png"
        [1] => string(10) "image2.png"
    }
}

La dernière étape consiste à utiliser la valeur images pour configurer le service via sa définition.

L'objet Definition

Cet objet contient toutes les informations nécessaires pour instancier un service:

Fichier définition:

<?php
class RageFaceExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');

        // alter the service definition to add an argument
        $container->getDefinition('rage_face.manager')
            ->replaceArgument(0, $config['images']);
    }
}

Des implémentations, un id : les alias

Il est possible d'avoir plusieurs implémentations pour un identifiant de service, cependant une seule instance est active à la suite de la création du container. Ce pattern est utilisé dans Symfony2 pour rajouter des loggers à certains services.

Voici un exemple avec le RageFaceManager, un nouveau service va être crée pour rajouter des logs lors d'appel à la méthode RageFaceManager::getFaces().

<services>
    <!-- Le service a été renommé rage_face.manager => rage_face.manager.default -->
    <service id="rage_face.manager.default" class="RageFace\Lib\RageFaceManager">
        <argument></argument>
    </service>

    <service id="rage_face.manager.loggable" class="RageFace\Lib\RageFaceManagerLoggable">
        <argument type="service" id="rage_face.manager.default" ></argument>
    </service>
</services>

L'extension peut maintenant en fonction du paramètre kernel.debug choisir quel service sera utilisé lors de la récupération du service rage_face.manager

<?php
class RageFaceExtension extends Extension
{
    /**
     * {@inheritDoc}
     */
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');

        // alter the service definition to add an argument
        $container->getDefinition('rage_face.manager.default')
            ->replaceArgument(0, $config['images']);

        // create an alias
        $debug = ($container->getParameter('kernel.debug');
        $container->setAlias(
            'rage_face.manager',
            'rage_face.manager.'. $debug ? 'loggable' : 'default')
        );
    }
}

L'importance des interfaces

Dans l'exemple, nous avons 2 services identiques, cependant l'implémentation technique change. Il faut donc un dénominateur commun pour s'assurer que les services dépendant du service rage_face.manager fonctionneront toujours en fonction des implémentations.

RageFaceManagerInterface
<?php
interface RageFaceManagerInterface
{
    public function getFace();
}
RageFaceManagerLoggable
<?php
class RageFaceManagerLoggable implements RageFaceManagerInterface
{
    protected $manager;

    protected $logger;

    public function __construct(RageFaceManagerInterface $manager, LoggerInterface $logger = null)
    {
        $this->manager = $manager;
        $this->logger = $logger;
    }

    public function getFace()
    {
        $face = $this->manager->getFace();

        if ($this->logger) {
            $this->logger->info(sprintf('Face %s => %s', $face->getName(), $face->getPath()));
        }

        return $face;
    }
}
RageFaceManager
<?php
class RageFaceManager implements RageFaceManagerInterface
{
    protected $images;

    public function __construct(array $images)
    {
        $this->images = $images;
    }

    public function getFace()
    {
        $file = new \SplFileInfo(array_rand($this->images));

        return new RageFace($file->getFilename(), $file->getPath());
    }
}

ContainerBuilder

Les définitions de service sont stockées dans le ContainerBuilder; il existe un ContainerBuilder par bundle. Une extension ne peut pas modifier la définition d'un service présent dans un autre bundle.

Cependant il existe un ContainerBuilder principal, les définitions déclarées dans les bundles sont simplement mergées dans le container principal, cela se passe dans la class MergeExtensionConfigurationPass.

Le ContainerBuilder contient donc des métadonnées définissant des services.

Résumons un peu: Kernel, config.yml, Bundle, Extension, Definition, Alias, Id, Interface, ContainerBuilder ....

.... Et le container dans tout ça ?

La compilation

Le ContainerBuilder est un container de métadonnées, il ne peut pas être utilisé dans l'état, il doit être dumpé dans un fichier php => le fichier appDevDebugProjectContainer.php.

Cependant avant de dumper le container, il doit être compilé:

Les étapes de la compilation

Les constantes:

<?php
class PassConfig
{
    const TYPE_AFTER_REMOVING      = 'afterRemoving';
    const TYPE_BEFORE_OPTIMIZATION = 'beforeOptimization';
    const TYPE_BEFORE_REMOVING     = 'beforeRemoving';
    const TYPE_OPTIMIZE            = 'optimization';
    const TYPE_REMOVE              = 'removing';
}

Les CompilerPass

Il en existe beaucoup dans Symfony2: AnalyzeServiceReferencesPass, CheckCircularReferencesPass, CheckDefinitionValidityPass, CheckExceptionOnInvalidReferenceBehaviorPass, CheckReferenceValidityPass, InlineServiceDefinitionsPass, MergeExtensionConfigurationPass, RemoveAbstractDefinitionsPass, RemovePrivateAliasesPass, RemoveUnusedDefinitionsPass, RepeatedPass, ReplaceAliasByActualDefinitionPass, ResolveDefinitionTemplatesPass, ResolveInvalidReferencesPass, ResolveParameterPlaceHoldersPass, ResolveReferencesToAliasesPass, RoutingResolverPass, ProfilerPass, RegisterKernelListenersPass, TemplatingPass, AddConstraintValidatorsPass, AddValidatorInitializersPass, FormPass, TranslatorPass, AddCacheWarmerPass, AddCacheClearerPass, TranslationExtractorPass, TranslationDumperPass, etc.

Exemple!

Rajoutons un RageFaceCompilerPass qui permet de définir des providers d'images.

Voici 3 nouvelles classes et donc 2 nouveaux services:

Le fichier de définitions:

<service id="rage_face.provider.chain" class="RageFace\Lib\Provider\ProviderChain">
</service>

<service id="rage_face.provider.array" class="RageFace\Lib\Provider\ArrayProvider">
    <argument ></argument>
    <tag name="rage_face.provider"></tag>
</service>

Nous allons changer la signature du constructeur de la class RageFaceManager pour accepter un objet de type ProviderInterface:

<?php
class RageFaceManager implements RageFaceManagerInterface
{
    protected $provider;

    public function __construct(ProviderInterface $provider)
    {
        $this->provider = $provider;
    }

    public function getFace()
    {
        return array_rand($this->provider->getFiles());
    }
}

Pour rester compatibles avec l'ancienne implémentation, nous allons juste modifier le nom du service qui accepte la liste des images dans le fichier RageFaceExtension. (enfin presque ...)

<?php
$container->getDefinition('rage_face.provider.array')
    ->replaceArgument(0, $config['images']);

Maintenant il faut implémenter le CompilerPass.

<?php
class RageFaceCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $providerIds = $container->findTaggedServiceIds('rage_face.provider');

        $chainDefinition = $container->getDefinition('rage_face.provider.chain');

        foreach ($providerIds as $id => $tags) {
            $chainDefinition->addMethodCall(
                'addProvider',
                array(new Reference($id)
            ));
        }
    }
}

Puis le déclarer

<?php
class RageFaceBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new RageFaceCompilerPass());
    }
}

Rajoutons un service dans le fichier config.yml

services:
    my_rage_face.provider:
        class: RageFace\Lib\Provider\DirectoryProvider
        arguments:
            - "%kernel.root_dir%/../web/rage-face"
        tags:
            - {name: rage_face.provider}

Résultat

<?php
protected function getRageFace_Provider_ChainService()
{
    $this->services['rage_face.provider.chain'] = $instance = new \RageFace\Lib\Provider\ProviderChain();

    $instance->addProvider($this->get('my_rage_face.provider'));
    $instance->addProvider($this->get('rage_face.provider.array'));

    return $instance;
}

Les CompilerPass: Private Service

Est ce que tous les services doivent être accessible dans le container ? Est ce qu'un service à besoin d'être exposé ? Si ce n'est pas cas => public=false

<service id="rage_face.provider.chain" class="RageFace\Lib\Provider\ProviderChain" public="false">
</service>

<service id="rage_face.provider.array" class="RageFace\Lib\Provider\ArrayProvider" public="false">
    <argument ></argument>
    <tag name="rage_face.provider"></tag>
</service>

Le résultat:

<?php
// rappel: le service sera accessible via un alias
protected function getRageFace_Manager_DefaultService()
{
    $a = new \RageFace\Lib\Provider\ProviderChain();
    $a->addProvider($this->get('my_rage_face.provider'));
    $a->addProvider(new \RageFace\Lib\Provider\ArrayProvider(array(0 => 'image1.png', 1 => 'image2.png')));

    return $this->services['rage_face.manager.default'] = new \RageFace\Lib\RageFaceManager($a);
}
Un CompilerPass supprime donc les identifiants qui ne sont pas publics

Résumons un peu: Kernel, config.yml, Bundle, Extension, Definition, Alias, Id, Interface, ContainerBuilder, CompilerPass, Tag, public ....

.... Et le container dans tout ça ?

Il faut maintenant le dumper, il existe plusieurs formats possibles: xml, php et graphviz

Encore là?

Retrouver son chemin

Il faut bien comprendre les étapes de création du container, deplus Symfony2 stocke des informations lors de la création du container. :

╭─vagrant@debian  ~/projects/sfpot-dic  ‹master*›
╰─$ head -n 5 app/cache/dev/appDevDebugProjectContainerCompiler.log
No directories configured for AnnotationConfigurationPass.
ResolveDefinitionTemplatesPass: Resolving inheritance for "templating.asset.default_package" (parent: templating.asset.path_package).
ResolveDefinitionTemplatesPass: Resolving inheritance for "security.user.provider.concrete.in_memory" (parent: security.user.provider.in_memory).
ResolveDefinitionTemplatesPass: Resolving inheritance for "security.user.provider.concrete.in_memory_user" (parent: security.user.provider.in_memory.user).
ResolveDefinitionTemplatesPass: Resolving inheritance for "security.user.provider.concrete.in_memory_admin" (parent: security.user.provider.in_memory.user).
ResolveDefinitionTemplatesPass: Resolving inheritance for "security.firewall.map.context.dev" (parent: security.firewall.context).

Il est possible de chercher un service via la commande app/console container:debug

╭─vagrant@debian  ~/projects/sfpot-dic  ‹master*›
╰─$ app/console container:debug | grep rage_face
my_rage_face.provider          container RageFace\Lib\Provider\DirectoryProvider
rage_face.manager              n/a       alias for rage_face.manager.default
rage_face.manager.default      container RageFace\Lib\RageFaceManager
rage_face.manager.loggable     container RageFace\Lib\RageFaceManagerLoggable

Pour aller plus loin