Je cite un article :
ETL : la grande manoeuvre
A la différence d’autres languages, PHP est assez dépourvu pour ce qui concerne les librairies pour faire de l’ETL (Extract – Transform – Load). C’est du moins la perception que nous avons eue lors de notre recherche d’une solution pour un de nos projets. Comme nous ne voulions – mais surtout ne pouvions – pas utiliser les grosses solutions logicielles existantes, nous avions pensé faire du sur mesure – à contrecoeur car cela aurait pris plus de temps et que ce code n’aurait certainement pas été réutilisable par la suite.
Nous n’avons pas eu besoin de le faire, puisque nous avons trouvé une petite libraire bien sympathique proposant déjà plein de composants tout prêts au sein d’un workflow, librairie dont nous nous sommes empressés de faire un fork. Présentation avec un mini tutoriel.
La librairie
Pour réaliser notre opération, nous utiliserons la librairie Wizaplace PHP-ETL sur laquelle j’ai donc travaillé avec d’autres développeurs. A la base, il s’agissait d’une librairie développée par Leonardo Marquine, mais quand, avec d’autres collaborateurs, nous en avons eu besoin, la librairie d’origine n’était plus maintenue.
Nous avons donc fait un fork, avec pour objectif de ne pas uniquement faire une copie, mais la mettre au goût du jour et continuer son développement. Ce fork a permis de nettoyer certains fonctionnalités, de se débarrasser de certains points faibles comme le support de vieilles versions de PHP ou de l’absence de typage, de supprimer le système d’injection de dépendances qui posait problème (on peut à présent l’utiliser avec Symfony sans aucun problème), mais surtout de rajouter plusieurs fonctionnalités. Le changelog est disponible ici.
Concrètement cette librairie est un ensemble de classes : Extractors, Transformers, Loaders, qui sont pilotés par une classe ETL. Ces éléments sont chaînés, se passant des données l’une à l’autre avec possibilité d’intervenir dessus durant le worklow, par exemple, si dans le transformer on se rend compte qu’une entrée est invalide, on peut la rejeter sans avoir à la passer au loader. L’extraction est faite de manière séquentielle, avec utilisation d’un generator permettant ainsi de ne pas avoir à charger toutes vos sources en mémoire.
Parmi les composants disponibles, nous pouvons mentionner :
- Extractors :
- CSV
- JSON
- Base de données (plusieurs SGBD sont supportés)
- XML
- …
- Transformers :
- Manipulation de données JSON
- Manipulation de lignes issues d’un fichier CSV
- Agrégation avec unicité
- …
- Loaders :
- Insertion dans un nouveau CSV
- Insertion dans une BDD
- …
De plus, il est très facile de créer ses propres composants, par exemple son propre transformer (c’est le but le plus souvent !), et de l’intégrer dans le workflow de l’ETL. Enfin, sachez qu’il est possible de multiplier les sources, c’est à dire d’extraire en simultané depuis plusieurs endroits, par exemple depuis un CSV et une BDD, de traiter ces données pour les envoyer en sortie.
Installation de la librairie
L’installation de la librairie est ultra simple :composer require wizaplace/php-etl
Pour ce qui est de son utilisation, tout dépendra du type d’application PHP que vous utilisez.
Premier exemple : le pénible, si vous n’utilisez pas de framework, vous devrez inclure chacun des composants dont vous avez besoin, par exemple :use Wizaplace\Etl\Etl;
use Wizaplace\Etl\Extractors\Csv;
use Wizaplace\Etl\Transformers\Trim;
use Wizaplace\Etl\Loaders\Insert;
Le premier est la classe ETL, qui pilote tout le workflow. Ensuite, nous avons nos trois composants, un extractor, un transfomer et un loader. Notons au passage que rien ne vous oblige à utiliser chacun de ces types de composants, ou à l’inverse, il est possible d’utiliser plusieurs transformers.
L’inconvénient de ne pas utiliser de framework ici c’est que cela vous oblige à instancier manuellement toutes les classes et à gérer leurs dépendances…
Deuxième exemple, le plus simple, avec un framework ayant un système d’injection de dépendances, par exemple Symfony. Imaginons que nous voulions utiliser les mêmes composants que ceux montrés dans l’exemple précédent. Vous avez juste à les déclarer dans votre fichier services.yaml pour que Symfony ait connaissance d’eux. À partir de là, l’injection de dépendance de Symfony fera le reste pour vous. Notez que comme précisé dans la documentation de la librairie, si vous souhaitez utiliser plusieurs instances d’un même composant en même temps, vous pouvez tirer profit de la configuration de Symfony, qui vous permet, sur demande, d’obtenir des instances uniques de vos services plutôt que des singletons :Wizaplace\Etl\Etl:
shared: false
En pratique (1) : exemple par une simple extraction avec mise en forme
Pour ce premier exemple, nous allons faire une opération toute simple – et sans aucun intérêt, juste pour présenter le workflow de l’application. Nous avons un fichier CSV en entrée, contenant des informations sur des clients :
customers.csvid;first name;last name;email
1;John;Doe;john.doe@acme.com
2;Jane;Does;jane.does@acme.com
3;Bart;Simpson;el-barto@acme.com
L’opération à réaliser est la suivante :
- obtenir un nouveau fichier CSV en sortie de l’ETL ;
- contenant uniquement les colonnes id et email ;
- la colonne email devant être renommée en courriel.
Je l’ai dit, je le répète, c’est un cas d’utilisation stupide, c’est juste pour présenter le concept.
Nous utilisons Symfony, la première étape est de configuer le framework pour lui « déclarer » les classes que nous allons utiliser afin de bénéficier de l’injection de dépendances :
services.yamlWizaplace\Etl\Etl:
Wizaplace\Etl\Extractors\Csv:
Wizaplace\Etl\Transformers\RenameColumns:
Wizaplace\Etl\Loaders\CsvLoader:
Enfin, voici la classe de notre service qui se chargera d’orchester l’opération, classe que vous pouvez appeler par exemple avec une commande :
<?php | |
declare(strict_types=1); | |
namespace App\Service; | |
use Wizaplace\Etl\Etl; | |
use Wizaplace\Etl\Extractors\Csv as CsvExtractor; | |
use Wizaplace\Etl\Transformers\RenameColumns; | |
use Wizaplace\Etl\Loaders\CsvLoader; | |
class DummyService | |
{ | |
/** @var \Wizaplace\Etl\Etl */ | |
private $etl; | |
/** @var \Wizaplace\Etl\Extractors\Csv */ | |
private $csvExtractor; | |
/** @var \Wizaplace\Etl\Transformers\RenameColumns */ | |
private $renameColumns; | |
/** @var \Wizaplace\Etl\Loaders\CsvLoader */ | |
private $csvLoader; | |
public function __construct( | |
Etl $etl, | |
CsvExtractor $csvExtractor, | |
RenameColumns $renameColumns, | |
CsvLoader $csvLoader | |
) { | |
$this->etl = $etl; | |
$this->csvExtractor = $csvExtractor; | |
$this->renameColumns = $renameColumns; | |
$this->csvLoader = $csvLoader; | |
} | |
public function process(): int | |
{ | |
$inputCsvFile = ‘assets/tuto/customers.csv’; | |
$outputCsvFile = ‘assets/tuto/output.csv’; | |
$this->etl | |
->extract( | |
$this->csvExtractor, | |
$inputCsvFile, | |
[ | |
‘throwError’ => true, | |
‘delimiter’ => ‘;’, | |
‘columns’ => [‘id’, ’email’] | |
] | |
) | |
->transform($this->renameColumns, [‘columns’ => [’email’ => ‘courriel’]]) | |
->load($this->csvLoader, $outputCsvFile) | |
->run(); | |
return 0; | |
} | |
} |
view rawDummyService.php hosted with ❤ by GitHub
Le csv suivant sera généré :id;courriel
1;john.doe@acme.com
2;jane.does@acme.com
3;el-barto@acme.com
Et voilà. C’est tout. Passons à quelques explications rapides : la classe ETL qui orchestre donc le tout est appelée en 4 étapes : extract, transform, load et enfin run qui lance l’opération. Chacune des trois étapes ETL prend en paramètre obligatoire le composant à utiliser, et en second, un tableau d’options.
En pratique (2) : exemple avec une extraction depuis plusieurs sources et agrégation.
Pour notre deuxième exemple, nous allons partir avec deux sources différentes, pour arriver vers un seul fichier CSV. La première source est le même fichier CSV que pour le premier exemple, qui contient une liste de clients, la seconde est le fichier JSON ci-dessous, qui contient lui d’autres informations sur les clients :[
{
« id »: « 1 »,
« type »: « premium »,
« subscriptionNumber »: « p123 »
},
{
« id »: « 2 »,
« type »: « standard »
},
{
« id »: « 3 »,
« type »: « gold »,
« subscriptionNumber »: « g234 »
}
]
Vous remarquerez donc que nous avons des données différentes dans ce fichier. En effet, notre exemple pourrait être d’agréger certaines données de clients avec un statut spécial, et ne pas tenir compte des autres. Comme nous ne vivons pas dans un monde parfait, certains sont gérés depuis un logiciel, les autres depuis un autre, et comme nous vivons dans un monde VRAIMENT pas parfait, l’un permet d’exporter qu’en CSV, l’autre qu’en JSON, et nous n’avons bien sûr pas accès au bases de données, nous avons donc deux exports !
Passons à présent au code qui va nous permettre d’agréger tout ceci en seul fichier CSV et de ne récuperer que certains champs, à savoir croiser id, email et données commerciales :
Tout d’abord, retournons dans le fichier services.yml de Symfony pour déclarer les composants dont nous avons besoin et demander à avoir des instances uniques de la classe ETL :Wizaplace\Etl\Etl:
shared: falseWizaplace\Etl\Extractors\Json:
Wizaplace\Etl\Extractors\Aggregator:
Le principe ici ? Nous allons utiliser trois instances de l’ETL. Une pour extraire le CSV, une pour faire la même chose avec le fichier JSON, et enfin une qui va agréger tout cela. Voici donc un exemple, imparfait bien sûr, de ce que vous pouvez faire :
<?php | |
declare(strict_types=1); | |
namespace App\Service; | |
use Wizaplace\Etl\Etl; | |
use Wizaplace\Etl\Extractors\Aggregator; | |
use Wizaplace\Etl\Extractors\Csv as CsvExtractor; | |
use Wizaplace\Etl\Extractors\Json as JsonExtractor; | |
use Wizaplace\Etl\Loaders\CsvLoader; | |
class DummyService | |
{ | |
/** @var \Wizaplace\Etl\Extractors\Json */ | |
private $jsonExtractor; | |
/** @var \Wizaplace\Etl\Extractors\Csv */ | |
private $csvExtractor; | |
/** @var \Wizaplace\Etl\Loaders\CsvLoader */ | |
private $csvLoader; | |
/** @var \Wizaplace\Etl\Etl */ | |
private $csvEtl; | |
/** @var \Wizaplace\Etl\Etl */ | |
private $jsonEtl; | |
/** @var \Wizaplace\Etl\Etl */ | |
private $etlAggregator; | |
/** @var \Wizaplace\Etl\Extractors\Aggregator */ | |
private $aggregator; | |
public function __construct( | |
Etl $csvEtl, | |
Etl $jsonEtl, | |
Etl $etlAggregator, | |
CsvExtractor $csvExtractor, | |
JsonExtractor $jsonExtractor, | |
CsvLoader $csvLoader, | |
Aggregator $aggregator | |
) { | |
$this->csvEtl = $csvEtl; | |
$this->jsonEtl = $jsonEtl; | |
$this->etlAggregator = $etlAggregator; | |
$this->csvExtractor = $csvExtractor; | |
$this->csvLoader = $csvLoader; | |
$this->jsonExtractor = $jsonExtractor; | |
$this->aggregator = $aggregator; | |
} | |
public function process(): int | |
{ | |
$outputCsvFile = ‘assets/tuto/output.csv’; | |
($this->etlAggregator) | |
->extract( | |
$this->aggregator, | |
[ | |
$this->getCsvEtl(), | |
$this->getJsonEtl(), | |
], | |
[ | |
‘index’ => [‘id’], | |
‘columns’ => [‘id’,’email’,’subscriptionNumber’], | |
‘strict’ => true | |
] | |
) | |
->load( | |
$this->csvLoader, | |
$outputCsvFile | |
) | |
->run() | |
; | |
return 0; | |
} | |
private function getCsvEtl() | |
{ | |
$inputFile = ‘assets/tuto/customers.csv’; | |
return $this->csvEtl | |
->extract( | |
$this->csvExtractor, | |
$inputFile, | |
[ | |
‘throwError’ => true, | |
‘delimiter’ => ‘;’, | |
‘columns’ => [‘id’, ’email’] | |
] | |
) | |
->toIterator(); | |
} | |
private function getJsonEtl() | |
{ | |
$inputFile = ‘assets/tuto/customers.json’; | |
return $this->jsonEtl | |
->extract( | |
$this->jsonExtractor, | |
$inputFile | |
) | |
->toIterator(); | |
} | |
} |
view rawDummyService-With-Multiple-Sources.php hosted with ❤ by GitHub
Note : comme vous le remarquerez, cela fait beaucoup de dépendances dans le constucteur. En effet, quand vous avez besoin de beaucoup de composants, nous recommendons d’utiliser une classe factory, qui sera votre seule dépendance. Un ticket en ce sens existe ici. Une autre solution est de mettre en injection de dépendance qu’une seule entrée par composant et de les cloner au besoin, mais 1) cela ne fait que repousser le problème et 2) cela rend très compliqué les tests unitaires sur votre code, bref c’est à éviter.
Voici le résultat que vous obtiendrez :id;email;type;subscriptionNumber
1;john.doe@acme.com;premium;p123
3;el-barto@acme.com;gold;g234
Conclusion
Nous avons dû utiliser cette librairie au cours d’un gros projet de migration pour un client, avec des dizaines de types d’informations différents, ces informations provenant elles-mêmes de plusieurs sources variées : CSV, BDD, API… pour les rendre compatibles et les migrer dans la nouvelle solution du client.
Concrètement, nous en avons été très satisfaits, et à chaque blocage, nous avons trouvé soit les composants qui permettaient d’aborder le problème sous un autre angle, soit nous avons tout simplement rajouté la brique manquante à la librairie, avec des performances tout à fait convenables.
Pour ce qui est de l’avenir de cette librairie, pour l’instant, nous nous contentons de la faire évoluer au besoin, en rajoutant des composants ou en corrigeant d’éventuels problèmes déjà existants avant le fork, nous n’avons pas prévu de V2 avec une réécriture complète, bien que certains composants en aient réellement besoin.