«1. Обзор

Когда мы разрабатываем долгосрочные системы, мы должны ожидать изменяемую среду.

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

В этой статье мы создадим пример API регистрации пользователей в соответствии с чистой архитектурой Роберта К. Мартина. Мы будем использовать его исходные слои — сущности, варианты использования, интерфейсные адаптеры и фреймворки/драйверы.

2. Обзор чистой архитектуры

Чистая архитектура сочетает в себе многие конструкции кода и принципы, такие как SOLID, стабильные абстракции и другие. Но основная идея состоит в том, чтобы разделить систему на уровни в зависимости от ценности для бизнеса. Следовательно, на самом высоком уровне есть бизнес-правила, каждое из которых находится ближе к устройствам ввода-вывода.

Также мы можем переводить уровни в слои. В данном случае все наоборот. Внутренний уровень равен самому высокому уровню и так далее:

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

3. Правила

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

    Пароль пользователя должен содержать более пяти символов

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

    Система получает имя пользователя и пароль, проверяет, не существует ли пользователь, и сохраняет нового пользователя вместе со временем создания

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

4. Уровень сущностей

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

interface User {
    boolean passwordIsValid();

    String getName();

    String getPassword();
}

И UserFactory:

interface UserFactory {
    User create(String name, String password);
}

Мы создали метод фабрики пользователей из-за двух причины. Придерживаться принципа стабильных абстракций и изолировать творение пользователя.

Далее, давайте реализуем оба:

class CommonUser implements User {

    String name;
    String password;

    @Override
    public boolean passwordIsValid() {
        return password != null && password.length() > 5;
    }

    // Constructor and getters
}
class CommonUserFactory implements UserFactory {
    @Override
    public User create(String name, String password) {
        return new CommonUser(name, password);
    }
}

Если у нас сложный бизнес, то мы должны построить наш доменный код как можно более понятным. Таким образом, этот слой — отличное место для применения шаблонов проектирования. В частности, следует принимать во внимание дизайн, ориентированный на предметную область.

4.1. Модульное тестирование

@Test
void given123Password_whenPasswordIsNotValid_thenIsFalse() {
    User user = new CommonUser("Baeldung", "123");

    assertThat(user.passwordIsValid()).isFalse();
}

Теперь давайте проверим нашего CommonUser:

Как мы видим, модульные тесты очень понятны. Ведь отсутствие моков — хороший сигнал для этого слоя.

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

5. Уровень вариантов использования

Варианты использования — это правила, связанные с автоматизацией нашей системы. В чистой архитектуре мы называем их интеракторами.

5.1. UserRegisterInteractor

class UserRegisterInteractor implements UserInputBoundary {

    final UserRegisterDsGateway userDsGateway;
    final UserPresenter userPresenter;
    final UserFactory userFactory;

    // Constructor

    @Override
    public UserResponseModel create(UserRequestModel requestModel) {
        if (userDsGateway.existsByName(requestModel.getName())) {
            return userPresenter.prepareFailView("User already exists.");
        }
        User user = userFactory.create(requestModel.getName(), requestModel.getPassword());
        if (!user.passwordIsValid()) {
            return userPresenter.prepareFailView("User password must have more than 5 characters.");
        }
        LocalDateTime now = LocalDateTime.now();
        UserDsRequestModel userDsModel = new UserDsRequestModel(user.getName(), user.getPassword(), now);

        userDsGateway.save(userDsModel);

        UserResponseModel accountResponseModel = new UserResponseModel(user.getName(), now.toString());
        return userPresenter.prepareSuccessView(accountResponseModel);
    }
}

Во-первых, мы создадим наш UserRegisterInteractor, чтобы мы могли видеть, куда мы идем. Затем мы создадим и обсудим все используемые части:

Как мы видим, мы делаем все шаги варианта использования. Также этот слой отвечает за управление танцем сущности. Тем не менее, мы не делаем никаких предположений о том, как работает пользовательский интерфейс или база данных. Но мы используем UserDsGateway и UserPresenter. Так как же мы можем их не знать? Потому что вместе с UserInputBoundary это наши входные и выходные границы.

