ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs

ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs

O que acontece entre o socket e o seu código no ASP.NET Core: Kestrel e host, configuração em camadas, o pipeline de middleware e a ordem que importa, cabeçalhos e OnStarting, tempos de vida, roteamento, binding de parâmetros e Minimal APIs ligadas à Livraria.
Linguagem C#

• • 16 min de leitura

A Livraria já sabe guardar produtos em três bancos. Agora ela vai atender pedidos que chegam pela rede, e para isso vamos entender o que acontece entre o byte que entra no socket e a linha de código que lê o estoque.

Da linha de comando ao servidor HTTP

Até aqui quem conversava com a Livraria era o Livraria.Cli: um processo que nasce, faz uma operação e termina. Um servidor web é outra coisa. Ele sobe, fica escutando uma porta por dias ou semanas e atende milhares de requisições ao mesmo tempo, e cada requisição precisa de uma parte do estado (um DbContext, por exemplo) só para ela. Tudo o que vimos em "Um repositório, três bancos: injeção de dependência e a Livraria independente de infraestrutura" foi escolhido pensando nesse cenário. O escopo que criávamos à mão com CreateAsyncScope no CLI agora vai ser criado pelo framework uma vez por requisição.

O ASP.NET Core é o framework web do .NET. Ele tem três peças principais:

  • O Kestrel é o servidor HTTP escrito em C#, multiplataforma e rápido o bastante para ficar exposto direto na internet. Na prática, costuma ficar atrás de um proxy reverso como nginx, YARP ou o balanceador de uma nuvem.
  • O host junta configuração, logging, injeção de dependência e o ciclo de vida da aplicação.
  • O pipeline de middleware é a sequência de funções pela qual cada requisição passa até virar uma resposta.

Criamos o projeto novo ao lado dos outros:

dotnet new web -n Livraria.Api -o src/Livraria.Api
dotnet sln add src/Livraria.Api
dotnet add src/Livraria.Api reference src/Livraria.Aplicacao src/Livraria.Dados.Postgres src/Livraria.Dados.MySql src/Livraria.Dados.Mongo

O arquivo .csproj gerado começa com <Project Sdk="Microsoft.NET.Sdk.Web">. Esse SDK diferente é o que traz a referência ao framework compartilhado Microsoft.AspNetCore.App, então não é preciso instalar nenhum pacote NuGet para ter servidor, roteamento e JSON.

O menor programa possível

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Livraria no ar");

app.Run();

São quatro linhas, e cada uma esconde bastante coisa. WebApplication.CreateBuilder monta um host com padrões sensatos:

  • Configuração lida em camadas, nesta ordem: appsettings.json, depois appsettings.{Ambiente}.json, depois os user secrets (só em Development), depois as variáveis de ambiente e por fim os argumentos de linha de comando. A última fonte vence.
  • Logging para o console.
  • Um IServiceCollection vazio, pronto para receber registros.
  • O Kestrel configurado.

A aplicação tem duas fases bem separadas. Enquanto temos o builder, registramos serviços em builder.Services. Depois de builder.Build(), a coleção de serviços fica somente leitura, e o objeto app serve para montar o pipeline e mapear os endpoints. Tentar registrar um serviço depois do Build lança exceção. É o mesmo princípio do BuildServiceProvider de "Um Repositório, Três Bancos": primeiro se descreve o grafo e depois ele é congelado.

app.Run() bloqueia a thread principal e fica atendendo até o processo receber um sinal de parada (Ctrl+C, SIGTERM de um contêiner). Nesse momento o host inicia um graceful shutdown: para de aceitar conexões novas e dá um tempo para as requisições em andamento terminarem.

O pipeline: uma fila de delegates

No coração do ASP.NET Core há um tipo muito simples:

public delegate Task RequestDelegate(HttpContext context);

Uma requisição inteira cabe em um HttpContext, que reúne Request, Response, User, RequestServices e mais algumas propriedades. Processar uma requisição é chamar um RequestDelegate. Um middleware é uma função que recebe o próximo delegate e devolve um novo delegate que o envolve. Quem leu "Delegates, lambdas e eventos" vai reconhecer o padrão: é composição de funções, montada uma única vez na inicialização.

O resultado lembra uma boneca russa:

