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

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サイト!

2011年5月3日火曜日

Dapper.NET

dapper-dot-net - Simple SQL object mapper for SQL Server - Google Project Hosting

これ。Massiveと同じくらい短いコードのORM。WebMatrix.Data.Databaseみたいなものですね。

オープンソースとしてたくさん存在する軽量ORMのなかでもDapperがスゴイところがパフォーマンス。どのくらい早いのかは上記サイトを確認してみると各種ORMとの速度比較が出てます。

A day in the life of a slow page at Stack Overflow

ASP.NET MVC & SQLServer & LINQ to SQLのスタックで有名な大規模サイトといえばStackoverflow.comですね。なんとそのStackoverflow.comでパフォーマンスに問題が出てきたので解決するために、このDapperを利用したというじゃないですか。

そもそもORMでN+1問題が出やすいので、LINQならjoin使ってたくさんのSQLを発行しないようにするとか、DBとのあいだのやりとりもちゃんと確認する(パフォーマンスに問題があると認識したならの話です。問題になってないなら気にしなくていいですよ)必要がありましょう。その辺はちゃんと実装したとしてもDapperが早いということです。Emitしちゃってるし。

使い方も簡単でIDbConnectionの拡張メソッドとして実装してるので、DBコネクションさえあれば簡単に導入できます。もちろんモデルクラスをPOCOで定義しておくことが最速を維持する秘訣。

AdventureWorksLT2008R2をDBに利用するためのサンプル。

public class Product
{
  public int ProductId { get; set; }
  public string Name { get; set; }
  public string ProductNumber { get; set; }
}

using (var connection = connectionFactory())
{
  var result = connection.Query<int>("select count(*) from Product").Single();
  Console.WriteLine(result + " products.");
}

// 1テーブルを1クラスにマッピング
using (var connection = connectionFactory())
{
  var result =
      connection.Query<Product>(
          "select * from Product where ListPrice between @low and @high",
          new { low = 10.0, high = 100.0 });
  Console.WriteLine("-- simple mapping:" + result.Count());
  foreach (var p in result)
  {
      Console.WriteLine(string.Format("ID:{0},Name:{1}", p.ProductId, p.Name));
  }
}

// ジェネリック指定しないで1テーブルをマッピング
using (var connection = connectionFactory())
{
  var result =
      connection.Query(
          "select * from Product where ListPrice between @low and @high",
          new { low = 10.0, high = 100.0 });
  Console.WriteLine("-- dynamic mapping:" + result.Count());
  foreach (var p in result)
  {
      Console.WriteLine(string.Format("ID:{0},Name:{1},Price:{2}", p.ProductID, p.Name, p.ListPrice));
  }
}

dapper2

ここで、実際にテーブルにはたくさん項目あるけど、モデルには省略しちゃってるんですけど、このままSQLにjoin含めた場合、ちゃんとモデルにはマッピングできませんでした。dynamicだとうまくいくんだけど、その辺の整合性はちゃんととっておかないとダメなわけですね。でもdynamicな戻り値なら早いわけではない。

dapper1

var stopwatch = new Stopwatch();
stopwatch.Start();
for (var i = 0; i < 100; i++)
{
  using (var connection = connectionFactory())
  {
    var result =
        connection.Query(
            @"select p.*, c.ProductCategoryId as CategoryId, c.Name as CategoryName
          from Product as p 
            inner join ProductCategory as c on 
              p.ProductCategoryID = c.ProductCategoryID 
          where p.ListPrice between @low and @high",
            new { low = 10.0, high = 100.0 });
    foreach (var p in result){}
  }
}
stopwatch.Stop();
Console.WriteLine("dapper time:" + stopwatch.ElapsedMilliseconds + " ms");

stopwatch.Reset();
stopwatch.Start();
for (var i = 0; i < 100; i++)
{
  using (var l2s = new AwDataContext(connectionFactory()))
  {
    var result = from p in l2s.Product
                 join c in l2s.ProductCategory on p.ProductCategoryID equals c.ProductCategoryID
                 select
                     new
                         {
                             ProductId = p.ProductID,
                             p.Name,
                             Category = c.Name
                         };
    foreach (var p in result) {}
  }
}
stopwatch.Stop();
Console.WriteLine("linq2sql time:" + stopwatch.ElapsedMilliseconds + " ms");

大きなサイトへ導入されてるというのは、興味深いじゃないですか。それだけの負荷で問題が起きないように丁寧な実装がされてるわけで(どっちもだけど)。

今後、より使いやすくなることを期待しつつ見守っていきたいプロジェクトです。

2009年4月30日木曜日

VirtualPathProviderを使ってデータベースからViewを読み込む

Scripting ASP.NET MVC Views Stored In The Database

PhilさんのブログではIronRubyのViewEngineを使って、データベースから読み込んだViewを出力するサンプルが公開されてたけど、そこはシンプルに普通のASPXの出力が欲しいところ。でも、そもそもファイルの実体が無くても処理出来るということに驚きですが。

以前ViewEngineの実装をしようとしてて、実体がないと上手く行かないな~、っていう理由で諦めたんですがVirtualPathProviderを使う事で解決出来るんですね。ASP.NETそのものの仕様をきちんと理解してなかったです...。

ASP.NET MVCのソースをみると、標準のViewEngine(WebFormViewEngine)はVirtualPathProviderを含むVirtualPathProviderViewEngineから派生し、WebFormViewをインスタンス化する際にBuildManager.CreateInstanceFromVirtualPathを呼び出してます。

VirtualPathProviderっていうのが、パスの指し示す場所にあるファイル(VirtualFile)かディレクトリ(VirtualDirectory)を返す役割を実装します。ファイルはVirtualFile.Openでストリームを返せばいいので、そのストリームをローカルHDDから読み込もうとDBからだろうと、なんかしら別のサービスから取得(なんならHTTPで別サーバーから)取得しようがお構いなしでOKっていうグレートなクラスになってます。

今回この機能を使って、特に変わったViewEngineではなくWebFormViewEngineをそのまま利用して、VirtualPathProviderがデータベース参照するようにしたものを作ってみました。

まずはどこから手をつけていい物やらよく分からないのでPhilさんのサンプルを眺めるところから。が、ViewEngineが違うからコードが少し多いし、ScriptRuntime使うからなんかちょっと複雑。ふにゅ~。

とりあえず、まずはデータベース作ろう。んで、Repository書いてしまおう。

fromdb1

fromdb2

こんな感じのテーブルでLINQ to SQLを使ってモデルを作成。 Idはオートナンバーの主キー。イヤマジでそれじゃおかしい使い方をしてるんだけど気にしない。 ViewNameが名前。ホントはコレが主キーデスね。 VirtualPathが仮想パス。そのままかよ!ここにパスを入れておいて、そのパスへのアクセスを横取りしてDBからファイル内容を返します。 Contentsにファイル内容。ChangeStampは使ってないけど癖で入れちゃってます。

namespace FromDB.Core
{
public interface IFromDBRepository : IDisposable
{
  IQueryable<VirtualView> All();
  VirtualView GetById(int id);
  VirtualView GetByViewName(string viewName);
  VirtualView GetByVirtualPath(string virtualPath);
  void Create(VirtualView model);
  void SubmitChanges();
}
}
リポジトリはこんなインターフェース。単純に全部取得、1件取得(3パターン)、新規、保存だけね。
  public class FromDBRepository : IFromDBRepository
{
  FromDBDataContext _context;

  public FromDBRepository() : this(new FromDBDataContext()){}
  public FromDBRepository(FromDBDataContext context)
  {
    _context = context;
  }

  public void Dispose()
  {
    if (_context != null)
      _context.Dispose();
  }

  public IQueryable<FromDB.Models.VirtualView> All()
  {
    return from view in _context.VirtualViews
           orderby view.Id
           select view;
  }

  public FromDB.Models.VirtualView GetById(int id)
  {
    return All().Where(v => v.Id == id).FirstOrDefault();
  }

  public FromDB.Models.VirtualView GetByViewName(string viewName)
  {
    return All().Where(v => v.ViewName == viewName).FirstOrDefault();
  }

  public FromDB.Models.VirtualView GetByVirtualPath(string virtualPath)
  {
    return All().Where(v => v.VirtualPath == virtualPath).FirstOrDefault();
  }

  public void Create(VirtualView model)
  {
    _context.VirtualViews.InsertOnSubmit(model);
  }

  public void SubmitChanges()
  {
    _context.SubmitChanges();
  }
}
今回もDIは使ってないデス。とりあえず、ココまで出来た段階でHomeControllerをガッツリ書き換えて、このデータを編集する機能にしてしまいます。コードは省略。ダウンロードしてから見てくだい。

fromdb3

↑Indexが一覧。

fromdb4 fromdb5

新規と編集が出来るように。コードエディタはPhilさんみたいにカッコイイのを使わずに、Html.TextAreaでごまかす。いいじゃん! そうそう、このやり方だと、Htmlタグをポストすることになるので、POSTを受け取るアクションのActionFilterで[ValidateInput(false)]を指定するのを忘れずに。 さてさて、ここまでは普通のASP.NET MVCでの作業。ここからVirtualPathProviderの実装に移ります。すったもんだの末、以下の2つがあればいいことが分かりました。 まずはVirtualPathProviderを派生させたクラス。
  public class FromDBVirtualPathProvider : VirtualPathProvider
{
  private VirtualView GetVirtualView(string virtualPath)
  {
    VirtualView vv;
    using (var rep = new FromDBRepository())
    {
      vv = rep.GetByVirtualPath(VirtualPathUtility.ToAppRelative(virtualPath));
    }

    return vv;
  }

  private bool IsVirtualPath(string virtualPath)
  {
    var path = VirtualPathUtility.ToAppRelative(virtualPath);
    return path.StartsWith("~/FromDBViews/", StringComparison.InvariantCultureIgnoreCase);
  }

  public override bool FileExists(string virtualPath)
  {
    if (IsVirtualPath(virtualPath))
      return GetVirtualView(virtualPath) != null;
    else
      return base.FileExists(virtualPath);
  }

  public override VirtualFile GetFile(string virtualPath)
  {
    if (IsVirtualPath(virtualPath))
    {
      var vv = GetVirtualView(virtualPath);

      return new FromDBVirtualFile(virtualPath, vv.Contents);
    }
    else
    {
      return base.GetFile(virtualPath);
    }
  }

  public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
  {
    if (IsVirtualPath(virtualPath))
      return null;
    else
      return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
  }
}
必要なのはFileExistsとGetFileとGetCacheDependency。GetCacheDependencyをオーバーライドしとかないと、実体フォルダもファイルもないのに、ファイルの書き換えが発生しないかどうかのフォルダ監視をスタートさせようとするので実行時にエラーになります。 とりあえず、nullを返しておくようにしてるけど、ここはSqlCacheDependencyとかちゃんと使うのがいいと思います。ちなみに、動かすと分かるんだけどnullを返すとキャッシュしたVirtualFileを使いまわされてちょっと不便。 あと、FileExistsとGetFileの2箇所で無駄にデータベースに問い合わせてるのもキャッシュを使って回避するようにしないとダサイですね。 ~/FromDBViewsフォルダにファイルがあるように見せかけるようにしてます。

次に、データベースから読み込んだデータをファイルだと偽るために、VirtualFileの派生クラスを実装。

  public class FromDBVirtualFile : VirtualFile
{
  string  _contents;

  public FromDBVirtualFile(string virtualPath, string contents)
    : base(virtualPath)
  {
    _contents = contents;
  }

  public override System.IO.Stream Open()
  {
    MemoryStream stream = new MemoryStream();
  
    using (var writer = new StreamWriter(stream))
    {
      writer.Write(_contents);
    }

    return new MemoryStream(stream.GetBuffer());
  }
}

データベースからの読み込みはFromDBVirtualFileProviderで行ってるから、 ここでは文字列をStreamにするだけです。エンコーディング手抜きでサーセン。

あと忘れずにやっておかなきゃいけないのが、VirtualFileProviderの登録。これはGlobal.asaxのApplication_Startで。

    protected void Application_Start()
  {
    RegisterRoutes(RouteTable.Routes);

    System.Web.Hosting.HostingEnvironment.RegisterVirtualPathProvider(new FromDBVirtualPathProvider());
  }

コレでViewの実体ファイルが無くてもデータベースから読み込んだデータを使ってViewを表示することが出来るようになります。最後にFromDBControllerがこのViewを表示するようにします。

  public class FromDBController : Controller
{
  public ActionResult Dynamic(string viewName)
  {
    ViewData["Message"] = "動的ページ生成 from Database";

    return View("~/FromDBViews/" + viewName + ".aspx");
  }
}

※ルーティング登録もしてます。

fromdb6

コレだけ。 That's it.

ひゃっほ~!

2008年6月20日金曜日

DataControllerでDynamic Dataみたいな

ASP.NET MVC Tip #4 - Create a Custom Data Controller Base Class - Stephen Walther on ASP.NET MVC これもありだな~、と思います。

Google App Engineでの開発っぽい。 DataControllerだけが大事なところなのにコード量がべらぼうに少なくて、なんか嬉しい。 Form 値はRequest.Form.Keysをforeachで取り出したのとエンティティのプロパティが同じなら(上手く言えないけどリフレクションで)書き換える感じで。エンティティそのままでViewData用のクラスなわけじゃないから、 System.Web.Mvc.BindingHelperExtensions.UpdateFromを使えないのかな(NewとUpdateで同じコード書いてるのか切ない部分)?

でもね、GetDynamicGetがカッコよすぎる。 IdentityColumnNameも主キーがintの項目を1つにするルールを貫けば(Railsとかそうだし)超絶便利な予感。 派生してるのがHomeControllerだからリソース名がHomeに思えるけど、そこはデフォルトのままいじってないってことなんだろうから気にしない。実際にはこのサンプルならMoviesControllerとかにするのがナウなヤングのRails風?

RESTfulじゃないけど「設定より規約」なところがいいっす! 最近のお気に入りはDBのテーブル全てにEntryDateとModifyDateをDateTime型で作っておいて、DataContextクラスのSubmitChangesをオーバーライドして勝手に値を入れるようにすること。

public override void SubmitChanges(ConflictMode failureMode) { ChangeSet changes = this.GetChangeSet(); DateTime now = DateTime.Now; Action setNow = (entity, name) => { var etype = entity.GetType(); var prop = etype.GetProperty(name); if (prop != null) prop.SetValue(entity, now, null); }; // insert foreach (var entity in changes.Inserts) { setNow(entity, "EntryDate"); setNow(entity, "ModifyDate"); } // update foreach (var entity in changes.Updates) { setNow(entity, "ModifyDate"); } base.SubmitChanges(failureMode); }

GetChangeSet()で追加・更新・削除それぞれの対象レコードを取得できるから、それを取り出して自動更新。エンティティの型は宣言しないでvarで。varと規約ベースの開発は最高っす! ただ悩みもありまして...。

入力値の検証(Validation)をどこでやるのがいいのかってことなんですけどね、LINQ to SQLのEntityのpartial classでやると、確実にチェックではじけていいんだけど、タイミング的にはもう少し早い段階でやりたかったりする。ViewDataクラスにバリデーション機能をつけるのがいいのかな...。アプリケーションの制約と、データベースの制約とレイヤーが違うからそれぞれに必要なんだろうけど(例えばAと Bっていうカラムがあって、更新するときにどっちも空は嫌だけど、どっちも空でもDBでは問題なしとか)。 タイムリーにオノさんところで紹介されてたサンプル(SingingEels : ASP.NET MVC in the Real World)を見てみたけど、普通にコントローラでForm値を検証してるじゃんよ...。 こんな感じなのかな~(とりあえずTempDataに入れちゃうとロードバランサとかで無茶ぶりできなくなる)。

追記なんですけど。 ASP.NET MVC Tip #5 – Create Shared Views - Stephen Walther on ASP.NET MVC これまた面白いのが...。Tip #4の続きで今度はまさにDynamic Data。 コントローラ用のViewフォルダに、コントローラでView()するASPXがない場合の初期動作として、Sharedフォルダ使う動きをうまく生かして、すべてに共通のViewでページ生成。なので、インターフェイスを動的に作る必要があるからASPXのコードビハインドにコード書いてる。 全然気になんない!Scaffold!

dotnetConf2015 Japan

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