Как стать автором
Обновить

Обеспечение границ компонентов чистой архитектуры с помощью Spring Boot и ArchUnit

Время на прочтение11 мин
Количество просмотров3.3K
Автор оригинала: Tom Hombergs

Когда мы создаем программное обеспечение, мы хотим создавать «-ости»: понятность, ремонтопригодность, расширяемость и - в тренде сейчас - декомпозицию (возможность разложить монолит на микросервисы, если возникнет необходимость). Добавьте в этот список свою любимую «способность».

Большинство - возможно, даже все - из этих «возможностей» идут рука об руку с чистыми зависимостями между компонентами.

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

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

Это тем более важно, если мы работаем над монолитной кодовой базой, охватывающей множество различных областей бизнеса или «ограниченных контекстов», если использовать жаргон Domain-Driven Design.

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

 Пример кода

Эта статья сопровождается примером рабочего кода на GitHub .

Видимость Package-Private

Что помогает с соблюдением границ компонентов? Уменьшение видимости.

Если мы используем Package-Private видимость для «внутренних» классов, доступ будут иметь только классы в одном пакете. Это затрудняет добавление нежелательных зависимостей извне пакета.

Итак, просто поместите все классы компонента в один и тот же пакет и сделайте общедоступными только те классы, которые нам нужны вне компонента. Задача решена?

Нет, на мой взгляд.

Это не работает, если нам нужны подпакеты в нашем компоненте.

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

Я не хочу ограничиваться одним пакетом для моего компонента! Возможно, у моего компонента есть подкомпоненты, которые я не хочу показывать снаружи. Или, может быть, я просто хочу отсортировать классы по отдельным сегментам, чтобы упростить навигацию по базе кода. Мне нужны эти дополнительные пакеты!

Так что, да, package-private видимость помогает избежать нежелательных зависимостей, но само по себе это, в лучшем случае, неполноценное решение.

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

Что мы можем с этим поделать? Мы не можем полагаться только на package-private видимость. Давайте рассмотрим подход к сохранению нашей кодовой базы чистой от нежелательных зависимостей с использованием интеллектуальной структуры пакета, package-private видимости, где это возможно, и ArchUnit в качестве средства обеспечения там, где мы не можем использовать package-private видимость.

Пример использования

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

Компонент биллинга предоставляет внешний вид калькулятора счетов. Калькулятор счетов генерирует счет для определенного клиента и определенного периода времени.

Чтобы использовать язык Domain-Driven Design (DDD): компонент биллинга реализует ограниченный контекст, который предоставляет варианты использования биллинга. Мы хотим, чтобы этот контекст был как можно более независимым от других ограниченных контекстов. В остальной части статьи мы будем использовать термины «компонент» и «ограниченный контекст» как синонимы.

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

Наш компонент состоит из трех подкомпонентов: калькулятора счетов, пакетного задания и кода базы данных. Все эти компоненты потенциально состоят из пары классов. Калькулятор счетов является public компонентом, а компоненты пакетного задания и базы данных являются внутренними компонентами, которые не должны быть доступны извне компонента биллинга.

Классы API и внутренние классы

Давайте посмотрим на структуру пакета, который я предлагаю для нашего биллингового компонента:

billing
├── api
└── internal
    ├── batchjob
    |   └── internal
    └── database
        ├── api
        └── internal

У каждого компонента и субкомпонента есть internalпакет, содержащий, ну, внутренние классы, и дополнительный apiпакет, содержащий, как вы угадали, классы API, которые предназначены для использования другими компонентами.

Такое разделение пакетов между internalи apiдает нам несколько преимуществ:

  • Мы можем легко вкладывать компоненты друг в друга.

  • Легко догадаться, что классы внутри internalпакета нельзя использовать извне.

  • Легко догадаться, что классы внутри internalпакета можно использовать из его подпакетов.

  • Пакеты apiи internalдают нам инструмент для обеспечения соблюдения правил зависимостей с ArchUnit (подробнее об этом позже).

  • Мы можем использовать столько классов или подпакетов в пакете api или internal, сколько захотим, при этом границы наших компонентов по-прежнему четко определены.

Если возможно, классы внутри internalпакета должны быть package-private. Но даже если они являются public (и они должны быть public, если мы используем подпакеты), структура пакета определяет четкие и легко отслеживаемые границы.

Вместо того, чтобы полагаться на недостаточную поддержку Java для package-private видимости, мы создали архитектурно выразительную структуру пакета, которая может быть легко реализована с помощью инструментов.

Теперь давайте посмотрим на эти пакеты.

Инверсия зависимостей для предоставления доступа к Package-Private функциям

Начнем с databaseподкомпонента:

database
├── api
|   ├── + LineItem
|   ├── + ReadLineItems
|   └── + WriteLineItems
└── internal
    └── o BillingDatabase

+означает, что класс является public, oозначает, что он является package-private.

databaseКомпонент выставляет API с двумя интерфейсами ReadLineItemsи WriteLineItems, которые позволяют читать и записвысать строку заказа клиента из и в базу данных, соответственно. Тип LineItemдомена также является частью API.

Внутри databaseподкомпонент имеет класс, BillingDatabaseреализующий два интерфейса:

@Component
class BillingDatabase implements WriteLineItems, ReadLineItems {
  ...
}

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

Обратите внимание, что это применение принципа инверсии зависимостей.

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

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

Давайте также заглянем в batchjobподкомпонент:

Подкомпонент batchjob не предоставляет API доступа к другим компонентам. У него просто есть класс LoadInvoiceDataBatchJob(и, возможно, несколько вспомогательных классов), который ежедневно загружают данные из внешнего источника, преобразуют их и передают их в базу данных биллингового компонента через WriteLineItemsинтерфейс:

@Component
@RequiredArgsConstructor
class LoadInvoiceDataBatchJob {

  private final WriteLineItems writeLineItems;

  @Scheduled(fixedRate = 5000)
  void loadDataFromBillingSystem() {
    ...
    writeLineItems.saveLineItems(items);
  }
}

Обратите внимание, что мы используем @Scheduledаннотацию Spring, чтобы регулярно проверять наличие новых элементов в биллинговой системе.

Наконец, содержимое компонента верхнего уровня billing:

billing
├── api
|   ├── + Invoice
|   └── + InvoiceCalculator
└── internal
    ├── batchjob
    ├── database
    └── o BillingService

Компонент billingпредоставляет доступ к интерфейсу InvoiceCalculatorи доменному типу Invoice. Опять же, интерфейс  InvoiceCalculatorреализован внутренним классом, который вызывается BillingServiceв примере. BillingServiceобращается к базе данных через ReadLineItemsAPI базы данных для создания счета-фактуры клиента из нескольких позиций:

@Component
@RequiredArgsConstructor
class BillingService implements InvoiceCalculator {

  private final ReadLineItems readLineItems;

  @Override
  public Invoice calculateInvoice(
        Long userId, 
        LocalDate fromDate, 
        LocalDate toDate) {
    
    List<LineItem> items = readLineItems.getLineItemsForUser(
      userId, 
      fromDate, 
      toDate);
    ... 
  }
}

Теперь, когда у нас есть чистая структура, нам нужна инъекция зависимостей, чтобы связать все это вместе.

Соединяем все вместе с помощью Spring Boot

Чтобы связать все вместе с приложением, мы используем функцию Spring Java Config и добавляем Configurationкласс в internalпакет каждого модуля :

billing
└── internal
    ├── batchjob
    |   └── internal
    |       └── o BillingBatchJobConfiguration
    ├── database
    |   └── internal
    |       └── o BillingDatabaseConfiguration
    └── o BillingConfiguration

Эти конфигурации говорят Spring внести набор компонентов Spring в контекст приложения.

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

