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

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

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

Урок 19 из 25 ~18 мин +50 XP

Subrouters и структура большого сервиса

Chi subrouter — это r.Route, который строит поддерево, наполняет его через функцию и возвращает chi.Router: на тридцати строках регистрации маршрутов приходит пора резать. Разберём структуру проекта go: Mount против Route по исходникам, пакет handlers с Routes(), middleware только на ветку через Group и живое место с тихим дублем, съевшим чужой хендлер.

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

Роутер сервиса доставки начинался скромно: r.Get("/healthz", ...) и пара хендлеров прямо в main.go. Через полгода файл разросся до 240 строк: /api/users, /api/orders, /api/payments, /api/admin — всё вперемешку, сверху стек из десяти middleware. Merge-конфликты в main.go стали ежедневным ритуалом: два разработчика добавляют хендлеры в один файл, git развозит их по разным углам, и кто-то дольше решает конфликт, чем пишет логику. Поиск «а где у нас ручка отмены заказа» — это Ctrl+F по 240 строкам, а переезд POST /orders/refund в пакет заказов — час осторожного копипаста с молитвой, что ничего не отвалится. Сигнал, что пора резать, простой: один r.Route распух на 30 строк, в нём больше трёх уровней вложенности, и файл трогает больше двух человек. Инструмент для разреза в chi есть — r.Route, который возвращает подроутер, — но перед ним надо договориться о раскладке, иначе вместо свалки в одном файле получится свалка в пяти.

Chi subrouter: r.Route возвращает chi.Router#

Сигнатура: r.Route(pattern string, fn func(r chi.Router)) — chi создаёт пустой подроутер, передаёт его в fn и монтирует на префикс. В уроке про Route и Group подроутеры строились в одном файле — здесь важно то, что Route ещё и возвращает значение. По исходникам chi v5.0.8 (mux.go) это три строки: subRouter := NewRouter(), fn(subRouter), mx.Mount(pattern, subRouter) — и return subRouter. Возвращённое значение имеет тип chi.Router: готовый, уже смонтированный подроутер, которым можно распорядиться — отдать функции-сборщику, навесить ещё middleware через .Use, вернуть из конструктора пакета. Второй факт из тех же исходников: при fn == nil chi паникует «attempting to Route() a nil subrouter» — забытая функция ловится на старте, а не в проде. И третий: Route — это не отдельный механизм, а сахар над Mount; чем именно они различаются, смотрим в исходниках подробнее.

Режем /api на подроутеры

GO
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)

r.Route("/api", func(r chi.Router) {
        r.Route("/users", func(r chi.Router) {
                r.Get("/", listUsers)      // GET /api/users
                r.Get("/{id}", getUser)    // GET /api/users/{id}
        })
        r.Route("/orders", func(r chi.Router) {
                r.Get("/", listOrders)     // GET /api/orders
                r.Post("/", createOrder)   // POST /api/orders
        })
})

Параметр r во вложенных функциях затеняет внешний: внутри r.Get регистрирует маршрут уже на подроутере, поэтому путь / — это /api/users, а не корень сервиса. Классическая ловушка копипаста — перепутать вложенный r с внешним: маршрут уедет мимо префикса и тихо ответит 404. Общие middleware стоят на корне и просвечивают в обе ветки; веточные добавляются внутри Route тем же r.Use — про это отдельный блок ниже.

Проверка: что возвращает Route

usersBranch := r.Route("/users", func(r chi.Router) { r.Get("/", list) }). Какой тип у usersBranch?

Mount против Route: разница по исходникам chi#

r.Mount(pattern string, handler http.Handler) — нижний уровень: он монтирует на префикс любой http.Handler. Свой chi.Mux, стандартный http.ServeMux, файловый сервер — в уроке про статику (Mount и статика) так подключался http.FileServer. Возврата у Mount нет — монтируемое поддерево уже собрано до вызова. r.Route — обёртка над ним: создаёт новый Mux, прогоняет через fn, зовёт Mount и возвращает результат. Три поведения Mount из исходников стоит знать. Первое: регистрация на занятый путь паникует — «attempting to Mount() a handler on an existing path»: chi проверяет дерево через findPattern до вставки. Второе: Mount вешает хендлер и на pattern, и на pattern+"/" — хвост со слэшем не теряется. Третье: если у смонтированного подроутера нет своего NotFound и MethodNotAllowed, Mount копирует родительские — единый формат ошибок из урока 15 доезжает в поддерево бесплатно.

Структура проекта go: пакет handlers и Routes()#

Раскладка, которая держит большие сервисы: на каждую сущность — свой пакет. handlers/users с listUsers, getUser и зависимостью-Store; handlers/orders с заказами и своим Store; main остаётся дирижёром. Контракт пакета — одна функция: Routes(r chi.Router) регистрирует маршруты сущности на переданное дерево, а зависимости принимает параметрами. Вариант второй — конструктор New() chi.Router: пакет строит собственное дерево со своим стеком, а main монтирует его через Mount. Routes проще и держит общий стек в одном месте; New даёт пакету автономность в middleware — полезно, когда модуль переезжает между сервисами. Файлы внутри пакета тоже раскладываются: routes.go с одной функцией-контрактом, handlers.go с хендлерами, model.go с типами. Структура проекта go здесь сводится к правилу «пакет не знает своего префикса»: /users вешает main, и переезд сущности на другой путь — правка одной строки в точке сборки, а не поиск по всему пакету.

Пакет handlers/users: Routes поверх chi.Router

GO
package users

import (
        "net/http"

        "github.com/go-chi/chi/v5"
)

type Store interface {
        ByID(id string) (*User, error)
        List() ([]User, error)
}

// контракт пакета: префикс вешает вызывающий, пакет знает только свой хвост
func Routes(r chi.Router, store Store) {
        r.Get("/", list(store))    // GET /users
        r.Get("/{id}", get(store)) // GET /users/{id}
        r.Post("/", create(store)) // POST /users
}

// хендлеры-замыкания: зависимости не гуляют по глобальным переменным
func get(store Store) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                u, err := store.ByID(chi.URLParam(r, "id"))
                if err != nil {
                        http.Error(w, "нет пользователя", http.StatusNotFound)
                        return
                }
                render(w, u)
        }
}

Routes принимает общий chi.Router и не знает, на каком префиксе его смонтировали: пакет нельзя сломать переездом пути. Хендлеры — замыкания над Store: зависимости переданы явно, глобальных переменных нет, тест подсовывает фейковый Store и проверяет пакет изолированно. Такой расклад проверяется локальным Go командой go test ./handlers/users — пакет компилируется и тестируется без остального сервиса.

Проверка: Mount или Route для пакета

Пакет admin построил собственный chi-роутер adminMux со своим стеком. Как подключить его в main?

Приём → когда#

ПриёмЧто делаетКогда уместен
r.Get / r.Post в mainмаршрут прямо в корневое деревомаленький сервис до десятка ручек, одна команда
r.Route("/api", fn)поддерево с префиксом, своё Use внутригруппировка версий API и сущностей в одном файле
r.Mount("/users", usersMux)монтирует готовый http.Handler на префикспакет собрал свой mux, подключается сторонний хендлер, статика
r.Group(func(r chi.Router){ r.Use(...) })ветка без префикса — только middlewareприватная зона в том же файле: авторизация на часть ручек
users.Routes(r, store)пакет регистрирует маршруты на чужое деревораскладка по пакетам handlers/* с общим стеком в main
users.New() + r.Mountпакет владеет своим деревом целикоммодуль с собственным стеком, переиспользуемый между сервисами

Что ответит chi на /api/users?

Прочитайте код и выберите, что он выведет:

r := chi.NewRouter()

r.Get("/api/users", legacyList) // пакет profiles
r.Get("/api/users", newList)    // пакет users: тот же путь и метод

// запрос: GET /api/users → ?

Middleware на ветку: Group и Use#

При резке на пакеты всплывает старый вопрос из урока 07: как дать одной ветке свой middleware, не заливая его на весь сервис. Ответ — r.Group(func(r chi.Router) { r.Use(...) }): по исходникам Group возвращает mx.With() — inline-роутер, который разделяет с родителем то же самое дерево, но несёт собственный стек. Внутри Group можно и Use, и маршруты, и даже Route — префикса Group не добавляет, это чистая middleware-зона. Гибрид для раскладки по пакетам: r.Group(func(r chi.Router) { r.Use(RequireAdmin); admin.Routes(r, store) }) — авторизация из урока про собственные middleware стоит на ветке, а не в каждом хендлере пакета. Порядок имеет значение: Use внутри Route действует на всё поддерево, а маршрут, зарегистрированный до Use в том же файле, его не получит — chi применяет middleware, уже добавленные на момент регистрации.

Group с Use: приватная ветка

GO
r := chi.NewRouter()
r.Use(middleware.Logger)

r.Group(func(r chi.Router) {
        r.Use(RequireAdmin) // только на ветку ниже
        r.Route("/admin", func(r chi.Router) {
                r.Get("/stats", stats)  // GET /admin/stats
                r.Post("/ban", banUser) // POST /admin/ban
        })
})

r.Get("/users", listUsers) // без RequireAdmin

Group вернул inline-роутер: дерево то же самое, префикса нет — только свой Use. RequireAdmin действует на всё внутри функции и не касается /users. Logger остался на корне, снаружи группы, поэтому в его строке лога виден честный 401, который RequireAdmin отдаст чужому. Проверьте это локальным Go: curl без заголовка на /admin/stats — 401 и в ответе, и в логе, а /users проходит без авторизации.

Спор: роутер в main против Routes() в каждом пакете#

Партия центра: все маршруты в main — вся карта сервиса на одном экране, ничего не спрятано, дубль виден глазами. Минусы вы уже видели в начале урока: файл растёт, конфликты в git, main тянет импорты всех пакетов и превращается в узкое место ревью. Партия децентрализации: Routes() в каждом пакете — пакеты самодостаточны, конфликты редки, main тонкий; минус — карта размазана, «где ручка X» решается обходом пакетов, а тихий дубль возможен, если два пакета повесили Routes на один префикс. Граница у меня такая: до десятка ручек — роутер в main, игра не стоит свеч; дальше — Routes() в пакете сущности; модуль, который живёт в нескольких сервисах, — свой New() и Mount. Золотая середина — Routes() в пакетах плюс файл сборки, где каждое монтирование занимает одну строку: карта возвращается, конфликты остаются внутри пакетов, где им и место.

Чек-лист резки большого роутера#

  • Route возвращает chi.Router: подроутер можно вернуть из функции-сборщика и передать пакету.
  • Один пакет — одна сущность: handlers/users, handlers/orders; зависимости — параметрами Routes(), не глобали.
  • Mount — для готовых http.Handler, Route — для построения поддерева на месте, Group — только middleware без префикса.
  • Повторный Get на тот же путь перезаписывает хендлер молча; повторный Mount паникует на старте — «existing path».
  • Общий стек — на корне в main, веточный — r.Use внутри Route/Group; порядок Use фиксировать до регистрации маршрутов.
  • Маршруто-тест после резки: запрос по каждому пути ветки должен отвечать тем пакетом, что заявлен.

Соберите ветку с авторизацией

Восстановите три вызова: поддерево с префиксом, middleware на ветку и регистрацию маршрута.

r.("/users", func(r chi.Router) { r.(RequireAuth) // middleware только на эту ветку r.("/{id}", getUser) // GET /users/{id} })

Соберите routes.go пакета

Расставьте строки контракта пакета handlers в правильном порядке: от функции до закрытия ветки.

Строки

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

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

Карточки: subrouters и Mount

Термин

r.Route(pattern, fn)

нажмите, чтобы перевернуть
Определение

Строит под-Mux: subRouter := NewRouter(), fn(subRouter), Mount(pattern, subRouter) — и возвращает chi.Router. При fn == nil паникует «attempting to Route() a nil subrouter».

1 / 6

main сжался до пары десятков строк: общий стек, монтирования по строке на сущность, единые NotFound и MethodNotAllowed. Пакеты handlers самостоятельны, конфликты в git переехали из main внутрь сущностей, а тихий дубль прикрыт маршруто-тестом. Побочный эффект разреза оказался приятнее ожиданий: хендлеры-замыкания с явными зависимостями проверяются в изоляции — подсовываешь фейковый Store и зовёшь хендлер, как его зовёт chi. Как именно строится такой тест без сети и без портов — тема следующего урока, а параметры маршрутов в хендлерах мы разбирали в уроке про URLParam. Полная карта курса — на странице chi.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • r.Route(pattern, fn) строит под-Mux, наполняет через fn и возвращает chi.Router — подроутер выносится в функцию и пакет.
  • r.Mount(pattern, handler) монтирует любой http.Handler и ничего не возвращает; Route — сахар над Mount.
  • Структура проекта go: handlers/users и handlers/orders отдают Routes(r chi.Router, deps...) — префикс вешает main, пакет знает только свой хвост.
  • Повторный Get на тот же путь молча перезаписывает хендлер; повторный Mount паникует на старте — «attempting to Mount() a handler on an existing path».
  • Group — inline-роутер без префикса: r.Use внутри действует только на ветку до конца функции.
  • Mount копирует подроутеру родительские NotFound и MethodNotAllowed — единый формат ошибок доезжает в смонтированный пакет.
  • До десятка ручек — роутер в main; дальше — Routes() в пакете; карта маршрутов возвращается файлом сборки с монтированием в одну строку.

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

Вынесите одну сущность из main в пакет handlers: файл routes.go с func Routes(r chi.Router, store) и хендлерами-замыканиями над Store. В main повесьте префикс через r.Route и смонтируйте пакет. Добавьте маршруто-тест, который стучится в каждый путь ветки и сверяет, что отвечает именно ваш пакет, — проверка на тихий дубль.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Subrouters и структура большого сервиса)

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

Route или Mount — что выбрать для выноса пакета?

Если пакет умеет только регистрировать маршруты — Routes(r chi.Router) и r.Route в main: стек общий, точка сборки одна. Если пакет строит своё дерево с собственным стеком — New() chi.Router и r.Mount в main. Route удобнее для типовой раскладки, Mount — для автономных модулей, статики и чужих хендлеров.

Чем Group отличается от Route?

Group не создаёт поддерево и не добавляет префикс: по исходникам он возвращает mx.With() — inline-роутер на том же дереве. Group — только middleware-зона: Use внутри действует на зарегистрированные после него маршруты ветки. Route строит новое поддерево с префиксом и монтирует его в родителя.

Куда класть общий middleware — в main или в пакет?

Сквозные слои — RequestID, RealIP, Logger, восстановление после паник — в main до монтирования: они должны накрывать все ветки одинаково и в одном порядке. Специфичное для сущности — внутрь Routes() пакета или в Group в точке сборки. Промежуточный случай — авторизация на группе пакетов: Use в Group перед их монтированием.

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

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

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

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

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter