=== Robustesse et flexibilité > Un code mal pensé entraîne nécessairement une perte d'énergie et de temps. > Il est plus simple de réfléchir, au moment de la conception du programme, à une architecture permettant une meilleure maintenabilité que de devoir corriger un code "sale" _a posteriori_. > C'est pour aider les développeurs à rester dans le droit chemin que les principes SOLID ont été énumérés. GNU/Linux Magazine HS 104 cite:[gnu_linux_mag_hs_104(26-44)] Les principes SOLID, introduit par Robert C. Martin dans les années 2000 sont les suivants: . SRP - Single responsibility principle - Principe de Responsabilité Unique . OCP - Open-closed principle . LSP - Liskov Substitution . ISP - Interface ségrégation principle . DIP - Dependency Inversion Principle En plus de ces principes de développement, il faut ajouter des principes au niveau des composants, puis un niveau plus haut, au niveau, au niveau architectural : . Reuse/release équivalence principle, . Common Closure Principle, . Common Reuse Principle. ==== Single Responsibility Principle Le principe de responsabilité unique conseille de disposer de concepts ou domaines d'activité qui ne s'occupent chacun que d'une et une seule chose. Ceci rejoint un peu la https://en.wikipedia.org/wiki/Unix_philosophy[Philosophie Unix], documentée par Doug McIlroy et qui demande de "_faire une seule chose, mais le faire bien_" cite:[unix_philosophy]. Une classe ou un élément de programmtion ne doit donc pas avoir plus d'une raison de changer. Il est également possible d'étendre ce principe en fonction d'acteurs: > A module should be responsible to one and only one actor. cite:[clean_architecture] Plutôt que de centraliser le maximum de code à un seul endroit ou dans une seule classe par convenance ou commodité footnote:[Aussi appelé _God-Like object_], le principe de responsabilité unique suggère que chaque classe soit responsable d'un et un seul concept. Une autre manière de voir les choses consiste à différencier les acteurs ou les intervenants: imaginez avoir une classe représentant des données de membres du personnel. Ces données pourraient être demandées par trois acteurs, le CFO, le CTO et le COO: ceux-ci ont tous besoin de données et d'informations relatives à une même base de données centralisées, mais ont chacun besoin d'une représentation différente ou de traitements distincts. cite:[clean_architecture] Nous sommes d’accord qu’il s’agit à chaque fois de données liées aux employés, mais elles vont un cran plus loin et pourraient nécessiter des ajustements spécifiques en fonction de l’acteur concerné. L’idée sous-jacente est simplement d’identifier dès que possible les différents acteurs, en vue de prévoir une modification qui pourrait être demandée par l’un d’entre eux. Dans le cas d'un élément de code centralisé, une modification induite par un des acteurs pourrait ainsi avoir un impact sur les données utilisées par les autres. [source,python] ---- class Document: def __init__(self, title, content, published_at): self.title = title self.content = content self.published_at = published_at def render(self, format_type): if format_type == "XML": return """ {} {} {} """.format( self.title, self.content, self.published_at.isoformat() ) if format_type == "Markdown": import markdown return markdown.markdown(self.content) raise ValueError("Format type '{}' is not known".format(format_type)) ---- Lorsque nous devrons ajouter un nouveau rendu (Atom, OpenXML, ...), nous devrons modifier la classe `Document`, ce qui n'est pas vraiment intuitif. Une bonne pratique consisterait à créer une classe de rendu par type de format à gérer: [source,python] ---- class Document: def __init__(self, title, content, published_at): self.title = title self.content = content self.published_at = published_at class DocumentRenderer: def render(self, document): if format_type == "XML": return """ {} {} {} """.format( self.title, self.content, self.published_at.isoformat() ) if format_type == "Markdown": import markdown return markdown.markdown(self.content) raise ValueError("Format type '{}' is not known".format(format_type)) ---- A présent, lorsque nous devrons ajouter un nouveau format de prise en charge, nous irons modifier la classe `DocumentRenderer`, sans que la classe `Document` ne soit impactée. En même temps, le jour où une instance de type `Document` sera liée à un champ `author`, rien ne dit que le rendu devra en tenir compte; nous modifierons donc notre classe pour y ajouter le nouveau champ sans que cela n'impacte nos différentes manières d'effectuer un rendu. En prenant l'exemple d'une méthode qui communique avec une base de données, ce ne sera pas à cette méthode à gérer l'inscription d'une exception à un emplacement quelconque. Cette action doit être prise en compte par une autre classe (ou un autre concept), qui s'occupera elle de définir l'emplacement où l'évènement sera enregistré (dans une base de données, une instance Graylog, un fichier, ...). Cette manière de structurer le code permet de centraliser la configuration d'un type d'évènement à un seul endroit, ce qui augmente ainsi la testabilité globale du projet. Lorsque nous verrons les composants, le SRP deviendra le CCP. Au niveau architectural, ce sera la définition des frontières (boundaries). ==== Open Closed > For software systems to be easy to change, they must be designed to allow the behavior to change by adding new code instead of changing existing code. L’objectif est de rendre le système facile à étendre, en évitant que l’impact d’une modification ne soit trop grand. Les exemples parlent d’eux-mêmes: des données doivent être présentées dans une page web. Et demain, ce seras dans un document PDF. Et après demain, ce sera dans un tableur Excel. La source de ces données restent la même (au travers d’une couche de présentation), mais la mise en forme diffère à chaque fois. L’application n’a pas à connaître les détails d’implémentation: elle doit juste permettre une forme d’extension, sans avoir à appliquer une modification (ou une grosse modification) sur son cœur. Un des principes essentiels en programmation orientée objets concerne l'héritage de classes et la surcharge de méthodes: plutôt que de partir sur une série de comparaisons pour définir le comportement d'une instance, il est parfois préférable de définir une nouvelle sous-classe, qui surcharge une méthode bien précise. Pour l'exemple, on pourrait ainsi définir trois classes: * Une classe `Customer`, pour laquelle la méthode `GetDiscount` ne renvoit rien; * Une classe `SilverCustomer`, pour laquelle la méthode revoit une réduction de 10%; * Une classe `GoldCustomer`, pour laquelle la même méthode renvoit une réduction de 20%. Si nous rencontrons un nouveau type de client, il suffit de créer une nouvelle sous-classe. Cela évite d'avoir à gérer un ensemble conséquent de conditions dans la méthode initiale, en fonction d'une autre variable (ici, le type de client). Nous passerions ainsi de: [source,python] ---- class Customer(): def __init__(self, customer_type: str): self.customer_type = customer_type def get_discount(customer: Customer) -> int: if customer.customer_type == "Silver": return 10 elif customer.customer_type == "Gold": return 20 return 0 >>> jack = Customer("Silver") >>> jack.get_discount() 10 ---- A ceci: [source,python] ---- class Customer(): def get_discount(self) -> int: return 0 class SilverCustomer(Customer): def get_discount(self) -> int: return 10 class GoldCustomer(Customer): def get_discount(self) -> int: return 20 >>> jack = SilverCustomer() >>> jack.get_discount() 10 ---- En anglais, dans le texte : "_Putting in simple words, the “Customer” class is now closed for any new modification but it’s open for extensions when new customer types are added to the project._". *En résumé*: nous fermons la classe `Customer` à toute modification, mais nous ouvrons la possibilité de créer de nouvelles extensions en ajoutant de nouveaux types [héritant de `Customer`]. De cette manière, nous simplifions également la maintenance de la méthode `get_discount`, dans la mesure où elle dépend directement du type dans lequel elle est implémentée. Nous pouvons également appliquer ceci à notre exemple sur les rendus de document, où le code suivant [source,python] ---- class DocumentRenderer: def render(self, document): if format_type == "XML": return """ {} {} {} """.format( document.title, document.content, document.published_at.isoformat() ) if format_type == "Markdown": import markdown return markdown.markdown(document.content) raise ValueError("Format type '{}' is not known".format(format_type)) ---- devient le suivant: [source,python] ---- class Renderer: def render(self, document): raise NotImplementedError class XmlRenderer(Renderer): def render(self, document) return """ {} {} {} """.format( document.title, document.content, document.published_at.isoformat() ) class MarkdownRenderer(Renderer): def render(self, document): import markdown return markdown.markdown(document.content) ---- Lorsque nous ajouterons notre nouveau type de rendu, nous ajouterons simplement une nouvelle classe de rendu qui héritera de `Renderer`. Ce point sera très utile lorsque nous aborderons les https://docs.djangoproject.com/en/3.1/topics/db/models/#proxy-models[modèles proxy]. ==== Liskov Substitution NOTE: Dans Clean Architecture, ce chapitre ci (le 9) est sans doute celui qui est le moins complet. Je suis d’accord avec les exemples donnés, dans la mesure où la définition concrète d’une classe doit dépendre d’une interface correctement définie (et que donc, faire hériter un carré d’un rectangle, n’est pas adéquat dans le mesure où cela induit l’utilisateur en erreur), mais il y est aussi question de la définition d'un style architectural pour une interface REST, mais sans donner de solution... Le principe de substitution fait qu'une classe héritant d'une autre classe doit se comporter de la même manière que cette dernière. Il n'est pas question que la sous-classe n'implémente pas certaines méthodes, alors que celles-ci sont disponibles sa classe parente. > [...] if S is a subtype of T, then objects of type T in a computer program may be replaced with objects of type S (i.e., objects of type S may be substituted for objects of type T), without altering any of the desirable properties of that program (correctness, task performed, etc.). (Source: http://en.wikipedia.org/wiki/Liskov_substitution_principle[Wikipédia]). > Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S, where S is a subtype of T. (Source: http://en.wikipedia.org/wiki/Liskov_substitution_principle[Wikipédia aussi]) Ce n'est donc pas parce qu'une classe **a besoin d'une méthode définie dans une autre classe** qu'elle doit forcément en hériter. Cela bousillerait le principe de substitution, dans la mesure où une instance de cette classe pourra toujours être considérée comme étant du type de son parent. Petit exemple pratique: si nous définissons une méthode `walk` et une méthode `eat` sur une classe `Duck`, et qu'une réflexion avancée (et sans doute un peu alcoolisée) nous dit que "_Puisqu'un `Lion` marche aussi, faisons le hériter de notre classe `Canard`"_, nous allons nous retrouver avec ceci: [source,python] ---- class Duck: def walk(self): print("Kwak") def eat(self, thing): if thing in ("plant", "insect", "seed", "seaweed", "fish"): return "Yummy!" raise IndigestionError("Arrrh") class Lion(Duck): def walk(self): print("Roaaar!") ---- Le principe de substitution de Liskov suggère qu'une classe doit toujours pouvoir être considérée comme une instance de sa classe parent, et *doit pouvoir s'y substituer*. Dans notre exemple, cela signifie que nous pourrons tout à fait accepter qu'un lion se comporte comme un canard et adore manger des plantes, insectes, graines, algues et du poisson. Miam ! Nous vous laissons tester la structure ci-dessus en glissant une antilope dans la boite à goûter du lion, ce qui nous donnera quelques trucs bizarres (et un lion atteint de botulisme). Pour revenir à nos exemples de rendus de documents, nous aurions pu faire hériter notre `MarkdownRenderer` de la classe `XmlRenderer`: [source,python] ---- class XmlRenderer: def render(self, document) return """ {} {} {} """.format( document.title, document.content, document.published_at.isoformat() ) class MarkdownRenderer(XmlRenderer): def render(self, document): import markdown return markdown.markdown(document.content) ---- Mais lorsque nous ajouterons une fonction d'entête, notre rendu en Markdown héritera irrémédiablement de cette même méthode: [source,python] ---- class XmlRenderer: def header(self): return """""" def render(self, document) return """{} {} {} {} """.format( self.header(), document.title, document.content, document.published_at.isoformat() ) class MarkdownRenderer(XmlRenderer): def render(self, document): import markdown return markdown.markdown(document.content) ---- A nouveau, lorsque nous invoquerons la méthode `header()` sur une instance de type `MarkdownRenderer`, nous obtiendrons un bloc de déclaration XML (``) pour un fichier Markdown. ==== Interface Segregation Principle Le principe de ségrégation d'interface suggère de limiter la nécessité de recompiler un module, en n’exposant que les opérations nécessaires à l’exécution d’une classe. Ceci évite d’avoir à redéployer l’ensemble d’une application. > The lesson here is that depending on something that carries baggage that you don’t need can cause you troubles that you didn’t except. Ce principe stipule qu'un client ne doit pas dépendre d'une méthode dont il n'a pas besoin. Plus simplement, plutôt que de dépendre d'une seule et même (grosse) interface présentant un ensemble conséquent de méthodes, il est proposé d'exploser cette interface en plusieurs (plus petites) interfaces. Ceci permet aux différents consommateurs de n'utiliser qu'un sous-ensemble précis d'interfaces, répondant chacune à un besoin précis. GNU/Linux Magazine cite:[gnu_linux_mag_hs_104(37-42)] propose un exemple d'interface permettant d'implémenter une imprimante: [source,java] ---- interface IPrinter { public abstract void printPage(); public abstract void scanPage(); public abstract void faxPage(); } public class Printer { protected string name; public Printer(string name) { this.name = name; } } ---- L'implémentation d'une imprimante multifonction aura tout son sens: [source,java] ---- public class AllInOnePrinter implements Printer extends IPrinter { public AllInOnePrinter(string name) { super(name); } public void printPage() { System.out.println(this.name + ": Impression"); } public void scanPage() { System.out.println(this.name + ": Scan"); } public void faxPage() { System.out.println(this.name + ": Fax"); } } ---- Tandis que l'implémentation d'une imprimante premier-prix ne servira pas à grand chose: [source,java] ---- public class FirstPricePrinter implements Printer extends IPrinter { public FirstPricePrinter(string name) { super(name); } public void printPage() { System.out.println(this.name + ": Impression"); } public void scanPage() { System.out.println(this.name + ": Fonctionnalité absente"); } public void faxPage() { System.out.println(this.name + ": Fonctionnalité absente"); } } ---- L'objectif est donc de découpler ces différentes fonctionnalités en plusieurs interfaces bien spécifiques, implémentant chacune une opération isolée: [source,java] ---- interface IPrinterPrinter { public abstract void printPage(); } interface IPrinterScanner { public abstract void scanPage(); } interface IPrinterFax { public abstract void faxPage(); } ---- Cette réflexion s'applique finalement à n'importe quel composant: votre système d'exploitation, les librairies et dépendances tierces, les variables déclarées, ... Quel que soit le composant que l'on utilise ou analyse, il est plus qu'intéressant de se limiter uniquement à ce dont nous avons besoin plutôt que En Python, ce comportement est inféré lors de l’exécution, et donc pas vraiment d’application pour notre contexte d'étude: de manière plus générale, les langages dynamiques sont plus flexibles et moins couplés que les langages statiquement typés, pour lesquels l'application de ce principe-ci permettrait de mettre à jour une DLL ou un JAR sans que cela n’ait d’impact sur le reste de l’application. Il est ainsi possible de trouver quelques horreurs, dans tous les langages: [source,javascript] ---- /*! * is-odd * * Copyright (c) 2015-2017, Jon Schlinkert. * Released under the MIT License. */ 'use strict'; const isNumber = require('is-number'); module.exports = function isOdd(value) { const n = Math.abs(value); if (!isNumber(n)) { throw new TypeError('expected a number'); } if (!Number.isInteger(n)) { throw new Error('expected an integer'); } if (!Number.isSafeInteger(n)) { throw new Error('value exceeds maximum safe integer'); } return (n % 2) === 1; }; ---- Voire, son opposé, qui dépend évidemment du premier: [source,javascript] ---- /*! * is-even * * Copyright (c) 2015, 2017, Jon Schlinkert. * Released under the MIT License. */ 'use strict'; var isOdd = require('is-odd'); module.exports = function isEven(i) { return !isOdd(i); }; ---- Il ne s'agit que d'un simple exemple, mais qui tend à une seule chose: gardez les choses simples (et, éventuellement, stupides) (((kiss))). Dans l'exemple ci-dessus, l'utilisation du module `is-odd` requière déjà deux dépendances: `is-even` et `is-number`. Imaginez la suite. ==== Dependency inversion Principle Dans une architecture conventionnelle, les composants de haut-niveau dépendent directement des composants de bas-niveau. L'inversion de dépendances stipule que c'est le composant de haut-niveau qui possède la définition de l'interface dont il a besoin, et le composant de bas-niveau qui l'implémente. L’objectif est que les interfaces soient les plus stables possibles, afin de réduire au maximum les modifications qui pourraient y être appliquées. De cette manière, toute modification fonctionnelle pourra être directement appliquée sur le composant de bas-niveau, sans que l'interface ne soit impactée. > The dependency inversion principle tells us that the most flexible systems are those in which source code dependencies refer only to abstractions, not to concretions. cite:[clean_architecture] L'injection de dépendances est un patron de programmation qui suit le principe d'inversion de dépendances. Django est bourré de ce principe, que ce soit pour les _middlewares_ ou pour les connexions aux bases de données. Lorsque nous écrivons ceci dans notre fichier de configuration, [source,python] ---- # [snip] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] # [snip] ---- Django ira simplement récupérer chacun de ces middlewares, qui répondent chacun à une https://docs.djangoproject.com/en/4.0/topics/http/middleware/#writing-your-own-middleware[interface clairement définie], dans l'ordre. Il n'y a donc pas de magie; c'est le développeur qui va simplement brancher ou câbler des fonctionnalités au niveau du framework, en les déclarant au bon endroit. Pour créer un nouveau _middleware_, il suffira d'implémenter le code suivant et de l'ajouter dans la configuration de l'application: [source,python] ---- def simple_middleware(get_response): # One-time configuration and initialization. def middleware(request): # Code to be executed for each request before # the view (and later middleware) are called. response = get_response(request) # Code to be executed for each request/response after # the view is called. return response return middleware ---- Dans d'autres projets écrits en Python, ce type de mécanisme peut être implémenté relativement facilement en utilisant les modules https://docs.python.org/3/library/importlib.html[importlib] et la fonction `getattr`. Un autre exemple concerne les bases de données: pour garder un maximum de flexibilité, Django ajoute une couche d'abstraction en permettant de spécifier le moteur de base de données que vous souhaiteriez utiliser, qu'il s'agisse d'SQLite, MSSQL, Oracle, PostgreSQL ou MySQL/MariaDB footnote:[http://howfuckedismydatabase.com/]. > The database is really nothing more than a big bucket of bits where we store our data on a long term basis. cite:[clean_architecture(281)] D’un point de vue architectural, nous ne devons pas nous soucier de la manière dont les données sont stockées, s’il s’agit d’un disque magnétique, de ram, ... en fait, on ne devrait même pas savoir s’il y a un disque du tout. Et Django le fait très bien pour nous. En termes architecturaux, ce principe autorise une définition des frontières, et en permettant une séparation claire en inversant le flux de dépendances et en faisant en sorte que les règles métiers n'aient aucune connaissance des interfaces graphiques qui les exploitent ou des moteurs de bases de données qui les stockent. Ceci autorise une forme d'immunité entre les composants. ==== Sources * http://www.codeproject.com/Articles/703634/SOLID-architecture-principles-using-simple-Csharp[Understanding SOLID principles on CodeProject] * http://lostechies.com/derickbailey/2011/09/22/dependency-injection-is-not-the-same-as-the-dependency-inversion-principle/[Dependency Injection is NOT the same as dependency inversion] * http://en.wikipedia.org/wiki/Dependency_injection[Injection de dépendances] === au niveau des composants De la même manière que pour les principes définis ci-dessus, Mais toujours en faisant attention qu’une fois que les frontières sont implémentés, elles sont coûteuses à maintenir. Cependant, il ne s’agit pas une décision à réaliser une seule fois, puisque cela peut être réévalué. Et de la même manière que nous devons délayer au maximum les choix architecturaux et techniques, > but this is not a one time decision. You don’t simply decide at the start of a project which boundaries to implémentent and which to ignore. Rather, you watch. You pay attention as the system evolves. You note where boundaries may be required, and then carefully watch for the first inkling of friction because those boundaries don’t exist. > at that point, you weight the costs of implementing those boundaries versus the cost of ignoring them and you review that decision frequently. Your goal is to implement the boundaries right at the inflection point where the cost of implementing becomes less than the cost of ignoring. En gros, il faut projeter sur la capacité à s’adapter en minimisant la maintenance. Le problème est qu’elle ne permettait aucune adaptation, et qu’à la première demande, l’architecture se plante complètement sans aucune malléabilité. ==== Reuse/release equivalence principle [quote] ---- Classes and modules that are grouped together into a component should be releasable together -- (Chapitre 13, Component Cohesion, page 105) ---- ==== CCP (= l’équivalent du SRP, mais pour les composants) > If two classes are so tightly bound, either physically or conceptually, that they always change together, then they belong in the same component Il y a peut-être aussi un lien à faire avec « Your code as a crime scene » 🤟 La définition exacte devient celle-ci: « gather together those things that change at the same times and for the same reasons. Separate those things that change at different times or for different reasons ». ==== CRP … que l’on résumera ainsi: « don’t depend on things you don’t need » 😘 Au niveau des composants, au niveau architectural, mais également à d’autres niveaux. ==== SDP (Stable dependency principle) qui définit une formule de stabilité pour les composants, en fonction de sa faculté à être modifié et des composants qui dépendent de lui: au plus un composant est nécessaire, au plus il sera stable (dans la mesure où il lui sera difficile de changer). En C++, cela correspond aux mots clés #include. Pour faciliter cette stabilité, il convient de passer par des interfaces (donc, rarement modifiées, par définition). En Python, ce ratio pourrait être calculé au travers des import, via les AST. ==== SAP (= Stable abstraction principle) pour la définition des politiques de haut niveau vs les composants plus concrets. SAP est juste une modélisation du OCP pour les composants: nous plaçons ceux qui ne changent pas ou pratiquement pas le plus haut possible dans l’organigramme (ou le diagramme), et ceux qui changent souvent plus bas, dans le sens de stabilité du flux. Les composants les plus bas sont considérés comme volatiles