13 juil. 2007

Refactoring des dépendances

Récemment, je suis tombé sur le site de Jason Gorman, consultant reconnu pour son expertise de l'architecture logicielle et des méthodes agiles[1]. Et sur son blog, j'ai découvert un test d'architecture logicielle qu'il utilise pour s'assurer de la qualité des aspirant architectes qui souhaitent travailler avec lui.

Notes

[1] et avec lequel je suis solidaire lorsqu'il donne sa vision de ce qu'est un architecte logiciel

Le test, tel que je le comprends, est simple: soit un certain nombre de classes réparties dans plusieurs packages. Si votre but est d'améliorer la maintenabilité de la solution, quel refactoring appliqueriez vous, pourquoi et quel serait le ou les bénéfices apportés par ces changements ?

package_problem.gif

Il y n'a qu'une seule limite imposée : les classes et les dépendances entres les classes ne doivent pas être modifiée - le but de cette restriction est de s'assurer que le comportement du système ne sera pas modifié. Histoire d'être honnête avec vous, je ne sais pas si cela implique qu'aucune classe ne peut être ajoutée à ce modèle - et sieur Gorman n'a pas encore daigné répondre à mon mail-réponse[1]. Soit. Je prends donc le parti de n'autoriser aucun ajout de classes, puisque c'est la règle que j'ai suivi lorsque j'ai proposé ma solution.

En soit, il s'agit là d'un problème intéressant - il y a plusieurs solutions possibles, mais toutes les solutions ne sont pas équivalentes en terme de maintenabilité.

On peut par exemple supprimer P3 en déplaçant la classe G dans P1 et H dans P2. On supprime ainsi un package et deux relations de dépendances. Et un package de moins à gérer, c'est toujours ça. Sauf que. Qui a dit que c'était une bonne chose ? Etant donné que nous avons juste déplacé des fichiers, la somme de code à maintenir est toujours la même. En quoi supprimer un lien de dépendance peut-il être bénéfique à un projet ? Est-ce toujours une bonne chose de réduire les dépendances entre les modules ?

Il y a deux réponses à cette question. Si par module vous entendez "classes" - et on vous pardonnera votre abus de langage - alors oui, supprimer les dépendances d'une classe envers les autres est dans la plupart des cas une bonne chose, à condition que votre architecture continue d'être cohérente et que votre modification ne signifie pas que les classes modifiées ne respectent plus le principe de responsabilité unique. La raison en est simple : en supprimant une dépendance entre deux classes, j'ai introduit un découplage supplémentaire qui me permet d'utiliser ces deux classes librement, sans avoir à me référer à l'autre lorsque j'utilise l'une. Le résultat est une proposition d'ouverture du code supplémentaire (une proposition d'ouverture car je peux peut-être utiliser ces classes dans un autre contexte, mais une proposition seulement car rien ne m'oblige à le faire).

Maintenant, si vous êtes à cheval sur la terminologie, vous employez certainement le mot "module" pour parler d'un package (au sens UML, c'est à dire un groupe de classes et de sous-packages). Dans ce cas, la réponse est "la plupart du temps, non, mais au final ça dépends surtout de conditions externes". Ainsi, si le module que vous souhaitez supprimer est référencé par d'autres modules, le problème risque d'être un brin épineux. Déplacer les classes et les services définis dans ce module peut créer d'autres dépendances qui seront peut être encore plus difficiles à gérer. Au niveau maintenance, le résultat peut être catastrophique, comme il peut être bon.

Dans ce cas, quelles sont les questions que je dois me poser avant d'effectuer ce refactoring ? Et nous tombons là dans le domaine de l'opinion, car je n'ai que peu de sources[2] pour corréler ce qui va suivre.

D'après mon expérience personnelle, il convient de se poser trois questions - l'ordre dans lequel je vous les livre n'est pas leur ordre d'importance.

  1. Que représente ce module dont je souhaite me débarrasser ?
  2. Est-ce que ça a du sens de déplacer telle ou telle classe de ce module dans tel ou tel autre module ?
  3. Qu'est-ce que j'y gagne, en fin de compte ?

La réponse à ses questions passe d'abord par un audit du code existant - qui fait quoi, pourquoi, comment, et quels sont les liens entre les classes de ce module à supprimer (appelons-le module source, pour plus de simplicité; les classes du module source sont alors les classes sources) ?

Ce que je considère comme étant un bon design est assez difficile à expliquer, mais tâchons de le faire quand même. Pour moi, un bon design respecte les 5 principes fondamentaux de l'architecture objet (déjà exposés sur ce blog dans des billets antérieurs, je ne reviens donc pas dessus). De plus, ce qui fait l'unité d'un module est la notion de domaine sémantique - les classes et services sont donc groupés par rapport à la fonctionnalité qu'ils offrent, en respectant bien sûr les niveaux d'abstraction. J'y ajoute généralement les classes qui ne sont pas accessibles à l'utilisateur - les détails d'implémentation.

Dans une telle organisation, une dépendance d'un module vers un autre a du sens - il s'agit pour un module d'utiliser un service particulier dans un domaine particulier, il n'a donc pas sa place ailleurs que là ou il se trouve. Si le petit jeu de Jason Gorman utilise les même concepts d'architecture, alors je suis au regret de lui dire que je ne peux rien faire - dans le cas contraire cela me forcerait à détruire l'unité des packages. Dans tous les cas, sans cette vision de domaine sémantique, je suis coincé - comment puis-je m'assurer que le refactoring sera viable si je n'ai aucune idée de ce que font les classes ?

Une fois définie norte manière de voir les choses, il devient plus aisé de répondre aux trois questions que j'ai posé ci-dessus:

  1. Que représente ce module dont je souhaite me débarrasser ?
    Si le module source n'a pas de sémantique propre - ce qui arrive bien plus souvent qu'on ne souhaite l'admettre - c'est qu'il n'a pas lieu d'exister. Les classes et les services n'ont que peu de liens entre eux, et il est de fait plus judicieux de casser ce module en plusieurs modules ayant une véritable sémantique. Si un ou plusieurs modules existant partagent des sémantiques similaires, on peut tenter d'intégrer nos nouveaux composants dans ces modules - ce qui m'amène au point suivant.
  2. Est-ce que ça a du sens de déplacer telle ou telle classe de ce module dans tel ou tel autre module ?
    Et bien, ça dépends encore une fois de la sémantique du package cible - même si on s'aperçoit qu'il est possible de contourner légèrement cette notion de sémantique. Si la sémantique de ma classe source s'inscrit dans celle de mon module cible, alors la réponse est "oui, dans la plupart des cas", dans la mesure ou le design global continue de respecter les qualités que j'ai énoncé plus haut. Si les sémantiques ne correspondent pas, pas de panique - est-ce qu'on peut considérer la classe source comme étant un détail d'implémentation du module cible ? C'est à dire, est-ce que une fois l'intégration effectuée, cette classe source fera partie du protocole du module cible ? Si vous répondez oui à la première question et non à la seconde, alors je dirais qu'il est conseillé de transférer la classe source dans le module cible - et même, dans un sous-package privé du module cible. Si votre réponse est différente, c'est que cette classe est bien là ou elle est, ou plus exactement qu'elle ne serait pas bien là ou vous voulez la mettre.
  3. Qu'est-ce que j'y gagne, en fin de compte ?
    La notion de gain, lorsqu'on parle d'architecture logicielle, se doit d'être interprétée avec la plus grande prudence. Un gain de productivité à court terme ne signifie pas qu'au final, le projet aura bénéficier de la modification - et bien souvent, c'est même l'inverse qui se produit. La question de la maintenance soit être soulevée, car c'est bien souvent dans les phases de maintenance (une fois que le cycle de développement produit arrive à son terme) que les modifications d'architecture se font le plus sentir: une mauvaise modification entrainera un surcout dans la correction des problèmes, tandis qu'une bonne modification aura tendance à diminuer ces couts, voire à diminuer le nombre des problèmes. Trouver une réponse à cette question est donc primordial, et pourtant c'est probablement la question à laquelle il est le plus difficile de répondre. Pourquoi ? Tout simplement parce nous ne connaissons pas à l'heure actuelle de métrique qui nous permette de calculer la maintenabilité d'un projet à priori : la seule chose que l'on sait faire, c'est chiffrer les couts de maintenance à postériori. C'est donc là qu'interviennent les qualités d'un architecte logiciel - et notamment son aptitude à juger de la qualité d'une architecture.

Pour conclure, je vous invite à me proposer vos solutions au problème posé par Jason Gorman. Je vous fait part des solutions que je lui ait proposé - mais pour lesquelles je n'ai aucun retour - afin de vous servir de base de discussion. Je ne prétends pas que ces solutions soient correctes, ou même que mon interprétation de leurs qualités soit correcte - à vous de juger !

Notes

[1] j'ai inclus ce mail dans les commentaires.

[2] lire: un peu moins de une

Commentaires

1. Le vendredi, juillet 13 2007, 22:14 par Emmanuel Deloget

Voici le coeur de la réponse que j'ai envoyé à Mr. Gorman - en Anglais dans le texte, mais je suis sûr que vous comprenez, n'est-ce pas ?

A possibility is to put G in P1 and H in P2, in order to have only two packages to maintain. Depending on the semantics of G and H, that can be interesting - code-wise - and that's a "big" improvement, which leads to the departure of one of the project managers - the one that was taking care of P3. So that's quite cost-saving. Well, even if he stays, that's one package less to maintain, and that can't be bad (although to be honest I would have hard time to figure out the real improvement value without knowing the class responsibilities). Let's say that it's a bit better than the original design.

But that's not the only possible refactoring. If we still don't consider the classes meaning (only their dependencies) and if we still don't change anything to the class hierarchy, we can group the dependencies in a way so that: P1 depends on P3, and P2 depends on P3 (thus, P1 and P2 doesn't share any dependency).

That means:

  • H and E in P2 (H and E share the same dependencies), depends on P3
  • G, D and F in P3
  • A, B and C can be left in P1, depends on P3

We still have 3 packages, but we only have dependencies between P1/P3 and P2/P3. But that's still only a bit better than the original design.

We still don't take much advantage of one important point: both A and F are abstract classes. We have the following dependency tree:

  B -> A,D; A -> C; C -> F,G

Let's try to do something with this : let's make a package PAbs that contains A and F.

A depends on C which then depends on F and G; we can then put G (which depends on nothing) either in PAbs or somewhere else, at the cost of introducing another dependency - for sure, we cannot put it in the same package as B since that would induce a circular reference. C will go in PAbs - it's a dependence of PAbs and it depends on PAbs.

B depends on A and D. D does not depend on anything, so we group B and D in the same package P1.

Both E and H depends on D and F. We can either put them in a separate package (P2), or we can put them in P1.

So in the end, we end up with:

P1: B, D, E, H, depends on PAbs
PAbs: A(a), C, G, F(a)

or

P1: B, D, depends on PAbs
P2: E, H, depends on PAbs
PAbs: A(a), C, G, F(a)

This refactoring is interesting because it decouples the abstract classes from the concrete ones (so we know respect RC Martin's DIP at the package level). Pabs is less subject to change than P1 and P2 (because, hey, abstraction is generally more abstract that concreteness...). P1 and P2 are a bit smaller, so that could really help their maintenance. It's quite hard to give numbers without a better in-depth explanation, but for sure this is (IMHO) a good improvement over the design you posted.

2. Le jeudi, septembre 9 2010, 10:08 par Christophe Quintard

Bonjour,
intéressant problème, même s'il n'est effectivement pas facile de réfléchir sur des classes abstraites.
Une bonne architecture consiste à découper l'application en modules pourvus d'interfaces stables. Ainsi l'implémentation peut changer, cela n'impacte pas les modules dépendants.
Voici à quoi je suis arrivé en ayant cet objectif en tête.
P1: BACG
P2 : EFH (interface F)
P3 : D (interface D)
Les dépendances de modules sont : P1 -> P2,P3 et P2 -> P3.
Le module P2 a pour interface une classe abstraite, ce qui est préférable. Le module P3 ne contient qu'une classe qui est appelée directement, cela n'est pas choquant, il peut s'agir d'une classe utilitaire.

On peut aussi sortir B et aboutir à une solution avec quatre modules :
P1: B
P2: ACG (interface A)
P3 : EFH (interface F)
P4 : D (interface D)
Les dépendances de modules sont : P1 -> P2,P4 ; P2 -> P3 et P3 -> P4
Cette solution a pour mérite de justifier pleinement la présence des classes abstraites A et F en les mettant interface des modules P2 et P3. Le module P1 ne contient que B, qui peut être vue comme la classe principale de notre application.
J'irai presque jusqu'à oser une interprétation du type : P1=classe principale (avec le main), P2=interface graphique, P3=modèle de données, P4=utilitaires.

3. Le jeudi, septembre 9 2010, 17:33 par Emmanuel Deloget

C'est une approche et un découpage très intéressant. Il faudra que je pense à reprendre en partie ce problème et les solutions proposées dans une série d'articles à venir sur les packaging principles.

En tout cas, merci pour cette intervention.

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet