Пример гексагональной архитектуры на Java

Автор оригинала: Animesh Gaitonde
  • Перевод
Перевод статьи подготовлен специально для студентов курса «Разработчик Java».





Как разработчикам нам часто приходится сталкиваться с легаси кодом, который тяжело поддерживать. Вы знаете как бывает сложно понять простую логику в большом запутанном спагетти-коде. Улучшение кода или разработка новой функциональности становятся ночным кошмаром для разработчика.

Одна из основных целей проектирования программного обеспечения — это удобство сопровождения. Код, который плохо поддерживать, становится сложными в управлении. Его не только трудно масштабировать, но становится проблемой привлечь новых разработчиков.

В мире ИТ все движется быстрыми темпами. Если вас попросят срочно реализовать новую функциональность или вы захотите перейти с реляционной базы данных на NoSQL, то какая будет ваша первая реакция?



Хорошее тестовое покрытие повышает уверенность разработчиков в том, что с новым релизом не будет проблем. Однако если ваша бизнес-логика переплетена с инфраструктурной логикой, то с ее тестированием могут быть проблемы.


Почему я?

Но хватит пустой болтовни, давайте посмотрим на гексагональную архитектуру. Использование этого шаблона поможет вам улучшить сопровождаемость, тестируемость и получить другие преимущества.

Введение в гексагональную архитектуру


Термин Hexagonal Architecture (гексагональная, шестиугольная архитектура) придумал в 2006 году Алистер Коберн (Alistair Cockburn). Этот архитектурный стиль также известен как Ports And Adapters Architecture (архитектура портов и адаптеров). Говоря простыми словами, компоненты вашего приложения взаимодействуют через множество конечных точек (портов). Для обработки запросов у вас должны быть адаптеры, соответствующие портам.

Здесь можно провести аналогию с USB-портами на компьютере. Вы можете ими воспользоваться, если у вас есть совместимый адаптер (зарядное устройство или флешка).



Эту архитектуру можно схематически представить в виде шестиугольника с бизнес-логикой в самом центре (в ядре), окруженную объектами с которыми она взаимодействует, и компонентами, которые ею управляют, предоставляя входные данные.

В реальной жизни с вашим приложением взаимодействуют и предоставляют входные данные пользователи, вызовы API, автоматизированные скрипты и модульное тестирование. Если ваша бизнес-логика смешана с логикой пользовательского интерфейса, то вы столкнетесь с многочисленными проблемами. Например, будет сложно переключить ввод данных с пользовательского интерфейса на модульные тесты.

Также приложение взаимодействует с внешними объектами, такими как базы данных, очереди сообщений, веб-серверы (через вызовы HTTP API) и т. д. При необходимости мигрировать базу данных или выгрузить данные в файл у вас должна быть возможность это сделать, не затрагивая бизнес-логику.

Как следует из названия “порты и адаптеры”, есть ”порты”, через которые происходит взаимодействие и “адаптеры” — компоненты, обрабатывающие пользовательский ввод и преобразующие его на “язык” домена. Адаптеры инкапсулируют логику взаимодействия с внешними системами, такими как базы данных, очереди сообщений и др., облегчают связь между бизнес-логикой и внешними объектами.

На диаграмме ниже показаны слои, на которые разделено приложение.



Гексагональная архитектура выделяет в приложении три слоя: домен (domain), приложение (application) и инфраструктура (framework):

  • Домен. Слой содержит основную бизнес-логику. Он не должен знать детали реализации внешних слоев.
  • Приложение. Слой действует как связующее звено между слоями домена и инфраструктуры.
  • Инфраструктура. Реализация взаимодействия домена с внешним миром. Внутренние слои выглядят для него как черный ящик.

Согласно этой архитектуре, с приложением взаимодействуют два типа участников: основные (driver) и вторичные (driven). Основные действующие лица отправляют запросы и управляют приложением (например, пользователи или автоматизированные тесты). Вторичные обеспечивают инфраструктуру для связи с внешним миром (это адаптеры базы данных, TCP- или HTTP-клиенты).
Это можно представить так:



Левая сторона шестиугольника состоит из компонент, обеспечивающих ввод для домена (они “управляют” приложением), а правая — из компонент, которые управляются нашим приложением.

Пример


Давайте спроектируем приложение, которое будет хранить обзоры фильмов. У пользователя должна быть возможность отправить запрос с названием фильма и получить пять случайных отзывов.

Для простоты сделаем консольное приложение с хранением данных в оперативной памяти. Ответ пользователю будем выводить на консоль.

У нас есть пользователь (User), который отправляет запрос приложению. Таким образом, пользователь становится “управляющим” (driver). Приложение должно уметь получать данные из любого типа хранилища и выводить результаты на консоль или в файл. Управляемыми (driven) объектами будут “хранилище данных” (IFetchMovieReviews) и “принтер ответов” (IPrintMovieReviews).

На следующем рисунке показаны основные компоненты нашего приложения.



Слева находятся компоненты, которые обеспечивают ввод данных в приложение. Справа — компоненты, которые позволяют взаимодействовать с базой данных и консолью.

Давайте рассмотрим код приложения.

Управляющий порт

public interface IUserInput {
    public void handleUserInput(Object userCommand);
}

Управляемые порты

public interface IFetchMovieReviews {
    public List<MovieReview> fetchMovieReviews(MovieSearchRequest movieSearchRequest);
}

public interface IPrintMovieReviews {
    public void writeMovieReviews(List<MovieReview> movieReviewList);
}

Адаптеры управляемого порта

Фильмы будем получить из репозитория фильмов (MovieReviewsRepo). Выводить обзоры фильмов на консоль будет класс ConsolePrinter. Давайте реализуем два вышеупомянутых интерфейса.

public class ConsolePrinter implements IPrintMovieReviews {
    @Override
    public void writeMovieReviews(List<MovieReview> movieReviewList) {
        movieReviewList.forEach(movieReview -> {
            System.out.println(movieReview.toString());
        });
    }
}

public class MovieReviewsRepo implements IFetchMovieReviews {
    private Map<String, List<MovieReview>> movieReviewMap;

    public MovieReviewsRepo() {
        initialize();
    }

    public List<MovieReview> fetchMovieReviews(MovieSearchRequest movieSearchRequest) {

        return Optional.ofNullable(movieReviewMap.get(movieSearchRequest.getMovieName()))
            .orElse(new ArrayList<>());
    }

    private void initialize() {
        this.movieReviewMap = new HashMap<>();
        movieReviewMap.put("StarWars", Collections.singletonList(new MovieReview("1", 7.5, "Good")));
        movieReviewMap.put("StarTreck", Arrays.asList(new MovieReview("1", 9.5, "Excellent"), new MovieReview("1", 8.5, "Good")));
    }
}

Домен


Основная задача нашего приложения — обрабатывать запросы пользователей. Необходимо получить фильмы, обработать их и передать результаты “принтеру”. На данный момент у нас есть только одна функциональность — поиск фильмов. Для обработки пользовательских запросов будем использовать стандартный интерфейс Consumer.

Давайте посмотрим на основной класс MovieApp.

public class MovieApp implements Consumer<MovieSearchRequest> {
    private IFetchMovieReviews fetchMovieReviews;
    private IPrintMovieReviews printMovieReviews;
    private static Random rand = new Random();

    public MovieApp(IFetchMovieReviews fetchMovieReviews, IPrintMovieReviews printMovieReviews) {
        this.fetchMovieReviews = fetchMovieReviews;
        this.printMovieReviews = printMovieReviews;
    }

    private List<MovieReview> filterRandomReviews(List<MovieReview> movieReviewList) {
        List<MovieReview> result = new ArrayList<MovieReview>();
        // logic to return random reviews
        for (int index = 0; index < 5; ++index) {
            if (movieReviewList.size() < 1)
                break;
            int randomIndex = getRandomElement(movieReviewList.size());
            MovieReview movieReview = movieReviewList.get(randomIndex);
            movieReviewList.remove(movieReview);
            result.add(movieReview);
        }
        return result;
    }

    private int getRandomElement(int size) {
        return rand.nextInt(size);
    }

    public void accept(MovieSearchRequest movieSearchRequest) {
        List<MovieReview> movieReviewList = fetchMovieReviews.fetchMovieReviews(movieSearchRequest);
        List<MovieReview> randomReviews = filterRandomReviews(new ArrayList<>(movieReviewList));
        printMovieReviews.writeMovieReviews(randomReviews);
    }
}

Теперь определим класс CommandMapperModel, который будет сопоставлять команды с обработчиками.

public class CommandMapperModel {
    private static final Class<MovieSearchRequest> searchMovies = MovieSearchRequest.class;

    public static Model build(Consumer<MovieSearchRequest> displayMovies) {
        Model model = Model.builder()
            .user(searchMovies)
            .system(displayMovies)
            .build();

        return model;
    }
}

Адаптеры управляющего порта


Пользователь будет взаимодействовать с нашей системой через интерфейс IUserInput. Реализация будет использовать ModelRunner и делегировать выполнение.

public class UserCommandBoundary implements IUserInput {
    private Model model;

    public UserCommandBoundary(IFetchMovieReviews fetchMovieReviews, IPrintMovieReviews printMovieReviews) {
        MovieApp movieApp = new MovieApp(fetchMovieReviews, printMovieReviews);
        model = CommandMapperModel.build(movieApp);
    }

    public void handleUserInput(Object userCommand) {
        new ModelRunner().run(model)
            .reactTo(userCommand);
    }
}

Теперь посмотрим на пользователя, который использует вышеупомянутый интерфейс.

public class MovieUser {
    private IUserInput userInputDriverPort;

    public MovieUser(IUserInput userInputDriverPort) {
        this.userInputDriverPort = userInputDriverPort;
    }

    public void processInput(MovieSearchRequest movieSearchRequest) {
        userInputDriverPort.handleUserInput(movieSearchRequest);
    }
}

Приложение


Далее, создадим консольное приложение. Управляемые адаптеры добавляем в качестве зависимостей. Пользователь будет создавать и отправлять запрос в приложение. Приложение будет получать данные, обрабатывать и выводить ответ на консоль.

public class Main {

    public static void main(String[] args) {
        IFetchMovieReviews fetchMovieReviews = new MovieReviewsRepo();
        IPrintMovieReviews printMovieReviews = new ConsolePrinter();
        IUserInput userCommandBoundary = new UserCommandBoundary(fetchMovieReviews, printMovieReviews);
        MovieUser movieUser = new MovieUser(userCommandBoundary);
        MovieSearchRequest starWarsRequest = new MovieSearchRequest("StarWars");
        MovieSearchRequest starTreckRequest = new MovieSearchRequest("StarTreck");

        System.out.println("Displaying reviews for movie " + starTreckRequest.getMovieName());
        movieUser.processInput(starTreckRequest);
        System.out.println("Displaying reviews for movie " + starWarsRequest.getMovieName());
        movieUser.processInput(starWarsRequest);
    }

}

Что можно улучшить, изменить


  • В нашей реализации можно легко переключиться с одного хранилища данных на другое. Реализация хранилища может быть внедрена (inject) в код без изменения бизнес-логики. Например, можно перенести данные из памяти в базу данных, написав адаптер базы данных.
  • Вместо вывода на консоль можно реализовать “принтер”, который будет записывать данные в файл. В таком многослойном приложении становится проще добавлять функциональность и исправлять ошибки.
  • Для проверки бизнес-логики можно написать комплексные тесты. Адаптеры можно тестировать изолированно. Таким образом, можно увеличить общее тестовое покрытие.

Заключение


Можно отметить следующие преимущества гексагональной архитектуры:

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

Ссылки



На этом все. До встречи на курсе!
OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 3

    +3
    Интересно, конечно, наблюдать, как Agile-коучи «изобретают» архитектурные паттерны, которые оказываются на деле давно известной «слоеной» архитектурой основанной на «dependency inversion»
      0
      Tom Hombergs делал доклад по теме
      Get Your Hands Dirty on Clean Architecture — его же книга
      когда представится возможность чистого творчества — обязательно попробую )
        0
        Не совсем понимаю смысловую нагрузку MovieUser, не могли бы пояснить поподробнее?

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое