Introduction

Vous avez peut-être entendu parler de MDA, l'acronyme pour Model-Driver Architecture - ou architecture dirigée par le modèle. MDA est une émanation de l'OMG à qui l'on doit aussi UML et d'autres standards tout aussi importants. Pour faire simple, la grande idée de MDA est de permettre la génération de code en fonction de la définition d'une architecture haut niveau. Des outils transforme les diagrammes définis par l'architecte en code fonctionnel.

Si l'idée est plus ou moins sympathique, il reste difficile de la mettre en pratique - sauf dans des cas particuliers. Il est assez évident qu'une architecture haut niveau ne contient pas assez d'information pour permettre la génération complète d'un produit ; et si c'est le cas, alors cette architecture est allée tellement loin dans les détails qu'il a fallut autant de temps pour la réaliser qu'il en aurait fallu pour réaliser le produit dans une autre technologie[1].

Le mapping objet/relationnel est l'un de ces cas particuliers dont je viens de parler. Une fois défini le modèle de la base de donnée, on peut générer un gros morceau de code qui va permettre de faire le lien entre cette base et le (ou les) programme(s) qui vont l'utiliser. Le gain de temps est plus qu'appréciable dans ce cas puisque la définition du modèle de donnée est aisée et rapide. Tout semble aller bien dans le meilleur des mondes, au point que cette approche est encensée un peu partout et que des dizaines de frameworks ont été développés en Java, en C#, en C++, en PHP, en python, etc. Une simple recherche sur "ORM" dans Google renvoie plus de 19 millions de réponses[2]. Le monde entier aime les frameworks ORM.

Alors qu'est-ce que je leur reproche ?

Point 1 : stable, mais concret et volatile

Puisqu'il représente une partie non négligeable du métier d'un produit, le code généré par un ORM est fait pour être utilisé par une grosse partie de ce produit. En architecture objet, c'est ce qu'on appelle la stabilité[3]. Plus un module est utilisé par d'autres modules, plus il est stable. Il faut comprendre que la stabilité est une conséquence de l'architecture : elle n'existe pas à priori.

Un module stable est peu susceptible d'évolution, car toute évolution peut avoir des conséquences désastreuse pour le projet puisque beaucoup d'autres modules dépendent de celui-ci. Par conséquent, plus un module est stable, plus ils doit respecter le principe ouvert/fermé. Pour qu'un tel module soit extensible, il faut qu'il présente des abstractions fortes et les mécanismes qui permettent de redéfinir ces abstractions. Au vu des technologies actuelles, ça veut dire que le module doit présenter suffisamment de classes abstraites.

Ces deux notions - stabilités et abstraction - peuvent être définies à l'aide de métriques. I, qui varie de 0 à 1, est l'instabilité (c'est à dire le contraire de la stabilité) et est définie à partir du moment ou le module existe dans un ecosystème d'autres modules et qu'au moins un module lui est lié par une relation de dépendance. Pour calculer I, on prends en compte les dépendances afférentes (des modules externes dépendent de ce module) et les dépendances efférentes (le module dépends d'autres modules).

Da est le nombre de dépendances afférentes.
De est le nombre de dépendances efférantes.

I = De / (Da + De)

Le calcul de l'abstraction A (variant aussi de 0 à 1) est plus simple : si C est le nombre de classes publiques du module, et Ca le nombre de classes abstraites publiques, alors

A = Ca / C

Un module stable doit être abstrait. Un module instable (donc un module dont peu de monde dépends) est généralement un module situé très haut dans les couches de l'application. Il défini les fonctionnalités de celle-ci. Il est par conséquent concret, puisqu'il n'a pas besoin d'être étendu. On voit se dessiner une relation idéale entre I et A. Celle-ci est simple :

I = 1 - A

Vu que c'est une relation idéale, elle a peu d'intérêt en soit. Elle dit juste que idéalement, plus le module est instable, moins il est abstrait. Ce qui nous intéresse le plus souvent est de savoir à quel point on s'éloigne de cet idéal. Pour ce faire, on calcule simplement distance D entre la ligne idéale et les valeur d'instabilité et d'abstraction réelles. Une formule simple nous donne une solution tout à fait acceptable :

D = abs(I + A - 1)

Dans l'idéal, on doit minimiser D (qui varie de 0 à 1) au mieux. Plus D est proche de 0, plus on s'approche de l'idéal donné par la relation ci-dessus. Une valeur proche de 1 signifie qu'on a soit un module stable et concret (problématique en termes de maintenance), soit un module instable et abstrait (donc d'aucune utilité).

Revenons maintenant à notre code généré. Est-il stable ? Oui. Le nombre de modules qui va dépendre de notre code généré risque d'être très élevé, d'autant plus que le nombre de module dont notre code va dépendre est lui très réduit. Donc I tends vers 0. Est-il abstrait ? Le principe de génération utilisé par les ORM est de dire qu'on associe une table (ou une requête) à une classe. Cette classe n'est pas abstraite - ce qui est logique, puisqu'elle représente quelque chose de véritablement concret. Par conséquent, l'interface de notre module est purement concrète, et A = 0. La conséquence est immédiate : si I et A sont proches de 0, alors D est proche de 1.

Il existe des cas ou une telle propriété est bienvenue : par exemple, si le module considéré est une surcouche à une API, alors cette surcouche sera par définition très concrète et stable. Pourquoi ces cas-là sont-ils acceptable ? Et bien parce que l'API étant peu volatile, le module est peu sujet à modification. D'ou la règle : un module stable et concret est un module qui n'est pas sujet à modification.

Une base de donnée est susceptible d'être amendée avec de nouvelles tables, de nouvelles colonnes dans une table, ou de nouvelles relations entre plusieurs tables. Puisque la base de donnée vit, elle est très volatile. Et puisque le code généré par l'ORM est d'une part basé sur le modèle de donnée et d'autre part basé sur les spécifications du produit, il résulte que le code généré par un ORM est très volatile - il a de fortes chances d'être modifié au cours de la vie du produit. La conséquence est directe : une modification, même peu importante, risque d'avoir un impact important sur le projet, avec tous les problèmes de maintenance et de tests que cela implique.

Point 2 : une différence de paradigme

Les ORM, en créant une relation entre une architecture objet et un modèle de données relationnel, tentent de résoudre la quadrature du cercle. Ils définissent des classes pour représenter le résultat de requêtes. Cette approche architecturale a un nom : c'est une approche dite ontologique pure, puisqu'elle définit l'architecture en se basant uniquement sur les propriétés connues des entités qu'elle représente.

Une approche ontologique produit des entités concrètes - elle n'est pas capable de déceler des abstractions. Prenons un exemple simple : un jeu type asteroid. Le joueur pilote un vaisseau, et tire des missiles sur des astéroides en mouvement. Une approche ontologique me permet de définir une classe Vaisseau avec les propriétés (position, vitesse, accélération,,nombre de rampes de lancement de missiles) et des méthodes représentant les différentes actions qu'on peut effectuer avec ce vaisseau, une classe Missile avec une autre liste de propriété et une classe Asteroide avec là aussi une liste de propriétés propres. L'approche ontologique pure me dit que les relations entre les différents éléments sont directe - c'est à dire qu'il existe une dépendance directe entre Vaisseau et Missile, entre Missile est Asteroide et entre Vaisseau et Asteroide. Tout va bien dans le meilleur des mondes, jusqu'à ce que je rajoute une autre entité - par exemple, des ennemis représentés par une classe Ennemi. Je suis alors obligé de rajouter le traitement des collisions entre un astéroïde et l'ennemi, entre le vaisseau et l'ennemi, et entre le missile et l'ennemi. Puis je rajoute un Planetoide fixe avec une gravité - et je crée de nouveau des liens, dont le nombre augmente en suivant une fonction factorielle du nombre de classes déjà présentes. Au bout de très peu de temps, mon projet devient horriblement difficile à maintenir car tout utilise tout, une modification a un endroit provoque un problème autre part, etc. Ma conception pourrit, littéralement.

Les architectures établies avec une approche ontologique pure ont toute le même problème : elles pourrissent d'autant plus vite qu'elles se complexifient.

Quelle est l'alternative ? Il s'agit bien évidemment de construire des abstractions (au sens sémantique et mathématique, pas au sens de "classe abstraite"). Pour ce petit jeu, au lieu d'une approche ontologique, je peux choisir une approche comportementale : je me base alors sur des entités qui échangent des messages (exemple de message : "je me déplace vers telle position", ou "je lance un missile"). Une plus grande complexité implique un plus grand nombre de comportements, mais le lien entre ces comportements est tenu, voir inexistant. La maintenance du produit final en est grandement simplifiée.

L'approche ontologique pure utilisée par les ORM produit elle aussi une architecture qui va, petit à petit, tendre vers le pourrissement. Dans le cas des ORM, ce pourrissement est plus ou moins caché par le fait que le code est généré automatiquement[4]. Mais il est quand même présent, et plus le modèle de donnée va se complexifier, plus vous allez le ressentir profondément.

Point 3 : je débogue. Enfin, j'essaie

Le code produit par les ORM fonctionne comme une boite noire, et nécessite la validation d'un certain nombre de pré-conditions et de post-conditions. Le problème ici est purement lié au développement, mais il faut quand même le prendre en considération : en cas de bug, celui-ci risque fort de ne pas être facile à découvrir (même si le bug est de votre coté et pas dans le code produit). Les messages renvoyés par les frameworks les plus courants sont typiquement peu pratiques et quelque fois difficiles à comprendre.

Ce n'est d'ailleurs pas le seul problème lié au développement. De nombreux utilisateurs d'ORM pointent du doigt les performances des outils qu'ils utilisent. Ces problèmes de performance ont deux sources principales :

  • La complexité du framework lui-même ; de nombreux niveaux d'indirection peuvent tout simplement rendre le code lent à l'exécution.
  • Les requêtes générées ne sont pas optimisées ; sur un produit dont les performances sont directement liées à la vitesse à laquelle les requêtes s'effectue, il faut que celles-ci soient optimisées. Bien souvent, le code produit par les ORM rend plus difficile l'optimisation des accès à la base de données.

Et finalement, bien que le problème soit plus rare, le code généré par l'ORM peut ne pas être du tout celui dont vous avez besoin. C'est ce que Joel Spolsky nomme la loi des abstractions trouées[5] : toutes les abstractions, au delà d'un certain point, sont "trouées" - c'est à dire qu'elles échouent à rendre le service pour lequel elles ont été créées. Si vous rencontrez ce problème, vous mettez les pieds dans un monde nouveau et effrayant ou les clients ne sont pas contents du tout lorsque vous leur expliquez que vous ne pouvez pas faire mieux parce que 1) vous ne pouvez pas changer d'ORM a ce stade du développement 2) votre ORM ne vous permet pas de faire ce que vous voulez. C'est généralement à ce moment là que vous décidez qu'on ne vous y reprendra plus à utiliser cet ORM moisi qu'on vous a forcé à utiliser.

Conclusion

Contrairement à l'approche de certains, je ne considère pas les ORM comme un mal nécessaire. Il existe d'ailleurs des développeurs qui sont d'accord avec moi sur ce point. Quel solution est-ce que je préconise pour éviter le recours à un ORM ?

En fait, aucune. Plus exactement, et puisque je considère que la construction d'abstractions orientées métier est indépendante de la façon dont les données métier peuvent être agencées, je préconise tout simplement l'utilisation d'une approche orientée données pour tout ce qui est du lien avec la base de donnée - c'est à dire que vous écrivez la requête, et vous rangez le résultat de celle-ci comme vous le souhaitez. Le tout sans chichi, sans surcouche inutile, et en acceptant l'idée que vous puissiez manipuler des tables de données. Une approche tout ce qu'il y a de plus naturelle en somme.

Le plus étonnant restant que cette approche naturelle est bien souvent plus simple et plus rapide à mettre en oeuvre et à déboguer[6], qu'elle offre des performances bien souvent meilleures, et qu'elle ne pourrit pas l'architecture de l'application.

En somme, encore un exemple de ce que nous autres, entre gens du monde, appelons le DTST.

Notes

[1] pour information : UML est un langage. En tant que tel, il peut être étendu pour être directement compilable. Executable UML est d'ailleurs un des axes de travail dans le domaine MDA

[2] qu'il faut diviser par deux, une partie des réponses ayant trait à Object Role Model, qui est un poil différent

[3] à ne pas confondre avec une autre forme de stabilité, celle relative au nombre de plantage d'une application

[4] Plus ou moins, parce que certains ORM nécessite des modifications importantes de certains fichiers de configuration - modifications qui peuvent dans certains cas excéder la complexité d'une modification du code généré, ce qui est à la limite de l'absurde.

[5] en anglais, leaky abstraction , ou abstraction qui a une fuite

[6] dixit la version chef de projet de moi même, qui a vu ses collaborateurs passer une demi journée voire une journée à batailler avec un ORM Java très répandu à chaque fois qu'une nouvelle requête devait être implémentée