Go-Programmiersprache: Ein einführendes Golang-Tutorial

Veröffentlicht: 2022-03-11

Was 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.

Golang-Tutorial: Logoillustration

Gehen Sie und OOP

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).

Diese Grafik zeigt ein einfaches Goroutine-Beispiel mit der Programmiersprache Go.

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.

Wie würden wir mehrere gleichzeitige Goroutinen in der Go-Sprache strukturieren?

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.

In diesem Go-Programmierbeispiel ist das Ergebnis mehrerer paralleler Goroutinen nicht günstig.

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.

Pufferung

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.

Ein Diagramm des Fonds, der in diesem Go-Programmiertutorial als Server verwendet wird.

Zeiger

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.

So könnte die Nutzung des Fonds als Dienstleistung in diesem Beispiel-Go-Sprachprogramm aussehen.

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.

Semaphoren und Fehler

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.

Siehe auch : Gut strukturierte Logik: Ein Golang-OOP-Tutorial