03-12-2022
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.
- Recorrem totes les classes del nostre codi dins de
- É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 flagclone
atrue
. - 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.