Знания как код: архитектурный репозиторий в git на базе PlantUML
Привет, Хабр! Меня зовут Максим Приходский, я архитектор 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: ""
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" "
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" "
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 и непосредственно узнать о сильных сторонах и ограничениях искусственного интеллекта. Ну и, конечно же, заняться приятным мне делом систематизации и распространения знаний среди коллег.