L’architecture héxagonale autrement

Une explication from the bottom-up

Julien LENORMAND - Kaizen Solutions

L’architecture héxagonale

Qui en a déjà entendu parler ?

Qui connaît ?

Qui pourrait me l’expliquer ?

Encore une explication ?

sur le blog Kaizen Solutions :

une introduction à l’architecture héxagonale par Xavier Bouvard

emoji : le doute

Pourquoi si compliqué ?

Quizz sur les pré-requis :

  • SOLID ?
  • domaine ?
  • API vs SPI ?
  • différence entre infrastructure et persistance ?
  • controller dans la Clean Architecture ?
  • lire du code Java écrit en français ?

du jargon …

Une approche explicative différente

qui est dèv ici ? on veut du code !

partir d’un problème concret et réel

principalement du code, qu’on va commenter ensemble

pas/peu de concept avant la fin (wrap-up)

Allez on code !

Le problème initial

Suivre l’avancement des PRs et de leurs relectures par plusieurs personnes au sein d’un projet micro-services.

La première solution

Je bricole un petit proto en Python :

v1

import stashy  # lib pour BitBucket

login = "Julien"
token = "AbCdEf01#"
server_url = "https://bitbucket.internal.corp:1234"
project_name = "AwesomeProject"  # contient plusieurs repo

stash = stashy.connect(server_url, login, token)

for repo_data in stash.projects[project_name].repos.list():
    repo_slug = repo_data["slug"]  # identifiant
    print("Repo " + repo_slug)
    for pr_data in (stash.projects[project_name]
            .repos[repo_slug].pull_requests.list()):
        title = pr_data["title"]
        author_name = pr_data["author"]["displayName"]
        print(f" - PR {title!r} de {author_name!s}")
        for reviewer_data in pr_data["reviewers"]:
            reviewer_name = reviewer_data["user"]["displayName"]
            has_approved = bool(reviewer_data["approved"])
            print(f"   - {'OK' if has_approved else '  '} {reviewer_name}")
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 peut faire plus clair : indiquer ce qui est actionnable !

Une seconde solution

v2

my_id = "123456"

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")
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

LGTM

Règles d’équipe

Je rajoute :

  • 2 relectures par PR
  • relancer les “needs work”
  • voir si une PR approuvée à évoluée
  • repérer les PR délaissées

Un super outil

plusieurs versions

. . .

émergence de 2 besoins distincts :

  • vision “personnelle”
  • vision “d’équipe”
@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"
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

v3

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)

Tester avec de fausses données

Sérialisation :

...  # définitions d'auparavant

...  # 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((dataclasses.asdict(repo) for repo in repos), test_data_file)
   # je vous épargne le fait que les `datetime.datetime` soient pénibles à sérialiser

Désérialisation :

# 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)
def test__team_global_view__case07():
    # Given
    repos = load_from_file("master_record_07.json")  # avec quelles données
    
    # When
    actual_output = compute_the_team_global_view(repos)  # ce que j'obtiens maintenant
    
    # Then
    expected_output = load_from_file("master_record_07.txt")  # à quoi doit ressembler la sortie pour le cas 07
    assertStringEquals(expected_output, actual_output)  # ou la fonction appropriée de votre framework de test
 Repo AlpesDHuez
  - PR "add foo to bar" de Elena
-   - OK Gabin
-   -    Julien
+   - ✔️ Gabin
+   - ✳️ Julien

Sale code !

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

Fonctionnalités voulues :

  • rendu en HTML
  • repos sur GitLab
  • anticiper les congés
  • commenter sur les PRs
  • grouper par Story plutôt que repo

Gros refacto

Hausse de la complexité ➡️ être plus exigeant

Changement de scope ➡️ prendre le temps de repenser

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, …).

Abstraction

class PullRequestsFetcher:  # classe abstraite
    def fetch_pull_requests(self) -> Sequence[PullRequest]:
        raise NotImplementedError  # méthode 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

v5

def fetch_pull_requests(fetchers: Sequence[PullRequestsFetcher]):
    for fetcher in fetchers:
        for pull_request in fetcher.fetch_pull_requests():
            ... # utiliser la pull request 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

Test

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)

Test (bis)

Peur de ne tester que les fakes ?

Test 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

Refacto encore !

Après les fetchers, les printers !

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(...)  

v6

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)

Et encore !

All problems in computer science can be solved by another level of indirection.
— Butler Lampson, Beautiful Code, 1972

Tous les problèmes en informatique peuvent être résolus par un niveau supplémentaire d’indirection.

@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

v7

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)

Evolutions possibles :

  • nouveau type d’entrée
  • façon différente de fetcher
  • nouveau type de sortie
  • façon différente de printer

Une solution parfaite ?

Non, pas du tout !

  • leaky abstractions
  • classes abstraites
  • problème N x M
  • nommage
  • complexité de la structure

Rappel du chemin parcouru

  • v1 : v0 quick and dirty, vue globale
  • v2 : vue personnelle (à la place de la vue globale précédente)
  • v3 : réintroduction de la vue globale (2 fonctions, je commente/décommente celle que je veux)
  • v4 : après ajout des interfaces de test, le main ressemble à rien
  • v5 : fonction fetch_prs qui utilise des fetchers abstraits
  • v6 : fonction reporter.display qui tape sur des reporters abstraits
  • v7 : introduction des fonctions compute

Architecture hexagonale !

- 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

Dépendances

Accès programmatique (API)

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)

Accès interactif (CLI)

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

Accès web

from flask import Flask  # framework web simple et rapide
from core import compute_personal_report, compute_global_report
from fetchers import GitLabFetcher, BitBucketFetcher
from printers import JsonPrinter

app = Flask("web-API de génération de rapports")
# ...
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()

Crédits photo

Photo by Robert Linder on Unsplash

Photo by David Clode on Unsplash

Photo by Dmitriy Demidov on Unsplash

Photo by Stephen Radford on Unsplash

Photo by Christian Wiediger on Unsplash

Photo by danilo.alvesd on Unsplash

Photo by drmakete lab on Unsplash

Julien LENORMAND

Responsable du Pôle Software

julien.lenormand@kaizen-solutions.net