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