EF et récursivité
A travers ce post, je vais vous présenter un cas concret de mauvaise utilisation d’EF observée récemment chez un client.
Mon modèle est le suivant :

Nous voulons simplement récupérer un arbre avec une contrainte : si le type du noeud est "G" et qu’il n’a pas d’enfant, il ne doit pas apparaître dans l’arborescence.
Comment faire ceci.
Voici le code que j’ai pu observer :
public static List<Node> GetTree()
{
using (var context = new TreeEntities())
{
List<Node> nodes = context.Nodes.Where(n => n.Parent == null).ToList();
foreach (var n in nodes)
LoadChildren(context, n);
foreach (var n in nodes.Where(n => n.Type == "G" && n.Children.Count == 0).ToList())
nodes.Remove(n);
return nodes;
}
}
public static void LoadChildren(TreeEntities context, Node parentNode)
{
List<Node> nodes = context.Nodes.Where(n => n.ParentId == parentNode.Id).ToList();
foreach (var n in nodes)
LoadChildren(context, n);
foreach (var n in nodes.Where(n => n.Type == "G" && n.Children.Count == 0).ToList())
context.Nodes.Detach(n);
}
J’ai effectué un test avec le jeu d’entités suivant :
using (var context = new TreeEntities())
{
for (int i0 = 0; i0 < 10; i0++)
{
Node n0 = new Node { Name = i0.ToString(), Type = i0 == 9 ? null : "G" };
context.Nodes.AddObject(n0);
if (i0 > 7)
break;
for (int i1 = 0; i1 < 10; i1++)
{
Node n1 = new Node { Name = n0.Name + i1.ToString(), Type = i1 == 9 ? null : "G", Parent = n0 };
context.Nodes.AddObject(n1);
if (i1 > 7)
break;
for (int i2 = 0; i2 < 10; i2++)
{
Node n2 = new Node { Name = n1.Name + i2.ToString(), Type = i2 == 9 ? null : "G", Parent = n1 };
context.Nodes.AddObject(n2);
if (i2 > 7)
break;
for (int i3 = 0; i3 < 10; i3++)
{
Node n3 = new Node { Name = n2.Name + i3.ToString(), Type = i3 == 9 ? null : "G", Parent = n2 };
context.Nodes.AddObject(n3);
if (i3 > 7)
break;
for (int i4 = 0; i4 < 10; i4++)
context.Nodes.AddObject(new Node { Name = n3.Name + i4.ToString(), Parent = n3 });
}
}
}
}
context.SaveChanges();
}
Pour cette première version, ce code s’exécute en 5 minutes 41 secondes et génère 46 226 requêtes en base !
Il n’y a pas besoin d’être DBA pour savoir que si le nombre de requêtes dépend du nombre de datarows, le code ne supporte pas la charge.
Comment améliorer les choses ?
Un des gros avantages d’EF est le fait qu’il fait tout seul les relations. C’est d’ailleurs pour cela que ce code fonctionne. A aucun moment on a affecté les enfants / parents d’un des noeuds. EF l’a fait pour nous.
On pourrait être tenter par le code suivant :
public static List<Node> GetTree()
{
using (var context = new TreeEntities())
{
return context.Nodes.Where(n => n.Type != "G" || n.Children.Any()).AsEnumerable().Where(n => n.ParentId == null).ToList();
}
}
Petite remarque avant de continuer : attention à bien utiliser ParentId != null et pas Parent != null. En effet, au moment où on énumère sur les entités, rien ne nous indique que le parent a bien été chargé. Si ce n’est pas le cas, ParentId sera différent de null alors que Parent sera égal à null.
D’autre part, ce code ne fonctionne pas comme il devrait. En effet, si j’ai un noeud de type G avec un enfant de type G sans enfant, le premier noeud doit être supprimé puisque son seul enfant doit être supprimé. Or avec mon système à un seul niveau, ce ne sera pas le cas.
SQL n’est cependant pas réputé pour la récursivité.
Je vais donc tenter deux approches :
- une première dans laquelle je vais faire cette récursivité dans mon code. A noter que cela implique de charger trop d’entités (comme c’est le cas avec la première version).
- une deuxième dans laquelle c’est SQL Server qui va se charger de la récursivité
Ma première solution est la suivante :
public static IEnumerable<T> GetEntitiesInCache<T>(this ObjectContext context, EntityState entityState = EntityState.Unchanged | EntityState.Modified | EntityState.Added)
{
return context.ObjectStateManager.GetObjectStateEntries(entityState).Select(ose => ose.Entity).OfType<T>();
}
public static List<Node> GetTree(TreeEntities context)
{
using (var context = new TreeEntities())
{
foreach (var n in context.Nodes);
bool continueLoop;
do
{
continueLoop = false;
foreach (var n in context.GetEntitiesInCache<Node>().Where(n => n.Type == "G" && n.Children.Count == 0).ToList())
{
continueLoop = true;
context.Nodes.Detach(n);
}
} while (continueLoop);
return context.GetEntitiesInCache<Node>().Where(n => n.ParentId == null).ToList();
}
}
Dans ce cas, je suis passé de plus de 5 minutes 41 secondes à 1 seconde avec une seule requête exécutée en base.
A noter que je travaille avec une base locale. Dans le cas contraire l’écart de temps aurait été encore plus important.
Remarquez également le
foreach (var n in context.Nodes);
qui permet de charger l’ensemble des noeuds.
Pour la récursivité avec SQL server, cela fera l’objet d’un post futur.
Ce post vous a plu ? Ajoutez le dans vos favoris pour ne pas perdre de temps à le retrouver le jour où vous en aurez besoin :