Archive - 2019.12

2019年を振り返る

今年はどういう年だったかというと、うーん、まともに会社として動き出した年、ですかね。去年Cysharpという会社を作りましたが、その時点では一人だったので会社感も全く何もなかったのですが、今年から何人か入ってきたので、やっと会社として体をなしてきました。

ブログの本数があからさまに減ってますが、Cygames Engineers’ Blogに書いている分もあるのでそこはしょーがない。今年は講演も結構した気がします。CEDECやUniteなど大きなイベントでも話してきましたし、特にUniteのUnderstanding C# Struct All Thingsは好評だったようで、アンケート結果でも実質一位(正しくは4位、1~3位はUnity Technologies Japanの人だったので、それを除いたらという謎基準により)だったので、嬉しみがあります。

ライブラリは、なんか一年中ずっとMessagePack for C# v2に関わってた気がするのですが、なんとか駆け込みでリリースできてよかった。それに合わせてMagicOnionMasterMemoryといった内部でMessagePackを使っているライブラリのアップデートも間に合い、今年を気持ちよく終えることができそうです。

Cysharpとしてはパブリックなリポジトリ(github/Cysharp)が10個。設立から一年ちょいの会社のポートフォリオとしては、かなり立派なんじゃないでしょうか!会社の社是「C#の可能性を切り開いていく」のとおりに、C#にないものを絶妙な感じに埋めまくっています。

私はC#にとてもこだわっているわけですが、実際のところ、言語の選択がアプリケーションの開発の成功に必須かといったら、別にそうではないとも思っているのです、実のところ。ほとんどのことは、別に何の言語だろうと達成できるだろう、とも。(実際、私はCTOとして在籍していた前の会社、グラニの立ち上げタイトルである「神獄のヴァルハラゲート」をPHPでの開発に同意して、暫くPHP書いていたりもしましたしね←ただし成功したらすぐにC#リプレイスプロジェクトを始動して、半年後に完全移行しましたが)。

それでも、人は何かを選択しなきゃあいけないわけで、そのときにC#を選んでほしいし、選ぶ理由を作っていくことを大事にしています。前提としての他の言語にあってC#にはないものをなくしていき、差別化としてC#にしかないものを用意(超ハイパフォーマンスなライブラリであったり、C#大統一理論なんかがそうです)する。そうすれば自然と選択肢に上がってくるし、その結果、選ばれることが増えていく。実績だって数多くできていく。そういった環境を作っていくために、気を吐いているわけです。

それって会社の経営者としてどうなの!?というと、そうした活動が回り回って自分たちにうまくいくようにうまくしている(いく)ので、まぁまぁうまくいってるんじゃないでしょうか。

私はいっぱいOSSを公開しているのですが、そもそもに私はライブラリを作ることをアートだと捉えているんですよね。比喩でなく文字通りの。強く伝えたいメッセージ、表現したい衝動があり、それは時に、哲学的であり、政治的であり、ポジショントークでもある。私にとってはOSSの形で公開することこそが最も強く表現できる手段で、ライブラリは思想の塊であり、言葉だけよりもずっと流暢に語ってくれると考えています。

なので、つまんない量産型アーキテクチャの話とか聞くと、もっと個性が立っていてもいいのに!いったい何がしたいの?(いや、普通に動くアーキテクチャが一番大事ですが)、とか思ったりはよくします。あと、外資系企業とかシアトルやシリコンバレー勤務とかの話もつまんないですね、彼らが誇っているのって結局は場所が違うだけで自分自身はフツーのことをしてるだけじゃない?魂がないよね!それで他人を腐してるのだから実にダサい。(いやまぁそれは言い過ぎで、どんな仕事にも情熱はこもっているものです!だいじょうぶだいじょうぶ!)

