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:
- Simplicité
- Lisibilité
- 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 }
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 {
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.
É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:
- Expliquez ce que fait le code.
- Expliquez comment il le fait.
- Expliquez pourquoi .
Le premier formulaire est idéal pour commenter des personnages publics:
Le second est idéal pour les commentaires à l'intérieur d'une méthode:
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{
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
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.
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.
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
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:
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
.
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.
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:- .
- . , .
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 utils
ou se helpers
trouvent 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
helpers
et, 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, strings
pour les utilitaires de traitement de chaîne.
Les packages portant des noms tels que base
ou common
sont 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/http
ne pas faire les paquets individuels client
et server
, à la place, il y a des fichiers client.go
et server.go
les types de données correspondants, ainsi que transport.go
pour le transport total.Astuce . Il est important de se rappeler que le nom de l'identifiant inclut le nom du package.
- Une fonction
Get
d'un package net/http
devient un http.Get
lien à partir d'un autre package.
- Un type
Reader
d'un package est strings
transformé en lors de son importation dans d'autres packages strings.Reader
.
- L'interface
Error
du package est net
clairement 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 try
et 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.lastRead
et 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.lastRead
supé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 nil
doit ê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.Mutex
qui 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
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 len
deux cap
sont égaux 0
et 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() {
Note . var s []string
similaire 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:- Utilisez des interfaces pour décrire le comportement requis par les fonctions ou les méthodes.
- É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:- Déplacez les variables correspondantes sous forme de champs vers les structures qui en ont besoin.
- 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 common
est 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 common
et 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/contour
qui 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
, private
et 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 .go
en 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 http
doit se trouver dans un fichier http.go
dans 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.go
contiendra des types Request
et Response
, un client.go
type de Client
fichier 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 go
prend en charge le package testing
à deux endroits. Si vous avez un package http2
, vous pouvez écrire un fichier http2_test.go
et 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 go
prend é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 go
reconnaî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 go
voit 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/f
peut importer uniquement un package à partir d'une arborescence de répertoires .../a/b/c
, mais pas du tout .../a/b/g
ou tout autre référentiel (voirdocumentation ).5.2. Le plus petit paquet principal
Une fonction main
et un package main
doivent avoir des fonctionnalités minimales, car ils main.main
agissent comme un singleton: un programme ne peut avoir qu'une seule fonction main
, y compris les tests.Comme il main.main
s'agit d'un singleton, il existe de nombreuses restrictions sur les objets appelés: ils ne sont appelés que pendant main.main
ou 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)
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 CopyFile
toujours 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
accepte deux paramètres: une adresse TCP pour écouter les connexions entrantes et http.Handler
pour traiter une requête HTTP entrante. Serve
permet 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.DefaultServeMux
comme paramètre implicite.Maintenant, l'appelant Serve
a 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 nil
se propage comme un virus. Le package a également http
une 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 ListenAndServe
permet à l'appelant de passer nil
pour le deuxième paramètre, il http.Serve
prend également en charge ce comportement. En fait, c'est dans la http.Serve
logique implémentée "si le gestionnaire est égal nil
, utilisez DefaultServeMux
". L'acceptation nil
d'un paramètre peut amener l'appelant à penser qu'il peut être transmis nil
pour 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 nil
et non nil
.
L'auteur a http.ListenAndServe
tenté 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, nil
il 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 ids
est 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 {
Étant donné que l'opérateur if
devenait très long, je voulais tirer la logique de validation dans une fonction distincte. Voici ce que j'ai trouvé:
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) {
Cependant, il y a un problème avec anyPositive
, quelqu'un pourrait l'appeler accidentellement comme ceci: if anyPositive() { ... }
Dans ce cas, anyPositive
reviendra false
. Ce n'est pas la pire option. Pire si anyPositive
retourné true
en 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):
Maintenant, anyPositive
vous 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 Document
sur le disque.
Je pourrais écrire une fonction Save
qui écrit Document
dans 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.File
dé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 signatureSave
décrit uniquement les parties pertinentes *os.File
.Que peut-on faire?
À l'aide de io.ReadWriteCloser
celui - ci, vous pouvez appliquer le principe de séparation d'interface - et le redéfinir Save
sur 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.ReadWriteCloser
peut être remplacé par le précédent *os.File
.Cela étend simultanément la portée Save
et clarifie à l'appelant quels types de méthodes *os.File
sont liés à son fonctionnement.Et l'auteur Save
ne 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.
Par conséquent, vous pouvez affiner les spécifications de l'interface pour Save
simplement écrire et fermer.Deuxièmement, le mécanisme de fermeture des threads y Save
est un héritage du temps où il fonctionnait avec le fichier. La question est de savoir dans quelles circonstances wc
il sera fermé.Que ce soit la Save
cause de Close
maniè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.
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 Save
pour 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, CountLines
accepte io.Reader
, non *os.File
; c’est déjà la tâche de l’appelant de fournir io.Reader
le 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 ReadString
retournera 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. ReadString
retournera io.EOF
quand il rencontrera la fin du fichier. C'est la situation attendue, donc pour que ReadString
vous 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 nil
et 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.Scanner
place bufio.Reader
.Sous le capot bufio.Scanner
utilise 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 true
si le scanner a rencontré une chaîne et n'a pas trouvé d'erreur. Ainsi, le corps de la boucle n'est for
appelé que s'il y a une ligne de texte dans le tampon du scanner. Cela signifie que le nouveau CountLines
gère les cas lorsqu'il n'y a pas de nouvelle ligne ou lorsque le fichier est vide.Deuxièmement, puisqu'il sc.Scan
revient false
lorsqu'une erreur est détectée, le cycle for
se termine lorsqu'il atteint la fin du fichier ou qu'une erreur est détectée. Le type se bufio.Scanner
souvient 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.EOF
et le convertit nil
si 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.ReadFile
et 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.Fprintf
et 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
.errWriter
satisfait le contrat io.Writer
, il peut donc être utilisé comme emballage. errWriter
transmet 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.
Si vous prenez moins d'une décision, vous ignorez l'erreur. Comme nous le voyons ici, l'erreur de est w.WriteAll
ignoré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)
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)
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)
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 buf
inconnu: 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.Errorf
fonctionne 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:- Assurez-vous qu'il n'est pas nul.
- 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 errors
vous 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 select
etgo
. 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 main
bloque 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 select
bloqué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.ListenAndServe
dans goroutine, laissant le problème principal de goroutine, exécutez simplement http.ListenAndServe
dans 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?
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 ListDirectory
jusqu'à 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 ListDirectory
renvoie 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 ListDirectory
canal a deux autres problèmes:- L'utilisation d'un canal fermé comme signal qu'il n'y a plus d'éléments à traiter
ListDirectory
ne 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.WalkDir
fonctionne 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)
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 serveApp
et serveDebug
de fonctions distinctes, nous avons séparé les de main.main
. Nous avons également suivi les conseils et précédent assurés serveApp
et serveDebug
laisser 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 serveApp
puis 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 serveDebug
commence 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 /debug
a 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, serverApp
ils serveDebug
vérifient les erreurs ListenAndServe
et, 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:- S'il
ListenAndServe
revient avec une erreur nil
, il n'y aura pas d'appel log.Fatal
et le service HTTP sur ce port se fermera sans arrêter l'application.
log.Fatal
les appels os.Exit
qui 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.Fatal
sur les fonctions main.main
ou 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 done
ne sera pas bloqué, car cela bloquera l'arrêt des goroutines et provoquera une fuite.Étant donné que le canal done
ne peut pas être fermé en toute sécurité, nous ne pouvons pas utiliser l'idiome pour le cycle du canal for range
jusqu'à 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 serve
accepte l'adresse et http.Handler
, de même http.ListenAndServe
, le canal stop
que 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
Maintenant, pour chaque valeur du canal, done
nous stop
fermons 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.main
se 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.