Соблюдение определённой структуры пакетов или архитектуры крайне важно. Особенно в Java, где для корректной работы некоторые элементы должны быть public или действительно доступны за пределами своего пакета. В новом переводе от команды Spring АйО рассмотрим библиотеку с открытым исходным кодом ArchUnit, которая помогает в тех случаях, когда одного компилятора недостаточно.

Комментарий от Михаила Поливаха

Для крупных проектов адоптация ArchUnit – это база. 

Вообще ArchUnit яркий пример того, как язык и экосистема делят между собой ответственность за решение тех или иных проблем. В данном случае ArchUnit берет на себя часть ответственности за решение проблемы visibility и общей инкапсуляции.

Ну нет в Java visibility modifier-а, который скажет, что "Этот member он хоть и public, но доступен только вот отсюда". Между public и protected есть огромная дыра, и закрыть её нечем.

Конечно, в Java 9 появился project jigsaw и модули, но они по ряду причин не получили такой широкой адаптации. И соотвественно, геп в решении проблемы остался. И вот ArchUnit чуть ли не единственный инструмент, который позволяет решить проблему. 

Кстати, Spring Data использует ArchUnit для enforcement-а своей внутренней архитектуры

Что такое ArchUnit?

ArchUnit — это библиотека с открытым исходным кодом для написания и обеспечения соблюдения архитектурных правил внутри проекта. Нет смысла полагаться на фасады и инкапсуляцию, если можно просто «дотянуться» и взять то, что хочется. Это становится ещё важнее, когда вы пытаетесь следовать подходам вроде ports and adapters или другим методологиям, которые накладывают очень строгие ограничения на то, какие классы должны использовать другие и каким образом.

Именно здесь в дело вступает ArchUnit. Он легко проверяет зависимости между пакетами и классами — какой класс использует/импортирует другой. Благодаря этой «простой» функции мы можем без труда настроить набор правил, которые будут ограничивать то, как наши классы могут взаимодействовать друг с другом. Затем эти правила можно так же легко добавить в набор тестов и, как следствие, модульно тестировать архитектуру.

По сути, эти правила — обычные тесты, и их можно запускать с помощью любой библиотеки/фреймворка для юнит-тестирования. Помимо упомянутой выше «простой» проверки зависимостей между пакетами и классами, ArchUnit умеет проверять зависимости между слоями и срезами (slices), выявлять циклические зависимости и многое другое.

С помощью ArchUnit можно создать, например, такие правила:

  • Классы в пакете X должны зависеть только от классов в пакете Y.

  • Классы в сервисном слое не должны обращаться к классам слоя контроллеров

  • Между этими пакетами не должно существовать циклических зависимостей

  • Запретить инъекцию через поля и сеттеры

  • Гарантировать, что аннотация @Transactional используется только в сервисном слое

  • Обязать использование аннотаций @Repository и @Service в конкретных пакетах
    ….

Не вдаваясь в подробности: ArchUnit работает, читая и анализируя байткод, а не исходный код. Следовательно, правила применяются не к исходникам как таковым, а к результирующему байткоду.

Комментарий от Евгения Сулейманова

ArchUnit анализирует байткод - значит, качество тестов архитектуры во многом определяется тем, какие классы вы импортируете.

Если случайно импортировать test-классы, сгенерированный код (MapStruct / OpenAPI / Lombok delombok output), или "вспомогательные" пакеты, вы получите ложные нарушения и начнете "чинить" архитектуру там, где проблема - в импорте.

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

static final JavaClasses CLASSES = new ClassFileImporter()
  .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
  .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
  .importPackages("org.ps");

Также, крайне рекомендуется импортировать классы один раз на весь тестовый класс (например, в @BeforeAll). Если делать new ClassFileImporter().importPackages(...) в каждом тесте, ArchUnit каждый раз заново читает байткод — и на CI это превращается в лишние секунды/минуты.

Пример того, как делать не рекомендуется:

class ArchTest {
  
  @Test
  void rule1() {
    JavaClasses classes = new ClassFileImporter()
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
      .importPackages("org.ps");
    someRule().check(classes);
  }
  
  @Test
  void rule2() {
    JavaClasses classes = new ClassFileImporter()
      .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
      .importPackages("org.ps");
    otherRule().check(classes);
  }
}

Вместо этого, стоит делать так:

class ArchTest {

  private static JavaClasses classes;

  @BeforeAll
  static void init() {
    classes = new ClassFileImporter()
        .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
        .importPackages("org.ps");
  }

  @Test
  void rule1() { someRule().check(classes); }
}

ArchUnit-Junit

Артефакт ArchUnit-Junit — часть более широкого фреймворка ArchUnit. Он делает тесты более описательными и компактными, убирая значительную часть JUnit-«шаблонного» кода. Самая важная часть этого пакета — аннотация ArchTest. Используя её, мы можем писать тесты в виде методов, а не JUnit-тестов. Артефакт сам позаботится о том, чтобы преобразовать метод в полноценный JUnit-тест.

