---
title: "Группы маршрутов"
description: "Группы маршрутов gin: RouterGroup и BasePath, вложенные группы, middleware на группу, версии API v1 и v2, NoRoute и NoMethod вместо дефолтных 404 и 405."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/10-gruppy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Группы маршрутов

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

Заготовку api := r.Group("/api/v1") мы мельком видели во втором уроке, а в седьмом вешали на группы middleware — теперь полный разбор. В gin группы маршрутов — это не синтаксический сахар, а самостоятельный тип RouterGroup с собственным basePath, указателем на движок и цепочкой звеньев: движок сам является группой, и именно поэтому у него есть методы GET и Group. Построим дерево /api → /v1 → /admin с requireAPIKey-заглушкой на админке, разведём версии API v1 и v2 через общие хендлеры и заменим дефолтные «404 page not found» и «405 method not allowed» на свои NoRoute и NoMethod с JSON-конвертом. А [валидацию из прошлого урока](/courses/gin/lessons/09-validaciya/) заберём с собой — в группах она не меняется ни на букву.

### gin группы маршрутов: RouterGroup вместо копипасты

Пока приложение маленькое, пути пишут руками: r.GET("/ping"), r.POST("/todos"). Как только эндпоинов становится больше десяти, начинается ерунда: префикс /api повторяется в каждом маршруте, переименовать его — значит пройтись поиском по всему проекту, а общий для половины путей middleware приходится дублировать аргументами. В gin группы маршрутов закрывают все три проблемы разом: r.Group("/api") возвращает значение типа *gin.RouterGroup, у которого есть те же методы GET, POST, PUT, DELETE и Use, что и у движка, — только пути внутри пишутся относительно префикса группы. Мы уже трогали тему дважды: во втором уроке мелькнула заготовка api := r.Group("/api/v1") с фигурными скобками — они, как тогда говорилось, чисто визуальный контейнер, Go их игнорирует; в седьмом уроке на группу вешали requireAPIKey. Важная деталь, которую легко упустить: Group ничего не регистрирует. Вызов r.Group("/api") не добавляет в дерево маршрутов ни одной записи — это лишь заготовка со склеенным basePath и пустым списком звеньев; пока вы не вызвали api.GET(...), группе неоткуда отвечать на запросы. Поэтому группы можно создавать впрок, пустая группа безвредна — она просто не участвует в обработке, — а комментарии к каждой ветке дерева пишут прямо рядом с объявлением. Фигурные скобки после Group — по желанию: они визуально ограничивают «зону» группы в файле, компилятору на них всё равно.

### Базовая группа: все задачи под /api

```go
r := gin.Default()

// Корень API: все пути ниже — относительные.
api := r.Group("/api")

api.GET("/todos", listTodos)          // GET /api/todos
api.POST("/todos", createTodo)        // POST /api/todos
api.GET("/todos/:id", getTodo)        // GET /api/todos/:id
api.DELETE("/todos/:id", deleteTodo)  // DELETE /api/todos/:id

if err := r.Run(":8080"); err != nil {
        panic(err)
}
```

Слева в комментариях — фактические пути, которые получит клиент. createTodo здесь — тот же хендлер из уроков про привязку и валидацию: ShouldBindJSON с TodoInput, проверка тегов, конверт ответа — ни одна строка не поменялась при переезде в группу. Единственное правило — относительные пути: /todos внутри группы с префиксом /api даёт /api/todos. Напишете /api/todos — получите /api/api/todos, и gin не предупредит: оба маршрута спокойно зарегистрируются.

### Анатомия RouterGroup: basePath, движок и корень

Откройте в исходниках файл routergroup.go: тип RouterGroup — это четыре поля. Handlers HandlersChain — список middleware, приклеенных к группе; basePath string — уже вычисленный префикс; engine *Engine — указатель на движок, чтобы группа могла регистрировать маршруты в общем дереве; root bool — флаг «я корневая группа». На этом держится трюк, из-за которого у движка есть методы групп: структура Engine первым полем встраивает RouterGroup, и конструктор New() инициализирует это поле со basePath "/" и root: true. Поэтому r := gin.Default() — это *gin.Engine, но r.GET и r.Group работают: методы встроенной группы поднимаются на поверхность. Создание вложенной группы — это создание новой структуры: сигнатура Group(relativePath string, handlers ...HandlerFunc) *RouterGroup, а Group() возвращает &RouterGroup{Handlers: group.combineHandlers(handlers), basePath: group.calculateAbsolutePath(relativePath), engine: group.engine} — механику склейки звеньев мы разобрали в седьмом уроке, здесь важен basePath: он вычисляется сразу, при создании, функцией joinPaths — это path.Join плюс сохранение хвостового слэша, если относительный путь кончался слэшем. path.Join чистит двойные слэши и точечные сегменты, поэтому "/api/" и "/v1" склеятся в аккуратное /api/v1. Итоговый префикс можно распечатать: метод BasePath() возвращает group.basePath — удобно для стартового лога «зарегистрированы маршруты: …» и для проверок в тестах. Сами группы — маленькие дешёвые структуры: смело заводите по одной на смысловую зону, никакой платы за лишний уровень вложенности нет.

### Проверка: хвостовой слэш в префиксах

**Вопрос:** api := r.Group("/api/"); v1 := api.Group("/v1/"). Что вернёт v1.BasePath()?

- ✅ "/api/v1/" — path.Join склеил префиксы и убрал лишний слэш посередине, а хвостовой слэш относительного пути сохранил
- "/api//v1//" — basePath это конкатенация строк, слэши gin не чистит
- "/api/v1" — gin всегда отрезает хвостовой слэш у basePath
- Панику: basePath группы не может заканчиваться слэшем

joinPaths — это path.Join плюс одна проверка: если relativePath кончается слэшем, а склейка — нет, слэш дописывается. path.Join("/api/", "/v1/") даст /api/v1, после проверки — /api/v1/. Хвостовой слэш в basePath не ошибка: на запросах разницу разрулит RedirectTrailingSlash редиректом. Хотите единообразия — просто не пишите хвостовые слэши в префиксах групп.

### Вложенные группы: /api/v1/admin за три строки

Сила групп — в композиции. api := r.Group("/api") — корень; v1 := api.Group("/v1") — версия; admin := v1.Group("/admin", checkAPIKey("…")) — защищённая ветка. basePath каждого следующего уровня приклеивается к basePath родителя: /api, /api/v1, /api/v1/admin. Вложенные группы наследуют и звенья: маршруты admin несут всё, что объявлено на api, на v1 и в аргументах собственной Group, — общий срез склеивается в момент регистрации маршрута, поэтому порядок «звенья до маршрутов» важен, и мы проверим это предсказанием ниже. Практика больших проектов: дерево собирается в одной функции buildAPI, каждая версия — в своей функции-регистраторе, тогда границы версий видны одним взглядом, а git-история не перемешивает правки разных зон. Глубина вложенности ничем не ограничена — /api/v1/admin/reports/weekly это четыре законных уровня; но каждый уровень должен иметь право на собственный middleware или собственный смысл, иначе это просто лишний отступ. Типичная ошибка новичка — забыть про относительность: v1.GET("/api/v1/todos", …) внутри группы даст путь /api/v1/api/v1/todos. gin не ругается — двойной префикс регистрируется как обычный маршрут и молча ловит запросы. И последнее: префикс можно и «перепрыгнуть», потому что joinPaths исполняет path.Join, а тот понимает «..» — v1.Group("../admin") даст /api/admin. Работает, но читается ужасно: в продакшене так делать не стоит.

### Поздний Use: кого он коснётся

```go
r := gin.Default()

api := r.Group("/api")

api.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"msg": "pong"})
})

// Режим обслуживания включили «позже»:
api.Use(func(c *gin.Context) {
        c.AbortWithStatusJSON(http.StatusServiceUnavailable,
                gin.H{"err": "maintenance"})
})

api.GET("/info", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"ver": "v1"})
})

// curl localhost:8080/api/ping
// curl localhost:8080/api/info
```

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

- ✅ ping → {"msg":"pong"} (200); info → {"err":"maintenance"} (503): Use подействовал только на маршруты, зарегистрированные после него
- Оба → 503: поздний Use действует на всю группу задним числом
- Оба → 200: Use после регистрации маршрутов игнорируется
- Паника при регистрации /info: после Use добавлять маршруты нельзя

Снимок цепочки делается в group.handle в момент регистрации: /ping склеился с пустым списком звеньев группы, /info — уже с maintenance. Пересчёта задним числом не существует: у RouterGroup нет механизма «пересобрать уже зарегистрированное», в отличие от движка, который умеет пересобирать 404-цепочки (дойдём ниже). Отсюда дисциплина «сначала Use, потом маршруты». Паники тоже нет: Group и Use можно звать в любом порядке до r.Run.

### Middleware на группу: снимок звеньев в момент регистрации

Как вешать звенья на группу, мы отработали в [уроке про кастомные middleware](/courses/gin/lessons/07-kastomnye-middleware/): два способа — аргументы Group и вызовы g.Use, а порядок в цепочке задаёт combineHandlers, склеивающий срезы при регистрации маршрута. Здесь добавим только два следствия, которые в прошлый раз остались за кадром. Следствие первое: снимок звеньев делается в момент вызова api.GET — всё, что добавлено в группу после регистрации маршрута, на него не влияет; api.Use не пересчитывает существующие маршруты. Отсюда правило из предсказания выше: сначала Use, потом маршруты — и держите их в коде именно в таком порядке. Следствие второе: групповые звенья не действуют на 404. Запрос к несуществующему пути /api/v1/nowhere обслуживается цепочкой движка, в которой групповых звеньев нет вовсе — только глобальные и NoRoute-хендлер, о котором речь впереди. Поэтому [глобальный Use из урока про встроенные middleware](/courses/gin/lessons/06-middleware/) видит вообще всё, а групповой — только свои маршруты: requireAPIKey на группе admin не защищает «ничего» — несуществующий путь под /admin получит 404 мимо ключа, и это корректное поведение, а не дыра.

### Практика: дерево /api/v1 с защищённой админкой

```go
// checkAPIKey — requireAPIKey-подобная заглушка: полная версия с c.Set — в уроке 07.
func checkAPIKey(secret string) gin.HandlerFunc {
        return func(c *gin.Context) {
                if c.GetHeader("X-API-Key") != secret {
                        // Конверт из урока про JSON-ответы.
                        c.AbortWithStatusJSON(http.StatusUnauthorized, Envelope{
                                Error: &APIError{
                                        Code:    "UNAUTHORIZED",
                                        Message: "неверный или пустой X-API-Key",
                                },
                        })
                        return
                }
                c.Next()
        }
}

// registerTodoRoutes ставит маршруты задач на ЛЮБУЮ группу:
// один и тот же набор хендлеров для v1 и v2.
func registerTodoRoutes(rg *gin.RouterGroup) {
        rg.GET("/todos", listTodos)
        rg.POST("/todos", createTodo) // внутри — ShouldBindJSON(TodoInput) из урока 09
        rg.GET("/todos/:id", getTodo)
}

func buildAPI(r *gin.Engine) {
        api := r.Group("/api")

        v1 := api.Group("/v1") // стабильная версия
        registerTodoRoutes(v1)

        admin := v1.Group("/admin", checkAPIKey("local-secret"))
        admin.GET("/stats", adminStats) // GET /api/v1/admin/stats

        v2 := api.Group("/v2") // следующая версия: слой общий, форматы свои
        registerTodoRoutes(v2)
        v2.GET("/todos/:id/history", todoHistory) // только в v2
}
```

Три уровня вложенности и один переиспользуемый регистратор. checkAPIKey — заглушка гварда: сравнение заголовка и AbortWithStatusJSON конвертом; для админки этого достаточно, а в уроке про JWT на это место встанет настоящая проверка токена. Заметьте: registerTodoRoutes принимает *gin.RouterGroup, а не *gin.Engine, — поэтому ей всё равно, куда её зовут. Хендлеры listTodos, createTodo и getTodo — те же функции, что и в прошлых уроках: группы меняют только адресацию, не логику.

### Версии API: v1 и v2 на одном дереве

Версионирование путями — самый простой и самый gin-идиоматичный способ эволюции API: префиксы v1 и v2 живут в одном дереве маршрутов, одном процессе и одном порту, а группы делают разводку бесплатной. Сценарий: формат ответа Todo поменялся — добавилось поле priority и дата в RFC 3339 вместо строки. Клиентов на старом формате ломать нельзя: v1 замораживается — в неё летят только багфиксы, — а v2 получает собственные хендлеры с новой структурой ответа. Когда логика общая, а отличается только сериализация, выручает функция-регистратор с параметром *gin.RouterGroup: registerTodoRoutes(v1) и registerTodoRoutes(v2) ставят одни и те же хендлеры под оба префикса; когда расходится сам контракт, у версии свои хендлеры, но общий сервис-слой. Чего делать не стоит: ветвиться по версии внутри одного хендлера, выдирая v2 из пути через c.Param — контракт размазывается по коду, и через год никто не скажет, что вообще умеет v1. План вывода версии тоже прописывается маршрутизацией: логируйте трафик по префиксам (BasePath в помощь), отвечайте на v1 заголовком Sunset с датой отключения из звена версии, потом отключайте — дерево маршрутов честно покажет, кто ещё ходит на старую версию. В трекере мы разводим версии так: v1 — стабильная, v2 — с историей изменений задачи; общие хендлеры зарегистрированы обеим версиям, специфичный /todos/:id/history — только второй.

### Проверка: эволюция формата ответа

**Вопрос:** В v2 изменился формат ответа Todo: дата уехала в RFC 3339, добавилось поле priority. Как правильно развести версии в gin?

- ✅ Создать v2 := api.Group("/v2") и зарегистрировать там свои хендлеры — v1 продолжает жить без изменений
- Заменить хендлеры v1 на новые и отвечать клиентам редиректом на новые пути
- Переключать поведение флагом внутри одного хендлера, читая версию из c.Param
- Поднять второй gin.Engine на другом порту — группы для этого не годятся

Группа — дешёвая структура, вложенная версия не стоит ничего: v2 := api.Group("/v2") плюс собственные хендлеры, а общий слой переиспользуется функцией-регистратором с *gin.RouterGroup. Редирект ломает клиентов, которые не follow 301/307, флаг внутри хендлера размазывает контракт, а второй процесс — лишняя эксплуатация там, где одно дерево решает всё. v1 при этом замораживается и выводится по логу трафика.

### NoRoute и NoMethod: свои 404 и 405

Дефолтные ответы движка на промах — тела «404 page not found» и «405 method not allowed» с Content-Type text/plain. Для JSON-API это брак: фронтенд ждёт конверт. Замена — два метода движка, и важно, что именно движка: func (engine *Engine) NoRoute(handlers ...HandlerFunc) и func (engine *Engine) NoMethod(handlers ...HandlerFunc) — у RouterGroup таких методов нет, r в r.NoRoute — это всегда корневой движок. Док-комментарий NoRoute честен: «It returns a 404 code by default» — хендлеры вы подставляете свои, статус остаётся 404. Первое правило: NoRoute заменяет предыдущий набор, а не дополняет — в исходниках присваивание engine.noRoute = handlers, поэтому звенья передавайте одним вызовом (r.NoRoute(jsonNotFound, extra)) или собирайте цепочку заранее; это отличие от Use, который дописывает. Второе: движок пересобирает итоговую цепочку промаха — allNoRoute = combineHandlers(noRoute) — при каждом вызове NoRoute и глобальном Use, поэтому глобальные звенья (Logger, Recovery, requestID) исполняются и на 404: строка [GIN] в логе появится и для промаха. Групповых звеньев там нет — см. прошлый блок. Третье: NoMethod бездействует, пока не включён флаг r.HandleMethodNotAllowed = true — мы упоминали его во втором уроке; без флага запрос с чужим методом валится в NoRoute как обычный 404. С флагом gin ищет путь в деревьях других методов: нашёл — отвечает 405 с заголовком Allow со списком допустимых (требование RFC 7231) и вашим хендлером; не нашёл ни в одном дереве — это уже не «не тот метод», а «нет пути», и правильный ответ — 404 через NoRoute. Дефолтные тела пишутся только если ваш хендлер не записал ничего: статус движок ставит заранее.

### NoRoute и NoMethod с конвертом Envelope

```go
r := gin.Default()
r.HandleMethodNotAllowed = true // без флага NoMethod не вызовется никогда

// Все промахи мимо дерева маршрутов — JSON-конверт вместо text/plain.
r.NoRoute(func(c *gin.Context) {
        c.JSON(http.StatusNotFound, Envelope{
                Error: &APIError{
                        Code:    "NOT_FOUND",
                        Message: "маршрут не найден: " + c.Request.URL.Path,
                },
        })
})

// Путь есть, метод чужой: 405 и Allow движок поставит сам.
r.NoMethod(func(c *gin.Context) {
        c.JSON(http.StatusMethodNotAllowed, Envelope{
                Error: &APIError{
                        Code:    "METHOD_NOT_ALLOWED",
                        Message: "метод не поддержан этим маршрутом",
                },
        })
})

api := r.Group("/api")
v1 := api.Group("/v1")
registerTodoRoutes(v1)
```

Оба вызова — variadic: r.NoRoute(jsonNotFound, extra) соберёт цепочку из нескольких хендлеров, но повторный вызов NoRoute заменит её целиком. Статус движок выбирает до ваших хендлеров, заголовок Allow для 405 ставит маршрутизатор — хендлер пишет только тело. Если хендлер ничего не записал, gin доложит дефолтные «404 page not found» или «405 method not allowed». Эхо пути в Message помогает фронту увидеть опечатку — главное, не отдавать наружу лишних деталей.

### Проверка: 404 мимо ключа

**Вопрос:** Зарегистрированы: r.Use(requestID), admin := v1.Group("/admin", checkAPIKey("…")) и r.NoRoute(jsonNotFound). Запрос GET /api/v1/admin/unknown без ключа — что получит клиент?

- ✅ 404-конверт NOT_FOUND: requestID исполняется, checkAPIKey не зовётся — групповых звеньев в цепочке промаха нет
- 401 UNAUTHORIZED: NoRoute наследует middleware всех групп
- Текст «404 page not found»: NoRoute уже перезаписан
- 500: NoRoute не умеет возвращать JSON

Цепочку промаха движок собирает как allNoRoute = combineHandlers(noRoute): в неё входят только глобальные звенья и NoRoute-хендлеры. Звенья групп admin и v1 попадают в цепочки своих маршрутов в момент их регистрации и к 404 отношения не имеют — ключ проверять нечему. requestID из глобального Use исполняется, jsonNotFound пишет конверт. Это не дыра, а честная семантика: несуществующего маршрута в системе нет.

### 405 или 404: два DELETE-запроса

```go
r := gin.New()
r.HandleMethodNotAllowed = true

api := r.Group("/api")
v1 := api.Group("/v1")
v1.GET("/todos", listTodos)
v1.POST("/todos", createTodo)

r.NoMethod(func(c *gin.Context) { c.String(405, "method!") })

// curl -X DELETE localhost:8080/api/v1/todos
// curl -X DELETE localhost:8080/api/v1/unknown
```

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

- Оба запроса получат 405 «method!»
- ✅ todos → 405 «method!» с Allow: GET, POST; unknown → 404: пути нет ни в одном дереве, срабатывает NoRoute
- todos → 404; unknown → 405 — наоборот
- Оба → 404: в DELETE-дереве маршрутов нет

Флаг включает поиск пути в деревьях остальных методов. Для /api/v1/todos gin нашёл маршрут в GET- и POST-деревьях — собирает Allow, ставит 405 и зовёт NoMethod-цепочку. Для /api/v1/unknown не нашёл ни в одном дереве: это не «не тот метод», а «нет такого пути» — запрос уходит в allNoRoute с честным 404. Поэтому 405 нельзя считать ответом на всё непонятное: он точен только при существующем маршруте.

### Соберите скелет API

Допишите флаг 405, вложенные группы и свои обработчики промахов.

```go
func buildAPI(r *gin.Engine) {
        r.___ = true // чужой метод у существующего пути — честный 405

        api := r.Group("/api") // корень API
        v1 := api.___("/v1")   // версия внутри корня
        admin := v1.___("/admin", checkAPIKey("local-secret"))
        admin.GET("/stats", adminStats)

        r.___(notFound)         // конверт NOT_FOUND на все промахи
        r.___(methodNotAllowed) // конверт METHOD_NOT_ALLOWED
}
```

Подсказка: Флаг движка — HandleMethodNotAllowed; вложенные группы создаются тем же методом Group; два обработчика промахов — NoRoute и NoMethod.

Правильные ответы в пропусках: `HandleMethodNotAllowed`, `Group`, `Group`, `NoRoute`, `NoMethod`

### Соберите путь 405-запроса

DELETE /api/v1/todos при включённом HandleMethodNotAllowed и своих NoRoute/NoMethod: разложите события от curl до строки лога.

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

1. `curl -X DELETE отправляет DELETE /api/v1/todos`
2. `handleHTTPRequest: DELETE-дерево есть, маршрута /api/v1/todos в нём нет`
3. `RedirectTrailingSlash проверен: хвостового слэша нет, редирект не нужен`
4. `HandleMethodNotAllowed = true: gin опрашивает деревья остальных методов`
5. `Путь найден в GET- и POST-деревьях — собран список allowed`
6. `Заголовок Allow установлен: GET, POST (требование RFC 7231)`
7. `c.handlers = engine.allNoMethod — глобальные звенья плюс NoMethod-хендлер`
8. `Групповые звенья v1 и admin в цепочке отсутствуют — 405 не требует ключа`
9. `NoMethod-хендлер пишет конверт METHOD_NOT_ALLOWED со статусом 405`
10. `Клиент получает 405, заголовок Allow и JSON-конверт`
11. `Logger печатает строку [GIN] DELETE /api/v1/todos 405`
12. `Recovery страховал цепочку всё время — как и на любом маршруте`

### RouteGroup или RouterGroup: кто есть кто в исходниках

Названия вокруг групп — постоянный источник путаницы, разберём раз и навсегда. Тип в исходниках называется RouterGroup — файл routergroup.go; варианта RouteGroup в gin нет, это популярная описка из статей. По-русски говорят и «группы роутов gin», и «роутер-группы» — имеется в виду один и тот же *gin.RouterGroup. Отдельного типа «роутер» не существует: gin.Router не скомпилируется. Роутер в разговорной речи — это *gin.Engine, который, как мы выяснили, встраивает RouterGroup со basePath "/" и root: true. Компилятор следит за этим хозяйством через два интерфейса, объявленных в начале routergroup.go. IRoutes — «что умеет любой маршрутный узел»: Use, Handle, Any, GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, Match и статические помощники Static, StaticFS, StaticFile. IRouter — это IRoutes плюс Group: право создавать подгруппы есть только у того, что может быть родителем. Строки var _ IRouter = (*RouterGroup)(nil) и var _ IRouter = (*Engine)(nil) — проверки компиляции: и движок, и группа обязаны удовлетворять интерфейсу. Любопытен Any: он регистрирует путь сразу на девять методов — GET, POST, PUT, PATCH, HEAD, OPTIONS, DELETE, CONNECT, TRACE, — а Match берёт список методов срезом: rg.Match([]string{"GET", "POST"}, "/todos", h). Практический вывод для своего кода: функции-регистраторы принимайте как *gin.RouterGroup — тогда их можно звать и на v1, и на v2, а при необходимости и на корень.

### Слэши, редиректы и что дальше

Три бытовые мелочи, на которых спотыкаются. Первая: ведущий слэш в относительном пути можно не писать — joinPaths исполняет path.Join, и api.Group("v1") даст тот же /api/v1. Вторая: хвостовой слэш. RedirectTrailingSlash включён по умолчанию: если маршрут зарегистрирован как /api/v1/todos, а запрос пришёл на /api/v1/todos/, движок не даст 404, а ответит редиректом — 301 для GET, 307 для остальных методов, сохраняя метод. Удобно, но клиенты ведут себя с редиректами POST по-разному: некоторые библиотеки не follow 307 без явной настройки — лучше не полагаться и держать пути в одном виде. Третья: пути групп и маршрутов именно склеиваются — двойного слэша не будет никогда, path.Join съест и его; а вот «..» в относительном пути реально поднимает ветку выше, что мы уже отмечали как анти-паттерн. Статические файлы — соседняя тема, и её хелперы живут прямо здесь же, в интерфейсе IRoutes: Static, StaticFS, StaticFile, — но это материал урока 11-statika, там и продолжим. А пока зафиксируем скелет, который вы унесёте из урока: buildAPI собирает дерево /api → /v1 и /v2 → /admin с ключом-заглушкой; registerTodoRoutes переиспользует хендлеры между версиями; NoRoute и NoMethod отвечают конвертом; и ни одна строка бизнес-логики при переезде в группы не изменилась.

### Шесть правил групп маршрутов

- r.Group("/api") возвращает новую *gin.RouterGroup: basePath склеен joinPaths (path.Join чистит слэши), сама Group ничего не регистрирует — маршруты ставят только GET/POST/…
- вложенные группы наследуют префиксы и звенья: /api → /v1 → /admin; снимок цепочки делается в момент регистрации маршрута — поздний Use на зарегистрированное не действует
- middleware на группу — аргументами Group или g.Use; групповые звенья не видят 404 и 405, глобальный Use видит всё
- версии API — вложенные группы v1/v2: общие хендлеры переиспользует функция-регистратор с параметром *gin.RouterGroup; v1 заморожена и выводится по логу префиксов
- NoRoute и NoMethod — методы движка, а не группы: NoRoute заменяет набор хендлеров, цепочка allNoRoute включает глобальные звенья — Logger печатает [GIN] и для промаха
- NoMethod работает только при HandleMethodNotAllowed = true и находит путь в другом метод-дереве: 405 с заголовком Allow; если пути нет нигде — честный 404

### Карточки: группы маршрутов

- **RouterGroup** — Тип из routergroup.go с четырьмя полями: Handlers (цепочка звеньев), basePath (склеенный префикс), engine (*Engine), root (флаг корня). Group() создаёт новую структуру, ничего не регистрируя; Engine встраивает RouterGroup со basePath "/" и root: true — поэтому у движка есть GET и Group.
- **joinPaths и BasePath()** — basePath считается при создании группы: path.Join чистит двойные слэши и «..», хвостовой слэш относительного пути сохраняется. BasePath() возвращает итоговый префикс — удобен для стартового лога и тестов. v1.Group("../admin") реально поднимется на уровень выше — анти-паттерн.
- **NoRoute** — Метод движка (не группы): NoRoute(handlers ...HandlerFunc) задаёт обработку промаха мимо дерева. Присваиванием engine.noRoute = handlers — заменяет набор, а не дополняет. allNoRoute = combineHandlers(noRoute) пересобирается при каждом вызове: глобальные звенья исполняются, групповые — нет. Дефолт — «404 page not found» text/plain.
- **NoMethod и HandleMethodNotAllowed** — NoMethod вызывается только при r.HandleMethodNotAllowed = true и только если путь найден в дереве другого метода: ответ 405 с заголовком Allow (RFC 7231). Пути нет нигде — запрос уходит в NoRoute с 404. Без флага чужой метод выглядит как обычный 404.
- **IRoutes и IRouter** — IRoutes — Use, Handle, GET/POST/…, Any (9 методов), Match (список методов), Static/StaticFS/StaticFile. IRouter = IRoutes + Group. Обе проверки var _ IRouter = … проходят и для Engine, и для RouterGroup. RouteGroup и gin.Router — несуществующие типы-описки.
- **Версии API на группах** — v1 := api.Group("/v1"), v2 := api.Group("/v2") — одно дерево, один процесс, один порт. Общие хендлеры — функция-регистратор с параметром *gin.RouterGroup, зовётся для обеих версий; специфичные маршруты — только в своей версии. v1 замораживается и выводится по логу трафика с заголовком Sunset.

> **Проверьте дерево с curl за пять минут.** Соберите buildAPI из урока и прогоните команды: curl localhost:8080/api/v1/todos — конверт с данными; curl -X POST -d '{"title":"Хлеб"}' localhost:8080/api/v1/todos — 201 и TodoInput в деле; curl localhost:8080/api/v1/admin/stats — 401 UNAUTHORIZED без ключа, а с -H "X-API-Key: local-secret" — данные; curl -X DELETE localhost:8080/api/v1/todos — 405 с Allow: GET, POST; curl localhost:8080/api/v1/missing — конверт NOT_FOUND вместо «404 page not found». В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 11-statika: обслуживание статических файлов, Static и StaticFS, embedding в бинарник.

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

- RouterGroup — четыре поля (Handlers, basePath, engine, root): Group создаёт новую структуру со склеенным basePath и ничего не регистрирует; Engine встраивает RouterGroup со basePath "/" и root: true, поэтому у движка есть GET и Group.
- Вложенные группы склеивают префиксы через joinPaths (path.Join плюс хвостовой слэш) и наследуют звенья; BasePath() возвращает итоговый префикс для логов и тестов.
- Снимок звеньев делается в момент регистрации маршрута: поздний api.Use не действует на уже зарегистрированные маршруты; групповые звенья не видят 404 — в отличие от глобального Use.
- Версии API — вложенные группы v1/v2: общие хендлеры переиспользует функция-регистратор с параметром *gin.RouterGroup, v1 заморожена и выводится по логу префиксов.
- NoRoute и NoMethod — методы движка: NoRoute заменяет набор хендлеров и пересобирает allNoRoute, где исполняются глобальные звенья; без своего хендлера клиент получает text/plain.
- NoMethod бездействует без HandleMethodNotAllowed = true; с флагом — 405 и заголовок Allow, а путь, неизвестный ни одному методу, честно уходит в 404.
- Тип называется RouterGroup, а не RouteGroup; интерфейсы IRoutes и IRouter описывают маршрутный узел и родителя групп — свои функции-регистраторы принимайте *gin.RouterGroup.

## Домашнее задание

Переведите трекер на группы: соберите buildAPI с корнем /api, вложите v1 и v2, вынесите регистратор todoRoutes(rg *gin.RouterGroup) и позовите его для обеих версий, а v2 дополните маршрутом /todos/:id/history. На v1.Group("/admin", checkAPIKey("…")) закройте /stats заглушкой из урока. Включите r.HandleMethodNotAllowed = true и добавьте свои NoRoute/NoMethod с конвертами NOT_FOUND и METHOD_NOT_ALLOWED; убедитесь curl-ом, что DELETE /api/v1/todos даёт 405 с Allow, GET /api/v1/nope — 404-конверт, а строка [GIN] печатается и для промаха: глобальный Logger входит в цепочку allNoRoute.

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

### Чем Group отличается от Use в gin?

Group создаёт новый узел дерева: basePath склеивается из префикса родителя и относительного пути, возвращается *RouterGroup, на которой регистрируют маршруты. Use дописывает звенья в цепочку существующего узла. Их часто комбинируют: v1 := api.Group("/v1", logger) — префикс и звено одним вызовом.

### Почему NoRoute нельзя вызвать на группе v1?

NoRoute и NoMethod объявлены на *Engine: таблица маршрутов одна на весь движок, и «свой 404 для ветки» движок различить не умеет — цепочка промаха собирается из глобальных звеньев и noRoute-хендлеров. Если нужен разный 404 для /api и остального — один NoRoute внутри разбирает c.Request.URL.Path по префиксам.

### Группы замедляют маршрутизацию?

Нет. Группа — конструкция времени старта: basePath склеен строками, звенья скопированы в срез в момент регистрации маршрута. В обработке запроса gin идёт по radix-дереву конкретного метода и исполняет готовый срез хендлеров — групп в рантайме не существует.
