Содержание урока · 10 разделов
- Chi subrouter: r.Route возвращает chi.Router
- Mount против Route: разница по исходникам chi
- Структура проекта go: пакет handlers и Routes()
- Приём → когда
- Middleware на ветку: Group и Use
- Спор: роутер в main против Routes() в каждом пакете
- Чек-лист резки большого роутера
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Роутер сервиса доставки начинался скромно: 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 на подроутеры
GOr := 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
GOpackage 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 → ?Пакет profiles и пакет users зарегистрировали GET /api/users — profiles на строке выше. chi молча оставил последний: хендлер profiles не отвечал никогда, с момента первого запуска. Отзывы о пользователях не приходили неделю: тесты обоих пакетов были зелёные, потому что каждый звал свой хендлер напрямую, без общего роутера. Защита — единственная точка сборки, где видны все монтирования, и «маршруто-тест»: прогон запроса по каждому пути смонтированной ветки со сверкой, что отвечает именно тот пакет. Перестановка двух строк в main не должна менять прод.
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: приватная ветка
GOr := 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) // без RequireAdminGroup вернул inline-роутер: дерево то же самое, префикса нет — только свой Use. RequireAdmin действует на всё внутри функции и не касается /users. Logger остался на корне, снаружи группы, поэтому в его строке лога виден честный 401, который RequireAdmin отдаст чужому. Проверьте это локальным Go: curl без заголовка на /admin/stats — 401 и в ответе, и в логе, а /users проходит без авторизации.
Mount на префикс, который уже есть в дереве, роняет процесс при регистрации: «chi: attempting to Mount() a handler on an existing path». Это дешёвая ошибка — сервис не поднялся, CI красный, видно сразу. Тихий вариант хуже: повторный Get на тот же путь перезаписывает хендлер молча, и от него спасает только маршруто-тест из истории выше. Ещё один бонус Mount из исходников: подроутер без собственного NotFound наследует родительский — ваш JSON-404 работает и внутри смонтированного пакета без правок.
Спор: роутер в 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 на ветку и регистрацию маршрута.
Соберите routes.go пакета
Расставьте строки контракта пакета handlers в правильном порядке: от функции до закрытия ветки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: subrouters и Mount
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 и смонтируйте пакет. Добавьте маршруто-тест, который стучится в каждый путь ветки и сверяет, что отвечает именно ваш пакет, — проверка на тихий дубль.
Личные заметкиПусто
Вопросы и ответы по уроку
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 перед их монтированием.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Тестирование хендлеров: httptest и chi
- Аутентификация: JWT и сессии как middleware
- Производительность chi: что под капотом
- Деплой: chi за reverse-прокси
- Порядок middleware: почему он важен
- Валидация параметров и тел запросов
- Graceful shutdown: выключаемся вежливо
- Централизованные ошибки: errorHandler и NotFound
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter