Du NoSQL en PHP : c'est possible et c'est (relativement) facile

optimisation et cas d'étude

Julien Garderon

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Du NoSQL en PHP : c'est possible et c'est (relativement) facile

- Auteur : Julien Garderon

- Parution : 18 juin 2018

- Licence : Copyright ® 2018 ulien Garderon. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Voir également :

- Du NoSQL en PHP : apprendre à créer un mini-moteur NoSQL en PHP

Sommaire

- I. Préambule

- II. Retour sur les différences fondamentales entre (My)SQL et notre moteur

- III. Approfondir notre SGBD NoSQL par PHP parce que…

- IV. Comment (bien!) lire un index ?

- V. Le tri d'index : exemple de possibilités d'optimisation

- V-A. Introduction

- V-A-1. À noter

- V-A-2. Pourquoi découper son index avant le tri ?

- V-B. Le calcul et la préparation

- V-B-1. commande OS

- V-B-2. script PHP

- V-B-3. Le comparatif

- V-C. La découpe en portions pouvant être chargées individuellement par le script

- V-C-1. Commande OS

- V-C-2. Script PHP

- V-C-3. Le comparatif

- V-D. Le tri de chaque morceau

- V-D-1. Commande OS

- V-D-2. Script PHP (1)

- V-D-3. Script PHP (2)

- V-D-4. Le comparatif

