Как восстановить архитектуру распределенной системы по Git-репозиториям, конфигурации и исходному коду

Рисунок 1. Как факты из Git-репозиториев, исходного кода и конфигурации превращаются в проверяемую архитектурную модель.
Рисунок 1. Как факты из Git-репозиториев, исходного кода и конфигурации превращаются в проверяемую архитектурную модель.

Это продолжение статьи "Разработчики знали код. Никто не знал систему".

В первой части я рассказал, как задача на развитие подсистемы уперлась в отсутствие актуального и целостного описания ее архитектуры. Документация отражала отдельные функции, специалисты знали свои компоненты, но фактический состав системы, связи между сервисами и граница между старой и новой реализацией оставались неясными. Поэтому следующим источником фактов стали Git-репозитории, конфигурация и исходный код. Ниже - методика, с помощью которой я восстанавливал реальную AS-IS архитектуру.

Определяем состав подсистемы

Перед началом анализа потребовалось получить доступ к корпоративным платформам и подготовить инструменты на рабочем ноутбуке.

Корпоративные платформы

  • Gerrit - система для работы с Git-репозиториями и проведения code review, близкая по назначению к GitHub и GitLab. Использовалась для поиска проектов, просмотра веток, тегов сборок и истории изменений.

  • OpenShift - корпоративная платформа оркестрации контейнеризированных приложений на базе Kubernetes. Использовалась для проверки фактически развернутых сервисов, контейнерных образов и версий сборок.

  • Nexus Repository - внутреннее хранилище программных артефактов, например Java-библиотек, Python-пакетов и других зависимостей, используемых при сборке приложений.

Инструменты на рабочем ноутбуке

  • IntelliJ IDEA - IDE для навигации по Java-коду, поиска реализаций, вызовов, зависимостей и точек входа.

  • Git CLI - командный интерфейс системы контроля версий Git, использовавшийся для клонирования репозиториев и работы с ветками, тегами и историей коммитов.

  • JDK - комплект средств разработки Java, включающий компилятор, среду выполнения и служебные инструменты.

  • Maven - система сборки и управления зависимостями Java-проектов.

Первичная выборка репозиториев

Поиск по слову audit в названиях проектов Gerrit дал первичный список из десяти репозиториев:

audit-library
audit-service
audit-search
audit-cleaner
audit-object-storage
audit-service-copy
audit-service-ydb
audit-enricher
audit-data-verifier
billing-audit-extender

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

  • ветки, теги и историю изменений Git;

  • контейнерные образы фактически запущенных сервисов в production-контуре OpenShift;

  • информацию от коллег, работавших с подсистемой.

Сопоставление production-сборки с репозиторием

Каждый репозиторий клонировался локально, после чего загружались удаленные ветки и теги:

git clone <repository-url>
git fetch --all --tags --prune

Следующим шагом нужно было определить, какому состоянию исходного кода соответствует сборка, развернутая в production. В принятой в компании схеме именования тег контейнерного образа содержал идентификатор сборки в формате build-<version>. Например: audit-service:build-4.1.0.8. В Git-репозитории этой сборке соответствовал тег с тем же номером версии, но с другим разделителем:

OpenShift: build-4.1.0.8
Git:       build/4.1.0.8

Различие в формате не мешало сопоставлению: ключевым идентификатором оставался номер сборки 4.1.0.8. Чтобы посмотреть метаданные Git-тега и commit, на который он указывает, использовалась команда:

git show --no-patch --format="%H | %ad | %s" build/4.1.0.8

Результат:

tag build/4.1.0.8
Tagger: jenkins <jenkins@company.com>

Tag is added automatically by jenkins user
a59f1c4b05df3436ae10e0766fe272210a457753 | Mon Jun 21 15:47:21 2021 +0300 | Fix index creation

Метаданные тега подтвердили еще один элемент процесса сборки: в компании использовался Jenkins. Он участвует в CI/CD-процессе и автоматически создает в Git-репозитории аннотированный тег для готовой сборки (использование Jenkins было ожидаемым для такого процесса, однако для дальнейшего анализа исходного кода этот инструмент значения не имел). Чтобы получить только хеш commit, на который указывал аннотированный тег, использовалась команда:

git rev-list -n 1 build/4.1.0.8
a59f1c4b05df3436ae10e0766fe272210a457753

После этого можно было определить удаленные ветки, содержащие найденный commit:

$commit = git rev-list -n 1 build/4.1.0.8
git branch -r --contains $commit
origin/hotfix/4.1.0
origin/master
origin/release/4.1

Один commit может одновременно находиться в нескольких ветках, например в release/*, hotfix/* и уже объединенной с ними master. Поэтому результат команды не определял единственную актуальную ветку, а показывал линии разработки, в которых присутствовал код production-сборки. Полная цепочка сопоставления выглядела так:

запущенный сервис в OpenShift
-> версия контейнерного образа
-> Git-тег, созданный Jenkins
-> конкретный commit
-> ветки, содержащие commit

Ветки на этом этапе использовались только как дополнительный контекст для понимания истории разработки. Для анализа AS-IS ключевым действием было переключение именно на commit, соответствующий production-сборке:

git checkout <commit-hash>

Это фиксировало репозиторий в том состоянии, из которого была собрана работающая версия сервиса, и исключало риск анализа более свежего кода из master, release/* или hotfix/*, который еще не был развернут в production.

Подтвержденный состав подсистемы

После сопоставления данных из Gerrit, Git и OpenShift, а также проверки информации у коллег, в актуальный состав подсистемы вошли пять компонентов:

Компонент

Фактическая роль

audit-library

Библиотека для подключения аудита к микросервисам через аннотации или прямой вызов методов. Не разворачивается как самостоятельный сервис

audit-service

Legacy-сервис обработки событий аудита и сохранения документов в MongoDB

audit-service-ydb

Новая реализация обработки событий аудита и сохранения данных в YDB

audit-object-storage

Отдельный Kafka consumer, сохраняющий тела HTTP-запросов и ответов в объектное хранилище Hitachi

audit-search

Микросервис поиска по данным аудита

Остальные кандидаты были исключены по совокупности признаков: отсутствие среди работающих production-сервисов + длительное отсутствие изменений. Коллеги подтвердили, что эти компоненты не использовались в актуальной реализации подсистемы.

Код нужно читать по маршруту данных, а не по списку файлов

После определения состава подсистемы следующим шагом стал анализ исходного кода активных компонентов. Все сервисные компоненты были реализованы на Java с использованием Spring Boot - фреймворка для создания Java-приложений, который упрощает конфигурацию и подключение типовой инфраструктуры: REST API, баз данных, Kafka и других интеграций. При этом значительная часть инфраструктурного поведения не реализуется разработчиком вручную. Spring на основе зависимостей, конфигурации и аннотаций создает и связывает компоненты приложения, регистрирует обработчики и подключает инфраструктурные механизмы. Поэтому при анализе Spring-приложения часть фактического поведения нельзя найти в одном явно написанном Java-классе. Его приходится восстанавливать по совокупности исходного кода, аннотаций, конфигурации и зависимостей. Но для анализа кода это даже лучше, так как Spring предоставляет типовые механизмы, по которым можно выполнять поиск и ориентироваться в проекте.

Первым я выбрал audit-service, поскольку он являлся основным сервисом legacy-реализации и отвечал за сохранение событий аудита в MongoDB. На этом сервисе я подробно покажу весь алгоритм вручную. Для остальных компонентов подсистемы тот же подход затем можно применять повторно или автоматизировать.

Последовательно открывать все Java-классы не имеет смысла. Даже в относительно небольшом Spring-приложении значительная часть файлов относится к конфигурации, моделям данных, обработке исключений, интеграционному коду и тестам. Без предварительной модели невозможно определить, какие классы участвуют в основном потоке данных, а какие решают вспомогательные задачи. Поэтому анализ строился от общего устройства приложения к конкретным сценариям его работы (методом дедукции).

README - первый файл, который стоит открыть

Анализ репозитория удобно начинать с README. Он быстро дает первоначальную гипотезу о назначении сервиса, его зависимостях и основных сценариях. При этом README может быть устаревшим или неполным, поэтому найденные в нем утверждения я использовал только как направления дальнейшей проверки по конфигурации и исходному коду. В начале файла были перечислены три зависимости:

Kafka
MongoDB
сервис справочных данных

Из описания следовало, что Kafka использовалась для получения событий аудита и отправки сообщений, которые не удалось обработать. MongoDB служила основным хранилищем. Справочный сервис предоставлял информацию о кодах событий, для которых применялось особое поведение при обработке. Далее в README был описан основной цикл работы:

  1. Сервис получает сообщение аудита из Kafka.

  2. Сообщение проходит проверку.

  3. Некорректное сообщение или ошибка чтения приводят к отправке в DLQ (Dead Letter Queue - очередь или топик для сообщений, которые не удалось обработать. Стандартный механизм в работе асинхронных систем).

  4. Для корректного сообщения проверяется, разрешена ли его обработка согласно справочным данным.

  5. Отключенное событие отправляется в отдельный топик.

  6. Остальные события сохраняются в MongoDB.

  7. При ошибке записи сервис выполнял повторные попытки.

Предварительная схема сервиса выглядела так:

Рисунок 2. Предварительная схема компонентов одного из микросервисов
Рисунок 2. Предварительная схема компонентов одного из микросервисов

В README также был описан механизм: повторная обработка пропущенных сообщений, которая в проекте называлась "донакатом". Сервис предоставлял REST endpoint, принимающий номер Kafka-партиции и список offset. Если не углубляться в устройство Kafka, здесь достаточно понимать две вещи: топик разделяется на partition, а каждое сообщение внутри partition имеет последовательный номер offset. Поэтому сочетание partition + offset позволяет указать конкретную позицию сообщения и позднее прочитать его повторно. После этого указанные сообщения повторно читались из Kafka и проходили обычную проверку перед сохранением. Таким образом, после чтения одного файла уже появилась исходная модель.

Однако считать эту модель подтвержденной было рано. README мог описывать предыдущую версию сервиса, упрощать алгоритм или не учитывать отдельные ветви обработки. Поэтому каждое утверждение из него превращалось в вопрос для проверки:

Предварительный вывод

Что требовалось найти в репозитории

Сервис читает сообщения из Kafka

обработчик сообщений (Kafka listener) из заданного топика

Данные сохраняются в MongoDB

настройки подключения и вызов операции сохранения

Ошибки отправляются в DLQ

обработчик исключений и Kafka producer

События могут быть отключены

проверку справочника и отдельный выходной топик

Используются повторные попытки сохранения в MongoDB

конфигурацию retry и место его вызова

Есть механизм повторной обработки

REST-контроллер и чтение сообщений по partition и offset

Так README выполнил роль карты. Он не доказал устройство сервиса, но позволил определить конкретные точки поиска и не читать репозиторий вслепую. Следующим шагом стало изучение структуры Maven-проекта и зависимостей, которые должны были подтвердить или опровергнуть эту предварительную модель.

Структура репозитория показывает, где искать реализацию

После README я перешел к структуре репозитория. На этом этапе задача была не читать содержимое файлов, а понять, как организован проект и где находится исполняемый код. В упрощенном виде структура audit-service выглядела так:

audit-service
├── pom.xml
├── Readme.md
├── release-notes.md
├── cicd.properties
├── audit-api
│   └── pom.xml
├── audit-impl
│   ├── pom.xml
│   └── src
│       ├── main
│       │   ├── java
│       │   └── resources
│       │       └── application.yml
│       └── test
└── deploy-templates
    └── audit-service.yaml

P.S. В репозитории был и release-notes.md - файл с описанием изменений по релизам. Звучит как идеальный источник для анализа, но на практике он отставал от production-сборки на 129 версий.

Для Java-проектов такая структура довольно типична: корневой pom.xml описывает проект в целом, дочерние каталоги могут представлять отдельные Maven-модули, src/main содержит рабочий код приложения, src/test - тесты, а resources - конфигурацию и другие ресурсы. Первым после структуры я открыл корневой pom.xml. В нем были объявлены два Maven-модуля:

<modules>
    <module>audit-api</module>
    <module>audit-impl</module>
</modules>

Maven-модуль - это отдельная часть проекта со своим pom.xml, которую Maven может собирать как самостоятельный артефакт. Здесь важно различать Maven-модуль и Maven-зависимость. Модуль является частью текущего проекта, а зависимость - уже готовой внешней библиотекой, которую проект использует в своей работе. Такие зависимости перечисляются в pom.xml, после чего Maven находит необходимые артефакты в подключенных репозиториях, в данном случае в корпоративном Nexus, загружает их и добавляет в проект. По названиям audit-api и audit-impl можно было предположить типичное разделение на API-контракт и его реализацию. Под API-контрактом здесь понимаются интерфейсы и модели данных, определяющие, какие операции предоставляет сервис и какие данные принимает и возвращает. Однако названия модулей сами по себе ничего не доказывали, поэтому сначала я закончил изучение корневого pom.xml. В нем также находился параметр:

<deployable.module>audit-impl</deployable.module>

Он указывал, что развертываемым модулем проекта является audit-impl. После этого я перешел в audit-impl/pom.xml, чтобы понять, как именно он собирается и откуда запускается приложение:

<artifactId>audit-impl</artifactId>
<packaging>jar</packaging>

<properties>
    <start-class>com.epam.edp.service.audit.Application</start-class>
</properties>

Эти настройки уже позволяли достаточно уверенно определить исполняемую часть проекта:

audit-impl
-> развертываемый Maven-модуль (собирается в JAR)
-> точка запуска - Application.java

Следовательно, при поиске фактической логики сервиса основное внимание нужно было сосредоточить на:

audit-impl/src/main/java
audit-impl/src/main/resources

Это существенно сокращало область дальнейшего анализа.

Зависимости как указатели на архитектуру

Также audit-impl/pom.xml стал полезным источником зависимостей. Здесь меня интересовали библиотеки, которые могли указывать на внешние взаимодействия и используемую инфраструктуру. Среди них находились:

spring-boot-starter-web
spring-kafka
spring-integration-kafka
spring-boot-starter-data-mongodb
spring-integration-mongodb
spring-cloud-starter-openfeign
lb-mdm-caching

Даже без чтения Java-кода этот набор задавал достаточно конкретные направления поиска. spring-kafka показывал наличие в проекте библиотек для работы с Kafka и давал основание искать Kafka consumer и producer. spring-boot-starter-data-mongodb указывал на использование механизмов Spring Data для работы с MongoDB. spring-cloud-starter-openfeign показывал, что проект поддерживает механизм декларативных HTTP-клиентов Feign, и давал основание искать @FeignClient (к тому, что означает эта аннотация и почему она является полезной точкой поиска, я вернусь далее). Корпоративная библиотека lb-mdm-caching соответствовала описанному в README механизму работы со справочными данными. Сам факт конкретного HTTP-взаимодействия еще требовалось подтвердить в Java-коде. Предварительная модель начала получать техническое подтверждение:

README                 pom.xml
------                 -------

Kafka              -> spring-kafka
MongoDB            -> spring-data-mongodb
справочный сервис  -> OpenFeign + lb-mdm-caching
REST               -> spring-boot-starter-web

При этом наличие зависимости еще не доказывает конкретный поток данных. Поэтому pom.xml я использовал так же, как и README: не как конечный источник архитектурной модели, а как способ определить направления дальнейшего анализа. После README и Maven-файлов уже было понятно, какие части приложения представляют интерес. Но переходить к Java-коду было еще рано. Следующим источником стал application.yml: конфигурация могла раскрыть конкретные имена топиков, параметры подключения к MongoDB и адреса других сервисов.

application.yml - карта внешних связей сервиса

Я перешел к audit-impl/src/main/resources/application.yml. Для Spring Boot это один из ключевых файлов конфигурации приложения. В нем задаются параметры подключения к внешним системам, настройки Kafka, базы данных, HTTP-клиентов и собственные параметры сервиса. Для архитектурного анализа нет смысла разбирать каждую настройку. Размеры Kafka batch, уровни логирования или таймауты могут быть важны при исследовании производительности, но на первом проходе меня интересовали параметры, отвечающие на более простые вопросы:

Как организовано хранение данных в MongoDB, в том числе работа с коллекциями?
Как реализовано взаимодействие с другими сервисами по REST?
Как работает retry-механизм и какие операции он охватывает?
Как обрабатываются ошибки при чтении сообщений из Kafka и записи в MongoDB?
Есть ли интеграции, которые вообще не были упомянуты в README?

Первое подтверждение касалось MongoDB. В конфигурации присутствовало подключение:

spring:
  data:
    mongodb:
      uri: mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONGODB_SERVER}:${MONGODB_PORT}/${MONGODB_DATABASE_NAME}

Таким образом, зависимость spring-data-mongodb, найденная в pom.xml, переставала быть просто признаком наличия библиотеки. Конфигурация показывала, что сервис действительно ожидает подключение к MongoDB. При этом самого адреса базы в репозитории не было. Вместо него использовались переменные:

MONGODB_USERNAME
MONGODB_PASSWORD
MONGODB_SERVER
MONGODB_PORT
MONGODB_DATABASE_NAME

application.yml не обязательно содержит фактическую конфигурацию конкретного контура. Более того, в коммерческой разработке чувствительные данные, как правило, вообще не хранятся в репозитории - даже для dev-среды. В файле остаются структура настроек, значения по умолчанию и ссылки на переменные, а реальные параметры передаются приложению при развертывании через переменные окружения, ConfigMap, Secret или другие механизмы управления конфигурацией. Поэтому в данном случае репозиторий подтверждал сам факт интеграции с MongoDB и показывал, какие параметры необходимы для подключения. При необходимости узнать фактические значения, следует проверять конфигурации развернутого приложения в OpenShift.

Следующий блок конфигурации относился к Kafka. В нем были обнаружены четыре именованных топика:

app:
  kafka:
    topic:
      audit: audit
      audit-dlq: audit.dlq
      audit-excluded: audit.excluded
      reference-update-notification: rsa-mdm-reference_update_notification

Первые три топика соответствовали потокам, уже описанным в README: основной поток событий аудита, DLQ и отдельный поток исключенных событий. А вот reference-update-notification в README вообще не упоминался. Таким образом, анализ application.yml не только подтвердил часть предварительной модели, но и обнаружил еще одну потенциальную интеграцию сервиса, которую требовалось исследовать отдельно. По названию можно было предположить, что топик связан с уведомлениями об изменении справочных данных, но делать окончательный вывод только по конфигурации было рано. Это хороший пример того, почему одного README недостаточно: даже содержательная документация может не отражать все взаимодействия, существующие в фактической реализации.

В конфигурации обнаружился и адрес внутреннего сервиса справочных данных:

service-mdm-data:
  ribbon:
    listOfServers: service-mdm-data:8080

Сам по себе этот параметр еще не показывал, какой REST-метод вызывается и какие данные возвращаются. Но он подтверждал существование исходящего взаимодействия с другим микросервисом.

Еще один фрагмент подтвердил описанный в README механизм повторных попыток записи:

app:
  retry:
    delay: 1000
    max-delay: 30000
    multiplier: 2.0

То есть при ошибке предположительно использовалась экспоненциально увеличивающаяся задержка: первая пауза составляла 1 секунду, затем интервал увеличивался в 2 раза и ограничивался максимальным значением 30 секунд. Эти значения отдельно зафиксированы среди настроек сервиса.

После анализа application.yml предварительную модель уже можно было дополнить конкретными техническими фактами:

Что было известно

Что подтвердила конфигурация

сервис работает с MongoDB

присутствует spring.data.mongodb.uri

сервис читает Kafka

настроен Kafka consumer

есть основной поток аудита

настроен топик audit

ошибки обрабатываются через DLQ

настроен audit-dlq

отключенные события направляются в отдельный Kafka-топик

настроен audit-excluded

предусмотрено обновление справочных данных (но это не точно)

настроен reference-update-notification

используется справочный сервис

указан адрес внутреннего микросервиса

при ошибках записи используется retry

заданы delay, multiplier и max-delay

Точки входа и фактические связи

Точка запуска приложения была известна еще из audit-impl/pom.xml, где в start-class был указан класс Application. После этого для поиска остальных значимых классов я использовал глобальный поиск IntelliJ IDEA - Ctrl+Shift+F (Cmd+Shift+F на macOS). Поисковые ключи следовали из зависимостей, найденных ранее в pom.xml (стандартные механизмы Spring):

spring-kafka                    -> @KafkaListener
spring-boot-starter-web         -> @RestController
spring-cloud-starter-openfeign  -> @FeignClient
spring-data-mongodb             -> MongoTemplate, MongoRepository

Первые три конструкции - аннотации, которые Spring использует для декларативного подключения определенного поведения к классу или методу. Сама аннотация не выполняет эту логику: Spring обнаруживает ее при запуске приложения и на ее основе создает или настраивает необходимую инфраструктуру. Например, @KafkaListener превращает отмеченный метод в обработчик сообщений Kafka, @RestController позволяет Spring зарегистрировать HTTP-обработчики, а @FeignClient - создать реализацию HTTP-клиента для вызова другого сервиса. Поэтому при анализе Spring-проекта аннотации являются важными точками навигации: по ним можно быстро находить входящие и исходящие взаимодействия приложения.

Для MongoDB механизм немного другой: MongoTemplate - класс для непосредственного выполнения операций с MongoDB, а MongoRepository - интерфейс Spring Data, позволяющий работать с MongoDB через готовые операции чтения и записи данных. Поэтому эти конструкции использовались как маркеры мест, где приложение фактически обращается к базе.

Первым я открыл уже найденный через pom.xml класс Application.java:

@SpringBootApplication
@EnableRetry
@EnableFeignClients
@EnableMdmCaching
public class Application {
    ...
}

Он подтвердил включение retry-механизма, Feign-клиентов и корпоративного механизма кэширования справочных данных. Основной входной поток нашелся по @KafkaListener в классе DocumentKafkaListener:

@Service
@RequiredArgsConstructor
public class DocumentKafkaListener {

    private final AuditDocumentService auditDocumentService;
    private final DisabledAuditCodeCacheService disabledAuditCodeCacheService;
    private final ExcludedAuditEventService excludedAuditEventService;

    @KafkaListener(topics = {"#{kafkaTopics.audit}"})
    public void handle(
        @Payload AuditEvent auditEvent,
        @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
        @Header(KafkaHeaders.OFFSET) long offset
    ) {
        ...
    }
}

Это уже напрямую подтверждало, что audit-service потреблял события из Kafka-топика audit, найденного ранее в application.yml. Поиск @RestController привел к AuditApiImpl, где был реализован метод:

@RestController
@RequiredArgsConstructor
public class AuditApiImpl implements AuditApi {

    private final MissedAuditEventService missedAuditEventService;

    @Override
    public List<AuditUploadResult> uploadMissedAuditEvents(
        List<PartitionOffsets> partitionOffsets
    ) {
        ...
    }
}

Так подтвердился описанный в README механизм "донаката" - повторной обработки Kafka-сообщений по partition и offset. REST здесь являлся служебной точкой входа, тогда как основной поток событий поступал через Kafka. Поиск @FeignClient привел к интерфейсу DisabledAuditCodeApiClient:

@FeignClient("service-mdm-data")
public interface DisabledAuditCodeApiClient {

    String MODEL_NAME = "Core";
    String REFERENCE_NAME = "DisabledAuditCode";

    @PostMapping("/" + MODEL_NAME + "/references/" + REFERENCE_NAME + "/search")
    ResponseEntity<List<DisabledAuditCode>> getDisabledAuditCodes(
        @RequestBody ReferenceSearchRequest searchRequest
    );
}

Этот фрагмент уже раскрывал конкретное назначение найденной ранее интеграции. audit-service обращался к service-mdm-data по HTTP и запрашивал справочник DisabledAuditCode через endpoint:

POST /Core/references/DisabledAuditCode/search

В ответ ожидался список объектов DisabledAuditCode. Таким образом, адрес service-mdm-data:8080 из application.yml получил конкретное назначение. Оставалось подтвердить фактическую работу с MongoDB. Из pom.xml уже было известно, что проект использует Spring Data MongoDB, поэтому поиск выполнялся по характерным конструкциям MongoTemplate и MongoRepository.

MongoTemplate привел к AuditDocumentServiceImpl (MongoRepository отсутствовал в проекте):

public class AuditDocumentServiceImpl implements AuditDocumentService {

  private static final String START_SAVE_AUDIT_MESSAGE = "Начало записи AuditDocument с кодом '{}' в MongoDB: id = {}";
  private static final String FINISH_SAVE_AUDIT_MESSAGE = "Конец записи AuditDocument с кодом '{}' в MongoDB: id = {}";

  private static final DateTimeFormatter COLLECTION_NAME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

  private final RetryTemplate retryTemplate;
  private final MongoTemplate mongoTemplate;
  private final AuditDocumentIndexService auditDocumentIndexService;

  public void save(AuditDocument auditDocument) {
    log.info(START_SAVE_AUDIT_MESSAGE, auditDocument.getCode(), auditDocument.getId());
    LocalDateTime auditEventDateTime = LocalDateTime.from(DATETIME_FORMATTER.parse(auditDocument.getDateTime()));
    String collectionName = auditEventDateTime.format(COLLECTION_NAME_FORMATTER);

    auditDocumentIndexService.createIndexForAuditDocumentCollectionIfNecessary(collectionName);

    retryTemplate.execute(arg0 -> mongoTemplate.save(auditDocument, collectionName));
    log.info(FINISH_SAVE_AUDIT_MESSAGE, auditDocument.getCode(), auditDocument.getId());
  }
}

Этот фрагмент подтвердил сразу несколько существенных деталей реализации. Событие сохранялось в MongoDB через MongoTemplate. Имя коллекции вычислялось из даты события в формате yyyy-MM-dd, то есть данные распределялись по дневным коллекциям:

2026-08-05
2026-08-06
2026-08-07
...

Перед сохранением сервис, судя по названию вызываемого метода, вероятно проверял необходимость создания индексов для соответствующей коллекции:

auditDocumentIndexService
        .createIndexForAuditDocumentCollectionIfNecessary(collectionName);

Сама операция записи выполнялась внутри RetryTemplate:

retryTemplate.execute(
        context -> mongoTemplate.save(auditDocument, collectionName)
);

RetryTemplate - компонент Spring Retry, который выполняет переданную ему операцию и при ошибке повторяет ее в соответствии с заданной политикой. Поэтому в данном случае retry охватывал не весь процесс обработки Kafka-сообщения, а конкретно вызов mongoTemplate.save(...). Это связывало Java-код с найденными ранее в application.yml параметрами повторных попыток. К этому моменту основные внешние взаимодействия audit-service были подтверждены непосредственно кодом. Но эта схема все еще показывала только границы сервиса. Она не отвечала на главный вопрос: как полученное из Kafka событие проходит между этими точками. Поэтому дальше я вернулся к DocumentKafkaListener и начал последовательно идти по вызываемым методам, восстанавливая полный маршрут события.

Как выбирались классы для дальнейшего анализа

После нахождения DocumentKafkaListener дальше я уже не искал классы. Каждый следующий шаг следовал из вызовов и зависимостей предыдущего класса (для перехода к реализации класса или интерфейса в IntelliJ IDEA, можно нажать на его название с зажатой клавишей ctrl (cmd)).

В DocumentKafkaListener были четыре интересующие зависимости:

private final AuditDocumentService auditDocumentService;
private final MapperFacade mapperFacade;
private final DisabledAuditCodeCacheService disabledAuditCodeCacheService;
private final ExcludedAuditEventService excludedAuditEventService;

И основной метод сразу показывал, куда двигаться:

if (Objects.isNull(auditEvent.getId())) {
    throw new NullIdException(auditEvent);
}

if (disabledAuditCodeCacheService.isDisabled(auditEvent)) {
    excludedAuditEventService.sendExcludedEvent(auditEvent);
} else {
    AuditDocument auditDocument =
        mapperFacade.map(auditEvent, AuditDocument.class);

    auditDocument.setPartitionId(partition);
    auditDocument.setOffset(offset);

    auditDocumentService.save(auditDocument);
}

Отсюда получилась естественная цепочка анализа:

DocumentKafkaListener
|
+-> DisabledAuditCodeCacheService
|   проверка отключенного кода
|
+-> ExcludedAuditEventService
|   обработка отключенного события
|
+-> AuditDocumentService
    сохранение события

DisabledAuditCodeCacheService был выбран потому, что именно его метод isDisabled() определял ветвление основного потока. Анализ реализации показал, что проверка выполнялась по справочнику DisabledAuditCode с учетом статуса записи и периода ее действия.

@Override
public boolean isDisabled(AuditEvent auditEvent) {
    log.debug("Check if audit event {} is disabled", auditEvent);

    OffsetDateTime auditEventDate = OffsetDateTime.ofInstant(
        Instant.ofEpochMilli(auditEvent.getDateTime()),
        ZoneOffset.UTC
    );

    ReferenceCachingMultiMapView<String, DisabledAuditCode> references =
        referenceCacheService.getReferences(
            DisabledAuditCodeApiClient.MODEL_NAME,
            DisabledAuditCodeApiClient.REFERENCE_NAME,
            DisabledAuditCode.class,
            DisabledAuditCode::getCode
        );

    return references.getReferences()
        .asMap()
        .getOrDefault(
            formatCodeIfNecessary(auditEvent.getCode()),
            emptyList()
        )
        .stream()
        .map(this::formatAuditCode)
        .filter(this::isNotRequired)
      
        // После преобразования и фильтрации записей справочника
        // anyMatch вернет true, если хотя бы для одной записи
        // isAuditCodeDisabled() вернет true.
        .anyMatch(disabledAuditCode ->
            isAuditCodeDisabled(disabledAuditCode, auditEventDate)
        );
}

Ключевой здесь является операция anyMatch: метод isDisabled() возвращает true, если хотя бы одна подходящая запись справочника удовлетворяет условию isAuditCodeDisabled(). Поэтому следующим шагом анализа стал именно этот метод:

private boolean isAuditCodeDisabled(
    DisabledAuditCode disabledAuditCode,
    OffsetDateTime auditEventDate
) {
    return ReferenceStatus.ACTUAL == disabledAuditCode.getStatus()
        && isDateInDisabledRange(disabledAuditCode, auditEventDate);
}

Здесь уже непосредственно видна логика проверки: одной записи в справочнике недостаточно. Она должна иметь статус ACTUAL, а дата события должна попадать в период действия этой записи. Если оба условия выполняются, isAuditCodeDisabled() возвращает true

Далее переходим к ExcludedAuditEventService. Этот класс появился из следующего вызова в DocumentKafkaListener:

excludedAuditEventService.sendExcludedEvent(auditEvent);

Переход к реализации показал конечное действие этой ветви:

kafkaTemplate.send(kafkaTopics.getAuditExcluded(), auditEvent);

То есть именно здесь подтвердилось, что отключенное событие отправлялось в Kafka-топик audit-excluded.

AuditDocumentService был найден аналогично через:

auditDocumentService.save(auditDocument);

Его реализация уже привела к MongoDB, механизму создания индексов и RetryTemplate.

Обработка исключений основного Kafka consumer

Отдельно требовалось понять, что происходит, если сам DocumentKafkaListener завершится с ошибкой. Как например в случае отсутствия идентификатора у события:

if (Objects.isNull(auditEvent.getId())) {
    throw new NullIdException(auditEvent);
}

В методе listener'а этой логики нет:

@KafkaListener(topics = {"#{kafkaTopics.audit}"})
public void handle(...) {
    ...
}

В самом методе обработчика логики работы с ошибками не было. Значит, ее нужно было искать уровнем выше - в инфраструктуре, которую Spring создает для @KafkaListener. Метод, отмеченный @KafkaListener, сам по себе не создает Kafka consumer. Spring помещает его внутрь так называемого listener container - инфраструктурного объекта, который управляет чтением Kafka, режимом подтверждения сообщений, обработкой ошибок и другими параметрами consumer. Настройки такого container задаются через containerFactory. В самой аннотации конкретный containerFactory указан не был (его можно передать как аргумент аналогично topics). Поэтому следующим кандидатом для проверки стал стандартный Spring bean с именем kafkaListenerContainerFactory (Spring bean - это объект приложения, который создается и управляется Spring. В данном случае bean kafkaListenerContainerFactory описывал, как именно должна быть настроена инфраструктура основного Kafka consumer). Поиск этого имени привел к KafkaConfig:

@Bean
public ConcurrentKafkaListenerContainerFactory<String, AuditEvent>
    kafkaListenerContainerFactory(...) {

    ...

    factory.setErrorHandler(
        new AuditKafkaContainerListenerErrorHandler(
            deadLetterQueueService,
            excludedExceptions
        )
    );

    return factory;
}

Именно этот фрагмент объяснил, почему следующим классом стал AuditKafkaContainerListenerErrorHandler: он был непосредственно зарегистрирован как обработчик ошибок Kafka listener'а. Этот класс содержал метод:

@Override
public void handle(
    Exception exception,
    ConsumerRecord<?, ?> consumerRecord,
    Consumer<?, ?> consumer
) {
    try {
        if (exception instanceof DeserializationException) {

            deadLetterQueueService.sendDeserializationException(
                (DeserializationException) exception,
                consumerRecord
            );

        } else if (!CollectionUtils.isEmpty(dlqExcludedExceptions)
            && dlqExcludedExceptions.stream()
                .anyMatch(exceptionClass ->
                    exceptionClass.isAssignableFrom(exception.getClass()))) {

            returnOffsetToOriginalPosition(
                consumerRecord,
                consumer
            );

        } else {

            deadLetterQueueService.sendException(
                exception,
                consumerRecord
            );
        }
    } catch (Exception e) {

        returnOffsetToOriginalPosition(
            consumerRecord,
            consumer
        );
    }
}

Из этого кода уже был виден фактический механизм обработки ошибок. DeserializationException передавался в DeadLetterQueueService отдельно, остальные исключения по умолчанию также направлялись туда же. Но между этими ветками находилась дополнительная проверка dlqExcludedExceptions:

dlqExcludedExceptions.stream()
    .anyMatch(exceptionClass ->
        exceptionClass.isAssignableFrom(exception.getClass()))

Название здесь достаточно говорящее: dlqExcludedExceptions содержал классы исключений, которые не должны были отправляться в DLQ. Оставалось понять, откуда берется этот список.

Возврат в KafkaConfig показал, что коллекция передавалась из конфигурации приложения:

@Value("${app.audit.dlq.excluded.exceptions:}")
Collection<Class<? extends Throwable>> excludedExceptions

То есть список исключений не был жестко задан в Java-коде. Он определялся параметром app.audit.dlq.excluded.exceptions и при запуске приложения преобразовывался Spring в коллекцию классов Throwable. В исследуемой конфигурации в этот список входил:

app:
  audit:
    dlq:
      excluded:
        exceptions:
          - org.apache.kafka.clients.consumer.CommitFailedException

Для исключений из этого списка вместо отправки сообщения в DLQ выполнялся:

returnOffsetToOriginalPosition(
    consumerRecord,
    consumer
);

Реализация метода показывала, что речь идет о возврате Kafka consumer на исходную позицию:

consumer.seek(
    new TopicPartition(
        consumerRecord.topic(),
        consumerRecord.partition()
    ),
    consumerRecord.offset()
);

То есть consumer возвращался к offset текущей записи, благодаря чему она могла быть прочитана повторно. Отдельно код показывал еще один случай такого поведения: если сама отправка сообщения в DLQ завершалась исключением, управление попадало в catch, где также выполнялся returnOffsetToOriginalPosition. Таким образом, обработчик разделял ошибки на два маршрута: большинство ошибок направлялось в audit-dlq, а исключения, явно перечисленные в dlqExcludedExceptions, не отправлялись в DLQ и приводили к возврату consumer на позицию текущего сообщения. Тот же механизм использовался как резервный сценарий при ошибке отправки в DLQ.

Подготовка MongoDB-коллекции и повторные попытки записи

В AuditDocumentService были найдены два следующих вызова:

auditDocumentIndexService
    .createIndexForAuditDocumentCollectionIfNecessary(collectionName);

retryTemplate.execute(
    context -> mongoTemplate.save(auditDocument, collectionName)
);

Первый вызов привел к AuditDocumentIndexService, потому что требовалось понять, что именно происходит перед записью в MongoDB. Второй поставил следующий вопрос: как настроен RetryTemplate и сколько попыток выполняется. Поиск места создания bean RetryTemplate привел к SpringRetryConfig:

retryTemplate.setRetryPolicy(new AlwaysRetryPolicy());
retryTemplate.setBackOffPolicy(exponentialBackOffPolicy());

Это оказалось существеннее самих значений из application.yml. AlwaysRetryPolicy означал отсутствие фиксированного максимального числа попыток.

Параметры:

delay: 1000
max-delay: 30000
multiplier: 2.0

задавали интервалы:

1 c
2 c
4 c
8 c
16 c
30 c
30 c
...

То есть после достижения максимальной задержки повторные попытки не прекращались, а продолжались с интервалом 30 секунд. По одному application.yml было видно только наличие retry и параметры задержки. Только переход к SpringRetryConfig позволил определить реальную политику повторных попыток.

Обновление справочника отключенных событий

После последовательного прохода по DocumentKafkaListener, вызываемым им сервисам, Kafka-конфигурации и механизму сохранения основная цепочка обработки события была практически восстановлена. Оставался один элемент, найденный еще в application.yml, но пока не встроенный в общую картину - Kafka-топик reference-update-notification. Напомню, в application.yml были обнаружены четыре Kafka-топика:

audit
audit-dlq
audit-excluded
reference-update-notification

Назначение первых трех к этому моменту уже было подтверждено кодом. audit являлся основным входным топиком, audit-excluded использовался для отключенных событий, а audit-dlq - для сообщений, обработка которых завершилась ошибкой. reference-update-notification отличался от них тем, что не относился непосредственно к потоку событий аудита. В Application.java ранее была найдена аннотация @EnableMdmCaching, а среди Maven-зависимостей присутствовала корпоративная библиотека lb-mdm-caching. При этом DisabledAuditCodeCacheService, используемый непосредственно из DocumentKafkaListener, обращался не к REST-клиенту напрямую, а к ReferenceCacheService:

referenceCacheService.getReferences(
    MODEL_NAME,
    REFERENCE_NAME,
    DisabledAuditCode.class,
    DisabledAuditCode::getCode
);

То есть при обработке каждого события сервис использовал локально доступное представление справочника DisabledAuditCode, а не выполнял отдельный HTTP-запрос в service-mdm-data. Назначение обнаруженного Kafka-топика стало понятнее после возвращения к KafkaConfig. Помимо основной фабрики consumer'а там существовала еще одна:

@Bean
public ConcurrentKafkaListenerContainerFactory<String, ReferenceUpdateNotification>
    referenceKafkaListenerContainerFactory(...) {

    ConcurrentKafkaListenerContainerFactory<String, ReferenceUpdateNotification> factory =
        new ConcurrentKafkaListenerContainerFactory<>();

    factory.setConsumerFactory(
        consumerFactory(kafkaProperties, objectMapper)
    );

    return factory;
}

Для нее отдельно задавался тип сообщения: ReferenceUpdateNotification и собственная consumer group:

consumerProperties.put(
    ConsumerConfig.GROUP_ID_CONFIG,
    "audit-reference-notification-group"
);

Таким образом, KafkaConfig содержал уже не один, а два разных Kafka consumer-контекста:

AuditEvent
-> основной поток событий аудита

ReferenceUpdateNotification
-> уведомления об изменении справочных данных

Конфигурация приложения дополнительно связывала механизм кэширования с топиком обновлений:

mdm.kafka.consumer.referenceUpdateNotificationPattern
-> reference-update-notification

Таким образом из найденных фактов собиралась целостная картина: при запуске audit-service загружал в кэш необходимые записи DisabledAuditCode (поскольку логика первичной загрузки находилась внутри lb-mdm-caching, этот этап я дополнительно проверил по реализации библиотеки), а дальнейшее обновление кэша происходило через Kafka-топик, заданный параметром app.kafka.topic.reference-update-notification. Получалась отдельная инфраструктурная цепочка:

service-mdm-data
-> справочник DisabledAuditCode
-> локальный MDM cache audit-service

изменение справочника
-> Kafka reference-update-notification
-> механизм lb-mdm-caching
-> актуализация локального кэша

Таким образом было обнаружено, что основной поток обработки события не обращается синхронно к service-mdm-data для каждого сообщения из Kafka. Проверка:

disabledAuditCodeCacheService.isDisabled(auditEvent)

выполнялась через кэш справочника, актуальность которого поддерживалась отдельным Kafka-потоком. Это также объясняло, почему поиск только по @KafkaListener внутри бизнес-кода audit-service не дал отдельного очевидного listener'а для справочных обновлений. Часть поведения предоставлялась подключенной инфраструктурной библиотекой lb-mdm-caching, активированной через @EnableMdmCaching. В самом сервисе находились конфигурация consumer'а и точки использования кэша.

Служебный вход для повторной обработки

Кроме обычного Kafka consumer в сервисе оставался найденный ранее механизм "донаката". В AuditApiImpl был реализован метод uploadMissedAuditEvents(...), принимающий данные о partition и необходимых offset. При этом сам REST endpoint непосредственно в проекте не был объявлен. AuditApiImpl реализовывал API-контракт, структура которого подтягивалась из внешней зависимости. Поэтому по исходному коду сервиса можно было подтвердить наличие метода и его дальнейшую обработку, но не определить HTTP path непосредственно из класса реализации. Сам endpoint удалось подтвердить по тестам:

mockMvc.perform(post("/upload")
        .contentType("application/json")
        .content(objectMapper.writeValueAsString(partitionOffsets)))
    .andDo(print())
    .andExpect(status().isOk())
    .andExpect(content().json("[]"));

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

Таким образом, служебной точкой входа являлся: POST /upload. После получения запроса MissedAuditEventService самостоятельно считывал указанные сообщения Kafka. В KafkaConfig для этого создавался отдельный consumer:

consumerProperties.put(
    ConsumerConfig.GROUP_ID_CONFIG,
    "audit-upload-missed"
);

После чтения пропущенное событие передавалось в тот же DocumentKafkaListener. То есть механизм "донаката" не создавал отдельную бизнес-логику сохранения.

P.S. Позже я все-таки разрешил внешние зависимости проекта через Maven и получил доступ к API-контракту, который не находился непосредственно в исходниках audit-service. Это позволило проверить уже найденную по тестам гипотезу и окончательно подтвердить, что метод uploadMissedAuditEvents(...) действительно соответствует endpoint POST /upload.

Итоговая модель обработки audit-service

После этого первоначальную схему из README можно было заменить уже подтвержденной последовательностью взаимодействий. Она включает не только основной путь Kafka -> MongoDB, но и справочные данные, исключенные события, DLQ, повторные попытки и механизм повторной обработки.

Итоговый сценарий удобно зафиксировать в виде диаграммы последовательности (sequence diagram). Она показывает участников взаимодействия по горизонтали и сами взаимодействия сверху вниз во времени, и позволяет увидеть не только основные связи между компонентами, но и порядок сообщений, альтернативные ветви обработки, retry и обработку ошибок.

Рисунок 3. Итоговая sequence-диаграмма обработки событий в audit-service, восстановленная по исходному коду и конфигурации
Рисунок 3. Итоговая sequence-диаграмма обработки событий в audit-service, восстановленная по исходному коду и конфигурации

Sequence-диаграмма показывает поведение системы во времени, но для итогового представления архитектуры полезно зафиксировать и статическую структуру взаимодействий. На основе восстановленного сценария я сформировал C4-диаграмму компонентов уровня C3, показывающую логические компоненты audit-service и основные внешние системы, с которыми они взаимодействуют. В отличие от sequence-диаграммы, здесь не показывается последовательность вызовов - схема отвечает на вопросы, из каких компонентов состоит рассматриваемый контур и как они взаимодействуют.

Рисунок 4. Компонентная схема audit-service и его внешних взаимодействий, восстановленная по исходному коду и конфигурации
Рисунок 4. Компонентная схема audit-service и его внешних взаимодействий, восстановленная по исходному коду и конфигурации

Итог

В начале анализа у меня был набор репозиториев, частично актуальная документация и общее понимание назначения подсистемы. Этого было недостаточно, чтобы уверенно описать ее AS-IS архитектуру.

Последовательный анализ позволил перейти от предположений к проверяемой модели:

production-сервис
-> версия сборки
-> Git-тег
-> конкретный commit
-> README и структура проекта
-> Maven-зависимости
-> конфигурация
-> точки входа
-> цепочки вызовов
-> внешние взаимодействия
-> фактический маршрут данных

На примере audit-service удалось подтвердить не только очевидный поток Kafka -> MongoDB, но и детали, которые не были видны из первоначального описания: использование дневных MongoDB-коллекций, отдельный механизм подготовки индексов для дневных коллекций, отдельный поток отключенных событий, DLQ, несколько механизмов повторной обработки, работу со справочником через локальный кэш и отдельный Kafka-поток его актуализации, а также служебный механизм повторного чтения сообщений по partition и offset.

Главный вывод здесь достаточно простой: исходный код не нужно читать целиком. Для системного аналитика он является еще одним источником фактов о системе. Если двигаться от точки входа по зависимостям и вызовам, постоянно сопоставляя найденное с конфигурацией и развернутой версией приложения, даже незнакомый Spring-проект постепенно превращается из набора классов в понятную архитектурную модель.

При этом первый сервис я разбирал практически вручную. Это было необходимо: прежде чем что-либо автоматизировать, нужно было понять, что именно искать в репозитории, какие находки действительно имеют архитектурное значение и каким выводам можно доверять.

На следующих репозиториях повторять тот же процесс вручную уже не имело смысла.

Что дальше

После первого разбора у меня фактически появился алгоритм анализа:

определить production-версию
-> найти нужный commit
-> определить структуру проекта
-> извлечь зависимости и конфигурацию
-> найти точки входа
-> построить цепочки вызовов
-> определить внешние интеграции
-> собрать потоки данных

Следующим шагом было превратить этот алгоритм в инструмент.

В следующей статье расскажу, как я начал автоматизировать анализ остальных репозиториев: от определения правильной ветки и commit до программного извлечения структуры Java-проекта, точек входа, Kafka-взаимодействий, REST-клиентов, хранилищ и связей между классами. Как мне помог в этом Python. И главное - где автоматический анализ действительно экономит время, а где без ручной проверки системного аналитика он начинает уверенно строить неправильную архитектуру.