
Привет! Я Масгутов Руслан, архитектор в Т-Банке. Одна из моих задач — вести архитектурный надзор по техническим решениям. Проверка структуры проектов при ревью довольно быстро становится скучной рутиной, и появляется желание автоматизировать эту деятельность, чтобы освободить время для более интересных задач.
Расскажу, как мы используем ArchUnit для автоматизации архитектурного контроля. Покажу, как мы обернули правила в Gradle-плагин, встроили их в CI/CD, боремся с архитектурными отклонениями до того, как они попадают в pull request, и расскажу о возможности сбора архитектурных метрик.
Что такое архитектурная чистота
Архитектурная чистота проекта — то, о чем все договариваются в начале, но что почти неизбежно начинает разрушаться с ростом команды и числа сервисов. Мы это проходили: количество сервисов кратно возрастает, и уже не хватает времени на надлежащий контроль.
Так возникают архитектурные отклонения — мелкие (а иногда и не очень) нарушения принципов, которые накапливаются и со временем приводят к деградации архитектуры:
-
границы между слоями размываются;
-
появляется нежеланная связность;
-
усложняется сопровождение;
-
команда начинает бояться «ломать старое».
Часто отклонения не злонамеренны: кто-то просто хотел быстрее закрыть таску, кто-то не знал правил, кто-то «временно» сделал как проще. И это нормально, пока таких «временных» решений не становится сотни.
Мы пробовали держать архитектуру на ревью, митингах и вики-страницах. Это работало — до тех пор, пока проектов не стало слишком много.
Причины, по которым ручной подход не выдержал масштаб:
-
разработчики забывают, путаются или просто не знают правил;
-
архитекторы физически не успевают смотреть каждый PR;
-
ошибки обнаруживаются, когда уже все срослось и переписывать больно.
На этом фоне мы начали искать способ автоматизировать архитектурный контроль — и нашли ArchUnit.
Масштабы нашей архитектуры
У нас в отделе 10 команд разработки, каждая отвечает за свои сервисы и бизнес-домены. В активной разработке более 20 проектов на Kotlin, часть из них — микросервисы, часть — внутренние библиотеки.
Каждая команда имеет определенную степень автономии, но мы придерживаемся общих принципов по архитектуре:
-
Принципы DDD (Domain-Driven Design): четкие границы контекстов.
-
Слоистая архитектура (Layered Architecture): зависимости направлены сверху вниз.
-
Single Responsibility Principle (SRP): каждый класс и модуль отвечает за одну зону ответственности, контроллеры не содержат бизнес-логики, сервисы работают с данными не напрямую, а через репозитории.
Общая специфика проектов:
-
использование фреймворков Spring Boot, Ktor или Camunda;
-
обработка OLTP-нагрузки, асинхронных событий или задач;
-
использование REST или Kafka для взаимодействия между сервисами;
-
есть единые внутренние библиотеки, которые используются для логирования, сбора метрик и трассировки.
Кроме архитектурных принципов у нас было описано соглашение о структуре проектов в виде отдельной wiki-страницы. В ней фиксировались правила разметки слоев, структура пакетов, общие подходы к зависимостям между слоями и примеры для команд.
Документ стал основой для формализации правил в ArchUnit — многие проверки напрямую отражают договоренности, зафиксированные в wiki.
Мы искали способ централизовать архитектурные правила, сделать их понятными и проверяемыми. Мы хотели ловить отклонения еще до того, как они попадут в main ветку, облегчить погружение новых разработчиков в проект и переход существующих между проектами.
Важно было достигнуть наших целей без дополнительной когнитивной нагрузки на команды и усложнения процессов через ADR или контроль со стороны архитектора.
Решением стал ArchUnit с оберткой в виде отдельного модуля с правилами, который можно подключать в проекты.
Основы ArchUnit
ArchUnit — это не отдельный инструмент или фреймворк, а Java-библиотека. Ее можно подключить в проект как обычную зависимость и использовать прямо внутри JUnit-тестов для проверки архитектурных ограничений.
Идея проста: мы описываем правила в виде кода, а библиотека проверяет, нарушены ли они в проекте. Это что-то вроде линтера, но не для кода, а для структуры проекта.
ArchUnit анализирует байткод и строит модель зависимостей классов, после чего позволяет выполнять над ней проверки. Примеры проверок:
-
какие пакеты импортируют какие;
-
какие аннотации используются;
-
какие классы вызывают или наследуют другие;
-
нет ли циклов, нарушений слоев и тому подобного.
Самое главное: все правила формулируются декларативно, в виде читаемого кода. Это значит, что архитектурные договоренности можно выразить явно и протестировать.
Примеры простых правил.
Запрет на использование java.util.logging:
@ArchTest static final ArchRule noJavaUtilLogging = noClasses() .should() .accessClassesThat() .resideInAPackage("java.util.logging") .because("мы используем SLF4J вместо java.util.logging");
Слой бизнес-логики должен быть доступен из слоя контроллеров и бизнес-логики:
@ArchTest static final ArchRule uiDoesNotDependOnDomain = classes() .that().resideInAPackage("..service..") .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
Классы не должны находиться вне разрешенных пакетов:
@ArchTest static final ArchRule classesShouldBeInAllowedPackages = classes() .should().resideInAnyPackage("..domain..", "..application..", "..infrastructure..") .because("мы придерживаемся слоистой архитектуры");
ArchUnit отлично дружит с JUnit 5 (и 4), что позволяет подключить правила в стандартные тестовые сборки проекта:
@AnalyzeClasses(packages = "com.example.myapp") public class ArchitectureTest { @ArchTest static final ArchRule rule = classes() .that().resideInAPackage("..service..") .should().onlyBeAccessed().byAnyPackage("..controller..", "..service.."); }
Организация архитектурных правил
Одно дело — писать архитектурные тесты, другое — поддерживать их единообразно во всех проектах. Если в работе больше двух сервисов, быстро становится понятно, что копировать одинаковые правила в каждый репозиторий — не вариант.
Мы пошли по пути вынесения правил в отдельный Gradle-плагин. Это позволило централизовать архитектурную логику и гибко подключать ее в нужных проектах.
Все архитектурные правила оформлены в виде внутреннего Gradle-плагина arch-checker, который предоставляет набор готовых задач, подключает зависимости ArchUnit и базовую конфигурацию, позволяет переопределять часть проверок на уровне проекта.
Плагин легко подключается:
plugins { id("ru.tbank.arch-checker") version "1.2.0" }
Мы реализовали набор собственных архитектурных правил, оформленных как таски внутри Gradle-плагина. Покажу реализацию двух проверо: «использование ограниченного набора пакетов» и «вызовы между слоями».
Для этого нужно реализовать классы:
-
имплемент для запуска правил из gradle;
-
с самим архитектурным правилом;
-
с особым условием проверки, если стандартных возможностей недостаточно.
Класс ProjectStructureValidatorTask, реализующий SourceTask и VerificationTask, позволяет легко конфигурировать, какие классы и пакеты будут проанализированы ArchUnit, и дает возможность встроить архитектурные проверки в стандартный жизненный цикл Gradle. Это обеспечивает чистую и прозрачную интеграцию с остальными этапами сборки, позволяет использовать зависимости (dependsOn) от других задач и стандартные возможности Gradle для кэширования и инкрементальной сборки.
Каждый проект может конфигурировать правила через заложенную параметризацию. Это особенно важно в тех проектах, где архитектура уже отличалась от принятого соглашения и инвестировать для исправления ситуации нецелесообразно. Мы предусмотрели механизмы:
-
фильтрация слоев — например, исключить конкретный пакет, который «исторически» появился);
-
задание корневого пакета — когда в проекте присутствует несколько пакетов, не выделенных в модули gradle.
/** * Таска проверки проекта на соответствие соглашениям по структуре. */ abstract class ProjectStructureValidatorTask : SourceTask(), VerificationTask { @InputFiles @SkipWhenEmpty @IgnoreEmptyDirectories @PathSensitive(PathSensitivity.RELATIVE) override fun getSource() = super.getSource() /** * Корневые пакеты проекта, от которых начинается разделение по слоям. * Всегда должен быть один пакет. Но в целях обратной совместимости изменений поддерживаются несколько. */ @Input lateinit var projectRootPackages: List<String> /** * RegExp для исключения пакетов из проверки. * Сделано для обратной совместимости подключения плагина. * */ @Input lateinit var excludePattern: List<String> @TaskAction fun runArchTests() { val javaClasses = importJavaClasses(source, excludePattern) listOf(PackagesStructureArchRule(projectRootPackages), LayeredArchitectureArchRule(projectRootPackages)) .map { it.evaluate(javaClasses) } .filter { it.hasViolation() } .takeIf { it.isNotEmpty() } ?.map { it.failureReport } ?.joinToString(separator = "\n\r") { it.toString() } ?.also { throw GradleException(ARCH_CHECKER_ERROR_MESSAGE_PREFIX + "\n\r" + it) } } companion object { private const val ARCH_CHECKER_ERROR_MESSAGE_PREFIX = "ArchChecker reported architecture failures: " /** * Импорт классов из директории с учетом exclude конфигурации. * */ private fun importJavaClasses(source: FileTree, excludePackages: List<String>): JavaClasses? { val excludePatterns = excludePackages.map { ".*$it.*" }.map { Pattern.compile(it) } return ClassFileImporter() .withImportOption { location -> excludePatterns.none { location.matches(it) } } .importPaths(source.files.map { it.toPath() }) } } }
Немного теории про используемые Gradle-классы:
org.gradle.api.tasks.SourceTask — базовый класс Gradle для задач, которые работают с исходным кодом или исходными файлами проекта.
На org.gradle.api.tasks.SourceTask возложена ответственность:
-
за получение набора исходных файлов (например,
.class-файлы,.java,.kt); -
конфигурацию путей к исходникам или классам через свойства задачи;
-
удобную обработку и фильтрацию файлов в рамках Gradle-пайплайна.
org.gradle.api.tasks.VerificationTask — интерфейс, который маркирует задачи как задачи верификации. Задачи, реализующие интерфейс, обладают несколькими преимуществами:
-
их легко интегрировать в цепочку сборки, например в
checkилиverify: Gradle автоматически запускает такие задачи как часть процесса валидации; -
они обычно завершаются с ошибкой, если проверка не пройдена, что останавливает билды в CI/CD;
-
позволяют разделять задачи на «проверяющие» и «генерирующие».
Класс LayeredArchitectureArchRule, реализующий интерфейс com.tngtech.archunit.lang.ArchRule, представляет само архитектурное правило. В нашем случае проверка структуры описывается через два правила: LayeredArchitectureArchRule и PackagesStructureArchRule.
LayeredArchitectureArchRule проверяет вызовы классов между слоев. Для реализации пришлось объединить пакеты в слои. В нашем случае для удобства задаются четыре слоя, что отходит от общепринятого разделения:
-
in interaction пакеты, в которых есть реализация контроллеров, консюмеров кафки и делегатов камунды;
-
logic — пакеты с классами, реализующие бизнес-логику;
-
persistence — пакеты, в которых содержатся классы по взаимодействию с БД;
-
out interaction — пакеты с классами rest-клиентов и продюсеров событий в кафку.
/** * Проверка взаимодействия слоев приложения. */ class LayeredArchitectureArchRule private constructor( private val rule: ArchRule ) : ArchRule by rule { /** * Проверка доступа классов между слоями приложения. * Входные точки (контроллеры, листенеры, джобы, делегаты и т. д.) -> бизнес-логика -> выходные точки (БД || внешнее взаимодействие) */ constructor(rootPackages: List<String>) : this(buildLayeredArchitecture(rootPackages)) companion object { private const val IN_INTERACTION_LAYER_NAME = "inInteraction" private const val LOGIC_LAYER_NAME = "logic" private const val PERSISTENCE_LAYER_NAME = "persistence" private const val OUT_INTERACTION_LAYER_NAME = "outInteraction" @SuppressWarnings("SpreadOperator") fun buildLayeredArchitecture(rootPackages: List<String>) = Architectures.layeredArchitecture().consideringOnlyDependenciesInLayers() .layer(IN_INTERACTION_LAYER_NAME).definedBy( *listOf("route", "controller", "job", "listener", "delegate").packagesSetOf(rootPackages) .toTypedArray() ) .layer(LOGIC_LAYER_NAME).definedBy( *listOf("service", "processor", "component").packagesSetOf(rootPackages).toTypedArray() ) .optionalLayer(PERSISTENCE_LAYER_NAME).definedBy( *listOf("repository", "dao").packagesSetOf(rootPackages).toTypedArray() ) .optionalLayer(OUT_INTERACTION_LAYER_NAME).definedBy( *listOf("client", "producer").packagesSetOf(rootPackages).toTypedArray() ) .whereLayer(IN_INTERACTION_LAYER_NAME).mayNotBeAccessedByAnyLayer() .whereLayer(LOGIC_LAYER_NAME).mayOnlyBeAccessedByLayers(IN_INTERACTION_LAYER_NAME) .whereLayer(OUT_INTERACTION_LAYER_NAME).mayOnlyBeAccessedByLayers( LOGIC_LAYER_NAME ) .whereLayer(PERSISTENCE_LAYER_NAME).mayOnlyBeAccessedByLayers( LOGIC_LAYER_NAME )!! } }
Правило PackagesStructureArchRule проверяет, что классы находятся в разрешенных пакетах
/** * Проверка структуры пакетов в проекте. */ @Suppress("unused") class PackagesStructureArchRule private constructor(private val rule: ArchRule) : ArchRule by rule { constructor(projectRootPackages: List<String>) : this( ArchRuleDefinition.classes() .should(PackagesStructureArchCondition(ROOT_PACKAGES.packagesSetOf(projectRootPackages)))!! ) companion object { /** * Список возможных пакетов для разделения по слоям. */ private val ROOT_PACKAGES = listOf( "route", "controller", "job", "listener", "delegate", "service", "processor", "component", "repository", "dao", "client", "producer", "config", "extension", "exception", "dto", "entity", "enum", "property", ) } }
В правиле PackagesStructureArchRule встроенных возможностей ArchUnit’а оказалось недостаточно, но в библиотеке заложена возможность расширения проверки через реализацию com.tngtech.archunit.lang.ArchCondition — и это наш третий класс.
/** * Условие проверяет пакеты в корне модуля на соответствие разрешенным пакетам. */ class PackagesStructureArchCondition( private val definedPackages: List<String>, ) : ArchCondition<JavaClass>("be in certain root packages") { /** * Проверяем для каждого класса начало full qualified имени. * При проверке класса учитываются пакеты-исключения. */ override fun check(item: JavaClass, events: ConditionEvents) { val isDefinedPackage = definedPackages.any { item.fullName.startsWith("$it.") } if (!isDefinedPackage) { events.add( SimpleConditionEvent.violated( item, "${item.fullName} расположен не в определенном списке пакетов: ${definedPackages.joinToString()}" ) ) } } }
В итоге мы получили плагин с такой структурой:
arch-checker/ ├── src/ │ └── main/ │ └── kotlin/ │ └── ru/tbank/archchecker/ │ ├── ArchCheckerPlugin.kt │ ├── condition/ -- содержит сложные условия проверки │ │ └── PackagesStructureArchCondition.kt │ ├── extenstion/ -- функции-утилиты │ │ └── StringExtension.kt │ ├── rule/ -- проверяемые правила │ │ ├── LayeredArchitectureArchRule.kt │ │ ├── ... │ │ └── PackagesStructureArchRule.kt │ └── task/ -- обертки для вызова правил через gradle task api │ ├── ProjectStructureValidatorTask.kt │ └── ...
Архитектурные проверки стали таким же стандартом, как линтеры или тесты. При этом подход с Gradle-плагином дал нам:
-
единый источник архитектурной истины;
-
прозрачную и версионируемую систему правил;
-
гибкость при кастомизации без потери поддержки.
Подводные камни
Как и с любой архитектурной инициативой, внедрение ArchUnit не обошлось без граблей. Вот список проблем, которых мы ожидали на входе или столкнулись в процессе внедрения подхода.
Проблема: сопротивление команд.
> «Зачем еще один слой тестов?»
> «У нас и так все нормально, мы пишем по совести»
Многие разработчики сначала воспринимают архитектурные проверки как избыточный контроль. Особенно если правила навязываются централизованно.
Решение: мы включили команды в процесс формулирования правил еще до автоматизации. Проверки стали ожидаемы и имели мотивацию, исходящую от команд разработки.
Проблема: хрупкие правила. Некоторые ArchUnit-правила могут разбиваться при незначительных изменениях, особенно если:
-
проект нестабилен;
-
в именовании или структуре нет конвенций;
-
проверка завязана на «магические строки» пакетов.
Решение: мы добавляли кастомные фильтры для исключений и писали понятную ошибку в каждое правило — объяснение очень помогает при падении. А еще качественно документировали реализацию правил.
Проблема: сложности с Kotlin. Хотя ArchUnit совместим с Kotlin, бывают нюансы:
-
некоторые зависимости не видны, особенно при использовании
inlineиreified; -
kotlin-модули компилируются позже, и
compileKotlinнадо явно указывать какdependsOn; -
extension-функции при компиляции становятся классами.
Решение:
-
настроили Gradle так, чтобы ArchUnit запускался после полной компиляции всех kotlin-классов;
-
учли в правилах соответствующие пакеты с extension-функциями.
Проблема: исходный набор исключений. При внедрении проще «временно замьютить» проверку, чем ее чинить. В итоге можно случайно замаскировать архитектурный долг.
Решение: при внедрении в существующие проекты исправляли исключения там, где возможно было сделать через минимальные инвестиции.
Эффект от внедрения
Использование ArchUnit оказывает ощутимое влияние на процессы в команде и архитектуру проектов, особенно в условиях роста, множества сервисов и распределенной разработки.
Архитектурные нарушения отлавливаются на раннем этапе. Большинство типовых ошибок — проброс сущностей через слои, случайные зависимости от инфраструктуры, обход сервисного слоя — автоматически ловятся еще до code review. Это разгружает ревьюеров и экономит время.
Новые проекты стартуют на четком архитектурном каркасе. Даже если сервис еще MVP, архитектурные правила дают ему «скелет». Команда с первых дней работает в четко очерченных границах: домен не знает про UI, инфраструктура не «протекает» в ядро и каждый слой отвечает за свое.
Меньше ручной рутины, больше автоматизации. Архитектурный контроль превращается в обычную часть пайплайна. ArchUnit проверяет правила так же, как линтер — стиль, а тесты — корректность. Архитектор перестает быть «сторожем входа» и больше фокусируется на эволюции системы, а не на микроконтроле.
Заключение
ArchUnit — это не про запреты, а про помощь в поддержании архитектурного порядка. Он усиливает проектную дисциплину, помогает держать архитектурную рамку и делает структуру кода предсказуемой.
Правила можно формализовать и делиться ими между проектами, а автоматизация позволяет масштабировать архитектурные подходы на десятки команд.
Инструмент требует настройки, времени и аккуратного внедрения. Но чем раньше он становится частью процессов, тем меньше хаоса копится в коде.
ArchUnit не серебряная пуля, но отличный повод перестать надеяться на «само не развалится».
Архитектурный контроль — не только правила вроде «Controller не должен знать про Repository». Иногда важно смотреть в корень системной сложности, измеряя, насколько структура проекта способствует или препятствует изменениям. Сейчас мы в процессе использования ArchUnit как инструмента для анализа архитектурных метрик, которые библиотека представляет «из коробки»:
-
Cumulative Dependency Metrics (John Lakos);
-
Component Dependency Metrics (Robert C. Martin);
-
Visibility Metrics (Herbert Dowalil).
Если вы уже используете ArchUnit — делитесь своими правилами, подходами и находками.
Если только присматриваетесь — начните с малого, и эффект не заставит себя ждать.
ссылка на оригинал статьи https://habr.com/ru/articles/940766/
Добавить комментарий