Le piège d’écrire du code couplé à une implémentation


On a reproché à la communauté de Twisted que c’était un silo fermé. Une lib écrite pour Twisted ne marchait que pour Twisted.

Puis on a reproché à la communauté de gevent la même chose.

Et maintenant la communauté d’asyncio recommence à faire la même erreur.

Regardez, pleins de libs compatibles asyncio, c’est génial non ?

Je ne vais pas dire non. Ça boost l’utilisabilité, l’adoption, etc.

Mais c’est aussi un énorme travail qui passe à côté de toute l’expérience (bugs, cas extrêmes, best practices, perfs…) des communautés précédentes. Et qui a une date de péremption, qui sera foutu à la poubelle à la prochaine vague.

Pourquoi ?

Parce que toutes ces libs se concentrent sur l’implémentation.

Vous ne pouvez pas réutiliser le code d’une lib SMTP Twisted, car elle est liée au reactor. Pourtant cette lib contient des milliers d’infos utiles, la gestion de ce serveur bizarre, la correction de cette erreur de calcul de date, qui n’ont rien à voir avec Twisted.

C’est la même chose avec ces libs pour asyncio.

Que faire alors ?

Et bien d’abord écrire une lib neutre. Qui contient des choses comme :

  • Le parsing des packets.
  • Le workflow.
  • Les constantes.
  • Les convertisseurs de données.
  • Les vérificateurs de données.
  • Les bases de connaissances sur le monde extérieur.

Il faut écrire cette lib de manière à ce qu’elle puisse être réutilisée dans tout contexte. À base de callbacks simples, de hooks, de points d’entrées.

Puis, vous rajoutez dans un sous-module, le support pour votre plateforme favorite. Un adaptateur qui utilise ces hooks pour Twisted, ou asyncio, ou gevent.

Cela a de multiples bénéfices:

  • Quand vous changez de plateforme ou que le nouveau joujou à la mode sort, une grande partie du travail peut être réutilisé.
  • Toute la partie neutre de la lib peut être réutilisée par toute la communauté Python, pas juste celle qui utilise la même plateforme.
  • Cela encourage les contributions à votre lib, puisque n’importe qui peut rajouter un module pour une plateforme et l’enrichir, et corriger des bugs sur la partie neutre.
  • Votre lib est beaucoup plus facile à tester.
  • Votre lib va cumuler du savoir sur la problématique qu’elle résout pendant bien plus de temps, puisqu’elle traverse les modes des implémentations. Au long terme, ce sera la lib la plus stable et complète.

Toutes les plateformes ont une manière ou un autre pour attaquer ce problème. Twisted par exemple a une classe protocole qui est indépendante du reactor. Oui, mais elle est dépendante de la manière de penser en Twisted. Personne ne documente ces protocoles de manière neutre. Personne n’utilise ces protocoles de manière neutre.

gevent utilise carrément le monkey patching pour essayer de se rendre transparent. Évidemment ça veut dire que c’est très dépendant de l’implémentation. Si CPython change, ça casse. Si on utilise une implémentation différente de Python, ça ne marche pas. Si on fait des expérimentations comme actuellement sur le JIT, les résultats sont imprévisibles.

async/await a l’énorme bénéfice de proposer une interface commune à tout travail asynchrone. Fut-ce de l’IO, du thread, du multi-processing, du sous-processing, du multi-interpretteur ou des callbacks ordinaires… Cela va donc énormément gommer ces problèmes de compatibilité, même si la séparation des responsabilités que je recommande n’est pas suivie. Mais pour le moment tout le monde n’implémente pas __await__. Et si __await__ lance le code sur l’autre plateforme, ça fait un truc en plus à gérer. Ce n’est pas tout à faire neutre.

Attention, je comprends très bien que cette séparation que je recommande ne soit pas suivie.

C’est très difficile de faire une API agnostique par rapport à la plateforme. Ça demande beaucoup plus de taf, de connaissance, etc. Je suis le premier à ne pas le faire pas fainéantise ou ignorance.

Mais il faut bien comprendre qu’à chaque fois, on réinvente la roue, une roue jetable par ailleurs.

Bien entendu, je dis ça pour l’async, mais c’est vrai pour tout.

Par exemple, des centaines de code ont leur propre moyen de définir un schéma et valider les données en entrée. Les ORM sont particulièrement coupable de cela, les libs de form aussi, mais on a tous codé ce genre de truc. C’est idiot, c’est un code qui n’a pas à être lié à une plateforme.

