Python - RDF et gestion des connaissances

Partie 2 – Fonctionnement avancé et opérationnel du triplestore

Julien Garderon

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

- Titre : Python - RDF et gestion des connaissances

- Auteur : Julien Garderon

- Parution : 19 avril 2019

- Licence : Copyright ® 2019 Julien 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 :

- RDF et gestion des connaissances - Partie 1 : apprendre les bases d’un triplestore REST en Python

Sommaire

- I. Introduction

- II. Un petit outil de conception de requêtes HTTP

- III. Gérer des données au-delà de la communication

- III-A. La manière d’écrire (et pas seulement de s’écrire !)

- III-B. Quel type choisir ?

- III-C. Quel type choisir (bis) ?

- IV. Bytes : ce n’est pas qu’une question de taille

- IV-A. La taille, toujours…

- IV-B. Bytes : ne pas être confus mais efficace

- IV-C. La carte aux trésors et des bits en plus des bytes

- V. La création de notre premier silo

- V-A. Commençons le commentaire…

- V-B. Ajouter, c’est facile… le reste moins

- V-C. Et les droits ?

- V-D. Tout peut être amélioré – au prix (souvent) de la lisibilité…

- V-E. « Aie, ma fonction d’écriture a échoué ! » ou la délicate gestion d’erreurs

- V-F. Et les filtres dans tout ça ?

- VI. Serveur et silo… enfin réunis

- VI-A. Quelle limite théorique en taille du silo ?

- VI-B. Organisation des fichiers

- VI-B-1. main.py – ce qui lance le service

- VI-B-2. CommandeHTTP.py

- VI-B-3. Silo.py

- VI-B-4. Triplets.py

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

I - Introduction

Cet article est le second de la série sur la création d’un triplestore dans la perspective d’une gestion grâce à la norme RDF pour la gestion des connaissances.

Nous y trouverons notamment les réflexions et une série de tests sur l’organisation des données au sein d’un fichier – binaire, forcément – et comment la représentation de l’information joue sur notre propre perception d’un moteur de base et dans la programmation.

Il s’arrêtera sur une proposition fonctionnelle d’un service triplestore. Mais tout d’abord un long travail de préparation nous attend…

II - Un petit outil de conception de requêtes HTTP

Rapidement, il peut devenir pénible de taper à la main les requêtes de test, sans un minimum de confort. Une fois que le service de triplestore sera opérationnel, ce sera même frustrant ! Il serait plus aisé que notre base fournisse ses propres instruments. Et c’est (facilement) possible ! Comme nous l’avions vu dans la première partie, les chemins de chaque requête sont testés afin de permettre des exceptions – des requêtes qui ne concernent pas directement le silo.

Dans le fichier page.html créé précédemment, ajouter le code suivant :

1. <!DOCTYPE html>

2. <html lang="fr">

3. <head>

4. <meta charset="utf-8">

5. <title>Conceveur de requêtes</title>

6. <script type="text/javascript">

7. function requeter(verbe, chemin, entetes=null, corps=null, suite=null) {

8. httpRequest = new XMLHttpRequest();

9. chargement();

10. httpRequest.onreadystatechange = () => {

11. if (httpRequest.readyState == XMLHttpRequest.DONE) {

12. var objR = document.querySelector( "#reponse" )

13. objR.textContent = httpRequest.status+" : "+httpRequest.statusText+"\n";

14. objR.textContent += httpRequest.getAllResponseHeaders()+"\n";

15. objR.textContent += httpRequest.responseText;

16. if (typeof suite=="function")

17. suite();

18. chargement( "non" );

19. }

20. };

21. httpRequest.open( verbe, chemin, false );

22. if (entetes!=null) {

23. for (var i=0; i<entetes.length; i++) {

24. var entete = entetes[i];

25. httpRequest.setRequestHeader( entete[0], entete[1] );

26. }

27. }

28. httpRequest.send( corps );

29. }

30. function chargement(etat="oui") {

31. document.querySelector( "#chargement" ).setAttribute( "attendre", etat );

32. }

33. function tester(evt) {

34. evt.preventDefault();

35. var objM = document.querySelector( "#requete span.message" )

36. objM.textContent = "Test de la requête...";

37. requeter(

38. verbe = evt.target.verbe.value,

39. chemin = evt.target.chemin.value,

40. entetes = evt.target.entetes.value.split("\n"),

41. corps = (evt.target.corps.value=="")?null:evt.target.corps.value,

42. suite = () => {

43. objM.textContent = "Requête effectuée";

44. }

45. );

46. chargement( "non" );

47. }

48. window.addEventListener(

49. "load",

50. () => {

51. document.querySelector( "#requete" ).addEventListener( "submit", tester );

52. chargement( "non" );

53. }

54. );

55. </script>

56. <style type="text/css">

57. body {

58. width: 100%;

59. height: 100%;

60. margin: 0px;

61. padding: 0px;

62. }

63. #page {

64. width: 100%;

65. max-width: 25em;

66. margin: auto;

67. }

68. h1 {

69. margin: 0px;

70. padding: 0.2em;

71. color: white;

72. text-align: center;

73. background: gray;

74. }

75. span.message {

76. display: block;

77. margin-top: 0px;

78. margin-bottom: 1em;

79. font-size: 80%;

80. font-style: italic;

81. text-align: center;

82. color: gray;

83. }

84. fieldset {

85. padding: 0.5em;

86. border: 0px;

87. border-top: dashed 1px gray;

88. }

89. fieldset+fieldset {

90. margin-top: 1em;

91. }

92. legend {

93. margin: auto;

94. padding: 0.1em 0.5em;

95. color: white;

96. border: solid 1px white;

97. background: gray;

98. }

99. input, textarea {

100. width: 100%;

101. }

102. #reponse {

103. width: 90%;

104. margin: 1em auto;

105. }

106. #chargement {

107. display: block;

108. position: fixed;

109. top: 0px;

110. left: 0px;

111. width: 100%;

112. height: 100%;

113. background: rgba( 0, 0, 0, 0.5 );

114. }

115. #chargement[attendre="non"] {

116. display: none;

117. }

118. </style>

119. </head>

120. <body>

121. <div id="page">

122. <h1>

123. Conceveur de requêtes

124. </h1>

125. <form id="requete">

126. <span class="message"></span>

127. <fieldset>

128. <legend>Verbe</legend>

129. <input type="text" name="verbe" value="GET" />

130. </fieldset>

131. <fieldset>

132. <legend>Chemin</legend>

133. <input type="text" name="chemin" value="/" />

134. </fieldset>

135. <fieldset>

136. <legend>Entêtes (1/ligne)</legend>

137. <textarea name="entetes"></textarea>

138. </fieldset>

139. <fieldset>

140. <legend>Corps</legend>

141. <textarea name="corps"></textarea>

142. </fieldset>

143. <input type="submit" value="tester" />

144. </form>

145. <pre id="reponse"></pre>

146. <div id="chargement" attendre="oui"></div>

147. </div>

148. </body>

149. </html>

Vous aurez désormais un outil minimal mais opérationnel pour éditer des requêtes simples. L’appel à l’URL http://127.0.0.1:888/page permet d’y accéder lorsque le serveur est lancé.

Le fonctionnement est simple : les éléments du formulaire fourni préparent une requête via la technologie AJAX, et l’envoi au serveur. Le retour est affiché, quel qu’il soit.

Il peut être utile d’avoir une forme – plus que basique certes – d’éditeur dans un contexte REST.

Attention tout de même : tous les navigateurs ne gèrent pas à l’identique la technologie AJAX – particulièrement le traitement de certains entêtes. Ainsi sous Firefox, inutile d’indiquer une taille du contenu, le navigateur s’en charge et n’enverra pas de corps si le protocole HTTP n’en prévoit pas (cas de GET notamment).

III - Gérer des données au-delà de la communication

III-A - La manière d’écrire (et pas seulement de s’écrire !)

Il est primordial de s’accorder, lors de la conception d’un outil, sur les modalités de l’échange. Évidemment, une grande part repose sur HTTP : mais il s’agit là du transport pour la partie « réseau ». Or le transport n’agit pas directement sur le contenu envoyé (même s’il y a des transformations : la source, l’origine, doit être retrouvée au moment de l’analyse).

En réalité, une fois la partie transport résolue, le problème de communication reste entier. Il s’agit maintenant de concevoir ce que le client doit envoyer pour être compris et ce qu’il recevra en retour – gardez à l’esprit qu’il s’agit là de la « charge utile » pour le client comme pour le serveur, peu importe la modalité de transmission. Nous quittons la sphère HTTP pour celle du cœur de la programmation – ce qui correspondrait, en quelque sorte, à notre langage de requête.

Un triplet, comme je l’expliquais en introduction de la série d’articles, est une base de trois données fondamentales, sous la forme d’une chaîne de caractères – c’est en tout cas ce que notre triplestore acceptera. Le format général est donc le suivant :

texte( Sujet ) texte( Prédicat ) texte( Objet )

Il est plutôt embêtant de ne gérer s’il n’y a qu’un seul triplet à la fois – souvent ils sont très courts, comme nous le verrons dans le dernier article, grâce à l’utilisation des préfixes et d’URI.

Alors notre triplestore comprendra – et renverra – possiblement 0, 1 ou n triplets à chaque requête, et sans réelle limite du nombre de triplets. Ces triplets se présenteront sous la forme d’un triplet par ligne, chaque partie du triplet étant séparée par un caractère de tabulation. Bref : nous utiliserons un format qui ressemble bigrement au TSV – une des alternatives du CSV.

Chaque ligne d’un corps de requête devra pouvoir répondre précisément à cette expression régulière :

^(?P<subject>[^\t]+)\t(?P<predicate>[^\t]+)\t(?P<object>[^\t]+)$

Enfin, pour les transactions d’insertion de nouveaux triplets, si une ligne est invalide, l’ensemble de la transaction est invalide : soit la transaction est entièrement cohérente (code HTTP 200), soit elle ne doit pas être exécutée (code HTTP 400).

Il y a un avantage à ce format : pouvoir analyser au fur et à mesure de la récupération d’un corps de requête par exemple, des triplets - sans à avoir à charger en une fois l’ensemble de la chaîne. Si une des lignes est invalide, il n’est pas nécessaire de récupérer et traiter le reste du corps de la requête !

Voici un exemple d’un corps de requête qui serait analysé au fur et à mesure :

1. import re

3. t = """<http://example.org/arnaud#me>    <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>    <http://xmlns.com/foaf/0.1/Person>

4. <http://example.org/arnaud#me>    <http://xmlns.com/foaf/0.1/knows>    <http://example.org/béatrice#me>

5. <http://example.org/arnaud#me>    <http://schema.org/birthDate>    "1990-07-14"^^<http://www.w3.org/2001/XMLSchema#date>

6. <http://example.org/arnaud#me>    <http://xmlns.com/foaf/0.1/topic_interest>    <http://www.wikidata.org/entity/Q12418>

7. <http://www.wikidata.org/entity/Q12418>    <http://purl.org/dc/terms/title>    "La Joconde"

8. <http://www.wikidata.org/entity/Q12418>    <http://purl.org/dc/terms/creator>    <http://fr.dbpedia.org/resource/Léonard_de_Vinci>

9. <http://data.europeana.eu/item/04802/243FA8618938F4117025F17A8B813C5F9AA4D619>    <http://purl.org/dc/terms/subject>    <http://www.wikidata.org/entity/Q12418>"""

11. def trouver(chaine):

12. return re.match(

13. "^(?P<subject>[^\t]+)\t(?P<predicate>[^\t]+)\t(?P<object>[^\t]+)$",

14. chaine,

15. re.I

16. ).groupdict()

18. try:

19. pas = 10

20. position = 0

21. data = ""

22. triplets = []

23. while True:

24. d = t[position:position+pas]

25. if d=="":

26. break

27. else:

28. data += d

29. if "\n" in data:

30. ligne, data = data.split( "\n", 1 )

31. triplets.append(

32. trouver( ligne )

33. )

34. position += pas

36. if data!="":

37. triplets.append( trouver( data )  )

38. print( "requête valide :", triplets )

39. except Exception:

40. print("requête invalide")

41. finally:

42. print("... poursuivre ...")

Le retour valide est :

requête valide : [{'subject': '<http://example.org/arnaud#me>', 'predicate': '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>', 'object': '<http://xmlns.com/foaf/0.1/Person>'}, {'subject': '<http://example.org/arnaud#me>', 'predicate': '<http://xmlns.com/foaf/0.1/knows>', 'object': '<http://example.org/béatrice#me>'}, {'subject': '<http://example.org/arnaud#me>', 'predicate': '<http://schema.org/birthDate>', 'object': '"1990-07-14"^^<http://www.w3.org/2001/XMLSchema#date>'}, {'subject': '<http://example.org/arnaud#me>', 'predicate': '<http://xmlns.com/foaf/0.1/topic_interest>', 'object': '<http://www.wikidata.org/entity/Q12418>'}, {'subject': '<http://www.wikidata.org/entity/Q12418>', 'predicate': '<http://purl.org/dc/terms/title>', 'object': '"La Joconde"'}, {'subject': '<http://www.wikidata.org/entity/Q12418>', 'predicate': '<http://purl.org/dc/terms/creator>', 'object': '<http://fr.dbpedia.org/resource/Léonard_de_Vinci>'}, {'subject': '<http://data.europeana.eu/item/04802/243FA8618938F4117025F17A8B813C5F9AA4D619>', 'predicate': '<http://purl.org/dc/terms/subject>', 'object': '<http://www.wikidata.org/entity/Q12418>'}]
... poursuivre ...

Et le retour invalide est radical (le reste de la chaîne n’est pas traitée) :

requête invalide
... poursuivre ...

III-B - Quel type choisir ?

Le stockage des données n’est pas seulement propre au stockage en lui-même dans la base de données, ou encore la manière dont on communique (transport et commande). Le stockage est aussi en RAM, lors des calculs et des opérations de lecture/écriture.

Pour ce dernier point, « stockage » est mal usité : il s’agit plutôt de mémoire – et à court terme, car le moindre arrêt du programme ou du serveur, sauf l’utilisation d’images et de certains systèmes virtuels, provoquera la perte de la donnée.

La mémorisation donc, est une question de performance, mais aussi de lisibilité et de style. Trois solutions s’offrent à vous :

- l’utilisation d’une classe, par exemple avec une instance par enregistrement, qui permet d’avoir une sorte de « super enregistrement » qui contiendrait en plus d’informations, des méthodes propres et éventuellement des contrôles. La lisibilité est idéale et, de plus, la surcharge de __getitem__ et __putitem__ permet un contrôle des données écrites ;

- l’utilisation d’un dictionnaire, avec un bon vieux système clé/valeur. La clé peut être du texte, et c’est utile pour la lisibilité, mais il y a clairement une différence sur la gestion des méthodes – l’évolution pour le développeur n’est pas aisée. Par contre, c’est un compromis idéal entre un indice non-numérique et l’utilisation des objets ;

- l’utilisation d’une liste : plus de clé en texte, seulement des indices numériques. Et contrairement à un dictionnaire, une liste se réorganise. C’est-à-dire que si un petit malin dans votre programme s’amuse à faire un tri sur une liste et que l’ordre était important (lien entre un indice et une clé stockée autrement), vous ne perdez pas les données, mais vous ne savez pas à quoi correspond le nouvel indice. Passons rapidement donc ce type qui ne nous intéresse pas ici ;

- l’utilisation d’un tuple – une liste « figée » ) en quelque sorte. Le terme technique retenu est immuable et résume à la fois sa faiblesse mais sa grande force : elle n’évolue pas. Ce qui ne veut pas dire qu’elle ne dispose pas de fonctions, mais celle-ci ne fournit que l’accès aux données triées.

Vous pouvez avoir des tuples « nommés » se rapprochant d’un dictionnaire, avec le module Collections et la fonction namedtuple. Cependant, cela ne nous intéressera pas ici. J’ai mis aussi de côté les set.

Dans votre IDLE préféré, copiez-collez le code suivant :

1. import datetime

3. nbre = 1000000

5. debut = datetime.datetime.now()

6. for i in range( 0, nbre ):

7. class A:

8. sujet = 0

9. predicat = 1

10. objet = 2

11. a = A()

12. b = a.predicat

13. fin = datetime.datetime.now()

14. print( "object :", fin-debut )

16. debut = datetime.datetime.now()

17. for i in range( 0, nbre ):

18. a = { "sujet": 0, "predicat": 1, "objet" : 2 }

19. b = a["predicat"]

20. fin = datetime.datetime.now()

21. print( "dict :", fin-debut )

23. debut = datetime.datetime.now()

24. for i in range( 0, nbre ):

25. a = [ 0, 1, 2 ]

26. b = a[1]

27. fin = datetime.datetime.now()

28. print( "list :", fin-debut )

30. debut = datetime.datetime.now()

31. for i in range( 0, nbre ):

32. a = ( 0, 1, 2 )

33. b = a[1]

34. fin = datetime.datetime.now()

35. print( "tuple :", fin-debut )

… Il représente pour chaque type évoqué au-dessus, un million d’itérations de création et d’un accès. J’ai mis le nom des parties d’un triplet (sujet, prédicat, objet). Lancez l’exécution et constatez que le résultat est sans appel :

object : 0:00:08.982256
dict : 0:00:00.417122
list : 0:00:00.249939
tuple : 0:00:00.156250

Les objets dans ce contexte sont hors de propos : et comme nous passerons notre temps à lire des portions de l’index et du contenu, pour des questions de tri et d’extraction, sa lenteur ne nous convient pas. Pour autant, s’il y a de nombreux traitements à faire derrière, le choix de l’objet pour sa praticité et sa souplesse (héritage, surcharge, etc.) n’est pas à ignorer.

Il reste les dictionnaires, qui offrent le meilleur compromis temps/lisibilité. Loin, certes, des tuples mais après tout, le sacrifice de la performance pour garder un code digeste parfois…

III-C - Quel type choisir (bis) ?

En se creusant les méninges, il reste cependant une solution : un ensemble de tuples – un peu comme en LISP.

Ainsi nous gardons la performance des tuples, mais aussi un (relatif) confort avec des accès par chaîne de caractères.

En somme, avoir le meilleur des deux. Testons ce scénario :

1. import datetime

3. nbre = 1000000

5. def extraireCle(partie, triplet):

6. return triplet[triplet[0].index( partie )+1]

8. debut = datetime.datetime.now()

9. for i in range( 0, nbre ):

10. a = (

11. ("sujet", "prédicat", "objet"),

12. 0,

13. 1,

14. 2

15. )

16. b = extraireCle( "prédicat", a )

17. fin = datetime.datetime.now()

18. print( "tuple-complexe :", fin-debut )

21. debut = datetime.datetime.now()

22. for i in range( 0, nbre ):

23. a = { "sujet": 0, "predicat": 1, "objet" : 2 }

24. b = a["predicat"]

25. fin = datetime.datetime.now()

26. print( "dict :", fin-debut )

Aïe ! Les résultats ne sont pas au rendez-vous :

tuple-complexe : 0:00:00.577991
dict : 0:00:00.328054

Ce qui est finalement logique, car l’on utilise une fonction au sein de la fonction : index, afin de retrouver l’indice numérique à utiliser sur le triplet. Pour le dictionnaire, tout cela est « caché » par le haut niveau de programmation utilisé en Python, de manière performante.

Cependant, ce constat est vrai si nous prenons un triplet en individuel. Ramener cela à une liste – par exemple extraire plusieurs triplets -, et la situation change assez radicalement en utilisant à chaque fois des « listes en intention » :

1. import datetime

3. nbre = 1000000

5. def extraireCle(partie, triplet):

6. i = triplet[0].index( partie )

7. return [ item[i] for item in triplet[1:] ]

9. # juste pour tester

10. a = (

11. ("sujet", "prédicat", "objet"),

12. ( 0, 1, 2 ),

13. ( 0, 1, 2 ),

14. ( 0, 1, 2 )

15. )

16. print( extraireCle( "prédicat", a ) ) # retour une liste de trois "1"

18. debut = datetime.datetime.now()

19. for i in range( 0, nbre ):

20. a = (

21. ("sujet", "prédicat", "objet"),

22. ( 0, 1, 2 ),

23. ( 0, 1, 2 ),

24. ( 0, 1, 2 ),

25. ( 0, 1, 2 ),

26. ( 0, 1, 2 ),

27. ( 0, 1, 2 ),

28. ( 0, 1, 2 ),

29. ( 0, 1, 2 ),

30. ( 0, 1, 2 ),

31. ( 0, 1, 2 )

32. )

33. b = extraireCle( "prédicat", a )

34. fin = datetime.datetime.now()

35. print( "tuple-complexe :", fin-debut )

37. debut = datetime.datetime.now()

38. for i in range( 0, nbre ):

39. a = [

40. { "sujet": 0, "predicat": 1, "objet" : 2 },

41. { "sujet": 0, "predicat": 1, "objet" : 2 },

42. { "sujet": 0, "predicat": 1, "objet" : 2 },

43. { "sujet": 0, "predicat": 1, "objet" : 2 },

44. { "sujet": 0, "predicat": 1, "objet" : 2 },

45. { "sujet": 0, "predicat": 1, "objet" : 2 },

46. { "sujet": 0, "predicat": 1, "objet" : 2 },

47. { "sujet": 0, "predicat": 1, "objet" : 2 },

48. { "sujet": 0, "predicat": 1, "objet" : 2 },

49. { "sujet": 0, "predicat": 1, "objet" : 2 }

50. ]

51. b = [ item["predicat"] for item in a ]

52. fin = datetime.datetime.now()

53. print( "dict :", fin-debut )

Le retour de la console ne se fait pas attendre :

[1, 1, 1]
tuple-complexe : 0:00:01.843313
dict : 0:00:02.655625

L’utilisation d’un type est donc liée à un contexte et pas – du tout – à ses qualités propres !

Pour autant, nous utilisons dans notre code les dictionnaires, moins performants, mais plus lisibles.

IV - Bytes : ce n’est pas qu’une question de taille

Durant cet article, nous allons beaucoup, beaucoup utiliser des nombres et de la « représentation » de texte au format binaire. En réalité, techniquement, c’est l’inverse : ce que vous voyez à l’écran lorsqu’il s’agit de texte au format « Bytes » n’est pas du texte mais sa représentation (et Bytearray est tel Bytes mais mutable, c’est-à-dire modifiable après création).

Compliqué à suivre ? Pas vraiment, comme nous l’apprend l’IDLE :

1. >>> b"a" == "a"

2. False

3. >>> ord( b"a" )

4. 97

5. >>> bin(97)

6. '0b1100001'

7. >>> type(bin(97))

8. <class 'str'>

9. >>> int( '0b1100001', 2 )

10. 97

11. >>> chr( int( "0b1100001", 2 ) )

12. 'a'

13. >>> "a".encode( "utf-8" )

14. b'a'

Et pour en rajouter une couche :

>>> texte = b"a"
>>> chr( int( bin( ord( texte ) ), 2 ) ).encode( "utf-8" ) == texte
True

Vous n’avez rien suivi : c’est normal. Une même information - la lettre « a » de type str en Python, la variable bytes b"a" et le chiffre 97 – est représentée différemment. Du moins ce que l’humain en comprend. Mais en programmation, ce n’est pas la même chose. Pas du tout : c’est un problème.

Tentons de résumer – les puristes me pardonneront les imprécisions :

- 97 en Python est un entier (un int) ;

- le nombre 97 dans la table d’encodage UTF-8 est le signe « a » ;

- l’UTF-8 en Python est de type bytes ;

- la lettre « a » en Python est une string.

Ainsi, je peux avoir un entier et je m’en sers pour d’indice dans une table d’encodage pour obtenir un signe. Ce signe, je peux tenter de le transformer en chaîne de caractères au sein de Python. Puis refaire la chaîne inverse.

La bonne nouvelle, c’est que ça permet des trucs rigolos :

1. >>> ( int.from_bytes( b"a", "big" ) + 1 ).to_bytes( 1, "big" )

2. b'b'

3. >>> ( int.from_bytes( b"a", "big" ) + 2 ).to_bytes( 1, "big" )

4. b'c'

5. >>> ( int.from_bytes( b"a", "big" ) + 3 ).to_bytes( 1, "big" )

6. b'd'

IV-A - La taille, toujours…

C’est rigolo, mais ça conduit à des erreurs parfois, si l’on ne prend pas garde à quelques précautions.

Cela permet surtout, parfois, de réduire la taille occupée par une information – attention, je parle bien d’information et non de la donnée utilisée par la machine.

Par exemple, la valeur 12 équivaut à « 12 » en entier chez Python, à « 0xc » en hexadécimal et à « 0b1100 » en binaire.

Si l’on compare la « charge utile » de ces représentations, nous avons donc en termes de signes utilisés :

- (entier) 12 → 2

- (hexadécimal) c → 1

- (binaire) 1100 → 4

Ainsi stocker dans un fichier la valeur 12, prend plus ou moins de place suivant sous quelle forme on le stocke. Mais c’est parce que vous êtes humain ! Pour la machine, la place est rigoureusement la même…

1. >>> bin( 12 ) # pour la démonstration

2. '0b1100'

3. >>> bin( ord( "c" )  ) # = 99

4. '0b1100011'

5. >>> "0b1100" # identique évidemment, à l’entier 12

6. '0b1100'

Vous allez me dire : c’est n’importe quoi ton truc, l’hexadécimal prend 7 bits et les deux autres sont identiques avec 4 bits ! Certes. Mais ramener à un octet et non pas aux seuls bits nécessaires, en remplissant de 0 les bits manquants, la taille occupée est rigoureusement la même : '0b00001100' ou 0b01100011' sont équivalents à 1 octet.

Si l’on prend des valeurs plus élevées, on comprend rapidement la différence et combien cette problématique de « représentation » est importante :

1. >>> import uuid

2. >>> uuid.uuid4().hex

3. '205437d5007c4cc481b932057352ce6d'

4. >>> len('205437d5007c4cc481b932057352ce6d')

5. 32

6. >>> uuid.uuid4().int

7. 325085372164078301086762340833993592925

8. >>> len('325085372164078301086762340833993592925') # nbre signes utilisés pour représenter le nombre

9. 39

10. >>> (325085372164078301086762340833993592925).to_bytes( 15, "big" )

11. Traceback (most recent call last):

12. File "<pyshell#10>", line 1, in <module>

13. (325085372164078301086762340833993592925).to_bytes( 15, "big" )

14. OverflowError: int too big to convert

15. >>> (325085372164078301086762340833993592925).to_bytes( 16, "big" )

16. b'\xf4\x91*OY\xf7B\xd1\xa5\xe5\x8cf\xbb\xae\x90]'

17. >>> len(b'\xf4\x91*OY\xf7B\xd1\xa5\xe5\x8cf\xbb\xae\x90]')

18. 16

Ainsi une représentation d’une même information – ici un nombre très élevé, peu importe sa valeur exacte – peut sembler être moins importante en version hexadécimale (32 signes) au lieu d’un entier (39 signes). Cependant, la représentation « réelle » pour le programme, au niveau machine, est qu’une chaîne de caractères pour représenter un nombre sera toujours plus imposant en taille.

La conclusion de cette partie, c’est qu’il est préférable en termes d’efficience de stocker et garder en mémoire des nombres que des chaînes, et ne pas se fier simplement à un comptage « bête et méchant » des signes qui représente l’information, car souvent, nous sommes sur une représentation de cette information.

IV-B - Bytes : ne pas être confus mais efficace

Tout cela peut vous sembler peu pertinent, c’est pourquoi nous en arrivons à l’essence même : passer de la mémoire (RAM) au stockage (DD sous toutes ses formes).

Dans les deux cas, c’est le binaire qui est utilisé et pour cette série d’articles, nous travaillerons toujours sur la base d’un octet – c’est-à-dire 8 bits qui doivent être ensemble dans un certain ordre pour former l’information d’origine.

Le problème comme nous l’avons vu, c’est qu’une machine ne fait jamais que stocker et traiter des nombres – enfin quelque chose qui s’en approche pour elle. Là où des caractères s’affichent, c’est grâce à une convention – l’encodage. D’où l’usage de fonctions comme « ord » ou « chr » qui font le lien entre valeur et représentation attendue.

Lorsque vous êtes en train de travailler sur une information en RAM, celle-ci est connue, repérée, bornée. Sur Python, lorsque vous accédez à la variable « toto », vous ne vous occupez pas de savoir où elle est stockée : l’interpréteur s’en charge pour vous.

Dans un fichier « texte », lorsqu’il est lu, ce dernier récupère en réalité une valeur – un nombre sous forme octale – avec une correspondance. D’où l’usage fréquent de :

open( mon_fichier, "r", encoding="utf-8" )

… Vous indiquez que vous aller lire « signe par signe » (en réalité bloc par bloc, ici un bloc étant un octet) en fonction d’un certain encodage. Vous auriez pu aussi écrire :

open( mon_fichier, "rb" )

… Alors la lecture vous aurait retourné la même valeur – le contenu du fichier ne change pas – mais la représentation n’est pas la même ».

Dans un logiciel de bloc-notes (Notepad++ ou autre), écrivez une phrase et enregistrez le fichier. Par défaut, la plupart utilisent l’UTF-8 justement – cela nous ira très bien. Puis exécutez le code suivant pour y accéder :

1. with open( "./test.txt", "r") as f:

2. print( type( f ) )

4. with open( "./test.txt", "rb") as f:

5. print( type( f ) )

Surprise : la même fonction ne retourne pas le même type d’accès !

<class '_io.TextIOWrapper'>
<class '_io.BufferedReader'>

À la lecture, logiquement, vous n’avez pas le même retour – quand bien même vous avez exécuté a priori la même fonction de lecture :

1. with open( "./test.txt", "r") as f:

2. print( f.read() )

4. with open( "./test.txt", "rb") as f:

5. print( f.read() )

… retourne dans mon cas…

ceci est un texte
b'ceci est un texte'

Là encore, méfiez-vous : dans les deux cas, vous avez une représentation certes, nous l’avons déjà vu. Des représentations qui ne sont pas de même nature pour notre langage de serpent préféré. Mais cela va plus loin : Python et son interpréteur ne savent absolument pas « détecter » naturellement des portions ou des parties dans un fichier. Les sauts de lignes, les tabulations, etc. ne sont jamais que d’autres valeurs numériques auxquelles ont été fixés des signes d’imprimerie et qui permettent un affichage « humain ».

Rien dans le texte que le code retourne, n’est en réalité autre chose qu’une valeur numérique au format octal (j’insiste : RIEN) :

with open( "./test.txt", "r") as f:
print( [ ord( s ) for s in f.read() ]

… retourne…

[99, 101, 99, 105, 32, 101, 115, 116, 32, 117, 110, 32, 116, 101, 120, 116, 101]

Et sans surprise :

>>> [ i for i in b'ceci est un texte' ]
[99, 101, 99, 105, 32, 101, 115, 116, 32, 117, 110, 32, 116, 101, 120, 116, 101]

Le présupposé, c’est donc que la chaîne binaire proposée repose sur un encodage UTF-8 dans les deux cas. Bingo :

1. >>> ord("c") == int.from_bytes( b"c", "big")

2. True

3. >>> ord("c") == int.from_bytes( "c".encode("utf-8"), "big")

4. True

5. >>> b"c" == "c".encode("utf-8")

6. True

Vous aurez compris que chr( 32 ) retournerait un espace et chr( 101 ) un « e » !

Mais alors comment distinguer des parties dans ce qui revient à être une chaîne de nombres ? Deux solutions :

- celle, lisible, par l’être humain : par exemple le CSV. Vos séparateurs sont certains signes non-échappés, que le parseur va comprendre, car il traite la représentation qui lui est adressée. Dans le même esprit, avec plus de raffinement, vous avez XML ou JSON… pour récupérer une partie dans un contenu plus large, il faut donc « comprendre » le fichier par le système, ce qui est long et coûteux en ressources pour la machine ;

- utiliser un plan de données, c’est-à-dire une organisation qui ne se préoccupe pas de confondre le contenu avec l’organisation du contenu. C’est un avantage mais cela demande une grande justesse – et toute erreur fut-elle minime, peut « corrompre » un fichier et le rendre inexploitable.

Pour le stockage de nos triplets, nous allons utiliser la seconde solution.

IV-C - La carte aux trésors et des bits en plus des bytes

Dans les prochains exemples, nous utiliserons des « fichiers virtuels », c’est-à-dire des objets qui se comportent comme des fichiers réguliers mais qui sont en réalité en RAM – pour des questions de praticité.

Un fichier vu par Python et bien d’autres langages de programmation, c’est avant tout une suite de valeurs numériques que l’on parcourt – parfois dans un sens – et parfois que l’on modifie. Comme un curseur dans un éditeur de texte, la position retourne la valeur à venir (c’est-à-dire qu’elle n’est pas lue). Cela se révèle pratique : une valeur numérique peut donc être stockée et représenter la taille du contenu à suivre. En somme, quelque chose qui se comporte ainsi :

1. import io

2. f = io.BytesIO()

3. contenu = "wahou c'est trop cool ! :)"

4. contenu = contenu.encode( "utf-8" )

5. taille = len( contenu )

6. taille = taille.to_bytes( 1, "big" )

7. f.write( taille )

8. f.write( contenu )

9. f.seek( 0 ) # retour au début du fichier ( 0 = premier signe = premier caractère)

10. print( "contenu du fichier :", f.read() )

11. f.seek( 0 )

12. taille = f.read( 1 )

13. t   aille = int.from_bytes( taille, "big" )

14. contenu = f.read( taille )

15. contenu = contenu.decode( "utf-8")

16. print( "contenu de la variable :", contenu )

Et là miracle, notre fichier est structuré et l’organisation ne se confond pas avec le contenu lors de la récupération :

contenu du fichier : b"\x1awahou c'est trop cool ! :)"
contenu de la variable : wahou c'est trop cool ! :)

Par contre, il y a des limites : la taille, codée sur des octets, précède toujours le contenu. Et il faut pouvoir savoir quand s’arrête la définition de la taille, du début du contenu.

Ainsi un octet a une valeur de 0 (pas de contenu) à 255 : soit un contenu de 255 « valeurs » ou octets, soit autant de signes possibles. Pour 2 octets par contre… la magie du binaire augmente fortement ce chiffre ! À 65 535 très précisément – soit 65 Ko. Pour trois octets, 16,7 Mo…

Voici une petite fonction personnelle pour vous aider :

1. >>> def taillePossible(i):

2. return int( "0b"+("1"*8*i), 2)

3. >>> print( taillePossible( 8 ) )

4. 18446744073709551615

Vous aurez noté que pour 8 octets, on peut stocker une taille de contenu de 18 446 pétaoctets…

Vous pouvez également avoir une version améliorée, très utilisée pour les Web Sockets par exempleet qui permet de ne pas savoir à l’avance la taille en octets du nombre à récupérer. Il faut jouer sur le bit le plus fort (celui utilisé pour stocker le signe du nombre, positif ou négatif) qui devient une sorte de consigne pour continuer ou arrêter la lecture du prochain octet comme faisant partie du nombre en cours.

Ce qui tombe bien, c’est que pour Python, un entier est en soi une représentation d’un octet valable (ou plusieurs si l’entier est grand, négatif…). C’est-à-dire que l’on applique les opérations booléennes à un entier – et voir le résultat se transformer. Ce qui veut dire que les opérations s’appliquent entre deux entiers via les bits qui sont concernés.

petit rappel avant tout : pour 8 bits, la plage de valeurs par bit est 1 ; 2 ; 4 ; 8 ; 16 ; 32 ; 64 ; et enfin 128. Soit un total de 255 == 1+2+4+8+16+32+64+128.

1. >>> 0 ^ 128 # ou

2. 128

3. >>> 128 ^ 64

4. 192

5. >>> bin( 0 )

6. '0b0'

7. >>> bin( 128 )

8. '0b10000000'

9. >>> bin( 64 )

10. '0b1000000'

11. >>> bin( 192 )

12. '0b11000000'

1. >>> 192 & 64 # et

2. 64

3. >>> 192 & 32

4. 0

5. >>> (192 & 64) == 64

6. True

7. >>> (192 & 32) == 32

8. False

1. >>> ( 0 << 1 ) # décalage de bits

2. 0

3. >>> ( 1 << 1 )

4. 2

5. >>> ( 64 << 1 )

6. 128

Nous pouvons donc avec un parcours naïf, récupérer une valeur dont on ne connaît pas la taille de définition (c’est-à-dire le nombre d’octets utilisés pour stocker la valeur).

1. import random

3. valeurs = []

4. for i in range(0, 10):

5. valeurs.append( random.randint(0, 255) )

7. print( valeurs )

9. _valeursPertinentes = [ "0b" ]

10. for v in valeurs:

11. if v > 128: # on continue : le bit de poids élevé vaut Vrai

12. _valeursPertinentes.append( bin( v )[3:] )

13. else: # on s'arrête : le bit de poids élevé vaut Faut

14. break

16. try:

17. print( _valeursPertinentes )

19. valeurFinale = int( "".join( _valeursPertinentes ), 2)

21. print( valeurFinale )

22. except ValueError:

23. print("pas assez de valeurs en entrée pour calculer - le hasard a mal fait la chose !")

Vous donnera un résultat aléatoire qui pourrait être :

[183, 171, 185, 187, 255, 53, 154, 181, 86, 189]
['0b', '0110111', '0101011', '0111001', '0111011', '1111111']
14855069183

Il a fallu 5 octets pour stocker l’entier 14 855 069 183 – et nous ne le savions pas au début.

Pour la fonction inverse, pour le même nombre, le code peut être le suivant :

1. valeurInitiale = 14855069183

3. representation = bin( valeurInitiale )[2:]

5. print( representation )

7. modulo = len( representation ) % 7

8. tailleNecessaire = ( len( representation ) - modulo ) / 7

9. tailleNecessaire = int( tailleNecessaire ) # pas de float !

10. if modulo > 0:

11. tailleNecessaire + 1

13. valeurs = []

14. for i in range( 0, tailleNecessaire ):

15. if i==(tailleNecessaire-1):

16. _v = "0b0"+representation[i*7:(i*7)+7]

17. else:

18. _v = "0b1"+representation[i*7:(i*7)+7]

19. valeurs.append( int( _v, 2) )

21. dernier = valeurs.pop()

23. print( valeurs )

Les plus observateurs auront noté tout de même quelque chose d’étrange : la production de la valeur finale a pourtant pris cinq valeurs initiales alors que l’opération en sens inverse, n’en produit que trois ?!

Le code n’est pas faux et s’explique très bien : les valeurs au hasard n’utilisaient pas pleinement tous les bits de chaque octet comme nous le faisons avec le second code. Dit autrement, la valeur finale – 14 855 069 183 – est produite par les deux l’utilisation des valeurs suivantes avec cette méthode :

- [183, 171, 185, 187, 255]

- [238, 214, 242]

… C’est dire si, parfois, il faut se méfier des apparences et que décidément, tout est une affaire de nombres représentés… et finalement du poids relatif des bits.

V - La création de notre premier silo

Bien, nous avons vu les principales généralités à connaître. Avant de l’accoupler à un moteur HTTP avec REST, tentons d’avoir déjà les principales commandes avec une classe qui fonctionne localement.

Je ne vais pas vous faire attendre : voici la proposition…

1. import asyncio

2. import io

3. import uuid

5. class Silo:

7. index = {

8. "actif" : 1,

9. "groupe" : 16,

10. "uid" : 16,

11. "taille" : 4

12. }

14. enregistrement = {

15. "sujet" : 3,

16. "predicat" : 3,

17. "objet" : 3

18. }

20. def __init__(self, f):

21. self.f = f

22. self.f.seek( 0 )

23. self.indexTaille = 0

24. for cle in self.index:

25. self.indexTaille += self.index[cle]

27. async def ajouter(self, enregistrement, groupe=0, uid=0, f=None):

28. if f is None:

29. f = self.f

30. parties = {}

31. tailleEnreg = 0

32. for cle in self.enregistrement:

33. partie = enregistrement[cle].encode( "utf-8" )

34. parties[cle] = (

35. len( partie ).to_bytes( self.enregistrement[cle], "big" ),

36. partie

37. )

38. tailleEnreg += len( partie )+self.enregistrement[cle]

39. index = bytearray()

40. index += ( 1 ).to_bytes( self.index["actif"], "big" )

41. index += groupe.to_bytes( self.index["groupe"], "big" )

42. index += uid.to_bytes( self.index["uid"], "big" )

43. index += tailleEnreg.to_bytes( self.index["taille"], "big" )

44. f.seek( 0, 2 )

45. f.write( index )

46. for cle in self.enregistrement:

47. f.write( parties[cle][0] )

48. f.write( parties[cle][1] )

49. f.flush()

50. return True

52. async def supprimer(self, groupe=None, uid=None, filtrer=None):

53. i = 0

54. for positionIndex, enregistrement in await self.trouver(

55. groupe = groupe,

56. uid = uid,

57. filtrer = filtrer

58. ):

59. self.f.seek( positionIndex )

60. self.f.write( bytearray(1) )

61. i += 1

62. return i

64. async def trouver(self, groupe=None, uid=None, filtrer=None):

65. self.f.seek( 0 )

66. r = []

67. while True:

68. enregistrement = await self.suivant(

69. groupe = groupe,

70. uid = uid

71. )

72. if enregistrement is None:

73. break

74. elif enregistrement is False:

75. continue

76. else:

77. positionIndex, index, enregistrement = enregistrement

78. if filtrer is not None:

79. if not await filter( enregistrement ):

80. continue

81. r.append( ( positionIndex, enregistrement ) )

82. return r

84. async def suivant(self, groupe=None, uid=None, filtreActif=True):

85. positionIndex = self.f.tell()

86. portion = self.f.read( self.indexTaille )

87. if portion==b"":

88. return None

89. index = {}

90. position = 0

91. for cle in self.index:

92. index[cle] = int.from_bytes(

93. portion[position:position+self.index[cle]],

94. "big"

95. )

96. position += self.index[cle]

97. c = True

98. if filtreActif is True:

99. if index["actif"] == 0:

100. c = False

101. if groupe is not None:

102. if index["groupe"] != groupe:

103. c = False

104. if uid is not None:

105. if index["uid"] != uid:

106. c = False

107. if c is False:

108. self.f.seek( positionIndex+self.indexTaille+index["taille"] )

109. return False

110. partiesBin = self.f.read( index["taille"] )

111. parties = {}

112. position = 0

113. for cle in self.enregistrement:

114. taillePartie = int.from_bytes(

115. partiesBin[position:position+self.enregistrement[cle]],

116. "big"

117. )

118. position += self.enregistrement[cle]

119. parties[cle] = partiesBin[position:position+taillePartie].decode( "utf-8" )

120. position += taillePartie

121. return ( positionIndex, index, parties )

123. async def modifier(self, enregistrement, groupe=None, uid=None):

124. r = await self.trouver( groupe = groupe, uid = uid )

125. if len( r )==0:

126. return False

127. positionIndex, parties = r[0]

128. for cle in enregistrement:

129. parties[cle] = enregistrement[cle]

130. r = await self.supprimer( groupe = groupe, uid = uid )

131. if r == 0:

132. return False

133. await self.ajouter( enregistrement = parties, groupe = groupe, uid = uid )

134. return True

136. async def optimiser(self, fDestination=None):

137. fDestination.seek( 0 )

138. self.f.seek( 0 )

139. i = 0

140. while True:

141. r = await self.suivant()

142. if r is None:

143. break

144. elif r is False:

145. continue

146. else:

147. positionIndex, index, enregistrement = r

148. await self.ajouter(

149. enregistrement = enregistrement,

150. groupe = index["groupe"],

151. uid = index["uid"],

152. f = fDestination

153. )

154. i += 1

155. self.f = fDestination

156. return i

158. async def statistiquer(self):

159. self.f.seek( 0 )

160. inactifs = 0

161. actifs = 0

162. tailleCharge = 0

163. while True:

164. r = await self.suivant( filtreActif=False )

165. if r is None:

166. break

167. elif r is False:

168. continue

169. else:

170. positionIndex, index, enregistrement = r

171. tailleCharge += index["taille"]

172. if index["actif"] == 0:

173. inactifs += 1

174. else:

175. actifs += 1

176. return actifs, inactifs, tailleCharge

179. async def main():

180. f1 = io.BytesIO()

181. s = Silo( f1 )

182. groupe = uuid.uuid4().int

183. uid1 = uuid.uuid4().int

184. uid2 = uuid.uuid4().int

185. uid3 = uuid.uuid4().int

186. uid4 = uuid.uuid4().int

187. await s.ajouter(

188. enregistrement = {

189. "sujet" : "pouetpouet",

190. "predicat" : "truc",

191. "objet" : "machin"

192. },

193. groupe = groupe,

194. uid = uid1

195. )

196. await s.ajouter(

197. enregistrement = {

198. "sujet" : "1",

199. "predicat" : "2",

200. "objet" : "3"

201. },

202. groupe = groupe,

203. uid = uid2

204. )

205. await s.ajouter(

206. enregistrement = {

207. "sujet" : "#Julien",

208. "predicat" : "est",

209. "objet" : "rdf:Personne"

210. },

211. groupe = groupe,

212. uid = uid3

213. )

214. await s.supprimer( groupe = groupe, uid = uid2 )

215. await s.ajouter(

216. enregistrement = {

217. "sujet" : "fjdfljds",

218. "predicat" : "manque d'imagination",

219. "objet" : "²&é\"'(-è_çà)='"

220. },

221. groupe = groupe,

222. uid = uid4

223. )

224. await s.modifier(

225. enregistrement = {

226. "sujet" : "je",

227. "objet" : "parfois"

228. },

229. groupe = groupe,

230. uid = uid4

231. )

232. r = await s.trouver( groupe = groupe )

233. print( "total de ", len( r ), " triplets -(détails)> ", r )

234. nbreActifs, nbreInactifs, tailleEstimee = await s.statistiquer()

235. ratio = nbreActifs / (nbreActifs+nbreInactifs)

236. print(

237. "\n-- Informations: \n\tactifs:", nbreActifs,

238. "\n\tinactifs:", nbreInactifs,

239. "\n\tratio:", ratio,

240. "\n\ttaille de la charge utile:", tailleEstimee,

241. "\n--- --- ---\n"

242. )

243. if ratio < 0.75:

244. f2 = io.BytesIO()

245. await s.optimiser( f2 )

246. r = await s.trouver( groupe = groupe )

247. print( "total de ", len( r ), " triplets -(détails)> ", r )

248. nbreActifs, nbreInactifs, tailleEstimee = await s.statistiquer()

249. ratio = nbreActifs / (nbreActifs+nbreInactifs)

250. print(

251. "\n-- Informations: \n\tactifs:", nbreActifs,

252. "\n\tinactifs:", nbreInactifs,

253. "\n\tratio:", ratio,

254. "\n\ttaille de la charge utile:", tailleEstimee,

255. "\n--- --- ---\n"

256. )

257. print("--- Finalement : ")

258. print("taille de f1 =", f1.getbuffer().nbytes)

259. print("taille de f2 =", f2.getbuffer().nbytes)

260. print("---")

263. asyncio.run( main() )

V-A - Commençons le commentaire…

Dans cette classe, j’ai séparé ce qui tient de l’index et de ce qui tient de l’enregistrement. Cette séparation n’est pas seulement esthétique : l’index a une taille fixe (ici 37 octets) et précède le contenu. Le contenu a ensuite une organisation particulière, ici avec trois parties pouvant être de taille maximale identique. Cette « complexité » d’organisation est largement suffisant pour du stockage de triplets.

Si vous souhaitez parcourir rapidement le fichier, vous devez donc lire les 37 premiers octets, les comprendre et trouver la taille du contenu, puis aller directement lire à partir de l’octet suivant cette même taille. En somme, un enregistrement « complet » (l’index et l’enregistrement lui-même) fait 37 + la taille de l’enregistrement.

Sans lecture ET compréhension de l’index, nous ne pourrions pas déterminer la position de l’index suivant. Ce choix n’est pas nécessairement le meilleur, surtout si votre index est composé de nombreux champs (date, type, etc.), et est beaucoup utilisé. Grâce à une taille fixe, dans un fichier séparé du contenu, vous pouvez ainsi naviguer en sautant de taille d’index en taille d’index. Dans notre cas, ce serait de 37 octets en 37 octets.

Si le fichier d’index et de contenu sont différents, alors l’index doit indiquer non seulement la taille du contenu, mais aussi sa position de départ au sein du fichier de contenu.

Dans le fichier de contenu lui-même, ces derniers n’ont aucune organisation et sont ajoutés les uns à la suite des autres : on retrouve l’organisation grâce à l’index.

V-B - Ajouter, c’est facile… le reste moins

Ajouter du contenu dans un fichier, binaire ou non, c’est facile : le fichier s’étend par miracle (l’OS se charge de tout !). Le modifier presque tout autant : avec le couple seek et tell, vous pouvez vous déplacer puis écrire « par dessus » (en réalité, vous ne faites que modifier la valeur de l’octet, d’où le terme « d’écrasement »). Par exemple, si l’on représente un fichier sous la forme d’une suite de valeurs :

abcdefghi # = 9

Mais… lorsque vous voulez modifier au même endroit, le « z » par « xyz », c’est la catastrophe :

abcxyzghi # = 9

La taille ne change pas ! Nous ne sommes pas dans un éditeur de texte. L’écrasement (le changement de valeurs) se poursuit. En somme, votre fichier peut grandir (ajout), réduire (toujours par la fin, sous certaines conditions, avec truncate), être écrit (modifié). Mais il n’y a pas de garantie pour avoir ces opérations combinées entre elles afin de former un comportement en particulier (l’insertion et non l’écrasement par exemple).

Dans le code du silo, je vous propose d’utiliser dans l’index, un octet qui a la valeur 0 ou 1 – soit respectivement inactif ou actif. Lorsque la modification est demandée, il suffit de rendre inactif l’enregistrement actuel (soit l’écrasement d’un seul octet), puis de réécrire son contenu modifié comme un nouvel enregistrement. Ainsi le problème de la taille de la modification disparaît, car le fichier est agrandi.

Ce n’est pas sans incidence : beaucoup de modifications donnent des tailles de fichiers impressionnantes, avec énormément de pertes : les enregistrements finissent par être majoritairement marqués comme inactifs. La fonction d’optimisation est là pour cela : elle réécrit, si l’on dépasse un certain seuil calculé avec une banale fonction de comptages (« statistiquer »), dans un nouveau fichier seulement les enregistrements actifs.

Dans un vrai moteur de bases de données évidemment, il faudrait d’abord comparer la taille de modifications, voire si l’on peut effectuer la modification sans incidence sur la taille. Mais attention : ce qui est vrai pour une modification « en plus » l’est aussi « en moins ». Un contenu moins grand que la taille prévue… Et vous risquez d’avoir la lecture de votre prochain index à la mauvaise position.

Là encore une solution existe : plutôt que de stocker dans l’index le couple position et taille, vous pouvez avoir le couple position du contenu actuel, puis position de l’index du contenu suivant avec la taille – histoire de prévenir tout désordre si votre modification est « en moins ». Mais elle prévient pas celle « en plus » et oblige à avoir une vérification et encore une combinaison d’opérations…

Cependant, avec la relative efficacité en vitesse de lecture et d’écriture des SDD, il est parfois plus simple à coder et à maintenir, un système comme la solution du code proposée : marquer inactif + réécrire avec les modifications. Appelons là la solution du « moins pire compromis ». Ce n’est pas le meilleur, mais c’est celui qui retient le moins de défauts en gardant en mémoire que l’intérêt est pédagogique (donc de la clarté).

Une autre solution est de « décaler » le reste du fichier (et « couper » un éventuel excédent de place totale), pour ajouter ou retirer du contenu à un enregistrement dont la taille varie – mais imaginez si à chaque modification, vous devez réécrire des centaines de Go… à chaque instant ou presque ?

Pour la solution du « moins pire compromis », j’y vois aussi un mérite : la réécriture facilite l’archivage et pas seulement l’efficacité même du stockage. En effet, lorsque vous réécrivez pour optimiser la taille de fichier qui était alors en production, vous libérez ce même fichier à la fin du processus – il sera lourd certes, mais complet. Vous pouvez donc le réécrire une seconde fois, dans un autre processus et hors de la production, pour avoir un fichier de sauvegarde vertueux. Ou simplement le garder en l’état si vos indications de suppression ont de l’importance pour vous.

Si vous préférez un stockage en RAM – un peu comme le propose avec le système clés/valeurs Redis –, des listes de tuples en Python restent probablement une meilleure option que d’utiliser des fonctions de lectures et d’écritures sur des fichiers « virtuels ».

V-C - Et les droits ?

C’est une question épineuse. En soi un moteur de base de données reçoit des commandes et les exécute. Dans le premier article de la série, j’ai spécialisé mes classes. Je reste sur cette logique : le code que je propose pour votre étude se contente d’un UID pour chaque enregistrement (mais il peut rester à 0 : il est donc facultatif) ainsi qu’un « groupe ». Charge à vous de faire le rapprochement entre un utilisateur ou plusieurs utilisateurs et un groupe… c’est à ce moment-là que l’authentification devrait se faire, et non au moment de l’exécution de la commande. Avec les questions subsidiaires : autoriser ou non une modification, un ajout ou encore un utilisateur à utiliser un ou plusieurs groupes…

En réalité, la question des droits rejoint plus précisément celui d’un éventuel langage de requête comme MySQL. Une fois identifié, vous disposez de droits. Mais vous pouvez ne pas l’être et en avoir quand même (des différents).

Nous verrons dans le troisième article concernant précisément RDF, que le langage de requête pour les triplets et les triplestores est Sparql.

Lorsqu’un client (et l’utilisateur derrière – identifié ou non) envoie sa requête, nous pouvons imaginer que c’est le moment de vérifier ses droits donc, et de préparer une commande que le moteur de base de données puisse comprendre – notamment vérifier éventuellement la cohérence des données et/ou la validité de la syntaxe de la commande.

V-D - Tout peut être amélioré – au prix (souvent) de la lisibilité…

… et la lisibilité est le début du chemin de croix de la maintenabilité.

Intéressons-nous à la fonction « ajouter ». Celle-ci a une contrainte : pour connaître précisément et écrire dans le fichier, elle a besoin de la taille « réelle » de l’enregistrement (c’est-à-dire le contenu et la taille de chaque partie du contenu). Très logiquement, j’utilise une variable pour temporiser l’index et les parties qui forment le contenu, avant d’écrire dans l’ordre l’enregistrement complet.

Cependant ce n’est pas très efficace : je dois doubler en mémoire la taille de tout le contenu de l’enregistrement (un type str et une chaîne binaire encodée en UTF-8).

Par ailleurs et pour mémoire, une opération d’encodage/de décodage prend très peu de temps et de ressources :

1. import datetime

3. nbre = 1000000

5. debut = datetime.datetime.now()

6. for i in range( 0, nbre ):

7. a = "coucou".encode( "utf-8" )

8. fin = datetime.datetime.now()

9. print( "encode :", fin-debut )

11. debut = datetime.datetime.now()

12. for i in range( 0, nbre ):

13. a = b"coucou".decode( "utf-8" )

14. fin = datetime.datetime.now()

15. print( "décode :", fin-debut )

17. debut = datetime.datetime.now()

18. for i in range( 0, nbre ):

19. a = ("coucou".encode( "utf-8" )).decode( "utf-8" )

20. fin = datetime.datetime.now()

21. print( "mixte :", fin-debut )

1. encode : 0:00:00.296841

2. décode : 0:00:00.343635

3. mixte : 0:00:00.546781

Alors n’y a-t-il pas un moyen plus ingénieux que ce double stockage ?

Eh bien oui, il y a :

1. async def ajouter(self, enregistrement, groupe=0, uid=0, f=None):

2. if f is None:

3. f = self.f

4. f.seek( 0, 2 )

5. f.write( bytes( self.indexTaille ) )

6. tailleEnreg = 0

7. for cle in self.enregistrement:

8. f.write( bytes( self.enregistrement[cle] ) )

9. if type(enregistrement[cle]) == str:

10. taillePartie = f.write( enregistrement[cle].encode( "utf-8" ) )

11. else:

12. taillePartie = f.write( enregistrement[cle] )

13. f.seek( 0-taillePartie-self.enregistrement[cle], 2 )

14. f.write( taillePartie.to_bytes( self.enregistrement[cle], "big" ) )

15. f.seek( f.tell()+taillePartie )

16. tailleEnreg += taillePartie+self.enregistrement[cle]

17. _index = ( 1, groupe, uid, tailleEnreg )

18. f.seek( 0-tailleEnreg-self.indexTaille, 2 )

19. for i, cle in enumerate( self.index ):

20. f.write( _index[i].to_bytes( self.index[cle], "big" ) )

21. f.flush()

22. return True

Plutôt que d’attendre d’avoir toutes les informations, je vais écrire dans le fichier une chaîne de valeurs nulle mais qui correspond à la taille de mon index. Cette taille-là est fixe, je la connais donc sans attendre. Puis j’écris les parties de l’enregistrement un à un – pas la peine de tous les avoir en mémoire doublés en même temps.

Pour chaque partie de l’enregistrement, je peux utiliser cette technique qui est en quelque sorte une « pré-allocation » dans le fichier. Cette technique, plus rapide et moins coûteuse en mémoire, a cependant le défaut d’agir davantage sur la position du curseur dans le fichier… ce n’est pas sans risque si un problème survient et que la fonction échoue : des données partielles peuvent avoir été écrites.

V-E - « Aie, ma fonction d’écriture a échoué ! » ou la délicate gestion d’erreurs

Un imprévu, une erreur… Bref l’écriture, quelle que soit la quantité de données à écrire, échoue. Le problème n’est pas la récupération de l’erreur (Python le gérera très bien si vous utilisez try/except) mais préserver la cohérence du fichier. Comme vous l’avez vu, le moindre problème non géré et l’organisation du fichier « fout » le camp. Évidemment, vous avez la possibilité de relire le fichier depuis le début et tenter une « réparation » - mais c’est complexe et pas toujours possible.

Pire : c’est parfois une erreur « définitive » : si le disque est plein, vous aurez beau tenter, ça ne fonctionnera pas jusqu’à libération de l’espace suffisant. L’idéal est de ne pas tenter une opération qui de toute façon n’aboutira pas et renvoyer, dès réception d’une commande, un message signalant l’impossibilité de l’opération.

Cependant, comment faire concrètement ? Tout d’abord gérer correctement ses erreurs : dès que vous allez commencer une opération d’écriture (pour ajouter ou modifier des données), pensez à tester si la variable d’accès au fichier ne retourne pas une valeur incorrecte ou nulle. C’est facile : faites un tell sur la fin actuelle du fichier par exemple (l’opération dans un contexte normal, ne retourne jamais une erreur), si vous êtes sur un ajout. Puis lancer au sein de la fonction de test, la fonction d’ajout et contrôlez son fonctionnement (try/except/else).

Si cet ajout échoue, alors vous pourrez grâce à la gestion de l’exception, tenter de supprimer des données éventuellement écrites qui ne seraient pas valides. Vous penserez également à laisser éventuellement un « état » à votre silo : est-il disponible seulement pour la lecture mais pas à l’écriture par exemple (et le pourquoi : paramètre de départ, disque plein, problème de droit, une erreur temporaire du programme, etc.).

Ce sont toutes ces petites réflexions, qui forment progressivement la qualité et permettent le respect des critères ACID.

V-F - Et les filtres dans tout ça ?

Probablement avez-vous déjà vu un paramètre à certaines fonctions - « filtres » - qui correspond à la possibilité de ne pas garder certains résultats. Nous verrons lors de l’intégration de ce code à notre serveur qu’il s’agit en réalité d’un croisement entre :

- des triplets qui, pris par « paquet », seront soumis à un test ;

- des requêtes qui, provenant de chaque client, formeront le test. C’est ici ce que j’ai appelé des « motifs » (ici un motif = une requête, c’est-à-dire la commande en lecture seule d’un client).

Le croisement positif entre une requête et un triplet permet de valider l’envoi de ce même triplet au client d’où provient la requête. Ce croisement peut être l’utilisation des expressions régulières, d’une comparaison de texte, etc.

En attendant d’avoir une fonction de renvoi – qui sera en réalité l’écriture sur le socket concerné, produisons la boucle de test qui pourrait être utilisée :

1. import re

2. import datetime

4. nbre = 1000000

6. motifs = (

7. (

8. "<http://www.polymtl.ca/profs#MichelGagnon>",

9. None,

10. None

11. ),

12. (

13. "<http://www.polymtl.ca/profs#MichelGagnon>",

14. re.compile( "\#worksAt\>?$", re.I ),

15. None

16. ),

17. )

19. triplets = (

20. (

21. "<http://www.polymtl.ca/profs#MichelGagnon>",

22. "<http://www.polymtl.ca/vocab#hasHomePage>",

23. "<http://www.professeurs.polymtl.ca/michel.gagnon>"

24. ),

25. (

26. "<http://www.polymtl.ca/profs#MichelGagnon>",

27. "<http://www.polymtl.ca/vocab#worksAt>",

28. "<http://www.dgi.polymtl.ca>"

29. ),

30. (

31. "<http://www.polymtl.ca/profs#MichelGagnon>",

32. "<http://www.polymtl.ca/vocab#name>",

33. '"Michel Gagnon"'

34. )

35. )

37. def tester(motifs, triplets):

38. reponses = []

39. for motif in motifs:

40. reponse = []

41. for triplet in triplets:

42. for t, m in zip( triplet, motif ):

43. if m is None:

44. continue

45. elif type(m)==str:

46. if t != m:

47. break

48. elif isinstance( m, re.Pattern ):

49. if m.search( t ) is None:

50. break

51. else:

52. reponse.append( triplet )

53. reponses.append( list( reponse ) )

54. return list( reponses )

57. debut = datetime.datetime.now()

58. for i in range( 0, nbre ):

59. tester( motifs, triplets )

60. fin = datetime.datetime.now()

61. print( "tester :", fin-debut )

Pour deux clients, trois triplets et un million d’itérations (soit 6 millions de tests au final), nous arrivons à un chiffre qui semble assez catastrophique de près de 10 secondes sur mon poste – et 7,3 secondes si je n’utilise que la comparaison de texte et aucune expression régulière. Vous l’aurez compris, avec le même poste depuis le début de la rédaction de cette série d’articles, c’est le filtrage (et son corollaire le tri) qui est le plus chronophage de toutes les étapes. En tout cas cette forme.

Face à cela, de nombreuses solutions peuvent existent et dépendent de la qualité de la programmation ; de la liberté laissée à utiliser telle ou telle possibilité au sein du moteur de base de données ; de la puissance de la machine et de la répartition du travail. C’est ce dernier point qui nous occupe : le problème du tri ici, peut être l’absence de répartition de la charge entre plusieurs « travailleurs ».

Cette répartition peut prendre plusieurs formes (multiprocessus, threads, embauche de stagiaires supplémentaires, etc.), mais testons-en deux ici :

- soit une série de threads, mais il faudrait veiller à synchroniser les résultats entre la partie asynchrone du serveur Web et la partie synchrone au sein du thread ;

- soit en utilisant une série de fonctions en concurrence grâce à asyncio, permettant d’utiliser la boucle générale du serveur mais celle-ci pourrait se retrouver fortement saturée.

Testons d’une manière naïve ces deux familles :

1. import re

2. import datetime

4. nbre = 1000000

6. motifs = (

7. (

8. "<http://www.polymtl.ca/profs#MichelGagnon>",

9. None,

10. None

11. ),

12. (

13. "<http://www.polymtl.ca/profs#MichelGagnon>",

14. re.compile( "\#worksAt\>?$", re.I ),

15. None

16. ),

17. )

19. triplets = (

20. (

21. "<http://www.polymtl.ca/profs#MichelGagnon>",

22. "<http://www.polymtl.ca/vocab#hasHomePage>",

23. "<http://www.professeurs.polymtl.ca/michel.gagnon>"

24. ),

25. (

26. "<http://www.polymtl.ca/profs#MichelGagnon>",

27. "<http://www.polymtl.ca/vocab#worksAt>",

28. "<http://www.dgi.polymtl.ca>"

29. ),

30. (

31. "<http://www.polymtl.ca/profs#MichelGagnon>",

32. "<http://www.polymtl.ca/vocab#name>",

33. '"Michel Gagnon"'

34. )

35. )

37. def tester(motif, triplet):

38. for t, m in zip( triplet, motif ):

39. if m is None:

40. continue

41. elif type(m)==str:

42. if t != m:

43. return False

44. elif isinstance( m, re.Pattern ):

45. if m.search( t ) is None:

46. return False

47. else:

48. return True

50. def tester_thread_travailler_unique(motifs, triplets):

51. reponses = []

52. for motif in motifs:

53. reponse = []

54. for triplet in triplets:

55. if tester( motif, triplet ):

56. reponse.append( triplet )

57. reponses.append( reponse )

58. return reponses

60. def tester_thread_travailler(i, queue_a_traiter, verrou):

61. print("thread n°%s" % i)

62. y = 0

63. while verrou.is_set():

64. # je triche un peu... je ne garde pas les résultats !

65. try:

66. tester_thread_travailler_unique(

67. * queue_a_traiter.get( timeout = 0.1 )

68. )

69. y += 1

70. if y % 10013==0:

71. print("thread n°%s - pallier : %s" % ( i, y ) )

72. except queue.Empty:

73. time.sleep( 0.5 )

75. debut = datetime.datetime.now()

76. import time

77. import queue

78. import threading

79. queue_a_traiter = queue.Queue()

80. Threads = []

81. verrou = threading.Event()

82. verrou.set()

83. for i in range( 0, 25 ): # 25 travailleurs sans relâche, travaillent…

84. t = threading.Thread(

85. target = tester_thread_travailler,

86. args = ( i, queue_a_traiter, verrou )

87. )

88. t.start()

89. Threads.append( t )

90. for i in range( 0, nbre ):

91. queue_a_traiter.put(

92. (

93. motifs,

94. triplets

95. )

96. )

97. verrou.clear()

98. for t in Threads:

99. t.join()

100. fin = datetime.datetime.now()

101. print( "tester avec des threads :", fin-debut )

Les threads mettent 1 minute et 11 secondes – là où le code sans « optimisation » mettait une dizaine de secondes. Plusieurs facteurs expliquent ce résultat (piteux), dont l’échange de données vers les threads via la queue (qui utilise un verrou interne), peu optimisé pour de nombreux petits échanges ; ainsi que le GIL qui impose à l’interpréteur Python un travail supplémentaire (particulièrement vrai sous CPython).

Bref c’est moyen-moyen… Asyncio seul s’en sort mieux avec 17,2 secondes, mais sans être exceptionnel – loin de là. Normal, ils ne sont pas prévus pour optimiser un « calcul pur », ce qui est plus ou moins notre cas ici :

1. import re

2. import datetime

4. nbre = 1000000

6. motifs = (

7. (

8. "<http://www.polymtl.ca/profs#MichelGagnon>",

9. None,

10. None

11. ),

12. (

13. "<http://www.polymtl.ca/profs#MichelGagnon>",

14. re.compile( "\#worksAt\>?$", re.I ),

15. None

16. ),

17. )

19. triplets = (

20. (

21. "<http://www.polymtl.ca/profs#MichelGagnon>",

22. "<http://www.polymtl.ca/vocab#hasHomePage>",

23. "<http://www.professeurs.polymtl.ca/michel.gagnon>"

24. ),

25. (

26. "<http://www.polymtl.ca/profs#MichelGagnon>",

27. "<http://www.polymtl.ca/vocab#worksAt>",

28. "<http://www.dgi.polymtl.ca>"

29. ),

30. (

31. "<http://www.polymtl.ca/profs#MichelGagnon>",

32. "<http://www.polymtl.ca/vocab#name>",

33. '"Michel Gagnon"'

34. )

35. )

37. def tester(motif, triplet):

38. for t, m in zip( triplet, motif ):

39. if m is None:

40. continue

41. elif type(m)==str:

42. if t != m:

43. return False

44. elif isinstance( m, re.Pattern ):

45. if m.search( t ) is None:

46. return False

47. else:

48. return True

50. async def tester_async(motif, triplet):

51. return tester( motif, triplet )

53. debut = datetime.datetime.now()

54. import asyncio

55. async def travailleur(queue_a_traiter):

56. while True:

57. motifs, triplets = await queue_a_traiter.get()

58. resultats = []

59. for motif in motifs:

60. resultat = []

61. for triplet in triplets:

62. resultat.append(

63. await tester_async( motif, triplet )

64. )

65. resultats.append( resultat )

66. queue_a_traiter.task_done()

67. async def main(nbre):

68. queue_a_traiter = asyncio.Queue()

69. for i in range( 0, nbre ):

70. queue_a_traiter.put_nowait(

71. (

72. motifs,

73. triplets

74. )

75. )

76. taches_travailleurs = []

77. for i in range( 0, 5 ):

78. taches_travailleurs.append(

79. asyncio.create_task( travailleur( queue_a_traiter ) )

80. )

81. # on arrête pas les travailleurs formellement

82. # on attend simplement que la queue à traiter soit vide

83. await queue_a_traiter.join()

84. asyncio.run( main( nbre ) )

85. fin = datetime.datetime.now()

86. print( "tester avec asyncio :", fin-debut )

Finalement notre premier code, celui sans « optimisation » n’était pas si catastrophique. Malheureusement, nous ne pourrons l’utiliser en l’état : ce serait bloquer de manière indéterminée la boucle principale. Nous devons donc accepter une prévisible perte de performance au profit d’une absence de blocage et utiliser Asyncio.

Relativisons cependant cette perte : 17 secondes pour nos 6 millions de comparaisons, cela représente par opération, 2,8 secondes… exposant -6. J’ai abordé ici l’optimisation du tri sous une forme naïve. Python dispose d’autres outils et il faut parfois découper son code sur plusieurs processus, faire des tests… et boire beaucoup de café.

VI - Serveur et silo… enfin réunis

Ou presque : le plus dur est maintenant (surtout pour vous, lecteur !). Nous allons revoir de manière assez radicale l’attendu de l’exercice afin de rester dans une limite déjà honorable de taille de code.

Je vous fournis ici une version fonctionnelle ; cependant, je vous incite fortement à faire par vous-même la combinaison de fonctions et d’opérations que nous avons vues jusqu’à présent. C’est en forgeant que l’on devient forgeron…

Notre triplestore remplira désormais les conditions suivantes :

- pour chaque commande, il distingue une requête (lecture seule), d’une transaction (écriture possible) et s’organise en conséquence ;

- la possibilité de lire, écrire et supprimer des triplets via HTTP (sans modifier ceux existant) ;

- il permet d’envoyer une page HTML pour l’édition de requête suivant une URL particulière ;

- chaque type d’opération dispose par requête, d’un filtre et/ou d’un UID limitant ;

- les filtres peuvent accepter des expressions régulières ;

- un UID (comme formulé grâce à UUID4) dans notre triplestore, est un entier pouvant être stocké sur 16 octets maximum. Il permet de regrouper un ou plusieurs triplets dans un même ensemble, et s’échange au format hexadécimal ;

- il ne prend pas en charge l’authentification ni aucune notion de sécurité ;

- il n’utilisera pas la possibilité « chuncked » de HTTP 1.1 pour les réponses, mais l’envoi d’un bloc lorsque la requête est terminée ;

- il n’agira que sur un seul fichier (f = index + contenu) et non sur deux (f1 = index, f2 = contenu) – un enregistrement correspond donc à une partie index et une partie contenu.

Vous noterez que l’encodage des requêtes entrant n’est pas considéré : ainsi le serveur ne supporte que du contenu au format UTF-8.

Certaines fonctions (optimisation, groupe, etc.) ne seront donc pas traitées. Concernant les filtres, un tableau nous permet d’y voir plus clair et entre ceux s’appliquant à chaque item du triplet (appelé « Filtre » et l’UID d’un triplet) :

Filtre

UID

La requête s’applique à…

Non

Non

Tous les triplets sans limite

Non

Oui

Au(x) seul(s) triplet(s) pourvu(s) de l’UID demandé

Oui

Non

Tous les triplets du triplestore répondant au filtre

Oui

Oui

Le ou les triplets du triplestore répondant au filtre et disposant de l’UID demandé

Nous terminons ici cette seconde partie ; nous aurons l’occasion de décortiquer plus en détail le code proposé ci-dessous dans la troisième et dernière partie, introduisant l’intérêt de RDF.

VI-A - Quelle limite théorique en taille du silo ?

Vous aurez l’occasion de le voir dans le code suivant, notre silo est bien « physiquement » limité en taille. En effet, comme évoqué sur le passage sur les représentations, nous utilisons un nombre défini préalablement d’octets pour coder les index (taille de l’enregistrement, tailles internes à l’organisation de l’enregistrement…).

Au-delà, notre silo est un fichier – ni plus, ni moins. Sous NTFS par exemple, la valeur théorique d’un fichier est de 16 Tio – pour autant ce serait une erreur de tenter un tel silo à cette taille. Le parcours risquerait de prendre du temps (plus si vous utilisez des systèmes de fichier sur plusieurs disques). Pour le coup, la limite est aussi celle de l’efficience du système que nous concevons !

En cas de nombreuses données (plusieurs centaines de Go, il faudrait mieux le montage d’un entrepôt de données (c’est-à-dire le rassemblement de nombreux silos d’une manière plus ou moins transparente).

VI-B - Organisation des fichiers

Afin de s’y retrouver, il devient nécessaire de découper notre code en une multitude de parties :

VI-B-1 - main.py – ce qui lance le service

1. import asyncio

3. from Client import Client

4. from CommandeHTTP import CommandeHTTP

5. from Silo import Silo, EnregistrementComposition

7. async def connexion_acceptee(lecteur, ecrivain):

8. global ObjSilo

9. try:

10. c = Client(

11. ObjSilo,

12. lecteur,

13. ecrivain,

14. CommandeHTTP

15. )

16. await c.dialoguer()

17. except Exception as err:

18. print("exception non-gérée pour un client : ", err)

19. finally:

20. try:

21. ecrivain.close()

22. except Exception:

23. pass

25. async def lancement(classeSilos, fChemin):

26. global ObjSilo, Serveur

27. ObjSilo = classeSilos( fChemin )

28. Serveur = await asyncio.start_server(

29. connexion_acceptee,

30. '127.0.0.1',

31. 8888

32. )

33. print( "Service sur %s:%s " % Serveur.sockets[0].getsockname() )

34. async with Serveur:

35. ObjSilo.tache = asyncio.create_task( ObjSilo.executer() )

36. await Serveur.serve_forever()

38. if __name__=="__main__":

39. try:

40. m = lancement(

41. Silo,

42. "./silo.prod" # penser à le créer avant de démarrer le serveur

43. )

44. asyncio.run( m )

45. except KeyboardInterrupt:

46. pass

[Client.py]

1. __all__ = [ "Client", ]

3. class Client:

5. def __init__(self, silo, lecteur, ecrivain, formatDialogue):

6. self.silo = silo

7. self.lecteur = lecteur

8. self.ecrivain = ecrivain

9. self.formatDialogue = formatDialogue

11. async def recupereLigne(self):

12. return (

13. await self.lecteur.readline()

14. ).decode("utf-8").strip()

16. async def dialoguer(self):

17. commande = self.formatDialogue( self )

18. while True:

19. ligne = await self.recupereLigne()

20. if ligne=="":

21. break

22. if commande.traiterLigne( ligne ) is False:

23. return ( await commande.repondre( codeHTTP = 400, messageHTTP = "bad request" ) )

24. if ( await commande.traiterChemin() ) is False:

25. await commande.executer()

VI-B-2 - CommandeHTTP.py

1. import asyncio

2. import os

3. import re

4. import tempfile

6. from Filtre import FiltreHTTP

7. from Triplets import TripletsHTTP

8. from Silo import EnregistrementComposition

10. __all__ = [ "CommandeHTTP", ]

12. class CommandeHTTP:

14. tailleTampon = 1048576 # 1 Mo

16. ressources = {

17. re.compile(

18. "\/page\/?$"

19. ) : "./page.html" # l'outil de conception des requêtes

20. }

22. expReg_requete = re.compile(

23. "([a-z]+)\s+(.*)\s+HTTP\/1.[01]$",

24. re.I

25. )

26. expReg_entete = re.compile(

27. "([^\:]+)\s*\:\s*(.*)$",

28. re.I

29. )

31. sautLigne = "\n"

32. sautLigneBin = b"\n"

34. def __init__(self, client):

35. self.client = client

36. self.methode = None

37. self.chemin = None

38. self.entetes = {}

39. self.reponse = tempfile.SpooledTemporaryFile( self.tailleTampon )

40. self.corps = None

41. self.etat = None

42. self.uid = 0

44. def traiterLigne(self, ligne):

45. if self.methode is None:

46. r = self.expReg_requete.match( ligne )

47. if r is None:

48. return False

49. else:

50. self.methode, self.chemin = r.groups()

51. self.methode = self.methode.lower()

52. else:

53. r = self.expReg_entete.match( ligne )

54. if r is None:

55. return False

56. else:

57. cle, valeur = r.groups()

58. self.entetes[cle.lower()] = valeur

59. return True

61. async def traiterChemin(self):

62. if self.chemin is None:

63. return False

64. for rExpReg in self.ressources:

65. r = rExpReg.match( self.chemin )

66. if r is not None:

67. await self.repondreFichier( self.ressources[rExpReg], r )

68. return True

69. return False

71. async def repondreFichier(self, chemin, resultat):

72. if os.path.isfile( chemin ):

73. with open( chemin, "r", encoding="utf-8" ) as f:

74. await self.repondre(

75. fichier = f

76. )

77. else:

78. await self.repondre( codeHTTP = 404, messageHTTP = "bad ressource" )

79. return True

81. async def repondreLigne(self, ligne, saut=True):

82. if type(ligne)==str:

83. ligne = ligne.encode( "utf-8" )

84. if saut is True:

85. ligne = ligne+self.sautLigneBin

86. self.client.ecrivain.write(

87. ligne

88. )

90. async def repondre(self, codeHTTP=200, messageHTTP="OK", entetes={}, fichier=None, corpsType="text/html"):

91. if fichier is None:

92. fichier = self.reponse

93. fichier.seek( 0, 2 )

94. tailleReponse = fichier.tell()

95. await self.repondreLigne(

96. "HTTP/1.0 %s %s" % ( codeHTTP, messageHTTP )

97. )

98. for cle in entetes:

99. await self.repondreLigne(

100. "%s: %s" % ( cle, entetes[cle] )

101. )

102. if corpsType is not None:

103. await self.repondreLigne(

104. "content-type: %s; charset=UTF-8" % corpsType

105. )

106. if tailleReponse<0:

107. await self.repondreLigne(

108. "content-length: %s" % len( corps )

109. )

110. await self.repondreLigne( "" )

111. await self.repondreLigne( "" )

112. await self.client.ecrivain.drain()

113. i = 0

114. fichier.seek( 0 )

115. while True:

116. p = fichier.read( 64 )

117. if p==b"" or p=="":

118. break

119. await self.repondreLigne( p, saut=False )

120. if i%11==0:

121. await self.client.ecrivain.drain()

122. i += 1

123. await self.client.ecrivain.drain()

125. async def executer(self):

126. try:

127. if "uid" in self.entetes:

128. self.uid = int( self.entetes, 16 ) # hex -> int

129. if hasattr( self, "cmd_%s" % self.methode ):

130. if ( await getattr( self, "cmd_%s" % self.methode)() ) is not True:

131. await self.repondre()

132. else:

133. await self.repondre( codeHTTP = 400, messageHTTP = "bad request" )

134. except Exception:

135. await self.repondre( codeHTTP = 400, messageHTTP = "bad request" )

137. async def recupererCorps(self):

138. if "content-length" not in self.entetes:

139. return False

140. else:

141. taille = int( self.entetes["content-length"]  )

142. self.corps = ( await self.client.lecteur.read( taille ) )

143. return True

145. async def attendre(self):

146. self.enAttente = True

147. while self.enAttente:

148. await asyncio.sleep( 0.1 )

150. async def cmd_get(self):

151. self.client.silo.requetes.append(

152. self

153. )

154. try:

155. self.filtre = FiltreHTTP( self.entetes )

156. except Exception as err:

157. print("(err) CommandeHTTP->cmd:get :", err)

158. await self.attendre()

159. if self.etat is False:

160. await self.repondre( codeHTTP = 500, messageHTTP = "error during execution" )

161. else:

162. await self.repondre( corpsType = "text/triplet" )

163. return True

165. async def cmd_delete(self):

166. self.client.silo.transactions.append(

167. ( "suppression", self )

168. )

169. try:

170. self.filtre = FiltreHTTP( self.entetes )

171. except Exception as err:

172. print("(err) CommandeHTTP->cmd:delete :", err)

173. await self.attendre()

174. if self.etat is False:

175. await self.repondre( codeHTTP = 500, messageHTTP = "error during execution" )

176. return True

177. return False

179. async def cmd_post(self):

180. if "content-length" not in self.entetes:

181. await self.repondre( codeHTTP = 400, messageHTTP = "bad request" )

182. return True

183. self.entetes["content-length"] = int( self.entetes["content-length"] )

184. _triplets = TripletsHTTP(

185. self.client

186. )

187. await _triplets.traiterCorps( self.entetes["content-length"] )

188. if _triplets.etat is False:

189. await self.repondre( codeHTTP = 400, messageHTTP = "bad request" )

190. return True

191. self.corps = _triplets

192. self.client.silo.transactions.append(

193. ( "ecriture", self )

194. )

195. await self.attendre()

196. if self.etat is False:

197. await self.repondre( codeHTTP = 500, messageHTTP = "error during execution" )

198. return True

199. return False

[Filtre.py]

1. import re

3. from Silo import EnregistrementComposition

5. __all__ = [ "FiltreHTTP", ]

7. class FiltreHTTP:

9. def __init__(self, entetesHTTP):

10. self.liste = {}

11. global EnregistrementComposition

12. for cle in EnregistrementComposition:

13. if cle in entetesHTTP:

14. if "%s_regexp" % cle in entetesHTTP:

15. self.liste[cle] = re.compile( entetesHTTP[cle], re.I )

16. else:

17. self.liste[cle] = entetesHTTP[cle]

18. else:

19. self.liste[cle] = None

21. async def deduire(self, commande, index, enregistrement):

22. try:

23. if commande.uid != index["uid"]:

24. return False

25. for cle in enregistrement:

26. if await self.tester( cle, enregistrement[cle] ) is False:

27. return False

28. return True

29. except Exception as err:

30. print("(err) Filtre->deduire :", err)

31. return False

33. async def tester(self, cle, chaine):

34. if cle not in self.liste:

35. return False

36. if self.liste[cle] is None:

37. return True

38. if type(self.liste[cle]) == re.Pattern:

39. r = self.liste[cle].search( chaine )

40. if r is not None:

41. return True

42. else:

43. if self.liste[cle]==chaine:

44. return True

45. return False

VI-B-3 - Silo.py

1. import asyncio

3. __all__ = [ "Silo", "EnregistrementComposition" ]

5. EnregistrementComposition = {

6. "sujet" : 3,

7. "predicat" : 3,

8. "objet" : 3

9. }

11. class Silo:

13. index = {

14. "actif" : 1,

15. "uid" : 16,

16. "taille" : 4

17. }

19. enregistrement = {}

21. nbreMaxRequetesSimultanee = 10

23. def __init__(self, fChemin):

24. global EnregistrementComposition

25. for cle in EnregistrementComposition:

26. self.enregistrement[cle] = EnregistrementComposition[cle]

27. self.indexTaille = 0

28. for cle in self.index:

29. self.indexTaille += self.index[cle]

30. self.fSiloChemin = fChemin

31. self.fSilo = open( fChemin, "rb+" )

32. self.requetes = []

33. self.transactions = []

35. async def executer(self):

36. while True:

37. try:

38. if len( self.requetes ) == 0:

39. await asyncio.sleep( 0.1 )

40. else:

41. self.fSilo.seek( 0 )

42. requetes = await self.recupererRequetes()

43. print("(info) Silo->exécuter : nouvelles requêtes en cours de réalisation")

44. while True:

45. triplet = await self.suivant()

46. if triplet is None:

47. break

48. elif triplet is False: # théoriquement impossible

49. pass

50. else:

51. positionInitiale, index, enregistrement = triplet

52. for requete in requetes:

53. if await requete.filtre.deduire( requete, index, enregistrement ) is True:

54. requete.reponse.write(

55. ( ( "\t".join( enregistrement.values() ) )+"\n" ).encode( "utf-8" )

56. )

57. for requete in requetes:

58. requete.enAttente = False

59. except Exception as err:

60. print("(err) Silo->exécuter (requêtes) :", err)

61. finally:

62. while len( self.transactions )>0:

63. try:

64. typeTransaction, transaction = self.transactions.pop()

65. await getattr( self, "executerTransaction_%s" % typeTransaction )(

66. transaction

67. )

68. transaction.etat = True

69. except Exception as err:

70. print("(err) Silo->exécuter (transactions) :", err)

71. transaction.etat = False

72. finally:

73. transaction.enAttente = False

75. async def recupererRequetes(self):

76. _requetes = []

77. for requete in self.requetes:

78. _requetes.append( requete )

79. self.requetes.remove( requete )

80. if len( _requetes ) >= self.nbreMaxRequetesSimultanee:

81. break

82. return _requetes

84. async def executerTransaction_suppression(self, transaction):

85. i = 0

86. self.fSilo.seek( 0 )

87. while True:

88. triplet = await self.suivant()

89. if triplet is None or triplet is False:

90. break

91. positionInitiale, index, enregistrement = triplet

92. if await transaction.filtre.deduire( transaction, index, enregistrement ) is True:

93. await self.supprimer( positionInitiale )

94. i += 1

95. transaction.reponse.write(

96. ( "suppressions = %s" % i ).encode( "utf-8" )

97. )

99. async def executerTransaction_ecriture(self, transaction):

100. for triplet in transaction.corps.liste:

101. await self.ajouter( triplet, transaction.uid )

103. async def ajouter(self, enregistrement, uid, f=None):

104. try:

105. if f is None:

106. f = self.fSilo

107. f.seek( 0, 2 )

108. positionInitiale = f.tell()

109. f.write( bytes( self.indexTaille ) )

110. tailleEnreg = 0

111. for cle in self.enregistrement:

112. f.write( bytes( self.enregistrement[cle] ) )

113. if type(enregistrement[cle]) == str:

114. taillePartie = f.write( enregistrement[cle].encode( "utf-8" ) )

115. else:

116. taillePartie = f.write( enregistrement[cle] )

117. f.seek( 0-taillePartie-self.enregistrement[cle], 2 )

118. f.write( taillePartie.to_bytes( self.enregistrement[cle], "big" ) )

119. f.seek( f.tell()+taillePartie )

120. tailleEnreg += taillePartie+self.enregistrement[cle]

121. f.seek( positionInitiale )

122. _index = ( 1, uid, tailleEnreg )

123. for i, cle in enumerate( self.index ):

124. f.write( _index[i].to_bytes( self.index[cle], "big" ) )

125. f.flush()

126. return True

127. except Exception as err:

128. print("(err) Silo-> ajouter :", err)

130. async def supprimer(self, positionIndex):

131. self.fSilo.seek( positionIndex )

132. self.fSilo.write( bytearray(1) )

134. async def suivant(self, uid=None, filtreActif=True):

135. positionIndex = self.fSilo.tell()

136. portion = self.fSilo.read( self.indexTaille )

137. if portion==b"":

138. return None

139. index = {}

140. position = 0

141. for cle in self.index:

142. index[cle] = int.from_bytes(

143. portion[position:position+self.index[cle]],

144. "big"

145. )

146. position += self.index[cle]

147. c = True

148. if filtreActif is True:

149. if index["actif"] == 0:

150. c = False

151. if uid is not None:

152. if index["uid"] != uid:

153. c = False

154. if c is False:

155. self.fSilo.seek( positionIndex+self.indexTaille+index["taille"] )

156. return False

157. partiesBin = self.fSilo.read( index["taille"] )

158. parties = {}

159. position = 0

160. for cle in self.enregistrement:

161. taillePartie = int.from_bytes(

162. partiesBin[position:position+self.enregistrement[cle]],

163. "big"

164. )

165. position += self.enregistrement[cle]

166. parties[cle] = partiesBin[position:position+taillePartie].decode( "utf-8" )

167. position += taillePartie

168. return ( positionIndex, index, parties )

VI-B-4 - Triplets.py

1. import re

3. __all__ = [ "TripletsHTTP" ]

5. class TripletsHTTP:

7. sautLigne = "\n"

8. expReg_triplet = re.compile(

9. "^(?P<sujet>[^\t]+)\t(?P<predicat>[^\t]+)\t(?P<objet>[^\t]+)$",

10. re.I

11. )

13. def __init__(self, client):

14. self.client = client

15. self.etat = None

16. self.liste = []

18. async def traiterCorps(self, taille):

19. try:

20. corps = await self.client.lecteur.read( taille )

21. corps = corps.decode( "utf-8" )

22. while True:

23. if self.sautLigne not in corps:

24. break

25. ligne, corps = corps.split( self.sautLigne, 1 )

26. self.liste.append( ( await self.traiterLigne( ligne ) ) )

27. if corps != "":

28. self.liste.append( ( await self.traiterLigne( corps ) ) )

29. self.etat = True

30. except Exception as err:

31. self.etat = False

33. async def traiterLigne(self, portion):

34. return self.expReg_triplet.match( portion ).groupdict()

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

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