Modèle de conception Commande en langage Groovy

Le modèle de conception Commande fait partie des modèles de conception de type comportemental, présenté notamment dans le célèbre livre Design Patterns: Elements of Reusable Object-Oriented Software de Erich Gamma, Richard Helm, Ralph Johnson, et John Vlissides.
Dans cet article, je donnerai un exemple d'implémentation de ce modèle de conception en langage Groovy, dans le style de programmation orientée objet, puis ensuite dans le style de la programmation fonctionnelle, avec beaucoup moins de "cérémonie" (moins de déclarations de type).

Approche classique dans le style de la programmation orientée objet

Un objet de type Commande permet de représenter une action à effectuer, avec tous les paramètres nécessaires à son exécution ; comme représenté dans le diagramme UML ci-dessous (provenant de la page Command pattern sur Wikipedia), de tels objets sont des instances de classes implémentant une interface commune (ici Command) définissant une méthode représentant l'exécution de l'action.

On peut ainsi créer de tels objets représentant des actions qui seront à n'importe quel moment exécutées via la méthode execute de l'interface Command, par un client qui a le type Invoker dans le diagramme. Il n'y a ainsi pas de couplage entre le client et une action implémentée, car l'interaction s'effectue par l'intermédiaire d'une interface.

Le diagramme porte aussi une notion de receveur, appelé également cible, sous la forme d'une classe : celle-ci représente la logique métier sur laquelle l'action s'appuie pour effectuer son traitement.

Gestion de panier d'achats

Imaginez que, dans une application de commerce en ligne, nous voulions garder un historique de toutes les actions d'un utilisateur sur son panier d'articles, en ne considérant -par simplification- que l'ajout et le retrait d'un article dans le panier.
Dans le code ci-dessous, que vous pourrez toujours exécuter en tant que script Groovy, nous avons les types suivants :

  • La classe Item représentant un article à ajouter au panier ou à l'en retirer.
  • La classe Cart permettant la gestion du panier, et codée de manière simpliste ; cette classe a le rôle de receveur.
  • Le trait CartCommand (qui nous permet de partager la propriété cart, tout comme nous aurions pu également partager une propriété item), et les classes action AddItemCommand et RemoveItemCommand pour respectivement, représenter des actions d'ajout et de retrait d'article.
1
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
class Item {
    String name
    Double price
}

class Cart {
    Map items = [:]

    Cart addItem(Item item) {
        items[item.name] = item
        this
    }
    
    Cart removeItem(Item item) {
        items.remove(item.name)
        this
    }
}

trait CartCommand {
    Cart cart
    abstract def execute()
}

class AddItemCommand implements CartCommand {
    Item item
    
    AddItemCommand(Cart cart, Item item) {
        this.cart = cart
        this.item = item
    }

    def execute() {
        cart.addItem(item)
    }
}

class RemoveItemCommand implements CartCommand {
    Item item
    
    RemoveItemCommand(Cart cart, Item item) {
        this.cart = cart
        this.item = item
    }

    def execute() {
        cart.removeItem(item)
    }
}

def cart = new Cart()

// Command object creation
def command1 = new AddItemCommand(cart, new Item(name: 'Item1', price: 1))
def command2 = new AddItemCommand(cart, new Item(name: 'Item2', price: 1))
def command3 = new RemoveItemCommand(cart, new Item(name: 'Item1', price: 1))

// Invoke commands (invoker part)
def commands = [command1, command2, command3]
commands*.execute()

assert cart.items.keySet() == ['Item2'] as Set

