Go: с нуля до своего блога Урок 22 из 50
Шаблоны в Go: html/template и страница вместо строк
Двадцать второй урок курса по Go. HTML переезжает из кода в отдельный файл: точка как имя данных, range по срезу, if и else. Почему html/template сам обезвреживает чужой текст, зачем шаблоны разбирают один раз при старте и почему страницу собирают в буфер, а не сразу в ответ.
Зачем это нужно
До сих пор обработчики писали строки: fmt.Fprintln(w, "…"). Собрать так одну страницу можно. Собрать десять — нельзя: HTML внутри кода не подсвечивается редактором, не проверяется и превращается в кашу из кавычек при первой же таблице.
Разложим: разметку в файл, данные в структуру, а сложение поручим шаблонизатору.
Сразу целиком
Новая папка, go mod init sabaq20. Внутри — папка templates и в ней list.html:
<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>{{ .Title }}</title></head>
<body>
<h1>{{ .Title }}</h1>
{{ if .Articles }}
<ul>
{{ range .Articles }}
<li><a href="/read/{{ .Slug }}">{{ .Title }}</a> — {{ .ReadingTime }} мин</li>
{{ end }}
</ul>
{{ else }}
<p>Пока статей нет.</p>
{{ end }}
</body>
</html>
Рядом main.go:
package main
import (
"bytes"
"embed"
"html/template"
"log"
"net/http"
)
//go:embed templates/*.html
var files embed.FS
type Article struct {
Slug string
Title string
Words int
}
func (a Article) ReadingTime() int { return (a.Words + 199) / 200 }
type page struct {
Title string
Articles []Article
}
func main() {
tpl := template.Must(template.ParseFS(files, "templates/*.html"))
blog := []Article{
{Slug: "dala", Title: "О степи", Words: 400},
{Slug: "shanyraq", Title: "Что такое шанырак", Words: 1000},
{Slug: "xss", Title: `<script>alert("взломано")</script>`, Words: 150},
}
http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "list.html", page{Title: "Мой блог", Articles: blog}); err != nil {
log.Println("template:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
})
log.Println("http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Прежде чем запускать — скажите вслух, что окажется на месте третьего заголовка, в котором лежит <script>. Потом запустите go run ., откройте localhost:8080 и посмотрите исходный код страницы:
<li><a href="/read/dala">О степи</a> — 2 мин</li>
<li><a href="/read/shanyraq">Что такое шанырак</a> — 5 мин</li>
<li><a href="/read/xss"><script>alert("взломано")</script></a> — 1 мин</li>
Третья строка — главное в уроке. Скрипт не выполнился и не сломал страницу: он приехал как текст.
Разбор
Шаблон — это HTML с дырками
Файл list.html — обычная разметка, которую понимает редактор. Всё, что в двойных фигурных скобках, шаблонизатор заменит на данные:
{{ .Title }}
Точка — это то, что вы передали в Execute. .Title — поле этой структуры. Имена полей должны начинаться с заглавной буквы, иначе шаблон их не увидит: он живёт в другом пакете, а правило про заглавную букву действует и здесь.
Образ. Бланк с пропусками. Форма напечатана заранее и одна на всех, а в пустые графы каждый раз вписывают своё.
range и if
{{ range .Articles }} … {{ end }}
{{ if .Articles }} … {{ else }} … {{ end }}
Внутри range точка меняет значение: теперь это очередная статья, а не вся страница. Поэтому внутри пишут .Title, и это заголовок статьи.
if проверяет пустоту: пустой срез, пустая строка и ноль считаются ложью. Отсюда {{ else }} со словами «пока пусто» — страница, которая не ломается на новом блоге без статей.
Метод, а не только поле
{{ .ReadingTime }} мин
ReadingTime — не поле, а метод из урока про структуры. Шаблон вызывает его сам, без скобок. Годится любой метод, который ничего не принимает и возвращает одно значение.
Логику лучше держать в методах и передавать в шаблон готовые числа. Шаблон, в котором считают, быстро становится второй программой — на языке, где нет ни отладчика, ни тестов.
Экранирование — то, ради чего берут html/template
В стандартной библиотеке два шаблонизатора. Разница видна на одной строке:
text/template: <li><script>alert("взломано")</script></li>
html/template: <li><script>alert("взломано")</script></li>
text/template подставляет как есть. html/template понимает, что собирает HTML, и обезвреживает чужой текст: скрипт становится текстом скрипта.
Это защита от целого класса атак: злоумышленник пишет <script> в заголовок статьи или в комментарий, а страница выполняет его у каждого читателя. Пока вы берёте html/template и не выключаете экранирование руками, эта дверь закрыта.
Образ. Обеззараживание на входе. Посылку не выбрасывают и не читают за вас — её просто обрабатывают так, чтобы содержимое не могло навредить.
Экранирование зависит от места: в тексте, в атрибуте, внутри <script> и в адресе ссылки правила разные, и шаблонизатор различает их сам.
Когда HTML нужен как HTML
Экранирование иногда мешает — и ровно один раз по делу. Тело статьи вы пишете в Markdown и превращаете в HTML сами; отдавать его строкой значит показать читателю теги вместо текста:
строкой: <p>абзац из <b>Markdown</b></p>
доверенно: <p>абзац из <b>Markdown</b></p>
Разница в типе. Обычная строка экранируется, а значение типа template.HTML шаблон подставляет как есть:
type page struct {
Body template.HTML
}
И здесь единственное правило, которое нельзя нарушать: в template.HTML кладут только то, что сделали вы сами. Текст читателя, поле из формы, кусок из чужого API — никогда. Этот тип отключает защиту, и отключает её молча.
Разбирать один раз, а не на каждый запрос
tpl := template.Must(template.ParseFS(files, "templates/*.html"))
Разбор шаблонов — работа с файлами и текстом. Делать её на каждый запрос значит тратить время читателя впустую.
template.Must — короткая запись для «если не разобралось, падай сразу». Это тот самый случай, когда panic уместен: сломанный шаблон при старте лучше, чем белая страница у читателя.
А //go:embed кладёт файлы шаблонов внутрь собранной программы. Один файл вместо программы и папки рядом с ней — и никакой возможности выложить сервер без разметки.
Почему в буфер, а не сразу в ответ
Вот тонкость, которая стоит целого абзаца.
Execute пишет по мере сборки. Если ошибка вылезет на середине — например, вы обратились к полю, которого нет, — часть страницы уже уехала читателю вместе с кодом 200. Проверьте сами:
<p>начало</p>
ошибка: template: c:2:3: executing "c" at <.Nope>: can't evaluate field Nope in type main.A
Первая строка уже у читателя, и вернуть её нельзя — это правило из урока про HTTP. Поэтому собирают в bytes.Buffer и только потом отдают: получилось — пишем целиком, не получилось — 500 и запись в журнал.
Образ. Блюдо выносят готовым. Повар не носит его в зал по частям и не забирает половину обратно, если что-то подгорело: на кухне собирают целиком, а в зал выходят один раз.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Что означает точка внутри
{{ range .Articles }}и почему она меняет значение? - Читатель назвал статью
<script>alert(1)</script>. Что увидят остальные? - Зачем собирать страницу в буфер, если можно писать сразу в
w?
Задание
Обязательное. Сделайте вторую страницу — одну статью по адресу /read/{slug}. Заведите шаблон article.html, найдите статью в срезе по адресу, а если такой нет — отвечайте 404 своей страницей, тоже из шаблона. Заголовок и время чтения возьмите из структуры, тело — из поля Body.
По желанию.
- Добавьте статью с заголовком
<b>жирный</b>и убедитесь, что теги видны как текст. - Поменяйте
html/templateнаtext/templateи посмотрите на ту же страницу. Верните обратно. - Уберите буфер, обратитесь в шаблоне к несуществующему полю и посмотрите, что получит браузер.
Куда это встанет в блоге
Блог перестал быть выводом в терминал. Дальше появятся формы — читатель начнёт присылать данные, а не только получать, — и статика: CSS, шрифты, картинки.
Общий макет — шапку, подвал и меню — вынесут в отдельный шаблон, чтобы не повторять их на каждой странице. Это тот же приём, что и обёртка из прошлого урока, только для разметки.
Ответы
Показать ответы
- Точка — это данные, которые сейчас подставляются. На странице это вся структура, а внутри
range— очередной элемент среза. Поэтому снаружи.Titleозначает заголовок страницы, а внутри — заголовок статьи. - Текст
<script>alert(1)</script>, а не выполненный скрипт:html/templateэкранирует всё, что подставляет, и по месту подстановки выбирает нужный способ. Именно поэтому берут его, а неtext/template. - Потому что
Executeпишет по ходу дела, и ошибка на середине оставит читателя с половиной страницы и кодом 200, который уже не изменить. Собрав в буфер, вы либо отдаёте готовое целиком, либо честно отвечаете 500.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.