13 juin 2019

One year of Golang

Cela fait maintenant plus d’un an que j’ai l’occasion d’utiliser Golang professionnellement (bien que j’en faisais déjà un peu avant cela sur des projets Open Source). je décrirais dans cet article mon ressenti actuel sur le langage.

Le contexte

J’ai rejoint en Mai 2018 Exoscale, où je travaille notamment sur le développement et la maintenance de certains produits. Ce n’est pas un secret, nous sommes à Exoscale de gros utilisateurs de Clojure, mais nous avons aussi pas mal de Go pour des services orientés "système".

De plus, en tant que cloud provider, il est important de s’intégrer dans l’écosystème "cloud" existant. Aujourd’hui, cet écosystème est en Go. Les outils comme Terraform, Packer, l’écosystème conteneur et Kubernetes…​ tout est écrit en Go. Les intégrations avec ces produits sont donc logiquement écrites en Go.

Je suis encore loin d’être un expert sur Go (n’hésitez pas à me contacter si vous n’êtes pas d’accord avec quelque chose dans cet article), mais je voulais partager mon expérience avec le langage.

Les trucs cools

     L’écosystème et la librairie standard

Le gros plus de Go selon moi est son écosystème. Comme dit précédemment, si vous voulez vous intégrer dans l’écosystème "cloud", vous n’aurez pas vraiment le choix que de partir sur Go.

Si vous avez besoin d’écrire des daemons intéragissant avec le système et le réseau, là aussi vous y trouverez votre compte.
Par exemple, des projets comme netlink ou netstack sont très utiles lorsque vous avez à intéragir avec le réseau. Vous pouvez également vous intégrer avec systemd avec go-systemd.
Il est également simple d’écrire de petits serveurs TCP, UDP ou HTTP en Go.

De manière générale, l’écosystème Go est assez complet. On n’est plus aujourd’hui sur un langage de niche.

La librairie standard est également riche et complète.

     gofmt

gofmt est le formatter de Go. J’aime le fait de n’avoir pas à me poser de questions sur le style à adopter (et l’outil s’intègre très bien avec les IDE). Pas grand chose d’autres à dire sur ce sujet ;)

     Temps de compilation

Ça compile vite, c’est toujours intéressant.

     Performances

Les performances du langage sont bonnes, et il est possible d’écrire des services très peu gourmands. C’est assez plaisant de pouvoir écrire de petits daemons consommant moins de 15MB de RAM.

     le package time

J’aime comment le temps est géré en Golang. C’est généralement un point noir pour un certain nombre de langages, mais en Go c’est facile et assez intuitif. Jetez un oeil à la documentation si ce sujet vous intéresse.

OK mais sans plus

     Multithreading

On présente souvent Golang comme un langage génial pour le multithreading. Je ne suis que moyennement d’accord. Vous avez en gros à votre disposition les goroutines, et c’est tout.

Parlons tout d’abord de concurrence. En Go, vous n’avez pas de structures de données concurrentes, pas de structures de données compare and set (les atom en Clojure), pas de software transactional memory…​ Les programmes Go sont remplis de mutex. Cela est assez fâcheux (pas la peine d’expliquer les problèmes qu’apportent les locks lors de l’écriture de programmes conséquents et complexes), mais il n’y a pas vraiment d’autres solutions lorsque l’on a besoin d’avoir des structures de données partagées entre plusieurs threads en Go.

Bien sûr, tout Gopher digne de ce nom nous dira à se moment que nous ne respectons pas la philosophie de Golang et que nous devons réécrire notre programme pour utiliser des goroutines et des channels. Malheureusement ce n’est pas simple, et je ne dois pas être le seul à le penser vu la quantité impressionnante de résultats lorsqu’on recherche l’utilisation de Mutex dans de gros projets open source Go.

D’ailleurs, parlons des goroutines et des channels. C’est en effet un outil intéressant, mais il est très facile en les utilisant de :

  • Leak des goroutines, c’est à dire en démarrer mais d’oublier de les stopper. On se retrouve donc avec de plus en plus de goroutines, jusqu’au l’éventuel explosion du programme.

  • De se faire deadlock. Cela peut facilement arriver lorsque plusieurs goroutines attendent sur des channels. Si vous ratez votre coup, vous pouvez vous retrouver avec toutes les goroutines en attente, et votre programme est bloqué.

Il existe des outils pour limiter en partie ces problèmes, comme le race detector ou encore la librairie tomb, sur lequel j'ai déjà écrit un article. Mais malgré cela, les goroutines ne sont pas si simples que cela.

Ce qui me surprend toujours, c’est que le concept de goroutines existe depuis longtemps et est disponible dans de nombreux langages, langages qui fournissent également généralement de nombreux autres moyens pour le multithreading. j’ai du mal à voir la "révolution" que serait Go dans ce domaine.

     Courbe d’apprentissage

Go est simple à apprendre. Le langage est pauvre et sa syntaxe réduite.

Contrairement à beaucoup de monde, je ne vois pas vraiment ça comme une bonne chose: le langage est tellement réduit que ça en devient handicapant. Je pense également qu’il faut du temps pour s’habituer aux bonnes pratiques et éviter certains pièges. (cf le reste de l’article).

Les défauts du langage

     Le typesystem

Ce sujet a déjà été débattu en long, en large et en travers, mais je vais en remettre une couche.

J’aime Clojure entre autre parce qu’il est dynamiquement typé. Quand je code en Clojure, les types ne me manquent pas, et de manière générale je ne pense pas qu’un typage fort soit nécessaire pour réaliser des programmes corrects (surtout si à côté vous avez l’immutabilité, des structures de données géniales, la programmation fonctionnelle …​ Bref, ce que fournit Clojure).

Mais je sais aussi apprécier les langages fortement typés, comme Ocaml ou Rust. Il y a une certaine beauté dans les types comme Result ou Option, les algebraic data types, le pattern matching…​

Golang se trouve dans la pire catégorie possible: statiquement typé mais avec un typesystem moisi. Les types ne seront pas là pour vous aider, vous lutterez contre le typesystem. Vous n’aurez pas accès aux generics, pas d’algebraic data type, pas de pattern matching, pas de type Result/Option…​ Bref, les types ne vous aideront pas tant que ça. Un exemple:

Je veux définir un type contenant les jours de la semaine. En Rust (que je n’ai pas pratiqué depuis longtemps d’ailleurs), j’écrirais:

pub enum Weekday {
	Monday
	Tuesday
	Wednesday
	Thursday
	Friday
	Saturday
        Sunday
}

Je pourrais ensuite utiliser par exemple du pattern matching sur une variable de ce type, et j’aurais la garantie à la compilation que tous les jours possibles sont traités par mon programme.

En Go (solution venant de la doc officielle), voici comment faire:

type Weekday int

const (
	Sunday Weekday = iota
	Monday
	Tuesday
	Wednesday
	Thursday
	Friday
	Saturday
)

iota indique que ma valeur Sunday est initialisée à 0, et que les jours suivant vaudront jours précédents + 1 (donc Monday = 1, Tuesday = 2…​). Bien sûr, il sera facile de se retrouver dans des cas comme ça:

func main() {
	var day Weekday = 10
	fmt.Printf("It compiles ! %d", day)
}

Il n’y aura aucune vérification ici que toutes les valeurs possibles de votre type Weekday (qui n’est qu’un alias pour int finalement) soient traitées. Pour être franc, ma première réaction quand j’ai vu le système de iota a été:

/alt="wtf is this shit"

     Les valeurs par défaut

En Go, chaque type a sa valeur par défaut, et combiné au typesystem décrit précédemment, c’est horrible. Je vais expliquer cela par un exemple.

Mettons que je veuille écrire un client Go pour Riemann. Un event Riemann possède un certain nombre de champ, tous optionnels.

En Rust, la définition d’un event donnerait à peu près:

#[derive(Debug)]
pub enum Metric {
    Int64(i64),
    Double(f64),
    Float(f32)
}

type State = String;
type Service = String;
type Host = String;
type Description = String;
type Tag = String;
type Tags = Vec<Tag>;
type Ttl = f32;
type AttrKey = String;
type AttrValue = String;
type Attributes = HashMap<AttrKey, AttrValue>;

#[derive(Debug)]
pub struct Event {
    pub time: Option<DateTime<Utc>>,
    pub state: Option<State>,
    pub service: Option<Service>,
    pub host: Option<Host>,
    pub description: Option<Description>,
    pub tags: Option<Tags>,
    pub ttl: Option<Ttl>,
    pub attributes: Option<Attributes>,
    pub metric: Option<Metric>
}

Comme on peut le voir, la struct Event a tous ses champs optionnels. J’utilise également des alias pour représenter chaque champ. Enfin, mon champ Metric peut avoir différents formats via une enum; Int64, Float ou Double.

Comment réaliser cela en Go ? On aura probablement une struct Event contenant des champs:

type Event struct {
// ???
}

Prenons par exemple le champ description. On aura probablement dans notre struct Description string. Sauf que…​ la valeur par défaut d’une string est une chaîne vide ("").

Lorsque je vais sérialiser mon event (en ce que vous voulez: json, protobuf…​), comment puis-je faire la différence entre l’utilisateur veur que la valeur de la description soit une chaîne vide et l’utilisateur n’a pas défini l’attribut description, et donc ne veut pas l’envoyer ? Et bah vous pouvez pas.

Ceci est un ENORME problème. La première fois que vous le rencontrez, je vous garantis que vous pétez un plomb. Tout ça car le type Option n’existe pas en Golang.

/alt="flip go"

J’ai déjà rencontré plusieurs fois ce cas. Par exemple, un appel de mise à jour d’une API acceptait une liste de valeurs, et cette liste pouvait être vide (et dans ce cas côté serveur la liste était vide aussi). Sauf qu’il était impossible de faire la distinction côté Golang entre une liste vide assignée par l’utilisateur, et la liste vide de la valeur par défaut du type liste de Golang.

Mais revenons à notre client Riemann. On voit dans le type Rust que le champ Metric peut avoir plusieurs valeurs. La solution naive en Go serait:

Metricf float3
Metricd float64
Metrici int64

Sauf que là aussi, toutes ces valeurs auront 0 par défaut, et là encore aucun moyen de faire la distinction entre ce que veux l’utilisateur et la valeur par défaut de golang. Donc vous finissez par faire:

Metricf interface{} // Could be Int, Float32, Float64

Le type interface{} étant nil par défaut. Et puis tant qu’à faire des trucs dégueulasses:

if event.Metric != nil {
	switch reflect.TypeOf(event.Metric).Kind() {
	case reflect.Int, reflect.Int32, reflect.Int64:
		e.MetricSint64 = pb.Int64(reflect.ValueOf(event.Metric).Int())
	case reflect.Float32:
		e.MetricD = pb.Float64(reflect.ValueOf(event.Metric).Float())
	case reflect.Float64:
		e.MetricD = pb.Float64(reflect.ValueOf(event.Metric).Float())
	case reflect.Uint, reflect.Uint32, reflect.Uint64:
		e.MetricSint64 = pb.Int64(int64(reflect.ValueOf(event.Metric).Uint()))
	default:
		return nil, fmt.Errorf("Metric of invalid type (type %v)",
			reflect.TypeOf(event.Metric).Kind())
	}
}

interface{} est d’ailleurs un type largement utilisé en Go (faites quelques recherches sur vos projets Go favoris…​).

Bref, le typesystem combiné aux valeurs par défaut est un cauchemar. Une solution est parfois d’utiliser des pointeurs (les pointeurs pouvant être nil), mais ça fait un peu mal de pourrir sa struct avec des pointeurs juste parce que le langage a été mal pensé.

     Les pointeurs

D’ailleurs, parlons en des pointeurs. Je cherche encore l’intérêt d’avoir des pointeurs dans un langage ayant un garbage collector. Il aurait été selon moi beaucoup plus simple d’avoir un comportement des struct "à la Java" (passage par référence), et un passage par valeur pour certains types primitifs.

Combiné aux problèmes concernant les valeurs par défaut exprimés précédemment (les pointeurs pouvant etre nil), cela rajoute une difficulté de plus au langage.

     La gestion des erreurs

Un programme Go ressemble généralement à ça:

foo, err := doFoo()
if err != nil {
	return nil, err
}
bar, err := doBar(foo)
if err != nil {
	return nil, err
}
baz, err := doBaz(bar)
if err != nil {
	return nil, err
}

Le code est littéralement pollué par la gestion des erreurs. Outre l’aspect visuel, il est très facile d’oublier de retourner une erreur, ou bien de se tromper et de retourner nil où on aurait dû retourner err.

Cela est dû au fait que Golang, comme dit précédemment, ne dispose pas de type Result nous permettant de vérifier à la compilation que nous avons géré tous les cas d’erreurs possibles.

Go n’a pas non plus d’exceptions (je ne parlerais pas de panic…​.), même si finalement le package errors amène plus ou moins le concept de "stacktraces" à construire manuellement.

     La gestion des dépendances

Après une période chaotique où différents outils se tiraient la bourre (glide, godep…​), on a maintenant les modules. Mais bon, le principe reste le même: on récupère du code depuis Github. Si vous êtes un mainteneur de librairie Go, ne supprimez pas trop vite votre repository Git ;)

J’ai aussi eu des expériences complètement hallucinantes (avec explosion au runtime) en utilisant la directive replace dans un go.mod (cela permet de remplacer une dépendance par un fork par exemple), mais n’ayant jamais trop réussi à reproduire je n’irais pas plus loin sur le sujet.

     La surcharge de fonction

Il n’est pas possible de définir une fonction avec plusieurs implémentations, ce qui est assez frustrant (il n’est par exemple pas possible de définir les fonctions add(i int) et add(i int, j int) dans le même programme).

C’est quelque chose que j’utilise énormément dans d’autres langages, et devoir nommer différement des fonctions faisant la même chose donne un code plus difficile à maintenir

     programmation fonctionnelle, immutabilité

De plus en plus de langages incorporent des éléments fonctionnnels. Mais pas Go.

  • Pas de fonctions de type map, reduce, filter…​ on fait de bonnes vieilles loop for.

  • Pas d’immutabilité, vous vivrez dans un monde d’effet de bord.
    Une erreur classique que tout le monde fait une fois dans sa vie:

func main() {
	toto := []int{1, 2, 3, 4, 5}
	for _, value := range toto {
		go func() {
			time.Sleep(1 * time.Second)
			fmt.Println(value)
		}()
	}
	time.Sleep(3 * time.Second)
}

Le résultat de ce programme est:

5
5
5
5
5

En effet, la variable value est mise à jour à chaque itération de la boucle ;)

Conclusion

Comme vous le voyez, j’ai eu du mal à trouver des choses à dire dans la première section de l’article. Go est loin d’être mon langage favoris. Pourtant, malgré ces défauts, ses avantages (écosystème, performances…​) font que je comprends tout à fait son utilisation aujourd’hui dans certains contextes.

Quid des alternatives ? Si je veux un langage:

  • Compilant en binaire statique facilement.

  • Garbage collecté (donc exit Rust, je peux me permettre un garbage collector dans mes projets et j’ai moyennement envie de gérer des lifetimes et les whatmille types de pointeurs de Rust).

  • Avec un écosystème correct (malheureusement, exit Ocaml, bien que j’espère qu’un jour cela changera.).

  • Avec des performances correctes et prédictives.

Il ne reste pas grand chose à part Go. Mais bon, voyons ce qui arrivera pour Go 2.0, peut être qu’on aura des surprises.

Tags: programming golang
Top of page