Vinego est un nouvel ensemble d'analyseurs Go liés à la sécurité que nous utilisons en interne. Si vous n'êtes pas familier avec la scène de linting Go, Go fournit des interfaces officielles pour développer des analyseurs de code qui forment la base des frameworks de linter communautaires et des bundles comme golangci-lint.
Ce qui rend les nouveaux linters de Vinego uniques, c'est qu'ils se concentrent sur le rejet de certains idiomes en faveur de modèles alternatifs non idiomatiques mais plus explicites et, espérons-le, plus sûrs. Voyons cela de plus près.
Lesvaleurs zéro font référence au fait qu'en Go, toutes les variables ont des valeurs valides même si elles ne sont pas explicitement initialisées, y comprisles chaînes de caractères (la chaîne vide) et les structures (une structure dont tous les champs ont des valeurs zéro). Fondamentalement, les deux cas suivants sont équivalents :
var a int
var a int = 0
Que se passe-t-il si une variable doit être explicitement initialisée ? Dans la philosophie générale de Go (ne me citez pas), vous devriez soit :
Mais parfois, ce n'est pas possible. Par exemple, considérons ce calcul :
var desiredInstances uint32 if using_process1 { desiredInstances = process1(currentInstances, 45) } else { process2() } adjustInstances(desiredInstances)
Imaginez qu'il s'agit d'un vrai code avec un ensemble complexe de branches déterminant l'algorithme utilisé pour calculer la valeur de desiredInstances
et non d'un exemple de jouet trop abstrait.
La branche else
du code ci-dessus oublie de définir la valeur de desiredInstances
, de sorte que lorsqu'elle est utilisée, elle se termine par desiredInstances == 0
et adjustInstances
provoque un arrêt inattendu.
Le compilateur Go ne fera pas d'erreur sur ce code car, de son point de vue, var desiredInstances int
est complètement initialisé, et si la valeur zéro pose un problème, c'est parce que le code n'est pas idiomatique en Go. Mais dans cet exemple, le domaine de desiredInstances
est la gamme complète de uint32
, il n'y a donc pas de valeur que nous pouvons utiliser comme sentinelle de désinitialisation, et toute valeur par défaut pourrait causer un ajustement inattendu, il n'y a donc pas de valeur par défaut sûre.
Une façon de contourner ce problème est de changer la déclaration en var desiredInstances *int
, augmentant ainsi le nombre de valeurs disponibles d'une unité (en ajoutant nil) que nous pouvons utiliser comme sentinelle. Toutes les utilisations doivent être déréférencées en premier(*desiredInstances
), mais cela nous permettra de paniquer au lieu de détruire accidentellement toutes nos instances si nous manquons une branche, ce qui est une amélioration.
Paniquer n'est toujours pas une bonne chose. Cela finit par être une sauvegarde à l'exécution contre quelque chose qui est statiquement incorrect. (Notez que même si nous pouvions utiliser 0
comme sentinelle de désinitialisation, il s'agirait toujours d'une sauvegarde à l'exécution).
Vinego fournit le linter varinit
pour ce scénario. Le linter varinit
rejette l'idiome de la valeur zéro et considère les initialisations zéro implicites comme var x int
comme étant réellement non initialisées, et le rapport lit de telles valeurs comme incorrectes.
var x int if something() { x = 4 } else { somethingElse() } consume(x)
L'exemple ci-dessus signale que x
dans consume(x)
n'a pas été initialisé dans la branche else
ci-dessus. Pour ce faire, il suit les variables à travers le graphe de flux de contrôle du code et enregistre les ruptures de chemin avec un statut d'initialisation mixte.
Cela vous permet d'effectuer sans crainte des branchements et des initialisations complexes, des calculs, etc., sans avoir à vous soucier des valeurs zéro inattendues qui se faufilent et à concevoir votre code de manière à détecter et à atténuer les dommages dans de tels scénarios.
Mais il existe une autre source de valeurs nulles : les littéraux de structure.
Chaque fois que vous spécifiez partiellement une structure avec un littéral de structure, Go initialisera tous les champs non spécifiés. Par exemple, pendant que varinit
vérifie que x
est initialisé :
type MyStruct struct { Important int } var x MyStruct x = MyStruct{}
x.Important
n'est pas pris en compte et reçoit une valeur nulle.
allfields
introduit une nouvelle annotation que vous pouvez utiliser pour indiquer au linter que tous les champs doivent être explicitement initialisés dans le littéral :
// check:allfields type MyStruct struct { Important int } x := MyStruct{}
Ce qui précède provoquera une erreur du linter qui communiquera que Important
n'a pas été explicitement initialisé. Vous pouvez marquer les champs comme optionnels (permettant une initialisation implicite à valeur nulle) comme suit :
// check:allfields type MyStruct struct { Unimportant int `optional : "1"` }
Entre varinit
et allfields
, vous ne devriez plus avoir à vous soucier des bogues à valeur nulle !
Il y a déjà un linter(staticcheck
) dans golangci-lint
qui vérifie que toutes les erreurs
sont consommées, mais le linter abandonne l'analyse aux frontières des fonctions.
Ainsi, si vous capturez accidentellement un err
dans une portée extérieure, l'analyse cesse silencieusement de vous alerter sur le mauvais code:
x, err := operation1() if err != nil { ... } go func() { err = operation2() // pas d'erreur ! }
Dans l'exemple ci-dessus, err
n'est jamais utilisé dans func()
mais staticcheck
ne le détectera pas car err
a été déclaré à l'extérieur.
Ce que capturederr
fait dans des cas comme celui-ci est simplement de marquer toutes les utilisations de variables de type erreur
dans les définitions de fonctions locales où la variable d'erreur
a été capturée à partir de la fonction extérieure plutôt que déclarée localement. Dans la grande majorité des cas, il s'agit clairement d'un mauvais comportement- les erreurs doivent être gérées pour interrompre l'exécution localement avant que le code dépendant ne s'exécute, et la plupart des callbacks ont un retour d'erreur.
Dans cet exemple, vous obtiendrez une erreur indiquant que err
est capturé dans go func() ....
Si vous corrigez cette erreur en la remplaçant par err := operation2()
, staticcheck
recommencera à fonctionner et s'assurera que vous gérez correctement l'erreur.
Il existe un paradigme "newtype" qui consiste à utiliser des enveloppes de types minces autour de types existants, comme par exemple
type Mebibyte uint64 type Gibibyte uint64
Ces types fournissent une meilleure documentation que l'utilisation directe de uint64
dans les déclarations de variables, les signatures de types de fonctions et les définitions de structures, puisque l'unité est clairement communiquée. Idéalement, ils devraient également faire l'objet d'une vérification stricte afin d'éviter de passer accidentellement Mebibyte
là où Gibibyte
est attendu et vice-versa.
Dans certaines circonstances, go
vous donnera une erreur si vous utilisez le mauvais type, par exemple :
func Resize(newSize Mebibyte) { ... } newSize := 4 Resize(newSize) // erreur !
Cependant, dans d'autres situations, Go convertit implicitement le type, comme le montre l'exemple suivant :
func ResizeAfterDelay(newSize Mebibyte, delay time.Duration) { ... } ResizeAfterDelay(3600, 256) // Oups ! Mélange du délai et de la taille, le délai n'a pas la bonne unité, pas d'erreur.
Cela ne tient pas compte de la sécurité supplémentaire offerte par les nouveaux types uniques.
La façon dont Go distingue ces scénarios est liée aux valeurs littérales. Si vous n'êtes pas familier avec les valeurs littérales, sachez qu'il s'agit de la partie de la syntaxe qui introduit des valeurs non calculées : 43
, true
, "tout dans cet article est un mensonge !"
, MyStruct{}
.
Ces valeurs n'ont pas encore de types concrets. Si 43
est un entier, il pourrait s'agir d'un int32
ou d'un uint64
ou de tout autre type d'int, par exemple.
Lorsque vous utilisez un littéral dans une expression, que vous l'affectez à une variable, que vous le renvoyez ou que vous le passez en tant qu'argument d'une fonction, Go lui attribue un type à partir du contexte.
Dans l'exemple ci-dessus, Mebibyte
et time.Duration
sont des nouveaux types sur des entiers primitifs, donc 3600
prend le type Mebibyte
et 256
time.Duration
. Lorsque nous avons fait newSize := 4
, comme il n'y a pas d'information explicite sur le type contextuel, Go a utilisé le type entier par défaut int
(les variables ont toujours des types concrets).
Le lint explicitcast
détecte les littéraux qui sont typés contextuellement et les signale, nécessitant un cast explicite pour l'apaiser :
ResizeAt(time.Seconds * 3600, Mebibyte(256))
Cela expose alors les problèmes de sécurité de type (mauvaise unité de temps, transport d'argument) dans ce qui précède - vous obtiendrez maintenant une erreur disant que le premier argument devrait être Mebibyte
et le second Duration
.
Avec un mauvais timing, je vous présente le dernier lint : loopvariableref
.
Si vous avez suivi les annonces de versions de Go, vous savez que Go 1.22 corrige le problème à la racine et que ce linter n'est donc plus nécessaire, mais il peut toujours être utile pour les bases de code qui ne peuvent pas mettre à jour pour une raison ou une autre (peut-être ?).
Ce linter vérifie et signale les références ou les captures de variables de boucle qui permettent à la durée de vie de la variable de boucle de dépasser la boucle et d'avoir un comportement inattendu.
Aucun de ces linters n'a un signal parfait. Par exemple, varinit
suppose le pire des cas en présence de captures et de goroutines, puisque dans ces cas l'ordre d'initialisation peut changer à l'exécution. Vous pouvez donc avoir un code parfaitement valide (par exemple, en utilisant un WaitGroup
pour s'assurer qu'une goroutine se termine avant d'utiliser une variable), mais le linter le signalera quand même.
De plus, certains, comme capturederr
, peuvent signaler du code légitime et nécessiter un effort supplémentaire pour le contourner : par exemple, si vous avez besoin de passer l'erreur verbatim d'une opération dans un callback out et que d'autres mécanismes ne sont pas fournis.
Cela dit, ces mécanismes devraient permettre de combler des lacunes assez importantes en matière de sécurité et d'obtenir de meilleures garanties de base. Et les solutions de contournement, lorsqu'elles sont nécessaires, ne sont pas trop onéreuses.
Vous pouvez l'obtenir sur https://github.com/upsun/vinego - leREADME contient les instructions d'installation et d'utilisation ainsi que plus de détails sur chaque linter.
Il est distribué sous la forme d'une image docker contenant l'intégralité de golangci-lint
en tant que solution complète de construction de Go, ou vous pouvez le construire vous-même.
Pour l'instant, gardez l'œil ouvert pour d'autres articles de blog sur l'architecture de l'analyseur Go, les trucs et astuces pour travailler avec les interfaces et golangci-lint
, et comment les linters Vinego effectuent leurs analyses.