HTTP-роутеры и middleware
Зачем знать: HTTP — основная транспорт-обвязка для backend на Go. От выбора роутера зависит, как пишутся handlers, middleware и какие ловушки ждут на проде. С Go 1.22 stdlib
net/httpстал «достаточно мощным», что заметно изменило экосистему. На middle-уровне ждут понимания — когда хватает stdlib, когда брать chi/gin/echo, и КАК работают middleware.
Содержание
Заголовок раздела «Содержание»- Базовая концепция
- В Go идиоматично
- Gotchas
- Best practices
- Вопросы на собесе
- Practice
- Источники
1. Базовая концепция
Заголовок раздела «1. Базовая концепция»1.1 net/http: что есть в stdlib
Заголовок раздела «1.1 net/http: что есть в stdlib»net/http — стандартная библиотека Go для HTTP. Содержит:
http.Server— сам сервер;http.Handler— интерфейс сServeHTTP(w, r);http.ServeMux— встроенный роутер;http.Client— HTTP-клиент;- middleware-style декораторы.
type Handler interface { ServeHTTP(ResponseWriter, *Request)}Любой объект, реализующий ServeHTTP — handler. Любая функция с такой сигнатурой — через http.HandlerFunc.
1.2 Go 1.22 ServeMux: что нового
Заголовок раздела «1.2 Go 1.22 ServeMux: что нового»До Go 1.22 встроенный mux был примитивен (только префиксы, без method matching). С 1.22 он поддерживает:
- Method matching:
mux.HandleFunc("GET /users", handler) - Path parameters:
mux.HandleFunc("GET /users/{id}", handler)→r.PathValue("id") - Wildcards:
mux.HandleFunc("GET /files/{path...}", handler)— захватывает остаток пути. - Host matching:
mux.HandleFunc("api.example.com/", handler) - Precedence rules: более специфичный путь имеет приоритет.
mux := http.NewServeMux()mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") fmt.Fprintf(w, "user %s", id)})mux.HandleFunc("POST /users", createUser)mux.HandleFunc("GET /files/{path...}", serveFile)Для большинства сервисов этого хватает. Раньше нужно было идти за chi/gin.
1.3 Популярные роутеры
Заголовок раздела «1.3 Популярные роутеры»chi (go-chi/chi)
Заголовок раздела «chi (go-chi/chi)»- Совместим с
net/http(http.Handler). - Sub-routers (вложенные группы).
- Middleware chain.
- Wildcard, regex routing.
chi.URLParam(r, "id")для path params.
import "github.com/go-chi/chi/v5"
r := chi.NewRouter()r.Use(middleware.Logger)r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") fmt.Fprintf(w, "user %s", id)})r.Route("/admin", func(r chi.Router) { r.Use(adminAuth) r.Get("/users", listAllUsers)})Совместим с любым http.Handler middleware. Самый идиоматичный выбор для Go.
gin (gin-gonic/gin)
Заголовок раздела «gin (gin-gonic/gin)»- НЕ реализует http.Handler. Свой
gin.Context. - Быстрый (radix tree).
- Огромное community, много примеров.
import "github.com/gin-gonic/gin"
r := gin.Default()r.GET("/users/:id", func(c *gin.Context) { id := c.Param("id") c.JSON(200, gin.H{"id": id})})Минусы:
- Не совместим с middleware из net/http экосистемы;
- Свой DSL для роутинга, JSON-respnose, и т.п.;
- В сложных случаях — vendor lock.
echo (labstack/echo)
Заголовок раздела «echo (labstack/echo)»- Похож на gin, тоже свой Context.
- Group middleware.
- Validator, renderer интегрированы.
import "github.com/labstack/echo/v4"
e := echo.New()e.GET("/users/:id", func(c echo.Context) error { id := c.Param("id") return c.JSON(200, map[string]string{"id": id})})fiber (gofiber/fiber)
Заголовок раздела «fiber (gofiber/fiber)»- Основан на fasthttp (НЕ net/http!).
- Невероятно быстрый для simple workloads.
- НЕ совместим с net/http middleware.
- НЕ поддерживает HTTP/2.
import "github.com/gofiber/fiber/v2"
app := fiber.New()app.Get("/users/:id", func(c *fiber.Ctx) error { return c.SendString("user " + c.Params("id"))})⚠️ fiber — отдельная экосистема, не совместима с net/http. Это и плюс (скорость), и минус (lock-in). Многие production-команды отказались из-за отсутствия HTTP/2 и SSE-проблем.
1.4 Сравнение
Заголовок раздела «1.4 Сравнение»| Параметр | net/http (1.22+) | chi | gin | echo | fiber |
|---|---|---|---|---|---|
| Совместимость с net/http | ✅ | ✅ | ❌ | ❌ | ❌ |
| Performance | хороший | хороший | очень хороший | очень хороший | максимальный |
| HTTP/2 | ✅ | ✅ | ✅ | ✅ | ❌ |
| Middleware ecosystem | net/http | net/http | gin | echo | fiber |
| Learning curve | низкая | низкая | средняя | средняя | средняя |
| Когда выбирать | дефолт | дефолт для роутера | если нужен gin DSL | альтернатива gin | максимум RPS, нет HTTP/2 |
Рекомендация для middle 1: net/http + chi (если нужно больше, чем stdlib даёт). Это покрывает 90% задач.
2. В Go идиоматично
Заголовок раздела «2. В Go идиоматично»2.1 Базовый HTTP-сервер
Заголовок раздела «2.1 Базовый HTTP-сервер»package main
import ( "context" "errors" "log" "net/http" "os/signal" "syscall" "time")
func main() { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, // защита от Slowloris ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()
go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } }()
<-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() srv.Shutdown(shutdownCtx)}⚠️ Обязательно ставьте таймауты. Без ReadHeaderTimeout сервер уязвим к Slowloris-атакам.
2.2 chi: типовой setup
Заголовок раздела «2.2 chi: типовой setup»package main
import ( "encoding/json" "net/http" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware")
func main() { r := chi.NewRouter()
// встроенные middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second))
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
r.Route("/api/v1", func(r chi.Router) { r.Use(authMiddleware)
r.Route("/users", func(r chi.Router) { r.Get("/", listUsers) r.Post("/", createUser) r.Route("/{userID}", func(r chi.Router) { r.Use(userCtx) r.Get("/", getUser) r.Put("/", updateUser) r.Delete("/", deleteUser) }) }) })
http.ListenAndServe(":8080", r)}2.3 Middleware patterns
Заголовок раздела «2.3 Middleware patterns»Middleware — это функция, оборачивающая http.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)) })}
// Применениеhandler := LoggingMiddleware(AuthMiddleware(MyHandler))Chain composition
Заголовок раздела «Chain composition»Без либы — вручную:
type Middleware func(http.Handler) http.Handler
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(myHandler, LoggingMiddleware, AuthMiddleware, RateLimitMiddleware,)// Порядок: Logging → Auth → RateLimit → MyHandlerС chi — r.Use(...):
r := chi.NewRouter()r.Use(LoggingMiddleware)r.Use(AuthMiddleware)r.Use(RateLimitMiddleware)// Тот же порядокPer-route vs global
Заголовок раздела «Per-route vs global»r := chi.NewRouter()r.Use(LoggingMiddleware) // global
r.Get("/public", publicHandler) // только Logging
r.Group(func(r chi.Router) { r.Use(AuthMiddleware) r.Get("/private", privateHandler) // Logging + Auth})2.4 Common middleware
Заголовок раздела «2.4 Common middleware»Request ID
Заголовок раздела «Request ID»func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-ID") if id == "" { id = uuid.NewString() } w.Header().Set("X-Request-ID", id) ctx := context.WithValue(r.Context(), requestIDKey, id) next.ServeHTTP(w, r.WithContext(ctx)) })}
type ctxKey intconst requestIDKey ctxKey = 1
func GetRequestID(ctx context.Context) string { if v, ok := ctx.Value(requestIDKey).(string); ok { return v } return ""}Recover
Заголовок раздела «Recover»func Recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rvr := recover(); rvr != nil { if rvr == http.ErrAbortHandler { panic(rvr) } log.Printf("panic: %v\n%s", rvr, debug.Stack()) http.Error(w, "internal error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) })}⚠️ Важно: http.ErrAbortHandler нужно «прокидывать дальше», это сигнал отмены, а не реальная паника.
Timeout
Заголовок раздела «Timeout»func Timeout(d time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), d) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) }}⚠️ Это только добавляет дедлайн в context. Чтобы handler реально прервался, он должен проверять ctx.Done() или передавать ctx в downstream-вызовы. Иначе timeout middleware ничего не остановит.
func CORS(allowOrigins []string) func(http.Handler) http.Handler { allow := make(map[string]bool, len(allowOrigins)) for _, o := range allowOrigins { allow[o] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if allow[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Request-ID") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) }}В production используйте rs/cors или встроенный chi cors.Handler — там больше edge cases.
Auth (JWT)
Заголовок раздела «Auth (JWT)»import "github.com/golang-jwt/jwt/v5"
func JWTAuth(secret []byte) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHdr := r.Header.Get("Authorization") if !strings.HasPrefix(authHdr, "Bearer ") { http.Error(w, "unauthorized", http.StatusUnauthorized) return } tokenStr := strings.TrimPrefix(authHdr, "Bearer ") token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected method: %v", t.Header["alg"]) } return secret, nil }) if err != nil || !token.Valid { http.Error(w, "unauthorized", http.StatusUnauthorized) return } claims := token.Claims.(jwt.MapClaims) ctx := context.WithValue(r.Context(), userIDKey, claims["sub"]) next.ServeHTTP(w, r.WithContext(ctx)) }) }}Rate limiting
Заголовок раздела «Rate limiting»import "golang.org/x/time/rate"
func RateLimit(rps float64, burst int) func(http.Handler) http.Handler { type clientLimiter struct { limiter *rate.Limiter last time.Time } var ( mu sync.Mutex clients = make(map[string]*clientLimiter) ) // GC устаревших клиентов go func() { for { time.Sleep(time.Minute) mu.Lock() for ip, c := range clients { if time.Since(c.last) > 5*time.Minute { delete(clients, ip) } } mu.Unlock() } }() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := getClientIP(r) mu.Lock() c, ok := clients[ip] if !ok { c = &clientLimiter{limiter: rate.NewLimiter(rate.Limit(rps), burst)} clients[ip] = c } c.last = time.Now() mu.Unlock() if !c.limiter.Allow() { http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) }}В production для распределённого rate limiting — Redis + token bucket.
Metrics (Prometheus)
Заголовок раздела «Metrics (Prometheus)»import "github.com/prometheus/client_golang/prometheus/promauto"
var ( httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total HTTP requests", }, []string{"method", "path", "status"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration", Buckets: prometheus.DefBuckets, }, []string{"method", "path"}))
func Metrics(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // нужен wrapper, чтобы поймать статус ww := &statusRecorder{ResponseWriter: w, status: 200} next.ServeHTTP(ww, r) path := chi.RouteContext(r.Context()).RoutePattern() // НЕ raw path! httpRequestsTotal.WithLabelValues(r.Method, path, strconv.Itoa(ww.status)).Inc() httpDuration.WithLabelValues(r.Method, path).Observe(time.Since(start).Seconds()) })}
type statusRecorder struct { http.ResponseWriter status int}
func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code)}⚠️ Используйте route pattern (/users/{id}), а не r.URL.Path (/users/123), иначе кардинальность метрик взорвётся.
2.5 Validation: go-playground/validator
Заголовок раздела «2.5 Validation: go-playground/validator»import "github.com/go-playground/validator/v10"
type CreateUserRequest struct { Name string `json:"name" validate:"required,min=2,max=100"` Email string `json:"email" validate:"required,email"` Age int `json:"age" validate:"gte=18,lte=120"`}
var validate = validator.New(validator.WithRequiredStructEnabled())
func createUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if err := validate.Struct(req); err != nil { // вернуть детали ошибки var ve validator.ValidationErrors if errors.As(err, &ve) { details := make(map[string]string) for _, fe := range ve { details[fe.Field()] = fe.Tag() } // ... возвращаем 400 с details } http.Error(w, "validation error", http.StatusBadRequest) return } // ...}Custom validator
Заголовок раздела «Custom validator»validate.RegisterValidation("uuid_v4", func(fl validator.FieldLevel) bool { _, err := uuid.Parse(fl.Field().String()) return err == nil})
type Req struct { ID string `validate:"uuid_v4"`}2.6 OpenAPI / Swagger
Заголовок раздела «2.6 OpenAPI / Swagger»Code-first: swaggo/swag
Заголовок раздела «Code-first: swaggo/swag»Пишете комментарии — генерируете spec.
// @Summary Create user// @Description Create new user// @Accept json// @Produce json// @Param request body CreateUserRequest true "request body"// @Success 201 {object} User// @Failure 400 {object} ErrorResponse// @Router /users [post]func createUser(w http.ResponseWriter, r *http.Request) { ... }$ swag init -g cmd/api/main.go# создаёт docs/swagger.yamlPros: легко начать. Cons: spec — production артефакт, тяжело держать в sync с реальным API.
Spec-first: deepmap/oapi-codegen
Заголовок раздела «Spec-first: deepmap/oapi-codegen»Сначала пишете OpenAPI spec (yaml), потом генерируете handlers.
paths: /users: post: operationId: createUser requestBody: ...$ oapi-codegen -package api api/openapi.yaml > internal/api/server.goГенерируется интерфейс, который вы реализуете:
type ServerInterface interface { CreateUser(w http.ResponseWriter, r *http.Request) GetUser(w http.ResponseWriter, r *http.Request, id string)}
type Server struct{}func (s *Server) CreateUser(w http.ResponseWriter, r *http.Request) { // ваша имплементация}Pros: contract-first, spec — source of truth. Cons: сложнее в начале.
Production рекомендация: spec-first для API, важных для интеграции. Code-first — для внутренних или прототипов.
2.7 Server Sent Events (SSE)
Заголовок раздела «2.7 Server Sent Events (SSE)»Однонаправленный поток server → client поверх HTTP.
func sseHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case t := <-ticker.C: fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339)) flusher.Flush() } }}⚠️ Не забудьте Flusher. Без Flush() данные буферизуются и не уходят клиенту.
2.8 WebSocket
Заголовок раздела «2.8 WebSocket»Самая популярная либа — gorilla/websocket. С 2022 года её также активно поддерживает сообщество (gorilla/websocket maintenance возобновлён).
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true // настройте по необходимости },}
func wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } defer conn.Close() for { msgType, msg, err := conn.ReadMessage() if err != nil { log.Println("read:", err) break } if err := conn.WriteMessage(msgType, msg); err != nil { log.Println("write:", err) break } }}Альтернатива — coder/websocket (бывшая nhooyr.io/websocket) — современная, без external deps.
2.9 HTTP/2 server push
Заголовок раздела «2.9 HTTP/2 server push»Был добавлен в Go 1.8, но deprecated в Chrome (с 2022) и большинстве других браузеров. Не используйте в новых проектах.
Если действительно нужно (gRPC к примеру использует HTTP/2 multiplexing, что НЕ то же самое что server push):
func handler(w http.ResponseWriter, r *http.Request) { if pusher, ok := w.(http.Pusher); ok { pusher.Push("/static/app.css", nil) } // ...}3. Gotchas
Заголовок раздела «3. Gotchas»3.1 Нет ReadHeaderTimeout
Заголовок раздела «3.1 Нет ReadHeaderTimeout»// ПЛОХО:srv := &http.Server{Addr: ":8080", Handler: mux}// нет таймаутов → уязвимость Slowloris (атакующий шлёт по 1 байту в секунду)Всегда ставьте ReadHeaderTimeout, минимум.
3.2 Timeout middleware не отменяет горутины
Заголовок раздела «3.2 Timeout middleware не отменяет горутины»func Timeout(d time.Duration) func(http.Handler) http.Handler { // ставит ctx с deadline, но если handler не проверяет ctx — не помогает}Timeout(5s) НЕ убьёт handler, который делает time.Sleep(10s). Чтобы это работало, handler должен:
- Передавать
r.Context()в downstream (db, http client); - Сам проверять
<-ctx.Done()в long-running loops.
3.3 http.Error и status code после WriteHeader
Заголовок раздела «3.3 http.Error и status code после WriteHeader»// ПЛОХО:w.WriteHeader(http.StatusOK)// ... ошибка ...http.Error(w, "err", http.StatusInternalServerError)// уже WriteHeader(200) — заголовок не поменяется, в логах warningНельзя писать заголовок дважды. Если уже начали response — придётся писать ошибку в тело.
3.4 Race condition на map в middleware
Заголовок раздела «3.4 Race condition на map в middleware»var counters = make(map[string]int)func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { counters[r.URL.Path]++ // RACE! next.ServeHTTP(w, r) })}HTTP server параллелит handlers по умолчанию. Защищайте map mutex’ом или используйте sync.Map, лучше — atomic-counter или Prometheus metric.
3.5 Body не закрыт
Заголовок раздела «3.5 Body не закрыт»resp, _ := http.Get("...")data, _ := io.ReadAll(resp.Body)// забыли resp.Body.Close() → leak соединенийВсегда defer resp.Body.Close() (для клиента) или обработать errors Decode (на сервере defer не нужен).
3.6 r.Body не прочитан после Decode
Заголовок раздела «3.6 r.Body не прочитан после Decode»json.NewDecoder(r.Body).Decode(&req)// если в body больше данных, чем JSON — connection не reusableЕсли хотите reuse keep-alive connection — нужно дочитать body. Обычно io.Copy(io.Discard, r.Body) или defer r.Body.Close().
3.7 Path vs RoutePattern в метриках
Заголовок раздела «3.7 Path vs RoutePattern в метриках»// ПЛОХО: высокая кардинальность Prometheus метрикcounter.WithLabelValues(r.URL.Path).Inc()// /users/123, /users/124, /users/125 → 3 разных seriesИспользуйте route pattern:
counter.WithLabelValues(chi.RouteContext(r.Context()).RoutePattern()).Inc()// /users/{id} → 1 series3.8 Глобальный validator vs локальный
Заголовок раздела «3.8 Глобальный validator vs локальный»// ПЛОХО:func handler(...) { v := validator.New() // создание на каждый request — медленно v.Struct(req)}
// ХОРОШО:var validate = validator.New()func handler(...) { validate.Struct(req)}validator дорого создать. Делайте один глобальный.
3.9 chi sub-router без mounting
Заголовок раздела «3.9 chi sub-router без mounting»r.Route("/api", func(r chi.Router) { r.Use(authMiddleware) // НЕ применится, если использовали Mount r.Get("/users", h)})// VSsub := chi.NewRouter()sub.Get("/users", h)r.Mount("/api", sub) // middleware на sub не применится!Используйте Route() для inline-определения, Mount() — для готового sub-router (нужно вешать middleware на него отдельно).
3.10 net/http и gin handlers не совместимы
Заголовок раздела «3.10 net/http и gin handlers не совместимы»// gin Handlerr.Use(func(c *gin.Context) { ... })
// net/http middlewarer.Use(func(next http.Handler) http.Handler { ... }) // НЕ работает в ginЭто причина, по которой переход с gin/echo на net/http (или наоборот) дорогой — все middleware придётся переписывать.
3.11 Server вернул error после Shutdown
Заголовок раздела «3.11 Server вернул error после Shutdown»err := srv.ListenAndServe()// после Shutdown() возвращает http.ErrServerClosedhttp.ErrServerClosed — нормально, это не ошибка. Игнорируйте:
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err)}3.12 Decode принимает любой JSON
Заголовок раздела «3.12 Decode принимает любой JSON»var req CreateUserRequestjson.NewDecoder(r.Body).Decode(&req)// {"foo": 1, "bar": 2} → req заполнится zero-values, без ошибкиДля strict parsing:
dec := json.NewDecoder(r.Body)dec.DisallowUnknownFields()if err := dec.Decode(&req); err != nil { ... }3.13 ResponseWriter не безопасен после возврата из Handler
Заголовок раздела «3.13 ResponseWriter не безопасен после возврата из Handler»func handler(w http.ResponseWriter, r *http.Request) { go func() { w.Write([]byte("...")) // НЕЛЬЗЯ! Handler уже завершён. }()}После выхода из handler w уже невалиден. Если нужна async-работа — пишите в response до возврата, либо используйте SSE/streaming.
3.14 Не везде есть ctx-пропагация
Заголовок раздела «3.14 Не везде есть ctx-пропагация»// ПЛОХО:http.Get("https://api.example.com") // без ctx
// ХОРОШО:req, _ := http.NewRequestWithContext(r.Context(), "GET", "https://api.example.com", nil)http.DefaultClient.Do(req)Передавайте r.Context() во все downstream-вызовы, иначе timeout middleware не работает.
3.15 gin.Context.Get/Set типов
Заголовок раздела «3.15 gin.Context.Get/Set типов»c.Set("user", &User{})v, _ := c.Get("user")user := v.(*User) // panic, если что-то не такgin использует map[string]any. Type-assertions опасны.
3.16 fiber и HTTP/2
Заголовок раздела «3.16 fiber и HTTP/2»fiber на fasthttp не поддерживает HTTP/2. Это блокирует:
- gRPC-Web (требует HTTP/2);
- многие современные клиенты;
- HTTP/2 server push (deprecated, но всё же).
Поэтому fiber не выбирают для production-API.
4. Best practices
Заголовок раздела «4. Best practices»- Используйте
net/http(1.22+) +chiдля большинства проектов. - Всегда ставьте таймауты на
http.Server:ReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout. - Middleware: Logger, RequestID, Recoverer, Timeout, Metrics — стандартный набор.
- Передавайте
r.Context()в downstream-вызовы (DB, HTTP-клиенты, Kafka). - Используйте
signal.NotifyContextдля graceful shutdown. - Не пишите в
ResponseWriterпосле возврата из handler. - Validator один глобальный, не создавайте на каждый запрос.
- Strict JSON parsing:
DisallowUnknownFields()где можно. - Метрики по route pattern, не по raw path.
- CORS — через готовую либу (
rs/cors,chi/cors), не вручную. - Auth — middleware, не в handler.
- Per-route middleware через
r.Group()илиr.With(). - Error responses — структурированные JSON, с кодом ошибки и сообщением.
- Не используйте fiber, если нужен HTTP/2 или совместимость с net/http экосистемой.
- OpenAPI spec — source of truth. spec-first для контракта.
- Health endpoints:
/healthz(liveness) и/readyz(readiness) отдельно. - Body size limit:
http.MaxBytesReader(w, r.Body, 1<<20)против DDoS. - TLS в production: используйте Let’s Encrypt (autocert), HTTP/2 включён по умолчанию.
5. Вопросы на собесе
Заголовок раздела «5. Вопросы на собесе»-
Что нового в
http.ServeMuxв Go 1.22? Method matching, path parameters (/users/{id}), wildcards ({path...}), host matching, precedence rules. Раньше нужен был chi/gorilla для этого. -
Чем
net/httpотличается от gin? net/http — stdlib, минимум, идиоматично. gin — фреймворк со своим Context, быстрее, но не совместим с net/http middleware. -
Что такое middleware в HTTP? Функция, оборачивающая
http.Handler. Сигнатураfunc(next http.Handler) http.Handler. Применяется для logging, auth, recover, etc. -
Чем chi отличается от gin? chi совместим с net/http (Handler). gin использует свой
gin.Context, не совместим. chi идиоматичнее для Go. -
Что такое
http.HandlerFunc? Адаптер, превращающий функциюfunc(w, r)вhttp.Handlerчерез методServeHTTP. Позволяет использовать обычные функции как handler. -
Зачем нужен
ReadHeaderTimeout? Защита от Slowloris-атак: атакующий медленно шлёт заголовки, держа коннект. Без таймаута — DOS. -
Чем
http.ErrServerClosedотличается от ошибки? Это «нормальное» возвращаемое значениеListenAndServeпослеShutdown(). Игнорируйте черезerrors.Is. -
Как сделать graceful shutdown HTTP-сервера?
srv.Shutdown(ctx)— даёт in-flight requests завершиться, потом закрывает listener. Запускайте по сигналу SIGTERM/SIGINT. -
Что делает middleware Timeout? Добавляет deadline в
r.Context(). Чтобы handler реально прервался — он должен проверятьctx.Done()или передавать ctx в downstream. -
Как передать данные из middleware в handler? Через
context.WithValue: создаёшь новый ctx, помещаешь значение, передаёшьr.WithContext(ctx)дальше. В handler —r.Context().Value(key). -
Чем отличается per-route middleware от global? Global — на всё (через
Use). Per-route — внутриGroup()/With()/Route(). В chi:r.With(mw).Get(...). -
Что такое
http.Flusher? Интерфейс, позволяющий принудительно отправить буферизованные данные клиенту. Нужен для SSE и streaming. -
Когда выбрать fiber? Когда нужен экстремальный RPS, не нужен HTTP/2, нет переплетений с net/http экосистемой. Редкая ниша.
-
Spec-first vs code-first для OpenAPI? Spec-first: spec — source of truth, генерируется сервер (oapi-codegen). Code-first: spec из комментариев (swag). Spec-first лучше для интеграции.
-
Как сделать rate limiting? *
golang.org/x/time/rate(token bucket). Per-IP — map[string]Limiter. Распределённый — Redis. -
Что такое CSRF и как защищаться? Cross-Site Request Forgery. Защита: SameSite cookies, CSRF-токены, Origin/Referer check. Не нужно для чистых JSON-API с Authorization header.
-
Что такое CORS preflight? Браузер шлёт OPTIONS перед сложным запросом (не GET/POST с simple headers). Сервер отвечает
Access-Control-Allow-*. -
Как вернуть streaming response? Установить
Content-Type, писать вwи вызыватьw.(http.Flusher).Flush()после каждого chunk. -
WebSocket: gorilla/websocket vs nhooyr.io? gorilla — старая популярная либа, недавно возобновлённая. nhooyr/coder — современная, без deps, проще API. Обе работают.
-
Recover middleware — зачем? Чтобы panic в одном handler не убил весь сервер. Catch panic, log, вернуть 500.
-
Как обработать
http.ErrAbortHandler? Это специальный sentinel для прерывания обработки. В Recoverer middleware нужно re-panic, чтобы Go тёхнологически отменил response. -
Как лимитировать размер body?
http.MaxBytesReader(w, r.Body, 1<<20)— обернуть body, чтобы Decode возвращал ошибку при превышении. -
Чем validate.Struct от validate.Var? Struct — валидирует struct по тегам. Var — одно значение с правилами строкой:
validate.Var(email, "email"). -
Что такое route pattern в chi? Шаблон роута без подстановки:
/users/{id}(а не/users/123). Полезен для метрик, чтобы не плодить series. -
Можно ли использовать
http.Serverиgrpc.Serverна одном порту? Да, черезcmux(soheilhy/cmux) или h2c upgrade. Но обычно разделяют порты (8080 HTTP, 9090 gRPC). -
Зачем
DisallowUnknownFields()? Чтобы JSON с лишними полями возвращал ошибку. По умолчанию Go их игнорирует. -
Чем
WriteTimeoutотличается отIdleTimeout? Write — на одну response. Idle — между запросами в keep-alive connection. -
Что вернёт
r.PathValue("missing")в Go 1.22? Пустую строку (""), не nil. PathValue для отсутствующего параметра не возвращает ошибку. -
Зачем нужен X-Request-ID header? Для distributed tracing — связать запрос на frontend с логами/трейсами на всех downstream-сервисах.
-
Как мерить latency правильно? Histogram (не summary), с правильными buckets под SLO. Использовать route pattern, не raw path. Не считать времени до первой записи в response.
6. Practice
Заголовок раздела «6. Practice»-
Напишите HTTP-сервер на stdlib (1.22+) с эндпоинтами
GET /users/{id},POST /users,DELETE /users/{id}. Без фреймворка. -
Реализуйте middleware-chain:
- RequestID;
- Logger (метод, путь, статус, длительность);
- Recoverer;
- Timeout(5s);
- Metrics (Prometheus). Покажите, что они применяются в правильном порядке.
-
Переведите тот же сервер на chi. Добавьте
/api/v1с auth middleware, оставив/healthzpublic. -
Настройте
go-playground/validatorдля CreateUserRequest. Возвращайте детали ошибок в JSON. -
Реализуйте SSE-эндпоинт
/events, который пушит время раз в секунду. Проверьте, чтоr.Context().Done()отменяет отправку. -
Сравните три имплементации одного и того же API:
net/http+chi;- gin;
- echo. На каком меньше всего кода?
-
Напишите spec на 2-3 эндпоинта OpenAPI 3.0, сгенерируйте handlers через
oapi-codegen, реализуйте логику. -
Настройте rate limiting на 100 req/s/IP. Покажите 429 ответ под нагрузкой (
hey/vegeta). -
Протестируйте graceful shutdown: SIGTERM → новые connections отбиваются, in-flight завершаются за 30s.
-
Реализуйте WebSocket-чат на gorilla/websocket: broadcast сообщений всем подключённым.
7. Источники
Заголовок раздела «7. Источники»- Go 1.22 release notes: ServeMux — https://go.dev/blog/routing-enhancements
- net/http godoc — https://pkg.go.dev/net/http
- chi — https://github.com/go-chi/chi
- gin — https://gin-gonic.com
- echo — https://echo.labstack.com
- fiber — https://gofiber.io
- go-playground/validator — https://github.com/go-playground/validator
- swaggo/swag — https://github.com/swaggo/swag
- oapi-codegen — https://github.com/oapi-codegen/oapi-codegen
- Mat Ryer. How I write HTTP services in Go — https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/
- Cloudflare. Exposing Go on the Internet — https://blog.cloudflare.com/exposing-go-on-the-internet/
- prometheus/client_golang — https://github.com/prometheus/client_golang