EF : la voie de la productivité
Une des principales forces de l’Entity Framework est le gain de productivité pour le développeur. Couplé avec le template de génération de code T4, ce gain explose. Imaginons que l’on veuille développer un service WCF exposant des données. Pour chaque type d’entité, vous allez très probablement vouloir faire un Get retournant la liste des entités, peut-être un deuxième Get prenant en paramètre l’id et chargeant l’entité avec ses relations, un Add, un Update, peut-être un Delete.
EF apporte un gain de productivité très important dans le développement des entités ainsi que dans l’utilisation de celles-ci. Cependant, dans notre cas, le code à écrire est particulièrement redondant : il faut écrire quasiment la même chose pour chacune des entités. Et c’est là que le T4 rentre en jeu.
Dans l’ensemble des exemples que j’ai pu regarder, ce template n’est utilisé que pour la génération d’entités. Essayons d’aller plus loin.
Grâce au template T4, vous allez écrire un meta-code qui va générer pour vous votre service WCF ! Et le mieux dans tout ça c’est que votre template est ensuite réutilisable. Pour ma part, je me suis amusé à faire un test de productivité entre un code utilisant l’ADO .NET 2.0 et un code utilisant l’Entity Framework. Première constatation : j’avais oublié que c’était si long de faire de l’ADO “classique”.
Avec EF et T4, la seule chose que je n’ai pas généré dans mon template est la liste des relations à charger avec l’entité (j’aurais pu utiliser une profondeur fixe avec T4 mais je voulais garder une logique métier pour le chargement des relations). Avec ADO .NET 2.0, il faut tout écrire !
De plus, qui dit ADO “classique” implique requête sous la forme d’une chaîne de caractères implique possibilité de faute de frappe dans les requêtes SQL implique des tests unitaires ce qui prend du temps.
Un des premiers points que l’on constate c’est l’apport en visibilité du code avec EF :
Pour la méthode GetOrder(orderId), le code ADO 2.0 (un peu factorisé) est le suivant :
private const string SELECT_ORDERS = "OrderID, CustomerID, EmployeeID, OrderDate, RequiredDate, ShippedDate, ShipVia, Freight, ShipName, ShipAddress, ShipCity, ShipRegion, ShipPostalCode, ShipCountry FROM ORDERS";
private const string SELECT_ORDERDETAILS = "OrderID, ProductID, UnitPrice, Quantity, Discount FROM [Order Details]";
private const string SELECT_CUSTOMERS = "C.CustomerID, CompanyName, ContactName, ContactTitle, [Address], City, Region, PostalCode, Country, Phone, Fax, Since, Points, CardNumber, CAST((CASE M.CustomerID when NULL then 0 else 1 END) AS bit) AS IsMember FROM Customers AS C LEFT OUTER JOIN Members AS M ON C.CustomerID = M.CustomerID";
public Order GetOrder(int orderID)
{
return ReadEntity<Order>(string.Format("SELECT TOP 1 {0} WHERE OrderID = @OrderID; \n SELECT {1} WHERE OrderID = @OrderID;", SELECT_ORDERS, SELECT_ORDERDETAILS), new[] { new SqlParameter("OrderID", orderID) }, reader => GetOrder(reader), (o, reader, connection) =>
{
o.OrderDetails = new List<OrderDetail>();
reader.NextResult();
while (reader.Read())
{
var orderDetail = GetOrderDetail(reader);
orderDetail.Order = o;
o.OrderDetails.Add(orderDetail);
}
if (o.CustomerID != null)
o.Customer = ReadEntity<Customer>(string.Format("SELECT TOP 1 {0} WHERE C.CustomerID = @CustomerID", SELECT_CUSTOMERS), new[] { new SqlParameter("CustomerID", o.CustomerID) }, subReader => GetCustomer(subReader), connection);
var pq = o.OrderDetails.Select(od => od.ProductID.ToString());
if (pq.Any())
{
var products = ReadEntities<Product>(string.Format("SELECT {0} WHERE ProductID IN ({1})", SELECT_PRODUCTS, pq.Skip(1).Any() ? pq.Aggregate((p1, p2) => p1.Contains(string.Format(" {0} ", p2)) ? p1 : string.Concat(" ", p1, " , ", p2)) : pq.First()), subReader => GetProduct(subReader), connection);
foreach (var p in products)
p.OrderDetails = o.OrderDetails.Where(od => od.ProductID == p.ProductID).Select(od => { od.Product = p; return od; }).ToList();
}
});
}
private T ReadEntity<T>(string commandText, SqlParameter[] parameters, Func<SqlDataReader, T> getEntityFromReader, Action<T, SqlDataReader, SqlConnection> moreAction = null) where T : class
{
SqlConnection connection = new SqlConnection(ConfigurationManager.ConnectionStrings["NorthwindEntities"].ToString());
connection.Open();
try
{
return ReadEntity(commandText, parameters, getEntityFromReader, connection, moreAction);
}
finally
{
connection.Close();
}
}
private T ReadEntity<T>(string commandText, SqlParameter[] parameters, Func<SqlDataReader, T> getEntityFromReader, SqlConnection connection, Action<T, SqlDataReader, SqlConnection> moreAction = null) where T : class
{
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
command.Parameters.AddRange(parameters);
SqlDataReader reader = command.ExecuteReader();
try
{
if (reader.Read())
{
var value = getEntityFromReader(reader);
if (moreAction != null)
moreAction(value, reader, connection);
return value;
}
return null;
}
finally
{
reader.Close();
}
}
private List<T> ReadEntities<T>(string commandText, Func<SqlDataReader, T> getEntityFromReader, SqlConnection connection) where T : class
{
var value = new List<T>();
SqlCommand command = connection.CreateCommand();
command.CommandText = commandText;
SqlDataReader reader = command.ExecuteReader();
try
{
while (reader.Read())
value.Add(getEntityFromReader(reader));
return value;
}
finally
{
reader.Close();
}
}
private Order GetOrder(SqlDataReader reader)
{
return new Order { OrderID = reader.GetInt32(0), CustomerID = reader.IsDBNull(1) ? null : reader.GetString(1), EmployeeID = reader.IsDBNull(2) ? null : (int?)reader.GetInt32(2), OrderDate = reader.IsDBNull(3) ? null : (DateTime?)reader.GetDateTime(3), RequiredDate = reader.IsDBNull(4) ? null : (DateTime?)reader.GetDateTime(4), ShippedDate = reader.IsDBNull(5) ? null : (DateTime?)reader.GetDateTime(5), ShipVia = reader.IsDBNull(6) ? null : (int?)reader.GetInt32(6), Freight = reader.IsDBNull(7) ? null : (decimal?)reader.GetDecimal(7), ShipName = reader.IsDBNull(8) ? null : reader.GetString(8), ShipAddress = reader.IsDBNull(9) ? null : reader.GetString(9), ShipCity = reader.IsDBNull(10) ? null : reader.GetString(10), ShipRegion = reader.IsDBNull(11) ? null : reader.GetString(11), ShipPostalCode = reader.IsDBNull(12) ? null : reader.GetString(12), ShipCountry = reader.IsDBNull(13) ? null : reader.GetString(13) };
}
private OrderDetail GetOrderDetail(SqlDataReader reader)
{
return new OrderDetail { OrderID = reader.GetInt32(0), ProductID = reader.GetInt32(1), UnitPrice = reader.GetDecimal(2), Quantity = reader.GetInt16(3), Discount = reader.GetFloat(4) };
}
private Customer GetCustomer(SqlDataReader reader)
{
if (reader.GetBoolean(14))
return GetMember(reader);
return GetCustomer<Customer>(reader);
}
private T GetCustomer<T>(SqlDataReader reader) where T : Customer, new()
{
return new T { CustomerID = reader.GetString(0), CompanyName = reader.GetString(1), ContactName = reader.IsDBNull(2) ? null : reader.GetString(2), ContactTitle = reader.IsDBNull(3) ? null : reader.GetString(3), Address = reader.IsDBNull(4) ? null : reader.GetString(4), City = reader.IsDBNull(5) ? null : reader.GetString(5), Region = reader.IsDBNull(6) ? null : reader.GetString(6), PostalCode = reader.IsDBNull(7) ? null : reader.GetString(7), Country = reader.IsDBNull(8) ? null : reader.GetString(8), Phone = reader.IsDBNull(9) ? null : reader.GetString(9), Fax = reader.IsDBNull(10) ? null : reader.GetString(10) };
}
private Member GetMember(SqlDataReader reader)
{
var member = GetCustomer<Member>(reader);
member.Since = reader.IsDBNull(11) ? null : (DateTime?)reader.GetDateTime(11);
member.Points = reader.IsDBNull(12) ? null : (int?)reader.GetInt32(12);
member.CardNumber = reader.IsDBNull(13) ? null : reader.GetString(13);
return member;
}
private Product GetProduct(SqlDataReader dataReader)
{
return new Product { ProductID = dataReader.GetInt32(0), ProductName = dataReader.GetString(1), SupplierID = dataReader.IsDBNull(2) ? null : (int?)dataReader.GetInt32(2), CategoryID = dataReader.IsDBNull(3) ? null : (int?)dataReader.GetInt32(3), QuantityPerUnit = dataReader.IsDBNull(4) ? null : dataReader.GetString(4), UnitPrice = dataReader.IsDBNull(5) ? null : (decimal?)dataReader.GetDecimal(5), UnitsInStock = dataReader.IsDBNull(6) ? null : (short?)dataReader.GetInt16(6), UnitsOnOrder = dataReader.IsDBNull(7) ? null : (short?)dataReader.GetInt16(7), ReorderLevel = dataReader.IsDBNull(8) ? null : (short?)dataReader.GetInt16(8), Discontinued = dataReader.GetBoolean(9) };
}
Tout simplement hallucinant !
Avec EF, cela donne tout simplement ceci :
//Generated file
partial class NorthwindService
{
private static Func<NorthwindEntities, System.Int32, Order> GetOrderCQ = CompiledQuery.Compile<NorthwindEntities, System.Int32, Order>((context, OrderID) => context.Orders.OfType<Order>().FirstOrDefault(e => e.OrderID == OrderID));
private static Func<NorthwindEntities, System.Int32, Order> GetOrderWithIncludeCQ { get; set; }
public Order GetOrder(System.Int32 OrderID)
{
using (var context = new NorthwindEntities())
{
if (GetOrderWithIncludeCQ != null)
return GetOrderWithIncludeCQ(context, OrderID);
return GetOrderCQ(context, OrderID);
}
}
}
//My partial part
partial class NorthwindService
{
static NorthwindService()
{
GetOrderWithIncludeCQ = (context, orderID) => context.Orders.Include("OrderDetails.Product").Include("Customer").FirstOrDefault(o => o.OrderID == orderID);
}
}
Je ne sais pas ce que vous en pensez mais perso je trouve ça un peu plus facile à lire… et un peu plus rapide à écrire
D’autre part, comme je l’ai expliqué plus haut, finalement je n’ai écrit qu’une seule ligne de code :
GetOrderWithIncludeCQ = (context, orderID) => context.Orders.Include("OrderDetails.Product").Include("Customer").FirstOrDefault(o => o.OrderID == orderID);
Vive la productivité !
Cela nous donne donc le temps de dev en fonction du développement suivant :
| Nb entités / temps de dev | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 100 | 500 | 1000 |
| EF | 3 | 3 | 4 | 4 | 4 | 4 | 5 | 5 | 5 | 5 | 6 | 6 | 6 | 6 | 28 | 128 | 253 |
| ADO .NET 2.0 | 35 | 65 | 95 | 125 | 155 | 185 | 215 | 245 | 275 | 305 | 335 | 365 | 395 | 425 | 3005 | 15005 | 30005 |
C’est à dire que pour 1000 entités, avec ADO 2.0, cela prend plus de 66 jours / homme (à raison de 7.5 h par jour) contre… 4 heures pour Entity Framework. Essayons d’être honnête dans les estimations, dans le cas de 1000 entités, il est souhaitable de de découper nos entités en plusieurs modèles. On va donc grossir le trait à 2 jours / homme pour EF et arrondir le développement avec ADO 2.0 à 2.5 mois. Impressionnant non ? CQFD
Si on rajoute à ça le fait que le code est plus lisible avec EF, je ne vois plus beaucoup de raison de vouloir continuer à faire de l’ADO .NET 2.0.
Il y a tout de même un point très important à préciser : pensez à vous former avant de réellement développer avec EF (vous pouvez d’ailleurs me contacter pour cela (matthieu.mezil at live.fr)). Ceci est très important car même si ça a l’air simple d’utilisation, il y a des concepts à maîtriser pour :
- ne pas perdre du temps lors du développement
- obtenir les résultats attendus (notamment au niveau de la gestion des entités non persistées)
- ne pas dégrader les perfs
Vous trouverez le tt que j’utilise ici.
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 :