JavaRush /Курси /Go SELF /Хелпери writeJSON і writeError: єдиний шлях JSON-відповід...

Хелпери writeJSON і writeError: єдиний шлях JSON-відповіді

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

1. Навіщо потрібні хелпери відповідей

Коли ви пишете перший HTTP‑сервер, здається, що відповідати просто: «ну я ж можу зробити json.NewEncoder(w).Encode(v) — і все». І так, це працює… рівно до того моменту, поки ви не зіткнетеся з реальними помилками й людьми, які намагаються користуватися вашим API. В API з’являється контракт: заголовки, статус-коди, формат помилок, стабільність відповіді та передбачуваність для клієнта.

Уявіть, що ваш клієнт — CLI, фронтенд або інший сервіс — уміє обробляти помилки лише тоді, коли вони приходять у форматі:

{ "error": { "code": "...", "message": "...", "fields": { ... } } }

А ви випадково в одному обробнику викликали http.Error(w, err.Error(), 500). Клієнт раптово отримує текст замість JSON, парсер падає, і користувачеві від цього не легше. І найгірше — це не «одна помилка», а просто відсутність дисципліни: кожен handler пише так, як йому зручно.

Є й підступніша технічна проблема: HTTP‑відповідь не можна «відкотити». Щойно ви почали писати тіло або виставили статус, ви вже не зможете раптом вирішити: «ой, усе-таки помилка» — і надіслати інший JSON. Тому хелпери важливі не лише заради краси, а й для того, щоб зменшити ризик частково записаної, зіпсованої відповіді.

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

flowchart TD
    R[HTTP‑запит] --> M[ServeMux обирає обробник]
    M --> H[Наш обробник]
    H -->|успіх| WJ[writeJSON]
    H -->|помилка| EH[повертаємо HTTPError]
    EH --> A[адаптер appHandler.ServeHTTP]
    A --> WE[writeError -> writeJSON]
    WJ --> RESP[HTTP‑відповідь]
    WE --> RESP

2. Єдина JSON‑відповідь і єдині помилки

writeJSON: єдиний спосіб писати JSON‑відповідь

Коли ви вводите writeJSON, ви ніби кажете собі: «Відтепер у проєкті є нормальний, єдиний спосіб повертати JSON». Це трохи схоже на домовленість у команді: ми не сперечаємося щоразу, чи треба ставити Content-Type, чи треба додавати \n, чи треба кодувати в буфер. Ми просто викликаємо одну функцію — і всі відповіді однакові.

Головні обов’язки writeJSON прості: виставити коректний Content-Type, поставити статус-код, серіалізувати значення в JSON і записати його в ResponseWriter. Тонкість починається тоді, коли серіалізація може впасти: якщо ви вже почали писати в w, а потім Encode зламався, ви не зможете красиво надіслати клієнту помилку, бо заголовки вже пішли. Тому хороший навчально‑практичний прийом — спочатку кодувати в bytes.Buffer, а вже потім писати в w.

Ось мінімальний варіант writeJSON:

package main

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

func writeJSON(w http.ResponseWriter, status int, v any) error {
	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(v); err != nil {
		return err
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_, _ = w.Write(buf.Bytes())
	return nil
}

Зверніть увагу на дві «нудні» деталі, які на практиці рятують нерви. По‑перше, заголовок Content-Type виставлено до WriteHeader, бо після запису статусу змінювати заголовки вже пізно. По‑друге, ми повертаємо error, бо кодування JSON — це операція, яка може зламатися (наприклад, якщо всередині v опиниться непідтримуване значення).

Тепер подивімося, як обробник виглядає з цим хелпером. Важливо: ми не змішуємо формування відповіді та ручну роботу із заголовками — нехай це робить writeJSON.

package main

import "net/http"

func handleHealth(w http.ResponseWriter, r *http.Request) *HTTPError {
	resp := map[string]any{"ok": true}
	if err := writeJSON(w, http.StatusOK, resp); err != nil {
		return internalError(err)
	}
	return nil
}

Так, поки що ми посилаємося на HTTPError і internalError, яких ще не визначили. Це нормально: ми збираємо систему, як конструктор — по деталях. Наразі головне ось що: успішна JSON‑відповідь завжди проходить через writeJSON.

writeError: єдиний формат помилок API

Помилки в API — це не «ой, щось пішло не так», а окремий продукт. Клієнт має вміти відрізняти помилку валідації від внутрішньої помилки, має розуміти, яке поле неправильне, і має мати машинний код помилки, щоб писати умовні if code == "validation" { ... }. Саме тому ми фіксуємо єдиний формат — обгортку помилки (error envelope).

Структури для обгортки зручно описати як DTO:

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"`
}

