Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~16 мин +50 XP

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

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, который внезапно требует токен.

Содержание урока · 14 разделов

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 — обратите внимание на это различие, оно самое заметное в тройке.

Соберите дерево канона

Заполните пропуски: общий слой аутентификации на /api, создание подгруппы, регистрация маршрута по id и свой 404 на поддереве.

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")) }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

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 только регистратор и копилка слоёв.

1 / 6

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

Слой организации маршрутов собран. 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 · Группы и версии API: e.Group, общие middleware и поддеревья v1/v2)

Вопросы и ответы по уроку

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

GORM25 уроков~14 чпрактика25 уроков уже доступны

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload