> For the complete documentation index, see [llms.txt](https://eda-1.gitbook.io/lgwt/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://eda-1.gitbook.io/lgwt/osnovy-go/html-templates.md).

# Шаблонизация

[**Весь код вы найдете здесь**](https://github.com/quii/learn-go-with-tests/tree/main/blogrenderer)

Мы живем в мире, где каждый хочет создавать веб-приложения с использованием новейшего, популярного фронтенд-фреймворка, построенного на гигабайтах транспилированного JavaScript и работающего с византийской системой сборки; [но, возможно, это не всегда необходимо](https://quii.dev/The_Web_I_Want).

Я бы сказал, что большинство Go-разработчиков ценят простой, стабильный и быстрый инструментарий, но фронтенд-мир часто не может этого обеспечить.

Многие веб-сайты не нуждаются в [SPA](https://en.wikipedia.org/wiki/Single-page_application). **HTML и CSS — это фантастические способы доставки контента**, и вы можете использовать Go для создания веб-сайта, который будет доставлять HTML.

Если вы все же хотите иметь динамические элементы, вы можете добавить немного клиентского JavaScript, или даже попробовать поэкспериментировать с [Hotwire](https://hotwired.dev), который позволяет обеспечить динамичный пользовательский опыт с серверным подходом.

Вы можете генерировать свой HTML в Go, используя сложный подход с [`fmt.Fprintf`](https://pkg.go.dev/fmt#Fprintf), но в этой главе вы узнаете, что стандартная библиотека Go предлагает инструменты для генерации HTML более простым и поддерживаемым способом. Вы также узнаете более эффективные способы тестирования такого кода, с которыми, возможно, не сталкивались раньше.

## Что мы собираемся построить

В главе [Чтение файлов](/lgwt/osnovy-go/reading-files.md) мы написали код, который принимает [`fs.FS`](https://pkg.go.dev/io/fs) (файловую систему) и возвращает срез `Post` для каждого найденного markdown-файла.

```go
posts, err := blogposts.NewPostsFromFS(os.DirFS("posts"))
```

Вот как мы определили `Post`:

```go
type Post struct {
	Title, Description, Body string
	Tags                     []string
}
```

Вот пример одного из markdown-файлов, который можно разобрать.

```markdown
Title: Welcome to my blog
Description: Introduction to my blog
Tags: cooking, family, live-laugh-love
---
# First recipe!
Welcome to my **amazing recipe blog**. I am going to write about my family recipes, and make sure I write a long, irrelevant and boring story about my family before you get to the actual instructions.
```

Если мы продолжим наш путь по написанию программного обеспечения для блога, мы возьмем эти данные и сгенерируем из них HTML, чтобы наш веб-сервер мог вернуть его в ответ на HTTP-запросы.

Для нашего блога мы хотим генерировать два типа страниц:

1. **Просмотр записи**. Отображает конкретную запись. Поле `Body` в `Post` — это строка, содержащая markdown, поэтому оно должно быть преобразовано в HTML.
2. **Индекс**. Перечисляет все записи с гиперссылками для просмотра конкретной записи.

Мы также хотим иметь единый внешний вид и поведение на всем нашем сайте, поэтому для каждой страницы у нас будут стандартные HTML-элементы, такие как `<html>` и `<head>`, содержащие ссылки на CSS-таблицы стилей и все остальное, что нам может понадобиться.

При создании программного обеспечения для блога у вас есть несколько вариантов подхода к тому, как вы создаете и отправляете HTML в браузер пользователя.

Мы спроектируем наш код так, чтобы он принимал `io.Writer`. Это означает, что вызывающий наш код имеет гибкость:

* Записывать их в [os.File](https://pkg.go.dev/os#File), чтобы они могли быть статически обслуживаться
* Выводить HTML напрямую в [`http.ResponseWriter`](https://pkg.go.dev/net/http#ResponseWriter)
* Или просто записывать их куда угодно! Пока объект реализует `io.Writer`, пользователь может генерировать HTML из `Post`

## Сначала напишите тест

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

На мой взгляд, фактический просмотр контента имеет более высокий приоритет, чем индексная страница. Мы могли бы запустить этот продукт и делиться прямыми ссылками на наш замечательный контент. Индексная страница, которая не может ссылаться на фактический контент, бесполезна.

Тем не менее, рендеринг записи, как описано ранее, всё ещё кажется масштабным. Все эти HTML-элементы, преобразование тела markdown в HTML, перечисление тегов и т.д.

На этом этапе я не слишком озабочен конкретной разметкой, и легким первым шагом было бы просто проверить, можем ли мы отобразить заголовок записи как `<h1>`. Это *кажется* наименьшим первым шагом, который может немного продвинуть нас вперед.

```go
package blogrenderer_test

import (
	"bytes"
	"github.com/quii/learn-go-with-tests/blogrenderer"
	"testing"
)

func TestRender(t *testing.T) {
	var (
		aPost = blogrenderer.Post{
			Title:       "hello world",
			Body:        "This is a post",
			Description: "This is a description",
			Tags:        []string{"go", "tdd"},
		}
	)

	t.Run("it converts a single post into HTML", func(t *testing.T) {
		buf := bytes.Buffer{}
		err := blogrenderer.Render(&buf, aPost)

		if err != nil {
			t.Fatal(err)
		}

		got := buf.String()
		want := `<h1>hello world</h1>`
		if got != want {
			t.Errorf("got '%s' want '%s'", got, want)
		}
	})
}
```

Наше решение принимать `io.Writer` также упрощает тестирование; в этом случае мы записываем данные в [`bytes.Buffer`](https://pkg.go.dev/bytes#Buffer), содержимое которого мы можем позже проверить.

## Попробуйте запустить тест

Если вы читали предыдущие главы этой книги, вы должны быть хорошо знакомы с этим. Вы не сможете запустить тест, потому что у нас нет определенного пакета или функции `Render`. Попробуйте сами следовать сообщениям компилятора и добиться состояния, когда вы сможете запустить тест и увидеть, что он завершается ошибкой с четким сообщением.

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

## Напишите минимальный объем кода для запуска теста и проверьте вывод падающего теста

Это минимальный код для запуска теста:

```go
package blogrenderer

// if you're continuing from the read files chapter, you shouldn't redefine this
type Post struct {
	Title, Description, Body string
	Tags                     []string
}

func Render(w io.Writer, p Post) error {
	return nil
}
```

Тест должен пожаловаться, что пустая строка не соответствует тому, что мы хотим.

## Напишите достаточно кода, чтобы он прошел

```go
func Render(w io.Writer, p Post) error {
	_, err := fmt.Fprintf(w, "<h1>%s</h1>", p.Title)
	return err
}
```

Помните, что разработка программного обеспечения — это прежде всего учебная деятельность. Чтобы открывать и учиться в процессе работы, нам нужно работать так, чтобы получать частые, высококачественные циклы обратной связи, и самый простой способ сделать это — работать небольшими шагами.

Поэтому сейчас мы не беспокоимся об использовании каких-либо библиотек шаблонов. Вы можете создавать HTML с помощью «обычной» строковой шаблонизации, и, пропустив часть с шаблоном, мы можем проверить небольшой, но полезный фрагмент поведения и выполнить небольшую работу по проектированию API нашего пакета.

## Рефакторинг

Пока особо нечего рефакторить, поэтому перейдем к следующей итерации.

## Сначала напишите тест

Теперь, когда у нас есть очень простая рабочая версия, мы можем итерировать тест, чтобы расширить функциональность. В данном случае, это рендеринг дополнительной информации из `Post`.

```go
	t.Run("it converts a single post into HTML", func(t *testing.T) {
		buf := bytes.Buffer{}
		err := blogrenderer.Render(&buf, aPost)

		if err != nil {
			t.Fatal(err)
		}

		got := buf.String()
		want := `<h1>hello world</h1>
<p>This is a description</p>
Tags: <ul><li>go</li><li>tdd</li></ul>`

		if got != want {
			t.Errorf("got '%s' want '%s'", got, want)
		}
	})
```

Обратите внимание, что написание этого *выглядит* неудобно. Видеть всю эту разметку в тесте неприятно, и мы еще даже не вставили тело или фактический HTML, который нам понадобится со всем содержимым `<head>` и любыми элементами оформления страницы.

Тем не менее, давайте пока *терпеть* эту боль.

## Попробуйте запустить тест

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

## Напишите достаточно кода, чтобы он прошел

Попробуйте сделать это сами, не копируя код. Вы должны обнаружить, что добиться прохождения этого теста *довольно раздражающе*! Когда я попробовал, моя первая попытка выдала такую ошибку:

```
=== RUN   TestRender
=== RUN   TestRender/it_converts_a_single_post_into_HTML
    renderer_test.go:32: got '<h1>hello world</h1><p>This is a description</p><ul><li>go</li><li>tdd</li></ul>' want '<h1>hello world</h1>
        <p>This is a description</p>
        Tags: <ul><li>go</li><li></li></ul>'
```

Новые строки! Кому они нужны? Ну, нашему тесту нужны, потому что он ищет точное строковое значение. Должен ли? Я пока убрал новые строки, просто чтобы тест прошел.

```go
func Render(w io.Writer, p Post) error {
	_, err := fmt.Fprintf(w, "<h1>%s</h1>\n<p>%s</p>\n", p.Title, p.Description)
	if err != nil {
		return err
	}

	_, err = fmt.Fprint(w, "Tags: <ul>")
	if err != nil {
		return err
	}

	for _, tag := range p.Tags {
		_, err = fmt.Fprintf(w, "<li>%s</li>", tag)
		if err != nil {
			return err
		}
	}

	_, err = fmt.Fprint(w, "</ul>")
	if err != nil {
		return err
	}

	return nil
}
```

**Ой-ой**. Не самый красивый код, который я написал, и мы всё ещё находимся на очень раннем этапе реализации нашей разметки. Нам понадобится гораздо больше контента и элементов на нашей странице, и мы быстро видим, что такой подход не подходит.

Тем не менее, что крайне важно, у нас есть проходящий тест; у нас есть работающее программное обеспечение.

## Рефакторинг

С подстраховкой в виде проходящего теста для работающего кода мы можем теперь обдумать изменение подхода к реализации на стадии рефакторинга.

### Введение шаблонов

В Go есть два пакета для шаблонов: [text/template](https://pkg.go.dev/text/template) и \[html/template), и они имеют один и тот же интерфейс. Оба они позволяют комбинировать шаблон и некоторые данные для создания строки.

В чем разница с HTML-версией?

> Пакет template (html/template) реализует управляемые данными шаблоны для генерации HTML-вывода, безопасного от инъекций кода. Он предоставляет тот же интерфейс, что и пакет text/template, и должен использоваться вместо text/template всякий раз, когда вывод является HTML.

Язык шаблонов очень похож на [Mustache](https://mustache.github.io) и позволяет динамически генерировать контент очень чисто, с хорошим разделением ответственностей. По сравнению с другими языками шаблонов, которые вы, возможно, использовали, он очень ограничен или «без логики», как любит говорить Mustache. Это важное **и преднамеренное** дизайнерское решение.

Хотя мы сосредоточены здесь на генерации HTML, если ваш проект выполняет сложные конкатенации строк и заклинания, вы можете обратиться к `text/template`, чтобы привести свой код в порядок.

### Обратно к коду

Вот шаблон для нашего блога:

`<h1>{{.Title}}</h1><p>{{.Description}}</p>Tags: <ul>{{range .Tags}}<li>{{.}}</li>{{end}}</ul>`

Где мы определяем эту строку? У нас есть несколько вариантов, но чтобы шаги были небольшими, давайте начнем с обычной строки:

```go
package blogrenderer

import (
	"html/template"
	"io"
)

const (
	postTemplate = `<h1>{{.Title}}</h1><p>{{.Description}}</p>Tags: <ul>{{range .Tags}}<li>{{.}}</li>{{end}}</ul>`
)

func Render(w io.Writer, p Post) error {
	templ, err := template.New("blog").Parse(postTemplate)
	if err != nil {
		return err
	}

	return templ.Execute(w, p)
}
```

Мы создаем новый шаблон с именем, а затем разбираем нашу строку шаблона. Затем мы можем использовать метод `Execute` для него, передавая наши данные, в данном случае `Post`.

Шаблон будет заменять такие вещи, как `{{.Description}}`, содержимым `p.Description`. Шаблоны также предоставляют некоторые программные примитивы, такие как `range` для циклического обхода значений и `if`. Более подробную информацию вы можете найти в [документации text/template](https://pkg.go.dev/text/template).

*Это должен быть чистый рефакторинг.* Нам не нужно будет менять наши тесты, и они должны продолжать проходить. Важно отметить, что наш код стало легче читать, и он содержит гораздо меньше раздражающей обработки ошибок.

Люди часто жалуются на многословность обработки ошибок в Go, но вы можете обнаружить, что есть лучшие способы написать код, чтобы он изначально был менее подвержен ошибкам, как здесь.

### Дополнительный рефакторинг

Использование `html/template` определенно было улучшением, но наличие его в виде строковой константы в нашем коде не очень хорошо:

* Его всё ещё довольно трудно читать.
* Он не удобен для IDE/редактора. Нет подсветки синтаксиса, возможности переформатировать, рефакторить и т.д.
* Он выглядит как HTML, но вы не можете работать с ним так, как с «обычным» HTML-файлом.

Что мы хотим сделать, так это разместить наши шаблоны в отдельных файлах, чтобы мы могли лучше их организовать и работать с ними, как с HTML-файлами.

Создайте папку "templates" и внутри нее создайте файл `blog.gohtml`, вставьте наш шаблон в файл.

Теперь измените наш код, чтобы встроить файловые системы, используя [функциональность встраивания, включенную в go 1.16](https://pkg.go.dev/embed).

```go
package blogrenderer

import (
	"embed"
	"html/template"
	"io"
)

var (
	//go:embed "templates/*"
	postTemplates embed.FS
)

func Render(w io.Writer, p Post) error {
	templ, err := template.ParseFS(postTemplates, "templates/*.gohtml")
	if err != nil {
		return err
	}

	return templ.Execute(w, p)
}
```

Встраивая «файловую систему» в наш код, мы можем загружать несколько шаблонов и свободно их комбинировать. Это станет полезным, когда мы захотим совместно использовать логику рендеринга в разных шаблонах, например, заголовок для верхней части HTML-страницы и нижний колонтитул.

### `embed`?

`embed` был немного затронут в главе [Чтение файлов](/lgwt/osnovy-go/reading-files.md). [Документация стандартной библиотеки объясняет](https://pkg.go.dev/embed):

> Пакет embed предоставляет доступ к файлам, встроенным в запущенную Go-программу.
>
> Исходные файлы Go, импортирующие "embed", могут использовать директиву `//go:embed` для инициализации переменной типа `string`, `[]byte` или `FS` содержимым файлов, прочитанных из каталога пакета или подкаталогов во время компиляции.

Зачем нам это использовать? Альтернатива заключается в том, что мы *можем* загружать наши шаблоны из «обычной» файловой системы. Однако это означает, что нам придется убедиться, что шаблоны находятся в правильном пути к файлу везде, где мы хотим использовать это программное обеспечение. В вашей работе у вас могут быть различные среды, такие как разработка, стейджинг и продакшн. Для того чтобы это работало, вам нужно будет убедиться, что ваши шаблоны скопированы в правильное место.

С `embed` файлы включаются в вашу программу Go при ее сборке. Это означает, что после того, как вы собрали свою программу (что вы должны делать только один раз), файлы всегда доступны вам.

Удобство заключается в том, что вы можете встраивать не только отдельные файлы, но и файловые системы; и эта файловая система реализует [io/fs](https://pkg.go.dev/io/fs), что означает, что ваш код не должен заботиться о том, с каким типом файловой системы он работает.

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

## Далее: Сделаем шаблон «красивым»

На самом деле мы не хотим, чтобы наш шаблон был определен как одна строка. Мы хотим иметь возможность разнести его, чтобы его было легче читать и работать с ним, примерно так:

```handlebars
<h1>{{.Title}}</h1>

<p>{{.Description}}</p>

Tags: <ul>{{range .Tags}}<li>{{.}}</li>{{end}}</ul>
```

Но если мы сделаем это, наш тест провалится. Это потому, что наш тест ожидает возврата очень специфической строки.

Но на самом деле нам не важны пробелы. Поддержка этого теста превратится в кошмар, если нам придется постоянно кропотливо обновлять строку утверждения каждый раз, когда мы вносим незначительные изменения в разметку. По мере роста шаблона такие изменения становится сложнее управлять, а затраты на работу выйдут из-под контроля.

## Введение Approval-тестов

[Go Approval Tests](https://github.com/approvals/go-approval-tests)

> ApprovalTests позволяет легко тестировать более крупные объекты, строки и все остальное, что может быть сохранено в файл (изображения, звуки, CSV и т.д.)

Идея похожа на «золотые» файлы или снимок-тестирование. Вместо того чтобы неуклюже поддерживать строки внутри тестового файла, инструмент утверждения может сравнить вывод с созданным вами «утвержденным» файлом. Затем вы просто копируете новую версию, если ее одобряете. Перезапустите тест, и вы снова в зеленой зоне.

Добавьте зависимость `"github.com/approvals/go-approval-tests"` (с помощью команды `go get github.com/approvals/go-approval-tests`) в ваш проект и отредактируйте тест следующим образом:

```go
func TestRender(t *testing.T) {
	var (
		aPost = blogrenderer.Post{
			Title:       "hello world",
			Body:        "This is a post",
			Description: "This is a description",
			Tags:        []string{"go", "tdd"},
		}
	)

	t.Run("it converts a single post into HTML", func(t *testing.T) {
		buf := bytes.Buffer{}

		if err := blogrenderer.Render(&buf, aPost); err != nil {
			t.Fatal(err)
		}

		approvals.VerifyString(t, buf.String())
	})
}
```

При первом запуске он завершится неудачей, потому что мы еще ничего не утвердили.

```
=== RUN   TestRender
=== RUN   TestRender/it_converts_a_single_post_into_HTML
    renderer_test.go:29: Failed Approval: received does not match approved.
```

Он создаст два файла, которые выглядят следующим образом:

* `renderer_test.TestRender.it_converts_a_single_post_into_HTML.received.txt`
* `renderer_test.TestRender.it_converts_a_single_post_into_HTML.approved.txt`

Полученный файл содержит новую, неутвержденную версию вывода. Скопируйте его в пустой утвержденный файл и перезапустите тест.

Скопировав новую версию, вы «утвердили» изменение, и теперь тест проходит.

Чтобы увидеть рабочий процесс в действии, отредактируйте шаблон, как мы обсуждали, чтобы сделать его более читабельным (но семантически он тот же).

```handlebars
<h1>{{.Title}}</h1>

<p>{{.Description}}</p>

Tags: <ul>{{range .Tags}}<li>{{.}}</li>{{end}}</ul>
```

Перезапустите тест. Будет сгенерирован новый файл "received", потому что вывод нашего кода отличается от утвержденной версии. Посмотрите на них, и если вы довольны изменениями, просто скопируйте новую версию и перезапустите тест. Обязательно зафиксируйте утвержденные файлы в системе контроля версий.

Этот подход значительно упрощает управление изменениями в больших, некрасивых вещах, таких как HTML. Вы можете использовать инструмент сравнения для просмотра и управления различиями, и это делает ваш тестовый код чище.

![Используйте инструмент diff для управления изменениями](https://i.imgur.com/0MoNdva.png)

Это на самом деле довольно незначительное использование Approval-тестов, которые являются чрезвычайно полезным инструментом в вашем арсенале тестирования. [Эмили Баче](https://twitter.com/emilybache) имеет [интересное видео, где она использует Approval-тесты для добавления невероятно обширного набора тестов к сложной кодовой базе, которая не имеет тестов](https://www.youtube.com/watch?v=zyM2Ep28ED8). «Комбинаторное тестирование» определенно стоит изучить.

Теперь, когда мы внесли это изменение, мы по-прежнему получаем выгоду от хорошо протестированного кода, но тесты не будут слишком мешать, когда мы экспериментируем с разметкой.

### Мы всё ещё используем TDD?

Интересным побочным эффектом этого подхода является то, что он отводит нас от TDD. Конечно, вы *могли* бы вручную отредактировать утвержденные файлы до нужного состояния, запустить тесты, а затем исправить шаблоны так, чтобы они выдавали то, что вы определили.

Но это просто глупо! TDD — это метод работы, а именно проектирования; но это не означает, что мы должны догматически использовать его для **всего**.

Важно то, что мы поступили правильно и использовали TDD как **инструмент проектирования** для создания API нашего пакета. Для изменений в шаблонах наш процесс может быть таким:

* Внесите небольшое изменение в шаблон
* Запустите approval-тест
* Визуально проверьте вывод, чтобы убедиться, что он выглядит правильно
* Выполните утверждение
* Повторите

Мы по-прежнему не должны отказываться от ценности работы небольшими, достижимыми шагами. Старайтесь находить способы делать изменения небольшими и продолжайте перезапускать тесты, чтобы получать реальную обратную связь о том, что вы делаете.

Если мы начнем менять код *вокруг* шаблонов, то, конечно, это может потребовать возврата к нашему методу работы TDD.

## Расширьте разметку

Большинство веб-сайтов имеют более богатый HTML, чем у нас сейчас. Для начала, элемент `html`, а также `head`, возможно, и `nav`. Обычно есть также нижний колонтитул.

Если наш сайт будет иметь разные страницы, мы захотим определить эти вещи в одном месте, чтобы наш сайт выглядел единообразно. Шаблоны Go поддерживают определение разделов, которые мы можем затем импортировать в другие шаблоны.

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

```handlebars
{{template "top" .}}
<h1>{{.Title}}</h1>

<p>{{.Description}}</p>

Tags: <ul>{{range .Tags}}<li>{{.}}</li>{{end}}</ul>
{{template "bottom" .}}
```

Затем создайте `top.gohtml` со следующим содержанием:

```handlebars
{{define "top"}}
<!DOCTYPE html>
<html lang="en">
<head>
    <title>My amazing blog!</title>
    <meta charset="UTF-8"/>
    <meta name="description" content="Wow, like and subscribe, it really helps the channel guys" lang="en"/>
</head>
<body>
<nav role="navigation">
    <div>
        <h1>Budding Gopher's blog</h1>
        <ul>
            <li><a href="/">home</a></li>
            <li><a href="about">about</a></li>
            <li><a href="archive">archive</a></li>
        </ul>
    </div>
</nav>
<main>
{{end}}
```

И `bottom.gohtml`:

```handlebars
{{define "bottom"}}
</main>
<footer>
    <ul>
        <li><a href="https://twitter.com/quii">Twitter</a></li>
        <li><a href="https://github.com/quii">GitHub</a></li>
    </ul>
</footer>
</body>
</html>
{{end}}
```

(Очевидно, не стесняйтесь использовать любую разметку, которая вам нравится!)

Теперь нам нужно указать конкретный шаблон для запуска. В `blog renderer` измените команду `Execute` на `ExecuteTemplate`.

```go
if err := templ.ExecuteTemplate(w, "blog.gohtml", p); err != nil {
	return err
}
```

Перезапустите свой тест. Должен быть создан новый файл "received", и тест завершится ошибкой. Проверьте его, и если вы довольны, утвердите его, скопировав поверх старой версии. Перезапустите тест еще раз, и он должен пройти.

## Повод поиграть с бенчмарками

Прежде чем продолжить, давайте рассмотрим, что делает наш код.

```go
func Render(w io.Writer, p Post) error {
	templ, err := template.ParseFS(postTemplates, "templates/*.gohtml")
	if err != nil {
		return err
	}

	return templ.ExecuteTemplate(w, "blog.gohtml", p)
}
```

* Разбор шаблонов
* Использование шаблона для рендеринга записи в `io.Writer`

Хотя влияние на производительность повторного разбора шаблонов для каждой записи в большинстве случаев будет довольно незначительным, усилия, чтобы *не* делать этого, также довольно незначительны и должны немного привести код в порядок.

Чтобы увидеть влияние отказа от повторного разбора, мы можем использовать инструмент для бенчмаркинга, чтобы увидеть, насколько быстро работает наша функция.

```go
func BenchmarkRender(b *testing.B) {
	var (
		aPost = blogrenderer.Post{
			Title:       "hello world",
			Body:        "This is a post",
			Description: "This is a description",
			Tags:        []string{"go", "tdd"},
		}
	)

	for b.Loop() {
		blogrenderer.Render(io.Discard, aPost)
	}
}
```

На моем компьютере результаты такие:

```
BenchmarkRender-8 22124 53812 ns/op
```

Чтобы избежать повторного разбора шаблонов, мы создадим тип, который будет хранить разобранный шаблон, и у него будет метод для выполнения рендеринга.

```go
type PostRenderer struct {
	templ *template.Template
}

func NewPostRenderer() (*PostRenderer, error) {
	templ, err := template.ParseFS(postTemplates, "templates/*.gohtml")
	if err != nil {
		return nil, err
	}

	return &PostRenderer{templ: templ}, nil
}

func (r *PostRenderer) Render(w io.Writer, p Post) error {
	return r.templ.ExecuteTemplate(w, "blog.gohtml", p)
}
```

Это меняет интерфейс нашего кода, поэтому нам нужно будет обновить наш тест.

```go
func TestRender(t *testing.T) {
	var (
		aPost = blogrenderer.Post{
			Title:       "hello world",
			Body:        "This is a post",
			Description: "This is a description",
			Tags:        []string{"go", "tdd"},
		}
	)

	postRenderer, err := blogrenderer.NewPostRenderer()

	if err != nil {
		t.Fatal(err)
	}

	t.Run("it converts a single post into HTML", func(t *testing.T) {
		buf := bytes.Buffer{}

		if err := postRenderer.Render(&buf, aPost); err != nil {
			t.Fatal(err)
		}

		approvals.VerifyString(t, buf.String())
	})
}
```

И наш бенчмарк:

```go
func BenchmarkRender(b *testing.B) {
	var (
		aPost = blogrenderer.Post{
			Title:       "hello world",
			Body:        "This is a post",
			Description: "This is a description",
			Tags:        []string{"go", "tdd"},
		}
	)

	postRenderer, err := blogrenderer.NewPostRenderer()

	if err != nil {
		b.Fatal(err)
	}

	for b.Loop() {
		postRenderer.Render(io.Discard, aPost)
	}
}
```

Тест должен продолжать проходить. Как насчет нашего бенчмарка?

`BenchmarkRender-8 362124 3131 ns/op`. Старые нс/оп были `53812 ns/op`, так что это достойное улучшение! По мере добавления других методов рендеринга, например, страницы индекса, это должно упростить код, так как нам не нужно будет дублировать разбор шаблонов.

## Возвращаемся к реальной работе

Что касается рендеринга записей, то важная оставшаяся часть — это фактически рендеринг `Body`. Если вы помните, это должен быть markdown, написанный автором, поэтому его нужно будет преобразовать в HTML.

Мы оставим это в качестве упражнения для вас, читатель. Вы должны быть в состоянии найти библиотеку Go для этого. Используйте approval-тест для проверки того, что вы делаете.

### О тестировании сторонних библиотек

**Примечание**. Будьте осторожны, не слишком беспокойтесь о явном тестировании поведения сторонней библиотеки в модульных тестах.

Написание тестов для кода, который вы не контролируете, расточительно и увеличивает накладные расходы на обслуживание. Иногда вы можете захотеть использовать [внедрение зависимостей](/lgwt/osnovy-go/dependency-injection.md) для контроля зависимости и имитации ее поведения для теста.

Однако в этом случае я рассматриваю преобразование markdown в HTML как деталь реализации рендеринга, и наши approval-тесты должны дать нам достаточную уверенность.

### Рендеринг индекса

Следующая часть функциональности, которую мы собираемся реализовать, — это рендеринг индекса, перечисляющего записи в виде упорядоченного HTML-списка.

Мы расширяем наш API, поэтому снова надеваем шляпу TDD.

## Сначала напишите тест

На первый взгляд, страница индекса кажется простой, но написание теста все же побуждает нас сделать некоторые проектные решения.

```go
t.Run("it renders an index of posts", func(t *testing.T) {
	buf := bytes.Buffer{}
	posts := []blogrenderer.Post{{Title: "Hello World"}, {Title: "Hello World 2"}}

	if err := postRenderer.RenderIndex(&buf, posts); err != nil {
		t.Fatal(err)
	}

	got := buf.String()
	want := `<ol><li><a href="/post/hello-world">Hello World</a></li><li><a href="/post/hello-world-2">Hello World 2</a></li></ol>`

	if got != want {
		t.Errorf("got %q want %q", got, want)
	}
})
```

1. Мы используем поле `Title` структуры `Post` как часть пути URL, но мы на самом деле не хотим пробелов в URL, поэтому заменяем их дефисами.
2. Мы добавили метод `RenderIndex` к нашему `PostRenderer`, который снова принимает `io.Writer` и срез `Post`.

Если бы мы здесь придерживались подхода «тест после» с approval-тестами, мы бы не отвечали на эти вопросы в контролируемой среде. **Тесты дают нам пространство для размышлений**.

## Попробуйте запустить тест

```
./renderer_test.go:41:13: undefined: blogrenderer.RenderIndex
```

## Напишите минимальный объем кода для запуска теста и проверьте вывод падающего теста

```go
func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	return nil
}
```

Вышеупомянутое должно привести к следующей ошибке теста:

```
=== RUN   TestRender
=== RUN   TestRender/it_renders_an_index_of_posts
    renderer_test.go:49: got "" want "<ol><li><a href=\"/post/hello-world\">Hello World</a></li><li><a href=\"/post/hello-world-2\">Hello World 2</a></li></ol>"
--- FAIL: TestRender (0.00s)
    --- FAIL: TestRender/it_renders_an_index_of_posts (0.00s)
```

## Напишите достаточно кода, чтобы он прошел

Хотя это *кажется*, что должно быть легко, это немного неудобно. Я сделал это в несколько шагов:

```go
func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	indexTemplate := `<ol>{{range .}}<li><a href="/post/{{.Title}}">{{.Title}}</a></li>{{end}}</ol>`

	templ, err := template.New("index").Parse(indexTemplate)
	if err != nil {
		return err
	}

	if err := templ.Execute(w, posts); err != nil {
		return err
	}

	return nil
}
```

Сначала я не хотел заморачиваться с отдельными файлами шаблонов, я просто хотел, чтобы это заработало. Предварительный разбор шаблонов и их разделение я рассматриваю как рефакторинг, который можно сделать позже.

Это не проходит, но близко.

```
=== RUN   TestRender
=== RUN   TestRender/it_renders_an_index_of_posts
    renderer_test.go:49: got "<ol><li><a href=\"/post/Hello%20World\">Hello World</a></li><li><a href=\"/post/Hello%20World%202\">Hello World 2</a></li></ol>" want "<ol><li><a href=\"/post/hello-world\">Hello World</a></li><li><a href=\"/post/hello-world-2\">Hello World 2</a></li></ol>"
--- FAIL: TestRender (0.00s)
    --- FAIL: TestRender/it_renders_an_index_of_posts (0.00s)
```

Вы можете видеть, что код шаблонизации экранирует пробелы в атрибутах `href`. Нам нужен способ заменить пробелы дефисами. Мы не можем просто пройтись по срезу `[]Post` и заменить их в памяти, потому что мы всё ещё хотим, чтобы пробелы отображались пользователю в анкорах.

У нас есть несколько вариантов. Первый, который мы рассмотрим, — это передача функции в наш шаблон.

### Передача функций в шаблоны

```go
func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	indexTemplate := `<ol>{{range .}}<li><a href="/post/{{sanitiseTitle .Title}}">{{.Title}}</a></li>{{end}}</ol>`

	templ, err := template.New("index").Funcs(template.FuncMap{
		"sanitiseTitle": func(title string) string {
			return strings.ToLower(strings.Replace(title, " ", "-", -1))
		},
	}).Parse(indexTemplate)
	if err != nil {
		return err
	}

	if err := templ.Execute(w, posts); err != nil {
		return err
	}

	return nil
}
```

*Прежде чем вы разберете шаблон*, вы можете добавить `template.FuncMap` в свой шаблон, что позволит вам определять функции, которые могут быть вызваны внутри вашего шаблона. В этом случае мы создали функцию `sanitiseTitle`, которую затем вызываем внутри нашего шаблона с помощью `{{sanitiseTitle .Title}}`.

Это мощная функция, позволяющая передавать функции в шаблон, что даст вам возможность делать очень крутые вещи, но стоит ли? Возвращаясь к принципам Mustache и шаблонам без логики, почему они выступали за отсутствие логики? **Что плохого в логике в шаблонах?**

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

Представьте, что вы вводите функцию в шаблон, которая имеет несколько различных вариантов поведения и граничных случаев, **как вы будете ее тестировать**? С текущим дизайном ваш единственный способ тестирования этой логики — *рендеринг HTML и сравнение строк*. Это не простой или разумный способ тестирования логики, и определенно не то, что вам понадобится для *важной* бизнес-логики.

Несмотря на то, что метод approval-тестов снизил стоимость поддержки этих тестов, они всё ещё дороже в обслуживании, чем большинство модульных тестов, которые вы будете писать. Они всё ещё чувствительны к любым незначительным изменениям разметки, которые вы можете внести; просто мы упростили управление ими. Мы все равно должны стремиться к такой архитектуре нашего кода, чтобы нам не приходилось писать много тестов вокруг наших шаблонов, и стараться разделять ответственности, чтобы любая логика, которая не должна находиться в нашем коде рендеринга, была должным образом отделена.

Движки шаблонов, вдохновленные Mustache, дают вам полезное ограничение: не пытайтесь обходить его слишком часто; **не идите против течения**. Вместо этого примите идею [моделей представления](https://stackoverflow.com/a/11074506/3193), где вы конструируете специфические типы, содержащие данные, необходимые для рендеринга, таким образом, чтобы это было удобно для языка шаблонов.

Таким образом, любая важная бизнес-логика, которую вы используете для генерации этого набора данных, может быть протестирована отдельно, вдали от запутанного мира HTML и шаблонизации.

### Разделение ответственностей

Итак, что мы могли бы сделать вместо этого?

#### Добавить метод в `Post`, а затем вызвать его в шаблоне

Мы можем вызывать методы в нашем коде шаблонизации для типов, которые мы отправляем, поэтому мы могли бы добавить метод `SanitisedTitle` к `Post`. Это упростит шаблон, и мы могли бы легко тестировать эту логику отдельно, если захотим. Это, вероятно, самое легкое решение, хотя и не обязательно самое простое.

Недостаток этого подхода заключается в том, что это всё ещё логика *представления*. Это не интересно для остальной части системы, но теперь это становится частью API для основного доменного объекта. Такой подход со временем может привести к созданию [объектов-богов](https://en.wikipedia.org/wiki/God_object).

#### Создайте выделенный тип модели представления, такой как `PostViewModel`, с точно теми данными, которые нам нужны

Вместо того чтобы наш код рендеринга был связан с доменным объектом `Post`, он вместо этого принимает модель представления.

```go
type PostViewModel struct {
	Title, SanitisedTitle, Description, Body string
	Tags                     []string
}
```

Вызывающие стороны нашего кода должны будут сопоставлять `[]Post` с `[]PostView`, генерируя `SanitizedTitle`. Способом сохранить это чистым было бы наличие `func NewPostView(p Post) PostView`, который инкапсулировал бы сопоставление.

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

Оба варианта хороши; в этом случае я склоняюсь к первому. По мере развития системы вы должны остерегаться добавления все большего числа специальных методов просто для облегчения процесса рендеринга; выделенные модели представления становятся более полезными, когда преобразование между доменным объектом и представлением становится более сложным.

Итак, мы можем добавить наш метод к `Post`:

```go
func (p Post) SanitisedTitle() string {
	return strings.ToLower(strings.Replace(p.Title, " ", "-", -1))
}
```

И тогда мы можем вернуться к более простому миру в нашем коде рендеринга.

```go
func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	indexTemplate := `<ol>{{range .}}<li><a href="/post/{{.SanitisedTitle}}">{{.Title}}</a></li>{{end}}</ol>`

	templ, err := template.New("index").Parse(indexTemplate)
	if err != nil {
		return err
	}

	if err := templ.Execute(w, posts); err != nil {
		return err
	}

	return nil
}
```

## Рефакторинг

Наконец, тест должен проходить. Теперь мы можем переместить наш шаблон в файл (`templates/index.gohtml`) и загрузить его один раз при создании нашего рендерера.

```go
package blogrenderer

import (
	"embed"
	"html/template"
	"io"
)

var (
	//go:embed "templates/*"
	postTemplates embed.FS
)

type PostRenderer struct {
	templ *template.Template
}

func NewPostRenderer() (*PostRenderer, error) {
	templ, err := template.ParseFS(postTemplates, "templates/*.gohtml")
	if err != nil {
		return nil, err
	}

	return &PostRenderer{templ: templ}, nil
}

func (r *PostRenderer) Render(w io.Writer, p Post) error {
	return r.templ.ExecuteTemplate(w, "blog.gohtml", p)
}

func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	return r.templ.ExecuteTemplate(w, "index.gohtml", posts)
}
```

Разобрав более одного шаблона в `templ`, мы теперь должны вызывать `ExecuteTemplate` и указывать, *какой* шаблон мы хотим отобразить, как это уместно, но, надеюсь, вы согласитесь, что код, к которому мы пришли, выглядит великолепно.

Существует *небольшой* риск, если кто-то переименует один из файлов шаблона, это приведет к ошибке, но наши быстро выполняемые модульные тесты быстро это обнаружат.

Теперь, когда мы довольны дизайном API нашего пакета и реализовали базовое поведение с помощью TDD, давайте изменим наш тест для использования approval-тестов.

```go
	t.Run("it renders an index of posts", func(t *testing.T) {
		buf := bytes.Buffer{}
		posts := []blogrenderer.Post{{Title: "Hello World"}, {Title: "Hello World 2"}}

		if err := postRenderer.RenderIndex(&buf, posts); err != nil {
			t.Fatal(err)
		}

		approvals.VerifyString(t, buf.String())
	})
```

Не забудьте запустить тест, чтобы увидеть его падение, а затем утвердить изменение.

Наконец, мы можем добавить элементы оформления страницы на нашу индексную страницу:

```handlebars
{{template "top" .}}
<ol>{{range .}}<li><a href="/post/{{.SanitisedTitle}}">{{.Title}}</a></li>{{end}}</ol>
{{template "bottom" .}}
```

Перезапустите тест, утвердите изменение, и мы закончили с индексом!

## Рендеринг тела markdown

Я предложил вам попробовать это самостоятельно, вот подход, который я в итоге выбрал.

```go
package blogrenderer

import (
	"embed"
	"github.com/gomarkdown/markdown"
	"github.com/gomarkdown/markdown/parser"
	"html/template"
	"io"
)

var (
	//go:embed "templates/*"
	postTemplates embed.FS
)

type PostRenderer struct {
	templ *template.Template
}

func NewPostRenderer() (*PostRenderer, error) {
	templ, err := template.ParseFS(postTemplates, "templates/*.gohtml")
	if err != nil {
		return nil, err
	}

	return &PostRenderer{templ: templ}, nil
}

func (r *PostRenderer) Render(w io.Writer, p Post) error {
	return r.templ.ExecuteTemplate(w, "blog.gohtml", newPostVM(p))
}

func (r *PostRenderer) RenderIndex(w io.Writer, posts []Post) error {
	return r.templ.ExecuteTemplate(w, "index.gohtml", posts)
}

type postViewModel struct {
	Post
	HTMLBody template.HTML
}

func newPostVM(p Post) postViewModel {
	vm := postViewModel{Post: p}
	extensions := parser.CommonExtensions | parser.AutoHeadingIDs
	mdParser := parser.NewWithExtensions(extensions)
	vm.HTMLBody = template.HTML(markdown.ToHTML([]byte(p.Body), mdParser, nil))
	return vm
}
```

Я использовал отличную библиотеку [gomarkdown](https://github.com/gomarkdown/markdown), которая работала именно так, как я и надеялся.

Обратите внимание, что мы создаем новый `parser.Parser` при каждом вызове `newPostVM`, а не конструируем его один раз и сохраняем в `PostRenderer`. Обычно, если вы можете создать что-то один раз и повторно использовать, это стоит того — это экономит затраты на повторное создание. Но этот общий принцип здесь не работает: парсер gomarkdown сохраняет внутреннее состояние при построении дерева документа и небезопасен для повторного использования в нескольких вызовах `Parse` — вы получите панику (или, в старых версиях, гораздо менее полезную разыменование нулевого указателя) при втором использовании того же самого. Поскольку `PostRenderer` предназначен для рендеринга многих записей в течение своего жизненного цикла, новый парсер для каждого вызова является правильным способом его использования — и это дёшево в любом случае, так как его создание тривиально по сравнению с самой работой по парсингу.

Если вы пытались сделать это сами, возможно, вы обнаружили, что при рендеринге тела HTML экранировался. Это функция безопасности пакета `html/template` в Go, предназначенная для предотвращения вывода вредоносного стороннего HTML.

Чтобы обойти это, в типе, который вы отправляете для рендеринга, вам нужно обернуть ваш доверенный HTML в [template.HTML](https://pkg.go.dev/html/template#HTML).

> HTML инкапсулирует известный безопасный фрагмент HTML-документа. Он не должен использоваться для HTML от третьих сторон или HTML с незакрытыми тегами или комментариями. Выходы надежного HTML-санитизатора и шаблона, экранированного этим пакетом, подходят для использования с HTML.
>
> Использование этого типа представляет риск безопасности: инкапсулированное содержимое должно поступать из доверенного источника, так как оно будет включено в вывод шаблона без изменений.

Итак, я создал **неэкспортируемую** модель представления (`postViewModel`), потому что я всё ещё рассматривал это как внутреннюю деталь реализации рендеринга. Мне не нужно тестировать это отдельно, и я не хочу, чтобы это загрязняло мой API.

Я создаю ее при рендеринге, чтобы я мог разобрать `Body` в `HTMLBody`, а затем я использую это поле в шаблоне для рендеринга HTML.

## Подведение итогов

Если вы объедините свои знания из главы [Чтение файлов](/lgwt/osnovy-go/reading-files.md) и этой, вы сможете легко создать хорошо протестированный, простой генератор статических сайтов и запустить собственный блог. Найдите несколько уроков по CSS, и вы сможете сделать его красивым.

Этот подход выходит за рамки блогов. Получение данных из любого источника, будь то база данных, API или файловая система, преобразование их в HTML и возврат с сервера — это простая техника, используемая на протяжении многих десятилетий. Люди любят сетовать на сложность современной веб-разработки, но уверены ли вы, что не навязываете сложность сами себе?

Go прекрасно подходит для веб-разработки, особенно когда вы четко понимаете свои реальные требования к создаваемому веб-сайту. Генерация HTML на сервере часто является лучшим, более простым и производительным подходом, чем создание «веб-приложения» с такими технологиями, как React.

### Чему мы научились

* Как создавать и рендерить HTML-шаблоны.
* Как компоновать шаблоны вместе и [DRY](https://en.wikipedia.org/wiki/Don't_repeat_yourself)-ить связанную разметку, а также помогать нам сохранять единый внешний вид и поведение.
* Как передавать функции в шаблоны и почему об этом стоит подумать дважды.
* Как писать «Approval-тесты», которые помогают нам тестировать большой, некрасивый вывод таких вещей, как рендереры шаблонов.

### О шаблонах без логики

Как всегда, все это связано с **разделением ответственностей**. Важно учитывать, каковы обязанности различных частей нашей системы. Слишком часто люди сливают важную бизнес-логику в шаблоны, смешивая ответственности и делая системы трудными для понимания, поддержки и тестирования.

### Не только для HTML

Помните, что Go имеет `text/template` для генерации других видов данных из шаблона. Если вам нужно преобразовать данные в какой-либо структурированный вывод, методы, изложенные в этой главе, могут быть полезны.

### Ссылки и дополнительные материалы

* [John Calhoun's 'Learn Web Development with Go'](https://www.calhoun.io/intro-to-templates-p1-contextual-encoding/) содержит ряд отличных статей по шаблонизации.
* [Hotwire](https://hotwired.dev) — Вы можете использовать эти методы для создания веб-приложений Hotwire. Он был создан Basecamp, которые в основном используют Ruby on Rails, но поскольку это серверная технология, мы можем использовать ее с Go.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://eda-1.gitbook.io/lgwt/osnovy-go/html-templates.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
