Shanraq.org Shanraq.org
Шаблоны в Go: html/template и страница вместо строк
IT

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">&lt;script&gt;alert(&#34;взломано&#34;)&lt;/script&gt;</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>&lt;script&gt;alert(&#34;взломано&#34;)&lt;/script&gt;</li>

text/template подставляет как есть. html/template понимает, что собирает HTML, и обезвреживает чужой текст: скрипт становится текстом скрипта.

Это защита от целого класса атак: злоумышленник пишет <script> в заголовок статьи или в комментарий, а страница выполняет его у каждого читателя. Пока вы берёте html/template и не выключаете экранирование руками, эта дверь закрыта.

Образ. Обеззараживание на входе. Посылку не выбрасывают и не читают за вас — её просто обрабатывают так, чтобы содержимое не могло навредить.

Экранирование зависит от места: в тексте, в атрибуте, внутри <script> и в адресе ссылки правила разные, и шаблонизатор различает их сам.

Когда HTML нужен как HTML

Экранирование иногда мешает — и ровно один раз по делу. Тело статьи вы пишете в Markdown и превращаете в HTML сами; отдавать его строкой значит показать читателю теги вместо текста:

строкой:   &lt;p&gt;абзац из &lt;b&gt;Markdown&lt;/b&gt;&lt;/p&gt;
доверенно: <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 и запись в журнал.

Образ. Блюдо выносят готовым. Повар не носит его в зал по частям и не забирает половину обратно, если что-то подгорело: на кухне собирают целиком, а в зал выходят один раз.

Карта урока

Карта урока: шаблон и данные складываются в одну страницу

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

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

  1. Что означает точка внутри {{ range .Articles }} и почему она меняет значение?
  2. Читатель назвал статью <script>alert(1)</script>. Что увидят остальные?
  3. Зачем собирать страницу в буфер, если можно писать сразу в w?

Задание

Обязательное. Сделайте вторую страницу — одну статью по адресу /read/{slug}. Заведите шаблон article.html, найдите статью в срезе по адресу, а если такой нет — отвечайте 404 своей страницей, тоже из шаблона. Заголовок и время чтения возьмите из структуры, тело — из поля Body.

По желанию.

  • Добавьте статью с заголовком <b>жирный</b> и убедитесь, что теги видны как текст.
  • Поменяйте html/template на text/template и посмотрите на ту же страницу. Верните обратно.
  • Уберите буфер, обратитесь в шаблоне к несуществующему полю и посмотрите, что получит браузер.

Куда это встанет в блоге

Блог перестал быть выводом в терминал. Дальше появятся формы — читатель начнёт присылать данные, а не только получать, — и статика: CSS, шрифты, картинки.

Общий макет — шапку, подвал и меню — вынесут в отдельный шаблон, чтобы не повторять их на каждой странице. Это тот же приём, что и обёртка из прошлого урока, только для разметки.

Ответы

Показать ответы
  1. Точка — это данные, которые сейчас подставляются. На странице это вся структура, а внутри range — очередной элемент среза. Поэтому снаружи .Title означает заголовок страницы, а внутри — заголовок статьи.
  2. Текст <script>alert(1)</script>, а не выполненный скрипт: html/template экранирует всё, что подставляет, и по месту подстановки выбирает нужный способ. Именно поэтому берут его, а не text/template.
  3. Потому что Execute пишет по ходу дела, и ошибка на середине оставит читателя с половиной страницы и кодом 200, который уже не изменить. Собрав в буфер, вы либо отдаёте готовое целиком, либо честно отвечаете 500.

Источники

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

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

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

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

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

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