Resolvendo um problema real com NodeJS: migração entre versões de uma API
A mudança é uma constante em produtos de software bem sucedidos e é esperado que ao longo do tempo, as dependências de um software mudem, seja por necessidades intrínsecas ao negócio, ou por motivos de melhoria de usabilidade, segurança, ou performance.
Esse entendimento passa a ser cada vez mais claro conforme você a experimenta em sua carreira, e talvez por isso pode se tornar uma obviedade, e por consequência ser subestimada.
Neste artigo, vou apresentar um caso de migração de uma API Rest que era dependência de um dos projetos em que trabalhei; quais os desafios dessa migração; e como a migração foi resolvida.
O que Eu sabia de antemão?
- A migração precisaria acontecer gradualmente, utilizando uma feature flag;
- A migração seria da v1 para a v2;
2.1 A v1 utiliza um sistema de autenticação diferente da v2, e o motivo inicial da migração está relacionada à segurança do método de autenticação;
2.2 Houve uma mudança no esquema da resposta de alguns recursos;
Mas, o que é uma feature flag?
Uma feature flag (ou feature toggle) é uma técnica que permite alterar o comportamento de um sistema por meio de um sinalizador com condições pré-definidas. Por exemplo:
function calcularSalario(...) {
const usarFormulaNova = true;
if(usarFormulaNova) {
return calcularSalarioFormulaNova(...);
}
return calcularSalarioFormulaAntiga(...);
}
Como pode observar, feature flags acompanham estruturas de decisão, e portanto, isso aumenta a complexidade do sistema.
Existem várias formas de se aplicar a técnica que incluem desde arquivos de configuração, à banco de dados, ou até mesmo serviços externos especializados como: Launchdarkly, Flagsmith, Unleash, etc.
Recomendo a leitura do artigo "Feature Toggles (aka Feature Flags)" no blog do Martin Fowler.
Como estava funcionando antes da migração?
- Na inicialização do servidor, o serviço era inicializado e seu estado era então acessível por toda a aplicação (
service.init()
); - Esse serviço possuía duas funções:
get
epost
;- Como o serviço já havia sido projetado para um caso de uso específico, o nome do serviço já expressava sua intenção e portanto, as funções apenas indicavam a operação sob aquele domínio;
- As requisições utilizavam o método de autenticação daquela versão da API;
O serviço tinha o seguinte contrato de interface.
const service = {
init: ()=>{
// Inicializava o serviço
},
get: async (...)=>{
// Implementação concreta da operação de leitura da API externa /v1
},
post: async (...)=>{
// Implementação concreta da operação de escrita na API externa /v1
}
}
export default service;
Como implementei a migração?
Para executar essa tarefa, Eu precisava ter em mente que esse serviço era utilizado em vários lugares da base de código e qualquer mudança que fosse feita em seu contrato de interface (nome das funções, parâmetros e etc), afetaria inúmeros lugares, e me obrigaria a ajustar vários casos de testes de consumidores deste serviço.
Com isso em mente, o objetivo que persegui foi:
Realizar uma migração que fosse transparente para os consumidores deste serviço, sem quebrar qualquer contrato de interface.
No cumprimento do meu objetivo, abstraí o uso das versões usando a feature flag para selecionar qual o contexto aquela requisição deveria utilizar. É importante dizer que a regra que selecionava a versão, era baseada no email do usuário, o que permitia que o serviço de feature flag pudesse distribuir percentualmente a quantidade de usuários que teria acesso à versão nova, ou à antiga.
Resultado final:
import serviceV1 from './serviceV1.js';
import serviceV2 from './serviceV2.js';
const getService = async (email)=>{
version = await fetchFeatureFlag('selecionar-versao-do-service', {email});
if(version === 'v2'){
return serviceV2;
}
return serviceV1;
}
const service = {
init: ()=>{
serviceV1.init();
serviceV2.init();
},
get: async (...)=>{
const actualService = await getService(email);
return actualService.get(...);
},
post: async (...)=>{
const actualService = await getService(email);
return actualService.post(...);
}
}
export default service;
Perceba que em tempo de execução, a versão do serviço apropriada era selecionada e então a operação com implementação concreta era chamada.
Conclusões
O que Eu quero com esse texto é apresentar esses termos e técnicas para quem não as conhecia e que desperte em você o desejo de empregar esse tipo de raciocínio no código que projeta.
Existem diversas formas de resolver esse problema, e talvez o que me motivou a não fazer a implementação concreta e selecioná-las no contexto dos consumidores, foi o objetivo que persegui.
Muitas vezes, procuramos respostas sem saber qual a pergunta. Então, sempre que possível, procure encontrar a pergunta correta, e pode ser que a resposta, caso não esteja na própria pergunta, seja um dia encontrada indubitavelmente.
Bom trabalho e bons estudos!