{{tag>dev module tutoriel go}}
====== Écrire un module en Go ======
Ce tutoriel propose de créer **deux modules**:
* Le premier sera une bibliothèque prévue pour être importée par d'autres bibliothèques ou applications ;
* Le second est une application appelante utilisant le premier.
Dans ce tutoriel on va :
- Créer un module -- Implémenter un petit module contenant des fonctions qui pourront être appelées depuis un autre module ;
- Appeler le code depuis un autre module -- Importer et utiliser notre nouveau module ;
- Retourner et contrôler une erreur -- Ajouter un contrôle élémentaire des erreurs ;
- Retourner une valeur quelconque -- Manipuler des données dans des slices (tableaux à taille dynamique en Go) ;
- Retourner des valeurs pour l'utilisateur -- Stocker des couples clé/valeur dans un map ;
- Ajouter des test -- Utiliser les fonctionnalités intégrés de Go pour les test unitaires du code ;
- Compilation et installation de l'application en local.
===== Un module utilisable par les autres =====
En Go, le module est l'unité de distribution du code. Il permet de distribuer une collection de paquetages. Par exemple on peut vouloir créer un module contenant des paquetages groupant des fonctions d'analyse financière. Toutes les applications ayant besoin de faire de l'analyse financière pourront bénéficier du code. Pour plus d'informations voir la documentation officielle [[https://go.dev/doc/modules/developing|développement et publication des modules]].
Le code en Go est regroupé en paquetages et les paquetages sont groupés et distribués dans des modules. Le module déclare les dépendances nécessaires pour l'exécution du code incluant la version de Go et l'ensemble des autres modules requis.
Lorsqu'on ajouter ou améliore les fonctionnalités d'un module, on publie une nouvelle version du module. Les développeurs qui appellent/utilisent les fonctions de votre module mettent à jour les paquetages et testent les nouvelles versions avant de passer en production.
Déclarer votre nouveau module en utilisant la commande **''go mod init''** et en fournissant le chemin vers le module. Si vous souhaitez publier votre module se devra être un chemin depuis lequel le module sera téléchargeable par les outils Go : cela pourrait être votre dépôt de code.
# créer un repertoire pour le code
mkdir greetings && cd greetings
# initialise le module
go mod init example.com/greetings
La commande **go mod init** crée le fichier go.mod qui permet le suivi des dépendances. Pour le moment le fichier ne contient que le nom du module et la version de Go supportée.
cat go.mod
module example.com/greetings
go 1.20
Lorsque des dépendances seront ajoutées, le fichier go.mod indiquera les versions des modules desquelles dépend votre code. Cela permet de garder la compilation reproductible et offre un contrôle direct sur les versions des modules à utiliser.
Via votre éditeur créer le fichier greetings.go avec le contenu suivant :
package greetings
import "fmt"
// Hello retourne un message de bienvenue à la personne désignée
func Hello( name string) string {
message := fmt.Sprintf("Salut, %v. Bienvenue!", name)
return message
}
Dans ce code on a:
* Déclarer un paquetage nommé "greetings" regroupant des fonction comme Hello() ;
* Définit la fonction Hello() pour retourner un message de salutation.
La fonction Hello() prend un argument nommé de type chaîne de caractères et retourne une chaîne de caractères. En Go les fonctions commençant par une majuscule peuvent être appelées en dehors du paquetage. En Go ce comportement est désigné "export des noms".
L'opérateur '':='' est un raccourci permettant de déclarer et d'initialiser une variable en une seule ligne. Go utilise l'instruction à droite de l'opérateur pour déterminer le type de la variable.
===== Appel du code depuis un autre module =====
Nous allons ici écrire le code appelant la fonction Hello() du module greetings.
Créer un répertoire hello au même niveau que greetings
cd ..
mkdir hello
Après cette opération on a donc l'arborescence suivante
.
├── grettings
│ ├── go.mod
│ └── greetings.go
└── hello
On entre dans le répertoire hello et on initialise le suivi du module :
go mod init example.com/hello
Ouvrir l'éditeur et créer le fichier hello.go avec le contenu suivant :
package main
import (
"fmt"
"example.com/greetings"
)
func main() {
msg := greetings.Hello("Yoann")
fmt.Println(msg)
}
Dans ce code :
* On déclare un paquetage "main". En Go, un code exécuté en tant qu'application doit être dans un paquetage "main" ;
* On importe deux paquetages : "example.com/greetings" et "fmt". Cela permet d'accéder aux fonctions de ces paquetages ;
* On affiche le message via l'appel de la fonction Hello() dans le paquetage greetings.
On doit maintenant éditer le module "example.com/hello" pour qu'il utilise le module local "example.com/greetings".
Dans le cas où le module est publié les outils Go peuvent le télécharger. Ici ce n'est pas le cas : il faut donc adapter le module "example.com/hello" pour qu'il puisse trouver le module "example.com/greetings" sur le système de fichier local. Depuis la ligne de commande :
go mod edit -replace example.com/greetings=../grettings
La commande spécifie que "example.com/greetings" devra être remplacé par "../grettings" lors du calcul de dépendances. Après exécution de la commande, le fichier go.mod doit contenir une directive **replace** :
cat go.mod
module example.com/hello
go 1.20
replace example.com/greetings => ../greetings
Toujours depuis la ligne de commande dans le répertoire hello, exécuter la commande **''go mod tidy''** pour synchroniser les dépendances du module "example.com/hello", ajoutant ce qui est requis par le code mais pas encore suivi dans le module.
go mod tidy
Après exécution de la commande le ficheir go.mod doit être de la forme :
module example.com/hello
go 1.20
replace example.com/greetings => ../grettings
require example.com/greetings v0.0.0-00010101000000-000000000000
La commande a bien trouvé le code dans le répertoire local ../grettings et introduit la directive ''require''. Cette dépendance a été créée par l'import du paquetage greetings dans hello.go
LEn complément du module on retrouve une pseudo-version générée à la place de la version semantique (semantic version number). La documentation officielle décrit en détail le [[https://go.dev/doc/modules/version-numbers|versionning des modules]].
go run .
Salut, Yoann. Bienvenue!
===== Retourner et contrôler une erreur =====
La gestion des erreurs est nécessaire à la production d' un code solide. On va maintenant aborder la génération d'une erreur depuis le module 'greetings' et sa gestion par le module appelant 'main'.
Via l'éditeur, on modifie le fichier ''greetings/greetings.go'' comme proposé ci-dessous:
package greetings
import (
"errors"
"fmt"
)
// Hello returns a greeting for the named person.
func Hello(name string) (string, error) {
// If no name was given, return an error with a message.
if name == "" {
return "", errors.New("empty name")
}
// If a name was received, return a value that embeds the name
// in a greeting message.
message := fmt.Sprintf("Hi, %v. Welcome!", name)
return message, nil
}
A propos des modifications apportées:
* La fonction Hello() retourne deux valeurs de type 'string' et 'error'. L'appelant devra vérifier la seconde valeur pour évaluer si une erreur s'est produit. Toutes les fonctions Go peuvent retourner plusieurs valeurs ;
* On a importé le paquetage "errors" de la bibliothèque standard Go et on a utiliser la fonction errors.New() pour générer un erreur ;
* Une instruction "if" permet de générer une si la valeur de l'argument name est incorrecte ;
* Le retour normal de la fonction contient la valeur nil en seconde valeur.
On peut à présent modifier le code appelant dans le fichier ''hello/hello.go'' :
package main
import (
"fmt"
"log"
"example.com/greetings"
)
func main() {
//Définition de propriétés du Logger
log.Default().SetPrefix("greetings: ")
//Désactive l'afficahge du timestamp, du fichier source et du numero
//de ligne
log.SetFlags((0))
msg, err := greetings.Hello("")
//Si une erreur est retournée, elle est affichée dans la console
//et le programme s'arrête
if err != nil {
log.Fatal(err)
}
//Affiche le message dans la console
fmt.Println(msg)
}
Dans le code ci-dessus on a :
* Configurer le paquetage "log" pour afficher le préfixe "greetings: " en entête des message de log et on a désactiver le timestamp et le nom du fichier source ;
* Assigné les deux valeurs retournées par Hello à des variables ;
* Modifié l'argument de la fonction Hello() en une chaîne vide pour provoquer l'erreur ;
* Testé la valeur de l'erreur et terminer le programme dans le cas ou elle existe ;
* Utilisé les fonctions du paquetage "log" de la bibliothèque standard de Go pour afficher les informations d'erreur et arrêter le programme avec log.Fatal().
Depuis la ligne de commande, on peut exécuter notre code:
go run .
greetings: empty name
exit status 1
===== Retourner un message aléatoire =====
On va modifier un peu le code pour retourner un message aléatoirement parmi un petit ensemble de messages. Pour cela on va utiliser une Go slice. Une slice peut être vu comme un tableau avec la particularité de pouvoir changer de taille dynamiquement lors des ajouts/suppressions d' éléments.
Nous allons ajouter une slice contenant trois messages et le code permettant d'en retourner aléatoirement un parmi les trois.
On ouvre l'éditeur et on modifie le fichier ''greetings/greetings.go''
package greetings
import (
"errors"
"fmt"
"math/rand"
)
// Hello retourne un message de bienvenue à la personne désignée
func Hello(name string) (string, error) {
//Si aucun nom n'est donné retourne une erreur avec un message.
if name == "" {
return "", errors.New("empty name")
}
message := fmt.Sprintf(randomFormat(), name)
return message, nil
}
// randomFormat retourne aléatoirement un message préformatté parmi l'ensemble des messages disponibles.
func randomFormat() string {
formats := []string{
"Hello, %v. Bienvenue!",
"Heureux de vous revoir %v!",
"Salut %v, enchanté!",
}
//retourne aléatoirement un format de message
return formats[rand.Intn(len(formats))]
}
Quelques remarques:
* La fonction randomFormat() commence par une minuscule la rendant accessible seulement par le code de son propre paquetage (en d'autres termes la fonction ne sera pas exportée).
* La fonction randomFormat() déclare une slice ''formats'' contenant trois messages préformattés. Pour déclarer une slice, on ne spécifie pas le nombre d'éléments entre les crochets. Cela indique à Go que la taille du tableau contenu dans la slice peut changé ;
* On utilise le paquetage "math/rand" pour générer un nombre aléatoirement afin de sélectionner un élément de la slice ;
* Dans la fonction Hello() on appelle maintenant randomFormat() pour obtenir un message aléatoire.
Le fichier hello.go est légèrement modifié : on remplace la chaîne vide par une chaîne de caractères quelconque pour retrouver le comportement normal du programme.
yoann@node-7c87:~/dev/go/hello$ go run .
Hello, Yoann. Bienvenue!
yoann@node-7c87:~/dev/go/hello$ go run .
Heureux de vous revoir Yoann!
yoann@node-7c87:~/dev/go/hello$ go run .
Heureux de vous revoir Yoann!
yoann@node-7c87:~/dev/go/hello$ go run .
Heureux de vous revoir Yoann!
yoann@node-7c87:~/dev/go/hello$ go run .
Hello, Yoann. Bienvenue!
===== Retourner un message à plusieurs personnes =====
Nous allons modifier une dernière fois nos modules afin d'obtenir des messages pour plusieurs personnes en une seule requête. Pour cela, nous allons devoir gérer plusieurs valeurs en entrée, associer à chacune une valeur et produire en sortie à valeurs multiples. En entrée on devra passer un ensemble de noms à la fonction qui retournera pour chacun des noms un message de bienvenue.
Ces modifications soulèvent une problématique : modifier les paramètres de la fonction Hello() change sa signature. Si le module a déjà été publié et qu'il est utilisé, ces changements conduiront à une à des erreurs dans les programmes appelants.
Dans ce cas une meilleur option est d'écrire une nouvelle fonction avec un nom différent. Cette nouvelle fonction prendra plusieurs paramètres. Cela permet de préserver l'ancienne fonction et assure la rétrocompatibilité.
Via l'éditeur, on modifie notre fichier greetings/greetings.go
package greetings
import (
"errors"
"fmt"
"math/rand"
)
// Hello retourne un message de bienvenue à la personne désignée
func Hello(name string) (string, error) {
//Si aucun nom n'est donné retourne une erreur avec un message.
if name == "" {
return "", errors.New("empty name")
}
message := fmt.Sprintf(randomFormat(), name)
return message, nil
}
// HelloAll retourne un map associant un message de bienvenue à chaque personne en entrée
func HelloAll(names []string) (map[string]string, error) {
//un map pour associer un message à chaque nom
messages := make(map[string]string)
//boucle sur la slice en entrée (les noms) et appelle Hello() pour associer une message
for _, name := range names {
//pour chaque index(non utilisé _), valeur du paramètre 'names'
msg, err := Hello(name)
if err != nil {
//une erreur s'est produite, on retourne un objet vide et le code erreur
return nil, err
}
messages[name] = msg
}
return messages, nil
}
// randomFormat retourne aléatoirement un message préformatté parmi l'ensemble des messages disponibles.
func randomFormat() string {
formats := []string{
"Hello, %v. Bienvenue!",
"Heureux de vous revoir %v!",
"Salut %v, enchanté!",
}
//retourne aléatoirement un format de message
return formats[rand.Intn(len(formats))]
}
Dans ce code nous avons:
* Ajouté une fonction HelloAll() ayant en paramètre une slice de plusieurs noms et retournant un map permettant d'associer un message (valeur) à chaque nom (clé) du map.
* La nouvelle fonction HelloAll() appelle la fonction Hello(). Cet usage des fonction est aussi désigné factorisation et réduit les risques d'erreurs liés à la duplication de code.
* Créé une variable "messages" de type map pour associer chaque message retourné par Hello() comme valeur à un clé (le nom passé en paramètre). En Go on initialise un map avec la syntaxe **''make(map[key-type]value-type)''**. La fonction HelloAll() retourne le map à l'appelant.
* Exécuter une boucle sur chaque élément de la slice "names". Dans cette boucle **''for''** l'opérateur **''range''** retourne deux valeurs : l'index de l'élément courant dans la boucle et une copie de sa valeur. N'ayant pas besoin de l'index il a été affecté à l’[[https://go.dev/doc/effective_go.html#blank|identifiant spécial '_']] pour être ignoré.
Dans notre fichier hello.go nous pouvons à présent faire quelques changements :
package main
import (
"fmt"
"log"
"example.com/greetings"
)
func main() {
//Définition de propriétés du Logger
log.Default().SetPrefix("greetings: ")
//Désactive l'afficahge du timestamp, du fichier source et du numero
//de ligne
log.SetFlags((0))
//définition d'une slice contenant les noms
names := []string{"Yoann", "Alfred", "Emilie", "John"}
//Obtenir un message de bienvenue pour chaque nom
messages, err := greetings.HelloAll(names)
//Si une erreur est retournée, elle est affichée dans la console
//et le programme s'arrête
if err != nil {
log.Fatal(err)
}
//Affiche le message dans la console
fmt.Println(messages)
}
Les changements sont les suivants :
* Création d'une variable "names" de type slice contenant 4 noms ;
* Passage en argument de la variable "names" à la fonction HelloAll().
Exécutons le code ainsi modifié :
go run .
map[Alfred:Heureux de vous revoir Alfred! Emilie:Hello, Emilie. Bienvenue! John:Heureux de vous revoir John! Yoann:Salut Yoann, enchanté!]
===== Ajout d' un test unitaire =====
Maintenant que notre code a implémenter les fonctionnalités souhaitées, ajoutons un test. Tester le code pendant le développement peut aider à révéler les bugs introduits lors des différentes modifications. Ici nous allons ajouter un test pour la fonction 'Hello()'.
Go intègre le support des tests unitaires. Le paquetage "testing" de Go et la commande test s'appuient sur des conventions de nommage et permettent d'écrire et d'exécuter facilement et rapidement des tests.
* Dans le répertoire greetings, créer un fichier ''greetings_test.go''. Terminer une fichier par le suffixe "_test.go" indique à la **commande test** de go que ce fichier contient des fonctions de test ;
* Éditer le fichier greetings_test.go avec le contenu suivant :
package greetings
import (
"regexp"
"testing"
)
// TestHelloName appelle Hello() avec un nom et vérifie
// la valeur retournée.
func TestHelloName(t *testing.T) {
aName := "Albert"
want := regexp.MustCompile(`\b` + aName + `\b`)
msg, err := Hello("Albert")
if !want.MatchString(msg) || err != nil {
t.Fatalf(`Hello("Albert") = %q, %v, want match for %#q, nil`, msg, err, want)
}
}
// TestHelloEmpty appelle Helo() avec une chaine vide doit
// retourner une erreur.
func TestHelloEmpty(t *testing.T) {
msg, err := Hello("")
if msg != "" || err == nil {
t.Fatalf(`Hello("") = %q, %v, want "", error`, msg, err)
}
}
Dans ce code on a :
* Définit des fonctions de test dans le même paquetage que le code à tester ;
* Définit deux fonctions de test pour tester la fonction greetings.Hello(). Les fonctions de test doivent avoir un nom préfixé par 'Test'. Les fonctions de test doivent en argument un pointeur vers le paquetage testing. Les méthodes de ce paramètre sont utilisées pour générer les rapports et les logs du test ;
* A propos des deux tests:
* TestHelloName() appelle la fonction Hello() avec un nom en argument et vérifie que la fonction est capable de retourner un message valide. Si le retour est une erreur ou un message avec un contenu non attendu on utilise le paramètre "t" et sa méthode "Fatalf()" pour afficher un message dans la console et interrompre l'exécution.
* TestHelloEmpty() appelle la fonction Hello() avec une chaine vide. Ce test permet de valider le fonctionnement de la gestion des erreurs. Si le test retourne une chaine non vide ou ne retourne pas d'erreur on utilise le paramètre "t" et la méthode "Fatalf()" pour afficher un message et interrompre l'exécution des tests.
Depuis la ligne de commande on peut lancer les tests:
go test
La commande **go test** exécute les fonctions de test (ayant des noms préfixés par "Test") dans les fichiers *_test.go. On peut ajouter l'argument -v (verbeux) pour avoir un retour plus détaillé de l'exécution des tests.
Si on introduit (ici volontairement) une erreur lors du developpement en modifiant le comportement de la fonction Hello(), une des fonctions de test pourra révèler l'anomalie:
go test -v
=== RUN TestHelloName
greetings_test.go:16: Hello("Albert") = "Heureux de vous revoir %!v(MISSING)!", , want match for `\bAlbert\b`, nil
--- FAIL: TestHelloName (0.00s)
=== RUN TestHelloEmpty
--- PASS: TestHelloEmpty (0.00s)
FAIL
exit status 1
FAIL example.com/greetings 0.140s
===== Compiler et installer l'application =====
Ici on aborde quelques nouvelles commandes Go. La commande go run permet de rapidement compiler et exécuter un programme lors du développement alors qu'on effectue régulièrement des modifications.
* La commande **''go build''** compile les paquetages et les dépendances, produit un binaire mais n'installe pas le résultat ;
* La commande go **''install''** compile et installe le binaire.
Lorsque le binaire est créé avec **''go build''** il peut être exécuté seulement depuis le répertoire courant ou si l'on précise son chemin complet (absolu). Installer l’exécutable consiste à placer le binaire dans un répertoire spécifique afin de pouvoir l'invoquer sans préciser son chemin complet.
Pour connaitre le chemin du dossier d'installation utlisé par Go:
go list -f '{{.Target}}'
Vérifier que le dossier d'installation de Go est présent dans le PATH :
echo $env:PATH
La variable PATH doit contenir le dossier d'installation de Go. Si ce n'est pas le cas modifier le PATH de l'utilisateur.
Si vous souhaitez installer le binaire dans un répertoire différent de votre PATH, vous pouvez modifier/définir la valeur de la variable **''GOBIN''**
go env -w GOBIN=C:\path\to\your\bin
Une fois le chemin d'installation identifier/redéfinit vous pouvez invoquer la commande ''**go install**''.
===== Références =====
* https://go.dev/doc/tutorial/create-module