9
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C#Advent Calendar 2021

Day 8

.NET+VS Codeでもデザイン時 T4 テキスト テンプレートを使いたかった

Last updated at Posted at 2021-12-08

TL;DR

  • C#に追加されたソース ジェネレーター機能を使って「デザイン時 T4 テキスト テンプレート」を再現してみたかった
    • C# ソースファイル(.cs)以外からソースコード生成する方法を解説
  • が、System.CodeDom絡みのエラーで断念
  • 前回と同様の手段で「デザイン時 T4 テキスト テンプレート」を使う手段を紹介

環境

  • Windows 10 Pro x64
    • 下記SDKが使えるならば、Linuxとかでもできるはず(未確認)
  • .NET 6 SDK
  • Visual Studio Code
    • C#拡張機能を入れておきましょう
    • T4 Supportも入れておくと.ttファイルがハイライトされて見やすいです

T4とは

T4はText Template Transformation Toolkitの略。
端的に言えば「Visual Studioでテキストファイルを自動生成するシステム」。

「いつファイルを生成するか」でおもに2通りある。

  • 実行時 T4 テキスト テンプレート
    • 「テンプレートに沿ったテキストを吐き出すクラス」を生成する。
      • プログラム側から、そのクラスを明示的に利用することになる
    • 前回利用したのはこっち
  • デザイン時 T4 テキスト テンプレート
    • コンパイル前(≒テンプレート保存時)に、テンプレートに沿ったファイルを生成する。
      • 基本的に、Visual Studio(VSCodeではない)が自動でやる
    • ほぼC#(VB)のソースコード自動生成用

Source Generatorを使う(失敗)

ソース ジェネレータープロジェクトの作成

> dotnet new classlib

生成されたcsprojの<TargetFramework>netstandard2.0に変更します。
また、<IsRoslynComponent>true</IsRoslynComponent>を追加します。

csproj
  <PropertyGroup>
-   <TargetFramework>net6.0</TargetFramework>
+   <TargetFramework>netstandard2.0</TargetFramework>
+   <IsRoslynComponent>true</IsRoslynComponent>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

必要なパッケージを追加

dotnet add packageコマンドで必要なパッケージを追加します。

追加したのち、csprojを以下のように編集します。

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
+   <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" />
-   <PackageReference Include="Mono.TextTemplating" Version="2.2.1" />
-   <PackageReference Include="Mono.TextTemplating.Roslyn" Version="2.2.1" />
-   <PackageReference Include="System.CodeDom" Version="4.4.0" />
+   <!-- 下記のTarget内で使うため、GeneratePathPropertyを設定する -->
+   <!-- 利用側では必要ないため、PrivateAssetsを設定する -->
+   <PackageReference Include="Mono.TextTemplating" Version="2.2.1" GeneratePathProperty="true" PrivateAssets="all" />
+   <PackageReference Include="Mono.TextTemplating.Roslyn" Version="2.2.1" GeneratePathProperty="true" PrivateAssets="all" />
+   <PackageReference Include="System.CodeDom" Version="4.4.0" GeneratePathProperty="true" PrivateAssets="all" />
  </ItemGroup>
+
+ <Target Name="GetDependencyTargetPaths">
+   <ItemGroup>
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgSystem_CodeDom)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgMono_TextTemplating)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+     <TargetPathWithTargetPlatformMoniker Include="$(PkgMono_TextTemplating_Roslyn)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
+   </ItemGroup>
+ </Target>

ISourceGeneratorの実装

Generator.cs
using System;
using System.CodeDom.Compiler;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Mono.TextTemplating;
using Location = Microsoft.CodeAnalysis.Location;

namespace T4Generator;

[Generator]
public class T4Generator : ISourceGenerator
{
  // 今回のGeneratorには不要
  public void Initialize(GeneratorInitializationContext context) { }

  public void Execute(GeneratorExecutionContext context)
  {
    // csprojの<AdditionalFiles>に一致したファイルはここに入る
    var t4Files = context.AdditionalFiles.Where(at => at.Path.EndsWith(".tt"));
    if (t4Files?.Any() != true) return;

    // T4テンプレート変換クラスの生成+Roslynコンパイラーの使用
    var generator = new TemplateGenerator();
    generator.UseInProcessCompiler();

    foreach (var file in t4Files)
    {
      var content = file.GetText(context.CancellationToken);
      if (content is null) continue;

      try
      {
        // T4テンプレートのコンパイル
        var template = generator.CompileTemplate(content.ToString());
        if (template is null)
        {
          // コンパイルエラー内容を取得し、ソースジェネレーター側にエラーを通知
          var error = generator.Errors.Cast<CompilerError>().FirstOrDefault();
          if (error is not null)
            context.ReportDiagnostic(CreateT4ErrorDiagnostic(error));
          continue;
        }

        // T4テンプレートの生成結果を取得
        string sourceCode = template.Process();

        // {ファイル名}.Generated.{ハッシュ}.csで保存
        string fileName = Path.GetFileNameWithoutExtension(file.Path);
        context.AddSource($"{fileName}.Generated.{file.GetHashCode():x8}.cs", sourceCode);
      }
      catch (Exception ex)
      {
        // ソースジェネレーター側にエラーを通知
        if (ex is not OperationCanceledException)
          context.ReportDiagnostic(Diagnostic.Create(_complieError, Location.None, ex));
      }
    }

    static Diagnostic CreateT4ErrorDiagnostic(CompilerError err)
      => Diagnostic.Create(_invalidFileWarning, Location.None, err.ErrorNumber, err.ErrorText);
  }

