Dans un projet logiciel, la gestion des dépendances en cadriciels et en bibliothèques tierces peut devenir, avec le temps, une véritable galère car avec celles-ci sont aussi tirées leurs propres dépendances qui, à leur tour, s’accompagnent aussi de leurs dépendances et ainsi de suite ; ces dépendances que l’on qualifie de transitives. Le cauchemar commence quand un tel graphe de dépendances est subit et n’est plus maîtrisé. Mais qu’est ce qui fait que les dépendances d’un projet peuvent devenir un enfer pour ses développeurs ?
Dans tout nouveau projet logiciel, l’important est de créer les fonctionnalités attendues et ceci dans un temps contraint. Aussi, pour maintenir une productivité soutenue, les équipes de développement choisissent en général d’abord un ou plusieurs cadriciels sur lesquels l’architecture du logiciel va pouvoir s’inscrire (Spring Framework, Apache Camel, etc.). Puis, pour éviter de réinventer la roue (en tout cas c’est la justification première qui en est donnée), diverses dépendances en bibliothèques tierces sont choisies pour aller encore plus vite dans la programmation, même si une seule minime partie du code de celles-ci est exploitée. Ainsi, par exemple, dans l’écosystème Java, pour une application ou un (micro-)service Web, SpringBoot est le socle architectural préféré par une grande majorité de développeurs avec ses nombreux composants prêt à emplois (persistence, MVC, sécurité, etc.) et sa qualité d’intégration de technologies tierces. De plus, souvent, les bibliothèques Apache (les fameux Apache Commons), voir celles de Google (Guava par exemple) sont aussi utilisées. Construire son application sur un cadriciel implique aussi de tirer ses nombreuses dépendances comme, par exemple, Hibernate en tant qu’implémentation de JPA. Et, de plus, pour profiter de fonctionnalités particulières, sont aussi choisis des briques logicielles tierces sur étagère qui font, peu ou prou, ce qui est demandé … avec leur propre lot de dépendances.
Jusqu’ici, il n’y a rien de surprenant. Les grandes avancées en génie logiciel ont toutes eu pour objectif de simplifier et de faciliter la conception de solution à des exigences techniques et fonctionnelles pouvant être complexes. Et les techniques propres aux languages ainsi que celles apportées par les cadriciels répondent à cet objectif. Et lorsqu’il existe des composants logiciels qui fournissent des fonctionnalités attendues (ou des parties de celles-ci), pourquoi perdre du temps à l’écrire soit-même ? Puis, avec le temps, de nouveaux besoins émergent immanquablement. Le plus souvent, ceux-ci sont l’expression de nouveaux clients. Ces nouvelles exigences se traduisent par l’ajout de nouvelles fonctionnalités ou l’évolution de celles existantes, impliquant inévitablement l’introduction de nouvelles dépendances sur du code tierce (avec là aussi leur propre lot de dépendances par transitivité). Le projet s’enrichit ainsi, peu à peu, d’un graphe de dépendances pouvant devenir conséquent. Et ceci d’autant plus que certains projets tierces dépendent d’une ou plusieurs bibliothèques pour, in fine, n’utiliser qu’une ou deux classes, classes qu’ils auraient pu écrire eux même (par exemple tirer tout Apache Commons Lang pour juste StringUtils). Et même si nous ne voulons pas de ces bibiothèques, il va falloir se les farcir ; le pire étant qu’elles finissent par être utilisées par de nouveaux développeurs, parce que présentes via leur IDE, créant ainsi un lien fort sur une dépendance à l’origine transitive ; le contrôle des dépendances transitives commence alors à nous échapper ; si un jour celle-ci disparaît par transitivé, que fait-on ?
Avec l’utilisation de nouveaux composants tierces il va falloir gérer leur graphe de dépendances à la fois directes et transitives parce que certaines d’entre elles peuvent entrer en conflit avec d’autres versions d’elles mêmes tirées ailleurs dans le projet. Et même si, à l’exécution, il n’y a pas de problèmes apparents, il est plus bien plus sûr de se restreindre à une seule version d’un composant. L’analyse du graphe des dépendances n’est pas restreint au seul nouveau composant tierce, mais doit s’appliquer aussi, par précaution, avec la mise à jour d’un composant déjà existant dans le projet. Il arrive en effet que des nouvelles versions majeurs d’une brique logicielle revoient leurs dépendances (exemple de Tika). De plus, lorsque cette dernière est conséquente, le parcours de son graphe de dépendances va nécessiter du temps et de l’attention afin de détecter correctement les dépendances qui sont requises, et celles qui, parmi les optionnelles, sont utilisées (soit par le projet, soit par une autre dépendance). Il y a aussi des versions qui peuvent entrer en conflit avec l’environnement de build du logiciel ; par exemple telle version requière Java 17 a minima, mais une autre dépendance, dont il n’existe pas de nouvelles versions, dépend de Java 8 et ne s’exécute pas correctement avec Java 17 (par exemple, à cause de la rétrospection incompatible avec Java 17). Ou encore des montées en versions majeurs qui conduisent à des régressions.
A chaque situation, des stratégies différentes peuvent être appliquées et celles-ci dépendent grandement du contexte du projet. Pour un conflit de version, souvent, avec des outils de build comme Maven, des dépendances transitives peuvent être exclues pour ne considérer que des versions précises de celles-ci (et il est bien de laisser dans le descripteur de build une indication précise à ce sujet). Il arrive toutefois que cela ne soit pas suffisant : il y a incompatibilité avec une autre version de la même dépendance tirée ailleurs dans le projet. Dans un tel cas, plusieurs solutions existent :
- la mise à niveau ou l’introduction du composant qui induit ce blocage est stoppée,
- il existe une version des autres dépendances du projet qui permet de résoudre le conflit : soit la dépendance transitive source du conflit n’y est plus tirée, soit elle est dans une version compatible d’avec celle du composant introduit ou mis à jour,
- le code du composant concerné, si c’est un projet open-source, est explicitement modifié pour être compatible avec la version courante de la dépendance dans le projet,
- il existe un autre composant qui implémente le même besoin que celui qui pose problème, ce qui peut induire à devoir modifier le code du projet.
Il peut être préférable aussi, si les délais ne sont pas pressants, de coder soit même le composant logiciel : selon la stratégie de l’entité (la division au sein de l’entreprise, l’éditeur de logiciel, etc.), il peut être en effet pertinent de vouloir coder soit même le ou les composants sous-tendant la fonctionnalité. Ceci implique de gérer aussi le cycle de vie de ces composants et par conséquent de leur modernisation dans le temps mais ils permettent, en général, de mieux contrôler les modifications et les dépendances nécessaires.
Si des conflits dans le projet du fait de versions différentes d’une même dépendance peuvent surgir, le pire reste, avec la montée en versions majeurs de dépendances importantes, de tomber sur des régressions : changement de comportement, modification de syntaxe ou encore disparition de code ou de bout d’API pour lequel il n’existe pas de remplacement simple ou immédiat. Les régressions surviennent le plus souvent avec la mise à jour des cadriciels. En effet, ces derniers sont en général pensés pour faciliter le développement initial d’applications et pas nécessairement pour leur cycle de vie. Qui, un jour, lors de la migration du code conséquent de son application sur la nouvelle version majeure d’un cadriciel, n’a pas maudit ce dernier. Et en particulier lorsque des parties utilisées des API ou, pire, des sous-composants entiers, ont disparues ou changées ? Et là c’est le drame. (Spring Social avec ses connecteurs vers les réseaux sociaux connus vous parle ?)
Normalement, ces derniers cas devraient arriver rarement si un soin particulier a été pris de suivre assidûment chaque nouvelle version puisqu’alors, à chaque montée, le code peut être adapté petit à petit aux modifications futures indiquées (code déprécié avant suppression par exemple). Mais voilà, dans la réalité, ce suivi des montées en version n’est pas fréquent et ceci pour diverses raisons. L’une d’elle est la récurrence frénétique avec laquelle certains projets open-source sortent une nouvelle version majeur, comme s’il fallait parader une certaines vigueur et vivacité. (Et le langage Java n’est pas exempte de ce défaut.) Une autre raison est l’incompatibilité de la nouvelle version avec des dépendances actuelles du projet et pour lesquelles il n’existe pas de mises à jour (dépendances plus maintenues par exemple). La dernière raison, qui d’ailleurs chapeaute toutes les autres, est que l’équipe de développement a d’autres priorités et celles-ci sont avant tout de répondre aux besoins et d’assurer la statibilité du logiciel ; c’est pourquoi le passage des dépendances à leur dernière version ne se fait la plupart du temps que si nécessaire (comme la découverte de CVE impactants).
Il peut exister toutefois de la documentation ou des outils suffisants (ce qui est rare) pour faciliter la migration vers la toute dernière version. Ceci est en fait un phénomène assez récent dans l’histoire de l’industrie du développement logiciel. Auparavant, les applications évoluaient peu et le choix de la réécriture sur des briques logicielles plus modernes, à terme, se posait plus facilement. Il est toutefois plus coûteux de réécrire un logiciel que d’assurer son évolution continue. D’autant plus de nos jours où il y a pléthore de bibliothèques et de cadriciels. Cela a nécessité un changement de paradigme dans l’industrie logicielle. Aussi passer à des versions plus récentes le cadriciel et les bibliothèques sous-jacents à l’application fait partie désormais du cycle de sa longue vie. Ce sont les phases de modernisation du logiciel. Autant dire que ce n’est plus aussi rare. S’il n’y pas de documentations ou d’outils, ou encore si ces derniers ne sont pas exhaustifs, les régressions sont en général identifiées à la compilation du projet ou, dans le cas de changements de comportement, à l’exécution des tests. (C’est pourquoi il est important de couvrir l’application de tests, qu’ils soient unitaires, d’intégration ou fonctionnelles.)
Quoiqu’il en soit, il n’y a pas d’échappatoire, il va falloir modifier le code de l’application. Certains projets sont assez stables dans le temps et il est rare par conséquent de rencontrer de telles régressions. D’autres, par contre, sont malheureusement connus pour, à chaque version majeur, introduire des changements impactants. Je peux citer Hibernate comme exemple. Dans Silverpeas, il y a deux implémentations de la persistance : l’une, originelle, est construite sur des DAO qui utilisent directement l’API JDBC, l’autre, plus récente, est basée sur JPA avec Hibernate comme implémentation. Et devinez quoi ? Nous n’avons jamais eu à retoucher la partie JDBC (si ce n’est pour moderniser certaines DAO). Quant à la partie JPA, nous avons du souvent revoir, entre autre, les instructions JPQL. En fait, il s’avère que, de par notre expérience, ce sont les parties faites maison qui sont les plus stables dans le temps ; si elles doivent être retouchées, modernisées, elles le sont suite à un choix décidé et cadré – au contraire des mises à niveau de dépendances dont les conséquences sont en général subies. C’est pourquoi il n’est pas étonnant que des éditeurs de progiciels priviligient souvent le «fait maison» ; en fait il est fréquemment mis en confrontation avec du «sur étagère» : quel est le coût sur le temps de tel produit au regard de ce qu’il apporte ? Parfois, il vaut mieux dépendre d’un composant tierce, d’autre fois il est plus raisonnable de l’écrire soit même (mais être prêt à revoir son fusil d’épaules). Par contre, les sociétés de services en général préfèrent tabler sur des briques logicielles tierces ; leurs besoins et comment elles adressent le marché n’est pas le même que ceux des éditeurs.
Reste à adresser les parties de code qui ont disparues suite à une mise à jour ou encore la nécessité de remplacer une dépendance qui n’évolue plus (et qui entre en conflit avec des nouvelles versions d’autres dépendances). Pour le premier cas, il suffit de chercher s’il y a un code de remplacement déjà prévu dans la dépendance (ce qui est souvent le cas). Sinon, il va falloir faire de l’archéologie en vue de découvrir comment était implémenté ce qui a été retiré et dans quelle mesure il peut-être réécrit avec le reste des API. Si c’est un projet open-source, rien de rédhibitoire. Dans le cas contraire, bon courage ; il va falloir y aller par tatonnement. Pour le second cas, comme écrit précédemment, le plus viable est de rechercher un composant tierce qui satisfait le même besoin. Sinon, selon le code utilisé de la dépendance dépréciée, il peut être plus simple de le réécrire. Au mieux, le projet, s’il est open-source, peut être récupéré et repris en main en interne et ainsi assurer qu’il n’entre plus en conflits avec les autres dépendances du projet. Ces derniers cas, on le voit, sont ceux qui nécessitent le plus de modifications. Heureusement, en général, ils sont rares.
Que pouvons nous conclure ? De ne pas négliger l’impact dans le temps des dépendances de vos projets. Même si vous n’intervenez qu’au début de celui-ci, que vous soyez chef de projet, responsable produit ou encore développeur, pensez à ceux qui devront se coltiner sa maintenance et son évolution. Ce n’est pas parce que vous êtes contraint dans le temps que vous devriez sauter à la moindre circonstance sur une bibliothèque ou un cadriciel tierce pour vous éviter d’écrire le code nécessaire. Sachez que de toute manière le projet ne finira pas dans les temps ; c’est un jeu de dupe qui risque de se payer cher des années après. Parfois même, la brique logicielle peut ne pas être pertinente au regard du contexte du projet. Je me souviens de l’exemple d’un projet d’IoT chez Orange Labs pour lequel l’équipe a naturellement, par habitude, utilisé Spring Framework, choix qui a été remis en cause à temps parce qu’il ne répondait pas aux objectifs de performance.
Avant de tirer une dépendance, demandez vous déjà du coût en complexité qu’elle ajoute au projet au regard de sa valeur ajoutée ; quelles sont ces dépendances (et celles transitives) ? Qu’est ce qu’elle ajoute en structures de code au regard du problème adressé ? Quelle solution elle offre ? Prenons l’exemple de Spring Data pour JPA. A mes yeux, si c’est juste pour faciliter l’écriture de repositories JPA, ce dernier est sudimensionné : la complexité de son code (parce qu’il adresse un grand ensemble d’usages) et les dépendances qu’il tire sont disporportionnées. Pour avoir écrit un POC sur le sujet, s’appuyer sur les Annotation Processors de Java pour proposer soit même une API de persistance facilitant l’utilisation de JPA est plutôt simple (si tant est que JPA nécessite un tel projet).
La même question peut se poser sur les bibliothèques utilitaires (comme par exemle les Apache Commons), bien qu’en général celles-ci sont bien moins intrusives que les autres. Si c’est juste pour utiliser un nombre très restreint de code, 2 ou 3 classes, posez vous la question s’il ne vaut mieux pas l’écrire vous même, quitte à vous inspirez du code de ladite bibliothèque. Quoiqu’il en soit, surtout éviter de tirer des dépendances différentes qui font peu ou prou la même chose : par exemple Apache Collections et Guava. Pareil avec les cadriciels : choisissez Google Guice, Spring Core ou Jakarta EE, mais pas deux ou les trois.
Ensuite, demandez vous si le besoin adressé est stratégique ou non à l’application (ou à l’entreprise) ; si c’est le cas, vaut mieux tabler sur du «fait maison». Si toutefois le temps est par trop contraint ou qu’une équipe dédiée ne peut être montée dans l’immédiat pour ça, alors enveloppez la dépendance avec votre propre API, ce qui permettra à terme de pouvoir changer d’implémentation sans trop d’impacts sur le code de l’application. Quoiqu’il en soit, évitez dans la mesure du possible à ce que le code de l’application, et en particulier celui métier, s’appuie directement sur du code tierce : utilisez de préférence vos propres abstractions et faites en sorte que ce soit leurs implémentations qui s’appuient sur les dépendances.