Itération #4 : Rendre l’application faiblement couplée (C#)
par Microsoft
Dans cette quatrième itération, nous nous appuyons sur plusieurs modèles de conception de logiciels pour faciliter la maintenance et la modification de l’application Gestionnaire de contacts. Par exemple, nous refactorisons notre application pour utiliser le modèle référentiel et le modèle d’injection de dépendances.
Génération d’une application Gestion des contacts ASP.NET MVC (C#)
Dans cette série de tutoriels, nous créons une application gestion des contacts entière du début à la fin. L’application Gestionnaire de contacts vous permet de stocker des informations de contact (noms, numéros de téléphone et adresses e-mail) pour une liste de personnes.
Nous créons l’application sur plusieurs itérations. À chaque itération, nous améliorons progressivement l’application. L’objectif de cette approche d’itération multiple est de vous permettre de comprendre la raison de chaque modification.
Itération n°1 - Créer l’application. Dans la première itération, nous créons le Gestionnaire de contacts de la manière la plus simple possible. Nous ajoutons la prise en charge des opérations de base de données de base : Créer, Lire, Mettre à jour et Supprimer (CRUD).
Itération n° 2 : rendre l’application agréable. Dans cette itération, nous améliorons l’apparence de l’application en modifiant la ASP.NET vue MVC par défaut master page et la feuille de style en cascade.
Itération #3 - Ajouter la validation de formulaire. Dans la troisième itération, nous ajoutons la validation de formulaire de base. Nous empêchant les utilisateurs d’envoyer un formulaire sans remplir les champs de formulaire obligatoires. Nous validons également les adresses e-mail et les numéros de téléphone.
Itération n° 4 : rendre l’application faiblement couplée. Dans cette quatrième itération, nous nous appuyons sur plusieurs modèles de conception de logiciels pour faciliter la maintenance et la modification de l’application Gestionnaire de contacts. Par exemple, nous refactorisons notre application pour utiliser le modèle référentiel et le modèle d’injection de dépendances.
Itération n°5 - Créer des tests unitaires. Dans la cinquième itération, nous rendons notre application plus facile à gérer et à modifier en ajoutant des tests unitaires. Nous nous moquons de nos classes de modèle de données et nous créons des tests unitaires pour nos contrôleurs et notre logique de validation.
Itération n°6 - Utiliser le développement piloté par les tests. Dans cette sixième itération, nous ajoutons de nouvelles fonctionnalités à notre application en écrivant d’abord des tests unitaires et en écrivant du code sur les tests unitaires. Dans cette itération, nous ajoutons des groupes de contacts.
Itération n°7 - Ajouter la fonctionnalité Ajax. Dans la septième itération, nous améliorons la réactivité et les performances de notre application en ajoutant la prise en charge d’Ajax.
Cette itération
Dans cette quatrième itération de l’application Contact Manager, nous refactorisons l’application pour rendre l’application plus faiblement couplée. Lorsqu’une application est faiblement couplée, vous pouvez modifier le code dans une partie de l’application sans avoir à modifier le code dans d’autres parties de l’application. Les applications faiblement couplées sont plus résilientes au changement.
Actuellement, toutes les logiques d’accès et de validation des données utilisées par l’application Contact Manager sont contenues dans les classes du contrôleur. C’est une mauvaise idée. Chaque fois que vous devez modifier une partie de votre application, vous risquez d’introduire des bogues dans une autre partie de votre application. Par exemple, si vous modifiez votre logique de validation, vous risquez d’introduire de nouveaux bogues dans votre logique d’accès aux données ou de contrôleur.
Notes
(SRP), une classe ne doit jamais avoir plus d’une raison de changer. Le mélange du contrôleur, de la validation et de la logique de base de données est une violation massive du principe de responsabilité unique.
Il existe plusieurs raisons pour lesquelles vous devrez peut-être modifier votre application. Vous devrez peut-être ajouter une nouvelle fonctionnalité à votre application, corriger un bogue dans votre application ou modifier la façon dont une fonctionnalité de votre application est implémentée. Les applications sont rarement statiques. Ils ont tendance à croître et à muter au fil du temps.
Imaginez, par exemple, que vous décidiez de modifier la façon dont vous implémentez votre couche d’accès aux données. À l’heure actuelle, l’application Gestionnaire de contacts utilise Microsoft Entity Framework pour accéder à la base de données. Toutefois, vous pouvez décider de migrer vers une technologie d’accès aux données nouvelle ou alternative, comme ADO.NET Data Services ou NHibernate. Toutefois, étant donné que le code d’accès aux données n’est pas isolé du code de validation et du contrôleur, il n’existe aucun moyen de modifier le code d’accès aux données dans votre application sans modifier un autre code qui n’est pas directement lié à l’accès aux données.
Quand une application est faiblement couplée, en revanche, vous pouvez apporter des modifications à une partie d’une application sans toucher à d’autres parties d’une application. Par exemple, vous pouvez changer de technologies d’accès aux données sans modifier votre logique de validation ou de contrôleur.
Dans cette itération, nous tireons parti de plusieurs modèles de conception de logiciels qui nous permettent de refactoriser notre application Contact Manager en une application plus faiblement couplée. Lorsque nous avons terminé, le Gestionnaire de contacts ne fera rien de ce qu’il n’avait pas fait auparavant. Toutefois, nous pourrons modifier l’application plus facilement à l’avenir.
Notes
La refactorisation est le processus de réécriture d’une application de telle sorte qu’elle ne perde aucune fonctionnalité existante.
Utilisation du modèle de conception du logiciel de dépôt
Notre première modification consiste à tirer parti d’un modèle de conception logicielle appelé modèle de référentiel. Nous allons utiliser le modèle Référentiel pour isoler notre code d’accès aux données du reste de notre application.
L’implémentation du modèle De dépôt nous oblige à effectuer les deux étapes suivantes :
- Créer une interface
- Créer une classe concrète qui implémente l’interface
Tout d’abord, nous devons créer une interface qui décrit toutes les méthodes d’accès aux données que nous devons effectuer. L’interface IContactManagerRepository est contenue dans listing 1. Cette interface décrit cinq méthodes : CreateContact(), DeleteContact(), EditContact(), GetContact et ListContacts().
Listing 1 - Models\IContactManagerRepository.cs
using System;
using System.Collections.Generic;
namespace ContactManager.Models
{
public interface IContactRepository
{
Contact CreateContact(Contact contactToCreate);
void DeleteContact(Contact contactToDelete);
Contact EditContact(Contact contactToUpdate);
Contact GetContact(int id);
IEnumerable<Contact> ListContacts();
}
}
Ensuite, nous devons créer une classe concrète qui implémente l’interface IContactManagerRepository. Étant donné que nous utilisons Microsoft Entity Framework pour accéder à la base de données, nous allons créer une classe nommée EntityContactManagerRepository. Cette classe est contenue dans la liste 2.
Listing 2 - Models\EntityContactManagerRepository.cs
using System.Collections.Generic;
using System.Linq;
namespace ContactManager.Models
{
public class EntityContactManagerRepository : ContactManager.Models.IContactManagerRepository
{
private ContactManagerDBEntities _entities = new ContactManagerDBEntities();
public Contact GetContact(int id)
{
return (from c in _entities.ContactSet
where c.Id == id
select c).FirstOrDefault();
}
public IEnumerable ListContacts()
{
return _entities.ContactSet.ToList();
}
public Contact CreateContact(Contact contactToCreate)
{
_entities.AddToContactSet(contactToCreate);
_entities.SaveChanges();
return contactToCreate;
}
public Contact EditContact(Contact contactToEdit)
{
var originalContact = GetContact(contactToEdit.Id);
_entities.ApplyPropertyChanges(originalContact.EntityKey.EntitySetName, contactToEdit);
_entities.SaveChanges();
return contactToEdit;
}
public void DeleteContact(Contact contactToDelete)
{
var originalContact = GetContact(contactToDelete.Id);
_entities.DeleteObject(originalContact);
_entities.SaveChanges();
}
}
}
Notez que la classe EntityContactManagerRepository implémente l’interface IContactManagerRepository. La classe implémente les cinq méthodes décrites par cette interface.
Vous vous demandez peut-être pourquoi nous devons nous embêter avec une interface. Pourquoi devons-nous créer à la fois une interface et une classe qui l’implémente ?
À une exception près, le reste de notre application interagit avec l’interface et non avec la classe concrète. Au lieu d’appeler les méthodes exposées par la classe EntityContactManagerRepository, nous allons appeler les méthodes exposées par l’interface IContactManagerRepository.
De cette façon, nous pouvons implémenter l’interface avec une nouvelle classe sans avoir à modifier le reste de notre application. Par exemple, à une date ultérieure, nous pourrions implémenter une classe DataServicesContactManagerRepository qui implémente l’interface IContactManagerRepository. La classe DataServicesContactManagerRepository peut utiliser ADO.NET Data Services pour accéder à une base de données au lieu de Microsoft Entity Framework.
Si notre code d’application est programmé sur l’interface IContactManagerRepository au lieu de la classe EntityContactManagerRepository concrète, nous pouvons changer de classes concrètes sans modifier le reste de notre code. Par exemple, nous pouvons basculer de la classe EntityContactManagerRepository à la classe DataServicesContactManagerRepository sans modifier notre logique d’accès aux données ou de validation.
La programmation sur des interfaces (abstractions) au lieu de classes concrètes rend notre application plus résiliente au changement.
Notes
Vous pouvez rapidement créer une interface à partir d’une classe concrète dans Visual Studio en sélectionnant l’option de menu Refactoriser, Extraire l’interface. Par exemple, vous pouvez d’abord créer la classe EntityContactManagerRepository, puis utiliser l’interface d’extraction pour générer automatiquement l’interface IContactManagerRepository.
Utilisation du modèle de conception du logiciel d’injection de dépendances
Maintenant que nous avons migré notre code d’accès aux données vers une classe De dépôt distincte, nous devons modifier notre contrôleur de contacts pour utiliser cette classe. Nous allons tirer parti d’un modèle de conception logicielle appelé Injection de dépendances pour utiliser la classe Repository dans notre contrôleur.
Le contrôleur de contacts modifié est contenu dans la liste 3.
Listing 3 - Controllers\ContactController.cs
using System.Text.RegularExpressions;
using System.Web.Mvc;
using ContactManager.Models;
namespace ContactManager.Controllers
{
public class ContactController : Controller
{
private IContactManagerRepository _repository;
public ContactController()
: this(new EntityContactManagerRepository())
{}
public ContactController(IContactManagerRepository repository)
{
_repository = repository;
}
protected void ValidateContact(Contact contactToValidate)
{
if (contactToValidate.FirstName.Trim().Length == 0)
ModelState.AddModelError("FirstName", "First name is required.");
if (contactToValidate.LastName.Trim().Length == 0)
ModelState.AddModelError("LastName", "Last name is required.");
if (contactToValidate.Phone.Length > 0 && !Regex.IsMatch(contactToValidate.Phone, @"((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}"))
ModelState.AddModelError("Phone", "Invalid phone number.");
if (contactToValidate.Email.Length > 0 && !Regex.IsMatch(contactToValidate.Email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"))
ModelState.AddModelError("Email", "Invalid email address.");
}
public ActionResult Index()
{
return View(_repository.ListContacts());
}
public ActionResult Create()
{
return View();
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create([Bind(Exclude = "Id")] Contact contactToCreate)
{
// Validation logic
ValidateContact(contactToCreate);
if (!ModelState.IsValid)
return View();
// Database logic
try
{
_repository.CreateContact(contactToCreate);
return RedirectToAction("Index");
}
catch
{
return View();
}
}
public ActionResult Edit(int id)
{
return View(_repository.GetContact(id));
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(Contact contactToEdit)
{
// Validation logic
ValidateContact(contactToEdit);
if (!ModelState.IsValid)
return View();
// Database logic
try
{
_repository.EditContact(contactToEdit);
return RedirectToAction("Index");
}
catch
{
return View();
}
}
public ActionResult Delete(int id)
{
return View(_repository.GetContact(id));
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Delete(Contact contactToDelete)
{
try
{
_repository.DeleteContact(contactToDelete);
return RedirectToAction("Index");
}
catch
{
return View();
}
}
}
}
Notez que le contrôleur de contact dans la liste 3 a deux constructeurs. Le premier constructeur transmet une instance concrète de l’interface IContactManagerRepository au second constructeur. La classe du contrôleur de contact utilise l’injection de dépendances du constructeur.
Le seul endroit où la classe EntityContactManagerRepository est utilisée se trouve dans le premier constructeur. Le reste de la classe utilise l’interface IContactManagerRepository au lieu de la classe EntityContactManagerRepository concrète.
Cela permet de changer facilement d’implémentations de la classe IContactManagerRepository à l’avenir. Si vous souhaitez utiliser la classe DataServicesContactRepository au lieu de la classe EntityContactManagerRepository, modifiez simplement le premier constructeur.
L’injection de dépendances de constructeur rend également la classe du contrôleur de contact très testable. Dans vos tests unitaires, vous pouvez instancier le contrôleur de contact en passant une implémentation fictive de la classe IContactManagerRepository. Cette fonctionnalité de l’injection de dépendances sera très importante pour nous dans l’itération suivante lorsque nous générerons des tests unitaires pour l’application Gestionnaire de contacts.
Notes
Si vous souhaitez dissocier complètement la classe du contrôleur de contact d’une implémentation particulière de l’interface IContactManagerRepository, vous pouvez tirer parti d’une infrastructure qui prend en charge l’injection de dépendances, telle que StructureMap ou Microsoft Entity Framework (MEF). En tirant parti d’une infrastructure d’injection de dépendances, vous n’avez jamais besoin de faire référence à une classe concrète dans votre code.
Création d’une couche de service
Vous avez peut-être remarqué que notre logique de validation est toujours mélangée à notre logique de contrôleur dans la classe de contrôleur modifié dans la liste 3. Pour la même raison qu’il est judicieux d’isoler notre logique d’accès aux données, il est judicieux d’isoler notre logique de validation.
Pour résoudre ce problème, nous pouvons créer une couche de service distincte. La couche de service est une couche distincte que nous pouvons insérer entre nos classes de contrôleur et de dépôt. La couche de service contient notre logique métier, y compris toute notre logique de validation.
ContactManagerService est contenu dans la liste 4. Il contient la logique de validation de la classe du contrôleur de contact.
Listing 4 - Models\ContactManagerService.cs
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Web.Mvc;
using ContactManager.Models.Validation;
namespace ContactManager.Models
{
public class ContactManagerService : IContactManagerService
{
private IValidationDictionary _validationDictionary;
private IContactManagerRepository _repository;
public ContactManagerService(IValidationDictionary validationDictionary)
: this(validationDictionary, new EntityContactManagerRepository())
{}
public ContactManagerService(IValidationDictionary validationDictionary, IContactManagerRepository repository)
{
_validationDictionary = validationDictionary;
_repository = repository;
}
public bool ValidateContact(Contact contactToValidate)
{
if (contactToValidate.FirstName.Trim().Length == 0)
_validationDictionary.AddError("FirstName", "First name is required.");
if (contactToValidate.LastName.Trim().Length == 0)
_validationDictionary.AddError("LastName", "Last name is required.");
if (contactToValidate.Phone.Length > 0 && !Regex.IsMatch(contactToValidate.Phone, @"((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}"))
_validationDictionary.AddError("Phone", "Invalid phone number.");
if (contactToValidate.Email.Length > 0 && !Regex.IsMatch(contactToValidate.Email, @"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"))
_validationDictionary.AddError("Email", "Invalid email address.");
return _validationDictionary.IsValid;
}
#region IContactManagerService Members
public bool CreateContact(Contact contactToCreate)
{
// Validation logic
if (!ValidateContact(contactToCreate))
return false;
// Database logic
try
{
_repository.CreateContact(contactToCreate);
}
catch
{
return false;
}
return true;
}
public bool EditContact(Contact contactToEdit)
{
// Validation logic
if (!ValidateContact(contactToEdit))
return false;
// Database logic
try
{
_repository.EditContact(contactToEdit);
}
catch
{
return false;
}
return true;
}
public bool DeleteContact(Contact contactToDelete)
{
try
{
_repository.DeleteContact(contactToDelete);
}
catch
{
return false;
}
return true;
}
public Contact GetContact(int id)
{
return _repository.GetContact(id);
}
public IEnumerable<Contact> ListContacts()
{
return _repository.ListContacts();
}
#endregion
}
}
Notez que le constructeur de ContactManagerService nécessite un ValidationDictionary. La couche de service communique avec la couche de contrôleur via ce ValidationDictionary. Nous abordons le ValidationDictionary en détail dans la section suivante lorsque nous abordons le modèle De décoration.
Notez, en outre, que ContactManagerService implémente l’interface IContactManagerService. Vous devez toujours vous efforcer de programmer par rapport aux interfaces au lieu de classes concrètes. Les autres classes de l’application Gestionnaire de contacts n’interagissent pas directement avec la classe ContactManagerService. Au lieu de cela, à une exception près, le reste de l’application Gestionnaire de contacts est programmé sur l’interface IContactManagerService.
L’interface IContactManagerService est contenue dans la liste 5.
Listing 5 - Models\IContactManagerService.cs
using System.Collections.Generic;
namespace ContactManager.Models
{
public interface IContactManagerService
{
bool CreateContact(Contact contactToCreate);
bool DeleteContact(Contact contactToDelete);
bool EditContact(Contact contactToEdit);
Contact GetContact(int id);
IEnumerable ListContacts();
}
}
La classe de contrôleur de contact modifiée est contenue dans la liste 6. Notez que le contrôleur de contacts n’interagit plus avec le référentiel ContactManager. Au lieu de cela, le contrôleur de contacts interagit avec le service ContactManager. Chaque couche est isolée autant que possible des autres couches.
Listing 6 - Controllers\ContactController.cs
using System.Web.Mvc;
using ContactManager.Models;
namespace ContactManager.Controllers
{
public class ContactController : Controller
{
private IContactManagerService _service;
public ContactController()
{
_service = new ContactManagerService(new ModelStateWrapper(this.ModelState));
}
public ContactController(IContactManagerService service)
{
_service = service;
}
public ActionResult Index()
{
return View(_service.ListContacts());
}
public ActionResult Create()
{
return View();
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create([Bind(Exclude = "Id")] Contact contactToCreate)
{
if (_service.CreateContact(contactToCreate))
return RedirectToAction("Index");
return View();
}
public ActionResult Edit(int id)
{
return View(_service.GetContact(id));
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(Contact contactToEdit)
{
if (_service.EditContact(contactToEdit))
return RedirectToAction("Index");
return View();
}
public ActionResult Delete(int id)
{
return View(_service.GetContact(id));
}
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Delete(Contact contactToDelete)
{
if (_service.DeleteContact(contactToDelete))
return RedirectToAction("Index");
return View();
}
}
}
Notre application ne respecte plus le principe de responsabilité unique (SRP). Le contrôleur de contacts dans la liste 6 a été supprimé de toutes les responsabilités autres que le contrôle du flux d’exécution de l’application. Toute la logique de validation a été supprimée du contrôleur de contact et poussée dans la couche de service. Toute la logique de base de données a été poussée dans la couche du référentiel.
Utilisation du modèle de décorateur
Nous voulons pouvoir dissocier complètement notre couche de service de notre couche de contrôleur. En principe, nous devrions être en mesure de compiler notre couche de service dans un assembly distinct de notre couche de contrôleur sans avoir à ajouter une référence à notre application MVC.
Toutefois, notre couche de service doit être en mesure de transmettre les messages d’erreur de validation à la couche contrôleur. Comment permettre à la couche de service de communiquer des messages d’erreur de validation sans associer le contrôleur et la couche de service ? Nous pouvons tirer parti d’un modèle de conception de logiciel nommé motif Décoratif.
Un contrôleur utilise un ModelStateDictionary nommé ModelState pour représenter les erreurs de validation. Par conséquent, vous pouvez être tenté de passer ModelState de la couche contrôleur à la couche de service. Toutefois, l’utilisation de ModelState dans la couche de service rend votre couche de service dépendante d’une fonctionnalité de l’infrastructure MVC ASP.NET. Ce serait mauvais car, un jour, vous voudrez peut-être utiliser la couche de service avec une application WPF au lieu d’une application ASP.NET MVC. Dans ce cas, vous ne souhaitez pas référencer l’infrastructure ASP.NET MVC pour utiliser la classe ModelStateDictionary.
Le modèle Decorator vous permet d’encapsuler une classe existante dans une nouvelle classe afin d’implémenter une interface. Notre projet Gestionnaire de contacts inclut la classe ModelStateWrapper contenue dans la liste 7. La classe ModelStateWrapper implémente l’interface dans listing 8.
Listing 7 - Models\Validation\ModelStateWrapper.cs
using System.Web.Mvc;
namespace ContactManager.Models.Validation
{
public class ModelStateWrapper : IValidationDictionary
{
private ModelStateDictionary _modelState;
public ModelStateWrapper(ModelStateDictionary modelState)
{
_modelState = modelState;
}
public void AddError(string key, string errorMessage)
{
_modelState.AddModelError(key, errorMessage);
}
public bool IsValid
{
get { return _modelState.IsValid; }
}
}
}
Listing 8 - Models\Validation\IValidationDictionary.cs
namespace ContactManager.Models.Validation
{
public interface IValidationDictionary
{
void AddError(string key, string errorMessage);
bool IsValid {get;}
}
}
Si vous examinez de près la liste 5, vous verrez que la couche de service ContactManager utilise exclusivement l’interface IValidationDictionary. Le service ContactManager ne dépend pas de la classe ModelStateDictionary. Lorsque le contrôleur de contacts crée le service ContactManager, le contrôleur encapsule son ModelState comme suit :
_service = new ContactManagerService(new ModelStateWrapper(this.ModelState));
Résumé
Dans cette itération, nous n’avons ajouté aucune nouvelle fonctionnalité à l’application Gestionnaire de contacts. L’objectif de cette itération était de refactoriser l’application Gestionnaire de contacts afin qu’elle soit plus facile à gérer et à modifier.
Tout d’abord, nous avons implémenté le modèle de conception du logiciel référentiel. Nous avons migré tout le code d’accès aux données vers une classe de dépôt ContactManager distincte.
Nous avons également isolé notre logique de validation de notre logique de contrôleur. Nous avons créé une couche de service distincte qui contient tout notre code de validation. La couche de contrôleur interagit avec la couche de service et la couche de service interagit avec la couche de dépôt.
Lorsque nous avons créé la couche de service, nous avons tiré parti du modèle Decorator pour isoler ModelState de notre couche de service. Dans notre couche de service, nous avons programmé sur l’interface IValidationDictionary au lieu de ModelState.
Enfin, nous avons tiré parti d’un modèle de conception logicielle nommé modèle d’injection de dépendances. Ce modèle nous permet de programmer sur des interfaces (abstractions) au lieu de classes concrètes. L’implémentation du modèle de conception d’injection de dépendances rend également notre code plus testable. Dans l’itération suivante, nous ajoutons des tests unitaires à notre projet.