PDO : injection par réflexion et méthodes magiques
Avertissement
Cet article est une synthèse de mes recherches concernant un comportement non-documenté de PDO. Étant peu familiarisé au code source de PHP, il comporte peut-être des erreurs et des approximations. Si tel est le cas, je serais heureux d’en discuter avec vous sur GitHub !
Introduction
Une société avec laquelle je collabore régulièrement m’a demandé en début d’année de former l’un de ses apprentis. Tout juste débarqué dans l’entreprise, on lui avait alors confié la tâche de développer from scratch une application de prospection visant à simplifier la vie des commerciaux.
N’écoutant que son courage, il se jeta tête baissée sur son IDE et aligna, des semaines durant, des kilomètres de code ; jusqu’au jour où, sans doute happé par la lumière du jour, il leva les yeux et fit face au monstre qu’il venait de créer.
Nous nous mîmes donc, deux demi-journées par quinzaine, à travailler en pair programming, alternant cours théoriques (architecture, sécurité, qualité, …), exemples pratiques et découverte des outils (git, composer, …).
Quand vint enfin le jour où, débarrassé de tous ces vilains mots de passe hardcodés qui me brûlaient les yeux, le code put être passé sous contrôle de version afin d’entamer sereinement le refactoring (tout reprendre de zéro eût été plus rapide mais beaucoup moins formateur).
Le plan d’action était simple : transformer les bibliothèques de fonctions qu’étaient les classes en code acceptable, puis en code propre et maintenable, dans le respect des paradigmes de la programmation orientée objet à laquelle le jeune développeur venait tout juste d’être initié ; sans intermédiaire magique, quitte à réécrire le code avec une couche d’abstraction après m’être assuré que les concepts sous-jacents avaient été compris et maîtrisés.
En corrigeant quelques bogues, nous nous aperçûmes très vite d’un problème d’inconsistance dans la modélisation des données : une même donnée était parfois représentée sous forme de tableau, d’autres fois sous forme d’objet et d’autres fois encore sous forme d’objet générique (stdClass
).
Nous décidâmes alors, d’un commun accord, de prendre le taureau par les cornes et de mettre en place un certain nombre d’entités. Ce qui d’ailleurs tombait bien, puisqu’en fouillant le manuel, nous venions de découvrir la constante PDO::FETCH_CLASS
. Du gâteau !
Cahier des charges
L’énorme base de données ayant subi quelques traumatismes (certaines colonnes ayant été castées à LA RACHE lors de la première importation), il était essentiel que nous puissions manipuler toutes les données transitant par nos entités pour les nettoyer au passage.
Défi supplémentaire : les conventions de nommage imposaient le snake_case pour la base de données lorsque le camelCase était utilisé pour l’application.
- Pour chaque table de la base de données, une entité (classe) devait être créée.
- Les propriétés de ces entités ne devaient être accessibles que via des getters et des setters (encapsulation lvl over 9000).
- Les propriétés nommées en snake_case devaient être mappées sur leur équivalent camelCase.
The cake is a lie
Pour le vieux de la vieille que vous êtes, implémenter de telles entités doit sembler bien trivial… Et c’est vrai !
Comme je l’ai dit plus haut, implémenter de telles entités est effectivement trivial. Mais seulement quand on a repéré l’arnaque !
Que s’est-il passé ?
Avant de vous révéler la solution (spoiler alert : c’est dans le titre), intéressons-nous de plus près à la manière dont a été créé l’objet User.
« Tiens, c’est marrant ça, les valeurs sont attribuées avant que le constructeur ne soit appelé ! », s’écriera le jeune développeur. Mais au vieux de la vieille, on ne la lui fait pas à lui ! Lui, il est là, assis sur sa chaise, et il facepalm… Mais il facepalm tellement putain !
Pour les moins perspicaces qui n’auraient pas encore saisi, je vous propose un autre exemple afin de vous mettre sur la piste :
L’objet que nous venons de manipuler a donc été créé sans faire appel à son constructeur, et les valeurs de ses propriétés définies sans tenir compte de leur portée.
L’injection par réflexion
Le coupable ? La réflexion ! Ou, d’après Wikipédia : « la capacité d’un programme à examiner, et éventuellement à modifier, ses propres structures internes de haut niveau lors de son exécution ».
À cette étape de l’article, je tiens à vous rappeler que ce qui suit est hautement spéculatif. Malgré les nombreuses heures passées à décortiquer pdo_stmt.c, une partie non-négligeable des sources de PHP relève encore pour moi du vaudou ; et il est ainsi envisageable qu’en dépit de ma bonne foi, je puisse raconter de grosses conneries.
Avant de s’intéresser au comment, intéressons-nous d’abord au pourquoi. Pourquoi diable l’équipe en charge de PDO s’est-elle embêtée à mettre au point un système aussi tordu ?
Sans être ultra-catégorique, mon intime conviction m’invite à penser que cette manière de faire est la seule qui soit suffisamment générique et robuste pour se révéler d’un quelconque intérêt.
Je soupçonne également fortement un impact bénéfique en terme de performance. Néanmoins, cette seconde hypothèse n’étant fondée sur aucun élément tangible, elle sera laissée de côté pour l’instant.
Prenons le problème à l’envers, et tentons d’imaginer la manière dont devrait fonctionner PDO::FETCH_CLASS
pour répondre au même cahier des charges sans utiliser la réflexion.
On pourrait par exemple créer l’objet avant de définir ses propriétés. Avantage : le cycle de vie des objets et l’encapsulation sont respectés. Inconvénient : l’obligation de définir un setter afin de gérer les propriétés protégées et privées.
Cette approche semble plutôt élégante. D’ailleurs, elle l’est tellement qu’elle a déjà été implémentée, puisque ce comportement c’est celui de PDO::FETCH_INTO
:
Une autre solution serait d’injecter les données provenant de la base de données dans l’objet via des méthodes définies explicitement. Mêmes avantages, mêmes inconvénients. Et une fois encore, ça existe déjà. Permettez-moi de vous présenter PDO::FETCH_FUNC
:
Vient alors la question du comment. Comment PDO injecte-t-il ses données dans les objets créés via le mode PDO::FETCH_CLASS
?
D’après ce que je pense avoir compris, il semblerait que le processus commence par l’initialisation d’une zend_class_entry
(représentation interne d’une classe) correspondant à la classe à instancier.
Ensuite : déréférencement du constructeur (pour le désactiver), injection des données sérialisées dans la zend_class_entry
, désérialisation de la zend_class_entry
, réinjection du constructeur et appel au constructeur.
Conclusion
Le mode PDO::FETCH_CLASS
de PDO utilise la réflexion afin d’injecter les valeurs des propriétés des classes qu’il instancie. Cette injection se fait alors aux dépens du principe d’encapsulation, ce qui peut parfois conduire à des comportements inattendus notamment en ce qui concerne l’utilisation des méthodes magiques.
Sans trop m’avancer, il me paraît évident (à la lecture du code source et au regard des modes alternatifs fournis par l’API) que le mode PDO::FETCH_CLASS
a été spécialement conçu dans le but de fournir un moyen simple, rapide et générique de récupérer un jeu de données sous forme d’objet non-générique.
Est-ce une bonne chose ? Je crois oui. Car dans le contexte présenté ci-dessus, le contrat est pleinement rempli et la solution fournie conviendra parfaitement à l’écrasante majorité des projets que nous sommes amenés à construire. Mais encore aurait-il fallu documenter ce comportement !
Dans le contexte qu’était le nôtre, trois solutions principales semblaient répondre au cahier des charges :
- Utiliser le mode
PDO::FETCH_INTO
à la place du modePDO::FETCH_CLASS
. - Utiliser le mode
PDO::FETCH_FUNC
et implémenter une factory pour créer nos objets proprement. - Utiliser des propriétés virtuelles pour forcer le passage des valeurs par la méthode magique
__set()
.
La première solution fût très vite écartée en raison de la surcharge de travail qu’elle représentait (créer un nouvel objet avant chaque requête, ne pas oublier de cloner l’objet dans les boucles de récupération, …).
Quant à la seconde, elle limitait nos possibilités à l’utilisation de la méthode PDOStatement::fetchAll()
et nous imposait l’écriture d’une factory (chaque nouvelle ligne de code ajoutée au projet devant être maintenue par la suite).
Même si j’était plutôt réticent à l’idée d’utiliser des propriétés virtuelles, c’est bien vers la troisième méthode que nous nous sommes tournés. Ci-joint, un exemple d’implémentation utilisant respect/validation pour le nettoyage des données.
Note : cette implémentation suppose qu’aucune colonne de votre base de données ne soit nommée hiddenProperties.