とはいえアートじゃ生きていけない!実際OSSでは喰っていけない!中世ならパトロンが必要なところですが、現代においても食客のような立ち回りが必要です。つまり……。いやまぁ、食客かどうかはともかくとしてともかくとして、私は役に立つべき局面(たまにある、しょっちゅうはない)では驚くべきほど役に立ちます!し、実際、食客じゃないのでちゃんと仕事してますし(してます!)、今仕込んでいるものはきっと来年にはお見せできる(したい)と思うのですが、非常にインパクトのある成果になっていることと思います。Win-Winじゃないですかー。そうありたいね。

というわけで、生きていく間で大事にしたいのは、これからもそうした表現したい衝動を持ち続けられること、です。そのためにも、どこから降ってくるか分からない衝動のために、色々な刺激を受け、脳みそで咀嚼して考えていきたいなあ、と。

C#

今年も色々作ったのですが、MicroBatchFrameworkが地味に大きかったかなあ。めっちゃくちゃ使ってます。作って良かった。もっと評判になればいいのに、ぐぬぬ……。

技術的にはMessagePack for C# v2がめちゃくちゃ大きくて、また一つC#が新しいレベルに到達してしまった……。みたいな感慨があります。v2のお陰で、MagicOnionはどうあるべきなのか、といった道筋がスッと通っていったのが実際感動的です。というわけで、来年はフレームワーク全体を通しての新世代のアーキテクチャの掲示、というのを行えるんじゃないかと思っています。

MasterMemoryも良かったのではないかと、SQLiteの4700倍!なのも当たり前で、これがあると取るべきアーキテクチャの幅が広がるはずです。サーバーサイドでもクライアントサイドでも、活用しがいのあるライブラリになっています。

反省点はUnity側の深堀りがほとんど出来なかったことですね。毎年言い続けているECSやる、とか、去年も言っていたECS+物理エンジンでちゃりんちゃりん、みたいなのも今年も何も実装せずに終わった、とか、いやはやー。来年こそECSイヤーにするぞい!

諸事情で、プログラミングに集中して割ける時間というのがどんどん減っていってはいるのですが(GitHubの草も壊滅的で)、それでも要所要所での爆発力みたいなので成果は出せているし、今年も純粋な能力的成長は果たせたと思っています。今のところの脳みその調子的にはかなり良いので、来年もいい成果を出していけそうです。

漫画/音楽/ゲーム/その他…

GDC 2019に行ってきたのですが、そのアウォードで表彰されてたゲームが、さすがの普通にとても良かった。特にCelesteは本当によく出来ていて暫く遊び続けていました。それとFlorence、これは(リリースは2018年ですが私が今年プレイした中で)Bestで、感情を動かすためのUI/UXの究極系みたいな感じで、がっつりショック受けました。とにかくヤバい。制作秘話がまさにGDCで発表されていました。[GDC 2019]鮮やかな人間ドラマを描く新感覚ゲーム「Florence」は,どのように作られたのか。22か月間の苦闘をデザイナーが振り返る

音楽は念願の相対性理論の野外ライブに行ってきた!Liveアルバム調べる相対性理論を日比谷野音で全曲実演したのですが、信じられないほど良かった!メンバーチェンジ後の相対性理論はビミョンに思ってたのですが、前のメンバーじゃこのライブには絶対ならなかっただろうし、この到達点のための……!みたいに思ってもう全てが受け入れられた。そんなわけで今年はずっとこのアルバム聴いてましたね。Live映像も出してほしい……。

あとは、魂がふるえる展でふるえて来ました。いやあ、よかったね。実際この展示は最終的に今年の美術展の入場者数でもトップクラスだったそうで、映え、もそうですけれど実物から漂う死生観には圧倒されました。

ベストガジェットはAirPods Proということで。SonyのWF-1000XM3も買っちゃってはいたんですが、どうにも使いづらくてAirPodsをずっと使い続けてたんですが、AirPods Proが出た瞬間に、完全に全て乗り換えました。恐ろしい完成度のデバイスだし、様々な面で既存の延長線上「ではない」革命的な要素が散りばめられていて、モノを作るならこういうのを作りたいよね(喩えで、別にハードウェアを作りたいとかそういうことを言ってるわけではない)、と思わせる一品でした。

来年は

MagicOnion v4と、そのためのパーツ作りが年始に入っています。これは間違いなくC#にとってのキーパーツになるので、ちゃんと仕上げたいですねえ。仕事のほうでも今仕込んでいるものが公開されていく年だと思うので、きっちりやっていけば相乗効果でC#元年(とは)ですよ!にできるんじゃないかと思ってます。そしてUnity度合いが結構下がっちゃってるので、そこも補填していければパーフェクトな姿に……!

といったのを目指してどしどしやっていきます。来年は爆発の年、ということで。

MessagePack for C# v2によるC#における最新のI/Oパイプライン最適化

MessagePack for C#のVersion 2を本日リリースしました。出る出る詐欺で、一年がかりでリリースまで漕ぎ着けました!とにかくめっちゃ時間かかった、死ぬほど私のリソースが取られていた、ので本当にリリースまで持ってこれてよかった……。めでたし。

今回はとてもOSSっぽく開発していて、メインの開発はMicrosoftのVisual StudioチームのPrincipal Software EngineerであるAndrew Arnottさんが書いています。私はそれに対してひたすら、APIデザインが好きじゃないだの、パフォーマンスが私の基準に満たしてないだの、文句つけまくる仕事をしていました。しかしコードのクオリティはさすがに非常に高くて、私だけだったらここには至れなかっただろうことを考えると、いい感じの共同開発ができたんじゃないかなあと思います。その結果として、一年前に掲示されたv2よりも、百億倍良くなってます。この一年間で磨きに磨き抜いたわけです。

最初のリリースから2年半が経ち、MessagePack for C#は今では ASP.NET のリポジトリに含まれていたり、Visual Studio内部で使われていたりと、もはや準標準バイナリシリアライザの地位を得ていたりします。さすがにここまで成長するとは想像してなかった。.NETに貢献し過ぎで偉い。Version 1はシリアライザのパフォーマンスの基準を大きく塗り替えて、新しい世代の水準を作り出したという偉業があったわけですが、今回のv2も大きな成果を出せると思っています。v2はI/Oパイプライン全体の最適化を見据えて、パイプラインの心臓部として正しく機能するためにはどうあるべきか、というのを指し示しました。今後のC#のアプリケーションのアーキテクチャは、ここで指し示した道に進んでいくことでしょう。

v1 -> v2においては破壊的変更多数なので、移行ガイドをmigration.mdにまとめてあるので、適当に読んでおくと良いでしょう、詳しい解説は特にしません。ライブラリ類は一斉にバージョン上げないと詰みます。Cysharpで作っているMagicOnionMasterMemoryは作業中なので、来週にはドバッと上げておきます多分予定。

パイプラインによるゼロコピー

MessagePack for C#の内部構造について、見るべきメソッドシグネチャは以下の2つです。

public static void Serialize<T>(IBufferWriter<byte> writer, T value, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)
public static T Deserialize<T>(in ReadOnlySequence<byte> byteSequence, MessagePackSerializerOptions options = null, CancellationToken cancellationToken = default)

シリアライズにおいては IBufferWriter<byte>, デシリアライズにおいては ReadOnlySequence<byte> を入出力口に使うというのがポイントです。 どちらもSystem.Buffersに定義されている .NET Standard 2.1 世代のインターフェイスです。反面、v1ではともに byte[] ベースでした。

I/Oのパイプラインってなんぞや、というと、ようするに入出力へのbyte[]をどう扱うかということであり、C#的には入り口も出口も最終的にはネイティブがやり取りするので、その手前のもの(SocketAsyncEventArgs, ConslePal+Stream, FileStream, etc…)、を呼び出して処理するフレームワーク、が呼び出すシリアライザ。といった流れになっています。シリアライザは常に中心(Object -> byte[]変換)にいます。上の画像は一般的なやり取りの場合(あるいはv1の場合)で、RedisValue(StackExchange.Redis)がbyte[]を、ByteArrayContent(HttpClient)がbyte[]を、といったように、割と素のbyte[]を求められる局面は多い。その場合、シリアライザはnew byte[]した結果を返すことになり、それはフレームワークの処理を得て、入出力の源流へ再度コピーされ(されないこともある)ます。

ここにおける無駄は、byte[]のアロケーションと、コピーです。

と、いうわけで、byte[]のアロケーションを避けるパターンとして、シリアライザは作業領域として外部のバッファープール( System.Buffers で定義されたArrayPool<byte>は .NET Core 3.0ではクラスライブラリ内でも多用されています)を使用し、フレームワークから提供されるStreamに書き込むことで、必要なコストをコピーだけにする手法があります。v1でも一部実装していました。なお、Streamに対して直接Writeすることで自前バッファを使わないという手も理論上可能ですが、Writeによるオーバーヘッドが多いため性能が悪化します。また、どちらにせよStream内部でバッファを持っている場合もあります。さらに、非同期にも対応できませんし、では全てをWriteAsyncで処理すれば、更にオーバーヘッドが多くて全く性能がでません。つまり、性能の良いアプリケーションを作るには、バッファをどう扱うが大事です。v1の設計思想として、シリアライズの一単位をバッファとして取り扱えば良い、だから全てをbyte[]ベースで処理し、Streamへは一気に書き込めば良い。という指針がありました。そして、それは実際正しく機能して、当時存在したあらゆるシリアライザを引き離す性能を叩き出しました。

IBufferWriter<byte>を活用すると、作業に必要なバッファを元ソースに対して直接要求することができます。それによりバッファ管理を完全に元ソースに任せることができるため、シリアライザ内部の作業バッファからのコピーコストが消滅します。例えばソケット通信で使われるSocketAsyncEventArgsは通常使いまわされますが、それの持つ(byte[] Buffer)に直接書き込む、といったようなことが可能です。

Streamに対してはSystem.IO.Pipleinesの提供するPipeWriterIBufferWriter<byte>を実装し、最適なバッファ管理を代替してくれます。

ASP.NET Core 3.0からは従来の(Stream HttpResponse.Body)だけでなく、(PipeWriter HttpResponse.BodyWriter)も提供されるようになりました。MessagePack.AspNetCoreMvcFormatterは、.NETCoreApp 3.0の場合にはBodyWriterに対してシリアライズする実装を用意しています。

現在の.NETのフレームワークは、Streamを要求するものか、あるいはbyte[]を要求するものがほとんどです。しかし、フレームワークレベルでのIBufferWriter対応が進んでいけば、よりMessagePack for C# v2の真価が発揮されていくことでしょう。もちろん、byte[]を返すAPI(byte[] Serialize<T>(T value))でも、最適なバッファ管理によってアロケーションやコピーコストを抑えるようになっています。

理論とパフォーマンス

多くある誤解として、async/awaitにしたら速くなるわけでもないし、Span<byte>にしたから速くなるわけでもありません。そして、IBufferWriter<byte>ReadOnlySequence<byte>にしても速くなるわけではありません。理屈上コピーが減ったとしても、遅くなりえます。素朴に実装すればコピーしたほうが10倍速い、といった状況はありえます。

例えば [10, 100, 100] をシリアライズしたいと思ったとして、intが最大5バイト必要だとして、都度writter.GetSpanで取得した場合と、byte[]でどばっと取得した場合を比較すると……

// IBufferWriter<byte>
foreach(var v in values)
{
    var buffer = writer.GetSpan(5);
    var length = WriteInt(buffer, v); // WriteInt returns write length
    writer.Advance(length); 
}
 
