2026-03-28 RahatPress

Зачем было писать очередной SSG?

Повод номер 1

Столкнулся я со следующим. Один из проектов я изначально строил на развитой микросервисной архитектуре - 86 микросервисов, общающихся через AMQP и HTTP API. На каждый микросервис ведется отдельная документация с помощью mkdocs. Документация писалась руками и ее много.

В определенный момент возникла задача объеденить весь этот зоопарк под одним реверс-прокси с унификацией вызовов API каждого микросервиса. По задумке это должен быть публичный API сервис, близкий по функционалу к dadata и аналогам. Соответственно, возник вопрос к созданию документации.

Ручная миграция показалась задачей слишком трудозатратной, поэтому я подошел к вопросу иначе - стал генерить её из функциональных тестов API с помощью нейросетки.

Эти тесты фактически являются просто вызовами с помощью httpie endpoint API с проверкой результата с использованием языка jq. То есть, в них уже содержится по умолчанию вся необходимая мета-информация о вызове - тип запроса, заголовки и т д. И это простые shell скрипты примерно такого вида:

#!/bin/bash

. .public

https ${params} GET ${new}'products/city' id==760 filters=='{"rooms":[5,9],"desktop":true}' mode==search page==1 | jq '.data | .[] | .match'

https ${params} GET ${new}'products/city' id==760 filters=='{"rooms":[5,9]}' mode==filter page==1 | jq '.data | .[] |  .distance'

Для генерации документации оказалось достаточно просто дооснастить эти вызовы минимальными комментариями. Вот так например это выглядит для API RahatPress:

#!/bin/bash

. .public

echo 'Добавление новой категории'

https ${args} POST ${url}'Test_test'

echo 'Добавление новой статьи - slug берется из title markdown'

https -f ${args} POST ${url} text='# Test article 
 text text text '

echo 'Добавление новой статьи - slug передается аргументом строки запроса'

https -f ${args} POST ${url}'articles/index' text='# Test article 
 text text text '
 
echo 'Добавление нового поста в категорию'

https -f ${args} POST ${url}'Test_test/new_post' text='# Test post 
 text text text '
 
 echo 'Редактирование статьи'

https -f ${args} PUT ${url}'articles/index' text='# Edit article 
 text text text '

echo 'Редактирование поста'

https -f ${args} PUT ${url}'Test_test/new_post' text='# Edit post
 text text text '

echo 'Редактирование (переименование) категории'

https -f ${args} PUT ${url}'Test_test' name='Test'

echo 'Получение последних постов без паджинации'

https ${args} GET ${url}

echo 'Получение последних постов с паджинацией'

https ${args} GET ${url} page==1

echo 'Получение всех категорий'

https ${args} GET ${url}'categories'

echo 'Получение категории'

https ${args} GET ${url}'categories/Test'

echo 'Получение постов из категории с паджинацией'

https ${args} GET ${url}'Test' page==1

echo 'Получение всех статей'

https ${args} GET ${url}'articles'

echo 'Получение конкретной статьи'

https ${args} GET ${url}'articles/index'

echo 'Получение конкретного поста'

https ${args} GET ${url}'Test/new_post'
 
echo 'Удаление статьи'

https ${args} DELETE ${url}'articles/new'

echo 'Удаление поста'

https ${args} DELETE ${url}'Test/new_post'

echo 'Удаление категории'

https ${args} DELETE ${url}'Test'

echo 'Удаление всех статей'

#https ${args} DELETE ${url}'articles'

echo 'Или аналогично'

https ${args} DELETE ${url}

echo 'Примеры ошибочных запросов'

https ${args} GET ${url}'categojkjkjkjkljklj'

https ${args} GET ${url}'categories/ljkljkjkjk'

https ${args} GET ${url}'articles/devops8989'

https ${args} POST ${url}

https -f ${args} POST ${url} text='dddddd dfdsadasd'

https -f ${args} POST ${url}'Text/new_post' text='# Test post 
 text text text '
 
https -f ${args} POST ${url}'Test/new_post' text='# Test post 
 text text text '
  
https ${args} DELETE ${url}'articles/not_found'

https ${args} DELETE ${url}'Test/not_found'

https ${args} DELETE ${url}'Text'

https -f ${args} PUT ${url}'articles/not_found' text='# Test article 
 text text text '

https -f ${args} PUT ${url}'not_found/test' text='# Test post
 text text text '

https -f ${args} PUT ${url}'not_found' name='new'

Попробовал. Оказалось, что с генерацией документации на основе таких данных нейросетка справляется практически идеально, поэтому сделал автогенерацию - сразу при запуске тестов по API дергаю модель и она генерит при необходимости документацию в Markdown, которой либо не требуются вовсе, либо требуются минимальные правки. Ну и, соответственно, коммитит их в репозиторий.

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

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

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

Повод номер 2

Другой проект. Тоже развитая микросервисная архитектура, обслуживающая в том числе и клиентские приложения - SPA и мобильные. В очередной раз всплывает все тот же вопрос, который практически всегда откладывается как мелочь на последний момент, - разные информационные страницы наподобие “правил”, “политик”, “оферт” и прочей служебной документации, которую надо показывать пользователю (для того, чтобы он, конечно, никогда эти портянки не читал).

Каждый раз одно и то же - надо что-то для такой тривиальной задачи срочно придумывать, чтобы отдавало отрендеренный html и pdf и при этом обладало хоть каким-то UI, чтобы можно было кому-нибудь дать править все эти текстовые полотна. Мелочь? Согласен. Но выливается каждый раз в полдня возни. Неплохо бы как-то вопрос решить комплексно.

Повод номер 3

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

Функциональные требования

В общем, сформировал базовые свои потребности:

  1. Быстрый рендеринг Markdown на лету, функционал SSG на уровне кеша, причем при необходимости.

  2. Полное API для управления контентом.

  3. Наследование в структуре URL структуры папок для прозрачности.

  4. Функционал, достаточный для развертывания минимального блоггинга - категории, RSS, поиск и так далее.

  5. Максимально простой деплой.

  6. Наличие минимального UI для администрирования, чтобы при необходимости мелких правок вообще не думать о коммитах, сборках и прочем.

  7. Встроенный простой шаблонизатор.

Погуглил, что-то нашел, что-то посмотрел, но все не понравилось - ни одного решения, полностью реализующего требования, не нашел. Что-то подходящее требовало изменений и использования чаще всего node.js или Python.

Мне это не подошло. Последние несколько лет для микросервисов я практически всегда использую OpenResty и Lua-JIT и стараюсь без крайней необходимости не тащить в микросервисы дополнительные технологии, равно как и лишний код. А уж тащить ради рендеринга пары страниц JS-монстров…

Второй момент - мне была важна прозрачная структура папок контента, полностью наследующая структуру URL - это просто удобно, т.к. снимает все вопросы относительно “что и где”. Критерий у меня простой - сервис должен безо всякого изменения структуры работать по протоколу Gopher, который я считаю наиболее удобным для документации, так как имею привычку читать ее через консоль. Залил в папку с нормальным названием все, что там должно лежать - Markdown, картинки, файлы схем и т д и забыл. Не потеряется и доступно. Ничего подобного ни один из SSG обеспечить оказался не в состоянии.

В экосистеме Lua вообще оказался только один кандидат на рассмотрение - LuaPress, который я рассматривал еще лет 10 назад. Сейчас проект заброшен, аналогов нет. Более того, оказалось, что нет и быстрых решений для рендеринга Markdown. Доступные в Luarocks варианты либо медленные, либо не полностью соответствуют стандарту, либо попросту устарели и не собираются. Поэтому начал с вопроса ключевого - рендеринга Markdown.

Рендеринг

В подавляющем большинстве случаев при разработке на современных комбайнах - что языках “все в одном”, что с использованием фреймворков, о рендеринге markdown вообще мало кто задумывается. Подключил библиотеку и ок.

В экосистеме OpenResty ситуация несколько иная. Популярное решение “взял и подключил” реализовано на чистом Lua и не отличается ни скоростью, ни полным соблюдением стандарта.

Оба этих качества зато присущи Си-шной либе Discount, для которой для Lua есть биндинг. Который, увы, не работает, ввиду того, что текущая 3 версия Discount имеет измененное API, не совместимое со второй версией, а биндинг есть только для третий.

Автор биндинга в своем гитхабе честно при этом пишет - API поменялось, проект поэтому бросил. В целом понятно почему - дока на Discount 3 не очень прозрачна в части описания API, а ключевые изменения описаны вообще абзацем снизу раздела Download - видать смена пола автора либы (стал Джессикой :)) не прошла бесследно.

Альтернативы, впрочем, особо нет - быстрее реализации рендеринга я не нашел. Поэтому написал биндинг для Discount 3 сам - https://gitlabor.ru/Datenlabor/discount3. Вариант сборки discount3 как shared библиотеки - https://gitlabor.ru/Datenlabor/discount, там же добавлена и поддержка pandoc-style комментариев.

Понятно, что подход с реализацией рендеринга не лету не самый производительный. Но, во-первых, какова нагрузка на моих задачах? Минимальная. А, во-вторых - кто мешает превратить все в SSR одной всего строчкой на стороне OpenResty, добавив простейшее кеширование встроенными средствами? Это много изящней, чем городить генерацию готовых страниц на каждый чих.

Реализация

Реализация тривиальна и не стоит даже описания подробного - прошли по папкам, нашли Markdown, отрендерили в одном из шаблонов, вывели, отдали по API.

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