
Il a été beaucoup de discussions dans le monde du génie logiciel sur les problèmes associés à la POO et spécifiquement à l'état mutable. La programmation fonctionnelle sans effets secondaires est souvent présentée comme une solution. Bien qu'il existe quelques points saillants sur la mutabilité, les véritables problèmes proviennent de l'utilisation naïve d'objets implicitement mutables. S'il est effectué correctement, l'état mutable peut être un outil utile pour raisonner sur le code et peut guider les futurs développeurs de manière naturelle et transparente vers des modifications correctes du code. Dans cet article, nous partagerons certains de nos maux de tête qui ont conduit à un principe de conception de classe : utiliser des types pour indiquer l'état des objets.
Collaborateurs avec état
Commençons par un exemple. Chez Rescale, nous utilisons une API REST pour garder une trace des métadonnées relatives à une tâche. Nos nœuds de travail interrogent l'API pour obtenir des informations sur le matériel virtuel à démarrer, et nos nœuds de cluster l'interrogent pour obtenir des informations sur l'analyse à exécuter. Tous deux s'authentifient en transmettant un jeton crypté. Notre première interface client ressemblait à ceci :
interface publique JobMetadataClient { Analyse getAnalysis (long jobId, informations d'identification); CoreSummary getCoreSummary (long jobId, informations d'identification); ... }
Cette interface était efficace pour garantir que nous transmettions toujours les informations d'identification correctes à chaque demande, mais elle était un peu lourde. Alors que nous refactorisions l'ancien code pour utiliser le nouveau client API, nous avons dû créer des informations d'identification pour chaque appel de méthode. Chaque travailleur assume une tâche pour un travail et effectuera ensuite de nombreuses requêtes en utilisant le jeton d'authentification de ce travail. Nous devions donc constamment créer des objets d'identification identiques ou en transmettre un. Il était donc difficile de s'appuyer sur l'API autant que nous le souhaitions.
Notre pensée suivante était que nous pourrions demander à un travailleur de définir les informations d'identification sur son client API une fois, lorsqu'il exécute une tâche pour un travail, puis de laisser tous les objets d'assistance utiliser le client en supposant qu'il avait déjà obtenu ses informations d'identification. L'interface ressemblait alors à ceci :
interface publique JobMetadataClient { void setCredentials (informations d'identification); Analyse getAnalysis(long jobId); CoreSummary getCoreSummary (long jobId); ... }
La plupart des codes appelants peuvent désormais simplement appeler les méthodes de requête sans se soucier des informations d'identification. Cela a permis d'atteindre l'objectif de rendre beaucoup plus facile l'utilisation des méthodes client de l'API, mais chaque fois que nous introduisions le client dans une nouvelle zone de la base de code, nous oubliions de définir les informations d'identification. Cela nous a également amené à écrire du code qui faisait des hypothèses implicites sur le contexte dans lequel il s'exécutait, et qui était donc moins réutilisable et plus fragile. Les modifications apportées à une partie du système risquaient de briser d’autres parties, ce qui est l’une des principales choses à éviter dans les systèmes logiciels.
La première mise en œuvre est ce que j’appellerai un collaborateur à État unique. Il ne s'agit que d'un ensemble de méthodes, et les développeurs peuvent facilement raisonner sur son comportement lorsqu'ils disposent d'une instance. La seconde ne rend pas les choses aussi évidentes car elle comporte un changement d’état implicite. Si un développeur met la main sur une instance, il voit qu'il peut appeler des méthodes de requête et ces méthodes fonctionneront probablement. Comprendre l'état d'authentification nécessite plus de connaissances du système.
Il existe une meilleure conception qui exploite le typage statique pour rendre l'état d'authentification immédiatement visible pour les futurs développeurs. Nous pouvons revenir à notre première interface client et exiger des informations d'identification à chaque appel de méthode, mais également fournir une classe wrapper dont le type indique son état:
interface publique JobMetadataClient { Analyse getAnalysis (long jobId, informations d'identification); CoreSummary getCoreSummary (long jobId, informations d'identification); ... } ... public class AuthenticatedMetadataClient { private final Credentials informations d'identification ; privé final JobMetadataClient metadataClient ; public Analysis getAnalysis(long jobId) { return this.metadataClient.getAnalysis(jobId, this.credentials); } ... }
Désormais, si un développeur travaille sur une classe qui a une instance de AuthenticatedMetadataClient en tant que collaborateur, il sait avec certitude qu'elle dispose d'une authentification et qu'il ne la perdra pas. Si nous écrivons de nouvelles classes qui prennent une instance de AuthenticatedMetadataClient dans leurs constructeurs, ces classes ne peuvent être utilisées que lorsque l'authentification a déjà été fournie. Les futurs développeurs verront dans la classe ce qu'ils peuvent faire avec les objets clients, et leurs IDE suggéreront des méthodes appropriées. Ils n’auront pas besoin de garder en tête autant d’informations sur l’ensemble du système pour en raisonner sur certaines parties. Ce sont des outils puissants pour travailler dans la base de code.
Accumulation d'état en mémoire
C'était bien pour un client API, mais cette classe ne l'était pas vraiment. need pour changer d'état car l'état réel est conservé dans l'API. Qu’en est-il lorsque nous voulons accumuler les changements d’état en mémoire avant de les conserver ? Prenons un autre exemple de la base de code de Rescale. Nous utilisons un logiciel d'optimisation qui exécute une analyse avec des valeurs variables pour les paramètres initiaux et sélectionne un résultat « optimal ». Nous représentons ce flux de travail avec une classe, par exemple CaseWorkflow, qui contiendra les valeurs des paramètres initiaux pour une exécution optimale une fois déterminée. Nous voulons conserver ces valeurs une fois que tout sera terminé.
Nous avions donc initialement un code très impératif qui effectuait toutes les actions d'initialisation et de nettoyage en une seule méthode :
public void runWorkflow (flux de travail CaseWorkflow) { doSomeInitialization1(); doSomeInitialization2(); for(InitialParameters initialParamters : workflow.getParams()) { //exécute les paramètres et les définit sur le workflow s'ils sont optimaux } doSomeCleanup1(); doSomeCleanup2(); //la ligne importante pour cet exemple persistOptimalParameters(workflow.getOptimalParameters()); }
Nous avons décidé de refactoriser cela en utilisant auditeurs du cycle de vie pour séparer les responsabilités et rendre le code plus facile à comprendre et à tester unitairement. Nous avons écrit une interface comme celle-ci :
interface publique WorkflowLifecycleListener { void notifyWorkflowStarting (flux de travail CaseWorkflow); void notifyParamtersRun(IntialParameters initialParameters); void notifyWorkflowCompleted(); }
Et refactorisé la méthode originale pour utiliser ces écouteurs :
public void run (workflow CaseWorkflow, usine ListenerFactory) { Collection lifecycleListener = factory.createListeners (workflow); for (écouteur WorkflowLifecycleListener : lifecycleListeners) { écouteur.notifyWorkflowStarting (workflow); } for(InitialParameters initialParamters : workflow.getParams()) { //paramètres de processus pour(WorkflowLifecycleListener listening : lifecycleListeners) { Listener.notifyParametersRun(initialParameters); } } for (écouteur WorkflowLifecycleListener : lifecycleListeners) { Listener.notifyWorkflowCompleted(); } }
Nous avons utilisé un objet d'usine parce que nous voulions un ensemble différent d'écouteurs pour différents types de flux de travail, mais ce n'est pas pertinent ici. Ce qui est pertinent, c'est que chaque objet d'écoute a été créé dans une portée et lié à un seul flux de travail. C'est pourquoi l'auditeur suivant avait du sens à l'époque :
classe publique PersistOptimalParametersListener implémente WorkflowLifecycleListener { private final InitialParameters optimalParameters ; public PersistOptimalParametersListener (workflow CaseWorkflow) { this.optimalParamters = workflow.getOptimalParameters(); } @Override public void notifyWorkflowStarting(flux de travail CaseWorkflow) { } @Override public void notifyParamtersRun(IntialParameters params) { } @Override void notifyWorkflowCompleted() { persistOptimalParameters(this.optimalParameters); } }
Après toutes ces explications, l'erreur semble évidente : les paramètres optimaux ne sont pas définis sur le workflow au moment où cet écouteur sera instancié. À cette époque, il ne s'agit que d'une collection vide, mais en pleine refactorisation, il est facile d'oublier cela. Garder cela à l’esprit nécessite beaucoup de contexte sur l’ensemble du système d’optimisation. Nous nous sommes demandé : pourrions-nous utiliser des types plus fins pour éviter cette erreur et communiquer l’état du flux de travail aux futurs développeurs ? Oui.
Un problème clé ici était l’utilisation des méthodes getter et setter qui sont courantes en Java. Une classe qui possède une méthode getOptimalParameters n’indique pas au développeur quand cette méthode peut être appelée de manière appropriée. Cette classe utilise des changements d'état implicites, comme notre client API qui permettait de définir des informations d'identification sur lui-même. Au lieu de cela, nous devrions écrire les objets de manière à ce qu'ils n'aient pas du tout ces méthodes s'il n'est pas approprié de les appeler :
classe publique CaseWorkflow { ... public CompletedWorkflow complete (InitalParameters optimalInitialParameters) { ... } ... } classe publique CompletedWorkflow extends CaseWorkflow { private final InitialParameters optimalInitialParameters ; public InitialParameters getOptimalParamters() { ... } }
Comme le AuthenticatedClient dans le premier exemple, nous pouvons désormais écrire des méthodes qui fonctionnent sur un CompletedWorkflow et être sûrs de son état. Nous n'avons pas besoin de nous souvenir de tous les tenants et aboutissants de ce qui est défini à quel moment, car les méthodes disponibles dans la classe nous le disent.
Résumé
Le facteur commun à ces exemples était d'exploiter le système de types de Java comme outil pour documenter les états possibles des objets. Avec l'aide de la suggestion de méthode IDE, le raisonnement sur les objets avec des types informatifs est naturel et fluide. Les types réduisent également le contexte requis pour comprendre correctement le comportement des objets, ce qui est une aubaine pour la productivité.
