---
title: "Группы и версии API: e.Group, общие middleware и поддеревья v1/v2"
description: "echo группы: e.Group и вложенные поддеревья, общие middleware на префиксе, версии api v1/v2 над одной базой обработчиков и RouteNotFound на группу."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/11-gruppy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Группы и версии API: e.Group, общие middleware и поддеревья v1/v2

*Курс «Echo: минималистичный быстрый фреймворк», урок 11 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/11-gruppy/*

echo группы — это слой организации маршрутов: e.Group(prefix, m...) возвращает *Group, чьи GET/POST/Add приклеивают префикс к каждому пути, а Use копит слои для всего поддерева. Мы пользовались им три урока подряд не задумываясь: e.Group("/api", bearerAuth(...)) в каноне восьмого урока выглядел строчкой конфигурации, а [десятый урок](/courses/echo/lessons/10-httperror/) обещал раскрыть, что за ней стоит. Теперь разбираем по исходникам group.go: Group — не роутер, а регистратор; маршруты уходят в общий роутер Echo с уже склеенными слоями; g.Use прячет трюк с catch-all, который делает middleware группы гарантированными — даже на 404. На этом слое строятся версии api echo: поддеревья /v1 и /v2 над одной базой обработчиков, с изоляцией конфигов между версиями и честной историей про то, что g.Use делает с 404 и 405. В [уроке fiber про группы маршрутов](/courses/fiber/lessons/15-gruppy-marshrutov/) мы собирали то же дерево через app.Group — в конце урока сравним подходы, а обещанные десятым уроком ловушки сквозных зависимостей разберём по ходу: от слоя, подключённого задним числом, до 404, который внезапно требует токен.

### 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(...))?

- ✅ Создаётся структура Group с префиксом и ссылкой на Echo, и сразу вызывается g.Use(m) — ни один маршрут ещё не зарегистрирован
- Создаётся отдельный Router со своим деревом, и Echo переключается на него для путей /api/*
- Регистрируется маршрут-заглушка на /api, отвечающая 404, — он потом заменится настоящими
- Клонируется вся структура Echo вместе с middleware-стеком и настройками сервера

Конструктор — три строки из echo.go: создать Group{prefix, echo} и позвать g.Use(m...). Маршрутов в этот момент ноль, роутер новый не создаётся: в Echo на инстанс один общий роутер (отдельные появляются только у хостов через e.Host). bearerAuth не исполняется и даже не вызывается — он лишь дописывается в срез g.middleware, откуда позже скопируется в каждый маршрут поддерева. Вся «магия группы» — это две вещи: конкатенация префикса при регистрации и копилка слоёв.

### Механика 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, перехватчики появятся только при первом настоящем слое.

### Слой подключили после маршрута

```go
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?
```

**Что выведет программа?**

- ✅ Только на /api/users: g.Add скопировал срез слоёв в момент регистрации, у tasks он был пуст
- На обоих: g.Use добавляет слой всем маршрутам группы задним числом
- Ни на одном: вызов g.Use после регистрации маршрутов игнорируется целиком
- Только на /api/tasks: первый маршрут группы всегда приоритетнее

g.Add читает g.middleware в момент регистрации и копирует его содержимое в свежий срез конкретного маршрута. /tasks успел зарегистрироваться с пустой копилкой — слой tag в его цепочку не попал и не попадёт никогда; /users регистрировался после Use и слой получил. Тонкий второй акт того же сюжета: на НЕсуществующий путь /api/missing слой tag сработает — catch-all, зарегистрированный g.Use, несёт слои группы. Итог звучит как парадокс: middleware группы исполняются на 404-пути, но не на реальном маршруте, зарегистрированном до g.Use. Правило гигиены: сначала все Use группы, потом маршруты.

### Вложенные группы: префиксы склеиваются, слои наследуются

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?

- ✅ 401 Unauthorized от bearerAuth: g.Use зарегистрировал catch-all с групповыми слоями, и middleware гарантированно исполняются
- 404 Not Found: путь не существует — до middleware дело не доходит
- 405 Method Not Allowed с заголовком Allow: GET-маршрут в дереве есть
- 500 Internal Server Error: несуществующий путь — ошибка приложения

Ровно гарантия из комментария к g.Use: catch-all-маршруты («404 is a safe way to emulate route match») несут групповые слои и совпадают с любым путём под префиксом — роутер их находит, bearerAuth исполняется и отвечает 401 до всякой проверки существования маршрута. 405 здесь взяться неоткуда: метод не проверяется на перехватчике 404. А конверт ошибки клиент получит из ErrorHandler десятого урока — {"error":"Unauthorized","code":401}, — потому что NewHTTPError из bearerAuth едет тем же потоком, что и все ошибки приложения.

### 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} применяется и к ним.

### Порядок слоёв при вложенности

```go
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 — что напечатает сервер?
```

**Что выведет программа?**

- ✅ A B C H: срез слился как [A, B, C] — родительские первыми, applyMiddleware оборачивает с конца
- B A C H: слои родителя исполняются после собственной Use-цепочки группы
- C B A H: самый свежий слой оказывается самым внешним
- A B H: v1.Use не влияет на подгруппу, созданную раньше этого вызова

Механика g.Group из этого блока: срез подгруппы наполняется слоями родителя СНАЧАЛА — [A] плюс [B] при создании v1, затем v1.Use дописывает C — итог [A, B, C]. Маршрут копирует этот срез, и applyMiddleware оборачивает обработчик с конца: C ближе всего к H, B поверх, A снаружи — исполнение идёт A, B, C, H. Вариант с «A B H» — ловушка на путаницу с зеркальной ситуацией: v1.Use вызван ДО регистрации маршрута, поэтому C успел попасть в копилку; не успел бы — если бы маршрут регистрировался раньше Use, как в первом predict-задании.

### Имена и реверс: 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")?

- ✅ /api/v1/tasks/42 — маршрут хранится полным путём, реверс собирает его со всеми префиксами групп
- /tasks/42 — группы участвуют только в регистрации, а при реверсе префиксы теряются
- /api/tasks/42 — подгруппа перезаписывает префикс родителя своим
- Пустую строку и ошибку: реверс в Echo работает только с e.Name

g.Add зарегистрировал маршрут как g.echo.add(g.host, method, g.prefix+path, ...) — в дереве роутера лежит полный путь /api/v1/tasks/:id вместе с именем main.getTask. e.URI вычисляет это имя из переданного обработчика и зовёт e.Reverse, который подставляет параметры — все префиксы групп возвращаются сами. Вариант с перезаписью префикса — ловушка на механику g.Group: подгруппа склеивает g.prefix+prefix, ничего не перезаписывая. Вариант с e.Name — ловушка на API других версий и фреймворков: в v4.15 имя маршрута выводится из названия функции-обработчика автоматически.

### Три фреймворка, одна идея

Идея «ветка пути с общими слоями» универсальна, но реализуется по-разному, и контраст полезнее копирования. В Echo группа — тонкий регистратор: собственного хранилища маршрутов у неё нет, каждый g.Add немедленно кладёт маршрут с полным путём и склеенными слоями в общий роутер, а сама Group остаётся копилкой префикса и слоёв. В fiber мы собирали те же деревья через app.Group и сталкивались с Router-интерфейсом третьей версии — [урок fiber про группы маршрутов](/courses/fiber/lessons/15-gruppy-marshrutov/) покажет этот контраст с другой стороны, повторять его факты здесь не буду. 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 на поддереве.

```go
func main() {
        e := echo.New()
        e.Use(middleware.RequestID())

        // /api — поддерево с общей аутентификацией обеих версий
        api := e.Group("/api", ___)

        v1 := api.___("/v1")
        v1.GET("/tasks", listTasks)
        v1.___("/tasks/:id", getTask)

        // свой 404 на поддереве: наследует bearerAuth автоматически
        v1.___("/*", func(c echo.Context) error {
                return echo.NewHTTPError(http.StatusNotFound, "нет маршрута в v1")
        })

        e.Logger.Fatal(e.Start(":1323"))
}
```

Подсказка: На /api вешается bearerAuth с канонным dev-token-42; подгруппа создаётся методом Group; задача по id читается GET-ом; свой 404 на поддереве — RouteNotFound с wildcard-путём.

Правильные ответы в пропусках: `bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"})`, `Group`, `GET`, `RouteNotFound`

### Соберите main() канона с деревом версий

Расставьте строки в правильном порядке: Echo первой, логгер до ErrorHandler (замыкание его ловит), глобальные Use до групп, дерево групп сверху вниз, запуск последним.

Правильный порядок строк:

1. `func main() {`
2. `  e := echo.New()`
3. `  logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))`
4. `  e.HTTPErrorHandler = apiErrorHandler(logger)`
5. `  e.Use(middleware.RequestID())`
6. `  api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))`
7. `  v1 := api.Group("/v1")`
8. `  v1.GET("/tasks/:id", getTask)`
9. `  e.Logger.Fatal(e.Start(":1323"))`
10. `}`

### Карточки: группы и версии

- **e.Group(prefix, m...) и структура Group** — echo.go v4.15: g = &Group{prefix: prefix, echo: e}; g.Use(m...) — три строки, нового роутера нет. Поля Group: common, host, prefix, echo, middleware []MiddlewareFunc. Док: набор подмаршрутов с общими middleware, отделённых от родителя, но наследующих от него. Маршруты уходят через g.echo.add в общий роутер Echo полным путём — Group только регистратор и копилка слоёв.
- **Механика g.Add и порядок слоёв** — Свежий срез: m = g.middleware + слои маршрута (копия — страховка от перезаписи поздними add). Затем g.echo.add(g.host, method, g.prefix+path, handler, m...). Замыкание маршрута зовёт applyMiddleware на каждый запрос — оборачивает с конца, первый элемент среза самый внешний. Полный порядок: e.Pre → роутер → глобальные e.Use → слои группы → слои маршрута → обработчик.
- **g.Use и catch-all-гарантия** — g.Use: append в копилку, затем — при непустом списке — g.RouteNotFound("", NotFoundHandler) и ("/*", NotFoundHandler). Комментарий: групповые слои исполняются только с найденным маршрутом, catch-all («404 is a safe way to emulate route match») гарантирует исполнение. Следствие: 404 под префиксом идёт через bearerAuth — без токена /api/v1/missing вернёт 401, не 404.
- **Вложенные группы** — g.Group(prefix, m...): префиксы склеиваются g.prefix+prefix, слои сливаются в свежем срезе — родительские первыми, host наследуется. api := e.Group("/api"); v1 := api.Group("/v1") — маршрут v1.GET("/tasks") ляжет на /api/v1/tasks. Ловушка: слои родителя копируются в момент создания подгруппы; api.Use ПОСЛЕ v1 := api.Group до её маршрутов не доедет.
- **404/405 в группе** — router.Find: «user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler». Группа с Use имеет catch-all — приоритет 404 над 405: POST на GET-маршрут внутри группы даёт 404 с исполненными слоями группы. Свой 404 поддерева — g.RouteNotFound("/*", h), метод-маркер echo_route_not_found, слои наследуются. Все ошибки едут в общий ErrorHandler из десятого урока.
- **Версии v1/v2 и реверс имён** — Версии — подгруппы над общим /api: одна база обработчиков, различия — слоями версии (versionTag ставит X-API-Version), свой 404 и слой-баннер деприкации на устаревающую версию. Имя маршрута = handlerName(handler) через runtime.FuncForPC («main.getTask»); e.URI(handler, params...) → e.Reverse подставляет параметры в полный путь — /api/v2/tasks/42 без ручных склеек; e.URL — алиас.

### Что дальше: статика и шаблоны

Слой организации маршрутов собран. 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](/courses/echo/); порядок цепочек и звенья — в [уроке про middleware и цепочки](/courses/echo/lessons/06-middleware/); ErrorHandler и конверт {error, code} — в [уроке про HTTPError](/courses/echo/lessons/10-httperror/); а bearerAuth и фабрики слоёв, которыми кормится весь канон, родились в восьмом уроке про кастомные middleware — загляните в него перед домашним заданием.

## Коротко: что запомнить

- 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 — реверс вернёт полный адрес нужной версии по имени функции-обработчика, без ручных строковых склеек.
