MSDN Magazine > Accueil > Tous les numéros > 2008 > Juin >  Les modèles dans la pratique : le principe Ouve...
Modèles dans la pratique
le principe Ouvert Fermé
Jeremy Miller

Ceci est le premier article de la nouvelle rubrique de MSDN® Magazine sur les principes de base de la conception de logiciels. Je souhaite aborder les modèles et principes de conception sans me limiter à un outil ou à une méthodologie de cycle de vie spécifique. En d'autres termes, je compte examiner les connaissances de base qui peuvent vous aider à obtenir de meilleures conceptions quels que soient la technologie utilisée et votre projet.
J'aimerais commencer par le principe Ouvert Fermé qui a été abordé par Robert C. Martin dans son livre Agile Software Development, Principles, Patterns, and Practices. Ne soyez pas impressionné par le mot « agile » qui ne fait référence qu'aux efforts visant à créer de bons logiciels.
Posez-vous la question suivante : combien de fois cela vous arrive-t-il de commencer à écrire une nouvelle application à partir de zéro et de commencer par ajouter de nouvelles fonctionnalités à une base de code existante ? Je parie que vous passez beaucoup plus de temps à ajouter de nouvelles fonctionnalités à une base de code existante !
Posez-vous ensuite une autre question : est-il plus facile d'écrire du nouveau code ou de modifier un code existant ? En général, il m'est beaucoup plus facile d'écrire de nouvelles méthodes et classes que d'essayer de trouver les sections que je dois modifier dans un vieux code. En modifiant un vieux code, vous risquez de compromettre les fonctionnalités existantes. Avec le nouveau code, en général, il suffit de tester les nouvelles fonctionnalités. Lorsque vous modifiez un vieux code, vous devez non seulement tester vos modifications mais aussi exécuter une série de tests de régression pour vous assurer que vous n'avez pas compromis une partie quelconque du code existant.
Vous utilisez donc généralement une base de code existante, mais il est pourtant plus facile d'écrire du nouveau code que de modifier un vieux code. Que ne feriez-vous pas pour étendre une base de code existante de sorte qu'elle soit aussi productive et conviviale que l'écriture d'un nouveau code ? C'est là qu'intervient le principe Ouvert Fermé. Au risque de faire de la paraphrase, le principe Ouvert Fermé se résume comme suit : les entités logicielles doivent être ouvertes pour l'extension mais fermées pour la modification.
Une contradiction dans les termes, diriez-vous ? Pas du tout ! Cela signifie tout simplement que vous devez structurer une application afin de pouvoir ajouter de nouvelles fonctionnalités en modifiant au minimum le code existant. Au début, je pensais que le principe Ouvert Fermé consistait simplement à utiliser des plug-ins, mais ce n'est là que le début de l'histoire.
Ce que vous devez éviter, c'est qu'une simple modification ne se propage aux diverses classes de votre application. Cela rend le système fragile, vulnérable aux problèmes de régression et coûteux à étendre. Pour isoler les modifications, pensez à écrire des classes et des méthodes de sorte qu'elles n'aient jamais besoin d'être modifiées une fois écrites.
Comment donc structurer le code pour isoler les modifications ? Je dirais que la toute première étape consiste à suivre le principe de responsabilité unique.

Principe de responsabilité unique
Dans le principe Ouvert Fermé suivant, je voudrais pouvoir écrire une classe ou une méthode puis l'oublier, sachant qu'elle fonctionne correctement et que je n'ai pas à y retourner pour la modifier. Vous n'atteindrez jamais la perfection avec le principe Ouvert Fermé, mais vous pouvez vous en approcher en suivant rigoureusement le principe de responsabilité unique qui lui est associé : une classe ne devrait avoir qu'une seule raison de changer
L'une des plus simples manières d'écrire des classes qui n'auront jamais à être modifiées est d'écrire des classes qui ne font qu'une seule chose. Ainsi, une classe n'aura besoin d'être modifiée que si la seule chose qu'elle fait a besoin d'être modifiée. La figure 1 montre un exemple qui ne suit pas le principe de responsabilité unique. Je doute fort que vous soyez en train de concevoir un tel système, mais il est bon de garder à l'esprit la raison pour laquelle nous ne structurons pas le code de cette façon.
public class OrderProcessingModule {
  public void Process(OrderStatusMessage orderStatusMessage) {
    // Get the connection string from configuration
    string connectionString = 
      ConfigurationManager.ConnectionStrings["Main"]
      .ConnectionString;

    Order order = null;

    using (SqlConnection connection = 
      new SqlConnection(connectionString)) {
      // go get some data from the database
      order = fetchData(orderStatusMessage, connection);
    }

    // Apply the changes to the Order from the OrderStatusMessage
    updateTheOrder(order);

    // International orders have a unique set of business rules
    if (order.IsInternational) {
      processInternationalOrder(order);
    }

    // We need to treat larger orders in a special manner
    else if (order.LineItems.Count > 10) {
      processLargeDomesticOrder(order);
    }

    // Smaller domestic orders
    else {
      processRegularDomesticOrder(order);
    }

    // Ship the order if it's ready
    if (order.IsReadyToShip()) {
      ShippingGateway gateway = new ShippingGateway();

      // Transform the Order object into a Shipment 
      ShipmentMessage message = 
        createShipmentMessageForOrder(order);
      gateway.SendShipment(message);
  } 
}
Le module OrderProcessingModule est très occupé. Il accède aux données, saisit des informations sur les fichiers de configuration, exécute des règles métier pour le traitement de commandes (probablement très compliqué à lui seul) et transforme des commandes terminées en livraisons. Il est probable que si vous créez le code OrderProcessingModule de cette façon, vous n'arrêtiez pas de retourner à ce code pour le modifier. Les modifications excessives des configurations système entraîneraient beaucoup trop de modifications au code OrderProcessingModule, ce qui expose le système à des risques et le rend cher à modifier.
Au lieu d'un grand blob de code, vous devriez suivre le principe de responsabilité unique pour diviser la classe OrderProcessingModule toute entière en sous-système de classes apparentées, chaque classe remplissant son propre rôle spécialisé. Vous pouvez, par exemple, mettre toutes les fonctions d'accès aux données dans une nouvelle classe appelée OrderDataService et la logique métier des commandes dans une classe différente (nous verrons cela plus en détail dans la section suivante).
En termes de principe Ouvert Fermé, en divisant les responsabilités de logique métier et d'accès aux données en classes séparées, vous devez pouvoir modifier l'un de ces éléments d'une manière indépendante, sans aucune incidence sur l'autre. Une modification apportée au déploiement de la base de données physique pourrait vous obliger à remplacer l'accès aux données par quelque chose de complètement différent (ouvert pour l'extension), alors que les classes de logique des commandes restent intactes (fermées pour modification).
Le but du principe de responsabilité unique n'est pas juste d'écrire de petites classes et méthodes. Le but est que chaque classe doit implémenter une série cohérente de fonctions apparentées. Une façon simple de suivre le principe de responsabilité unique est de vous demander constamment si chaque méthode et opération d'une classe est directement liée à son nom. Si vous trouvez des méthodes qui ne correspondent pas au nom de la classe, vous devriez penser à les transférer vers une autre classe.

Le modèle Chaîne de responsabilité
Les règles métier seront probablement soumises à plus de modifications durant tout le cycle de vie d'une base de code que toute autre partie du système. Dans la classe OrderProcessingModule, il y avait une grande quantité de logique de branchement pour le traitement des commandes en fonction du type de commande reçu :
if (order.IsInternational) {
  processInternationalOrder(order);
}

else if (order.LineItems.Count > 10) {
  processLargeDomesticOrder(order);
}

else {
  processRegularDomesticOrder(order);
}
Il est très probable qu'un vrai système de traitement de commandes contienne beaucoup plus de types de commandes à mesure que l'entreprise grandit et accumule de nombreux cas d'exceptions tels que ceux liés au gouvernement et clients préférés et aux offres spéciales de la semaine. Il serait très utile de pouvoir écrire et tester une nouvelle logique de traitement de commandes sans risquer de compromettre l'une des règles métier existantes.
À cette fin, vous pouvez vous approcher davantage du principe Ouvert Fermé pour l'exemple de traitement de commandes en utilisant un formulaire du modèle Chaîne de responsabilité, comme le montre la figure 2. La première chose que j'ai faite était de placer chaque branche conditionnelle du code OrderProcessingModule original dans une classe séparée qui implémente l'interface IOrderHandler :
public interface IOrderHandler {
  void ProcessOrder(Order order);
  bool CanProcess(Order order);
}
public class OrderProcessingModule {
  private IOrderHandler[] _handlers;

  public OrderProcessingModule() {
    _handlers = new IOrderHandler[] {
                new InternationalOrderHandler(),
                new SmallDomesticOrderHandler(),
                new LargeDomesticOrderHandler(),
    };
  }

  public void Process (OrderStatusMessage orderStatusMessage, 
    Order order) {
    // Apply the changes to the Order from the OrderStatusMessage
    updateTheOrder(order);

    // Find the first IOrderHandler that "knows" how
    // to process this Order
    IOrderHandler handler = 
      Array.Find(_handlers, h => h.CanProcess(order));

    handler.ProcessOrder(order);
  }

  private void updateTheOrder(Order order) {
  }
}   
J'ai ensuite écrit une implémentation séparée de IOrderHandler pour chaque type de commande, y compris la logique qui dit : « Je sais ce que je dois faire avec cette commande, laisse-moi la traiter. »
À présent que la logique métier pour chaque type de commande est isolée dans une classe de gestionnaire séparée, vous pouvez modifier les règles métier d'un type de commande sans avoir à vous soucier des règles d'un autre type de commande. Mieux encore, vous pouvez ajouter des types de traitement de commandes complètement nouveaux avec une modification minimale du code existant.
Par exemple, supposons que plus tard, je doive ajouter une prise en charge des commandes du gouvernement dans le système. Avec le modèle Chaîne de responsabilité, je peux écrire une classe entièrement nouvelle appelée GovernmentOrderHandler qui implémente l'interface IOrderHandler. Une fois que je sais que GovernmentOrderHandler fonctionne normalement, je peux ajouter les nouvelles règles de traitement de la commande de gouvernement en modifiant une ligne de la fonction de constructeur de OrderProcessingModule :
public OrderProcessingModule() {
  _handlers = new IOrderHandler[] {
              new InternationalOrderHandler(),
              new SmallDomesticOrderHandler(),
              new LargeDomesticOrderHandler(),
              new GovernmentOrderHandler(),
  };
}
En suivant le principe Ouvert Fermé avec les règles de traitement de la commande, je facilite énormément l'opération d'ajout de nouveaux types de logique de traitement de commandes au système. J'ai pu ajouter les règles de commande de gouvernement en minimisant le risque de déstabilisation des autres types de commandes auquel j'aurais fait face si une seule classe avait implémenté tous les divers types de traitement de commandes.

Double répartition
Et si les étapes devenaient plus compliquées à l'avenir ? Que se passerait-il si le polymorphisme pur ne suffit pas à utiliser toutes les variations qui peuvent se présenter à l'avenir ? Nous pouvons utiliser un modèle appelé Double répartition pour placer la variation dans les sous-classes de manière à ne pas compromettre les contrats d'interface existants.
Par exemple, supposons que je crée une application de bureau composite qui affiche un écran à la fois dans une sorte de panneau principal. À chaque fois que j'ouvre un nouvel écran dans l'application, j'ai besoin d'un certain nombre de choses. J'aurais peut-être besoin de modifier les menus disponibles, de vérifier l'état des écrans déjà ouverts, d'effectuer diverses opérations pour personnaliser l'affichage de l'écran tout entier et, pendant que j'y pense, d'afficher le nouvel écran d'une manière ou d'une autre.
J'utilise généralement une variété du MVP (Model View Presenter) pour mes architectures clientes de bureau, et j'utilise normalement le modèle Application Controller pour coordonner les diverses triades de MVP dans l'application. Étant donné l'utilisation du modèle MVP avec un Application Controller (pour plus d'informations sur MVP, voir la rubrique Modèles de conception de Jean-Paul Boodhoo sur MSDN Magazine, disponible à l'adresse msdn2.microsoft.com/magazine/cc188690), l'activation de l'écran pourrait nécessiter les trois éléments de base suivants :
  1. Un présentateur pour un écran unique. Chaque Presenter sait tout ce qu'il faut savoir sur un écran spécifique unique.
  2. Un ApplicationShell pour le formulaire principal de l'application. L'ApplicationShell est chargé d'afficher les vues individuelles au sein d'un Panneau ou TabControl d'un certain type. L'ApplicationShell inclura également tous les menus.
  3. Un ApplicationController faisant office d'agent de circulation de l'application. L'ApplicationController est au courant de l'ApplicationShell et de chaque Presenter qui passe à travers l'application. L'ApplicationController contrôle l'activation d'écran et les cycles de vie de désactivation.
Si tout ce que j'ai à faire est de simplement afficher la vue dans l'ApplicationShell à l'activation, le code pourrait ressembler à la figure 3. Ceci est parfaitement possible pour une application simple, mais que se passerait-il si l'application devenait plus compliquée ? Et si, dans la deuxième version, je suis obligé d'ajouter des éléments de menu au shell principal lorsque certains écrans sont actifs ? Et si je souhaitais commencer à afficher des contrôles supplémentaires dans un nouveau volet le long du bord gauche de l'écran principal pour certains vues (et pas toutes) ?
public interface IApplicationShell {
  void DisplayMainView(object view);
}

public interface IPresenter {
  // Just exposes a getter for the inner WinForms UserControl or Form
  object View { get; }
}

public class ApplicationController {
  private IApplicationShell _shell;

  public ApplicationController(IApplicationShell shell) {
    _shell = shell;
  }

  public void ActivateScreen(IPresenter presenter) {
    teardownCurrentScreen();

    // Setup the new screen
    _shell.DisplayMainView(presenter.View);
  }

  private void teardownCurrentScreen() {
    // teardown the existing screen
  }
}
Mon architecture doit quand même être enfichable afin que je puisse ajouter de nouveaux écrans à l'application en y branchant tout simplement de nouveaux présentateurs, donc les connaissances de ces nouveaux menus et structures du volet gauche devraient aller dans l'abstraction Presenter existante. Je devrais ensuite modifier l'ApplicationShell ou l'ApplicationController pour réagir aux nouveaux éléments de menu et aux contrôles supplémentaires placés dans le volet gauche.
La figure 4 montre une solution possible. J'ai ajouté de nouvelles propriétés à l'interface IPresenter pour modéliser les nouveaux éléments de menu et les contrôles supplémentaires qui pourraient être ajoutés au nouveau volet gauche. J'ai également ajouté de nouveaux membres à IApplicationShell pour ces nouveaux concepts. Ensuite, j'ai ajouté le nouveau code à la méthode ApplicationControl­ler.ActivateScreen(IPresenter).
public class MenuCommand
{
	// ...
}
public interface IApplicationShell
{
	void DisplayMainView(object view);
	
	// New behavior
	void AddMenuCommands(MenuCommand[] commands);
	void DisplayInExplorerPane(object paneView);
}
public interface IPresenter
{
	object View { get; }

	// New properties
	MenuCommand[] Commands{ get; }
	object[] ExplorerViews { get; }
}
public class ApplicationController
	{
	private IApplicationShell _shell;
		
	public ApplicationController(IApplicationShell shell)
		{	
		_shell = shell;
		}

	public void ActivateScreen(IPresenter presenter)
	{
		teardownCurrentScreen();
		
		// Setup the new screen
		_shell.DisplayMainView(presenter.View);

		// New code
		_shell.AddMenuCommands(presenter.Commands);
		foreach (var explorerView in presenter.ExplorerViews)
		{
		_shell.DisplayInExplorerPane(explorerView);
		}
	}

	private void teardownCurrentScreen()
	{
		// teardown the existing screen
	}
}
Cette solution était-elle donc conforme au principe Ouvert Fermé ? Pas du tout ! D'abord, j'ai dû modifier l'interface de IPresenter. Puisqu'il s'agit d'une interface, il m'aurait fallu trouver chaque implémenteur de l'interface de IPresenter dans ma base de code et ajouter des implémentations vides de ces nouvelles méthodes tout simplement pour que mon code puisse être compilé à nouveau. Ce changement est souvent intolérable, surtout si l'un de ces implémenteurs de IPresenter est hors de votre contrôle immédiat. Nous reviendrons là-dessus plus tard.
J'ai dû modifier également la classe ApplicationController de sorte qu'elle soit au courant de tous les nouveaux types de personnalisations que chaque écran pourrait nécessiter sur l'ApplicationShell principal. Enfin, j'ai dû modifier ApplicationShell pour prendre en charge les nouvelles personnalisations du shell. Les modifications étaient mineures, mais, il n'est pas impossible que je souhaite ajouter d'autres personnalisations d'écran plus tard.
Dans une vraie application, la classe ApplicationController peut s'avérer suffisamment compliquée sans la responsabilité supplémentaire d'avoir à configurer ApplicationShell. Il serait bon de pouvoir garder cette responsabilité dans chaque Presenter.
Il est possible d'atténuer l'effet du changement de chaque implémentation d'IPresenter en utilisant une classe abstraite appelée Presenter au lieu d'une interface. Il me suffirait d'ajouter des implémentations par défaut à la classe abstraite comme le montre la figure 5. Et je n'aurais pas à modifier les implémentations existantes de Presenter pour ajouter le nouveau comportement.
public abstract class BasePresenter
{
    public abstract object View { get;}

    // Default implementation of Commands
    public virtual MenuCommand[] Commands
    {
         get
         {
             return new MenuCommand[0];
         }
    }

    // Default ExplorerViews
    public virtual object[] ExplorerViews
    {
         get
         {
             return new object[0];
         }
    }
}
Il existe une autre façon, peut-être beaucoup plus nette, de se rapprocher de l'idéal que présente le principe Ouvert Fermé. Au lieu de mettre des getters sur l'abstraction IPresenter ou BasePresenter, je pourrais utiliser le modèle Double répartition.
L'autre jour, j'ai eu une démonstration imprévue du modèle Double répartition dans la réalité. Mon équipe venait d'emménager dans un nouveau bureau et nous étions confrontés à des problèmes de réseau. La semaine dernière, notre spécialiste réseau m'appelle et commence à m'expliquer ce que mon collègue et moi devions faire pour nous connecter au VPN. Il déballe à toute allure des théories sur la mise en réseau si incompréhensibles qu'à la fin, je passe le téléphone à mon collègue et lui demande de prendre la relève !
Faisons donc de même pour l'ApplicationController. Au lieu que l'ApplicationController demande à chaque Presenter ce qui doit être affiché dans l'ApplicationShell, le Presenter se contentera de dire directement à l'ApplicationShell ce qu'il doit faire pour chaque écran (voir figure 6).
public interface IPresenter {
  void SetupView(IApplicationShell shell);
}

public class ApplicationController {
  private IApplicationShell _shell;

  public ApplicationController(IApplicationShell shell) {
    _shell = shell;
  }

  public void ActivateScreen(IPresenter presenter) {
    teardownCurrentScreen();

    // Set up the new screen using Double Dispatch
    presenter.SetupView(_shell);
  }

  private void teardownCurrentScreen() {
    // tear down the existing screen
  }
} 
J'aurais été obligé de changer ApplicationShell pour la nouvelle personnalisation de menu et les contrôles du volet gauche indépendamment de ce que j'avais fait au début, mais si j'avais commencé avec la stratégie de double répartition, j'aurais apporté moins de modifications à ApplicationController et à chaque Presenter. Je n'ai plus besoin de toucher à ApplicationController ou aux classes Presenter pour créer d'autres concepts d'écran. L'architecture est ouverte pour être étendue aux nouveaux concepts de shell, mais ApplicationController et les classes Presenter individuelles sont fermées pour la modification.

Principe de substitution de Liskov
Comme je l'ai dit précédemment, la manifestation la plus commune du principe Ouvert Fermé est dans l'utilisation du polymorphisme pour remplacer une partie existante de l'application par une classe tout neuve. Supposons que vous ayez, au départ, une classe appelée BusinessProcess dont la tâche consiste, disons, à exécuter un processus métier. À un certain point, elle doit accéder aux données d'une source de données :
public class BusinessProcess {
  private IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source;
  }
}
public interface IDataSource {
  Entity FindEntity(long key);
}
La conception suit le principe Ouvert Fermé si vous pouvez étendre le système en échangeant les implémentations de IDataSource sans modifier la classe BusinessProcess. Vous pourriez commencer par un simple mécanisme basé sur fichier XML puis passer à l'utilisation d'une base de données pour le stockage et terminer par une sorte de mise en cache, toujours sans modifier la classe BusinessProcess. Tout cela est possible, mais seulement si vous suivez un principe apparenté : le principe de substitution de Liskov.
En gros, disons que vous suivez le principe de substitution de Liskov si vous pouvez utiliser n'importe quelle implémentation d'une abstraction à n'importe quel endroit qui accepte cette abstraction. BusinessProcess devrait pouvoir utiliser n'importe quelle implémentation de IDataSource sans modification. BusinessProcess n'a rien à rien savoir sur les données internes de IDataSource, à l'exception de ce qui est communiqué par l'interface publique.
Pour illustrer tout cela, la figure 7 montre un exemple qui ne réussit pas à suivre le principe de substitution de Liskov. Cette version de la classe BusinessProcess possède la logique spécifique pour lancer une FileSource et s'appuie également sur la connaissance d'une certaine logique de traitement d'erreur spécifique pour la classe DatabaseSource. Vous créez les implémenteurs de IDataSource de sorte qu'ils puissent contrôler tous leurs besoins en matière d'infrastructure. Ce faisant, vous autorisez l'écriture de la classe BusinessProcess, comme le montre la figure 8.
public class BusinessProcess {
  private IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source; 
  }

  public void Process() {
    long theKey = 112;

    // Special code if we're using a FileSource
    if (_source is FileSource)  {
      ((FileSource)_source).LoadFile();
    }

    try {
      Entity entity = _source.FindEntity(theKey);
    }
    catch (System.Data.DataException) {
      // Special exception handling for the DatabaseSource,
      // This is an example of "Downcasting"
      ((DatabaseSource)_source).CleanUpTheConnection(); 
    }
  }
}
public class BusinessProcess {
  private readonly IDataSource _source;

  public BusinessProcess(IDataSource source) {
    _source = source;
  }

  public void Process(Message message) {
    // the first part of the Process() method

    // There is NO code specific to any implementation of     // IDataSource here
    Entity entity = _source.FindEntity(message.Key);

    // the last part of the Process() method
  }
}

Trouver la fermeture
N'oubliez pas que le principe Ouvert Fermé n'est réalisé par polymorphisme que si une classe dépend seulement du contrat public des autres classes avec lesquelles elle interagit. Si une classe utilisant une abstraction doit passer à une sous-classe spécifique dans une section, cela signifie que vous ne suivez pas le principe Ouvert Fermé.
Si une classe utilisant une autre classe intègre des connaissances sur le fonctionnement interne de sa dépendance (par exemple, la supposition que les résultats d'une méthode sont toujours triés du plus grand au plus petit), cela signifie que vous ne pouvez pas utiliser une implémentation à la place de cette dépendance. Ces types d'association implicite à une implémentation spécifique sont particulièrement pernicieux parce qu'ils ne sont pas évidents pour la personne qui lit votre code. Ne laissez pas le consommateur d'une abstraction dépendre d'autre chose que le contrat public de cette abstraction.
Je vous conseille de traiter le principe Ouvert Fermé comme un vecteur de conception au lieu d'un objectif direct. Si vous essayez de faire en sorte que tout ce qui est à même de changer soit entièrement enfichable, vous allez probablement créer un système trop élaboré qui sera très difficile à utiliser. Vous ne pourrez probablement pas toujours écrire du code qui soit entièrement conforme au principe Ouvert Fermé, mais le fait d'y parvenir en partie peut être avantageux.

Envoyez vos questions et commentaires à l'adresse suivante : mmpatt@microsoft.com.

Jeremy Miller MVP Microsoft pour C#, Jeremy est l'auteur de l'outil OpenSource StructureMap (structuremap.sourceforge.net) pour l'injection de dépendance avec .NET et du prochain outil StoryTeller (storyteller.tigris.org) pour les tests FIT dans. NET. Consultez son blog, « The Shade Tree Developer », sur codebetter.com/blogs/jeremy.miller, partie intégrante du site CodeBetter.

Contenu de la communauté   Qu'est-ce que le Contenu de la communauté ?
Ajouter du contenu RSS  Annotations
Processing
Page view tracker