Die Golumne

Requests richtig verarbeiten: Keine Sorge beim Multiplexen in Go

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

Mit der zunehmenden Beliebtheit von Go als Sprache für Webanwendungen häufen sich in den entsprechenden Foren bei Slack, StackOverflow oder Reddit die Fragen nach dem Multiplexing, also dem Verteilen der Anfragen auf die hierfür zuständigen Funktionen. Für diese Aufgabe gibt es inzwischen diverse stark verbreitete Lösungen, beispielsweise gorilla/mux. Doch sie bringen neben ihren Features auch einige Nachteile mit sich.

Zu diesen Nachteilen gehört etwa ein für viele Aufgaben zu großer Codeumfang mit vielen Abhängigkeiten zu externen Bibliotheken sowie eine hohe Komplexität. Dabei enthält die Standardbibliothek in net/http doch bereits einen flexiblen Ansatz. Einfache Funktionen, flexible Typen und auch ein geschachteltes Multiplexing lassen sich zur Verarbeitung der Requests einsetzen.

Wer nun jedoch einen umfänglichen Ansatz wie in Rails erwartet, wird enttäuscht. Diesen Abstraktionsgrad bietet Go (zumindest von Haus aus) nicht. Besser ist hier der klassische Weg, Geschäftslogik und hierin genutzte Persistenz sauber über Interfaces zu definieren und diese zu nutzen. Neben den produktiven Implementierungen können so auch Stubs  für Tests elegant implementiert oder Datenbanksysteme ausgetauscht werden. Doch dies ist ein anderes Thema.

DevOpsCon Istio Cheat Sheet

Free: BRAND NEW DevOps Istio Cheat Sheet

Ever felt like service mesh chaos is taking over? Then our brand new Istio cheat sheet is the right one for you! DevOpsCon speaker Michael Hofmann has summarized Istio’s most important commands and functions. Download FOR FREE now & sort out your microservices architecture!

Anfrage verarbeiten

Das Paket net/http bringt einen Server mit, der eintreffende HTTP Requests auf diese verarbeitenden Funktionen in Goroutinen verteilt. So sind die Funktionen http.ListenAndServe() für HTTP und http.ListenAndServeTLS() für HTTPS die einfachsten Varianten. Ebenso gibt es http.Serve() und http.ServeTLS(), die jeweils mit individuellen Instanzen eines net.Listener arbeiten und so mehr Flexibilität in dessen Konfiguration bieten. Der dritte Weg und derjenige mit der größten Flexibilität ist die Erzeugung einer eigenen Instanz des http.Server, die sich in vielen Parametern konfigurieren lässt.

Allen drei Varianten ist gemein, dass sie eine Implementierung des Interface http.Handler für die Verarbeitung der Requests benötigen. Diese Schnittstelle definiert nur die eine Methode ServeHTTP(w http.ResponseWriter, r *http.Request). In dieser Methode ist nun je nach Bedarf der Request r zu analysieren und eine Antwort an den Writer w zu schreiben. Beide Typen stellen hierfür unterschiedliche Felder und Methoden zur Verfügung.

Eine einfache und praktische Spielform des Interface ist http.HandlerFunc. Es ist der einfache Funktionstyp func(w http.ResponseWriter, r *http.Request), dessen Methode func (f HandlerFunc) ServeHTTP(w, r) als Aufruf von f(w, r) definiert ist. Und so lässt sich ein ganz einfacher Server wie folgt definieren:

h := func(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(http.StatusOK)
  w.Write([]byte("Hello, World!"))
}

http.ListenAndServe(":8080", h)

Die Analyse des Pfads eines Requests und die Verteilung auf entsprechende Funktionen innerhalb des Handlers sind dabei allerdings recht umständlich. Ein Weg ist zum Beispiel die Auswertung über ein switch (Listing 1). Spaß macht das aber nicht.

func (api *ShopAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  switch {
  case strings.HasPrefix(r.URL.Path, "/api/customers/"):
    api.customersServeHTTP(w, r)
  case strings.HasPrefix(r.URL.Path, "/api/orders/"):
    api.ordersServeHTTP(w, r)
  default:
    http.Error(w, "cannot handle request", http.StatusNotFound)
  }
}

Bei sehr kleinen APIs mag es noch gehen. Doch mit zunehmender Anzahl möglicher Pfade wird dieser Ansatz zu statisch und die Wartung zunehmend unmöglich. Zudem ist das Beispiel bewusst einfach gehalten. Für Pfade wie /posts/../api/customers oder /api/customers ohne den abschließenden Slash würde dieser Weg versagen. Der Pfad muss vor einer Auswertung also erst einmal aufbereitet werden. Ein leistungsfähigeres Multiplexing muss her.

Glücklicherweise bringt Go im http-Package den http.ServeMux mit. Dieser implementiert selbst auch wieder einen http.Handler und lässt sich daher durch die oben aufgeführten Server nutzen. Eine Instanz des Multiplexers ermöglicht es nun, weitere Handler für unterschiedliche Pfade zu registrieren. Diese sind jeweils entweder absolut für individuelle Ressourcen oder Pfade für alle Ressourcen innerhalb dieser Pfade. Die verwandten Pfade dürfen sich auch überlappen. Beispielsweise können ein Handler für /posts/ und einer für /posts/images/ registriert werden. Entspricht der Pfad eines Requests dem längeren registrierten Pfad, hat dieser eine höhere Priorität. Vor der Verteilung auf die Handler werden auch relative Pfadbestandteile in Requests aufgelöst (Listing 2).

mux := http.NewServeMux()

mux.Handle("/posts/", NewPostsHandler())
mux.Handle("/posts/images/", NewPostImagesHandler())
mux.Handle("/api/", NewAPIHandler())
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(http.StatusNotFound)
})

http.ListenAndServe(":8080", mux)

Sofern nur so wie hier das einfache http.ListenAndServe() genutzt wird, ist die Anlage einer eigenen Instanz des Multiplexers nicht notwendig. Das Package verfügt für diesen Fall über den globalen DefaultServeMux und die Handler lassen sich via http.Handle(„/api/“, NewAPIHandler()) registrieren. Beim Start des Servers genügt dann die Angabe von nil als Handler, also http.ListenAndServe(„:8080“, nil).

Aber welche Methode

Neben dem Pfad einer Anfrage ist natürlich auch dessen HTTP-Methode wichtig. Hierfür hat Go leider nichts Eigenes zu bieten. Stattdessen muss das Feld Request.Method manuell ausgewertet werden. Doch auch ein MethodMux ist kein Problem (Listing 3).

type MethodMux struct {
  handlers map[string]http.Handler
}

func (mux *MethodMux) Handle(method string, handler http.Handler) {
  mux.handlers[method] = handler
}

func (mux *MethodMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  handler, ok = mux.handlers[r.Method]
  if !ok {
    http.Error(w, "cannot handle request", http.StatusMethodNotAllowed)
  }
  handler.ServeHTTP(w, r)
}

In einem RESTful Shop API auf Basis des DefaultServeMux lassen sich so individuelle Handler oder auch Handler-Funktionen für die jeweiligen HTTP-Methoden einsetzen (Listing 4).

customerAPI := NewMethodMux()

customerAPI.Handle(http.MethodPost, NewCustomerCreateHandler())
customerAPI.Handle(http.MethodGet, NewCustomerReadHandler())
customerAPI.Handle(http.MethodPut, NewCustomerUpdateReplaceHandler())
customerAPI.Handle(http.MethodPatch, NewCustomerUpdateModifyHandler())
customerAPI.Handle(http.MethodDelete, NewCustomerDeleteHandler())

http.Handle("/api/customers/", customerAPI)

Für viele APIs mag aber die Nutzung nur eines Handlers mit unterschiedlichen Go-Methoden für die verschiedenen HTTP-Methoden der elegantere Weg sein. Hier lässt sich leicht ein hilfreicher Wrapper entwickeln. Er prüft das Vorhandensein der jeweils passenden Methode und ruft diese dann auf. Hierzu ist einmalig für jede Methode ein eigenes kleines Interface zu definieren (Listing 5).

type GetHandler interface {
  ServeGet(w http.ResponseWriter, r *http.Request)
}

type PostHandler interface {
  ServePost(w http.ResponseWriter, r *http.Request)
}

// ...

Der Wrapper nutzt nun die Type Assertion von Go in der ServeHTTP()-Methode. Sie erlaubt es ihm, über die Interfaces zu prüfen, ob der enthaltene Handler eine HTTP-Methode verarbeiten kann (Listing 6).

type MethodWrapper struct {
  handler http.Handler
}

func (mw MethodWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  switch r.Method {
  case http.MethodGet:
    if h, ok := mw.handler.(GetHandler); ok {
      h.ServeGet(w, r)
      return
    }
  case http.MethodPost:
    if h, ok := mw.handler.(PostHandler); ok {
      h.ServePost(w, r)
      return
    }
  case ...:
    ...
  }
  mw.handler.ServeHTTP(w, r)
}

Nun kann die Logik auf die entsprechenden Methoden verteilt werden. Dabei können sie alle von den Feldern und weiteren Methoden des Handlers profitieren, beispielsweise für den Zugriff auf die Persistenz. Die ServeHTTP()-Methode ist für alle weiteren HTTP-Methoden oder die Fehlermeldung zuständig (Listing 7).

type shopAPI struct {}

func (api shopAPI) ServeGet(w http.ResponseWriter, r *http.Request) { ... }

func (api shopAPI) ServePost(w http.ResponseWriter, r *http.Request) { ... }

func (api shopAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  http.Error(w, "cannot handle shop request", http.StatusMethodNotAllowed)
}

So wird das Deployment zu einem Einzeiler:

http.Handle("/shop/", NewMethodWrapper(NewShopAPI()))

Der Wrapper lässt sich so übrigens auch für weitere produktive Handler einsetzen.

Mit Sicherheit

Eine weitere Anforderung an viele Webanwendungen ist der Schutz gegen unberechtigte Zugriffe. Eine hierfür geeignete Technologie sind JSON Web Tokens. Sie enthalten via Schlüssel gesichert eine Payload mit standardisierten sowie eigenen Feldern. Hierunter sind beispielsweise die Benutzerkennung (sub), die Ausgabezeit (iat), die Startzeit (nbf) und die Ablaufzeit (exp). Eigene der als Claims bezeichneten Felder können zudem Dinge wie Rollen oder berechtigte Pfade für den jeweiligen Benutzer enthalten. Der JWT-Standard kann über Hashing sicherstellen, dass diese Payload nicht verändert werden kann.

Der Token für einen Benutzer ist nach dessen Anmeldung durch den Server zu generieren. Anschließend ist er bei jedem gesicherten Request als Header der Form Authorization: Bearer <Token> mitzuliefern. Ein Sicherheits-Wrapper hat nun die Aufgabe, diesen Header auszulesen, die Korrektheit und Gültigkeit der Payload zu prüfen und dann auf Basis von Methode und Pfad nur berechtigte Anfragen durchzulassen. Für die Nutzung der JWT stehen glücklicherweise hinreichend helfende Bibliotheken zur Verfügung (Listing 8).

type AuthWrapper struct {
  handler http.Handler
  authURL string
  roles   []string
}

func NewAuthWrapper(
  handler http.Handler, 
  authURL string,
  roles ...string) http.Handler {
  return AuthWrapper{
    handler: handler,
    authURL: authURL,
    roles:   roles...,
  }
}

func (aw AuthWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  if !aw.hasValidToken(r) {
    http.Redirect(w, r, aw.authURL, http.StatusUnauthorized)
    return
  }
  if !aw.isAllowed(r) {
    http.Error(w, "access not allowed", http.StatusUnauthorized)
    return
  }
  w.handler.ServeHTTP(w, r)
}

Ist kein Token vorhanden oder dieser abgelaufen, erfolgt ein Redirect auf einen URL zur Anmeldung. Eine sinnvolle Ergänzung wäre auch noch eine automatische Verlängerung, wenn das Token in den folgenden Minuten abläuft. Strategien gibt es hier viele, wesentlich ist dabei die Idee des Wrappings als Baukastensystem.

usersAPI := NewAuthWrapper(NewMethodWrapper(NewUsersAPI()), "admin")

http.Handle("/api/users/", usersAPI)

Mit dem trivialen Interface nur einer Methode ist dies einfacher und komfortabler möglich als bei komplexen Schnittstellen und umfangreicher Konfiguration. Eine weitere Stärke dieses Ansatzes ist zudem das erleichterte isolierte Testen der auf die Geschäftslogik ausgerichteten Handler. Hierfür bringt Go in net/http/httptest mit dem httptest.Server einen entsprechenden lokalen Server mit.

Und RESTful APIs?

Der http.ServeMux kann zwar mit klar definierten Pfaden umgehen, dynamische Bestandteile beherrscht er jedoch nicht. Gute RESTful APIs adressieren so je nach HTTP-Methode

  • via /api/orders einen neuen Auftrag oder eine Liste von Aufträgen,
  • via /api/orders/{order-id} einen einzelnen Auftrag,
  • via /api/orders/{order-id}/items eine neue Auftragsposition oder eine Liste von Auftragspositionen und
  • via /api/orders/{order-id}/items/{item-id} eine einzelne Auftragsposition.

Schön wäre es, wenn hierfür der Einsatz von nur zwei Handlern ausreichend wäre: ein OrdersAPI für die Aufträge und ein OrdersItemsAPI für die Auftragspositionen. Doch wie kann das elegant realisiert werden?

Mit dem ServeMux werden alle Requests ab /api/orders/ einem Handler zugeordnet. Dieser müsste sich dann also sowohl um die Aufträge als auch die Auftragspositionen kümmern. Eine kleine Hilfsfunktion zur Prüfung und Ermittlung der jeweiligen Positionen mit Auftragsnummer beziehungsweise dem Index der Auftragsposition ist kein Problem. Mit ihr kann das OrdersAPI via id, ok := GetPathElem(r, 3) leicht die Auftragsnummer ermitteln. Bleibt also noch die Frage, wie die jeweiligen Requests auf die beiden Handler zu verteilen sind. Auch hier hilft wieder ein einfacher Wrapper (Listing 9).

type NestedWrapper struct {
  firstHandler  http.Handler
  secondHandler http.Handler
}

func (w NestedWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  switch len(strings.Split(r.URL.Path, "/")[1:]) {
  case 2, 3:
    w.firstHandler.ServeHTTP(w, r)
  case 4, 5:
    w.secondHandler.ServeHTTP(w, r)
  default:
    http.Error(w, "invalid URL", http.StatusNotFound)
  }
}

Die durch diesen Wrapper verwalteten Handler können ihrerseits natürlich wieder über andere Wrapper verfügen, je nach Bedarf. Zum Beispiel auch wieder über die bereits oben beschriebenen Wrapper für die Verwaltung von Rechten oder das Dispatching der HTTP-Methoden (Listing 10).


ordersAPI := NewAuthWrapper(
  NewNestedWrapper(
    NewMethodWrapper(NewOrdersAPI()), 
    NewMethodWrapper(NewOrdersItemsAPI()),
  ), 
  "dispatcher",
)

http.Handle("/api/orders/", ordersAPI)

Fazit

Natürlich gibt es sinnvolle Anwendungsgebiete, in denen der Gewinn durch die Verwendung einer existierenden Bibliothek größer ist als die Nachteile der so geschaffenen Komplexität und Abhängigkeiten. Doch diese Entscheidung sollte gut durchdacht und bewertet werden. Go ist vom Kern auf Einfachheit und Komposition dieser Einfachheit ausgelegt. Und das Package net/http zeigt über ein Interface und den Typ http.Request und http.ResponseWriter, wie sich mit dieser Philosophie schnell beherrschbare Toolboxen für die eigenen Bedürfnisse aufbauen lassen.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: