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:
- Einfachheit
- Lesbarkeit
- 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 }
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 {
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.
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:
- Erklären Sie, was der Code tut.
- Erklären Sie, wie er es macht.
- Erklären Sie warum .
Die erste Form ist ideal zum Kommentieren öffentlicher Charaktere:
Die zweite ist ideal für Kommentare innerhalb einer Methode:
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{
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
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.
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.
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
Es gibt eine Ausnahme von dieser Regel: Sie müssen keine Methoden dokumentieren, die die Schnittstelle implementieren. Tun Sie dies insbesondere nicht:
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.
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.
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:- Der Paketname ist zu allgemein.
- . , .
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 utils
oder helpers
werden 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
helpers
und 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 strings
für Dienstprogramme zur Zeichenfolgenverarbeitung.
Pakete mit Namen wie base
oder common
werden 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/http
nicht die einzelne Pakete client
und server
stattdessen gibt es Dateien client.go
und server.go
mit den entsprechenden Datentypen sowie transport.go
für den gesamten Verkehr.Tipp . Es ist wichtig zu beachten, dass der Bezeichnername den Paketnamen enthält.
- Eine Funktion
Get
aus einem Paket net/http
wird zu einem http.Get
Link aus einem anderen Paket.
- Ein Typ
Reader
aus einem Paket wird strings
beim Import in andere Pakete umgewandelt strings.Reader
.
- Die Schnittstelle
Error
aus dem Paket ist net
eindeutig 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 try
und 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 UnreadRune
wird der Status überprüft. b.lastRead
Wenn der vorherige Vorgang nicht ausgeführt wurde ReadRune
, wird sofort ein Fehler zurückgegeben. Der Rest der Funktion basiert auf dem, was b.lastRead
größ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 nil
muss 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
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 len
beide cap
gleich 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() {
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:- Verwenden Sie Schnittstellen, um das für Funktionen oder Methoden erforderliche Verhalten zu beschreiben.
- 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:- Verschieben Sie die entsprechenden Variablen als Felder in die Strukturen, die sie benötigen.
- 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 common
letztendlich eng mit dem größten Konsumenten verbunden, und dies macht es schwierig, Korrekturen an früheren Versionen (Back-Port-Korrekturen) vorzunehmen, ohne sowohl common
den 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
, private
und 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 .go
in 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 http
sollte sich beispielsweise in einer Datei http.go
in 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.go
enthält die Typen Request
und Response
Dateitypclient.go
- Client
, Datei server.go
- Servertyp.
- , . , .
- . ,
messages.go
HTTP- , http.go
, client.go
server.go
— HTTP .
Tipp . .
. Go . ( — Go). .
5.1.2.
Das Tool go
unterstützt das Paket testing
an zwei Stellen. Wenn Sie ein Paket haben http2
, können Sie eine Datei schreiben http2_test.go
und 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 go
unterstü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 go
erkennt 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 go
den 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/f
nur ein Paket aus einem Verzeichnisbaum importieren .../a/b/c
, jedoch überhaupt nicht .../a/b/g
oder ein anderes Repository (sieheDokumentation ).5.2. Das kleinste Hauptpaket
Eine Funktion main
und ein Paket main
müssen nur über minimale Funktionen verfügen, da sie sich main.main
wie ein Singleton verhalten: Ein Programm kann nur eine Funktion haben main
, einschließlich Tests.Da es sich main.main
um ein Singleton handelt, gibt es viele Einschränkungen für aufgerufene Objekte: Sie werden nur während main.main
oder main.init
und 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)
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 CopyFile
immer 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
akzeptiert zwei Parameter: eine TCP-Adresse zum Abhören eingehender Verbindungen und http.Handler
zum Verarbeiten einer eingehenden HTTP-Anforderung. Serve
ermö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.DefaultServeMux
als impliziten Parameter zu verwenden.Jetzt hat der Anrufer Serve
zwei 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 nil
verbreitet sich wie ein Virus. Das Paket http
hat 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 ListenAndServe
der Aufrufer nil
den zweiten Parameter übergeben kann, wird http.Serve
dieses Verhalten ebenfalls unterstützt. In der Tat ist es in der http.Serve
Logik implementiert "wenn der Handler gleich ist nil
, verwenden DefaultServeMux
". Die Akzeptanz nil
eines Parameters kann den Aufrufer zu der Annahme veranlassen, dass er nil
fü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 nil
und nicht nil
.
Der Autor hat http.ListenAndServe
versucht, das Leben der API-Benutzer für den Standardfall zu vereinfachen, die Sicherheit war jedoch beeinträchtigt.In der Gegenwart nil
gibt 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 ids
ein 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 {
Da der Operator if
sehr lang wurde, wollte ich die Validierungslogik in eine separate Funktion ziehen. Folgendes habe ich mir ausgedacht:
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) {
Es gibt jedoch ein Problem mit anyPositive
, jemand könnte es versehentlich so nennen: if anyPositive() { ... }
In diesem Fall anyPositive
wird zurückkehren false
. Dies ist nicht die schlechteste Option. Schlimmer noch, wenn die anyPositive
Rückkehr true
in 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:
Jetzt können anyPositive
Sie 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 Document
auf der Festplatte beibehält.
Ich könnte eine Funktion schreiben Save
, die Document
in eine Datei schreibt *os.File
. Es gibt jedoch einige Probleme.Die Signatur verhindert Save
die 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.Save
auch 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 f
in einen temporären Ordner geschrieben und anschließend gelöscht wird.*os.File
definiert auch viele Methoden, die sich nicht auf das Save
Lesen von Verzeichnissen und das Überprüfen, ob ein Pfad eine symbolische Verknüpfung ist, beziehen . Na wenn die UnterschriftSave
beschrieb nur die relevanten Teile *os.File
.Was kann getan werden?
Mithilfe io.ReadWriteCloser
dieser können Sie das Prinzip der Schnittstellentrennung anwenden - und es Save
auf 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 Save
und verdeutlicht dem Aufrufer, welche Typmethoden *os.File
mit seiner Operation zusammenhängen.Und der Autor Save
kann 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.
Daher können Sie die Spezifikationen der Schnittstelle eingrenzen, um Save
nur zu schreiben und zu schließen.Zweitens ist der Thread-Schließmechanismus y Save
ein Erbe der Zeit, als er mit der Datei arbeitete. Die Frage ist, unter welchen Umständen wc
es geschlossen wird.Ob die Save
Ursache Close
unbedingt, 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.
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, CountLines
akzeptiert io.Reader
, nicht *os.File
; Es ist bereits Aufgabe des Anrufers, anzugeben, io.Reader
wessen Inhalt wir zählen möchten.Wir erstellen bufio.Reader
die 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 ReadString
einen 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. ReadString
wird zurückgegeben, io.EOF
wenn das Ende der Datei erreicht ist. Dies ist die erwartete Situation. ReadString
Sie 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 nil
und 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.Scanner
stattdessen bufio.Reader
.Unter der Haube bufio.Scanner
verwendet 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, true
wenn der Scanner auf eine Zeichenfolge gestoßen ist und keinen Fehler gefunden hat. Daher wird der Schleifenkörper for
nur aufgerufen, wenn sich eine Textzeile im Scannerpuffer befindet. Dies bedeutet, dass der neue CountLines
Fälle behandelt, wenn keine neue Zeile vorhanden ist oder wenn die Datei leer ist.Zweitens endet der Zyklus , da er sc.Scan
zurückkehrt, false
wenn ein Fehler erkannt wird, for
wenn er das Ende der Datei erreicht oder ein Fehler erkannt wird. Der Typ bufio.Scanner
merkt 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.EOF
und konvertiert sie, nil
wenn 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.ReadFile
und 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.Fprintf
und ü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
.errWriter
erfüllt den Vertrag io.Writer
, so dass es als Wrapper verwendet werden kann. errWriter
leitet 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 , errWriter
um WriteResponse
den Code Klarheit deutlich verbessert. Sie müssen nicht mehr bei jedem einzelnen Vorgang nach Fehlern suchen. Die Fehlermeldung wird als ew.err
Feldprü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.
Wenn Sie weniger als eine Entscheidung treffen, ignorieren Sie den Fehler. Wie wir hier sehen, wird der Fehler von w.WriteAll
ignoriert.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)
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)
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)
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 buf
unbekannt: 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.Errorf
funktioniert 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:- Stellen Sie sicher, dass es nicht Null ist.
- 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 errors
kö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 select
und 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 main
Gorutin 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 select
fü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.ListenAndServe
in Goroutine zu laufen und das Hauptproblem der Goroutine zu verlassen, laufen Sie einfach http.ListenAndServe
in 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?
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, ListDirectory
bis 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 ListDirectory
der 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 ListDirectory
Kanalversion hat zwei weitere Probleme:- Die Verwendung eines geschlossenen Kanals als Signal dafür, dass keine Elemente mehr verarbeitet werden müssen,
ListDirectory
kann 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.WalkDir
funktioniert.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)
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 serveApp
und serveDebug
auf verschiedene Funktionen, die wir haben sie von getrennt main.main
. Wir folgten auch die vorherige Beratung und sorgte dafür , serveApp
und serveDebug
lassen 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 serveDebug
beginnt 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 /debug
schon 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 serverApp
und serveDebug
Kontrollfehler aus ListenAndServe
und ggf. Ursache log.Fatal
. Da beide Handler in Goroutinen arbeiten, erstellen wir die Hauptroutine in select{}
.Dieser Ansatz weist eine Reihe von Problemen auf:- Wenn es
ListenAndServe
mit 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.
log.Fatal
ruft os.Exit
das 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.Fatal
für Funktionen main.main
oder 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 done
nicht blockiert wird, da dies das Herunterfahren von Goroutinen blockiert und ein Leck verursacht.Da der Kanal done
nicht sicher geschlossen werden kann, können wir die Redewendung nicht für for range
den 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 serve
akzeptiert die Adresse und http.Handler
ebenso http.ListenAndServe
den 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
Jetzt done
schließ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.main
endet 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.