JavaRush /Курсы /Go SELF /Middleware chaining и порядок применения

Middleware chaining и порядок применения

Go SELF
62 уровень , 0 лекция
Открыта

1. Введение

Если вы пишете сервер «в лоб», то почти сразу появляются сквозные требования: добавить заголовок всем ответам, логировать каждый запрос, проверять доступ, ограничивать частоту, ловить паники. И внезапно вы обнаруживаете, что один и тот же код дублируется в каждом handler’е. В какой-то момент вы меняете одно правило (например, формат ошибки) — и начинаете искать «ещё 17 мест, где это поправить».

Middleware решает эту проблему так же, как хорошая привычка мыть руки решает проблему «почему я опять простыл»: это не магия, а системность.

Ключевая идея: handler занимается бизнес‑логикой, а middleware занимается сквозной политикой вокруг этой логики.

Контракт net/http: http.Handler как точка обёртки

Перед тем как строить цепочки, важно ещё раз увидеть контракт, вокруг которого мы будем плясать. В net/http обработчик — это любой тип, у которого есть метод ServeHTTP(w, r). Именно поэтому в Go легко писать адаптеры и обёртки: вы просто возвращаете другой http.Handler, который внутри вызывает следующий. Это тот же «паттерн адаптера», только без пафосных слов.

Вспомним минимальный каркас сервера (сильно упрощённо):

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))
}

Почти всё интересное в дальнейшей архитектуре сервера крутится вокруг того, что именно мы передаём в ListenAndServe вторым аргументом.

Middleware как функция-обёртка

Когда впервые слышишь «middleware», хочется спросить: «Это библиотека? Это фреймворк? Это религия?» На самом деле это просто функция, которая берёт один handler и возвращает другой handler. То есть, буквально «обёртка» вокруг http.Handler.

Самая популярная сигнатура выглядит так:

func(next http.Handler) http.Handler

Чтобы код читался легче, обычно вводят именованный тип:

package httpmw

import "net/http"

type Middleware func(http.Handler) http.Handler

После этого любая middleware читается как «прими следующий слой (next) и верни новый слой».

2. Механика middleware: «до → next → после»

Почти любая middleware внутри выглядит как «сделать что-то до запроса», затем передать управление ниже, и «сделать что-то после». Это удобно представлять как луковицу: внешний слой видит запрос первым, а ответ — последним.

Давайте сделаем минимальную middleware, которая добавляет заголовок всем ответам. (Да, это простенько, но идеально для понимания механики.)

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) // "до"
			next.ServeHTTP(w, r)        // "передать ниже"
		})
	}
}

Обратите внимание: мы используем http.HandlerFunc, потому что это адаптер «функция как handler». То есть мы не обязаны объявлять отдельный type X struct{} только ради ServeHTTP.

Short-circuit: middleware может остановить запрос

У новичков часто есть внутреннее ощущение, что next.ServeHTTP(...) надо вызывать всегда, иначе «как же оно дальше-то пойдёт?». Но смысл некоторых middleware как раз в том, чтобы не пускать запрос дальше: проверка доступа, лимиты, maintenance‑режим.

Сделаем пример «сервер на техобслуживании»: если включён флаг — отвечаем сразу.

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: ниже не идём
			}
			next.ServeHTTP(w, r)
		})
	}
}

Здесь главное — понять мораль: middleware имеет право завершить запрос самостоятельно. Это не «хак», а основной инструмент построения политик.

4. Цепочка middleware и порядок применения

Когда middleware становится больше одной, мы хотим применить их все к одному mux (или к одному handler’у). И тут рождается важный вопрос: в каком порядке они применяются?

Если у нас есть:

  • A — middleware №1
  • B — middleware №2
  • C — middleware №3
  • H — финальный handler (например mux)

то логическая «луковица» обычно выглядит так:

A(B(C(H)))

То есть A — самый внешний слой: он видит запрос первым и «провожает» ответ последним.

Для понимания порядка полезна схема:

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]

Тут важный момент: «туда» мы идём ABCH, а «обратно» (когда обработчик уже закончил) — HCBA. Поэтому порядок middleware влияет даже на такие вещи, как логирование статуса или перехват паник: внешний слой «видит всё», что произошло внутри.

Функция Chain: собираем цепочку с читаемым порядком

Теперь хочется сделать так, чтобы порядок был:

  • явно виден в коде,
  • всегда собирался одинаково (а не «где-то так, где-то иначе»),
  • не требовал ручного вложения A(B(C(H))), потому что это больно даже смотреть.

Сделаем helper Chain. Ключевой трюк: применяем middleware в обратном порядке, чтобы первая в списке стала внешней.

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
}

И вот это место — «магия без магии»: список читается слева направо (как мы привыкли), но применяется справа налево, чтобы итог был mws[0](mws[1](...h)).

Кто выполнится первым: мини-таблица

Чтобы окончательно не запутаться (а вы запутаетесь, это традиция), держите простую таблицу. Представим:

h := Chain(mux, A, B, C)

Тогда:

Сущность Кто видит запрос первым Кто вызывает next первым Кто «закрывает» ответ последним
A
да да да
B
нет после
A
перед
A
C
нет после
B
перед
B
mux
нет самый внутренний самый внутренний

Если вы чувствуете лёгкое головокружение — это нормально. Middleware‑порядок сначала ощущается как «провода в наушниках»: кажется, что ты ничего не делал, а оно всё равно запуталось.

