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

Мінімізація дублювання: HTTP‑обробники

Go SELF
Рівень 60 , Лекція 4
Відкрита

1. Чому дублювання в обробниках — це «тихе» джерело багів

Коли ви тільки починаєте писати HTTP‑сервер, обробник виглядає невинно: кілька рядків, пара перевірок, w.WriteHeader(...) — і готово. Але щойно з’являється другий обробник, потім третій, потім «ще трохи валідації», потім «а давайте всюди JSON», ви раптом помічаєте, що половина проєкту — це повторювані блоки: виставити Content-Type, вибрати статус, сформувати JSON помилки, не забути return, не показати назовні err.Error() на 500 і так далі.

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

У Go такі речі традиційно розв’язують простим і чесним способом: помилки — це значення, з ними можна програмувати, а не лише друкувати їх як рядок. Цю ідею добре сформульовано в класичному тексті «Errors are values». І якщо помилки — це значення, то ми можемо винести повторювану обробку помилок в одне місце замість того, щоб повторювати її всюди.

Головна ідея: обробник повертає результат і помилку

Щоб позбутися дублювання, потрібно розділити відповідальність. Обробник має займатися сенсом: «прочитав вхідні дані → перевірив → порахував → повернув результат». А HTTP‑обгортка має займатися ритуалом: «якщо є помилка — перетвори її на JSON, вистав статус і заголовки; якщо все добре — віддай JSON результату».

Це не нова магія і не «патерн із модного фреймворку». У Go це базова практика: створити свій тип функції, дати йому метод ServeHTTP і тим самим вбудувати його в net/http. У класичній, хоч і давній, статті про обробку помилок у Go прямо показано, як тип appHandler допомагає винести повторювану обробку помилок з окремих обробників в один спільний 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. Центральна точка серіалізації: writeJSON

Будь-який серйозний API рано чи пізно впирається в просте правило: відповіді мають бути однаковими. І це стосується не лише помилок, а й успішних відповідей.

Якщо кожен обробник сам викликає 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 для сценарію «знайди в ланцюжку помилок значення потрібного типу й запиши його в змінну», який з’явився разом із загальним підходом до розгортання помилок.

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: єдиний вхід для всіх обробників

Тепер збираємо головну страву дня: адаптер, який перетворює нашу зручну функцію на http.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» часто використовують, щоб винести повторювану обробку помилок з обробників в одне місце.

Якщо ви зараз думаєте: «А що, так можна було?», то так — можна. Go взагалі дуже добре поводиться в цьому місці: не вимагає класів, не вимагає наслідування, просто беремо тип і даємо йому метод.

Невелика схема потоку виглядає так:

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

7. Мінізастосунок: API задач із чистими обробниками

Тепер давайте зберемо маленький сервер, який демонструє цей стиль. Ми не будуємо сховище і не робимо справжній CRUD (це окрема тема), а просто створюємо мінімальні обробники, щоб побачити, як виглядає код без дублювання.

Спочатку визначимо модель і тестові дані:

package main

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

var tasks = []Task{
	{ID: 1, Title: "Вивчити Go", Done: false},
	{ID: 2, Title: "Випити чаю", 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": "обовʼязкове поле"}}
	}
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, &ValidationError{Fields: map[string]string{"id": "має бути цілим числом"}}
	}
	if id <= 0 {
		return 0, &ValidationError{Fields: map[string]string{"id": "має бути додатним"}}
	}
	return id, nil
}

Обробник GET /tasks/{id}: лінійний код без ручного «повернути помилку»

Тепер сам обробник: він не знає, як улаштована 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: "некоректний запит", 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
}

Зверніть увагу на приємний ефект: обробник читається як звичайна функція. Помилки не розмазують логіку. Наче пишемо: «спочатку перевір — потім зроби».

І це дуже по-Go: ми не намагаємося сховати помилки, ми намагаємося не дублювати однакову реакцію на них. Ця думка напряму продовжує ідею «помилки — це значення, ними можна програмувати».

Обробник 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: реєструємо маршрути з урахуванням методу

І нарешті підключаємо все до 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": "некоректний запит",
    "fields": { "id": "має бути цілим числом" }
  }
}

А якщо /tasks/2, то отримає 200 і JSON задачі.

Де живе decode і чому важливо не змішувати рівні

Слово «decode» в HTTP‑контексті інколи лякає новачків: здається, що зараз почнеться «декодуємо JSON, перевіряємо DTO, мапимо домен, пишемо middleware…». Насправді decode — це просто «витягти вхідні дані із запиту й привести їх до потрібних типів».

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

Ще один тонкий, але корисний принцип: якщо ваш обробник уже почав писати успішну відповідь (наприклад, w.WriteHeader(200)), а потім «раптом» зрозумів, що сталася помилка, ви вже запізнилися. Саме тому стиль «обробник повертає Response/HTTPError, а пише завжди адаптер» так дисциплінує код: або успіх, або помилка, і рішення централізоване.

8. Типові помилки під час побудови спільного шару обробників

Помилка № 1: частина обробників пише відповідь напряму, а частина — через адаптер.
Це виглядає невинно («ну тут я сам швиденько Encode зроблю»), але швидко перетворюється на мішанину форматів. У якийсь момент клієнт API побачить два різні види помилок, а ви пів дня шукатимете «де ж у нас той обробник, який не використовує 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
Опитування
HTTP-роутинг, рівень 60, лекція 4
Недоступний
HTTP-роутинг
HTTP-роутинг
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