Menu
Une approche différente de l'architecture hexagonale

Une approche différente de l'architecture hexagonale

Temps de lecture estimé : 22 minutes

 

Sommaire

 

C'est quoi et à quoi ça sert une architecture hexagonale ? Je vais vous le présenter from the bottom-up, c'est-à-dire en partant du problème, et avec des exemples concrets de code.

 

 

And now for something completely different

Pourquoi encore un article sur ces sujets ? En effet, récemment sur ce blog a déjà été postée une introduction à l'architecture hexagonale. Et il existe pléthore de présentations déjà sur ce sujet. Elles ont toutes un point commun : elles commencent par expliquer ce que c'est, et ensuite seulement à la fin donner un exemple d'à quoi ça ressemble. C'est une approche descendante (approche top-down) classique.

Pour ma part, je comprends mieux avec une approche ascendante (approche bottom-up) : se confronter au problème directement, en examiner les contours, tester des solutions, itérer, et seulement à la fin donner un nom et une définition à ce qu'on a vu. Cette approche me permettra aussi tout du long de mettre en évidence les forces et contraintes qui s'opposent, qui motivent les choix d'implémentation, comment on aboutit à l'hexagonal.

Sur ce, je vous propose donc une aventure autour du code. Elle se base sur des besoins et faits réels. Ce sera long, et on ne parlera pas directement d'architecture du tout, ça attendra la fin.

 

 

Les bons outils font les bons artisans

Le terme outil peut se définir par « moyen d'action ». J'aime bien l'idée convoyée par ce sens : ils nous permettent de travailler, de manière améliorée parfois, voire même d'une nouvelle façon.

Sur ma mission actuelle, du fait que l'on fasse du micro-service en multi-repo, on se retrouve avec un fatras de PR (Pull Requests) : difficile de savoir ce qui a été mergé ou pas, qui a relu quoi, quelle PR est bloquée en attente de relecture, ...

Pour mieux m'organiser, je me suis dit qu'un petit outil m'aiderait beaucoup à y voir plus clair. Tous ces repos sont heureusement dans un même projet sur un même serveur BitBucket. Dans le but de se bricoler un truc vite fait, j'ai pris Python pour sa simplicité d'écriture, et voilà ce à quoi j'arrive (v1) :
    import stashy  # librairie tierce, pour interagir avec un serveur BitBucket
               # historiquement BitBucket s'appellait Stash

my_login = "Julien"
my_token = "AbCdEf01#"
my_server_url = "https://bitbucket.internal.corp:1234"
my_project_name = "AwesomeProject"  # dans un Projet il y a plusieurs Repertoires

stash = stashy.connect(my_server_url, my_login, my_token)

for repo_data in stash.projects[my_project_name].repos.list():
    repo_slug = repo_data["slug"]  # le nom qui sert d'identifiant
    print("Repo " + repo_slug)
    for pull_request_data in stash.projects[my_project].repos[repo_slug].pull_requests.list():
        pr_title = pull_request_data["title"]
        pr_author_display_name = pull_request_data["author"]["displayName"]
        print(f" - PR {pr_title!r} de {pr_author_display_name!s}")
        for reviewer_data in pull_request_data["reviewers"]:
            reviewer_display_name = reviewer_data["user"]["displayName"]
            reviewer_has_approved = bool(reviewer_data["approved"])
            print(f"   - {'OK' if reviewer_has_approved else '  '} {reviewer_display_name}")
Ce qui me produit : 
    Repo AlpesDHuez
 - PR "add foo to bar" de Elena
   - OK Gabin
   -    Julien
 - PR "remove qux from tez" de Julien
   - OK Gabin
Repo Bonneval
Repo Chamrousse
 - PR "increase pol to 4000" de Julien
   -    Elena
   -    Gabin
Repo GrandBornand
Repo Meribel
 - PR "doc for lud" de Elena
   - OK Agathe
On a déjà quelque chose d'utile : je peux avoir une vision globale de toutes les PR ouvertes, et d'où elles en sont. Mais ça ne me donne pas encore une vision précise sur ce que je dois faire moi personnellement. Après quelques modifications (v2) :
    my_id = "123456"

for repo_data in stash.projects[my_project_name].repos.list():
    repo_slug = repo_data["slug"]
    for pull_request_data in stash.projects[my_project].repos[repo_slug].pull_requests.list():
        pr_title = pull_request_data["title"]
        pr_author_id = pull_request_data["author"]["id"]
        i_am_reviewer = my_id in (reviewer_data["user"]["id"]
                                  for reviewer_data in pull_request_data["reviewers"])

        if my_id == pr_author_id:
            print(f"Repo {repo_slug!s} PR {pr_title!r}")
            # afficher la liste des gens qui n'ont PAS approuvé, ou alors "à merger"
            reviewers_data_not_approved = tuple(reviewer_data
                                                for reviewer_data in pull_request_data["reviewers"]
                                                if not reviewer_data["approved"])
            if len(reviewers_data_not_approved) == 0:
                print(" -> à merger")
            else:
                print("\n".join(" -> relancer " + reviewer_data["user"]["displayName"]
                                for reviewer_data in reviewers_data_not_approved))
            
        elif i_am_reviewer:
            print(f"Repo {repo_slug!s} PR {pr_title!r} de {pr_author_display_name!s}")
            print(" -> à relire")
Et j'ai enfin une liste claire des actions à faire :
    Repo AlpesDHuez PR "add foo to bar" de Elena
 -> à relire
Repo AlpesDHuez PR "remove qux from tez"
 -> à merger
Repo Chamrousse PR "increase pol to 4000"
 -> relancer Elena
 -> relancer Gabin

Ensuite, j'ai continué d'itérer pour rajouter d'autres fonctionnalités, par exemple basées sur nos règles de développement ou pour palier des limitations de BitBucket :

  • une PR ne doit être mergée que si elle a eu au moins 2 approbations, il faut donc signaler celles qui n'ont pas assez de relecteurs, ainsi que de ne pas indiquer "à merger" si elle n'a pas encore atteint 2. Aussi, à titre indicatif, avoir une liste des PRs sur lesquelles je ne suis pas assigné.
  • sur BitBucket on peut à la place d'approuver mettre un "needs work" qui empêche de merger tant que la personne ne le retire pas, il faut donc prioritairement relancer ces personnes une fois que leurs remarques ont été prises en compte.
  •  voir quelles PRs ont évolué depuis que j'ai mis mon approbation (en réponse à d'autres remarques), pour voir si cela va toujours.
  • signaler les PRs qui sont vieilles et/ou qui n'ont pas été mises à jour depuis longtemps.
  • ...

 

Le meilleur des mondes ?

Je suis très content de mon script, je le fais tourner plusieurs fois par jour, ça ne me prend que quelques secondes pour avoir une vision claire sur mes tâches. J'en parle à mes collègues, certains sont enthousiastes et commencent à l'utiliser aussi.

Cependant, je dois avouer que, étant donné ma mémoire peu fiable, lors de notre daily meeting SCRUM, j'ai déjà oublié les actions recommandées par mon script. Et je remarque que mes collègues ont le même problème : soit ils oublient de l'utiliser avant la réunion, soit ils ne l'utilisent pas du tout.

Résultat : chacun manque de visibilité au niveau de l'équipe.


Le problème, c'est que cette visibilité globale était justement le propre de la v1, alors que moi individuellement, je préfère la v2. Mais la v1 qui offrait une vision globale manquait des nombreux raffinements que j'ai ajoutés depuis. Il va donc falloir trouver une moyen de concilier les deux approches parce que je n'ai pas envie de faire un dédale de if mode=="équipe", je préfère avoir des fonctions séparées (v3) :

    import datetime
from dataclasses import dataclass
from enum import Enum, unique
from typing import *  # court mais simple

# définissons nos données :

@dataclass
class Repo:
    slug: str
    name: str
    pull_requests: Sequence[PullRequest]

@dataclass
class PullRequest:
    name: str
    author: User
    reviewers: Sequence[Reviewer]
    approvals_count: int
    created_datetime: datetime.datetime
    updated_datetime: datetime.datetime

@dataclass
class User:
    bitbucket_id: str
    corporate_id: str
    display_name: str

@dataclass
class Reviewer(User):
    has_approved: bool
    approval_status: ApprovalStatus

@enum.unique
class ApprovalStatus(Enum):
    UNAPPROVED = "UNAPPROVED"
    APPROVED = "APPROVED"
    NEEDS_WORK = "NEEDS_WORK"

# et les fonctions qui vont les manipuler :

def fetch_all_pull_requests(stash, my_project_name: str) -> Sequence[Repo]:
    ...  # assez identique à ce qui était fait avant, mais à la place de printer,
    return tuple(...)  # tout est stocké dans des objets et finalement `return`é

def print_my_personal_actions(repos: Sequence[Repo], my_id: str) -> None:
    ...  # on itère sur les données passées en paramètre, et on print les actions personnelles

def print_the_team_global_view(repos: Sequence[Repo]) -> None:
    ...  # on itère sur les données passées en paramètre, et on print la vue globale


def main() -> None:
    ...  # assignation des variables nécessaires
    stash = stashy.connect(my_server_url, my_login, my_token)
    repos = fetch_all_pull_requests(stash, my_project_name)
    print_my_personal_actions(repos, my_id)
    #print_the_team_global_view(repos)
Les fonctions n'ont pas changé, juste que le code est un peu plus clair (bien que plus long). Par contre, en termes d’utilisabilité, pour l'instant, je commente/décommente la ligne que je veux exécuter dans le main. Je pourrais exiger que le mode soit passé en ligne de commande, mais cela rendrait son utilisation un peu plus complexe. Actuellement, le script peut simplement être lancé avec la commande "run" et il fonctionne.

Dans le même genre, lorsque j'itère sur l'implémentation, je me contente de le laisser printer sur stdout. Par contre, quand je l'utilise, je préfère qu'il me stocke ces infos dans un fichier horodaté, pour éviter toute confusion avec d'anciens rapports. Actuellement, c'est implémenté avec une bonne grosse variable globale SAVE_TO_FILE = False que je passe manuellement à True lorsque je commence à travailler dessus, mais je trouve ça pas très propre non plus, bien que ça marche.

Ce qui m'embête par contre, c'est que lorsque je développe sur les fonctions print*, pour tester je tape constamment sur le serveur BitBucket avant de les appeler. Ce n'est ni très rapide, ni très écolo, ni même très efficace parce que (véridique !) la connexion a tendance à planter et donc à faire échouer le script. Et puisque les données changent en temps réel (untel a relu, unetelle a créé une PR, ...) je ne peux pas reproduire mes résultats une heure après.

Si vous avez des yeux aiguisés, vous avez peut-être remarqué que je n'ai pas besoin d'appeler fetch pour pouvoir appeler print : je peux créer les données moi-même et les envoyer se faire printer. Et puisque je suis flemmard, je peux même demander à fetch de les créer pour moi :
    import json
from dataclasses import asdict
...
def main():
    ...  # paramètres et création de l'objet stash
    repos = fetch_pull_requests(stash, my_project_name)
    with open("test_data.json", "wt") as test_data_file:
       json.dump((asdict(repo) for repo in repos), test_data_file)
       # je vous épargne le fait que les `datetime.datetime` soient pénibles à sérialiser
Me voilà avec des données de test persistées sur le disque, versionnées sous Git si je le souhaite. Maintenant il me reste à faire l'inverse : les charger et les envoyer à mes fonctions print.
    def main():
    # besoin de rien !
    with open("test_data.json", "rt") as test_data_file:
        repos_data = json.load(test_data_file)
        repos = tuple(
            Repo(
                name=repo_data["name"],
                ...  # je vous épargne toutes les imbrications
                     # on pourrait utiliser des moyens plus intelligents, mais pour l'instant ça fait l'affaire
            ) for repo_data in repos_data
        )
    print_my_personal_actions(repos, my_id)
    #print_the_team_global_view(repos)
Bien sûr, je vais bouger ce code dans des fonctions, même le "quick and dirty" a ses limites. Mais maintenant, je peux tester indépendamment que mon fetch ou que mon print fonctionne. Si je veux aller encore plus loin dans la testabilité (et dans l'ergonomie de développement), je peux m'inspirer de la gestion des IO de la programmation fonctionnelle : au lieu de laisser mes fonctions print* effectivement printer, je les transforme en fonctions pures compute_display(repos) -> str dont j'ai juste à print le résultat après. Cela me permet de réaliser des tests du genre :
    def test__team_global_view__case07():
    expected_output = load_from_file("master_record_07.txt")  # à quoi doit ressembler la sortie pour le cas 07
    repos = load_from_file("master_record_07.json")  # avec quelles données
    actual_output = compute_the_team_global_view(repos)  # ce que j'obtiens maintenant
    assertStringEquals(expected_output, actual_output)  # ou la fonction appropriée de votre framework de test

Ce qui est capable de me produire des sorties du genre :

 

 

Apocalypse Now

Le main ne ressemble plus à rien désormais (v4) :
    ... # définitions de constantes, certaines inutilisées selon la valeur des flags ci-dessous
load_from_file_instead_of_fetching = True
save_the_fetch_result_to_file = True
mode_team = False

if load_from_file_instead_of_fetching:
    repos = load_repos_from_json_file(test_data_filepath)
else:
    repos = fetch_pull_requests(stash, my_project_name)

if save_the_fetch_result_to_file:
    save_repos_into_json_file(test_data_filepath)

if mode_team:
    print(compute_the_team_global_view(repos))
else:
    print(compute_my_personal_actions(repos, my_id))

Et surtout le petit outil perso commence à s'intégrer au sein de l'écosystème :

  1. Il faudrait une visualisation plus claire que du texte et plus accessible que la stdout de mon terminal : un tableau sur une page web. Ça veut dire une fonction compute supplémentaire pour le rendu en HTML, mais aussi de la placer quelque part où toute l'équipe peut la récupérer (serveur web).
  2. Aussi notre équipe doit-elle maintenant travailler avec un repo qui n'est pas sur notre BitBucket mais sur un GitLab, il faudrait que le script surveille ce qui se passe là-bas également. Ça va sérieusement compliquer le fetch, surtout pour faire la compatibilité entre les deux types de serveurs.
  3. Et ce serait bien, en mode Team, de récupérer les infos de présences/absences depuis le calendrier pour anticiper sur les congés : si le responsable d'une PR sera absent dans moins de 3 jours alors il faudrait qu'on le sache.
  4. Et aussi inversement si le script pouvait commenter sur les PR que l'auteur est en congés. Et élire un nouveau relecteur comme responsable de la PR. Parmi une liste prédéfinie de personnes. En fonction du repo.
  5. Et aussi encore si le script pouvait traquer dans la description des PRs le tag qui indique de quelle story elles font partie, afin de les grouper ensemble plutôt que de les séparer par repo, parce qu'une story couvre souvent plusieurs repos.
  6. ...


Ouch. Autant je suis content de l'engouement, autant je sens qu'implémenter tout ça sans réfléchir va le transformer en plat de spaghetti, que moi seul connaitrais, donc que ce sera à moi de le maintenir car c'est à la base « mon outil ».

 

Les Métamorphoses

Pour contrer la complexité qui augmente, je vais devoir augmenter mon niveau d'exigence.

Lâchons le clavier un moment pour réfléchir au problème : quelles sont les données, les objectifs et les contraintes de cet outil ?

Il sert d'assistant aux humains qui travaillent sur des PRs, à la fois pour leur donner une vision globale de ce qu'il se passe, à la fois leur donner une vision détaillée pour chaque PR de l'avancement et de leur participation. L'outil récupère des données principalement depuis des serveurs Git (BitBucket, GitLab, ...), agrège ces données avec celles de présence et de responsabilité, et ensuite génère et publie différents types de rapports, globaux ou spécifiques, sur différents supports (console, web, API des pull requests sur les différents serveurs, ...).

Les choses me paraissent plus clair comme ça ! On voit qu'on a plusieurs sources de données, différents traitements, et plusieurs sorties différentes. En soi, c'est un pipeline assez simple, le problème c'est la multiplicité. La solution classique à cela c'est l'abstraction.

Voici la mise en œuvre d'une abstraction pour le fetch (v5) :
    class PullRequestsFetcher:  # classe abstraite
    def fetch_pull_requests(self) -> Sequence[PullRequest]:
        raise NotImplementedError  # la façon très particulière de signaler qu'une méthode est abstraite en Python ...
    

class GitLabPullRequestsFetcher(PullRequestsFetcher):
    def __init__(self, server_url, credentials, ...):
        ... # spécifique à GitLab
    def fetch_pull_requests(self) -> Sequence[PullRequest]:
        ... # fetch depuis GitLab

class BitBucketPullRequestsFetcher(PullRequestsFetcher):
    def __init__(self, server_url, credentials, ...):
        ... # spécifique à BitBucket
    def fetch_pull_requests(self) -> Sequence[PullRequest]:
        ... # fetch depuis BitBucket

    
def fetch_pull_requests(fetchers: Sequence[PullRequestsFetcher]):
    for fetcher in fetchers:
        for pull_request in fetcher.fetch_pull_requests():
            ... # utiliser la pullrequest agnostique d'hébergeur

def main():
    fetchers = [
        GitLabPullRequestsFetcher(gitlab_url, gitlab_credentials),
        BitBucketPullRequestsFetcher(bitbucket_url, bitbucket_credentials),
    ]
    pull_requests = fetch_pull_requests(fetchers)
    ...  # et le reste
Et si à la place je veux tester :
    class FakePullRequestsFetcher(PullRequestsFetcher):
    def __init__(self, filepath):
        self._pull_requests = load_from_file(filepath)  # on réutilise le code de test d'avant
    def fetch_pull_requests(self) -> Sequence[PullRequest]:
        return self._pull_requests

def test__team_global_view__case07():  # le même test qu'avant
    # given
    expected_output = load_from_file("master_record_07.txt")
    fake_fetcher = FakePullRequestsFetcher("pull_requests_07.json")  # on instancie notre fake
    pull_requests = fetch_pull_requests(fetchers=[fake_fetcher])  # pour créer nos données de test
    # when
    actual_output = compute_the_team_global_view(pull_requests)
    # then
    assertStringEquals(expected_output, actual_output)
Et implémenter des tests supplémentaires à partir de là devient presque simple ! Je copie des inputs qui existent déjà, je les altère pour qu'elles collent au cas que je veux tester, et selon si on est de l'école TDD ou ITL, on écrit l'attendu avant ou on laisse le code le générer. Git commit et on recommence !

Si je préfère ne pas dépendre du fonctionnement de fetch_pull_requests pour mon test, je peux me créer une factory pour mes objets intermédiaires (ceux que mon fetch passe à mon print/compute_display). A voir selon votre stratégie, si vous préférez de l'isolation (au prix de plus de code requis pour les tests), ou si vous privilégiez du test d'intégration.

Si vous avez peur qu'un matin en arrivant au boulot vous voyez que plus rien ne fonctionne car dans la nuit la SI a mis à jour BitBucket sans prévenir, et que la montée de version mineure change néanmoins le schéma des réponses du serveur, c'est qu'il manque des tests de contrat :
    def test__contrat__bitbucket():
    real_fetcher = BitBucketPullRequestsFetcher(server_url, credentials)
    fake_fetcher = FakePullRequestsFetcher(server_url, credentials)
    assertSequenceEquals(real_fetcher.fetch_pull_requests(),  # expected
                         fake_fetcher.fetch_pull_requests())  # actual for the tests using the fake

Ce test échoue quand votre fake ne colle plus à la réalité. Mais en général ce genre de tests est assez délicat : les données changent souvent (le cas ici avec les PRs), ou n'existent même pas forcément (commandes client pas encore dépilées), ou bien la requête prend des heures (entrainement d'un modèle d'IA, ...), ou bien elle coûte très cher (transaction bancaire, ...), ou bien un million d'autres raisons. Le fake est très pratique, mais il vaut parfois quand même mieux de vraiment interagir avec les systèmes externes pour s'assurer que les tests restent pertinents.

 

 

Tout est pour le mieux dans le meilleur des mondes possibles

J'ai bien montré tous les avantages apportés par l'abstraction des fetchers, que se passe-t-il si on fait de même pour les sorties (v6) ?
    class Reporter:
    def display(self, pull_requests) -> None:  # on s'attend à un side-effect (IO)
        raise NotImplementedError  # méthode abstraite

class StdoutGlobalReporter(Reporter):
    # même pas besoin de __init__ !
    def display(self, pull_requests) -> None:
        for pull_request in pull_requests:
            print(...)  # en partie le code de `print_the_team_global_view` (v3)

class HtmlFileGlobalReporter(Reporter):
    def __init__(self, filename):
        self._filename = filename
    def display(self, pull_requests) -> None:
        with open(self._filename, "wt") as html_file:
            html_file.write(...)  


def main():
    ...  # cf v5
    pull_requests = fetch_pull_requests(fetchers)
    # maintenant on peut faire :
    reporter = StdoutGlobalReporter()
    # ou bien :
    reporter = HtmlFileGlobalReporter("report.html")
    # et ça ne change pas :
    reporter.display(pull_requests)

Mouais. J'ai encore du code à commenter-décommenter, et ça ne gère que le use case de la vision d'équipe, pas celle individuelle…

 

All problems in computer science can be solved by another level of indirection.

— Butler Lampson, Beautiful Code, 1972

Ce qui au pays de l'infonuagique se traduirait par « tous les problèmes en informatique peuvent être résolus par niveau supplémentaire d'indirection ». Interprétons donc très littéralement cette citation complètement hors de contexte (écrite l'année de l'invention du langage C).

(Pour ceux qui voudraient une explication rationnelle : si on voit notre outil comme un pipeline qui prend en entrée des infos de serveur et un calendrier de présence, qui consolide ces données en différents types de rapports, et qui exporte ensuite ces différents rapports dans le format voulu, alors on n'a pas encore fait émerger le concept de rapport)

Voilà la version la plus versatile jusqu'à maintenant (v7) :
    @dataclass
class GlobalReport:
    ...

@dataclass
class PersonalReport:
    ...

def compute_global_report(pull_requests) -> GlobalReport:
    ...
def compute_personal_report(pull_requests, person) -> PersonalReport:
    ...


class ReportPrinter:  # classe abstraite
    def display_global_report(self, global_report) -> None:
        raise NotImplementedError  # méthode abstraite
    def display_personal_report(self, personal_report) -> None:
        raise NotImplementedError  # méthode abstraite

class StdoutPrinter(ReportPrinter):  # classe dérivée
    def display_global_report(self, global_report) -> None:
        ...
    def display_personal_report(self, personal_report) -> None:
        ...

class HtmlPrinter(ReportPrinter):  # classe dérivée
    ...  # implémentation des méthodes abstraites


def main__create_global_report(fetchers: Sequence[Fetcher], printer: ReportPrinter) -> None:
    printer.display_global_report(fetch_pull_requests(fetchers))
def main__create_personal_report(fetchers: Sequence[Fetcher], printer: ReportPrinter) -> None:
    printer.display_personal_report(fetch_pull_requests(fetchers))


def main():
    # je peux faire tout ce qui me plait !
    all_fetchers = [FakeFetcher(...), GitLabFetcher(...), BitBucketFetcher(...)]
    all_printers = [FakePrinter(...), StdoutPrinter(...), HtmlPrinter(...)]
    for fetchers in itertools.combinations(all_fetchers):
        for printer in all_printers:
            main__create_global_report(fetchers, printer)
            main__create_personal_report(fetchers, printer)

Est-ce que vous voyez où ça nous amène ? Autant je ne suis pas tout à fait fan d'empiler les couches d'abstractions avec des noms à rallonge, autant ici on a quand même gagné nettement en composabilité : je suis capable de construire un pipeline qui correspond précisément à mon besoin, juste en branchant ensemble les bons éléments. On a construit des briques, aux interfaces clairement définies, qui répondent à mon problème.

Dans d'autres langages ou avec certaines librairies, je pourrais même rendre le code plus élégant, mais fondamentalement j'ai un soft aux contours très modelables :

  • si je veux créer un nouveau type de rapport (par exemple une météo Kanban) alors je bénéficie déjà de toutes mes classes de récupération de pull requests et je peux rajouter une méthode à mes printers,
  • si je veux prendre en compte l'objectif de sprint pour la priorisation de mon travail, je peux créer une nouvelle classe abstraite et sa dérivée, et fournir cela à mes fonctions de création de rapports
  • si je veux que mon fetch des repos GitLab soit plus rapide, alors je peux créer une classe dérivée AsyncProxySuperFastGitLabPullRequestsFetcher et l'utiliser à la place,
  • si je veux que tout le monde y ait accès pendant le daily, je peux ajouter un printer qui poste sur notre Slack interne, ou qui balance le rapport sur la télé dans la salle à 9h30,
  • ...


On dirait bien une solution miracle (silver bullet) !

 

There is no silver bullet

Mais il y a eu des compromis que je n'ai pas forcément montrés, le code des implémentations n'étant pas le but de cet article. Ce n'est pas facile de définir des abstractions qui conviennent, voire impossible d'être correct :

 

All non-trivial abstractions, to some degree, are leaky.

— Joel Spolsky, The Law of Leaky Abstractions, 2002

Ce que l'Académie Française dans un mél expliquerait comme « toutes les abstractions non triviales sont, dans une certaine mesure, percées », c'est-à-dire qu'il est nécessaire au-dessus de savoir ce qu'il y a vraiment en dessous, ou inversement de devoir changer en dessous pour des considérations qui devraient être celle de dessus.

Si je reprends mon cas du fetching qui est trop lent, alors je n'ai pas un instantané, mais une photo floue de l'état de mon projet, le rapport ne sera peut-être pas cohérent dans ce cas. Ou bien, je n'ai pas accès aux serveurs la nuit car ils sont éteints, alors mon outil de génération de rapport ferait bien de ne pas tourner à ce moment-là.

Si la télé sur laquelle je veux afficher le rapport est petite, je ne pourrais pas forcément tout afficher dessus. Est-ce que c'est la responsabilité du printer de faire un choix éditorial et de couper des trucs, ou bien au générateur de rapports de devoir être plus synthétique en raison de la classe concrète de printer qui devrait lui être caché ?

Et je ne parle pas du fait qu'il soit parfois juste impossible d'assurer les mêmes capacités entre toutes les classes dérivées. Pour savoir si une PR peut être mergée, il vaut mieux que toutes ses discussions aient été resolved (ayant abouti à une conclusion). Sur GitLab c'est un booléen, alors que cette notion n'existe pas du tout côté BitBucket. On pourrait tenter de bricoler des heuristiques, mais du point de vue du compute ce n'est juste pas la même chose. Et si on admet qu'il y a des différences entre les implémentations, à ce moment, c'est compute qui en paye le prix parce qu'il va falloir qu'il vérifie les capacités de ses fetchers avant de les appeler, on est loin de l'idéal.

Mais je demeure satisfait de l'équilibre qu'on a trouvé, par contre comment pourrait-on le qualifier ?

 

L'art et la manière

Je me suis abstenu de parler frontalement d'architecture tout du long, mais toutes les décisions qui ont été prises dans cet article cherchaient à concilier des forces antagonistes, et l'architecture logicielle tend à traiter justement ce genre de sujet.



On est parti de la v1, très fortement couplée : il n'y avait même pas de fonction, mais s'il y en avait une on aurait pu dire qu'elle faisait tout toute seule. C'est un avantage, c'est facile à utiliser, comme une librairie. Mais elle ne faisait que ce qu'elle savait faire, on ne pouvait pas la modeler du tout.

La v2 a introduit le second use case ("rapport personnel") et celui-ci s'est mêlé au premier. C'était par facilité, parce qu'ils avaient une structure très similaire, mais dès lors les deux étaient entrelacés.

Mais dès lors que les deux ont divergé, pour ne pas sombrer dans un enfer de ifs imbriqués, il a fallu dans la v3 les séparer, et identifier tout ce qu'ils avaient de commun pour le partager en dehors de leurs fonctions respectives. Cela permettait aussi de les appeler plus librement, mais demandait donc plus de code, car il y avait un point de connexion dont main devait se charger désormais.

En ajoutant brutalement des branches pour charger/sauvegarder des tests, le code s'est retrouvé cyclomatiquement plus compliqué dans la v4. Le code n'était pas très modelable de base, donc y ajouter ces capacités s'est un peu fait au forceps, et s'il fallait continuer comme ça on s'enfoncerait dans un océan de problèmes de maintenance.

Pour les v5 et v6, sans la nommer, on a introduit de l'inversion de dépendance (DI) : on donne à la fonction les objets qui lui permettent de faire son travail, au lieu qu'elle les choisisse elle-même. Certes, c'est plus de choses à faire pour l'appelant, mais ça lui donne la liberté de choisir quels outils donner, ceux qui correspondent au contexte particulier qu'il connaît mieux.

Enfin pour la v7, on s'est permis de créer des fonctions pures : leurs sorties dépendent très exactement et seulement de leurs entrées, elles n'ont aucun effet de bord. Cela les rend testables et infiniment adaptables, il suffit de respecter les conventions fixées (par une abstraction, une interface, du typage structurel, etc).

On a ainsi isolé le code "métier" (la transformation ou synthèse des informations) du code de l'environnement (la récupération des informations ou l'affichage du résultat, les technologies ou implémentations particulières pour le faire…). On peut ainsi travailler sur chaque partie séparément, sans avoir besoin du reste, de la façon qu'il nous plaît.

C'est le principe de l'Architecture Hexagonale : on isole notre code métier des contingences externes. Voici un exemple de comment cela se traduirait en termes de découpage :
    - core/  # le dossier contenant le domain métier
    - reports.py  # les fonctions de génération de rapport
    - fetching.py  # la classe abstraite Fetcher
    - printing.py  # la classe abstraite Printer
    - tests/  # les tests du code métier
        - fakes.py  # les classes Fake dérivées pour Fetcher et Printer
- fetchers/  # le dossier contenant toutes les classes de prod dérivées de Fetcher
    - gitlab.py
    - bitbucket.py
    - tests/  # les tests pour ces implémentations
- printers/  # le dossier contenant toutes les classes de prod dérivées de Printer
    - stdout.py
    - html.py
    - tests/  # les tests pour ces implémentations
- main.py  # le point d'entrée dans l'application

Si on raisonne en termes de dépendances :

  • les fonctions du core ne dépendent que d'elles-mêmes
  • les tests du core ne dépendent que du core
  • les fetchers dépendent de la classe abstraite définie dans le core (interface)
  • les printers pareil
  • main dépend des fonctions du core ET dépend des classes dérivées pour lui fournir les services attendus

Ainsi, le core est "pur" des dépendances concrètes, les dépendances concrètes ne se soucient pas de ce que le core fera d'elles à part du contrat qui a été défini par la classe abstraite. On a une architecture bien moins couplée, où les responsabilités sont clairement séparées.

Représentation sous forme d'hexagone du domaine contenant ses fonctions à l'intérieur, avec ses interfaces (les classes abstraites) sur les bords, et les classes dérivées à l'extérieur-droit.
Si pour générer les rapports il faut disposer d'autres choses (les congés de l'équipe, l'objectif du sprint actuel, etc), je peux rajouter autant de nouvelles interfaces et leurs implémentations.

On a bien vu jusqu'ici les dépendances dont le core a besoin pour réaliser ses fonctions, mais il y a aussi les dépendances dans l'autre sens, celles qui dépendent du core pour réaliser leurs fonctions. Jusqu'à maintenant, on se contentait de mettre tout ça dans le main et bidouiller selon les besoins, mais notre core pourrait s'exposer de différentes façons : programmatiquement comme on a fait jusqu'à maintenant, via une API Rest, dans une appli en ligne de commande, ... Voilà ce que ça pourrait donner (v8) : programmatiquement :
    def generate_personal_report(username,
                             gitlab_credentials, gitlab_url,
                             bitbucket_credentials, bitbucket_url,
                             output_file_path) -> None:
    from core import compute_personal_report
    from fetchers import GitLabFetcher, BitBucketFetcher
    from printers import FilePrinter
    fetchers = [
        GitLabFetcher(gitlab_url, gitlab_credentials),
        BitBucketFetcher(bitbucket_url, bitbucket_credentials),
    ]
    personal_report = compute_personal_report(username, fetchers)
    printer = FilePrinter(output_file_path)
    printer.display_personal_report(personal_report)
en ligne de commande
    def main():
    from argparse import ArgumentParser
    # configurer l'ArgumentParser
    args = parser.parse()
    if args.personal_report:
        # récupérer la config qu'il faut depuis les args
        # instancier tout ce qu'il faut : fetchers, printer, ...
        compute_personal_report(...)
        # ...
    elif args.global_report:
        # pareil
        compute_global_report(...)
        # ...
    else:
        # ...
via une API web
    app = Flask("web-API de génération de rapports")

from core import compute_personal_report, compute_global_report
from fetchers import GitLabFetcher, BitBucketFetcher
from printers import JsonPrinter

GITLAB_URL = ...
GITLAB_CREDENTIALS = ...
# ...
FETCHERS = [
    GitLabFetcher(GITLAB_URL, GITLAB_CREDENTIALS),
    BitBucketFetcher(BITBUCKET_URL, BITBUCKET_CREDENTIALS),
]
PRINTER = JsonPrinter()

@app.route("/personal_report/<str:username>")
def create_personal_report(username: str):
    return PRINTER.display_personal_report(compute_personal_report(username, FETCHERS))

@app.route("/global_report")
def create_global_report():
    return PRINTER.display_personal_report(compute_global_report(FETCHERS))

def main():
    app.run()

Et si demain je veux rajouter une interface graphique, je n'ai encore une fois rien à changer, juste à réutiliser les briques pré-existantes. Mettons ainsi à jour le schéma :

Mise à jour de la représentation hexagonale précédente, en rajoutant les interfaces nouvellement définies du côté gauche, à l'extérieur de l'hexagone

On a ainsi un hexagone dont les bords représentent des interfaces, soit les différents moyens de l'utiliser, soit les services dont il doit disposer pour fonctionner. Traditionnellement, on les représente respectivement à gauche et à droite. Et là, on approche de la notion de ports and adapters, et de remplir les derniers vides dans le schéma. Mais quelle différence alors avec l'hexagonal alors ? C'est une très bonne question… pour un prochain article !

 

 

Tractatus architecto-hexagonalus

J''espère que les explications fournies jusqu'ici ont permis de vous donner une idée claire de ce qu'est l'architecture hexagonale. En attendant mon prochain article qui traitera des relations entre les concepts qui tournent autour de l'hexagonal, vous pouvez (re-)lire l'Introduction à l’architecture hexagonale qui présente différemment le même sujet, ou bien Pourquoi est-ce important d’écrire des articles et de continuer à vulgariser DDD qui met à profit ce type d'architecture.

Retour aux articles

C'est à lire...