Tout commence par un prospect qui nous contacte pour auditer son site qui souffre de problèmes de performances. Dans le cadre de cet article, je vais ignorer les pages qui mettaient 40 secondes à s'afficher, et me concentrer sur le sujet pertinent : les scripts d'import de données.
Pour faire court, le process consiste à interroger un PIM à travers une API qui présente des données produit organisées en arborescence, et à créer ou mettre à jour les produits correspondant dans l'application. Le script le plus volumineux traite à peine 30 000 objets, et prenait 24 heures. Pour remettre les choses dans leur contexte, ce script est supposé être exécuté tous les jours.
Le code s'est bien sûr révélé être similaire à un plat de pâtes longues, fines et cylindriques, grand classique de la cuisine italienne. La lecture et l'écriture mélangées dans des classes de centaines de lignes, des if et des switch à gogo, et du copié-collé dans tous les sens, donne un code illisible, non extensible et non maintenable. Dissimulés au milieu de tout ça, les causes principales des lenteurs : des écritures à la fois redondantes (plusieurs écritures pour un même objet) et inutiles (l'objet local est déjà à jour).
Tenter de sauver le code existant aurait été futile, j'ai donc décidé de réécrire l'ensemble en me basant sur Dataflow. Voyons les différentes briques qui forment le nouveau code.
Note : le code suivant est une version anonymisée et simplifiée du code réel. Le but est de présenter le découpage du code en différentes classes, pas de rentrer dans les détails d'implémentation de cet import particulier.
Tout d'abord, le reader, dont le rôle est de produire un flux d'objets à traiter.
<pre><?php
declare(strict_types=1);
namespace App\Dataflow\ProductImport;
class Reader
{
public function __construct(private readonly PIMClient $pimClient)
{
}
// $config contient la configuration de l'import
public function __invoke(Config $config): iterable
{
yield from $this->readLevel($config->rootId);
}
public function readLevel(int $parentId): iterable
{
// l'API du PIM retourne les enfants d'un noeud de l'arbre sous forme de JSON
foreach ($this->pimClient->getChildren($parentId) as $item) {
// PIMItem est un POPO pour encapsuler les données pour la suite du traitement
yield new PIMItem($parentId, $item);
// Appel récursif, comme on interroge un arborescence
yield from $this->readLevel($item['id']);
}
}
}
</pre>
Le rôle de cette classe est simple : parcourir l'arborescence du PIM et retourner chaque objet pour traitement.
Ensuite, un groupe de transformers, qui formatent les données reçues dans le format attendu par le writer.
<pre><?php
declare(strict_types=1);
namespace App\DataflowType\ProductImport;
abstract class AbstractTransformer implements TransformerInterface
{
public function __construct(private readonly ContentService $contentService)
{
}
// ContentStructure est la classe attendue par le writer
public function transform(Config $config, PIMItem $item): ContentStructure|false
{
// $remoteId permet de faire la correspondance entre l'élément PIM et l'élément local
$remoteId = RemoteIdMaker::makeRemoteId($config, $item->data['id']);
try {
$this->contentService->loadContentInfoByRemoteId($remoteId);
return $this->makeUpdateStructure($config, $item);
} catch (NotFoundException) {
return $this->makeCreateStructure($config, $item);
}
}
private function makeCreateStructure(Config $config, PIMItem $item): ContentCreateStructure
{
$remoteId = RemoteIdMaker::makeRemoteId($config, $item['id']);
MemoryCache::addHandledRemoteId($remoteId);
return new ContentCreateStructure(
$this->contentTypeIdentifier(),
$this->fields($config, $item),
$remoteId,
);
}
private function makeUpdateStructure(Config $config, PIMItem $item): ContentUpdateStructure|false
{
$remoteId = RemoteIdMaker::makeRemoteId($config, $item['id']);
// Les éléments peuvent être présents plusieurs fois dans l'arborescence PIM,
// on s'assure de ne les traiter qu'une seule fois
if (MemoryCache::isRemoteIdHandled($remoteId)) {
return false;
}
MemoryCache::addHandledRemoteId($remoteId);
return new ContentUpdateStructure(
$remoteId,
$this->fields($config, $item),
);
}
abstract protected function contentTypeIdentifier(): string;
abstract protected function fields(Config $config, PIMItem $item): array;
}
</pre>
On a un transformer pour chaque type d'objet (catégorie, produit, gamme), et la classe ci-dessus sert de parent dans laquelle on regroupe les fonctionnalités communes.
Ensuite, on ne veut écrire que les objets qui ne sont pas à jour par rapport au PIM.
<pre><?php
declare(strict_types=1);
namespace App\DataflowType\ProductImport;
class NotModifiedFilter
{
public function __construct(
private readonly ContentService $contentService,
private readonly FieldComparator $comparator,
) {
}
public function filterNotModified($item): mixed
{
// Seules les mises à jour doivent être filtrées
if (!$item instanceof ContentUpdateStructure) {
return $item;
}
// On récupère l'objet local
$content = $this->contentService->loadContentByRemoteId($item->remoteId);
if ($this->comparator->compare($content->fields, $item->fields)) {
// L'objet local et celui du PIM sont identiques, on ne fait pas l'écriture
return false;
}
// Il existe une différence, on poursuit le traitement
return $item;
}
}
</pre>
Il reste le writer, dont le rôle est d'écrire l'objet reçu. Pas de logique complexe ici, tout le formatage a été fait dans les transformers.
On assemble tout ça dans notre dataflow type.
<pre><?php
declare(strict_types=1);
namespace App\DataflowType\ProductImport;
use CodeRhapsodie\DataflowBundle\DataflowType\AbstractDataflowType;
use CodeRhapsodie\DataflowBundle\DataflowType\DataflowBuilder;
use CodeRhapsodie\DataflowBundle\DataflowType\Writer\DelegatorWriter;
class ImportDataflowType extends AbstractDataflowType
{
public function __construct(
private readonly Reader $reader,
private readonly TransformerRegistry $transformerRegistry,
private readonly NotModifiedFilter $notModifiedFilter,
private readonly ContentCreateWriter $contentCreateWriter,
private readonly ContentUpdateWriter $contentUpdateWriter,
) {
}
public function getLabel(): string
{
return 'PIM import';
}
protected function buildDataflow(DataflowBuilder $builder, array $options): void
{
// Initialisation du cache et de la configuration
MemoryCache::reset();
$config = new Config($options);
// On utilise le DelegatorWriter pour séparer l'écriture création et celle mise à jour
$writer = new DelegatorWriter();
$writer->addDelegate($this->contentCreateWriter);
$writer->addDelegate($this->contentUpdateWriter);
$builder
->setReader(($this->reader)($config))
->addStep(function ($item) use ($config) {
// On utilise un registre pour appeler le bon transformer
return $this->transformerRegistry->getForType($item['type'])->transform($config, $item);
})
->addStep($this->notModifiedFilter)
->addWriter($writer)
;
}
}
</pre>
Dataflow encourage le découpage de votre code en plusieurs classes qui ont chacune leur responsabilité, dans le but de produire un code clair, maintenable et extensible.
Pour célébrer la sortie de Dataflow 5 pour Symfony 7, voici un retour d'expérience sur ...
🎯 Nous relevons le défi de lancer un grand concours : Vous faire gagner un ...
Comment calculer dynamiquement des frais de port ? La documentation officielle qui explique comment créer ...
Comment créer un nouveau type d'attribut produit dans Ibexa Commerce ?
A la recherche d'un poste de travail temporaire ou permanent ? Vous recherchez un environnement ...
Après une découverte de surface d'Ibexa Commerce, entrons plus dans le détail pour comprendre son ...
Ibexa DXP propose un module pour gérer des produits pour la réalisation d'un site e-commerce. ...
Voici une présentation d'IbexaMailing, un module qui ajoute la gestion des newsletters à Ibexa. IbexaMailing est ...
C'est la dernière occasion de vous souhaitez le meilleur pour cette année 2024 et surtout ...
Pour célébrer la sortie de Dataflow 5 pour Symfony 7, voici un retour d'expérience sur ...
🎯 Nous relevons le défi de lancer un grand concours : Vous faire gagner un ...
Comment calculer dynamiquement des frais de port ? La documentation officielle qui explique comment créer ...
Comment créer un nouveau type d'attribut produit dans Ibexa Commerce ?
A la recherche d'un poste de travail temporaire ou permanent ? Vous recherchez un environnement ...
Après une découverte de surface d'Ibexa Commerce, entrons plus dans le détail pour comprendre son ...
Ibexa DXP propose un module pour gérer des produits pour la réalisation d'un site e-commerce. ...
Voici une présentation d'IbexaMailing, un module qui ajoute la gestion des newsletters à Ibexa. IbexaMailing est ...
C'est la dernière occasion de vous souhaitez le meilleur pour cette année 2024 et surtout ...