ラベル T4 の投稿を表示しています。 すべての投稿を表示
ラベル T4 の投稿を表示しています。 すべての投稿を表示

2012年4月30日月曜日

CompiledQueryの積極利用

とうとうゴルフ、始めました。まだ練習場でパカパカうつだけだけど。ホッケーとボルダリングとゴルフと筋トレ。多趣味になってきたね!

LINQ to SQL/LINQ to Entityを利用してると、CompiledQueryを使いたいけど、なんか面倒くさいよ、ってことありますよね。ないですか。いまさらか!的な内容ですけど。

CompiledQueryってなに~?という話もあるかもしれないけど、どういうメリットがあるのかというと以下のサイトに書いてるとおり。

LINQ To SQL Very Slow Performance Without Compile (CompileQuery) « Er. alokpandey's Blog

早いんです。なぜ早くなるのかというと、LINQ to SQL/LINQ to Entityの実行時っていろいろLINQ解析して、SQL組み立てるまでの事前準備(IQueryProvider.CreateQuery)と、SQLを実行してマッピングする実行処理(IQueryProvider.Execute)に分けて考えて、この事前処理の部分を使いまわすからですよね。QueryProvider大変だねー。

IQueryProvider インターフェイス (System.Linq)
チュートリアル : IQueryable LINQ プロバイダーの作成

.NET 3.5までだとLINQ to Entityって使えにくかった(シーケンスとの組み合わせとか)し、激烈に遅かったけど、.NET 4からはその辺ずいぶん改善されてて、さらに次のEF5に至っては、パフォーマンスもかなり向上するようで、楽しみでしょうがないっす。

Sneak Preview: Entity Framework 5.0 Performance Improvements - ADO.NET team blog - Site Home - MSDN Blogs

で、CompiledQueryを使うときの面倒くささって、あれですよ、事前にイロイロ用意してFuncデリゲートに登録しておかないといけないところですよね。

LINQ to SQL : Understanding Compiled Query - Wriju's BLOG - Site Home - MSDN Blogs

最初はAd Hocに書いて、パフォーマンス的に問題になるところを、ちょこちょこCompiledQueryにしていく、っていうのが王道なんでしょーか。面倒ですね。面倒です。だからQuery実行を勝手にキャッシュしてくれるようになるEF5のアプローチは大変興味深く、すぐにでも適用してしまいたいと思わずにはいられない。

DAL書き換えるのも面倒だしー。Repository書き換えるのも大変だしー。楽ちんにするなら、Ad Hocなクエリの部分だけ以下のように書くと、CompiledQueryにもしてくれてキャッシュもすると今までのコードもそのまま流用しやすくていいなー。

このアプローチを紹介してくれてるのが以下のエントリ。

Linq to Sql CompiledQuery container - Mitsu's blog - Site Home - MSDN Blogs

素敵ですね!

面倒なこと考えずに使えますね!でも、ちょっと待って。ちょっとだけ残念なのがSequence。CompiledQuery対象のLINQクエリの中で、パラメータで渡したIEnumerable的(Sequence parameter)な変数参照を行ってる場合、CompiledQueryは正しく機能しない。既知です既知!

linq to sql - Compiled queries and "Parameters cannot be sequences" - Stack Overflow

パッと見わかりにくいですね。どういう意味か。なので、先のエントリで提示されてるMyQueriesを使ったサンプルを使って確認。

