Reflection oferece um mecanismo sofisticado para inspeção e manipulação de metadados de tipos, métodos e propriedades em tempo de execução. No entanto, essa flexibilidade vem acompanhada de custos elevados, que penalizam desempenho e eficiência de qualquer aplicação.
Sempre que podemos evitar, evitamos seu uso, mas e quando não podemos? Como podemos tratar ou mitigar esses prejuízos em desempenho?
Hoje vou apresentar uma abordagem que usei recentemente em um projeto recente.
Reflection e seus custos
O impacto do uso de Reflection no desempenho pode ser atribuído a diversos fatores, sendo os principais:
- Sobrecarga Computacional e Latência: O uso de Reflection exige buscas dinâmicas por metadados no assembly, além de processos de validação de acesso e resolução dinâmica de métodos. Esse mecanismo pode ser entre 10 e 100 vezes mais lento do que chamadas diretas a métodos, o que pode impactar a responsividade da aplicação em cenários de alta concorrência.
- Alocação Excessiva de Memória: Reflection frequentemente induz a criação de objetos temporários, principalmente ao utilizar
Activator.CreateInstance
para instanciar dinamicamente classes, o que resulta em maior pressão sobre o garbage collector e consequente degradação de desempenho. - Impedimentos à Otimização Just-In-Time (JIT): Métodos invocados via Reflection não são otimizados pelo compilador JIT, pois a inferência estática é impossibilitada. Como resultado, essas chamadas não aproveitam otimizações como inlining e caching de código compilado.
- Redução da Capacidade de Diagnóstico e Debugging: Reflection pode tornar o código menos previsível, dificultando a rastreabilidade e depuração de erros, uma vez que falhas podem ocorrer apenas em tempo de execução e não serem capturadas pelo compilador.
- Impacto na Segurança e Manutenção do Código: O uso excessivo de Reflection pode dificultar a manutenção do código, especialmente em equipes grandes, pois oculta dependências explícitas e torna a lógica de negócio mais difícil de compreender e auditar.
Se é tão ruim, por que não abolimos o uso de reflection?
Abolir é um termo muito duro para o problema, mas sim evitar o uso de reflection é uma estratégia que começou a ser adotada por volta de 2005 e é utilizada até hoje.
Evitamos reflection sempre que possível, como no caso que trago hoje para o exemplo:
As versões pré-lançamento do Oragon.RabbitMQ eram baseadas em lambdas com assinaturas pré-definidas, de forma que não precisava de reflection em momento algum. Forçar uma assinatura única, para evitar o uso de reflection, não era meu desejo, mas era o que dava para fazer para evitar reflection.
Somente em dezembro de 2024, em uma mentoria (não me lembro se foi com os alunos do Cloud Native .NET ou do Mensageria .NET) que, durante uma explicação, resolvi olhar a implementação de Minimal API’s do ASP .NET com o ILSpy, e entender por baixo do capô como Minimal API’s faziam, que me toquei de que não tinha como evitar o uso de reflection.
E de fato, em certos tipos de cenário, não dá para evitar reflection.
Situações em que Reflection é inevitável
Há casos em que simplesmente não é possível adotar outra abordagem, e seu emprego é indispensável e, em alguns poucos casos, reflection é a única solução viável:
- Serialização/Desserialização Dinâmica: Frameworks de serialização, como Newtonsoft.Json e System.Text.Json, utilizam Reflection para inspecionar propriedades de classes e converter dados entre diferentes formatos.
- Injeção de Dependência e Configuração de Serviços: Contêineres de injeção de dependência, como Autofac e o próprio
IServiceProvider
do ASP.NET Core, utilizam Reflection para resolver instâncias de serviços e gerar fábricas dinamicamente. - Execução de Código Dinâmico: Aplicações que requerem carregamento dinâmico de assemblies e execução de código em tempo de execução, como sistemas de plugins e motores de scripts, dependem da capacidade introspectiva da Reflection.
- Automação e Ferramentas de Análise Estática: Ferramentas de análise de código, geração de proxies dinâmicos e introspecção de assemblies fazem uso extensivo de Reflection para examinar a estrutura interna de classes e métodos.
Source Generator é uma saída elegante
Lançado em 2020 com o .NET 5, Source Generator um novo recurso do compilador C# que permite que desenvolvedores C# inspecionem o código do usuário e gerem novos arquivos de origem C# que podem ser adicionados a uma compilação.. Source Generator não é bala de prata e não substitui completamente Reflection em todos os cenários, mas há uma parte significativa dos cenários de uso de reflection, em que é possível trazer toda a introspecção e dinamismo, que seria executada em runtime, para o momento do build.
Muitas vezes reflection é utilizada para simplificar e facilitar a codificação. Por exemplo, quando você constrói uma Action (Método) em uma Controller ou define uma expressão em uma Minimal API você está usando essa simplificação. O intuito é reduzir a quantidade de código protocolar e deixar para a infraestrutura do ASP.NET realizar todo o bind entre o model, o mecanismo de injeção de dependência e seu método.
Mas concorda que cada controller ou minimal API terá uma única mesma assinatura durante o ciclo de vida da instância da aplicação? A assinatura não é dinâmica, é estática. Para mudar uma assinatura do método, demanda um build, deploy etc.
Então, por que usar reflection em tempo de execução se podemos fazer isso apenas durante o build?
Essa é uma pergunta para a qual Source Generator é a resposta.
Assim, é perfeitamente possível ter source generators que convertam minimal api’s e actions de controllers em métodos mais burocráticos, removendo toda necessidade de reflection, trazendo todo o custo para a build. Essa abordagem antecipa a introspecção para o momento do build, assegurando que não precisaremos de introspecção durante a execução da aplicação (runtime).
Enquanto na Microsoft a busca por AOT faz a adoção de Source Generators estar aceleradíssima e abrangente, na comunidade e para os produtores de bibliotecas essa ainda não é uma realidade.
Bibliotecas como:
- MassTransit
- EasyNetQ
- Oragon.RabbitMQ
Ainda não adotaram Source Generators e é possível que outras bibliotecas sequer façam algum dia, sendo incompatíveis com AOT.
Mas afinal que custo é esse?
Quando trabalhamos com Middlewares, somos forçados a implementar uma assinatura específica, assim evitamos a utilização de reflection, mas quando lidamos com Controllers, Actions e Minimal API’s, há certa liberdade na assinatura e reflection se faz necessário.
Quando você implementa uma Minimal API ou uma Controller, um mecanismo do ASP.NET precisa mapear uma rota (em geral, informada via atributo) para um método escrito pelo desenvolvedor, do qual o ASP.NET não reconhece a assinatura. Esse método, ou seja, o código que você escreveu, pode ter praticamente qualquer assinatura.
Assim, o ASP.NET precisa transformar um request HTTP em parâmetros para que consiga chamar o seu código (da Action, por exemplo), e para isso ele vai pesquisar todos os argumentos do método, de todos os métodos públicos, de todas as suas controllers e minimal API’s em busca das assinaturas de cada método. Uma vez de posse dessa lista de assinaturas, é possível determinar os ModelBinders para cada Action e realizar algumas validações em busca de coerência e consistência.
Dessa forma, o ASP.NET consegue dinamicamente chamar seu método, passando Body, Http Headers, forms, serviços configurados na injeção de dependência e tudo mais que seu método precisa.
Sempre que precisamos perguntar para um tipo quais são suas características, pagamos um preço alto. Se queremos descobrir quais são as propriedades de um tipo, quais são os parâmetros de um método, ou coisas similares, temos de usar reflection para isso.
Esse não é o único cenário de reflection, é um exemplo.
Então a solução não seria abolir?
De um lado, reflection nos permite a injeção de dependência como conhecemos hoje, actions e controllers, minimal API’s e abstrações de consumo de filas como MassTransit, EasyNetQ e Oragon.RabbitMQ, da mesma forma que nos permite a adoção de MediatR e outras muitas tecnologias, inclusive de ORM.
Então, conviver com .NET sem reflection tornaria a plataforma irreconhecível, muito mais burocrática, tediosa e complexa.
As alternativas são:
- De um lado, adotar source generators sempre que possível e viável.
- E, de outro lado, otimizar o uso de reflection, evitando introspecções desnecessárias.
Otimizando Reflection em runtime com cache
Uma das demandas para o Oragon.RabbitMQ é a adoção de Source Generators para podermos eliminar o uso de reflection em runtime. Mas assim como outras bibliotecas desse gênero, essa solução ainda não está pronta.
Não busquei no roadmap das demais,
apenas busquei implementações
de source generators e reflection
na data desse post
Então, como minimizar o impacto de reflection em meu código?
A primeira pergunta a ser realizada antes de pensarmos em otimização é:
Quantas vezes serão realizadas essas introspecções?
A forma mais comum de implementação desse tipo de introspecção é realizar a cada chamada dinâmica.
Essa é a forma mais fácil e também a mais ineficiente.
A solução mais eficiente seria não adotar reflection, mas uma vez necessária, a solução mais eficiente é produzir um cache otimizado, reduzindo drasticamente o custo e pagando esse preço apenas uma vez, deixando de executar a introspecção a toda chamada dinâmica para pagar esse preço apenas uma única vez, necessariamente na subida da aplicação.
Temos como solução:
- A redução da quantidade de introspecção.
- Movemos todo o custo para a inicialização da aplicação.
- Cada chamada dinâmica não precisa mais de introspecção.
- Mover para a inicialização da aplicação evita custo exorbitante na primeira execução, como seria natural caso precisasse da primeira chamada para ativar esse cache.
Assim, evitamos buscas repetitivas por métodos, propriedades, parâmetros e atributos, e é possível armazenar esses resultados em estruturas de cache eficientes que duram por todo o ciclo de vida da instância da aplicação.
Essa estratégia reduz significativamente o tempo gasto na recuperação de metadados, melhorando a eficiência global.
Mas como implementar?
Ao invés de, ao receber cada mensagem, descobrir como chamar o método dinamicamente, optamos por uma estratégia em que, na subida da aplicação validamos tudo que foi registrado.
E talvez você não tenha percebido que ao chamar app.MapPost, você está registrando um handler na infra do ASP.NET Minimal API.
Assim quando você chama:
- No caso do ASP.NET minimal API:
- MapGet
- MaoPost
- MapGet
- MapPatch
- MapPut
- MapDelete
- …
- Ou no caso do Oragon.RabbitMQ:
- MapQueue
Você está registrando listeners, e parte do que você registra é essa expressão, que durante a compilação é encarada como um método.
... app.MapPost("/http-example", ([FromServices] EmailService svc, [FromBody] DoSomethingCommand cmd) => { ... return Results.Ok(); }); ...
... app.MapQueue("amqp-example", ([FromServices] EmailService svc, [FromBody] DoSomethingCommand cmd) => { ... return AmqpResults.Ack(); }) ...
Nem ASP.NET Minimal API, nem o Oragon.RabbitMQ precisamos ler o assembly inteiro buscando itens de interesse. Nós já recebemos essas informações gratuitamente quando no program.cs as chamadas ao MapPost (e seus irmãos) e MapQueue são executadas para registrar esses handlers. Então, ainda durante o processo de configuração da aplicação, já recebemos tudo que precisamos para fazer todas as análises.
... public static ConsumerParameters MapQueue(this IHost host, string queueName, Delegate handler) { return host.Services.MapQueue(queueName, handler); } ... public static ConsumerDescriptor MapQueue(this IServiceProvider serviceProvider, string queueName, Delegate handler) { var consumerDescriptor = new ConsumerDescriptor(serviceProvider, queueName, handler); ConsumerServer consumerServer = serviceProvider.GetRequiredService<ConsumerServer>(); consumerServer.AddConsumerDescriptor(consumerDescriptor); return consumerDescriptor; } ...
Após a configuração, temos a subida da aplicação, e é aí que fazemos uso dessas configurações para de fato produzir análises com base em tudo que foi registrado.
Na hora de subir o consumidor, é hora de varrer os metadados internos, que temos com a assinatura de cada endpoint e buscar todos os parâmetros.
Com base na lista de parâmetros de entrada e saída, constrói-se o model binder com a lista de binders que preencherão os argumentos daquele método.
Pronto, agora a cada execução você não precisa descobrir quais são os argumentos do método dinâmico, você apenas obtém o model binder correspondente, itera produzindo a lista de argumentos e produz essa chamada.
Benefícios
A implementação das técnicas acima proporciona uma série de vantagens:
- Melhoria no Tempo de Resolução de Métodos: A redução do número de chamadas de Reflection permite ganhos significativos de tempo de execução.
- Eficiência na Utilização de Memória: O uso de caches reduz a alocação desnecessária de objetos intermediários.
- Escalabilidade Aprimorada: Aplicações de alta carga se beneficiam da redução da latência de chamadas dinâmicas, tornando-se mais responsivas e eficientes.
- Maior Clareza e Manutenção do Código: Reduzindo a dependência de Reflection, o código se torna mais previsível e fácil de manter.
- Segurança Aprimorada: Evitar Reflection excessiva reduz vulnerabilidades associadas a ataques baseados em introspecção de código e manipulação dinâmica de objetos.
Considerações Finais
Reflection, apesar de seu custo, continua sendo um recurso fundamental para determinados cenários. A aplicação de técnicas como caching de metadados e geração estática de código reduzem significativamente os impactos negativos associados ao seu uso. Portanto, em vez de evitá-la completamente, a abordagem correta é otimizar sua utilização, garantindo que a flexibilidade oferecida pela introspecção de código não comprometa a escalabilidade e o desempenho da aplicação.
0 comentários