Ce message est mon top des erreurs les plus courantes que j'ai rencontrées dans les projets Go. L'ordre n'a pas d'importance.

Valeur inconnue d'Enum
Jetons un coup d'œil à un exemple simple:
type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown )
Ici, nous créons un énumérateur en utilisant iota, ce qui conduira à cet état:
StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2
Imaginons maintenant que ce type de statut fasse partie de la requête JSON qui sera compressée / décompressée. Nous pouvons concevoir la structure suivante:
type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` }
Ensuite, nous obtenons le résultat de cette requête:
{ "Id": 1234, "Timestamp": 1563362390, "Status": 0 }
En général, rien de spécial - le statut sera décompressé dans StatusOpen.
Maintenant, obtenons une autre réponse dans laquelle la valeur d'état n'est pas définie:
{ "Id": 1235, "Timestamp": 1563362390 }
Dans ce cas, le champ Status de la structure Request sera initialisé à zéro (pour uint32, il est égal à 0). Par conséquent, nous obtenons à nouveau StatusOpen au lieu de StatusUnknown.
Dans ce cas, il est préférable de définir d'abord la valeur inconnue de l'énumérateur - c'est-à-dire 0:
type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed )
Si le statut ne fait pas partie de la demande JSON, il sera initialisé dans StatusUnknown, comme nous nous y attendons.
Analyse comparative
Une analyse comparative correcte est assez difficile. Trop de facteurs peuvent influencer le résultat.
Une erreur courante est trompée par les optimisations du compilateur. Voyons un exemple spécifique de la
bibliothèque teivah / bitvector :
func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n }
Cette fonction efface les bits dans une plage donnée. Nous pouvons tester les performances de cette manière:
func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } }
Dans ce test, le compilateur remarquera que clear n'appelle aucune autre fonction, il l'incorpore donc simplement tel quel. Une fois qu'il est intégré, le compilateur verra qu'aucun effet secondaire ne se produit. Ainsi, l'appel clair sera simplement supprimé, ce qui entraînera des résultats inexacts.
Une solution peut être de définir le résultat sur une variable globale, comme celle-ci:
var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r }
Ici, le compilateur ne saura pas si l'appel crée un effet secondaire. Par conséquent, le repère sera précis.
Pointeurs! Les pointeurs sont partout!
Le passage d'une variable par valeur créera une copie de cette variable. En passant par le pointeur, copiez simplement l'adresse en mémoire.
Par conséquent, passer un pointeur sera toujours plus rapide, non?
Si vous le pensez, jetez un œil à
cet exemple . Il s'agit d'une référence pour une structure de données de 0,3 Ko que nous transmettons et recevons d'abord par pointeur, puis par valeur. 0,3 Ko est un peu - sur les structures de données habituelles avec lesquelles nous travaillons quotidiennement occupent à peu près autant.
Lorsque j'exécute ces tests dans un environnement local, la transmission valeur par valeur est plus de 4 fois plus rapide. Assez inattendu, non?
L'explication de ce résultat est liée à une compréhension de la façon dont la gestion de la mémoire se produit dans Go. Je ne peux pas l'expliquer aussi brillamment que
William Kennedy , mais essayons de résumer en un mot.
Une variable peut être placée sur le tas ou la pile:
- La pile contient les variables actuelles de ce programme. Dès que la fonction revient, les variables sont extraites de la pile.
- Le tas contient des variables communes (variables globales, etc.).
Regardons un exemple simple où nous retournons une valeur:
func getFooValue() foo { var result foo
Ici, la variable de résultat est créée par le goroutine actuel. Cette variable est poussée sur la pile actuelle. Dès le retour de la fonction, le client recevra une copie de cette variable. La variable elle-même est extraite de la pile. Elle existe toujours en mémoire jusqu'à ce qu'une autre variable soit remplacée, mais elle n'est plus accessible.
Maintenant le même exemple, mais avec un pointeur:
func getFooPointer() *foo { var result foo
La variable de résultat est toujours créée par le goroutine actuel, mais le client recevra un pointeur (une copie de l'adresse de la variable). Si la variable de résultat a été extraite de la pile, le client de cette fonction ne pourra pas y accéder.
Dans ce scénario, le compilateur Go affichera la variable de résultat là où les variables peuvent être partagées, c'est-à-dire en groupe.
Un autre script pour passer des pointeurs:
func main() { p := &foo{} f(p) }
Puisque nous appelons f dans le même programme, la variable p n'a pas besoin d'être empilée. Il est simplement poussé sur la pile et une sous-fonction peut y accéder.
Par exemple, de cette manière, une tranche est obtenue dans la méthode Read de io.Reader. Le retour d'une tranche (qui est un pointeur) la place dans un tas.
Pourquoi la pile est-elle si rapide? Il y a deux raisons:
- Pas besoin d'utiliser le ramasse-miettes sur la pile. Comme nous l'avons déjà dit, une variable est simplement poussée après avoir été créée, puis extraite de la pile lorsque la fonction revient. Pas besoin de déclencher un processus compliqué pour renvoyer des variables inutilisées, etc.
- La pile appartient à une seule goroutine, donc le stockage de la variable n'a pas besoin d'être synchronisé, comme cela arrive avec le stockage sur le tas, ce qui entraîne également une augmentation des performances.
En conclusion, lorsque nous créons une fonction, notre action par défaut devrait être d'utiliser des valeurs au lieu de pointeurs. Un pointeur ne doit être utilisé que si nous voulons partager une variable.
De plus, si nous souffrons de problèmes de performances, l'une des optimisations possibles pourrait être de vérifier si les pointeurs aident dans des situations spécifiques? Si le compilateur génère une variable dans le tas, vous pouvez le savoir avec la commande suivante:
go build -gcflags "-m -m"
.
Mais, encore une fois, pour la plupart de nos tâches quotidiennes, il est préférable d'utiliser des valeurs.
Abandon de / commutateur ou de / sélection
Que se passe-t-il dans l'exemple suivant si f renvoie vrai?
for { switch f() { case true: break case false:
Nous appelons pause. Seule cette coupure rompt l'interrupteur, pas la boucle for.
Même problème ici:
for { select { case <-ch:
Break est associé à une instruction select, pas à une boucle for.
Une solution possible pour interrompre pour / changer ou pour / sélectionner est d'utiliser une étiquette:
loop: for { select { case <-ch:
Gestion des erreurs
Go est encore jeune, surtout dans le domaine du traitement des erreurs. Surmonter cette lacune est l'une des innovations les plus attendues de Go 2.
La bibliothèque standard actuelle (antérieure à Go 1.13) ne propose que des fonctions de construction d'erreurs. Par conséquent, il sera intéressant de regarder le paquetage
pkg / errors .
Cette bibliothèque est un bon moyen de suivre une règle qui n'est pas toujours respectée:
L'erreur ne doit être traitée qu'une seule fois. La journalisation des erreurs est la gestion des erreurs
. Par conséquent, l'erreur doit être enregistrée ou lancée plus haut.
Dans la bibliothèque standard actuelle, ce principe est difficile à observer, car nous pouvons vouloir ajouter du contexte à l'erreur et avoir une sorte de hiérarchie.
Regardons un exemple avec un appel REST conduisant à une erreur de base de données:
unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction
Si nous utilisons pkg / errors, nous pouvons faire ce qui suit:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error {
L'erreur initiale (si elle n'est pas renvoyée par la bibliothèque externe) peut être créée à l'aide d'erreurs. La couche intermédiaire, insert, encapsule cette erreur, en y ajoutant plus de contexte. Ensuite, le parent l'enregistre. Ainsi, chaque niveau renvoie ou traite une erreur.
Nous pouvons également vouloir rechercher la cause de l'erreur, par exemple, pour rappeler. Supposons que nous ayons un package db d'une bibliothèque externe qui a accès à une base de données. Cette bibliothèque peut renvoyer une erreur temporaire appelée db.DBError. Pour déterminer si nous devons réessayer, nous devons établir la cause de l'erreur:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil }
Cela se fait en utilisant des erreurs.Cause, qui est également incluse dans
pkg / errors :
L'une des erreurs courantes que j'ai rencontrées a été l'utilisation partielle de
pkg / errors . Une vérification d'erreur, par exemple, a été effectuée comme suit:
switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) }
Dans cet exemple, si db.DBError est encapsulé, il ne fera jamais un deuxième appel.
Initialisation de tranche
Parfois, nous savons quelle sera la longueur finale de la tranche. Par exemple, supposons que nous voulons convertir une tranche Foo en tranche Bar, ce qui signifie que ces deux tranches auront la même longueur.
Je rencontre souvent des tranches initialisées de cette façon:
var bars []Bar bars := make([]Bar, 0)
Slice n'est pas une structure magique. Sous le capot, il met en œuvre une stratégie pour augmenter la taille s'il n'y a plus d'espace libre. Dans ce cas, un nouveau tableau est automatiquement créé (avec une plus grande capacité) et tous les éléments y sont copiés.
Imaginons maintenant que nous devions répéter cette opération d'augmentation de la taille plusieurs fois, car notre [] Foo contient des milliers d'éléments. La complexité de l'algorithme d'insertion restera O (1), mais en pratique, cela affectera les performances.
Par conséquent, si nous connaissons la longueur finale, nous pouvons:
- Initialisez-le avec une longueur prédéfinie:
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars }
- Ou initialisez-le avec une longueur de 0 et une capacité prédéterminée:
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars }
Quelle est la meilleure option? Le premier est un peu plus rapide. Cependant, vous pouvez préférer cette dernière, car elle est plus cohérente: que nous connaissions ou non la taille initiale, l'ajout d'un élément à la fin de la tranche se fait en utilisant append.
Gestion du contexte
context.Context est souvent mal compris par les développeurs. Selon la documentation officielle:
Le contexte porte l'échéance, le signal d'annulation et d'autres valeurs au-delà des limites de l'API.
Cette description est assez générale, elle peut donc induire le programmeur en erreur quant à son utilisation correcte.
Essayons de le comprendre. Le contexte peut porter:
- Date limite - signifie soit la durée (par exemple, 250 ms) ou la date-heure (par exemple, 2019-01-08 01:00:00), selon laquelle nous pensons que si elle est atteinte, l'action en cours doit être annulée (demande d'E / S ), en attente de l'entrée du canal, etc.).
- Annuler le signal (essentiellement <-chan struct {}). Ici, le comportement est similaire. Dès que nous recevons un signal, nous devons arrêter le travail en cours. Par exemple, supposons que nous recevions deux demandes. L'une pour insérer des données, l'autre pour annuler la première demande (car elle n'est plus pertinente, par exemple). Ceci peut être réalisé en utilisant le contexte annulé dans le premier appel, qui sera ensuite annulé dès que nous recevrons la deuxième demande.
- Liste de clés / valeurs (toutes deux basées sur le type d'interface {}).
Encore deux points. Premièrement, le contexte est composable. Par conséquent, nous pouvons avoir un contexte qui porte l'échéance et la liste clé / valeur, par exemple. De plus, plusieurs goroutines peuvent partager le même contexte, donc un signal d'annulation peut potentiellement arrêter plusieurs travaux.
Revenant à notre sujet, voici une erreur que j'ai rencontrée.
L’application Go était basée sur
urfave / cli (si vous ne savez pas, c’est une bonne bibliothèque pour créer des applications de ligne de commande dans Go). Une fois lancé, le développeur hérite d'une sorte de contexte d'application. Cela signifie que lorsque l'application est arrêtée, la bibliothèque utilise le contexte pour envoyer un signal d'annulation.
J'ai remarqué que ce contexte était transmis directement, par exemple, lorsqu'un point de terminaison gRPC était appelé. Ce n'est pas du tout ce dont nous avons besoin.
Au lieu de cela, nous voulons dire à la bibliothèque gRPC: veuillez annuler la demande lorsque l'application est arrêtée, ou après 100 ms, par exemple.
Pour y parvenir, nous pouvons simplement créer un contexte composite. Si parent est le nom du contexte d'application (créé par
urfave / cli ), alors nous pouvons simplement faire ceci:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request)
Les contextes ne sont pas si difficiles à comprendre et, à mon avis, c'est l'une des meilleures caractéristiques du langage.
Ne pas utiliser l'option -race
Tester une application Go sans l'option -race est un bug que je rencontre constamment.
Comme écrit
dans cet article , bien que Go ait été «
conçu pour rendre la programmation parallèle plus simple et moins sujette aux erreurs », nous souffrons toujours grandement de problèmes de concurrence.
De toute évidence, le détecteur de course Go ne résoudra aucun problème. Cependant, c'est un outil précieux et nous devons toujours l'inclure lors du test de nos applications.
Utilisation du nom de fichier comme entrée
Une autre erreur courante consiste à transmettre le nom de fichier à une fonction.
Supposons que nous devons implémenter une fonction pour compter le nombre de lignes vides dans un fichier. L'implémentation la plus naturelle ressemblerait à ceci:
func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil }
Le nom du fichier est défini comme une entrée, nous l'ouvrons donc, puis implémentons notre logique, non?
Supposons maintenant que nous voulons couvrir cette fonction avec des tests unitaires. Nous allons tester avec un fichier normal, un fichier vide, un fichier avec un type d'encodage différent, etc. Cela peut être très difficile à gérer.
De plus, si nous voulons implémenter la même logique, par exemple, pour le corps HTTP, nous devrons créer une autre fonction pour cela.
Go est livré avec deux grandes abstractions: io.Reader et io.Writer. Au lieu de passer le nom du fichier, nous pouvons simplement passer io.Reader, qui va abstraire la source de données.
S'agit-il d'un fichier? Corps HTTP? Tampon d'octets? Cela n'a pas d'importance, car nous utiliserons toujours la même méthode de lecture.
Dans notre cas, nous pouvons même tamponner l'entrée pour la lire ligne par ligne. Pour ce faire, vous pouvez utiliser bufio.Reader et sa méthode ReadLine:
func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } }
Désormais, la responsabilité de l'ouverture du fichier a été déléguée au client de comptage:
file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file))
Dans une deuxième implémentation, une fonction peut être appelée quelle que soit la source de données réelle. En attendant, cela facilitera nos tests unitaires, car nous pouvons simplement créer bufio.Reader à partir de la ligne:
count, err := count(bufio.NewReader(strings.NewReader("input")))
Goroutines et variables de cycle
La dernière erreur commune que j'ai rencontrée était lors de l'utilisation de goroutines avec des variables de boucle.
Quelle sera la conclusion de l'exemple suivant?
ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() }
1 2 3 au hasard? Non.
Dans cet exemple, chaque goroutine utilise la même instance d'une variable, elle affichera donc 3 3 3 (le plus probable).
Il existe deux solutions à ce problème. La première consiste à passer la valeur de la variable i à la fermeture (fonction interne):
ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) }
La seconde consiste à créer une autre variable dans la boucle for:
ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() }
Attribuer i: = i peut sembler un peu étrange, mais cette conception est parfaitement valide. Être dans une boucle signifie être dans une portée différente. Par conséquent, i: = i crée une autre instance de la variable i. Bien sûr, nous pouvons l'appeler avec un nom différent pour plus de lisibilité.
Si vous connaissez d'autres erreurs courantes, n'hésitez pas à en parler dans les commentaires.