Содержание урока · 14 разделов
- echo группы: поддерево маршрутов как слой организации
- Group: четыре поля и трёхстрочный конструктор
- Механика g.Add: свежий срез и applyMiddleware
- g.Use: глобальное против группового
- Вложенные группы: префиксы склеиваются, слои наследуются
- Общие middleware на поддерево: что это даёт на практике
- Версии api echo: v1/v2 над одной базой обработчиков
- 404, 405 и группы: приоритет перехватчика
- Имена и реверс: e.URI против конкатенации строк
- Три фреймворка, одна идея
- Что дальше: статика и шаблоны
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
echo группы: поддерево маршрутов как слой организации#
Тема «echo группы» начинается с проблемы, которую вы уже могли нащупать в каноне десятого урока: маршрутов становится много, и у большинства из них общие свойства. Пять эндпоинтов задач хотят один и тот же bearerAuth, будущие /healthz и /metrics — ничего, а следующая мажорная версия API хочет всё то же самое, но с другими настройками. Размазывать эти решения по каждому вызову v1.GET("/tasks", listTasks, bearerAuth(...)) — значит копировать одну и ту же строку десятки раз и молиться, что при следующем изменении никто не забудет обновить половину копий. Echo решает это слоем организации: e.Group(prefix, m...) возвращает структуру Group — заготовку поддерева маршрутов с общим префиксом и общими middleware. Дальше вы регистрируете маршруты уже на ней, и оба свойства приклеиваются автоматически.
Ключевое слово здесь — поддерево: группа группирует не «все маршруты подряд», как e.Use, а конкретную ветку пути, скажем /api. Это среднее звено между двумя крайностями шестого урока: глобальные слои, которые достаются каждому запросу, и маршрутные слои, которые достаются ровно одному маршруту. Групповые слои достаются всей ветке — и это ровно та единица масштабирования, которую требуют реальные API: аутентификация на /api, лимиты на /v2, лог-формат на отдельном домене.
Group: четыре поля и трёхстрочный конструктор#
Открываем group.go. Док-комментарий формулирует контракт целиком: Group is a set of sub-routes for a specified route. It can be used for inner routes that share a common middleware or functionality that should be separate from the parent echo instance while still inheriting from it. Подмаршруты, разделяющие общие middleware, отделённые от родительского инстанса, но наследующие от него — каждое слово потом превратится в строку кода.
Сама структура крошечная: prefix string — клей для путей; echo *Echo — ссылка на родителя, через которую группа регистрирует маршруты; middleware []MiddlewareFunc — копилка слоёв; host string — для варианта с виртуальными хостами, который нам пока не нужен. Плюс встроенный пустой тип common — служебный носитель общих хелперов. И главное: никакого собственного роутера внутри нет. Конструктор в echo.go занимает три строки: g = &Group{prefix: prefix, echo: e}; g.Use(m...); return. Передали ли вы middleware вторым аргументом или нет — g.Use всё равно вызывается: с пустым вариадиком он станет точкой, где позже включится один важный трюк, о котором будет отдельный блок.
Group из исходников: структура и конструктор
GO// group.go, v4.15.0 — док и структура целиком
// Group is a set of sub-routes for a specified route. It can be used for inner
// routes that share a common middleware or functionality that should be separate
// from the parent echo instance while still inheriting from it.
type Group struct {
common
host string
prefix string
echo *Echo
middleware []MiddlewareFunc
}
// echo.go — конструктор: три строки, никакого нового роутера
func (e *Echo) Group(prefix string, m ...MiddlewareFunc) (g *Group) {
g = &Group{prefix: prefix, echo: e}
g.Use(m...)
return
}
// echo.go — родственный конструктор: та же Group, но на виртуальный хост
// (роутер на домен заводится в карте e.routers)
func (e *Echo) Host(name string, m ...MiddlewareFunc) (g *Group)Читаем поля в порядке объявления. prefix — единственное, что группа добавляет к путям; echo — ссылка наверх: регистрация всё равно идёт через родителя; middleware — накопитель слоёв, из которого позже соберётся цепочка; host — редкая деталь для e.Host(name, m...), где под домен заводится отдельный роутер в карте e.routers. Конструктор ничего не проверяет: не совпадающий с чем-то префикс, не занятый путь — только структура и вызов g.Use. Обратите внимание, что Group нигде не хранит собственное дерево маршрутов: в v4.15 роутер один — структура Router из router.go, «registry of all registered routes».
Проверка: что делает e.Group
Что именно происходит в момент вызова api := e.Group("/api", bearerAuth(...))?
Механика g.Add: свежий срез и applyMiddleware#
Каждый вызов v1.GET("/tasks", listTasks) превращается в g.Add(http.MethodGet, "/tasks", listTasks) — а его полная подпись из group.go выглядит так: func (g Group) Add(method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) Route. Внутри слои сливаются в НОВЫЙ срез: сначала всё, что накопила группа, потом слои, переданные прямо при регистрации маршрута. Копия — не педантизм: комментарий в исходнике — Combine into a new slice to avoid accidentally passing the same slice for multiple routes, which would lead to later add() calls overwriting the middleware from earlier calls — объясняет, что иначе один и тот же срез мог бы случайно уехать в несколько маршрутов, и поздние регистрации перезаписали бы слои ранних. Затем маршрутизирующий вызов: g.echo.add(g.host, method, g.prefix+path, handler, m...) — путь склеен из префикса группы и пути маршрута, и дальше работает обычный e.Add из третьего урока.
Внутри e.add живёт уже знакомое вам по восьмому уроку: замыкание маршрута при каждом запросе вызывает applyMiddleware(handler, m...), который оборачивает обработчик с конца среза. Отсюда полный порядок слоёв для запроса к /api/v1/tasks: e.Pre срабатывают до роутера («run before router»), роутер находит маршрут, глобальные e.Use — «run after router» — идут снаружи, затем слои группы, затем маршрутные, и только потом обработчик. Родительские слои всегда снаружи дочерних, групповые — всегда снаружи маршрутных: срез сливается в порядке «группа → маршрут», а applyMiddleware делает первый элемент среза самым внешним звеном.
g.Add и applyMiddleware из исходников
GO// group.go — метод Add: слои группы + слои маршрута в свежем срезе
func (g *Group) Add(method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
// Combine into a new slice to avoid accidentally passing the same slice
// for multiple routes, which would lead to later add() calls overwriting
// the middleware from earlier calls.
m := make([]MiddlewareFunc, 0, len(g.middleware)+len(middleware))
m = append(m, g.middleware...)
m = append(m, middleware...)
return g.echo.add(g.host, method, g.prefix+path, handler, m...)
}
// echo.go — e.add: замыкание маршрута собирает цепочку на каждый запрос
route := router.add(method, path, name, func(c Context) error {
h := applyMiddleware(handler, middlewares...)
return h(c)
})
func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}Три фрагмента складываются в одну механику. Add копирует слои в свежий срез нужной ёмкости — групповые первыми, маршрутные следом; срез живёт своей жизнью для каждого маршрута. e.add сохраняет замыкание, которое на каждый запрос заново вызывает applyMiddleware: цикл идёт с конца среза, каждый элемент оборачивает текущий обработчик — первый элемент среза оказывается самым внешним звеном. Для группы это значит: bearerAuth-слой, дописанный в копилку /api, будет самым внешним звеном каждого маршрута поддерева — раньше глобальных не бывает ничего, кроме e.Pre и стековых e.Use.
g.Use: глобальное против группового#
Теперь вторая половина контракта — g.Use(middleware ...MiddlewareFunc). Первая его строка скучна: g.middleware = append(g.middleware, middleware...) — та же копилка, что у e.Use из шестого урока. Но дальше — самый нетривиальный фрагмент group.go, и док-комментарий объясняет, зачем он нужен. Групповые слои — НЕ то же самое, что глобальные Pre и Use: те исполняются всегда, потому что стоят в стеке самого Echo; групповые же исполняются только если роутер нашёл маршрут, зарегистрированный вместе с ними. А что если путь под префиксом группы не совпал ни с одним маршрутом? Роутер просто не нашёл бы маршрут — и групповые слои бы молча пропустили.
Решение Echo: после каждого g.Use с непустым списком группа регистрирует сама себе два перехватчика несуществующих путей — g.RouteNotFound("", NotFoundHandler) и g.RouteNotFound("/*", NotFoundHandler). Документация называет это «catch all route (404 is a safe way to emulate route match)»: несуществующий путь теперь совпадает с wildcard-маршрутом группы, и во время маршрутизации роутер гарантированно находит маршрут — а значит, исполняет и групповые слои. Цена трюка: 404 внутри группы с middleware начинает жить по особым правилам, и это отзовётся и в поведении с 405 — разберём после канона.
g.Use из исходников: append и два перехватчика
GO// group.go, v4.15.0 — метод целиком
func (g *Group) Use(middleware ...MiddlewareFunc) {
g.middleware = append(g.middleware, middleware...)
if len(g.middleware) == 0 {
return
}
// group level middlewares are different from Echo `Pre` and `Use`
// middlewares (those are global). Group level middlewares are only
// executed if they are added to the Router with route.
// So we register catch all route (404 is a safe way to emulate route
// match) for this group and now during routing the Router would find
// route to match our request path and therefore guarantee the
// middleware(s) will get executed.
g.RouteNotFound("", NotFoundHandler)
g.RouteNotFound("/*", NotFoundHandler)
}Метод двуликий. Видимая половина — append в копилку слоёв. Невидимая — регистрация двух перехватчиков 404, которая происходит при КАЖДОМ вызове Use с непустым списком: и когда вы передали слои в конструктор e.Group, и при каждом последующем g.Use. Перехватчики регистрируются через g.RouteNotFound, а он через g.Add — то есть несут в себе те групповые слои, что уже накоплены к этому моменту. Пустой вызов (e.Group без middleware) не регистрирует ничего: len == 0 — ранний return, перехватчики появятся только при первом настоящем слое.
Слой подключили после маршрута
Прочитайте код и выберите, что он выведет:
g := e.Group("/api")
g.GET("/tasks", func(c echo.Context) error {
return c.String(200, "tasks")
})
g.Use(tag("v1")) // слой, печатающий "tag" в лог
g.GET("/users", func(c echo.Context) error {
return c.String(200, "users")
})
// GET /api/tasks и GET /api/users — где сработает tag?Вложенные группы: префиксы склеиваются, слои наследуются#
Group умеет порождать группы — и это то, из чего строятся версии api. Метод g.Group(prefix, middleware...) — подпись из group.go: func (g *Group) Group(prefix string, middleware ...MiddlewareFunc) — делает две склейки. Первая — путь: sg = g.echo.Group(g.prefix+prefix, m...), префиксы конкатенируются, /api плюс /v1 дают /api/v1; вложенность может быть глубже — дерево ответственности читается прямо в коде. Вторая — слои: новый срез наполняется сначала ВСЕМИ слоями родителя, потом новыми, и получившаяся подгруппа наследует их как свои собственные. Наследуется и поле host — у подгруппы того же виртуального домена.
Здесь спрятана вторая ловушка со срезами, зеркальная ловушка из predict-задания выше. Слои родителя копируются в подгруппу в момент её СОЗДАНИЯ: если после v1 := api.Group("/v1") написать api.Use(newLayer), то newLayer допишется в срез api и достанется маршрутам, зарегистрированным на api позже, — но в срез уже созданной v1 он не попадёт, её маршруты унаследуют только старый набор. Читаемость кода страдает сильнее корректности: дерево из трёх строк выше превращается в неявный график «кто что успел скопировать». Практическое правило: конструируйте дерево групп сверху вниз за один проход — все Use на каждом уровне до порождения следующего.
Канон урока: дерево /api/v1 и /api/v2 на :1323
GO// main.go — дерево групп канона: две версии над одной базой обработчиков
func main() {
e := echo.New()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
e.Use(middleware.RequestID()) // глобальный слой — снаружи групп
e.Use(middleware.Recover())
// /api — общее поддерево: аутентификация обеих версий за один вызов
api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))
v1 := api.Group("/v1") // /api/v1 — наследует bearerAuth
v2 := api.Group("/v2", versionTag("v2")) // /api/v2 + свой слой версии
// одна база обработчиков: listTasks/getTask из десятого урока
v1.GET("/tasks", listTasks)
v1.GET("/tasks/:id", getTask)
v2.GET("/tasks", listTasks)
v2.GET("/tasks/:id", getTask)
// свой 404 на поддерево v1 — несёт наследованный bearerAuth
v1.RouteNotFound("/*", func(c echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound, "нет маршрута в v1")
})
e.HTTPErrorHandler = apiErrorHandler(logger) // ErrorHandler из десятого урока
e.Logger.Fatal(e.Start(":1323"))
}Читаем дерево сверху вниз — ровно так, как оно строится. e.Use ставит глобальные слои до создания групп: они всегда снаружи. /api получает bearerAuth восьмого урока один раз — вместо четырёх копий на каждом маршруте. Подгруппы /v1 и /v2 рождаются после этого и наследуют слой автоматически; v2 дополнительно получает versionTag — фабрику слоя из восьмого урока в миниатюре. Одна база обработчиков регистрируется в обеих версиях: это и есть тизер десятого урока про «префиксы /v1 и /v2 рядом с одной базой обработчиков». И обратите внимание на порядок: HTTPErrorHandler назначен ПОСЛЕ дерева маршрутов — ему всё равно, он перехватывает ошибки в момент запроса, а не регистрации.
Общие middleware на поддерево: что это даёт на практике#
Слой bearerAuth на /api — не экономия строк, а смена точки принятия решения. Во-первых, невозможно забыть: новый маршрут, добавленный в v1 или v2, защищён ещё до того, как его написали — копия слоя прилетает из копилки группы при каждой регистрации. Во-вторых, пользователь доступен везде: bearerAuth кладёт имя пользователя под ключ ctxUserKey, и любой обработчик обеих версий достаёт его одинаково — контракт «всё под /api аутентифицировано» выполняется архитектурой, а не дисциплиной. В-третьих, изоляция: /healthz и /metrics, зарегистрированные прямо на e, в группу не входят и токена не требуют — глобальный e.Use(bearerAuth(...)) задел бы их все, и пришлось бы вырезать исключения скиппером, как в восьмом уроке.
Теперь эффект от трюка g.Use, который мы отложили. Catch-all-перехватчики зарегистрированы через g.Add — значит, несут групповые слои. Роутер гарантированно находит их для любого несовпавшего пути под префиксом — значит, слои исполняются. Практический вывод: 404 под /api идёт ЧЕРЕЗ bearerAuth. Запрос без токена на /api/v1/missing получит 401 Unauthorized от аутентификации, а не 404 Not Found от роутера: несуществующие маршруты вашего закрытого API не раскрываются неаутентифицированным клиентам. Многие команды считают это фичей, а не побочным эффектом — но знать о ней обязан каждый.
Версии api echo: v1/v2 над одной базой обработчиков#
Схема «подгруппы над общим префиксом» — это и есть канонический способ строить версии api echo. Один обработчик listTasks регистрируется в v1 и в v2 — код домена не дублируется, багфикс доезжает до обеих версий одним коммитом. Вся разница версий живёт в слоях и перехватчиках уровня группы: v2 получила versionTag, v1 может получить свой слой-баннер с предупреждением об устаревании, у v2 может быть более строгий лимит запросов или другой сериализатор — каждая версия настраивает свой конфиг на своём уровне, не трогая соседей. Это и есть «изоляция конфигов между версиями», обещанная десятым уроком: различия собираются в слои на границе версии, а не размазываются if-ами по обработчикам.
У схемы есть и оборотная сторона — те самые ловушки сквозных зависимостей. Общий обработчик означает общий эволюционный груз: добавив поле в структуру задачи, вы измените ответ обеих версий сразу — если это недопустимо, различия придётся поднимать в слои раньше, чем хочется. Проверка версии внутри обработчика по c.Path() — запах: версия должна приходить слоем на границе, а не разбором пути в домене. И деприкация делайте честной: v1, помеченная устаревшей, — это всё та же подгруппа, которой добавили слой-баннер и дату заката, а не вторая копия всего дерева. Когда-нибудь v1 удалится — удалением двух строк регистрации.
versionTag и curl-матрица канона
GO// versionTag — фабрика слоя версии: конфигурируется номером
func versionTag(v string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// помечаем ответ версией — клиенты v2 видят источник
c.Response().Header().Set("X-API-Version", v)
return next(c)
}
}
}
// curl -H "Authorization: Bearer dev-token-42" localhost:1323/api/v1/tasks
// → 200, список задач
// curl -H "Authorization: Bearer dev-token-42" localhost:1323/api/v2/tasks
// → 200, тот же список + заголовок X-API-Version: v2
// curl localhost:1323/api/v1/missing
// → 401 Unauthorized: catch-all группы несёт bearerAuth
// curl -H "Authorization: Bearer dev-token-42" -X POST localhost:1323/api/v1/tasks
// → 404, а не 405 — почему, разбираем нижеФабрика versionTag — три ступени из восьмого урока: тело фабрики один раз при регистрации группы v2, слой func(next) и замыкание, ставящее заголовок до next(c). Заголовок X-API-Version получает каждый ответ поддерева v2 — дешёвый способ дать клиенту знать, с кем он говорит. Curl-матрица проверяет все сюжеты урока разом. Про песочницу: у курса Echo её нет — runner-ов в уроке не будет, код исполняется локально: go mod init, go get github.com/labstack/echo/v4, go run main.go и четыре команды curl выше. Обратите внимание на последнюю строку: с валидным токеном POST на GET-маршрут внутри группы с middleware возвращает 404 — следующий блок объясняет почему.
Проверка: 404 без токена
В каноне /api закрыт bearerAuth, v1 := api.Group("/v1"). Что вернёт сервер на curl без заголовка Authorization по адресу /api/v1/missing?
404, 405 и группы: приоритет перехватчика#
Соберём связку с шестым и десятым уроками. 404 и 405 в Echo — не магия, а обработчики: NotFoundHandler возвращает ErrNotFound, MethodNotAllowedHandler — ErrMethodNotAllowed со списком методов в заголовке Allow из контекста (ContextKeyHeaderAllow). Роутер решает, какой из них отдать, в методе Find — и вот его точное правило из комментария router.go: user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler. Перехватчик 404, зарегистрированный на узле дерева, СТАВИТСЯ ВЫШЕ проверки методов: если поиск привёл в узел с notFoundHandler, вы получите 404, даже когда путь совпал, а метод нет.
Теперь соедините это с трюком g.Use — и станет ясно, почему curl-матрица вернула 404 на POST к GET-маршруту. Любая группа с middleware зарегистрировала себе catch-all — а значит, для каждого пути под префиксом существует узел с notFoundHandler, и приоритет 404 над 405 срабатывает прежде, чем роутер доберётся до MethodNotAllowedHandler. Группового аналога NoMethod у Echo нет — его место занял именно этот приоритет. Вне групп с middleware (или там, где перехватчик переопределён по-своему через g.RouteNotFound(path, h)) всё работает по правилам десятого урока: 405, Allow, и обе ошибки едут в ваш ErrorHandler тем же общим потоком — конверт {error, code} применяется и к ним.
Порядок слоёв при вложенности
Прочитайте код и выберите, что он выведет:
api := e.Group("/api")
api.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { fmt.Println("A"); return next(c) }
})
v1 := api.Group("/v1", func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { fmt.Println("B"); return next(c) }
})
v1.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { fmt.Println("C"); return next(c) }
})
v1.GET("/tasks", func(c echo.Context) error { fmt.Println("H"); return nil })
// запрос GET /api/v1/tasks — что напечатает сервер?Имена и реверс: e.URI против конкатенации строк#
У каждого маршрута в Echo есть имя, и берётся оно автоматически: при регистрации e.add вычисляет handlerName(handler) — через runtime.FuncForPC это полное имя функции-обработчика вроде main.getTask. Отдельного метода «переименовать маршрут» в v4.15 нет: имя — это название вашей функции, и это честно фиксирует, что реверс работает по обработчикам, а не по строковым ярлыкам. Реверс выполняет e.URI(handler, params...) — «URI generates an URI from handler»: имя обработчика превращается в e.Reverse(name, params...), который ищет маршрут по имени в списке и подставляет параметры вместо :id-заглушек. e.URL — просто алиас того же метода.
При чём здесь группы при прямом отношении к путям: маршрут хранится УЖЕ с полным префиксом (вспомните g.prefix+path из g.Add), поэтому реверс вернёт /api/v1/tasks/42 без единой конкатенации с вашей стороны. Для версий это особенно ценно: e.URI(getTask, "42") внутри логики v2 вернёт адрес версии v2 — ссылки собираются из факта регистрации, а не склеиваются руками, и переезд обработчика между поддеревьями не ломает заголовки Location. Это базовое имя-неймспейс курса: достаточно, чтобы перестать строить URL строковой конкатенацией, а тонкости шаблонов путей вернутся в двенадцатом уроке вместе со статикой.
Реверс: Location без ручных склеек
GO// подписи из echo.go: имя маршрута вычисляется при регистрации
// name := handlerName(handler) — в теле e.add
// func (e *Echo) URI(handler HandlerFunc, params ...interface{}) string
// func (e *Echo) Reverse(name string, params ...interface{}) string
// e.URL — alias для URI (тот же вызов под другим именем)
// обработчик createTask версии v2 после создания задачи:
func createTask(c echo.Context) error {
// ... создали задачу с id = 42
loc := e.URI(getTask, "42") // /api/v2/tasks/42 — имя из функции
c.Response().Header().Set("Location", loc)
return c.JSON(http.StatusCreated, task)
}
// e.Reverse работает и по имени, если оно известно как строка:
loc2 := e.Reverse("main.getTask", "42") // тот же результатe.URI принимает обработчик и вычисляет имя сам: handlerName через runtime.FuncForPC возвращает main.getTask — так маршрут был зарегистрирован в e.add, так его найдёт e.Reverse в списке r.routes. Подстановкой параметров занимается роутер: он проходит по символам сохранённого пути /api/v2/tasks/:id и вставляет 42 на место :id. Обратите внимание, что createTask знает только СВОЮ версию — полный префикс приехал из группы ещё на регистрации. Реверс по строковому имени e.Reverse("main.getTask", ...) работает так же, но связывает код с названием функции — при переименовании обработчика такая строка сломается тихо, вернув пустой путь.
Проверка: что вернёт реверс
getTask зарегистрирован вызовом v1.GET("/tasks/:id", getTask) внутри v1 := api.Group("/v1") и api := e.Group("/api"). Что вернёт e.URI(getTask, "42")?
Три фреймворка, одна идея#
Идея «ветка пути с общими слоями» универсальна, но реализуется по-разному, и контраст полезнее копирования. В Echo группа — тонкий регистратор: собственного хранилища маршрутов у неё нет, каждый g.Add немедленно кладёт маршрут с полным путём и склеенными слоями в общий роутер, а сама Group остаётся копилкой префикса и слоёв. В fiber мы собирали те же деревья через app.Group и сталкивались с Router-интерфейсом третьей версии — урок fiber про группы маршрутов покажет этот контраст с другой стороны, повторять его факты здесь не буду. Gin разбирает RouterGroup с BasePath и отдельными NoRoute/NoMethod в своём уроке про группы маршрутов — а вот у Echo группового NoMethod нет вовсе: как вы только что видели, его место в группах с middleware занял приоритет 404-перехватчика из router.Find. Если читали тот урок gin — обратите внимание на это различие, оно самое заметное в тройке.
Первая: общий срез слоёв. g.Add намеренно копирует слои в свежий срез — комментарий в исходнике предупреждает, что иначе поздние регистрации перезаписали бы слои ранних. Но ваш собственный код, разделяющий один срез между группами и дописывающий в него append-ом, этой защиты не получает — копируйте явно. Вторая: срез снимается в момент регистрации. Use после маршрута, api.Use после создания v1 — слой уже никуда не доедет; дерево групп стройте сверху вниз, слои — до маршрутов. Третья: версия не должна угадываться в домене. Проверки «это v1?» по c.Path() внутри обработчика рассыпаются при первом переезде маршрута — различия версий живут слоями на границе группы, а домен остаётся общим.
Соберите дерево канона
Заполните пропуски: общий слой аутентификации на /api, создание подгруппы, регистрация маршрута по id и свой 404 на поддереве.
Соберите main() канона с деревом версий
Расставьте строки в правильном порядке: Echo первой, логгер до ErrorHandler (замыкание его ловит), глобальные Use до групп, дерево групп сверху вниз, запуск последним.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: группы и версии
Что дальше: статика и шаблоны#
Слой организации маршрутов собран. e.Group — трёхстрочный конструктор над копилкой слоёв и префиксом; g.Add склеивает путь и свежий срез слоёв, applyMiddleware оборачивает их вокруг обработчика на каждый запрос; g.Use гарантирует исполнение слоёв catch-all-ом и меняет правила 404/405 внутри поддерева; вложенные группы строят дерево /api → /v1 → /v2 над одной базой обработчиков; реверс через e.URI возвращает полные адреса версий по имени функции. Канон на :1323 теперь выглядит как настоящий API: защищённое поддерево /api, две версии рядом, свой 404 на v1 и конверт ошибок из десятого урока поверх всего.
Двенадцатый урок уводит курс от JSON к HTML: статика и шаблоны — e.Static для файловых деревьев, Renderer и Template-интерфейс, html/template с автограничением. Группы и там пригодятся: /assets и страницы сайта удобно держать отдельными поддеревьями, а ErrorHandler из десятого урока уже умеет рисовать HTML-ветку по префиксу пути. Пока проверьте себя карточками, пересоберите fill и order и прогоните curl-матрицу канона. Навигация по курсу — на странице курса Echo; порядок цепочек и звенья — в уроке про middleware и цепочки; ErrorHandler и конверт {error, code} — в уроке про HTTPError; а bearerAuth и фабрики слоёв, которыми кормится весь канон, родились в восьмом уроке про кастомные middleware — загляните в него перед домашним заданием.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- e.Group(prefix, m...) — трёхстрочный конструктор echo.go: создаёт Group{prefix, echo} и зовёт g.Use(m...). Группа — регистратор, не роутер: маршруты через g.Add уходят в общий роутер Echo полным путём g.prefix+path со склеенными слоями.
- g.Add копирует слои в свежий срез (групповые первыми, затем маршрутные) — страховка от того, что поздние регистрации перезапишут слои ранних; замыкание маршрута зовёт applyMiddleware на каждый запрос.
- Полный порядок звеньев: e.Pre до роутера, глобальные e.Use после роутера, слои группы, слои маршрута, обработчик — родительские снаружи дочерних, групповые снаружи маршрутных.
- g.Use после append регистрирует g.RouteNotFound("", NotFoundHandler) и ("/*", NotFoundHandler) — catch-all-гарантию исполнения групповых middleware даже на несуществующих путях: 404 под /api идёт через bearerAuth и без токена вернёт 401.
- Внутри группы с middleware приоритет у 404-перехватчика: router.Find ставит notFoundHandler выше 405 (MethodNotAllowedHandler с Allow); свой 404 поддерева — g.RouteNotFound("/*", h) с наследованием слоёв; все ошибки едут в общий ErrorHandler.
- Вложенные группы склеивают префиксы и наследуют слои (родительские первыми) в момент создания; Use после создания подгруппы или регистрации маршрута до адресата не доезжает — срез снимается при регистрации.
- Версии v1/v2 — подгруппы над общим /api: одна база обработчиков, изоляция конфигов слоями версии (versionTag → X-API-Version), деприкация слоем-баннером; e.URI(handler, params...) собирает полный URL версии из имени функции-обработчика.
Домашнее задание
Соберите канон урока на :1323: e.Group("/api", bearerAuth с dev-token-42), поддеревья /v1 и /v2 над общими listTasks/getTask, versionTag("v2") и свой 404 на v1 через g.RouteNotFound. Первое: прогоните curl-матрицу — обе версии с токеном и без; несуществующий путь в v1 без токена (ожидайте 401, а не 404 — и объясните себе, какой механизм это обеспечил) и с токеном (404 из вашего перехватчика). Второе: зарегистрируйте в v2 один маршрут ДО versionTag и докажите curl-ом, что заголовок X-API-Version на нём не появился, а на catch-all той же группы — появился. Третье: создайте v3 := api.Group("/v3"), затем вызовите api.Use(...) и опишите словами, на какие маршруты слой доехал, а на какие нет, сверяясь с механикой свежего среза в g.Add. Четвёртое: в заглушке POST-маршрута верните Location через e.URI и убедитесь, что ссылка собралась с полным префиксом версии — без единой конкатенации строк.
Личные заметкиПусто
Вопросы и ответы по уроку
Группа Echo — это отдельный роутер?
Нет. В v4.15 у инстанса Echo роутер один — структура Router из router.go, «registry of all registered routes». Group хранит только prefix, ссылку на Echo и срез middleware: каждый g.Add немедленно регистрирует маршрут в общем роутере полным путём g.prefix+path со склеенными слоями. Собственные роутеры появляются в единственном случае — у виртуальных хостов: e.Host(name, m...) создаёт новый роутер в карте e.routers и возвращает Group с заполненным полем host.
Почему POST на существующий GET-маршрут внутри группы с middleware возвращает 404, а не 405?
Потому что g.Use группы зарегистрировал catch-all-перехватчик на "/*", несущий групповые слои. В router.Find у перехватчика 404 приоритет перед проверкой методов — дословно: user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler. Слои группы при этом исполняются. Отдельного NoMethod на группу у Echo нет; вне групп с middleware действуют правила десятого урока: 405 Method Not Allowed с заголовком Allow из ContextKeyHeaderAllow.
Как держать две версии API, не копируя обработчики?
Подгруппы над общим префиксом: v1 := api.Group("/v1") и v2 := api.Group("/v2") наследуют слои /api, и одна база обработчиков регистрируется в обеих. Различия версий — только в слоях уровня группы: у v2 свой versionTag с заголовком X-API-Version или более строгий лимит, у v1 — слой-баннер деприкации с датой заката; свой 404 тоже можно настроить по версии через g.RouteNotFound. Ссылки на ресурсы собирайте через e.URI — реверс вернёт полный адрес нужной версии по имени функции-обработчика, без ручных строковых склеек.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload