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

2011年8月19日金曜日

HighPerformanceSessionStateProvider

ASP.NET Univarsal Providers のセッションプロバイダを使ってみる (2) « ブチザッキ

カメさんのこのエントリーに対してこんなコメントをしてみたわけですが、具体的なコードを提示しないで案だけ出すって、そんな失礼なことがあっちゃいけねー。江戸っ子なら宵越しの銭はもってちゃいけねー。江戸っ子じゃないんですけどね。

まずは現状確認。カメさんのコードをいただきつつ。ASP.NET MVC3のサイトをデフォルトで作成。ASP.NET Universal ProvidersをNuGetでインストール。web.configでSessionStateの設定をCookieless="UseUri"とTimeout="1"に変更(検証が簡単だからね)。

using System;
using System.Net;

namespace ConsoleApplication1
{
  class Program
  {
    static void Main(string[] args)
    {
      System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();

      GetPage();

      int count = int.Parse(args[0]);

      for (int i = 0; i < count; i++)
      {
        sw.Reset();
        sw.Start();
        var success = GetPage();
        sw.Stop();
        Console.ForegroundColor = success ? ConsoleColor.White : ConsoleColor.Red;
        Console.WriteLine("{0}\t{1}\t{2}\t{3}", 
            DateTime.UtcNow.ToString("yyyy/MM/dd hh:mm:ss.fff"), 
            sw.Elapsed, i, success ? "OK" : "NG");
      }
    }

    static private bool GetPage()
    {
      var result = true;
      try
      {
        var request = WebRequest.Create("http://localhost:52855/");
        {
          using (var response = request.GetResponse())
          {

          }
        }
      }
      catch
      {
        result = false;
      }

      return result;
    }
  }
}

ずるくないもん!オマージュだもん!

コレとMVCサイトで実行した結果(クリックでズーム)。

up1

セッションを生成するコンソールは3個でCPUはMAX。白行はエラーなく進んだところで、赤行はASP.NET側でエラーになったところ。

up2

1分でセッションは切れるので、1分後からドカドカ古いセッションの削除が始まります。

概ね680セッションあたりで飽和。エラーと正常実行を繰り返す感じです。切ないですね。セッションの削除が同時実行されててんやわんやな例外。

これを解消するために、InitializeRequestをoverrideしたSessionStateProviderクラスを定義します。

やることはSessionsテーブルにExpires列のインデックスがなければ作成と、セッション初期化時の有効期限切れセッションの削除をEFのDeleteObjectじゃなくSQLのDelete文を実行するように。

ちょっと長いですけど。

using System;
using System.Configuration;
using System.Data.Common;
using System.Reflection;
using System.Web;
using System.Web.Providers;
using System.Web.Providers.Entities;

namespace UniversalProviders
{
  public class HighPerformanceSessionStateProvider : DefaultSessionStateProvider
  {
    private string _connectionStringName;
    private bool _initialized = false;

    public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
    {
      _connectionStringName = config["connectionStringName"];

      base.Initialize(name, config);
    }

    private void ExecuteSql(ConnectionStringSettings connectionStringSettings, Action<DbProviderFactory, DbCommand> functor)
    {
      var providerName = connectionStringSettings.ProviderName;
      var factory = DbProviderFactories.GetFactory(providerName);
      using (var connection = factory.CreateConnection())
      {
        connection.ConnectionString = connectionStringSettings.ConnectionString;
        connection.Open();

        var command = connection.CreateCommand();

        functor(factory, command);
        command.ExecuteNonQuery();
        connection.Close();
      }
    }

    private void CreateSessionIndex(ConnectionStringSettings connectionStringSettings)
    {
      ExecuteSql(connectionStringSettings, (factory, command) =>
      {
        command.CommandText = @"IF not EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[Sessions]') AND name = N'IX_Sessions')
begin
CREATE NONCLUSTERED INDEX [IX_Sessions] ON [dbo].[Sessions] 
(
[Expires] ASC
) ON [PRIMARY]
end
";
      });
    }

    private void RemoveExpiredSessions(ConnectionStringSettings connectionStringSettings)
    {
      ExecuteSql(connectionStringSettings, (factory, command) =>
      {
        command.CommandText = "delete Sessions where Expires < @0";
        var parameter = factory.CreateParameter();
        parameter.ParameterName = "@0";
        parameter.Value = DateTime.UtcNow;
        command.Parameters.Add(parameter);
      });
    }

    private MethodInfo GetCreateSessionEntities()
    {
      var modelHelper =
        Assembly.GetAssembly(typeof(Session))
                .GetType("System.Web.Providers.Entities.ModelHelper");
      return modelHelper.GetMethod("CreateSessionEntities", 
        BindingFlags.NonPublic | BindingFlags.Static);
    }

    public override void InitializeRequest(HttpContext context)
    {
      var connectionStringSettings = ConfigurationManager.ConnectionStrings[_connectionStringName];
      if (!_initialized)
      {
        var initializer = GetCreateSessionEntities();
        initializer.Invoke(null, new object[] { connectionStringSettings });
        CreateSessionIndex(connectionStringSettings);
        _initialized = true;
      }

      RemoveExpiredSessions(connectionStringSettings);
    }
  }
}

クラス名はもちろんHighPerformanceSessionStateProviderデス!

up3

<sessionState mode="Custom" customProvider="HighPerformanceSessionProvider" cookieless="UseUri" timeout="1">
  <providers>
    <add name="DefaultSessionProvider"
         type="System.Web.Providers.DefaultSessionStateProvider, System.Web.Providers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
         connectionStringName="DefaultConnection" applicationName="/" />
    <add name="HighPerformanceSessionProvider"
         type="UniversalProviders.HighPerformanceSessionStateProvider, UniversalProviders"
         connectionStringName="DefaultConnection" applicationName="/" />
  </providers>
</sessionState>

Web.configも上記のように変更しちゃいましょう!customProviderの指定がDefaultSessionProviderでオリジナルに戻ります。

セッションが飽和するのが、940件くらいのところです。もちろん例外なんて起きませんよ。有効期限切れのレコード削除なんて何回実行したって、無いものはない!潔し!ちなみに先程の数値もそうですけど、コレが同時実行の限界値なわけではないです。マイノートPCの同時実行の限界くらいです。Webの受付のね。DbProviderFactoriesを利用することでEFと同じだけのポータビリティ(言い過ぎ)。

名前ほど早くないけど、エラーが起きないから実用的なんじゃん?読み込み性能は気にしてないでーす。あくまで新規セッション作成時のパフォーマンス向上委員会です。言い訳がましい...。

ご利用は計画的に!

Azureでは動かしてないので、そちらの検証はたぶんカメさんがその3でやってくれるんじゃないでしょーか。

2011年6月5日日曜日

ClayFactory

Clay面白いかも!

NuGet Package of the Week #6 - Dynamic, Malleable, Enjoyable Expando Objects with Clay - Scott Hanselman

Clay - Home

ORM使ったリポジトリを使った場合、戻りの型って結構融通効きにくいじゃないですか。select対象のクラスを全部手書きするのかよ、って。だからといって無名クラスは戻せないじゃない。objectでリフレクションとか本末転倒。

なのでdynamicを使うことにするんですけど、そのためにLINQ selectの射影をdynamicにする簡単なヘルパーを用意しとくと便利ですね。

public static class ObjectToDynamicExtensions
{
  public static dynamic ToDynamic(this object obj)
  {
    dynamic model = new ExpandoObject();
    var properties = obj.GetType().GetProperties();
    foreach (var property in properties)
    {
      ((IDictionary<string, object>)model)[property.Name] = property.GetValue(obj, null);
    }
    return model;
}
}

使用例

return from person in db.People
       select new {
         FullName = person.FirstName + 
                    " " + 
                    person.LastName,
         Age = ((DateTime.Today.Year*10000 + 
               DateTime.Today.Month*100 + 
               DateTime.Today.Day) - 
               (person.Birthday.Year*10000 + 
               person.Birthday.Month*100 + 
               person.Birthday.Day))/10000
       }.ToList().ToDynamic()
       

 

使い道はこんな感じのマッピングなんですけど、ここでちょっと残念なのが、ASP.NET MVCのViewにdynamicを渡すと、ほとんどのヘルパーが機能してくれないこと。残念ですね。凹みますね。

ちゃんと型付けしとけ、っていうのはもちろんそうなんですけどね。だからScaffoldingもあるわけでして。

とは言え、柔軟な実装にしたい。単純にマッピングをしたいようなときにはClayいいかもという流れです。

dynamicをインターフェース実装のプロキシに変換してくれるみたい。そうなればもう強い型付け完了。

インターフェースは規約で判定するみたいだけど、namespaceどうするんだろ。気になる。あ、でも、Entity ModelとOutput Modelを両方共クラスにするんじゃなくて、Output Modelはインターフェースにしちゃう感じなのかな。

dynamicから特定のインターフェースに変換したくなるときって、よくあるきがしてきた!ワクワクするね!

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");

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

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

2011年4月7日木曜日

ブチザッキさんへ

お元気ですか。ボクは元気です。

OData and Authentication – Part 7 – Forms Authentication - WCF Data Services Team Blog - Site Home - MSDN Blogs

WCF Data Servicesをプロキシクラス経由で呼び出すときにForm認証してやんよ、の件についてなんですけども。

WCF Data Services で フォーム認証を利用する « ブチザッキ

ナニをナニする系で、ナニしてみたのでご覧いただけたら幸いです。

Custom Membership Providers - Task Manager - CodeProject

プロキシからの呼び出しに介入し、別リクエストでAuthentication_JSON_AppService.axdを呼び出すことでForm認証し、レスポンスからCookieを横取りすることで、認証チケット再利用する部分を、FormsAuthenticationクラスを使って認証チケットを発行してそちらを使うことで実現してみました。

とはいえ、まったく楽チン度と便利さが上がらなかったので、ボツにしようかと思ったのですが、それはそれで少し寂しくなっちゃったので、公開レターの形式にしてみました。

WCF DataServicesをホストするサーバーサイドプロジェクトをASP.NET MVCにして、HomeControllerに以下のアクションメソッドを追加します。

[HttpPost]
public ContentResult FormAuth(string userName, string password)
{
  if (Membership.ValidateUser(userName, password))
  {
    var authTicket = new FormsAuthenticationTicket(
       1,
       userName,
       DateTime.Now,
       DateTime.Now.AddMinutes(30),
       false,
       userName);
    var cookie = 
       FormsAuthentication.FormsCookieName + "=" +
       FormsAuthentication.Encrypt(authTicket) + "; path=" +
       FormsAuthentication.FormsCookiePath + "; " +
       (!FormsAuthentication.RequireSSL
         ? "HttpOnly" : "");
    return Content(cookie);
  }

  return null;
}

続いて、クライアントサイドのプロキシクラス用に実装しているGetCookieの代わりとなる部分を以下のように。

string GetTicket(string userName, string password)
{
  string loginUri = string.Format("{0}/{1}/{2}",
      ServiceUri,
      "Home",
      "FormAuth");
  WebRequest request = HttpWebRequest.Create(loginUri);
  request.ContentType = "application/x-www-form-urlencoded";
  request.Method = "POST";

  string authBody = String.Format(
      "userName={0}&password={1}",
      userName,
      password);
  request.ContentLength = authBody.Length;

  using (StreamWriter w = new StreamWriter(request.GetRequestStream()))
  {
      w.Write(authBody);
      w.Close();

      WebResponse res = request.GetResponse();
      var body = new StreamReader(res.GetResponseStream()).ReadToEnd();
      if (!string.IsNullOrEmpty(body))
      {
          Cookie = body;
      }
      else
      {
          throw new Exception("Invalid username and password");
      }
  }

  return Cookie;
}

GetCookie(userName,password)の部分を上記のGetTicket(userName,password)にすることで、Cookie横取りではなくなりますね。

formauth

だから言ったじゃないですか!ボツにしたかったって...。

2011年3月19日土曜日

EF4.1 CodeFirstでenumを使いたい

最近、いろんなお誘いメールがケータイに舞い込んできて、いやもうマジクリックしちゃうぞコノヤロー。そんなに誘惑するんじゃないよ!

なんか迷惑メールが地震以降強烈に増えましたね。

結局enumはサポートされないことが決定してしまったCodeFirst。しょうがないですね。RCからRTMまでの間で機能追加はないので、ここは潔く諦めましょう。

でも、やっぱりenumを使いたいですよね。プロパティをパースするときにenumかenumのジェネリックは完全にスルーされてデータベーステーブルがScaffoldingされます。なのでComplex Typeとしてenumをラップしたクラスを用意し「オレenumじゃないよ、全然関係ないから!」とEFを騙す必要があります。

Tip 23 – How to fake Enums in EF 4 - Meta-Me - Site Home - MSDN Blogs

古のテクニックですね。2009年です。今もこれしか方法がないみたいです。

コンソールアプリを作りながら同じようにやってみよー!VS2010SP1を前提にしますよ?いいですか?だってSQLCE4使ってみたいでしょ。なのでSQLCE4も入ってる前提で行きます。なきゃないで問題ないので、SQLCE4関連の部分は読み替えてください。

ソリューション作ったらまずは、NuGet!NuGetで必要なパッケージを入れてしまいましょう。以下の2つ。

install-package EFCodeFirst
install-package EFCodeFirst.SqlServerCompact

↑こうです。

efenum1 efenum2

WebActivatorは不要なので、参照設定から削除しましょう。WebActivatorについては先日書いたので、気になる人はチェックしてみてね。

無聊を託つ: WebActivatorでお手軽Bootstrapper

App_Start/SQLCEEntityFramework.csもEFCodeFirst 0.8ベースなので少しいじっちゃいます。といっても、DbDatabaseをDatabaseにするのとWebActivatorの属性を削除。あと、SqlCe用のnamespace(System.Data.Entity.Infrastructure)が変わってるので、そこも変更。

efenum3

これで準備完了。ワクワクするね!ワクワクの強要。

最初にモデルクラスをつくって、次にDbContext派生。行きます。

using System.Data.Entity;

namespace EFEnum
{
    public enum Role
    {
        Unknown,
        Sniper,
        Captain
    }

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public Role Role { get; set; }
    }

    public class EFEnumContext : DbContext
    {
        public DbSet<Person> People { get; set; }
    }
}

↑EFEnumContext.cs

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <connectionStrings>
    <add name="SampleContext" connectionString="Data Source=|DataDirectory|db.sdf;" providerName="System.Data.SqlServerCE.4.0"/>
  </connectionStrings>
  <system.data>
    <DbProviderFactories>
      <remove invariant="System.Data.SqlServerCe.4.0" />
      <add name="Microsoft SQL Server Compact Data Provider 4.0" invariant="System.Data.SqlServerCe.4.0" description=".NET Framework Data Provider for Microsoft SQL Server Compact" type="System.Data.SqlServerCe.SqlCeProviderFactory, System.Data.SqlServerCe, Version=4.0.0.0, Culture=neutral, PublicKeyToken=89845dcd8080cc91" />
    </DbProviderFactories>
  </system.data>
</configuration>

↑App.config。

とりあえず、enumのまま作ってどういう動作になるのかを見てみます。

using System;
using System.Linq;
using EFEnum.App_Start;

namespace EFEnum
{
    class Program
    {
        static void Main(string[] args)
        {
            SQLCEEntityFramework.Start();
            var db = new EFEnumContext();

            var query = from p in db.People
                        select p;
            var count = query.Count();
            Console.WriteLine("{0}人いるよ!", count);

            if (count == 0)
            {
                var person1 = new Person
                                  {
                                      Name = "ルフィー",
                                      Role = Role.Captain
                                  };
                var person2 = new Person
                                  {
                                      Name = "ウソップ",
                                      Role = Role.Sniper
                                  };
                db.People.Add(person1);
                db.People.Add(person2);
                db.SaveChanges();
            }

            count = query.Count();
            Console.WriteLine("{0}人いるよ!", count);
            foreach (var person in query)
            {
                Console.WriteLine(person.Name + string.Format("({0})", person.Role));
            }


            Console.ReadLine();
        }
    }
}

↑main.cs。

    public static class SQLCEEntityFramework {
        public static void Start() {
            Database.DefaultConnectionFactory = new SqlCeConnectionFactory("System.Data.SqlServerCe.4.0");

            // Sets the default database initialization code for working with Sql Server Compact databases
            // Uncomment this line and replace CONTEXT_NAME with the name of your DbContext if you are 
            // using your DbContext to create and manage your database
            Database.SetInitializer(new DropCreateCeDatabaseIfModelChanges<EFEnumContext>());
        }
    }

SQLCEEntityFrameworkのStartメソッドにDbContextのクラスを指定するのも忘れずに。

ちょっと、コード長いけどドンマイ。行くぜ!

efenum4

クリックして拡大してみてね。ちゃんと名前の横にロールが表示されました。これはAddするインスタンスに設定したものが、そのまま表示されただけです。次に一度終了して、もう一度実行してみます。

efenum5

そうすると、今度は"Unknown"と出ました。なんでかというと、なんとテーブルには保存されてないからです!そういう仕様なので驚かないでね。VSから確認してみます。

efenum6

efenum7

カラム無いですね。ちょっと切ないですね。

さぁ本題です!ちょこちょこっとクラス用意しましょう。

    public abstract class EnumWrapper<T>
    {
        private T _value;
        public string Value
        {
            get
            {
                return _value.ToString();
            }
            set
            {
                _value = (T)Enum.Parse(typeof(T), value, true);
            }
        }

        public static implicit operator string (EnumWrapper<T> value)
        {
            return value.Value;
        }
    }

これを基底クラスとしてenumの型毎にクラスを用意していきます。

    public class RoleWrapper : EnumWrapper<Role>
    {
        public static implicit operator RoleWrapper(Role value)
        {
            return new RoleWrapper { Value = value.ToString() };
        }
    }

enum型1個しかないので、1個つくりましょう。ホントはimplicit operatorも書きたくないんだけど、こればっかりはしょうがない。何かいい方法ありますかね~?最終的にはT4で自動生成(プロジェクト内のenumを全部列挙してしまえばいいかな)させちゃえばいいでしょう。

そして、モデルクラスをこのクラスを使うように変更します。

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public RoleWrapper Role { get; set; }
    }

このままだと、mainのConsole.WriteLineでクラス名がでちゃうのでそこはRole.Valueに変えておきましょう。そうして実行してみると...。

efenum9 efenum8

タターン!左が1回目。0件で追加して2件。右が2回目。2件あるから2件表示。ちゃんとロールも表示されますね。テーブル見てみましょう。

efenum10

カラムも追加されてるし、値も入ってる~。文字列にしてるのはパースしやすいのと、パッと見データ見ただけでわかるからっていうだけの理由です。intがよければそのように。

いいんじゃないでしょうか。こんな感じで。

packages含んじゃってるのでちょっと大きいですがご容赦を。

2011年3月12日土曜日

ASP.NET MVCでDataAnnotationsのエラーメッセージをカスタム

ちょっとこの質問見てみてくださいよ!

Asp.Net MVC 2 - Changing the PropertyValueRequired string. - Stack Overflow

ASP.NET MVCのDefaultModelBinderにはResourceClassKeyっていうプロパティがあって、そこに自作リソースを指定して、文字列リソースのキー名にPropertyValueInvalid/PropertyValueRequiredっていうのを作っておくと、アプリケーション全体にその文字列が適用される。でもInvalidは動作するけどRequiredがぜんぜん適用されません!っていう内容なんですね。

これ質問もなかなか面白いからか、特別ポイントが付与される質問だったんです。なので、張り切って思ってソース追っかけたりしながら動作を見てみたわけですよ。そしたら確かにInvalidはメッセージの置き換えができるのにRequiredは置き換えがおきない。

public class Person
{
  [Required]
  public string Name { get; set; }

  [Required]
  [Range(0,100)]
  public int? Age { get; set; }

  [DataType(DataType.Date)]
  public DateTime Birthday { get; set; }

  [DataType(DataType.EmailAddress)]
  public string Email { get; set; }
}

msg1

protected void Application_Start()
{
  AreaRegistration.RegisterAllAreas();

  RegisterGlobalFilters(GlobalFilters.Filters);
  RegisterRoutes(RouteTable.Routes);

  DefaultModelBinder.ResourceClassKey = "Messages";
}

msg2

なんでかな~、と調べてみたらDataAnnotationsのRequiredAttributeなんかは内部で参照するリソース名を固定で保持してて、DefaultModelBinderのほうの設定を参照しないんですね。分かってしまえば、そりゃそうなんですけど(日本語のエラーちゃんと出るし、GACに入ってるリソースを参照するのが正しい挙動な感じするしね)、それでもASP.NET MVCならできる方法がありそうな気がするんですよね。これだけ拡張ポイントたくさんあるんだから。

いろいろ見ていくとちゃんと用意されてるのを発見しました。

Reusable Validation Error Message Resource Strings for DataAnnotations

DataAnnotationsModelValidatorProvider.RegisterAdapterでValidationAttributeの型ごとにAdapterを指定できるんです。このAdapterのコンストラクタにはValidationAttributeクラスのインスタンスがわたってくるんですが、このAdapterを経由させてから、MVCはエラーメッセージを生成したりするので、AdapterのコンストラクタでインスタンスプロパティのErrorMessageResourceTypeとErrorMessageResourceNameを書き換えてあげれば、アプリケーション全体のメッセージを変更できるっていう仕組みです。

分かりやすいやり方としてはね、カスタムValidationAttributeを作って、ErrorMessage~の設定を上書いておくか、Modelに指定するときに属性プロパティに指定する方法なんだと思うけど、それだと属性を指定する箇所がすごい数になっちゃうじゃないですか。それでもいいけど、そうじゃなく一括して変更したいっていうのが質問の内容じゃないですか。

んで、ちゃんとサンプル書いて返信したんですよ。

public class MyRequiredAttributeAdapter : RequiredAttributeAdapter
{
  public MyRequiredAttributeAdapter(ModelMetadata metadata, ControllerContext context, RequiredAttribute attribute) : base(metadata, context, attribute)
  {
    attribute.ErrorMessageResourceType = typeof (Messages);
    attribute.ErrorMessageResourceName = "PropertyValueRequired";
  }
}

アダプター用意して、Global.asaxに

protected void Application_Start()
{
  AreaRegistration.RegisterAllAreas();

  RegisterGlobalFilters(GlobalFilters.Filters);
  RegisterRoutes(RouteTable.Routes);

  DefaultModelBinder.ResourceClassKey = "Messages";
  DataAnnotationsModelValidatorProvider.RegisterAdapter(
    typeof(RequiredAttribute),
    typeof(MyRequiredAttributeAdapter));
}

↑こう書けばカスタムできるよ!って。

msg3

なのに!なのになのに!カスタムValidationAttributeを作るほうに特別ポイントが!!ガッカリデス。ガッカリ過ぎてスネ毛が少し抜けたよ...。

こういう一括していの方法があることを知らなかったので、勉強になりました。

WebActivatorでお手軽Bootstrapper

いろんなセッションビデオやサンプルコードによく登場してるWebActivator。属性指定のしかたから、.NET4で登場したPreApplicationStartMethod(AssemblyInfo.csに指定するやつ)に関する何かだろうと勝手に解釈して、ちゃんと見てなかったんだけど、なんか急に見てみたくなった。

Light up your NuGets with startup code and WebActivator - Angle Bracket Percent - Site Home - MSDN Blogs

解釈はおおむね間違ってないんだけど、PreAppricationStartMethodだと1個しか指定できない(よね?)のと、フックポイントが起動直後しかない(そりゃそうだ)のを拡張してしまおうというものでした。なるほど。

Pre/Post/Shutdowの3箇所に仕込めるんですね。

  • PreApplicationStartMethod
  • PostApplicationStartMethod
  • ApplicationShutdownMethod

この3種類。うまいこと考えてますね。PreとPostはAOPっぽい感じです。タイミングはPreはそのままPreなんだけど、PostはMicrosoft.Web.Infrastructure.DynamicModuleHelper.DynamicModuleUtility.RegisterModule を使って動的にロードする初期化HttpModule(これ自体ちゃんとWebActivatorに含まれてるので気にすることは無いですね)のInitイベントで実行。ShutodownはそのHttpModuleのDispose時に実行。

David Ebbo: Register your HTTP modules at runtime without config

面白いですね。賢いですね~。アセンブリ属性にしてるのは使い勝手を考慮してのことでしょう。属性宣言とクラス実装が近くにあるはずだから、クラス属性にしてもいいような気もするけど、使い勝手は大事でしょう!

[assembly: WebActivator.PreApplicationStartMethod(typeof(クラス), "実行メソッド名")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(クラス), "実行メソッド名")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(クラス), "実行メソッド名")]

こんな感じでアセンブリ内に宣言します。いくつ宣言してもいいです。ActivationManager(これが標準のPreApplicationStartMethodで指定されてるよ)が実行時にbinフォルダのアセンブリから全部抽出してくれます。

davidebbo / WebActivator / overview – Bitbucket

オレオレBootstrapperを卒業するときがきましたね!

注意点として、HttpModuleを利用するPostとShutdownはAppDomainがロードされなおすたびに実行されるので、何度も実行されるのでその辺気をつけましょう。

とにかくNuGetで Install-Package webactivator とタイプしてインストールしてみて以下のコードで動かしてみました。

[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper1), "Pre")]
[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper2), "Pre")]
[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper3), "Pre")]

[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper1), "Post")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper2), "Post")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper3), "Post")]

[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper1), "Shutdown")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper2), "Shutdown")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper3), "Shutdown")]

namespace Mvc3
{
    public class Bootstrapper
    {
        public static void Pre(string name)
        {
            Console.WriteLine("Pre : "+name + " " + DateTime.Now);
        }

        public static void Post(string name)
        {
            Console.WriteLine("Post : " + name + " " + DateTime.Now);
        }

        public static void Shutdown(string name)
        {
            Console.WriteLine("Shutdown : " + name + " " + DateTime.Now);
        }
    }

    public class Bootstrapper1
    {
        private static string Name = "1番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

    public class Bootstrapper2
    {
        private static string Name = "2番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

    public class Bootstrapper3
    {
        private static string Name = "3番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

}

VS2010SP1だとIIS Expressで実行ができるけど、あえて外部コマンドでIIS Expressを指定して実行してみます。

wa

コンソールのスクリーンショットを取りたかったからデス!これって統合されたらどこで見ればいいんだろか。出力ウィンドウだとちょっと違うじゃん?ん~。まぁ、いいか。おいおい分かるでしょう。

残念ながらShutdownは表示するまで待つのも設定変えるのも面倒(IIS ExpressだとidleTimeoueでシャットダウンしない?)なので未確認!雰囲気が伝わればいいかな、なんて。

2011年2月19日土曜日

ドッグイアとjQuery Mobile

コンピュータ業界は進むのが早いっていう話じゃなくて、本を読んで気になるページに折り目をつける話です。

専門書でも漫画でも小説でも本なら何でも折り目をつけてしまう癖があります。最悪なのは借りた本に折り目をつけてしまうところ。マジすいません。読んだ本で気に入ったフレーズや大事なことだと思った部分を簡単に残しておきたいと思って、ブログに書いたりもするけど、なるべくその瞬間に書き残しておければより記憶に残るっていいなーと思ったわけです。ブクログとかいろいろ便利なサイトが世の中にはありますが、もっともっと特化してしまってもいいと思いませんか!手軽にささっと、となるとモバイルデバイスが利用対象になりましょう。と、いうか自分にとって使いやすいのは何かを考えたらiPhoneだなっていう。

iPhoneといえば最近何かと話題のjQuery Mobile。面白いですよね。まだまだコレだという作り方を見つけることが出来てないのですが。ASP.NET MVCとの相性も悪くない感じです。

そこで本やDVDを見てぐっと来た部分を簡単につぶやいて記録に残せるものを作ってみました。

IMG_0142 IMG_0143 IMG_0144

ぐっと来た.cc

jQuery MobileなのでiPhone/Android等スマートフォン専用です。PCでもそれっぽく動作しますが、まともなのはChromeだけ。Firefoxでも3.6系だと見た目だけはそれっぽいけど、動作(アニメーション)はしょぼしょぼです。HTML5+CSS3だからといってIE9はまるで対象外。パッチを当てても全然ダメだった。

gu1 gu2

左からChrome、Firefox、IE9。背景を黒ししてるのでひどいもんですよ。

使い方は簡単です。最初にサイトにアクセスすると「みんなのぐっと来た」が表示されます。

gu3 gu4

赤い枠の部分をクリックすると、Amazonの商品詳細ページへ。緑の枠の部分をクリックするとつぶやきの全文が表示されます。

右上の「Sign in」をクリックするとTwitterアカウントを使ってログイン。最近お気に入りのOAuthです。http://razordo.it も同じです。

ログインするとHome(検索と最近つぶやいた商品リスト)とPeople(みんなのぐっと来た)を切り替えるタブが出てくるので、初めてつぶやく商品は検索して探しましょう。んで、つぶやく!そしたらTwitterにつぶやき内容とAmazonへのリンクが一緒にポストされます。

That's it!

それだけの機能なんですが、ASP.NET MVC3+Razor+EF CodeFirst+jQuery Mobile+ExpressWeb(宣伝?)です。HTML5とはいえ別に新しいエレメント使ってなくてdata-*属性を指定するくらいなのお手軽実装です。

jQuery Mobileの特徴的な部分はすべてのページは最初に表示されるHTMLに従属する、的なところです。ページ遷移は基本的にAjaxでHTMLを先取りしておいたものを自ページ内にDIVエレメント(data-role="page")として展開して、それを表示するためにアニメーションするというものです。表示されてるのはui-page-activeクラスが指定されてる部分になります。

gu5

なのでページやドキュメントのロード時というのは基本的に最初のページロードの時になりますね。しかも、ページ自体をDIVエレメントで1ページ内に収めるし、キャッシュのためにエレメントは毎回削除されるわけじゃないので、ID属性が同じエレメントが多数発生して$(“#id”)がたくさんいるじゃないか、みたいな状況が出来上がります。

その辺はclassでのセレクタでアクティブなpageからの相対参照で書くようにとコツが必要になるので、慣れが必要でしょう。おや~?と思う動きをするときはだいたいこの辺の設計が関与してます。jQuery MobileっていうのをjQuery UIみたいにとらえると痛い目見るぜハニー。

全く別のフレームワークとして、ちゃんと認識しておくことが必要です。作り方が変わってくるからね。もちろん専用のイベントも用意されてるので、ページのロードじに何か処理をするとかは普通にかけます。コレ以外にもタッチ前提のスマートフォンだからkeydownなんかも発生しなくてびっくりした。そりゃそうなんだけど。

jQuery Mobile Docs – Events

あとはcache manifestがちょっと難しい。manifestを指定してるHTML自体が必ずキャッシュされるという理屈になかなか気がつかなかったです。この辺もうまいこと調整していけば晴れてスマートフォンでも最適なモバイルアプリが出来上がるわけですね。涙ぐましい。

なにはともあれ、それっぽく動くようになったので使ってみてね!あと、Amazonへのリンクは設定で自分アソシエイトIDを登録しておくとそれを使うようになってるので、ID持ってる人は登録しておくとチャリンチャリンと聞こえるかもね!

2011年1月19日水曜日

WebMatrixでAmazon検索

今回はAmazon.co.jpの商品を検索するためのHelperを作ってみたよ。

今まで同様、WebMatrixでスターターサイトがある前提で。今まで作ったヘルパーもちょいちょい拡張しつつ載せていきます。

~/App_Code/Utility.cshtml

@functions {
    public static new dynamic Cache(string key, int expireSeconds, Func<dynamic> thunk){
        var context = new HttpContextWrapper(HttpContext.Current);
        return Cache(context, key, expireSeconds, thunk);
    }
    
    public static new dynamic Cache(HttpContextBase context, string key, int expireSeconds, Func<dynamic> thunk){
        dynamic value = context.Cache[key];
        if(value == null) {
            value = thunk();
            context.Cache.Insert(
                key,
                value,
                null,
                DateTime.Now.AddSeconds(expireSeconds),
                System.Web.Caching.Cache.NoSlidingExpiration
            );
        }
        
        return value;
    }

    public static string WebRequest(Uri uri)
    {
        string response;
        using (var client = new WebClient())
        {
            client.Encoding = System.Text.Encoding.UTF8;
            response = client.DownloadString(uri);
        }
        return response;
    }
}

UtilityヘルパーにはキャッシュだけじゃなくてHTTP GETを実行するメソッドを追加してます。なので郵便番号検索なんかもこっちを呼ぶようにリファクタリングしたほうがいいね。

~/App_Code/Aws.cshtml

@using System.Text
@using System.Security.Cryptography
@using System.Web.Routing;
@using System.Xml.Linq;
@using System.Dynamic;

@functions {

    public static class AwsSetting
    {
        public static XNamespace Namespace = "http://webservices.amazon.com/AWSECommerceService/2010-11-01";
        public static readonly string Host = "ecs.amazonaws.jp";
        public static readonly string Path = "/onca/xml";
        public static readonly string Service = "AWSECommerceService";
        public static readonly string Version = "2010-11-01";

        public static readonly string AWSAccessKeyId = "アクセスキーID";
        public static readonly string SecretAccessKey = "シークレットアクセスキー";
    }

    /*
     * Percent-encode (URL Encode) according to RFC 3986 as required by Amazon.
     * 
     * This is necessary because .NET's HttpUtility.UrlEncode does not encode
     * according to the above standard. Also, .NET returns lower-case encoding
     * by default and Amazon requires upper-case encoding.
     */
    private static string PercentEncodeRfc3986(string str)
    {
        str = HttpUtility.UrlEncode(str, Encoding.UTF8);
        str = str.Replace("'", "%27").Replace("(", "%28").Replace(")", "%29").Replace("*", "%2A").Replace("!", "%21").Replace("%7e", "~").Replace("+", "%20");

        StringBuilder sbuilder = new StringBuilder(str);
        for (int i = 0; i < sbuilder.Length; i++)
        {
            if (sbuilder[i] == '%')
            {
                if (Char.IsLetter(sbuilder[i + 1]) || Char.IsLetter(sbuilder[i + 2]))
                {
                    sbuilder[i + 1] = Char.ToUpper(sbuilder[i + 1]);
                    sbuilder[i + 2] = Char.ToUpper(sbuilder[i + 2]);
                }
            }
        }
        return sbuilder.ToString();
    }

    public static string GenerateSettingQueryString(RouteValueDictionary parameters)
    {
        var pairs = new SortedDictionary<string, string> { 
            {"AWSAccessKeyId",AwsSetting.AWSAccessKeyId},
            {"Service",AwsSetting.Service},
            {"Version",AwsSetting.Version}
        };
        parameters["Timestamp"] = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.000Z");
        foreach (var kv in parameters)
            pairs.Add(kv.Key, (string)kv.Value);

        var qs = from kv in pairs
                 select string.Format("{0}={1}", kv.Key, PercentEncodeRfc3986(kv.Value));
        return string.Join("&", qs);
    }

    public static Uri GenerateUri(object parameters)
    {
        return GenerateUri(new RouteValueDictionary(parameters));
    }
    
    public static Uri GenerateUri(RouteValueDictionary parameters)
    {
        var values = GenerateSettingQueryString(parameters);
        var unsigned = string.Join("\n", new[] { "GET", AwsSetting.Host, AwsSetting.Path, values });
        
        byte[] key = Encoding.UTF8.GetBytes(AwsSetting.SecretAccessKey);
        byte[] data = Encoding.UTF8.GetBytes(unsigned);
        var hmac = new HMACSHA256(key);
        var hash = hmac.ComputeHash(data);
        var signature = PercentEncodeRfc3986(Convert.ToBase64String(hash));

        return new Uri(string.Format("http://{0}{1}?{2}&Signature={3}", AwsSetting.Host, AwsSetting.Path, values, signature));
    }

    private static dynamic DynamicExpandoObject(Action<dynamic> init)
    {
        dynamic model = new ExpandoObject();
        init(model);
        return model;
    }
    
    private static string ElementValue(XElement parent, string name)
    {
        var ns = AwsSetting.Namespace;
        if (parent == null)
            return string.Empty;

        var element = parent.Element(ns + name);
        if (element == null)
            return string.Empty;

        return element.Value;
    }
    
    public static IEnumerable<dynamic> ItemSearch(string keywords)
    {
        var uri = GenerateUri(new { 
            Operation = "ItemSearch", 
            SearchIndex = "Books", 
            Keywords = keywords 
        });
        var response = Utility.WebRequest(uri);
        var ns = AwsSetting.Namespace;
        var xml = XDocument.Parse(response).Descendants(ns + "Item");
        
        return from item in xml
               let attrs = item.Element(ns + "ItemAttributes")
               select DynamicExpandoObject(model =>
               {
                   model.ASIN = ElementValue(item,"ASIN");
                   model.DetailPageURL = ElementValue(item, "DetailPageURL");
                   model.Author = ElementValue(attrs, "Author");
                   model.Manufacturer = ElementValue(attrs, "Manufacturer");
                   model.Title = ElementValue(attrs, "Title");
               });
    }
}

サンプル実装を参考にした(というか、もうそのまま持ってきた)のはUrlEncodeするコードだけで。RFC3986に準拠させるための文字列置換だからロジックなんて無いのでいいかなと思いまして。後はちゃんとスクラッチ。無駄にスクラッチ。いや、もう、ホント、だからなに?

アクセスキーIDとシークレットアクセスキーは自分でAmazonから取得したものを使ってね!

取得の仕方にかなり戸惑ったけど、AWSに登録してアカウントページからセキュリティ証明書のページにいけば載ってたよ。APIの詳細はDocumentation Archive : Amazon Web Servicesからドキュメントダウンロードして確認しつつ作ったけど、改行コードの部分が"\n"だけでいいことに気がつくのにすげー時間かかった。

とりあえず、BooksカテゴリのItemSearch APIだけを実装してるけど、いくらでも追加しやすいように作ったから、他のAPIも実装してみるといいかも。あと今回はキャッシュも使ってないけど、キャッシュ組み込みはしたほうがいいかな~、どうかな~。同一キーワードで何度も検索する必要がほとんど無い、よね?

~/amazon.cshtml

@{  
    Layout = "~/_SiteLayout.cshtml";
    Page.Title = "Amazon書籍検索";
}
<form method="post">
    キーワード:
    @Html.TextBox("keywords")
    <input type="submit" value="検索" />
</form>

@if (IsPost)
{
    var keywords = Request["keywords"];
    <ul>
    @foreach (dynamic item in Aws.ItemSearch(keywords)) { 
        <li>
            <a href="@item.DetailPageURL">@item.Title</a>
            <div>@item.Author / @item.Manufacturer</div>
        </li>
    }
    </ul>
}

んで、表示ページのコード。特に変わったことはしてないです。

“ONE PIECE”と入れて実行したのが↓コレ。

aws

もちろんASINでも検索できる。API仕様がそうだから。

"ワンピース"で検索してみる。

aws2

ちゃんと動いてるね。よしよし。これで4 Helpers。あ、WebRequestをヘルパーにしたから5個ってことでいいっすかね。

もちろんASP.NET MVCでも動くよ。

ちなみにヘルパーはロジックが入るので、Visual Studio 2010で開発したほうが楽かも。使う分にはコピペしちゃえばいいからWebMatrixで十分かな?

今の気持ちを表現するならこれしかないね!

20100105_1650081

すません...。

2011年1月16日日曜日

@functionsと@helperの組み合わせ

いろいろ作りたい物があったりするんだけど、お試しする時間も必要でしょう。前回までで@functionsと@helperそれぞれを作ってみたんですが、今回はそれを組み合わせたサンプル。

もちろんASP.NET MVC3でもWebMatrixでも動くわけです。

最初に作ったキャッシュヘルパーにHttpContextBaseを渡さない版のオーバーロードを用意したので、まずはそちらを再掲。

~/App_Code/Utility.cshtml

@functions {
    public static new dynamic Cache(string key, int expireSeconds, Func<dynamic> thunk){
        var context = new HttpContextWrapper(HttpContext.Current);
        return Cache(context, key, expireSeconds, thunk);
    }
    
    public static new dynamic Cache(HttpContextBase context, string key, int expireSeconds, Func<dynamic> thunk){
        dynamic value = context.Cache[key];
        if(value == null) {
            value = thunk();
            context.Cache.Insert(
                key,
                value,
                null,
                DateTime.Now.AddSeconds(expireSeconds),
                System.Web.Caching.Cache.NoSlidingExpiration
            );
        }
        
        return value;
    }
}

続いて、このキャッシュヘルパーを使うものを考えてみたんだけど、郵便番号検索なんてどうかな~。

ゆうびんホームページ - 日本郵便

↑ここにある検索フォームを使って、返ってくるHTMLを解析だ!なんてことをやろうとするとちょっとコード量もかさばる(正規表現でtd class=”data”とればいいだけなんだけど)し、今風じゃない。とてもグレートな郵便番号検索サービスを見つけたのでそちらを使ってみます。

郵便番号データのダウンロード – zipcloud

Google App Engine上にホストされてて、日本郵便のデータを綺麗に正規化してくれてるグレートサービスです。一応規約はチェックしてみたけど、使ってみる人も確認してみてね!エロとかグロとかダメだからね!

ここのサービス実際にはJSONPでも取得できるのでクライアントサイドのテクノロジだけで見事に完結してしまうわけですが、そこをあえてサーバーサイドで行ってみよう(今風じゃないね...)。

~/App_Code/ZipCroud.cshtml

@functions{    
    private readonly static string _api = "http://zipcloud.ibsnet.co.jp/api/search?zipcode=";
    public static dynamic Search(string zipCode){
        var key = string.Format("zipcroud:{0}",zipCode);
        var expire = 60 * 60 * 24; // 1日
        return Utility.Cache(key,expire,()=>{
            var url = string.Format("{0}{1}",_api, zipCode);
            string response;
            using (var client = new WebClient()){
                client.Encoding = System.Text.Encoding.UTF8;
                response = client.DownloadString(url);
            }
            return Json.Decode(response);
        });
    }
}

@helper Render(string zipCode){
    var sw = new System.Diagnostics.Stopwatch();
    sw.Start();
    var result = ZipCroud.Search(zipCode);
    sw.Stop();
    <div>@zipCode<text>の検索結果(@sw.ElapsedMilliseconds ms)</text></div>
    if (result == null || result.status != 200 || result.results == null) {
        @:見つかりませんでした。
    } else {
        <ul>
        @foreach(var zip in result.results){
            <li>@zip.address1 @zip.address2 @zip.address3</li>
        }
        </ul>
    }
}

とにかく↑これをコピペしてZipCroud.cshtmlをApp_Codeフォルダ配下に作成だぜ!

データを取得するSearch(こっちが@functions)と、レンダリングするRender(こっちが@helper)。要するに表示に関するものの場合は@helperにしとけばよろしっていう感じです(んじゃApp_Codeに入れるなよ、っていうツッコミは勘弁してね)。

Json.Decodeヘルパーとか超絶便利なヘルパーが標準搭載されてるので、楽チンぽんですよ。

続いてページの方。こっちはもちろんWebMatrixを使ってWebPagesとして作ります。

~/zip.cshtml

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8" />
        <title>郵便番号検索</title>
    </head>
    <body>
    郵便番号を入れてみてね!
    <form action="zip" method="post">
        <input type="text" name="zipCode" />
        <input type="submit" value="検索" />
    </form>
    @if(IsPost){
        var zipCode = Request.Form["zipCode"];
        @ZipCroud.Render(zipCode)
    }
    </body>
</html>

IsPostとかRequest["zipCode"]っていう部分が気になるけど、まぁ、いいでしょう。MVCの時はちゃんとModelBindしてね。

zip1

起動するとそっけないページが出てきました。

zip2

例えば"0288401"が複数の住所を保持しているらしいので、これを入力した結果が↑これ。

いっぱい出てきた。

ここからキャッシュヘルパーの威力を発揮します。画像拡大してみると分かるんだけど、検索結果の横に634msと表示されてるのが分かると思います。これは、まぁ、だいたいブレるんでこの値そのものにはそれほど重要な意味はないんですよ。なんとなく3桁ミリ秒くらいかかるんだね、っていうのが伝わればそれで良し。

ここのページはPOSTで値を送信してるので、F5を押すか同一の郵便番号を押して再度検索してみましょう。

zip3

今度は驚異の1ms!

そりゃそうですよ。なんせ実際には検索しに行かないでキャッシュから結果を取り出してるだけなんだから。今回はちなみに1日ほどキャッシュするようにしてみました。実際にはAppDomainの再起動なんかでもキャッシュはクリアされるので、こんなもんでしょう。郵便番号の値なのでそれほど頻繁に更新されるものでもないしね!

郵便番号毎にキャッシュするようにしてるので、一度検索してしまうと2回目以降はちょっぱや。

そして、ヘルパー三連投するオレ。つまりこういうことだよ。

20100609_1842417

2011年1月3日月曜日

Razor Hosting

Hosting the Razor Engine for Templating in Non-Web Applications - Rick Strahl's Web Log

いや~、参っちゃった。年末にRazor Templating Engine出てたじゃないですか。で、Instapaper見てみたらこっちも”後で読む”扱いになってたので、そろそろ読むかと読んでみたわけですよ。

そしたらこっちはハナからAppDomainを別にしてコンパイルするのを前提に作られてるじゃない。もちろんプロセスのAppDomainと同じところでコンパイルも出来ますよ。その辺はぬかりないデスヨ。同じ目的でもこんなにも設計が違うんだね~、っていう感じです。ちなみにRickさんのRazor HostingではVB.NETコンパイラは入ってないです。

はい、Hello World!

var engine = new RazorEngine<RazorTemplateBase>();
dynamic context = new ExpandoObject();
context.Name = "World!";
var result = engine.RenderTemplate("Hello @Context.Name", new string[] { }, context);

匿名クラスじゃダメだったからdynamicなExpandoObjectで。まぁ、その辺は適当でね。

続いて、HtmlHelperを使うようにして見ましょう!RTE(Razor Templating Engine)にはちゃんとサンプルがあったからそのまま簡単にできたけど、今回は無いのでしょうがないから自分で書く。と、言ってもRTEのをままもってきて、RazorEngineのジェネリックに基底クラスとして指定です。この辺ちょっとあれっすね。まぁ、いいや。

internal class HtmlHelperFactory
{
    #region Methods
    /// <summary>
    /// Creates a <see cref="HtmlHelper{T}"/> for the specified model.
    /// </summary>
    /// <typeparam name="T">The model type.</typeparam>
    /// <param name="model">The model to create a helper for.</param>
    /// <param name="writer">The writer used to output html.</param>
    /// <returns>An instance of <see cref="HtmlHelper{T}"/>.</returns>
    public HtmlHelper<T> CreateHtmlHelper<T>(T model, TextWriter writer)
    {
        var container = new InternalViewDataContainer<T>(model);
        var context = new ViewContext(
            new ControllerContext(),
            new InternalView(),
            container.ViewData,
            new TempDataDictionary(),
            writer);

        return new HtmlHelper<T>(context, container);
    }
    #endregion

    #region Types
    /// <summary>
    /// Defines an internal view.
    /// </summary>
    private class InternalView : IView
    {
        #region Methods
        /// <summary>
        /// Renders the contents of the view to the specified writer.
        /// </summary>
        /// <param name="context">The current View context.</param>
        /// <param name="writer">The writer used to generate the output.</param>
        public void Render(ViewContext context, TextWriter writer) { }
        #endregion
    }

    /// <summary>
    /// Defines an internal view data container.
    /// </summary>
    private class InternalViewDataContainer<T> : IViewDataContainer
    {
        #region Methods
        /// <summary>
        /// Initialises a new instance of <see cref="InternalViewDataContainer{T}"/>.
        /// </summary>
        public InternalViewDataContainer(T model)
        {
            ViewData = new ViewDataDictionary<T>(model);
        }
        #endregion

        #region Properties
        /// <summary>
        /// Gets or sets the view data dictionary.
        /// </summary>
        public ViewDataDictionary ViewData { get; set; }
        #endregion
    }
    #endregion
}

/// <summary>
/// Provides a base implementation of a template base that supports <see cref="HtmlHelper{T}"/>s.
/// </summary>
/// <typeparam name="T">The model type.</typeparam>
[RequireNamespaces("System.Web.Mvc.Html")]
public class HtmlRazorTemplateBase<T> : RazorTemplateBase
{
    private readonly HtmlHelperFactory factory = new HtmlHelperFactory();

    /// <summary>
    /// Initialises a new instance of <see cref="HtmlTemplateBase{T}"/>.
    /// </summary>
    public HtmlRazorTemplateBase()
    {
        CreateHelper(Context);
    }
    /// <summary>
    /// Gets the <see cref="HtmlHelper{T}"/> for this template.
    /// </summary>
    public HtmlHelper<T> Html { get; private set; }

    private void CreateHelper(T model)
    {
        Html = factory.CreateHtmlHelper(model, Response.Writer);
    }
}

そのままですみませんね!

後は、テンプレートを書いてテイッ!と実行。

static string Template =
@"
@using System.Web.Mvc
@using System.Web.Mvc.Html
Hello @Context.Name
@AppDomain.CurrentDomain.FriendlyName
Email: @Html.TextBoxFor(m => m.Email)";

public void ExecuteParse()
{
    ExecuteParse(-1);
}

public void ExecuteParse(int counter)
{
    var model = new PageModel
                    {
                        Name = "World", 
                        Email = "someone@somewhere.com"
                    };
    var asms = AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic ).Select(a=>a.Location);
    asms = asms.Concat(typeof(System.Web.Mvc.HtmlHelper)
                        .Assembly
                        .GetReferencedAssemblies().Select(an => Assembly.Load(an.FullName).Location)).Distinct();
    var refs = asms.Where(a => !a.EndsWith("System.dll")
                    && !a.EndsWith("System.Core.dll")
                    && !a.EndsWith("Microsoft.CSharp.dll")).ToArray();

    for (var i = 0; i < (counter != -1 ? 1 : Repeat); i++)
    {
        var sw = new Stopwatch();
        sw.Start();
        var engine = new RazorEngine<HtmlRazorTemplateBase<PageModel>>();
        var result = engine.RenderTemplate(Template, refs, model);

        sw.Stop();

        Console.WriteLine(result);
        Console.WriteLine(
            "{0} - {1} {2} ms {3:0,###} bytes",
            AppDomain.CurrentDomain.FriendlyName,
            counter != -1 ? counter : i,
            sw.ElapsedMilliseconds,
            Process.GetCurrentProcess().PrivateMemorySize64);
    }
}

なんですが、ここでかなりはまりました。

Template Execution Error: Security Transparent メソッド 'System.Web.Mvc.TypeDescriptorHelper.Get(System.Type)' がセキュリティ上重要なメソッド 'System.ComponentModel.DataAnnotations.AssociatedMetadataTypeTypeDescriptionProvider..ctor(System.Type)' にアクセスしようとして失敗しました。

アセンブリ 'System.ComponentModel.DataAnnotations, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35' は条件付きの APTCA アセンブリであり、現在の AppDomain では有効化されていません。このアセンブリを有効化して部分信頼コードまたは Security Transparent コードで使用できるようにするには、AppDomain の作成時に、アセンブリ名 'System.ComponentModel.DataAnnotations, PublicKey=0024000004800000940000000602000000240000525341310004000001000100B5FC90E7027F67871E773A8FDE8938C81DD402BA65B9201D60593E96C492651E889CC13F1415EBB53FAC1131AE0BD333C5EE6021672D9718EA31A8AEBD0DA0072F25D87DBA6FC90FFD598ED4DA35E44C398C454307E8E33B8426143DAEC9F596836F97C8F74750E5975C64E2189F45DEF46B2A2B1247ADC3652BF5C308055DA9' を PartialTrustVisibleAssemblies リストに追加してください。

なんすかこのエラー。コエー。入っちゃいけない領域に入っちゃった系のエラーなんじゃ...。

ASP.NET アプリケーションでコード アクセス セキュリティを使用します。

なんかDataAnnotationsも含まれてるね~。透過的モデルに移行したアセンブリ。

だいたいが

[assembly: System.Security.AllowPartiallyTrustedCallers()]

これ書けって書いてるんだけど、これ何さ。ってなもんで、いろいろよくわかんない。そもそもRTEでは動くんだから何か違いが有るんだろうと、参照アセンブリとかいろいろ比較してみたわけですよ。

そしたらですね、ちゃんと違いがありました。

Razor Templating Engine

↑ここに行って、RazorEngine.Templates.csprojを見てみてください。

rh1

衝撃!分かります?普通さ~、Razorっつったら、MVC3を想定するじゃないっすか。だから、HtmlHelper使うために参照するアセンブリもMVC3のものにしちゃうじゃないっすか。そこだった...。ショック。MVC2で動かしてやがった。なんか意味があるんだろうね。いつかちゃんと調べるとしてですね、とりあえずRazor Hostingの場合もMVC2のアセンブリを参照してHtmlHelperを呼び出すとちゃんとできた。

rh2

はぁ...。ぜんぜんわかんなかった。いや、今でも理由はよく分からない。ATPCAってなにさ。部分信頼ってなんだね。AllowPartiallyTrustedCallersつけたら↓こう。

rh3

型 'ConsoleApplication1.HtmlRazorTemplateBase`1<T>' で継承セキュリティ ルールの違反が発生しました。派生型は、基本型のセキュリティ アクセシビリティと一致するか、それより低いアクセスが設定されている必要があります。

CASが廃止。.NET 4のセキュリティはどうなるのか? - @IT

ふんふん、そっか!なるほど!

...

よし、がんばろう。

で、コンパイル済みのものを呼び出すときは

var engine = new RazorEngine<HtmlRazorTemplateBase<PageModel>>();
var compiledId = engine.ParseAndCompileTemplate(refs, new StringReader(Template));

var result = engine.RenderTemplateFromAssembly(compiledId, model);

こうね。はいはい。

続いて、違うAppDomainで実行するときはこう。

var host = new RazorStringHostContainer<HtmlRazorTemplateBase<PageModel>>
{
    UseAppDomain = true,
    ReferencedAssemblies = refs.ToList()
};

host.Start();
var result = host.RenderTemplate(Template, model);
host.Stop();

ちゃんとHost.Start()とStop()しようね。これがCreateAppDomainとUnload。

ちなみにこのコードはこのままじゃRazor Hostingでは動きません。何でかというと、RazirStringHostContainerはジェネリックじゃないから。基底クラスの指定方法がHost派生しかなかったから、それじゃ面倒なので、RazorStringHostContainer自体を書き換えてます。

といっても、定義だけ。

    public class RazorStringHostContainer<T> : RazorBaseHostContainer<T>
        where T : RazorTemplateBase, new()
    {        

rh4

ちょいちょいAppDomainを生成するのはやっぱり遅すぎ。もうちょっと賢くやんないとね。

と、言うわけでRazorのテンプレートエンジン2作品を取り上げてみたけど、HtmlHelperを使ったりするようにWebで使う場合はAPTCAのところをちゃんとクリアしてからじゃないとダメですね。

ここまで書いてて気がついたんだけど、MVC3ってどうやってるのか見るといいのかな。BuildManagerだと違ったりするのかな。うぬ~。なぞは深まるばかり。

2010年12月29日水曜日

Razor Templating Engine

Razor Templating Engine

CodePlexで新しいのが出てたよ~。面白いね~。テンプレートエンジンっていろいろ用途があるけど需要はどーなんだろね。まぁ、いいや。Razor記法でWeb以外にも使えるって楽しいっすよね。だっていろいろ覚えなくて済むし。ちなみにASP.NETの<%...%>とか<%$…%>とか<%#…%>なんかの記法も用途特化したテンプレートエンジンが解釈してクラスファイルを生成してますよね。

んでね、Razorは純粋にテンプレートエンジンって言ってるくらいだからWebのコンテキストや実行Hostに依存しないわけですね。つまりConsoleアプリでも使えるってことです。もちろんHtmlHelperとかWeb関連のアセンブリに依存するものはあるとしても、それ自体はRazorの機能じゃなくてHelperの機能。TemplateBaseクラスが生成されるクラスの親クラスになるって言う仕組みなので、親クラスにいろいろWeb関連の機能を保持するっていう、Razorとは直接関係なっしんぐ。

サンプル見てみるとこんな感じですね。

rte1

string template = "Hello @Model.Name! Welcome to Razor!";
string result = Razor.Parse(template, new { Name = "World" });

Console.WriteLine(result);
Console.ReadLine();

簡単ですね~。お気楽です。

同じくサンプルのHtmlHelperを使うパターン。

rte2

Razor.SetTemplateBaseType(typeof(HtmlTemplateBase<>));
string template =
@"<html>
    <head>
    <title>Hello @Model.Name</title>
    </head>
    <body>
    Email: @Html.TextBoxFor(m => m.Email)
    </body>
</html>";

var model = new PageModel { Name = "World", Email = "someone@somewhere.com" };
string result = Razor.Parse(template, model);

Console.WriteLine(result);
Console.ReadLine();

はい、これも簡単ですね。イメージ通りです。

PageModelは自分で作ってね。あと、RazorEngine.Coreだけじゃ足りなくて、RazorEngine.Templatesアセンブリも必要なので、ソースのダウンロードはここからどーぞ。

これらは全て実行時にクラスファイル(特に指定が無い限りc#)をオンメモリで生成してコンパイルします。そのタイミングでAppDomain内からはクラスのインスタンスを生成できるようになるって言う流れはASP.NETのパイプラインそのもの。

試しに最初のサンプル(Hello World!のほう)をステップ実行していくと、どういうクラス名で生成されるのかというのを追いかけていくと...。

rte3

雑ですね。Guid.NewGuid()から数字とハイフンを除外したのがクラス名。それにNamespaceをくっつけてます。で確認。

rte4

ちゃんといます。で、AppDomainの仕様を見てみるとこんなことがかかれてます。

既定のアプリケーション ドメインにロードされたアセンブリは、プロセスの実行中にメモリからアンロードすることはできません。 しかし、アプリケーション ドメインをもう 1 つ開いてアセンブリをロードおよび実行すると、そのアプリケーション ドメインがアンロードされたときに、アセンブリもアンロードされます。 このテクニックを使用すると、大きな DLL を使用する場合のある、長時間実行されるプロセスのワーキング セットを最小限に抑えることができます。

AppDomain クラス (System)

ん~、どーだろ。どうなるんだろね。

rte6

rte5

めちゃめちゃ分かりにくいですね。2個目のサンプル(HtmlHelper使うほう)を使ってRazor.Parseを繰り返してる途中です。

Razor.SetTemplateBaseType(typeof(HtmlTemplateBase<>));
var model = new PageModel { Name = "World", Email = "someone@somewhere.com" };

for (var i = 0; i < (singleExec ? 1 : Repeat); i++)
{
    var sw = new Stopwatch();
    sw.Start();
    var result = Razor.Parse(Template, model);
    sw.Stop();

    Console.WriteLine(
        "{0} - {1} {2} ms {3:0,###} bytes",
        result.GetHashCode(),
        i,
        sw.ElapsedMilliseconds,
        Process.GetCurrentProcess().PrivateMemorySize64);
}

少しずつメモリ使用量と動作時間が長くなっていきます。最初は200msとかで処理。すごく単純で1種類だけの生成なのにね。ちなみに一度コンパイルしたものをその後何度も使用する場合は超早い。

rte8

rte7

10000回実行してもすぐ終了。

これってつまり、ASP.NETでも同じでaspxを何度も書き換えてると処理時間はどんどん増加。いつか破綻?でもリサイクルも走るし、そもそも運用環境でそんなことしないから問題にはならないか。

AppDomainをアンロードしない限りアセンブリもアンロードされないってことはつまり常時動いてるようなシステムで頻繁にコンパイルを繰り返すのはよろしくないね!ってなりましょう。それをひっくり返すのがAppDomainを別に作ってそっちで動かす方法。

リファレンスにもちゃんとかかれてますね。ボスもタイムリーに(MEFだけど)、そんなことをブログに書いてました。

MEFでディレクトリカタログを追いかける(C# Advent Calender jp:2010 12/02) « kazuk は null に触れてしまった

なのでAppDomainを作って実行するように試してみた。

rte9

けど無理!遅すぎ!

var sw = new Stopwatch();
sw.Start();

AppDomainSetup ads = new AppDomainSetup();
ads.ApplicationBase = Environment.CurrentDirectory;
ads.DisallowBindingRedirects = false;
ads.DisallowCodeDownload = true;
ads.ConfigurationFile = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;

AppDomain executeAppDomain = AppDomain.CreateDomain("AD#" + i, null, ads);
var razor = executeAppDomain.CreateInstanceAndUnwrap(
    Assembly.GetEntryAssembly().FullName,
    typeof(RazorTemplateTest).FullName
) as RazorTemplateTest;

razor.ExecuteParse(i);

AppDomain.Unload(executeAppDomain);

sw.Stop();

Console.WriteLine(" {0} : {1} ms",
    AppDomain.CurrentDomain.FriendlyName,
    sw.ElapsedMilliseconds);

でも、まぁ、思ったような結果が出なかった残念な調査でした。ホントはもっとメモリ開放されないことを想定してたんだけどな~。

オチがない...。

using System;
using System.Diagnostics;
using System.Reflection;
using RazorEngine;
using RazorEngine.Templating;

namespace ConsoleApplication1
{
    public class PageModel
    {
        public string Name { get; set; }
        public string Email { get; set; }
    }

    public class RazorTemplateTest : MarshalByRefObject
    {
        private static int Repeat = 10000;
        static string Template =
@"<html>
    <head>
        <title>Hello @Model.Name</title>
    </head>
    <body>
        Email: @Html.TextBoxFor(m => m.Email)
    </body>
</html>";

        public void ExecuteParse()
        {
            ExecuteParse(-1);
        }

        public void ExecuteParse(int counter)
        {
            Razor.SetTemplateBaseType(typeof(HtmlTemplateBase<>));
            var model = new PageModel { Name = "World", Email = "someone@somewhere.com" };

            for (var i = 0; i < (counter!=-1 ? 1 : Repeat); i++)
            {
                var sw = new Stopwatch();
                sw.Start();
                var result = Razor.Parse(Template, model);
                sw.Stop();

                Console.WriteLine(
                    "{0} - {1} {2} ms {3:0,###} bytes",
                    AppDomain.CurrentDomain.FriendlyName,
                    counter != -1 ? counter : i,
                    sw.ElapsedMilliseconds,
                    Process.GetCurrentProcess().PrivateMemorySize64);
            }
        }

        public void ExecuteCompile()
        {
            Razor.SetTemplateBaseType(typeof(HtmlTemplateBase<>));
            var model = new PageModel { Name = "World", Email = "someone@somewhere.com" };

            Razor.Compile(Template, typeof(PageModel), "test");

            for (var i = 0; i < Repeat; i++)
            {
                var sw = new Stopwatch();
                sw.Start();

                var result = Razor.Run(model, "test");

                sw.Stop();

                Console.WriteLine(
                    "{0} - {1} {2} ms {3:0,###} bytes",
                    result.GetHashCode(),
                    i,
                    sw.ElapsedMilliseconds,
                    Process.GetCurrentProcess().PrivateMemorySize64);
            }
        }

        public void ExecuteAnotherAppDomain()
        {
            for (var i = 0; i < Repeat; i++)
            {
                var sw = new Stopwatch();
                sw.Start();

                AppDomainSetup ads = new AppDomainSetup();
                ads.ApplicationBase = Environment.CurrentDirectory;
                ads.DisallowBindingRedirects = false;
                ads.DisallowCodeDownload = true;
                ads.ConfigurationFile = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;

                AppDomain executeAppDomain = AppDomain.CreateDomain("AD#" + i, null, ads);
                var razor = executeAppDomain.CreateInstanceAndUnwrap(
                    Assembly.GetEntryAssembly().FullName,
                    typeof(RazorTemplateTest).FullName
                ) as RazorTemplateTest;

                razor.ExecuteParse(i);
                
                AppDomain.Unload(executeAppDomain);

                sw.Stop();

                Console.WriteLine(" {0} : {1} ms",
                    AppDomain.CurrentDomain.FriendlyName,
                    sw.ElapsedMilliseconds);
            }
        }
    }

    class Program
    {

        static void Main(string[] args)
        {
            //new RazorTemplateTest().ExecuteParse();
            //new RazorTemplateTest().ExecuteCompile();
            new RazorTemplateTest().ExecuteAnotherAppDomain();

            Console.ReadLine();
        }
    }
}

こんなコードで~す。

2010年12月18日土曜日

Razor Helper的なForEachでテンプレート

今年も終わりですね。タイトル意味分かんないですね。

来週から夏休み&年末年始休暇に入るので、今年の仕事終了です。てへ。ブログサイトがあるのをすっかり忘れるくらい追い込まれる毎日。よくもまぁ、こんな状態が続くもんだと感心するばかりです。え?きれてねーよ。

そんなことはいいんですけどね、ちょっとここ見てみてくださいよ。

Razor, Nested Layouts and Redefined Sections - Marcin On ASP.NET - Site Home - MSDN Blogs

ナイスエントリーにも程があるデスヨ。

Layout用のビューを直接指している、子供ビューのセクションはみえるけど、多段Layoutになってて孫ビューのセクションはスコープの範囲外でエラーになっちゃうっていうエントリー。

実際に試してみたらたしかに動かない。Section not defined。そ

foreach

りゃそうだ。それを克服するために中間LayoutでRedefineSectionで孫のセクションを生成(WebPageBase.DefineSection)してしまおうよっていう強引さには度肝を抜かれますがなによりそこじゃないんです。

その部分より何よりセクション初期値をヘルパーに渡す部分ですよ!

Func<object, HelperResult> defaultContent

これを知っておくと何が出来るかというと...

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}
@helper ForEach(IEnumerable<int> values, Func<object, HelperResult> template){
    var partialHtml = new System.Text.StringBuilder();
    foreach(var value in values){
        partialHtml.Append( template(value));
    }

    Write(MvcHtmlString.Create(partialHtml.ToString()));
}
<div>
<ul>
@ForEach(Enumerable.Range(1,5),
    @<li>@item</li>
)
</ul>
</div>

↑こんなコード書けるってことですよ。ヘルパーにRazor構文のテンプレートを渡してるんですよ。いいでしょ?ん~、もんどりうつ。

追記:もっと簡単でよかった。

@helper ForEach(IEnumerable<int> items, Func<object, HelperResult> template){
    foreach(var item in items){
        Write(template(item));
    }
}

@helperだとGenericメソッドに出来なさそうなので、もうちょっと調べてみたら、普通に@functionsで実装するサンプルをAndrew君が...。

VibrantCode - Inside Razor - Part 3 – Templates

この場合は↓こうね。

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}
@functions{
    static IHtmlString ForEachGeneric<T>(IEnumerable<T> items, Func<T, HelperResult> template)
    {
        var sb = new System.Text.StringBuilder();
        foreach(var item in items){
            sb.Append(template(item));
        }
        
        return MvcHtmlString.Create(sb.ToString());
    }
}

<div>
<ul>
@(ForEachGeneric<int>(Enumerable.Range(1,5),
    @<li>@item</li>
))
</ul>
</div>

 

foreach2

小っちゃ!
クリックして拡大してみてね。

Razorのコンパイル結果を見てみると分かるけど、ヘルパーに渡すテンプレートブロックにはobject型のitemが必ずわたりまする。

dotnetConf2015 Japan

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