1. Wprowadzenie
Jeśli piszesz serwer „na żywca”, to niemal od razu pojawiają się wymagania przekrojowe: dodać nagłówek do wszystkich odpowiedzi, logować każde żądanie, sprawdzać dostęp, ograniczać częstotliwość, łapać paniki. I nagle odkrywasz, że ten sam kod dubluje się w każdym handlerze. W pewnym momencie zmieniasz jedną regułę (na przykład format błędu) — i zaczynasz szukać „jeszcze 17 miejsc, gdzie to poprawić”.
Middleware rozwiązuje ten problem tak samo, jak dobry nawyk mycia rąk rozwiązuje problem „dlaczego znowu się przeziębiłem”: to nie magia, tylko systematyczność.
Kluczowa idea: handler zajmuje się logiką biznesową, a middleware zajmuje się polityką przekrojową wokół tej logiki.
Kontrakt net/http: http.Handler jako punkt opakowania
Zanim zaczniemy budować łańcuchy, warto jeszcze raz zobaczyć kontrakt, wokół którego będziemy się poruszać. W net/http obsługujący żądania to dowolny typ, który ma metodę ServeHTTP(w, r). Właśnie dlatego w Go tak łatwo pisać adaptery i opakowania: po prostu zwracasz inny http.Handler, który wewnątrz wywołuje następny. To ten sam „wzorzec adaptera”, tylko bez pompatycznych słów.
Przypomnijmy minimalny szkielet serwera (mocno uproszczony):
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Prawie wszystko interesujące w dalszej architekturze serwera kręci się wokół tego, co dokładnie przekazujemy jako drugi argument do ListenAndServe.
Middleware jako funkcja-opakowanie
Kiedy po raz pierwszy słyszysz „middleware”, chcesz zapytać: „To biblioteka? To framework? To religia?” W rzeczywistości to po prostu funkcja, która bierze jeden handler i zwraca inny handler. Czyli dosłownie „opakowanie” wokół http.Handler.
Najpopularniejsza sygnatura wygląda tak:
func(next http.Handler) http.Handler
Aby kod czytał się łatwiej, zwykle wprowadza się nazwany typ:
package httpmw
import "net/http"
type Middleware func(http.Handler) http.Handler
Po tym każda middleware czyta się jak „przyjmij następny poziom (next) i zwróć nowy poziom”.
2. Mechanika middleware: „przed → next → po”
Prawie każda middleware wewnątrz wygląda jak „zrobić coś przed żądaniem”, potem przekazać sterowanie niżej i „zrobić coś po”. Wygodnie jest wyobrażać to sobie jak cebulę: zewnętrzna warstwa widzi żądanie jako pierwsza, a odpowiedź — jako ostatnia.
Zróbmy minimalną middleware, która dodaje nagłówek do wszystkich odpowiedzi. (Tak, to proste, ale idealne do zrozumienia mechaniki.)
package httpmw
import "net/http"
func WithHeader(name, value string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(name, value) // "przed"
next.ServeHTTP(w, r) // "przekazać niżej"
})
}
}
Zwróć uwagę: używamy http.HandlerFunc, ponieważ to adapter „funkcja jako handler”. Czyli nie musimy deklarować osobnego type X struct{} tylko po to, aby mieć ServeHTTP.
Short-circuit: middleware może zatrzymać żądanie
Początkujący często mają wewnętrzne przekonanie, że next.ServeHTTP(...) trzeba wywoływać zawsze, bo „jak to inaczej pójdzie dalej?”. Ale sens niektórych middleware polega właśnie na tym, żeby nie przepuszczać żądania dalej: sprawdzanie dostępu, limity, tryb maintenance.
Zróbmy przykład „serwera w trybie konserwacji”: jeśli flaga jest włączona — odpowiadamy od razu.
package httpmw
import "net/http"
func MaintenanceMode(enabled bool) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enabled {
w.WriteHeader(http.StatusServiceUnavailable)
return // short-circuit: nie idziemy niżej
}
next.ServeHTTP(w, r)
})
}
}
Najważniejsze jest tu zrozumienie morału: middleware ma prawo zakończyć żądanie samodzielnie. To nie „hack”, tylko podstawowe narzędzie budowania polityk.
4. Łańcuch middleware i kolejność zastosowania
Gdy middleware jest więcej niż jedno, chcemy zastosować je wszystkie do jednego mux (albo do jednego handlera). I wtedy pojawia się ważne pytanie: w jakiej kolejności są stosowane?
Jeśli mamy:
- A — middleware nr 1
- B — middleware nr 2
- C — middleware nr 3
- H — finalny handler (na przykład mux)
to logiczna „cebula” zwykle wygląda tak:
A(B(C(H)))
Czyli A jest najbardziej zewnętrzną warstwą: widzi żądanie jako pierwsza i „odprowadza” odpowiedź jako ostatnia.
Do zrozumienia kolejności przydatny jest schemat:
flowchart TD
R[HTTP request] --> A[Middleware A]
A --> B[Middleware B]
B --> C[Middleware C]
C --> H["Final handler (mux)"]
H --> C
C --> B
B --> A
A --> W[HTTP response]
Tu ważny jest jeden szczegół: „tam” idziemy A → B → C → H, a „z powrotem” (gdy handler już zakończył pracę) — H → C → B → A. Dlatego kolejność middleware wpływa nawet na takie rzeczy, jak logowanie statusu czy przechwytywanie panic: zewnętrzna warstwa „widzi wszystko”, co wydarzyło się wewnątrz.
Funkcja Chain: składamy łańcuch w czytelnej kolejności
Teraz chcemy zrobić tak, aby kolejność była:
- jasno widoczna w kodzie,
- zawsze składana tak samo (a nie „tu tak, tam inaczej”),
- nie wymagała ręcznego zagnieżdżania A(B(C(H))), bo to boli nawet przy samym patrzeniu.
Zróbmy helper Chain. Kluczowy trik: stosujemy middleware w odwrotnej kolejności, aby pierwsza na liście była najbardziej zewnętrzna.
package httpmw
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
I właśnie to miejsce jest „magią bez magii”: lista czyta się od lewej do prawej (jak jesteśmy przyzwyczajeni), ale jest stosowana od prawej do lewej, aby wynik był mws[0](mws[1](...h)).
Kto wykona się pierwszy: mini-tabela
Aby już całkiem się nie pogubić (a pogubicie się, to tradycja), weźcie prostą tabelę. Załóżmy:
h := Chain(mux, A, B, C)
Wtedy:
| Element | Kto widzi żądanie pierwszy | Kto wywołuje next pierwszy | Kto „zamyka” odpowiedź jako ostatni |
|---|---|---|---|
|
tak | tak | tak |
|
nie | po |
przed |
|
nie | po |
przed |
|
nie | najbardziej wewnętrzny | najbardziej wewnętrzny |
Jeśli czujesz lekki zawrót głowy — to normalne. Kolejność middleware najpierw przypomina „kable od słuchawek”: wydaje się, że nic nie robiłeś, a i tak wszystko się poplątało.
„Ręczna matrioszka” vs Chain
Żeby umieć czytać cudzy kod, warto zobaczyć oba warianty. Czasem trafisz na coś takiego:
h := A(B(C(mux)))
To jest uczciwe, proste i działa. Ale po tygodniu A(B(C(D(E(F(...))))) wygląda jak hasło do Wi‑Fi w kawiarni: niby znaki są, ale radości niewiele.
Z Chain to samo zamienia się w:
h := Chain(mux, A, B, C)
I to już czyta się jako „A na zewnątrz, potem B, potem C”. Właśnie tego chcemy.
5. Podłączamy chaining do aplikacji bez przepisywania serwera
Do tej pory przekazywaliśmy do ListenAndServe nasz mux. Teraz będziemy przekazywać opakowany mux. I to jest ważny architectural win: zmieniamy zachowanie całego serwera, nie ruszając samych endpointów.
Szkielet main.go (uproszczony, ale w istocie poprawny):
package main
import (
"log"
"net/http"
"time"
"example.com/taskapi/httpmw"
)
func main() {
mux := http.NewServeMux()
registerRoutes(mux)
h := httpmw.Chain(mux,
httpmw.WithHeader("Content-Type", "application/json; charset=utf-8"),
httpmw.MaintenanceMode(false),
httpmw.WithTimeoutHint(2*time.Second),
)
log.Fatal(http.ListenAndServe(":8080", h))
}
Tutaj registerRoutes(mux) to wasz zwyczajowy kod rejestracji endpointów (/api/v1/tasks, /api/v1/tasks/{id} itd.), którego nie chcemy ruszać. A WithTimeoutHint to kolejna prosta middleware, którą zaraz napiszemy, aby zobaczyć przykład „sparametryzowanego” opakowania.
Przykład middleware z parametrem: WithTimeoutHint
Chcemy pokazać middleware, która przyjmuje parametr i robi coś użytecznego, ale nie wymaga trudnych tematów. Nagłówki pasują idealnie.
Załóżmy, że chcemy dać klientowi „wskazówkę”: serwer oczekuje, że żądania będą szybkie (to nie jest prawdziwy timeout, tylko demonstracja „zachowania przekrojowego”).
package httpmw
import (
"net/http"
"time"
)
func WithTimeoutHint(d time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Timeout-Hint", d.String())
next.ServeHTTP(w, r)
})
}
}
Zaletą tego przykładu jest to, że pokazuje „middleware jako fabrykę”: raz ustawiasz parametr podczas składania serwera, a potem dla każdego żądania działa ta sama reguła.
Dlaczego łańcuch lepiej składać w jednym miejscu
Gdy łańcuch middleware składa się w jednym miejscu (zwykle w main), dostajesz niemal darmowe korzyści.
Po pierwsze, kolejność można przeczytać wzrokiem jak umowę serwera: od góry do dołu wymienione są polityki, które obowiązują dla wszystkich żądań. Po drugie, jeśli coś zepsuło się w logice (na przykład nagle zniknęły nagłówki albo serwer przestał odpowiadać na część żądań), sprawdzasz jeden plik, a nie biegasz po dziesiątkach handlerów.
I wreszcie, to dyscyplinuje: jedna middleware — jedna odpowiedzialność. Gdy zaczynasz mieszać w jednym opakowaniu i nagłówki, i auth, i logowanie, i obsługę panic, wracasz do tej samej „kluski”, tylko w bardziej modnym sosie.
6. Niuans dotyczący Context: jak middleware przenosi dane w dół
Na razie nie będziemy zagłębiać się w przechowywanie danych w kontekście (to osobny duży temat), ale ważne jest, aby zapamiętać: middleware często nie tylko ustawia nagłówki, ale też dodaje coś do r.Context() i przekazuje dalej przez r.WithContext(...).
Dlaczego to w ogóle możliwe? Bo http.Request już zawiera Context, a standardowa biblioteka oczekuje, że będziesz respektować życie żądania przez kontekst (anulowanie, deadliny, metadane).
Na razie zapamiętajmy tylko myśl: jeśli middleware „utworzyła nowy kontekst”, trzeba go włożyć z powrotem do r, inaczej niższe warstwy nie zobaczą zmian.
7. Typowe błędy przy chainingu i kolejności middleware
Błąd nr 1: błędne oczekiwanie co do kolejności („dlaczego logowanie nie widzi tego, co chciałem?”).
Bardzo częste pomieszanie: wstawiłeś middleware jako pierwszą na liście, ale oczekujesz, że „zadziała po wszystkich”. W rzeczywistości pierwsza na liście zwykle jest najbardziej zewnętrzna: widzi żądanie pierwsza i kończy odpowiedź jako ostatnia. Jeśli chcesz „wykonać się bliżej handlera”, warstwa musi być głębiej (bardziej na prawo na liście).
Błąd nr 2: zapomniałeś wywołać next.ServeHTTP(w, r) tam, gdzie trzeba przepuścić żądanie dalej.
To klasyka: napisałeś middleware, sprawdziłeś jeden przypadek, wszystko wygląda dobrze. Potem okazuje się, że serwer odpowiada tylko 401/403/coś jeszcze — a „normalne” żądania po prostu nie przechodzą. Leczy się to dyscypliną: trzymać w głowie szkielet „przed → next → po” i jawnie sprawdzać wszystkie gałęzie return.
Błąd nr 3: przypadkowe podwójne wywołanie next.ServeHTTP.
Czasem początkujący próbują zrobić „ponowne wywołanie” albo błędnie wywołują next i w if, i po if. Efekt: handler zapisuje do ResponseWriter dwa razy, pojawiają się dziwne odpowiedzi, a czasem — paniki. Prawie zawsze middleware powinna wywołać next dokładnie raz (albo zero razy, jeśli short-circuit).
Błąd nr 4: zbyt późne zapisywanie nagłówków.
Nagłówki trzeba ustawić zanim ktoś niżej zacznie pisać ciało odpowiedzi albo wywoła WriteHeader. Jeśli wewnętrzny handler już zapisał body, to „zmienić nagłówek później” jest jak próba przepisania napisów końcowych po zakończeniu filmu w kinie: teoretycznie chcesz, praktycznie jest już za późno.
Błąd nr 5: składanie łańcucha „w różnych miejscach” i dryf kolejności.
Najpierw opakowujesz mux w jednym pliku, potem jakiś handler opakowuje inny handler „lokalnie”, potem w testach pojawia się trzeci schemat — i tracisz zrozumienie, co jest polityką globalną, a co lokalnym wyjątkiem. Dobra zasada: ogólna kolejność middleware jest ustalana raz na granicy aplikacji, a lokalne opakowania stosuje się tylko wtedy, gdy naprawdę dobrze wiesz, po co.
GO TO FULL VERSION