Garantindo retrocompatibilidade em sistemas distribuídos

A mudança para arquiteturas distribuídas traz muitos benefícios: testes mais fáceis, unidades implantáveis menores, desacoplamento mais frouxo, superfícies de falha menores, para citar alguns.

Mas também traz seu próprio conjunto de desafios. Como um conjunto de serviços pode evoluir juntos de uma forma que não quebre o sistema?

 

À medida que nossas vidas se tornam mais distribuídas, também tem o software em que confiamos. O que vemos como uma única interface de usuário é tipicamente alimentado por uma série de serviços conectados, cada um com um trabalho específico.

 

Considere a Netflix. Na página inicial, vemos uma mistura de conteúdo: programas assistidos anteriormente, novos títulos populares, gerenciamento de contas e muito mais.

 

Mas essa tela não é gerada pela netflix.exe rodando em um PC em algum lugar. A partir de 2017, foi alimentado por mais de 700 serviços individuais. Isso significa que a tela inicial é realmente apenas uma agregação de centenas de microsserviços trabalhando juntos. Um serviço para gerenciar recursos da conta, outro para fazer recomendações, e assim por diante.

 

A mudança para arquiteturas distribuídas traz muitos benefícios: testes mais fáceis, unidades implantáveis menores, desacoplamento mais frouxo, superfícies de falha menores, para citar alguns. Mas também traz seu próprio conjunto de desafios.

 

Uma delas é manter a retrocompatibilidade entre os componentes. Em outras palavras, como um conjunto de serviços pode evoluir juntos de uma forma que não quebre o sistema? Os serviços só podem funcionar juntos se todos concordarem com vários contratos: como trocar dados e como é o formato dos dados. Quebrar até mesmo um único contrato pode causar estragos no seu sistema.

 

Mas como desenvolvedores, sabemos que a mudança é a única constante. A tecnologia e as necessidades de negócios inevitavelmente mudam com o tempo, assim como nossos serviços. Isso pode acontecer de várias maneiras: APIs web, mensagens como JMS ou Kafka, e até mesmo em lojas de dados.

 

Abaixo, veremos algumas práticas recomendadas para a construção de sistemas distribuídos que nos permitem modificar serviços e interfaces de forma a facilitar a atualização.

 

Web APIs

 

As APIs web RESTful são uma das principais formas pelas quais os sistemas distribuídos se comunicam. Estes são apenas um modelo básico cliente-servidor: o Serviço A (o cliente) envia uma solicitação para o serviço B (o servidor). O servidor faz algum trabalho e envia de volta uma resposta indicando sucesso ou falha.

 

Com o tempo, nossas APIs web podem precisar mudar. Seja por mudança de prioridades de negócios ou novas estratégias, devemos aceitar desde o primeiro dia que nossas APIs provavelmente serão modificadas.

 

Vamos ver algumas maneiras de tornar nossas APIs web retrocompatíveis.

 

O princípio da robustez

 

Para criar APIs web fáceis de evoluir, siga o princípio da robustez,resumido como "Seja conservador no que você faz, seja liberal no que você aceita".

 

No contexto das APIs web, esse princípio pode ser aplicado de várias maneiras:

 

Cada ponto final de API deve ter um objetivo pequeno e específico que siga apenas uma das operações crud. Os clientes devem ser responsáveis por agregar várias chamadas conforme necessário.

Os servidores devem comunicar os formatos de mensagens e esquemas esperados e aderir a eles.

Campos novos ou desconhecidos em corpos de mensagens não devem causar erros nas APIs, eles devem apenas ser ignorados.

 

Versão

 

A versão de uma API nos permite suportar funcionalidades diferentes para o mesmo recurso.

 

Por exemplo, considere um aplicativo de blog que ofereça uma API para gerenciar seus dados principais, como usuários, posts em blogs, categorias, etc. Digamos que a primeira iteração tenha um ponto final que cria um usuário com os seguintes dados: nome, e-mail e uma senha. Seis meses depois, decidimos que cada conta agora deve incluir um papel (administrador, editor, autor, etc). O que devemos fazer com a API existente?

 

Temos essencialmente duas opções:

 

Atualize a API do usuário para exigir uma função a cada solicitação.

Suporte simultaneamente as APIs antigas e novas do usuário.

Com a opção 1, atualizamos o código e qualquer solicitação que não inclua o novo parâmetro é rejeitada como uma solicitação ruim. Isso é fácil de implementar, mas também quebra os usuários de API existentes.

 

Com a opção 2, implementamos a nova API e também atualizamos a API original para fornecer algum padrão razoável para o novo parâmetro de função. Embora isso seja definitivamente mais trabalho para nós, não quebramos nenhum usuário de API existente.

 

A próxima pergunta é como fazemos a versão de uma API? Este debate se arrasta há muitos anos, e não há uma única resposta certa. Muito dependerá da sua pilha de tecnologia, mas, de um modo geral, existem três maneiras primárias de implementar a versão da API:

 

Url

 

Esta é a maneira mais fácil e comum e pode ser alcançada usando o caminho:

 

POST /v2/blog/users

Ou usando parâmetros de consulta:

 

POST /blog/users?v=2

UrLs são convenientes porque são uma parte necessária de cada pedido, então seus consumidores têm que lidar com isso. A maioria das estruturas registra URLs a cada solicitação, por isso é fácil rastrear quais consumidores estão usando quais versões.

 

Cabeçalhos

 

Você pode fazer isso com um nome de cabeçalho personalizado que seus serviços entendem:

 

API-Version: 2

Ou podemos sequestrar o cabeçalho 'Aceitar' para incluir extensões personalizadas:

 

Accept: application/vnd.mycompany.v2+json

O uso de cabeçalhos para versionação está mais alinhado com as práticas RESTful. Afinal, a URL deve representar o recurso, não uma versão dele. Além disso, os cabeçalhos já são ótimos em passar o que é essencialmente metadados entre clientes e servidores, por isso adicionar na versão parece um bom ajuste.

 

Por outro lado, os cabeçalhos são complicados de trabalhar em alguns quadros, mais difíceis de testar e não são viáveis de fazer login para cada solicitação. Alguns proxies da internet podem remover cabeçalhos desconhecidos, o que significa que perderíamos nosso cabeçalho personalizado antes que ele chegue ao serviço.

 

Corpo de mensagens

 

Podemos embrulhar o corpo da mensagem com alguns metadados que incluem a versão:

 

{

  metadata: {

    version: 2

  },

  message: {

    name: “John Doe”,

    email: “test@mail.com”,

    password: “P@assword123”,

    role: “editor”

  }

}

Do ponto de vista restítico, isso viola a ideia de que os organismos de mensagens são representações de recursos, não uma versão do recurso. Também temos que embrulhar todos os objetos de domínio em uma classe de invólucro comum, o que não parece ótimo — se essa classe de invólucro precisar mudar, todas as nossas APIs potencialmente terão que mudar com ela.

 

Um pensamento final sobre a versão: considere usar algo além de um esquema de contagem simples (v1, v2, etc). Você pode fornecer um pouco mais de contexto aos seus usuários usando um formato de data (ou seja, "201911") ou até mesmo versão semântica.

 

Documentação

 

Quando liberamos bibliotecas para GitHub ou Maven, fornecemos registros de alterações e documentação. Nossas APIs web não devem ser diferentes.

 

Os registros de alteração são essenciais para permitir que os consumidores de API tosquem decisões informadas sobre se e quando devem atualizar seus clientes. No mínimo, os registros de alteração de API devem incluir os seguintes:

 

Versão e data efetiva

 

Quebrando mudanças que os consumidores terão que lidar

Novos recursos que podem ser usados opcionalmente, mas não exigem atualizações por parte dos consumidores

Correções e alterações nas APIs existentes que não exigem que os consumidores mudem nada

Avisos de depreciação que estão planejados para trabalhos futuros

Esta última parte é fundamental para tornar nossas APIs evoluíveis. Excluir um ponto final claramente não é compatível com o retrocesso, então, em vez disso, devemos depreciá-los. Isso significa que continuamos a apoiá-lo por um período fixo de tempo e permitir que nossos consumidores modifiquem seu código em vez de quebrar inesperadamente.

 

Serviços de mensagens

 

Serviços de mensagens como JMS e Kafka são outra maneira de conectar sistemas distribuídos. Ao contrário das APIs da Web, os serviços de mensagens são de fogo e esquecimento. Isso significa que normalmente não recebemos feedback imediato sobre se o consumidor aceitou ou não a mensagem.

 

Por isso, temos que ter cuidado ao atualizar o editor ou o consumidor. Existem várias estratégias que podemos adotar para evitar mudanças ao atualizar nossos aplicativos de mensagens.

 

Atualize os consumidores primeiro

 

Uma boa prática é atualizar primeiro os aplicativos de consumo. Isso nos dá a chance de lidar com novos formatos de mensagens antes de realmente começarmos a publicá-los.

 

O princípio da robustez também se aplica aqui. Os produtores devem sempre enviar a carga mínima necessária, e os consumidores só devem consumir os campos com os que se importam e ignorar qualquer outra coisa.

 

Criar novos tópicos e filas

 

Se os corpos de mensagens mudarem significativamente ou introduzirmos um novo tipo de mensagem inteiramente, devemos usar um novo tópico ou fila. Isso nos permite publicar mensagens sem se preocupar que os consumidores possam não estar prontos para consumi-las. As mensagens farão fila nos corretores, e estamos livres para implantar o consumidor novo ou atualizado sempre que quisermos.

 

Use cabeçalhos e filtros

 

A maioria dos ônibus de mensagens oferece cabeçalhos de mensagens. Assim como os cabeçalhos HTTP, esta é uma ótima maneira de passar metadados sem poluir a carga de mensagem. Podemos usar isso a nosso favor de várias maneiras. Assim como nas APIs web, podemos publicar mensagens com informações de versão no cabeçalho.

 

Do lado do consumidor, podemos filtrar mensagens que combinem com versões que nos são conhecidas, ignorando os outros.

 

Lojas de dados

 

Em uma verdadeira arquitetura de microsserviços, os armazenamentos de dados não são recursos compartilhados. Cada serviço possui seus dados e controla o acesso a ele.

 

No entanto, no mundo real, isso não é sempre o caso. A maioria dos sistemas é uma mistura de código legado e moderno que todos acessam lojas de dados usando seus próprios acessórios.

 

Então, como podemos evoluir os armazenamentos de dados de uma forma retrocompatível? Como a maioria dos armazenamentos de dados é um banco de dados relacional ou no SQL, vamos olhar para cada um separadamente.

 

Bancos de dados relacionais

 

Bancos de dados relacionais, como Oracle, MySQL e PostgreSQL, têm várias características que podem torná-los um desafio:

 

Tabelas têm esquemas muito rigorosos e rejeitarão dados que não estão exatamente de acordo

Tabelas podem ter restrições de chave estrangeira entre si

Alterações nas bases de dados relacionais podem ser divididas em três categorias.

 

Adicionando novas tabelas

 

Isso geralmente é seguro de fazer e não vai quebrar quaisquer aplicações existentes. Devemos evitar criar restrições de chave estrangeiras nas tabelas existentes, mas, caso contrário, não há muito com o que se preocupar neste caso.

 

Adicionando novas colunas

 

Adicione sempre novas colunas ao final das tabelas. Se a coluna não for anulada, devemos incluir um valor padrão razoável para as linhas existentes.

 

Além disso, as consultas em nossos aplicativos devem sempre usar colunas nomeadas em vez de índices numéricos. Esta é a maneira mais segura de garantir que novas colunas não quebrem as consultas existentes.

 

Remoção de colunas ou tabelas

 

Esses tipos de atualizações representam o maior risco para retrocompatibilidade. Não há uma boa maneira de garantir que uma tabela ou coluna exista antes de consultá-la. A ouviria verificar uma tabela antes de cada consulta simplesmente não vale a pena.

 

Se possível, as consultas de banco de dados devem lidar graciosamente com falhas. Supondo que a tabela ou coluna que está sendo removida não seja crítica ou parte de alguma transação maior, a consulta deve continuar a execução, se possível.

 

No entanto, isso não funcionará para a maioria dos casos. As chances são de que cada coluna ou tabela no esquema é importante, e tê-la desaparecendo inesperadamente quebrará suas consultas.

 

Portanto, a abordagem mais prática para remover colunas e tabelas é primeiro atualizar o código que o chama. Isso significa atualizar cada consulta que faz referência à tabela em questão e modificar seu comportamento. Uma vez que todos esses usos se foram, é seguro derrubá-lo do banco de dados.

 

Bancos de dados noSQL

 

Os armazenamentos de dados noSQL, como MongoDB, ElasticSearch e Cassandra, têm restrições diferentes de suas contrapartes relacionais.

 

A principal diferença é que, em vez de linhas de dados que todos devem estar em conformidade com um esquema, os documentos dentro de um banco de dados NoSQL não têm essa restrição. Isso significa que nossos aplicativos já estão acostumados a lidar com documentos que não têm um esquema unificado.

 

Temos o benefício adicional de que a maioria das bases de dados noSQL não permitem restrições entre as coleções da maneira como as bases de dados relacionais fazem.

 

Nesse contexto, adicionar novas coleções e campos geralmente não é uma preocupação. Aqui novamente o princípio da robustez é o nosso guia: apenas persistir campos necessários e ignorar todos os campos que não nos importamos ao ler um documento.

 

Por outro lado, a remoção de campos e coleções deve seguir as mesmas melhores práticas que as bases de dados relacionais. Se possível, nossas consultas devem lidar com o fracasso graciosamente e continuar executando. Tirando isso, devemos atualizar quaisquer consultas primeiro e atualizar o próprio armazenamento de dados.

 

Implantações de software

 

Independentemente da pilha de tecnologia que usamos, existem certas práticas que podemos incorporar em nosso ciclo de vida de software que ajudam a eliminar ou minimizar problemas de compatibilidade.

 

Tenha em mente que a maioria destes só funciona em duas condições:

 

Novos projetos de software.

 

Organizações maduras de desenvolvimento de software dispostas a dedicar os recursos necessários para o treinamento de ferramentas.

Se sua organização não se encaixar em uma dessas categorias, é improvável que você tenha sucesso na implementação de qualquer um desses processos.

 

Além disso, nenhuma das práticas abaixo é destinada a ser uma bala de prata que resolverá todos os problemas de implantação. É possível que nenhum, um ou muitos deles sejam aplicáveis à sua organização. Avalie como cada um pode ou não ajudá-lo.

 

Implantação canária

 

Uma implantação canária, também conhecida como implantação azul/verde, vermelha/preta ou roxa/vermelha, é a ideia de lançar uma nova versão de um aplicativo e apenas permitir que uma pequena porcentagem de tráfego chegue a ele.

 

O objetivo é testar novas versões de aplicativos com tráfego real, minimizando os impactos de quaisquer problemas que possam ocorrer. Se o novo aplicativo funcionar como esperado, as demais instâncias poderão ser atualizadas. Se algo der errado, a única instância pode ser revertida e apenas uma pequena parte do tráfego é impactada.

 

Isso só funciona para serviços agrupados, onde executamos mais de uma instância. Aplicações que são executadas como singletons não podem ser testadas desta forma.

 

Além disso, implantações canárias requerem malhas de serviço sofisticadas para funcionar. A maioria das arquiteturas de microsserviço já usa algum tipo de detecção de serviço, mas nem todas são criadas iguais. Sem uma malha de serviço que forneça controle fino sobre o fluxo de tráfego, uma implantação canária não é possível.

 

Finalmente, as implantações canárias não são a resposta para todos os upgrades. Eles não trabalham serviços sendo implantados pela primeira vez. Se o modelo de dados subjacente estiver mudando com um serviço, talvez não seja possível ter várias versões do aplicativo em execução simultaneamente.

 

Os três Ns

 

Os três Ns referem-se à ideia de que um aplicativo deve suportar três versões de cada serviço com o qual interage:

 

A versão anterior (N-1)

A versão atual (N)

A próxima versão (N+1)

Então, o que exatamente isso significa? Ele realmente se resume a não assumir que nossos serviços serão atualizados em qualquer ordem particular.

 

Como exemplo, vamos considerar dois serviços, A e B, onde A faz chamadas RESTful para B.

 

Se precisarmos fazer uma mudança para A, não devemos assumir que B será atualizado antes ou depois de A, ou mesmo em tudo. As mudanças que fazemos para A ou B devem ficar por conta própria.

 

E o que acontece se B tiver que reverter? Não deveríamos ter que reverter todos os seus serviços dependentes neste caso.

 

Para ser claro: o princípio de três Ns não é fácil de alcançar, especialmente quando se lida com aplicações monolíticas legados. No entanto, não é impossível.

 

É preciso planejamento e premeditação, e não virá sem dores e fracassos crescentes ao longo do caminho. Normalmente requer uma mudança maciça na mentalidade de desenvolvimento ao ponto de cada desenvolvedor e equipe fazer duas perguntas antes de lançar qualquer novo código:

 

Quais serviços meu código usa e quais serviços usam meu código?

 

O que aconteceria se meu código tivesse que ser revertido e qual é o meu plano de reversão?

A primeira pergunta pode não ser fácil de responder, mas há muitas ferramentas que podem ajudar. Desde a análise estática do código fonte até ferramentas mais sofisticadas como o Zipkin,um gráfico de dependência pode ajudá-lo a entender como os serviços interagem.

 

A segunda pergunta deve ser fácil de responder: o que mudou fora do código? Isso pode ser banco de dados, arquivos de configuração, etc. Precisamos ter um plano para reverter essas mudanças, não apenas um código compilado.

 

Se você está disposto a se esforçar, alcançar os três Ns é uma ótima maneira de garantir a compatibilidade para trás em um sistema distribuído.

 

Alternadores de recursos

 

Outra ótima maneira de proteger os serviços de quebrar mudanças é usando alternâncias de recursos.

 

Um alternador de recursos é uma peça de configuração que os aplicativos podem usar para determinar se um recurso específico está ativado ou não. Isso nos permite lançar um novo código, mas evite realmente executá-lo até o momento de nossa escolha. Da mesma forma, podemos desativar rapidamente novas funcionalidades se encontrarmos um problema com ela.

 

Existem muitas ferramentas que podem ser usadas para implementar alternâncias de recursos, como rollout.io e Optimizely. Independentemente de qual ferramenta usamos, existem certas características que devemos procurar.

 

Rápido

 

Implementar alternâncias de recursos geralmente significa adicionar muito código como o seguinte em nossos aplicativos:

 

if(newFeatureEnabled()) {

  // do new stuff

}

Verificar o estado de um alternador de características deve, portanto, ser rápido. Não devemos confiar na leitura de um banco de dados ou arquivo remoto toda vez que precisamos verificar esse estado de alternância, pois isso poderia degradar nossa aplicação muito rapidamente.

 

Distribuído

 

Como estamos lidando com sistemas distribuídos, é provável que um alternância de recursos precise ser acessado por vários aplicativos. Portanto, o estado de um alternador deve ser distribuído para que cada aplicativo veja o mesmo estado, juntamente com quaisquer alterações.

 

Atômica

 

Mudar o estado de um alternador deve ser uma única operação. Se tivermos que atualizar várias fontes de configuração, estamos aumentando as chances de que os aplicativos tenham uma visão diferente do alternador.

 

Alternadores têm uma tendência a se acumular ao longo do tempo em código. Embora o impacto de desempenho da verificação de muitos alternadores possa ser insignificante, eles podem rapidamente se transformar em dívidas tecnológicas e exigir limpeza periódica. Certifique-se de planejar o tempo para revisitá-los e limpar conforme necessário.

 

Mova-se rápido, mas não quebre as coisas

 

Em nosso mundo distribuído em constante mudança, existem muitas maneiras de aplicativos e serviços se comunicarem. O que significa que há muitas maneiras de quebrá-los à medida que eles inevitavelmente evoluem.

 

As dicas e ideias acima são apenas um ponto de partida e não cobrem todas as maneiras pelas quais nossos sistemas podem falar. Coisas como caches distribuídos e transações também podem fornecer obstáculos para construir softwares retrocompatíveis.

 

Há também outros protocolos, como tomadas web ou gRPC, que têm seus próprios recursos que podemos utilizar para atualizar inteligentemente nossos sistemas.

 

À medida que nos afastamos dos monólitos e dos microsserviços, precisamos garantir que estamos focando tanto na evolução de nossos sistemas quanto com a funcionalidade.

 

 

O Avance Network é uma comunidade fácil de usar que fornece segurança de primeira e não requer muito conhecimento técnico. Com uma conta, você pode proteger sua comunicação e seus dispositivos. O Avance Network não mantém registros de seus dados; portanto, você pode ter certeza de que tudo o que sai do seu dispositivo chega ao outro lado sem inspeção.


Strong

5178 مدونة المشاركات

التعليقات