.NET Core時代のT4によるC#のテキストテンプレート術
- 2019-12-06
C# Advent Calendar 2019用の記事となります。C# Advent Calendar 2019はその2もあって、そちらも埋まってるので大変めでたい。
さて、今回のテーマはT4で、この場合にやりたいのはソースコードジェネレートです。つまるところC#でC#を作る、ということをやりたい!そのためのツールがテンプレートエンジンです。.NETにおいてメジャーなテンプレートエンジンといえばRazorなわけですが、アレはASP.NET MVCのHTML用のViewのためのテンプレートエンジンなため、文法が全くソースコード生成に向いていません、完全にHTML特化なのです。また、利用のためのパイプラインもソースコード生成に全く向いていない(無理やりなんとか使おうとするRazorEngineといったプロジェクトもありますが……)ので、やめておいたほうが無難です。
では何を使えばいいのか、の答えがT4(Text Template Transfomration Toolkit)です。過去にはMicro-ORMとテーブルのクラス定義自動生成についてという記事で、データベースのテーブル定義からマッピング用のC#コードを生成する方法を紹介しました。テーブル定義は、実際にDB通信してもいいし、あるいは何らかの定義ファイルを解析して生成、とかでもいいですね。また、最近の私の作っているライブラリには(UnityのIL2CPP対策で)コードジェネレーターがついていますが(MagicOnion, MasterMemory, MessagePack-CSharpなど)、それらの生成テンプレートは全てT4で作っています。
元々はVisual Studioべったりでしたし、実際べったりなのですが、Mac環境ではVisual Studio for Macで、あるいは(一手間入りますが)VS Codeで使用したり、最近だとRiderが2019.3 EAP 5からばっちしサポートされたようなので、MacやRider派の人も安心です。RiderのT4サポートは曰く
You asked us to support T4, and we’ve answered! T4 support is here, based on our own generator and available as a pre-installed plugin.
The plugin is open-sourced (https://github.com/JetBrains/ForTea) and your contributions are very welcome.
Feature-rich C# support in code blocks includes highlighting, navigation, code completion, typing assistance, refactorings, context actions, inspections, formatting, and more.
T4-specific features include inspections, typing assistance, folding, brace matching, etc.
Extensive support is offered for includes to make the resolve in C# code as correct as possible.
You can execute T4 templates.
You can also debug T4 templates.
All these features work across Windows, macOS, and Linux.
だそうで、なかなかイケてるじゃないですか。Visual Studioも、Riderもそうですが、テンプレートエンジンでデバッガ動かしてステップ実行できたりするのが地味に便利です。VS Codeだと設定に一手間が必要なので(Qiitaにて.NET Core+VS CodeでもT4 テンプレートエンジンでコード生成したい!といった紹介もありますが)、VS2019, VS for Mac, Riderを使ったほうが楽そうです。
T4には2種類の生成パターン、デザイン時コード生成(TextTemplatingFileGenerator)と、実行時テキスト生成(TextTemplatingFilePreprocessor)の2種類がありますが、両方紹介します。
また、詳細なドキュメントがコード生成と T4 テキスト テンプレートにあり、実際かなり複雑な機能も搭載してはいますが、テキストテンプレートで複雑なことはやるべきではない(テキストテンプレートなんてあまり使わないような機能で使い倒した複雑なことやられても解読に困るだけ)ので、シンプルに使いましょう。シンプルに使う分には、全く難しくないです。
デザイン時コード生成
デザイン時コード生成とは、テンプレート単体でテキストを出力するタイプです。出力されたコードが、そのまま自身のプロジェクトのコンパイル対象になるイメージ。使い道としては、手書きだと面倒くさい単純な繰り返しのあるソースコードを一気に量産するパターンがあります。例えばジェネレクスのT1~T16までの似たようなコードを作るのに、一個雛形をテンプレートとして用意して for(1..16) で生成すれば一発、というわけです。他に、プリミティブの型(intとかdoubleとか)に対してのコード生成などもよくやりますね。またマークダウンで書かれた表(別の人がドキュメントとして書いているもの)から、enumを生成する、なんてこともやったりしますね。違うようで違わないようで実際微妙に違う退屈なユニットテストコードの生成、なんかにも使えます。
例としてMessagePackのカスタムシリアライザとして、以下のようなものを作りたいとします(単純な繰り返しの例としてちょうどよかったというだけなので、MessagePackの細かいAPIの部分は見なくてもいいです)。
using System;
using System.Buffers;
namespace MessagePack.Formatters
{
public sealed class ForceInt16BlockFormatter : IMessagePackFormatter<Int16>
{
public static readonly ForceInt16BlockFormatter Instance = new ForceInt16BlockFormatter();
private ForceInt16BlockFormatter()
{
}
public void Serialize(ref MessagePackWriter writer, Int16 value, MessagePackSerializerOptions options)
{
writer.WriteInt16(value);
}
public Int16 Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return reader.ReadInt16();
}
}
// 以下、Int16の部分がInt32だったりDoubleだったりするのを用意したい。
}
Int16になっている部分を、Int32やDoubleなど全てのプリミティブにあてはめて作りたい、と。手書きでも気合でなんとかなりますが、そもそも面倒くさいうえに、修正の時は更に面倒くさい。なので、これはT4を使うのが最適な案件といえます。
例はVisual Studio 2019(for Windows)で説明していきますが、T4の中身自体は他のツールを使っても一緒なのと、最後にRiderでの使用方法を解説するので、安心してください。
まずは新しい項目の追加で、「テキスト テンプレート」を選びます。
すると、以下のような空のテンプレートが生成されたんじゃないかと思われます。
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".txt" #>
csprojには以下のような追加のされ方をしています。
<ItemGroup>
<None Update="ForceSizePrimitiveFormatter.tt">
<Generator>TextTemplatingFileGenerator</Generator>
<LastGenOutput>ForceSizePrimitiveFormatter.txt</LastGenOutput>
</None>
<None Update="ForceSizePrimitiveFormatter.txt">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>ForceSizePrimitiveFormatter.tt</DependentUpon>
</None>
</ItemGroup>
2個のItemが追加されているのは、T4本体と、生成物の2つ分です。<Generator>TextTemplatingFileGenerator</Generator>
というのがT4をこれで処理しますよ、という話で、DependentUponはソリューションエクスプローラーでの見た目上、ネストする親を指定、ということになっています。Visual Studioの不正終了なので、たまに***1.csなどといった末尾にインクリメントされたファイルしか生成されなくなってムカつく!という状況にたまによく陥るのですが、その場合はLastGenOutputあたりを手書きで修正してけば直ります。
さて、まず思うのはシンタックスハイライトが効いていない!ということなので、しょうがないのでVS拡張を入れます。私がよく使うのはtangible T4 Editor 2.5.0 plus UML modeling toolsというやつで、インストール時にmodeling Toolsとかいういらないやつはチェック外してT4 Editorだけ入れておきましょう。なお、Visual Studioは閉じておかないとインストールできません。他のT4用拡張も幾つかあるのですが、コードフォーマッタがキモいとかキモいとか色々な問題があるので、私はこれがお気に入りです(そもそもコードフォーマットがついてない!変な整形されるぐらいなら、ないほうが百億倍マシです)。
さて、<#@ ... #>
が基本的な設定部分で、必要な何かがあればここに足していくことになります。assembly nameは参照アセンブリ、基本的なアセンブリも最小限しか参照されていないので、必要に応じて足しておきましょう。ちなみにRiderだと何も参照されていない空テンプレートが生成されるので、デフォだとLINQすら使えなくてハァァァ?となるので要注意。System.Coreぐらい入れておけよ……。
import namespaceはまんま、名前空間のusingです。output extensionは、フツーは.csを吐きたいと思うので.csにしておきましょう。場合によってはcsvとかjsonを吐きたい場合もあるかもしれないですが。
次に、<# ... #> を使って、テンプレートに使う変数群を用意します。この中ではC#コードが書けて、別に先頭じゃなくてもいいんですが、あまりテンプレート中にC#コードが散らかっても読みづらいので、用意できるものはここで全部用意しておきましょう。
<#
var types = new[]
{
typeof(Int16),
typeof(Int32),
typeof(Int64),
typeof(UInt16),
typeof(UInt32),
typeof(UInt64),
typeof(byte),
typeof(sbyte),
};
Func<Type, string> GetSuffix = t =>
{
return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
};
#>
今回は生成したいプリミティブ型を並べた配列を用意しておきます。また、ここではローカル関数を定義して、くり返し使う処理をまとめることもできるんですが(テンプレート中に式を書くと見にくくなるので、引数を受け取ってstringを返す関数を用意しておくと見やすくなります)、tangible T4 Editorがショボくてローカル関数に対してシンタックスエラー扱いしてくるので(動作はする)、Funcを使うことでお茶を濁します。
ここから先は本体ですが、短いので↑で見せた部分も含めてフルコードが以下になります。Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合に、ちゃんとファイルが生成されていることが確認できるはずです!
<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
<#
var types = new[]
{
typeof(Int16),
typeof(Int32),
typeof(Int64),
typeof(UInt16),
typeof(UInt32),
typeof(UInt64),
typeof(byte),
typeof(sbyte),
};
Func<Type, string> GetSuffix = t =>
{
return t.Name == nameof(Byte) ? "UInt8" : (t.Name == nameof(SByte)) ? "Int8" : t.Name;
};
#>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY T4. DO NOT CHANGE IT. CHANGE THE .tt FILE INSTEAD.
// </auto-generated>
using System;
namespace MessagePack.Formatters
{
<# foreach(var t in types) { #>
public sealed class Force<#= t.Name #>BlockFormatter : IMessagePackFormatter<<#= t.Name #>>
{
public static readonly Force<#= t.Name #>BlockFormatter Instance = new Force<#= t.Name #>BlockFormatter();
private Force<#= t.Name #>BlockFormatter()
{
}
public void Serialize(ref MessagePackWriter writer, <#= t.Name #> value, MessagePackSerializerOptions options)
{
writer.Write<#= GetSuffix(t) #>(value);
}
public <#= t.Name #> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
return reader.Read<#= t.Name #>();
}
}
<# } #>
}
記法の基本的なルールは<# ... #>で括っている部分以外は平文で出力されます。そして、<#= ... #>が式で、そこが文字列として展開されます。
なお、テンプレートを書くにあたって、長年の経験から得たおすすめのお作法があります。
- 計算式はなるべくテンプレート中で書くのを避ける、ために事前に関数として定義しておく
<auto-generated>
を冒頭に書く、これはlintによる解析対象から外れる効果があるのと、このファイルがどこから来ているのかを伝えるために有用- foreachやifなどはなるべく一文で書く、複数行に渡っているとテンプレート中のノイズになって見難くなる。また、あまり
<#
の開始行はインデントつけずに先頭で、というのもインデントつけてると平文のテンプレート生成対象になるため生成コードのインデントがズレやすい
この辺を守ると、割と綺麗に書けると思います。テンプレートはどうしてもコード埋め込みがあって汚くなりやすいので、なるべく綺麗にしておくのは大事です。それでもどうしても避けられないifだらけで、読みにくくなったりはしてしまいますが、そこはしょうがない。
実行時テキスト生成
実行時テキスト生成は、いわゆるふつーに想像するテンプレートエンジンの動作をするもので、プログラム実行時に、変数を渡したらテンプレートにあてはめてstringを返してくれるクラスを生成します。データベースのテーブル定義からマッピング用のC#コードを生成するツール、であったり、私がよくやってるのはRoslyn(C# Compiler)でC#コードを解析して、それをもとにして更にC#コードを生成するツールであったり、というかむしろC#コードを解析してKotlinコードを生成したりなど、やれることはいっぱいあります。リフレクションでアセンブリを舐めて、条件に一致した型、メソッドから何かを作る、みたいなのも全然よくありますね。
これの作り方は、追加→新しい項目から「ランタイム テキスト テンプレート」を選ぶと、悲しいことに別に普通の「テキスト テンプレート」とほぼ同じものが生成されてます(VS2019/.NET Coreプロジェクトの場合)。なぜかというと、csprojを開くとGeneratorがTextTemplatingFileGenerator、つまり普通のテキストテンプレートと同じ指定になっているからです。これは、多分バグですね、でもなんか昔から全然直されてないのでそういうもんだと思って諦めましょう。
しょーがないので手書きで直します。GeneratorのTextTemplatingFileGeneratorをTextTemplatingFilePreprocessorに変えてください。そうすると以下のようなファイルが生成されています(Visual Studioの場合は保存時、Riderの場合は右クリックから手動で実行した場合)
#line 1 "C:\Users\neuecc\Source\Repos\ConsoleApp14\ConsoleApp14\MyCodeGenerator.tt"
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.TextTemplating", "16.0.0.0")]
public partial class MyCodeGenerator : MyCodeGeneratorBase
{
/// <summary>
/// Create the template output
/// </summary>
public virtual string TransformText()
{
return this.GenerationEnvironment.ToString();
}
}
ファイル名で作られたパーシャルクラスと、TransformTextメソッドがあるのが分かると思います。これで何となく使い方は想像つくと思いますが、new MyCodeGenerator().TransformText()
でテンプレート結果が実行時に得られる、という寸法です。
さて、しかしきっとコンパイルエラーが出ているはずです!これはNuGetで「System.CodeDom」を参照に追加することで解決されます。別にCodeDomなんて使ってなくて、CompilerErrorとCompilerErrorCollectionというExceptionを処理する生成コードが吐かれてるから、というだけなので、自分でその辺のクラスを定義しちゃってもいいんですが、まぁ面倒くさいんでCodeDom参照するのが楽ちんです。この辺ねー、昔の名残って感じでめっちゃイケてないんですがしょーがない。
それと行番号がフルパスで書かれててめっちゃ嫌、というかフルパスが書かれたのをバージョン管理に突っ込めねーよ、って感じなので、これも消しましょう。linePragmas="false"をテンプレート冒頭に書いておけば消えます。
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
この対応はほとんど必須ですね、ていうかデフォでfalseにしといてくれよ……。
さて、テンプレートにパラメーターを渡す方法ですが、これは生成されたクラス名と同じpartial classを定義すればOK。
namespace ConsoleApp14
{
// 名前空間/クラス名(ファイル名)を合わせる(親クラスの継承部分は書かなくてOK)
public partial class MyCodeGenerator
{
public GenerationContext Context { get; }
// とりあえずコンストラクタで受け取る
public MyCodeGenerator(GenerationContext context)
{
this.Context = context;
}
}
// 生成に使うパラメーターはクラス一個にまとめておいたほうが取り回しは良い
public class GenerationContext
{
public string NamespaceName { get; set; }
public string TypeSuffix { get; set; }
public int RepeatCount { get; set; }
}
}
これでテンプレート中でContextが変数として使えりょうになります。テンプレートの例として、面白みゼロのサンプルコードを出すと……(Roslynとか使うともっと意味のあるコード例になるのですが、本題と違うところがかさばってしまうので、すみません……)
<#@ template language="C#" linePragmas="false" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ output extension=".cs" #>
// <auto-generated>
// THIS (.cs) FILE IS GENERATED BY ConsoleApp14.exe. DO NOT CHANGE IT.
// </auto-generated>
namespace <#= Context.NamespaceName #>
{
<# foreach(var i in Enumerable.Range(0, Context.RepeatCount)) { #>
public class Foo<#= Context.TypeSuffix #><#= i #>
{
}
<# } #>
}
RepeatCountの回数で中身空のクラスを作るというしょっぱい例でした。これの注意事項は、普通のテキストテンプレートと特に変わりはないのですが、auto-generatedのところに、生成ツール名を入れておいたほうがいいです。外部ツールでコード生成するという形になるため、ファイルだけ見てもなにで生成されたかわからないんですね。あとから、何かで作られたであろう何で作られたかわからない自動生成ファイルを解析する羽目になると、ツールの特定から始めなくちゃいけなくてイライラするので、この辺をヘッダにちゃんと書いといてあげましょう。ただたんに「これは自動生成されたコードです」と書いてあるだけよりも親切で良い。
記述におけるコツは、やはりテンプレート中に式を書いて文字列を生成するよりかは、この場合だとContext側にメソッドを作って、引数をもらってstringを返すようにすると見通しが良いものが作りやすいでしょう。ちなみにthis.Context
のContextのIntelliSenseは効きません、そっちの解析までしてくれないので。テンプレートファイルにおける入力補完は甘え、おまけみたいなもんなので、基本的には頼らず書きましょう。もちろん、そのせいでばんばんTypoしてエラーもらいます。これが動的型付け言語の世界だ!をC#をやりながら体感できるので、いやあ、やっぱ静的型付け言語はいいですねえ、という気持ちに浸れます。
さて、こうして出来たクラスは、ただのパラメーターを受け取ってstringを返すだけのクラスなので、何に使っても良いのですが、9割はシンプルなコマンドラインツールになるのではないでしょうか。
C#でコマンドラインツールといったら!私の作ってるMicroBatchFrameworkが当然ながらオススメなので、それを使ってツールに仕立ててみましょう。
using MicroBatchFramework;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleApp14
{
class Program : BatchBase
{
static async Task Main(string[] args)
{
await BatchHost.CreateDefaultBuilder().RunBatchEngineAsync<Program>(args);
}
public void Run(
[Option("o", "output file path")]string outputPath,
[Option("n", "namespace name")]string namespaceName,
[Option("t", "type name suffix")]string typeSuffix = "Foo",
[Option("c", "type generate count")]int repeatCount = 10
)
{
// パラメータを作って
var context = new GenerationContext
{
NamespaceName = namespaceName,
TypeSuffix = typeSuffix,
RepeatCount = repeatCount
};
// テキストを生成して
var text = new MyCodeGenerator(context).TransformText();
// UTF8(BOMなし)で出力
File.WriteAllText(outputPath, text, new UTF8Encoding(false));
Console.WriteLine("Success generate:" + outputPath);
}
}
}
これで ConsoleApp14.exe -o "foo.cs" -n "HogeHoge" -t "Bar" -c 99
というしょっぱいコマンドでfoo.csが吐かれるツールが完成しました!
RiderにおけるT4
RiderでのT4は、Visual Studioが使っている生成ツールとは違う、T4の記法として互換性のあるJet Brains独自のツールを実行している気配があります(そのため、場合によっては互換性がないところもあるかもしれません、というか実際linePragms=falseで行番号が消えなかった……)
実行自体は簡単で、ttに対して右クリックしてRunを選べばデザイン時コード生成、Preprocessを選べば実行時テキスト生成の出力結果が得られます。
現時点ではEAP、つまりプレビュー版ですが、まぁ他にもいっぱい良い機能が追加されてるっぽいので、Riderユーザーは積極的にEAPを使っていけばいいんじゃないかしら。
T4にできないこと
T4は、基本的に.ttで書かれたファイルを事前に(VSだと保存時、Riderだと任意に、あるいは設定次第でビルド時に)外部ツールが叩いて、テキスト(.csだったり色々)を生成します。いいところはC#プロジェクトと一体化して埋め込まれるので、パフォーマンスもいいし、そもそもT4生成時に型が合わなければエラーが出てくれる(動的の塊であるテンプレートファイルにとって、書き間違えは日常茶飯事なので、これは結構嬉しい)。実行効率も良いです、ファイル読んでパースして必要があればキャッシュして云々というのがないので。
が、しかし、プログラムが実行時に動的にテンプレートを読み込んでなにかする、みたいなことはできません。ツールのビルド時にテンプレートが出来上がってないといけないので、プラグイン的に足すのは無理です。それは成約になる場合もある、でしょうし、私的には型もわからないようなのを動的に合わせてテンプレート作るとか苦痛なので(←最近なんかそういうのやる機会が多くて、その度にシンドイ思いをしてる)、パラメータ渡しだけでなんとかして済むようにして諦めて作り込んだほうが百億倍マシ、ぐらいには思っていますが、まぁそういうのがしたいというシチュエーション自体は否定できません。その場合は、他のテンプレートエンジンライブラリを選んで組み込めば良いでしょう、T4がやや特殊なだけで、他のテンプレートエンジンライブラリは、むしろそういう挙動だけをサポートしているので。
まとめ
T4は十分使い物になります。微妙にメンテされてるのかされてないのか不安なところもありますが、そもそもMicrosoftもバリバリ使っているので(GitHubに公開されているcorefxのコードとかはT4で生成されているものもかなりあります、最近追加されたようなコードでも)、全く問題ないでしょう。実際、記法も必要十分揃っているし、特に極端に見にくいということもないと思います、ていうかテンプレートエンジンとしてフツーなシンタックスですしね。
仮に複雑なことやりたければ、例えばテンプレートをパーツ化して使い回すために分割して、都度インポートとか、というのもできます(T4 インクルード ディレクティブでは.t4という拡張子が紹介されていますが(拡張子はなんでもいい)、一般的には.ttincludeという拡張子が使われています)。他いろいろな機能がありますが、あらためて、テキストテンプレートごときで複雑なことやられると、追いかける気が失せるので、なるべく単純に保ちましょう。やるとしてもせいぜいincludeまで。それ以上はやらない。
というわけで、どうでしょう。MicroBatchFrameworkとも合わせて、C#のこの辺の環境は割といいほうだと思ってます。コード生成覚えるとやれることも広がるので、ぜひぜひ、コード生成生活を楽しんでください!
また、Unityでも普通に便利に使えると思います。Editor拡張でソースコード生成するものを用意するパターンは多いと思いますが(なにかリソースを読み込んで生成したり、あるいはそのままAssembly.GetExecutingAssembly().GetTypes()して型から生成したり)、その時のテンプレートとして、普通にstringの連結で作ってるケースも少なくなさそうですが、「デザイン時コード生成」も「実行時テキスト生成」も、どっちも全然いけます。「T4にできないこと」セクションに書いたとおり、よくも悪くも外部ツールで実行環境への依存がないので。