requisição →  [Tratamento de exceções
                 [HTTPS redirect
                    [Arquivos estáticos
                       [Autenticação
                          [Autorização
                             [Endpoint: seu código]
                          ]
                       ]
                    ]
                 ]
              ] → resposta

Cada camada pode agir na ida, na volta ou nas duas. Também pode interromper o caminho sem chamar o próximo, o que se chama short-circuit. O middleware de arquivos estáticos faz isso: quando encontra o arquivo, responde e as camadas internas nunca ficam sabendo da requisição.

Este middleware inline mede quanto tempo a requisição levou:

using System.Diagnostics;

app.Use(async (HttpContext contexto, RequestDelegate proximo) =>
{
    var relogio = Stopwatch.StartNew();

    contexto.Response.OnStarting(() =>
    {
        contexto.Response.Headers["X-Tempo-Ms"] =
            relogio.ElapsedMilliseconds.ToString();
        return Task.CompletedTask;
    });

    await proximo(contexto);
});

O detalhe que costuma pegar quem está começando é o OnStarting. Quando o primeiro byte do corpo da resposta sai pela rede, os cabeçalhos já foram enviados e não podem mais mudar. Se escrevêssemos o cabeçalho depois de await proximo(contexto), o endpoint provavelmente já teria começado a escrever o JSON, e a tentativa lançaria InvalidOperationException ("Headers are read-only, response has already started"). OnStarting registra um callback que roda no último instante em que os cabeçalhos ainda podem ser alterados.

Prefira a sobrecarga de Use que recebe (HttpContext, RequestDelegate). A versão antiga recebia um Func<Task> e alocava uma closure por requisição.

app.Run(...) registra um middleware terminal, que nunca chama o próximo. Se ele aparecer antes de outros middlewares, esses outros nunca vão rodar. A ordem de registro é a ordem de execução, e ela importa de verdade. A sequência recomendada pela documentação é esta:

  1. Tratamento de exceções (UseExceptionHandler), o primeiro, para envolver todo o resto.
  2. HSTS e redirecionamento para HTTPS.
  3. Arquivos estáticos.
  4. Roteamento, que o WebApplication insere sozinho.
  5. CORS.
  6. Autenticação e em seguida autorização.
  7. Middlewares próprios que dependem do usuário autenticado.
  8. Endpoints.

Se a autorização vier antes da autenticação, ela vai avaliar um usuário anônimo e negar tudo. Voltaremos a isso em "Autenticação e autorização com JWT".

Middleware como classe e a armadilha dos tempos de vida

Quando a lógica cresce, o middleware vira uma classe. A convenção é um construtor que recebe o RequestDelegate e um método InvokeAsync(HttpContext, ...):

public sealed class IdDeCorrelacao(RequestDelegate proximo)
{
    private const string Cabecalho = "X-Correlation-Id";

    public async Task InvokeAsync(HttpContext contexto, ILogger<IdDeCorrelacao> log)
    {
        var id = contexto.Request.Headers[Cabecalho].FirstOrDefault()
                 ?? contexto.TraceIdentifier;

        contexto.Response.Headers[Cabecalho] = id;

        using (log.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
        {
            await proximo(contexto);
        }
    }
}

// no Program.cs
app.UseMiddleware<IdDeCorrelacao>();

Aqui podemos escrever o cabeçalho antes de chamar o próximo sem problema, porque a resposta ainda não começou.

A regra de tempo de vida é a seguinte. Um middleware baseado em convenção é instanciado uma vez e vive enquanto a aplicação viver, então na prática é um singleton. Se ele pedir um IRepositorioProdutos no construtor, vai capturar o repositório (e o DbContext junto) da primeira requisição e reutilizá-lo em todas as outras. É exatamente a captive dependency que o validateScopes de "Um Repositório, Três Bancos" existe para detectar. Dependências com escopo entram como parâmetros do InvokeAsync, que o framework resolve a partir do escopo da requisição em curso.

Existe também a alternativa IMiddleware: a classe implementa a interface, é registrada no contêiner com o tempo de vida que você quiser e é resolvida a cada requisição. É mais explícita e um pouco mais cara.

Roteamento e endpoints

Um middleware olha para todas as requisições. Um endpoint é o destino de uma requisição específica: um par formado por um padrão de rota e um delegate, acompanhado de metadados. O roteamento acontece em dois tempos. O UseRouting escolhe o endpoint pelo caminho e pelo verbo HTTP e o grava no HttpContext. O UseEndpoints executa o endpoint escolhido. O WebApplication insere os dois automaticamente, o primeiro no início e o segundo no fim do pipeline, e é por isso que o programa mínimo funciona sem chamá-los.

Essa separação tem uma consequência prática. Qualquer middleware registrado depois do roteamento já sabe qual endpoint vai rodar e pode ler os metadados dele. É assim que a autorização descobre se aquele endpoint exige login:

app.Use(async (contexto, proximo) =>
{
    var endpoint = contexto.GetEndpoint();
    if (endpoint?.Metadata.GetMetadata<SomenteLeituraAttribute>() is not null
        && !HttpMethods.IsGet(contexto.Request.Method))
    {
        contexto.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
        return;
    }
    await proximo(contexto);
});

Os padrões de rota aceitam parâmetros e restrições:

app.MapGet("/produtos/{id:int}", ...);           // só casa com inteiros
app.MapGet("/produtos/isbn/{isbn:length(13)}", ...);
app.MapGet("/arquivos/{**caminho}", ...);         // catch-all

As restrições de rota servem para desambiguar rotas, não para validar dados. GET /produtos/abc com {id:int} devolve 404 porque nenhuma rota casou, e não 400. A validação de verdade é o assunto de "Validação, ProblemDetails e OpenAPI".

Binding: de onde vem cada argumento

O que torna as Minimal APIs agradáveis é que o delegate do endpoint é uma função comum, com parâmetros tipados, e o framework descobre de onde tirar cada um:

app.MapGet("/produtos/{id:int}", async (
    int id,                              // valor da rota
    IRepositorioProdutos repositorio,    // serviço registrado no contêiner
    CancellationToken ct) =>             // HttpContext.RequestAborted
{
    var produto = await repositorio.ObterAsync(id, ct);
    return produto is null ? Results.NotFound() : Results.Ok(produto);
});

As regras de inferência, em ordem de prioridade:

  • Tipos especiais são injetados diretamente: HttpContext, HttpRequest, HttpResponse, ClaimsPrincipal e CancellationToken.
  • Um nome que aparece no padrão da rota vem da rota.
  • Um tipo registrado no contêiner vem do contêiner.
  • Tipos simples (números, string, Guid, DateOnly, qualquer tipo com TryParse) vêm da query string.
  • Um tipo complexo em POST, PUT ou PATCH vem do corpo, desserializado de JSON.

Quando a inferência não basta, os atributos resolvem: [FromRoute], [FromQuery], [FromHeader(Name = "X-Api-Versao")], [FromBody], [FromServices], e [AsParameters], que agrupa vários parâmetros numa classe ou record.

O CancellationToken merece atenção. Ele é cancelado quando o cliente desiste, por exemplo ao fechar a aba ou quando o timeout do proxy estoura. Repassá-lo até o ToListAsync do EF Core e até o driver do Mongo faz a consulta ser abortada no banco em vez de rodar para ninguém. É o mesmo raciocínio de "Assincronia Avançada", agora ligado de ponta a ponta sem esforço.

O código acima devolve a entidade Produto diretamente como JSON. Funciona, mas é um erro de projeto que vamos corrigir no próximo artigo.

Reaproveitando a infraestrutura de "Um Repositório, Três Bancos"

No Livraria.Cli, a escolha do banco vinha da variável LIVRARIA_PROVEDOR. Na API ela passa a vir da configuração, que já sabe ler variáveis de ambiente:

// appsettings.Development.json
{
  "Livraria": { "Provedor": "postgres" },
  "ConnectionStrings": {
    "Postgres": "Host=localhost;Database=livraria;Username=livraria;Password=livraria",
    "MySql": "Server=localhost;Database=livraria;User=livraria;Password=livraria",
    "Mongo": "mongodb://localhost:27017/?replicaSet=rs0"
  }
}
var builder = WebApplication.CreateBuilder(args);

var provedor = builder.Configuration["Livraria:Provedor"] ?? "postgres";

switch (provedor)
{
    case "postgres":
        builder.Services.AdicionarLivrariaPostgres(
            builder.Configuration.GetConnectionString("Postgres")!);
        break;
    case "mysql":
        builder.Services.AdicionarLivrariaMySql(
            builder.Configuration.GetConnectionString("MySql")!);
        break;
    case "mongo":
        builder.Services.AdicionarLivrariaMongo(
            builder.Configuration.GetConnectionString("Mongo")!);
        break;
    default:
        throw new InvalidOperationException($"Provedor desconhecido: {provedor}");
}

builder.Services.AddScoped<ServicoDeVendas>();

var app = builder.Build();

Em produção, a variável de ambiente Livraria__Provedor=mysql sobrescreve o arquivo. O duplo sublinhado é o separador de seção que funciona em qualquer sistema operacional, porque o : não é válido em nomes de variável em todo lugar. A connection string de produção nunca vai para o appsettings.json do repositório. Ela entra por variável de ambiente ou por um cofre de segredos, e o artigo "Configuração, logging e testes de integração" aprofunda o assunto.

Nenhuma linha dos projetos de dados mudou, e esse é o ganho de "Um Repositório, Três Bancos". O ASP.NET Core usa o mesmo Microsoft.Extensions.DependencyInjection. A cada requisição ele cria um escopo, expõe esse escopo em HttpContext.RequestServices e o descarta quando a resposta termina. O LivrariaContext registrado como scoped passa a viver exatamente uma requisição. De bônus, quando o ambiente é Development, o host liga sozinho ValidateScopes e ValidateOnBuild, a mesma rede de segurança que ligamos à mão no CLI.

Ambientes, portas e launchSettings

O ambiente vem da variável ASPNETCORE_ENVIRONMENT (ou DOTNET_ENVIRONMENT), e o padrão é Production. Esse padrão é deliberado: esquecer de configurar o ambiente deve resultar no modo mais seguro, que não mostra páginas de erro detalhadas.

O dotnet new web cria o arquivo Properties/launchSettings.json com perfis de execução. Cada perfil define portas e variáveis de ambiente, e é dali que vem o ASPNETCORE_ENVIRONMENT=Development quando se roda dotnet run. Esse arquivo serve apenas ao desenvolvimento local e não vai para a publicação. Em produção, as portas são definidas por ASPNETCORE_URLS ou ASPNETCORE_HTTP_PORTS. As imagens de contêiner oficiais do .NET 8 em diante escutam na porta 8080 por padrão, e não mais na 80, porque rodam com um usuário sem privilégios.

dotnet run --project src/Livraria.Api
curl http://localhost:5080/produtos/1   # a porta é a do seu launchSettings.json, que o template sorteia

Minimal APIs ou controllers?

O ASP.NET Core continua suportando controllers MVC ([ApiController], ControllerBase), e muito código em produção usa esse modelo. As Minimal APIs, introduzidas no .NET 6, viraram a recomendação da Microsoft para APIs novas. Os motivos:

  • Têm menos cerimônia.
  • São mais rápidas, porque o Request Delegate Generator pode gerar o código de binding em tempo de compilação.
  • São compatíveis com Native AOT via WebApplication.CreateSlimBuilder, o que dá o arranque rápido que "Source Generators" anunciou e que "Performance e distribuição" vai medir.

Os controllers ainda ganham em alguns recursos específicos, como as convenções de model binding mais ricas e os filters do MVC. Neste curso usaremos Minimal APIs. Os conceitos de pipeline, DI, binding e resultados são os mesmos nos dois modelos, e quem aprendeu um lê o outro sem dificuldade.

O caminho de uma requisição agora está completo: o Kestrel aceita a conexão, o pipeline passa o HttpContext de camada em camada, o roteamento escolhe um endpoint e o binding transforma rota, query, corpo e contêiner em argumentos de uma função comum. Falta dar a essa função um contrato decente.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Registre três middlewares inline que escrevem no console "A-ida", "B-ida" e "C-ida" antes de chamar o próximo e "A-volta", "B-volta" e "C-volta" depois. Qual ordem aparece no console para uma requisição? E se o B não chamar o próximo?

Ver resposta

✓ Resposta: Com todos chamando o próximo, a saída é A-ida, B-ida, C-ida, depois o endpoint, depois C-volta, B-volta, A-volta: a ida segue a ordem de registro e a volta segue a ordem inversa, como numa pilha de chamadas. Se o B fizer short-circuit, a saída é A-ida, B-ida, B-volta (se ele imprimir antes de retornar) e A-volta. O C e o endpoint nunca rodam.

Exercício 2

Um colega escreveu um middleware que grava o cabeçalho X-Versao depois de await proximo(contexto). Em alguns endpoints funciona e em outros lança "Headers are read-only". Por que o comportamento varia, e como corrigir?

Ver resposta

✓ Resposta: Depende de o endpoint ter escrito algum corpo. A primeira escrita no corpo, por menor que seja, inicia a resposta e trava os cabeçalhos: medido no .NET 10, até um endpoint que devolve o texto "oi" (2 bytes) ou um JSON curto lança a exceção. Funciona só nos endpoints que não escrevem corpo nenhum, como um 204 No Content, um 404 de rota inexistente ou um redirecionamento. Daí a impressão de que "às vezes funciona". Há duas correções: escrever o cabeçalho antes de chamar o próximo, quando o valor já é conhecido, ou registrar contexto.Response.OnStarting(...), que roda no último momento em que ainda é possível alterar os cabeçalhos. Checar contexto.Response.HasStarted evita a exceção, mas não entrega o cabeçalho.

Exercício 3

Por que este middleware é um bug, e como reescrevê-lo?

public sealed class Auditoria(RequestDelegate proximo, LivrariaContext db)
{
    public async Task InvokeAsync(HttpContext c) { /* grava em db */ await proximo(c); }
}
Ver resposta

✓ Resposta: Middlewares baseados em convenção são criados uma única vez. O LivrariaContext, que é scoped, fica capturado para sempre e é compartilhado entre requisições concorrentes. O DbContext não é thread-safe, e o change tracker acumula entidades sem parar. Em Development o host detecta a situação e falha na inicialização, porque ValidateScopes está ligado. A correção é mover a dependência para o método: public async Task InvokeAsync(HttpContext c, LivrariaContext db). Outra opção é implementar IMiddleware e registrar a classe como scoped.

Exercício 4

Com a rota app.MapGet("/produtos/{id:int}", ...), o que o cliente recebe em GET /produtos/abc? E em GET /produtos/99999999999? Por que usar restrições de rota como validação é má ideia?

Ver resposta

✓ Resposta: Em /produtos/abc a restrição int não casa, nenhuma rota é encontrada e a resposta é 404. Em /produtos/99999999999 o valor não cabe num int, a restrição também falha e a resposta é outro 404. O cliente recebe "não existe" quando o problema é "dado mal formado", sem mensagem nenhuma. Restrições servem para escolher entre rotas (por exemplo, /produtos/{id:int} e /produtos/{slug}). A validação deve produzir um 400 com uma explicação, como veremos em "Validação, ProblemDetails e OpenAPI".

Exercício 5

Faça a API aceitar a variável de ambiente LIVRARIA_PROVEDOR, a mesma do CLI, além de Livraria__Provedor, sem duplicar o switch. Qual das duas deve vencer se ambas estiverem definidas?

Ver resposta

✓ Resposta: Uma forma simples é var provedor = builder.Configuration["Livraria:Provedor"] ?? Environment.GetEnvironmentVariable("LIVRARIA_PROVEDOR") ?? "postgres";. Uma alternativa mais elegante é mapear a variável antiga para a nova chave com builder.Configuration.AddInMemoryCollection(...) antes de ler, ou usar AddEnvironmentVariables(prefix: "LIVRARIA_"), que expõe LIVRARIA_PROVEDOR como a chave PROVEDOR. Deve vencer a forma nova (Livraria__Provedor), porque é a convenção da configuração hierárquica e a variável antiga existe só por compatibilidade. Vale registrar um aviso no log quando a antiga for usada, para que ela possa ser removida depois.

Comentários

Mais em Linguagem C#

Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos
Assincronia Avançada: Muitas Tarefas, Cancelamento e Fluxos Assíncronos

Assincronia avançada no C#: Task.WhenAll e as exceções múltiplas, WaitAsync e…

Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis
Sequências Sob Demanda: IEnumerable, yield e Coleções Imutáveis

O que o foreach faz por baixo com IEnumerable e IEnumerator, iteradores com…

gRPC: contratos binários e streaming entre serviços
gRPC: contratos binários e streaming entre serviços

A Livraria ganha um serviço de frete e conversa com ele por gRPC: contrato em…