«Ручная матрёшка» vs Chain

Чтобы вы умели читать чужой код, полезно увидеть оба варианта. Иногда вы встретите вот такое:

h := A(B(C(mux)))

Это честно, просто, и работает. Но через неделю A(B(C(D(E(F(...))))) выглядит как пароль от Wi‑Fi в кафе: вроде символы есть, но радости мало.

С Chain то же самое превращается в:

h := Chain(mux, A, B, C)

И это уже читается как «A снаружи, потом B, потом C». Именно это мы и хотим.

5. Подключаем chaining в приложение без переписывания сервера

До этого дня мы передавали в ListenAndServe наш mux. Теперь мы будем передавать обёрнутый mux. И это важный architectural win: мы меняем поведение всего сервера, не трогая сами endpoints.

Скелет main.go (упрощённый, но по сути правильный):

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))
}

Здесь registerRoutes(mux) — это ваш привычный код регистрации эндпоинтов (/api/v1/tasks, /api/v1/tasks/{id} и т.д.), который мы трогать не хотим. А WithTimeoutHint — ещё одна простая middleware, которую сейчас напишем, чтобы видеть пример «параметризованной» обёртки.

Пример middleware с параметром: WithTimeoutHint

Хочется показать middleware, которая принимает параметр и делает что-то полезное, но не требует сложных тем. Заголовки подходят идеально.

Допустим, мы хотим добавлять «намёк» клиенту: сервер ожидает, что запросы будут быстрые (это не настоящий таймаут, а просто демонстрация «сквозного поведения»).

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)
		})
	}
}

Плюс этого примера в том, что он показывает «middleware как фабрика»: вы один раз задаёте параметр при сборке сервера, и дальше для каждого запроса работает одинаковое правило.

Почему цепочку лучше собирать в одном месте

Когда цепочка middleware собирается в одном месте (обычно в main), вы получаете почти бесплатные преимущества.

Во‑первых, порядок можно прочитать глазами как договор сервера: сверху вниз перечислены политики, которые применяются ко всем запросам. Во‑вторых, если что-то сломалось в логике (например, внезапно пропали заголовки или сервер перестал отвечать на часть запросов), вы проверяете один файл, а не бегаете по десяткам handler’ов.

И, наконец, это дисциплинирует: одна middleware — одна ответственность. Когда вы начинаете смешивать в одной обёртке и заголовки, и auth, и логирование, и обработку паник, вы возвращаетесь к той самой «лапше», только в более модном соусе.

6. Нюанс про Context: как middleware протаскивает данные вниз

Сейчас мы не будем углубляться в хранение данных в контексте (это отдельная большая тема), но важно зафиксировать: middleware часто не только ставит заголовки, но и добавляет что-то в r.Context() и прокидывает дальше через r.WithContext(...).

Почему это вообще возможно? Потому что http.Request уже содержит Context, и стандартная библиотека ожидает, что вы будете уважать жизнь запроса через контекст (отмена, дедлайны, метаданные).

Пока просто запомним мысль: если middleware «создала новый контекст», его нужно положить обратно в r, иначе нижние слои не увидят изменений.

7. Типичные ошибки при chaining и порядке middleware

Ошибка №1: неправильное ожидание порядка («почему логирование не видит то, что я хотел?»).
Очень частая путаница: вы поставили middleware в список первой, но ждёте, что она «сработает после всех». На самом деле первая в списке обычно является самой внешней: она видит запрос первой и завершает ответ последней. Если вам нужно «выполниться ближе к handler’у», значит слой должен быть глубже (правее в списке).

Ошибка №2: забыли вызвать next.ServeHTTP(w, r) там, где надо пропускать запрос дальше.
Это классика: вы написали middleware, проверили один кейс, всё вроде хорошо. Потом оказывается, что сервер отвечает только 401/403/что‑то ещё — а «нормальные» запросы просто не проходят. Лечится дисциплиной: держать в голове каркас «до → next → после» и явно проверять все ветки return.

Ошибка №3: случайный двойной вызов next.ServeHTTP.
Иногда новички пытаются сделать «повторный вызов» или ошибочно вызывают next и в if, и после if. Итог: handler пишет в ResponseWriter дважды, появляются странные ответы, а иногда — паники. Почти всегда middleware должна вызывать next ровно один раз (или ноль раз, если short-circuit).

Ошибка №4: запись заголовков слишком поздно.
Заголовки нужно выставлять до того, как кто-то ниже начнёт писать тело ответа или вызовет WriteHeader. Если внутренний handler уже записал body, то «поменять заголовок потом» — как пытаться переписать титры после выхода фильма в кинотеатрах: теоретически хочется, practically уже поздно.

Ошибка №5: сборка цепочки «в разных местах» и дрейф порядка.
Сначала вы оборачиваете mux в одном файле, потом какой-то handler оборачивает другой handler «локально», потом в тестах появляется третья схема — и вы теряете понимание, что является глобальной политикой, а что локальным исключением. Хорошее правило: общий порядок middleware задаётся один раз на границе приложения, а локальные обёртки используются только если вы очень хорошо понимаете, зачем.

1
Задача
Go SELF, 62 уровень, 0 лекция
Недоступна
Заголовок на выход
Заголовок на выход
1
Задача
Go SELF, 62 уровень, 0 лекция
Недоступна
Луковая цепочка
Луковая цепочка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