Skip to content

Stream d'un export CSV volumineux avec Symfony et doctrine

Auteur : Philippe Le Van - @plv@framapiaf.org

Date : 30 mars 2021

Introduction

Parfois il faut générer un export CSV d'un nombre considérable de données.

Pour éviter que ça soit très long ou limité par la mémoire maximum de PHP, on peut "streamer" la réponse, c'est-à-dire l'envoyer au fil de l'eau.

Dans l'exemple ci-dessous, on récupère avec doctrine en base 30 millions de données et on doit générer un export CSV et l'envoyer au navigateur.

Solution proposée

Un repository qui renvoie un generator

Pour que la requête doctrine ne fasse pas exploser la mémoire, on utilise un générateur dans le repository.

ci-joint le findAllGenerator de la classe ObservationRepository

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
// [...]

    /**
     * @return Observation[]|\Generator
     */
    public function findAllGenerator(): \Generator
    {
        $qb = $this->createQueryBuilder('o')
            ->innerJoin('o.autreTable', 'at')
        ;
        return $qb->getQuery()->toIterable();

        /*
        // le toIterable fait plus ou moins l'équivalent du code suivant
        foreach ($qb->getQuery()->iterate() as $result) {
            yield $result[0];
            $this->_em->clear();
        }
        */
    }

Et ensuite dans le contrôleur, c'est utilisé de la façon suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php

namespace App\Controller;

use App\Entity\Observation;
use App\Repository\ObservationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Annotation\Route;

class ExportController extends AbstractController
{
    /**
     * @Route("/stream-export-csv", name="stream-export-csv")
     */
    public function streamExportCsv(): Response
    {
        /** @var ObservationRepository $repo */
        $repo = $this->getDoctrine()->getRepository(Observation::class);
        // on fixe le timeout php à 1h
        set_time_limit(3600);
        // je referme le ob_start par défaut de symfony (à voir si c'est nécessaire)
        ob_flush();
        // streamed response
        $response = new StreamedResponse();
        // pour virer le bufferring du serveur web (nginx par ex)
        $response->headers->set('X-Accel-Buffering', 'no');
        // pour forcer le téléchargement
        $disposition = HeaderUtils::makeDisposition(
            HeaderUtils::DISPOSITION_ATTACHMENT,
            'export.csv'
        );
        $response->headers->set('Content-Disposition', $disposition);
        // le callback est la fonction qui renvoie effectivement la donnée
        $response->setCallback(function () use ($repo) {
            $fp = fopen('php://output', 'w');
            // le "findAllGenerator" est un générateur, il renvoie les données au
            // fil de l'eau et libère la mémoire à chaque itération.
            foreach ($repo->findAllGenerator() as $observation) {
                fputcsv($fp, [
                    (new \DateTime())->format('H:i:s'),
                    $observation->getHappenedAt(),
                    $observation->getValue(),
                    $observation->getKey()
                ]);
                flush();
            }
            fclose($fp);
        });
        // lance le processus (et notamment le callback)
        $response->send();
    }
}