---
title: "Обслуживание статических файлов"
description: "Статические файлы в gin: Static и StaticFS с gin.Dir, StaticFile для favicon, запрет листинга директорий, кэш-заголовки через middleware, SPA-фолбэк и embed.FS."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/11-statika/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 15
---

# Обслуживание статических файлов

*Курс «Gin: самый популярный веб-фреймворк Go», урок 11 из 25. ~15 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/11-statika/*

В прошлом уроке мы встретили Static, StaticFS и StaticFile в интерфейсе IRoutes и отложили разговор — теперь полный разбор gin статики. Разберём, какие маршруты реально регистрирует r.Static, почему листинг директорий по умолчанию выключен, как устроена подмена цепочки на NoRoute для отсутствующих файлов, зачем статике Cache-Control и как упаковать всё это в один бинарник через embed. На этой статике живёт HTML-страница нашего трекера, а [группы маршрутов из прошлого урока](/courses/gin/lessons/10-gruppy/) дадут middleware для кэша.

### gin статика: Static, StaticFS и StaticFile из IRoutes

Начнём с постановки. До сих пор наш трекер отвечал только JSON — конвертами Envelope из урока про ответы. Но у любого веб-приложения есть второй этаж: stylesheet, скрипты, картинки, favicon, robots.txt. Эти gin статические файлы не меняются от запроса к запросу, и городить для них JSON-хендлеры смешно. Можно, конечно, отдавать их nginx-ом перед приложением — в проде так и делают, — но локально, в демо и в маленьких сервисах хочется, чтобы бинарник раздавал всё сам. В gin статика обслуживается тремя хелперами, которые живут в интерфейсе IRoutes — том самом, что мы разбирали в прошлом уроке: r.Static для папки, r.StaticFile для одного файла и r.StaticFS для папки со своей файловой системой. Все три — методы RouterGroup, поэтому работают и на движке, и на вложенных группах: это не сахар на уровне движка, а обычная регистрация маршрутов. Важно понимать, что происходит под капотом: Static не подключает никакой сторонний статик-сервер — он оборачивает стандартный http.FileServer из net/http, тот же, что вы использовали бы без всякого фреймворка. Значит, поведение — семантика 304, поддержка Range для докачки, Content-Type по расширению — достаётся вам из стандартной библиотеки, и всё, что вы знаете про http.FileServer, применимо к gin напрямую. В этом уроке мы разберём все три хелпера, заглянем в их исходники — там спрятаны два неочевидных сюрприза про статус-коды, — навесим кэш-заголовки через middleware на группу и соберём статику в бинарник через embed. Проверять будете curl-ом: -I для HEAD, -i для заголовков.

### Базовая раскладка: папка, favicon, robots

```go
package main

import (
        "net/http"

        "github.com/gin-gonic/gin"
)

func main() {
        r := gin.Default()

        // Всё содержимое ./assets доступно по /assets/...
        r.Static("/assets", "./assets")

        // Одиночные файлы по точным путям.
        r.StaticFile("/favicon.ico", "./web/favicon.ico")
        r.StaticFile("/robots.txt", "./web/robots.txt")

        api := r.Group("/api")
        v1 := api.Group("/v1")
        registerTodoRoutes(v1) // хендлеры из прошлых уроков

        if err := r.Run(":8080"); err != nil {
                panic(err)
        }
}
```

Три вызова — и файловый сервер работает рядом с API. Папка ./assets монтируется под префиксом /assets: файл ./assets/style.css отдаётся на GET /assets/style.css. StaticFile привязывает один конкретный файл к одному конкретному пути — favicon и robots.txt живут в ./web, а раздаются с корня. Пути к файлам на диске относительные: они считаются от рабочей директории процесса, а не от файла main.go, — запустите бинарник из другой папки, и статика «исчезнет».

### Анатомия StaticFS: catch-all, GET и HEAD

Откройте routergroup.go и найдите три метода — там всё прозрачно. Static это одна строка: return group.StaticFS(relativePath, Dir(root, false)) — «папка без листинга», и мы сейчас расшифруем, что это значит. StaticFS — центральный метод тройки: запрос staticfs gin в поиске приводит в первую очередь сюда, и вы сейчас увидите его изнутри. Метод делает три вещи. Первая — проверка на forbidden-символы: если в relativePath есть двоеточие или звёздочка, метод паникует с текстом URL parameters can not be used when serving a static folder — параметры пути и catch-all несовместимы с префиксом статики, префикс должен быть чистым литералом. Вторая — вычисление шаблона маршрута: urlPattern := path.Join(relativePath, "/*filepath"), то есть r.Static("/assets", "./assets") регистрирует catch-all /assets/*filepath — тот же тип маршрута, что и :id из третьего урока, только съедает он весь хвост пути, включая слэши. Третья — регистрация двух методов сразу: GET и HEAD на один и тот же хендлер. HEAD — это «GET без тела»: браузеры и мониторы дёргают его для проверки ресурсов, и без него не работал бы curl -I. Сам хендлер собирает createStaticHandler: он вычисляет absolutePath, оборачивает http.FileServer в http.StripPrefix(absolutePath, ...) — чтобы из пути /assets/style.css отрезать префикс, — и в теле запроса достаёт c.Param("filepath"). Дальше чистый net/http: fs.Open файла, Stat, Content-Type по расширению через mime.TypeByExtension, Last-Modified, проверка If-Modified-Since, Range и io.Copy. Обратите внимание: проверка «:» и «*» стоит и в StaticFile — точечный путь тоже должен быть литералом. И ещё: Static возвращает IRoutes — returnObj() возвращает либо группу, либо движок, если группа корневая, поэтому вызовы можно продолжать цепочкой.

### Проверка: что зарегистрирует r.Static

**Вопрос:** r.Static("/assets", "./assets") — какие маршруты появились в дереве?

- Только GET /assets — хвост пути FileServer разберёт сам
- ✅ GET и HEAD на /assets/*filepath — catch-all съедает весь хвост, включая слэши
- Все девять методов на /assets/*filepath — Static регистрирует через Any
- Ничего: Static возвращает middleware, его нужно повесить через Use

Static это обёртка над StaticFS: urlPattern := path.Join(relativePath, "/*filepath"), затем group.GET(urlPattern, handler) и group.HEAD(urlPattern, handler). Catch-all *filepath — тот же механизм, что и :id из урока про параметры, только захватывает весь остаток пути. Any тут ни при чём: POST /assets/style.css уйдёт в NoRoute, файловый сервер отвечает только на чтение.

### Листинг директорий: gin.Dir и OnlyFilesFS

Вот и первый сюрприз. Static передаёт в StaticFS значение Dir(root, false) — функция Dir из файла fs.go: при listDirectory true она возвращает обычный http.Dir, а при false оборачивает его в OnlyFilesFS — файловую систему, у которой метод Readdir всегда возвращает nil. Readdir — это то, чем http.FileServer перечисляет содержимое каталога; вернёт nil — листинга нет. Почему выключено по умолчанию: открытый листинг вываливает наружу весь layout директории — бэкапы, служебные файлы, названия приватных папок; это вечная находка сканеров безопасности. Включить можно явно: r.StaticFS("/docs", gin.Dir("./docs", true)) — и GET /docs/ отдаст HTML-страницу со списком файлов, из которой кликаются дальше. Но второй сюрприз интереснее: хендлер статики начинается с проверки, не OnlyFilesFS ли передана, и если да — ставит в gin-обёртке статус 404 заранее. Выглядит дико: зачем помечать 404-ом ответ, который ещё не случился? Разгадка в том, что WriteHeader у gin-обёртки не отправляет ничего — он лишь запоминает статус в поле, а реальная отправка случается при первой записи тела. Если потом net/http решит, что всё хорошо, и вызовет WriteHeader(200), — статус перезапишется. А если запросили директорию без index.html и листинг выключен, файловый сервер отрисует пустой листинг (каркас страницы, в котором нет ни одной строки), статус никто не перезапишет — и клиент получит честный 404 с почти пустым телом. Красивое следствие: gin по умолчанию отдаёт 404 на корень папки без index.html, даже если папка существует — «пустой» ответ вместо оглавления.

### Запрос без слэша: что ответит движок

```go
r := gin.Default()
r.Static("/assets", "./assets")
// ./assets/index.html существует

// curl -i localhost:8080/assets
// curl -iL localhost:8080/assets
```

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

- Первый запрос — 404, второй — 404: маршрута /assets нет, только /assets/*filepath
- ✅ Первый — 301 с Location: /assets/, второй с -L — 200 и index.html: работает RedirectTrailingSlash из прошлого урока
- Оба — 200: catch-all совпадает с /assets и без слэша
- Первый — 200, второй — 301: gin сначала отдаёт index.html, потом редиректит

В дереве зарегистрирован только catch-all /assets/*filepath, а узел /assets хендлера не имеет — дерево даёт TSR-рекомендацию (в тестах gin маршрут /src/*filepath прямо перечислен среди TSR-случаев): «попробуй с хвостовым слэшем». RedirectTrailingSlash включён по умолчанию, для GET это 301, для остальных методов 307 — ровно как мы разбирали в уроке о группах. curl без -L редирект не провожает: увидите 301 и Location. С -L — повторный запрос на /assets/, catch-all отдаёт filepath = /, FileServer находит index.html и показывает его вместо оглавления.

### Статус-пазл: почему файлы всё-таки приходят с 200

Теперь соберём пазл статуса целиком, потому что это самое неочевидное место в gin статике. Сценарий: r.Static("/assets", "./assets"), в папке лежит style.css. Запрос GET /assets/style.css проходит по цепочке: Logger, затем статический хендлер. Хендлер видит OnlyFilesFS — и, как мы выяснили, ставит в gin-обёртке статус 404. Файл открывается, fileServer.ServeHTTP принимает дело. Внутри net/http функция serveContent выставляет заголовки — Content-Type, Last-Modified, Accept-Ranges, Content-Length — и перед записью тела явно вызывает w.WriteHeader(200). Это не gin, это стандартная библиотека; и через gin-обёртку этот вызов перезаписывает запомненный статус с 404 на 200. Тело льётся через io.CopyN, клиент получает 200. Когда же preset-404 выживает? В двух случаях. Первый: файл не открылся — Open вернул ошибку, и хендлер сам ставит 404, об этом следующий блок. Второй: запросили директорию — с отключённым листингом dirList рисует пустой каркас и WriteHeader-а поверх не случится, уходит 404. Поэтому и всплывают редкие репорты вроде issue #2227 «иногда ассеты отвечают 404»: не иногда, а ровно в этих двух сценариях. Практический вывод без похода в исходники: всегда кладите в корень раздаваемой папки index.html, если префикс могут открывать «голым» путём, и не пугайтесь 404-а на /assets/ — это не поломка, это выключенный листинг. Проверьте пазл прагматично: curl -i localhost:8080/assets/style.css покажет 200 и набор заголовков из стандартной библиотеки, curl -i localhost:8080/assets/ — 404 с крошечным HTML-телом.

### Файл существует: какой статус уйдёт клиенту

```go
r := gin.Default()
r.Static("/assets", "./assets")
// ./assets/style.css существует, 42 байта

// curl -i localhost:8080/assets/style.css
```

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

- 404: хендлер статики ставит 404 заранее, и это финальный статус
- ✅ 200: serveContent из net/http вызывает WriteHeader(200) перед телом и перезаписывает preset-статус
- 204 No Content: тела почти нет, gin схлопывает ответ
- 403 Forbidden: OnlyFilesFS запрещает прямой доступ к файлам

Preset-404 живёт только в поле gin-обёртки и никуда не отправлен. Успешная ветка fileServer доходит до serveContent, который перед записью тела зовёт w.WriteHeader(200) — обёртка перезаписывает статус. Только директории и отсутствующие файлы уносят с собой 404. Ровно поэтому r.Static не ломает браузеры: все живые файлы приходят с 200, Last-Modified и правильным Content-Type.

### Промах статики: fs.Open и подмена цепочки на NoRoute

Второе сокровище прячется в ветке ошибки. Смотрите на код createStaticHandler: после preset-проверки хендлер открывает файл сам — f, err := fs.Open(file) — и если файла нет, происходит нечто большее, чем просто WriteHeader(404): c.handlers = group.engine.noRoute и c.index = -1. Это подмена цепочки на лету: вместо продолжения статического хендлера gin подставляет всю NoRoute-цепочку движка — ту самую, что мы собирали в прошлом уроке из глобальных звеньев и вашего jsonNotFound-хендлера, — и сбрасывает индекс, чтобы цепочка исполнилась с начала. Значит, запрос к несуществующему ассету GET /assets/nope.css не вернёт текст «404 page not found» от FileServer — он уйдёт в ваш NoRoute и вернёт JSON-конверт NOT_FOUND, единый для всего API. Для фронтендера это подарок: одна обработка ошибок на всё — и на промах мимо маршрутов, и на битую ссылку на картинку. Проверка Open перед передачей fileServer нужна ещё и для того, чтобы ошибка открытия не «протухла» внутри net/http: FileServer сам умеет отвечать 404, но тогда вы бы потеряли свои звенья. Здесь же gin честно возвращает управление вашему коду. Обратная сторона: Open выполняется дважды — раз в gin, раз внутри FileServer, — для локального диска это невидимо, но знать механику полезно, когда отдаёте файлы из медленного хранилища. И заметка на полях: HEAD-запросы идут через тот же хендлер, подмена NoRoute работает и для них, а тело net/http отбрасывает автоматически — статус и заголовки остаются.

### StaticFS с NoRoute: единый конверт 404

```go
r := gin.Default()
r.HandleMethodNotAllowed = true

// Один обработчик промахов на всё: и маршруты, и статику.
r.NoRoute(func(c *gin.Context) {
        c.JSON(http.StatusNotFound, Envelope{
                Error: &APIError{
                        Code:    "NOT_FOUND",
                        Message: "не найдено: " + c.Request.URL.Path,
                },
        })
})

// Документация с листингом: gin.Dir с true.
r.StaticFS("/docs", gin.Dir("./docs", true))
r.Static("/assets", "./assets")
```

gin.Dir("./docs", true) возвращает чистый http.Dir — листинг включён, preset-404 не ставится, GET /docs/ покажет оглавление. Для /assets листинг выключен: его корень ответит 404, зато 404 уйдёт в NoRoute и вернёт конверт NOT_FOUND — как и GET /assets/nope.css. Envelope и APIError — из урока про JSON-ответы; checkAPIKey-звенья на статике не нужны: файлы у нас публичные.

### StaticFile и StaticFileFS: favicon, robots и точечные файлы

Папка папкой, но половина статики — единичные файлы: favicon.ico, robots.txt, sitemap.xml, пара логотипов. Для них StaticFile: r.StaticFile("/favicon.ico", "./web/favicon.ico") — регистрирует GET и HEAD на точный путь /favicon.ico без всяких catch-all. Заметьте симметрию с папочными хелперами: здесь тоже паника, если в relativePath появились «:» или «*» — URL parameters can not be used when serving a static file. Внутри — c.File(filepath), о которой стоит знать подробнее, потому что это ваш инструмент и за пределами StaticFile. c.File зовёт http.ServeFile: тот умеет и Last-Modified с 304, и Range, и даже каталоги с index.html — по сути c.File это «отдай файл как умелый файловый сервер». Родственная c.FileFromFS(filepath, fs) подменяет Request.URL.Path на переданный путь и запускает http.FileServer(fs) — удобно, когда файл живёт в кастомной файловой системе, например в embed.FS, но отдавать его нужно по другому URL. Именно на ней построен StaticFileFS — младший брат StaticFile из IRoutes: r.StaticFileFS("/logo.svg", "./logo.svg", gin.Dir("./web", false)) берёт файл из своей файловой системы, а не с диска. Третья сестра — c.FileAttachment(filepath, filename): то же самое плюс заголовок Content-Disposition с именем для сохранения — «скачать этот файл под именем report.pdf», пригодится, когда дойдём до выгрузки отчётов. Content-Type во всех случаях вычисляется по расширению через mime.TypeByExtension: .css → text/css, .png → image/png; незнакомое расширение — application/octet-stream и скачивание вместо показа, так что расширениям файлов стоит следить.

### Кэш: Last-Modified, 304, Range и свой Cache-Control

Заголовки из стандартной библиотеки — половина дела. Last-Modified ставится по времени модификации файла, и повторный запрос с If-Modified-Since, если файл не менялся, получает 304 Not Modified с пустым телом: трафика ноль, страница собирается из локального кэша браузера. Accept-Ranges: bytes включает докачку и перемотку — видео отдаётся порциями с 206 Partial Content. Всё это бесплатно. Но есть заголовок, которого не будет никогда: Cache-Control. Стандартная библиотека его не ставит, gin не ставит — а именно он говорит браузеру, сколько хранить файл локально, и без него браузер на каждый рендер лезет на сервер за свежестью. Решение — звено из [седьмого урока](/courses/gin/lessons/07-kastomnye-middleware/): middleware, которое пишет Cache-Control и пропускает запрос дальше. Подвесить его нужно именно на группу: assets := r.Group("/assets", cacheStatic()), и затем assets.Static("/", "./assets") — Static это метод группы, префикс приклеится тот же /assets, а звено встанет в цепочку всех статических маршрутов. Заголовок в ответе — header ещё до передачи управления FileServer, значит, и на 304, и на 404 он уйдёт. Значения — по сроку жизни: content с отпечатком в имени (app.a1b2c3.js от bundler-а) — max-age=31536000, immutable, «хранить год, имя меняется — файл новый»; обычные картинки — max-age=86400; robots.txt — max-age=3600, чтобы правки подхватывались за час. Инвалидации как таковой нет: поменяете файл без смены имени — Last-Modified обновится, но браузер с активным max-age даже не спросит. Это цена за скорость; для трекера с одним style.css берём средний вариант — час.

### Кэш-заголовок звеном на группе статики

```go
// cacheStatic вешается на группу: один заголовок — вся статика.
func cacheStatic() gin.HandlerFunc {
        return func(c *gin.Context) {
                c.Header("Cache-Control", "public, max-age=3600")
                c.Next()
        }
}

func buildStatic(r *gin.Engine) {
        assets := r.Group("/assets", cacheStatic())
        // Префикс группы + относительный путь: снова /assets/*filepath.
        assets.Static("/", "./assets")

        api := r.Group("/api")
        v1 := api.Group("/v1")
        registerTodoRoutes(v1)
}
```

Static — метод IRoutes, поэтому звать его на группе законно: path.Join("/assets", "/*filepath") даёт прежний /assets/*filepath, а cacheStatic входит в цепочку каждого статического маршрута — механика combineHandlers из седьмого урока. c.Header пишет заголовок до c.Next(), FileServer добавит свои — они не конфликтуют, заголовки складываются.

### Проверка: повторный запрос картинки

**Вопрос:** Браузер запросил /assets/logo.png, получил 200 и Last-Modified, а через минуту повторил запрос с заголовком If-Modified-Since из прошлого ответа. Файл не менялся. Что получит клиент?

- 200 и полное тело: Cache-Control отсутствует, кэш не работает
- ✅ 304 Not Modified с пустым телом: проверку If-Modified-Since делает serveContent из net/http
- 206 Partial Content: статика всегда отдаётся порциями
- 412 Precondition Failed: precondition не пройден

Это работа стандартной библиотеки, а не gin: serveContent сравнивает If-Modified-Since с mtime файла и отвечает 304, не открывая лишний раз тело. Range и 206 — про докачку по частям, они включаются заголовком Range от клиента, а не по умолчанию. 412 — про If-Unmodified-Since и ETag-проверки, другой сценарий. А вот Cache-Control на 304-ответ тоже ляжет — нашим звеном на группе.

### Embedding: gin статика в бинарник

Дальше — второй этаж автономности. Пока статика лежит в ./assets, разворачивание это два артефакта: бинарник и папка рядом. Забыли скопировать папку — сервер жив, а интерфейс голый. С Go 1.16 ответ встроен в язык: директива //go:embed упаковывает файлы в бинарник. Объявляете var webFS embed.FS, над ней — комментарий //go:embed web/*, компилятор на этапе сборки зашивает содержимое папки web в переменную, и deploy сводится к одному файлу. Для gin остаётся обернуть embed.FS в http.FS — адаптер из net/http, превращающий fs.FS в http.FileSystem, который ждёт StaticFS, — и передать: r.StaticFS("/static", http.FS(webFS)). Здесь прячется грабля, о которую спотыкаются все: embed хранит пути вместе с корневой папкой, поэтому файл web/style.css внутри ФС называется web/style.css, и URL получится /static/web/style.css — с лишним web. Лечится fs.Sub: sub, err := fs.Sub(webFS, "web") возвращает подсистему, корень которой уже «внутри» web, — и StaticFS принимает её через тот же http.FS(sub). Теперь style.css живёт на /static/style.css, как и хочется. Цена вопроса — память: всё зашитое лежит в бинарнике целиком и грузится в адресное пространство при старте; несколько мегабайт CSS и картинок — ерунда, а вот видео и гигабайтные архивы зашивать не надо. И вторая цена — пересборка: поменялась иконка — нужен новый бинарь и новый релиз, hot-fix файлом на диске не прокатит. Зато процесс честно работает из любой директории, из read-only контейнера и с пустым volume — идеальная пара для scratch-образа, о чём поговорим в уроке про деплой, где тема сборки минимальных образов встретится с этой.

### Статика из embed.FS через fs.Sub

```go
package main

import (
        "embed"
        "io/fs"
        "net/http"

        "github.com/gin-gonic/gin"
)

//go:embed web
var webFS embed.FS

func main() {
        r := gin.Default()

        // Срезаем корень web: иначе URL будет /static/web/style.css.
        sub, err := fs.Sub(webFS, "web")
        if err != nil {
                panic(err)
        }

        r.StaticFS("/static", http.FS(sub))
        r.NoRoute(func(c *gin.Context) {
                c.FileFromFS("index.html", http.FS(sub))
        })

        if err := r.Run(":8080"); err != nil {
                panic(err)
        }
}
```

//go:embed web зашивает папку целиком — подпапки тоже. fs.Sub(webFS, "web") даёт ФС с корнем внутри web, и StaticFS раздаёт содержимое по /static/*. NoRoute с c.FileFromFS("index.html", ...) — мини-фолбэк для SPA: любой промах мимо /static и маршрутов получает страницу приложения. В release-сборке бинарник вместе со статикой — один файл на все машины целевой архитектуры.

### Проверка: лишний web в URL

**Вопрос:** //go:embed web, var webFS embed.FS, затем r.StaticFS("/static", http.FS(webFS)) — без fs.Sub. По какому URL отдаётся файл web/style.css?

- /static/style.css — StaticFS сам срезает корневую папку embed
- ✅ /static/web/style.css — embed.FS хранит пути вместе с корневой папкой web
- /style.css — префикс /static игнорируется
- 404: embed.FS несовместим с http.FS

http.FS просто оборачивает fs.FS в http.FileSystem, а внутри embed-файловой системы файл лежит под именем web/style.css — StaticFS добавляет только свой префикс. Лечится fs.Sub(webFS, "web"): подсистема видит содержимое web как корень, и URL становится ожидаемым /static/style.css. Несовместимости нет — только лишний сегмент пути, который легко не заметить на проде.

### SPA-фолбэк и когда статике не место в gin

Пара слов про одностраничники и про границы. SPA на react/vue-router живёт на клиентских путях вроде /todos/42, которых сервер не знает: прямой заход по ссылке или refresh падает в 404. Классическое решение — отдавать index.html на все промахи: в gin это NoRoute плюс c.File("./web/index.html"), как в примере с embed выше. Только держите в голове два правила. Первое: NoRoute-хендлер один на движок — если вы, как мы, пишете туда JSON-конверт для API, ветвитесь внутри по префиксу: путь начинается с /api/ — отдавайте NOT_FOUND конверт, иначе — index.html. Второе: фолбэк на всё съедает честные 404 по ассетам — битые ссылки на картинки станут 200 с HTML вместо CSS, и браузер выдаст загадочную ошибку разбора; поэтому SPA-фолбэк ставят после StaticFS, а ассеты проверяют отдельно. Когда же статике не место в gin вообще? Если файлов много, они большие, а трафик продовый: файловый сервер в вашем процессе тратит на отдачу те же горутины и память, что и API, gzip он не умеет, CDN-заголовков не проставит. Зрелая раскладка: nginx или CDN перед приложением раздаёт /assets из своей папки, gin занимается только /api — а Static остаётся для локальной разработки, демо и внутренних панелей. Гибрид тоже легален: разрабатываете с r.Static, в проде монтируете тот же префикс через прокси — код не меняется, меняется только то, что стоит перед приложением.

### Справочник: три хелпера и фолбэк

| Метод | Что регистрирует | Листинг | Типичный случай |
| --- | --- | --- | --- |
| r.Static("/assets", "./assets") | GET и HEAD на /assets/*filepath | выключен | CSS, JS, картинки приложения |
| r.StaticFS("/docs", gin.Dir("./docs", true)) | GET и HEAD на /docs/*filepath | включен | каталог документации для просмотра |
| r.StaticFS("/static", http.FS(sub)) | GET и HEAD на /static/*filepath | как у переданной ФС | статика из embed.FS через fs.Sub |
| r.StaticFile("/favicon.ico", "...") | GET и HEAD на точный путь | нет | favicon, robots.txt, sitemap |
| r.NoRoute + c.File("index.html") | фолбэк на все промахи | нет | SPA с клиентской маршрутизацией |

### Соберите файловую раскладку трекера

Допишите хелперы статики: папка, один файл, файловая система с листингом и embed-подсистема.

```go
func buildStatic(r *gin.Engine) {
        // Папка с ассетами: листинг выключен по умолчанию.
        r.___("/assets", "./assets")

        // Один файл по точному пути.
        r.___("/favicon.ico", "./web/favicon.ico")

        // Документация с оглавлением: листинг включаем явно.
        r.___("/docs", gin.___("./docs", true))

        // Статика из бинарника: срезаем корень web.
        sub, err := fs.___(webFS, "web")
        if err != nil {
                panic(err)
        }
        r.StaticFS("/static", http.FS(sub))
}
```

Подсказка: Три хелпера из IRoutes: Static для папки, StaticFile для файла, StaticFS для своей ФС. gin.Dir включает или выключает листинг, fs.Sub срезает корень embed.

Правильные ответы в пропусках: `Static`, `StaticFile`, `StaticFS`, `Dir`, `Sub`

### Соберите путь статического запроса

GET /assets/style.css при r.Static("/assets", "./assets") и cacheStatic на группе: разложите события от curl до строки лога.

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

1. `curl отправляет GET /assets/style.css`
2. `handleHTTPRequest находит в GET-дереве /assets/*filepath: catch-all съедает /style.css`
3. `c.Param("filepath") равен /style.css; цепочка: cacheStatic, Logger, статический хендлер`
4. `Хендлер видит OnlyFilesFS и ставит в gin-обёртке preset-статус 404`
5. `fs.Open("/style.css") через http.Dir("./assets") открывает файл — ошибки нет`
6. `http.StripPrefix срезает /assets, FileServer ищет style.css по оставшемуся пути`
7. `serveContent ставит Content-Type по расширению, Last-Modified, Accept-Ranges`
8. `WriteHeader(200) перезаписывает preset-статус — клиент увидит 200`
9. `io.CopyN льёт тело файла, на Linux это sendfile без лишних копий`
10. `cacheStatic уже положил Cache-Control: заголовки уходят вместе с телом`
11. `Logger печатает [GIN] с 200, размером и временем запроса`
12. `Браузер получает style.css и кэширует его на max-age из звена`

### Шесть правил gin статики

- Static это StaticFS(Dir(root, false)): регистрируются GET и HEAD на path.Join(prefix, "/*filepath"); ловить POST на статике бесполезно — уйдёт в NoRoute.
- Префикс статики — чистый литерал: двоеточие или звёздочка в пути дают панику «URL parameters can not be used when serving a static folder/file».
- Листинг выключен по умолчанию; включайте gin.Dir(root, true) осознанно и только для тех папок, чьё содержимое можно показывать.
- Успешные файлы приходят с 200 (serveContent перезаписывает preset-404), а вот корень папки без index.html и отсутствующие файлы — с 404.
- Отсутствующий файл переключает цепочку на NoRoute: один JSON-конверт обслуживает и промахи маршрутов, и битые ссылки на ассеты.
- Cache-Control стандартная библиотека не ставит — вешайте звено с заголовком на группу статики; для embed не забывайте fs.Sub, иначе пути обрастут лишним сегментом.

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

- **Static и StaticFS** — Static(relativePath, root) — одна строка: StaticFS(relativePath, Dir(root, false)). StaticFS регистрирует GET и HEAD на path.Join(relativePath, "/*filepath"), принимает любой http.FileSystem — диск через gin.Dir, embed через http.FS. Паника при «:» или «*» в префиксе.
- **gin.Dir и OnlyFilesFS** — Dir(root, true) — чистый http.Dir с листингом; Dir(root, false) — OnlyFilesFS, чей Readdir всегда возвращает nil. Хендлер статики замечает OnlyFilesFS и заранее ставит 404 в gin-обёртке: на директориях без index.html он и уходит клиенту, на файлах его перезаписывает serveContent с WriteHeader(200).
- **Промах статики и NoRoute** — fs.Open вернул ошибку — хендлер ставит 404, подменяет c.handlers на engine.noRoute и сбрасывает c.index: запрос дообслуживается NoRoute-цепочкой (глобальные звенья плюс ваш хендлер). Поэтому битая ссылка на картинку возвращает тот же JSON-конверт, что и несуществующий маршрут.
- **StaticFile, StaticFileFS, c.File** — StaticFile — GET и HEAD на точный путь, внутри c.File (http.ServeFile: 304, Range, Content-Type по расширению). StaticFileFS — то же, но файл берётся из http.FileSystem. c.FileFromFS подменяет URL.Path и запускает FileServer, c.FileAttachment добавляет Content-Disposition для скачивания.
- **Кэш и стандартная библиотека** — Last-Modified с проверкой If-Modified-Since (304), Accept-Ranges с 206 для докачки — из net/http бесплатно. Cache-Control никто не ставит: звено c.Header("Cache-Control", ...) на группе статики, например max-age=3600, — и браузер перестаёт перекачивать те же файлы каждый рендер.
- **embed.FS и fs.Sub** — //go:embed web + var webFS embed.FS зашивают папку в бинарник; http.FS(webFS) адаптирует её для StaticFS. embed хранит пути с корневой папкой — без fs.Sub(webFS, "web") файл web/style.css отвечает на /static/web/style.css. Цена — память и пересборка после каждой правки файла.

> **Прогоните файловую раскладку curl-ом.** Соберите buildStatic и проверьте: curl -i localhost:8080/assets/style.css — 200, Content-Type text/css, Cache-Control от нашего звена; curl -i localhost:8080/assets/ — 404 с пустым листингом, а /docs/ с gin.Dir(..., true) покажет оглавление; curl -I localhost:8080/favicon.ico — HEAD-ответ без тела; curl localhost:8080/assets/nope.css — конверт NOT_FOUND из NoRoute. Повторите запрос картинки с If-Modified-Since из прошлого ответа — поймайте 304. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 12-shablony: шаблоны HTML, LoadHTMLGlob, передача данных и кастомные функции шаблонов — то, ради чего нам и понадобился stylesheet.

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

- Static это StaticFS(Dir(root, false)): оба регистрируют GET и HEAD на path.Join(prefix, "/*filepath"); префикс — чистый литерал, «:» и «*» дают панику.
- OnlyFilesFS с пустым Readdir отключает листинг; хендлер ставит preset-404, который на файлах перезаписывается WriteHeader(200) из serveContent, а на директориях без index.html и на промахах выживает.
- fs.Open с ошибкой подменяет цепочку на engine.noRoute: несуществующий ассет обслуживает ваш NoRoute-хендлер с конвертом NOT_FOUND, как и любой другой промах.
- StaticFile и StaticFileFS — точечные файлы через c.File и c.FileFromFS; рядом лежат c.FileAttachment для скачивания и mime.TypeByExtension для Content-Type.
- Last-Modified/304 и Range/206 достаются от net/http бесплатно; Cache-Control добавляем сами звеном на группе: assets := r.Group("/assets", cacheStatic()), затем assets.Static("/", "./assets").
- embed.FS с fs.Sub упаковывает статику в бинарник и раздаётся через StaticFS + http.FS; SPA-фолбэк — NoRoute с c.File, с веткой /api/ для JSON-конверта.

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

Соберите статический этаж трекера: r.Static("/assets", "./assets") со звеном cacheStatic (max-age=3600) на группе, favicon и robots.txt через StaticFile, документацию через StaticFS с gin.Dir(..., true) и JSON-конверт NOT_FOUND в NoRoute из прошлого урока. Убедитесь curl-ом: GET /assets/style.css — 200 с Cache-Control, повтор с If-Modified-Since — 304, GET /assets/ — 404 с почти пустым телом, GET /assets/nope.css — конверт, а не «404 page not found». Затем перенесите web/ в embed: //go:embed web, fs.Sub и StaticFS("/static", http.FS(sub)) — проверьте, что URL больше не содержит лишнего сегмента web, и соберите один бинарь go build.

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

### Почему r.Static отдаёт 404 на /assets/, хотя папка существует?

Листинг выключен: Static использует gin.Dir(root, false), Readdir у OnlyFilesFS возвращает nil, и хендлер заранее ставит 404. Положите index.html — он отдаётся вместо оглавления, — или включите листинг явно через StaticFS с gin.Dir(root, true).

### Как запретить доступ к отдельным файлам внутри раздаваемой папки?

Оставьте в папке только то, что предназначено наружу: Static не умеет фильтровать. Для сложных правил оберните fs.Open кастомным http.FileSystem или раздавайте точечно StaticFile. Конфиденциальное не держите в публичной папке вовсе.

### Static замедляет API?

Нет, пока файлы небольшие: отдача идёт через io.Copy/sendfile и не мешает остальным горутинам. Но под продовый трафик с большими файлами выносите статику в nginx или CDN: ваш процесс должен считать, а не раздавать.

### Чем c.File отличается от c.FileFromFS?

c.File(filepath) зовёт http.ServeFile по пути на диске. c.FileFromFS(filepath, fs) подменяет Request.URL.Path на filepath и запускает http.FileServer(fs) — работает с любой файловой системой, например с embed.FS, и не трогает диск напрямую.
