---
title: "Wildcards: маршруты со звёздочкой"
description: "chi wildcard: r.Get(\"/files/*\") ловит весь хвост пути, chi.URLParam(r, \"*\") достаёт его. Отличие от {param}, SPA-фолбэк, пустой хвост и паника на /docs/*.pdf."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/09-wildcards/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 16
---

# Wildcards: маршруты со звёздочкой

*Курс «Chi: роутер для Go HTTP-сервисов», урок 9 из 25. ~16 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/09-wildcards/*

Маршруты со звёздочкой в chi ловят хвост пути целиком: один шаблон /files/* закрывает и файлы любой вложенности, и SPA-приложение, чьи пути знает только браузерный роутер. Разберём chi wildcard на практике: что вернёт chi.URLParam(r, "*"), чем звёздочка отличается от {param}, что делать с /files/ без хвоста и почему /docs/*.pdf роняет сервер на старте.

В REST-каркасе [курса chi](/courses/chi/) каждый адрес был известен заранее: /notes, /notes/{id}, /notes?page=2 — шаблон перечисляет сегменты, и их число фиксировано. Для JSON API этого хватает, но добавьте к сервису файлы: отчёты лежат в files/reports/2024/q4.xls, картинки — в files/photos/trip/img.png, и глубина вложенности заранее неизвестна, потому что папки создают пользователи. Писать маршрут на каждый уровень — /files/{a}, /files/{a}/{b}, /files/{a}/{b}/{c} — значит проиграть бесконечную игру. Второй сюжет — SPA-приложение из [урока про Mount и статику](/courses/chi/lessons/08-mount-statica/): браузерный роутер придумывает пути /dashboard/settings/team сам, и сервер обязан отдать index.html на любой из них. Обе задачи закрывает один инструмент — wildcard: шаблон со звёздочкой на конце, который ловит весь остаток пути, сколько бы сегментов в нём ни было. У инструмента три сюрприза: пустой хвост, жадность и запрет на фильтрацию по расширению — им и посвящён урок.

### Маршруты со звёздочкой в chi: r.Get("/files/*", h)

Звёздочка в шаблоне означает «весь остаток пути, включая слэши»: r.Get("/files/*", listFiles) ловит и /files/a.txt, и /files/reports/2024/q4.xls — всё, что начинается с /files/. Хвост достаётся той же chi.URLParam, но под особым именем: chi.URLParam(r, "*") вернёт reports/2024/q4.xls — без ведущего слэша, разделитель после /files роутер съедает при сопоставлении. Синтаксис документирован в README chi: рядом с named params вида /users/{userID} описан wildcard /admin/* и значение по ключу "*". Два жёстких правила. Первое: звёздочка обязана быть последним символом шаблона — /files/*.pdf не «маршрут с фильтром», а паника при регистрации, chi не даст даже запуститься; фильтрация переезжает в хендлер, разберём ниже. Второе: в шаблоне ровно одна звёздочка, и имени у неё нет — это фиксированный ключ "*", двух разных wildcards в одном маршруте не бывает. В остальном это обычный маршрут: метод, хендлер, обычная регистрация.

### Хвост пути целиком

```go
r.Get("/files/*", func(w http.ResponseWriter, r *http.Request) {
        tail := chi.URLParam(r, "*")

        if tail == "" {
                fmt.Fprint(w, "каталог /files/, хвост пуст")
                return
        }

        http.ServeFile(w, r, "./files/"+tail)
})
```

Хвост приходит одной строкой с внутренними слэшами: /files/reports/2024/q4.xls даёт reports/2024/q4.xls — разбирать по сегментам не нужно. http.ServeFile из net/http отдаёт файл с Content-Type и Last-Modified и отклоняет запросы, где в пути запроса есть «..», так что выход за корень ./files закрыт стандартной библиотекой. Ветка tail == "" срабатывает на адрес /files/ со слэшем и пустым хвостом — что с ней делать, отдельная тема ниже. Для запуска нужен локальный Go с каталогом ./files рядом с бинарником: песочница сайта файловую систему сервиса не видит.

> **Хвост приходит закодированным: %20 останется %20.** Chi маршрутизирует по RawPath, когда в адресе есть процентное кодирование, поэтому хвост из chi.URLParam(r, "*") приходит как есть: /files/my%20report.pdf даст my%20report.pdf, а не my report.pdf. ServeFile будет искать файл с процентами в имени и честно ответит 404. Лечится одной строкой url.PathUnescape перед склейкой пути; выход за корень и так закрыт проверкой «..» внутри ServeFile.

### Wildcard против {param}: один сегмент против хвоста

На первый взгляд шаблоны /files/{name} и /files/* близнецы — оба «ловят что угодно после /files/». Разница в границах. {name} ловит ровно один сегмент: /files/q4.xls совпадёт, а /files/reports/q4.xls — нет, роутер ответит 404. Звёздочка ловит весь хвост с любым числом сегментов — слэши для неё не граница. Отсюда разное применение: {param} — для идентификаторов ресурса без слэшей, id заметки и slug статьи из [урока про URLParam](/courses/chi/lessons/04-urlparam/); wildcard — для файлов, SPA-путей и всего, что структурно «хвост», а не «сегмент». Третье различие — в именах: имя для {name} придумываете вы, у звёздочки оно всегда одно — "*". Шаблоны можно смешивать: /hubs/{hubID}/view/* ловит параметр по имени и хвост под "*" одновременно — chi так делает в собственных тестах, и значения приходят парой: hubID отдельно, хвост отдельно.

### {param} и wildcard рядом

| Критерий | {param}: /files/{name} | Wildcard: /files/* |
| --- | --- | --- |
| Что ловит | один сегмент до слэша | весь хвост, включая слэши |
| /files/reports/q4.xls | не совпадёт: два сегмента | совпадёт: хвост целиком |
| Имя в URLParam | придумываете вы: "name" | всегда "*" |
| Позиция в шаблоне | любая, можно несколько | только в конце, ровно одна |
| Фильтр по расширению | нет — проверка в хендлере | нет — и /files/*.pdf паникует |
| Типовое применение | id, slug, номера | файлы, SPA-фолбэк |

### Проверка: что вернёт звёздочка

**Вопрос:** Зарегистрирован r.Get("/files/*", h). Пришёл запрос GET /files/reports/2024/q4.xls. Что вернёт chi.URLParam(r, "*")?

- ✅ "reports/2024/q4.xls": весь хвост без ведущего слэша
- "/reports/2024/q4.xls": хвост вместе со слэшем
- "q4.xls": только последний сегмент
- Пустую строку: звёздочка не разбирает вложенность

Слэш после /files роутер съедает при сопоставлении, поэтому хвост приходит без ведущего слэша — то же видно и в тестах chi. Звёздочка не знает про сегменты: для неё reports/2024/q4.xls — одна строка. Если хвост нужно разбирать, это забота хендлера, а не роутера.

### SPA-фолбэк: index.html на любой незнакомый путь

Одностраничное приложение живёт по правилу: сервер отдаёт index.html на любой путь, потому что /dashboard/settings/team существует только в JS-роутере браузера. Из урока про статику остался обещанный механизм: настоящие файлы монтируются на /assets, а wildcard-обёртка решает судьбу каждого хвоста на ветке приложения. Решение принимает простая проверка расширения: в хвосте есть точка — просят файл, его отдаёт ServeFile; точки нет — это путь роутера SPA, отдавайте index.html. Приём грубоватый и честно об этом говорит: стили и скрипты всегда с расширением, маршруты SPA — никогда; ломается он только на путях вида /download.tar.gz, которые тоже файлы, — тогда условие дополняется, а не выбрасывается. Для отдачи index.html годится тот же http.ServeFile с явным именем: заголовки и кэш поставит он же, отдельной логики не нужно.

### Обёртка SPA с проверкой точки

```go
r.Handle("/app/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tail := chi.URLParam(r, "*")

        if strings.Contains(tail, ".") {
                http.ServeFile(w, r, "./static/"+tail)
                return
        }

        http.ServeFile(w, r, "./static/index.html")
}))
```

Handle без метода закрывает ветку на все HTTP-запросы — для SPA это то, что нужно: браузер стучится и GET-ом за разметкой, и OPTIONS-ом из CORS-логики. Хвост с точкой уходит в ServeFile с фактическим файлом, хвост без точки — в index.html: /app/settings вернёт разметку приложения со статусом 200. strings.Contains здесь сознательно примитивнее filepath.Ext — проверка спрашивает «похоже ли на файл?», а не «какое расширение?». Проверьте поведение локальным Go: curl /app/settings должен вернуть HTML, а /app/css/app.css — CSS.

### Что получит клиент?

```go
r.Handle("/app/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tail := chi.URLParam(r, "*")
        if strings.Contains(tail, ".") {
                http.ServeFile(w, r, "./static/"+tail)
                return
        }
        w.Write([]byte("INDEX"))
}))

// в ./static лежат index.html и css/app.css
// запрос: GET /app/settings
```

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

- ✅ INDEX: точек в хвосте нет, срабатывает фолбэк
- Содержимое index.html: ServeFile вызывается всегда
- 404 page not found: маршрута /app/settings нет
- 405 Method Not Allowed: Handle не ловит GET

Хвост settings без точки не проходит проверку расширения, и хендлер отвечает сам — строка INDEX в примере стоит вместо отдачи index.html. Маршрут существует: звёздочка закрыла всю ветку /app/, включая /app/settings, поэтому ни 404, ни 405 роутер не вернёт — судьбу пути целиком решает хендлер.

### Пустой хвост: /files/ и /files

У звёздочки есть пограничный случай, о котором вспоминают по журналу ошибок. Адрес /files/ со слэшем попадает в маршрут /files/*, и хвост оказывается пустой строкой: chi.URLParam(r, "*") вернёт "". Это не сбой, а честный ответ роутера: хвоста нет. Что с ним делать — решение вашего API: ответить 404, отдать листинг каталога или приветствие, как в первом примере урока. А вот /files без слэша в этот маршрут не попадает вовсе: chi сопоставляет адрес посегментно, шаблон содержит слэш после /files, и без него совпадения нет — клиент получает 404. Лечится либо отдельной регистрацией r.Get("/files", ...), либо middleware.RedirectSlashes из пакета chi/middleware, который переадресует адреса к каноническому виду со слэшем. Отдельная регистрация честнее для API: статус переадресации и ручной маршрут не спорят между собой, а журнал остаётся чистым.

### Проверка: звёздочка и расширение

**Вопрос:** Что произойдёт при регистрации r.Get("/docs/*.pdf", h)?

- Маршрут будет отдавать только PDF-файлы
- ✅ chi запаникует на старте: звёздочка обязана быть последней в шаблоне
- Маршрут зарегистрируется и никогда не совпадёт
- chi отбросит .pdf и зарегистрирует /docs/*

Звёздочка ловит весь хвост, поэтому стоять за ней в шаблоне нечему: chi проверяет это при вставке маршрута и паникует с текстом про последний pattern. Маршрута с фильтром по расширению не существует ни в каком виде — проверка пишется в хендлере: достали хвост, сверили filepath.Ext, не то — 404.

### Конфликт маршрутов: что паникует, а что выигрывает

Расхожее мнение: жадный wildcard глотает соседние маршруты. В chi карта приоритетов устроена иначе, и это хорошо видно по исходникам и тестам роутера: статичные ветки и ветка со звёздочкой — разные дети одного узла дерева, и при поиске статичные пробуются первыми. Специфичный маршрут /files/reports побеждает звёздочку /files/* при любом порядке регистрации — была ли звёздочка добавлена раньше или позже, значения не имеет: в тестах chi /admin/user уходит в статичный маршрут, /admin/hi — в catchall. Паника при конфликте паттернов в одной ветке — тоже не про chi: тот же шаблон, зарегистрированный дважды, молча перезаписывается, последний хендлер выигрывает, а вот повторный Mount на занятый путь роняет программу честно, с паникой на старте. Настоящая опасность — не регистрация, а хвосты глубже специфичного маршрута: /files/reports/q4.xls уйдёт в звёздочку, потому что у статичной ветки после reports продолжения нет.

> **Хвост глубже маршрута уходит в звёздочку.** Схема аварии: сервис отдаёт файлы через /files/*, позже добавили API-маршрут r.Get("/files/reports", listReports) — и он работает. Через месяц клиент запрашивает /files/reports/2024 — отчёт конкретного года. Точного маршрута на такой хвост нет, chi уходит в звёздочку, и в зависимости от того, что там живёт, приходит либо «файл не найден», либо index.html SPA — со статусом 200. API-клиент парсит HTML и падает на JSON.parse, а пейджер молчит: 200 в мониторинге — успех. Лекарство: ветка со звёздочкой — всегда «ничей хвост», и поведение в ней проектируется заранее — 404 с JSON для API-путей, index.html для SPA; специфичные маршруты держат рядом со звёздочкой и покрывают тестом хвосты.

### Пять правил звёздочки

- Звёздочка — последняя в шаблоне и без текста после неё: /files/*.pdf роняет регистрацию с паникой.
- Хвост достаётся chi.URLParam(r, "*") одной строкой, без ведущего слэша; имя "*" фиксировано.
- {param} ловит один сегмент до слэша, wildcard — весь хвост: вложенные пути ловит только звёздочка.
- /files/ даёт пустой хвост "", /files без слэша в маршрут /files/* не попадает вовсе.
- Статичные маршруты сильнее звёздочки при любом порядке регистрации, но хвост глубже них уходит в wildcard.

### Фильтр по расширению в хендлере

```go
r.Get("/docs/*", func(w http.ResponseWriter, r *http.Request) {
        tail := chi.URLParam(r, "*")

        if filepath.Ext(tail) != ".pdf" {
                http.Error(w, "только PDF", http.StatusNotFound)
                return
        }

        http.ServeFile(w, r, "./docs/"+tail)
})
```

Идея «/docs/*.pdf» в chi не существует — фильтр живёт здесь: filepath.Ext вырезает расширение из хвоста и сравнивает с ".pdf", чужой формат получает 404 ещё до обращения к диску. Ext вызывается у хвоста, а не у полного пути: для /docs/reports/q4.pdf он вернёт ".pdf". ServeFile после проверки отдаёт файл со стандартными заголовками; расширение — подсказка, а не гарантия: Content-Type клиент получит по фактическому содержимому файла.

### Соберите SPA-обёртку

Восстановите регистрацию ветки, доставание хвоста, проверку расширения и отдачу фолбэка.

```go
r.___("/app/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tail := chi.___(r, "*")

        if strings.___(tail, ".") {
                http.ServeFile(w, r, "./static/"+tail)
                return
        }

        http.___(w, r, "./static/index.html")
}))
```

Подсказка: Handle без метода закрывает ветку на все запросы; хвост достаёт chi.URLParam; точку ищет strings.Contains; файлы отдаёт http.ServeFile.

Правильные ответы в пропусках: `Handle`, `URLParam`, `Contains`, `ServeFile`

### Соберите SPA-хендлер по строкам

Расставьте строки хендлера: от получения хвоста до отдачи файла или фолбэка.

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

1. `func spa(w http.ResponseWriter, r *http.Request) {`
2. `tail := chi.URLParam(r, "*")`
3. `if strings.Contains(tail, ".") {`
4. `http.ServeFile(w, r, "./static/"+tail)`
5. `}`
6. `http.ServeFile(w, r, "./static/index.html")`

### Карточки: wildcards

- **r.Get("/files/*", h)** — Маршрут со звёздочкой: ловит весь хвост пути после /files/ с любым числом сегментов. Звёздочка обязана быть последней в шаблоне, иначе chi паникует при регистрации.
- **chi.URLParam(r, "*")** — Достаёт хвост одной строкой без ведущего слэша: reports/2024/q4.xls. Имя "*" фиксировано, других имён у звёздочки нет.
- **Пустой хвост** — Адрес /files/ попадает в маршрут с пустым хвостом "" — решение принимает хендлер. Адрес /files без слэша в маршрут /files/* не попадает вовсе — нужен отдельный маршрут или RedirectSlashes.
- **Wildcard против {param}** — {param} ловит один сегмент до слэша, имя придумываете вы; звёздочка ловит хвост целиком и зовётся "*". В одном шаблоне — ровно одна звёздочка.
- **SPA-фолбэк** — Wildcard-обёртка: точка в хвосте — файл через ServeFile, без точки — index.html. Разметка уходит на любой путь браузерного роутера со статусом 200.
- **Приоритет маршрутов** — Статичные ветки chi пробует раньше звёздочки при любом порядке регистрации; повторный шаблон перезаписывается, повторный Mount паникует. Хвост глубже специфичного маршрута уходит в wildcard.

Звёздочка закрыла последний пробел в адресах: фиксированные сегменты, {param} для одного, wildcard для хвоста любой длины — серверу не страшны ни файлы вглубь, ни браузерный роутер. Осталась задача из другого измерения: данные, которые не живут в адресе. Middleware проверил токен, сгенерировал id запроса — и должен донести это до хендлера, не заводя глобальных переменных. Инструмент — context.Context внутри каждого *http.Request, и [следующий урок](/courses/chi/lessons/10-context/) разбирает его по-взрослому: кастомный тип ключа, helper-функции и цена строкового "user" из туториалов. Домашнее задание ниже собирает обе ветки этого урока — файлы и SPA — в один роутер: curl по трём адресам покажет разницу между файлом, фолбэком и пустым хвостом.

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

- Шаблон /files/* ловит весь хвост пути с любым числом сегментов; хвост достаётся chi.URLParam(r, "*") одной строкой без ведущего слэша.
- Звёздочка обязана быть последней в шаблоне: /docs/*.pdf роняет регистрацию паникой, фильтр по расширению пишется в хендлере через filepath.Ext.
- {param} ловит один сегмент до слэша, wildcard — весь хвост: вложенные пути и SPA-адреса ловит только звёздочка.
- /files/ даёт пустой хвост "" и решение принимает хендлер; /files без слэша в маршрут /files/* не попадает вовсе.
- Статичные маршруты chi пробует раньше звёздочки при любом порядке регистрации; повторный Mount на занятый путь паникует.
- Хвост глубже специфичного маршрута уходит в wildcard: вместо отчёта может прийти index.html со статусом 200 — тихий способ сломать API-клиента.
- SPA-фолбэк — wildcard-обёртка: точка в хвосте — файл через http.ServeFile, без точки — index.html.

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

Соберите в main.go две ветки: /files/* с отдачей файлов через http.ServeFile и 404 на пустой хвост, и /app/* как SPA-фолбэк с проверкой точки. Добавьте r.Get("/files/reports") и проверьте curl-ом три адреса: конкретный файл, вложенный хвост и /app/settings — третий должен вернуть HTML со статусом 200.

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

### Чем wildcard в chi отличается от звёздочки в Gin?

Синтаксисом и способом достать значение: chi ловит хвост шаблоном /files/* и отдаёт его через chi.URLParam(r, "*"), Gin пишет /files/*filepath и достаёт c.Param("filepath"). Механика та же — хвост одной строкой; а вот правила приоритета соседних маршрутов в роутерах различаются, chi старается специфичное.

### Как отдать только PDF через wildcard?

Никак на уровне шаблона: /docs/*.pdf паникует, звёздочка не умеет фильтровать. Фильтр пишется в хендлере: filepath.Ext(chi.URLParam(r, "*")) сравнивается с ".pdf", и на несовпадение хендлер отвечает 404 ещё до обращения к диску.

### Почему /files отвечает 404, а /files/ работает?

Шаблон /files/* содержит слэш после /files, и chi сопоставляет адрес посегментно: без хвостового слэша совпадения нет. Либо регистрируйте отдельный r.Get("/files", ...), либо включите middleware.RedirectSlashes — он переадресует адрес к каноническому виду со слэшем.
