Pour cela, j'ai utilisé le mécanisme d'interface proposé par PHP, car il se prête remarquablement bien à la mise en place de ce patron de conception.
Pour cela, j'ai commencé par définir l'interface des objets qui seront observés, de la manière suivante :
<?php
namespace mageekguy\atoum;
interface observable
{
public function callObservers($method);
}
?>
En effet, un objet observé doit pouvoir appeler ses observateurs pour leur demander l’exécution d'une méthode.
Dans un premier temps, j'avais également défini une méthode observable::addObserver(observer $observer)
, avant de réaliser que je n'avais aucune raison de donner cette responsabilité à mon interface.
En effet, l'ajout d'un observateur peut prendre bien des formes, et imposer un format spécifique pour cela peut vite devenir bloquant, comme nous allons le voir par la suite.
Une fois la définition de l'interface de mes objets observés, j'ai ensuite défini celle de mes observateurs, et elle s'est révélée très simple :
<?php
namespace mageekguy\atoum;
interface observer {}
?>
À première vue, cette interface ne sert strictement à rien, si ce n'est à définir le type observer
.
Je l'ai pourtant conservé, car rien ne dit que je n'aurais pas à l'avenir des fonctionnalités à mutualiser à ce niveau.
J'ai ensuite fait la liste des événements pertinents susceptibles de survenir au cours de l'exécution des tests, et je les ai rattaché à un nom de méthode.
Au niveau de ma classe chargée de lancer les tests, nommée runner
, je n'en ai identifié que deux :
- Le début de l'exécution, rattaché à la méthode observer::runnerStart().
- La fin de l'exécution, rattachée à la méthode observer::runnerStop().
Ensuite, jai créé à partir de cette liste l'interface dédié à l'observation d'un runner
, qui a donc pris cette forme :
<?php
namespace mageekguy\atoum\observers;
use \mageekguy\atoum;
interface runner extends atoum\observer
{
public function runnerStart(atoum\runner $runner);
public function runnerStop(atoum\runner $runner);
}
?>
J'ai fais de même pour ma classe de test de base, nommée comme il se doit test
.
J'ai ainsi obtenu la liste suivante :
- Le début de l'exécution de la classe de test, rattaché à la méthode
observer::testRunStart()
. - Le début de la mise en place de l'environnement de test, rattaché à la méthode
observer::beforeTestSetup()
. - La fin de la mise en place de l'environnement de test, rattachée à la méthode
observer::afterTestSetup()
. - Le début de l'exécution d'une méthode de test, rattaché à la méthode
observer::beforeTestMethod()
. - Etc.
De la même façon que dans le cas du runner
, j'ai obtenu l'interface suivante à partir de cette liste :
<?php
namespace mageekguy\atoum\observers;
use \mageekguy\atoum;
interface test extends atoum\observer
{
public function testRunStart(atoum\test $test);
public function beforeTestSetUp(atoum\test $test);
public function afterTestSetUp(atoum\test $test);
public function beforeTestMethod(atoum\test $test);
public function testAssertionFail(atoum\test $test);
public function testError(atoum\test $test);
public function testException(atoum\test $test);
public function testAssertionSuccess(atoum\test $test);
public function afterTestMethod(atoum\test $test);
public function beforeTestTearDown(atoum\test $test);
public function afterTestTearDown(atoum\test $test);
public function testRunStop(atoum\test $test);
}
?>
Une fois ces interfaces définies, il suffit ensuite de les implémenter au niveau de la classe chargée de la génération du rapport de test, nommée reporter
.
<?php
namespace mageekguy\atoum;
class reporter implements observers\test
{
public function testRunStart(test $test) { ... return $this; }
public function beforeTestSetup(test $test) { ... return $this; }
public function afterTestSetup(test $test) { ... return $this; }
public function beforeTestMethod(test $test) { ... return $this; }
...}
?>
Il faut ensuite d'implémenter l'interface observable
créée précédemment et de définir une méthodes d'injection des observateurs au niveau de chacune des classes runner
et test
, par exemple de la façon suivante dans le cas de test
:
<?php
namespace mageekguy\atoum;
use mageekguy\atoum;
use mageekguy\atoum\asserter;
abstract class test implements observable
{
protected $observers = array();
...
public function addObserver(atoum\observers\test $observer)
{
$this->observers[] = $observer;
return $this;
}
public function callObservers($method)
{
foreach ($this->observers as $observer)
{
$observer->{$method}($this);
}
return $this;
}
...}
?>
Il n'y a plus ensuite qu'à faire appel à la méthode observable::callObservers()
aux endroits stratégiques dans le code des classes test
et runner
pour pouvoir générer n'importe quel type de rapport, en fonction des observateurs qui sont rattachés aux instances de ces classes à l'aide des méthodes runner::addObserver()
et test::addObserver()
.
Et pour créer ces observateurs, il suffit d'implémenter au niveau d'une classe l'interface correspondante, en fonction du résultat désiré.
De plus, cerise sur le gâteau, grâce à la puissance des interfaces, il est possible de définir une classe dont les instances peuvent simultanément être un observateur d'une instance de runner
et d'une instance de test
, puisqu'une classe peut implémenter plusieurs interfaces.
Dernière précision, si j'avais défini la méthode observable::addObserver(atoum\observer $observer)
au niveau de l'interface observable comme j'avais envisagé de le faire dans un premier temps, je n'aurais pas pu définir le type précis d'observateur que les méthodes test::addObserver()
et runner::addObserver()
devaient accepter.
En effet, PHP refuse catégoriquement que la signature d'une méthode rendue obligatoire par une interface ne soit pas identique dans l'interface et dans les classes implémentant cette dernière.
9 réactions
1 De metagoto - 16/09/2010, 16:51
Pas convaincu du truc
Peut être que je chipote... Voici comment je vois les chose:
Le pattern observer consiste avant tout à découpler les observables des observers dans le sens où du point de vue des observables, les observers ne sont que des entités opaques. Généralement, un observable sait juste qu'un observer implémente une méthode "update()" et c'est tout. Les observers ne sont pas forcément homogènes entre eux (ils n'ont pas à respecter une interface "dictée" par les observables, à part la méthode update).
Les observers, par nature, puisqu'ils observent, savent manipuler les observables s'ils en ont l'utilité. Ces mêmes observers devraient se permettre de ne pas être obligés d'implémenter les 10 méthodes before/afterTest, test* etc.
De même, les observables ne sont pas sensés connaître les méthodes des observers. Dans ce que tu montres, les observables appellent eux-mêmes les méthodes: je pense que c'est problématique dans un cadre du "pattern observer".
Entre appeller une méthode dont le nom est dynamique (ton implémentation) et lancer/passer un event dont l'id serait ce même nom, il n'y a qu'un pas. L'avantage c'est que les observers sont libres de ne rien implémenter du tout, si ce n'est update() et les observables vivent leur vie de leur coté sans se soucier des observers (= refactorer, ajouter des méthodes...)
As tu pensé à SplObserver et SplSubject, des interfaces de la SPL? Ou un truc du genre publish/subscribe à la EventDispatcher de Symfony2 (c'est pas le seul à faire ça, je pense juste à lui)
2 De mageekguy - 16/09/2010, 17:22
@metagoto : D'un strict point de vue théorique, je ne respecte effectivement pas le patron observer/observable.
Ma première implémentation le suivait strictement, mais avoir une unique méthode
observer::update($event)
avec unswitch... case
pour gérer l’événement me paraissait vraiment trop immonde, d'autant qu'en plus mes événements peuvent provenir de plusieurs sources, ce qui oblige à avoir des identifiants uniques entre tout les observables, ou bien à tester le type de l'observable pour ensuite interpréter l’événement.Du coup, j'ai changé mon fusil d'épaule pour arriver à ce résultat qui me satisfait beaucoup plus, conceptuellement parlant.
Il y a effectivement un couplage entre l'observateur et l'observé, mais il est connu et contraint par les interfaces, et si un observateur ne veut pas gérer un événement/méthode, il lui suffit de l'implémenter via une méthode vide.
C'est d'ailleurs ce que fait mon reporter de base, qui est une classe abstraite.
3 De metagoto - 17/09/2010, 15:49
Pour le switch/case, il y aurait moyen de bidouiller, genre passer par __call(). Dans le cadre d'une librairie de tests, qui n'est pas sollicitée à chaque instant, je préfère encore avoir ce genre d'indirection ET savoir que le découplage observers/observables est conforme, et donc pouvoir faire varier un coté comme de l'autre indépendamment (y compris les entités d'un même coté), plutôt que d'avoir ces deux parties trop couplées. Je ne raisonne pas en puriste des design patterns, ce que je ne suis absolument pas, mais par rapport au problème que tu as exposé. Et du coup, je me dis que un publish/subscribe plus généraliste serait probablement plus approprié (les subscribers pourraient être n'importe quels callables, ex. de simple closures, que tu aimes si bien
4 De mageekguy - 17/09/2010, 18:00
@metagoto : Passer par
__call()
ne dispenserait pas de vérifier l'existence de la méthode appelée.Cela veut dire soit un
switch... case
, soit faire appel à la reflection, et j'avoue que je suis moyennement fan.Et j'avoue que je ne vois pas bien ou tu veux en venir avec les callables (enfin, j'ai bien une idée, mais la mise en œuvre serait trop complexe à mon goût).
Si tu veux bien prendre la peine de détailler, je suis preneur.
5 De Armand - 17/09/2010, 19:12
Je suis du même avis que metagoto.
Tu ne peux pas utiliser method_exists() ou is_callable() pour tester l'existence de la méthode appelée sinon ?
6 De metagoto - 18/09/2010, 03:12
Pour __call(): juste faire en sorte que l'observer appelle lui même sa bonne méthode avec une heuristique propre à lui (dans le plus simple des cas, un $this->$method()). call est juste un fallback pour les méthodes (ou events en fait) qui ne seraient pas pris en compte. Ce mécanisme de dispatching se passe du coté observer. De nouveaux events/états interessants des observables peuvent être ajoutés sans pénaliser les observers existants (ou ceux qui ne monitorent qu'une poignée d'états).
Enregistrer des callables plutôt que des objets "observers" auprès des observables serait juste un moyen de généraliser les choses, si le besoin s'en fait ressentir bien entendu. Ca serait un bon moyen d'étendre les comportements en fonction du déroulement des tests, tout en étant très permissif quant à la nature des observers (ou plugins finalement).
Techniquement, ça reviendrait à passer par call_user_func(), ou ses variantes, dans ta boucle de la méthode callObservers()
7 De Ivan Enderlin - 19/09/2010, 10:01
Hey :-),
En fait, ton implémentation actuelle ressemble plus à un implémentation de plugins que d'un système d'événements.
Le mieux serait d'avoir une seule méthode à appeler, mais ça pose des problèmes. Si tu as un système de flux unifié dans Atoum, tu peux laisser ton système d'événement gérer ça tout seul (il saurait quelle méthode appeler). Sinon, lors de l'enregistrement d'un observeur auprès d'un observable, il préciserait la méthode à appeler. Avantages : un possible callback, un flux, une closure etc.
Je prends comme exemple Hoa et son système d'évenement : http://j.mp/b47fb3. L'implémentation peut paraître un brin compliquée au premier abord mais c'est loin d'être le cas. Les observables implémentent l'interface Hoa_Core_Event_Source si jamais (elle n'impose aucune méthode, mais on se préserve du futur et d'évolution du système). Chaque observable donc déclare un événement avec un identifiant :
Voilà, notre événement existe.
Les observeurs ont juste à dire qu'ils attachent quelque chose à cet événement (même s'il n'a pas été créé, c'est asynchrone) :
Notons que la fonction event() est un alias de Hoa_Core_Event::getEvent().
Dans le lien que j'ai donné, on voit que dans le cas des flux, on sélectionne la méthode appropriée en fonction du type de données à écrire. Si c'est un entier, on appelera writeInteger, si c'est un tableau, on appelera writeArray etc. Bien sûr, il faut le système de flux unifié associé, mais c'est facilement adaptable à d'autres choses.
Les avantages de cette solution sont multiples : tu peux attacher des « services »/observeurs à des événements avant même qu'ils soient créés dès le moment où tu connais l'ID. Tu peux attacher tout ce que tu veux, et c'est bien pratique pour les flux. Tu n'as qu'une seule interface : Hoa_Event_Source, qui pour l'instant est « inutile ». Tu n'as aucune méthode particulière à appeler, tout est dynamique et décidé par l'utilisateur.
Dans ton cas, tu as des before/after tout le temps. Tu peux donner les identifiants suivant : @atoum/test/run:start, @atoum/test/setup:before, @atoum/test/setup:after etc. On comprend bien cette notation, elle constitue des identifiants uniques etc. Pas besoin d'interface avec des dizaines de méthodes.
L'utilisateur peut ne capturer qu'un seul événement s'il le souhaite ! C'est une grande différence : pas besoin d'écrire des méthodes vides.
Voilà mes idées :-).
8 De Florian - 21/09/2010, 10:50
Ne croyez vous pas que le plus simple serait d'avoir:
De cette maniere, la seule chose que doivent connaitre tes observables est le EventDispatcher. ( et hop, découplage idéal).
Ce design pattern, qui s'appelle Observer, ( et est implémenté dans Cocoa je crois) a été porté en PHP en tant que SF Component: http://components.symfony-project.o...
Il est 100% indépendant de Symfony.
9 De mageekguy - 21/09/2010, 11:33
@Florian : On peut se tutoyer sans problème, et c'est déjà ce qui a été proposé les commentaires précédents.
Et pour te répondre précisément, j'y réfléchie, car dans mon cas, les deux implémentations ont leurs avantages et leurs inconvénients.
Pour l'instant, le dispatcher a l'inconvénient de mettre en jeu une n-ieme classe, qui plus est indépendante sémantiquement (j'insiste sur ce mot) des autres.
Son intégration me pose donc des problèmes par rapport à l'API d'Atoum, au sens sémantique du terme (j'insiste encore).
Il faudra un jour que je fasse un billet sur cette histoire de sémantique dans les API, mais comme cela a trait à l'esthétique, la beauté, l'organisation, etc, ce n'est pas évident à formaliser...