Architecture des applications

Introduction

Une application c'est

Les classes valeurs

Objectif : représenter les données manipulées par l'application sans référence aux traitements.

La définition des classes valeurs peut être obtenue par une phase de conception UML (Diagramme de classes) ou par une méthode d'analyse des données (Modèle conceptuel des données).

L'écriture de ces classes peut être prise en charge par des outils automatiques (à partir de schémas UML, relationnels ou XML).

Les JavaBeans

En Java, les classes valeurs sont représentées par des JavaBeans. Un JavaBean est une classe qui respecte les contraintes suivantes :

Plus d'information sur les javaBeans.

Un exemple de JavaBean :

package fr.sample.beans;

import java.util.Set;

public class Person {
   // properties
   private String name;
   private boolean student;
   private Set<Person> friends;

   // constructor
   public Person() { }

   // getters
   public String getName() { return name; }
   public boolean isStudent() { return student; }
   public Set<Person> getFriends() { return friends; }

   // setters
   public void setName(String name) { this.name = name; }
   public void setStudent(boolean student) { this.student = student; }
   public void setFriends(Set<Person> friends) { this.friends = friends; }

}

Vous pouvez noter la construction normalisée des noms de méthodes et l'utilisation du changement de casse pour construire les identificateurs. Il faut également noter la forme particulière des getters quand la propriété est un booléen.

Dernières remarques :

Les différents types de JavaBeans

Suivant le contexte, nous pouvons avoir plusieurs JavaBeans pour représenter une donnée :

aa/beans.png

Où sont les traitements ?

Un exemple : comment implanter une méthode de sauvegarde ?

public class Person {
   ...

   public void save() { ... }
   
   ...
}

Pour implanter cette méthode nous devons avoir des informations sur la nature (BDR, XML, etc.). et les paramètres (login, mot de passe, etc.) du système de persistance.

Ces informations ne peuvent pas être représentées par une propriété du bean Person :

Nous devons donc ajouter un paramètre à cette méthode :

public class Person {
   ...

   public void save(JDBCParameters where) { ... }

   ...
}

Le paramètre where est obligatoire car il indique où effectuer la sauvegarde (classe JDBCParameters). Cette approche pose d'autres problèmes :

Nouveaux objectifs :

Solution : il faut ranger le code technique de sauvegarde dans une classe spécialisée qui va se charger de la sauvegarde de tous les beans :

public class JDBCStorage {
   ...

   public void save(Person p) {
      ...
   }
   
   ...
}

Il peut exister plusieurs versions de cette classe (JDBCStorage, FileStorage, XmlStorage) qui rendent le même service mais de manière différente.

public class JDBCStorage implements Storage {
   ...
}

public class FileStorage implements Storage {
   ...
}

Ces implantations partagent la même interface ou peuvent hériter de la même classe abstraite. Le partage d'interfaces est une solution préférable.

Nous venons de définir les classes de service.

Les classes de service

Un service logiciel c'est

Les utilisateurs d'un service travaillent à partir de la spécification (interface) et ignorent les détails de l'implantation sous-jacente.

Le rôle de la couche d'intégration est de faire le lien entre les utilisateurs d'une spécification et une implantation particulière.

Une application doit être concue comme un ensemble de services construits les uns à partir des autres en vue de répondre aux spécifications détaillées.

aa/arch-app.png

Les services sont développés indépendamment et la couche d'intégration va faire le lien entre A/C, A/D, B/D, C/B et C/D.

On peut classer les services en fonction de leur rôle.

Les services d'accès aux données

aa/dao.png

Une couche DAO (Data Access Object) offrent plusieurs services :

Les contrôleurs

aa/controleur.png

Un contrôleur assure :

Un contrôleur est un point d'entrée d'une couche logicielle.

Les vues

aa/vue.png

Les vues assurent :

Les services métier

aa/metier.png

Les services « métier » assurent les opérations de traitement des données.

Caractéristiques :

Les types de services

Les services peuvent :

Spécification d'un service

Une spécification décrit « ce qui est fait » sans préciser « comment le faire ». En Java, elle est exprimée par une ou plusieurs interfaces :

package fr.sample.services.mailer;

public interface IMailer {

   void sendMail(String subject, String body,
      String from, String to, String cc);

}

Les interfaces d'un service sont rangées dans un paquetage particulier. Ce n'est peut-être pas la même personne qui va développer l'interface et son implantation.

Gestion des erreurs

Les erreurs émises ne doivent pas dévoiler les choix d'implantation.

package fr.sample.services.mailer;

public interface IMailer {

   void sendMail(String subject, String body,
      String from, String to, String cc)
      throws MailerException;

}

Pour éviter cette fuite d'information, le paquetage doit regrouper l'interface et la définition des exceptions du service :

package fr.sample.services.mailer :
   | IMailer.class
   | MailerException.class

Les données manipulées

Une spécification peut utiliser, voir définir les classes valeurs dont elle a besoin :

package fr.sample.services.mailer;

public interface IMailer {

   void sendMail(Mail mail) throws MailerException;

}

Le paquetage regroupe maintenant les interfaces, les javaBeans et les exceptions. C'est le cas le plus général.

package fr.sample.services.mailer :
   | IMailer.class
   | MailerException.class
   | Mail.class

Un service pour les données

La définition des données peut être vue comme un service. Le paquetage contient les JavaBeans (classes) sans interface ni exception.

On peut également définir les données par des interfaces :

package fr.sample.service.values;

import java.util.Collection;

public interface IPerson {

   // getters
   String getName();
   boolean isStudent();
   Collection<IPerson> getFriends();

   // setters
   void setName(String name);
   void setStudent(boolean student);
   void setFriends(Collection<IPerson> friends);

}

Dans ce cas, il faut également fournir un service de création des instances des JavaBeans qui respectent l'interface :

package fr.sample.service.values;

public interface IValuesFactory {
   
   IPerson newPerson();

}

Nous obtenons une indépendance complète entre la définition des données, leur implantation et leur utilisation.

L'implantation d'un service

Les classes d'implantation doivent :

Attention : il est assez difficile d'avoir plusieurs implantations interchangeables.

Cet objectif est néanmoins important car il permet de découpler client et fournisseur de service et donc

Factoriser l'implantation

Si nous offrons plusieurs implantations d'un même service, il est probable que certaines méthodes puissent partager leur définition.

Pour ce faire, nous devons définir une classe d'implantation abstraite qui va regrouper ce code commum :

package fr.sample.imp.mailer;

import fr.sample.services.mailer.IMailer;
import fr.sample.services.mailer.Mail;
import fr.sample.services.mailer.MailerException;

public abstract class AbstractMailer implements IMailer {

   protected void checkMail(Mail m) throws MailerException {
      // check addresses, body and destination
   }
   
   ... autres méthodes implantées

}

Le code d'implantation

Une première solution consiste à créer une classe d'implantation dont le constructeur permet de récupérer les paramètres de fonctionnement :

package fr.sample.imp.mailer;

import fr.sample.services.mailer.*;

public class SmtpMailer extends AbstractMailer implements IMailer {
   // SMTP server name
   final private String host;

   public SmtpMailer(String host) {
      super();
      if (host == null) throw new NullPointerException();
      this.host = host;
   }

   public void sendMail(Mail mailToSend) throws MailerException {
      checkMail(mailToSend);
      // send mail to the SMTP server
      ...
   }

}

son utilisation est simple :

IMailer mailer = new SmtpMailer("mail.dil.univ-mrs.fr");
mailer.sendMail(mail);

Mais

Un JavaBean pour l'implantation

Nous allons utiliser les getters et les setters pour gérer les paramètres et nous introduisons deux nouvelles méthodes d'initialisation et de clôture.

package fr.sample.imp.mailer;
import fr.sample.services.mailer.*;

public class JavaBeanSmtpMailer extends AbstractMailer implements IMailer {
   // SMTP server name
   private String host = "localhost";

   // getter and setter for parameters
   public String getHost() { return host; }
   public void setHost(String host) { this.host = host; }

   // initialize service and ressources
   public void init() {
      if (host == null) throw new IllegalStateException("no SMTP host");
      ...
   }

   // close service and ressources
   public void close() { ... }
   
   public void sendMail(Mail mailToSend) throws MailerException { ... }
}

son utilisation est un peu moins simple :

JavaBeanSmtpMailer mailer = new JavaBeanSmtpMailer();
mailer.setHost("mail.dil.univ-mrs.fr");
mailer.init();
mailer.sendMail(mail);
mailer.close();

Mais

Les singletons

De nombreux services sont souvent implémentés par des singletons c'est-à-dire des classes à instance unique.

Dans un environnement Multi-Threads, il est primordial de prévoir des sections critiques qui protègent les ressources critiques (ressources extérieures ou propriétés de la classe d'implantation).

Une autre solution consiste à créer une instance par client. Dans cette optique, il est nécessaire de mettre en place un service de création des instances (factory) qui lui, est un singleton.

Si le service ne gère pas le multi-threading, c'est le client qui doit le prévoir. Attention : les classes façades peuvent masquer le service réel et donc rendre inefficace les clauses synchronized.

Tests unitaires

Une fois les services spécifiés, nous pouvons prévoir une campagne de tests.

Cette étape de tests unitaires permet de vérifier

La recherche d'erreur est facilitée par le fait que les couches sont clairement isolées.

Conseil 1 : les jeux de tests doivent être rédigés avant l'implantation du service.

Conseil 2 : Testez un service à chaque étape de son développement.

Dans un deuxième temps, ces tests unitaires doivent être complétés par des tests d'intégration.

Injection de dépendances

Ce principe traite le délicat problème de la communication et de la dépendance entre service logiciel.

Imaginons que nous ayons un service d'envoi de mail par un utilisateur authentifié. L'implantation de ce service nécessite le service Mailer.

public class AuthMailer implements IAuthMailer {
   // a mailer
   JavaBeanSmtpMailer mailer = new JavaBeanSmtpMailer();
   
   // init service
   public void init() {
      mailer.setHost("mail.dil.univ-mrs.fr");
      mailer.init();
   }
   
   ...

Dans cette version nous introduisons un dépendance involontaire avec une implantation particulière de IMailer et le nom du serveur de mail n'est pas correctement paramétré.

Pour régler ce problème, nous allons

Le code devient

public class AuthMailer implements IAuthMailer {
   // mailer parameter
   IMailer mailer;
   
   // init service
   public void init() {
      if (mailer == null) throw new IllegalStateException("no mailer");
   }
   
   // setter and getter
   public IMailer getMailer() { ... }
   public void setMailer(IMailer mailer) { ... }
   
   ...

Le code d'intégration (programme principal) devient :

// a SMTP mailer
JavaBeanSmtpMailer mailer = new JavaBeanSmtpMailer();
mailer.setHost("mail.dil.univ-mrs.fr");
mailer.init();
// mailer for users
AuthMailer am = new AuthMailer();
am.setMailer(mailer);
am.init();
... use am
am.close();
mailer.close();

Nous pouvons très facilement et sans modifier la couche AuthMailer changer la stratégie d'envoi des mails. Il suffit de changer les quatre premières lignes du code d'intégration.

C'est la partie intégration qui se charge d'injecter dans le service utilisateur la référence vers le service utilisé.

Initialiser une application revient à créer les services logiciels, injecter les dépendances et appeler les méthodes d'initialisation (callback).

Composant logiciel

Nous venons de définir la notion de composant logiciel :

Inversion de contrôle

L'injection des dépendances peut aussi être vu comme une inversion de contrôle :

aa/invcont1.png

Dans la deuxième approche nous décomposons un problème complexe (C) en problèmes plus simples (B puis A).

Paralléliser le développement

Nous pouvons même paralléliser le développement des couches en prévoyant une implantation vide de chaque spécification (bouchon) et en suivant les étapes ci-dessous :

Nous devrons ensuite réaliser un test d'intégration.

Note : Cette démarche correspond bien aux méthodes agiles qui sont dirigées par les tests et qui préconisent une première implantation simple et juste puis une série d'améliorations.