@Configuration
@EnableJpaRepositories
@ComponentScan
class BillingDatabaseConfiguration {

}

С помощью аннотации @Configurationмы сообщаем Spring, что это класс конфигурации, который вносит компоненты Spring в контекст приложения.

Аннотация @ComponentScanговорит Spring, что нужно включить все классы , которые находятся в том же пакете, что и класс конфигурации (или подпакет) и аннотированные с @Componentкак бины в контекст приложения. Это загрузит наш BillingDatabaseкласс, приведенный выше.

Вместо @ComponentScanмы могли бы также использовать @Beanаннотированные фабричные методы внутри @Configurationкласса.

Под капотом для подключения к базе данных databaseмодуль использует репозитории Spring Data JPA. Мы включаем их с помощью аннотации @EnableJpaRepositories.

Конфигурация batchjobвыглядит так же:

@Configuration
@EnableScheduling
@ComponentScan
class BillingBatchJobConfiguration {

}

Только аннотация @EnableSchedulingдругая. Нам это нужно, чтобы включить аннотацию @Scheduledв нашем bean-компонентеLoadInvoiceDataBatchJob.

Наконец, конфигурация компонента верхнего уровня billingвыглядит довольно скучно:

@Configuration
@ComponentScan
class BillingConfiguration {

}

С помощью аннотации @ComponentScanэта конфигурация гарантирует, что подкомпоненты @Configurationбудут обнаружены Spring и загружены в контекст приложения вместе с их bean-компонентами.

Благодаря этому у нас есть четкое разделение границ не только по измерению пакетов, но и по измерению Spring конфигураций.

Это означает, что мы можем настроить таргетинг на каждый компонент и подкомпонент отдельно, обращаясь к его @Configurationклассу. Например, мы можем:

  • Загрузить только один (под) компонент в контекст приложения в рамках интеграционного теста SpringBootTest.

  • Включить или отключить определенные (под) компоненты, добавив аннотацию @Conditional...к конфигурации этого подкомпонента.

  • Заменить компоненты, внесенные в контекст приложения, на (под) компонент, не затрагивая другие (под) компоненты.

Однако у нас все еще есть проблема: классы в billing.internal.database.apiпакете являются public, то есть к ним можно получить доступ извне billingкомпонента, что нам не нужно.

Давайте решим эту проблему, добавив в игру ArchUnit.

Обеспечение границ с помощью ArchUnit

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

В нашем случае мы хотим определить правило, согласно которому все классы в internalпакете не используются извне этого пакета. Это правило гарантирует, что классы внутри billing.internal.*.apiпакетов недоступны извне billing.internalпакета.

Маркировка внутренних пакетов

Чтобы управлять нашими internalпакетами при создании правил архитектуры, нам нужно как-то пометить их как «внутренние».

Мы могли бы сделать это по имени (то есть рассматривать все пакеты с именем «internal» как внутренние пакеты), но мы также можем отметить пакеты другим именем, для чего создадим аннотацию @InternalPackage:

@Target(ElementType.PACKAGE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InternalPackage {

}

Затем во все наши внутренние пакеты мы добавляем package-info.javaфайл с этой аннотацией:

@InternalPackage
package io.reflectoring.boundaries.billing.internal.database.internal;

import io.reflectoring.boundaries.InternalPackage;

Таким образом, все внутренние пакеты отмечены, и мы можем создать правила для этого.

Проверка отсутствия доступа к внутренним пакетам извне

Теперь мы создаем тест, который проверяет, что классы в наших внутренних пакетах не доступны извне:

class InternalPackageTests {

  private static final String BASE_PACKAGE = "io.reflectoring";
  private final JavaClasses analyzedClasses = 
      new ClassFileImporter().importPackages(BASE_PACKAGE);

  @Test
  void internalPackagesAreNotAccessedFromOutside() throws IOException {

    List<String> internalPackages = internalPackages(BASE_PACKAGE);

    for (String internalPackage : internalPackages) {
      assertPackageIsNotAccessedFromOutside(internalPackage);
    }
  }

  private List<String> internalPackages(String basePackage) {
    Reflections reflections = new Reflections(basePackage);
    return reflections.getTypesAnnotatedWith(InternalPackage.class).stream()
        .map(c -> c.getPackage().getName())
        .collect(Collectors.toList());
  }

  void assertPackageIsNotAccessedFromOutside(String internalPackage) {
    noClasses()
        .that()
        .resideOutsideOfPackage(packageMatcher(internalPackage))
        .should()
        .dependOnClassesThat()
        .resideInAPackage(packageMatcher(internalPackage))
        .check(analyzedClasses);
  }

  private String packageMatcher(String fullyQualifiedPackage) {
    return fullyQualifiedPackage + "..";
  }
}

В internalPackages(), мы используем reflection библиотеку для сбора всех пакетов, аннотированных нашей @InternalPackageаннотацией.

Затем для каждого из этих пакетов мы вызываем assertPackageIsNotAccessedFromOutside(). Этот метод использует API-интерфейс ArchUnit, подобный DSL, чтобы гарантировать, что «классы, которые находятся вне пакета, не должны зависеть от классов, которые находятся внутри пакета».

Этот тест теперь завершится ошибкой, если кто-то добавит нежелательную зависимость к public классу во внутреннем пакете.

Но у нас все еще есть одна проблема: что, если мы переименуем базовый пакет (io.reflectoringв данном случае) в процессе рефакторинга?

Тогда тест все равно пройдет, потому что он не найдет никаких пакетов в (теперь несуществующем) io.reflectoringпакете. Если у него нет пакетов для проверки, он не может потерпеть неудачу.

Итак, нам нужен способ сделать этот тест безопасным при рефакторинге.

Обеспечение безопасного рефакторинга правил архитектуры

Чтобы сделать наш тестовый рефакторинг безопасным, мы проверяем наличие пакетов:

class InternalPackageTests {

  private static final String BASE_PACKAGE = "io.reflectoring";

  @Test
  void internalPackagesAreNotAccessedFromOutside() throws IOException {

    // make it refactoring-safe in case we're renaming the base package
    assertPackageExists(BASE_PACKAGE);

    List<String> internalPackages = internalPackages(BASE_PACKAGE);

    for (String internalPackage : internalPackages) {
      // make it refactoring-safe in case we're renaming the internal package
      assertPackageIsNotAccessedFromOutside(internalPackage);
    }
  }

  void assertPackageExists(String packageName) {
    assertThat(analyzedClasses.containPackage(packageName))
        .as("package %s exists", packageName)
        .isTrue();
  }

  private List<String> internalPackages(String basePackage) {
    ...
  }

  void assertPackageIsNotAccessedFromOutside(String internalPackage) {
    ...
  }
}

Новый метод assertPackageExists()использует ArchUnit, чтобы убедиться, что рассматриваемый пакет содержится в классах, которые мы анализируем.

Мы делаем эту проверку только для базового пакета. Мы не выполняем эту проверку для внутренних пакетов, потому что знаем, что они существуют. В конце концов, мы идентифицировали эти пакеты по @InternalPackageаннотации внутри internalPackages()метода.

Этот тест теперь безопасен для рефакторинга и завершится ошибкой, если мы переименуем пакеты должным образом.

Вывод

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

Это позволяет нам разрабатывать компоненты с четкими API и четкими границами, избегая большого шара грязи.

Дайте мне знать свои мысли в комментариях!

Вы можете найти пример приложения, использующего этот подход, на GitHub .

Если вас интересуют другие способы работы с границами компонентов с помощью Spring Boot, вам может быть интересен проект moduliths.

Теги:
Хабы:
0
Комментарии2

Публикации

Изменить настройки темы

Истории

Работа

Java разработчик
352 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн