Перейти к содержимому

net/http: HTTP-сервер и клиент в Go

Кратко: net/http — стандартный пакет для HTTP-сервера и клиента в Go. Никаких фреймворков не нужно — большинство сервисов в проде написаны на голом stdlib (или тонкой обёртке). Понимание net/http — фундамент любого Go-бэкендера.

Зачем знать джуну: На собесе спросят “как написать HTTP-сервер без фреймворка?”, “что не так с http.Get в проде?”, “как сделать graceful shutdown?”. В production к тебе придут с зависшим сервисом — окажется, нет таймаута на клиенте, или body не закрыт, или handler паникует.

  1. Базовое API: Server и Client
  2. Под капотом: lifecycle запроса, горутина на запрос
  3. Gotchas: типичные ловушки
  4. Производительность: пулы соединений, профилирование
  5. Вопросы на собесе
  6. Practice
  7. Источники

package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!\n", r.URL.Query().Get("name"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}

Запусти, открой http://localhost:8080/hello?name=world — увидишь “Hello, world!”.

⚠️ Этот код НЕ для production. Нет таймаутов, используется глобальный DefaultServeMux, нет graceful shutdown.

Сердце всей системы:

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

Любой объект, реализующий ServeHTTP, может быть HTTP-хендлером:

type myHandler struct {
greeting string
}
func (h *myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s, %s!\n", h.greeting, r.URL.Query().Get("name"))
}
func main() {
h := &myHandler{greeting: "Привет"}
http.ListenAndServe(":8080", h)
}
type HandlerFunc func(ResponseWriter, *Request)
// HandlerFunc реализует Handler через вызов f(w, r)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

Это позволяет передавать обычную функцию туда, где ждут Handler:

func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello")
}
// Преобразуем функцию в Handler:
var h http.Handler = http.HandlerFunc(hello)

http.HandleFunc под капотом делает именно это.

ServeMux — простой мультиплексор: по path выбирает Handler.

mux := http.NewServeMux()
mux.HandleFunc("/users", listUsers)
mux.HandleFunc("/users/", getUser) // обрати внимание на trailing slash!
http.ListenAndServe(":8080", mux)

До Go 1.22 ServeMux был очень простой: только path, без поддержки методов и path-параметров.

С Go 1.22 ServeMux поддерживает методы и wildcards. Это убрало необходимость в фреймворках для большинства задач!

mux := http.NewServeMux()
// Method + path
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
// Wildcard в конце пути
mux.HandleFunc("GET /files/{path...}", serveFile)

Получить параметр пути:

func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "User ID: %s\n", id)
}

⚠️ Подвох: если паттерн пересекается с другим без указания метода (/users/{id} vs GET /users/{id}), будет паника при регистрации. Используй методы во всех роутах для ясности.

Обязательная конфигурация:

srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // защита от Slowloris
ReadTimeout: 10 * time.Second, // лимит на полное чтение запроса
WriteTimeout: 10 * time.Second, // лимит на запись ответа
IdleTimeout: 60 * time.Second, // keep-alive
MaxHeaderBytes: 1 << 20, // 1 MB
}
log.Fatal(srv.ListenAndServe())

Зачем каждый таймаут:

ТаймаутЗащищает от
ReadHeaderTimeoutSlowloris атака (медленная отправка заголовков)
ReadTimeoutМедленный/застрявший клиент (включая body)
WriteTimeoutМедленная запись ответа
IdleTimeoutВисящие keep-alive соединения

⚠️ БЕЗ таймаутов сервер уязвим. Это самая частая ошибка джунов.

func handler(w http.ResponseWriter, r *http.Request) {
// Method
fmt.Println(r.Method) // GET, POST, etc.
// URL
fmt.Println(r.URL.Path) // /users/123
fmt.Println(r.URL.RawQuery) // foo=bar
fmt.Println(r.URL.Query().Get("foo")) // bar
// Headers
fmt.Println(r.Header.Get("Content-Type"))
fmt.Println(r.Header.Get("User-Agent"))
// Body
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(string(body))
// Context (Go 1.7+)
ctx := r.Context()
// ctx отменится, если клиент разорвёт соединение
}
func handler(w http.ResponseWriter, r *http.Request) {
// Парсит query + body для POST с Content-Type: application/x-www-form-urlencoded
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name") // query или form
only := r.PostFormValue("name") // только form (POST body)
fmt.Println(name, only)
// r.Form — все, r.PostForm — только из body
}

Для multipart/form-data (file upload) — r.ParseMultipartForm(maxMemory).

func handler(w http.ResponseWriter, r *http.Request) {
// Установить headers (ДО WriteHeader/Write!)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "value")
// Установить status code (опционально, дефолт 200)
w.WriteHeader(http.StatusCreated)
// Записать body
fmt.Fprintf(w, `{"id": 42}`)
}

⚠️ КРИТИЧНО: w.Header().Set() работает только до первого Write() или WriteHeader(). После — игнорируется!

// Это БАГ:
w.Write([]byte("data"))
w.Header().Set("Content-Type", "application/json") // ИГНОРИРУЕТСЯ

Middleware — функция, оборачивающая Handler:

func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "secret" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/data", getData)
// Цепочка: log → auth → handler
handler := loggingMiddleware(authMiddleware(mux))
srv := &http.Server{Addr: ":8080", Handler: handler}
srv.ListenAndServe()
}
resp, err := http.Get("https://api.example.com/users")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close() // КРИТИЧНО!
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))

⚠️ ОПАСНО в production: http.Get использует http.DefaultClient, у которого нет таймаута. Зависший сервер → твоя горутина висит вечно → leak.

client := &http.Client{
Timeout: 10 * time.Second, // общий таймаут на весь запрос
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

Best practice: один *http.Client на весь сервис (singleton). Transport кеширует TCP-соединения (connection pool).

srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
// Ждём сигнал
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("server stopped")

Shutdown перестаёт принимать новые соединения и ждёт завершения активных запросов. Если за ctx дедлайн не успели — закрывает принудительно.


Клиент Server
│ │
│── TCP connect ──→│ (Listener accepts)
│ │
│ │── go conn.serve() (горутина на соединение!)
│ │ │
│── Request ──────→│ │── readRequest()
│ │ │── ServeMux.Handler(r)
│ │ │── Handler.ServeHTTP(w, r)
│ │ │── flush response
│←── Response ─────│ │
│ │ │── (keep-alive: ждём след. запрос)
│ │ │ или conn.close()

Ключевая идея: каждое соединение обслуживается в отдельной горутине. Это даёт высокую конкурентность из коробки.

В рамках одного keep-alive соединения может быть несколько запросов — все обрабатываются последовательно в одной горутине (HTTP/1.1). С HTTP/2 — мультиплексирование.

func handler(w http.ResponseWriter, r *http.Request) {
// Этот код выполняется в горутине, созданной runtime'ом net/http.
// 1000 одновременных запросов = ~1000 горутин.
// Это нормально — горутины дешёвые.
}

⚠️ Важно: общая память между горутинами требует синхронизации (мьютексов, channels, atomic). Если ты хранишь shared state в handler без блокировки — race condition.

// БАГ:
var counter int
func handler(w http.ResponseWriter, r *http.Request) {
counter++ // race!
}
// Правильно:
var counter atomic.Int64
func handler(w http.ResponseWriter, r *http.Request) {
counter.Add(1)
}

ServeMux хранит map: pattern → handler. При запросе ищет longest match по path. С Go 1.22 добавилась поддержка методов и path-параметров через более сложный матчер.

// Упрощённо:
type ServeMux struct {
mu sync.RWMutex
tree *routingNode // для Go 1.22+
patterns []*pattern
}
http.HandleFunc("/foo", handler) // регистрирует в http.DefaultServeMux
http.ListenAndServe(":8080", nil) // nil = DefaultServeMux

⚠️ Подвох: DefaultServeMux — глобальная переменная. Если в твою программу импортируется пакет (например, net/http/pprof), он автоматически регистрирует обработчики в DefaultServeMux. В production это может неожиданно экспонировать debug endpoints!

Best practice: всегда явно создавай свой mux := http.NewServeMux().

ResponseWriter — интерфейс. Конкретная реализация (http.response) пишет в TCP-соединение. Внутри есть буфер (bufio.Writer). Первый Write или явный WriteHeader “коммитит” статус и заголовки — они уходят в сокет, и дальше менять нельзя.

type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}

Для WebSocket, raw TCP внутри HTTP:

hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "not hijackable", 500)
return
}
conn, bufrw, err := hj.Hijack()
// теперь conn — твоё, дальше пиши/читай напрямую

С Go 1.6+ HTTP/2 включён по умолчанию для HTTPS. Никаких настроек не надо:

http.ListenAndServeTLS(":443", "cert.pem", "key.pem", mux)
// HTTP/2 поддерживается автоматически

Для HTTP/2 без TLS (h2c) — нужно явное включение через golang.org/x/net/http2/h2c.

transport := &http.Transport{
MaxIdleConns: 100, // всего idle
MaxIdleConnsPerHost: 10, // idle на хост
IdleConnTimeout: 90 * time.Second,
}

После запроса соединение возвращается в пул и переиспользуется. Это критично для производительности — TCP+TLS handshake очень дорогой.

⚠️ Если не вызвать resp.Body.Close() — соединение не возвращается в пул. Каждый запрос будет открывать новое TCP-соединение.

net/http не поддерживает WebSocket напрямую. Используются:

  • github.com/gorilla/websocket (популярный, проверенный).
  • nhooyr.io/websocket (более современный).
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
msgType, msg, err := conn.ReadMessage()
if err != nil {
return
}
conn.WriteMessage(msgType, msg)
}
}
err := http.ListenAndServeTLS(":443",
"cert.pem", // публичный сертификат
"key.pem", // приватный ключ
mux)

Для production используют Let’s Encrypt через golang.org/x/crypto/acme/autocert:

m := &autocert.Manager{
Cache: autocert.DirCache("certs"),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.com"),
}
srv := &http.Server{
Addr: ":443",
TLSConfig: m.TLSConfig(),
}
go http.ListenAndServe(":80", m.HTTPHandler(nil)) // ACME challenge
srv.ListenAndServeTLS("", "")

// БАГ:
resp, err := http.Get("http://slow-server.com")
// http.DefaultClient.Timeout == 0 (нет таймаута)
// Зависший сервер → зависнет твоя горутина → утечка

Решение: свой *http.Client с Timeout.

resp, err := client.Do(req)
if err != nil {
return err
}
// ЗАБЫЛИ defer resp.Body.Close()
// Соединение не возвращается в пул, TCP не закрывается.

Правило: всегда defer resp.Body.Close() после успешной проверки err.

if err != nil {
return err
}
defer resp.Body.Close()
// ВАЖНО: даже если ты не читаешь body — закрой!
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errors.New("bad status")
// body не дочитан → keep-alive не используется → новое соединение для следующего запроса!
}
io.ReadAll(resp.Body)

Правило: дочитай body до конца, даже если статус не 200:

defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
w.Header().Set("Content-Type", "text/plain") // ИГНОРИРУЕТСЯ
}

Правило: Header → WriteHeader → Write. В этом порядке.

net/http ловит panic в handler через defer recover(). Соединение закроется, но программа не упадёт. Однако:

  • Лог покажет panic (через Server.ErrorLog).
  • Клиент получит обрезанный ответ или ничего.
  • Это плохо! Лучше использовать middleware-recovery с логированием в твой logger.
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
var requests = make(map[string]int)
func handler(w http.ResponseWriter, r *http.Request) {
requests[r.URL.Path]++ // race! map не concurrent-safe
}

Решение: sync.Mutex, sync.Map, или atomic для счётчиков.

func handler(w http.ResponseWriter, r *http.Request) {
go func() {
// длинная работа без context
time.Sleep(10 * time.Minute)
}()
w.Write([]byte("ok"))
}

Клиент уже ушёл, но горутина в фоне работает. Если их тысячи — память течёт. Используй context:

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
go doWork(ctx) // работа отменится при разрыве соединения
}

⚠️ Но r.Context() отменяется и когда handler возвращается! Если работа должна продолжаться — используй context.Background() с собственным таймаутом.

http.Transport не кеширует DNS долго (использует системный resolver). Если DNS меняется (rolling deployment в k8s), Transport может держать старые IP. Решение — короткий IdleConnTimeout + создание новых соединений периодически.

mux.HandleFunc("/", root) // ловит ВСЕ пути (prefix match)
mux.HandleFunc("/api/v1", api) // только точное совпадение
// В Go 1.22+ можно явно: "/{$}" — только корень
mux.HandleFunc("/{$}", root)
// Middleware:
body, _ := io.ReadAll(r.Body)
log.Println(string(body))
// r.Body теперь пустой!
next.ServeHTTP(w, r)
// Handler пытается прочитать body → ничего

Решение:

body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))
log.Fatal(http.ListenAndServe(":8080", nil))
// при ошибке log.Fatal → os.Exit(1), defer'ы НЕ выполнятся!

Используй Server.Shutdown для graceful, а ошибку логгируй явно.

Если ты вызываешь w.Write(data) один раз, Go автоматически выставит Content-Length. Если несколько раз и не знаешь общую длину — будет Transfer-Encoding: chunked. Это нормально, но имей в виду.