class TestCQ
{
  public void Test1()
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test1", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where new[] { "Red" }.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test1:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test2()
  {
    var context = new AdventureWorksDataContext();
    var localArray = new[] { "Red" };
    var cq = MyQueries.Get("Test2", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where localArray.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test2:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test3(string[] array)
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test3", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where array.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test3:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test4(string[] array)
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test4", 
      (AdventureWorksDataContext db, string[] option) =>
      from m in db.Product where option.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test4:" + cq(context,array).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }
}

 

var cq = new TestCQ();

cq.Test1();
cq.Test2();
cq.Test3(new[] { "Black", "White" });
cq.Test3(new[] { "Blue" });
cq.Test4(new[] { "Black", "White" });
cq.Test4(new[] { "Blue" });

Test1はクエリの中で配列を作成。Test2はメソッドの中で宣言した配列をキャプチャ。Test3はメソッド引数で渡した配列をキャプチャ。最後のTest4はメソッド引数で渡した配列をCompiledQueryのパラメータとして利用(キャプチャしない)。

cq1

Test3実行が同一値。Test4実行時に「パラメーターをシーケンスにすることはできません。」。

ParameterExpressionだとダメなんだって。

.NET4でビルドするとExpressionをDebugViewで簡単に見れるから見てみよう!

  • Test1
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
           .NewArray System.String[] {
                "Red"
            }
    ,
            $m.Color)
    }

  • Test2
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
           .Constant<FastLinq.Program+TestCQ+<>c__DisplayClass42>(FastLinq.Program+TestCQ+<>c__DisplayClass42).localArray,
            $m.Color)
    }

  • Test3
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
            .Constant<FastLinq.Program+TestCQ+<>c__DisplayClass44>(FastLinq.Program+TestCQ+<>c__DisplayClass44).array,
            $m.Color)
    }

  • Test4
    .Lambda #Lambda1<System.Func`3[FastLinq.Data.LS.AdventureWorksDataContext,System.String[],System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(
        FastLinq.Data.LS.AdventureWorksDataContext $db,
        System.String[] $option) {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
            $option,
            $m.Color)
    }

太字のとこが違うとこ。Test4はParameterExpression。

ちなみに、Test4の使い方をしないなら、以降の話はすっ飛ばしてもらっても大丈夫。MyQueriesを使うだけでパフォーマンスは劇的に向上します。LINQ to SQLに関して言えば.NET3.5で約3倍、.NET4で約2倍。LINQ to Entityだとあんまり効果なし。効果ってどこの効果かというと、CompiledQueryをキャッシュしたときの効果ね。

訳あって、Test4のパターンを利用する必要があるので、しょうがなくExpressionVisitorを使うことにしました。とはいっても、ParameterExpressionをConstantExpressionに置き換えることでTest4もうまく動くはず。実行時のパラメータの値でExpressionキャッシュしたい(じゃないと、SQLに変換したときのParameterの数が違うことになる)のと、CompiledQueryの作成とQueryの実行が離れた場所にあるっていう都合もあって、ちょっとわかりにくいコードになったんだけど、以下のような感じです。T4です。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;

namespace FastLinq
{
	public static class To
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>
		
