IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

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

Réalisation d’un mini-moteur NoSQL en PHP

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum. 5 commentaires Donner une note à l´article (5)

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Préambule

Dans cet article, le tout premier d’une série, le terme de « NoSQL » est à prendre dans le sens de « Not only SQL » et non de « No SQL ». Le contexte est volontairement « restreint » et un tel usage « courant » du PHP en dehors de l’exemple que j’aborde, n’est très probablement pas une bonne idée !

II. Le contexte si particulier

Le cas d’école : à l’occasion d’une demande d’un « client » dernièrement, j’ai eu à réaliser un petit catalogue web de produits. Problème loin d’être insoluble : le catalogue doit évoluer dans ses « attributs » de produits, dans le nom et dans les types de produits (donc les catégories en somme).

Le catalogue fait aujourd’hui 30 à 40 produits, pour une perspective à une centaine de produits. Banal dans le contexte.

Le plus simple (il tient à un site « vitrine ») aurait été de faire un joli XML ou JSON, avec toutes les références qui vont bien, dans un fichier unique. Directement dans un fichier HTML qui sera zippé lors de son transfert, une maquette de site monopage, avec quelques lignes en JS qui vous fournit un carrousel charmant des produits et la possibilité de trier tout ça.

De toute façon, et avec près de 500 caractères par produits en JSON — format plus « compact » que XML —, cela représente 50 000 caractères soit environ 400 000 bits (en UTF-8, si l'on considère 1 octet comme 8 bit). Une misère ! Wikipedia nous indiquant un débit à 384 kbit par seconde en 3G, cela représente un tiers de seconde pour le téléchargement. Ainsi que quelques poussières d'une seconde pour le calcul de la mise en forme par le navigateur.

C’est peu de temps me direz-vous ? Certes. Mais habitant une campagne fort belle et sympathique, mais dépourvue d’une installation de réseau véloce (la 3G est déjà en soi un mythe, alors la 4G je n’y compte pas), votre seconde à vous, citadin, n’est pas ma seconde à moi de campagnard… Et puis pour un téléphone intelligent mais à capacité limitée, on peut facilement arriver à deux ou trois secondes. Comme ça, l’air de rien. Le client lui, ça, il le constate tous les jours et il râle : il faut mettre en cache le catalogue en local, en le récupérant au fur et à mesure.

Et puis sinon, face à tant de simplicité, cet article n’aurait pas existé, et cela aurait été dommage. Dans la « vraie vie », multiplions le nombre de produits du catalogue que j’évoque par 100. La donne du téléchargement d’un bloc d’office change. Par 1000, ça devient vraiment difficile d’envisager de continuer ainsi. Par quelques centaines de milliers, c’est niet. Embêtant hein ?

Les esprits chagrins nous dirons qu’une base SQL ferait très bien l’affaire sur un ou plusieurs serveurs dédiés : il suffit de charger les produits à la demande. Couplé à une utilisation intelligente des requêtes (aller chercher un catalogue de produits « bout par bout »), notamment celles multiples offertes par HTTP/2 – c’est un idéal.

Hé bien non, dans mon cas, pas de HTTP/2 supporté. Et pas de MySQL non plus ; pas davantage SQLite. Juste PHP 5 et rien d’autre. En rase campagne. Vous faites quoi ? Terrible cahier des charges. Mais il existe une solution… Je vois que vos yeux pleurent déjà, mais je vous promets que ce n’est pas si dur…

III. Introduction

La « mode » est au NoSQL, et il faut toujours épater un client en lui disant qu’il a besoin de quelque chose d’exceptionnel et que son besoin est dans la tendance du moment. D’abord c’est bon pour notre ego ses petits yeux qui brillent, et il pense qu’il en a davantage pour son argent — la poésie du chéquier.

Ainsi parfois c’est la motivation de vendre du « plus » au client qui l’emporte sur la considération technique. D’autres fois, c’est l’argument technique qui s’impose au contraire : pour un hébergement avec juste PHP d’installé (et en hébergement mutualisé, très probablement des fonctions essentielles désactivées), pas toujours à jour et sans grande quantité de RAM, ça relève vite de la mission impossible que de gérer des produits sous la forme d’une base de données. Oui, mais le client est roi (et pauvre), et il faut quand même le satisfaire.

Trop souvent là où le bon développeur va au plus simple, nous dirions même au plus efficace du simplisme, le client, lui, tend vers la production d’une belle usine à gaz à pas chère. Il ne voudra pas nécessairement mettre des moyens là où vous, vous le souhaitez. Et si c’est trop lourd, c’est que ce n’est pas assez travaillé : il faut aller plus loin dans le compliqué. N’avez-vous pas remarqué que la liste de choses à faire pour un SI se rapproche doucement mais sûrement d’une liste des options d’une voiture neuve ?

Que désormais il faut, même pour installer un banal hébergement, une quantité astronomique de modules et sous-modules, d’options diverses ?

Clients et prestataires suivent un rythme de complexification, de multiplications des outils au détriment parfois de la rationalité, sans plus vraiment savoir pourquoi ou comment proposer une alternative avec moins. La frugalité n’est certes pas la tendance : telles des modes, il faut des listes de fonctionnalités toujours plus longues… et pas toujours pertinentes ou adaptées à la réalité financière.

IV. LA solution ?

Du coup, je me suis dit, paraphrasant un ancien publicitaire très connu : « Allez, t’as 30 ans et tu n’as toujours pas monté ton propre système de données : tu rates ta vie ». Avant tout et pour me prouver à moi-même que je suis capable du pire, me voilà donc à produire from scratch, une base de données capable de supporter des charges folles et JUSTE avec PHP. C’est cette réflexion du comment que je partage par cet article non pas seulement pour un besoin d’ego : une part des questionnements, de leur résolution, peut aider sur d’autres projets.

Ce client-là, il m’impose un challenge nouveau. Tout d’abord, il m’a fallu recopier son catalogue (papier). En commençant avec plein de petits fichiers JSON qui, une fois mis dans un index par un identifiant unique (ID) correspondant au nom de chaque fichier JSON, me crée une version numérique et éclatée de son propre catalogue.

Aparté : le NoSQL, ça commence parfois un peu par la situation que je décris. Un truc pas toujours très clair, qui ne peut pas être encore toujours parfaitement optimisé. Manque de temps ou d’envie, de consignes claires, de savoir comment aborder tout simplement la complexité. On ne veut plus de SQL ou on ne peut pas/plus l’utiliser, à cause de certaines de ses lourdeurs, d’un format trop fermé dans la constitution des tables peut-être. Parce que les serveurs ont évolué et certaines limites du SQL s’imposent (problème de la scalabilité verticale). Parce qu’un logiciel de base n’est pas là au bon moment, ou dans la version que l’on voudrait. Parce qu’enfin, la production ne peut pas être arrêtée et que le système doit être revu de fond en comble, avec des compatibilités nulles avec l’ancien système. Nul besoin d’être seul et novice : même une équipe expérimentée peut rencontrer ces problématiques.

Bref, les raisons sont multiples de se dire « allez, j’y vais, je passe à autre chose ». Le NoSQL n’est pas une perfection, un aboutissement supplémentaire de SQL. Il offre une liberté d’appréhender par touches un problème complexe. NoSQL c’est presque un jumeau maléfique de SQL : s’il est plus facile pour débuter — la question de la mise en ordre des données avant lors de leur sauvegarde ne se pose quasi pas —, il révèle des dangers si le développeur n’est pas derrière parfaitement strict sur ses données. Pour l’alternative que je présente volontairement ici dans la « pire » des situations, le NoSQL devient une évidence. Nul besoin d’avoir les possibilités techniques quasi illimitées d’un Google pour apprécier certains de ses aspects : NoSQL se goûte aussi sur des systèmes moins fiables, moins performants.

Revenons à notre technique : un tel index d’IDs n’est pas ordonné et sert de base pour connaître rapidement les produits existants : plutôt que parcourir un dossier qui contient ces entités sous forme de fichiers indépendants — la fonction glob est utile, mais sans yield avec une grande série de données, elle peut se révéler coûteuse en ressources sur un grand nombre d’utilisateurs et de fichiers à parcourir —, on parcourt un fichier qui renvoie vers un path de la fiche produit (le fichier JSON dans mon cas donc). Un catalogue « généraliste », qui serait complété par ceux par type évidemment : cela permet de faire un catalogue des catalogues, si le besoin s’en fait sentir.

En somme, le fichier JSON va être une entité, l’élément de base de ma logique de gestion de données (une entité organisée sous le format d’un objet avec des attributs, des paires clé/valeur), et nous ne chercherons pas en lui mais par lui, c.-à-d. une série de catalogues qui sont en réalité des index.

Il y a plusieurs types d’index. Le premier est l’index principal et il ne fait que donner les IDs et la correspondance avec le path de l’entité. La recherche d’un ID (ou plusieurs IDs) est donc simple : on parcourt le fichier et on garde dans les résultats ceux qui correspondent. Si un ID n’est pas trouvé, il est réputé ne pas exister de ressource associée : nous verrons plus loin que ce cas se révèle intéressant dans certaines situations.

Le parcours peut être en comparaison stricte de texte (l’égalité absolue de deux chaînes), une comparaison de structure (grâce à la fonction explode, puis la comparaison de texte éventuelle), ou une comparaison par expression régulière. Peu nous importe : l’implémentation de la fonction de tri n’est pas plus difficile dans chacun de ces cas. Elles peuvent même exister, celles-ci et d’autres, en même temps pour un même système.

La constitution de l’ID peut être une incrémentation générale ou particulière, ou bien la formule de la force du vent au moment de l’écriture sur le disque : peu importe.

Là où ça bloque, c’est que si un tel index est efficace (et facile à travailler) pour retrouver un identifiant particulier dans ceux qui sont référencés dans l’index… La recherche d’un attribut d’entité, comme le prix ou une spécification, se révèle plus longue et plus compliquée : il faut alors ouvrir chaque entité, éventuellement la « comprendre » dans le cas du XML ou du JSON, pour fouiller dans ses attributs et effectuer la comparaison. Les temps de lecture sur le disque ne sont pas admissibles.

Il faut donc doubler cet index d’autres, qui prendront à leur tour une valeur particulière à chaque entité référencée, liée à un attribut de l’entité, et faisant référence à son identifiant avec, éventuellement, une référence vers le fichier direct, pour éviter que la recherche soit doublée afin de retrouver le path d’une entité dont on a l’ID unique.

En résumé, une recherche sur un attribut ayant été indexé, donnerait le schéma suivant :

(1) acquisition de la recherche, sa compréhension par le moteur

(2) ouverture et parcours de l’index secondaire pour trouver la ou les entités dont la valeur d’attribut correspond

(3) comparaison des IDs trouvés par l’index secondaire à l’index principal : les IDs qui ne sont pas retrouvés, sont présumés ne pas exister et ne doivent pas être renvoyés

(4) renvoi du contenu au client, des entités dont les IDs ont été trouvés dans l’index principal

Dans ce cas, seule la pertinence de leur usage et le poids « maximum » que l’on admet limitent la création de ces index secondaires. Restreindre à ce qui est pertinent permet de réduire la taille occupée sur le disque, le parcours éventuel des index entre eux, comme de la complexité des mises à jour (j’y reviendrai plus longuement bientôt).

Aparté : pour les bases les plus imposantes, il est aussi possible d’avoir des index principaux « multiples », en fonction par exemple d’une catégorie d’entité : si votre type est n, un index « principal » n — ne comprenant pas d’autres types — peut être plus efficace que celui qui les contient tous.

Globalement une telle méthode d’organisation est une des bases possibles du NoSQL, dite « orientée colonnes » avec une logique sous-jacente « clé-valeur » : on garde un objet unique et on crée autant de rappels que nécessaires pour le retrouver, en utilisant à plusieurs reprises la valeur de ces attributs. Attention cependant : une telle organisation n’implique pas que l’inverse soit vrai ! Il n’y a pas de garantie que la mise ensemble de tous les rappels de colonnes, reforme sans perte ou sans erreur, l’objet initial ; ce point sera développé plus loin.

C’est ainsi que fonctionne par exemple IndexedDB dans nos navigateurs modernes. Si un attribut (ou une clé) n’est pas spécifié comme colonne « déclarée » pour l’indexation, la recherche directe (du moins le parcours) sur cet attribut ne sera pas possible. L’index n’étant que la réunion du même attribut de toutes les entités en une liste unique.

V. Le temps devient valeur cardinale

Vous le saviez déjà : NoSQL ne préserve pas toutes les propriétés ACID. La mise à jour d’une entité n’est donc pas automatique pour « cascader » dans tous les index qui y font référence (la vie d’un index est indépendante des entités qui le composent). C’est vrai dans de nombreuses solutions logicielles, pouvant supporter de très nombreuses opérations en simultanée au détriment de la précision des recherches.

Cela peut avoir cependant un avantage : en cas de nombreuses modifications simultanées, vous pouvez restreindre la génération / la mise à jour de vos index à rythme régulier et non pas « en direct » comme pour SQL : c’est-à-dire que le temps de blocage est à l’échelle d’une entité et non de toute une table de données. Pratique pour séquencer vos mises à jour de données. Parfois même, modifier une entité n’impose pas de mettre à jour un index.

Cela implique, si vous souhaitez impérativement ne pas avoir d’erreur lors de la recherche (par exemple une suppression ou modification qui n’a pas été mise à jour dans les index, et fait donc un « mauvais » renvoi vers une entité), d’imposer une comparaison ultime des résultats soit au niveau des serveurs (par exemple la quantité de produits : le client ne peut pas savoir si 0 est une valeur à jour ou non), soit au niveau du client (par exemple un attribut manquant ou invalide peut-être déterminé par le client s’il a la « notice » de ce qu’il doit disposer).

C’est-à-dire que ma base NoSQL telle que je la conçois et que la « philosophie » NoSQL l’entend, n’est pas un « totem » au sens où elle est un tout unique et qui évolue de manière uniforme. Une entité peut être plus à jour que les index et donc les index peuvent retourner un résultat qui serait faux ou inattendu. Un phénomène que l’on retrouve à tout moment dans des fermes de serveurs, où la donnée se démultiplie partout — mais pas de manière instantanée.

Plus généralement, il y a trois contraintes que l’on impose à ces systèmes et dont l’une devra être sacrifiée. C’est le théorème CAP : soit la cohérence, soit la disponibilité, soit la tolérance au partitionnement. Jamais les trois à la fois, toujours deux contraintes sur trois !

Pour notre cas, la complexité se résume donc à la recherche d’une double pertinence :

  • celle à la fois dans la création — ou non — d’un index (éviter des index inutiles) ;
  • celle des mises à jour (que je vais résumer en un bref combo thread-safe / fréquence de mise à jour).

Par contre, l’avantage est une grande modularité et des mises à jour (ou sauvegardes) incrémentielles faciles : la comparaison de la date de dernière mise à jour de l’inode de l’entité suffit.

Aparté : ne croyez pas que le schéma que j’expose invente la pluie : de tels logiciels existent déjà, tout ou en partie basés sur les concepts que j’évoque : ils sont juste infiniment plus optimisés. Pour ma part, j’apprécie particulièrement Néo4JS (et son langage de requête Cypher) qui est spécialisé dans les nœuds et relations. MongoDB, orienté « document », est la référence actuellement, et probablement pour longtemps. Pour ce qui nous concerne, cet article propose une base « sans-schéma », c’est-à-dire sans schéma réellement fixe : les données stockées, c’est-à-dire les attributs des entités, le cœur de la raison du stockage, n’étant nullement fixées ou connues à l’avance.

VI. La mise en œuvre pour tester chez vous

VI-A. Les données de tests

Après ces considérations purement théoriques, place à la pratique. Voici comment je conçois mon système : le fichier de l’entité est composé par un identifiant unique par type, avec deux extensions successives (le type lui-même et le terme « entite »). En somme, si nous étions dans les vins et spiritueux, nous aurions par exemple : chateau-dupond.vin.entite ou encore pomerol.terroir.entite.

Le type devrait donc porter un « sens » d’organisation, et ne pas être seulement une « facilité ».

Mais ici nous serons sur un seul type, parce que l’objet est de réaliser des tests et non de vous inviter à vous pochetronner :

 
Sélectionnez
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.
<?php 
function Texte($max=15) { 
    $y = 0; 
    $texte = array(); 
    while($y<$max) { 
        $texte[] = chr( 
            rand(97,122)
        ); 
        $y++; 
    } 
    return implode( 
        "", 
        $texte 
    ); 
} 

$type = "test"; 
$extension = "entite"; 
$extComplete = "$type.$extension"; 
$max = 1000; 
$i = 0; 
while($i<$max) { 
    $eId = uniqid(); 
    file_put_contents(
        "./tests/$eId.$extComplete", 
        '{
    "type": "'.$type.'", 
    "id" : "'.$eId.'", 
    "affichage" : "'.$eId.'", 
    "donnees": { 
        "valeurTexte" : "'.Texte().'", 
        "valeurNumerique" : '.rand(0,1000).' 
    } 
}'
    ); 
    $i++; 
}

Lancer ce code sur votre serveur : le sous-dossier « tests » (qui devra exister préalablement) se remplira d’un millier d’entités. Il s’agit des produits de notre catalogue. Que vous en mettiez 1 000 ou 10 000 — ou plus — importe peu : seul le temps d’exécution des scripts changera.

Le JSON que j’utilise est toujours le même pour toutes les entités, quel que soit le type : un objet contenant le type, l’identifiant unique par type, le nom d’affichage (une forme de « résumé » pour l’affichage humain), puis un objet « donnees » qui prend autant d’attributs que nécessaire. Le premier objet forme donc les entêtes de l’entité, quand le second objet, contenu dans le premier, est le corps. Une présentation qui n’est pas sans rappeler la racine dans un fichier XML…

À ce propos j’utilise JSON, mais c’est un exemple : vous pouvez utiliser pour le stockage de données dans l’entité, indifféremment le CSV, le XML, un ODT ou même une base SQLite. Peu importe ! Le NoSQL sera ici une « surcouche » pour trouver et pointer vers une origine : comme si vous utilisiez systématiquement le format BLOB dans SQL…

VI-B. La création de l’index principal

C’est bien joli tous ces JSON, mais comment fait-on désormais ? D’abord la création d’un index principal (appelé index.entites), qui est l’étape la plus facile. Le parcours du dossier permet de récupérer toutes les entités disponibles : un premier tri est effectué en fonction du type de l’entité, facilement récupérable dans le nom du fichier de l’entité. Viennent ensuite l’étape de compréhension de l’entité elle-même (décodage du JSON), puis l’étape d’insertion dans le fichier d’index.

 
Sélectionnez
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.
<?php 
function index_creer($dossier, $types) { 
    $extension = "entite"; 
    $fIndex = fopen( 
        "./tests/index.entites", 
        "w" 
    ); 
    foreach($types as $type) { 
        foreach( 
            glob( 
                "$dossier/*.$type.$extension", 
                GLOB_NOSORT 
            ) as $entite 
        ) { 
            if (preg_match( 
                "#\/([0-9a-z\-\_]+)\.([0-9a-z\-\_]+)\.([a-z]+)$#i", 
                $entite, 
                $r 
            )==1) { 
                list($ePath, $eId, $eType, $eExtension) = $r; 
                if (in_array($eType, $types)) { 
                    $eObjet = json_decode( 
                        file_get_contents( 
                            $entite 
                        ), 
                        true 
                    ); 
                    $aff = $eObjet["affichage"]; 
                    fwrite(
                        $fIndex, 
                        "$eType\t$eId\t$entite\t$aff\n" 
                    ); 
                } 
            } 
        } 
    } 
    fflush(
        $fIndex 
    ); 
    fclose( 
        $fIndex 
    ); 
} 

index_creer(
    "./tests", 
    array( 
        "test" 
    ) 
);

Ici, j’ai laissé la possibilité de trier par type, alors que mon exemple n’en prend qu’un (« test ») : il s’agit d’une question de logique et de réalisme de l’algorithme.

VI-C. La recherche dans l’index principal

En ce moment, notre index principal est le reflet à un instant t des entités disponibles pour les types choisis, avec un court résumé de l’essentiel : type, ID, path et affichage humain. Le parcours de cet index est facile, pour un ou plusieurs types comme des IDs.

Une fonction suffit, prenant en argument le dossier visé, et deux tableaux (les types et les IDs). Nous créons un premier tableau des combinaisons du texte de chaque paire type/ID, qui forment le début de chaque ligne de notre index principal. La comparaison de ce tableau à chaque ligne permet de savoir si l’ID est désiré ou non, pour un seul type ou plusieurs.

Ce code évite également d’avoir plusieurs parcours de l’index principal à faire : un seul parcours, mais un tableau des combinaisons possibles, rend efficient le parcours du disque, en monopolisant avantageusement la RAM. Bonus : si plusieurs sous-processus peuvent être lancés en parallèle, vous pouvez décharger les comparaisons à ceux-ci sans difficulté.

Si un ID n’est pas trouvé, pour un ou plusieurs types, il sera juste absent des résultats ! Ces derniers sont trouvés en décomposant chaque ligne désirée par le signe de tabulation.

 
Sélectionnez
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.
<?php 
function index_trouver($dossier, $types, $eIds=null) { 
    $chaines = array(); 
    if ($eId===null) 
         $eId = array( 
             "" 
         ); 
    foreach ($types as $type) { 
        foreach ($eIds as $eId) { 
            $chaines[] = "$type\t$eId"; 
        }
    }
    $fct = function ($chaine, $cherche) {
        return ( 
            substr($chaine, 0, strlen($cherche)) === $cherche 
        ); 
    }; 
    $f = fopen( 
        "$dossier/index.entites", 
        "r" 
    ); 
    $r = array(); 
    while ( 
        ($ligne = fgets($f))!== false 
    ) { 
        foreach ($chaines as $chaine) {
            if ($fct( 
                $ligne, 
                $chaine 
            )) 
                $r[] = explode("\t", $ligne); 
        }             
    } 
    return $r; 
} 

index_trouver(
    "./tests", 
    array(
        "test"
    ), 
    array( 
        "5ad5b8e963a6b", 
        "5ad5b8e962a38", 
        "5ad5b8e962d72", 
        "existepas" 
    ) 
);

Une fois le parcours terminé, les résultats peuvent être librement utilisés :

  • renvoi direct des IDs trouvés au client ;
  • extraction complète des entités pour leur renvoi au client.

Une variante est de déterminer une taille maximum (1Gg par exemple) pour un index principal : au-delà, peut-être faut-il mieux scinder son index principal – en somme « découper » sa base en morceaux par type –, afin de garder des volumes plus abordables si vos machines sont à la peine.

VI-D. La création d’un index secondaire

Pour l’instant nous ne savons pas chercher ni trouver autre chose que des IDs. Il nous faut créer une sorte de registre, comme l’index principal, à la différence qu’une valeur (un attribut de l’entité ou une synthèse quelconque de celui-ci, un hash par exemple) sera utilisée avec l’ID, et non le chemin vers l’entité.

Pour créer un tel index, dit « secondaire » dans mon exemple, deux possibilités s’offrent à vous : soit le fichier d’index principal (index.entites) est frais et à jour, auquel cas il convient de l’utiliser. Il suffit de le parcourir pour retrouver toutes les entités qui doivent composer notre index secondaire, car le but de l’index principal est justement de référencer toutes les entités de notre base. Sans exception.

Cela est un gain de temps si vous avez de nombreux dossiers qui forment votre base.

Si par contre, et comme dans mon exemple, vous n’avez qu’un type et qu’un dossier, la fonction glob de PHP restera aussi efficace… Cependant en production ce n’est pas recommandé : si votre index secondaire reprend des entités qui n’ont pas été référencées dans l’index principal, cela peut être source d’erreurs et de ralentissements (lors du tri final).

Il faudra lire et comprendre chaque fichier des entités, en extraire l’attribut qui intéresse (l’idéal étant de n’en avoir qu’un par index secondaire). Ce n’est pas encore l’heure du tri s’il y a lieu : il suffit de faire notre index secondaire avec la valeur sélectionnée, ici un attribut de l’entité (sous format « texte », c’est-à-dire sérialiser si besoin), et l’ID. Le nom de cet index secondaire sera composé du nom de l’attribut, du type de l’entité ainsi que de l’extension « entites ». Par exemple : valeurNumerique.test.entites.

Si un ordre est donné, vous pouvez vous amuser avec le nom des fichiers : valeurNumerique$asc.test.entites ou valeurNumerique$desc.test.entites. Le tout étant de garder à la fois la cohérence du nom de l’index, la possibilité de le retrouver « à la volée », ainsi que le respect des noms de fichier supportés par le système hôte (taille et motif).

Une fois l’index réalisé, si besoin, il devra être trié. La fonction principale du tri est commune quel que soit le type de tri et le type de données triées : il faudra une fonction subalterne qui récupérera deux lignes consécutives de l’index secondaire, afin d’éventuellement les échanger, afin de les réécrire dans le fichier d’index secondaire. C’est-à-dire un bon vieux tri à bulles !

Pour simplifier, l’exemple suivant prend un fichier qui a un numéro par ligne, et trie de manière ascendante les lignes entre elles :

 
Sélectionnez
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.
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.
<?php 
ini_set("auto_detect_line_endings", true); 
/*5
9
4
3
7
0
1
6
2
8
*/ // → copier-coller dans un fichier vide ./tests/test-simple.txt, les lignes (non commentées) suivantes. Penser à bien mettre un dernier saut de ligne après le 8 ! 
function index_trier($path, $fctTrie) { 
    $f_e = fopen(
        $path, 
        "r+"
    ); 
    $modification = false; 
    $continue = true; 
    while($continue) { 
        while(true) { 
            $ligne_1 = fgets( 
                $f_e 
            ); 
            $ligne_2 = fgets( 
                $f_e 
            ); 
            if ($ligne_2==false) 
                break; 
            if ($fctTrie(
                $ligne_1, 
                $ligne_2 
            )) { 
                fseek( 
                        $f_e, 
                0-(strlen($ligne_1)+strlen($ligne_2)), 
                SEEK_CUR 
                ); 
            $modification = ($modification or true); 
            fwrite(
                $f_e, 
                "$ligne_1$ligne_2" 
            ); 
            fflush(
                $f_e
                ); 
            } 
            fseek( 
                $f_e, 
                0-strlen($ligne_2), 
                SEEK_CUR 
            ); 
        } 
        $continue = boolval($modification); 
        $modification = false; 
        fseek( 
            $f_e, 
            0, 
            SEEK_SET 
        ); 
    } 
    fclose( 
        $f_e 
    ); 
} 

index_trier( 
    "./tests/test-simple.txt", 
    function (&$ligne1, &$ligne2) { 
        if (intval($ligne2)<intval($ligne1)) { 
            $tmp1 = $ligne1; 
            $tmp2 = $ligne2; 
            $ligne1 = $tmp2; 
            $ligne2 = $tmp1; 
            return true; 
        } 
        return false; 
    } 
);

La lecture d’un index secondaire trié vous permet de retourner tout ou une portion délimitée de celui-ci, permettant ainsi d’éviter de « trier » à la volée et systématiquement les attributs des entités venant de l’index principal.

En résumé :

  • mon index principal est la « carte » des entités disponibles, notamment celles utilisées dans les index secondaires ;
  • mes index secondaires sont les « raccourcis » pour accéder à des valeurs particulières des entités éventuellement triées.

VI-E. La recherche dans un index secondaire

La recherche dans un index secondaire est assez similaire à celle de l’index principal : seule différence, dans la très grande majorité des cas, il vous faudra :

  1. Déterminer précisément le séparateur entre la valeur et l’ID de l’entité — en gardant à l’esprit que le caractère « \n » est utilisé dans notre situation pour séparer les entrées de l’index entre elles. En cas de texte comme valeur, qui peut contenir des caractères particuliers et notamment un caractère de tabulation que j’ai pu évoquer plus haut, il conviendra de prendre toujours la dernière valeur d’un tableau produit par la fonction explode en PHP comme la valeur de l’ID et de considérer comme une seule valeur les autres entrées de ce tableau (join). Un index secondaire qui n’est pas facilement compréhensible ou demande trop de calcul n’est pas un index pertinent !
  2. « Comprendre » ou traduire la valeur utilisée pour l’entité. Par exemple des entiers : il peut être utile dans certains cas, non pas de comparer du texte (s’il est contenu ou non, etc.), mais de travailler directement sur une valeur. Ainsi une valeur dans un index secondaire pourrait être une valeur sérialisée d’un objet, une représentation en base64, etc.

À ce propos, si une valeur est multiple dans une entité — parce qu’il s’agit d’un tableau par exemple — rien n’empêche dans un index secondaire (et seulement dans les index secondaires), une entité à apparaître plusieurs fois, avec une valeur différente pour chaque récurrence.

VI-F. Extraire des données à la volée

Admettons que tout soit désormais opérationnel : comment interroger efficacement ce qui reste pour l’instant qu’une série de fichiers, dont le contenu n’est qu’un « copier-coller » d’autres ?

Ce qu’il faut garder à l’esprit, c’est que (presque) toutes les opérations jusqu’à présent, sont du registre de la création et du maintien à jour de la base – pas du tout de son exploitation par des clients logiciels ou humains, comme de l’accès à des données. Vous pouvez d’ailleurs n’avoir qu’une base produite sur un autre système et collée sur votre hébergement avec le lecteur de celle-ci (c’est-à-dire le code PHP qui vient), sans que cela pose de problème.

L’accès aux données est simple : nous récupérons une requête, que nous comprenons, puis nous retournerons les résultats qui y correspondent :

  • s’il s’agit de « connaître » des entités, d’en extraire en fonction de types ou d’IDs, l’exploration déjà fournie au point (3) suffit ;
  • s’il s’agit de retourner des portions d’index (fussent-ils des IDs), il faut regarder le type et le nom de l’attribut qui a servi au classement (même si le classement est ascendant, descendant ou autre), et d’en retourner un nombre déterminé de lignes.

Reste des points que l’article n’aborde pas, mais qui ont leur intérêt : vérifier par exemple qu’une entité est bien effectivement à jour ou que son index n’est pas périmé. Pour ce cas, seul l’usage détermine la fonction qui sera utilisée.

Si vous utilisez une requête HTTP, des paramètres GET ou POST peuvent faire l’affaire, afin de produire une API REST. Exemples :

  • GET /entite/test/ids=545 → une seule entité dont l’ID est indiqué ;
  • GET /entite/test/ids=545,4848,1408 → plusieurs entités respectant une série d’IDs ;
  • GET /entite/test,truc/ids=545,4848,1408 → plusieurs entités respectant plusieurs types et ID ;
  • GET /entites/test,truc → toutes les entités des types fournis ;
  • POST /entite/test/id=&data={…} → la création (ou PUT pour la modification) d’une entité dont l’ID n’est pas fourni et devra donc être généré par la base ;
  • etc.

Lorsqu’il s’agit de modifier des entités à la demande d’un client, en soi l’opération n’est pas complexe : il s’agit simplement d’éditer le fichier source de l’entité en gardant à l’esprit qu’il faut rester thread-safe, car la base n’a pas de sécurité native sur ce sujet (ou un verrou exclusif en écriture utilisé par le système s’il y a lieu).

Cependant, il faut garder à l’esprit que les index générés (le principal ou les secondaires), ne seront pas mis à jour tout seul. Ce n’est pas forcément un problème : si vous modifiez un attribut qui n’est pas utilisé dans un index secondaire, il est inutile d’avoir à demander le recalcul des index qui ne l’utilisent pas.

D’ailleurs un fichier supplémentaire, avec les path des entités et leur dernière date de mise à jour, semble indispensable pour éviter d’avoir à mettre à jour des index qui ne sont pas périmés. Si la source de l’entité — le fichier de celle-ci — n’a pas été modifiée, il n’est pas utile de modifier les index qui en dépendent. Si un index secondaire ne contient qu’une partie des entités disponibles, il est parfois préférable de simplement vérifier que la date de création / modification de cet index est postérieure à l’entité citée la plus récente…

Tout est question d’usage !

VII. Ce qu’il faut vendre au client

Ah le client ! Revenons à lui, lui qui, souvent, ne comprend pas un mot de ce que vous dites. Et parfois, aïe, si : « pourquoi prendre un si gros marteau pour ma si petite mouche ? » Il aurait raison.

Même dans mon exemple extrême, des arguments peuvent se faire valoir, au-delà d’un certain fun. Les mêmes arguments que pour les plus ambitieux projets :

– performance → la lecture ligne à ligne ne coûte quasi rien en RAM ou en temps de disque (pratique en cas de montée en charge de requêtes parallèles ou de ressources limitées), et les calculs peuvent être gérés en « map-reduce », c’est-à-dire sur une multitude de sous-processus en parallèle (PHP le gère mal lorsqu’il agit par le biais d’Apache ou équivalent) ;

– scalabilité horizontale→ sur une multitude de serveurs si besoin, du fait qu’il ne s’agit jamais que de la production d’index et de fichiers d’entités pouvant se situer sur des environnements différents et que le calcul des index est réalisé à la demande et non comme une obligation à chaque requête. Une copie par FTP entre les serveurs peut suffire dans certains cas simples ;

– résilience → en simultané, possibilité de disposer d’un accès multiple (un processus par appel client, donc le risque de défaut est lié à cet appel) grâce un programme central superviseur, gérant également les modifications (pour la gestion plus aisée des verrous). Enfin très peu de perte de données en cas de crash complet et inopiné : au pire, les quelques entités ouvertes risquent la corruption (un back-up de la sauvegarde par FTP et tout ira bien !). Les index pouvant être régénérés, rien n’est vraiment « dramatique » pour eux.

Ces arguments marchent pour un tout petit système comme un plus grand.

Enfin, la création et la mise à jour des index d’une base se marient très bien avec des tâches CRON. Et ça, le client, il aime ça.

VIII. Les avantages de ma démonstration

D’abord c’est la grande « portabilité » du code de génération de la base : peu importe finalement le système hôte, la version de PHP voire du langage utilisé (Python, C++, Java… ). Ce que je propose est même réalisable en scripts bash si le cœur vous en dit.

Ainsi parler d’une « base » (avec le terme sous-entendu du « moteur » utilisé pour la faire « fonctionner ») est à la fois (définitions toutes personnelles) :

  • correct : un lieu unique et déterministe pour des données, connues et pouvant être extraites par une série ordonnée d’opérations, gérées d’une manière « centralisée » / supervisée ;
  • … et incorrect : nous ne sommes pas sur un système unique comme l’entendent souvent les moteurs de bases de données, c’est-à-dire un ensemble uniforme et limité de daemons coordonnés. Il faudrait affiner bien davantage les quelques bouts de scripts que je propose : nous sommes ici davantage sur un protocole de gestion d’un moteur de bases de données. Et bien sûr, celle sur des portions de scripts pour des tests !

Quelle qu’en soit la définition, le principe reste le même : il s’agit à la fin d’une base de données NoSQL, assez classique. Trop ? Peut-être : l’informatique tend parfois à recréer de nouvelles frontières. À bien y regarder d’ailleurs, un fichier CSV est un « ancêtre » de NoSQL, pour peu qu’on lui adjoigne le programme adéquat, qui gère les requêtes…

Ma démonstration supporterait même la création d’un langage de requête quasi naturel grâce à un tokenizer. À ce stade, un accès full-REST est largement suffisant : mais la possibilité existe !

IX. Les limites de ma démonstration

Je vois bien que les plus féroces d’entre vous n’ont pas été impressionnés par la performance de cet article. Soit : nul n’est parfait. Ainsi et pour ma défense, voici quelques remarques :

  • Il n’y a volontairement aucune gestion des erreurs : il ne s’agit pas d’un code en fonctionnement. Ne mettez pas ce code sur un serveur de production.
  • Cet article est à considérer comme un polyfill PHP de ce que pourrait être une « vraie » base (un vrai moteur) NoSQL — et qui peut déjà exister par ailleurs.
  • Dans certains cas — et présentement pour le mien —, je n’avais pas d’autres choix que de recourir à cette « ruse » pour des raisons que la morale réprouve (chut). Parfois, l’environnement de travail est limité ou fermé, pour des motifs souvent légitimes, et il faut faire avec : j’en ai tiré de ce travail la moelle intéressante. Eh oui, il y avait plus simple que de réinventer la roue !
  • Il est moins question ici d’optimisation que de compréhension. À ce sujet, le tout « optimisation » produit parfois des abstractions ou des mauvaises pratiques que l’expérience seule efface. Rendant parfois des produits logiciels mal fichus. Ainsi vous avez de plus en plus des utilisateurs de technologies et non pas des producteurs / développeurs de celles-ci : comprendre est un préalable non pour seulement « faire » (c’est-à-dire en avoir l’usage), mais produire efficacement en l’utilisant. Un conducteur n’est nullement obligé de connaître la mécanique d’un moteur pour rouler en voiture. Mais un ingénieur mécano lui, le doit — même s’il ne conduit pas de voiture. Vous pouvez passer votre vie en développant dans un langage ou en utilisant un système sans le comprendre. Mais le moindre pépin ou changement peut vous laisser sur le bord de la route…

X. Et la sécurité des données alors ?

Je me doutais bien que la question allait arriver. Pour les crashs, qui relèvent de la sécurité, il faut remonter quelques paragraphes plus haut ; je n’y reviens pas. La sûreté des données est, comme souvent, d’abord celle de votre système et des libertés / pratiques de vos clients humains ou logiciels.

  • Sous Linux, un bon paramétrage des droits suffit, en plus d’un système d’authentification si nécessaire pour l’utilisation ou la mise à jour de la base (par JSON Web Token par exemple).
  • Sous Windows, c’est différent mais tout aussi simple : il suffit de passer sous Linux.

XI. Mot de la fin

Merci de votre lecture. J’espère que vous aurez eu, par moment, quelques sourires comme je les ai eus…

XII. Note de la rédaction de Developpez.com

Nous tenons à remercier f-leb et Jean-Philippe André pour la relecture orthographique de ce tutoriel.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Copyright © 2018 Julien Garderon. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.