Comment fonctionne HTTP ?


Plus je fais du dev Web, plus je m’aperçois que beaucoup de mes collègues n’ont aucune idée de comment fonctionne HTTP sous le capot. Comme vous le savez, ma règle numéro 1 c’est qu’il n’y a rien d’évident, donc petit tuto pour expliquer les bases.

On ne va pas rentrer dans les petits détails, juste une petite intro histoire de savoir ce qui se passe derrière ce script PHP ou cette application bottle.

Article long, vous connaissez la chanson.

Et puis c’est dans le ton de l’actu ^^

La logique de client / serveur

(Je me repompe moi-même)

Se balader sur le Web, c’est comme aller au resto. On est un client, on demande quelque chose au serveur, le serveur va voir en cuisine, et revient avec la bouffe, ou une explication sur l’absence de la bouffe (mais jamais pourquoi la bouffe est dégueulasse, allez comprendre):

Schéma du protocole HTTP, en gros

Le protocole HTTP, en (très) gros

Ce cycle requête/réponse se déroule des centaines de fois quand on parcours un site Web. Chaque lien cliqué déclenche une requête GET, et la page suivante ne s’affiche que parce qu’on a reçu la réponse contenant le HTML de celle-ci. Les formulaires, les requêtes AJAX, la mise à jour de vos Tweets sur votre téléphone, tout ça fonctionne sur le même principe.

Donc, sur votre site Web, Firefox, Chrome et les autres vont faire une requête à une machine, votre machine contient un code (si vous êtes dev, votre code :) qui va recupérer cette requête et générer une réponse.

Tout est texte

Généralement, les développeurs utilisent des outils sophistiqués pour écrire des sites Web. Si bien que quand on écrit du code, on ne voit pas vraiment la requête et la réponse, mais des fonctions, des objets, du HTML… Comment ça arrive et comment ça repart est géré automatiquement.

Regardons ce qui se passe quand ce n’est PAS géré automatiquement.

Voici le code d’un petit serveur HTTP écrit en Python 3. Il accepte n’importe quelle requête sur le port 7777 et retourne toujours une page avec marqué “Coucou”.

import asyncio
 
# Le texte de la réponse qu'on va renvoyer
COUCOU = b"""HTTP/1.1 200 OK
Date: Fri, 16 Jun 2014 23:59:59 UTC
Content-Type: text/html
 
<html>
<body>
<h1>Coucou</h1>
</body>
</html>"""
 
# La fonction qui gère chaque requête et qui renvoie une réponse
# pour chacune d'entre elles. La même réponse à chaque fois pour cet
# exemple, mais on peut fabriquer une réponse différente si on veut.
def handle_request(request, response):
    # On lit la requête
    data = yield from request.read(1000000)
    # On affiche son contenu. Surprise, c'est que du texte !
    print(data.decode('ascii'))
    # On écrit notre réponse, que du texte aussi !
    response.write(COUCOU)
    # On ferme la connexion : le protocole HTTP est stateless,
    # c'est à dire qu'il n'y a pas de maintien d'un état
    # côté client ou serveur et chaque requête est indépendante
    # de toutes les autres.
    response.close()
 
if __name__ == '__main__':
    # Machinerie pour faire tourner le serveur :
    # Récupération de la boucle d'événements.
    loop = asyncio.get_event_loop()
    # Création du serveur qui écoute sur le port 7777
    # et qui va appeler notre fonction quand il reçoit
    # une requête.
    f = asyncio.start_server(handle_request, port=7777)
    # Installation du serveur dans la boucle d'événements.
    loop.run_until_complete(f)
    # On démarre la boucle d'événement.
    print("Serving on localhost:7777")
    loop.run_forever()
 
# Si vous êtes dev, vous commencez à comprendre combien
# les libs et frameworks qui gèrent tout ce bordel pour
# vous sont fantastiques.

Si on lance le serveur et qu’on visite la page http://localhost:7777 sur un navigateur, on va alors voir ceci dans le terminal où tourne le serveur :

$ python3 server.py
Serving on localhost:7777
GET / HTTP/1.1
Host: 127.0.0.1:7777
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:30.0) Gecko/20100101 Firefox/30.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Cache-Control: max-age=0

C’est la requête envoyée par notre client, et reçue par notre serveur. “Aller” sur l’adresse veut dire en fait que votre navigateur envoie ce morceau de texte.

Juste après, mon serveur renvoie aussi du texte. Toujours le même dans notre cas :

HTTP/1.1 200 OK
Date: Fri, 16 Jun 2014 23:59:59 UTC
Content-Type: text/html
 
<html>
<body>
<h1>Coucou</h1>
</body>
</html>

Le navigateur l’interprète, et vous fabrique cette page :

Vous contemplez le Web de 1995

Comme vous pouvez le voir, ce n’est que du texte tout simple qui est reçu et envoyé.

Quand quelqu’un développe un site Web, ce qu’il fait vraiment, c’est ça. La plupart du temps il ne le voit pas car ses outils lui facilitent la tâche en analysant le texte et en lui donnant des fonctions et des objets pour le manipuler. Mais derrière, c’est juste du texte.

Quand quelqu’un surf le Web, ce qu’il fait vraiment, c’est ça. Mais le navigateur se charge de le cacher derrière des jolis contrôles, et affiche un résultat bien plus agréable à regarder.

Structure des requêtes

Le texte d’une requête est divisé en 3 parties :

  • L’action demandée sur la ressource.
  • Les headers.
  • Le corps de la requête

Prenons notre précédente requête d’exemple :

GET / HTTP/1.1
Host: 127.0.0.1:7777
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:30.0) Gecko/20100101 Firefox/30.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Cache-Control: max-age=0

L’action demandée sur la ressource est toujours sur la première ligne :

GET / HTTP/1.1

Elle se divise en 3 parties :

  • Le verbe d’action : GET, POST, PUT, OPTION, HEAD, etc. C’est ce qu’on veut faire sur les données qu’on demande. Le plus courant sur le Web, c’est GET (lire la donnée) et POST (créer la donnée, qu’on utilise souvent pour les formulaires).
  • Le chemin de la ressource. Où se trouve la ressource à laquelle on souhaite accéder. Ici c’est “/”, la racine, mais ça peut être “/user/monique/profile” par exemple.
  • La version du protocole utilisé. Aujourd’hui tout le monde utilise HTTP en version 1.1, donc ça ne change pas vraiment.

Notez bien que le chemin de la ressource n’a pas à correspondre à un chemin réel d’un fichier sur l’ordinateur. Une ressource est quelque chose de complètement virtuel, quelque chose que mon programme met à disposition selon mes désirs. Si c’est un fichier, très bien, mais ce n’est pas obligatoire, et je peux générer n’importe quelle réponse qui me plait.

En effet, si je vais sur l’adresse http://127.0.0.1:7777/user/monique/profile/, vous notez que mon serveur continue de marcher. Il reçoit simplement la requête :

GET /user/monique/profile/ HTTP/1.1
...

C’est à mon serveur de décider ce qu’il choisit de faire avec le chemin /user/monique/profile/. Ici il est un peu con et continue de renvoyer “coucou”.

Bien, on vient de voir “L’action demandée sur la ressource”, voyons maintenant les headers :

Host: 127.0.0.1:7777
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:30.0) Gecko/20100101 Firefox/30.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Cache-Control: max-age=0

Les headers sont des informations supplémentaires que le client fourni à mon serveur sous la forme :

Nom-De-L-Information: contenu

Les sauts de ligne sont très importants. La première ligne de la requête est “L’action demandée sur la ressource”, puis on met un saut de ligne, ensuite vient un header, puis un saut de ligne, puis un header… Chaque header doit tenir sur une ligne.

En HTTP/1.1, seul le header Host est obligatoire, mais comme aucun header n’était obligatoire en HTTP/1.0, beaucoup de serveurs acceptent l’absence de tout header.

Les headers contiennent généralement des informations sur le client (ex: Accept-Language vous dit quelles langues le client accepte), le contenu de la requête (ex: Content-Length indique la taille de la requête) ou demande un comportement du server (Cache-Control précise comment gérer les ressources qu’on peut mettre en cache).

Pour “Le corps de la requête”, il nous faut ajouter du contenu à notre requête.

Pour cela, faisons une page avec un petit formulaire HTML :

<html>
<head>
    <title>Test post</title>
</head>
<body>
<form method="post" action="http://127.0.0.1:7777/">
<p><input type="test" value="coucou, tu veux voir mon POST ?" name="salut">
<input type="submit"></p>
</form>
</body>
</html>

Ce qui nous donne cette page :

Capture d'écran d'un simple formulaire web sous firefox

Techniquement, on peut poster un formulaire avec une requête GET, mais shut...

Si on active le formulaire, notre serveur affiche cette requête :

POST / HTTP/1.1
Host: 127.0.0.1:7777
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:30.0) Gecko/20100101 Firefox/30.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: fr,fr-fr;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 41

salut=coucou%2C+tu+veux+voir+mon+POST+%3F

Déjà, vous notez que le verbe d’action a changé : POST / HTTP/1.1.

Ensuite, à la fin de la requête, on laisse une ligne vide, puis on a de nouveau du texte :

salut=coucou%2C+tu+veux+voir+mon+POST+%3F

C’est le corps de la requête.

C’est comme ça que le serveur reçoit le contenu des données des formulaires, et c’est ce qu’on retrouve dans la variable $_POST en PHP ou request.POST en Django

Encore une fois, les outils de programmation évitent au codeur de travailler avec du charabia, et le transforme en quelque chose de facile à comprendre et à manipuler.

Structure des réponses

C’est pareil, ma bonne dame. Reprenons notre exemple de réponse :

HTTP/1.1 200 OK
Date: Fri, 16 Jun 2014 23:59:59 UTC
Content-Type: text/html
 
<html>
<body>
<h1>Coucou</h1>
</body>
</html>

Première ligne, on précise le protocole, puis le code de réponse, qui stipule la nature de votre réponse (tout va bien, une erreur, une redirection, la page n’existe pas, etc).

Ensuite les headers, puis on saute une ligne, et on met le corps de la réponse. Ici, le code HTML qui va donner notre jolie page “Coucou”.

Tout tient là dedans

Tout le reste, toutes les fonctionnalités fantastiques du Web (les liens hypertextes, les fichiers statiques JS et CSS inclus, les cookies, les redirections, le cache, la compression…) ont pour point d’entrée un de ces 3 éléments de la requête ou de la réponse. C’est que c’est un protocole bien foutu.

Enfin, disons, presque tout ? :)


Télécharger le code de l’article

12 thoughts on “Comment fonctionne HTTP ?

  • FLOZz

    Il y a une petite erreur dans le premier schéma : c’est « 401 Unauthorized », pas 301 :)

  • Sam Post author

    Et merde, 301 c’est la redirection (en plus je viens juste de faire un article dessus). J’ai trop la flemme d’éditer le dessin, le reuploader, changer les deux articles… Tant pis, ça restera là.

  • foX

    # On ferme la connexion : le protocole HTTP est stateless,
    # c’est à dire qu’il n’y a pas de maintien d’un état
    # côté client ou serveur et chaque requête est indépendante
    # de toutes les autres.

    Presque ;-). Je chipotte, mais justement en HTTP 1.1 c’est plus sioux. Au niveau HTTP en effet, les requêtes sont indépendantes. La session est gérée (ou pas !) au niveau de l’application par divers moyens, et les fameux cookies sont un des moyens permettant de garder l’état pour savoir qui est qui et recoller les bouts.
    Mais au niveau TCP, ce n’est heureusement pas stateless. Donc après la réponse, le serveur ne fait pas un close() sur la socket TCP, et le client non plus. Cela permet d’envoyer plusieurs requetes HTTP dans la même connexion TCP.

    Pourquoi ça ? Et bien pour des raisons de perf. L’ouverture d’une connexion TCP a un coût (syn, ack, synack) et il est plus rapide de garder les tuyaux ouverts entre un client et le serveur web, une page page web étant souvent constituée de dizaines de ressources demandant chacune un appel HTTP. De plus, cela limite le nombre de connexion TCP en //, ce qui a un coût au niveau de tous les équipements réseaux sur le chemin de votre requête (firewal, reverse proxy, …).

    C’est pour cela que le header sur la taille de la réponse est obligatoire en HTTP 1.1, car le client doit savoir quand ça s’arrette. En HTTP 1.0, c’est facile, il lit jusqu’à qu’à recevoir un close() de la part du serveur. Mais en HTTP 1.1 la socket reste ouverte, donc le client doit lire jusqu’à x octets, ensuite la connexion reste ouverte pour envoyer une autre requête.

    On peut même multiplexer plusieurs requêtes dans une même connexion TCP, c’est à dire que le client fait plusieurs demandes à la suite sans attendre la réponse de chacune, puis reçoit plusieurs réponses, au lieu de simplement sérialiser. C’est plus rock’n roll et pas toujours pris en charge (souvent désactivé dans les browser). Cela s’appelle le pipelining http.

  • policier moustachu

    the schumacher song !!! Depuis le temps que je recherchais ce titre. Merci les gars.

  • policier moustachu

    J’adore la techno kitsh. Autre titre phare : le theme de Mortal Kombat

  • Sam Post author

    @foX: si la connexion reste ouverte, comment ça se fait que ça bloque pas les workers WSGI synchrones non threadés type gunicorn ?

    @policier moustachu: on l’a sur le blog.

  • Alex

    @foX: je vois pas pourquoi tu devrais chipoter, HTTP 1.1 est un protocole stateless les requêtes contiennent l’information nécessaire à la construction de la réponse. N’oublions pas que la RFC indique qu’il n’est pas impossible d’avoir une implémentation par dessus UDP ou tout autre protocole.

    De plus, tu parles du champ Content-length, celui-ci n’est pas obligatoire dans le cas d’un serveur qui fermeraient les connections (Section 4.4 ). C’est également sans parler de la mécanique de messages Chunked. Bien sûr que pour des raisons de performance (et c’est pas forcement le cas article), on ne ferme pas le socket à la fin de la réponse, rien ne l’en empêche de le faire, c’est purement un choix technique et/ou une configuration sur le serveur.

    Il est également intéressant de montrer que le protocole avait été conçu comme un protocole synchrone (requête puis réponse) mais qu’au final la plupart des implémentations ne le sont pas. Ce qui permet de tricher en envoyant la réponse avant même d’avoir reçu la requête Article récent décrivant la découverte.

  • foX

    @Sam : c’est la connexion tcp qui reste ouverte. Un process peut conserver la connexion TCP ouverte sans bloquer le fil d’exécution derrière. C’est l’appel système select qui permet à une application mono thread de gérer plusieurs connexions ouvertes de manière non bloquante.

  • herison

    @sam

    Bonne questions, voici une épave ébauche de serveur sans thread qui peux prendre plusieurs clients en gardant les connexion ouvertent.

    class Server:
        def __init__(self):
            self.sock = socket.socket()
            self.sock.setblocking(False) # rend la socket non-blocante.
            self.sock.bind(("127.0.0.1", 7777))
            self.sock.listen(5)
            self.clients = []
     
        def run(self):
            while True:
                try:
                    sock_client, addr_info = self.sock.accept()
                except BlockingIOError:
                    pass # Personne veux se connecter, pas grave, je m'occupe des clients.
                else:
                    sock_client.setblocking(False)
                    self.clients.append(sock_client)
     
                for client in self.clients:
                    try:
                        print(client.recv(50))
                    except BlockingIOError:
                        pass # Le clients n'envoie rien pas grave, je passe à l'autre.
                    else:
                        try:
                            client.send(b"Coucou")
                        except BrokenPipeError: # Le client à fermé la connection
                            self.clients.remove(client) # on le gicle de la liste
     
                sleep(0.25) # pour pas crammer le cpu
     
        def __del__(self):
            self.sock.close()
     
    class Client:
        def __init__(self, msg):
            self.sock = socket.socket()
            self.sock.connect(("127.0.0.1", 7777))
            for i in range(10):
                self.sock.send(bytes('{} ({})'.format(msg, i), 'utf-8'))
                print(self.sock.recv(7))
            self.sock.close()
     
    # Bon là les thread c'est juste pour simuler 
    # les gonz qui se connectent. Tu peut trés bien
    # mettre les client ds un autre shell sans les thread
    from threading import Timer
    Timer(1, Client, args=("salut",)).start()
    Timer(1, Client, args=("le",)).start()
    Timer(1, Client, args=("monde",)).start()
     
    server = Server()
    server.run()
  • foX

    @Alex : en effet, je ne savais pas qu’on pouvais avoir le comportement à la http 1.0 en fermant également la connexion pour indiquer la fin de réponse. Merci pour l’info.

  • Sam Post author

    Ok, donc c’est bien la structure d’une API qui la rend bloquante, et non l’implémentation socket qui permet bien de gérer cela.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.