Symfony2: Writing a Yaml Driver for your Metadata Factory

Posted on by Matthias Noback

In my previous post, I wrote about annotations and how to gather them using a metadata factory and an annotation driver. I also mentioned a future post in which I explain other ways of collecting metadata, namely by locating and parsing files in several different formats (distinguished by their extension). This is the post. In the case of our @DefaultValue annotation, I'm thinking of a Yaml flavoured alternative. This allows us to store all default values as an array of property "name" - "value" pairs, like

# /src/Matthias/AnnotationBundle/Resources/metadata/Data.SomeClass.yml
name: "Noback, Matthias"

After reading and parsing this file, we should be able to run the previously created DefaultValueProcessor with an instance of Matthias\AnnotationBundle\Data\SomeClass and retrieve an object of which the "name" property has the default value "Noback, Matthias".

Adding a file driver makes things slightly more complicated. We have the following requirements:

  1. The annotation driver and the file driver should live side by side
  2. The file driver needs a file locator to find the right files containing the metadata in Yaml format
  3. The file locator needs a collection of locations (directories) where it can look for the Yaml files
  4. The list of directories should be configurable

The Yaml driver

Below you will find the code for the YamlDriver, which parses a Yaml file and creates PropertyMetadata objects in the same way the AnnotationDriver does:

namespace Matthias\AnnotationBundle\Metadata\Driver;

use Metadata\Driver\AbstractFileDriver;
use Metadata\MergeableClassMetadata;
use Matthias\AnnotationBundle\Metadata\PropertyMetadata;
use Symfony\Component\Yaml\Yaml;

class YamlDriver extends AbstractFileDriver
{
    protected function loadMetadataFromFile(\ReflectionClass $class, $file)
    {
        $classMetadata = new MergeableClassMetadata($class->getName());
        $data = Yaml::parse($file);
        foreach ($data as $propertyName => $value) {
            $propertyMetadata = new PropertyMetadata($class->getName(), $propertyName);
            $propertyMetadata->defaultValue = $value;

            $classMetadata->addPropertyMetadata($propertyMetadata);
        }

        return $classMetadata;
    }

    protected function getExtension()
    {
        return 'yml';
    }
}

As you can see, the YamlDriver extends the AbstractFileDriver, so we can skip some file finding business.

This also means, that we should supply a FileLocator. The FileLocator looks through a given collection of directories for files with the extension "yml" (as returned by YamlDriver::getExtension).

We should add service definitions for the FileLocator and the YamlDriver. We will make the first constructor argument of the FileLocator configurable, until then, we leave it empty.

<parameter key="matthias_annotation.metadata.file_locator_class">Metadata\Driver\FileLocator</parameter>
<parameter key="matthias_annotation.metadata.yaml_driver.class">Matthias\AnnotationBundle\Metadata\Driver\YamlDriver</parameter>

<!-- ... -->

<service id="matthias_annotation.metadata.file_locator" class="%matthias_annotation.metadata.file_locator_class%">
    <argument /><!-- collection of directories -->
</service>

<service id="matthias_annotation.metadata.yaml_driver" class="%matthias_annotation.metadata.yaml_driver.class%" public="false">
    <argument type="service" id="matthias_annotation.metadata.file_locator" />
</service>

Creating a driver chain

The YamlDriver should be equally important as the AnnotationDriver, and both should be available at the same time and for different classes. The DriverChain comes to the rescue! This driver accepts a collection of drivers and when asked for ClassMetadata, it loops over all available drivers (AnnotationDriver and YamlDriver) and whichever returns ClassMetadata first, wins.

The only thing we should do to accomplish this, is add a service definition for the DriverChain and provide it with the collection of existing drivers. Also, the DriverChain will be the one and only driver that is given to the metadata factory.

<parameter key="matthias_annotation.metadata.driver_chain.class">Metadata\Driver\DriverChain</parameter>

<!-- ... -->

<service id="matthias_annotation.driver_chain" class="%matthias_annotation.metadata.driver_chain.class%">
    <argument type="collection">
        <argument type="service" id="matthias_annotation.metadata.yaml_driver" />
        <argument type="service" id="matthias_annotation.metadata.annotation_driver" />
    </argument>
</service>

<service id="matthias_annotation.metadata_factory" class="%matthias_annotation.metadata_factory.class%" public="false">
    <argument type="service" id="matthias_annotation.driver_chain" />
</service>

Configuring the collection of directories

We need to find a way to set the first and only constructor argument of the FileLocator. This argument should be an array of "namespace prefix" => "directory" pairs, like

array('Matthias\\AnnotationBundle' => '/src/Matthias/AnnotationBundle/Resources/metadata');

We can accomplish this in a few steps:

  1. Define a configuration for the bundle, so it is possible to add a list of namespace prefix/directory combinations to config.yml
  2. Process the configuration files, collect the namespace prefix/directory combinations and hand them over to the FileLocator by modifying it's service definition

First, the Configuration class should look like this:

namespace Matthias\AnnotationBundle\DependencyInjection;

use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;

class Configuration implements ConfigurationInterface
{
    function getConfigTreeBuilder()
    {
        $builder = new TreeBuilder;

        $builder
            ->root('matthias_annotation')
            ->children()
                ->arrayNode('metadata')
                    ->children()
                        ->arrayNode('directories')
                        ->prototype('array')
                        ->children()
                            ->scalarNode('path')->isRequired()->end()
                            ->scalarNode('namespace_prefix')->defaultValue('')->end()
                        ->end()
                    ->end()
                ->end()
            ->end()
        ;

        return $builder;
    }
}

(This configuration structure was adapted from the JMSSerializerBundle library.)

So now we can have something like this is /app/config.yml:

matthias_annotation:
    metadata:
        directories:
            annotationbundle:
                namespace_prefix: 'Matthias\AnnotationBundle'
                path: '%kernel.root_dir%/../src/Matthias/AnnotationBundle/Resources/metadata'

This means that metadata for classes within the namespace Matthias\AnnotationBundle can be found in the given directory.

Finally, the MatthiasAnnotationExtension class should look like this:

namespace Matthias\AnnotationBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;

class MatthiasAnnotationExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');

        $configuration = new Configuration;

        $config = $this->processConfiguration($configuration, $configs);

        $directories = array();
        foreach ($config['metadata']['directories'] as $directory) {
            $directories[$directory['namespace_prefix']] = $directory['path'];
        }

        $container
            ->getDefinition('matthias_annotation.metadata.file_locator')
            ->replaceArgument(0, $directories)
        ;
    }
}

As you can see, the directories as defined in config.yml will be parsed to comply to the "namespace prefix" => "directory" format and will be set as the first constructor argument of the FileLocator service definition.

Now you can use files like /src/Matthias/AnnotationBundle/Resources/metadata/Data.SomeClass.yml to contain metadata for the class Matthias\AnnotationBundle\Data\SomeClass (when your configuration looks the same as mine does - see above).

PHP Symfony2 annotations configuration metadata reflection
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Matthias Noback

Thank you!

Thomas Luzat

Also, great series. Exactly what I was looking for.

Sebastiaan Stok

You can also automate the mapping (if you always use the same config-directory structure) as done in. https://github.com/symfony/...

And its also wise to register any file or directory the container as a resource so that any changes will be automatically picked-up in the development env.

Thomas Luzat

What do you mean by registering any file or directory [in?] the container as a resource?

Are you speaking of what is described at the config/caching and di/compilation docs?

Matthias Noback

That's right: adding resources to the container will make sure that the container gets compiled again when one of these resources is not "fresh" anymore, i.e. has been modified.

Thomas Luzat

Thanks, I was unsure as to whether Sebastiaan was referring to another concept. As a side note, quotations (q) in comments do not seem to get any special formatting.

Matthias Noback

Thanks Sebastiaan - excellent suggestion!