DevOps avec Dhawos

Cloud, infrastructure et programmation

Introduction au langage Go pour les devs Python

Au programme

  • Comparaisons entre Go et Python
  • Les bases de Go
  • Programmation asynchrone en Go
  • Serveurs web
  • Conclusion

Comparaisons entre Python et Go

Caractéristique Python 🐍 Go 🐹
Exécution Interprété Compilé
Portable Oui Oui*
Orienté Objet Oui Non
Typage Dynamique Statique
Erreurs Exception Valeurs
Mémoire Garbage collector Garbage collector

Distribution

  • Python 🐍 : Fichiers python + dépendances (requirements.txt, pyproject.toml)
  • Go 🐹 : Compile vers un binaire statique

Image Docker

          
            FROM python:3.14
            COPY requirements.txt .
            RUN pip install -r requirements.txt
            COPY src .
            CMD src/main.py
          
        
          
            FROM golang:1.25 as builder
            COPY go.mod go.sum .
            RUN go build -o dist/my-binary .

            FROM scratch as runner
            RUN mkdir -p /app
            COPY --from=builder dist/my-binary /app
            RUN chmod +x /app/my-binary
            ENTRYPOINT /app/my-binary
          
        

Cas d'utilisations courants

  • Python 🐍 : Serveurs web, Data Science, Automatisation, Scripting
  • Go 🐹 : Serveurs, Tooling, DevOps, Cloud

Runtime

  • Python 🐍 : Binaire python qui gère l'exécution ainsi que les allocations et le garbage collector
  • Go 🐹 : Runtime inclus à la compilation dans le binaire. Il gère les goroutines ainsi que le garbage collector

Les bases de Go

En Go on organise le code en dossiers, qu'on appelle des packages. Tout programme en Go commence par un package main.

        
          // supermodule/main.go

          package main

          import "fmt"

          func main() {
            fmt.Println("Coucou")
          }
        
      

Ces packages peuvent être organisés en modules matérialisés par un fichier go.mod. C'est donc l'inverse de ce qu'on retrouve en Python.

        
          # Initialisation d'un module Go
          $ go mod init "gitlab.com/Dhawos/supermodule"
        
      

Si le module est public, n'importe qui peut le télécharger en dépendance de son projet.

        
          $ go get "gitlab.com/Dhawos/supermodule"
        
      

Chaque package peut exporter ses symboles. Tous les symboles commençant par une majuscule sont exportés, les autres ne seront pas visibles à l'extérieur. Contrairement à Python, cette règle est appliquée par le compilateur.

        
          // Pas exporté
          func echo(s string) {
            fmt.Println(s)
          }

          // Exporté
          func Echo(s string) {
            fmt.Println(s)
          }
        
      

En parlant de nommage, voici quelques conventions de nommage en Go :

  • Packages: Un seul mot simple en minuscule
  • Structs, Variables, Fonctions, etc.. : CamelCase

Définir des variables et constantes

        
          const a int = 42 // valeur explicite constante
          var b int = 42 // valeur explicite variable
          c := 42 // le plus utilisé, déclaration + assignation
        
      

Types de base

  • bool
  • int,uint
  • float
  • complex
  • string
  • rune

Playground

Zero values

Chaque type a une "zero value" qui est la valeur assignée à toute variable qui ne serait pas initialisée explicitement.

  • bool = false
  • int, uint = 0
  • float = 0
  • complex = 0
  • string = ""
  • rune = 0

Playground

Les pointeurs

En Go, les pointeurs existent mais ils sont beaucoup plus simples à utiliser qu'en C/C++. Ils permettent de prendre une valeur par "référence" sans avoir à la recopier.

⚠️ Tenter d'utiliser un pointeur nil = panic

Zero Value : nil

Playground

Les slices

C'est l'équivalent de la liste en Python.

Sous le capot, c'est un array de taille fixe mais qui peut être réalloué si besoin pour augmenter sa capacité.

Contrairement aux listes en python, un slice en Go ne peut contenir qu'un type d'éléments.

        
          mySlice := make([]int, 0, 12) // type, taille, capacité
          mySlice = append(mySlice, 42)
        
      

Zero Value : nil

Playground

Les maps

C'est l'équivalent du "dict" en Python.

        
          myMap := make(map[string]int, 12) // type clé, type valeur, capacité
        
      

Zero Value : nil

Playground

Fonctions

Pour définir des fonctions en Go, on utilise le mot clé func.

        
          // prend deux entiers en entrées et un en sortie
          func sum(a int, b int) int {
            return a + b
          }

          // une fonction peut retourner plusieurs valeurs
          func div(a int, b int) (int, error) {
            if b == 0 {
              return 0, fmt.Errorf("cannot divide by 0")
            } else {
              return a / b, nil
          }
        
      

Playground

Fonctions en tant que valeur

En Go, comme en Python, les fonctions peuvent être utilisées en tant que valeurs.

        
          var myFunc := func(a int) int {return a}
        
      

Flux d'exécution habituels

Ici on retrouve ce qu'on a l'habitude d'avoir en Python :

  • if
  • for (pas de while en Go)
  • switch

        
          // L'opérateur "range" pour les boucles for
          // ressemble très fort au enumerate en Python
          mySlice := []int{42, 12, 40, 2}
          for i, value := range mySlice {
            fmt.Println(i, value)
          }
        
      

Playground

Flux d'exécution moins habituels

Mais on a aussi quelques primitives pas si habituelles :

  • defer: exécuter des commandes en fin de scope
  • go: lancer une goroutine
  • select: lire des channels

        
          func main() {
            defer func() { fmt.Println("deferred call 1") }()
            defer func() { fmt.Println("deferred call 2") }()

            fmt.Println("normal call")
          }
        
      

Playground

Les Structs

En Go comme en Python, on peut définir ses propres types. En Python on va définir des classes tandis qu'on va parler de structs en Go. Les concepts sont similaires mais ils comportent certaines différences :

  • Pas d'héritage
  • Pas de fonctions "spéciales" comme __init__ ou de surcharge d'opérateur
  • La définition des "méthodes" ne fait pas partie de la définition de la struct

Exemple de struct

        
          type Person struct {
            Name string
            Surname string
            Age int
          }
        
      

⚠️ Les champs qui commencent avec une minuscule ne seront pas exportés et donc ne seront pas modifiables par un autre package.

Playground

Définir des méthodes

En Python, on définit les méthodes d'une classe à l'intérieur de celle-ci. Typiquement avec un argument : self.

En Go, on définit les méthodes où on veut au sein du même package et on définit un receiver.

        
          // p est notre receiver
          func (p Person) Greet() {
            fmt.Printf("Hello %s %s", p.Name, p.Surname)
          }
        
      

Playground

Autre exemple

        
          func (p Person) AgeUp() {
            p.Age = p.Age + 1
          }

          func main() {
            person := Person{
              Age: 31,
            }

            person.AgeUp()
            fmt.Println(person.Age)
          }
        
      

À votre avis quelle sera la sortie de ce programme ?

  • 31
  • 32

Playground

Valeur ou pointeur ?

        
          func (p Person) Greet() {
           fmt.Printf("Hello %s %s", p.Name, p.Surname)
          }
        
      

et :

        
          func (p *Person) Greet() {
            fmt.Printf("Hello %s %s", p.Name, p.Surname)
          }
        
      

Quelques règles pour choisir

Valeur (copie):

  • Petite struct
  • Read-only

Pointeur (référence):

  • Grosse struct
  • Read-write

  • ✅ Dans le doute : Pointeur
  • ✅ On garde le même fonctionnement partout

Interfaces

En Go, on peut définir des interfaces qui sont des types abstraits respectant un certain contrat.

Ce concept est assez différent en Go de ce qui peut exister en Python ou même en Java à cause de ces propriétés :

  • Les interfaces définissent des "comportements" et pas des attributs
  • Une struct doit implémenter toutes les méthodes d'une interface pour remplir le contrat
  • ‼️ Les interfaces sont implémentées implicitement

Exemple d'interfaces et d'implémentation

        
          type Vehicle interface {
            Move(fuel int)
            Stop()
          }
        
      
        
          type Car struct{}

          func (c Car) Move(fuel int) {
           fmt.Println("moving car")
          }

          func (c Car) Stop() {
           fmt.Println("Stopping")
          }
        
      

Playground

Interfaces implicites

Le fait que les interfaces soient implémentées de manière implicite en Go permet une grande flexibilité.

Il est par exemple possible et même recommandé dans de nombreux cas de définir les interfaces au niveau du consommateur plutôt que du producteur.

Proverbe de Gopher

The bigger the interface, the weaker the abstraction.

La gestion des erreurs

⛔ Pas d'exceptions en Go

✅ Les erreurs sont traitées comme des valeurs

N'importe quel type peut être utilisé comme erreur. Le seul pré-requis est que le type implémente l'interface :

        
          type error interface {
            Error() string
          }
        
      

La ligne de code la plus présente dans l'écosystème Go

        
          if err != nil
        
      

Fonction typique en Go

        
          // Pseudo-code
          func myFunction() (string,error) {
            client, err := example.NewClient()
            if err != nil {
              return "", fmt.Errorf("creating client: %w",err)
            }
            result1, err := example.DoRequest1()
            if err != nil {
              return "", fmt.Errorf("making request: %w",err)
            }
            var response myStruct
            err := json.Unmarshal(result1, &response)
            if err != nil {
              return "", fmt.Errorf("unmarshaling response: %w",err)
            }
            return response.Field, nil
          }
        
      

Traiter des erreurs spécifiques

Si je veux retourner une erreur que la fonction appelante pourra facilement identifier, le mieux est de définir une "sentinel value".

        
          package main

          import (
            "errors"
            "fmt"
          )

          var ErrDataInvalid = errors.New("data invalid")

          func getData() (string, error) {
            response, err := example.MakeRequest()
            if err != nil {
             return "", err
            }
            if !isValid(response) {
             return "", ErrDataInvalid
            }
            return response
          }

          func main() {
            data, err := getData()
            if err != nil {
              if err == ErrDataInvalid {
                fmt.Println("invalid data")
                return
              } else {
                panic(err)
              }
            }
            fmt.Println(data)
          }
        
      

Types d'erreurs custom

Si on veut pouvoir faire ses propres types d'erreurs, il suffit d'implémenter l'interface Error mentionnée précédemment.

        
          type APIError struct {
            msg string
            errorCode int
          }

          func (e APIError) Error() string {
            return fmt.Sprintf("api error %s (%d)", e.msg, e.errorCode)
          }
        
      

Chaînage d'erreur

Puisqu'on n'a pas d'exceptions, il n'y a pas non plus de stack traces en Go.*

Mais on peut "wrapper" les erreurs pour avoir le maximum de contexte.

        
          err := bad()
          if err != nil {
            return fmt.Errorf("processing in inner: %w", err)
          }
        
      

Playground

* Techniquement si, mais uniquement dans le cas d'un panic

Retrouver une erreur chaînée par valeur

Chaîner les erreurs permet de rajouter du contexte, MAIS casse la comparaison pour trouver.

Heureusement on peut retrouver une erreur enfouie dans une chaîne :

        
          err = outerSpecificError()
          if err != nil {
            if errors.Is(err, ErrInvalidData) {
              fmt.Println("handled invalid data error gracefully")
            } else {
              fmt.Printf("unknown error: %s\n", err)
            }
          }
        
      
Playground

Retrouver une erreur chainée par type

On peut également le faire avec les types d'erreurs custom.

        
          err = outerSpecificError()
          if err != nil {
            if errors.As(err, &specificError) {
              fmt.Println("handled invalid data error gracefully")
            } else {
              fmt.Printf("unknown error: %s\n", err)
            }
          }
        
      
Playground

Composition plutôt qu'héritage

Le Go n'est pas un langage strictement OOP, il n'y a pas d'héritage*.

Pour remplacer les cas ou l'héritage est utile, on préférera utiliser la composition.

        
          type Person struct {
            Name string
            Age int
          }

          type Employee struct {
            Person Person
            Salary int
          }

          type Contractor struct {
            Person Person
            DailyRate uint64
          }
        
      

* Techniquement le "struct embedding" peut y être assimilé mais il ne permet que des cas très simples et il faut prendre des précautions pour le manier.

Tester son code

Go offre des fonctionnalités de test dans sa bibliothèque standard. Admettons que l'on veuille tester cette fonction :

        
          // example/sum.go
          package example

          func Sum(a, b int) int {
            return a + b
          }
        
      

Tester les fonctions exportées

Pour tester cette fonction on peut créer un fichier suffixé de _test.go.

        
          // example/sum_test.go
          // On peut suffixer le nom du package de _test
          // Dans ce cas on n'a accès qu'aux fonctions et
          // structs publiques
          package example_test

          import (
            "playground/example"
            "testing"
          )

          func TestSum(t *testing.T) {
            result := example.Sum(3, 4)
            expected := 7
            if result != expected {
              t.Errorf("sum failed, expected %d but got %d", result, expected)
            }
          }
        
      

Tester les fonctions internes

Il faudra utiliser le même nom de package.

        
          // example/sum_test.go
          // Sans le _test dans le nom du package
          // notre test est considéré faisant partie du package
          package example

          import (
            "testing"
          )

          func TestSum(t *testing.T) {
            result := sum(3, 4) // La fonction non exportée reste testable
            expected := 8
            if result != expected {
              t.Errorf("sum failed, expected %d but got %d", result, expected)
            }
          }
        
      

Asynchrone

Asynchrone

Go a été pensé pour permettre d'écrire facilement des applications dans des contextes avec une concurrency très forte, comme des services web ou réseau.

C'est donc un langage qui par construction est très puissant dans les contextes I/O bound.

Les Goroutines

Faire du code asynchrone est trivial au premier abord grâce au mot-clé go.

        
          package main

          import "fmt"

          func func1() {
            fmt.Println("Hello from func1")
          }

          func main() {
            fmt.Println("Start func1")
            go func1()
            fmt.Println("End func1 ?")
          }
        
      

Lorsque la goroutine principale se termine, toutes les goroutines "enfant" sont terminées instantanément.

Playground

Se synchroniser grâce aux wait groups

Évidemment on veut souvent contrôler de nos goroutines. Et pour ça on a plusieurs options, comme par exemple les wait groups.

        
          func heavyTask(id int) {
            fmt.Printf("Processing task %d\n", id)
          }

          func main() {
            var wg sync.WaitGroup

            for i := range 5 {
              wg.Go(func() { heavyTask(i) })
            }
            wg.Wait()
          }
        
      

Playground

Se synchroniser grâce aux channels

On peut également se synchroniser grâce aux channels qui sont une primitive de la concurrency en Go. Là où dans d'autres langages on va principalement communiquer en partageant de la mémoire, en Go on va surtout partager en communiquant via ces channels.

        
          func enqeueTasks(queue chan<- Task) {
            for i := range 100 {
              fmt.Printf("Enqueueing task %d\n", i)
              queue <- Task{ID: i}
            }
            close(queue)
          }

          func taskWorker(queue <-chan Task) {
            for task := range queue {
              fmt.Printf("Task %d processed\n", task.ID)
            }
          }
        
      

Playground

Le mot clé select

Pour exécuter une branche de code en fonction de quel channel répond en premier, on peut utiliser le mot clé select.

        
          c := make(chan string)
          ctxWithTimeout, cancel := context.WithTimeout(context.Background(), 2*time.Second)
          go sendMsg(c)
          defer cancel()
          select {
          case msg := <-c:
            fmt.Println(msg)
          case <-ctxWithTimeout.Done():
            fmt.Println("timeout")
          }
        
      

Playground

Partager de la mémoire mais mal

Bien que ça ne soit pas la façon de faire principale en Go, on peut quand même partager de la mémoire entre les goroutines. Mais il faut faire attention lorsqu'on le fait.

Si on fait ça de façon naïve :

        
          var m = map[string]int{"toto": 1}

          func Read() {
            for {
              fmt.Println(m["toto"])
            }
          }

          func Write() {
            for {
              n := rand.Intn(10)
              m["toto"] = n
            }
          }
        
      

Playground

Partager de la mémoire comme il faut

Il faut introduire un sync.Mutex qui permet de Lock en lecture/écriture.

        
          type SafeMap struct {
            sync.Mutex
            m map[string]int
          }

          var safeMap = SafeMap{m: make(map[string]int)}

          func Read() {
            for {
              safeMap.Lock()
              fmt.Println(safeMap.m["toto"])
              safeMap.Unlock()
            }
          }

          func Write() {
            for {
              n := rand.Intn(10)
              safeMap.Lock()
              safeMap.m["toto"] = n
              safeMap.Unlock()
            }
          }
        
      

Playground

Serveurs web

Écrire un serveur http

        
          package main

          import (
            "fmt"
            "log/slog"
            "net/http"
          )

          func healthzHandler(w http.ResponseWriter, r *http.Request) {
            fmt.Fprint(w, "ok")
          }

          func main() {
            const addr = "0.0.0.0:8080"
            http.HandleFunc("/healthz", healthzHandler)
            slog.Info("Starting server", slog.String("addr", addr))
            if err := http.ListenAndServe(addr, nil); err != nil {
              slog.Error("serving requests:")
            }
          }
        
      

Écrire un middleware

On peut facilement écrire des middlewares en restant dans la bibliothèque standard.

        
          func LoggingMiddleware(next http.Handler) http.Handler {
            return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              received := time.Now()
              next.ServeHTTP(w, r)
              handled := time.Now()
              duration := handled.Sub(received)
              slog.Info("Request handled",
              slog.Time("start", received),
              slog.Time("end", handled),
              slog.Duration("duration", duration),
              )
            })
          }
        
      

Appeler un middleware

        
          http.Handle("/logged", LoggingMiddleware(http.HandlerFunc(healthzHandler)))
        
      

Grouper des routes

On peut facilement grouper des routes en créant un ServeMux et en pensant à supprimer le préfixe des requêtes.

        
          usersGroup := http.NewServeMux()
          usersGroup.Handle("/hello", http.HandlerFunc(helloUser))

          http.Handle("/users/", http.StripPrefix("/users", usersGroup))
        
      

Contexte

Sorti de son contexte

Le package contexte va être très utile dans des cas d'usages de clients ou de serveurs.

Il permet de partager des informations dans une suite d'appels de fonctions.

        
          func DoSomething(ctx context.Context, arg Arg) error {
            client.Call(ctx,arg)
            otherclient.OtherCall(ctx,arg)
          }
        
      

Hiérarchie des contextes

En général on va créer des contextes à partir d'un contexte parent. Par exemple :

  • L'invocation d'une commande dans un outil en CLI
  • Une requête dans le cas d'un serveur web

Lorsqu'un contexte est annulé, tous ses enfants le sont également, ce qui permet de facilement partager l'information qu'une action peut être abandonnée.

Timeouts via contexte

On peut s'en servir pour gérer des timeouts.

        
          func slowOperationWithTimeout(ctx context.Context) (Result, error) {
            ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
            defer cancel() // releases resources if slowOperation completes before timeout elapses
            return slowOperation(ctx)
          }
        
      

