Estratégia Arquitetural

Estratégia

Existem diferentes formas de projetar software. Pelos últimos 20 anos, a indústria de software tem usado vários métodos para criar seus produtos, cada um com suas vantagens e desvantagens. Trabalhamos com a intenção de aplicar um método que surgiu e evoluiu ao longo das duas últimas décadas, mas que vem se consolidando mais claramente durante os últimos anos: Domain-Driven Design

Acreditamos que DDD só mostra seu real valor quando aplicado em nível estratégico. Acreditamos que podemos usar DDD para decompor nossas soluções atuais, que são todas elas monolíticas em essência, em serviços fisicamente distribuídos, e acreditamos que usar a arquitetura baseada em microserviços seja a solução adequada para o momento.

Nosso objetivo aqui não é apresentar DDD ou a arquitetura de microserviços, mas sim demonstrar como nós, aqui no STF, lidamos com todos os novos desafios inerentes a implantação de tal abordagem para definirmos um modelo de referência que suporte a definição, a construção, a implantação e a operação dos nossos produtos.

Modelo Conceitual

Um ponto chave para construção de um modelo de referência é a definição de um modelo conceitual, com diretrizes para definição da granularidade, das fronteiras e das interações entre os componentes da arquitetura. Sem esse modelo, nós provavelmente voltaríamos a algo como a Big Ball of Mud, ou arquitetura espaguete, como é popularmente conhecida.

Achar um forma de sair de uma arquitetura monolítica para uma solução modular, distribuída, talvez seja o maior desafio aqui. Essencialmente, o que precisamos é encontrar uma forma de aplicar o princípio da responsabilidade única ao nível arquitetural. Domain-Driven Design, criado por Eric Evans, introduz uma técnica chamada Strategic Design, que nos ajuda a modelar grandes soluções como um conjunto de soluções menores. O pattern central da ferramenta é o Bounded Context. Com DDD, cada pequeno modelo é projetado como um Bounded Context, ou seja, cada BC tem seu próprio conjunto de conceitos, seu próprio modelo, ou, ainda, na linguagem DDD, tem sua própria Ubiquitous Language. Com Strategic Design, o conjunto dos vários BC’s que compõem a solução deve ser mapeado em um Context Map, que explicita o tipo de relacionamento entre eles. Esses relacionamentos são especialmente importantes porque em um cenário em que múltiplas equipes estão envolvidas na solução, uma por BC, o tipo de relacionamento entre os BC’s define também o tipo de relacionamento entre as equipes.

Usaremos um BC por serviço porque, como o próprio Evans, acreditamos que “a forma como um microserviço opera faz dele um ótimo bounded context”. Portanto, horizontalmente, o mapa com os contextos definirá as fronteiras de cada microserviço e as integrações entre eles. Verticalmente, os serviços devem ser organizados em camadas, de acordo com o tipo de serviço, conforme ilustrado na figura abaixo.

Visão Lógica

Uma importante consequência dessa divisão é que você pode usar diferentes arquiteturas em diferentes bounded contexts. Por exemplo, um BC pode ser implementado usando uma arquitetura baseada em DDD, outro pode usar uma arquitetura em três camadas para CRUD’s e outra pode usar uma arquitetura derivada do pattern CQRS. A figura abaixo ilustra o sistema com múltiplos BC’s, cada um usando um estilo arquitetural diferente. Observe também que cada BC é full-stack, contém tudo o que é necessário para implementar uma determinada função de negócio, desde a persistência de dados até a interface do usuário, que juntamente com a interface de outros BC’s compõe a interface final do sistema, no caso, formando uma Single-Page Application.

Uma vantagem dessa abordagem é que você pode definir a arquitetura mais apropriada considerando as características de cada contexto. Por exemplo, um contexto com funcionalidades acessórias, de cadastro, não demanda a aplicação de uma arquitetura como DDD, que só entrega benefícios quando aplicado a um contexto mais complexo, geralmente presentes nos principais contextos que compõem a solução, denominados Core Domain.

Arquitetura

Até pouco tempo atrás o estilo arquitetural dominante era o famoso modelo data-centric, com a arquitetura em três camadas (apresentação, negócio e persistência). Recentemente, passamos a observar uma mudança, da arquitetura data-centric para uma arquitetura mais model-centric. Existem duas grandes diferenças entre essas duas arquiteturas. Primeiro, a camada de negócio é dividida em duas novas camadas: application layer e domain layer. Segundo, a camada de persistência se transforma em uma camada de infraestrutura.

Ao mesmo tempo, também observamos o crescimento de Event-Driven Architecture, com desenvolvedores cada vez mais experimentando os benefícios de padrões como Command/Query Responsibility Segregation (CQRS) e Event Sourcing (ES) CQRS não é uma abordagem abrangente como, por exemplo, é o caso com DDD. CQRS é um padrão que serve como guia para definição da arquitetura de um BC específico, possívelmente em um sistema complexo. Executar o design estratégico com DDD e identificar cada BC de seu sistema continua sendo o primeiro passo. Depois, CQRS pode ser uma alternativa válida para Domain Model e CRUD para implementação de um BC em particular.

A mudança que CQRS introduz é a possibilidade de dividir o modelo conceitual de um BC em modelos separados para alteração e consulta de informações. Como qualquer outro pattern, CQRS não se aplica para todos os casos. A lógica é que em alguns casos, particularmente em domínios mais sofisticados, ter o mesmo modelo conceitual para operações de alteração e consulta pode levar a um modelo mais complexo, desnecessariamente. CQRS é ideal para sistemas colaborativos, task-based, em que múltiplos usuários podem alterar/consultar as mesmas informações. Nesse tipo de sistema, os usuários competem pelos mesmos recursos, o que significa que cada um deles pode estar vendo informações desatualizadas. Isso ocorre porque a lógica de negócio é particularmente complexa e geralmente envolve diversos módulos, alguns dos quais podendo até mesmo demandar carregamento dinâmico. Com CQRS a lógica de negócio pode ser decomposta em um conjunto de comandos individuais, que podem ser gerenciados mais facilmente, reduzindo a complexidade geral do sistema. De outra parte, a exigência de múltiplas representações da mesma informação pode ser tratada com modelos específicos para consultas específicas, o que também ajuda a resolver potenciais problemas de performance.

Existem algumas variações do pattern. Os modelos separados podem estar em módulos diferentes e talvez até em hardware’s diferentes. Os modelos podem compartilhar a mesma base de dados, que pode usada para integração entre os dois modelos. Neste caso, embora menos comum, os modelos podem inclusive compartilhar objetos. No caso de usarem bases separadas, deve haver um mecanimo de comunicação entre os dois modelos.

Como vimos, CQRS é apenas um guia. Ele direciona a definição do modelo conceitual de cada bounded context. Se aplicável, a implementação de cada modelo deve ser feita de acordo com suas características. Geralmente, podemos aplicar DDD para construção do modelo de escrita (Command Stack), enquanto uma abordagem mais data-centric pode ser aplicada para construção do modelo de leitura (Query Stack).

Layers

Componentes

Encontrar uma forma de construir soluções menores é um dos nossos grandes desafios, como vimos anteriormente, no entanto existem vários outros. Para endereçar cada um deles, vamos precisar de novas soluções, que impactarão diretamente na arquitetura. Abaixo, o conjunto de componentes que julgamos importantes:

Modelo de Referência

Se aplicarmos ao Modelo Conceitual descrito acima o conjunto de serviços de suporte necessários para viabilizar uma arquitetura como a descrita aqui, teremos o Modelo de Referência ilustrado na figura abaixo.

Componentes

Implementação

Até aqui definimos um modelo de referência para construção do ecosistema que suportará a arquitetura proposta. Agora, já entrando na construção desse ecosistema, precisamos definir como implementar cada componente do modelo. Spring Cloud, Netflix OSS e ELX Stach serão usados para implementar todos os componentes necessários.

Depois de uma breve introdução aos componentes do Spring Cloud, o Netflix OSS e da ELX, nós vamos apresentar o ecosistema que usaremos para implementar os serviços da autuação de processos.

Componentes

Spring Cloud é um projeto relativamente novo na família spring.io. Ele fornece um conjunto de componentes que podem ser usados para implementar o modelo conceitual descrito acima. Grande parte deles usam os componente da Netflix OSS, fornecendo uma forma mais simples de configuração e utilização baseada na proposta Spring Boot. Ou seja, você pode usar Spring Boot para configurar e usar qualquer dos componentes da Netflix utilizando, parar tanto, a camada de abstração fornecida pelos componentes do Spring Cloud. A tabela abaixo mapeia os componentes genéricos do nosso modelo conceitual para os componentes que efetivamente vão ser usados nos casos de uso reais.

Componentes Arquiteturais
Service DiscoveryNetflix Eureka
Dynamic Routing e Load BalancerNetflix Ribbon
Circuit BreakerNetflix Hystrix
MonitoringNetflix Hystrix Dashboard e Turbine
GatewayNetflix Zull
ConfigurationSpring Cloud Config
SecuritySpring Cloud e Sprint Security OAuth2
LoggingLogstash, Elasticsearch e Kibana

A figura abaixo ilustra a arquitetura com os componentes que serão utilizados para implementar os serviços do modelo conceitual.

Implementação

Integração

Uma aplicação monolítica geralmente tem um único banco relacional. Um benefício chave de se usar um banco como esse é que sua aplicação pode usar transações ACID, que garante a consistência dos dados. Basta abrir a transação, fazer as operações necessárias (insert, update e delete) e fazer o commit. Outro importante benefício de usar uma base relacional é que ela é acessada via SQL, que é a liguagem padrão de consultas. Você pode facilmente escrever uma consulta que combina dados de várias tabelas. Como todos os dados estão em um único banco de dados, é sempre fácil acessar as informações.

Infelizmente, acessar dados externos se torna bem mais difícil quando migramos para uma arquitetura de microserviços. Isso ocorre porque os dados de um microserviço são de propriedade exclusiva daquele microserviço e só podem ser acessados via API. O encapsulamento desses dados em cada microserviço reduz o acomplamento entre eles, garantindo que cada um possa envoluir independentemente dos outros.

Para piorar, diferentes microserviços podem usar diferentes tipos de bases. Aplicações modernas armazenam e processam diversos tipos de dados e um banco relacional nem sempre é a melhor escolha. Para alguns casos de uso, um tipo particular de banco NoSQL pode ser mais adequado por oferecer melhor performance e maior escalabilidade. Consequentemente, microserviços podem usar uma mistuta de bancos SQL e NoSQL, a denominada persistência poliglota.

Aplicar persistência poliglota pode trazer vários benefícios, incluindo a redução do acoplamento, melhor performance e melhor escalabilidade. Entretano, ela introduz alguns desafios importantes. O principal está em como garantir que as transações mantenham a consistência entre múltiplos serviços.

Event-Driven Architecture pode nos ajudar a resolver esses desafios. Nessa arquitetura, um micricroserviço publica um evento quando algo relevante acontece, como quando uma entidade de negócio é atualizada. Outros microserviços podem se registrar para receber esses eventos. Quando um deles recebe um evento, ele pode atualizar suas próprias entidades de negócio. Você pode usar eventos para implementar transações que envolvem múltiplos serviços. Um transação consiste de uma série de passos. Cada passo representa um microserviço que atualiza suas entidades de negócio e publica um evento que dispara o próximo passo. É importante observar que essas transações não são ACID. Elas oferecem garantias mais fracas como a consistência eventual. Este modelo de transação tem sido referenciado como BASE Model.

Com Event-Driven Architecture existe também o problema de atomicamente atualizar a base de dados e publicar um evento. Por exemplo, o Serviço de Petição pode inserir um registro na tabela de petições e publicar o evento Petição Recebida. É fundamental que essas duas operações sejam atomicas. Se o serviço cair depois de atualizar a base mas antes de publicar o evento, o sistema ficará inconsistente. A forma mais usual de garantir atomicidade é usar uma transação distribuída envolvendo o banco de dados e o message broker. Entretanto, for razões já descritas aqui e outras como as descritas no teorema CAP, isso é exatamente o que não queremos fazer.

Existem algumas formas de se resolver esse problema. Pela simplicidade, optamos por um processo que envolve apenas transações locais. Precisamos ter um tabela de eventos, que funciona como uma fila de mensagens, na mesma base de dados que armazena as entidades de negócio. A aplicação inicia uma transação local, atualiza o estados da entidade de negócio, insere um evento na tabela de eventos e fecha a transação. Um processo separado consulta periodicamente a tabela de eventos, publica os eventos para o message broker e então uma transação local para marcar o evento como publicado. A figura abaixo ilustra esse design.

Integração

Estrutura de Empacotamento

A arquitetura proposta aqui não prevê a utilização de componentes EJB, ou seja, somente são utilizados componentes web (Servlets). Este design simplifica a estrutura de empacotamento já que podemos colocar todo o código do sistema em um único módulo web. Em aplicações como essas, a opção mais comum é empacotar todo a aplicação em um único arquivo “.war” (Web Archive), no entanto, optamos por empacotar os serviços em arquivos “.jar” (Java Archive). Para tanto, precisamos usar um servidor HTTP embarcado. Uma das vantagens dessa abordagem é que ela facilita muito o trabalho de execução da aplicação. Você pode rodar sua aplicação da mesma forma que roda qualquer outra aplicação Java. Debugar também é muito fácil; você não precisa de qualquer instalação adicional, nem usar IDE’s ou plugins especiais. Além disso, podemos selecionar o servidor HTTP mais apropriado, sendo que atualmente existem opções muito leves, capazes de reduzir o tempo de statup da aplicação para apenas alguns segundos.

Para viabilizar a arquitetura de implantação definida abaixo, adicionaremos o Jar do serviço em uma imagem Docker, que nesse caso servirá como recipiente para execução da aplicação Java rodando um determinado serviço. Conforme definido anteriormente, usaremos Spring Boot como principal framework para implementação dos serviço. Quando usamos a a combinação Spring Boot e Docker geralmente obtemos imagens com arquitetura semelhante à ilustrada abaixo:

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
e465b0e4cc7e 26 hours ago /bin/sh -c #(nop) ENTRYPOINT &{["/docker-entr 0 B
missing 26 hours ago /bin/sh -c sh -c 'touch /app.jar' 86.81 MB
missing 26 hours ago /bin/sh -c #(nop) ADD file:c1f6175d71569467dc 1.004 kB
missing 26 hours ago /bin/sh -c #(nop) COPY file:3aacbf6f0e870cbf2 97 B
missing 26 hours ago /bin/sh -c #(nop) ADD file:8fec0b0914fbe51fe1 86.81 MB
missing 11 weeks ago /bin/sh -c #(nop) VOLUME [/tmp] 0 B
missing 12 weeks ago /bin/sh -c apk add --no-cache --virtual=build 155.6 MB
missing 12 weeks ago /bin/sh -c #(nop) ENV JAVA_VERSION=8 JAVA_UPD 0 B
missing 12 weeks ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0 B
missing 12 weeks ago /bin/sh -c ALPINE_GLIBC_BASE_URL="https://git 6.935 MB
missing 3 months ago /bin/sh -c #(nop) ADD file:033ab063740d9ff4dc 4.798 MB

Observe, em vermelho, que o Jar do serviço contém todas as bibliotecas utilizadas. Como consequência, uma única alteração no Jar da aplicação invalida toda a layer, fazendo com que 86.81 MB sejam baixados novamente sempre que a imagem for atualizada no repositório de imagens. Para evitar isso, usamos a solução descrita no artigo Spring Boot’s fat jars vs. Docker. Basicamente, a lógica da solução é separar os jars mais modificados daqueles menos modificados para reduzir o tamanho de download a cada atualização da imagem. No nosso caso, podemos dividir as libs em três categorias: bibliotecas externas, ocupam o maior espaço no Jar e são atualizadas com baixa frequência; bibliotecas compartilhadas do stfdigital (core, por exemplo), ocupam pouco espaço do Jar, mas são atualizadas com uma frequência média; e a aplicação em si, que também ocupam pouco espaço do Jar, mas são atualizadas com frequência. Cada uma dessas categorias serão serapadas em layers específicas, resultando na seguinte estrutura:

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
bf560d188068 7 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/docker-entryp 0 B
d3dd017e011b 7 minutes ago /bin/sh -c sh -c 'touch /app/*' 2.812 kB
cf63c2b5d639 7 minutes ago /bin/sh -c #(nop) ADD file:2fed27996813e97007 1.004 kB
e084a265a161 7 minutes ago /bin/sh -c #(nop) COPY file:c31d4f91ed0cb5545 137 B
91fccef4e386 7 minutes ago /bin/sh -c #(nop) ADD dir:8044936efd3b30ae79a 317.6 kB
193798cba327 7 minutes ago /bin/sh -c #(nop) ADD dir:fa0bc6a98ce898224c1 73.42 kB
5cb25699a872 7 minutes ago /bin/sh -c #(nop) ADD dir:13458d12684ae8b6714 86.57 MB
7448c27b0aed 9 weeks ago /bin/sh -c #(nop) VOLUME [/tmp] 0 B
235ff60512aa 10 weeks ago /bin/sh -c apk add --no-cache --virtual=build 155.7 MB
missing 10 weeks ago /bin/sh -c #(nop) ENV JAVA_VERSION=8 JAVA_UPD 0 B
missing 11 weeks ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0 B
missing 11 weeks ago /bin/sh -c ALPINE_GLIBC_BASE_URL="https://git 6.935 MB
missing 3 months ago /bin/sh -c #(nop) ADD file:033ab063740d9ff4dc 4.798 MB

Agora a layer em vermelho não será baixada quando houver modificação das bibliotecas compartilhadas ou na aplicação em si. Isso diminuirá consideravelmente o tempo de atualização em 90% dos casos (irá baixar apenas cerca de 400kB no exemplo acima). Somente quando uma biblioteca externa for atualizada é que os 86MB serão baixados novamente.

Estrutura de Diretórios

TODO: Descrever a estrutura das imagens docker

Implantação

Implantar uma aplicação monolítica significa fazer o deploy de cópias idênticas de uma única aplicação em vários servidores (físicos ou virtuais). A implantação de uma aplicação monolítica nem sempre é totalmente simples, mas ela é muito mais simples do que a implantação de um aplicativo dividido em microservices.

Uma aplicação microservices consiste em dezenas ou mesmo centenas de serviços. Cada serviço pode utilizar um conjunto diferente de frameworks. Cada um é um mini-aplicativo com o seu próprio conjunto de requisitos. Por exemplo, você pode precisar executar um determinado número de instância de cada serviço baseados na demanda por esse serviço. Além disso, cada instância de serviço deve ser fornecido com o processador adequado, memória e recursos de armazenamento. O que é ainda mais desafiador é que, apesar desta complexidade, implantar esses serviços deve ser rápido, confiável e de baixo custo.

Existe alguns diferentes tipos de patterns que podem ser usados para implantação de microservices. Aqui nós vamos apresentar os patterns que julgamos mais adequados para o nosso caso.

Nossa Estratégia

Optamos por adotar o pattern Service Instance per Host. Neste pattern, você executa cada serviço em seu próprio host, isolado de qualquer outro serviço. Existem duas especializações desse pattern: Service Instance per Virtual Machine and Service Instance per Container.

Usaremos o Service Instance per Container pattern, em que cada serviço roda em seu próprio container. Containers são um mecanismo de virtualização no nível do sistema operacional. Um container consiste de um ou mais processos rodando em uma “sandbox”. Você pode limitar a memória e os recursos de CPU. Algumas implementações também permitem limitar a taxa de I/O. Há várias tecnologias que implementam esse conceito. Nós optamos por usar Docker. A figura abaixo ilustra o modelo descrito aqui.

Implantação

Para usar este pattern, você deve empacotar seu serviço como uma imagem. A imagem é um arquivo contendo o serviço e todas as bibliotecas necessárias para executar o serviço. Uma vez tendo gerado a imagem, você pode executá-la em um ou mais containers e cada container pode rodar em um ou mais hosts. Para facilitar o gerenciamento dos containers, nós poderíamos usar um cluster manager como o Kubernetes. Um cluster manager trata os hosts como recursos. Ele decide, automaticamente, em que host colocar cada container baseado nos recursos necessários pelo serviço e os recursos disponíveis em cada host.

Escolhemos utilizar containers porque eles são muito fáceis de se construir e iniciam muito rápido. Isso facilita muito a tarefa de montagem do ambiente. Atualmente nós conseguimos montar nosso ambiente, com mais de 10 container em nossas máquinas de desenvolvimento, sem que isso prejudique o desempenho geral da máquina e sem ter que entar nos detalhes de configuração de cada serviço.