Перейти к основному содержимому

Чистая архитектура с Spring Boot

· 10 мин. чтения

1. Обзор

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

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

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

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

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

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

./90007f7bec7d450030df3ed686b779ce.png

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

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. Модульное тестирование

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

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

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

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

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

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);
}
}

Как мы видим, мы делаем все шаги варианта использования. Также этот слой отвечает за управление танцем сущности. Тем не менее, мы не делаем никаких предположений о том, как работает пользовательский интерфейс или база данных. Но мы используем 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. Модели запросов и ответов

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

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

class UserRequestModel {

String login;
String password;

// Getters, setters, and constructors
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@Id
String name;

String password;

LocalDateTime creationTime;

//Getters, setters, and constructors
}

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

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

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

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

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

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 . Если вместо этого мы выберем UserDsGateway , то у других пользователей возникнет соблазн нарушить принцип разделения интерфейса .

6.2. API регистрации пользователей

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

@RestController
class UserRegisterController {

final UserInputBoundary userInput;

// Constructor

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

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

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);
}
}

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

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

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

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

7. Драйверы и платформы

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

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

До сих пор мы не использовали аннотацию 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.foreach.pattern.cleanarchitecture");
}

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

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

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

./43be62d61e40cf67064b61af61041587.png

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

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

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

Как обычно, полный код доступен на GitHub .