Uma das razões para a popularidade dos microsserviços é a possibilidade de desenvolvimento autônomo e independente. Em essência, a arquitetura de microsserviço é a troca da possibilidade de desenvolvimento autônomo por uma implantação, teste, depuração e monitoramento mais complexos (em comparação com um monólito). Mas lembre-se de que os microsserviços não perdoam a separação de responsabilidades. Se a separação de tarefas estiver incorreta, alterações dependentes frequentes ocorrem em diferentes serviços. E isso é muito mais doloroso e complicado do que alterações coordenadas dentro da estrutura de diferentes módulos ou pacotes dentro do monólito. Alterações consistentes nos microsserviços são complicadas pelo layout, implantação, testes consistentes etc.
E eu gostaria de falar sobre os vários padrões e antipadrões da divisão de responsabilidades em microsserviços.
Entidade de serviço como antipadrão
“Entidade de Serviço” é um dos possíveis (anti) padrões de design da arquitetura de microsserviço, o que leva a códigos altamente dependentes em diferentes serviços e pouco acoplados aos serviços.
Para a maioria dos desenvolvedores, parece que, ao selecionar serviços de acordo com a essência da área de assunto: “negócio”, “pessoa”, “cliente”, “ordem”, “imagem”, ele segue os princípios de responsabilidade exclusiva e, além disso, muitas vezes isso parece lógico. Mas a abordagem da entidade de serviço pode se transformar em um antipadrão. Isso acontece porque a maioria dos recursos ou alterações afeta várias entidades, e não uma. Como resultado, cada um desses serviços combina a lógica de diferentes processos de negócios.
Por exemplo, pegue uma loja online. Decidimos destacar os serviços “produto”, “pedido”, “cliente”.
Quais alterações e serviços devo fazer para adicionar a entrega em domicílio?
Por exemplo, você pode fazer isso:
- na ordem de serviço, adicione o endereço de entrega, o horário desejado e o entregador
- no serviço ao cliente, adicione uma lista de endereços de entrega selecionados para o cliente
- no serviço "produto", adicione uma lista de entidades de mercadorias
Para a interface do fornecedor, será necessário criar um método API separado no serviço "pedido", que fornecerá uma lista de pedidos atribuídos a esse fornecedor em particular. Além disso, serão necessários métodos para remover as mercadorias do pedido que não se encaixam ou que o cliente recusou no momento da entrega.
Ou que alterações e em quais serviços eu preciso fazer para adicionar descontos no código promocional?
Você precisa, no mínimo:
- adicione um código promocional ao serviço de "pedidos"
- no serviço "produto", adicione se os descontos se aplicam ao código promocional deste produto
- no serviço ao cliente, adicione uma lista de códigos promocionais que foram emitidos para o cliente
Na interface do gerente, adicionar um código promocional personalizado ao cliente é um método separado no serviço ao cliente, disponível apenas para gerentes de loja, mas não disponível para o próprio cliente. E no serviço "produto", crie um método que forneça uma lista de produtos afetados pelo código promocional, para facilitar a escolha do cliente em sua interface.
As fontes de alterações no serviço podem ser vários processos de negócios - seleção e design, pagamento e cobrança, entrega. Cada uma das áreas problemáticas tem suas próprias limitações, invariantes e requisitos para o pedido. Como resultado, verifica-se que, no serviço "produto", armazenamos informações sobre o produto, sobre descontos e saldos de produtos em armazéns. E na "ordem" é armazenada a lógica do entregador.
Em outras palavras, uma mudança na lógica de negócios que está espalhada por vários serviços leva a alterações dependentes em vários serviços. E, ao mesmo tempo, em um serviço, há um código que não está conectado um ao outro.
Serviços de armazenamento
Parece que esse problema pode ser resolvido se um serviço de "camada" separado for criado nos serviços da entidade, que encapsula toda a lógica. Mas geralmente isso também termina mal. Como os serviços da entidade se tornam serviços de armazenamento, ou seja, toda a lógica de negócios é lavada, exceto para armazenamento.
Se os dados são armazenados em bancos de dados diferentes, em máquinas diferentes, então nós
- perdemos o desempenho porque não fornecemos dados diretamente do banco de dados, mas através da camada de serviço
- perdemos flexibilidade porque a API de serviço geralmente é muito menos flexível que o SQL ou qualquer outra linguagem de consulta
- perdemos flexibilidade porque é difícil fazer mesclagens de dados de diferentes serviços
Se serviços de entidade diferentes tiverem acesso a outros bancos de dados, a comunicação entre serviços ocorrerá implicitamente - por meio de um banco de dados comum, para fazer qualquer alteração que afete uma alteração no esquema de dados, só será possível depois de verificar se essa alteração não interromperá todos os outros serviços que usam esse banco de dados ou tablet. .
Além do desenvolvimento complexo, esses serviços se tornam excessivamente críticos e carregados - com quase todas as solicitações de um serviço de nível superior, é necessário fazer várias solicitações para diferentes entidades de serviço, o que significa que editá-las se torna ainda mais difícil para atender aos requisitos crescentes de confiabilidade e desempenho.
Devido a essas dificuldades com o desenvolvimento e suporte de serviços de entidade em sua forma pura, você raramente vê um padrão; geralmente os serviços de entidade se transformam em um ou dois “monólitos de microsserviço” centrais, que geralmente mudam e contêm a principal lógica de negócios e os placers de pequenos microsserviços geralmente infra-estrutura e pequenos que raramente mudam.
Separação por áreas problemáticas
Mudanças em si mesmas não nascem, elas vêm de alguma área problemática. Uma área de problemas é uma área de tarefas dentro da qual os problemas que exigem alterações no código são formulados em um idioma, usando um conjunto de conceitos ou interconectados pela lógica de negócios. Assim, dentro da estrutura de uma área problemática, provavelmente haverá um conjunto de restrições, invariantes nas quais você pode confiar ao escrever código.
A separação da responsabilidade dos serviços por áreas problemáticas, e não por entidades, geralmente leva a uma arquitetura mais suportada e compreensível. As áreas problemáticas geralmente correspondem aos processos de negócios. Para a loja on-line, as áreas problemáticas mais prováveis serão "pagamento e cobrança", "entrega", "processo de pedidos".
As mudanças que afetariam várias áreas problemáticas ao mesmo tempo são menores que as mudanças que afetariam várias entidades.
Além disso, os serviços divididos por processos de negócios podem ser reutilizados no futuro. Por exemplo, se próximo à loja on-line desejássemos fazer outra venda de passagens aéreas, poderíamos reutilizar o serviço geral "Cobrança e pagamento". E não faça outra similar, mas específica para a venda de ingressos.
Por exemplo, podemos dividir em serviços:
- Um serviço ou um grupo de serviços "Entrega", que armazenará a lógica do trabalho com a entrega de um pedido específico, a organização do trabalho dos fornecedores, a avaliação da qualidade do seu trabalho, a aplicação móvel do fornecedor, etc.
- Um serviço ou um grupo de serviços “Faturamento e pagamento”, que armazenará a lógica do trabalho com pagamento, contas de pagamento para pessoas jurídicas, geração de contratos e documentos de fechamento.
- Serviço ou grupo de serviços "Processo de Pedido", que armazena a lógica da escolha de produtos, catalogação, marcas, lógica de cesta, etc. do cliente, etc.
- Serviço "autorização e autenticação".
- Pode até fazer sentido separar o serviço de desconto.
Para interagir entre si, os serviços podem usar o modelo de evento ou trocar objetos simples entre si (API repousante, grpc etc.). É verdade que vale a pena notar que não é fácil organizar corretamente a interação entre esses serviços. No mínimo, a descentralização de dados apresenta problemas com consistência em algum momento (consistência eventual) e transacionalidade (no caso em que é importante).
Descentralização de dados, a troca de objetos simples tem seus prós, contras e armadilhas. Por um lado, a descentralização possibilita o desenvolvimento e a operação independentes de vários serviços. Por outro lado, o custo de armazenar duas ou três cópias de dados e manter a consistência em diferentes sistemas.
Na vida real, algo geralmente ocorre no meio. Entidade de serviço com um conjunto mínimo de atributos usado por todos os serviços pelos consumidores. E alguma camada mínima de lógica - por exemplo, um modelo de status e eventos na fila com a notificação de todas as alterações na entidade. Ao mesmo tempo, os serviços ao consumidor ainda mantêm um "cache" de dados. Tudo o que está sendo feito está sendo feito para que haja o menor número possível de alterações em um serviço e isso, em princípio, é difícil de fazer devido ao fato de haver muitos consumidores.
Ao mesmo tempo, é importante entender que qualquer partição - tanto por entidade quanto por área de problema - não é uma bala de prata; sempre haverá recursos que exigirão alterações dependentes em vários serviços. É que, com um colapso, haverá muito mais mudanças do que com outro. E a tarefa do desenvolvimento é minimizar o número de alterações dependentes.
Uma divisão ideal só é possível se você tiver dois produtos completamente independentes. Em qualquer empresa, você tem tudo conectado a tudo, a única questão é quanto está conectado.
E a questão está na separação de responsabilidades e no auge das barreiras às abstrações.
API do serviço de design
A criação de interfaces no serviço repete o histórico com a divisão em serviços, apenas em uma escala menor. Alterar a interface (não apenas uma extensão) é complexo e consome tempo. Em aplicativos complexos, a interface deve ser universal o suficiente para não causar mudanças constantes e deve ser específica e específica o suficiente para não causar a disseminação de responsabilidades e semânticas.
Portanto, as interfaces de serviço devem ser projetadas para que sua semântica seja resistente a mudanças. E isso é possível se a semântica ou área de responsabilidade da interface se basear nas limitações da área do problema.
Interfaces CRUD para serviços com lógica de negócios complexa
Uma interface muito ampla e inespecífica contribui para a erosão da responsabilidade ou a complexidade excessiva.
Por exemplo, API CRUD para serviços com lógica de negócios complexa, que não encapsulam o comportamento. Eles não apenas permitem que a lógica de negócios vaze para outros serviços e corroem a responsabilidade do serviço, como provocam a disseminação da lógica de negócios - restrições, invariantes e métodos de trabalho com dados agora estão em outros serviços. Os serviços de usuário da interface (APIs) devem implementar a própria lógica.
Se tentarmos, sem alterar significativamente a interface, transferir a lógica de negócios para o serviço, obteremos um método muito universal e muito complicado.
Por exemplo, há um serviço de ticket. Um ticket pode ser de tipos diferentes. Cada tipo tem um conjunto diferente de campos e uma validação ligeiramente diferente. O ticket também possui um modelo de status - uma máquina de estado para a transição de um status para outro.
Deixe a API ter esta aparência: métodos POST / PATCH / GET, url /api/v1/tickets/{ticket_idasket.json
Então, você pode atualizar o ticket
PATCH /api/v1/tickets/{ticket_id}.json { "type": "bug", "status": "closed", "description": " " }
Se o modelo de status depender do ticket, são possíveis conflitos de lógica de negócios. Primeiro, altere o status de acordo com o modelo de status antigo e depois o tipo de ticket. Ou vice-versa?
Acontece que dentro do método da API haverá código que não está conectado entre si - alterando os campos da entidade, uma lista dos campos disponíveis, dependendo do tipo de ticket e um modelo de status. Eles mudam por vários motivos e faz sentido distribuí-los de acordo com diferentes métodos e interfaces da API.
Se a alteração de um campo na estrutura dos métodos API CRUD não for apenas uma alteração de dados, mas uma operação relacionada a uma alteração coordenada no estado de uma entidade, essa operação deverá ser realizada em um método separado e não poderá ser alterada diretamente. Se a alteração de uma API sem compatibilidade com versões anteriores for muito ruim (para APIs públicas), é melhor pensar nisso imediatamente ao projetar a API.
Portanto, para evitar tais problemas, é melhor tornar as interfaces pequenas, específicas e tão orientadas para os problemas quanto possível, em vez das interfaces universais centradas em dados.
Esse (anti) padrão é mais frequentemente característico das interfaces RESTful, devido ao fato de que, por padrão, existem apenas alguns "verbos" de ações centrados em dados para criar, excluir, atualizar, ler. Nenhuma operação de entidade específica do negócio
O que pode ser feito para tornar o RESTful mais orientado a problemas?
Primeiro, você pode adicionar métodos às entidades. A interface está se tornando menos tranquila. Mas existe essa oportunidade. Ainda não lutamos pela pureza da corrida, mas resolvemos problemas práticos
Em vez do recurso universal
/api/v1/tickets.json
adicione mais recursos:
/api/v1/tickets/{ticket_id}/migrate.json
- migra de um tipo para outro
/api/v1/tickets/{ticket_id}/status.json
- se houver um modelo de status
Em segundo lugar, você pode imaginar qualquer operação como um recurso na estrutura do REST. Existe uma operação de migração de ticket de um tipo para outro (ou de um projeto para outro?). Ok, então haverá um recurso
/api/v1/tickets/migration.json
Existe uma operação comercial para criar uma assinatura de avaliação?
/api/v1/subscriptions/trial.json
Existe uma operação de transferência de dinheiro?
/api/v1/money_transfers.json
Etc.
O antipadrão com a API centrada em dados também se refere à interação rpc. Por exemplo, a presença de métodos muito gerais, como editAccount () ou editTicket (). "Modificar um objeto" não carrega a carga semântica associada à área do problema. Isso significa que esse método será chamado por vários motivos, por vários motivos para mudar.
Deve-se notar que as interfaces centradas em dados estão bem, se a área do problema envolver apenas o armazenamento, o recebimento e a modificação de dados.
Modelo de evento
Uma maneira de desatar partes do código é organizar a interação entre serviços por meio de uma fila de mensagens.
Por exemplo, se no serviço, ao registrar um usuário, precisamos enviar uma carta de boas-vindas, criar uma solicitação no CRM para um gerente de clientes etc., é lógico não fazer uma chamada de serviço externa, mas colocar a mensagem "o usuário 123 está registrado" ”, E todos os serviços necessários lerão esta mensagem e tomarão as medidas necessárias. Ao mesmo tempo, a alteração da lógica de negócios não exigirá a alteração do serviço de registro.
Na maioria das vezes, não apenas as mensagens são lançadas na fila, mas os eventos. Como a fila é apenas um protocolo de transporte, as mesmas restrições se aplicam à interface de dados e à interface síncrona regular. Portanto, para evitar problemas com a alteração da interface e edições subsequentes em outros serviços, é melhor tornar os eventos o mais orientados a problemas possível. Ainda assim, esses eventos são chamados de eventos de domínio. Ao mesmo tempo, o uso do modelo de evento geralmente não afeta muito os limites nos quais os (micro) serviços combatem.
Como os eventos do domínio são praticamente 1 em 1 convertidos em métodos de API síncronos, às vezes eles sugerem o uso de um fluxo de eventos em vez de um fluxo de eventos em vez de uma chamada de API (Event Sourcing). Pelo fluxo de eventos, você sempre pode restaurar o estado dos objetos, mas também ter um histórico livre. De fato, geralmente essa abordagem não é muito flexível - você precisa oferecer suporte a todos os eventos e, geralmente, é mais fácil manter uma história ao lado da API usual.
Microsserviços e desempenho. Cqrs
Em princípio, a área do problema implica alterações no código associado não apenas aos requisitos funcionais de negócios, mas também aos não funcionais - por exemplo, desempenho. Se houver dois trechos de código com requisitos de desempenho diferentes, isso significa que esses dois trechos de código podem fazer sentido separar. E eles geralmente são divididos em serviços separados para poder usar idiomas e tecnologias diferentes que são mais adequados para a tarefa.
Por exemplo, existe um método de calculadora vinculada à CPU em um serviço escrito em PHP que executa cálculos complexos. Com um aumento na carga e quantidade de dados, ele parou de lidar. E, claro, como uma das opções, faz sentido fazer cálculos não em código php, mas em um daemon de sistema de alto desempenho separado.
Como um dos exemplos da divisão de serviços pelo princípio da produtividade - a separação de serviços em leitura e modificação (CQRS). Essa separação geralmente é oferecida porque os requisitos de desempenho dos serviços de leitura e gravação são diferentes. A carga de leitura geralmente é uma ordem de magnitude maior que a carga de gravação. E os requisitos para a velocidade de resposta das solicitações de leitura são muito maiores do que para gravação.
O cliente gasta 99% do tempo na pesquisa de mercadorias e apenas 1% do tempo no processo de pedido. Para um cliente em um estado de pesquisa, a velocidade de exibição é importante e os recursos relacionados a filtros, várias opções para exibir mercadorias, etc. Portanto, faz sentido destacar um serviço separado responsável pela pesquisa, filtragem e exibição de mercadorias. É provável que esse serviço funcione em algum tipo de ELK, um banco de dados orientado a documentos com dados desnormalizados.
Obviamente, uma divisão ingênua em serviços de leitura e modificação nem sempre pode ser boa.
Um exemplo Para um gerente que trabalha com o preenchimento da linha de produtos, os principais recursos serão a capacidade de adicionar mercadorias, excluir, alterar e exibir convenientemente. Não há muita carga; se separarmos a leitura e a mudança em serviços separados, não obteremos nada dessa separação, exceto por problemas quando for necessário fazer alterações coordenadas nos serviços.