Skip to content

Gestion des erreurs

En Go, il existe trois niveaux d'exceptions :

  • error : erreur de flux normale, doit être traitée ; si elle est ignorée, le programme ne plantera pas
  • panic : problème très grave, le programme doit se terminer immédiatement après avoir traité le problème
  • fatal : problème très critique, le programme doit se terminer immédiatement

Pour être précis, le langage Go n'a pas d'exceptions ; elles sont représentées par des erreurs. De même, Go n'a pas d'instructions try-catch-finally. Les fondateurs de Go espéraient que les erreurs seraient contrôlables ; ils ne voulaient pas que chaque opération nécessite d'imbriquer des try-catch. Ainsi, dans la plupart des cas, les erreurs sont retournées comme valeurs de retour de fonctions. Exemple :

go
func main() {
  // Ouvrir un fichier
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

L'intention de ce code est claire : ouvrir un fichier nommé README.txt. Si l'ouverture échoue, la fonction retourne une erreur et affiche le message d'erreur. Si l'erreur est nil, l'ouverture a réussi et le nom du fichier est affiché.

Cela semble plus concis que try-catch, mais s'il y a beaucoup d'appels de fonctions, des instructions if err != nil apparaîtront partout. Par exemple, voici une démo de calcul de hachage de fichier, où if err != nil apparaît trois fois dans ce court extrait :

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

C'est pourquoi la gestion des erreurs est le point le plus critiqué de Go. if err != nil occupe une part considérable du code source de Go. Rust retourne également des valeurs d'erreur, mais personne ne le critique pour cela, car il résout ce problème avec du sucre syntaxique. Comparé à Rust, Go a très peu de sucre syntaxique, pour ne pas dire aucun.

Cependant, il faut voir les choses de manière dialectique : tout a des avantages et des inconvénients. La gestion des erreurs en Go présente plusieurs avantages :

  • Faible charge mentale : traiter les erreurs ou les retourner
  • Lisibilité : comme la méthode de traitement est très simple, le code est facile à comprendre dans la plupart des cas
  • Facilité de débogage : chaque erreur est produite par la valeur de retour d'un appel de fonction, on peut remonter couche par couche ; il est rare qu'une erreur apparaisse soudainement sans qu'on sache d'où elle vient

Mais il y a aussi des inconvénients :

  • Pas d'informations de pile d'appels dans les erreurs (nécessite un paquet tiers ou une encapsulation personnelle)
  • Laid, beaucoup de code répétitif (selon les préférences personnelles)
  • Les erreurs personnalisées sont déclarées avec var, ce sont des variables et non des constantes (ce qui n'est vraiment pas idéal)
  • Problème d'ombrage de variables

Les propositions et discussions sur la gestion des erreurs en Go n'ont jamais cessé depuis la création de Go. Il y a une blague : si vous pouvez accepter la gestion des erreurs de Go, alors vous êtes un Gopher qualifié.

TIP

Voici deux articles de l'équipe Go sur la gestion des erreurs, pour les intéressés :

error

error est une erreur de flux normale. Son apparition est acceptable. Dans la plupart des cas, elle doit être traitée, bien qu'on puisse l'ignorer. La gravité d'une error ne suffit pas à arrêter tout le programme. error est elle-même une interface prédéfinie, qui n'a qu'une seule méthode Error(). La valeur de retour de cette méthode est une chaîne, utilisée pour afficher le message d'erreur.

go
type error interface {
   Error() string
}

error a connu des changements majeurs dans son histoire. Dans la version 1.13, l'équipe Go a introduit les erreurs en chaîne et a fourni un mécanisme de vérification des erreurs plus complet, qui sera présenté ci-dessous.

Création

Il existe plusieurs méthodes pour créer une error. La première consiste à utiliser la fonction New du paquet errors :

go
err := errors.New("c'est une erreur")

La deuxième consiste à utiliser la fonction Errorf du paquet fmt, qui permet d'obtenir une error avec des paramètres formatés :

go
err := fmt.Errorf("c'est une erreur avec %d paramètre formaté", 1)

Voici un exemple complet :

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("doit être un entier positif")
   }
   return i + j, nil
}

Dans la plupart des cas, pour une meilleure maintenabilité, on ne crée pas d'error temporairement, mais on utilise des error courantes comme variables globales. Exemple extrait du fichier os/errors.go :

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

On peut voir qu'elles sont toutes définies avec var.

Erreurs personnalisées

En implémentant la méthode Error(), on peut facilement personnaliser une error. Par exemple, errorString dans le paquet errors est une implémentation simple :

go
func New(text string) error {
   return &errorString{text}
}

// structure errorString
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

Comme l'implémentation de errorString est trop simple et n'a pas assez de capacité d'expression, de nombreuses bibliothèques open source, y compris les bibliothèques officielles, choisissent de personnaliser des error pour répondre à différents besoins d'erreur.

Transmission

Dans certaines situations, la fonction appelée par l'appelant retourne une erreur, mais l'appelant n'est pas responsable du traitement de l'erreur, alors il retourne également l'erreur comme valeur de retour, la lançant à l'appelant de niveau supérieur. Ce processus s'appelle la transmission. Pendant la transmission, les erreurs peuvent être emballées couche par couche. Lorsque l'appelant de niveau supérieur veut juger du type d'erreur pour effectuer un traitement différent, il peut ne pas pouvoir identifier la catégorie d'erreur ou la juger incorrectement. Les erreurs en chaîne sont apparues pour résoudre ce problème.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrapError implémente également l'interface error, et ajoute une méthode Unwrap qui retourne une référence à l'erreur originale interne. Sous des emballages successifs, une chaîne d'erreurs se forme. En suivant la chaîne, on peut facilement trouver l'erreur originale. Comme cette structure n'est pas exposée, on ne peut utiliser que la fonction fmt.Errorf pour la créer :

go
err := errors.New("c'est une erreur originale")
wrapErr := fmt.Errorf("erreur, %w", err)

Lors de l'utilisation, il faut utiliser le verbe de formatage %w, et le paramètre ne peut être qu'une seule error valide.

Traitement

La dernière étape du traitement des erreurs est de savoir comment traiter et vérifier les erreurs. Le paquet errors fournit plusieurs fonctions pratiques pour traiter les erreurs :

go
func Unwrap(err error) error

La fonction errors.Unwrap() sert à déballer une chaîne d'erreurs. Son implémentation interne est simple :

go
func Unwrap(err error) error {
   u, ok := err.(interface { // assertion de type, vérifie si la méthode est implémentée
      Unwrap() error
   })
   if !ok { // si non implémenté, c'est une error de base
      return nil
   }
   return u.Unwrap() // sinon appelle Unwrap
}

Après déballage, elle retourne l'erreur enveloppée par la chaîne d'erreurs actuelle. L'erreur enveloppée peut toujours être une chaîne d'erreurs. Si on veut trouver la valeur ou le type correspondant dans la chaîne d'erreurs, on peut effectuer une recherche récursive, mais la bibliothèque standard a déjà fourni des fonctions similaires.

go
func Is(err, target error) bool

La fonction errors.Is sert à juger si une chaîne d'erreurs contient une erreur spécifiée. Exemple :

go
var originalErr = errors.New("this is an error")

func wrap1() error { // emballe l'erreur originale
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // erreur originale
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // si on utilise if err == originalErr, ce sera false
      fmt.Println("original")
   }
}

Ainsi, lors du jugement d'erreurs, il ne faut pas utiliser l'opérateur ==, mais errors.Is().

go
func As(err error, target any) bool

La fonction errors.As() sert à trouver la première erreur de type correspondant dans la chaîne d'erreurs, et à assigner la valeur à target passé. Dans certains cas, il faut convertir une erreur de type error en type d'implémentation d'erreur concret pour obtenir plus de détails sur l'erreur. Utiliser une assertion de type sur une chaîne d'erreurs est inefficace, car l'erreur originale est enveloppée dans une structure, c'est pourquoi la fonction As est nécessaire. Exemple :

go
type TimeError struct { // error personnalisée
   Msg  string
   Time time.Time // enregistre l'heure de l'erreur
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // emballe l'erreur originale
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // erreur originale
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // Vérifie si la chaîne d'erreurs contient une erreur de type *TimeError
   if errors.As(err, &myerr) { // affiche l'heure de TimeError
      fmt.Println("original", myerr.Time)
   }
}

target doit être un pointeur vers error. Comme lors de la création de la structure, un pointeur de structure est retourné, error est en fait de type *TimeError, donc target doit être de type **TimeError.

Cependant, le paquet errors officiel n'est pas suffisant, car il n'a pas d'informations de pile d'appels et ne permet pas de localiser. Il est généralement recommandé d'utiliser un autre paquet amélioré officiel :

github.com/pkg/errors

Exemple :

go
import (
  "fmt"
  "github.com/pkg/errors"
)

func Do() error {
  return errors.New("error")
}

func main() {
  if err := Do(); err != nil {
    fmt.Printf("%+v", err)
  }
}

Sortie :

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

Avec un affichage formaté, on peut voir les informations de pile d'appels. Par défaut, la pile n'est pas affichée. Ce paquet est une version améliorée du paquet errors de la bibliothèque standard, tous deux écrits par l'équipe officielle. On ne sait pas pourquoi il n'a pas été intégré dans la bibliothèque standard.

panic

panic, traduit par panique, représente un problème de programme très grave. Le programme doit s'arrêter immédiatement pour traiter ce problème, sinon il s'arrête et affiche les informations de pile d'appels. panic est la forme d'exception d'exécution en Go, apparaissant généralement dans des opérations dangereuses, principalement pour limiter les dégâts et éviter des conséquences plus graves. Cependant, avant de se terminer, panic effectue le travail de nettoyage du programme, et panic peut également être récupéré pour permettre au programme de continuer à s'exécuter.

Voici un exemple d'écriture dans une map nil, qui déclenchera certainement un panic :

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

Lorsqu'il y a plusieurs goroutines dans le programme, si l'une d'elles déclenche un panic et qu'il n'est pas récupéré, tout le programme plantera.

Création

Créer explicitement un panic est très simple : il suffit d'utiliser la fonction intégrée panic, de signature :

go
func panic(v any)

La fonction panic accepte un paramètre v de type any. Lors de l'affichage des informations de pile d'erreurs, v est également affiché. Exemple d'utilisation :

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("paramètres de connexion de données illégaux")
  }
    // ...autre logique
}

Lorsque l'initialisation de la connexion à la base de données échoue, le programme ne devrait pas démarrer, car sans base de données, le programme n'a aucun sens. Donc ici, il faut lancer un panic :

panic: paramètres de connexion de données illégaux

Nettoyage

Avant que le programme ne se termine à cause d'un panic, il effectue un travail de nettoyage, comme l'exécution d'instructions defer :

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

Sortie :

C
B
A
panic: panic

Les instructions defer des fonctions en amont sont également exécutées :

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

Sortie :

C
2
1
B
A
panic: panic

On peut également imbriquer des panic dans des defer. Voici un exemple plus complexe :

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

L'ordre d'exécution des panic imbriqués dans defer reste cohérent. Lorsqu'un panic se produit, la logique suivante ne peut pas être exécutée :

C
2
1
A
panic: panicB
        panic: panicA

En résumé, lorsqu'un panic se produit, la fonction actuelle se termine immédiatement et exécute le travail de nettoyage de la fonction actuelle, comme defer, puis remonte couche par couche. Les fonctions en amont effectuent également un travail de nettoyage jusqu'à l'arrêt du programme.

Lorsqu'une goroutine enfant déclenche un panic, le travail de nettoyage de la goroutine actuelle n'est pas déclenché. Si le panic n'est pas récupéré jusqu'à la fin de la goroutine enfant, le programme s'arrête directement :

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // La goroutine parent attend bloquée que la goroutine enfant se termine
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

Sortie :

C
2
1
panic: panicB

On peut voir qu'aucune instruction defer dans demo() n'a été exécutée, et le programme s'est directement terminé. Il faut noter que sans waitGroup pour bloquer la goroutine parent, la vitesse d'exécution de demo() pourrait être plus rapide que celle de la goroutine enfant, et le résultat de sortie deviendrait très trompeur. Modifions légèrement le code :

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // Le travail de nettoyage de la goroutine parent prend 20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // La goroutine enfant doit exécuter une logique, prend 1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

Sortie :

C
D
2
1
panic: panicB

Dans cet exemple, lorsque la goroutine enfant déclenche un panic, la goroutine parent a déjà terminé l'exécution de la fonction et est entrée dans le travail de nettoyage. Lors de l'exécution du dernier defer, elle rencontre le panic de la goroutine enfant, donc le programme se termine directement.

Récupération

Lorsqu'un panic se produit, l'utilisation de la fonction intégrée recover() permet de le traiter à temps et de garantir que le programme continue à s'exécuter. Elle doit être exécutée dans une instruction defer. Exemple d'utilisation :

go
func main() {
   dangerOp()
   fmt.Println("programme terminé normalement")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic récupéré")
      }
   }()
   panic("panic survenu")
}

L'appelant ne sait pas du tout qu'un panic s'est produit à l'intérieur de la fonction dangerOp(). Le programme exécute la logique restante et se termine normalement. La sortie est :

panic survenu
panic récupéré
programme terminé normalement

Mais en fait, l'utilisation de recover() comporte de nombreux pièges implicites. Par exemple, utiliser recover dans une fermeture à l'intérieur de defer :

go
func main() {
  dangerOp()
  fmt.Println("programme terminé normalement")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic récupéré")
      }
    }()
  }()
  panic("panic survenu")
}

Une fonction de fermeture peut être considérée comme un appel de fonction. panic est transmis vers le haut et non vers le bas, donc naturellement la fonction de fermeture ne peut pas récupérer le panic. La sortie est :

panic: panic survenu

De plus, il y a une situation très extrême : le paramètre de panic() est nil.

go
func main() {
   dangerOp()
   fmt.Println("programme terminé normalement")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic récupéré")
      }
   }()
   panic(nil)
}

Dans ce cas, panic est effectivement récupéré, mais aucune information d'erreur n'est affichée.

Sortie :

programme terminé normalement

En résumé, la fonction recover a plusieurs points d'attention :

  1. Doit être utilisée dans defer
  2. Plusieurs utilisations ne permettent qu'à une seule de récupérer le panic
  3. recover dans une fermeture ne récupère aucun panic des fonctions externes
  4. Le paramètre de panic ne doit pas être nil

fatal

fatal est un problème extrêmement grave. Lorsqu'un fatal se produit, le programme doit s'arrêter immédiatement sans exécuter aucun travail de nettoyage. En général, on appelle la fonction Exit du paquet os pour quitter le programme :

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("logique normale")
}

Sortie :

fatal

Les problèmes de niveau fatal sont rarement déclenchés explicitement ; dans la plupart des cas, ils sont déclenchés passivement.

Golang by www.golangdev.cn edit