blog.pagesd.info

Aller au contenu | Aller au menu | Aller à la recherche

Formation ASP.NET MVC

Fil des billets - Fil des commentaires

mercredi 24 mars 2010

Contact Manager avec MongoDB et NoRM

Maintenant que j'ai enfin expédié mon billet consacré au portage hyper passionnant de l'application ContactManager sous NHibernate, je peux enfin revenir à des trucs beaucoup plus amusants.

Ca faisait un petit moment que j'avais envie de tester pour de bon une de ces nouvelles bases de données NoSQL dont on nous rabat les oreilles ces derniers temps.

Mais bon, ça demande du temps et il faut choisir quelle NoBase de données employer et il faut qu'il y ait des pilotes pour C# et il y a plein d'autres trucs à faire... Et un soir, Rob Conery sort un billet où non seulement il explique comment utiliser une base de données MongoDB avec LINQ mais en plus il présente le "provider" NoRM sur lequel il est en train de travailler avec quelques autres.

C'est trop beau pour être vrai => je télécharge, je trifouille et j'arrive à peu près à faire marcher ses bouts d'exemples.

C'est pas compliqué ! J'ai plus qu'à transformer Contact Manager pour qu'il fonctionne avec MongoDB et NoRM !!!

Oulla! Ne nous emballons pas. NoRM est en cours de développement donc y'a pas forcément tout ce qu'il faut et surtout j'ai pas encore tout assimilé en matière de base de données documents.

C'est pas compliqué ! J'ai qu'à refaire seulement la toute première étape !!!

Le faire, c'est bien et ça va même assez vite. Le dire, c'est mieux mais c'est un peu plus long.

Et donc, après quelques heures d'efforts, voici mon tutorial sur le développement d'une application de gestion de contacts avec ASP.NET MVC, MongoDB et NoRM qui reprend quasiment mot à mot la première étape du tutoriel de Microsoft. Comme l'original, ce tutoriel présente donc comment créer une application de gestion de contact de la façon la plus simple qui soit. Tout au long de celui-ci, vous pourrez voir comment mettre en place le support d'opérations CRUD classiques vers une base de données MongoDB : création, lecture, mise à jour et suppression d'enregistrements.

mardi 23 mars 2010

Porter Contact Manager sous NHibernate

Après avoir fait quelques essais très basiques avec NHibernate, j'ai voulu essayer d'être plus concret et d'aller un peu plus loin en tentant de porter l'application Contact Manager sous NHibernate. A priori, ça ne devait pas être trop compliqué dans la mesure où il y a quelques temps j'avais déjà remplacé Entity Framework par LINQ to SQL et que ça ne s'était pas trop mal passé. Sans compter, que je souhaitais juste faire en sorte que ça marche à peu près tel quel, sans trop chercher à découvrir ou à mettre au point la "bonne" façon d'utiliser NHibernate avec ASP.NET MVC.

Dans la pratique, ça m'a pris un peu plus de temps que ce que j'avais pensé y consacrer. En fait, j'aurais pu faire ce billet depuis un petit moment, mais avant il fallait que j'arrive à mettre au propre la solution que j'avais suivie. Parce que c'est là que le bât blesse, c'est qu'avec NHibernate il y a tout plein de façons de faire et que je n'ai pas réussi à trouver une documentation "officielle" (et récente) qui décrive clairement quel est l'état de l'art pour utiliser NHibernate dans le cadre d'une application ASP.NET.

Voici donc parmi tous mes essais (avec des réussites, des ratés et des abandons) une des façons de faire à laquelle je suis arrivé et qui est assez "simple" à expliquer. Ca marche correctement, mais je ne peux absolument pas affirmer que c'est la méthode à suivre.

Etape 1 : Installer NHibernate

Pour faire simple, le plus pratique est d'aller sur le site de Castle ActiveRecord et d'y télécharger la toute dernière version d'Active Record. Une fois l'archive décompressée, il suffit de créer un répertoire "lib" dans le répertoire racine de "ContactManager" et d'y copier les quelques fichiers suivants :

  • Iesi.Collections.dll
  • Iesi.Collections.xml
  • LinFu.DynamicProxy.dll
  • log4net.dll
  • log4net.xml
  • nhibernate-configuration.xsd
  • nhibernate-mapping.xsd
  • NHibernate.ByteCode.LinFu.dll
  • NHibernate.ByteCode.LinFu.xml
  • NHibernate.dll
  • NHibernate.Linq.dll
  • NHibernate.xml

En réalité, je n'utilise pas du tout l'approche Active Record. Mais l'avantage de passer par leur site c'est qu'on est certain d'avoir DLLs récentes avec des versions compatibles entre elles. Au cours de mes différents essais, j'avais récupéré des DLLs au fur et à mesure de mes besoins et d'un petit peu tous les côtés et ça m'avait joué quelques mauvais tours.

Une fois ces différentes librairies mises en place, il suffit de référencer les 4 éléments suivants dans l'application ContactManager existante :

  • log4net.dll
  • NHibernate.dll
  • NHibernate.ByteCode.LinFu.dll
  • NHibernate.Linq.dll

Et pour bien faire les choses, il faut aussi prendre le temps de dé-référencer les éléments qui correspondent à Entity Framework et par conséquent de supprimer les fichiers liés à EF dans le sous-répertoire Models.

Etape 2 : Configurer NHibernate

NHibernate est installé et référencé au niveau du projet. Il faut maintenant le configurer pour lui expliquer qu'on aimerait bien qu'il utilise notre base de données SQL Server habituelle. Et là commence la galère puisqu'il existe apparement des tonnes de façon différentes pour faire ça. J'en ai essayé quelques unes et je n'ai pas vraiment très bien compris quels sont les avantages et les inconvénients de telle ou telle façon de faire (mais c'est vrai que j'ai pas trop passé de temps dessus).

Par conséquent, je vais aller au plus simple et donner directement la méthode que j'ai repérée sur la vidéo de présentation de NHibernate proposée par TekPub. C'est juste un extrait, mais ça m'a suffi pour emprunter le source à l'application Kona de Rob Conery. Cela consiste à créer un fichier "nhibernate.config" à la racine de l'application ASP.NET MVC pour y définir tous les éléments nécessaires pour configurer NHibernate :

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-configuration xmlns="urn:nhibernate-configuration-2.2">

  <session-factory name="ContactManager">
    <property name="dialect">NHibernate.Dialect.MsSql2008Dialect</property>
    <property name="connection.driver_class">NHibernate.Driver.SqlClientDriver</property>
    <property name="connection.connection_string">server=.\SQLExpress;database=ContactManagerDB;Integrated Security=true;</property>
    <property name="show_sql">true</property>
    <property name="cache.use_query_cache">false</property>
    <property name="adonet.batch_size">16</property>
    <property name="proxyfactory.factory_class">NHibernate.ByteCode.LinFu.ProxyFactoryFactory, NHibernate.ByteCode.LinFu</property>
    <mapping assembly="ContactManager" />
  </session-factory>
  
</hibernate-configuration>

Par rapport à l'écriture de ces informations dans le Web.config, il me semble que c'est un peu plus clair d'isoler ça dans un fichier à part (sans compter que ça évite de complexitrifier le fichier Web.config inutilement). Sinon, il aurait aussi été possible d'utiliser directement le fichier "hibernate.cfg.xml" standard mais je trouve qu'un fichier ".config" c'est plus propre (et plus sûr ?) qu'un fichier ".xml".

Etape 3 : Gérer une session NHibernate

Dans la version Entity Framework de l'application Contact Manager, on utilise un objet ContactManagerDBEntities généré automatiquement par ADO.NET EF pour communiquer avec la base de données SQL Server. Dans la version portée sous LINQ to SQL, c'est un objet ContactManagerModelDataContext qui joue ce rôle.

En ce qui concerne NHibernate, les communications avec la base de données se font par l'intermédiaire d'un objet Session. Et là aussi, pour gérer / instancier cet objet Session, il existe tout plein de façons de faire.

Pour l'instant, je reprend de façon ultra simplifiée la méthode proposée par Rob Conery parce qu'il est possible de la mettre en oeuvre en très très peu de lignes au niveau du fichier global.asax.cs :

using System;
using System.IO;
using System.Web.Mvc;
using System.Web.Routing;
using NHibernate;
using NHibernate.Cfg;

namespace ContactManager
{

    public class MvcApplication : System.Web.HttpApplication
    {

        public static ISessionFactory SessionFactory = CreateSessionFactory();

        private static ISessionFactory CreateSessionFactory()
        {
            var cfg = new Configuration().Configure(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "nhibernate.config"));
            return cfg.BuildSessionFactory();
        }

Grâce à ça, il devient tout simple de remplacer la ligne :

private ContactManagerDBEntities _entities = new ContactManagerDBEntities();

par l'instruction suivante :

private ISession _session = MvcApplication.SessionFactory.GetCurrentSession();

Etape 4 : Faire le mapping entre les tables et les objets

Avec NHibernate, les classes Contact et Group ne sont pas générées automatiquement comme avec Entity Framework ou LINQ to SQL. Il faut donc créer à la main des classes POCO (c'est pas le plus compliqué) et il faut également créer un fichier de mapping XML pour associer chaque classe à la table correspondante dans la base de données (c'est déjà un peu plus mariole).

Je ne vais pas expliquer comment faire ni entrer dans les détails de comment ça marche (en plus je n'ai pas encore suffisament bien compris moi-même), mais voici ce à quoi je suis arrivé après quelques essais.

Models\Contact.cs

namespace ContactManager.Models
{
    public partial class Contact
    {
        public Contact()
        {
            Group = new Group();
        }
        public virtual int Id { get; set; }
        public virtual string FirstName { get; set; }
        public virtual string LastName { get; set; }
        public virtual string Phone { get; set; }
        public virtual string Email { get; set; }
        public virtual Group Group { get; set; }

        /// <summary>
        /// Create a new Contact object.
        /// </summary>
        /// <param name="id">Initial value of Id.</param>
        /// <param name="firstName">Initial value of FirstName.</param>
        /// <param name="lastName">Initial value of LastName.</param>
        /// <param name="phone">Initial value of Phone.</param>
        /// <param name="email">Initial value of Email.</param>
        public static Contact CreateContact(int id, string firstName, string lastName, string phone, string email)
        {
            Contact contact = new Contact();
            contact.Id = id;
            contact.FirstName = firstName;
            contact.LastName = lastName;
            contact.Phone = phone;
            contact.Email = email;
            return contact;
        }
    }

}

Mappings\Contact.hbm.xml

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" namespace="ContactManager.Models" assembly="ContactManager">

  <class name="Contact" table="Contacts" dynamic-update="true">

    <cache usage="read-write" />

    <id name="Id" column="Id" type="integer">
      <generator class="identity" />
    </id>
    <property name="FirstName" type="string" />
    <property name="LastName" type="string" />
    <property name="Phone" type="string" />
    <property name="Email" type="string" />
    <many-to-one name="Group" column="groupId" not-null="true" />

  </class>

</hibernate-mapping>

Models\Group.cs

using System.Collections.Generic;

namespace ContactManager.Models
{
    public class Group
    {
        public Group()
        {
            Contacts = new List<Contact>();
        }
        public virtual int Id { get; set; }
        public virtual string Name { get; set; }
        public virtual IList<Contact> Contacts { get; set; }
    }
}

Mappings\Group.hbm.xml

<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" namespace="ContactManager.Models" assembly="ContactManager">

  <class name="Group" table="Groups" dynamic-update="true">

    <cache usage="read-write" />

    <id name="Id" column="Id" type="integer">
      <generator class="identity" />
    </id>
    <property name="Name" type="string" />
    <bag
        name="Contacts"
        inverse="true"
        lazy="true">
      <key column="groupId" />
      <one-to-many class="Contact" />
    </bag>

  </class>

</hibernate-mapping>

En ce qui concerne les deux fichiers Contact.hbm.xml et Group.hbm.xml, il faut impérativement modifier leur propriété "Action de génération" au niveau de l'explorateur de projet pour la définir à "Ressource incorporée" sans quoi le mapping NHibernate ne fonctionne pas. C'est un problème très facile à identifier parce qu'on obtient une erreur "Association references unmapped class: ContactManager.Models.Contact" dès le lancement de l'application.

Redévelopper le repository avec NHibernate

Après ces 4 premières étapes, tout est enfin prêt pour pouvoir modifier la couche repository de l'application afin de remplacer Entity Framework par NHibernate :

  • NHibernate est installé et référencé
  • NHibernate est configuré pour accéder à la base de données SQL Server
  • Un objet NHibernate Session a été défini pour communiquer avec la base de données
  • Les objets données et les fichiers de mapping NHibernate sont en place

Par conséquent, il ne reste plus qu'à implémenter l'interface IContactManagerRepository en utilisant NHibernate et LINQ to NHibernate pour remplacer le fichier EntityContactManagerRepository.cs qui l'implémentait pour Entity Framework.

Models\NHContactManagerRepository.cs

using System.Collections.Generic;
using System.Linq;
using NHibernate;
using NHibernate.Linq;

namespace ContactManager.Models
{
    public class NHContactManagerRepository : IContactManagerRepository
    {
        private ISession _session = ContactManager.MvcApplication.SessionFactory.OpenSession();

        public Contact GetContact(int id)
        {
            return (from c in _session.Linq<Contact>()
                    where c.Id == id
                    select c).FirstOrDefault();
        }

        public Contact CreateContact(int groupId, Contact contactToCreate)
        {
            // Associate group with contact
            contactToCreate.Group = GetGroup(groupId);

            // Save new contact
            _session.SaveOrUpdate(contactToCreate);
            return contactToCreate;
        }

        public Contact EditContact(int groupId, Contact contactToEdit)
        {
            // Associate with new group
            contactToEdit.Group = GetGroup(groupId);

            // Save changes
            _session.SaveOrUpdate(contactToEdit);
            _session.Flush();
            return contactToEdit;
        }

        public void DeleteContact(Contact contactToDelete)
        {
            _session.Delete(contactToDelete);
            _session.Flush();
        }

        public Group CreateGroup(Group groupToCreate)
        {
            _session.SaveOrUpdate(groupToCreate);
            return groupToCreate;
        }

        public IEnumerable<Group> ListGroups()
        {
            return _session.Linq<Group>().OrderBy(o => o.Name).ToList();
        }

        public Group GetFirstGroup()
        {
            return _session.Linq<Group>().FirstOrDefault();
        }

        public Group GetGroup(int id)
        {
            return (from g in _session.Linq<Group>()
                    where g.Id == id
                    select g).FirstOrDefault();
        }

        public void DeleteGroup(Group groupToDelete)
        {
            _session.Delete(groupToDelete);
            _session.Flush();
        }

    }
}

Une fois encore, je n'ai pas cherché à faire "bien" mais simplement à ce que ça marche. Dans la pratique, il vaudrait mieux éviter tous ces "_session.Flush()" et passer par des transactions et des "using" mais pour l'instant, ce n'était absolument pas mon objectif.

Pour pouvoir compiler et vérifier que tout fonctionne comme prévu, il reste encore à modifier le constructeur ContactManagerService pour qu'il utilise ce repository en lieu et place de celui d'Entity Framework.

public ContactManagerService(IValidationDictionary validationDictionary)
    : this(validationDictionary, new NHContactManagerRepository())
{
}

Une fois cette ultime modification réalisée, la solution compile sans aucune erreur et elle m'offre bien les mêmes fonctionnalités que les versions avec Entity Framework ou LINQ to SQL :

  • lister les contacts rattachés à un groupe,
  • ajouter, modifier ou supprimer un contact,
  • ajouter ou supprimer un groupe de contacts.

Et juste pour le plaisir, je peux lancer les quelques tests unitaires de ContactManager.Test et tout va bien !

Conclusion

Par rapport au passage sous LINQ to SQL, ça a été beaucoup plus laborieux. Le problème, c'est qu'il y a à la fois beaucoup (trop) de façon de faire et beaucoup (trop) d'informations disponibles ce qui fait qu'il n'est vraiment pas évident de savoir dans quelle direction partir.

Mais malgré tout, c'est quand même un succès. Il a suffi de changer le repository (et de recréer les classes Contact et Group générées automatiquement) pour que l'application ContactManager de base fonctionne correctement avec NHibernate. C'est donc un nouveau bon point pour le pattern Repository (zut! juste au moment où j'avais presque envie de me (ré)orienter vers Active Record). Et avec la prochaine version de Entity Framework qui devrait être POCO friendly, on peut même supposer que ce sera de plus en plus facile de passer d'un ORM à l'autre.

Peut-être que plus tard (c'est à dire une fois que je saurai faire), j'écrirai un autre billet pour présenter une façon d'utiliser Nibernate avec ASPNET MVC qui soit un peu plus orthodoxe. Mais déjà, j'aimerai très vite essayer de voir ce que ça donne en changeant la base de données et en testant cette version de Contactmanager NHibernate avec MySQL ou Oracle.

mardi 1 décembre 2009

Test de T4MVC avec le projet Contact Manager

Finalement, j'ai un peu délaissé l'application NerdDinner pour revenir au projet ContactManager et faire quelques tentatives avec T4MVC (j'ai quand même fini de publier la traduction de la 5° partie du tutoriel NerdDinner).

Première remarque : si on veut utiliser T4MVC il vaut mieux penser à le faire dès le départ, parce qu'après c'est pas la chose la plus passionnante à faire. Sans compter que Contact Manager est un tout petit projet... Ce qui est dommage, c'est que Visual Studio n'est vraiment pas très pratique pour faire ça à coup de rechercher / remplacer :

  • dès qu'on modifie le code trouvé à la main, ça devient assez bizarre quand on lance la suite de la recherche. J'ai l'impression que ça repart du début du fichier modifié ou peut être que ça repart en marche arrière... J'ai pas trop compris comment ça marche et pour être bien certain d'êre passé partout
  • la recherche s'applique aussi aux fichiers .designer.cs générés par les différents concepteurs (et y compris par T4MVC.tt !) alors qu'on en a rien à faire et qu'il ne faut surtout pas les modifier. Et le pire c'est qu'il n'y a rien à faire et que c'est pas demain la veille que ça va changer !

J'ai malgré tout réussi à venir à bout des différentes mises à jour nécessaires pour que le source de Contact Manager soit beau comme un sou neuf en traitant successivement les éléments suivants :

  • Html.RenderPartial()
  • return View()
  • Html.ActionLink()
  • Url.Action()
  • return RedirectToAction)
  • Html.BeginForm()

Je n'ai pas eu à m'occuper des Ajax.ActionLink() puisque pour ma part, j'ai totalement laissé tombé Ajax.NET au profit de jQuery.

Je ne me suis pas non plus occupé des routes.MapRoute() dans le Global.asax parce qu'il contient seulement la route par défaut et qu'en plus je ne savais pas trop quoi y faire :

routes.MapRoute(
    "Default",                                        // Route name
    "{controller}/{action}/{id}",                     // URL with parameters
    new { controller = "Contact", action = "Index", id = "" } // Parameter defaults
);

Mais rien qu'avec ces premiers essais, ça marche plutôt pas mal. Le seul petit bémol est du côté des vues. Quand on veut utiliser les objets T4MVC à l'intérieur des balises HTML, Visual Studio n'est pas capable de proposer l'intellisense et il faut tout taper de tête :

<a href="<%= Url.Action(MVC.Contact.Edit(item.Id)) %>">
    <img src="<%= Links.Content.Edit_png %>" alt="Edit" />
</a>

Pour contourner ça, il faut procéder en deux étapes et saisir le code à l'extérieur de la balise puis le couper / coller à l'intérieur de la balise :

<%= Url.Action(MVC.Contact.Edit(item.Id)) %>
<%= Links.Content.Edit_png %>
<a href="">
    <img src="" alt="Edit" />
</a>

C'est moyen, mais pas insupportable. Là où c'est plus ennuyeux, c'est que dans ce cas, s'il y a des erreurs, elles n'apparaissent pas immédiatement lors de la compilation mais seulement au moment de l'exécution.

Un autre truc qui pourrait s'avérer gênant, c'est que T4MVC modifie (très légèrement) le code source et qu'il transforme :

  • les classes en partial
  • les méthodes actions en virtual

Je me demande si ça ne pourrait pas être un peu compliqué à gérer par rapport au contrôle de sources sous Subversion ? Mais là aussi, si on utilise T4MVC dès le départ, il me semble que cela ne devrait pas poser de problème (à vérifier).

vendredi 13 novembre 2009

Portage du tutoriel Contact Manager sous LINQ to SQL

Encouragé par un premier succès pour faire passer le tutoriel ContactManager en tout jQuery, j'ai décidé de continuer ma formation ASP.NET MVC en modifiant l'application ContactManager pour remplacer Entity Framework par LINQ to SQL.

Création du modèle LINQ to SQL

Après un ou deux essais, j'ai vu que le plus pratique était de commencer par exclure ou supprimer du projet tout ce qui touchait à Entity Framework, à savoir ContactManagerModel.edmx et EntityContactManagerRepository.cs. Il devient alors possible d'ajouter un modèle LINQ to SQL au projet pour représenter la base de données, sans que cela provoque des définitions en double avec les objets générés par Entity Framework.

L'ajout d'un modèle LINQ to SQL est plutôt simple :

  • se placer dans le dossier Models de l'explorateur de solution
  • faire un clic droit pour sélectionner la commande "New items..."
  • dans la catégorie "Data", choisir le modèle "LINQ to SQL Classes"
  • le nommer "ContactManagerModel.dbml" et cliquer sur le bouton "Add" pour le créer

Le concepteur LINQ to SQL est alors automatiquement ouvert et il est possible d'y faire glisser les deux tables "Contacts" et "Groups" depuis l'explorateur de base de données, ce qui a pour effet de :

  • créer les classes "Contact" et "Group" basées sur ces deux tables
  • ajouter une propriété "Group" à la classe "Contact" pour refléter le côté "1" de la relation entre les deux tables
  • ajouter une propriété "Contacts" à la classe "Group" pour refléter le côté "N" de la relation entre les deux tables

Cela génère aussi une classe ContactManagerModelDataContext.cs qui fourni deux propriétés "Contacts" et "Groups" permettant de gérer des objets "Contact" et "Group" issus de la base de données.

Concrètement, cette première étape a eu pour résultat de substituer ContactManagerModel.dbml à ContactManagerModel.edmx.

Création d'un Repository basé sur LINQ to SQL

Cette seconde étape va consister à remplacer EntityContactManagerRepository.cs par une classe L2SContactManagerRepository.cs qui implémente elle aussi l'interface IContactManagerRepository. Et au lieu d'utiliser un objet ContactManagerDBEntities, cette nouvelle classe va utiliser un objet ContactManagerModelDataContext.

using System.Collections.Generic;
using System.Linq;

namespace ContactManager.Models
{
    public class L2SContactManagerRepository : IContactManagerRepository
    {
        private ContactManagerModelDataContext _context = new ContactManagerModelDataContext();

        ...
    }
}

Il reste alors à implémenter les différentes méthodes déclarées dans l'interface IContactManagerRepository, mais à la sauce LINQ to SQL. Par rapport au repository basé sur Entity Framework, les différences sont finalement assez mineures.

  • Les objets _entities.ContactSet et _entities.GroupSet deviennent des objets _context.Contacts et _context.Groups :
// ---------- Entity Framework
public IEnumerable<Group> ListGroups()
{
    return _entities.GroupSet.OrderBy(o => o.Name).ToList();
}

// ---------- LINQ to SQL
public IEnumerable<Group> ListGroups()
{
    return _context.Groups.OrderBy(o => o.Name).ToList();
}
  • Les méthodes pour la mise à jour des données ont des noms différents : AddToXxxxxxSet() devient InsertOnSubmit(), DeleteObject() devient DeleteOnSubmit() et SaveChanges() devient SubmitChanges() :
// ---------- Entity Framework
public Group CreateGroup(Group groupToCreate)
{
    _entities.AddToGroupSet(groupToCreate);
    _entities.SaveChanges();
    return groupToCreate;
}

// ---------- LINQ to SQL
public Group CreateGroup(Group groupToCreate)
{
    _context.Groups.InsertOnSubmit(groupToCreate);
    _context.SubmitChanges();
    return groupToCreate;
}
  • La méthode ApplyPropertyChanges() de Entity Framework n'ayant apparemment pas d'équivalent en LINQ to SQL, et le helper UpdateModel() étant lié à la classe Controller, je me suis pour l'instant contenté d'une mise à jour des propriétés à la main pour retrouver la même fonctionnalité :
// ---------- Entity Framework
public Contact EditContact(int groupId, Contact contactToEdit)
{
    // Get original contact
    var originalContact = GetContact(contactToEdit.Id);
    
    // Update with new group
    originalContact.Group = GetGroup(groupId);
    
    // Save changes
    _entities.ApplyPropertyChanges(originalContact.EntityKey.EntitySetName, contactToEdit);
    _entities.SaveChanges();
    return contactToEdit;
}

// ---------- LINQ to SQL
public Contact EditContact(int groupId, Contact contactToEdit)
{
    // Get original contact
    var originalContact = GetContact(contactToEdit.Id);

    // Update with new group
    originalContact.Group = GetGroup(groupId);

    // Update properties
    originalContact.FirstName = contactToEdit.FirstName;
    originalContact.LastName = contactToEdit.LastName;
    originalContact.Phone = contactToEdit.Phone;
    originalContact.Email = contactToEdit.Email;

    // Save changes
    _context.SubmitChanges();
    return contactToEdit;
}

Malgré tout, il y a une autre "grosse" différence dans la mesure où avec Entity Framework on utilise la méthode Include(relation) dans la requête LINQ pour retrouver les objets associés à l'élément sur lequel on effectue la requête.

Concrètement, cela a pour effet de générer une jointure lors de l'exécution de la requête LINQ.

  • Sans le Include(relation) :
return (from c in _entities.ContactSet
        where c.Id == id
        select c).FirstOrDefault();

=>  SELECT 1 AS [C1], 
                [Extent1].[Id] AS [Id], 
                [Extent1].[FirstName] AS [FirstName], 
                [Extent1].[LastName] AS [LastName], 
                [Extent1].[Phone] AS [Phone], 
                [Extent1].[Email] AS [Email], 
                [Extent1].[groupId] AS [groupId]
        FROM [dbo].[Contacts] AS [Extent1]
        WHERE [Extent1].[Id] = @p__linq__1
  • Avec le Include(relation) :
return (from c in _entities.ContactSet.Include("Group")
        where c.Id == id
        select c).FirstOrDefault();

=> SELECT 1 AS [C1], 
                [Extent1].[Id] AS [Id], 
                [Extent1].[FirstName] AS [FirstName], 
                [Extent1].[LastName] AS [LastName], 
                [Extent1].[Phone] AS [Phone], 
                [Extent1].[Email] AS [Email], 
                [Extent2].[Id] AS [Id1], 
                [Extent2].[Name] AS [Name]
        FROM  [dbo].[Contacts] AS [Extent1]
        LEFT OUTER JOIN [dbo].[Groups] AS [Extent2] ON [Extent1].[groupId] = [Extent2].[Id]
        WHERE [Extent1].[Id] = @p__linq__1

Là aussi, je n'ai pas réussi à trouver d'équivalent pour LINQ to SQL, d'autant que dans ce cas je n'ai pas cherché avec beaucoup d'acharnement puisque l'application fonctionne parfaitement même si on s'en passe...

// ---------- Entity Framework
public Contact GetContact(int id)
{
    return (from c in _entities.ContactSet.Include("Group")
            where c.Id == id
            select c).FirstOrDefault();
}

// ---------- LINQ to SQL
public Contact GetContact(int id)
{
    return (from c in _context.Contacts
            where c.Id == id
            select c).FirstOrDefault();
}

Isoler les tests unitaires de l'ORM

Une fois la classe Repository complètement ré-écrite pour s'appuyer sur LINQ to SQL et plus Entity Framework, le projet Contact manager compile sans problème. Encore faut-il penser à modifier la couche de service pour instancier un objet IContactManagerRepository à partir de L2SContactManagerRepository :

public ContactManagerService(IValidationDictionary validationDictionary)
    : this(validationDictionary, new L2SContactManagerRepository())
{
}

Par contre, la compilation de la solution provoque une erreur parce que les tests unitaires font appel à la méthode CreateContact() de la classe Contact. Apparemment, Entity Framework génère automatiquement une méthode CreateContact() pour ses objets, alors que LINQ to SQL n'en fait rien !

Cela met en évidence deux problèmes :

  • chez Microsoft, ils sont si peu opiniated qu'ils partent dans tous les sens
  • les tests unitaires du Contact Manager sont fortement liés à l'ORM Entity Framework et ça, c'est pas bon du tout

Dans un premier temps, j'ai tout simplement piraté la méthode CreateContact() générée par Entity Framework et ajouté une classe partielle Contact :

namespace ContactManager.Models
{
    public partial class Contact
    {
        /// <summary>
        /// Create a new Contact object.
        /// </summary>
        /// <param name="id">Initial value of Id.</param>
        /// <param name="firstName">Initial value of FirstName.</param>
        /// <param name="lastName">Initial value of LastName.</param>
        /// <param name="phone">Initial value of Phone.</param>
        /// <param name="email">Initial value of Email.</param>
        public static Contact CreateContact(int id, string firstName, string lastName, string phone, string email)
        {
            Contact contact = new Contact();
            contact.Id = id;
            contact.FirstName = firstName;
            contact.LastName = lastName;
            contact.Phone = phone;
            contact.Email = email;
            return contact;
        }
    }
}

Mais j'ai trouvé ça un peu trop spécifique pour LINQ to SQL (puisque c'est redondant avec le code généré par Entity Framework) et j'ai finalement préféré ajouter une méthode CreateContact() utilisant la syntaxe "Object initializers" à la classe ContactManagerServiceTest des tests unitaires :

private Contact CreateContact(int id, string firstName, string lastName, string phone, string email)
{
    Contact contact = new Contact() { Id = id, FirstName = firstName, LastName = lastName, Phone = phone, Email = email };
    return contact;
}

Puis remplacer les appels à Contact.CreateContact() dans ContactManagerServiceTest.cs par de simples appels à CreateContact().

Une fois cette dernière modification réalisée, la solution compile sans erreur, les tests unitaires continuent à être OK et l'application fonctionne sans problème. Je peux :

  • lister les contacts rattachés à un groupe,
  • ajouter, modifier ou supprimer un contact,
  • ajouter ou supprimer un groupe de contacts.

Conclusion

Suite à mes différents essais avec les tutoriels ContactManager et NerdDinner, j'avais eu le pressentiment que cette notion de repository et de service pouvait rendre une application indépendante de la façon d'implémenter l'accès à la base de données.

La réalisation de ce petit exercice m'a permis de vérifier cette impression puisque hormis une ligne de la couche service, seule la classe Repository a été mise à jour. Par conséquent, si on fait les choses comme il faut, on ne se retrouve pas lié corps et âme à un ORM mais au contraire complètement libre d'en changer dans le cas où il ne serait pas satisfaisant.

Dans la pratique, cela veut dire qu'il ne faut pas se focaliser sur des détails techniques mais sur des problèmes réels. Et notamment décider si on conserve une base de données pourrie pléthorique et mal conçue ou si on fait table rase des errements du passé pour construire une vrai base de données et de vrais objets adaptés uniquement à nos besoins actuels.

mardi 3 novembre 2009

Gestion de contacts avec ASP.NET MVC et jQuery

La dernière étape du tutoriel Développer une application de gestion de contacts avec ASP.NET MVC consistait à ajouter de l'Ajax dans l'application pour la rendre plus performante et plus moderne. Pour parvenir à cela, le tutoriel utilisait Ajax.NET pour les requêtes Ajax et jQuery pour les animations.

De mon côté, j'ai préféré faire entièrement confiance à la librairie jQuery : à la fois pour les animations et pour les fonctionnalités Ajax.

Utilisation d'une vue partielle via jQuery

Il suffit de remplacer les 3 fonctions Javascript beginContactList(), successContactList() et failureContactList() par le tout petit script suivant :

<script type="text/javascript">

    $(document).ready(function() {

        // Ajax Loading
        $("#leftColumn li a").click(function() {

            $("#leftColumn li").removeClass('selected');
            $(this).parent().addClass('selected');

            var url = $(this).attr("href");

            $("#divContactList")
                .fadeOut()
                .load(url, function() {
                    $(this).fadeIn();
                });

            return false;

        });

    });

</script>

Explication de code :

  1. $("#leftColumn li a") pour toutes les balises <a> comprises dans un élément <li> apparaissant dans la balise ayant l'identifiant leftColumn
  2. .click(function() on associe une fonction à l'évènement "click" de ces balises
  3. $("#leftColumn li").removeClass('selected'); supprime la classe "selected" de tous les éléments <li>
  4. $(this).parent().addClass('selected'); ajoute la classe "selected" au parent du lien <a> qui a été cliqué
  5. var url = $(this).attr("href"); retrouve l'url correspondant au lien <a> qui a été cliqué
  6. $("#divContactList") sélectionne l'élément ayant l'identifiant divContactList
  7. .fadeOut() fait disparaitre progressivement l'élément sélectionné
  8. .load(url, function() { charge un contenu externe pointé par url dans l'élément sélectionné puis appelle une fonction lorsque le chargement est terminé
  9. $(this).fadeIn(); fait apparaitre progressivement l'élément sélectionné
  10. return false; annule l’action en cours, c'est à dire le clic sur un lien => le navigateur ne vas pas charger la page dont l'url est définie dans la propriété href de la balise <a>

L'avantage avec cette solution, c'est que du point de vue du développeur, on doit connaitre uniquement jQuery et pas jQuery (pour les animations destinées à rassurer l'utilisateur) + Ajax.NET.

D'autre part, on continue à utiliser la fonction Html.ActionLink() au lieu de la fonction Ajax.ActionLink() au niveau de la vue Index.aspx :

    <ul id="leftColumn">
    <% foreach (var item in Model.Groups) { %>
        <li<%= Html.Selected(item.Id, Model.SelectedGroup.Id) %>>
            <%= Html.ActionLink(item.Name, "Index", new { id = item.Id }) %>
        </li>
    <% } %>
    </ul>

Ce qui en HTML donne le code suivant :

    <ul id="leftColumn">
        <li class="selected">
            <a href="/Contact/Index/1">Business</a>
        </li>
        <li>
            <a href="/Contact/Index/2">Friends</a>
        </li>
    </ul>

On a bien une balise <a> toute simple (<a href="/Contact/Index/1">Business</a>) qui est parfaite pour être sélectionnée en jQuery avec l'expression $("#leftColumn li a").

Pour mémoire, utiliser Ajax.NET et sa méthode Ajax.ActionLink(), c'est pas l'horrible soupe des WebForms, mais ça commence quand même à faire peur :

    <ul id="leftColumn">
        <li class="selected">
                <a groupid="1" href="/Contact/Index/1" onclick="Sys.Mvc.AsyncHyperlink.handleClick(this, new Sys.UI.DomEvent(event), { insertionMode: Sys.Mvc.InsertionMode.replace, updateTargetId: 'divContactList', onBegin: Function.createDelegate(this, beginContactList), onFailure: Function.createDelegate(this, failureContactList), onSuccess: Function.createDelegate(this, successContactList) });">Business</a>
        </li>
        <li>
                <a groupid="2" href="/Contact/Index/2" onclick="Sys.Mvc.AsyncHyperlink.handleClick(this, new Sys.UI.DomEvent(event), { insertionMode: Sys.Mvc.InsertionMode.replace, updateTargetId: 'divContactList', onBegin: Function.createDelegate(this, beginContactList), onFailure: Function.createDelegate(this, failureContactList), onSuccess: Function.createDelegate(this, successContactList) });">Friends</a>
        </li>
    </ul>

Faire les suppressions via jQuery

Là aussi, c'est un jeu d'enfant que de se débarrasser de Ajax.NET :

<script type="text/javascript">

    $(document).ready(function() {

        // Ajax Loading
        ...

        // Ajax Deletes
        $(".delete a").click(function() {
            var answer = confirm('Delete contact?');
            if (answer == true) {
                var url = $(this).attr("href");
                $("#divContactList")
                    .fadeOut()
                    .html($.ajax({
                        type: "DELETE",
                        url: url,
                        cache: false,
                        async: false
                    }).responseText)
                    .fadeIn();
            }
            return false;
        });

    });

</script>

On associe une fonction à tous les liens compris dans une classe "delete" (ce qui se dit $(".delete a").click(function() { ... }) en jQuery). Cette fonction commence par demander à l'utilisateur de confirmer qu'il veut bien supprimer le contact (ce qui se dit "Delete Contact?" en anglais). Si l'utilisateur répond par l'affirmative (answer == true), on fait disparaitre la balise contenant la liste des contacts, on remplace son contenu par le résultat d'une requête Ajax puis on la fait ré-apparaitre.

Et pour finir, on fait un return false; pour éviter que le navigateur suive le lien cliqué et atterrisse sur le formulaire de suppression qu'on a laissé là pour les navigateurs qui ne supportent pas le Javascript.

Pour information, j'ai utilisé le paramètre async: false pour attendre que la requête Ajax soit terminée côté serveur. En effet, ce n'est qu'à la fin de l'action AjaxDelete() que le contrôleur nous renvoie la liste des contacts mis à jour. Sans ce paramètre, le navigateur lancerait la requête Ajax puis mettrait immédiatement à jour le contenu de la divContactList, ce qui ne marcherait pas puisque la requête Ajax n'aurait encore rien renvoyé.

Mise à jour (12/11/2009)

Cette méthode présente un tout petit défaut. La fonction jQuery chargée d'ajaxifier les liens pour la suppression s'exécute une fois que la page a été chargée (c'est le propre de la méthode $(document).ready( ... )).

Le problème, c'est que lorsque on change de groupe, on remplace dynamiquement une partie du contenu de la page sans la recharger entièrement ! Et c'est là que le bât blesse. Les liens suppression du nouveau contenu ne sont donc pas ajaxifiés et se comportent de façon classique :

  • lien vers la vue destinée à faire confirmer la suppression du contrat
  • post vers l'action Delete du contrôleur Contact en cas de confirmation

Heureusement, ce n'est pas trop compliqué à corriger. Il suffit de penser à relancer le bout de code jQuery $(".delete a").click( ... ) une fois le contenu mis à jour.

Au final, cela donne donc le source javascript suivant :

<script type="text/javascript">

    $(document).ready(function() {

        // Ajax Loading
        $("#leftColumn li a").click(function() {
            $("#leftColumn li").removeClass('selected');
            $(this).parent().addClass('selected');
            var url = $(this).attr("href");
            $("#divContactList")
                .fadeOut()
                .load(url, function() {
                    $(this).fadeIn();
                    BindDelete();
                });
            return false;
        });

        // Ajax Deletes on page loading
        BindDelete();

    });

    function BindDelete() {
        // Ajax Deletes
        $(".delete a").click(function() {
            var answer = confirm('Delete contact?');
            if (answer == true) {
                var url = $(this).attr("href");
                $("#divContactList")
                    .fadeOut()
                    .html($.ajax({
                        type: "DELETE",
                        url: url,
                        cache: false,
                        async: false
                    }).responseText)
                    .fadeIn();
                    BindDetete();
            }
            return false;
        });
    }    

</script>

Billet suivant dans la série : Portage du tutoriel Contact Manager sous LINQ to SQL

lundi 2 novembre 2009

Utiliser Ajax avec ASP.NET MVC

C'est la dernière ligne droite : plus que 14 pages de tutoriel à avaler et je pourrai cocher la case "Finir le tutoriel de Gestion de Contacts" de ma totolist. Pour cette 7° partie, il va s'agir d'ajouter le support d'Ajax à notre gestion de contacts en ASP.NET MVC, dans le but d'améliorer ses performances et son ergonomie (en quelque sorte donner l'impression à l'utilisateur qu'il est en train d'utiliser une application Windows).

Les deux premières pages passent comme une lettre à la poste : pourquoi utiliser Ajax, ce qu'on va faire avec, Ajax c'est gentil... Il faut juste faire attention à la version du jQuery disponible dans le dossier Scripts.

Utilisation d'une vue partielle avec Ajax

Puis on s'attaque au vif du sujet pour modifier la vue Index des contacts, afin déviter de recharger toute la page juste quand on change de groupe de contacts.

Pour commencer, on utilise une vue partielle qui sera chargé d'afficher la table html contenant les contacts rattachés au groupe sélectionné. Concrètement, on fait un contrôle utilisateur .ASCX dans lequel on déporte tout le code générant la <table> qui existe actuellement dans la vue Index.aspx

Ce qui nous donne le fichier Views\Contact\ContactList.ascx suivant :

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<ContactManager.Models.Group>" %>
<%@ Import Namespace="Helpers" %>
<table class="data-table" cellpadding="0" cellspacing="0">
    <thead>
        <tr>
            <th class="actions edit">
                Edit
            </th>
            <th class="actions delete">
                Delete
            </th>
            <th>
                Name
            </th>
            <th>
                Phone
            </th>
            <th>
                Email
            </th>
        </tr>
    </thead>
    <tbody>
        <% foreach (var item in Model.Contacts)
           { %>
        <tr>
            <td class="actions edit">
                <a href='<%= Url.Action("Edit", new {id=item.Id}) %>'><img src="../../Content/Edit.png" alt="Edit" /></a>
            </td>
            <td class="actions delete">
                <a href='<%= Url.Action("Delete", new {id=item.Id}) %>'><img src="../../Content/Delete.png" alt="Delete" /></a>
            </td>
            <td>
                <%= Html.Encode(item.FirstName) %> <%= Html.Encode(item.LastName) %>
            </td>
            <td>
                <%= Html.Encode(item.Phone) %>
            </td>
            <td>
                <%= Html.Encode(item.Email) %>
            </td>
        </tr>
        <% } %>
    </tbody>
</table>

Et on remplace ce code par une simple ligne :

<% Html.RenderPartial("ContactList", Model.SelectedGroup); %>

Ce qui fait que notre fichier Contact\Index.aspx ne contient plus que le code ci-dessous :

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<ContactManager.Models.ViewData.IndexModel>" %>
<%@ Import Namespace="Helpers" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
        Index
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

<div class="container">
    
    <ul id="leftColumn">
    <% foreach (var item in Model.Groups) { %>
        <li<%= Html.Selected(item.Id, Model.SelectedGroup.Id) %>>
            <%= Html.ActionLink(item.Name, "Index", new { id = item.Id }) %>
        </li>
    <% } %>
    </ul>

    <div id="divContactList">
        <% Html.RenderPartial("ContactList", Model.SelectedGroup); %>
    </div>
    
    <div class="clear"></div>

</div>

</asp:Content>

Perso, je ne vais même pas plus loin et je lance le tout pour vérifier que tout fonctionne correctement. Il me faut quand même supprimer ContactList.ascx.cs mais au final ça marche sans problème.

Donc, même sans faire d'Ajax, cette vue partielle permet de simplifier le code dans le cas de vues un peu compliquées, en évitant de tout faire au même endroit...

Ensuite, si on veut vraiment faire de l'Ajax, il faut faire en sorte que quand on clique sur un des groupes dans la 1° colonne, au lieu de redemander l'action Index avec un autre groupId, on fasse seulement un appel à une fonction Ajax pour ne récupérer que le résultat de cette vue partielle avec les contacts correspondant au groupId sélectionné.

Pour cela, on remplace la méthode Html.ActionLink() par une méthode Ajax.ActionLink()

<%= Ajax.ActionLink(item.Name, "Index", new { id = item.Id }, new AjaxOptions { UpdateTargetId = "divContactList"})%>

Ce coup-ci, je ne me fais pas avoir et je vérifie directement pour voir s'il s'agit d'un Html Helper standard de ASP.NET MVC ou s'il a été fait à la main pour le tutoriel. Rien de nouveau dans le dossier Helpers => c'est du standard.

Donc, du côté de la vue on fait en sorte de faire un appel Ajax pour demander la mise à jour du contenu. Par conséquent, il faut qu'il y ait du répondant côté contrôleur. Pour cela, nous devons modifier l'action Index pour qu'elle gère le cas où elle est invoquée dans le cadre d'une demande Ajax.

Et dans ce cas, elle ne renvoie que la vue partielle destinée à afficher la liste des contacts et pas toute la vue Contact\Index.

public ActionResult Index(int? id)
{
    // Get selected group
    var selectedGroup = _service.GetGroup(id);
    if (selectedGroup == null)
        return RedirectToAction("Index", "Group");

    // Ajax Request
    if (Request.IsAjaxRequest())
        return PartialView("ContactList", selectedGroup);
    
    // Normal Request
    var model = new IndexModel
    {
        Groups = _service.ListGroups(),
        SelectedGroup = selectedGroup
    };
    return View("Index", model);
}

Je compile, j'exécute et tout marche comme prévu dans le tutoriel. Y compris le fait que le groupe sélectionné n'est pas mis en évidence (puisqu'il n'y a pas de classe "selected").

Ajout d'une animation jQuery

Pour résoudre le petit problème de CSS et aussi pour que l'utilisateur se rende compte qu'il se passe quelque chose, on va utiliser la librairie jQuery (qui est incluse dans le framework ASP.NET MVC) pour :

  • changer la classe CSS du groupe sélectionné
  • ajouter une animation pour que l'utilisateur comprenne qu'il se passe quelque chose

C'est assez simple à faire puisque dans les options Ajax on dispose des évènements OnBegin, OnSuccess et OnFailure :

<%= Ajax.ActionLink(item.Name, "Index", new { id = item.Id }, new AjaxOptions { UpdateTargetId = "divContactList", OnBegin = "beginContactList", OnSuccess = "successContactList", OnFailure = "failureContactList" })%>

Ce qui nous permet de définir les fonctions Javascript à appeller quand :

  • la requête Ajax va commencer
  • la requête Ajax s'est terminée correctement
  • la requête Ajax a échoué
<script type="text/javascript">

    function beginContactList(args) {
        // Highlight selected group
        $('#leftColumn li').removeClass('selected');
        $(this).parent().addClass('selected');
        // Animate
        $('#divContactList').fadeOut('normal');
    }

    function successContactList() {
        // Animate
        $('#divContactList').fadeIn('normal');
    }

    function failureContactList() {
        alert("Could not retrieve contacts.");
    }

</script>

Ajout du support de l'historique du navigateur

J'ai lu cette partie, mais c'est tout. Utiliser Ajax pour faire croire à un utilisateur qu'il est dans une application Windows puis faire des bidouilles interminables pour qu'il retrouve des automatismes du monde web, très peu pour moi.

Faire des suppressions en Ajax

Ca c'est quelque chose de beaucoup plus intéressant. Pas vraiment d'un point de vue interface utilisateur (je préfère afficher un écran de détail où on voit clairement la fiche qui va être supprimée si on confirme), mais plus pour le côté "si je veux faire une suppression, j'utilise une méthode HTTP DELETE".

Pour commencer, il faut remplacer l'image du lien "Supprimer" par un nouveau Helper ImageActionLinkHelper.cs créé spécialement. Son but est simplement de nous permettre d'avoir une méthode Ajax.ActionLink() associé à l'image utilisée pour matérialiser l'action de suppression.

Le nouveau helper Ajax.ImageActionLink() gère l'appel Ajax côté client et côté serveur on ajoute une nouvelle action AjaxDelete au contrôleur Contact.

[AcceptVerbs(HttpVerbs.Delete)]
[ActionName("Delete")]
public ActionResult AjaxDelete(int id)
{
    // Get contact to delete
    Contact contactToDelete = _service.GetContact(id);

    // Get group from the contact
    var selectedGroup = _service.GetGroup(contactToDelete.Group.Id);

    // Delete from database
    _service.DeleteContact(contactToDelete);

    // Return Contact List
    return PartialView("ContactList", selectedGroup);
}

Cette action a deux particularités :

  • L'attribut [AcceptVerbs(HttpVerbs.Delete)] la rend utilisable seulement dans le cas d'une opération HTTP DELETE (et donc pas via un simple GET ou POST)
  • L'attribut [ActionName("Delete")] sert à avoir un nom d'action ("Delete") qui est différent du nom de la méthode ("AjaxDelete")

Il est à noter que dans le cas où Javascript est désactivé, la suppression d'un contact continue de fonctionner :

  • un clic sur l'image "Supprimer" fait suivre le lien /Contact/Delete/123
  • le lien /Contact/Delete/123 correspond à l'action Delete(int id) d'origine en mode GET qui renvoie la vue Delete.aspx pour faire confirmer
  • un clic sur le bouton "Delete" pour confirmer effectue un POST vers /Contact/Delete/123
  • un post sur /Contact/Delete/123 correspond à l'action Delete(Contact contactToDelete) en mode POST qui supprime le contact et renvoie la vue Index.

Conclusion

Ouf ! Ca a été long (plus d'un mois) et pas toujours facile, mais j'ai finalement réussi à suivre ce tutoriel d'un bout à l'autre. Avec du recul, je me rend compte qu'il aurait été préférable de suivre les tutoriels ASP.NET MVC dans l'ordre, plutôt que d'attaquer directement avec l'exercice de fin de cours ! Mais il n'est jamais trop tard pour bien faire, surtout maintenant que la traduction française est disponible. Je vais donc pouvoir continuer mon apprentissage avant de retrouver le tutoriel NerdDinner.

Plus tard, j'aimerais utiliser cette application de Gestion de Contacts comme une base. Je pense qu'il serait intéressant de la refaire en remplaçant Entity Framework par LINQ to SQL et voir ce que cela change et en particulier si les différences sont bien cantonnées au niveau du repository. Et puis continuer en testant ça avec SubSonic ou NHibernate, en particulier avec Fluent NHibernate...


Billet suivant dans la série : Gestion de contacts avec ASP.NET MVC et jQuery

vendredi 30 octobre 2009

Test-Driven Development avec ASP.NET MVC (fin)

Cette fois je termine cette 6° partie du tutoriel de Gestion des Contacts. En un, il me reste pas grand chose à faire. En deux, j'utilise les grands moyens et je lance deux Visual Web Developper, un avec ma solution en cours de finition et un avec la solution "finie" de l'étape 6 du tutoriel.

Si je regarde au niveau du contrôleur des contacts, je constate que l'action Index a pas mal changé par rapport à ce qui existait avant. On est passé d'une seule ligne :

public ActionResult Index()
{
    return View(_service.ListContacts());
}

A tout ça :

public ActionResult Index(int? id)
{
    var model = new IndexModel
    {
        Groups = _service.ListGroups(),
        SelectedGroup = _service.GetGroup(id)
    };

    if (model.SelectedGroup == null)
        return RedirectToAction("Index", "Group");

    return View(model);
}

Si je tente de comprendre ce qui est fait :

  • on crée un objet de type IndexModel (à voir d'où il sort) et on défini sa propriété "Groups" avec la liste des groupes et sa propriété SelectedGroup avec un objet Group correspondant à l'identifiant passé en paramètre.
  • s'il n'y de groupe sélectionné, le contrôleur redirige l'utilisateur vers la gestion des groupes (soit vers l'action Index du contrôleur des groupes)
  • sinon, l'objet IndexModel est transmis à la vue Index pour affichage

Première étape, trouver à quoi correspond le type IndexModel. Il était bien planqué dans un sous-répertoire ViewData du dossier Models, mais j'ai fini par trouver à quoi ressemble IndexModel.cs :

using System.Collections.Generic;

namespace ContactManager.Models.ViewData
{
    public class IndexModel
    {
        public Group SelectedGroup { get; set; }
        public IEnumerable<Group> Groups { get; set; }
    }
}

C'est ni plus ni moins qu'une classe qui sert à contenir les 2 objets attendus par la nouvelle vue Contacts\Index.aspx :

  • la liste des groupes qui apparaitra dans le <ul id="leftColumn">
  • la liste des contacts qui sera rendue dans le <div id="divContactList">

Je fais pareil mais ça ne compile pas car la méthode GetGroup() attend un paramètre de type "int" alors que l'action Index ne peut que lui envoyer un paramètre de type "int?". Il me faut donc modifier la méthode GetGroup() du service et par conséquent celle du repository pour qu'elles gèrent un paramètre "int?" (sans oublier les interfaces et le FakeContactManagerRepository.cs).

En fait non. Il faut seulement modifier le GetGroup() du service pour qu'il prenne en compte le fait que le paramètre "id" peut être null :

public Group GetGroup(int? id)
{
    if (id.HasValue)
        return _repository.GetGroup(id.Value);
    return _repository.GetFirstGroup();
}

Ce coup-ci ça compile, mais ça ne s'exécute toujours pas :(

c:\MVC\ContactManager\ContactManager\Views\Contact\Index.aspx(13): error CS1061: 'System.Web.Mvc.HtmlHelper<ContactManager.Models.ViewData.IndexModel>' ne contient pas une définition pour 'Selected' et aucune méthode d'extension 'Selected' acceptant un premier argument de type 'System.Web.Mvc.HtmlHelper<ContactManager.Models.ViewData.IndexModel>' n'a été trouvée (une directive using ou une référence d'assembly est-elle manquante ?)

D'où qu'il sort ce "Selected" ? Normalement il ne devrait y avoir que des "SelectedGroup" dans Contacts\Index.aspx. Ou alors c'est pas ma faute parce que j'ai copié / collé du tutoriel. Je regarde quand même et que vois-je ?

<li <%= Html.Selected(item.Id, Model.SelectedGroup.Id) %>>

Oh! Un Html Helper. Voyons-voir s'il y a du nouveau dans le dossier Helpers ? Oh! un fichier SelectedHelper.cs

using System;
using System.Web.Mvc;

namespace Helpers
{
    public static class SelectedHelper
    {

        public static string Selected<T>(this HtmlHelper helper, T value1, T value2)
        {
            if (value1.Equals(value2))
                return "class=\"selected\"";
            return String.Empty;
        }
    }
}

J'ajoute ce fichier à mon projet, je recompile et j'exécute mais j'ai toujours une erreur.

c:\MVC\ContactManager\ContactManager\Views\Contact\Index.aspx(14): error CS1061: 'System.Web.Mvc.HtmlHelper<ContactManager.Models.ViewData.IndexModel>' ne contient pas une définition pour 'Selected' et aucune méthode d'extension 'Selected' acceptant un premier argument de type 'System.Web.Mvc.HtmlHelper<ContactManager.Models.ViewData.IndexModel>' n'a été trouvée (une directive using ou une référence d'assembly est-elle manquante ?)

Ok, j'ai oublié le <%@ Import Namespace="Helpers" %> dans Index.aspx. Je recompile, j'exécute et ça marche !

Je vais enfin pouvoir passer à la 7° partie du tutoriel et faire de l'Ajax. Mais là tout de suite, c'est le week-end !


Billet suivant dans la série : Utiliser Ajax avec ASP.NET MVC

jeudi 29 octobre 2009

Test-Driven Development avec ASP.NET MVC (suite)

Je dois avouer que j'ai pas mal dévié de l'objectif de cette 6° partie du tutoriel de Gestion des Contacts. Son but était d'illustrer l'intérêt du test-driven-development en prenant pour exemple l'ajout d'une notion de groupes de contacts. Comme je trouvais que la fin du tutoriel passait un peu vite sur la réalisation concrète des modifications réalisées et que je ne voulais pas me contenter de les récupérer toutes faites, j'ai pris ça comme un exercice pour compléter mon apprentissage d'ASP.NET MVC.

Première tentative

Dans ce billet, je vais donc continuer la modification de la base de données, c'est à dire ajouter une colonne groupId à la table des contacts et définir une relation entre les deux tables Contacts et Groups.

Je commence par l'ajout de la colonne groupId de type int, en prenant soin de définir sa propriété "Default Value or Binding" à 1. Cette petite astuce m'évite d'avoir à vider la table des contacts lorsque j'ajoute la relation entre cette nouvelle colonne et la table Groups créée la fois précédente.

Puis je m'occupe de la mise à jour du modèle. Mais ça ne se fait pas tout seul :( Je dois passer par la toolbox pour ajouter l'association entre les deux tables. Et même après ça, j'ai des erreurs de compilation ! je recommence, je bidouille, mais rien à faire, ça ne veut pas compiler...

Seconde tentative

Pour aller plus vite, je récupère la base de données (App_Data\ContactManagerDB*.*) et le modèle (Models\ContactManagerModel.*) qui datent d'avant l'ajout de la table "Groups" et je refais tout comme indiqué dans le tutorial (ou à peu près).

  • ajout de la table "Groups"
  • création d'un premier groupe "Business"
  • ajout de la colonne "groupId" à la table "Contacts", en mettant 1 comme "Default Value or Binding"
  • définition de la relation entre les deux tables
  • mise à jour du modèle

Et cette fois-ci, tout marche comme sur des roulettes ! Je vérifie que ça compile (Ok), je quitte VWD et je sauvegarde. On ne sait jamais...

Prise en compte de groupId dans la table Contacts

Pour la suite, je vais déjà tenter de faire apparaitre la liste des groupes dans les formulaires de création et de mise à jour des contacts, mais juste pour l'affichage, sans chercher à en tenir compte pour enregistrer les contacts.

Je commence par modifier la classe EntityContactManagerRepository.cs pour modifier les requêtes LINQ et leur ajouter les .Include() afin de gérer la relation entre les tables :

...
public Contact GetContact(int id)
{
    return (from c in _entities.ContactSet.Include("Group")
            where c.Id == id
            select c).FirstOrDefault();
}
...
public Group GetGroup(int id)
{
    return (from g in _entities.GroupSet.Include("Contacts")
            where g.Id == id
                    select g).FirstOrDefault();
}
...

J'exécute et je teste l'ajout d'un groupe "Friends" et ça passe. J'essaie alors de créer un nouveau contact mais là le clic sur le bouton "Create" n'a plus aucun effet. Un petit débugage m'apprend que _entities.SaveChanges() ne fonctionne plus.

Qu'à cela ne tienne, je fais une autre modification au repository pour associer le contact à créer au groupe par défaut juste avant de procéder à son enregistrement et là ça marche.

public Contact CreateContact(Contact contactToCreate)
{
    // Associate group with contact
    contactToCreate.Group = GetGroup(1);

    // Save new contact
    _entities.AddToContactSet(contactToCreate);
    _entities.SaveChanges();
    return contactToCreate;
}

Et ça marche même pour la mise à jour sans qu'il n'y ait rien d'autre à faire. Tant mieux.

Bon. Aux vues maintenant.

Mise à jour des vues contacts pour lister les groupes

Je démarre avec la vue Create.aspx en regardant comment c'est fait dans les sources finis de la 6° partie et en refaisant la même chose pas à pas.

Ajouter une dropdown list des groupes dans le formulaire Views\Contact\Create/aspx :

<p>
    <label for="GroupId">Group:</label>
    <%= Html.DropDownList("GroupId") %>
</p>

Tel quel, ça reste insuffisant et on obtient une erreur «There is no ViewData item with the key 'GroupId' of type 'IEnumerable<SelectListItem>'.» quand on essaie d'afficher le formulaire de création d'un contact.

Il faut donc que le contrôleur fasse passer la liste des groupes à la vue :

public ActionResult Create()
{
    if (!AddGroupsToViewData(-1))
        return RedirectToAction("Index", "Group");
    return View("Create");
}

protected bool AddGroupsToViewData(int selectedId)
{
    var groups = _service.ListGroups();
    ViewData["GroupId"] = new SelectList(groups, "Id", "Name", selectedId);
    return groups.Count() > 0;
}

La fonction AddGroupsToViewData() ajoute la liste des groupes à la collection ViewData et dans le cas où cette collection serait vide, le contrôleur redirige l'utilisateur vers la liste des groupes afin qu'il puisse y créer au moins un nouveau groupe.

Pour que cela fonctionne, il faut encore ajouter using System.Linq;, sinon la syntaxe groups.Count() n'est pas acceptée.

Après avoir vérifié que ça marche bien, j'en ai profité pour présenter les noms des groupes dans l'ordre alphabétique. Après pas mal de Google, j'ai trouvé comment faire :

public IEnumerable<Group> ListGroups()
{
    return _entities.GroupSet.OrderBy(o => o.Name).ToList();
}

C'est quand même plus joli.

Je continue donc avec la vue Edit.aspx où je refais quasiment la même chose.

Ca avance ! Je peux maintenant associer un contact au groupe de contacts de mon choix. Y'a plus qu'à faire en sorte que ce choix soit enregistré dans la base de données.

Mais avant, je ne résiste pas au plaisir de trier aussi la liste des contacts :

public IEnumerable<Contact> ListContacts()
{
    return _entities.ContactSet.OrderBy(o => o.FirstName).ThenBy(o => o.LastName).ToList();
}

Utilisation du groupe sélectionné pour créer un contact

Maintenant, je vais essayer de tenir compte du groupe sélectionné lors de la création d'un contact.

Pour cela, il faut théoriquement :

  • récupérer l'identifiant du groupe sélectionné dans le formulaire
  • utiliser cet identifiant au moment de l'enregistrement du contact, en lieu et place du "1" que j'avais mis en dur

Facile ! Si je regarde le code fini de la 6° partie, un paramètre int groupId a été ajouté à l'action Create de ContactController.cs.

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(int groupId, [Bind(Exclude = "Id")] Contact contactToCreate)
{
    if (_service.CreateContact(contactToCreate))
        return RedirectToAction("Index");
    AddGroupsToViewData(-1);
    return View("Create");
}

Et pour que ça marche, il faut aussi modifier ContactControllerTest.cs qui teste cette action en donnant la valeur 1 à ce nouveau paramètre.

Donc pour l'instant le contrôleur récupère l'identifiant du groupe qui a été choisi. Il faut ensuite trouver comment faire suivre cet identifiant au repository pour qu'il en tienne compte. J'aurai eu tendance à écrire contactToCreate.groupId = groupId, mais malheureusement l'objet Contact n'a pas de propriété groupId.

Il faut donc procéder pas à pas et commencer par faire passer le groupId du contrôleur au service : _service.CreateContact(contactToCreate) devient _service.CreateContact(groupId, contactToCreate), ce qui implique de modifier IContactManagerService.cs, ContactManagerService.cs et ContactManagerServiceTest.cs

Puis ensuite faire suivre le groupId du service au repository, ce qui nécessite la mise à jour de IContactManagerRepository.cs, EntityContactManagerRepository.cs et FakeContactManagerRepository.cs

1. ContactController.cs récupère groupId de la vue et l'envoie au service

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(int groupId, [Bind(Exclude = "Id")] Contact contactToCreate)
{
    if (_service.CreateContact(groupId, contactToCreate))
        return RedirectToAction("Index");
    AddGroupsToViewData(-1);
    return View("Create");
}

2. ContactManagerService.cs récupère groupId du contrôleur et l'envoie au repository

public bool CreateContact(int groupId, Contact contactToCreate)
{
    // Validation logic
    if (!ValidateContact(contactToCreate))
        return false;

    // Database logic
    try
    {
        _repository.CreateContact(groupId, contactToCreate);
    }
    catch
    {
        return false;
    }
    return true;
}

3. EntityContactManagerRepository.cs : récupère groupId du service et l'utilise pour créer le contact

public Contact CreateContact(int groupId, Contact contactToCreate)
{
    // Associate group with contact
    contactToCreate.Group = GetGroup(groupId);

    // Save new contact
    _entities.AddToContactSet(contactToCreate);
    _entities.SaveChanges();
    return contactToCreate;
}

Je lance l'application et crée un nouveau contact après avoir sélectionné "Friends" dans la dropdown list des groupes, je clique sur le bouton "Create" et ça marche ! Faut vérifier dans la table Contacts pour le croire mais c'est bien le cas.

Utilisation du groupe pour modifier un contact

Ca consiste très simplement à refaire à peu près pareil avec la vue Edit.aspx et les méthode Edit() qu'on trouve un peu partout dans le contrôleur, le service et le repository.

Déjà il faut faire en sorte que la vue Edit.aspx fasse apparaitre correctement le groupe auquel le contact a été rattaché. Pour cela, il suffit que le contrôleur lui donne la bonne information :

public ActionResult Edit(int id)
{
    var contactToEdit = _service.GetContact(id);
    AddGroupsToViewData(contactToEdit.Group.Id);
    return View(contactToEdit);
}

Au moins maintenant quand je vais en modification du contact que je viens de créer, c'est bien le "Friends" qui apparait sélectionné dans la dropdown list des groupes.

Reste plus qu'à refaire les 3 étapes pour que le groupe sélectionné au niveau de la vue Edit.aspx soit transmis jusqu'au niveau du repository.

1. ContactController.cs récupère groupId de la vue et l'envoie au service

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(int groupId, Contact contactToEdit)
{
    if (_service.EditContact(groupId, contactToEdit))
        return RedirectToAction("Index");
    AddGroupsToViewData(groupId);
    return View("Edit");
}

2. ContactManagerService.cs récupère groupId du contrôleur et l'envoie au repository

public bool EditContact(int groupId, Contact contactToEdit)
{
    // Validation logic
    if (!ValidateContact(contactToEdit))
        return false;

    // Database logic
    try
    {
        _repository.EditContact(groupId, contactToEdit);
    }
    catch
    {
        return false;
    }
    return true;
}

3. EntityContactManagerRepository.cs : récupère groupId du service et l'utilise pour mettre à jour le contact

public Contact EditContact(int groupId, Contact contactToEdit)
{
    // Get original contact
    var originalContact = GetContact(contactToEdit.Id);
    
    // Update with new group
    originalContact.Group = GetGroup(groupId);
    
    // Save changes
    _entities.ApplyPropertyChanges(originalContact.EntityKey.EntitySetName, contactToEdit);
    _entities.SaveChanges();
    return contactToEdit;
}

Quel progrès ! Je peux enfin rattacher un contact à un groupe au moment de sa création et même en changer lors de sa modification, et tout ça pour de vrai. Et sans compter que les tests unitaires sont toujours au vert.

Je touche au but

Pour enfin arriver au bout de cette 6° étape, il me reste juste à faire apparaitre les groupes au niveau de la vue Contact\Index.aspx qui affiche la liste des groupes. Malheureusement pour moi, le tutoriel n'a pas choisi la facilité en se contentant d'ajouter une colonne "Group" à cette liste mais a préféré filtrer la liste par groupe.

Juste pour le fun, je commence par essayer d'ajouter une colonne "Group" à la liste en modifiant Contact\Index.aspx :

<td>
    <%= Html.Encode(item.Group.Name) %>
</td>

Mais à l'exécution, ça ne passe pas et j'ai une erreur «La référence d'objet n'est pas définie à une instance d'un objet.» à cause de item.Group.Name. Je fais des progrès parce que je comprend de suite que la liste des contacts que le contrôleur ContactController.cs fait passer à la vue n'a pas été modifiée pour y faire apparaitre le groupe.

Une très légère modification à EntityContactManagerRepository.cs et c'est résolu :

public IEnumerable<Contact> ListContacts()
{
    return _entities.ContactSet.Include("Group").OrderBy(o => o.FirstName).ThenBy(o => o.LastName).ToList();
}

J'en serais bien resté là, mais la septième (et dernière !) étape du tutoriel va modifier cette vue Index pour utiliser Ajax. Si je veux vraiment aller jusqu'au bout de ce tutoriel il va falloir que j'en passe par le filtrage des contacts en fonction du groupe...

Je supprime donc mes modifications et c'est reparti.

Pour ce qui concerne la vue, je fais du copier / coller depuis le résultat du tutoriel, parce que c'est un peu tard et que je n'ai plus le courage de tout faire moi même. De toute façon, le principe est assez simple. On commence par faire deux blocs dans le html, le premier avec la liste des groupes (c'est le <ul id="leftColumn">) et le second avec la table des contacts (le <div id="divContactList">).

<ul id="leftColumn">
<% foreach (var item in Model.Groups) { %>
    <li <%= Html.Selected(item.Id, Model.SelectedGroup.Id) %>>
    <%= Html.ActionLink(item.Name, "Index", new { id = item.Id }) %>
    </li>
<% } %>
</ul>

<div id="divContactList">
    <table class="data-table" cellpadding="0" cellspacing="0">
        <thead>
            <tr>
                ...
            </tr>
        </thead>
        <tbody>
            <% foreach (var item in Model.SelectedGroup.Contacts)
               { %>
            <tr>
                ...
            </tr>
            <% } %>
        </tbody>
    </table>
</div>    

Puis on ajoute un peu de CSS pour faire flotter tout ça que les deux blocs apparaissent côte à côte.

F5 pour voir ce que ça donne... et c'est la grosse erreur :

c:\MVC\ContactManager\ContactManager\Views\Contact\Index.aspx(12): error CS1061: 'System.Collections.Generic.IEnumerable<ContactManager.Models.Contact>' ne contient pas une définition pour 'Groups' et aucune méthode d'extension 'Groups' acceptant un premier argument de type 'System.Collections.Generic.IEnumerable<ContactManager.Models.Contact>' n'a été trouvée (une directive using ou une référence d'assembly est-elle manquante ?)

Comme maintenant je commence à être doué, je vois très vite que la vue Index hérite toujours de System.Web.Mvc.ViewPage<IEnumerable<ContactManager.Models.Contact>> et que par conséquent il n'y a rien pour remplir la liste des groupes. Zut alors, il fallait aussi que je copie / colle la première ligne de l'Index.aspx du tutoriel.

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<ContactManager.Models.ViewData.IndexModel>" %>

Attendez-voir. Y'a un IndexModel. Il sort d'où lui ?

Je relance l'application pour la forme, mais je sais bien que ça va planter.

c:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\Temporary ASP.NET Files\root\01b85515\43ff20e2\App_Web_index.aspx.ebd9867.w7_tnpa3.0.cs(148): error CS0234: Le type ou le nom d'espace de noms 'ViewData' n'existe pas dans l'espace de noms 'ContactManager.Models' (une référence d'assembly est-elle manquante ?)

Finalement, c'est pas ce soir que je vais finir...


Billet suivant dans la série : Test-Driven Development avec ASP.NET MVC (fin)

mardi 20 octobre 2009

Test-Driven Development avec ASP.NET MVC (suite)

Enfin, ça y est, je vais enfin faire des trucs pour de vrai : modifier la base de données, écrire du code qui permet à l'utilisateur de réellement gérer des groupes de contacts... et venir à bout de la 6° partie du tutoriel pour gérer des contacts.

Mises à jour comme dans le tutoriel

La base de données : facile

Pour commencer, direction le "Database Explorer" pour modifier la base de données :

  • création d'une table "Groups" pour enregistrer les groupes de contacts
  • ajout d'une colonne "GroupId" à la table "Contacts" pour y stocker l'identifiant du groupe auquel est rattaché le contact
  • définition de la relation entre les tables "Contacts" et "Groups"

Le modèle : encore plus facile

Rien à ajouter au tutoriel.

Le Repositiry : ça se complique

Zut ! Le tutoriel donne bien les modifications à apporter repository (l'interface et la classe), mais pas un seul mot sur les autres modifications nécessaires...

  • Au début de cette 6° partie, on avait créé une classe Group à la main puisqu'on n'avait pas encore touché à la base de données. Ce coup-ci, pour que ça marche il faut la supprimer sinon on ne peut pas compiler parce que Missing partial modifier on declaration of type 'ContactManager.Models.Group'; another partial declaration of this type exists.
  • Les méthodes CreateContact() et EditContact() du repository attendent désormais un premier paramètre int groupId => il faut donc modifier le service ContactManagerService pour que celui-ci le leur fasse passer !
  • Qu'à cela ne tienne, un rapide coup d'oeil sur sur les sources finis de la 6° partie me confirme qu'il faut aussi faire apparaitre ce paramètre groupId au niveau du service : dans l'appel aux méthodes du repository mais aussi dans les signatures des méthodes du service
  • Et bien sûr, ça ne compile toujours pas puisque le contrôleur ContactController ne fait pas passer ce paramètre groupId aux méthodes de ContactManagerService...

Stop ! J'efface tout et je recommence pas à pas. En un, gérer la table des groupes. En deux, modifier la gestion des contacts pour tenir compte des groupes.

Mises à jour à ma façon

Une fois revenu en arrière :

  • mise à jour de la base de données en créant seulement la table "Groups", sans toucher à la table "Contacts"
  • mise à jour du modèle pour prendre en compte la nouvelle table (et pas la relation entre les tables "Groups" et "Contacts" puisqu'elle n'a pas été créée)
  • suppression de Models/Group.cs
  • mise à jour du repository pour la partie qui concerne les opérations sur la table "Groups"
  • mise à jour du FaleRepository pour que ça compile
  • un coup de tests unitaires et tout continue à être bon.

Ajout de la vue Index

Ca avance bien, donc je continue :

  • création d'une vue Groups/Index en automatique
  • modification de site.master pour ajouter un onglet "Manage Contact Groups"
  • compilation => ça marche, mais la liste est vide (normal puisque la table "Groups" est vide)

Passage par le "Database Explorer" (Show Table Data) pour ajouter un premier groupe "Business" et là la liste ressemble enfin à quelque chose. Et toujours rien de cassé niveau tests unitaires !

D'après ce que je vois dans la copie d'écran du tutoriel, la liste des groupes est un peu "spéciale". Elle ne contient pas de bouton "Edit" mais seulement un lien image pour supprimer. Et pas de lien pour créer un nouveau groupe, mais directement une zone de texte pour saisir le libellé d'un nouveau groupe et un nouton "Create" pour l'enregistrer.

Donc retour sur la vue Index pour essayer d'arriver à la même chose. Pour commencer, de simples copier / coller depuis la vue Index des Contacts suffisent pour modifier la liste. C'est juste un peu plus compliqué pour ajouter les contrôles nécessaires pour la création d'un nouveau groupe, mais pas infaisable.

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<IEnumerable<ContactManager.Models.Group>>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
        Index
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Index</h2>

    <%= Html.ValidationSummary() %>

    <table>
        <tr>
            <th class="actions delete">
                Delete
            </th>
            <th>
                Name
            </th>
        </tr>

    <% foreach (var item in Model) { %>
    
        <tr>
            <td class="actions delete">
                <a href='<%= Url.Action("Delete", new {id=item.Id}) %>'><img src="../../Content/Delete.png" alt="Delete" /></a>
            </td>
            <td>
                <%= Html.Encode(item.Name) %>
            </td>
        </tr>
    
    <% } %>

        <tr>
            <th>
                Add
            </th>
            <th>
                <%= Html.TextBox("Name") %>
                <input type="submit" value="Create" />
                <%= Html.ValidationMessage("Name", "*") %>
            </th>
        </tr>

    </table>

</asp:Content>

Et c'est OK pour l'interface utilisateur. Après avoir vérifié que le contrôleur a bien le code nécessaire pour gérer le Create, je compile, j'exécute et je tente de créer un nouveau groupe "Friends". Et ça ne marche pas :( J'ai beau m'acharner sur le bouton "Create", rien ne se passe...

Ok ! J'ai pas de balise <form> dans ma page ! Il faut penser à englober les contrôles <input> entre

<% using (Html.BeginForm("Create", "Group")) { %>

et

<% } %>

MVC, c'est pas encore automatique pour moi. Je recommence, resaisi "Friends", clique sur le bouton "Create" et boum !

Erreur du serveur dans l'application '/'.
--------------------------------------------------------------------------------
The view 'Create' or its master could not be found. The following locations were searched:
~/Views/Group/Create.aspx
~/Views/Group/Create.ascx
~/Views/Shared/Create.aspx
~/Views/Shared/Create.ascx 
Description : Une exception non gérée s'est produite au moment de l'exécution de la demande Web actuelle. Contrôlez la trace de la pile pour plus d'informations sur l'erreur et son origine dans le code. 

Quoi encore ? Pourquoi diable est-ce que ça veut afficher une vue Create ? J'ai rien demandé moi !

...

Ca y est, j'ai trouvé le coupable dans GroupController.cs :

public ActionResult Create(Group groupToCreate)
{
    if (_service.CreateGroup(groupToCreate))
        return RedirectToAction("Index");
    return View("Create");
}

Il y a un return View("Create"); alors que la création se fait dans la vue Index => à remplacer par return View("Index");.

Je re-lance, re-saisi "Friends", re-clique sur "Create" et re-boum. Mais ce coup-ci je me retrouve dans le débugueur avec une erreur «NullReferenceException was Unhandled by user code» sur la ligne <% foreach (var item in Model) { %>.

...

Ca va bien finir par marcher. Mais pour gagner du temps, je vais pomper sur le source de GroupController.cs du tutoriel où il y a un return View("Index", _service.ListGroups());. J'aurai pu trouver ! Dans l'action Index il y a déjà un return View(_service.ListGroups());. C'est sûr que si je renvoie la vue Index sans lui passer la liste des groupes, ça peut pas marcher...

Je re-lance, re-saisi "Friends", re-clique sur "Create" et voilà-t-y pas que ça me dit «A value is required.» (en rouge en plus). Je re-lance, re-saisi "Friends" (des fois que le coup d'avant j'ai oublié ou qu'il aurait pas bien compris), re-clique sur "Create" et pareil.

C'est parce que je suis idiot. Comment qu'elle fait mon action Create si je lui dit pas de récupérer les valeurs du POST pour fabriquer l'objet Group à insérer ? Je regarde comment fait le Create dans ContactController.cs, copie / colle et c'est parti :

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create([Bind(Exclude = "Id")]Group groupToCreate)
{
    if (_service.CreateGroup(groupToCreate))
        return RedirectToAction("Index");
    return View("Index", _service.ListGroups());
}

Je re-lance, re-saisi "Friends", re-clique sur "Create" et euréka ! J'ai réussi à insérer un nouveau groupe !!!

Je re-lance, ne saisi rien dans la zone texte, clique sur le bouton "Create" et j'ai bien un message «Name is required.» pour m'apprendre à vouloir créer un groupe sans lui donner de nom.

Je re-lance, clique sur l'image "Delete" devant le groupe "Friends" et ça me sort une erreur «La ressource est introuvable.». Ah ouais, j'ai pas encore géré la suppression.

Ajout de la suppression d'un groupe

Avant d'attaquer ça, je refais vite un coup de tests unitaires pour vérifier que tout continue de fonctionner comme prévu et ouf! rien de cassé.

J'ajoute l'action Delete au contrôleur GroupController.cs, en faisant tout comme dans GroupContact.cs :

public ActionResult Delete(int id)
{
    var groupToDelete = _service.GetGroup(id);
    return View(groupToDelete);
}

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Delete(Contact groupToDelete)
{
    if (_service.DeleteGroup(groupToDelete))
        return RedirectToAction("Index");
    return View();
}

Un filet rouge sous _service.GetGroup et _service.DeleteGroup me rappelle que ces deux méthodes ne sont pas encore implémentées dans ContactManagerService.cs (et pas déclarées dans IContactManagerService.cs par la même occasion).

public bool DeleteGroup(Group groupToDelete)
{
    // Database logic
    try
    {
        _repository.DeleteGroup(groupToDelete);
    }
    catch
    {
        return false;
    }
    return true;
}

public Group GetGroup(int id)
{
    return _repository.GetGroup(id);
}

Il me reste alors à créer une vue Delete (strongly-typed view, empty content) puis à m'inspirer de la vue Contact/Delete.aspx pour la compléter :

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<ContactManager.Models.Group>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
        Delete
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Delete</h2>

    <p>
    Are you sure that you want to delete the entry for
    <%= Model.Name %>
    </p>

    <% using (Html.BeginForm(new { Id = Model.Id }))
       { %>
       <p> 
            <input type="submit" value="Delete" /> &nbsp; <%=Html.ActionLink("Cancel", "Index") %>
        </p>
    <% } %>

</asp:Content>

Je lance, clique sur l'image "Delete" devant le groupe "Friends", clique sur le bouton "Delete" pour confirmer et plus de groupe "Friends". Trop fort, je peux aussi supprimer un groupe !!!

Je re-lance, clique sur l'image "Delete" devant le groupe "Business", clique sur le lien "Cancel" pour annuler et le groupe "Business" est toujours là. Des fois, l'informatique ça marche comme on voudrait...

Allez hop ! Encore un coup de tests unitaires pour me conditionner et c'est tout pour aujourd'hui.


Billet suivant dans la série : Test-Driven Development avec ASP.NET MVC (suite)

vendredi 9 octobre 2009

Test-Driven Development avec ASP.NET MVC (suite)

Je continue ma formation ASP.NET MVC avec la suite de la 6° partie du tutoriel pour gérer des contacts qui est consacrée à la Programmation Pilotée par les Tests (ou Test-Driven Development en anglais).

Point de départ

Jusqu'ici, j'ai donc abordé les deux premières étapes sur les trois que compte la méthode TDD :

  1. Ecrire un test unitaire qui échoue (Rouge)
  2. Ecrire un code qui passe le test unitaire avec succès (Vert)
  3. Revoir l’architecture de votre code (Refactoring)

Pour résumer l'épisode précédent, il fallait modifier l'application des gestion de contacts pour ajouter une notion de groupe de contacts. Comme point de départ, on avait 3 scénarios utilisateur (lister les groupes, créer un groupe et valider un groupe) et on a appliqué les 2 premières étape du TDD :

  • Etape 1 : on écrit des tests unitaires pour vérifier les fonctionnalités attendues (et ces tests échouent car non compilables)
  • Etape 2 : on écrit le minimum de code pour que les tests unitaires réussissent

Refactoring

J'en arrive donc à l'étape 3 : revoir l’architecture du code (refactoriser). Aux deux premières étapes du TDD, l'objectif est de se concentrer sur les tests unitaires destinés à vérifier que les fonctionnalités sont biens implémentées, sans se prendre la tête sur la "bonne" façon de faire. Ce n'est qu'à partir de cette troisième étape qu'il faut chercher à produire du "bon" code. Et on peut toucher à notre premier jet de code l'esprit libre, puisqu'on s'est blindé grâce à nos tests unitaires qui vont nous empêcher de casser quoi que ce soit d'important.

Dans le cas présent, le refactoring va consister à réviser le code du contrôleur GroupController qui mélange un peu tout pour le rendre plus conforme au Single Responsibility Principle comme on l'avait déjà fait dans la 4° partie du tutoriel pour le contrôleur ContactController.

Pour cela, on va modifier le contrôleur pour qu'il utilise la couche de service ContactManagerService que l'on avait mis en place pour le contrôleur ContactController. C'est assez simple et il suffit de taper le code source fourni dans le tutoriel. Il y manque quelques using ContactManager.Models.Validation; et il faut bien penser à modifier aussi les fichiers interfaces, mais c'est OK pour la 3° étape du TDD.

Ca avance, mais je me pose quand même des questions.

  • Est-ce que ça prendrait vraiment beaucoup plus de temps si on avait fait "bien" dès le premier coup ?
  • Et même, est-ce qu'une fois qu'on a l'habitude d'utiliser les couches service et repository, est-ce que ce n'est pas plus long de se souvenir comment écrire du code qui ne s'en sert pas ?
  • Et surtout, il n'y a pas de 4° étape dans le TDD et pourtant je n'ai encore rien qui gère des groupes de contacts pour de "vrai" !

Coup d'oeil en arrière

Sinon, cette partie du tutoriel m'a fait prendre conscience d'un truc auquel je n'avais pas fait attention lors de la partie sur le refactoring. En fait, la classe repository et la classe service servent pour toute l'application et pas seulement dans le cas de la table Contact. C'est d'ailleurs pour ça qu'elles s'appellent ContactManagerRepository et ContactManagerService et pas seulement ContactRepository et ContactService.

Ca non plus, je sais pas trop si ça me plait bien. J'ai un peu peur qu'à la fin on risque de se retrouver avec un source énorme si notre application doit gérer un très grand nombre de tables. Sans compter que ça devient génant dans le cas des FakeRepository où on devra donc tout implémenter à coup de throw new NotImplementedException() (et revenir dessus en permanence dès qu'on ajoutera des signatures à l'interface).

Mais je suppose qu'il n'est pas totalement incongru d'avoir des "sous" repository pour différents tables (ou peut-être des classes partielles ?). A voir...

Sous le coude

Je vais continuer avec la suite de cette 6° partie qui va enfin s'attaquer à la réalisation des fonctionnalités pour de "vrai". Mais si j'ai un peu de temps, j'aimerai bien essayer d'approfondir tout ça pour voir comment implémenter les autres scénarios utilisateurs selon la méthode TDD :

  • L’utilisateur peut supprimer un groupe de contacts existant
  • L’utilisateur peut sélectionner un groupe lorsqu’il crée un nouveau contact
  • L’utilisateur peut sélectionner un groupe lorsqu’il édite un contact existant
  • La liste des groupes de contacts est affichée dans la vue Index
  • Lorsqu’un utilisateur clique sur un groupe, la liste de contacts associée est affichée

(Bien que pour l'instant je ne vois pas trop quel test unitaire faire pour vérifier la suppression d'un groupe de contacts.)

Ou en attendant tout ça, deux liens assez intéressant sur les TDD :


Billet suivant dans la série : Test-Driven Development avec ASP.NET MVC (suite)

mardi 6 octobre 2009

Test-Driven Development avec ASP.NET MVC

Dans la sixième étape du tutoriel pour créer une application de gestion de contacts avec ASP.NET MVC, le but est de faire encore mieux que de simples tests unitaires et d'apprendre à développer selon les principes du "test-driven development" (= programmation pilotée par les tests) qui implique d'écrire d'abord les tests unitaires puis ensuite le "vrai" code fonctionnel en respectant le moule des tests unitaires. L'idée, c'est qu'à la fin du codage, on a forcément un code qui fait correctement ce qu'on avait prévu de faire (et accessoirement que ce qui était prévu de faire).

Par rapport aux parties précédentes, je vais détailler un peu plus ce que j'ai fait, parce que j'ai eu plus de mal à en venir à bout :

  • c'est assez compliqué, ou en tout cas assez nouveau pour moi
  • il ya sans doute quelques petites erreurs dans le code fourni en exemple
  • les modifications réalisées ne sont pas toujours toutes expliquées

Par contre, je ne vais pas revenir pas sur l'intérêt du TDD, la façon de le mettre en œuvre ou tous les avantages que cela apporte, mais plutôt sur la façon dont se déroule le tutoriel.

1° scénario : lister les groupes de contacts

Le premier contact avec le TDD a pour but de répondre au user story (= scénario utilisateur) suivant : "L’utilisateur peut voir une liste de groupes de contacts".

Je commence donc par taper le premier test unitaire destiné à tester que la méthode Index() du contrôleur Group renvoie bien un ensemble de groupes.

Mais comme pour l'instant, il n'y a pas encore de "vrai" code, l'IntelliSense ne m'aide pas vraiment voire me complique la vie en remplaçant GroupController() par GroupControllerTest() dès que je je tape la parenthèse ouvrante !

Mais bon, c'est le but... Le fait que rien ne marche (et que l'application ne compile plus) constitue la 1° étape du TDD, à savoir "écrire un test unitaire qui échoue". 1° étape TDD : OK (je trouve quand même que c'est un raisonnement un peu tiré par les cheveux).

Le bon côté des choses, c'est que cela m'a au moins permis d'identifier une petite différence entre MSTest :

Assert.IsInstanceOfType(result.ViewData.Model, typeof(IEnumerable));

et NUnit :

Assert.IsInstanceOfType(typeof(IEnumerable), result.ViewData.Model);

Le truc important, c'est que l'ordre des paramètres est inversé. Et accessoirement, cette méthode IsInstanceOfType est maintenant obsolète avec NUnit et il faudrait employer :

Assert.IsInstanceOf(typeof(IEnumerable), result.ViewData.Model);

Quoiqu'il en soit, pour passer à l'étape suivante du TDD ("Ecrire un code qui passe le test unitaire avec succès"), il faut au minimum réussir à compiler. Pour cela, il faut modifier le projet pour ajouter la classe Controllers\GroupController.cs :

using System.Collections.Generic;
using System.Web.Mvc;
using ContactManager.Models;

namespace ContactManager.Controllers
{
    public class GroupController : Controller
    {
        //
        // GET: /Group/

        public ActionResult Index()
        {
            var groups = new List();
            return View(groups);
        }

    }
}

Mais c'est malheureusement toujours impossible à compiler, puisqu'on obtient le message d'erreur Using the generic type 'System.Collections.Generic.List<T>' requires '1' type arguments. C'est sans doute pour cela que le tutoriel fait aussi saisir la classe Models\Group.cs (sinon elle semblait servir à rien) :

namespace ContactManager.Models
{
    public class Group
    {
    }
}

Il suffit alors de modifier la classe GroupController.cs pour y remplacer la ligne var groups = new List(); par var groups = new List<Group>(); pour que ça compile et que le test unitaire soit passé avec succès. 2° étape TDD : OK (déjà 5 pages de passées sur 21, ça avance vite!).

Pour relativiser un peu, on a "juste" mis au point la façon de vérifier que notre futur contrôleur GroupController aura bien une méthode Index() qui renverra une liste d'objets de type Group.

2° scénario : créer un groupe de contacts

Avant de passer à la programmation du "vrai" code, on remet ça avec la prise en compte d'un second scénario utilisateur, à savoir : "L’utilisateur peut créer un nouveau groupe de contacts".

Pour ça, il faudra donc que le contrôleur GroupController ait une méthode Create et que l'utilisation de cette méthode ajoute bien un nouveau groupe dans la liste des groupes. On va donc commencer par écrire un test unitaire qui contrôle ça.

[TestMethod]
public void Create()
{
    // Arrange
    var controller = new GroupController();
    // Act
    var groupToCreate = new Group();
    controller.Create(groupToCreate);
    // Assert
    var result = (ViewResult)controller.Index();
    var groups = (IEnumerable<Group>)result.ViewData.Model;
    CollectionAssert.Contains(groups.ToList(), groupToCreate);
}

Encore une fois, la 1° étape du TDD est OK par KO : le test échoue puisqu'on ne peut pas compiler. On peut donc se précipiter pour écrire le minimum du code nécessaire pour que le test réussisse. Pour ça, on ajoute une méthode action Create() à notre contrôleur GroupController et celle-ci ajoute l'objet Group qui lui est passé en paramètre à la collection des groupes. Pour que tout fonctionne, la collection des groupe est désormais renvoyée par l'action Index().

Notre contrôleur GroupController.cs contient donc le code suivant :

using System.Collections.Generic;
using System.Web.Mvc;
using ContactManager.Models;

namespace ContactManager.Controllers
{
    public class GroupController : Controller
    {
        private IList<Group> _groups = new List<Group>();

        public ActionResult Index()
        {
            return View(_groups);
        }

        public ActionResult Create(Group groupToCreate)
        {
            _groups.Add(groupToCreate);
            return RedirectToAction("Index");
        }
    }
}

Zut! Ca ne compile toujours pas ! Il y a un problème au niveau de la ligne

CollectionAssert.Contains(groups.ToList(), groupToCreate);

dans la classe de test GroupControllerTest.cs :

'System.Collections.Generic.IEnumerable<ContactManager.Models.Group>' does not contain a definition for 'ToList' and no extension method 'ToList' accepting a first argument of type 'System.Collections.Generic.IEnumerable<ContactManager.Models.Group>' could be found (are you missing a using directive or an assembly reference?)

J'efface .ToList(), je colle un point après groups et l'IntelliSense me propose Equals, GetEnumerator, GetHashCode, GetType et ToString. C'est ma foi vrai qu'il n'y a pas de ToList() ! Que faire ? Je tente le coup en laissant juste :

CollectionAssert.Contains(groups, groupToCreate);

Ca compile et le test réussit. Que demander de plus ? Ca fait ma seconde 2° étape TDD OK de la journée. La suite sera pour plus tard...

Mise à jour du 2/12/9 : En fait, il manquait un using System.Linq; au début de GroupControlletTest.cs pour pouvoir utiliser .ToList().

3° scénario : valider la création d'un groupe de contacts

(Plus tard) Pour bien faire les choses, notre création d'un nouveau groupe de contacts est un peu limite. On ne peut pas en créer un comme ça à la bonne franquette, sans vérifier ce que saisi l'utilisateur. Ce qui implique un nouveau scénario utilisateur : "L’utilisateur ne peut pas créer un nouveau groupe de contacts sans lui donner un nom".

C'était pas une fonctionnalité prévue dans les scénarios utilisateurs du départ, mais cela semble logique. Soyons réactifs, on fait du TDD, pas du Cycle en V !

On écrit donc un test unitaire "CreateRequiredName" qui va s'assurer que l'on a bien une erreur quand on essaie créer un groupe de contacts sans lui donner de nom.

[TestMethod]
public void CreateRequiredName()
{
    // Arrange
    var controller = new GroupController();
    // Act
    var groupToCreate = new Group();
    groupToCreate.Name = String.Empty;
    var result = (ViewResult)controller.Create(groupToCreate);
    // Assert
    var error = result.ViewData.ModelState["Name"].Errors[0];
    Assert.AreEqual("Name is required.", error.ErrorMessage);
}

Attention, il faut également modifier le test unitaire "Create" précédent pour renseigner le nom du groupe afin qu'il continue à fonctionner :

...
// Act
var groupToCreate = new Group();
groupToCreate.Name = "Test";
controller.Create(groupToCreate);
...

Ca ne compile plus <=> le test unitaire échoue => on peut coder vite fait quelque chose pour le passer. (Finalement, c'est assez rigolo comme méthode).

Déjà, pour que cela ait une chance de marcher, il faut ajouter une propriété Name à notre classe Group. Pour cela, on a seulement besoin d'insérer une ligne pour définir une propriété automatique :

namespace ContactManager.Models
{
    public class Group
    {
        public string Name { get; set; }
    }
}

Ca re-compile, le test "Create" réussi toujours mais le test "CreateRequiredName" échoue => c'est pas fini : il faut encore vérifier que le nom du groupe n'est pas vide avant de le créer. Pour cela, il suffit d'ajouter quelques lignes à l'action "Create" du contrôleur GroupController :

public ActionResult Create(Group groupToCreate)
{
    // Validation logic
    if (groupToCreate.Name.Trim().Length == 0)
    {
        ModelState.AddModelError("Name", "Name is required.");
        return View("Create");
    }
    // Database logic
    _groups.Add(groupToCreate);
    return RedirectToAction("Index");
}

Mission accomplie : ça continue de compiler et tous les tests unitaires sont réussis.

Petit bilan avant de continuer

On progresse peu à peu sur la voie du développement piloté par les tests : création des scénarios utilisateur, écriture des tests unitaires et codage à la hussarde pour respecter ces tests. Mais il ne faut pas perdre de vue que ce n'est que le tout début du chemin :

  • la façon dont on a codé c'est du ni fait ni à faire...
  • on ne peut a toujours pas créer un groupe de contacts pour de "vrai" !

Billet suivant dans la série : Test-Driven Development avec ASP.NET MVC (suite)

lundi 28 septembre 2009

ASP.NET MVC et les tests unitaires

La cinquième étape du tutoriel pour développer une application de gestion de contacts avec ASP.NET MVC concerne les tests unitaires : à quoi cela sert, comment les utiliser et les créer... puis aborde la réalisation d'un premier jeu de tests unitaires pour contrôler le bon fonctionnement de la couche de service et un autre pour vérifier le fonctionnement du contrôleur.

Rendre NUnit compatible avec Visual Studio Unit Test

Pour commencer, comme je fais mes essais avec Visual Web Developer et pas Visual Studio 2008, je ne peux pas utiliser Visual Studio Unit Test, le framework de test par défaut. A sa place, j'utilise NUnit et pour l'instant je n'ai pas rencontré trop de problème (mais il est vrai que je n'ai pas encore fait grand chose...).

Ce coup-ci, cela devient un peu plus sérieux et il s'agit dans un premier temps de saisir une ou deux pages de tests unitaires pour tester le service ContactManagerService. J'ai carrément triché et fait du copié / collé pour d'avoir tout à saisir moi-même, mais le plus embêtant, c'est que le code proposé par le tutoriel ne fonctionne pas avec NUnit :(

Premier truc pas si compliqué à trouver : il faut supprimer la référence au framework Visual Studio Unit Test

using Microsoft.VisualStudio.TestTools.UnitTesting;

et la remplacer par une référence à NUnit :

using NUnit.Framework;

C'était malin, mais pas suffisant... Le couple VWD + NUnit n'aime pas du tout les attributs [TestClass], [TestInitialize] et [TestMethod] et me le fait savoir :

  • The type or namespace name 'TestClassAttribute' could not be found
  • The type or namespace name 'TestClass' could not be found
  • The type or namespace name 'TestInitializeAttribute' could not be found
  • The type or namespace name 'TestInitialize' could not be found
  • The type or namespace name 'TestMethodAttribute' could not be found
  • The type or namespace name 'TestMethod' could not be found

Après quelques recherches, j'ai finalement trouvé la solution chez static void (NUnit/MSTest Dual Testing). Il suffit d'ajouter quelques "usings" au début du source pour que NUnit accepte les mêmes attributs que VS Unit Test :

using TestClass = NUnit.Framework.TestFixtureAttribute;
using TestMethod = NUnit.Framework.TestAttribute;
using TestInitialize = NUnit.Framework.SetUpAttribute;
using TestCleanup = NUnit.Framework.TearDownAttribute;
using TestContext = System.Object;

Encore deux petites remarques :

  • VWD ne proposant pas l'option "Add / New Test / Unit Test", il faut choisir "Add / New Item / Class"
  • La méthode .Expect() de Moq a été remplacée par .Setup()

Sur quoi faire porter les tests unitaires

Sinon, l'étude de cette cinquième partie m'en a un peu plus appris sur l'intérêt des tests unitaires, notamment sur quoi les faire porter (la couche métier) ou plus exactement sur quoi "éviter" de les faire poster (la présentation et l'accès aux données). L'idée, c'est que les tests unitaires doivent s'exécuter très très très rapidement, sinon on risque de les laisser tomber, sauf si on est très joueur...

unit-testing.png

Il ne faut donc pas faire de tests unitaires sur l'interface utilisateur, parce que puisque on est en ASP.NET, cela exige le lancement d'un serveur Web ce qui ralentira la réalisation des tests. C'est une des raisons qui fait que MVC est "mieux" que les WebForms. Dans MVC, la View qui est la partie interface utilisateur n'est pas faite pour contenir du code (ce qui fait qu'on a pas à le tester !). Dans le cas des WebForms, où tout est plus ou moins mélangé, il est plus difficile d'isoler le code de la présentation.

Il ne faut pas non plus faire de tests sur l'accès aux bases de données. Si on doit en passer par une vraie base de données, le temps d'exécution des tests unitaires va devenir beaucoup trop long. Et c'est là que l'empilement des couches évoqué dans la partie précédente dévoile tout son intérêt. On va "émuler" l'accès à la base de données grâce à des classes Mock qui simulent l'implémentation de l'interface IRepository vue précédemment, sans réellement interagir avec la base de données. Par conséquent, la réalisation de cette interface IRepository ne doit pas être ressentie comme un niveau de complexité supplémentaire et inutile mais comme une étape clé pour faciliter la mise en place de tests unitaires.

Sous le coude

La sixième étape du tutoriel va consister à ajouter une fonctionnalité à la gestion des contacts. Cela promet d'être très intéressant car on va pour cela bouleverser l'application en introduisant la notion de groupe de contacts. Et ce coup-ci on va d'abord créer les tests unitaires à priori, dans le but d'encadrer la programmation des modifications.

Et à lire pour plus tard, un article d'une quinzaine de pages en français sur les tests unitaires en pratique par Patrick Smacchia. Dans un premier temps, il présente comment réaliser des tests unitaires et les bénéfices que l'on peut en retirer. La seconde partie est consacrés aux problèmes de mise en oeuvre que l'on peut rencontrer (il aborde un peu plus en détail les problématiques liées à l'accès à la base de données) et comment les résoudre.


Billet suivant dans la série : Test-Driven Development avec ASP.NET MVC

mercredi 23 septembre 2009

Troisième étape avec ASP.NET MVC

Ces derniers temps, j'ai eu du mal à avancer sur dans le tutoriel NerdDinner où je patine un peu sur la partie intitulée "Building the model". Celle-ci est consacré à la partie "Model" du triptyque Model-View-Controller. Au début, c'est plutôt simple : on utilise LINQ To SQL pour créer automatiquement des classes de données basées sur les tables de la base de données. Puis ça commence à se compliquer quand on fait intervenir le pattern Repository et encore un peu plus avec le pattern de validation.

Pour essayer de bien assimiler ces techniques, j'ai retravaillé une ou deux fois sur cette partie. Et pour mettre plus de chances de mon côté, j'ai décidé de faire un petit détours par le tutoriel Building a Contact Management ASP.NET MVC Application. Par rapport au NerdDinner, son avantage est qu'il en existe une version française : Développement d’une application de gestion de contacts avec ASP.NET MVC. C'est quand même plus pratique pour faciliter la compréhension de nouveaux concepts.

La fil rouge de ce tutoriel, c'est de construire une petite application pour gérer un carnet d'adresses tout simple, avec nom, prénom, numéro de téléphone et adresse mél.

  1. La première étape est plutôt copieuse puisqu'elle consiste à créer une première version assez complète de l'application qui permette la consultation, l'ajout, la modification et la suppression de contacts. Le but de cette première partie est de nous montrer que c'est quand même vachement rapide de faire une application avec ASP.NET MVC. Et c'est aussi un bon support pour introduire les notions qui seront abordées dans les 6 étapes suivantes.
  2. La deuxième étape est plus tranquille. Elle nous montre combien il est facile de modifier la charte graphique d'une application en ASP.NET MVC, soit en utilisant une charte graphique toute faite de la Design Gallery de Microsoft, soit en créant sa propre charte graphique. Et c'est vrai que c'est quand même mieux foutu que pour styler le code généré par les contrôles WebForms et sans parler de leurs satanés fichiers skin !
  3. Dans la troisième étape, on ajoute des règles de validation aux formulaires. Là encore, c'est quelque chose d'assez simple : on ajoute une procédure pour tester l'objet saisi et les méthodes Html.ValidationSummary() et Html.ValidationMessage() se chargent automatiquement du reste. Dans le tutoriel NerdDinner, comme c'est beaucoup plus axé sur le pattern de validation, j'étais un peu passé à côté de la la simplicité fondamentale du système.
  4. Et puis arrive la quatrième étape où cela commence à se corser. L'objectif de cette partie est de passer d'un code écrit un peu à la va-vite à un code qui soit plus digne d'une application professionnelle, afin qu'elle soit plus facilement modifiable et maintenable. Et comme dans la 4° partie du NerdDinner, j'y retrouve les modèles de conception et notamment le pattern Repository.

Le fait que ce coup-ci cela soit en français, ça aide, mais ça ne fait pas tout. J'ai du m'accrocher pour réussir à tout ingurgiter et finir par maitriser comment tout ça s'articule. Pour y parvenir, j'ai finalement ré-écrit toute cette partie à ma façon.

Pour cela, j'ai essayé de plus insister sur les modifications apportées au fur et à mesure au code de départ, pour bien faire ressortir les évolutions apportées à la classe contrôleur :

  • utilisation du pattern "Repository" pour sortir le code d'accès aux données du contrôleur,
  • création d'une couche de "Service" pour gérer la validation et interagir avec le repository,
  • mise en oeuvre de "l'injection de dépendance" pour que l'application soit faiblement couplée,
  • utilisation du pattern "Decoration" pour que la couche de service ne soit pas liée à ASP.NET MVC.

Pour ceux que cela intéresse, voici ma version de l'étape 4 pour rendre l’application de Gestion de contacts faiblement couplée.

Il me semble avoir assez bien compris comment ça marche et les avantages que cela peut apporter, même si je reste un peu dubitatif devant l'empilement de couches que cela implique. Pour l'instant, je suis donc raisonnablement convaincu mais pas encore totalement enthousiasmé.

En attendant, j'ai maintenant l'embarras du choix pour continuer mon apprentissage d'ASP.NET MVC : soit je refait un passage par NerdDinner, soit je poursuis avec la cinquième étape en français de Contact Management...


Billet suivant dans la série : ASP.NET MVC et les tests unitaires

jeudi 30 juillet 2009

Seconds pas avec ASP.NET MVC et NerdDinner

La fois d'avant, j'avais seulement fait la toute première étape du tutoriel NerdDinner de Scott Guthrie, à savoir :

  • créer le projet NerdDinner,
  • vérifier qu'il fonctionnait bien comme prévu,
  • exécuter les tests unitaires avec NUnit.

Ce coup-ci, je me suis attaqué aux deux parties suivantes qui concernent la base de données :

  • Creating the Database
  • Building the Model

La création de la base de données s'est très bien passée. Le seul truc à noter c'est que la fenêtre "Server Explorer" s'appelle en fait "Database Explorer" sous VWD. Pour la création des tables, pas de problème non plus, si ce n'est que c'est un peu fastidieux. Je me demande si écrire un bête script SQL ne serait pas tout aussi pratique... En tout cas, pour l'ajout des données, ils auraient quand même pu donner un script tout prêt pour éviter d'avoir à tout ressaisir !

En ce qui concerne la réalisation du modèle, pas de problème non plus : bien que je sois sous VWD, tout se passe exactement comme décrit dans le tutoriel. Pour essayer de refaire les premiers essais d'intellisense tels qu'ils apparaissent dans le tutoriel, le plus simple est de se placer dans le source HomeController.cs après lui avoir ajouté la ligne "using NerdDinner.Models;" .

Pour le reste de la partie "Building the Model", je suis allé jusqu'au bout et j'ai tapé moi-même tout le code source proposé (à part les expressions régulières). Pour l'instant, je n'ai pas forcément tout assimilé en ce qui concerne LINQ to SQL et le pattern "Repository", mais c'est déjà ça de pris...

Et pour finir, j'ai décidé d'installer "SQL Server Management Studio". Après bien des péripéties, ça a enfin (et assez inexplicablement) réussi. Ca m'a permis de générer le script pour créer les deux tables Dinners et RSVP et pour initialiser leur contenu avec quelques données de tests.


Billet suivant dans la série : Troisième étape avec ASP.NET MVC

vendredi 17 juillet 2009

Premier pas avec ASP.NET MVC et NerdDinner

Ce coup-ci je me lance enfin et je vais tenter de suivre pas à pas la réalisation de l'application NerdDinner telle qu'elle est expliquée dans le tutoriel ASP.NET MVC de ScottGu.

Après avoir vérifié que le .Net Framework 3.5 est déjà présent sur mon PC, fait un rapide Windows Update puis redémarré mon ordinateur, j'ai commencé par le début et lancé l'installation de ASP.NET MVC, Visual Web Developer 2008 Express et SQL Server 2008 Express en utilisant le Microsoft Web Platform Installer. C'est un peu long mais ça se fait vraiment tout seul et le seul truc bizarre c'est qu'il faut souvent redémarrer la machine...

Enfin je peux lancer Microsoft Visual Web Developer 2008 Express Edition (ou VWD pour parler plus vite) et faire File - New Project, sélectionner Visual C# / Web et le template ASP.NET MVC Web Application, donner le nom NerdDinner et cliquer sur OK. Ca marche ! Mais ça ne me propose pas la création du projet de test puisque je n'ai que la version Express et que je n'ai pas installé d'outil de test unitaire.

C'est pas grave, je Start Debuging, je répond Yes un peu partout et j'atterris sur ma première Home Page ASP.NET MVC. Je clique un peu partout comme indiqué dans le tutoriel et c'est bien tout pareil :

  • la Home Page
  • la page About Us
  • la page Log On
  • la page Register

Déjà une bonne chose de faite. Avant d'aller plus loin, je quitte et j'efface mon projet NerdDinner pour tout recommencer une fois que j'aurais installé un framework de tests unitaires.

Après quelques recherches sur internet, il semblerait qu'il existe un template NUnit / MVC pour VWD. Ca date de la version RTM de ASP.NET MVC mais ça vaut quand même le coup d'essayer...

Dans mon cas, tout s'est bien passé. Je peux donc recommencer la partie "File - New Project" du tutoriel NerdDinner. Et là, après le clic sur OK pour créer mon projet NerdDinner, j'ai bien une boite de dialogue pour m'inviter à créer le projet de test unitaire correspondant :

NUnit-Create.png

Après avoir cliqué sur le bouton OK, je lance ma seconde application ASP.NET MVC et là c'est KO : There where build errors (et plus précisément The type or namespace name 'NUnit' could not be found). En fait, cela vient du fait qu'il y a un petit triangle jaune devant nunit.framework dans le dossier References du projet NerdDinner.Tests. Ca se corrige très facilement en supprimant la référence puis en la re-ajoutant.

Quand on relance l'exécution, il y a encore un souci parce que c'est le projet NerdDinner.Tests qui est actif (son nom est en gras) et qu'il s'agit d'un projet de type Class Library qui ne peut pas être démarré directement par VWD. Il faut donc cliquer sur le projet NerdDinner et le définir en tant que projet de démarrage et tout roule !

Par contre, pas moyen de lancer les tests unitaires depuis VWD. Il faut passer par NUnit lui-même :

  • File - Open Project...
  • Aller dans le répertoire bin/Debug ou bin/Release du projet NerdDinner.Tests
  • Y ouvrir la dll NerdDinner.Tests.dll

On peut alors lancer les tests unitaires automatiquement générés avec notre projet de base an cliquant sur le gros bouton "Run". Et comme je n'ai encore touché à rien, tout les tests sont réussis :)

NUnit-Run.png


Billet suivant dans la série : Seconds pas avec ASP.NET MVC et NerdDinner