fbpx
Publicado em: quinta-feira, 16 de nov de 2023
Cloud Native .NET com .NET Aspire – Primeiras Impressões

Um dos lançamentos do .NET Conf foi o .NET Aspire. Ele é um orquestrador de recursos (containers ou não), é um conjunto de building blocks para inicialização resiliente e observável dos principais recursos que sua aplicação pode depender.

Diferente de tudo que já vimos, hoje começamos a desvendar o .NET Aspire e seus recursos.

Projetos Cloud Native precisam de outros serviços para entregar seus vários de seus requisitos não funcionais, como resiliência, escalabilidade, concorrência, sem ferir regras importantes para o negócio. Redis além de seu uso óbvio para cache, também é muito usado para Lock Distribuído. Já o RabbitMQ é um dos mecanismos responsáveis por entrega de resiliência e controle do processamento, melhorando o consumo de recursos.

Além de claro, nossos bancos de dados: Seja ele relacional como SQL Server ou Postgres, ou NoSQL como MongoDB e outros.

Como um projeto Cloud Native, a primeira opção de deployment é usando containers, com muito potencial para tirar bom proveito do Kubernetes.

Em geral, esses projetos quando baseados em Kubernetes, rodam com Docker Compose na máquina do dev. Não é regra, mas até hoje nunca vi uma ambiente de desenvolvimento onde os desenvolvedores tenham hardware suficiente para rodar Kubernetes local. Simplesmente é incomum.

Nessa breve descrição sobre o arquétipo de um projeto Cloud Native comum, já falamos de diversas tecnologias. Mas se olharmos com cuidado e somarmos os patterns e boas práticas que cada uma dessas tecnologias carrega consigo, encontraremos ao menos uma ou duas centenas de patterns e boas práticas.


Agora imagine que acabou de entrar em um projeto Cloud Native. Logo nas primeiras horas você percebe que o projeto usa vários ou todos esses serviços.

Caso não esteja em dias com essas tecnologias, terá de dedicar algum tempo a estudá-las.

Para piorar sua vida, caso tenha negligenciado o estudo sobre Docker e Containers, perceberá que para rodar a aplicação precisará estudar Docker, Docker Compose, e lidar com arquivos complexos como Dockerfiles e YAML’s para todo o lado, cheios de configurações especializadas.

Como sempre, cada vírgula, cada case, importa. Um detalhe e fora do lugar errado e nada funciona.

Como se não bastasse a frustração, Docker e Containers, em geral, são assuntos que parecem familiares. Essa familiaridade não passa de uma bela casca de banana, e se você não tratar de entender bem, sofrerá com o efeito Danny Kruger.

Será que pode piorar? Pode!

Eventualmente você pode estar em uma empresa onde sequer pode usar o Docker Desktop, pois a emprese se enquadra na faixa de faturamento onde Docker Desktop passa a ser pago e por algum motivo burocrático ou financeiro, Docker Desktop está banido do seu ambiente de desenvolvimento.

Pronto, está desenhado o problema!

Agora imagina uma solução que possa remover toda a complexidade desse dilema a respeito de containers, de tal forma que você só precise focar nas tecnologias e no projeto em si.

Imagine que, além disso, pudesse lidar com as configurações de inicialização da sua aplicação para tornar a subida da sua aplicação mais resiliente.

E que possa instrumentar e configurar toda a parte de observabilidade básica, para monitorar sua aplicação.

E como se não bastasse, ainda tivesse um dashboard incrível para te ajudar com esses cenários de aplicações distribuídas com diversos serviços interconectados.

Esse é o .NET Aspire!


Embora possa parecer uma realidade assustadora, não é bem assim.

Ao longo dos últimos 15 anos, essas mudanças arquiteturais aconteceram gradualmente na medida que as demandas de negócio também mudaram.

Hoje, com quase 500 alunos só no Cloud Native .NET, posso assegurar que essa história que contei acima é realidade de muitas empresas, principalmente as maiores.


E como o .NET Aspire contribui para simplificar essa Jornada?

O primeiro ponto que precisamos ter em mente é que não podemos comparar .NET Aspire com nada que já vimos. Isso porque ele é um punhado de coisas.

.NET Aspire é dividido em diversas partes onde cada uma atende a uma demanda diferente, todas são complementares. São áreas complementares, com níveis de maturidade diferentes, portanto até faria sentido adotar nomes diferentes, para se ter ideia.

Predição: Acredito muito no potencial da feature de orquestração virar um projeto autônomo no futuro.

Proponho entendermos o que existe no Aspire e como cada feature dessa se relaciona com as demais.

Orquestração

A primeira coisa curiosa do Aspire é a parte de orquestração. Em termos de comportamento, é algo bem parecido com a integração do Docker Compose com o Visual Studio.

Um novo projeto com sufixo .AppHost é criado como um projeto Exe c#. Nele temos no Program.cs o papel de expressar a relação de dependência e informar para o orquestrador como cada recurso é criado.

Os recursos podem ser sua aplicação, mas também um SQL Server, Redis, RabbitMQ ou outros recursos necessários para a execução.

Acima temos um exemplo.

Entrando no detalhe, vou usar o projeto Starter para exemplificar. Nele temos a oportunidade de criar uma estrutura pronta par rodar um projeto Blazor e uma Web Api. Uma das features legais desse template é que ele dá a possibilidade de usarmos Redis. Essa opção é apresentada na criação do projeto e ele faz tudo par nós depois da seleção.

Criei a solution com o nome AspireApp1, assim como resultado temos:

  • AspireApp1.ApiService | Web API
  • AspireApp1.AppHost | Aspire Host
  • AspireApp1.ServiceDefaults | Aspire IHostApplicationBuilder Default Configuration
  • AspireApp1.Web | Blazor

Abaixo temos o Program.cs do projeto AspireApp1.AppHost. Note que o segundo comando é a chamada ao método AddRedisContainer().

Fiz o disassembly para podermos ver esse código.

var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedisContainer("cache");

var apiservice = builder.AddProject<Projects.AspireApp1_ApiService>("apiservice");

builder.AddProject<Projects.AspireApp1_Web>("webfrontend")
    .WithReference(cache)
    .WithReference(apiservice);

builder.Build().Run();
public static class RedisBuilderExtensions
{
    /// <summary>
    /// Adds a Redis container to the application model. The default image is "redis" and tag is "latest".
    /// </summary>
    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
    /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
    /// <param name="port">The host port for the redis server.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{RedisContainerResource}"/>.</returns>
    public static IResourceBuilder<RedisContainerResource> AddRedisContainer(this IDistributedApplicationBuilder builder, string name, int? port = null)
    {
        var redis = new RedisContainerResource(name);
        return builder.AddResource(redis)
                      .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteRedisResourceToManifest))
                      .WithAnnotation(new ServiceBindingAnnotation(ProtocolType.Tcp, port: port, containerPort: 6379))
                      .WithAnnotation(new ContainerImageAnnotation { Image = "redis", Tag = "latest" });
    }
...
}
var redis = new RedisContainerResource(name);
return builder.AddResource(redis)
  .WithAnnotation(new ManifestPublishingCallbackAnnotation(WriteRedisResourceToManifest))
  .WithAnnotation(new ServiceBindingAnnotation(ProtocolType.Tcp, port: port, containerPort: 6379))
  .WithAnnotation(new ContainerImageAnnotation { Image = "redis", Tag = "latest" });

Acima temos o Program.cs do novos projeto .AppHost. Note que na linha 3 temos a chamada a uma extension Method de nome AddRedisContainer().

O papel desse projeto é definir programaticamente os componentes e suas dependências, para que ao ser executado, um grafo de dependências seja gerado. É assim que esse projeto orquestrará a criação dos recursos, nesse caso, a criação do container com o Redis.

Esse mecanismo é bem parecido com o do ServiceProvider, no entanto, seu papel é construir recursos de infraestrutura na hora de rodar sua aplicação.

Ele se encarrega de assegurar que o recurso está disponível, evitando que sua aplicação capote já na inicialização. Enquanto o recurso não estiver disponível, ele gerencia o ciclo de vida. Tudo para que quando sua aplicação suba, ela encontre um ambiente saudável pronto para uso.

Aqui há muita similaridade com o docker-compose, entretanto com a definição e execução é totalmente programática, podemos extrapolar a ideia de rodar algo em um container, para conseguir expressar algo como um serviço gerenciado no cloud provider.

No terceiro exemplo de código temos o conteúdo da Extension Method AddRedisContainer. Nela conseguimos ver os detalhes de como ele define a criação de um container com a imagem “redis” e tag “latest”, nesse caso obtendo-a necessariamente do docker hub.

É um bom exemplo para nos ajudar a entender como funciona e o que ele faz.

Podemos dizer que Aspire é um substituto do Docker Compose?

Se o projeto se limitasse à responsabilidade descrita acima, sim. Mas não é a verdade.

O projeto conta com algumas outras capacidades bem interessantes e muito mais maduras.

Dev Dashboard

Herança do Microsoft Tye, o Aspire ganha um dashboard de desenvolvimento.

Esse dashboard já nos mostra que há uma camada e configuração generosa e integrada.

ServiceDefaults

Um novo projeto criado ao lado do projeto principal é o .ServiceDefaults.

Esse novo projeto tem o sufixo .ServiceDefaults. Seu papel é configura o AppBuilder se sua aplicação.

Travestido de uma extension method ( AddServiceDefaults ), esse código configura Telemetria, HealthCheck, ServiceDiscovery e muito mais.

Como podemos ter diversos microsserviços, a presença do ServiceDefaults ajuda a isolar configurações de infraestrutura, coisas genéricas, mas que a Microsoft com cuidado trouxe como boa prática.

Padronização

De fato, configurar observabilidade em um projeto pode ser um desafio. E acredito que olhando as principais implementações se tenha encarado um problema: pouca gente faz certo, e menos gente ainda sequer faz.

Assim a Microsoft lidou com isso configurando boa parte do projeto por você. Se seguir o template ganhará esses projetos e novas Extension Methods.

Componentes

Já que estamos falando de métricas, um dos elementos que suja a imagem de aplicações .NET é o crash na subida da aplicação. Mas de outro lado, o pânico desse crash já fez muita gente negligenciar configurações e recursos dependentes, de tal forma que eles nunca eram usados (por um erro de configuração) mas não havia exceção, ou notificação significativa.

Essas falhas na inicialização do projeto mascaram problemas reais de qualidade de código e implantações.

A solução adotada pela Microsoft é a criação de uma série de Extension Methods que lidam com a criação dos principais objetos de conexão. Seja com banco de dados, com Redis, RabbitMQ, Serviços do Azure e muito mais.

A ideia é criar inicializadores resilientes que, com ajuda do Polly, fazem retry, do jeito certo, para que sua aplicação tenha um ciclo de vida mais saudável. Além disso, realizar todo o setup de observabilidade é relevante para conseguirmos tirar proveito de toda a integração entre os diversos elementos do projeto.

Essas implementações garantem métricas, log tracing, e trará resiliência evitando falsos positivos nos dashboards de observabilidade, causados por ausência de configuração (ou qualidade).

Se olharmos com cuidado é até uma estratégia de contenção de riscos, para a reputação do .NET.

Entendendo a demo

Nosso primeiro exemplo é com o Starter que conta com uma aplicação Blazor e outra WebApi. A aplicação Blazor depende do Redis. O Redis é peculiar porque não sofre com esse problema de resiliência até que se tente obter uma instância do IDatabase .

Abaixo temos um exemplo:

No startup de um projeto Blazor que depende do Redis.

using AspireApp1.Web;
using AspireApp1.Web.Components;

var builder = WebApplication.CreateBuilder(args);
...
builder.AddRedisOutputCache("cache");
...
app.Run();

O fluxo segue
AddRedisOutputCache()
–> AddRedis()
–> AddRedisOutputCacheCore()

Abaixo temos o código dos 3 métodos.

public static void AddRedisOutputCache(this IHostApplicationBuilder builder, string connectionName, Action<StackExchangeRedisSettings>? configureSettings = null, Action<ConfigurationOptions>? configureOptions = null)
{
    builder.AddRedis(connectionName, configureSettings, configureOptions);

    builder.AddRedisOutputCacheCore((RedisOutputCacheOptions options, IServiceProvider sp) =>
    {
        options.ConnectionMultiplexerFactory = () => Task.FromResult(sp.GetRequiredService<IConnectionMultiplexer>());
    });
}
    private static void AddRedis(IHostApplicationBuilder builder, string configurationSectionName, Action<StackExchangeRedisSettings>? configureSettings, Action<ConfigurationOptions>? configureOptions, string connectionName, object? serviceKey)
    {
        ArgumentNullException.ThrowIfNull(builder);

        var configSection = builder.Configuration.GetSection(configurationSectionName);

        StackExchangeRedisSettings settings = new();
        configSection.Bind(settings);

        if (builder.Configuration.GetConnectionString(connectionName) is string connectionString)
        {
            settings.ConnectionString = connectionString;
        }

        configureSettings?.Invoke(settings);

        // see comments on ConfigurationOptionsFactory for why a factory is used here
        builder.Services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory<ConfigurationOptions>),
            sp => new ConfigurationOptionsFactory(
                settings,
                sp.GetServices<IConfigureOptions<ConfigurationOptions>>(),
                sp.GetServices<IPostConfigureOptions<ConfigurationOptions>>(),
                sp.GetServices<IValidateOptions<ConfigurationOptions>>())));

        string? optionsName = serviceKey is null ? null : connectionName;
        builder.Services.Configure<ConfigurationOptions>(
            optionsName ?? Options.Options.DefaultName,
            configurationOptions =>
            {
                BindToConfiguration(configurationOptions, configSection);

                configureOptions?.Invoke(configurationOptions);
            });

        if (serviceKey is null)
        {
            builder.Services.AddSingleton<IConnectionMultiplexer>(
                sp => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, connectionName, configurationSectionName, optionsName), CreateLogger(sp)));
        }
        else
        {
            builder.Services.AddKeyedSingleton<IConnectionMultiplexer>(serviceKey,
                (sp, key) => ConnectionMultiplexer.Connect(GetConfigurationOptions(sp, connectionName, configurationSectionName, optionsName), CreateLogger(sp)));
        }

        if (settings.Tracing)
        {
            // Supports distributed tracing
            builder.Services.AddOpenTelemetry()
                .WithTracing(t =>
                {
                    t.AddRedisInstrumentation();
                });
        }

        if (settings.HealthChecks)
        {
            var healthCheckName = serviceKey is null ? "StackExchange.Redis" : $"StackExchange.Redis_{connectionName}";

            builder.TryAddHealthCheck(
                healthCheckName,
                hcBuilder => hcBuilder.AddRedis(
                    // The connection factory tries to open the connection and throws when it fails.
                    // That is why we don't invoke it here, but capture the state (in a closure)
                    // and let the health check invoke it and handle the exception (if any).
                    connectionMultiplexerFactory: sp => serviceKey is null ? sp.GetRequiredService<IConnectionMultiplexer>() : sp.GetRequiredKeyedService<IConnectionMultiplexer>(serviceKey),
                    healthCheckName));
        }

        static TextWriter? CreateLogger(IServiceProvider serviceProvider)
            => serviceProvider.GetService<ILoggerFactory>() is { } loggerFactory
                ? new LoggingTextWriter(loggerFactory.CreateLogger("Aspire.StackExchange.Redis"))
                : null;
    }

private static void AddRedisOutputCacheCore(this IHostApplicationBuilder builder, Action<RedisOutputCacheOptions, IServiceProvider> configureRedisOptions)
{
    builder.Services.AddStackExchangeRedisOutputCache(static _ => { });

    builder.Services.AddOptions<RedisOutputCacheOptions>() // note that RedisOutputCacheOptions doesn't support named options
        .Configure(configureRedisOptions);
}

O ponto importante aqui é que o client do Redis não valida conectividade na inicialização da instância. Apenas no primeiro consumo, que significa ao acessar o objeto database obtido a partir de uma conexão.

Ainda assim vemos inúmeras configurações dedicadas ao setup das métricas e telemetria em geral, e não vemos resiliência.

    private static IConnection CreateConnection(IConnectionFactory factory, int retryCount)
    {
        var policy = Policy
            .Handle<SocketException>().Or<BrokerUnreachableException>()
            .WaitAndRetry(retryCount, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));

        using var activity = s_activitySource.StartActivity("rabbitmq connect", ActivityKind.Client);
        AddRabbitMQTags(activity);

        return policy.Execute(() =>
        {
            using var connectAttemptActivity = s_activitySource.StartActivity("rabbitmq connect attempt", ActivityKind.Client);
            AddRabbitMQTags(connectAttemptActivity, "connect");

            try
            {
                return factory.CreateConnection();
            }
            catch (Exception ex)
            {
                if (connectAttemptActivity is not null)
                {
                    connectAttemptActivity.AddTag("exception.message", ex.Message);
                    connectAttemptActivity.AddTag("exception.stacktrace", ex.ToString());
                    connectAttemptActivity.AddTag("exception.type", ex.GetType().FullName);
                    connectAttemptActivity.SetStatus(ActivityStatusCode.Error);
                }
                throw;
            }
        });
    }

Acima temos um exemplo do RabbitMQ. Note o cuidado na criação da IConnection.

Vale lembrar que boa parte desses recursos hoje só lidam com o Startup da aplicação.

Conclusão

Aspire é maior do que apenas uma substituição do Docker Compose. Embora não tenha abordado aqui, o deployment é feito usando containers, nós que não precisamos tocar nos Dockerfiles, yaml’s do docker compose e essas coisas.

O Aspire é uma reposta para demanda de tornar profissionais inexperientes e sem skills específicos, mais produtivos.

Orquestração é uma tarefa complexa que destoa dos demais recursos e por isso trem potencial para caminhar isolado até com um nome diferente no futuro.

Por hoje é só!

Dúvidas? Nos comentários ou na caixinha de perguntas lá do instagram.

0 comentários

Enviar um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.

Lives

Fique de olho nas lives

Fique de olho nas lives no meu canal do Youtube, no Canal .NET e nos Grupos do Facebook e Instagram.

Aceleradores

Existem diversas formas de viabilizar o suporte ao teu projeto. Seja com os treinamentos, consultoria, mentorias em grupo.