Mauvais conseils pour un programmeur Go

Après des décennies de programmation en Java, ces dernières années, j'ai principalement travaillé sur Go. Travailler avec Go est génial, principalement parce que le code est très facile à suivre. Java a simplifié le modèle de programmation C ++ en supprimant l'héritage multiple, la gestion manuelle de la mémoire et la surcharge de l'opérateur. Go fait de même, continuant à évoluer vers un style de programmation simple et direct, supprimant complètement l'héritage et la surcharge de fonctions. Le code simple est un code lisible et le code lisible est un code pris en charge. Et c'est formidable pour l'entreprise et mes employés.
Comme dans toutes les cultures, le développement de logiciels a ses propres légendes, des histoires qui sont racontées par le refroidisseur d'eau. Nous avons tous entendu parler de développeurs qui, au lieu de se concentrer sur la création d'un produit de qualité, se concentrent sur la protection de leur propre travail contre les étrangers. Ils n'ont pas besoin de code pris en charge, car cela signifie que d'autres personnes pourront le comprendre et le modifier. Est-ce possible sur Go? Est-il possible de rendre le code Go si compliqué? Je dirai tout de suite - ce n'est pas une tâche facile. Examinons les options possibles.
Vous pensez: «
Combien pouvez-vous corroder le code dans un langage de programmation? Est-il possible d'écrire un code si horrible sur Go que son auteur devient indispensable dans l'entreprise? »Ne t'inquiète pas. Quand j'étais étudiant, j'avais un projet dans lequel je soutenais le code Lisp-e de quelqu'un d'autre écrit par un étudiant diplômé. En fait, il a réussi à écrire du code Fortran-e en utilisant Lisp. Le code ressemblait à ceci:
(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d )
Il y avait des dizaines de fichiers de ce code. Il était absolument terrible et absolument brillant à la fois. J'ai passé des mois à essayer de le comprendre. Par rapport à cela, écrire du mauvais code sur Go n'est qu'une salive.
Il existe de nombreuses façons différentes de rendre votre code non pris en charge, mais nous n'en examinerons que quelques-unes. Pour faire le mal, vous devez d'abord apprendre à faire le bien. Par conséquent, nous examinons d'abord comment les «bons» programmeurs Go écrivent, puis nous regardons comment faire le contraire.
Mauvais emballage
Les packages sont un sujet pratique pour commencer. Comment l'organisation du code peut-elle nuire à la lisibilité?
Dans Go, le nom du package est utilisé pour faire référence à l'entité exportée (par exemple, `
fmt.Println` ou` http.RegisterFunc` ). Puisque nous pouvons voir le nom du package, les «bons» programmeurs Go s'assurent que ce nom décrit ce que sont les entités exportées. Nous ne devrions pas avoir de paquets util, car des noms comme `
util.JSONMarshal` ne fonctionneront pas pour nous - nous avons besoin de`
json.Marshal` .
Les "bons" développeurs Go ne créent pas non plus de package séparé pour le DAO ou le modèle. Pour ceux qui ne connaissent pas ce terme, un DAO est un «
objet d'accès aux données » - une couche de code qui interagit avec votre base de données. J'avais l'habitude de travailler pour une entreprise où 6 services Java importaient la même bibliothèque DAO pour accéder à la même base de données qu'ils partageaient, car "
... eh bien, vous savez, les microservices sont les mêmes ... ".
Si vous avez un package séparé avec tous vos DAO, il est plus probable que vous obtiendrez une dépendance circulaire entre les packages, ce qui est interdit dans Go. Et si vous disposez de plusieurs services qui incluent ce package DAO en tant que bibliothèque, vous pouvez également rencontrer une situation où un changement dans un service nécessite la mise à jour de tous vos services, sinon quelque chose se cassera. C'est ce qu'on appelle un monolithe distribué et est incroyablement difficile à mettre à jour.
Lorsque vous savez comment les emballages devraient fonctionner et ce qui les aggrave, «commencer à servir le mal» devient simple. Organisez mal votre code et donnez de mauvais noms à vos packages. Divisez votre code en packages tels que
model ,
util et
dao . Si vous voulez vraiment commencer à créer le chaos, essayez de créer des packages en l'honneur de votre chat ou de votre couleur préférée. Lorsque les gens sont confrontés à des dépendances cycliques ou à des monolithes distribués en raison de l'utilisation de votre code, vous devez soupirer, rouler des yeux et leur dire qu'ils font simplement du mal ...
Interfaces inappropriées
Maintenant que tous nos packages sont corrompus, nous pouvons passer aux interfaces. Les interfaces dans Go ne sont pas comme les interfaces dans d'autres langues. Le fait que vous ne déclariez pas explicitement que ce type implémente l'interface au premier abord semble insignifiant, mais en fait il renverse complètement le concept des interfaces.
Dans la plupart des langues avec des types abstraits, une interface est définie avant ou en même temps que l'implémentation. Vous devrez le faire au moins pour les tests. Si vous ne créez pas l'interface à l'avance, vous ne pouvez pas l'insérer ultérieurement sans casser tout le code qui utilise cette classe. Parce que vous devez le réécrire avec un lien vers l'interface au lieu d'un type spécifique.
Pour cette raison, le code Java a souvent de gigantesques interfaces de service avec de nombreuses méthodes. Les classes qui implémentent ces interfaces utilisent ensuite les méthodes dont elles ont besoin et ignorent le reste. L'écriture de tests est possible, mais vous ajoutez un niveau d'abstraction supplémentaire, et lorsque vous écrivez des tests, vous avez souvent recours à des outils pour générer des implémentations de ces méthodes dont vous n'avez pas besoin.
Dans Go, les interfaces implicites déterminent les méthodes à utiliser. Le code possède une interface, et non l'inverse. Même si vous utilisez un type avec de nombreuses méthodes définies, vous pouvez spécifier une interface qui inclut uniquement les méthodes dont vous avez besoin. Un autre code utilisant des champs séparés du même type définira d'autres interfaces qui couvrent uniquement les fonctionnalités nécessaires. En règle générale, ces interfaces n'ont que quelques méthodes.
Cela facilite la compréhension de votre code, car une déclaration de méthode détermine non seulement les données dont elle a besoin, mais indique également avec précision les fonctionnalités qu'elle va utiliser. C'est l'une des raisons pour lesquelles les bons développeurs Go suivent le conseil: "
Accepter les interfaces, retourner les structures ."
Mais ce n'est pas parce que c'est une bonne pratique que vous devez le faire ...
La meilleure façon de rendre vos interfaces «mauvaises» est de revenir aux principes d'utilisation des interfaces d'autres langues, c'est-à-dire Définissez les interfaces à l'avance dans le cadre du code appelé. Définissez d'énormes interfaces avec de nombreuses méthodes utilisées par tous les clients de service. Il devient difficile de savoir quelles méthodes sont vraiment nécessaires. Cela complique le code, et la complication, comme vous le savez, est le meilleur ami d'un programmeur «diabolique».
Passer les pointeurs de tas
Avant d'expliquer ce que cela signifie, vous devez un peu philosopher. Si vous distrayez et pensez, chaque programme écrit fait la même chose. Il reçoit des données, les traite, puis envoie les données traitées à un autre emplacement. Il en est ainsi, que vous écriviez un système de paie, acceptiez des requêtes HTTP et renvoyiez des pages Web, ou même vérifiez le joystick pour suivre un clic de bouton - les programmes traitent les données.
Si nous regardons les programmes de cette manière, la chose la plus importante à faire est de nous assurer qu'il est facile pour nous de comprendre comment les données sont converties. Il est donc recommandé de conserver les données inchangées aussi longtemps que possible pendant le programme. Parce que les données qui ne changent pas sont des données faciles à suivre.
Dans Go, nous avons des types de référence et des types de valeur. La différence entre les deux est de savoir si la variable fait référence à une copie des données ou à l'emplacement des données en mémoire. Les pointeurs, les tranches, les cartes, les canaux, les interfaces et les fonctions sont des types de référence, et tout le reste est un type de valeur. Si vous affectez une variable de type valeur à une autre variable, cela crée une copie de la valeur; changer une variable ne change pas la valeur d'une autre.
L'affectation d'une variable d'un type de référence à une autre variable d'un type de référence signifie qu'ils partagent tous les deux la même zone de mémoire, donc si vous modifiez les données vers lesquelles le premier pointe, vous modifiez les données vers lesquelles le second pointe. Cela est vrai pour les variables locales et les paramètres de fonction.
func main() {
Les développeurs de Kind Go veulent faciliter la compréhension de la manière dont les données sont collectées. Ils essaient d'utiliser le type de valeurs comme paramètres de fonctions aussi souvent que possible. Il n'y a aucun moyen dans Go de marquer les champs dans les structures ou les paramètres de fonction comme définitifs. Si une fonction utilise des paramètres de valeur, la modification des paramètres ne modifiera pas les variables dans la fonction appelante. Tout ce que la fonction appelée peut faire est de renvoyer la valeur à la fonction appelante. Ainsi, si vous remplissez une structure en appelant une fonction avec des paramètres de valeur, vous ne pouvez pas avoir peur de transférer des données vers la structure, car vous comprenez d'où vient chaque champ de la structure.
type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA)
Eh bien, comment devient-on «mal»? Très simple - retourner ce modèle.
Au lieu d'appeler des fonctions qui renvoient les valeurs souhaitées, vous passez un pointeur sur la structure dans la fonction et leur permettez d'apporter des modifications à la structure. Étant donné que chaque fonction a sa propre structure, la seule façon de savoir quels champs changent est de regarder le code entier. Vous pouvez également avoir des dépendances implicites entre les fonctions - la 1ère fonction transfère les données nécessaires à la 2ème fonction. Mais dans le code lui-même, rien n'indique que vous devez d'abord appeler la 1ère fonction. Si vous construisez vos structures de données de cette façon, vous pouvez être sûr que personne ne comprendra ce que fait votre code.
type Foo struct { A int B string } func setA(f *Foo) { fA = 20 }
Revêtement de panique
Maintenant, nous commençons à gérer les erreurs. Vous pensez probablement qu'il est mauvais d'écrire des programmes qui gèrent les erreurs d'environ 75%, et je ne dirai pas que vous vous trompez. Le code Go est souvent rempli de gestion des erreurs de la tête aux pieds. Et bien sûr, il serait commode de les traiter pas si facilement. Des erreurs se produisent et leur gestion est ce qui distingue les professionnels des débutants. Une gestion erronée des erreurs conduit à des programmes instables qui sont difficiles à déboguer et difficiles à maintenir. Parfois, être un «bon» programmeur signifie «tendre».
func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil }
De nombreux langages, tels que C ++, Python, Ruby et Java, utilisent des exceptions pour gérer les erreurs. Si quelque chose se passe mal, les développeurs de ces langages lèvent ou lèvent une exception, s'attendant à ce que du code le gère. Bien sûr, le programme s'attend à ce que le client soit au courant d'une erreur possible de lancement à un emplacement donné afin qu'il soit possible de lever une exception. Parce que, sauf (sans jeu de mots), les exceptions Java vérifiées, rien dans la signature de la méthode dans les langages ou les fonctions n'indique qu'une exception peut se produire. Alors, comment les développeurs savent-ils quelles exceptions s'inquiéter? Ils ont deux options:
- Tout d'abord, ils peuvent lire tout le code source de toutes les bibliothèques que leur code appelle, et toutes les bibliothèques qui appellent les bibliothèques appelées, etc.
- Deuxièmement, ils peuvent faire confiance à la documentation. Je peux être partial, mais mon expérience personnelle ne me permet pas de faire entièrement confiance à la documentation.
Alors, comment pouvons-nous faire venir ce mal? Abuser de la panique (
panique ) et de la récupération (
récupérer ), bien sûr! La panique est conçue pour des situations telles que «le lecteur est tombé» ou «la carte réseau a explosé». Mais pas pour un tel - "quelqu'un a passé une chaîne au lieu d'un int".
Malheureusement, d'autres «développeurs moins éclairés» renverront des erreurs de leur code. Par conséquent, voici une petite fonction d'aide de PanicIfErr. Utilisez-le pour transformer les erreurs des autres développeurs en panique.
func PanicIfErr(err error) { if err != nil { panic(err) } }
Vous pouvez utiliser PanicIfErr pour envelopper les erreurs des autres, compresser le code. Plus de gestion d'erreur laide! Toute erreur est maintenant une panique. C'est tellement productif!
func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} }
Vous pouvez placer la récupération quelque part plus près du début du programme, peut-être dans votre propre
middleware . Et dites ensuite que non seulement vous traitez les erreurs, mais que vous nettoyez également le code de quelqu'un d'autre. Faire le mal en faisant le bien est le meilleur type de mal.
func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) }
Définition des effets secondaires
Ensuite, nous allons créer un effet secondaire. N'oubliez pas que le «bon» développeur Go veut comprendre comment les données transitent par le programme. La meilleure façon de savoir ce que traversent les données est de configurer des dépendances explicites dans l'application. Même les entités qui correspondent à la même interface peuvent varier considérablement dans leur comportement. Par exemple, un code qui stocke des données en mémoire et un code qui accède à la base de données pour le même travail. Cependant, il existe des moyens d'installer des dépendances dans Go sans appels explicites.
Comme beaucoup d'autres langages, Go a un moyen d'exécuter comme par magie du code sans l'invoquer directement. Si vous créez une fonction appelée init sans paramètres, elle démarrera automatiquement au chargement du package. Et, pour encore plus de confusion, si dans un fichier il y a plusieurs fonctions avec le nom init ou plusieurs fichiers dans un seul paquet, elles démarreront toutes.
package account type Account struct{ Id int UserId int } func init() { fmt.Println(" !") } func init() { fmt.Println(" , init()") }
Les fonctions init sont souvent associées à des importations vides. Go a une façon spéciale de déclarer les importations, qui ressemble à «import _« github.com / lib / pq ». Lorsque vous définissez un identifiant de nom vide pour un package importé, la méthode init s'exécute dans celui-ci, mais elle n'affiche aucun des identificateurs de package. Pour certaines bibliothèques Go - telles que les pilotes de base de données ou les formats d'image - vous devez les charger en activant l'importation de package vide, juste pour appeler la fonction init afin que le package puisse enregistrer son code.
package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") }
Et c'est clairement une option «maléfique». Lorsque vous utilisez l'initialisation, le code qui fonctionne comme par magie est complètement hors du contrôle du développeur. Les meilleures pratiques ne recommandent pas d'utiliser les fonctions d'initialisation - ce sont des fonctionnalités non évidentes, elles confondent le code et sont faciles à masquer dans la bibliothèque.
En d'autres termes, les fonctions init sont idéales pour nos fins perverses. Au lieu de configurer ou d'enregistrer explicitement des entités dans des packages, vous pouvez utiliser les fonctions d'initialisation et d'importation vide pour configurer l'état de votre application. Dans cet exemple, nous mettons le compte à la disposition du reste de l'application via le registre et le package lui-même est placé dans le registre à l'aide de la fonction init.
package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) }
Si vous souhaitez utiliser un compte, mettez une importation vide dans votre programme. Il n'est pas nécessaire que ce soit le code principal ou connexe - il doit simplement être «quelque part». C'est magique!
package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) }
Si vous utilisez inits dans vos bibliothèques pour configurer les dépendances, vous verrez immédiatement que d'autres développeurs se demandent comment ces dépendances ont été installées et comment les modifier. Et personne ne sera plus sage que toi.
Configuration compliquée
Il y a encore beaucoup de tout ce que nous pouvons faire avec la configuration. Si vous êtes un «bon» développeur Go, vous voudrez isoler la configuration du reste du programme. Dans la fonction main (), vous obtenez des variables de l'environnement et les convertissez en valeurs nécessaires pour les composants qui sont explicitement liés les uns aux autres. Vos composants ne savent rien des fichiers de configuration ni de la façon dont leurs propriétés sont appelées. Pour les composants simples, vous définissez les propriétés publiques et pour les plus complexes, vous pouvez créer une fonction d'usine qui reçoit des informations de configuration et renvoie un composant correctement configuré.
func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} }
Mais les développeurs "maléfiques" savent qu'il vaut mieux disperser les informations sur la configuration à travers le programme. Au lieu d'avoir une fonction dans un package qui définit les noms et les types de valeur de votre package, utilisez une fonction qui prend la configuration telle qu'elle est et la convertit d'elle-même.
Si cela vous semble trop "diabolique", utilisez la fonction init pour charger le fichier de propriétés depuis votre package et définissez vous-même les valeurs. Il peut sembler que vous avez facilité la vie d'autres développeurs, mais vous et moi savons ...
En utilisant la fonction init, vous pouvez définir de nouvelles propriétés à l'arrière du code, et personne ne les trouvera jusqu'à ce qu'elles entrent en production et que tout tombe, car quelque chose n'entrera pas dans l'une des dizaines de fichiers de propriétés nécessaires à l'exécution. Si vous voulez encore plus de «puissance maléfique», vous pouvez suggérer de créer un wiki pour garder une trace de toutes les propriétés dans toutes les bibliothèques et pour «oublier» en ajouter périodiquement de nouvelles. En tant que gardien de propriété, vous devenez la seule personne à pouvoir exécuter le logiciel.
func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) }
Cadres de fonctionnalité
Enfin, nous arrivons au sujet des frameworks vs bibliothèques. La différence est très subtile. Ce n'est pas seulement une question de taille; vous pouvez avoir de grandes bibliothèques et de petits cadres. Le framework appelle votre code pendant que vous appelez vous-même le code de la bibliothèque. Les frameworks exigent que vous écriviez votre code d'une certaine manière, qu'il s'agisse de nommer vos méthodes selon des règles spécifiques, ou qu'elles correspondent à certaines interfaces, ou que vous soyez obligé d'enregistrer votre code dans le framework. Les frameworks ont leurs propres exigences pour tout votre code. C'est-à-dire, en général, les frameworks vous commandent.
Go encourage l'utilisation des bibliothèques car les bibliothèques sont liées. Bien que, bien sûr, chaque bibliothèque s'attende à ce que les données soient transmises dans un format spécifique, vous pouvez écrire du code de connexion pour convertir la sortie d'une bibliothèque en entrée pour une autre.
Il est difficile de faire fonctionner les frameworks de manière transparente, car chaque framework veut un contrôle complet sur le cycle de vie du code. Souvent, la seule façon de faire fonctionner les cadres est que leurs auteurs se réunissent et organisent clairement un soutien mutuel.
Et la meilleure façon d'utiliser les «mauvais cadres» pour gagner du pouvoir à long terme est d'écrire votre propre cadre, qui n'est utilisé qu'au sein de l'entreprise.Mal actuel et futur
Après avoir maîtrisé ces astuces, vous vous embarquerez à jamais sur le chemin du mal. Dans la deuxième partie, je montrerai comment déployer tout ce "mal", et comment transformer correctement le "bon" code en "mal".