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.