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 :
2.
3.
4.
@FunctionalInterface
public
interface
Command {
void
execute
(
);
}
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 :
2.
3.
4.
5.
public
interface
Conversation<
C extends
Command>
{
void
exec
(
C cmd);
void
undo
(
);
void
redo
(
);
}
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).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
public
class
TypeString implements
CompensableCommand, MementoableCommand {
private
final
Display display;
private
final
String stringToType;
public
TypeString
(
Display display, String stringToType) {
this
.display =
display;
this
.stringToType =
stringToType;
}
@Override
public
void
execute
(
) {
display.append
(
stringToType);
}
[...]
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
public
class
Display {
private
final
LinkedList<
String>
displayElements =
new
LinkedList<>(
);
public
void
append
(
String stringToAppend) {
displayElements.addLast
(
stringToAppend);
}
public
String displayed
(
) {
// Collectors.joining est équivalent à un foreach+StringBuilder mais plus fonctionnel
// (évite l'itération externe/style impératif)
return
displayElements.stream
(
).collect
(
Collectors.joining
(
));
}
[...]
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
/**
* Undo is noop when there were no execs
* undo --> ""
*/
@Test
public
void
basicNoopUndo
(
) {
Conversation<
C>
commands =
newConversation
(
);
commands.undo
(
);
assertEquals
(
""
, display.displayed
(
));
}
/**
* Redo is noop when there were no undos
* redo --> ""
*/
@Test
public
void
basicNoopRedo
(
) {
Conversation<
C>
commands =
newConversation
(
);
commands.redo
(
);
assertEquals
(
""
, display.displayed
(
));
}
/**
* Basic undo
* a --> "a"
* undo --> ""
*/
@Test
public
void
basicUndo
(
) {
Conversation<
C>
commands =
newConversation
(
);
commands.exec
(
typeString
(
"a"
));
assertEquals
(
"a"
, display.displayed
(
));
commands.undo
(
);
assertEquals
(
""
, display.displayed
(
));
}
/**
* Basic redo
* a --> "a"
* undo --> ""
* redo --> "a"
*/
@Test
public
void
basicRedo
(
) {
Conversation<
C>
commands =
newConversation
(
);
commands.exec
(
typeString
(
"a"
));
assertEquals
(
"a"
, display.displayed
(
));
commands.undo
(
);
assertEquals
(
""
, display.displayed
(
));
commands.redo
(
);
assertEquals
(
"a"
, display.displayed
(
));
}
/**
* a --> "a"
* b --> "ab"
* undo --> "a"
* undo --> ""
* redo --> "a"
* redo --> "ab"
*/
@Test
public
void
exec_exec_undo_undo_redo_redo
(
) {
Conversation<
C>
commands =
newConversation
(
);
commands.exec
(
typeString
(
"a"
));
assertEquals
(
"a"
, display.displayed
(
));
commands.exec
(
typeString
(
"b"
));
assertEquals
(
"ab"
, display.displayed
(
));
commands.undo
(
);
assertEquals
(
"a"
, display.displayed
(
));
commands.undo
(
);
assertEquals
(
""
, display.displayed
(
));
commands.redo
(
);
assertEquals
(
"a"
, display.displayed
(
));
commands.redo
(
);
assertEquals
(
"ab"
, display.displayed
(
));
}
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) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
public
class
CommandUndoTest_Compensation_Typing_Test extends
CommandUndoTest_Typing<
CompensableCommand>
{
@Override
protected
Conversation<
CompensableCommand>
newConversation
(
) {
return
new
CompensationConversation
(
);
}
@Override
protected
CompensableCommand typeString
(
String stringToType) {
return
new
TypeString
(
display, stringToType);
}
}
public
class
CommandUndoTest_Replay_Typing_Test extends
CommandUndoTest_Typing<
Command>
{
@Override
protected
Conversation<
Command>
newConversation
(
) {
return
new
ReplayConversation
((
)->{
display.reset
(
);
}
);
}
@Override
protected
Command typeString
(
String stringToType) {
return
new
TypeString
(
display, stringToType);
}
}
public
class
CommandUndoTest_Memento_Typing_Test extends
CommandUndoTest_Typing<
MementoableCommand>
{
@Override
protected
Conversation<
MementoableCommand>
newConversation
(
) {
return
new
MementoConversation
(
);
}
@Override
protected
MementoableCommand typeString
(
String stringToType) {
return
new
TypeString
(
display, stringToType);
}
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
/**
*
@param
<C>
Type of executed commands
*
@param
<S>
Type of stored state (commands or mementos)
*/
public
abstract
class
AbstractConversation<
C extends
Command, S>
implements
Conversation<
C>
{
protected
final
Stack<
S>
undos, redos;
public
AbstractConversation
(
) {
this
.undos=
new
Stack<
S>(
);
this
.redos=
new
Stack<
S>(
);
}
}
AbstractConversation utilise la classe Stack (
classe interne au framework):
public
class
Stack<
T>
{
//Delegate to avoid exposing too many Deque methods
private
final
Deque<
T>
stack =
new
LinkedList<>(
);
/**
*
@return
null if stack is empty
*/
public
T pop
(
) {
return
stack.pollLast
(
); //Not using pop since it throws NoSuchElementException if the deque is empty
}
public
void
push
(
T cmd) {
stack.addLast
(
cmd);
}
public
void
clear
(
) {
stack.clear
(
);
}
public
void
forEachFifo
(
Consumer<
? super
T>
action) {
stack.stream
(
).forEachOrdered
(
action);
}
}
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 :
2.
3.
public
interface
CompensableCommand extends
Command {
void
compensate
(
);
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
public
class
CompensationConversation extends
AbstractConversation<
CompensableCommand, CompensableCommand>
{
@Override
public
void
exec
(
CompensableCommand todo) {
todo.execute
(
);
undos.push
(
todo);
redos.clear
(
);
}
@Override
public
void
undo
(
) {
CompensableCommand latestCmd =
undos.pop
(
);
if
(
latestCmd==
null
) return
;
latestCmd.compensate
(
);
redos.push
(
latestCmd);
}
@Override
public
void
redo
(
) {
CompensableCommand latestCmd =
redos.pop
(
);
if
(
latestCmd==
null
) return
;
latestCmd.execute
(
);
undos.push
(
latestCmd);
}
}
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).
2.
3.
4.
5.
public
class
TypeString implements
CompensableCommand {
@Override
public
void
compensate
(
) {
display.unappend
(
);
}
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
public
class
ReplayConversation extends
AbstractConversation<
Command, Command>
{
private
final
Command reset;
public
ReplayConversation
(
Command reset) {
this
.reset =
reset;
}
@Override
public
void
exec
(
Command todo) {
todo.execute
(
);
undos.push
(
todo);
redos.clear
(
);
}
@Override
public
void
undo
(
) {
Command latestCmd =
undos.pop
(
) ;
if
(
latestCmd==
null
) return
;
redos.push
(
latestCmd);
reset.execute
(
);
undos.forEachFifo
(
cmd->
cmd.execute
(
));
}
@Override
public
void
redo
(
) {
Command latestCmd =
redos.pop
(
) ;
if
(
latestCmd==
null
) return
;
latestCmd.execute
(
);
undos.push
(
latestCmd);
}
}
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 :
2.
3.
4.
5.
6.
7.
public
class
CommandUndoTest_Replay_Typing_Test extends
CommandUndoTest_Typing<
Command>
{
@Override
protected
Conversation<
Command>
newConversation
(
) {
return
new
ReplayConversation
((
)->{
display.clear
(
);
}
);
}
}
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 :
2.
3.
4.
5.
6.
7.
/**
* Implementations must be immutable (the memento must capture a snapshot)
*/
@FunctionalInterface
public
interface
Memento {
void
restore
(
);
}
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 :
2.
3.
public
interface
MementoableCommand extends
Command {
Memento takeSnapshot
(
);
}
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:
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
public
class
MementoConversation extends
AbstractConversation<
MementoableCommand, BeforeAfter>
{
@Override
public
void
exec
(
MementoableCommand todo) {
Memento before =
todo.takeSnapshot
(
);
todo.execute
(
);
Memento after =
todo.takeSnapshot
(
);
undos.push
(
new
BeforeAfter
(
before, after));
redos.clear
(
);
}
@Override
public
void
undo
(
) {
BeforeAfter latestMemento =
undos.pop
(
);
if
(
latestMemento==
null
) return
;
Memento latestBefore =
latestMemento.before;
latestBefore.restore
(
);
redos.push
(
latestMemento);
}
@Override
public
void
redo
(
) {
BeforeAfter latestMemento =
redos.pop
(
);
if
(
latestMemento==
null
) return
;
Memento latestAfter =
latestMemento.after;
latestAfter.restore
(
);
undos.push
(
latestMemento);
}
}
//@Immutable
class
BeforeAfter {
final
Memento before, after;
/**
*
@param
before
must be immutable
*
@param
after
must be immutable
*/
public
BeforeAfter
(
Memento before, Memento after) {
this
.before =
before;
this
.after =
after;
}
}
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
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
public
class
Macro implements
Command {
private
final
List<
Command>
parts;
public
Macro
(
List<
Command>
parts) {
this
.parts =
parts;
}
@Override
public
void
execute
(
) {
this
.parts.forEach
(
c ->
c.execute
(
));
}
}
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.