Всё больше внимания уделяется программам с чистой архитектурой. Рассказываем и показываем, как разрабатывать такие приложения на языке Java.
Архитектура программного обеспечения − важная тема программной инженерии последних лет. На практике реализация приложений с чистой архитектурой часто вызывает затруднения. Мы не затрагиваем паттерны или алгоритмы: под прицелом другие проблемы с точки зрения Java-разработчиков.
Перед погружением в код посмотрим на архитектуру:
- Объекты: это самый стабильный код приложения, который не должен подвергаться внешним изменениям. Объектами могут быть методы и структуры данных.
- Сценарии использования: здесь происходит инкапсуляция и внедряется бизнес-логика.
- Адаптеры интерфейса: здесь располагаются сущности, происходит преобразование и представление данных в сценарии использования.
- Фреймворки и драйверы: эта область содержит инструменты и фреймворки для запуска приложения.
Ключевые понятия:
- Каждый уровень ссылается только на нижний и ничего не знает о существовании уровней выше
- Сценарии использования и сущности − это сердце вашего приложения, у них минимальный набор зависимостей от внешних библиотек
☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»
Реализация
Мы будем использовать Gradle и модули Java Jigsaw для обеспечения зависимости между различными слоями.
Сделаем простое приложение, чтобы понять работу архитектуры. Вот некоторые из его функций:
- создание пользователя;
- поиск пользователя;
- обработка списка всех пользователей;
- авторизация пользователя с паролем.
Начнем с внутренних уровней (сущностей или сценариев использования), затем реализуем слой адаптеров интерфейса и закончим внешним слоем. Мы продемонстрируем гибкость архитектуры изменениями реализации и переключением фреймворков.
Так выглядит проект:
Давайте погрузимся в разработку.
🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»
Внутренние уровни
Наши объекты и сценарии разделены на два проекта: «domain» и «usecase».
Эти пакеты − сердце нашего приложения.
Архитектура должна быть четкой. Из приведенного выше примера становятся понятными расположение и тип операций. При создании одиночного сервиса трудно определить его назначение и приходится погружаться в реализацию. В чистой архитектуре достаточно взглянуть на пакет usecase, чтобы увидеть список поддерживаемых операций.
Пакет entity содержит все сущности. В нашем случае будет только один пользователь:
package com.slalom.example.domain.entity; public class User { private String id; private String email; private String password; private String lastName; private String firstName; }
Модуль usecase содержит бизнес-логику. Начнем с простого сценария FindUser:
package com.slalom.example.usecase; import com.slalom.example.domain.entity.User; import com.slalom.example.domain.port.UserRepository; import java.util.List; import java.util.Optional; public final class FindUser { private final UserRepository repository; public Optional<User> findById(final String id) { return repository.findById(id); } public List<User> findAllUsers() { return repository.findAllUsers(); } }
У нас есть две операции, которые извлекают пользователей из хранилища, что стандартно для сервис-ориентированной архитектуры.
UserRepository − это интерфейс, который не реализован в текущем проекте. Это деталь нашей архитектуры, а детали реализовываются на внешних уровнях. Мы реализуем UserRepository при создании сценария использования (например, с помощью внедрения зависимости). Это даёт нам следующие преимущества:
- Бизнес-логика не изменяется в зависимости от реализации.
- Любое изменение в реализации не затрагивает бизнес-логику.
- Очень легко вносить серьезные изменения в реализацию.
Сделаем первую итерацию сценария CreateUser:
package com.slalom.example.usecase; import com.slalom.example.domain.entity.User; import com.slalom.example.domain.port.IdGenerator; import com.slalom.example.domain.port.PasswordEncoder; import com.slalom.example.domain.port.UserRepository; public final class CreateUser { private final UserRepository repository; private final PasswordEncoder passwordEncoder; private final IdGenerator idGenerator; public User create(final User user) { var userToSave = User.builder() .id(idGenerator.generate()) .email(user.getEmail()) .password(passwordEncoder.encode(user.getEmail() + user.getPassword())) .lastName(user.getLastName()) .firstName(user.getFirstName()) .build(); return repository.create(userToSave); } }
Как и в сценарии FindUser, нам нужен репозиторий, способ генерации идентификатора и способ кодирования пароля. Всё это − детали, которые будут реализованы позже на внешних уровнях.
Нам нужно проверить данные пользователя и убедиться в том, что он не был создан ранее. Пишем последнюю итерацию сценария:
package com.slalom.example.domain.usecase; import com.slalom.example.domain.entity.User; import com.slalom.example.domain.exception.UserAlreadyExistsException; import com.slalom.example.domain.port.IdGenerator; import com.slalom.example.domain.port.PasswordEncoder; import com.slalom.example.domain.port.UserRepository; import com.slalom.example.domain.usecase.validator.UserValidator; public final class CreateUser { private final UserRepository repository; private final PasswordEncoder passwordEncoder; private final IdGenerator idGenerator; public User create(final User user) { UserValidator.validateCreateUser(user); if (repository.findByEmail(user.getEmail()).isPresent()) { throw new UserAlreadyExistsException(user.getEmail()); } var userToSave = User.builder() .id(idGenerator.generate()) .email(user.getEmail()) .password(passwordEncoder.encode(user.getEmail() + user.getPassword())) .lastName(user.getLastName()) .firstName(user.getFirstName()) .build(); return repository.create(userToSave); } }
Если пользователь не проходит проверку или уже существует, происходит исключение. Эти исключения обрабатываются другими уровнями.
Последний сценарий LoginUser довольно прост и доступен на GitHub.
Для обеспечения границ оба пакета используют модули Jigsaw. Они позволяют не раскрывать детали реализации. Например, нет причин раскрывать класс UserValidator:
module slalom.example.domain { exports com.slalom.example.domain.entity; exports com.slalom.example.domain.port; exports com.slalom.example.domain.exception; } module slalom.example.usecase { exports com.slalom.example.usecase; requires slalom.example.domain; requires org.apache.commons.lang3; }
Ещё раз о роли внутренних уровней:
- Внутренние уровни содержат объекты и бизнес-логику. Это самая стабильная часть приложения.
- Любое взаимодействие с внешним миром (например, с базой данных или внешним сервисом) не реализовывается во внутренних уровнях.
- Не используются фреймворки и минимальные зависимости.
- Модули Jigsaw скрывают детали реализации.
Внешние уровни
Теперь, когда у нас есть сущности и сценарии использования, мы можем реализовать детали. Чтобы продемонстрировать гибкость архитектуры, сделаем несколько реализаций, которые используем в разных контекстах.
Начнем с репозитория.
Репозиторий
Реализация UserRepository с простым HashMap:
package com.slalom.example.db; import com.slalom.example.domain.entity.User; import com.slalom.example.domain.port.UserRepository; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; public class InMemoryUserRepository implements UserRepository { private final Map<String, User> inMemoryDb = new HashMap<>(); @Override public User create(final User user) { inMemoryDb.put(user.getId(), user); return user; } @Override public Optional<User> findById(final String id) { return Optional.ofNullable(inMemoryDb.get(id)); } @Override public Optional<User> findByEmail(final String email) { return inMemoryDb.values().stream() .filter(user -> user.getEmail().equals(email)) .findAny(); } @Override public List<User> findAllUsers() { return new ArrayList<>(inMemoryDb.values()); } }
Другие адаптеры
Адаптеры реализованы таким же способом, как интерфейс, объявленный в domain, и доступны на GitHub:
Собираем все вместе
Теперь нужно собрать детали реализации вместе. Для этого создаём папку с конфигурацией приложения и папку с кодом для запуска приложения.
Так выглядит конфигурационный файл:
public class ManualConfig { private final UserRepository userRepository = new InMemoryUserRepository(); private final IdGenerator idGenerator = new JugIdGenerator(); private final PasswordEncoder passwordEncoder = new Sha256PasswordEncoder(); public CreateUser createUser() { return new CreateUser(userRepository, passwordEncoder, idGenerator); } public FindUser findUser() { return new FindUser(userRepository); } public LoginUser loginUser() { return new LoginUser(userRepository, passwordEncoder); } }
Этот конфиг инициализирует сценарии использования с соответствующими адаптерами. Если нужно изменить реализацию, можно легко переключаться с одного адаптера на другой, не меняя код сценария.
Ниже представлен класс запуска приложения:
public class Main { public static void main(String[] args) { // Setup var config = new ManualConfig(); var createUser = config.createUser(); var findUser = config.findUser(); var loginUser = config.loginUser(); var user = User.builder() .email("john.doe@gmail.com") .password("mypassword") .lastName("doe") .firstName("john") .build(); // Create a user var actualCreateUser = createUser.create(user); System.out.println("User created with id " + actualCreateUser.getId()); // Find a user by id var actualFindUser = findUser.findById(actualCreateUser.getId()); System.out.println("Found user with id " + actualFindUser.get().getId()); // List all users var users = findUser.findAllUsers(); System.out.println("List all users: " + users); // Login loginUser.login("john.doe@gmail.com", "mypassword"); System.out.println("Allowed to login with email 'john.doe@gmail.com' and password 'mypassword'"); } }
Веб-фреймворки
Если потребуется использовать Spring Boot или Vert.x, нужно:
- Создать новую конфигурацию веб-приложения.
- Создать новый ApplicationRunner.
- Добавить контроллеры в папку адаптера. Контроллер отвечает за связь с внутренними уровнями.
Вот как выглядит контроллер Spring:
package com.slalom.example.spring.controller; @RestController public class UserController { private final CreateUser createUser; private final FindUser findUser; private final LoginUser loginUser; @RequestMapping(value = "/users", method = RequestMethod.POST) public UserWeb createUser(@RequestBody final UserWeb userWeb) { var user = userWeb.toUser(); return UserWeb.toUserWeb(createUser.create(user)); } @RequestMapping(value = "/login", method = RequestMethod.GET) public UserWeb login(@RequestParam("email") final String email, @RequestParam("password") final String password) { return UserWeb.toUserWeb(loginUser.login(email, password)); } @RequestMapping(value = "/users/{userId}", method = RequestMethod.GET) public UserWeb getUser(@PathVariable("userId") final String userId) { return UserWeb.toUserWeb(findUser.findById(userId).orElseThrow(() -> new RuntimeException("user not found"))); } @RequestMapping(value = "/users", method = RequestMethod.GET) public List<UserWeb> allUsers() { return findUser.findAllUsers() .stream() .map(UserWeb::toUserWeb) .collect(Collectors.toList()); } }
Полный пример этого приложения для Spring Boot и Vert.x доступен на GitHub.
Заключение
В этой статье мы продемонстрировали приложение с чистой архитектурой.
Обычно архитектура опирается на требования бизнеса, поэтому идеального решения не существует. От предъявленных требований зависит выбор инструментов, фреймворков и самого подхода к разработке. Архитектуру будущего приложения разделяют на уровни: данные, логику, сервисы, представление и так далее.
Плюсы
- Мощность: ваша бизнес-логика защищена, и ничто извне не выведет её строя. Ваш код не зависит от внешнего фреймворка, контролируемого кем-то другим.
- Гибкость: любой адаптер можно заменить в любое время любой другой реализацией на выбор. Переключение Spring boot/Vert.x происходит очень быстро.
- Отложенные решения: можно построить бизнес-логику, не зная деталей о будущих БД и фреймворках.
- Высокий уровень поддержки: легко определить компонент, вышедший из строя.
- Быстрая реализация: архитектура позволяет сосредоточиться и уменьшить стоимость разработки.
- Тесты: модульное тестирование проводится легче, так как зависимости четко определены.
- Интеграционные тесты: можно реализовать любой внешний сервис для использования во время интеграционных тестов. Например, если не хочется платить за базы данных в облаке, можно использовать реализацию адаптера в памяти.
Минусы
- Обучение: архитектура может стать непреодолимым препятствием для джуниоров.
- Больше классов, больше пакетов, больше проектов, и с этим ничего не сделать. Изучайте другие языки, например, Kotlin. Он поможет уменьшить количество создаваемых файлов.
- Высокая сложность проекта.
- Не подходит маленьким проектам.
Понравилась статья о разработке приложений с чистой архитектурой? Другие статьи по теме:
- Подробный гайд по разработке Android-приложений с помощью Clean Architecture
- Что такое микросервисная архитектура и когда ее применять
- ТОП-20 популярных Java-репозиториев на Github
Источник: Создание приложений с чистой архитектурой на языке программирования Java 11 на Medium
Комментарии