Accueil Nos publications Blog [ASP.NET Core] Build d’un projet avec Sake

[ASP.NET Core] Build d’un projet avec Sake

ASP.NET logoAujourd’hui, je souhaite vous faire découvrir le processus de compilation d’une application ASP.NET Core tel qu’il est proposé par les équipes de Microsoft. L’avantage de ce processus est qu’il est bien plus léger que MSBUILD et qu’il est multi-plateformes. De plus, il est facilement intégrable à n’importe quel serveur de build et ne nécessite aucun prérequis.

L’intégralité de ce billet se base sur un scénario dans lequel vous souhaiteriez générer une librairie ASP.NET Core, le but ultime étant de réussir à produire automatiquement un paquet NuGet à partir de cette librairie. Les éléments minimums requis dans ce cas de figure sont les suivants :

  • Un fichier Startup.cs qui ne contient qu’une simple classe dont la définition est la suivante.
    namespace MyLib
    {
        public class Service
        {
        }
    }
    
  • Un fichier project.json, lui aussi très simple.
    {
        "dependencies": {
        },
        "frameworks": {
            "dotnet5.4": { }
        }
    }
    

Préparer un environnement pour Sake

Sake est un make file basé sur .NET. Sa syntaxe utilise le langage C# dans des templates compatibles avec le moteur de vues Spark. Ces templates sont exprimés au travers de fichiers à l’extension .shade.

L’installation de Sake se fait au travers d’un paquet nuget. Prenez pour exemple le script suivant. Celui-ci va automatiquement installer nuget s’il n’est pas déjà présent puis, il va télécharger Sake dans un répertoire packages. Placez le script ci-dessous dans un fichier build.cmd, que vous placerez dans le répertoire racine de votre projet ASP.NET Core. Vous venez de faire le premier pas vers un mécanisme de build autonome capable de charger lui-même ses prérequis.

Le script se termine par un label :getdnx, et nous le compléterons au fur et à mesure de ce billet.

@echo off
cd %~dp0

SETLOCAL
SET NUGET_VERSION=latest
SET CACHED_NUGET=%LocalAppData%\NuGet\nuget.%NUGET_VERSION%.exe
SET BUILDCMD_KOREBUILD_VERSION=
SET BUILDCMD_DNX_VERSION=

IF EXIST %CACHED_NUGET% goto copynuget
echo Downloading latest version of NuGet.exe...
IF NOT EXIST %LocalAppData%\NuGet md %LocalAppData%\NuGet
@powershell -NoProfile -ExecutionPolicy unrestricted -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest 'https://dist.nuget.org/win-x86-commandline/%NUGET_VERSION%/nuget.exe' -OutFile '%CACHED_NUGET%'"

:copynuget
IF EXIST .nuget\nuget.exe goto restore
md .nuget
copy %CACHED_NUGET% .nuget\nuget.exe > nul

:restore
IF EXIST packages\Sake goto getdnx
.nuget\NuGet.exe install Sake -ExcludeVersion -Source https://www.nuget.org/api/v2/ -Out packages

:getdnx

Notez que le script build.cmd peut aussi être écrit pour les systèmes non Windows. L’exemple suivant est le pendant de ce script, à placer dans un fichier build.sh.

#!/usr/bin/env bash

if test `uname` = Darwin; then
    cachedir=~/Library/Caches/KBuild
else
    if [ -z $XDG_DATA_HOME ]; then
        cachedir=$HOME/.local/share
    else
        cachedir=$XDG_DATA_HOME;
    fi
fi
mkdir -p $cachedir
nugetVersion=latest
cachePath=$cachedir/nuget.$nugetVersion.exe

url=https://dist.nuget.org/win-x86-commandline/$nugetVersion/nuget.exe

if test ! -f $cachePath; then
    wget -O $cachePath $url 2>/dev/null || curl -o $cachePath --location $url /dev/null
fi

if test ! -e .nuget; then
    mkdir .nuget
    cp $cachePath .nuget/nuget.exe
fi

if test ! -d packages/Sake; then
    mono .nuget/nuget.exe install Sake -ExcludeVersion -Source https://www.nuget.org/api/v2/ -Out packages
fi

L’arborescence du projet sur lequel nous travaillons a maintenant la structure présentée ci-dessous :

build.cmd
build.sh
src
    MyProject
        project.json        
        Startup.cs

En exécutant l’un des deux scripts build.cmd ou build.sh, selon le système sous lequel vous vous situez, vous devriez voir apparaître un dossier nuget et un dossier packages. Dans ce dernier, vous trouverez un répertoire correspondant à Sake. Ouvrez-le, car il contient plusieurs templates .shade dont je souhaiterais vous parler.

La syntaxe et le fonctionnement de Sake

L’extrait de code ci-dessous est un exemple de la syntaxe de Sake. Il correspond à ce que j’appellerai une tâche dans la suite de ce billet. La tâche ci-dessous permet l’exécution d’un processus. Il s’agit de la tâche exec. Notez qu’elle dépend d’une autre tâche Sake, la tâche log. Notez également qu’elle référence différents éléments du Framework .NET, et que c’est grâce à ce dernier qu’elle est en mesure d’effectuer son traitement.

use namespace="System.Diagnostics"
use namespace="System.IO"

default workingdir="${Directory.GetCurrentDirectory()}"
default commandline=""

log info="Exec"
log info="  program: ${program}"
log info="  commandline: ${commandline}"
log info="  workingdir: ${workingdir}"


functions
    @{
        bool __WriteExecOutputToLogger { get; set; }
    }

@{
    var processStartInfo = new ProcessStartInfo {
        UseShellExecute = false,
        WorkingDirectory = workingdir,
        FileName = program,
        Arguments = commandline,

    };

    if (__WriteExecOutputToLogger)
    {
        processStartInfo.RedirectStandardError = true;
        processStartInfo.RedirectStandardOutput = true;
    }

    using (var process = Process.Start(processStartInfo))
    {
        if (__WriteExecOutputToLogger)
        {
            process.EnableRaisingEvents = true;
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();

            process.ErrorDataReceived += (sender, eventArgs) =>
            {
                if (!string.IsNullOrWhiteSpace(eventArgs.Data))
                {
                    Log.Error(eventArgs.Data);
                }
            };

            process.OutputDataReceived += (sender, eventArgs) =>
            {
                Log.Info(eventArgs.Data);
            };
        }

        process.WaitForExit();

        if (process.ExitCode != 0)
        {
            throw new Exception(string.Format("Exit code {0} from {1}", process.ExitCode, program));
        }
    }
}

Dans le cas d’un mécanisme de build, nous souhaiterions simplement invoquer via la tâche exec le processus capable de compiler chacun de nos projets. Dans le cas d’une application .NET classique, c’est le processus msbuild.exe a qui nous passerions le chemin vers un .csproj ou un .vbproj. Ci-dessous, le code d’une tâche capable d’invoquer msbuild.exe.

default configuration='Release'
default outputDir=''
default extra=''

use import="Environment"
use assembly="Microsoft.Build.Utilities.v4.0, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
use namespace="Microsoft.Build.Utilities"

var buildProgram=''
@{
  if (IsMono)
  {
    buildProgram = "xbuild";
  }
  else
  {
    buildProgram = ToolLocationHelper.GetPathToDotNetFrameworkFile("msbuild.exe",TargetDotNetFrameworkVersion.Version40);
  }
}

var OutDirProperty=''
set OutDirProperty='OutDir=${outputDir}${Path.DirectorySeparatorChar};' if='!string.IsNullOrWhiteSpace(outputDir)'

exec program="${buildProgram}" commandline='${projectFile} "/p:${OutDirProperty}Configuration=${configuration}" ${extra}'

Enfin, dernier point important à propos de Sake : il possède un support natif d’un mécanisme de gestion de cycle de vie. Ce point est primordial lors de la création de scripts de build ; il est en effet important d’être capable de se brancher facilement sur différentes étapes prédéfinies (ex. génération du livrable, exécution des tests unitaires, etc.).

Ces différentes phases sont définies par ce que l’on appelle un target. Chaque target est associé à un label. Si vous utilisez Sake comme mécanisme de build, vous devrez donc simplement associer une action, ou une liste d’actions, basée sur des tâches telles que build ou exec ou d’autres encore, et associer le tout au label de la target désirée.

Créez à présent un fichier makefile.shade avec le contenu suivant.

use-standard-lifecycle

use namespace="System.IO"

default BASE_DIR='${Directory.GetCurrentDirectory()}'
default TARGET_DIR='${Path.Combine(BASE_DIR, "target")}'

#target-dir-clean target="clean"
  directory delete="${TARGET_DIR}"

Placez le fichier à la racine de votre projet.

build.cmd
build.sh
makefile.shade
src
    MyProject
        project.json        
        Startup.cs

Souvenez-vous, Sake est basé sur le moteur de vues Spark. Le mécanisme de résolution de tâches fonctionne alors comme le mécanisme de résolution de vues que l’on pourrait trouver dans un projet ASP.NET MVC. Sachant cela, la première instruction, use-standard-lifecycle, va simplement demander à Spark de rechercher une vue et d’inclure son contenu. Il se trouve que dans le dossier d’installation de Sake, vous trouvez un dossier Shared et dans ce dossier, un fichier _use-standard-lifecycle.shade. Rien de surprenant pour des développeurs ASP.NET MVC.

Les autres instructions permettent d’inclure une assembly standard de .NET (System.IO), et d’utiliser cette dernière pour définir deux variables : BASE_DIR et TARGET_DIR. Enfin ce premier fichier de makefile se termine par la définition d’une action associée au target clean. Cette action se nomme target-dir-clean et va invoquer une tâche directory afin de supprimer le répertoire TARGET_DIR et son contenu. Regardez à nouveau le dossier Shared de Sake, vous y trouverez un fichier _directory.shade. Simple, non ?

Faîtes le test, exécutez votre script build.cmd ou build.sh selon votre plateforme. Si vous aviez pris le soin de créer un dossier target avant de lancer le script, vous le verrez automatiquement disparaître.

Je vous mets pour référence le contenu de ce fichier _directory.shade. Notez la syntaxe vraiment simple et facile à apprendre pour un développeur .NET.

default delete=''
default create=''

test if="!string.IsNullOrEmpty(delete) && Directory.Exists(delete)"
  @{
    try
    {
      Directory.Delete(delete, true);
    }
    catch
    {
      // blind catch and retry - delete throws "Directory is not empty" sometimes !?
      Directory.Delete(delete, true);
    }
  }

test if="!string.IsNullOrEmpty(create) && !Directory.Exists(create)"
  -Directory.CreateDirectory(create);

Bien sûr vous n’allez pas réécrire la roue dans vos fichiers makefile. Par exemple, dans le cas d’un projet .NET classique, vous pourriez résumer votre fichier makefile.shade aux quelques lignes suivantes.

default AUTHORS='leo'
default VERSION='1.0.0'
default FULL_VERSION='1.0.0'

use-standard-lifecycle
use-standard-goals

Notez qu’ici je ne définis plus directement le contenu des différents targets. A la place, je référence une nouvelle tâche (use-standard-goals). C’est ce script qui va définir toutes les targets nécessaires à la génération d’un projet .NET standard, en gérant également l’exécution de tests unitaires ou encore la génération d’un paquet NuGet.

Notez également que, pour fonctionner, la tâche use-standard-goals a besoin de plusieurs variables (VERSION, FULL_VERSION, AUTHORS). Sans ces variables, l’exécution de votre makefile va échouer.

A présent, si vous exécuter à nouveau build.cmd ou build.sh, il n’y aura pas d’erreur d’exécution mais le processus de compilation ne va tout simplement pas compiler votre projet, ni générer de sortie. En fait, la tâche use-standard-goals est adaptée à un projet .NET classique (basé sur msbuild et les .csproj), mais nous sommes ici dans le cas d’une application ASP.NET Core. La compilation de cette dernière utilise d’autres outils propres à l’environnement DNX. Il nous faut donc simplement une tâche plus adaptée (mais en continuant à utiliser les mêmes tâches de bas niveaux et les mêmes targets de cycle de vie).

Sake et ASP.NET Core

C’est à ce moment qu’intervient un nouveau paquet : KoreBuild. Il est lui aussi distribué au travers de NuGet. En le récupérant, vous allez automatiquement télécharger des templates Sake adaptés à l’environnement DNX. Modifiez donc le fichier build.cmd de la manière suivante.

Notez que, dans ce script, j’ai ajouté automatiquement une partie pour la récupération et l’installation du dernier environnement DNX. En effet, cette étape est nécessaire pour pouvoir compiler un projet ASP.NET Core.

@echo off
cd %~dp0

SETLOCAL
SET NUGET_VERSION=latest
SET CACHED_NUGET=%LocalAppData%\NuGet\nuget.%NUGET_VERSION%.exe
SET BUILDCMD_KOREBUILD_VERSION=
SET BUILDCMD_DNX_VERSION=

IF EXIST %CACHED_NUGET% goto copynuget
echo Downloading latest version of NuGet.exe...
IF NOT EXIST %LocalAppData%\NuGet md %LocalAppData%\NuGet
@powershell -NoProfile -ExecutionPolicy unrestricted -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest 'https://dist.nuget.org/win-x86-commandline/%NUGET_VERSION%/nuget.exe' -OutFile '%CACHED_NUGET%'"

:copynuget
IF EXIST .nuget\nuget.exe goto restore
md .nuget
copy %CACHED_NUGET% .nuget\nuget.exe > nul

:restore
IF EXIST packages\Sake goto getdnx
IF "%BUILDCMD_KOREBUILD_VERSION%"=="" (
    .nuget\nuget.exe install KoreBuild -ExcludeVersion -o packages -nocache -pre
) ELSE (
    .nuget\nuget.exe install KoreBuild -version %BUILDCMD_KOREBUILD_VERSION% -ExcludeVersion -o packages -nocache -pre
)
.nuget\NuGet.exe install Sake -ExcludeVersion -Source https://www.nuget.org/api/v2/ -Out packages

:getdnx
IF "%BUILDCMD_DNX_VERSION%"=="" (
    SET BUILDCMD_DNX_VERSION=latest
)
IF "%SKIP_DNX_INSTALL%"=="" (
    CALL packages\KoreBuild\build\dnvm install %BUILDCMD_DNX_VERSION% -runtime CoreCLR -arch x86 -alias default
    CALL packages\KoreBuild\build\dnvm install default -runtime CLR -arch x86 -alias default
) ELSE (
    CALL packages\KoreBuild\build\dnvm use default -runtime CLR -arch x86
)

packages\Sake\tools\Sake.exe -I packages\KoreBuild\build -f makefile.shade %*

Notez que lors de l’appel à Sake.exe, le chemin du paquet KoreBuild lui est passé en paramètre. Si vous ouvrez le répertoire de KoreBuild, vous y trouverez des tâches pour tous les différents éléments du monde ASP.NET Core : bower, dnu, kpm-build, node, etc. Et surtout un k-standard-goals. C’est ce dernier qui contient la définition des différentes actions à appeler pour générer un projet ASP.NET Core.

Pour le fichier build.sh, l’implémentation suivante va également récupérer le paquet KoreBuild en plus du paquet de Sake.

#!/usr/bin/env bash

if test `uname` = Darwin; then
    cachedir=~/Library/Caches/KBuild
else
    if [ -z $XDG_DATA_HOME ]; then
        cachedir=$HOME/.local/share
    else
        cachedir=$XDG_DATA_HOME;
    fi
fi
mkdir -p $cachedir
nugetVersion=latest
cachePath=$cachedir/nuget.$nugetVersion.exe

url=https://dist.nuget.org/win-x86-commandline/$nugetVersion/nuget.exe

if test ! -f $cachePath; then
    wget -O $cachePath $url 2>/dev/null || curl -o $cachePath --location $url /dev/null
fi

if test ! -e .nuget; then
    mkdir .nuget
    cp $cachePath .nuget/nuget.exe
fi

if test ! -d packages/Sake; then
    mono .nuget/nuget.exe install KoreBuild -ExcludeVersion -o packages -nocache -pre
    mono .nuget/nuget.exe install Sake -ExcludeVersion -Source https://www.nuget.org/api/v2/ -Out packages
fi

if ! type dnvm > /dev/null 2>&1; then
    source packages/KoreBuild/build/dnvm.sh
fi

if ! type dnx > /dev/null 2>&1 || [ -z "$SKIP_DNX_INSTALL" ]; then
    dnvm install latest -runtime coreclr -alias default
    dnvm install default -runtime mono -alias default
else
    dnvm use default -runtime mono
fi

mono packages/Sake/tools/Sake.exe -I packages/KoreBuild/build -f makefile.shade "$@"

Je peux alors éditer le fichier makefile.shade de la manière suivante.

default AUTHORS='leo'
default VERSION='1.0.0'
default FULL_VERSION='1.0.0'

use-standard-lifecycle
k-standard-goals

Relancez votre script build.cmd ou build.sh, et voilà ! Au bout de quelques instants, une fois la compilation du projet terminée, vous verrez apparaître un dossier artifacts avec un paquet NuGet correspondant à notre projet.

Conclusion

Vous avez à présent sous la main un mécanisme de génération d’un projet ASP.NET Core (mais pas que), utilisable sous n’importe quelle plateforme et facilement personnalisable. Je vous invite d’ailleurs à consulter le contenu de la tâche k-standard-goals, puisque vous y trouverez les différentes variables utilisées, notamment pour versionner vos paquets NuGet.