ArchUnit-Junit

@ArchTest
static final ArchRule classesInXShouldOnlyDependOnClassesInY=ArchRuleDefinition
  .classes()
  .that().resideInAPackage("..x..")
  .should().onlyDependOnClassesThat()
  .resideInAnyPackage(
    "..y..",
    "java..");

JUnit

@Test
void testClassesInXShouldOnlyDependOnClassesInY() {
    ArchRule rule = classes()
          .that().resideInAPackage("..x..")
          .should().onlyDependOnClassesThat()
          .resideInAnyPackage(
                    "..y..",   
                    "java.."   
            );
    rule.check(IMPORTED_CLASSES);
}

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

Все тесты, приведённые здесь, написаны в стиле JUnit — без archunit-junit5-engine. Тем не менее примеры, выполненные с использованием библиотеки junit-archunit, вы можете найти в репозитории.

Примеры ArchUnit

Начнём с реализации всех правил, перечисленных выше. Затем я перейду к правилам, которые гарантируют, что ваша конфигурация ports & adapters останется неизменной.

Классы в пакете X должны зависеть только от классов в пакете Y.

@Test
void testClassesInXShouldOnlyDependOnClassesInY() {
    // Дано: определяем правило, которое ограничивает классы в пакете «..x..» —
    // им разрешено зависеть только от классов из «..y..» или стандартных пакетов Java.
    ArchRule rule = classes()
            .that().resideInAPackage("..x..")
            .should().onlyDependOnClassesThat()
            .resideInAnyPackage(
                    "..y..",   // Разрешаем зависимость от пакета «..y..»
                    "java.."   // Разрешаем зависимость от стандартной библиотеки Java
            );

    // Тогда
    rule.check(IMPORTED_CLASSES);
}

Классы в сервисном слое не должны обращаться к классам слоя контроллеров.

@Test
void testServiceLayerShouldNotAccessControllers() {
    // Дано: определяем правило, которое запрещает сервисному слою
    // зависеть от классов слоя контроллеров.
    ArchRule rule = noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..controller..");

    // Затем
    rule.check(IMPORTED_CLASSES);
}

Между пакетами не должно существовать циклических зависимостей.

@Test
void testNoCyclicDependencies() {
    // Дано: определяем правило, гарантирующее отсутствие циклических зависимостей
    // между модулями, сгруппированными по подпакетам первого уровня внутри 'org.ps'.
    ArchRule rule = SlicesRuleDefinition.slices()
            .matching("org.ps.(*)..")  // Определяем «срезы» по подпакетам внутри 'org.ps'
            .should()
            .beFreeOfCycles();         // Проверяем, что между ними нет циклических зависимостей

    // Затем
    rule.check(IMPORTED_CLASSES);
}

Запретить инъекцию через поля и сеттеры

@Test
void testNoFieldInjection() {

    // Дано: определяем правило, запрещающее инъекцию через поля с использованием @Autowired.
    ArchRule noFieldInjectionRule = noFields()
            .should().beAnnotatedWith(Autowired.class)
            .because("Используйте инъекцию через конструктор вместо инъекции через поля.");

    // Также определяем правило, запрещающее инъекцию через сеттеры с использованием @Autowired.
    ArchRule noSetterInjectionRule = noMethods()
            .that().haveNameMatching("set[A-Z].*")
            .should().beAnnotatedWith(Autowired.class)
            .because("Используйте инъекцию через конструктор вместо инъекции через сеттеры.");

    // Когда: объединяем оба правила в одно составное правило.
    ArchRule compositeRule = CompositeArchRule.of(noFieldInjectionRule).and(noSetterInjectionRule);

    // Затем
    compositeRule.check(IMPORTED_CLASSES);
}

Гарантировать, что аннотация @Transactional используется только в сервисном слое.

@Test
void testTransactionalAnnotationOnlyInService() {
    // Дано: определяем правило, которое гарантирует, что классы,
    // аннотированные @Transactional, находятся в сервисном слое.
    ArchRule classLevelTransactional = classes()
            .that().areAnnotatedWith(Transactional.class)
            .should().resideInAPackage("..service..")
            .because("Аннотация @Transactional на уровне класса должна использоваться только в сервисном слое.");

    // Также определяем правило для методов, аннотированных @Transactional:
    // они должны быть объявлены только в классах сервисного слоя.
    ArchRule methodLevelTransactional = methods()
            .that().areAnnotatedWith(Transactional.class)
            .should().beDeclaredInClassesThat().resideInAPackage("..service..")
            .because("Аннотация @Transactional на уровне метода должна использоваться только в сервисном слое.");

    // Когда: объединяем оба правила в одно составное правило.
    ArchRule compositeRule = CompositeArchRule.of(classLevelTransactional).and(methodLevelTransactional);

    // Затем
    compositeRule.check(IMPORTED_CLASSES);
}

Обязать использование аннотаций @Repository и @Service в конкретных пакетах.

@Test
void testRepositoryAnnotationInRepositoryPackage() {

    // Дано: определяем правило, которое гарантирует, что классы,
    // аннотированные @Repository, расположены только в пакете repository.
    ArchRule rule = classes()
            .that().areAnnotatedWith(Repository.class)
            .should().resideInAPackage("..repository..");

    // Затем
    rule.check(IMPORTED_CLASSES);
}

@Test
void testServiceAnnotationInServicePackage() {

    // Дано: определяем правило, которое гарантирует, что классы,
    // аннотированные @Service, расположены только в пакете service.
    ArchRule rule = classes()
            .that().areAnnotatedWith(Service.class)
            .should().resideInAPackage("..service..");

    // Затем
    rule.check(IMPORTED_CLASSES);
}

ArchUnit и гексагональная архитектура

Здесь конфигурация несколько сложнее. Полный набор тестов ArchUnit для гексагональной архитектуры.

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

Рассмотрим следующую структуру:

org.ps
├─ domain
│ └─ ... (доменные модели и сервисы)
├─ application
│ ├─ port
│ │ ├─ in
│ │ │ └─ ... (интерфейсы для входящих запросов и сообщений)
│ │ └─ out
│ │ └─ ... (интерфейсы для исходящих запросов и сообщений)
│ └─ ... (сервисы приложения, реализации вариантов использования)
├─ adapters
│ ├─ in (входящие запросы и сообщения)
│ └─ out (исходящие запросы и сообщения)
├─ infrastructure
│ └─ ... (внешние настройки — подключения к БД, очереди, метрики)
└─ config
└─ ... (классы конфигурации для всех остальных пакетов)

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

Мы хотим, чтобы наша структура соответствовала следующему набору правил — насколько я понял, это своего рода индустриальный стандарт для гексагональной архитектуры:

  • Домен (Domain) не должен обращаться ни к одному слою, но к нему могут обращаться слои Application и Adapters.

  • Слой Application может обращаться к слоям Config и Domain, но обращаться к нему может слой Adapters.

  • Слой Adapters может обращаться к Application, Adapters, Domain и Infrastructure, но к нему не должны обращаться другие слои.

  • Слой Infrastructure может обращаться только к слою Config, но обращаться к нему может только слой Adapters.

  • Слой Config не должен обращаться ни к одному слою, но к нему могут обращаться Application, Adapters, Domain и Infrastructure.

Ниже показано, как это можно протестировать и обеспечить с помощью ArchUnit. Тест довольно длинный, но отдельные правила чётко отделены друг от друга.

@Test
public void hexagonArchTest() {

    // Дано
    JavaClasses importedClasses = new ClassFileImporter().importPackages("org.ps.hexagon");

    LayeredArchitecture portsAndAdaptersLayers = layeredArchitecture()
          .consideringOnlyDependenciesInLayers()
            // Определяем каждый «слой» по его пакету
          .layer("Adapters").definedBy("..adapters..")
          .layer("Application").definedBy("..application..")
          .layer("Config").definedBy("..config..")
          .layer("Domain").definedBy("..domain..")
          .layer("Infrastructure").definedBy("..infrastructure..")

            // Домен (Domain) не должен обращаться ни к одному слою, но к нему могут обращаться слои Application и Adapters.
          .whereLayer("Domain").mayNotAccessAnyLayer()
          .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapters")

            // Слой Application может обращаться к слоям Config и Domain, но обращаться к нему может слой Adapters.
          .whereLayer("Application").mayOnlyAccessLayers("Config", "Domain")
          .whereLayer("Application").mayOnlyBeAccessedByLayers("Adapters")

            // Слой Adapters может обращаться к Infrastructure, Config, Application и Domain, но к нему не должны обращаться другие слои.
          .whereLayer("Adapters").mayOnlyAccessLayers("Infrastructure", "Config", "Application", "Domain")
          .whereLayer("Adapters").mayNotBeAccessedByAnyLayer()

            // Слой Infrastructure может обращаться только к слою Config, но обращаться к нему может только слой Adapters.
          .whereLayer("Infrastructure").mayOnlyAccessLayers("Config")
          .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Adapters")

            // Слой Config не должен обращаться ни к одному слою, но к нему могут обращаться Application, Adapters, Domain и Infrastructure.
          .whereLayer("Config").mayNotAccessAnyLayer()
          .whereLayer("Config").mayOnlyBeAccessedByLayers("Application", "Adapters", "Domain", "Infrastructure");

    // Затем
    portsAndAdaptersLayers.check(importedClasses);
}

Итоги

Вот и всё — это всё, чем я хотел поделиться с вами сегодня. Если вам нужно больше примеров, вы можете найти их либо на GitHub ArchUnit, либо в их документации.

С другой стороны, можно просто начать писать код и посмотреть, куда вас приведёт API.

Спасибо за ваше время.

Комментарий от редакции Spring АйО

Друзья, у нашего эксперта Евгения Сулейманова как раз есть полезная информация по ArchUnit в его бесплатном курсе по системному дизайну. Рекомендуем!

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