Je découvre Task et le Taskfile.yaml

Le Makefile des temps modernes ?

Simple à mettre en place et généralement disponible immédiatement, le vénérable Make (et son Makefile) se propose en général comme première solution quand le besoin d’un build tool se précise. Écrire un Makefile est chose plutôt aisée au début. Une cible simple peut mâcher le travail pour exécuter une ou des commandes plus pénibles à retaper que make cible.

L’outil n’est cependant pas pleinement satisfaisant au fur et à mesure que les règles et leur dépendances se complexifient. J’ai donc décidé de tester un nouvel outil, Task, qui se veut être une alternative moderne à Make et ses Makefile.

Je présente dans ce billet l’usage que j’en fais dans le cadre d’un projet nommé deluge, un système de message queuing écrit en Go. Cette présentation donne un aperçu de l’outil.

En conclusion, je me livre à quelques réflexions quant à Task vis-a-vis des systèmes plus sophistiqués encore.

Bref rappel sur les règles d’un Makefile et son vocabulaire.
Un Makefile se présente comme un ensemble de règles (rules). Une règle permet de déterminer comment créer une cible (target, généralement un fichier à produire) et quand (re)créer cette cible selon l’existence et la date des prérequis (des fichiers). Un prérequis peut être lui-même cible d’une autre règle, ce qui permet alors de combiner astucieusement plusieurs règles, en faisant ainsi sortir un arbre de dépendances déterminant un ordre cohérent de recettes (recipe) à exécuter pour arriver à une cible à partir des autres.1

# Règle
cible: prérequis1 prérequis2
	recette pour parvenir à cible

  1. Une introduction brève mais plus complète est disponible dans le manuel de GNU Make : What a Rule Looks Like.

Task, Taskfile.yaml

Taskfile.yaml

Task travaille avec des fichiers YAML. Personnellement, je ne suis pas le plus enthousiaste en éditant des fichiers YAML. Cependant, la structure d’un Taskfile.yaml est peu imbriquée et reste raisonnable. Voici le premier exemple d’une tâche (task) appelée build qui compile le projet avec Go :

version: '3'

tasks:
  build:
    cmds:
      - mkdir -p bin
      - go build -o bin/deluge -v .  # compilation et création du binaire bin/deluge

La commande suivante permet de lancer cette tâche : task build.

Dépendances et fichiers source

Jusque-là, la différence avec Make ne semble pas notable au delà de la syntaxe. Mais il est à observer dès à présent que Task ne raisonne pas en termes de règles comme Make, mais en termes de tâches, lesquelles sont plus expressives. À la place des cibles, une tâche indique précisément ce qu’on peut en attendre comme résultat après l’exécution. En l’occurrence, un nouveau fichier bin/deluge est généré par le compilateur de Go, cela est indiqué avec generates :

tasks:
  build:
    cmds:
      - mkdir -p bin
      - go build -o bin/deluge -v .
    generates:
      - bin/deluge

Il est aussi possible d’indiquer les fichiers nécessaires à l’exécution de la tâche, ils sont indiqués avec sources :

tasks:
  build:
    cmds:
      - mkdir -p bin
      - go build -o bin/deluge -v .
    generates:
      - bin/deluge
    sources:
      - '*.go'
      - '*/*.go'   # marche aussi avec juste '**/*.go'

Le projet deluge propose une interface gRPC. Cela implique de travailler avec des fichiers Protobuf (extension .proto) à partir desquels du code Go gérant la (dé)sérialisation est généré par l’outil protoc prévu pour cela.

Les choses deviennent intéressantes dès lors qu’il devient nécessaire de générer les fichiers Go à partir du fichier Prorobuf avant de compiler le projet dans son ensemble.

Une nouvelle tâche grpc est créée dans Taskfile.yaml, avec les déclarations de sources et generates adéquates :

tasks:
  build:
    cmds:
      - mkdir -p bin
      - go build -o bin/deluge -v .
    generates:
      - bin/deluge
    sources:
      - '*.go'
      - '*/*.go'   # marche aussi avec '**/*.go' seul
 
  grpc:
    cmds:
      - protoc --go_out=. --go-grpc_out=. service.proto
    sources:
      - service.proto
    generates:
      - grpcsvc/service.pb.go
      - grpcsvc/service_grpc.pb.go

Enfin, la déclaration de dépendance se fait avec une clé deps qu’il convient ici d’ajouter à la tâche build puisqu’elle a besoin d’être lancée après la tâche grpc qui invoquera protoc :

 build:
    deps: [grpc]  # dépendance ajoutée
    cmds:
      - mkdir -p bin
      - go build -o bin/deluge -v .
    generates:
      - bin/deluge
    sources:
      - '*.go'
      - '*/*.go'

Pour chaque tâche déclarée, les informations renseignées dans generates et sources permettent à Task d’effectuer les actions uniquement si nécessaire dans l’accomplissement d’une tâche1. Par exemple, la modification du fichier service.proto (déclarée comme source de la tâche grpc) entraînera en chaîne l’appel protoc --go_out=. --go-grpc_out=. service.proto, modifiant par là-même les fichiers Go, entraînant subséquemment l’appel go build. Task ne relance que les tâches nécessaires à l’accomplissement du but (en parallèle si la chaîne de dépendance le permet), comme pourrait le faire Make.

Il est intéressant de comparer avec les règles du Makefile qui accompagnait le projet lors de ses premières lignes :

grpcsvc/service.pb.go: service.proto
	protoc --go_out=. --go-grpc_out=. service.proto
  
bin/deluge: $(wildcard *.go) $(wildcard core/*.go) grpcsvc/service.pb.go grpcsvc/service_grpc.pb.go
	go build -o bin/deluge -v .

Avec Make, les prérequis sont généralement des fichiers. L’approche de Task me semble bien plus intuitive : une tâche doit dépendre d’autres tâches dans le sens où elles doivent s’exécuter avant, mais Task les distingue bien des fichiers source, lesquels permettent de déterminer si l’exécution des commandes doit avoir lieu.

Malgré le formatage en YAML, la version Taskfile.yaml m’est plus agréable et facile à la lecture. La verbosité est ici une vertu et elle rend le fichier plus explicite.

Préconditions

Task présente la notion de précondition dont la satisfaisabilité est nécessaire à l’exécution de la tâche, sous peine de la voir échouer. La tâche grpc nécessite quelques dépendances Go qu’il convient d’installer pour le bon déroulement des opérations2.

tasks:
  grpc:
    cmds:
      - protoc --go_out=. --go-grpc_out=. service.proto
    sources:
      - service.proto
    generates:
      - grpcsvc/service.pb.go
      - grpcsvc/service_grpc.pb.go
    preconditions:
      - sh: test -f $GOPATH/bin/protoc-gen-go
        msg: "Please try this command: go install google.golang.org/protobuf/cmd/protoc-gen-go"
      - sh: test -f $GOPATH/bin/protoc-gen-go-grpc
        msg: "Please try this command: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc"

Les préconditions testent si les deux dépendances sont présentes (test -f teste l’existence d’un fichier). La clé msg permet d’afficher un message à l’utilisateur en cas d’échec de précondition :

% task build
task: Please try this command: go install google.golang.org/protobuf/cmd/protoc-gen-go
task: precondition not met

Variables d’environnement

Les variables d’environnement sont fixées avec env :

version: '3'

env:
  CGO_ENABLED: '0'
  GOAMD64: v3

tasks:
   ...
   ...
   ...

Elles s’appliqueront alors dans l’ensemble des tâches exécutées. Il est cependant possible de les overrider uniquement pour une tâche donnée en reprécisant un env au sein d’une tâche :

version: '3'

env:
  CGO_ENABLED: '0'  # Initialement à 0
  GOAMD64: v3

tasks:
  test:
    deps: [grpc]
    env:
      CGO_ENABLED: '1'  # Fixé à 1 tel que requis pour utiliser le flag -race
    cmds:
      - echo Ici CGO_ENABLED = $CGO_ENABLED
      - go test -race ./...
    sources:
      - '*.go'
      - '*/*.go'

Cela donne bien le résultat voulu :

% task test
task: Task "grpc" is up to date
task: [test] echo Ici CGO_ENABLED = $CGO_ENABLED
Ici CGO_ENABLED = 1
task: [test] go test -race ./...

Autre fonctionnalités

Task propose d’autres fonctionnalités qui ne sont pas présentées ici, certaines sont plutôt originales comme defer (inspiré du defer de Go). La liste complète est ici.

Je conclus la présentation de Task en mettant en évidence un aspect qui n’a pas été abordé. Task ne se présente pas comme uniquement un build tool :

Task is a task runner / build tool that aims to be simpler and easier to use than, for example, GNU Make.

D’autres fonctionnalités sortent du cadre du build tool strict pour s’apparenter en effet à de la gestion de tâches. Elles ne sont pas présentées dans le présent exposé car je n’ai pas encore eu l’occasion d’utiliser Task ainsi. Peut-être paraîtra-t-il un prochain billet là-dessus ;)


  1. Task maintient, pour chaque tâche, un hash calculé à partir des sources pour déterminer si l’exécution de ses commandes est nécessaire.
  2. À savoir : google.golang.org/protobuf/cmd/protoc-gen-go et google.golang.org/grpc/cmd/protoc-gen-go-grpc, cf. Quick start | Go | gRPC.

Il persiste toujours un aspect fondamental et commun à Make et à Task en cela qu’ils laissent au développeur tout le soin d’écrire les recettes ou les commandes. Le développeur est donc responsable de la cohérence globale du Makefile ou du Taskfile.yaml, ainsi que de son bon déroulement par la déclaration correcte des prérequis ou des dépendances, notamment dans le cas d’une gestion multi-plateformes.1

À cet égard, Task est-il réellement moderne au vu des alternatives plus sophistiquées qui existent ? Depuis peu, un autre build tool prenant une approche opposée suscite mon intérêt : Bazel. La promesse d’un tel outil s’entend bien : le programmeur n’a plus, en principe, à écrire à la main le détail des commandes concrètes décrivant les étapes de build. Le niveau d’abstraction est placé plus haut et ces détails sont relégués au système de build.

L’une des premières phrases de la documentation de Bazel semble plutôt opiniâtre :

Bazel uses an abstract, human-readable language to describe the build properties of your project at a high semantical level. Unlike other tools, Bazel operates on the concepts of libraries, binaries, scripts, and data sets, shielding you from the complexity of writing individual calls to tools such as compilers and linkers.

Intro to Bazel

Bazel semble disposer d’un bon support pour Go (et pour Protobuf en l’occurrence). Il a donc été envisagé. Cependant, le niveau de complexité va de paire avec le niveau de sophistication. Je n’exclus aucunement avoir recours à un tel système dans le futur, d’autant plus que le projet a des chances de devenir polyglotte.2

En attendant, Task me semble être un bon compromis : un Make revisité, à mi-chemin entre la tradition et la modernité.


  1. Et le cas de deluge est encore simple : l’implémentation de référence du langage Go et le support exclusif de Linux limite la disparité des contextes de build possibles, permettant ainsi d’alléger les commandes.
  2. Si vous avez des retours d’expérience sur de tels systèmes (Bazel ou concurrent qui se place au même niveau), je suis tout ouïe !

7 commentaires

bravo ! j’en avais jamais entendu parle mais grace a toi oui.

NightProg

Merci pour ton commentaire.

Task est plutôt jeune (la v1 date de 2017) et a eu son petit retentissement surtout au sein de la communauté Go, du fait qu’il soit lui-même écrit en Go. En dehors de cette communauté, j’ai effectivement l’impression qu’on en parle un peu moins.
Mais Task n’est évidemment pas cantonné aux projets Go. Leur documentation est illustrée d’exemples n’ayant rien à voir avec Go, par ailleurs. C’est aussi presque un hasard que j’utilise ici Task avec Go. Ça aurait pu tout aussi bien tomber avec un projet Python (et ça arrivera sûrement ^^).

Il y a peu, un article sur Task a été partagé dans une newsletter influente consacrée au monde de Go : Taskfiles for Go Developers (cet article présente d’ailleurs un cas d’usage qui s’apparente plus à l’aspect "task runner" que Task assume).

Salut,

J’ai l’impression que comme de nombreuses autres "alternatives" à Make, ce projet passe à côté de ce qui fait que Make n’est pas un très bon outil. Le gros problème de Make est qu’il a le cul entre deux chaises :

  • fondamentalement, c’est un outil relativement bas niveau pour gérer un arbre de dépendance de build ;
  • certains personnes s’en servent comme un task runner parce que c’est relativement facile de l’utiliser de cette manière.

Les problèmes avec Make sont alors multiples :

  • il résout déjà mal le problème qui est son cœur de métier, le problème fondamental sur ce point vient de son design à coup de targets et de dépendances qui est un mauvais modèle de ce qui défini effectivement une build : les commandes de build elle-mêmes (les recipes) ne sont pas considérées comme des dépendances des artifacts à construire, ce qui veut dire qu’on peut facilement compiler un projet avec un set de flags et d’options, modifier un truc, puis recompiler partiellement avec un autre set de flags incompatibles avec le premier et se retrouver avec un artifact final daubé ;
  • il est pas très bon comme task runner parce que c’est pas son cœur de métier ;
  • et son plus gros problème est que beaucoup de projets l’utilisent en hybride pour faire les deux sans qu’il fasse ni l’un ni l’autre d’une façon totalement satisfaisante (et donc forcément, ça aide pas à redorer son image générale, surtout pour les gens qui se fatiguent encore à les écrire à la main :-° ).

Et j’ai l’impression que cet outil fait exactement la même erreur : il est (volontairement cette fois !) entre ces deux cas d’usages qui sont en fait bien différents (et donc à mon sens c’est une mauvaise idée de vouloir faire les deux, surtout si le but est d’être un front-end facile à utiliser). En l’occurrence, de ce qu’on peut voir sur ce billet, il a l’air de s’en sortir pas trop mal comme task runner, mais serait vraiment pas terrible comme build system parce qu’on peut facilement retomber sur les mêmes problèmes que Make en étant encore plus verbeux…

D’ailleurs un truc que je trouve absolument absurde est de déclarer les fichiers source comme dépendance de l’appel à go build. Quand on utilise un langage qui a déjà un build-system dans son tooling, c’est la responsabilité de ce build-system de gérer ses dépendances et donc de savoir ce qu’il a besoin de recompiler ou non.

Un outil qui a bien compris ce problème de Make d’être entre deux cas d’usages différents est ninja. Il se présente activement comme étant l’assembleur du build system, et résout correctement la question particulière de résoudre un arbre de dépendance de builds modélisé intelligemment sans essayer de faire le café par ailleurs. Le but est complètement avoué d’être un backend pour des build systèmes de plus haut niveau (cmake ou meson par exemple) qui eux permettent de définir des tâches et de relations de dépendances de haut niveau sans se soucier de devoir exprimer le vrai arbre de dépendances en tant qu’artifacts, fichiers sources et recettes.

J’ai l’impression que le créneau qui a vraiment besoin d’être rempli, c’est surtout celui d’un task runner qui n’essaie pas aussi d’être un build system et qui ait un front end qui permet de vraiment représenter facilement et simplement ce problème. Le plus proche que j’ai vu pour l’instant est just, mais on remarquera tout de même le README qui fait deux erreurs assez hilarantes vu de l’extérieur :

  • le premier exemple est une représentation de build de la pire façon qui soit (appeler cc *.c) alors que l’intérêt du projet est qu’il n’est pas un système de build ;
  • la liste de comparaison à Make qui ironiquement montre parfaitement pourquoi just serait un très mauvais système de build sans prendre l’opportunité d’expliquer que just n’a pas "many improvements over make" mais plutôt qu’il résout un problème différent de celui pour lequel Make est pensé mais que les gens s’échinent à forcer à rentrer dans le modèle de Make.
+0 -0

certains personnes s’en servent [de Make] comme un task runner parce que c’est relativement facile de l’utiliser de cette manière.

J’ai moi-même parfois le réflexe d’écrire un Makefile comme un bête « wrapper » de commandes ennuyeuses à retaper, et c’est même pas dans des projets de développement. Ça peut même être one-shot et partir à la poubelle à la fin de la journée quand j’ai fait ce que j’ai à faire. Ça reste néanmoins un cas extrêmement simple qui est plutôt à voir comme une alternative au fait de wrapper ses commande dans un petit script shell qui doit gérer les sous-commandes en invocation.

