Fake 5

Je pars du postulat que le lecteur connait Fake de nom, sait qu’il s’agit d’un DSL qui s’appuie sur le langage .NET F# et à quoi il sert, mais pas beaucoup plus. Il s’agit ici d’une introduction technique. L’objectif est de savoir lire un script Fake (et comprendre ce qu’il fait).

Si vous connaissez (le moins tendance) Cake, Fake est son alternative en F# (et si vous ne connaissez pas Cake, c’est l’alternative en C#). Il existe plusieurs autres alternatives encore. Étant développeur C#, je me devais de citer au moins Cake. L’un comme l’autre ne sont pas limités à compiler des projets dans leur propre langage.

Rapidement identifier la présence de Fake sur un dépôt de code source (typiquement un dépôt git)

Un dépôt de code source qui s’appuie sur Fake contient typiquement les fichiers suivants:

  • build.fsx
  • [lanceur].[cmd|sh]

Le lanceur est un script exécutable par l’OS (.cmd sur Windows, .sh sur Linux). Il est par convention nommé fake.cmd et/ou fake.sh mais cela peut varier.

Le lanceur est responsable d’exécuter le script build.fsx qui contient le pipeline de build (le script Fake). Le script est par convention nommé build.fsx. Bien que cela puisse varier, c’est plus rare car l’outil Fake.exe s’appuie sur ce nom par défaut.

Le script lanceur gère l’installation de Fake et l’exécution du script build.fsx

Voici un exemple de lanceur pour Windows (build.cmd):


SET TOOL_PATH=.fake

IF NOT EXIST "%TOOL_PATH%\fake.exe" (
 dotnet tool install fake-cli --tool-path ./%TOOL_PATH%
)

"%TOOL_PATH%/fake.exe" %*

La première étape du lanceur: vérifier que Fake est installé (en local dans le sous-dossier .fake)

Sinon, Fake est installé via dotnet CLI.

La commande dotnet tool install installe un .NET Core Global Tool, qui est un package Nuget spécial contenant une application console (versus plus conventionnellement une librairie).
À noter qu’il n’y a pas actuellement de méthode de recherche standard pour ces packages spéciaux. La doc de Microsoft pointe deux sources de recherche: le dépôt github natemcmaster/dotnet-tools et un dépôt github de l’équipe ASP.NET.

Ici, le .NET Core Global Tool installé est fake-cli.

L’argument --tool-path .fake installe fake-cli dans le sous-dossier .fake au lieu de l’installer dans un emplacement central et partagé sur la machine. À noter: le sous-dossier .fake est une sorte de cache, et ne devrait pas être inclus dans le contrôle de sources.

Je pense avoir bien résumé, mais pour plus de détails sur l’installation de Fake, la documentation est ici.

La deuxième étape du lanceur consiste à exécuter le script de build Fake

Fake.exe étant installé, on peut exécuter notre script build.fsx.

La commande Fake build est un raccourci équivalent à Fake.exe run build.fsx

Ainsi, la commande .fake\Fake.exe build %* du lanceur lance le script build.fsx en lui passant en paramètres les arguments spécifiés dans la ligne de commande qui invoque notre lanceur.

Là encore, je pense avoir résumé l’essentiel. La documentation de la ligne de commande de l’outil Fake.exe est ici.

Identifier s’il s’agit d’un script Fake 4 ou Fake 5

On rencontre sur les projets existants typiquement Fake 4 ou Fake 5.
La version courante recommandée est Fake 5, qui dépend de .NET Core (mais des projets .Net Framework peuvent être compilés).

Si vous prenez connaissance d’un dépôt de source avec un script Fake, la façon qui me paraît la plus simple pour déterminer s’il s’agit de la version Fake 4 est de regarder la façon dont les targets sont déclarées:


Target "Build" // correspond à la syntaxe Fake 4
Target.create "Build" // correspond à la syntaxe Fake 5

Créer un script Fake dans un dépôt de code source .NET

Si le dépôt n’utilise pas encore Fake, la marche à suivre recommandée[1] est d’utiliser .NET CLI pour générer les fichiers requis à partir d’un template Fake:


dotnet new -i "fake-template::*"
dotnet new fake

La première commande installe le template (ou le met à jour) sur la machine. Cette commande n’est pas requise à chaque génération de template. La seconde commande génère les fichiers à partir du template Fake.

Vous verrez les nouveaux fichiers suivants dans le dossier:

  • build.fsx (script Fake)
  • fake.cmd (lanceur pour Windows)
  • fake.sh (lanceur pour Linux)
  • paket.dependencies (dépendances requises par Fake avec le gestionnaire de packages paket)

Le fichier paket.dependencies est lié au gestionnaire de packages paket utilisé par Fake

Ce gestionnaire est une surcouche à différentes sources telles que Nuget, Git, Github. Si vous n’utilisez pas paket initialement dans votre projet, il est possible (et même recommandable[2]) de fusionner les fichiers paket.dependencies et build.fsx afin de rendre plus discrète la présence de ce nouvel outil (sinon des dossiers et fichiers supplémentaires liés à paket vont apparaitre). Pour cela, il suffit presque de copier-coller le contenu du fichier paket.dependencies au début du script build.fsx. Cela est dommage que le template Fake ne le fasse pas par défaut (cela changera peut-être dans une version future).

Quoiqu’il en soit, fusionné ou non, c’est une autre syntaxe propre au fichier paket.dependencies à apprendre, indépendante de Fake. Un exemple de fusion de ce fichier dans le script build.fsx est donné en fin d’article.

Si votre ou vos projets .NET compilent, vous pouvez dès à présent tester le script avec la commande fake build (notez que le script build.fsx généré par le template Fake s’attend à trouver vos projets dans un sous-dossier « src »).

Vous voudrez surement modifier le script build.fsx pour l’adapter à vos besoins. Par exemple, vous ne voulez pas forcément compiler tous les projets présents dans le dépôt (actuellement le template de script généré scanne et compile tous les projets trouvés dans le dossier), mais seulement ceux regroupés au sein d’une solution particulière. Pour cela, il faut apprendre la syntaxe Fake basée sur le langage F#, et/ou regarder les quelques exemples proposés dans ce dépôt github. Une autre idée est de rechercher « github build.fsx » pour trouver un grand nombre d’exemples.

Anatomie d’un script Fake

Si vous êtes comme moi, c’est-à-dire non initié précédemment à F# et à paket,vous devriez vous poser beaucoup de questions.

Le script contient typiquement :

  • du code F# (dont beaucoup de fonctions helpers apportées par des modules de Fake)
  • des directives telles que #r et #load.

L’entête du fichier build.fsx peut contenir la liste des dépendances paket (en général les packages Nuget requis par votre script Fake).

Les directives #r et #load ne sont pas propres à Fake, ni à F#, mais au langage de scripting ajouté à ces langages C# et F#. Un fichier .fsx est un script F#, un fichier .csx est un script C#. C’est suite à ces langages de scripting qu’est apparue la fonctionnalité REPL[3] proposé par Visual Studio 2015 (appelé aussi C# Interactive). Fake est finalement basé sur le langage de scripting F#, et est constitué essentiellement de l’outil Fake.exe et de modules de fonctions helpers. En deux mots, #load sert à lancer un sous-script, #r sert à référencer une librairie (qui ne le serait pas déjà indirectement via les dépendances importées par Fake). À noter que Fake gère de façon particulière la directive #r "paket:[...]" (cette particularité a été abordée sommairement plus haut avec Paket). Une directive de référence plus conventionnelle est par exemple: #r System.Xml.Linq ou bien #r System.Xml.Linq.dll (un chemin absolu est également possible).

Pour plus d’informations sur ces directives, reportez-vous à la documentation sur C# Interactive.

Structure d’un script Fake

Un script Fake représente un pipeline de build. Un pipeline est une séquence d’étapes généralement inter-dépendantes. Chaque étape est une target, littéralement la cible (l’action) à accomplir. Une target est définie par un nom et une action (une fonction F#).

La façon la plus rapide de prendre connaissance des différentes étapes du script est d’aller regarder à la fin de celui-ci. La fin du script build.fsx spécifie de manière déclarative les dépendances entre les targets:


"Clean"
 ==> "Build"
 ==> "All"

Dans l’exemple ci-dessus, la target « Build » dépend de la target « Clean », et la target « All » dépend de la target « Build ».

Par convention, il existe une target un peu particulière en dernière position: celle-ci contient une action ignore qui ne fait rien, et la target porte un nom explicite pour exprimer le fait qu’elle dépend de toutes les autres étapes. Dans le template généré, cette target est nommée « All »:


Target.create "All" ignore

En exécutant la commande fake build -t All, Fake va exécuter toutes les étapes (targets) requises jusqu’à celle spécifiée (All).

Le script définit essentiellement des fonctions (les targets), et se termine par une instruction qui lance l’exécution en fonction de la target à accomplir. Il s’agit soit de l’argument -t passé au lanceur (puis au script), soit de la target par défaut définie dans cette dernière instruction qui exécute le pipeline:


Target.runOrDefault "All"

Donc la commande fake build est équivalente à la commande fake build -t All.

C’est ainsi que l’on peut orchestrer différents pipelines (c’est-à-dire différentes séquences activables en ligne de commande) avec un seul script. Par exemple un pipeline qui se termine après la compilation, un autre qui se terminera après l’exécution des tests (et qui dépendra de la compilation réussie), un autre qui génère et publie les packages Nuget, etc.

Quelques mots sur la syntaxe F# 

Ce qui vient d’être dit sur la structure générale d’un script Fake, avec les targets, permet d’avoir une vague idée des actions du script, si ces targets ont été nommées avec un nom explicite.

Pour avoir une idée plus précise de ce que fait un script et surtout comment il le fait, il faut un apprentissage minimum de la syntaxe F# et des opérateurs propres à Fake.

Je vais tenter de donner quelques explications sommaires sur les notations les plus déroutantes, en espérant que cela suffise au moins à comprendre ce que fait un script. Cela devrait également suffire pour modifier des paramètres ou faire un diagnostic d’erreur simple.

Pour créer ou retravailler un script, l’apprentissage de F# reste nécessaire.

Import des namespaces et des modules

open Fake.Core

C’est l’équivalent de using Fake.Core; en C#.

Un namespace F# ne contient pas des classes mais des modules (équivalent de classes statiques). L’ouverture du namespace donne accès à tous ses modules.

Target.create "All" ignore

Ici, Target est un module présent dans l’un des namespaces importés (ouverts).

Un module expose des fonctions (ou, plus rarement me semble-t-il, d’autres modules). Dans l’exemple ci-dessus, on invoque la fonction create du module Target (en C#, il s’agirait d’une méthode statique sur la classe Target).

Binding d’identificateur (let)

let identifier = 5

Cela déclare un binding immuable, conceptuellement une sorte de constante de type int (type par défaut pour les nombres, comme en C#) nommée « identifier » dont la valeur est 5. Cette notation permet de déclarer une valeur ou une fonction (on dit qu’on lie ou bind la valeur ou la fonction à un identificateur).

L’identificateur déclaré ainsi est immuable. On ne peut plus le modifier. En revanche, un scope inférieur peut redéfinir un binding, cela s’appelle le shadowing. Le shadowing ne modifie pas un binding existant, il permet d’en créer un nouveau ayant le même identificateur au sein du scope courant (cela « masque » effectivement le binding du scope de niveau supérieur). Une fois sorti du scope, le l’identificateur reprend la signification du binding du scope supérieur. Dans l’idéal, on évite ce mécanisme mais cela arrive typiquement avec des identificateurs (noms de constantes) très génériques.

L’autre exemple ci-dessous déclare trois constantes a, bet c à l’aide d’un tuple:


let a, b, c = (1, 2, 3)

// same as:
let a = 1
let b = 2
let c = 3

Techniquement, la syntaxe donnée dans les exemples précédent ne définit pas des constantes. Cela ressemblera davantage à static readonly int a = 1 en C# (assignation au runtime).

Pour une véritable constante (assignation au compile time), F# prévoit l’attribut Literal :


[<Literal>]
let a = 1

Dans l’exemple donné, cela n’a aucun intérêt. C’est une subtilité nécessaire dans certains cas comme du pattern matching qui dépendrait de la valeur a, lorsque la valeur doit être connue au compile time. Je n’entre pas dans les détails du pattern matching ici.

Déclaration de tableaux

La syntaxe suivante sert à déclarer un tableau de deux valeurs (immuable comme vu précédemment):


let myArray = [|"abc";"def"|]

// same as:
let item1 = "abc"
let item2 = "def"
let myArray = [| item1 ; item2 |]

Invocation de fonctions

open Fake.Core
Target.create "All" ignore

// Same as :
Fake.Core.Target.create "All" ignore

Cette instruction invoque la fonction createdu module Target (exposé par le namespace Fake.Core), en lui passant en paramètre la chaîne de caractères « All » et ignore. Ce dernier paramètre est un opérateur standard F#. C’est une fonction qui accepte un argument et qui n’a aucune action. Cet opérateur sert à ignorer/manger le résultat d’une fonction lorsque l’on chaîne plusieurs fonctions. Concrètement ici, cela permet de déclarer une target nommée « All » qui ne fait rien.

Fonctions et Scopes

Les scopes sont délimités par les niveaux d’indentation (équivalent des blocs entre accolades en C# { [...] } ).
Pour déclarer une fonction sur plusieurs lignes, on peut le faire ainsi:

let buildAction (_) =
  !! "src/**/*.*proj"
  |> Seq.iter (DotNet.build id)

Target.create "Build" buildAction

// same as:
Target.create "Build" (fun _ ->
   !! "src/**/*.*proj"
   |> Seq.iter (DotNet.build id)
)

Wildcard pattern (_)

Le caractère _ qu’on voit dans l’exemple plus haut est nommé wildcard pattern car il peut correspondre à « tout ». C’est un peu le any de TypeScript. On le rencontre typiquement lorsqu’on déclare une fonction qui accepte zero à plusieurs arguments et qui les ignorera. Cela facilite le chaînage de fonctions qui seraient sinon incompatibles entre leur type de retour et leur type d’entrée.

Curried functions et tupled functions

L’exemple plus haut est une curried function. Cela est reconnaissable au fait que les arguments sont séparés par un espace et qu’il n’y a pas de parenthèses englobantes autour des arguments. Une tupled function se voit passer des arguments séparés par une virgule et englobés dans une paire de parenthèses (en fait, la fonction accepte un seul argument de type Tuple).

La très grande majorité des fonctions que vous devriez rencontrer dans un script Fake, sont des curried functions. Celles-ci sont plus souples à utiliser que des tupled functions. Comme cette caractéristique dépend de la façon dont la fonction est déclarée, les fonctions/méthodes de la BCL .NET qui ne sont pas propres à F# et qui acceptent plusieurs arguments sont des tupled functions. Cela explique que tant que vous utilisez des fonctions F# (et donc ceux des modules exposés par Fake), vous utilisez plutôt des curried functions.

La principale chose à retenir à propos d’une curried function est que l’on peut lui appliquer ses arguments de façon partielle (partial application of arguments): cela veut dire que le résultat de l’application partielle des arguments à cette fonction sera une nouvelle fonction qui prend en paramètre le reste des arguments qui n’ont pas encore été spécifiés (évidemment l’ordre des arguments reste important). C’est une façon de contraindre certains paramètres d’une fonction sans avoir à déclarer une nouvelle méthode comme on le ferait en C#.

Un exemple (qui n’a pas trop de sens mais qui compile):

let createTargetAll = Target.create "All" 
createTargetAll ignore

// same as:
 Target.create "All" ignore

Il serait trop long d’entrer dans les détails des curried functions ici. Des éléments de réponse rapide sont sur Stackoverflow.

Une fonction F# prend toujours un seul argument et retourne toujours un résultat en sortie

C’est également important à savoir.

Les curried functions sont un sucre syntaxique que le compilateur va transformer en une suite de fonctions à un seul paramètre en entrée, et un paramètre en sortie (typiquement une fonction pour les fonctions intermédiaires).

Pour exprimer le fait qu’une fonction ne retourne aucune valeur utile ou ne prend aucune valeur utile, le type spécial unit est proposé dans la BCL. C’est l’équivalent du mot-clé void en C# à ceci près qu’il s’applique aussi en argument. On voit en général ce terme dans l’intellisense (rarement dans le code lui-même car sa présence est implicite, comme tous les autres types).

C’est pour cela qu’une fonction de plusieurs lignes se termine parfois curieusement, avec une valeur un peu « seule » sur sa ligne: c’est la valeur retournée par la fonction (le mot-clé return existe en F# mais dans un autre contexte que le retour d’une fonction).

Opérateurs F# pour le pipelining de fonctions (forward pipe et forward composition)

F# propose beaucoup d’opérateurs. Je n’en citerai que deux liés au pipelining de fonctions et que l’on retrouve beaucoup:

  • forward pipe operator |>
  • forward composition >>

L’opérateur |> est le forward pipe et sert à passer la valeur à gauche de l’opérateur comme argument à la fonction placée à droite de l’opérateur.


"Hello world"  |> System.Console.WriteLine

// same as:
System.Console.WriteLine("Hello world")

L’opérateur >> est le forward composition et sert composer une nouvelle fonction à partir d’un pipeline de fonctions (le résultat est donc une fonction). L’avantage de cette syntaxe, outre la composition de fonctions, est que l’ordre d’invocation des fonctions composées est l’ordre logique de leur exécution (de gauche à droite).


let printToConsole(message:string) = System.Console.WriteLine(message)
let getGreeting name = "Hello " + name

// composed function:
let printGreetingToConsole = getGreeting >> printToConsole 
printGreetingToConsole("Eric")

// same result as (with forward pipe):
"Eric" |> getGreeting |> printToConsole

// same result as (without composition nor forward pipe):
printToConsole(getGreeting("Eric"))

Noter dans l’exemple ci-dessus que l’on a dû spécifier le type de l’argument message comme étant string car le compilateur ne peut pas le déterminer seul à cause des différentes signatures proposées par la méthode Console.WriteLine.

Fonction identité (id)

Si vous rencontrez un argument nommé id qui semble sortir de nul part, il s’agit de la fonction identité proposée par F#. Celle-ci prend un argument et retourne sa valeur en sortie. C’est l’équivalent de fun x -> x. Plus d’exemples dans cette réponse de Stackoverflow.

Cette fonction identité apparaît dans l’exemple fourni avec le template Fake (cf. script complet en fin d’article):


!! "src/**/*.*proj"
|> Seq.iter (DotNet.build id)

La fonction DotNet.build prend deux paramètres: setParams et project. L’argument setParams est une fonction qui prend un argument de type BuildOptions et qui retourne un résultat du même type. Dans l’exemple ci-dessus, l’argument setParams se voit passer la valeur id, qui revient à écrire (fun x -> x), qui revient à ne pas modifier les options de build par défaut.

Type option (Some, None)

Le type option est un type F# qui sert à optionnellement encapsuler une valeur. C’est un peu l’équivalent du type Nullable<T> en C#.

Vous pourriez rencontrer les termes Some et None sortant un peu de nul part, comme pour id décrit plus haut. Il s’agit de deux fonctions exposées dans le module Option, qui retournent une valeur de type option . Some sera toujours suffixé par un argument qui est la valeur à renfermer dans l’option, tandis que None ne prend pas d’argument (l’option retournée ne refermera aucune valeur).

Opérateurs Fake

Dans le script Fake généré par le template Fake, on voit plusieurs opérateurs propres à Fake:

  • !!
  • ++
  • ==>

!! et ++ sont deux opérateurs de Fake, importés avec ligne open Fake.IO.Globbing.Operators en début de script, qui sert au scan du système de fichiers à partir d’un pattern.


Target.create "Build" (fun _ ->
   !! "src/**/*.*proj"
   |> Seq.iter (DotNet.build id)
)

!! "src/**/*.*proj" retourne tous les chemins de fichier (ou de dossiers) « *.*prj » à partir du sous-répertoire « src » (avec parcours récursif des sous-dossiers ). Le résultat est de type IEnumerable<string>, qui est passé à la fonction F# Seq.iter (qui revient à un foreach en C#). Concrètement, chaque chemin de fichier *.*proj trouvé est passé en paramètre à la fonction build du module DotNet (helper Fake pour exécuter la ligne de commande dotnet build de la CLI).

Un autre exemple:


Target.create "Clean" (fun _ ->
   !! "src/**/bin"
   ++ "src/**/obj"
   |> Shell.cleanDirs
)

L’opérateur ++ est également propre à Fake, et vient ajouter un second pattern au scan du système de fichiers. Evidemment son opposé existe avec -- (que l’on peut traduire par « mais pas »).
Ici, tous les chemins de dossier (ou de fichiers) qui correspondent aux deux patterns récursifs « src/**/bin » et « src/**/obj »sont passés à la fonction cleanDirs du module Shell. Noter que l’absence de la fonction F# Seq.iter nous permet de déduire que la fonction cleanDirs attend une séquence de chaînes (heureusement, l’intellisense est là pour le confirmer): seq<string> (équivalent à IEnumerable<string>, seqétant l’alias F# de IEnumerable).

Vous pourriez tomber également sur l’opérateur Fake </> qui est l’équivalent de Path.Combine. Je peux difficilement lister tous ces opérateurs ici. En général, vous trouverez les opérateurs de Fake en cherchant les différents liens « Operator » dans la documentation de son API (ils sont catégorisés par modules). Si vous ne trouvez pas, il s’agit en général d’un opérateur du langage F# lui-même.

Enfin, l’opérateur ==> typique en fin de script Fake définit les dépendances entre les targets. Cet opérateur est importé avec la ligne open Fake.Core.TargetOperators en début de script.


"Clean"
 ==> "Build"
 ==> "All"

Cela me permet de conclure cet article introductif. Pour la suite, le mieux est de regarder le site fake.build et la documentation de référence de son API. Si vous souhaitez trouver la documentation d’une fonction, le plus souvent il s’agit d’un helper fourni par Fake, sinon d’une fonction du framework F# ou de la BCL NET.

Voici pour terminer un exemple de script Fake tel que généré par le template Fake et adapté légèrement. Pour l’éditer dans VS Code, l’extension ionide.fsharp est conseillée.


#if FAKE // avoids intellisense warning for non-standard #r "paket:[...]"

// Merge of paket.dependencies file into build.fsx file:
#r "paket:
storage none
source https://api.nuget.org/v3/index.json
nuget Fake.DotNet.Cli
nuget Fake.IO.FileSystem
nuget Fake.Core.Target
"

#endif

#load ".fake/build.fsx/intellisense.fsx"
open Fake.Core
open Fake.DotNet
open Fake.IO
open Fake.IO.Globbing.Operators //enables !! and globbing
open Fake.Core.TargetOperators

Target.create "Clean" (fun _ ->
   // deletes /bin and /obj content
   !! "src/**/bin"
   ++ "src/**/obj"
   |> Shell.cleanDirs 
)

Target.create "Build" (fun _ ->
   // executes command line "dotnet build" on each .NET project filepath found
   !! "src/**/*.*proj"
   |> Seq.iter (DotNet.build id) 
)

// Empty target
Target.create "All" ignore

"Clean"
 ==> "Build"
 ==> "All"

Target.runOrDefault "All"

Références

1: Fake – Getting Started
2: Fake – Modules? Packages? Paket?
3: C# Scripting (MSDN Magazine, Mark Michaelis, January 2016)