Langage de programmation Go : un didacticiel d'introduction à Golang

Publié: 2022-03-11

Qu'est-ce que le langage de programmation Go ?

Le langage de programmation Go relativement nouveau se situe parfaitement au milieu du paysage, offrant de nombreuses fonctionnalités intéressantes et omettant délibérément de nombreuses mauvaises. Il se compile rapidement, s'exécute rapidement, inclut un environnement d'exécution et une récupération de place, possède un système de type statique simple et des interfaces dynamiques, ainsi qu'une excellente bibliothèque standard. C'est pourquoi tant de développeurs souhaitent apprendre la programmation Go.

Tutoriel Golang : illustration du logo

Allez et OOP

La POO est l'une de ces fonctionnalités que Go omet délibérément. Il n'a pas de sous-classes, et donc il n'y a pas de diamants d'héritage ou de super appels ou de méthodes virtuelles pour vous faire trébucher. Pourtant, de nombreuses parties utiles de la POO sont disponibles d'autres manières.

Les *Mixins* sont disponibles en incorporant des structures de manière anonyme, permettant à leurs méthodes d'être appelées directement sur la structure contenante (voir incorporation). Promouvoir des méthodes de cette manière s'appelle *forwarding*, et ce n'est pas la même chose que sous-classer : la méthode sera toujours invoquée sur la structure interne intégrée.

L'incorporation n'implique pas non plus le polymorphisme. Bien que `A` puisse avoir un `B`, cela ne signifie pas que c'est un `B` - les fonctions qui prennent un `B` ne prendront pas un `A` à la place. Pour cela, nous avons besoin d' interfaces , que nous rencontrerons brièvement plus tard.

Pendant ce temps, Golang adopte une position forte sur les fonctionnalités qui peuvent entraîner confusion et bugs. Il omet les idiomes POO tels que l'héritage et le polymorphisme, au profit de la composition et des interfaces simples. Il minimise la gestion des exceptions au profit d'erreurs explicites dans les valeurs de retour. Il existe exactement une manière correcte de disposer le code Go, appliquée par l'outil gofmt . Etc.

Pourquoi Apprendre Golang ?

Go est également un excellent langage pour écrire des programmes concurrents : des programmes avec de nombreuses parties s'exécutant indépendamment. Un exemple évident est un serveur Web : chaque requête s'exécute séparément, mais les requêtes doivent souvent partager des ressources telles que des sessions, des caches ou des files d'attente de notification. Cela signifie que les programmeurs Go qualifiés doivent gérer l'accès simultané à ces ressources.

Bien que Golang dispose d'un excellent ensemble de fonctionnalités de bas niveau pour gérer la concurrence, leur utilisation directe peut devenir compliquée. Dans de nombreux cas, une poignée d'abstractions réutilisables sur ces mécanismes de bas niveau rend la vie beaucoup plus facile.

Dans le didacticiel de programmation Go d'aujourd'hui, nous allons examiner une de ces abstractions : un wrapper qui peut transformer n'importe quelle structure de données en un service transactionnel . Nous utiliserons un type de Fund comme exemple - un simple magasin pour le financement restant de notre startup, où nous pouvons vérifier le solde et effectuer des retraits.

Pour démontrer cela dans la pratique, nous allons construire le service par petites étapes, en faisant du désordre en cours de route, puis en le nettoyant à nouveau. Au fur et à mesure que nous progressons dans notre didacticiel Go, nous rencontrerons de nombreuses fonctionnalités intéressantes du langage Go, notamment :

  • Types de structure et méthodes
  • Tests unitaires et benchmarks
  • Goroutines et canaux
  • Interfaces et typage dynamique

Créer un fonds simple

Écrivons du code pour suivre le financement de notre startup. Le fonds commence avec un solde donné, et l'argent ne peut être retiré (nous déterminerons les revenus plus tard).

Ce graphique illustre un exemple simple de goroutine utilisant le langage de programmation Go.

Go n'est délibérément pas un langage orienté objet : il n'y a pas de classes, d'objets ou d'héritage. Au lieu de cela, nous allons déclarer un type de structure appelé Fund , avec une fonction simple pour créer de nouvelles structures de fonds et deux méthodes publiques.

fonds.go

 package funding type Fund struct { // balance is unexported (private), because it's lowercase balance int } // A regular function returning a pointer to a fund func NewFund(initialBalance int) *Fund { // We can return a pointer to a new struct without worrying about // whether it's on the stack or heap: Go figures that out for us. return &Fund{ balance: initialBalance, } } // Methods start with a *receiver*, in this case a Fund pointer func (f *Fund) Balance() int { return f.balance } func (f *Fund) Withdraw(amount int) { f.balance -= amount }

Tester avec des repères

Ensuite, nous avons besoin d'un moyen de tester Fund . Plutôt que d'écrire un programme séparé, nous utiliserons le package de test de Go, qui fournit un cadre pour les tests unitaires et les benchmarks. La logique simple de notre Fund ne vaut pas vraiment la peine d'écrire des tests unitaires, mais puisque nous parlerons beaucoup plus tard de l'accès simultané au fonds, écrire un indice de référence a du sens.

Les benchmarks sont comme des tests unitaires, mais incluent une boucle qui exécute le même code plusieurs fois (dans notre cas, fund.Withdraw(1) ). Cela permet au framework de chronométrer la durée de chaque itération, en calculant la moyenne des différences transitoires des recherches de disque, des échecs de cache, de la planification des processus et d'autres facteurs imprévisibles.

Le framework de test veut que chaque benchmark s'exécute pendant au moins 1 seconde (par défaut). Pour s'en assurer, il appellera le benchmark plusieurs fois, en transmettant à chaque fois une valeur croissante de "nombre d'itérations" (le champ bN ), jusqu'à ce que l'exécution prenne au moins une seconde.

Pour l'instant, notre indice de référence déposera simplement de l'argent, puis le retirera un dollar à la fois.

fund_test.go

 package funding import "testing" func BenchmarkFund(b *testing.B) { // Add as many dollars as we have iterations this run fund := NewFund(bN) // Burn through them one at a time until they are all gone for i := 0; i < bN; i++ { fund.Withdraw(1) } if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Maintenant, exécutons-le :

 $ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s

Cela s'est bien passé. Nous avons exécuté deux milliards (!) d'itérations, et la vérification finale de l'équilibre était correcte. Nous pouvons ignorer l'avertissement "aucun test à exécuter", qui fait référence aux tests unitaires que nous n'avons pas écrits (dans les exemples de programmation Go ultérieurs de ce didacticiel, l'avertissement est supprimé).

Accès simultané dans Go

Rendons maintenant le benchmark concurrent, pour modéliser différents utilisateurs effectuant des retraits en même temps. Pour ce faire, nous allons générer dix goroutines et demander à chacune d'elles de retirer un dixième de l'argent.

Comment structurer plusieurs goroutines concurrentes dans le langage Go ?

Les goroutines sont la pierre angulaire de la simultanéité dans le langage Go. Ce sont des threads verts - des threads légers gérés par le runtime Go, pas par le système d'exploitation. Cela signifie que vous pouvez en exécuter des milliers (ou des millions) sans frais généraux importants. Les goroutines sont générées avec le mot-clé go et commencent toujours par une fonction (ou un appel de méthode) :

 // Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()

Souvent, nous voulons générer une courte fonction unique avec seulement quelques lignes de code. Dans ce cas, nous pouvons utiliser une fermeture au lieu d'un nom de fonction :

 go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()

Une fois que toutes nos goroutines sont apparues, nous avons besoin d'un moyen d'attendre qu'elles se terminent. Nous pourrions en créer un nous-mêmes en utilisant des canaux , mais nous ne les avons pas encore rencontrés, ce serait donc sauter de l'avant.

Pour l'instant, nous pouvons simplement utiliser le type WaitGroup dans la bibliothèque standard de Go, qui existe précisément dans ce but. Nous allons en créer un (appelé « wg ») et appeler wg.Add(1) avant de générer chaque travailleur, pour garder une trace de leur nombre. Ensuite, les travailleurs feront rapport en utilisant wg.Done() . Pendant ce temps, dans la goroutine principale, nous pouvons simplement dire wg.Wait() pour bloquer jusqu'à ce que chaque travailleur ait terminé.

À l'intérieur des goroutines de travail dans notre prochain exemple, nous utiliserons defer pour appeler wg.Done() .

defer prend un appel de fonction (ou de méthode) et l'exécute immédiatement avant le retour de la fonction actuelle, une fois que tout le reste est fait. C'est pratique pour le nettoyage :

 func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()

De cette façon, nous pouvons facilement faire correspondre le Unlock avec son Lock , pour plus de lisibilité. Plus important encore, une fonction différée s'exécutera même s'il y a une panique dans la fonction principale (quelque chose que nous pourrions gérer via try-finally dans d'autres langages).

Enfin, les fonctions différées s'exécuteront dans l'ordre inverse dans lequel elles ont été appelées, ce qui signifie que nous pouvons bien faire un nettoyage imbriqué (similaire à l'idiome C des goto s et label s imbriqués, mais beaucoup plus propre):

 func() { db.Connect() defer db.Disconnect() // If Begin panics, only db.Disconnect() will execute transaction.Begin() defer transaction.Close() // From here on, transaction.Close() will run first, // and then db.Disconnect() // ... }()

OK, donc avec tout ce qui a été dit, voici la nouvelle version :

fund_test.go

 package funding import ( "sync" "testing" ) const WORKERS = 10 func BenchmarkWithdrawals(b *testing.B) { // Skip N = 1 if bN < WORKERS { return } // Add as many dollars as we have iterations this run fund := NewFund(bN) // Casually assume bN divides cleanly dollarsPerFounder := bN / WORKERS // WaitGroup structs don't need to be initialized // (their "zero value" is ready to use). // So, we just declare one and then use it. var wg sync.WaitGroup for i := 0; i < WORKERS; i++ { // Let the waitgroup know we're adding a goroutine wg.Add(1) // Spawn off a founder worker, as a closure go func() { // Mark this worker done when the function finishes defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { fund.Withdraw(1) } }() // Remember to call the closure! } // Wait for all the workers to finish wg.Wait() if fund.Balance() != 0 { b.Error("Balance wasn't zero:", fund.Balance()) } }

Nous pouvons prédire ce qui va se passer ici. Les travailleurs exécuteront tous Withdraw les uns sur les autres. À l'intérieur, f.balance -= amount lira le solde, en soustraira un, puis le réécrira. Mais parfois, deux travailleurs ou plus liront le même solde et feront la même soustraction, et nous nous retrouverons avec le mauvais total. Droit?

 $ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s

Non, ça passe quand même. Que s'est-il passé ici?

N'oubliez pas que les goroutines sont des threads verts - ils sont gérés par le runtime Go, pas par le système d'exploitation. Le runtime planifie des goroutines sur le nombre de threads du système d'exploitation dont il dispose. Au moment de la rédaction de ce didacticiel sur le langage Go, Go n'essaie pas de deviner combien de threads de système d'exploitation il doit utiliser, et si nous en voulons plus d'un, nous devons le dire. Enfin, le runtime actuel ne devance pas les goroutines - une goroutine continuera à s'exécuter jusqu'à ce qu'elle fasse quelque chose qui suggère qu'elle est prête pour une pause (comme interagir avec un canal).

Tout cela signifie que même si notre benchmark est désormais concurrent, il n'est pas parallèle . Un seul de nos travailleurs fonctionnera à la fois, et il fonctionnera jusqu'à ce que ce soit fait. Nous pouvons changer cela en disant à Go d'utiliser plus de threads, via la variable d'environnement GOMAXPROCS .

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s

C'est mieux. Maintenant, nous perdons évidemment certains de nos retraits, comme nous nous y attendions.

Dans cet exemple de programmation Go, le résultat de plusieurs goroutines parallèles n'est pas favorable.

Faites-en un serveur

À ce stade, nous avons différentes options. Nous pourrions ajouter un mutex explicite ou un verrou en lecture-écriture autour du fonds. Nous pourrions utiliser une comparaison et un échange avec un numéro de version. Nous pourrions tout mettre en œuvre et utiliser un schéma CRDT (peut-être en remplaçant le champ du balance par des listes de transactions pour chaque client et en calculant le solde à partir de celles-ci).

Mais nous ne ferons aucune de ces choses maintenant, parce qu'elles sont désordonnées ou effrayantes ou les deux. Au lieu de cela, nous déciderons qu'un fonds doit être un serveur . Qu'est-ce qu'un serveur ? C'est quelque chose à qui tu parles. Dans Go, les choses parlent via des canaux.

Les canaux sont le mécanisme de communication de base entre les goroutines. Les valeurs sont envoyées au canal (avec channel <- value ), et peuvent être reçues de l'autre côté (avec value = <- channel ). Les canaux sont "sûrs pour les goroutines", ce qui signifie que n'importe quel nombre de goroutines peut envoyer et recevoir d'eux en même temps.

Mise en mémoire tampon

La mise en mémoire tampon des canaux de communication peut être une optimisation des performances dans certaines circonstances, mais elle doit être utilisée avec beaucoup de prudence (et de benchmarking !).

Cependant, il existe des utilisations pour les canaux tamponnés qui ne concernent pas directement la communication.

Par exemple, un idiome de limitation commun crée un canal avec (par exemple) une taille de tampon "10", puis y envoie dix jetons immédiatement. N'importe quel nombre de goroutines ouvrières sont ensuite générées, et chacune reçoit un jeton du canal avant de commencer le travail, et le renvoie ensuite. Alors, quel que soit le nombre de travailleurs, seuls dix travailleront jamais en même temps.

Par défaut, les canaux Go sont sans tampon . Cela signifie que l'envoi d'une valeur à un canal bloquera jusqu'à ce qu'une autre goroutine soit prête à la recevoir immédiatement. Go prend également en charge les tailles de tampon fixes pour les canaux (en utilisant make(chan someType, bufferSize) ). Cependant, pour une utilisation normale, c'est généralement une mauvaise idée .

Imaginez un serveur Web pour notre fonds, où chaque demande effectue un retrait. Lorsque les choses sont très occupées, le FundServer ne pourra pas suivre le rythme et les demandes essayant d'envoyer à son canal de commande commenceront à se bloquer et à attendre. À ce stade, nous pouvons appliquer un nombre maximal de requêtes sur le serveur et renvoyer un code d'erreur sensible (comme un 503 Service Unavailable ) aux clients dépassant cette limite. C'est le meilleur comportement possible lorsque le serveur est surchargé.

L'ajout de tampon à nos canaux rendrait ce comportement moins déterministe. Nous pourrions facilement nous retrouver avec de longues files d'attente de commandes non traitées basées sur des informations que le client a vues beaucoup plus tôt (et peut-être pour des requêtes qui ont depuis expiré en amont). Il en va de même dans de nombreuses autres situations, comme l'application d'une contre-pression sur TCP lorsque le destinataire ne peut pas suivre l'expéditeur.

Dans tous les cas, pour notre exemple Go, nous nous en tiendrons au comportement par défaut sans tampon.

Nous utiliserons un canal pour envoyer des commandes à notre FundServer . Chaque travailleur de référence enverra des commandes au canal, mais seul le serveur les recevra.

Nous pourrions transformer directement notre type de fonds en une implémentation de serveur, mais ce serait désordonné - nous mélangerions la gestion de la concurrence et la logique métier. Au lieu de cela, nous laisserons le type de fonds exactement tel qu'il est et ferons de FundServer un wrapper distinct autour de lui.

Comme tout serveur, le wrapper aura une boucle principale dans laquelle il attend les commandes et répond à chacune à tour de rôle. Il y a encore un détail que nous devons aborder ici : Le type des commandes.

Un schéma du fonds utilisé comme serveur dans ce didacticiel de programmation Go.

Pointeurs

Nous aurions pu faire en sorte que notre canal de commandes prenne des *pointeurs* vers des commandes (`chan *TransactionCommand`). Pourquoi ne l'avons-nous pas fait ?

Passer des pointeurs entre les goroutines est risqué, car l'une ou l'autre des goroutines pourrait le modifier. C'est aussi souvent moins efficace, car l'autre goroutine peut s'exécuter sur un cœur de processeur différent (ce qui signifie plus d'invalidation du cache).

Dans la mesure du possible, préférez passer des valeurs simples.

Dans la section suivante ci-dessous, nous enverrons plusieurs commandes différentes, chacune avec son propre type de structure. Nous voulons que le canal de commandes du serveur accepte n'importe lequel d'entre eux. Dans un langage POO, nous pourrions le faire via le polymorphisme : faire en sorte que le canal prenne une superclasse, dont les types de commandes individuels étaient des sous-classes. En Go, nous utilisons plutôt des interfaces .

Une interface est un ensemble de signatures de méthode. Tout type qui implémente toutes ces méthodes peut être traité comme cette interface (sans être déclaré pour le faire). Pour notre première exécution, nos structures de commande n'exposeront aucune méthode, nous allons donc utiliser l'interface vide, interface{} . Puisqu'il n'y a pas d'exigences, toute valeur (y compris les valeurs primitives comme les entiers) satisfait l'interface vide. Ce n'est pas l'idéal – nous ne voulons accepter que les structures de commande – mais nous y reviendrons plus tard.

Pour l'instant, commençons par l'échafaudage de notre serveur Go :

serveur.go

 package funding type FundServer struct { Commands chan interface{} fund Fund } func NewFundServer(initialBalance int) *FundServer { server := &FundServer{ // make() creates builtins like channels, maps, and slices Commands: make(chan interface{}), fund: NewFund(initialBalance), } // Spawn off the server's main loop immediately go server.loop() return server } func (s *FundServer) loop() { // The built-in "range" clause can iterate over channels, // amongst other things for command := range s.Commands { // Handle the command } }

Ajoutons maintenant quelques types de structures Golang pour les commandes :

 type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }

Le WithdrawCommand contient juste le montant à retirer. Il n'y a pas de réponse. La BalanceCommand a une réponse, elle inclut donc un canal sur lequel l'envoyer. Cela garantit que les réponses iront toujours au bon endroit, même si notre fonds décide plus tard de répondre dans le désordre.

Nous pouvons maintenant écrire la boucle principale du serveur :

 func (s *FundServer) loop() { for command := range s.Commands { // command is just an interface{}, but we can check its real type switch command.(type) { case WithdrawCommand: // And then use a "type assertion" to convert it withdrawal := command.(WithdrawCommand) s.fund.Withdraw(withdrawal.Amount) case BalanceCommand: getBalance := command.(BalanceCommand) balance := s.fund.Balance() getBalance.Response <- balance default: panic(fmt.Sprintf("Unrecognized command: %v", command)) } } }

Hmm. C'est plutôt moche. Nous activons le type de commande, en utilisant des assertions de type, et éventuellement en plantant. Allons quand même de l'avant et mettons à jour le benchmark pour utiliser le serveur.

 func BenchmarkWithdrawals(b *testing.B) { // ... server := NewFundServer(bN) // ... // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { server.Commands <- WithdrawCommand{ Amount: 1 } } }() } // ... balanceResponseChan := make(chan int) server.Commands <- BalanceCommand{ Response: balanceResponseChan } balance := <- balanceResponseChan if balance != 0 { b.Error("Balance wasn't zero:", balance) } }

C'était un peu moche aussi, surtout quand nous avons vérifié l'équilibre. Peu importe. Essayons:

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s

Bien mieux, nous ne perdons plus de retraits. Mais le code devient difficile à lire et il y a des problèmes plus sérieux. Si jamais nous émettons une BalanceCommand et que nous oublions ensuite de lire la réponse, notre serveur de fonds bloquera à jamais toute tentative d'envoi. Nettoyons un peu les choses.

Faites-en un service

Un serveur est quelque chose à qui vous parlez. Qu'est-ce qu'un service ? Un service est quelque chose auquel vous parlez avec une API . Au lieu de faire fonctionner le code client directement avec le canal de commande, nous allons rendre le canal non exporté (privé) et encapsuler les commandes disponibles dans les fonctions.

 type FundServer struct { commands chan interface{} // Lowercase name, unexported // ... } func (s *FundServer) Balance() int { responseChan := make(chan int) s.commands <- BalanceCommand{ Response: responseChan } return <- responseChan } func (s *FundServer) Withdraw(amount int) { s.commands <- WithdrawCommand{ Amount: amount } }

Maintenant, notre benchmark peut simplement dire server.Withdraw(1) et balance := server.Balance() , et il y a moins de chance de lui envoyer accidentellement des commandes invalides ou d'oublier de lire les réponses.

Voici à quoi pourrait ressembler l'utilisation du fonds en tant que service dans cet exemple de programme de langue Go.

Il y a encore beaucoup de passe-partout supplémentaire pour les commandes, mais nous y reviendrons plus tard.

Transactions

Finalement, l'argent s'épuise toujours. Convenons que nous arrêterons de retirer lorsque notre fonds aura atteint ses dix derniers dollars, et dépenserons cet argent dans une pizza commune pour célébrer ou sympathiser. Notre benchmark reflétera ceci :

 // Spawn off the workers for i := 0; i < WORKERS; i++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < dollarsPerFounder; i++ { // Stop when we're down to pizza money if server.Balance() <= 10 { break } server.Withdraw(1) } }() } // ... balance := server.Balance() if balance != 10 { b.Error("Balance wasn't ten dollars:", balance) }

Cette fois, nous pouvons vraiment prédire le résultat.

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 fund_test.go:43: Balance wasn't ten dollars: 6 ok funding 0.009s

Nous sommes de retour là où nous avons commencé – plusieurs travailleurs peuvent lire le solde à la fois, puis tous le mettre à jour. Pour gérer cela, nous pourrions ajouter une logique dans le fonds lui-même, comme une propriété minimumBalance , ou ajouter une autre commande appelée WithdrawIfOverXDollars . Ce sont deux idées terribles. Notre accord est entre nous, pas la propriété du fonds. Nous devrions le garder dans la logique d'application.

Ce dont nous avons vraiment besoin, ce sont des transactions , au même sens que les transactions de base de données. Étant donné que notre service n'exécute qu'une seule commande à la fois, c'est très simple. Nous allons ajouter une commande Transact qui contient un callback (une fermeture). Le serveur exécutera ce rappel à l'intérieur de sa propre goroutine, en passant dans le fichier brut Fund . Le rappel peut alors en toute sécurité faire ce qu'il veut avec le Fund .

Sémaphores et erreurs

Dans cet exemple suivant, nous faisons mal deux petites choses.

Tout d'abord, nous utilisons un canal "Terminé" comme sémaphore pour informer le code appelant lorsque sa transaction est terminée. C'est bien, mais pourquoi le type de canal est-il "bool" ? Nous n'enverrons que "true" dedans pour signifier "fait" (que signifierait même l'envoi de "false" ?). Ce que nous voulons vraiment, c'est une valeur à un seul état (une valeur qui n'a pas de valeur ?). Dans Go, nous pouvons le faire en utilisant le type de structure vide : `struct{}`. Cela a aussi l'avantage d'utiliser moins de mémoire. Dans l'exemple, nous nous en tiendrons à `bool` pour ne pas avoir l'air trop effrayant.

Deuxièmement, notre rappel de transaction ne renvoie rien. Comme nous le verrons dans un instant, nous pouvons obtenir des valeurs du rappel dans le code d'appel en utilisant des astuces de portée. Cependant, les transactions dans un système réel échoueraient probablement parfois, donc la convention Go serait que la transaction renvoie une "erreur" (puis vérifie si elle était "nil" dans le code d'appel).

Nous ne le faisons pas non plus pour l'instant, puisque nous n'avons pas d'erreurs à générer.
 // Typedef the callback for readability type Transactor func(fund *Fund) // Add a new command type with a callback and a semaphore channel type TransactionCommand struct { Transactor Transactor Done chan bool } // ... // Wrap it up neatly in an API method, like the other commands func (s *FundServer) Transact(transactor Transactor) { command := TransactionCommand{ Transactor: transactor, Done: make(chan bool), } s.commands <- command <- command.Done } // ... func (s *FundServer) loop() { for command := range s.commands { switch command.(type) { // ... case TransactionCommand: transaction := command.(TransactionCommand) transaction.Transactor(s.fund) transaction.Done <- true // ... } } }

Nos rappels de transaction ne renvoient rien directement, mais le langage Go facilite l'extraction directe des valeurs d'une fermeture, nous le ferons donc dans le benchmark pour définir l'indicateur pizzaTime lorsque l'argent est bas :

 pizzaTime := false for i := 0; i < dollarsPerFounder; i++ { server.Transact(func(fund *Fund) { if fund.Balance() <= 10 { // Set it in the outside scope pizzaTime = true return } fund.Withdraw(1) }) if pizzaTime { break } }

Et vérifiez que cela fonctionne :

 $ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s

Rien que des transactions

Vous avez peut-être repéré une opportunité de nettoyer un peu plus les choses maintenant. Puisque nous avons une commande Transact générique, nous n'avons plus besoin de WithdrawCommand ou BalanceCommand . Nous allons les réécrire en termes de transactions :

 func (s *FundServer) Balance() int { var balance int s.Transact(func(f *Fund) { balance = f.Balance() }) return balance } func (s *FundServer) Withdraw(amount int) { s.Transact(func (f *Fund) { f.Withdraw(amount) }) }

Désormais, la seule commande que le serveur prend est TransactionCommand , nous pouvons donc supprimer tout le désordre de l' interface{} dans son implémentation et lui faire accepter uniquement les commandes de transaction :

 type FundServer struct { commands chan TransactionCommand fund *Fund } func (s *FundServer) loop() { for transaction := range s.commands { // Now we don't need any type-switch mess transaction.Transactor(s.fund) transaction.Done <- true } }

Beaucoup mieux.

Il y a une dernière étape que nous pourrions franchir ici. Outre ses fonctions de commodité pour l' Balance et le Withdraw , la mise en œuvre du service n'est plus liée au Fund . Au lieu de gérer un Fund , il pourrait gérer une interface{} et être utilisé pour envelopper n'importe quoi . Cependant, chaque rappel de transaction devrait alors reconvertir l' interface{} en une valeur réelle :

 type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })

C'est moche et source d'erreurs. Ce que nous voulons vraiment, ce sont des génériques au moment de la compilation, afin que nous puissions "modèler" un serveur pour un type particulier (comme *Fund ).

Malheureusement, Go ne prend pas encore en charge les génériques. On s'attend à ce qu'il finisse par arriver, une fois que quelqu'un aura trouvé une syntaxe et une sémantique raisonnables. En attendant, une conception soignée de l'interface supprime souvent le besoin de génériques, et quand ce n'est pas le cas, nous pouvons nous débrouiller avec des assertions de type (qui sont vérifiées au moment de l'exécution).

Sommes-nous finis ?

Oui.

Bon, d'accord, non.

Par exemple:

  • Une panique dans une transaction tuera tout le service.

  • Il n'y a pas de délais d'attente. Une transaction qui ne revient jamais bloquera le service pour toujours.

  • Si notre fonds développe de nouveaux champs et qu'une transaction se bloque à mi-chemin de leur mise à jour, nous aurons un état incohérent.

  • Les transactions peuvent divulguer l'objet Fund géré, ce qui n'est pas bon.

  • Il n'y a aucun moyen raisonnable d'effectuer des transactions sur plusieurs fonds (comme retirer de l'un et déposer dans un autre). Nous ne pouvons pas simplement imbriquer nos transactions car cela permettrait des blocages.

  • L'exécution d'une transaction de manière asynchrone nécessite désormais une nouvelle goroutine et beaucoup de manipulations. Dans le même ordre d'idées, nous souhaitons probablement pouvoir lire l'état le plus récent Fund d'ailleurs pendant qu'une transaction de longue durée est en cours.

Dans notre prochain didacticiel sur le langage de programmation Go, nous examinerons quelques moyens de résoudre ces problèmes.

En relation: Logique bien structurée: un didacticiel Golang OOP