L’avantage d’avoir quelques années de programmations dans les pattes et un certain nombres de projets à son actif, c’est qu’on arrive à identifier des motifs communs qui se dégagent encore et encore.
Par exemple, quand j’étais en tout début de carrière, j’ai ouvert l’excellent bouquin “Head first design patterns” et je n’en ai pas retiré grand chose car je n’avais pas la matière pour pouvoir identifier l’utilité des solutions proposées. Bien plus tard, en le relisant, je me suis aperçu que j’avais en fait rencontré moult fois chaque chapitre IRL, base de code après base de code.
La vie apprend les design patterns bien plus efficacement que les écrits. Mais ces derniers ont l’avantage de mettre de l’ordre dans ses idées. Ils mettent des mots sur des pensées floues, et tracent des contours qui délimitent pragmatiquement les concepts.
Aujourd’hui néanmoins, nous n’allons pas parler de design pattern, bien que faire un dossier dessus serait une bonne idée. Mais ça fait des mois que je dois finir le dossier tests unitaires, alors je vais pas commencer un nouveau dossier.
Non, aujourd’hui, nous allons parler d’outils dont on a besoin dans quasiment tous les projets importants et qu’on réinvente, ou rapièce, presque à chaque fois.
Dispatching
Que ce soit parce que vous avez un observer, du pub/sub, un système de routing ou des events internes, votre projet finira par avoir besoin d’un système de dispatching. Le dispatching c’est la propagation/distribution de l’information et de son traitement.
Ils ont toujours la même chose en commun:
- Des points centraux qui prennent les entrées et les dirigent vers les bonnes sorties.
- Un système d’enregistrement des points d’entrées (hooks) et des points de sorties (endpoints).
- Une logique de résolution qui permet de faire transiter les données depuis une entrée, vers une ou plusieurs sorties, parfois aller-retour.
Par exemple:
- Le pub/sub de redis ou crossbar.io.
- Le routing URL d’un framework Web comme Django ou Rails.
- Les events d’un UI comme le addEventListener en JS ou connect() en QT.
- La remontée hiérarchique des messages de logs dans le module Python logging.
Ce sont tous des implémentations spécialisées d’un système de dispatching. Le hello world du dispatching est le design pattern observer, qui minimalement ressemble à ça:
>>> class Dispatcher: ... def __init__(self): ... self.registry = {} ... def on(self, event, callback): ... self.registry.setdefault(event, []).append(callback) ... def trigger(self, event): ... for callback in self.registry[event]: ... callback() ... ... hub = Dispatcher() ... ... hub.on("j'arrive", lambda: print('coucou')) ... hub.on("j'arrive", lambda: print('salut')) ... ... hub.trigger("j'arrive") ... coucou salut |
En fait, à moins de faire uniquement des scripts, vous avez utilisé plein de systèmes de dispatching sans le savoir.
Bien que pour qu’ils soient utiles il faut des versions spécialisées pour chaque usage, c’est un problème générique et il est ridicule que nous devions réimplementer à chaque fois ce truc. Un bon système de dispatching est utile dans tout gros projet. Vous voulez permettre à quelqu’un de lancer du code quand votre système s’initialise ? Créer une logique de plugin ? Bam, il faut du dispatching.
Il faudrait donc un framework en Python qui permette de fabriquer son propre système de dispatching. Il devra bien entendu inclure des implémentations spécialisées au moins pour les cas les plus courants sinon ça fera comme une lib zope et ça prendra la poussière.
Le but étant qu’au bout de quelques années, tout le monde base son implémentation sur cette brique, robuste et documentée, plutôt que de créer son propre système.
En effet, un bon système de dispatching doit pouvoir gérer les cas suivants :
- Mono directionnel ou bi-directionnel.
- Un ou plusieurs endpoints.
- Middlewares.
- Algo de routing custo.
- Passage de paramètres à l’ajout d’un enpoint.
- Passage de contexte à l’appel de l’endpoint.
- Appel synchrone ou asynchrone des endpoints.
- Enpoints locaux ou remotes.
- Gestion de plusieurs dispatchers en parallèle.
- Interconnexion de plusieurs dispatchers.
- Adapters pour les dispatchers courants déjà existants.
Le tout bien entendu avec des backends pour chaque partie qu’on puisse swapper.
Configuration
La conf, c’est l’exemple exacte de la fragmentation dans notre métier. C’est l’usine à roues (carrées) réinventées.
Sérieusement, entre le parsing des arguments de la ligne de commande, les fichiers de config, les services de config (etcd anyone ?), les configs sauvegardées en BDD, les API de conf, les variables d’environnement, etc. c’est un bordel sans nom.
Tout le monde a fait son petit fichier params.(ini|yml|xml|json) ou sa table SQL settings dans un coin. Et la validation. Et la génération. Et les valeurs par défaut. Et l’overriding des envs. Ca change à chaque projet, à chaque framework, à chaque foutue lib.
C’est que le but est simple, mais le problème est complexe. Mais on en a tous besoin, et il n’y a rien, mais alors rien qui existe de générique.
Une bonne lib de conf doit:
- Offrir une API standardisée pour définir les paramètres qu’attend son programme sous la forme d’un schéma de données.
- Permettre de générer depuis ce schéma les outils de parsing de la ligne de commande et des variables d’env.
- Permettre de générer depuis ce schéma des validateurs pour ces schémas.
- Permettre de générer des API pour modifier la conf.
- Permettre de générer des UIs pour modifier la conf.
- Séparer la notion de configuration du programme des paramètres utilisateurs.
- Pouvoir marquer des settings en lecture seule, ou des permissions sur les settings.
- Notifier le reste du code (ou des services) qu’une valeur à été modifiée. Dispatching, quand tu nous tiens…
- Charger les settings depuis une source compatible (bdd, fichier, api, service, etc).
- Permettre une hiérarchie de confs, avec une conf principale, des enfants, des enfants d’enfants, etc. et la récupération de la valeur qui cascade le long de cette hiérarchie. Un code doit pouvoir plugger sa conf dans une branche de l’arbre à la volée.
- Fournir un service de settings pour les architectures distribuées.
- Etre quand même utile et facile pour les tous petits scripts.
- Auto documentation des settings.
Pas évident non ? Ça change de “ah bah je vais dumper tout ça dans un settings.py et on verra bien” :)
Les bénéfices d’avoir un bon système de settings sont énormes. D’abord, si il est largement adopté, plus besoin de fouiller dans la doc de chaque projet pour savoir comment l’utiliser. Les problèmes difficiles comme les “live settings” sont réglés une fois pour toute. Plus besoin d’écrire pour la millième fois le code de glue entre son schéma marshmallow + ses params clicks + son parser yml qui sera forcément bricolé. Et une expérience utilisateur bien meilleure avec de la doc, des messages standardisés, des checks de sources de données que d’habitude personne ne fait, etc.
Logging
Oui, je sais, je sais, Python a un excellent module de logging, très riche et polyvalent. Et puis ce ne sont pas les projets de logging qui manquent. Il y a celui de twisted, il y a logbook, logpy… En fait j’ai même pondu devpy.
Malgré ça, force est de constater que tous ces projets sont loin d’être une bonne solution pour convenir à tous.
Le module logging Python manque de configuration par défaut, n’a pas de gestion multiprocessing, aucune facilité pour générer des logs structurés ou binaires, etc. logbook et logpy sont des surcouches qui améliorent, mais sans aller assez loin, l’expérience. Twisted comme d’hab fait le café mais est indigeste. Logpy n’est bien que pour les cas simples.
Un bon module de logging devrait:
- Etre compatible avec la stdlib, et avoir des adaptateurs pour les systèmes les plus courants (comme twisted).
- Gérer le multiproccessing, particulièrement le locking des fichiers ou avoir un process central dédié aux logs.
- Proposer une UI user friendly de consommation des logs.
- Avoir des pré-configurations prête à l’usage hyper simple et rapide à utiliser pour les cas les plus simples, comme le scripting.
- Gérer des formats avancés: json, binaires, nesting, logs structurés, etc. Avec des recettes toutes faites pour les cas courants.
- Avoir une bonne intégration avec les systèmes de logs natifs de l’OS sans besoin de trifouiller.
- Proposer des outils d’analyse de logs.
- Offrir des solutions pour les cas simples (console, fichier, coloration, post-mortem, etc.) ou complexes mais courants (mail, logstash, sentry, log sur serveur distant, etc).
Au fait, aviez-vous noté que le cœur d’un système de log est un dispatcheur ? :)
Lifecyle
Dès que vous créez un projet, il a un cycle de vie. Il s’initialise, charge les paramètres, load les plugins si il y en a, lance son processus principal, puis finit par s’arrêter, ou foirer, ou les deux.
Si c’est un petit script, ce n’est pas très important, on ne s’en rend même pas compte.
Si c’est un gros projet, vous allez vouloir que le code du reste du monde puisse interagir avec ça. D’ailleurs, tous les gros frameworks vous permettent de réagir au cycle de vie. Django a le fichier appconfig.py par exemple pour lancer du code au démarrage du framework, et des middlewares pour intercepter les requêtes et les réponses. Twisted permet de dire “lance ce code dès que le reactor est en route”. Pour comprendre Angular ou une app Android, la moitié du boulot c’est de piger les différentes phases du cycle de chaque composant.
Le cycle de vie est en fait un système de dispatching (surprise !) couplé à une machine à état fini, et concrétisé dans un processus métier. La bonne nouvelle, c’est que des libs de state machines en Python on en a un max, et des bien fournies. La mauvaise, c’est qu’avec la popularité grandissante d’asyncio, on a de plus en plus besoin de gérer explicitement le cycle de vie de ses projets et qu’on a rien de générique pour ça alors la cyclogénèse envahie la communauté.
En effet, dès qu’on a une boucle d’événement comme avec asyncio/twisted/tornado, on a un cycle de vie complexe mais implicite qui se met en place puisque la loop démarre, s’arrête, est supprimée, est remplacée, est en train d’exécuter une tâche, une tâche qui peut générer des erreurs… Et très vite le cycle dégouline de partout, et on commence à coder ici et là pour gérer tout ça sans se rendre compte qu’on crée petit à petit un énième framework de lifecycle. Pas vrai Gordon ?
C’est l’histoire de la viiiiiiiiiiiiiiiie. C’est le cycle éterneleuuuuuuuuuh. De la roue infinieeeeeeee. Codée à la truelleuuuuuuh.
Structure de projet
Bon, imaginons que vous ayez une lib de life cycle, qui charge vos settings avec votre super lib de conf, logge tout grâce à votre géniale lib de logging, le tout powered par une lib de dispatching que le monde vous envie. Le perfide.
Je dis “imaginez” parce que dans votre projet vous avez plutôt un tas de crottes retenues par un cornet de glace que vous avez codé pour la énième fois à la va vite en utilisant 30% de libs tierce partie, 30% d’outils de votre framework du jour et 40% de roux (du NIH sans âme quoi…).
Donc imaginez ça. Et maintenant vous voulez mettre en place un moyen de diviser votre projet en sous parties. Peut être des apps, ou pourquoi pas des plugins. Mais vous voudriez que tout ça soit gérable par un point d’entrée principal, ou individuellement. Que ça se plug dynamiquement. Que ça joue bien avec votre système de conf et de lifecyle. Diantre, vous voulez qu’un code externe puisse être découvert et pluggé au système. Choisir si ça tourne dans des threads ou des processus séparés. Mais communiquer entre les parties. Et que tout ça soit découplé bien entendu ! Sauf qu’il y a une gestion de dépendances des plugins…
Pas de problème, vous prenez un bus de communication, un système de plugin, un graph de résolution de dépendances, vos super libs ci-dessus et vous gluez tout ça avec de la logique de chez mémé et de la sueur. Une mémé si ronde qu’elle a un pneu autour de la taille. Et un essieu.
Django a ses apps. jQuery a ses plugins. L’app d’un de mes clients avec un hack à base d’importlib et ctype qui loadait une dll pour charger les drivers de leur matos. Ca roule Maurice, ça roule à mort.
Il nous faut une lib de référence qui permette:
- De diviser son projets en plus petites unités tout en gardant un point central pour faire le lien.
- De découvrir, charger et décharger ces unités.
- Permettre de répartir ces unités dans des threads, multiprocess, machines.
- Les faire communiquer.
Et dans les ténèbres les lier
Une fois qu’on a tout ça, il faut bien entendu un gros framework qui permette de faire le lien entre tout ça et coder un projet automatiquement intégré.
Imaginez… Imaginez pouvoir faire un truc comme ça:
from super_framework import projets, config # Tout est configurable et réassemblable, mais le framework offre des réglages # auto pour les usages simples project, app, conf = projets.SimpleProject('name') # Fichier de conf automatiquement créé, parsé et vérifié. Valeurs exposées en # CLI et overridables. @config.source(file="foo.tml") class Schema(config.Schema): foo = config.CharField(max_len=30, live=True) bar = config.DateField(optional=True, local=True) baz = config.TextField( verbose_name="Basile", default="Je sers la science et c'est ma joie" ) # Lancé automatiquement à la phase d'init du projet # Des events comme ça sont lancés pour chaque app, et chaque phase de vie de # chacune d'elles. @project.on('init') async def main(context): # un log sain automatiquement fourni app.log('Début du projet. Verbosité:', conf.log.level) # Démarre l'event loop. Parse la ligne de commande et les # variables d'env, puis le fichier de conf. Mais seulement si le module n'est # pas importé (comme __name__ == "__main__") # Print le fichier de log automatique dès le démarrage du programme project.cmd() |
Et imaginez que de ce petit script, ça scale sur 20 plugins qui peuvent communiquer, un système de settings live, de gestion d’erreurs et de logs aux petits oignons.
Imaginez que l’api bas niveau soit suffisamment flexible pour que les plus grands frameworks puissent réécrire exactement la même API qu’ils ont déjà en utilisant cette fondation. Imaginez que tous vos projets futurs soient du coup compatibles entre eux.
Vous pouvez imaginez longtemps, car ça n’arrivera jamais. Mais j’avais du temps à l’aéroport alors j’ai écrit cet article.
J’ai peut-être mal suivi, mais… le trigger du Dispatcher devrait pas plutôt ressembler à ça ?
if event in self.registry:
for callback in self.registry[event]:
callback()
Là dans l’exemple, le nom de l’event ne sert à rien.
Absolument. Je corrige de ce pas.
Quelques petites coquilles :
vous avez utilisez plein de systèmes => vous avez utilisé plein de systèmes
la moitiée du boulot c’est de pigée les différentes phase => la moitiée du boulot c’est de piger les différentes phases
Sinon c’est beau de rêver, mais faut hélas retourner travailler dans la réalité…
Heureusement que Linus s’est pas dit ça lol. Merci pour les typos.
Merci pour ce partage de point de vue ! Bon article, mais ça manquait un peu de cul quand même !
Je trouve pas ça entièrement irréalisable ni irréaliste mais :
1. il faudrait quelque chose de compilé avec des bindins par langage pour toucher plusieurs communautés.
2. Que ça soit porté par un acteur avec un minimum de notoriété : mozilla, google etc. pour que la mayonnaise prenne.
Mmh, je me suis permis de rajouter un
@
àproject.on('init')
pour en faire un décorateur deasync def main(context)
, j’espère avoir respecté l’esprit de ce prototype?Lol oui. Si il ne manquait que ca…
Effectivement.
Pour le dispatching je suis un peu dépassé mais pour le logging et la configuration c’est clair qu’un truc universel ce serait génial
Y’a quand même git comme roue bien huilée qui marche partout.
“ĵ’ai même pondu devpy”
Et tu as aussi pondu un magnifique “j accent circonflexe”.
Sinon, concernant le logging, je ne serais pas forcément d’accord.
Pour moi, le logging, c’est ce qu’il reste quand on a tout perdu, et qu’on veut (autant que faire se peut) garder une trace de ce qui a merdé et pourquoi.
Ça veut dire qu’il faut que ça reste le plus simple possible, et respecter le plus possible le contexte d’exécution “natif”.
Il faut que ce soit toujours possible de logger, même si tous les chargements de libs ont foirés, qu’on est dans un bloc except déclenché par un autre bloc except, qu’on vient de perdre la connexion internet, qu’il ne reste plus que 2Ko de disque dur, 1% de charge dans la batterie, et que tous les processus viennent de recevoir un signal d’arrêt car l’utilisateur a appuyé sur “power off”. Et tant pis si ça implique de spécifier des noms de fichier en dur ou de pourrir l’encodage en remplaçant tout ce qui n’est pas de l’ascii par des points d’interrogations.
J’innove.
Heureusement que
LinusRichard s’est pas dit ça quand il a commencé GNU Hurdlol
Ça fait rêver… Logging, conf, plugins (!), c’est tout ce que je déteste et qu’on a réimplémenté dans mon projet au taf.
Et qui est non documenté et non testé :)