// byte[]
var buffer = ArrayPool<byte>.Shared.Rent(64K).AsSpan();
var offset = 0;
foreach(var v in values)
{
    var length = WriteInt(buffer.Slice(index), v);
    offset += length;
}
// Return buffer...

というようなコードを書いた場合、どう見てもbyte[]ベースで素朴にやったほうが速そうです、というか速いです。ReadOnlySequence<byte>もそうで、内部は複雑な型のため、そのまま使ってSliceなどを多用すると、かなり遅くなります。よって、IBufferWriter<byte>によって得られたバッファを適切に管理する中間層、ReadOnlySequence<byte>によって得られたSegmentのバッファを適切に管理する中間層、の作り込みが必要になってきます。

v2の開発にあたっては、byte[]ベースで極限まで性能を高めたv1があるので、どれだけv1と比較して遅くなっていないか、を基準に随時ベンチマークを取ることによって、中間層の存在による性能低下を検知し、極力性能低下を抑えることに成功しました。

逆に言えば、純粋なシリアライザとしての性能はv1のほうが高速(な場合も多い/バッファ管理が賢くなったのでシリアライズ対象が大きい場合はv2が有利な場合もある)なのですが、パイプラインに組み込めることと、様々な工夫により、トータルでみるとv2のほうが実アプリケーションとしては有利になっています。

配列上のLZ4圧縮

v1 -> v2による性能向上の一つに、v1では64K以上のシリアライズではプールを使わず新規アロケーションをしていましたが、v2ではArrayPoolから取得する32Kのチャンクの連結リストを内部バッファとして使用しています(外部からIBufferWriterを渡さず、v2内部のバッファプールを使用する場合)。

image

byte[]を作る場合は、最後に連結して一塊に。Streamに書き込む場合は32K毎にWriteAsyncします。これによりバッファが溢れた場合に、List<T>のように二倍のサイズのバッファを新規に確保して書き込み、などせずに済んでいます。また、常に使用するバッファの大きさが85K以下で済むため、悪名高いLarge Object Heap(LOH)を消費する(ここに溜まるとGCの性能が極度に低下する)ことも避けられています。

そしてv2から新規搭載された新しい圧縮モードである MessagePackCompression.Lz4BlockArray では、この内部形式を利用して32K単位でLZ4圧縮をかけることにより、圧縮するために、一度、全部が一塊になった大きな配列を確保することを避けています。

image

実装上の工夫としては、MessagePackの拡張領域であるExtを使用することによって圧縮種別を判定可能にしていることと、Extは長さが必要なため、LZ4で圧縮されてサイズが縮むことを考えると事前に長さを計算することができない!ことを避けるために、Arrayを使用した上で、Arrayの最初の要素をExtにして判定用+LZ4のデシリアライズに必要な圧縮前の長さをここの部分に格納しています。これ、シリアライズもそうですが、デシリアライズ時もブロック単位で伸張できるので、大きなデータでも巨大配列を確保しないで済むという利点があります。

v1までの圧縮モードはMessagePackCompression.Lz4Blockとして残していますが、v2ではMessagePackCompression.Lz4BlockArrayを使用することをお薦めしています。既に圧縮済みのバイナリデータに関しては、Lz4BlockArrayでもLz4Blockをデシリアライズすることが可能です(逆も同様)。

ちなみにこの32Kというサイズを選んだのには、ちゃんと意味があります!まず、ArrayPoolの仕様で16K, 32K, 64K, 128Kの大きさで確保されます。20Kを要求した場合は32Kが、65Kを要求した場合は128Kが得られるという図式です。

そしてLZ4圧縮した場合、全く圧縮できなかった場合、ワーストケースでは要求サイズよりもほんの少し「大きく」なります。さて、そこでチャンクのサイズが64Kギリギリまで使用していて、LZ4圧縮をかけようとした場合は、圧縮後のサイズは圧縮完了まで不明のため、事前にワーストケースを想定し64.1K(仮)を要求し、結果として128Kが得られます。つまり、LOH行きです。厳密にはArrayPoolを使用しているため使い回されるので大丈夫ですが、プールサイズには上限を設けているので(全体で共有で32K * 100)、使い切った場合はアロケートされるので、そういうケースでもLOH行きを避けるためのサイズになっています。

真のコードジェネレーター

悪名高いド不安定なコードジェネレーターは大きく改善され、真の安定性と、ディレクトリ単位での指定と、ファイル単位での出力と、CIで使いやすい .NET Core (Local/Global) Toolsでのインストールと、XamarinやUWPで便利なMSBuild Taskの提供と、Unityでは初心者フレンドリーなEditor拡張を追加しました。

とにかくMac/Linuxでも安定して動作する!というのが大きい!やっと大手を振って人にお薦めできるようになりました。

まとめ

と、解説しましたが、実装の8割以上は前述のAArnottさんが行ったものなので、まずは本当にありがとうございます。そもそもv2のキッカケはプロトタイプ実装を掲示されて、Forkしていくパターンか一緒に実装するかのどちらかと言われて、(何ヶ月も返事を放置した末に)、一緒にやっていきましょうとしたのが元でした。その後も、一ヶ月質問を放置するとかメールスルーとか、そもそも開発の遅れは私のコミュニケーションによるものでは……、というようなところを粘り強く乗り越えてもらったお陰です。

いや、そうはいっても私もかなりしっかりやってますよ!?特にAPIのデザインは紆余曲折あってもめにもめた末に一周回って私が最初に掲示したデザインになってるし、性能面では延々と私が地道にベンチマーク取って掲示することで腰を上げてもらったり(その時点で大体どこをどう変えればいいのかは分かってるので、指摘しながら)、Unity周りはそもそも興味ないみたいなので油断するとすぐ壊れるところを直していったりと、はい。

これでパイプラインにおける心臓部分を手に入れることが出来たのですが、改めてまだそもそもパイプラインに血が流れてません。ASP.NET Coreだけでなく他のフレームワークやライブラリ郡(特にRedisと、ADO.NETはいい加減に10年前のレガシーモデルから卒業して新しい抽象層を提供して欲しい)も対応していかなければならないし、私の場合はMagicOnionが、トランスポート層に採用しているGoogleのgRPCがイマイチなせいでめちゃくちゃイマイチです。

というわけで、次回はMagicOnionのパイプライン化を最適化するために、通信層から手を入れる予定です。また、シリアライザはMessagePackだけではなく、JSONも重要なので、改めてUtf8Jsonの改修も行いたいと思っています。.NET Core 3で華々しくデビューしたMicrosoft公式実装のSystem.Text.JsonによるJsonSerializerの性能が極めて悪いので……。残念ながらMicrosoftは柔軟かつ性能の出るシリアライザの作り方を全く分かっていないのでしょう。

また、このパイプラインはサーバーの入口→出口だけで閉じるものではなく、ネットワークを超えてクライアント側(Unity)にまで届くものだと考えています。サーバー/クライアントを大きなパイプラインに見立てて、見えるところ通るところ全てを最適化することが「C#大統一理論」であり、真に強力なのだ。ということを実証していくのが当座の目標で、やっと最初の一歩が踏めました。C#凄いな、と心の底から世界中の人が思ってもらうためにも(そしてあわよくば採用してもらう!)、まだ足りてないものは山のようにあるので、どんどん潰していきましょう。

.NET Core時代のT4によるC#のテキストテンプレート術

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を選べば実行時テキスト生成の出力結果が得られます。

image

現時点では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にできないこと」セクションに書いたとおり、よくも悪くも外部ツールで実行環境への依存がないので。

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for Developer Technologies(C#)

April 2011
|
July 2020

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc