Модуль 8. Создание Web API сервиса
Цели модуля
После модуля студент сможет:
- Создавать HTTP-серверы на net/http
- Понимать интерфейсы http.Handler и http.HandlerFunc
- Использовать новый роутинг http.ServeMux (Go 1.22+)
- Понимать жизненный цикл запроса/ответа
- Кодировать и декодировать JSON
- Проектировать DTO и мапперы
- Реализовывать health checks (/healthz, /readyz)
- Структурировать HTTP-слой по шаблону проекта
🔥 Инцидент: успешный деплой и пять минут ошибок
Пятничный деплой прошёл образцово: пайплайн зелёный, под поднялся, балансировщик сразу пустил на него трафик. Через минуту мониторинг показал волну 500-х: сервис заказов принимал запросы, ещё не успев поднять пул соединений к базе. Health-чек этого не заметил — он отвечал 200 всегда, даже когда сервис был неработоспособен:
gohttp.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) // «здоров», даже когда БД недоступна }) http.ListenAndServe(":8080", nil) // ни одного таймаута
Через пять минут ошибки прекратились сами — сервис «догнался», и инцидент
списали на «деплой такой». Через неделю он повторился на следующем релизе.
А в ту же ночь был и второй звонок: зависший вызов платёжки через http.Get
без таймаута держал горутины и соединения почти час.
В этом модуле соберём HTTP-слой, который не подводит: обработчики и роутинг,
жизненный цикл запроса/ответа, таймауты сервера и клиента, JSON и DTO, честные
/healthz и /readyz и структуру HTTP-слоя по шаблону проекта.
8.1 net/http и HTTP-сервер
Минимальный HTTP-сервер
gopackage main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello, World!") }) fmt.Println("server starting on :8080") http.ListenAndServe(":8080", nil) }
http.Server с настройками
Для production всегда настраивайте таймауты:
gosrv := &http.Server{ Addr: ":8080", Handler: router, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } if err := srv.ListenAndServe(); err != nil { log.Fatalf("server error: %v", err) }
| Таймаут | Назначение |
|---|---|
| ReadTimeout | Максимальное время чтения запроса |
| WriteTimeout | Максимальное время записи ответа |
| IdleTimeout | Время ожидания в keep-alive соединении |
Запуск в горутине с graceful shutdown
gogo func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() srv.Shutdown(shutdownCtx)
8.2 Хэндлеры и http.Handler
Интерфейс http.Handler
Вся обработка HTTP в Go построена на одном интерфейсе:
gotype Handler interface { ServeHTTP(ResponseWriter, *Request) }
Любой тип с методом ServeHTTP является хэндлером.
http.HandlerFunc
HandlerFunc - адаптер позволяющий использовать обычную функцию как Handler:
gotype HandlerFunc func(ResponseWriter, *Request) func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) } // Использование handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello") })
Хэндлер как структура
Для реальных сервисов хэндлеры - структуры с зависимостями:
gotype Handler struct { repoService *service.RepoService userService *service.UserService logger *slog.Logger } func NewHandler(repoSvc *service.RepoService, userSvc *service.UserService) *Handler { return &Handler{ repoService: repoSvc, userService: userSvc, logger: slog.Default(), } } func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") repo, err := h.repoService.GetRepo(r.Context(), id) if err != nil { h.handleError(w, err) return } h.writeJSON(w, http.StatusOK, repo) }
ResponseWriter
http.ResponseWriter - интерфейс для записи ответа:
go// Запись заголовков (до WriteHeader) w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Request-ID", requestID) // Установка статус кода w.WriteHeader(http.StatusCreated) // Запись тела w.Write([]byte("response body"))
ВАЖНО: порядок операций:
- Header().Set() - установка заголовков
- WriteHeader() - установка статус кода
- Write() - запись тела
Если вызвать Write() без WriteHeader(), автоматически вызовется WriteHeader(200).
Request
http.Request содержит всю информацию о запросе:
textr.Method // HTTP метод: GET, POST, PUT, DELETE r.URL.Path // путь: /api/v1/repos r.URL.Query() // query параметры: ?page=1&limit=10 r.Header // заголовки запроса r.Body // тело запроса (io.ReadCloser) r.Context() // context запроса r.PathValue("id") // path параметр (Go 1.22+) r.Host // хост r.RemoteAddr // адрес клиента
8.3 Роутинг (http.ServeMux в Go 1.22+)
Новый роутинг Go 1.22+
Начиная с Go 1.22, стандартный http.ServeMux поддерживает:
-
HTTP методы в паттернах
-
Path parameters
-
Wildcards
mux := http.NewServeMux()
// Метод + путь mux.HandleFunc("GET /api/v1/repos", h.ListRepos) mux.HandleFunc("POST /api/v1/repos", h.CreateRepo) mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo) mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo) mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo)
// Wildcard mux.HandleFunc("GET /static/{path...}", h.StaticFiles)
Path parameters
Параметры извлекаются через r.PathValue():
gofunc (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") // извлекает {id} из паттерна repo, err := h.repoService.GetRepo(r.Context(), id) if err != nil { h.handleError(w, err) return } h.writeJSON(w, http.StatusOK, repo) }
Регистрация роутов в шаблоне
Пример из шаблона: internal/handlers/http/api/v1/routes.go
gopackage v1 import ( "net/http" ) func RegisterRoutes(mux *http.ServeMux, h *Handler) { // Repos mux.HandleFunc("GET /api/v1/repos", h.ListRepos) mux.HandleFunc("POST /api/v1/repos", h.CreateRepo) mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo) mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo) mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo) // Users mux.HandleFunc("GET /api/v1/users/{id}", h.GetUser) mux.HandleFunc("POST /api/v1/users", h.CreateUser) // Health checks mux.HandleFunc("GET /healthz", h.Healthz) mux.HandleFunc("GET /readyz", h.Readyz) }
Альтернативные роутеры
Для сложных проектов можно использовать сторонние роутеры:
| Роутер | Особенности |
|---|---|
| chi | Лёгкий, middleware-friendly, совместим с net/http |
| gin | Быстрый, много встроенных фич |
| echo | Минималистичный, хорошая документация |
| gorilla/mux | Мощный, но на поддержке |
Для большинства проектов стандартного http.ServeMux достаточно.
8.4 Жизненный цикл запроса/ответа
Полный цикл HTTP-запроса
text1. Клиент отправляет запрос | 2. http.Server принимает соединение | 3. Парсинг запроса (метод, путь, заголовки, тело) | 4. Создание http.Request | 5. Роутер находит хэндлер по паттерну | 6. Middleware-цепочка оборачивает хэндлер | 7. Хэндлер обрабатывает запрос | - Валидация входных данных | - Вызов usecase/service | - Формирование ответа | 8. Запись ответа (заголовки, статус, тело) | 9. Ответ отправляется клиенту | 10. Соединение закрывается или переиспользуется (keep-alive)
Middleware в цикле
Middleware оборачивает хэндлер и выполняется до/после:
gofunc 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)) }) }
Порядок middleware
go// Middleware выполняется в порядке добавления handler = LoggingMiddleware(handler) // 1. logging handler = RequestIDMiddleware(handler) // 2. request id handler = RecoveryMiddleware(handler) // 3. recovery // Порядок выполнения запроса: // Recovery -> RequestID -> Logging -> Handler
8.5 JSON encoding/decoding
Кодирование (Go -> JSON)
goimport "encoding/json" type Repo struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } repo := &Repo{ID: "1", Name: "test", CreatedAt: time.Now()} // В http.ResponseWriter w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(repo)
Декодирование (JSON -> Go)
gofunc (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) { var req CreateRepoRequest // Ограничение размера тела r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request body") return } // Валидация и обработка }
Теги структур
Теги управляют сериализацией:
gotype Repo struct { ID string `json:"id"` // имя поля в JSON Name string `json:"name"` // имя поля в JSON Internal string `json:"-"` // не сериализуется Optional string `json:"optional,omitempty"` // пропускается если пусто }
Обработка ошибок декодирования
gofunc decodeJSON(r *http.Request, v interface{}) error { defer r.Body.Close() decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() // строгий режим if err := decoder.Decode(v); err != nil { var syntaxErr *json.SyntaxError var typeErr *json.UnmarshalTypeError switch { case errors.As(err, &syntaxErr): return fmt.Errorf("syntax error at position %d", syntaxErr.Offset) case errors.As(err, &typeErr): return fmt.Errorf("invalid type for field %s", typeErr.Field) default: return err } } return nil }
Вспомогательные функции для ответов
gofunc (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(data); err != nil { h.logger.Error("failed to encode response", "error", err) } } func (h *Handler) writeError(w http.ResponseWriter, status int, message string) { h.writeJSON(w, status, ErrorResponse{ Code: http.StatusText(status), Message: message, }) }
8.6 DTO и мапперы
Зачем нужны DTO
DTO (Data Transfer Object) - контракт API. Отделяет:
- Что видит клиент (DTO)
- Что хранится внутри (Domain Model)
go// DTO - контракт API type CreateRepoRequest struct { Name string `json:"name"` OwnerID string `json:"owner_id"` IsPrivate bool `json:"is_private"` } type CreateRepoResponse struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } // Domain Model - внутреннее представление // models.Repo содержит больше полей
Мапперы
Мапперы преобразуют DTO <-> Domain Model:
go// DTO -> Domain func CreateRepoRequestToDomain(req CreateRepoRequest) *models.Repo { return models.NewRepo(req.Name, req.OwnerID, req.IsPrivate) } // Domain -> DTO func RepoToResponse(repo *models.Repo) *CreateRepoResponse { return &CreateRepoResponse{ ID: repo.ID, Name: repo.Name, CreatedAt: repo.CreatedAt, } }
Пример из шаблона: internal/handlers/http/api/v1/dto.go
gopackage v1 import ( "time" "yourproject/internal/models" ) // Requests type CreateRepoRequest struct { Name string `json:"name" validate:"required,min=3,max=100"` OwnerID string `json:"owner_id" validate:"required,uuid"` IsPrivate bool `json:"is_private"` } func (r *CreateRepoRequest) ToDomain() *models.Repo { return models.NewRepo(r.Name, r.OwnerID, r.IsPrivate) } // Responses type RepoResponse struct { ID string `json:"id"` Name string `json:"name"` OwnerID string `json:"owner_id"` IsPrivate bool `json:"is_private"` CreatedAt time.Time `json:"created_at"` } func NewRepoResponse(repo *models.Repo) *RepoResponse { return &RepoResponse{ ID: repo.ID, Name: repo.Name, OwnerID: repo.OwnerID, IsPrivate: repo.IsPrivate, CreatedAt: repo.CreatedAt, } } type ErrorResponse struct { Code string `json:"code"` Message string `json:"message"` }
8.7 Health checks
Зачем нужны health checks
Health checks используются:
- Kubernetes liveness probe (/healthz)
- Kubernetes readiness probe (/readyz)
- Load balancer checks
- Мониторинг
/healthz - liveness
Проверяет что процесс жив. Не проверяет зависимости.
jsonfunc (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", }) }
/readyz - readiness
Проверяет что сервис готов принимать трафик. Проверяет зависимости.
gofunc (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // Проверяем БД if err := h.db.Ping(ctx); err != nil { h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "status": "not ready", "reason": "database unavailable", }) return } h.writeJSON(w, http.StatusOK, map[string]string{ "status": "ready", }) }
Разница между healthz и readyz
| Endpoint | Назначение | Что проверяет |
|---|---|---|
| /healthz | Процесс жив | Ничего (всегда 200) |
| /readyz | Готов принимать трафик | БД, кэш, внешние сервисы |
Kubernetes использует:
- livenessProbe -> /healthz (перезапуск если не жив)
- readinessProbe -> /readyz (исключение из балансировки если не готов)
8.8 Структура HTTP-слоя в шаблоне
Организация файлов
textinternal/handlers/http/ ├── api/ │ └── v1/ │ ├── handler.go # HTTP-хэндлеры │ ├── dto.go # DTO: requests и responses │ ├── mapper.go # Мапперы DTO <-> Domain │ └── routes.go # Регистрация роутов ├── git/ │ └── handler.go # Git Smart HTTP (отдельный модуль) └── middleware/ ├── request_id.go ├── logging.go ├── recovery.go ├── timeout.go └── pii.go
Ответственность файлов
| Файл | Ответственность |
|---|---|
| handler.go | HTTP-хэндлеры: декодирование, вызов usecase, кодирование ответа |
| dto.go | DTO: структура запросов и ответов API |
| mapper.go | Мапперы: преобразование DTO <-> Domain Model |
| routes.go | Регистрация роутов в mux |
| middleware/ | Middleware для обработки запросов |
Handler в шаблоне
go// internal/handlers/http/api/v1/handler.go package v1 import ( "net/http" "yourproject/internal/service" "yourproject/internal/usecase" ) type Handler struct { createRepoUC *usecase.CreateRepoUseCase getRepoUC *usecase.GetRepoUseCase logger *slog.Logger } func NewHandler( createRepoUC *usecase.CreateRepoUseCase, getRepoUC *usecase.GetRepoUseCase, logger *slog.Logger, ) *Handler { return &Handler{ createRepoUC: createRepoUC, getRepoUC: getRepoUC, logger: logger, } } func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) { var req CreateRepoRequest if err := decodeJSON(r, &req); err != nil { h.writeError(w, http.StatusBadRequest, "invalid request body") return } repo, err := h.createRepoUC.Execute(r.Context(), req) if err != nil { h.handleError(w, err) return } h.writeJSON(w, http.StatusCreated, NewRepoResponse(repo)) } func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") repo, err := h.getRepoUC.Execute(r.Context(), id) if err != nil { h.handleError(w, err) return } h.writeJSON(w, http.StatusOK, NewRepoResponse(repo)) } func (h *Handler) handleError(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrRepoNotFound): h.writeError(w, http.StatusNotFound, "repo not found") case errors.Is(err, service.ErrRepoAlreadyExists): h.writeError(w, http.StatusConflict, "repo already exists") default: h.logger.Error("unexpected error", "error", err) h.writeError(w, http.StatusInternalServerError, "internal server error") } } func (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func (h *Handler) writeError(w http.ResponseWriter, status int, message string) { h.writeJSON(w, status, ErrorResponse{ Code: http.StatusText(status), Message: message, }) }
Привязка к шаблону проекта
Где HTTP-слой в шаблоне
textinternal/handlers/http/api/v1/ ├── handler.go # HTTP-хэндлеры ├── dto.go # DTO ├── mapper.go # Мапперы └── routes.go # Роуты
Что изучаем на примере шаблона
- Handler зависит от usecase, не от service
- DTO отделяют API-контракт от доменных моделей
- Мапперы преобразуют DTO <-> Domain
- Роуты регистрируются в отдельном файле
- Health checks на отдельных эндпоинтах
Задание: изучите internal/handlers/http/api/v1/
- Какие эндпоинты реализованы?
- Как хэндлеры получают зависимости?
- Как обрабатываются ошибки?
- Есть ли валидация запросов?
💱 Компромиссы
http.ServeMuxvs сторонний роутер: с Go 1.22 stdlib умеет методы и{id}-параметры без единой зависимости, но middleware-цепочки собираем вручную;chiдаёт middleware и группы роутов из коробки, но это внешняя зависимость вgo.mod.- DTO vs доменные модели в ответе: DTO фиксируют контракт API, и домен меняется не ломая клиентов, но каждый эндпоинт обрастает маппером; доменная модель напрямую — меньше кода, но внутренние поля протекают наружу.
DisallowUnknownFieldsvs мягкий decode: строгий режим сразу ловит опечатки клиента и отвечает 400, но добавление нового поля ломает старых клиентов; мягкий совместим вперёд, но мусор в запросе молча игнорируется./readyzс проверкой БД vs всегда 200: честный readiness снимает неработоспособный под с балансировщика, но при лежащей БД весь сервис уходит из ротации разом; всегда 200 не даёт каскада, но пропускает деплой «зелёный, но мёртвый» — сценарий из начала модуля.- Таймауты сервера vs умолчания:
ReadTimeout/WriteTimeoutобрывают зависшие запросы и не дают соединениям копиться, но режут и медленные легитимные ответы — выгрузки, стриминг; без таймаутов медленный клиент доезжает, но зависший клиент держит соединение и горутину часами.
Практическое задание
Задание 1: Минимальный HTTP-сервер
Создайте HTTP-сервер с двумя эндпоинтами:
gofunc main() { mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, World!") }) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) srv := &http.Server{ Addr: ":8080", Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, } log.Println("server starting on :8080") srv.ListenAndServe() }
Задание 2: CRUD API для Repo
Реализуйте полный CRUD:
textmux.HandleFunc("GET /api/v1/repos", h.ListRepos) mux.HandleFunc("POST /api/v1/repos", h.CreateRepo) mux.HandleFunc("GET /api/v1/repos/{id}", h.GetRepo) mux.HandleFunc("PUT /api/v1/repos/{id}", h.UpdateRepo) mux.HandleFunc("DELETE /api/v1/repos/{id}", h.DeleteRepo)
Задание 3: DTO и мапперы
Создайте DTO для CreateRepo:
gotype CreateRepoRequest struct { Name string `json:"name"` OwnerID string `json:"owner_id"` IsPrivate bool `json:"is_private"` } type RepoResponse struct { ID string `json:"id"` Name string `json:"name"` OwnerID string `json:"owner_id"` CreatedAt time.Time `json:"created_at"` } func (r *CreateRepoRequest) ToDomain() *models.Repo { ... } func NewRepoResponse(repo *models.Repo) *RepoResponse { ... }
Задание 4: Health checks
Реализуйте /healthz и /readyz:
textmux.HandleFunc("GET /healthz", h.Healthz) mux.HandleFunc("GET /readyz", h.Readyz)
Задание 5: Обработка ошибок
Реализуйте handleError с маппингом ошибок на HTTP-статусы:
gofunc (h *Handler) handleError(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrRepoNotFound): h.writeError(w, http.StatusNotFound, "repo not found") case errors.Is(err, service.ErrRepoAlreadyExists): h.writeError(w, http.StatusConflict, "repo already exists") case errors.Is(err, context.Canceled): return // клиент закрыл соединение default: h.logger.Error("unexpected error", "error", err) h.writeError(w, http.StatusInternalServerError, "internal server error") } }
Задание 6: Тесты для хэндлеров
Напишите тесты с httptest:
gofunc TestHandler_GetRepo(t *testing.T) { mockUC := new(MockGetRepoUseCase) mockUC.On("Execute", mock.Anything, "repo-123"). Return(&models.Repo{ID: "repo-123", Name: "test"}, nil) h := NewHandler(nil, mockUC, slog.Default()) req := httptest.NewRequest("GET", "/api/v1/repos/repo-123", nil) w := httptest.NewRecorder() h.GetRepo(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "repo-123") }
Quiz "Проверь себя"
/healthz и чем он отличается от /readyz?if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// что здесь?
}✅ Итоги модуля
Студент должен:
- Создавать HTTP-серверы с настройкой таймаутов
- Понимать http.Handler и http.HandlerFunc
- Использовать http.ServeMux с path parameters
- Кодировать и декодировать JSON
- Проектировать DTO и мапперы
- Реализовывать health checks
- Тестировать хэндлеры с httptest
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 9
Следующий модуль: Модуль 9. Middleware и валидация - middleware-паттерн, request ID, logging, recovery, timeout, валидация запросов. Привязка к internal/handlers/http/middleware/ шаблона.