December 2016

Volume 31 Number 13

Roslyn - Roslyn と T4 テンプレートによる JavaScript の生成

Nick Harrison | December 2016

先日、娘がスマートフォンとフィーチャー フォンとが会話するジョークを教えてくれました。こんなジョークです。 さて、スマートフォンはフィーチャー フォンに何と言ったのでしょう。 「未来から来た私のこと、理解できるかしら」 新しい最先端技術を学んでいると、時折こんな気持ちになることがあります。Roslyn はこれからの技術なので、最初は理解しづらいかもしれません。

今回はこの Roslyn を取り上げます。ただし、Roslyn にふさわしい大きな注目を集めるテーマではないかもしれません。今回は、T4 により JavaScript を生成する場合のメタデータのソースとして Roslyn を使用します。そのため、Workspace API、Syntax API の一部、Symbol API、および T4 エンジンのランタイム テンプレートを使用します。実際に生成した JavaScript は、メタデータの収集に使用されたプロセスを理解するうえでは、あまり重要ではありません。

Roslyn はコード生成に適したオプションもいくつか提供しているため、この 2 つのテクノロジを調和できず、正しく連携さることはできないだろうと考えるかもしれません。テクノロジは、そのサンドボックスがオーバーラップすると、うまく調和させることができないことがよくありますが、今回の 2 つのテクノロジはむしろうまく連携します。

T4 の概要

T4 をあまり使ったことのない方は、2015 年刊行の Syncfusion Succinctly シリーズの電子書籍『T4 Succinctly』に必要な背景知識がすべて掲載されています (bit.ly/2cOtWuN、英語)。

差し当たり認識しておくべき主なことは、T4 とはマイクロソフトが提供する、テンプレートベースのテキスト変換ツールキットであることです。テンプレートにメタデータをフィードすると、テキストが目的のコードに変換されます。実際には、変換先はコードに限定されません。任意の種類のテキストを生成できますが、ソース コードが最も一般的な出力です。HTML、SQL、テキスト文書、Visual Basic .NET、C# などの任意のテキストベースの出力を生成できます。

図 1 をご覧ください。示しているのはシンプルなコンソール アプリケーション プログラムです。今回は Visual Studio で、AngularResourceService.tt という新しいランタイム テキスト テンプレートを追加しました。このテンプレート コードによって、実行時にそのテンプレートを実装する C# コードが自動生成されます。そのようすが、コンソール ウィンドウに表示されています。

デザイン時コード生成での T4 の使用
図 1 デザイン時コード生成での T4 の使用

今回は、Roslyn を使用して Web API プロジェクトからメタデータを収集し、これを T4 にフィードして、JavaScript クラスを生成する方法を示します。その後、Roslyn を使用してこの JavaScript をソリューションに追加して戻します。

処理フローの考え方を 図 2 に示します。

T4 の処理フロー
図 2 T4 の処理フロー

Roslyn から T4 へのデータのフィード

コードの生成は、メタデータを利用する処理です。そのため、生成するコードを表すメタデータが必要です。リフレクション、コード モデル、およびデータ ディクショナリが一般的なソースで、いずれもすぐに利用できます。Roslyn では、リフレクションやコード モデルから受け取ることになるすべてのメタデータを提供できますが、他のアプローチで起きるような問題は生じません。

ここでは、Roslyn を使用して ApiController から派生したクラスを検索します。T4 テンプレートを使用して、コントローラーごとに JavaScript クラスを作成し、コントローラーに関連付けられたビューモデルのアクションごとにメソッドを、プロパティごとにプロパティを公開します。結果は、図 3 のコードのようになります。

図 3 実行結果のコード

var app = angular.module("challenge", [ "ngResource"]);
  app.factory(ActivitiesResource , function ($resource) {
    return $resource(
      'http://localhost:53595//Activities',{Activities : '@Activities'},{
    Id : "",
    ActivityCode : "",
    ProjectId : "",
    StartDate : "",
    EndDate : "",
  , get: {
      method: "GET"
    }
  , put: {
      method: "PUT"
    }
  , post: {
      method: "POST"
    }
  , delete: {
      method: "DELETE"
    }
  });
});

メタデータの収集

メタデータの収集から着手します。そのため、Visual Studio 2015 で新しいコンソール アプリケーション プロジェクトを作成します。このプロジェクトには、Roslyn を使ってメタデーターの収集に専念するクラスと、T4 テンプレートを用意します。この T4 テンプレートが、収集されたメタデータに基づいて JavaScript コードを生成するランタイム テンプレートになります。

プロジェクトを作成したら、パッケージ マネージャー コンソールから以下のコマンドを発行します。

Install-Package Microsoft.CodeAnalysis.CSharp.Workspaces

これにより、CSharp コンパイラーと関連サービスに対応する最新の Roslyn コードが使用されるようになります。

RoslynDataProvider という新しいクラスにさまざまなメソッドのコードを配置します。今回の説明の随所でこのクラスを参照します。Roslyn を使ってメタデータを収集する場合は常に、このクラスが手ごろな参考資料になります。

MSBuildWorksspace を使用して、コンパイルに必要なすべてのコンテキストを提供するワークスペースを取得します。このソリューションを用意したら、WebApi プロジェクトを検索するプロジェクトを簡単に確認できるようになります。

private Project GetWebApiProject()
{
  var work = MSBuildWorkspace.Create();
  var solution = work.OpenSolutionAsync(PathToSolution).Result;
  var project = solution.Projects.FirstOrDefault(p =>
    p.Name.ToUpper().EndsWith("WEBAPI"));
  if (project == null)
    throw new ApplicationException(
      "WebApi project not found in solution " + PathToSolution);
  return project;
}

別の名前規則に従っている場合は、GetWebApiProject にその名前規則を簡単に組み込んで、目的のプロジェクトを検索することができます。

これで操作するプロジェクトが分かったので、そのプロジェクトのコンパイルと、目的のコントローラーの識別に使用する型への参照を取得する必要があります。SemanticModel を使用して、クラスが System.Web.Http.ApiController から派生されているかどうかを判断するため、コンパイルが必要になります。プロジェクトからは、プロジェクトに含まれているドキュメントを取得できます。各ドキュメントは別個のファイルです。各ファイルには、複数のクラス宣言を含めることができます。どのファイルにも 1 つのクラスのみを含め、ファイル名とクラス名を一致させるのがベスト プラクティスですが、必ずしも常にこの標準に従うことはありません。

コントローラーの検索

図 4 は、各ドキュメント内のすべてのクラス宣言を検索して、クラスが ApiController から派生されているかどうかを判断する方法を示しています。

図 4 プロジェクト内のコントローラーの検索

public IEnumerable<ClassDeclarationSyntax> FindControllers(Project project)
{
  compilation = project.GetCompilationAsync().Result;
  var targetType = compilation.GetTypeByMetadataName(
    "System.Web.Http.ApiController");
  foreach (var document in project.Documents)
  {
    var tree = document.GetSyntaxTreeAsync().Result;
    var semanticModel = compilation.GetSemanticModel(tree);
    foreach (var type in tree.GetRoot().DescendantNodes().
      OfType<ClassDeclarationSyntax>()
      .Where(type => GetBaseClasses(semanticModel, type).Contains(targetType)))
    {
      yield return type;
    }
  }
}

コンパイルでは、プロジェクトのコンパイルに必要なすべての参照にアクセスできるため、ターゲット型の解決ついては問題ありません。コンパイル オブジェクトを取得する時点でプロジェクトのコンパイルを開始しています。ただし、必要なメタデータを取得するための詳細情報を入手すると中断します。

図 5 は、現在のクラスがターゲット クラスから派生されているかどうかを判断するための主な処理を行う GetBaseClasses メソッドを示しています。ここでは、本来必要な処理よりもやや多くの処理を行っています。クラスが ApiController から派生されているかどうかを判断する場合は、途中で実装しているインターフェイスは本来必要ありません。ただし、このような詳細を含めることで、さまざまな状況に使用できる手軽なユーティリティ メソッドになります。

図 5 基本クラスとインターフェイスの検索

public static IEnumerable<INamedTypeSymbol> GetBaseClasses
  (SemanticModel model, BaseTypeDeclarationSyntax type)
{
  var classSymbol = model.GetDeclaredSymbol(type);
  var returnValue = new List<INamedTypeSymbol>();
  while (classSymbol.BaseType != null)
  {
    returnValue.Add(classSymbol.BaseType);
    if (classSymbol.Interfaces != null)
      returnValue.AddRange(classSymbol.Interfaces);
    classSymbol = classSymbol.BaseType;
  }
  return returnValue;
}

この種の分析は、リフレクションを使用すると複雑になります。リフレクションによる方法は再帰を利用しており、介在するすべての型にアクセスできるように、途中でいくつかのアセンブリを読み込む必要が生じる可能性があるためです。この種の分析には、コード モデルも適していません。ただし、SemanticModel を使用する Roslyn であれば比較的簡単です。SemanticModel はメタデータの宝庫です。構文ツリーをシンボルにバインドする厄介な作業がすべて行われた後、コンパイラがコードについて認識したすべての情報を提示します。基本型を追跡するだけでなく、オーバーロード/オーバーライドの解決や、メソッド、プロパティ、任意のシンボルのすべての参照の検索など、難しい課題の解決に使用できます。

関連付けモデルの検索

この時点で、プロジェクト内のすべてのコントローラーにアクセスできるようになっています。JavaScript クラスでは、コントローラーのアクションから返されるモデル内で見つかるプロパティも公開できるようになります。このしくみを理解するため、以下のコードを見てください。このコードは、WebApi のスキャフォールディングの実行から得られた出力を示します。

public class Activity
  {
    public int Id { get; set; }
    public int ActivityCode { get; set; }
    public int ProjectId { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
  }

この場合、スキャフォールディングはモデルに対して実行します (図 6 参照)。

図 6 生成後の API コントローラー

public class ActivitiesController : ApiController
  {
    private ApplicationDbContext db = new ApplicationDbContext();
    // GET: api/Activities
    public IQueryable<Activity> GetActivities()
    {
      return db.Activities;
    }
    // GET: api/Activities/5
    [ResponseType(typeof(Activity))]
    public IHttpActionResult GetActivity(int id)
    {
      Activity activity = db.Activities.Find(id);
      if (activity == null)
      {
        return NotFound();
      }
      return Ok(activity);
    }
    // POST: api/Activities
    [ResponseType(typeof(Activity))]
    public IHttpActionResult PostActivity(Activity activity)
    {
      if (!ModelState.IsValid)
      {
        return BadRequest(ModelState);
      }
      db.Activities.Add(activity);
      db.SaveChanges();
      return CreatedAtRoute("DefaultApi", new { id = activity.Id }, activity);
    }
    // DELETE: api/Activities/5
    [ResponseType(typeof(Activity))]
    public IHttpActionResult DeleteActivity(int id)
    {
      Activity activity = db.Activities.Find(id);
      if (activity == null)
      {
        return NotFound();
      }
      db.Activities.Remove(activity);
      db.SaveChanges();
      return Ok(activity);
    }

アクションに追加された ResponseType 属性によって、ビューモデルがコントローラーにリンクされます。この属性を使用して、アクションに関連付けられたモデルの名前を取得できます。スキャフォールディングを使用してコントローラーを作成した場合、すべてのアクションが同じモデルに関連付けられますが、手作業で作成したコントローラーや生成後に編集されたコントローラーには、このような一貫性がないこともあります。図 7 は、コントローラーに複数のモデルが関連付けられている場合に、1 つのコントローラーに関連付けられているモデルの完全なリストを取得するためにすべてのアクションに対して比較を行う方法を示しています。

図 7 コントローラーに関連付けられているモデルの検索

public IEnumerable<TypeInfo> FindAssociatedModel
  (SemanticModel semanticModel, TypeDeclarationSyntax controller)
{
  var returnValue = new List<TypeInfo>();
  var attributes = controller.DescendantNodes().OfType<AttributeSyntax>()
    .Where(a => a.Name.ToString() == "ResponseType");
  var parameters = attributes.Select(a =>
    a.ArgumentList.Arguments.FirstOrDefault());
  var types = parameters.Select(p=>p.Expression).OfType<TypeOfExpressionSyntax>();
  foreach (var t in types)
  {
    var symbol = semanticModel.GetTypeInfo(t.Type);
    if (symbol.Type.SpecialType == SpecialType.System_Void) continue;
    returnValue.Add( symbol);
  }
  return returnValue.Distinct();
}

このメソッドには興味深いロジックが含まれていますが、若干わかりにくい部分もあります。ResponseType 属性は以下のようになっていました。

[ResponseType(typeof(Activity))]

typeof 式で参照される型のプロパティにアクセスします。アクセスするのは属性の最初のパラメーターです。この例では Activity になります。変数 attributes は、コントローラー内で見つかった ResponseType 属性のリストです。変数 parameters は、これらの属性に対するパラメーターのリストです。これらの各パラメーターは TypeOfExpressionSyntax になるため、TypeOfExpressionSyntax オブジェクトの type プロパティから関連付けられている型を取得できます。ここでも、その型のシンボルを取り出すために SemanticModel を使用します。これにより、必要なすべての詳細が提供されます。

メソッドの最後にある Distinct は、返される各モデルが一意になることを保証します。多くの場合、コントローラー内の複数のアクションが同じモデルに関連付けられるため、重複が予想されます。ResponseType が void になることをチェックする方法もお勧めです。その場合は、目的のプロパティが見つからなかったことになります。

関連付けモデルのテスト

以下のコードは、コントローラーで見つかったすべてのモデルからプロパティを検索する方法を示しています。

public IEnumerable<ISymbol> GetProperties(IEnumerable<TypeInfo> models)
{
  return models.Select(typeInfo => typeInfo.Type.GetMembers()
    .Where(m => m.Kind == SymbolKind.Property))
    .SelectMany(properties => properties).Distinct();
}

アクションの検索

関連付けモデルのプロパティを示すだけでなく、コントローラー内にあるメソッドへの参照も含めます。コントローラー内のメソッドがアクションです。関心があるのはパブリック メソッドのみです。WebApi のアクションがあるため、このアクションはすべて適切な HTTP 動詞に変換する必要があります。

このマッピングを処理する場合、従う表記の規則が 2 種類あります。スキャフォールディングが従う表記の規則は、動詞名で始まるメソッド名になります。したがって、put メソッドは PutActivity に、post メソッドは PostActivity に、delete メソッドは DeleteActivity に、通常、get メソッドは GetActivity と GetActivities の 2 つになります。この 2 つの get メソッドの戻り値の型をテストすれば、その違いを指示できます。戻り値の型が直接または間接的に IEnumerable インターフェイスを実装している場合、すべてを取得する get メソッドになり、それ以外の場合は、1 つを取得する get メソッドになります。

もう 1 つの方法は、動詞を指定するために属性を明示的に追加してから、メソッドに任意の名前にを付けます。図 8 に、GetActions のコードを示します。このコードでは、パブリック メソッドを特定後、両方のメソッドを使用して特定したメソッドを動詞にマップします。

図 8 コントローラーのアクションの検索

public IEnumerable<string> GetActions(ClassDeclarationSyntax controller)
{
  var semanticModel = compilation.GetSemanticModel(controller.SyntaxTree);
  var actions = controller.Members.OfType<MethodDeclarationSyntax>();
  var returnValue = new List<string>();
  foreach (var action in actions.Where
        (a => a.Modifiers.Any(m => m.Kind() == SyntaxKind.PublicKeyword)))
  {
    var mapName = MapByMethodName(semanticModel, action);
    if (mapName != null)
      returnValue.Add(mapName);
    else
    {
      mapName = MapByAttribute(semanticModel, action);
      if (mapName != null)
        returnValue.Add(mapName);
    }
  }
  return returnValue.Distinct();
}

まず、GetActions メソッドは、メソッド名に基づくマッピングを試行します。これが機能しない場合は、次に属性によるマッピングを試行します。メソッドをマップできない場合、そのメソッドはアクションのリストに含めません。チェックの対象にする別の表記の規則がある場合、その規則を GetActions メソッドに簡単に組み込みがむことができます。図 9 に、MapByMethodName メソッドと MapByAttribute メソッドの実装を示します。

図 9 MapByName と MapByAttribute

private static string MapByAttribute(SemanticModel semanticModel,
  MethodDeclarationSyntax action)
{
  var attributes = action.DescendantNodes().OfType<AttributeSyntax>().ToList();
  if ( attributes.Any(a=>a.Name.ToString() == "HttpGet"))
    return IdentifyIEnumerable(semanticModel, action) ? "query" : "get";
  var targetAttribute = attributes.FirstOrDefault(a =>
    a.Name.ToString().StartsWith("Http"));
  return targetAttribute?.Name.ToString().Replace("Http", "").ToLower();
}
private static string MapByMethodName(SemanticModel semanticModel,
  MethodDeclarationSyntax action)
{
  if (action.Identifier.Text.Contains("Get"))
    return IdentifyIEnumerable(semanticModel, action) ? "query" : "get";
  var regex = new Regex("\b(?'verb'post|put|delete)", RegexOptions.IgnoreCase);
  if (regex.IsMatch(action.Identifier.Text))
    return regex.Matches(action.Identifier.Text)[0]
      .Groups["verb"].Value.ToLower();
  return null;
}

どちらのメソッドも、最初に、Get アクションを明示的に検索して、メソッドがどちらの種類の “get” を表しているかを判断しています。

アクションがいずれかの種類の “get” ではない場合、MapByAttribute はアクションが Http で始まる属性を持つかどうかをチェックします。属性が見つかったら、単純に属性名を取得して、その属性名から Http を削除することで、動詞を決定できます。どの動詞が使用されているかを判断するために、各属性に対して明示的にチェックを行う必要はありません。

MapByMethodName の構造も同様です。まず Get アクションをチェックしてから、正規表現を使用して、他のいずれかの動詞と一致するかどうかを確認します。一致が見つかれば、名前のキャプチャ グループから動詞名を取得します。

どちらのマッピング手法でも、1 つを取得する get アクションとすべてを取得する get アクションを区別する必要があり、以下のコードで示す Identify­Enumerable メソッドを使用しています。

private static bool IdentifyIEnumerable(SemanticModel semanticModel,
  MethodDeclarationSyntax actiol2n)
{
  var symbol = semanticModel.GetSymbolInfo(action.ReturnType);
  var typeSymbol = symbol.Symbol as ITypeSymbol;
  if (typeSymbol == null) return false;
  return typeSymbol.AllInterfaces.Any(i => i.Name == "IEnumerable");
}

ここでも、SemanticModel が非常に重要な役割を果たします。メソッドの戻り値の型をテストすることで、2 種類の get メソッドを区別できます。SemanticModel は、戻り値の型にバインドされるシンボルを返します。このシンボルにより、戻り値の型が IEnumerable インターフェイスを実装しているかどうかを指示できます。メソッドが List<T>、Enumerable<T>、または任意の型のコレクションを返す場合、IEnumerable インターフェイスを実装しています。

T4 テンプレート

これですべてのメタデータを収集したので、ここからはすべての情報を結びつける T4 テンプレートを確認していきます。まず、ランタイム テキスト テンプレートをプロジェクトに追加します。

ランタイム テキスト テンプレートの場合、テンプレート実行の出力は、生成する目的のコードではなく、定義されたテンプレートを実装するクラスです。大抵の場合、テキスト テンプレートで実行できることは、ランタイム テキスト テンプレートでも実行できます。違いは、コードを生成するためにテンプレートを実行する方法です。テキスト テンプレートでは、テンプレートの実行と、テンプレートが実行されるホスティング環境の作成を Visual Studio が行います。ランタイム テキスト テンプレートでは、ホスティング環境の設定とテンプレートの実行は自身で行います。そのため作業は増えますが、テンプレートの実行方法や出力を使用して行う作業に関して、より柔軟な制御が可能になります。Visual Studio では依存関係の削除も行います。

まず、AngularResource.tt を編集して、図 10 のコードをテンプレートに追加します。

図 10 初期テンプレート

<#@ template debug="false" hostspecific="false" language="C#" | #>
var app = angular.module("challenge", [ "ngResource"]);
  app.factory(<#=className #>Resource . function ($resource) {
    return $resource('<#=Url#>/<#=className#>',{<=className#> : '@<#=className#>'},{
    <#=property.Name#> : "",
  query : {
    method: "GET"
    , isArray : true
    }
  ' <#=action#>: {
    method: "<#= action.ToUpper()#>
    }
  });
});

