Всё больше внимания уделяется программам с чистой архитектурой. Рассказываем и показываем, как разрабатывать такие приложения на языке 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
Комментарии
Если рассматривать в контексте спринга, то UseCase представляется Сервисом ? Или сервисы также делаются вроде UserService, а UseCase это просто компонент-прослойка между контроллером и сервисом ?