Top 10 der häufigsten Fehler, auf die ich in Go-Projekten gestoßen bin

Dieser Beitrag ist einer der häufigsten Fehler, die ich in Go-Projekten festgestellt habe. Ordnung spielt keine Rolle.

Bild

Unbekannter Wert von Enum


Schauen wir uns ein einfaches Beispiel an:

type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown ) 

Hier erstellen wir mit iota einen Enumerator, der zu diesem Status führt:

 StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2 

Stellen wir uns nun vor, dass diese Art von Status Teil der JSON-Anforderung ist, die gepackt / entpackt wird. Wir können die folgende Struktur entwerfen:

 type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` } 

Dann erhalten wir dieses Abfrageergebnis:

 { "Id": 1234, "Timestamp": 1563362390, "Status": 0 } 

Im Allgemeinen nichts Besonderes - Status wird in StatusOpen entpackt.
Lassen Sie uns nun eine weitere Antwort erhalten, bei der der Statuswert nicht festgelegt ist:

 { "Id": 1235, "Timestamp": 1563362390 } 

In diesem Fall wird das Statusfeld der Anforderungsstruktur auf Null initialisiert (für uint32 ist es 0). Daher erhalten wir wieder StatusOpen anstelle von StatusUnknown.

In diesem Fall ist es am besten, zuerst den unbekannten Wert des Enumerators einzustellen - d. H. 0:

 type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed ) 

Wenn der Status nicht Teil der JSON-Anforderung ist, wird er erwartungsgemäß in StatusUnknown initialisiert.

Benchmarking


Richtiges Benchmarking ist ziemlich schwierig. Zu viele Faktoren können das Ergebnis beeinflussen.

Ein häufiger Fehler wird durch Compiler-Optimierungen ausgetrickst. Sehen wir uns ein konkretes Beispiel aus der teivah / bitvector-Bibliothek an :

 func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n } 

Diese Funktion löscht Bits in einem bestimmten Bereich. Wir können die Leistung folgendermaßen testen:

 func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } } 

In diesem Test wird der Compiler feststellen, dass clear keine andere Funktion aufruft und sie einfach so einbettet, wie sie ist. Sobald es eingebaut ist, sieht der Compiler, dass keine Nebenwirkungen auftreten. Somit wird der eindeutige Anruf einfach gelöscht, was zu ungenauen Ergebnissen führt.

Eine Lösung kann darin bestehen, das Ergebnis auf eine globale Variable wie diese festzulegen:

 var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r } 

Hier weiß der Compiler nicht, ob der Aufruf einen Nebeneffekt erzeugt. Daher ist der Benchmark genau.

Zeiger! Zeiger sind überall!


Wenn Sie eine Variable als Wert übergeben, wird eine Kopie dieser Variablen erstellt. Kopieren Sie beim Übergeben des Zeigers einfach die Adresse in den Speicher.

Folglich ist das Übergeben eines Zeigers immer schneller, oder?

Wenn Sie so denken, schauen Sie sich dieses Beispiel an . Dies ist ein Benchmark für eine 0,3-KB-Datenstruktur, die wir zuerst per Zeiger und dann nach Wert senden und empfangen. 0,3 KB sind ein bisschen - ungefähr die üblichen Datenstrukturen, mit denen wir jeden Tag arbeiten, belegen ungefähr so ​​viel.

Wenn ich diese Tests in einer lokalen Umgebung durchführe, ist die Übertragung von Wert zu Wert mehr als viermal schneller. Ziemlich unerwartet, oder?

Die Erklärung dieses Ergebnisses hängt mit dem Verständnis der Speicherverwaltung in Go zusammen. Ich kann es nicht so brillant erklären wie William Kennedy , aber lassen Sie uns versuchen, es auf den Punkt zu bringen.

Eine Variable kann auf dem Heap oder Stack platziert werden:
  • Der Stack enthält die aktuellen Variablen dieses Programms. Sobald die Funktion zurückkehrt, werden die Variablen vom Stapel entfernt.
  • Der Heap enthält allgemeine Variablen (globale Variablen usw.).

Schauen wir uns ein einfaches Beispiel an, in dem wir einen Wert zurückgeben:

 func getFooValue() foo { var result foo // Do something return result } 

Hier wird die Ergebnisvariable von der aktuellen Goroutine erstellt. Diese Variable wird auf den aktuellen Stapel verschoben. Sobald die Funktion zurückkehrt, erhält der Client eine Kopie dieser Variablen. Die Variable selbst wird vom Stapel genommen. Es ist noch im Speicher vorhanden, bis eine andere Variable überschrieben wird, auf die jedoch nicht mehr zugegriffen werden kann.
Nun das gleiche Beispiel, aber mit einem Zeiger:

 func getFooPointer() *foo { var result foo // Do something return &result } 

Die Ergebnisvariable wird weiterhin von der aktuellen Goroutine erstellt, der Client erhält jedoch einen Zeiger (eine Kopie der Adresse der Variablen). Wenn die Ergebnisvariable aus dem Stapel entfernt wurde, kann der Client dieser Funktion nicht darauf zugreifen.

In diesem Szenario gibt der Go-Compiler die Ergebnisvariable dort aus, wo die Variablen gemeinsam genutzt werden können, d. H. in einem Haufen.

Ein weiteres Skript zum Übergeben von Zeigern:

 func main() { p := &foo{} f(p) } 

Da wir f im selben Programm aufrufen, muss die Variable p nicht gehäuft werden. Es wird einfach auf den Stapel geschoben, und eine Unterfunktion kann darauf zugreifen.

Auf diese Weise wird beispielsweise ein Slice in der Read-Methode von io.Reader erhalten. Wenn Sie ein Slice (das ein Zeiger ist) zurückgeben, wird es auf einen Heap gelegt.

Warum ist der Stapel so schnell? Es gibt zwei Gründe:
  • Der Garbage Collector muss nicht auf dem Stapel verwendet werden. Wie bereits erwähnt, wird eine Variable nach dem Erstellen einfach verschoben und dann vom Stapel entfernt, wenn die Funktion zurückkehrt. Sie müssen keinen komplizierten Prozess aufrühren, um nicht verwendete Variablen usw. zurückzugeben.
  • Der Stack gehört zu einer Goroutine, sodass der Speicher der Variablen nicht synchronisiert werden muss, wie dies beim Speichern auf dem Heap der Fall ist, was ebenfalls zu einer Leistungssteigerung führt.

Wenn wir eine Funktion erstellen, sollte unsere Standardaktion darin bestehen, Werte anstelle von Zeigern zu verwenden. Ein Zeiger sollte nur verwendet werden, wenn wir eine Variable gemeinsam nutzen möchten.

Wenn wir unter Leistungsproblemen leiden, besteht eine der möglichen Optimierungen darin, zu überprüfen, ob Zeiger in bestimmten Situationen hilfreich sind. Ob der Compiler eine Variable an den Heap ausgibt, kann mit dem folgenden Befehl ermittelt werden:
 go build -gcflags "-m -m" 
.
Aber auch hier ist es für die meisten unserer täglichen Aufgaben am besten, Werte zu verwenden.

Abbrechen für / switch oder for / select


Was passiert im folgenden Beispiel, wenn f true zurückgibt?

 for { switch f() { case true: break case false: // Do something } } 

Wir nennen Pause. Nur diese Unterbrechung unterbricht den Schalter, nicht die for-Schleife.

Gleiches Problem hier:

 for { select { case <-ch: // Do something case <-ctx.Done(): break } } 

Break ist einer select-Anweisung zugeordnet, nicht einer for-Schleife.

Eine mögliche Lösung zum Unterbrechen von / switch oder for / select ist die Verwendung eines Etiketts:

 loop: for { select { case <-ch: // Do something case <-ctx.Done(): break loop } } 

Fehlerbehandlung


Go ist noch jung, insbesondere im Bereich der Fehlerbehandlung. Die Überwindung dieses Mangels ist eine der am meisten erwarteten Innovationen in Go 2.

Die aktuelle Standardbibliothek (vor Go 1.13) bietet nur Funktionen zum Erstellen von Fehlern. Daher wird es interessant sein, sich das Paket pkg / Errors anzusehen.

Diese Bibliothek ist eine gute Möglichkeit, einer Regel zu folgen, die nicht immer eingehalten wird:
Der Fehler sollte nur einmal verarbeitet werden. Die Fehlerprotokollierung ist eine Fehlerbehandlung
. Daher sollte der Fehler protokolliert oder höher geworfen werden.

In der aktuellen Standardbibliothek ist dieses Prinzip schwer zu beobachten, da wir dem Fehler möglicherweise einen Kontext hinzufügen und eine Art Hierarchie haben möchten.

Schauen wir uns ein Beispiel mit einem REST-Aufruf an, der zu einem Datenbankfehler führt:

 unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction 

Wenn wir pkg / error verwenden, können wir Folgendes tun:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error { // Do something then fail return errors.New("unable to commit transaction") } 

Der anfängliche Fehler (wenn er nicht von der externen Bibliothek zurückgegeben wird) kann mit error.New erstellt werden. Die mittlere Ebene, Einfügen, umschließt diesen Fehler und fügt ihm mehr Kontext hinzu. Dann protokolliert der Elternteil es. Somit gibt jede Ebene entweder einen Fehler zurück oder verarbeitet ihn.

Möglicherweise möchten wir auch die Fehlerursache finden, z. B. zurückrufen. Angenommen, wir haben ein Datenbankpaket aus einer externen Bibliothek, die Zugriff auf eine Datenbank hat. Diese Bibliothek gibt möglicherweise einen temporären Fehler namens db.DBError zurück. Um festzustellen, ob wir es erneut versuchen müssen, müssen wir die Fehlerursache ermitteln:

 func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } 

Dies geschieht mit Fehlern. Ursache, die auch in pkg / error enthalten ist :

Einer der häufigsten Fehler, auf die ich gestoßen bin, war die Verwendung von pkg / error nur teilweise. Eine Fehlerprüfung wurde beispielsweise wie folgt durchgeführt:

 switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } 

Wenn in diesem Beispiel db.DBError eingeschlossen ist, wird niemals ein zweiter Aufruf ausgeführt.

Slice-Initialisierung


Manchmal wissen wir, wie lang die Scheibe letztendlich sein wird. Angenommen, wir möchten ein Foo-Slice in ein Bar-Slice konvertieren, was bedeutet, dass diese beiden Slices dieselbe Länge haben.

Ich stoße oft auf Slices, die folgendermaßen initialisiert wurden:

 var bars []Bar bars := make([]Bar, 0) 

Slice ist keine magische Struktur. Unter der Haube implementiert er eine Strategie zur Vergrößerung, wenn kein freier Speicherplatz mehr vorhanden ist. In diesem Fall wird automatisch ein neues Array erstellt (mit einer größeren Kapazität), und alle Elemente werden darauf kopiert.

Stellen wir uns nun vor, wir müssen diesen Vorgang des Vergrößerns mehrmals wiederholen, da unser [] Foo Tausende von Elementen enthält. Die Komplexität des Einfügealgorithmus bleibt O (1), in der Praxis wirkt sich dies jedoch auf die Leistung aus.

Wenn wir also die endgültige Länge kennen, können wir entweder:

  • Initialisieren Sie es mit einer vordefinierten Länge:

 func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars } 

  • Oder initialisieren Sie es mit einer Länge von 0 und einer vorgegebenen Kapazität:

 func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars } 

Was ist die beste Option? Der erste ist etwas schneller. Sie können jedoch Letzteres bevorzugen, da es konsistenter ist: Unabhängig davon, ob wir die Anfangsgröße kennen, erfolgt das Hinzufügen eines Elements am Ende des Slice mithilfe von Anhängen.

Kontextverwaltung


context.Context wird von Entwicklern oft missverstanden. Laut offizieller Dokumentation:
Der Kontext enthält die Frist, das Abbruch-Signal und andere Werte über die Grenzen der API hinweg.
Diese Beschreibung ist recht allgemein gehalten und kann den Programmierer verwirren, wie er sie richtig verwendet.

Versuchen wir es herauszufinden. Kontext kann tragen:
  • Frist - bezeichnet entweder die Dauer (z. B. 250 ms) oder die Datums- und Uhrzeit (z. B. 2019-01-08 01:00:00), nach der wir der Ansicht sind, dass die aktuelle Aktion abgebrochen werden muss, wenn sie erreicht ist (E / A-Anforderung) ), Warten auf Kanaleingang usw.).
  • Signal abbrechen (im Grunde <-chan struct {}). Hier ist das Verhalten ähnlich. Sobald wir ein Signal erhalten, müssen wir die aktuelle Arbeit einstellen. Nehmen wir zum Beispiel an, wir erhalten zwei Anfragen. Eine zum Einfügen von Daten und die andere zum Abbrechen der ersten Anforderung (weil sie beispielsweise nicht mehr relevant ist). Dies kann mithilfe des abgebrochenen Kontexts im ersten Anruf erreicht werden, der dann abgebrochen wird, sobald wir die zweite Anfrage erhalten.
  • Schlüssel- / Werteliste (beide basierend auf dem Typ der Schnittstelle {}).

Noch zwei Punkte. Erstens ist der Kontext zusammensetzbar. Daher haben wir möglicherweise einen Kontext, der beispielsweise die Frist und die Schlüssel- / Werteliste enthält. Darüber hinaus können mehrere Goroutinen denselben Kontext verwenden, sodass ein Abbruchsignal möglicherweise mehrere Jobs stoppen kann.

Zurück zu unserem Thema, hier ist ein Fehler, den ich getroffen habe.

Die Go-Anwendung basierte auf urfave / cli (wenn Sie nicht wissen, ist dies eine gute Bibliothek zum Erstellen von Befehlszeilenanwendungen in Go). Nach dem Start erbt der Entwickler eine Art Anwendungskontext. Dies bedeutet, dass die Bibliothek beim Stoppen der Anwendung den Kontext verwendet, um ein Abbruchsignal zu senden.

Ich habe festgestellt, dass dieser Kontext direkt übertragen wurde, beispielsweise wenn ein gRPC-Endpunkt aufgerufen wurde. Das brauchen wir überhaupt nicht.

Stattdessen möchten wir der gRPC-Bibliothek mitteilen: Bitte brechen Sie die Anforderung ab, wenn die Anwendung gestoppt wird oder beispielsweise nach 100 ms.

Um dies zu erreichen, können wir einfach einen zusammengesetzten Kontext erstellen. Wenn parent der Name des Anwendungskontexts ist (erstellt von urfave / cli ), können wir dies einfach tun:

 ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request) 

Kontexte sind nicht so schwer zu verstehen, und meiner Meinung nach ist dies eines der besten Merkmale der Sprache.

Die Option -race wird nicht verwendet


Das Testen einer Go-Anwendung ohne die Option -race ist ein Fehler, auf den ich ständig stoße.

Wie in diesem Artikel beschrieben , leiden wir immer noch stark unter Parallelitätsproblemen, obwohl Go „ entwickelt wurde, um die parallele Programmierung einfacher und weniger fehleranfällig zu machen “.

Offensichtlich hilft der Renndetektor Go bei keinem Problem. Es ist jedoch ein wertvolles Werkzeug, und wir sollten es beim Testen unserer Anwendungen immer einbeziehen.

Dateinamen als Eingabe verwenden


Ein weiterer häufiger Fehler besteht darin, den Dateinamen an eine Funktion zu übergeben.

Angenommen, wir müssen eine Funktion implementieren, um die Anzahl der leeren Zeilen in einer Datei zu zählen. Die natürlichste Implementierung würde ungefähr so ​​aussehen:

 func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil } 

Der Dateiname wird als Eingabe festgelegt, also öffnen wir ihn und implementieren dann unsere Logik, oder?

Nehmen wir nun an, wir möchten diese Funktion mit Unit-Tests abdecken. Wir werden mit einer regulären Datei, einer leeren Datei, einer Datei mit einer anderen Art der Codierung usw. testen. Es kann sehr schwierig sein, sie zu verwalten.

Wenn wir dieselbe Logik beispielsweise für den HTTP-Body implementieren möchten, müssen wir hierfür eine weitere Funktion erstellen.

Go kommt mit zwei großartigen Abstraktionen: io.Reader und io.Writer. Anstatt den Dateinamen zu übergeben, können wir einfach io.Reader übergeben, wodurch die Datenquelle abstrahiert wird.
Ist das eine Datei? HTTP-Body? Byte-Puffer? Es spielt keine Rolle, da wir immer noch dieselbe Lesemethode verwenden.

In unserem Fall können wir Eingaben sogar puffern, um sie Zeile für Zeile zu lesen. Dazu können Sie bufio.Reader und seine ReadLine-Methode verwenden:

 func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } } 

Jetzt wurde die Verantwortung für das Öffnen der Datei an den Zählclient delegiert:

 file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file)) 

In einer zweiten Implementierung kann eine Funktion unabhängig von der tatsächlichen Datenquelle aufgerufen werden. In der Zwischenzeit wird dies unsere Unit-Tests erleichtern, da wir einfach bufio.Reader aus der Zeile erstellen können:

 count, err := count(bufio.NewReader(strings.NewReader("input"))) 

Goroutinen und Zyklusvariablen


Der letzte häufige Fehler, den ich traf, war die Verwendung von Goroutinen mit Schleifenvariablen.

Was wird die Schlussfolgerung des folgenden Beispiels sein?

 ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() } 

1 2 3 zufällig? Nein.

In diesem Beispiel verwendet jede Goroutine dieselbe Instanz einer Variablen, sodass (höchstwahrscheinlich) 3 3 3 ausgegeben wird.

Für dieses Problem gibt es zwei Lösungen. Die erste besteht darin, den Wert der Variablen i an den Abschluss zu übergeben (interne Funktion):

 ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) } 

Die zweite besteht darin, eine weitere Variable innerhalb der for-Schleife zu erstellen:

 ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() } 

Das Zuweisen von i: = i mag etwas seltsam erscheinen, aber dieses Design ist vollkommen gültig. In einer Schleife zu sein bedeutet, in einem anderen Bereich zu sein. Daher erstellt i: = i eine weitere Instanz der Variablen i. Natürlich können wir es aus Gründen der Lesbarkeit mit einem anderen Namen bezeichnen.

Wenn Sie andere häufige Fehler kennen, können Sie diese gerne in den Kommentaren beschreiben.

Source: https://habr.com/ru/post/de461051/


All Articles