Valinor a Symfony amb Value Objects

Introducció

En el desenvolupament d’aplicacions web ens trobem sovint amb la necessitat de rebre dades en cru i convertir-les al nostre domini, on totes les primitives haurien d’estar embolcallades en Value Objects. Així doncs, necessitem convertir dades externes i tipar-les en Value Objects al nostre domini.

PHP és un llenguatge feblement tipat, però en les darreres versions ha fet moltes passes per a tenir un sistema de tipus robust. Això ha permès que apareguessin llibreries i utilitats per a garantir i fer complir els tipus de dades al nostre codi.

En aquest article, veurem una llibreria que fa ús d’aquestes millores en el sistema de tipus per a convertir dades externes al nostre domini.

Llibreria cuyz/valinor

Fa poc va aparèixer la versió 1.0.0 de la llibreria Valinor, que permet convertir dades en cru (arrays, JSONs…) a objectes tipats, de manera que sempre tenen un estat vàlid.

Aquesta llibreria fa ús de __construct per a construir els objectes. Això no obstant, té diverses opcions de configuració, una de les quals és la de registrar named constructors. Això és útil pels Value Objects, ja que és bona pràctica fer-ne servir named constructors per a instanciar-los .

A continuació hi ha un exemple d’ús de la llibreria fent servir un named constructor:

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConstructor([Color::class, 'fromHex'])
    ->mapper()
    ->map(Color::class, [/* … */]);

Configuració a Symfony

Actualment, no existeix cap bundle de Symfony per a Valinor, de manera que cal configurar la llibreria a mà. Ara bé, qualsevol aplicació web, per petita que sigui, acostuma a tenir una gran quantitat de Value Objects. Per a cadascun d’aquests Value Objects caldria afegir una crida a registerConstructor de la llibreria per a indicar-li el named constructor. Això suposa sobretot dos problemes:

  • Genera uns fitxers de configuració enormes, sobretot, si es fa servir YAML.
  • Fa que cada desenvolupador hagi de recordar registrar cada Value Object que crea, cosa que pot suposar oblits, ja que cal pensar en la infraestructura mentre s’està desenvolupant el domini.

Fóra bo tenir una manera de registrar automàticament els constructors dels Value Objects, perquè simplificaria la configuració de l’aplicació i n’evitaria oblits.

La solució és fer servir un CompilerPass de Symfony per a registrar automàticament els named constructors de tots els Value Objects de la nostra aplicació. D’aquesta manera, quan es compila el kernel, es registren automàticament tots els named constructors requerits.

Aquesta solució no té cap impacte en el rendiment de l’aplicació en entorns de producció, ja que el kernel compilat es cacheja i no es reconstrueix amb cada petició.

A continuació veurem com implementar-ho.

Interfície ValueObject

Abans de res, necessitem una manera d’unificar tots els Value Object, per a poder-ne obtenir els seus named constructor programàticament. La solució és fer servir una interfície que tingui un mètode que retorni el callable del named constructor:

<?php

declare(strict_types=1);

namespace rubenrubio\Shared\Domain\ValueObject;

interface ValueObject
{
    public static function defaultNamedConstructor(): callable;
}

Aquí tenim un exemple d’implementació a un Value Object d’Email:

<?php

declare(strict_types=1);

namespace rubenrubiob\User\Domain\ValueObject;

use rubenrubiob\User\Domain\ValueObject\ValueObject;

final class Email implements ValueObject
{
    private function __construct(private readonly string $email)
    {
    }

    public static function create(string $email): self
    {
        return new self($email);
    }

    public static function defaultNamedConstructor(): callable
    {
        return [self::class, 'create'];
    }
    
    // Others methods
}

Fent servir aquesta sintaxi ens assegurem que el nostre IDE indexi el nom del mètode ‘create’, de manera que permet autocompletar el codi i refactoritzar-lo sense errors.

CompilerPass

Necessitem una llibreria que ens permeti recórrer el nostre codi per descobrir els Value Objects que implementin la interfície ValueObject. En aquest cas, farem servir league/construct-finder.

Així doncs, ja podem implementar ValinorMapperRegisterConstructorCompilerPass:

  • Definim el servei TreeMapper amb les definicions de servei de Symfony.
  • Després, hi afegim les crides al registerConstructor de Valinor:
    • Recorrem totes les classes del nostre codi dins de src.
    • Filtrem el namespace dels Value Objects: tots els Value Object han de ser a un namespace pel qual es pugui escriure una expressió regular.
    • Filtrem tots els Value Object que implementin la interfície, i n’obtenim el callable de named constructor fent servir reflection.
  • És important fer notar que cada crida a un mètode de Valinor retorna una nova instància, de manera que a cada addMethodCall hem de passar-hi el flag clone a true.
  • Addicionalment, podem afegir més crides a addMethodCall per a configurar la llibreria.
