← voltar ao índice

· 10 min de leitura · por Tiago

Source Generators: menos reflection, mais startup

Se você já rodou um dotnet-trace num serviço .NET em produção e olhou o flame graph do startup, provavelmente encontrou um vilão recorrente: reflection. Chamadas a Type.GetProperties(), Activator.CreateInstance(), resolução de atributos customizados em runtime, tudo isso tem um custo que se paga toda vez que o processo sobe. Em ambientes tradicionais, onde a aplicação fica rodando por horas ou dias, esse custo é irrelevante. Mas no mundo de containers, funções serverless e Kubernetes com autoscaling agressivo, cada milissegundo de cold start importa, e é exatamente aí que os Source Generators do Roslyn se tornaram uma ferramenta central no ecossistema .NET.

Neste post vamos entender por que reflection é cara, como os Source Generators atacam esse problema gerando código em tempo de compilação em vez de descobrir tudo em runtime, e vamos construir exemplos práticos, desde o uso dos geradores prontos do próprio .NET até a criação de um gerador incremental do zero.

Por que reflection é um problema de performance

Reflection não é lenta por acidente de implementação, ela é lenta por natureza. Quando você chama typeof(Pessoa).GetProperties(), o runtime precisa varrer metadados do assembly, montar objetos PropertyInfo (que não são gratuitos), e cachear tudo isso internamente para não repetir o trabalho na próxima chamada. Esse cache ajuda em chamadas subsequentes, mas o custo do "primeiro acesso" continua existindo, e em aplicações que sobem e morrem rapidamente (functions, jobs curtos, testes de integração que criam containers) esse primeiro acesso acontece toda vez.

Além do custo de CPU, reflection tem um problema mais sério em cenários modernos: ela não é amigável com trimming e Native AOT. Quando o publicador de uma aplicação remove código "não utilizado" para reduzir o tamanho do binário, ele precisa saber estaticamente quais tipos e membros são referenciados. Reflection dinâmica (Type.GetType("Namespace.Classe") a partir de uma string, por exemplo) quebra essa análise estática, porque o compilador não tem como saber, olhando o código, que aquele tipo será necessário. O resultado são exceptions em runtime só descobertas em produção, depois que o trimmer removeu algo que era referenciado apenas via reflection.

Os Source Generators resolvem os dois problemas ao mesmo tempo: o código que faria reflection em runtime é substituído por código concreto, gerado durante a compilação, que o compilador e o trimmer enxergam normalmente. Não há mágica escondida, é só código C# comum, só que escrito por uma ferramenta em vez de um humano.

Como funciona um Source Generator na prática

