net/http вглубь: сервисы на стандартной библиотеке (и почему не gin)

Стандартный net/http после Go 1.22 (роутинг по методу и пути, path values) + middleware-функции покрывают большинство HTTP-сервисов без фреймворка. Разбираем ServeMux, middleware, таймауты сервера и клиента — и дуализм gin: почему он самый популярный и почему для долгоживущего сервиса стоит держаться ближе к stdlib

«Первым делом ставим роутер и фреймворк» — так по инерции начинается почти каждый Go-сервис. Между тем стандартная библиотека давно закрывает большую часть того, ради чего фреймворк тянули: интерфейс http.Handler, вокруг которого выстроена вся экосистема, а с Go 1.22 — ещё и роутинг по методу и пути прямо в ServeMux. Для долгоживущего сервиса, который поддерживают годами, это меняет расчёт: тонкий слой middleware-функций поверх stdlib оказывается дешевле в сопровождении, чем зависимость от фреймворка.

Разберём net/http по узлам типового сервиса — роутинг, middleware, таймауты сервера, клиент — и в конце честно взвесим дуализм gin: почему он самый популярный веб-фреймворк Go и почему для сервиса «на годы» стоит держаться ближе к стандартной библиотеке. Всё заземлено на живой стенд digital-cookbook/go-net-http/: только stdlib, без единой внешней зависимости, поведение доказано тестами через net/http/httptest.

Схема «Полдень»: HTTP-сервис на стандартной библиотеке Go — маршрутизация ServeMux (method+path, 405/404), цепочка middleware (recover/request-id/logger), четыре таймаута http.Server со щитом от Slowloris, graceful shutdown; весы gin ⇄ stdlib после Go 1.22

В статье

Философия net/http: один интерфейс

Вся мощь net/http держится на одном маленьком интерфейсе:

type Handler interface {
	ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Один метод — ServeHTTP. Всё, что умеет обрабатывать HTTP-запрос в Go, — это Handler. Сервер (http.Server), маршрутизатор (http.ServeMux), любой обработчик — всё это Handler. Функцию с нужной сигнатурой поднимает до интерфейса адаптер http.HandlerFunc:

// В stdlib это буквально так:
type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	f(w, r) // тип-функция сама реализует интерфейс, вызывая себя
}

Отсюда — сила стандартной библиотеки как точки сборки. Раз любой обработчик и любой роутер сводятся к http.Handler, компоненты складываются друг с другом без клея: middleware — это func(http.Handler) http.Handler, роутер отдаёт http.Handler, сервер принимает http.Handler. Экосистема (chi, метрики, трейсинг, тестовые серверы httptest) говорит на одном языке http.Handler — компоненты от разных авторов совместимы, потому что опираются на один стандартный контракт, а не на тип конкретного фреймворка. Именно эту совместимость теряют, когда переходят на собственный тип контекста фреймворка (об этом — в разделе про gin).

Роутинг на стандартном ServeMux (Go 1.22)

Главная историческая причина тянуть сторонний роутер — стандартный ServeMux до Go 1.22 не умел ни метода, ни path-параметров. Приходилось руками разбирать r.Method и резать r.URL.Path, или ставить chi/gorilla/mux. Go 1.22 это закрыл: паттерн теперь может содержать метод и wildcard-сегменты.

func New() *http.ServeMux {
	mux := http.NewServeMux()

	// "GET /users/{id}" — метод и путь в одном паттерне. Несовпадение метода
	// даёт 405, несовпадение пути — 404; всё это ServeMux делает сам.
	mux.HandleFunc("GET /users/{id}", getUser)

	// Отдельный обработчик на POST того же префикса: методы не конфликтуют,
	// ServeMux выбирает по паре (метод, путь).
	mux.HandleFunc("POST /users", createUser)

	return mux
}

// getUser достаёт wildcard-сегмент {id} через r.PathValue и отдаёт JSON.
func getUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	writeJSON(w, http.StatusOK, User{ID: id, Name: "user-" + id})
}

Два нововведения делают всю работу. Во-первых, метод в паттерне: "GET /users/{id}" матчит только GET; DELETE на тот же путь автоматически получит 405 Method Not Allowed, причём ServeMux сам выставит корректный заголовок Allow. Во-вторых, именованный wildcard {id} достаётся через r.PathValue("id") — без ручного парсинга пути. Поддерживается и {path...} для захвата остатка пути.

Стенд router/ проверяет ровно эти четыре ветки через httptest (без поднятия сетевого сервера — только NewRequest + NewRecorder):

func TestGetUserReturnsPathValue(t *testing.T) {
	mux := New()
	req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("ожидали 200, получили %d", rec.Code)
	}
	var u User
	if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil {
		t.Fatalf("не разобрали тело: %v", err)
	}
	if u.ID != "42" {
		t.Errorf("ожидали id=42, получили %q", u.ID)
	}
}

Итог тестового набора: GET /users/42200 и id == "42"; чужой метод (DELETE /users/42) → 405; несуществующий путь (/unknown) → 404; POST /users с валидным JSON → 201. Всё это — без единой строки ручной диспетчеризации. Именно это убрало главный довод в пользу стороннего роутера: базовые нужды сервиса (метод, путь, параметр пути) стандартный ServeMux теперь закрывает сам.

Тонкости, которые стоит держать в голове. Приоритет маршрутов — по специфичности: конкретный путь выигрывает у wildcard, более длинный паттерн — у более общего, порядок регистрации не важен. Конфликтующие паттерны ServeMux ловит на старте — паникой при регистрации, а не тихой неоднозначностью в рантайме. Чего в stdlib по-прежнему нет — регэкспных ограничений на сегмент ({id:[0-9]+}) и групп маршрутов с общим префиксом; если это критично, chi остаётся разумным выбором — он тоже строится на http.Handler и совместимости не ломает.

Middleware без фреймворка

Сквозная логика — логирование, восстановление после паники, аутентификация, request-id — в идиоме net/http выражается одним типом: функция, которая оборачивает http.Handler и возвращает http.Handler.

// Middleware — канонический тип: принимает http.Handler, возвращает http.Handler.
type Middleware func(http.Handler) http.Handler

// Chain оборачивает h слоями mws. Применяем в обратном порядке, чтобы при
// вызове Chain(h, mw1, mw2) внешним оказался mw1: запрос идёт mw1 → mw2 → h.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

Chain — весь «фреймворк», который нужен для композиции: он склеивает слои так, что первый в списке оказывается внешним, и запрос проходит их по порядку. Никакого реестра, рефлексии или магии — только замыкания над http.Handler.

Два слоя из стенда показывают типовые задачи. Первый — recover, и здесь важно сначала понять, зачем он нужен, потому что расхожая формулировка «без него паника уронит сервер» неверна. Сам net/http.Server уже перехватывает панику обработчика: он логирует стектрейс и обрывает соединение (для HTTP/2 — сбрасывает поток). То есть паника одного запроса не роняет процесс и не задевает другие запросы даже без всякого middleware. Recover-слой нужен для другого — вернуть клиенту контролируемый ответ (аккуратный 500 в вашем формате вместо резко оборванного соединения) и записать событие в ваши логи и метрики единообразно. Спасение процесса тут ни при чём.

Важная оговорка про «контролируемый 500»: он получится, только если обработчик ещё не начал писать ответ. Как только в ResponseWriter ушли заголовки и часть тела, статус уже отправлен — «переиграть» его на чистый 500 нельзя (http.Error попытается записать статус повторно, но это уже поздно). Если паника случилась в середине записи ответа, recover остановит её и залогирует, но клиент получит оборванный, наполовину записанный ответ, а не опрятную ошибку. Поэтому middleware надёжно даёт 500 для паники до первой записи (частый случай — паника в бизнес-логике перед формированием ответа); для паник посреди стриминга гарантий нет.

// Recover перехватывает панику в любом нижележащем обработчике через defer +
// recover и превращает её в контролируемый 500. Важно: сам net/http.Server и
// без этого слоя перехватывает панику обработчика (логирует стектрейс и
// обрывает соединение), так что паника одного запроса не роняет процесс. Слой
// нужен ради аккуратного ответа клиенту (500 в своём формате вместо
// оборванного соединения) и единообразных логов/метрик, а не ради спасения
// процесса. Оговорка: чистый 500 выйдет, только если ответ ещё не начали
// писать; если заголовки уже ушли, http.Error не сможет переиграть статус.
func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Тест стенда доказывает поведение напрямую для главного случая — паники до записи ответа: обработчик, который делает panic(...) в начале, обёрнутый в Recover, отдаёт клиенту аккуратный 500 (а не оборванное соединение, которым ответил бы сервер сам по себе). Паника посреди уже начатого ответа тестом не покрыта — там, как сказано выше, чистого 500 не гарантировать. Второй слой — проброс request-id через контекст запроса, с приватным типом ключа против коллизий:

// contextKey — приватный тип ключа контекста, чтобы исключить коллизии с
// ключами из других пакетов.
type contextKey string

const requestIDKey contextKey = "request-id"

func RequestID(id string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := context.WithValue(r.Context(), requestIDKey, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Приватный тип contextKey — не формальность: строковые ключи в context — известный источник тихих пересечений между пакетами, а неэкспортируемый тип это исключает физически. Подробнее про контекст и его правильное использование — в статье про context.

Тест Chain собирает все три слоя вместе — Chain(inner, Logger(&counter), RequestID("chain-1"), Recover) — и проверяет, что счётчик логгера вырос ровно на единицу, id дошёл до обработчика через контекст, а ответ — 200. Композиция сквозной логики без фреймворка: несколько функций и один Chain.

Таймауты сервера и graceful shutdown

Самая частая продакшн-ошибка новичка в Go — http.ListenAndServe(addr, handler). Одна строка, работает, попадает в прод — и создаёт http.Server без единого таймаута. Соединение при этом живёт неограниченно долго, и это открытая дверь для атаки класса Slowloris: медленный клиент шлёт заголовки запроса по одному байту, удерживая коннект открытым, и десятками таких коннектов исчерпывает лимит файловых дескрипторов — отказ в обслуживании без единого «тяжёлого» запроса.

Лечится это конструированием http.Server вручную с явными таймаутами на каждой фазе обмена:

func New(addr string, handler http.Handler) *http.Server {
	return &http.Server{
		Addr:    addr,
		Handler: handler,

		// ReadHeaderTimeout — срок на чтение заголовков. Главный барьер против
		// Slowloris: клиент, тянущий заголовки, будет отключён.
		ReadHeaderTimeout: 5 * time.Second,

		// ReadTimeout — полный срок на чтение запроса вместе с телом.
		ReadTimeout: 10 * time.Second,

		// WriteTimeout — срок на запись ответа; отсекает клиентов, читающих
		// ответ по байту.
		WriteTimeout: 10 * time.Second,

		// IdleTimeout — сколько keep-alive соединение ждёт следующего запроса,
		// прежде чем сервер его закроет.
		IdleTimeout: 60 * time.Second,
	}
}

Ключевой барьер против Slowloris — ReadHeaderTimeout: он ограничивает именно фазу чтения заголовков, где атака и живёт. ReadTimeout покрывает весь запрос с телом, WriteTimeout отсекает клиентов, читающих ответ по байту, IdleTimeout закрывает простаивающие keep-alive соединения. Значение по умолчанию у всех этих полей — ноль, то есть «без ограничения»; конкретные числа подбирают под свою нагрузку и обратный прокси, но нулей в проде быть не должно.

Таймауты закрывают DoS по времени, но парный по классу риск — DoS по объёму: тело запроса. По умолчанию net/http размер тела не ограничивает — обработчик, читающий r.Body целиком (io.ReadAll, JSON-декодер), примет столько, сколько пришлёт клиент, и злонамеренный (или просто кривой) POST на гигабайты выест память сервера. Барьер — http.MaxBytesReader: оборачивает тело и обрывает чтение на заданном лимите.

func handler(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // не больше 1 MiB
    var in Payload
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        // при превышении лимита err.Error() == "http: request body too large"
        http.Error(w, "тело слишком большое", http.StatusRequestEntityTooLarge) // 413
        return
    }
    // ...
}

При превышении лимита чтение возвращает ошибку http: request body too large, а MaxBytesReader помечает соединение так, что сервер его не переиспользует; ответить клиенту (обычно 413 Request Entity Too Large) должен сам обработчик, как в примере. Лимит ставят на каждый эндпоинт, принимающий тело: по умолчанию его нет, и это такой же «открытый по умолчанию» риск, как нулевые таймауты выше. На стенде это оформлено как middleware MaxBytes(n) в middleware/middleware.go, а тест TestMaxBytesRejectsOversizedBody через httptest доказывает пару фактов: тело сверх лимита даёт ошибку request body too large и ответ 413, а тело в пределах лимита проходит.

Вторая обязательная привычка — graceful shutdown. srv.Shutdown(ctx) перестаёт принимать новые соединения и ждёт завершения активных запросов до истечения контекста:

// Shutdown корректно останавливает сервер: перестаёт принимать новые соединения
// и ждёт завершения активных запросов до истечения ctx.
func Shutdown(ctx context.Context, srv *http.Server) error {
	return srv.Shutdown(ctx)
}

Тест стенда поднимает сервер на порту :0 (свободный порт выдаёт ОС), делает один живой запрос — получает 200 и pong — и сразу инициирует shutdown. Важная деталь штатного завершения: после успешного Shutdown метод Serve возвращает не nil, а http.ErrServerClosed — это ожидаемый исход, а не ошибка:

// После Shutdown Serve возвращает http.ErrServerClosed — это штатный исход.
if err := <-errCh; err != nil && err != http.ErrServerClosed {
	t.Errorf("ожидали ErrServerClosed, получили %v", err)
}

Полный сценарий корректной остановки — перехват сигналов, дедлайн на дозавершение, порядок закрытия зависимостей — разобран в отдельной статье про graceful shutdown.

Клиент: таймаут и отмена по контексту

Со стороны клиента ловушка зеркальная. http.Get(url) и http.DefaultClient — удобно и опасно: у DefaultClient нет таймаута. Зависший или медленный сервер удержит горутину вечно, и под нагрузкой это утечёт в исчерпание горутин. Правило простое: свой http.Client с таймаутом, всегда.

// New возвращает *http.Client с общим таймаутом на всю операцию (соединение,
// отправка, чтение ответа). Это грубая верхняя граница; точечная отмена — через
// context в конкретном запросе.
func New(timeout time.Duration) *http.Client {
	return &http.Client{Timeout: timeout}
}

// Get выполняет GET с привязкой к ctx: если контекст отменят или истечёт его
// дедлайн, запрос прервётся и вернётся ошибка контекста.
func Get(ctx context.Context, c *http.Client, url string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := c.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	return io.ReadAll(resp.Body)
}

Два уровня контроля дополняют друг друга. Client.Timeout — грубая верхняя граница на всю операцию (соединение + отправка + чтение ответа). http.NewRequestWithContext даёт точечную отмену конкретного запроса: если родительская операция отменена или у контекста истёк дедлайн, запрос прервётся немедленно, не дожидаясь общего таймаута клиента.

Тест стенда доказывает отмену честно — против медленного httptest.Server, который держит ответ две секунды, при контексте с дедлайном 50 ms:

ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

c := New(5 * time.Second) // таймаут клиента заведомо больше дедлайна ctx
_, err := Get(ctx, c, ts.URL)
if err == nil {
	t.Fatal("ожидали ошибку отмены, получили nil")
}
// Ошибка транспорта оборачивает context.DeadlineExceeded — проверяем сквозь
// цепочку через errors.Is.
if !errors.Is(err, context.DeadlineExceeded) {
	t.Errorf("ожидали DeadlineExceeded, получили %v", err)
}

Таймаут клиента (5 s) заведомо больше дедлайна контекста — значит, запрос прервал именно контекст. Обратите внимание на распаковку ошибки: транспорт оборачивает исходную ошибку, поэтому проверяют её через errors.Is(err, context.DeadlineExceeded), а не сравнением напрямую. Про обёртывание и корректную работу с ошибками — статья про обработку ошибок, про контекст и отмену — про context.

Дуализм gin: почему популярен и когда не нужен

Здесь важно не впасть в догму. gin — один из самых популярных веб-фреймворков Go, и не на пустом месте. Разберём честно обе стороны.

Почему gin популярен. Он даёт batteries included: параметрический роутинг (который до Go 1.22 в stdlib отсутствовал вовсе), биндинг и валидацию тела запроса из коробки, готовые рендеры (JSON, XML, HTML), набор готовых middleware (logger, recovery, CORS). Производительный роутер на radix-дереве. Знакомый по другим языкам DX и очень быстрый старт: типовой CRUD-сервис поднимается за десяток строк. Для прототипа или сервиса с типовыми нуждами это реально экономит время.

Почему для долгоживущего сервиса стоит держаться ближе к stdlib. Здесь несколько независимых доводов, и все они про сопровождение на дистанции.

  • После Go 1.22 для базового роутинга главный довод стал слабее. Основная причина, по которой gin часто брали, — роутинг с параметрами и методом — теперь есть в стандартном ServeMux. Для несложной маршрутизации ключевая функциональность фреймворка перестала быть уникальной (хотя gin по-прежнему даёт больше «из коробки»: биндинг, валидацию, готовые middleware).
  • gin вводит свой *gin.Context вместо стандартных типов. Обработчик в gin — это func(c *gin.Context), а не func(w http.ResponseWriter, r *http.Request). Как только код и middleware завязываются на *gin.Context, теряется совместимость со всей экосистемой http.Handler: чужой middleware, тестовые серверы, метрики, трейсинг — всё, что говорит на языке стандартного интерфейса, требует адаптеров или переписывания.
  • Framework lock-in. Миграция с фреймворка дорога именно потому, что на его тип завязан весь код. А обёртки фреймворка над stdlib традиционно отстают от новых возможностей языка и стандартной библиотеки — вы получаете свежие фичи net/http не тогда, когда их выпустил Go, а тогда, когда их прокинул наружу мейнтейнер фреймворка.
  • Прозрачность контроля. Явные таймауты сервера, graceful shutdown, распаковка ошибок через errors.Is — всё это в stdlib на виду и под вашим контролем. Фреймворк часто прячет эти решения за умолчаниями, и чтобы настроить их правильно, всё равно приходится спускаться до net/http.

Что до скорости — сравнивать имеет смысл только качественно: разница роутеров на реальном сервисе, где время съедают база, сеть и сериализация, обычно теряется в шуме, поэтому «gin быстрее» редко бывает решающим доводом. Числовых бенчмарков «gin против stdlib» здесь намеренно нет: на этом стенде их не мерили, а чужие микробенчмарки роутеров к вашей нагрузке отношения не имеют.

Вывод без догмы. gin оправдан для быстрых прототипов и CRUD-сервисов с типовыми нуждами, где важнее скорость разработки. Для долгоживущего сервиса, который будут поддерживать годами, дешевле обходится стандартный ServeMux 1.22 плюс тонкий слой middleware-функций: меньше зависимостей, полная совместимость с экосистемой http.Handler, прямой доступ к новым фичам языка. Дело не в том, что «gin плохой» — дело в том, чтобы выбирать осознанно, под задачу и её горизонт жизни, а не по инерции «в Go всегда ставят роутер и фреймворк».

Демо и версии

  • Код: живой стенд digital-cookbook/go-net-http/ — четыре подкаталога по узлам типового сервиса (router/, middleware/, server/, client/), только stdlib, без единой внешней зависимости. Всё поведение из статьи доказано тестами: маршруты и middleware — через net/http/httptest (NewRequest + NewRecorder, без сетевого сервера), сервер и клиент — против управляемого httptest.Server. Запуск: go build ./... && go vet ./... && go test ./.... Код в тексте — выжимки из стенда.
  • Версии: method-роутинг в ServeMux ("GET /path", {id}, PathValue) требует Go 1.22+ — это ключевая причина, по которой сторонний роутер больше не обязателен для базовых нужд. Модуль стенда собран на Go 1.25. Всё остальное (http.Server, таймауты, Shutdown, http.Client, NewRequestWithContext, httptest, errors.Is) — давно в stdlib и свежих версий не требует.
  • Смежные статьи: graceful shutdown — полный сценарий корректной остановки; context — отмена и дедлайны; обработка ошибок — обёртывание и errors.Is.

Документация и первоисточники

  • pkg.go.dev/net/http — первоисточник по Handler, HandlerFunc, ServeMux, Server, Client; здесь же описаны семантика паттернов роутинга Go 1.22, PathValue, поля таймаутов и Shutdown.
  • Go 1.22 Release Notes — Enhanced routing patterns — официальное описание method+path паттернов и wildcard-сегментов в ServeMux.
  • Writing Web Applications — вводный материал Go по построению HTTP-сервисов и обработке запросов на стандартной библиотеке.

Обсуждение в Telegram

Присоединиться →

Комментарии