Содержание урока · 15 разделов
- fiber группы маршрутов: одна ветка — один префикс
- Анатомия Group: пять полей и один Router-интерфейс
- Вложенные группы и getGroupPath: как склеиваются префиксы
- Общие middleware на группу: Use-маршрут на префиксе
- Версионирование api: v1 и v2 живут одновременно
- Ошибки на каждую версию: префикс решает
- Router-интерфейс: один контракт для app и группы
- Name: неймспейс имён маршрутов (базово)
- Сравнение с gin: две философии одного приёма
- Fiber Group и gin RouterGroup рядом
- Ловушки групп: симптом, причина, лечение
- Запуск, шесть сценариев curl и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 и четыре метода на ней
GOpackage 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()). Что именно произошло в этот момент?
Вложенные группы и 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
GOapp := 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, путь без версии, общий для всех поколений клиента. Родительская группа не «закрывается» после создания детей: это просто узел с префиксом, на котором можно регистрировать маршруты и дальше.
Проверьте склейку: хвостовой и ведущий слэши
Прочитайте код и выберите, что он выведет:
app := fiber.New()
api := app.Group("/api/") // хвостовой слэш в префиксе
v1 := api.Group("v1") // ведущий слэш забыли
v1.Get("/tasks", func(c fiber.Ctx) error {
return c.SendString("ok")
})Общие 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
GOapp := 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. Что ответит сервер?
Версионирование 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
Прочитайте код и выберите, что он выведет:
// ErrorHandler из четырнадцатого урока настроен:
// для путей /api отдаёт JSON {"error": ..., "code": ...}
// зарегистрированы ветки /api/v1 и /api/v2
// запрос: curl localhost:8080/api/v3/tasksОшибки на каждую версию: префикс решает#
Тизер из четырнадцатого урока мы обещали развить: «научившись группировать маршруты, вы получите обработчики ошибок на каждую версию 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
GOimport "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 вернулась ошибка. Кто её обработает?
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 встала под стражем, маршрут зарегистрировался, а функция-регистратор приняла любой узел дерева.
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 до ответа клиенту.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Сравнение с gin: две философии одного приёма#
Если вы прошли десятый урок курса gin — «Группы маршрутов», сегодняшняя тема покажется знакомой на девяносто процентов — и именно оставшиеся десять стоит проговорить, потому что это разные инженерные решения одинаковой задачи. Общее ядро совпадает дословно: группа = префикс + общие 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() на версию — оба способа мы разобрали выше.
Словарь урока
Запуск, шесть сценариев 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, а механику ErrorHandler, на которую легли версии, мы подробно разбирали в четырнадцатом уроке; фабрика requireAPIKey — в седьмом.
Дальше — шестнадцатый урок про WebSocket: постоянное двунаправленное соединение поверх того же приложения. Дерево маршрутов вам пригодится сразу: веб-сокетные ветки заводятся группами так же, как версии API, и живут под общими слоями.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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 отвечает раньше, чем проход дойдёт до слоя, добавленного после него.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT