Importer plus de 500 000 utilisateurs via un csv en moins de deux minutes avec Symfony 4 et Doctrine

Importer plus de 500 000 utilisateurs via un csv en moins de deux minutes avec Symfony 4 et Doctrine

Diviser pour mieux régner :

Lorsque l’on construit un projet web, il arrive parfois de partir d’un fichier csv assez conséquent. Dans mon cas je devais importer un fichier d’environ 700 000 lignes, chaque ligne pouvant devenir à terme un utilisateur du système.

Une première problématique qui nous vient à l’esprit c’est la gestion de la mémoire avec Php, en effet lorsqu’on importe via un ORM ce genre de volume, on peut être confronté à un problème de fuite mémoire.  Même si le Garbage Collector de Php semble de plus en plus efficace au fil des versions, il est possible de devoir effectuer quelques actions afin d’aider le langage dans sa gestion de la mémoire. L’objet de mon article n’est pas de vous présenter cette problématique mais plutôt de décomposer et vous montrer ce qu’il est possible de faire au niveau de doctrine pour palier à ce problème.

Ensuite ce sont plutôt des problématiques que l’on rencontre lorsqu’on développe le script d’import en question. Même en faisant attention à la mémoire, mon premier script prenait énormément de temps et je ne comprenais pas pourquoi. Après quelques recherches je me suis rendu compte que ce ralentissement était dû à l’utilisation de FosUser. Et plus précisément aux listeners sur la table User qui ralentissaient considérablement mon script.

Après avoir réglé ce problème, je trouvais que ça n’allait pas assez vite (En effet je me doutais bien que si je devais faire des traitements sur les données du csv je devrais le rejouer dans le futur). J’ai réfléchi à comment accélérer ce script et je me suis dis que je pourrai découper ce gros fichier en plusieurs fichiers, afin de pouvoir exécuter l’import en base de données simultanément.

Dernière chose que j’ai remarqué lors de l’écriture de mon script ; les doublons , en effet le fichier contenait un identifiant de référence pour chaque utilisateur et je me suis aperçu que certaines lignes étaient dupliquées aléatoirement dans le fichier.

Au final je devais simplement développer un script pour importer un gros fichier csv, j’ai fini par développer :

  • Un script pour dédoublonner le csv
  • Un script pour découper le csv en plusieurs csv
  • Un script pour importer les csv

Pour ce faire j’ai essayé de découper au mieux mon code  et d’être le plus SOLID possible, si vous ne connaissez pas ce principe je vous conseille de lire cet excellent article sur le sujet :

Votre code est STUPID ? Rendez-le SOLID !

Dédoublonner le fichier :

Ci-dessous la commande qui dédoublonne les lignes de mon csv : 

<?php

namespace App\Command;

use App\Service\Csv\CsvConvertToArray;
use App\Service\Csv\CsvSanitizeDuplicateRpps;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Class SanitizeCsvCommand
 * @package App\Command
 */
class SanitizeCsvCommand extends Command
{
    /**
     * @var CsvSanitizeDuplicateRpps $csvConvertToArray
     */
    private $csvSanitizeDuplicateRpps;

    /**
     * @var CsvConvertToArray $csvConvertToArray
     */
    private $csvConvertToArray;

    /**
     * @var $outputFileFolder
     */
    private $sourceFileFolder;

    /**
     * SanitizeCsvCommand constructor.
     * @param CsvSanitizeDuplicateRpps $csvSanitizeDuplicateRpps
     * @param CsvConvertToArray $csvConvertToArray
     * @param $sourceFileFolder
     */
    public function __construct(CsvSanitizeDuplicateRpps $csvSanitizeDuplicateRpps, CsvConvertToArray $csvConvertToArray, $sourceFileFolder)
    {
        $this->csvSanitizeDuplicateRpps = $csvSanitizeDuplicateRpps;
        $this->csvConvertToArray        = $csvConvertToArray;
        $this->sourceFileFolder         = $sourceFileFolder;

        parent::__construct();
    }

    protected function configure()
    {
        $this
            ->addArgument('fileinput', InputArgument::REQUIRED, 'The filename input on the source folder')
            ->addArgument('fileoutput', InputArgument::REQUIRED, 'The filename output on the source folder')
        ;
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int|null|void
     * @throws \Doctrine\Common\Persistence\Mapping\MappingException
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $fileinput  = $input->getArgument('fileinput');
        $data       = $this->csvConvertToArray->convert($this->sourceFileFolder.$fileinput);
        $output->writeln(count($data).' lines before sanitize');
        $fileoutput = $input->getArgument('fileoutput');
        $progress  = new ProgressBar($output, count($data));
        $progress->start();
        $data       = $this->csvSanitizeDuplicateRpps->sanitize($data , $this->sourceFileFolder.$fileoutput ,$progress,$output);
        $output->writeln(count($data).' lines after sanitize');
        $progress->finish();
    }
}

 

Ici on voit bien que cette commande a une dépendance avec plusieurs services ayant une responsabilité limitée comme CsvConvertToArray qui va uniquement transformer un csv en tableau et  CsvSanitizeDuplicateRpps qui va dédoublonner le csv.

Au passage j’ai utilisé un composant Symfony intéressant pour afficher la progression lors de l’exécution d’une commande : ProgressBar

Voici ici le service responsable de la suppression de la duplication :

<?php

namespace App\Service\Csv;

use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Stopwatch\Stopwatch;

/**
 * Class CsvSanitizeDuplicateRpps
 * @package App\Service\Csv
 */
class CsvSanitizeDuplicateRpps
{
    /**
     *
     */
    const BATCH_SIZE = 1000;

    /**
     * @var CsvConvertToArray
     */
    private $csvConvertToArray;

    /**
     * CsvSaniteDuplicateRpps constructor.
     * @param CsvConvertToArray $csvConvertToArray
     */
    public function __construct(CsvConvertToArray $csvConvertToArray)
    {
        $this->csvConvertToArray = $csvConvertToArray;
    }

    /**
     * Sanitize duplicate rpps
     * @param array $data
     * @param $outputFilePath
     * @param ProgressBar $progressBar
     * @param OutputInterface $output
     * @return array
     */
    public function sanitize(array $data , $outputFilePath, ProgressBar $progressBar, OutputInterface $output)
    {
        $rppsTreated   = [];

        $stopwatch = new Stopwatch();
        $stopwatch->start('sanitize');

        foreach($data as $key => $row) {
            if (!empty($row[CsvColMapping::COL_RPPS]) &&
                isset($rppsTreated[$row[CsvColMapping::COL_RPPS]])) {

                unset($data[$key]);
            }

             $rppsTreated[$row[CsvColMapping::COL_RPPS]] = $row[CsvColMapping::COL_RPPS];

            if (($key % self::BATCH_SIZE) === 0) {
                $event = $stopwatch->lap('sanitize');
                $output->writeln(' | Lines during sanitize '.count($data).' - Memory used : '.number_format($event->getMemory() / 1048576, 2) .' MB');
                $progressBar->advance(self::BATCH_SIZE);
            }
        }

        $fp = fopen($outputFilePath, 'w');

        foreach ($data as $fields) {
            fputcsv($fp, $fields , ';');
        }

        fclose($fp);
        $stopwatch->stop('sanitize');

        return $data;
    }
}

Traitement assez classique, toutes les 1000 lignes on affiche l’avancé du script. Ici j’ai utilisé un autre composant très pratique de Symfony lorsqu’on travaille avec des commandes : StopWatch , il permet d’afficher notamment certaines informations comme : la mémoire utilisée, le temps d’exécution du script , la durée etc …

Je ne vous le montre pas ici mais j’ai créé une classe spécifique (CsvColMapping) avec des constantes pour lister les colonnes de mon csv et les rendre plus lisibles/compréhensibles lorsqu’on traite le fichier.

Après ça on obtient un fichier dédoublonné et j’ai économisé plusieurs dizaines de milliers de lignes de traitement grâce à ça.

On récupère ce nouveau fichier et on le découpe en plusieurs fichiers csv.

Diviser en plusieurs fichiers :

Voici la commande permettant d’effectuer le découpage  :

<?php

namespace App\Command;

use App\Service\Csv\CsvSplitFile;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class SplitCsvCommand extends Command
{
    /**
     * @var CsvSplitFile $csvSplitFile
     */
    private $csvSplitFile;

    /**
     * SplitCsvCommand constructor.
     * @param CsvSplitFile $csvSplitFile
     */
    public function __construct(CsvSplitFile $csvSplitFile)
    {
        $this->csvSplitFile      = $csvSplitFile;

        parent::__construct();
    }

    protected function configure()
    {
        $this
            ->addArgument('filename', InputArgument::REQUIRED, 'The filename on the source folder')
        ;
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int|null|void
     * @throws \Doctrine\Common\Persistence\Mapping\MappingException
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $filename = $input->getArgument('filename');
        $this->csvSplitFile->split($filename , $output);
    }
}

Et la classe qui effectue le découpage :

<?php
/**
 * Created by PhpStorm.
 * User: psylo
 * Date: 09/02/18
 * Time: 10:06
 */

namespace App\Service\Csv;

use Symfony\Component\Console\Output\OutputInterface;

class CsvSplitFile
{
    /**
     * @var $sourceFileFolder
     */
    private $sourceFileFolder;

    /**
     * @var $sourceFileFolder
     */
    private $outputFileFolder;

    /**
     * CsvSplitFile constructor.
     * @param $sourceFileFolder
     * @param $outputFileFolder
     */
    public function __construct($sourceFileFolder,$outputFileFolder)
    {
        $this->sourceFileFolder = $sourceFileFolder;
        $this->outputFileFolder  = $outputFileFolder;
    }

    /**
     * Generate File name
     * @param $inputFileName
     * @param $partNumber
     * @return string
     */
    private function generateFileName($inputFileName,$partNumber)
    {
        return pathinfo($inputFileName, PATHINFO_FILENAME).'_part_'.$partNumber.'.csv';
    }

    /**
     * Generate output File Path
     * @param $outputFolder
     * @param $inputFileName
     * @param $partNumber
     * @return string
     */
    private function generateOutputFilePath($outputFolder,$inputFileName,$partNumber)
    {
        $outputFileName = $this->generateFileName($inputFileName,$partNumber);
        return $outputFolder.$outputFileName;
    }

    /**
     *  Split file into multi-parts
     * @param $inputFileName
     * @param OutputInterface $output
     * @param int $splitSize
     */
    public function split($inputFileName,
                          OutputInterface $output,
                          $splitSize = 100000)
    {
        $in = fopen($this->sourceFileFolder.$inputFileName, 'r');
        $out = null;
        $rowCount = 0;
        $partNumber = 0;

        while (!feof($in)) {
            if (($rowCount % $splitSize) == 0 ) {
                $outputFilePath = $this->generateOutputFilePath($this->outputFileFolder,$inputFileName,$partNumber);
                if ($rowCount > 0 && $out !== null) {
                    fclose($out);
                    $output->writeln('Closing '.$outputFilePath);
                }
                $partNumber++;
                $output->writeln('Opening '.$outputFilePath);
                $out = fopen($outputFilePath, 'w');
            }
            $data = fgetcsv($in , 0 , ';');
            if ($data)
                fputcsv($out, $data , ';');
            $rowCount++;
        }

        fclose($out);
    }
}

Ici j’ai décidé d’effectuer un fichier toutes les 100 000 lignes, aucune subtilité particulière.

Dans mon cas j’obtiens 6 fichiers, et pour chaque fichier j’utilise la commande d’import suivante :

<?php

namespace App\Command;

use App\Service\Csv\CsvConvertToArray;
use App\Service\Manager\UserImportManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Class ImportUserCommand
 * @package App\Command
 */
class ImportUserCommand extends Command
{
    /**
     * @var CsvConvertToArray $csvConvertToArray
     */
    private $csvConvertToArray;

    /**
     * @var CsvConvertToArray $csvConvertToArray
     */
    private $userImportManager;

    /**
     * @var $outputFileFolder
     */
    private $outputFileFolder;

    /**
     * ImportUserCommand constructor.
     * @param CsvConvertToArray $csvConvertToArray
     * @param UserImportManager $userImportManager
     * @param $outputFileFolder
     */
    public function __construct(CsvConvertToArray $csvConvertToArray,UserImportManager $userImportManager,$outputFileFolder)
    {
        $this->csvConvertToArray = $csvConvertToArray;
        $this->userImportManager = $userImportManager;
        $this->outputFileFolder  = $outputFileFolder;

        parent::__construct();
    }

