Pontos de dados

Problemas e indicadores de uma classe Logging de base em modelos do EF

Julie Lerman

 

Julie LermanRecentemente, passei algum tempo com um cliente que estava tendo alguns problemas de desempenho ocasionais, mas severos, com seu código relacionado ao Entity Framework. Usando uma ferramenta para determinar o perfil das consultas geradas pelo Entity Framework, descobrimos uma consulta SQL de 5.800 linhas acessando o banco de dados. (Aprenda sobre as ferramentas de determinação de perfis em minha coluna Pontos de dados de dezembro de 2010, “Determinando o perfil das atividades de banco de dados no Entity Framework” em msdn.microsoft.com/magazine/gg490349.) Engasguei quando vi que o modelo EDMX continha uma hierarquia de herança que eu tinha ensinado aos amigos, queridos e desenvolvedores que evitassem. O modelo tinha uma entidade base única da qual toda outra entidade derivava. A entidade base era usada para garantir que cada entidade tivesse propriedades para acompanhar os dados de log como DateCreated e DateLastModified. Como esse modelo era criado usando Model First, a herança era interpretada pelo Entity Framework como um modelo tabela por tipo (TPT) no qual cada entidade era mapeada para sua própria tabela no banco de dados. Para os não iniciados, isso parece muito inocente.

Mas a herança TPT é famosa no Entity Framework por seu padrão genérico de criação de consultas que pode resultar em consultas SQL extensas e de mau desempenho. Você pode estar começando com um novo modelo em que você pode evitar TPT, ou pode já estar preso a um modelo existente e à herança TPT. Qualquer que seja, a coluna desse mês é dedicada a ajudá-lo a compreender os potenciais problemas de desempenho do TPT nesse cenário e a mostrar-lhe alguns truques que você pode aproveitar para contorná-los, se for muito tarde para modificar o modelo e o esquema do banco de dados.

O modelo assustador

A Figura 1 mostra um exemplo de um modelo que tenho visto com muita frequência. Observe a entidade chamada TheBaseType. Toda outra entidade deriva dela para herdar automaticamente uma propriedade DateCreated. Entendo por que é tentador projetar isso dessa maneira, mas as regras de esquema do Entity Framework também exigem que o tipo de base possua a propriedade principal de cada entidade derivada. Para mim, isso já é um sinal vermelho, não é um uso adequado de herança.

All Classes Inherit from TheBaseType
Figura 1 Todas as classes herdam de TheBaseType

Não é que o Entity Framework tenha criado especificamente um problema com esse design; é uma falha de design no próprio modelo. Nesse caso, a herança diz que Customer é um TheBaseType. E se alterássemos o nome da entidade de base para “LoggingInfo” e, então, repetíssemos a instrução “Customer is a LoggingInfo”? A falácia dessa instrução torna-se mais óbvia com o novo nome de classe Compare-a com Customer is a Person. Talvez agora eu o tenha convencido a evitar fazer isso em seus modelos. Mas se não, ou se já estiver preso com esse modelo, vamos continuar um pouco mais.

Por padrão, o fluxo de trabalho Model First define um esquema de banco de dados com relacionamentos de uma para um entre a tabela base e todas as tabelas que representam os tipos derivados. Essa é a hierarquia TPT mencionada anteriormente.

Se você tivesse de executar algumas consultas simples, talvez não notasse nenhum problema, especialmente se, como eu, você não for um DBA ou outra variedade de guru de banco de dados.

Por exemplo, essa consulta LINQ to Entities recupera a propriedade DateCreated de um cliente específico:

context.TheBaseTypes.OfType<Customer>()
  .Where(b => b.Id == 3)
  .Select(c => c.DateCreated)
  .FirstOrDefault();

A consulta resulta no seguinte TSQL executado no banco de dados:

    SELECT TOP (1)
    [Extent1].[DateCreated] AS [DateCreated]
    FROM  [dbo].[TheBaseTypes] AS [Extent1]
    INNER JOIN [dbo].[TheBaseTypes_Customer] 
    AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
    WHERE 3 = [Extent1].[Id]

É uma consulta perfeitamente boa.

Uma consulta para recuperar um entidade inteira é um pouco pior, por causa da necessidade de executar uma consulta aninhada. A consulta base recupera todos os campos que representam a junção entre a tabela TheBaseTypes e a tabela que contém o tipo derivado. Em seguida, uma consulta nesses resultados projeta os campos a serem retornados para o Entity Framework para preencher o tipo. Por exemplo, eis uma consulta para recuperar um único produto:

 

context.TheBaseTypes.OfType<Product>().FirstOrDefault();

A Figura 2 mostra o TSQL executado no servidor.

Figura 2 Listagem parcial de uma consulta TSQL aninhada ao especificar um tipo

    SELECT
    [Limit1].[Id] AS [Id],
    [Limit1].[C1] AS [C1],
    [Limit1].[DateCreated] AS [DateCreated],
    [Limit1].[ProductID] AS [ProductID],
    [Limit1].[Name] AS [Name],
    [...continued list of fields required for Product class...]
    FROM ( SELECT TOP (1)
          [Extent1].[Id] AS [Id],
          [Extent1].[DateCreated] AS [DateCreated],
          [Extent2].[ProductID] AS [ProductID],
          [Extent2].[Name] AS [Name],
          [Extent2].[ProductNumber] AS [ProductNumber],
          [...continued list of fields from Products table aka "Extent2" ...],
          [Extent2].[ProductPhoto_Id] AS [ProductPhoto_Id],
          '0X0X' AS [C1]
          FROM  [dbo].[TheBaseTypes] AS [Extent1]
          INNER JOIN [dbo].[TheBaseTypes_Product] 
          AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
    )  AS [Limit1]

Ainda não é uma consulta incorreta. Se você estivesse determinando o perfil se suas consultas até esse ponto, talvez não notasse qualquer problema causado pelo design do modelo.

Mas e essa próxima consulta “simples”, que quer localizar todos os objetos que foram criados hoje, independentemente do tipo? A consulta poderia retornar Customers, Products, Orders, Employees ou qualquer outro tipo em seu modelo que derive da base. Graças ao design do modelo, parece uma solicitação razoável, e o modelo mais LINQ to Entities torna mais fácil expressar (DateCreated é armazenado no banco de dados como um tipo de data, portanto, não preciso me preocupar em comparar com os campos DateTime das minhas consultas de exemplo):

var today= DateTime.Now.Date;
context.TheBaseTypes
  .Where(b => b.DateCreated == today)  .ToList();

Expressar essa consulta em LINQ to Entities é curto e direto. Mas não se engane. É uma solicitação pesada. Você está pedindo ao EF e seu banco de dados que retorne instâncias de qualquer tipo (seja Customer, Product ou Employee) criadas hoje. O Entity Framework deve começar consultando cada tabela mapeada para as entidades derivadas e unindo cada uma à tabela TheBaseTypes simples relacionada com o campo DateCreated. Em meu ambiente, isso cria uma consulta de 3.200 linhas (quando a consulta é corretamente formatada por EFProfiler), o que pode fazer com que o Entity Framework demore algum tempo para criar e o banco de dados algum tempo para executar.

De qualquer forma, em minha experiência, uma consulta como essa pertence a uma ferramenta de análise comercial. Mas e se você tiver o modelo e quiser obter essas informações de dentro de seu aplicativo, talvez para uma área de relatório administrativo de um aplicativo que você está criando? Já vi desenvolvedores tentando fazer esse tipo de consulta em seus aplicativos, e ainda digo que é preciso pensar fora do Entity Framework. Crie a lógica no banco de dados com uma visualização ou procedimento armazenado e chame-a do Entity Framework, em vez de pedir ao EF que crie a consulta por você. Mesmo como um procedimento de banco de dados, essa lógica específica não é simples de criar. Mas há benefícios. Primeiro, você tem uma chance maior de criar uma consulta com melhor desempenho. Segundo, o EF não precisará gastar tempo para descobrir a consulta. Terceiro, seu aplicativo não terá de enviar uma consulta de 3.300 linhas, ou mais, pelo pipe. Mas esteja avisado de que quanto mais você pesquisa o problema e tenta solucioná-lo de dentro do banco de dados ou usando EF e lógica de codificação .NET, mais claro fica que o problema não é tanto o Entity Framework quanto o design de modelo geral que está no seu caminho.

Se puder evitar consultar do tipo de base e dos tipos específicos de consulta, suas consultas serão muito mais simples. Aqui está um exemplo que expressa a consulta anterior para que se concentre em um tipo específico:

context.TheBaseTypes.TypeOf<Product>()
  .Where(b => b.DateCreated == today)
  .ToList();

Como o EF não teve de ser preparado para cada tipo no modelo, o TSQL resultante é uma consulta de 25 linhas simples. Com a API DbContext, você nem mesmo tem de usar TypeOf para consultar os tipos derivados. É possível criar propriedades DbSet para os tipos derivados. Assim, poderia consultar ainda de uma forma mais simples:

context.Products
  .Where(b => b.DateCreated == today)
  .ToList();

De fato, para esse modelo eu removeria completamente o TheBaseTypes DbSet de minha classe de contexto e impediria que alguém expressasse as consultas diretamente desse tipo de base.

Registrando em log sem o modelo assustador

Até agora me concentrei em um cenário de hierarquia que fortemente aconselho aos desenvolvedores que evitem ao criar modelos com o Entity framework: usar uma entidade mapeada como base da qual toda entidade simples no modelo também derive. Algumas vezes me deparo com cenários em que é tarde demais para alterar o modelo. Mas outras vezes, sou capaz de ajudar meus clientes a evitar esse caminho completamente (ou eles estão tão no início do desenvolvimento que podem alterar o modelo).

Então, como atingir da melhor forma esse objetivo, que é fornecer dados comumente controlados, como dados de log, para cada tipo em seu modelo?

Com frequência, o primeiro pensamento é manter a herança, mas alterar o tipo de hierarquia. Com Model First, TPT é o padrão, mas você pode alterá-lo para tabela por hierarquia (TPH) usando o Entity Designer Generation Power Pack (disponível na Galeria do Visual Studio via Gerenciador de Extensões). O padrão de Code First é TPH quando você define herança em suas classes. Mas rapidamente você verá que essa não é mesmo uma solução. Por que? TPH significa que a hierarquia inteira está contida em uma única tabela. Em outras palavras, seu banco de dados consistiria em apenas uma tabela. Espero que nenhuma outra explicação seja necessária para convencê-lo de que esse não é um bom caminho.

Como disse anteriormente, quando perguntei se um Customer é realmente um tipo de LoggingInfo, o cenário específico em que me concentrei, para resolver o problema de controlar dados comuns, pede que você apenas evite herança completamente para esse objetivo. Recomendaria considerar uma interface ou, em vez disso, tipos complexos, que incorporarão os campos em cada tabela. Se já estiver preso com o banco de dados que criou uma tabela separada, um relacionamento bastará.

Para demonstrar, alternarei para um modelo baseado em classes usando Code First em vez de um EDMX (embora possa alcançar os mesmos padrões usando um modelo EDMX e o designer).

No primeiro caso usarei uma interface:

public interface ITheBaseType
{
  DateTime DateCreated { get; set; }
}

Cada classe implementará a interface. Ela terá sua própria propriedade principal e conterá uma propriedade DateCreated. Por exemplo, aqui está a classe Product:

public class Product : ITheBaseType
{
  public int ProductID { get; set; }
  // ...other properties...
  public DateTime DateCreated { get; set; }
}

No banco de dados, cada tabela tem sua própria propriedade DateCreated. Portanto, repetir a consulta anterior em relação a Products cria uma consulta direta:

context.Products
.Where(b => b.DateCreated == today)
.ToList();

Como todos os campos estão contidos nessa tabela, não preciso mais de uma consulta aninhada:

    SELECT TOP (1) [Extent1].[Id]                     AS [Id],
                   [Extent1].[ProductID]              AS [ProductID],
                   [Extent1].[Name]                   AS [Name],
                   [Extent1].[ProductNumber]          AS [ProductNumber],
                   ...more fields from Products table...
                   [Extent1].[ProductPhoto_Id]        AS [ProductPhoto_Id],
                   [Extent1].[DateCreated]            AS [DateCreated]
    FROM   [dbo].[Products] AS [Extent1]
    WHERE  [Extent1].[DateCreated] = '2012-05-25T00:00:00.00'

Se preferir definir um tipo complexo e reutilizá-lo em cada uma das classes, seus tipos poderão ter a seguinte aparência:

public class Logging
{
  public DateTime DateCreated { get; set; }
}
public class Product{
  public int ProductID { get; set; }
  // ...other properties...
  public Logging Logging { get; set; } 
}

Observe que a classe Logging não tem um campo principal (como Id ou LoggingId). As convenções de Code First presumirão que esse é um tipo complexo e o tratarão como quando usado para definir as propriedades em outras classes, como fiz com Product.

A tabela Products do banco de dados tem uma coluna gerada por Code First chamada Logging_DateCreated, e a propriedade Product.Logging.DateCreated é mapeada para essa coluna. Adicionar a propriedade Logging à classe Customer teria o mesmo efeito. A tabela Customers também terá sua própria propriedade Logging_DateCreated, que é mapeada de volta para Customer.Logging.DateCreated.

No código, você precisará navegar pela propriedade Logging para referenciar esse campo DateCreated. Aqui está a mesma consulta de antes, reescrita para funcionar com os novos tipos:

context.Products.Where(b => b.Logging.DateCreated == DateTime.Now).ToList();

O SQL resultante é o mesmo do exemplo da interface, exceto o nome do campo agora é Logging_DateCreated em vez de DateCreated. É uma consulta curta que apenas consulta a tabela Products.

Um dos benefícios de se herdar da classe do modelo original é que é fácil codificar lógica para preencher automaticamente os campos da classe base, durante SaveChanges, por exemplo. Mas é possível criar lógica com a mesma facilidade para o tipo complexo ou para a interface, assim não vejo nenhuma desvantagem com esses novos padrões. A Figura 3 mostra um exemplo simples de definição da propriedade DateCreated para novas entidades durante SaveChanges (você pode saber mais sobre essa técnica nas edições Second e DbContext da minha série de livros “Programming Entity Framework”).

Figura 3 Definindo a propriedade DateCreated da interface durante SaveChanges

public override int SaveChanges()
{
  foreach (var entity in this.ChangeTracker.Entries()
    .Where(e =>
    e.State == EntityState.Added))
  {
    ApplyLoggingData(entity);
  }
  return base.SaveChanges();
}
private static void ApplyLoggingData(DbEntityEntry entityEntry)
{
  var logger = entityEntry.Entity as ITheBaseType;
  if (logger == null) return;
  logger.DateCreated = System.DateTime.Now;
}

Algumas alterações no EF 5

O Entity Framework 5 traz algumas melhorias às consultas que são geradas de hierarquias TPT que ajudam, mas não aliviam, os problemas que demonstrei anteriormente. Por exemplo, executar novamente minha consulta que inicialmente resultou em 3.300 linhas de SQL em um computador com o Microsoft .NET Framework 4.5 instalado (sem nenhuma outra alteração na solução) gera uma consulta reduzida a 2.100 linhas de SQL. Uma das grandes diferenças é que o EF 5 não conta com UNIONs para criar a consulta. Não sou um DBA, mas meu entendimento é que tal melhoria não impactaria o desempenho da consulta no banco de dados. Você pode ler mais sobre essa alteração nas consultas TPT em minha postagem do blog, “Entity Framework June 2011 CTP: TPT Inheritance Query Improvements” em bit.ly/MDSQuB.

Nem toda herança é ruim

Ter um único tipo de base para todas as entidades do seu modelo é um exemplo extremo de modelagem e herança que deram errado. Há muitos bons casos para se ter uma hierarquia de herança em seu modelo, por exemplo, quando você quer descrever que um Customer é uma Person. Também é importante a lição que LINQ to Entities é apenas uma ferramenta que está disponível para você. No cenário que meu cliente me mostrou, um desenvolvedor de banco de dados inteligente reconstruiu a consulta em relação aos campos do tipo de base como um procedimento armazenado de banco de dados, que reduziu uma atividade de vários segundos a uma que levou apenas 9 milissegundos. E todos nós nos alegramos. No entanto, ainda esperamos que eles possam reprojetar o modelo e ajustar o banco de dados para a próxima versão do software. Enquanto isso, eles podem deixar que o Entity Framework continue a gerar as consultas que não são problemáticas e use as ferramentas que deixei para ajustar o aplicativo e o banco de dados para que tenham algumas melhorias fantásticas de desempenho.

Julie Lerman é uma Microsoft MVP, mentora e consultora do .NET, que reside nas colinas de Vermont. Você pode encontrá-la fazendo apresentações sobre acesso a dados e outros tópicos do Microsoft .NET em grupos de usuários e conferências em todo o mundo. Seu blog está em thedatafarm.com/blog e ela é autora do livro “Programming Entity Framework” (2010), além das edições Code First (2011) e DbContext (2012), todos da O’Reilly Media. Siga-a no Twitter, em twitter.com/julielerman.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Diego Vega