Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 5 из 25 ~18 мин +50 XP

Кодеки: JSON encoding/decoding

Транспорт из четвёртого урока работает, но дефолты ещё колются: сломанный JSON уезжает с 500 в text/plain, а кодирование ошибок умещается в один case. Кодеки go kit — это декодер и энкодер из NewServer, единственные функции, которым разрешено знать формат. Ты подключишь свой ErrorEncoder через опцию ServerErrorEncoder, разберёшься со StatusCoder и Headerer, попробуешь готовый kithttp EncodeJSONResponse и посмотришь, как меняется кодек при переходе на protobuf. После урока go kit json станет предсказуемым контрактом, а не набором сюрпризов.

Содержание урока · 12 разделов

Дефолт, который пора заменить#

Вспомни таблицу ошибок из урока 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: одна функция вместо сюрпризов

GO
import (
        "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: готовый энкодер

GO
mux := 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 + готовая функция

GO
func 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.EncodeJSONResponse200 и JSON {"v":"HI"}
Сломанный JSONтело not jsonencodeError через badRequestError400 и {"error":"..."}
Бизнес-ошибкаErrEmpty из сервисаencodeResponse → encodeError400 и {"error":"empty string"}
Создание ресурсаresponse со StatusCode() 201kithttp.EncodeJSONResponse201 Created
Инфраструктурный сбойerr endpoint'а: база недоступнаencodeError → default ветка500 — честный сигнал
Ошибка энкодераклиент отвалился на EncodeServerErrorHandler → логобрубленный ответ

Когда 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, а не переименовывай старые.

Пять правил кодеков#

  1. Формат живёт только в кодеках: r.Body, заголовки и Content-Type не трогают ни сервис, ни endpoint, ни адаптер.
  2. Один ErrorEncoder на сервис: контракт ошибок API описан одной функцией с таблицей codeFrom.
  3. Декодер классифицирует транспортные ошибки: типы-носители со StatusCoder решают статус на месте.
  4. Свой энкодер проверяет Failer до делегирования готовой функции — иначе бизнес-ошибки уезжают с 200.
  5. Бизнес-валидация не в декодере: формат проверяет кодек, смысл проверяет сервис из урока 03.

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'а, метод статуса, метод сериализации.

mux.Handle("/uppercase", kithttp.NewServer( makeUppercaseEndpoint(svc), decodeUppercaseRequest, encodeResponse, kithttp.(encodeError), )) func encodeError(_ context.Context, err error, w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.(codeFrom(err)) json.NewEncoder(w).(map[string]interface{}{"error": err.Error()}) }

Путь сломанного JSON после урока

Расставь этапы обработки POST /uppercase с телом not json в правильном порядке: от приёма соединения до ответа клиенту.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Термины урока

Термин

ErrorEncoder

нажмите, чтобы перевернуть
Определение

func(ctx, err, w) из kithttp: единственная функция, кодирующая все классы ошибок — декодера, endpoint'а, энкодера — в HTTP-ответ.

1 / 6

Что дальше#

Контракт ошибок собран: сломанный 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 не потребовал ни одной правки.

Личные заметкиПусто
Хранятся только в вашем браузере (go-kit · Кодеки: JSON encoding/decoding)

Вопросы и ответы по уроку

Свой ErrorEncoder или типы-носители со StatusCoder — что выбрать?

Начни с одного ErrorEncoder с таблицей codeFrom: он даёт единый конверт ошибок и читается как документация API. Типы-носители добавляй, когда ошибки рождаются в разных местах и каждой удобнее нести свой статус и тело с собой; оба подхода комбинируются без конфликтов.

Почему DefaultErrorEncoder отдаёт 500 на сломанный JSON?

Потому что транспорт не может знать, чья вина в ошибке декодирования, и выбирает консервативный дефолт. Классификация — твоя работа: оборачивай ошибки декодера в тип со StatusCode() 400 или мапь их в codeFrom, и статус станет честным без изменения сигнатур.

Нужен ли protobuf в HTTP-сервисе?

Когда на стыке скорость и строгая схема важнее читаемости, а gRPC по каким-то причинам не вариант: публичный шлюз остаётся на JSON, внутренний трафик — на protobuf через свои кодеки. Endpoint один и тот же — меняется только пара функций, поэтому эксперимент стоит три функции, а не рефакторинг.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

gRPC25 уроков~14 чпродвинутый25 уроков уже доступны

gRPC-Go: высокопроизводительный RPC

Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.

  • Писать .proto-схемы и генерировать код
  • Реализовывать unary и streaming сервисы
  • Управлять метаданными, дедлайнами и отменами
  • Строить interceptors для логов и аутентификации