Go pratique: conseils pour écrire des programmes pris en charge dans le monde réel

Cet article se concentre sur les meilleures pratiques pour écrire du code Go. Il est composé dans le style de présentation, mais sans les diapositives habituelles. Nous allons essayer de parcourir brièvement et clairement chaque élément.

Vous devez d'abord convenir de ce que signifient les meilleures pratiques pour un langage de programmation. Ici, vous pouvez vous rappeler les mots de Russ Cox, directeur technique de Go:

Le génie logiciel est ce qui arrive à la programmation, si vous ajoutez le facteur temps et d'autres programmeurs.

Ainsi, Russ fait la distinction entre les concepts de programmation et de génie logiciel . Dans le premier cas, vous écrivez un programme pour vous-même, dans le second, vous créez un produit sur lequel d'autres programmeurs travailleront au fil du temps. Les ingénieurs vont et viennent. Les équipes s'agrandissent ou se rétrécissent. De nouvelles fonctionnalités sont ajoutées et des bugs sont corrigés. Telle est la nature du développement logiciel.

Table des matières



1. Principes fondamentaux


Je suis peut-être l'un des premiers utilisateurs de Go parmi vous, mais ce n'est pas mon opinion personnelle. Ces principes de base sous-tendent Go lui-même:

  1. Simplicité
  2. Lisibilité
  3. La productivité

Remarque Veuillez noter que je n'ai pas mentionné «performance» ou «simultanéité». Il existe des langues plus rapides que Go, mais elles ne peuvent certainement pas être comparées en termes de simplicité. Il existe des langages qui placent le parallélisme au premier rang des priorités, mais ils ne peuvent être comparés en termes de lisibilité ou de productivité de programmation.

Les performances et la simultanéité sont des attributs importants, mais pas aussi importants que la simplicité, la lisibilité et la productivité.

Simplicité


«La simplicité est une condition préalable à la fiabilité» - Edsger Dijkstra

Pourquoi viser la simplicité? Pourquoi est-il important que les programmes Go soient simples?

Chacun de nous est tombé sur un code incompréhensible, non? Lorsque vous avez peur de faire un changement, car cela interrompra une autre partie du programme que vous ne comprenez pas bien et que vous ne savez pas comment résoudre. Telle est la difficulté.

«Il y a deux façons de concevoir un logiciel: la première consiste à le rendre si simple qu'il n'y a pas de défauts évidents, et la seconde est de le rendre si complexe qu'il n'y a pas de défauts évidents. Le premier est beaucoup plus difficile. » - C.E. R. Hoar

La complexité transforme un logiciel fiable en peu fiable. La complexité est ce qui tue les projets logiciels. Par conséquent, la simplicité est l'objectif ultime de Go. Quels que soient les programmes que nous écrivons, ils devraient être simples.

1.2. Lisibilité


«La lisibilité fait partie intégrante de la maintenabilité» - Mark Reinhold, JVM Conference, 2018

Pourquoi est-il important que le code soit lisible? Pourquoi devrions-nous nous efforcer de lisibilité?

«Les programmes doivent être écrits pour les gens, et les machines doivent simplement les exécuter» - Hal Abelson et Gerald Sassman, «Structure et interprétation des programmes informatiques»

Non seulement les programmes Go, mais généralement tous les logiciels sont écrits par des personnes pour des personnes. Le fait que les machines traitent également le code est secondaire.

Une fois écrit, le code sera lu à plusieurs reprises par des personnes: des centaines, voire des milliers de fois.

«La compétence la plus importante pour un programmeur est la capacité de communiquer efficacement des idées.» - Gaston Horker

La lisibilité est la clé pour comprendre ce que fait un programme. Si vous ne comprenez pas le code, comment le maintenir? Si le logiciel ne peut pas être pris en charge, il sera réécrit; et c'est peut-être la dernière fois que votre entreprise utilise Go.

Si vous écrivez un programme pour vous-même, faites ce qui vous convient. Mais si cela fait partie d'un projet commun ou si le programme sera utilisé assez longtemps pour changer les exigences, les fonctions ou l'environnement dans lequel il fonctionne, alors votre objectif est de rendre le programme maintenable.

La première étape pour écrire un logiciel pris en charge est de s'assurer que le code est clair.

1.3. La productivité


«Le design est l'art d'organiser le code pour qu'il fonctionne aujourd'hui, mais il soutient toujours le changement.» - Sandy Mets

Comme dernier principe de base, je veux nommer la productivité du développeur. C'est un gros sujet, mais cela se résume au rapport: combien de temps vous passez sur un travail utile et combien - en attendant une réponse d'outils ou d'errances désespérées dans une base de code incompréhensible. Les programmeurs de Go devraient sentir qu'ils peuvent gérer beaucoup de travail.

C'est une blague que le langage Go a été développé pendant la compilation du programme C ++. La compilation rapide est une caractéristique clé de Go et un facteur clé pour attirer de nouveaux développeurs. Bien que les compilateurs soient améliorés, en général, la compilation de minutes dans d'autres langues prend quelques secondes sur Go. Les développeurs de Go sont donc aussi productifs que les programmeurs dans les langages dynamiques, mais sans aucun problème avec la fiabilité de ces langages.

Si nous parlons fondamentalement de la productivité des développeurs, les programmeurs Go comprennent que la lecture de code est essentiellement plus importante que son écriture. Dans cette logique, Go va même jusqu'à utiliser les outils pour formater tout le code dans un certain style. Cela élimine la moindre difficulté à apprendre le dialecte spécifique d'un projet particulier et aide à identifier les erreurs car elles semblent fausses par rapport au code normal.

Les programmeurs Go ne passent pas des jours à déboguer des erreurs de compilation étranges, des scripts de construction complexes ou à déployer du code dans un environnement de production. Et surtout, ils ne perdent pas de temps à essayer de comprendre ce qu'un collègue a écrit.

Lorsque les développeurs Go parlent d' évolutivité , ils parlent de productivité.

2. Identifiants


Le premier sujet que nous aborderons - les identificateurs , est un synonyme de noms : noms de variables, fonctions, méthodes, types, packages, etc.

«Le mauvais nom est un symptôme d'une mauvaise conception» - Dave Cheney

Étant donné la syntaxe limitée de Go, les noms d'objets ont un impact énorme sur la lisibilité du programme. La lisibilité est un facteur clé d'un bon code, il est donc crucial de choisir de bons noms.

2.1. Identificateurs de nom basés sur la clarté plutôt que sur la brièveté


«Il est important que le code soit évident. Ce que vous pouvez faire en une ligne, vous devez le faire en trois. » - Ukia Smith

Go n'est pas optimisé pour les lignes simples délicates ou le nombre minimum de lignes dans un programme. Nous n'optimisons pas la taille du code source sur le disque, ni le temps nécessaire pour taper le programme dans l'éditeur.

«Un bon nom est comme une bonne blague. Si vous avez besoin de l'expliquer, alors ce n'est plus drôle. » - Dave Cheney

La clé d'une clarté maximale réside dans les noms que nous choisissons pour identifier les programmes. Quelles qualités sont inhérentes à une bonne réputation?

  • Un bon nom est concis . Il ne doit pas nécessairement être le plus court, mais ne contient pas d'excès. Il a un rapport signal / bruit élevé.
  • Un bon nom est descriptif . Il décrit l'utilisation d'une variable ou d'une constante, pas le contenu. Un bon nom décrit le résultat d'une fonction ou le comportement d'une méthode, pas une implémentation. Le but de l'emballage, pas son contenu. Plus le nom décrit avec précision la chose qui l'identifie, mieux c'est.
  • Un bon nom est prévisible . Par un nom, vous devez comprendre comment l'objet sera utilisé. Les noms doivent être descriptifs, mais il est également important de suivre la tradition. C'est ce que les programmeurs Go veulent dire quand ils disent "idiomatique" .

Examinons plus en détail chacune de ces propriétés.

2.2. Longueur ID


Parfois, le style de Go est critiqué pour les noms de variables courts. Comme l'a dit Rob Pike, "les programmeurs Go veulent des identifiants de la bonne longueur."

Andrew Gerrand propose des identifiants plus longs pour indiquer l'importance.

«Plus la distance entre la déclaration d'un nom et l'utilisation d'un objet est grande, plus le nom doit être long» - Andrew Gerrand

Ainsi, quelques recommandations peuvent être faites:

  • Les noms de variables courts sont bons si la distance entre la déclaration et la dernière utilisation est petite.
  • Les noms de variables longs doivent se justifier; plus ils sont longs, plus ils devraient être importants. Les titres verbeux contiennent peu de signal par rapport à leur poids sur la page.
  • N'incluez pas le nom du type dans le nom de la variable.
  • Les noms de constantes doivent décrire la valeur interne, pas comment la valeur est utilisée.
  • Préférez les variables à une lettre pour les boucles et les branches, des mots séparés pour les paramètres et les valeurs de retour, plusieurs mots pour les fonctions et les déclarations au niveau du package.
  • Préférez des mots simples pour les méthodes, les interfaces et les packages.
  • N'oubliez pas que le nom du package fait partie du nom que l'appelant utilise pour référence.

Prenons un exemple.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

Dans la dixième ligne, une variable de plage p déclarée et elle n'est appelée qu'une seule fois à partir de la ligne suivante. Autrement dit, la variable vit sur la page pendant très peu de temps. Si le lecteur s'intéresse au rôle de p dans le programme, il n'a qu'à lire deux lignes.

A titre de comparaison, les people déclarées dans les paramètres de fonction et sept lignes sont en direct. Il en va de même pour la sum et le count , ils justifient donc leurs noms plus longs. Le lecteur doit scanner plus de code pour les trouver: cela justifie les noms les plus distingués.

Vous pouvez choisir s pour sum et c (ou n ) pour count , mais cela réduit l'importance de toutes les variables du programme au même niveau. Vous pouvez remplacer les people par p , mais il y aura un problème, comment appeler la variable d'itération for ... range . Une seule person aura l'air étrange, car une variable d'itération de courte durée obtient un nom plus long que plusieurs valeurs dont elle est dérivée.

Astuce . Séparez le flux de fonctions par des lignes vides, car les lignes vides entre les paragraphes interrompent le flux de texte. Dans AverageAge , nous avons trois opérations consécutives. Tout d'abord, vérifier la division par zéro, puis la conclusion de l'âge total et du nombre de personnes, et la dernière - le calcul de l'âge moyen.

2.2.1. L'essentiel est le contexte


Il est important de comprendre que la plupart des conseils de dénomination sont spécifiques au contexte. J'aime dire que c'est un principe, pas une règle.

Quelle est la différence entre i et index ? Par exemple, vous ne pouvez pas dire sans équivoque qu'un tel code

 for index := 0; index < len(s); index++ { // } 

fondamentalement plus lisible que

 for i := 0; i < len(s); i++ { // } 

Je crois que la deuxième option n'est pas pire, car dans ce cas, la région i ou l' index limitée par le corps de la boucle for , et la verbosité supplémentaire ajoute peu à la compréhension du programme.

Mais laquelle de ces fonctions est la plus lisible?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

ou

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

Dans cet exemple, oid est une abréviation de SNMP Object ID, et l'abréviation supplémentaire de o oblige lors de la lecture du code à passer d'une notation documentée à une notation plus courte dans le code. De même, la réduction de l' index à i rend la compréhension plus difficile, car dans les messages SNMP, la sous-valeur de chaque OID est appelée index.

Astuce . Ne combinez pas les paramètres formels longs et courts dans une seule annonce.

2.3. Ne nommez pas les variables par type


Vous n'appelez pas vos animaux de compagnie "chien" et "chat", non? Pour la même raison, vous ne devez pas inclure le nom du type dans le nom de la variable. Il doit décrire le contenu, pas son type. Prenons un exemple:

 var usersMap map[string]*User 

À quoi sert cette annonce? Nous voyons que c'est une carte, et cela a quelque chose à voir avec le *User type d' *User : c'est probablement bon. Mais usersMap est vraiment une carte, et Go en tant que langage de type statique ne permettra pas d'utiliser accidentellement un tel nom où une variable scalaire est requise, donc le suffixe Map est redondant.

Considérez une situation où d'autres variables sont ajoutées:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

Nous avons maintenant trois variables de type map: usersMap , companiesMap et productsMap , et toutes les lignes sont mappées à différents types. Nous savons que ce sont des cartes et nous savons également que le compilateur générera une erreur si nous essayons d'utiliser companiesMap là où le code attend map[string]*User . Dans cette situation, il est clair que le suffixe Map n'améliore pas la clarté du code, ce ne sont que des caractères supplémentaires.

Je suggère d'éviter tout suffixe qui ressemble au type d'une variable.

Astuce . Si le nom des users ne décrit pas l'essence de manière suffisamment claire, alors usersMap aussi.

Cette astuce s'applique également aux paramètres de fonction. Par exemple:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

Le nom de *Config paramètre *Config est redondant. Nous savons déjà qu'il s'agit de *Config , il est immédiatement écrit à côté.

Dans ce cas, considérez conf ou c si la durée de vie de la variable est suffisamment courte.

Si à un moment donné dans notre région il y a plus d'une *Config , alors les noms conf1 et conf2 moins significatifs que l' original et updated , car ces derniers sont plus difficiles à mélanger.

Remarque Ne laissez pas les noms de package voler de bons noms de variables.

Le nom de l'identifiant importé contient le nom du package. Par exemple, le type de Context dans le package de context sera appelé context.Context . Cela rend impossible l'utilisation d'une variable ou d'un type de context dans votre package.

 func WriteLog(context context.Context, message string) 

Cela ne se compilera pas. C'est pourquoi lors de la déclaration de types ctx localement, par exemple, des noms comme ctx sont traditionnellement utilisés.

 func WriteLog(ctx context.Context, message string) 

2.4. Utilisez un seul style de dénomination


Une autre propriété d'une bonne réputation est qu'elle doit être prévisible. Le lecteur doit le comprendre immédiatement. S'il s'agit d'un nom commun , le lecteur a le droit de supposer qu'il n'a pas changé le sens par rapport à la fois précédente.

Par exemple, si le code fait le tour du descripteur de base de données, chaque fois que le paramètre est affiché, il doit avoir le même nom. Au lieu de toutes sortes de combinaisons comme d *sql.DB , dbase *sql.DB , DB *sql.DB et database *sql.DB il est préférable d'utiliser une chose:

 db *sql.DB 

Il est plus facile de comprendre le code. Si vous voyez db , alors vous savez qu'il s'agit de *sql.DB et qu'il est déclaré localement ou fourni par l'appelant.

Conseils similaires concernant les destinataires d'une méthode; utilisez le même nom de destinataire pour chaque méthode de ce type. Il sera donc plus facile pour le lecteur d'apprendre l'utilisation du destinataire parmi les différentes méthodes de ce type.

Remarque L'accord sur le nom abrégé du destinataire Go est en contradiction avec les recommandations exprimées précédemment. C'est l'un de ces cas où le choix fait à un stade précoce devient le style standard, comme l'utilisation de CamelCase au lieu de snake_case .

Astuce . Le style Go pointe vers des noms à une seule lettre ou des abréviations pour les destinataires dérivés de leur type. Il peut s'avérer que le nom du destinataire entre parfois en conflit avec le nom du paramètre dans la méthode. Dans ce cas, il est recommandé d'allonger un peu le nom du paramètre et de ne pas l'oublier.

Enfin, certaines variables d'une lettre sont traditionnellement associées aux boucles et au comptage. Par exemple, i , j et k sont généralement des variables inductives dans les boucles for , n généralement associé à un compteur ou un additionneur cumulatif, v est une abréviation typique pour la valeur dans une fonction de codage, k généralement utilisé pour une clé de mappage et s souvent utilisé comme abréviation pour les paramètres de type string .

Comme avec l'exemple db ci-dessus, les programmeurs s'attendent i ce que i soit une variable inductive. S'ils le voient dans le code, ils s'attendent à voir une boucle bientôt.

Astuce . Si vous avez tellement de boucles imbriquées que vous n'avez plus de variables i , j et k , vous souhaiterez peut-être diviser la fonction en unités plus petites.

2.5. Utilisez un style de déclaration unique


Go a au moins six façons différentes de déclarer une variable.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

Je suis sûr que je ne me suis pas encore souvenu de tout. Les développeurs de Go considèrent probablement cela comme une erreur, mais il est trop tard pour changer quoi que ce soit. Avec ce choix, comment assurer un style uniforme?

Je veux proposer un style de déclaration de variables que j'essaie moi-même d'utiliser dans la mesure du possible.

  • Lorsque vous déclarez une variable sans initialisation, utilisez var .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

    var agit comme un indice que cette variable est intentionnellement déclarée comme une valeur nulle du type spécifié. Ceci est cohérent avec l'exigence de déclarer des variables au niveau du package avec var par opposition à la syntaxe de déclaration courte, bien que je soutienne plus tard que les variables au niveau du package ne devraient pas être utilisées du tout.
  • Lors de la déclaration avec initialisation, utilisez := . Cela montre clairement au lecteur que la variable à gauche de := intentionnellement initialisée.

    Pour expliquer pourquoi, regardons l'exemple précédent, mais cette fois nous initialisons spécialement chaque variable:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

Étant donné que Go n'a pas de conversions automatiques d'un type à un autre, dans les premier et troisième exemples, le type sur le côté gauche de l'opérateur d'affectation doit être identique au type sur le côté droit. Le compilateur peut déduire le type de la variable déclarée à partir du type de droite, donc l'exemple peut être écrit de manière plus concise:

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

Ici, les players explicitement initialisés à 0 , ce qui est redondant, car la valeur initiale des players est nulle dans tous les cas. Par conséquent, il est préférable de préciser que nous voulons utiliser une valeur nulle:

 var players int 

Et le deuxième opérateur? Nous ne pouvons pas déterminer le type et écrire

 var things = nil 

Parce que nil pas de type . Au lieu de cela, nous avons le choix: ou nous utilisons une valeur nulle pour trancher ...

 var things []Thing 

... ou créer une tranche avec zéro élément?

 var things = make([]Thing, 0) 

Dans le second cas, la valeur de la tranche n'est pas nulle, et nous le précisons au lecteur en utilisant une forme courte de déclaration:

 things := make([]Thing, 0) 

Cela indique au lecteur que nous avons décidé d'initialiser explicitement les things .

Nous arrivons donc à la troisième déclaration:

 var thing = new(Thing) 

Ici, à la fois l'initialisation explicite de la variable et l'introduction du new mot-clé «unique», que certains programmeurs Go n'aiment pas. Utilisation des rendements de syntaxe courts recommandés

 thing := new(Thing) 

Cela montre clairement que la thing explicitement initialisée au résultat de new(Thing) , mais laisse toujours un new atypique. Le problème pourrait être résolu en utilisant un littéral:

 thing := &Thing{} 

Ce qui est similaire à new(Thing) , et une telle duplication dérange certains programmeurs Go. Cependant, cela signifie que nous initialisons explicitement la thing avec un pointeur sur Thing{} et une valeur Thing de zéro.

Mais il vaut mieux prendre en compte le fait que la thing déclarée avec une valeur nulle, et utiliser l'adresse de l'opérateur pour passer l'adresse de la thing dans json.Unmarshall :

 var thing Thing json.Unmarshall(reader, &thing) 

Remarque Bien sûr, il existe des exceptions à toute règle. Par exemple, parfois deux variables sont étroitement liées, il sera donc étrange d'écrire

 var min int max := 1000 

Déclaration plus lisible:

 min, max := 0, 1000 

Pour résumer:

  • Lorsque vous déclarez une variable sans initialisation, utilisez la syntaxe var .
  • Lors de la déclaration et de l'initialisation explicite d'une variable, utilisez := .

Astuce . Soulignez explicitement des choses complexes.

 var length uint32 = 0x80 

Ici, la length peut être utilisée avec la bibliothèque, qui nécessite un type numérique spécifique, et cette option indique plus clairement que la longueur du type est spécifiquement choisie comme uint32 que dans la déclaration courte:

 length := uint32(0x80) 

Dans le premier exemple, j'ai violé intentionnellement ma règle en utilisant la déclaration var avec une initialisation explicite. Une dérogation à la norme fait comprendre au lecteur que quelque chose d'inhabituel se produit.

2.6. Travailler pour l'équipe


J'ai déjà dit que l'essence du développement logiciel est la création de code pris en charge lisible. La majeure partie de votre carrière sera probablement consacrée à des projets communs. Mon conseil dans cette situation: suivez le style adopté dans l'équipe.

Changer de style au milieu du fichier est ennuyeux. La cohérence est importante, mais au détriment des préférences personnelles. Ma règle d'or est la suivante: si le code passe par gofmt , le problème ne vaut généralement pas la peine d'être discuté.

Astuce . Si vous souhaitez renommer tout au long de la base de code, ne mélangez pas cela avec d'autres modifications. Si quelqu'un utilise git bisect, il n'aimera pas parcourir des milliers de renommages pour trouver un autre code modifié.

3. Commentaires


Avant de passer à des points plus importants, je voudrais prendre quelques minutes pour commenter.

«Un bon code a beaucoup de commentaires et un mauvais code a besoin de beaucoup de commentaires.» - Dave Thomas et Andrew Hunt, programmeur pragmatique

Les commentaires sont très importants pour la lisibilité du programme. Chaque commentaire doit faire une - et une seule - de trois choses:

  1. Expliquez ce que fait le code.
  2. Expliquez comment il le fait.
  3. Expliquez pourquoi .

Le premier formulaire est idéal pour commenter des personnages publics:

 // Open     . //           . 

Le second est idéal pour les commentaires à l'intérieur d'une méthode:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

La troisième forme («pourquoi») est unique en ce qu'elle ne remplace ni ne remplace les deux premières. Ces commentaires expliquent les facteurs externes qui ont conduit à l'écriture du code dans sa forme actuelle. Souvent sans ce contexte, il est difficile de comprendre pourquoi le code est écrit de cette manière.

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

Dans cet exemple, il peut ne pas être immédiatement clair ce qui se passe lorsque HealthyPanicThreshold est défini sur zéro pour cent. Le commentaire est destiné à préciser qu'une valeur de 0 désactive le seuil de panique.

3.1. Les commentaires dans les variables et les constantes doivent décrire leur contenu, pas leur objectif


J'ai dit plus tôt que le nom d'une variable ou d'une constante devrait décrire son objectif. Mais un commentaire sur une variable ou une constante doit décrire exactement le contenu , pas le but .

 const randomNumber = 6 //     

Dans cet exemple, un commentaire décrit pourquoi randomNumber sur 6 et d'où il provient. Le commentaire ne décrit pas où randomNumber sera utilisé. Voici quelques exemples supplémentaires:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

Dans le contexte de HTTP, le nombre 100 appelé StatusContinue , tel que défini dans la RFC 7231, section 6.2.1.

Astuce . Pour les variables sans valeur initiale, le commentaire doit décrire qui est responsable de l'initialisation de cette variable.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

Ici, un commentaire indique au lecteur que la fonction dowidth responsable du maintien de l'état de sizeCalculationDisabled .

Astuce . Cachez-vous en vue. Voici les conseils de Kate Gregory . Parfois, le meilleur nom d'une variable est masqué dans les commentaires.

 //   SQL var registry = make(map[string]*sql.Driver) 

Un commentaire a été ajouté par l'auteur parce que le registry noms n'explique pas suffisamment son but - c'est un registre, mais qu'est-ce que le registre?

Si vous renommez une variable en sqlDrivers, il devient clair qu'elle contient des pilotes SQL.

 var sqlDrivers = make(map[string]*sql.Driver) 

Maintenant, le commentaire est devenu redondant et peut être supprimé.

3.2. Documentez toujours les caractères accessibles au public


La documentation de votre package est générée par godoc, vous devez donc ajouter un commentaire à chaque caractère public déclaré dans le package: une variable, une constante, une fonction et une méthode.

Voici deux consignes du Google Style Guide:

  • Toute fonction publique qui n'est pas à la fois évidente et concise doit être commentée.
  • Toute fonction de la bibliothèque doit être commentée, quelle que soit sa longueur ou sa complexité.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

Il existe une exception à cette règle: vous n'avez pas besoin de documenter les méthodes qui implémentent l'interface. Plus précisément, ne procédez pas ainsi:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

Ce commentaire ne veut rien dire. Il ne dit pas ce que fait la méthode: pire, il envoie quelque part chercher de la documentation. Dans cette situation, je propose de supprimer complètement le commentaire.

Voici un exemple du package io .

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

Notez que la déclaration LimitedReader est immédiatement précédée de la fonction qui l'utilise et la déclaration LimitedReader.Read suit la déclaration de LimitedReader elle-même. Bien que LimitedReader.Read lui-même ne soit pas documenté, il peut être compris qu'il s'agit d'une implémentation d' io.Reader .

Astuce . Avant d'écrire une fonction, écrivez un commentaire la décrivant. Si vous trouvez difficile d'écrire un commentaire, cela signifie que le code que vous êtes sur le point d'écrire sera difficile à comprendre.

3.2.1. Ne commentez pas le mauvais code, réécrivez-le


«Ne commentez pas le mauvais code - réécrivez-le» - Brian Kernighan

Il ne suffit pas d'indiquer dans les commentaires la difficulté du fragment de code. Si vous rencontrez l'un de ces commentaires, vous devez commencer un ticket avec un rappel de refactoring. Vous pouvez vivre avec une dette technique tant que son montant est connu.

Il est habituel de laisser des commentaires dans la bibliothèque standard dans le style TODO avec le nom de l'utilisateur qui a remarqué le problème.

 // TODO(dfc)  O(N^2),     . 

Ce n'est pas une obligation de résoudre le problème, mais l'utilisateur spécifié peut être la meilleure personne à contacter pour une question. D'autres projets accompagnent TODO avec une date ou un numéro de ticket.

3.2.2. Au lieu de commenter le code, refactorisez-le


«Un bon code est la meilleure documentation. Lorsque vous êtes sur le point d'ajouter un commentaire, posez-vous la question: "Comment améliorer le code pour que ce commentaire ne soit pas nécessaire?" Refactorisez et laissez un commentaire pour le rendre encore plus clair. » - Steve McConnell

Les fonctions ne doivent effectuer qu'une seule tâche. Si vous souhaitez écrire un commentaire car un fragment n'est pas lié au reste de la fonction, envisagez de l'extraire dans une fonction distincte.

Les fonctionnalités plus petites sont non seulement plus claires, mais plus faciles à tester séparément les unes des autres. Lorsque vous avez isolé le code dans une fonction distincte, son nom peut remplacer un commentaire.

4. Structure du package


«Écrivez un code modeste: des modules qui ne montrent rien de superflu aux autres modules et qui ne dépendent pas de l'implémentation d'autres modules» - Dave Thomas

Chaque package est essentiellement un petit programme Go distinct. Tout comme l'implémentation d'une fonction ou d'une méthode n'a pas d'importance pour l'appelant, l'implémentation des fonctions, des méthodes et des types qui composent l'API publique de votre package n'a pas d'importance non plus.

Un bon package Go aspire à une connectivité minimale avec d'autres packages au niveau du code source, de sorte que, à mesure que le projet se développe, les modifications d'un package ne sont pas répercutées dans la base de code. De telles situations empêchent considérablement les programmeurs de travailler sur cette base de code.

Dans cette section, nous parlerons de la conception d'un package, y compris son nom et des conseils pour écrire des méthodes et des fonctions.

4.1. Un bon package commence par un bon nom


Un bon forfait Go commence par un nom de qualité. Considérez-le comme une courte présentation limitée à un seul mot.

Comme les noms de variables dans la section précédente, le nom du package est très important. Pas besoin de penser aux types de données de ce package, il vaut mieux se poser la question: "Quel service ce package propose-t-il?" Habituellement, la réponse n'est pas «Ce package fournit le type X», mais «Ce package vous permet de vous connecter via HTTP».

Astuce . Choisissez un nom de package par sa fonctionnalité et non par son contenu.

4.1.1. Les bons noms de colis doivent être uniques


Chaque package a un nom unique dans le projet. Il n'y a aucune difficulté si vous avez suivi les conseils de donner des noms aux fins des packages. S'il s'avère que les deux packages portent le même nom, le plus probable:

  1. .
  2. . , .

4.2. base , common util


Une raison courante des mauvais noms est les soi-disant packages de services , où au fil du temps divers assistants et codes de service s'accumulent. Puisqu'il est difficile d'y trouver un nom unique. Cela conduit souvent au fait que le nom du package est dérivé de ce qu'il contient : les utilitaires.

Des noms comme utilsou se helperstrouvent généralement dans les grands projets, dans lesquels une profonde hiérarchie de packages est enracinée, et les fonctions auxiliaires sont partagées. Si vous extrayez une fonction dans un nouveau package, l'importation échoue. Dans ce cas, le nom du package ne reflète pas l'objectif du package, mais uniquement le fait que la fonction d'importation a échoué en raison d'une mauvaise organisation du projet.

Dans de telles situations, je recommande d'analyser d'où les packages sont appelés.utils helperset, si possible, déplacez les fonctions correspondantes vers le paquet appelant. Même si cela implique la duplication d'un code auxiliaire, c'est mieux que d'introduire une dépendance d'importation entre deux packages.

«[Un peu] de duplication est beaucoup moins cher qu'une mauvaise abstraction» - Sandy Mets

Si les fonctions utilitaires sont utilisées dans de nombreux endroits, au lieu d'un package monolithique avec des fonctions utilitaires, il est préférable de créer plusieurs packages, chacun se concentrant sur un aspect.

Astuce . Utilisez le pluriel pour les packages de services. Par exemple, stringspour les utilitaires de traitement de chaîne.

Les packages portant des noms tels que baseou commonsont souvent trouvés lorsqu'un certain package de fonctionnalités communes de deux ou plusieurs implémentations ou types communs est fusionné dans un package distinct pour le client et le serveur. Je pense que dans de tels cas, il est nécessaire de réduire le nombre de packages en combinant le client, le serveur et le code commun dans un package avec un nom qui correspond à sa fonction.

Par exemple, pour net/httpne pas faire les paquets individuels clientet server, à la place, il y a des fichiers client.goet server.goles types de données correspondants, ainsi que transport.gopour le transport total.

Astuce . Il est important de se rappeler que le nom de l'identifiant inclut le nom du package.

  • Une fonction Getd'un package net/httpdevient un http.Getlien à partir d'un autre package.
  • Un type Readerd'un package est stringstransformé en lors de son importation dans d'autres packages strings.Reader.
  • L'interface Errordu package est netclairement associée à des erreurs de réseau.

4.3. Revenez rapidement sans plonger profondément


Étant donné que Go n'utilise pas d'exceptions dans le flux de contrôle, il n'est pas nécessaire de creuser profondément dans le code pour fournir une structure de niveau supérieur pour les blocs tryet catch. Au lieu d'une hiérarchie à plusieurs niveaux, le code Go descend l'écran pendant que la fonction progresse. Mon ami Matt Ryer appelle cette pratique une «ligne de vue» .

Ceci est réalisé en utilisant des opérateurs de limites : des blocs conditionnels avec une condition préalable à l'entrée de la fonction. Voici un exemple du package bytes:

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

En entrant dans la fonction UnreadRune, l'état est vérifié b.lastReadet si l'opération précédente ne l'était pas ReadRune, une erreur est immédiatement renvoyée. Le reste de la fonction fonctionne en fonction de ce qui est b.lastReadsupérieur à opInvalid.

Comparez avec la même fonction, mais sans l'opérateur de frontière:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

Le corps d'une branche réussie plus probable est intégré dans la première condition if, et la condition pour une sortie réussie return nildoit être découverte en faisant correspondre soigneusement les crochets de fermeture . La dernière ligne de la fonction renvoie maintenant une erreur et vous devez suivre l'exécution de la fonction jusqu'au crochet d' ouverture correspondant pour savoir comment y parvenir.

Cette option est plus difficile à lire, ce qui dégrade la qualité de la programmation et la prise en charge du code, donc Go préfère utiliser les opérateurs de limites et renvoyer les erreurs à un stade précoce.

4.4. Rendre la valeur nulle utile


Chaque déclaration de variable, en supposant l'absence d'un initialiseur explicite, sera automatiquement initialisée avec une valeur correspondant au contenu de la mémoire mise à zéro, c'est-à-dire zéro . Le type de valeur est déterminé par l'une des options: pour les types numériques - zéro, pour les types de pointeurs - nul, le même pour les tranches, les cartes et les canaux.

La possibilité de toujours définir une valeur par défaut connue est importante pour la sécurité et l'exactitude de votre programme et peut rendre vos programmes Go plus faciles et plus compacts. C'est ce que les programmeurs Go ont en tête lorsqu'ils disent: «Donnez aux structures une valeur zéro utile».

Considérons un type sync.Mutexqui contient deux champs entiers représentant l'état interne du mutex. Ces champs sont automatiquement nuls dans toute déclaration.sync.Mutex. Ce fait est pris en compte dans le code, donc le type peut être utilisé sans initialisation explicite.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

Un autre exemple de type avec une valeur nulle utile est bytes.Buffer. Vous pouvez le déclarer et commencer à y écrire sans initialisation explicite.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

La valeur zéro de cette structure signifie que les lendeux capsont égaux 0et y array, le pointeur vers la mémoire avec le contenu du tableau de tranches de sauvegarde, valeur nil. Cela signifie que vous n'avez pas besoin de couper explicitement, vous pouvez simplement le déclarer.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

Note . var s []stringsimilaire aux deux lignes commentées en haut, mais pas identique à elles. Il existe une différence entre une valeur de tranche nulle et une valeur de tranche de longueur nulle. Le code suivant s'imprime faux.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

Une propriété utile, quoique inattendue, des variables de pointeur non initialisées - pointeurs nil - est la possibilité d'appeler des méthodes sur des types qui sont nuls. Cela peut être utilisé pour fournir facilement des valeurs par défaut.

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5. Évitez l'état au niveau du package


La clé pour écrire des programmes faciles à prendre en charge dans des environnements mal couplés est que la modification d'un package devrait avoir une faible probabilité d'affecter un autre package qui ne dépend pas directement du premier.

Il existe deux excellents moyens d'obtenir une connectivité faible dans Go:

  1. Utilisez des interfaces pour décrire le comportement requis par les fonctions ou les méthodes.
  2. Évitez le statut global.

Dans Go, nous pouvons déclarer des variables dans la portée d'une fonction ou d'une méthode, ainsi que dans la portée d'un package. Lorsqu'une variable est accessible au public, avec un identifiant avec une majuscule, sa portée est en fait globale pour tout le programme: n'importe quel package à tout moment voit le type et le contenu de cette variable.

L'état global mutable fournit une relation étroite entre les parties indépendantes du programme, car les variables globales deviennent un paramètre invisible pour chaque fonction du programme! Toute fonction qui s'appuie sur une variable globale peut être violée lorsque le type de cette variable change. Toute fonction qui dépend de l'état d'une variable globale peut être violée si une autre partie du programme modifie cette variable.

Comment réduire la connectivité créée par une variable globale:

  1. Déplacez les variables correspondantes sous forme de champs vers les structures qui en ont besoin.
  2. Utilisez des interfaces pour réduire la connexion entre le comportement et la mise en œuvre de ce comportement.

5. Structure du projet


Parlons de la façon dont les packages sont combinés dans un projet. Il s'agit généralement d'un seul référentiel Git.

Comme le package, chaque projet doit avoir un objectif clair. S'il s'agit d'une bibliothèque, elle doit faire une chose, par exemple, l'analyse XML ou la journalisation. Vous ne devez pas combiner plusieurs objectifs dans un même projet, cela vous évitera une bibliothèque effrayante common.

Astuce . D'après mon expérience, le référentiel commonest en fin de compte étroitement associé au plus grand consommateur, ce qui rend difficile d'apporter des corrections aux versions précédentes (correctifs de port arrière) sans mettre à jour à la fois le commonet le consommateur au stade du blocage, ce qui conduit à de nombreux changements non liés, et ils se brisent en cours de route API

Si vous avez une application (application web, contrôleur Kubernetes, etc.), le projet peut avoir un ou plusieurs packages principaux. Par exemple, dans mon contrôleur Kubernetes, il existe un package cmd/contourqui sert de serveur déployé dans un cluster Kubernetes et de client de débogage.

5.1. Moins de paquets mais plus gros


Dans la revue de code, j'ai remarqué l'une des erreurs typiques des programmeurs qui sont passés à Go à partir d'autres langues: ils ont tendance à abuser des packages.

Go ne fournit pas le système complexe de visibilité: la langue n'est pas assez modificateurs d'accès comme dans Java ( public, protected, privateet implicite default). Il n'y a pas d'analogue des classes amies de C ++.

Dans Go, nous n'avons que deux modificateurs d'accès: ce sont des identifiants publics et privés, qui sont indiqués par la première lettre de l'identifiant (majuscule / minuscule). Si l'identifiant est public, son nom commence par une lettre majuscule, il peut être référencé par tout autre package Go.

Note . On pouvait entendre les mots «exporté» ou «non exporté» comme synonymes de public et privé.

Compte tenu des fonctionnalités de contrôle d'accès limitées, quelles méthodes peuvent être utilisées pour éviter les hiérarchies de packages trop complexes?

Astuce . Dans chaque package, en plus cmd/et internal/doit être présent le code source.

J'ai dit à plusieurs reprises qu'il vaut mieux préférer moins de gros paquets. Votre position par défaut doit être de ne pas créer de nouveau package. Cela fait que trop de types deviennent publics, créant une large et petite étendue d'API disponibles. Ci-dessous, nous considérons cette thèse plus en détail.

Astuce . Vous êtes venu de Java?

Si vous venez du monde Java ou C #, souvenez-vous de la règle tacite: un package Java est équivalent à un seul fichier source .go. Le package Go est équivalent à l'ensemble du module Maven ou de l'assembly .NET.

5.1.1. Tri du code par fichier à l'aide des instructions d'importation


Si vous organisez des packages par service, devez-vous faire de même pour les fichiers du package? Comment savoir quand diviser un fichier .goen plusieurs? Comment savoir si vous êtes allé trop loin et devez penser à fusionner des fichiers?

Voici les recommandations que j'utilise:

  • Démarrez chaque package avec un fichier .go. Donnez à ce fichier le même nom que le répertoire. Par exemple, le package httpdoit se trouver dans un fichier http.godans un répertoire http.
  • Au fur et à mesure que le package se développe, vous pouvez diviser les différentes fonctions en plusieurs fichiers. Par exemple, le fichier messages.gocontiendra des types Requestet Response, un client.gotype de Clientfichier server.go, un serveur de type fichier .
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2. Préférez les tests internes aux tests externes


L'outil goprend en charge le package testingà deux endroits. Si vous avez un package http2, vous pouvez écrire un fichier http2_test.goet utiliser la déclaration de package http2. Il compile le code http2_test.go, comme il fait partie du paquet http2. Dans le langage courant, un tel test est appelé interne.

L'outil goprend également en charge une déclaration de package spéciale qui se termine par test , c'est-à-dire http_test. Cela permet aux fichiers de test de vivre dans un seul package avec le code, mais lorsque ces tests sont compilés, ils ne font pas partie du code de votre package, mais vivent dans leur propre package. Cela vous permet d'écrire des tests comme si un autre paquet appelait votre code. Ces tests sont appelés externes.

Je recommande d'utiliser des tests internes pour les tests unitaires unitaires. Cela vous permet de tester directement chaque fonction ou méthode, en évitant la bureaucratie des tests externes.

Mais il est nécessaire de placer des exemples de fonctions de test ( Example) dans un fichier de test externe . Cela garantit que lorsqu'ils sont visualisés dans godoc, les exemples reçoivent le préfixe de package approprié et peuvent être facilement copiés.

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


Si votre projet comporte plusieurs packages, vous pouvez trouver des fonctions exportées destinées à être utilisées par d'autres packages, mais pas pour l'API publique. Dans une telle situation, l'outil goreconnaît un nom de dossier spécial internal/qui peut être utilisé pour placer du code ouvert pour votre projet, mais fermé aux autres.

Pour créer un tel package, placez-le dans un répertoire avec un nom internal/ou dans son sous-répertoire. Lorsque l'équipe govoit l'importation du package avec le chemin d'accès internal, elle vérifie l'emplacement du package appelant dans un répertoire ou un sous-répertoire internal/.

Par exemple, un package .../a/b/c/internal/d/e/fpeut importer uniquement un package à partir d'une arborescence de répertoires .../a/b/c, mais pas du tout .../a/b/gou tout autre référentiel (voirdocumentation ).

5.2. Le plus petit paquet principal


Une fonction mainet un package maindoivent avoir des fonctionnalités minimales, car ils main.mainagissent comme un singleton: un programme ne peut avoir qu'une seule fonction main, y compris les tests.

Comme il main.mains'agit d'un singleton, il existe de nombreuses restrictions sur les objets appelés: ils ne sont appelés que pendant main.mainou main.init, et une seule fois . Cela rend difficile l'écriture de tests pour le code main.main. Ainsi, vous devez vous efforcer de tirer le plus de logique possible de la fonction principale et, idéalement, du package principal.

Astuce . func main()doit analyser les indicateurs, ouvrir les connexions aux bases de données, les enregistreurs, etc., puis transférer l'exécution vers un objet de haut niveau.

6. Structure de l'API


Le dernier conseil de conception pour le projet que je considère comme le plus important.

Toutes les phrases précédentes ne sont en principe pas contraignantes. Ce ne sont que des recommandations basées sur l'expérience personnelle. Je ne pousse pas trop ces recommandations dans une revue de code.

L'API est une autre affaire. Ici, les erreurs sont prises plus au sérieux, car tout le reste peut être corrigé sans briser la compatibilité descendante: pour la plupart, ce ne sont que des détails d'implémentation.

En ce qui concerne les API publiques, il convient de considérer sérieusement la structure dès le début, car les modifications ultérieures seront destructrices pour les utilisateurs.

6.1. API de conception difficiles à abuser par conception


«Les API doivent être simples pour une utilisation correcte et difficiles pour des erreurs» - Josh Bloch

Les conseils de Josh Bloch sont peut-être les plus précieux de cet article. Si l'API est difficile à utiliser pour des choses simples, chaque appel d'API est plus compliqué que nécessaire. Lorsqu'un appel d'API est complexe et non évident, il est probable qu'il soit ignoré.

6.1.1. Soyez prudent avec les fonctions qui acceptent plusieurs paramètres du même type.


Un bon exemple d'une API simple à première vue, mais difficile à utiliser, est lorsqu'elle nécessite deux ou plusieurs paramètres du même type. Comparez deux signatures de fonction:

 func Max(a, b int) int func CopyFile(to, from string) error 

Quelle est la différence entre ces deux fonctions? Évidemment, l'un renvoie un maximum de deux nombres et l'autre copie le fichier. Mais ce n'est pas la question.

 Max(8, 10) // 10 Max(10, 8) // 10 

Max est commutatif : l'ordre des paramètres n'a pas d'importance. Un maximum de huit et dix est égal à dix, que huit et dix ou dix et huit soient comparés.

Mais dans le cas de CopyFile, ce n'est pas le cas.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

Lequel de ces opérateurs sauvegardera votre présentation et lequel la remplacera par la version de la semaine dernière? Vous ne pouvez pas le dire avant d'avoir vérifié la documentation. Au cours de la révision du code, il n'est pas clair si l'ordre des arguments est correct ou non. Encore une fois, regardez la documentation.

Une solution possible consiste à introduire un type auxiliaire responsable de l'appel correct CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

Il est CopyFiletoujours appelé correctement ici - cela peut être déclaré à l'aide d'un test unitaire - et peut être effectué en privé, ce qui réduit encore plus la probabilité d'une utilisation incorrecte.

Astuce . Une API avec plusieurs paramètres du même type est difficile à utiliser correctement.

6.2. Concevoir une API pour un cas d'utilisation de base


Il y a quelques années, j'ai fait une présentation sur l'utilisation des options fonctionnelles pour rendre l'API plus simple par défaut.

L'essence de la présentation était que vous deviez développer une API pour le cas d'utilisation principal. En d'autres termes, l'API ne doit pas obliger l'utilisateur à fournir des paramètres supplémentaires qui ne l'intéressent pas.

6.2.1. L'utilisation de nil comme paramètre n'est pas recommandée


J'ai commencé par dire qu'il ne fallait pas forcer l'utilisateur à fournir des paramètres API qui ne l'intéressaient pas. Cela signifie concevoir les API pour le cas d'utilisation principal (option par défaut).

Voici un exemple du package net / http.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServeaccepte deux paramètres: une adresse TCP pour écouter les connexions entrantes et http.Handlerpour traiter une requête HTTP entrante. Servepermet au deuxième paramètre d'être nil. Dans les commentaires, il est à noter qu'en général, l'objet appelant passera effectivementnil , indiquant une volonté de l'utiliser http.DefaultServeMuxcomme paramètre implicite.

Maintenant, l'appelant Servea deux façons de faire de même.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Les deux options font la même chose.

Cette application nilse propage comme un virus. Le package a également httpune aide http.Serve, vous pouvez donc imaginer la structure de la fonction ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

Puisqu'il ListenAndServepermet à l'appelant de passer nilpour le deuxième paramètre, il http.Serveprend également en charge ce comportement. En fait, c'est dans la http.Servelogique implémentée "si le gestionnaire est égal nil, utilisez DefaultServeMux". L'acceptation nild'un paramètre peut amener l'appelant à penser qu'il peut être transmis nilpour les deux paramètres. Mais telServe

 http.Serve(nil, nil) 

conduit à une terrible panique.

Astuce . Ne mélangez pas les paramètres dans la même signature de fonction nilet non nil.

L'auteur a http.ListenAndServetenté de simplifier la vie des utilisateurs d'API pour le cas par défaut, mais la sécurité a été affectée.

En présence, nilil n'y a pas de différence de nombre de lignes entre utilisation explicite et indirecte DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

comparé à

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Vaut-il la peine de garder une ligne?

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

Astuce . Réfléchissez sérieusement au temps que les fonctions d'assistance permettront au programmeur. La clarté vaut mieux que la brièveté.

Astuce . Évitez les API publiques avec des paramètres dont seuls les tests ont besoin. Évitez d'exporter des API avec des paramètres dont les valeurs ne diffèrent que pendant les tests. Au lieu de cela, exportez les fonctions d'encapsuleur qui masquent le transfert de ces paramètres et, dans les tests, utilisez des fonctions d'assistance similaires qui transmettent les valeurs nécessaires au test.

6.2.2. Utilisez des arguments de longueur variable au lieu de [] T


Très souvent, une fonction ou une méthode prend une tranche de valeurs.

 func ShutdownVMs(ids []string) error 

Ce n'est qu'un exemple inventé, mais c'est très courant. Le problème est que ces signatures supposent qu'elles seront appelées avec plusieurs enregistrements. Comme l'expérience le montre, ils sont souvent appelés avec un seul argument, qui doit être «compressé» à l'intérieur de la tranche afin de répondre aux exigences de la signature de fonction.

De plus, puisque le paramètre idsest une tranche, vous pouvez passer une tranche vide ou zéro à la fonction, et le compilateur sera content. Cela ajoute une charge de test supplémentaire car les tests devraient couvrir de tels cas.

Pour donner un exemple d'une telle classe d'API, j'ai récemment refactorisé la logique qui nécessitait l'installation de certains champs supplémentaires si au moins un des paramètres n'était pas nul. La logique ressemblait à ceci:

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

Étant donné que l'opérateur ifdevenait très long, je voulais tirer la logique de validation dans une fonction distincte. Voici ce que j'ai trouvé:

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

Cela a permis d'indiquer clairement la condition dans laquelle l'unité intérieure sera exécutée:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

Cependant, il y a un problème avec anyPositive, quelqu'un pourrait l'appeler accidentellement comme ceci:

 if anyPositive() { ... } 

Dans ce cas, anyPositivereviendra false. Ce n'est pas la pire option. Pire si anyPositiveretourné trueen l'absence d'arguments.

Cependant, il serait préférable de pouvoir modifier la signature de anyPositive pour garantir qu'au moins un argument est passé à l'appelant. Cela peut être fait en combinant les paramètres des arguments normaux et des arguments de longueur variable (varargs):

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

Maintenant, anyPositivevous ne pouvez pas appeler avec moins d'un argument.

6.3. Laissez les fonctions déterminer le comportement souhaité.


Supposons que l'on m'ait donné la tâche d'écrire une fonction qui préserve la structure Documentsur le disque.

 // Save      f. func Save(f *os.File, doc *Document) error 

Je pourrais écrire une fonction Savequi écrit Documentdans un fichier *os.File. Mais il y a quelques problèmes.

La signature Saveélimine la possibilité d'enregistrer des données sur le réseau. Si une telle exigence apparaît à l'avenir, la signature de la fonction devra être modifiée, ce qui affectera tous les objets appelants.

Saveégalement désagréable à tester, car il fonctionne directement avec les fichiers sur disque. Ainsi, pour vérifier son fonctionnement, le test doit lire le contenu du fichier après l'écriture.

Et je dois m'assurer qu'il est fécrit dans un dossier temporaire puis supprimé.

*os.Filedéfinit également de nombreuses méthodes qui ne sont pas liées Save, par exemple, à la lecture de répertoires et à la vérification si un chemin est un lien symbolique. Eh bien, si la signatureSavedécrit uniquement les parties pertinentes *os.File.

Que peut-on faire?

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

À l'aide de io.ReadWriteClosercelui - ci, vous pouvez appliquer le principe de séparation d'interface - et le redéfinir Savesur une interface qui décrit les propriétés plus générales du fichier.

Après une telle modification, tout type qui implémente l'interface io.ReadWriteCloserpeut être remplacé par le précédent *os.File.

Cela étend simultanément la portée Saveet clarifie à l'appelant quels types de méthodes *os.Filesont liés à son fonctionnement.

Et l'auteur Savene peut plus appeler ces méthodes indépendantes *os.File, car il est caché derrière l'interface io.ReadWriteCloser.

Mais nous pouvons étendre encore plus le principe de la séparation des interfaces.

Tout d'abord siSave suit le principe de la responsabilité unique, il est peu probable qu'il lise le fichier qu'il vient d'écrire pour vérifier son contenu - un autre code devrait le faire.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

Par conséquent, vous pouvez affiner les spécifications de l'interface pour Savesimplement écrire et fermer.

Deuxièmement, le mécanisme de fermeture des threads y Saveest un héritage du temps où il fonctionnait avec le fichier. La question est de savoir dans quelles circonstances wcil sera fermé.

Que ce soit la Savecause de Closemanière inconditionnelle, que ce soit dans le cas de succès.

Cela présente un problème pour l'appelant car il peut souhaiter ajouter des données au flux une fois le document écrit.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

La meilleure option est de redéfinir Enregistrer pour ne fonctionner qu'avec io.Writer, en sauvant l'opérateur de toutes les autres fonctionnalités, à l'exception de l'écriture de données dans le flux.

Après avoir appliqué le principe de séparation d'interface, la fonction est devenue à la fois plus spécifique en termes d'exigences (elle n'a besoin que d'un objet où elle peut être écrite), et plus générale en termes de fonctionnalité, puisque maintenant nous pouvons l'utiliser Savepour enregistrer des données partout où elle est implémentée io.Writer.

7. Gestion des erreurs


J'ai donné plusieurs présentations et écrit beaucoup sur ce sujet sur le blog, donc je ne le répéterai pas. Au lieu de cela, je veux couvrir deux autres domaines liés à la gestion des erreurs.



7.1. Élimine le besoin de gestion des erreurs en supprimant les erreurs elles-mêmes


J'ai fait de nombreuses suggestions pour améliorer la syntaxe de gestion des erreurs, mais la meilleure option est de ne pas les gérer du tout.

Note . Je ne dis pas «supprimer la gestion des erreurs». Je suggère de changer le code afin qu'il n'y ait aucune erreur de traitement.

Le récent livre sur la philosophie du développement logiciel de John Osterhout m'a inspiré pour faire cette suggestion . L'un des chapitres est intitulé «Éliminer les erreurs de la réalité». Essayons d'appliquer ce conseil.

7.1.1. Nombre de lignes


Nous allons écrire une fonction pour compter le nombre de lignes dans un fichier.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

Comme nous suivons les conseils des sections précédentes, CountLinesaccepte io.Reader, non *os.File; c’est déjà la tâche de l’appelant de fournir io.Readerle contenu dont nous voulons compter.

Nous créons bufio.Reader, puis appelons la méthode en boucle ReadString, en augmentant le compteur, jusqu'à ce que nous atteignions la fin du fichier, puis nous renvoyons le nombre de lignes lues.

Au moins, nous voulons écrire un tel code, mais la fonction est gênée par la gestion des erreurs. Par exemple, il y a une construction si étrange:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

Nous augmentons le nombre de lignes avant de rechercher des erreurs - cela semble étrange.

La raison pour laquelle nous devons l'écrire de cette façon est qu'il ReadStringretournera une erreur s'il rencontre la fin du fichier plus tôt que le caractère de nouvelle ligne. Cela peut se produire s'il n'y a pas de nouvelle ligne à la fin du fichier.

Pour essayer de résoudre ce problème, modifiez la logique du compteur de lignes, puis voyez si nous devons quitter la boucle.

Note . Cette logique n'est pas encore parfaite, pouvez-vous trouver une erreur?

Mais nous n'avons pas fini de vérifier les erreurs. ReadStringretournera io.EOFquand il rencontrera la fin du fichier. C'est la situation attendue, donc pour que ReadStringvous ayez besoin de dire "arrêtez, il n'y a plus rien à lire". Par conséquent, avant de renvoyer l'erreur à l'objet appelant CountLine, vous devez vérifier que l'erreur n'est pas liée à io.EOF, puis la transmettre, sinon nous revenons nilet disons que tout va bien.

Je pense que c'est un bon exemple de la thèse de Russ Cox sur la façon dont la gestion des erreurs peut masquer la fonction. Regardons la version améliorée.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

Cette version améliorée utilise à la bufio.Scannerplace bufio.Reader.

Sous le capot bufio.Scannerutilise bufio.Reader, mais ajoute un bon niveau d'abstraction, ce qui permet de supprimer la gestion des erreurs.

. bufio.Scanner , .

La méthode sc.Scan()renvoie une valeur truesi le scanner a rencontré une chaîne et n'a pas trouvé d'erreur. Ainsi, le corps de la boucle n'est forappelé que s'il y a une ligne de texte dans le tampon du scanner. Cela signifie que le nouveau CountLinesgère les cas lorsqu'il n'y a pas de nouvelle ligne ou lorsque le fichier est vide.

Deuxièmement, puisqu'il sc.Scanrevient falselorsqu'une erreur est détectée, le cycle forse termine lorsqu'il atteint la fin du fichier ou qu'une erreur est détectée. Le type se bufio.Scannersouvient de la première erreur qu'il a rencontrée, et en utilisant la méthode, sc.Err()nous pouvons restaurer cette erreur dès que nous quittons la boucle.

Enfin, il sc.Err()prend en charge le traitement io.EOFet le convertit nilsi la fin du fichier est atteinte sans erreur.

Astuce . Si vous rencontrez un traitement d'erreur excessif, essayez d'extraire certaines opérations dans un type d'assistance.

7.1.2. Writeresponse


Mon deuxième exemple est inspiré de l'article «Les erreurs sont des valeurs» .

Plus tôt, nous avons vu des exemples de la façon dont un fichier est ouvert, écrit et fermé. Il existe une gestion des erreurs, mais ce n'est pas trop, car les opérations peuvent être encapsulées dans des assistants, tels que ioutil.ReadFileet ioutil.WriteFile. Mais lorsque vous travaillez avec des protocoles réseau de bas niveau, il est nécessaire de créer une réponse directement à l'aide de primitives d'E / S. Dans ce cas, la gestion des erreurs peut devenir intrusive. Considérons un fragment d'un serveur HTTP qui crée une réponse HTTP.

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

Commencez par créer la barre d'état avec fmt.Fprintfet vérifiez l'erreur. Ensuite, pour chaque rubrique, nous écrivons une clé et une valeur de rubrique, vérifiant à chaque fois une erreur. Enfin, nous complétons la section d'en-tête par une autre \r\n, vérifions l'erreur et copions le corps de la réponse au client. Enfin, bien que nous n'ayons pas besoin de vérifier l'erreur io.Copy, nous devons la traduire de deux valeurs de retour à la seule qui retourne WriteResponse.

C'est beaucoup de travail monotone. Mais vous pouvez faciliter votre tâche en appliquant un petit type d'emballage errWriter.

errWritersatisfait le contrat io.Writer, il peut donc être utilisé comme emballage. errWritertransmet les enregistrements via la fonction jusqu'à ce qu'une erreur soit détectée. Dans ce cas, il rejette les entrées et renvoie l'erreur précédente.

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

S'il est appliqué errWriterà WriteResponse, la clarté du code est considérablement améliorée. Vous n'avez plus besoin de vérifier les erreurs dans chaque opération individuelle. Le message d'erreur se déplace à la fin de la fonction en tant que vérification de champ ew.err, évitant la traduction gênante des valeurs io.Copy renvoyées.

7.2. Traiter l'erreur une seule fois


Enfin, je tiens à noter que les erreurs ne doivent être traitées qu'une seule fois. Le traitement signifie vérifier la signification de l'erreur et prendre une seule décision.

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

Si vous prenez moins d'une décision, vous ignorez l'erreur. Comme nous le voyons ici, l'erreur de est w.WriteAllignorée.

Mais prendre plus d'une décision en réponse à une erreur est également une erreur. Voici le code que je rencontre souvent.

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

Dans cet exemple, si une erreur se produit pendant le temps w.Write, la ligne est écrite dans le journal et également renvoyée à l'objet appelant, qui, peut-être, le journalisera également et le transmettra, jusqu'au niveau supérieur du programme.

Très probablement, l'appelant fait de même:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Ainsi, une pile de lignes répétitives est créée dans le journal.

 unable to write: io.EOF could not write config: io.EOF 

Mais en haut du programme, vous obtenez une erreur d'origine sans aucun contexte.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

Je souhaite analyser ce sujet plus en détail, car je ne considère pas le problème du renvoi simultané d'une erreur et de l'enregistrement de mes préférences personnelles.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Je rencontre souvent un problème qu'un programmeur oublie de renvoyer d'une erreur. Comme nous l'avons dit plus tôt, le style de Go est d'utiliser des opérateurs de limites, de vérifier les prérequis lors de l'exécution de la fonction et de revenir tôt.

Dans cet exemple, l'auteur a vérifié l'erreur, l'a enregistrée, mais a oublié de revenir. Pour cette raison, un problème subtil se pose.

Le contrat de gestion des erreurs Go indique qu'en présence d'une erreur, aucune hypothèse ne peut être émise sur le contenu des autres valeurs de retour. Comme le marshaling JSON a échoué, le contenu est bufinconnu: il peut ne rien contenir, mais pire, il peut contenir un fragment JSON à moitié écrit.

Étant donné que le programmeur a oublié de revenir après avoir vérifié et enregistré l'erreur, le tampon endommagé sera transféré WriteAll. L'opération est susceptible de réussir et le fichier de configuration ne sera donc pas écrit correctement. Cependant, la fonction se termine normalement et le seul signe qu'un problème s'est produit est une ligne dans le journal où le marshaling JSON a échoué, et non un échec d'enregistrement de configuration.

7.2.1. Ajout de contexte aux erreurs


Une erreur s'est produite car l'auteur tentait d'ajouter du contexte au message d'erreur. Il a tenté de laisser une marque pour indiquer la source de l'erreur.

Voyons une autre façon de faire de même fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

Si vous combinez l'enregistrement d'erreur avec le retour sur une seule ligne, il est plus difficile d'oublier de revenir et d'éviter une continuation accidentelle.

Si une erreur d'E / S se produit lors de l'écriture du fichier, la méthode Error()produira quelque chose comme ceci:

 could not write config: write failed: input/output error 

7.2.2. Erreur d'encapsulation avec github.com/pkg/errors


Le modèle fmt.Errorffonctionne bien pour l'enregistrement des messages d' erreur, mais le type d' erreur passe par le chemin. J'ai soutenu que la gestion des erreurs en tant que valeurs opaques est importante pour les projets à couplage lâche , donc le type de l'erreur d'origine ne devrait pas avoir d'importance si nous avons seulement besoin de travailler avec sa valeur:

  1. Assurez-vous qu'il n'est pas nul.
  2. Affichez-le à l'écran ou enregistrez-le.

Cependant, il arrive que vous deviez restaurer l'erreur d'origine. Pour annoter de telles erreurs, vous pouvez utiliser quelque chose comme mon package errors:

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

Maintenant, le message devient un joli bug de style K & D:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

et sa valeur contient un lien vers la raison d'origine.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

Ainsi, vous pouvez restaurer l'erreur d'origine et afficher la trace de la pile:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

Le package errorsvous permet d'ajouter du contexte aux valeurs d'erreur dans un format pratique pour une personne et une machine. Lors d'une récente présentation, je vous ai dit que dans la prochaine version de Go, un tel wrapper apparaîtra dans la bibliothèque standard.

8. Concurrence


Go est souvent choisi en raison de ses capacités de concurrence. Les développeurs ont fait beaucoup pour augmenter son efficacité (en termes de ressources matérielles) et sa productivité, mais les fonctions de parallélisme de Go peuvent être utilisées pour écrire du code qui n'est ni productif ni fiable. À la fin de l'article, je veux donner quelques conseils sur la façon d'éviter certains des pièges des fonctions de concurrence de Go.

La prise en charge simultanée de premier ordre de Go est fournie par des canaux, ainsi que des instructions selectetgo. Si vous avez étudié la théorie du Go dans des manuels ou dans une université, vous avez peut-être remarqué que la section sur le parallélisme est toujours l'une des dernières du cours. Notre article n'est pas différent: j'ai décidé de parler du parallélisme à la fin, comme quelque chose en plus des compétences habituelles que le programmeur Go devrait apprendre.

Il existe une certaine dichotomie, car la caractéristique principale de Go est notre modèle simple et facile de parallélisme. En tant que produit, notre langue se vend aux dépens de presque cette seule fonction. D'un autre côté, la simultanéité n'est en fait pas si facile à utiliser, sinon les auteurs n'en auraient pas fait le dernier chapitre de leurs livres, et nous n'aurions pas regardé avec regret notre code.

Cette section traite de certains des pièges de l'utilisation naïve des fonctions de concurrence Go.

8.1. Faites du travail tout le temps.


Quel est le problème avec ce programme?

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

Le programme fait ce que nous voulions: il sert un simple serveur Web. En même temps, il passe du temps CPU dans une boucle infinie, car for{}dans la dernière ligne, il mainbloque gorutin main, sans effectuer d'E / S, il n'y a pas d'attente pour bloquer, envoyer ou recevoir des messages, ou une sorte de connexion avec le sheduler.

Étant donné que le temps d'exécution Go est généralement servi par un sheduler, ce programme s'exécute de manière insensée sur le processeur et peut se retrouver dans un verrou actif (live-lock).

Comment y remédier? Voici une option.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

Cela peut sembler idiot, mais c'est une solution courante qui me vient à l'esprit dans la vraie vie. Il s'agit d'un symptôme d'une mauvaise compréhension du problème sous-jacent.

Si vous êtes un peu plus expérimenté avec Go, vous pouvez écrire quelque chose comme ça.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

Une instruction vide est selectbloquée pour toujours. C'est utile, car maintenant nous ne faisons pas tourner le processeur entier juste pour un appel runtime.GoSched(). Cependant, nous ne traitons que le symptôme, pas la cause.

Je veux vous montrer une autre solution qui, je l'espère, vous est déjà venue à l'esprit. Au lieu de courir http.ListenAndServedans goroutine, laissant le problème principal de goroutine, exécutez simplement http.ListenAndServedans le goroutine principal.

Astuce . Si vous quittez la fonction main.main, le programme Go se termine inconditionnellement, indépendamment de ce que font les autres goroutines en cours d'exécution pendant l'exécution du programme.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

C'est donc mon premier conseil: si le goroutin ne peut pas progresser tant qu'il n'a pas reçu le résultat d'un autre, alors il est souvent plus facile de faire le travail soi-même, plutôt que de le déléguer.

Cela élimine souvent beaucoup de suivi d'état et de manipulation de canal nécessaires pour retransférer le résultat de la goroutine à l'initiateur du processus.

Astuce . De nombreux programmeurs Go abusent des goroutines, surtout au début. Comme tout le reste de la vie, la clé du succès est la modération.

8.2. Laisser le parallélisme à l'appelant


Quelle est la différence entre les deux API?

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

Nous mentionnons les différences évidentes: le premier exemple lit le répertoire dans une tranche, puis retourne la tranche entière ou l'erreur en cas de problème. Cela se produit de manière synchrone, l'appelant bloque ListDirectoryjusqu'à ce que toutes les entrées du répertoire aient été lues. Selon la taille du répertoire, cela peut prendre beaucoup de temps et potentiellement beaucoup de mémoire.

Prenons le deuxième exemple. C'est un peu plus comme la programmation Go classique, ici elle ListDirectoryrenvoie le canal par lequel les entrées du répertoire seront transmises. Lorsque le canal est fermé, cela signifie qu'il n'y a plus d'entrées de catalogue. Puisque le remplissage du canal a lieu après le retour ListDirectory, on peut supposer que les goroutines commencent à remplir le canal.

Note . Dans la deuxième option, il n'est pas nécessaire d'utiliser réellement goroutine: vous pouvez sélectionner un canal suffisant pour stocker toutes les entrées du répertoire sans les bloquer, le remplir, le fermer, puis renvoyer le canal à l'appelant. Mais cela est peu probable, car dans ce cas, les mêmes problèmes se poseront lors de l'utilisation d'une grande quantité de mémoire pour mettre en mémoire tampon tous les résultats dans le canal.

La version du ListDirectorycanal a deux autres problèmes:

  • L'utilisation d'un canal fermé comme signal qu'il n'y a plus d'éléments à traiter ListDirectoryne peut pas informer l'appelant d'un ensemble d'éléments incomplet en raison d'une erreur. L'appelant n'a aucun moyen de transmettre la différence entre un répertoire vide et une erreur. Dans les deux cas, il semble que la chaîne sera immédiatement fermée.
  • L'appelant doit continuer à lire à partir du canal lorsqu'il est fermé, car c'est la seule façon de comprendre que le goroutine de remplissage du canal a cessé de fonctionner. Il s'agit d'une sérieuse restriction d'utilisation ListDirectory: l'appelant passe du temps à lire sur le canal, même s'il a reçu toutes les données nécessaires. C'est probablement plus efficace en termes d'utilisation de la mémoire pour les répertoires moyens et grands, mais la méthode n'est pas plus rapide que la méthode basée sur la tranche d'origine.

Dans les deux cas, la solution consiste à utiliser un rappel: une fonction qui est appelée dans le contexte de chaque entrée de répertoire lors de son exécution.

 func ListDirectory(dir string, fn func(string)) 

Sans surprise, la fonction filepath.WalkDirfonctionne de cette façon.

Astuce . Si votre fonction lance goroutine, vous devez fournir à l'appelant un moyen d'arrêter explicitement cette routine. Il est souvent plus facile de laisser le mode d'exécution asynchrone à l'appelant.

8.3. Ne jamais courir de goroutine sans savoir quand elle s'arrêtera


Dans l'exemple précédent, la goroutine a été utilisée inutilement. Mais l'une des principales forces de Go est ses capacités de concurrence de première classe. En effet, dans de nombreux cas, un travail parallèle est tout à fait approprié, et il est alors nécessaire d'utiliser des goroutines.

Cette application simple sert le trafic http sur deux ports différents: le port 8080 pour le trafic d'application et le port 8001 pour l'accès au point de terminaison /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

Bien que le programme ne soit pas compliqué, il est le fondement d'une véritable application.

L'application dans sa forme actuelle présente plusieurs problèmes qui apparaîtront au fur et à mesure de leur croissance, alors examinons immédiatement certains d'entre eux.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

gestionnaires de rupture serveAppet serveDebugde fonctions distinctes, nous avons séparé les de main.main. Nous avons également suivi les conseils et précédent assurés serveAppet serveDebuglaisser la tâche d'assurer le parallélisme de l'appelant.

Mais il y a quelques problèmes avec la performance d'un tel programme. Si nous quittons serveApppuis quittons main.main, le programme se termine et sera redémarré par le gestionnaire de processus.

Astuce . Tout comme les fonctions de Go laissent le parallélisme à l'appelant, les applications doivent cesser de surveiller leur état et redémarrer le programme qui les a appelées. Ne rendez pas vos applications responsables du redémarrage elles-mêmes: cette procédure est mieux gérée depuis l'extérieur de l'application.

Cependant, il serveDebugcommence dans un goroutine séparé, et en cas de sortie, le goroutine se termine, tandis que le reste du programme continue. Vos développeurs n'aimeront pas le fait que vous ne puissiez pas obtenir de statistiques sur les applications, car le gestionnaire /debuga longtemps cessé de fonctionner.

Nous devons nous assurer que l'application est fermée si un goroutine le servant s'arrête .

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

Maintenant, serverAppils serveDebugvérifient les erreurs ListenAndServeet, si nécessaire, les appellent log.Fatal. Puisque les deux gestionnaires travaillent dans des goroutines, nous établissons la routine principale en select{}.

Cette approche pose un certain nombre de problèmes:

  1. S'il ListenAndServerevient avec une erreur nil, il n'y aura pas d'appel log.Fatalet le service HTTP sur ce port se fermera sans arrêter l'application.
  2. log.Fatalles appels os.Exitqui quittent inconditionnellement le programme; les appels différés ne fonctionneront pas, les autres goroutines ne seront pas informés de la fermeture, le programme s'arrêtera simplement. Cela rend difficile l'écriture de tests pour ces fonctions.

Astuce . Utiliser uniquement log.Fatalsur les fonctions main.mainou init.

En fait, nous voulons transmettre toute erreur qui se produit au créateur de la goroutine, afin qu'il puisse découvrir pourquoi elle a arrêté et terminé proprement le processus.

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

Le statut de retour de la goroutine peut être obtenu via le canal. La taille du canal est égale au nombre de goroutines que nous voulons contrôler, donc l'envoi vers le canal donene sera pas bloqué, car cela bloquera l'arrêt des goroutines et provoquera une fuite.

Étant donné que le canal donene peut pas être fermé en toute sécurité, nous ne pouvons pas utiliser l'idiome pour le cycle du canal for rangejusqu'à ce que tous les goroutines aient signalé. Au lieu de cela, nous exécutons tous les goroutines en cours d'exécution dans un cycle, qui est égal à la capacité du canal.

Nous avons maintenant un moyen de quitter proprement chaque goroutine et de corriger toutes les erreurs qu'ils rencontrent. Il ne reste plus qu'à envoyer un signal pour terminer le travail du premier goroutine à tout le monde.

L'appel àhttp.Serverà propos de l'achèvement, j'ai donc enveloppé cette logique dans une fonction d'aide. L'assistant serveaccepte l'adresse et http.Handler, de même http.ListenAndServe, le canal stopque nous utilisons pour exécuter la méthode Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

Maintenant, pour chaque valeur du canal, donenous stopfermons le canal , ce qui fait que chaque gorutine sur ce canal se ferme http.Server. À son tour, cela conduit à un retour de tous les goroutines restants ListenAndServe. Lorsque tous les gorutins en cours d'exécution se sont arrêtés, il main.mainse termine et le processus s'arrête proprement.

Astuce . Écrire une telle logique par vous-même est un travail répétitif et le risque d'erreurs. Regardez quelque chose comme ce package qui fera la plupart du travail pour vous.

Source: https://habr.com/ru/post/fr441842/


All Articles