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();
}
}
|