Hatena::ブログ(Diary)

seraphyの日記 このページをアンテナに追加 RSSフィード

2014-04-19 Visual Studio搭載のT4テンプレートエンジンの3通りの活用方法

[][] Visual Studio 2013 Expressで標準サポートされるテンプレートエンジン(T4)の活用方法  Visual Studio 2013 Expressで標準サポートされるテンプレートエンジン(T4)の活用方法を含むブックマーク

コードジェネレータの有用性とT4

業務系のアプリケーションを作成していると、多かれ少なかれデータモデルのようなものをソースコード上のエンティティクラスとして表すために似たようなコードをたくさん書かなければならないことがある。

もちろん手で地道に書くなんてことはなくて、このようなものはコードジェネレータで機械的に生成させるのが良い。

だいたいデータモデルは開発中にどんどん変わってゆくのでエンティティクラスも都度変更してゆく必要があるからだ。

自動化しておけば何度でも生成できるし、コードの修正漏れなんてこともありえない。

これは良くあるシチュエーションなので、もう過去に何度も何度も何度もジェネレータを作成してきた。


ところが昨年達人出版会で興味をそそられた本を買ってきたところ、

「メタプログラミング.NET」

http://tatsu-zine.com/books/metaprogramming-dotnet

Visual Studioには、"T4"という、まさにコードジェネレータとして使うに最適なテンプレートエンジンがあらかじめ備わっている

ということを知った。


少し時間がかかってしまったが、T4の使い方について、だいたいの感触をつかめたので、記録に残しておきたいと思う。


T4とは何か?

T4とは、"Text Template Transformation Toolkit"の略であり、

その名の通り、テキストのひな形から何かしらのテキストを生成するためのツールである。

目的としては、"DSL(ドメイン特化言語)"を作るようなものと考えてよい。


先の本によれば、T4は、もともと2004年にマイクロソフトのDSLチームがASP.NETのエンジンを使って、"柔軟で簡単に統合できるコードジェネレータ"として作成したのが起源という。

そのため、テンプレートの記述形式は現在でもASP.NET的な構文の面影を残している。(現在はASP.NETとは直接関係ない。)


T4には利用形態がいくつかあるが、具体例としては、

たとえば、以下のような「デザイン時テンプレート」を作成したとする。

<#@ 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 className = "MyClass";
    var properties = new String[] { "Field1", "Field2", "Field3", "Field4" };
#>
namespace TemplateSample
{
    public class <#= className #>
    {
<#
        foreach (string prop in properties)
        {
#>
        public string <#= prop #> { get; set; }
<#
        }
#>
    }
}

Visual Studio上で、このテンプレートを作成すると、自動的に以下のようなコードが生成される。

namespace TemplateSample
{
    public class MyClass
    {
        public string Field1 { get; set; }
        public string Field2 { get; set; }
        public string Field3 { get; set; }
        public string Field4 { get; set; }
    }
}

テンプレートの拡張子として「.cs」を指定しているので、生成されたテキストファイルはソースコードとみなされる。

当然、プロジェクトの他のソースコードと何ら扱いはかわりない。

もちろん、ソースコードになっているので生成されたコードのインテリセンスも効く。


このジェネレータによるソースコード生成からコンパイルまでの一連の流れは、Visual Studioに統合されておりシームレスに利用できるため、下手に自作したジェネレータを使うよりも、よっぽど使い勝手が良い。


そのためであろうか、T4は、ASP.NET MVC, ADO.NET Entity Framework、そのほか、ポピュラーなフレームワークのコード生成に利用されたため、T4は、もっとも広く使われているツール構築フレームワークの一つとなっている、という。


このT4がVisual Studioに搭載されるようになったのは、Visual Studio 2008以降(要DSLツールキット)で、Visual Studio 2010以降では標準装備となっている。

現在のVisual Studio 2013であれば無償のExpress版を含めて、すべてのエディションでT4は利用可能である。


T4の利用形態

T4には利用形態が3種類ほどある。

  • デザイン時テンプレート
  • 実行時テンプレート
  • 実行時のT4エンジンの直接利用(やや特殊)

以下、3種類の使い方と特性を示した後、それぞれの具体的な実装例についてみてゆくものとする。


デザイン時テンプレートとしての利用

テンプレートを(コンパイル前に)ファイルとして生成するのが、デザイン時テンプレートである。

先の例でみたとおり、ソースコードを自動生成するような用途に用いることができる。

(もちろん、テキストファイルであれば何でも良い。)


ソースコードへの変換はコンパイル前に行われ、コンパイル後はT4で生成されたかどうかは問わないので、生成されたアプリケーションにT4ランタイムが必要ない。


また、生成するコードなどの情報を取得するためにVisual Studio側(環境)の情報を取得するための方法が用意されている。

プロジェクト中の定義ファイルを読み取ってソースコードを自動生成する、などの用途ではプロジェクトのフォルダ位置などを判定するために、Visual Studioの環境にアクセスすることができるようになっている。


その他にも、Visual Studioからテンプレートへ引数を与え、テンプレートを制御する仕組みもある。

(ただし、このテンプレートへの引数渡しの機能を使うためには、Visual Studioのアセンブリへの参照が必要となり、且つ、Visual Studio SDKを必要とするため、実質的にProfessional以降の有償のVisual Studioでなければならない。どちらかというと、Visual Studioの拡張としてのT4の利用方法と思われる。)


実行時テンプレートとしての利用

もう一方の利用形態である「実行時テンプレート」では、Visual Studio上でテンプレートを作成すると自動的にソースコードに変換される。


ただし、デザイン時テンプレートと異なるのは、実行時テンプレートが作成するソースコードは、テンプレートそのものをソースコードに変換する「テンプレートを適用したテキストを生成するためのコードを生成するもの」である。

生成されたテンプレートクラスはT4に依存しない単純なソースとして生成される。(どんなコードが生成されたのかはファイルを開けば確認できる。)


実際にテンプレートを適用したテキストを取得するには、生成されたテンプレートクラスにデータを与えて実行することで変換・取得する。

用途としては、たとえば「アプリケーションの機能として、テキストで記述されたレポートファイルを生成しなければならない」というような場合に、そのテンプレートエンジンとして利用する、などが考えられるだろう。


実行時のT4エンジンの直接利用

「実行時テンプレート」と似ているが、実行時テンプレートではテンプレートがソースコードに変換されコンパイルされるため、テンプレートそのものを実行時に変更することはできない。

これに対してT4エンジンを直接利用すれば、実行時にテンプレートを変更できるようになる。


ただし、当然のことながら、作成されたアプリケーションはT4に依存し、T4を動かすために必要なアセンブリも実行時に必要となる。

これにはVisual Studio SDKでインストールされるアセンブリも含まれ、(ビルドサーバへの配備以外に)それらのアセンブリを再配布することは基本的にはできない。

また、Visual Studio ExpressにはVisual Studio SDKをインストールすることはできないため、Express版でもT4エンジンの直接利用はできないことになる。

よって、この方法を通常のアプリで採用するメリットはあまりないと思われる。


ただし、MonoDevelopの成果物として、T4エンジンとソースコードレベルでほぼ互換性のあるMono.TextTemplatingがあり、こちらを使うことでT4エンジン相当のものをアプリケーションに組み込むことが可能となっている。

Mono.TextTemplatingのライセンスはMIT/X11であり、非常に緩いため、商用・非商用にかかわらず大部分のケースでアプリケーションに組み込むうえでのライセンス的な支障はないと思われる。


デザイン時テンプレートの使用

まずは、デザイン時テンプレートの使い方について見てみる。

デザイン時テンプレートの作成

Visual Studio Express 2013 For Desktopで「デザイン時テンプレート」を新規に作成するには、「新しい項目の追加」から、「テキストテンプレート」を選択する。


f:id:seraphy:20140419122834p:image


このときに指定したファイル名「TextTemplate1.tt」をベースとして、

ソースコードである「TextTemplate1.txt」が自動的に生成されるようになる。


Visual Studioが、この*.ttファイルを認識して自動的にコード生成するのは、ソリューションエクスプローラから*.ttファイルのプロパティを見ると、カスタムツールが「TextTemplatingFileGenerator」となっており、これによって、このファイルがT4のテンプレートファイルであると認識してコードを生成するようになっている。

(「TextTemplatingFilePreprocessor」となっているし実行時テンプレートとして認識される。)


f:id:seraphy:20140419122835p:image


初期状態ではテンプレートは空であり、また拡張子もtxtとなっているので、ただの空のテキストファイルが生成されるテンプレートになっている。


テンプレートに与えるデータファイルの作成

デザイン時テンプレートを使う場合には、テンプレートに与えるデータをテンプレート側から取得する方法を考えなければならない。

ここでは、プロジェクト中に生成すべきクラスを定義したXMLファイルを置いておき、テンプレートによって自動的に必要なクラスを大量生産するケースを想定してみる。

<?xml version="1.0" encoding="UTF-8"?>
<classes>
    <class name="Class1">
        <field name="val1" type="System.Int32"/>
        <field name="val2" type="System.String"/>
    </class>

    <class name="Class2">
        <field name="val2" type="System.String"/>
        <field name="val3" type="System.Int32"/>
        <field name="val4" type="System.String"/>
    </class>

    <class name="Class3">
        <field name="val3" type="System.Int32"/>
    </class>

    <class name="Class4">
        <field name="val4" type="System.String"/>
        <field name="val5" type="System.Int32"/>
    </class>
</classes>

Class1からClass4まで、単純なプロパティをもつクラスを自動的に生成するものとする。


Visual Studio環境へのアクセス

このXMLファイルを「defClasses.xml」としてプロジェクト中に置いた場合、これをテンプレートからアクセスするには以下のように記述する。

<#@ template debug="true" hostspecific="true" language="C#" #>
<#@ output encoding="UTF-8" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Xml" #>
<#@ assembly name="System.Xml.Linq" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Xml.Linq" #>

namespace TemplateSample
{
<#
    // templateディレクティブでhostspecific="true"とすると、
    // Visual Studioの環境にアクセスできる.
    // ResolvePathで、このテンプレートファイルからの相対位置にあるファイルパスを
    // 取得することができる.
    string file = this.Host.ResolvePath("defClasses.xml");

    XDocument xdoc = XDocument.Load(file);
    System.Diagnostics.Debug.WriteLine(xdoc);
    var classes = from classesElm in xdoc.Descendants("class")
                  select new {
                        Name = classesElm.Attribute("name").Value,
                        Fields = classesElm.Descendants("field")
                  };

    foreach (var classDef in classes) {
        var classMetaInf = new ClassMetaInf()
            {
                ClassName = classDef.Name
            };
        foreach (var field in classDef.Fields) {
            classMetaInf.Fields.Add(new FieldMetaInf()
                {
                    FieldName = field.Attribute("name").Value,
                    FieldType = field.Attribute("type").Value 
                });
        }
#>
<# // T4ではテンプレートが大きくなりすぎないように分割しincludeによって読み込むことができる #>
<#@ include file="DesigntimeTemplate2.ttinclude" #>
<#
    }
#>
}
<#+
    // クラス機能ブロック以降にはサポートクラスやメソッド等を宣言可能

    class FieldMetaInf
    {
        public string FieldName { get; set; }

        public string FieldType { get; set; }
    }

    class ClassMetaInf
    {
        public string ClassName { get; set; }

        private List<FieldMetaInf> _fields = new List<FieldMetaInf>();

        public List<FieldMetaInf> Fields
        {
            get
            {
                return _fields;
            }
        }

        public int NumberOfColumns
        {
            get
            {
                return _fields.Count;
            }
        }

        public string ColumnName(int idx)
        {
            return _fields[idx].FieldName;
        }

        public string ColumnType(int idx)
        {
            return _fields[idx].FieldType;
        }
    }
#>

templateディレクティブで、hostspecific="true" と指定する。

これにより、テンプレート中で「Host」というITextTemplatingEngineHostインスタンスを示す変数にアクセスできるようになる。

Visual Studioからテンプレートが実行される場合は、HostはVisual Studioの環境を示している。*1

    string file = this.Host.ResolvePath("defClasses.xml");

とすることで、"defClasses.xml"のファイルへのパスを得ることができる。

これによりテンプレートの内から、プロジェクトに必要な生成すべきクラスの情報を取得できることになる。


なお、ITextTemplatingEngineHostが、具体的にどのような役割で、どのようなことをしているのかは、本記事で後述する、T4テンプレートエンジンの直接利用の際に実際にホストを実装する事例がある。


サポートクラスの定義と、アセンブリの指定

テンプレートファイルには<#+ 〜 #>というブロックで囲むことで、クラス機能ブロックをつくることができ、ここにはテンプレートに必要なメソッドや、サポートクラスなどを定義することができる。


注意すべき点としては、テンプレートファイルはコンパイルされるタイミングも単位も異なっており、プロジェクト内の他のクラスは見えていない、ということである。


もし、テンプレートの中でも共有したいクラス等があれば、プロジェクトを分けるなどして別個のアセンブリとし、

テンプレートで使用するすべてのアセンブリは、アセンブリディレクティブで明示的に宣言しておく必要がある。


このとき、アセンブリディレクティブの検索方法は、GACやVisual StudioのPublicAssemblyフォルダか、あるいは絶対パスでの指定のいずれかとなるので、

プロジェクト内にあるdllを参照する場合には、以下のようなVisual Studioのマクロを使うのが良いと思われる。

<#@ assembly name="$(SolutionDir)T4SupportClassLibrary\bin\T4SupportClassLibrary.dll" #>

参考:

http://weblogs.asp.net/lhunt/archive/2010/05/04/t4-template-error-assembly-directive-cannot-locate-referenced-assembly-in-visual-studio-2010-project.aspx


ここによると、T4テンプレートのアセンブリディレクティブにdllを明示する方法としては、

  1. Global Assembly Cache(GAC)に入れておく。
  2. dllをフルパスで記述する。
  3. Visual Studioの"C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\PublicAssemblies" のようなVS用のパブリックフォルダに入れておく。
  4. 環境変数を使い、 <#@ assembly name="%mypath%\dotless.Core.dll" #> のように指定する。
  5. Visual Studio Macroを使う。

クラスの生成部

実際のクラス用のコード生成部は、別ファイル「DesigntimeTemplate2.ttinclude」として定義し、それをインクルードしている。

「DesigntimeTemplate2.ttinclude」の中身は、単に変数展開をしているぐらいなので面白みはないが、以下のような感じになる。

    /// <summary>
    /// defClasses.xmlより生成されたクラス <#= classMetaInf.ClassName #>
    /// </summary>
    public partial class <#= classMetaInf.ClassName #>
    {
<#
        for (int idx = 0; idx < classMetaInf.NumberOfColumns; idx++)
        {
#>
        public <#=  classMetaInf.ColumnType(idx) #> <#= classMetaInf.ColumnName(idx) #> { set; get; }
<#
        }
#>

        /// <summary>
        /// 診断用文字列の出力
        /// </summary>
        /// <returns>診断用文字列</returns>
        public override string ToString()
        {
            var buf = new System.Text.StringBuilder();
            buf.Append("<#= classMetaInf.ClassName #>");
            buf.Append("{");
<#
        for (int idx = 0; idx < classMetaInf.NumberOfColumns; idx++)
        {
            if (idx != 0)
            {
#>
            buf.Append(", ");
<#
            }
#>
            buf.Append("<#= classMetaInf.ColumnName(idx) #>=").Append(<#= classMetaInf.ColumnName(idx) #>);
<#
        }
#>
            buf.Append("}");
            return buf.ToString();
        }
    }

ちなみに、「partial class」としているのは生成済みクラスをカスタマイズできるようにするためのものである。

自動生成によってクラスが毎回作り直されるため、生成されたソースファイルに直接メソッドの追加などはできないが、partial classにしておけばカスタマイズ部分は別ファイルにしておくことでコンパイル時に1つのクラスとして結合してくれるわけである。


デバッグおよび強制的な実行

デザイン時テンプレートは、テンプレートを保存するたびに自動的にコードの生成が実行される。

しかし、"defClasses.xml"を変更した場合には感知されないので手動でコード生成を実行する必要がある。

これは簡単で、コンテキストメニューから「カスタムツールの実行」を選択すれば良い。


f:id:seraphy:20140419122836p:image


また、デザイン時テンプレートはVisual Studioが勝手に実行してしまうが、手動でデバッグしたい場合もコンテキストメニューから「T4テンプレートのデバッグ」を選択すれば、ちゃんとブレークポイントも効く状態でデバッグを行うことができる。


生成されたコード例

上記のデザイン時テンプレートを実行すると、以下のコードが自動生成される。

namespace TemplateSample
{
    /// <summary>
    /// defClasses.xmlより生成されたクラス Class1
    /// </summary>
    public partial class Class1
    {
        public System.Int32 val1 { set; get; }
        public System.String val2 { set; get; }

        /// <summary>
        /// 診断用文字列の出力
        /// </summary>
        /// <returns>診断用文字列</returns>
        public override string ToString()
        {
            var buf = new System.Text.StringBuilder();
            buf.Append("Class1");
            buf.Append("{");
            buf.Append("val1=").Append(val1);
            buf.Append(", ");
            buf.Append("val2=").Append(val2);
            buf.Append("}");
            return buf.ToString();
        }
    }

    /// <summary>
    /// defClasses.xmlより生成されたクラス Class2
    /// </summary>
    public partial class Class2
    {
        public System.String val2 { set; get; }
        public System.Int32 val3 { set; get; }
        public System.String val4 { set; get; }

        /// <summary>
        /// 診断用文字列の出力
        /// </summary>
        /// <returns>診断用文字列</returns>
        public override string ToString()
        {
            var buf = new System.Text.StringBuilder();
            buf.Append("Class2");
            buf.Append("{");
            buf.Append("val2=").Append(val2);
            buf.Append(", ");
            buf.Append("val3=").Append(val3);
            buf.Append(", ");
            buf.Append("val4=").Append(val4);
            buf.Append("}");
            return buf.ToString();
        }
    }

    /// <summary>
    /// defClasses.xmlより生成されたクラス Class3
    /// </summary>
    public partial class Class3
    {
        public System.Int32 val3 { set; get; }

        /// <summary>
        /// 診断用文字列の出力
        /// </summary>
        /// <returns>診断用文字列</returns>
        public override string ToString()
        {
            var buf = new System.Text.StringBuilder();
            buf.Append("Class3");
            buf.Append("{");
            buf.Append("val3=").Append(val3);
            buf.Append("}");
            return buf.ToString();
        }
    }

    /// <summary>
    /// defClasses.xmlより生成されたクラス Class4
    /// </summary>
    public partial class Class4
    {
        public System.String val4 { set; get; }
        public System.Int32 val5 { set; get; }

        /// <summary>
        /// 診断用文字列の出力
        /// </summary>
        /// <returns>診断用文字列</returns>
        public override string ToString()
        {
            var buf = new System.Text.StringBuilder();
            buf.Append("Class4");
            buf.Append("{");
            buf.Append("val4=").Append(val4);
            buf.Append(", ");
            buf.Append("val5=").Append(val5);
            buf.Append("}");
            return buf.ToString();
        }
    }

}

このように、単調なコードをズラズラと生成するには、コードジェネレータは抜群に威力を発揮してくれる。


なお、「<# 〜 #>」のブロック記号の前後に何もなく、ただちに改行である場合には、その行は無かったこととみなされる。

そのため、隙間の空いたみっともないコードを生成することはないのも嬉しいところである。


実行時テンプレートの使用

実行時テンプレートはデザイン時テンプレート同様にクラスとして生成されるが、生成されるコードはテンプレートを適用したものではなく、「テンプレートを適用したテキストを生成するためのコード」が生成される点が異なる。


テンプレートの記述方法は基本的に同じであるが、扱い方はずいぶん異なる。


実行時テンプレートの作成

Visual Studio Express 2013 For Desktopで「実行時テンプレート」を新規に作成するには、

「新しい項目の追加」から、「ランタイムテキストテンプレート」を選択する。

f:id:seraphy:20140419122837p:image

このときに指定したファイル名「RuntimeTextTemplate1.tt」をベースとして、ソースコードである「RuntimeTextTemplate1.cs」が自動的に生成されるようになる。


ソリューションエクスプローラから*.ttファイルのプロパティを見ると、カスタムツールが「TextTemplatingFilePreprocessor」となっており、デザイン時テンプレートとは処理方法が異なることが示されている。


初期状態ではテンプレートは空であるが、空であるなりに、何もしないためのテンプレートクラスが自動的に生成される。


生成するテキストテンプレートの定義

今回は、以下のようなHTML的なテキストファイルを実行時に生成するテンプレートを作成してみるものとする。

<#@ template language="C#" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Web" #>
<#@ import namespace="System.Collections.Generic" #>
<!DOCTYPE>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title><#= HttpUtility.HtmlEncode(Title) #></title>
</head>
<body>
<div>
    The date and time now is: <#= DateTime.Now #>
</div>
<div>
    <ul>
<#
    foreach (var item in Items)
    {
#>
        <li><#= HttpUtility.HtmlEncode(item) #></li>
<#
    }
#>
    </ul>
</div>
</body>
</html>
パラメータの定義

実行時テンプレートでは、実行時のプログラムからパラメータをテンプレートに渡す必要がある。

すでに前述のテンプレートでは「Title」や「Items」といった変数を使用している。

これを引き渡すのは簡単であり、以下のようにクラス名を同じにしたPartial Classを作成すれば良いだけである。

    // T4の"実行時テンプレート"は、内部で使用する各種パラメータは
    // 単に、partial classとしてメンバ変数を定義し、それを使用すれば良いだけ.
    // クラス名はテンプレートファイル名から命名される.
    partial class RuntimeTemplate1
    {
        public string Title { set; get; }

        public IEnumerable<string> Items { set; get; }
    }

RuntimeTemplate1.ttは、RuntimeTemplate1というクラスを生成するので、これと同じ名前でPartial Classを作成する。


生成されたRuntimeTemplate1クラスでは、テンプレート中で使用された変数は、そのままソースコードとして出てきているだけなので、Partial Classで変数を定義してあげれば、それでOKとなる。(Partial Classで変数を定義しなければ、変数未定義によりコンパイルエラーになるだけである。)


サポートクラスの定義と、アセンブリの指定

実行時テンプレートはデザイン時テンプレートと異なり、ソースは生成するが、コンパイルはしないし、実行することもない。

ただ単にソースを生成するだけである。


生成されたソースはプロジェクトの他のソースと一緒にコンパイルされることになるため、

テンプレート中では、プロジェクト中の他のクラスを利用するのも制限はなく、アセンブリディレクティブの指定については、まったく必要ない。(プロジェクト側の参照設定に従うため。)


テンプレートの実行

テンプレートクラスを実行するには、生成されたテンプレートクラスに用意されている「TransformText」メソッドを呼び出す。

        /// <summary>
        /// 実行時テンプレートのテスト
        /// </summary>
        private static void runRuntimeTemplate1()
        {
            // 実行時テンプレートをインスタンス化する
            // 実行時テンプレートはカスタムツールは"TextTemplatingFilePreprocessor"として
            // 設定されており、テンプレートの保存時に実行時テンプレートクラスに変換される.
            var tmpl1 = new RuntimeTemplate1();

            // partial classで宣言したメンバ変数に対して値を入れる.
            tmpl1.Title = "Hello, T4 RuntimeTemplate!";
            tmpl1.Items = new List<string>() { "aaa", "bbb", "ccc" };

            // テンプレートを評価する.
            string generatedText = tmpl1.TransformText();

            // 結果の出力
            System.Diagnostics.Debug.WriteLine(generatedText);
            System.Console.WriteLine(generatedText);
        }

非常に簡単に利用できるようになっている。


このメソッドは特に何らかのインターフェイスを実装しているわけでなく、純粋に「TransformText」というメソッドが生成されているだけである。

このテンプレートクラスは、ソースを見れば明らかであるが、内部ではテンプレート処理に必要なさまざまな処理を実装しているが、それ自身で完結しており、T4などの外部のアセンブリは参照していない。

そのため、生成されたテンプレートクラスをアプリケーションに組み込んでも特別なライブラリへの参照設定が増えるようなことはないようになっている。


実行結果

実行結果は以下のようになる。

<!DOCTYPE>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Hello, T4 RuntimeTemplate!</title>
</head>
<body>
<div>
    The date and time now is: 04/17/2014 21:17:23
</div>
<div>
    <ul>
        <li>aaa</li>
        <li>bbb</li>
        <li>ccc</li>
    </ul>
</div>
</body>
</html>

テンプレートエンジンの実装は巷にたくさんあるし、テキストファイルを読み込んでの変数展開や、ちょっとしたループ処理をするだけなら、自作したとしても、たいして難しい実装が必要なわけでもない。


しかしながら、T4を使えば、C#の全機能を活用でき、且つ、それをソースコードとして取得できるため、実行時にはコンパイル済みとなる高効率なテキスト変換処理を、とても容易く入手することができるわけである。


実行時にガチャガチャと文字列判定処理する非効率さや、サードパーティのライブラリを組み込むような手間を考えれば、T4による実行時テンプレートは非常に強力な選択肢になると思われる。


実行時のT4エンジンの直接利用

これまでのデザイン時テンプレートも実行時テンプレートも、テンプレートそのものは静的なものである。

これに対して、T4のテンプレートエンジンをアプリケーションから直接使うメリットは、

アプリケーションの実行時にテンプレートを動的に変更できる、という点にある。


ただし、この方法での問題は、前述のとおり、T4のテンプレートエンジンにアプリケーションが依存することになるが、このVisual Studio付属の正規のT4のテンプレートエンジンは一般的な再配布ができないコンポーネントである、ということである。

それではVisual Studioをカスタマイズする用途ぐらいにしか使えない。(本来は、それがT4の目的であったわけであるが。)


しかしながら、前述のとおり、MIT/X11でライセンスされている、MonoDevelopの成果物として、T4エンジンとソースコードレベルでほぼ互換性のあるMono.TextTemplatingを用いることで、

ソースコードレベルで、ほぼ互換性のあるT4エンジンを使うことができる。

これであれば一般的なアプリケーションに同梱してもライセンス違反にはならないだろう。


以降は、Mono.TextTemplatingによるT4エンジンの利用について述べるものとする。


Mono.TextTemplatingの入手とビルド

MonoDevelopGitHubでホストされているので、Cloneするなりしてもらってくる。

https://github.com/mono/monodevelop


"main/src/addins/TextTemplating/Mono.TextTemplating"の下に、T4テンプレートエンジン一式の互換ソースがあり、Mono.TextTemplating.csprojもあるので、Visual Studio Express 2013 for Desktopからでもビルドすることができる。


ただし、ビルドするとだいぶ下の階層のbuild\AddIns\MonoDevelop.TextTemplatingというフォルダにdllが作成されるので、プロジェクト設定を修正して、プロジェクトフォルダの下にdllを生成するようにしておいたほうが良いかもしれない。


ビルドすると、"Mono.TextTemplating.dll"が得られる。


これがT4エンジンの互換アセンブリとなり、アセンブリの名前は"Mono.TextTemplating.dll"だが、中身の名前空間では"Microsoft.VisualStudio.TextTemplating"といったものを定義していたりする。


これをT4テンプレートエンジンを使用するプロジェクトで参照設定すればよい。


ホストの設定と実装

T4エンジンは、ITextTemplatingEngineを実装するクラスであり、

"ProcessTemplate"メソッドを呼び出すことで、

引数に指定したテンプレートをコンパイルして実行し、テンプレートの適用結果を戻り値として返してくれる。


このときテンプレートからアクセスする様々な情報を提供するためのホストオブジェクトを渡す必要がある。

これは、ITextTemplatingEngineHostを実装する必要があり、テンプレート内にパラメータを引き渡す場合には、加えて、ITextTemplatingSessionHostも実装する必要がある。


したがって、まず、ITextTemplatingEngineHostを作成することとする。

大まかな実装例としては、MSDNの以下のページが参考になった。

http://msdn.microsoft.com/ja-jp/library/bb126579.aspx

    /// <summary>
    /// TextTemplatingEngineHostの実装.
    /// 大まかな実装はMSDNの以下のページを参考とした.
    /// http://msdn.microsoft.com/ja-jp/library/bb126579.aspx
    /// </summary>
    [Serializable]
    class TextTemplatingEngineHostImpl
        : ITextTemplatingEngineHost
        , ITextTemplatingSessionHost
    {
        /// <summary>
        /// 現在処理中のテンプレートファイル
        /// </summary>
        public string TemplateFile { set; get; }

        /// <summary>
        /// T4エンジンがコードを生成する先のAppDomain
        /// </summary>
        public AppDomain AppDomain { set; get; }

        /// <summary>
        /// テンプレートに引き渡すデータを保持するセッション
        /// </summary>
        private ITextTemplatingSession session = null;

        public ITextTemplatingSession CreateSession()
        {
            return session;
        }

        public ITextTemplatingSession Session
        {
            get
            {
                return session;
            }
            set
            {
                session = value;
            }
        }

        /// <summary>
        /// オプションを処理する.
        /// </summary>
        /// <param name="optionName"></param>
        /// <returns></returns>
        public object GetHostOption(string optionName)
        {
            object returnObject;
            switch (optionName)
            {
                case "CacheAssemblies":
                    returnObject = true;
                    break;
                default:
                    returnObject = null;
                    break;
            }
            return returnObject;
        }

        public AppDomain ProvideTemplatingAppDomain(string content)
        {
            return AppDomain;
        }

        public string ResolveAssemblyReference(string assemblyReference)
        {
            // フルパスで実在すれば、そのまま返す.
            if (File.Exists(assemblyReference))
            {
                return assemblyReference;
            }

            // "System.Core"のようなアセンブリ参照が来たとき、
            // 標準アセンブリの位置からの相対パスでdllが実在するか判断する.
            foreach (string sysloc in StandardAssemblyReferences)
            {
                string dir = Path.GetDirectoryName(sysloc);
                string candidate = Path.Combine(dir, assemblyReference + ".dll");
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }

            // このクラスがあるアセンブリの場所からの相対位置で判断する.
            {
                string dir = Path.GetDirectoryName(this.GetType().Assembly.Location);
                string candidate = Path.Combine(dir, assemblyReference + ".dll");
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }

            // 不明
            return "";
        }

        public Type ResolveDirectiveProcessor(string processorName)
        {
            throw new Exception("Directive Processor not found");
        }

        public string ResolvePath(string fileName)
        {
            if (fileName == null)
            {
                throw new ArgumentNullException("the file name cannot be null");
            }

            if (!File.Exists(fileName))
            {
                // 現在処理中のテンプレートファイルからの相対位置で実在チェックする.
                string dir = Path.GetDirectoryName(this.TemplateFile);
                string candidate = Path.Combine(dir, fileName);
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }

            // 不明
            return fileName;
        }
        
        public bool LoadIncludeText(string requestFileName, out string content, out string location)
        {
            location = ResolvePath(requestFileName);
            if (File.Exists(location))
            {
                content = File.ReadAllText(location);
                return true;
            }

            content = "";
            return false;
        }

        public void LogErrors(CompilerErrorCollection errors)
        {
            foreach (CompilerError error in errors)
            {
                Console.Error.WriteLine(error.Line + ":" + error.Column +
                    " #" + error.ErrorNumber + " " + error.ErrorText);
            }
        }

        public string ResolveParameterValue(string directiveId, string processorName, string parameterName)
        {
            // テンプレートで、hostSpecific="true" の場合に、<#@ parameter ...#>を使用した場合で、
            // その変数名がSessionにない場合に、このメソッドが呼び出される.
            // (hostSpecific="false"であるか、セッションに変数がある場合は呼び出されない.)
            throw new NotImplementedException();
        }

        private string _extension;

        public string Extension
        {
            get
            {
                return _extension;
            }

            set
            {
                _extension = value;
            }
        }

        public void SetFileExtension(string extension)
        {
            this.Extension = extension;
        }

        private Encoding _encoding = Encoding.UTF8;

        public Encoding Encoding
        {
            get
            {
                return _encoding;
            }

            set
            {
                _encoding = value;
            }
        }

        public void SetOutputEncoding(System.Text.Encoding encoding, bool fromOutputDirective)
        {
            this.Encoding = encoding;
        }

        /// <summary>
        /// 標準で参照するアセンブリの一覧
        /// </summary>
        public IList<string> StandardAssemblyReferences
        {
            get
            {
                var ret = new string[]
                {
                    typeof(System.Uri).Assembly.Location, // System名前空間用
                    typeof(System.Linq.Enumerable).Assembly.Location, // Linq名前空間用
                    typeof(ITextTemplatingEngineHost).Assembly.Location, // T4エンジンのホストインターフェイス用
                };
                return ret;
            }
        }

        /// <summary>
        /// 標準でインポートする名前空間の一覧
        /// </summary>
        public IList<string> StandardImports
        {
            get
            {
                return new string[]
                {
                    "System",
                    "System.Collections.Generic",
                    "System.Linq",
                    "System.Text"
                };
            }
        }
    }
}

Hostオブジェクトは、実行時テンプレート、デザイン時テンプレート共用であるため、他方には意味のないメソッドもある。


重要なのは以下のあたり。

  • ProvideTemplatingAppDomain
    • テンプレートをコンパイルする先のAppDomain.
      • これによりAppDomain.Unloadすれば使用済みのテンプレートクラスを破棄できる.
    • nullを返すと、現在のAppDomain上に作成される.
  • ResolveAssemblyReference
    • アセンブリディレクティブまたはStandardAssemblyReferencesで指定してアセンブリのパスを解決する
  • StandardAssemblyReferences
    • アセンブリディレクティブを使わずともに標準で参照すべきアセンブリの一覧
  • StandardImports
    • 明示的にインポートせずとも標準でインポートされる名前空間の一覧
  • LogErrors
    • テンプレートに誤りがある場合は、これに通知される.

また、T4エンジンが現在のAppDomainとは異なる場所にテンプレートクラスを生成する場合には、ホストオブジェクトがAppDomain間をまたぐことになる。

したがって、このHostクラスと内包するフィールドのすべてはシリアライズ可能でなければならない。


※ MarshalbyRefObjectにすると、RemotingServices.Disconnect()で切断したとしても、なぜかメモリリークしてしまう。このあたり、よくわかっていない。



なお、ProcessTemplateは、テンプレートをコンパイルしテキストを生成後するまでの一連の流れを一発で行うが、生成されたコードは使い捨てであり、一度生成されたコードはAppDomain内にゴミとして蓄積されることになる。

(内部的に生成されたアセンブリはAppDomainにロードされると、AppDomain自体を破棄する以外にアンロードすることはできないため。)

そのため、定期的にAppDomain.Unloadでアンロードする必要がある。


セッションクラスの実装

セッションは単にアプリケーションとテンプレート間のデータの受け渡しのためのコレクションクラスである。

AppDomainをまたぐため、こちらもシリアライズをサポートする必要がある。


※ Dictionaryクラスは、ISerializableによる明示的なシリアライズを実装しているので、派生クラスではGetObjectDataを忘れずにオーバーライドし、

且つ、逆シリアル用コンストラクタの実装も忘れてはならない。


    /// <summary>
    /// セッションの実装.
    /// 単純にDictionaryと、IDを持っているだけのコレクションクラスである.
    /// </summary>
    [Serializable]
    public sealed class TextTemplatingSessionImpl
        : Dictionary<string, Object>
        , ITextTemplatingSession
        , ISerializable
    {
        public TextTemplatingSessionImpl()
        {
            this.Id = Guid.NewGuid();
        }

        private TextTemplatingSessionImpl(
           SerializationInfo info, 
           StreamingContext context)
            : base(info, context)
        {
            this.Id = (Guid)info.GetValue("Id", typeof(Guid));
        }

        [SecurityCritical]
        void ISerializable.GetObjectData(SerializationInfo info,
           StreamingContext context)
        {
            base.GetObjectData(info, context);
            info.AddValue("Id", this.Id);
        }
        
        public Guid Id
        {
            get;
            private set;
        }

        public override int GetHashCode()
        {
            return Id.GetHashCode();
        }

        public override bool Equals(object obj)
        {
            var o = obj as TextTemplatingSession;
            return o != null && o.Equals(this);
        }

        public bool Equals(Guid other)
        {
            return other.Equals(Id);
        }

        public bool Equals(ITextTemplatingSession other)
        {
            return other != null && other.Id == this.Id;
        }
    }

セッションオブジェクトはアプリケーション側からは、以下のように用いる。

    var host = new TextTemplatingEngineHostImpl();
    host.TemplateFile = templateFile;

    ITextTemplatingSession session = new TextTemplatingSessionImpl();
    host.Session = session;
    session["maxCount"] = 20;

これを受けるテンプレート側は以下のようなスクリプトを記述する。

<#@ template language="C#" debug="true" hostSpecific="true" #>
...
<#
// "Host"変数を有効とするために、hostSpecific="true" とすること。
var sessionHost = (Microsoft.VisualStudio.TextTemplating.ITextTemplatingSessionHost) Host;
var mx = (int)sessionHost.Session["maxCount"];
#>

templateディレクティブでhostSpecificがtrueの場合は、Hostオブジェクトをテンプレート内で利用可能となる。

Hostオブジェクトに実装したセッションオブジェクトを取得し、セッションの値を読み取る。



本来、parameterディレクティブが使える場合にはセッションに格納した値は自動的に利用可能になるはずであるが、MonoDevelopのMono.TextTemplatingの実装では、parameterディレクティブを使用するとエラーになってしまう。

(内部的に非公開メソッドをリフレクション経由で使おうとしており、そのあたりで、うまくかみ合っていない。)


もし、T4エンジンをVisual Studio付属(要Visual Studio SDK)のものを参照設定したとすれば、

以下のように書くことができる。

<#@ template language="C#" debug="true" hostSpecific="true" #>
<#@ parameter name="maxCount" type="System.Int32" #>

これでテンプレート内では「maxCount」という変数がセッションの内容としてアクセスできるようになる。


なお、Visual Studio付属のT4テンプレートエンジンを使うために必要なアセンブリとしては、以下のものを参照指定する必要がある。

  • Microsoft.VisualStudio.TextTemplating.12.0
    • Program Files\Microsoft Visual Studio 12.0\VSSDK\VisualStudioIntegration\Common\Assemblies\v4.0\Microsoft.VisualStudio.TextTemplating.12.0.dll
  • Microsoft.VisualStudio.TextTemplating.Interfaces.10.0
    • Program Files\Microsoft Visual Studio 12.0\VSSDK\VisualStudioIntegration\Common\Assemblies\v4.0\Microsoft.VisualStudio.TextTemplating.Interfaces.10.0.dll
  • Microsoft.VisualStudio.TextTemplating.Interfaces.11.0
    • Program Files\Microsoft Visual Studio 12.0\VSSDK\VisualStudioIntegration\Common\Assemblies\v4.0\Microsoft.VisualStudio.TextTemplating.Interfaces.11.0.dll

VSSDKというフォルダが示すように、これは「Visual Studio 2013 SDK」のインストールが必要である。

(これはExpress版にはインストールできない。)


アプリケーション側の呼び出し部

ホストが準備できたら、あとはT4エンジンを呼び出すだけである。

    // テンプレートファイルの読み込み
    var curdir = Path.GetDirectoryName(typeof(Program).Assembly.Location);
    var templateFile = Path.Combine(curdir, "template\\TemplateFile.txt");
    var templateString = File.ReadAllText(templateFile);

    // ホストの設定
    var host = new TextTemplatingEngineHostImpl();
    host.TemplateFile = templateFile;

    // セッションの設定
    ITextTemplatingSession session = new TextTemplatingSessionImpl();
    host.Session = session;
    session["maxCount"] = 20;

    // T4エンジンの構築
    var engine = new Engine();

    // コードを自動生成するドメインを指定する.
    host.AppDomain = AppDomain.CreateDomain("TextTemplate AppDomain");
    try
    {
        // テンプレートクラスの構築と、テキスト生成までの一括処理
        string output = engine.ProcessTemplate(templateString, host);
        Console.WriteLine("output=" + output);
    }
    finally
    {
        // 使い終わったらアンロードする.
        AppDomain.Unload(host.AppDomain);
    }

なお、テンプレートに誤りがある場合は、ProcessTemplateはnullを返すだけである。

エラーはホストのLogErrorsに通知される。


実行例

以下のテンプレートを引き渡すとする。


TemplateFile.txt

<#@ template language="C#" debug="true" hostSpecific="true" #>
<#@ output extension=".txt" encoding="Shift_JIS" #>
<#@ assembly name = "System.Core" #>
<#@ import namespace="System.Linq" #>
<#
var sessionHost = (Microsoft.VisualStudio.TextTemplating.ITextTemplatingSessionHost) Host;
var mx = (int)sessionHost.Session["maxCount"];
#>
<#@ include file="TemplateFile2.txt" #>
done.

TemplateFile2.txt

<#
for (int idx = 0; idx < mx; idx++)
{
#>hello, world! No.<#= idx #>
<#
}
#>

実行結果は以下のようになる。

hello, world! No.0
hello, world! No.1
hello, world! No.2
hello, world! No.3
hello, world! No.4
done.

ホストまわりを準備するのが少々めんどくさいが、使い方そのものは難しくはない。

ただし、実行時に動的にコンパイルし、しかも、一回限りの使い捨てであるため、あまり効率が良いとは言えないかもしれない。


このような単純なテンプレートでも、こちらのテスト環境では一回あたり80mSecほどかかっていた。



実行時のT4エンジンの直接利用 (PreprocessTemplateの利用)

もし、テンプレートを動的にコンパイルしたあと、それを複数回繰り返し利用するのであれば、

ITextTemplatingEngine.PreprocessTemplate

を利用すると良いだろう。


これは、実行時テンプレートに相当するもので、テンプレートを生成するためのコードを文字列として生成する。


引数としてホストを与えなければならないが、ProcessTemplateの場合と異なり、AppDomainなどは返す必要がない。

これは、実際にコンパイルを行うわけではなく、ソースコードを文字列として組み立てるところまでしか実施しないためである。


テンプレートからソースコードへの変換
    // T4 Engine
    var engine = new Engine();

    // テンプレートをソースコードに変換する.(実行時テンプレート)
    string className = "GeneratedClass";
    string namespaceName = "TemplateEngineExample";
    string lang;
    string[] references;
    string generatedSource = engine.PreprocessTemplate(
        templateContent, // テンプレート
        host, // ホスト
        className, // 生成するテンプレートクラス名
        namespaceName, // 生成するテンプレートクラスの名前空間
        out lang, // 生成するソースコードの種別が返される
        out references // 参照しているアセンブリの一覧が返される
        );

上記のように、テンプレートを与えると、そのソースコードと参照しているアセンブリ一覧やコードの種類(C#等)を取得できる。


ソースコードからアセンブリへの変換と、テンプレートクラスのインスタンス

これをCodeDomProviderを使って動的にコンパイルすれば、テンプレートクラスのインスタンスを得ることができる。

    // コンパイラを取得する.
    var codeDomProv = CodeDomProvider.CreateProvider(lang);

    // 参照するアセンブリの定義
    var compilerParameters = new CompilerParameters(references);

    // アセンブリはインメモリで作成する.
    compilerParameters.GenerateInMemory = true;

    // コンパイルする.
    result = codeDomProv.CompileAssemblyFromSource(
        compilerParameters, sourcecode);

    // エラーがあれば例外を返す.
    if (result.Errors.Count > 0)
    {
        var msg = new StringBuilder();
        foreach (CompilerError error in result.Errors)
        {
            msg.Append(error.FileName).Append(": line ").Append(error.Line)
                .Append("(").Append(error.Column).Append(")[")
                .Append(error.ErrorNumber).Append("]")
                .Append(error.ErrorText).AppendLine();
        }
        throw new ApplicationException(msg.ToString());
    }

    // エラーがなければアセンブリを取得し、
    // テンプレートクラスのインスタンスを作成する.
    Assembly assembly = result.CompiledAssembly;

    Type type = assembly.GetType(fqClassName); // 名前空間.クラス名を指定してクラスを取得
    dynamic templateInstance = Activator.CreateInstance(type);

動的にアセンブリを読み込むため、上記コードのコンパイル時点では生成されたクラスの情報は不明である。

そのような場合には、Activator.CreateInstanceで生成したインスタンスはdynamic型で受け取ると便利である。


生成したテンプレートクラスは以下のように利用する。

    templateInstance.Host = host;
    string output = templateInstance.TransformText();

dynamic型で受け取っているので、上記のHostフィールドや、TransformTextメソッドは実行時にリフレクションによって結び付けられる。


基本的には上記コードで、

  1. テンプレートをソースコードに変換する。
  2. ソースコードをアセンブリにする。
  3. アセンブリからテンプレートクラスを構築する。
  4. テンプレートクラスを利用する。

という流れはできる。


ただし、AppDomainを分離していないため、このまま実行すると、テンプレートを構築するたびにクラスが生成されつづけることになる。


AppDomainを分離可能にしたバージョン
    /// <summary>
    /// 実行時テンプレートのインターフェイス
    /// </summary>
    public interface IRuntimeTextTemplate : IDisposable
    {
        /// <summary>
        /// ホスト
        /// </summary>
        ITextTemplatingEngineHost Host { set; get; }

        /// <summary>
        /// テンプレート変換を実施する.
        /// </summary>
        /// <returns></returns>
        string TransformText();
    }

    /// <summary>
    /// テンプレートクラスをAppDomain間で利用できるようするProxy
    /// </summary>
    class RuntimeTextTemplateProxyImpl
        : MarshalByRefObject
        , IRuntimeTextTemplate
    {
        /// <summary>
        /// テンプレートクラスのインスタンス
        /// </summary>
        private dynamic templateInstance;

        /// <summary>
        /// 破棄済みフラグ
        /// </summary>
        private bool _disposed;

        /// <summary>
        /// 初期化。アセンブリをロードする.
        /// </summary>
        /// <param name="assemblyBytes">ロードするアセンブリの内容</param>
        /// <param name="fqClassName">名前空間・クラス名</param>
        public void LoadAssembly(byte[] assemblyBytes, string fqClassName)
        {
            var assembly = Assembly.Load(assemblyBytes);
            templateInstance = (dynamic) assembly.CreateInstance(fqClassName);
        }

        ~RuntimeTextTemplateProxyImpl()
        {
            Dispose(false);
        }

        public void Dispose()
        {
            GC.SuppressFinalize(this);
            Dispose(true);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!_disposed)
            {
                templateInstance = null;

                RemotingServices.Disconnect(this);
                _disposed = true;
            }
        }

        public ITextTemplatingEngineHost Host
        {
            set
            {
                templateInstance.Host = value;
            }

            get
            {
                return templateInstance.Host;
            }
        }

        public string TransformText()
        {
            System.Diagnostics.Debug.WriteLine("current appdomain=" + AppDomain.CurrentDomain.FriendlyName);
            return templateInstance.TransformText();
        }

        public sealed override object InitializeLifetimeService()
        {
            // AppDomainを越えてアクセスするため、マーシャリングされているが
            // 使用期間は不明であるため無期限とする.
            // そのため、使い終わったらDisposeメソッドを呼び出し、Disconnectする必要がある.
            return null;
        }
    }

    /// <summary>
    /// テンプレートクラスを構築するファクトリ
    /// </summary>
    public class RuntimeTextTemplateFactory
    {
        /// <summary>
        /// 生成したテンプレートクラスをロードするAppDomain
        /// </summary>
        public AppDomain TemplateAppDomain { get; set; }

        /// <summary>
        /// T4テンプレートエンジン
        /// </summary>
        private Engine engine;

        public RuntimeTextTemplateFactory()
        {
            this.engine = new Engine();
            this.TemplateAppDomain = AppDomain.CurrentDomain;
        }

        /// <summary>
        /// ファイルを指定してテンプレートを構築する.
        /// </summary>
        /// <param name="templateFile">テンプレートファイル</param>
        /// <returns>テンプレートクラスのインスタンス</returns>
        public IRuntimeTextTemplate Generate(string templateFile)
        {
            string templateContent = File.ReadAllText(templateFile);
            return Generate(templateContent, templateFile);
        }

        /// <summary>
        /// テンプレートとファイルの位置を指定してテンプレートを構築する.
        /// </summary>
        /// <param name="templateContent">テンプレートの内容</param>
        /// <param name="templateFile">テンプレートファイルの位置</param>
        /// <returns>テンプレートクラスのインスタンス</returns>
        public IRuntimeTextTemplate Generate(string templateContent, string templateFile)
        {
            TextTemplatingEngineHostImpl host = new TextTemplatingEngineHostImpl();
            host.TemplateFile = templateFile;

            // 生成するクラス名をランダムに作成する.
            // (アセンブリが毎回異なるので必須ではないが、一応。)
            Guid id = Guid.NewGuid();
            String className = "Generated" +
                BitConverter.ToString(id.ToByteArray()).Replace("-", "");

            // テンプレートをソースコードに変換する.(実行時テンプレート)
            string lang;
            string[] references;
            string generatedSource = engine.PreprocessTemplate(
                templateContent,
                host,
                className,
                "TemplateEngineExample",
                out lang,
                out references
                );
            string fqClassName = "TemplateEngineExample." + className;

            // アセンブリの位置が確定していないものは先に確定しておく
            var resolvedReferences = references.Select(host.ResolveAssemblyReference)
                .Where(x => !string.IsNullOrEmpty(x)).ToArray();


            // コンパイラを取得する.
            var codeDomProv = CodeDomProvider.CreateProvider(lang);

            // 参照するアセンブリの定義
            // アセンブリはテンポラリに作成する.
            var compilerParameters = new CompilerParameters(references);

            // コンパイルする.
            var result = codeDomProv.CompileAssemblyFromSource(
                compilerParameters, generatedSource);

            // エラーがあれば例外を返す.
            if (result.Errors.Count > 0)
            {
                var msg = new StringBuilder();
                foreach (CompilerError error in result.Errors)
                {
                    msg.Append(error.FileName).Append(": line ").Append(error.Line)
                        .Append("(").Append(error.Column).Append(")[")
                        .Append(error.ErrorNumber).Append("]")
                        .Append(error.ErrorText).AppendLine();
                }
                throw new ApplicationException(msg.ToString());
            }

            // エラーがなければ生成されたアセンブリを取得する.
            byte[] assemblyBytes = File.ReadAllBytes(result.PathToAssembly);

            try
            {
                // 生成されたアセンブリファイルは不要になるので削除する.
                File.Delete(result.PathToAssembly);
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine("Can't delete file: " + ex);
                // 削除失敗しても無視して継続する.
            }

            // ターゲットのAppDomain内でアセンブリをロードするためのプロキシを作成する.
            var proxy = (RuntimeTextTemplateProxyImpl)TemplateAppDomain.CreateInstanceAndUnwrap(
                typeof(RuntimeTextTemplateProxyImpl).Assembly.FullName,
                typeof(RuntimeTextTemplateProxyImpl).FullName);

            // アセンブリをロードさせる.
            proxy.LoadAssembly(assemblyBytes, fqClassName);
            
            return proxy;
        }
    }

AppDomainが絡んでくるようなコードは、いままで書く機会がなかったので、さまざまなサイトやMSDNを読んでみて、何度も試行錯誤して、ようやく動いたコードである。

AppDomain間のマーシャリングが、これで問題ないのか、無駄なことや間違いをしているのではないかと気がかりもあるが、とりあえず、実験したかぎりでは、うまく動いているっぽい。


テンプレートをソースコードに変換し、それをコンパイルした結果のアセンブリを、独自に作成したAppDomain上に読み込ませて、それを利用してテンプレート変換したあと、AppDomainをアンロードする。ということを繰り返してみてもメモリ消費量は一定であるため、おそらくメモリリークはしていないと思われる。


利用例
    var host = new TextTemplatingEngineHostImpl();
    host.Session = new TextTemplatingSessionImpl();

    host.TemplateFile = "TemplateSample1.tt";
    host.Session["maxCount"] = 2;

    // 独自のAppDomainを構築する.
    AppDomain appDomain = AppDomain.CreateDomain("RuntimeTextTemplate AppDomain");
    try
    {
        var fact = new RuntimeTextTemplateFactory();
        fact.TemplateAppDomain = appDomain;

        // テンプレートファイル
        var curdir = Path.GetDirectoryName(typeof(Program).Assembly.Location);
        var templateFile = Path.Combine(curdir, "template\\TemplateFile.txt");

        // テンプレートクラスのAppDomain越えプロキシを作成する
        using (var textTemplate = fact.Generate(templateFile))
        {
            //// 実行する.
            textTemplate.Host = host;
            string output = textTemplate.TransformText();
            Console.WriteLine("output=" + output);
        }
    }
    finally
    {
        // AppDomainをアンロードして動的に生成したアセンブリをメモリから解放する.
        AppDomain.Unload(appDomain);
    }

テンプレートクラスを構築後、

    string output = textTemplate.TransformText();

は何度でも繰り返し実行できる。

何度もテンプレートを利用する場合には、前述のProcessTemplateを使うよりも高速に処理できるはずである。



試したところ、テンプレートの実行そのものは、1回あたり0.1mSec程度だった。

しかし、コンパイルするところまでが非常に遅く、テンプレートクラスが取得できるまで400mSecほどかかっていた。

この数字だと、5回以上テンプレートを使用する用途でないかぎり、ProcessTemplateを単純に使ったほうがよさそうという結果である。

実装が非効率なのか、あるいは、なにか無駄や間違いがあるのかもしれない。

ここは、もう少し詰めてみたほうがよさそうである。

※ 新規のAppDomainでCodeDomProviderでコンパイルすると初回準備に時間がかかっているようである。繰り返し同じAppDomainを使うとコンパイル時間は平均80mSecと、ProcessTemplateと変わらなくなる。なので、改善策としてはプライマリのAppDomain内でコンパイルし、アセンブリはメモリではなく一時ファイルを経由して分離したAppDomain内でロードする、などの方法が考えられる。(4/22追記、コードも訂正済み)


とりあえず、テンプレートからのソースコード生成と、ソースコードからアセンブリの生成、という一連の流れの基礎については、今回の実験で理解できたと思う。


結論

T4テンプレートエンジンの使い方を見てきたが、この実験コードにより、

  • デザイン時テンプレートは、ソースコードの自動生成に使うと大変便利そうである。
  • 実行時テンプレートはテンプレートを使ってテキスト変換処理をするアプリで使うと便利そうである。
  • T4エンジンそのものを直接利用するのはDotNETのフルスペックでテンプレートを書ける強力なツールを作れそうである

、ということが分かった。


また、後半、CodeDomProviderとかAppDomainとか、いままで使う機会のなかった部分について横道にそれてしまった感じがあるが、これらについても、とても良い勉強になった。


こうした動的コンパイルのめんどくささを経験してから、T4の一般的な使い方であるDSL的な用途を考え直してみると、テンプレートからソースコードを生成して静的にコンパイルされる、ということの効率の良さと、取扱いの容易さがよく分かる。


あらためて、T4がよくできたツールだということを感じられた。


以上、メモ終了。

*1:DSLツールキットを入れたMSBUILDの場合は、MSBUILDの環境を示す。

Connection: close