Правило: один *http.Client на сервис. Он переиспользует TCP+TLS соединения.

// БАГ — каждый запрос новый client → новый pool → новые соединения
func get(url string) {
client := &http.Client{Timeout: 10 * time.Second}
resp, _ := client.Get(url)
defer resp.Body.Close()
// ...
}
// OK — один client на всю программу
var client = &http.Client{Timeout: 10 * time.Second}

Дефолт 2. Если ходишь много в один сервис — увеличь:

transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, // важно!
IdleConnTimeout: 90 * time.Second,
}

С HTTP/2 одно соединение обрабатывает много параллельных запросов. Это часто лучше, чем много HTTP/1.1 соединений.

// БАГ — fmt.Fprintf аллоцирует
fmt.Fprintf(w, "user: %d\n", id)
// OK — буферизованный writer + strconv
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("user: ")
buf.WriteString(strconv.Itoa(id))
buf.WriteByte('\n')
w.Write(buf.Bytes())
bufPool.Put(buf)

В большинстве случаев это не нужно. Но в hot path (десятки тысяч RPS) — оправдано.

import _ "net/http/pprof"
func main() {
go http.ListenAndServe("localhost:6060", nil) // pprof endpoints
// ... твой основной сервер на :8080
}

Эндпоинты:

  • /debug/pprof/profile?seconds=30 — CPU profile.
  • /debug/pprof/heap — heap.
  • /debug/pprof/goroutine — все горутины.
  • /debug/pprof/trace?seconds=5 — execution trace.

⚠️ Не выставляй pprof в публичный интернет! Только на внутренний порт.

Окно терминала
# hey: 100 одновременных соединений, 10 сек
hey -c 100 -z 10s http://localhost:8080/
# wrk: 4 потока, 100 connections, 30 сек
wrk -t4 -c100 -d30s http://localhost:8080/

Метрики смотрим:

  • RPS (requests per second).
  • p50/p95/p99 latency.
  • Errors.
func BenchmarkHandler(b *testing.B) {
req := httptest.NewRequest("GET", "/users/42", nil)
h := http.HandlerFunc(getUser)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
}
}
Окно терминала
go test -bench=. -benchmem

1. Что такое http.Handler? Интерфейс с методом ServeHTTP(w ResponseWriter, r *Request). Любой тип, реализующий его, может обслуживать HTTP-запросы.

2. Что такое http.HandlerFunc? Адаптер: тип-функция, у которой есть метод ServeHTTP, который просто вызывает саму функцию. Позволяет передавать обычные функции туда, где ждут Handler.

3. Что такое ServeMux? Простой роутер: маппит path (с Go 1.22 — и метод + параметры) на Handler.

4. Что нового в ServeMux Go 1.22? Поддержка методов (GET /users), path-параметров (/users/{id}), wildcards (/files/{path...}), и более строгое сопоставление.

5. Что не так с http.Get в production? Использует http.DefaultClient без таймаута. Зависший сервер → горутина висит вечно.

6. Какие таймауты нужны на http.Server? ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout. Каждый защищает от своего класса проблем (Slowloris, slow body, slow write, висящие keep-alive).

7. Как сделать graceful shutdown? Запускаем сервер в горутине, ловим SIGTERM/SIGINT, вызываем srv.Shutdown(ctx) — он ждёт активные запросы, не принимает новые.

8. Сколько горутин использует net/http? Одна на TCP-соединение (для HTTP/1.1). HTTP/2 — одна на stream. 1000 одновременных соединений = ~1000 горутин.

9. Почему важен resp.Body.Close()? Без закрытия TCP-соединение не возвращается в пул → утечка соединений, потеря производительности.

10. Что такое middleware? Функция, оборачивающая Handler: принимает Handler, возвращает Handler. Позволяет добавить логирование, аутентификацию, recovery и т.п. сквозным образом.

11. ResponseWriter порядок вызовов?

  1. Header().Set(...) — установить headers.
  2. WriteHeader(status) — отправить статус и headers.
  3. Write(data) — отправить body. После Write/WriteHeader заголовки уже отправлены — менять нельзя.

12. Что такое DefaultServeMux и почему его опасно использовать? Глобальная переменная. Другие пакеты могут регистрировать в неё handlers (например, net/http/pprof). Это может неожиданно выставить debug endpoints.

13. Как обработать panic в handler? net/http сам ловит панику и закрывает соединение. Но лучше использовать recovery middleware: логгировать stack trace и возвращать 500.

14. HTTP/2 — нужно ли что-то делать? В Go HTTP/2 включён by default для HTTPS. Для h2c (без TLS) — отдельная библиотека.

15. Что делает Transport.MaxIdleConnsPerHost? Лимит idle (keep-alive) соединений к одному хосту в пуле. Дефолт 2 — часто мало. Для микросервисов поднимают до 50-100.

16. Как передать данные из middleware в handler? Через r.Context():

ctx := context.WithValue(r.Context(), "userID", id)
next.ServeHTTP(w, r.WithContext(ctx))

17. r.Body — зачем дочитывать до конца? Чтобы keep-alive соединение могло переиспользоваться. Иначе сервер не знает, где конец запроса, и закроет соединение.

18. Чем отличается r.Form от r.PostForm? r.Form — всё (query + body), r.PostForm — только из body (только для POST с form-encoded).

19. Что такое Hijacker? Интерфейс, позволяющий взять TCP-соединение под прямой контроль (для WebSocket, custom протоколов). Получаем raw net.Conn.

20. Как ограничить размер body?

r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB

21. Что такое keep-alive и как им управлять? TCP-соединение остаётся открытым после ответа, чтобы переиспользоваться для следующих запросов. Управляется заголовком Connection: keep-alive (дефолт в HTTP/1.1). Server отключает: Server.SetKeepAlivesEnabled(false).

22. Как сделать SSE (Server-Sent Events)? Установить Content-Type: text/event-stream, использовать http.Flusher:

flusher := w.(http.Flusher)
fmt.Fprintf(w, "data: hello\n\n")
flusher.Flush()

23. Как сделать proxying запросов? net/http/httputil.ReverseProxy:

proxy := httputil.NewSingleHostReverseProxy(targetURL)
http.Handle("/", proxy)

24. Что произойдёт при разрыве соединения клиентом во время handler? r.Context() отменится. Если handler не проверяет ctx — продолжит работать (а WriteResponse уйдёт в никуда). Лучшая практика — респектить ctx.Done() в длинных операциях.

25. Чем gin/echo лучше net/http? Удобнее API (особенно для path params до Go 1.22), сложные middleware-цепочки, validation, binding. Но Go 1.22 enhanced ServeMux закрывает большую часть use cases. Для микросервисов часто хватает stdlib.


Напиши HTTP-сервер с:

  • эндпоинт GET /users/{id},
  • middleware: логирование, recovery, request ID,
  • таймауты,
  • graceful shutdown по SIGTERM,
  • /healthz для k8s probe.

Напиши функцию Get(url string) ([]byte, error), которая:

  • использует общий *http.Client с таймаутом,
  • ретраит до 3 раз при сетевых ошибках,
  • exponential backoff (1s, 2s, 4s),
  • уважает context,
  • правильно закрывает body.

Напиши прокси, который:

  • принимает запросы на :8080,
  • форвардит на http://localhost:9000,
  • добавляет header X-Forwarded-For,
  • логгирует время каждого запроса.

Используй httputil.ReverseProxy.

func handler(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
body, _ := io.ReadAll(resp.Body)
w.Write(body)
}

Найди две проблемы. (Подсказка: timeout, body.Close).

Напиши бенчмарк, сравнивающий:

  1. http.HandleFunc + ручной парсинг pattern /users/{id} через strings.Split,
  2. Go 1.22 enhanced ServeMux с r.PathValue("id").

Сравни B/op и ns/op.


  1. Документация net/httphttps://pkg.go.dev/net/http
  2. Enhanced ServeMux Go 1.22https://go.dev/blog/routing-enhancements
  3. The complete guide to Go net/http timeoutshttps://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/
  4. gorilla/websockethttps://github.com/gorilla/websocket
  5. Server Programming with Go (книга) — Mat Ryer, Go Programming Blueprints.
  6. 100 Go Mistakes — Teiva Harsanyi (глава 11 — HTTP).
  7. Go Web Exampleshttps://gowebexamples.com/

Чек-лист джуна:

  • Могу написать HTTP-сервер без фреймворка.
  • Знаю, какие 4 таймаута нужны на http.Server.
  • Никогда не использую http.Get в проде.
  • Всегда defer resp.Body.Close() + дочитываю body.
  • Делаю graceful shutdown через srv.Shutdown(ctx).
  • Понимаю, что middleware — это обёртка handler.
  • Знаю про Go 1.22 enhanced ServeMux.
  • Использую r.Context() в длинных операциях.
  • Знаю, как профилировать через net/http/pprof.