Введение
В Netflix мы работаем по стратегии polyrepo — у нас десятки тысяч Java-репозиториев. Это означает, что нам нужны способы совместно использовать общую логику сборки между этими репозиториями. В команде JVM Ecosystem внутри Java Platform мы разрабатываем инструменты — например, набор Gradle-плагинов под названием Nebula с целью стандартизировать сборку проектов, поддерживать зависимости в актуальном состоянии и надежно публиковать артефакты по всей Java-экосистеме. Наша миссия также включает предоставление разработчику обратной связи на этапе сборки, когда он отклоняется от «проторенного пути» или когда кодовая база содержит технический долг.
Кейс
После инцидента в Netflix, связанного с тем, что одна библиотека выпустила обратно несовместимое изменение, нашу команду попросили предложить инструменты и практики, которые улучшат управление жизненным циклом Java-библиотек. Это был не простой случай безрассудного breaking change. Удаленный код был помечен как устаревший годами. Авторам библиотек часто тяжело понять, когда безопасно удалять устаревший код или рефакторить то, что не предназначено для использования downstream-приложениями. Массовые миграции по всему парку, например обновление мажорных версий Spring Boot, также включают удаление устаревшего кода. Чтобы помочь с этим, мы сформировали набор аннотаций жизненного цикла API:
-
@Deprecatedиз стандартной библиотеки Java -
@Publicпользовательская аннотация для API, которые предназначены для использования downstream -
@Experimentalпользовательская аннотация для новых API, которые могут быть еще нестабильными -
Все остальные API считаются «внутренними»
Комментарий от Михаила Поливаха
Явный пример, где компаняи написала свйо тулинг, вместо того, чтобы, например, использовать Java модули. А ведь Java модули могли бы решить эту проблему.
Интересное, однако, наблюдение. На редите была дискуссия после Q&A сессии с архитекторами Java, где им задали именно такой вопрос: «почему модули не используют?» Рекомендую посмотреть видео и почитать дискуссию:
https://www.reddit.com/r/java/comments/1stf7gs/ask_the_architects_javaone_2026/
Авторы библиотек могут размечать свои API этими аннотациями. Но как им понять, какие downstream-проекты используют их API неправильно с учетом этих правил?
По мере того как мы улучшали «проторенный путь» для JVM-библиотек в Netflix, нам нужен был хороший способ выявлять такой технический долг — не только ради библиотек, предоставляемых Java Platform, но и для любой команды, которая поставляет общие библиотеки в организации. Для этого мы обратились к ArchUnit.
ArchUnit — популярная open source библиотека (3,5 тыс. звезд, 84 контрибьютора), которая используется для принудительного соблюдения «архитектурных» правил кода как части JUnit-набора. Ее используют внутри Gradle и Spring, а также она входит в платформу Spring Modulith. Движок правил, построенный напрямую поверх ASM, подходит для широкого спектра сценариев. Он достаточно мощный, чтобы выступать как инструмент статического анализа общего назначения со следующими отличительными особенностями:
-
Работает межъязыково (в экосистеме JVM), потому что использует ASM/байткод, а не разбор AST.
-
Предоставляет builder-стиль API, упрощающий написание правил.
-
Также имеет более низкоуровневый API, идеально подходящий для создания более сложных пользовательских правил.
Ограничение ArchUnit в том, что он рассчитан на использование в составе JUnit-набора внутри одного репозитория. Плагины Nebula ArchRules дают организациям возможность делиться правилами и применять их в любом количестве репозиториев. Правила можно брать из open source библиотек или из закрытых внутренних библиотек. Это делает плагин в целом полезным для любой инженерной организации, работающей с JVM и Gradle.
Почему ArchUnit?
Прежде чем перейти к тому, как работает ArchRules, полезно понять, почему мы хотим использовать ArchUnit именно так, а не другие инструменты статического анализа.
AST против байткода
Некоторые инструменты, например PMD, применяют правила к AST (abstract syntax tree — абстрактному синтаксическому дереву). AST — это структурированное представление исходного кода. Такой инструмент будет иметь правила, зависящие от синтаксиса. Проблема анализа AST в том, что правила, которым нужно поддерживать несколько языков JVM — например Kotlin или Scala, — часто приходится переписывать под каждый язык. Кроме того, код, который правило должно было бы находить, может скрываться за синтаксическим сахаром, не учтенным автором правила. ArchUnit использует ASM, чтобы анализировать реальный скомпилированный байткод, а значит не важно, как этот код был получен. Анализируется именно тот код, который будет исполняться.
Кастомные правила
Такие инструменты, как PMD и Spotbugs, не оптимизированы для написания кастомных правил. Чаще всего их используют, запуская встроенные правила или подключая готовые сторонние плагины. Посмотрите, как может выглядеть пользовательское правило для PMD:
<![CDATA[ //AllocationExpression/ClassOrInterfaceType[ @Image='DateTime' and ( (count(..//Name[@Image='DateTimeZone.UTC'])<=0) and (count(..//Name[@Image='DateTimeZone.forID'])<=0) ) or ( ( (count(..//Name[@Image='DateTimeZone.UTC'])>0) or (count(..//Name[@Image='DateTimeZone.forID'])>0) ) and (../Arguments/ArgumentList and count(../Arguments/ArgumentList/Expression) = 1) ) ]]]>
Это правило гарантирует, что DateTime не создаются без явно заданной временной зоны. Это «сырая» строка, предназначенная для использования в xpath-парсере PMD. При составлении нет подсказок IDE. Чтобы протестировать правило, нужно отдельно связать целый процесс PMD, который интерпретирует правило и оценивает его на исходном файле. Посмотрим, как похожее правило будет выглядеть с ArchUnit:
ArchRuleDefinition.priority(Priority.MEDIUM).noClasses().should().callConstructorWhere( // constructor does not have a zone arguement target(doesNot(have(rawParameterTypes(DateTimeZone.class)))) // constructor is for DateTime .and(targetOwner(assignableTo(DateTime.class))))
Это типобезопасный Java-код с fluent API. Его также легко покрывать unit-тестами: в ArchUnit есть метод, который принимает объект правила и ссылки на классы, чтобы оценить правило относительно этих классов.
Связи между классами
Поскольку ArchUnit обрабатывает весь classpath через ASM, он хранит граф данных о классах, позволяя правилам легко проходить по отношениям между классами и по местам вызовов. Это дает правилам гораздо больше контекста о коде, который они оценивают.
Библиотеки правил
Первым шагом было создать возможность писать правила ArchUnit так, чтобы ими можно было делиться и публиковать. Для этого у нас есть плагин ArchRules Library. Этот плагин добавляет в Gradle-проект дополнительный source set под названием archRules. В этом source set можно создать класс, который реализует интерфейс ArchRulesService. У этого интерфейса есть один абстрактный метод, возвращающий Map<String, ArchRule>. Ключи этой map — имена ваших правил, а ArchRule — это правило, которое вы хотите определить с помощью стандартного API ArchUnit. Пример:
public class GuavaRules implements ArchRulesService { static final ArchRule OPTIONAL = ArchRuleDefinition.priority(Priority.MEDIUM) .noClasses() .should() .dependOnClassesThat() .haveFullyQualifiedName("com.google.common.base.Optional") .because("Java Optional is preferred over Guava Optional"); @Override public Map<String, ArchRule> getRules() { Map<String, ArchRule> rules = new HashMap<>(); rules.put("guava optional", OPTIONAL); return rules; }}
Этот код и его зависимости не будут упакованы вместе с основным кодом. Они собираются в отдельный JAR с classifier arch-rules. При публикации ваша библиотека публикует этот JAR как отдельный вариант с атрибутом usage, установленным в arch-rules. Это означает, что для использования этих правил в downstream-проектах им нужно применять Gradle Module Metadata для разрешения зависимостей. Существует 2 варианта библиотек c собственным правилами: автономные библиотеки правил и пакетные (bundled) библиотеки правил.
Комментарий от Михаила Поливаха
Ну, то есть парни, грубо, публикуют вместе с основным артефактом сборки ещё один артефакт, который представляет собой JAR с дополнительным archunit правилами.
И уже далее при использовании этой библиотеки их кастомный Nebula плагин нахоит эти дополнительные артефакты и прогоняет тесты.
Автономные библиотеки правил
Автономная библиотека правил не содержит основного кода: только archRules. Это удобно для определения правил для кода, которым вы не владеете, например для API Core Java или open source библиотек. Они также полезны для универсальных правил, которые можно применять к любому коду, например: «не используйте код, помеченный как @Deprecated». Мы поддерживаем коллекцию open source автономных библиотек правил, которыми может пользоваться любой желающий; они также служат примерами того, какие типы правил вы можете написать сами. Однако настоящая сила ArchRules — в «пакетных библиотеках правил».
Пакетные библиотеки правил
Пакетная библиотека правил — это библиотека, где есть и основной код, и исходники archRules. Основной source set содержит полезный библиотечный код — какой бы он ни был. А в archRules находятся правила, специфичные для использования этой библиотеки. Например, правила, ограниченные пакетом этой библиотеки, или правила, которые ссылаются на конкретный API этой библиотеки. По возможности мы рекомендуем писать правила именно в таком, пакетном виде. Дело в том, что плагин ArchRules Runner сможет автоматически обнаруживать такие правила и запускать их только в тех source set’ах, которые используют эту библиотеку как зависимость. Пример этого можно увидеть в нашей библиотеке Nebula Test.
В любом случае Gradle плагин при сборке автоматически сгенерирует запись регистрации ServiceLoader для вашего ArchRulesService, чтобы runner мог находить ваши правила.
Комментарий от Михаила Поливаха
ServiceLoader это базовый способ динамического обнаружения реализаций SPI интерфейсов в рантайме. Вездесущий SLF4J так же делает.
Запуск правил
Плагин ArchRules Runner позволяет проверять правила на вашем коде. Автономные библиотеки правил можно прогонять по всем source set’ам, добавив их в конфигурацию archRules в вашем build-файле. Например:
dependencies { archRules("your:rules:1.0.0")}
Как упоминалось выше, пакетные же правила (которые поставляются вместе с библиотекой) будут выполняться автоматически. Для этого runner-плагин создает отдельную конфигурацию для каждого вашего source set’а. В каждой из этих конфигураций classpath archRules объединяется с runtimeClasspath, при этом выбирается вариант arch-rules. Именно этот classpath используется, когда ServiceLoader обнаруживает реализации ArchRulesService.
Комментарий от Михаила Поливаха
Тут речь про Gradle Configuration. Для людей, кто больше работает с Maven: это некоторый аналог scopes, хотя и такое сравнение не совсем корркетно, но оно наиболее точно.
В следующем примере ниже у нас есть проект, который использует библиотеку-хелпер для тестов как зависимость testImplementation, а также добавляет автономную библиотеку правил в конфигурацию archRules. Runtime classpath для тестов будет содержать только implementation JAR хелпер-библиотеки, но arch rules runtime будет содержать archrules JAR для пакетных правил и автономных правил. Все это происходит автоматически.

После того как classpath для правил определен, runner-плагин создаст Gradle work action, чтобы проверить правила применительно к конкретному source set’у. Этот action запускается с изоляцией classpath, используя конфигурацию *archRuleRuntime. Внутри action применяется ServiceLoader для обнаружения определений правил. Завершается action записью бинарной сериализации нарушений правил в файл для последующего формирования отчетов.
В проекте, где запускаются правила, у вас также есть возможность настраивать конфигурации правил через расширение archRules. Например, можно переопределить уровень приоритета правила:
archRules { ruleClass("com.netflix.nebula.archrules.deprecation") { priority("HIGH") }}
Среди других настроек — отключение запуска правил для определенных source set’ов и конфигурация порога «падения» сборки (то есть, например, ошибки с высоким приоритетом приведут к падению сборки).
Отчетность
Плагин ArchRules runner имеет два встроенных отчета: JSON и консольный. JSON-отчет собирает вывод по всем source set’ам внутри проекта и формирует один JSON-файл со всеми данными. Консольный отчет также агрегирует вывод по всем source set’ам проекта, но выводит в консоль удобочитаемый отчет, например:

Обратите внимание: детали сбоев включают подробное описание на простом английском, а также указатель на точную строку кода, в которой допущено нарушение.
Для пользовательской отчетности вы можете либо использовать JSON-файл, либо создать собственную таску, которая читает бинарные файлы. В качестве примера того, как это сделать, посмотрите исходный код таски, которая делает репортинг в runner-плагине ArchRules.
Решение кейса
Возвращаясь к нашей исходной проблеме: используя ArchRules, мы смогли предоставить платформу, с помощью которой авторы библиотек отслеживают использование своих API. Они пишут ArchRules, чтобы выявлять использование аннотаций в пределах пакета своей библиотеки, например:
ArchRuleDefinition.priority(Priority.MEDIUM) .noClasses().that(resideOutsideOfPackage(packageName + "..")) .should() .dependOnClassesThat(resideInAPackage(packageName + "..").and(are(deprecated()))) .orShould().accessTargetWhere(targetOwner(resideInAPackage(packageName + "..")) .and(target(is(deprecated())).or(targetOwner(is(deprecated()))))) .allowEmptyShould(true) .because("Deprecated APIs are subject to removal");
Наш внутренний стандартный Gradle wrapper и набор плагинов Nebula автоматически включают ArchRules runner в каждом проекте и предоставляют пользовательский репортер, который отправляет данные отчета в наш Internal Developer Portal на каждом CI-билде основной ветки. Так авторы библиотек могут легко видеть отчет обо всех downstream-потребителях, которые используют их экспериментальные, устаревшие или непубличные API, — и уверенно делать «ломающие» изменения, зная, что это на самом деле не сломает downstream-потребителей. Если их изменения сейчас заблокированы из-за downstream-использования, они могут быстро увидеть, какие именно проекты сообщают об этих использованиях.
OSS-библиотеки правил
Хотя самый мощный способ использовать ArchRules — писать собственные правила, мы также сделали несколько open source библиотек правил, которыми может пользоваться любой желающий или на которые можно ссылаться как на примеры.
Nullability
Эти правила обеспечивают корректное применение аннотаций nullable в Java — например, чтобы каждый публичный класс был помечен @NullMarked из JSpecify. При этом правила достаточно «умные», чтобы исключать Kotlin-код, поскольку в Kotlin nullability встроена.
Комментарий от Михаила Поливаха
Мне кажется, не надо так делать. Это довольно странное правило. Оставим на откуп авторам.
Best practices для Gradle-плагинов
Писать Gradle-плагины бывает сложно — особенно потому, что существует множество API и паттернов, которые больше не следует использовать. Эти правила помогают закреплять актуальные best practices при разработке Gradle-плагинов.
Правила для Joda / Guava
Эти библиотеки правил discouraging использование классов Joda Time и Guava (соответственно), поскольку их заменили java.time и улучшения стандартной библиотеки.
Правила безопасности
Эти правила помогают снижать риск CVE, обнаруживая использование заведомо уязвимых API. В идеале CVE нужно устранять обновлением зависимостей. Но иногда это невозможно сделать сразу, и тогда проверки на этапе компиляции, гарантирующей, что конкретный уязвимый API не используется, часто достаточно.
Заключение
Сейчас мы запускаем 358 (и число растет) правил более чем в 5 000 репозиториях и обнаруживаем почти 1 миллион проблем. Около 1 000 из них относятся к правилам с приоритетом «High». Возможность запускать эти правила в таком масштабе позволяет нам быстро получать инсайты по нашему большому парку микросервисов и находить области с самым критичным техническим долгом. Это упрощает фокусировку и приоритизацию усилий.
В дальнейшем мы будем изучать, как привязать решения авто-ремедиации к результатам ArchRules. ArchUnit уже предоставляет в отчетах очень конкретную и детализированную информацию о сбоях, что дает сильный входной сигнал для инструмента авто-ремедиации. Мы рассмотрим детерминированные решения вроде OpenRewrite и недетерминированные решения вроде LLM. Связка простого авторства правил и детерминированных результатов ArchUnit с инструментом авто-ремедиации, который умеет корректно интерпретировать результаты и решать конкретную проблему, будет очень мощной комбинацией.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
ссылка на оригинал статьи https://habr.com/ru/articles/1037012/