Жизнь – это вечная спираль, где всё идёт по кругу, но с каждым витком становится лучше. Ещё 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">&lt;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 варианта:

  1. ssr:for="value in array"

  2. 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:

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

Переменные в URL

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

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

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


  1. Pavel-Lukyanov
    12.10.2024 13:00

    А почему бы просто не взять nuxtjs для vue и не изобретать велосипед?


    1. svistunov Автор
      12.10.2024 13:00

      1. Мне нравится Go, я уважаю JS/TS, но, лично для меня, это языки для фронтенда, не для сервера.

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

      3. Может ли NuxtJS для Vue отдавать страницы за 2-10 микросекунд (не мили)?