mail

net/http est une bibliothèque standard très populaire en Go. Simple pour démarrer, elle vien avec un ensemble de fonctionnalités riches qui permettent de construire, tester et évoluer sans même y penser. Cet article explique comment les pièces s'emboitent. Quelle est la différence entre HandleFunc et HandlerFunc ? Comment écrire un middleware ? Si vous démarrez ou que vous ne comprenez pas complètement comment ça marche... C'est très simple; vérifez par vous-même.

TL;DR http.Handler est une interface et le bloc de base pour gérer les requêtes. httptest aide avec un Recorder et un Server de test. HandlerFunc transforme une fonction en Handler. Un middleware est un decorator qui prend un Handler et retourne un a Handler. Handle et HandleFunc supportent le multiplexer de net/http...

Les sections qui suivent presentent les concepts clé de net/http; elles expliquent pourquoi, pour quoi et illustrent à l'aide de nombreux exemples.

Handler, Server et Listener

http.Handler est une interface Go utilisé comme bloc de base par net/http afin de créer des Server. L'interface est minimale et ne requière qu'une seule méthode. La signature de l'interface consiste en la méthode suivante ServeHTTP(http.ResponseWriter, *http.Request).

Résultat de cette construction, écrire un serveur web avec la bibliothèque standard est très facile : (1) implémentez un Handler (2) démarrez un serveur qui l'utilise. Vous trouverez ci-dessous l'indémodable exemple Hello World! qui fait exactement ça :

// Un simple serveur web
package main

import (
	"fmt"
	"net/http"
)

type helloworld struct{}

func (h *helloworld) ServeHTTP(w http.ResponseWriter,r *http.Request) {
	fmt.Fprintln(w, "Hello World!")
}

func main() {
	http.ListenAndServe(":8080", &helloworld{})
}

Comme vous pouvez le voir, le type helloworld implemente l'interface http.Handler. La function main appelle le helper ListenAndServe qui crée une instance du type helloworld. C'est tout, vous pouvez lancer le code avec la commande go run et utiliser une outil tel que curl pour faire appel à lui :

curl 0.0.0.0:8080
Hello World!

Note dans l'exemple ci-dessus, le helper ListenAndServe crée un http.Server par défaut de manière implicite. Si vous voulez créer et utiliser un Server vous-même pour une configuration avancée telle que configurer un TTL pour vos requêtes ou gérer des arrêts propres, vous pouvez facilement le faire.

Client et Request

Maintenant que vous avez un serveur, vous devriez être capable de coder une requête. La bibliothèque net/http offre les types Request et Client ainsi qu'un jeu de méthodes pour vous aider à les coder. Pour les utiliser, commencez par la fonction requestHome dans l'exemple qui suit :

// Simple Get on / request
package main

import (
	"errors"
	"io"
	"fmt"
	"net/http"
)

func requestHome() error {
	request, _ := http.NewRequest("Get", "http://localhost:8080", nil)
	client := &http.Client{}
	response, err := client.Do(request)
	if err != nil {
		return err
	}
	return readClose(response.Body)
}

func main() {
	err := requestHome()
	if err != nil {
		fmt.Printf("**Error**: %v", err)
		return
	}
}

func readClose(r io.ReadCloser) error {
	if r == nil {
		return errors.New("empty")
	}
	defer r.Close()
	data := make([]byte, 1024)
	for {
		n, err := r.Read(data)
		if n > 0 {
			fmt.Printf("%s", string(data[:n]))
		}
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
	}
}

Tester un Handler

La biblothèque net/http/httptest vient avec un ensemble d'interfaces, types, fonction et méthodes pour vous aider à tester des Handlers sans avoir à démarrer un listener web. Dans lexemple qui suit, vous verrez :

  • Comment créer et utiliser un httptest.Recorder qui implémente l'interface http.ResponseWriter afin de tester un Handler. La méthode Result() qui retourne une *http.Response une fois les Client/Request utilisés
  • Comment créer une requête http.Request basique avec la fonction httptest.NewRequest() pour tester vos Handler.

Dans l'exemple ci-dessous, voux créez une Request et un Recorder pour appeler un Handler. Vous pouvez ensuite tester la Response comme si vous aviez fait un appel distant.

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHelloWorldHandler(t *testing.T) {
	recorder := httptest.NewRecorder()
	request := httptest.NewRequest("Get", "/", nil)
	handler := &helloworld{}
	handler.ServeHTTP(recorder, request)
	result := recorder.Result()
	if result.StatusCode != http.StatusOK {
		t.Errorf(
			"Status Code should be 200, current: %d",
			result.StatusCode,
		)
	}
}

En fait, la biblothèque net/http/httptest inclut également un constructeur pour http.Server. Vous trouverez ci-dessous le même test que le précédent qui utilise cette fois NewServer(). Dans ce cas, vous pouvez simplement lancer la requête avec une commande client.Do() sans avoir à utiliser httptest.Recorder. Vous pouvez utiliser la manière de coder qui vous convient le mieux :

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHelloWorldServer(t *testing.T) {
	handler := &helloworld{}
	server := httptest.NewServer(handler)
	client := server.Client()

	request, _ := http.NewRequest("Get", server.URL, nil)
	response, err := client.Do(request)
	if err != nil {
		t.Errorf("Should not return %v", err)
	}
	if response.StatusCode != http.StatusOK {
		t.Errorf(
			"Status Code should be 200, current: %d",
			response.StatusCode,
		)
	}
}

HandlerFunc et Closure

Vous avez surement déjà entendu que les fonctions en Go sont "fist class". Résultat, si vous préférez utiliser une func au lieu d'un type type, facile. La fonction http.HandlerFunc() convertit une fonction qui partage sa signature avec ServeHTTP et la transforme en une intreface http.Handler.

Un exemple vaut 1000 mots. Voici une réécriture du premier exemple helloworld avec une fonction plutôt qu'un type :

// Un simple serveur web helloworld
package main

import (
	"fmt"
	"net/http"
)

func hello(w http.ResponseWriter,r *http.Request) {
	fmt.Fprintln(w, "Hello World!")
}

func main() {
	handler := http.HandlerFunc(hello) // transforms hello into an Handler
	http.ListenAndServe(":8080", handler)
}

Si vous réduisez le code en utilisant une fonction anonyme que vous appelez directement, vous pouvez réduire le code comme ceci:

// Un simple serveur web helloworld
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.ListenAndServe(
		":8080",
		http.HandlerFunc(
			func(w http.ResponseWriter,r *http.Request) {
				fmt.Fprintln(w, "Hello World!")
			}),
	)
}

Note souvenez-vous du proverbe Go "Clear is better than clever" et pensez aux lecteurs quand vous écrivez le code ci-dessous. Pensez que dans quelques semaines, ce lecteur ça sera peut-être vous.

Le principale bénéfice lorsqu'on utilise des fonctions, c'est la possibilité d'utiliser des closures. Par exemple, si vous voulez que votre serveur web soit multi-langue, vous pouvez créer une variable lang que vous injecterez au dans votre fonction au démarrage de votre serveur comme ceci :

// a simple hello world web server
package main

import (
	"flag"
	"fmt"
	"net/http"
)

func translatedHello(lang string) func(http.ResponseWriter, *http.Request) {
	msg := ""
	switch lang {
		case "fr":
			msg = "Bonjour le Monde!"
		default:
			msg = "Hello World!"
	}
	return func(w http.ResponseWriter,r *http.Request) {
		fmt.Fprintln(w, msg)
	}
}

func main() {
	lang := flag.String("lang", "en", "Server language")
	flag.Parse()
	http.ListenAndServe(
		":8080",
		http.HandlerFunc(translatedHello(*lang)),
	)
}

Vous verrez d'autres façons de coder, y compris avec un container pour réaliser de l'injection de dépendances et ainsi interagir avec des services en backend ou faciliter vos tests.

Middleware

Considérons maintenant une fonction qui a un Handler comme un paramètre et renvoie un Handler en sortie. Vous avez la base d'un Middleware. L'idée derrière le middleware consiste à séparer un ensemble de fonctionnalités de votre code, disons dans une fonction g. De cette manière, si f contient la logique principale, alors g(f) contient la logique principale augmentée des aspects traités par g.

Il existe des dizaines d'applications. Vous pouvez utiliser un middleware pour journaliser et comptabilier les temps de réponse des requêtes. Vous pouvez les utiliser pour l'authentification et gérer les autorisations. Vous pouvez maintenir des sessions, cacher des données, vérifier des headers comme les headers pour les CORS. Le bénéfice de cette approche est de pouvoir séparer les sujets et aussi de coder une fois et de l'utiliser partout.

Mais passons à la pratique. Voici un exemple de middleware simple :

// Un serveur web simple avec un middleware
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func hello(w http.ResponseWriter,r *http.Request) {
	fmt.Fprintln(w, "Hello World!")
}

func logMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
		starttime := time.Now()
		next.ServeHTTP(w, r)
		log.Printf(
			"%dms %s %s",
			time.Since(starttime).Milliseconds(),
			r.Method,
			r.URL.Path,
		)
	})
}

func main() {
	handler := http.HandlerFunc(hello)
	http.ListenAndServe(":8080", logMiddleware(handler))
}

Comme vous aurez déjà pu vous en rendre compte, il est possible d'enchainer les middleware les uns après les autres pour obtenir les fonctionnalités souhaitées. Pour aller plus loin, il est également possible d'utiliser une closure pour générer vos middleware.

Imaginons par exemple que vous vouliez personnaliser le format de vos logs, vous pouvez tout à fait créer une fonction qui retourne une type func(http.Handler) http.Handler comme ci-dessous :

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func hello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello World!")
}

func logFormatMiddleware(format string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			starttime := time.Now()
			next.ServeHTTP(w, r)
			log.Printf(
				format,
				time.Since(starttime).Milliseconds(),
				r.Method,
				r.URL.Path,
			)
		})
	}
}

func main() {
	handler := http.HandlerFunc(hello)
	http.ListenAndServe(
		":8080",
		logFormatMiddleware("%d [%s] \"%s\"")(handler))
}

Multiplexer, Handle et HandleFunc

Une chose manque encore à votre puzzle, la capacité de découper un Handler en morceaux. Disons par exemple que vous voulez un certain fonctionnement lorsqu'un utilisateur appelle /healthz et donc associer un Handler à cette partie et un autre lorsqu'un utilisateur appelle /compute. C'est à ça que sert un Multiplexer ou Mux. Le Multiplexer est lui-même un Handler dont le comportement est modifié selon les Handler que vous lui avez associé.

C'est là que les fonction Handle et HandleFunc interviennent. De manière simple, elles permettent de signifier:

  • si l'appel concerne la route /compute, utilise le Handler compute
  • si l'appel concerne la route /healthz, utilise le Handler healthz

Une fois encore, voici un exemple pour bien comprendre:

package main

import (
	"fmt"
	"net/http"
)

type helloworld struct{}

func (h *helloworld) ServeHTTP(w http.ResponseWriter,r *http.Request) {
	fmt.Fprintln(w, "Hello World!")
}

func goodbye(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Goodbye World!")
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("/hello", helloworld)
	mux.HandleFunc("/goodbye", goodbye)
	http.ListenAndServe(
		":8080",
		mux)
}

Comme vous l'aurez deviné, Handle() est utilisé pour enregistrer un Handler dans le multiplexer, alors aue HandleFunc() est utilisé pour enregistrer une HandlerFunc. Par ailleurs :

  • Il n'est généralement pas utile de créer un multiplexer car il existe un multiplexer par défaut, Celui-ci est alimenté lorque les fonctions http.Handle() et http.HandleFunc() sont utilisées au lieu des méthodes Handle() et HandleFunc(). Si le second paramètre de la fonction http.ListenAndServe() est nil, ce sera le multiplexer par défaut qui sera utilisé.
  • Si vous terminez par / le premier paramètre des fonctions Handle() et HandleFunc() comme dans mux.Handle("/hello/", helloworld), alors toutes les routes sous la route définie comme /hello/gregory seront gérées par le Handler. Vous pourrez utiliser regexp ou d'autres outils associés aux URL pour coder les différents comportements de votre application.

Utilitaires

Comme déjà évoqué, nons seulement la bibliothèque est bien écrite mais elle permet d'évoluer avec un nombre important de scenarios, comme d'utiliser des certificats TLS, des cookies, des keep-alive, des traces, le profiler et beaucoup plus. La bibliothèque est fantastique !

Parmi les outils extrêmement précieux, regardez la fonction httputil.DumpRequest() qui permet d'afficher le contenu d'une requête HTTP et peut s'avérer très utile. Ci-dessous un exemple d'utilisation:

package main

import (
	"fmt"
	"net/http"
	"net/http/httputil"
)

func dump(w http.ResponseWriter, r *http.Request) {
	b, err := httputil.DumpRequest(r, false)
	if err != nil {
		fmt.Fprintf(w, "Error dumping request: %v", err)
	}
	fmt.Fprint(w, string(b))
}

func main() {
	http.ListenAndServe(
		":8080",
		http.HandlerFunc(dump))
}

Voilà ce qui est retourné quand vous l'appelez:

curl 0.0.0.0:8080/goodbye
GET /goodbye HTTP/1.1
Host: 0.0.0.0:8080
Accept: */*
User-Agent: curl/7.64.1

Pour continuer

La plupart des applications écrites en Go s'appuient sur HTTP pour fournir des services comme une API, des appels à des services distant ou publier une application web. Si la bibliothèque standard ne fournit pas tout, elle fournit beaucoup. Prenez le temps de la découvrir et de l'utiliser.

Non pas que Gorilla, Chi, Gin, Echo, Go Kit, FastHTTP, HTTPRouter ne soient pas également d'une grande valeur. Simplement, les utiliser et payer le prix de la maintenance associée, y compris de rester compétent, n'est pas toujours nécessaire.

Prenez le temps d'apprendre net/http; lisez la doc et lisez le code. Il y a tellement de choses à en dire! Il ne fait pas de doute que certain des articles à venir continueront à en creuser le fonctionnement.