Поле fields позначено omitempty: якщо помилок по полях немає, воно не повинно бовтатися порожнім об’єктом — це дрібниця, але саме такі дрібниці роблять API приємнішим.

Тепер пишемо writeError. Ідея проста: writeError не займається HTTP‑магією напряму — він викликає writeJSON, передаючи envelope.

package main

import "net/http"

func writeError(w http.ResponseWriter, status int, code, msg string, fields map[string]string) {
	_ = writeJSON(w, status, ErrorEnvelope{
		Error: APIError{
			Code:    code,
			Message: msg,
			Fields:  fields,
		},
	})
}

Тут спеціально стоїть _ = writeJSON(...): коли ми вже вирішили «пишемо помилку», можлива помилка серіалізації — це вже зовсім аварійна ситуація, і вдруге «відповісти помилкою на помилку» ми не зможемо. За потреби пізніше ви можете додати логування цього випадку — обережно, без ускладнень.

Тепер обробник може повертати структуровану помилку валідації як значення, а відповідь формуватиме окремий шар:

package main

import "net/http"

func badRequest(fields map[string]string) *HTTPError {
	return &HTTPError{
		Status:  http.StatusBadRequest,
		Code:    "validation",
		Message: "invalid request",
		Fields:  fields,
	}
}

І далі все однаково: будь-яка 400‑помилка в нас повертатиметься як envelope. Клієнт задоволений: він завжди знає, де шукати error.code, error.message і error.fields.

Правило безпеки: не віддаємо err.Error() на 500

Дуже хочеться, особливо в навчальних прикладах, зробити так:

http.Error(w, err.Error(), 500)

Це виглядає чесно: «ну ось же помилка, чого приховувати». Але в реальному сервері це майже завжди помилка дизайну.

Чому? Тому що err.Error() часто містить внутрішні деталі: назви таблиць, шляхи до файлів, адреси серверів, шматки JSON, внутрішні ідентифікатори, а інколи навіть фрагменти секретів, якщо хтось необережно сформував помилку. Плюс це нестабільно: ви зміните бібліотеку — текст помилки зміниться, і клієнтські тести чи скрипти почнуть ламатися.

Є важлива думка з практики Go: коли ви додаєте контекст до помилки або вирішуєте, чи обгортати помилку, ви фактично приймаєте API-рішення про те, які деталі готові розкривати стороні, що викликає. Цю думку часто формулюють так: «wrap робить underlying error доступною для inspection стороні, що викликає; не wrap, якщо це розкриє деталі реалізації».

Для HTTP‑API думка ще жорсткіша: клієнту майже ніколи не потрібна «сира внутрішня причина». Клієнту потрібна зрозуміла реакція — "internal error", а деталі — вам, у логи.

Звідси правило лекції: для помилок 500‑класу в JSON‑відповіді завжди фіксоване message, наприклад "internal error". Справжній err зберігаємо всередині — у структурі помилки — і за потреби логуємо.

HTTPError: що віддаємо клієнту і що лишаємо собі

Щоб правило «не віддаємо err.Error() назовні» виконувалося автоматично, нам потрібен тип помилки, який зберігає і публічні поля для відповіді, і внутрішню причину для логів чи діагностики. Саме ним і буде HTTPError.

package main

type HTTPError struct {
	Status  int
	Code    string
	Message string
	Fields  map[string]string

	Err error // внутрішня причина (НЕ віддаємо клієнту)
}

Виглядає майже надто просто. Але саме так і треба: структура помилок має бути нудною, як бухгалтерія. Креатив залишимо для назв змінних.

Тепер зробімо конструктор для внутрішніх помилок. Він фіксує контракт: статус 500, код "internal", повідомлення "internal error", а внутрішня причина зберігається в Err.

package main

import "net/http"

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

3. Централізація обробки помилок: appHandler

Якщо ви продовжите писати обробники напряму, майже в кожному повторюватиметься одне й те саме: перевірка вхідних даних, if err != nil, формування error envelope, return. Це не кінець світу, але мозок швидко втомлюється від однакових гілок, і в якийсь момент в одному обробнику ви випадково повернете помилку не тим форматом.

Класична ідея в Go — зробити адаптер: обробник повертає помилку як значення, а адаптер реалізує ServeHTTP і централізує реакцію на неї. У нашому варіанті обробник повертає *HTTPError (бо це саме помилка HTTP‑шару зі статусом, кодом і полями), а адаптер пише відповідь через writeError.

package main

import (
	"log"
	"net/http"
)

type appHandler func(http.ResponseWriter, *http.Request) *HTTPError

func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if e := h(w, r); e != nil {
		if e.Err != nil {
			log.Printf("internal error: %v", e.Err) // деталі залишаються в нас
		}
		writeError(w, e.Status, e.Code, e.Message, e.Fields)
	}
}

Зверніть увагу на приємну річ: appHandler — це тип функції, і в нього є метод ServeHTTP. Так, у функції може бути метод. Так, Go інколи так уміє — і це один із тих моментів, коли мова ніби каже: «Я не забороняю вам робити зручно, я просто прошу не робити дивно».

4. Збірка міні‑API: маршрути та єдині відповіді

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

Збірка mux і запуск сервера:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.Handle("/health", appHandler(handleHealth))
	mux.Handle("/ping", appHandler(handlePing))

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

/health ми вже бачили: він відповідає JSON‑об’єктом { "ok": true }. Зробімо /ping, щоб показати і успішну відповідь, і помилку валідації залежно від методу.

package main

import "net/http"

func handlePing(w http.ResponseWriter, r *http.Request) *HTTPError {
	if r.Method != http.MethodGet {
		return badRequest(map[string]string{
			"method": "must be GET",
		})
	}

	if err := writeJSON(w, http.StatusOK, map[string]string{"pong": "pong"}); err != nil {
		return internalError(err)
	}
	return nil
}

Зверніть увагу, як читається обробник: спочатку guard clause (рання перевірка методу), потім успішний шлях, потім return nil. Немає змішування WriteHeader, json.Encode, http.Error і випадкових рядків. Саме це й є мета: зробити обробник лінійним.

5. Нюанс: writeJSON теж може повернути помилку

Є момент, який часто дивує новачків: «А що може зламатися під час кодування відповіді?». Здається, якщо ви формуєте структуру самі, то вона точно закодується. Але в Go можна випадково передати в any те, що JSON не вміє кодувати (наприклад, канал, функцію, циклічну структуру), і тоді Encode поверне помилку.

Тому writeJSON повертає error, і в обробнику ми робимо:

if err := writeJSON(...); err != nil {
	return internalError(err)
}

Це виглядає як зайва перевірка, але вона варта того: ви уникаєте ситуації «половина відповіді записалася, а потім усе впало». Плюс, навіть якщо помилка кодування сталася, назовні ви все одно не віддаєте err.Error(), а повертаєте стабільне повідомлення "internal error".

З погляду дизайну помилок це узгоджується із загальною ідеєю Go: помилки — це значення, їх можна збагачувати контекстом, але розкривати нутрощі назовні треба свідомо.

6. Типові помилки при writeJSON/writeError і appHandler

Помилка №1: писати частину відповіді, а потім намагатися «повернути помилку».
Зазвичай це виглядає так: ви спочатку зробили w.WriteHeader(200), потім почали писати JSON, потім щось упало — і ви намагаєтеся викликати writeError. Але HTTP — не відеомонтаж: «перемотати назад» не можна. Тому ми кодуємо в буфер до запису заголовків, а в обробниках тримаємо правило: або ми успішно відповіли, або повернули *HTTPError до запису тіла.

Помилка №2: віддавати клієнту err.Error() на 500‑класі.
Це класика, і вона часто з’являється «тимчасово, для налагодження», а потім тимчасове стає вічним. Правильний підхід: публічне повідомлення фіксоване ("internal error"), внутрішня причина зберігається в HTTPError.Err і йде в логи. Це не параноя, а контракт і безпека, бо деталі реалізації не повинні ставати частиною зовнішнього API.

Помилка №3: змішувати формати помилок.
Якщо один обробник відповідає JSON‑помилкою, а інший робить http.Error, клієнту доводиться писати два парсери або вгадувати за Content-Type. Такий API здається «живим» лише розробнику сервера, а клієнту він здається непередбачуваним. Виправляється просто: усі помилки проходять через writeError, а writeError завжди пише envelope.

Помилка №4: робити fields завжди порожнім об’єктом.
Якщо fields з’являється завжди, клієнт починає думати, що там завжди є зміст, і пише зайву логіку. Краще тримати контракт акуратним: fields є лише тоді, коли це справді помилки по полях, тому omitempty і nil — ваші друзі.

Помилка №5: намагатися «логувати все» всередині кожного обробника.
Новачок часто додає log.Println у кожен обробник, і за тиждень логи перетворюються на шум. Набагато чистіше, коли у вас є одне місце — адаптер ServeHTTP, — де ви логуєте внутрішню причину e.Err. Такий підхід якраз і цінний тим, що зменшує копіпасту та робить поведінку однаковою.

1
Опитування
HTTP server, рівень 59, лекція 4
Недоступний
HTTP server
HTTP server
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