À la découverte du langage Go : création d'un outil pas à pas
Publié le 17 avril 2024
Pour la 2ème année consécutive, Go (pour Golang, créé il y a maintenant 15 ans par Google), se hisse dans le top 10 des langages les plus populaires sur GitHub. Le langage est même le 5ème en terme de vitesse de progression.
Cette popularité s’explique par de multiples facteurs : ses performances impressionnantes, son usage dans des produits très à la mode (Docker, K8s, Caddy), ou encore ses utilisations dans le domaine des intelligences artificielles où il offre une intéressante concurrence à Python. Chez Les-Tilleuls.coop, nous nous sommes très vite intéressés à Go : nous avons contribué directement au langage et avons développé ces dernières années plusieurs produits :
- Mercure pour remplacer les websockets avec une solution rapide et fiable.
- Le très populaire FrankenPHP, un serveur PHP moderne et rapide
- Gosumer pour consommer vos messages Symfony Messenger
- Caddy-cbrotli un module Caddy pour le support de la compression Brotli
- Vulcain
- Nous sommes également sponsors et mainteneurs de Caddy
Cela a aussi été le sujet de quelques articles sur notre blog et nous vous proposons également des formations sur mesure adaptées à vos besoins.
Récemment, j’ai donc souhaité moi aussi m’initier à Go et comprendre son fonctionnement, sa syntaxe et découvrir par moi-même les performances dont j’avais beaucoup entendu parler. Je suis donc parti de zéro, via la documentation officielle, dans l’optique d’écrire un script assez simple permettant de détecter les dump/die
oubliés dans mon code PHP. Fonctions, boucles, goroutines, découvrons ensemble les rudiments du langage avant de benchmarker sommairement notre script.
Écrire notre premier script Go
Une fois le langage installé localement, créons notre premier fichier Go, le mien s’appelle dumpsniffer.go.
La première étape va être de pouvoir utiliser des arguments en ligne de commande afin de lancer mon script en lui spécifiant un chemin vers un fichier ou un dossier. On va donc importer le package “os” dans la liste de dépendances et la variable Args pourra être utilisée comme la variable $argv
en PHP, elle nous renvoie un tableau de strings et comme en PHP, la première entrée est le nom du script lancé (ici le chemin complet vers l'exécutable), la seconde sera le chemin vers le fichier.
package main
import (
"fmt"
"os"
)
func main() {
// Ici on récupère notre chemin
path := os.Args[1]
}
Comme vous pouvez le voir, Go propose une syntaxe d’affectation courte :=
pour la déclaration de variable, cette affectation/déclaration n’est disponible qu’à l’intérieur d’une fonction. Elle infère automatiquement le type de la variable, à l’inverse de la déclaration “longue” qui l’attend : var foo string
Nos premières fonctions
Il est temps d’écrire nos premières méthodes Go en ajoutant des fonctions permettant de savoir si le chemin fourni en paramètre correspond à un fichier ou à un dossier, et de vérifier son extension.
Nous utilisons de nouveau le package “os”, et cette fois sa fonction Stat(), qui renvoie des informations sur le fichier fourni. Nous voyons ici une autre particularité des fonctions en Go, elles peuvent renvoyer plusieurs résultats comme en Python. En Go vous pouvez nommer vos retours, n’oubliez pas de typer les deux retours entre parenthèses.
func isFile(path string) (flag bool, err error) {
fileInfo, err := os.Stat(path)
if err != nil {
return false, err
}
return fileInfo.Mode().IsRegular(), nil
}
// Pour récupérer le retour de la fonction:
func main() {
flag, err := isFile(path)
}
Ici les fonctions Mode() puis IsRegular() permettent de savoir s’il s’agit bien d’un fichier. On va ensuite écrire la fonction chargée de lire les fichiers ligne par ligne à la recherche des occurrences suivantes : dump( die;
et die(
.
Pour ce faire, nous avons besoin de deux packages supplémentaires :
func checkDumpDieOccurences(filePath string){
file, err := os.Open(filePath)
scanner := bufio.NewScanner(file)
lineNumber := 1
for scanner.Scan() {
lineContent := scanner.Text()
if strings.Contains(lineContent, "dump(") {
fmt.Printf("%s: dump/var_dump found on line %d \n", filePath, lineNumber)
}
lineNumber++
}
}
Ici avec le package os
vu précédemment, on ouvre le fichier correspondant au chemin spécifié, on le scanne grâce à bufio
puis on boucle sur les lignes afin de trouver les occurrences qui nous intéressent.
Les goroutines
Les goroutines font partie des concepts clés du langage Go. Elles permettent de paralléliser des tâches en utilisant plusieurs cœurs de processeurs, et ainsi de décupler la vitesse d'exécution de nos processus. Pour faire simple : pas besoin d’attendre la fin d’une tâche avant de commencer à exécuter la suivante.
Pour utiliser ces fonctionnalités de base, nous avons besoin du package “sync” :
- Le type WaitGroup permet d'attendre qu'une collection de goroutines aient terminé.
- La fonction Add() attend un entier qui va incrémenter le compteur du WaitGroup (ici à chaque itération de ma boucle).
- La fonction Done() va quant à elle elle décrémenter le compteur à chaque fois qu’une tâche est terminée.
- Enfin la méthode Wait() attend que le compteur arrive à 0 (ce qui signifie que l’exécution de toutes les goroutines est terminée).
En l’état, notre code ressemble peu ou prou à :
if isDir(path) {
var wg sync.WaitGroup
dirError := filepath.Walk(path, func(filePath string, fileInfo os.FileInfo, fileError error) error {
if isPHPFile(filePath) {
wg.Add(1)
go func(filePath string) {
defer wg.Done()
checkDumpDieOccurences(filePath)
}(filePath)
}
return nil
})
wg.Wait()
}
Si le chemin spécifié correspond à un répertoire, on crée alors un WaitGroup sur lequel on utilise la méthode Walk() du package filepath
afin de boucler sur tous les fichiers de notre dossier (cette méthode attend en paramètre un chemin et une fonction de callback WalkFunc()). Lors de chaque itération, nous vérifions via une méthode personnalisée si le fichier est de type PHP, si oui le compteur du WaitGroup est incrémenté puis nous utilisons la syntaxe go func()
pour lancer une goroutine utilisant une fonction anonyme pour exécuter notre checker de dump/die
, avant de terminer la routine par un appel à la méthode Done()
. Le mot clé “defer” permet de reporter le Done()
à la fin de notre fonction anonyme, ainsi quoi qu’il arrive dans la suite de notre code, le Done()
sera tout de même effectué, la goroutine pourra donc être considérée “terminée”, et notre compteur être décrémenté. (la méthode Wait()
permet d’attendre que chaque goroutine soit terminée pour clore le script).
Côté performances, j’ai lancé ici deux fois le script :
Une fois de manière procédurale, sans paralléliser les tâches via les goroutines :
Une seconde fois après l’implémentation des goroutines :
Le temps d'exécution est divisé par 2.5, la mémoire consommée est réduite d’environ 30%.
Note : Nous aurions pû utiliser la méthode Glob() à la place de la méthode Walk()
du package filepath
, puisque Glob()
permet de récupérer dans un dossier tous les fichiers répondant à un pattern donné et donc ici à l’extension .php. Cependant elle ne supporte actuellement pas le pattern double stars **/*.php
qui permettrait de récupérer tous les fichiers du dossier et des sous dossiers de manière récursive. Il faut ajouter une dépendance supplémentaire pour en profiter. Mais les gains de performances n’étant pas significatifs, j’ai préféré conserver le simple Walk()
.
Benchmark de performances
Dans cet article, nous avons vu quelques éléments clés du langage nécessaires pour écrire notre premier script Go : parcourir les fichiers, vérifier les extensions, contrôler chaque ligne des classes pour afficher les occurrences. Il manque cependant pas mal de choses à notre script : la gestion des erreurs, le temps d'exécution, la mémoire utilisée. J’ai donc créé un dépôt GitHub avec le code complet : https://github.com/clementtalleu/php-dumpsniffer.
Le dépôt contient évidemment l’intégralité du code, mais aussi une procédure d’installation pour télécharger et compiler l’exécutable afin de pouvoir l’utiliser facilement en lançant simplement : dumpsniffer src/Controller
Afin de benchmarker un peu notre script et les performances proposées par Go, j’ai écrit un script similaire en PHP (sans parallélisme des tâches), disponible dans ce gist.
Comme on peut le voir dans le tableau précédent, les performances sur de la simple lecture de fichiers et recherche de string montrent une nette différence à l’avantage de Go.
Pour conclure, l'expérience fut très enrichissante. Go est très accessible, la documentation est complète et les nombreux outils disponibles permettent de rapidement développer des petites fonctions efficaces et faciles à relire.
Au quotidien je développe principalement en PHP, et occasionnellement en JS, j’ai beaucoup apprécié le typage statique et les erreurs dès la compilation, cela nous laisse moins de marge d’erreur, le code peut nous apparaître plus strict mais cela encourage aux bonnes pratiques. Les Goroutines et les performances du langage sont impressionnantes, je pense qu’il est facile d’imaginer des outils en Go qui permettent de réaliser des tâches lourdes de manière asynchrone.
Si vous ou vos équipes souhaitent de l’expertise Go, nos équipes sont disponibles pour vous accompagner !