Design Patterns — Solutions éprouvées
Les design patterns (patrons de conception) sont des solutions réutilisables à des problèmes récurrents de conception logicielle. Ils ne sont pas du code prêt à copier, mais des recettes adaptables à votre contexte.
Notions théoriques
Pourquoi les connaître ?
Sans patterns, on réinvente la roue et on produit du code difficile à faire évoluer. Les patterns offrent un vocabulaire commun entre développeurs : dire "c'est un Singleton" ou "c'est un Observer" communique une architecture entière en deux mots.
Nous allons voir 4 patterns essentiels, tous illustrés avec le projet RPG.
Singleton — Une seule instance
Problème : certains objets ne doivent exister qu'en un seul exemplaire (gestionnaire de jeu, connexion à la base, configuration).
Solution : le constructeur est privé, l'accès se fait via une méthode statique qui crée l'instance au premier appel et la retourne ensuite.
public class GestionnaireJeu {
private static GestionnaireJeu instance;
// Constructeur privé : impossible de faire new GestionnaireJeu()
private GestionnaireJeu() {
System.out.println("Gestionnaire initialisé");
}
public static GestionnaireJeu getInstance() {
if (instance == null) {
instance = new GestionnaireJeu();
}
return instance;
}
public void demarrerPartie() {
System.out.println("Partie démarrée !");
}
}
// Utilisation
GestionnaireJeu g1 = GestionnaireJeu.getInstance();
GestionnaireJeu g2 = GestionnaireJeu.getInstance();
System.out.println(g1 == g2); // true : même instance
La version ci-dessus n'est pas thread-safe. En environnement multi-thread, ajoutez synchronized à la méthode, ou utilisez l'enum Singleton (version la plus robuste en Java) : public enum GestionnaireJeu { INSTANCE; }.
Factory — Fabriquer sans connaître la classe
Problème : le code appelant ne devrait pas avoir à connaître les classes concrètes (Guerrier, Mage...) pour créer des personnages.
Solution : une Factory centralise la création et retourne le type abstrait.
public enum TypePersonnage { GUERRIER, MAGE, ARCHER }
public class PersonnageFactory {
public static Personnage creer(TypePersonnage type, String nom) {
return switch (type) {
case GUERRIER -> new Guerrier(nom, 150, 30);
case MAGE -> new Mage(nom, 100, 20, 50);
case ARCHER -> new Archer(nom, 120, 25, 80);
};
}
}
// Utilisation : l'appelant ne cite jamais Guerrier ou Mage
Personnage p1 = PersonnageFactory.creer(TypePersonnage.GUERRIER, "Aragorn");
Personnage p2 = PersonnageFactory.creer(TypePersonnage.MAGE, "Gandalf");
Strategy — Algorithme interchangeable
Problème : un personnage peut avoir différentes stratégies d'attaque (normale, critique, magique) que l'on veut changer à l'exécution.
Solution : encapsuler l'algorithme dans une interface, l'injecter dans la classe.
@FunctionalInterface
public interface StrategieAttaque {
int calculerDegats(int force);
}
public class Personnage {
private StrategieAttaque strategie;
public Personnage(String nom, int force, StrategieAttaque strategie) {
this.nom = nom;
this.force = force;
this.strategie = strategie;
}
public int attaquer() {
return strategie.calculerDegats(this.force);
}
// Changer de stratégie à runtime
public void changerStrategie(StrategieAttaque nouvelleStrategie) {
this.strategie = nouvelleStrategie;
}
}
// Strategies
StrategieAttaque normale = force -> force;
StrategieAttaque critique = force -> force * 2;
StrategieAttaque magique = force -> force + 50;
Personnage guerrier = new Personnage("Aragorn", 150, normale);
System.out.println(guerrier.attaquer()); // 150
guerrier.changerStrategie(critique);
System.out.println(guerrier.attaquer()); // 300
Observer — Notifier des abonnés
Problème : plusieurs composants (logger, statistiques, interface) doivent être informés des événements de combat sans que Combat les connaisse directement.
Solution : Combat maintient une liste d'observateurs et les notifie à chaque événement.
public interface CombatListener {
void onAttaque(String attaquant, String cible, int degats);
}
public class Combat {
private List<CombatListener> listeners = new ArrayList<>();
public void ajouterListener(CombatListener l) {
listeners.add(l);
}
private void notifier(String attaquant, String cible, int degats) {
for (CombatListener l : listeners) {
l.onAttaque(attaquant, cible, degats);
}
}
public void attaquer(Personnage attaquant, Personnage cible) {
int degats = attaquant.attaquer();
// ... appliquer les dégâts ...
notifier(attaquant.getNom(), cible.getNom(), degats);
}
}
// Utilisation
Combat combat = new Combat();
combat.ajouterListener((att, cib, deg) ->
System.out.println("[LOG] " + att + " frappe " + cib + " pour " + deg + " dégâts"));
combat.ajouterListener((att, cib, deg) ->
stats.enregistrer(att, deg));
Exemple pratique
public class Main {
public static void main(String[] args) {
// Singleton
GestionnaireJeu jeu = GestionnaireJeu.getInstance();
jeu.demarrerPartie();
// Factory
Personnage aragorn = PersonnageFactory.creer(TypePersonnage.GUERRIER, "Aragorn");
Personnage gandalf = PersonnageFactory.creer(TypePersonnage.MAGE, "Gandalf");
// Strategy : commencer en attaque normale, passer en critique
StrategieAttaque normale = force -> force;
StrategieAttaque critique = force -> force * 2;
aragorn.changerStrategie(normale);
System.out.println("Attaque normale : " + aragorn.attaquer());
aragorn.changerStrategie(critique);
System.out.println("Attaque critique : " + aragorn.attaquer());
// Observer
Combat combat = new Combat();
combat.ajouterListener((att, cib, deg) ->
System.out.println("[LOG] " + att + " attaque " + cib + " (" + deg + " dégâts)"));
combat.attaquer(aragorn, gandalf);
}
}
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Vous allez implémenter le pattern Factory et le pattern Strategy dans le projet RPG.
Étape 1 — Créer l'enum TypePersonnage et la Factory
Définissez l'enum TypePersonnage puis complétez la méthode creer() de PersonnageFactory.
En regroupant la création dans la Factory, vous définissez les valeurs par défaut (force=150, défense=30) en un seul endroit. Modifier ces valeurs pour tous les guerriers du jeu ne demande qu'un changement dans la Factory.
Étape 2 — Implémenter l'interface StrategieAttaque
Créez l'interface fonctionnelle StrategieAttaque et deux stratégies : normale et critique.
Quand une Strategy n'a qu'une méthode, l'annoter @FunctionalInterface permet aux appelants de passer une lambda au lieu d'instancier une classe anonyme. C'est l'usage moderne et idiomatique en Java 8+.
Étape 3 — Intégrer Strategy dans Personnage et tester le changement à runtime
Ajoutez l'attribut StrategieAttaque dans Personnage, une méthode attaquer() et une méthode changerStrategie().
Injecter la stratégie dans le constructeur (et non la coder en dur) rend le personnage testable : les tests unitaires peuvent passer n'importe quelle stratégie. C'est le principe d'injection de dépendance, que Spring Boot automatise avec @Autowired.