Olá!
Este é um post da seção CooperaCode, uma iniciativa do colega Yan Justino que propõe desafios no LinkedIn baseados em códigos com más práticas.
Normalmente respondo diretamente pelos comentários mas, desta vez, achei interessante trazer para o blog já que temos uma questão importante de desempenho envolvida.
Vamos lá!
O Desafio
O desafio é otimizar uma query muito ineficiente, que vai ao banco de dados por diversas vezes, uma para cada registro necessário à composição de uma view. Veja o código abaixo:
public class PedidoService
{
private readonly AppDbContext _context;
public PedidoService(AppDbContext context)
{
_context = context;
}
public List<PedidoComClienteDto> ObterPedidos()
{
var pedidos = _context.Pedidos.ToList();
var resultado = new List<PedidoComClienteDto>();
foreach (var pedido in pedidos)
{
var cliente = _context.Clientes.FirstOrDefault(c => c.Id == pedido.ClienteId);
resultado.Add(new PedidoComClienteDto
{
PedidoId = pedido.Id,
Data = pedido.DataCriacao,
NomeCliente = cliente?.Nome
});
}
return resultado;
}
}
public class PedidoComClienteDto
{
public int PedidoId { get; set; }
public DateTime Data { get; set; }
public string NomeCliente { get; set; }
}
Repare que, nesta abordagem, para cada pedido existente na base, será feita uma nova consulta para seu respectivo cliente. Ou seja, quanto mais a tabela de pedidos cresce, mais consultas são realizadas, mais rede é consumida, e mais demorada é a resposta. E pior, existe uma alocação tremenda de memória porque a transformação de IEnumuerable<PedidoComClienteDto> em List<PedidoComClienteDto> duplica a quantidade de objetos alocados, pois os que pertencem ao IEnumerable tem cópias criadas para a List por conta da natureza lazy de IEnumerable versus a eager da List.
Um verdadeiro horror!
A Solução
A solução mais elegante que encontrei, cujos números você poderá ver no benchmark que estará no final deste bloco, faz algo muito interessante. Em vez de ir ao banco uma vez para cada cliente demandado por um pedido, apenas uma conexão é necessária para trazer todo o conteúdo de pedidos e clientes para a memória e, então, criar as views.
Aqui segue o código:
public IList<CustomerIdentifiedOrderViewAsStruct> Solution()
{
using var dbContext = new AppDbContext();
var result = dbContext.Orders
.Include(o => o.Customer)
.AsNoTracking()
.AsValueEnumerable()
.Select(order => new CustomerIdentifiedOrderViewAsStruct
{
OrderId = order.Id,
CreationDate = order.CreationDate,
CustomerName = order.Customer!.Name
})
.ToList();
return result;
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public readonly record struct CustomerIdentifiedOrderViewAsStruct(int OrderId, DateTime CreationDate, string CustomerName);
Antes de mais nada, perceba como este código é muito mais simples e direto ao ponto. Existe apenas uma consulta, relacionando os pedidos com seus respectivos clientes, e todo o resto do trabalho é feito em memória.
Além de realizarmos menos idas ao banco, repare no método AsNoTracking(). Ele é um método do EF Core para trabalhar com consultas cujos dados devam servir apenas para leitura, ou seja, não serão modificados e persistidos. Isso melhora muito a velocidade e a alocação em memória porque dispensa o EF Core de criar os rastreadores para os objetos criados na query.
Se você leu meu post sobre o ZLinq deve ter percebi que ele está em uso nessa solução por conta do método AsValueEnumerable(). A ideia é reduzir dramaticamente a quantidade de alocações para economizar memória e reduzir, com isso, a pressão sobre o Garbage Collector.
Além disso, a própria view foi transformada em um record struct em vez de uma classe para reduzir as alocações. Repare no atributo StructLayout, ele serve para indicar ao compilador como os bytes da struct devem ser arranjados para melhor uso da stack. Escrevi este post a respeito, vale a leitura!
Apesar desta solução ser ótima do ponto de vista de desempenho, há algumas considerações que gostaria de fazer do ponto de vista de design. Em primeiro lugar o ideal é evitar o uso de um único DbContext para toda a aplicação. Isso porque haverá uma pressão enorme para que consultas como a do desafio sejam feitas. Neste cenário em especial, o ideal seria que cada DbContext estivesse encapsulado em um módulo (Pedido ou Cliente) e que o módulo de Pedido pudesse pedir uma coleção de Clientes com base em uma coleção de ID. Isso manteria o código coeso do ponto de vista de cada módulo e, ao mesmo tempo, manteria os módulos isolados, evitando um acoplamento que levaria o código à famosa Grande Bola de Lama (Big Ball of Mud).
O ideal, do meu ponto de vista, seria o serviço de pedidos trabalhar com eventos de domínio que, a partir de uma projeção, geraria essa view, para que ela pudesse ser obtida com uma única consulta a partir de quem precisa desse dado.
Mas, como é um exemplo meramente ilustrativo, não há o menor problema em estar como está!
E, aqui, o benchmark dos dois codigos. Ambos utilizando a mesma base, com 100.000 pedidos e 100.000 clientes.

Diferença assombrosa. Não? Pois é!
Show me the Code
Como sempre o código desta demonstração está disponível no GitHub. Ela utiliza SQLite e, portanto, não exige o uso de Docker nem mesmo de uma instância de qualquer banco. É baixar, rodar, e modificar como quiser.
Conclusão
Apesar de ser um desafio de desempenho, é preciso sempre levar questões de design em consideração para que a aplicação seja modular e, com isso, mais fácil de evoluir. Ao mesmo tempo, conhecer mais profundamente ferramentas como o EF Core e o ZLinq te ajudam a considerar desempenho quando trabalhando com consultas.
Gostou? Me deixe saber pelos indicadores. Alguma dúvida ou comentário? Diga aqui na caixa de comentários ou me procure nas redes sociais, onde costumo responder mais rapidamente.
Muito obrigado por ler até aqui e até a próxima.
Comentários (0)
Deixe seu comentário