ArchUnit против хаоса

от автора

Привет! Я Масгутов Руслан, архитектор в Т-Банке. Одна из моих задач — вести архитектурный надзор по техническим решениям. Проверка структуры проектов при ревью довольно быстро становится скучной рутиной, и появляется желание автоматизировать эту деятельность, чтобы освободить время для более интересных задач.  

Расскажу, как мы используем 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *