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

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

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

Урок 11 из 25 ~15 мин +50 XP

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

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

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

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") — какие маршруты появились в дереве?

Листинг директорий: 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, даже если папка существует — «пустой» ответ вместо оглавления.

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

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

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

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

Статус-пазл: почему файлы всё-таки приходят с 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-телом.

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

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

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

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

Промах статики: 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 не ставит — а именно он говорит браузеру, сколько хранить файл локально, и без него браузер на каждый рендер лезет на сервер за свежестью. Решение — звено из седьмого урока: 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 из прошлого ответа. Файл не менялся. Что получит клиент?

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?

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-подсистема.

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)) }

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

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

Строки

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

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

Шесть правил 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. Паника при «:» или «*» в префиксе.

1 / 6

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

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

Вывод

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

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

  • 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Обслуживание статических файлов)

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

Почему 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, и не трогает диск напрямую.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию