Go: с нуля до своего блога Урок 24 из 50
Статика в Go: CSS, шрифты и картинки
Двадцать четвёртый урок курса по Go. Сайт начинает выглядеть, а не только работать. http.FileServerFS, почему для файлов с диска нужен StripPrefix, а для встроенных нет, откуда берётся Content-Type, почему папка static видна целиком и почему встроенный файл браузер качает заново.
Зачем это нужно
Блог работает. Выглядит он при этом как документ, который забыли в 1994 году: чёрный Times на белом, во всю ширину экрана.
Всё, чего не хватает, — файлы: таблица стилей, шрифт, картинка. Их не надо собирать, как страницу из шаблона. Их надо отдать ровно такими, какие они есть, и в этом вся разница.
Сразу целиком
Новая папка, go mod init sabaq22. Внутри две папки — templates и static.
static/style.css:
:root { --ink: #1a1a1a; --paper: #fffdf8; --accent: #d32f2f; }
body {
margin: 0 auto;
max-width: 38rem;
padding: 2rem 1rem;
background: var(--paper);
color: var(--ink);
font: 1.05rem/1.6 -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}
h1 { font-size: 1.6rem; letter-spacing: -0.01em; }
.brand { display: flex; align-items: center; gap: 0.75rem; }
.brand h1 { margin: 0; }
a { color: var(--accent); }
ul { padding-left: 1.1rem; }
li { margin: 0.4rem 0; }
img { max-width: 100%; height: auto; }
@media (max-width: 30rem) {
body { padding: 1.2rem 0.9rem; }
h1 { font-size: 1.35rem; }
}
Про этот файл сразу оговорка. Курс — про Go, и CSS в нём не изучают. Стили здесь взяты готовыми, чтобы страница выглядела прилично, и разбирать их правила мы не будем ни сегодня, ни дальше: это отдельное ремесло, размером с такой же курс. Копируйте как есть, меняйте цвета на свой вкус — на Go это никак не влияет.
Если захочется научиться этому всерьёз, идите к первоисточникам, а не к пересказам: MDN — справочник по CSS и MDN — «Устройство CSS». В конце файла есть минимум для узких экранов — вместе с <meta name="viewport"> в шаблоне этого хватает, чтобы страница читалась на телефоне. Для нашего урока достаточно понимать одно: файл со стилями — обычный файл, и сервер отдаёт его как есть.
templates/list.html:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Мой блог</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
</head>
<body>
<header class="brand">
<img src="/static/logo.svg" width="40" height="40" alt="">
<h1>Мой блог</h1>
</header>
<ul>
{{ range .Articles }}<li><a href="/read/{{ .Slug }}">{{ .Title }}</a></li>{{ end }}
</ul>
</body>
</html>
static/logo.svg — знак сайта. Текста внутри нет, поэтому один файл годится для всех языков. Он же стоит значком вкладки — это строка <link rel="icon"> в шаблоне, и никакого отдельного favicon.ico заводить не нужно:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="40" height="40">
<g fill="none" stroke-linecap="round">
<g stroke="#ffadad" stroke-opacity="0.5" stroke-width="45">
<line x1="76" y1="76" x2="324" y2="324"/>
<line x1="137" y1="37" x2="363" y2="263"/>
<line x1="37" y1="137" x2="263" y2="363"/>
<line x1="76" y1="324" x2="324" y2="76"/>
<line x1="137" y1="363" x2="363" y2="137"/>
<line x1="37" y1="263" x2="263" y2="37"/>
</g>
<g stroke="#e53935" stroke-width="30">
<line x1="76" y1="76" x2="324" y2="324"/>
<line x1="137" y1="37" x2="363" y2="263"/>
<line x1="37" y1="137" x2="263" y2="363"/>
<line x1="76" y1="324" x2="324" y2="76"/>
<line x1="137" y1="363" x2="363" y2="137"/>
<line x1="37" y1="263" x2="263" y2="37"/>
</g>
</g>
</svg>
И main.go:
package main
import (
"bytes"
"embed"
"html/template"
"log"
"net/http"
)
//go:embed templates/*.html static
var files embed.FS
var tpl = template.Must(template.ParseFS(files, "templates/*.html"))
type Article struct{ Slug, Title string }
var blog = []Article{
{Slug: "dala", Title: "О степи"},
{Slug: "shanyraq", Title: "Что такое шанырак"},
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "list.html", map[string]any{"Articles": blog}); err != nil {
log.Println("шаблон:", err)
http.Error(w, "ошибка на сервере", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
})
mux.Handle("GET /static/", http.FileServerFS(files))
log.Println("http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
go run . — и страница наконец выглядит как страница.
Разбор
Статику не собирают
Обработчик страницы каждый раз делает работу: берёт данные, подставляет в шаблон, склеивает. Файловый сервер не делает ничего — он находит файл и отдаёт байты.
Отсюда одна строка на всю статику сайта:
mux.Handle("GET /static/", http.FileServerFS(files))
Не HandleFunc, а Handle: FileServerFS возвращает готовый http.Handler, а не функцию. Это тот самый интерфейс из урока про обработчики — и вот вам чужой обработчик, который вы просто ставите на маршрут.
Обратите внимание на косую черту в конце: GET /static/ — это префикс, а не точный адрес. Так же, как /read/{slug} ловит любую статью, /static/ ловит всё, что за ним.
Почему здесь нет StripPrefix — и когда он нужен
В книгах и статьях эта строка почти всегда пишется так:
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
Разберём, откуда берётся обёртка. Файловый сервер ищет файл по пути из запроса. Пришёл /static/style.css — он ищет style.css внутри папки, которую вы ему дали, то есть static/static/style.css. Такого файла нет.
Проверьте на своей машине. Поставьте рядом второй маршрут без обёртки:
mux.Handle("GET /raw/", http.FileServer(http.Dir("static")))
и спросите оба:
curl -o /dev/null -w "%{http_code}\n" http://localhost:8080/raw/style.css
curl -o /dev/null -w "%{http_code}\n" http://localhost:8080/static/style.css
404
200
StripPrefix срезает /static/ с адреса, и только после этого файл находится.
А в нашей программе обёртки нет — и всё работает. Причина в embed: мы встроили саму папку static, поэтому внутри files файл лежит по пути static/style.css. Адрес запроса совпадает с путём в файловой системе буква в букву, и срезать нечего.
Образ. Ящик, подписанный «носки», внутри шкафа «носки». Просишь «шкаф носки, ящик носки» — и всё сходится, только если шкаф и правда так устроен. Иначе просьбу надо укоротить.
Content-Type берётся из расширения
Спросим заголовки:
curl -sD- -o /dev/null http://localhost:8080/static/style.css
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 616
Content-Type: text/css; charset=utf-8
Никто не писал text/css — Go взял его из расширения .css. Для logo.svg тем же способом получится image/svg+xml.
Это не мелочь. Браузер применяет таблицу стилей, только если она пришла как text/css. Отдайте тот же файл как text/plain — страница останется без оформления, и в консоли будет ровно одна строка про несовпадение типа. Час на поиски там, где заголовок был виден с самого начала.
Ловушка: папка видна целиком
Откройте http://localhost:8080/static/ — без имени файла.
<pre>
<a href="logo.svg">logo.svg</a>
<a href="style.css">style.css</a>
</pre>
FileServer показывает список каталога. Не ошибка, а поведение по умолчанию, и о нём стоит знать заранее: всё, что вы положите в static, посторонний человек увидит списком. Черновик, выгрузку базы, забытый архив.
Простое правило: в static лежит только то, что вы согласны показать. Остальное — в другую папку и через свой обработчик, где вы решаете, кому можно.
Встроенный файл браузер скачивает заново каждый раз
Вот неочевидное следствие embed, из-за которого сайт медленнее, чем мог бы.
Файл на диске имеет время изменения. Отдавая его, Go ставит Last-Modified, браузер в следующий раз спрашивает «а не изменилось ли», и получает пустой ответ 304 Not Modified вместо всего файла.
У встроенных файлов времени изменения нет — оно нулевое, и заголовок не ставится. Сравните сами.
С диска:
Last-Modified: Sat, 05 Sep 2026 10:36:02 GMT
а повторный запрос с If-Modified-Since даёт 304. Из embed заголовка нет вовсе, и тот же повторный запрос даёт 200 и весь файл целиком.
Это не повод отказываться от embed — одна программа без хвоста файлов стоит дорого. Это повод знать, что кэшированием придётся управлять самому: заголовком Cache-Control и неизменяемым именем файла вроде style.7f3a1c.css, которое меняется вместе с содержимым. Мы вернёмся к этому, когда сайт поедет на настоящий сервер.
Шрифты: самый быстрый шрифт — тот, который не качают
В style.css шрифт задан так:
font: 1.05rem/1.6 -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
Здесь не скачивается ничего. Браузер берёт системный шрифт: на маке свой, на Windows свой, на Android свой. Текст появляется мгновенно, и весит это ноль байт.
Свой шрифт — решение, за которое платит читатель. Одно начертание в woff2 — это 20–40 килобайт, а начертаний обычно берут четыре. На медленной сети это заметно. Если всё же берёте, то только woff2, только нужные начертания и обязательно font-display: swap, чтобы текст был виден, пока шрифт едет.
Картинки: два атрибута, которые нельзя забывать
<img src="/static/logo.svg" width="40" height="40" alt="">
width и height нужны не для размера — размером управляет CSS. Они нужны, чтобы браузер знал пропорции до загрузки и оставил место. Без них страница дёргается: текст уже виден, картинка приезжает и всё сдвигает вниз под пальцем читателя.
alt — текст для тех, кто картинку не увидит: программа чтения экрана, медленная сеть, поисковый робот.
Здесь он пустой, и это не забывчивость. Рядом со знаком стоит название блога обычным текстом, и сам знак ничего к нему не добавляет. Пустой alt означает «читать нечего» — программа чтения экрана пропустит картинку и не произнесёт название дважды. А вот забытый alt она прочтёт вслух как имя файла: «логотип точка эс-ви-джи».
Для картинок ниже первого экрана добавляют loading="lazy" — браузер не станет качать их, пока читатель не дошёл.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему таблица стилей, отданная с
Content-Type: text/plain, не оформит страницу? - Почему для файлов с диска нужен
StripPrefix, а для встроенных — нет? - Читатель открыл сайт второй раз. Почему браузер снова скачал
style.cssцеликом?
Задание
Обязательное. Сделайте страницу статьи по адресу /read/{slug} со своим оформлением: шаблон article.html, общий style.css и картинка-обложка с width, height и alt. Проверьте через curl -sD- -o /dev/null, что у картинки правильный Content-Type.
Внимание на будущее. Пока файлов три, они лежат в static рядом, и это нормально. Как только к ним добавится чужое — загруженные читателями картинки, — папки станут обязательными: brand для логотипа, css, js, images для своих картинок и uploads для чужих. Так сделано начиная с шага 25, и причина простая: раскладывать сорок файлов задним числом дороже, чем завести пять папок вовремя.
По желанию.
- Положите в
staticфайлzametki.txtи откройте/static/. Найдите его в списке. - Раздайте те же файлы вторым маршрутом с диска, через
http.Dir, и сравните заголовки ответа с встроенным вариантом. - Оберните
/static/своей прослойкой из прошлого урока и поставьтеCache-Control: max-age=3600. Посмотрите, что изменится при обновлении страницы.
Куда это встанет в блоге
Блог перестал быть голым текстом. Дальше — ошибки и журнал: увидеть, что сломалось, вместо белого экрана.
Долги, взятые сегодня. Кэшированием придётся управлять руками, и вместе с ним придёт версия в имени файла. Сжатие ответа — gzip или br — уменьшает CSS втрое, и его тут нет. И static пока открыта списком; на настоящем сайте это закрывают.
Ответы
Показать ответы
- Потому что браузер применяет файл по заявленному типу, а не по расширению в адресе. Пришло
text/plain— значит, это текст, и оформлять им страницу браузер не будет. Тип определяется по расширению файла на сервере, поэтому проверять надо заголовок ответа, а не имя файла в вёрстке. - Потому что файловый сервер ищет файл по пути из запроса внутри той папки, которую вы ему дали. Для
http.Dir("static")запрос/static/style.cssпревращается в поискstatic/static/style.css— отсюда 404 и нужда срезать префикс. Уembedмы встроили саму папкуstatic, поэтому путь в запросе и путь внутри совпадают. - Потому что у встроенных файлов нулевое время изменения, и Go не ставит
Last-Modified. Браузеру нечем переспросить «не изменилось ли», поэтому он берёт файл заново. Лечится заголовкомCache-Controlи именем файла, которое меняется вместе с содержимым.
Источники
- net/http: FileServerFS
- net/http: StripPrefix
- embed — документация
- MDN: Cache-Control
- MDN: font-display
- MDN: справочник по CSS — если захотите изучать стили отдельно
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.