(Certains) détracteurs des ORM ont tout compris
Voici une republication d'un billet personnel que Bernard a écrit en 2012
J'ai décidé de republier le texte suivant à propos des Object-Relational Mappers (ORM). J'avais publié la version originale en 2012, sur mon blog personnel revision-zero.org, que j'ai depuis retiré d'Internet. De temps à autre, je vois passer sur les réseaux sociaux des gens affirmant qu'« on ne peut pas confier la conception d'un logiciel sérieux à ceux qui détestent les ORM », et cela me rappelle ce texte.
Cela me rappelle aussi que, sur le chemin qui mène à Klaro Cards et pour l'alimenter, j'ai créé :
- Alf, l'algèbre relationnelle à portée de main
- BMG, son successeur et compilateur SQL prêt pour la production
- Finitio, un langage de données qui mériterait un peu plus d'amour
- Et Klaro Cards, bien sûr
Je n'ai aucune idée de savoir si cela fait de moi « quelqu'un à qui l'on peut confier la conception d'un logiciel sérieux », mais le texte ci-dessous vaut certainement le détour de toute façon.
L'ouverture du texte en 2012
Ce billet prolonge la discussion (re-)lancée par des articles sur les ORM comme celui-ci ou celui-là. Je ne suis pas particulièrement heureux de participer à cette très vieille guerre de tranchées. J'ai cependant découvert récemment que certains développeurs (clairement pas les auteurs des billets ci-dessus) ne sont tout simplement pas du tout conscients de certaines faiblesses des ORM, comme l'énorme quantité de requêtes qu'un usage naïf peut générer vers la base de données.
Ma motivation initiale, en écrivant ceci, était justement de fournir à ces développeurs un peu de matière de haut niveau sur le sujet. Cela est arrivé le jour même où le premier billet ci-dessus est apparu sur Hacker News. En réfléchissant à tout cela, j'ai fini par écrire cet essai. Comme il apporte un contexte généralement absent des autres discussions, j'ai décidé de le publier ici.
Introduction
Les Object-Relational Mappers (ORM) sont une drôle de bête. Ils ont tendance à diviser profondément la communauté du génie logiciel. Les « détracteurs » se plaignent que les ORM sont lents, génèrent d'énormes quantités de requêtes inefficaces, rendent les migrations plus difficiles que nécessaire, etc. Les défenseurs répondent aux détracteurs qu'ils utilisent mal les ORM et écrivent des mappings et des algorithmes naïfs.
Je ne me considérerais pas comme un détracteur des ORM, en tout cas pas de ceux décrits ci-dessus. La raison en est simple : je n'utilise pas d'ORM du tout. Je ne rencontre donc pas ces problèmes en premier lieu. Mais je déteste les ORM, ça oui. Je les déteste parce que, à mon avis, ils sont l'incarnation même de la mauvaise informatique. Je laisse cette affirmation sans justification pour le moment. Si vous voulez savoir pourquoi je l'avance, je l'expliquerai dans un autre billet. Ici, j'aimerais simplement expliquer pourquoi, selon moi :
Vous ne pouvez simplement pas utiliser un ORM de la bonne manière
Vous pouvez les utiliser de la manière prévue, mais il n'existe malheureusement pas de bonne manière. La raison est que l'approche ORM est intrinsèquement défectueuse. C'est la thèse dont j'aimerais discuter aujourd'hui.
Vous avez probablement entendu parler de ce qu'on appelle l'impedance mismatch. Ce terme désigne la difficulté à réconcilier les approches objet et relationnelle de la gestion des données, c'est-à-dire à réconcilier les tables avec les classes, les enregistrements avec les objets, les classes avec les types de données SQL, et ainsi de suite. C'est l'objectif général des Object-Relational Mappers.
Le fait est qu'il existe un autre type de décalage, que très peu de développeurs connaissent vraiment, et dont presque personne ne parle. Ce décalage porte sur la façon dont les deux camps raisonnent au niveau logique.
La façon de penser orientée objet
Côté objet, le développeur raisonne en termes d'individus. Ici, « individus » ne désigne pas des êtres humains, mais des choses distinguables : cet utilisateur, cette commande, cet abonnement, ce produit, ce livre, etc. Presque toutes les fonctionnalités d'un ORM visent en réalité des individus, aussi appelés objets ou instances. Un ORM vous aide à créer ou détruire des objets, un par un ; vous pouvez les observer individuellement ; utiliser des callbacks spécifiques pour réagir à des événements précis de leur cycle de vie personnel ; etc.
C'est la « façon de penser orientée objet », celle qui vous dit d'écrire des algorithmes comme ceci :
pour chaque individu i d'intérêt # une construction ORM dédiée est nécessaire ici
si i.remplit_une_condition
i.fait_une_tache
end
end
En programmation orientée objet, vous parlez à des objets individuels ; on dit souvent que les appels de méthode correspondent à des messages envoyés aux objets. Des méthodes comme remplit_une_condition et fait_une_tache peuvent correspondre à des accesseurs d'attributs et à de simples mises à jour, respectivement. Cependant, elles encapsulent généralement des conditions et des tâches d'une complexité plus grande. Dans ces cas-là, elles capturent des tâches et des règles métier et impliquent couramment beaucoup d'autres objets, des itérations, des conditions, et ainsi de suite.
Par exemple, remplit_une_condition pourrait encapsuler la logique métier suivante :
- La commande
icontient au moins 10 produits - L'emprunt
ia deux semaines de retard et le livre n'est pas emprunté par un employé de la bibliothèque - Les informations de l'utilisateur comprennent un profil Facebook ou Twitter
Les experts ORM vous diront que l'algorithme ci-dessus serait mieux écrit comme suit, en particulier quand remplit_une_condition correspond à une condition non triviale (c'est-à-dire qu'elle ne correspond pas à un seul attribut ou accesseur) :
pour chaque i TEL QUE i.remplit_une_condition # construction spéciale de l'ORM
i.fait_une_tache
end
Presque tous les ORM fournissent une construction « pour chaque ... tel que ... ». Il vaut mieux l'utiliser parce qu'elle tend à n'itérer que sur les objets pertinents pour la tâche en cours et, plus important encore, parce qu'elle génère bien moins de requêtes vers la base de données (il est laissé en exercice au lecteur de vérifier cette affirmation). Dans la communauté Ruby on Rails, par exemple, ne pas utiliser une telle construction de plus haut niveau est considéré comme une erreur courante.
La raison pour laquelle le second algorithme génère moins de requêtes est que le « pour chaque ... tel que ... » peut généralement être traduit en SQL par l'ORM. C'est évidemment plus malin que de s'appuyer sur un if dans le langage orienté objet, qui tend à générer une nouvelle requête pour chaque remplit_une_condition non triviale sur les individus.
Malheureusement, même avec de « meilleures » façons de faire, vous continuez à penser en termes d'individus (fait_une_tache est toujours envoyée à chaque individu d'intérêt). C'est la « façon de penser objet », et vous ne pouvez pas y échapper sans répudier la programmation orientée objet elle-même. Nous y reviendrons un peu plus loin, en analysant une amélioration supplémentaire de l'exemple ci-dessus.
Ce que vous devez comprendre, c'est que
Le modèle relationnel, et donc les bases de données relationnelles, ne parlent PAS d'individus. Et c'est voulu.
Sachant cela, comment pourriez-vous espérer qu'un ORM soit utilisé ou écrit « de la bonne manière » ? Vous ne pouvez pas écrire un outil qui réconcilie deux façons de raisonner antagonistes. Les antagonismes ne se résolvent pas « de la bonne manière ». C'est tout.
Discutons maintenant un peu plus avant du côté relationnel.
La façon de penser relationnelle
D'abord, une base de données relationnelle ne parle pas d'individus parce qu'une base de données capture généralement de l'information à propos du monde, pas le monde lui-même et certainement pas ses individus. C'est une distinction très importante, peut-être pas très subtile, mais importante malgré tout. Ne pensez-vous pas qu'il y a une excellente raison pour que le mot information soit un nom indénombrable en anglais ? Parce que l'information n'EST PAS des individus, elle est au mieux à propos d'individus. Bien sûr, vous pouvez « isoler » un morceau très précis de la masse d'information dont vous disposez, mais cela n'en fait pas un individu, et penser en ces termes est donc défectueux à la racine.
Par ailleurs, savez-vous pourquoi Edgar F. Codd a inventé le modèle relationnel au départ ? Codd travaillait alors pour IBM. Il avait observé que les développeurs passaient beaucoup de temps à écrire des programmes sujets aux erreurs pour manipuler des individus à travers des chemins d'accès spécifiques à l'information. À l'époque, les individus étaient plus proches des pointeurs que ne le sont les objets ; et les « chemins d'accès » étaient des algorithmes impératifs, des itérations, des suivis et déréférencements de pointeurs, ainsi que des conditions permettant de naviguer dans des structures hiérarchiques mappées sur des fichiers physiques.
Codd soutenait qu'il valait mieux raisonner en termes d'ensembles plutôt que d'individus. Et disposer d'un langage déclaratif pour les manipuler plutôt que d'un langage procédural. Raisonner en termes d'ensembles permet de manipuler de nombreux enregistrements (tuples serait un meilleur mot ici) aussi simplement qu'un seul. Un langage déclaratif découple l'intention de la manipulation de l'algorithme effectif. Ce découplage permet d'optimiser le processus de manipulation, puisqu'un raisonnement automatique sur son intention devient possible. L'optimisation peut aussi être réalisée automatiquement, au lieu de reposer sur la bonne volonté du développeur.
Pour mieux expliquer l'approche ensemble à la fois, reconsidérons le prédicat remplit_une_condition. En raisonnement relationnel, ce prédicat doit être compris comme partitionnant l'ensemble initial de tuples en deux sous-ensembles disjoints. La manière dont ce partitionnement est effectué est cachée à l'utilisateur au niveau logique. En termes SQL déclaratifs :
SELECT ... FROM ... WHERE remplit_une_condition
Retour à l'exemple
Revenons maintenant à l'exemple ci-dessus. En particulier au processus d'« optimisation » qui a mené à la seconde version avec un « pour chaque i ... tel que ... ». La seconde forme est bien plus maligne parce qu'un algorithme impératif a été traduit (plus ou moins en douce) en une intention déclarative. Résultat : cette intention déclarative peut être traduite en SQL (un langage déclaratif lui-même) par l'ORM. L'effet net est que les requêtes générées pour évaluer le « si i.remplit_une_condition » sur chaque individu ont été éliminées au passage.
Autrement dit, une itération naïve a été remplacée par une itération plus maligne. C'est un processus d'optimisation typique... dont les développeurs doivent se charger manuellement, en évitant la construction if. Plus précisément, en déplaçant le if du langage hôte (Ruby ou Java, par exemple) vers le langage de requête (la construction spéciale de l'ORM pour créer des requêtes, qui doit logiquement être vue comme un langage distinct).
D'autres optimisations peuvent évidemment être réalisées. Par exemple, supposons que i.fait_une_tache se contente de mettre à jour les attributs de i, ou de créer une instance d'une autre classe, c'est-à-dire de « créer » un individu. Dans ces cas (vraiment très courants en pratique), pourquoi ne pas essayer d'éviter une itération explicite ? Soit n le nombre d'individus remplissant la condition. L'algorithme original génère n+1 requêtes :
pour chaque i TEL QUE i.remplit_une_condition
i.fait_une_tache # une requête update/insert pour chaque `i`
end
Pourquoi ne pas avoir une version optimisée qui ne génère qu'une seule requête ?
mettre à jour tous les `i` TELS QUE i.remplit_une_condition
Et de fait, les ORM fournissent généralement ce genre de constructions de plus haut niveau également. Et ne pas les utiliser est considéré comme une erreur courante.
Félicitations aux développeurs d'ORM pour avoir fourni de telles constructions ! Vous êtes en train de réinventer la roue. Plus précisément, vous êtes en train de redécouvrir la motivation originale de Codd. Mais vous avez quarante ans de retard. Le contexte des ORM a un goût différent de celui de CODASYL à l'époque, bien sûr, mais il me semble très similaire, vous ne trouvez pas ?
Si vous poursuivez le même raisonnement, vous finirez par éviter tout travail sur les individus. Par exemple, vous voudrez remonter le code de fait_une_tache en amont, parce qu'il contient une autre itération qui contient une autre condition, et ainsi de suite. Mais ce faisant, vous constaterez simplement que cela conduit à rejeter la programmation orientée objet en premier lieu. C'est-à-dire que c'est strictement incompatible avec le souhait d'avoir un modèle objet pour capturer les données (notez que je ne rejette pas la programmation orientée objet dans son ensemble, mais seulement pour représenter les données). La programmation orientée objet, c'est DES individus.
Autre exemple de cette répudiation de l'objet : dans l'ORM de Ruby on Rails (ActiveRecord), la fonctionnalité « update ... such that » ci-dessus est strictement incompatible avec les callbacks de mise à jour. Ces derniers ne sont pas activés lors des mises à jour de masse. Autrement dit : ou bien vous appliquez la façon de penser orientée objet au prix d'inonder votre base de données de requêtes, ou bien vous préservez votre base de données, au prix de la façon de penser orientée objet.
Conclusion
J'espère que vous comprenez mieux l'antagonisme dont il est question ici (sinon, vous voudrez peut-être relire ce billet une fois de plus). Et que vous êtes maintenant convaincu que les ORM devraient être évités parce qu'ils ne peuvent pas réussir sur le long terme. Et j'espère certainement que la prochaine fois que vous, utilisateurs d'ORM, déciderez d'écrire des billets de blog expliquant aux autres développeurs à quel point le modèle relationnel, les bases SQL, les JOIN ou les schémas normalisés sont lents, vous privilégierez des phrases du type :
Étant donné que j'utilise un ORM, dont je sais (maintenant) qu'il est complètement incompatible par conception avec les bases de données relationnelles, je rencontre des problèmes de performance, ...
plutôt que toute autre forme qui inviterait les gens à penser que le problème est intrinsèque à la théorie relationnelle elle-même. En particulier, j'espère que vous admettrez désormais que dans
Générer 100 000 requêtes impliquant un JOIN, c'est lent
la partie inefficace est probablement le 100 000 (linéaire), et pas le JOIN lui-même (constant). Et que dénormaliser dans ces cas-là est juste une mauvaise réponse à un vrai problème de performance. Essayez d'envoyer une seule requête à la place, même si elle contient un JOIN ;-)
J'espère aussi que la prochaine fois que vous serez tenté de terminer votre prose par quelque chose comme
Vous ne savez pas de quoi vous parlez.
vous prendrez le temps nécessaire pour vous demander si ce ne serait pas plutôt l'inverse.