Раздел 22 · Go

HTTP-сервисы на net/http

middle~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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

Что здесь произошло:

  1. ListenAndServe слушает TCP-порт.
  2. Под каждый запрос рантайм запускает отдельную горутину (это бесплатно, см. 04-concurrency-deep).
  3. Запрос разбирается, заголовки складываются в *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 /users matches только /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)
}

Порядок строгий:

  1. Меняешь заголовки через w.Header().Set(...).
  2. Вызываешь w.WriteHeader(code) (или сразу w.Write, тогда автоматически 200).
  3. Пишешь тело через 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.