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

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

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

Урок 13 из 25 ~20 мин +50 XP

HTML-шаблоны

Тема «fiber шаблоны» закрывает пробел, который копился с самого первого сервера: двенадцать уроков все ответы выдавал хендлер — строкой, JSON-ом, редиректом, — и ни разу полноценной HTML-страницей. Сегодня разбираем рендер страниц. Первый слой — стандартная библиотека html/template: действия {{.Field}}, range, if, функции и главное свойство — автоматическое экранирование по контексту. Второй слой — механизм рендера Fiber v3: интерфейс fiber.Views с двумя методами, метод c.Render на контексте, поля конфига Views, ViewsLayout и PassLocalsToViews, и девять адаптеров движков в модуле github.com/gofiber/template — от html до pug и mustache. Минимальный стек html template fiber — это стандартная библиотека плюс один движок-адаптер, и всё сверено с исходниками тега v3.5.0: res.go, ctx.go, app.go. Канон продолжается: API на :8080, слои helmet, cors, logger, сессии из двенадцатого урока — обещанный в нём тизер исполнится здесь же: страница задач наконец поприветствует пользователя по имени из sess.Get("user").

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

fiber шаблоны: страницы вместо JSON#

До сегодняшнего дня ваш инстанс Fiber был чистым API: он отдавал данные — c.JSON в девятом уроке, статусы, редиректы, — а рисовал интерфейс кто-то другой: фронтенд на Vite из восьмого урока или index.html из пятого. Серверные шаблоны переворачивают распределение труда: HTML собирает сам сервер, прямо в хендлере, подставляя данные в заранее приготовленные файлы-заготовки. Зачем это нужно, если есть SPA? Во-первых, простые сайты — лендинги, блоги, админки, страницы документов — не заслуживают сборщика и фреймворка: шаблон на сервере дешевле и быстрее. Во-вторых, SEO и первый рендер: браузер получает готовый HTML без второго этапа «скачал JS, подождал, отрисовал». В-третьих, старый добрый POST формы из одиннадцатого урока красивее всего работает с серверной отрисовкой: форма уходит, сервер рисует страницу снова, уже с флеш-сообщением от сессии.

Тема «fiber шаблоны» стоит на двух слоях, и их важно не путать. Нижний слой — язык шаблонов: как в файле записать «сюда подставь поле Title», «здесь пройди циклом по задачам», «если пользователь залогинен — покажи кнопку выхода». В Go за это отвечает стандартная библиотека, и знакомство с html template fiber начинается именно с неё. Верхний слой — транспорт: как Fiber находит движок, отдаёт ему данные, забирает готовый HTML и кладёт его в ответ с правильным Content-Type. Это исходники Fiber v3: интерфейс fiber.Views в ctx.go, метод Render в res.go, поля конфига в app.go. Пройдём снизу вверх: сначала язык, потом транспорт, потом layout-каркасы, перезагрузка в dev и защита от XSS. Канон курса продолжается: тот же инстанс на :8080, те же слои helmet, cors и logger, сессии из двенадцатого урока — и страница задач, которая знает, кто её открыл.

html/template: действия, поля и Parse#

Шаблон — это текст с действиями: фрагментами в двойных фигурных скобках {{ }}, которые при исполнении заменяются данными. Всё остальное — HTML, который уйдёт в ответ как есть. Действие {{.Title}} означает «поле Title от точки»: точка — текущие данные, переданные шаблону, и для структуры это доступ к полям, а для карты — к ключам. Фигурные скобки называются разделителями, или delims, и в стандартной библиотеке они фиксированы; движки gofiber/template умеют их менять, но это редкая экзотика.

Два пакета стандартной библиотеки различаются одним словом и целой философией. text/template — про произвольный текст: письма, конфиги, логи; он ничего не знает про HTML и вставляет данные как есть. html/template — надстройка над ним с тем же синтаксисом, но с автоматическим экранированием: перед вставкой данные чистятся с учётом HTML-контекста, в который они попали. Для веб-страниц всегда берите html/template — это правило безопасности, к которому вернёмся в середине урока; Fiber даже в запасном механизме рендера парсит файлы именно html/template-ом.

Жизненный цикл шаблона — три вызова. template.New("имя") создаёт пустой набор; Parse(s string) разбирает строку и возвращает готовый к исполнению набор или ошибку — опечатка в действии ловится здесь, до первого запроса; Execute(w, data) исполняет шаблон, пишет результат в writer и подставляет данные. Удобная обёртка template.Must(p, err) паникует при ошибке разбора — она для шаблонов, вшитых в программу на этапе инициализации: упало при старте — честнее, чем молча рисовать сломанные страницы. В Fiber этот цикл сохраняется один в один, только Execute вызовет за вас движок, а writer-ом будет буфер ответа. И ещё одна привычка, которую стоит завести сразу: шаблон компилируют один раз, а исполняют на каждый запрос — Parse в хендлере на каждый приход запроса это лишняя работа и мусор для сборщика.

Минимальный шаблон: список задач

GO
package main

// Task — модель канона курса: рисуем список задач на HTML
type Task struct {
        ID    int
        Title string
        Done  bool
}

// Page — данные, которые уйдут в шаблон целиком (это «точка»)
type Page struct {
        Title string
        Tasks []Task
}

func main() {
        // html/template: разбор один раз при старте, template.Must — паника при ошибке
        tmpl := template.Must(template.New("list").Parse(`
<h1>{{.Title}}</h1>
<ul>
  {{range .Tasks}}<li>#{{.ID}} {{.Title}}{{if .Done}} — готово{{end}}</li>{{else}}<li>пока пусто</li>{{end}}
</ul>
`))

        data := Page{Title: "Задачи", Tasks: []Task{
                {ID: 1, Title: "Купить кофе", Done: true},
                {ID: 2, Title: "Написать отчёт", Done: false},
        }}

        // Execute пишет результат в writer: здесь stdout, в Fiber — буфер ответа
        if err := tmpl.Execute(os.Stdout, data); err != nil {
                log.Fatal(err)
        }
}

Здесь собран весь базовый словарь шаблонов. {{.Title}} — поле данных. {{range .Tasks}}...{{end}} — цикл по слайсу: внутри точка смещается на текущий элемент, поэтому {{.Title}} в теле цикла — это уже поле Task, а не Page. {{if .Done}}...{{end}} — ветвление по любому значению: ноль, пустая строка, nil и false трактуются как ложь. {{else}} после range — приятная мелочь стандартной библиотеки: ветка для пустого слайса, когда итерировать нечего. Разбор через Parse выполнен один раз в main, а не в хендлере: шаблон — неизменяемая программа, данные меняются, код — нет.

Что напечатает Execute?

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

// html/template: что уйдёт в stdout?
tmpl := template.Must(template.New("t").Parse("<p>{{.}}</p>"))
_ = tmpl.Execute(os.Stdout, "<b>\"Важно\"</b>")

range, if и функции: арсенал шаблонов#

Второй уровень языка — управление потоком и вычисления. Ветвление {{if .User}}...{{else}}...{{end}} уже встречалось; у if есть цепочки {{else if}}, а {{with}} — это if плюс переопределение точки: внутри {{with .User}} значение поля становится точкой, и пишут коротко {{.}}, не повторяя путь. Цикл range умеет декомпозицию с двумя переменными: {{range $i, $t := .Tasks}} даёт индекс и элемент, а $$-переменные живут до конца шаблона и не путаются с точкой, смещающейся в циклах.

Функции в шаблонах вызываются по имени, аргументы — через пробел, а вертикальная черта превращает вызов в конвейер: {{.Title | printf "%s"}} то же самое, что {{printf "%s" .Title}}. Встроенных функций хватает на повседневное: len для длин, index для слайсов и карт, сравнения eq, ne, lt, le, gt, ge, логика and, or, not и знакомый по fmt printf. Конвейеры комбинируются свободно: {{if gt (len .Tasks) 0}} — «если задач больше нуля», скобки группируют вызовы, как в shell.

Свои функции подключаются через FuncMap — карту «имя-строка: Go-функция» — и вот тут появляется мостик к движкам Fiber: template.New("t").Funcs(template.FuncMap{"короткаяДата": formatDate}) до Parse. В gofiber/template то же самое делает engine.AddFunc(name, fn) и массово AddFuncMap, а комментарий в исходнике разрешает переопределение: It is legal to overwrite elements of the default actions. Наконец, переиспользование кусков: {{define "имя"}}...{{end}} объявляет именованный фрагмент, {{template "имя" .}} вставляет его с текущей точкой, {{block "имя" .}} — объявляет и сразу вставляет. Именно на этих трёх действиях строятся партиалы — переиспользуемые шапки, футеры и карточки, — а layout-паттерн Fiber, к которому мы придём через несколько абзацев, реализован через функцию с именем embed.

Проверьте себя: {{else}} внутри range

В шаблоне строка: {{range .Tasks}}<li>{{.Title}}</li>{{else}}<li>пока пусто</li>{{end}}. Хендлер передал Page с полем Tasks, равным nil. Что увидит клиент?

Рендер v3: интерфейс fiber.Views и c.Render#

Теперь транспорт. В исходниках Fiber v3.5.0 весь механизм держится на одном интерфейсе, объявленном в ctx.go, и комментарий к нему лаконичен: Views is the interface that wraps the Render function. Два метода: Load() error — один раз разобрать шаблоны и приготовиться — и Render(out io.Writer, name string, binding any, layout ...string) error — отрисовать шаблон с именем name, подставив данные binding, и, если переданы имена layout, обернуть результат в каркас. Всё, что удовлетворяет этому интерфейсу, — движок Fiber; всё, что не удовлетворяет, — не движок, как ни называй себя в документации.

Метод c.Render(name string, bind any, layouts ...string) error живёт в res.go на DefaultRes — Ctx подмешивает его через встраивание, как и c.Cookie из прошлого урока. Док-комментарий обещает: Render a template with data and sends a text/html response. We support the following engines: https://github.com/gofiber/template. Внутри — четыре шага. Первый: буфер из пула bytebufferpool — тот же пул, что экономит аллокации во всём Fiber. Второй: nil в bind превращается в пустую fiber.Map, а затем renderExtensions досыпает данные: сначала ключи из c.ViewBind (о нём — в блоке про fiber.Map), затем, если взведён PassLocalsToViews, — значения из Locals; свои ключи вызов Render перекрывает всех. Третий шаг — поиск движка: Fiber идёт по смонтированным приложениям сзади наперёд, и для каждого совпавшего префикса берёт конфиг этого приложения — так каждое смонтированное sub-приложение может нести свой движок; сам вызов обёрнут в RLock глобальной по движку блокировки viewsLock, а перезагрузка шаблонов берёт Lock — читатели не мешают друг другу, писатель ждёт всех.

Четвёртый шаг — и самый неожиданный: если движка в конфиге нет вовсе, c.Render не падает с ошибкой «движок не настроен», а честно рендерит запасным механизмом. Комментарий в исходнике: Render raw template using 'name' as filepath if no engine is set — файл по пути name открывается с диска, парсится html/template-ом и исполняется с вашими данными. Ошибки на этом пути называются по шагам: failed to open, failed to parse, failed to execute; путь от движка оборачивается как failed to render. В конце Content-Type принудительно ставится в text/html; charset=utf-8, и буфер уезжает в тело ответа.

Подключаем движок: Views в конфиге

GO
package main

import (
        "log"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/session"
        "github.com/gofiber/template/html/v3"
)

func main() {
        // движок: папка ./views, расширение .html
        engine := html.New("./views", ".html")
        // перечитывать шаблоны на каждом рендере — режим разработки
        engine.Reload(true)

        app := fiber.New(fiber.Config{
                // поле Views принимает любой интерфейс fiber.Views
                Views: engine,
        })

        // сессии из двенадцатого урока: страница приветствует по имени
        app.Use(session.New())

        tasks := []fiber.Map{
                {"ID": 1, "Title": "Купить кофе", "Done": true},
                {"ID": 2, "Title": "Написать отчёт", "Done": false},
        }

        app.Get("/tasks", func(c fiber.Ctx) error {
                // достаём пользователя из сессии: тизер прошлого урока сбылся
                user, _ := session.FromContext(c).Get("user").(string)
                // c.Render: имя шаблона, данные, layouts... — здесь без layout
                return c.Render("index", fiber.Map{
                        "Title": "Задачи",
                        "User":  user,
                        "Tasks": tasks,
                })
        })

        log.Fatal(app.Listen(":8080"))
}

Вся настройка — три строки. html.New("./views", ".html") создаёт движок: папка, где лежат шаблоны, и расширение, по которому их отличают от остального мусора. engine.Reload(true) включает перечитывание на каждом рендере — режим разработки, о нём отдельный блок. fiber.Config{Views: engine} — и движок встроен: при старте app.init() вызовет engine.Load() один раз, а при ошибке лишь предупредит в лог: failed to load views: %v — приложение всё равно поднимется, и первая же страница честно скажет render: template ... does not exist. В хендлере — ровно один вызов: c.Render("index", fiber.Map{...}). Рядом в ./views/index.html лежит шаблон с тем же словарём, что в минимальном примере выше, плюс приветствие: {{if .User}}<p>Привет, {{.User}}!</p>{{end}}. Сессия из двенадцатого урока отдаёт имя, шаблон вставляет его с автоматическим экранированием — связка работает от куку session_id до готового HTML.

Девять движков gofiber/template и имена v2#

Синтаксис {{.Title}} — не единственный в экосистеме Go, и Fiber не заставляет его любить. Док-комментарий c.Render ссылается на модуль github.com/gofiber/template, и в нём девять адаптеров движков: ace, amber, django, handlebars, html, jet, mustache, pug, slim. Каждый адаптер — отдельный модуль с путём вида github.com/gofiber/template/html/v3: импортируете пакет — получаете конструктор New(directory, extension string) и тот же набор методов настройки. Движок html — это обёртка над стандартным html/template, то есть именно тот синтаксис, который мы разбираем в этом уроке; остальные — над сторонними библиотеками: mustache приносит лаконичный синтаксис без логики {{name}}, pug — питоноподобные отступы вместо закрывающих тегов, django — {% if %} и {% for %}, handlebars — {{#each}} и {{#if}}, jet — быстрые шаблоны с наследованием, amber и slim — компактные языки разметки, ace — максимально близкий к Go-шаблонам. Забавная деталь из исходников: даже pug-движок внутри себя компилирует разметку в html/template — Go-экранирование работает всюду.

Теперь про версии — здесь сюрприз приятный. Сверка с веткой v2 (master репозитория Fiber /v2) показывает: имена переехали без изменений. В v2 те же Config.Views, Config.ViewsLayout и PassLocalsToViews, тот же интерфейс с Load и Render, тот же вызов c.Render(name, bind, layouts...). После уроков про куки и сессии, где v2-имена CookieName, Expiration и глобальная session.Store приходилось выпалывать из чужих примеров, это передышка: старые сниппеты конфига движка переносятся на v3 как есть, достаточно поменять путь импорта. Отдельно стоит NewFileSystem(fs http.FileSystem, extension string) — вариант конструктора, принимающий http.FileSystem: с ним шаблоны читаются не с диска, а из файловой системы, в которую можно подсунуть embed.FS из пятого урока — все шаблоны вшиты в бинарник, и деплой не тащит за собой папку views.

Адаптеры gofiber/template: девять движков#

ДвижокСинтаксисПакет
htmlhtml/template: {{.Field}}, range, ifgithub.com/gofiber/template/html/v3
mustacheбез логики: {{name}}, {{#tasks}}github.com/gofiber/template/mustache/v3
pugотступы вместо тегов: h1= Titlegithub.com/gofiber/template/pug/v3
handlebars{{#each}}, {{#if}}, хелперыgithub.com/gofiber/template/handlebars/v3
django{% if %}, {% for %}, фильтрыgithub.com/gofiber/template/django/v3
jetнаследование, компиляция в байткодgithub.com/gofiber/template/jet/v3
amberотступы, компилируется в html/templategithub.com/gofiber/template/amber/v3
slimкомпактные отступы, как в Rubygithub.com/gofiber/template/slim/v3
aceпочти Go-шаблоны, без директивgithub.com/gofiber/template/ace/v3

Данные в шаблон: fiber.Map, ViewBind и Locals#

Что передать вторым аргументом c.Render? Что угодно — binding any, — но на практике царит fiber.Map: тип из app.go, буквально map[string]any. Это готовая структура данных без объявлений: собрал карту — отдал в шаблон. В примере выше задачи лежат как []fiber.Map — каждая задача карта с ключами ID, Title, Done, и в шаблоне {{.Title}} по-прежнему работает, потому что html/template читает и поля структур, и ключи карт одинаково. Типизированные структуры — не запрет, а выбор: они дают компилятору шанс поймать опечатку в имени поля, а fiber.Map — скорость прототипирования. Для урока канона хватит карт; в большом проекте структуры выгоднее.

Второй аргумент — не единственный источник данных, и вот точный порядок из res.go. Сначала c.ViewBind(vars Map) — метод с док-строкой: ViewBind Add vars to default view var map binding to template engine. Variables are read by the Render method and may be overwritten. Он копит карту на запрос: middleware или общий хендлер кладёт туда то, что нужно всем страницам сразу — имя пользователя, CSRF-токен, версию приложения. Затем, если в конфиге взведён PassLocalsToViews, все значения c.Locals — та же карта пользовательских значений fasthttp — тоже попадают в шаблон; поле конфига честно предупреждает: default выключен, включайте осознанно, чтобы внутренние локалы не утекали в разметку. Порядок приоритетов прост и линеен: то, что вы передали прямо в c.Render, перекрывает ViewBind; ViewBind перекрывает Locals; ни один из них не затирает ключи, уже пришедшие сверху. Практический вывод: в Render — данные конкретной страницы, в ViewBind — общий для всех страниц контекст, Locals — только если вы сознательно включили проход и держите локалы в порядке.

Проверьте себя: чей ключ сильнее

Взведён PassLocalsToViews. Middleware положил c.Locals("user", "anna"). Другой middleware вызвал c.ViewBind(fiber.Map{"user": "boris"}). Хендлер рендерит c.Render("index", fiber.Map{"user": "vera"}). Что подставит {{.User}}?

Layout: общий каркас страницы#

У любой страницы есть всё одинаковое: DOCTYPE, head с мета-тегами, шапка с навигацией, футер. Дублировать это в каждом шаблоне — значит править пять файлов при добавлении одного пункта меню. Layout — это каркас страницы с дыркой посередине, в которую вставляется отрисованный шаблон. В Fiber у layout-паттерна два уровня включения. Локальный: третьим аргументом c.Render — c.Render("index", data, "layouts/main") — variadic layouts ...string, и README модуля показывает именно такой вызов. Глобальный: поле конфига ViewsLayout с док-строкой Views Layout is the global layout for all template render until override on Render function — каркас по умолчанию для всех рендеров, пока конкретный вызов не передал свой layout; в res.go это буквально: если layouts пуст, а ViewsLayout задан, глобальное имя подставляется автоматически.

Внутри каркаса дырку рисует действие {{embed}}. Имя настраивается: метод движка engine.Layout("yield") меняет его, а дефолт — embed, константа из исходников html-движка LayoutName: "embed". Механика в Render красивая: движок строит цепочку вложенных функций — внешний layout рендерится первым, его {{embed}} вызывает рендер следующего, последний embed рендерит сам шаблон страницы. Вариадичность layouts не декоративна: передав список имён, вы получите вложенные каркасы — базовый HTML-скелет, внутри него каркас раздела с сайдбаром, внутри него страница. И две ошибки, которые стоит знать заранее: если переданного имени layout нет среди загруженных шаблонов, движок отвечает render: LayoutName %s does not exist; если же {{embed}} случайно написать в обычной странице — не в каркасе, — срабатывает функция-страж из исходников с сообщением layoutName called unexpectedly.

Каркас layouts/main.html и вызов с layout

GO
<!-- views/layouts/main.html — общий каркас всех страниц -->
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>{{.Title}}</title>
</head>
<body>
  <nav><a href="/tasks">Задачи</a> <a href="/about">О проекте</a></nav>
  <!-- {{embed}} вставляет отрисованный шаблон страницы -->
  {{embed}}
  <footer>Самоучитель Go — курс Fiber</footer>
</body>
</html>

// ---- хендлер: третий аргумент Render — имя каркаса ----
app.Get("/tasks", func(c fiber.Ctx) error {
        return c.Render("index", fiber.Map{"Title": "Задачи"}, "layouts/main")
})

Каркас и страница живут в одном движке, поэтому layout — не отдельный механизм, а просто ещё один загруженный шаблон с именем layouts/main: вложенная папка layouts входит в имя. Данные доступны и каркасу, и странице — одна карта binding отдаётся обоим, поэтому {{.Title}} в head работает. Вызов без третьего аргумента отрисует index без каркаса — так удобно отлаживать фрагменты; глобальный ViewsLayout в конфиге закрывает обратный случай, когда каркас нужен всем рендерам сразу. Помните про две ошибки из блока выше: отсутствие файла-каркаса даст render: LayoutName %s does not exist, а случайный {{embed}} в обычной странице — layoutName called unexpectedly.

Каркаса нет: что ответит сервер?

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

// файл views/layouts/missing.html НЕ создан,
// при этом views/index.html существует
return c.Render("index", fiber.Map{"Title": "Задачи"},
        "layouts/missing")

Автоперезагрузка в dev: Reload и ReloadViews#

Шаблоны парсятся при старте, но вы правите их каждые пять минут — перезапускать сервер на каждую правку вёрстки значит терять темп. В gofiber/template для этого есть метод engine.Reload(true), и комментарий в исходниках формулирует назначение честно: reload on each render, use it when you're in development and you don't want to restart the application when you edit a template file. Дефолт — false: в проде шаблоны разбираются один раз при Load, и каждый рендер — чистое исполнение без чтения диска. Механика включена в исходниках как ShouldReload, а проверка — в методе PreRenderCheck: если набор уже загружен и перезагрузка выключена, рендер идёт по быстрому пути под shared-блокировкой; если ShouldReload взведён, перед каждым рендером файлы читаются заново. Приятная деталь для отладки: неудавшийся Load не выставляет флаг Loaded, поэтому следующий рендер сам повторит попытку — починили опечатку в шаблоне и просто обновили страницу.

Второй инструмент — на стороне Fiber: app.ReloadViews(). Док-комментарий: ReloadViews reloads the configured view engine by invoking its Load method. Метод обходит и корневое приложение, и все смонтированные, у каждого движка берёт write-блокировку — рендеры на время перезагрузки вежливо ждут, — а при неудаче возвращает ошибку fiber: failed to reload views: %w. Сценарий — файловый вотчер: fsnotify заметил изменение в ./views, вызвал ReloadViews, ни один запрос не потерян. Третий уровень — Debug(true) у движка: при каждой загрузке печатает в лог строку вида views: parsed template: имя — незаменимо, когда сомневаетесь, какие имена вообще попали в набор. И последнее из исходников, о чём редко пишут: Load при старте вызывается в app.init(), и его ошибка не валит процесс — только log.Warnf "failed to load views: %v". Сервер поднимется без шаблонов, и каждая страница будет отвечать render: template ... does not exist, пока вы не перезагрузите движок или не перезапустите приложение с починенными файлами.

Соберите подключение движка и рендер

Вставьте пропуски: конструктор html-движка, включение перезагрузки, поле конфига Views и вызов рендера с layout.

// Каркас страницы задач: движок + рендер func setup() *fiber.App { // конструктор html-движка: папка и расширение engine := html.("./views", ".html") // в dev перечитываем шаблоны при каждом рендере engine.(true) app := fiber.New(fiber.Config{ // интерфейс fiber.Views принимает движок : engine, }) app.Get("/tasks", func(c fiber.Ctx) error { // имя шаблона, данные, имя каркаса return c.("index", fiber.Map{ "Title": "Задачи", "Tasks": tasks, }, "layouts/main") }) return app }

Соберите путь запроса через рендер

Расставьте шаги GET /tasks от curl до готового HTML в ответе.

Строки

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

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

Экранирование XSS: контексты html/template#

Вернёмся к обещанному XSS. Опасность серверных шаблонов — подстановка данных: если пользователь контролирует поле, которое уезжает в HTML, то без экранирования <script>alert(1)</script> в названии задачи станет исполняемым скриптом на странице каждого, кто её увидит. html/template защищает автоматически, и защита умнее простой замены символов: пакет разбирает сам шаблон и понимает контекст каждого действия — текст страницы, значение атрибута, URL в href, тело <script>, CSS. Для каждого контекста свой экранировщик из исходников: attrEscaper для атрибутов, urlFilter для адресов, jsValEscaper для JavaScript. В тексте страницы (htmlEscaper) применяется таблица htmlReplacementTable: <, >, &, " и ' — в сущности, плюс и нулевой байт заодно.

Самая поучительная защита — URL. Схема javascript: опасна не символами, а смыслом, поэтому замена символов не поможет — и в исходниках стоит фильтр urlFilter: безопасные схемы http, https и mailto проходят, всё остальное заменяется словом-заглушкой filterFailsafe = "ZgotmplZ". Комментарий к нему рассказывает историю: innocuous word, not a keyword in any programming language, contains no special characters... when it appears in output it is distinct enough that a developer can find the source of the problem via a search engine — авторы специально выбрали слово, которое невозможно встретить случайно и которое гуглится за секунду. Если увидели #ZgotmplZ в href — html/template заблокировал опасную схему.

И честное предупреждение о люке: стандартная библиотека даёт типы «доверенного» контента — template.HTML, template.JS, template.URL. Значение такого типа экранировщик пропускает как есть — в исходниках это буквально if t == contentTypeHTML { return s }. Люк для разметки, которую вы построили сами из проверенных частей; люк в стене для всего остального. template.HTML(строка от пользователя) — это рукопожатие с XSS. Свяжем с восьмым уроком: helmet-заголовки — Content-Security-Policy, ограничения на инлайн-скрипты — это второй рубеж обороны: экранирование не пустило скрипт в разметку, CSP не даст исполниться и тому, что просочилось. Один механизм не отменяет другой — слоёная защита строится именно так.

Проверьте себя: javascript: в href

Шаблон <a href="{{.URL}}">открыть</a>, хендлер передал URL: "javascript:alert(document.cookie)". Что окажется в HTML?

Статика и шаблоны: когда что выбирать#

Пятый урок научил отдавать готовые файлы: app.Static монтирует каталог, и каждый запрос к /assets/style.css выливается в чтение файла с диска и отправку байт в ответ — файл на сервере и файл в ответе совпадают попиксельно, Fiber даже кэширует обработчики fasthttp на CacheDuration вперёд. Шаблоны — противоположный полюс: HTML не существует до запроса, он собирается заново из каркаса и данных для каждого клиента. Граница выбора проходит по вопросу «зависит ли ответ от данных?». Логотип, стили, собранный бандл SPA, robots.txt, картинки — статика из пятого урока, быстрый путь без Go-кода на каждый байт. Страница задач текущего пользователя, список заказов, форма с flash-сообщением из сессии — шаблоны: данные меняются, и готового файла просто нет.

Гибрид — норма индустрии, и у него два типовых вида. Первый вы уже собирали в восьмом уроке: статический SPA-каркас из app.Static плюс JSON API — шаблонам тут делать нечего, интерфейс рисует JavaScript. Второй — серверный рендер: страницы целиком собирает Fiber, а статика обслуживает ассеты под префиксом /assets; в одном приложении спокойно живут app.Static("/assets", "./public") для файлов и Config{Views: engine} для страниц — они не конфликтуют, потому что/static отдаёт существующие файлы, а маршруты страниц обрабатывают остальное. Отдельный мостик — embed: пятый урок вшивал статику в бинарник через embed.FS, а здесь NewFileSystem(fs, extension) делает то же с шаблонами; в обоих случаях деплой — один файл, без папок views и public рядом с бинарником. Разница в сложности поддержки: статике всё равно, шаблоны — это код в данных, и за их синтаксис отвечает тот же code review, что и за Go.

Ловушки рендера: симптом, причина, лечение#

СимптомПричинаЛечение
c.Render читает файл с диска и парсит на каждый запросViews в конфиге не задан: работает запасной рендер 'name' как пути файланастроить fiber.Config{Views: engine} — запасной путь только для экспресс-задач
render: template index does not existимя не совпало: расширение файла != Extension движка или файл вне Directoryсверить папку и расширение; Debug(true) покажет список загруженных имён
render: LayoutName layouts/main does not existфайл каркаса не загружен или имя передано с расширением .htmlимена — без расширения: layouts/main, не layouts/main.html
layoutName called unexpectedly{{embed}} написан в обычной странице, а не в каркасе{{embed}} живёт только в layout-шаблонах, в страницы — {{template}} и {{block}}
правки шаблона не видны без перезапускаReload(false) — дефолт: рендер идёт по скомпилированному наборуdev: engine.Reload(true) или app.ReloadViews() из вотчера
приложение поднялось, все страницы дают failed to renderLoad при старте упал, но только log.Warnf failed to load views: %vчинить шаблоны; при Reload(true) следующий рендер повторит загрузку сам
в href вместо адреса — #ZgotmplZurlFilter html/template заблокировал небезопасную схемупринимать только http/https/mailto; схему валидировать на входе

Словарь урока

Термин

fiber.Views

нажмите, чтобы перевернуть
Определение

Интерфейс из ctx.go: Views is the interface that wraps the Render function. Два метода: Load() error — разбор шаблонов — и Render(out io.Writer, name string, binding any, layout ...string) error. Всё, что умеет эти два метода, — движок Fiber; интерфейс реализуют все девять адаптеров github.com/gofiber/template.

1 / 6

Запуск, четыре сценария curl и что дальше#

Соберите каркас: движок html.New("./views", ".html") с Reload(true), Config{Views: engine}, сессии из двенадцатого урока, шаблоны index.html и layouts/main.html, маршрут /tasks — при желании под стражем requireAPIKey("s3cret") из седьмого урока, как в каноне API. Прогоните четыре сценария. Первый: curl localhost:8080/tasks — в ответе HTML с каркасом: навигация, заголовок «Задачи», список из двух пунктов и подпись «Гость», потому что сессии нет. Второй: войдите формой и повторите curl -b jar.txt localhost:8080/tasks — вместо «Гость» появится «Привет, anna!»: имя приехало из sess.Get("user") и проэкранировалось на пути в разметку. Третий: переименуйте шаблон в views/index.html.bak и обновите страницу — 500 с render: template index does not exist; верните имя, и при Reload(true) следующая же загрузка починит страницу без перезапуска сервера. Четвёртый: подставьте в Title строку со скриптом и посмотрите в исходнике страницы сущности вместо тегов — XSS закрыт экранированием, а в href с javascript:-схемой вы найдёте ZgotmplZ.

Материал этого урока держится на четырёх опорах: статики из пятого урока — там app.Static и embed.FS, с которыми шаблоны делят приложение, полный план — на странице курса Fiber. Песочница сайта сетевые сценарии не запускает — runner-ов в уроках Fiber нет, интерактивы здесь quiz, predict, fill, order и флешкарты; каркас с шаблонами собирайте локально в терминале.

Дальше — четырнадцатый урок «Обработка ошибок»: ErrorHandler, свои типы ошибок и единый формат ответа. Сегодняшняя тема тянет его за собой уже сейчас: c.Render вернул ошибку failed to render — и по умолчанию это голый текст на 500; в следующем уроке вы научитесь рисовать вместо него страницу «что-то сломалось» в том же каркасе layouts/main и отдавать API честные JSON-ошибки — по одной схеме для страниц и для API.

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

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

Вывод

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

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

  • Два слоя темы: язык шаблонов — стандартный html/template ({{.Field}}, range, if, функции, конвейеры) и транспорт Fiber v3 — интерфейс fiber.Views (Load + Render) и метод c.Render(name, bind, layouts...) в res.go. Поля конфига Views, ViewsLayout и PassLocalsToViews переехали из v2 без переименований.
  • Механика c.Render: буфер из bytebufferpool, nil в bind становится fiber.Map, ViewBind и Locals досыпают только отсутствующие ключи (Render > ViewBind > Locals), движок ищется среди смонтированных приложений, Content-Type принудительно text/html; charset=utf-8. Без движка — запасной рендер файла по пути name через html/template (failed to open/parse/execute).
  • Модуль github.com/gofiber/template несёт девять адаптеров движков — ace, amber, django, handlebars, html, jet, mustache, pug, slim — каждый своим модулем вида github.com/gofiber/template/html/v3. html — обёртка над стандартной библиотекой; NewFileSystem принимает http.FileSystem для embed.FS.
  • Layout: третий аргумент c.Render или глобальный ViewsLayout; внутри каркаса {{embed}} (дефолт LayoutName, меняется engine.Layout). Вложенные каркасы — через список имён. Ошибки: render: template %s does not exist, render: LayoutName %s does not exist, layoutName called unexpectedly.
  • Разработка: engine.Reload(true) перечитывает шаблоны на каждом рендере, app.ReloadViews() — по требованию (fiber: failed to reload views: %w), Debug(true) печатает загруженные имена. Load при старте не валит процесс — только log.Warnf failed to load views: %v; неудавшийся Load повторится на следующем рендере.
  • Экранирование контекстное: текст и атрибуты — сущности, URL — фильтр схем (http/https/mailto, остальное — #ZgotmplZ), JS и CSS — свои экранировщики. template.HTML отключает экранирование — только для своей разметки. Helmet из восьмого урока — второй рубеж.
  • Статика из пятого урока — файлы как есть (app.Static), шаблоны — HTML собирается на каждый запрос из данных; гибрид норма: /assets статикой, страницы — через Views. Имя шаблона — путь от Directory без расширения: layouts/main, index.

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

Дособерите страницу задач до продакшн-видов: вынесите каркас в layouts/main.html, добавьте партиал карточки задачи через {{define "task"}} и {{template "task" .}} в цикле, а флеш-сообщения из двенадцатого урока отрисуйте в каркасе — {{if .Flash}} с полем, которое страж кладёт в ViewBind до хендлера. Добавьте функцию движка engine.AddFunc("короткаяДата", ...) и форматируйте CreatedAt из канона в человекочитаемый вид прямо в шаблоне. Сломайте нарочно один шаблон, убедитесь, что приложение живо и рендер ругается, затем почините при Reload(true) без перезапуска. Подставьте в поле заголовка строку со скриптом и найдите сущности в исходнике страницы; соберите ссылку с javascript:-схемой и объясните себе ZgotmplZ. Наконец, вшейте шаблоны в бинарник через embed.FS и NewFileSystem из этого урока и статические ассеты из пятого — один бинарник должен нести и разметку, и стили.

Личные заметкиПусто
Хранятся только в вашем браузере (Fiber · HTML-шаблоны)

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

c.Render отдаёт render: template index does not exist — что проверить первым делом?

Три вещи в порядке частоты: совпадает ли расширение файлов с тем, что передано конструктору html.New("./views", ".html") — файлы .tmpl при Extension ".html" молча пропускаются при обходе; попадает ли файл в Directory — обход идёт по папке движка, а имя в c.Render — путь от этой папки без расширения; и вызывался ли Load — при старте он выполняется сам, но ошибка только логируется (failed to load views: %v), а не валит процесс. Включите engine.Debug(true): при каждой загрузке в лог печатается views: parsed template: имя — сразу видно, какие имена реально есть в наборе.

Обязателен ли gofiber/template, если есть html/template в стандартной библиотеке?

Нет. c.Render без настроенного Views умеет запасной путь: читает файл по переданному имени с диска и парсит его html/template-ом — для пары страниц этого хватает. Движок даёт главное: разбор всех шаблонов один раз при старте, layout-механику с {{embed}}, переиспользование через {{define}}, FuncMap-функции и перезагрузку без перезапуска. Другие движки из девяти адаптеров берут ради синтаксиса — mustache для лаконичности, pug для отступов; их Fiber встраивает тем же интерфейсом fiber.Views, и хендлеры не меняются.

Как вывести в шаблон готовый HTML без экранирования — и когда это допустимо?

Только через типы доверенного контента: template.HTML для разметки, template.JS для скриптов, template.URL для адресов — значение такого типа экранировщик пропускает как есть. Допустимо, когда разметку построил ваш код из проверенных частей: например, подсветка синтаксиса или иконки из собственной библиотеки. Недопустимо — для любых данных пользователя: template.HTML(строка от клиента) — это прямое рукопожатие с XSS, экранирование для того и встроено, чтобы не доверять данным. Правило: разметку строит шаблон из данных, а не данные несут разметку; если без HTML в данных никак — прогоняйте их через санитайзер до помещения в template.HTML.

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

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

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

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

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

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

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT