Содержание урока · 10 разделов
- REST API на chi: структура по слоям
- Каркас: слой → файл → ответственность
- Middleware-стек: RequestID, RealIP, errorHandler, Logger и Timeout
- Валидация тела и параметров с единым JSON-форматом ошибок
- Тесты: httptest на роутер целиком
- Graceful shutdown и выкат: каркас закрывается
- Чек-лист: каркас готов к прод
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Первый сервис, который я собирал на chi, жил одним файлом: main, хендлеры, запросы к базе и рендер ответов в одной куче. Пока роутов было три, это терпело. На пятом хендлере правка в списке заметок начала ронять создание, curl по всем маршрутам перед релизом стал ритуалом, а тест написать было негде — функция совмещала маршрутизацию, хранилище и транспорт в одной сигнатуре. Итоговый проект по chi — REST API записной книжки — устроен ровно наоборот: те же возможности, что и весь курс, но разложенные по слоям, где каждый элемент вы уже собирали руками. Маршруты и методы — из урока про маршрутизацию, стек middleware — из уроков про встроенные слои и их порядок, единые ошибки — из урока про errorHandler, тесты — из урока про httptest. Новых сущностей здесь нет: каркас собирается из деталей, которые уже лежат на столе, и собирается один раз — чтобы жить дальше в ваших проектах, а не умереть в конце курса.
REST API на chi: структура по слоям#
Дерево проекта короткое: cmd/api/main.go, внутренний пакет internal/notes с тремя файлами — handler.go, service.go, store.go — и тесты рядом. main.go только собирает и поднимает: читает флаги, создаёт хранилище, строит роутер, кладёт его в http.Server и обслуживает graceful shutdown. handler.go — транспорт: декодирует тело, достаёт chi.URLParam, решает, каким кодом ответить, и рендерит JSON. service.go — правила предметной области: обрезать пробелы в title, проставлять updated_at, решать, что вообще считать заметкой. store.go — граница данных: интерфейс Store и реализация хоть над картой в памяти, хоть над postgres; остальной код знает только интерфейс. Каталог internal не даёт чужим проектам импортировать вашу начинку, а нарезка по файлам — не про красоту, а про направление зависимостей: транспорт зовёт правила, правила зовут данные, и никогда наоборот. CRUD-четвёрка из урока про маршруты и методы ложится на subrouter ровно по схеме из урока про subrouters и структуру каталога: ветка /api/notes отвечает за свои маршруты и никого не трогает.
Каркас: слой → файл → ответственность#
| Слой | Файл | Ответственность |
|---|---|---|
| Точка входа | cmd/api/main.go | флаги, http.Server, graceful shutdown |
| Транспорт | internal/notes/handler.go | декод JSON, chi.URLParam, коды ответов |
| Правила | internal/notes/service.go | TrimSpace, updated_at, проверки заметки |
| Данные | internal/notes/store.go | интерфейс Store: List, Get, Create, Update, Delete |
| Сборка | internal/notes/router.go | стек middleware и все маршруты одной функцией |
| Тесты | internal/notes/router_test.go | httptest на весь роутер, мок Store |
Каркас без слоёв выглядит невинно ровно до первого теста. Проект, который мне однажды достался: вся логика в одном handler.go на 400 строк — маршрутизация, запросы к базе и рендер вперемешку, тестов ноль, потому что вызвать хендлер вне живого сервера было невозможно. Расслоили за один вечер: Store уехал в интерфейс, правила — в service, хендлеры похудели до транспорта. Первый же httptest-тест нашёл баг, который месяц жил в проде: PUT с нечисловым id возвращал 500. Тесты — не награда за красивую структуру, а причина её сделать.
internal/notes/router.go: одна функция сборки
GOfunc newRouter(st Store) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(errorHandler) // паники и apiError — в единый JSON
r.Use(middleware.Logger)
r.Use(middleware.Timeout(5 * time.Second))
r.Get("/healthz", health)
r.Route("/api/notes", func(r chi.Router) {
r.Get("/", listNotes)
r.Get("/{noteID}", getNote)
r.Group(func(r chi.Router) {
r.Use(RequireAuth)
r.Post("/", createNote)
r.Put("/{noteID}", updateNote)
r.Delete("/{noteID}", deleteNote)
})
})
return r
}newRouter — единственная сборка каркаса, и main.go, и тесты зовут именно её: одна функция гарантирует, что тест проверяет то же, что работает в проде. Пять слоёв глобально, health-проба без auth, чтение публично, изменения — за Group с RequireAuth. Маршруты внутри Group получают проверку потому, что зарегистрированы после r.Use в этой ветке. Пощупать каркас руками можно локальным Go: go run ./cmd/api и curl на /healthz и /api/notes.
Проверка: почему auth в Group
Чтение оставили публичным, а POST, PUT и DELETE — за Group с RequireAuth. Почему auth не поставили общим r.Use в корне стека?
Middleware-стек: RequestID, RealIP, errorHandler, Logger и Timeout#
Порядок слоёв в newRouter — не вкусовщина, а рабочий контракт из уроков про встроенные middleware и порядок стека. RequestID первый: он кладёт идентификатор в контекст, его подхватывают Logger и ответы об ошибках, и жалоба пользователя сшивается с журналом за секунды. RealIP стоит рядом и тоже до Logger: адрес в строке лога берётся из RemoteAddr в момент сборки, и слой должен успеть переписать его на настоящий адрес клиента заранее. errorHandler из урока про централизованные ошибки ловит свои apiError и чужие паники, отвечает единым JSON и стоит до Logger — так его ответы попадают в строку лога с честным статусом. Logger пишет одну строку на запрос: метод, путь, статус, длительность, идентификатор. Timeout задаёт дедлайн контексту в пять секунд: зависший хендлер не держит соединение вечно, а значение согласовано с бюджетом прокси из урока про деплой. RequireAuth в корневой стек не входит — он живёт в Group внутри ветки и достаётся только защищённым маршрутам.
Валидация тела и параметров с единым JSON-форматом ошибок#
Хендлер — худшее место для бизнес-правил и лучшее для HTTP-церемоний. Валидация из урока про URLParam и проверку данных распадается на два этажа. Тело: json.NewDecoder в request-структуру, TrimSpace по title, проверка непустоты и длины; синтаксический мусор — это 400, а валидный JSON с пустым title — 422. Параметры: noteID из chi.URLParam обязан быть числом, иначе 400 ещё до обращения к хранилищу. Ответ об ошибке всегда один: helper renderError из урока про централизованные ошибки пишет обёртку error с машинным code и человеческим message, одинаковые для любого провала. Клиент парсит один контракт вместо трёх, фронтенд-обработчик перестаёт падать на неожиданном формате, а тест фиксирует формат жёстче любой договорённости в чате. Коды разведены по смыслу: 400 — синтаксис, 422 — семантика, 404 — нет заметки, 401 — нет токена на защищённой ветке.
internal/notes/handler.go: create и getNote
GOtype createRequest struct {
Title string
Body string
}
func createNote(w http.ResponseWriter, r *http.Request) {
var in createRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
renderError(w, r, http.StatusBadRequest, "bad_request", "битый JSON")
return
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || utf8.RuneCountInString(in.Title) > 120 {
renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", "title пуст или длиннее 120 символов")
return
}
n, err := store.Create(r.Context(), in.Title, in.Body)
if err != nil {
renderError(w, r, http.StatusInternalServerError, "internal", "не удалось сохранить заметку")
return
}
writeJSON(w, http.StatusCreated, n)
}
func getNote(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
if err != nil {
renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
return
}
n, err := store.Get(r.Context(), id)
if errors.Is(err, ErrNotFound) {
renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
return
}
writeJSON(w, http.StatusOK, n)
}Два хендлера — и весь шаблон транспорта: декод в request-структуру, TrimSpace, проверка длины, ответ через renderError с кодом из таблицы статусов. Нечисловой noteID отсекается до хранилища: мусор в пути не превращается в нечитаемую ошибку на слое данных. JSON-теги у Note и request-структур задают ключи id, title, body и updated_at — здесь они опущены для краткости, а в реальном каркасе пишутся рядом с каждым полем.
Что получит клиент на /api/notes/abc?
Прочитайте код и выберите, что он выведет:
// маршрут: r.Get("/{noteID}", getNote)
func getNote(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
if err != nil {
renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
return
}
n, err := store.Get(r.Context(), id)
if errors.Is(err, ErrNotFound) {
renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
return
}
writeJSON(w, http.StatusOK, n)
}Тесты: httptest на роутер целиком#
Тесты из урока про тестирование chi поднимают не хендлер поодиночке, а весь каркас: newRouter с той же сборкой, что и в main, httptest.NewRequest без сокета и httptest.NewRecorder вместо ResponseWriter. r.ServeHTTP(rr, req) прогоняет запрос через весь стек — RequestID генерируется, URLParam наполняется только роутером, RequireAuth срабатывает на защищённых маршрутах — и это ловит класс багов, невидимый на уровне функций: опечатку в шаблоне маршрута, забытый Use в Group, неверный Content-Type. Мок Store через интерфейс делает набор автономным: Store — интерфейс с пятью методами, в тестах подставляется mockStore над картой, и никакая база не нужна. Набор для каркаса компактен: 201 на создание, 200 на чтение, 404 по errors.Is, 400 на нечисловой noteID, 422 на пустой title, 401 на изменение без токена. Каждый случай — десяток строк, весь набор крутится за миллисекунды.
internal/notes/router_test.go: мок и полный прогон
GOtype mockStore struct {
notes map[int]Note
}
func (m *mockStore) Get(ctx context.Context, id int) (Note, error) {
n, ok := m.notes[id]
if !ok {
return Note{}, ErrNotFound
}
return n, nil
}
// List, Create, Update, Delete — по той же схеме над картой
func TestGetNote(t *testing.T) {
st := &mockStore{notes: map[int]Note{7: {ID: 7, Title: "черновик"}}}
r := newRouter(st) // тот же каркас, что и в main
req := httptest.NewRequest(http.MethodGet, "/api/notes/7", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("код: %d, хотим 200", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type: %q, хотим application/json", ct)
}
}mockStore реализует интерфейс Store над картой: тест подменяет только хранилище, весь остальной каркас настоящий. Запрос идёт через r.ServeHTTP — то же дерево маршрутов, что и в main, поэтому опечатка в шаблоне или забытый RequireAuth упадут прямо здесь. Прогон — локальным Go командой go test ./...: без портов, сети и базы, параллельно и за миллисекунды. Заготовка та же, что в уроке про httptest, — она просто наезжает на готовый каркас.
Проверка: что реально проверено
Тест гоняет запрос через newRouter с mockStore вызовом r.ServeHTTP. Что он проверил?
Graceful shutdown и выкат: каркас закрывается#
Два последних болта закрывают эксплуатацию. Graceful shutdown из одноимённого урока: srv.ListenAndServe уходит в горутину, signal.NotifyContext ловит SIGTERM от systemd или docker stop, srv.Shutdown с бюджетом в десять секунд даёт долетающим запросам закончиться, а новые не принимает. Таймауты http.Server — ReadTimeout, WriteTimeout, IdleTimeout — ставятся рядом с middleware.Timeout и согласуются с ним, чтобы дедлайны не противоречили друг другу. Выкат за прокси из урока про деплой: chi слушает 127.0.0.1:8080, nginx держит TLS и передаёт заголовки клиента, middleware.RealIP в начале стека восстанавливает адрес, /healthz без токена опрашивается прокси. Каркас к этому ничего не требует: health-проба и вежливое выключение уже в коде, поэтому инстанс снимается с балансировки до остановки, а выкат не рвёт активные запросы.
Соберите ветку /api/notes
Верните три строки каркаса: префикс ветки, auth-слой и имя параметра маршрута.
Соберите стек newRouter
Расставьте вызовы стека в правильном порядке — от идентификатора запроса до auth на защищённых ветках.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: каркас итогового проекта
Мой расклад: новый проект начинает с монолит-каркаса из этого урока, и это осознанный выбор, а не компромисс. Граница такая: один сервис, одна команда, общие данные — слои handler, service, store дают ту же изоляцию, что и отдельные сервисы, но без сетевых прыжков, версионирования API и распределённых трейсов. Микросервисы окупаются, когда домены реально независимы: разные команды, разные циклы релизов, разная нагрузка — платежи и каталог, например. Резать заранее — значит платить цену распределённости за гибкость, которая ещё не понадобилась. Признак, что пора делить: два модуля начали деплоиться по расписанию друг друга. До этого момента один процесс с чистыми слоями — самый дешёвый работающий вариант.
Чек-лист: каркас готов к прод#
- Стек полный и в правильном порядке: RequestID, RealIP, errorHandler, Logger, Timeout.
- RequireAuth только внутри Group: /healthz и чтение публичны, изменения по токену.
- Все ошибки — один контракт error с code и message через renderError.
- Валидация до хранилища: битый JSON и нечисловой noteID — 400, пустой title — 422.
- Store — интерфейс: mockStore в тестах, mapStore в main, postgres когда дорастёте.
- Тесты на роутер целиком через ServeHTTP: go test ./... зелёный без сети и базы.
- Graceful shutdown ловит SIGTERM, таймауты http.Server согласованы с Timeout и прокси.
Что получилось в сухом остатке: REST API на chi с четырьмя операциями над заметками, пять файлов Go и ни одной новой сущности сверх курса. Каркас намеренно тонкий — на него наращивается всё, что шло дальше по программе: postgres вместо mapStore подменяется в одной точке main.go, Throttle добавляется одной строкой Use, CORS встаёт перед auth по правилу порядка слоёв, версионирование API — новой веткой Route. Дальше только ваши проекты: скопируйте структуру, соберите стек, прогоните тесты и выкатите за прокси. Каждый болт этого каркаса вы уже держали в руках по отдельности — осталось затянуть их вместе.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каркас REST API записной книжки — пять файлов: main.go собирает и поднимает, internal/notes делит транспорт, правила и данные.
- newRouter — одна функция сборки: её зовут и main, и тесты, поэтому прод и прогон идут по одному стеку.
- Стек: RequestID, RealIP, errorHandler, Logger, Timeout глобально; RequireAuth — только в Group на изменениях.
- Ошибки в одном контракте error с code и message: 400, 422, 404 и 401 разведены по смыслу.
- Store — интерфейс: mockStore в тестах, mapStore в проде; httptest гоняет роутер целиком через ServeHTTP.
- Graceful shutdown и выкат за прокси замыкают каркас: SIGTERM ловится, /healthz отвечает, RealIP восстанавливает адрес.
Домашнее задание
Развейте каркас: добавьте реализацию Store поверх JSON-файла, не меняя интерфейс, и вынесите порт в переменные окружения. Допишите в router_test.go два кейса — 404 на несуществующий noteID и 401 на PUT без токена. Прогоните go test ./... и поднимите каркас за nginx из урока про деплой.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Store — интерфейс, если данных пока немного?
Интерфейс — не про объём данных, а про границы теста. Handler и service зависят от пяти методов, а не от реализации: mockStore подставляется за минуту, тесты идут без базы, а переход на postgres — новый файл store_postgres.go без правок логики. Цена — одна декларация, выгода — автономность тестов.
Можно ли обойтись без слоя service в маленьком API?
Технически да — хендлеры могут звать Store напрямую. Но тогда правила — TrimSpace, updated_at, перевод ErrNotFound — расползаются по хендлерам и дублируются между create и update. Service на сотню строк возвращает себя первым же тестом: подменять и проверять приходится одно место, а не каждую ручку отдельно.
Достаточно ли этого стека middleware для продакшена?
Для простого API — да: идентификация, адрес, ошибки, лог и дедлайн закрывают базовые риски. Дальше слои добавляются по мере роста: Throttle и NoCache из встроенных, CORS перед auth, если появится браузерный фронтенд, метрики — своим слоем. Правило из урока про порядок: сначала вопрос «кто кого видит», потом строка r.Use.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter