Tutoriel sur Pattern Command : Undo, variations Compensation/Replay/Memento

| Laurent Claisse (Zenika) | Zenika |

La dernière fois, je vous avais parlé de quelques patterns d'implémentation avec les enums Java, et en particulier de l'application des enums au Domain Driven Design grâce à l'inversion de dépendance. Aujourd'hui, nous allons parler d'un design pattern classique, le pattern Command. Après un rappel de sa structure et de ses utilisations, nous irons un peu plus loin en analysant en particulier trois variations permettant d'implémenter l'undo/redo.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur Pattern Command : Undo, variations Compensation/Replay/Memento

- Auteurs : Laurent Claisse (Zenika), Zenika

- Parution : 14 janvier 2015

- Licence : Copyright ® 2015 Laurent Claisse. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Rappels sur le pattern Command

- II. Découplage modulaire

- III. Découplage spatial ou temporel avec le pattern Command

- III-A. Découplage spatial : commande remote

- III-B. Découplage temporel : l'undo

- III-C. Cas spatial et temporel

- IV. Implémentations de l'undo avec le pattern Command, tests et variations

- IV-A. Les interfaces principales : Command et Conversation

- IV-B. Les tests exécutés pour toutes les variations

- IV-C. Cœur de l'implémentation

- IV-D. Variation : Undo par Compensation

- IV-E. Variation : Undo par Replay

- IV-F. Variation : Undo par Memento

- V. Critères de choix d'une variation

- V-A. Compensation Undo

- V-B. Variation : Memento Undo

- V-C. Variation : Replay Undo

- VI. Autres considérations

- VI-A. Profondeur de l'undo-redo

- VI-B. Commandes et macros

- VII. Conclusion et remerciements

I - Rappels sur le pattern Command

Dans le langage commun, un pattern désigne un motif répétitif, tels ceux qui décorent les tapis et le carrelage. En programmation, un pattern désigne une solution fréquente à un problème récurrent. Il existe plusieurs types de patterns :

- les analysis patterns modélisent des domaines métiers particuliers (mesures et observations, comptabilité...) ;

- les design patterns offrent des solutions à des problèmes généraux de conception (dans le monde objet : structure, création, comportement et interactions des objets) ;

- les implementation patterns recensent les techniques d'implémentation propres à chaque langage (dans l'article précédent, l'utilisation des enums polymorphiques de Java).

Ainsi, le pattern Command est un design pattern, utilisable dans tout langage orienté objet.

Le livre de loin le plus connu sur les design patterns est Design Patterns: Elements of Reusable Object-Oriented Software. On l'appelle souvent le GOF, abréviation de Gang of Four qui désigne ses auteurs. Selon le GOF, l'intention du pattern Command est d'encapsuler une requête comme un objet, autorisant ainsi le paramétrage des clients par différentes requêtes, file d'attente et récapitulatifs de requêtes, et de plus permettant la révision des opérations. Le GOF fait ici allusion à deux utilisations des commandes : découpler la création d'une requête de son exécution, et implémenter l'undo. Ce dernier point est le sujet principal de ce post.

La structure du pattern Command, telle qu'expliquée dans le GOF, est la suivante :

Expliquons les rôles intervenant dans ce pattern :

- Command : l'abstraction d'une action, qui permet de découpler la création de l'action de son exécution ;

- Invocator : celui qui déclenche l'exécution des commandes (ex. : un item d'un menu d'IHM) ;

- ConcreteCommand : un type de commande particulier ;

- Client : l'instanciateur de ConcreteCommand. Il capture les paramètres passés au constructeur de ConcreteCommand. Attention, le terme Client n'est pas à prendre au sens remoting ni au sens IHM ;

- Receptor : la cible d'un type de commande particulier (ex. : un graphique pour une commande "drawLine"). Attention, le terme Receptor n'est pas à prendre au sens remoting.

Ils interagissent de la façon suivante :

- le Client capture dans le système les valeurs permettant de paramétrer une ConcreteCommand . Il l'instancie en lui passant ces paramètres ;

- l'Invocator déclenche une Command ;

- la ConcreteCommand exécute une action impactant le Receptor, en utilisant les paramètres que son constructeur a capturés.

On voit que ce pattern permet bien de découpler l'instanciation d'un traitement de son exécution. C'est pour cela qu'on introduit souvent ce degré d'abstraction lorsque les lieux d'instanciation et d'exécution d'un traitement sont éloignés, dans l'espace ou dans le temps.

II - Découplage modulaire

Sans aller jusqu'à la séparation physique, la séparation entre modules (même co-localisés) est une motivation suffisante. Le pattern Command permet :

- de garder des modules faiblement couplés : le module d'exécution a juste à connaître l'interface de Command sans se soucier de la création de nouvelles commandes ;

- en particulier, d'ajouter de nouveaux types de commandes (de nouvelles ConcreteCommand) dans le module de création, sans modifier le module d'exécution. Cette approche correspond au principe open-close de Robert Martin (voir aussi les autres principes SOLID du même auteur).

III - Découplage spatial ou temporel avec le pattern Command

Le problème à résoudre est ici que le point de création de la commande, qui est le seul à connaître les données nécessaires à l'exécution d'un traitement, peut être différent du point d'exécution de la commande, qui est le seul à connaître le contexte (portée/scope d'une séquence de commandes, et/ou ressources techniques) nécessaire à son exécution.

III-A - Découplage spatial : commande remote

Dans le cas spatial, la commande est typiquement créée localement et exécutée sur un serveur distant/remote (les rôles du pattern Command sont indiqués sous forme de stéréotypes UML quand ils diffèrent des types propres à la variation Remote Command) :

La cinématique de la création d'une commande côté local et de son exécution côté distant est la suivante :

- Le Client (attention, pas au sens client-serveur) instancie la ConcreteCommand en lui passant les paramètres propres à une opération particulière.

- Le constructeur de la ConcreteCommand capture ces paramètres.

- Côté local, le LocalInvocator (ex. : un client-proxy EJB) déclenche l'exécution de la commande en l'envoyant au RemoteInvocator distant (sérialisation/remoting).

- Côté serveur, la commande est reçue par le RemoteInvocator (ex. : un serveur proxy EJB).

- Le RemoteInvocator instancie un ConcreteExecutionEnvironment, en passant au constructeur de ce dernier les ressources techniques nécessaires (EntityManager...). Le RemoteInvocator peut par exemple être un EJB @Remote.

- Le ConcreteExecutionEnvironment construit les récepteurs concrets (DAO...) en utilisant les ressources techniques fournies par le RemoteInvocator (EntityManager...)

- Le RemoteInvocator invoque la méthode execute de la commande en lui passant cette instance de ConcreteExecutionEnvironment.

- La méthode execute de la commande concrète invoque un ou plusieurs récepteurs abstraits (ex: Repository). Pour ce faire, elle récupère les Receptors cibles de la commande dans l'ExecutionEnvironment (ex: executionEnvironment.getRepository()).

III-B - Découplage temporel : l'undo

Dans le cas temporel, le problème est différent : la commande peut être exécutée tout de suite, mais elle doit pouvoir être annulée ou rejouée à un instant ultérieur et indéfini. Comme le dit le GOF, un objet Commande peut avoir une durée de vie indépendante de la requête originelle. Il est donc nécessaire qu'un contexte maintienne une référence vers la commande exécutée. Dans la suite, on nomme ce contexte Conversation. Ce découplage temporel peut être utilisé de plusieurs façons :

- introduire un niveau d'asynchronisme (léger différé) entre la création de la commande et son exécution ;

- rejouer la commande ou son inverse (déclenché par la conversation).

Dans le cas qui nous intéresse ici, la conversation mémorise les commandes exécutées et déclenche l'undo/redo. Chacune des commandes stockées par la conversation continue à contenir les paramètres spécifiques à une exécution particulière, qui avaient été capturés lors de l'instanciation de la commande. Ce type de commande est typiquement exécuté dans le même environnement que celui où la commande a été instanciée, et la méthode execute n'a donc pas besoin d'un paramètre ExecutionEnvironnement (les ressources nécessaires à l'exécution peuvent être passées dès l'instanciation).

L'undo par commande a la structure suivante (les rôles du pattern Command sont indiqués sous forme de stéréotypes UML quand ils diffèrent des types propres à la variation Undo Command - en UML on devrait utiliser une « collaboration paramétrée », mais par facilité on utilise ici de simples stéréotypes) :

III-C - Cas spatial et temporel

Il est évidemment possible de cumuler les deux difficultés, auquel cas le serveur devra à la fois fournir un ExecutionEnvironment et maintenir une pile des commandes déjà exécutées (un EJB Stateful permet par exemple de remplir ces deux fonctionnalités). Puisque ce post concerne l'undo, on simplifiera cependant en supposant que les commandes sont locales (intra-JVM, sans remoting).

IV - Implémentations de l'undo avec le pattern Command, tests et variations

Venons-en au cœur du sujet : quelles sont les implémentations possibles ? Laquelle choisir pour un type de commande particulier ?

La fonctionnalité d'undo/redo est fréquemment demandée pour une IHM, car l'être humain se trompe. Elle est souvent liée à une action utilisateur Ctrl+Z/Ctrl+Y. Elle n'est cependant pas triviale à implémenter, car les implémentations possibles dépendent du type de commande (comme on le verra dans la partie Critères de choix d'une variation). On propose ici trois variations du pattern, plus ou moins adaptées selon le type de commande :

- compensation : pour chaque commande, on définit une commande inverse appelée compensation ;

- memento : lors de chaque exécution de commande, on mémorise l'état du système ; annuler une commande consiste alors à revenir à l'état précédent ;

- replay : pour annuler la dernière commande, on commence par repositionner le système dans l'état initial (reset), puis on exécute de nouveau toutes les commandes sauf la dernière.

Le code est disponible sur Github. Il nécessite Java 8. Le projet contient un framework d'undo par commande. Les tests JUnit implémentent la commande Typing de saisie d'un message sur un affichage (Display). Il comprend une même suite de tests appliquée aux trois variations.

IV-A - Les interfaces principales : Command et Conversation

Puisqu'on se place dans le cas simple sans ExecutionEnvironment, l'interface Command est la suivante :

Command.java

1. @FunctionalInterface

2. public interface Command {

3. void execute();

4. }

L'annotation @FunctionalInterface n'est pas obligatoire, mais elle a les avantages suivants :

- demande au compilateur de vérifier que notre interface est une Single Abstract Method Interface, qui peut être implémentée par un lambda ;

- documente ce fait aux utilisateurs de l'interface, et signale l'engagement de l'auteur du framework à maintenir cette caractéristique dans les versions futures.

Introduisons la conversation, qui est le scope dans lequel on exécute, annule, et rejoue des commandes :

Conversation.java

1. public interface Conversation<C extends Command> {

2. void exec(C cmd);

3. void undo();

4. void redo();

5. }

Le nom Conversation souligne le fait qu'il arrive souvent que le résultat d'une suite de commandes soit committé atomiquement, ce qui correspond à un scope conversation. La conversation est identifiée à l'Invocator (du pattern Command du GOF), qui a le double rôle de mémoriser et de déclencher les commandes. Ici, c'est le même Client qui instancie les commandes concrètes et qui demande à l'Invocator de les déclencher. Notons que l'interface Conversation est générique : elle porte un type parameter C extends Command. Le but est d'avoir une seule interface Conversation commune aux trois variations d'undo, tout en permettant l'utilisation par ses implémentations des trois déclinaisons de Command que nous allons présenter.

IV-B - Les tests exécutés pour toutes les variations

On montre les tests (ce qu'on veut faire) avant de montrer l'implémentation (comment on le fait). Les tests utilisent une commande TypeString qui représente la saisie d'un String dans un Display (les interfaces CompensableCommand et MementoableCommand sont expliquées dans les parties sur les variations correspondantes).

TypeString.java

1. public class TypeString implements CompensableCommand, MementoableCommand {

3. private final Display display;

4. private final String stringToType;

6. public TypeString(Display display, String stringToType) {

7. this.display = display;

8. this.stringToType = stringToType;

9. }

11. @Override public void execute() {

12. display.append(stringToType);

13. }

15. [...]

16. }

Le code passé sous silence pour l'instant est spécifique à une des trois variations.

Le receptor (cf. partie Rappels sur le pattern Command) Display est la cible de la commande concrète Typing :

Display.java

1. public class Display {

3. private final LinkedList<String> displayElements = new LinkedList<>();

5. public void append(String stringToAppend) {

6. displayElements.addLast(stringToAppend);

7. }

9. public String displayed() {

10. // Collectors.joining est équivalent à un foreach+StringBuilder mais plus fonctionnel

11. // (évite l'itération externe/style impératif)

12. return displayElements.stream().collect(Collectors.joining());

13. }

15. [...]

16. }

Le code passé sous silence est spécifique à une des trois variations.

Pour s'assurer que les trois variations sont fonctionnellement équivalentes, on écrit les tests dans une classe abstraite CommandUndoTest_Typing. Les tests commencent par les cas triviaux et vont jusqu'à une conversation complexe :

1. /**

2. * Undo is noop when there were no execs

3. * undo --> ""

4. */

5. @Test public void basicNoopUndo() {

6. Conversation<C> commands = newConversation();

8. commands.undo();

9. assertEquals("", display.displayed());

10. }

12. /**

13. * Redo is noop when there were no undos

14. * redo --> ""

15. */

16. @Test public void basicNoopRedo() {

17. Conversation<C> commands = newConversation();

19. commands.redo();

20. assertEquals("", display.displayed());

21. }

23. /**

24. * Basic undo

25. * a    --> "a"

26. * undo --> ""

27. */

28. @Test public void basicUndo() {

29. Conversation<C> commands = newConversation();

31. commands.exec(typeString("a"));

32. assertEquals("a", display.displayed());

34. commands.undo();

35. assertEquals("", display.displayed());

36. }

38. /**

39. * Basic redo

40. * a    --> "a"

41. * undo --> ""

42. * redo --> "a"

43. */

44. @Test public void basicRedo() {

45. Conversation<C> commands = newConversation();

47. commands.exec(typeString("a"));

48. assertEquals("a", display.displayed());

50. commands.undo();

51. assertEquals("", display.displayed());

53. commands.redo();

54. assertEquals("a", display.displayed());

55. }

57. /**

58. * a    --> "a"

59. * b    --> "ab"

60. * undo --> "a"

61. * undo --> ""

62. * redo --> "a"

63. * redo --> "ab"

64. */

65. @Test public void exec_exec_undo_undo_redo_redo() {

66. Conversation<C> commands = newConversation();

68. commands.exec(typeString("a"));

69. assertEquals("a", display.displayed());

71. commands.exec(typeString("b"));

72. assertEquals("ab", display.displayed());

74. commands.undo();

75. assertEquals("a", display.displayed());

77. commands.undo();

78. assertEquals("", display.displayed());

80. commands.redo();

81. assertEquals("a", display.displayed());

83. commands.redo();

84. assertEquals("ab", display.displayed());

85. }

Certains des tests peuvent être consultés dans le code sur Github, mais ne sont pas mentionnés ici pour alléger l'article.

Les classes concrètes spécifiques aux trois variations ne font qu'instancier un type différent de Conversation (à un détail technique près, propre aux génériques Java) :

1. public class CommandUndoTest_Compensation_Typing_Test extends CommandUndoTest_Typing<CompensableCommand> {

2. @Override protected Conversation<CompensableCommand> newConversation() {

3. return new CompensationConversation();

4. }

6. @Override protected CompensableCommand typeString(String stringToType) {

7. return new TypeString(display, stringToType);

8. }

9. }

11. public class CommandUndoTest_Replay_Typing_Test extends CommandUndoTest_Typing<Command> {

12. @Override protected Conversation<Command> newConversation() {

13. return new ReplayConversation(()->{

14. display.reset();

15. });

16. }

18. @Override protected Command typeString(String stringToType) {

19. return new TypeString(display, stringToType);

20. }

21. }

23. public class CommandUndoTest_Memento_Typing_Test extends CommandUndoTest_Typing<MementoableCommand> {

24. @Override protected Conversation<MementoableCommand> newConversation() {

25. return new MementoConversation();

26. }

28. @Override protected MementoableCommand typeString(String stringToType) {

29. return new TypeString(display, stringToType);

30. }

31. }

IV-C - Cœur de l'implémentation

Le cœur de l'implémentation est l'utilisation de deux stacks (FIFO), une pour l'undo et une pour le redo : intuitivement, on sent bien que :

- chaque exécution de commande rajoute un undo potentiel ;

- chaque undo rajoute un redo potentiel.

La classe AbstractConversation utilise deux paramètres de type, C et S. En effet, chacune des trois implémentations concrètes de Conversation a besoin de connaître :

- le type de commandes exécutées (paramètre C), qui dérive de Command. Par exemple, CompensationConversation ne peut accepter que des CompensableCommand ;

- le type d'état conversationnel stocké (paramètre S). Dans la variante Memento, on ne mémorise pas les commandes, mais plutôt des snapshots de l'état du système (dans l'interface Conversation, il n'y a toujours que le paramètre C, car le type d'état conversationnel stocké est un détail d'implémentation du point de vue de l'utilisateur de l'API Conversation).

La classe AbstractConversation se présente ainsi :

1. /**

2. *  @param <C> Type of executed commands

3. * @param <S> Type of stored state (commands or mementos)

4. */

5. public abstract class AbstractConversation<C extends Command, S> implements Conversation<C> {

6. protected final Stack<S> undos, redos;

8. public AbstractConversation() {

9. this.undos= new Stack<S>();

10. this.redos= new Stack<S>();

11. }

12. }

13. AbstractConversation utilise la classe Stack (classe interne au framework):

15. public class Stack<T> {

17. //Delegate to avoid exposing too many Deque methods

18. private final Deque<T> stack = new LinkedList<>();

20. /**

21. * @return null if stack is empty

22. */

23. public T pop() {

24. return stack.pollLast(); //Not using pop since it throws NoSuchElementException if the deque is empty

25. }

27. public void push(T cmd) {

28. stack.addLast(cmd);

29. }

31. public void clear() {

32. stack.clear();

33. }

35. public void forEachFifo(Consumer<? super T> action) {

36. stack.stream().forEachOrdered(action);

37. }

38. }

Stack délègue à une Deque (double-ended queue) l'implémentation de la stack ; en effet, il existe une classe java.util.Stack mais elle est obsolète, comme Vector. Déléguer à LinkedList plutôt que l'étendre présente deux avantages :

- évite la pollution d'API : Stack n'expose que les méthodes utiles pour notre framework ;

- liberté de nommage : dans Deque, push() et pop() n'ont pas la sémantique souhaitée (pop() : exception si vide), donc on utilise pollLast()/addLast(). Mais push et pop sont plus compréhensibles, donc ce sont ces noms-là qu'on expose à notre framework.

Enfin, précisons que forEachFifo() est utilisé par la variation Replay. FIFO signifie first-in first-out, donc un parcours dans l'ordre d'insertion.

IV-D - Variation : Undo par Compensation

L'infrastructure étant en place, regardons la première variation qui est la plus naturelle. Une action compensatoire d'une action A consiste à effectuer l'action inverse -A. Par exemple, l'action compensatoire d'un débit erroné de x EUR est un crédit de x EUR.

Dans le pattern Command, ceci se traduit par une commande compensable :

1. public interface CompensableCommand extends Command {

2. void compensate();

3. }

On a vu dans le paragraphe « Découplage temporel : l'undo » que la conversation pouvait être identifiée à l'acteur Invocator du pattern Command du GOF. CompensationConversation est l'Invocator spécialisé pour les commandes compensables :

1. public class CompensationConversation extends AbstractConversation<CompensableCommand, CompensableCommand> {

3. @Override public void exec(CompensableCommand todo) {

4. todo.execute();

5. undos.push(todo);

6. redos.clear();

7. }

9. @Override public void undo() {

10. CompensableCommand latestCmd = undos.pop();

11. if(latestCmd==null) return;

12. latestCmd.compensate();

13. redos.push(latestCmd);

14. }

16. @Override public void redo() {

17. CompensableCommand latestCmd = redos.pop();

18. if(latestCmd==null) return;

19. latestCmd.execute();

20. undos.push(latestCmd);

21. }

22. }

Dans la variation Compensation, les deux types parameters sont identiques (S=C=CompensableCommand dans AbstractConversation<C,S>), puisque l'état mémorisé est constitué de commandes. En effet, la compensation utilise les commandes déjà exécutées pour les compenser (undo) ou les exécuter de nouveau (redo).

À la fin d'exec(), on vide la stack de redo : les commandes annulées qui précèdent l'exécution d'une commande sont complètement effacées de la mémoire. La raison de ce choix est de se conformer aux conventions de toutes les IHM offrant un undo/redo (principe de moindre surprise).

1. public class TypeString implements CompensableCommand {

2. @Override public void compensate() {

3. display.unappend();

4. }

5. }

IV-E - Variation : Undo par Replay

Il est parfois difficile de trouver une action compensatrice. Dans ce cas, si on a invoqué N commandes, une implémentation alternative de l'undo est de réinitialiser l'état à zéro, puis de rejouer les N-1 premières commandes. Le redo consiste ensuite à rejouer la Nième commande.

Contrairement à la variation Compensation, puisque la variation Replay se repose uniquement sur l'exécution des commandes dans leur sens normal, ReplayConversation n'a pas besoin d'un type particulier de commandes : S=C=Command dans AbstractConversation<C,S>). Par contre, elle a besoin d'une instance de commande capable de remettre l'état à zéro :

1. public class ReplayConversation extends AbstractConversation<Command, Command> {

2. private final Command reset;

4. public ReplayConversation(Command reset) {

5. this.reset = reset;

6. }

8. @Override public void exec(Command todo) {

9. todo.execute();

10. undos.push(todo);

11. redos.clear();

12. }

14. @Override public void undo() {

15. Command latestCmd = undos.pop() ;

16. if(latestCmd==null) return;

17. redos.push(latestCmd);

18. reset.execute();

19. undos.forEachFifo(cmd->cmd.execute());

20. }

22. @Override public void redo() {

23. Command latestCmd = redos.pop() ;

24. if(latestCmd==null) return;

25. latestCmd.execute();

26. undos.push(latestCmd);

27. }

28. }

La réinitialisation de l'état utilise une commande spécifique au type d'état modifié par les commandes. Dans le cas de la commande Typing (saisie) qui modifie un Display (affichage), on peut simplement effacer complètement l'affichage :

1. public class CommandUndoTest_Replay_Typing_Test extends CommandUndoTest_Typing<Command> {

2. @Override protected Conversation<Command> newConversation() {

3. return new ReplayConversation(()->{

4. display.clear();

5. });

6. }

7. }

Le lambda qui est passé au constructeur de ReplayConversation correspond au champ ReplayConversation.reset. Il vide l'affichage.

IV-F - Variation : Undo par Memento

Plutôt que de mémoriser les commandes, on peut aussi implémenter l'undo en mémorisant leur effet, plus précisément l'état du système avant et après chaque exécution.

Cela évoque un autre pattern du GOF, le Memento :

L'Originator instancie un Memento en passant à son constructeur un snapshot de l'état du système. Le CareTaker est chargé de connaître le Memento, afin de pouvoir demander, ultérieurement, la restauration de l'état capturé.

Le Memento doit être capable de restaurer un état capturé antérieurement, d'où l'exigence d'immutabilité. On ne veut pas voir les changements de l'état du système postérieurs au snapshot, il faut donc utiliser des techniques comme la copie défensive. Comme Memento est une interface, on précise cette exigence dans le contrat sous forme de Javadoc :

1. /**

2. * Implementations must be immutable (the memento must capture a snapshot)

3. */

4. @FunctionalInterface

5. public interface Memento {

6. void restore();

7. }

Chaque type de commande peut modifier un type d'état différent, pour lequel la façon de capturer un Memento sera différente. Par exemple, la capture d'un état persisté en BDD sera très différente de la capture de l'état d'un logiciel de dessin. Le plus simple est donc de spécialiser Command en MementoableCommand, pour que les implémentations de MementoableCommand réalisent à la fois la modification et la capture de cet état, suivant les spécificités de ce dernier :

1. public interface MementoableCommand extends Command {

2. Memento takeSnapshot();

3. }

Dans la variation Memento, les deux types parameters ne sont pas identiques (C=MementoableCommand, S=BeforeAfter dans AbstractConversation<C,S>), puisque l'état mémorisé est constitué de captures successives de l'état et non de commandes. Petite subtilité : pour implémenter le redo, on a besoin de capturer l'état avant ET après exécution de la commande:

1. public class MementoConversation extends AbstractConversation<MementoableCommand, BeforeAfter> {

3. @Override public void exec(MementoableCommand todo) {

4. Memento before = todo.takeSnapshot();

5. todo.execute();

6. Memento after = todo.takeSnapshot();

8. undos.push(new BeforeAfter(before, after));

9. redos.clear();

10. }

12. @Override public void undo() {

13. BeforeAfter latestMemento = undos.pop();

14. if(latestMemento==null) return;

15. Memento latestBefore = latestMemento.before;

16. latestBefore.restore();

17. redos.push(latestMemento);

18. }

20. @Override public void redo() {

21. BeforeAfter latestMemento = redos.pop();

22. if(latestMemento==null) return;

23. Memento latestAfter = latestMemento.after;

24. latestAfter.restore();

25. undos.push(latestMemento);

26. }

27. }

29. //@Immutable

30. class BeforeAfter {

32. final Memento before, after;

34. /**

35. * @param before must be immutable

36. * @param after  must be immutable

37. */

38. public BeforeAfter(Memento before, Memento after) {

39. this.before = before;

40. this.after = after;

41. }

42. }

Dans cette variation, la commande est donc l'Originator du pattern Memento du GOF (l'acteur qui déclenche le snapshot), et la conversation en est le Caretaker (l'acteur qui mémorise les Mementos). Les rôles du pattern Memento sont indiqués sous forme de stéréotypes UML quand ils diffèrent des types propres à la variation Memento Undo Command).

V - Critères de choix d'une variation

Le critère éliminatoire est la possibilité d'implémenter une variation. Cette possibilité (ou impossibilité) dépend essentiellement de deux facteurs :

- le type de Receptor des commandes: les tests communs aux trois variations utilisent tous le même type de Receptor, la classe Display. Celle-ci implémente des méthodes permettant de choisir n'importe laquelle des variations. Ainsi, unappend() est nécessaire pour implémenter la variation Compensation, getState()/setState() sont nécessaires pour implémenter la variation Memento et clear() est nécessaire pour implémenter la variation Replay. Pour un Receptor réel, il est peu vraisemblable d'avoir autant de liberté de choix ;

- le type de commandes concrètes : certaines commandes ne se prêtent pas du tout à certaines variations.

Voyons pour chacune des trois variations quelques critères spécifiques.

V-A - Compensation Undo

Facteurs favorables :

- la Compensation est la plus naturelle (symétrie entre la commande et son inverse) ;

- cette variation est idéale quand il existe une commande inverse naturelle dont le nom est évident: le contraire d'une création est une suppression (et vice-versa), le contraire d'un débit est un crédit (vice-versa) ;

- ceci fonctionne bien quand la sémantique de la commande est très précise et a peu de degrés de liberté ;

- du point de vue de l'utilisation mémoire, une pile de commandes est souvent plus légère que des snapshots de l'état complet (VS Memento) ;

- du point de vue de la performance, elle évite de rejouer une séquence de commandes (VS Replay) ou de repositionner un gros état (VS Memento).

Facteurs défavorables :

- Les commandes non compensables ne peuvent utiliser cette variation. Par exemple, l'écriture dans un log ne peut être compensée.

- Une commande très générale comme un "update" est difficile à compenser: il faut mémoriser tous les champs mis à jour, utiliser l'introspection...

- Le nombre et la variété de commandes concrètes : pour choisir cette variation, il faut être sûr à l'avance qu'on pourra compenser toutes les commandes (même celles qui peuvent être introduites par une évolution future de l'application).

- Il est difficile de compenser les modifications de relations entre objets d'un graphe, surtout si ces relations sont bidirectionnelles.

Remarques :

- La méthode Domain Driven Design préconise de toute façon d'éviter les commandes très générales comme l'update générique (POST), et de modéliser les changements d'état par des transitions déclenchées par des événements (PUT /event {eventData}). Les transitions ont une sémantique plus précise et sont plus faciles à compenser.

- Quand tous les changements de l'état applicatif sont stockés comme une séquence d'événements (Event Sourcing), doit-on forcément utiliser les actions compensatoires ? Pas forcément, tant que l'événement qu'on veut annuler n'est pas définitif (tant qu'il fait partie d'une conversation pas encore commitée).

V-B - Variation : Memento Undo

Facteurs favorables :

- quand les commandes individuelles ne sont pas (ou difficilement) compensables, ou que le système présente une hystérésis ;

- quand l'API du Receptor donne un moyen direct de réaliser des snapshots ;

- quand on peut modifier ou wrapper un Receptor existant pour se ramener au cas précédent.

Facteurs défavorables :

- quand le Receptor est write-only ou qu'il est difficile de capturer son état ;

- lorsque les contraintes sur la mémoire sont trop importantes.

V-C - Variation : Replay Undo

Facteurs favorables :

- Quand toute autre action a échoué : cette variation est celle qui fait le moins d'hypothèses sur le Receptor : il suffit qu'il puisse être remis à zéro.

- Si on ne peut pas utiliser la variation Compensation, le Replay utilise dans la plupart des cas moins de mémoire, car les commandes prennent moins de place que les Mementos.

Facteurs défavorables :

- Pas très élégant quand on peut faire autrement ;

- Ne doit pas être rédhibitoire du point de vue des performances.

VI - Autres considérations

VI-A - Profondeur de l'undo-redo

Dans notre exemple, les piles d'undo/redo ont une profondeur illimitée. Cela peut provoquer une OutOfMemoryError, et de toute façon l'utilisateur ne se rappelle plus les opérations trop anciennes. Le principe GRASP (attribution de responsabilités) expert en information nous suggère de placer la gestion de cette limitation dans le type qui a la connaissance de la profondeur actuelle de la pile, donc Stack.

VI-B - Commandes et macros

À partir du pattern Command, il est très simple de définir des macros : il suffit d'utiliser en plus le pattern Composite (les rôles du pattern Composite sont indiqués sous forme de stéréotypes UML quand ils diffèrent des types propres à la variation Command Macro) :

Les macros sont simples à implémenter avec le pattern Command

1. public class Macro implements Command {

3. private final List<Command> parts;

4. public Macro(List<Command> parts) {

5. this.parts = parts;

6. }

8. @Override public void execute() {

9. this.parts.forEach(c -> c.execute());

10. }

11. }

VII - Conclusion et remerciements

Le pattern Command est la façon la plus répandue d'implémenter l'undo. Comme souvent en conception orientée objet, la solution est de remplacer l'invocation directe d'une méthode par la création d'un objet (ici, la commande). Cette technique est également très utile pour refacturer du code procédural (cf. Working effectively with legacy code).

La fonction d'undo/redo peut être difficile à implémenter suivant le type d'état modifié. Cette fonctionnalité est structurante, et ne peut pas toujours être rajoutée a posteriori dans une conception existante sans une très grosse refonte, alors incluez-la dès le début si c'est nécessaire. Attention aux erreurs de type effet de bord, cette fonctionnalité doit absolument avoir une bonne couverture de tests.

Cet article a été publié avec l'aimable autorisation de Laurent Claisse. L'article original (Pattern Command: Undo, variations Compensation/Replay/Memento) peut être vu sur le blog/site de Zenika.

Nous tenons à remercier ced pour sa relecture orthographique attentive de cet article et Mickael Baron pour la mise au gabarit.