Como refatorar um monolítico em microsserviços

Este guia de referência é o segundo de uma série de quatro partes sobre como projetar, criar e implantar microsserviços. Esta série descreve os vários elementos de uma arquitetura de microsserviços. A série inclui informações sobre os benefícios e as desvantagens do padrão de arquitetura de microsserviços e como aplicá-lo.

  1. Introdução a microsserviços
  2. Como refatorar um monolítico em microsserviços (este documento)
  3. Comunicação entre serviços em uma configuração de microsserviços
  4. Rastreamento distribuído em um aplicativo de microsserviços

Esta série é destinada a desenvolvedores e arquitetos de aplicativos que projetam e implementam a migração para refatorar um aplicativo monolítico em um aplicativo de microsserviços.

O processo de transformação de um aplicativo monolítico em microsserviços é uma forma de modernização de aplicativos (em inglês). Para realizar a modernização do aplicativo, recomendamos que você não refatore todo o código ao mesmo tempo. Em vez disso, recomendamos que você refatore gradualmente o aplicativo monolítico. Ao refatorar gradualmente um aplicativo, você cria gradualmente um novo aplicativo que consiste em microsserviços e o executa junto do aplicativo monolítico. Essa abordagem também é conhecida como padrão Strangler Fig (em inglês). Com o tempo, a quantidade de funcionalidade implementada pelo aplicativo monolítico diminui até que desaparecer completamente ou se tornar outro microsserviço.

Para desacoplar os recursos de um monolítico, extraia com cuidado os dados, a lógica e os componentes voltados para o usuário do recurso e redirecione-os para o novo serviço. É importante que você tenha um bom entendimento do espaço do problema antes de passar para o espaço da solução.

Ao entender o espaço do problema, você entende os limites naturais no domínio que fornecem o nível certo de isolamento. Recomendamos que você crie serviços maiores em vez de serviços menores até entender completamente o domínio.

Definir limites de serviço é um processo iterativo. Como esse processo é uma quantidade não trivial de trabalho, você precisa avaliar continuamente o custo do desacoplamento comparado com os benefícios recebidos. Veja a seguir alguns fatores que ajudarão você a avaliar como abordar o desacoplamento de um monolítico:

  • Evite refatorar tudo de uma só vez. Para priorizar o desacoplamento de serviços, avalie o custo-benefício.
  • Os serviços em uma arquitetura de microsserviços são organizados em torno de preocupações de negócios, e não de questões técnicas.
  • Ao migrar serviços gradualmente, configure a comunicação entre serviços e monolíticos para passar por contratos de API bem definidos.
  • Os microserviços exigem muito mais automação: pense com antecedência sobre integração contínua (CI, na sigla em inglês), implantação contínua (CD, na sigla em inglês), geração de registros central e monitoramento.

Nas seções a seguir, apresentamos várias estratégias para desacoplar serviços e migrar gradativamente o aplicativo monolítico.

Desacoplar por design orientado a domínio

Os microsserviços precisam ser projetados para recursos de negócios, não para camadas horizontais, como acesso a dados ou mensagens. Os microsserviços também precisam ter acoplamento flexível e coesão funcional alta. Os microserviços são acoplados com flexibilidade se você puder alterar um serviço sem exigir que outros serviços sejam atualizados ao mesmo tempo. Um microsserviço é coeso se tiver uma única finalidade bem definida, como gerenciar contas de usuário ou processar pagamentos.

O projeto orientado por domínio (DDD, na sigla em inglês; link em inglês) exige uma boa compreensão do domínio para o qual o aplicativo é escrito. O conhecimento de domínio necessário para criar o aplicativo está dentro das pessoas que o entendem: os especialistas no domínio.

É possível aplicar a abordagem de DDD retroativamente a um aplicativo existente da seguinte maneira:

  1. Identifique um idioma onipresente, (em inglês) um vocabulário comum compartilhado entre todas as partes interessadas. Como desenvolvedor, é importante usar termos no código que uma pessoa não técnica possa entender. O que o código está tentando alcançar precisa ser um reflexo dos processos da empresa.
  2. Identifique os módulos (em inglês) relevantes no aplicativo monolítico e aplique o vocabulário comum a esses módulos.
  3. Defina contextos limitados (em inglês) em que você aplica limites explícitos aos módulos identificados com responsabilidades claramente definidas. Os contextos limitados que você identifica são candidatos a serem refatorados em microsserviços menores.

O diagrama a seguir mostra como aplicar contextos delimitados a um aplicativo de comércio eletrônico existente:

Contextos delimitados são aplicados a um aplicativo.

Figura 1. Os recursos do aplicativo são separados em contextos delimitados que migram para serviços.

Na figura 1, os recursos do aplicativo de comércio eletrônico são separados em contextos limitados e migrados para serviços da seguinte maneira:

  • Os recursos de gerenciamento de pedidos e fulfillment estão vinculados às seguintes categorias:
    • O recurso de gerenciamento de pedidos migra para o serviço de pedidos.
    • O recurso de gerenciamento de entrega de logística migra para o serviço de entrega.
    • O recurso de inventário migra para o serviço de inventário.
  • Os recursos de contabilidade são vinculados em uma única categoria:
    • Os recursos de consumidor, vendedores e terceiros são vinculados e migram para o serviço de contabilidade.

Priorizar serviços para migração

Um ponto de partida ideal para separar os serviços é identificar os módulos acoplados com flexibilidade no aplicativo monolítico. É possível escolher um módulo acoplado com flexibilidade como um dos primeiros candidatos a converter como um microsserviço. Para fazer uma análise de dependência de cada módulo, observe o seguinte:

  • O tipo da dependência: dependências de dados ou outros módulos.
  • Escala da dependência: como uma alteração no módulo identificado pode afetar outros módulos.

Migrar um módulo com muitas dependências de dados geralmente não é uma tarefa não trivial. Se você migrar os recursos primeiro e os dados relacionados posteriormente, é possível ler e gravar dados temporariamente em vários bancos de dados. Portanto, você precisa considerar a integridade dos dados e os desafios de sincronização.

Recomendamos a extração de módulos com diferentes requisitos de recursos m comparação com o restante do monolítico. Por exemplo, se um módulo tiver um banco de dados na memória, é possível convertê-lo em um serviço que pode ser implantado em hosts com memória mais alta. Ao transformar módulos com requisitos de recursos específicos em serviços, é possível facilitar muito o escalonamento do aplicativo.

Do ponto de vista operacional, refatorar um módulo como o próprio serviço também significa ajustar as estruturas de equipe atuais. O melhor caminho para simplificar a responsabilidade é capacitar pequenas equipes que têm um serviço inteiro.

Outros fatores que podem afetar a maneira como você prioriza os serviços de migração incluem criticidade comercial, cobertura abrangente de testes, postura de segurança do aplicativo e adesão organizacional. Com base nas suas avaliações, é possível classificar os serviços conforme descrito no primeiro documento desta série, pelo benefício que você recebe da refatoração.

Extrair um serviço de um monolítico

Depois de identificar o candidato ideal para o serviço, identifique uma maneira de fazer os módulos de microsserviço e monolítico coexistirem. Uma maneira de gerenciar essa coexistência é introduzir um adaptador de comunicação entre processos (IPC, na sigla em inglês), que pode ajudar os módulos a funcionarem juntos. Com o tempo, o microsserviço assume o carregamento e elimina o componente monolítico. Esse processo incremental reduz o risco na migração do aplicativo monolítico para o novo microsserviço, porque é possível detectar bugs ou problemas de desempenho gradualmente.

O diagrama a seguir mostra como implementar a abordagem da IPC:

Uma abordagem IPC é implementada para ajudar os módulos a funcionarem juntos.

Figura 2. Um adaptador IPC coordena a comunicação entre o aplicativo monolítico e um módulo de microsserviços.

Na figura 2, o módulo Z é o candidato ao serviço que você quer extrair do aplicativo monolítico. Os módulos X e Y dependem do módulo Z. Os módulos de microserviço X e Y usam um adaptador IPC no aplicativo monolítico para se comunicarem com o módulo Z por uma API REST.

O próximo documento desta série, Comunicação entre serviços em uma configuração de microsserviços, descreve o padrão Strangler Fig (em inglês) e como desconstruir um serviço do aplicativo monolítico.

Gerenciar um banco de dados monolítico

Normalmente, os aplicativos monolíticos têm bancos de dados monolíticos. Um dos princípios de uma arquitetura de microsserviços é ter um banco de dados para cada microsserviço. Portanto, ao modernizar o aplicativo monolítico para microsserviços, você precisa dividir o banco de dados monolítico com base nos limites do serviço identificados.

Para determinar onde dividir um banco de dados monolítico, primeiro analise os mapeamentos do banco de dados. Como parte da análise de extração de serviço, você coletou alguns insights sobre os microsserviços que precisa criar. Use a mesma abordagem para analisar o uso do banco de dados e mapear tabelas ou outros objetos de banco de dados para os novos microsserviços. Ferramentas como SchemaTracker, SchemaSpy ERBuilder (links em inglês) podem ajudar você a realizar essa análise. O mapeamento de tabelas e outros objetos ajuda a entender o acoplamento entre os objetos de banco de dados que abrangem os limites de microsserviços em potencial.

No entanto, dividir um banco de dados monolítico é complexo porque pode não haver uma separação clara entre os objetos do banco de dados. Você também precisa considerar outros problemas, como sincronização de dados, integridade transacional, mesclagens e latência. A próxima seção descreve padrões que podem ajudar a responder a esses problemas ao dividir o banco de dados monolítico.

Tabelas de referência

Em aplicativos monolíticos, é comum que os módulos acessem dados necessários em um módulo diferente por uma mescla de SQL na tabela do outro módulo. O diagrama a seguir usa o exemplo de aplicativo de comércio eletrônico anterior para mostrar esse processo de acesso da mescla do SQL:

Um módulo usa uma mescla do SQL para acessar dados de outro módulo.

Figura 3. Um módulo mescla dados na tabela de um módulo diferente.

Na figura 3, para receber informações do produto, um módulo de pedido usa uma chave externa product_id para mesclar um pedido à tabela de produtos.

No entanto, se você desconstruir módulos como serviços individuais, recomendamos que não faça com que o serviço de pedido chame diretamente o banco de dados do serviço de produto para executar uma operação de mesclagem. As seções a seguir descrevem opções que podem ser consideradas para separar os objetos do banco de dados.

Compartilhar dados por uma API

Ao separar as funcionalidades ou módulos principais em microsserviços, normalmente são usadas APIs para compartilhar e expor dados. O serviço referenciado expõe os dados como uma API necessária ao serviço de chamada, conforme mostrado no diagrama a seguir:

Os dados são expostos por uma API.

Figura 4 Um serviço usa uma chamada de API para receber dados de outro serviço.

Na figura 4, um módulo de pedido usa uma chamada de API para receber dados de um módulo de produto. Essa implementação tem problemas óbvios de desempenho devido a chamadas adicionais de rede e banco de dados. No entanto, o compartilhamento de dados uma API funciona bem quando o tamanho dos dados é limitado. Além disso, se o serviço chamado estiver retornando dados que têm uma taxa de alteração conhecida, é possível implementar um cache TTL local no autor da chamada para reduzir as solicitações de rede para o serviço chamado.

Replicar dados

Outra maneira de compartilhar dados entre dois microsserviços separados é replicar os dados no banco de dados do serviço dependente. A replicação de dados é somente leitura e pode ser recriada a qualquer momento. Esse padrão permite que o serviço seja mais coeso. O diagrama a seguir mostra como a replicação de dados funciona entre dois microsserviços:

Os dados são replicados entre microsserviços.

Figura 5. Os dados de um serviço são replicados em um banco de dados de serviço dependente.

Na figura 5, o banco de dados de serviço do produto é replicado para o banco de dados de serviço do pedido. Essa implementação permite que o serviço de pedido receba dados do produtos sem chamadas repetidas para o serviço do produto.

Para criar a replicação de dados, é possível usar técnicas como visualizações materializadas, captura de dados de alterações (CDC, na sigla em inglês) e notificações de eventos. Os dados replicados são consistentes, mas pode haver atraso na replicação dos dados, portanto, há o risco de exibir dados desatualizados.

Dados estáticos como configuração

Os dados estáticos, como códigos de países e moedas compatíveis, mudam lentamente. É possível injetar dados estáticos como uma configuração em um microsserviço. Microsserviços modernos e frameworks de nuvem fornecem recursos para gerenciar esses dados de configuração usando servidores de configuração, armazenamentos de chave-valor e cofres. É possível incluir esses recursos de maneira declarativa.

Dados mutáveis compartilhados

Os aplicativos monolíticos têm um padrão comum, conhecido como estado mutável compartilhado. Em uma configuração de estado mutável compartilhada, vários módulos usam uma única tabela, conforme mostrado no diagrama a seguir:

Uma configuração de estado mutável compartilhada disponibiliza uma única tabela para vários módulos.

Figura 6. Vários módulos usam uma única tabela.

Na figura 6, as funcionalidades de pedido, pagamento e envio do aplicativo de comércio eletrônico usam a mesma tabela ShoppingStatus para manter o status do pedido do cliente durante toda a jornada de compra.

Para migrar um monolítico de estado mutável compartilhado, é possível desenvolver um microsserviço ShoppingStatus separado para gerenciar a tabela de banco de dados ShoppingStatus. Esse microserviço expõe APIs para gerenciar o status de compra de um cliente, conforme mostrado no diagrama a seguir:

As APIs são expostas a outros serviços.

Figura 7. Um microsserviço expõe APIs a vários outros serviços.

Na Figura 7, os microsserviços de pagamento, pedido e envio usam as APIs de microsserviço do ShoppingStatus. Se a tabela do banco de dados estiver intimamente relacionada a um dos serviços, recomendamos que você mova os dados para esse serviço. Em seguida, é possível expor os dados por uma API para que outros serviços os consumam. Essa implementação ajuda a garantir que você não tenha muitos serviços refinados que chamam uns aos outros com frequência. Se você dividir os serviços incorretamente, volte a definir os limites dos serviços.

Transações distribuídas

Depois de isolar o serviço do monolítico, uma transação local no sistema monolítico original pode ser distribuída entre vários serviços. Uma transação que abrange vários serviços é considerada uma transação distribuída. No aplicativo monolítico, o sistema de banco de dados garante que as transações sejam atômicas. Para processar transações entre vários serviços em um sistema baseado em microsserviço, é necessário criar um coordenador de transações global. O coordenador de transações lida com reversão, ações de compensação e outras transações descritas no próximo documento desta série, Comunicação entre serviços em uma configuração de microsserviços.

Consistência de dados

As transações distribuídas apresentam o desafio de manter a consistência de dados entre serviços. Todas as atualizações precisam ser feitas de maneira atômica. Em um aplicativo monolítico, as propriedades das transações garantem que uma consulta retorne uma visualização consistente do banco de dados com base no nível de isolamento (em inglês).

Por outro lado, pense em uma transação em várias etapas em uma arquitetura baseada em microsserviços. Se alguma transação de serviço falhar, os dados precisarão ser reconciliados revertendo as etapas que foram bem-sucedidas nos outros serviços. Caso contrário, a visualização global dos dados do aplicativo é inconsistente entre os serviços.

Pode ser desafiador determinar quando uma etapa que implementa a consistência posterior falhou. Por exemplo, uma etapa pode não falhar imediatamente, mas pode ser bloqueada ou expirar. Portanto, talvez seja necessário implementar algum tipo de mecanismo de tempo limite. Se os dados duplicados estiverem desatualizados quando o serviço chamado os acessar, armazenar em cache ou replicar dados entre serviços para reduzir a latência da rede também pode resultar em dados inconsistentes.

O próximo documento da série, Comunicação entre serviços em uma configuração de microsserviços, fornece um exemplo de um padrão sobre como lidar com transações distribuídas entre microsserviços.

Projetar a comunicação entre serviços

Em um aplicativo monolítico, os componentes (ou módulos do aplicativo) invocam um ao outro diretamente usando chamadas de função. Por outro lado, um aplicativo baseado em microsserviços consiste em vários serviços que interagem uns com os outros na rede.

Ao projetar a comunicação entre serviços, primeiro pense em como os serviços interagiriam entre si. As interações entre serviços podem ser uma das seguintes:

  • Interações um para um: cada solicitação do cliente é processada por exatamente um serviço.
  • Interações um para muitos: cada solicitação é processada por vários serviços.

Considere também se a interação é síncrona ou assíncrona:

  • Síncrona: o cliente espera uma resposta oportuna do serviço, que pode ser bloqueada enquanto aguarda.
  • Assíncrona: o cliente não bloqueia enquanto aguarda uma resposta. A resposta, se houver, não é necessariamente enviada imediatamente.

A tabela a seguir mostra combinações de estilos de interação:

Um para um Um para muitos
Síncrona Solicitação e resposta: envia uma solicitação a um serviço e aguarda uma resposta.
Assíncrona Notificação: envia uma solicitação a um serviço, mas nenhuma resposta é esperada ou enviada. Publicar e assinar: o cliente publica uma mensagem de notificação e zero ou mais serviços interessados consomem a mensagem.
Solicitação e resposta assíncrona: envia uma solicitação a um serviço, que responde de forma assíncrona. O cliente não bloqueia. Publicar e respostas assíncronas: o cliente publica uma solicitação e aguarda respostas vindas dos serviços interessados.

Normalmente, cada serviço usa uma combinação desses estilos de interação.

Implementar a comunicação entre serviços

Para implementar a comunicação entre serviços, é possível escolher entre diferentes tecnologias de IPC. Por exemplo, os serviços podem usar mecanismos de comunicação síncronos baseados em solicitação e resposta, como gRPC, Thrift ou REST com base em HTTP. Como alternativa, os serviços podem usar mecanismos de comunicação assíncronos baseados em mensagens, como AMQP ou STOMP. Também é possível escolher entre vários formatos de mensagens diferentes. Por exemplo, os serviços podem usar formatos legíveis com base em texto, como JSON ou XML. Como alternativa, os serviços podem usar um formato binário, como Avro ou buffers de protocolo.

Configurar serviços para chamar diretamente outros serviços leva a um alto acoplamento entre os serviços. Em vez disso, recomendamos o uso de mensagens ou comunicação baseada em eventos:

  • Mensagens: ao implementar mensagens, você elimina a necessidade de chamadas de serviços diretamente a outros serviços. Em vez disso, todos os serviços conhecem um agente de mensagens, e eles enviam mensagens para esse agente. O agente de mensagens salva essas mensagens em uma fila de mensagens. Outros serviços podem se inscrever nas mensagens que interessam a eles.
  • Comunicação baseada em eventos: quando você implementa o processamento orientado a eventos, a comunicação entre serviços ocorre em eventos produzidos por serviços individuais. Os serviços individuais gravam os eventos deles em um agente de mensagens. Os serviços podem detectar os eventos que interessar a eles. Esse padrão mantém os serviços acoplado com flexibilidade porque os eventos não incluem payloads.

Em um aplicativo de microsserviços, recomendamos o uso de comunicação assíncrona entre serviços em vez da comunicação síncrona. A solicitação-resposta é um padrão de arquitetura bem compreendido. Portanto, projetar uma API síncrona pode parecer mais natural do que projetar um sistema assíncrono. A comunicação assíncrona entre serviços pode ser implementada usando mensagens ou comunicação orientada a eventos. O uso da comunicação assíncrona oferece as seguintes vantagens:

  • Acoplamento flexível: um modelo assíncrono divide a interação entre solicitação e resposta em duas mensagens separadas, uma para a solicitação e outra para a resposta. O consumidor de um serviço inicia a mensagem de solicitação e aguarda a resposta, e o provedor de serviços aguarda mensagens de resposta às quais ele responde com mensagens de resposta. Essa configuração significa que o autor da chamada não precisa aguardar a mensagem de resposta.
  • Isolamento de falhas: o remetente ainda pode enviar mensagens mesmo que o consumidor downstream falhe. O consumidor recebe o backlog sempre que se recupera. Esse recurso é especialmente útil em uma arquitetura de microsserviços, porque cada serviço tem o próprio ciclo de vida. No entanto, as APIs síncronas exigem que o serviço downstream esteja disponível, ou a operação falhará.
  • Receptividade: um serviço upstream pode responder mais rápido se não esperar por serviços downstream. Se houver uma cadeia de dependências de serviço (serviço A, que chama B, que chama C etc.), aguardar chamadas síncronas poderá adicionar quantidades inaceitáveis de latência.
  • Controle de fluxo: uma fila de mensagens funciona como um buffer, para que os destinatários possam processar mensagens na própria taxa.

No entanto, veja a seguir alguns desafios de usar mensagens assíncronas de maneira eficaz:

  • Latência: se o agente de mensagens enfrentar um gargalo, a latência total poderá ficar alta.
  • Sobrecarga no desenvolvimento e teste: com base na escolha da infraestrutura de mensagens ou eventos, pode haver mensagens duplicadas, o que dificulta a operação idempotente. Também pode ser difícil implementar e testar a semântica de solicitação-resposta usando mensagens assíncronas. É necessário ter uma maneira de correlacionar mensagens de solicitação e resposta.
  • Capacidade: o processamento assíncrono de mensagens, usando uma fila central ou algum outro mecanismo, pode se tornar um gargalo no sistema. Os sistemas de back-end, como filas e consumidores downstream, precisam ser escalonados para atender aos requisitos de capacidade do sistema.
  • Complica o tratamento de erros: em um sistema assíncrono, o autor da chamada não sabe se uma solicitação foi bem-sucedida ou falhou. Portanto, o tratamento de erros precisa ser fora da banda. Esse tipo de sistema pode dificultar a implementação de lógica, como novas tentativas ou esperas exponenciais. O tratamento de erros é ainda mais complicado se houver várias chamadas assíncronas encadeadas, as quais precisam todas ser bem-sucedidas ou ter falha.

O próximo documento da série, Comunicação entre serviços em uma configuração de microsserviços, fornece uma implementação de referência para lidar com alguns dos desafios mencionados na lista anterior.

A seguir