REST API mit net/http und dem neuen Routing von Go 1.22

1. Übersicht

Vor Go 1.22 konnte http.ServeMux nur nach Pfaden abgleichen. Es unterschied nicht zwischen GET und POST und konnte keine Path-Parameter wie /orders/{id} auslesen. Daher mussten viele Projekte zusätzliche Router wie gorilla/mux oder chi verwenden.

Ab Go 1.22 unterstützt ServeMux Method-Matching, Wildcards und klare Prioritätsregeln. Dieser Artikel zeigt dir, wie du eine REST API zur Verwaltung von Order nur mit der Standardbibliothek aufbaust. Du wirst auch Tests mit httptest schreiben und typische Fehler mit der neuen Syntax kennenlernen.

2. Projekt vorbereiten

Du brauchst Go 1.22 oder neuer. Die Zeile go in go.mod muss ebenfalls 1.22 oder höher sein:

mkdir order-api && cd order-api
go mod init example.com/order-api

Die Datei go.mod nach dem Erstellen:

module example.com/order-api

go 1.22

Wichtig: Das neue Routing-Verhalten hängt von der Zeile go in go.mod ab, nicht von der installierten Toolchain-Version. Wenn go.mod go 1.21 angibt, verhält sich ServeMux weiterhin wie vorher. Das Pattern "GET /orders/{id}" würde dann als Pfad mit Leerzeichen interpretiert, und deine Request erhält 404 ohne Fehlermeldung.

Dieser Artikel benötigt keine externen Abhängigkeiten, daher kein go get.

3. Neue Pattern-Syntax

3.1. Method und Path-Parameter abgleichen

Minimales Beispiel in main.go:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		name := r.PathValue("name")
		w.Write([]byte("Hallo, " + name + "\n"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

Der obige Code macht folgende Dinge:

  • "GET /hello/{name}": Der Teil vor dem Leerzeichen ist die Method, der Teil danach ist der Pfad. {name} ist ein Wildcard, der genau ein Segment abgleicht.
  • r.PathValue("name") gibt den abgeglichenen Wert zurück, immer als string. Wenn der Wildcard-Name nicht existiert, gibt die Funktion eine leere Zeichenkette zurück.

Zum Testen:

go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
Hallo, Lan

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed

Wenn der Pfad passt, aber die Method nicht, gibt ServeMux automatisch 405 mit dem Allow-Header zurück. Das GET-Pattern gleicht auch HEAD automatisch ab.

3.2. Wildcards mit mehreren Segmenten und exaktes Matching

Zwei weitere spezielle Syntaxen:

mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Datei: " + r.PathValue("path") + "\n"))
})

mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Startseite\n"))
})
  • {path...} muss am Ende des Patterns stehen. Es gleicht den Rest des Pfads ab, einschließlich /. Bei /files/docs/2024/report.pdf ist der Wert docs/2024/report.pdf.
  • {$} gleicht nur Pfade ab, die genau mit / enden. Ohne es gleicht das Pattern "/" jeden Pfad ab. Das ist eine häufige Ursache dafür, dass unbekannte Routes nicht 404 zurückgeben.

3.3. Prioritätsregeln

Wenn mehrere Patterns eine Request abgleichen, gewinnt das spezifischere Pattern:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // gewinnt für /orders/latest

/orders/latest ist spezifischer als /orders/{id}, weil jede Request, die das zweite Pattern abgleicht, auch das erste abgleicht, aber nicht umgekehrt. Die Reihenfolge der Registrierung ist irrelevant.

Wenn zwei Patterns überlappen und keines spezifischer ist, wird Go bei der Registrierung in panic gehen:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...

Der Fehler tritt beim Programmstart auf, nicht während des Anfrageverarbeitens. So erkennst du Routing-Fehler früh.

4. Order API aufbauen

4.1. Model und In-Memory-Store

Erstelle eine Datei store.go:

package main

import (
	"sort"
	"sync"
)

type Order struct {
	ID       int     `json:"id"`
	Customer string  `json:"customer"`
	Total    float64 `json:"total"`
}

type OrderStore struct {
	mu     sync.RWMutex
	orders map[int]Order
	nextID int
}

func NewOrderStore() *OrderStore {
	return &OrderStore{orders: make(map[int]Order), nextID: 1}
}

func (s *OrderStore) Create(o Order) Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	o.ID = s.nextID
	s.nextID++
	s.orders[o.ID] = o
	return o
}

func (s *OrderStore) Get(id int) (Order, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	o, ok := s.orders[id]
	return o, ok
}

func (s *OrderStore) List() []Order {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make([]Order, 0, len(s.orders))
	for _, o := range s.orders {
		result = append(result, o)
	}
	sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
	return result
}

func (s *OrderStore) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.orders[id]; !ok {
		return false
	}
	delete(s.orders, id)
	return true
}

Jede Request wird in einer eigenen Goroutine verarbeitet. Deshalb muss die map mit sync.RWMutex geschützt werden. Ohne Mutex würde go test -race sofort einen Data Race melden. List sortiert nach ID, weil die Reihenfolge beim Durchlaufen einer map in Go zufällig ist.

4.2. Hilfsfunktionen für JSON

Erstelle eine Datei json.go:

package main

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

Die Reihenfolge der Aufrufe ist wichtig. Du musst Header().Set vor WriteHeader aufrufen. Nach dem Aufrufen von WriteHeader oder Write werden alle Header-Änderungen ignoriert.

4.3. Handler

Erstelle eine Datei handler.go:

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

type OrderHandler struct {
	store *OrderStore
}

func (h *OrderHandler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /orders", h.list)
	mux.HandleFunc("POST /orders", h.create)
	mux.HandleFunc("GET /orders/{id}", h.get)
	mux.HandleFunc("DELETE /orders/{id}", h.delete)
}

func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, h.store.List())
}

func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maximal 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var in Order
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "ungültiger Body: "+err.Error())
		return
	}
	if in.Customer == "" || in.Total <= 0 {
		writeError(w, http.StatusUnprocessableEntity, "customer und total sind erforderlich")
		return
	}

	created := h.store.Create(in)
	w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
	writeJSON(w, http.StatusCreated, created)
}

func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	order, found := h.store.Get(id)
	if !found {
		writeError(w, http.StatusNotFound, "Order nicht gefunden")
		return
	}
	writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	if !h.store.Delete(id) {
		writeError(w, http.StatusNotFound, "Order nicht gefunden")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id muss eine positive Ganzzahl sein")
		return 0, false
	}
	return id, true
}

Einige Punkte zum Beachten:

  • Routes bündelt alle Route-Deklarationen an einem Ort. Du siehst sofort, welche Endpoints die API hat.
  • GET /orders und GET /orders/{id} konfligieren nicht, da die Anzahl der Segmente unterschiedlich ist.
  • ServeMux validiert Datentypen nicht. {id} gleicht auch abc ab, daher musst du selbst mit strconv.Atoi validieren.
  • MaxBytesReader blockiert zu große Bodies. DisallowUnknownFields lehnt unbekannte Felder wie "totl" (Tippfehler) ab.

4.4. Middleware und main-Funktion

Middleware in net/http ist nur eine Funktion, die einen http.Handler annimmt und einen http.Handler zurückgibt. Aktualisiere main.go:

package main

import (
	"log"
	"net/http"
	"time"
)

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
	})
}

func newServer() http.Handler {
	mux := http.NewServeMux()
	h := &OrderHandler{store: NewOrderStore()}
	h.Routes(mux)
	return logging(mux)
}

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newServer(),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

Die Funktion newServer trennt den Handler-Aufbau von main. Dadurch können Tests die gleiche Konfiguration wie der Live-Betrieb verwenden. http.Server hat ReadHeaderTimeout, um gegen Clients mit langsamen Header-Übertragungen zu schützen. http.ListenAndServe setzt diese Timeout standardmäßig nicht.

Ab Go 1.23 hat http.Request ein zusätzliches Feld Pattern. Du kannst r.Pattern loggen (z. B. GET /orders/{id}) statt des tatsächlichen Pfads. Das hilft, Metriken nach Route zu gruppieren.

Zum Testen:

go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"id muss eine positive Ganzzahl sein"}
204

5. Tests mit httptest schreiben

Go hat net/http/httptest eingebaut, daher musst du keinen echten Port öffnen. Erstelle eine Datei handler_test.go:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestCreateAndGetOrder(t *testing.T) {
	srv := newServer()

	body := strings.NewReader(`{"customer":"Lan","total":120000}`)
	req := httptest.NewRequest(http.MethodPost, "/orders", body)
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("POST status = %d, want 201", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/orders/1" {
		t.Errorf("Location = %q, want /orders/1", loc)
	}

	req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
	rec = httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	var got Order
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatal(err)
	}
	if got.Customer != "Lan" || got.Total != 120000 {
		t.Errorf("got %+v", got)
	}
}

func TestRouting(t *testing.T) {
	srv := newServer()
	tests := []struct {
		name, method, path string
		want               int
	}{
		{"id ist keine Zahl", http.MethodGet, "/orders/abc", http.StatusBadRequest},
		{"nicht vorhanden", http.MethodGet, "/orders/99", http.StatusNotFound},
		{"falsche Method", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
		{"unbekannte Route", http.MethodGet, "/customers", http.StatusNotFound},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
			if rec.Code != tc.want {
				t.Errorf("status = %d, want %d", rec.Code, tc.want)
			}
		})
	}
}

Der erste Test prüft den Ablauf vom Erstellen bis zum Auslesen einer Order. Der zweite Test ist ein Table-Driven Test, der das Routing-Verhalten überprüft, einschließlich des von ServeMux automatisch generierten 405. Führe es mit Race Detector aus:

go test -race -v ./...
=== RUN   TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN   TestRouting
=== RUN   TestRouting/id_ist_keine_Zahl
...
--- PASS: TestRouting (0.00s)
PASS

6. Häufige Fehler und wann man es nicht verwenden sollte

Häufig auftretende Fehler:

  • go.mod gibt eine alte Version an. Alle neuen Routes geben 404 zurück. Überprüfe zuerst die Zeile go in go.mod.
  • Falsche Pattern-Syntax. Es muss genau ein Leerzeichen zwischen Method und Pfad sein. Die Method muss großgeschrieben sein, z. B. GET nicht get.
  • Versehentlich "/" als Fallback verwenden. Das Pattern "/" fängt jede Request auf, die keine andere Route passt. Verwende "/{$}", wenn du nur die Startseite willst.
  • Wildcards teilen sich kein Segment mit anderen Zeichen. Das Pattern /orders/{id}.json ist ungültig. Ein Wildcard kann nicht mit anderen Zeichen im selben Segment stehen.
  • 405 gibt Text zurück statt JSON. ServeMux gibt Text für 404 und 405 zurück, nicht JSON. Wenn dein Client einheitliches JSON erwartet, musst du eine zusätzliche Ebene hinzufügen.

Hinweis: Wenn ein altes Projekt nach dem Upgrade von go.mod auf 1.22 kaputt geht, kannst du vorübergehend GODEBUG=httpmuxgo121=1 setzen, um das alte Verhalten zu nutzen. Das ist nur eine Übergangslösung. Du solltest die Patterns trotzdem korrigieren.

Reine net/http reicht möglicherweise nicht aus, wenn du:

  • Typbindung direkt in der Route brauchst, z. B. nur Zahlen abgleichen oder Regex verwenden.
  • Routes mit eigener Middleware pro Gruppe gruppieren. Das ist mit mehreren verschachtelten ServeMux möglich, aber der Code wird aufgebläht.
  • Ein vorgebautes Middleware-Ökosystem wie CORS, Rate Limiting oder Recovery brauchst.

Für diese Fälle ist chi eine leichte Wahl und vollständig kompatibel mit http.Handler. Außer in diesen Fällen reicht die Standardbibliothek für die meisten REST APIs aus.

7. Zusammenfassung

Go 1.22 macht http.ServeMux mächtig genug für echte REST APIs. Du kannst nach Method abgleichen, Path-Parameter mit r.PathValue auslesen und Go 405 sowie Prioritätsregeln automatisch handeln lassen. Du hast auch eine vollständige Order API mit Middleware, Input-Validierung und Tests mit httptest gebaut, ohne externe Abhängigkeiten.

Das wichtigste zum Merken: Setze go 1.22 oder höher in go.mod, verwende {$} für exaktes Matching und validiere Wildcard-Werte selbst, bevor du sie verwendest.

golangnet/httprest-apigo-1.22httptest