Содержание урока · 12 разделов
- Дефолт, который пора заменить
- Кодек = декодер + энкодер: где живёт формат
- ServerErrorEncoder: проводим ошибку до клиента
- StatusCoder и Headerer: ошибки с амбициями
- Чего у готового энкодера нет: Failer
- Карта ответов и статусов stringsvc после урока
- Когда JSON не нужен: protobuf и свои форматы
- Пять правил кодеков
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Дефолт, который пора заменить#
Вспомни таблицу ошибок из урока 04: сломанный JSON попадает в DefaultErrorEncoder и возвращается клиенту со статусом 500 и телом text/plain. Разберём, почему это плохо в трёх проекциях. Первая — контракт API: потребитель не может отличить свою ошибку (битое тело) от нашей (упавший сервис), а значит, не может принять верное решение — повторить запрос или исправить payload. Вторая — мониторинг: если каждая ошибка декодирования — это 500, то график 500-х врёт; алерт на него будет будить дежурного из-за кривого скрипта заказчика, и однажды он проморгает настоящую аварию. Третья — утечка: текст внутренней ошибки, например от драйвера базы, уходит наружу как есть, в text/plain без единого правила форматирования. Кодеки go kit устроены так, что всё это чинится в одном месте: транспортный пакет даёт тип ErrorEncoder и опцию ServerErrorEncoder, а весь контракт ошибок API собирается в одну функцию с таблицей кодов. Это и есть главный урок темы: кодеки go kit — точка, где формат данных и статусы решаются централизованно, один раз на весь сервис.
Кодек = декодер + энкодер: где живёт формат#
Зафиксируем словарь. Кодек — это пара функций из kithttp.NewServer: DecodeRequestFunc превращает HTTP-запрос в request-структуру, EncodeResponseFunc — response-структуру в HTTP-ответ. Больше никто в проекте не имеет права трогать r.Body, заголовки и Content-Type — это правило мы проговорили в четвёртом уроке, а теперь оно становится рабочим инструментом. Хочешь отдавать клиенту protobuf вместо JSON? Меняешь пару функций, endpoint и сервис не двигаются — та самая транспорт-агностичность из урока 01. Хочешь единый конверт ошибок вида {"error": "..."}? Пишешь ErrorEncoder один раз — он сработает для всех эндпоинтов сервиса. Важно прочитать и обратную сторону: кодеки — не место для бизнес-логики. Декодер отвечает за транспортную корректность (тело распарсилось, обязательное поле есть, типы совпали), энкодер — за сериализацию и статусы. Проверка «сумма заказа больше нуля» живёт в сервисе из урока 03, и никакие кодеки её не подменяют. Проведи границу один раз и держи её: формат — кодекам, смысл — сервису.
Свой ErrorEncoder: одна функция вместо сюрпризов
GOimport (
"context"
"errors"
"encoding/json"
"net/http"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
)
// ErrorEncoder — func(ctx, err, w): ошибка целиком в твоих руках.
func encodeError(_ context.Context, err error, w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(codeFrom(err))
json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()})
}
func codeFrom(err error) int {
var sc interface{ StatusCode() int }
if errors.As(err, &sc) {
// Тип-носитель (badRequestError): статус едет с ошибкой.
return sc.StatusCode()
}
switch {
case errors.Is(err, ErrEmpty):
return http.StatusBadRequest // 400 — вина клиента
default:
return http.StatusInternalServerError // 500 — наша вина
}
}Тип ErrorEncoder из пакета kithttp — это func(ctx context.Context, err error, w http.ResponseWriter). Мы взяли encodeError и codeFrom из урока 04 и подняли их на уровень опции сервера: теперь одна и та же функция кодирует и бизнес-ошибки, и ошибки декодера. Обрати внимание на errors.As в codeFrom: сначала у ошибки спрашивают её собственный StatusCode — так таблица дружит с типами-носителями из следующего раздела. Конверт {"error": "..."} — единый для всего API; подключение — одной опцией, о ней ниже.
ServerErrorEncoder: проводим ошибку до клиента#
Посмотрим на путь ошибки в исходниках server.go пакета transport/http, потому что от него зависит всё остальное. Декодер вернул ошибку — Server вызывает errorHandler.Handle (это лог, по умолчанию NopLogger, настраивается опцией ServerErrorHandler) и сразу errorEncoder(ctx, err, w): endpoint не вызывается, клиент получает твой ответ. Endpoint вернул err — инфраструктурный сбой — и он идёт тем же маршрутом: лог, потом твой ErrorEncoder. Даже ошибка энкодера в актуальной версии идёт в errorHandler и затем в errorEncoder — но там, как мы говорили в уроке 04, заголовки обычно уже уехали, и честным последним словом остаётся лог. Вывод для практики: ErrorEncoder — это единственная функция, которой достаются все три класса ошибок, поэтому контракт ошибок API настраивается ровно в одном месте. Подключение — опция NewServer: kithttp.ServerErrorEncoder(encodeError). Опций у Server ровно шесть — ServerBefore, ServerAfter, ServerErrorEncoder, ServerErrorHandler, ServerErrorLogger (deprecated), ServerFinalizer — и это полный список ручек транспорта; всё остальное делается в твоих функциях.
Сломанный JSON становится честным 400
GO// Ошибка-носитель: сама знает свой статус и свой JSON.
type badRequestError struct{ err error }
func (e badRequestError) Error() string { return e.err.Error() }
func (e badRequestError) StatusCode() int { return http.StatusBadRequest }
func (e badRequestError) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{"error": e.err.Error()})
}
func decodeUppercaseRequest(_ context.Context, r *http.Request) (interface{}, error) {
var req UppercaseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Битое тело — вина клиента. Оборачиваем и отдаём наверх.
return nil, badRequestError{err}
}
return req, nil
}Канонический приём: декодер классифицирует ошибку, оборачивая её в тип-носитель. Дальше — фокус из исходников: DefaultErrorEncoder проверяет, не реализует ли err интерфейсы StatusCoder и json.Marshaler, и если да — берёт статус из StatusCode() и тело из MarshalJSON. То есть даже с дефолтным error encoder битый JSON теперь уедет с 400 и телом {"error":"..."} в application/json — без единой строчки в кодировщике.
StatusCoder и Headerer: ошибки с амбициями#
Только что мы использовали два интерфейса, о которых стоит сказать отдельно, потому что это официальная система расширения транспорта. StatusCoder — интерфейс StatusCode() int: если значение (ошибка или response) его реализует, кодер берёт статус из него, а не дефолтный. Headerer — интерфейс Headers() http.Header: реализовавшие его значения добавляют свои заголовки в ответ; для ошибки это работает после установки Content-Type. DefaultErrorEncoder идёт дальше и проверяет ещё json.Marshaler: ошибка, умеющая себя маршалить, уедет клиенту как application/json. Собираем в кучу: тебя есть три пути изобразить красивый ответ на ошибку — свой ErrorEncoder с таблицей codeFrom, тип-носитель с StatusCoder и MarshalJSON на дефолтном энкодере, или комбинация. Что выбрать? Для сервиса с одним API-контрактом чаще хватает первого: одна функция, которую читает новенький. Типы-носители выигрывают, когда ошибки порождаются в разных местах (декодер, адаптер, клиентская обёртка) и им удобнее нести свой статус с собой. Ничто не мешает смешать: sentinel-ошибки из урока 03 мапятся таблицей, а транспортные классы — типами-носителями.
kithttp.EncodeJSONResponse: готовый энкодер
GOmux := http.NewServeMux()
mux.Handle("/uppercase", kithttp.NewServer(
makeUppercaseEndpoint(svc),
decodeUppercaseRequest,
kithttp.EncodeJSONResponse, // готовый EncodeResponseFunc
kithttp.ServerErrorEncoder(encodeError),
))
// Response, который просит себе статус 201 Created:
type CreatedResponse struct {
ID string `json:"id"`
}
func (r CreatedResponse) StatusCode() int { return http.StatusCreated }В пакете kithttp есть готовый энкодер: EncodeJSONResponse выставляет Content-Type: application/json; charset=utf-8, применяет Headerer, берёт статус из StatusCoder (по умолчанию 200), вызывает WriteHeader и кодирует тело json.NewEncoder'ом. Если код 204, тело не пишется. Наш CreatedResponse со StatusCode() уедет как 201 — и это удобный способ давать честные статусы на создание ресурсов без ручного WriteHeader.
Чего у готового энкодера нет: Failer#
Прежде чем ты побежишь менять все свои NewServer на kithttp.EncodeJSONResponse, прочитай исходники внимательно: готовый энкодер не знает про Failer. Он не проверяет Failed() — для него ваш UppercaseResponse с заполненным Err-полем такой же успешный ответ, как и любой другой: уедет со статусом 200 и телом {"v":"","err":"empty string"}. Клиент получит 200 с ошибкой внутри — худшая из возможных комбинаций, потому что ретраи и алерты по статусам пропустят её мимо. Это не недоработка, а честное разделение ответственности: EncodeJSONResponse отвечает за сериализацию, а знание про Failed() — твоя политика. Отсюда канонический свой энкодер: тонкая обёртка, которая сначала смотрит на Failer, а счастливый путь делегирует готовой функции. Три строки, зато контракт ошибок единый и для endpoint'а, и для декодера, и для энкодера — весь API говорит на одном языке статусов.
Свой энкодер: Failer + готовая функция
GOfunc encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
if f, ok := response.(endpoint.Failer); ok && f.Failed() != nil {
encodeError(ctx, f.Failed(), w) // бизнес-ошибка → честный статус
return nil
}
return kithttp.EncodeJSONResponse(ctx, w, response) // счастливый путь
}Эволюция энкодера из урока 04: ручные Header().Set и json.NewEncoder заменены делегированием kithttp.EncodeJSONResponse, а ветка Failer осталась нашей. Теперь статус 204, заголовки и Content-Type — забота готовой функции; бизнес-ошибки — забота encodeError с codeFrom. Оба маршрута проверяются компилятором и пишутся один раз на сервис.
Карта ответов и статусов stringsvc после урока#
| Ситуация | Пример | Кто кодирует | Статус |
|---|---|---|---|
| Счастливый путь | POST /uppercase с валидным телом | kithttp.EncodeJSONResponse | 200 и JSON {"v":"HI"} |
| Сломанный JSON | тело not json | encodeError через badRequestError | 400 и {"error":"..."} |
| Бизнес-ошибка | ErrEmpty из сервиса | encodeResponse → encodeError | 400 и {"error":"empty string"} |
| Создание ресурса | response со StatusCode() 201 | kithttp.EncodeJSONResponse | 201 Created |
| Инфраструктурный сбой | err endpoint'а: база недоступна | encodeError → default ветка | 500 — честный сигнал |
| Ошибка энкодера | клиент отвалился на Encode | ServerErrorHandler → лог | обрубленный ответ |
Когда JSON не нужен: protobuf и свои форматы#
JSON — дефолт, но кодеки существуют именно для того, чтобы формат был решением, а не привычкой. XML: готового энкодера в kithttp нет — есть только EncodeJSONResponse, зато свой XML-энкодер пишется по образцу готового за пять минут: encoding/xml и xml.NewEncoder(w).Encode(response). protobuf: тело запроса — бинарные байты, декодер читает их через io.ReadAll и прогоняет через proto.Unmarshal в сгенерированную структуру, энкодер — proto.Marshal(response-сообщения) и Content-Type: application/proto; валидация статуса — всё те же StatusCoder и encodeError. Зачем это в HTTP-мире, если есть gRPC? Для пограничных сценариев: фронтенд на HTTP, а межсервисный трафик — на gRPC, при этом endpoint один (урок 13 покажет сборку gRPC-транспорта на тех же структурах). И общий совет по версиям: json-теги на request/response-структурах — это контракт API, меняй их так же осторожно, как публичные функции: переименование поля ломает клиентов молча и безвозвратно. Если контракт эволюционирует — добавляй поля с omitempty, а не переименовывай старые.
Пять правил кодеков#
- Формат живёт только в кодеках: r.Body, заголовки и Content-Type не трогают ни сервис, ни endpoint, ни адаптер.
- Один ErrorEncoder на сервис: контракт ошибок API описан одной функцией с таблицей codeFrom.
- Декодер классифицирует транспортные ошибки: типы-носители со StatusCoder решают статус на месте.
- Свой энкодер проверяет Failer до делегирования готовой функции — иначе бизнес-ошибки уезжают с 200.
- Бизнес-валидация не в декодере: формат проверяет кодек, смысл проверяет сервис из урока 03.
Соблазн вырастить у каждого эндпоинта свой формат ошибки велик: «а здесь удобнее вернуть код строкой». Не поддавайся: потребитель пишет один обработчик ошибок на весь API, и в нём должна быть одна форма. Все различия — в коде статуса и тексте, не в структуре. Если появились заголовки вроде Retry-After — это ветка Headerer, а не повод менять конверт.
Урок требует установленного Go и модуля github.com/go-kit/kit — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении и понимании кода. Локально собери сервис из урока 04, добавь ServerErrorEncoder и прогони curl'ом POST /uppercase с телом not json: вместо 500 text/plain теперь должен прийти честный 400 с JSON-конвертом.
POST /uppercase с телом not json, декодер оборачивает ошибку в badRequestError{StatusCode() 400, json.Marshaler}. Какой ответ получит клиент?
Response-структура реализует только StatusCoder с StatusCode() 201. Что сделает kithttp.EncodeJSONResponse?
Почему свой encodeResponse обязан проверять Failer до вызова kithttp.EncodeJSONResponse?
Ошибка, которая умеет в JSON: что выберет DefaultErrorEncoder?
Прочитайте код и выберите, что он выведет:
type quotaError struct{ Limit int }
func (quotaError) Error() string { return "quota exceeded" }
func (quotaError) StatusCode() int { return http.StatusTooManyRequests }
func (e quotaError) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]int{"limit": e.Limit})
}
// Endpoint вернул err = quotaError{Limit: 10}
// ServerErrorEncoder НЕ настроенnil на входе готового энкодера
Прочитайте код и выберите, что он выведет:
// Endpoint вернул response = nil, err = nil
// (NopRequestDecoder + endpoint без логики)
mux.Handle("/healthz", kithttp.NewServer(
healthEndpoint,
kithttp.NopRequestDecoder,
kithttp.EncodeJSONResponse,
))Подключи свой ErrorEncoder и добей энкодер
Собери монтаж эндпоинта и свой энкодер. Пропуски — по порядку: опция error encoder'а, метод статуса, метод сериализации.
Путь сломанного JSON после урока
Расставь этапы обработки POST /uppercase с телом not json в правильном порядке: от приёма соединения до ответа клиенту.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Контракт ошибок собран: сломанный JSON — честный 400, бизнес-ошибки — по таблице codeFrom, счастливый путь — делегирован готовому энкодеру. Весь go kit json теперь описан двумя функциями и горсткой интерфейсов. Вернуться к тому, откуда пришёл Failer, можно в уроке 02-endpoint, к sentinel-ошибкам сервиса — в уроке 03-service, а полный разбор транспорта — в уроке 04-http-transport; карта курса — на странице курса. Следующий шаг — урок 06-logirovanie: kitlog, структурные пары ключ-значение, уровни и endpoint-middleware логирования, который будет писать в лог каждый вызов каждого endpoint'а — без единой строки в сервисе.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Кодеки go kit — пара функций из NewServer: декодер читает транспорт, энкодер пишет ответ; формат живёт только здесь.
- Ошибка декодера/endpoint'а/энкодера идёт в ErrorEncoder; свой подключается опцией kithttp.ServerErrorEncoder(encodeError).
- DefaultErrorEncoder читает интерфейсы ошибки: json.Marshaler даёт application/json, StatusCoder — статус, Headerer — заголовки.
- Тип-носитель badRequestError превращает сломанный JSON из дефолтного 500 text/plain в честный 400 с JSON-конвертом.
- kithttp.EncodeJSONResponse — готовый счастливый путь: Content-Type, StatusCoder, 204 без тела; про Failer он не знает.
- Свой encodeResponse = проверка Failed() + делегирование EncodeJSONResponse: один контракт ошибок на весь API.
- Другой формат — другой кодек: XML пишется руками, protobuf — proto.Unmarshal/Marshal; endpoint и сервис не меняются.
Домашнее задание
Возьми stringsvc из домашки урока 04 и доведи кодеки до боевых. Первое: подключи kithttp.ServerErrorEncoder(encodeError) к обоим NewServer и проверь curl'ом, что POST /uppercase с телом not json отдаёт 400 и конверт {"error":"..."} в application/json — а не дефолтный 500 text/plain. Второе: оберни ошибку декодера в тип-носитель со StatusCode() и MarshalJSON и убедись, что без настроенного ErrorEncoder статус всё равно честный — это запасная прочность контракта. Третье: перепиши encodeResponse на ветку Failer плюс делегирование kithttp.EncodeJSONResponse и добейся, чтобы ErrEmpty уходил как 400, а счастье — как 200. Четвёртое: добавь в codeFrom вторую sentinel-ошибку своего сервиса из урока 03 и зафиксируй изменение статуса. Пятое, со звёздочкой: напиши декодер и энкодер для protobuf-сообщения по своему proto-файлу и проверь, что endpoint не потребовал ни одной правки.
Личные заметкиПусто
Вопросы и ответы по уроку
Свой ErrorEncoder или типы-носители со StatusCoder — что выбрать?
Начни с одного ErrorEncoder с таблицей codeFrom: он даёт единый конверт ошибок и читается как документация API. Типы-носители добавляй, когда ошибки рождаются в разных местах и каждой удобнее нести свой статус и тело с собой; оба подхода комбинируются без конфликтов.
Почему DefaultErrorEncoder отдаёт 500 на сломанный JSON?
Потому что транспорт не может знать, чья вина в ошибке декодирования, и выбирает консервативный дефолт. Классификация — твоя работа: оборачивай ошибки декодера в тип со StatusCode() 400 или мапь их в codeFrom, и статус станет честным без изменения сигнатур.
Нужен ли protobuf в HTTP-сервисе?
Когда на стыке скорость и строгая схема важнее читаемости, а gRPC по каким-то причинам не вариант: публичный шлюз остаётся на JSON, внутренний трафик — на protobuf через свои кодеки. Endpoint один и тот же — меняется только пара функций, поэтому эксперимент стоит три функции, а не рефакторинг.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации