Um exemplo de arquitetura hexagonal em Java

Uma tradução do artigo foi preparada especificamente para os alunos do curso Java Developer .





Como desenvolvedores, geralmente temos que lidar com código legado, difícil de manter. Você sabe como é difícil entender a lógica simples em um código espaguete grande e complicado. Melhorar o código ou desenvolver novas funcionalidades se torna um pesadelo para o desenvolvedor.

Um dos principais objetivos do design de software é a facilidade de manutenção. Código mal mantido torna-se difícil de gerenciar. Não é apenas difícil de escalar, mas está se tornando um problema para atrair novos desenvolvedores.

No mundo da TI, as coisas estão mudando rapidamente. Se você for solicitado a implementar urgentemente novas funcionalidades ou se desejar mudar de um banco de dados relacional para o NoSQL, qual será sua primeira reação?



Uma boa cobertura de teste aumenta a confiança dos desenvolvedores de que não haverá problemas com o novo lançamento. No entanto, se sua lógica de negócios estiver entrelaçada com a lógica de infraestrutura, poderá haver problemas com seus testes.


Por que eu?

Mas há bastante conversa vazia, vejamos a arquitetura hexagonal. O uso deste modelo ajudará você a melhorar a capacidade de manutenção, a testabilidade e outros benefícios.

Introdução à arquitetura hexagonal


O termo Arquitetura Hexagonal (arquitetura hexagonal e hexagonal) foi cunhado em 2006 por Alistair Cockburn. Esse estilo arquitetônico também é conhecido como Arquitetura de portas e adaptadores . Em palavras simples, os componentes do seu aplicativo interagem através de muitos pontos de extremidade (portas). Para processar solicitações, você deve ter adaptadores que correspondam às portas.

Aqui você pode desenhar uma analogia com as portas USB do computador. Você pode usá-los se tiver um adaptador compatível (carregador ou unidade flash).



Essa arquitetura pode ser representada esquematicamente como um hexágono com lógica de negócios no centro (no núcleo), cercado pelos objetos com os quais interage e pelos componentes que os controlam, fornecendo dados de entrada.

Na vida real, usuários, chamadas de API, scripts automatizados e testes de unidade interagem e fornecem informações para o seu aplicativo. Se sua lógica de negócios estiver combinada com a lógica da interface do usuário, você encontrará muitos problemas. Por exemplo, será difícil alternar a entrada de dados da interface do usuário para testes de unidade.

O aplicativo também interage com objetos externos, como bancos de dados, filas de mensagens, servidores da Web (via chamadas da API HTTP), etc. Se necessário, migre o banco de dados ou faça o upload de dados para um arquivo, você poderá fazer isso sem afetar os negócios. lógica.

Como o nome indica, “ portas e adaptadores ”, existem “portas” por meio das quais a interação ocorre e “adaptadores” são componentes que processam a entrada do usuário e a convertem para o “idioma” do domínio. Os adaptadores encapsulam a lógica da interação com sistemas externos, como bancos de dados, filas de mensagens, etc., e facilitam a comunicação entre a lógica de negócios e objetos externos.

O diagrama abaixo mostra as camadas nas quais o aplicativo está dividido.



A arquitetura hexagonal distingue três camadas no aplicativo: domínio, aplicativo e infraestrutura:

  • Domínio . A camada contém a lógica de negócios principal. Ele não precisa conhecer os detalhes da implementação das camadas externas.
  • Aplicação . Uma camada atua como uma ponte entre as camadas de um domínio e a infraestrutura.
  • Infraestrutura . A implementação da interação do domínio com o mundo exterior. As camadas internas parecem uma caixa preta para ele.

De acordo com essa arquitetura, dois tipos de participantes interagem com o aplicativo: primário (driver) e secundário (controlado). Os principais atores enviam solicitações e gerenciam o aplicativo (por exemplo, usuários ou testes automatizados). Os secundários fornecem a infraestrutura para comunicação com o mundo externo (são adaptadores de banco de dados, clientes TCP ou HTTP).
Isso pode ser representado da seguinte maneira: O



lado esquerdo do hexágono consiste em componentes que fornecem entrada para o domínio (eles “controlam” o aplicativo) e o lado direito consiste em componentes controlados por nosso aplicativo.

Exemplo


Vamos criar um aplicativo que armazene críticas de filmes. O usuário deve poder enviar uma solicitação com o nome do filme e obter cinco revisões aleatórias.

Para simplificar, faremos um aplicativo de console com armazenamento de dados na RAM. A resposta ao usuário será exibida no console.

Temos um usuário (usuário) que envia uma solicitação para o aplicativo. Assim, o usuário se torna um "gerente" (motorista). O aplicativo deve poder receber dados de qualquer tipo de armazenamento e enviar os resultados para o console ou para um arquivo. Os objetos gerenciados (controlados) serão o “data warehouse” ( IFetchMovieReviews) e a “impressora de resposta” ( IPrintMovieReviews).

A figura a seguir mostra os principais componentes de nosso aplicativo.



À esquerda, estão os componentes que fornecem entrada de dados no aplicativo. À direita estão os componentes que permitem interagir com o banco de dados e o console.

Vamos dar uma olhada no código do aplicativo.

Porta de controle

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

Portas gerenciadas

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

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

Adaptadores de porta gerenciada Os

filmes serão obtidos no repositório do filme (MovieReviewsRepo). Exibir resenhas de filmes no console será uma aula ConsolePrinter. Vamos implementar as duas interfaces acima.

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")));
    }
}

Domínio


A principal tarefa do nosso aplicativo é processar solicitações de usuários. Você precisa obter os filmes, processá-los e transferir os resultados para a "impressora". No momento, temos apenas uma funcionalidade - a pesquisa de filmes. Para processar solicitações de usuários, usaremos a interface padrão Consumer.

Vamos olhar para a classe principal 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);
    }
}

Agora, definimos uma classe CommandMapperModelque mapeará comandos para manipuladores.

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;
    }
}

Adaptadores de porta de controle


O usuário irá interagir com o nosso sistema através da interface IUserInput. A implementação usará ModelRunnere delegará a execução.

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);
    }
}

Agora vamos olhar para o usuário que usa a interface acima.

public class MovieUser {
    private IUserInput userInputDriverPort;

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

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

inscrição


Em seguida, crie um aplicativo de console. Adaptadores gerenciados são adicionados como dependências. O usuário criará e enviará uma solicitação ao aplicativo. O aplicativo receberá dados, processará e exibirá uma resposta ao console.

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);
    }

}

O que pode ser melhorado, alterado


  • Em nossa implementação, você pode alternar facilmente de um armazenamento de dados para outro. A implementação de armazenamento pode ser injetada no código sem alterar a lógica de negócios. Por exemplo, você pode transferir dados da memória para um banco de dados gravando um adaptador de banco de dados.
  • Em vez de enviar para o console, você pode implementar uma "impressora", que gravará dados em um arquivo. Em um aplicativo com várias camadas, fica mais fácil adicionar funcionalidade e corrigir bugs.
  • Para testar a lógica de negócios, você pode escrever testes complexos. Os adaptadores podem ser testados isoladamente. Assim, é possível aumentar a cobertura geral do teste.

Conclusão


As seguintes vantagens da arquitetura hexagonal podem ser observadas:

  • Acompanhamento - camadas pouco acopladas e independentes. Torna-se fácil adicionar novos recursos a uma camada sem afetar outras.
  • Testabilidade - os testes de unidade são escritos de maneira simples e rápida. Você pode escrever testes para cada camada usando objetos stub que simulam dependências. Por exemplo, podemos remover a dependência no banco de dados criando um armazém de dados na memória.
  • Adaptabilidade - a principal lógica de negócios se torna independente de alterações em objetos externos. Por exemplo, se você precisar migrar para outro banco de dados, não será necessário fazer alterações no domínio. Nós podemos fazer um adaptador apropriado para o banco de dados.

Referências



Isso é tudo. Vejo você no curso !

All Articles