<?php

declare(strict_types=1);

namespace rubenrubiob\Shared\Infrastructure\Symfony\DependencyInjection;

use CuyZ\Valinor\Mapper\TreeMapper;
use CuyZ\Valinor\MapperBuilder;
use League\ConstructFinder\ConstructFinder;
use ReflectionClass;
use ReflectionException;
use rubenrubiob\Shared\Domain\ValueObject\ValueObject;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;

use function array_map;
use function preg_match;
use function sprintf;

final class ValinorMapperRegisterConstructorCompilerPass implements CompilerPassInterface
{
    private const VO_NAMESPACE_PATTERN = '/Domain\\\\ValueObject(\\\\(.*))?/i';

    private const SRC_FOLDER_MASK = '%s/src';

    public function __construct(
        private readonly string $projectDir,
    ) {
    }

    public function process(ContainerBuilder $container): void
    {
        $container->setDefinition(
            TreeMapper::class,
            $this->mapperDefinition(
                $this->mapperBuilderDefinition()
            )
        );
    }

    private function mapperDefinition(Definition $mapperBuilderDefinition): Definition
    {
        $mapperDefinition = new Definition(TreeMapper::class);

        $mapperDefinition->setFactory(
            [
                $mapperBuilderDefinition,
                'mapper'
            ]
        );

        return $mapperDefinition;
    }

    private function mapperBuilderDefinition(): Definition
    {
        $mapperBuilderDefinition = new Definition(MapperBuilder::class);
        $valueObjectsWithNamedConstructor = $this->valueObjectsWithNamedConstructor();
        $valueObjectsConstructorsToRegister = $this->constructorsToRegister($valueObjectsWithNamedConstructor);

        foreach ($valueObjectsConstructorsToRegister as $valueObjectConstructor) {
            $mapperBuilderDefinition->addMethodCall(
                'registerConstructor',
                [$valueObjectConstructor],
                true,
            );
        }

        return $mapperBuilderDefinition;
    }

    /**
     * @return list<ReflectionClass<ValueObject>>
     */
    private function valueObjectsWithNamedConstructor(): array
    {
        $srcFolder = sprintf(self::SRC_FOLDER_MASK, $this->projectDir);

        $classNames = ConstructFinder::locatedIn($srcFolder)->findClassNames();

        $valueObjects = [];

        foreach ($classNames as $className) {
            if (preg_match(self::VO_NAMESPACE_PATTERN, $className) === 0) {
                continue;
            }

            try {
                $reflection = new ReflectionClass($className);
            } catch (ReflectionException) {
                continue;
            }

            if ($reflection->isInterface()) {
                continue;
            }

            if (!$reflection->implementsInterface(ValueObject::class)) {
                continue;
            }

            $valueObjects[] = $reflection;
        }

        return $valueObjects;
    }

    /**
     * @return list<callable>
     */
    private function constructorsToRegister(array $valueObjectsWithNamedConstructor): array
    {
        return array_map(
            static function (ReflectionClass $class): callable {
                return $class->getMethod('defaultNamedConstructor')->invoke(null);
            },
            $valueObjectsWithNamedConstructor
        );
    }
}

Registrar el CompilerPass al Kernel de Symfony

El darrer pas és el d’afegir el CompilerCass al Kernel de Symfony:

<?php

declare(strict_types=1);

namespace rubenrubiob\Shared\Infrastructure\Symfony;

use rubenrubiob\Shared\Infrastructure\Symfony\DependencyInjection\ValinorMapperRegisterConstructorCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    protected function build(ContainerBuilder $container): void
    {
        $container->addCompilerPass(
            new ValinorMapperRegisterConstructorCompilerPass(
                $this->getContainer()->getParameter('kernel.project_dir')
            )
        );
    }
}

Amb això, la nostra aplicació registrarà automàticament tots els named constructors dels nostres Value Objects a la llibreria Valinor, que estarà a punt per utilitzar-se amb el servei TreeMapper.

Resum

En el desenvolupament d’aplicacions web necessitem convertir dades en cru a objectes tipats del nostre domini.

Valinor és una solució vàlida a aquest problema, però cal configurar-la per millorar-ne l’experiència de desenvolupament i evitar possibles errors.

Això ho aconseguim amb els CompilerPass de Symfony, que són una eina complexa però alhora potent per a fer configuracions avançades de llibreries.