gwift-book/source/part-1-workspace/maintainable-applications/solid.adoc

23 KiB
Raw Blame History

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. Cest 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:

  1. SRP - Single responsibility principle - Principe de Responsabilité Unique

  2. OCP - Open-closed principle

  3. LSP - Liskov Substitution

  4. ISP - Interface ségrégation principle

  5. 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 :

  1. Reuse/release équivalence principle,

  2. Common Closure Principle,

  3. Common Reuse Principle.

Single Responsibility Principle

Le principe de responsabilité unique conseille de disposer de concepts ou domaines dactivité qui ne soccupent chacun que dune et une seule chose. Ceci rejoint un peu la 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 dune raison de changer.

Il est également possible détendre ce principe en fonction dacteurs:

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é [1], le principe de responsabilité unique suggère que chaque classe soit responsable dun 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 dinformations relatives à une même base de données centralisées, mais ont chacun besoin dune représentation différente ou de traitements distincts. cite:{clean_architecture} Nous sommes daccord quil sagit à 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 lacteur concerné. Lidée sous-jacente est simplement didentifier dès que possible les différents acteurs, en vue de prévoir une modification qui pourrait être demandée par lun dentre eux. Dans le cas dun é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.

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 """<?xml version = "1.0"?>
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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 nest pas vraiment intuitif. Une bonne pratique consisterait à créer une classe de rendu par type de format à gérer:

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 """<?xml version = "1.0"?>
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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 nimpacte nos différentes manières deffectuer un rendu.

En prenant lexemple dune méthode qui communique avec une base de données, ce ne sera pas à cette méthode à gérer linscription dune exception à un emplacement quelconque. Cette action doit être prise en compte par une autre classe (ou un autre concept), qui soccupera elle de définir lemplacement 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 dun 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.

Lobjectif est de rendre le système facile à étendre, en évitant que limpact dune modification ne soit trop grand.

Les exemples parlent deux-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 dune couche de présentation), mais la mise en forme diffère à chaque fois.

Lapplication na pas à connaître les détails dimplémentation: elle doit juste permettre une forme dextension, sans avoir à appliquer une modification (ou une grosse modification) sur son cœur.

Un des principes essentiels en programmation orientée objets concerne lhé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 dune instance, il est parfois préférable de définir une nouvelle sous-classe, qui surcharge une méthode bien précise. Pour lexemple, 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 davoir à gérer un ensemble conséquent de conditions dans la méthode initiale, en fonction dune autre variable (ici, le type de client).

Nous passerions ainsi de:

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:

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 its 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

class DocumentRenderer:
    def render(self, document):
        if format_type == "XML":
            return """<?xml version = "1.0"?>
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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:

class Renderer:
    def render(self, document):
        raise NotImplementedError

class XmlRenderer(Renderer):
    def render(self, document)
        return """<?xml version = "1.0"?>
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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 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 daccord avec les exemples donnés, dans la mesure où la définition concrète dune classe doit dépendre dune interface correctement définie (et que donc, faire hériter un carré dun rectangle, nest pas adéquat dans le mesure où cela induit lutilisateur en erreur), mais il y est aussi question de la définition dun style architectural pour une interface REST, mais sans donner de solution…

Le principe de substitution fait quune classe héritant dune autre classe doit se comporter de la même manière que cette dernière. Il nest pas question que la sous-classe nimplé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: 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: Wikipédia aussi)

Ce nest donc pas parce quune classe a besoin dune méthode définie dans une autre classe quelle 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 quune réflexion avancée (et sans doute un peu alcoolisée) nous dit que "Puisquun Lion marche aussi, faisons le hériter de notre classe `Canard`", nous allons nous retrouver avec ceci:

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 quune classe doit toujours pouvoir être considérée comme une instance de sa classe parent, et doit pouvoir sy substituer. Dans notre exemple, cela signifie que nous pourrons tout à fait accepter quun 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:

class XmlRenderer:
    def render(self, document)
        return """<?xml version = "1.0"?>
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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 dentête, notre rendu en Markdown héritera irrémédiablement de cette même méthode:

class XmlRenderer:
    def header(self):
        return """<?xml version = "1.0"?>"""

    def render(self, document)
        return """{}
            <document>
                <title>{}</title>
                <content>{}</content>
                <publication_date>{}</publication_date>
            </document>""".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 (<?xml version = "1.0"?>) pour un fichier Markdown.

Interface Segregation Principle

Le principe de ségrégation dinterface suggère de limiter la nécessité de recompiler un module, en nexposant que les opérations nécessaires à lexécution dune classe. Ceci évite davoir à redéployer lensemble dune application.

The lesson here is that depending on something that carries baggage that you dont need can cause you troubles that you didnt except.

Ce principe stipule quun client ne peut en aucun cas dépendre dune méthode dont il na pas besoin. Plus simplement, plutôt que de dépendre dune seule et même (grosse) interface présentant un ensemble conséquent de méthodes, il est proposé dexploser cette interface en plusieurs (plus petites) interfaces. Ceci permet aux différents consommateurs de nutiliser quun sous-ensemble précis dinterfaces, répondant chacune à un besoin précis.

Un exemple est davoir une interface permettant daccéder à des éléments. Modifier cette interface pour permettre lécriture impliquerait que toutes les applications ayant déjà accès à la première, obtiendraient (par défaut) un accès en écriture, ce qui nest pas souhaité/souhaitable.

Pour contrer ceci, on aurait alors une première interface permettant la lecture, tandis quune deuxième (héritant de la première) permettrait lécriture. On aurait alors le schéma suivant :

  • A : lecture

  • B (héritant de A) : lecture (par A) et écriture.

Mais ceci sapplique finalement à nimporte quel composant: votre système dexploitation, les librairies et dépendances tierces, les variables déclarées, …​

En Python, ce comportement est inféré lors de lexécution, et donc pas vraiment dapplication 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 lapplication de ce principe-ci permettrait de mettre à jour une DLL ou un JAR sans que cela nait dimpact sur le reste de lapplication.

Dependency inversion Principle

Dans une architecture conventionnelle, les composants de haut-niveau dépendent directement des composants de bas-niveau. Linversion de dépendances stipule que cest le composant de haut-niveau qui possède la définition de linterface dont il a besoin, et le composant de bas-niveau qui limplémente.

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.

Lobjectif est que les interfaces soient stables, et de réduire au maximum les modifications qui pourraient y être appliquées. De la meme manière, il convient déviter de surcharger des fonctions ou des classes concrètes (= non abstraites).

En termes architecturaux, ce principe définira les frontières dont il a déjà été question, en demandant à ce quune délimitation ne se base que sur une dépendance qui soit … cela permet notamment une séparation claire au niveau des frontières, en inversant le flux de dépendance et en faisant en sorte (par exemple) que les règles métiers naient aucune connaissance des interfaces graphiques qui les exploitent. Ces interfaces pouvant être desktop, web, … cela na pas vraiment dimportance. Cela autorise une for.e dimmunité entre les composants.

Ne pas oublier quécrire du nouveau code est toujours plus facile que de modifier du code existant.

The database is really nothing more than a big bucket of bits where we store our data on a long term basis (the database is a detail, chap 30, page 281) Dun point de vue architectural, nous ne devons pas nous soucier de la manière dont les données sont stockées, sil sagit dun disque magnétique, de ram, … en fait, on ne devrait même pas savoir sil y a un disque du tout.

Le composant de haut-niveau peut définir quil sattend à avoir un Publisher, afin de publier du contenu vers un emplacement particulier. Plusieurs implémentation de cette interface peuvent alors être mise en place:

  • Une publication par SSH

  • Une publication par FTP

  • Une publication

  • …​

Linjection de dépendances est un patron de programmation qui suit le principe dinversion 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 quune fois que les frontières sont implémentés, elles sont coûteuses à maintenir. Cependant, il ne sagit 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 dont 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 dont 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é à sadapter en minimisant la maintenance. Le problème est quelle ne permettait aucune adaptation, et quà la première demande, larchitecture se plante complètement sans aucune malléabilité.

Reuse/release equivalence principle

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 lon résumera ainsi: « dont depend on things you dont need » 😘
Au niveau des composants, au niveau architectural, mais également à dautres 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 lorganigramme (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


1. Aussi appelé God-Like object