Composants "architecturaux": introduction aux design patterns

images/bluebelt.png

L’expérience accumulée par les programmeurs a permis de constater que certaines classes, bien que traitant de problèmes différents, avaient des analogies structurelles. D’où l’idée de repérer des modèles organisationnels qui permettent de récupérer des mécanismes de fonctionnement très généraux.

L’analyse approfondie de la pertinence de ces mécanismes permet en fait de résoudre une classe de problèmes apparentés en ne s’intéressant qu’aux propriétés structurelles des objets mis en oeuvre.

Ces trames structurelles (patterns) font l’objet de diverses publications et le programmeur doit les connaître et savoir les adapter à ses problèmes. On a aussi une autre catégorie de composants qui ne concernent pas des codes spécifiques mais des agencements de code.

Certaines architectures, certains types de relations entre objets ont donc des propriétés structurelles intéressantes qui peuvent inspirer des réalisations qui seront:

Il faut toutefois bien retenir cette présentation de C.Alexander (qui parlait d'éléments architecturaux - au sens architecture des bâtiments! - ):

Each pattern describes a problem which occurs over and over again in our environment and then describes the core of the solution to that problem, in such a way that you can use this solution a million times over, without ever doing it the same way twice.

On a donc à utiliser des patterns :

Il est très important de s’intéresser à l’abstraction structurelle et de comprendre que la catégorisation des solutions ne permet pas de fournir automatiquement une solution et qu’il faut très précisément analyser le contexte pour trouver une solution spécifique qui s’inspire du modèle.

Pour illustrer ce propos nous allons présenter un pattern, le décomposer et montrer que différentes réalisations apparentées peuvent en découler.

Modèle/Vue

Soit la situation suivante: dans une salle de marché financier un mini-ordinateur gère plusieurs écrans. L’application gère les cours des actions.

Un processus externe apporte des informations sur les évolutions du cours des actions. Quand une nouvelle valeur est affectée aux données de référence (le "modèle"), d’autres parties de l’application (les "vues") doivent être prévenues de ces changements.

image: exemple concret modèle/vue

On a ici un dispositif classique qui était précédemment implanté en Java par le pattern Observer/Observable (Observable est le modèle, Observer est la vue).

Voici une description UML de référence de cette trame structurelle:

image: diagramme UML Observer/Observable

Ici la description ne fait pas référence à Java. On a deux classes abstraites Subject (super-classe du modèle) et Observer (super-classe de la vue effective). Subject fournit un moyen d’enregistrer les Observers et permet de lancer une notification générale (via la méthode Update). Parmi les défauts potentiels du mécanisme ainsi décrit vous pouvez analyser les problèmes de concurrence d’accès à l'état du Subject.

Or nous allons voir que ni cette description ni l’implantation initiale dans Java avec l’interface Observer et la classe Observable ne permettent de répondre à certains cas de figure (Observer/Observable sont maintenant obsolete - Deprecated - )

Pour analyser les tenants et les aboutissants d’un tel dispositif on peut le décomposer en patterns élémentaires.

Enregistrement/rappel (call-back)

Un objet s’enregistre auprès d’un autre objet pour que ce dernier le rappelle (plus tard… lorsqu’un événement se produit).

image: call back

Ici une référence sur l’objet Recepteur est passée à l’objet Emetteur (soit par une méthode setRecepteur, soit par le constructeur). Le moment venu l’objet émetteur invoque la méthode appropriée sur l’objet récepteur.

Ce dispositif appelle quelques remarques:

  • Si les objets sont des instances de classes Emetteur, Recepteur il y a risque de couplage dans le code (les deux codes sont interdépendants).

    Pour éliminer ce couplage on peut:

    • faire réaliser l’enregistrement du récepteur par un code tiers (un "controleur"). Dans ce cas le récepteur ignore tout de l'émetteur.
    • décrire le récepteur comme une interface Java. Le code émetteur ne connaît pas le type effectif du récepteur. Cette solution est la plus "ouverte" puisqu’elle ne présage pas de l’initiative de l’enregistrement (qui peut être réalisé par le récepteur, ou par un controleur) ni de la connaissance du type effectif de l'émetteur par le récepteur.

Donc:

//  un type paramétré Recepteur<T>
public interface Recepteur<T> {
   public activation(T argument);
}

public class Emetteur<T> {
   private Recepteur<T> recept;

   public Emetteur(Recepteur<T> rc, °°°°°) { ....
   // et / ou
   public void setRecepteur(Recepteur<T> rc) { ....

   // dans un code
   recept.activation(arg) ;

Enregistrements multiples, diffusion

On peut étendre le pattern précédent de manière à avoir plusieurs récepteurs.

D’une certaine manière c’est ce que faisait Java dans son implantation du pattern Observer/Observable (dans le package java.util):

public interface Observer {
  void    update(Observable o, Object arg) ;
}

et

public class Observable {
   // méthodes liées à l'enregistrement
   public void addObserver(Observer o) {°°°°
   public void deleteObserver(Observer o) { °°°°

   // méthodes de notification
   public void notifyObservers(Object o) { °°°

Exemple de code dans une sous-classe d'Observable :

public class CoursBourse extends Observable {
   private HashMap cours ;
   ....
   // p.e. synchronized
   public void changeCote(String valeur, Money cote){
      cours.put(valeur,cote) ;
      this.setChanged();//rend notification possible
      notifyObservers(valeur) ;
      // aurait pu être un objet combinant valeur et cote
   }

Par rapport à la description initiale du pattern on notera que Java a pris une option de push (un objet accompagne la notification). Bien que le rappel du Modèle par la Vue soit possible (1° paramètre de update) ce n’est pas la solution privilégiée.

[Note]

A l’inverse le code Iterator propose une option pull dans laquelle le code client prend l’initiative de demander l'élément suivant.

Cela suppose que le code fournisseur génère autant de contextes qu’il y a de demandeurs et que pour chacun on gère l'état d’avancement des requêtes.

Un problème est le risque d'épuisement des ressources si le code demandeur ne notifie pas la fin de ses requêtes (il faudrait une combinaison Iterator/Closeable).

Remarques:

  • Bien que possible le rappel d’une méthode du Modèle par la Vue doit être étudié avec soin (si par ex. cela déclenche un autre événement). Si possible il faudrait éviter qu’une modification du modèle se produise dans le code de update.
  • Le code de update ne doit pas bloquer ni ralentir les autres notifications. Si besoin rendre le code de update asynchrone. On pourrait fabriquer un Observable qui s’assure que les appels sont asynchrones (dans un Thread différent,…). Des appels "en rafale" de cette méthode sont susceptibles de créer des engorgements (c’est une défaut du modèle "push").
  • Il faudrait être sûr que les événements (les objets envoyés par le modèle) soient effectivement reçus dans l’ordre de génération. (voir également sur le Web les critiques du pattern Observer/Observable. Attention toutefois: la documentation de l’API redirige aussi vers les événements liés aux beans, ce qui est une erreur! et vers java.util.concurrent et, en particulier, vers les interfaces liées à Flow, ce qui est souhaitable!)

Dans la mesure où la connaissance respective des objets Modèle et Vue sont un point important on peut aussi considérer un cas extrème où ils ne se connaissent absolument pas et où les notifications sont asynchrones. On a alors un pattern "publication/abonnement" (publish/subscribe) basé sur mécanisme de messages.

Un autre cas apparenté sont les queues d'événements AWT: les notifications se succèdent en rafale et un mécanisme particulier assure la "fusion" de plusieurs événements successifs.

Programmation réactive avec Flow

A partir de java 9 le package java.util.concurrent propose un nouveau pattern pour régir les systèmes de publication.

A la base les rôles sont définis par des interfaces:

  • Flow.Publisher : enregistre des abonnements de Flow.Subscriber
  • Flow.Subscriber : reçoit à l’enregistrement auprès de l’objet qui publie des évènements un objet Flow.Subscription qui sera un objet partagé de contrôle des modalités d'échange. Il implante donc une méthode qui réagit à cet enregistrement, et ensuite des méthode qui réagissent à la publication d’un objet, ou à une erreur ou à la fin des opérations.
  • L’objet Flow.Subscription est manipulé par le souscripteur et "lu" par le producteur. Il notifie des requêtes de N objets que le souscripteur peut recevoir et, éventuellement, signale une demande de fin des notifications. L’implantation effective de cet objet est de la responsabilité du Publisher: une instance est transmise au Subscriber au moment de l’enregistrement.
  • Il existe également un contrat de Flow.Processor qui associe les comportements de publication et de réception. Les Processors sont des objets qui servent de relais dans les échanges. Ils peuvent opérer des transformations et recevoir des objets d’un type T et publier des objets d’un type R.

On peut comprendre que la réalisation concrète d’un Publisher est extrèmement complexe:

  • Les objets partagés Subscription posent des problèmes de concurrence d’accès (et il faut que le Publisher réagisse quand une Subscription est modifiée!). La réalisation concrète de cet objet relève aussi du codage du Publisher.
  • Il faut gérer en parallèle plusieurs Subscriber, garantir la succession des publications et éviter les "étouffements" si un Subscriber tarde à traiter un évènement.

Donc en général les programmeurs réalisent des codes de Subscriber mais s’appuient sur une implantation d’un SubmissionPublisher.

Dans l’exemple suivant on a un Processor qui reçoit des changements de valeurs boursières (par exemple du réseau) et qui reventile ces objets Cote à des vues:

//voir cours sur les aspects avancés de java.util.concurrent
import java.util.concurrent.Executors;
import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;


 // en fait un Flow.Processor<Cote,Cote>
public class PublicationCotes extends SubmissionPublisher<Cote> implements Flow.Subscriber<Cote>{
   // l'objet de gestion des requêtes
    Flow.Subscription subscription ;

    public PublicationCotes() {
        // exécuteur mutitâche
       //voir leçon sur java util concurrent
        super(Executors.newFixedThreadPool(
                   // n threads parallèles
                   // associé au nombre de "processeurs" disponibles
                   Runtime.getRuntime().availableProcessors()),
                      // taille du buffer d'attente lié à chaque abonné
                      100);
    }

    // ici le code fournisseur prendra l'initiative d'arréter
    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription ;
        // accepte un flot infini
        subscription.request(Long.MAX_VALUE);
    }

    // on reventile les Cotes reçues
    @Override
    public void onNext(Cote item) {
        submit(item)   ;
    }

    // si ça se passe mal on notifie les abonnés
    @Override
    public void onError(Throwable throwable) {
              closeExceptionally(throwable);
    }

    // on notifie aux abonnés la fin des opérations
    @Override
    public void onComplete() {
                 close();
    }
}

Maintenant une classe abstraite qui représente les vues possibles :

import java.util.concurrent.Flow;

public  abstract class VueAbstraite implements Flow.Subscriber<Cote>{
   // l'objet de gestion des requêtes
    Flow.Subscription subscription ;

    //on enregistre l'objet de régulation
    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription;
        //on commence par demander un élément
        subscription.request(1);
    }

    public abstract void traiterCote(Cote item);

    // comportement analogue à un itérateur
    @Override
    public void onNext(Cote item) {
       //on redemande l'élément suivant
        subscription.request(1);
        traiterCote(item);
    }
}

Note: penser à un service pour associer objet de publications et objets abonnés.

Modèle, Vue, Contrôleur

Nous avons déjà vu le rôle d’un objet contrôleur pour découpler Modèle et Vue. Ce rôle est important dans les codes d’interfaces graphiques si l’on considère que le code "métier" ne doit pas être couplé avec l’IHM.

Les codes "Contrôleurs" ont un rôle d’intermédiation. Ils mettent en relation les modèles et les vues (et souvent les créent). Selon les circonstances ils permettront une communication directe, ou contrôleront ces communications ou mettront en place des codes "adaptateurs" qui permettront ces communications. En fonction d'événements reçus par le contrôleur celui-ci peut également gérer tout un scénario d’enchainements de vues (et de mises en relations avec les modèles).

On obtient alors des catégories de pattern différents (qui souvent se combinent dans des architectures complexes sur plusieurs niveaux). L’important est de ne pas perdre de vue les objectifs: répartition des rôles, généralisation/abstraction, découpage en composants, découplage.

Singleton

Certaines fonctions sont rendues par un objet unique.

Du moins c’est ce que nous pensons initialement … mais peut-être qu’un jour nous changerons d’avis et découvriront qu’il en faut plusieurs.

Donc nous pouvons rendre initialement un objet unique tout en cachant à l’utilisateur cette unicité!

public class AccesAuSysteme {
    //classe non publique
    static class SingletonSys {
      SingletonSys() {
         //initialisation de l'accès au système d'exploitation
         // ou recherche d'une instance de Service par un ServiceLoader
      }
       public Object rechercheInfo(String argument) throws ExceptionInfoManquante{ // evt. synchronized si nécessaire
          // code
       }
    }
    // ou autre système d'initialisation au load-time (voir architecture de Service!!)
    // on pourrait avoir SingletonAcces comme interface publique et une recherche de classe de service
    private static final SingletonSys singleton = new SingletonSys() ;

    public AccesAuSysteme () {
    }

    public Object rechercheInfo(String argument) throws ExceptionInfoManquante{ // délégation
       singleton.rechercheInfo(argument) ;
    }
}

Méthodes "fabriques"

Dans l’exemple précédent nous aurions pu remplacer l’usage d’un constructeur par une méthode "fabrique"

public interface AccesSysteme {
   public Object rechercheInfo(String argument) throws ExceptionInfoManquante ;
}

public class AccesAuSysteme {
  public static AccesSysteme getInstance() {
     // code retournant un Singleton
     // ou  une autre instance si on change d'avis
  }
}

Les méthodes fabriques sont, en particulier, intéressantes quand le code réalisant doit décider du type d’objet à créer sans que le code client ait besoin de le connaître.

Exemple: dans la classe java.net.URL la méthode InputStream openStream() rend un InputStream particulier en fonction des caractéristiques de l’URL (par ex. lecture dans un fichier ou sur le réseau).

Un exemple plus complexe: le "décorateur"

Introduction: combinaison héritage/délégation

Il n’existe pas d’héritage multiple en Java. Si l’on veut récupérer des comportements de deux classes différentes il faut explicitement l'écrire:

image: heritage multiple

Ici le ClientSalarie hérite des méthodes de Client et définit des méthodes de Salarie en déléguant leur réalisation à une instance locale de Salarie.

Très probablement on créera un constructeur :

     public ClientSalarie(Salarie salarie, ....) {
          super(...) ;
          this.sal = salarie ;
          ....
    }

Introduction: un cas particulier d’héritage/délégation

image: fils-pere

Ici chaque méthode de la super-classe est redéfinie pour être déléguée … à une instance de la super classe!

Tous les constructeurs prennent en paramètre une instance de la superclasse!

class FilsPere extends Pere {
    private Pere pere ;

    public FilsPere(Pere pere) {
        this.pere = pere ;
    }

    public void f() {
        pere.f() ;
    }
    ...
}

A quoi peut bien servir un dispositif aussi étrange?

Un arbre d’héritage complexe…

image: decorateurs

et même très complexe!:

  • Les classes FilsA et FilsB héritent de Père en spécialisant ses méthodes. Le comportement de ces méthodes est donc différent et spécifique aux classes FilsA et FilsB.
  • La classe FilsPere correspond au dispositif vu précédemment, n’oublions pas que son constructeur est FilsPere(Père)
  • Les classes FilsEnrichi et FilsDécoré héritent de FilsPere. Leurs méthodes peuvent être redéfinies mais s’appuient in fine sur les méthodes de même nom de la classe Père. Dans le cas de FilsEnrichi on créée de nouvelles méthodes dont la réalisation s’appuie sur les méthodes fondamentales de la classe Père. Les constructeurs de ces classes utilisent toujours une instance de Père.
  • On peut alors construire une instance de FilsDécoré qui s’appuie sur les comportements de FilsEnrichi et de FilsA:

    new FilsDecore(new FilsEnrichi(new FilsA())) ;

On a ainsi composé des comportements en agissant de manière transversale dans l’arbre d’héritage.

Nous allons donner un exemple pratique de cette trame structurelle (nommé "pattern décorateur").

Application du pattern "décorateur" aux E/S en Java:

image: call back