16390

Создаём приложение с чистой архитектурой на Java 11

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


Архитектура программного обеспечения − важная тема программной инженерии последних лет. На практике реализация приложений с чистой архитектурой часто вызывает затруднения. Мы не затрагиваем паттерны или алгоритмы: под прицелом другие проблемы с точки зрения Java-разработчиков.

Перед погружением в код посмотрим на архитектуру:

  • Объекты: это самый стабильный код приложения, который не должен подвергаться внешним изменениям. Объектами могут быть методы и структуры данных.
  • Сценарии использования: здесь происходит инкапсуляция и внедряется бизнес-логика.
  • Адаптеры интерфейса: здесь располагаются сущности, происходит преобразование и представление данных в сценарии использования.
  • Фреймворки и драйверы: эта область содержит инструменты и фреймворки для запуска приложения.

Ключевые понятия:

  • Каждый уровень ссылается только на нижний и ничего не знает о существовании уровней выше
  • Сценарии использования и сущности − это сердце вашего приложения, у них минимальный набор зависимостей от внешних библиотек

Реализация

Мы будем использовать Gradle и модули Java Jigsaw для обеспечения зависимости между различными слоями.

Сделаем простое приложение, чтобы понять работу архитектуры. Вот некоторые из его функций:

  • создание пользователя;
  • поиск пользователя;
  • обработка списка всех пользователей;
  • авторизация пользователя с паролем.

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

Так выглядит проект:

Давайте погрузимся в разработку.

Внутренние уровни

Наши объекты и сценарии разделены на два проекта: «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. Он поможет уменьшить количество создаваемых файлов.
  • Высокая сложность проекта.
  • Не подходит маленьким проектам.

Понравилась статья о разработке приложений с чистой архитектурой? Другие статьи по теме:

Источник: Создание приложений с чистой архитектурой на языке программирования Java 11 на Medium

РУБРИКИ В СТАТЬЕ

Комментарии

BUG
LIVE