Привет, Хабр! Меня зовут Максим Приходский @etherfly, я архитектор R‑Style Softlab. Сегодня хочу рассказать вам о проекте создания архитектурного репозитория в git на базе PlantUML.

Большие, долгосрочные проекты неизбежно накапливают в себе энтропию: каждое неучтенное, незадокументированное и «временное» решение постепенно усложняет разрабатываемую систему, знания о ней растворяются среди множества людей и документов и поддерживать ее становится все сложнее. Есть несколько архитектурных инструментов, чтобы замедлить старение проекта и, насколько это возможно, сохранить знания о том, что из себя представляет система сейчас и какая череда изменений к этому привела.
Я бы хотел рассказать об отдельном кейсе внедрения одного такого инструмента 一 самописного архитектурного репозитория, использующего принцип «архитектура как код». Традиционно архитектурная информация хранится в разрозненном виде 一 что‑то в виде документов и UML‑диаграмм, что‑то в корпоративной базе знаний, что‑то в виде опыта и знаний отдельных сотрудников, и нередко эти источники друг другу противоречат. Архитектурный репозиторий 一 это прежде всего «единый источник истины» (от англ. Single Source of Truth), в котором аккумулируется вся актуальная информация в виде текстовых описаний, схем и диаграмм, а также общего глоссария и библиотеки компонентов. Последние позволяют всем участникам проекта использовать единую терминологию и четко понимать, какой компонент или процесс понимается под тем или иным термином.
План реализации и первые шаги
Архитектурные репозитории и подход «архитектура как код» не являются чем‑то новым. Сейчас их использование фактически является стандартом индустрии, и есть готовые специализированные решения, такие как DocHub. В пользу создания собственного решения у меня было несколько аргументов: оно должно было быть максимально прозрачным и понятным в плане контроля данных для компании и и��тегрированным с существующими GitLab‑репозиторием и Confluence. Дополнительный плюс лично для меня как архитектора 一 возможность подтянуть свои компетенции в области архитектуры и работы с документацией и лучше понять, как подобные инструменты работают изнутри. В качестве базы был выбран PlantUML 一 инструмент создания диаграмм на основе формализованного текстового описания.
Данная активность пришлась на время взлета популярности языковых моделей вроде ChatGPT. Ради эксперимента я задал вопрос ChatGPT: так и так, хочу написать архитектурный репозиторий с PlantUML и интеграцией с Confluence, напиши план. ИИ действительно написал хороший план действий, который помог мне начать погружение в тему. Переписка, увы, не сохранилась. Однако суть была такова:
Создать проект в GitLab и организовать структуру каталогов.
Настроить CI/CD в GitLab и зарегистрировать GitLab Runner.
Написать скрипт для компиляции схем PlantUML и публикации материалов в Confluence.
Со структурой каталогов и принципами, по которым будет организовано хранение информации о компонентах системы и решениях, я решил разобраться самостоятельно. Для меня это интересная и приятная часть, представляющая собой раскладывание своих знаний о проекте по полочкам. Поскольку нам важно иметь изолированное окружение для работы PlantUML, для компиляции был использован локальный сервер, поднятый в Docker. В качестве основной модели, описывающей структуру системы, была выбрана модель C4. Она позволяет показать систему в разном масштабе: от самых верхнеуровневых схем, где она предстает как один блок, взаимодействующий с другими системами, до самых детализированных, на которых можно увидеть взаимосвязь Java‑классов. Максимальная детализация нам на данном этапе не потребовалась, достаточно было описывать систему вплоть до отдельных запускаемых приложений, брокеров и баз данных. Для C4 имеется готовая, удобная open‑source интеграция с PlantUML, и она, конечно, тоже была выкачана в проект для локального использования в качестве зависимости для компонентных схем.
В итоге архитектурный репозиторий обзавелся такой структурой:
с4 – расширение C4 для PlantUML
actors – библиотека акторов для архитектурных схем (клиент, сотрудник) для импорта
domain – каталог, содержащий все архитектурные описания проекта. В нём можно найти:
external-systems – библиотека внешних систем для импорта
patterns – библиотека паттернов для импорта
my-system – иерархические описания компонентов системы
changes – база записей об архитектурных решениях (ADR)
Основной принцип для описания компонентов гласит: в самих схемах новые сущности объявлять нельзя. Они должны быть объявлены в отдельном файле и импортированы в нужные схемы. Таким образом мы сохраняем единство наименований и описаний. Другой, не менее важный принцип – переиспользование кода в виде архитектурных паттернов. Зачастую наши сервисы и процессы похожи друг на друга, и чтобы не тратить время на рисование в целом идентичных диагр��мм, мы используем библиотеку паттернов, вроде типовых микросервисов или бизнес-операций.
В результате это позволяет написать несколько строчек PlantUML-кода…
@startuml !include domain/patterns/c4/microservice_archetype.puml $microservice_archetype(yet_another_service, "Микросервис настроек", yet_another_backend.puml, yet_another_frontend.puml, yet_another_db.puml, yet_another_backend, yet_another_frontend, yet_another_db) @enduml
…и получить готовую схему для несложного, созданного из архетипа сервиса. Ее, конечно же, можно обогатить другими компонентами, если это потребуется.

CI/CD и Python с нуля без онлайн-курсов
Следующим шагом для меня было знакомство с GitLab CI/CD, с GitLab Runner и с тем, как запустить свой первый пайплайн. К счастью, отвечать на мои вопросы приходилось не девопсу, а ИИ, и первый предложенный скрипт CI/CD был таким:
image: plantuml/plantuml-server variables: CONFLUENCE_SERVER_URL: "<https://your.confluence.server>" CONFLUENCE_API_TOKEN: "your_confluence_api_token" stages: - update_confluence update_confluence: stage: update_confluence script: - apt-get update && apt-get install -y python3-pip - pip3 install -r requirements.txt - python3 update_confluence.py only: changes: - domain/**/*
Это была отправная точка не только для более глубокой работы с shell‑скриптами, но и для знакомства с Python. Этот скрипт с тех пор претерпел изменения: теперь в скрипт публикации передаётся список каталогов, затронутых в данной ревизии, а также появилась отдельная задача для полной выгрузки всего репозитория.
... script: - pip3 install -r requirements.txt - GIT_DIFF=$(git diff --name-only --diff-filter=ACMRTUXB $CI_COMMIT_SHA^..$CI_COMMIT_SHA) - echo "GIT_DIFF=$GIT_DIFF" - echo "Filtering domain folders" - DOMAIN_FOLDERS=$(echo "$GIT_DIFF" | grep -E '^domain/.*' || true) - if [ -z "$DOMAIN_FOLDERS" ]; then echo "No changes in domain folder"; exit 0; fi - echo "DOMAIN_FOLDERS=$DOMAIN_FOLDERS" - echo "Extracting directory names" - DIR_NAMES=$(echo "$DOMAIN_FOLDERS" | xargs -I{} dirname {}) - echo "DIR_NAMES=$DIR_NAMES" - echo "Removing duplicates" - UPDATED_FOLDERS=$(echo "$DIR_NAMES" | uniq) - echo "UPDATED_FOLDERS=$UPDATED_FOLDERS" - python3 update_confluence.py --updated-folders "$UPDATED_FOLDERS" ... script: - pip3 install -r requirements.txt - echo "Initiating full update" - python3 update_confluence.py ...
Последним большим шагом к запуску архитектурного репозитория была доработка предложенного ChatGPT скрипта на Python. По изначальной задумке (и запросу к ИИ) скрипт должен был иерархически сканировать содержимое каталога domain и для каждого подкаталога создавать страничку в Confluence, состоящую из текстового описания в файле desc.txt и диаграммы для него, скомпилированной из файла container_diagram.puml.
Выглядел он так:
import os import requests from requests.auth import HTTPBasicAuth CONFLUENCE_SERVER_URL = os.environ.get("CONFLUENCE_SERVER_URL") CONFLUENCE_API_TOKEN = os.environ.get("CONFLUENCE_API_TOKEN") CONFLUENCE_PARENT_PAGE_TITLE = "Parent Page Title" CONFLUENCE_SPACE_KEY = "SPACE_KEY" CONFLUENCE_CONTENT_TYPE = "application/json" CONFLUENCE_API_PATH = "/rest/api/content" def get_confluence_page_by_title(page_title): url = f"{CONFLUENCE_SERVER_URL}{CONFLUENCE_API_PATH}?title={page_title}&spaceKey={CONFLUENCE_SPACE_KEY}" response = requests.get(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"Content-Type": CONFLUENCE_CONTENT_TYPE}) return response.json()["results"][0] if response.json()["results"] else None def create_confluence_page(parent_page_id, page_title, page_desc, page_diagram): url = f"{CONFLUENCE_SERVER_URL}{CONFLUENCE_API_PATH}" data = { "type": "page", "title": page_title, "ancestors": [{"id": parent_page_id}], "body": { "storage": { "value": page_desc, "representation": "storage" } } } if page_diagram: puml_file = os.path.join(os.path.dirname(__file__), page_diagram) png_file = os.path.splitext(puml_file)[0] + ".png" os.system(f"plantuml -tpng -o{os.path.dirname(__file__)} {puml_file}") with open(png_file, "rb") as f: png_data = f.read() data["body"]["storage"]["value"] += f"<ac:image><ri:attachment ri:filename=\\"{os.path.basename(png_file)}\\" /></ac:image>" response = requests.post(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"Content-Type": CONFLUENCE_CONTENT_TYPE}, json=data) page_id = response.json()["id"] url = f"{CONFLUENCE_SERVER_URL}{CONFLUENCE_API_PATH}/{page_id}/child/attachment" response = requests.post(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"X-Atlassian-Token": "no-check"}, files={"file": (os.path.basename(png_file), png_data, "image/png")}) else: response = requests.post(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"Content-Type": CONFLUENCE_CONTENT_TYPE}, json=data) return response.json()["id"] def update_confluence_page(page_id, page_desc, page_diagram): url = f"{CONFLUENCE_SERVER_URL}{CONFLUENCE_API_PATH}/{page_id}" data = { "id": page_id, "type": "page", "title": page_title, "body": { "storage": { "value": page_desc, "representation": "storage" } } } if page_diagram: puml_file = os.path.join(os.path.dirname(__file__), page_diagram) png_file = os.path.splitext(puml_file)[0] + ".png" os.system(f"plantuml -tpng -o{os.path.dirname(__file__)} {puml_file}") with open(png_file, "rb") as f: png_data = f.read() data["body"]["storage"]["value"] += f"<ac:image><ri:attachment ri:filename=\\"{os.path.basename(png_file)}\\" /></ac:image>" response = requests.put(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"Content-Type": CONFLUENCE_CONTENT_TYPE}, json=data) url = f"{CONFLUENCE_SERVER_URL}{CONFLUENCE_API_PATH}/{page_id}/child/attachment" response = requests.post(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"X-Atlassian-Token": "no-check"}, files={"file": (os.path.basename(png_file), png_data, "image/png")}) else: response = requests.put(url, auth=HTTPBasicAuth("", CONFLUENCE_API_TOKEN), headers={"Content-Type": CONFLUENCE_CONTENT_TYPE}, json=data) return response.json()["id"] def main(): for subdir, _, files in os.walk("domain"): if "desc.txt" not in files: continue page_title = os.path.basename(subdir) page_desc = open(os.path.join(subdir, "desc.txt"), "r").read() page_diagram = None if "container_diagram.puml" in files: page_diagram = os.path.join(subdir, "container_diagram.puml") parent_page = get_confluence_page_by_title(CONFLUENCE_PARENT_PAGE_TITLE) if not parent_page: print(f"Confluence parent page '{CONFLUENCE_PARENT_PAGE_TITLE}' not found.") return existing_page = get_confluence_page_by_title(page_title) if existing_page: update_confluence_page(existing_page["id"], page_desc, page_diagram) else: create_confluence_page(parent_page["id"], page_title, page_desc, page_diagram) if __name__ == "__main__": main()
Впоследствии скрипт был переработан для поддержки следующих возможностей:
Произвольная верстка публикуемых страниц Confluence. На место файла desc.txt пришёл page.html, в котором прописывался XHTML‑контент страницы. Выбор был сделан именно в пользу XHTML ввиду наличия более сложных инструментов вёрстки, возможности встроить Confluence‑макросы, ссылки на другие страницы и диаграммы в любые места на странице.
Любое количество диаграмм на странице. Скрипт парсит контент страницы на предмет наличия макросов вставки файла вида *.png, находит соответствующие им *.puml‑файлы, компилирует их и загружает в Confluence.
А для того чтобы компиляция схем с внутренними зависимостями работала, вызов plantuml был расширен параметром plantuml.include.path, который позволяет выбирать отдельные каталоги в качестве корневых при поиске импортируемых в схемы зависимостей. Параметр PLANTUML_LIMIT_SIZE позволил преодолеть ограничение на максимальный размер диаграммы, с которым мы столкнулись, работая над sequence‑диаграммой одного очень большого и разветвлённого процесса.
command = ['java', '-DPLANTUML_LIMIT_SIZE=8192', '-Dplantuml.include.path="' + os.getcwd() + '"', '-jar', os.getcwd() + '/plantuml.jar', '-stdrpt:1', '-tpng', plantuml_file_path] result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
Итоги и ценности
Процесс работы с репозиторием стал выглядеть так:
Архитектор вносит свои изменения в проект архитектурного репозитория: как правило, это текстовое описание доработки и необходимые схемы в каталоге changes, и, если это находит отражение в компонентных схемах системы, изменения в соответствующих каталогах компонентов системы в my-system.
Архитектор отправляет на ревизию merge request.
Архитектурная команда обсуждает это решение и в итоге принимает его в основную ветку.
Запускается конвейер, который публикует изменения в общую базу знаний для всех заинтересованных участников проекта.
Работа над архитектурным репозиторием, будучи не единственной рабочей задачей, заняла около месяца, однако если исключить его первичное наполнение, то основные принципы и механизмы работы были заложены в течение двух недель.
Две главных ценности, которые он принёс проекту, 一 это улучшенная доступность и наглядность знаний для всех участников, а также возможность отслеживания истории изменений по важным решениям. Кроме того, появилась резервная копия тех самых знаний, которая очень пригодилась в момент, когда в результате сбоя была временно утрачена часть страниц в Confluence. Пока многие были лишены привычного источника информации, адепты архитектурного репозитория продолжали работу как ни в чём не бывало.
Наконец, этот проект позволил лично мне подтянуть некоторые компетенции в части архитектуры, работы с контейнеризацией и CI/CD, пощупать Python и непосредственно узнать о сильных сторонах и ограничениях искусственного интеллекта. Ну и, конечно же, заняться приятным мне делом систематизации и распространения знаний среди коллег.
