L'ETL

ce qu'il vous manquait
pour intégrer vos clients

 

Lyon

CPE - 16/05/2025

📍

L'ETL

ce qu'il vous manquait
pour intégrer vos clients

 

Lyon

CPE - 16/05/2025

📍

Qui suis-je ?

Nicolas Jourdan

Lead Developer

Symfony

 

@NicolasJourdan_

NicolasJourdan

nicolas-jourdan.medium.com

L'ETL

ce qu'il vous manquait
pour intégrer vos clients

@afup
@NicolasJourdan_

Contexte

@afup
@NicolasJourdan_

Contexte

Mandarine🍊

@afup
@NicolasJourdan_

Contexte

Mandarine🍊

🍊

🍍

🍇

🍍

🍇

🍍

🍇

@afup
@NicolasJourdan_

Contexte

Nom

Taille

Capacité

SKU

Couleur

Tags

Mandarine🍊

@afup
@NicolasJourdan_

Contexte

[
  {
  	"name": "YouPhone 16 Pro Max",
  	"size": 6.7,
  	"capacity": 512,
  	"sku": "YOUPHONE_16_PRO_MAX_ZXC",
  	"color": "black",
  	"tags": [
   	  "premium",
      "5G",
      "waterproof"
    ]
  }
]

POST /api/phones

Mandarine🍊

@afup
@NicolasJourdan_

Contexte

[
  {
    "name": "YouPhone 16 Pro Max",
    "size": 6.7,
    "capacity": 512,
    "sku": "YOUPHONE_16_PRO_MAX_ZXC",
    "color": "black",
    "tags": [
      "premium",
      "5G",
      "waterproof"
    ]
  }
 ]

Mandarine🍊

POST

🍊

🍊

🍊

🍐

🍋

🍇

Contexte

SUPER👌

@afup
@NicolasJourdan_

Contexte

MAIS

@afup
@NicolasJourdan_

Contexte

🚰

@afup
@NicolasJourdan_

Contexte

Poire🍐

@afup
@NicolasJourdan_

Contexte

🍐

Poire🍐

@afup
@NicolasJourdan_

Contexte

🍐

Poire🍐

🍐

🍐

🍍

🍇

🍐

🍐

@afup
@NicolasJourdan_

Contexte

Poire🍐

Nom

Couleur

Taille

SKU

Famille

Capacité

Résistance

Connectivité

Prix

@afup
@NicolasJourdan_

Contexte

Poire🍐

🍐

[
  {
    "nom": "YouPhone 16 Pro Max",
  	"taille": 6.7,
  	"capacité": 512,
  	"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
  	"couleur": "black",
  	"connectivité": "5G",
  	"prix": 1499.90,
  	"famille": "pro",
  	"résistance": "IP68"
  }
]

GET

🍐

🍊

🍍

🍇

@afup
@NicolasJourdan_

Contexte

Poire🍐

[
  {
  	"nom": "YouPhone 16 Pro Max",
  	"taille": 6.7,
  	"capacité": 512,
  	"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
  	"couleur": "black",
  	"connectivité": "5G",
  	"prix": 1499.90,
  	"famille": "pro",
  	"résistance": "IP68"
  }
]

GET /api/telephones

@afup
@NicolasJourdan_

Contexte

Poire🍐

[
  {
  	"nom": "YouPhone 16 Pro Max",
  	"taille": 6.7,
  	"capacité": 512,
  	"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
  	"couleur": "black",
  	"connectivité": "5G",
  	"prix": 1499.90,
  	"famille": "pro",
  	"résistance": "IP68"
  }
 ]

GET /api/telephones

[
  {
    "name": "YouPhone 16 Pro Max",
    "size": 6.7,
    "capacity": 512,
    "sku": "YOUPHONE_16_PRO_MAX_ZXC",
    "color": "black",
    "tags": [
      "premium",
      "5G",
      "waterproof"
    ]
  }
]

POST /api/phones

Mandarine🍊

@afup
@NicolasJourdan_

Délai serré

Récupération des données

Contraintes

Développements spécifiques (tags)

Mapping des champs

Faible volumétrie

@afup
@NicolasJourdan_

ETL

@afup
@NicolasJourdan_

ETL

Extract Transform Load

🧃 1 L
  • Base de données
  • API
  • Fichier (CSV, XML, etc.)
🍇 1,5 kg
🍌 1 kg
🍇 1,5 kg
🧃 1 L
🍇 1,5 kg
🍌 1 kg
  • Base de données
  • API
  • Fichier (CSV, XML, etc.)

🍐Poire/Mandarine🍊

Extract Transform Load

[
  {
  	"nom": "YouPhone 16 Pro Max",
  	"taille": 6.7,
  	"capacité": 512,
  	"code_produit": "YOUPHONE_16_PRO_MAX_ZXC",
  	"couleur": "black",
  	"connectivité": "5G",
  	"prix": 1499.90,
  	"famille": "pro",
  	"résistance": "IP68"
  }
]

GET

🍐
[
  {
    "name": "YouPhone 16 Pro Max",
    "size": 6.7,
    "capacity": 512,
    "sku": "YOUPHONE_16_PRO_MAX_ZXC",
    "color": "black",
    "tags": [
      "premium",
      "5G",
      "waterproof"
    ]
  }
]

POST

🍊
@afup
@NicolasJourdan_

ETL

Outils existants

Projet interne

AWS Glue

Talend Open Studio

Apache Airflow

@afup
@NicolasJourdan_

ETL

Outils existants

Connecteurs

"Un connecteur est un module préconfiguré qui permet à un outil ETL de communiquer avec une source ou une destination."

@afup
@NicolasJourdan_

PostgreSQL

MongoDB

MySQL

S3

Instagram

Facebook

HubSpot

SalesForce

ElasticSearch

Google Analytics

ETL

AWS Glue

ETL

Outils existants

Pas de connecteur ?

@afup
@NicolasJourdan_

Python

Scala

ETL

AWS Glue

ETL

Comparaison

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile
Scalabilité Optimisée Limitée

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile
Scalabilité Optimisée Limitée
Coût Potentiellement élevé Faible

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile
Scalabilité Optimisée Limitée
Coût Potentiellement élevé Faible
Performance Optimisée Limitée

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile
Scalabilité Optimisée Limitée
Coût Potentiellement élevé Faible
Performance Optimisée Limitée
Monitoring Intégré À développer manuellement

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Long si besoin de créer un connecteur (Python/Scala) Rapide
Connectivité Limitée Facile
Flexibilité (format, etc.) Limitée Totale
Évolutivité Limitée Facile
Scalabilité Optimisée Limitée
Coût Potentiellement élevé Faible
Performance Optimisée Limitée
Monitoring Intégré À développer manuellement
Sécurité Intégrée À gérer manuellement

🍐Poire/Mandarine🍊

@afup
@NicolasJourdan_

ETL

Critère Outils existants Projet interne
Temps de développement Rapide Rapide
Connectivité Facile Facile
Flexibilité (format, etc.) Totale Totale
Évolutivité Facile Facile
Scalabilité Optimisée Limitée
Coût Potentiellement élevé Faible
Performance Optimisée Limitée
Monitoring Intégré À développer manuellement
Sécurité Intégrée À gérer manuellement

Avec un connecteur existant

@afup
@NicolasJourdan_

ETL

Projet interne

@afup
@NicolasJourdan_
@afup
@NicolasJourdan_

ETL

🍐

🍊

🍐

🍊

@afup
@NicolasJourdan_
🍐
🍊

Extract

Transform

Load

Extract

@afup
@NicolasJourdan_

Extract

<?php

readonly class ProductFetcher
{
    //...

    public function fetch(): array
    {
        $response = $this->client->request('GET', $this->apiUrl, [
            'auth_bearer' => $this->apiKey,
        ]);

        if ($response->getStatusCode() !== 200) {
            throw new \RuntimeException('Failed to fetch products');
        }

        return $response->toArray();
    }
}

Extract

$pearProducts = [
  [
   	"nom" => "YouPhone 16 Pro Max",
    "taille" => 6.7,
    "capacité" => 512,
    "code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
    "couleur" => "black",
    "connectivité" => "5G",
    "prix" => 1499.90,
    "famille" => "pro",
    "résistance" => "IP68"
  ]
];
@afup
@NicolasJourdan_
@afup
@NicolasJourdan_
🍐
🍊

