Shanraq.org Shanraq.org
Статика в Go: CSS, шрифты и картинки
IT

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" — браузер не станет качать их, пока читатель не дошёл.

Карта урока

Карта урока: страница собирается, а файл отдаётся как есть

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему таблица стилей, отданная с Content-Type: text/plain, не оформит страницу?
  2. Почему для файлов с диска нужен StripPrefix, а для встроенных — нет?
  3. Читатель открыл сайт второй раз. Почему браузер снова скачал 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 пока открыта списком; на настоящем сайте это закрывают.

Ответы

Показать ответы
  1. Потому что браузер применяет файл по заявленному типу, а не по расширению в адресе. Пришло text/plain — значит, это текст, и оформлять им страницу браузер не будет. Тип определяется по расширению файла на сервере, поэтому проверять надо заголовок ответа, а не имя файла в вёрстке.
  2. Потому что файловый сервер ищет файл по пути из запроса внутри той папки, которую вы ему дали. Для http.Dir("static") запрос /static/style.css превращается в поиск static/static/style.css — отсюда 404 и нужда срезать префикс. У embed мы встроили саму папку static, поэтому путь в запросе и путь внутри совпадают.
  3. Потому что у встроенных файлов нулевое время изменения, и Go не ставит Last-Modified. Браузеру нечем переспросить «не изменилось ли», поэтому он берёт файл заново. Лечится заголовком Cache-Control и именем файла, которое меняется вместе с содержимым.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.