        public static Expression<Func<<#=generics#>TR>> Expression<<#=generics#>TR>(Expression<Func<<#=generics#>TR>> expression)
        {
            return expression;
        }
	<# } #>
    }

    public abstract class QueryBase<TDB>
    {
        private static Dictionary<string, object> _cachedQuery = new Dictionary<string, object>();
        private static ReaderWriterLockSlim _locked = new ReaderWriterLockSlim();

	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n).ToArray()) + ",";#>
		
        public abstract Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query);
	<# } #>

		public static void Clear()
		{
			_cachedQuery.Clear();
		}

        private int GetOptionHash<T>(T option)
        {
            var values = option as IEnumerable;
            if (values != null)
            {
                return string.Join("\r\n", values.OfType<object>().Select(v => v.ToString()).ToArray()).GetHashCode();
            }
            return option.GetHashCode();
        }

		private object GetCache(string key, Func<object> compiledQueryFunctor)
		{
            _locked.EnterUpgradeableReadLock();
            try
            {
                object cachedQuery;
                if (_cachedQuery.TryGetValue(key, out cachedQuery))
                    return cachedQuery;

                var compiedQuery = compiledQueryFunctor();
                try
                {
                    _locked.EnterWriteLock();
                    if (!_cachedQuery.ContainsKey(key))
                    {
                        _cachedQuery[key] = compiedQuery;
                    }
                }
                finally
                {
                    _locked.ExitWriteLock();
                }
                return compiedQuery;
            }
            finally
            {
                _locked.ExitUpgradeableReadLock();
            }
        }

	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>
		<#var options = i==0 ? "" : "," + string.Join(",", Enumerable.Range(1,i).Select(n=>"option"+n));#>
		<#var formats = i==0 ? "" : ":" + string.Join(":", Enumerable.Range(1,i).Select(n=>"{" + n + "}"));#>
		<#var hashs = i==0 ? "" : "," + string.Join(",", Enumerable.Range(1,i).Select(n=>"GetOptionHash(option" + n + ")"));#>

		public Func<TDB, <#=generics#>TR> Fast<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query) where TR : class
        {
            Func<TDB, <#=generics#>TR> wrapper = (context<#=options#>) =>
            {
			<# if (i==0) { #>

				var key = string.Format("{0}", query.ToString().GetHashCode());
			<# } else { #>
				
				var replaces = new Dictionary<string, Expression>{
					<# for(var j=1; j<=i; j ++) {#>{query.Parameters[<#=j#>].Name, Expression.Constant(option<#=j#>)},
					<# } #>
				};
			
                query = new ParameterToConstantVisitor().Replace(query, replaces) as Expression<Func<TDB, <#=generics#>TR>>;
				var key = string.Format("{0}<#=formats#>", query.ToString().GetHashCode()<#=hashs#>);
			<# } #>

				var compiledQuery = GetCache(key, ()=>Compiled(query));
                return (compiledQuery as Func<TDB, <#=generics#>TR>)(context<#=options#>);
            };

            return wrapper;
        }
	<#}#>
    }
}

さらに以下のクラスを用意。

LINQ to SQL

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Linq.Expressions;

namespace FastLinq.LinqToSql
{
    public class Query<TDB> : QueryBase<TDB>
        where TDB : System.Data.Linq.DataContext
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>

		public override Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query)
        {
            return System.Data.Linq.CompiledQuery.Compile(query);
        }
	<#}#>
	
    }
}

LINQ to Entity

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Linq.Expressions;

namespace FastLinq.LinqToEntity
{


    public class Query<TDB> : QueryBase<TDB>
        where TDB : System.Data.Objects.ObjectContext
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>

		public override Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query)
        {
            return System.Data.Objects.CompiledQuery.Compile(query);
        }
	<#}#>
	
    }
}

以下のようなテストで計測。

 

var options = new {Colors = new List<string> {"Red"}, City = "Bothell", CompanyName = "Bike"};

var query = new LinqToSql.Query<AdventureWorksDataContext>();
var exp1a = To.Expression((AdventureWorksDataContext db, int option) => 
  from m in db.Product where options.Colors.Contains(m.Color) select m);
var exp1b = To.Expression((AdventureWorksDataContext db, List<string> option) => 
  from m in db.Product where option.Contains(m.Color) select m);
var exp2 = To.Expression((AdventureWorksDataContext db, string option) => 
  from m in db.Address where m.City == option select m);
var exp3 = To.Expression((AdventureWorksDataContext db, string option) => 
  from m in db.Customer where m.CompanyName.StartsWith(option) select m);

using (var connection = new System.Data.SqlClient.SqlConnection(
 ConfigurationManager.ConnectionStrings["AdventureWorks"].ConnectionString))
using (var context = new AdventureWorksDataContext(connection))
{
  context.ObjectTrackingEnabled = false;
  context.DeferredLoadingEnabled = false;
  Test("Ad hoc", () =>
  {
    (from m in context.Product 
     where options.Colors.Contains(m.Color) 
     select m).FirstOrDefault();
    (from m in context.Product 
     where new List<string> { "Red" }.Contains(m.Color) 
     select m).FirstOrDefault();
    (from m in context.Address 
     where m.City == options.City 
     select m).FirstOrDefault();
    (from m in context.Customer 
     where m.CompanyName.StartsWith(options.CompanyName) 
     select m).FirstOrDefault();
  });

  Test("Expression", () =>
  {
    exp1a.Compile()(context, 0).FirstOrDefault();
    exp1b.Compile()(context, new List<string> { "Red" }).FirstOrDefault();
    exp2.Compile()(context, options.City).FirstOrDefault();
    exp3.Compile()(context, options.CompanyName).FirstOrDefault();
  });

  Test("Fast 1", () =>
  {
    query.Fast(exp1a)(context, 1).FirstOrDefault();
    query.Fast(exp1b)(context, new List<string> { "Red" }).FirstOrDefault();
    query.Fast(exp2)(context, options.City).FirstOrDefault();
    query.Fast(exp3)(context, options.CompanyName).FirstOrDefault();
  });

  Test("Fast 2", () =>
  {
    query.Fast(
      (AdventureWorksDataContext db, int option) => 
        from m in db.Product where options.Colors.Contains(m.Color) select m
    )(context, 2).FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, List<string> option) => 
        from m in db.Product where option.Contains(m.Color) select m
    )(context, new List<string> { "Red" }).FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, string option) => 
        from m in db.Address where m.City == option select m
    )(context, "Bothell").FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, string option) => 
        from m in db.Customer where m.CompanyName.StartsWith(option) select m
    )(context, "Bike").FirstOrDefault();
  });

結果。

cq3

ちょっとわかりにくいけど。full4って書いたのが、DataContextともに.NET4。4+3.5がテストコードが.NET4でDBが.NET3.5。full3.5がいずれも.NET3.5。青が100回で赤が1000回実行したときの時間。

なので、Ad Hocと比較するとCompiledQueryキャッシュ実装だと.NET4の場合で概ね2倍。.NET3.5で概ね2.5倍。さすがCompiledQueryですね!パラメータのバリエーションがそれほど多くなかったり、参照が圧倒的に多いアプリケーション(WebでCMS的なものだったり、WebMatrix使ったWebPagesの実装だったりだと使いやすいかも)の場合、顕著にレスポンスが早くなります。

LINQ To SQL Very Slow Performance Without Compile (CompileQuery) « Er. alokpandey's Blog

Compiled Queries in Entity Framework : Don't Be Iffy

.NET 3.5の場合はExpressionVisitorを以下からコピペしておきましょう。Expressionとかマジ勘弁。

方法 : 式ツリー ビジタを実装する

是非どーぞー。ソースは以下から。

takepara/FastLinq · GitHub

ちなみにこれに加え、さらにDataキャッシュも加えると、ローカルDBに対してだとさらに20%くらい早くなって、ネットワーク越しだと2~3倍

目指せ、スケーラブルWebサイト!

2009年8月2日日曜日

T4MVC再び

世界中でMVC v2祭りな雰囲気の中、少し前にリリースのあったT4MVC 2.4.0.1について。早くMVC祭りに参加したいところだけど、そこは焦らずに少しずつ...。

Angle Bracket Percent : T4MVC 2.4 updates: settings file, sub view folders, ActionName support and more

前回のT4MVCの投稿でActionNameをサポートするようttファイルの書き換えを行ったけど、新しいバージョンではしっかりとその辺も含めた改良が施されてます。なので、もうActionNameで名前を変えたものを指定したいからと、たけはらエディションを使う必要は全く無くなりました。

どうやってActionName属性を取り出すのかなと確認したところ、メソッドのCodeAttribute2を取り出してActionNameAttributeが付いてるかを見てました。ActionMethodInfoが変更かかってるのも合わせて、やり方はだいたい同じですね。間違ってなくてホッとした。変更前のAction名が選択肢に出てくるあたりの仕様も同じだし。

HomeControllerに以下のIndexPostを追加してみる。

    public virtual ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

    [AcceptVerbs(HttpVerbs.Post)]
    [ActionName("Index")]
    public virtual ActionResult IndexPost(string value)
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

このIndexPostを参照するようにIndex.aspxにFORMを書いてみる。

    <% using (Html.BeginForm(MVC.Home.Actions.IndexPost, MVC.Home.Name))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

結果、出力されるHTMLは↓。

    <form action="/" method="post">
       
       <input type="submit" value="送信" />
       
    </form>

それから、既定ではMVCがクラス名だったり、ViewsフォルダやControllersが固定だったけど、今回からT4MVC.settings.t4というファイルで変更出来るようになってます。なので、MVC.Home.~じゃ無くてMy.Home.~とかに変更も可能。サブディレクトリを再帰(なんちゃらRecursive関数達)で潜っていくようになってるので、より実用的に。

Actionの引数をなくしたoverloadを定義してるみたで、上記BeginFormの書き方を以下のように変えてもOKらしい。

    <% using (Html.BeginForm(MVC.Home.IndexPost()))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

だけど、これで出力されるURLがなんかキモイ。

    <form action="/?RouteValues=System.Web.Routing.RouteValueDictionary" method="post">
       
       <input type="submit" value="送信" />
       
    </form>

変なの。と、思って出力されたコードを確認してみたけど、今回のような使い方の場合、上記のようにBegineFormを使うと、標準のHtmlHelper.BeginFormが呼ばれるから変なURLになる。正しくは↓こう。

    <% using (Html.BeginForm(MVC.Home.IndexPost(), FormMethod.Post))
       { %>
       
       <input type="submit" value="送信" />
       
    <% } %>

ちゃんと、FormMethodを指定しましょう。じゃないと、T4MVCで定義してるBeginFormのoverloadに入ってこないもん。こうすれば、ちゃんとURLが出てくるようになりました。にゃろめ。ちなみにルーティング登録時(MapRoute)でもマジックストリングを使わずに登録できるようになってるT4MVC。

最高です。

2009年7月4日土曜日

男らしさってなんですか?

前回のエントリーに引き続きT4MVCです。

前回判明したのが、ActionName属性を指定したアクションはオリジナルのアクション名でしかMVCクラスに展開されないというもの。で、改善リリースを待つか、自分でttファイルをいじるのか、どっちが男らしいかというところで、とりあえず逃げの一手を打ったんでしたね。

このままじゃべーさんにどやされる。週明けに「この腰抜けが!」なんて言われた日には、枕を涙でぬらす日々。涙じゃない、悲しみのシミだよ、なんて言ったところで「誠意ってなにかね? by 文太」とたたみかけられる。

まぁ、いいや。

早速、面白そうなものを見つけたんだから、T4追いかけてみるかと、VSでソースを開くと真っ白背景に白の文字。あ、コードハイライト効かないんだった。

そんなときには"Clarius Visual T4"。Professionalエディションを$99.99払ってまでは使い込まないだろうから、Communityエディションで。無料だし。Code Generatorエディション出たら買ってもいいかもね。いや、買わないか。で、インストールして早速T4MVC.ttファイルを開いてみると...。

t4mvc1

ぽかーんデス。なんじゃこれ。テーマ設定してるとグチャグチャっすね。

Clarius Forums • View topic - t4Editor and custom fonts and colors

フォーラム見てみたら、白バックのデフォルトテーマに戻せと書いてるけど、日本語版のVSだからか、「ツール>オプション>環境>フォントおよび色>テキストエディタ・表示項目」にそんな選択肢がそもそも出てこなかったり。ガッカリだな。無料だし、それもやむなし。そういえば、CommunityエディションだとIntelliSenceも使えないんだった。インストールする意味がまるでなかった。

で、結局↓こんな素っ気ない画面で。

t4mvc2

そろそろ、中身を確認せねば。T4の使い方は前にどこかで読んだことがあったから、細かい仕様的なのは無視。とにかく、<# #>で囲まれてる部分がテンプレート処理部。で、テンプレート内で参照出来る変数を下の方で宣言してて、その辺にいろいろコードがあるはずなので、そこら辺中心にチェックで。

一応ASP.NET MVCに関する投稿デス。

