Como você gerencia a base de código subjacente para uma API com versão?

104

Tenho lido sobre estratégias de controle de versão para APIs ReST, e algo que nenhum deles parece abordar é como você gerencia a base de código subjacente.

Digamos que estejamos fazendo várias alterações significativas em uma API - por exemplo, alterando nosso recurso Customer para que ele retorne campos forenamee separados surnameem vez de um único namecampo. (Para este exemplo, usarei a solução de controle de versão de URL, pois é fácil entender os conceitos envolvidos, mas a questão é igualmente aplicável à negociação de conteúdo ou cabeçalhos HTTP personalizados)

Agora temos um endpoint em http://api.mycompany.com/v1/customers/{id}e outro endpoint incompatível em http://api.mycompany.com/v2/customers/{id}. Ainda estamos lançando correções de bugs e atualizações de segurança para a API v1, mas o desenvolvimento de novos recursos agora está focado na v2. Como escrevemos, testamos e implementamos mudanças em nosso servidor API? Posso ver pelo menos duas soluções:

  • Use um branch / tag de controle de origem para a base de código v1. v1 e v2 são desenvolvidos e implantados de forma independente, com mesclagens de controle de revisão usadas conforme necessário para aplicar a mesma correção de bug a ambas as versões - semelhante a como você gerencia bases de código para aplicativos nativos ao desenvolver uma nova versão principal, embora ainda suporte a versão anterior.

  • Torne a própria base de código ciente das versões da API, para que você termine com uma única base de código que inclui a representação do cliente v1 e a representação do cliente v2. Trate o controle de versão como parte da arquitetura da sua solução, em vez de um problema de implantação - provavelmente usando alguma combinação de namespaces e roteamento para garantir que as solicitações sejam tratadas pela versão correta.

A vantagem óbvia do modelo de branch é que é trivial excluir versões antigas da API - apenas pare de implantar o branch / tag apropriado - mas se você estiver executando várias versões, pode acabar com uma estrutura de branch e pipeline de implantação realmente complicada. O modelo de "base de código unificada" evita esse problema, mas (eu acho?) Tornaria muito mais difícil remover recursos obsoletos e pontos de extremidade da base de código quando eles não fossem mais necessários. Sei que isso é provavelmente subjetivo, pois é improvável que haja uma resposta correta simples, mas estou curioso para entender como as organizações que mantêm APIs complexas em várias versões estão resolvendo esse problema.

Dylan Beattie
fonte
41
Obrigado por fazer esta pergunta! NÃO POSSO acreditar que mais pessoas não estão respondendo a essa pergunta !! Estou farto de ver que todos têm uma opinião sobre como as versões entram em um sistema, mas ninguém parece enfrentar o problema real e difícil de despachar versões para seus códigos apropriados. A esta altura, deve haver pelo menos uma série de "padrões" ou "soluções" aceitos para esse problema aparentemente comum. Há um número absurdo de perguntas sobre o SO relacionadas ao "controle de versão da API". Decidir como aceitar as versões é FRIKKIN SIMPLE (relativamente)! Manipulá-lo na base de código assim que entrar é DIFÍCIL!
arijeet

Respostas:

45

Eu usei ambas as estratégias que você mencionou. Dessas duas, sou a favor da segunda abordagem, por ser mais simples, nos casos de uso que a suportam. Ou seja, se as necessidades de controle de versão forem simples, use um design de software mais simples:

  • Um baixo número de mudanças, mudanças de baixa complexidade ou programação de mudança de baixa frequência
  • Alterações que são amplamente ortogonais ao resto da base de código: a API pública pode existir pacificamente com o resto da pilha sem exigir ramificação "excessiva" (para qualquer definição desse termo que você escolher adotar) no código

Não achei muito difícil remover versões obsoletas usando este modelo:

  • Uma boa cobertura de teste significa que retirar uma API desativada e o código de apoio associado garantiu nenhuma regressão (bem, mínima)
  • Uma boa estratégia de nomenclatura (nomes de pacotes com versão de API ou, um pouco mais feia, versões de API em nomes de métodos) facilitou a localização do código relevante
  • As preocupações transversais são mais difíceis; as modificações nos sistemas back-end principais para suportar várias APIs devem ser avaliadas com muito cuidado. Em algum ponto, o custo do back-end de versionamento (veja o comentário sobre "excessivo" acima) supera o benefício de uma única base de código.

A primeira abordagem é certamente mais simples do ponto de vista de reduzir o conflito entre versões coexistentes, mas a sobrecarga de manter sistemas separados tende a superar o benefício de reduzir o conflito de versões. Dito isso, foi muito simples criar uma nova pilha de API pública e começar a iterar em um branch de API separado. Claro, a perda geracional se instalou quase imediatamente e os ramos se transformaram em uma confusão de fusões, soluções de conflitos de fusões e outras coisas divertidas.

Uma terceira abordagem está na camada de arquitetura: adote uma variante do padrão Facade e abstraia suas APIs em camadas voltadas para o público, com versão que se comunica com a instância de Facade apropriada, que por sua vez se comunica com o back-end por meio de seu próprio conjunto de APIs. Your Façade (usei um adaptador em meu projeto anterior) torna-se seu próprio pacote, independente e testável, e permite que você migre APIs de front-end independentemente do back-end e entre si.

Isso funcionará se suas versões de API tenderem a expor os mesmos tipos de recursos, mas com representações estruturais diferentes, como em seu exemplo de nome completo / nome / sobrenome. Fica um pouco mais difícil se eles começarem a contar com cálculos de back-end diferentes, como em "Meu serviço de back-end retornou juros compostos calculados incorretamente que foram expostos na API v1 pública. Nossos clientes já corrigiram esse comportamento incorreto. Portanto, não posso atualizar isso computação no back-end e aplicá-la até a v2. Portanto, agora precisamos bifurcar nosso código de cálculo de juros. " Felizmente, eles tendem a ser raros: praticamente falando, os consumidores de APIs RESTful favorecem representações precisas de recursos em vez de compatibilidade reversa bug-a-bug, mesmo entre mudanças ininterruptas em um GETrecurso teoricamente idempotente .

Terei interesse em ouvir sua decisão final.

Palpatim
fonte
5
Só por curiosidade, no código-fonte, vocês duplicam modelos entre v0 e v1 que não mudaram? Ou você tem v1 usa alguns modelos v0? Para mim, ficaria confuso se visse v1 usando modelos de v0 para alguns campos. Mas, por outro lado, reduziria o inchaço do código. Para lidar com várias versões, precisamos apenas aceitar e viver com código duplicado para modelos que nunca mudaram?
EdgeCaseBerg
1
Minha lembrança é que nossos modelos com versão de código-fonte independentemente da própria API, então, por exemplo, a API v1 pode usar o Modelo V1 e a API v2 também pode usar o Modelo V1. Basicamente, o gráfico de dependência interna para a API pública inclui o código da API exposto, bem como o código de "cumprimento" do backend, como o servidor e o código do modelo. Para várias versões, a única estratégia que usei é a duplicação de toda a pilha - uma abordagem híbrida (módulo A é duplicado, módulo B é versionado ...) parece muito confuso. YMMV, é claro. :)
Palpatim
2
Não tenho certeza se estou seguindo o que é sugerido para a terceira abordagem. Existe algum exemplo público de código estruturado como este?
Ehtesh Choudhury
13

Para mim, a segunda abordagem é melhor. Eu o usei para os serviços da web SOAP e planejo usá-lo para REST também.

Conforme você escreve, a base de código deve reconhecer a versão, mas uma camada de compatibilidade pode ser usada como camada separada. Em seu exemplo, a base de código pode produzir representação de recurso (JSON ou XML) com nome e sobrenome, mas a camada de compatibilidade irá alterá-lo para ter apenas um nome.

A base de código deve implementar apenas a versão mais recente, digamos v3. A camada de compatibilidade deve converter as solicitações e respostas entre a versão mais recente v3 e as versões suportadas, por exemplo, v1 e v2. A camada de compatibilidade pode ter adaptadores separados para cada versão suportada, que podem ser conectados em cadeia.

Por exemplo:

Solicitação de cliente v1: v1 adapta-se a v2 ---> v2 adapta-se a v3 ----> base de código

Solicitação v2 do cliente: v1 se adapte a v2 (pular) ---> v2 se adapte a v3 ----> base de código

Para a resposta, os adaptadores funcionam simplesmente na direção oposta. Se estiver usando Java EE, você pode usar a cadeia de filtros de servlet como cadeia de adaptadores, por exemplo.

Remover uma versão é fácil, exclua o adaptador correspondente e o código de teste.

S.Stavreva
fonte
É difícil garantir a compatibilidade se toda a base de código subjacente mudou. Muito mais seguro manter a base de código antiga para lançamentos de correção de bugs.
Marcelo Cantos
5

A ramificação parece muito melhor para mim e usei essa abordagem no meu caso.

Sim, como você já mencionou - correções de bugs de backport exigirá algum esforço, mas ao mesmo tempo, o suporte a várias versões em uma base de origem (com roteamento e todas as outras coisas) exigirá se não menos, mas pelo menos o mesmo esforço, tornando o sistema mais complicado e monstruoso com diferentes ramos de lógica dentro (em algum ponto de versionamento você definitivamente chegará a case()apontar para módulos de versão com código duplicado, ou pior ainda if(version == 2) then...). Também não se esqueça de que, para fins de regressão, você ainda precisa manter os testes ramificados.

Com relação à política de versionamento: eu manteria no máximo -2 versões da atual, descontinuando o suporte para as antigas - isso daria alguma motivação para os usuários mudarem.

Edmarisov
fonte
Estou pensando em testar em uma única base de código no momento. Você mencionou que os testes sempre precisariam ser ramificados, mas estou pensando que todos os testes para v1, v2, v3 etc poderiam viver na mesma solução também e todos ser executados ao mesmo tempo. Estou pensando em decorar os testes com atributos que especificam quais versões que suportam: por exemplo [Version(From="v1", To="v2")], [Version(From="v2", To="v3")], [Version(From="v1")] // All versions apenas explorando-lo agora, já ouviu alguém fazê-lo?
Lee Gunn,
1
Bem, depois de 3 anos, aprendi que não há uma resposta precisa para a pergunta original: D. É muito dependente do projeto. Se você puder congelar a API e apenas mantê-la (por exemplo, correções de bugs), eu ainda iria ramificar / desanexar o código relacionado (lógica de negócios relacionada à API + testes + endpoint restante) e ter todo o material compartilhado em uma biblioteca separada (com seus próprios testes ) Se V1 vai coexistir com V2 por algum tempo e o trabalho de recursos ainda está em andamento, eu os manteria juntos e os testes também (cobrindo V1, V2 etc. e nomeados de acordo).
edmarisov
1
Obrigado. Sim, parece ser um espaço bastante opinativo. Vou tentar a abordagem de uma solução primeiro e ver como funciona.
Lee Gunn,
0

Normalmente, a introdução de uma versão principal da API levando você a uma situação de ter que manter várias versões é um evento que não ocorre (ou não deveria ocorrer) com muita frequência. No entanto, isso não pode ser evitado completamente. Eu acho que é geralmente uma suposição segura que uma versão principal, uma vez introduzida, permaneceria como a versão mais recente por um período de tempo relativamente longo. Com base nisso, eu preferiria obter simplicidade no código em detrimento da duplicação, pois isso me dá mais confiança para não quebrar a versão anterior quando introduzo alterações na última.

user1537847
fonte