SymfonyLive ~ Dependency Injection Container!

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.

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

Résultat: appDevDebugProjectContainer.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
}

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
$manager = $contaner->get('rage_face.manager');

Cycle de vie de la création du Container

diagram

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>

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

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');
    }
}

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>
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;
    }
}

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:

  • Les arguments à donner dans le constructeur: une valeur, une collection ou une Réference vers un service
  • Les fonctions à appeler
  • … et bien plus encore … scope, public/private et tags

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>
<?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.

diagram top

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é:

  • Pour vérifier l’intégrité d’un service
  • Pour optimiser les appels
  • Pour altérer la définition des services

Les étapes de la compilation

  • mergePass: merge les ContainerBuilders
  • beforeOptimizationPasses: contiens toutes les définitions de tous les services
  • optimizationPasses: checks les services
  • beforeRemovingPasses
  • removingPasses: supprime les services inutiles
  • afterRemovingPasses: le ContainerBuilder est maintenant prêt pour être mergé

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:

  • ArrayProvider
  • DirectoryProvider
  • ProviderChain

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)
            ));
        }
    }
}
<?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);
}

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

diagram bottom

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

  • Scope
  • Cache Metadata / ResourceInterface
  • Class to compile
  • Synchronized Service (Symfony 2.3)