  private static readonly DiagnosticDescriptor _complieError = new(
    "T4GEN001",
    "Couldn't parse T4 file",
    "{0}",
    "T4Generator",
    DiagnosticSeverity.Warning,
    true);

  private static readonly DiagnosticDescriptor _invalidFileWarning = new(
    "T4GEN002",
    "Couldn't parse T4 file",
    "{0}: {1}",
    "T4Generator",
    DiagnosticSeverity.Warning,
    true);
}

ソース ジェネレーターを使うプロジェクトの作成

> dotnet new console

ソース ジェネレーターを参照する

csproj
+ <ItemGroup>
+   <!-- ソースジェネレーターでファイルを扱うには、AdditionalFilesが必要 -->
+   <AdditionalFiles Include="./**/*.tt" />
+ </ItemGroup>
+
+ <!-- プロジェクトをAnalyzerとして読み込む -->
+ <ItemGroup>
+   <ProjectReference Include="../../src/Nogic.T4Generator/Nogic.T4Generator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
+ </ItemGroup>

T4ファイルを作成する

ついでにProgram.csも削除しています。

Program.tt
<#@ template hostspecific="false" language="C#" #>
<#@ output extension=".g.cs" #>
/// <auto-generated/>

<# string message = "Hello, World from T4!!!"; #>
Console.WriteLine("<#= message #>");

コンパイル(失敗)

> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  T4Generator -> T4Generator.dll
CSC : warning T4GEN001: System.IO.FileNotFoundException: Could not load file or assembly 'System.CodeDom, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. 指定されたファイルが見つかりません。 [Sample.csproj]
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [Sample.csproj]

ビルドに失敗しました。

CSC : warning T4GEN001: System.IO.FileNotFoundException: Could not load file or assembly 'System.CodeDom, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. 指定されたファイルが見つかりません。 [Sample.csproj]
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [Sample.csproj]
    1 個の警告
    1 エラー

System.CodeDomのアセンブリが読み込めず失敗。

.NET ローカルツールを使う(成功)

プロジェクトの作成

省略

ローカルツールのインストール

> dotnet new tool-manifest
> dotnet tool install dotnet-t4

「ビルド前」「クリーンアップ後」の処理を追加

csproj
<ItemGroup>
  <!-- T4ファイルをプロジェクトから参照する (TextTemplateは好きな名前でよい) -->
  <TextTemplate Include="./**/*.tt" />
  <!-- 生成後のファイルをプロジェクトから参照する (Generatedは好きな名前でよい) -->
  <Generated Include="./**/*.g.cs" />
</ItemGroup>

<!-- ビルド前にdotnet-t4をTextTemplateに指定された各ファイルに対して呼び出すタスク -->
<Target Name="TextTemplateTransform" BeforeTargets="BeforeBuild">
  <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet t4 %(TextTemplate.FullPath)" />
</Target>
<!-- クリーンアップ後にGeneratedに指定されたファイルを削除するタスク -->
<Target Name="TextTemplateClean" AfterTargets="Clean">
  <Delete Files="@(Generated)" />
</Target>

T4ファイルを作成する

ついでにProgram.csも削除しています。

Program.tt
<#@ template hostspecific="false" language="C#" #>
<# // extensionは、csprojのGeneratedで指定したものに合わせる #>
<#@ output extension=".g.cs" #>
/// <auto-generated/>

<# string message = "Hello, World from T4!!!"; #>
Console.WriteLine("<#= message #>");

ビルド

> dotnet build

ビルドプロセス上で、以下のファイルが生成される。
生成されたファイルも、Gitなどのバージョン管理に追加したほうがよさげ?(プロジェクトの方針による)

Program.g.cs
/// <auto-generated/>

Console.WriteLine("Hello, World from T4!!!");

既知の問題

初回ビルド時に失敗することがある

> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  復元対象のプロジェクトを決定しています...
  復元対象のすべてのプロジェクトは最新です。
CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [xxx.csproj]

ビルドに失敗しました。

CSC : error CS5001: プログラムは、エントリ ポイントに適切な静的 'Main' メソッドを含んでいません [xxx.csproj]
    0 個の警告
    1 エラー
  • Program.g.csがコンパイル対象になる前に、ビルド実行されてしまうのが原因?
  • もう一度ビルド実行することで解決する

T4ファイルが存在しない場合にエラー

> dotnet build
.NET 向け Microsoft (R) Build Engine バージョン 17.0.0+c9eb9dd64
Copyright (C) Microsoft Corporation.All rights reserved.

  復元対象のプロジェクトを決定しています...
  Input is empty
xxx.csproj(16,5): error MSB3073: コマンド "dotnet t4 " はコード 1 で終了しました。    
  • <TextTemplate>に当てはまるファイルが存在しない場合、dotnet-t4を呼び出すコマンドに空の値が渡ってしまうことが原因
  • T4を使わなくなった場合は、ちゃんと後片付けしましょう

Visual Studioとの共存

上記環境を構築すると、Visual Studio側からT4テンプレートをソリューションに追加する手法が取れなくなります。
また、すでにVisual Studio側にてT4テンプレートを使っている場合は、上記設定をすることでファイル生成が二重に行われてしまいます。
Visual Studio側の動作を止める場合は、<ItemGroup>内にあるxxx.ttを指定している項目を削除します。

まとめ

  • ソースジェネレーターはいいぞ
    • JSON Schemaから「Entityクラス」と「Web APIを呼び出すサービスクラス」とか作れそう
    • ただ、NuGetパッケージまわりの項目はまだ難しい
  • ソースジェネレーターで外部ファイルを読み込むには<AdditionalFiles>を追加する
  • ビルド周りで何かしたければMSBuildの黒魔術(というほどでもないけど)に頼る

参考

9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?