Explication illustrée du principe "Premature optimization is the root of all evil"

Objectif

Le but de cette page est de montrer avec un cas réaliste l'illustration de la célèbre citation de Donald Knuth premature optimization is the root of all evil. Une discussion d'architecture avec une de mes équipes de développement a fait apparaître un exemple simple et - à mon avis très efficace - pour illustrer ce principe maintes fois confirmé par l'expérience.

Contexte

La situation est arrivée sur un développement en .NET hybride MAUI / Blazor. Le cas d'usage est l'utilisation d'un composant à affichage modal permettant de sélectionner des biens immobiliers, et de renvoyer cette sélection à l'appelant, qui en l'occurrence rattache ces biens dans une section "biens associés" d'un formulaire, sous une forme de vignettes pour rendre la lecture simple sans toutefois encombrer l'affichage. La fenêtre de sélection appelle une API REST en HTTP pour récupérer ces données, ce qui est une opération coûteuse en temps. Comme les données, une fois la sélection réalisée, vont devoir être utilisées à nouveau dans la page appelante, il est logique de se poser la question de leur transmission dans la réponse du composant de sélection. Ceci permettrait logiquement de supprimer un appel HTTP.

Le diagramme correspond à cette approche est le suivant :

Fausse optimisation

Problématique

En termes d'architecture de code, cette solution pose un problème car elle rompt le principe de responsabilité unique (Single Responsibility Principle, le premier des cinq principes S.O.L.I.D.). Chaque classe ou module est censée s'occuper d'une chose et d'une seule : la fenêtre de sélection doit aider l'utilisateur à trouver rapidement un bien (avec des filtres, une ergonomie adaptée, en améliorant la performance par l'utilisation de pagination lors de l'appel du serveur, etc.), tandis que l'appelant doit utiliser la liste sélectionnée pour afficher des vignettes et persister cette liste une fois le formulaire rempli et validé par l'utilisateur.

Bien sûr, ce principe de responsabilité ne signifie pas que les modules vivent en complète autonomie les uns par rapport aux autres. La fenêtre de sélection a bien sûr besoin du serveur pour récupérer les données des biens immobiliers, elle ne peut pas les inventer. De la même manière, si la fiche appelle la fenêtre de sélection des données, elle crée bien une relation avec elle. Toute l'idée est de miniser ce couplage et faire en sorte que, dans cette relation aussi, les responsabilités soient bien partagées et que ce qui reste en commun soit minimisé autant que faire se peut.

Si on creuse plus précisément ce point de l'interaction entre le client et la fenêtre de sélection, on peut détailler les partages de responsabilité comme suit : - Le client informe la fenêtre du mode de sélection qu'il souhaite (simple ou multiple). - La fenêtre renvoie une contenu vide, une entité ou bien une liste. Dans la pratique, pour rendre les choses plus simples, une seule signature d'appel sera utilisée avec une liste qui pourra être vide, remplie avec une seule entité. Ce sera la responsabilité de la fenêtre de faire en sorte que ce soit bien le cas si l'appelant l'a initiée en mode de sélection simple. - On peut éventuellement rajouter une responsabilité au client de partir en exception si la liste fournie renvoie plusieurs entrées alors qu'il a fourni un paramètre pour la sélection simple. Un autre choix pourrait aussi être de ne traiter que la première donnée de la liste. C'est la responsabilité du client de choisir ce comportement, mais ce qu'il faut comprendre est que - même s'il a délégué l'implémentation du mode de sélection à la fenêtre - c'est bien lui qui en reste responsable. - La fenêtre de sélection est responsable de remplir cette liste en fonction des interactions de l'utilisateur, qui porte le choix effectif des biens. Pour cela, elle récupère des données du serveur et les met en forme de façon à rendre efficace la sélection. - Le client est responsable de la suite des opérations après la sélection, à savoir afficher les données dans une forme de vignettes, pour que l'utilisateur constate la sélection opérée et puisse décider de rappeler la fenêtre de sélection pour la modifier, voire la supprimer, s'il le considère utile.

C'est autour de la liste qui est échangée entre les deux modules que se cristalise le problème de responsabilité unique : comme la liste est justement ce qui va être véhiculé entre les deux modules pour qu'ils se parlent, il est impossible de dire que la grammaire de cette liste relève de la responsabilité de l'un ou de l'autre. Certes, la fenêtre va la remplir et le client va la lire, mais pour cela, il faut bien qu'ils soient d'accord sur le contenu.

L'erreur de l'optimisation prématurée

Comme la fenêtre a eu besoin de charger des biens immobiliers avec toutes les données nécessaires à ce que l'utilisateur réalise un choix, il serait dommage de simplement mettre ces données à la poubelle : pourquoi ne pas faire en sorte que la liste contienne toutes les données, bref que la grammaire soit celle utilisée par la fenêtre de sélection ?

C'est précisément avec ce raisonnement qu'on entre dans le problème d'optimisation prématurée. En essayant trop tôt d'optimiser les performances de l'ensemble et, pour cela, en cherchant à réduire le nombre d'appels au serveur de données, on fait l'hypothèse que l'appelant n'aura besoin que d'une partie des données renvoyées par la fenêtre de sélection. Or, rien n'est moins sûr car l'appelant pourrait très bien avoir besoin d'informations que la fenêtre de sélection n'a pas présentées. Par exemple, il se peut que la fenêtre de sélection ait mis en avant le prix, la localisation du bien immobilier, voire même des photos pour faciliter le choix. Mais peut-être que l'appelant, lui, avait besoin pour l'établissement de sa fiche des contacts des propriétaires. Or, s'ils ne font pas partie de la liste envoyée, parce que la grammaire était fixée par la fenêtre de sélection, l'appelant sera bien obligé d'appeler à nouveau le serveur de données pour compléter la liste avec ce qui lui manque, annihilant ainsi tout l'effet bénéfique initialement espéré.

Pire, s'il se trouve que la liste convenait dans un premier temps mais que l'usage nécessite d'autres donnnées, il sera nécessaire de revenir sur le logiciel et de réaliser une seconde version, avec tous les coups de recette et de déploiement afférents.

Et si on pousse le bouchon encore plus loin en imaginant que la fenêtre de sélection laisse choisir à l'utilisateur ses critères de données, et que la fiche appelante fasse pareil... il n'y a tout simplement presque aucune chance que les listes s'accordent au final et que le client ne soit pas effectivement obligé de relancer une coûteuse requête HTTP. Bref, l'établissement de la grammaire de la liste échangée entre les deux modules doit être fixée une bonne fois pour toute de façon à rendre possible tous les cas.

Le choix Plus Grand Commun Diviseur

Une méthode consiste à ramener la totalité des données des biens disponibles, même si la fenêtre de sélection ne s'en sert pas pour sa responsabilité, car c'est le seul moyen d'être sûr que le client y trouvera bien tout ce qui est nécessaire.

Mais cette solution souffre elle aussi de nombreux problèmes : - Tout d'abord, ce n'est pas parce qu'on ramène tous les attributs possibles à un instant t que ces derniers ne vont pas bouger plus tard, avec l'augmentation des données disponibles. Il faudra alors revoir à nouveau le périmètre et donc la grammaire de la liste, et ce dans les deux modules en même temps sinon l'échange ne sera pas correct. Il reste donc un couplage temporel. - Ensuite, le fait de ramener toutes les données disponibles pour chaque bien n'est pas du tout efficace, et va complètement à l'encontre de l'objectif initial d'optimisation des performances. Si par exemple les biens embarquent de nombreuses photos en plus des attributs de type texte ou numérique et que, finalement, ni la fenêtre de sélection ni le client appelant ne les utilise, alors ce sera un gâchis de plusieurs mégaoctets. - Enfin, pour échanger des données en mémoire entre deux modules, il est nécessaire de définir un protocole. Dans le cas d'une Single Page Application, on peut mettre en place une gestion d'état, mais c'est une solution complexe pour traiter un cas aussi simple. De plus, pour une application web standard sans état, le mode de fonctionnement standard est de passer le contenu dans la requête pour la page suivante. Et pour cela, il serait donc nécessaire de sérialiser la liste des biens, la passer dans le POST (en mode GET, l'URL ne suffirait pas, surtout s'il y a des binaires à incorporer), puis désérialiser cette liste de l'autre côté. Bref, encore une fois, beaucoup de complexité pour une approche qui souhaitait optimiser en enlevant un appel HTTP.

En conclusion, l'approche "PGCD" de prendre tout ce qui peut être utilisé par l'un comme par l'autre des modules est complexe et peu performante.

Le choix Plus Petit Commun Multiple

Que nous reste-t-il comme solution ? Tout simplement l'approche inverse qui consiste à prendre le minimum possible de données permettant une compréhension commune entre les deux modules. C'est celle qui permet de réduire au maximum le couplage. Certes, il en reste un, mais c'est obligatoire puisque le client appelle la fenêtre et lui délègue une part de fonctionnalité : il y a donc une dépendance. Toutefois, le fait de réduire le plus possible, et donc d'échanger uniquement des identifiants (si possible normés sous forme d'URL, par exemple), est le couplage le plus réduit qui puisse se faire.

Certes, il nécessitera que l'appelant, muni de ces identifiants, rappelle le serveur HTTP pour recevoir les attributs qu'il souhaite sur ces biens. Mais, hormis le fait que nous avons vu plus haut que cet appel supplémentaire pouvait très bien se retrouver nécessaire, cette approche a de nombreux avantages : - Le principe de responsabilité unique est très bien respecté : le seul point d'accord entre le client et la fenêtre de sélection étant l'identifiant des biens (dont on peut imaginer la grande stabilité), chacun peut désormais évoluer à sa guise et choisir d'autres champs pour traiter les biens comme il l'entend, voire les rendre dynamiques, sans avoir le moindre risque d'impact sur l'autre module. - Si les données ont changé depuis l'affichage dans la sélection, le client obtiendra bien la donnée la plus fraîche possible, ce qui peut être important dans la suite des opérations. - Si le client décide que, finalement, il n'est pas nécessaire de montrer le détail des données choisies, mais uniquement leur nombre, il pourra supprimer tout appel web. De son côté, la fenêtre de sélection n'aura consommé que ce qui est nécessaire.

Résumé de la meilleure solution

Bien que cela paraisse à première vue non optimal, la meilleure solution est donc que la fenêtre de sélection charge des données pour obtenir le choix de l'utilisateur, renvoie seulement les identifiants des biens choisis à l'appelant et que ce dernier rappelle le serveur de données pour obtenir les informations qu'il souhaite sur ces biens.

Fonctionnement correct

Quelques objections / réflexions supplémentaires

Hypothèse du logiciel évolutif

A titre d'exhaustivité, il est nécessaire de souligner que nous sommes partis du principe qu'un logiciel évolue, ce qui est fondamentalement vrai dans l'énorme majorité des cas, les architectes logiciels et urbanistes utilisant souvent le proverbe comme quoi "le changement... c'est tout le temps !". Mais dans le cas - qu'on admettra rarissime - d'un logiciel dont on pourrait fixer le périmètre fonctionnel lors de la sortie - il serait effectivement possible de rester sur une grammaire commune d'échange des données qui serait ainsi garantie, et donc faciliterait le passage de l'ensemble des données sélectionnées. Il resterait toutefois les problèmes de sérialisation et de fraîcheur de données à régler...

Possibilité de cache

Revenons donc dans notre cas logique et supposons que, quelques secondes après que l'utilisateur ait manipulé la fenêtre de sélection et ramené des données du serveur, le client rappelle ces mêmes données pour son propre besoin. La situation pourrait paraître intéressante pour la mise en place d'un cache, mais encore une fois, attention à éviter toute optimisation prématurée. Seuls des tests de performance dans un contexte réaliste montreront si ce point est un goulet d'étranglement ou pas. Si d'autres parties de l'application réalisent des calculs plus complexes, il conviendra de porter les efforts sur ceux-ci plutôt que de perdre du temps et des ressources mémoire à mettre en place un cache sur une portion des échanges qui le mérite moins. Et là encore, il faut bien prendre en compte tous les aspects limitants dans la balance. Or, il est également proverbial que les plus grandes difficultés en programmation sont le nommage des variables... et la gestion de l'invalidation de cache !

Multi-sélection dans une API REST

Au lieu de mettre en place un cache, une action beaucoup plus intéressante pourra être réalisée en cas de gestion des sélections multiples. En effet, la plupart des API REST permettent de ramener des listes paginées ou bien une seule entité, mais il n'est pas si courant de voir des API qui implémentent des grammaires permettant des choix plus complexes.

Une solution à cette demande sera de mettre en place une grammaire Open Data Protocol permettant des requêtes du type https://.../api/biens/?$filter=or(id eq '0002', id eq '0005')). Au passage, ceci rendrait plus compliqué la gestion du cache dont nous avons parlé plus haut : elle devrait se faire en aval de la requête HTTP, ce qui réduirait sa portée, et ne rendrait vraiment utile l'approche que si la récupération des données en elle-même était très complexe. Ce n'est pas le cas s'il s'agit d'une simple requête en base de données, comme on peut s'y attendre dans ce type de situation.

Mais en tout cas, cela permet de ne pas multiplier les appels HTTP au serveur de données par le nombre d'entités demandés, ce qui - pour le coup - serait un vrai problème de surconsommation de données et ne relèverait pas de l'optimisation, mais tout simplement de l'absence de gaspillage (surtout qu'il y a un intérête supplémentaire pour la consistance de la donnée à toutes les récupérer en un seul coup plutôt que l'une après l'autre, avec le risque de désynchronisation).

Consistance de la donnée temporelle

Justement, cette problématique de synchronisation de la donnée pourrait amener à une objection de la part de quelqu'un qui verrait la soi-disant optimisation écartée plus haut comme une capacité à être sûr que la donnée consommée dans l'appelant soit exactement comme présentée à l'utilisateur lors de son choix.

Ceci part d'une bonne idée sur la consistance de la donnée et il y a en effet un risque - minime mais bien réel - que la donnée évolue entre sa sélection et son chargement dans la fiche. C'est tout le problème de la consistance de la donnée, mais il ne peut pas être géré de manière simple ici car nous ne sommes pas dans une base de données et la transactionnalité n'est pas disponible.

Alors effectivement, pourquoi ne pas transmettre la donnée sérialisée, ce qui permet d'être sûr qu'elle est dans son état correct ? Tout simplement car cela rend encore plus complexe les problèmes ci-dessus : si jamais la donnée est incomplète, il faudra faire un aller-retour au serveur, et le souci de consistance sera encore pire car une partie de la donnée sera 'à date' et une autre sera 'fraîche'. Et un autre problème se pose : que faire si l'application tombe en panne à ce moment-là et que la donnée est perdue ? Il faudra alors réaliser la sélection à nouveau.

En fait, la bonne façon de gérer cette problématique de consistance est différente. Le problème en est clairement un : il ne serait pas logique qu'une personne sélectionne un bien en fonction de son prix et qu'il voit celui-ci différent dans la fiche, car il a été modifié pile entretemps. Mais sa résolution nécessite une approche bien plus ambitieuse avec une réelle gestion des dates de valeurs. Si on souhaite ce type de consistance, alors l'utilisateur ne doit pas sélectionner un bien, mais un bien à une date de valeur qui lui est proposée. Et toutes les versions du bien, chacune à leur date de valeur, sont alors fixes et immuables, stockées dans une base de données qui ne persiste pas un simple état, mais un enchainement de modifications et d'état résultants, chacun avec une date de valeur, et permettant au final une traçabilité totale de la donnée.

Bref, on parle là d'un véritable référentiel pour ces données qui le méritent (car le coût en complexité de mise en oeuvre et en volume n'est pas anodin). Pour en revenir à notre sujet d'optimisation, on pourra alors objecter que la solution est très coûteuse par rapport à l'usage initial. Mais si la consistance forte de la donnée est un réel enjeu, alors un référentiel historisé est le seul moyen de traiter les nombreuses fonctionnalités qui en sont la conséquence (gestion de la traçabilité, capacité à revenir en arrière dans le temps, gestion simplifiée de la concurrence des écritures - sans verrou ni risque d'écrasement, cohérence a posteriori, etc.).

Conclusion

Comme nous l'avons vu, l'optimisation prématurée aurait pu causer des problèmes de couplage et, au final, dénaturer la performance du système. Attention à bien comprendre cet effet temporel : il ne s'agit pas de défendre que le principe de responsabilité unique est plus important que la performance ; ce n'est pas ce que dit le proverbe de Donald Knuth. C'est bien le fait de réaliser cette optimisation trop tôt qui pose problème.

La bonne approche est bien de se concentrer sur la fonctionnalité et le découplage (envoyer les identifiants seulement), et ensuite seulement de gérer les performances (cache si nécessaire, etc.). Dans notre exemple, s'occuper du découplage propre grâce à une liste d'échange ne contenant que des identifiants était l'essentiel et permettait - une fois cette problématique résolue - de s'occuper de l'optimisation. Si un cache était mis en place (encore une fois, attention de ne le faire qu'une fois le système entièrement développé et les tests de performance réalisés en production), c'est le diagramme de séquence ci-dessous qui serait alors mis en oeuvre, en veillant bien sûr à ce que le cache soit un module séparé, avec le moins de couplage possible avec les deux autres modules qui l'utiliseront :

Fonctionnement avec cache

links

social