Mot-clef « Surcharge »

Historique des méthodes de surcharge de code PHP dans RBS Change

Le but de cet article est de revenir sur les différentes méthodes de surcharge du code PHP qu'on a eu (et que pour beaucoup on a encore) dans RBS Change depuis que je travaille dessus (au départ en faisant des sites avec puis par la suite en tant que développeur du produit).

Je n'entrerai pas dans les détails concrets d'utilisation mais ça me semble intéressant de voir un peu l'évolution des concepts, les différentes tentatives qu'il y a eu et les raisons qui ont pu orienter certains choix.

En fin d'article je présente ce qui sera fait dans la version 4.0 dont la sortie n'est prévue que courant 2013. En conséquence, ce que j'évoque pourrait potentiellement encore évoluer d'ici là et ne reflète que ce qui a été mis en œuvre pour l'instant, pas une vérité gravée dans le marbre.

Écrasement des fichiers dans la webapp

J'ai commencé à travailler avec Change sur la version 1.2 en 2006. À cette époque le seul moyen offert pour surcharger du code du produit était d'écraser certains fichiers (PHP ou autres, cela fonctionnait pour tous les fichiers des modules et même du framework : PHP, XML, templates, styles, etc) dans le dossier "webapp". La mécanique était (trop) simple : à chaque chargement de fichier PHP, l'application commençait par le chercher dans le dossier webapp. S'il était présent cette version était chargée, sinon celle du code standard l'était. Le fichier de surcharge était donc contraint de reprendre l'intégralité du code qu'il surchargeait, même au cas où il réécrivait seulement une méthode d'une classe en comptant cinquante.

C'était toujours un peu mieux que de modifier directement le code du produit mais à peine puisque lors d'une mise à jour, il fallait passer sur chaque fichier surchargé pour ré-appliquer les modification.

Dès la version 2, l'année suivante, la possibilité de surcharge de code PHP directement dans le dossier webapp a été supprimée car elle posait beaucoup trop de problèmes. En remplacement, une autre méthode plus cadrée a été mise en place : "l'injection" de service et peu après, de document.

Injection de service et de document

L'injection de service (toujours d'actualité dans la version 3.6) fonctionne sur un principe qui reste très simple mais qui est nettement plus cadré. Dans la configuration projet déclare des injections consistant à dire "le servie A est à remplacer par le service B". Tous les service sont instanciés via une méthode getInstance() (selon le patron de conception singleton) qui inclut une mécanique qui va chercher dans la configuration projet si une injection a été déclarée sur ce service. S'il y en a une, on retourne une instance de la classe remplaçante au lieu de la classe appelée.

Le code travaille donc simplement avec une autre classe que celle qu'il a demandé. Pour que les choses marchent bien, il faut évidemment que la classe remplaçante étende la classe remplacée.

Peu après le même principe a été appliqué aux documents : en déclarant une injection de document on remplace son modèle, sa classe finale et son service là encore en renvoyant des instances de classes étendant celles que le code demande en réalité.

Globalement ça marche assez bien mais avec deux limitations :

  1. Dans le cas où on injecte une classe déjà étendue par ailleurs, les classes étendues ne profiteront pas de l'injection. Par exemple, si une classe C étend A et que j'injecte A par une classe B. Si je demande une instance de A, j'obtiendrai une instance de B mais si je demande une instance de C j'obtiendrai une instance de C étendant A et non B.

    Cette limitation peut rapidement s'avérer gênante quand on développe des modules complexes avec des documents de base étendus par d'autres (par exemple les différents types de produits dans le module Catalogue et boutiques).

  2. Une même classe ne peut être injectée qu'une seule fois. Cela peut devenir problématique si plusieurs modules veulent se greffer sur un module existant et ont besoin de remplacer du code : il faudra alors écrire une classe en spécifique dans le projet qui cumules les différentes modifications ce qui n'est pas très bon en terme de maintenabilité (on se rapproche à nouveau un peu de l'écrasement décrit plus haut même si ça reste plus restreint).

Points d'entrée spécifiques (stratégies, section "mvc" du project.xml, etc.)

Parallèlement à l'injection, d'autres mécaniques plus ciblées ont été mises en place dans certains modules pour permettre de remplacer du code standard par des implémentations spécifiques.

Les modules liés à l'e-commerce se sont ainsi enrichis d'un certain nombre de "stratégies" (du patron de conception strategy). Ces points d'entrée explicites permettent de choisir entre plusieurs implémentations pour un traitement donné. Par exemple on proposait une stratégie sur l'arrondi des prix avec une implémentation standard qui se contentait d'arrondir à deux chiffres après la virgule, là où certains projets avaient d'autres règles.

On trouve encore ce genre de chose en version 3.6, en particulier sur les points le choix n'est pas unique au sein d'un projet donné (par exemple le calcul du montant d'un frais : deux frais peuvent s'appliquer avec des stratégies de calcul différentes).

On en trouve encore quelques autres dont l'implémentation est choisie une fois pour toutes dans la configuration projet mais elles tendent à disparaitre (cette mécanique spécifiquement mise en place à chaque fois étant alors redondante avec les mécaniques génériques d'injection).

D'autres points d'entrée spécifiques ont été mis en place au fur et à mesure, notamment pour les différentes classes entrant en jeu dans le modèle MVC (notamment les différents contrôleurs) qui peuvent être remplacée via une section dédiée dans la configuration projet.

Dans tous les cas ces points d'entrée ont été mis en place spécifiquement dans certaines parties du code. Les mettre en place implique donc de prévoir volontairement que telle ou telle partie doit pouvoir être remplacé au contraire de l'injection qui fonctionne de manière plus globale.

Réécriture de classe et AOP

Dans la version 3.0 de Change, un nouveau mécanisme a été implémenté, tiré de certains concepts de la programmation orientée aspect (AOP).

Cette mécanique propose plusieurs choses reposant sur la réécriture des classes lors d'une phase de compilation.

Premièrement le remplacement d'une classe par une autre. Il s'agit là non pas de simplement retourner une instance d'une autre classe (comme c'était le cas avec l'injection décrite plus haut) mais bien de réécrire la classe pour y intégrer les modifications. Pour ce faire, on renomme la classe d'origine (avec un suffixe quelconque) puis on renomme la remplaçante avec le nom de l'originale. Ainsi le remplacement est totalement transparent pour le code qui l'entoure : il travaille bien physiquement avec une instance de la classe qu'il a demandé, à ceci près que cette classe a été modifiée.

Par exemple on part d'une classe A que l'on veut remplacer par une classe B (qui doit étendre A). On commence par renommer A en A_replaced0. Puis on renomme B en A et on la fait étendre A_replaced0. On obtient alors une nouvelle classe A qui a toutes les méthodes et propriétés de la classe B, y compris celles de la classe A d'origine.

Cette méthode fait sauter la limitation de l'injection décrite plus haut sur les classe étendues puisque du coup une classe C qui étendrait notre classe A d'origine héritera du même coup du code introduit par B. Dans son implémentation proposé dans la version 3.0 (qui reste la même en 3.6), elle ne fait par contre pas sauter l'autre limitation liée aux sources multiples.

Depuis la version 3.0, c'est sur cette mécanique que repose l'injection de documents et plus sur la mécanique décrite auparavant (ainsi une injection du document catalog/product se répercutera sur tous les documents qui l'étendent : catalog/simpleproduct, catalog/productdeclination, etc.).

Au delà de cette simple mécanique de réécriture (qui n'est pas de l'AOP à proprement parler mais juste un préalable), d'autres possibilités ont été mises en place permettant d'ajouter du code avant ou après l'exécution d'une méthode, d'ajouter une méthode à une classe ou d'en remplacer une (il s'agit là effectivement des concept de l'AOP). Là aussi on fait de la réécriture de classes mais avec l'avantage de pouvoir faire venir des modifications de plusieurs sources.

L'inconvénient est que dans les fait c'est un peu compliqué à comprendre et expliquer (le développeur web n'étant pas forcément familier avec ça) et pas forcément très lisible ni intuitif à écrire. Du coup finalement ça a été assez peu utilisé dans la pratique à part pour contourner la limitation sur les sources multiples ou bien pour agir sur des méthodes privées d'une classe (ce que permet dans une certaine mesure l’implémentation actuelle mais relève à mon sens un peu de l'hérésie... même si dans certains cas c'est tentant, réécrire des méthodes privées c'est réécrire du code qui n'est pas censé être accessible par l'extérieur et c'est très mauvais lors des mises à jour).

Injection de blocs

La dernière mécanique en date à avoir été mise en place est l'injection de blocs qui arrive avec la version 3.5.

Lorsqu'on insère un bloc dans une page, on ne référence pas directement un nom de classe mais un "type" (celui qui est indiqué dans le fichier blocks.xml du module) qui en pratique découle du nom de la classe. Mais a grosse différence avec les service c'est que le bloc n'est pas utilisé en tant que tel par du code des modules. Rien n'oblige donc au final à ce que le bloc finalement rendu soit réellement le bloc identifié par le type.

Lorsqu'on utilise un service on va faire appel à ses méthodes pour effectuer des traitements si une méthode manque ou n'a pas la bonne signature la plupart du temps ça finit avec une belle page dont le contenu commence par "Fatal error:". Lorsqu'on utilise un bloc, on va simplement lui demander de se rendre mais son implémentation importe peu en terme de réussite ou d'échec de l’exécution.

L'injection de bloc peut donc être nettement plus libre que l'injection de service ou le remplacement de classe. En effet, elle se contente de changer le mapping entre le "type" du bloc et la classe PHP effectivement utilisée sans obligation que la nouvelle classe étende la classe d'origine (du moment que ça reste un bloc). Si on reste sur un fonctionnement proche, on étendra le bloc d'origine mais si ce n'est pas le cas on peut aussi repartir d'une feuille blanche et le remplacer par une nouveau bloc totalement différent (voire étendant un autre bloc existant qui se rapproche plus de ce qu'on veut faire).

À venir en 4.0 : unification et simplification

Comme on a pu le voir, les méthodes de remplacement de code disponibles sont très nombreuses avec chacune leur limitations. De tout ce que j'ai pu évoquer, trois sortent du lot car elles apportent vraiment un plus en terme de possibilités (hors des limitation liées à l'implémentation). En effet toutes finalement visent à remplacer des classes par des classes qui les étendent une fois pour toutes globalement pour le projet à l'exception de :

  • l'injection de document est un peu à part puisqu'elle implique plusieurs classes (modèle, classe finale du document, service) mais au final reste du remplacement de classe
  • les stratégies dont le choix ne relève pas de la configuration projet mais se fait au cours de l'exécution selon le contexte (exemples : calcul de frais, calcul de réductions, etc)
  • l'injection de blocs qui n'impose pas de reprendre le code du bloc existant mais peut repartir de zéro dans une autre direction si le besoin s'en fait sentir

Toutes les autres (injection de service, remplacement de classe, AOP, stratégies définies dans la configuration projet, remplacement de classes spécifiques) répondent bien au même besoin : introduire du code spécifique dans une classe existante en ciblant au mieux la partie réécrite pour avoir le moins de problème possible lors des mises à jour. En version 4.0 ces différentes mécaniques parallèles seront donc supprimées au profit d'une seule et unique mécanique reprenant le meilleur de chacune.

Elle s'appellera "injection de classe" et pourra d'une manière générale être appliquée à n'importe quelle classe (à part une poignée qui sont chargées trop tôt dans l'exécution pour pouvoir en profiter). Elle reposera sur le concept de réécriture de classe (comme la mécanique de remplacement de classe) et sera déclarée dans la section injection sur le modèle de l'injection de service actuel.

Reste à couvrir un point que permettait l'AOP : injecter une classe avec du code provenant de plusieurs sources. Conceptuellement, rien ne l'empêche. C'est uniquement l'implémentation mise en œuvre dans les versions précédentes qui ne le permettait pas.

En version 4.0, on pourra donc déclarer des "injections chainées" en gros au lieu d'indiquer une seule classe de remplacement, on pourra en indiquer plusieurs séparées par des virgules. La mécanique de remplacement sera appliquée successivement dans l'ordre indiqué.

Par exemple, si une classe A est déclarée injectée par trois classes B, C et D (qui doivent toujours toutes étendre A), on obtiendra la chaine d'extension suivante : A_injected0 (anciennement A), A_injected1 (anciennement B) qui étend A_injected0, A_injected2 (anciennement C) qui étend A_injected1 et enfin A (anciennement D) qui étend A_injected2. De cette manière plusieurs modules peuvent proposer leur injections spécifiques sans exclure les autres.

Ceci évitera au développeur de se retrouver à devoir choisir entre une multitude de mécaniques proches avec chacune ses limitations pour n'utiliser toujours qu'une seule et même mécanique simple à comprendre et à mettre en œuvre.

Conclusion

Voilà, j'ai fini mon petit tour de l'historique des méthodes de surcharge. Je me suis concentré sur ce qui est lié directement aux classes PHP, omettant volontairement tout ce qui a trait aux templates, styles et fichiers de configuration des modules (déjà comme ça ça donne un sacré pavé).

Comme je le disais en introduction, je ne suis volontairement pas entré dans les détails de mises en œuvre, pour cela référez-vous à la documentation de Change ou posez vos questions sur le forum.

Comme je l'ai dit également, la dernière partie concernant la version 4.0 ne reflète que l'état actuel des développement et pourrait éventuellement évoluer encore un peu d'ici la sortie. Mais globalement les grandes lignes devraient rester valables. On notera du coup qu'il n'est pas conseillé d'abuser de l'AOP dans un projet actuel car cela nécessitera beaucoup de boulot de migration vers la version 4.0 au contraire d'un remplacement de classe qui reste le plus proche de ce qu'on aura au final (c'est d'ailleurs pour cela que l'AOP n'a pas été documentée dans le wiki).

Voilà, félicitations à ceux qui liront mon pavé jusqu'au bout... à supposer qu'il y en ait :euh: