I. Introduction▲
Le design pattern strategy définit un objet représentant un algorithme dédié à une tâche spécifique.
Dans une application orientée objet, lorsqu'une nouvelle tâche est identifiée, la réaction du programmeur est habituellement de créer une classe qui représente cette tâche. Cependant, il se peut que la classe évolue, ce qui génère de nombreuses sous-tâches, chacune d'elles rendant la tâche d'origine plus complexe. Parfois, ce type de sous-tâches peut avoir de nombreux rôles différents. Un exemple typique est une classe logger (1) dont le rôle est habituellement d'écrire un message dans un fichier. Dans un premier temps, cette classe pourrait être enrichie, permettant l'écriture sur de nombreuses autres cibles : fichiers et bases de données. L'ajout d'une fonctionnalité pourrait permettre au message en question d'être formaté de différentes façons : chaîne de caractères, XML, données sérialisées, voire HTML.
Désormais, notre classe comprend une tâche basique, l'action de logger. Elle inclut également deux sous-tâches distinctes : formater et écrire. Chaque sous-tâche possède de nombreuses fonctionnalités ayant chacune leur importance. Le pattern strategy suggère une façon d'implémenter une structure de classe flexible qui permet aux programmeurs de permettre une extension aisée de la classe, évitant ainsi quelques pièges habituels tout en en conservant cette flexibilité. En outre, le pattern strategy démontre que, bien souvent, la composition est plus puissante que le simple héritage.
II. Mettre en œuvre le design pattern strategy▲
Fondamentalement, cette classe aspire à la simplicité. Elle reçoit un message et l'écrit quelque part, pour le stocker. Peu importe la manière dont nous allons factoriser et étendre le logger, n'oublions pas notre objectif : conserver une interface maintenable. Alors qu'en Php 4 cela relevait de la rigueur du programmeur, PHP 5 permet d'implémenter une interface. C'est aussi simple de créer une interface (similaire aux classes) que chaque classe Logger devra respecter.
Il n'est pas nécessaire de détailler de manière trop approfondie le fonctionnement des interfaces. Toutes les classes qui implémentent une interface doivent obligatoirement redéfinir une liste de méthodes définies dans cette interface ; ne pas le faire entraînera une erreur fatale. Nous avons choisi de nommer notre interface IWriter plutôt que Ilogger, car sa description est plus générique, cette interface pourrait être réutilisée par un autre type de classe.
Avec l'interface définie ci-dessus, reprenons notre exemple de logger.
Notre logger original pourrait bien ressembler à cela :
class Logger {
private
$file
=
null
;
public
function
__construct
($file
) {
$this
->
file =
$file
;
}
public
function
write($message
) {
file_put_contents($this
->
file,
array
(PHP_EOL,
$message
),
FILE_APPEND);
}
}
Cette classe est particulièrement simple. Elle est instanciée avec un paramètre : l'emplacement du fichier de log qui doit être stocké. L'appel de la méthode Logger::write(), engendre l'ouverture du fichier et l'ajout de la valeur du paramètre $message (précédé par le caractère saut de ligne) à la fin de ce même fichier. Le tableau contenant les valeurs est automatiquement concaténé (nous avons simplement utilisé join()) et le flag FILE_APPEND nous assure que nous n'écraserons pas des données existantes dans le fichier.
Cela ne devrait pas présenter de difficulté particulière.
Malheureusement, les besoins de notre logger pourraient s'avérer plus complexes. Comme évoqué dans l'introduction, le logger doit pouvoir stocker des messages aussi bien dans un fichier que dans une base de données. Quoi que l'on souhaite utiliser, la solution la plus simple est de créer des sous-classes de notre classe logger. Nous pourrions appeler ces classes Logger_File et Logger_DB. Pour le moment l'implémentation de ces classes n'est pas trop lourde.
La difficulté survient lorsque nous ajoutons un second élément variable. Le nombre de prérequis augmente pour inclure différentes méthodes nécessaires au formatage d'un message avant que ce dernier ne soit stocké. Cette augmentation est source de confusion.
L'héritage est certes très adapté pour séparer une différence de comportement en deux nouvelles classes distinctes. C'est d'ailleurs ce que nous avons fait en séparant la partie stockage en deux classes avec Logger_File et Logger_DB.
Cependant, lorsqu'il existe plus d'une variation, l'héritage produit quelque chose de sale. Regardons de plus près nos sous-classes Logger_File et Logger_DB. En ajoutant différentes méthodes de formatage, nous devrions créer autant de sous-classes que d'options de formatage. Si les besoins de formatage sont String, XML ou HTML, la liste de nos classes devrait ressembler à cela :
- Logger_DB_String
- Logger_DB_XML
- Logger_DB_HTML
- Logger_File_String
- Logger_File_XML
- Logger_File_HTML
Désormais, nous avons donc six sous-classes de la classe Logger. Avant d'écrire davantage de code, quelques observations s'imposent concernant cette structure :
- La tâche de formatage est dupliquée pour chacune des deux options de sortie base de données (DB) et File ;
- Ajouter un nouveau format amènerait à ajouter deux sous-classes (une pour chaque option) ;
- Ajouter une nouvelle sortie amènerait à ajouter trois nouvelles classes (dupliquer tous les formats pour la nouvelle sortie) ;
- Les points 1 et 3 ci-dessus ont des effets indésirables qui rendront l'ensemble très difficile à maintenir et à faire évoluer.
Ces remarques soulignent bien que le choix exclusif du sous-classement pour résoudre nos problèmes présente de nombreuses lacunes.
Chaque option supplémentaire augmentera de manière exponentielle le nombre de classes à ajouter pour la maintenir.
Ajouter ne serait-ce que quelques options aurait pour effet de créer des DIZAINES de nouvelles classes, créant ainsi de la complexité et de la redondance de code.
La solution ? Eh bien, j'espère que vous avez lu le titre de l'article ;). Le pattern strategy résout élégamment ce problème en utilisant la puissance de la composition, remplaçant ainsi la complexité croissante induite par l'extension de la hiérarchie des classes.
Le pattern est relativement simple et de nombreux programmeurs expérimentés l'auraient proposé lorsque nous avons défini la problématique du Logger. Même sans avoir entendu parler de ce pattern, ils auraient certainement trouvé la solution par eux-mêmes.
Le pattern Stragegy fonctionne par identification de chacune de ces sous-tâches et les isole dans une nouvelle famille de classe. Voyons à nouveau la liste des sous-classes Logger. La tâche la plus variable est le formatage. Il y a deux types de sorties et il est clair que nous aurons besoin de davantage. Cependant le formatage est bien plus ouvert. Il pourrait y avoir des dizaines de façons d'utiliser le logger pour le formatage du message. En fait, nous pourrions ajouter une fonction de formatage sprintf pour permettre davantage de flexibilité sans pour autant forcer les programmeurs à définir le chargement des formats spécialisés.
Sachant que le formatage est l'élément le plus variable et le plus prompt à posséder de nouvelles options, nous allons le séparer du Logger. Le formatage pourrait être ajouté comme une nouvelle famille de classe Formatter. Nous pouvons créer une super classe appelée Formatter et la sous-classer pour chaque méthode de formatage. Dans notre cas, les options de formatage n'ont pas de tâche en commun, (dans le monde du formatage, chacun n'exécute qu'une tâche unique). Cela signifie que nous pouvons faire autrement avec une classe parente commune. Maintenant que le principe de design pattern (et de POO en général) « programmer une interface et non une implémentation » est défini, nous nous assurons que chaque Formateur respecte la même interface. En PHP 5, nous pouvons mettre cela en application en définissant une interface.
interface IFormatter {
public
function
format();
}
Après avoir mis en place l'interface, nous passons à la création de la classe Formatter.
class Formatter_String implements
IFormatter {
public
function
format($message
) {
return
$message
.
PHP_EOL;
}
}
class
Formatter_XML implements
IFormatter {
public
function
format($message
) {
$timestamp
=
time();
// Syntaxe heredoc
$xml
=
<<<XML_EOL
<message>
<time>
$timestamp
</time>
<text>
$message
</text>
</message>
XML_EOL;
return
$xml
.
PHP_EOL;
}
}
class
Formatter_HTML implements
IFormatter {
public
function
format($message
) {
$timestamp
=
time();
// Syntaxe heredoc
$html
=
<<<HTML_EOL
<p>
<b>Timestamp:</b>
$timestamp
<br />
<b>Message:</b>
$message
</p>
HTML_EOL;
return
$html
.
PHP_EOL;
}
}
La structure de notre classe Formatter en place, nous pouvons maintenant nous attarder sur la façon de l'utiliser avec notre classe Logger. Sans les options de formatage, notre famille de classe Logger a été subdivisée en trois classes bien plus maintenables. En l'occurrence une classe parente : Logger et deux sous-classes Logger, à savoir : Logger_File et Logger_DB.
Voilà maintenant à quoi ressemble la classe parent Logger :
abstract class Logger implements
IWriter {
protected
$formatter
;
protected
function
__construct
($formatter
) {
if
($formatter
instanceof
IFormatter)
{
$this
->
formatter =
$formatter
;
}
else
{
trigger_error('Invalid Formatter!'
,
E_USER_ERROR);
}
}
}
class
Logger_File extends
Logger {
private
$file
=
null
;
public
function
__construct
($formatter
,
$file
) {
parent
::
__construct
($formatter
);
$this
->
file =
$file
;
}
public
function
write($message
) {
$formatted_message
=
$this
->
formatter->
format($message
);
file_put_contents($this
->
file,
$formatted_message
,
FILE_APPEND);
}
}
class
Logger_DB extends
Logger {
// Dans notre exemple, $db est un objet de connexion de base de données ADOdb Lite.
private
$db
=
null
;
public
function
__construct
($formatter
,
$db
) {
parent
::
__construct
($formatter
);
$this
->
db =
$db
;
}
public
function
write($message
) {
$formatted_message
=
$this
->
formatter->
format($message
);
$_date
=
time();
$_escaped_message
=
$db
->
qstr($message
);
$this
->
db->
Execute('INSERT INTO app_log_messages (time, message) VALUES ('
.
$_date
.
','
.
$_escaped_message
.
')'
);
}
}
Note du traducteur : le code du constructeur de la classe Logger pourrait être avantageusement remplacé en spécifiant le type de l'objet attendu en paramètre et donc en évitant de faire cette vérification explicitement. Merci à Julien Pauli pour la suggestion.
protected function __construct(IFormatter $formatter
) {
$this
->
formatter =
$formatter
;
}
Maintenant que nous avons créé une nouvelle classe Logger, nous pouvons maintenant passer un objet Formatter comme paramètre du constructeur. Vu son type actuel, cet objet Formatter respectera toujours la même interface. Pour cette raison, le logger peut utiliser n'importe quel format très facilement en utilisant la méthode write() comme cela est défini par l'interface IWriter. En étudiant la nouvelle structure (le logger et la famille de classes Formatter qu'il utilise par composition), on voit une nette amélioration par rapport au concept original qui reposait uniquement sur de l'héritage.
Utiliser notre nouveau logger avec la classe Formatter en appliquant le design pattern strategy est aussi simple que cela :
// Ajouter un message de log au format XML dans un fichier XML
$logger
=
new Logger_File(new Formatter_XML(),
'
/tmp/mylog.xml
'
);
$logger
->
write('
Je suis un message de log formaté en XML!
'
)
L'amélioration de la conception est due à l'implémentation du design pattern strategy appliqué au Logger ; séparer la variabilité du formatage en son propre type de classe et associer le logger avec un formatter en passant ce dernier en tant que paramètre de constructeur. Dans les années 1990, la « Bande des quatre » dans leur livre « Design Patterns » ont indiqué le principe à suivre : « Privilégier la composition à l'héritage ». Le design pattern strategy démontre avec élégance ce principe par l'exemple.
Que pouvons-nous dire d'autre à propos du design pattern strategy ? L'architecture de notre logger est bien plus simple et propre que si nous avions simplement utilisé l'héritage seul. Toute redondance de code a été évitée. Ajouter un nouveau formateur ou bien une nouvelle option de sortie au log ne requiert qu'une seule nouvelle classe. Nous avons créé une nouvelle classe indépendante du logger (le Formatter) qui permet une réutilisation future. Les avantages font que le pattern strategy est un des pattern que le programmeur ne voudra pas oublier (négliger ?).
III. Conclusion▲
Notre implémentation du pattern strategy pour l'exemple du logger démontre à la fois sa simplicité et son côté pratique. C'est un pattern qui utilise la composition à la préférence de l'héritage pour réduire la complexité qui pourrait résulter de l'expansion des classes lorsqu'au moins deux sous-tâches sont variables.
Une leçon importante à retenir de l'implémentation du pattern strategy est que la composition devrait être envisagée par tout programmeur. L'héritage est très pratique, mais dans certains scénarios complexes, c'est juste un mauvais choix.
III-A. Liens▲
Cinq motifs classiques de conception pour PHP par Jack D. Herrington, Guillaume Rossolini
POO PHP 5 : Design Pattern observateur aidé de la Standard PHP Library (Spl) par Julien Pauli
Méthodologie de développement MVC d'une application PHP par Serge Tahé
POO PHP 5 : Créer un agrégateur à base de réflexion et de Spl par Julien Pauli
III-B. Remerciements▲
Nous tenons à adresser un grand merci à Guillaume Rossolini alias Yogui pour son rôle actif (aide, relecture et conseils) dans la rédaction de cet article. Merci également à Sylvain James pour la relecture et ses suggestions et à Julien Pauli pour ses observations.