UML-редактор с блок-схемами по ГОСТ — как устроено внутри
- среда, 14 октября 2026 г. в 00:00:08
Я работаю системным аналитиком, и UML-диаграммы для меня это часть работы. Но официальный редактор на plantuml.com из России не открывается, а ставить Java и плагин в IDE или VPN на рабочий компьютер не хочется.
Поэтому я навайбкодил umleditor.ru - редактор UML-диаграмм в браузере. Пишешь код и сразу видишь схему. Регистрироваться не надо. Схемы рисует мой собственный PlantUML-сервер в Москве, так что код диаграмм не уходит на чужие сервисы.
Весь сайт написал Claude: интерфейс, серверную часть, скрипты для сервера, даже тексты справочника. Я объяснял, что хочу, смотрел результат, просил переделать и делал то, что нейросеть сделать не может: платил за сервер, покупал домен и вводил пароли.
Первая рабочая версия появилась за два часа. Потом проект рос: блок-схемы по ГОСТ со своим рендерером, редактор JSON, справочник на 70+ страниц. Ниже описание того, как это устроено внутри, с кодом и цифрами.

16 видов диаграмм PlantUML: классов, прецедентов, последовательности, деятельности, состояний, ER, Ганта, интеллект-карты и другие.
Блок-схемы алгоритмов по ГОСТ 19.701-90: пишешь псевдокод по-русски, схема рисуется сразу.
Редактор JSON с деревом и переводом в YAML и XML.
Шаблоны, кнопки вставки типовых конструкций, экспорт в PNG и SVG.
Диаграммы сохраняются без регистрации.
Справочник, 20 задач с блок-схемами и готовые примеры - пригодится студентам для курсовых.

Весь сайт живёт на одном VPS за 525 ₽ в месяц, в трёх контейнерах Docker Compose. Снаружи открыт только Caddy, он сам получает сертификат Let's Encrypt, сжимает ответы и проксирует всё в приложение. PlantUML-сервер слушает только внутреннюю сеть Docker.
Бэкенд - Node.js 24 и Express 5. Это единственная зависимость сервера: SQLite встроен в Node с версии 22.13, а лимиты, кеш, cookie и сжатие PlantUML написаны своим кодом.
Фронтенд - Vite и обычные ES-модули без фреймворка, две страницы сборки: редактор диаграмм и редактор JSON. Mermaid подключается только для части видов диаграмм.
Общий код. Папка frontend/src/lib - описания диаграмм, справочник, задачи, рендерер ГОСТ, парсеры. Её импортируют и браузер, и сервер.
Хранение без регистрации. Посетитель получает случайный uid в cookie, диаграммы лежат в одной таблице с индексом (owner, updated_at DESC), режим WAL.
Выкладка. deploy.bat упаковывает проект tar и по SSH распаковывает его на сервере. Скрипт настройки ставит Docker и открывает порты 80 и 443. Если Docker Hub из России не отвечает, он включает зеркало реестра и запускает docker compose up -d --build.
Браузер шлёт код на POST /api/render, а сервер сжимает его и передаёт контейнеру plantuml-server. PlantUML принимает диаграмму прямо в URL: текст сжимается deflate и кодируется своим вариантом base64 с другим алфавитом.
import { deflateRawSync } from "node:zlib"; const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; export function encodePlantUml(text) { const data = deflateRawSync(Buffer.from(text, "utf8"), { level: 9 }); let out = ""; for (let i = 0; i < data.length; i += 3) { const [b1, b2 = 0, b3 = 0] = [data[i], data[i + 1], data[i + 2]]; out += ALPHABET[b1 >> 2] + ALPHABET[((b1 & 3) << 4) | (b2 >> 4)] + ALPHABET[((b2 & 15) << 2) | (b3 >> 6)] + ALPHABET[b3 & 63]; } return out; }
Короткий код, до 4000 символов после кодирования уходит GET-запросом /svg/<код>, длинный - POST с текстом в теле. На запрос даётся 15 секунд через AbortController, потом понятная ошибка вместо висящего редактора.
Что ещё пришлось сделать вокруг этого вызова:
Песочница. Сервер публичный, а PlantUML умеет !include файлов и URL. Контейнер запущен с PLANTUML_SECURITY_PROFILE=SANDBOX, PLANTUML_LIMIT_SIZE=8192, -Xmx512m и mem_limit: 900m, и наружу не открыт: его видит только контейнер приложения.
Очистка SVG. Картинка вставляется в страницу как разметка, поэтому сервер вырезает <script> и атрибуты on*= перед отдачей.
Кеш. Ключ - SHA-1 от исходника, LRU на 300 записей на Map. Удачные ответы и ответы с ошибкой синтаксиса кешируются, сетевые сбои нет.
Лимит. 60 отрисовок в минуту с одного IP: фиксированное окно в памяти, ответ 429 с Retry-After. За Caddy включён trust proxy, иначе у всех посетителей был бы один IP прокси и один общий лимит.
Номер строки ошибки. PlantUML отдаёт его в заголовке X-PlantUML-Diagram-Error-Line, и он считает с нуля, а в самой картинке ошибки пишет [From string (line 4)] уже с единицы. До нагрузочного теста редактор подсвечивал строку на одну выше настоящей. Теперь номер берётся из картинки, а заголовок - запасной вариант:
export function errorLineOf(body, lineHeader) { const m = /\(line (\d+)\)/.exec(body || ""); if (m) return Number(m[1]); // в картинке - с единицы return lineHeader ? Number(lineHeader) + 1 : null; // в заголовке - с нуля }
Пока в коде ошибка, редактор показывает последнюю удачную версию схемы, а не красную картинку PlantUML.
Мне этого было достаточно, но Claude подсказал, что люди еще часто ищут блок-схемы. Поэтому я решил и их добавить. Сначала я попросил сделать их на Mermaid, и вышло криво, подписи "да/нет" наезжали на линии, стрелки циклов шли дугами, а пропорции блоков не совпадали с ГОСТом. Общий алгоритм раскладки графа тут мешает. У блок-схемы структура известна заранее. Поэтому рендерер свой: около 400 строк без зависимостей.
На входе псевдокод по-русски, вложенность задаётся отступами, как в Python:
блок-схема: Двоичный поиск начало ввод n, массив a[1..n], x L = 1 R = n пока L <= R m = (L + R) div 2 если a[m] = x вывод m иначе если a[m] < x L = m + 1 иначе R = m - 1 конец
Разбор. Строки превращаются в дерево узлов term, io, proc, sub, if, while, for, repeat. Рекурсивная функция block(indent) читает строки своего отступа и спускается глубже, когда отступ растёт. Цепочка если/иначе если/иначе становится вложенными ромбами в ветке "нет". Ошибка всегда несёт номер строки: "Лишний отступ", "иначе без если", "После "если" нужно условие".
Раскладка. Каждый узел сам сообщает, сколько места ему нужно слева и справа от оси потока и какой высоты он будет. Рисует он себя сам, когда родитель даст координаты. Вход всегда сверху, выход снизу на оси:
// каждый узел: { l, r, h, draw(x, y) } function shape(n) { const h = n.t === "term" ? TH : H; return { l: W / 2, r: W / 2 + commentSize(n).w, h, draw(x, y) { /* прямоугольник, параллелограмм или "таблетка" + текст */ }, }; }
Последовательность складывает высоты детей и берёт максимум их l и r. Ветвление ставит ветку "да" слева, а "нет" справа на расстоянии, чтобы их r и l не пересеклись. Цикл добавляет коридор слева под возвратную линию и справа под выход. Один проход снизу вверх считает размеры, один сверху вниз рисует.
Правила ГОСТа в коде:
Блок в пропорции b = 1,5a, ширина одна на всю схему.
Ромб с длинным условием расширяется ступенями 1 - 1,25 - 1,5 ширины, пока текст не влезет в две строки. Высота считается из геометрии ромба: чтобы прямоугольник текста поместился, нужно условие h ≥ th / (1 − tw/w).
Цикл "для" - шестиугольник "модификация", вызов - прямоугольник с двойными боками, комментарий - квадратная скобка на пунктире.
Линии только горизонтальные и вертикальные. Стрелка ставится только там, где поток идёт не сверху вниз и не слева направо: на возвратах циклов и слияниях справа налево.
* выводится как ·, <= как ≤, != как ≠.
Без DOM. Рендерер собирает SVG строкой и не трогает document, поэтому один и тот же файл работает в браузере и на сервере, где из него делаются картинки для страниц задач. Ширина текста без measureText оценивается таблицей ширин символов в долях кегля:
function textWidth(s, fs = FS) { let w = 0; for (const ch of s) { if (ch === " ") w += 0.28; else if ("ijlI.,:;|!'()[]".includes(ch)) w += 0.3; else if ("mwMWШЩЖЮМЫФ".includes(ch)) w += 0.85; else if (/[A-ZА-ЯЁ]/.test(ch)) w += 0.68; else w += 0.56; } return w * fs; }
Погрешность такой оценки несколько процентов, и она закрывается полями внутри блоков. Шрифт - ГОСТ тип B, если он есть в системе, иначе Arial. Все 20 задач сайта прогоняются через рендерер в тестах.

JSON.parse тут не подошёл по трём причинам:
Объект JavaScript переставляет целочисленные ключи в начало: {"2":1,"1":2} превращается в {"1":2,"2":1}.
2.50 становится 2.5, а большие целые теряют точность.
Сообщение об ошибке зависит от браузера и обычно сводится к Unexpected token.
Поэтому все три формата разбираются своим кодом в общее дерево, где порядок ключей и запись чисел сохраняются:
{ t: "obj", e: [["заказ", { t: "num", v: "10452", ln: 2 }], ...] } // объект: массив пар { t: "arr", a: [...] } { t: "str", v } | { t: "num", v: "2.50" } | { t: "bool", v } | { t: "null" }
У каждого узла есть ln - строка в исходнике. Щелчок по узлу дерева переносит курсор к нужной строке кода. Изменение в дереве переписывает текст в текущем формате, а переключатель JSON/YAML/XML - это просто запись того же дерева другим сериализатором.
YAML. Полная спецификация огромна, поэтому разбирается то, что встречается в конфигах:
вложенные словари и списки, в том числе - ключ: значение;
записи в скобках [ ] и { }, многострочные;
строки в кавычках, блоки | и > с модификаторами -, + и цифрой отступа;
комментарии.
Якоря &/*, слияние << и несколько документов в файле честно сообщают "не поддерживается" с номером строки, а не разбираются неверно.
Парсер сверялся с PyYAML: один и тот же текст разбирали оба, и JSON на выходе должен был совпасть. Так нашлась ошибка в блоке >: пустая строка давала \n\n вместо \n.
Обратная запись опаснее разбора. Строки, которые другой парсер прочитает не как строку, нужно брать в кавычки:
"yes", "no", "y" - в YAML 1.1 это логические значения;
"2026-10-06" - PyYAML превращает в дату;
"10:00" - в YAML 1.1 это число 600 в шестидесятеричной записи;
"0123" - иначе потеряется ведущий ноль.
Даты и времена попали в этот список только после теста на примере заказа с полем дата. В тестах прогоняются кругом JSON - YAML - JSON три десятка "скользких" строк.
XML переводится по привычному соглашению:
атрибуты становятся ключами @имя;
текст рядом с тегами становится ключом #text;
повторяющиеся теги собираются в массив.
Парсер XML тоже свой, без DOMParser, чтобы тот же код работал на сервере и сообщал строку ошибки: "Закрывающий тег </b> не совпадает с открытым <a>".
Большие файлы. На JSON в 4,3 МБ каждое действие на обычном компьютере занимало 3-4 секунды. Профилировщик показал, что разбор занимает ~150 мс, а всё остальное это чтение textarea.scrollTop. Оно заставляло браузер заново верстать текст вместе с колонкой из 240 тысяч номеров строк. Теперь номера рисуются только для видимых строк, а подсветка синтаксиса выключается на текстах длиннее 200 тысяч символов. В облачном браузере без видеокарты, где всё медленнее, одно действие стало занимать 7 секунд вместо 11. Остаток это вёрстка самого textarea, и дальше поможет только виртуальный редактор вроде CodeMirror.
Редактор - одностраничное приложение, и поисковики видят в нём пустую страницу. Поэтому справочник, задачи и примеры Express отдаёт готовым HTML. Шаблонизатора нет: страницы собираются шаблонными строками JavaScript с функцией esc() на каждом вставляемом значении.
Один источник данных. Описания видов диаграмм, справка по конструкциям, задачи и примеры лежат в ES-модулях frontend/src/lib/*.js. Их импортирует и редактор в браузере, и сервер, а Dockerfile копирует папку lib в рабочий образ. Правка в одном месте меняет и сайт и подсказки в редакторе.
Картинки для статей рисует сам сервер. Адреса вида /api/examples/<вид>.svg и /api/tasks/<задача>.svg идут через PlantUML или рендерер ГОСТ. Ответ кешируется в памяти процесса и отдаётся с Cache-Control: max-age=86400. В robots.txt эти адреса разрешены, хотя остальной /api/ закрыт, иначе картинки не попадут в поиск. В каждой странице есть разметка schema.org в JSON-LD: TechArticle, BreadcrumbList, а на посадочных страницах ещё WebApplication и FAQPage.
Переходы без перезагрузки. При обычных ссылках боковая панель на 70 пунктов на каждом переходе сбрасывала прокрутку и сворачивала группы. Решение в духе PJAX заняло около 50 строк без фреймворка:
const doc = new DOMParser().parseFromString(await (await fetch(url)).text(), "text/html"); const art = doc.querySelector(".docs article"); if (!art) throw "no-docs"; // чужая страница - обычный переход history.pushState({ docs: 1 }, "", url); document.querySelector(".docs article").replaceWith(document.adoptNode(art)); document.title = doc.title; // + description, canonical и JSON-LD ym(METRIKA_ID, "hit", url, { title: doc.title, referer: prev }); // счётчик видит переход
При быстрых щелчках предыдущий запрос отменяется через AbortController. Любая ошибка превращается в обычный переход по ссылке, так что худший случай это поведение обычного сайта. В тесте переходы занимали 60-240 мс, включая "Назад" через popstate.
Сервер самый дешёвый: 2 vCPU, 2 ГБ памяти, 525 ₽ в месяц. Тест шёл из браузера на домашнем интернете: пачки fetch по HTTP/2, смесь страниц справочника, редактора и SVG-картинок, с cache: "no-store" и уникальным параметром в URL, чтобы не попадать в кеш.
Тест | Результат |
|---|---|
400 запросов одновременно | 0 ошибок, медиана 433 мс, максимум 702 мс |
25 секунд, 120 потоков без перерыва | 13 287 запросов (~530 в секунду), 227 МБ, 0 ошибок; p50 191 мс, p95 426 мс, p99 666 мс |
58 отрисовок PlantUML одновременно, в том числе диаграммы на 30–65 классов | все успешны за 1,8 с, SVG до 138 КБ |
61-я отрисовка за минуту | 429 "Слишком много отрисовок подряд" |
40 сохранений диаграмм одновременно в SQLite | все 201 за 213 мс |
Тело запроса 2 МБ, битый JSON, имя длиннее 120 символов | 413, 400, 400 с понятным текстом |
Имя диаграммы | сохраняется как текст, в интерфейсе не выполняется |
За 25 секунд скорость упала примерно вдвое без роста ошибок. 227 МБ за 25 секунд это ~70 Мбит/с, так что похоже на потолок канала, а не процессора. Одного клиента для точного ответа мало. Ответы сжимает Caddy (encode zstd gzip), страница справочника весит 20-26 КБ до сжатия.
Баг 1: главная съедала лимит. Галерея на главной рисовала превью 16 видов диаграмм через тот же POST /api/render, что и редактор. Четыре возврата на главную за минуту съедали весь лимит в 60 отрисовок, и редактор внезапно переставал рисовать. Со стороны это выглядело как будто сайт тормозит. Теперь превью берутся GET-запросом /api/examples/<вид>.svg. Их кеширует браузер, а в лимит они не входят. Заодно картинка кладётся в клиентский кеш по коду шаблона, и открытый из галереи шаблон не рисуется заново. Было 16 POST на каждое открытие главной ,а стало 0.
Баг 2: "если" без условия. Строка если без условия не совпадала с регуляркой ^если\s+(.+) и молча рисовалась прямоугольником с текстом "если". Теперь такие строки это ошибка с подсказкой: "После "если" нужно условие, например, если x > 0".
Всё делалось в приложении Claude на ПК. Нейросеть не только писала код, но и сама складывала файлы в папку проекта, запускала тесты и проверяла готовый сайт в браузере.
Идея. Я написал одним сообщением: нужен сайт как plantuml.com, с современным интерфейсом и разделением по видам диаграмм. Через несколько минут был первый рабочий вариант.
Правки. Дальше шли обычные замечания, как дизайнеру, тёмная тема слишком мрачная, сделай в синих тонах. Я присылал скриншот и писал, что не так, а Claude исправлял. Не всегда с первого раза.
Свой сервер. Я арендовал недорогой сервер в Москве, а нейросеть подготовила скрипт deploy.bat. Дважды щёлкаешь, вводишь пароль и скрипт по SSH копирует проект, ставит Docker и запускает docker compose up -d --build. С доменом я немного застрял: купил его, а сайт по адресу не открывался. Оказалось, забыл создать зону в DNS-хостинге.
Рост. Потом появились блок-схемы по ГОСТ, задачи, редактор JSON и нагрузочный тест. Сейчас в проекте около 7 тысяч строк своего JavaScript и 50 тестов на node:test, из зависимостей Express, Mermaid и Vite.
За всё время я не написал ни строчки кода сам, только объяснял словами и скриншотами.
Нейросеть написала весь код и тесты, посоветовала сервер и сама находила свои ошибки. Я честно не знал, что такое NS-записи, она объяснила на скриншотах моей же панели.
Технический вывод: из 2 багов из раздела про тест юнит-тесты не поймали ни одного. ИИ хорошо пишет код и тесты к нему, но тесты повторяют его же представления о том, как работает внешний мир. Ошибки нашлись, когда я попросил проверить настоящий сайт как тестировщик, и когда YAML сверялся с чужим парсером.
Оплата, пароли и настройки в личных кабинетах хостинга, Яндекса и Google остались на мне - это нейросеть за тебя не сделает.
Сейчас сделать свой небольшой сервис может человек, который не пишет код сам. Но понимать, что ты хочешь получить, и проверять результат всё равно придётся.
Виртуальный редактор кода вместо textarea - для файлов в несколько мегабайт.
Ссылка "поделиться", чтобы отправить диаграмму коллеге или преподавателю.
Необязательный вход, чтобы диаграммы не пропадали при смене браузера.
Если какая-то диаграмма не рисуется, отправьте код в комментарии. Попробовать можно на umleditor.ru.