1. Rappel sur les méthodes magiques
Pour ceux d'entre vous qui auraient manqué l'épisode précédent (GNU/Linux Magazine n°151), les méthodes magiques de PHP sont une fonctionnalité avancée de ce langage, permettant de modifier profondément le comportement attendu des objets de la classe à laquelle elle s'applique. La difficulté que posent de telles méthodes est qu'elles se font facilement oublier par le développeur, qui pourrait avoir de grandes difficultés à comprendre l'origine de certains bogues, surtout dans le contexte d'un travail d'équipe ou de longs enchaînements d'héritage dans les classes. La dernière fois, nous avions utilisé la méthode __get pour permettre à un objet de déterminer lui-même ses propriétés, une fois pour toutes. Dans notre exemple, il en avait résulté un code bien plus court et plus lisible, ainsi qu'un gain de performance notable.
Cette fois-ci, notre travail sera plus élaboré, puisqu'il s'agira d'implémenter un cache automatique sur les méthodes d'une classe. Un premier intérêt est évident : éviter la ré-exécution inutile d'une méthode appelée avec les mêmes paramètres sans avoir à gérer le cache à l'intérieur même de la fonction.
Il y a toutefois un autre intérêt, en programmation PHP avancée dans le cadre d'un projet MVC (Modèle/Vue/Contrôleur). Dans ce contexte, il est fréquent de définir une méthode statique sur la classe parente des modèles, qui crée une instance de cette classe à partir de paramètres restreints, typiquement, un identifiant permettant d'instancier un objet PHP depuis un enregistrement en base de données. Une telle méthode doit veiller à ne pas créer deux objets identiques qui pourraient ensuite évoluer indépendamment l'un de l'autre : lors de l'enregistrement de cet objet, une part des modifications serait perdue... Cette méthode doit donc renvoyer le même objet et ne pas le récréer. Ce point est simplifié par le fait que PHP passe les objets par référence et non par valeur, un simple cache sur la méthode permet donc de résoudre le problème. Un cache sur la classe parente de tous les modèles serait donc une solution définitive.
En plus des méthodes magiques, nous allons devoir faire appel aux « fonctions sur la gestion des fonctions » (http://php.net/manual/fr/ref.funchand.php). Celles-ci peuvent se montrer très utiles dans le cadre d'une programmation objet avancée, elles sont cependant assez retorses à utiliser.
Pour en savoir plus sur les méthodes magiques en général, je vous renvoie à la page du manuel officiel de PHP qui leur est consacrée (http://php.net/manual/fr/language.oop5.magic.php). Sachez seulement que ces méthodes ne sont pas appelées « magiques » par hasard : autant leur utilisation peut améliorer grandement votre code à de nombreux points de vue, autant les bogues qu'elles pourraient provoquer sont difficiles à déceler... N'est pas Gandalf qui veut !
2. Présentations des méthodes magiques à utiliser
Nous allons travailler avec deux méthodes magiques ici : __call et __callStatic. Ces fonctions sont appelées lorsque PHP détecte un appel à une méthode inaccessible (inexistante, protégée et appelée hors de l'objet ou encore privée et appelée hors de la classe qui la définit).
La première, __call() est appelée dynamiquement, c'est-à-dire depuis un objet existant :
$monobjet->maMethodeInaccessibleDynamique();
La seconde, __callStatic() est appelée de façon statique, c'est-à-dire comme méthode de la classe :
maclasse::maMethodeInaccessibleStatique();
Remarquez que si vous avez la mauvaise habitude d'appeler de manière dynamique des méthodes statiques, vous devrez implémenter deux fois le même traitement (ou le mutualiser dans une méthode que vous appellerez les deux fois) pour couvrir ces deux types d'appel. Sachez tout de même que PHP est considérablement plus performant avec des méthodes statiques que des méthodes dynamiques. En fait, vous ne devriez recourir aux appels dynamiques que si vous avez besoin de $this, et si tel est le cas, vous ne pourrez appeler ces méthodes de manière statique... Tout cela est cohérent.
3. Convention de nommage
Comme dans notre précédent article, nous ne voulons pas rendre accessibles toutes les méthodes inaccessibles de l'objet, mais nous voulons seulement rendre inaccessibles certaines méthodes pour permettre à l'objet d'utiliser son cache avant d'appeler la méthode si nécessaire. Il faut donc que l'objet ait un moyen de déterminer s'il doit ou non appliquer ce traitement lors d'un appel de méthode inaccessible. Pour y parvenir, nous allons utiliser une convention de nommage : nous allons préfixer toutes les méthodes auxquelles le cache s'appliquera par _C_. Ainsi, il nous sera possible d'activer ou de désactiver le cache sur une méthode simplement en ajoutant ou en supprimant ce préfixe.
4. Implémentation dynamique
4.1. Le code
classwithCachedMethods{
var $methodsCache=array();
static $staticMethodsCache=array();
function __call($method,$args) {
$key = md5(serialize($args));
// traitement de cas particuliers
if (substr($method,0,3)=='_C_') {
if (method_exists($this,$method)) {
// la méthode cachée a été appelée explicitement: on rafraichit le cache
return $this->methodsCache[$method][$key]=call_user_func_array(array(get_called_class(),$method),$args);
}else{
// le cache a été désactivé pour cette fonction, mais le code demande un rafraichissement du cache, on rappelle simplement la fonction
$method = substr($method,3);
return call_user_func_array(array(get_called_class(),$method),$args);
}
}
// traitement normal
$cachedMethod = '_C_'.$method;
if (method_exists($this,$cachedMethod)) {
if (!is_array($this->methodsCache[$cachedMethod]) || !array_key_exists($key,$this->methodsCache[$cachedMethod])) {
$this->methodsCache[$cachedMethod][$key]=call_user_func_array(array(get_called_class(),$cachedMethod),$args);
}
return $this->methodsCache[$cachedMethod][$key];
Voici donc notre nouvelle classe que nous avons appelée withCachedMethods. Nous n'avons pour le moment implémenté qu'une seule méthode, la fonction __call, qui prend en charge l'interception de méthodes inaccessibles. Par ailleurs, une propriété est définie : $methodsCache, qui contient le cache des méthodes sous forme d'un tableau.
Détaillons le fonctionnement de notre méthode magique __call. Remarquons, en guise de préambule, que le code qui la compose se structure en deux parties. La première est consacrée au traitement de cas particuliers sur lesquels nous reviendrons plus loin. La seconde est le traitement normal, celui qui permet le fonctionnement de notre cache de la façon la plus ordinaire.
__call() reçoit deux arguments : le premier est le nom de la méthode inaccessible, le second un tableau contenant les arguments envoyés à cette méthode.
Du nom de la méthode, nous déduisons celui de la méthode cible que nous plaçons dans la variable $cachedMethod. Si un appel est fait à ousontmeslunettes() et que ousontmeslunettes() n'existe pas ou n'est pas accessible, nous allons travailler avec_C_ousontmeslunettes().
Nous utilisons la fonction method_exists pour savoir si elle existe effectivement. Si tel est le cas, cela signifie que le traitement du cache doit être effectué. Il peut être de deux types : soit on trouve la valeur recherchée dans le cache, soit on exécute la méthode et on place le résultat dans le cache.
Notre cache, nous l'avons dit, est un tableau. Pour y stocker les données, la première clé que nous utilisons est le nom de la méthode cachée, _C_ousontmeslunettes, dans notre exemple. Le deuxième élément qui nous permet de déterminer si le résultat recherché se trouve déjà dans le cache est la liste des arguments. Ceux-ci nous sont connus sous la forme d'un tableau. Toutefois, PHP ne nous permettant pas d'utiliser un tableau comme index d'un autre tableau (ce qui ne serait d'ailleurs pas judicieux), nous ne pouvons les utiliser en l'état. PHP nous fournit heureusement une fonction qui permet de convertir un tableau en chaîne de caractères sans perte de données (une autre fonction existe permettant le traitement inverse si nécessaire). Il s'agit de serialize(). Mais cette transformation, bien que fonctionnant, n'est sans doute pas optimale : il est fréquent d'appeler une fonction avec des arguments très complexes et très lourds, des objets contenant des tableaux de valeurs, d'autres objets... Bref, une telle liste d'arguments peut se révéler très longue et il n'est pas utile de conserver toutes ces informations. Nous allons utiliser le hash md5() des arguments sérialisés comme deuxième clef de notre cache :
$key=md5(serialize($args));
Ensuite, nous déterminons s'il existe un cache pour notre méthode et si oui, si ce cache contient la clef correspondant à nos arguments. Il est important de vérifier si le cache existe bel et bien (is_array()) avant d'y chercher la clef car array_key_exists() peut, selon la configuration et la version de PHP, lever des erreurs.
Si une telle clef n'existe pas, il faut créer cette clef et déterminer la valeur qui lui correspond. Ici, il va nous falloir faire appel à la fonction fournie par PHP call_user_func_array. Celle-ci permet d'appeler une autre fonction en lui passant un tableau d'arguments de longueur arbitraire, comme si cette fonction cible avait été appelée normalement. Par exemple :
functionY($a,$b,$c){
//traitement
}
Peut être appelée ainsi :
call_user_func_array('Y',array('argA,'argB','argC'));
Le premier argument de call_user_func_array() permet d'appeler une méthode plutôt qu'une fonction lorsqu'il contient un tableau dont le premier élément est le nom de la classe et le second le nom de la méthode. Et c'est ce que nous faisons ici. Il est à noter que cet appel est effectué de manière dynamique.
4.2. Le test
Pour tester notre cache, nous allons ajouter à notre objet une méthode simple, qui aura pour seule tâche d'effectuer une addition. Pour savoir si le résultat que nous obtenons est issu du cache ou non, nous allons ajouter une ligne à notre méthode qui affichera un petit texte indiquant que non. Voici donc la méthode que nous ajoutons :
function _C_plus($a,$b){
print "No cache!\n";
return $a+$b;
Maintenant, écrivons encore quelques lignes en dehors de la classe pour la tester :
$test = newwithCachedMethods();
print $test->plus(1,2)."\n";
print $test->plus(3,4)."\n";
Le résultat sera :
No cache!
3
No cache!
7
3
Nous constatons que le premier appel à plus() avec comme arguments 1 et 2, puis 3 et 4, nous fournit un résultat déterminé par la méthode _C_plus, mais que le deuxième appel à plus() avec comme arguments 1 et 2 nous fournit un résultat issu du cache.
4.3. Traitement des cas particuliers
Nous l'avons indiqué plus haut, notre méthode __call supporte le traitement de certains cas particuliers. Quels sont-ils ? Il y en a deux. Dans les deux cas, le code appelant la méthode y accède explicitement en utilisant le préfixe de cache. Cela signifie que le programmeur qui a écrit cet appel sait qu'il s'agit d'une méthode cachée : c'est exactement ce qu'il ferait s'il voulait contourner le cache. Cet appel étant tout de même intercepté par __call(), cela implique que même préfixée, la méthode appelée reste inaccessible, car elle a été déclarée protected et appelée extérieurement à l'objet. Pour autant, nous n'allons pas laisser tomber notre développeur : s'il appelle explicitement la méthode de cette façon, c'est que le résultat présent dans le cache ne le satisfait pas. Peut-être a-t-il détecté une erreur, ou bien sait-il que la donnée est périmée suite à une action de sa part ? Peu importe : nous pouvons interpréter cet appel comme une demande de correction du cache. Nous allons donc rappeler la fonction et rafraîchir la valeur stockée dans le cache.
Toutefois, dans ce cas particulier, se dissimule un autre cas particulier : nous avons défini que nous pouvions désactiver le cache d'une méthode simplement en supprimant son préfixe (et sa protection). Si nous voulons pouvoir continuer ainsi, il faut également que notre interception soit capable de traiter le cas où, d'une part, le cache aurait été désactivé pour la méthode et, d'autre part, une demande de rafraîchissement de cache serait fait pour cette méthode. Dans ce cas-là, il nous faut alors appeler la méthode non-préfixée et renvoyer directement ce résultat.
Dès lors, le code suivant :
print $test->plus(1,2)."\n";
print $test->plus(1,2)."\n";
print $test->_C_plus(1,2)."\n";
produira :
No cache!
3
3
No cache!
3
Voilà, nous en avons terminé avec la partie dynamique. Nous pouvons donc maintenant passer à l'...
5. Implémentation statique
5.1. Avertissement
La version 5 de PHP a beaucoup évolué dans ses différentes sous-versions en élargissant les possibilités offertes au programmeur pour utiliser les classes d'objets de manière statique. En ce qui concerne les techniques de programmation développées dans la suite de cet article, il est impératif de disposer d'une version de PHP au moins égale à 5.3, sans quoi, cela ne fonctionnera pas.
5.2. Le code
Ajoutons maintenant le code suivant à notre classe withCachedMethods :
static $staticMethodsCache = array();
static function __callStatic($method,$args) {
$key = md5(serialize($args));
$calledClass = get_called_class();
// traitement des cas particuliers
if (substr($method,0,3)=='_C_') {
if (method_exists($calledClass,$method)) {
// la méthode cachée a été appelée explicitement: on rafraichit le cache
return static::$staticMethodsCache[$calledClass][$method][$key]=forward_static_call_array(array($calledClass,$method),$args);
}else{
// le cache a été désactivé pour cette fonction, mais le code demande un rafraichissement du cache, on rappelle simplement la fonction
$method = substr($method,3);
return forward_static_call_array(array($calledClass,$method),$args);
}
}
// traitement normal
$cachedMethod = '_C_'.$method;
if (method_exists($calledClass,$cachedMethod)) {
if (!is_array(static::$staticMethodsCache[$calledClass][$cachedMethod]) || !array_key_exists($key,static::$staticMethodsCache[$calledClass][$cachedMethod])) {
static::$staticMethodsCache[$calledClass][$cachedMethod][$key]=forward_static_call_array(array($calledClass,$cachedMethod),$args);
}
return static::$staticMethodsCache[$calledClass][$cachedMethod][$key];
L'algorithme fondamental étant le même que pour l'implémentation dynamique, il n'y a rien de surprenant à ce que les deux méthodes se ressemblent autant. Les différences tiennent au fonctionnement statique.
Notons que nous commençons par définir une propriété de la classe $staticMethodsCache, qui contiendra le cache statique. Une telle variable est habituellement appelée en utilisant la syntaxe nom_de_la_classe::$nom_de_la_propriété. PHP permet également d'appeler ces propriétés de manière réflexive et de deux façons différentes. A l'intérieur d'un objet, un appel réflexif est fait en utilisant la variable $this. A l'intérieur d'une classe, on peut utiliser soit self, soit static. Il y a une grande différence entre les deux, qui tient à l'héritage : self va faire une réflexion sur la classe où il est écrit, alors que static va faire une réflexion sur la classe dans laquelle la méthode est exécutée.
Un peu confus ? Prenons un exemple :
class Vador {
static $force;
static function deQuelCote() {
return "Obscur";
}
static function faisTonChoix() {
return self::deQuelCote();
}
static function quelEstTonDestin() {
return static::deQuelCote();
}
}
class Luke extends Vador {
static function deQuelCote() {
return "Lumière";
print Luke::faisTonChoix()."\n";
print Luke::quelEstTonDestin()."\n";
Un tel code affichera :
Obscur
Lumière
Qu'avons-nous fait ? Nous avons défini une classe Vador avec trois méthodes statiques : dequelCote() qui retourne toujours "Obscur", puis deux autres qui appellent cette première, mais l'une en utilisant self et l'autre en utilisant static. Sur la classe Vador, ces méthodes fourniraient toujours le même résultat. Mais dès lors que nous les utilisons sur la classe Luke, qui en hérite mais qui redéfinit la méthode deQuelCote, il n'en est plus de même et la différence entre static et self devient flagrante.
Dans notre contexte, nous ne connaîtrons pas les dilemmes de Luke, nous voulons que nos classes aient chacune un cache bien indépendant, aussi utilisons-nous systématiquement static dans nos méthodes qui gèrent le cache.
forward_static_call_array() effectue le même travail que call_user_func_array(), mais de manière statique.
Attention ! Il y a une différence fondamentale entre le fonctionnement statique et le fonctionnement dynamique, qui si elle n'est pas prise en compte au moment d'une implémentation statique, pourrait être à l'origine de bogues très retords : les variables statiques sont héritées par référence. Cela signifie que ce code :
class maman {
static $test;
}
class fille extends maman {}
maman::$test = 'ko';
fille::$test = 'ok';
print maman::$test."\n";
print fille::$test."\n";
n'affichera pas :
ko
ok
comme on pourrait s'y attendre, mais :
ok
ok
La solution, si c'est un problème, consiste à redéfinir la variable statique dans la classe fille. Ainsi, avec le code suivant :
class fille extends maman {
static $test;
}
Les variables statiques $test de la fille et de la mère seront bien différentes.
Dans notre code, nous ne voulons pas que les caches de l'une ou l'autre classe se mélangent, simplement parce que ces classes auraient des méthodes synonymes. Même héritées, elles pourraient fournir des résultats différents, leurs contextes statiques pouvant être différents. Pour pallier cela, nous avons ajouté une dimension à notre tableau, le premier index étant toujours le nom de la classe appelée, tel que donné par get_call_class(). Cette précaution est suffisante et permet d'éviter de redéfinir le cache dans toutes les classes qui utiliseront cette méthode de cache.
6. Pour finir
Nous avons vu lors de ces deux articles comment implémenter des caches automatiques et puissants sur des classes d'objets PHP en utilisant certaines des fonctions les plus avancées de PHP, les méthodes magiques et les fonctions de manipulation de fonctions. Tout cela est bien joli, mais dans un contexte où les développeurs sont amenés de plus en plus souvent à étendre des classes fournies par des frameworks et PHP ne permettant pas l'héritage multiple, cela peut rester lettre morte dans les cas les plus intéressants. Heureusement est arrivé... PHP 5.4, qui propose les « traits » pour répondre à cette difficulté. Nous aborderons ce concept dans un prochain article.