HTTP-сервисы на net/http
Этот урок про практику. Если 02-idioms-and-stdlib дал каркас языка и stdlib, то здесь мы строим то, ради чего Go обычно и берут на бэкенд: HTTP-сервис, который держит нагрузку, корректно завершается, и который можно протестировать без поднятия Docker.
Идея простая: stdlib net/http после Go 1.22 закрывает 80% задач без всякого фреймворка. Сначала строим сервер на голом net/http, потом смотрим, чего не хватает, и только после этого решаем, нужен ли chi, echo, gin или fiber.
Источники, которые держим под рукой: pkg.go.dev/net/http, pkg.go.dev/net/http/httptest, статья Mat Ryer How I write HTTP services in Go (2024) и блог-пост Go-команды про routing 1.22.
1. Как устроен HTTP-сервер в Go
Минимальный сервер запускается одной строкой:
package main
import "net/http"
func main() {
err := http.ListenAndServe(":8080", nil)
if err != nil {
panic(err)
}
}
Что здесь произошло:
ListenAndServeслушает TCP-порт.- Под каждый запрос рантайм запускает отдельную горутину (это бесплатно, см. 04-concurrency-deep).
- Запрос разбирается, заголовки складываются в
*http.Request, и вызываетсяHandler. Если второй параметрnil, берётсяhttp.DefaultServeMux.
Адрес записывается как IP:port или просто :port. Если хочешь, чтобы сервер был доступен и снаружи контейнера, и через localhost, бери :8080 (это эквивалент 0.0.0.0:8080). Привязка только к 127.0.0.1:8080 отрежет внешние подключения, иногда полезно для health-only сервиса.
Главный интерфейс всего пакета:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Один метод. Всё остальное в net/http, это либо реализации, либо адаптеры.
2. Handler, HandlerFunc и почему это удобно
Прямой Handler через структуру:
type pingHandler struct{}
func (pingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
}
Каждый раз писать type ... struct{} лень. Есть адаптер http.HandlerFunc, это типизированная функция, которая сама удовлетворяет Handler:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
Поэтому идиоматичный handler пишут как функцию и приводят к http.HandlerFunc:
func ping(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/ping", ping)
http.ListenAndServe(":8080", mux)
}
Это идиома: handler, это функция вида func(http.ResponseWriter, *http.Request). Её можно протестировать в изоляции, передать как middleware, обернуть.
3. ServeMux после 1.22
До 1.22 родной ServeMux был очень аскетичный: только префиксы пути, никаких методов и параметров. Поэтому почти все проекты тащили gorilla/mux (мёртв с 2022) или chi. С Go 1.22 стандартный ServeMux получил методы, точные совпадения и path-параметры. Это меняет дефолт.
mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
mux.HandleFunc("GET /static/", serveStatic) // префикс с '/' на конце
Внутри handler параметр пути достаём через r.PathValue("id"):
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}
Что важно знать про новый ServeMux:
- Точное совпадение vs префикс.
GET /usersmatches только/users.GET /static/(с trailing slash) matches любой/static/foo/bar.js. - Конфликты. Если два паттерна одинаково применимы, рантайм паникует на регистрации. Это лучше чем silent overwrite, но поначалу обжигает.
- Wildcard
{id}matches один сегмент.{path...}matches остаток пути (greedy). - Method-specific. Без префикса метода паттерн matches любой метод.
Этого достаточно для большинства REST-сервисов. Реальные причины тащить chi или echo: middleware-стек из коробки, route groups, готовые helpers (json render, request validation), exception fingerprinting. Если ничего из этого не нужно, голый net/http 1.22 закрывает задачу.
Совет про
http.DefaultServeMux. Не используй его. Глобальный mutable singleton, в который любая импортируемая библиотека может тихо зарегистрировать ручку (привет,net/http/pprof). Свойmux := http.NewServeMux()всегда явный и ничей.
4. Структура http.Request
Всё, что пришло от клиента, лежит в *http.Request. Поля, которыми пользуешься постоянно:
type Request struct {
Method string // GET, POST, ...
URL *url.URL // путь, query, fragment
Header Header // map[string][]string
Body io.ReadCloser // тело
ContentLength int64
Host string
RemoteAddr string
// ...
}
Несколько практических моментов:
// 1. Method-константы вместо магических строк
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 2. Query-параметры
city := r.URL.Query().Get("city")
units := r.URL.Query().Get("units")
// 3. Заголовки
auth := r.Header.Get("Authorization")
ct := r.Header.Get("Content-Type")
// 4. Form-данные
// для application/x-www-form-urlencoded или multipart/form-data
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
login := r.FormValue("login")
// 5. JSON-тело: декодим стримом, не через io.ReadAll
var payload struct {
Email string `json:"email"`
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&payload); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
r.Body закрывать руками не надо, сервер сам закроет. Но прочитать до конца стоит: если тело не вычитано, а соединение хочет переиспользоваться (keep-alive), оно зависнет. На больших телах есть встроенный лимит: оборачивай в http.MaxBytesReader(w, r.Body, 1<<20) для защиты от gigabyte-payload атак.
5. ResponseWriter и порядок вызовов
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
Порядок строгий:
- Меняешь заголовки через
w.Header().Set(...). - Вызываешь
w.WriteHeader(code)(или сразуw.Write, тогда автоматически 200). - Пишешь тело через
w.Write(илиio.Copy,fmt.Fprint,json.NewEncoder(w).Encode).
После первого Write заголовки уже улетели и менять их бесполезно. Это самая частая ошибка новичков: ставят Content-Type после Write, и сервер его игнорирует.
Канонический шаблон JSON-ответа:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// на этом этапе уже отправили статус, остаётся только залогировать
slog.Error("encode response", "err", err)
}
}
Для частых ошибок есть готовое: http.Error(w, msg, status) пишет text/plain с переводом строки, http.NotFound(w, r) отдаёт 404, http.Redirect(w, r, url, code) отдаёт 3xx с правильным заголовком Location.
6. Middleware
Middleware, это просто функция, которая принимает http.Handler и возвращает http.Handler. Никаких фреймворков для этого не нужно:
type Middleware func(http.Handler) http.Handler
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"took", time.Since(start),
)
})
}
func withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rv := recover(); rv != nil {
slog.Error("panic", "value", rv, "stack", string(debug.Stack()))
http.Error(w, "internal", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Собирается конвейером:
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
handler := Chain(mux,
withRecover, // первым ловит панику
withLogging, // вторым логирует
withRequestID, // третьим прокидывает request id
)
http.ListenAndServe(":8080", handler)
Порядок имеет значение. Recover должен быть снаружи логгера, чтобы поймать панику и в самом логгере. Request id, внутри recover, чтобы лог-сообщения уже знали id.
Чтобы достать статус ответа в логгере, оборачивай ResponseWriter:
type statusWriter struct {
http.ResponseWriter
status int
}
func (sw *statusWriter) WriteHeader(code int) {
sw.status = code
sw.ResponseWriter.WriteHeader(code)
}
Большинство сторонних роутеров (chi, echo) предоставляют такой helper из коробки.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.