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