    protected function configure()
    {
        $this
            ->addArgument('filename', InputArgument::REQUIRED, 'The filename on the source folder')
        ;
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int|null|void
     * @throws \Doctrine\Common\Persistence\Mapping\MappingException
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $filename  = $input->getArgument('filename');
        $data      = $this->csvConvertToArray->convert($this->outputFileFolder.$filename);
        $size      = count($data);
        $progress  = new ProgressBar($output, $size);
        $progress->start();
        $this->userImportManager->addOptions();
        $this->userImportManager->import($data , $progress, $output);
        $progress->finish();
    }
}

Importer les fichiers :

Le service permettant d’importer les utilisateurs :

<?php

namespace App\Service\Manager;

use App\Entity\CsvAddress;
use App\Entity\User;
use App\Service\Csv\CsvColMapping;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Stopwatch\Stopwatch;

/**
 * Class UserImportManager
 * @package App\Service\Manager
 */
class UserImportManager
{
    CONST BATCH_SIZE = 500;

    /**
     * @var EntityManagerInterface $em
     */
    private $em;

    /**
     * @var $repUser
     */
    private $repUser;

    /**
     * @param ObjectManager $em
     */
    public function __construct(ObjectManager $em)
    {
        $this->em      = $em;
        $this->repUser = $this->em->getRepository('App:User');
    }

    /**
     * Extract Locality
     * @param $subject
     * @return mixed
     */
    private function extractLocality($subject)
    {
        preg_match("/[0-9]{4,6}\s([A-ZÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ ]{2,})/" , $subject , $matches, PREG_OFFSET_CAPTURE);
        if (isset($matches[1]) && isset($matches[1][0])) {
            return $matches[1][0];
        }
        return "";
    }

    /**
     * Add options
     * @param $options
     */
    public function addOptions($options = ['logQueries' => false])
    {
        // Turning off doctrine default logs queries for saving memory
        if (isset($options['logQueries']) && $options['logQueries'] === false) {
            $this->em->getConnection()->getConfiguration()->setSQLLogger(null);
        }
    }

    /**
     * Update Csv Address
     * @param CsvAddress $csvAddress
     * @param array $row
     * @return CsvAddress
     */
    private function updateCsvAddress(CsvAddress $csvAddress, array $row)
    {
        $csvAddress->setStreetNumber($row[CsvColMapping::COL_STREET_NUMBER]);
        $csvAddress->setRoute(!empty($row[CsvColMapping::COL_ROUTE_1]) ?
            $row[CsvColMapping::COL_ROUTE_1].' '.$row[CsvColMapping::COL_ROUTE_2] : $row[CsvColMapping::COL_ROUTE_2]);
        $csvAddress->setAddress2($row[CsvColMapping::COL_ADDRESS2]);
        $csvAddress->setLocality(!empty($row[CsvColMapping::COL_LOCALITY]) ? $this->extractLocality($row[CsvColMapping::COL_LOCALITY]) : $row[CsvColMapping::COL_LOCALITY]);
        $csvAddress->setPostalCode($row[CsvColMapping::COL_POSTAL_CODE]);
        $csvAddress->setEmail($row[CsvColMapping::COL_EMAIL]);

        return $csvAddress;
    }

    /**
     * Update User Row
     * @param User $user
     * @param array $row
     * @return User
     */
    private function updateUser(User $user , array $row)
    {
        $user->setFirstname($row[CsvColMapping::COL_FIRSTNAME]);
        $user->setLastname($row[CsvColMapping::COL_LASTNAME]);
        $user->setRpps($row[CsvColMapping::COL_RPPS]);
        $user->setCodeCategory(($row[CsvColMapping::COL_CODE_CATEGORY]));
        $user->setLabelCategory($row[CsvColMapping::COL_LABEL_CATEGORY]);
        $user->setCodeProfession($row[CsvColMapping::COL_CODE_PROFESSION]);
        $user->setLabelProfession($row[CsvColMapping::COL_LABEL_EXPERTISE]);
        $user->setSiret($row[CsvColMapping::COL_SIRET]);
        $user->setCodeExpertise($row[CsvColMapping::COL_CODE_EXPERTISE]);
        $user->setLabelExpertise($row[CsvColMapping::COL_LABEL_EXPERTISE]);
        $user->setSocialReason($row[CsvColMapping::COL_SOCIAL_REASON]);
        $user->setStructureId($row[CsvColMapping::COL_STRUCTURE_ID]);
        $user->setEmail($row[CsvColMapping::COL_RPPS].'@mediveille.fr');
        $user->setUsernameCanonical($row[CsvColMapping::COL_RPPS].'@mediveille.fr');
        $user->setEmailCanonical($row[CsvColMapping::COL_RPPS].'@mediveille.fr');
        $user->setPlainPassword(md5($row[CsvColMapping::COL_RPPS]));
        $user->setPassword(md5($row[CsvColMapping::COL_RPPS]));
        $user->setPhone(trim($row[CsvColMapping::COL_PHONE_1]));
        $user->setPhone2(trim($row[CsvColMapping::COL_PHONE_2]));
        $user->setTelecopy(trim($row[CsvColMapping::COL_TELECOPY]));
        $user->setIsOnMap(true);
        $user->setType(User::TYPE_DOCTOR);
        $user->addRole(User::ROLE_DOCTOR);

        $csvAddress = $user->getCsvAddress();

        if (!$csvAddress instanceof CsvAddress) {
            $csvAddress = new CsvAddress();
        }

        if (!empty($row[CsvColMapping::COL_ROUTE_2]) &&
            !empty($row[CsvColMapping::COL_LOCALITY]) &&
            !empty($row[CsvColMapping::COL_POSTAL_CODE])) {

            $csvAddress = $this->updateCsvAddress($csvAddress , $row);
            $user->setCsvAddress($csvAddress);
        }

        return $user;
    }

    /**
     * Import User
     * @param array $data
     * @param ProgressBar $progress
     * @param OutputInterface $output
     * @throws \Doctrine\Common\Persistence\Mapping\MappingException
     * @throws \Doctrine\ORM\ORMException
     * @throws \Doctrine\ORM\OptimisticLockException
     */
    public function import(array $data,
                           ProgressBar $progress,
                           OutputInterface $output)
    {
        $i             = 1;

        $stopwatch = new Stopwatch();
        $stopwatch->start('import');

        foreach($data as $row) {

                $user = $this->repUser->findOneBy(['rpps' => $row[CsvColMapping::COL_RPPS]]);

                if (!$user instanceof User) {
                    $user = new User();
                }

                $user = $this->updateUser($user , $row);
                $this->em->persist($user);

                if (($i % self::BATCH_SIZE) === 0) {

                    $event = $stopwatch->lap('import');
                    $progress->advance(self::BATCH_SIZE);
                    $output->writeln(' of users imported ... | - Memory : ' .number_format($event->getMemory() / 1048576, 2) . ' MB - Time : ' . number_format($event->getDuration() / 1000, 2) .' seconds');
                    $this->em->flush();
                    $this->em->clear();
                }

                $i++;
        }

        $stopwatch->stop('import');

        $this->em->flush();
        $this->em->clear();
    }
}

Première méthode intéressante et pouvant être à l’origine d’une fuite mémoire, celle-ci :

    /**
     * Add options
     * @param $options
     */
    public function addOptions($options = ['logQueries' => false])
    {
        // Turning off doctrine default logs queries for saving memory
        if (isset($options['logQueries']) && $options['logQueries'] === false) {
            $this->em->getConnection()->getConfiguration()->setSQLLogger(null);
        }
    }

Cette option est automatiquement activée par défaut et permet de logger toutes les requêtes SQL effectuées par l’ORM, pour débugger c’est très bien et ça permet de voir correctement quelles requêtes génère l’ORM, dans notre cas ça risque d’augmenter la mémoire utilisée au fur et mesure de l’avancée de notre script. J’ai utilisé cette méthode mais il est possible d’ajouter l’option –no-debug à votre commande pour avoir cet effet.

Ensuite ce qui est coûteux, cette fois-ci pour la base de données c’est les aller retour avec elle, en effet il vaut mieux lui injecter une fois 500 lignes plutôt que 500 fois une ligne, (Pas toujours tout dépend de la complexité de la requête) au final c’est une grosse requête contre 500 petites que la base va mal encaisser. C’est pour cela que j’ai pris l’habitude de faire du traitement par lots, en définissant à partir de combien d’éléments j’envoie la requête, ici ma constante BATCH_SIZE.

Ci-dessous une partie importante de mon import :

if (($i % self::BATCH_SIZE) === 0) {
   $event = $stopwatch->lap('import');
   $progress->advance(self::BATCH_SIZE);
   $output->writeln(' of users imported ... | - Memory : ' .number_format($event->getMemory() / 1048576, 2) . ' MB - Time : ' . number_format($event->getDuration() / 1000, 2) .' seconds');
   $this->em->flush();
   $this->em->clear();
}

C’est au moment de la méthode flush qu’on l’insère les 500 lignes d’utilisateurs, la méthode clear va quant à elle permettre de détacher les objets doctrines en question et éviter les problèmes de fuite mémoire.

Lorsque j’ai écris mon script, je devais insérer les utilisateurs une fois et puis c’était fini. Les demandes dans le web évoluent assez rapidement et il arrive aussi qu’on n’injecte pas correctement tout du premier coup, donc j’ai été obligé de rendre la mise à jour des utilisateurs possible en les mettant à jour via leur identifiant. Avec cette ligne :

$user = $this->repUser->findOneBy(['rpps' => $row[CsvColMapping::COL_RPPS]]);

Le problème c’est qu’ici à chaque itération on exécute une requête, ce qui est coûteux pour la base d’autant plus si le champs sur lequel on effectue la requête n’est pas indexé. Pour mes entités j’utilise les annotations pour doctrine voici comment j’ai rajouté un index sur le champs en question :

@ORM\Table(name="user",indexes={@ORM\Index(name="rpps_idx", columns={"rpps"})})

A partir du moment ou j’ai ajouté cet index et j’ai rejoué mon script, ça a été le jour et la nuit sur le temps de traitement du script.

Dernière chose, si vous utilisez FosUser, j’en parlais plus haut, il faut désactiver les listeners par défaut lors de l’exécution de votre script pour accélérer considérablement le temps  de traitement.

Voici  la configuration qui permet de désactiver les listeners sur l’entité User  :

fos_user:
    use_listener: false

En pratique maintenant vous n’avez plus qu’à lancer une commande par fichier comme ceci :

bin/console app:import:users users_201705190736_sanitized_part_0.csv

J’ai ainsi réussi à injecter plus de 500 000 utilisateurs en environ 1 minute 30 sec, le plus long c’est de préparer les 6 terminaux à lancer un par un à quelques secondes d’intervalle.

Pour aller plus loin :

Aujourd’hui l’ajout ou la mise à jour de ces données se fait manuellement c’est à dire que l’on doit lancer chaque commande pour mettre à jour la base de données. On peut imaginer que ce fichier csv soit déposé régulièrement sur le serveur et que nous devions traiter quotidiennement cet import, dans ce cas nous pourrions ajouter le dédoublonnage et la division du fichier à travers un Cron et ajouter une commande pour lister tous les fichiers et lancer dynamiquement chaque import. La seule problématique je vois ici serait de refactoriser l’application afin de se passer des listeners de FosUser.

J’espère que cet article vous a plu et je vous laisse le soin de me faire vos remarques en commentaire.

 

3 réactions au sujet de « Importer plus de 500 000 utilisateurs via un csv en moins de deux minutes avec Symfony 4 et Doctrine »

  1. Bonjour Monsieur,

    Je suis un étudiant en informatique et j’ai trouvé votre article intéressant pour le développement d’un projet en cours en entreprise. Par ailleurs, je voulais tester votre projet mais il manque à l’appel une classe qui se nomme « csvConvertToArray ». Je voulais donc savoir s’il serait possible de me l’envoyer par mail ?

    En attente de votre réponse, je vous souhaite une bonne journée.

    Bien à vous.

  2. Merci pour ce super article. c’est au Top.
    Mais par contre si la personne import une fichier xlsx et que on converti en csv avant de le traiter? cela va t’il marcher ?

Répondre à Huynh Vincent Annuler la réponse.

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *