RDF et gestion des connaissances

Partie 1 - Apprendre les bases d’un triplestore REST en Python

Cet article, qui est le premier d’une série en trois parties et aborde les bases d’un triplestore REST en Python, c’est-à-dire un serveur Web HTTP qui nous permettra d’avoir une base technique pour recevoir des commandes et les exécuter grossièrement.

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

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Prérequis

Pour la lecture aisée de cet article, vous devez avoir un certain nombre de connaissances préalables :

  • l’écriture de scripts asynchrones en Python (notamment via Asyncio), dont le fonctionnement général est expliqué ici ;
  • connaître le fonctionnement général d’un serveur Web et du protocole HTTP ;
  • connaître le fonctionnement général de l’architecture REST.

À chaque fois que j’utiliserai le terme Python, c’est au moins la version 3.7 (et plus) qui est évoquée.

Cet article est le premier d’une série en trois parties :

  1. Les bases d’un triplestore REST en Python, c’est-à-dire un serveur Web HTTP qui nous permettra d’avoir une base technique pour recevoir des commandes et les exécuter grossièrement ;
  2. Le fonctionnement avancé et opérationnel d’un triplestore, avec le fonctionnement d’un silo de données et comment les deux peuvent s’articuler ;
  3. L’intérêt de RDF et d’un triplestore dans la gestion des connaissances, particulièrement dans la veille personnelle, avec l’approche des linked open data.

II. Préambule : triplestore, késako ?

Chose étonnante : nous ne nous occuperons pas aujourd’hui de ce qu’est un triplestore ! Gardez à l’esprit qu’il s’agit d’une sorte d’entrepôt pour des « triplets » – un groupe de trois données fondamentales formant ensemble une entité et qui est l’équivalent de deux nœuds et d’un arc dans un graphe. Un assemblage de triplets permet d’organiser un schéma, un graphe des données et permettre, à plus haut niveau, des inférences, c’est-à-dire des déductions nouvelles à partir de l’existant (un peu sous la forme d’un syllogisme, avec possiblement des risques intrinsèques… « Socrate est un homme »).

Un triplet n’est pas une chose récente : c’est une unité, ou plus exactement un rassemblement, des données les plus petites possible dans la gestion des connaissances suivant la norme RDF – Resource Description Framework – qui date du début des années 2000, par l’inventeur du Web, Tim Berners-Lee, dans la perspective que beaucoup ont appelé le Web 3.0 : c’est-à-dire le web sémantique. Les triplets peuvent être utilisés partout, quel que soit le sujet ou le contexte.

Des deux nœuds et de l’arc que j’évoquais auparavant, il s’agit dans une forme plus régulière pour nous humain, d’une phrase qui serait appelée une «proposition» : un sujet, un prédicat (une forme de verbe, qui porte en réalité le sens avec les autres données) et un objet.

Un triplet est un rassemblement donc, qui peuvent être soit des URI, des URL, du texte conventionné (une écriture particulière)… et n’a d’intérêt que rassemblé avec ses congénères (leur rassemblement est une connaissance). Un langage de requête a été conçu pour interroger, quel que soit le triplestore (ou même l’interface) utilisé, d’une manière transparente pour celui qui conçoit la requête : il s’agit de SparQL.

Si vous souhaitez d’ores et déjà découvrir ces sujets, Wikipédia est bien pourvu : triplestore, RDF sont autant d’articles pour débuter.

II-A. Exemple d’un raisonnement autour d’un triplet

Si nous avons la phrase « Paul a un polo rouge », le triplet « grossier » qui y serait associé est : Sujet( Paul ) Prédicat( a ) Objet ( un polo rouge )… où « Paul » et « un polo rouge » sont des entités uniques et le prédicat « a », dans ce contexte, implique implicitement qu’il le porte aujourd’hui (ce qui serait une inférence) et à tout le moins qu’il en dispose (qu’il en ait ou pas la propriété).

Il y aurait encore beaucoup à dire, notamment sur la notation et les relations autour et pour ce triplet. Mais restons-en là pour cette partie.

II-B. Un silo de données ?

Le principe d’un silo ici, même si nous le verrons dans le troisième article, est un espace où sont stockés des données sans nécessairement d’organisation comme l’entend une base de données MySQL (avec des bases, des tables, etc.) : prenez l’exemple d’un silo à grain. Comme son homologue dans la vie physique, le silo que nous allons construire aura une taille maximum – élevée certes – qui ne pourra pas être dépassée. C’est-à-dire que lorsqu’un silo est rempli, il faudra déporter les futures données sur un autre.

III. Un serveur Web minimum

Pour notre triplestore, étant donné que nous utiliserons le format REST, cela implique de disposer d’un serveur Web qui puisse parfaitement se coupler avec le fonctionnement de notre silo.

III-A. Un serveur pour les servir tous…

Nous utiliserons en point de départ, le serveur « écho » (TCP) qui sert d’exemple à Python 3.7. Peu est à modifier, car la structure d’Asyncio est pensée naturellement pour être efficace :

 
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.
import asyncio

async def handle_echo(reader, writer):
    data = await reader.read(100)
    message = data.decode()
    addr = writer.get_extra_info('peername')

    print(f"Received {message!r} from {addr!r}")

    print(f"Send: {message!r}")
    writer.write(data)
    await writer.drain()

    print("Close the connection")
    writer.close()

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

Deux fonctions sont définies et utilisent la « loop » (boucle) principale, générée lors de la première tentative de récupération. Nous n’utiliserons que celle-ci durant cet article, et aucunement les threads.

La fonction « main » permet d’initier un service opérationnel qui écoute les connexions locales sur le port 8888 – je n’entre pas dans le détail, mais sachez si vous êtes novices (nous le sommes tous à des degrés divers…!) que des centaines de lignes et d’heures sont derrière ce simple « start_server »). Comme d’ailleurs pour les termes « async » et « await », qui seront utilisés.

Ce service transforme donc le poste en serveur sur ce port : un service à durée indéfinie, qui permet de gérer des clients (des connexions), jusqu’à la fin des temps (d’où l’appel à « serve_forever »). Chacune de ces connexions est gérée par une fonction, « handle_echo », qui prend en paramètre deux arguments : l’équivalent d’un fichier comme « lecteur » et l’équivalent d’un fichier comme « écrivain ». En somme, « reader » permet d’accéder à une ressource qui se comporte comme un fichier et qui dispose des données reçues du client par le serveur. La ressource « writer » est son inverse : elle permet d’écrire des données du serveur au client.

Prenez garde cependant : vous pouvez en quelque sorte « lire et écrire » en même temps. Il ne s’agit pas d’un dialogue où chacun attend l’autre et que ce dernier termine. Il s’agit de deux monologues qui, en outre, peuvent ne pas avoir de lien (sémantique). Ainsi vous pouvez très bien recevoir à n’importe quel moment des données – mais aussi en émettre sans attendre.

Gardons cela à l’esprit sans nous en soucier : nous verrons bientôt pourquoi cette liberté de deux monologues est (parfois) nécessaire et n’est pas problématique dans notre cas.

À ce code fondamental, rajoutons ceci :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
import re 

# pour mémoire, re.I indique lors de la compilation de l’expression régulière, 
# de ne pas prendre en compte la casse 
expReg_requete = re.compile(
    "([a-z]+)\s+.*\s+HTTP\/1.[01]$",
    re.I 
)

expReg_entete = re.compile(
    "([^\:]+)\s*\:\s*(.*)$",
    re.I 
)

Il s’agit deux variables globales, qui permettent de récupérer la ligne de commande (le verbe d’action, l’URL demandée, la version du protocole – seul le premier nous intéresse). Puis ce sont les lignes d’entêtes qui sont « canoniquement » définies.

Nb : nous utilisons match et non search du module re : l’expression de recherche est toujours sur le début de la chaîne analysée….

Dit autrement, ces deux variables se traduiraient humainement par les définitions suivantes :

  • pour tout début de texte, cherche un bloc de texte composé d’au-moins un caractère, suivi ou non d’un espace, suivi d’un nombre indéfini de signes (caractères, espaces, ponctuations, etc.) et se terminant par « HTTP/1. » et soit 1, soit 1. Soit l’équivalent de :
    GET /coucou HTTP/1.1
  • pour tout début de texte, chercher un bloc de signes composé de tous les caractères sauf les sauts de lignes et du caractère « : », suivi ou non d’un non d’un espace, suivi de « : », suivi par le reste de la chaîne quel qu’en soit le contenu. Soit l’équivalent de :
    Content-length : 125

Ajoutons une première fonction de réponse, très simple, pour les tests :

 
Sélectionnez
1.
2.
3.
4.
5.
async def reponse(ecrivain, code, message):
    ecrivain.write( ( "HTTP/1.0 %s %s" % ( code, message ) ).encode( "utf-8" ) ) 
    await ecrivain.drain()
    ecrivain.close()
    raise Exception("connexion à clore")

Modifions enfin la fonction « handle_echo » pour répondre aux premières requêtes (sans récupération du corps pour l’instant) :

 
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.
async def handle_echo(reader, writer):
    methode = None
    entetes = {} 
    while True:
        ligne = await reader.readline()
        ligne = ligne.decode("utf-8").strip() 
        if ligne=="": 
            break
        if methode is None:
            r = expReg_requete.match( ligne )
            if r is None: 
                return await reponse( writer, 400, "bad request" ) 
            else:
                methode = r.group( 0 ) 
        else:
            r = expReg_entete.match( ligne )
            if r is None:
                return await reponse( writer, 400, "bad request" ) 
            else:
                _, cle, valeur = r.groups()
                entetes[cle] = valeur 
        print(methode, entetes) 
        await reponse( writer, 200, "OK" )

    print("Close the connection")
    writer.close()

Nous connaissons le début du corps (et la fin de l’entête HTTP), grâce à la syntaxe qui prévoit deux sauts de ligne consécutifs (soit l’équivalent d’une ligne vierge). Il suffit donc de détecter qu’une ligne est considérée comme vierge de tout signe pour faire un arrêt de la boucle :

 
Sélectionnez
while True:
        (…) 
        if ligne=="": 
            break

Tester ce code avec votre navigateur : l’ouverture de l’URL 127.0.0.1:8888 renverra systématiquement un code « HTTP 200 OK » sans corps.

Si par contre vous tester une connexion avec Putty en mode « raw » par exemple, le non-respect de la syntaxe HTTP vous renverra systématiquement un code d’erreur 400 « bad request ».

Voilà, notre serveur peut communiquer avec l’extérieur en utilisant un minimum de langage. Le plus dur est presque prêt !

III-B. Une classe puis des classes, puis…

Le problème survient avec le temps : si l’on reste sur des fonctions qui s’appellent, il faut constamment envoyé le simili-fichier « lecteur », celui « écrivain », la commande déjà traitée et que sais-je d’autres… Pas pratique. Les objets sont donc hautement nécessaires, pour ne pas dire indépassables.

Il nous faut créer une classe. Pas d’inquiétude pour Asyncio : les mots-clés « await » et « async » se marient très bien avec la définition de méthodes de classe.

Tout d’abord, réagençons notre code avec une classe « Client » qui « dialogue » lors d’une connexion au service :

 
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.
import asyncio
import re 

class ClientHTTP:

    expReg_requete = re.compile(
        "([a-z]+)\s+.*\s+HTTP\/1.[01]$",
        re.I 
    )
    expReg_entete = re.compile(
        "([^\:]+)\s*\:\s*(.*)$",
        re.I 
    ) 

    def __init__(self, lecteur, ecrivain):
        self.lecteur, self.ecrivain = lecteur, ecrivain 
        self.methode = None
        self.entetes = {}

    async def dialoguer(self): 
        while True:
            ligne = await self.lecteur.readline()
            ligne = ligne.decode("utf-8").strip() 
            if ligne=="":
                break
            if self.methode is None: 
                r = self.expReg_requete.match( ligne ) 
                if r is None: 
                    return ( await self.reponse( 400, "bad request" ) )
                else:
                    self.methode = r.group( 0 ) 
            else:
                r = self.expReg_entete.match( ligne ) 
                if r is None:
                    return ( await self.reponse( 400, "bad request" ) )
                else:
                    cle, valeur = r.groups() 
                    self.entetes[cle.lower()] = valeur 
        print(self.methode, self.entetes)
        await self.reponse( 200, "OK" )
        print("Close the connection") 
        
    async def reponse(self, code, message):
        self.ecrivain.write( ( "HTTP/1.0 %s %s" % ( code, message ) ).encode( "utf-8" ) ) 
        await self.ecrivain.drain() 

async def handle_echo(reader, writer):
    c = ClientHTTP( 
        reader,
        writer
    )
    await c.dialoguer()
    writer.close() 

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

Ce code est « équivalent » à celui au-dessus, mais il est plus clair et surtout plus facile à étendre. Notamment si vous avez besoin de l’héritage.

Détachons désormais le client, du dialogue HTTP. C’est-à-dire qu’un client pourrait traiter indifféremment du HTTP ou tout autre protocole – dès le démarrage du serveur, il suffirait d’indiquer quel « format » est utilisé. Pour cela, nous renommons la classe « ClientHTTP » en « Client », nous créons la classe « CommandeHTTP » et nous y intégrons ce qui tient lieu du dialogue HTTP à proprement parler (ici, récupérer les informations de la syntaxe précisément, et y répondre).

Nous n’oublions pas d’ajouter en paramètre de la création d’une instance du client, de donner la bonne classe du format de commande à utiliser. Voici ce que cela donne, sachant que ce code est toujours identique aux deux exemples précédents :

 
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.
81.
82.
import asyncio
import re 

class CommandeHTTP:

    expReg_requete = re.compile(
        "([a-z]+)\s+.*\s+HTTP\/1.[01]$",
        re.I 
    )
    expReg_entete = re.compile(
        "([^\:]+)\s*\:\s*(.*)$",
        re.I 
    ) 

    def __init__(self, client):
        self.client = client 
        self.methode = None 
        self.entetes = {} 
    
    def traiterLigne(self, ligne):
        if self.methode is None: 
            r = self.expReg_requete.match( ligne ) 
            if r is None: 
                return False 
            else:
                self.methode = r.group( 0 ) 
        else:
            r = self.expReg_entete.match( ligne ) 
            if r is None:
                return False 
            else:
                cle, valeur = r.groups() 
                self.entetes[cle.lower()] = valeur
        return True 
        
    async def repondre(self, code, message):
        self.client.ecrivain.write( ( "HTTP/1.0 %s %s" % ( code, message ) ).encode( "utf-8" ) ) 
        await self.client.ecrivain.drain() 

class Client: 

    def __init__(self, lecteur, ecrivain, formatDialogue):
        self.lecteur, self.ecrivain = lecteur, ecrivain
        self.formatDialogue = formatDialogue 

    async def recupereLigne(self):
        return (
            await self.lecteur.readline()
        ).decode("utf-8").strip()
    
    async def dialoguer(self):
        commande = self.formatDialogue( self ) 
        while True: 
            ligne = await self.recupereLigne() 
            if ligne=="":
                break
            if commande.traiterLigne( ligne ) is False:
                return ( await commande.repondre( 400, "bad request" ) ) 
        print(commande.methode, commande.entetes)
        await commande.repondre( 200, "OK" )
        print("Close the connection") 

async def handle_echo(reader, writer):
    c = Client( 
        reader,
        writer,
        CommandeHTTP 
    )
    await c.dialoguer()
    writer.close() 

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

Vous devriez avoir avec votre navigateur, en testant l’URL localhost sur le port 8888, un retour similaire dans la console de votre serveur :

 
Sélectionnez
Serving on ('127.0.0.1', 8888)
GET / HTTP/1.1 {'host': '127.0.0.1:8888', 'connection': 'keep-alive', 'cache-control': 'max-age=0', 'upgrade-insecure-requests': '1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36', 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 'accept-encoding': 'gzip, deflate, br', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8'}
Close the connection
GET /favicon.ico HTTP/1.1 {'host': '127.0.0.1:8888', 'connection': 'keep-alive', 'pragma': 'no-cache', 'cache-control': 'no-cache', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36', 'accept': 'image/webp,image/apng,image/*,*/*;q=0.8', 'referer': 'http://127.0.0.1:8888/', 'accept-encoding': 'gzip, deflate, br', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8'}
Close the connection

Notez que mon navigateur a envoyé deux requêtes – et non une seule comme j’aurai pu l’imaginer – car il tente de récupérer un « favicon », c’est-à-dire l’équivalent du logo du site. Ce comportement est classique pour la visite d’un nouveau site par la plupart des navigateurs modernes. Ne soyez donc pas étonné, ce n’est pas une « erreur » du serveur de détecter deux requêtes !

III-C. Et les routes d’abord ?

Jusqu’à présent, nous ne nous occupions pas des routes indiquées par le client connecté : pas de besoin. Or ce serait super d’avoir une page de présentation de notre serveur (ou de paramétrage, ou que sais-je…).

Nul besoin de quelque chose de compliqué pour l’instant : juste renvoyé du HTML.

Commençons par récupérer le chemin en modifiant notre expression régulière compilée de la ligne de commande HTTP :

 
Sélectionnez
1.
2.
3.
4.
 expReg_requete = re.compile(
        "([a-z]+)\s+(.*)\s+HTTP\/1.[01]$", # j’ai rajouté les parenthèses : le groupe devient « capturant » ! 
        re.I 
    )

Modifions ensuite la fonction initialisation de notre classe CommandeHTTP :

 
Sélectionnez
1.
2.
3.
4.
5.
def __init__(self, client):
        self.client = client 
        self.methode = None 
        self.chemin = None 
        self.entetes = {}

Et enfin, dans cette classe, pour la fonction « traiterLigne », remplacer la ligne :

self.methode = r.group( 0 )

…par,

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

Le tour est joué ! Votre chemin est récupéré. Modifions la fonction « dialoguer » pour prendre en compte que certains chemins sont en quelque sorte des « exceptions », qui seront gérés par la classe CommandeHTTP :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
async def dialoguer(self):
        commande = self.formatDialogue( self ) 
        while True: 
            ligne = await self.recupereLigne() 
            if ligne=="":
                break
            if commande.traiterLigne( ligne ) is False:
                return ( await commande.repondre( 400, "bad request" ) )
        if ( await commande.traiterChemin() ) is False: 
            #print(commande.methode, commande.chemin, commande.entetes)
            await commande.repondre( 200, "OK" )
            print("Close the connection")

Dans CommandeHTTP, ajoutons d’abord les ressources sous la forme d’expressions régulières compilées, qui renvoient vers un chemin local (ici, que des fichiers statiques). Ajoutons également deux fonctions : la première va parcourir à chaque requête, les ressources ainsi définies et si l’expression est valide, on renvoie le fichier associé. Évidemment, on en profite pour gérer un fichier qui n’existerait pas localement, et renvoyer une erreur 404…

 
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.
async def traiterChemin(self):
        for rExpReg in self.ressources:  
            r = rExpReg.match( self.chemin )
            if r is not None: 
               await self.repondreFichier( self.ressources[rExpReg], r ) 
               return True 
        return False 
    
    async def repondreFichier(self, chemin, resultat): 
        if os.path.isfile( chemin ): # pensez à ajouter « import os » 
            with open( chemin, "r" ) as f:
                r = f.read().encode( "utf-8" ) 
                await self.repondre( 200, "correct ressource" ) 
                # les quelques lignes ci-dessous peuvent poser problème : 
                # nous verrons plus tard pourquoi 
                self.client.ecrivain.write(
                    ( "Content-length: %s\n" % len( r ) ).encode( "utf-8" ) 
                )
                self.client.ecrivain.write(
                    "\n".encode( "utf-8" ) 
                ) 
                self.client.ecrivain.write(
                    r 
                )
                await self.client.ecrivain.drain()
                return True 
        return ( await self.repondre( 404, "bad ressource" ) ) 
        return True

Enfin nous modifions la fonction « repondre » pour y ajouter le caractère « \n » en fin de ligne… sinon votre première ligne de réponse sera confondue avec la seconde…

 
Sélectionnez
1.
2.
3.
async def repondre(self, code, message):
        self.client.ecrivain.write( ( "HTTP/1.0 %s %s\n" % ( code, message ) ).encode( "utf-8" ) ) 
        await self.client.ecrivain.drain()

Si vous testez désormais votre code, vous aurez le comportement suivant :

Vous l’aurez compris, un retour avec un code HTTP 200 est donc particulièrement relatif à un contexte. Un code HTTP d’ailleurs, n’est jamais qu’une indication sur la situation de la requête et n’est absolument pas une « réalité ».

Ainsi pour des raisons de sécurité, un serveur peut être configuré pour retourner un code 404 (ressource inconnue) au lieu et place d’un code 403 (accès refusé à cette ressource).

III-D. Simplifions !

Tout cela est sympathique, mais n’offre guère de possibilité de réutilisation. Ce serait vite un bazar inouï si nous n’y prenions pas garde. Ce qui est intéressant avec le protocole HTTP et qui motive son usage le plus large, c’est sa grande simplicité et robustesse dans les réponses. Finalement, les requêtes sont aussi faciles à concevoir qu’à analyser :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
async def repondre(self, codeHTTP=200, messageHTTP="OK", entetes={}, corps=None):
        reponse = [ 
            "HTTP/1.0 %s %s" % ( codeHTTP, messageHTTP )
        ]
        for cle in entetes:
            reponse.append(
                "%s: %s" % ( cle, entetes[cle] )
            )
        if corps is not None:
            reponse.append(
                "content-length: %s" % len( corps ) 
            )
            reponse.append( "" )
            reponse.append( corps ) 
        print(reponse) 
        self.client.ecrivain.write(
            ( "\n".join( reponse ) ).encode( "utf-8" )
        ) 
        await self.client.ecrivain.drain()

Nous gérons plus aisément les différents types de réponses possibles, avec ou sans corps. Rien d’exceptionnel certes, mais c’est ce que nous avons besoin - ni plus ni moins.

Modifiez le reste de votre conséquence ; vous devriez alors avoir quelque chose qui ressemble à ceci :

 
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.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
import asyncio 
import re
import os  

class CommandeHTTP:

    ressources = {
        re.compile(
            "\/page\/?$"
        ) : "./page.html"  
    } 

    expReg_requete = re.compile(
        "([a-z]+)\s+(.*)\s+HTTP\/1.[01]$",
        re.I 
    ) 
    expReg_entete = re.compile(
        "([^\:]+)\s*\:\s*(.*)$",
        re.I 
    ) 

    def __init__(self, client):
        self.client = client 
        self.methode = None
        self.chemin = None 
        self.entetes = {} 
    
    def traiterLigne(self, ligne):
        if self.methode is None: 
            r = self.expReg_requete.match( ligne ) 
            if r is None: 
                return False 
            else:
                self.methode, self.chemin = r.groups() 
        else:
            r = self.expReg_entete.match( ligne ) 
            if r is None:
                return False 
            else:
                cle, valeur = r.groups() 
                self.entetes[cle.lower()] = valeur
        return True

    async def traiterChemin(self):
        for rExpReg in self.ressources:  
            r = rExpReg.match( self.chemin )
            if r is not None: 
               await self.repondreFichier( self.ressources[rExpReg], r ) 
               return True 
        return False 
    
    async def repondreFichier(self, chemin, resultat): 
        if os.path.isfile( chemin ):
            with open( chemin, "r" ) as f:
                await self.repondre(
                    corps = f.read()
                ) 
                return True 
        return ( await self.repondre( codeHTTP = 404, messageHTTP = "bad ressource" ) ) 
        return True 
    
    async def repondre(self, codeHTTP=200, messageHTTP="OK", entetes={}, corps=None):
        reponse = [
            "HTTP/1.0 %s %s" % ( codeHTTP, messageHTTP )
        ]
        for cle in entetes:
            reponse.append(
                "%s: %s" % ( cle, entetes[cle] )
            )
        if corps is not None:
            reponse.append(
                "content-length: %s" % len( corps ) 
            )
            reponse.append( "" )
            reponse.append( corps ) 
        print(reponse) 
        self.client.ecrivain.write(
            ( "\n".join( reponse ) ).encode( "utf-8" )
        ) 
        await self.client.ecrivain.drain() 

class Client: 

    def __init__(self, lecteur, ecrivain, formatDialogue):
        self.lecteur, self.ecrivain = lecteur, ecrivain
        self.formatDialogue = formatDialogue 

    async def recupereLigne(self):
        return (
            await self.lecteur.readline()
        ).decode("utf-8").strip()
    
    async def dialoguer(self):
        commande = self.formatDialogue( self ) 
        while True: 
            ligne = await self.recupereLigne() 
            if ligne=="":
                break
            if commande.traiterLigne( ligne ) is False:
                return ( await commande.repondre( codeHTTP = 400, messageHTTP = "bad request" ) )
        if ( await commande.traiterChemin() ) is False: 
            #print(commande.methode, commande.chemin, commande.entetes)
            await commande.repondre()
            print("Close the connection") 

async def handle_echo(reader, writer):
    c = Client( 
        reader,
        writer,
        CommandeHTTP 
    )
    await c.dialoguer()
    writer.close() 

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

Notez dans la fonction « dialoguer » de la classe « Client », que la réponse à proprement parler, est un appel tout simple et sans argument à la fonction « repondre » de la classe « Commande » :

await commande.repondre()

Bref : c’est efficace et bien organisé, mais ça ne sert rigoureusement à rien, car le renvoi sera toujours un code HTTP 200.

III-E. Un verbe = une action

Le protocole HTTP prévoit des « verbes » d’action. Pour une architecture REST (representational state transfer), ce verbe est en quelque sorte soit un « contexte » général, soit tout à fait l’action à proprement parler. Tout dépend de l’API (application programming interface) souhaitée :

  • soit les verbes indiquent une action, exemples : GET = obtenir et POST = créer ;
  • soit les verbes indiquent un contexte, exemples : GET = une requête (lecture seule) et POST = une transaction (écriture probable).

Notre classe « CommandeHTTP » a pour devoir de gérer ces situations – qui heureusement sont peu nombreuses : GET, POST, PUT, DELETE, HEAD, CONNECT, OPTIONS, TRACE, PATCH. Les deux premiers sont les plus connus, mais chacun recouvre en réalité un besoin et l’on confond parfois les verbes entre eux : GET et HEAD ; POST et PUT…

Python n’offre pas d’éléments de langage de type « switch/case » pour gérer de nombreuses situations. Dès lors, soit l’on utilise une série de « if/else », soit une combinaison d’expressions régulières testées les unes après les autres (comme pour les routes plus haut). Soit, comme c’est notre cas, nous avons aussi le moyen de tester l’existence ou non d’une méthode…

Dans « CommandeHTTP », ajoutons cette fonction :

 
Sélectionnez
1.
2.
3.
4.
5.
async def executer(self):
        if hasattr( self, "cmd_%s" % self.methode ):
            await getattr( self, "cmd_%s" % self.methode)() 
        else:
            return ( await self.repondre( codeHTTP = 400, messageHTTP = "bad request" ) )

Son intérêt est de savoir si une méthode de l’instance existe à partir d’un nom composé à la volée. Ainsi vous pouvez gérer facilement (et étendre tout aussi facilement) vos verbes HTTP. Une telle méthode serait par exemple :

 
Sélectionnez
async def cmd_get(self):
        await self.repondre( corps = "youpi tralala" )

…toutes les requêtes GET (pas celles POST, PUT, etc.) renverront le même message.

Remplaçons dans la fonction « dialoguer » de « Client », la fonction de réponse par la fonction d’exécution :

await commande.executer()

Et testez : rien ne marche… ! Une erreur 400 apparaît systématiquement !

En effet, peut-être avez-vous remarqué tout au début, pour le traitement des entêtes des requêtes, que je passais à une petite casse des clés :

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

Ainsi « Content-length », « CONTENT-length » ou « ConTenT-LengHT » aboutiront toutes à la chaîne « content-length » - facile à retrouver. Il faut faire de même avec la méthode, dans la même fonction « traiterLigne » de « CommandeHTTP » :

self.methode = self.methode.lower()

Relancez et ouf, « youpi tralala » devrait s’afficher… Notre serveur est prêt.

 
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.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
import asyncio 
import re
import os  

class CommandeHTTP:

    ressources = { 
        re.compile( 
            "\/page\/?$"
        ) : "./page.html"  
    } 

    expReg_requete = re.compile(
        "([a-z]+)\s+(.*)\s+HTTP\/1.[01]$",
        re.I 
    ) 
    expReg_entete = re.compile(
        "([^\:]+)\s*\:\s*(.*)$",
        re.I 
    ) 

    def __init__(self, client):
        self.client = client 
        self.methode = None
        self.chemin = None 
        self.entetes = {} 
    
    def traiterLigne(self, ligne):
        if self.methode is None: 
            r = self.expReg_requete.match( ligne ) 
            if r is None: 
                return False 
            else:
                self.methode, self.chemin = r.groups()
                self.methode = self.methode.lower() 
        else:
            r = self.expReg_entete.match( ligne ) 
            if r is None:
                return False 
            else:
                cle, valeur = r.groups() 
                self.entetes[cle.lower()] = valeur
        return True

    async def traiterChemin(self):
        for rExpReg in self.ressources:  
            r = rExpReg.match( self.chemin )
            if r is not None: 
               await self.repondreFichier( self.ressources[rExpReg], r ) 
               return True 
        return False 
    
    async def repondreFichier(self, chemin, resultat): 
        if os.path.isfile( chemin ):
            with open( chemin, "r" ) as f:
                await self.repondre(
                    corps = f.read()
                ) 
                return True 
        return ( await self.repondre( codeHTTP = 404, messageHTTP = "bad ressource" ) ) 
        return True 
    
    async def repondre(self, codeHTTP=200, messageHTTP="OK", entetes={}, corps=None):
        reponse = [
            "HTTP/1.0 %s %s" % ( codeHTTP, messageHTTP )
        ]
        for cle in entetes:
            reponse.append(
                "%s: %s" % ( cle, entetes[cle] )
            )
        if corps is not None:
            reponse.append(
                "content-length: %s" % len( corps ) 
            )
            reponse.append( "" )
            reponse.append( corps ) 
        #print(reponse) 
        self.client.ecrivain.write(
            ( "\n".join( reponse ) ).encode( "utf-8" )
        ) 
        await self.client.ecrivain.drain() 

    async def executer(self):
        if hasattr( self, "cmd_%s" % self.methode ):
            await getattr( self, "cmd_%s" % self.methode)() 
        else:
            await self.repondre( codeHTTP = 400, messageHTTP = "bad request" ) 
    
    async def cmd_get(self):
        await self.repondre( corps = "youpi tralala" ) 

class Client: 

    def __init__(self, lecteur, ecrivain, formatDialogue):
        self.lecteur, self.ecrivain = lecteur, ecrivain
        self.formatDialogue = formatDialogue 

    async def recupereLigne(self):
        return (
            await self.lecteur.readline()
        ).decode("utf-8").strip()
    
    async def dialoguer(self):
        commande = self.formatDialogue( self ) 
        while True: 
            ligne = await self.recupereLigne() 
            if ligne=="":
                break
            if commande.traiterLigne( ligne ) is False:
                return ( await commande.repondre( codeHTTP = 400, messageHTTP = "bad request" ) )
        if ( await commande.traiterChemin() ) is False: 
            #print(commande.methode, commande.chemin, commande.entetes)
            await commande.executer()
            print("Close the connection") 

async def handle_echo(reader, writer):
    c = Client( 
        reader,
        writer,
        CommandeHTTP 
    )
    await c.dialoguer()
    writer.close() 

# le reste du code sera désormais ici (et suivant) sauf mention contraire 

async def main():
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}')

    async with server:
        await server.serve_forever()

asyncio.run(main())

IV. L’organisation de notre premier silo

Notre silo, comme je l’expliquais plus haut, utilisera pour fonctionner la même boucle que le serveur : c’est-à-dire que les tâches du serveur gérées par Python grâce aux coroutines, sont confondues avec celles du silo. Cela est permis, car la fonction « main » organise dès le départ la création des tâches nécessaires et du lancement des serveurs.

Notre appel à cette boucle est désormais ceci :

 
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.
# n’oubliez pas qu’au-dessus, il y a le code vu précédemment 
ObjSilo = None 

async def main(_silos):
    global ObjSilo 
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)

    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}') 

    ObjSilo = _silos() 

    async with server:
        await server.serve_forever()

class Silo: 

    def __init__(self): 
        self.commandes = [] 
        self.tache = asyncio.create_task( self.executer() ) 

    async def executerCommande(self, commande): 
        print("attendre 3 secondes pour ", commande) 
        await asyncio.sleep( 3 ) 
        commande.enAttente = False 

    async def executer(self): 
        while True: 
            _commandes = [] 
            for c in self.commandes: 
                _commandes.append( c ) 
                self.commandes.remove( c ) 
            for c in _commandes: 
                print("traitement de", c) 
                await self.executerCommande( c ) 
            print("silo executer") 
            await asyncio.sleep(1) 

if __name__=="__main__":
    try:
        m = main(
            Silo  
        ) 
        asyncio.run( m )
    except KeyboardInterrupt: 
        pass

Testez votre script : vous verrez qu’au-delà des requêtes gérées par le serveur, vous aurez chaque seconde un message « silo executer », qui se répétera indéfiniment.

Nous modifions également notre client, en ajoutant une fonction d’attente et « cmd_get » :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
async def attendre(self): 
        self.enAttente = True  
        while self.enAttente: 
            print("un client attend", self) 
            await asyncio.sleep( 1 ) 

    async def cmd_get(self): 
        global ObjSilo 
        ObjSilo.commandes.append( 
            self 
        ) 
        await self.attendre() 
        await self.repondre( corps = "youpi tralala" )

Vous devriez voir un retour de console similaire à ceci, en gardant à l’esprit qu’il y a deux requêtes envoyées par le navigateur :

 
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.
Serving on ('127.0.0.1', 8888)
silo executer
silo executer
silo executer
un client attend <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
traitement de <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
attendre 3 secondes pour  <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F1EB8>
silo executer
Close the connection <__main__.Client object at 0x000002666B8F1C18>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F5B38>
traitement de <__main__.CommandeHTTP object at 0x000002666B8F5B38>
attendre 3 secondes pour  <__main__.CommandeHTTP object at 0x000002666B8F5B38>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F5B38>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F5B38>
un client attend <__main__.CommandeHTTP object at 0x000002666B8F5B38>
silo executer
Close the connection <__main__.Client object at 0x000002666B8F5B00>
silo executer
silo executer
silo executer
...

Alors comment fonctionne un tel code ? La fonction « cmd_get » gère toujours dans notre cas les requêtes du navigateur, sauf que nous ajoutons à notre variable globale « ObjSilo », la commande du client à une liste de toutes les commandes de tous les clients, puis nous mettons la présente commande dans un mode d’attente.

Lorsque cette attente est terminée, la réponse est envoyée : ici la réponse est toujours envoyée après au moins 3 secondes.

Le plus intéressant est ailleurs : « ObjSilo » est une variable globale donc, qui contient un objet instancié de la classe « Silo ». C’est celle-ci qui définit le comportement du silo, ses modalités d’actions, et qui récupère et gère les commandes acquises.

En somme, le déroulé de notre script est le suivant, qui nous permet d’identifier précisément le rôle de chacun :

  • un serveur asynchrone est lancé au travers d’une fonction « main », qui crée elle-même une instance d’un silo ;
  • ce serveur ne gère que les connexions TCP/IP, et pour chaque nouvelle connexion, crée un client qui va être l’outil pour récupérer le contenu des connexions et permettre de répondre ;
  • chaque client, pour les requêtes qu’il reçoit, va créer une instance de commande, qui est l’outil de compréhension (récupération ≠ compréhension ; permettant au client d’être agnostique sur le protocole) :
  • chaque silo gère individuellement les commandes, en « verrouillant » le fichier lorsque c’est nécessaire, et libère en quelque sorte la commande et donc le client. Cette libération déclenche l’envoi d’une réponse.

Il est important que des fonctions d’attente soient utilisées – et pas seulement pour l’exemple. En effet, si vous ne les utilisez pas, la commande envoie une réponse trop tôt – le client pourrait donc recevoir une réponse erronée (voire pas de réponse), sans que le silo ait eu le temps de gérer la commande.

Si nous commentons la ligne suivante :

await self.attendre()

…notez que désormais l’envoi de la réponse est immédiat. C’est-à-dire que le silo, lorsqu’il récupère la commande, celle-ci est déjà considérée comme exécutée, car la réponse est envoyée… Cette erreur logique peut être difficile à appréhender et à détecter, mais elle est essentielle à ne pas être commise.

De la même façon, la fonction « executer » de la classe « Silo » récupère, à un instant précis, les commandes qui existent ; et traitent seulement celles-ci jusqu’au passage suivant : nous verrons bientôt pourquoi.

IV-A. La vraie nature du silo

Notre silo est – avant toute autre chose – un fichier et surtout un pointeur dans un fichier (en réalité deux dans notre cas). Ce fichier doit rester cohérent : il faut donc que chaque opération d’écriture soit parfaitement gérée et surtout exclusivement à un moment donné.

Comme je l’indiquais plus haut, il y a donc :

  • des commandes « en lecture seule » - des requêtes ;
  • des commandes « en écriture » - des transactions.

Les requêtes peuvent être multiples à un moment donné, car elles n’entraînent pas d’effets sur le contenu de fichier, elles sont donc « plus rapides » dans l’absolu (c’est relatif…) qu’une transaction. Par contre les transactions sont traitées individuellement, de bout en bout, afin de ne pas avoir d’incohérences ou d’écrasement lors de l’écriture, et cela a une incidence sur les performances.

En réalité, c’est un peu plus compliqué : si vous avez 1000 lecteurs par exemple, vous aurez donc 1000 clients qui seront traités de manière concurrentielle – ralentissant d’autant la performance « pure » si la requête était la seule et encore plus si des opérations de comparaison sont à faire… L’asynchrone a une efficacité relative et propre à chaque situation (+ article sur le sujet).

Nous mettrons donc un nombre maximum de lecteurs simultanés afin de conserver un équilibre (10 dans notre exemple).

Mais continuons : créez dans le même dossier, un fichier « silo.test » (un simple fichier texte, encodé en UTF-8), avec une liste de chiffres (un par ligne). Quelque chose de similaire à ceci :

| 1

| 2

| 3

| 4

| 5

| 6

| 7

| 8

| 9

La consigne sera de renvoyer seulement les valeurs qui sont paires (c’est-à-dire que le modulo de 2 retourne 0). Deux boucles sont nécessaires : la première permet de garder la fonction « executer » de « Silo » active, celle-ci comparant si des requêtes sont en attente. La seconde permet de parcourir le fichier, renvoyant une portion du fichier individuellement aux requêtes sélectionnées pour être traitées.

Cette portion dans notre cas, sera simplement de faire le modulo et d’ajouter, si le nombre est pair, à une nouvelle variable « reponse » de la classe « Commande ». Ce qui sera renvoyé au client sera d’ailleurs la concaténation de ces valeurs.

Voici le code complet, avec les modifications. Prenez le temps nécessaire à sa découverte et vous familiariser avec :

 
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.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
138.
139.
140.
141.
142.
143.
144.
145.
146.
147.
148.
149.
150.
151.
152.
153.
154.
155.
156.
157.
158.
159.
160.
161.
162.
163.
164.
165.
166.
167.
168.
169.
170.
171.
172.
173.
174.
175.
176.
177.
178.
179.
180.
181.
182.
183.
184.
185.
186.
187.
188.
189.
190.
191.
192.
193.
194.
195.
196.
197.
198.
199.
200.
201.
202.
203.
204.
205.
206.
207.
208.
209.
210.
211.
212.
import asyncio 
import re
import os  

class CommandeHTTP:

    ressources = { 
        re.compile( 
            "\/page\/?$"
        ) : "./page.html"  
    } 

    expReg_requete = re.compile(
        "([a-z]+)\s+(.*)\s+HTTP\/1.[01]$",
        re.I 
    ) 
    expReg_entete = re.compile(
        "([^\:]+)\s*\:\s*(.*)$",
        re.I 
    ) 

    def __init__(self, client):
        self.client = client 
        self.methode = None
        self.chemin = None 
        self.entetes = {} 
        self.reponse = [] 
    
    def traiterLigne(self, ligne):
        if self.methode is None: 
            r = self.expReg_requete.match( ligne ) 
            if r is None: 
                return False 
            else:
                self.methode, self.chemin = r.groups()
                self.methode = self.methode.lower() 
        else:
            r = self.expReg_entete.match( ligne ) 
            if r is None:
                return False 
            else:
                cle, valeur = r.groups() 
                self.entetes[cle.lower()] = valeur
        return True

    async def traiterChemin(self):
        for rExpReg in self.ressources:  
            r = rExpReg.match( self.chemin )
            if r is not None: 
               await self.repondreFichier( self.ressources[rExpReg], r ) 
               return True 
        return False 
    
    async def repondreFichier(self, chemin, resultat): 
        if os.path.isfile( chemin ):
            with open( chemin, "r" ) as f:
                await self.repondre(
                    corps = f.read()
                ) 
                return True 
        return ( await self.repondre( codeHTTP = 404, messageHTTP = "bad ressource" ) ) 
        return True 
    
    async def repondre(self, codeHTTP=200, messageHTTP="OK", entetes={}, corps=None):
        reponse = [
            "HTTP/1.0 %s %s" % ( codeHTTP, messageHTTP )
        ]
        for cle in entetes:
            reponse.append(
                "%s: %s" % ( cle, entetes[cle] )
            )
        if corps is not None:
            reponse.append(
                "content-length: %s" % len( corps ) 
            )
            reponse.append( "" )
            reponse.append( corps ) 
        #print(reponse) 
        self.client.ecrivain.write(
            ( "\n".join( reponse ) ).encode( "utf-8" )
        ) 
        await self.client.ecrivain.drain() 

    async def executer(self):
        if hasattr( self, "cmd_%s" % self.methode ):
            await getattr( self, "cmd_%s" % self.methode)() 
        else:
            await self.repondre( codeHTTP = 400, messageHTTP = "bad request" ) 
    
    async def attendre(self): 
        self.enAttente = True  
        while self.enAttente: 
            print("un client attend", self) 
            await asyncio.sleep( 1 ) 

    async def cmd_get(self): 
        global ObjSilo 
        ObjSilo.requetes.append( 
            self 
        ) 
        await self.attendre() 
        await self.repondre( corps = "\n".join( self.reponse ) ) 

class Client: 

    def __init__(self, lecteur, ecrivain, formatDialogue):
        self.lecteur, self.ecrivain = lecteur, ecrivain
        self.formatDialogue = formatDialogue 

    async def recupereLigne(self):
        return (
            await self.lecteur.readline()
        ).decode("utf-8").strip()
    
    async def dialoguer(self):
        commande = self.formatDialogue( self ) 
        while True: 
            ligne = await self.recupereLigne() 
            if ligne=="":
                break
            if commande.traiterLigne( ligne ) is False:
                return ( await commande.repondre( codeHTTP = 400, messageHTTP = "bad request" ) )
        if ( await commande.traiterChemin() ) is False: 
            #print(commande.methode, commande.chemin, commande.entetes)
            await commande.executer()
            print("Close the connection", self) 

async def handle_echo(reader, writer):
    c = Client( 
        reader,
        writer,
        CommandeHTTP 
    )
    await c.dialoguer()
    writer.close() 

#################################################### 

ObjSilo = None 

async def main(_silos):
    global ObjSilo 
    server = await asyncio.start_server(
        handle_echo, '127.0.0.1', 8888)
    addr = server.sockets[0].getsockname()
    print(f'Serving on {addr}') 
    ObjSilo = _silos() 
    async with server:
        await server.serve_forever()

class Silo: 

    nbreMaxRequetesSimultanee = 10 

    def __init__(self): 
        self.requetes = [] 
        self.transactions = [] 
        self.fSilo = open( "./silo.test", "r" ) 
        self.tache = asyncio.create_task( self.executer() ) 

    async def executerRequete(self, commande, portion): 
        if portion%2==0: 
            commande.reponse.append( 
                str( portion ) 
            ) 

    def recupererRequetes(self): 
        # cette opération doit rester bloquante 
        # il s'agit de récupérer les requêtes 
        # (commandes en lecture) à un instant t 
        _requetes = [] 
        for requete in self.requetes: 
            _requetes.append( requete ) 
            self.requetes.remove( requete ) 
            if len( _requetes ) >= self.nbreMaxRequetesSimultanee: 
                break 
        return _requetes 

    async def recupererPortion(self): 
        return self.fSilo.readline() 

    async def executer(self): 
        while True: 
            try: 
                if len( self.requetes ) == 0: 
                    print("Silo : pas encore de client à exécuter")
                    await asyncio.sleep( 0.5 ) 
                else: 
                    self.fSilo.seek( 0 ) # début du fichier 
                    requetes = self.recupererRequetes() 
                    print("Silo : requêtes en cours de réalisation", requetes)
                    while True: 
                        p = ( await self.recupererPortion() ) 
                        if p=="": 
                            break # la fin du fichier détectée 
                        else: 
                            p = int( p ) 
                        for requete in requetes: 
                            await self.executerRequete( requete, p ) 
                    for requete in requetes: 
                        requete.enAttente = False 
            except Exception as err: 
                print(err) 

if __name__=="__main__":
    try:
        m = main(
            Silo  
        ) 
        asyncio.run( m )
    except KeyboardInterrupt: 
        pass

Le retour de la console devrait être similaire à ceci :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
Serving on ('127.0.0.1', 8888)
Silo : pas encore de client à exécuter
Silo : pas encore de client à exécuter
un client attend <__main__.CommandeHTTP object at 0x0000018A00AFB550>
Silo : requêtes en cours de réalisation [<__main__.CommandeHTTP object at 0x0000018A00AFB550>]
Silo : pas encore de client à exécuter
Silo : pas encore de client à exécuter
Close the connection <__main__.Client object at 0x0000018A00AFB518>
un client attend <__main__.CommandeHTTP object at 0x0000018A00AFB908>
Silo : requêtes en cours de réalisation [<__main__.CommandeHTTP object at 0x0000018A00AFB908>]
Silo : pas encore de client à exécuter
Silo : pas encore de client à exécuter
Close the connection <__main__.Client object at 0x0000018A00AFB8D0>
Silo : pas encore de client à exécuter
Silo : pas encore de client à exécuter
Silo : pas encore de client à exécuter
…

Et pour ce qui est des transactions, c’est-à-dire d’une opération en écriture, me direz-vous ? Facile : une fois qu’une sélection de requêtes a été traitée, nous pouvons traiter les opérations en écriture.

Dans la fonction d’initialisation du silo, modifiez le mode d’ouverture du fichier de silo de « r » (read, lecture seule) en « r+ » (read et write, possibilité d’écriture, avec le curseur en début de fichier par défaut).

self.fSilo = open( "./silo.test", "r+" )

Nous allons nous servir du try / except que j’ai écrit dans la fonction « executer » du silo, pour être certain qu’après les requêtes, nous ayons toujours des transactions en attente :

 
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.
async def executer(self): 
        while True: 
            try: 
                if len( self.requetes ) == 0: 
                    print("Silo : pas encore de client à exécuter")
                    await asyncio.sleep( 2 ) # on attend un peu… 
                else: 
                    self.fSilo.seek( 0 ) # début du fichier 
                    requetes = self.recupererRequetes() 
                    print("Silo : requêtes en cours de réalisation", requetes)
                    while True: 
                        p = ( await self.recupererPortion() ) 
                        if p=="": 
                            break # la fin du fichier détectée 
                        else: 
                            p = int( p ) 
                        for requete in requetes: 
                            await self.executerRequete( requete, p ) 
                    for requete in requetes: 
                        requete.enAttente = False 
            except Exception as err: 
                print("erreur :", err) 
            finally: 
                # on simule des transactions en attente 
                self.fSilo.seek( 0, 2 ) # fin du fichier 
                for i in range(0, 10): 
                    self.fSilo.write( "%i\n" % i ) 
                self.fSilo.flush()

Ainsi à chaque paquet de requêtes traité, ou toutes les deux secondes s’il n’y a pas de requêtes à traiter, nous ajoutons des valeurs à notre fichier, dont voici un retour de console possible :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
Serving on ('127.0.0.1', 8888)
Silo : pas encore de client à exécuter
un client attend <__main__.CommandeHTTP object at 0x0000018603E2B588>
Silo : écriture de nouvelles valeurs
Silo : requêtes en cours de réalisation [<__main__.CommandeHTTP object at 0x0000018603E2B588>]
Silo : écriture de nouvelles valeurs
Silo : pas encore de client à exécuter
Close the connection <__main__.Client object at 0x0000018603E2B550>
un client attend <__main__.CommandeHTTP object at 0x0000018603E7AF98>
un client attend <__main__.CommandeHTTP object at 0x0000018603E7AF98>
Silo : écriture de nouvelles valeurs
Silo : requêtes en cours de réalisation [<__main__.CommandeHTTP object at 0x0000018603E7AF98>]
Silo : écriture de nouvelles valeurs
Silo : pas encore de client à exécuter
Close the connection <__main__.Client object at 0x0000018603E7AF60>
Silo : écriture de nouvelles valeurs
Silo : pas encore de client à exécuter
Silo : écriture de nouvelles valeurs
Silo : pas encore de client à exécuter
Silo : écriture de nouvelles valeurs
Silo : pas encore de client à exécuter
…

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

Nous tenons à remercier f-leb 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 © 2019 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.