D’ailleurs un truc que je trouve absolument absurde est de déclarer les fichiers source comme dépendance de l’appel à go build. Quand on utilise un langage qui a déjà un build-system dans son tooling, c’est la responsabilité de ce build-system de gérer ses dépendances et donc de savoir ce qu’il a besoin de recompiler ou non.

Il me semble que les sources ne sont pas à voir comme des dépendances (au sens des deps ou au sens des prérequis de Make). J’ai l’impression qu’elles permettent simplement à Task de calculer le hash des fichiers pour savoir ultérieurement si la ré-exécution de la tâche est nécessaire quand une modification a eu lieu. En l’occurrence, oui ça ne changerait pas grand chose dans le cas d’un go build qui fait déjà son taf de ce côté-là, si ce n’est d’éviter d’invoquer go inutilement en premier lieu.

L’autre outil sur lequel je me suis renseigné, Bazel, (qui ne joue clairement pas dans la même cour) semble se différencier nettement sur cet aspect : il est « intelligent » vis-a-vis des implémentations avec lesquelles il travaille pour composer correctement avec elles. D’où le fait qu’une techno donnée, par exemple Go, a besoin d’être supportée par Bazel au préalable.

J’ai l’impression de retrouver un peu ce que tu disais à propos de Ninja. Un outil intelligent de haut-niveau qui connaît ce avec quoi il travaille pour produire exactement les étapes bas-niveau de build nécessaires avec ninja. La différence ici étant que la phase de haut-niveau et bas-niveau sont découplées, chaque phase ayant son outil.

Et j’ai l’impression que cet outil fait exactement la même erreur : il est (volontairement cette fois !) entre ces deux cas d’usages qui sont en fait bien différents (et donc à mon sens c’est une mauvaise idée de vouloir faire les deux, surtout si le but est d’être un front-end facile à utiliser). En l’occurrence, de ce qu’on peut voir sur ce billet, il a l’air de s’en sortir pas trop mal comme task runner, mais serait vraiment pas terrible comme build system parce qu’on peut facilement retomber sur les mêmes problèmes que Make en étant encore plus verbeux…

[…]

J’ai l’impression que le créneau qui a vraiment besoin d’être rempli, c’est surtout celui d’un task runner qui n’essaie pas aussi d’être un build system et qui ait un front end qui permet de vraiment représenter facilement et simplement ce problème. Le plus proche que j’ai vu pour l’instant est just, mais on remarquera tout de même le README qui fait deux erreurs assez hilarantes vu de l’extérieur :

En y pensant, Task est finalement plutôt un task runner par essence qu’on peut plier en build tool par accident. Si je compare ses features à just, j’aperçois quelques similarités dans le sens d’une ergonomie d’un task runner : récupérer les arguments de leur invocation pour les ré-utiliser, charger des fichiers .env, lister les tâches/recipes. Après un rapide passage sur la documentation, just semble tout de même plus fourni sur ce créneau en allant au bout des choses.

Je suppose que rien n’empêche de l’utiliser comme task runner pur. C’est un aspect que je n’ai pas présenté car je n’ai pas encore exploré la chose. Du peu que j’en ai vu jusque-là, je suis assez inspiré pour le soumettre à un tel usage. Je rédigerai sûrement alors un prochain billet qui se concentrera sur cet aspect. Si j’ai l’occasion de tester just d’ici là, une comparaison peut être intéressante, tout comme j’ai surtout pris l’angle de la comparaison avec Make dans ce billet.

le premier exemple est une représentation de build de la pire façon qui soit (appeler cc *.c) alors que l’intérêt du projet est qu’il n’est pas un système de build ;

C’est d’autant plus curieux que le premier point est sans équivoque à ce sujet : "just is a command runner, not a build system, so it avoids much of make's complexity and idiosyncrasies. No need for .PHONY recipes!"
Ça mériterait peut-être même une correction de la doc et une PR.

Merci pour ton partage.

Il me semble que les sources ne sont pas à voir comme des dépendances (au sens des deps ou au sens des prérequis de Make). J’ai l’impression qu’elles permettent simplement à Task de calculer le hash des fichiers pour savoir ultérieurement si la ré-exécution de la tâche est nécessaire quand une modification a eu lieu. En l’occurrence, oui ça ne changerait pas grand chose dans le cas d’un go build qui fait déjà son taf de ce côté-là, si ce n’est d’éviter d’invoquer go inutilement en premier lieu.

Pour moi, c’est justement un problème de conception que ton task runner soit responsable de savoir si go build a besoin d’être lancé (cela dit, si je comprends bien c’est pas un truc imposé par task, si tu mets pas de dépendances il va considérer que la task est à lancer, c’est bien ça ?). C’est un problème parce que :

  • ça n’économise en fait rien, que task vérifie que les sources sont à jours, ou bien que task laisse faire go build revient au même dans le cas où il n’y a rien à faire, et double le travail de vérification dans le cas où les fichiers sont pas à jour ;
  • si tu as des dépendances en trop (des fichiers qui sont pas compilés par exemple parce que pour une autre architecture ou que sais-je mais qui sont déclarés dans les deps), tu lances la commande go build pour rien ;
  • si tu as des dépendances manquantes, tu lances pas go build à tort (ce qui est le pire scénario). Note que les dépendances d’un système de build peuvent être un peu plus "subtiles" que ce qu’on peut croire après un jet d’œil superficiel (et Make gère pas forcément ces cas correctement d’ailleurs) : ça comprend le compilateur lui-même, les trucs externes sur lequel tu te lis (au hasard glibc), des variables d’environnements, etc.

Bref, j’ai l’impression qu’il y a un peu trois cas d’utilisations, le premier étant clairement distinct des deux autres mais le second parfois traité comme le premier.

  1. On veut gérer la build et les opérations de haut niveau (genre créer la doc, faire tourner les tests) pour un langage qui ne dispose pas d’outil de build. Il est raisonnable alors d’utiliser un build manager de haut niveau (par exemple cmake ou meson) au-dessus d’un assembleur de build (e.g. make ou ninja) et s’en servir aussi pour gérer les opérations de haut niveau puisque le backend de build sera capable de les gérer même si c’est pas pratique comme c’est pas son coeur de métier (on s’en fout parce que les tâches sont définies avec un outil de plus haut niveau).
  2. On veut gérer la build et les opérations de haut niveau pour un langage qui dispose d’un outil de build (Rust, Go, LaTeX, Python, etc). Dans ce cas, on n’a absolument pas à soucier de la build, et c’est une tâche parfaitement banale comme une autre. Il est raisonnable si c’est utile (pour de la cross compilation par exemple) d’appeler cet outil de build comme une simple tâche avec un task runner qui ne se soucie absolument pas de ce dont le système de build à besoin pour faire son travail.
  3. On veut gérer des commandes arbitraires qui sont lourdes à répéter à la main mais qu’on a pas non plus besoin de sortir un script fait main, c’est le cas idéal du task runner, et en fait équivalent au cas précédent.

Pour moi, c’est justement un problème de conception que ton task runner soit responsable de savoir si go build a besoin d’être lancé (cela dit, si je comprends bien c’est pas un truc imposé par task, si tu mets pas de dépendances il va considérer que la task est à lancer, c’est bien ça ?). C’est un problème parce que : […]

Effectivement, ce n’est en rien obligatoire. Tu peux ne rien préciser et Task invoquera toujours go build qui saura recompiler si besoin. Idem pour go test.

Perso, je trouve assez remarquable que quelqu’un ait enfin eu l’idée (absolument révolutionnaire!) de construire un système de build/orchestrateur de tâches dont la syntaxe du fichier d’entrée se base sur yaml.

Ça signifie tout simplement qu’on peut écrire des scripts pour éditer automatiquement ces taskfiles pour supprimer ou rajouter des choses dedans.

C’est bien le genre de chose que ça me gonfle d’avoir à faire manuellement à base de copier-coller dans mon makefile au travail quand je veux ajouter un serveur (donc la tâche de build de l’exécutable, de l’image du conteneur, la publication de cette dernière, le déploiement du chart helm, et la petite tâche bonus qui se contente de pousser l’image et de faire un "rollout" du déploiement déjà existant).

Je crois que je finirai par essayer Task. Je n’en ai pas encore eu le temps, mais rien que le format d’input qu’on peut manipuler avec les outils standard de n’importe quel langage de script, ça représente une grosse valeur ajoutée pour moi : celle du bon sens.

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte