TP4 – Les design patterns créateurs Design Patterns, Qualité, SOLID, Abstraction, Dépendances
Introduction
Quand on parle de design patterns on fait généralement référence aux patterns dits GoF (Gang of Four) présentés dans le livre Design Patterns: Elements of Reusable Object-Oriented Software, qui est une référence dans le monde de l’ingénierie logicielle. Le Gang of Four fait référence au quatre auteurs de ce livre : Erich Gamma, Richard Helm, Ralph Johnson et John Vlissides.
Pour rappel, un design pattern est une solution réutilisable et adaptable vis-à-vis d’un problème de conception logicielle assez récurent. Un pattern est généralement présenté via un diagramme de classes de conception et un exemple d’implémentation. Un pattern a pour but d’être applicable et adaptable à n’importe quel projet ou contexte.
Il existe d’autres types de design patterns (par exemple, les patterns GRASP) mais les plus connus et les plus utilisés sont ceux du GoF que nous allons explorer dans ce cours. Tous ces patterns permettent de renforcer l’application des principes SOLID. Par ailleurs, certains langages comme Java
proposent (dans leur librairie standard) des classes et interfaces permettant d’implémenter certains patterns GoF comme observateur, itérateur, prototype…
Les design patterns GoF se divisent en trois catégories :
-
Les patterns créateurs : concernent la création d’objet. Ils permettent de mettre en place des solutions et des structures pour contrôler l’instanciation des objets du programme. Cela permet de réduire les dépendances de type “create”.
-
Les patterns structuraux : permettent d’organiser les objets en faisant notamment de l’abstraction pour augmenter la modularité du programme et l’ajout de nouvelles fonctionnalités (principe ouvert/fermé).
-
Les patterns comportementaux : permettent d’organiser la collaboration et les communications entre les objets du programme afin de distribuer efficacement les responsabilités.
Dans le TP précédent, vous avez déjà vu un pattern structurel (Décorateur) et un pattern comportemental (Stratégie). Dans le premier TP git, le programme utilisait un pattern créateur (fabrique) et un autre pattern comportemental (commande). Bref, les design patterns sont partout !
Dans ce TP, nous allons voir tous les patterns créateurs et allons les mettre en application : Singleton, Builder, Prototype et enfin Fabrique Abstraite.
Nous verrons aussi un autre pattern structurel appelé adaptateur. En fait, nous allons voir que beaucoup de ces patterns peuvent (et doivent) être combinés dans un projet plus large. Nous traitons souvent des problèmes “simple” avec quelques classes pour illustrer le fonctionnement d’un pattern, mais sur un projet plus large, il est tout à fait naturel de faire travailler en concert les différents patterns.
-
Pour commencer, forkez le dépôt GitLab suivant en le plaçant dans le namespace
qualite-de-developpement-semestre-3/etu/votrelogin
. -
Clonez votre nouveau dépôt en local. Ouvrez le projet avec votre IDE et vérifiez qu’il n’y a pas d’erreur.
-
Pendant ce TP, on vous demandera de créer des diagrammes UML de conception (classes, séquences). Vous déposerez vos diagrammes dans un dossier
uml
(en image ou bien avec le fichier du projet .mdj si vous utilisez StarUML) que vous devrez créer à la racine du dépôt. -
À la fin de chaque séance, n’oubliez pas de faire un push vers votre dépôt distant sur GitLab.
Le pattern Singleton
L’idée du pattern Singleton est très simple : on souhaite garantir qu’il n’existe qu’une seule instance d’une classe dans le programme et fournir un point d’accès global à cette instance. Dès qu’un autre objet souhaite utiliser cette classe, il utilise l’instance “globale” définie dans l’application et ne doit pas pouvoir en instancier lui-même (l’utilisation de new doit être bloqué en dehors de la classe). L’instance globale doit être accessible à n’importe quel endroit de l’application (à travers une méthode qui l’expose).
Pour faire cela, il existe deux solutions :
-
Utilisation d’une classe statique (toutes les méthodes et attributs statiques). C’est une solution à éviter, nous verrons pourquoi par la suite.
-
Utilisation du pattern Singleton.
Voyons un exemple :
public class Service {
public void actionA() {
...
}
public void actionB() {
...
}
public int calcul() {
...
}
}
public class Client {
public static void main(String[]args) {
Service s = new Service();
s.actionA();
s.actionB();
ClasseExemple c = new ClasseExemple();
c.operation();
}
}
public class ClasseExemple {
private Service service;
private int data;
public ClasseExemple() {
service = new Service();
data = service.calcul();
}
public void operation() {
service.actionB();
}
}
Dans cet exemple, nous voyons qu’il y a au moins deux instances de la classe Service qui sont générées. Une dans Client
et une autre dans ClasseExemple
. S’il n’y a aucun intérêt à avoir plusieurs instances de la classe Service
, nous pouvons alors la transformer en Singleton afin que toutes les classes qui souhaitent utiliser passent par la même instance.
Pour cela, il y a trois changements à réaliser dans la classe qu’on veut transformer en Singleton :
-
Changer la visibilité du ou des constructeurs de la classe en privé. Ainsi, plus aucune autre entité pourra créer des instances de cette classe (hormis la classe elle-même).
-
Dans la classe, créer un attribut privé et statique (un attribut de classe :
static
en Java) du même type que la classe. Cet attribut est généralement nomméINSTANCE
(en majuscules). -
Enfin, créer une méthode publique et statique qui retourne un objet du type de la classe. Cette méthode est nommée
getInstance
. Tout d’abord, elle regarde si la variableINSTANCE
a été initialisée ou non (si elle estnull
ou non). Si elle n’est pas encore initialisée, la classe l’instancie. Dans tous les cas, à la fin, on retourneINSTANCE
.
Par la suite, toutes les entités qui souhaitent récupérer une instance de cette classe utilisent la méthode getInstance
qui peut directement être appelée sur la classe, car statique. Lors du premier accès, l’instance “globale” sera alors initialisée.
Dans un environnement multi-threadé (exécution de plusieurs processus en parallèle, comme des Runnable
ou des Thread
en Java) il faut faire attention à ce que la méthode getInstance
ne soit pas appelée en même temps par deux Threads différents. Pour régler cela en Java, il suffit d’ajouter le mot clé synchronized
lors de la définition de la méthode.
Regardons ce que cela donne :
public class Service {
private static Service INSTANCE;
//Interdit l'instanciation en dehors de la classe Service
private Service() {}
//On appelle systématiquement cette méthode pour récupérer l'instance "globale"
public synchronized static Service getInstance() {
if(INSTANCE == null) {
INSTANCE = new Service();
}
return INSTANCE;
}
public void actionA() {
...
}
public void actionB() {
...
}
public int calcul() {
...
}
}
public class Client {
public static void main(String[]args) {
Service s = Service.getInstance();
s.actionA();
s.actionB();
ClasseExemple c = new ClasseExemple();
c.operation();
}
}
public class ClasseExemple {
private int data;
public ClasseExemple() {
data = Service.getInstance().calcul();
}
public void operation() {
Service.getInstance().actionB();
}
}
Ici, on a la garantie que la classe Client
et la classe ClasseExemple
utilisent la même instance de Service
.
Ce pattern s’avère très utile quand nous avons une classe (généralement un service) que plusieurs classes doivent utiliser et qu’il n’y a pas besoin d’instancier celui-ci à chaque fois (notamment si son paramétrage est complexe). Cependant, nous verrons aussi qu’il ne faut pas en abuser et limiter son utilisation, car cela peut engendrer certains problèmes, notamment au niveau des tests unitaires.
Mise en application
Commençons par une mise en application très simple.
-
Ouvrez le paquetage
singleton1
. Cette petite application dispose d’une classeCalculatrice
permettant de réaliser des opérations de gérer un historique de résultat afin de pouvoir revenir en arrière. -
La classe
Calculatrice
est un service qui doit pouvoir être utilisé par toutes les classes de l’application (il n’y en a qu’une seule pour le moment, mais le programme va grandir dans le futur). On souhaite que pour réaliser des opérations, toutes les classes utilisent la même instance de la classeCalculatrice
. Appliquez le pattern Singleton afin de transformer la classeCalculatrice
en Singleton. -
Normalement, si vous avez bien traité la question précédente, vous allez avoir des erreurs dans
Client
. Adaptez le code de cette classe pour que tout fonctionne.
Dépendance commune
Un cas un peu plus concret qui montre l’intérêt d’utiliser un Singleton
est dans le contexte où deux objets différents dépendent de l’état d’une classe. Une classe A
modifie l’état de Service
et une classe B
a besoin de connaître les modifications qu’a effectuées A
dans Service
pour fonctionner. Les deux classes A
et B
doivent alors dépendre de la même instance de Service
!
Aussi, imaginons une classe d’accès à une base de données : celle-ci initialise une connexion vers la base qui peut prendre plusieurs secondes. Il serait inconscient d’instancier cette classe à chaque endroit où on en a besoin (cela dégraderait les performances !). On va plutôt utiliser un Singleton. C’est le cas de la classe SQLUtils
que vous avez codé dans le TP “JDBC / Hibernate” en cours de base de données.
-
Ouvrez le paquetage
singleton2
. Cette application permet de créer des animaux avec un nom et une vitesse, de les stocker dans la mémoire et de les lire. -
Testez l’application. Créez plusieurs animaux puis tentez de les lire. Quels sont les deux problèmes que vous constatez ?
-
Réglez cela en appliquant le pattern Singleton sur la classe
StockageAnimaux
et en adaptant votre code. -
Vérifiez que tout fonctionne et que les bugs sont bien résolus.
-
Le principe SOLID
D
(dependency inversion) est-il respecté sur les classesCreationAnimal
etLectureAnimaux
? Si ce n’est pas le cas, refactorez votre code.
Classe statique VS Singleton
En introduction de cette section, nous avions évoqué le fait d’utiliser une classe statique pour obtenir un résultat similaire au singleton. Une classe statique est une classe où il n’y a que des attributs et des méthodes de classe qui appartiennent donc à la classe et pas à une instance en particulier. Ainsi, tous les objets qui manipulent cette classe utilisent la même “entité” (en fait, il n’y a juste pas d’instance).
Par exemple, on pourrait remplacer ce Singleton :
public class Service {
private String data;
private static Service INSTANCE;
private Service() {}
public synchronized static Service getInstance() {
if(INSTANCE == null) {
INSTANCE = new Service();
}
return INSTANCE;
}
public void action() {
...
}
public String getData() {
return data;
}
public String setData(String data) {
this.data = data;
}
}
class Main {
public static void main(String[]args) {
Service service = Service.getInstance();
service.setData("test");
System.out.println(service.getData())
}
}
Par cela :
public class Service {
private static String data;
static {
//Opérations à réaliser lors de l'initialisation de la classe
}
public static void action() {
...
}
public static String getData() {
return data;
}
public static String setData(String data) {
Service.data = data;
}
}
class Main {
public static void main(String[]args) {
Service.setData("test");
System.out.println(Service.getData())
}
}
Si cette méthode pourrait paraître plus “facile” à mettre en place et à utiliser (pas de getInstance
, etc.) c’est généralement une mauvaise idée. Nous allons voir pourquoi avec le prochain exercice.
-
Ouvrez le paquetage
singleton3
. Cette application permet de simuler des services de création d’utilisateurs et de gestion de commandes. Dans une application concrète, ces deux services sont susceptibles d’envoyer des mails. Testez un peu l’application. Aussi, deux classes de tests sont disponibles dans le dossierjava/singleton3
. -
Il semble inutile d’instancier la classe
ServiceMail
à chaque fois. Cette fois-ci, au lieu d’utiliser unSingleton
, nous allons transformerServiceMail
en classe statique. Faites ce changement et adaptez le code dans les classesServiceUtilisateur
etServiceCommande
. -
Si ce n’est pas déjà fait, lancez les tests unitaires des classes
ServiceUtilisateurTest
etServiceCommandeTest
. On voit bien quand dans un contexte réel, les mails seraient vraiment envoyés à chaque test ! C’est un problème. -
Nous avions apporté une solution à ce même problème dans le TP précédent en utilisant l’injection de dépendances. Au lieu d’instancier directement le service “mail” dans la classe qui en a besoin, nous l’avions injecté via le constructeur. L’idée serait alors de :
-
Créer une interface
ServiceMailInterface
avec la signature la méthodeenvoyerMail
. -
Faire implémenter cette interface à
ServiceMail
. -
Créer une classe
FakeMailer
qui implémenteServiceMailInterface
et qui ne fait rien (ou quelque chose relatif aux tests) dansenvoyerMail
. -
Faire en sorte que
ServiceUtilisateur
etServiceCommande
possèdent un attribut de typeServiceMailInterface
qui sera injecté via le constructeur et l’utilisent à la place de l’instance concrèteServiceMail
. -
Injecter le
ServiceMail
quand on instancieServiceUtilisateur
etServiceCommande
dans l’application. -
Injecter le
FakeServiceMail
quand on instancieServiceUtilisateur
etServiceCommande
dans nos tests unitaires.
Un tel refactoring est-il possible ici? Essayez de refactorer et relevez les problèmes que vous rencontrez.
-
En essayant de refactorer, vous avez dû rencontrer divers problèmes :
-
Une
interface
(dans la plupart des langages) ne peut pas posséder de signature de méthode de classe (static
). -
Il est impossible d’injecter la classe statique comme paramètre d’un constructeur. Et oui, une classe n’est pas un objet et une instance ! Elle ne peut donc pas être passée en paramètre.
Bref, l’utilisation d’une classe statique rend les services qui l’utilisent étroitement dépendant de la classe. Ce qui rend l’application intestable car ces services ne peuvent pas être testés indépendamment des services concrets qui sont utilisés dans l’application. On ne peut pas remplacer le service par un “faux” service utilisé pour les tests unitaires (un stub ou un mock).
L’utilisation d’un Singleton résout tous ces problèmes :
-
L’instance de la classe est une véritable instance, un objet. Elle peut donc être passée en paramètre dans un constructeur, par exemple.
-
La classe peut hériter, implémenter des interfaces…
public interface Service {
void action();
}
public class ServiceConcret implements Service {
private static ServiceConcret INSTANCE;
private ServiceConcret() {}
public static ServiceConcret getInstance() {
if(INSTANCE == null) {
INSTANCE = new ServiceConcret();
}
return INSTANCE;
}
public void action() {
...
}
}
public class Exemple {
private Service service;
public Exemple(Service service) {
this.service = service;
}
}
class Main {
public static void main(String[]args) {
Exemple ex = new Exemple(ServiceConcret.getInstance());
}
}
-
À la place d’une classe statique, utilisez le pattern
Singleton
pour refactorer la classeServiceMailer
. -
Comme expliqué dans l’exercice précédent, introduisez une interface
ServiceMailerInterface
pour abstraire ce service. -
Créez un nouveau service
FakeServiceMail
implémentant aussiServiceMailerInterface
mais qui ne fait rien dans sa méthodeenvoyerMail
. Ce service sera aussi un singleton. -
Refactorez
ServiceUtilisateur
etServiceCommande
pour injecter la dépendance nécessaire au lieu de directement récupérer l’instance du singleton dans la classe viagetInstance
. -
Adaptez le
Main
afin que le service concret utilisé soitServiceMailer
dans les deux autres services. -
Adaptez aussi les tests pour utiliser
FakeServiceMail
. Vérifiez que l’exécution des tests ne déclenche plus l’envoi (fictif) de mails. -
Réalisez le diagramme de classes de conception de l’application.
Avertissement
Vous maîtrisez maintenant le pattern Singleton, mais vous devez être très précautionneux quant à son utilisation. En conclusion du dernier TP vous étiez invité à aller jeter un coup d’œil aux principes STUPID qui sont un ensemble de mauvaises pratiques en conception logicielle. Regardez à quoi correspond le S
.
En effet, l’abus de singletons est dangereux pour la santé (du logiciel…) ! Il présente une solution qui semble plutôt efficace à un problème assez récurent : la gestion des dépendances vers les services de l’application. À tel point qu’on retrouve parfois dans certains projets beaucoup trop de singletons.
Mais pourquoi est-il déconseillé d’utiliser “trop” de singletons :
-
Il n’est pas possible de passer des paramètres à un singleton pour son initialisation (comme un objet classique) car c’est lui-même qui se construit, cependant, on peut éventuellement mettre en place un fichier de configuration que le singleton ira lire lors de son initialisation.
-
Il est très difficile (voir impossible) de tester un singleton ! Comme le constructeur est privé et qu’il n’y a qu’une instance de l’objet dans l’application, on ne peut tester que cette instance. On ne peut pas instancier de nouveaux objets de cette classe pendant les tests unitaires.
-
Il existe des solutions bien meilleures pour gérer les dépendances de l’application de manière plus flexibles tout en conservant la possibilité de réaliser des tests unitaires : les fabriques abstraites (dont nous allons bientôt parler) et le conteneur d’inversion de contrôle (conteneur IoC, dont nous parlerons au prochain TP).
Attention, tout cela ne veut pas dire que le singleton est inutile ! Seulement qu’il ne doit pas être utilisé pour tout et n’importe quoi. Il faut également se poser la question “est-ce que cette instance doit pouvoir être accessible par tout le monde” ?
On va notamment éviter d’appliquer singletons sur des classes qui contiennent du code métier relatif à des règles de notre application et que nous allons sûrement vouloir tester. À l’inverse, nous allons bientôt parler des Fabriques et du pattern Fabrique Abstraite. Les classes produites par ces applications ont pour but de créer et/ou fournir des objets (des dépendances). Ces classes doivent pouvoir être accessibles par toute l’application et on ne va pas spécialement les tester (pas de code métier). Il est alors judicieux d’appliquer Singleton sur ces classes.
Certains membres du GoF regrettent un peu la surutilisation maladroite de Singleton par certains développeurs. Ces développeurs ont l’illusion de coder proprement, car ils utilisent un design pattern, mais c’est tout l’inverse. Appliquer les design patterns ne veut pas forcément dire que votre conception est de qualité. Il faut savoir quand et comment les utiliser !
Le pattern Builder
Le design pattern Builder est un pattern que vous avez déjà abordé l’année dernière en cours de développement orienté objets. Cela devrait donc vous rappeler quelques souvenirs.
Ce pattern permet de résoudre le problème de création d’un objet qui a beaucoup d’options. Voyons donc cela avec un petit exercice.
-
Ouvrez le paquetage
builder1
. Dans cette application, nous voulons construire des burgers. Dans le restaurant, le client peut composer son burger. Un Burger est obligatoirement composé d’un nom (donné par le client) et d’un type de pain (oui, s’il veut, un client peut avoir un burger juste avec du pain). Le client peut ensuite décider d’ajouter une viande, ou des tomates, ou les deux.À ce stade, il y a donc quatre types de burger possibles (même si les types de pains et de viandes diffèrent) :
-
Un burger avec du pain.
-
Un burger avec du pain et une viande.
-
Un burger avec du pain et des tomates.
-
Un burger avec du pain, de la viande et des tomates.
Pour gérer tout cela, nous avons donc créé une classe
Burger
avec quatre constructeurs, pour gérer chaque cas. Cela fonctionne. -
-
Le restaurant ajoute de nouvelles options :
-
L’ajout d’un certain type de fromage dans le burger.
-
L’ajout de salade dans le burger.
Le fromage sera géré par un attribut
typeFromage
de typeFromage
dans la classeBurger
et la salade par un booléen (comme pour les tomates).Ajoutez ces nouveaux attributs à la classe
Burger
. -
-
Maintenant que nous avons ajouté ces deux options, quels sont tous les types de burgers qu’il est possible de créer ? Ajoutez de nouveaux constructeurs pour gérer tous les cas… Si vous bloquez à un moment, c’est normal ! Comprenez-vous pourquoi il n’est pas possible de poursuivre la conception actuelle ?
En effet, rien que le fait de pouvoir avoir des burgers avec du pain et des tomates et des burgers avec du pain et de la salade empêche de créer les constructeurs adéquats :
public class Burger {
...
public Burger(String nom, PainBurger typePain, boolean tomates) {
this(nom, typePain);
this.tomates = tomates;
}
//Erreur !
public Burger(String nom, PainBurger typePain, boolean salade) {
this(nom, typePain);
this.salade = salade;
}
}
Dans l’exemple précédent, les deux constructeurs entrent en collision, car ils ont la même signature Burger(String, PainBurger, boolean)
.
Pour pallier cela, on pourrait mettre en place la solution bancale suivante :
-
Instancier un Burger seulement avec son nom et son type de pain.
-
Utiliser des setters pour définir le fait d’ajouter de la salade ou des tomates.
Cette solution est mauvaise, car on part du principe que tous les setters nécessaires sont accessibles publiquement. Ce n’est pas forcément le cas et dans le cas du Burger
, seul le setter du nom
est accessible. On a décidé qu’une fois créé, on ne doit pas pouvoir modifier les ingrédients du Burger, mais qu’on peut le renommer. Il n’est donc pas possible d’ajouter des setters !
Le pattern builder permet de résoudre ce problème avec une classe qui permet de contrôler comment l’objet est créé. Cette classe est généralement placée à l’intérieur de la classe à construire même si on retrouve des versions et des variantes avec la classe “Builder” comme classe externe indépendante.
Cette classe possède :
-
Les mêmes attributs que la classe cible (du moins ceux qu’on peut préciser lors de l’initialisation de l’objet).
-
Un constructeur qui prend en paramètre tous les attributs qu’on doit obligatoirement initialiser lorsqu’on crée l’objet cible.
-
Pour chaque option, une méthode
WithNomOption(valeur)
qui permet d’ajouter une option (initialise l’attribut correspondant dans la classe du Builder). Cette méthode renvoiethis
(donc, l’instance du Builder) afin qu’on puisse enchaîner les appels des méthodeswith
pour ajouter d’autres options. -
Une méthode
build
qui permet d’instancier l’objet cible :-
Soit en instanciant l’objet puis en modifiant ses attributs : cela est possible même si les attributs de la classe cible sont privés, car la classe
Builder
est définie à l’intérieur de la classe cible ! -
Soit en définissant un constructeur prenant en paramètre un Builder dans la classe cible. L’instance se construit en allant chercher les données dans l’instance du Builder. On peut notamment ajouter des
getters
au Builder dans ce cas-là. Cette méthode a l’avantage d’aussi être compatible avec une classe Builder définie à l’extérieur de la classe.
-
Si le builder est interne, il faut aussi éliminer les constructeurs “classiques” et le constructeur “vide” de la classe (par exemple, en les rendant privés).
Voyons un exemple :
class Exemple {
//Obligatoire
private String data;
private int compteur;
//Options
private String optionA;
private boolean optionB;
private double optionC;
private boolean optionD;
public void setData(String data) {
this.data = data;
}
public void setOptionB(boolean optionB) {
this.optionB = optionB;
}
}
Dans cette classe, il y a deux attributs qu’il est obligatoire d’initialiser : data
et compteur
. Ensuite, il y a diverses options. L’attribut data
et optionB
peuvent être modifiés, mais pas le reste. Pour contrôler la construction d’instances de cette classe, nous pouvons donc mettre en place un Builder
:
class Exemple {
//Obligatoire
private String data;
private int compteur;
//Options
private String optionA;
private boolean optionB;
private double optionC;
private boolean optionD;
//On rend le constructeur privé pour obliger à passer par le Builder
private Exemple() {}
public void setData(String data) {
this.data = data;
}
public void setOptionB(boolean optionB) {
this.optionB = optionB;
}
public static class Builder {
private String data;
private int compteur;
private String optionA;
private boolean optionB;
private double optionC;
private boolean optionD;
public Builder(String data, int compteur) {
this.data = data;
this.compteur = compteur;
}
public Builder withOptionA(String optionA) {
this.optionA = optionA;
return this;
}
//Par défaut, un booléen est à false, donc ajouter cette option signifie passer la valeur à true.
// Mais on pourrait éventuellement aussi autoriser un paramètre ici.
public Builder withOptionB() {
this.optionB = true;
return this;
}
public Builder withOptionC(double optionC) {
this.optionC = optionC;
return this;
}
public Builder withOptionD() {
this.optionD = true;
return this;
}
public Exemple build() {
Exemple exemple = new Exemple();
exemple.data = data;
exemple.compteur = compteur;
exemple.optionA = optionA;
exemple.optionB = optionB;
exemple.optionC = optionC;
exemple.optionD = optionD;
return exemple;
}
}
}
public class Main {
public static void main(String[]args) {
Builder builderEx1 = new Exemple.Builder("test", 5);
Exemple ex1 = builderEx1.withOptionA("truc").withOptionD().build();
Builder builderEx2 = new Exemple.Builder("test2", 50);
Exemple ex2 = builderEx2.withOptionB().withOptionC(10.05).withOptionD().build();
}
}
Sur StarUML le fait qu’une classe soit incluse dans une autre classe est représenté par la relation “Containment” qu’on trouve dans la section “Packages” dans la boîte à outil.
Comme annoncé précédemment, on pourrait adapter le Builder
pour le passer en paramètre au constructeur à la place :
class Exemple {
...
private Exemple(Builder builder) {
exemple.data = builder.data;
exemple.compteur = builder.getCompteur();
exemple.optionA = builder.getOptionA();;
exemple.optionB = builder.getOptionB();
exemple.optionC = builder.getOptionC();
exemple.optionD = builder.getOptionD();
}
...
public static class Builder {
...
//Des getters ici...
public Exemple build() {
Exemple exemple = new Exemple(this);
return exemple;
}
}
}
-
Refactorez le code de l’application de burgers pour mettre en place un
Builder
. Adaptez leMain
en conséquence. -
Vérifiez que vous pouvez bien ajouter les burgers suivants :
-
Burger avec du pain moisi, avec de la salade et du poulet.
-
Burger avec du pain au sésame, avec des tomates, de la salade.
-
Burger avec du pain classique, avec de la salade, des tomates, de la viande de bœuf et du cheddar.
-
-
Réalisez le diagramme de classes de conception de votre application.
NB : Certains IDE (dont IntelliJ) permettent de générer automatiquement le code d’un Builder à partir d’une classe. Sur IntelliJ, il faut faire un clic droit sur le constructeur de la classe pour laquelle vous souhaitez générer le builder, puis aller dans Replace constructor with builder. Le Builder sera généré comme une classe à part. Pour la rendre interne à la classe cible, il suffira juste de faire un Drag & Drop du Builder dans sa classe cible.
Remarque : lorsqu’il s’agit d’une hiérarchie de classes (par héritage) pour laquelle il faut créer des builders, la solution n’est pas forcément évidente. En effet, il faut faire une hiérarchie de builders pour éviter la duplication de code, tout en respectant le principe LSP, à savoir : les méthodes with… doivent retourner le bon type de builder. Pour plus de détails voir le le dernier TP de Développement Orienté Objets de première année).
Le pattern Prototype
Nous allons directement présenter le principe et l’intérêt du pattern prototype à travers un exercice.
-
Ouvrez le paquetage
protoype1
et consultez les classes mises à disposition. Il s’agit d’une mini-application bancaire permettant de gérer des comptes. Pour le moment, la classeBanque
permet de gérer des comptes courants. C’est elle qui gère intégralement ces comptes bancaires : leur création et le changement de solde (créditer/débiter). Elle est donc composée des comptes bancaires (ce qui se traduirait par une agrégation forte sur un diagramme de classes de conception). -
Le développeur a pensé qu’il serait utile qu’un objet extérieur puisse récupérer un
CompteCourant
depuis la banque pour consulter le solde (ou autre). Il a donc créé une méthodegetInformationsCompteNumero
. Expérimentez dans leMain
en créditant le compte d’un objet récupéré par cette méthode puis récupérez de nouveau ce compte depuis la banque et affichez son solde. Le solde du compte t-il était modifié ? Lancez le test unitaire situé dans la classe danssrc/test/java/prototype1
. Il ne passe pas… Comprenez-vous pourquoi ?
Le test unitaire n’est pas mal écrit. En fait, comme la banque gère exclusivement les différents comptes bancaires, il ne doit pas être possible qu’une autre classe puisse récupérer et modifier les objets que contient la banque. C’est toute la sémantique derrière la composition forte (ou agrégation forte) sur un diagramme de classe. Le “composant” est créé dans la classe (c’est le cas) et ne doit pas en sortir (ce n’est pas le cas ici).
Une première solution pourrait être d’ajouter une méthode double recupererSolde(int numeroCompte)
dans la classe Banque
. Mais dans certains cas, on a vraiment besoin d’obtenir l’objet CompteCourant
cible, sans pour autant modifier l’original… alors comment faire ?
L’idée est de renvoyer une copie complète de l’objet ! Toutes les valeurs des attributs de l’objet copié seront identiques dans le nouvel objet, mais s’il est modifié, l’objet original (stocké dans Banque
) ne sera pas impacté. L’objet renvoyé par getInformationsCompteNumero
est alors un nouvel objet identique au compte, mais ce n’est pas l’objet original. Le principe de la composition est alors respecté.
Mais alors, qui doit faire cette copie ?
-
Est-ce que
Banque
est habilité à faire cela ? Cela ne semble pas trop Single responsability friendly ! De plus, est-ce queBanque
a la capacité de copierCompteCourant
? A priori, il n’a pas accès aux attributs privés ou protégés commesolde
, alors queCompteCourant
, oui. -
À la place, on peut définir un constructeur par copie dans
CompteCourant
. L’objectif d’un tel constructeur est de prendre un objet du même type et d’initialiser ses propres attributs avec les valeurs des attributs de l’autre objet. Vous avez sans doute utilisé de tels constructeurs en première année (notamment dans le cours d’initiation au développement) :class Date { private int jour; private int mois; private int annee; //Constructeur de base public Date(int jour, int mois, int annee) { this.jour = jour; this.mois = mois; this.annee = annee; } //Constructeur par copie public Date(Date dateACopier) { this(dateACopier.jour, dateACopier.mois, dateACopier.annee); } }
-
Refactorez votre code afin que
Banque
renvoie une copie du compte courant sélectionné. Attention au solde ! Il faut qu’il soit identique au compte copié. Vérifiez que cela fonctionne en lançant le test unitaire. Il devrait maintenant passer. -
On aimerait maintenant que la
Banque
gère aussi des comptes épargne. En fait, il faut qu’elle puisse gérer des CompteBancaire et pas seulement des comptes courants. refactorez le code de la classeBanque
pour rendre cela possible (en utilisant plutôt la classe abstraiteCompteBancaire
plutôt queCompteCourant
).Vous devrez néanmoins conserver la méthode
ouvrirCompteCourant
telle quelle, car elle ouvre spécifiquement un compte courant. Vous devrez aussi ajouter une méthodeouvrirCompteEpargne
(tout cela ne respecte pas vraiment le principe ouvert/fermé, mais nous allons nous en contenter pour cet exercice).Vous n’avez pas à ajouter de nouveaux attributs ou d’autres méthodes en dehors de
ouvrirCompteEpargne
. (Notamment, interdiction de faire une deuxième liste séparée gérant les comptes épargnes).Normalement, vous avez une erreur sur la méthode
getInformationsCompteNumero
. C’est normal. -
Comprenez-vous pourquoi l’implémentation de
getInformationsCompteNumero
provoque des erreurs ?
Notre solution à base de copie semblait bonne, mais elle tombe à l’eau dès lors qu’on manipule des abstractions et plus des classes concrètes ! Banque
ne peut plus utiliser le constructeur par copie de CompteCourant
, car elle gère une liste de CompteBancaire
abstraits (qui peuvent être des comptes courants, des comptes épargnes ou autre…).
Le pattern prototype permet de résoudre ce problème en précisant que :
-
Une méthode
cloner
doit être définie au niveau de l’entité abstraite (dans l’interface ou dans la classe abstraite). Cette méthode renvoie un objet du même type que la classe mère. -
Chaque enfant implémente la méthode
cloner
qui renvoie une copie de lui-même. -
Dans la signature de la méthode, l’enfant peut éventuellement remplacer le type abstrait par son propre type.
interface Exemple {
void action();
Exemple cloner();
}
class A implements Exemple {
private int data;
public A(int data) {
this.data = data;
}
public A(A objetACopier) {
this(objetACopier.data);
}
@Override
public void action() {
//...
}
@Override
public A cloner() {
//Appel de son propre constructeur par copie
return new A(this);
}
}
class B implements Exemple {
private boolean attribut;
public B(boolean attribut) {
this.attribut = attribut;
}
public B(B objetACopier) {
this(objetACopier.attribut);
}
@Override
public void action() {
//...
}
@Override
public B cloner() {
//Appel de son propre constructeur par copie
return new B(this);
}
}
Avec cette nouvelle architecture, il suffit alors d’utiliser la méthode cloner
sur l’objet abstrait :
class Service {
private Exemple ex;
public Exemple getExemple() {
return ex.cloner();
}
}
-
Refactorez votre code en appliquant le pattern prototype sur vos comptes bancaires. Adaptez aussi le code de
getInformationsCompteNumero
en conséquence. -
Vérifiez que le test unitaire passe toujours.
-
Réalisez le diagramme de séquence des interactions permettant de modéliser ce qu’il se passe lors de l’éxécution du code suivant :
Banque banque = new Banque(); int numeroCompte = banque.ouvrirCompteCourant("Test", Formule.ADULTE); banque.crediterCompte(numeroCompte, 50); CompteBancaire compte = banque.getInformationsCompteNumero(numeroCompte); compte.crediter(100);
L’objectif du pattern prototype est donc de définir un moyen d’obtenir des copies d’un objet, notamment dans un contexte où vos classes manipulent des abstractions (comme c’est souvent le cas quand on applique les principes SOLID et les design patterns). Si on souhaite pouvoir copier un objet abstrait manipulé, chaque classe concrète de la hiérarchie doit définir sa propre logique de copie via la méthode cloner
.
Remarque technique (pour Java) : En Java, un mécanisme de clonage est prévu à travers la fonction clone
de la classe Object
. Pour l’utiliser sur une classe que vous développez, il faut implémenter l’interface Cloneable
. Cette interface ne contient aucune méthode, mais elle permet de signaler que la classe peut être clonée. Le mécanisme interne de cette spécification est assez obscure et a été beaucoup critiqué (voir Item 13 du livre Effective Java de Joshua Bloch, ainsi que les discussions en ligne). Il est donc préférable de ne pas utiliser cette interface et de définir votre propre méthode cloner
comme nous l’avons fait dans l’exemple précédent.
La Fabrique - un concept très utile
Le concept de Fabrique permet de centraliser l’instanciation d’une famille de classes dans une classe spécialisée. Les classes ayant besoin d’instancier des objets de cette famille ne vont plus faire elle-même des new, mais vont plutôt passer par la fabrique.
Remarque importante : Bien que le concept de Fabrique est extrêmement pratique (et utilisé dans différents design patterns), il ne s’agit pas d’un design pattern à proprement parler. Prenons-le plutôt comme une application de règles de bon sens. En l’occurrence, il ne faut pas confondre la Fabrique avec le design pattern Méthode Fabrique (Factory Method) du GoF, dont on ne parlera pas dans ce TD. Pour les curieux, vous pouvez lire l’exemple de l’URL (correspondant à la page Wikipédia en anglais), où l’exemple en Java pour ce design pattern est bien choisi.
La fabrique connaît les différents types concrets à instancier, mais elle va généralement renvoyer des abstractions (classes abstraites/interfaces) pour que les classes ayant besoin de créer des objets ne dépendent plus du tout d’un objet concret, mais plutôt de son abstraction. Ainsi, l’impact du changement diminue (si on veut changer la classe concrète utilisée) ce qui renforce notamment le principe ouvert/fermé.
Dans une application où certaines classes sont instanciées à différents endroits, il est donc plutôt judicieux d’utiliser une fabrique pour réduire les dépendances de type “create”. Seule la fabrique est dépendante des objets concrets et les autres classes sont seulement dépendante de la fabrique (et des abstractions).
Voyons l’application de Fabrique sur un exemple :
public interface FigureGeometrique {
int getAire();
void afficherFigure();
}
public interface Carre extends FigureGeometrique {
int getTailleCote();
}
public interface Cercle extends FigureGeometrique {
int getDiametre();
}
public class CarreSimple implements Carre {
private int tailleCote;
public CarreSimple(int tailleCote) {
this.tailleCote = tailleCote;
}
@Override
public int getTailleCote() {
return tailleCote;
}
@Override
public int getAire() {
return tailleCote * tailleCote;
}
@Override
public void afficherFigure() {
//Affichage simple en console...
}
}
public class CarreGraphique implements Carre {
//Composition
private CarreSimple carre;
public CarreGraphique(int tailleCote) {
carre = new CarreSimple(tailleCote);
}
@Override
public int getTailleCote() {
return carre.getTailleCote();
}
@Override
public int getAire() {
return carre.getAire();
}
@Override
public void afficherFigure() {
//Affichage graphique du carré...
}
}
public class CercleSimple implements Cercle {
private int rayon;
public CercleSimple(int rayon) {
this.rayon = rayon;
}
@Override
public int getDiametre() {
return rayon*2;
}
@Override
public int getAire() {
return Math.PI * Math.pow(rayon, 2);
}
@Override
public void afficherFigure() {
//Affichage simple en console...
}
}
public class CercleGraphique implements Cercle {
//Composition
private Cercle cercle;
public CercleGraphique(int rayon) {
cercle = new CercleSimple(rayon);
}
@Override
public int getDiametre() {
return cercle.getDiametre();
}
@Override
public int getAire() {
return cercle.getAire();
}
@Override
public void afficherFigure() {
//Affichage graphique du cercle...
}
}
public class ServiceA {
public void action() {
Cercle c = new CercleSimple(10);
c.afficherFigure();
}
}
public class ServiceB {
public void action() {
Carre c1 = new CarreSimple(10);
c1.afficherFigure();
Cercle c2 = new CercleSimple(5);
c2.afficherFigure();
Sytem.out.println(c2.getAire());
}
}
Dans cet exemple, ServiceA
et ServiceB
sont fortement dépendants de CarreSimple
et CercleSimple
. Et on ne peut pas simplement injecter les dépendances, car ces services ont besoin d’instancier ces classes dans leurs méthodes ! Si nous devons changer pour utiliser des CarreGraphique
et des CercleGraphique
, il faut modifier ces deux classes !
Nous pouvons mettre en place une Fabrique pour régler ce problème :
public class FigureGeometriqueFactory {
public Carre creerCarre(int tailleCote) {
return new CarreSimple(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleSimple(rayon);
}
}
public class ServiceA {
private FigureGeometriqueFactory factory = new FigureGeometriqueFactory();
public void action() {
Cercle c = factory.creerCercle(10);
c.afficherFigure();
}
}
public class ServiceB {
private FigureGeometriqueFactory factory = new FigureGeometriqueFactory();
public void action() {
Carre c1 = factory.creerCarre(10);
c1.afficherFigure();
Cercle c2 = factory.creerCercle(5);
c2.afficherFigure();
Sytem.out.println(c2.getAire());
}
}
Maintenant, si nous voulons utiliser CarreGraphique
et CercleGraphique
à la place, nous n’avons à faire ce changement que dans une seule classe : FigureGeometriqueFactory
!
Ici, il semble judicieux de transformer FigureGeometriqueFactory
en Singleton et de l’injecter comme dépendance pour éviter que chaque service instancie une version de la fabrique :
public class FigureGeometriqueFactory {
private static FigureGeometriqueFactory INSTANCE;
private FigureGeometriqueFactory() {}
public synchronized static FigureGeometriqueFactory getInstance() {
if(INSTANCE == null) {
INSTANCE = new FigureGeometriqueFactory();
}
return INSTANCE;
}
public Carre creerCarre(int tailleCote) {
return new CarreSimple(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleSimple(rayon);
}
}
public class ServiceA {
private FigureGeometriqueFactory factory;
public ServiceA(FigureGeometriqueFactory factory) {
this.factory = factory;
}
//...
}
public class ServiceB {
private FigureGeometriqueFactory factory;
public ServiceB(FigureGeometriqueFactory factory) {
this.factory = factory;
}
//...
}
public class Main {
public static void main(String[]args) {
ServiceA s1 = new ServiceA(FigureGeometriqueFactory.getInstance());
ServiceB s2 = new ServiceB(FigureGeometriqueFactory.getInstance());
}
}
-
Ouvrez le paquetage
fabrique1
. Il s’agit de l’application avec des animaux que vous aviez développée lors du dernier TP. La nouveauté est qu’il y a aussi des animaux normaux et des animaux robots (qui ont seulement en plus une voix robotique quand ils font un cri…). -
Actuellement, si on souhaite changer les animaux pour utiliser des animaux robotiques dans le
Main
, on est obligé de changer tout le code duMain
. Il faut vous imaginer cela pour plusieurs classes : s’il faut changer les animaux pour des animaux robotiques, alors il faudra changer le code de toutes les classes qui créent des animaux ! Pour permettre plus de flexibilité, vous devez :-
Mettre en place une fabrique
AnimalFactory
créant des animaux “normaux” pour le moment. -
Faire en sorte que cette fabrique soit un singleton.
-
Utiliser cette fabrique dans
Main
au lieu de faire directement desnew
.
-
-
Si votre implémentation est bonne, vous devriez être capable d’utiliser des animaux robots seulement en changeant le code de votre fabrique. Vérifiez que cela est bien possible.
Dans certains exemples, vous pouvez rencontrer une fabrique avec une seule méthode creerAnimal
se présentant ainsi :
public class AnimalFactory {
public Animal creerAnimal(String type, String nom) {
return switch (type) {
case "chat" -> new Chat(nom);
case "chien" -> new Chien(nom);
case "oiseau" -> new Oiseau(nom);
case "poule" -> new Poule(nom);
default -> throw new RuntimeException("Animal inconnu");
};
}
}
Néanmoins, cette solution n’est pas forcément conseillée car :
-
On est obligé de retourner une classe abstraite/interface plus générale (pas de
I_Chien
,I_Chat
…). -
Elle implique que toutes les classes concrètes instanciées ont les mêmes paramètres (ce qui n’est pas toujours le cas, par exemple, si nous avions eu une figure
Rectangle
dans l’exemple des figures géométriques…) -
Il faut gérer le cas où
type
n’est pas un type connu. Ceci ne peut pas arriver si on sépare les instanciations en différentes méthodes (et évite les bugs causés par des fautes de frappe…).
Bref, on préférera généralement séparer chaque instanciation dans une méthode distincte, comme ce que vous avez probablement fait sur l’exercice ou comme dans l’exemple des figures géométriques.
Pour l’instant, si nous voulons changer de “famille” de classes utilisée (animaux normaux / animaux robots) nous sommes obligés de changer le code de la classe AnimalFactory
. Avec le design pattern “Fabrique Abstraite” de la section suivante du TP, nous allons pouvoir résoudre ce problème (et même faire cohabiter deux familles).
Le pattern Fabrique Abstraite
Le pattern Fabrique Abstraite utilise des fabriques et va notamment permettre de les interchanger lorsqu’elles instancient différentes familles de classes concrètes. Ainsi, lorsqu’on souhaite changer la famille de classes à construire, au lieu de modifier le code de la fabrique, on changera plutôt l’instance de la fabrique concrète utilisée par le programme ou une classe en particulier. Cela peut paraître encore un peu abstrait… regardons donc directement l’exemple suivant.
Construction de donjons
-
Ouvrez le paquetage
fabrique2
. Cette application permet de construire des donjons pour un jeu-vidéo. Un donjon est constitué de différentes salles (des pièces avec des coffres, des pièces avec des ennemis, des pièces avec des énigmes, des pièces avec des boss…). Une salle peut être complétée par le joueur ou non. Chaque donjon possède un algorithme de génération qui créé les salles dans un ordre précis (avec un peu d’aléatoire). Il est constitué de “salles normales”, de “salles spéciales” et d’une “salle finale”, dans cet ordre :-
Une salle normale
-
Cinq salles spéciales
-
Une salle normale
-
Une salle spéciale
-
Une salle normale
-
Une salle finale
Pour l’instant, dans sa configuration actuelle on a les correspondances suivantes :
-
Salle normale : Une salle avec un coffre
-
Salle spéciale : Une salle contenant entre 1 et 5 ennemis ou bien une salle avec une énigme (une chance sur deux)
-
Salle finale : Une salle avec un Boss avec un niveau entre 1 à 10.
-
-
Mettez en place une fabrique pour que le
Donjon
n’ait pas à instancier les salles lui-même. Il y aura donc trois méthodes dans cette fabrique :creerSalleNormale
,creerSalleSpeciale
etcreerSalleFinale
. Pour cet exercice, il n’y a pas besoin de créer de sous-classes ou d’interfacesSalleSpeciale
,SalleFinale
, etc. La fabrique renverra simplement des objetsSalle
pour chaque méthode. Transformez également cette fabrique en Singleton. Adaptez le code deDonjon
et deMain
en conséquence. -
La configuration actuelle du Donjon est un mode “facile”. On aimerait introduire le mode “difficile” où on a les correspondances suivantes :
-
Salle normale : Une salle contenant entre 20 et 40 ennemis.
-
Salle spéciale : Une salle avec une énigme (20% de chances) ou bien une salle avec un boss avec un niveau entre 30 à 50 (80% de chances).
-
Salle finale : Une salle avec un Boss de niveau 80.
Comment pourriez-vous faire pour pouvoir facilement construire à la fois des donjons “faciles” et des donjons “difficiles” dans le programme ? Tout en respectant les principes SOLID ?
-
Vous avez peut-être trouvé la solution par vous-même avec la dernière question, mais si vous commencez à maîtriser les bonnes pratiques de conception logicielle, il y a un concept qui revient souvent dans les patterns et lorsqu’on applique les principes SOLID : l’abstraction.
L’idée du pattern Fabrique Abstraite est donc de disposer d’une abstraction (classe abstraite ou interface) qui définie le contrat que doivent remplir les fabriques concrètes. Pour chaque famille de classes, on crée une fabrique concrète qui implémente cette interface ou étend cette classe abstraite. Ensuite, toutes les classes qui souhaitent utiliser cette fabrique doivent dépendre de la fabrique abstraite et non plus d’une fabrique concrète. Couplé à de l’injection de dépendances, il est très facile de complétement changer le comportement d’une classe. C’est globalement l’idée du pattern Stratégie que vous avez déjà vu lors du dernier TP, mais appliqué pour des fabriques !
Reprenons l’exemple des figures géométriques :
public interface AbstractFigureGeometriqueFactory {
Carre creerCarre(int tailleCote);
Cercle creerCercle(int rayon);
}
public class FigureGeometriqueSimpleFactory implements AbstractFigureGeometriqueFactory {
private static FigureGeometriqueSimpleFactory INSTANCE;
private FigureGeometriqueSimpleFactory() {}
public synchronized static FigureGeometriqueSimpleFactory getInstance() {
if(INSTANCE == null) {
INSTANCE = new FigureGeometriqueSimpleFactory();
}
return INSTANCE;
}
public Carre creerCarre(int tailleCote) {
return new CarreSimple(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleSimple(rayon);
}
}
public class FigureGeometriqueGraphiqueFactory implements AbstractFigureGeometriqueFactory {
private static FigureGeometriqueGraphiqueFactory INSTANCE;
private FigureGeometriqueGraphiqueFactory() {}
public synchronized static FigureGeometriqueGraphiqueFactory getInstance() {
if(INSTANCE == null) {
INSTANCE = new FigureGeometriqueGraphiqueFactory();
}
return INSTANCE;
}
public Carre creerCarre(int tailleCote) {
return new CarreGraphique(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleGraphique(rayon);
}
}
public class ServiceA {
private AbstractFigureGeometriqueFactory factory;
public ServiceA(AbstractFigureGeometriqueFactory factory) {
this.factory = factory;
}
public void action() {
Cercle c = factory.creerCercle(10);
System.out.println(c.getAire());
c.afficherFigure();
}
}
public class Main {
public static void main(String[]args) {
//Le service utilise des figures géométriques simples
ServiceA s1 = new ServiceA(FigureGeometriqueSimpleFactory.getInstance());
//Le service utilise des figures géométriques graphiques
ServiceA s2 = new ServiceA(FigureGeometriqueGraphiqueFactory.getInstance());
}
}
Comme vous pouvez le constater, après refactoring, il est très facile de changer de famille utilisée dans le service, sans avoir besoin de changer le code de notre fabrique. De plus, on respecte ainsi le principe ouvert/fermé, car si on souhaite créer une nouvelle famille de classes, on a juste à ajouter une nouvelle fabrique.
-
Refactorez votre code afin d’introduire une nouvelle fabrique permettant de construire des donjons “difficiles” en appliquant le pattern Fabrique Abstraite. Vous devez aussi logiquement légèrement adapter le code de la classe
Donjon
en introduisant une injection de dépendance vers la fabrique abstraite. -
Dans le
Main
, construisez des donjons difficiles et des donjons faciles. -
La méthode
getInfosSalle
doit permettre de récupérer les informations d’une salle du donjon “à l’extérieur”. Cependant, son implémentation est problématique… Par exemple, que pourriez-vous faire comme action indésirable en récupérant une salle d’unDonjon
dans leMain
(indice : seul le donjon devrait avoir le droit de modifier l’état de ses salles) ? Quel pattern que nous avons vu pendant ce TP pourriez-vous appliquer pour régler ce problème ? Refactorez votre code dans ce sens. -
Réalisez le diagramme de classes de conception de votre application.
Parfois, il n’est pas nécessairement souhaitable d’avoir la possibilité d’utiliser toutes les fabriques en même temps (on doit alors pointer l’instance de la fabrique qu’on souhaite utiliser). Il faut donc trouver un moyen d’en sélectionner une seule, mais aussi de pouvoir facilement changer la fabrique concrète utilisée. Nous allons voir cela dans le prochain exercice.
Combats de monstres
-
Ouvrez le paquetage
fabrique3
. C’est le retour des pokémons ! Mais il y a également une nouvelle famille de monstres : les digimons qui ont un fonctionnement similaire aux pokémons mais ont en plus un système d’énergie. En tout cas, ils se créent de la même manière que les pokémons ou plus généralement, qu’un “monstre”. -
Actuellement, dans le
Main
, on crée des pokémons et on utilise le simulateur de combat pour en faire combattre deux. À la place des pokémons, on voudrait parfois utiliser des digimons. Maintenant que vous connaissez le pattern Fabrique Abstraite refactorez le code afin de pouvoir facilement switcher entre des combats avec divers pokémons et des combats avec divers digimons. Si vous rencontrez quelques petits problèmes, regardez les méthodes deDigimon
… ne serait-il pas judicieux de lui faire implémenter une certaine interface ? Et pour les différentes sous-classes deDigimon
? (DigimonEau
,DigimonFeu
…). Vous devrez aussi adapter leMain
etSimulateurCombat
. -
Contrairement à l’exercice précédent sur les donjons, on ne veut pas que les deux fabriques cohabitent à un instant t du programme. On veut que l’application fonctionne exclusivement avec des pokémons ou bien fonctionne exclusivement avec des digimons. Mais on ne veut pas changer manuellement l’instance de la fabrique utilisée partout dans le code source. Il faut imaginer que cette fabrique est probablement utilisée à divers endroits et surtout, qu’on ne souhaite pas recompiler le code source à chaque changement. Comment pourrions-nous faire ?
Nous sommes dans un cas particulier où tout le programme va utiliser une seule et même instance d’une fabrique donnée, même s’il y a en a plusieurs à disposition. Par exemple, imaginons qu’une application utilise des fabriques pour gérer la connexion à une source de données afin de stocker les données du programme. Une première fabrique permet de stocker les informations dans une base de données, une deuxième dans un fichier XML. Pendant que le programme tournera, on utilisera qu’une seule source de données, mais pas les deux à la fois. Néanmoins, on utilise quand même une fabrique abstraite pour pouvoir changer facilement de source de stockage à l’avenir (par exemple, avec une configuration “test” utilisant une base de données en mémoire…)
Mais comment faire cela sans modifier l’instance concrète utilisée un peu partout ? (car elle est surement injectée à différentes classes). La réponse est simple : il faut regrouper la logique qui instancie la classe concrètement utilisée à un seul endroit. On peut ensuite mettre un système de paramétrage qui permet de changer la fabrique utilisée facilement. Les classes qui ont besoin de cette fabrique iront donc appeler une méthode de cette classe afin de récupérer la fabrique concrète à utiliser.
La question est de savoir : où placer ce bout de code ? Il n’y a pas vraiment de réponse officielle à cette question. Cela pourrait être dans une classe indépendante avec une méthode statique. Cependant, dans la pratique, on retrouve parfois ce bout de code directement dans la Fabrique Abstraite qu’on mélange à un Singleton :
public abstract class AbstractFigureGeometriqueFactory {
private static AbstractFigureGeometriqueFactory INSTANCE;
private String fabriqueSelectionee = "graphique";
public synchronized static AbstractFigureGeometriqueFactory getInstance() {
if(INSTANCE == null) {
if(fabriqueSelectionee.equals("simple")) {
INSTANCE = new FigureGeometriqueSimpleFactory();
}
else if(fabriqueSelectionee.equals("graphique")) {
INSTANCE = new FigureGeometriqueGraphiqueFactory();
}
else {
throw new RuntimeException("Fabrique inconnue.");
}
}
return INSTANCE;
}
public abstract Carre creerCarre(int tailleCote);
public abstract Cercle creerCercle(int rayon);
}
public class FigureGeometriqueSimpleFactory extends AbstractFigureGeometriqueFactory {
public Carre creerCarre(int tailleCote) {
return new CarreSimple(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleSimple(rayon);
}
}
public class FigureGeometriqueGraphiqueFactory extends AbstractFigureGeometriqueFactory {
public Carre creerCarre(int tailleCote) {
return new CarreGraphique(tailleCote);
}
public Cercle creerCercle(int rayon) {
return new CercleGraphique(rayon);
}
}
public class ServiceA {
private AbstractFigureGeometriqueFactory factory;
public ServiceA(AbstractFigureGeometriqueFactory factory) {
this.factory = factory;
}
}
public class Main {
public static void main(String[]args) {
ServiceA s = new ServiceA(AbstractFigureGeometriqueFactory.getInstance());
}
}
Avec cette nouvelle implémentation, il n’y a plus qu’à changer le paramètre fabriqueSelectionee
dans la fabrique abstraite pour changer la fabrique concrète manipulée dans tout le programme. On notera qu’il n’y a plus besoin que les sous-classes soient des singletons, car elles sont instanciées par la fabrique abstraite.
Encore mieux : dans les faits, il faut (légèrement) changer le code source de la fabrique abstraite pour changer de fabrique… Donc il faut recompiler. Pour éliminer ce dernier problème, on peut plutôt utiliser un fichier de configuration : lors de son initialisation, la fabrique abstraite va lire dans ce fichier pour déterminer quelle fabrique instancier.
public abstract class AbstractFigureGeometriqueFactory {
private static AbstractFigureGeometriqueFactory INSTANCE;
public synchronized static AbstractFigureGeometriqueFactory getInstance() {
if(INSTANCE == null) {
try {
InputStream stream = ClassLoader.getSystemResourceAsStream("config/figure_factory_config.txt");
if(stream == null) {
throw new RuntimeException("Fichier de configuration manquant.");
}
BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
String config = reader.readLine();
if(config.equals("simple")) {
INSTANCE = new FigureGeometriqueSimpleFactory();
}
else if(config.equals("graphique")) {
INSTANCE = new FigureGeometriqueGraphiqueFactory();
}
else {
throw new RuntimeException("Fabrique inconnue.");
}
} catch (IOException e) {
throw new RuntimeException("Fichier de configuration corrompu.");
}
}
return INSTANCE;
}
public abstract Carre creerCarre(int tailleCote);
public abstract Cercle creerCercle(int rayon);
}
La fabrique abstraite vise un fichier qui se situe dans src/main/resources/config/figure_factory_config.txt
qui contient simplement une chaîne de caractères. Par exemple : simple
.
-
Créez un dossier
config
danssrc/main/resources/
puis, à l’intérieur de ce nouveau répertoire, un fichiermonstres_factory_config.txt
. -
Refactorez le code de l’application afin qu’il soit possible d’utiliser soit les pokémons, soit les digimons en changeant simplement le contenu du fichier de configuration.
-
Testez les deux versions !
-
Réalisez le diagramme de classes de conception de votre application. Pour ne pas trop perdre de temps, ne mettez que deux pokémons, deux digimons (et les fabriques), ne détaillez pas les méthodes et les attributs. Le plus important est de faire apparaitre les associations et les dépendances.
Intégrer une librairie externe
Les pokémons et les digimons sont des classes qui ont été créées par le développeur de ce projet et où il est donc possible de réarranger le code pour que tout fonctionne. Mais que se passerait-il si nous voulions intégrer à notre système du code sur lequel nous n’avons pas la main (dont nous ne pouvons pas éditer le code source)? Par exemple, des classes compilées qui proviennent d’une librairie.
Pour illustrer ce problème, reprenons notre exemple de figures géométriques. Imaginons que vous souhaitez pouvoir afficher des carrés et des cercles de manière “complexe” (à voir ce que “complexe” veut dire dans ce contexte). Au lieu de réinventer la roue, vous avez trouvé une librairie FiguresComplexes
qui fait cela pour vous ! Via Maven
, vous installez cette librairie sur votre projet.
Vous ne pouvez pas modifier le code source de cette classe, mais vous pouvez étudier leur documentation. Vous êtes notamment intéressés par les deux classes suivantes :
public class ComplexSquare {
//Construit le carré en précisant la taille d'un côté
public ComplexSquare(int size) {
...
}
//Retourne la taille du côté du carré complexe
public int getSize() {
...
}
//Renvoie l'aire du carré
public int calculateArea() {
...
}
//Affichage du carré
public void displaySquare() {
}
}
public class ComplexCircle {
//Construit le cercle en précisant son rayon
public ComplexCircle(int radius) {
...
}
//Retourne le diamètre du cercle complexe
public int getDiameter() {
...
}
//Renvoie l'aire du cercle
public int calculateArea() {
...
}
//Affichage du cercle
public void displayCircle() {
}
}
Ces deux classes répondent à tous les besoins…mais, de prime abord, il semble impossible de les intégrer dans notre système de fabrique :
-
Les noms des méthodes ne sont pas les mêmes.
-
On ne peut pas faire implémenter à ces classes
Carre
ouCircle
… -
On ne peut pas modifier le code source de ces classes…
Cela semble compromis ! Mais pas de panique, un design pattern résout ce problème : Adaptateur
.
L’objectif de ce pattern est de faire en sorte qu’une classe “incompatible” avec notre architecture puisse tout de même être utilisée en passant par une classe dîte adaptateur qui fait le pont entre votre architecture et cette classe grâce à de la composition (encore et toujours !). C’est un peu comme quand vous voyagez au Royaume-Uni par exemple. Les prises électriques ne sont pas compatibles avec la prise de votre chargeur de téléphone. Vous devez alors utiliser un adaptateur entre la prise de votre chargeur et celle anglaise.
Ici, la “prise du chargeur” est votre architecture, les interfaces, les abstractions, l’adaptateur la classe qui va être amenée par le pattern et la prise anglaise la classe “externe” que vous souhaitez utiliser.
Voici comment appliquer ce pattern :
-
Créer une classe dite “Adapter” qui possède un attribut du type de la classe à adapter. Cet attribut peut être injecté via le constructeur ou bien, dans certains cas, directement construit dans la classe Adapter.
-
Faire implémenter à notre Adapter l’interface souhaitée ou lui faire étendre la classe abstraite souhaitée.
-
L’implémentation de chaque méthode abstraite se fera par délégation en appelant la méthode d’origine sur la classe cible.
Mettons cela en application sur notre exemple :
public class ComplexSquareAdapter implements Carre {
private ComplexSquare complexSquare;
public ComplexSquareAdapter(ComplexSquare complexSquare) {
this.complexSquare = complexSquare;
}
//Ou bien
/*
public ComplexSquareAdapter(int tailleCote) {
this.complexSquare = new ComplexSquare(tailleCote);
}*/
@Override
public int getTaille() {
return complexSquare.getSize();
}
@Override
public int getAire() {
return complexSquare.getArea();
}
@Override
public void afficherFigure() {
complexSquare.displaySquare();
}
}
public class ComplexCircleAdapter implements Cercle {
private ComplexCircle complexCircle;
public ComplexCircleAdapter(ComplexCircle complexCircle) {
this.complexCircle = complexCircle;
}
//Ou bien
/*
public ComplexCircleAdapter(int rayon) {
this.complexCircle = new ComplexCircle(rayon);
}*/
@Override
public int getDiametre() {
return complexCircle.getDiameter();
}
@Override
public int getAire() {
return complexCircle.getArea();
}
@Override
public void afficherFigure() {
complexCircle.displayCircle();
}
}
public class FigureGeometriqueComplexeFactory extends AbstractFigureGeometriqueFactory {
public Carre creerCarre(int tailleCote) {
return new ComplexSquareAdapter(new ComplexSquare(tailleCote));
}
public Cercle creerCercle(int rayon) {
return new ComplexCircleAdapter(new ComplexCircle(rayon));
}
}
Maintenant, revenons à nos petits monstres…
-
Une librairie nommée
patternmon
est importée dans le projet ! Explorez ses classes en allant danssrc/main/resources/libs/patternmons.jar
(normalement, l’IDE vous permet de voir les classes contenues dans le.jar
). Attention, si vous travaillez sur une machine de l’IUT, il est probable que vous deviez utiliser le JDK 19 que vous pouvez installer via IntelliJ (dans File, Project Structure…). Dans ce cas il faudra aussi changer le fichierpom.xml
du projet en changeant les deux “17” et “19”. -
Comme vous pouvez le constater, cette librairie définit de nouveaux monstres “patternmons” qu’on souhaite maintenant intégrer à notre système de simulation de combat. Comme vous l’avez deviné, nous allons donc devoir mettre en place un
Adaptateur
! Cependant, comme il y a une hiérarchie de classes ici, la mise en place va être un poil plus complexe que sur notre exemple, donc nous allons le faire pas à pas :-
Commencez par implémenter une classe abstraite
PatternmonAdapter
qui permet d’adapter la classe abstraitePatternmon
. Normalement, vous ne devriez pas trop avoir de difficulté, c’est quasiment comme dans l’exemple. -
Patternmon
est une classe abstraite et nous avons besoin de pouvoir adapter ses classes filles concrètes (correspondant àMonstreEau
,MonstreFeu
, etc.). Il faut créer un adaptateur par classe fille ! Cet adaptateur devra étendre votre adaptateur abstraitPatternmonAdapter
et implémenter l’interfaceMonstreX
correspondante (MonstreEau
pour l’adaptation deWaterPatternmon
par exemple). -
Commencez par tenter d’adapter
WaterPatternmon
. Vous allez sans doute rencontrer une difficulté lorsque vous allez essayer d’implémenter les méthodes propres à l’interface. Dans un premier temps, essayez de résoudre cela par vous-même, puis, après quelques essais, lisez la prochaine section.
-
Le problème que vous avez dû rencontrer est le suivant : dans un de vos adaptateurs concrets, vous n’aviez pas accès au Patternmon
passé à la classe parente. Une solution que vous avez peut-être mise en place et de changer la visibilité de cet attribut en protected
, mais même là, vous aviez sans doute des erreurs !
Voyons un peu tout ça :
public abstract class PatternmonAdapter implements Monstre {
protected Patternmon patternmon;
//...
}
public class PatternmonEauAdapter extends PatternmonAdapter implements MonstreEau {
//Prend un Waterpatternmon en paramètre et pas un Patternmon !
public PatternmonEauAdapter(WaterPatternmon patternmon) {
super(patternmon);
}
@Override
public int getDureeRespiration() {
//Erreur !
return this.patternmon.getBreathingTime();
}
}
Si vous avez bien une implémentation similaire (faites les changements nécessaires si ce n’est pas le cas) vous obtenez une erreur au niveau de la méthode getDureeRespiration
. Cela est dû au fait que dans la classe mère, l’attribut patternmon
est du type abstrait Patternmon
et pas un Waterpatternmon
! Donc, dans la classe fille, impossible d’utiliser getBreathingTime
.
La première solution “simple” serait de caster le patternmon. Normalement, comme on est sûr d’avoir passé au constructeur parent un Waterpatternmon
, c’est un cast sans risques :
@Override
public int getDureeRespiration() {
return ((Waterpatternmon) this.patternmon).getBreathingTime();
}
Mais cette solution n’est pas élégante et il en existe une bien meilleure : la généricité. Dans la classe mère, il est possible de définir un (ou plusieurs) “type paramétré” qu’on peut par exemple nommer T
. On peut imposer des restrictions, par exemple, que T
soit une classe qui descende d’une autre classe spécifique.
Quand une classe enfant étend la classe mère, elle peut alors préciser la classe concrète utilisée à la place de T
. Ainsi, la classe mère est configurée pour utiliser ce type.
Petite démonstration sur PatternmonAdapter
et PatternmonEauAdapter
:
//On définit le paramètre générique T lors de la déclaration de la classe
//Je suis assuré que T est un objet qui étend la classe Patternmon
public abstract class PatternmonAdapter<T extends Patternmon> implements Monstre {
//Peut-être n'importe quel patternmon.
//Je peux appeler les méthodes de "patternmon" sur cet objet
protected T patternmon;
public PatternmonAdapter(T patternmon) {
this.patternmon = patternmon;
}
//...
}
//Quand on étend PatternmonAdapter, on précise la classe concrète utilisée (ici, Watterpatternmon)
//Il faut imaginer que dans la classe mère, le "T" est remplacé par Watterpatternmon.
public class PatternmonEauAdapter extends PatternmonAdapter<Waterpatternmon> implements MonstreEau {
//Prend un Waterpatternmon en paramètre et pas un Patternmon !
//Maintenant, on est "forcé" d'utiliser ce constructeur.
public PatternmonEauAdapter(WaterPatternmon patternmon) {
super(patternmon);
}
@Override
public int getDureeRespiration() {
//Plus d'erreurs, car l'attribut "patternmon" dans la classe mère est un Waterpatternmon !
return this.patternmon.getBreathingTime();
}
}
Si vous n’avez pas tout compris, n’hésitez pas à en parler avec votre enseignant.
-
Adaptez votre code pour introduire la généricité comme dans l’exemple ci-dessus puis implémentez les classes restantes :
PatternmonElectriqueAdapter
,PatternmonPsyAdapter
,PatternmonFeuAdapter
etPatternmonPlanteAdapter
. -
Créez une nouvelle fabrique permettant de créer des
patternmons
. -
Adaptez le code de votre fabrique abstraite afin de pouvoir gérer votre nouvelle fabrique.
-
Un simple changement dans le fichier de configuration doit suffire pour utiliser les patternmons. Testez et vérifiez que tout marche bien (les attaques des monstres devraient avoir des noms anglais).
Gestion des dépendances
Jusqu’ici, nous avons vu des fabriques qui instancient systématiquement un objet à chaque appel d’une méthode type creer...
. Cependant, ce fonctionnement n’est pas obligatoire. Par exemple, on pourrait avoir une fabrique qui instancie et stocke certains objets lors de son initialisation puis renvoie une référence vers ces objets lorsqu’on la sollicite.
Avec un tel fonctionnement, une fabrique permet alors de gérer les dépendances d’un programme. Elle pourrait par exemple stocker différentes instances de services concrets de l’application. Couplé avec le pattern Fabrique Abstraite, cela nous permet de rendre les dépendances de notre programme fortement modulables !
interface ServiceA {
void actionA();
}
interface ServiceB {
void actionB();
}
public class ServiceA1 implements ServiceA {
public void actionA() {
//...
}
}
public class ServiceA2 implements ServiceA {
public void actionA() {
//...
}
}
public class ServiceB1 implements ServiceB {
public void actionB() {
//...
}
}
public class ServiceB2 implements ServiceB {
public void actionB() {
//...
}
}
public class ServiceV1Factory extends AbstractServiceFactory {
private ServiceA serviceA;
private ServiceB serviceB;
public ServiceV1Factory() {
this.serviceA = new ServiceA1();
this.serviceB = new ServiceB1();
}
public ServiceA getServiceA() {
return serviceA;
}
public ServiceB getServiceB() {
return serviceB;
}
}
public class ServiceV2Factory extends AbstractServiceFactory {
private ServiceA serviceA;
private ServiceB serviceB;
public ServiceV2Factory() {
this.serviceA = new ServiceA2();
this.serviceB = new ServiceB2();
}
public ServiceA getServiceA() {
return serviceA;
}
public ServiceB getServiceB() {
return serviceB;
}
}
public abstract class AbstractServiceFactory {
public abstract ServiceA getServiceA();
public abstract ServiceB getServiceB();
private static AbstractServiceFactory INSTANCE;
public synchronized static AbstractServiceFactory getInstance() {
if(INSTANCE == null) {
try {
InputStream stream = ClassLoader.getSystemResourceAsStream("config/services.txt");
if(stream == null) {
throw new RuntimeException("Fichier de configuration manquant.");
}
BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
String config = reader.readLine();
if(config.equals("V1")) {
INSTANCE = new ServiceV1Factory();
}
else if(config.equals("V2")) {
INSTANCE = new ServiceV2Factory();
}
else {
throw new RuntimeException("Fabrique inconnue.");
}
} catch (IOException e) {
throw new RuntimeException("Fichier de configuration corrompu.");
}
}
return INSTANCE;
}
}
public class Main {
public static void main(String[]args) {
ServiceA s1 = AbstractServiceFactory.getInstance().getServiceA();
s1.action();
ServiceB s2 = AbstractServiceFactory.getInstance().getServiceB();
s2.action();
}
}
Dans l’exemple ci-dessus, un simple changement dans le fichier de configuration permet de changer les services concrets utilisés pour ServiceA
et ServiceB
. Contrairement aux exercices précédents, dans le cas d’un service, il n’est pas utile (voire, il ne faut pas) le réinstancier chaque fois. Par exemple, dans le cas d’une classe “repository” permettant de faire des requêtes sur une base de données.
C’est un peu comme si toutes les dépendances stockées dans ces fabriques étaient de singletons, car on ne manipule qu’une seule instance de ces objets dans le programme. Mais cela est encore mieux qu’un singleton, parce que ces classes (ServiceA1
, ServiceB1
, etc.) ne sont pas construites comme des singletons ! Elles sont donc beaucoup plus adaptées aux tests unitaires. Comme mentionné dans la section “Avertissement” sur les singletons, nous préférerons utiliser des fabriques plutôt que de multiplier les singletons dans l’application.
Il est toléré d’avoir un singleton pour les classes telles que les fabriques abstraites ou leurs sous-classes, car elles n’ont pas vraiment pour but d’être testées (unitairement). Mais sur les services qui contiennent du code métier, il faut éviter le singleton quand cela est possible. Les fabriques et les fabriques abstraites répondent à ce problème.
Dans le prochain TP, nous verrons un outil encore plus puissant pour gérer les dépendances : le conteneur IoC.
Pour le moment, revenons à nos moutons et appliquons ce que nous avons appris sur un nouvel exercice.
-
Ouvrez le paquetage
fabrique4
. Il s’agit de la dernière application que vous aviez refactoré lors du dernier TP permettant de simuler l’inscription et la connexion d’un utilisateur. L’application est fonctionnelle. Lancez-la et explorer aussi les différentes classes mises à disposition si vous ne vous souvenez pas bien de son fonctionnement. -
Actuellement, si nous voulons changer la méthode de stockage (en mémoire ou dans un fichier) il faut éditer à la main
IHMUtilisateur
pour remplacer les paramètres injectés lors de la création deServiceUtilisateur
. On souhaite rendre cela plus modulable. Refactorez le code pour mettre en place et utiliser une fabrique abstraite et des fabriques pour gérer les deux types de méthodes stockage disponibles dans l’application. La configuration du type stockage utilisé devra se faire via un fichier de configuration textuel simple. Aussi, vos fabriques doivent stocker les dépendances qu’elles gèrent et ne pas les ré-instancier à chaque fois qu’on en a besoin.Normalement, vous n’avez pas besoin de toucher la classe
ServiceUtilisateur
, mais il faudra probablement adapter le code deIHMUtilisateur
. -
Testez que tout fonctionne en alternant entre les deux méthodes de stockage via votre fichier de configuration.
-
Il existe divers autres services que nous utilisons fréquemment dans l’application : le service permettant d’envoyer des mails (mailer) et le service de chiffrement (hasheur). On voudrait définir deux configurations correspondantes à ces deux “familles” de services :
-
Une configuration “basique” utilisant
ServiceMailerBasique
etHasheurMD5
. -
Une configuration “sécurisée” utilisant
ServiceMailerChiffre
etHasheurSHA256
.
Là aussi, on veut pouvoir facilement switcher entre ces deux configurations sans changer le code, mais en éditant simplement un fichier de configuration.
Mettez en place un système pour pouvoir gérer ces deux configurations. Encore une fois, vous ne devriez pas avoir besoin d’éditer d’autres classes que
IHMUtilisateur
après la mise en place de votre système. -
-
Vérifiez que tout fonctionne en alternant entre les deux configurations.
Le conteneur IoC que nous allons utiliser dans le prochain TP permet de ne pas multiplier les fabriques pour gérer les différentes dépendances en centralisant le tout. C’est une sorte de super-fabrique hautement configurable gérant des dépendances (mais avec un objectif assez différent des fabriques que vous avez vues avant comme avec les figures géométriques, les pokémons ou les donjons).
Mise en pratique sur une application plus large
Vous allez maintenant mettre en application ce que vous avez appris sur une application un peu plus large : l’application de gestion des étudiants (OGE) sur laquelle vous avez travaillé dans le cours de base de données.
-
Forkez le dépôt GitLab suivant en le plaçant dans le namespace
qualite-de-developpement-semestre-3/etu/votrelogin
. -
Clonez votre nouveau dépôt en local. Ouvre le projet avec
IntelliJ
et vérifiez qu’il n’y a pas d’erreur. Si vous exécutez le programme, il y aura une erreur, c’est normal pour le moment. -
Pour rappel, ce programme peut se connecter à une base de données par deux moyens : en utilisant l’API
JDBC
en “brut” ou bien en passant par l’ORMHibernate
. Vous n’avez pas nécessairement besoin d’avoir fini ce TP dans son intégralité en base de données pour pouvoir faire cet exercice. Il y a un peu de configuration à faire pour que vous puissiez vous connecter à votre BDD par JDBC et Hibernate :-
Dans la classe
storage/jdbc/JDBCUtils
, remplacez les valeursaCompleter
par vos identifiants d’accès à la base de donnéesOracle
de l’IUT. -
Faites de même dans le fichier
hibernate.cfg.xml
danssrc/main/java/resources
.
Lancez l’application et vérifiez que tout fonctionne.
-
-
De base cette application est configurée pour utiliser
JDBC
et pasHibernate
. Si on souhaite changer la méthode de stockage en BDD utilisée, il faut changer le code source des classesRessourceService
,EtudiantService
etNoteService
. On aimerait pouvoir plus facilement switcher entreJDBC
etHibernate
en utilisant un simple fichier de configuration. On vous demande donc de refactorer le code afin de mettre en place un tel système (avec une fabrique abstraite gérant des dépendances, comme dans l’exercice précédent). Quelques commentaires :-
L’interface Stockage (dans le paquetage
storage
) permet de définir un repository. Vous pouvez notamment aller jeter un œil aux repositories concrets qui utilisentJDBC
et implémentent cette interface dansstorage/jdbc
. -
Il faudra utiliser votre fabrique abstraite directement dans la méthode
getInstance
des trois services. Comme vous pouvez le constater, ces services sont des singletons. Ils sont donc difficilement testables…mais nous allons régler ce problème dans le prochain TP ! -
Dans les trois services, vous avez des exemples d’instanciation des repositories JDBC simples (définis dans l’application).
-
On accède aux repositories issus de
Hibernate
différemment. Pour rappel, ils sont fournis via une librairie externe nomméeHibernateRepositories
. Vous n’avez donc pas la main dessus ! De plus, les noms des méthodes différents de celle de votre interfaceStockage
… Quel pattern allez-vous utiliser pour résoudre ce problème ? Vous l’avez vu pendant ce TP. On rappelle comment manipuler les repositories deHibernate
(vous n’étiez peut-être pas allé jusque-là dans le TP) :-
La classe
EntityRepository<T>
permet de gérer un repository correspondant à une entitéT
(paramètre générique, qui peut être instancié, par exemple parEntityRepository<Ressource>
). -
Les méthodes qu’on peut utiliser sur un tel repository sont :
create
,update
,deleteById
,findById
,findAll
qui sont similaires aux méthodes définies dans l’interfaceStockage
. -
On n’instancie pas directement ces repositories, on les récupère via l’instruction suivante :
EntityRepository<MonEntite> repository = RepositoryManager.getRepository(MonEntite.class); //Par exemple : EntityRepository<Ressource> repository = RepositoryManager.getRepository(Ressource.class);
- Si vous gérez bien le concept de généricité adapter les
EntityRepository
à votre architecture ne devrait demander qu’une seule classe (peut-être un peu dure à trouver si vous n’êtes pas encore trop à l’aise avec cette notion). Sinon, il y a aussi une solution plus basique utilisant trois classes.
-
-
-
Une fois le refactoring terminé, vérifiez que tout fonctionne en alternant entre
JDBC
etHibernate
en modifiant votre fichier de configuration. Comme les deux systèmes utilisent la même base, cela n’est pas forcément évident de repérer quand l’un ou l’autre est utilisé. au démarrage,Hibernate
va vous mettre divers messages d’informations dans la console contrairement àJDBC
. Sinon, essayez de mettre un mauvais mot de passe dans une des configurations (dansJDBCUtils
ou bien le fichier de configuration d’Hibernate) et alternez entre les deux méthodes. L’application ne devrait pas fonctionner seulement dans un cas sur deux. -
N’oubliez pas de push ce projet sur GitLab. Attention, si vous ne voulez pas que vos identifiants soient visibles, supprimez-les avant de push. Mais, à priori, seuls les enseignants ont accès à vos dépôts GitLab si vous l’avez placé dans le bon
namespace
.
Avec votre refactoring, il devient très facile d’ajouter et d’utiliser une nouvelle source de stockage de données, comme un fichier XML par exemple ou une base de données SQLite
dédiées aux tests ! Et passer d’une méthode à l’autre ne demande alors plus aucune modification du code source.
Décorateur : Builder ou Fabrique ?
Dans le dernier TP, vous avez découvert le pattern Décorateur permettant d’ajouter des nouvelles fonctionnalités de manière dynamique. Nous avions notamment vu un exemple portant sur des salariés, puis un exercice portant sur différents types de produits.
Comme l’objet créé avec le pattern Décorateur peut être assez complexe, il peut être intéressant de le coupler avec une Fabrique ou bien un Builder.
Reprenons l’exemple des salariés et essayons d’appliquer ces deux patterns :
interface I_Salarie {
double getSalaire();
//Prototype
I_Salarie clone();
}
class Salarie implements I_Salarie {
private double salaire;
public Salarie(double salaire) {
this.salaire = salaire;
}
private Salarie(Salarie salarieACopier) {
this(salarieACopier.salaire);
}
public double getSalaire() {
return salaire;
}
public I_Salarie clone() {
return new Salarie(this);
}
}
abstract class SalarieDecorator implements I_Salarie {
protected I_Salarie salarie;
public SalarieDecorator(I_Salarie salarie) {
this.salarie = salarie;
}
}
class ChefProjet extends SalarieDecorator {
private int nombreProjetsGeres;
public ChefProjet(I_Salarie salarie, int nombreProjetsGeres) {
super(salarie);
this.nombreProjetsGeres = nombreProjetsGeres;
}
private ChefProjet(ChefProjet chefProjetACopier) {
this(chefProjetACopier.salarie.clone(), chefProjetACopier.nombreProjetsGeres);
}
@Override
public double getSalaire() {
return salarie.getSalaire() + 100 * (nombreProjetsGeres);
}
@Override
public I_Salarie clone() {
return new ChefProjet(this);
}
}
class ResponsableDeStagiaires extends SalarieDecorator {
private int nombreStagiairesGeres;
public ResponsableDeStagiaires(I_Salarie salarie, int nombreStagiairesGeres) {
super(salarie);
this.nombreStagiairesGeres = nombreStagiairesGeres;
}
private ResponsableDeStagiaires(ResponsableDeStagiaires responsableACopier) {
this(responsableACopier.salarie.clone(), responsableACopier.nombreStagiairesGeres);
}
@Override
public double getSalaire() {
return salarie.getSalaire() + 50 * nombreStagiairesGeres;
}
@Override
public I_Salarie clone() {
return new ResponsableDeStagiaires(this);
}
}
On peut créer : des salariés simples, des salariés chefs de projet, des salariés responsables de stagiaires et des salariés à la fois chefs de projets et responsables de stagiaires. Si on ajoutait un nouveau type de Salarie
, cela multiplierait les possibilités…
Sans pattern créateur, il faut donc créer les salariés ainsi :
I_Salarie salarie1 = new Salarie(2000);
I_Salarie salarie2 = new SalarieChef(new Salarie(2500), 3);
I_Salarie salarie3 = new ResponsableDeStagiaires(new Salarie(2300), 5);
I_Salarie salarie4 = new SalarieChef(new ResponsableDeStagiaires(new Salarie(1800), 4), 2);
Essayons d’abord d’utiliser le concept de Fabrique :
class SalarieFactory {
private static SalarieFactory INSTANCE;
private SalarieFactory() {}
public static SalarieFactory getInstance() {
if(INSTANCE == null) {
INSTANCE = new SalarieFactory();
}
return INSTANCE;
}
public Salarie creerSalarie(double salaire) {
return new Salarie(salaire);
}
public SalarieChef creerSalarieChef(I_Salarie salarie, double nombreProjetsGeres) {
return new SalarieChef(salarie, nombreProjetsGeres);
}
public ResponsableDeStagiaires creerResponsableStagiaires(I_Salarie salarie, int nombreStagiairesGeres) {
return new ResponsableDeStagiaires(salarie, nombreStagiairesGeres);
}
}
class Main {
public static void main(String[]args) {
SalarieFactory factory = SalarieFactory.getInstance();
I_Salarie salarie = factory.creerSalarieChef(factory.creerResponsableStagiaires(factory.creerSalarie(1800), 4), 2);
}
}
-
Ouvrez le paquetage
decorateur
. Il s’agit du code complété de l’exercice sur les produits du dernier TP où on a mis en place un décorateur pour pouvoir avoir à la fois des produits avec des réductions et des produits qui périment bientôt. -
Comme dans l’exemple des salariés, refactorez votre code pour mettre en place une fabrique qui permettant de construire vos produits. Adaptez le code de
Main
en conséquence.
Bon, cela fonctionne, mais à part déplacer la création dans la fabrique, il est toujours assez complexe de créer l’objet comme quand nous utilisions les new
directement dans Main
… Essayons plutôt avec un Builder
!
Attention, l’implémentation de Builder
que nous allons présenter ici n’est pas un vrai Builder
comme nous l’avons fait dans l’exercice des builders, mais plutôt une adaptation pour fonctionner avec le décorateur :
-
La classe
Builder
va se trouver à l’extérieur deProduit
. Autrement, cela voudrait dire queProduit
dépendrait de toutes ses classes filles, et cela ne semble pas très adéquat. -
Le produit va être instancié dès le départ puis augmenté au fur et à mesure alors que normalement, la cible du builder n’est instanciée que quand on appelle la méthode
build
. -
La méthode
build
renvoie une copie (clone) de l’instance (prototype).
Bref, retenez simplement que c’est une adaptation un peu particulière et qui s’éloigne quand même pas mal du pattern de base (retenez plutôt l’exemple des burgers). Néanmoins, nous allons voir que cette adaptation s’emboîte assez bien avec décorateur :
class SalarieBuilder {
private I_Salarie salarie;
public SalarieBuilder(double salaire) {
salarie = new Salarie(salaire);
}
public SalarieBuilder withChefProjet(int nombreProjetsGeres) {
salarie = new SalarieChef(salarie, nombreProjetsGeres);
return this;
}
public SalarieBuilder withResponsableStagiaires(int nombreStagiairesGeres) {
salarie = new ResponsableDeStagiaires(salarie, nombreStagiairesGeres);
return this;
}
public I_Salarie build() {
return salarie.clone();
}
}
class Main {
public static void main(String[]args) {
I_Salarie salarie1 = new SalarieBuilder(2000).build();
I_Salarie salarie2 = new SalarieBuilder(2500).withChefProjet(3).build();
I_Salarie salarie3 = new SalarieBuilder(2300).withResponsableStagiaires(5).build();
I_Salarie salarie4 = new SalarieBuilder(1800).withChefProjet(4).withResponsableStagiaires(2).build();
}
}
-
Refactorez le code de votre application pour ajouter un builder (adapté) pour vos produits.
-
Adaptez le code de
Main
pour utiliser votre builder au lieu de la fabrique.
La version utilisant le builder semble bien plus pratique que la fabrique ! C’est donc une solution plutôt satisfaisante pour ce problème qu’on pourra retenir.
Amélioration du générateur de donjons
Et si nous essayons de combiner plus de patterns ? Nous avons l’application idéale pour ça : le générateur de donjon (paquetage fabrique2
). Il y a déjà la Fabrique Abstraite, le Singleton et Prototype.
-
Nous aimerions maintenant que les différentes salles puissent être combinées afin de créer une “super-salle”. Par exemple, on aimerait avoir des salles avec des coffres et 20 ennemis, une salle avec une énigme et un boss, une salle avec tout à la fois…! Quel design pattern pouvez-vous utiliser pour mettre en place une telle fonctionnalité ? La seule méthode qui nous intéresse est
toString
. À l’affichage, on doit avoir les informations de toutes les salles qui composent une salle. Après vos refactoring, vous devrez logiquement adapter le code de vos deux fabriques. Pour la configuration difficile, on souhaite faire des changements supplémentaires :-
Une salle normale est une salle avec un coffre et entre 20 et 40 ennemis.
-
Une salle spéciale est soit une salle avec un coffre et 20 ennemis (une chance sur cinq) ou bien une salle avec un boss avec un niveau entre 30 et 50.
-
La salle finale est une salle avec une énigme, un boss de niveau 80 et 30 ennemis.
-
-
On aimerait améliorer la création des salles composées. Par exemple, avec un Builder (adapté)…?
-
Testez que tout fonctionne.
-
Mettez à jour le diagramme de classes de conception que vous aviez initialement produit pour cette application.
Conclusion
Vous maîtrisez maintenant l’ensemble des design patterns créateurs ainsi que le pattern Adaptateur. Ce TP vous a aussi logiquement permis de renforcer votre pratique de la conception logicielle et de l’application des principes SOLID
.
Toutefois, attention à ne pas attraper la patternité : c’est une maladie connue qu’attrapent les jeunes ingénieurs logiciels qui découvrent les design patterns. Cela se manifeste par une envie de mettre des patterns partout à tort et à travers même quand cela n’est pas nécessaire. Comme pour le singleton, il faut se poser la question de savoir si un pattern est nécessaire ou non. Normalement, l’utilisation d’un pattern vient assez naturellement quand on souhaite résoudre le problème qui lui est lié. Si son utilisation semble un peu trop forcée, c’est que cela est probablement injustifié, ou pire : on est en train de construire un anti-pattern, une solution non-seulement injustifiée, mais aussi qui a plus de conséquences négatives que positives. Bref, comme toujours, il faut avoir une vision générale et sur le long terme !
Dans le prochain TP, nous verrons comment encore plus optimiser la gestion des dépendances de notre programme en construisant notre propre conteneur IoC afin de gérer dynamiquement les dépendances majeures de nos applications.