Extract

Transform

Load

$pearProducts = [
  [
   	"nom" => "YouPhone 16 Pro Max",
    "taille" => 6.7,
    "capacité" => 512,
    "code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
    "couleur" => "black",
    "connectivité" => "5G",
    "prix" => 1499.90,
    "famille" => "pro",
    "résistance" => "IP68"
  ]
];

Transform

@afup
@NicolasJourdan_

Transform

<?php

readonly class ProductMapper
{
    public function map(array $pearProducts): array
    {
        $mappedProducts = [];

        foreach ($pearProducts as $pearProduct) {
            $mappedProducts[] = [
                "name" => $pearProduct["nom"],
                "size" => $pearProduct["taille"],
                // ...
                'tags' => $this->resolveTags($pearProduct),
            ];
        }

        return $mappedProducts;
    }
    
    // ...
}

Transform

<?php

readonly class ProductMapper
{
    //...

    private function resolveTags(array $pearProduct): array
    {
        return \array_filter([
            $pearProduct['connectivité'] ?? null, // 5G, 4G, 3G, etc.
            //...
        ]);
    }
}
@afup
@NicolasJourdan_

Transform

<?php

readonly class ProductMapper
{
    //...

    private function resolveTags(array $pearProduct): array
    {
        return \array_filter([
            //...
            (
                isset($pearProduct['famille']) && $pearProduct['famille'] === 'pro'
                && isset($pearProduct['prix']) && $pearProduct['prix'] > 1000
            ) ? 'premium' : null,
            //...
        ]);
    }
}
@afup
@NicolasJourdan_

Transform

<?php

readonly class ProductMapper
{
    //...

    private function resolveTags(array $pearProduct): array
    {
        return \array_filter([
            //...
            (
                isset($pearProduct['résistance'])
                && $this->extractResistance($pearProduct['résistance']) >= 67
            ) ? 'waterproof' : null,
        ]);
    }
}
@afup
@NicolasJourdan_

Transform

$mandarineProducts = [
  [
    "name" => "YouPhone 16 Pro Max",
    "size" => 6.7,
    "capacity" => 512,
    "sku" => "YOUPHONE_16_PRO_MAX_ZXC",
    "color" => "black",
    "tags" => [
        "premium",
        "5G",
        "waterproof"
    ]
  ]
];
@afup
@NicolasJourdan_

Transform

$mandarineProducts = [
  [
    "name" => "YouPhone 16 Pro Max",
    "size" => 6.7,
    "capacity" => 512,
    "sku" => "YOUPHONE_16_PRO_MAX_ZXC",
    "color" => "black",
    "tags" => [
        "premium",
        "5G",
        "waterproof"
    ]
  ]
];
$pearProducts = [
  [
    "nom" => "YouPhone 16 Pro Max",
    "taille" => 6.7,
    "capacité" => 512,
    "code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
    "couleur" => "black",
    "connectivité" => "5G",
    "prix" => 1499.90,
    "famille" => "pro",
    "résistance" => "IP68"
  ]
];
@afup
@NicolasJourdan_
@afup
@NicolasJourdan_
🍐
🍊

Extract

Transform

Load

$pearProducts = [
  [
   	"nom" => "YouPhone 16 Pro Max",
    "taille" => 6.7,
    "capacité" => 512,
    "code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
    "couleur" => "black",
    "connectivité" => "5G",
    "prix" => 1499.90,
    "famille" => "pro",
    "résistance" => "IP68"
  ]
];
$mandarineProducts = [
  [
    "name" => "YouPhone 16 Pro Max",
    "size" => 6.7,
    "capacity" => 512,
    "sku" => "YOUPHONE_16_PRO_MAX_ZXC",
    "color" => "black",
    "tags" => [
        "premium",
        "5G",
        "waterproof"
    ]
  ]
];

Load

@afup
@NicolasJourdan_

Load

<?php

readonly class ProductLoader
{
    //...

    public function load(array $mandarineProducts): void
    {
        $response = $this->client->request('POST', $this->apiUrl, [
            'auth_bearer' => $this->apiKey,
            'json' => $mandarineProducts,
        ]);

        if ($response->getStatusCode() !== 201) {
            throw new \RuntimeException('Failed to load products');
        }
    }
}
@afup
@NicolasJourdan_
🍐
🍊

Extract

Transform

Load

$pearProducts = [
  [
   	"nom" => "YouPhone 16 Pro Max",
    "taille" => 6.7,
    "capacité" => 512,
    "code_produit" => "YOUPHONE_16_PRO_MAX_ZXC",
    "couleur" => "black",
    "connectivité" => "5G",
    "prix" => 1499.90,
    "famille" => "pro",
    "résistance" => "IP68"
  ]
];
$mandarineProducts = [
  [
    "name" => "YouPhone 16 Pro Max",
    "size" => 6.7,
    "capacity" => 512,
    "sku" => "YOUPHONE_16_PRO_MAX_ZXC",
    "color" => "black",
    "tags" => [
        "premium",
        "5G",
        "waterproof"
    ]
  ]
];

Job

@afup
@NicolasJourdan_

Job

<?php 

readonly class ProductJob
{
    public function __construct(
        private ProductFetcher $productFetcher,
        private ProductMapper $productMapper,
        private ProductLoader $productLoader,
    ) {
    }

    public function __invoke(): void
    {
        $products = $this->productFetcher->fetch();
        $mappedProducts = $this->productMapper->map($products);
        $this->productLoader->load($mappedProducts);
    }
}
@afup
@NicolasJourdan_
@afup
@NicolasJourdan_

ETL

🍐

🍊

🍐

🍊

Défis

@afup
@NicolasJourdan_

Défis

Système de logs

Nouveau flux en entrée / sortie

@afup
@NicolasJourdan_

Données manquantes

Données manquantes

@afup
@NicolasJourdan_
<?php

readonly class ProductMapper
{
	private OptionsResolver $resolver;

	public function __construct()
    {
    	$this->resolver = new OptionsResolver();
        $this->configureOptions($this->resolver);
    }

    //...
}
<?php

readonly class ProductMapper
{
    //...
    
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver
            ->setRequired(['nom', 'taille', 'capacité', 'code_produit', 'couleur'])
            ->setDefined(['connectivité', 'famille', 'prix', 'résistance'])
            ->setAllowedTypes('nom', 'string')
            ->setAllowedTypes('taille', 'float')
            ->setAllowedTypes('capacité', 'int')
            ->setAllowedTypes('code_produit', 'string')
            ->setAllowedTypes('couleur', 'string')
            //...
        ;
    }
}
<?php

readonly class ProductMapper
{
	//...

    public function map(array $pearProducts): array
    {
        $mappedProducts = [];

        foreach ($pearProducts as $pearProduct) {
            $pearProduct = $this->resolver->resolve($pearProduct);

            $mappedProducts[] = [
                "name" => $pearProduct["nom"],
               //...
            ];
        }

        return $mappedProducts;
    }

    //...
}

Système de logs

@afup
@NicolasJourdan_
<?php

use Psr\Log\LoggerInterface;

readonly class ProductFetcher
{
    public function __construct(
        private LoggerInterface $logger,
        //...
    ) {
    }

    //...
}
<?php

readonly class ProductFetcher
{
    //...

    public function fetch(): array
    {
        $this->logger->info('Starting product fetch from external API', [
            'url' => $this->apiUrl,
        ]);

        //...
    }
}
<?php

readonly class ProductFetcher
{
    //...

    public function fetch(): array
    {
        //...

        try {
            $response = $this->client->request('GET', $this->apiUrl, [
                'auth_bearer' => $this->apiKey,
            ]);
        } catch (\Throwable $e) {
            $this->logger->error('API request failed', [
                'exception' => $e,
                'url' => $this->apiUrl,
            ]);

            throw new \RuntimeException('API request failed');
        }
        
        //...
    }
}
<?php

readonly class ProductFetcher
{
    //...

    public function fetch(): array
    {
        //...
        
        $statusCode = $response->getStatusCode();

        if ($statusCode !== 200) {
            $this->logger->error('Product API returned unexpected status code', [
                'status_code' => $statusCode,
            ]);
 
            throw new \RuntimeException('Unexpected status code');
        }

        //...
    }
}
<?php

readonly class ProductFetcher
{
    //...

    public function fetch(): array
    {
        //...
        
        $products = $response->toArray();

        $this->logger->info('Product data successfully fetched', [
            'product_count' => \count($products),
        ]);

        return $products;
    }
}
<?php

readonly class ProductMapper
{
	//...

    public function map(array $pearProducts): array
    {
        $mappedProducts = [];

        foreach ($pearProducts as $pearProduct) {
            try {
                $resolved = $this->resolver->resolve($pearProduct);

                //...
            } catch (ExceptionInterface $e) {
                $this->logger->error('Failed to resolve product', [
                    'error' => $e->getMessage(),
                    'product' => $pearProduct,
                ]);

                continue;
            }
        }

        //...
    }

    //...
}
<?php

readonly class ProductLoader
{
    //...

    public function load(array $mandarineProducts): void
    {
        $this->logger->info('Starting product load to internal API', [
            'url' => $this->apiUrl,
            'product_count' => \count($mandarineProducts),
        ]);

        //...
    }
}
<?php

readonly class ProductLoader
{
    //...

    public function load(array $mandarineProducts): void
    {
        //...

        try {
            $response = $this->client->request('POST', $this->apiUrl, [
                'auth_bearer' => $this->apiKey,
                'json' => $mandarineProducts,
            ]);
        } catch (\Throwable $e) {
            $this->logger->error('API request failed during product load', [
                'exception' => $e,
                'url' => $this->apiUrl,
            ]);
            
            throw new \RuntimeException('Failed to load products');
        }

       //...
    }
}
<?php

readonly class ProductLoader
{
    //...

    public function load(array $mandarineProducts): void
    {
        //...
        $statusCode = $response->getStatusCode();

        if ($statusCode !== 201) {
            $this->logger->error('Unexpected status code', [
                'status_code' => $statusCode,
            ]);

            throw new \RuntimeException('Failed to load products');
        }

        $this->logger->info('Product load completed successfully');
    }
}

Nouveau flux en entrée / sortie

@afup
@NicolasJourdan_
<?php

interface ProductFetcherInterface
{
    public function fetch(): array;
}

readonly class ProductFetcher implements ProductFetcherInterface {...}

Interfaces

@afup
@NicolasJourdan_
<?php

interface ProductMapperInterface
{
    public function map(array $pearProducts): array;
}

readonly class ProductMapper implements ProductMapperInterface {...}

Interfaces

@afup
@NicolasJourdan_
<?php

interface ProductLoaderInterface
{
    public function load(array $mandarineProducts): void;
}

readonly class ProductLoader implements ProductLoaderInterface {...}

Interfaces

@afup
@NicolasJourdan_

Job

<?php 

readonly class ProductJob
{
    public function __construct(
        private ProductFetcherInterface $productFetcher,
        private ProductMapperInterface $productMapper,
        private ProductLoaderInterface $productLoader,
    ) {
    }

    public function __invoke(): void
    {
        $products = $this->productFetcher->fetch();
        $mappedProducts = $this->productMapper->map($products);
        $this->productLoader->load($mappedProducts);
    }
}
@afup
@NicolasJourdan_

Défis

Performances

Volume de données

...

@afup
@NicolasJourdan_
@afup

Bonnes pratiques

et

pièges à éviter

@afup
@NicolasJourdan_

Bonnes pratiques et

pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques

Volume ?

Charge ?

Performances ?

Flux ?

@afup
@NicolasJourdan_

Bonnes pratiques et

pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques

Outil existant

Projet interne

Choisir la solution la plus adaptée

@afup
@NicolasJourdan_

Bonnes pratiques et

pièges à éviter

Bien comprendre le besoin métier et les contraintes techniques

Choisir la solution la plus adaptée

Documenter (mappings, schémas, flux, etc.)

@afup
@NicolasJourdan_

Bonnes pratiques et

pièges à éviter

Tests et validation des données

Gestion des erreurs et des cas limites

Logs et alerting

Flexibilité et évolutivité

@afup
@NicolasJourdan_

Merci !

@afup
@NicolasJourdan_

Feedback

L’ETL, ce qu’il vous manquait pour intégrer vos clients (AFUP Day 2025)

By Nicolas Jourdan

L’ETL, ce qu’il vous manquait pour intégrer vos clients (AFUP Day 2025)

  • 277