1 Punkte von GN⁺ 2023-10-17 | 1 Kommentare | Auf WhatsApp teilen
  • Die ServeMux-Verbesserungen in net/http von Go 1.22 bringen stärkeres Pattern Matching in den Standard-HTTP-Multiplexer und verringern damit den Bereich, in dem man wegen einfacher Pfad-Matches auf Third-Party-Router angewiesen war
  • Der neue mux kann HTTP-Methoden und Pfad-Wildcards wie /task/{id}/ im selben Pattern verwenden, sodass derselbe Pfad je nach GET, POST oder DELETE mit unterschiedlichen Handlern verbunden werden kann
  • Gematchte Werte lassen sich mit req.PathValue("id") auslesen; mit {id}... und {$} kann man den gesamten nachfolgenden Pfad bzw. das exakte Pfadende steuern
  • Pattern-Konflikte werden beim Registrieren durch eine panic sichtbar und zeigen, welcher Pfad auf beide Patterns matcht und warum keines von beiden spezifischer ist
  • Für mehr Server könnte der Standard-mux allein ausreichen, doch Third-Party-Router und Frameworks wie gorilla/mux oder Gin bieten weiterhin einen größeren Funktionsumfang und mehr Werkzeuge

Änderungen an ServeMux in Go 1.22

  • Go 1.22 soll erweitertes Pattern Matching für http.ServeMux bringen, den standardmäßigen HTTP-Serving-Multiplexer in net/http
  • Der bisherige http.ServeMux war vor allem auf grundlegendes Pfad-Matching ausgerichtet, weshalb für komplexeres Routing häufig Third-Party-Bibliotheken genutzt wurden
  • Der neue mux holt fortgeschrittenes Matching in die Standardbibliothek und verkleinert damit die Funktionslücke zu Third-Party-Paketen
  • Als Vergleich dienen Beispiele für REST-Server mit dem bisherigen Ansatz der Standardbibliothek und mit gorilla/mux

Grundlegende Nutzung des neuen mux

  • Wer schon Go-Router-Pakete wie gorilla/mux verwendet hat, wird die Nutzung des neuen Standard-mux vertraut finden
  • Das Beispiel erstellt mit http.NewServeMux() einen mux und registriert reichhaltigere Patterns über HandleFunc
mux.HandleFunc("GET /path/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "got path\n")
})

mux.HandleFunc("/task/{id}/", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "handling task with id=%v\n", id)
})
  • Der erste Handler enthält die Methode GET im Pattern und funktioniert daher nur für GET-Requests, die mit /path/ beginnen
  • Der zweite Handler matcht mit {id} in /task/{id}/ eine einzelne Pfadkomponente und liest den Wert im Handler mit r.PathValue("id") aus
  • Da Go 1.22 zum Zeitpunkt des Beispiels noch nicht veröffentlicht war, wird die Ausführung mit gotip empfohlen
  • Die Testergebnisse machen die Routing-Regeln direkt sichtbar
    • Ein Request auf /what/ ergibt 404 page not found
    • Ein GET-Request auf /path/ ergibt got path
    • Ein POST-Request auf /path/ ergibt Method Not Allowed
    • Ein Request auf /task/f0cd2e/ wird mit id=f0cd2e verarbeitet

Zusätzliche Pattern-Funktionen und Konfliktbehandlung

  • Der neue ServeMux erlaubt neben einfachen Wildcards eine feinere Kontrolle des Pfad-Matchings
    • Mit {id}... wird der gesamte nachfolgende Pfad auf die Wildcard gematcht
    • Mit {$} wird das Pfadende strikt gematcht
    • Verarbeitung von Prioritäten und Konfliktregeln zwischen Patterns
  • Mögliche Konflikte waren ein besonders beachteter Teil des Vorschlags
  • Die folgenden beiden Patterns matchen beispielsweise beide auf einen Request an /task/0/status/
mux.HandleFunc("/task/{id}/status/", ...)
mux.HandleFunc("/task/0/{action}/", ...)
  • In diesem Fall löst der neue ServeMux beim Registrieren eine panic aus
  • Die Fehlermeldung zeigt sowohl einen Beispielpfad, auf den beide Patterns matchen, als auch den Grund, warum keines der Patterns spezifischer ist als das andere
  • In komplexem Code, der Patterns an mehreren Stellen registriert, ist eine solche detaillierte Konfliktdiagnose besonders nützlich

Neuimplementierung des task-Server-Beispiels

  • Der task/todo-list-Server aus der früheren Reihe REST Servers in Go wurde mit dem verbesserten mux aus Go 1.22 neu implementiert
  • Der vollständige Code liegt im stdlib-newmux-Beispiel
  • Die registrierten Patterns verwenden HTTP-Methoden zusammen mit Pfad-Wildcards
mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)
  • Wie im Beispiel mit gorilla/mux lässt sich derselbe Pfad nach HTTP-Methode getrennt auf unterschiedliche Handler aufteilen
  • Beim früheren http.ServeMux landeten mehrere Methoden für denselben Pfad im selben Handler, und die Methode musste innerhalb des Handlers geprüft werden
  • Im neuen mux wird ein größerer Teil der Routing-Entscheidung außerhalb des Handlers erledigt, wodurch sich Handler leichter trennen lassen

Nutzung und Einschränkungen von PathValue

  • getTaskHandler liest die aus dem Pfad extrahierte ID mit req.PathValue("id") und wandelt sie mit strconv.Atoi in eine Ganzzahl um
  • Das {id}-Pattern des neuen mux gibt keinen regulären Ausdruck vor, der nur Ganzzahlen matcht; daher ist Fehlerbehandlung für strconv.Atoi erforderlich
  • Schlägt die ID-Konvertierung fehl, wird mit http.StatusBadRequest geantwortet; findet der Speicher die task nicht, wird mit http.StatusNotFound geantwortet
  • Die finale Implementierung ist der Lösung aus Part 2 mit gorilla/mux sehr ähnlich
  • Falls nötig, lässt sich der nachfolgende Pfad mit der {$}-Wildcard des neuen http.ServeMux einfach einschränken

Position von Standard-mux und Third-Party-Routern

  • Für Go-Anfänger war „Welches Router-Paket soll ich verwenden?“ eine häufige Frage
  • Nach Go 1.22 könnten viele Nutzer feststellen, dass der neue Standard-mux auch ohne Third-Party-Pakete ausreicht
  • Router wie gorilla/mux bieten weiterhin mehr Funktionen als die Standardbibliothek
  • Leichtgewichtige Frameworks wie Gin bieten neben einem Router auch zusätzliche Werkzeuge für den Aufbau von Web-Backends
  • Dass die Standardbibliothek leistungsfähiger wird, ist sowohl für Nutzer von Third-Party-Paketen als auch für Nutzer, die nur die Standardbibliothek verwenden, eine positive Entwicklung

1 Kommentare

 
GN⁺ 2023-10-17
Meinungen auf Hacker News
  • Einen panic auszulösen, wenn zwei Routen gleichzeitig matchen, wirkt kontraintuitiv. Andere Web-Frameworks verwenden normalerweise die zuerst registrierte passende Route; ich frage mich, ob es einen Grund gibt, warum das nur bei Go anders ist.
    Der Randfall, dass „HTTP-Routen an mehreren Stellen registriert werden können und doppelte Matches deshalb schwer zu finden sind“, scheint sich mit Werkzeugen lösen zu lassen. In meiner ganzen Laufbahn habe ich das Verhalten „der erste Match gewinnt“ oft genutzt, und es gab viele fachliche Anforderungen, bei denen /foo/bar eine eigene Route sein musste und /foo/{id} eine andere

    • Da nicht garantiert werden kann, dass die Registrierungsreihenfolge immer gleich ist, wird es faktisch zu undefiniertem Verhalten. Der ursprüngliche ServeMux wurde so entworfen und implementiert, und man hielt es für sinnvoll, dieses Verhalten weiter zu unterstützen.
      Im Design Proposal[1] heißt es, dass das Matchen des spezifischeren Patterns die Reihenfolge-Unabhängigkeit der ursprünglichen ServeMux-Patterns bewahren kann, es aber auf einen Blick schwer zu erkennen sein kann, welches von zwei Patterns spezifischer ist oder warum sie kollidieren. Deshalb zeigt die panic-Meldung beim Registrieren kollidierender Patterns die Kollision anhand eines Beispielpfads.
      Auch in der Hintergrunddiskussion[2] wird erklärt, dass die mux-Semantik nicht von der Aufrufreihenfolge von Handle oder HandleFunc abhängt. Dank der Reihenfolge-Unabhängigkeit ist die Initialisierungsreihenfolge von Paketen nicht wichtig, und Code-Refactorings werden einfacher. Deshalb wurde als Kriterium für Gleichstände nicht die Registrierungsreihenfolge gewählt; doppelte Registrierungen lösen einen panic aus, und das Vermeiden einer Semantik, die von der Registrierungsreihenfolge abhängt, bleibt ein zentrales Designziel.
      [1]: https://github.com/golang/go/issues/61410
      [2]: https://github.com/golang/go/discussions/60227
    • Der Grund, eine kompilierte, typisierte Sprache wie Go zu verwenden, ist, Bugs weiter nach links zu verlagern. Ein Bug, der beim Start der Anwendung auffällt, ist besser als einer, der in der Produktion in einem Randfall auftritt; und ein Bug, der beim Kompilieren auffällt, ist besser als einer, der erst beim Start auffällt.
      In einem aktuellen persönlichen Projekt bin ich bei der Nutzung von gorilla/mux auf einen Bug gestoßen, bei dem ich versehentlich überlappende Routen erstellt hatte. Hätte der Router statt der Ausführung einen panic ausgelöst, hätte mich das dazu gebracht, die URLs zu refactoren, und der Bug wäre vollständig vermieden worden
    • panic selbst ist ein Werkzeug. Es verhindert, dass man mehrdeutigen und schwer nachvollziehbaren Code verwendet, und sorgt dafür, dass man sich nicht auf Faktoren verlässt, die sich willkürlich ändern können, etwa die Initialisierungsreihenfolge. Schon eine Änderung des Dateinamens kann zum Beispiel die Reihenfolge der dateiweiten init-Funktionen in Go beeinflussen.
      Wenn man ein Verhalten wie /foo/bar und /foo/{id} braucht, kann man einen Handler für den Basispfad erstellen und darin explizit auf andere Handler-Funktionen verzweigen. Wenn man ein Verhalten möchte, das es in der Standardbibliothek nicht gibt, gibt es auch viele andere Bibliotheken mit mehr Funktionen.
      Die Go-Standardbibliothek und die Sprache selbst haben sich angesichts möglicher Mehrdeutigkeit immer eher konservativ verhalten. Dadurch ist es leicht, eine starke und präzise Intuition dafür zu entwickeln, wie die Standardbibliothek funktioniert. Code schnell und korrekt lesen und verstehen zu können, ist viel wertvoller, als beim Schreiben ein paar Sekunden zu sparen
    • /foo/bar und /foo/{id} dürften wohl erlaubt sein. Ersteres ist spezifischer und hat deshalb Priorität, also scheint das in Ordnung zu sein.
      Wenn es allerdings /foo/{id}/delete und /foo/bar/{action} gibt, matcht /foo/bar/delete auf beide, und keine von beiden ist spezifischer; daher dürfte es einen panic geben. Das fühlt sich vernünftig an, aber eine Priorität, bei der die zuerst registrierte Route gewinnt, könnte auch besser sein
    • Einen Fehler zu bekommen ist fürs Debugging viel besser, als falsche Daten zu erhalten, weil tatsächlich eine andere Route ausgeführt wurde. In Django habe ich so etwas tatsächlich erlebt, weil bei einer Route der abschließende $-Regex fehlte
  • Das Projekt gorilla/mux ist etwas seltsam und verwirrend. Letztes Jahr haben die Maintainer das Projekt archiviert, deshalb bin ich zu einem anderen Multiplexer namens gin-gonic gewechselt.
    Als es im Artikel erwähnt wurde, habe ich noch einmal nachgesehen, und es war nicht mehr archiviert. Ich weiß nicht, ob das Zweifel an der Stabilität des gesamten Projekts aufwirft oder ob es eher ein Beleg dafür ist, dass Open Source tatsächlich stabil ist, weil andere die Wartung übernehmen können, wenn Entwickler aussteigen. Trotzdem ist es gut, dass diese Funktion in Go selbst bereitgestellt wird

  • Wie ich schon früher in einem Kommentar[1] geschrieben habe: Ich finde, die Syntax des Vorschlags ist falsch.
    Um Handler zu definieren, muss man merkwürdige magische Strings bauen. Ich verstehe nicht, warum man daraus keine echten Argumente macht; dann könnte man auch bestehende Konstanten leichter verwenden.
    [1] https://github.com/golang/go/issues/61410#issuecomment-16580...

    • Ich vermute, das liegt an Gos Garantie der Abwärtskompatibilität.
      Wenn man dem Mux-Interface ein neues Argument hinzufügt, bricht bestehender Code; und weil man die Methodensignatur nicht ändern kann, wirken solche magischen Strings wie ein vernünftiger Kompromiss. HTTP-Methoden werden sich auch nicht gerade bald ändern, und zur Registrierungszeit oder per statischer Analyse lässt sich das leicht validieren. Ich sehe nicht recht, welchen Wert Konstanten bringen sollen – erst recht nicht, wenn man dafür Abwärtskompatibilität bricht oder für immer eine neue Mux-API parallel pflegen muss, die sich nur minimal von der bestehenden unterscheidet.
    • Wegen des Parsings von {parameter} ist es doch ohnehin schon ein magischer String, oder?
      In RFC2616-Begriffen kann man es als Request-URI mit etwas Parameter-Magie sehen, und wenn man sich vorstellt, dass daraus wie bei einer Request-Line [ Method SP ] Request-URI wird, passt das. Es ist vollständig abwärtskompatibel, und auch ohne Doku ist ziemlich klar, was es macht.
      Method ist entweder eine von ein paar vordefinierten Konstanten oder extension-method = token, und token schließt Leerzeichen, Slashes und diverse Klammern aus. Daher scheint es selbst bei sehr seltsamen Custom-Methoden kaum möglich, dass etwas verwechselt oder falsch geparst wird.
    • Ich stimme zu, dass es etwas seltsam ist, aber der Grund ist klar: Man will das bestehende öffentliche Interface nicht brechen oder ändern.
    • Anfangs hatte ich denselben Eindruck, aber ehrlich gesagt haben mir Methoden wie .Get() oder .Post() bei der Nutzung von Gin nie wirklich etwas gelöst. Eine einzelne Registrierungsmethode, bei der die Methode im String steckt, ist wahrscheinlich völlig in Ordnung.
    • Ich sehe nicht, was daran problematisch sein soll, HEAD-Routen automatisch zu registrieren.
  • Mir gefällt dieser Ansatz nicht. Ich frage mich, ob es einen Grund gibt, warum man stringifizierte Methoden-Präfixe verwendet.
    Typsicherheit durch verb-spezifische Methoden wie mux.Get oder mux.Post ist besser als magische Strings, die erst zur Laufzeit validiert werden. Außerdem ermöglicht das Editor-Autovervollständigung bzw. IntelliSense.

    • So etwas kann man sehr leicht ergänzen. Wenn es dich wirklich so sehr stört, kannst du einfach func Get(mux, uri, handler) { mux.HandleFunc("GET " + uri, handler) } bauen.
      Die Typen habe ich nur der Kürze halber weggelassen.
    • Ich bin auch kein Fan. Ich möchte sicher sein können, dass Routing zur Compile-Zeit und nicht zur Laufzeit funktioniert.
    • Wenn du Typsicherheit willst, solltest du eine typsichere Sprache wählen.
    • Ich bevorzuge Strings zwar nicht, aber fairerweise ist die HTTP-Methode letztlich auch ein String, wenn ein Request eingeht. Es hat schon eine gewisse Eleganz, dass es zum Präfix der ersten Zeile eines HTTP-Pakets passt.
    • Das wird im Vorschlag ausführlich behandelt.
  • Interessant. Da der Mux nun auch Methoden matchen kann, habe ich mich gefragt, was passiert, wenn die Route passt, aber die Methode nicht. Also ob es 404 oder 405 ist.
    Wie sich herausstellt, wird ein 405 zurückgegeben, bei dem der Allow-Header korrekt gefüllt ist.
    https://cs.opensource.google/go/go/+/master:src/net/http/ser...
    Ich weiß, dass viele Leute stringbasierte Interfaces nicht mögen, aber ich halte es für wahrscheinlicher, dass man korrekt etwas Falsches eingibt, als dass man den Namen einer HTTP-Methode vertippt. Für mich persönlich reicht es, wenn statische Analyse bei falscher Syntax warnt.
    Wenn man allerdings bereits fortgeschrittene Anforderungen hat, sollte man meiner Meinung nach eher nicht den Standard-Serve-Mux verwenden. Es gibt viele Optionen, die besser zu unterschiedlichen Use Cases passen, und wenn man bereits dynamisch Routen erzeugt, kann es weniger Aufwand sein, gleich einen eigenen Router zu verwenden, statt die eigene Datenstruktur in einen bestehenden Router hineinzuzwingen.

  • Bei überlappenden Pfaden wäre es viel besser, wenn sie nicht panicen, sondern in der definierten Reihenfolge gematcht würden.
    Wenn man im Beispiel die Route /task/0/{action}/ vor der Wildcard-Route definiert, würde ich erwarten, dass sie zuerst matcht. So könnte man Handler für Sonderfälle leicht definieren.
    Statt auf eine „hilfreiche“ Weise zu scheitern, wäre es besser, einfach so zu funktionieren, wie ich es gesagt habe. Das riecht etwas merkwürdig und nicht sehr Go-typisch.

    • Semantik einer Sprache ist wichtig, und das ist tatsächlich eine ziemlich Go-typische Wahl.
      Der ursprüngliche ServeMux wurde bewusst so entworfen, dass er die Registrierungsreihenfolge nicht respektiert, weil es gefährlich ist, sich darauf zu verlassen. Eines der zentralen Designziele von Go ist die Unterstützung von „Programmieren im großen Maßstab“, und seltsame Nebeneffekte durch weit entfernte Codeänderungen sind genau die Art von Dingen, die man vermeiden will.
      In einem einfachen Beispiel, in dem alle Registrierungen in einer einzigen Funktion eines einzigen Pakets erfolgen, ist die Absicht der Registrierungsreihenfolge klar. In einer hinreichend großen Codebasis können Registrierungen aber an mehreren Stellen stattfinden und auch aus Eingabedateien oder generiertem Code stammen. Wer die Spezifikation für generierten Code schreibt, kennt möglicherweise nicht die Feinheiten der Ausführungsreihenfolge und deren Auswirkungen auf das Routing. Schon eine Änderung der lexikografischen Sortierung von Package-Imports könnte das Routing unerwartet verändern.
      Unerwartete Ergebnisse zu vermeiden, Refactoring zu erleichtern und komplexe Programme leichter nachvollziehbar zu machen, ist eindeutig eine Go-typische Richtung. Man muss der Entscheidung nicht immer zustimmen, aber die Begründung ist konsistent.
  • Schön, dass eine externe Abhängigkeit wegfällt. Nebenbei: Die Kombination sqlc + Postgres + templ + htmx + Tailwind war ein Stack mit sehr hoher Entwicklungsproduktivität. templ fühlt sich wie JSX für Go an.

    • Ich habe bisher mit sqlc ziemlich gute Erfahrungen gemacht.
      htmx sollte ich mir auch einmal ansehen.
    • Ich frage mich, wie man hier Tailwind handhabt. Die Idee, Tailwind hinzuzunehmen, ist wirklich gut, aber wenn ich dafür npm verwenden muss, würde ich lieber direkt CSS schreiben.
    • Zuerst dachte ich, es sei ein Tippfehler für template, aber tatsächlich ist templ ein anderes Paket als die Standardbibliothek. Nach einem kurzen Blick in die Doku wirkt es ziemlich sauber.
      Ich hatte gehofft, dass bud mit angehängter Svelte-Kompilierung diese Lücke füllen würde, aber templ scheint eine bessere Alternative zu sein.
    • Es wäre wirklich schön, wenn templ IntelliJ-Unterstützung hätte. Die Idee gefällt mir.
  • Dieser Vorschlag gefällt mir wirklich nicht. Die HTTP-Request-Methode in die URI zu packen – und das auch nur manchmal – und dann vielleicht sogar so etwas wie POST,PUT,PATCH /something zu machen, darauf würde ich gern verzichten.
    Wenn es unbedingt sein muss, kann man eine eigene Methode erstellen, die den Namen der HTTP-Request-Methode entgegennimmt.

    • Ich weiß nicht, warum. In der Praxis ändert sich dadurch auch nicht viel. Wegen einer reinen Stilfrage kann ich nicht den ganzen Vorschlag wirklich ablehnen.
      Mich würde interessieren, welches Problem eine eigene Methode, die den Namen der HTTP-Methode entgegennimmt, im Vergleich dazu löst, die Methode in den String aufzunehmen.
    • Stimme zu. So ein Design ergibt nicht besonders viel Sinn.
      Man hätte separate Argumente für die Methode oder die Liste von Optionen vorsehen können.
  • Go ist nicht mein Hauptwerkzeug, aber ich habe Gin benutzt, und dort sieht es so aus:
    router.GET("/", func(context *gin.Context) { ... })
    Das scheint sich nicht groß von vielen anderen Sprachen und Frameworks zu unterscheiden. Ich frage mich, ob es weitere Beispiele gibt, die den Ansatz GET /path/ verwenden.

    • Abgesehen von Abwärtskompatibilität gibt es keinen Grund, das so zu machen, daher vermutlich kaum.
    • In der Praxis habe ich das noch nicht gesehen. Es ist einfach ein schlechter Ansatz.
  • Dass ein offizielles Sprach-Team präfixierte Strings statt Enums verwendet, wirkt wie eine sehr seltsame Designentscheidung.
    Zum Beispiel scheint (Http.GET, "/path") besser zu sein als das aktuelle ("GET /path"). Merkwürdig.

    • So wird das Go-1-Kompatibilitätsversprechen eingehalten.
    • Als Go-Entwickler hätte ich mir in solchen Fällen wegen der Abwärtskompatibilität Function Overloading gewünscht. Das Kompatibilitätsversprechen ist großartig, aber die daraus entstehenden Merkwürdigkeiten sind es weniger.
    • Es ist ein Fehler, der sich nicht beheben lässt, ohne bestehenden Code zu brechen oder eine neue Funktion einzuführen.
      Trotzdem würde ich mehrere Funktionen wie mux.GET und mux.POST dieser Methode vorziehen.