MVC Scaffolding, Eager loading et ViewModel
2011-11-17 #ef#mvc
Pour tenter de mettre au point une méthode simple(iste) pour construire une application avec ASP.NET MVC, j'essaie de développer une petite application destinées à générer une brochure de voyages . Pour cela, je dois gérer une base de données avec une table "Voyages", une table "Tarifs" pour enregistrer les chaque tarif du voyage et une table "Parties" qui sert à décrire les différentes étapes du voyage.
Eager loading pour les actions Index
Pour l'instant, j'ai plus ou moins laissé MVC Scaffolding écrire mon code pour la classe Voyage associée à la table Voyages. Par défaut, il a généré le code C# suivant pour l'action Index du contrôleur VoyagesController afin retrouver tous les voyages à afficher :
var voyages = voyageRepository
.AllIncluding(voyage => voyage.Parties, voyage => voyage.Tarifs)
.OrderBy(voyage => voyage.Position)
.ThenBy(voyage => voyage.Title);
Ce qui correspond à quelque chose dans ce genre si on ne passe pas par un Repository :
var voyages = context
.Voyages
.Include(voyage => voyage.Parties)
.Include(voyage => voyage.Tarifs)
.OrderBy(voyage => voyage.Position)
.ThenBy(voyage => voyage.Title);
Ces deux codes C# ont tous les deux pour effet de générer une (grosse) requête SQL qui charge à la fois le contenu de la table Voyages et par des jointures celui des tables Parties et Tarifs :
SELECT
[UnionAll1].[VoyageID] AS [C1],
[UnionAll1].[VoyageID1] AS [C2],
[UnionAll1].[Position1] AS [C3],
[UnionAll1].[Title1] AS [C4],
[UnionAll1].[VoyageType] AS [C5],
[UnionAll1].[Notes] AS [C6],
[UnionAll1].[C1] AS [C7],
[UnionAll1].[PartieID] AS [C8],
[UnionAll1].[VoyageID2] AS [C9],
[UnionAll1].[Position2] AS [C10],
[UnionAll1].[PartieType] AS [C11],
[UnionAll1].[Content] AS [C12],
[UnionAll1].[C2] AS [C13],
[UnionAll1].[C3] AS [C14],
[UnionAll1].[C4] AS [C15],
[UnionAll1].[C5] AS [C16],
[UnionAll1].[C6] AS [C17],
[UnionAll1].[C7] AS [C18],
[UnionAll1].[C8] AS [C19],
[UnionAll1].[C9] AS [C20],
[UnionAll1].[C10] AS [C21],
[UnionAll1].[C11] AS [C22]
FROM (SELECT
CASE WHEN ([Extent2].[PartieID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1],
[Extent1].[Position] AS [Position],
[Extent1].[Title] AS [Title],
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[VoyageID] AS [VoyageID1],
[Extent1].[Position] AS [Position1],
[Extent1].[Title] AS [Title1],
[Extent1].[VoyageType] AS [VoyageType],
[Extent1].[Notes] AS [Notes],
[Extent2].[PartieID] AS [PartieID],
[Extent2].[VoyageID] AS [VoyageID2],
[Extent2].[Position] AS [Position2],
[Extent2].[PartieType] AS [PartieType],
[Extent2].[Content] AS [Content],
CAST(NULL AS int) AS [C2],
CAST(NULL AS int) AS [C3],
CAST(NULL AS nvarchar(1)) AS [C4],
CAST(NULL AS nvarchar(1)) AS [C5],
CAST(NULL AS real) AS [C6],
CAST(NULL AS real) AS [C7],
CAST(NULL AS real) AS [C8],
CAST(NULL AS real) AS [C9],
CAST(NULL AS real) AS [C10],
CAST(NULL AS nvarchar(1)) AS [C11]
FROM [Voyages] AS [Extent1]
LEFT OUTER JOIN [Parties] AS [Extent2] ON [Extent1].[VoyageID] = [Extent2].[VoyageID]
UNION ALL
SELECT
2 AS [C1],
[Extent3].[Position] AS [Position],
[Extent3].[Title] AS [Title],
[Extent3].[VoyageID] AS [VoyageID],
[Extent3].[VoyageID] AS [VoyageID1],
[Extent3].[Position] AS [Position1],
[Extent3].[Title] AS [Title1],
[Extent3].[VoyageType] AS [VoyageType],
[Extent3].[Notes] AS [Notes],
CAST(NULL AS int) AS [C2],
CAST(NULL AS int) AS [C3],
CAST(NULL AS int) AS [C4],
CAST(NULL AS int) AS [C5],
CAST(NULL AS nvarchar(1)) AS [C6],
[Extent4].[TarifID] AS [TarifID],
[Extent4].[VoyageID] AS [VoyageID2],
[Extent4].[Title] AS [Title2],
[Extent4].[Year] AS [Year],
[Extent4].[Prix1] AS [Prix1],
[Extent4].[Prix2] AS [Prix2],
[Extent4].[Prix3] AS [Prix3],
[Extent4].[Prix4] AS [Prix4],
[Extent4].[Prix5] AS [Prix5],
[Extent4].[Notes] AS [Notes1]
FROM [Voyages] AS [Extent3]
INNER JOIN [Tarifs] AS [Extent4] ON [Extent3].[VoyageID] = [Extent4].[VoyageID]) AS [UnionAll1]
ORDER BY [UnionAll1].[Position] ASC, [UnionAll1].[Title] ASC, [UnionAll1].[VoyageID1] ASC, [UnionAll1].[C1] ASC
Puis dans la vue Index.cshtml, on utilise le
IEnumerable<Voyage>
obtenu pour afficher une liste des
voyages, avec pour chacun d'entre eux le nombre de parties qui composent sa
description détaillée et le nombre de tarifs définis pour ce voyage.
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink("Edit", "Edit", new { id = item.VoyageID })
</td>
<td>
@item.Position
</td>
<td>
@Html.ActionLink(@item.Title, "Details", new { id = item.VoyageID })
</td>
<td>
@Html.DisplayTextFor(_ => item.TypeVoyage).ToString()
</td>
<td>
@(item.Parties == null ? "" : item.Parties.Count.ToString())
</td>
<td>
@(item.Tarifs == null ? "" : item.Tarifs.Count.ToString())
</td>
<td>
@Html.ActionLink("Delete", "Delete", new { id = item.VoyageID })
</td>
</tr>
}
Ce qui est intéressant, c'est que MVC Scaffolding fait automatiquement du
"Eager Loading" pour que les instructions
item.Parties.Count.ToString()
et
item.Tarifs.Count.ToString()
en boucle ne provoquent pas autant
d'accès à la base de données qu'il y a de voyage à afficher.
Lazy loading et SELECT N+1
En effet, si par défaut, MVC Scaffolding n'avait pas fait le lien avec les parties et les tarifs et qu'il se soit contenté de générer le code C# suivant :
var voyages = voyageRepository
.All
.OrderBy(voyage => voyage.Position)
.ThenBy(voyage => voyage.Title);
Ou sans le Repository :
var voyages = context
.Voyages
.OrderBy(voyage => voyage.Position)
.ThenBy(voyage => voyage.Title);
Alors la consultation de la page Index n'aurait pas généré 1 seule requête, mais 71 !!!
Soit 1 première requête toute simple pour charger la liste des 35 voyages :
SELECT
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[Position] AS [Position],
[Extent1].[Title] AS [Title],
[Extent1].[VoyageType] AS [VoyageType],
[Extent1].[Notes] AS [Notes]
FROM [Voyages] AS [Extent1]
ORDER BY [Extent1].[Position] ASC, [Extent1].[Title] ASC
Puis 35 requêtes pour charger successivement les parties de chacun des 35 voyages :
SELECT
[Extent1].[PartieID] AS [PartieID],
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[Position] AS [Position],
[Extent1].[PartieType] AS [PartieType],
[Extent1].[Content] AS [Content]
FROM [Parties] AS [Extent1]
WHERE [Extent1].[VoyageID] = @EntityKeyValue1
Et encore 35 requêtes pour charger successivement les tarifs de chacun des 35 voyages :
SELECT
[Extent1].[TarifID] AS [TarifID],
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[Title] AS [Title],
[Extent1].[Year] AS [Year],
[Extent1].[Prix1] AS [Prix1],
[Extent1].[Prix2] AS [Prix2],
[Extent1].[Prix3] AS [Prix3],
[Extent1].[Prix4] AS [Prix4],
[Extent1].[Prix5] AS [Prix5],
[Extent1].[Notes] AS [Notes]
FROM [Tarifs] AS [Extent1]
WHERE [Extent1].[VoyageID] = @EntityKeyValue1
Par rapport à l'eager loading, ici on a fait du "lazy loading" : les entités enfants ne sont chargées que lorsque on y accède. Des fois c'est bien. Des fois c'est mal. Mais heureusement, MVC Scaffolding qui a pensé à tout est est assez malin pour nous éviter l'écueil du SELECT N+1 quand on génère l'action Index (merci Scott).
Lazy loading et virtual
Pour être complet, c'est parce que les propriétés Parties et Tarifs de la classe Voyage sont déclarées en tant que propriétés virtuelles, que Entity Framework a fait du "lazy loading".
Ainsi, si au lieu d'utiliser virtual
pour les
déclarer :
public virtual ICollection<Partie> Parties { get; set; }
public virtual ICollection<Tarif> Tarifs { get; set; }
On s'était contenté de :
public ICollection<Partie> Parties { get; set; }
public ICollection<Tarif> Tarifs { get; set; }
Alors la consultation de la page Index n'aurait plus généré qu'une seule
requête pour charger uniquement les données de la table des voyages (pas de
eager loading puisque on n'a pas d'Include
) :
SELECT
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[Position] AS [Position],
[Extent1].[Title] AS [Title],
[Extent1].[VoyageType] AS [VoyageType],
[Extent1].[Notes] AS [Notes]
FROM [Voyages] AS [Extent1]
ORDER BY [Extent1].[Position] ASC, [Extent1].[Title] ASC
Et puis c'est tout ! Comme on n'a pas de virtual
, Entity
Framework ne fait pas non plus de lazy loading et ne cherche donc pas à charger
les parties et les tarifs.
C'est pour cela que les colonnes nombre de parties et nombre de tarifs
restent vides puisque les instructions item.Parties == null ? "" : item.Xxxxxx.Count.ToString()
sont là pour gérer le fait que
les collections voyage.Parties et voyage.Tarifs sont nulles.
Par contre, même sans le virtual
, si on demande à faire de
l'eager loading à grand coups de Include
, on retombe bien sur la
grosse requête qui accède par jointure aux tables Voyages, Parties et
Tarifs.
Action Index et ViewModel
À titre personnel, je trouve malgré tout que la requête SQL générée par Entity Framework est un peu lourde pour ce qu'on en fait. Alors que le but est simplement d'afficher le nombre de parties et de tarifs d'un voyage, on se retrouve quand même à charger toutes les parties et tous les tarifs de tous les voyages, ce qui revient donc à charger l'intégralité des tables Parties et Tarifs.
Note : Si on avait une pagination par paquet de 20 voyages, ça serait un peu mieux puisque cela reviendrait "seulement" à charger toutes les parties et tous les tarifs correspondant à 20 voyages uniquement.
Dans l'idéal, il faudrait donc récupérer les informations de base de chaque voyage sans ses propriétés voyage.Parties et voyage.Tarifs, plus le nombre de ses parties et le nombre de ses tarifs. Ce qui pourrait être représenté par la classe ViewModel suivante :
public class VoyageViewModel
{
public int VoyageID { get; set; }
public int Position { get; set; }
public string Title { get; set; }
public int VoyageType { get; set; }
public int TarifsCount { get; set; }
public int PartiesCount { get; set; }
}
Le fait d'utiliser un ViewModel est d'autant plus intéressant qu'une liste n'est pas là pour afficher la totalité des propriétés d'une entité, mais seulement quelques informations essentielles pour remplir son rôle, à savoir permettre de retrouver d'un coup d'oeil les informations qui comptent et éventuellement accéder au détail de la fiche pour consulter l'ensemble de ses données.
Par exemple, si j'ai une table Contacts avec civilité, nom, prénom, téléphone, portable, fax, email, adresse, code postal, ville, pays, format pour les emails, date de naissance, etc... je ne veux pas d'une liste qui fassent 50 colonnes pour afficher tout ça. Je préfère sacrifier quelques détails et me contenter d'une liste édulcorée mais lisible.
Pour résumer, je pense que dans le cas d'une liste (au moins), il est préférable d'employer un objet ViewModel spécifique pour collecter les données importantes puis de transmettre cet objet à la vue pour qu'elle présente ces informations à l'utilisateur.
Cela implique donc de créer une classe ViewModel "VoyagesIndex" (nommée d'après le nom du contrôleur (VoyagesController) et de l'action (Index) qui va l'utiliser) :
public class VoyagesIndex
{
public int VoyageID { get; set; }
public int Position { get; set; }
public string Title { get; set; }
public int VoyageType { get; set; }
public int TarifsCount { get; set; }
public int PartiesCount { get; set; }
public VoyageType TypeVoyage
{
get { return (VoyageType)VoyageType; }
set { VoyageType = (int)value; }
}
}
Note : public VoyageType TypeVoyage
est
une bidouille qui n'a rien à voir avec le sujet et que j'essairai d'expliquer
plus tard si je persiste à passer par elle.
Il faut ensuite modifier le code de l'action Index pour qu'elle récupère une collection de VoyagesIndex et plus une collection d'objets Voyage :
var voyages = voyageRepository
.All
.OrderBy(voyage => voyage.Position)
.ThenBy(voyage => voyage.Title)
.Select(voyage => new VoyagesIndex
{
VoyageID = voyage.VoyageID
Position = voyage.Position,
Title = voyage.Title,
VoyageType = voyage.VoyageType,
TarifsCount = voyage.Tarifs.Count(),
PartiesCount = voyage.Parties.Count()
}).ToList();
Et pour finir modifier la vue Index.cshtml pour qu'elle utilise désormais un
IEnumerable<VoyagesIndex>
pour afficher son
contenu :
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink("Edit", "Edit", new { id = item.VoyageID })
</td>
<td>
@item.Position
</td>
<td>
@Html.ActionLink(@item.Title, "Details", new { id = item.VoyageID })
</td>
<td>
@item.TypeVoyage.ToString()
</td>
<td>
@item.PartiesCount
</td>
<td>
@item.TarifsCount
</td>
<td>
@Html.ActionLink("Delete", "Delete", new { id = item.VoyageID })
</td>
</tr>
}
Grâce à quoi la requête SQL générée quand on consulte la page Index est un peu plus simple à lire, et normalement plus efficace puisqu'elle ne cherche pas à charger les tables Parties et Tarifs.
SELECT
[Project2].[VoyageID] AS [VoyageID],
[Project2].[Position] AS [Position],
[Project2].[Title] AS [Title],
[Project2].[VoyageType] AS [VoyageType],
[Project2].[C1] AS [C1],
[Project2].[C2] AS [C2]
FROM ( SELECT
[Project1].[VoyageID] AS [VoyageID],
[Project1].[Position] AS [Position],
[Project1].[Title] AS [Title],
[Project1].[VoyageType] AS [VoyageType],
[Project1].[C1] AS [C1],
[SSQTAB1].[A1] AS [C2]
FROM ( SELECT
[Extent1].[VoyageID] AS [VoyageID],
[Extent1].[Position] AS [Position],
[Extent1].[Title] AS [Title],
[Extent1].[VoyageType] AS [VoyageType],
[SSQTAB1].[A1] AS [C1]
FROM [Voyages] AS [Extent1]
OUTER APPLY
(SELECT
COUNT(1) AS [A1]
FROM [Tarifs] AS [Extent2]
WHERE [Extent1].[VoyageID] = [Extent2].[VoyageID]) AS [SSQTAB1]
) AS [Project1]
OUTER APPLY
(SELECT
COUNT(1) AS [A1]
FROM [Parties] AS [Extent3]
WHERE [Project1].[VoyageID] = [Extent3].[VoyageID]) AS [SSQTAB1]
) AS [Project2]
ORDER BY [Project2].[Position] ASC, [Project2].[Title] ASC