JavaRush /Курсы /Go SELF /Минимизация дублирования: HTTP‑handler’ы

Минимизация дублирования: HTTP‑handler’ы

Go SELF
60 уровень , 4 лекция
Открыта

1. Почему копипаста в handler’ах — это «тихий» источник багов

Когда вы только начинаете писать HTTP‑сервер, handler выглядит безобидно: несколько строк, пара проверок, w.WriteHeader(...), готово. Но как только появляется второй handler, затем третий, затем «ещё чуть-чуть валидации», затем «а давайте везде JSON», вы внезапно обнаруживаете, что половина проекта — это повторяющиеся блоки: выставить Content-Type, выбрать статус, сформировать JSON ошибки, не забыть return, не выдать наружу err.Error() на 500 и так далее.

Проблема не в эстетике (хотя эстетика тоже страдает, и IDE начинает плакать). Проблема в том, что копипаста почти всегда расходится: в одном handler’е вы забыли charset, в другом — отдали 500 вместо 400, в третьем — сделали формат ошибки «чуть другой», и клиенты вашего API начинают жить в мире неожиданностей.

В Go исторически любят решать такие штуки через простой и честный приём: ошибки — это значения, с ними можно программировать, а не только печатать их строкой. Эта идея хорошо сформулирована в классическом тексте “Errors are values”. И если ошибки — значения, то мы можем вынести повторяющуюся обработку ошибок в одно место, вместо того чтобы повторять её везде.

Главная идея: handler возвращает результат и ошибку

Чтобы избавиться от дублирования, нужно разделить ответственность. Handler должен заниматься смыслом: «прочитал вход → проверил → посчитал → вернул результат». А вот HTTP‑обвязка должна заниматься ритуалами: «если ошибка — переведи её в JSON, выставь статус, выставь заголовки; если успех — отдай JSON результата».

Это не новая магия и не «паттерн из модного фреймворка». В Go это базовая практика: сделать свой тип функции, дать ему метод ServeHTTP и тем самым встроить его в net/http. В старой (но очень показательной) статье про обработку ошибок в Go прямо демонстрируется, как тип appHandler помогает вынести повторяющуюся обработку ошибок из отдельных handler’ов в один общий ServeHTTP.

Мы сделаем похожую конструкцию, только вместо «просто http.Error текстом» будем возвращать единый error envelope в JSON и придерживаться нашего контракта по ValidationError.Fields.

2. Кирпичики контракта: ValidationError, HTTPError, error envelope

Прежде чем писать адаптер, нужно договориться о «форме данных». Иначе общий слой не сможет быть общим: ему нечего будет стандартизировать.

Сначала — ошибка валидации. Она хранит ошибки по конкретным полям, а Error() у неё — просто маркер “validation error”. Это удобно: человеку мы показываем нормальное сообщение на уровне API, а машине (и UI‑клиенту) — отдаём fields.

package main

type ValidationError struct {
	Fields map[string]string
}

func (e *ValidationError) Error() string {
	return "validation error"
}

Теперь — error envelope (единая форма ответа об ошибке). Мы держим code (машинный), message (короткий и безопасный) и опционально fields.

package main

type APIError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type ErrorEnvelope struct {
	Error APIError `json:"error"`
}

И наконец — «ошибка HTTP‑слоя», которая разделяет публичное и внутреннее. Внутри мы храним настоящую причину (Err) — для логов/диагностики. Снаружи — контролируемый Message.

package main

import "net/http"

type HTTPError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func internal(err error) *HTTPError {
	return &HTTPError{
		Status:  http.StatusInternalServerError,
		Code:    "internal",
		Message: "internal error",
		Err:     err,
	}
}

Здесь важно прочувствовать философию: сообщение пользователю и внутренняя причина — это разные вещи. Если всё свалить в err.Error(), вы либо раскроете лишнее, либо будете вынуждены скрывать полезную диагностику.

4. Центральная точка encode: writeJSON

Любой серьёзный API в какой-то момент упирается в одно простое правило: «ответы должны выглядеть одинаково». И это касается не только ошибок, но и успешных ответов.

Если каждый handler сам делает json.NewEncoder(w).Encode(...), то неизбежно появляются отличия: где-то забыли Content-Type, где-то забыли статус до записи тела, где-то в одном месте начали писать ответ, а потом решили «ой, ошибка».

Мы сделаем маленький writeJSON, который станет единственным местом, где мы сериализуем JSON для успеха.

package main

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

Да, мы пока игнорируем ошибку Encode. Не потому что «ошибок не бывает», а потому что если кодек сломался на ровном месте — клиенту уже не помочь «красивым JSON»: заголовки могли быть отправлены, часть тела могла уйти. В реальных сервисах это логируют. Мы логирование сейчас сознательно не углубляем, чтобы не устраивать «всё и сразу».

5. Центральная точка ошибок: writeError и errors.As

Теперь делаем вторую центральную точку — для ошибок. Именно здесь наш ValidationError.Fields должен превращаться в fields внутри error envelope.

Чтобы безопасно извлечь типизированную ошибку из Err, мы используем errors.As. Это стандартный путь в Go для «найди в цепочке ошибок значение нужного типа и запиши в переменную», появившийся вместе с общим подходом error unwrapping.

package main

import (
	"encoding/json"
	"errors"
	"net/http"
)

func writeError(w http.ResponseWriter, e *HTTPError) {
	var ve *ValidationError
	fields := map[string]string(nil)

	if e.Err != nil && errors.As(e.Err, &ve) {
		fields = ve.Fields
	}

	env := ErrorEnvelope{
		Error: APIError{
			Code:    e.Code,
			Message: e.Message,
			Fields:  fields,
		},
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(e.Status)
	_ = json.NewEncoder(w).Encode(env)
}

Обратите внимание на аккуратность: fields появляется только если это валидация. Для not found/internal и прочих ошибок fields будет отсутствовать (из-за omitempty), и это часть стабильного контракта.

6. Адаптер appHandler: один вход для всех handler’ов

Теперь собираем главное блюдо дня: адаптер, который превращает «нашу удобную функцию» в http.Handler.

Мы договоримся, что handler возвращает либо успешный Response, либо *HTTPError. Это даст нам единый путь: ServeHTTP либо вызывает writeError, либо writeJSON.

package main

import "net/http"

type Response struct {
	Status int
	Body   any
}

type appHandler func(r *http.Request) (Response, *HTTPError)

func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	resp, err := h(r)
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, resp.Status, resp.Body)
}

И вот тут происходит маленькая «магия Go» (на самом деле — честная механика): у типа функции есть метод, значит он реализует интерфейс http.Handler, значит его можно регистрировать в mux. Ровно этот стиль «тип функции + ServeHTTP» часто используют, чтобы вынести повторяющуюся обработку ошибок из handler’ов в одну точку.

Если вы сейчас думаете «а что, так можно было?», то да — так можно было. Go вообще довольно добрый в этом месте: не требует классов, не требует наследования, просто берём тип и даём ему метод.

Небольшая схема потока выглядит так:

flowchart LR
    A[ServeMux нашёл маршрут] --> B[appHandler.ServeHTTP]
    B --> C[наш handler возвращает Response или *HTTPError]
    C -->|успех| D[writeJSON]
    C -->|ошибка| E[writeError]

7. Мини-приложение: API задач с чистыми handler’ами

Теперь давайте соберём маленький сервер, который демонстрирует стиль. Мы не строим хранилище и не делаем настоящий CRUD (это отдельная тема), а просто делаем минимальные обработчики, чтобы увидеть, как выглядит код без копипасты.

Сначала определим модель и «заглушечные данные»:

package main

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

var tasks = []Task{
	{ID: 1, Title: "Learn Go", Done: false},
	{ID: 2, Title: "Drink tea", Done: true},
}

Теперь сделаем поиск (простенький, линейный — нам сейчас не про алгоритмы):

package main

func findTaskByID(id int) (Task, bool) {
	for _, t := range tasks {
		if t.ID == id {
			return t, true
		}
	}
	return Task{}, false
}

parseID: единый контракт без размазывания проверок

Мы используем тот же контракт parseID, который уже фиксировали: строка → int, id > 0, ошибки — через ValidationError.Fields.

package main

import "strconv"

func parseID(s string) (int, error) {
	if s == "" {
		return 0, &ValidationError{Fields: map[string]string{"id": "is required"}}
	}
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, &ValidationError{Fields: map[string]string{"id": "must be integer"}}
	}
	if id <= 0 {
		return 0, &ValidationError{Fields: map[string]string{"id": "must be positive"}}
	}
	return id, nil
}

Handler GET /tasks/{id}: линейный код без ручного «ответить ошибкой»

Теперь сам handler: он не знает, как устроен JSON‑ответ об ошибке. Он только возвращает *HTTPError с нужными полями.

package main

import (
	"fmt"
	"net/http"
)

func handleGetTask(r *http.Request) (Response, *HTTPError) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		return Response{}, &HTTPError{Status: 400, Code: "validation", Message: "invalid request", Err: err}
	}

	t, ok := findTaskByID(id)
	if !ok {
		return Response{}, &HTTPError{Status: 404, Code: "not_found", Message: "task not found", Err: fmt.Errorf("task id=%d", id)}
	}

	return Response{Status: http.StatusOK, Body: t}, nil
}

Обратите внимание на приятный эффект: handler читается как обычная функция. Ошибки не «размазывают» логику. Мы как будто пишем «сначала проверь, потом сделай».

И это очень по‑Go: мы не пытаемся спрятать ошибки, мы пытаемся не дублировать одинаковую реакцию на них. Эта мысль напрямую продолжает идею «ошибки — значения, ими можно программировать».

Handler GET /tasks: просто отдаём список

package main

import "net/http"

func handleListTasks(r *http.Request) (Response, *HTTPError) {
	return Response{Status: http.StatusOK, Body: tasks}, nil
}

Да, всё. Никаких Content-Type, никаких json.NewEncoder — всё это будет сделано общим слоем.

main: регистрируем маршруты через method-aware patterns

И наконец, подключаем всё к http.ServeMux. Здесь важно помнить, что mux.HandleFunc принимает «функцию без возвратов», а mux.Handle принимает http.Handler. Поэтому мы регистрируем через mux.Handle(..., appHandler(...)).

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /tasks", appHandler(handleListTasks))
	mux.Handle("GET /tasks/{id}", appHandler(handleGetTask))

	log.Fatal(http.ListenAndServe(":8080", mux))
}

Теперь, если клиент запросит /tasks/abc, он получит 400 и JSON вида:

{
  "error": {
    "code": "validation",
    "message": "invalid request",
    "fields": { "id": "must be integer" }
  }
}

А если /tasks/2, то получит 200 и JSON задачи.

Где живёт decode и почему важно не смешивать уровни

Слово «decode» в HTTP‑контексте иногда пугает новичков: кажется, что сейчас начнётся «декодируем JSON, валидируем DTO, маппим домен, пишем middleware…». На самом деле decode — это просто «вынуть входные данные из запроса и привести к нужным типам».

В нашем сегодняшнем минимуме decode — это две вещи: r.PathValue("id") (получить строку из пути) и parseID (привести строку к int и провалидировать). Важный момент: parseID не пишет HTTP‑ответ. Он возвращает error, и дальше handler решает, какой это класс ошибки (у нас — validation). Это позволяет сохранять границы: parseID — утилита, handler — HTTP‑логика, адаптер — форматирование ответа.

Ещё один тонкий, но полезный принцип: если ваш handler начал писать успешный ответ (например, w.WriteHeader(200)), а потом «вдруг» понял, что ошибка — вы уже опоздали. Именно поэтому стиль «handler возвращает Response/HTTPError, а пишет всегда адаптер» так дисциплинирует код: либо успех, либо ошибка, и решение централизовано.

8. Типичные ошибки при построении общего слоя handler’ов

Ошибка №1: часть handler’ов пишет ответ напрямую, а часть — через адаптер.
Это выглядит безобидно («ну тут я сам быстренько Encode сделаю»), но быстро превращается в мешанину форматов. В какой-то момент клиент API увидит два разных вида ошибок, а вы будете полдня искать «где же у нас тот handler, который не использует writeError». Лечится просто: выбираете один путь и придерживаетесь его хотя бы в рамках одного HTTP‑слоя.

Ошибка №2: возвращать наружу err.Error() на 500‑ошибках.
Соблазн понятный: «пусть клиент сам увидит, что сломалось». Но это почти всегда плохая идея: утечки внутренних деталей, нестабильные сообщения, и, в худшем случае, лишняя информация для атакующего. Правильный путь — фиксированное безопасное сообщение для 5xx, а внутренняя ошибка остаётся внутри (HTTPError.Err). В Go очень важно осознанно решать, что именно вы «экспортируете» наружу — как через ошибки, так и через HTTP‑ответы.

Ошибка №3: пытаться «впихнуть» всю валидацию в Error() строкой.
Если сделать ValidationError.Error() чем-то вроде "id must be integer", то UI‑клиенту придётся парсить строку (а парсинг строк — это всегда маленький театр абсурда). Гораздо надёжнее хранить детали структурно через Fields map[string]string, а строку Error() оставить маркером. Тогда адаптер легко достаёт данные через errors.As, что и было задумано в стандартном механизме типизированных ошибок.

Ошибка №4: писать заголовки и статус в нескольких местах.
Если часть кода ставит Content-Type, а часть — WriteHeader, очень легко получить «header already written»‑ситуацию или просто неконсистентные ответы. В вашем коде должен быть очевидный «единственный вход» в сериализацию. В нашем варианте это writeJSON и writeError, вызываемые только из ServeHTTP.

Ошибка №5: адаптер пытается быть слишком умным.
Есть опасный момент: увидев успех, хочется добавить туда «автоматическое логирование», «автоматическую метрику», «автоматический recover», «автоматическое чтение body», и вот у вас уже мини‑фреймворк, который понимаете только вы (и то по праздникам). Держите адаптер простым: его задача — стандартизировать ответы и убрать копипасту. Всё остальное должно появляться только тогда, когда у вас есть чёткая причина и ясная граница ответственности.

1
Задача
Go SELF, 60 уровень, 4 лекция
Недоступна
Пинг в JSON
Пинг в JSON
1
Задача
Go SELF, 60 уровень, 4 лекция
Недоступна
Адаптер ответа
Адаптер ответа
1
Задача
Go SELF, 60 уровень, 4 лекция
Недоступна
Заметки без дублей
Заметки без дублей
1
Опрос
HTTP роутинг, 60 уровень, 4 лекция
Недоступен
HTTP роутинг
HTTP роутинг
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