Знания как код: архитектурный репозиторий в git на базе PlantUML

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

4502ef6d7e355372ba2cc67af4b35843.png

Большие, долгосрочные проекты неизбежно накапливают в себе энтропию: каждое неучтенное, незадокументированное и «временное» решение постепенно усложняет разрабатываемую систему, знания о ней растворяются среди множества людей и документов и поддерживать ее становится все сложнее. Есть несколько архитектурных инструментов, чтобы замедлить старение проекта и, насколько это возможно, сохранить знания о том, что из себя представляет система сейчас и какая череда изменений к этому привела.

Я бы хотел рассказать об отдельном кейсе внедрения одного такого инструмента 一 самописного архитектурного репозитория, использующего принцип «архитектура как код». Традиционно архитектурная информация хранится в разрозненном виде 一 что‑то в виде документов и UML‑диаграмм, что‑то в корпоративной базе знаний, что‑то в виде опыта и знаний отдельных сотрудников, и нередко эти источники друг другу противоречат. Архитектурный репозиторий 一 это прежде всего «единый источник истины» (от англ. Single Source of Truth), в котором аккумулируется вся актуальная информация в виде текстовых описаний, схем и диаграмм, а также общего глоссария и библиотеки компонентов. Последние позволяют всем участникам проекта использовать единую терминологию и четко понимать, какой компонент или процесс понимается под тем или иным термином.

План реализации и первые шаги

Архитектурные репозитории и подход «архитектура как код» не являются чем‑то новым. Сейчас их использование фактически является стандартом индустрии, и есть готовые специализированные решения, такие как DocHub. В пользу создания собственного решения у меня было несколько аргументов: оно должно было быть максимально прозрачным и понятным в плане контроля данных для компании и интегрированным с существующими GitLab‑репозиторием и Confluence. Дополнительный плюс лично для меня как архитектора 一 возможность подтянуть свои компетенции в области архитектуры и работы с документацией и лучше понять, как подобные инструменты работают изнутри. В качестве базы был выбран PlantUML 一 инструмент создания диаграмм на основе формализованного текстового описания.

Данная активность пришлась на время взлета популярности языковых моделей вроде ChatGPT. Ради эксперимента я задал вопрос ChatGPT: так и так, хочу написать архитектурный репозиторий с PlantUML и интеграцией с Confluence, напиши план. ИИ действительно написал хороший план действий, который помог мне начать погружение в тему. Переписка, увы, не сохранилась. Однако суть была такова:

  1. Создать проект в GitLab и организовать структуру каталогов.

  2. Настроить CI/CD в GitLab и зарегистрировать GitLab Runner.

  3. Написать скрипт для компиляции схем 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

…и получить готовую схему для несложного, созданного из архетипа сервиса. Ее, конечно же, можно обогатить другими компонентами, если это потребуется.

b817d2f5fdc347f1d5c7c989969213bd.png

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()

Впоследствии скрипт был переработан для поддержки следующих возможностей:

  1. Произвольная верстка публикуемых страниц Confluence. На место файла desc.txt пришёл page.html, в котором прописывался XHTML‑контент страницы. Выбор был сделан именно в пользу XHTML ввиду наличия более сложных инструментов вёрстки, возможности встроить Confluence‑макросы, ссылки на другие страницы и диаграммы в любые места на странице.

  2. Любое количество диаграмм на странице. Скрипт парсит контент страницы на предмет наличия макросов вставки файла вида *.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)

Итоги и ценности

Процесс работы с репозиторием стал выглядеть так:

  1. Архитектор вносит свои изменения в проект архитектурного репозитория: как правило, это текстовое описание доработки и необходимые схемы в каталоге changes, и, если это находит отражение в компонентных схемах системы, изменения в соответствующих каталогах компонентов системы в my-system.

  2. Архитектор отправляет на ревизию merge request.

  3. Архитектурная команда обсуждает это решение и в итоге принимает его в основную ветку.

  4. Запускается конвейер, который публикует изменения в общую базу знаний для всех заинтересованных участников проекта.

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

Две главных ценности, которые он принёс проекту, 一 это улучшенная доступность и наглядность знаний для всех участников, а также возможность отслеживания истории изменений по важным решениям. Кроме того, появилась резервная копия тех самых знаний, которая очень пригодилась в момент, когда в результате сбоя была временно утрачена часть страниц в Confluence. Пока многие были лишены привычного источника информации, адепты архитектурного репозитория продолжали работу как ни в чём не бывало.

Наконец, этот проект позволил лично мне подтянуть некоторые компетенции в части архитектуры, работы с контейнеризацией и CI/CD, пощупать Python и непосредственно узнать о сильных сторонах и ограничениях искусственного интеллекта. Ну и, конечно же, заняться приятным мне делом систематизации и распространения знаний среди коллег.

© Habrahabr.ru