Dennis Haarbrink
Dennis Haarbrink

Reputation: 3760

Custom config with service/di references

For my project I want to specify some custom configuration. I have a bunch of 'mappers' that have some properties and will refer to other services.

For example, I would like my config to look like this:

self_service:
  mappers:
    branche_vertalingen:
      data_collector: "@self_service.branche_vertalingen.data_collector"
      data_loader: "@self_service.branche_vertalingen.data_loader"
      map_data: SelfServiceBundle\Entity\BrancheVertalingMapData

Where self_service is the bundle name, mappers is the 'container' where all the mappers are defined. And branche_vertalingen is one of the defined mappers, there can (and will) be many more. At the moment, each mapper has a data_collector and a data_loader that refer to services defined in the bundle's services.yml, and a map_data property which refers to an entity's class name.

I have put this configuration in SelfServiceBundle/Resources/config/config.yml and import it in app/config/config.yml.
I have create a SelfServiceExtension class according to this article. In the extension's load() method I receive my defined configuration as an array. So far, so good.

The problem I am having is that the value for data_collector I receive is just the defined string, and not the service I was expecting. No problem, I thought. I have a $container available, I will just look it up, but I can't get the service there.

The question: How do I make sure I can get the service I reference in the config?


I had already tried doing the same in a parameters block so that I wouldn't even need a bundle Extension, but doing that I got this error: You cannot dump a container with parameters that contain references to other services. So after that I tried to do it via an Extension.

Upvotes: 3

Views: 108

Answers (1)

Yoshi
Yoshi

Reputation: 54659

As I wrote in the comments, I think tagged services are a good fit for this. It allows you to very easily add or remove mappers just by tagging a service. This way there's no hard requirement for all mappers to live at the same place or similar.

Using interfaces to ensure that everything is wired correctly also allows for easy extension.

To see how this can even incorporate your initial idea, see the example at the end of this answer.


usage

/** @var $mapperManager MapperManager */
$mapperManager = $this->get('app.mapper_manager');

dump($mapperManager);
foreach ($mapperManager->getMappers('branche_vertalingen') as $mapper) {
    dump($mapper);
}

implementation

(closely following the official docs):

service.yml

services:
    app.mapper_manager:
        class: AppBundle\Mapper\Manager

    # mappers

    app.mapper_1:
        public: false
        class: AppBundle\Mapper\DefaultMapper
        arguments:
            - "a"
            - "b"
            - SelfServiceBundle\Entity\BrancheVertalingMapData
        tags:
            - { name: app.mapper, branch: branche_vertalingen }

    app.mapper_2:
        public: false
        class: AppBundle\Mapper\DefaultMapper
        arguments:
            - "c"
            - "d"
            - SelfServiceBundle\Entity\BrancheVertalingMapData
        tags:
            - { name: app.mapper, branch: branche_vertalingen }

    app.mapper_3:
        public: false
        class: AppBundle\Mapper\DefaultMapper
        arguments:
            - "e"
            - "f"
            - SelfServiceBundle\Entity\BrancheVertalingMapData
        tags:
            - { name: app.mapper, branch: other_branch }

the compiler pass:

<?php
// src/AppBundle/DependencyInjection/Compiler/MapperCompilerPass.php
namespace AppBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Reference;

class MapperCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        if (!$container->has('app.mapper_manager')) {
            return;
        }

        $definition = $container->findDefinition('app.mapper_manager');
        $taggedServices = $container->findTaggedServiceIds('app.mapper');

        foreach ($taggedServices as $id => $tags) {
            foreach ($tags as $attributes) {
                $definition->addMethodCall('addMapper', [$attributes['branch'], new Reference($id)]);
            }
        }
    }
}

using the compiler pass:

<?php
// src/AppBundle/AppBundle.php
namespace AppBundle;

use AppBundle\DependencyInjection\Compiler\MapperCompilerPass;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class AppBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new MapperCompilerPass());
    }
}

a simple manager class (call it whatever fits better):

<?php
// src/AppBundle/Mapper/Manager.php
namespace AppBundle\Mapper;


class Manager
{
    private $mappers = [];

    public function addMapper($branch, MapperInterface $mapper)
    {
        if (!array_key_exists($branch, $this->mappers)) {
            $this->mappers[$branch] = [];
        }

        $this->mappers[$branch][] = $mapper;
    }

    public function getMappers($branch)
    {
        if (!array_key_exists($branch, $this->mappers)) {
            // handle invalid access
            // throw new \InvalidArgumentException('%message%');
        }

        return $this->mappers[$branch];
    }
}

a default mapper class (this is actually not required, but could make things easier to start with):

<?php
// src/AppBundle/Mapper/DefaultMapper.php
namespace AppBundle\Mapper;


class DefaultMapper implements MapperInterface
{
    private $dataCollector;
    private $dataLoader;
    private $mapData;

    public function __construct($dataCollector, $dataLoader, $mapData)
    {
        $this->dataCollector = $dataCollector;
        $this->dataLoader = $dataLoader;
        $this->mapData = $mapData;
    }

    public function getDataCollector()
    {
        return $this->dataCollector;
    }

    public function getDataLoader()
    {
        return $this->dataLoader;
    }

    public function getMapData()
    {
        return $this->mapData;
    }
}

and finaly a simple interface to use with the data mappers:

<?php
// src/AppBundle/Mapper/MapperInterface.php
namespace AppBundle\Mapper;


interface MapperInterface
{
    public function getDataCollector();
    public function getDataLoader();
    public function getMapData();
}

a little extra

With an additional compiler pass (or only, see code-comments) you could also extend the above solution:

using an extra compiler pass, like:

<?php
// src/AppBundle/DependencyInjection/Compiler/MapperCollectionCompilerPass.php
namespace AppBundle\DependencyInjection\Compiler;

use AppBundle\Mapper\DefaultMapper;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Definition;


class MapperCollectionCompilerPass implements CompilerPassInterface
{
    private $parameterName;

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

    public function process(ContainerBuilder $container)
    {
        if (!$container->has('app.mapper_manager')) {
            return;
        }

        if (!$container->hasParameter($this->parameterName)) {
            return;
        }

        $definition = $container->findDefinition('app.mapper_manager');
        $mappers = $container->getParameter($this->parameterName);

        foreach ($mappers as $branch => $meta) {
            $mapper = new Definition(DefaultMapper::class, [
                new Reference($meta['data_collector']),
                new Reference($meta['data_loader']),
                $meta['map_data'],
            ]);

            $mapper
                ->setPublic(false)
                ->addTag('app.mapper', ['branch' => $branch])
            ;

            $container->addDefinitions([$mapper]);

            // If you don't want to use tags, simply add the 'addMethodCall'
            // from MapperCompilerPass here
            // $definition->addMethodCall('addMapper', [$branch, $mapper]);
        }
    }
}

adding it to the bundle:

<?php
// src/AppBundle/AppBundle.php
namespace AppBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use AppBundle\DependencyInjection\Compiler\MapperCompilerPass;
use AppBundle\DependencyInjection\Compiler\MapperCollectionCompilerPass;


class AppBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new MapperCollectionCompilerPass('mappers'));
        $container->addCompilerPass(new MapperCompilerPass());
    }
}

and adding the config:

# app/config/services.yml
parameters:
    mappers:
        branche_vertalingen:
            # !note the missing @
            data_collector: app.some_service
            data_loader: app.some_service
            map_data: SelfServiceBundle\Entity\BrancheVertalingMapData

Upvotes: 2

Related Questions