Um Source Generator é um componente que roda dentro do processo de compilação do Roslyn. Ele recebe acesso à árvore sintática e ao modelo semântico do seu código (o compilador já fez o parsing, então o gerador não precisa reinventar um parser de C#) e, a partir disso, produz arquivos .cs adicionais que são compilados junto com o resto do projeto. Desde o .NET 6, a API recomendada é a de Incremental Generators (IIncrementalGenerator), que substituiu a API original ISourceGenerator justamente porque ela permite que o Roslyn faça caching de cada etapa do pipeline, reexecutando apenas o que mudou entre uma digitação e outra no editor. Isso é crítico: um gerador mal escrito, que reprocessa toda a solução a cada tecla, pode deixar o IntelliSense do Visual Studio ou do Rider completamente travado.

O exemplo mais usado no dia a dia .NET hoje é o System.Text.Json com geração de contexto de serialização. Em vez de o serializador descobrir em runtime, via reflection, quais propriedades uma classe tem e como convertê-las, você declara um contexto de serialização e o gerador cria, em tempo de compilação, todo o código de leitura e escrita:

using System.Text.Json.Serialization;

public class Pedido
{
    public int Id { get; set; }
    public string Cliente { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public DateTime CriadoEm { get; set; }
}

[JsonSerializable(typeof(Pedido))]
[JsonSerializable(typeof(List<Pedido>))]
internal partial class PedidoJsonContext : JsonSerializerContext
{
}

O atributo JsonSourceGenerationOptions pode ser adicionado à classe de contexto para configurar coisas como PropertyNamingPolicy ou WriteIndented, mas o ponto principal está na palavra partial. O gerador enxerga essa classe parcial, entende que ela herda de JsonSerializerContext, olha os tipos declarados nos atributos JsonSerializable e escreve a outra metade da classe, com métodos como GetTypeInfo totalmente concretos, sem nenhuma chamada a GetProperties() em runtime. Para usar esse contexto:

var pedido = new Pedido { Id = 1, Cliente = "Ana", Total = 199.90m, CriadoEm = DateTime.UtcNow };

string json = JsonSerializer.Serialize(pedido, PedidoJsonContext.Default.Pedido);

Pedido? deserializado = JsonSerializer.Deserialize(json, PedidoJsonContext.Default.Pedido);

Repare que não passamos mais o tipo genérico <Pedido> como parâmetro de tipo do método, e sim a instância PedidoJsonContext.Default.Pedido, que é o metadado já compilado estaticamente. Isso não é só mais rápido: em projetos publicados com Native AOT, essa é praticamente a única forma suportada de serializar JSON, porque o serializador reflexivo simplesmente não existe no binário final (ele foi retirado pelo trimmer, e mesmo que não fosse, geração de código dinâmico via Reflection.Emit não funciona em AOT).

Outro exemplo consagrado: Regex Source Generator

Outro caso que ilustra bem o ganho de performance é o GeneratedRegexAttribute, introduzido no .NET 7. Antes dele, criar uma instância de Regex com RegexOptions.Compiled gerava IL dinamicamente em runtime na primeira chamada, o que trazia um pico de latência logo no início. Com o gerador, você declara o método parcial e o padrão:

public partial class ValidadorDeEmail
{
    [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
    private static partial Regex EmailRegex();

    public bool Valido(string email) => EmailRegex().IsMatch(email);
}

O gerador cria, em tempo de compilação, uma classe interna derivada de Regex com toda a lógica de matching já traduzida para C# comum, incluindo os estados da máquina de busca. Isso significa: zero geração de IL em runtime, startup mais previsível e, como bônus, o analisador consegue validar a sintaxe da expressão regular ainda no editor, avisando sobre erros antes mesmo de compilar.

Construindo seu próprio gerador incremental

Entender os geradores prontos é útil, mas o verdadeiro valor aparece quando você identifica um padrão repetitivo no seu próprio código, baseado em reflection, e decide substituí-lo. Um exemplo clássico é gerar automaticamente um método ToDictionary() para classes marcadas com um atributo, evitando reflection sobre propriedades em runtime.

Primeiro, o projeto do gerador precisa ser uma biblioteca separada, visando netstandard2.0 (restrição do Roslyn, que precisa rodar em qualquer versão do compilador) e com o pacote Microsoft.CodeAnalysis.CSharp:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.0" PrivateAssets="all" />
  </ItemGroup>
</Project>

Em seguida, o atributo marcador, que costuma viver num projeto compartilhado ou ser gerado pelo próprio gerador via RegisterPostInitializationOutput:

[AttributeUsage(AttributeTargets.Class)]
public sealed class GerarDicionarioAttribute : Attribute { }

O gerador em si segue o padrão de pipeline do IIncrementalGenerator. A ideia é filtrar rapidamente, usando apenas sintaxe (sem tocar no modelo semântico, que é mais caro), quais nós candidatos existem, e só então enriquecer com informação semântica os que sobreviveram ao filtro:

[Generator]
public class DicionarioGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var candidatos = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "GerarDicionarioAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol);

        context.RegisterSourceOutput(candidatos, static (spc, tipo) =>
        {
            var propriedades = tipo.GetMembers()
                .OfType<IPropertySymbol>()
                .Where(p => p.GetMethod is not null && !p.IsStatic);

            var linhas = string.Join("\n",
                propriedades.Select(p => $"        dict[\"{p.Name}\"] = {p.Name};"));

            var codigo = $$"""
                namespace {{tipo.ContainingNamespace}};

                public partial class {{tipo.Name}}
                {
                    public System.Collections.Generic.Dictionary<string, object?> ToDictionary()
                    {
                        var dict = new System.Collections.Generic.Dictionary<string, object?>();
                {{linhas}}
                        return dict;
                    }
                }
                """;

            spc.AddSource($"{tipo.Name}.ToDictionary.g.cs", codigo);
        });
    }
}

O método ForAttributeWithMetadataName é a API mais eficiente para esse cenário, disponível a partir do .NET 8, porque o próprio Roslyn já otimiza internamente a busca por classes com aquele atributo específico, em vez de você varrer manualmente toda a árvore sintática do projeto. O resultado é um método ToDictionary() totalmente estático, visível no IntelliSense, navegável com F12, e sem nenhuma chamada a GetProperties() quando a aplicação roda.

Para consumir, basta marcar a classe e declará-la como partial:

[GerarDicionario]
public partial class ClienteDto
{
    public int Id { get; set; }
    public string Nome { get; set; } = string.Empty;
}

Armadilhas e pontos de atenção

O primeiro erro comum é esquecer que geradores rodam durante a compilação e, portanto, não podem depender de bibliotecas de runtime "normais" com liberdade total. Como o projeto do gerador visa netstandard2.0, APIs mais recentes de LINQ ou de string precisam ser verificadas com cuidado, e é fácil quebrar a compilação do gerador em máquinas com uma versão diferente do SDK.

O segundo ponto é performance do próprio gerador. Um gerador que usa ForAttributeWithMetadataName corretamente e mantém os passos do pipeline com dados imutáveis e comparáveis por valor (evite usar ISymbol diretamente como chave de cache sem cuidado, prefira projetar para um record simples) se beneficia do cache incremental do Roslyn. Já um gerador que reconstrói toda a análise a cada chamada de RegisterSourceOutput, sem aproveitar os providers incrementais, vai reprocessar tudo a cada tecla digitada, deixando o editor lento. Esse é o motivo pelo qual, ao migrar de ISourceGenerator para IIncrementalGenerator, projetos grandes como o próprio ASP.NET Core relataram ganhos perceptíveis de responsividade no IDE.

Outro cuidado importante é com diagnostics. Um gerador bem construído não deve simplesmente falhar silenciosamente quando encontra um caso não suportado (por exemplo, uma propriedade init-only ou um tipo genérico aberto). O ideal é emitir um Diagnostic customizado, com um ID próprio (convencionalmente prefixado, como LOGBY001), para que o desenvolvedor veja o erro diretamente na lista de erros do editor, e não descubra o problema só em runtime.

Por fim, vale lembrar que Source Generators não substituem reflection em todos os cenários. Bibliotecas que dependem de plugins carregados dinamicamente, com tipos desconhecidos em tempo de compilação, continuam precisando de reflection ou de outras técnicas como DependencyInjection baseado em convenções. A regra prática é: se o conjunto de tipos envolvidos é conhecido em tempo de compilação (seus próprios DTOs, seus próprios modelos), o gerador é a escolha natural; se o conjunto só é conhecido em runtime (plugins de terceiros, carregamento dinâmico de assemblies), reflection continua sendo a ferramenta certa, ainda que se possa mitigar seu custo com cache agressivo.

Guia de decisão rápido

Use Source Generators quando você tem tipos fixos e conhecidos em compilação, como DTOs de API, entidades de mapeamento objeto-relacional simples ou classes de configuração, e quer eliminar reflection do caminho crítico de startup ou preparar a aplicação para Native AOT. Prefira reflection tradicional quando o cenário exige descoberta dinâmica de tipos em runtime, como sistemas de plugins ou frameworks de injeção de dependência que escaneiam assemblies carregados externamente. Considere Expression Trees compiladas ou DynamicMethod (via Reflection.Emit) como meio-termo quando você precisa de flexibilidade em runtime mas ainda quer evitar o custo repetido de reflection pura, aceitando o custo único de compilação de uma expressão na primeira execução, desde que o ambiente não seja Native AOT (onde emissão de IL dinâmica não é suportada).

Conclusão

Source Generators representam uma mudança de mentalidade importante no ecossistema .NET: em vez de descobrir a estrutura do programa em runtime, gastando ciclos de CPU toda vez que o processo sobe, movemos essa descoberta para o momento da compilação, quando ela só precisa acontecer uma vez. Isso não é apenas uma otimização de performance, é um pré-requisito para o futuro de Native AOT, trimming agressivo e startups medidos em milissegundos que a nuvem moderna exige. Bibliotecas centrais como System.Text.Json, Regex e o próprio Microsoft.Extensions.DependencyInjection já adotaram essa abordagem, e a tendência é que cada vez mais código de infraestrutura repetitivo no seu projeto, aquele que hoje depende de GetProperties() espalhado por toda a base, seja substituído por geradores incrementais bem escritos. Vale o investimento de aprender a API IIncrementalGenerator: o ganho em startup e a compatibilidade com AOT tendem a compensar rapidamente a curva de aprendizado inicial.

← voltar ao índice