Executando verificação de segurança...
7

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:

  1. A configuração ativa varia de acordo com o ambiente de deploy
  2. A classe RemoteBrDevSettingsProvider é reativa a uma fonte remota de configuração
  3. 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:

  1. Exemplo da migração da API v2 para v3
  2. Controlar o comportamento de batching ou concorrência de chamadas para alguma API externa
  3. Ativar/Desativar novos endpoints progressivamente nos deployment environments
  4. Ativar/Desativar funcionalidades para usuários específicos (com muito cuidado!)
  5. 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.

Carregando publicação patrocinada...
1

Muito bacana a sua publicação! Só um ponto, conhecia essa possibilidade de usar configurações como parametrização, indicando propriedades que são imutáveis a nível de runtime, mas mutáveis e controláveis externamente às aplicações. Vi esta prática aqui, sendo a referência ao ambiente que está sendo utilizado no momento um parâmetro:

{
    "context": {
        "environment": "STAGING"
    } 
}

Além de ter esta parametrização percebi outro padrão aqui que é conhecido como ToggleFeature:

// 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);
        }

Existem diversas formas de implementar e manipular a ToggleFeature, essa é uma das formas mais simples.

Era só este complemento, muito obrigado pela publicação mais uma vez!

1

Não sei se é o exemplo que não é o ideal para demonstrar o uso dessa técnica.
Mas me parece que injeção de dependência resolveria de forma mais elegante, dispensando o if/else

No nível onde a injesção de dependência acontece (ou na criação do Bean no caso de um Spring por exemplo), é lido do arquivo de configuração para decidir qual implementação concreta do service da api do Reddit será injetada.

Além disso, como comentado pelo outro colega, o exemplo demonstrado para muito Feature Toggle/Feature Flag.

Dei uma pesquisada rápida no google e não encontrei nada sobre esse design pattern também. Você tem algum link de refência?