Passage d'informations via contexte

On peut aussi s'en servir pour passer des informations liées à une requête dans le cas d'un serveur.

        
          type traceIDkey string

          var key = traceIDkey("traceID")

          func TraceIDMiddleware(ctx context.Context, arg Arg) error {
            traceID := generateTraceID()
            ctx := context.WithValue(ctx, key, traceID)
          }

          func processRequest(ctx context.Context) {
            err := doSomething()
            if err != nil {
              if traceID := ctx.Value(key); v != nil {
              tracing.Send(string(traceID))
              return
              }
            }
            fmt.Println("request processed")
          }
        
      

Conclusion

Nous venons de faire un petit tour d'horizon (loin d'être exhaustif) de ce que le langage Go a à offrir.

Parmi les sujets importants qui n'ont pas été évoqués, on retrouve :

  • Les Generics
  • Le package reflect
  • Le fonctionnement du GC
  • Le race detector
  • CGO
  • WASM

Je vous propose quelques pistes si vous voulez creuser quelques cas d'usages particulièrement adaptés à ce langage.

Développement autour de Kubernetes

Kubernetes est développé en Go et il y a donc des bibliothèques clientes très utilisées et très matures. Celles qui sont utilisées dans kubectl d'ailleurs.

C'est donc naturellement aussi un très bon choix pour le développement de controller, il existe même des frameworks comme kubebuilder pour le faire "facilement".

Outils en CLI

La nature des outputs du build en Go (un binaire, compilable sur plusieurs plateformes) se prête très bien à des outils en CLI.

Pour ça il existe la bibliothèque Cobra utilisée notamment dans :

  • kubectl
  • Github CLI
  • Docker

Pour aller plus loin

Si apprendre le Go vous intéresse, voici quelques idées de projets pour explorer le langage et sa bibliothèque standard :

  • Réécrire ses propres commandes GNU (cat, ls, grep etc...)
  • Son propre Redis simplifié
  • Advent of code

Quelques idées de projets intéressants à faire ensuite

Références et ressources

Amusez-vous !