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. |
– Une requête qui échoue ne fait pas échouer les autres. |
Empreinte mémoire |
– En attente : minimale, en attente de réception d'une demande. |
– En attente : aucune en l'absence de client. |
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. |
– Montée en charge totalement liée aux besoins du client et de leur nombre. |
Principaux inconvénients |
– Avant MySQL8, la difficulté à gérer correctement/facilement des données non structurées ou la scalabilité verticale. |
– Une base orientée « lecture », du fait de la lourdeur relative des calculs des index. |
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 |
12,12 |
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 »
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.
<?php
$debut
=
microtime(true
);
$tailleMax
=
memory_get_peak_usage(
true
);
$tailleFichier
=
intval($tailleMax
*
0.45
);
$nbre
=
0
;
$max
=
10000
;
try
{
while
($nbre
<
$max
) {
$stream
=
fopen(
"./tests/index.entites"
,
"r"
);
$portion
=
""
;
$trouve
=
0
;
while
(true
) {
$portion
.=
fread(
$stream
,
$tailleFichier
);
if
(strlen($portion
)==
0
)
break
;
preg_match_all(
"#(?<type>[^
\t
]+)
\t
(?<id>[^
\t
]+)
\t
(?<path>[^
\t
]+)
\t
(?<aff>[^
\n
]+)
\n
#im"
,
$portion
,
$matches
,
PREG_SET_ORDER
);
$trouve
+=
count($matches
);
$n
=
count($matches
)-
1
;
if
($n
<
0
)
throw
new
Exception
(
"opération impossible, le délimiteur n'a pas été retrouvé"
);
// éviter une boucle infinie en cas de problème sur l'index
$portion
=
explode(
$matches
[
$n
][
0
],
$portion
)[
1
];
}
fclose($stream
);
$nbre
++;
}
}
catch
(Exception
$e
) {
var_dump($e
);
}
$fin
=
microtime(true
);
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 ».
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
<?php
function
fichierNbreLignes_OS(
$fPath
) {
if
(preg_match(
"#^([0-9]+)#"
,
exec(
"wc -l
{
$fPath
}
"
,
$retour
),
$r
))
return
$r
[
0
];
return
false
;
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
<?php
function
fichierNbreLignes(
$fPath
,
$taillePortion
=
1024
*
1024
,
$delimiteur
=
"
\n
"
) {
$f
=
fopen($fPath
,
"r"
);
$nbre
=
0
;
while
(!
feof($f
)) {
$nbre
+=
substr_count(
fread(
$f
,
$taillePortion
),
$delimiteur
);
}
fclose($f
);
return
$nbre
;
}
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 |
11,18 |
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é.
2.
3.
4.
5.
6.
7.
8.
9.
10.
<?php
function
fichierDecouperLignes_OS(
$fPath
,
$fPrefix
=
"tmp_"
) {
exec(
"split -d -l 10000
{
$fPath
}
{
$fPrefix
}
"
);
}
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).
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.
<?php
function
fichierDecouperLignes(
$fPath
,
$fPrefix
=
"tmp_"
) {
$f
=
fopen(
$fPath
,
"r"
);
$i
=
0
;
$y
=
0
;
$portion
=
""
;
while
(!
feof($f
)) {
if
($i
>=
10000
) {
$i
=
0
;
file_put_contents(
"
{
$fPrefix
}{
$y
}
.portion.txt"
,
$portion
);
$y
++;
$portion
=
""
;
}
$portion
.=
fgets(
$f
);
$i
++;
}
file_put_contents(
"
{
$fPrefix
}{
$y
}
"
,
$portion
);
}
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 |
17,38 |
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
<?php
function
listeTrier_OS(
$fPathEntree
,
$fPathSortie
) {
exec(
"head -n 10000
$fPathEntree
| sort -k 1 -n >
$fPathSortie
"
);
}
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…
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.
error_reporting(E_ALL);
function listeTrier($listeOriginale
) {
$nbre
=
count($listeOriginale
);
if ($nbre
==
1
) {
return $listeOriginale
;
}
else {
$taille
=
floor(
$nbre
/
2
);
$t1
=
listeTrier(
array_slice(
$listeOriginale
,
0
,
$taille
)
);
$t2
=
listeTrier(
array_slice(
$listeOriginale
,
$taille
,
$nbre
)
);
unset($listeOriginale
);
$t3
=
[];
while(count($t1
)>
0
and count($t2
)>
0
) {
if ($t1
[
0
]>
$t2
[
0
]
) {
$t3
[]
=
array_shift($t2
);
}
else {
$t3
[]
=
array_shift($t1
);
}
}
if (count($t1
)>
0
) {
$t3
=
array_merge(
$t3
,
$t1
);
}
if (count($t2
)>
0
) {
$t3
=
array_merge(
$t3
,
$t2
);
}
return $t3
;
}
}
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 |
0,04 |
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,2,3 ; 7,8,9 ; 4,5,6 ]…
- 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,2,3 ; 4,5,6 ; 7,8,9 ]
- [ 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 :
- (processus A) comparaison des portions n avec n+1, (processus B) comparaison portion n+2 avec n+3, etc. → puis tri individuel des portions ;
- (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.
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.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
<?php
ini_set('display_errors'
,
1
);
ini_set('display_startup_errors'
,
1
);
error_reporting(E_ALL);
$dossierOrigine
=
"/home/julien/public-www/flat3-PHP"
;
$dossierTravail
=
$dossierOrigine
.
"/test"
;
$fPathEntree1
=
$dossierTravail
.
"/tmp_00.tmp"
;
$fPathEntree2
=
$dossierTravail
.
"/tmp_01.tmp"
;
$fPathTmp1
=
$dossierTravail
.
"/tmp_00_tmp.tmp"
;
$fPathTmp2
=
$dossierTravail
.
"/tmp_01_tmp.tmp"
;
file_put_contents(
$fPathTmp1
,
""
);
file_put_contents(
$fPathTmp2
,
""
);
$c
=
file_get_contents(
$fPathEntree1
);
$lignes
=
[];
preg_match_all(
"#^([0-9]+)
\t
([0-9a-z]+)
\n
#im"
,
$c
,
// substr($c, 0, 250)
$lignes
);
$lignes
[
0
]
=
array_reverse($lignes
[
0
]
);
$lignes
[
1
]
=
array_reverse($lignes
[
1
]
);
$lignes
[
2
]
=
array_reverse($lignes
[
2
]
);
$f
=
fopen(
$fPathEntree2
,
"r"
);
$i
=
0
;
while
(!
feof($f
)) {
$ligne
=
fgets(
$f
);
list
($numeroP2
,
$idP2
) =
sscanf(
$ligne
,
"%d
\t
%s"
);
$numeroP1
=
intval($lignes
[
1
][
$i
]
);
file_put_contents(
($numeroP1
>
$numeroP2
) ?
$fPathTmp1
:
$fPathTmp2
,
$ligne
,
FILE_APPEND
);
file_put_contents(
($numeroP1
>
$numeroP2
) ?
$fPathTmp2
:
$fPathTmp1
,
$lignes
[
0
][
$i
],
FILE_APPEND
);
$i
++;
}
fclose(
$f
);
rename(
$fPathTmp1
,
$fPathEntree1
);
rename(
$fPathTmp2
,
$fPathEntree2
);
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
<?php
function
fichierRassemblerLignes_OS(
$fichiersTypePath
,
$fichierIndexPathTmp
) {
exec(
"cat
{
$fichiersTypePath
}
>
{
$fichierIndexPathTmp
}
"
);
}
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).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
<?php
function
fichierRassemblerLignes(
$fichiersTypePath
,
$fichierIndexPathTmp
) {
file_put_contents(
$fichierIndexPathTmp
,
""
);
foreach
(
glob(
$fichiersTypePath
) as
$fPath
) {
file_put_contents(
$fichierIndexPathTmp
,
file_get_contents(
$fPath
),
FILE_APPEND
);
}
}
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 |
10,75 |
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.
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.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
<?php
ini_set('display_errors'
,
1
);
ini_set('display_startup_errors'
,
1
);
error_reporting(E_ALL);
// pour ma configuration, max_execution_time est à 30 (secondes)
// 90% de 30 secondes, cela 27 secondes
$dureePossible
=
ceil(
ini_get('max_execution_time'
)*
0.90
);
$depart
=
microtime(true
);
$delaisAtteint
=
false
;
$dossierOrigine
=
"/home/julien/public-www/flat3-PHP"
;
$dossierTravail
=
$dossierOrigine
.
"/tests"
;
$fPathEntree
=
$dossierTravail
.
"/test-volumineux.index"
;
$fPathTmp
=
$dossierTravail
.
"/test-volumineux.tmp"
;
$f
=
fopen(
$fPathEntree
,
"r"
);
$fTmp
=
fopen(
$fPathTmp
,
"a+"
);
function
trier(
$numeros
,
$ids
,
$tableau
,
$f
) {
array_multisort(
$numeros
,
SORT_NUMERIC,
$ids
,
SORT_STRING,
$tableau
);
fwrite(
$f
,
implode(
"
\n
"
,
array_map(
function
($element
) {
return
implode(
"
\t
"
,
$element
);
},
$tableau
)
).
"
\n
"
);
}
$numeros
=
[];
$ids
=
[];
$tableau
=
[];
$i
=
0
;
fseek(
$f
,
(isset($_GET
[
"fseek"
]
))?
intval($_GET
[
"fseek"
]
):
0
,
SEEK_SET
);
$ligneDepart
=
(isset($_GET
[
"ligneDepart"
]
))?
intval($_GET
[
"ligneDepart"
]
):
0
;
echo "ligne départ = "
.
$ligneDepart
.
"<br />"
;
$y
=
0
;
while
(!
feof($f
)) {
$ligne
=
fgets(
$f
);
if
($y
>=
$ligneDepart
) {
// si l'on est ou dépasse la durée possible, on enregistre et on stoppe
if
((microtime(true
)-
$depart
)>=
$dureePossible
)
$delaisAtteint
=
true
;
list
($numero
,
$id
) =
sscanf(
$ligne
,
"%d
\t
%s"
);
$numeros
[]
=
$numero
;
$ids
[]
=
$id
;
$tableau
[]
=
array
(
$numero
,
$id
);
// 10.007 est le nombre premier le plus proche au-dessus de 10.000
if
(
$delaisAtteint
==
true
or
($i
>
0
and
$i
%
10007
==
0
)
) {
trier(
$numeros
,
$ids
,
$tableau
,
$fTmp
);
$i
=
0
;
$tableau
=
[];
$numeros
=
[];
$ids
=
[];
if
($delaisAtteint
==
true
)
echo "délai atteint, on a arrêté le script par sécurité ; reprendre fseek = "
.
ftell($f
);
if
($delaisAtteint
==
true
)
break
;
}
else
{
$i
++;
}
}
$y
++;
}
if
($delaisAtteint
!=
true
)
echo "fin de fichier atteint"
;
fclose(
$f
);
fclose(
$fTmp
);
// on remplace l'ancien index par le fichier généré
rename(
$fPathTmp
,
$fPathEntree
);
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 :
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.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
<?php
abstract
class
IndexCreation {
// - savoir si l'on travaille sur un index principal ou non
private
$indexPrincipal
=
false
;
// - savoir si la classe abstraite supportera la simulation de l'asynchronisme
protected
$async
=
false
;
// - le dossier source des entités
protected
$dossierSource
=
null
;
// - le pattern du nom de fichier des entités ou des entités elles-mêmes
protected
$patternEntite
=
null
;
// - les références des index à conserver
protected
$indexDestinations
=
array
();
// - les attributs à indexer
protected
$attributs
=
array
();
// - débuter la création
abstract
protected
function
debuter();
// - parcourir le dossier source (possibilité par exemple d'avoir un générateur)
abstract
protected
function
_dossier_parcourir();
// - créer ou ouvrir un index (un par attribut stocké)
abstract
protected
function
_index_ouvrir($attribut
,
$path
);
// - ouvrir un fichier d'entité, puis le comprendre
abstract
protected
function
_entite_ouvrir($pathFichierEntite
);
abstract
protected
function
_entite_comprendre($contenuFichierEntite
);
// - extraire un attribut en particulier, puis l'enregistrer dans le bon index
abstract
protected
function
_attribut_extraire($fichier
,
$entite
,
$nomAttribut
);
abstract
protected
function
_attribut_enregistrer($valeur
,
$idIndex
);
// - gérer les erreurs s'il y a lieu
abstract
protected
function
prevenir($erreur
);
// - terminer la création et fermeture propre par exemple, des fichiers ouverts
abstract
protected
function
finir();
// la fonction essentielle : celle qui va gérer tout ce qui précède
public
function
__construct
() {
try
{
$this
->
debuter();
foreach
($this
->
attributs as
$attribut
) {
$this
->
_index_ouvrir(
$attribut
);
}
foreach
($this
->
_dossier_parcourir() as
$fichier
) {
$entite
=
$this
->
_entite_comprendre(
$this
->
_entite_ouvrir(
$fichier
)
);
foreach
($this
->
attributs() as
$attribut
) {
$this
->
_attribut_enregistrer(
$this
->
_attribut_extraire(
$entite
,
$attribut
),
$attribut
);
}
}
}
catch
(Exception
$e
){
$this
->
prevenir(
$e
);
}
finally
{
@
$this
->
finir();
// on évite la génération d'une erreur à ce niveau
}
}
}
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 :
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.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
<?php
class
IndexPCreation extends
IndexCreation {
var
$indexPrincipal
=
false
;
var
$dossierSource
=
"./tests"
;
private
$dossier
=
null
;
// la ressource vers le dossier
var
$patternEntite
=
"#([0-9a-z
\-\_
]+)
\.
([0-9a-z
\-\_
]+)
\.
([a-z]+)$#i"
;
var
$indexDestinations
=
array
(
"$"
=>
null
);
var
$attributs
=
array
(
"$"
=>
"./test-principal.index.txt"
);
function
debuter() {}
function
_dossier_parcourir() {
$this
->
dossier =
opendir(
$this
->
dossierSource
);
while
(false
!==
($entree
=
readdir($this
->
dossier))) {
$entree
=
$this
->
dossierSource.
'/'
.
$entree
;
if
(is_file($entree
))
yield
$entree
;
}
closedir(
$this
->
dossier
);
}
function
_index_ouvrir($attribut
,
$path
) {
if
($attribut
==
"$"
)
$this
->
indexDestinations[
$attribut
]
=
fopen(
$path
,
"w"
);
}
function
_entite_ouvrir($pathFichierEntite
) {
return
file_get_contents(
$pathFichierEntite
);
}
function
_entite_comprendre($contenuFichierEntite
) {
return
json_decode(
$contenuFichierEntite
,
true
);
}
function
_attribut_extraire($fichier
,
$entite
,
$nomAttribut
) {
if
($nomAttribut
!=
"$"
)
return
;
if
(preg_match(
$this
->
patternEntite,
$fichier
,
$r
)==
1
) {
list
($ePath
,
$eId
,
$eType
,
$eExtension
) =
$r
;
$aff
=
$entite
[
"affichage"
];
return
"
$eType\t$eId\t$fichier\t$aff\n
"
;
}
}
function
_attribut_enregistrer($valeur
,
$attribut
) {
$fIndex
=
$this
->
indexDestinations[
$attribut
];
fwrite(
$fIndex
,
$valeur
);
fflush(
$fIndex
);
}
function
prevenir($erreur
) {
var_dump(
$erreur
);
}
function
finir() {
closedir(
$this
->
dossier
);
}
public
function
__construct
() {
try
{
$this
->
debuter();
foreach
($this
->
attributs as
$attribut
=>
$path
) {
$this
->
_index_ouvrir(
$attribut
,
$path
);
}
foreach
($this
->
_dossier_parcourir() as
$fichier
) {
$entite
=
$this
->
_entite_comprendre(
$this
->
_entite_ouvrir(
$fichier
)
);
foreach
($this
->
attributs as
$attribut
=>
$path
) {
$this
->
_attribut_enregistrer(
$this
->
_attribut_extraire(
$fichier
,
$entite
,
$attribut
),
$attribut
);
}
}
}
catch
(Exception
$e
){
$this
->
prevenir(
$e
);
}
finally
{
@
$this
->
finir();
// on évite la génération d'une erreur à ce niveau
}
}
}
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.