Dans le script ci-dessus, nous créons trois commandes représentant des actions utilisateur au cours de sa session d'achat puis, de manière différée, nous exécutons chacune d'elles (grâce à l'opérateur spread), de sorte qu'au final, nous avons dans l'objet Cart l'état final du panier.
En l'occurrence, nous ajoutons l'article "Item1", puis l'article "Item2", et enfin nous retirons le premier article, ce qui fait que le panier ne contient plus que l'article "Item2", ce qui est vérifié par l'assertion.

Macro commande

Pour faciliter l'exécution d'un ensemble de commandes, mais aussi afin de pouvoir définir un autre receveur sur chacune d'elle en une seule fois, nous pouvons définir la classe MacroCartCommand suivante, qui est aussi une Commande :

1
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
class MacroCartCommand implements CartCommand {
    def commands = []
    
    MacroCartCommand leftShift(CartCommand command) {
        commands << command
        this
    }

    void setReceiver(Cart cart) {
        commands*.cart = cart
    }

    def execute() {
        commands*.execute()
    }
}

def macroCommand = new MacroCartCommand()
macroCommand << command1 << command2 << command3

def newCart = new Cart()

macroCommand.receiver = newCart
macroCommand.execute()

assert newCart.items.keySet() == ['Item2'] as Set

La classe MacroCartCommand encapsule ainsi une collection d'actions, dont nous pouvons demander l'exécution par la même méthode d'interface CartCommand.execute.
MacroCartCommand possède aussi la méthode setReceiver permettant de cibler le panier de notre choix.
Nous voyons que nous avons bien là un moyen de rejouer un ensemble d'actions sur un receveur donné.

Approche programmation fonctionnelle

Voyons maintenant comment nous pourrions implémenter notre exemple en ayant une approche du style programmation fonctionnelle, reconnaissant qu'une Commande peut s'implémenter au travers d'une Closure Groovy : en effet, si une Commande s'exécute de manière différée, c'est aussi le cas d'une Closure Groovy qui une fonction pouvant s'appeler plus tard.

Nous conservons les classes Item et Cart ; par contre les commandes peuvent s'implémenter comme des fonctions d'ordre supérieur, sous la forme de fonctions renvoyant des Closures :

1
2
3
4
5
6
7
def makeAddItemCommand(Cart cart, Item item) {
    { -> cart.addItem(item) }
}

def makeRemoveItemCommand(Cart cart, Item item) {
    { -> cart.removeItem(item) }
}

On peut ainsi créer la Commande souhaitée en passant à la fonction les paramètres nécessaires, qui retourne alors une Closure, et invoquer cette dernière au moment opportun, comme ceci :

1
2
3
4
5
6
7
8
9
def fpCart = new Cart()

// Create command
def addItem1 = makeAddItemCommand(fpCart, new Item(name: 'Item1', price: 1))

// Invoke command
addItem1()

assert fpCart.items.keySet() == ['Item1'] as Set

Dans cet exemple, addItem() est bien sûr l'appel de la Closure retournée par makeAddItemCommand.

Spécifier le receveur

Et si nous voulions pouvoir spécifier l'objet Cart, le receveur, sur lequel appliquer la commande ?
Dans ce cas, nous pourrions modifier les fonctions makeAddItemCommand et makeRemoveItemCommand comme ci-dessous, en supprimant le paramètre cart de chaque fonction, et en l'introduisant comme paramètre de la Closure retournée :

1
2
3
4
5
6
7
def _makeAddItemCommand(Item item) {
    { Cart cart -> cart.addItem(item) }
}

def _makeRemoveItemCommand(Item item) {
    { Cart cart -> cart.removeItem(item) }
}

Cela signifie que pour invoquer maintenant une commande, il faut lui passer un objet Cart :

1
2
3
4
5
6
7
8
9
10
def actions = [
    _makeAddItemCommand(new Item(name: 'Item1', price: 1)),
    _makeAddItemCommand(new Item(name: 'Item2', price: 1)),
    _makeRemoveItemCommand(new Item(name: 'Item1', price: 1))
]

def _fpCart = new Cart()
actions*.call(_fpCart)

assert _fpCart.items.keySet() == ['Item2'] as Set

En guise de conclusion

Nous voyons bien que l'approche fonctionnelle du modèle de conception Commande, mise en évidence avec le langage Groovy et ses particularités au travers d'un exemple, permet de réduire la "cérémonie" à mettre en place pour l'implémenter !