Tag Archives: msbuild

Transformer les templates T4 pendant la build, et passer des variables du projet

T4 (Text Template Transformation Toolkit) est un excellent outil pour générer du code ; on peut, par exemple, créer des classes POCO à partir des tables d’une base de données, générer du code répétitif, etc. Dans Visual Studio, les fichiers T4 (extension .tt) sont associés au custom tool TextTemplatingFileGenerator, qui transforme un template pour générer un fichier de sortie à chaque fois qu’on enregistre le template. Mais il arrive que ce ne soit pas suffisant, et qu’on souhaite regénérer les sorties des templates à chaque build. C’est assez facile à mettre en œuvre, mais il y a quelques écueils à éviter.

Transformer les templates lors du build

Si votre projet est un csproj ou vbproj "classique" (c’est-à-dire pas un projet .NET Core "SDK-style"), les choses sont assez simples et bien documentées sur cette page.

Déchargez votre projet, et ouvrez le dans l’éditeur. Ajoutez le PropertyGroup suivant vers le début du fichier :

<PropertyGroup>
    <!-- 15.0 is for VS2017, adjust if necessary -->
    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">15.0</VisualStudioVersion>
    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
    <!-- This is what will cause the templates to be transformed when the project is built (default is false) -->
    <TransformOnBuild>true</TransformOnBuild>
    <!-- Set to true to force overwriting of read-only output files, e.g. if they're not checked out (default is false) -->
    <OverwriteReadOnlyOutputFiles>true</OverwriteReadOnlyOutputFiles>
    <!-- Set to false to transform files even if the output appears to be up-to-date (default is true)  -->
    <TransformOutOfDateOnly>false</TransformOutOfDateOnly>
</PropertyGroup>

Et ajoutez l’Import suivant à la fin, après l’import de Microsoft.CSharp.targets ou Microsoft.VisualBasic.targets :

<Import Project="$(VSToolsPath)\TextTemplating\Microsoft.TextTemplating.targets" />

Rechargez votre projet, et le tour est joué. Générer le projet devrait maintenant transformer les templates et regénérer leur sortie.

Projets "SDK-style"

Si vous utilisez le nouveau format de projet du SDK .NET Core (souvent appelé de façon informelle projet "SDK-style"), l’approche décrite ci-dessus nécessite un petit changement pour fonctionner. C’est parce que le fichier de cibles par défaut (Sdk.targets dans le SDK .NET Core) est maintenant importé implicitement à la toute fin du projet, il n’est donc pas possible d’importer les cibles T4 après les cibles par défaut. Du coup la variable BuildDependsOn, qui est modifiée par les cibles T4, est écrasée par les cibles par défaut, et la cible TransformAll ne s’exécute plus avant la cible Build.

Heureusement, il y a un workaround : vous pouvez importer les cibles par défaut explicitement, et importer les cibles T4 après celles-ci :

<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="$(VSToolsPath)\TextTemplating\Microsoft.TextTemplating.targets" />

Notez que cela causera un avertissement MSBuild dans la sortie de la build (MSB4011), parce que Sdk.targets est importé deux fois ; cet avertissement peut être ignoré sans risque.

Passer des variables MSBuild aux templates

À un moment donné, il est possible que la logique de génération de code devienne trop complexe pour rester entièrement dans le template T4. Vous pourriez vouloir en extraire une partie dans un assembly utilitaire, qui sera référencé depuis le template comme ceci :

<#@ assembly name="../CodeGenHelper/bin/Release/net462/CodeGenHelper.dll" #>

Mais spécifier le chemin de l’assembly de cette façon n’est pas très pratique… par exemple, si vous êtes actuellement sur la configuration Debug, la version Release de CodeGenHelper.dll ne sera pas forcément à jour. Heureusement, le custom tool TextTemplatingFileGenerator de Visual Studio supporte l’utilisation de variables MSBuild du projet, il est donc possible de faire ceci :

<#@ assembly name="$(SolutionDir)/CodeGenHelper/bin/$(Configuration)/net462/CodeGenHelper.dll" #>

Les variables $(SolutionDir) et $(Configuration) seront remplacées par leurs valeurs respectives. Si vous enregistrez le template, il sera bien transformé en utilisant l’assembly CodeGenHelper.dll. Pratique !

Mais il y a un hic… Si vous avez configuré votre projet pour transformer les templates lors de la build, comme décrit plus haut, la build va maintenant échouer, avec une erreur comme celle-ci :

System.IO.FileNotFoundException: Could not find a part of the path ‘C:\Path\To\The\Project\$(SolutionDir)\CodeGenHelper\bin\$(Configuration)\net462\CodeGenHelper.dll’.

Vous avez remarqué les variables $(SolutionDir) et $(Configuration) dans le chemin ? Elles n’ont pas été remplacées ! C’est parce que la cible MSBuild qui transforme les templates et le custom tool TextTemplatingFileGenerator n’utilisent pas le même moteur de transformation de texte. Et malheureusement, celui utilisé par MSBuild ne supporte pas nativement les variables MSBuild… un comble !

Cependant tout n’est pas perdu. Il suffit de spécifier explicitement les variables que vous voulez passer en temps que paramètres T4. Éditez à nouveau votre fichier projet, et créez un nouveau ItemGroup avec les éléments suivants :

<ItemGroup>
    <T4ParameterValues Include="SolutionDir">
        <Value>$(SolutionDir)</Value>
        <Visible>False</Visible>
    </T4ParameterValues>
    <T4ParameterValues Include="Configuration">
        <Value>$(Configuration)</Value>
        <Visible>False</Visible>
    </T4ParameterValues>
</ItemGroup>

L’attribut Include est le nom du paramètre tel qu’il sera passé au moteur de transformation de texte. L’élément Value, comme son nom l’indique, est la valeur du paramètre. Et l’élément Visible permet d’éviter que l’élément T4ParameterValues n’apparaisse comme élément du projet dans l’explorateur de solution.

Avec ce changement, la build devrait à nouveau transformer les templates correctement.

Pour résumer, gardez à l’esprit que le custom tool TextTemplatingFileGenerator et la cible MSBuild de transformation de texte ont des mécanismes différents pour passer des variables aux templates :

  • TextTemplatingFileGenerator supporte uniquement les variables MSBuild du projet
  • MSBuild supporte uniquement T4ParameterValues

Donc si vous utilisez des variables dans votre template, et que vous voulez pouvoir transformer le template quand vous l’enregistrez dans Visual Studio et quand vous générez le projet, les variables doivent être définies à la fois comme variables MSBuild et comme T4ParameterValues.

Propriétés et éléments MSBuild partagés avec Directory.Build.props

Pour être honnête, je n’ai jamais vraiment aimé MSBuild jusqu’à récemment. Les fichiers de projet générés par Visual Studio étaient immondes, l’essentiel de leur contenu était redondant, il fallait décharger les projets pour les éditer, c’était mal documenté… Mais avec l’avènement de .NET Core et du nouveau format de projet, plus léger, MSBuild est devenu un bien meilleur outil.

MSBuild 15 a introduit une nouvelle fonctionnalité assez sympa : les imports implicites (je ne sais pas si c’est le nom officiel, mais c’est celui que j’utiliserai). En gros, vous pouvez créer un fichier nommmé Directory.Build.props n’importe où dans votre solution, et il sera automatiquement importé par tous les projets sous le répertoire qui contient ce fichier. Cela permet de partager très facilement des propriétés et éléments communs entre les projets. Cette fonctionnalité est décrite en détail sur cette page.

Par exemple, si vous voulez partager certaines métadonnées entre plusieurs projets, créer simplement un fichier Directory.Build.props dans le dossier parent de vos projets :

<Project>

  <PropertyGroup>
    <Version>1.2.3</Version>
    <Authors>John Doe</Authors>
  </PropertyGroup>

</Project>

On peut aussi faire des choses plus intéressantes, comme activer et configurer StyleCop pour tous les projets :

<Project>

  <PropertyGroup>
    <!-- Common ruleset shared by all projects -->
    <CodeAnalysisRuleset>$(MSBuildThisFileDirectory)MyRules.ruleset</CodeAnalysisRuleset>
  </PropertyGroup>

  <ItemGroup>
    <!-- Add reference to StyleCop analyzers to all projects  -->
    <PackageReference Include="StyleCop.Analyzers" Version="1.0.2" />
    
    <!-- Common StyleCop configuration -->
    <AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" />
  </ItemGroup>

</Project>

Notez que la variable $(MSBuildThisFileDirectory) fait référence au répertoire contenant le fichier MSBuild courant. Une autre variable utile est $(MSBuildProjectDirectory), qui fait référence au répertoire du projet en cours de génération.

MSBuild cherche le fichier Directory.Build.props en partant du répertoire du projet et en remontant les dossiers jusqu’à ce qu’il trouve un fichier correspondant, puis s’arrête de chercher. Dans certains cas, il peut être utile de définir des propriétés communes à tous les projets, et d’en ajouter d’autres qui ne s’appliquent qu’à un sous-répertoire. Pour faire cela, il faut que le fichier Directory.Build.props le plus "profond" importe explicitement celui du répertoire parent :

  • (rootDir)/Directory.build.props:
<Project>

  <!-- Properties common to all projects -->
  <!-- ... -->
  
</Project>
  • (rootDir)/tests/Directory.build.props:
<Project>

  <!-- Import parent Directory.build.props -->
  <Import Project="../Directory.Build.props" />

  <!-- Properties common to all test projects -->
  <!-- ... -->
  
</Project>

La documentation mentionne une autre approche, utilisant la fonction GetPathOfFileAbove, mais cela ne semblait pas fonctionner quand j’ai essayé… De toute façon, je pense qu’il est plus simple d’utiliser un chemin relatif, on risque moins de se tromper.

Utiliser les imports implicites apporte quelques avantages :

  • des fichiers de projet plus petits, puisque les propriétés et éléments identiques peuvent être factorisés dans des fichiers communs
  • un seul point de référence : si tous les projets référencent le même package NuGet, la version à référencer est définie à un seul endroit; il n’est plus possible d’avoir des incohérences.

Cette approche a cependant un inconvénient : Visual Studio n’a pas la notion de l’origine d’une variable ou d’un élément, donc si vous changez une propriété ou une référence de package dans l’IDE (via les pages de propriétés du projet ou le gestionnaire de packages NuGet), elle sera modifiée dans le fichier de projet lui-même, et non dans le fichier Directory.Build.props. De mon point de vue, ce n’est pas un gros problème, parce que j’ai pris l’habitude d’éditer les projets manuellement plutôt que d’utiliser l’IDE, mais ça peut être gênant pour certaines personnes.

Si vous voulez un exemple réel de l’utilisation de cette technique, jetez un oeil au repository de FakeItEasy, où nous utilisons plusieurs fichiers Directory.Build.props pour garder les projets propres et concis.

Notez que vous pouvez également créer un fichier Directory.Build.targets, suivant les mêmes principes, pour définir des cibles communes.