Содержание урока · 13 разделов
- gin шаблоны: HTML вместо JSON
- LoadHTMLGlob и LoadHTMLFiles: как gin парсит шаблоны
- Передача данных: gin.H, структуры и экранирование
- Кастомные функции шаблонов: SetFuncMap
- Delims: когда двойные фигурные скобки заняты
- Раскладки и частичные шаблоны: define и template
- Multitemplate: две раскладки в одном приложении
- Что выбрать для ответа
- HTML или JSON: когда что рендерить
- Шесть правил шаблонов gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gin шаблоны: HTML вместо JSON#
Пока наш трекер отвечал только JSON: Envelope для API, NOT_FOUND для промахов, статика из прошлого урока обслуживала файлы, но не страницы. Пришло время gin шаблонов — серверного HTML, который собирается на лету из данных. Сценарий знакомый: страница со списком задач, где каждая строка приходит из хендлера, а разметка живёт в отдельном файле. Gin не пишет свой шаблонизатор — он оборачивает html/template из стандартной библиотеки, тот самый, с auto-escaping: любая строка из данных вставляется в разметку безопасно, теги и скрипты экранируются автоматически. Если вы уже работали с html/template — всё знакомо; если нет — разберём по ходу, для gin-специфики достаточно трёх действий. Первое: загрузить шаблоны один раз при старте — LoadHTMLGlob или LoadHTMLFiles. Второе: отдавать страницы через c.HTML(код, имя, данные) — собрат c.JSON, который вместо конверта рендерит файл. Третье: при необходимости зарегистрировать свои функции шаблонов через SetFuncMap — даты, плюрализация, любые преобразования прямо в разметке. Архитектурно это выглядит так: хендлеры /api/v1/todos продолжают отдавать JSON из прошлого урока, а новые маршруты — /, /tasks, /tasks/:id — рендерят страницы. Один движок, два формата: JSON для машин, HTML для людей — и вся связка html gin держится на трёх действиях из начала урока. В этом уроке мы соберём страницу трекера на gin.H и структуре, разберём экранирование на живом примере, подключим кастомные функции и раскладки, а на выходе получим приложение, у которого есть и API, и интерфейс.
Первая страница: LoadHTMLGlob и c.HTML
GOr := gin.Default()
r.LoadHTMLGlob("templates/*")
r.GET("/", func(c *gin.Context) {
tasks := []Todo{
{Title: "Купить хлеб", Done: false},
{Title: "Полить цветы", Done: true},
}
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "Задачи на сегодня",
"tasks": tasks,
})
})
if err := r.Run(":8080"); err != nil {
panic(err)
}LoadHTMLGlob("templates/*") разбирает все файлы каталога templates один раз при старте. c.HTML берёт три аргумента: статус, имя шаблона и данные. Имя — это базовое имя файла: index.html. Точка в разметке — это gin.H, переданный вторым: {{ .title }} читает ключ title. Папка templates — относительно рабочей директории процесса, как и статика из прошлого урока.
LoadHTMLGlob и LoadHTMLFiles: как gin парсит шаблоны#
Заглянем в gin.go — там два метода, и они ближе к стандартной библиотеке, чем кажется. LoadHTMLGlob(pattern) делает ровно одно: template.Must(template.New("").Delims(...).Funcs(engine.FuncMap).ParseGlob(pattern)) — это чистый html/template: ParseGlob находит файлы по маске через filepath.Glob и парсит каждый, а template.Must паникует, если файл не читается или разметка битая. То есть опечатка в шаблоне роняет приложение на старте, до первого запроса — и это хорошо: битая разметка должна ловиться при запуске, а не в проде у пользователя. Имена шаблонов — базовые имена файлов: templates/index.html становится index.html, и c.HTML ищет именно по этому имени. LoadHTMLFiles делает то же самое по явному списку: r.LoadHTMLFiles("templates/index.html", "templates/form.html") — удобно, когда каталог замусорен частями, которые не должны попадать в набор. Раскладки и подпапки требуют блока define: маска templates/ не заходит в подкаталоги, а маска templates// (две звёздочки в filepath.Glob — это просто один уровень) собирает файлы из подпапок, но тогда каждое имя объявляют вручную: {{ define "posts/index.html" }} в начале файла и соответствующее имя в c.HTML — так делают в официальных примерах gin. Теперь главное, что почти все упускают: результат парсинга зависит от gin.Mode(). В release-режиме отрендеренный набор кладётся в HTMLProduction — один раз спарсенный, навсегда закэшированный. А в debug-режиме — режиме по умолчанию — gin ставит HTMLDebug, который перечитывает и перепарсивает шаблоны на каждый запрос: правка файла видна по F5, без перезапуска. Это удобно в разработке и незаметно дорого в бою: перепарс на каждый запрос плюс паники template.Must уже внутри обслуживания. Отсюда правило: gin.SetMode(gin.ReleaseMode) перед LoadHTMLGlob в проде — и шаблоны спарсятся ровно один раз.
Проверка: правка шаблона после старта
Приложение собрано с gin.SetMode(gin.ReleaseMode), LoadHTMLGlob отработал, сервер крутится. Вы правили templates/index.html, не перезапуская процесс. Что увидит следующий запрос?
Передача данных: gin.H, структуры и экранирование#
Данные в шаблон попадают любым значением — c.HTML принимает any. Для быстрого прототипа используют gin.H — это псевдоним map[string]any, тот же тип, что и в c.JSON: gin.H{"title": "Задачи", "tasks": tasks} — и точка в разметке ходит по ключам карты. Для боевого кода лучше структуры: поля типизированы, поля подсказывают в IDE, а json-теги, которые мы расставляли в уроке про привязку, работают и здесь — только в шаблонах тегом по умолчанию считается form, а имена полей html/template берёт по-другому. Ключевое правило: шаблон видит только экспортированные поля. Структура Todo с полем title (со строчной) отдаст пустоту — интерполяция {{ .title }} или {{ .Title }} вернёт нулевое значение; с полем Title — всё отрисуется. Методы тоже работают, если они экспортированы: {{ .DueFmt }} вызовет метод с таким именем — это законный способ класть вычисления рядом с данными вместо размножения функций. А вот экранирование — главная причина, почему html/template, а не text/template. Контекстный анализатор понимает, где именно вставляется значение: в HTML-тексте экранирует <b>, в атрибуте — кавычки, в JavaScript-блоке — спецсимволы строк, в URL — пробелы и кавычки. Практический тест: передайте в title строку с <script>alert(1)</script> — на странице отобразится текстом ровно то, что ввёл пользователь, а в HTML-исходнике будут экранированные сущности. XSS-векторы через данные гибнут на ровном месте. Побег есть всегда — кроме одного: значение типа template.HTML html/template вставит как есть, без экранирования, потому что вы явно заявили «это уже HTML». Такой тип — осознанный костыль для заранее sanitized-контента; если он добирается до шаблона из пользовательского ввода, вы только что собрали XSS своими руками, поэтому держите template.HTML подальше от форм.
Что окажется в HTML при вводе тега
Прочитайте код и выберите, что он выведет:
r := gin.Default()
r.LoadHTMLGlob("templates/*")
r.GET("/echo", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"title": "<b>Срочно</b> и <script>alert(1)</script>",
})
})
// <h1>{{ .title }}</h1>
// curl localhost:8080/echoКастомные функции шаблонов: SetFuncMap#
Рано или поздно разметке нужны вычисления: дата в человеческом формате, правильная форма слова «задача/задачи/задач», цена с копейками, урезанный текст. В html/template это решается функциями: FuncMap — карта имя-функция, и шаблон вызывает их как {{ fmtDate .Due }}. В gin за это отвечает r.SetFuncMap(template.FuncMap{...}) — метод движка, который кладёт карту в engine.FuncMap, и дальше её подхватывают оба загрузчика. Обратите внимание на порядок — это самая частая ошибка темы: SetFuncMap должен быть вызван до LoadHTMLGlob и LoadHTMLFiles. Причина в механике парсинга: при разборе шаблона имена функций фиксируются, и если функции ещё не зарегистрированы, ParseGlob упадёт с «function "fmtDate" not defined» — template.Must превратит это в панику на старте. Зарегистрируете после — та же паника, только в запутанной формулировке; держите SetFuncMap первой строкой после gin.Default() и не отделяйте его от загрузки шаблонов. Что класть в карту: функции чистые, быстрые и про представление — форматирование дат через t.Format, плюрализация по правилам русского языка (n%10, группы исключений), обрезка строк с многоточием, экранирование уже не нужно — оно контекстное. Что класть не стоит: обращения к базе и любые I/O — шаблон должен собирать строку из готовых данных, а не ходить за ними; готовьте значения в хендлере или сервисном слое. Функции в карте типизированы по-обычному: fmtDate func(time.Time) string, plural func(int, string) string — компилятор проверит сигнатуры, а шаблон просто вызовет по имени.
FuncMap: дата и плюрализация
GOfunc fmtDate(t time.Time) string {
return t.Format("02.01.2006")
}
func plural(n int, forms string) string {
// forms: "задача,задачи,задач" — формы через запятую.
parts := strings.Split(forms, ",")
idx := 2
if n%10 == 1 && n%100 != 11 {
idx = 0
} else if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
idx = 1
}
return fmt.Sprintf("%d %s", n, parts[idx])
}
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.SetFuncMap(template.FuncMap{
"fmtDate": fmtDate,
"plural": plural,
})
r.LoadHTMLGlob("templates/*") // функции уже зарегистрированы
// ...
}SetFuncMap перед LoadHTMLGlob — иначе ParseGlob не найдёт fmtDate и plural и упадёт в template.Must. В разметке: {{ fmtDate .Due }} и {{ plural .OpenCount "задача,задачи,задач" }}. Строковый аргумент в кавычках — литерал шаблона. Функции чистые: никаких обращений к базе внутри разметки.
Проверка: порядок инициализации
В какой момент нужно вызвать r.SetFuncMap, чтобы шаблоны видели ваши функции?
Delims: когда двойные фигурные скобки заняты#
Штатные разделители {{ }} — источник конфликтов с фронтенд-шаблонизаторами. Vue, Alpine.js и целый зоопарк мини-фреймворков используют те же двойные фигурные: страница собирается на сервере gin-ом, а затем «оживает» на клиенте, и обе машины претендуют на одни скобки. Реакция предсказуема: Vue выдернет из {{ .title }} значение, получит пустую строку и швырнёт warning в консоль, а ваша серверная интерполяция окажется съеденной. Решение в gin — r.Delims("[[", "]]"): метод движка меняет разделители для всех загружаемых после него шаблонов, и в разметке пишете [[ .title ]]. Обратите внимание на два требования. Первое: Delims тоже до загрузки — парсер расставляет границы по разделителям, менять их после ParseGlob поздно. Второе: возвращаемое значение — Engine, метод цепочный: r.Delims("[[", "]]").LoadHTMLGlob("templates/") — хотя читается такая цепочка тяжеловато, лучше отдельной строкой. Бэкенд и фронтенд расходятся по зонам: серверные интерполяции в [[ ]], клиентские {{ }} остаются Vue — конфликт снят без escaping-танцев. Из той же серии — точечная гигиена: если скобки нужны в тексте как литералы (фрагменты кода в статье, документация по шаблонам), вынесите их в данные и вставляйте значением — или используйте пользовательские функции, возвращающие нужный текст. И помните про predict ниже: смена Delims делает старый синтаксис просто текстом, и никакой ошибки парсинга не случится — файл с {{ .title }} при Delims("[[", "]]") спарсится тихо, а на странице отобразится фигурная запись дословно. Проверяйте визуально после миграции, а не по логам.
Старые скобки после смены разделителей
Прочитайте код и выберите, что он выведет:
r := gin.Default()
r.Delims("[[", "]]")
r.LoadHTMLGlob("templates/*")
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{"title": "План"})
})
// index.html: <h1>{{ .title }}</h1>
// curl localhost:8080/Раскладки и частичные шаблоны: define и template#
Одна страница — редкость; хочется общего каркаса: head с stylesheet, шапка с навигацией, подвал. В html/template это делается блоками define и template, и gin здесь не добавляет ничего — это чистая стандартная библиотека. Блок {{ define "head" }}...{{ end }} объявляет именованный фрагмент внутри файла, а {{ template "head" . }} вставляет его в точке вызова, передавая те же данные — точка в конце не опечатка, это передача контекста. Классическая раскладка: layout.html держит каркас с двумя template-вставками — head и footer, — а page-файлы объявляют эти блоки своим содержимым. Порядок загрузки в gin: LoadHTMLGlob("templates/*") соберёт все файлы в один набор, и имена блоков станут общими для набора — template ищет блок по имени во всём наборе, а не только в своём файле. Отсюда следствие, которое стоит проговорить: имена define уникальны в рамках набора — два файла с {{ define "head" }} перезапишут друг друга без предупреждений; добавьте зону в имя: {{ define "site/head" }}, {{ define "admin/head" }}. Для вложенных страниц из подпапок действует правило из прошлого блока: {{ define "posts/index.html" }} и c.HTML с тем же именем — маска с двумя звёздочками собирает подпапки, define даёт им предсказуемые имена. И тонкость передачи данных: {{ template "head" . }} передаёт весь контекст, а {{ template "head" .Title }} — только значение; внутри блока точка уже будет строкой, а не картой. Это удобно для узких фрагментов, но ловит на рефакторинге: поменяли контекст — блоки молча отрисуют пустоту. Держите передачу контекста явной и одинаковой во всех раскладках проекта.
Multitemplate: две раскладки в одном приложении#
Загрузчики gin дают один набор шаблонов на приложение — и этого хватает, пока все страницы живут в общем каркасе. Как только появляются принципиально разные раскладки — админка с боковой панелью и публичные страницы с меню, письмо-шаблон, лендинг, — наборный подход трещит: имена define расползаются, общий head переопределяется, layout-логика превращается в условия. Официальное решение — HTMLRender интерфейс: у движка есть поле HTMLRender с методом Instance(name string, data any) Render, и c.HTML просто делегирует ему. Подмените HTMLRender — и c.HTML начнёт отдавать то, что вы решите. Пакет gin-contrib/multitemplate делает подмену за вас: multitemplate.NewRenderer() создаёт рендерер, методы AddFromFiles и AddFromGlob регистрируют именованные наборы — каждый со своим каркасом и своими файлами, — а присвоение r.HTMLRender = renderer включает его. Дальше c.HTML(http.StatusOK, "user", data) рендерит набор user с раскладкой пользователя, c.HTML(..., "admin", data) — набор admin с панелью администратора; JSON-API это не трогает. Собирайте рендерер фабричной функцией — createRender() — и передавайте движку до старта: HTMLRender назначается один раз, как и FuncMap. Плата очевидна и мала: каждый набор парсится отдельно, дубли общих фрагментов можно разрулить общими define-блоками с зональными именами. Альтернатива без внешнего пакета — свой тип с тем же методом Instance поверх html/template: интерфейс крошечный, а понимание, что за ним стоит, у вас теперь есть. В трекере multitemplate закрывает ровно одну развилку — публичная страница и будущая админка — но решает её чисто, без условий в разметке.
Две раскладки через gin-contrib/multitemplate
GOimport (
"github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/gin"
)
func createRender() multitemplate.Renderer {
r := multitemplate.NewRenderer()
// Каркас + страница для каждого набора.
r.AddFromFiles("user", "templates/layout.html", "templates/user.html")
r.AddFromFiles("admin", "templates/layout.html", "templates/admin.html")
r.AddFromGlob("letter", "templates/letters/*.html")
return r
}
func main() {
r := gin.Default()
r.HTMLRender = createRender()
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "user", gin.H{"title": "Мои задачи"})
})
r.GET("/admin", func(c *gin.Context) {
c.HTML(http.StatusOK, "admin", gin.H{"title": "Админка"})
})
}Имена наборов заменяют имена файлов: c.HTML ищет не index.html, а user или admin. AddFromFiles парсит файлы пачкой — каркас и страница; AddFromGlob берёт набор из маски. Присвоение r.HTMLRender до первого запроса: подмена на лету с живым трафиком — гонка. Пакет ставится классически: go get github.com/gin-contrib/multitemplate.
Проверка: Content-Type страницы
Хендлер отдал c.HTML(http.StatusOK, "index.html", data). Какой Content-Type придёт в ответе?
Что выбрать для ответа#
| Инструмент | Content-Type | Данные | Когда брать |
|---|---|---|---|
| c.HTML | text/html; charset=utf-8 | шаблон + gin.H/структура | страницы, отчёты, HTML-письма |
| c.JSON | application/json; charset=utf-8 | конверт Envelope | API для фронтенда и клиентов |
| c.Data(code, type, bytes) | тот, что передали | готовые байты | SVG-иконки, CSV, свои форматы |
| Static из урока 11 | по расширению файла | файлы на диске/embed | неизменяемые ассеты: CSS, JS, картинки |
| NoRoute + c.File | по расширению | файл | SPA-фолбэк на промахи |
HTML или JSON: когда что рендерить#
Теперь у трекера два лица, и стоит зафиксировать критерий выбора. JSON — когда потребителя нельзя предсказать: мобильное приложение, чужой скрипт, публичный API; контракт из урока про ответы — Envelope с полем Error — остаётся сетевым стандартом проекта. HTML — когда потребитель человек и SEO важнее интерактивности: публичные страницы, документация, панель со списком задач — серверный рендер даёт готовую разметку без ожидания и запросов данных с клиента, а поисковик индексирует её без выкручиваний. Смешивать — нормально и типично: страницы рендерятся на /, /tasks и /tasks/:id через c.HTML, а под группой /api/v1 живут JSON-эндпоинты того же трекера — группы маршрутов из прошлого урока как раз разводят эти зоны, и middleware на группе API не мешает страницам. Опасная зона одна: не начинайте выдавать HTML за JSON-клиента. У API нет шаблонов: если фронтенд — SPA на webpack/vite, ваши c.HTML-страницы никому не нужны, а полноценный HTML-слой удвоит работу. Признак, что серверный рендер вам показан: ссылки должны открываться с нуля, поиск должен видеть контент, интерактив минимален. Признак против: страница — это приложение с состоянием, список фильтруется без перезагрузки, офлайн-режим. В учебном трекере скучно ни то ни другое, поэтому берём обе стороны: страница списка — HTML, операции — JSON. Так проект честно упражняет оба режима ответа, и на собеседовании вы не будете делать вид, что «HTML для веба не нужен».
Шесть правил шаблонов gin#
- Порядок инициализации: gin.SetMode, SetFuncMap, Delims, LoadHTMLGlob/Files, маршруты, Run — всё, что влияет на парсинг, до загрузки шаблонов.
- Имя шаблона — базовое имя файла; для подпапок — явный {{ define "posts/index.html" }} и то же имя в c.HTML.
- Шаблон видит только экспортированные поля структуры; методы тоже доступны, если они экспортированы.
- Экранирование контекстное и обязательное; template.HTML — сознательный обход для проверенного контента, не для строк из форм.
- В debug-режиме шаблоны перечитываются на каждый запрос (HTMLDebug), в release парсятся один раз (HTMLProduction) — для прода ставьте gin.SetMode(gin.ReleaseMode).
- Разные раскладки — gin-contrib/multitemplate или свой рендерер поверх HTMLRender: именованные наборы вместо условий в разметке.
Соберите инициализацию страниц
Расставьте вызовы по порядку: функции шаблонов, разбор каталога и отдача страницы.
Соберите путь запроса страницы
GET / при release-режиме и LoadHTMLGlob: разложите события от curl до строки лога.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: шаблоны HTML
Соберите buildPages и снимите страницу curl-ом: curl -i localhost:8080/ — заголовок Content-Type: text/html; charset=utf-8, в теле интерполированный title и список задач; подставьте в данные <script>alert(1)</script> и убедитесь, что в исходнике приходят сущности, а браузер ничего не исполняет. Сломайте разметку шаблона и перезапустите сервер — паника на старте в release поймает опечатку раньше пользователя. Сравните два режима: закомментируйте gin.SetMode(gin.ReleaseMode) и понаблюдайте, как правка файла видна по F5 без рестарта. В браузерной песочнице сайта сетевой код не исполняется, поэтому runner-ов в курсе нет — интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь курс gin. Дальше — 13-fajly: загрузка файлов, FormFile и SaveUploadedFile, ограничения размера — форма трекера обзаведётся вложениями.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- LoadHTMLGlob/Files собирают набор html/template один раз при старте; имена — базовые имена файлов, битая разметка паникует на старте через template.Must.
- Режим решает: в debug шаблоны перечитываются на каждый запрос (HTMLDebug), в release закэшированы в HTMLProduction — прод стартует с gin.SetMode(gin.ReleaseMode).
- Данные — gin.H или структура: шаблон видит только экспортированные поля и методы; экранирование контекстное, template.HTML — сознательный обход.
- SetFuncMap до загрузки шаблонов: имена функций фиксируются при парсинге; функции — про представление, без I/O.
- Delims меняет скобки до парсинга — способ разойтись с Vue и Alpine; старый синтаксис после смены — просто текст.
- Раскладки — define/template с уникальными именами; разные каркасы — gin-contrib/multitemplate с r.HTMLRender = createRender() и именованными наборами.
Домашнее задание
Соберите HTML-этаж трекера: LoadHTMLGlob("templates/*"), SetFuncMap с fmtDate и plural до загрузки, страницу / со списком задач через c.HTML и gin.H — карточка задачи показывает Done флагом {{ if }}, дату через fmtDate, счётчик через plural. Рядом оставьте /api/v1/todos в JSON — группы из прошлого урока разводят зоны. Подставьте в данные строку с тегами и убедитесь в экранировании. Затем добавьте multitemplate: каркас layout.html, наборы user и admin, r.HTMLRender = createRender() и /admin с другой шапкой. Прогоните оба режима gin и зафиксируйте разницу в правках шаблона без рестарта.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему шаблоны не видят поля структуры со строчной буквы?
html/template работает через рефлексию и видит только экспортированные поля — верхний регистр первой буквы. Поле title для шаблона пусто, поле Title отрисуется. Методы — то же правило: интерполяция {{ .DueFmt }} вызовет экспортированный метод без аргументов.
Как грузить шаблоны из подпапок?
Маска LoadHTMLGlob("templates/**/*") в filepath.Glob означает один уровень вложенности и соберёт файлы подпапок; имена объявляйте явно через {{ define "posts/index.html" }} и указывайте то же имя в c.HTML. Вложеннее двух уровней — несколько вызовов LoadHTMLGlob с разными масками или multitemplate.
Можно ли перезагружать шаблоны без рестарта в проде?
Штатно — только оставаясь в debug-режиме, где HTMLDebug перепарсивает на каждый запрос; в проде это не рекомендуется: лишняя работа и паники в момент обслуживания. Правильный путь — релиз с новым бинарником; фанатам hot-reload — свой рендерер поверх HTMLRender с проверкой mtime и мьютексом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию