Olá!
Este é um post da seção Coopera Sharp, 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.
Top comments (4)
Me tira uma dúvida, como não tenho XP com EF, quando vc escreve. ~
dbContext.Orders.Include(o => o.Customer).Select(order =>) é o mesmo que fazer: (select * from order join Customer), certo?
se for isso, eu entendo que se precisamos preencher apenas os atributos: OrderId, CreationDate e CustomerName, fico pensando que a query seria mais performática se pudessemos escrever na cláusula do select o que seria: (select o.Id, o.CreationDate, c.CustomerName from order as o join Customer) no EF isso é possível? outra duvida. como voce faz para extrair os dados do benchmark? (já ouvi falar mais não sei de onde vem rsrsr). Abraço!
Oi, @leondil_ribeiro!
No EF não é possível especificar as colunas da query a menos que você utilize a opção de executar a query manualmente via RawSQL. A função Select recebe um objeto (neste caso do tipo Order) e, a partir dele, é possível extrair os campos desejados.
O Benchmark é extraído via Benchmark.NET. Dá uma conferida no repositório deste post que o necessário para fazer funcionar está lá. É basicamente uma classe com os métodos que serão medidos, e uma chamada ao executor do benchmark passando o tipo dessa classe como parâmetro genérico.
Me diz depois se funcionou e, se sobrar qualquer dúvida, é só perguntar por aqui ou mesmo me mandar uma mensagem no LinkedIn (que vejo mais rapidamente).
Obrigado pelo comentário e um abraço!
entendi! vou baixar lá e verificar! sobre o comentário acima, ficou claro o comportamento. imaginei que a opção de se criar o SQL manualmente foge da proposta do EF.
Top demais!!!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.