Practical Go: Tipps zum Schreiben unterstützter Programme in der realen Welt

Dieser Artikel konzentriert sich auf bewährte Methoden zum Schreiben von Go-Code. Es ist im Präsentationsstil komponiert, jedoch ohne die üblichen Folien. Wir werden versuchen, jeden Punkt kurz und klar durchzugehen.

Zunächst müssen Sie sich darauf einigen, was die Best Practices für eine Programmiersprache bedeuten. Hier können Sie sich an die Worte von Russ Cox, technischer Direktor von Go, erinnern:

Software-Engineering passiert mit der Programmierung, wenn Sie den Zeitfaktor und andere Programmierer hinzufügen.

So unterscheidet Russ zwischen den Konzepten der Programmierung und der Softwareentwicklung . Im ersten Fall schreiben Sie ein Programm für sich selbst, im zweiten erstellen Sie ein Produkt, an dem andere Programmierer im Laufe der Zeit arbeiten werden. Ingenieure kommen und gehen. Teams wachsen oder schrumpfen. Neue Funktionen wurden hinzugefügt und Fehler behoben. Dies ist die Natur der Softwareentwicklung.

Inhalt



1. Grundprinzipien


Ich bin vielleicht einer der ersten Benutzer von Go unter Ihnen, aber dies ist nicht meine persönliche Meinung. Diese Grundprinzipien liegen Go selbst zugrunde:

  1. Einfachheit
  2. Lesbarkeit
  3. Produktivität

Hinweis Bitte beachten Sie, dass ich weder "Leistung" noch "Parallelität" erwähnt habe. Es gibt Sprachen, die schneller als Go sind, aber sie können sicherlich nicht einfach verglichen werden. Es gibt Sprachen, bei denen Parallelität oberste Priorität hat, die jedoch hinsichtlich Lesbarkeit oder Programmierproduktivität nicht verglichen werden können.

Leistung und Parallelität sind wichtige Attribute, aber nicht so wichtig wie Einfachheit, Lesbarkeit und Produktivität.

Einfachheit


„Einfachheit ist Voraussetzung für Zuverlässigkeit“ - Edsger Dijkstra

Warum nach Einfachheit streben? Warum ist es wichtig, dass Go-Programme einfach sind?

Jeder von uns ist auf einen unverständlichen Code gestoßen, oder? Wenn Sie Angst haben, Änderungen vorzunehmen, weil dadurch ein anderer Teil des Programms beschädigt wird, den Sie nicht ganz verstehen und den Sie nicht beheben können. Das ist die Schwierigkeit.

„Es gibt zwei Möglichkeiten, Software zu entwerfen: Die erste besteht darin, sie so einfach zu gestalten, dass keine offensichtlichen Mängel vorliegen, und die zweite darin, sie so komplex zu gestalten, dass keine offensichtlichen Mängel vorliegen. Das erste ist viel schwieriger. ” - C. E. R. Hoar

Komplexität macht zuverlässige Software unzuverlässig. Komplexität ist das, was Softwareprojekte tötet. Einfachheit ist daher das ultimative Ziel von Go. Welche Programme wir auch schreiben, sie sollten einfach sein.

1.2. Lesbarkeit


„Lesbarkeit ist ein wesentlicher Bestandteil der Wartbarkeit“ - Mark Reinhold, JVM-Konferenz, 2018

Warum ist es wichtig, dass der Code lesbar ist? Warum sollten wir uns um Lesbarkeit bemühen?

"Programme sollten für Menschen geschrieben werden, und Maschinen führen sie einfach aus" - Hal Abelson und Gerald Sassman, "Struktur und Interpretation von Computerprogrammen"

Nicht nur Go-Programme, sondern im Allgemeinen wird die gesamte Software von Menschen für Menschen geschrieben. Die Tatsache, dass Maschinen auch Code verarbeiten, ist zweitrangig.

Einmal geschriebener Code wird wiederholt von Menschen gelesen: hunderte, wenn nicht tausende Male.

„Die wichtigste Fähigkeit eines Programmierers ist die Fähigkeit, Ideen effektiv zu kommunizieren.“ - Gaston Horker

Lesbarkeit ist der Schlüssel zum Verständnis der Funktionsweise eines Programms. Wenn Sie den Code nicht verstehen können, wie können Sie ihn pflegen? Wenn die Software nicht unterstützt werden kann, wird sie neu geschrieben. Dies ist möglicherweise das letzte Mal, dass Ihr Unternehmen Go verwendet.

Wenn Sie ein Programm für sich selbst schreiben, tun Sie, was für Sie funktioniert. Wenn dies jedoch Teil eines gemeinsamen Projekts ist oder das Programm lange genug verwendet wird, um die Anforderungen, Funktionen oder die Umgebung, in der es funktioniert, zu ändern, besteht Ihr Ziel darin, das Programm wartbar zu machen.

Der erste Schritt zum Schreiben unterstützter Software besteht darin, sicherzustellen, dass der Code klar ist.

1.3. Produktivität


„Design ist die Kunst, Code so zu organisieren, dass er heute funktioniert, aber immer Veränderungen unterstützt.“ - Sandy Mets

Als letztes Grundprinzip möchte ich die Produktivität des Entwicklers nennen. Dies ist ein großes Thema, aber es kommt auf das Verhältnis an: wie viel Zeit Sie für nützliche Arbeit aufwenden und wie viel - auf eine Antwort von Tools oder hoffnungslose Irrfahrten in einer unverständlichen Codebasis warten. Go-Programmierer sollten das Gefühl haben, dass sie viel Arbeit erledigen können.

Es ist ein Witz, dass die Go-Sprache während des Kompilierens des C ++ - Programms entwickelt wurde. Die schnelle Kompilierung ist ein wichtiges Merkmal von Go und ein Schlüsselfaktor für die Gewinnung neuer Entwickler. Obwohl die Compiler verbessert werden, dauert die Minutenkompilierung in anderen Sprachen im Allgemeinen einige Sekunden. So fühlen sich Go-Entwickler genauso produktiv wie Programmierer in dynamischen Sprachen, jedoch ohne Probleme mit der Zuverlässigkeit dieser Sprachen.

Wenn wir grundlegend über die Produktivität von Entwicklern sprechen, verstehen Go-Programmierer, dass das Lesen von Code wesentlich wichtiger ist als das Schreiben. In dieser Logik geht Go sogar so weit, die Werkzeuge zu verwenden, um den gesamten Code in einem bestimmten Stil zu formatieren. Dies beseitigt die geringste Schwierigkeit, den spezifischen Dialekt eines bestimmten Projekts zu lernen, und hilft, Fehler zu identifizieren, da sie im Vergleich zu normalem Code einfach falsch aussehen .

Go-Programmierer verbringen keine Tage damit, seltsame Kompilierungsfehler, komplexe Build-Skripte oder das Bereitstellen von Code in einer Produktionsumgebung zu debuggen. Und vor allem verschwenden sie keine Zeit damit, zu verstehen, was ein Kollege geschrieben hat.

Wenn Go-Entwickler über Skalierbarkeit sprechen, bedeutet dies Produktivität.

2. Kennungen


Das erste Thema, das wir diskutieren werden - Bezeichner - ist ein Synonym für Namen : Namen von Variablen, Funktionen, Methoden, Typen, Paketen usw.

"Schlechter Name ist ein Symptom für schlechtes Design" - Dave Cheney

Aufgrund der eingeschränkten Syntax von Go haben Objektnamen einen großen Einfluss auf die Programmlesbarkeit. Die Lesbarkeit ist ein Schlüsselfaktor für guten Code, daher ist die Auswahl guter Namen von entscheidender Bedeutung.

2.1. Namenskennungen basieren eher auf Klarheit als auf Kürze


„Es ist wichtig, dass der Code offensichtlich ist. Was Sie in einer Zeile tun können, müssen Sie in drei tun. “ - Ukia Smith

Go ist nicht für knifflige Einzeiler oder die Mindestanzahl von Zeilen in einem Programm optimiert. Wir optimieren weder die Größe des Quellcodes auf der Festplatte noch die Zeit, die zum Eingeben des Programms in den Editor erforderlich ist.

„Ein guter Name ist wie ein guter Witz. Wenn Sie es erklären müssen, ist es nicht mehr lustig. " - Dave Cheney

Der Schlüssel zu maximaler Klarheit sind die Namen, die wir zur Identifizierung von Programmen auswählen. Welche Eigenschaften hat ein guter Name?

  • Ein guter Name ist prägnant . Es muss nicht das kürzeste sein, enthält aber keinen Überschuss. Es hat ein hohes Signal-Rausch-Verhältnis.
  • Ein guter Name ist beschreibend . Es beschreibt die Verwendung einer Variablen oder Konstante, nicht des Inhalts. Ein guter Name beschreibt das Ergebnis einer Funktion oder das Verhalten einer Methode, nicht einer Implementierung. Der Zweck des Pakets, nicht sein Inhalt. Je genauer der Name das Identifizierende beschreibt, desto besser.
  • Ein guter Name ist vorhersehbar . Unter einem Namen müssen Sie verstehen, wie das Objekt verwendet wird. Die Namen sollten beschreibend sein, aber es ist auch wichtig, der Tradition zu folgen. Das ist es, was Go-Programmierer meinen, wenn sie "idiomatisch" sagen.

Lassen Sie uns jede dieser Eigenschaften genauer betrachten.

2.2. ID Länge


Manchmal wird Go's Stil für kurze Variablennamen kritisiert. Wie Rob Pike sagte : "Go-Programmierer wollen Bezeichner mit der richtigen Länge."

Andrew Gerrand bietet längere Kennungen an, um die Wichtigkeit anzuzeigen.

„Je größer der Abstand zwischen der Angabe eines Namens und der Verwendung eines Objekts ist, desto länger sollte der Name sein“ - Andrew Gerrand

Daher können einige Empfehlungen gegeben werden:

  • Kurze Variablennamen sind gut, wenn der Abstand zwischen der Deklaration und der letzten Verwendung gering ist.
  • Lange Variablennamen sollten sich rechtfertigen; Je länger sie sind, desto wichtiger sollten sie sein. Ausführliche Titel enthalten wenig Signal in Bezug auf ihr Gewicht auf der Seite.
  • Fügen Sie den Typnamen nicht in den Variablennamen ein.
  • Konstante Namen sollten den internen Wert beschreiben, nicht wie der Wert verwendet wird.
  • Bevorzugen Sie Einzelbuchstabenvariablen für Schleifen und Verzweigungen, separate Wörter für Parameter und Rückgabewerte, mehrere Wörter für Funktionen und Deklarationen auf Paketebene.
  • Bevorzugen Sie einzelne Wörter für Methoden, Schnittstellen und Pakete.
  • Denken Sie daran, dass der Paketname Teil des Namens ist, den der Aufrufer als Referenz verwendet.

Betrachten Sie ein Beispiel.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

In der zehnten Zeile wird eine Variable des Bereichs p deklariert und ab der nächsten Zeile nur einmal aufgerufen. Das heißt, die Variable lebt für eine sehr kurze Zeit auf der Seite. Wenn der Leser an der Rolle von p im Programm interessiert ist, muss er nur zwei Zeilen lesen.

Zum Vergleich werden people in Funktionsparametern deklariert und sieben Zeilen leben. Das Gleiche gilt für sum und count , daher rechtfertigen sie ihre längeren Namen. Der Leser muss mehr Code scannen, um sie zu finden. Dies rechtfertigt die differenzierteren Namen.

Sie können s für sum und c (oder n ) für count wählen, dies reduziert jedoch die Bedeutung aller Variablen im Programm auf die gleiche Ebene. Sie können people durch p ersetzen, aber es wird ein Problem geben, wie die Iterationsvariable for ... range aufgerufen for ... range . Eine einzelne person sieht seltsam aus, da eine kurzlebige Iterationsvariable einen längeren Namen erhält als mehrere Werte, von denen sie abgeleitet ist.

Tipp . Trennen Sie den Funktionsstrom durch leere Zeilen, da leere Zeilen zwischen Absätzen den Textfluss unterbrechen. In AverageAge haben wir drei aufeinanderfolgende Operationen. Überprüfen Sie zuerst die Division durch Null, dann die Schlussfolgerung des Gesamtalters und der Anzahl der Personen und zuletzt die Berechnung des Durchschnittsalters.

2.2.1. Die Hauptsache ist der Kontext


Es ist wichtig zu verstehen, dass die meisten Benennungstipps kontextspezifisch sind. Ich möchte sagen, dass dies ein Prinzip ist, keine Regel.

Was ist der Unterschied zwischen i und index ? Zum Beispiel können Sie nicht eindeutig sagen, dass ein solcher Code

 for index := 0; index < len(s); index++ { // } 

grundsätzlich besser lesbar als

 for i := 0; i < len(s); i++ { // } 

Ich glaube, dass die zweite Option nicht schlechter ist, da in diesem Fall der Bereich i oder der index durch den Körper der for Schleife begrenzt ist und die zusätzliche Ausführlichkeit wenig zum Verständnis des Programms beiträgt.

Aber welche dieser Funktionen ist besser lesbar?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

oder

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

In diesem Beispiel ist oid eine Abkürzung für SNMP Object ID, und die zusätzliche Abkürzung für o zwingt Sie, beim Lesen von Code von einer dokumentierten zu einer kürzeren Notation im Code zu wechseln. In ähnlicher Weise ist das Verstehen des index auf i schwieriger zu verstehen, da in SNMP-Nachrichten der Unterwert jeder OID als Index bezeichnet wird.

Tipp . Kombinieren Sie keine langen und kurzen formalen Parameter in einer Anzeige.

2.3. Benennen Sie Variablen nicht nach Typ


Sie nennen Ihre Haustiere nicht "Hund" und "Katze", oder? Aus dem gleichen Grund sollten Sie den Typnamen nicht in den Variablennamen aufnehmen. Es sollte den Inhalt beschreiben, nicht seinen Typ. Betrachten Sie ein Beispiel:

 var usersMap map[string]*User 

Was nützt diese Ankündigung? Wir sehen, dass dies eine Karte ist und etwas mit dem *User zu tun hat: Dies ist wahrscheinlich gut. Aber usersMap ist wirklich eine Map, und Go als statisch typisierte Sprache verwendet diesen Namen nicht versehentlich, wenn eine skalare Variable erforderlich ist, sodass das Map Suffix redundant ist.

Stellen Sie sich eine Situation vor, in der andere Variablen hinzugefügt werden:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

Jetzt haben wir drei Variablen der usersMap : usersMap , companiesMap und productsMap , und alle Zeilen werden verschiedenen Typen zugeordnet. Wir wissen, dass dies Karten sind, und wir wissen auch, dass der Compiler einen Fehler auslöst, wenn wir versuchen, companiesMap zu verwenden companiesMap bei dem der Code map[string]*User erwartet. In dieser Situation ist klar, dass das Map Suffix die Klarheit des Codes nicht verbessert. Dies sind nur zusätzliche Zeichen.

Ich schlage vor, Suffixe zu vermeiden, die dem Typ einer Variablen ähneln.

Tipp . Wenn der Name " users " die Essenz nicht klar genug beschreibt, wird auch " usersMap .

Dieser Tipp gilt auch für Funktionsparameter. Zum Beispiel:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

Der config für den Parameter *Config ist redundant. Wir wissen bereits, dass dies *Config , es wird sofort daneben geschrieben.

Betrachten conf in diesem Fall conf oder c wenn die Lebensdauer der Variablen kurz genug ist.

Wenn es in unserer Region irgendwann mehr als eine *Config , sind die Namen conf1 und conf2 weniger aussagekräftig als die original und updated , da letztere schwieriger zu verwechseln sind.

Hinweis Lassen Sie Paketnamen keine guten Variablennamen stehlen.

Der Name des importierten Bezeichners enthält den Namen des Pakets. Beispielsweise wird der context.Context im context als context.Context . Dies macht es unmöglich, eine Variable oder einen Kontexttyp in Ihrem Paket zu verwenden.

 func WriteLog(context context.Context, message string) 

Dies wird nicht kompiliert. Aus diesem Grund werden beim Deklarieren von context.Context lokal verwendet, z. B. Namen wie ctx .

 func WriteLog(ctx context.Context, message string) 

2.4. Verwenden Sie einen einzelnen Namensstil


Eine weitere Eigenschaft eines guten Namens ist, dass er vorhersehbar sein sollte. Der Leser muss es sofort verstehen. Wenn dies ein gebräuchlicher Name ist, hat der Leser das Recht anzunehmen, dass er die Bedeutung gegenüber dem vorherigen Zeitpunkt nicht geändert hat.

Wenn der Code beispielsweise den Datenbankdeskriptor umgeht, sollte er bei jeder Anzeige des Parameters denselben Namen haben. Anstelle aller Arten von Kombinationen wie d *sql.DB , dbase *sql.DB , DB *sql.DB und database *sql.DB es besser, eines zu verwenden:

 db *sql.DB 

Es ist einfacher, den Code zu verstehen. Wenn Sie db , wissen Sie, dass es sich um *sql.DB und dass es lokal deklariert oder vom Aufrufer bereitgestellt wird.

Ähnliche Ratschläge bezüglich der Empfänger einer Methode; Verwenden Sie für jede Methode dieses Typs denselben Empfängernamen. So wird es für den Leser einfacher sein, die Verwendung des Empfängers unter den verschiedenen Methoden dieses Typs zu lernen.

Hinweis Go Recipient Short Name Agreement widerspricht zuvor geäußerten Empfehlungen. Dies ist einer der Fälle, in denen die früh getroffene Auswahl zum Standardstil wird, z. B. die Verwendung von CamelCase anstelle von snake_case .

Tipp . Der Go-Stil verweist auf Einzelbuchstaben oder Abkürzungen für Empfänger, die von ihrem Typ abgeleitet sind. Es kann sich herausstellen, dass der Empfängername manchmal mit dem Parameternamen in der Methode in Konflikt steht. In diesem Fall wird empfohlen, den Parameternamen etwas länger zu machen und nicht zu vergessen, ihn nacheinander zu verwenden.

Schließlich sind einige Ein-Buchstaben-Variablen traditionell mit Schleifen und Zählen verbunden. Zum Beispiel sind i , j und k normalerweise induktive Variablen in for Schleifen, n normalerweise einem Zähler oder einem akkumulativen Addierer zugeordnet, v ist eine typische Abkürzung für den Wert in einer Codierungsfunktion, k normalerweise für einen Kartenschlüssel verwendet und s häufig als Abkürzung für Parameter vom Typ string .

Wie im obigen db Beispiel erwarten Programmierer, dass i eine induktive Variable ist. Wenn sie es im Code sehen, erwarten sie bald eine Schleife.

Tipp . Wenn Sie so viele verschachtelte Schleifen haben, dass Ihnen die Variablen i , j und k , möchten Sie die Funktion möglicherweise in kleinere Einheiten aufteilen.

2.5. Verwenden Sie einen einzelnen Deklarationsstil


Go hat mindestens sechs verschiedene Möglichkeiten, eine Variable zu deklarieren.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

Ich bin sicher, ich habe mich noch nicht an alles erinnert. Go-Entwickler halten dies wahrscheinlich für einen Fehler, aber es ist zu spät, um etwas zu ändern. Wie kann mit dieser Wahl ein einheitlicher Stil sichergestellt werden?

Ich möchte einen Stil für die Deklaration von Variablen vorschlagen, den ich selbst zu verwenden versuche, wo immer dies möglich ist.

  • Verwenden Sie var wenn Sie eine Variable ohne Initialisierung deklarieren .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

    var dient als Hinweis darauf, dass diese Variable absichtlich als Nullwert des angegebenen Typs deklariert wird. Dies steht im Einklang mit der Anforderung, Variablen auf Paketebene mit var zu deklarieren var im Gegensatz zur Syntax für kurze Deklarationen, obwohl ich später argumentieren werde, dass Variablen auf Paketebene überhaupt nicht verwendet werden sollten.
  • Verwenden Sie beim Deklarieren mit der Initialisierung := . Dies macht dem Leser klar, dass die Variable links von := absichtlich initialisiert wird.

    Um zu erklären, warum, schauen wir uns das vorherige Beispiel an, aber dieses Mal initialisieren wir jede Variable speziell:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

Da Go keine automatischen Konvertierungen von einem Typ in einen anderen hat, muss im ersten und dritten Beispiel der Typ auf der linken Seite des Zuweisungsoperators mit dem Typ auf der rechten Seite identisch sein. Der Compiler kann den Typ der deklarierten Variablen aus dem Typ rechts ableiten, sodass das Beispiel präziser geschrieben werden kann:

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

Hier werden players explizit auf 0 initialisiert, was redundant ist, da der Anfangswert von players auf jeden Fall Null ist. Daher ist es besser klar zu machen, dass wir einen Nullwert verwenden möchten:

 var players int 

Was ist mit dem zweiten Operator? Wir können den Typ nicht bestimmen und schreiben

 var things = nil 

Weil nil keinen Typ hat . Stattdessen haben wir die Wahl: oder wir verwenden einen Nullwert, um ...

 var things []Thing 

... oder ein Slice mit null Elementen erstellen?

 var things = make([]Thing, 0) 

Im zweiten Fall ist der Wert für das Slice nicht Null, und wir machen es dem Leser anhand einer kurzen Deklarationsform klar:

 things := make([]Thing, 0) 

Dies sagt dem Leser, dass wir beschlossen haben, die things explizit zu initialisieren.

Also kommen wir zur dritten Erklärung:

 var thing = new(Thing) 

Hier sowohl die explizite Initialisierung der Variablen als auch die Einführung des "eindeutigen" Schlüsselworts new , das einige Go-Programmierer nicht mögen. Die Verwendung der empfohlenen kurzen Syntax ergibt

 thing := new(Thing) 

Dies macht deutlich, dass das thing explizit auf das Ergebnis von new(Thing) initialisiert wird, aber immer noch ein atypisches new hinterlässt. Das Problem könnte mit einem Literal gelöst werden:

 thing := &Thing{} 

Das ist ähnlich wie bei new(Thing) , und eine solche Vervielfältigung stört einige Go-Programmierer. Dies bedeutet jedoch, dass wir das thing explizit mit einem Zeiger auf Thing{} und einem Thing Wert von Null initialisieren.

Es ist jedoch besser, die Tatsache zu berücksichtigen, dass das thing mit einem Nullwert deklariert ist, und die Adresse des Operators zu verwenden, um die Adresse des thing in json.Unmarshall zu übergeben. json.Unmarshall :

 var thing Thing json.Unmarshall(reader, &thing) 

Hinweis Natürlich gibt es Ausnahmen zu jeder Regel. Zum Beispiel sind manchmal zwei Variablen eng miteinander verbunden, so dass es seltsam ist, zu schreiben

 var min int max := 1000 

Lesbarere Erklärung:

 min, max := 0, 1000 

Zusammenfassend:

  • Verwenden Sie beim Deklarieren einer Variablen ohne Initialisierung die var Syntax.
  • Verwenden Sie beim Deklarieren und expliziten Initialisieren einer Variablen := .

Tipp . Weisen Sie explizit auf komplexe Dinge hin.

 var length uint32 = 0x80 

Hier kann die length mit der Bibliothek verwendet werden, für die ein bestimmter numerischer Typ erforderlich ist, und diese Option zeigt deutlicher an, dass die Typlänge speziell als uint32 ausgewählt ist als in der kurzen Deklaration:

 length := uint32(0x80) 

Im ersten Beispiel verstoße ich absichtlich gegen meine Regel, indem ich die var-Deklaration mit expliziter Initialisierung verwende. Eine Abweichung vom Standard lässt den Leser verstehen, dass etwas Ungewöhnliches passiert.

2.6. Arbeite für das Team


Ich habe bereits gesagt, dass die Essenz der Softwareentwicklung die Erstellung von lesbarem, unterstütztem Code ist. Der größte Teil Ihrer Karriere wird wahrscheinlich an gemeinsamen Projekten arbeiten. Mein Rat in dieser Situation: Folgen Sie dem im Team gewählten Stil.

Das Ändern von Stilen in der Mitte der Datei ist ärgerlich. Konsistenz ist wichtig, wenn auch zum Nachteil der persönlichen Präferenz. Meine Faustregel lautet: Wenn der Code durch gofmt passt, ist das Problem normalerweise nicht die Diskussion wert.

Tipp . Wenn Sie die gesamte Codebasis umbenennen möchten, mischen Sie dies nicht mit anderen Änderungen. Wenn jemand Git Bisect verwendet, wird er nicht gerne Tausende von Umbenennungen durchgehen, um einen anderen geänderten Code zu finden.

3. Kommentare


Bevor wir zu wichtigeren Punkten übergehen, möchte ich einige Minuten dauern, um einen Kommentar abzugeben.

„Ein guter Code hat viele Kommentare, und ein schlechter Code braucht viele Kommentare.“ - Dave Thomas und Andrew Hunt, Pragmatic Programmer

Kommentare sind sehr wichtig für die Lesbarkeit des Programms. Jeder Kommentar sollte eines - und nur eines - von drei Dingen tun:

  1. Erklären Sie, was der Code tut.
  2. Erklären Sie, wie er es macht.
  3. Erklären Sie warum .

Die erste Form ist ideal zum Kommentieren öffentlicher Charaktere:

 // Open     . //           . 

Die zweite ist ideal für Kommentare innerhalb einer Methode:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

Die dritte Form („Warum“) ist insofern einzigartig, als sie die ersten beiden nicht ersetzt oder ersetzt. Solche Kommentare erklären die externen Faktoren, die zum Schreiben des Codes in seiner aktuellen Form geführt haben. Ohne diesen Kontext ist es oft schwierig zu verstehen, warum der Code so geschrieben ist.

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

In diesem Beispiel ist möglicherweise nicht sofort klar, was passiert, wenn HealthyPanicThreshold auf null Prozent festgelegt ist. Der Kommentar soll klarstellen, dass ein Wert von 0 die Panikschwelle deaktiviert.

3.1. Kommentare in Variablen und Konstanten sollten ihren Inhalt beschreiben, nicht den Zweck


Ich habe vorhin gesagt, dass der Name einer Variablen oder Konstante ihren Zweck beschreiben sollte. Ein Kommentar zu einer Variablen oder Konstante sollte jedoch genau den Inhalt und nicht den Zweck beschreiben .

 const randomNumber = 6 //     

In diesem Beispiel beschreibt ein Kommentar, warum randomNumber auf 6 gesetzt ist und woher es stammt. Der Kommentar beschreibt nicht, wo randomNumber verwendet wird. Hier sind einige weitere Beispiele:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

Im Zusammenhang mit HTTP wird die Nummer 100 als StatusContinue , wie in RFC 7231, Abschnitt 6.2.1 definiert.

Tipp . Bei Variablen ohne Anfangswert sollte der Kommentar beschreiben, wer für die Initialisierung dieser Variablen verantwortlich ist.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

Hier sagt ein Kommentar dem Leser, dass die dowidth Funktion für die Aufrechterhaltung des Status von sizeCalculationDisabled .

Tipp . In Sichtweite verstecken. Dies ist ein Rat von Kate Gregory . Manchmal ist der beste Name für eine Variable in den Kommentaren versteckt.

 //   SQL var registry = make(map[string]*sql.Driver) 

Der Autor hat einen Kommentar hinzugefügt, weil die Namensregistrierung ihren Zweck nicht ausreichend erklärt - dies ist eine Registrierung, aber was ist die Registrierung?

Wenn Sie eine Variable in sqlDrivers umbenennen, wird deutlich, dass sie SQL-Treiber enthält.

 var sqlDrivers = make(map[string]*sql.Driver) 

Jetzt ist der Kommentar überflüssig geworden und kann gelöscht werden.

3.2. Dokumentieren Sie immer öffentlich verfügbare Zeichen


Die Dokumentation für Ihr Paket wird von godoc generiert. Sie sollten daher jedem im Paket deklarierten öffentlichen Zeichen einen Kommentar hinzufügen: eine Variable, eine Konstante, eine Funktion und eine Methode.

Hier sind zwei Richtlinien aus dem Google Style Guide:

  • Jede öffentliche Funktion, die nicht offensichtlich und prägnant ist, sollte kommentiert werden.
  • Jede Funktion in der Bibliothek sollte kommentiert werden, unabhängig von Länge oder Komplexität.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

Es gibt eine Ausnahme von dieser Regel: Sie müssen keine Methoden dokumentieren, die die Schnittstelle implementieren. Tun Sie dies insbesondere nicht:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

Dieser Kommentar hat nichts zu bedeuten. Er sagt nicht, was die Methode macht: Schlimmer noch, er schickt irgendwohin, um nach Dokumentation zu suchen. In dieser Situation schlage ich vor, den Kommentar vollständig zu löschen.

Hier ist ein Beispiel aus dem io Paket.

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

Beachten Sie, dass der LimitedReader Deklaration unmittelbar die Funktion vorausgeht, die sie verwendet, und dass die LimitedReader.Read Deklaration der Deklaration von LimitedReader selbst folgt. Obwohl LimitedReader.Read selbst nicht dokumentiert ist, kann verstanden werden, dass dies eine Implementierung von io.Reader .

Tipp . Schreiben Sie vor dem Schreiben einer Funktion einen Kommentar, der sie beschreibt. Wenn Sie Schwierigkeiten haben, einen Kommentar zu schreiben, ist dies ein Zeichen dafür, dass der Code, den Sie schreiben möchten, schwer zu verstehen ist.

3.2.1. Kommentieren Sie keinen schlechten Code, schreiben Sie ihn neu


"Kommentieren Sie keinen schlechten Code - schreiben Sie ihn neu" - Brian Kernighan

Es reicht nicht aus, in den Kommentaren die Schwierigkeit des Codefragments anzugeben. Wenn Sie auf einen dieser Kommentare stoßen, sollten Sie ein Ticket mit einer Erinnerung an das Refactoring starten. Sie können mit technischen Schulden leben, solange deren Höhe bekannt ist.

Es ist üblich, Kommentare in der Standardbibliothek im TODO-Stil mit dem Namen des Benutzers zu hinterlassen, der das Problem bemerkt hat.

 // TODO(dfc)  O(N^2),     . 

Dies ist keine Verpflichtung, das Problem zu beheben, aber der angegebene Benutzer ist möglicherweise die beste Person, um eine Frage zu stellen. Andere Projekte begleiten TODO mit einem Datum oder einer Ticketnummer.

3.2.2. Anstatt den Code zu kommentieren, überarbeiten Sie ihn


„Guter Code ist die beste Dokumentation. Wenn Sie einen Kommentar hinzufügen möchten, stellen Sie sich die Frage: "Wie kann der Code verbessert werden, sodass dieser Kommentar nicht benötigt wird?" Refactor und hinterlasse einen Kommentar, um es noch klarer zu machen. “ - Steve McConnell

Funktionen sollten nur eine Aufgabe ausführen. Wenn Sie einen Kommentar schreiben möchten, weil ein Fragment nicht mit dem Rest der Funktion zusammenhängt, sollten Sie es in eine separate Funktion extrahieren.

Kleinere Funktionen sind nicht nur klarer, sondern auch einfacher voneinander zu testen. Wenn Sie den Code in eine separate Funktion isoliert haben, kann sein Name einen Kommentar ersetzen.

4. Paketstruktur


„Schreiben Sie einen bescheidenen Code: Module, die für andere Module nichts Überflüssiges anzeigen und nicht auf die Implementierung anderer Module angewiesen sind“ - Dave Thomas

Jedes Paket ist im Wesentlichen ein separates kleines Go-Programm. Ebenso wie die Implementierung einer Funktion oder Methode für den Aufrufer keine Rolle spielt, spielt die Implementierung der Funktionen, Methoden und Typen, aus denen die öffentliche API Ihres Pakets besteht, keine Rolle.

Ein gutes Go-Paket strebt eine minimale Konnektivität mit anderen Paketen auf Quellcodeebene an, damit Änderungen in einem Paket nicht mit der gesamten Codebasis kaskadiert werden, wenn das Projekt wächst. Solche Situationen behindern Programmierer, die an dieser Codebasis arbeiten, erheblich.

In diesem Abschnitt werden wir uns mit dem Paketdesign befassen, einschließlich seines Namens und Tipps zum Schreiben von Methoden und Funktionen.

4.1. Ein gutes Paket beginnt mit einem guten Namen


Ein gutes Go-Paket beginnt mit einem Qualitätsnamen. Stellen Sie sich das als eine kurze Präsentation vor, die auf nur ein Wort beschränkt ist.

Wie die Variablennamen im vorherigen Abschnitt ist der Paketname sehr wichtig. Sie müssen nicht über die Datentypen in diesem Paket nachdenken. Stellen Sie besser die Frage: "Welchen Service bietet dieses Paket?" Normalerweise lautet die Antwort nicht "Dieses Paket bietet Typ X", sondern "Mit diesem Paket können Sie eine Verbindung über HTTP herstellen."

Tipp . Wählen Sie einen Paketnamen anhand seiner Funktionalität und nicht anhand seines Inhalts.

4.1.1. Gute Paketnamen müssen eindeutig sein


Jedes Paket hat einen eindeutigen Namen im Projekt. Es ist kein Problem, wenn Sie den Rat befolgen, Namen für die Zwecke der Pakete anzugeben. Wenn sich herausstellt, dass die beiden Pakete denselben Namen haben, wahrscheinlich:

  1. Der Paketname ist zu allgemein.
  2. . , .

4.2. base , common util


Ein häufiger Grund für schlechte Namen sind die sogenannten Service-Pakete , bei denen sich im Laufe der Zeit verschiedene Helfer und Service-Codes ansammeln. Da ist es schwierig, dort einen eindeutigen Namen zu finden. Dies führt häufig dazu, dass der Paketname von dem abgeleitet wird, was er enthält : Dienstprogramme.

Namen wie utilsoder helperswerden normalerweise in großen Projekten gefunden, in denen eine tiefe Hierarchie von Paketen verwurzelt ist und Hilfsfunktionen gemeinsam genutzt werden. Wenn Sie eine Funktion in ein neues Paket extrahieren, wird der Import abgebrochen. In diesem Fall spiegelt der Name des Pakets nicht den Zweck des Pakets wider, sondern nur die Tatsache, dass die Importfunktion aufgrund einer nicht ordnungsgemäßen Organisation des Projekts fehlgeschlagen ist.

In solchen Situationen empfehle ich zu analysieren, woher die Pakete aufgerufen werden.utils helpersund verschieben Sie nach Möglichkeit die entsprechenden Funktionen in das aufrufende Paket. Selbst wenn dies das Duplizieren eines Hilfscodes impliziert, ist es besser, als eine Importabhängigkeit zwischen zwei Paketen einzuführen.

"[Ein wenig] Vervielfältigung ist viel billiger als eine falsche Abstraktion" - Sandy Mets

Wenn Dienstprogrammfunktionen an vielen Stellen verwendet werden, ist es besser, anstelle eines monolithischen Pakets mit Dienstprogrammfunktionen mehrere Pakete zu erstellen, die sich jeweils auf einen Aspekt konzentrieren.

Tipp .Verwenden Sie den Plural für Servicepakete. Zum Beispiel stringsfür Dienstprogramme zur Zeichenfolgenverarbeitung.

Pakete mit Namen wie baseoder commonwerden häufig angetroffen, wenn eine bestimmte gemeinsame Funktionalität von zwei oder mehr Implementierungen oder gemeinsamen Typen für einen Client und einen Server in einem separaten Paket zusammengeführt wird. Ich glaube, dass es in solchen Fällen notwendig ist, die Anzahl der Pakete zu reduzieren, indem Client, Server und allgemeiner Code in einem Paket mit einem Namen kombiniert werden, der seiner Funktion entspricht.

Zum Beispiel, net/httpnicht die einzelne Pakete clientund serverstattdessen gibt es Dateien client.gound server.gomit den entsprechenden Datentypen sowie transport.gofür den gesamten Verkehr.

Tipp . Es ist wichtig zu beachten, dass der Bezeichnername den Paketnamen enthält.

  • Eine Funktion Getaus einem Paket net/httpwird zu einem http.GetLink aus einem anderen Paket.
  • Ein Typ Readeraus einem Paket wird stringsbeim Import in andere Pakete umgewandelt strings.Reader.
  • Die Schnittstelle Erroraus dem Paket ist neteindeutig mit Netzwerkfehlern verbunden.

4.3. Komm schnell zurück, ohne tief zu tauchen


Da Go keine Ausnahmen im Kontrollfluss verwendet, müssen Sie nicht tief in den Code eintauchen, um eine Struktur auf oberster Ebene für tryund Blöcke bereitzustellen catch. Anstelle einer mehrstufigen Hierarchie wird der Go-Code im Verlauf der Funktion auf dem Bildschirm angezeigt. Mein Freund Matt Ryer nennt diese Praxis "line of sight" .

Dies wird mit Randoperatoren erreicht : Bedingte Blöcke mit einer Vorbedingung am Eingang der Funktion. Hier ist ein Beispiel aus dem Paket bytes:

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

Beim Aufrufen der Funktion UnreadRunewird der Status überprüft. b.lastReadWenn der vorherige Vorgang nicht ausgeführt wurde ReadRune, wird sofort ein Fehler zurückgegeben. Der Rest der Funktion basiert auf dem, was b.lastReadgrößer als ist opInvalid.

Vergleichen Sie mit derselben Funktion, jedoch ohne den Grenzoperator:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

Der Körper eines wahrscheinlicher erfolgreichen Zweigs ist in die erste Bedingung eingebettet if, und die Bedingung für einen erfolgreichen Ausgang return nilmuss durch sorgfältiges Anpassen der schließenden Klammern ermittelt werden. Die letzte Zeile der Funktion gibt jetzt einen Fehler zurück, und Sie müssen die Ausführung der Funktion in der entsprechenden öffnenden Klammer verfolgen , um herauszufinden, wie Sie zu diesem Punkt gelangen.

Diese Option ist schwerer zu lesen, was die Qualität der Programmierung und der Codeunterstützung beeinträchtigt. Daher bevorzugt Go die Verwendung von Grenzoperatoren und gibt Fehler frühzeitig zurück.

4.4. Machen Sie den Nullwert nützlich


Jede Variablendeklaration wird unter der Annahme, dass kein expliziter Initialisierer vorhanden ist, automatisch mit einem Wert initialisiert, der dem Inhalt des auf Null gesetzten Speichers entspricht, dh Null . Der Wertetyp wird durch eine der folgenden Optionen bestimmt: für numerische Typen - Null, für Zeigertypen - Null, für Slices, Maps und Kanäle gleich.

Die Möglichkeit, immer einen bekannten Standardwert festzulegen, ist wichtig für die Sicherheit und Korrektheit Ihres Programms und kann Ihre Go-Programme einfacher und kompakter machen. Dies ist, was Go-Programmierer denken, wenn sie sagen: "Geben Sie Strukturen einen nützlichen Nullwert."

Stellen Sie sich einen Typ vor sync.Mutex, der zwei ganzzahlige Felder enthält, die den internen Status des Mutex darstellen. Diese Felder sind in jeder Deklaration automatisch null.sync.Mutex. Diese Tatsache wird im Code berücksichtigt, sodass der Typ für die Verwendung ohne explizite Initialisierung geeignet ist.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

Ein weiteres Beispiel für einen Typ mit einem nützlichen Nullwert ist bytes.Buffer. Sie können ohne explizite Initialisierung deklarieren und mit dem Schreiben beginnen.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

Der Nullwert dieser Struktur bedeutet, dass lenbeide capgleich sind 0, und y array, der Zeiger auf den Speicher mit dem Inhalt des Sicherungs-Slice-Arrays, Wert nil. Dies bedeutet, dass Sie nicht explizit schneiden müssen, sondern es einfach deklarieren können.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

Hinweis . var s []stringähnlich den beiden kommentierten Zeilen oben, aber nicht identisch mit ihnen. Es gibt einen Unterschied zwischen einem Slice-Wert von Null und einem Slice-Wert von Null Länge. Der folgende Code gibt false aus.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

Eine nützliche, wenn auch unerwartete Eigenschaft nicht initialisierter Zeigervariablen - Nullzeiger - ist die Fähigkeit, Methoden für Typen aufzurufen, die Null sind. Dies kann verwendet werden, um einfach Standardwerte bereitzustellen.

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5. Vermeiden Sie den Status auf Paketebene


Der Schlüssel zum Schreiben von einfach zu unterstützenden Programmen, die schwach verbunden sind, besteht darin, dass das Ändern eines Pakets eine geringe Wahrscheinlichkeit haben sollte, ein anderes Paket zu beeinflussen, das nicht direkt vom ersten abhängig ist.

Es gibt zwei großartige Möglichkeiten, um eine schwache Konnektivität in Go zu erreichen:

  1. Verwenden Sie Schnittstellen, um das für Funktionen oder Methoden erforderliche Verhalten zu beschreiben.
  2. Vermeiden Sie den globalen Status.

In Go können wir Variablen im Bereich einer Funktion oder Methode sowie im Bereich eines Pakets deklarieren. Wenn eine Variable mit einem Bezeichner mit Großbuchstaben öffentlich verfügbar ist, ist ihr Gültigkeitsbereich für das gesamte Programm global: Jedes Paket sieht zu jeder Zeit den Typ und den Inhalt dieser Variablen.

Der veränderbare globale Zustand stellt eine enge Beziehung zwischen den unabhängigen Teilen des Programms her, da globale Variablen zu einem unsichtbaren Parameter für jede Funktion im Programm werden! Jede Funktion, die auf einer globalen Variablen beruht, kann verletzt werden, wenn sich der Typ dieser Variablen ändert. Jede Funktion, die vom Status einer globalen Variablen abhängt, kann verletzt werden, wenn ein anderer Teil des Programms diese Variable ändert.

So reduzieren Sie die Konnektivität, die eine globale Variable erzeugt:

  1. Verschieben Sie die entsprechenden Variablen als Felder in die Strukturen, die sie benötigen.
  2. Verwenden Sie Schnittstellen, um die Verbindung zwischen dem Verhalten und der Implementierung dieses Verhaltens zu verringern.

5. Projektstruktur


Lassen Sie uns darüber sprechen, wie Pakete zu einem Projekt kombiniert werden. Dies ist normalerweise ein einzelnes Git-Repository.

Wie das Paket sollte jedes Projekt ein klares Ziel haben. Wenn es sich um eine Bibliothek handelt, muss sie eine Aufgabe ausführen, z. B. XML-Analyse oder Journaling. Sie sollten nicht mehrere Ziele in einem Projekt kombinieren, um eine unheimliche Bibliothek zu vermeiden common.

Tipp .Nach meiner Erfahrung ist das Repository commonletztendlich eng mit dem größten Konsumenten verbunden, und dies macht es schwierig, Korrekturen an früheren Versionen (Back-Port-Korrekturen) vorzunehmen, ohne sowohl commonden Konsumenten als auch den Konsumenten in der Blockierungsphase zu aktualisieren , was zu vielen nicht verwandten Änderungen führt und sie auf dem Weg brechen API

Wenn Sie eine Anwendung haben (Webanwendung, Kubernetes-Controller usw.), enthält das Projekt möglicherweise ein oder mehrere Hauptpakete. In meinem Kubernetes-Controller gibt es beispielsweise ein Paket cmd/contour, das als Server dient, der in einem Kubernetes-Cluster bereitgestellt wird, und als Debug-Client.

5.1. Weniger Pakete, aber größer


Bei der Codeüberprüfung habe ich einen der typischen Fehler von Programmierern festgestellt, die aus anderen Sprachen zu Go gewechselt sind: Sie neigen dazu, Pakete zu missbrauchen.

Gehen Sie nicht das ausgeklügelte System von Sichtbarkeit: die Sprache Modifikatoren wie in der Java nicht genug Zugang ist ( public, protected, privateund implizit default). Es gibt kein Analogon für freundliche Klassen aus C ++.

In Go haben wir nur zwei Zugriffsmodifikatoren: Dies sind öffentliche und private Bezeichner, die durch den ersten Buchstaben des Bezeichners (Groß- / Kleinschreibung) angegeben werden. Wenn der Bezeichner öffentlich ist, beginnt sein Name mit einem Großbuchstaben und kann von jedem anderen Go-Paket referenziert werden.

Hinweis . Sie konnten die Wörter "exportiert" oder "nicht exportiert" als Synonyme für öffentlich und privat hören.

Welche Methoden können angesichts der eingeschränkten Zugriffssteuerungsfunktionen verwendet werden, um zu komplexe Pakethierarchien zu vermeiden?

Tipp .In jedem Paket muss zusätzlich zu cmd/und internal/Quellcode vorhanden sein.

Ich habe wiederholt gesagt, dass es besser ist, weniger größere Pakete zu bevorzugen. Ihre Standardposition sollte sein, kein neues Paket zu erstellen. Dies führt dazu, dass zu viele Typen öffentlich werden, wodurch ein breiter und kleiner Bereich der verfügbaren API erstellt wird. Nachfolgend betrachten wir diese These ausführlicher.

Tipp .Kam aus Java?

Wenn Sie aus der Java- oder C # -Welt stammen, beachten Sie die unausgesprochene Regel: Ein Java-Paket entspricht einer einzelnen Quelldatei .go. Das Go-Paket entspricht dem gesamten Maven-Modul oder der gesamten .NET-Assembly.

5.1.1. Sortieren des Codes nach Datei mithilfe von Importanweisungen


Wenn Sie Pakete nach Service organisieren, sollten Sie dasselbe für die Dateien im Paket tun? Woher wissen, wann eine Datei .goin mehrere aufgeteilt werden muss? Woher wissen Sie, ob Sie zu weit gegangen sind und über das Zusammenführen von Dateien nachdenken müssen?

Hier sind die Empfehlungen, die ich verwende:

  • Starten Sie jedes Paket mit einer Datei .go. Geben Sie dieser Datei den gleichen Namen wie dem Verzeichnis. Das Paket httpsollte sich beispielsweise in einer Datei http.goin einem Verzeichnis befinden http.
  • Wenn das Paket wächst, können Sie die verschiedenen Funktionen in mehrere Dateien aufteilen. Zum Beispiel kann die Datei messages.goenthält die Typen Requestund ResponseDateitypclient.go - Client, Datei server.go- Servertyp.
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

Tipp . .

. Go . ( — Go). .

5.1.2.


Das Tool gounterstützt das Paket testingan zwei Stellen. Wenn Sie ein Paket haben http2, können Sie eine Datei schreiben http2_test.gound die Paketdeklaration verwenden http2. Es kompiliert den Code http2_test.go, wie es Teil des Pakets ist http2. In der Umgangssprache wird ein solcher Test als intern bezeichnet.

Das Werkzeug gounterstützt auch ein spezielles Paket Erklärung, die an den Enden Test , dh http_test. Auf diese Weise können die Testdateien im selben Paket wie der Code gespeichert werden. Wenn solche Tests jedoch kompiliert werden, sind sie nicht Teil des Codes Ihres Pakets, sondern befinden sich in einem eigenen Paket. Auf diese Weise können Sie Tests so schreiben, als würde ein anderes Paket Ihren Code aufrufen. Solche Tests werden als extern bezeichnet.

Ich empfehle die Verwendung interner Tests für Unit-Unit-Tests. Auf diese Weise können Sie jede Funktion oder Methode direkt testen und so die Bürokratie externer Tests vermeiden.

Es ist jedoch erforderlich , Beispiele für Testfunktionen ( Example) in einer externen Testdatei abzulegen . Dies stellt sicher, dass die Beispiele bei Betrachtung in godoc das entsprechende Paketpräfix erhalten und einfach kopiert werden können.

Tipp . , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


Wenn Ihr Projekt mehrere Pakete enthält, finden Sie möglicherweise exportierte Funktionen, die von anderen Paketen verwendet werden sollen, jedoch nicht für die öffentliche API. In einer solchen Situation goerkennt das Tool einen speziellen Ordnernamen internal/, mit dem Code platziert werden kann, der für Ihr Projekt geöffnet, für andere jedoch geschlossen ist.

Um ein solches Paket zu erstellen, platzieren Sie es in einem Verzeichnis mit einem Namen internal/oder in seinem Unterverzeichnis. Wenn das Team goden Import des Pakets mit dem Pfad sieht internal, überprüft es den Speicherort des aufrufenden Pakets in einem Verzeichnis oder Unterverzeichnis internal/.

Beispielsweise kann ein Paket .../a/b/c/internal/d/e/fnur ein Paket aus einem Verzeichnisbaum importieren .../a/b/c, jedoch überhaupt nicht .../a/b/goder ein anderes Repository (sieheDokumentation ).

5.2. Das kleinste Hauptpaket


Eine Funktion mainund ein Paket mainmüssen nur über minimale Funktionen verfügen, da sie sich main.mainwie ein Singleton verhalten: Ein Programm kann nur eine Funktion haben main, einschließlich Tests.

Da es sich main.mainum ein Singleton handelt, gibt es viele Einschränkungen für aufgerufene Objekte: Sie werden nur während main.mainoder main.initund nur einmal aufgerufen . Dies erschwert das Schreiben von Codetests main.main. Daher müssen Sie sich bemühen, so viel Logik wie möglich aus der Hauptfunktion und im Idealfall aus dem Hauptpaket abzuleiten.

Tipp . func main() muss Flags analysieren, Verbindungen zu Datenbanken, Loggern usw. öffnen und dann die Ausführung auf ein übergeordnetes Objekt übertragen.

6. API-Struktur


Der letzte Design-Rat für das Projekt halte ich für den wichtigsten.

Alle vorhergehenden Sätze sind grundsätzlich unverbindlich. Dies sind nur Empfehlungen, die auf persönlichen Erfahrungen beruhen. Ich drücke diese Empfehlungen nicht zu sehr in eine Codeüberprüfung ein.

Die API ist eine andere Sache, hier nehmen wir die Fehler ernster, weil alles andere behoben werden kann, ohne die Abwärtskompatibilität zu beeinträchtigen: Zum größten Teil sind dies nur Implementierungsdetails.

Wenn es um öffentliche APIs geht, lohnt es sich, die Struktur von Anfang an ernsthaft zu betrachten, da nachfolgende Änderungen für die Benutzer destruktiv sind.

6.1. Design-APIs, die vom Design her schwer zu missbrauchen sind


"APIs müssen für die ordnungsgemäße Verwendung einfach und für die falsche schwierig sein" - Josh Bloch

Josh Blochs Rat ist vielleicht der wertvollste in diesem Artikel. Wenn es schwierig ist, die API für einfache Dinge zu verwenden, ist jeder API-Aufruf komplizierter als nötig. Wenn ein API-Aufruf komplex und nicht offensichtlich ist, wird er wahrscheinlich übersehen.

6.1.1. Seien Sie vorsichtig mit Funktionen, die mehrere Parameter desselben Typs akzeptieren.


Ein gutes Beispiel für eine auf den ersten Blick einfache, aber schwierig zu verwendende API ist, wenn zwei oder mehr Parameter desselben Typs erforderlich sind. Vergleichen Sie zwei Funktionssignaturen:

 func Max(a, b int) int func CopyFile(to, from string) error 

Was ist der Unterschied zwischen diesen beiden Funktionen? Offensichtlich gibt einer maximal zwei Zahlen zurück und der andere kopiert die Datei. Aber das ist nicht der Punkt.

 Max(8, 10) // 10 Max(10, 8) // 10 

Max ist kommutativ : Die Reihenfolge der Parameter spielt keine Rolle. Maximal acht und zehn sind zehn, unabhängig davon, ob acht und zehn oder zehn und acht verglichen werden.

Bei CopyFile ist dies jedoch nicht der Fall.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

Welcher dieser Operatoren sichert Ihre Präsentation und welcher überschreibt sie mit der Version der letzten Woche? Sie können nicht sagen, bis Sie die Dokumentation überprüfen. Im Verlauf der Codeüberprüfung ist unklar, ob die Reihenfolge der Argumente korrekt ist oder nicht. Schauen Sie sich noch einmal die Dokumentation an.

Eine mögliche Lösung besteht darin, einen Hilfstyp einzuführen, der für den korrekten Anruf verantwortlich ist CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

Es wird hier CopyFileimmer richtig aufgerufen - dies kann mit einem Unit-Test festgestellt werden - und kann privat durchgeführt werden, was die Wahrscheinlichkeit einer falschen Verwendung weiter verringert.

Tipp . Eine API mit mehreren Parametern desselben Typs ist schwer korrekt zu verwenden.

6.2. Entwerfen Sie eine API für einen grundlegenden Anwendungsfall


Vor einigen Jahren hielt ich eine Präsentation über die Verwendung von Funktionsoptionen , um die API standardmäßig zu vereinfachen.

Das Wesentliche der Präsentation war, dass Sie eine API für den Hauptanwendungsfall entwickeln sollten. Mit anderen Worten, die API sollte vom Benutzer nicht verlangen, zusätzliche Parameter anzugeben, die ihn nicht interessieren.

6.2.1. Die Verwendung von nil als Parameter wird nicht empfohlen


Ich habe zunächst gesagt, dass Sie den Benutzer nicht zwingen sollten, API-Parameter anzugeben, die ihn nicht interessieren. Dies bedeutet , dass die APIs für den Hauptanwendungsfall entworfen werden (Standardoption).

Hier ist ein Beispiel aus dem net / http-Paket.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServeakzeptiert zwei Parameter: eine TCP-Adresse zum Abhören eingehender Verbindungen und http.Handlerzum Verarbeiten einer eingehenden HTTP-Anforderung. Serveermöglicht den zweiten Parameter zu sein nil. In den Kommentaren wird darauf hingewiesen, dass das aufrufende Objekt normalerweise tatsächlich übergeben wird nil, was auf den Wunsch hinweist, es http.DefaultServeMuxals impliziten Parameter zu verwenden.

Jetzt hat der Anrufer Servezwei Möglichkeiten, dasselbe zu tun.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Beide Optionen machen dasselbe.

Diese Anwendung nilverbreitet sich wie ein Virus. Das Paket httphat auch einen Helfer http.Serve, so dass Sie sich die Struktur der Funktion vorstellen können ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

Da ListenAndServeder Aufrufer nilden zweiten Parameter übergeben kann, wird http.Servedieses Verhalten ebenfalls unterstützt. In der Tat ist es in der http.ServeLogik implementiert "wenn der Handler gleich ist nil, verwenden DefaultServeMux". Die Akzeptanz nileines Parameters kann den Aufrufer zu der Annahme veranlassen, dass er nilfür beide Parameter übergeben werden kann. Aber soServe

 http.Serve(nil, nil) 

führt zu einer schrecklichen Panik.

Tipp .Mischen Sie keine Parameter in derselben Funktionssignatur nilund nicht nil.

Der Autor hat http.ListenAndServeversucht, das Leben der API-Benutzer für den Standardfall zu vereinfachen, die Sicherheit war jedoch beeinträchtigt.

In der Gegenwart nilgibt es keinen Unterschied in der Anzahl der Zeilen zwischen expliziter und indirekter Verwendung DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

im Vergleich zu

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

War es die Verwirrung wert, eine Zeile zu halten?

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

Tipp .Überlegen Sie ernsthaft, wie viel Zeit die Hilfsfunktionen dem Programmierer ersparen. Klarheit ist besser als Kürze.

Tipp .Vermeiden Sie öffentliche APIs mit Parametern, die nur Tests benötigen. Vermeiden Sie den Export von APIs mit Parametern, deren Werte sich nur während des Tests unterscheiden. Exportieren Sie stattdessen Wrapper-Funktionen, die die Übertragung solcher Parameter verbergen, und verwenden Sie in Tests ähnliche Hilfsfunktionen, die die für den Test erforderlichen Werte übergeben.

6.2.2. Verwenden Sie Argumente mit variabler Länge anstelle von [] T.


Sehr oft nimmt eine Funktion oder Methode einen Werteschnitt an.

 func ShutdownVMs(ids []string) error 

Dies ist nur ein erfundenes Beispiel, aber dies ist sehr häufig. Das Problem ist, dass diese Signaturen davon ausgehen, dass sie mit mehr als einem Datensatz aufgerufen werden. Wie die Erfahrung zeigt, werden sie häufig mit nur einem Argument aufgerufen, das in das Slice „gepackt“ werden muss, um die Anforderungen der Funktionssignatur zu erfüllen.

Da der Parameter idsein Slice ist, können Sie außerdem ein leeres Slice oder eine Null an die Funktion übergeben, und der Compiler wird sich freuen. Dies erhöht die Testlast zusätzlich, da die Tests solche Fälle abdecken sollten.

Um ein Beispiel für eine solche API-Klasse zu geben, habe ich kürzlich die Logik überarbeitet, die die Installation einiger zusätzlicher Felder erforderte, wenn mindestens einer der Parameter ungleich Null war. Die Logik sah ungefähr so ​​aus:

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

Da der Operator ifsehr lang wurde, wollte ich die Validierungslogik in eine separate Funktion ziehen. Folgendes habe ich mir ausgedacht:

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

Dies ermöglichte es, die Bedingung, unter der das Innengerät ausgeführt wird, klar anzugeben:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

Es gibt jedoch ein Problem mit anyPositive, jemand könnte es versehentlich so nennen:

 if anyPositive() { ... } 

In diesem Fall anyPositivewird zurückkehren false. Dies ist nicht die schlechteste Option. Schlimmer noch, wenn die anyPositiveRückkehr truein Abwesenheit von Argumenten.

Es ist jedoch besser, die Signatur von anyPositive ändern zu können, um sicherzustellen, dass mindestens ein Argument an den Aufrufer übergeben wird. Dies kann durch Kombinieren von Parametern für normale Argumente und Argumente variabler Länge (varargs) erfolgen:

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

Jetzt können anyPositiveSie nicht mit weniger als einem Argument aufrufen.

6.3. Lassen Sie die Funktionen das gewünschte Verhalten bestimmen.


Angenommen, ich habe die Aufgabe erhalten, eine Funktion zu schreiben, die die Struktur Documentauf der Festplatte beibehält.

 // Save      f. func Save(f *os.File, doc *Document) error 

Ich könnte eine Funktion schreiben Save, die Documentin eine Datei schreibt *os.File. Es gibt jedoch einige Probleme.

Die Signatur verhindert Savedie Möglichkeit, Daten über das Netzwerk aufzuzeichnen. Wenn eine solche Anforderung in Zukunft auftritt, muss die Signatur der Funktion geändert werden, was sich auf alle aufrufenden Objekte auswirkt.

Saveauch unangenehm zu testen, da es direkt mit Dateien auf der Festplatte funktioniert. Um den Betrieb zu überprüfen, muss der Test daher den Inhalt der Datei nach dem Schreiben lesen.

Und ich muss sicherstellen, dass es fin einen temporären Ordner geschrieben und anschließend gelöscht wird.

*os.Filedefiniert auch viele Methoden, die sich nicht auf das SaveLesen von Verzeichnissen und das Überprüfen, ob ein Pfad eine symbolische Verknüpfung ist, beziehen . Na wenn die UnterschriftSavebeschrieb nur die relevanten Teile *os.File.

Was kann getan werden?

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

Mithilfe io.ReadWriteCloserdieser können Sie das Prinzip der Schnittstellentrennung anwenden - und es Saveauf einer Schnittstelle neu definieren , die die allgemeineren Eigenschaften der Datei beschreibt.

Nach einer solchen Änderung kann jeder Typ, der die Schnittstelle implementiert io.ReadWriteCloser, durch den vorherigen ersetzt werden *os.File.

Dies erweitert gleichzeitig den Bereich Saveund verdeutlicht dem Aufrufer, welche Typmethoden *os.Filemit seiner Operation zusammenhängen.

Und der Autor Savekann diese nicht verwandten Methoden nicht mehr aufrufen *os.File, weil er sich hinter der Schnittstelle versteckt io.ReadWriteCloser.

Wir können das Prinzip der Schnittstellentrennung jedoch noch weiter ausbauen.

Erstens wennSave Nach dem Prinzip der Einzelverantwortung ist es unwahrscheinlich, dass er die Datei, die er gerade geschrieben hat, liest, um ihren Inhalt zu überprüfen - anderer Code sollte dies tun.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

Daher können Sie die Spezifikationen der Schnittstelle eingrenzen, um Savenur zu schreiben und zu schließen.

Zweitens ist der Thread-Schließmechanismus y Saveein Erbe der Zeit, als er mit der Datei arbeitete. Die Frage ist, unter welchen Umständen wces geschlossen wird.

Ob die SaveUrsache Closeunbedingt, ob im Fall des Erfolgs.

Dies stellt ein Problem für den Anrufer dar, da er möglicherweise Daten zum Stream hinzufügen möchte, nachdem das Dokument geschrieben wurde.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

Die beste Option ist, Save neu zu definieren, um nur mit zu arbeiten io.Writer, und den Operator vor allen anderen Funktionen zu schützen, außer dem Schreiben von Daten in den Stream.

Nach der Anwendung des Prinzips der Schnittstellentrennung wurde die Funktion gleichzeitig sowohl spezifischer in Bezug auf die Anforderungen (sie benötigt nur ein Objekt, in das sie geschrieben werden kann) als auch allgemeiner in Bezug auf die Funktionalität, da wir sie jetzt verwenden können Save, um Daten dort zu speichern, wo sie implementiert sind io.Writer.

7. Fehlerbehandlung


Ich habe mehrere Präsentationen und viel geschrieben zu diesem Thema im Blog, also werde ich nicht wiederholen.

Stattdessen möchte ich zwei weitere Bereiche im Zusammenhang mit der Fehlerbehandlung behandeln.

7.1. Beseitigen Sie die Notwendigkeit der Fehlerbehandlung, indem Sie die Fehler selbst entfernen


Ich habe viele Vorschläge zur Verbesserung der Fehlerbehandlungssyntax gemacht, aber die beste Option ist, sie überhaupt nicht zu behandeln.

Hinweis . Ich sage nicht "Fehlerbehandlung löschen". Ich schlage vor, den Code so zu ändern, dass keine Fehler bei der Verarbeitung auftreten.

John Osterhouts jüngstes Buch zur Philosophie der Softwareentwicklung hat mich zu diesem Vorschlag inspiriert . Eines der Kapitel trägt den Titel „Fehler aus der Realität entfernen“. Versuchen wir, diesen Rat anzuwenden.

7.1.1. Zeilenanzahl


Wir werden eine Funktion schreiben, um die Anzahl der Zeilen in einer Datei zu zählen.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

Da wir den Ratschlägen aus den vorhergehenden Abschnitten folgen, CountLinesakzeptiert io.Reader, nicht *os.File; Es ist bereits Aufgabe des Anrufers, anzugeben, io.Readerwessen Inhalt wir zählen möchten.

Wir erstellen bufio.Readerdie Methode und rufen sie dann in einer Schleife auf ReadString, wobei wir den Zähler erhöhen, bis wir das Ende der Datei erreichen. Dann geben wir die Anzahl der gelesenen Zeilen zurück.

Zumindest wollen wir solchen Code schreiben, aber die Funktion ist mit Fehlerbehandlung belastet. Zum Beispiel gibt es so eine seltsame Konstruktion:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

Wir erhöhen die Anzahl der Zeilen, bevor wir nach Fehlern suchen - das sieht seltsam aus.

Der Grund, warum wir es so schreiben sollten, ist, dass es ReadStringeinen Fehler zurückgibt, wenn es das Ende der Datei früher als das Zeilenumbruchzeichen findet. Dies kann passieren, wenn am Ende der Datei keine neue Zeile steht.

Um dies zu beheben, ändern Sie die Logik des Zeilenzählers und prüfen Sie, ob die Schleife verlassen werden muss.

Hinweis . Diese Logik ist immer noch nicht perfekt. Können Sie einen Fehler finden?

Wir haben jedoch noch nicht nach Fehlern gesucht. ReadStringwird zurückgegeben, io.EOFwenn das Ende der Datei erreicht ist. Dies ist die erwartete Situation. ReadStringSie müssen also auf irgendeine Weise sagen: "Stopp, es gibt nichts mehr zu lesen." Bevor Sie den Fehler an das aufrufende Objekt zurückgeben CountLine, müssen Sie daher überprüfen, ob der Fehler nicht mit dem Fehler zusammenhängt io.EOF, und ihn dann weiterleiten. Andernfalls kehren wir zurück nilund sagen, dass alles in Ordnung ist.

Ich denke, dies ist ein gutes Beispiel für Russ Cox 'These, wie die Fehlerbehandlung die Funktion verbergen kann. Schauen wir uns die verbesserte Version an.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

Diese verbesserte Version verwendet bufio.Scannerstattdessen bufio.Reader.

Unter der Haube bufio.Scannerverwendet bufio.Reader, fügt aber eine gute Abstraktionsebene hinzu, die zur Beseitigung der Fehlerbehandlung beiträgt.

. bufio.Scanner , .

Die Methode sc.Scan()gibt einen Wert zurück, truewenn der Scanner auf eine Zeichenfolge gestoßen ist und keinen Fehler gefunden hat. Daher wird der Schleifenkörper fornur aufgerufen, wenn sich eine Textzeile im Scannerpuffer befindet. Dies bedeutet, dass der neue CountLinesFälle behandelt, wenn keine neue Zeile vorhanden ist oder wenn die Datei leer ist.

Zweitens endet der Zyklus , da er sc.Scanzurückkehrt, falsewenn ein Fehler erkannt wird, forwenn er das Ende der Datei erreicht oder ein Fehler erkannt wird. Der Typ bufio.Scannermerkt sich den ersten aufgetretenen Fehler. Mit der Methode können sc.Err()wir diesen Fehler wiederherstellen, sobald wir die Schleife verlassen.

Schließlich sc.Err()kümmert es sich um die Verarbeitung io.EOFund konvertiert sie, nilwenn das Ende der Datei fehlerfrei erreicht ist.

Tipp . Wenn Sie auf eine übermäßige Fehlerbehandlung stoßen, versuchen Sie, einige Vorgänge in einen Hilfstyp zu extrahieren.

7.1.2. Antwort des Autors


Mein zweites Beispiel wird per Post inspiriert „Errors - diesen Wert“ .

Zuvor haben wir Beispiele dafür gesehen, wie eine Datei geöffnet, geschrieben und geschlossen wird. Es gibt eine Fehlerbehandlung, die jedoch nicht zu umfangreich ist, da Vorgänge in Hilfsprogrammen wie ioutil.ReadFileund gekapselt werden können ioutil.WriteFile. Bei der Arbeit mit Netzwerkprotokollen auf niedriger Ebene muss jedoch eine Antwort direkt mithilfe von E / A-Grundelementen erstellt werden. In diesem Fall kann die Fehlerbehandlung aufdringlich werden. Stellen Sie sich ein Fragment eines HTTP-Servers vor, das eine HTTP-Antwort erstellt.

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

Erstellen Sie zunächst die Statusleiste mit fmt.Fprintfund überprüfen Sie den Fehler. Dann schreiben wir für jede Überschrift einen Schlüssel und einen Überschriftenwert, wobei jedes Mal ein Fehler überprüft wird. Schließlich vervollständigen wir den Header-Abschnitt mit einem zusätzlichen \r\n, überprüfen den Fehler und kopieren den Antworttext auf den Client. Obwohl wir den Fehler nicht überprüfen müssen io.Copy, müssen wir ihn von zwei Rückgabewerten in den einzigen zurückgeben, der zurückgibt WriteResponse.

Das ist viel eintönige Arbeit. Sie können Ihre Aufgabe jedoch vereinfachen, indem Sie einen kleinen Wrapper anwenden errWriter.

errWritererfüllt den Vertrag io.Writer, so dass es als Wrapper verwendet werden kann. errWriterleitet Datensätze durch die Funktion, bis ein Fehler erkannt wird. In diesem Fall werden die Einträge zurückgewiesen und der vorherige Fehler zurückgegeben.

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

Wenn Sie sich bewerben , errWriterum WriteResponseden Code Klarheit deutlich verbessert. Sie müssen nicht mehr bei jedem einzelnen Vorgang nach Fehlern suchen. Die Fehlermeldung wird als ew.errFeldprüfung an das Ende der Funktion verschoben, um die störende Übersetzung der zurückgegebenen io.Copy-Werte zu vermeiden.

7.2. Behandeln Sie den Fehler nur einmal


Abschließend möchte ich darauf hinweisen, dass Fehler nur einmal behandelt werden sollten. Verarbeitung bedeutet, die Bedeutung des Fehlers zu überprüfen und eine einzige Entscheidung zu treffen.

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

Wenn Sie weniger als eine Entscheidung treffen, ignorieren Sie den Fehler. Wie wir hier sehen, wird der Fehler von w.WriteAllignoriert.

Es ist aber auch falsch , mehr als eine Entscheidung als Reaktion auf einen Fehler zu treffen. Unten ist der Code, auf den ich oft stoße.

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

Wenn in diesem Beispiel während der Zeit ein Fehler auftritt w.Write, wird die Zeile in das Protokoll geschrieben und auch an das aufrufende Objekt zurückgegeben, das es möglicherweise auch protokolliert und an die oberste Ebene des Programms weiterleitet.

Höchstwahrscheinlich macht der Anrufer dasselbe:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Somit wird ein Stapel sich wiederholender Zeilen im Protokoll erstellt.

 unable to write: io.EOF could not write config: io.EOF 

Aber oben im Programm erhalten Sie einen ursprünglichen Fehler ohne Kontext.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

Ich möchte dieses Thema genauer analysieren, da ich das Problem der gleichzeitigen Rückgabe eines Fehlers und der Protokollierung meiner persönlichen Einstellungen nicht in Betracht ziehe.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Ich stoße oft auf ein Problem, das ein Programmierer vergisst, um von einem Fehler zurückzukehren. Wie bereits erwähnt, besteht der Stil von Go darin, Grenzoperatoren zu verwenden, die Voraussetzungen bei der Ausführung der Funktion zu überprüfen und frühzeitig zurückzukehren.

In diesem Beispiel hat der Autor den Fehler überprüft, registriert, aber vergessen , zurückzukehren. Aus diesem Grund entsteht ein subtiles Problem.

Der Go-Fehlerbehandlungsvertrag besagt, dass bei Vorliegen eines Fehlers keine Annahmen über den Inhalt anderer Rückgabewerte getroffen werden können. Da das JSON-Marshalling fehlgeschlagen ist, ist der Inhalt bufunbekannt: Es enthält möglicherweise nichts, aber schlimmer noch, es enthält möglicherweise ein halb geschriebenes JSON-Fragment.

Da der Programmierer nach Überprüfung und Registrierung des Fehlers vergessen hat, zurückzukehren, wird der beschädigte Puffer übertragen WriteAll. Der Vorgang ist wahrscheinlich erfolgreich und daher wird die Konfigurationsdatei nicht korrekt geschrieben. Die Funktion wird jedoch normal ausgeführt, und das einzige Anzeichen dafür, dass ein Problem aufgetreten ist, ist eine Zeile im Protokoll, in der das JSON-Marshalling fehlgeschlagen ist, und kein Fehler im Konfigurationsdatensatz.

7.2.1. Hinzufügen von Kontext zu Fehlern


Ein Fehler ist aufgetreten, weil der Autor versucht hat, der Fehlermeldung einen Kontext hinzuzufügen. Er versuchte, eine Markierung zu hinterlassen, um die Fehlerquelle anzugeben.

Schauen wir uns einen anderen Weg an, um dasselbe zu tun fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

Wenn Sie den Fehlerdatensatz mit der Rückgabe in einer Zeile kombinieren, ist es schwieriger, die Rückgabe zu vergessen und eine versehentliche Fortsetzung zu vermeiden.

Wenn beim Schreiben der Datei ein E / A-Fehler auftritt, erzeugt die Methode Error()Folgendes:

 could not write config: write failed: input/output error 

7.2.2. Fehler beim Umschließen mit github.com/pkg/errors


Die Vorlage fmt.Errorffunktioniert gut für die Aufzeichnung von Nachrichten Fehlern, aber die Art des Fehlers geht auf der Strecke. Ich habe argumentiert, dass die Behandlung von Fehlern als undurchsichtige Werte für lose gekoppelte Projekte wichtig ist. Daher sollte die Art des Quellfehlers keine Rolle spielen, wenn wir nur mit seinem Wert arbeiten müssen:

  1. Stellen Sie sicher, dass es nicht Null ist.
  2. Zeigen Sie es auf dem Bildschirm an oder protokollieren Sie es.

Es kommt jedoch vor, dass Sie den ursprünglichen Fehler wiederherstellen müssen. Um solche Fehler zu kommentieren, können Sie so etwas wie mein Paket verwenden errors:

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

Jetzt wird die Nachricht zu einem netten Fehler im K & D-Stil:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

und sein Wert enthält einen Link zum ursprünglichen Grund.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

Auf diese Weise können Sie den ursprünglichen Fehler wiederherstellen und die Stapelverfolgung anzeigen:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

Mit dem Paket errorskönnen Sie Fehlerwerten in einem praktischen Format sowohl für eine Person als auch für eine Maschine einen Kontext hinzufügen. Bei einer kürzlich gehaltenen Präsentation habe ich Ihnen gesagt, dass in der kommenden Version von Go ein solcher Wrapper in der Standardbibliothek erscheinen wird.

8. Parallelität


Go wird häufig aufgrund seiner Parallelitätsfunktionen ausgewählt. Die Entwickler haben viel getan, um die Effizienz (in Bezug auf Hardwareressourcen) und Produktivität zu steigern, aber die Parallelitätsfunktionen von Go können verwendet werden, um Code zu schreiben, der weder produktiv noch zuverlässig ist. Am Ende des Artikels möchte ich einige Tipps geben, wie Sie einige der Fallstricke von Go-Parallelitätsfunktionen vermeiden können.

Die erstklassige Parallelitätsunterstützung von Go wird von den Kanälen sowie von Anweisungen selectund bereitgestelltgo. Wenn Sie Go-Theorie aus Lehrbüchern oder an einer Universität studiert haben, haben Sie vielleicht bemerkt, dass der Parallelitätsabschnitt immer einer der letzten im Kurs ist. Unser Artikel ist nicht anders: Ich habe beschlossen, am Ende über Parallelität zu sprechen, als etwas zusätzlich zu den üblichen Fähigkeiten, die der Go-Programmierer lernen sollte.

Hier gibt es eine gewisse Zweiteilung, da das Hauptmerkmal von Go unser einfaches, leichtes Modell der Parallelität ist. Als Produkt verkauft sich unsere Sprache auf Kosten fast dieser einen Funktion. Andererseits ist Parallelität eigentlich nicht so einfach zu verwenden, sonst hätten die Autoren es nicht zum letzten Kapitel in ihren Büchern gemacht, und wir hätten unseren Code nicht mit Bedauern betrachtet.

In diesem Abschnitt werden einige der Fallstricke der naiven Verwendung von Go-Parallelitätsfunktionen erläutert.

8.1. Mach die ganze Zeit etwas Arbeit.


Was ist das Problem mit diesem Programm?

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

Das Programm macht das, was wir beabsichtigt haben: Es dient einem einfachen Webserver. Gleichzeitig verbringt es CPU-Zeit in einer Endlosschleife, da for{}in der letzten Zeile mainGorutin Main blockiert wird, ohne dass eine E / A ausgeführt wird, und nicht auf das Blockieren, Senden oder Empfangen von Nachrichten oder eine Verbindung mit dem Sheduler gewartet werden muss.

Da die Go-Laufzeit normalerweise von einem Sheduler bedient wird, wird dieses Programm sinnlos auf dem Prozessor ausgeführt und kann in einer aktiven Sperre (Live-Sperre) enden.

Wie kann ich das beheben? Hier ist eine Option.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

Es mag albern aussehen, aber dies ist eine übliche Lösung, die mir im wirklichen Leben einfällt. Dies ist ein Symptom für ein Missverständnis des zugrunde liegenden Problems.

Wenn Sie etwas erfahrener mit Go sind, können Sie so etwas schreiben.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

Eine leere Anweisung ist selectfür immer gesperrt. Dies ist nützlich, da wir jetzt nicht den gesamten Prozessor nur für einen Anruf drehen runtime.GoSched(). Wir behandeln jedoch nur das Symptom, nicht die Ursache.

Ich möchte Ihnen eine andere Lösung zeigen, die Ihnen hoffentlich bereits in den Sinn gekommen ist. Anstatt http.ListenAndServein Goroutine zu laufen und das Hauptproblem der Goroutine zu verlassen, laufen Sie einfach http.ListenAndServein der Hauptgoroutine.

Tipp .Wenn Sie die Funktion verlassen main.main, wird das Go-Programm bedingungslos beendet, unabhängig davon, was andere Goroutinen während der Ausführung des Programms ausführen.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

Das ist also mein erster Rat: Wenn Goroutine keine Fortschritte machen kann, bis er ein Ergebnis von einem anderen erhält, ist es oft einfacher, die Arbeit selbst zu erledigen, als sie zu delegieren.

Dadurch entfällt häufig viel Statusverfolgung und Kanalmanipulation, die erforderlich sind, um das Ergebnis von Goroutine zurück an den Prozessinitiator zu übertragen.

Tipp .Viele Go-Programmierer missbrauchen Goroutinen, besonders am Anfang. Wie alles andere im Leben ist Mäßigung der Schlüssel zum Erfolg.

8.2. Überlassen Sie die Parallelität dem Anrufer


Was ist der Unterschied zwischen den beiden APIs?

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

Wir erwähnen die offensichtlichen Unterschiede: Das erste Beispiel liest das Verzeichnis in ein Slice und gibt dann das gesamte Slice oder den Fehler zurück, wenn etwas schief gelaufen ist. Dies geschieht synchron, der Aufrufer blockiert, ListDirectorybis alle Verzeichniseinträge gelesen wurden. Je nachdem, wie groß das Verzeichnis ist, kann es viel Zeit und möglicherweise viel Speicher in Anspruch nehmen.

Betrachten Sie das zweite Beispiel. Es ist ein bisschen mehr wie bei der klassischen Go-Programmierung, hier wird ListDirectoryder Kanal zurückgegeben, über den Verzeichniseinträge übertragen werden. Wenn der Kanal geschlossen ist, ist dies ein Zeichen dafür, dass keine Katalogeinträge mehr vorhanden sind. Da das Füllen des Kanals nach der Rückkehr erfolgt ListDirectory, kann davon ausgegangen werden, dass Goroutinen beginnen, den Kanal zu füllen.

Hinweis . Bei der zweiten Option ist es nicht erforderlich, Goroutine tatsächlich zu verwenden: Sie können einen Kanal auswählen, der ausreicht, um alle Verzeichniseinträge ohne Blockierung zu speichern, ihn ausfüllen, schließen und dann den Kanal an den Anrufer zurückgeben. Dies ist jedoch unwahrscheinlich, da in diesem Fall dieselben Probleme auftreten, wenn eine große Speichermenge verwendet wird, um alle Ergebnisse im Kanal zu puffern.

Die ListDirectoryKanalversion hat zwei weitere Probleme:

  • Die Verwendung eines geschlossenen Kanals als Signal dafür, dass keine Elemente mehr verarbeitet werden müssen, ListDirectorykann den Aufrufer aufgrund eines Fehlers nicht über einen unvollständigen Satz von Elementen informieren. Der Anrufer hat keine Möglichkeit, den Unterschied zwischen einem leeren Verzeichnis und einem Fehler zu vermitteln. In beiden Fällen scheint der Kanal sofort geschlossen zu werden.
  • Der Anrufer muss beim Schließen des Kanals weiterlesen, da nur so zu verstehen ist, dass die Kanalfüll-Goroutine nicht mehr funktioniert. Dies ist eine schwerwiegende Einschränkung der Verwendung ListDirectory: Der Anrufer verbringt viel Zeit damit, vom Kanal zu lesen, selbst wenn er alle erforderlichen Daten erhalten hat. Dies ist wahrscheinlich effizienter in Bezug auf die Speichernutzung für mittlere und große Verzeichnisse, aber die Methode ist nicht schneller als die ursprüngliche Slice-basierte Methode.

In beiden Fällen besteht die Lösung darin, einen Rückruf zu verwenden: eine Funktion, die bei der Ausführung im Kontext jedes Verzeichniseintrags aufgerufen wird.

 func ListDirectory(dir string, fn func(string)) 

Es überrascht nicht, dass die Funktion so filepath.WalkDirfunktioniert.

Tipp .Wenn Ihre Funktion Goroutine startet, müssen Sie dem Aufrufer eine Möglichkeit bieten, diese Routine explizit zu stoppen. Es ist oft am einfachsten, den asynchronen Ausführungsmodus für den Anrufer zu belassen.

8.3. Führen Sie niemals Goroutine aus, ohne zu wissen, wann es aufhören wird


Im vorherigen Beispiel wurde Goroutine unnötig verwendet. Eine der Hauptstärken von Go sind jedoch die erstklassigen Parallelitätsfunktionen. In der Tat ist in vielen Fällen Parallelarbeit durchaus angebracht, und dann müssen Goroutinen verwendet werden.

Diese einfache Anwendung bedient den HTTP-Verkehr an zwei verschiedenen Ports: Port 8080 für den Anwendungsverkehr und Port 8001 für den Zugriff auf den Endpunkt /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

Obwohl das Programm unkompliziert ist, ist es die Grundlage einer echten Anwendung.

Die Anwendung in ihrer aktuellen Form weist mehrere Probleme auf, die beim Wachstum auftreten. Schauen wir uns daher einige davon sofort an.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

Brechen - Handler serveAppund serveDebugauf verschiedene Funktionen, die wir haben sie von getrennt main.main. Wir folgten auch die vorherige Beratung und sorgte dafür , serveAppund serveDebuglassen Sie die Aufgabe , die Parallelität des Anrufers zu gewährleisten.

Es gibt jedoch einige Probleme mit der Leistung eines solchen Programms. Wenn wir kommen serveApp, und dann aus main.main, das Programm schaltet sich ab und startet den Prozess - Manager.

Tipp .So wie Funktionen in Go dem Aufrufer Parallelität überlassen, sollten Anwendungen die Überwachung ihres Status beenden und das Programm, das sie aufgerufen hat, neu starten. Machen Sie Ihre Anwendungen nicht für den Neustart selbst verantwortlich. Dieses Verfahren wird am besten von außerhalb der Anwendung ausgeführt.

Es serveDebugbeginnt jedoch in einer separaten Goroutine, und im Falle seiner Veröffentlichung endet die Goroutine, während der Rest des Programms fortgesetzt wird. Ihren Entwicklern wird es nicht gefallen, dass Sie keine Anwendungsstatistiken erhalten können, da der Handler /debugschon lange nicht mehr funktioniert.

Wir müssen sicherstellen, dass die Anwendung geschlossen ist, wenn eine Goroutine, die sie serviert, aufhört .

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

Jetzt serverAppund serveDebugKontrollfehler aus ListenAndServeund ggf. Ursache log.Fatal. Da beide Handler in Goroutinen arbeiten, erstellen wir die Hauptroutine in select{}.

Dieser Ansatz weist eine Reihe von Problemen auf:

  1. Wenn es ListenAndServemit einem Fehler zurückgegeben wird nil, erfolgt kein Aufruf log.Fatal, und der HTTP-Dienst an diesem Port wird beendet, ohne die Anwendung zu stoppen.
  2. log.Fatalruft os.Exitdas Programm bedingungslos auf; Zurückgestellte Anrufe funktionieren nicht, andere Goroutinen werden nicht über den Abschluss informiert, das Programm wird einfach gestoppt. Dies macht es schwierig, Tests für diese Funktionen zu schreiben.

Tipp .Nur log.Fatalfür Funktionen main.mainoder verwenden init.

Tatsächlich möchten wir dem Schöpfer der Goroutine jeden Fehler mitteilen, der auftritt, damit er herausfinden kann, warum sie den Prozess gestoppt und sauber abgeschlossen hat.

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

Der Goroutine-Rückgabestatus kann über den Kanal abgerufen werden. Die Kanalgröße entspricht der Anzahl der Goroutinen, die wir steuern möchten, sodass das Senden an den Kanal donenicht blockiert wird, da dies das Herunterfahren von Goroutinen blockiert und ein Leck verursacht.

Da der Kanal donenicht sicher geschlossen werden kann, können wir die Redewendung nicht für for rangeden Kanalzyklus verwenden, bis alle Goroutinen gemeldet haben. Stattdessen führen wir alle laufenden Goroutinen in einem Zyklus aus, der der Kapazität des Kanals entspricht.

Jetzt haben wir die Möglichkeit, jede Goroutine sauber zu beenden und alle aufgetretenen Fehler zu beheben. Es bleibt nur ein Signal zu senden, um die Arbeit von der ersten Goroutine an alle anderen abzuschließen.

Der Appell anhttp.Serverüber die Fertigstellung, also habe ich diese Logik in eine Hilfsfunktion eingewickelt. Der Helfer serveakzeptiert die Adresse und http.Handlerebenso http.ListenAndServeden Kanal stop, über den wir die Methode ausführen Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

Jetzt doneschließen wir für jeden Wert im Kanal den Kanal stop, wodurch jeder Gorutin auf diesem Kanal seinen eigenen schließt http.Server. Dies führt wiederum zu einer Rückgabe aller verbleibenden Goroutinen ListenAndServe. Wenn alle laufenden Gorutine gestoppt sind, main.mainendet es und der Prozess stoppt sauber.

Tipp .Eine solche Logik selbst zu schreiben, ist sich wiederholende Arbeit und das Risiko von Fehlern. Schauen Sie sich so etwas wie dieses Paket an , das den größten Teil der Arbeit für Sie erledigt.

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


All Articles