Содержание урока · 13 разделов
- Транспортный слой: три функции вместо фреймворка
- Как работает NewServer изнутри
- Декодер: единственный, кто трогает тело
- Энкодер: сериализация и Content-Type
- Ошибки транспорта: три пути к клиенту
- Куда уходит ошибка: путь к статусу
- Роутер остаётся твоим
- ServerBefore и ServerAfter: хуки контекста
- Чеклист транспортного слоя
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Транспортный слой: три функции вместо фреймворка#
Вспомни луковицу из первого урока: транспорт — внешний слой, привязанный к протоколу. В пакете transport/http (в коде его обычно импортируют с алиасом kithttp) транспорт go kit сводится к трем сигнатурам. NewServer(endpoint, decode, encode, options...) принимает endpoint и две функции, возвращая Server — обычный http.Handler. DecodeRequestFunc — func(context.Context, http.Request) (interface{}, error): декодер превращает HTTP-запрос в request-структуру, которую ждёт адаптер. EncodeResponseFunc — func(context.Context, http.ResponseWriter, interface{}) error: энкодер сериализует response-структуру в ответ. Всё. Ни роутера, ни миддлварь-цепочки, ни магии привязок: транспорт — это три функции, и ровно они единственные в проекте знают про тело запроса и заголовки. Такой размен — многословность за явность — и есть фирменный стиль go kit http: замени gRPC — поменяются три функции, сервис и endpoint'ы останутся нетронутыми.
Монтируем stringsvc: NewServer на стандартном mux
GOimport (
"log"
"net/http"
"os"
"github.com/go-kit/log" // kitlog
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
)
func main() {
logger := kitlog.NewJSONLogger(os.Stdout)
var svc StringService = stringService{}
upper := makeUppercaseEndpoint(svc)
count := makeCountEndpoint(svc)
mux := http.NewServeMux()
mux.Handle("/uppercase", kithttp.NewServer(
upper,
decodeUppercaseRequest, // *http.Request -> UppercaseRequest
encodeResponse, // response -> JSON
))
mux.Handle("/count", kithttp.NewServer(
count,
decodeCountRequest,
encodeResponse,
))
logger.Log("msg", "listening", "addr", ":8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Каждый метод сервиса — свой NewServer со своим декодером, энкодер общий. kithttp.NewServer возвращает http.Handler, поэтому роутер остаётся обычным: стандартный mux, gorilla, chi — что нравится. Метод по умолчанию — POST, это учтём в curl-проверке; ограничение методов — забота роутера, не go-kit.
Как работает NewServer изнутри#
Заглянем в server.go пакета transport/http — там меньше сотни строк, и полезно понимать путь запроса. ServeHTTP берёт r.Context(), прогоняет через ServerBefore-функции (о них ниже), затем вызывает декодер: получил request — передал его в endpoint вместе с контекстом; ошибка декодера — сразу в error encoder, endpoint не зовётся. Дальше ответ попадает в энкодер, а его ошибка уходит в ServerErrorHandler — обычно в логгер, потому что заголовки уже уехали и клиенту сказать нечего. Четыре серверных опции дополняют картину: ServerBefore добавляет в контекст значения из запроса — путь, заголовки, auth; ServerAfter срабатывает после ответа — туда лягут метрики и трейсинг в уроках 07–08; ServerErrorEncoder подменяет то, как ошибка превращается в HTTP-ответ; ServerErrorHandler ловит ошибки энкодера. Правило чтения простое: NewServer — это конвейер декодер → endpoint → энкодер с четырьмя хуками по краям, и все хуки транспортные, к домену отношения не имеют.
Декодер: единственный, кто трогает тело#
Декодер получает *http.Request и возвращает request-структуру — вот и весь контракт. Что здесь легально: json.NewDecoder(r.Body).Decode(&req) для POST-тел, r.URL.Query() для GET-параметров, переменные маршрута, если роутер их умеет, заголовки — например, Authorization, который потом уйдёт в контекст. Три практики, которые сэкономят нервы. Первая: тело читается один раз — декодер обязан, а не «может» прочитать r.Body до конца, иначе keep-alive-соединения начнут гнить; json.NewDecoder делает это сама. Вторая: возвращай примитивные структуры ровно того типа, что ждёт адаптер из урока 02 — любой mismatch выстрелит паникой assertion, и это будет твоя вина, не go-kit. Третья: ошибки декодера — это транспортный сбой, они идут по error-каналу и по умолчанию превращаются в 500; кастомизируем это на следующем уроке кодеков. Для GET-эндпоинтов тело пустое — декодер просто собирает структуру из query, и это нормально: декодер — не ритуал JSON, а переводчик HTTP в домен.
Декодеры для Uppercase и Count
GOimport (
"context"
"encoding/json"
"net/http"
)
// POST /uppercase {"s":"hello"} — читаем тело.
func decodeUppercaseRequest(_ context.Context, r *http.Request) (interface{}, error) {
var req UppercaseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err // транспортная ошибка: endpoint не вызовется
}
return req, nil
}
// GET /count?s=hello — тело пустое, берём query.
func decodeCountRequest(_ context.Context, r *http.Request) (interface{}, error) {
s := r.URL.Query().Get("s")
return CountRequest{S: s}, nil
}Две типовые формы декодера. Uppercase читает JSON-тело; ошибка Decode возвращается наружу — она инфраструктурная. Count собирает запрос из query: декодер не обязан трогать тело. Контекст не используется и назван _ — значения из заголовков сюда добавят ServerBefore-хуки в уроках трейсинга и аутентификации.
Энкодер: сериализация и Content-Type#
Энкодер симметричен: получает response-структуру и пишет её в http.ResponseWriter. Базовая форма — три строки: выставить Content-Type: application/json; charset=utf-8 и вернуть json.NewEncoder(w).Encode(response). Заученная ловушка HTTP здесь прячется в порядке операций: первый же вызов, который пишет в тело, триггерит неявный WriteHeader(200) — после этого менять заголовки бессмысленно, они уже отправлены. Поэтому Set — всегда до Encode, а явный WriteHeader — только если хочешь статус отличный от 200. Ошибка, которую возвращает энкодер, — отдельная история: когда Encode падает (клиент отвалился, срез по конвейеру), заголовки уже ушли, сообщить клиенту нечего — ошибка попадает в ServerErrorHandler и логируется. Статус по умолчанию 200 устраивает счастливый путь; несчастливый — следующий раздел, и там появится таблица codeFrom.
Энкодер и честные статусы: Failer в действии
GOimport (
"context"
"encoding/json"
"net/http"
"github.com/go-kit/kit/endpoint"
)
// Счастливый путь: JSON со статусом 200.
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
if f, ok := response.(endpoint.Failer); ok && f.Failed() != nil {
// Бизнес-ошибка: не 500, а честный статус.
encodeError(ctx, f.Failed(), w)
return nil
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(response)
}
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 {
switch err {
case ErrEmpty:
return http.StatusBadRequest // 400
default:
return http.StatusInternalServerError // 500
}
}Канон stringsvc в редакции Failer из урока 02: response проверяется на Failed(), и бизнес-ошибка кодируется отдельно — со статусом из codeFrom, а не пачкой 500. ErrEmpty стал честным 400. Энкодер — последний пункт, где допустимы заголовки и статусы; сервис про HTTP не узнает никогда.
Ошибки транспорта: три пути к клиенту#
Сведём всё, что знает транспорт go kit об ошибках, — тут три принципиально разных маршрута. Первый: ошибка декодера — сломанный JSON, недостающий параметр; она возвращается из DecodeRequestFunc, endpoint не вызывается, а ответ формирует error encoder. По умолчанию работает DefaultErrorEncoder: статус 500 и текст ошибки в text/plain — нечестно для плохого запроса, поэтому следующий урок посвятим собственному ErrorEncoder через опцию ServerErrorEncoder. Второй: бизнес-ошибка из response-структуры — Failed() != nil; её ловит наш encodeResponse и кодирует через encodeError со статусом из codeFrom. Третий: err endpoint'а — инфраструктурный сбой; он тоже уходит в error encoder, и это правильно: база недоступна — клиенту честный 500, а breaker'у на клиентской стороне — сигнал. Сравни с gin, где c.JSON и c.Abort размазаны по хендлерам: здесь кодирование ошибки — функция с таблицей статусов, единая для всех эндпоинтов. Такая дисциплина делает поведение API предсказуемым: одинаковые ошибки — одинаковые ответы на любом транспорте.
Куда уходит ошибка: путь к статусу#
| Ошибка | Пример | Кто ловит | Что получит клиент |
|---|---|---|---|
| Бизнес, известная | ErrEmpty из сервиса | encodeResponse через Failer | 400 и JSON с полем error |
| Бизнес, не смапленная | новый sentinel без ветки в codeFrom | encodeError → default | 500 — добавь ветку в codeFrom |
| Сломанный запрос | EOF от json.Decoder | DefaultErrorEncoder | 500 и text/plain — лечится ErrorEncoder (урок 05) |
| Инфраструктурный сбой | база недоступна: err endpoint'а | DefaultErrorEncoder | 500 — честный сигнал breaker'у |
| Ошибка энкодера | Encode упал на отвалившемся клиенте | ServerErrorHandler → лог | обрубленный ответ — клиенту нечего сказать |
| Паника | программистская ошибка | процесс падает громко | соединение рвётся — канон урока 01 |
Роутер остаётся твоим#
NewServer возвращает http.Handler — значит, go-kit не отбирает маршрутизацию, и это решение FAQ, а не случайность. Стандартный http.NewServeMux тянет простые проекты; gorilla/mux и chi добавляют переменные пути и middleware уровня роутера; gin можно смонтировать обёрткой его хендлера, если команда уже на нём живёт. Два практических замечания. Методы: mux.Handle не ограничивает метод — POST/GET различаются на уровне роутера (gorilla: r.Methods("POST")), иначе декодер JSON встретит GET с пустым телом и честно вернёт ошибку декодера. Переменные пути: если нужен /profiles/{id}, бери роутер с параметрами и передавай их в request-структуру из декодера — домен и endpoint по-прежнему не видят URL. Пара NewServer + чужой роутер — типовой гибрид больших проектов: go-kit отвечает за слои и обёртки, роутер — за маршруты. В уроке 13 тот же endpoint посадим на gRPC, и станет видно, зачем всё это разделение.
ServerBefore и ServerAfter: хуки контекста#
Осталось два хука, на которые в этом уроке только смотрим. ServerBefore принимает RequestFunc — функцию func(ctx context.Context, r *http.Request) context.Context — и вызывается до декодера: здесь берут из запроса то, что относится к условиям вызова, а не к данным — заголовок Authorization, X-Request-ID, путь. Значение кладётся в контекст с ключом и едет вниз через endpoint в сервис. ServerAfter — зеркальный хук после ответа: func(ctx, response) — туда ложатся метрики и трейсы. Зачем это знать сейчас: чтобы не соблазниться положить данные запроса в декодере в ctx — данные едут в request-структуре, условия вызова — в контексте через ServerBefore. Практика появится по делу: идентификатор запроса — в уроке 06 про логи, спаны трейсинга — в 08, JWT — в 23. Сегодня достаточно прочитать опции в сигнатуре NewServer и не бояться их.
Чеклист транспортного слоя#
- На каждый endpoint — свой kithttp.NewServer; декодер уникален, энкодер общий.
- Декодер — единственный читатель r.Body, query и заголовков; ошибки декодера — транспортные.
- Content-Type и статусы выставляются только в энкодере и encodeError, до первого Write.
- Бизнес-ошибки кодируются через Failer и таблицу codeFrom, а не все подряд 500.
- Роутер внешний: методы и переменные пути — его забота; NewServer отдаёт http.Handler.
- Ни одна транспортная сущность не проходит дальше декодера: *http.Request в endpoint не везут.
Если увидел *http.Request в endpoint-адаптере или тем более в сервисе — архитектура треснула. Урок 01 строил транспорт-агностичное ядро не для красоты: gRPC-версия из урока 13 переиспользует endpoint'ы и сервис только потому, что ни один слой ниже декодера не знает про net/http. Контрольный вопрос при ревью: «смогу ли я посадить этот endpoint на второй транспорт, не меняя ни строки ниже?»
Урок требует установленного Go и модуля github.com/go-kit/kit — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода, а не на запуске. Локально: go mod init && go get github.com/go-kit/kit@v0.13.0, потом curl -X POST localhost:8080/uppercase -d '{"s":"hi"}' — и сервис впервые ответит.
Что возвращает kithttp.NewServer и куда его монтировать?
Сервис вернул ErrEmpty, response-структура реализует Failer, encodeResponse написан по канону. Какой статус увидит клиент?
Где выставляется Content-Type: application/json в go-kit сервисе?
Сломанный JSON: какой статус по умолчанию?
Прочитайте код и выберите, что он выведет:
// POST /uppercase телом "not json"
// Декодер: json.NewDecoder(r.Body).Decode(&req)
// ServerErrorEncoder НЕ настроенПереставили Set и Encode: дойдёт ли Content-Type?
Прочитайте код и выберите, что он выведет:
func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
json.NewEncoder(w).Encode(response) // сначала тело
w.Header().Set("Content-Type", "application/json; charset=utf-8") // потом заголовок
return nil
}Восстанови монтаж эндпоинта и энкодер
Собери транспортный слой. Пропуски — по порядку: конструктор из kithttp, метод заголовка, метод энкодера.
Путь запроса через транспорт
Расставь этапы пути POST /uppercase в правильном порядке: от ListenAndServe до отправленного тела.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
stringsvc отвечает по сети: POST /uppercase и GET /count работают, статусы честные, слои не слиплись. Но дефолты транспорта видны: плохой JSON уезжает с 500, а кодирование ошибок умещается в один case. Следующий шаг — урок 05-codeki: свой ErrorEncoder, аккуратные JSON-ошибки и правила кодеков. Освежить Basis можно в уроке 02-endpoint — теперь ты знаешь, кто вызывает адаптеры, — а карта курса лежит в уроке 01-filosofiya и на странице курса. Транспорт был последним недостающим слоем — дальше курс надевает на них наблюдаемость.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Транспорт — три функции: NewServer(endpoint, decode, encode, options...) возвращает http.Handler.
- Декодер единственный трогает тело, query и заголовки; его ошибка — транспортная, endpoint не зовётся.
- Энкодер пишет Content-Type до первого Write: неявный WriteHeader(200) срабатывает на первой записи тела.
- Бизнес-ошибки кодируются через Failer и encodeError с таблицей codeFrom: ErrEmpty — 400, не 500.
- Дефолтный error encoder отдаёт 500 text/plain на всё — включая сломанный JSON; свой ErrorEncoder — урок 05.
- Роутер остаётся твоим: mux, gorilla, chi, gin; методы и переменные пути — его забота.
- ServerBefore кладёт условия вызова в ctx до декодера; ServerAfter работает после ответа — территория метрик и трейсинга.
Домашнее задание
Доведи stringsvc до работающего сервиса: смонтируй оба endpoint'а через kithttp.NewServer на стандартном mux, подключи декодеры из урока и общий encodeResponse с encodeError и codeFrom. Проверь curl'ом три сценария и запиши наблюдения: валидный POST /uppercase с телом {"s":"hi"}, GET /count?s=hello через query и POST со сломанным JSON — на последнем поймай дефолтный 500 от DefaultErrorEncoder и объясни себе, почему это нечестно. Затем добавь в codeFrom вторую sentinel-ошибку своего сервиса из домашки урока 03 и убедись, что статус поменялся без правки сервиса. В конце попробуй смонтировать один endpoint на gin или chi — NewServer должен вписаться без изменений в декодерах и энкодерах.
Личные заметкиПусто
Вопросы и ответы по уроку
Как смонтировать go-kit endpoint на gin или chi?
NewServer возвращает http.Handler, поэтому chi монтирует напрямую: r.Post("/uppercase", handler). Для gin нужна обёртка его хендлера вокруг handler.ServeHTTP — стандартный приём. Роутер отвечает за методы и параметры пути, go-kit — за слои и декодеры.
GET с query или POST с JSON — как правильно?
Оба легальны, решает декодер: для GET он собирает request-структуру из r.URL.Query(), для POST — из тела. Разница только в форме декодера; endpoint, сервис и энкодер не меняются. Метод ограничивает роутер, не go-kit.
Почему сломанный JSON даёт 500, а не 400?
Потому что ошибка декодера попадает в DefaultErrorEncoder, который отвечает 500 и text/plain на любую ошибку. Это дефолт, а не приговор: настроенный ServerErrorEncoder с таблицей статусов сведёт ошибки декодирования к 400 — именно этим займётся урок 05 про кодеки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации