Introducing The Compiler Pass


In this video we will continue our refactoring process, this time decoupling each of the individual ConverterInterface implementations from the Conversion service.

The problem in our code so far is that whenever we need to add in a new ConverterInterface implementation, not only must we create the implementation, but we must also change the implementation in the Conversion service. Assuming you have written unit tests for your Conversion service, this is still another bunch of tests to write.

And if you haven't got tests, this is yet another place a potential bug could creep in.

Note also that in the write up to the previous video, I mentioned that you could decouple the creation of the concrete ConverterInterface implementations from the conversion process itself. However, even this would be a method of masking the coupling, rather than solving it.

To address the coupling here we will use a Symfony Compiler Pass.

To begin with, we will need to define each of our ConvertToX implementations as a Symfony service.

Then, we will tag each of these services with a custom tag name that we come up with. Any string will do.

Once we've tagged these services, our Compiler Pass will handle the heavy lifting of figuring out which services should be available in our chain (basically any that are tagged with our custom tag name), and then add them to the available array of converters.

This may sound complex, but aside from a little boilerplate, it actually makes writing implementations easier - in my opinion.

The downside to doing this is that it is more complex. Developers who are new to Symfony are likely going to have a harder time understanding this implementation than the previous one. However, once they 'get it', hopefully they will see that it is a preferable implementation in larger systems.

Okay, onto the code.

Revisiting Conversion Service

We already have a Conversion service defined:

# /app/config/services.yml

services:
    crv.conversion:
        class: AppBundle\Service\Conversion
        arguments:
            - "@logger"
            - "@crv.converter.convert_to_xml"

We can largely re-use this definition. The only thing is, we no longer need to inject the second argument. Therefore, our revised service definition will be:

# /app/config/services.yml

services:
    crv.conversion:
        class: AppBundle\Service\Conversion
        arguments:
            - "@logger"

A really good question now would be: "then how can we do this without injecting our services?"

Let's start off by seeing the code, and then stepping through it as appropriate:

<?php

// /src/AppBundle/Service/Conversion.php

namespace AppBundle\Service;

use AppBundle\Converter\ConverterInterface;
use Psr\Log\LoggerInterface;

class Conversion
{
    /**
     * @var LoggerInterface
     */
    private $logger;
    /**
     * @var array
     */
    private $converters;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
        $this->converters = [];
    }

    public function addConverter(ConverterInterface $converter)
    {
        $this->converters[] = $converter;

        return $this->converters;
    }

    public function convert(array $data, $format)
    {
        foreach ($this->converters as $converter) {
            if ($converter->supports($format)) {
                return $converter->convert($data);
            }
        }

        throw new \RuntimeException('No supported Converters found in chain.');
    }
}

In the constructor we simply inject the logger instance. I have removed the logging statements from the code above to reduce the noise, but do feel free to add them in - or use the code from GitHub - to better understand how this service will be created, and used.

At construction we setup an array of converters. This will hold, as you might expect, all our implementations of ConverterInterface - e.g. ConvertToJson, and so on.

To add an instance to this array we must call addConverter, which will expect to receive something implementing ConverterInterface. In the previous video I mentioned how important it is that each of these implementations follows a standard pattern (or, ahem, common interface) and now it's starting to become more obvious as to why this is so important.

This is fine, but do we need to define yet another service somewhere that loops through and calls addConverter for each instance? No, thankfully not. More on this shortly, though.

Lastly, we have convert, which keeps the same method signature as our previous implementation of the Conversion service.

However, rather than know any of the details about any of the available ConverterInterface implementations, instead, we just loop through the array of added Converters, and see if the current item in the array supports the given $format.

This is a method we don't have yet on our ConverterInterface, so we will need to add it. All this function needs to do is return a boolean value. If we get back a true, then we go ahead and return the outcome of running that implementation's convert method against our array of $data.

Lastly, if none of the implementations in the chain (array) support the given format then we simply throw an exception. Feel free to do whatever you like here, but blowing up hard is sometimes the only valid option. Wrap this in a try / catch in the callee as / if needed.

Updating ConverterInterface

From now on, when we call convert on our Conversion class, we will loop through each of the converters (items) in the array and firstly, check if the current converter supports the given $format.

This check will simply return a true or false.

As we need each of our converters to implement this method, it would make most sense to add it to the ConverterInterface:

<?php

// /src/AppBundle/Converter/ConverterInterface.php

namespace AppBundle\Converter;

interface ConverterInterface
{
    public function convert(array $data);
    public function supports(string $format);
}

Of course this means we need to go through each of the existing classes that implement this interface and ensure we have this method defined. An example of this would be:

<?php

// /src/AppBundle/Converter/ConvertToYaml.php

namespace AppBundle\Converter;

use Symfony\Component\Yaml\Yaml;

class ConvertToYaml implements ConverterInterface
{
    public function supports(string $format)
    {
        return $format === 'yaml';
    }

    // * snip *

Simple enough, we just check that the given $format matches a hardcoded string. We could make these strings into constants and extract them to some shared Constants class, or put them on the ConverterInterface itself. That's your call.

Note here that I'm using scalar type hints, a PHP 7.0 feature. If you aren't running PHP 7.0+, then don't put string before $format.

Bagging and Tagging

Now that we have all these classes setup and that they all implement the interface properly, the next thing we need to do is to define each as its own Symfony service:

This is somewhat of a chore, but is unavoidable:

# /app/config/services.yml

    crv.converter.convert_to_csv:
        class: AppBundle\Converter\ConvertToCsv
        tags:
            - { name: "crv.converter" }

    crv.converter.convert_to_object:
        class: AppBundle\Converter\ConvertToObject
        tags:
            - { name: "crv.converter" }

    crv.converter.convert_to_yaml:
        class: AppBundle\Converter\ConvertToYaml
        tags:
            - { name: "crv.converter" }

    crv.converter.convert_to_xml:
        class: AppBundle\Converter\ConvertToXml
        arguments:
            - "@serializer"
        tags:
            - { name: "crv.converter" }

Note that each has been tagged with a name. The name itself is just something we've invented. It means something in our project only.

This alone, however, is not enough to make this work.

In order for Symfony to be made aware of our intended use for these tags we must define a custom Compiler Pass.

This sounds scary, complex, and confusing.

Thankfully, the implementation itself is pretty straightforward, and you don't need to know 'how' it all works behind the scenes to actually use it. However, if you are interested in this sort of thing then there is no better place to continue learning than with the official documentation.

Let's look at the implementation, then cover what it's doing:

<?php

// /src/AppBundle/DependencyInjection/Compiler/ConverterPass.php

namespace AppBundle\DependencyInjection\Compiler;

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

class ConverterPass implements CompilerPassInterface
{
    const CONVERSION_SERVICE_ID = 'crv.conversion';
    const SERVICE_ID = 'crv.converter';

    public function process(ContainerBuilder $container)
    {
        // check if the conversion service is even defined
        // and if not, exit early
        if ( ! $container->has(self::CONVERSION_SERVICE_ID)) {
            return false;
        }

        $definition = $container->findDefinition(self::CONVERSION_SERVICE_ID);

        // find all the services that are tagged as converters
        $taggedServices = $container->findTaggedServiceIds(self::SERVICE_ID);

        foreach ($taggedServices as $id => $tag) {
            // add the service to the Service\Conversion::$converters array
            $definition->addMethodCall(
                'addConverter',
                [
                    new Reference($id)
                ]
            );
        }
    }
}

This code is extremely similar to that found in the documentation for Creating a Compiler Pass

The first thing we do is to defensively check if our core service - the Conversion service - is even defined. If not, we want to return false; as early as possible, and be done with it.

I'm using constants for both of the strings we care about in this method, but you don't have too.

Next, if we do have a defined service then we want to grab it from the container.

Then, we want to find all the services we just tagged with our custom tag. This gives us an array of our tagged services to work with.

Lastly, we loop through this array of tagged services calling addConverter on the Conversion service for each, thus ensuring all our tagged implementations are added to the $this->converters array inside Conversion.

Awesome.

We aren't quite done though. Lastly, we must tell Symfony to add our compiler pass inside the build method our current Bundle - AppBundle in our example:

<?php

// /src/AppBundle/AppBundle.php

namespace AppBundle;

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

class AppBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(
            new ConverterPass()
        );
    }
}

At this point our Conversion service should be all working, and we can add in new, different implementations of ConverterInterface by declaring a new class, and ensuring we add the right tag. We don't need to alter any existing code to make this work, which is really nice.

One thing to note though, your chain will be constructed lazily. This can make debugging rather confusing. If you don't explicitly call the convert method on a given request, you will not see any of the ConverterInterface implementations added to the chain.

The nice thing about this now is that your code is more extensible as the Conversion service is not directly tied to any particular implementation.

The main downside in my opinion is that this is more confusing, especially on larger projects. You need to be aware that this is possible before you can understand what might be happening, and for developers who are new to Symfony, that can be a big hurdle to get over.

Anyway, hopefully you've found this to be useful, and as ever, if you have any questions, comments, or feedback do please leave them in the comments section below this, or any other video.

Code For This Course

Get the code for this course.

Episodes

# Title Duration
1 Extract, Extract, Extract 10:09
2 Introducing The Compiler Pass 13:03