Содержание урока · 13 разделов
- проект echo: задание на выпускной
- ТЗ канона: десять требований
- Домен и сервис: владение задачей как бизнес-правило
- Контракт: методы, пути, статусы и заголовки
- Контракт канона на :1323
- JWT и роли: echo-jwt на группе, клеймы в контексте
- Валидация и конверт: два фильтра на входе, один формат на выходе
- Тесты проекта: та же пирамида, новые кейсы
- Прод: наблюдаемость и выкат уже настроены
- Проект сдан: двадцать пять уроков за плечами
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
проект echo: задание на выпускной#
Тема «проект echo» начинается не с mkdir, а с ТЗ. Двадцать четыре урока каждое новое знание приезжало в канон — задачник на :1323 — по одному: маршруты из третьего урока, bind из четвёртого, validator из пятого, middleware из седьмого, slog из девятого, конверт ошибок из десятого, группа /api/v1 из одиннадцатого, JWT из пятнадцатого, REST-контракт из семнадцатого, тесты из двадцатого, слои из двадцать первого, прод-режим и таймауты из двадцать второго, пробы и метрики из двадцать третьего, Docker и graceful shutdown из двадцать четвёртого. Финальный проект — это не новый материал, а сборка: те же файлы, те же решения, но связанные одним заданием и одним чеклистом приёмки. Именно так выглядит настоящая работа: не «изучить тему», а «довести rest api echo до определения готовности».
Три качества отличают итоговый проект от учебных примеров. Первое: контракт предсказуем — любой эндпоинт отвечает по документированной таблице статусов, и ошибка всегда приходит в одном конверте. Второе: слои заменяемы — хранилище на карте подменяется базой без правки сервиса, потому что сервис зависит от интерфейса, объявленного потребителем. Третье: приёмка приборами — готовность доказывают зелёные тесты, пробы и код выхода при docker stop, а не уверенность автора. Всё остальное в этом уроке — исполнение этих трёх требований теми механиками, которые вы уже знаете.
ТЗ канона: десять требований#
- CRUD задач: POST/GET/PUT(PATCH)/DELETE на /api/v1/tasks и /api/v1/tasks/:id — ресурсы, статусы и заголовки по контракту семнадцатого урока.
- Аутентификация JWT: POST /api/v1/login и /api/v1/refresh из пятнадцатого урока; доступ к задачам — только с валидным Bearer-токеном.
- Авторизация по владению: менять и удалять задачу может только её владелец; чужая задача — 403, не 404 и не 500.
- Валидация входа: validate-теги и echo.Validator из пятого урока; битое тело — 400 validation_failed, а не 500.
- Единый конверт ошибок: ErrorHandler с таблицей errors.Is из десятого урока; ни один хендлер не форматирует ошибки сам.
- Бизнес-правила в сервисе: лимит задач на пользователя и владение; хендлер знает только HTTP, репозиторий — только хранилище.
- Идемпотентный POST: повтор с тем же Idempotency-Key возвращает сохранённый ответ из карты replay — дублей нет.
- Пагинация: offset/limit с клампами в сервисе и заголовком Link — страница двадцатого размера максимум, отрицательные значения не падают.
- Наблюдаемость: slog-JSON, RequestID, /healthz, /readyz и /metrics из двадцать третьего урока; версия в логе старта.
- Деплой: multi-stage Dockerfile, nonroot, graceful shutdown по SIGTERM — docker stop завершает процесс кодом 0.
Канон: дерево проекта и границы
GOtasksapp/
├── cmd/
│ └── server/
│ └── main.go // composition root: env → app.New → e.Start
├── internal/ // чужие модули сюда не импортируют — компилятор
│ ├── domain/
│ │ └── task.go // Task + sentinel-ошибки ErrNotFound/ErrForbidden
│ ├── repository/
│ │ ├── memory.go // карта под sync.Mutex, seq, NewMemory()
│ │ └── memory_test.go
│ ├── service/
│ │ ├── tasks.go // TaskStore (потребитель), лимит, владение
│ │ └── tasks_test.go // fake-репозиторий
│ ├── handler/
│ │ ├── tasks.go // TaskHandler + Register(g *echo.Group)
│ │ └── tasks_test.go // fake-сервис
│ └── app/
│ └── app.go // echo.New, middleware, ErrorHandler, echojwt
├── Dockerfile // multi-stage из двадцать четвёртого урока
├── go.mod // module tasksapp
└── deploy/
└── prod.env // JWT_SECRET, PORT — вне gitЭто дерево из двадцать первого урока, доросшее до финала: пять пакетов под internal, тесты рядом с кодом, деплой-артефакты — в корне и deploy/. Ничего не изобретено заново — и в этом оценка: если каждый файл откликается на конкретный урок, структура собрана правильно. Правило internal осталось страховкой компилятора: use of internal package not allowed для чужих модулей. prod.env живёт вне git и вне образа — секреты в историю слоёв не попадают.
Домен и сервис: владение задачей как бизнес-правило#
В финале канон получает одно новое существо — владельца задачи. До сих пор задача была {id, title, done}: анонимной, потому что аутентификации не было. Как только в пятнадцатом уроке у токена появились клеймы, у каждой задачи появился хозяин: sub из JWT. Поле Owner в домене — не украшение, а ось авторизации: аутентификация отвечает «кто ты» (токен проверяет echo-jwt до входа в хендлер), авторизация — «тебе можно трогать это» (правило в сервисе). Разница стоит статуса: нет токена или истёк — 401 Unauthorized; токен валиден, но задача чужая — 403 Forbidden. Перепутать — значит сказать клиенту, что ресурс существует и защищён неправильно.
Где живёт правило владения? Там же, где лимит сотни задач: в сервисе. Хендлер — переводчик HTTP, ему запрещено знать правила; репозиторий — хранитель, ему запрещено знать «можно». Поэтому сервис читает задачу через хранилище, сравнивает t.Owner с клеймом вызывающего и возвращает domain.ErrForbidden, если совпадения нет. Интерфейс TaskStore подрос под полный CRUD: List, ByID, Create, Update, Delete — и по-прежнему объявлен потребителем, сервисом, а не репозиторием: «the bigger the interface, the weaker the abstraction», сужаем до того, что реально зовётся. Sentinel-ошибки — в domain, чтобы repository и service ссылались на одно имя без цикла импортов.
Канон: domain и service с правилами
GO// internal/domain/task.go
package domain
import "errors"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Owner string `json:"owner"` // sub из JWT — владелец
Done bool `json:"done"`
}
var (
ErrNotFound = errors.New("task not found")
ErrForbidden = errors.New("not the owner")
)
// internal/service/tasks.go — фрагмент
package service
var ErrLimitReached = errors.New("task limit reached")
type TaskStore interface { // потребитель объявляет, repository реализует
List(offset, limit int) ([]domain.Task, int, error)
ByID(id int) (domain.Task, error)
Create(t domain.Task) (domain.Task, error)
Update(t domain.Task) (domain.Task, error)
Delete(id int) error
}
func (s *TaskService) Create(title, owner string) (domain.Task, error) {
if s.countByOwner(owner) >= limitPerUser { // бизнес-лимит — здесь
return domain.Task{}, ErrLimitReached
}
return s.store.Create(domain.Task{Title: title, Owner: owner})
}
func (s *TaskService) Delete(id int, caller string) error {
t, err := s.store.ByID(id)
if err != nil {
return err // domain.ErrNotFound едет наверх как есть
}
if t.Owner != caller { // владение — правило бизнеса
return domain.ErrForbidden
}
return s.store.Delete(id)
}Поток ошибок из двадцать первого урока не изменился: sentinel внизу, движение наверх без переупаковки, перевод один раз — в ErrorHandler. ErrForbidden в domain по той же причине, что ErrNotFound: его производят правила сервиса, а форматит десятый урок. Обратите внимание на Create(title, owner string): хендлер достаёт owner из клейма echo-jwt и передаёт аргументом — сервис не знает ни JWT, ни echo, ни c.Get. Тест правила — три строки на fake-репозитории: чужой владелец даёт ErrForbidden, свой — удаление.
Контракт: методы, пути, статусы и заголовки#
rest api echo узнаётся по таблице, а не по коду: какие методы, какие пути, какие статусы — и что происходит при ошибке. Семнадцатый урок задал правила, финал их скрепляет. Ресурс — tasks, идентификатор — path-параметр :id из третьего урока. Статусы честные: 201 Created с заголовком Location: /api/v1/tasks/7 на создание; 200 на чтение и обновление; 204 NoContent на удаление — c.NoContent из четвёртого урока. Идемпотентность — по RFC 9110: GET, PUT и DELETE повторяются безопасно, POST — только с заголовком Idempotency-Key и картой replay из семнадцатого урока. Пагинация: page и limit как query-параметры, клампы в сервисе (limit ≤ 100, page ≥ 1), ссылка на следующую страницу — заголовком Link по RFC 8288. Лимит на пользователя — 429 TooManyRequests с Retry-After: слишком много — временная проблема, а не 400 и не 413.
Заголовок X-API-Version на группе /api/v1 остался с одиннадцатого урока, Sunset из RFC 8594 — с семнадцатого: контракт должен сам сообщать о своей версии и дате заката. Все ответы об ошибках — в одном конверте {"error": "…", "code": "…"} ErrorHandler-а: клиент парсит не статусы, а коды. Таблица ниже — это и есть ТЗ в сжатом виде: держите её в README проекта, она же — чеклист тестовой матрицы.
Контракт канона на :1323#
| Метод и путь | Кто | Успех | Ошибки | Что делает |
|---|---|---|---|---|
| POST /api/v1/login | все | 200 + {access, refresh} | 401 invalid_credentials | выдаёт пару токенов из пятнадцатого урока |
| POST /api/v1/refresh | все | 200 + {access, refresh} | 401 expired | меняет refresh на свежую пару |
| GET /api/v1/tasks | Bearer | 200 + список + Link | 401 | страница задач владельца, клампы в сервисе |
| GET /api/v1/tasks/:id | Bearer | 200 + Task | 401, 404 task_not_found, 403 | чужая задача — 403: она есть, но не ваша |
| POST /api/v1/tasks | Bearer | 201 + Location + Task | 400 validation_failed, 429 limit_reached + Retry-After | идемпотентен по Idempotency-Key |
| PUT /api/v1/tasks/:id | Bearer + владелец | 200 + Task | 401, 400, 403, 404 | обновление: правило владения в сервисе |
| DELETE /api/v1/tasks/:id | Bearer + владелец | 204 NoContent | 401, 403, 404 | идемпотентен по определению RFC 9110 |
| GET /healthz, /readyz, /metrics | все | 200 / 503 / текст | 503 not ready | пробы и метрики двадцать третьего урока |
JWT и роли: echo-jwt на группе, клеймы в контексте#
Пятнадцатый урок оставил вам всё, что нужно финалу, — здесь только сборка. На группе /api/v1 стоит echojwt.Config с HS256 и секретом из окружения; /login и /refresh живут на публичной подгруппе без гварда. Клеймы — sub и role: sub становится владельцем задач, role разграничивает будущие админские эндпоинты (в каноне роль admin умеет List по всем владельцам — одна проверка в сервисе, ещё один аргумент держать правила там). Хендлер достаёт личность из c.Get("user") — echo-jwt кладёт туда token *jwt.Token, из MapClaims вытаскивается sub — и передаёт строкой в сервис. Ни один слой ниже хендлера не знает слова JWT: это HTTP-механика перевода идентичности в аргумент.
Чего в финале быть не должно: чтения Authorization вручную и сравнения строк. bearerAuth восьмого урока был ступенькой, финал её снимает: источник токена, валидация подписи и exp — работа echo-jwt, отказ — его 401, который ErrorHandler форматирует в конверт. Refresh-поток тоже каноничен: access живёт 15 минут, refresh 30 суток, отзыв refresh-ов — отдельная задача реестра, потому что JWT по своей природе безгосударственен; в ТЗ проекта это честно записано ограничением, а не спрятано.
Проверьте себя: 401 или 403
Задача 7 принадлежит борису. Анна пришла с валидным access-токеном (подпись верна, exp не вышел) и вызвала DELETE /api/v1/tasks/7. Что отвечает канон и почему?
Валидация и конверт: два фильтра на входе, один формат на выходе#
Вход проекта охраняют два фильтра подряд. Первый — валидация из пятого урока: struct-теги validate на DTO, echo.Validator, вызов c.Validate в хендлере; пустой title, слишком длинная строка или битое число в query — 400 validation_failed ещё до сервиса. Второй фильтр — бизнес: лимит на пользователя и владение; их к сервису выпускают только с корректными данными. Разводка принципиальна: формат запроса — обязанность границы, правила — обязанность сервиса; тест каждого фильтра не тянет второй.
Выход — один на весь проект. ErrorHandler из десятого урока — единственное место, где доменная ошибка становится HTTP-ответом: errors.Is разворачивает sentinel-цепочку fmt.Errorf с %w, таблица переводов фиксирует статус и код конверта, логирует только 500-е с деталями Internal, клиенту — общий текст. Финальная таблица канона: domain.ErrNotFound → 404 task_not_found, domain.ErrForbidden → 403 forbidden, service.ErrLimitReached → 429 limit_reached + Retry-After: 30, *echo.HTTPError от валидатора → 400 validation_failed, отказ echo-jwt → 401 unauthorized, всё остальное → 500 internal_error. Хендлеры при этом ни разу не зовут echo.NewHTTPError с бизнес-ошибками — только для ошибок перевода запроса вроде нечислового :id, как договорился двадцать первый урок.
Канон: ErrorHandler финальной версии
GO// internal/app/app.go — фрагмент
e.HTTPErrorHandler = func(err error, c echo.Context) {
code, apiCode := http.StatusInternalServerError, "internal_error"
switch {
case errors.Is(err, domain.ErrNotFound):
code, apiCode = http.StatusNotFound, "task_not_found"
case errors.Is(err, domain.ErrForbidden):
code, apiCode = http.StatusForbidden, "forbidden"
case errors.Is(err, service.ErrLimitReached):
code, apiCode = http.StatusTooManyRequests, "limit_reached"
c.Response().Header().Set("Retry-After", "30")
}
var he *echo.HTTPError // ошибки фреймворка: 400 валидатора, 401 echo-jwt, 404 маршрута
if errors.As(err, &he) && apiCode == "internal_error" {
code, apiCode = he.Code, apiCodes[he.Code] // map[int]string: 400→validation_failed, 401→unauthorized
}
if code >= 500 {
c.Logger().Error("internal", "request_id", c.Get(echo.HeaderXRequestID), "err", err)
err = errors.New("internal error") // детали — в лог, не клиенту
}
_ = c.JSON(code, map[string]string{"error": err.Error(), "code": apiCode})
}Один switch держит весь контракт ошибок: добавление нового доменного отказа — строка в таблице и кейс в тест-матрице, ноль правок хендлеров. Retry-After ставится рядом со своим 429 — связка из семнадцатого урока, которую клиент обязан уважать. RequestID из седьмого урока прилетает в лог 500-х — иначе расследование по slog-строкам превратится в угадайку. Не Found превращается в честный generic: стек и внутренности — приборам, не внешнему миру.
Что вернёт повторный POST с тем же Idempotency-Key
Прочитайте код и выберите, что он выведет:
// клиент потерял связь и ретраит создание:
// 1-й POST /api/v1/tasks Idempotency-Key: abc-123 → 201, {"id":7,...}
// сеть упала; клиент повторяет точно тот же запрос
// 2-й POST /api/v1/tasks Idempotency-Key: abc-123 → ?Тесты проекта: та же пирамида, новые кейсы#
Пирамида двадцатого урока переживает финал без перестройки. Внизу — сервис с fake-репозиторием: лимит, владение, клампы пагинации — три правила, три сценарных отказа ручкой err. Средний уровень — хендлер с fake-сервисом: перевод запроса в вызов и статусы, включая нечисловой :id, который обязан дать 400 на границе. Вершине — честный тест repository.Memory: создать, прочитать, удалить, поймать ErrNotFound. И над всем — стек-тест на сборке app.New с fake-хранилищем: e.ServeHTTP из двадцатого урока прогоняет матрицу контракта целиком, включая echo-jwt и ErrorHandler — то, что юниты поодиночке не увидят.
Финал добавляет в матрицу два класса кейсов. Авторизационные: без токена — 401, валидный токен чужой задачи — 403, свой — 204; они держат таблицу ТЗ на замке. Идемпотентные: два POST с одним ключом — одинаковые тела и одинаковый статус, задача одна. Запуск обязателен с гонками: go test -race ./... — карта replay и репозиторий под мьютексом обязаны молчать; это тот же инструмент, что ловил двойной Create ещё в восьмом уроке. Команда приёмки проекта: go vet ./... && go test -race ./... — зелёная, прежде чем соберётся первый образ.
Канон: стек-матрица контракта e.ServeHTTP
GOfunc TestContractMatrix(t *testing.T) {
tests := []struct {
name string
req func() *http.Request
want int
inCode string // ожидаемый код конверта, "" — не ошибка
}{
{"create без токена", rPOST("/api/v1/tasks", body), 401, "unauthorized"},
{"create битое тело", rPOSTAuth("/api/v1/tasks", "{"), 400, "validation_failed"},
{"create ок", rPOSTAuth("/api/v1/tasks", body), 201, ""},
{"create повтор с тем же ключом", rPOSTAuthIdem("/api/v1/tasks", body, "k1"), 201, ""},
{"list чужих страниц нет", rGETAuth("/api/v1/tasks?page=-3"), 200, ""},
{"get чужой задачи", rGETAuthAs("/api/v1/tasks/7", anna), 403, "forbidden"},
{"get не существует", rGETAuth("/api/v1/tasks/999"), 404, "task_not_found"},
{"delete своего", rDELAuth("/api/v1/tasks/7", boris), 204, ""},
{"delete своего повторно", rDELAuth("/api/v1/tasks/7", boris), 404, "task_not_found"},
{"нечисловой id", rGETAuth("/api/v1/tasks/abc"), 400, "validation_failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := app.New(newFakeStore(), testSecret, false) // свежая сборка на кейс
rec := httptest.NewRecorder()
e.ServeHTTP(rec, tt.req())
if rec.Code != tt.want {
t.Fatalf("status: got %d, want %d", rec.Code, tt.want)
}
assertConvert(rec.Body, tt.inCode) // конверт {error, code}
})
}
}Свежий app.New на каждый кейс — то правило из двадцатого урока, которое в финале важнее всего: повторное удаление обязано уйти в 404, а не наткнуться на след предыдущего кейса. Матрица — это исполняемая версия таблицы контракта: каждая строка ТЗ имеет кейс, каждый статус из таблицы проверен числом. Такие матрицы читаются ревьюером без запуска — и именно они продают проект: видно, что контракт придуман до кода.
Проект сдан, когда все пункты отвечают «да» и каждый подтверждён командой или прибором: go vet ./... && go test -race ./... — зелёные; матрица контракта совпадает с README построчно; чужая задача — 403, а не 404 и не 500; повтор POST с Idempotency-Key не создаёт дубль; limit_reached приходит с Retry-After; в логах — slog-JSON с RequestID и версией на старте; /healthz отвечает 200 сразу, /readyz управляет трафиком, /metrics отдаёт echo_requests_total; CGO_ENABLED=0 бинарник собирается с версией из git describe; docker build проходит, контейнер стартует от nonroot; docker stop завершает процесс кодом 0 — Shutdown дождался in-flight запросов. Десять «да» — и проект можно показывать.
Прод: наблюдаемость и выкат уже настроены#
Последняя треть проекта не пишет нового кода — она включает переключатели, которые вы собрали в трёх предыдущих уроках. Прод-режим из двадцать второго: prod := os.Getenv("APP_ENV") == "production", Release-настройки и таймауты http.Server параметрами конструктора. Наблюдаемость из двадцать третьего: middleware RequestID и Logger со slog-JSON из девятого, пробы /healthz и /readyz (ready гасится до прогрева и отвечает 503), /metrics с echo_requests_total и ярлыком версии — HEALTHCHECK Docker-образа бьёт ровно в эти ручки. Выкат из двадцать четвёртого: CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=$(git describe)", multi-stage Dockerfile с USER nonroot и ca-certificates, SIGTERM → e.Shutdown(8s) внутри 10-секундного бюджета docker stop, errors.Is(err, http.ErrServerClosed) в горутине запуска, hub.Close() после Shutdown, если держите WebSocket-комнату из восемнадцатого урока.
Сборка финала — момент истины для архитектуры: main.go остаётся трёхстрочным (хранилище → app.New → e.Start), потому что двадцать первый урок уже развёл слои, а двадцать четвёртый научил main останавливаться. Если при сборке проекта main пришлось править слоями — где-то протекла зависимость; вернитесь к дереву из начала урока и найдите, кто вызывает чужого. Красота финала в том, что сборка должна занять вечер, а не неделю: всё уже написано по дороге.
Проверьте себя: первый запрос на свежем деплое
Контейнер канона только что стартовал. Вы вызываете POST /api/v1/tasks без заголовка Authorization. Кто и с чем ответит?
Соберите композицию main.go
Восстановите три осмысленные строки composition root: хранилище, сборка приложения, старт.
Порядок: путь проекта от ТЗ до приёмки
Расставьте шаги реализации проекта в порядке, при котором каждый следующий опирается на готовый предыдущий.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: выпускной словарь курса
Проект сдан: двадцать пять уроков за плечами#
Оглянемся на маршрут, который прошли. Мы начали с вопроса «что такое Echo и чем оно отличается от соседей», подняли первый сервер на :1323, научились маршрутизировать и читать запрос, ввели валидацию, построили свои цепочки middleware и встроили стандартные, завели slog и единый конверт ошибок, охраняли группы, выдавали cookie-сессии и JWT, закрывали CORS и CSRF, договаривались REST-контрактом, держали WebSocket и SSE, доказали всё тестами, разложили по слоям, ускорили (производительность), научили отвечать приборам и останавливаться вежливо — и сегодня собрали итоговый rest api echo, где каждый файл откликается на урок, а каждый пункт ТЗ — на строку тест-матрицы.
Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; финальный проект собирается и запускается у вас, и в этом его ценность: это настоящий репозиторий с историей, тестами и Docker-образом, который можно показать работодателю. Куда дальше: курс Gin покажет те же принципы на чистом net/http-фундаменте — сравнение станет практикой, а не таблицей; go-advanced углубит сам язык — горутины, каналы и конкурентность в рабочем исполнении. Домашнее задание ниже — выпускной экзамен: пройдите его целиком, не подсматривая в уроки. Навигация по курсу — на странице курса Echo.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый проект echo — сборка канона, а не новый материал: задачник на :1323 получает владельцев (Owner = sub из JWT), полный CRUD, роли, идемпотентный POST и приёмку приборами. ТЗ — десять требований, чеклист приёмки — Definition of Done из десяти «да».
- Авторизация живёт в сервисе: t.Owner != caller → domain.ErrForbidden → 403 forbidden в ErrorHandler. Аутентификация — echo-jwt на группе /api/v1 (HS256, клеймы sub/role, /login и /refresh публичны): 401, когда сервер не узнал; 403 — когда узнал, но нельзя.
- Контракт в таблице: 201+Location на создание, 204 на удаление, 429 limit_reached с Retry-After, 400 validation_failed на границе, 404 task_not_found, 403 forbidden, 401 unauthorized, 500 generic. Пагинация с клампами и Link; Idempotency-Key с картой replay против дублей.
- Слои не изменились: handler переводит HTTP, service владеет TaskStore (потребитель объявляет) и правилами, repository прячет хранилище за мьютексом, app.New собирает middleware/ErrorHandler/echojwt, main — composition root на три строки.
- Тесты — та же пирамида: fake-репозиторий для сервиса, fake-сервис для хендлера, честный memory_test, стек-матрица e.ServeHTTP поверх app.New со свежей сборкой на кейс; запуск только с go test -race ./... — карта replay и Memory под мьютексом обязаны молчать.
- Прод собран из трёх последних уроков: APP_ENV и таймауты из двадцать второго, /healthz /readyz /metrics и slog-JSON из двадцать третьего, Dockerfile multi-stage + nonroot + graceful shutdown (SIGTERM → e.Shutdown 8s → код 0) из двадцать четвёртого.
- Курс закрыт: 25 уроков от echo.New до docker stop; дальше — курс Gin как вторая реализация тех же принципов и go-advanced для глубины языка; выпускной экзамен — домашнее задание этого урока.
Домашнее задание
Выпускной экзамен: соберите проект с нуля и пройдите приёмку по частям. Первое — скелет: go mod init tasksapp, дерево из начала урока, domain с Task{ID, Title, Owner, Done} и двумя sentinel-ошибками; go build ./... обязан пройти при пустых слоях. Второе — хранилище и сервис: repository.Memory с мьютексом и seq, TaskStore (List/ByID/Create/Update/Delete), лимит 100 задач на владельца, владение в Delete/Update, клампы пагинации; fake-репозиторий покрывает ErrLimitReached, ErrForbidden, ErrNotFound — по тесту на каждый. Третье — контракт: handler с Register на группе, ErrorHandler финальной версии со всеми строками таблицы, Retry-After у 429; нечисловой :id даёт 400 в хендлере. Четвёртое — аутентификация: /login и /refresh из пятнадцатого урока (access 15 минут, refresh 30 суток), echojwt на /api/v1, sub становится Owner-ом; напишите кейс «валидный токен анны, задача бориса → 403 forbidden» и кейс «просроченный токен → 401 unauthorized». Пятое — идемпотентность: middleware Idempotency-Key с картой replay и TTL; два POST с одним ключом дают одинаковое тело и статус, задача одна; тест с -race на параллельные повторы. Шестое — матрица: стек-тест из урока (10 кейсов минимум) на app.New с fake-хранилищем, свежая сборка на кейс; go vet ./... && go test -race ./... — зелёные. Седьмое — выкат: версия через -ldflags, Dockerfile multi-stage с nonroot и HEALTHCHECK по /healthz, prod.env вне git; под нагрузкой выполните docker stop и покажите в логах, что Shutdown дождался in-flight запросов, а код выхода — 0. Восьмое — защита: напишите README с таблицей контракта, перечнем уроков курса на каждый файл проекта и списком известных ограничений (отзыв refresh-токенов, TTL карты replay); устно проговорите, почему владение — в сервисе, а не в хендлере, и что сломается при переносе хранилища в PostgreSQL. Зачёт — десять «да» чеклиста Definition of Done.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем итоговый проект echo отличается от аналогичного на Gin или Fiber?
Требования контракта совпадают почти дословно — CRUD, JWT, валидация, тесты, деплой не зависят от фреймворка. Различия — в механике уровня фреймворка: Echo даёт типизированный echo.Context, HTTPErrorHandler как единую точку перевода ошибок и echo-jwt из echo-contrib; у Gin — gin.Context и ShouldBind, у Fiber — fasthttp-фундамент и свой обработчик ошибок. Слои, интерфейс потребителя, sentinel-ошибки и матрица тестов переносятся между фреймворками один в один — поэтому проект ценен не как «пример на Echo», а как умение строить контракт и границы.
Что расширять в проекте после курса, чтобы он рос в портфолио?
Замена repository.Memory на PostgreSQL — самый честный следующий шаг: сервис не меняется ни на строку, если интерфейс TaskStore не тронут, а миграции и sql-слой лягут в отдельный пакет. Дальше по вкусу: refresh-токены с реестром отзывов, rate limiting per-IP из кастомных middleware, OpenAPI-схема, сгенерированная из таблицы контракта, Prometheus-алерты на долю 5-хх, CI с go test -race и docker build на каждый тег. Каждое расширение — это тот же цикл: пункт ТЗ → строка в таблице → кейс в матрице → приёмка.
Как понять, что проект недостаточно хорош, и что чаще всего снимают на защите?
Три красных флага: правило в хендлере (владение или лимит, найденные в tasks.go слоя handler), статус, которого нет в таблице контракта, и тест, который падает при втором запуске (состояние протекло между кейсами). На защите чаще всего проверяют именно границы: где проверяется владение (сервис), кто форматирует ошибку (один ErrorHandler), что случится при подмене хранилища (ничего), почему 403, а не 404 (решение контракта, зафиксированное в ТЗ и тесте). Если можете ответить на эти четыре вопроса, не глядя в код, — проект ваш.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload