Flighting vai salvar a sua vida (e dos usuários)
Continuando a série de posts didáticos aqui no TabNews, vou falar sobre uma técnica que permite fazer alterações no código de forma mais segura, progressivamente liberar uma nova feature, ou até mesmo “desfazer” uma má alteração.
E é bastante simples: flighting.
Btw: todos os exemplos de código aqui são os mais brutos e toscos possíveis, tem apenas a finalidade de ilustrar a funcionalidade no geral.
O que é Flighting
Flighting é uma técnica que envolve ter configurações para uma camada específica do seu app, ou seu app como um todo. Essas configurações podem ser de vários tipos, e geralmente o consumidor delas é reativo às suas mudanças, enquanto o provedor é reativo à alguma fonte externa como um arquivo ou servidor remoto.
Vamos ver um exemplo:
internal sealed BrDevService
{
private readonly IPoster poster;
public BrDevService(IPoster poster)
{
this.poster = poster;
}
public async ValueTask<bool> TryPostContent(string markdownContent)
{
var result = await this.poster.PostUsingV2Api(markdownContent);
return result.IsSuccess;
}
}
Acima, nossa classe BrDevService é responsável por postar um certo conteúdo markdown no Reddit e propagar se a operação teve sucesso ou não. No entanto, faz pouco tempo que a equipe do Reddit anunciou que a API v2 estava sendo descontinuada, e devemos mudar urgentemente para a v3.
Temos um prazo de 1 mês para efetuar a mudança para v3, e queremos ter certeza que os usuários do BrDev não sejam prejudicados com a mudança, ou, ao mínimo, que não seja algo catastrófico.
É aqui que se encaixa o flighting. Podemos adicionar um provedor de configuração que controla, sem a necessidade de alterações posteriores no código, qual versão da API estamos utilizando:
internal sealed BrDevService
{
private readonly IPoster poster;
private readonly RemoteBrDevSettingsProvider settings;
public BrDevService(IPoster poster, RemoteBrDevSettingsProvider settings)
{
this.poster = poster;
this.settings = settings;
}
public async ValueTask<bool> TryPostContent(string markdownContent)
{
Result result;
// Consideremos que UseNewV3Api é, por padrão, falso.
if (await this.settings.GetValueFor<bool>("UseNewV3Api"))
{
result = await this.poster.PostUsingV3Api(markdownContent);
}
else
{
result = await this.poster.PostUsingV2Api(markdownContent);
}
return result.IsSuccess;
}
}
Veja que mesmo com as alterações, o caminho que nosso código tomou ainda não mudou, estamos utilizando a V2 Api da mesmo forma que utilizávamos antes.
Criamos um novo caminho no nosso código, que só será ativado e substituirá o antigo quando o provedor de configurações decidir. Da mesma forma, se a implementação para consumir a v3 estiver errada e causar erros, podemos simplesmente pedir ao provedor de configurações que desative o novo caminho.
Note que, como:
Essas configurações podem ser de vários tipos, e geralmente o consumidor delas é reativo às suas mudanças, enquanto o provedor é reativo à alguma fonte externa como um arquivo ou servidor remoto.
Não precisamos alterar o nosso código e gerar um novo deploy para ativar/desativar o novo caminho, podemos alterar a configuração remotamente. Isso gera um ganho de agilidade absurdo em resposta a erros.
Como o RemoteBrDevSettingsProvider
é reativo?
Geralmente o provedor de configuração está conectado com algum serviço externo, como o AWS App Settings. Com serviços como esse, podemos controlar as configurações através de uma UI.
Vamos criar um exemplo simples para ver como um servidor remoto de configuração funcionaria, e outras possibilidades.
interface IPostConfigurationRequest
{
configurationJsonBlob: string;
filters: [key: string]: string;
}
// Assuma que estamos usando express
app.post("/configuration", async (request, response) => {
var configuration: IPostConfigurationRequest = request.body;
// validações...
await database.save({ configuration: configuration.configurationJsonBlob, filters: configuration.filters });
return response.status(201).send();
});
No exemplo, não estou me preocupando com nada além de simplesmente salvar a configuração e filtros. Mas o que são esses filtros?
Imagine que temos environments diferentes para o deploy da nossa aplicação: STAGING, PROD. Com filtros, somos capazes de propagar configurações diferentes para esses dois environments! Assim, a equipe interna de desenvolvimento pode fazer o deploy tranquilamente para STAGING e PROD, enquanto a mudança só é ativada em STAGING para testes.
Preferencialmente, queremos que as requests de POST para "/configuration" sejam feitas a partir de uma UI administrativa, para evitarmos o caso de configurações mudando loucamente por que alguém está chamando a API de algum script/worker.
Uma chamada para /configuration
da nossa ADMIN UI seria algo como:
POST ... /configuration
{
"configurationJsonBlob": "{ \"UseNewV3Api\": true }",
"filters": {
"environment": "STAGING"
}
}
Agora, para consumir as configurações:
interface IPostContextualConfigurationRequest
{
context: [key: string]: string;
}
// Assuma que estamos usando express
app.post("/contextualConfigurations", async (request, response) => {
var { context }: IPostContextualConfigurationRequest = request.body;
// validações...
// Utilizamos os filtros criados em /configurations para retornar as configurações que se
// aplicam aos valores dentro de context;
const configurations = await database.get({ context });
const mergedConfigurations = // faz o merge das configurações criadas para um único JSON blob.
return response.send({ mergedConfigurations });
});
Uma chamada para /contextualConfiguration
partiria do nosso provedor de configurações, e seria algo como:
POST ... /contextualConfiguration
{
"context": {
"environment": "PROD"
}
}
ou
POST ... /contextualConfiguration
{
"context": {
"environment": "STAGING"
}
}
Assim, a classe RemoteBrDevSettingsProvider
chamaria a API contextualConfiguration
através de algo como polling ou outra estratégia, e cachearia seu resultado num TTL sensível. Vou dar um exemplo tosco sem nenhuma otimização:
internal sealed class RemoteBrDevSettingsProvider
{
private readonly IRemoteServerConfigurationProvider remoteProvider;
private readonly IEnvironmentProvider environmentProvider;
public RemoteBrDevSettingsProvider(IRemoteServerConfigurationProvider remoteProvider, IEnvironmentProvider environmentProvider)
{
this.remoteProvider = remoteProvider;
this.environmentProvider = environmentProvider;
}
public async ValueTask<T> GetValueFor<T>(string configurationName)
{
var currentEnvironment = this.environmentProvider.Get("DEPLOYMENT_ENV");
var context = new ConfigurationContext(environment: currentEnvironment);
// Chama o nosso endpoint de /contextualConfiguration
var configurationDictionary = this.remoteProvider.GetContextualConfiguration(context);
return configurationDictionary[configurationName];
}
}
Assim, obtemos o seguinte comportamento:
- A configuração ativa varia de acordo com o ambiente de deploy
- A classe
RemoteBrDevSettingsProvider
é reativa a uma fonte remota de configuração - Alteramos o comportamento do
BrDevService
para utilizar V2 ou V3 sem precisar de um novo deploy
Outras possibilidades
Como as configurações são relativas a um context, podemos colocar que informações quisermos como filtro. Por exemplo: região geográfica, tipo do device, etc.
As configurações são úteis para N casos, por exemplo:
- Exemplo da migração da API v2 para v3
- Controlar o comportamento de batching ou concorrência de chamadas para alguma API externa
- Ativar/Desativar novos endpoints progressivamente nos deployment environments
- Ativar/Desativar funcionalidades para usuários específicos (com muito cuidado!)
- Recuperação (quase) instantânea de erros causados pela mudança - claro que depende de bons alertas de monitoração
A sua linguagem provavelmente tem algum pacote que facilita implementar isso
Vale a pena procurar por "remote configuration provider {linguagem}" e ver o que volta. Para o .NET, temos o Options: https://learn.microsoft.com/pt-br/dotnet/core/extensions/options.