«Первым делом ставим роутер и фреймворк» — так по инерции начинается почти каждый Go-сервис. Между тем стандартная библиотека давно закрывает большую часть того, ради чего фреймворк тянули: интерфейс http.Handler, вокруг которого выстроена вся экосистема, а с Go 1.22 — ещё и роутинг по методу и пути прямо в ServeMux. Для долгоживущего сервиса, который поддерживают годами, это меняет расчёт: тонкий слой middleware-функций поверх stdlib оказывается дешевле в сопровождении, чем зависимость от фреймворка.
Разберём net/http по узлам типового сервиса — роутинг, middleware, таймауты сервера, клиент — и в конце честно взвесим дуализм gin: почему он самый популярный веб-фреймворк Go и почему для сервиса «на годы» стоит держаться ближе к стандартной библиотеке. Всё заземлено на живой стенд digital-cookbook/go-net-http/: только stdlib, без единой внешней зависимости, поведение доказано тестами через net/http/httptest.
В статье
- Философия net/http: один интерфейс
- Роутинг на стандартном ServeMux (Go 1.22)
- Middleware без фреймворка
- Таймауты сервера и graceful shutdown
- Клиент: таймаут и отмена по контексту
- Дуализм gin: почему популярен и когда не нужен
- Демо и версии
- Документация и первоисточники
Философия 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/42 → 200 и 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-сервисов и обработке запросов на стандартной библиотеке.
Комментарии