admin 23 февраля 2019

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

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

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

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

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Аналитик данных
Екатеринбург, по итогам собеседования
PHP Developer
от 200000 RUB до 270000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