Quand un élément logiciel (méthode, bibliothèque, programme exécutable…) expose une interface programmatique (une API), les personnes qui l’utilisent s’attendent à une certaine compatibilité lorsque cet élément logiciel est mis à jour.
Cela signifie qu’en principe on s’attend à ce qu’aucune partie existante de cette interface ne soit modifiée ou supprimée.
Cet engagement peut-être formel, lorsque par exemple l’élément logiciel est fourni dans le cadre d’un contrat avec un éditeur qui garantit la compatibilité, ou complètement informel quand il s’agit d’un outil développé par une personne sur son temps libre et distribué gratuitement sous une licence libre.
Lorsqu’une incompatibilité est détecté, le niveau de frustration ne dépend pas forcément du niveau d’engagement. Quiconque décide de partager sur internet du code développé pour son usage propre prend le risque d’avoir à affronter le mécontentement d’une personne ayant décidé d’utiliser ce code et ayant rencontré un problème.
L’implicite et l’explicite
On s’attend à ce que la compatibilité couvre toutes les facettes d’une API, qu’elles soient implicites ou explicites.
Ainsi corriger un message d’erreur peut casser des scripts qui s’appuient sur le contenu des messages pour déclencher des comportements. Le fait qu’un code d’erreur soit fourni, et que la documentation indique de s’appuyer sur le code et pas sur le message quand la compatibilité est en jeu n’empêchera ni que le message d’erreur soit quand même considéré comme une API stable, ni que les personnes dont le code ne fonctionnera plus à cause de ce changement de se sentir autant trahies que s’il s’agissait d’un problème d’incompatibilité sur un élément “sous garantie”.
Un autre exemple est celui de l’ordre des données : quand des informations suivent un certain ordre relativement constant, même s’il n’est pas officiellement garanti ou si très officiellement cet ordre n’est pas garanti, tout changement dans cet ordre peut causer des problèmes.
J’ai déjà vu cela se produire pour l’ordre des entêtes dans des échanges HTTP ou des résultats de requêtes SQL (qui sont souvent par défaut plus ou moins triées par ordre d’insertion).
Dès qu’un comportement reste constant un certain temps, on s’attend à ce qu’il continue à l’être. Plus cette stabilité perdure, plus le sentiment qu’une forme d’engagement existe devient fort.
Windows et le C
Deux exemples opposés — et qui ont chacun une influence importante sur le monde de l’informatique — illustrent la manière dont il est possible de prendre en compte l’implicite et l’explicite dans les contrats.
Le premier est Windows, lors du développement de nouvelles versions, Microsoft dépense une énergie importante pour assurer la compatibilité des programmes existants. Leur objectif n’est pas seulement de continuer à faire fonctionner les programmes qui respectent les règles officielles, mais que cela soit aussi le cas pour les programmes qui ne les respectent pas.
Raymond Chen poste régulièrement sur son blog de nombreux articles sur des contournements qui doivent être mis en œuvre pour cela.
Windows contient ainsi un nombre important de modules détectant des comportements incorrects connus de tel ou tel programme et appliquant des rustines simulant le comportement attendu.
L’idée de Microsoft est que si une application fonctionne sur une version de Windows et ne fonctionne plus sur la suivante, la personne se retrouvant avec une application qui crashe sera mécontente de Microsoft et pas de l’éditeur de l’application, même si la faute est du côté de l’application.
Le deuxième exemple est celui des compilateurs C. Dans le langage C, quand quelque chose n’est pas explicitement défini dans la spécification, le programme est libre de faire ce qu’il a envie. C’est ce qu’on appelle des comportements indéfinis.
Exploiter ces comportements est vital pour que les programmes s’exécutent rapidement. Pour améliorer les performances, les compilateurs C peuvent être tentés de modifier la manière dont ils implémentent certains comportements indéfinis, modifiant ainsi le comportement des programmes qui les utilisent.
Cela tourne parfois à la discussion légaliste ou les personnes dissèquent la spécification en pesant chaque mot pour savoir si quelque chose est un comportement indéfini ou pas, et donc s’il est permis de faire planter des programmes qui jusqu’alors fonctionnent. Ces évolutions sont parfois source de controverses quand les personnes développant des logiciels s’opposent à certains changements qui n’apportent que des gains marginaux tout en rendant le comportement du code moins prévisible, même si celui-ci est conforme aux règles officielles. LWN a quelques articles à ce sujet.
Les échanges sur la frontière entre compatibilité explicite (et donc à prendre en compte) et implicite (qu’il est donc permis de ne pas respecter, quitte à fâcher des personnes) est donc un enjeu important car il ne s’agit pas seulement de temps à passer pour corriger des bugs de compatibilité, mais d’être en capacité d’améliorer la performance des logiciels.
Réduire le périmètre
Le fait de vouloir réduire au minimum le périmètre d’une API, et donc le périmètre couvert par les garanties de compatibilité est donc parfaitement logique : cela évite de casser les logiciels qui utilisent votre code, et évite d’avoir à faire aux personnes dont le logiciel a été cassé.
Ce comportement peut s’observer dans certains composants qui préfèrent fournir des APIs les plus élémentaires possibles, alors que d’autres fourniront en plus des APIs plus fournies et qui rendent l’utilisation du composant plus simple, au prix d’une augmentation de la surface de l’API.
C’est l’un des avantages parfois sous-estimé de REST : les choix techniques et de pratiques de REST lui permettent d’avoir très peu d’implicite, et donc de pouvoir limiter le risque d’une incompatibilité par accident.
Jusqu’au jour où vous recevez un rapport d’erreur car vous avez changé l’ordre des attributs dans un objet JSON.
Faciliter, au risque d’être pris au piège
La loi de Postel m’a toujours fascinée, souvent citée dans les articles sur le design d’API elle stipule :
Soyez conservateur dans ce que vous faites et libéral dans ce que vous acceptez.
L’idée est qu’il est souhaitable de faciliter la vie des personnes qui utilisent votre API en essayant de faire en sorte qu’elle soit tolérante dans sa gestion des paramètres.
Cette tolérance porte le risque d’aboutir à une API très riche et dont la compatibilité sera difficile à maintenir.
Dans un cas typique, le comportement du logiciel peut se mettre à dépendre de la manière dont le langage dans lequel le système est implémenté transforme les types de données les uns dans les autres, alors que cela n’est pas forcément souhaitable, par exemple quand l’API exposée est une API réseau.
Par exemple si vous acceptez des nombres sous forme de texte, qui aura l’effet d’intégrer à votre API les règles de conversion de votre système, ainsi un nombre commençant par un zéro est parfois interprété comme octal ce qui peut arriver lorsqu’on manipule des nombres comme du texte mais qui est très rare dans d’autres cas.
Si la loi de Postel peut simplifier les choses au départ, elle peut augmenter le coût de maintenance mais aussi le risque d’introduire des incompatibilités accidentelles lors des évolutions.
Je pense que dans de nombreux cas, une meilleure manière de rendre une API facile d’emploi est d’avoir une gestion d’erreur qui aide les personnes à identifier et corriger leurs problèmes. Ainsi quand un paramètre devrait être un nombre, plutôt que d’être arrangeant·e et d’accepter également du texte, il est possible de remonter une erreur indiquant “le paramètre XXX est un texte au lieu d’un nombre”.
Bug ou pas bug ?
On l’a vu, la compatibilité est quelque chose de délicat parfois soumis à interprétation.
Ainsi, chaque fois qu’on corrige quelque chose qu’on voit comme un bug dans du code exposant une API, on prend le risque de casser du code qui — volontairement ou involontairement — s’appuyait sur l’existence de ce bug.
En allant jusqu’au bout de ce raisonnement, chaque correction introduit une forme d’incompatibilité.
La gestion sémantique de version, qui propose une manière de nommer les versions de logiciels en fonction du type de changement utilisé par de nombreux projets et qui parle de “corrections d’anomalies rétrocompatibles” est — de ce point de vue — inapplicable.
En revenant sur ce qu’on a dit plus haut, une correction d’anomalie ne peut être rétrocompatible que si on ne prend en compte que la zone d’engagement explicite, la compatibilité des bugs étant un engagement implicite.
Je ne veux pas dire que la gestion sémantique de version ne sert à rien, mais qu’il ne faut pas se tromper sur son utilité et sur ses limites.
En conclusion
J’espère vous avoir donné quelques outils pour mieux réfléchir aux questions de compatibilité, et pour prendre de meilleures décisions à ce sujet.
J’espère aussi vous avoir convaincu de l’importance de tenter de limiter à la fois la taille de la frontière de vos APIs et la part d’implicite dans vos contrats.