Aller au contenu

Tracking des entités

Introduction

Lorsque Entity Framework Core (EF Core) exécute une requête Linq To Entities et instancie les entités, les objets obtenus sont par défaut suivis au sein du contexte de données (DbContext). Ce mécanisme, appelé tracking, permet à EF Core de détecter les modifications apportées aux objets, afin de générer les requêtes SQL lors de l'appel aux méthodes SaveChanges et SaveChangesAsync.

Après avoir chargé les données, cette fonctionnalité permet d'ajouter, modifier et supprimer des données, puis de persister ces opérations dans la base de données. Voici un exemple dans lequel le nom de l'utilisateur est modifié :

var customer = await context.Customers
    .FirstAsync(customer => customer.Id == 10);

customer.Name = "James Ravaille";

await context.SaveChangesAsync();

Dans cet exemple, aucune instruction n'est nécessaire pour indiquer à EF Core que la propriété Name a été modifiée. Le contexte de données suit l'entité customer et peut détecter cette modification via une gestion d'états.

Cependant, ce mécanisme a un coût. EF Core doit conserver des informations concernant les entités suivies et leur état. Lorsque les données sont uniquement destinées à être consultées, cette fonctionnalité n'est pas nécessaire et peut être temporairement désactivée. Dans cet article, nous verrons donc comment fonctionne le tracking des entités et comment choisir le comportement approprié afin, d'améliorer les performances des requêtes Linq To Entities.

Le tracking des entités

Lorsqu'une requête Linq To Entities retourne des entités, par défaut, EF Core suit les modifications effectuées sur ces dernières, au travers d'un service nommé ChangeTracker s'exécutant au sein du contexte de données (DbContext).

Par exemple, prenons cette requête Linq To Entities :

var customers = await context.Customers
    .Where(customer => customer.IsActive)
    .ToListAsync();

Les instances de l'entité Customer retournées sont suivies par le contexte de données. Nous pouvons le vérifier en interrogeant le ChangeTracker :

var customers = await context.Customers
    .Where(customer => customer.IsActive)
    .ToListAsync();

var entries = context.ChangeTracker
    .Entries<Customer>()
    .ToArray();

La collection entries contient les entités Customer issues de la requête Linq To Entities. Le ChangeTracker permet à EF Core de connaître les entités et leur état, suivies à un instant donné, par le contexte de données.

Les états d'une entité

Chaque entité suivie possède un état représenté par l'énumération EntityState. Une entité peut avoir l'un des états suivants :

  • Unchanged : l'entité existe en base de données et aucune modification n'a été détectée.

  • Modified : au moins une propriété de l'entité a été modifiée.

  • Added : l'entité doit être ajoutée à la base de données.

  • Deleted : l'entité doit être supprimée de la base de données.

  • Detached : l'entité n'est pas suivie par le contexte de données.

Après l'exécution d'une requête Linq to Entities, les entités obtenues ont un état Unchanged. Le Change Tracker permet de connaître les entités présentes dans le contexte de données :

var customer = await context.Customers
    .FirstAsync(customer => customer.Id == 10);

var state = context.Entry(customer).State;

La variable state contient la valeur Unchanged. Si nous modifions la valeur d'une propriété :

customer.Name = "James Ravaille";

context.ChangeTracker.DetectChanges();

var state = context.Entry(customer).State;

Console.WriteLine(state);

Ainsi, la variable state contient la valeur Modified. EF Core dispose des informations nécessaires pour générer la requête SQL UPDATE appropriée lors de l'appel à la méthode SaveChangesAsync.

L'appel à la méthode DetectChanges demande à EF Core de rechercher les modifications apportées aux entités suivies et à mettre à jour leur état. Dans une utilisation classique d'EF Core, il n'est pas nécessaire de l'utiliser, car cette détection est notamment réalisée automatiquement lors de l'appel aux méthodes SaveChanges et SaveChangesAsync sur le contexte de données.

Quel est le coût du tracking sur les performances ?

Le tracking apporte donc un service important, mais il a un impact sur les performances. Pour chaque entité suivie, EF Core doit maintenir différentes informations permettant d'identifier l'entité, de connaître son état, de détecter certaines modifications, de gérer les relations entre les entités et de garantir qu'une même entité n'est représentée que par une seule instance au sein du contexte de données.

Et plus le nombre d'entités suivies augmente, plus la mémoire utilisée par le contexte de données et le coût lié à la gestion de ces entités augmentent. Implémentons une requête Linq to Entities retournant plusieurs milliers de produits :

var products = await context.Products
    .ToArrayAsync();

Si ces produits sont uniquement utilisés pour afficher des données dans une vue ou fournir des données via un service Web, leur suivi n'apporte aucun avantage. Le coût du suivi n'est donc pas compensé. Nous pouvons alors demander au contexte de données de désactiver le tracking des entités.

Désactiver le tracking avec la méthode AsNoTracking

Pour les requêtes pour lesquelles le tracking n'est pas nécessaire, EF Core propose la méthode AsNoTracking qu'on applique au jeu d'entités :

var customers = await context.Customers
    .AsNoTracking()
    .Where(customer => customer.IsActive)
    .ToArrayAsync();

Les instances de Customer obtenues ne sont pas suivies par le ChangeTracker. Nous pouvons le vérifier en l'interrogeant :

var customers = await context.Customers
    .AsNoTracking()
    .Where(customer => customer.IsActive)
    .ToArrayAsync();

var entries = context.ChangeTracker
    .Entries<Customer>()
    .ToArray();

Si aucune autre instance de Customer n'était précédemment suivie par le contexte, le tableau entries est vide. La méthode AsNoTracking() est donc particulièrement adaptée aux scénarios dans lesquels les données sont uniquement lues pour être consultées ou exposées.

Un exemple dans une API ASP.NET Core

Implémentons une méthode permettant d'obtenir la liste des produits disponibles :

public async Task<Product[]> GetAvailableProductsAsync()
{
    return await context.Products
        .AsNoTracking()
        .Where(product => product.IsAvailable)
        .ToArrayAsync();
}

Les produits sont destinés à être retournés au client et ne seront pas modifiés au sein du contexte de données. Le tracking est inutile. Pour optimiser les performances, nous pouvons même combiner cette optimisation avec une projection, abordée dans l'article concernant le chargement des données :

public async Task<List<ProductDto>> GetAvailableProductsAsync()
{
    return await context.Products
        .Where(product => product.IsAvailable)
        .Select(product => new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price
        })
        .ToListAsync();
}

Dans cet exemple, l'appel à la méthode AsNoTracking n'est pas nécessaire. La projection ne crée aucune instance de l'entité Product, mais uniquement des instances de la classe ProductDto.

Quand conserver le tracking ?

La méthode AsNoTracking ne doit être appliquée de manière systématique à toutes les requêtes Linq To Entities, car lorsque nous souhaitons charger une entité afin de la modifier, nous avons besoin qu'elle soit suivie, comme dans l'exemple présenté ci-dessus, lorsque nous avons modifié le nom de l'utilisateur. Une règle simple s'applique lorsque nous écrivons une requête Linq to Entities :

Si une entité est chargée afin d'être modifiée, le tracking est approprié. Si elle est uniquement consultée, il peut être désactivé en utilisant la méthode AsNoTracking afin d'éviter le coût lié au suivi de l'entité.

Configurer NoTracking par défaut

Dans une application affichant principalement des données ou exposant des informations via des services Web, nous pouvons choisir de désactiver le suivi de manière centralisée :

optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

Les requêtes sont alors exécutées sans tracking par défaut :

var customers = await context.Customers
    .ToListAsync();

Lorsque le tracking devient nécessaire pour une requête Linq To Entities particulière, nous pouvons l'activer :

var customer = await context.Customers
    .AsTracking()
    .FirstAsync(customer => customer.Id == 10);

Tracking et résolution d'identité

Le tracking possède une autre caractéristique importante pour garantir la cohérence du graphe d'objets : la résolution d'identité. Lorsqu'EF Core rencontre plusieurs fois la même entité au cours d'une requête suivie, il retourne la même instance de cette entité.

Pour illustrer cette fonctionnalité, prenons l'exemple de plusieurs commandes appartenant au même client. Avec une requête utilisant le tracking, les différentes références vers ce client peuvent pointer vers une seule instance de l'entité Customer. Avec la méthode AsNoTracking, les entités ne sont pas suivies par le contexte de données. Lors de l'exécution d'une requête Linq to Entities, une même ligne d'une table de la base de données peut alors produire plusieurs instances représentant la même entité.

Pour ce besoin, EF Core propose un troisième mode avec l'utilisation de la méthode AsNoTrackingWithIdentityResolution, qui associe deux comportements :

  • Les entités retournées ne sont pas suivies par le contexte de données.

  • Une résolution d'identité est néanmoins effectuée pendant la création des entités.

var orders = await context.Orders
    .AsNoTrackingWithIdentityResolution()
    .Include(order => order.Customer)
    .ToListAsync();

Pour ce faire, EF Core utilise un mécanisme de suivi indépendant du ChangeTracker. Une fois les instances des entités obtenues, ce mécanisme est désactivé. Cette solution est particulièrement intéressante lorsqu'une requête Linq To Entities obtenant des instances pour lire des données, retourne un graphe d'objets contenant plusieurs occurrences des mêmes entités.

Toutefois, attention : son utilisation a aussi un coût. Si la résolution d'identité n'apporte rien à la requête, l'utilisation de la méthode AsNoTracking est plus appropriée.

Projection et tracking

Il existe un point qui peut facilement prêter à confusion : réaliser une projection de données avec Select utilisant un type anonyme ou un DTO ne signifie pas nécessairement qu'EF Core ne suit plus aucune entité. Voici un exemple :

var customers = await context.Customers
    .Select(customer => new
    {
        Customer = customer,
        OrderCount = customer.Orders.Count
    })
    .ToListAsync();

Le résultat n'est pas une collection de Customer, mais la projection contient toujours une instance de l'entité Customer. Avec le comportement de tracking par défaut, cette entité est suivie par le contexte de données.

Cependant, dans cet autre exemple :

var customers = await context.Customers
    .Select(customer => new CustomerDto
    {
        Id = customer.Id,
        Name = customer.Name
    })
    .ToListAsync();

Aucune instance de l'entité Customer n'est obtenue du fait de la projection réalisée avec le DTO. Il n'y a donc pas d'entité Customer suivie dans le contexte de données. Il est donc important de distinguer la projection et le tracking : une projection peut améliorer les performances en limitant les données récupérées, tandis que le tracking concerne le suivi des instances d'entités obtenues.

Attention à la durée de vie du contexte de données

Le coût du suivi des entités dépend également de la durée de vie du contexte de données. Au fur et à mesure que des requêtes Linq to Entities demandant le suivi des entités sont exécutées, le contexte de données peut accumuler des entités via son ChangeTracker.

Dans une application ASP.NET Core, le contexte de données est généralement utilisé avec une durée de vie scoped, correspondant à l'exécution d'une requête HTTP. Cette durée de vie relativement courte limite l'accumulation des entités suivies. Dans les traitements plus longs, les imports ou les traitements par lots, il faut être davantage attentif au nombre d'entités conservées par le contexte de données. Lorsque plusieurs milliers d'entités sont chargées et suivies pendant une longue période, la consommation mémoire et le coût de la détection des modifications peuvent devenir significatifs.