Server Side Rendering на Go
- воскресенье, 13 октября 2024 г. в 00:00:22
Жизнь – это вечная спираль, где всё идёт по кругу, но с каждым витком становится лучше. Ещё 20 лет назад я писал веб-приложения на Perl + Template Toolkit 2, генерируя HTML на стороне сервера. Время шло, и веб-разработка разделилась на две половины: фронтенд и бэкенд, а между ними API. Со временем я переключился с Perl на Go для бэкенда и AngularJS, а потом и Vue для фронтенда. В таком стеке я создал несколько проектов, включая HighLoad.Fun. Писать API и генерировать клиентскую библиотеку на TypeScript было удобно, а Vue-приложение деплоилось как SPA. Всё вроде бы шло хорошо... до тех пор, пока не пришла необходимость внедрить SSR для SEO. Тут начались проблемы: нужно было поднять NodeJS сервер для выполнения SSR, который должен ходить на Go сервер за данными, думать о том, где в данный момент выполняется код, на сервере или в браузере и писать и писать бессмысленный код перекладывающий данные.
Тогда я встал перед выбором: либо отказаться от Go на бэкенде, либо отказаться от Vue на фронтенде. Для меня выбор был очевиден: я остался с Go.
Генерация HTML на Go, в общем-то, не проблема: можно использовать готовые шаблонизаторы, вручную писать контроллеры и настроить WebPack для сборки статики. Но всё это долго и неудобно. А главное – я люблю писать программы, но ненавижу писать код. И тогда я задался целью: создать инструмент, который облегчит мне жизнь и будет автоматически решать большую часть задач за меня.
Мне нужен был генератор, который бы:
Превращал Vue-подобные шаблоны в Go-код с типизированными переменными, позволяя ловить ошибки на этапе компиляции.
Автоматически генерировал DataProvider интерфейсы для получения данных и, желательно, их базовую имплементацию.
Собирал и подключал только нужные JS и CSS файлы из лежащих рядом с шаблонами TypeScript и SCSS файлов.
Поддерживал переменные, выражения, условия и циклы в шаблонах, как во Vue.
Объединял шаблоны из подпапок по принципу Vue-тега <router-view/>
.
Автоматически маршрутизировал страницы, поддерживая динамические параметры.
И главное – всё это должно работать в автоматическом режиме: изменения в исходном коде автоматически пересобираются и перезапускаются без лишних усилий.
После ряда экспериментов и нескольких ночей мне это кажется удалось. Под катом – подробный туториал, как разрабатывать быстрые и удобные сайты с помощью GoSSR.
Для работы генератора необходимы Go версии 1.22 и выше и NPM, оба должны быть доступны в PATH
.
Когда окружение настроено, надо установить GoSSR генератор:
go install github.com/sergei-svistunov/go-ssr@latest
После того как установка успешно завершилась, необходимо в пустой папке инициализировать GoSSR проект:
go-ssr -init -pkg-name ssrdemo
где ssrdemo
- имя Go пакета, используемого для приложения, можно выбрать любое валидное. Генератор создаст основные файлы и папки, скачает Go'шные и фронтендные зависимости. В итоге получится что-то типа такого:
├── go.mod
├── gossr.yaml
├── go.sum
├── internal
│ └── web
│ ├── dataprovider.go
│ ├── node_modules
│ │ └── ...
│ ├── package.json
│ ├── package-lock.json
│ ├── pages
│ │ ├── dataprovider.go
│ │ ├── index.html
│ │ ├── index.ts
│ │ ├── ssrhandler_gen.go
│ │ ├── ssrroute_gen.go
│ │ └── styles.scss
│ ├── static
│ │ ├── css
│ │ │ └── main.49b4b5dc8e2f7fc5c396.css
│ │ └── js
│ │ └── main.49b4b5dc8e2f7fc5c396.js
│ ├── tsconfig.json
│ ├── web.go
│ ├── webpack-assets.json
│ └── webpack.config.js
└── main.go
Основные файлы и папки:
main.go: web приложение
gossr.yaml: конфиг для GoSSR
internal/web/: папка, в которой происходит вся магия:
web.go: содержит http.Handler
, который объединяет статические и динамические пути
dataprovider.go: объединяет все дочерние dataprovider'ы для использования в SSRHandler
package.json: все фронендные зависимости
webpack.config.js: здесь можно донастроить сборку фронтенда
pages/: корневая папка для страниц, все пути строятся от неё
dataprovider.go: место, где подготавливаются данные для шаблона
ssrroute_gen.go: реализация шаблона, в него превращается index.html
ssrhandler_gen.go: хендлер, объединяющий все дочерние хендлеры, содержится только в папке pages, в подпапках его не будет.
index.html: шаблон
index.ts: скрипты для страницы, необязательный файл
styles.scss: стили для страницы, необязательный файл
static/: сюда складывается собранная статика
На самом деле, это уже готовый одностраничный проект, который можно запустить выполнив
# go run .
На экран выведется информация, что сервер доступен по адресу http://localhost:8080/. Если открыть его в браузере, то это будет выглядеть так:
Вообще, запускать генератор и сборку проекта руками каждый раз не надо, достаточно выполнить
go-ssr -watch
и всё будет происходить автоматически, как только изменятся исходники. Причём пересобираться будут только нужные части.
Предположим, что стоит задача выводить на экран не просто "Hello world", а "Hello <имя из параметра name>", для этого в файле internal/web/pages/index.html
нужно объявить переменную (Go язык со статической типизацией, поэтому нужно знать тип) и вставить её в нужное место в шаблоне.
Переменная объявляется с помощью тега `<ssr:var/>`, с обязательными атрибутами name
и type
. А для того, чтобы использовать её в шаблоне, необходимо заключить её в двойные скобки {{ varName }}
. В итоге файл index.html
должен выглядеть так (см. строки 10 и 11):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoSSR</title>
<ssr:assets/>
</head>
<body>
<ssr:var name="userName" type="string"/>
<h1>Hello {{ userName }} </h1>
</body>
</html>
После сохранения файла, GoSSR автоматически перегенерирует шаблон, в котором появится новая переменная.
Переменную в шаблоне объявили и заиспользовали, теперь в неё надо положить данные, для этого в файле internal/web/pages/dataprovider.go
нужно изменить метод GetRouteRootData
, один из его аргументов указатель на структуру, поля которой являются переменными шаблона. В итоге должно получиться что-то типа такого:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// Берём данные из параметра name
data.UserName = r.FormValue("name")
return nil
}
Сохраняем файл, GoSSR пересоберёт и перезапустит приложение, и можно обновлять страницу в браузере, слово world
ожидаемо пропало, но если добавить параметр name=User
в запрос, то всё будет как задумано:
Если передать что-то потенциально вредоносное в параметр, то ничего страшного не случится, вывод экранируется:
Если очень надо вставить неэкранированный HTML, то вместо {{ }}
нужно использовать {{$ expr }}
, но быть предельно аккуратным. Если предыдущий пример изменить на использование {{$ }}
, то результат будет плачевный:
Если не передавать параметр с именем, то чтобы избежать оборванной фразы, можно либо в DataProvider'е добавить дефолтное значение, либо можно заиспользовать мощь выражений шаблонизатора, а ещё там есть тернарный if:
<h1>Hello {{ userName == "" ? 'Anonymous' : userName }} </h1>
Для объявления строк в шаблоне можно использовать как одинарные, так и двойные кавычки.
Для условного отображения HTML тегов можно использовать атрибуты:
ssr:if="УСЛОВИЕ"
ssr:else-if="УСЛОВИЕ"
ssr:else
Для примера можно добавить ещё одну переменную получаемую из параметров, пусть будет age
. Для неё сделаем вывод возрастной группы:
<ssr:var name="age" type="uint8"/>
<p ssr:if="age < 18"><18</p>
<p ssr:else-if="age <= 30">18-30</p>
<p ssr:else-if="age <= 60">31-60</p>
<p ssr:else>61+</p>
В DataProvider'е нужно добавить получение и парсинг числа:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// Берём данные из параметра name
data.UserName = r.FormValue("name")
// Получаем возраст
if ageParam := r.FormValue("age"); ageParam != "" {
age, err := strconv.ParseUint(ageParam, 10, 8)
if err != nil {
return err
}
data.Age = uint8(age)
}
return nil
}
Сохраняем файлы и обновляем страницу:
Аналогично условиям, в HTML тегах можно использовать циклы. Для этого существует 2 варианта:
ssr:for="value in array"
ssr:for="index, value in array"
В качестве примера добавим на текущую страницу список из строк:
<ssr:var name="list" type="[]string"/>
<ul>
<li ssr:for="value in list">{{ value }}</li>
</ul>
В DataProvider'е соответственно нужно переменной присвоить значение:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// ...
// Данные для цикла
data.List = []string{"value1", "value2", "value3"}
return nil
}
В результате получим
Сайт - это не одна страница, а целая иерархия. GoSSR позволяет довольно легко ей управлять. Шаблон лежащий в папке pages
является обвязкой, в нём задаётся внешний вид сайта, шапка, меню, ..., а в подпапках создаются разделы сайта. Для примера создадим страницу /home
, для этого в папке pages
нужно создать подпапку с таким именем и в ней создать файл index.html
. Генератор увидит новый файл и создаст для него DataProvider в файле dataprovider.go
и ssrroute_gen.go
с реализацией шаблона на Go.
В файл index.html
предлагаю положить следующий контент:
<h1>Home page</h1>
Теперь нужно немного модифицировать шаблон лежащий в папке pages
, а именно добавить тег <ssr:content>
, а чтобы даже при заходе по адресу http://localhost:8080/ выполнялся подхендлер /home
, нужно добавить атрибут `default="home">, в итоге должно получиться так:
<!doctype html>
<html lang="en">
<!-- ... -->
<body>
<ssr:content default="home"/>
<!-- ... -->
</body>
</html>
Если открыть страницу по адресу http://localhost:8080/, то произойдёт редирект на http://localhost:8080/home и содержимое шаблона pages/home/index.html
будет добавлено в содержимое родительского шаблона pages/index.html
:
Указать дефолтный хендлер можно не только через шаблон, но и через DataProvider с помощью метода вида GetRoute*DefaultSubRoute
, где *
- имя хендлера.
Аналогично странице /home
можно сделать страницу /contacts
, просто добавив ещё одну папку в которой есть index.html
:
<h1>Contacts page</h1>
Теперь доступен и URL http://localhost:8080/contacts:
Вложенность путей и шаблонов может быть любой и каждый шаблон может содержать свой набор переменных.
GoSSR поддерживает переменные внутри пути, например можно создать страницы, которые будут содержать в пути логин пользователя, т.е. http://localhost/login123/info, где login123
динамическая строка. Чтобы это сделать, надо создать папку начинающуюся и заканчивающуюся на _
, например _userId_
. Теперь все несуществующие подпути на этом уровне будут попадать в этот хендлер, а значение можно получить в DataProvider'е с помощью метода r.URLParam("userId")
. Ниже пример того как это выглядит в проекте.
Файл pages/_userId_/index.html
:
<ssr:var name="userId" type="string"/>
<h2><strong>User ID:</strong> {{ userId }}</h2>
И главный метод в файле pages/_userId_/dataprovider.go
:
func (p *DP_userId_) GetRoute_userId_Data(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
data.UserId = r.URLParam("userId")
return nil
}
Данный хендлер также может содержать дочерние хендлеры со своим контентом.
Чтобы проверить работоспособность, сохраняем файлы и заходим по адресу http://localhost:8080/login123, результат должен быть таким:
Имя пользователя из URL появилось на странице. А если зайти на http://localhost:8080/home, то отработает шаблон в папке pages/home/
как и было задумано.
Возле каждого шаблона можно положить скрипты, стили и картинки. Они будут собраны в бандлы с помощью WebPack, для GoSSR я написал специальный плагин GoSSRAssetsPlugin, в конфиге из бойлерплейта он уже используется.
Для каждого шаблона создаётся свой собственный бандл, который будет подключен только тогда, когда этот шаблон отрисовывается. И в нужном порядке. Чтобы указать место, где импортируется статика, в главном шаблоне надо добавить тег <ssr:assets/>
. Обычно его надо положить перед закрытием </head>
.
Входной точкой является файл index.ts
, другие файлы, если они не импортированы в index.ts
, будут проигнорированы.
В текущем демо проекте у нас есть иерархия роутов:
pages/
home/
contacts/
Предлагаю создать в каждом из них создать по файлу index.ts
с таким содержимым:
pages/index.ts
(уже существует, нужно только заменить контент):
console.log("Root template")
pages/home/index.ts
:
console.log("Home template")
pages/contacts/index.ts
:
console.log("Contacts template")
Сохраняем файлы, дожидаемся пересборки статики и заходим на страницу http://localhost:8080/home, в исходниках можно увидеть подключенные JS файлы в порядке вложенности шаблонов:
А если посмотреть в консоль, то там ожидаемо будет 2 сообщения:
Если же зайти на страницу с контактами, то там будут свои 2 сообщения:
Аналогично скриптам, рядом с шаблонами можно класть стили, которые будут подключаться только тогда, когда отрисовывается конкретный шаблон. Для этого нужно создать файл styles.scss
.
Для примера я покажу как подключить Bootstrap. Первым делом нужно в файле internal/web/package.json
в секции dependencies
добавить зависимость от "bootstrap": "^5.3.3"
. Сохраняем файл, GoSSR автоматически скачает все необходимые зависимости. Как только это произойдёт, можно импортировать его в файл pages/styles.scss
:
@use "bootstrap/scss/bootstrap";
body {
background-color: lightgray;
}
Сохраняем файл, дожидаемся пересборки статики и идём по адресу http://localhost:8080/home, Bootstrap подключился, а его стили применились:
Рядом с шаблоном можно положить и картинки, а в качестве src
использовать относительный путь к ней, например:
<img src="./image.png">
GoSSR скопирует её в папку static/
, а адрес в src
заменит на валидный.
Для примера предлагаю взять изображение Go'шного маскота, сохранить его в папке pages/home/
под именем gopher.png
. Затем добавим его в файл pages/home/index.html
:
<h1>Home page</h1>
<img src="./gopher.png" alt="Gopher">
После автоматической пересборки и обновления страницы получится:
Картинка загружается из папки /static/
.
По умолчанию WebPack вызывается в режиме development
, но можно передать параметр -prod
в go-ssr
, и тогда WebPack будет вызван с `--mode production`, что приведёт к более компактным бандлам.
Это первая публичная версия, в которой точно есть недоработки. Но как идея, которую можно развивать, мне кажется вполне.
Более комплексный пример можно найти на GitHub в папке example. Там же есть и benchmark, который на моём ноутбуке выдаёт:
goos: linux
goarch: amd64
pkg: github.com/sergei-svistunov/go-ssr/example/internal/web/pages
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkSsrHandlerSimple
BenchmarkSsrHandlerSimple-16 432955 2343 ns/op
BenchmarkSsrHandlerDeep
BenchmarkSsrHandlerDeep-16 164113 7131 ns/op
На самом деле там есть где ещё оптимизировать и я этим обязательно займусь, но даже сейчас результат довольно быстрый.