Des centaines de libs ont leur code de persistance lié à une plateforme. Même celles qui utilisent un ORM, au final, se lient à certaines bases de données (raison pour laquelle je suis GraphQL de très près).

La généricité a ses limites, et c’est toujours un compromis entre le coût immédiat, et le bénéfice futur. Si on fait tout générique, on se retrouve avec un truc qui évolue à 2 à l’heure et qui a 15 surcouches pour faire un print. On se retrouve avec Zope. Dont personne, et c’est ironique, ne réutilise les composants parce que c’est devenu trop compliqué de les comprendre.

Car évidemment, qui dit découplage, dit doc bien faite, qui explique clairement comment bénéficier de ce découplage. Mais dit aussi que le code utilisant les adapteurs doit être aussi simple que si on avait un fort couplage, ce qui est dur à faire.

Et on tombe ici sur un autre problème : la compétence pour faire ce genre de code. Si il faut 10 ans d’expérience pour faire une lib propre, alors on va réduire considérablement le nombre de personnes qui vont oser coder des libs.

Aussi cet article n’est en aucun cas un savon que je souhaite passer aux auteurs. Merci de coder ces libs. Merci de donner de votre temps.

Non, cet article est juste là pour dire : dans la mesure du possible, il est très bénéfique sur le long terme de se découpler de la plateforme.