- V-E. (3') Le tri des morceaux entre eux

- V-E-1. La logique générale

- V-F. La réunion de tous les morceaux

- V-F-1. Commande OS

- V-F-2. Script PHP

- V-F-3. Le comparatif

- V-G. Une recette « unique » en PHP

- V-H. Révélation : et PHP ne servait à rien ?

- V-H-1. … Géré par PHP, mais exécuté par autre chose

- V-H-2. … Un index construit ailleurs

- V-H-3. L'implémentation « réelle » : jouer de l'abstraction des classes

- V-H-4. Côté client, ça donne quoi ?

- V-H-5. C'est déjà la fin ?!

- VI. Note de la rédaction de Developpez.com

I - Préambule

Dans l'article précédent, nous avons abordé un cas d'école : mettre en œuvre un SGBD, de type NoSQL, grâce à PHP 5.6 (faute de mieux) et dans un contexte d'exécution CGI (via Apache par exemple). Cet environnement, sans module spécifique ni d'autres SGBD à disposition, sera réutilisé ici.

Une partie des résultats issue de l'article précédent devra être reprise : pensez donc d'abord à générer les entités et index grâce aux indications fournies.

Nous nous attarderons ici principalement sur le tri et la lecture des index, moins sur l'aspect supervision et accès client, qui feront l'objet d'autres articles.

II - Retour sur les différences fondamentales entre (My)SQL et notre moteur

Dans le domaine du Web, contrairement à (My)SQL qui est une activité permanente de service — ou plus exactement un ensemble de processus — strictement différent du serveur Web (SQL peut aussi évoluer avec des logiciels ou d'autres services), avec son autonomie propre, dans notre cas c'est l'appel d'un client qui active un ensemble de fonctions pour émuler, ou reproduire, l'usage et l'activité d'un serveur NoSQL classique. Cet ensemble est en soi un SGBD.

Le maintien même de la base, pour ses mises à jour ou l'entretien des index, est fait en fonction d'appels client. Notre base n'est donc pas un service — donc pas un logiciel « serveur » de données — mais un composant de SGBD pour PHP.

Items

Serveur SQL

SGBD par PHP-CGI

Dépendance / indépendance

– Indépendant, peut être utilisé en dehors d'un contexte Web / réseau.

– Dépendant de PHP, le plus souvent dépendant d'un serveur type Apache ou Nginx (cas CGI / hors CLI).

Résilience du système / de la base en cas de défaut

– Obligation de redémarrer le logiciel serveur.
– Surveillance du processus parent SQL.
– Sécurité interne et native, pour éviter la corruption de la base.

– Une requête qui échoue ne fait pas échouer les autres.
– Surveiller chaque requête pour éviter une corruption de la base.
– Risque mineur de corruption (nombreux fichiers d'entités, index pouvant être régénéré « facilement »).

Empreinte mémoire

– En attente : minimale, en attente de réception d'une demande.
– En fonctionnement : proportionnelle à la complexité de la requête.

– En attente : aucune en l'absence de client.
– En fonctionnement : proportionnelle à la complexité de la requête, s'insère dans un contexte d'exécution PHP.

Langage de requête

- Oui, en natif, de haut niveau (proche du langage humain) et obligatoire pour toute demande.

- Non, au mieux sous le format d'une API avec la possibilité d'implémenter un pseudolangage par tozenizer.

Optimisations / événements programmés

– En partie et en natif.

– À programmer grâce à des appels client programmés (exemple : tâches CRON).

Contexte d'utilisation / avantages

– Nécessité de profiter des propriétés ACID.
– SGBD indépendant du client.

– Montée en charge totalement liée aux besoins du client et de leur nombre.
– SGBD simple et des données facilement exportables / manipulables.

Principaux inconvénients

– Avant MySQL8, la difficulté à gérer correctement/facilement des données non structurées ou la scalabilité verticale.
– L'optimisation est très liée à une structure « bien pensée » des données.

– Une base orientée « lecture », du fait de la lourdeur relative des calculs des index.
– Des performances qui n'égaleront pas (et jamais) des programmes écrits en C.

III - Approfondir notre SGBD NoSQL par PHP parce que…

Nous pourrions rester sur les principes énoncés par le premier article publié. Cependant, trois facteurs viendront alors faire échouer l'utilisation de la base lorsqu'elle grandira :

- les index, lorsqu'ils seront imposants, se heurteront à une limite de taille admise pour gérer les fichiers par PHP en CGI. Notre script retournera une erreur telle que « Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 72 bytes) ». Il n'y a alors pas d'échappatoire : soit réduire la taille de l'index (du fichier donc), soit augmenter la RAM allouée pour le script qui s'exécute ;

- le calcul ou l'accès disque, deviendra bien trop long en exécution : il ne pourra jamais se terminer, car très souvent, il y a une barrière temporelle fixée par le serveur et qui est quasi-indépassable pour les scripts PHP (aux alentours de 30 secondes). L'erreur sera cette fois-ci « Fatal error: Maximum execution time of 30 seconds exceeded ». Sauf à virtuellement démultiplier le nombre de requêtes émises par le client en tentant de réduire toujours plus les portions : la qualité de navigation et la charge sur le réseau rendront ces tentatives désespérées ;

- le système, « naïf », sera très inefficace. Et très au-delà de ce que l'on pourrait admettre pour un système fonctionnant dans des conditions critiques comme celles que j'évoquais dans mon premier article. Dès lors, il faut sérieusement se poser la question non pas de la pertinence de notre réponse (c'est-à-dire la mise en œuvre d'un SGBD NoSQL par PHP), mais de sa mise en œuvre (quelle application pour le SGBD).

IV - Comment (bien!) lire un index ?

J'ai repris l'index principal de l'article précédent, en appliquant 10 000 fois une même opération de lecture de notre index principal (1 000 lignes). Il y a donc 10 000 000 de lignes parcourues au total.

Dans la réalité de la production, il n'y a pas 10 000 ouvertures/fermetures d'un fichier d'index : les performances affichées ici ne peuvent refléter que les conditions du test et sont une indication générale. À ce sujet, l'utilisation de fseek au lieu du couple fermeture/ouverture de fichier me fait gagner 0,2 seconde pour le premier test et 0,3 seconde pour le second test.

Deux opérations différentes, associées à un découpage par regExp (un pattern peu efficace) sont comparées :

- la lecture « ligne à ligne », c'est-à-dire la récupération par fgets d'une seule ligne du fichier, par itération, jusqu'à la fin du fichier. Cela correspond à retourner le buffer du texte déjà lu lorsque la lecture trouve « \n » (ou « \r\n » ; ou plus généralement ce qui symbolise la fin de ligne sur votre système) ;

- la lecture « par portion », c'est-à-dire la récupération d'une taille arbitraire, sans notion de délimiteur.

Dans mon test, la lecture « par portion » est fixée à 45 % de la RAM allouée pour le script PHP (dont la valeur nous est connue par memory_get_peak_usage et son argument à true). Cela revient, dans une version un peu moins efficace, à charger l'ensemble du fichier dans la RAM, comme pourrait le faire file_get_contents.

Les résultats parlent d'eux-mêmes : quand la lecture « ligne à ligne » est aux alentours de 16,5 secondes, la lecture « par portion » redescend vers 12,1 secondes.

ligne à ligne

par portion

16,74
16,46
16,59
16,58
16,61
16,48
16,59
16,55
16,63
16,73

12,12
12,16
12,17
12,35
12,15
12,77
12,33
11,98
12,31
12,41

Pour autant, le fichier est ici plutôt léger — seulement 1 000 lignes de quelques dizaines caractères — avec une recherche finalement simple. Si l'opération avait été plus complexe (par exemple avec des transformations et comparaisons d'attributs), avec une RAM peu élevée, le choix d'une lecture « ligne à ligne » pourrait se révéler plus sûr : les performances seraient sacrifiées (comme le time out probablement…) sur l'autel d'une opération qui aboutit.

Le fichier PHP de test de la lecture « ligne à ligne »

1. <?php

3. $debut = microtime(true);

5. $tailleMax = memory_get_peak_usage(

6. true

7. );

8. $tailleFichier = intval($tailleMax*0.45);

10. $nbre = 0;

11. $max = 10000;

13. try {

14. while($nbre<$max) {

15. $stream = fopen(

16. "./tests/index.entites",

17. "r"

18. );

19. $portion = "";

20. $trouve = 0;

21. while (true) {

22. $portion .= fread(

23. $stream,

24. $tailleFichier

25. );

26. if (strlen($portion)==0)

27. break;

28. preg_match_all(

29. "#(?<type>[^\t]+)\t(?<id>[^\t]+)\t(?<path>[^\t]+)\t(?<aff>[^\n]+)\n#im",

30. $portion,

31. $matches,

32. PREG_SET_ORDER

33. );

34. $trouve += count($matches);

35. $n = count($matches)-1;

36. if ($n<0)

37. throw new Exception(

38. "opération impossible, le délimiteur n'a pas été retrouvé"

39. ); // éviter une boucle infinie en cas de problème sur l'index

40. $portion = explode(

41. $matches[$n][0],

42. $portion

43. )[1];

44. }

45. fclose($stream);

46. $nbre++;

47. }

48. } catch(Exception $e) {

49. var_dump($e);

50. }

52. $fin = microtime(true);

54. var_dump($fin-$debut);

L'opération de lecture est primordiale : il faut bien trouver les informations à extraire et ne pas renvoyer des données inutiles ou trompeuses ou client. Lorsque l'on fait des calculs sur les index (tri, moyenne, etc.), la lecture est tout aussi impérieuse.

Dégrader la performance au détriment d'une requête dont on est davantage sûr de son aboutissement peut parfois se révéler nécessaire : j'y reviendrai des implications dans le contexte Web dans un passage sur Server Send Events (SSE).

V - Le tri d'index : exemple de possibilités d'optimisation

V-A - Introduction

V-A-1 - À noter

- Les commandes hors PHP sont disponibles souvent par défaut sous la plupart des systèmes Linux. Elles sont utilisées pour bash et sont appelées ici par facilité de langage (certes un peu abusivement…), « commande OS ».

- Le fichier de test sur ma machine faisait 6,1 millions de lignes pour 113,5 Mo. Par défaut, j'ai utilisé des portions de lecture pour PHP de 1024² octets (soit 1 Mo).

- Les tests de rapidité sont réalisés par paquet de plusieurs itérations de la même fonction à chaque recueil de temps.

V-A-2 - Pourquoi découper son index avant le tri ?

La question paraît évidente : pourquoi ne pas tout charger en mémoire, dans un immense tableau PHP, qui serait trié puis enregistré ?

Tout d'abord le principe et le contexte : avec 113,5 Mo, mon index de test peut déjà mettre à genoux les hébergements « premier prix » ou gratuits, mutualisés. Ou lighttpd dans mon cas, qui est dans sa configuration par défaut. Dans le meilleur des cas, vous n'aboutirez jamais : un time out sacrifiera encore et encore le travail. Travailler sur des portions permet de continuer facilement en relançant lorsque nécessaire le script.

Cette découpe est aussi la possibilité de paralléliser soit au niveau même du serveur ainsi qu'au niveau du client, qui peut faire plusieurs requêtes en parallèle (en AJAX par exemple, ou par le biais de SSE – j'y reviendrai) et déclencher donc plusieurs processus PHP.

Ensuite, c'est une question de possibilité du tri lui-même : si vos portions ne sont pas clairement définies, en dehors du tri à bulle que j'illustrais dans mon premier article consacré à ce thème, les algorithmes de tri ne s'appliqueront pas. J'insiste : une liste non définie (c.-à-d. infinie), dont toutes les valeurs ne sont pas disponibles ou chargées à un instant t, n'est pas triable directement en dehors du tri par bulle, qui ne se préoccupe que de deux valeurs à chaque fois, et qui continuera donc « éternellement » à parcourir cette liste indéfinie.

En cela c'est reprendre le concept de MapReduce, en éclatant en morceaux indépendants le calcul ou le tri.

De la même façon, il n'y est pas si aisé d'imaginer le tri d'une liste qui grandirait sans discontinuer dans notre cas, avec par exemple un algorithme de tri par insertion. Car sauf à tout enregistrer sur le disque et à effectuer de lourdes opérations pour réaménager en permanence le fichier, une limite de taille de liste gérée par PHP s'appliquera.

V-B - Le calcul et la préparation

V-B-1 - commande OS

Nous utiliserons la commande « wc » (words count) disponible, qui renvoie le nombre de lignes du fichier indiqué grâce à l'argument « - l ».

1. <?php

3. function fichierNbreLignes_OS(

4. $fPath

5. ) {

6. if (preg_match(

7. "#^([0-9]+)#",

8. exec(

9. "wc -l {$fPath}",

10. $retour

11. ),

12. $r

13. ))

14. return $r[0];

15. return false;

16. }

V-B-2 - script PHP

Le script PHP va lire, par portion, le fichier désiré et chercher les occurrences du délimiteur de fin de ligne. Rien de très particulier. J'ai préféré cette version à celle de la fonction fgets pour illustrer mon propos avec le sentiment diffus (et erroné ?) que fgets dans ce cas resterait moins rapide.

1. <?php

3. function fichierNbreLignes(

4. $fPath,

5. $taillePortion = 1024*1024,

6. $delimiteur = "\n"

7. ) {

8. $f = fopen($fPath, "r");

9. $nbre = 0;

10. while (!feof($f)) {

11. $nbre += substr_count(

12. fread(

13. $f,

14. $taillePortion

15. ),

16. $delimiteur

17. );

18. }

19. fclose($f);

20. return $nbre;

21. }

V-B-3 - Le comparatif

Pour ce comparatif, 100 itérations de l'index volumineux ont été réalisées par passage, c'est-à-dire un total de 100 fois 113,5 Mo, ou 100 fois 6,1 millions de lignes.

Le code PHP évolue finalement assez peu face à la commande OS native, dont les résultats sont très stables. Une commande qui exploite totalement les ressources de la machine et dont la programmation en C permet un gain considérable. La moyenne, à 14,19 secondes pour le code PHP et 11,28 secondes pour la commande OS native, est d'un facteur pratiquement d'un quart.

Cela renforce le sentiment initial que, lorsque les fonctions permettant le déport de certaines fonctions au shell sont disponibles, il est largement préférable de les faire « sous-traiter » à ces dernières.

Code PHP

Commande OS native

14,21
14,10
15,53
14,96
13,89
13,83
14,17
13,59
13,54
13,99

11,18
11,10
11,36
11,22
11,16
11,27
11,19
11,13
11,15
12,05

N. B. L'intérêt de calculer le nombre de lignes nous aide, en fonction de la moyenne du poids d'une ligne, à déterminer le nombre maximal de lignes à garder pour chaque portion.

V-C - La découpe en portions pouvant être chargées individuellement par le script

V-C-1 - Commande OS

Par défaut, la commande split copie 1 000 lignes, ce qui n'est pas raisonnable dans notre situation (cela représenterait aux environs de 6 100 fichiers/portion, de trop petite taille compte tenu du nombre d'octets par ligne). Nous préférerons donc 10 000 lignes par portion. Ce chiffre est arbitraire et doit être adapté dans la réalité, à ce que votre serveur accepte en RAM par script PHP lancé.

1. <?php

3. function fichierDecouperLignes_OS(

4. $fPath,

5. $fPrefix="tmp_"

6. ) {

7. exec(

8. "split -d -l 10000 {$fPath} {$fPrefix}"

9. );

10. }

V-C-2 - Script PHP

Le code, très simple, se comprend facilement. J'ai préféré passer par une variable provisoire plutôt que l'écriture ligne à ligne de l'ancien fichier au nouveau, pour une question de vitesse d'écriture (« l'écriture » en RAM est bien plus rapide que l'écriture sur un DD).

1. <?php

3. function fichierDecouperLignes(

4. $fPath,

5. $fPrefix="tmp_"

6. ) {

7. $f = fopen(

8. $fPath,

9. "r"

10. );

11. $i = 0;

12. $y = 0;

13. $portion = "";

14. while(!feof($f)) {

15. if ($i>=10000) {

16. $i = 0;

17. file_put_contents(

18. "{$fPrefix}{$y}.portion.txt",

19. $portion

20. );

21. $y++;

22. $portion = "";

23. }

24. $portion .= fgets(

25. $f

26. );

27. $i++;

28. }

29. file_put_contents(

30. "{$fPrefix}{$y}",

31. $portion

32. );

33. }

V-C-3 - Le comparatif

Ce test a été de 5 itérations de la fonction par passage sur l'index volumineux, soit la création de 5 fois 610 fichiers (représentant aux environs de 187 ko chacun).

La moyenne pour PHP se fixe à 20,12 secondes contre 16,67 secondes pour la commande OS native : là encore, l'écart entre les deux est flagrant et largement en faveur d'un déport vers le shell.

Code PHP

Commande OS native

26,68
23,31
16,78
20,72
21,71
22,42
20,09
17,27
15,90
16,34

17,38
16,49
16,48
16,08
17,73
17,87
15,53
16,35
16,58
16,24

V-D - Le tri de chaque morceau

V-D-1 - Commande OS

Ici je n'ai pas gardé une entrée path classique qui serait lue en PHP puis envoyée à la commande, car l'intérêt est de travailler au plus près des fichiers et non de faire des aller-retour inutiles.

Ainsi dans mon exemple, aucune donnée ne passe par PHP, qui se contente d'appeler la commande. Dès lors les performances de la commande OS sont très nettement supérieures au script PHP seul.

1. <?php

3. function listeTrier_OS(

4. $fPathEntree,

5. $fPathSortie

6. ) {

8. exec(

9. "head -n 10000 $fPathEntree | sort -k 1 -n  > $fPathSortie"

10. );

11. }

V-D-2 - Script PHP (1)

Implantons un algorithme de tri par tas par nos propres moyens : il s'agit du tri par tas. Nous verrons lors du comparatif que les résultats pour un fichier de 10 000 lignes avec 5 passages sont honorables…

1. error_reporting(E_ALL);

3. function listeTrier($listeOriginale) {

4. $nbre = count($listeOriginale);

5. if ($nbre==1) {

6. return $listeOriginale;

7. } else {

8. $taille = floor(

9. $nbre/2

10. );

11. $t1 = listeTrier(

12. array_slice(

13. $listeOriginale,

14. 0,

15. $taille

16. )

17. );

18. $t2 = listeTrier(

19. array_slice(

20. $listeOriginale,

21. $taille,

22. $nbre

23. )

24. );

25. unset($listeOriginale);

26. $t3 = [];

27. while(count($t1)>0 and count($t2)>0) {

28. if ($t1[0]>$t2[0]) {

29. $t3[] = array_shift($t2);

30. } else {

31. $t3[] = array_shift($t1);

32. }

33. }

34. if (count($t1)>0) {

35. $t3 = array_merge(

36. $t3,

37. $t1

38. );

39. }

40. if (count($t2)>0) {

41. $t3 = array_merge(

42. $t3,

43. $t2

44. );

45. }

46. return $t3;

47. }

48. }

V-D-3 - Script PHP (2)

… Pourtant notre fonction est sans commune mesure avec le tri par la fonction native de PHP sort, dont les résultats arrondis sont tous identiques, à 0,04 seconde pour 10 000 lignes ! Impossible de rivaliser même, et c'est étonnant, avec la commande de l'OS.

La raison est l'implémentation de la fonction de tri, qui n'est pas du PHP interprété (lent), mais du C compilé (très rapide en proportion).

V-D-4 - Le comparatif

Là encore, 5 itérations d'un fichier de 10 000 lignes, trié et enregistré sur lui-même.

Code PHP (1)

Code PHP (2)

Commande OS

3,78
3,63
3,55
3,50
3,72
3,53
3,58
3,47
3,73
3,53

0,04
0,04
0,04
0,04
0,04
0,04
0,04
0,04
0,04
0,04

0,08
0,08
0,08
0,08
0,09
0,15
0,08
0,08
0,08
0,08

V-E - (3') Le tri des morceaux entre eux

V-E-1 - La logique générale

Chaque portion est désormais triée. C'est-à-dire que notre index est constitué virtuellement d'une série de sous-parties ordonnées individuellement, mais pas entre elles. Exemple :

1. [ 1,2,3 ; 7,8,9 ; 4,5,6 ]…

2. ou encore [ 1,2,4 ; 6,9,8 ; 3,5,7 ].

Pour résoudre ce problème, nous devons appliquer l'équivalent d'un gros tri à bulle pour séparer des bouts d'une portion qui devraient se situer dans la prochaine portion. Le tri étant ici ascendant, les nombres trouvés en fin de portion n et qui sont plus grands que la position inverse en portion n+1 doivent être échangés. Si l'on repend notre exemple précédent, après comparaison et re-tri :

1. [ 1,2,3 ; 4,5,6 ; 7,8,9 ]

2. [ 1,2,4 ; 3,5,7 ; 6,8,9 ] → 4 reste ainsi dans la première portion, qui est traitée avec la deuxième, elle-même étant triée avec la troisième. Au passage suivant, il progressera d'une portion, en lieu et place de 3, tout comme 7, qui prendre la place de 6.

À chaque passage donc, nous aurons une partie de la portion qui sera possiblement réintégrée ailleurs, et le tri reprendra pour la portion entière.

Si toutes les portions comparées par paire ne donnent aucune modification, alors c'est que l'ensemble des portions sont triées individuellement et collectivement. Il ne reste plus qu'à « recoller » les morceaux.

C'est l'opération la plus longue et qui mériterait, pour être vraiment efficace, d'être parallélisée ainsi en deux vagues renouvelées entre elles :

1. (processus A) comparaison des portions n avec n+1, (processus B) comparaison portion n+2 avec n+3, etc. → puis tri individuel des portions ;

2. (processus A) portion n+1 avec n+2, (processus B) portion n+3 avec n+4, etc. → puis trie individuel des portions.
Compte tenu du contexte, une telle parallélisation n'est cependant pas possible. Dans le temps imparti et sans aide par une commande OS, il faudra probablement relancer à plusieurs reprises le script pour terminer complètement le tri sur l'ensemble des portions. Dans l'exemple, nous nous limiterons à la comparaison de deux portions entre elles.
En termes opérationnels, cela nous oblige à avoir le chargement d'une des deux portions (celle parcourue en sens inverse : la portion qui contiendra les nombres les moins élevés), et lire ligne à ligne l'autre portion (celle parcourue dans le sens « normal » de lecture).
Pour éviter la surcharge en RAM et pouvoir interrompre à tout moment notre opération sans perdre ce qui a déjà été fait, nous stockerons les données provisoires des bouts de portion à échanger dans deux autres fichiers. Soit :

- le fichier P1 — la portion 1,

- le fichier P2 — la portion 2,

- le fichier P1' — ce qui sera extrait de la portion 1 vers la portion 2,

- le fichier P2' — ce qui sera extrait de la portion 1 vers la portion 2.

1. <?php

2. ini_set('display_errors', 1);

3. ini_set('display_startup_errors', 1);

4. error_reporting(E_ALL);

6. $dossierOrigine = "/home/julien/public-www/flat3-PHP";

7. $dossierTravail = $dossierOrigine."/test";

9. $fPathEntree1 = $dossierTravail."/tmp_00.tmp";

10. $fPathEntree2 = $dossierTravail."/tmp_01.tmp";

11. $fPathTmp1 = $dossierTravail."/tmp_00_tmp.tmp";

12. $fPathTmp2 = $dossierTravail."/tmp_01_tmp.tmp";

14. file_put_contents(

15. $fPathTmp1,

16. ""

17. );

19. file_put_contents(

20. $fPathTmp2,

21. ""

22. );

24. $c = file_get_contents(

25. $fPathEntree1

26. );

27. $lignes = [];

28. preg_match_all(

29. "#^([0-9]+)\t([0-9a-z]+)\n#im",

30. $c, // substr($c, 0, 250)

31. $lignes

32. );

34. $lignes[0] = array_reverse($lignes[0]);

35. $lignes[1] = array_reverse($lignes[1]);

36. $lignes[2] = array_reverse($lignes[2]);

38. $f = fopen(

39. $fPathEntree2,

40. "r"

41. );

43. $i = 0;

44. while(!feof($f)) {

45. $ligne = fgets(

46. $f

47. );

48. list($numeroP2, $idP2) = sscanf(

49. $ligne,

50. "%d\t%s"

51. );

52. $numeroP1 = intval($lignes[1][$i]);

53. file_put_contents(

54. ($numeroP1>$numeroP2) ? $fPathTmp1 : $fPathTmp2,

55. $ligne,

56. FILE_APPEND

57. );

58. file_put_contents(

59. ($numeroP1>$numeroP2) ? $fPathTmp2 : $fPathTmp1,

60. $lignes[0][$i],

61. FILE_APPEND

62. );

63. $i++;

64. }

66. fclose(

67. $f

68. );

70. rename(

71. $fPathTmp1,

72. $fPathEntree1

73. );

75. rename(

76. $fPathTmp2,

77. $fPathEntree2

78. );

V-F - La réunion de tous les morceaux

V-F-1 - Commande OS

Comme pour le point au-dessus, les performances sont intéressantes, car PHP ne gère que l'envoi de la commande et rien ne transite par lui en termes de données.

1. <?php

3. function fichierRassemblerLignes_OS(

4. $fichiersTypePath,

5. $fichierIndexPathTmp

6. ) {

7. exec(

8. "cat {$fichiersTypePath} > {$fichierIndexPathTmp}"

9. );

10. }

V-F-2 - Script PHP

Ma fonction, bien qu'elle puisse être naïve, répond finalement à tous les critères que l'on attend d'elle : stable, simple, lisible, et ne prend pas de place en mémoire (les fichiers sont gérés individuellement).

1. <?php

3. function fichierRassemblerLignes(

4. $fichiersTypePath,

5. $fichierIndexPathTmp

6. ) {

7. file_put_contents(

8. $fichierIndexPathTmp,

9. ""

10. );

11. foreach(

12. glob(

13. $fichiersTypePath

14. ) as $fPath

15. ) {

16. file_put_contents(

17. $fichierIndexPathTmp,

18. file_get_contents(

19. $fPath

20. ),

21. FILE_APPEND

22. );

23. }

24. }

V-F-3 - Le comparatif

Les résultats ci-dessous sont sur 500 itérations de la concaténation de 1 000 fichiers de 174 octets chacun (contenu généré aléatoirement et stocké individuellement). Le coefficient est ici énorme — de l'ordre de 2 — et la palme revient, cette fois encore, à une commande gérée par OS.

Code PHP

Commande OS

20,67
20,78
20,78
20,70
20,60
20,72
20,76
20,81
20,48
20,81

10,75
11,56
11,59
11,09
10,85
11,42
11,39
11,44
11,09
12,85

V-G - Une recette « unique » en PHP

Vous vous dites : que se passe-t-il si l'on applique toutes ces portions, en une seule fois et pour une seule itération ?

Tout d'abord, intéressons-nous aux différences en PHP et les commandes natives. Pour ces dernières, j'ai exprimé ici les performances souvent bien meilleures vis-à-vis de PHP et la très grande « simplicité » (en dehors de la gestion des erreurs et des PIPEs, non abordées ici) de mise en œuvre.

Pour autant, nul besoin de passer par autant d'étapes : même pour mon fichier d'exemple à 113,5 Mo, la seule commande short avec l'index d'origine non ordonné, mettra à peine 10 secondes à se réaliser sur mon poste qui est loin, très loin, d'être un foudre de guerre.

Si par contre, vous rencontrer des times out, la solution « tout PHP », plus lente, mais plus certaine dans ce cas, devrait être adoptée.

Ainsi, la conclusion est qu'il faut toujours adapter au contexte : commandes OS si disponibles et efficaces, sinon se rabattre sur les opportunités du langage disponible.

Ensuite, il y a une façon plus simple d'aborder le problème, que j'ai volontairement occulté jusqu'ici, afin de présenter des étapes plus exhaustives, indépendantes et surtout nativement résilientes : travailler sur le fichier lui-même, par portion, sans le découper en d'autres fichiers pour autant.

L'avantage est de réduire le nombre d'étapes et de condenser le code. L'inconvénient, c'est de travailler sur le fichier lui-même : un arrêt brutal (un time out notamment) peut provoquer une corruption du fichier, ce qui n'est pas forcément tolérable. Car il faudra de nombreux passages pour finaliser complètement le tri.

L'entre-deux que je présente ci-dessous, est d'écrire le résultat dans un fichier temporaire. La taille sur le disque double : cependant, une fois que l'index finit d'être trié, il pourra écraser la version précédente sans risque.

Pour éviter de trier toujours les mêmes portions, il suffit de relancer le script avec une ligne de départ différente : par exemple en étant au milieu de la première liste. Évidemment, il convient ensuite de ne garder que fseek pour le parcours normal.

Il convient donc de modifier le fichier d'entrée et de sortie autant que nécessaire pour « boucler » sur le fichier à trier qui produira un fichier en sortie, qui devra être la nouvelle source, jusqu'au tri complet.

1. <?php

2. ini_set('display_errors', 1);

3. ini_set('display_startup_errors', 1);

4. error_reporting(E_ALL);

6. // pour ma configuration, max_execution_time est à 30 (secondes)

7. // 90% de 30 secondes, cela 27 secondes

8. $dureePossible = ceil(

9. ini_get('max_execution_time')*0.90

10. );

11. $depart = microtime(true);

12. $delaisAtteint = false;

14. $dossierOrigine = "/home/julien/public-www/flat3-PHP";

15. $dossierTravail = $dossierOrigine."/tests";

17. $fPathEntree = $dossierTravail."/test-volumineux.index";

18. $fPathTmp = $dossierTravail."/test-volumineux.tmp";

20. $f = fopen(

21. $fPathEntree,

22. "r"

23. );

24. $fTmp = fopen(

25. $fPathTmp,

26. "a+"

27. );

29. function trier(

30. $numeros,

31. $ids,

32. $tableau,

33. $f

34. ) {

35. array_multisort(

36. $numeros,

37. SORT_NUMERIC,

38. $ids,

39. SORT_STRING,

40. $tableau

41. );

42. fwrite(

43. $f,

44. implode(

45. "\n",

46. array_map(

47. function($element) {

48. return implode(

49. "\t",

50. $element

51. );

52. },

53. $tableau

54. )

55. )."\n"

56. );

57. }

59. $numeros = [];

60. $ids = [];

61. $tableau = [];

62. $i = 0;

64. fseek(

65. $f,

66. (isset($_GET["fseek"]))?intval($_GET["fseek"]):0,

67. SEEK_SET

68. );

70. $ligneDepart = (isset($_GET["ligneDepart"]))?intval($_GET["ligneDepart"]):0;

71. echo "ligne départ = ".$ligneDepart."<br />";

72. $y = 0;

73. while(!feof($f)) {

74. $ligne = fgets(

75. $f

76. );

77. if ($y>=$ligneDepart) {

78. // si l'on est ou dépasse la durée possible, on enregistre et on stoppe

79. if ((microtime(true)-$depart)>=$dureePossible)

80. $delaisAtteint = true;

81. list($numero, $id) = sscanf(

82. $ligne,

83. "%d\t%s"

84. );

85. $numeros[] = $numero;

86. $ids[] = $id;

87. $tableau[] = array(

88. $numero,

89. $id

90. );

91. // 10.007 est le nombre premier le plus proche au-dessus de 10.000

92. if (

93. $delaisAtteint==true

94. or

95. ($i>0 and $i%10007==0)

96. ) {

97. trier(

98. $numeros,

99. $ids,

100. $tableau,

101. $fTmp

102. );

103. $i = 0;

104. $tableau = [];

105. $numeros = [];

106. $ids = [];

107. if ($delaisAtteint==true)

108. echo "délai atteint, on a arrêté le script par sécurité ; reprendre fseek = ".ftell($f);

109. if ($delaisAtteint==true)

110. break;

111. } else {

112. $i++;

113. }

114. }

115. $y++;

116. }

118. if ($delaisAtteint!=true)

119. echo "fin de fichier atteint";

121. fclose(

122. $f

123. );

125. fclose(

126. $fTmp

127. );

129. // on remplace l'ancien index par le fichier généré

130. rename(

131. $fPathTmp,

132. $fPathEntree

133. );

V-H - Révélation : et PHP ne servait à rien ?

V-H-1 - … Géré par PHP, mais exécuté par autre chose

Soyons sérieux : si vous avez la possibilité d'exécuter exec en PHP ou équivalent, nul besoin de tout ce code lent. PHP est interprété : il est peu adapté à l'usage que j'exprime dans ma série d'articles, surtout en CGI.

Une des pistes est le programme AWK, générique sous Unix/Linux (le parc de serveur étant majoritairement sous ces OS), et qui offre la possibilité de standardiser tout cela. Des fichiers de « programmation » (les opérations à faire) permettent de construire assez facilement des appels type « API » grâce à PHP, qui se contente alors d'être le relais.

Une autre piste sont les commandes « OS » à exécuter que j'indiquais à chaque étape. Elles permettent de travailler tout ou en partie avec PHP — ou seulement via PHP.

Enfin, il y a aussi certains programmes à installer, qui gèrent très bien les fichiers CSV. L'index tel que conçu, avec des tabulations et des sauts de ligne, ne nous trompons pas, est une variante de CSV. Mon choix n'était pas anodin…

V-H-2 - … Un index construit ailleurs

Dans mon premier article, j'évoquai le résumé d'une situation vécue. Du PHP 5.6 avec Apache, pas de possibilité de modifier le php.ini et pas de base de données : comment faire ? L'idée de départ était de travailler exclusivement sur le serveur.

La réalité est qu'il est plus aisé de construire la base ailleurs — sur mon poste —, et d'en faire la copie sur le serveur au besoin. Ainsi, amateur de Python, j'ai utilisé la version 3 pour réaliser les index, les tris et toutes les opérations lourdes. Je ne laisse à PHP que le soin de modifier les fichiers « sources » — les fichiers des entités —, et j'automatise par CRON une fois par heure, les recalculs nécessaires. Les quelques centaines de Mo ne sont donc plus un problème ni en temps ni en RAM disponible.

Certes la vitesse de copie est « lente » (celle du FTP…), mais s'il y a très peu de modifications comme dans mon cas, cette méthode est suffisante. Elle offre en outre la possibilité de garder une version de sauvegarde, pour réinstaller rapidement en cas de crash. Coup double !

Nous pouvons voir la richesse des solutions derrière l'aridité apparente et combien nous nous rapprochons de solutions proches des standards industriels. Un SGBD ne doit plus être nécessairement et totalement considéré comme monolithique et monoposte, comme SQL nous en a laissé l'illusion durant des années. Le NoSQL a cet avantage de prendre des formes étranges certes, mais donnant des possibilités qui n'auraient pas existé autrement. Avec une illusion là aussi : que tout soit possible, et possible efficacement !

V-H-3 - L'implémentation « réelle » : jouer de l'abstraction des classes

Dans l'article précédent ou celui-ci, j'évoque des points généraux à de nombreuses reprises, donnant bien davantage des indications sur la procédure, des exemples, mais guère de code utilisable en production. En soi c'est plutôt sain : en fonction de la situation et comme le contexte d'exemple est « dégradé », l'application va nécessairement différer pour optimiser l'ensemble.

Par exemple avec la fonction proc_open, proche de exec évoquée plus haut, souvent désactivée sur les hébergements mutualisés et qui permet de faire appel à une commande pouvant être extérieure à PHP (Python, bash, etc.), en gérant les entrées et sorties : sous LAMP si cette fonction est disponible, l'utilisation conjointe de proc_open et de yield permet de « simuler » un fonctionnement asynchrone alors que le processus principal de PHP reste un thread dépendant d'Apache.

Si nous avons par exemple deux index à lire et à traiter : chacun est lu ligne à ligne (une fois l'un, une fois l'autre), chose rendue possible par yield, et les données sont traitées grâce à proc_open sur un ou plusieurs autres processus. Le retour des processus enfants viendra alimenter les résultats du script PHP principal.

Il y a donc l'équivalent d'un map/reduce dans un contexte qui, sinon, ne le permet pas. Le gain peut être considérable ; pour ceux que cela intéresse, j'ai ouvert les entrailles d'un fonctionnement asynchrone dans un article antérieur.

Si par contre les fonctions permettant ce déport vers un autre processus n'existent pas, il n'y a pas le choix : le synchrone (séquentiel) reste la seule possibilité. Les gains de performance à tous niveaux sont alors négligeables et la seule condition devient alors de faire aboutir le script : s'il reste actif trop longtemps, il y a un risque qu'il soit killé ou que le client coupe la connexion (j'y reviendrai).

Ainsi l'abstraction de classe permet d'avoir la gestion des deux situations d'une manière transparente : si une solution est possible, elle est utilisée. Sinon on se rabat sur l'autre. Pour « l'utilisateur » côté serveur, les noms et appels de fonction devraient rester en grande majorité les mêmes.

Si l'on reprend les exemples fournis dans mon premier article, tant pour un index principal que les index secondaires, nous pouvons tracer une abstraction générique de classe :

1. <?php

3. abstract class IndexCreation {

5. // - savoir si l'on travaille sur un index principal ou non

6. private $indexPrincipal = false;

8. // - savoir si la classe abstraite supportera la simulation de l'asynchronisme

9. protected $async = false;

11. // - le dossier source des entités

12. protected $dossierSource = null;

14. // - le pattern du nom de fichier des entités ou des entités elles-mêmes

15. protected $patternEntite = null;

17. // - les références des index à conserver

18. protected $indexDestinations = array();

20. // - les attributs à indexer

21. protected $attributs = array();

23. // - débuter la création

24. abstract protected function debuter();

26. // - parcourir le dossier source (possibilité par exemple d'avoir un générateur)

27. abstract protected function _dossier_parcourir();

29. // - créer ou ouvrir un index (un par attribut stocké)

30. abstract protected function _index_ouvrir($attribut, $path);

32. // - ouvrir un fichier d'entité, puis le comprendre

33. abstract protected function _entite_ouvrir($pathFichierEntite);

34. abstract protected function _entite_comprendre($contenuFichierEntite);

36. // - extraire un attribut en particulier, puis l'enregistrer dans le bon index

37. abstract protected function _attribut_extraire($fichier, $entite, $nomAttribut );

38. abstract protected function _attribut_enregistrer($valeur, $idIndex);

40. // - gérer les erreurs s'il y a lieu

41. abstract protected function prevenir($erreur);

43. // - terminer la création et fermeture propre par exemple, des fichiers ouverts

44. abstract protected function finir();

46. // la fonction essentielle : celle qui va gérer tout ce qui précède

47. public function __construct() {

48. try{

49. $this->debuter();

50. foreach($this->attributs as $attribut) {

51. $this->_index_ouvrir(

52. $attribut

53. );

54. }

55. foreach($this->_dossier_parcourir() as $fichier) {

56. $entite = $this->_entite_comprendre(

57. $this->_entite_ouvrir(

58. $fichier

59. )

60. );

61. foreach($this->attributs() as $attribut) {

62. $this->_attribut_enregistrer(

63. $this->_attribut_extraire(

64. $entite,

65. $attribut

66. ),

67. $attribut

68. );

69. }

70. }

71. } catch(Exception $e){

72. $this->prevenir(

73. $e

74. );

75. } finally {

76. @$this->finir(); // on évite la génération d'une erreur à ce niveau

77. }

79. }

81. }

Un tel code permet, pour ce point de la génération des index, d'avoir des implémentations parfois très différentes pour un même SGBD et un même langage, au plus proche des possibles.

Ainsi la réalisation d'une API qui ferait évoluer ou étendre les fonctionnalités sera cadrée par l'abstraction.

Même pour la création d'un index principal, qui ne fait pas de référence aux attributs des entités, peut étendre cette abstraction au prix de quelques astuces mineures :

1. <?php

3. class IndexPCreation extends IndexCreation {

5. var $indexPrincipal = false;

7. var $dossierSource = "./tests";

8. private $dossier = null; // la ressource vers le dossier

10. var $patternEntite = "#([0-9a-z\-\_]+)\.([0-9a-z\-\_]+)\.([a-z]+)$#i";

12. var $indexDestinations = array(

13. "$" => null

14. );

16. var $attributs = array(

17. "$" => "./test-principal.index.txt"

18. );

20. function debuter() {}

22. function _dossier_parcourir() {

23. $this->dossier = opendir(

24. $this->dossierSource

25. );

26. while (false !== ($entree = readdir($this->dossier))) {

27. $entree = $this->dossierSource.'/'.$entree;

28. if (is_file($entree))

29. yield $entree;

30. }

31. closedir(

32. $this->dossier

33. );

34. }

36. function _index_ouvrir($attribut, $path) {

37. if ($attribut=="$")

38. $this->indexDestinations[$attribut] = fopen(

39. $path,

40. "w"

41. );

42. }

44. function _entite_ouvrir($pathFichierEntite) {

45. return file_get_contents(

46. $pathFichierEntite

47. );

48. }

50. function _entite_comprendre($contenuFichierEntite) {

51. return json_decode(

52. $contenuFichierEntite,

53. true

54. );

55. }

57. function _attribut_extraire($fichier, $entite, $nomAttribut) {

58. if ($nomAttribut!="$")

59. return;

60. if (preg_match(

61. $this->patternEntite,

62. $fichier,

63. $r

64. )==1) {

65. list($ePath, $eId, $eType, $eExtension) = $r;

66. $aff = $entite["affichage"];

67. return "$eType\t$eId\t$fichier\t$aff\n";

68. }

69. }

71. function _attribut_enregistrer($valeur, $attribut) {

72. $fIndex = $this->indexDestinations[$attribut];

73. fwrite(

74. $fIndex,

75. $valeur

76. );

77. fflush(

78. $fIndex

79. );

80. }

82. function prevenir($erreur) {

83. var_dump(

84. $erreur

85. );

86. }

88. function finir() {

89. closedir(

90. $this->dossier

91. );

92. }

93. public function __construct() {

94. try{

95. $this->debuter();

96. foreach($this->attributs as $attribut => $path) {

97. $this->_index_ouvrir(

98. $attribut,

99. $path

100. );

101. }

102. foreach($this->_dossier_parcourir() as $fichier) {

103. $entite = $this->_entite_comprendre(

104. $this->_entite_ouvrir(

105. $fichier

106. )

107. );

108. foreach($this->attributs as $attribut => $path) {

109. $this->_attribut_enregistrer(

110. $this->_attribut_extraire(

111. $fichier,

112. $entite,

113. $attribut

114. ),

115. $attribut

116. );

117. }

118. }

119. } catch(Exception $e){

120. $this->prevenir(

121. $e

122. );

123. } finally {

124. @$this->finir(); // on évite la génération d'une erreur à ce niveau

125. }

127. }

129. }

Les classes abstraites, étendues par l'implémentation, offrent alors un bon sommaire de l'usage à en faire sans préoccupation de la réalité technique de l'implémentation.

V-H-4 - Côté client, ça donne quoi ?

Jusqu'à présent, les articles évoquaient des opérations côté serveur. Or le web c'est un échange et le client a ici un rôle indispensable.

À un moment dans cet article, j'ai évoqué par exemple la nécessité de relancer un script PHP, coupé préventivement avant un time out. C'est au client de faire le nécessaire en donnant les bonnes instructions (ligne de départ ou fseek).

L'idéal est l'utilisation des WebSockets, mais cela impose que PHP ne fonctionne pas en mode CGI, mais CLI (il gère lui-même la réception des sockets). Je ne m'y attarde donc pas.

Vous pouvez utiliser AJAX évidemment : l'attente du retour du résultat s'apparente à la technique du long polling. Cependant si votre hébergement vous en offre la possibilité — totalement incompatible avec la compression des données envoyées au client —, tournez-vous du côté de SSE – Server Send Event.

Disponible pour tous les navigateurs récents, il offre la possibilité d'avoir du push server monodirectionnel : vous envoyez une requête (une seule !), par le biais d'une requête HTTP traditionnelle comme vous l'auriez fait avec AJAX. La différence est au moment de la réponse : dès réception d'une portion (sous le format de lignes…) est reçue du serveur par le client-navigateur, ce dernier l'envoie à JavaScript pour traitement.

Vous avez donc une gestion événementielle côté client, et la possibilité de ne pas garder en un seul bloc une grande quantité d'informations côté serveur, qui se délestent très régulièrement (voir ligne par ligne) avec flush, des données pouvant déjà être envoyées.

Il reste bien sûr des contraintes : cela n'est pas compatible avec la compression des données envoyées, le time out de PHP reste présent, le format doit être respecté. Mais globalement, c'est une solution très intéressante pour la gestion de la RAM et du cache, et qui se base sur la seule modification d'un entête HTTP : Content-Type: text/event-stream !

Enfin la possibilité de créer des ID et des dates lors de l'envoi de chaque event du serveur permet une relative transparence entre les données stockées côté client vis-à-vis du serveur. On préférera envoyer des ID avec la date de la dernière mise à jour de l'entité plutôt que l'entité tout entière — et laisser le soin au client de récupérer si nécessaire la version plus récente de l'entité.

V-H-5 - C'est déjà la fin ?!

Non, juste la fin de l'article. Il nous reste beaucoup à voir : les transactions, leur gestion et leur sauvegarde entre deux requêtes HTTP, les jointures entre index, tenter de mettre un peu d'ACID dans tout cela…

Je vous dis donc à très bientôt !

VI - Note de la rédaction de Developpez.com

Nous tenons à remercier f-leb pour la relecture orthographique de ce tutoriel.