---
title: "Группы маршрутов и версионирование"
description: "Урок fiber группы маршрутов: app.Group и вложенные ветки, общие middleware на префиксе, версионирование api v1/v2 и Router-интерфейс Fiber v3."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/15-gruppy-marshrutov/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Группы маршрутов и версионирование

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 15 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/15-gruppy-marshrutov/*

Обещание из четырнадцатого урока исполняется: там мы выяснили, что ErrorHandler ищется по префиксам смонтированных приложений, и пообещали научиться эти префиксы строить. Тема «fiber группы маршрутов» — про структуру большого API: когда маршрутов становится больше десятка, префикс /api в каждом app.Get превращается в копипасту, а общие слои для зоны приходится вешать на каждый маршрут по отдельности. Метод app.Group(prefix, handlers...) возвращает Router с общим префиксом: на него вешаются общие middleware, внутри него заводятся вложенные ветки — и на этом фундаменте вырастает версионирование api, когда /api/v1 и /api/v2 живут на одном дереве одновременно: старая версия обслуживает старых клиентов, новая принимает новых, а ошибки каждой ветки находят своего обработчика. Канон курса продолжается: тот же инстанс на :8080, слои helmet, cors и logger, ErrorHandler с конвертом {error, code} из четырнадцатого урока и страж requireAPIKey("s3cret") из седьмого — теперь он встанет не на отдельный маршрут, а на целую ветку /api/v1. Все факты сверены с исходниками тега v3.5.0: прежде всего group.go, app.go, router.go и helpers.go.

### fiber группы маршрутов: одна ветка — один префикс

Посмотрите на канон, который мы собирали четырнадцать уроков. app.Get("/api/tasks", ...), app.Post("/api/tasks", ...), app.Get("/api/tasks/:id", ...) — префикс /api повторяется в каждой строке, и это ещё четыре маршрута; настоящий сервис несёт десятки. За префиксом прячется более серьёзная проблема: общие правила зоны. «Все пути /api отвечают JSON-ошибками», «все пути /api/v1 требуют ключ» — формулируются эти требования про ветку целиком, а выражать их пока нечем: в шестом и седьмом уроках мы вешали middleware на всё приложение через app.Use или на отдельный маршрут вторым аргументом. Промежуточного уровня — «ветка маршрутов с общим префиксом и общими слоями» — у нас не было. Fiber группы маршрутов закрывают ровно эту дыру.

В прошлой лекции осталась и вторая незашитая нить. Мы разобрали, что App.ErrorHandler ищет обработчики ошибок по префиксам смонтированных sub-приложений — но что такое «префикс» и как его правильно построить, объяснено не было. Сегодня это станет рабочим инструментом: вы соберёте дерево /api → /v1 и /v2 и увидите, как на каждую версию API повесить свой формат ошибок, не ломая единый конверт.

Центральный метод урока объявлен в app.go и умещается в одну сигнатуру: func (app *App) Group(prefix string, handlers ...any) Router. Три наблюдения уже из неё. Первое: метод принимает префикс — строку, которая приклеится ко всем маршрутам, зарегистрированным через возвращённое значение. Второе: handlers вариадический и any — на группу сразу можно повесить middleware, и мы разберём, во что они превращаются (спойлер: в Use-маршрут на префиксе). Третье: возвращается Router — интерфейс, а не конкретный тип; это та же самая дверь, через которую вы работаете с app, и на этом держится вся композиция: у группы можно вызвать Group ещё раз, а функции-регистраторы можно писать один раз для любого узла дерева.

Канон не меняется: инстанс на :8080, helmet, cors и logger из шестого урока, страница задач из тринадцатого, ErrorHandler конвертом {error, code} из четырнадцатого. Новое — только геометрия: маршруты уезжают с корня в ветки.

### Анатомия Group: пять полей и один Router-интерфейс

Откроем исходники Fiber v3.5.0, файл group.go. Тип Group объявлен там первым и умещается в восемь строк: type Group struct { app *App; parentGroup *Group; name string; Prefix string; hasAnyRoute bool }. Док-комментарий формулирует назначение одной фразой: Group represents a collection of routes that share middleware and a common path prefix — «набор маршрутов с общим middleware и общим префиксом пути». Разберём поля, потому что каждое из них — это строчка поведения, которую мы сегодня потрогаем руками.

Поле app *App — указатель на приложение-владелец: группы не создают своего движка, все маршруты регистрируются в общем стеке того же самого приложения. Отсюда важный вывод, к которому мы вернёмся в блоке про ошибки: группа сама по себе не несёт ни конфига, ни ErrorHandler — это просто угол дерева маршрутов одного приложения. Поле parentGroup *Group — ссылка на родительскую группу: оно заполняется только при вложенном создании (api.Group("/v1")) и используется в неймспейсе имён, о котором поговорим ближе к концу урока. Поле name string — имя группы для Name-механики. Prefix — единственное экспортируемое поле: строку префикса можно читать напрямую из своего кода, например для стартового лога «зарегистрирована ветка /api/v1». И hasAnyRoute bool — флаг «в группе уже есть хоть один маршрут»: по нему Name решает, именовать ли саму группу или последний маршрут.

Второй герой блока — интерфейс Router из router.go. Это один контракт на восемнадцать методов: десять HTTP-методов (Get, Head, Post, Put, Delete, Connect, Options, Trace, Patch, Query), плюс Use, Add для списка методов, All для всех методов сразу, Group для вложенных веток, Domain для маршрутов по имени хоста, RouteChain и Route для цепочных и замыкательных форм регистрации и Name для имён. И самое главное: и *App, и *Group этот интерфейс реализуют. Поэтому не существует отдельного «типа группы-роутера» с урезанной логикой — у группы ровно те же методы регистрации, что у приложения, просто каждый путь они склеивают со своим префиксом. В курсе gin мы видели другой рисунок: там RouterGroup и Engine — разные структуры, а движок встраивает группу с флагом root: true. В Fiber рисунок проще: два типа, один интерфейс, никакого наследования — композиция через префиксы.

### Первая группа: app.Group и четыре метода на ней

```go
package main

import "github.com/gofiber/fiber/v3"

// хендлеры канона из прежних уроков
func listTasks(c fiber.Ctx) error   { return c.JSON(fiber.Map{"tasks": []string{"сшить макет"}}) }
func createTask(c fiber.Ctx) error  { return c.SendStatus(fiber.StatusCreated) }
func updateTask(c fiber.Ctx) error  { return c.SendString("обновлено") }
func deleteTask(c fiber.Ctx) error  { return c.SendStatus(fiber.StatusNoContent) }

func main() {
        app := fiber.New()

        // одна ветка на всю зону API: префикс пишется один раз
        api := app.Group("/api")

        api.Get("/tasks", listTasks)        // GET  /api/tasks
        api.Post("/tasks", createTask)      // POST /api/tasks
        api.Put("/tasks/:id", updateTask)   // PUT  /api/tasks/:id
        api.Delete("/tasks/:id", deleteTask) // DELETE /api/tasks/:id

        _ = app.Listen(":8080")
}
```

api := app.Group("/api") возвращает структуру Group с Prefix "/api" и указателем на то же приложение. Дальше на api вызываются знакомые методы — Get, Post, Put, Delete, — но механика под ними изменилась: в group.go каждый из них — это тонкая обёртка над Add(methods, path, handler, handlers...), а Add выполняет одну ключевую строку: grp.app.register(methods, getGroupPath(grp.Prefix, path), grp, converted...). То есть путь маршрута склеивается с префиксом группы в момент регистрации — getGroupPath("/api", "/tasks") даёт "/api/tasks", — и результат попадает в общий стек приложения. Маршрут, зарегистрированный через группу, ничем не отличается от app.Get("/api/tasks", ...): тот же Route, тот же стек, те же правила матчинга из третьего урока. Группа — это удобная ручка, а не отдельный мир. Заметьте также флаг hasAnyRoute: первый же Get переключил его в true, и теперь вызов api.Name("...") будет именовать последний маршрут, а не группу.

### Проверьте себя: что делает middleware в вызове Group

**Вопрос:** В приложении выполнена строка app.Group("/api", logger.New()). Что именно произошло в этот момент?

- ✅ Создана структура Group с Prefix "/api", а logger.New() зарегистрирован как Use-маршрут на префиксе /api в общем стеке приложения
- logger.New() сохранён внутри группы и выполнится как снимок звеньев перед каждым будущим маршрутом группы
- logger.New() сработает только для запросов с путём ровно /api, а на /api/tasks не попадёт
- Паника: Group принимает только префикс, middleware на группу вешают отдельным вызовом Use

Читаем app.go, метод Group: grp := &Group{Prefix: prefix, app: app}; if len(handlers) > 0 { converted := collectHandlers("group", handlers...); app.register([]string{methodUse}, prefix, grp, converted...) }. Вариадик handlers не копится в структуре и не «снимается» на будущие маршруты — он немедленно регистрируется в стеке как Use-маршрут на префиксе (methodUse — внутреннее имя метода для middleware). Use-маршруты совпадают с началом пути, поэтому логгер получит и /api/tasks, и /api/v1/tasks, и вообще всё, что начинается с /api: вариант с «ровно /api» описывает не префиксный матчинг, а полное совпадение, которого у Use нет. Снимок звеньев — это механика gin (combineHandlers), в fiber стек общий и динамический. И паники нет: передать middleware в Group — штатный путь, отдельный Use нужен только когда слой добавляется позже.

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

Настоящая сила групп — вложенность. В group.go метод Group есть и у самой группы, и работает он так: prefix = getGroupPath(grp.Prefix, prefix) — префикс ребёнка склеивается с префиксом родителя уже в момент создания, — затем создаётся новая структура &Group{Prefix: prefix, app: grp.app, parentGroup: grp} с заполненной ссылкой на родителя. Вложенная группа знает своего предка, живёт в том же приложении и несёт итоговый, полностью склеенный префикс. Дерево собирается в три строки: api := app.Group("/api"), v1 := api.Group("/v1"), v2 := api.Group("/v2") — и с этого момента v1.Get("/tasks", ...) регистрирует /api/v1/tasks, а v2.Get("/tasks", ...) — /api/v2/tasks. Одинаковые относительные пути /tasks на разных ветках больше не конфликтуют: полный путь разный.

Функция склейки getGroupPath объявлена в helpers.go и состоит из трёх шагов, которые стоит выучить дословно. Шаг первый: if path == "" { return prefix } — пустой относительный путь возвращает префикс как есть, поэтому api.Group("") легален и даёт снова /api. Шаг второй: if path[0] != '/' { path = "/" + path } — забыли ведущий слэш? Ничего страшного, функция допишет его сама: api.Group("v1") даст тот же /api/v1. Шаг третий: return utils.TrimRight(prefix, '/') + path — у префикса родителя обрезаются хвостовые слэши, и относительный путь приклеивается конкатенацией. Из-за обрезки хвостовых слэшей app.Group("/api/") и app.Group("/api") эквивалентны, а двойной слэш в стыке не появится никогда: он либо срезан справа от префикса, либо не возникнет из-за авто-слэша слева от пути.

Но конкатенация — не path.Join, и это принципиальная разница с gin, который мы разбирали в его десятом уроке. gin склеивает basePath через joinPaths — это path.Join плюс сохранение хвостового слэша, — а path.Join чистит сегменты: съедает двойные слэши и разрешает «..», поднимая ветку выше (v1.Group("../admin") в gin реально даст /api/admin). Fiber v3 ничего не резолвит: что написано в относительном пути после первого символа, то и приклеилось — «//» внутри пути останется двойным слэшем, а «..» уедет в маршрут литерально. На практике это значит одно: пишите префиксы аккуратно, без хитростей, — функция склейки вас не спасёт, да и не должна.

И последнее: вложенные группы — дешёвые структуры из пяти полей, никакой платы за уровень нет. Смело заводите по группе на смысловую зону — /api, версии, админка, health-зоны — но помните правило хорошего вкуса: каждый уровень должен либо вешать свой middleware, либо иметь собственный смысл; вложенность ради отступов читаемость не улучшает.

### Дерево версий: api → v1 и v2

```go
app := fiber.New()

// корень API: общая зона для всех версий
api := app.Group("/api")

// v1 := api.Group("/v1") — префикс склеен в момент создания: "/api/v1"
v1 := api.Group("/v1")
v1.Get("/tasks", listTasks)  // GET /api/v1/tasks
v1.Post("/tasks", createTask) // POST /api/v1/tasks

// v2 живёт на том же дереве одновременно с v1
v2 := api.Group("/v2")
v2.Get("/tasks", listTasksV2) // GET /api/v2/tasks
v2.Delete("/tasks/:id", deleteTask) // DELETE /api/v2/tasks/:id

// health-ветка без версии: на корне API
api.Get("/health", func(c fiber.Ctx) error { return c.SendString("ok") })
```

Три уровня — три структуры Group, у каждого своя геометрия. api имеет Prefix "/api"; v1 и v2 созданы через api.Group, поэтому при создании получили Prefix "/api/v1" и "/api/v2" — склейка уже случилась, дальше работает обычный Add: getGroupPath("/api/v1", "/tasks") даёт "/api/v1/tasks". Обратите внимание на два момента. Первый: маршруты /tasks на v1 и v2 — это два независимых маршрута в общем стеке, их хендлеры могут быть совершенно разными, и именно так делается версионирование api. Второй: группа api сама остаётся рабочим узлом — api.Get("/health", ...) регистрирует /api/health, путь без версии, общий для всех поколений клиента. Родительская группа не «закрывается» после создания детей: это просто узел с префиксом, на котором можно регистрировать маршруты и дальше.

### Проверьте склейку: хвостовой и ведущий слэши

```go
app := fiber.New()
api := app.Group("/api/") // хвостовой слэш в префиксе
v1 := api.Group("v1")     // ведущий слэш забыли

v1.Get("/tasks", func(c fiber.Ctx) error {
        return c.SendString("ok")
})
```

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

- ✅ Маршрут встал на /api/v1/tasks: getGroupPath обрезал хвостовые слэши префикса и дописал ведущий слэш относительному пути
- Маршрут встал на /api//v1/tasks: обе ошибки склеились в двойной слэш посередине пути
- Паника при регистрации: Group требует слэши с обеих сторон
- Маршрут встал на /tasks: префикс родителя потерян, группа не наследует путь

Трассируем getGroupPath по шагам. Создание v1: prefix = getGroupPath("/api/", "v1"). Path не пуст, path[0] != '/', значит path становится "/v1"; utils.TrimRight("/api/", '/') даёт "/api"; конкатенация — "/api/v1". Хвостовой слэш префикса срезан, ведущий относительному дописан: обе опечатки нейтрализованы. Регистрация маршрута: getGroupPath("/api/v1", "/tasks") — path уже начинается со слэша, TrimRight ничего не срезает, итог "/api/v1/tasks". Двойной слэш не возник бы и в другом сценарии: функция обрабатывает только стык, а стык всегда ровно один слэш; но «//» внутри самого относительного пути (например "//tasks") остался бы как есть — конкатенация не чистит сегменты. И наследование не теряется никогда: Prefix ребёнка строится из Prefix родителя по определению.

### Общие middleware на группу: Use-маршрут на префиксе

Теперь главный практический вопрос: как повесить общий middleware на ветку. Ответ мы уже дали в quiz-е, но соберём его целиком. В Fiber у группы нет собственного списка слоёв, который «накладывается» на маршруты, — вместо этого middleware, переданные в Group, регистрируются как Use-маршрут на префиксе ветки в общем стеке приложения. Use-маршрут совпадает с запросом по началу пути (так работает префиксный матчинг, о котором говорит и док-комментарий метода Use в group.go), поэтому слой, повешенный на /api/v1, исполнится для любого пути этой ветки — и для /api/v1/tasks, и для /api/v1/tasks/42. Эквивалентность полная: api.Group("/v1", requireAPIKey("s3cret")) и api.Group("/v1") с последующим v1.Use(requireAPIKey("s3cret")) до маршрутов — одно и то же.

Это даёт три формы, которые вы будете использовать постоянно. Первая: middleware прямо в вызове — v1 := api.Group("/v1", requireAPIKey("s3cret"), rateLimit()) — удобно, когда ветка объявляется один раз и слои у неё фиксированы. Вторая: v1.Use(...) после создания — удобно, когда слои добавляются по мере роста ветки, но строго до регистрации маршрутов. Третья: Use с префиксом внутри группы — v1.Use("/admin", adminGuard()) — префикс снова склеит getGroupPath, и слой ляжет на /api/v1/admin/*: подветка под защитой внутри ветки.

Почему «до маршрутов» — не стилистика, а механика? Потому что стек приложения сканируется по порядку регистрации. В router.go цикл app.next помечен комментарием Loop over the route stack starting from previous index: запрос идёт по стеку от начала, Use-маршруты продолжают цепочку, endpoint-маршрут отвечает и закрывает её. Слой, зарегистрированный после маршрута, в этом проходе встречается позже самого маршрута — маршрут уже ответил, и до слоя дело не доходит. Это то же правило «первый выигрывает», которое мы фиксируем с третьего урока, только применённое к группам: общие middleware объявляйте в Group или Use-ом сразу после создания группы, до первого Get/Post.

И связка с каноном: requireAPIKey("s3cret") из седьмого урока — фабрика, возвращающая fiber.Handler. Тогда мы вешали её на отдельный маршрут: app.Get("/api/notes", requireAPIKey("s3cret"), listNotes). Теперь та же фабрика встаёт на целую ветку — и один вызов защищает все маршруты версии, включая те, что вы добавите завтра.

### Канон: страж requireAPIKey на всю ветку /api/v1

```go
app := fiber.New()

// корневые слои: на всё приложение (шестой урок)
app.Use(helmet.New())
app.Use(cors.New())
app.Use(logger.New())

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

// v1 под стражем: один вызов защищает все маршруты ветки
v1 := api.Group("/v1", requireAPIKey("s3cret"))
v1.Get("/tasks", listTasks)   // GET /api/v1/tasks
v1.Post("/tasks", createTask) // POST /api/v1/tasks

// v2 пока публичный: read-only черновик новой версии
v2 := api.Group("/v2")
v2.Get("/tasks", listTasksV2) // GET /api/v2/tasks
```

Слои по уровням, как в матрёшке. helmet, cors и logger зарегистрированы на корне — их Use-маршруты совпадают с любым путём, они исполняются для всего: и для страниц, и для API. Группа api сама не несёт слоёв — это чистая геометрия. А вот v1 создана с middleware: при вызове api.Group("/v1", requireAPIKey("s3cret")) выполнится та самая ветка из app.go — collectHandlers превратит фабрику в Handler и app.register зарегистрирует Use-маршрут с методом methodUse на пути /api/v1. На запрос без ключа к любому маршруту v1 страж ответит 401 — та же логика прерывания цепочки из седьмого урока, но область действия выросла с одного маршрута до ветки. v2 зарегистрирована без слоёв: публичный черновик, который позже закроют своим стражем — одним вызовом, а не правкой каждого маршрута.

### Проверьте себя: поздний Use не работает задним числом

**Вопрос:** Группа собрана так: v1 := api.Group("/v1"); v1.Get("/tasks", listTasks); v1.Use(requireAPIKey("s3cret")). Клиент вызвал GET /api/v1/tasks без заголовка X-API-Key. Что ответит сервер?

- 401: Use в конце группы всё равно перехватит запрос
- ✅ 200 и список задач: страж не сработал, потому что Use-маршрут зарегистрирован после Get-маршрута, а стек сканируется по порядку регистрации
- 404: Use после маршрутов запрещён и паникует
- 401, но только для POST: GET страж пропускает свободно

Порядок в коде — порядок в стеке. Get-маршрут /api/v1/tasks зарегистрирован первым и сидит в стеке раньше, чем Use-маршрут со стражем. На запрос app.next сканирует стек с начала (Loop over the route stack starting from previous index), встречает endpoint-маршрут, он совпадает методом и путём, listTasks исполняется, отвечает JSON и возвращает nil — цепочка закрыта, до requireAPIKey, стоящего в стеке позже, проход не доходит. Никакого «задним числом» не существует: fiber не переставляет маршруты и не строит снимки звеньев на момент запроса. Правило то же, что в третьем уроке: регистрируйте общие слои до маршрутов ветки. 404 тоже неверно — Use после маршрутов вполне легален (паника у collectHandlers только на неподдерживаемый тип хендлера), он просто бесполезен для уже зарегистрированных маршрутов этой ветки.

### Версионирование api: v1 и v2 живут одновременно

Зачем вообще версии? Потому что контракт API нельзя менять молча. У вас есть клиенты: фронтенд, мобильное приложение, интеграции партнёров — они парсят конкретный формат ответа. Вы переименовали поле, свернули список в объект, добавили обязательный заголовок — и все старые клиенты сломались разом. Версионирование api — это соглашение: изменения, ломающие совместимость, уезжают в новый префикс, а старый продолжает работать как есть. Обе версии живут на одном дереве одного приложения: v1 обслуживает старых клиентов до момента, когда вы официально их выключите, v2 принимает новых. Это резко дешевле, чем «большой переезд» с одним днем переключения.

Группы дают версионирование почти даром, и вот практический скелет. Корневая группа /api — место общих для всех поколений вещей: /api/health, /api/version, общие слои вроде лога и лимитера. Каждая версия — ветка: v1 := api.Group("/v1"), v2 := api.Group("/v2"). Внутри версии пути могут совпадать один в один — /tasks есть и там, и там, — потому что полные пути разные. Хендлеры версий независимы: v1 может отдавать плоский формат, v2 — конверт с data и meta. При этом бизнес-логика под хендлерами общая: функции хранилища задач не дублируются, дублируется только транспортный слой. Правило экономии: дублировать — транспорт, не домен.

Какими маршрутами живет версия? Теми же восемнадцатью методами Router-интерфейса, что и корень: Get, Post, Put, Delete, Patch и так далее — на группе доступны все, плюс Add для списка методов и All для всех сразу. Отдельно скажем про эволюцию внутри одной версии: добавлять новые поля в v1 можно — старые клиенты игнорируют неизвестное; удалять или переименовывать — нельзя, это и есть сигнал для v2. Когда время v1 подходит к концу, на ветку вешается один Use-слой, отвечающий заголовком Deprecation (RFC 8594) или кодом 410 Gone на всё дерево, — и один день v1 := api.Group("/v1") просто удаляется из кода. Ничего другого трогать не придётся: в этом и смысл, что граница версии — одна строка.

Про совместимость с каноном: ErrorHandler из четырнадцатого урока у нас один на приложение и уже умеет ветвиться по strings.HasPrefix(c.Path(), "/api"). Версии ничего в нём не ломают: /api/v1/... начинается с /api, значит API-ветка конверта обслужит обе версии. Но у версий есть и более сильный инструмент — собственные обработчики ошибок на ветку; это следующий блок.

### Эволюция формата: плоский v1 и конверт v2

```go
// v1: плоский формат первого поколения — трогать нельзя
v1.Get("/tasks", func(c fiber.Ctx) error {
        return c.JSON(fiber.Map{"title": "сшить макет", "done": false})
})

// v2: тот же ресурс, но ответ в конверте с data и meta
v2.Get("/tasks", func(c fiber.Ctx) error {
        return c.JSON(fiber.Map{
                "data": fiber.Map{"title": "сшить макет", "done": false},
                "meta": fiber.Map{"version": "v2", "count": 1},
        })
})
```

Одинаковый ресурс, разные представления — суть версионирования в двух хендлерах. v1 отдаёт плоскую структуру, как и в день релиза: клиент v1 парсит поля title и done прямо из корня ответа. v2 оборачивает данные в конверт: data — полезная нагрузка, meta — служебные сведения версии и количества. Ни один старый клиент не пострадал, ни один новый не обязан знать про плоский формат. Обратите внимание: под хендлерами обеих версий — одно и то же хранилище задач канона; отличаются только транспортные функции. Если завтра понадобится v3 с постраничной выдачей, добавится третья ветка — api.Group("/v3") — и ни один маршрут v1/v2 не сдвинется: префиксы делают границы версий изолированными по построению.

### Проверьте себя: запрос к незарегистрированной v3

```go
// ErrorHandler из четырнадцатого урока настроен:
// для путей /api отдаёт JSON {"error": ..., "code": ...}
// зарегистрированы ветки /api/v1 и /api/v2
// запрос: curl localhost:8080/api/v3/tasks
```

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

- ✅ 404 и {"error":"Not Found","code":404}: совпадений в стеке нет, emitSkip передал ErrNotFound в ErrorHandler
- 200 и пустой список: несуществующая версия считается пустой веткой
- 405 Method Not Allowed: GET не разрешён для группы v3
- 500: ErrorHandler не знает про версии и падает

Группа v3 никем не регистрировалась — в стеке просто нет маршрутов с префиксом /api/v3, и никакой «автосоздающей» магии у fiber нет: Group создаёт ветку только явным вызовом в коде. Роутер не находит совпадения и, как мы разобрали в четырнадцатом уроке, не пишет ответ сам: emitSkip формирует fiber.ErrNotFound — это NewError(StatusNotFound) с сообщением "Not Found" — и передаёт в ErrorHandler приложения (для несовпадения метода было бы ErrMethodNotAllowed с заголовком Allow). Ваш обработчик узнаёт *fiber.Error через errors.As, берёт Code 404 и, поскольку путь начинается с /api, отвечает конвертом {"error":"Not Found","code":404}. Полезное следствие: несуществующие версии отвечают клиенту тем же единым форматом, что и все ошибки API, — фронтенд обрабатывает один случай, а не три.

### Ошибки на каждую версию: префикс решает

Тизер из четырнадцатого урока мы обещали развить: «научившись группировать маршруты, вы получите обработчики ошибок на каждую версию API отдельно». Теперь у нас есть префиксы — где здесь ошибки? Разберём честно, в два уровня.

Уровень первый — тот, что у вас уже работает. Группы не несут собственного ErrorHandler: группа — угол дерева одного приложения, а конфиг, включая ErrorHandler, один на приложение. Значит, ошибки из всех веток стекают в единственный обработчик корня. И этого достаточно для большинства проектов: внутри ErrorHandler вы ветвитесь по пути — strings.HasPrefix(c.Path(), "/api/v2") даёт возможность отвечать версии v2 её собственным конвертом, остальному /api — общим, страницам — HTML. Это по-прежнему «единая точка», просто с более мелкими развилками. Рекомендуем начинать именно так: один обработчик, ветки по префиксам, один конверт.

Уровень второй — когда версии должны быть по-настоящему самостоятельными: свой ErrorHandler, свой конфиг, возможно свой набор middleware-пакетов. Для этого fiber предоставляет монтаж: отдельное приложение fiber.New() со своим Config можно пристроить на ветку — app.Use("/api/v2", v2App). В group.go у Use есть ветка case *App: subApp = arg ... return grp.mount(prefix, subApp) — и маршруты смонтированного приложения начинают обслуживать пути под префиксом. А теперь главное, что мы объяснили в прошлый раз лишь на словах: метод App.ErrorHandler в app.go перед вызовом своего конфига ищет ErrorHandler среди смонтированных sub-приложений по их префиксам — док-комментарий формулирует это дословно: It searches any mounted sub fibers by their prefixes and if it finds a match, it uses that error handler. Из совпавших побеждает самый специфичный — с бо́льшим числом слэшей в префиксе; не нашлось — работает конфиг корня.

Соберём обе половины в одну картину: ошибка из хендлера /api/v1/tasks — поиск по префиксам не находит смонтированных приложений на /api/v1, работает корневой ErrorHandler с конвертом {error, code}. Ошибка из хендлера, живущего внутри смонтированного v2App на /api/v2, — префикс совпал, и клиент получает формат ошибок именно версии v2, какой бы странный он ни был. Именно это мы и обещали: обработчики ошибок на каждую версию отдельно, при этом группы — инструмент префиксов, а mount — инструмент собственных конфигов; связывает их поиск по префиксам. И да, 404/405 внутри веток идут через те же обработчики: emitSkip, о котором мы говорили в четырнадцатом уроке, работает на всём дереве.

### Смонтированное приложение со своим ErrorHandler

```go
import "errors"

// v2App — отдельное приложение со своим форматом ошибок
v2App := fiber.New(fiber.Config{
        ErrorHandler: func(c fiber.Ctx, err error) error {
                code := fiber.StatusInternalServerError
                var fe *fiber.Error
                if errors.As(err, &fe) { // лестница из 14-го урока
                        code = fe.Code
                }
                // свой конверт v2: ключ "details" для фронтенда версии
                return c.Status(code).JSON(fiber.Map{"error": err.Error(), "code": code})
        },
})
v2App.Get("/tasks", listTasksV2) // внутри v2App путь "/tasks"

// монтаж: ветка /api/v2 обслуживается своим приложением
app.Use("/api/v2", v2App)
```

Три шага схемы. Первый: v2App — полноценный fiber.New() с собственным Config; его ErrorHandler — та же лестница errors.As из четырнадцатого урока, но конверт версии добавляет поле details (здесь упрощено до err.Error() — в реальном коде держите белые списки, как договаривались). Второй: маршруты внутри v2App регистрируются относительно его корня — v2App.Get("/tasks", ...) — префикс приклеится при монтаже. Третий: app.Use("/api/v2", v2App) монтирует приложение на ветку. Дальше включается механика из app.go: ошибка, всплывшая из хендлера ветки /api/v2, попадает в App.ErrorHandler корня, тот находит по префиксам смонтированный v2App и вызывает его обработчик — самый специфичный префикс побеждает. При этом /api/v1 остаётся на корневом ErrorHandler: для него поиск совпадений не даст, и конфиг корня — единственный обработчик. Два формата ошибок на одном сервере, ноль дублирования лестницы в хендлерах.

### Проверьте себя: чей ErrorHandler сработает

**Вопрос:** Приложение: в корневом Config настроен ErrorHandler с конвертом {error, code}; отдельное приложение v2App со своим ErrorHandler смонтировано вызовом app.Use("/api/v2", v2App). Из хендлера GET /api/v1/tasks вернулась ошибка. Кто её обработает?

- Собственный ErrorHandler v2App: он единственный нестандартный в приложении
- ✅ Корневой ErrorHandler: поиск по префиксам смонтированных приложений не нашёл совпадения для /api/v1, поэтому применяется конфиг корня
- Дефолтный DefaultErrorHandler: наличие смонтированных приложений отключает корневой обработчик
- Оба последовательно: сначала v2App, затем корневой

App.ErrorHandler работает по алгоритму из app.go: сначала поиск среди смонтированных sub-приложений по префиксам, побеждает самый специфичный (число слэшей), и только если совпадений нет — собственный конфиг корня. Смонтировано ровно одно приложение — на префиксе /api/v2; путь ошибки /api/v1/tasks этим префиксом не начинается, совпадений ноль, значит работает корневой ErrorHandler с конвертом {error, code}. v2App-овский обработчик отзывается только на ошибки ветки /api/v2. Дефолт тоже не при делах: поле конфига заполнено, а guard превращает в DefaultErrorHandler лишь nil. И никакой цепочки: обработчик ошибок один на запрос — не middleware, вызывается однократно после того, как ошибка дошла до конца стека.

### Router-интерфейс: один контракт для app и группы

Мы уже пользовались фактом «у группы те же методы, что у приложения», но теперь зафиксируем его как инструмент. Интерфейс Router объявлен в router.go: Use(args ...any), десять HTTP-методов с подписью (path string, handler any, handlers ...any) Router, Add(methods []string, path string, ...), All(path string, ...), Group(prefix string, handlers ...any), Domain(host string), RouteChain(path string), Route(prefix string, fn func(router Router), name ...string) и Name(name string). И *App, и *Group удовлетворяют этому интерфейсу. Практическое следствие — паттерн, который вы будете применять всю карьеру: функции-регистраторы, принимающие fiber.Router. func registerV1(r fiber.Router) { r.Get("/tasks", listTasks); r.Post("/tasks", createTask) } — эта функция честно отработает и на v1, и на v2, и на корневом app: внутри неё не написано ничего про префикс, склейку делает вызывающий.

Пара методов интерфейса, которые стоит знать сверх изученного. Add — регистрация списка методов разом: r.Add([]string{fiber.MethodGet, fiber.MethodHead}, "/tasks", listTasks). All — хендлер на все методы конфига (config.RequestMethods): так делают заглушки и «поймать всё». Route — замыкательная форма: app.Route("/api", func(r fiber.Router) { r.Get("/health", ...) }) создаёт группу, вызывает вашу функцию с ней и возвращает её; у Route есть необязательное имя для Name-неймспейса, а fn == nil паникует с сообщением route handler 'fn' cannot be nil. RouteChain — цепочная регистрация одного пути с общим префиксом. Domain — маршруты, привязанные к имени хоста, — упомянем и пойдём мимо: это ниша мультиарендных сервисов.

Отдельно — про any в подписях (handler any, handlers ...any). Fiber принимает не только func(Ctx) error: toFiberHandler в adapter.go распознаёт и Express-style подписи, и http.HandlerFunc, и fasthttp-хендлеры. Цена гибкости — паника на неподдерживаемом типе, и её текст стоит знать: collectHandlers паникует сообщением вида group: invalid handler #1 (тип) — «group» в начале это как раз контекст-строка, которую Group передаёт при регистрации своих handlers. Если увидите такую панику — вы передали в Group/маршрут функцию не той сигнатуры, и искать нужно не глубину вложенности, а тип аргумента.

И терминологическая скобка, чтобы не путаться в статьях: «группа маршрутов», «группа роутов», «роутер-группа» — всё о группе; Router же в fiber — имя интерфейса, а не типа группы. У gin, как мы видели в его десятом уроке, вместо одного Router-интерфейса — два (IRoutes и IRouter), а RouterGroup — отдельная структура, которую встраивает Engine с флагом root: true. Fiber-версия лаконичнее: два типа, один контракт.

### Соберите регистрацию версии

Вставьте пропущенные вызовы так, чтобы ветка /api/v1 встала под стражем, маршрут зарегистрировался, а функция-регистратор приняла любой узел дерева.

```go
app := fiber.New()
api := app.___("/api")
v1 := api.___("/v1", requireAPIKey("s3cret"))

// маршрут внутри версии: префикс склеится автоматически
v1.___("/tasks", listTasks)

// функция-регистратор принимает любой узел дерева
func registerService(r fiber.___) {
        r.Get("/health", func(c fiber.Ctx) error { return c.SendString("ok") })
}
registerService(v1)
```

Подсказка: Группа создаётся методом Group и у приложения, и у группы; регистрация метода — Get; тип параметра функции-регистратора — интерфейс Router.

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

### Name: неймспейс имён маршрутов (базово)

Последний метод Router-интерфейса, который мы ещё не разбирали, — Name. Он двойственного поведения, и group.go описывает его в док-комментарии честно: Name Assign name to specific route or group itself. Если метод вызван до того, как в группу добавлен хоть один маршрут, — он именует саму группу: grp.name записывается в структуру, и срабатывает hook OnGroupNameHook. Если после — он именует последний зарегистрированный маршрут: вызов уходит в app.Name(name), а тот проставляет имя в стеке.

Интересна вложенность: при создании группы через api.Group("/v1") поле name наследуется конкатенацией — grp.name = grp.parentGroup.name + name. Без разделителя: если api.Name("api") и v1.Name(".v1"), итоговое имя группы — "api.v1"; точку (или другой разделитель) вы добавляете сами — механика просто клеит строки. Дальше имена маршрутов группы получают этот префикс автоматически: в app.go при именовании маршрута стоит route.Name = route.group.name + name — то есть v1.Get("/tasks", ...), v1.Name("list") даст маршруту имя "api.v1.list". Это и есть имя-неймспейс: имена маршрутов каждой ветки автоматически несут её идентификатор, и вы никогда не получите коллизию «list» из v1 против «list» из v2.

Зачем это на практике? Три сценария. Логи и метрики: имя маршрута — стабильный ключ, не зависящий от параметров пути; в ошибке или дашборде "api.v2.tasks.list" говорит больше, чем "/api/v2/tasks/:id". Отладка стека: при печати зарегистрированных маршрутов в стартовом логе имена делают дерево читаемым. Хуки: OnGroupNameHook и OnName — штатные точки, где можно собрать реестр веток приложения. Для чего Name НЕ нужен — так это для матчинга: маршрутизация идёт по путям и методам, имена — чистая метаинформация. Мы разобрали механизм базово, без URL-генерации; этого достаточно, чтобы читать чужой код и вести собственный реестр маршрутов.

### Соберите путь запроса по дереву групп

Соберите по порядку путь успешного запроса GET /api/v1/tasks по дереву групп — от curl до ответа клиенту.

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

1. `curl -H "X-API-Key: s3cret" localhost:8080/api/v1/tasks`
2. `helmet.New() на корне приложения: первый слой стека, срабатывает на любой путь`
3. `cors.New() и logger из шестого урока: корень продолжает цепочку c.Next()`
4. `Use-маршрут группы /api: префикс совпал с началом пути — общие слои ветки вступают в работу`
5. `Use-маршрут /api/v1 с requireAPIKey("s3cret"): ключ верный — return c.Next()`
6. `Get-маршрут /api/v1/tasks: совпали метод и весь путь — endpoint найден`
7. `хендлер listTasks читает задачи из хранилища канона`
8. `c.JSON(fiber.Map{...}) записывает тело ответа`
9. `хендлер вернул nil: ошибок нет — ErrorHandler остаётся в стороне`
10. `клиент получает 200 и JSON со списком задач версии v1`

### Сравнение с gin: две философии одного приёма

Если вы прошли [десятый урок курса gin — «Группы маршрутов»](/courses/gin/lessons/10-gruppy/), сегодняшняя тема покажется знакомой на девяносто процентов — и именно оставшиеся десять стоит проговорить, потому что это разные инженерные решения одинаковой задачи. Общее ядро совпадает дословно: группа = префикс + общие middleware, вложенность склеивает префиксы, версии API делаются ветками /v1 и /v2 на одном дереве, маршрут в группе записывается относительным путём, а «прописать полный путь внутри группы» — одна и та же ловушка в обоих фреймворках (двойной префикс регистрируется как обычный маршрут и молча ловит запросы).

Теперь различия. Первое — где живёт группа. В gin RouterGroup — отдельная структура с четырьмя полями (Handlers, basePath, engine, root), а движок Engine лишь встраивает корневую группу со basePath "/" и root: true — поэтому у движка «есть» методы GET и Group. В fiber группа Group — самостоятельный тип с указателем app на владельца, и *App реализует Router-интерфейс напрямую, без встраивания: наследования нет, есть два типа с общим контрактом. Второе — момент вычисления префикса. gin считает basePath сразу при создании (calculateAbsolutePath через joinPaths) и хранит приватно, выдавая наружу методом BasePath(); fiber тоже склеивает префикс вложенной группы при создании (getGroupPath), но хранит его в экспортируемом поле Prefix — читать можно прямо, метода не нужно.

Третье различие — философия склейки. gin склеивает через joinPaths, то есть path.Join: он чистит двойные слэши и разрешает «..», поэтому в gin v1.Group("../admin") поднимает ветку выше. fiber v3 в getGroupPath делает обрезку хвостовых слэшей префикса и конкатенацию — никаких чисток и резолвинга: что в относительном пути, то и в маршруте. Мы сознательно не рекомендуем «..» в обоих фреймворках, но в gin это работающая экзотика, а в fiber — дорога к сломанному пути. Четвёртое — механика общих middleware: gin в момент регистрации маршрута делает combineHandlers — снимок звеньев группы прямо в список хендлеров маршрута; fiber регистрирует слои группы отдельными Use-маршрутами в общем стеке. Эффект для аккуратного кода одинаков (слой на ветке работает для её маршрутов), а вот правило «поздний Use не действует задним числом» верно для обоих: и снимок gin, и порядок стека fiber фиксируются к моменту регистрации.

Пятое — интерфейсы: в gin два (IRoutes для регистрации, IRouter добавляет Group), в fiber — один Router на восемнадцать методов. Вывод практический не в пользу «кто лучше», а в пользу навыка: паттерн один — дерево префиксов с общими слоями, — и, освоив его в одном фреймворке, вы переносите его в другой за полчаса, сверяя только механику склейки.

### Fiber Group и gin RouterGroup рядом

| Аспект | Fiber v3 (Group) | gin (RouterGroup) |
| --- | --- | --- |
| Тип и файл | Group, group.go: пять полей (app, parentGroup, name, Prefix, hasAnyRoute) | RouterGroup, routergroup.go: четыре поля (Handlers, basePath, engine, root) |
| Связь с движком | *App реализует Router сам; группа держит указатель app | Engine встраивает RouterGroup со basePath "/" и root: true |
| Префикс наружу | экспортируемое поле Prefix — читается напрямую | приватное basePath + метод BasePath() |
| Склейка префиксов | getGroupPath: TrimRight(prefix, '/') + конкатенация, ведущий слэш допишется; «..» и «//» не чистятся | joinPaths: path.Join чистит сегменты и разрешает «..», хвостовой слэш сохраняется |
| Middleware на группу | handlers из Group регистрируются как Use-маршрут на префиксе в общем стеке | combineHandlers: снимок звеньев вшивается в каждый маршрут при регистрации |
| Интерфейс(ы) | один Router на 18 методов (методы HTTP + Use/Add/All/Group/Route/Name/Domain/RouteChain) | два: IRoutes и IRouter (IRouter = IRoutes + Group) |
| Имена маршрутов | Name: до маршрутов — имя группы (вложенность склеивает parentGroup.name + name), после — имя последнего маршрута с префиксом группы | группового неймспейса нет, имена ставятся на уровне движка |
| Версии API | v1 := api.Group("/v1"), v2 := api.Group("/v2") на одном дереве; свой ErrorHandler — через монтирование fiber.New() на префикс | тот же приём с RouterGroup; отдельный обработчик ошибок — через собственный Engine или middleware-ветку |

> **Практики: функции-регистраторы и границы версий.** Три привычки, которые окупаются с первого месяца. Первая: пишите регистраторы версий как func registerV1(r fiber.Router) и func registerV2(r fiber.Router) — границы версий становятся физическими файлами, diff между версиями читается как список отличий, а тесты зовут регистратор с той веткой, которую проверяют. Вторая: держите в группе /api только то, что общее для всех версий (health, version, лимитеры), — каждый слой на корневой группе стоит запросам всех поколений, специфичное для версии живёт в v1/v2. Третья: печатайте Prefix в стартовом логе — v1.Prefix это экспортированное поле, и строка «registered /api/v1 (guarded), /api/v2 (public)» в логе стоит пяти минут чтения кода. И не плодите уровни без смысла: /api/v1/admin — два уровня по делу; пятый уровень вложенности почти всегда признак того, что его звенья пора вынести в функции-регистраторы.

### Ловушки групп: симптом, причина, лечение

Первая ловушка — двойной префикс. Симптом: запросы на /api/v1/api/v1/tasks отвечают 200, а на /api/v1/tasks — 404. Причина: внутри группы v1 написан полный путь v1.Get("/api/v1/tasks", ...) — getGroupPath честно приклеил префикс к уже полному пути, и маршрут зарегистрировался на удвоенном адресе; fiber не ругается, потому что формально это легальный маршрут. Лечение: в группах — только относительные пути; если хочется страховки, простой тест «GET по ожидаемому адресу» ловит это мгновенно. Заметьте: та же ловушка и в gin, где мы её уже разбирали, — проблема не фреймворка, а привычки.

Вторая — поздний Use. Симптом: страж на ветке пропускает все запросы. Причина: v1.Use(requireAPIKey(...)) вызван после регистрации маршрутов, а стек сканируется по порядку — endpoint-маршрут отвечает раньше, чем проход дойдёт до слоя. Лечение: общие слои — в аргументах Group или Use-ом сразу после создания группы, до первого маршрута.

Третья — слэш-фобия. Симптом: споры в ревью о хвостовых слэшах и ведущих слэшах. Причина незнания механики: getGroupPath срезает хвостовые слэши префикса и дописывает ведущий слэш пути, поэтому api.Group("/api/") и api.Group("v1") безопасны; а вот «//» внутри относительного пути сохранится — конкатенация сегменты не чистит. Лечение: не пишите хвостовые слэши в префиксах и никогда не пишите «..» в относительных путях — fiber, в отличие от gin с его path.Join, «..» не резолвит.

Четвёртая — паника при старте с текстом group: invalid handler #N. Причина: в Group передан аргумент, который toFiberHandler не распознал — чаще всего функция с неправильной сигнатурой (не func(Ctx) error) или случайное значение; collectHandlers паникует с индексом и типом. Лечение: читать тип из сообщения паники и чинить сигнатуру; «group» в сообщении — контекст регистрации именно групповых handlers.

Пятая — ожидание собственного ErrorHandler у группы. Симптом: попытка v1.Config{ErrorHandler: ...} не компилируется или ожидание «группа несёт свои слои, значит и обработчик» ломает план. Причина: у Group пять полей, конфига там нет — обработчики ошибок привязаны к приложениям и ищутся по префиксам смонтированных sub-app. Лечение: ветвление по пути в едином ErrorHandler либо монтирование отдельного fiber.New() на версию — оба способа мы разобрали выше.

### Словарь урока

- **Group (group.go)** — Тип из пяти полей: app (владелец), parentGroup (родитель при вложении), name (имя для Name-механики), Prefix (экспортируемый префикс), hasAnyRoute (есть ли маршруты). Док: a collection of routes that share middleware and a common path prefix. Группа не несёт конфига и ErrorHandler — это угол дерева одного приложения.
- **app.Group(prefix, handlers...)** — app.go:1144: func (app *App) Group(prefix string, handlers ...any) Router. Создаёт &Group{Prefix: prefix, app: app}; если handlers переданы — collectHandlers конвертирует их и app.register([]string{methodUse}, prefix, ...) — middleware становятся Use-маршрутом на префиксе. Возврат — Router: у группы те же методы, что у app.
- **getGroupPath (helpers.go)** — Склейка префиксов: path == "" → вернуть prefix; path без ведущего слэша — слэш допишется; итог utils.TrimRight(prefix, '/') + path. Конкатенация, НЕ path.Join: «//» внутри пути и «..» не чистятся — в отличие от gin с его joinPaths. Вложенная группа склеивает Prefix при создании, маршрут — при регистрации через Add.
- **Router-интерфейс (router.go)** — Один контракт на 18 методов: 10 HTTP-методов + Use, Add, All, Group, Domain, RouteChain, Route, Name. Реализуют и *App, и *Group — поэтому функции-регистраторы принимают fiber.Router и работают на любом узле дерева. collectHandlers паникует «group: invalid handler #N (тип)» на неподдерживаемую сигнатуру.
- **Name и неймспейс** — До первого маршрута: Name именует группу, вложенность клеит grp.name = parentGroup.name + name (без разделителя — точку добавляйте сами). После: именует последний маршрут, route.Name = route.group.name + name. Имена — мета для логов/метрик/хуков, маршрутизация по ним не идёт.
- **Ошибки и версии** — Группа не меняет ErrorHandler: ошибки всех веток стекают в конфиг корня (ветвь по strings.HasPrefix(c.Path(), ...)). Свой обработчик на версию — монтирование: v2App := fiber.New(Config{ErrorHandler}); app.Use("/api/v2", v2App). App.ErrorHandler ищет обработчики по префиксам смонтированных sub-app, побеждает самый специфичный (число слэшей). 404/405 веток идут через те же обработчики (emitSkip).

### Запуск, шесть сценариев curl и что дальше

Соберите канон целиком: инстанс на :8080, helmet, cors и logger из шестого урока, ErrorHandler с конвертом {error, code} и веткой для страниц из четырнадцатого, фабрика requireAPIKey("s3cret") из седьмого — и новое дерево: api := app.Group("/api"), v1 := api.Group("/v1", requireAPIKey("s3cret")), v2 := api.Group("/v2") с маршрутами задач в обеих версиях, плюс смонтированный v2App со своим ErrorHandler, если дошли до соответствующего блока. Прогоните шесть сценариев.

Первый: curl -H "X-API-Key: s3cret" localhost:8080/api/v1/tasks — 200 и список задач: страж пропустил, маршрут ветки ответил. Второй: тот же curl без заголовка — 401 от стража ветки, и проверьте, что /api/v2/tasks при этом отвечает свободно: области действия слоёв видны на паре запросов лучше, чем в любом объяснении. Третий: curl localhost:8080/api/v3/tasks — 404 и конверт {"error":"Not Found","code":404} из корневого ErrorHandler: незарегистрированная версия — обычный случай ErrNotFound. Четвёртый: сломайте формат v1 — добавьте в хендлер return errors.New("симуляция сбоя") — и убедитесь, что ошибка из ветки v1 отформатирована корневым конвертом, а ошибка внутри v2App — конвертом версии: поиск по префиксам в действии. Пятый: перенесите v1.Use(requireAPIKey("s3cret")) после регистрации маршрутов и повторите второй сценарий — страж замолчит; верните порядок. Шестой: curl localhost:8080/api/health — путь без версии с корневой группы: общий для всех поколений клиент.

Пара слов о том, чего в уроке нет и почему. Песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; дерево групп собирайте локально в терминале. И монтирование sub-app мы разобрали ровно настолько, насколько оно нужно для версионирования ошибок: полное сравнение монтирования с группами — за рамками урока.

Материал держится на четырёх опорах: структура Group из group.go, склейка getGroupPath из helpers.go, Router-интерфейс с восемнадцатью методами и поиск ErrorHandler по префиксам из app.go. Полный план курса — на странице [курса Fiber](/courses/fiber/), а механику ErrorHandler, на которую легли версии, мы подробно разбирали в [четырнадцатом уроке](/courses/fiber/lessons/14-oshibki/); фабрика requireAPIKey — в [седьмом](/courses/fiber/lessons/07-svoi-middleware/).

Дальше — шестнадцатый урок про WebSocket: постоянное двунаправленное соединение поверх того же приложения. Дерево маршрутов вам пригодится сразу: веб-сокетные ветки заводятся группами так же, как версии API, и живут под общими слоями.

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

- Group — тип из group.go с пятью полями (app, parentGroup, name, Prefix, hasAnyRoute); doc: a collection of routes that share middleware and a common path prefix. Группа не создаёт своего движка: все маршруты регистрируются в общем стеке того же приложения.
- app.Group(prefix, handlers...) (app.go:1144) возвращает Router; переданные handlers немедленно регистрируются как Use-маршрут на префиксе (collectHandlers + app.register(methodUse, ...)). Use-маршрут совпадает по началу пути — слой ветки работает для всех её маршрутов.
- Вложенные группы: api := app.Group("/api"); v1 := api.Group("/v1") — префикс ребёнка склеен getGroupPath уже при создании, parentGroup заполнен. getGroupPath: пустой путь → префикс; без ведущего слэша — допишется; TrimRight(prefix, '/') + конкатенация. «//» и «..» не чистятся — это не path.Join, как у gin.
- Порядок регистрации — порядок в стеке (app.next сканирует стек с начала): общие middleware объявляйте в Group или Use-ом до маршрутов ветки; поздний Use не действует задним числом. Двойной префикс (полный путь внутри группы) регистрируется молча — пишите относительные пути.
- Версионирование api: v1 := api.Group("/v1") и v2 := api.Group("/v2") на одном дереве, одинаковые относительные пути не конфликтуют; дублируется транспорт, не домен. Незарегистрированная версия отвечает 404 через emitSkip → ErrorHandler тем же конвертом.
- Свой ErrorHandler на версию — через монтирование: v2App := fiber.New(fiber.Config{ErrorHandler: ...}); app.Use("/api/v2", v2App). App.ErrorHandler ищет обработчики по префиксам смонтированных sub-app (It searches any mounted sub fibers by their prefixes), побеждает самый специфичный по числу слэшей; без совпадений работает конфиг корня.
- Router-интерфейс (router.go) — 18 методов, его реализуют и *App, и *Group: функции-регистраторы func registerV1(r fiber.Router) работают на любом узле. Name до маршрутов именует группу (вложенность клеит parentGroup.name + name), после — последний маршрут с префиксом группы; имена — мета для логов и метрик, не маршрутизация.

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

Соберите двухверсионный API канона целиком. Во-первых, вынесите регистрации версий в функции registerV1(r fiber.Router) и registerV2(r fiber.Router) в отдельных файлах: v1 — плоский формат, v2 — конверт с data и meta; хранилище задач сделайте общим. Во-вторых, распределите слои: helmet, cors, logger — на корень, лимитер запросов — на группу api, requireAPIKey("s3cret") — только на v1; проверьте curl-ом, что /api/v2/tasks открыт, а каждая ветка v1 под ключом. В-третьих, смонтируйте на /api/v2 отдельное приложение fiber.New со своим ErrorHandler, отвечающим конвертом {error, code, details}, и убедитесь, что ошибка из v2-хендлера отформатирована им, а из v1 — корневым обработчиком. В-четвёртых, добавьте api.Get("/version", ...) на корневой группе и Name-неймспейс: api.Name("api"), v1.Name(".v1"), v2.Name(".v2") — распечатайте имена маршрутов в стартовом логе. В-пятых, сломайте дерево нарочно: зарегистрируйте v1.Get("/api/v1/tasks", ...) внутри ветки и найдите этот маршрут curl-ом по удвоенному пути; затем выведите Use со стражем за маршруты и объясните по порядку регистрации, почему страж замолчал. В конце соберите всё обратно и прогоните шесть сценариев из финального блока одним скриптом.

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

### Чем группа отличается от app.Use("/api", mw) — можно ли жить вообще без Group?

Use-маршрут с префиксом и группа технически близки: оба совпадают с запросом по началу пути. Группа добавляет три вещи: префикс склеивается с относительными путями маршрутов (не копипастить /api в каждом Get), вложенность (v1 := api.Group("/v1") с наследованием префикса и parentGroup) и неймспейс Name. Жить без Group можно ровно до первого переименования зоны: без групп переезд /api на /apiv2 — правка каждого маршрута, с группами — одна строка. Страж на ветке при этом и так выражается Use-ом — app.Use("/api/v1", requireAPIKey(...)) эквивалентен api.Group("/v1", requireAPIKey(...)).

### Как дать каждой версии API свой формат ошибок?

Два способа по возрастанию самостоятельности. Первый: один ErrorHandler корня с ветвлением по пути — errors.As на *fiber.Error, затем strings.HasPrefix(c.Path(), "/api/v2") и разные конверты для v1, v2 и страниц; всё в одной функции, один формат для клиента при желании. Второй: смонтировать на версию отдельное приложение — v2App := fiber.New(fiber.Config{ErrorHandler: ...}) и app.Use("/api/v2", v2App): метод App.ErrorHandler ищет обработчики по префиксам смонтированных sub-app и вызывает самый специфичный, поэтому ошибки ветки v2 уйдут в его собственный обработчик, а ветки v1 — в корневой. Начинайте с первого способа: второй оправдан, когда у версии действительно свой стандарт ответа или отдельная команда.

### Наследуют ли группы middleware друг друга и корня — и что с порядком?

Наследование в fiber не объектное, а геометрическое: слои группы — это Use-маршруты на префиксе, и Use совпадает с началом пути. Поэтому на запрос /api/v1/tasks исполнятся: Use-маршруты корня (они совпадают с любым путём), Use-маршруты группы /api (путь начинается с /api), Use-маршруты ветки /api/v1, и только затем endpoint-маршрут. Порядок внутри этого списка — порядок регистрации в стеке: кто объявлен раньше, тот и исполнится раньше, поэтому корневые слои регистрируют до создания групп, а слои группы — до её маршрутов. Из той же механики следует, что «поздний» Use не распространяется на уже зарегистрированные маршруты: стек сканируется с начала, endpoint отвечает раньше, чем проход дойдёт до слоя, добавленного после него.
