Polar Code 🎭

Command Palette

Search for a command to run...

21
Pièce N°21

CHAPITRE 20 – COMMAND

Le carnet était là, sur le bureau du patron. Des ordres griffonnés : « VERIFIER LE ALIBI DE X », « PERQUISITIONNER L’APPARTEMENT Y », « INTERROGER LE TEMOIN Z ». Chaque ligne était une action atomique, pouvant être donnée, annulée, reportée, déléguée. Dans le code, les actions étaient souvent des appels de méthode directs, dispersés, impossibles à orchestrer après coup. Il fallait les encapsuler, les rendre manipulables comme des objets de première classe. Il fallait une Commande.

Encapsulation d’actions

Le pattern Command transforme une requête ou une action en un objet. Cet objet contient toutes les informations nécessaires pour exécuter l’action : la méthode à appeler, ses arguments, et l’objet receveur. En encapsulant l’action, on peut la paramétrer, la passer en argument, la stocker, l’exécuter plus tard, ou l’annuler.

Le cœur du pattern repose sur une interface avec une seule méthode, souvent appelée execute() (ou execute()).

// L'interface Command abstraite
interface Command {
    execute(): void;
    undo?(): void; // Optionnel pour le support de l'annulation
}

// Le Receveur (Receiver) : l'objet qui sait vraiment effectuer le travail
class CompteBancaire {
    constructor(private solde: number = 0) {}

    deposer(montant: number): void {
        this.solde += montant;
        console.log(`✅ Dépôt de ${montant}. Nouveau solde: ${this.solde}`);
    }

    retirer(montant: number): void {
        if (montant <= this.solde) {
            this.solde -= montant;
            console.log(`✅ Retrait de ${montant}. Nouveau solde: ${this.solde}`);
        } else {
            console.log(`❌ Retrait de ${montant} refusé. Solde insuffisant: ${this.solde}`);
        }
    }

    getSolde(): number {
        return this.solde;
    }
}

Implémentation de base et Undo/Redo

La vraie puissance de Command apparaît avec l’annulation. Pour cela, chaque commande doit savoir inverser ses propres effets.

// Commande concrète pour un dépôt
class CommandDepot implements Command {
    private compte: CompteBancaire;
    private montant: number;

    constructor(compte: CompteBancaire, montant: number) {
        this.compte = compte;
        this.montant = montant;
    }

    execute(): void {
        this.compte.deposer(this.montant);
    }

    undo(): void {
        // Pour annuler un dépôt, on retire le même montant.
        this.compte.retirer(this.montant);
        console.log(`↩️ Annulation du dépôt de ${this.montant}`);
    }
}

// Commande concrète pour un retrait
class CommandRetrait implements Command {
    private compte: CompteBancaire;
    private montant: number;
    private succes: boolean = false; // Mémorise si le retrait a réussi

    constructor(compte: CompteBancaire, montant: number) {
        this.compte = compte;
        this.montant = montant;
    }

    execute(): void {
        const soldeAvant = this.compte.getSolde();
        this.compte.retirer(this.montant);
        const soldeApres = this.compte.getSolde();
        // Le retrait n'a réussi que si le solde a diminué exactement du montant.
        this.succes = (soldeAvant - soldeApres) === this.montant;
    }

    undo(): void {
        if (this.succes) {
            // Pour annuler un retrait réussi, on redépose le montant.
            this.compte.deposer(this.montant);
            console.log(`↩️ Annulation du retrait de ${this.montant}`);
        } else {
            console.log(`↩️ Rien à annuler (le retrait avait échoué)`);
        }
    }
}

// L'Invoker (le déclencheur) : Celui qui demande l'exécution des commandes.
// Peut être un bouton, un menu, un script, un planificateur...
class Invoker {
    private history: Command[] = []; // Historique pour undo/redo
    private redoStack: Command[] = []; // Pile pour redo

    executeCommand(command: Command): void {
        command.execute();
        this.history.push(command);
        this.redoStack = []; // Une nouvelle action invalide la pile redo
        console.log(`💾 Commande exécutée et sauvegardée dans l'historique.`);
    }

    undo(): void {
        const lastCommand = this.history.pop();
        if (lastCommand && lastCommand.undo) {
            lastCommand.undo();
            this.redoStack.push(lastCommand);
            console.log(`↩️ Undo effectué.`);
        } else {
            console.log(`⚠️ Rien à annuler.`);
        }
    }

    redo(): void {
        const lastUndone = this.redoStack.pop();
        if (lastUndone) {
            lastUndone.execute();
            this.history.push(lastUndone);
            console.log(`↪️ Redo effectué.`);
        } else {
            console.log(`⚠️ Rien à refaire.`);
        }
    }

    showHistory(): void {
        console.log(`\n📜 Historique des commandes (${this.history.length}):`);
        this.history.forEach((cmd, idx) => {
            console.log(`  ${idx + 1}. ${cmd.constructor.name}`);
        });
    }
}

File d’attente de commandes et exécution différée

Une fois les actions encapsulées, on peut les mettre en file d’attente, les planifier, ou les envoyer sur un réseau.

// Scheduler / File d'attente de commandes
class Scheduler {
    private queue: Command[] = [];
    private isProcessing: boolean = false;

    addCommand(command: Command): void {
        this.queue.push(command);
        console.log(`📥 Commande ajoutée à la file. Taille: ${this.queue.length}`);
        this.processQueue(); // Déclenche le traitement si pas déjà en cours
    }

    private async processQueue(): Promise<void> {
        if (this.isProcessing || this.queue.length === 0) {
            return;
        }
        this.isProcessing = true;
        while (this.queue.length > 0) {
            const command = this.queue.shift()!;
            console.log(`⚙️ Exécution de la commande depuis la file...`);
            await this.simulateDelayedExecution(command);
        }
        this.isProcessing = false;
        console.log(`🏁 File d'attente vide.`);
    }

    private simulateDelayedExecution(command: Command): Promise<void> {
        return new Promise(resolve => {
            setTimeout(() => {
                command.execute();
                resolve();
            }, 1000); // Délai d'une seconde
        });
    }
}

// --- Scénario de démonstration ---
function scenario() {
    console.log("=== SCÉNARIO : Compte Bancaire avec Commandes ===");
    const compte = new CompteBancaire(100);
    const invoker = new Invoker();
    const scheduler = new Scheduler();

    // Création des commandes
    const depot1 = new CommandDepot(compte, 50);
    const retrait1 = new CommandRetrait(compte, 30);
    const retrait2 = new CommandRetrait(compte, 150); // Va échouer (solde insuffisant)
    const depot2 = new CommandDepot(compte, 25);

    console.log("\n1. Exécution directe et undo/redo via Invoker:");
    invoker.executeCommand(depot1); // Dépôt de 50
    invoker.executeCommand(retrait1); // Retrait de 30
    invoker.showHistory();
    invoker.undo(); // Annule le retrait de 30
    invoker.undo(); // Annule le dépôt de 50
    invoker.redo(); // Refait le dépôt de 50
    console.log(`Solde final après undo/redo: ${compte.getSolde()}`);

    console.log("\n2. Exécution différée via Scheduler (file d'attente):");
    // On réinitialise le compte pour le second scénario
    const compte2 = new CompteBancaire(100);
    const depotFile = new CommandDepot(compte2, 200);
    const retraitFile = new CommandRetrait(compte2, 75);

    scheduler.addCommand(depotFile);
    scheduler.addCommand(retraitFile);
    // Les commandes seront exécutées avec un délai d'une seconde chacune.

    console.log("\n3. Macros (Commandes composées):");
    // Une macro est une commande qui en exécute plusieurs autres.
    class MacroCommand implements Command {
        private commands: Command[] = [];
        add(command: Command): void { this.commands.push(command); }
        execute(): void {
            console.log("▶️ Début de la macro...");
            for (const cmd of this.commands) {
                cmd.execute();
            }
            console.log("⏹️ Fin de la macro.");
        }
        undo(): void {
            console.log("↩️ Annulation de la macro (inverse l'ordre)...");
            for (let i = this.commands.length - 1; i >= 0; i--) {
                if (this.commands[i].undo) this.commands[i].undo!();
            }
        }
    }

    const macro = new MacroCommand();
    macro.add(new CommandDepot(compte, 10));
    macro.add(new CommandRetrait(compte, 5));
    macro.add(new CommandDepot(compte, 2));

    invoker.executeCommand(macro);
    invoker.undo(); // Annule toute la macro d'un coup
}

// Décommenter pour exécuter le scénario
// scenario();

Cas d’usage avancés

  1. Transactions : Une suite de commandes peut former une transaction. Si une échoue, on exécute undo() sur toutes les précédentes (rollback).
  2. Journalisation et audit : Enregistrer toutes les commandes exécutées (avec leurs paramètres) permet de rejouer l’historique, de déboguer, ou de respecter des contraintes légales.
  3. Réseau et parallélisme : Les commandes peuvent être sérialisées, envoyées sur le réseau, et exécutées sur une machine distante (RPC). Elles peuvent aussi être placées dans une queue pour être traitées par un pool de workers.
  4. Wizards et assistants : Les étapes d’un assistant peuvent être des commandes, permettant à l’utilisateur de revenir en arrière sans tout perdre.

Avantages et inconvénients

Avantages :

  • Découplage total entre l’objet qui invoque l’opération et celui qui l’exécute.
  • Extensibilité : Ajouter de nouvelles commandes est facile (principe OCP).
  • Fonctionnalités avancées : Annulation, rejouabilité, file d’attente, journalisation deviennent possibles.
  • Composition : Possibilité de créer des commandes complexes (macros).

Inconvénients :

  • Complexité : Le code devient plus verbeux (beaucoup de petites classes).
  • Overhead : Créer un objet pour chaque action peut avoir un impact sur les performances dans des boucles très serrées (à profil avant d’optimiser).

Le carnet d’ordres était maintenant un système. Chaque action, figée dans un objet Command, pouvait vivre indépendamment, être examinée, annulée, rejouée, envoyée ailleurs.

Le pattern Command était l’outil de l’orchestration et du contrôle. Il transformait le code procédural (« fais ceci, puis cela ») en un système d’objets manipulables.

Le prochain pattern, Observer, aborderait un problème comportemental inverse : non pas déclencher une action, mais réagir à un changement. Le mécanisme de notification, fondamental dans les interfaces utilisateur et les architectures événementielles.