たぶんアセンブリ内をリフレクションでグルグル処理してるんだろーなと、たかをくくってたらこれが全然違うのね。ビックリした。CodeFunction2って誰ちゃん?CodeElementインターフェースってどこからはえてきたの?みたいな。どうやらVisual Studioオートメーションってことらしい。クラスの定義をpartialにしたり、関数定義をvirtualにしたり、ソースをいじるのにそうしとかないと面倒なことになるからなんだろうね。そんな物の存在を全然知らなくてたまげたけど、使う部分はちょびっとだけなんだから気にせず読み進める。

Controllers変数の中にControllerInfoのコレクションが入ってて、ControllerInfo.ActionMethodsの中にActionMethodInfoのコレクションが入ってるんだって。それらコレクション達を見ながらコード部を生成させる仕組み。今回はActionNameAttributeが宣言されてれば、アクション名をそっちに切り替えるってことをしたいので、ActionMethodInfoあたりを中心にチェック。ふと思ったけど、オリジナルのアクション名が消えるとちょっと分かりにくいかも?オリジナル+ActionName指定の両方を展開しといたほうが、優しさチラリな気がしたので、そういう事にします。

CodeFunctionのNameを書き換えるのは無理なんだろうから、ActionNameが指定されてるならそっちの名前を返すように内部のクラスを書き換える。

// Data structure to collect data about a method
class FunctionInfo: BaseFunctionInfo {
public string _actionName = null; public FunctionInfo(CodeFunction2 method) : base(method) { } public FunctionInfo(CodeFunction2 method, string actionName) : base(method) { _actionName = actionName; } public string Name { get { return _actionName ?? _method.Name; } } public string ReturnType { get { return _method.Type.CodeType.Name; } } public bool IsPublic { get { return _method.Access == vsCMAccess.vsCMAccessPublic; } } }

太字の部分が追加したコードです。さらに、このクラスを派生させてる部分も同じように変える。

// Data structure to collect data about an action method
class ActionMethodInfo: FunctionInfo {
    private bool _isOverride = true;
	
    public ActionMethodInfo(CodeFunction2 method, ControllerInfo controller): base(method) {
        Controller = controller;
    }

    public ActionMethodInfo(CodeFunction2 method, ControllerInfo controller, string actionName): base(method,actionName) {
        Controller = controller;
        _isOverride = false;
    }
    
    public bool IsOverride {get {return _isOverride;} }
    public ControllerInfo Controller { get; private set; }

    public string GeneratedName {
        get {
            // If the action name would cause a class/method conflict, append an underscore to it
            if (Controller.Name == Name)
                return Name + "_";
            return Name;
        }
    }
}

これも太字。IsOverrideって言うのが追加されてるのは、Controllerの書き換えをされる時に、アクション関数はすべてvirtualになって、そのControllerを内部で派生させて、すべてのアクションをoverrideしてるから。でも、今回追加するActionNameで指定したアクション関数はそもそもそんな名前でControllerには存在しないから、派生クラスでしか不要だし、overrideするものじゃ無いじゃないですか。なので、ここでそのフラグを保持しておいて、テンプレート処理部でこれを見て、overrideを付加するかどうか判定させるって感じです。

続いて、ActionMethodsコレクションを構築しているProcessControllerActionMethodsを書き換える。

void ProcessControllerActionMethods(ControllerInfo controllerInfo, CodeClass2 type) {
    foreach (CodeFunction2 method in GetMethods(type)) {
        ~ 長いのでココはカット ~
        // Collect misc info about the action method and add it to the collection
        controllerInfo.ActionMethods.Add(new ActionMethodInfo(method, controllerInfo));

        // ActionNameAttributeを見てそっちも追加する。
        // オリジナルが不要なら↑のAddを削除。
        foreach(CodeAttribute2 attr in method.Attributes)
        {
          if(attr.Name == "ActionName")
          {
            foreach(CodeAttributeArgument arg in attr.Arguments)
            {
              var actionName = arg.Value.Replace("\"","");
              controllerInfo.ActionMethods.Add(new ActionMethodInfo(method, controllerInfo, actionName));
            }
          }
        }
    }
}

太字部です。なんか、リフレクションじゃないから属性とか属性パラメータの取り方がちょっと特殊。MSDN最高。基本すべてソースコードを解析した結果構築されるオブジェクト達だから中身は単なる文字列。arg.Valueで取り出したActionNameに指定してるName値がソース上で定義されてるのと同じようにダブルクォーテーション付きになってるので、そこを削除。

最後にテンプレート部で処理してる部分を書き換え。

public static class MVC {
<#  foreach (var controller in Controllers) { #>
    public static <#= controller.DerivedClassName #> <#= controller.Name #> = new <#= controller.DerivedClassName #>();
<#  } #>
}

まずはMVCクラスの部分でオリジナルControllerクラスを入れ物にしてたから、テンプレートで生成した派生クラスになるように変更。こうしないとActionName名のアクション関数が無いから困るんです。

namespace <#= T4MVCNamespace #> {
<#  foreach (var controller in Controllers.Where(c => !c.SharedViewFolder)) { #>
    [CompilerGenerated]
    public class <#= controller.DerivedClassName #>: <#= controller.FullClassName #> {
        public <#= controller.DerivedClassName #>() : base(_Dummy.Instance) { }

<#      foreach (var method in controller.ActionMethods) { 
        var overrideKeyword = method.IsOverride ? "override " : "";
#>
        public <#= overrideKeyword #><#= method.ReturnType #> <#= method.Name #>(<# method.WriteFormalParameters(true); #>) {
            var callInfo = new T4MVC_<#= method.ReturnType #>("<#= controller.Name #>", "<#= method.Name #>");
<#          if (method.Parameters.Count > 0) { #>
<#              foreach (var p in method.Parameters) { #>
            callInfo.RouteValues.Add("<#= p.Name #>", <#= p.Name #>);
<#              } #>
<#          }#>
            return callInfo;
        }

<#      } #>
    }
<#  } #>

    [CompilerGenerated]
    public class _Dummy {
        private _Dummy() { }
        public static _Dummy Instance = new _Dummy();
    }
}

上記太字の部分で最後です。無駄にソースを引用してるんで、長いですけど、実質10行ほどじゃないっすかね。思ったより、変更箇所も少なくて助かりました。Visual Studioオートメーション&T4の組み合わせ恐るべし。

そうすると、前回の投稿ではIntelliSenceに出てこなかったActionName指定の部分も出てくるようになりました。

t4mvc4

やったね!

これまた興味ある方はダウンロードどうぞ(T4MVC-T.ttに名前変えてます)。

T4MVC

べーさんも気になってるように、ASP.NET MVCでは文字列(マジックストリング)を指定して、リンクやURLを生成するのが普通ですね。

    <% using(Html.BeginForm("Index","Home")) { %>
      <% = Html.TextBox("Name") %>
      <input type="submit" value="ポスト" />
    <% } %>

例えば、↑こんな感じで書くと↓こんな感じの出力に。

    <form action="/" method="post">
<input id="Name" name="Name" type="text" value="" /> <input type="submit" value="ポスト" /> </form>

リンクの場合もそう。

    <% = Html.ActionLink("ホーム", "Index") %>

↑こんな感じでしょう。URL生成はコントローラとアクションを指定したり、ルーティング名を指定したりするのが、今までのオーソドックスな書き方。

そんな状況は誰も嬉しくないよ!だってアクション名変えたら、指定してる箇所全部検索して変更しなきゃ行けないし、1文字でも間違ってたらちゃんと出力してくれない。あぁ、誰か助けて。

そもそもの始まりは↓ここからでした。

Angle Bracket Percent : A BuildProvider to simplify your ASP.NET MVC Action Links

BuildProviderを使ってASP.NET実行時に、CodeDom使ったコンパイラを走らせてがんばる方法(自アセンブリをリフレクションで探索)。なるほど~、と。ただ、やっぱり応用しにくいっていうか、コードが書きにくい感じで、その時はそれほど便利でもないかな~、なんて油断してました。それでも、この時点でHtmlHelperを拡張して、文字列指定じゃなくて関数指定でリンク生成出来てました。この辺でPhilさんもエントリ上げてて、ActionNameで別名使ったときにこれだと対応出来ないから、その辺上手いこと処理出来るのを次のバージョンに向けて考えてます的なことを言ってた気がする。

その後、いろいろ試行錯誤があって、出てきたのが↓これですよ。

Angle Bracket Percent : A new and improved ASP.NET MVC T4 template

CodeDomじゃなくてT4でいいんじゃね?と気がついたんでしょうか。コード生成するならこういうテンプレートエンジン使った方が断然生産性が高いしね。このときはまだMvc-CodeGenっていう名前だったんですが、これを更にブラッシュアップして出てきたのが↓。

Angle Bracket Percent : T4MVC 2.2 update: Routing, Forms, DI container, fixes

ちょっとバージョンアップしたんだけど、T4MVCに名前変わってからはMVCのソースが公開されてるCodePlexに組み込まれました(アドインだからこういう言い方はおかしいかも?)。

ASP.NET - Release: ASP.NET MVC v1.0 Source

ドキュメントなんてかけらもないので、どういう物かはソースを見て判断しましょう。まぁ、TTファイルの最初に書かれてる内容がそのままなんですけどね。

とにかくまず、自分のMVCプロジェクトのルートにT4MVC.ttをコピー。準備はこれだけ。後はT4なんで勝手にコード生成してコンパイルしてくれるので、すぐに使い始められます(最初にApp_Codeに放り込んでみたら全然動かなくてビックリした)。

t4mvc1

大枠でMVCという静的クラスが生成されて、そこからたどっていく感じになります。ASP.NETでも自動生成されるグローバルクラスにASPっていうのがあるけどそれと同じような感覚ですね。とりあえず、最初に書いたサンプルをT4MVCで書くとどうなるか、ですが、↓こうです。

    <% using(Html.BeginForm(MVC.Home.Actions.Index, MVC.Home.Name)) { %>
      <% = Html.TextBox("Name") %>
      <input type="submit" value="ポスト" />
    <% } %>

    <% = Html.ActionLink("ホーム", MVC.Home.Index()) %>

リンクに表示する部分以外のマジックストリングが無くなりました。しかもこのMVCクラスは動的なので、アクションやコントローラの名前を変更したら、コンパイルエラーが起きるので、変更し忘れともおさらばですよ!素敵です。

これだけじゃなくてデスね、Linksというクラスも同時に生成されるんですが、そこにはContentフォルダとScriptsフォルダに含まれてるファイル達のリンクが生成されます。

    <% = Links.Content.Site_css %>
    <% = Links.Scripts.jquery_1_3_2_js %>

↑こんな感じで。

さて、ここでActionNameAttributeをつけたアクションはちゃんと指定出来るのか気になるところなので、簡単に実験してみましょう。

    [ActionName("Index2")]
    public ActionResult KoukaiShitakunaiNamaeNoAction()
    {
      return View("Index");
    }

こんなのを作成してみる。保存したりすると、勝手にT4MVCが動く。で、ViewでIntelliSenceをきかせてみると~?

t4mvc2

残念!ActionNameまでは見てくれませんでした。Index2が出てくるのを期待したんだけど。実行結果も残念ながら。元の名前のままでした。がんばって自分でtt書き換えてActionName属性を見るようにするか、おとなしく新しいバージョンを待つか。どっちが男らしい?

ところで、このT4MVCを実行するとすべてのコントローラはpartialクラスになって、すべてのアクションはvirtualが自動でくっつきます。どうしてかというと、ActionResultを引数に受け取るHtmlHelper拡張に渡して、URLを生成しやすくするためですよね。内部で独自クラスに派生させたコントローラを生成し、アクションをオーバーライドしてるんですね。なんとも強引な方法。コントローラ書き換えられるのが気持ち悪い!って人にはお勧めしないですけど...。

お試しあれ!

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。