- Ein langlebiger Go-HTTP-Service lässt sich leichter warten und prüfen, wenn er aus expliziter Übergabe von Abhängigkeiten, zentral gebündelten Routen und einer testbaren Funktion
runbesteht - Handler werden statt als Methoden einer Server-Struktur als Funktionen, die
http.Handlerzurückgeben, gebaut und nehmen die benötigten Werte per Closure entgegen; gemeinsames Middleware-Verhalten wird beim Erzeugen des Servers und beim Registrieren der Routen kombiniert - Wenn
func main()schlank bleibt undrun()mitcontext.Context, Argumenten, Umgebungszugriff sowie Standard-Ein- und -Ausgabe versorgt wird, werden Shutdown-Behandlung und Teststeuerung einfacher - Request-/Response-Encoding, Validierung, Middleware-Adapter und verzögerte Initialisierung mit
sync.Oncereduzieren wiederholten Code und behalten dabei den Standard-net/http-Ablauf von Go bei - Bei Tests wird statt einzelner Handler ein End-to-End-Ansatz bevorzugt, der echten API-Aufrufen näher kommt; jeder Test startet seinen eigenen Server und prüft den Bereitschaftszustand über
/healthzoder/readyz
Server-Erzeugung und Service-Einstiegspunkt
- Der Konstruktor
NewServerdient als Funktion, die den zentralenhttp.Handlerdes Services erzeugt- In der Regel gibt es pro Service einen, intern verteilt er Requests an die jeweiligen Handler
- Er nimmt alle Abhängigkeiten wie Logger, Konfiguration, Storage und externe Clients als Argumente entgegen
- Wenn möglich, sollte er
http.Handlerzurückgeben; in komplexeren Fällen kann ein eigener Typ verwendet werden - Er konfiguriert seinen eigenen Muxer und übergibt ihn anschließend an die Funktion zur Routenregistrierung in
routes.go
- HTTP-Verarbeitung, die für alle Endpunkte gemeinsam nötig ist, wird in
NewServergebündelt- CORS
- Authentifizierungs-Middleware
- Logging
- Middleware für Trace-IDs
- Auch wenn die Liste der Abhängigkeitsargumente lang wird, wird eine explizite Angabe als Funktionsargumente bevorzugt
- Bei Strukturfeldern verhindert der Compiler nicht immer, dass etwas vergessen wird, aber Funktionsargumente erzwingen die Übergabe der benötigten Werte
- Lange Argumentlisten bleiben gut lesbar, wenn sie vertikal formatiert werden
- Abhängigkeiten, die in bestimmten Tests nicht verwendet werden, können als
nilübergeben werden, um zu signalisieren, dass sie unbenutzt bleiben
Die API-Oberfläche in routes.go bündeln
routes.godient als Datei, in der alle Routen eines Services an einer Stelle sichtbar sind- So gibt es pro Projekt einen einzigen Ort, an dem sich die API-Oberfläche überblicken lässt
- Wegen der großen Abhängigkeitsliste von
NewServerkann auchaddRouteseine ähnliche Argumentliste bekommen - Die Typprüfung von Go erkennt fehlende oder falsch angeordnete Argumente
addRoutessollte nach Möglichkeit einfach und flach bleiben- Arbeitsschritte, die Fehler verursachen können, werden zuvor in der Funktion
runerledigt - Beim Registrieren der Handler liegt der Fokus auf Routing mit
mux.Handle,mux.HandleFuncoderhttp.NotFoundHandler - Wenn das Design vorsieht, dass Handler selbst Fehler zurückgeben, kann auch
addRouteseinen Fehler zurückgeben
- Arbeitsschritte, die Fehler verursachen können, werden zuvor in der Funktion
main soll nur run aufrufen
func main()bleibt eine schlanke Funktion, dierun()aufruft, bei Fehlern nachstderrschreibt und mit einem Fehlercode beendetrunerhält Betriebssystem-Grundbausteine wiecontext.Context, Argumente, Ein-/Ausgabe und Funktionen für den Umgebungszugriff als Argumente- Da
runeinen Fehler zurückgibt, ist normale Fehlerbehandlung wie in üblichem Go-Code möglich
- Beispiele für Werte, die an
runübergeben werden könnenos.Args: für Programmargumente und das Parsen von Flagsos.Stdin: zum Lesen von Eingabenos.Stdout: zum Schreiben von Ausgabenos.Stderr: zum Schreiben von Fehlerlogsos.Getenv: zum Lesen von Umgebungsvariablenos.Getwd: zum Ermitteln des aktuellen Arbeitsverzeichnisses
signal.NotifyContextwird innerhalb vonruneingerichtet- Bei einem Beendigungssignal wie
Ctrl+Cwird der Context abgebrochen - Gibt
runnilzurück, endet das Programm regulär - Gibt es einen Fehler zurück, schreibt
mainihn aus und beendet mit einem von 0 verschiedenen Code
- Bei einem Beendigungssignal wie
- Wenn globaler Zustand vermieden wird, kann in mehr Tests
t.Parallel()verwendet werden- Selbst mehrere Aufrufe von
runbeeinflussen sich dann nicht gegenseitig - Flags werden innerhalb von
runmitflags.NewFlagSetstatt über das globaleflagverarbeitet - Umgebungsvariablen werden kontrolliert, indem man
getenv func(string) stringinjiziert, statt die echte Umgebung zu verändern - Anders als bei
t.SetEnvkönnen so parallele Tests weiter genutzt werden
- Selbst mehrere Aufrufe von
Umgang mit Shutdown und Bereitschaftszustand
- Der Context sollte durch alle Schichten des Services weitergereicht werden
- Bei einem Shutdown-Signal wird der Context abgebrochen
- Lange oder wiederkehrende Aufgaben prüfen
ctx.Err()oderctx.Done()und brechen ab - Auch gestartete andere Goroutines entscheiden mithilfe des Contexts, wann sie anhalten sollen
- Der HTTP-Server wird beim Beenden durch Aufruf von
Shutdownsauber gestoppt- Im Beispiel wartet eine separate Goroutine auf
ctx.Done() - Der Shutdown-Context erhält ein Timeout von
10 * time.Second - Treten beim Herunterfahren Fehler auf, werden sie nach
stderrgeschrieben
- Im Beispiel wartet eine separate Goroutine auf
- Damit Tests prüfen können, ob der Server wirklich bereit ist, gibt es Endpunkte wie
/healthzoder/readyz- Man könnte auch ein separates Channel-Signal für Bereitschaft verwenden, bevorzugt wird aber die Prüfung per echtem HTTP-Request
- Eine Readiness-Schleife sendet Requests, bis
200 OKzurückkommt - Wird der Context abgebrochen oder ein Timeout erreicht, wird ein Fehler zurückgegeben
- Im Beispiel pausiert die Schleife
250mszwischen den Requests
Aufbau von Handlern
- Handler-Funktionen werden nicht direkt als
http.Handleroderhttp.HandlerFuncimplementiert, sondern zurückgegeben- Beispiel:
func handleSomething(logger *Logger) http.Handler - So lässt sich pro Handler eine Closure-Umgebung aufbauen
- Initialisierte Werte können bei der Request-Verarbeitung verwendet werden
- Beispiel:
- Gemeinsame Daten sollten sicherheitshalber nur lesend genutzt werden
- Wenn ein Handler Werte verändert, sind Schutzmechanismen wie ein Mutex nötig
- Programmzustand in Closures zu speichern, wird im Allgemeinen nicht empfohlen
- In Cloud-Umgebungen ist es schwer, von lang laufenden Instanzen auszugehen
- Ein Server kann zur Ressourcenschonung beendet werden oder aus anderen Gründen abstürzen
- Mehrere Instanzen können gleichzeitig laufen und Requests auf unvorhersehbare Weise verteilt bekommen
- Dauerhafter Zustand eines realen Projekts gehört daher besser in eine Datenbank oder eine separate Storage-API
Request-/Response-Encoding und Validierung
- Da jeder Service das Decodieren von Request-Bodies und das Encodieren von Response-Bodies braucht, gibt es Hilfsfunktionen
encode/decode- Im Beispiel wird der JSON-
Content-Typegesetzt, der Statuscode geschrieben und dannjson.NewEncoder(w).Encode(v)aufgerufen - Das Decodieren kapselt
json.NewDecoder(r.Body).Decode(&v)und ergänzt den Fehler um Kontext - Mit Generics ist Typinferenz möglich, etwa bei
encode(w, r, http.StatusOK, obj) - Da
decodeeinen Typ zurückgibt, muss der erwartete Typ explizit angegeben werden, zum Beispieldecode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
- Im Beispiel wird der JSON-
- Für die Validierung wird ein Interface mit nur einer Methode verwendet
- Das Interface
Validatorhat die FormValid(ctx context.Context) map[string]string - Wenn es keine Probleme gibt, wird eine Map der Länge 0 zurückgegeben
- Bei fehlerhaften Feldern ist der Feldname der Key und eine menschenlesbare Beschreibung der Value
- Das Interface
- Validierung eignet sich für schnelle Feldprüfungen
- ob Pflichtfelder nicht leer sind
- ob ein bestimmtes String-Format wie bei einer E-Mail passt
- ob Zahlen innerhalb eines erlaubten Bereichs liegen
- Komplexere Prüfungen wie Datenbankabfragen werden an anderer Stelle behandelt
- Solche Prüfungen sind zu wichtig, um sie in einer schnellen Validierungsfunktion zu verstecken
- Die generische Variante
decodeValid[T Validator]erzwingt, dass TypTValidatorimplementiert - Selbst bei einer
nil-Map führt ein Aufruf vonlen(problems)zu 0 und nicht zu einem Panic
Das Middleware-Adapter-Muster
- Middleware nimmt einen
http.Handlerentgegen und gibt einen neuenhttp.Handlerzurück- Sie kann vor und nach dem Aufruf des ursprünglichen Handlers Code ausführen
- Je nach Bedingung kann sie den ursprünglichen Handler auch gar nicht aufrufen
- Im Beispiel gibt
adminOnlyfür Nicht-AdministratorenHTTP 404 Not Foundzurück und ruft den ursprünglichen Handler nicht auf
- Der Ort für das Anwenden von Middleware ist meist
routes.go- Schon anhand der Endpunktliste lässt sich sehen, welche Route welche Middleware verwendet
- Wird die Liste der Middleware länger, kann sie zur besseren Lesbarkeit über mehrere Zeilen verteilt werden
- Middleware mit vielen Abhängigkeiten wird in eine Funktion gekapselt, die selbst Middleware zurückgibt
newMiddleware(logger, db, slackClient, rroll)gibtfunc(http.Handler) http.Handlerzurück- Im Code zur Routenregistrierung lässt sich das dann kompakt als
middleware(handleSomething(...))verwenden - Man könnte zusätzlich
type middleware func(h http.Handler) http.Handlerdefinieren, aber der direkte Rückgabetyp ist oft klarer lesbar
Den Geltungsbereich von Request-/Response-Typen verkleinern
- Request-/Response-Typen, die nur in einem bestimmten Endpunkt verwendet werden, können innerhalb der Handler-Funktion definiert werden
- Dadurch bleibt der globale Namespace sauber
- Andere Handler hängen dann nicht von Typen ab, deren Stabilität gar nicht zugesichert ist
- Wenn Testcode dieselben Typen braucht, kann dabei Reibung entstehen
- In diesem Fall ist es auch sinnvoll, die Typen nach außen zu ziehen
- Bleiben die Request-/Response-Typen im Handler, können Tests neue anonyme Strukturen oder lokale Typen deklarieren
- Lokale Typen im Test machen die Absicht sichtbar
- Wenn zum Beispiel der Endpunkt
/greetnicht die vollständigePerson, sondern nur das FeldNamebraucht, enthält die Test-Eingabestruktur auch nurName - Wer den Test liest, erkennt sofort, für welche Felder sich der Endpunkt tatsächlich interessiert
- Wenn zum Beispiel der Endpunkt
Initialisierung mit sync.Once verzögern
- Teure Arbeit bei der Vorbereitung eines Handlers kann mit
sync.Oncebis zum ersten Request aufgeschoben werden- Das verkürzt die Startzeit der Anwendung
- Wird der Handler nie aufgerufen, fällt auch die teure Arbeit nie an
- Im Beispiel wird das Parsen einer Template-Datei erst beim ersten Request genau einmal ausgeführt
sync.Oncegarantiert, dass der Code nur einmal ausgeführt wird- Andere gleichzeitig eintreffende Requests warten, bis die Initialisierung abgeschlossen ist
- Die Fehlerprüfung erfolgt außerhalb von
init.Do, damit Fehler weiter sichtbar bleiben
- Dieser Ansatz verschiebt die Initialisierungszeit vom Startpunkt der Anwendung auf den ersten Laufzeit-Zugriff auf den Endpunkt
- In Umgebungen mit viel Google App Engine kann das gut passen
- Je nach Deployment-Umgebung muss entschieden werden, wo und wann
sync.Oncesinnvoll ist
Teststrategie
- Diese Struktur setzt Testbarkeit als wichtiges Ziel
- Die Funktion
runerlaubt es Testcode, das Programm direkt auszuführen - Tests werden daran gemessen, ob sie das Verhalten des Programms leicht verständlich machen, ob sie bei Änderungen weniger Angst vor Brüchen erzeugen und ob bestandene Tests Vertrauen für ein Production-Deployment schaffen
- Die Funktion
- Es ist auch möglich, nur Handler isoliert zu testen
- Man ruft die Handler-Erzeugungsfunktion auf und übergibt die nötigen Abhängigkeiten
- Mit
httptest.NewRecorderundhttp.NewRequestwerden Request und Response aufgebaut - Statuscode, Response-Body und Header werden geprüft
- Dieser Ansatz überspringt Middleware wie Authentifizierung und geht direkt in den Handler-Code
- Bevorzugt wird jedoch ein Ansatz, der näher an End-to-End-Tests liegt
- Durch Aufruf von
runwird das Programm fast wie im echten Betrieb gestartet - Das umfasst Argument-Parsing, Verkabelung der Abhängigkeiten, Datenbank-Migrationen und das Starten des Servers
- Wenn der Test die API aufruft, werden alle Schichten einschließlich
routes.gogemeinsam geprüft - Dabei kann auch mit einer echten Datenbank interagiert werden
- Durch Aufruf von
- Dieser Ansatz hilft, redundante Tests zu vermeiden
- Wenn alle Schichten getrennt getestet werden, wird derselbe Inhalt oft mehrfach auf leicht unterschiedliche Weise geprüft
- End-to-End-Tests liefern stattdessen ein zentrales Testset, das die Interaktion zwischen Benutzer und System beschreibt
- Bereits vorhandene Unit-Tests, etwa aus TDD, können bleiben, wenn sie sinnvoll sind; wiederholen sie nur dasselbe wie End-to-End-Tests, können sie entfernt werden
- Jeder Test kann seine eigene Programminstanz starten
- Pro Test werden unterschiedliche Argumente, Flags, Standard-Ein-/Ausgaben und Umgebungsvariablen übergeben
- Mit
context.WithCancelwird eine Cancel-Funktion erzeugt und übert.Cleanup(cancel)registriert - Nach Ende des Tests wird der Context abgebrochen und das Programm sauber beendet
t.Cleanupaus Go 1.14 dient dabei als Alternative zu einem direktendefer
Praktischer Einsatzbereich und organisatorischer Kontext
- Beim Bauen einfacher APIs zielt dieses Muster auf gut lesbaren und leicht erweiterbaren Code
- Das Muster lässt sich leicht kopieren und ausbauen
- Neue Mitwirkende finden sich leichter zurecht
- Änderungen verursachen weniger Unsicherheit
- Alles ist explizit aufgebaut, ohne magisch wirkendes Verhalten
- Auch mit Code-Generierungswerkzeugen kann dieser Ansatz bestehen bleiben
- Als Beispiel kann für Template-basiertes Boilerplate-Generating das Oto package verwendet werden
- In großen Projekten oder Organisationen können bestehende Technologieentscheidungen die Wahl beeinflussen
- In einer Organisation wie Grafana Labs können bestimmte Tools und Abstraktionen bereits breit etabliert sein
- gRPC ist ein solches Beispiel
- Wenn es bereits gefestigte Muster und Erfahrung gibt, ist es oft eine pragmatische Entscheidung, diesem Pfad zu folgen
- Der Kontext der Grafana-IRM-Produktfamilie wird ebenfalls erwähnt
- Grafana IRM ist eine Produktfamilie, die bei Grafana Labs aufgebaut wird
- Grafana Alerting sendet Benachrichtigungen, wenn Metriken den zulässigen Bereich verlassen
- Grafana OnCall automatisiert die Kontaktaufnahme mit der richtigen Person anhand von Zeitplänen und Eskalationsregeln
- Grafana Incident erstellt Zoom-Räume, dedizierte Slack-Kanäle und Event-Timelines und hilft bei der Incident-Reaktion
- Einträge in einem Slack-Kanal, die mit dem Roboterkopf-Emoji markiert werden, werden zur Timeline hinzugefügt
1 Kommentare
Hacker-News-Kommentare
Ich habe auch schon den Ansatz mit einem separaten Validator wie einer
Valid-Methode verwendet, aber seit ich Lexi Lambdas „Parse, Don’t Validate“ [0] gelesen habe, habe ich das Gefühl, dass es deutlich weniger Fehler gibt, wenn man den Typprüfer von Go nutztWenn man zum Beispiel verhindern will, dass ein Benutzer jemals einen ungültigen Benutzernamen mit spitzen Klammern setzen kann, dann muss man beim Validator-Ansatz den Validator in jedem Codepfad aufrufen, über den der Benutzername aus nicht vertrauenswürdiger Eingabe kommt
Stattdessen kann man einen Typ
Usernameund einen KonstruktorNewUsername(username string) (Username, error)haben; allein die Existenz einesUsername-Objekts garantiert dann bereits, dass die Validierung bestanden wurde[0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
stringdurchreichen, sondern parsen und typisierenDieses Muster erlaubt es, sowohl früh als auch spät zu validieren, sagt aber nicht, wann man es tun sollte. Oft ist es am besten, das als Teil des Parsens/Validierens eines größeren Objekts zu machen
Für den Umgang mit nicht validierten Daten im UI-Kontext ist Steven Wittens „I is for Intent“ [1] lesenswert
[1] https://acko.net/blog/i-is-for-intent/
Usernamein einer Struktur enthalten ist und man vergisst, einen Wert zu setzen, landet dort ein Zero Value, der die Einschränkungen verletzen kannEines der Muster, die ich am wenigsten mag, ist, ein Config-Objekt für die gesamte Systemkonfiguration entgegenzunehmen und es dann überall veränderlich herumzureichen
Dadurch wird alles über das Konfigurationsobjekt gekoppelt. In manchen Systemen schrieb jemand wieder Werte in das weitergereichte Konfigurationsobjekt zurück, sodass man die einzelnen Teile in einer bestimmten Reihenfolge konfigurieren musste, damit alles korrekt funktionierte
In anderen Fällen schrieb ein Subsystem Daten in das Konfigurationsobjekt, die ein anderes Subsystem später las, sodass sich Teile des Systems nicht deaktivieren ließen
Das Muster „Konfiguration als ein großer veränderlicher Wert“ ist nicht nur in Go, sondern auch in anderen Sprachen ziemlich lästig
In Python-Projekten verwende ich oft unveränderliche
dataclass-Konfigurationen und reiche sie durch mehrere Module weiter; wenn mehrere Funktionen von mehreren Werten abhängen, ist das ein ziemlich praktisches Entwurfsmuster, weil man statt jede Funktion mit einzelnen Parametern und separaten Typdefinitionen zu versehen, alle Variablen und Typen an einer Stelle in einerdataclasshatOption-Funktionen zusammensetzbar zu haltenDie internen
optionswerden nur während der Erstellung verändert, und nach außen exponiert man nurConfigund Accessor-Methoden. Zum Beispiel kann man etwas wieconfig.New(config.Name("Emanon"))bauen und dann mitcfg.Name()lesenConfig-Struktur, undconfigs.Configist dann eine Sammlung derConfig-Typen der einzelnen PaketeDas ist vielleicht keine Go-Best-Practice, aber beim Start kann man die gesamte Systemkonfiguration als eine Einheit aufbauen und an jedes Paket nur die minimal nötigen Abhängigkeiten weitergeben
Auch beim Testen wird es etwas einfacher, weil man nicht die gesamte Konfiguration fälschen muss, nur um ein einzelnes Paket zu testen
Man sollte es niemals verändern. Die verschwendete Arbeitszeit ist es nicht wert, weil man nie weiß, wo und wie es verwendet wird; wenn eine Änderung nötig ist, ist es besser, einen vom Original abgeleiteten Wert zu erzeugen
Das Lustige war, dass das Konfigurationsobjekt im Design eigentlich weitgehend unveränderlich war und man zum Modifizieren die API
WARNING_DO_NOT_USEverwenden musste — und trotzdem hat jemand damit das Objekt verändert und den Ausfall verursachtIch mag die Arbeit von Mat Ryer sehr und habe die meisten Ideen aus der Version dieses Artikels von 2018 seitdem in allen Go-Projekten angewendet, an denen ich gearbeitet habe.
Allerdings hatte ich immer ein ungutes Gefühl dabei, dass
NewServerein großer Konstruktor ist, der alle Abhängigkeiten als Argumente entgegennimmt, und dass man im Testnilfür nicht benötigte Abhängigkeiten übergibt, als Signal dafür, dass sie nicht verwendet werden.Das führt dazu, dass große Teile des Codes unnötig viel geteilten Zustand haben. Es gibt viele HTTP-Handler, die in Wirklichkeit nur prüfen müssen, ob der anfragende Benutzer auf eine Ressource zugreifen darf, und dann genau eine Funktion des Datenspeichers aufrufen — stattdessen werden sie Teil eines riesigen Blocks, der Zugriff auf alle Objekte des übergeordneten Servers und den gesamten Datenspeicher hat.
Selbst wenn man für einen Test nur zwei Methoden mocken will, ist es schwierig, einen einfachen Test zu schreiben. Das Muster von Mat Ryer ist das Beste, was ich bisher gesehen habe, aber ich habe weiter das Gefühl, dass es eine bessere Lösung geben müsste.
Wenn möglich, sind Plugins eine gute Strategie für Code-Grenzen. Eine Plugin-Architektur ist standardmäßig im Grunde abgewählt und wird nur sichtbar, wenn man sie explizit auswählt, statt alle Möglichkeiten einem bestimmten Codeblock aufzuzwingen.
Diese Art von Software nenne ich „à la carte“. Im Allgemeinen sollte man vermeiden, dass „alles alles tut, um irgendetwas tun zu können“.
users-Package oder eincomments-Package.Diese Packages haben überhaupt keine HTTP-Schnittstelle, aber jedes hat sein eigenes
mainund eine Art CLI-Schnittstelle. Das//go:build ignorein dem Datei-Kommentar ist nützlich.Statt einen Handler als
func HandleX(w http.ResponseWriter, req *http.Request)zu definieren, lasse ich ihn benötigte Abhängigkeiten in der Formfunc HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFuncannehmen und intern das eigentlichehttp.HandlerFunczurückgeben.Und am Einstiegspunkt initialisiere ich das einmal.
NewServererzeugte Objekt zu viele Dinge macht. Wahrscheinlich sind zu viele Datentypen und Verhaltensweisen miteinander gekoppelt.Ein einfaches Beispiel: Wenn man dem Konstruktor einen Logger als Abhängigkeit hinzufügt, macht das Objekt etwas mehr als die ursprünglich einfache Implementierung. Das ist an sich okay, aber wenn ich keine Möglichkeit finde, Logging hinzuzufügen, ohne die Implementierung der einfachen Sache zu verändern, finde ich das schade.
Funktionen höherer Ordnung, etwa ein Logger-Dekorator, ermöglichen Komposition, haben aber ebenfalls Nachteile. Trotzdem ist das eine handhabbare Strukturform und kein Fehler.
Der Kern ist, dass innerhalb von
NewServerdie Werte des optionalen Konfigurations-Structs validiert und dann in das Server-Struct kopiert werden. Dadurch muss man weniger Abhängigkeiten mocken, und Tests werden viel einfacher.Das Functional-Options-Pattern habe ich, wie viele andere es empfehlen, ebenfalls oft ausprobiert, aber am Ende aufgegeben. Es ist mir etwas zu clever und schwer lesbar, und ich finde, es erzeugt mehr Boilerplate als das Muster aus Konfigurations-Struct plus Validierung und anschließendem Kopieren.
[0] https://news.ycombinator.com/item?id=39320170
Ich wünschte, dieser Gedanke würde bei HTTP-Services in jeder Sprache breiter akzeptiert: Wenn ein Handler Abhängigkeiten braucht, sollte er sie direkt als Argumente verlangen, statt als Methode an einem Server-Struct zu hängen und damit beim Testen überraschende Abhängigkeiten einzuführen.
In den Handlern eines HTTP-Service steckt meist viel Business-Logik, und diese Logik hat oft viele Abhängigkeiten. Ich sehe tatsächlich häufig einzelne Handler, die DB, Cache, Blob-Storage, endpoint-spezifische Berechtigungsprüfungen, einen Lizenzprüfer, eine Queue, einen speziellen Logger, einen Metrik-Client usw. verwenden.
Es können leicht mehr als 9 Parameter werden, und Linter oder Faustregeln wollen das meist verhindern — aber die Abhängigkeiten verschwinden dadurch nicht, sie werden nur in einer
server-Klasse bzw. einemserver-Struct versteckt, sodass die Methodensignatur kurz aussieht und vorgaukelt, es gäbe nur wenige Abhängigkeiten.Mit der Zeit finde ich Code besser, in dem alle Abhängigkeiten in der Signatur der Funktion/Methode sichtbar sind, selbst wenn es 20 werden. So täuscht man sich wenigstens nicht darüber hinweg, dass die Komplexität des Codes wächst.
Zum Beispiel enthält ein
CreateUser-Struct nur die für diese Aufgabe nötigen Abhängigkeiten wiestore,cache,logger,pubund implementiertServeHTTP.In
main.gooder dort, wo die Abhängigkeiten eingerichtet werden, wird jede Aufgabe erzeugt und es werden nur die benötigten Abhängigkeiten übergeben. Das ist schön, weil man Hilfsmethoden für eine bestimmte Aufgabe bzw. einen bestimmten Handler als private Methoden dieses Structs behalten kann.Wenn allerdings eine Aufgabe eine andere Aufgabe benötigt, wird es mühsam, weil man anfängt, sie gegenseitig zu übergeben, oder sie in ein separates Package bzw. einen separaten Service auslagern muss.
Zum Beispiel könnte man
handleHello({ db, cache, blobStore, authz }, req, res)schreiben. Wenn zwei Handler exakt denselben Kontext verwenden, kann man ihn wiederverwenden, und am Aufrufort lässt sich der handler-spezifische Kontext ebenfalls leicht deklarieren.Ich stimme vielen Punkten dieses Artikels zu und möchte noch ein paar ergänzen.
Wenn man zusammen mit dem App-Kontext eine WaitGroup an das Service-Struct übergibt, kann ein Interrupt über den Kontext das Herunterfahren der App auslösen, und die Main-Goroutine kann vor dem tatsächlichen Beenden auf die WaitGroup warten.
Bei CLI-Programmen ist es nützlich, stdout, stdin, stderr, args, env usw. zu testen, aber bei HTTP-Servern meiner Meinung nach weniger. Ich würde der
run-Funktion eine strukturierte Konfiguration übergeben, damit die Tests fokussierter werden.Ich bin dagegen, Templates im Handler mit
sync.Oncezu parsen. Meiner Ansicht nach sollten Handler keine Templates parsen; das sollte beim Start der App passieren. Wenn sich Templates nicht parsen lassen, sollte die App nicht bereit sein, Anfragen entgegenzunehmen, und mit einem Exit-Code ungleich 0 beendet werden.Ich spiele in letzter Zeit mit ogen herum: https://github.com/ogen-go/ogen
Wenn man eine OpenAPI-Definition schreibt, übernimmt es Routing, Struct-Definitionen, JSON-Schema-Validierung usw. Ich muss dann nur noch den Service implementieren
Dinge wie die Validierung von Integer-Bereichen in Query-Strings sind einfach zu langweilig, und wenn man sie selbst schreibt, vertippt man sich viel zu leicht
Ich bin noch in der Experimentierphase und habe bisher keine schlechten Seiten entdeckt
Das Schreiben einer ähnlichen IDL wie Protobuf oder capnproto fühlt sich deutlich produktiver an
[1] https://github.com/danielgtaylor/huma
[2] https://github.com/swaggest/rest
Ich dachte, das Schreiben der Spezifikation würde langweilig werden, aber es war viel besser als erwartet, und da man die Spezifikation ohnehin braucht, halte ich es für besser, sie gleich im Voraus zu schreiben
Ich habe den Eindruck, dass fx(https://github.com/uber-go/fx) ein sehr simples und zugleich vielseitiges Werkzeug für die Gestaltung von Anwendungen ist
Der Rat im Artikel ist weiterhin nützlich, aber es eliminiert den Teil „Wie stelle ich sicher, dass X initialisiert ist, wenn Y es braucht?“ vollständig. Das N*M-Problem wird auf ein N-Problem reduziert: Man muss sich nur darum kümmern, wie jedes Teil initialisiert wird, und nicht um die Synchronisierung der Initialisierung zwischen den Teilen
Ich habe in mehreren Sprachen ziemlich viele Dependency-Injection-Bibliotheken verwendet und auch selbst welche gebaut, aber die Einfachheit und Allgemeingültigkeit von fx gefallen mir bisher am besten
In einem gut entworfenen System sollte dieses Problem trivial sein. Sicherzustellen, dass etwas initialisiert ist, wenn man es verwenden will, ist eine Frage davon, ob es so vorbereitet ist, dass man es als Konstruktorargument übergeben kann
Man kann es einfach so aufbauen:
stockService := NewStockService(),orderService := NewOrderService(),orderProcessor := NewOrderProcessor(stockService, orderService)So etwas wie eine „Synchronisierung“ der Initialisierung sollte nicht nötig sein, und wenn etwas falsch ist, kompiliert es nicht. Selbst wenn man zirkuläre Abhängigkeiten hinzufügt, kann man es nicht in der richtigen Reihenfolge zusammensetzen, also wird das Problem klar sichtbar
Ein großartiger Artikel mit vielen interessanten Ideen. Ich kann kaum glauben, dass ich
signal.NotifyContextnicht kannteJetzt werde ich mir merken können, wie man Signalbehandlung macht, ohne es in jedes Projekt per Copy-and-Paste zu übernehmen
Mir gefällt die hier gezeigte Methode sehr, aber meine Tests sind etwas anders
In
newTestServer()starte ich einen Server, bei dem die Abhängigkeiten durch Fakes ersetzt sind, und wenn ich Abhängigkeitsfehler testen will, ersetze ich die jeweilige Eigenschaft durch einen Fake, der einen Fehler zurückgibtSo kann ich Fehlerpfade, Log-Einträge, Metrik-Emission, Timeouts und sogar graceful shutdown prüfen
Nachdem der Server gestartet ist, prüfe ich, an welchen Port er gebunden wurde. Der Standardwert ist
:0, daher muss ich auf den tatsächlich zugewiesenen Port warten„Unit“-Tests kann man auf Handler-Ebene oder HTTP-Ebene durchführen, und man kann den Code ausreichend so testen, wie Benutzer ihn erleben werden, entweder durch den kompletten Middleware-Stack hindurch oder ganz ohne ihn. Es ist auch möglich, N Instanzen zu starten und parallel zu testen
Ich nutze Go zwar nicht, aber diese Muster gefallen mir. Sie wirken ziemlich allgemein anwendbar auf testbaren Code
Ich möchte insbesondere keine Python-Quickstart-Guides mehr sehen, die Abhängigkeiten implizit/statisch/nicht testbar behandeln