En 2013, nous apprenons les technologies dans le sens inverse

Je me souviens vaguement des cours sur les serveurs web. Je pense me souvenir d’un cours rapide qui expliquait qu’un serveur web fournit le plus souvent des fichiers via http. Très vite, les cours évoluent pour créer un serveur utilisant WebStorm en C#. Le temps passe, et j’étais maintenant capable d’écrire une application web. Mon expérience très nouvelle m’a fait sauter les étapes d’apprentissages fondamentaux : l’application utilise http pour transférer les pages web. Bref, même si j’étais capable d’écrire une application web, j’étais incapable de comprendre les échanges du protocole http. Impossible pour moi de comprendre les responsabilités du client et du serveur.

Les années passent, et ma compréhension s’améliore. Puis, en 2019, soit 6 ans plus tard, je m’attèle vraiment à une compréhension fine du protocole http.

S’il m’a fallu 6 ans pour comprendre le protocole, j’imagine que mes collègues de même âge ont une expérience similaire. Pour tous ceux qui n’ont pas encore exploré le protocole http, voici un client naïf, mais fonctionnel pour comprendre les interactions entre client et serveur. Ceci en moins de 100 lignes de code. Explorons la version 1.0 assez facile à implémenter, car nous devons fermer la connexion TCP entre chaque requête.

Après tout, si le nombre de développeurs double tous les 5 ans, ceci implique qu’à tout moment la moitié des développeurs ont moins de 5 ans d’expérience.

De plus, un constat est que la majorité d’entre nous explorent le développement via des frameworks pré-établis. Ça n’est pas une erreur en soi, mais implique que nous l’ordre d’apprentissage des technologies change. Au lieu d’apprendre les protocoles, et ensuite les outils de productivité, nous apprenons d’abord les outils et ensuite leurs fonctionnements. Mais je garde plus de réflexion à ce sujet pour un autre article pour plus tard.

Écrivons un client naïf http 1.0

N’utilisez pas ce client en prod. Le body renvoyé n’est pas recopié exactement as-is, des fonctionnalités sont manquantes. Afin de garder l’exemple simple, des CRLF peuvent être remplacé par des LF dans le body de la réponse, les fonctionnalités sont limitées au strict nécessaire pour avoir des réponses syntaxiquement correctes pour le serveur.

Le langage go n’est pas le plus populaire actuellement en Belgique, toutefois sa simplicité, l’écriture relativement bas niveau, ainsi que la librairie standard adapté à de l’IO en fait un langage adapté à aujourd’hui. Les commentaires inclus dans le code sont adaptés pour un public connaissant le C#.

package httpclient

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"net"
	"net/url"
	"strconv"
	"strings"
)

// Equivalent of a struct declaration in C#.
type HttpRequest struct {
	URL     *url.URL
	Method  string
	Headers map[string]string

	Body io.Reader
}
type HttpResponse struct {
	Status  int
	Headers map[string]string
	Body    io.Reader
}

// Main "class",
type Client struct{}

// Constructor of the Client struct. As Ctor return a pointer of the struct, it is equivalent of `public Client() {...}` in C# that return a reference of an instance.
func NewHttp10Client() *Client {
	return &Client{}
}

// In go, methods are defined outside the struct declaration. This is equivalent of adding `public (HttpResponse, Exception) Do() {...}` in C#
func (c *Client) Do(request HttpRequest) (*HttpResponse, error) {
	// 0.0 Open a TCP connection to the server, and start listening into it
	connection, err := net.Dial("tcp", request.URL.Host)
	if err != nil {
		return nil, err
	}
	defer connection.Close()

	// This first section write the request, like described here https://datatracker.ietf.org/doc/html/rfc1945#section-5
	// 1.0 Write request line
	path := request.URL.Path
	if path == "" {
		path = "/"
	}
	io.WriteString(connection, fmt.Sprintf("%s %s HTTP/1.0\r\n", request.Method, path)) // Will write something like `GET /api/v1/mycontroller HTTP/1.0`

	// 1.1 Write headers
	// In case of a body in the request, the request object have to contains the Content-Length header with valid value
	for key, value := range request.Headers {
		io.WriteString(connection, fmt.Sprintf("%s: %s\r\n", key, value)) // Will write something like `Host: blog.craflabit.be`
	}
	// Add empty line to informe that all Headers are written
	io.WriteString(connection, "\r\n")
	// 1.2 Write body
	if request.Body != nil {
		io.Copy(connection, request.Body)
	}

	// Scanner is an object that read an io.Reader, and able to return strings line by lines.
	scanner := bufio.NewScanner(connection)

	response := &HttpResponse{}
	response.Headers = make(map[string]string)

	// The second section read the response, like described in https://datatracker.ietf.org/doc/html/rfc1945#section-6
	// 2.0 Read status line
	scanner.Scan()              // Read next line
	firstLine := scanner.Text() // Get line value. Should be like "HTTP/1.1 200 OK"

	statusStr := strings.Split(firstLine, " ")[1] // Extract http status number
	status, err := strconv.Atoi(statusStr)        // Convert to integer

	if err != nil {
		return nil, err
	}
	response.Status = status

	// 2.1 Read headers
	// Get next line until an empty line. In Rfc, empty line means end of headers, and start of body if any.
	for scanner.Scan() && scanner.Text() != "" {
		line := scanner.Text()
		parts := strings.Split(line, ": ")
		response.Headers[parts[0]] = parts[1]
	}

	// 2.2 Read body
	// Read the whole body and put in in a Buffer, equivalent of MemoryStream
	var buffer bytes.Buffer

	for scanner.Scan() {
		io.WriteString(&buffer, scanner.Text())
		io.WriteString(&buffer, "\r\n")
	}
	response.Body = &buffer

	return response, nil
}

Toutes les transitions sont numérotées afin que vous puissiez faire le lien entre la description et le code.

Description globale du protocole http 1.0

L’acronyme http pour Hyper Text Transfert Protocol échange des messages via une syntaxe texte. Ce qui signifie que le texte envoyé à une signification pour le protocole. Par exemple le caractère ‘:’ permet de séparer la clé et la valeur d’un header. Les retours à la ligne peuvent signifier que la suite contient la prochaine section ou encore le prochain header. Aussi, les valeurs numériques des headers, status et autre sont encodés en ASCII

Cette approche texte a le bénéfice d’être à la fois lisible par l’humain et l’ordinateur, et aussi facile à implémenter. C’est probablement le cumul de ces 2 avantages qui a contribué a l’adoption massive de ce protocole par notre communauté.

Le protocole a toujours le même schéma : un client envoi une requête et le serveur répond. La requête doit contenir une ligne de requête, 0 ou plusieurs headers, et peut contenir un body La réponse doit contenir une ligne de status, 0 ou plusieurs headers, et peut contenir un body

A des fin d’exemple, la requête utilisée sera celle-ci:

httpclient.HttpRequest{
    URL:     "http://localhost:8080/path",
    Method:  "POST",
    Headers: map[string]string{"Content-Type": "text", "Content-Length": "11", "Host": "localhost"},
    Body:    io.NopCloser(bytes.NewBufferString("hello world")),
}

0.0 établir la connection avec le serveur.

Http peut (mais pas nécessairement) se baser sur le protocole de transport TCP. Tout ce que vous devez savoir est que la connection est un objet dont on peut lire et écrire des bytes (et donc possiblement du texte). Dans ce cas-ci une connection tcp.

1.0 Écrire la ligne de requête

Toutes les requêtes http doivent commencer par une ligne de requête. Cette ligne contient la méthode (GET, HEAD, POST ou une méthode étandue que votre serveur supporte), le path (l’url sans le domaine), et la version du protocole utilisé. Dans notre cas, nous utilisons la version 1.0

La première ligne écrite sur la connexion est donc GET / HTTP/1.0

la version 1.0 décrit uniquement les méthodes GET, HEAD et POST. Mais libre aux implémentations de supporter plus de méthode. “FOO /mypage HTTP/1.0” est tout à fait valide.

le deuxième segment de la ligne n’indique pas le nom de domaine. Pour notre exemple, c’est suffisant, mais insuffisant pour d’autre cas d’utilisation notamment pour les proxies. Si vous avez un proxy, le nom de domaine y est requis. Sans proxy, le nom de domaine est interdit. Ce qui signifi que vous devez adapter le contenu de votre message selon la présence ou non de proxy. Http 1.1 résout cette ambiguïté via le header Host obligatoire.

1.1 Écrire les headers

Chaque header est écrite précisément avec le format Key: Value dont la séparation est faite via : .

Dans le cas où la requête contient une entité (=body), il est nécessaire que le header Content-Length soit présent.

La fin de la section des headers est définie par un double CRLF. Soit la requête contient un body informé le header Content-Length, soit il n’y en a pas.

À des fins de simplification de lecture de code, le header Content-Length soit être instruite par l’utilisateur du client. C’est le cas ici où la requête d’exemple indique une longueur de 11 bytes

1.2 Écrire le body

Il ne reste qu’à simplement écrire sur la connexion le body renseigné par la requête. La longueur doit être égale à la valeur de Content-Length.

À ce stade, la requête est terminée. Le serveur reçoit sur le réseau la fin de la requête et doit y répondre.

2.0 Lecture de la ligne de status

Le serveur, qu’il ait compris ou non la requête, doit répondre. Idéalement avec une valeur 2xx pour indiquer une bonne réception ou 3xx pour indiquer que la ressource existe ailleurs ou une cache. 4xx pour des erreurs de compréhension de la requête, et 5xx si le serveur n’a pas été en capacité de répondre à une requête valide.

Cette information de status passe par la première ligne qui ressemble à ceci HTTP/1.0 201 Created. Le serveur confirme l’usage du protocole http 1.0, donne une valeur numérique (mais sous un encoding textuel), et une phrase de raison (optionnel).

2.1 Lecture des headers

Tout comme dans la requête, la réponse contient des headers. Le format est strictement identique à la requête.

S’il y a une présence d’un body, le header Content-Length devient optionnel pour la réponse. Comme la connexion doit être fermée après la réponse du serveur, le client peut lire le contenu du body jusqu’à ce que la connexion soit fermé, indiquant que le body complet est transféré.

2.2 Lecture du body

La réponse est écrite dans un buffer et mis à disposition dans la réponse. La connexion est fermée par le serveur à la fin du body.

Une autre implémentation valide est de ne pas écrire la réponse dans un buffer, mais de renvoyer la connection en cours. C’est d’ailleurs le choix de la majorité des http client des langages que nous employons Ainsi, les utilisateurs des clients peuvent déjà traiter comme ils souhaitent la réponse sans devoir attendre un retour complet du body. C’est d’ailleurs pour ces raisons qu’en C#, 2 await sont nécessaires sur les objets les plus primitifs du HttpClient.

var httpClient = new HttpClient();
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://www.google.com");
var response = await httpClient.SendAsync(httpRequest); // A la complétion de la task, la status line et les headers sont reçus. Mais la connexion est toujours en cours d'envoyer le contenu du body. Nous pouvons déjà vérifier la présence de header, ou bien la valeur du status code.
var body = await response.Content.ReadAsStringAsync(); // A la complétion de la task, le body est reçu

Plus loin

Dans le cas d’aujourd’hui, écrire une implémentation partielle d’un protocole ne sert à ajouter un outil dans ses librairies, mais de s’assurer que la compréhension d’un protocole est correcte. Aussi, vous pouvez découvrir des subtilités par accident.

Par exemple, il est facile d’ajouter un temps d’arrêt de quelques secondes entre l’écriture des headers et du body, et observer le comportement de votre framework http server préféré. La plupart traite déjà votre requête avant même de recevoir le body.

C’est aussi l’occasion de se familiariser avec les Request For Comment (RFC), les spécifications produites par l’IETF pour décrire les protocoles de l’internet. D’ailleurs, ils font d’excellent poisson d’avril, la lecture idéale pour à la fois découvrir la discipline d’écriture tout en passant un bon moment :)

Le protocole http peut se faire via n’importe quelle channel de communication bidirectionnelle. Vous pouvez très bien créer un serveur http écoutant sur un unix socket et faire des requêtes sur ce socket. C’est exactement ce que fait le docker daemon pour le serveur et le docker-cli. Si vous avez docker en route, faites curl --unix-socket /var/run/docker.sock http://localhost/containers/json pour voir :)