5.2. Входные и выходные границы

interface UserInputBoundary {
    UserResponseModel create(UserRequestModel requestModel);
}

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

interface UserRegisterDsGateway {
    boolean existsByName(String name);

    void save(UserDsRequestModel requestModel);
}

Затем у нас есть выходные границы для использования внешних слоев. Во-первых, давайте определим шлюз источника данных:

interface UserPresenter {
    UserResponseModel prepareSuccessView(UserResponseModel user);

    UserResponseModel prepareFailView(String error);
}

Во-вторых, презентатор представления:

Обратите внимание, что мы используем принцип инверсии зависимостей, чтобы освободить наш бизнес от таких деталей, как базы данных и пользовательские интерфейсы.

5.3. Режим разделения

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

«Монолитный — скорее всего, организованный с использованием некоторой структуры пакета. С помощью модулей. С помощью служб/микрослужб

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

5.4. Модели запроса и ответа

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

class UserRequestModel {

    String login;
    String password;

    // Getters, setters, and constructors
}

Обратите внимание, что все наши границы имеют дело только с объектами String или Model:

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

    Но почему у нас так много похожих объектов? Когда мы получаем повторяющийся код, он может быть двух типов:

Ложное или случайное дублирование — сходство кода является случайным, поскольку каждый объект имеет разные причины для изменения. Если мы попытаемся удалить его, мы рискуем нарушить принцип единой ответственности. Истинное дублирование — код меняется по тем же причинам. Следовательно, мы должны удалить его

Поскольку у каждой модели своя ответственность, мы получили все эти объекты.

5.5. Тестирование UserRegisterInteractor

@Test
void givenBaeldungUserAnd12345Password_whenCreate_thenSaveItAndPrepareSuccessView() {
    given(userDsGateway.existsByIdentifier("identifier"))
        .willReturn(true);

    interactor.create(new UserRequestModel("baeldung", "123"));

    then(userDsGateway).should()
        .save(new UserDsRequestModel("baeldung", "12345", now()));
    then(userPresenter).should()
        .prepareSuccessView(new UserResponseModel("baeldung", now()));
}

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

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

6. Интерфейсные адаптеры

На этом мы закончили все наши дела. Теперь давайте начнем вставлять наши детали.

Наш бизнес должен иметь дело только с наиболее удобным для него форматом данных, как и наши внешние агенты, такие как БД или UI. Но этот формат обычно отличается. По этой причине уровень адаптера интерфейса отвечает за преобразование данных.

6.1. UserRegisterDsGateway с использованием JPA

@Entity
@Table(name = "user")
class UserDataMapper {

    @Id
    String name;

    String password;

    LocalDateTime creationTime;

    //Getters, setters, and constructors
}

Во-первых, давайте воспользуемся JPA для сопоставления нашей пользовательской таблицы:

Как мы видим, целью Mapper является сопоставление нашего объекта с форматом базы данных.

@Repository
interface JpaUserRepository extends JpaRepository<UserDataMapper, String> {
}

Далее JpaRepository, использующий нашу сущность:

Учитывая, что мы будем использовать spring-boot, этого достаточно, чтобы сохранить пользователя.

class JpaUser implements UserRegisterDsGateway {

    final JpaUserRepository repository;

    // Constructor

    @Override
    public boolean existsByName(String name) {
        return repository.existsById(name);
    }

    @Override
    public void save(UserDsRequestModel requestModel) {
        UserDataMapper accountDataMapper = new UserDataMapper(requestModel.getName(), requestModel.getPassword(), requestModel.getCreationTime());
        repository.save(accountDataMapper);
    }
}

Теперь пришло время реализовать наш UserRegisterDsGateway:

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

6.2. User Register API

@RestController
class UserRegisterController {

    final UserInputBoundary userInput;

    // Constructor

    @PostMapping("/user")
    UserResponseModel create(@RequestBody UserRequestModel requestModel) {
        return userInput.create(requestModel);
    }
}

Теперь давайте создадим наш HTTP-адаптер:

Как мы видим, единственная цель здесь — получить запрос и отправить ответ клиенту.

6.3. Подготовка ответа

class UserResponseFormatter implements UserPresenter {

    @Override
    public UserResponseModel prepareSuccessView(UserResponseModel response) {
        LocalDateTime responseTime = LocalDateTime.parse(response.getCreationTime());
        response.setCreationTime(responseTime.format(DateTimeFormatter.ofPattern("hh:mm:ss")));
        return response;
    }

    @Override
    public UserResponseModel prepareFailView(String error) {
        throw new ResponseStatusException(HttpStatus.CONFLICT, error);
    }
}

Прежде чем ответить, мы должны отформатировать наш ответ:

@Test
void givenDateAnd3HourTime_whenPrepareSuccessView_thenReturnOnly3HourTime() {
    UserResponseModel modelResponse = new UserResponseModel("baeldung", "2020-12-20T03:00:00.000");
    UserResponseModel formattedResponse = userResponseFormatter.prepareSuccessView(modelResponse);

    assertThat(formattedResponse.getCreationTime()).isEqualTo("03:00:00");
}

Наш UserRegisterInteractor заставил нас создать докладчика. Тем не менее, правила презентации касаются только внутри адаптера. Кроме того, всякий раз, когда что-то трудно проверить, мы должны разделить это на тестируемый и простой объект. Итак, UserResponseFormatter легко позволяет нам проверить наши правила представления:

Как мы видим, мы протестировали всю нашу логику перед отправкой в ​​представление. Следовательно, только скромный объект находится в менее проверяемой части.

7. Драйверы и фреймворки

@SpringBootApplication
public class CleanArchitectureApplication {
    public static void main(String[] args) {
      SpringApplication.run(CleanArchitectureApplication.class);
    }
}

По правде говоря, мы обычно не пишем здесь код. Это связано с тем, что этот уровень представляет собой самый низкий уровень подключения к внешним агентам. Например, драйвер H2 для подключения к базе данных или веб-фреймворку. В этом случае мы собираемся использовать spring-boot в качестве веб-среды и фреймворка для внедрения зависимостей. Итак, нам нужна его точка запуска:

До сих пор мы не использовали аннотацию spring в нашем бизнесе. За исключением адаптеров, специфичных для Spring, в качестве нашего UserRegisterController. Это потому, что мы должны рассматривать spring-boot как любую другую деталь.

8. Ужасный основной класс

Наконец-то финальная часть!

@Bean
BeanFactoryPostProcessor beanFactoryPostProcessor(ApplicationContext beanRegistry) {
    return beanFactory -> {
        genericApplicationContext(
          (BeanDefinitionRegistry) ((AnnotationConfigServletWebServerApplicationContext) beanRegistry)
            .getBeanFactory());
    };
}

void genericApplicationContext(BeanDefinitionRegistry beanRegistry) {
    ClassPathBeanDefinitionScanner beanDefinitionScanner = new ClassPathBeanDefinitionScanner(beanRegistry);
    beanDefinitionScanner.addIncludeFilter(removeModelAndEntitiesFilter());
    beanDefinitionScanner.scan("com.baeldung.pattern.cleanarchitecture");
}

static TypeFilter removeModelAndEntitiesFilter() {
    return (MetadataReader mr, MetadataReaderFactory mrf) -> !mr.getClassMetadata()
      .getClassName()
      .endsWith("Model");
}

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

В нашем случае мы используем внедрение зависимостей spring-boot для создания всех наших экземпляров. Поскольку мы не используем @Component, мы сканируем наш корневой пакет и игнорируем только объекты Model.

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

9. Заключение

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

Тем не менее, мы оставили некоторые принципы в стороне. Но все они ведут в одном направлении. Мы можем резюмировать его, процитировав его создателя: «Хороший архитектор должен максимизировать количество непринятых решений». И мы сделали это, защитив наш бизнес-код от деталей с помощью границ.