.Net

Entity Framework Core : optimisation sur la récupération d’entités liées

Août 13, 2020

Nicolas Bailly

Dans cet article nous allons voir que dans le cas de la récupération d’une grappe d’objets, Entity Framework cache le manque d’optimisation. Sans erreur visible, il n’y a qu’une forte volumétrie qui nous fera apparaître des latences.
Nous allons voir un cas concret et comment l’optimiser.

Cas Concret

Prenons l’exemple d’un site qui aurait une liste d’utilisateurs qui s’y connectent. Chaque utilisateur a une liste de commandes et une liste de favoris.

Nous avons donc une table User qui représente les utilisateurs :

Nous avons une classe User qui représente cet objet :

public class User
{
    [Key]
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public List<Order> Orders { get; set; }
    public List<Favorite> Favorites { get; set; }
}

Nous avons une table Order qui représente les commandes de ces utilisateurs :

Cet objet est représenté par la classe Order :

public class Order
{
    [Key]
    public int Id { get; set; }

    [Required] 
    public User User { get; set; }

    public int PaymentStatusId { get; set; }

    public string BillingAddress { get; set; }
    public int UserId { get; set; }
}

Et enfin, nous avons une table Favorite qui représente les produits favoris des utilisateurs :

Cet objet est représenté par la classe suivante :

public class Favorite
{
    [Key]
    public int Id { get; set; }

    public string ProductName { get; set; }
    public User User { get; set; }
    public int UserId { get; set; }
}

Maintenant nous allons récupérer les données d’un utilisateur. Pour cela, nous serions tenté de faire comme ceci :

int id = 1;
using ExampleContext dbContext = new ExampleContext(options);
var user = dbContext.Users.Where(u=>u.Id == id).Include(u=>u.Orders).Include(u=>u.Favorites).AsNoTracking().FirstOrDefault();

Ici, nous récupérons les données de l’utilisateur ayant l’id=1 ainsi que les objets qui lui sont liés, c’est-à-dire ses favoris et ses commandes.
Nous pouvons voir que le résultat est bon, que nous récupérons bien 4 commandes et 2 favoris. C’est ce que nous avons en base de données comme vu plus haut :

Regardons maintenant les logs d’Entity Framework pour voir comment les données ont été récupérées :

Examinons cette requête :
– Une seule requête SQL est générée ce qui est normal puisque nous n’avons fait qu’une seule requête linq.
– Pour chaque include dans notre code C#, une jointure externe est faite. Pourquoi une jointure externe et pas interne? car avec une jointure interne, les utilisateurs qui n’ont pas de commande ou qui n’ont pas de favoris ne remonteraient pas.

Essayons de lancer cette requête directement en base de données :

On voit que 8 lignes sont retournées alors que nous n’avons que 4 commandes, 2 favoris et 1 utilisateur. Lancées séparément, les requêtes nous auraient retournées 5 lignes. C’est ce qu’on appelle un produit cartésien : 4 commandes x 2 favoris = 8 lignes retournées.
Ce qu’il se passe, c’est qu’on demande à récupérer des choux et des carottes dans une seule requêtes (des favoris et des commandes).
Entity Framework sait le gérer puisqu’on voit que nos objets contiennent les bonnes données. Il va regrouper en mémoire tous les objets pour les avoir de manière cohérente mais on va se retrouver avec des problèmes de performances qui seront de plus en plus visibles avec une volumétrie de données qui augmentera car les données seront toute chargées en mémoire.

Comment faire?

Pour éviter ce phénomène, il faut éviter d’imbriquer les « include » à la suite s’ils n’ont pas de lien entre eux (les favoris n’ont aucun lien avec les commandes). Attention, je parle uniquement de ce genre de cas. Dans d’autres cas, il peut être possible d’enchaîner les « include ». C’est pourquoi, il faut bien comprendre ce qu’Entity Framework fait.
On va donc récupérer les commandes de l’utilisateur dans un premier temps puis les favoris ensuite :

int id = 1;
using ExampleContext dbContext = new ExampleContext(options);
//On récupère d'abord le User et ses Orders
var user = dbContext.Users.Where(u => u.Id == id).Include(u => u.Orders).AsQueryable().AsNoTracking().FirstOrDefault();
//Puis on récupére ses Favorites
user.Favorites = dbContext.Favorites.Where(f => f.UserId == id).AsNoTracking().ToList();

De cette manière, nos objets contiennent exactement la même chose mais ils ont été construit avec moins de données provenant de la base. Pour s’en assurer, regardons les logs Entity Framework :

On voit bien que 2 requêtes ont été exécutées. Essayons de les jouer directement sur la base de données :

On voit que la première requête renvoie 4 lignes et la seconde 2 lignes. Nous tenons là notre optimisation.
La volumétrie de cette exemple ne rend pas compte des problèmes de performance que ça pourrait générer mais si l’utilisateur avait 100 commandes et 100 favoris, la base de données ne renverra que 200 lignes alors qu’avec le produit cartésien ça en renverrai 10000, c’est non négligeable.

Conclusion

Dans cet article, nous avons vu plus qu’une optimisation, c’est une bonne pratique d’utilisation d’Entity Framework. Comme pour tout ORM, il faut bien comprendre les requêtes qui sont générées pour optimiser nos applications.

0 commentaires

Soumettre un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Découvrez nos autres articles

Aller au contenu principal