Go-Programmiersprache: Ein einführendes Golang-Tutorial
Veröffentlicht: 2022-03-11Was ist die Go-Programmiersprache?
Die relativ neue Programmiersprache Go fügt sich nahtlos in die Landschaft ein, bietet viele gute Funktionen und verzichtet bewusst auf viele schlechte. Es lässt sich schnell kompilieren, läuft ziemlich schnell, enthält eine Laufzeit- und Garbage-Collection, hat ein einfaches statisches Typsystem und dynamische Schnittstellen und eine ausgezeichnete Standardbibliothek. Aus diesem Grund sind so viele Entwickler daran interessiert, die Go-Programmierung zu lernen.
OOP ist eine dieser Funktionen, die Go bewusst weglässt. Es gibt keine Unterklassen, und daher gibt es keine Vererbungsdiamanten oder Superrufe oder virtuelle Methoden, die Sie stolpern lassen. Dennoch sind viele der nützlichen Teile von OOP auf andere Weise verfügbar.
*Mixins* sind verfügbar, indem Strukturen anonym eingebettet werden, sodass ihre Methoden direkt auf der enthaltenden Struktur aufgerufen werden können (siehe Einbetten). Das Heraufstufen von Methoden auf diese Weise wird als *Forwarding* bezeichnet und ist nicht dasselbe wie das Erstellen von Unterklassen: Die Methode wird immer noch auf der inneren, eingebetteten Struktur aufgerufen.
Das Einbetten impliziert auch keinen Polymorphismus. Während „A“ ein „B“ haben kann, bedeutet das nicht, dass es ein „B“ ist – Funktionen, die ein „B“ annehmen, nehmen stattdessen kein „A“. Dafür brauchen wir Interfaces , denen wir später noch kurz begegnen werden.
In der Zwischenzeit nimmt Golang eine starke Position zu Funktionen ein, die zu Verwirrung und Fehlern führen können. Es lässt OOP-Idiome wie Vererbung und Polymorphismus zugunsten von Komposition und einfachen Schnittstellen weg. Es spielt die Ausnahmebehandlung zugunsten von expliziten Fehlern in Rückgabewerten herunter. Es gibt genau einen richtigen Weg, Go-Code zu gestalten, der vom gofmt
Tool erzwungen wird. Und so weiter.
Warum Golang lernen?
Go ist auch eine großartige Sprache zum Schreiben von nebenläufigen Programmen : Programme mit vielen unabhängig voneinander laufenden Teilen. Ein offensichtliches Beispiel ist ein Webserver: Jede Anfrage läuft separat, aber Anfragen müssen oft Ressourcen wie Sitzungen, Caches oder Benachrichtigungswarteschlangen teilen. Dies bedeutet, dass erfahrene Go-Programmierer mit dem gleichzeitigen Zugriff auf diese Ressourcen umgehen müssen.
Golang verfügt zwar über eine hervorragende Reihe von Low-Level-Funktionen zum Umgang mit Parallelität, deren direkte Verwendung kann jedoch kompliziert werden. In vielen Fällen macht eine Handvoll wiederverwendbarer Abstraktionen über diese Low-Level-Mechanismen das Leben viel einfacher.
Im heutigen Go-Programmiertutorial werden wir uns eine solche Abstraktion ansehen: Einen Wrapper, der jede Datenstruktur in einen Transaktionsdienst umwandeln kann. Wir verwenden als Beispiel einen Fund
– ein einfaches Geschäft für die verbleibende Finanzierung unseres Startups, wo wir den Kontostand überprüfen und Abhebungen vornehmen können.
Um dies in der Praxis zu demonstrieren, werden wir den Dienst in kleinen Schritten aufbauen, dabei ein Chaos anrichten und ihn dann wieder aufräumen. Während wir unser Go-Tutorial durcharbeiten, werden wir auf viele coole Go-Sprachfunktionen stoßen, darunter:
- Strukturtypen und Methoden
- Unit-Tests und Benchmarks
- Goroutinen und Kanäle
- Schnittstellen und dynamische Eingabe
Aufbau eines einfachen Fonds
Lassen Sie uns einen Code schreiben, um die Finanzierung unseres Startups zu verfolgen. Der Fonds beginnt mit einem bestimmten Guthaben, und Geld kann nur abgehoben werden (wir werden die Einnahmen später herausfinden).
Go ist bewusst keine objektorientierte Sprache: Es gibt keine Klassen, Objekte oder Vererbung. Stattdessen deklarieren wir einen Strukturtyp namens Fund
mit einer einfachen Funktion zum Erstellen neuer Fondsstrukturen und zwei öffentlichen Methoden.
fund.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 }
Testen mit Benchmarks
Als nächstes brauchen wir eine Möglichkeit, Fund
zu testen. Anstatt ein separates Programm zu schreiben, verwenden wir das Testpaket von Go, das einen Rahmen für Komponententests und Benchmarks bietet. Die einfache Logik in unserem Fund
ist es nicht wirklich wert, Unit-Tests zu schreiben, aber da wir später viel über den gleichzeitigen Zugriff auf den Fonds sprechen werden, ist das Schreiben einer Benchmark sinnvoll.
Benchmarks sind wie Unit-Tests, enthalten aber eine Schleife, die denselben Code viele Male ausführt (in unserem Fall fund.Withdraw(1)
). Auf diese Weise kann das Framework zeitlich festlegen, wie lange jede Iteration dauert, und vorübergehende Unterschiede aus Festplattensuchen, Cache-Fehlschlägen, Prozessplanung und anderen unvorhersehbaren Faktoren mitteln.
Das Testframework möchte, dass jeder Benchmark (standardmäßig) mindestens 1 Sekunde lang ausgeführt wird. Um dies sicherzustellen, wird der Benchmark mehrmals aufgerufen, wobei jedes Mal ein zunehmender Wert für die „Anzahl der Iterationen“ (das bN
-Feld) übergeben wird, bis die Ausführung mindestens eine Sekunde dauert.
Im Moment wird unser Benchmark nur etwas Geld einzahlen und es dann einen Dollar nach dem anderen abheben.
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()) } }
Lassen Sie es uns jetzt ausführen:
$ go test -bench . funding testing: warning: no tests to run PASS BenchmarkWithdrawals 2000000000 1.69 ns/op ok funding 3.576s
Das lief gut. Wir haben zwei Milliarden (!) Iterationen ausgeführt, und die letzte Überprüfung des Gleichgewichts war korrekt. Wir können die Warnung „no tests to run“ ignorieren, die sich auf die Unit-Tests bezieht, die wir nicht geschrieben haben (in späteren Go-Programmierbeispielen in diesem Tutorial wird die Warnung ausgeschnitten).
Gleichzeitiger Zugriff in Go
Lassen Sie uns nun den Benchmark simultan machen, um verschiedene Benutzer zu modellieren, die gleichzeitig Abhebungen vornehmen. Dazu spawnen wir zehn Goroutinen und lassen jede von ihnen ein Zehntel des Geldes abheben.
Goroutinen sind der Grundbaustein für Parallelität in der Go-Sprache. Sie sind grüne Threads – leichtgewichtige Threads, die von der Go-Laufzeit verwaltet werden, nicht vom Betriebssystem. Das bedeutet, dass Sie Tausende (oder Millionen) davon ohne nennenswerten Overhead ausführen können. Goroutinen werden mit dem Schlüsselwort go
gestartet und beginnen immer mit einer Funktion (oder einem Methodenaufruf):
// Returns immediately, without waiting for `DoSomething()` to complete go DoSomething()
Oft möchten wir eine kurze einmalige Funktion mit nur wenigen Codezeilen erstellen. In diesem Fall können wir anstelle eines Funktionsnamens eine Closure verwenden:
go func() { // ... do stuff ... }() // Must be a function *call*, so remember the ()
Sobald alle unsere Goroutinen gespawnt sind, müssen wir warten, bis sie fertig sind. Wir könnten selbst einen bauen, indem wir Kanäle verwenden, aber wir sind denen noch nicht begegnet, also würde das überspringen.
Im Moment können wir einfach den WaitGroup
-Typ in der Standardbibliothek von Go verwenden, die genau für diesen Zweck existiert. Wir erstellen einen (namens „ wg
“) und rufen wg.Add(1)
auf, bevor wir jeden Worker spawnen, um zu verfolgen, wie viele es gibt. Dann melden sich die Worker mit wg.Done()
zurück. Währenddessen können wir in der Haupt-Goroutine einfach wg.Wait()
sagen, um zu blockieren, bis jeder Arbeiter fertig ist.
Innerhalb der Worker-Goroutinen in unserem nächsten Beispiel verwenden wir defer
, um wg.Done()
.
defer
nimmt einen Funktions- (oder Methoden-)Aufruf und führt ihn aus, unmittelbar bevor die aktuelle Funktion zurückkehrt, nachdem alles andere erledigt ist. Das ist praktisch für die Reinigung:
func() { resource.Lock() defer resource.Unlock() // Do stuff with resource }()
Auf diese Weise können wir das Unlock
zur besseren Lesbarkeit leicht mit seinem Lock
abgleichen. Noch wichtiger ist, dass eine verzögerte Funktion auch dann ausgeführt wird , wenn in der Hauptfunktion eine Panik auftritt (etwas, das wir in anderen Sprachen möglicherweise über try-finally behandeln).
Schließlich werden verzögerte Funktionen in der umgekehrten Reihenfolge ausgeführt, in der sie aufgerufen wurden, was bedeutet, dass wir eine verschachtelte Bereinigung gut durchführen können (ähnlich dem C-Idiom von verschachtelten goto
s und label
s, aber viel ordentlicher):
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, also nach allem, was gesagt wurde, hier ist die neue 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()) } }
Wir können vorhersagen, was hier passieren wird. Die Worker führen alle Withdraw
übereinander aus. Darin f.balance -= amount
den Kontostand, subtrahiert eins und schreibt ihn dann zurück. Aber manchmal lesen zwei oder mehr Arbeiter beide dieselbe Bilanz und führen dieselbe Subtraktion durch, und wir erhalten am Ende die falsche Summe. Rechts?
$ go test -bench . funding BenchmarkWithdrawals 2000000000 2.01 ns/op ok funding 4.220s
Nein, es geht noch. Was ist hier passiert?
Denken Sie daran, dass Goroutinen grüne Threads sind – sie werden von der Go-Laufzeit verwaltet, nicht vom Betriebssystem. Die Laufzeit plant Goroutinen über so viele OS-Threads hinweg, wie sie verfügbar sind. Zum Zeitpunkt des Schreibens dieses Go-Lernprogramms versucht Go nicht zu erraten, wie viele Betriebssystem-Threads es verwenden sollte, und wenn wir mehr als einen wollen, müssen wir das sagen. Schließlich unterbindet die aktuelle Laufzeit Goroutinen nicht – eine Goroutine läuft weiter, bis sie etwas tut, was darauf hindeutet, dass sie für eine Pause bereit ist (wie die Interaktion mit einem Kanal).
All dies bedeutet, dass unser Benchmark zwar jetzt gleichzeitig, aber nicht parallel ist. Es wird jeweils nur einer unserer Worker ausgeführt, und er wird ausgeführt, bis er fertig ist. Wir können dies ändern, indem wir Go über die Umgebungsvariable GOMAXPROCS
, mehr Threads zu verwenden.
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 --- FAIL: BenchmarkWithdrawals-4 account_test.go:39: Balance wasn't zero: 4238 ok funding 0.007s
Das ist besser. Jetzt verlieren wir offensichtlich einige unserer Auszahlungen, wie wir erwartet haben.
Machen Sie es zu einem Server
An dieser Stelle haben wir verschiedene Möglichkeiten. Wir könnten einen expliziten Mutex oder eine Lese-Schreib-Sperre um den Fonds herum hinzufügen. Wir könnten ein Vergleichen und Tauschen mit einer Versionsnummer verwenden. Wir könnten alles daran setzen und ein CRDT-Schema verwenden (vielleicht das balance
durch Transaktionslisten für jeden Kunden ersetzen und daraus den Kontostand berechnen).
Aber wir werden jetzt keines dieser Dinge tun, weil sie chaotisch oder beängstigend oder beides sind. Stattdessen entscheiden wir, dass ein Fonds ein Server sein soll . Was ist ein Server? Es ist etwas, mit dem man spricht. In Go sprechen die Dinge über Kanäle.
Kanäle sind der grundlegende Kommunikationsmechanismus zwischen Goroutinen. Werte werden an den Kanal gesendet (mit channel <- value
) und können auf der anderen Seite empfangen werden (mit value = <- channel
). Kanäle sind „goroutinensicher“, was bedeutet, dass eine beliebige Anzahl von Goroutinen gleichzeitig an sie senden und von ihnen empfangen können.
Das Puffern von Kommunikationskanälen kann unter Umständen eine Performance-Optimierung sein, sollte aber mit großer Vorsicht (und Benchmarking!) eingesetzt werden.
Es gibt jedoch Anwendungen für gepufferte Kanäle, bei denen es nicht direkt um Kommunikation geht.
Zum Beispiel erstellt ein gängiges Drosselungsidiom einen Kanal mit (zum Beispiel) Puffergröße "10" und sendet dann sofort zehn Token hinein. Dann wird eine beliebige Anzahl von Worker-Goroutinen erzeugt, und jede erhält ein Token vom Kanal, bevor sie mit der Arbeit beginnt, und sendet es danach zurück. Dann werden, egal wie viele Arbeiter es gibt, immer nur zehn gleichzeitig arbeiten.
Standardmäßig sind Go-Kanäle ungepuffert . Das bedeutet, dass das Senden eines Werts an einen Kanal blockiert, bis eine andere Goroutine bereit ist, ihn sofort zu empfangen. Go unterstützt auch feste Puffergrößen für Kanäle (mit make(chan someType, bufferSize)
). Für den normalen Gebrauch ist dies jedoch normalerweise eine schlechte Idee .
Stellen Sie sich einen Webserver für unseren Fonds vor, bei dem jede Anfrage eine Auszahlung vornimmt. Wenn die Dinge sehr beschäftigt sind, kann der FundServer
nicht mithalten, und Anfragen, die versuchen, an seinen Befehlskanal zu senden, beginnen zu blockieren und zu warten. An diesem Punkt können wir eine maximale Anzahl von Anfragen im Server erzwingen und Clients, die diese Grenze überschreiten, einen sinnvollen Fehlercode (wie 503 Service Unavailable
) zurückgeben. Dies ist das bestmögliche Verhalten, wenn der Server überlastet ist.

Das Hinzufügen von Pufferung zu unseren Kanälen würde dieses Verhalten weniger deterministisch machen. Wir könnten leicht mit langen Warteschlangen von unverarbeiteten Befehlen enden, die auf Informationen basieren, die der Client viel früher gesehen hat (und vielleicht für Anfragen, die seitdem im Upstream abgelaufen sind). Dasselbe gilt in vielen anderen Situationen, beispielsweise beim Anwenden von Backpressure über TCP, wenn der Empfänger nicht mit dem Sender mithalten kann.
In jedem Fall bleiben wir für unser Go-Beispiel beim standardmäßigen ungepufferten Verhalten.
Wir verwenden einen Kanal, um Befehle an unseren FundServer
zu senden. Jeder Benchmark-Worker sendet Befehle an den Kanal, aber nur der Server empfängt sie.
Wir könnten unseren Fondstyp direkt in eine Serverimplementierung umwandeln, aber das wäre chaotisch – wir würden Parallelitätsbehandlung und Geschäftslogik vermischen. Stattdessen lassen wir den Fund-Typ genau so, wie er ist, und machen FundServer
zu einem separaten Wrapper darum.
Wie jeder Server hat der Wrapper eine Hauptschleife, in der er auf Befehle wartet und nacheinander auf jeden antwortet. Es gibt noch ein weiteres Detail, das wir hier ansprechen müssen: Die Art der Befehle.
Wir hätten unseren Befehlskanal dazu bringen können, *Zeiger* auf Befehle zu nehmen (`chan *TransactionCommand`). Warum haben wir nicht?
Das Übergeben von Zeigern zwischen Goroutinen ist riskant, da jede Goroutine sie ändern könnte. Es ist auch oft weniger effizient, da die andere Goroutine möglicherweise auf einem anderen CPU-Kern läuft (was mehr Cache-Invalidierung bedeutet).
Wenn möglich, ziehen Sie es vor, einfache Werte weiterzugeben.
Im nächsten Abschnitt unten senden wir mehrere verschiedene Befehle, jeder mit seinem eigenen Strukturtyp. Wir möchten, dass der Befehlskanal des Servers alle akzeptiert. In einer OOP-Sprache könnten wir dies über Polymorphismus tun: Lassen Sie den Kanal eine Oberklasse annehmen, von der die einzelnen Befehlstypen Unterklassen waren. In Go verwenden wir stattdessen Schnittstellen .
Eine Schnittstelle ist ein Satz von Methodensignaturen. Jeder Typ, der alle diese Methoden implementiert, kann als diese Schnittstelle behandelt werden (ohne dafür deklariert zu werden). Bei unserem ersten Durchlauf stellen unsere Befehlsstrukturen eigentlich keine Methoden bereit, also verwenden wir die leere Schnittstelle, interface{}
. Da es keine Anforderungen gibt, erfüllt jeder Wert (einschließlich primitiver Werte wie Ganzzahlen) die leere Schnittstelle. Das ist nicht ideal – wir wollen nur Befehlsstrukturen akzeptieren – aber wir werden später darauf zurückkommen.
Beginnen wir zunächst mit dem Gerüst für unseren Go-Server:
server.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 } }
Lassen Sie uns nun ein paar Golang-Strukturtypen für die Befehle hinzufügen:
type WithdrawCommand struct { Amount int } type BalanceCommand struct { Response chan int }
Der WithdrawCommand
enthält nur den abzuhebenden Betrag. Es kommt keine Antwort. Der BalanceCommand
hat eine Antwort, also enthält er einen Kanal, um ihn zu senden. Dadurch wird sichergestellt, dass die Antworten immer an der richtigen Stelle landen, selbst wenn unser Fonds später beschließt, nicht in der richtigen Reihenfolge zu antworten.
Jetzt können wir die Hauptschleife des Servers schreiben:
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. Das ist irgendwie hässlich. Wir schalten den Befehlstyp ein, verwenden Typzusicherungen und stürzen möglicherweise ab. Machen wir trotzdem weiter und aktualisieren den Benchmark, um den Server zu verwenden.
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) } }
Das war auch irgendwie hässlich, besonders als wir das Gleichgewicht überprüft haben. Macht nichts. Lass es uns versuchen:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 465 ns/op ok funding 2.822s
Viel besser, wir verlieren keine Abhebungen mehr. Aber der Code wird schwer lesbar, und es gibt ernstere Probleme. Wenn wir jemals einen BalanceCommand
und dann vergessen, die Antwort zu lesen, blockiert unser Fondsserver für immer den Versuch, sie zu senden. Lass uns ein bisschen aufräumen.
Machen Sie es zu einem Dienst
Ein Server ist etwas, mit dem Sie sprechen. Was ist eine Dienstleistung? Ein Dienst ist etwas, mit dem Sie über eine API sprechen. Anstatt den Clientcode direkt mit dem Befehlskanal arbeiten zu lassen, machen wir den Kanal nicht exportiert (privat) und verpacken die verfügbaren Befehle in Funktionen.
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 } }
Jetzt kann unser Benchmark einfach server.Withdraw(1)
und balance := server.Balance()
sagen, und es besteht eine geringere Wahrscheinlichkeit, dass ihm versehentlich ungültige Befehle gesendet oder vergessen werden, Antworten zu lesen.
Es gibt noch viele zusätzliche Textbausteine für die Befehle, aber darauf kommen wir später zurück.
Transaktionen
Irgendwann geht das Geld immer aus. Lassen Sie uns vereinbaren, dass wir aufhören, abzuheben, wenn unser Fonds auf seine letzten zehn Dollar gesunken ist, und dieses Geld für eine gemeinsame Pizza ausgeben, um zu feiern oder Mitleid zu haben. Unser Benchmark wird dies widerspiegeln:
// 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) }
Diesmal können wir das Ergebnis wirklich vorhersagen.
$ 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
Wir sind wieder da, wo wir angefangen haben – mehrere Mitarbeiter können den Kontostand gleichzeitig lesen und ihn dann alle aktualisieren. Um damit umzugehen, könnten wir dem Fonds selbst etwas Logik hinzufügen, wie eine minimumBalance
, oder einen weiteren Befehl namens WithdrawIfOverXDollars
hinzufügen. Das sind beides schreckliche Ideen. Unsere Vereinbarung ist untereinander, nicht Eigentum des Fonds. Wir sollten es in der Anwendungslogik belassen.
Was wir wirklich brauchen, sind Transaktionen im gleichen Sinne wie Datenbanktransaktionen. Da unser Dienst immer nur einen Befehl gleichzeitig ausführt, ist dies super einfach. Wir fügen einen Transact
Befehl hinzu, der einen Callback (einen Abschluss) enthält. Der Server führt diesen Callback in seiner eigenen Goroutine aus und übergibt den rohen Fund
. Der Callback kann dann getrost mit der Fund
machen was er will.
In diesem nächsten Beispiel machen wir zwei kleine Dinge falsch.
Zuerst verwenden wir einen „Done“-Kanal als Semaphor, um den aufrufenden Code darüber zu informieren, wann seine Transaktion abgeschlossen ist. Das ist in Ordnung, aber warum ist der Kanaltyp „bool“? Wir werden immer nur "true" hineinschicken, um "fertig" zu bedeuten (was würde das Senden von "false" überhaupt bedeuten?). Was wir wirklich wollen, ist ein Single-State-Wert (ein Wert, der keinen Wert hat?). In Go können wir dies mit dem leeren Strukturtyp tun: `struct{}`. Dies hat auch den Vorteil, dass weniger Speicher verbraucht wird. Im Beispiel bleiben wir bei `bool`, um nicht zu gruselig auszusehen.
Zweitens gibt unser Transaktions-Callback nichts zurück. Wie wir gleich sehen werden, können wir mithilfe von Scope-Tricks Werte aus dem Callback in aufrufenden Code übertragen. Transaktionen in einem realen System würden jedoch vermutlich manchmal fehlschlagen, daher würde die Go-Konvention darin bestehen, dass die Transaktion einen "Fehler" zurückgibt (und dann überprüft, ob es im aufrufenden Code "nil" war).
Das machen wir vorerst auch nicht, da wir keine Fehler zu generieren haben.
// 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 // ... } } }
Unsere Transaktions-Callbacks geben nichts direkt zurück, aber die Go-Sprache macht es einfach, Werte direkt aus einem Abschluss herauszuholen, also werden wir das im Benchmark tun, um das pizzaTime
Flag zu setzen, wenn das Geld knapp wird:
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 } }
Und überprüfen Sie, ob es funktioniert:
$ GOMAXPROCS=4 go test -bench . funding BenchmarkWithdrawals-4 5000000 775 ns/op ok funding 4.637s
Nichts als Transaktionen
Vielleicht haben Sie jetzt eine Gelegenheit entdeckt, die Dinge noch etwas aufzuräumen. Da wir einen generischen Transact
-Befehl haben, brauchen wir WithdrawCommand
oder BalanceCommand
nicht mehr. Wir werden sie in Bezug auf Transaktionen umschreiben:
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) }) }
Jetzt ist der einzige Befehl, den der Server nimmt, TransactionCommand
, also können wir das ganze interface{}
-Chaos in seiner Implementierung entfernen und ihn nur Transaktionsbefehle akzeptieren lassen:
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 } }
Viel besser.
Es gibt einen letzten Schritt, den wir hier tun könnten. Abgesehen von den Komfortfunktionen für Balance
und Withdraw
ist die Serviceimplementierung nicht mehr an Fund
gebunden. Anstatt einen Fund
zu verwalten, könnte es eine interface{}
und verwendet werden, um alles zu verpacken . Allerdings müsste dann jeder Transaktions-Callback die interface{}
wieder in einen realen Wert umwandeln:
type Transactor func(interface{}) server.Transact(func(managedValue interface{}) { fund := managedValue.(*Fund) // Do stuff with fund ... })
Das ist hässlich und fehleranfällig. Was wir wirklich wollen, sind Generika zur Kompilierzeit, damit wir einen Server für einen bestimmten Typ (wie *Fund
) als Vorlage erstellen können.
Leider unterstützt Go keine Generika – noch nicht. Es wird erwartet, dass es irgendwann ankommt, sobald jemand eine vernünftige Syntax und Semantik dafür herausgefunden hat. In der Zwischenzeit beseitigt ein sorgfältiges Schnittstellendesign häufig die Notwendigkeit von Generika, und wenn dies nicht der Fall ist, können wir mit Typzusicherungen auskommen (die zur Laufzeit überprüft werden).
Sind wir fertig?
Jawohl.
Nun gut, nein.
Zum Beispiel:
Eine Panik in einer Transaktion wird den gesamten Dienst beenden.
Es gibt keine Zeitüberschreitungen. Eine Transaktion, die niemals zurückkehrt, blockiert den Dienst für immer.
Wenn unser Fonds einige neue Felder erweitert und eine Transaktion nach der Hälfte der Aktualisierung abstürzt, haben wir einen inkonsistenten Status.
Transaktionen können das verwaltete
Fund
durchsickern lassen, was nicht gut ist.Es gibt keine vernünftige Möglichkeit, Transaktionen über mehrere Fonds hinweg durchzuführen (z. B. Abhebungen von einem und Einzahlungen in einen anderen). Wir können unsere Transaktionen nicht einfach verschachteln, weil das Deadlocks zulassen würde.
Das asynchrone Ausführen einer Transaktion erfordert jetzt eine neue Goroutine und viel Herumspielen. Dementsprechend möchten wir wahrscheinlich in der Lage sein, den neuesten
Fund
von woanders zu lesen, während eine lang andauernde Transaktion im Gange ist.
In unserem nächsten Go-Programmiersprachen-Tutorial werden wir uns einige Möglichkeiten ansehen, wie diese Probleme angegangen werden können.