JavaScript をどの程度使用しているかによって、この作業を初めて行うかもしれませんが、心配いりません。

最初の行は template ディレクティブです。これにより、C# でテンプレート コードを記述するよう T4 に指示します。残りの 2 つの属性は、ランタイム テンプレートの場合は無視されます。ただし、ここではわかりやすくするために、ホスティング環境については何も想定していないこと、デバッグのための中間ファイルの保持は想定していないことを明示的に宣言しています。

T4 テンプレートは、ASP ページにやや似ています。<# タグと #> タグでコードを区切り、テンプレートによって、テンプレートとテキストが変換されるように導きます。<#= タグと #> タグは変数の置き換えを区切ります。変数は評価された後に、生成後のコードに挿入されます。

このテンプレートを見ると、メタデータは className、URL、プロパティのリスト、およびアクションのリストを提供すると想定されているのがわかります。これはランタイム テンプレートであるため、処理を簡略化するためにできることが 2 つあります。しかし、まずはこのテンプレートが実行された場合に作成されるコードを見てみましょう。.TT ファイルを保存するか、ソリューション エクスプローラーでこのファイルを右クリックして [カスタム ツールの実行] を選択すると、コードが作成されます。

テンプレートの実行による出力は新しいクラスです。これがテンプレートに相当します。重要なのは、下にスクロールすると、テンプレートによって基本クラスも生成されたことがわかります。基本クラスを新しいファイルに移動して、template ディレクティブで明示的に基本クラスを宣言すると、それ以降は基本クラスが生成されず、必要に応じてこの基本クラスを自由に変更できるため、この点は重要です。

次に、template ディレクティブを以下のように変更します。

<#@ template debug="false" hostspecific="false" language="C#"
  inherits="AngularResourceServiceBase" #>

次に、AngularResourceServiveBase を独自のファイルに移動します。もう一度テンプレートを実行すると、生成されたクラスは同じ基本クラスから派生されていますが、基本クラスは生成されなくなったことがわかります。これで、基本クラスに対して必要な変更を自由に行うことができるようになります。

続いて、新しいメソッドをいくつかと 2 つのプロパティを基本クラスに追加して、もっと簡単にメタデータをテンプレートに提供できるようにします。

新しいメソッドとプロパティに対応するために、新しい using ステートメントもいくつか必要です。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

今回は、URL のプロパティと、冒頭で作成した RoslynDataProvider のプロパティを追加します。

public string Url { get; set; }
public RoslynDataProvider MetadataProvider { get; set; }

これらを適切に配置するには、MetadataProvider とやり取りする 2 つのメソッドも必要になります (図 11 参照)。

図 11 AngularResourceServiceBase に追加したヘルパー メソッド

public IList<ClassDeclarationSyntax> GetControllers()
{
  var project = MetadataProvider.GetWebApiProject();
  return MetadataProvider.FindControllers(project).ToList();
}
protected IEnumerable<string> GetActions(ClassDeclarationSyntax controller)
{
  return MetadataProvider.GetActions(controller);
}
protected IEnumerable<TypeInfo> GetModels(ClassDeclarationSyntax controller)
{
  return MetadataProvider.GetModels(controller);
}
protected IEnumerable<ISymbol> GetProperties(IEnumerable<TypeInfo> models)
{
  return MetadataProvider.GetProperties(models);
}

これで、基本クラスにメソッドを追加したので、テンプレートを拡張して使用する準備ができました。図 12 でテンプレートの変更方法を確認します。

図 12 最終版のテンプレート

<#@ template debug="false" hostspecific="false" language="C#" inherits="AngularResourceServiceBase" #>
var app = angular.module("challenge", [ "ngResource"]);
<#
  var controllers = GetControllers();
  foreach(var controller in controllers)
  {
    var className = controller.Identifier.Text.Replace("Controller", "");
#>    app.facctory(<#=className #>Resource , function ($resource) {
      return $resource('<#=Url#>/<#=className#>',{<#=className#> : '@<#=className#>'},{
<#
    var models= GetModels(controller);
    var properties = GetProperties(models);
    foreach (var property in properties)
    {
#>
      <#=property.Name#> : "",
<#
    }
    var actions = GetActions(controller);
    foreach (var action in actions)
    {
#>
<#
      if (action == "query")
      {
#>      query : {
      method: "GET"

テンプレートの実行

これはランタイム テンプレートであるため、テンプレートを実行する環境を自身で設定する必要があります。図 13 にテンプレートの実行に必要なコードを示します。

図 13 ランタイム テキスト テンプレートの実行

private static void Main()
{
  var work = MSBuildWorkspace.Create();
  var solution = work.OpenSolutionAsync(Path to the Solution File).Result;
  var metadata = new RoslynDataProvider() {Workspace = work};
  var template = new AngularResourceService
  {
    MetadataProvider = metadata,
    Url = @"http://localhost:53595/"
  };
  var results = template.TransformText();
  var project = metadata.GetWebApiProject();
  var folders = new List<string>() { "Scripts" };
  var document = project.AddDocument("factories.js", results, folders)
    .WithSourceCodeKind(SourceCodeKind.Script)
    ;
  if (!work.TryApplyChanges(document.Project.Solution))
    Console.WriteLine("Failed to add the generated code to the project");
  Console.WriteLine(results);
  Console.ReadLine();
}

テンプレートの保存時またはカスタム ツールの実行時に作成されるクラスは、直接インスタンスを作成できます。また、パブリック プロパティの設定やアクセス、基本クラスからのパブリック メソッドの呼び出しが可能です。ここで示しているのは、プロパティ値の設定方法です。TransformText メソッドを呼び出すことでテンプレートが実行され、生成後のコードが文字列として返されます。変数 results に、生成後のコードを保持しています。残りのコードは、生成されたコードを使ってプロジェクトに新しいドキュメントを追加する処理を行います。

ただし、このコードには問題があります。AddDocuments の呼び出しによってドキュメントの作成に成功すると、そのドキュメントを Scripts フォルダーに配置しています。TryApplyChanges を呼び出すと、成功が返されます。しかし、ソリューションを見ると、問題があることが分かります。 Scripts フォルダーに factories ファイルはあります。ですが、factories.js ではなく、factories.cs になっています。AddDocument メソッドは、拡張子を受け取るようには構成されていません。受け取った拡張子には関係なく、追加先のプロジェクトの種類に基づいてドキュメントが追加されます。これは仕様です。

つまり、プログラムを実行して JavaScript クラスを生成すると、ファイルが Scripts フォルダーに用意されます。後は拡張子を .cs から .js に変更するだけです。

まとめ

今回行った作業のほとんどは、Roslyn によるメタデータの取得が中心です。今回と同じプラクティスは、メタデータをどのように使用するかに関係なく、役に立ちます。T4 のコードに関しては、引き続きさまざまな場面で関連してきます。Roslyn がサポートしていない言語のコードを生成する場合、処理に簡単に組み入れることができる T4 が優れた選択肢になります。Roslyn を使用すると C# と Visual Basic .NET のコードしか生成できませんが、T4 を使えば、SQL、JavaScript、HTML、CSS、従来のプレーン テキストなど、任意の種類のテキストを生成できるためです。

JavaScript クラスのようなコードの作成は面倒で間違いを起こしやすいため、今回紹介したようなコード生成がお勧めです。また、コードをパターンに合わせるのも容易です。可能であれば、できるだけ一貫性を保つため、パターンに従うようにします。最も重要なことは、コードの生成方法が時間の経過とともに変化する可能性があることです。特に新しいテクノロジが登場しベスト プラクティスが生まれるときは顕著です。「目的を達成する最善の方法」へと新たに変更する場合に必要なのは T4 テンプレートを更新することだけだとしたら、新たに生まれるベスト プラクティスに従うのはそれほど難しいことではありません。しかし、手作業で生成した単調で面倒なコードを大量に修正しなければならないとしたら、新たなベスト プラクティスが生まれるたびに、複数の実装を用意することになります。


Nick Harrison は、妻 Tracy と娘と共にサウスカロライナ州コロンビアに住むソフトウェア コンサルタントです。2002 年以降、ビジネス ソリューションを構築するために、.NET を使用してフル スタックを開発しています。彼には Twitter (@Neh123us、英語) からアクセスできます。ブログ投稿、記事の公開、講演についても、Twitter で告知しています。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの James McCaffrey に心より感謝いたします。
Dr.James McCaffrey は、ワシントン州レドモンドの Microsoft Research に勤務しています。これまでに、Internet Explorer、Bing などの複数のマイクロソフト製品にも携わってきました。Dr.McCaffrey の連絡先は jammc@microsoft.com (英語のみ) です。