10 thoughts on “Le piège d’écrire du code couplé à une implémentation

  • cym13

    Bon article, il me rappelle celui-ci sur la différence entre librairie et framework. En anglais+F#, désolé.

  • Sytoka

    C’est normalement tout à fait la philosophie du CPAN de Perl est de lib sur la couche AnyEvent. Ceci dis, Python a le problème de ne pas avoir de CPAN, les espaces de noms ne se construisent pas de manière aussi collective que dans Perl.

  • ultra

    Ce post est une parfaite illustration des design patterns.

    Un des design patterns le plus recommandé et de créer un maximum de classes abstraites / interfaces et laisser à la fin l’implémentation.

    Le hic dans python c’est qu’il n’y pas de classes abstraites et encore moins d’interfaces.

    Forcément le dèv s’attaque directement à l’implémentation.

    Autre point, faire du design avant d’implémenter, c’est après quelques années de galères à découpler dans tous les sens les travaux des autres qu’on en voit l’intérêt.

    Donc oui, faut de l’expérience + pas mal de connaissances théoriques sur les design patterns => ça limite le nombre de contributeurs.

    Après, comme tu le dis si bien, faut pas tomber dans le piège du design pour le design à la zope mais en général, il est facile d’identifier ce qui est spécifique au générique.

  • Ludovic Gasc

    Avec ma casquette d’ingénieur, je serais d’accord avec toi.

    Mais, ceci n’engage que mon expérience, les avantages que tu y vois sont, de mon point de vue, fortement contrebalancés par les coûts de maintenance et le ticket d’entrée des contributeurs.

    3615 my life pour illustrer: Exemple qui m’est arrivé au début que j’utilisais AsyncIO, outre Crossbar qui est multi async framework, il y a également Obulus, un binding Asterisk écrit par Antoine Pitrou (coucou Antoine): https://bitbucket.org/optiflowsrd/obelus

    Antoine a fait un gros travail d’abstraction pour justement supporter différents frameworks async.

    Je suis surement très bête, mais j’ai eu beaucoup de mal à comprendre comment ça fonctionnait en dessous pour pouvoir l’enrichir.

    Pour finir, j’ai pris Panoramisk, https://github.com/gawel/panoramisk qui sur le papier était moins avancé à l’époque mais où j’arrivais à rajouter des fonctionnalités mais surtout à être autonome pour debugger par moi-même.

    Autant je suis partisan pour lire du code d’implémentation d’autres librairies pour récupérer des idées, autant vouloir une abstraction dans tous les sens me paraît augmenter les coûts de maintenance et d’ajout de fonctionnalités de manière exponentielle.

    L’async est déjà assez compliqué à gérer par lui-même, vouloir en plus être multi async framework dont chacun n’a pas forcément la même stratégie de scheduling, c’est un peu comme vouloir être ami avec tout le monde: à un moment, il faut faire des choix sinon on risque de manquer de cohérence ;-)

    Concrètement, quand tu vas avoir un bug en production dans la couche d’abstraction, vas-tu être capable de corriger rapidement ? Chaque librairie que tu rajoutes avec cette surcouche, es-tu capable de plonger dedans au prochain problème ?

    Si oui, tant mieux, mais quelle est la probalité que d’autres vont également y arriver ?

    Sans parler du fait que tous les frameworks async ne sont pas égaux, et donc ça va être un sous ensemble qui sera utilisé, un peu comme les ORMs avec les DBs.

    Mais après, vous faites comme vous voulez les gens, c’est votre temps libre/votre stress à gérer quand il y aura un problème ;-)

    tu auras quelque chose de simple, + ça sera facile d’accès aux développeurs.

    Pour moi, les approches à la Java/Zope avec beaucoup d’abstractions me semblent avoir déjà montré par le passé que cela n’est pas la silver bullet pour faire du développement.

  • yoshi120

    Je ne suis pas sur d’avoir compris le propos de l’article.

    Si je prend l’exemple de http://zeromq.org/ . C’est une lib qui repose sur un protocole qui fait que zeromq ne parle qu’avec zeromq.

    Ce protocole n’a été extrait que récemment et il me semble que c’est beta : http://zmtp.org/

    Qu’est ce que ça veut dire : dépendre de l’implémentation ? Quel contre exemple positif à donner ?

    Je crois sans te vexer Sam que tu confond ou plutôt que tu n’explicite pas le flou qu’il existe entre :

    Paradigme de programmation
    Théorie informatiques pure (Un truc con) (1)
    Théorie informatiques des OS (pipe en Shell) (2)
    Concepts first class (goroutine en Go, Erlang, monade Haskell) dans un langage

    (Pour ZeroMQ, le fait qu’elle est codé en C++ rend le bindings simple
    alors qu’une lib python dépend du langage et est difficile à inclure, intégrer dans un autre ou pire un sous langage.

    designs patterns
    bibliothèques
    frameworks
    toolkit

    (1) (2) Je pose la question à 1 000 000 de dollars en jouant au candide. Comment cela ce fait il que les pipes shell bash n’existe que shell et pas dans tout les langages ? C’est bizarre, tous les langages possède des fonctions (subroutine) ?

    Pourquoi ?

  • touilleMan

    Je suis tout à fait d’accord avec cet article, c’est même le gros problème que je vois avec asyncio : son aspect explicite le rend de base incompatible avec une programmation synchrone classique.

    Du coup que faire ? Pour migrer de Python2 vers Python3 ont été mis en place des outils et des best practices, ne serait-ce pas une solution que de faire de même pour ce nouveau problème.

    Je verrais bien un projet expliquant comment faire en sorte qu’une bibliothèque synchrone soit compatible

    – On considère une bibliothèque de basique utilisant les sockets python standard

    – On présente comment abstraire les implémentations bas niveau

    – On fourni des implémentations de base pour la version synchrone, asynchrone avec gevent et celle asynchrone avec asyncio

    – On explique comment faire pour garder les choses compatible Python2/3 (notamment avec les “yield from” qui n’existent pas en python<3)

    Je pense qu’une démo par l’exemple de cette forme fournirait une guideline très pratique, toutefois je reste dubitatif sur la faisabilité même d’une bibliothèque compatible synchrone/asyncio vu que le second oblige à penser toute sa pile d’appel avec des “async”…

  • Sam Post author

    @yoshi120 : pour zeromq l’implémentation est en C, et on a des wrappers autour, donc la question ne se pose pas car la séparation existe déjà de facto. Il n’y a pas d’implémentation en Python. Mais prenons par exemple une implémentation d’un client redis. Une grosse partie du boulot c’est la validation des paramètres, leur sérialisation, la gestion des erreurs, la création des packets, etc. Se connecter à redis et envoyer les messages n’est qu’une petite partie du boulot. La bonne pratique serait donc de faire cette partie de manière neutre, et de proposer des hooks pour la partie connection / envoie de message, puis de faire des modules séparés spécifiques à twisted/tornado/asyncio qui utilisent ses hooks pour gérer la partie IO.

  • Ludovic Gasc

    @Sam: splitter le parsing du protocole avec la partie réseau me paraît par contre une bonne idée pour mutualiser des ressources.

  • Sam Post author

    Pour zeromq ? Oui mais comme c’est écrit en C, on y a pas accès. On peut pas reprocher aux gens d’asyncio de ne pas le faire.

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.