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

2009年7月4日土曜日

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を生成しやすくするためですよね。内部で独自クラスに派生させたコントローラを生成し、アクションをオーバーライドしてるんですね。なんとも強引な方法。コントローラ書き換えられるのが気持ち悪い!って人にはお勧めしないですけど...。

お試しあれ!

2009年6月7日日曜日

Json.NETとStringTemplateでお気楽HTML出力

暇だったんですよね。で、暇つぶしにJSONからStringTemplateを通してHTMLをはき出させてみたんです。何となくですけど、適当にデータを定義しておいて、テンプレートに当てはめてHTMLを出力するっていうツールがないものかと探してみたんだけど、どーにも楽ちんそうなのが見あたらなくて。あ、ちなみに5月の話です。

データはXMLでも良かったんだけど、書くのが面倒になるのもやだし、テンプレート解釈とかは作りたくないし、ってことで、Json.NET - James Newton-KingStringTemplate Template Engineで書いてみたっす。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Antlr.StringTemplate;
using System.IO;
using Newtonsoft.Json.Linq;

namespace STHtml
{
  public class STGenerator
  {
    private const string INPUT_PATH = "input";
    private const string ROOT_PROPERTY = "templateItems";
    private const string FILENAME_PROPERTY = "output_path";
    private const string FILENAME_FORMAT = "output{0}.txt";

    private STSetting _setting;

    public STGenerator(STSetting setting)
    {
      _setting = setting;
    }

    public void Execute(IOutput output)
    {
      var group = new StringTemplateGroup("htmlTemplates", INPUT_PATH);
      var json = LoadJSON();

      if (json == null)
        return;

      // 
      var list = (from item in json[ROOT_PROPERTY]
                  select item).ToList();

      foreach (var item in list)
      {
        // テンプレートの読み込み
        var st = group.GetInstanceOf(_setting.TemplateFileName);

        // ファイル名は?
        var filePath = GetOutputFilePath(item, list.IndexOf(item));

        // 生成
        var generated = GenerateFromJSON(st, item);
        if (generated == null)
          return;

        // 出力
        output.Print(filePath, generated);
        Console.WriteLine("output -> " + filePath);

      }
      Console.WriteLine("{0} file(s) template generated.", list.Count());
    }

    private string GetOutputFilePath(JToken item, int itemIndex)
    {
      // ファイル名は?
      var fileName = (from t in item
                      select item[FILENAME_PROPERTY]).FirstOrDefault();
      var filePath = Path.Combine(_setting.OutputPath,
        fileName != null ?
          fileName.ToString().Replace("\"", "") :
          string.Format(FILENAME_FORMAT, itemIndex)
      );

      return filePath;
    }

    private JToken LoadJSON()
    {
      string jsonText;
      JObject json = null;

      try
      {

        jsonText = File.ReadAllText(Path.Combine(INPUT_PATH, _setting.JsonFileName));
        json = JObject.Parse(jsonText);
      }
      catch (Exception e)
      {
        Console.WriteLine("データファイルの書式が間違ってるか、ファイルがないか...");
        Console.WriteLine(e.Message);
        json = null;
      }

      return json;
    }

    private string GenerateFromJSON(StringTemplate st, JToken json)
    {
      try
      {
        // JsonからDictionaryに変換
        // JObjectからそのままはStringTemplateが無理さ
        var dict = JsonToDictionary(json) as Dictionary<string, object>;
        foreach (var kv in dict)
        {
          st.SetAttribute(kv.Key, kv.Value);
        }
      }
      catch (Exception e)
      {
        Console.WriteLine("変換に失敗したよ。テンプレートがおかしいと思われる。");
        Console.WriteLine(e.Message);
      }

      return st.ToString();
    }

    private object JsonToDictionary(JToken token)
    {
      Dictionary<string, object> result = new Dictionary<string, object>();

      // なんか美しくないね。
      // 値セットと値戻しが同列だしな~。まぁ、いっか。
      foreach (var node in token)
      {
        if (node is JProperty)
        {
          // プロパティ型(name:value)なら取得
          var prop = node as JProperty;
          // 配列([...])かオブジェクト({...})なら再帰
          if (prop.Value.Type == JsonTokenType.Array ||
              prop.Value.Type == JsonTokenType.Object)
          {
            result[prop.Name] = JsonToDictionary(prop);
          }
          else
          {
            // その他の値型なら文字列化
            var value = prop.Value.ToString()
                                  .Replace("\"", "");

            result[prop.Name] = value;
          }
        }
        else if (node is JArray)
        {
          // 配列型なら戻す(再帰の時にしか処理しないもん)
          var arr = node as JArray;
          var list = new List<object>();
          foreach (var item in arr)
          {
            list.Add(JsonToDictionary(item as JToken));
          }
          return list;
        }
      }

      return result;
    }
  }
}

説明するのが面倒なんでソースです。こんな適当な感じでもそれなりに動くよ~。JsonからHTMLへの埋め込み時にEncodeかかってないからその辺は気をつけましょう。

使い方なんですけど(使う人がいるとは思えないけど)。

まずはinputフォルダのdata.jsonファイルにデータを書き込んでいきます。例えば↓こんな感じです。

{templateItems:
  [
    {
      output_path:'test1.html',
      title:'ページ1',
      subject:'うぎょぎょ<br />ぼへ',
      gallery:[
        {src:'1.jpg', alt:'', title:'説明文を書きましょう'},
        {src:'2.jpg', alt:'', title:'説明文を書きましょう'},
        {src:'3.jpg', alt:'', title:'説明文を書きましょう'}
      ]
    },
    {
      output_path:'test2.html',
      title:'ページ2',
      subject:'うぎょぎょ<br />ぼへ',
      gallery:[
        {src:'1.jpg', alt:'', title:'説明文を書きましょう'}
      ]
    },
    {
      output_path:'test3.html',
      title:'ページ3',
      subject:'うぎょぎょ<br />ぼへ',
      gallery:[
        {src:'1.jpg', alt:'', title:'説明文を書きましょう'},
        {src:'2.jpg', alt:'', title:'説明文を書きましょう'}
      ]
    }

  ]
}

全体が一つのObjectです。で、ルートではtemplateItemsっていう名前の配列を定義がルールです。templateItems配列に入れる各オブジェクト(アイテムオブジェクト)の書式は気をつけて統一する必要あり。こんな時にJSONスキーマが役立つんだろうな~。面倒なので気をつけるっていうルールで。アイテムオブジェクトはどんな形式でもOK、のはず。最初に提示したソースのJsonToDictionary関数が再帰でその辺上手くやってくれるようにしてます。でも、JArray型とJProperty型以外は想定してないので、まぁ、その辺は雰囲気で。

あ、アイテムオブジェクトに絶対に入れなきゃ行けないのが出力ファイル名をセットしたoutput_path。上記の例だとoutputフォルダにそのまま出す感じなんですが、例えば"output_path:’folder1/default.html’"とかってしておくと、outputフォルダ内にfolder1フォルダを作成して、その中にdefault.htmlを出力します。

次にテンプレートファイルとして↓こんなのを用意。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  <meta name="keywords" content="">
  <title>$title$</title>
</head>
<body>
<h1>$title$</h1>
<div>
  <h2>$subject$</h2>
  <ul>
    $gallery:{g|
    <li><img src="$g.src$" alt="$g.alt$" title="$g.title$" /></li>
    }$
  </ul>
</div>
</body>
</html>

後は実行するだけ。そうすると↓こんなのが出力されました。

sthtml1 sthtml2

またしてもプロジェクトファイルは添付しておくのでご自由に。

2009年5月26日火曜日

UnityとEntityのAttach

第2回 スキャフォールディング機能で軽々DB連携アプリケーション - @IT

まだ2回目だけど、楽しみにしてる連載です。今回の記事で少し気になる部分があったので、実際に動くコードを書いてみました。暇人...。いやいや、お勉強デス!

その気になる部分っていうのが、ページ5の「既存のレコードを変更したい今回のようなケースでは、UpdateModelメソッドを使用する必要がある。」という部分。いや、もちろん、ModelBinderのみでDBに保存なんてしないし、これが間違ってるという分けじゃないけど、単純に出来る出来ないという話なら出来るっていう話です。

EntityKey and ApplyPropertyChanges() - Stack Overflow

なんだかんだと、実際にコードを書いてみたわけじゃなく、たんなる耳年増なだけだと、ちょっとカッコ悪し。

efunity2

Home/Indexが一覧。Editを用意。

efunity3

"Chai"を"Chai XXX"に編集。

efunity4

ちゃんと保存されました。

↓これがObjectContextにアタッチされていないエンティティを使って、DB更新してみるコード。

   public void Save<TEntity>(TEntity entity, Func<TEntity, TEntity> setIDFunc) 
where TEntity : new() { var entitySetName = _dataContext.DefaultContainerName + "." + EntitySetName(typeof(TEntity)); setIDFunc(entity); // 空のエンティティをアタッチしておいて、更新情報はクリア _dataContext.AttachTo(entitySetName, setIDFunc(new TEntity())); _dataContext.AcceptAllChanges(); _dataContext.ApplyPropertyChanges(entitySetName, entity); }

オブジェクトのアタッチ (Entity Framework)

AttachToでObjectContextに空のエンティティをアタッチしておいて、ModelBinderで復元したエンティティの値をApplyPropertyChangesで反映させる流れです。ObjectStateManagerを使うともっと綺麗にできるのかも?

↑このコードはRepositoryクラスに。コレを呼び出すControllerコードは↓。

    [AcceptVerbs(HttpVerbs.Post)]
    public ActionResult Edit(int id, Product entity)
    {
      if (ModelState.IsValid)
      {
        _repository.Save<Product>(entity, e =>
        {
          e.ProductID = id;
          return e;
        });
        _repository.SaveChanges();

        return RedirectToAction("Index");
      }

      return View(entity);
    }

認証、認可、入力検証ははしょってるんですが、何となく雰囲気は伝わるかな~、と思います。データベースはNorthwindで、Entity Frameworkを使ってProducts/Categoriesだけのエンティティクラスを作成してます。

efunity

HomeControllerのIndexが一覧ページになるようにしておいて、Editを追加しただけのプロジェクトで試してます。今回はコレに加えてUnityを使ってDIでObjectContextをRepositoryにコンストラクタインジェクションと、RepositoryをControllerにコンストラクタインジェクションさせるようにしてみました。

Unityを使ったControllerFactoryなんかはMvcContribにもコードがあったりするので、そちらを参考にするのが近道です。

でもMvcContribのUnityControllerFactoryはLifetimeManagerを指定しないので、Resolveの度に新しいインスタンスを作ります(TransientLifetimeManager)。HttpContextにObjectContextを入れておいて、同じコンテキストなら無駄使いしないようにするためのHttpContextLifetimeManagerクラスを用意。「ASP.NET MVC Tip: Dependency Injection with Unity Application Block - Shiju Varghese's Blog」に書かれてる、HttpContextLifetimeManagerを使わせてもらいました。Get/Set/RemoveのoverrideでHttpContextを使うようにしてるだけですね。

ObjectContextのインスタンスがどうなってるのかを確認するコードをHomeControllerに書いて確認。

    INorthwindRepository _repository;

    public HomeController(INorthwindRepository repository)
    {
      _repository = repository;
      _repository.Debug("Constructor");
    }

    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
      _repository.Debug("OnActionExecuting");
      base.OnActionExecuting(filterContext);
    }

    protected override void OnActionExecuted(ActionExecutedContext filterContext)
    {
      _repository.Debug("OnActionExecuted");
      base.OnActionExecuted(filterContext);
    }

    public ActionResult Index()
    {
      var httpApp = HttpContext.ApplicationInstance as IUnityContainerAccessor;
      new NorthwindRepository(
        (NorthwindEntities)httpApp.Container.Resolve(typeof(NorthwindEntities))
      ).Debug("Other-1");

      var list = _repository.All<Product>();

      new NorthwindRepository(
        (NorthwindEntities)httpApp.Container.Resolve(typeof(NorthwindEntities))
      ).Debug("Other-2");

      return View(list);
    }

Debug内ではObjectContext.GetHashCode()を出力するようにしてます。コンストラクターで渡されるRepositoryはUnityに任せたもので、OnActionExecutingとOnActionExecutedではコンストラクタで渡されたものを出力し、Index内でそれぞれ(Other-1/2)新しくリポジトリのインスタンスを作成(Resolve)して出力させてます。

TransientLifetimeManagerを使った出力。

Constructor:6658142
OnActionExecuting:6658142
Other-1:5603269
Other-2:50559794
OnActionExecuted:6658142

Otherはそれぞれ違うインスタンスが生成されてますね。

HttpContextLifetimeManagerを使った出力。Other-1/2は全然違うのが出力されますね。

Constructor:60183783
OnActionExecuting:60183783
Other-1:60183783
Other-2:60183783
OnActionExecuted:60183783

Otherも同じインスタンスです。お利口さんです。Global.asaxに以下のコードを追加してUnityに登録してます。

    void InitializeContainer()
    {
      if (_container == null)
        _container = new UnityContainer();

      IControllerFactory controllerFactory = new UnityControllerFactory(_container);
      ControllerBuilder.Current.SetControllerFactory(controllerFactory);

      // Register
      _container.RegisterType<NorthwindEntities>(
        /*
         * ContainerControlledLifetimeManagerを使うとSingleton。
         * デフォルトはTransientLifetimeManager
         */
          new HttpContextLifetimeManager<NorthwindEntities>()
                )
                .Configure<InjectedMembers>()
                .ConfigureInjectionFor<NorthwindEntities>(
                  new InjectionConstructor(
                    ConfigurationManager.ConnectionStrings["NorthwindEntities"].ConnectionString
                    )
                );
      _container.RegisterType<INorthwindRepository, NorthwindRepository>();
    }

とりあえず、今回はProductデータだけを利用したけど、他にもいろいろなテーブルを同じコードで簡単に処理できるように「An Irishman Down Under - Polymorphic Repository for ADO.Net Entity Framework - Keith Patton's blog」に書かれてるようなジェネリックなリポジトリを書いてみようかな~と試してみたんですが、どうですかね。あんまり便利な気がしない。簡単な機能実装ならいいかもしれないけど、この辺は機能ドメイン毎にちゃんと書いた方がいい気がする。ところで、EntitySet名ってエンティティクラスから簡単に取得する方法ってないんですかね。EntityKeyには入ってるみたいだけど、アタッチして無いとnullで取り出せないじゃないですか。今回はずるっこして、エンティティクラス名+"s"として生成してます。

んん~。今回もプロジェクト添付しておくので、動かしてみたい方はどーぞー。あ、データベースファイルは添付してないです。

2009年5月13日水曜日

HandleErrorの使い方

そういえば、今月に入って全然エントリを書いてなかった事に気がついた。人に言われて気がついた。ずっと本を読んでて、頭がプシューってなっててさ~。

そんなこんなで、ASP.NET MVCでHandleErrorを使って、HTTPステータスコードに合わせたエラーページを用意する方法が日本語情報として無いかもとふと思ったので書いておきます。

使い方はアクションにHandleError属性を指定して、web.configのcustomErrorsのモードをOn/RemoteOnlyに設定しておけば、defaultRedirectが表示されるというものです。

  [HandleError]
  public class HomeController : Controller
  {
    public ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

コントローラに指定しても、アクションに指定してもいいですよね。それぞれに指定した場合、コントローラの普通はHandleErrorが優先されます。 例えば、上記コントローラに以下のアクションを追加してみると確認できますね。

    [HandleError(ExceptionType=typeof(InvalidOperationException),View="Exception")]
    public ActionResult ThrowException()
    {
      throw new InvalidOperationException("ぶぶ~");
    }

ViewにThrowExceptionへのリンクを作成して表示させてみたのが↓こちら。

handleerror2

普通に標準のエラーページが表示されます。ちゃんとアクションのHandleErrorにはViewを指定しているにもかかわらず。これをアクションに指定してるほうを優先させるにはコントローラに指定してるHandleErrorの優先順位を下げればOKです。

  [HandleError(Order=1)]
  public class HomeController : Controller
  {
    public ActionResult Index()
    {
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    }

↑こんな感じで。Orderは0スタートなので1以上なら何でもいいです。そうすると今度はどういう表示になるか確認。

handleerror1 

グレート!

ちなみにリリース版のVSテンプレートが出力する~/Views/Shared/Error.aspxでは例外情報の表示コードが無くなってるので分かりにくいですが、このページは少し特殊でInheritsが単なるViewPageじゃなく、例外情報が渡されるように型付きViewPageになってます。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" 
Inherits="System.Web.Mvc.ViewPage<System.Web.Mvc.HandleErrorInfo>" %>

なので、Viewページ内でModelを参照するとアクション内で発生したExceptionが参照出来るようになってます。あら便利。自分でエラーページを定義する場合のInheritsを上記のように変更しておけば、自動で参照できるという寸法です。

ちなみに上記スクリーンショットのViewは~/Views/Shared/Exception.aspxというのを作成して表示してます。

で、ですね、例外を捕捉して専用エラーページを表示するのももちろん必要だと思うんだけど、そこはやっぱりHTTPステータスコードに合わせたエラーページを定義して、レスポンスのステータスコードも合わせた方がサーチエンジンにもエラーが認識できるし、RESTfulな感じがして素敵だと思いませんか。例えば、上記のページの場合レスポンスされるステータスコードはなんになってるか確認してみる。

handleerror3

500ですね。例外だからそれで良し。でも、例えばidを指定してDB検索して一致するデータがない、なんて時は404で返したいな~、なんて思いませんか。

試しに、web.configのcustomErrorsを以下のように変更。

    <customErrors mode="On" defaultRedirect="Errors">
      <error statusCode="400" redirect="/Errors/400" />
      <error statusCode="403" redirect="/Errors/403" />
      <error statusCode="404" redirect="/Errors/404" />
    </customErrors>

で、HomeControllerに以下のコードを追加。

    public ActionResult BadRequest()
    {
      throw new HttpException(400, "Bad Request");
    }

    public ActionResult Forbidden()
    {
      throw new HttpException(403, "Forbidden");
    }

    public ActionResult NotFound()
    {
      throw new HttpException(404, "Not Found");
    }

ErrorsControllerを追加して、HTTPステータスコードに合わせてViewを返すようにします。

  public class ErrorsController : Controller
  {
    public ActionResult Index(int? statusCode, string aspxerrorpath)
    {
      var viewName = string.Format("Error{0}", statusCode);

      return View(viewName);
    }
  }

handleerror4 

で、Home/BadRequestにアクセスしたのが↑。拡大すると見えるけど、200が返ってます。400が返って欲しい。そんなときはViewResultを書き換えてしまいましょう。

  public class StatusViewResult : ViewResult
  {
    public int StatusCode { get; set; }

    public StatusViewResult(ViewResult viewResult)
    {
      ViewName = viewResult.ViewName;
      MasterName = viewResult.MasterName;
      ViewData = viewResult.ViewData;
      TempData = viewResult.TempData;
    }

    public override void ExecuteResult(ControllerContext context)
    {
      context.HttpContext.Response.StatusCode = StatusCode;
      
      base.ExecuteResult(context);
    }
  }

こんな感じのクラスを作成しておいて、Errorsコントローラでこっちを利用するように変更。

    public ActionResult Index(int? statusCode, string aspxerrorpath)
    {
      var viewName = string.Format("Error{0}", statusCode);
      var resStatusCode = statusCode ?? 500;

      return new StatusViewResult(View(viewName)) {
        StatusCode = resStatusCode
      };
    }

で、同じアクションを実行したのが↓こちら。

handleerror5 

うへへ。ちゃんと400が返ってます。そりゃそーだ。

エラー用のViewとして400/403/404の3種類だけ用意してみました。

handleerror6

それぞれの実行結果は省略しときますが、例えばこの状態で405とかエラー発生させたらどうでしょうね。HomeControllerに以下を追加。

    public ActionResult MethodNotArrowed()
    {
      throw new HttpException(405, "Method Not Arrowed");
    }

これにアクセスしてみたのが↓こちら。

handleerror7

ErrorsControllerのIndexアクションでError405をViewとして返そうとするけど、そんなViewは定義してないのでInternal Server Errorが発生して、テンプレートで用意されてる~/Views/Shared/Error.aspxがレンダリングされてます。すばらしいじゃないですか。

と、いうわけで、今回のプロジェクト一式もダウンロード出来るようにしておきます。

最近こんな話題ばっかりで、周りからはブログつまらないという声もちらほら。しょうがないので前に書いてた今は無き「オレがルールだ!」からサルベージしてごまかそうかと計画中。

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.

ひゃっほ~!

2009年4月24日金曜日

MySQLとMonoでASP.NET MVC - その2

ということで、続きました。

LINQ to EntityがMonoで使えないとなると、やっぱりLINQ to MySQLを使おうかなというところに戻りますよね。これがまたなんだかんだとがんばってみたんだけど...。ガッカリな結果でした。理由はサッパリ分からない。最初はライセンスの問題なのかな~、とかいろいろ試してみたけどできなかった。

mono1

↑これMySQLとxsp2(Mono)で動かしてるんだけど、ちゃんとログインできてるのが確認できると思います。なのでMembershipProvider経由のDBアクセスは成功ってことです。だけど、Sakilaを使ったサンプルはやっぱりダメで。

mono3 mono2

左がMonoの実行結果で、右が同じ物をVista上で動かしたもの。悲しいな~。エラーの意味が全く分からない。なんでリフレクションのエラーなんだろ...。どうしよっかな~。

ってことで、ふとNHibernateが気になりだした。Monoでも動くし。ドライバはDevartじゃなくてConnector/NETを使えばいいから、無料で構築できるのも魅力的。ただ...、xmlでマッピングはマッピラだ。韻を踏もうとして失敗した。それはいいとして、Fluent NHibernateっていうのがあったのも思い出した。NHibernateを使ったことないんだし、いっそのこと最初からFluent NHibernateでいいじゃない。

Fluent NHibernate

まずはNHibernateを調べなきゃ。近道するためにDime castでNHibernate Episodesを全部見て分かった気になったところでFluent NHibernate。

Getting Started: First Project in Fluent NHibernate

ここでいきなりSakilaを使うのはちょっと難しそう(マッピングの仕方もサッパリ分からないし)ので、チュートリアルに書かれてるデータベースをMySQLに構築して作ってみることにしてみました。何となくリレーションに使う項目名がStore_id/Product_idっていうのがカッコ悪いきがするのでStoreId/ProductIdにしてみる。

んで、言われるがままにコンソールアプリケーションを作って、エンティティ定義して、マッピング定義して...。いざ実行!動かず。泣ける。これだけじゃダメ?そりゃそうですね。接続の設定とか全然してないし。Fluentで出来るのかもしれないけど、よくわかんないのでapp.configに書く。

NHibernate - Learning with Code Samples

でも、これもFluent出来るんだね。まぁ、いいや。そこは早足で後回し。

Fluent NHibernate - Configuration - Chad Myers' Blog -

リレーションのカラム名を変えて作っちゃったおかげで、マッピングに失敗して動かず。面倒なことをしてしまった...。

なんだかんだでEomployeeMap。

  public class EmployeeMap : ClassMap<Employee>
 {
   public EmployeeMap()
   {
     Id(x => x.Id);

     Map(x => x.FirstName);
     Map(x => x.LastName);
    
     References(x => x.Store)
       .ColumnName("StoreId");
   }
 }
ProductMapにも挑戦。
  public class ProductMap : ClassMap<Product>
 {
   public ProductMap()
   {
     Id(x => x.Id);
     Map(x => x.Name);
     Map(x => x.Price);
     HasManyToMany(x => x.StoresStockedIn)
         .Cascade.All()
         .Inverse()
         .WithTableName("StoreProduct")
         .WithChildKeyColumn("ProductId");
   }
 }
最後にちょっと難しそうなStoreMap。
  public class StoreMap : ClassMap<Store>
 {
   public StoreMap()
   {
     Id(x => x.Id);
     Map(x => x.Name);

     HasManyToMany(x => x.Products)
         .Cascade.All()
         .WithTableName("StoreProduct")
         .WithChildKeyColumn("ProductId")
         .WithParentKeyColumn("StoreId");

     HasMany(x => x.Staff)
         .KeyColumnNames.Add("StoreId")
         .Cascade.All()
         .Inverse();
   }
 }
たったこれだけのマッピングにえらい時間がかかってしまいました。もっと簡単にAccess.As~CaseFieldでできるのかもしれないけど、ここもスキップして実行!

mono4

おぉ~。チュートリアルと同じ結果だ。そりゃそうだ。今度はコレをASP.NET MVCで実装。すったもんだの末に動くようになりました。

mono5 mono6

mono7 mono8

ぱっと見、普通ジャンって思えるけど、コレ実行環境はxsp2。Mono上で動かしてるんですよ!感動ですよ!もちろんNHibernate.Linqでアクセス!

これだけだと、Fluent NHibernate(r453で試しました)は含まれて無いし、データベース定義も入って無いから動かないけど、その辺はチュートリアルを見ながら試してみて下さいまし。

一応、ダウンロードできるようにして置きます。興味があるならどうぞ。とりあえず、コードを説明。

namespace MonoTest.FluentNHibernate.Controllers
{
 [HandleError]
 public class HomeController : Controller
 {
   IStoreRepository _repository;

   public HomeController()
   {
     _repository = new StoreRepository();
   }

   protected override void Dispose(bool disposing)
   {
     base.Dispose(disposing);
    
     _repository.Dispose();
   }

   public ActionResult Index()
   {
     var list = _repository.AllStore();

     return View(list);
   }

   public ActionResult Details(int id)
   {
     var store = _repository.GetById(id);

     return View(store);
   }

   public ActionResult Edit(int id)
   {
     var store = _repository.GetById(id);

     return View(store);
   }

   [AcceptVerbs(HttpVerbs.Post)]
   public ActionResult Edit(int id, FormCollection forms)
   {
     var store = _repository.GetById(id);
     TryUpdateModel<IStore>(store, forms.ToValueProvider());

     if (!ModelState.IsValid)
       return View(store);

     try
     {
       _repository.Save(store);

       return RedirectToAction("Index");
     }
     catch{
       ModelState.AddModelError("", "保存に失敗デス");
     }

     return View();
   }

   public ActionResult About()
   {
     return View();
   }
 }
}

まずは、コントローラ。HomeControllerを書き換えてます。普通ですね。はい。データアクセスをRepositoryにしといたくらいです。でもDI使ってないデス。

ViewはMonoで動かすのに型付きには出来ないので、スキャッフォールディングで作成(コンテキストメニューのAdd View)した後に、PageディレクティブのInheritsは修正。さらに、LINQ to SQLでもLINQ to Entityでもないので、ID列も自動でリンクに反映されないのを修正。

Repositoryは↓これだけ。

  public interface IStoreRepository : IDisposable
 {
   IQueryable<Store> AllStore();
   Store GetById(int id);
   void Save(Store store);
 }
  public class StoreRepository : IStoreRepository
 {
   ISession _session;

   public StoreRepository() : this(DbSession.GetSession()) { }

   public StoreRepository(ISession session)
   {
     _session = session;
   }

   public IQueryable<Store> AllStore()
   {
     return from store in _session.Linq<Store>()
            select store;
   }

   public Store GetById(int id)
   {
     return AllStore().Where(s => s.Id == id).FirstOrDefault();
   }

   public void Save(Store store)
   {
     using (var transaction = _session.BeginTransaction())
     {
       _session.SaveOrUpdate(store);
       transaction.Commit();
     }
   }

   public void  Dispose()
   {
     if (_session == null )
       return;

     if (_session.IsConnected)
       _session.Close();

     _session.Dispose();
   }
 }

データベースのSession(DataContext的な?)は↓こう。

  public class DbSession
 {
   private static ISessionFactory CreateSessionFactory()
   {
     return Fluently.Configure()
         .Database(
           MySQLConfiguration.Standard.ConnectionString(c =>
             c.FromConnectionStringWithKey("MySqlServices")))
         .ExposeConfiguration(c => c.Properties.Add("use_proxy_validator", "false"))
         .Mappings(m =>
             m.FluentMappings.AddFromAssemblyOf<DbSession>())
         .BuildSessionFactory();
   }

   public static ISession GetSession()
   {
     return CreateSessionFactory().OpenSession();
   }
 }

この辺、どうやって作るのがいいのかまだよく分かってないけど、とりあえずは動くものってことで。

Fluent NHibernateをORMに使って、データアクセスにはLINQ、データベースはMySQLでドライバはMySQL.Data(Connector/Net)。実行環境はCent OS 5.3上のMono 2.4。完全にフリーで動かす環境で、ASP.NET MVCを使ったプロダクト開発が出来ることが分かりました!過負荷の時にも上手く動き続けるのかは今後の課題。運用時にはxspじゃなくてApache+mod_monoで動かしましょう。

で、ここまでやってやっとSharp Architectureの良さに気がつくわけですね。

sharp-architecture - Google Code

Sharp Architectureを使うと、ASP.NET MVC用のヘルパーがちゃんと用意されてて、NHibernateが更に使いやすいみたい。T4 Toolboxでのマッピングの自動生成や、Sessionを管理出来るTransactionAttributeなんかがあるし、プロジェクトのフォルダ構成なんかがきちんと区切られてて、もっともっとちゃんと調べて使えるようになれば、Windows環境だけじゃなく、Monoにもデプロイ出来るプロジェクトを綺麗に作れること間違いなし。

#75 - Introdction to S#arp Architecture

Dime Castsにもビデオあるから、これも興味あれば見てみるといいと思います。10分くらいだし。

しかし、分からないことだらけで、時間かかりすぎたな~。もっと、サクッと動くコードが書けると思ったのに。

追記

Monoで動かす際に、System.Web.Mvc/System.Data.Servicesはアセンブリのローカルコピーを忘れずにね。

2009年4月4日土曜日

もう一度AsyncController

正式にドキュメントが公開されてるので、改めてドキュメントに書かれてるようにサンプルを書いてみました。

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

現時点で”Using the AsyncController.docx”はあんまり人気無いみたいね...。ダウンロード数の桁が違う。

async

ドキュメントから勝手に引用。ようするに非同期ハンドラなんですが、いろいろ(アクションだけじゃなくて非同期で大丈夫なように)とあるようで結構なコード量での対応になってます。

非同期にするにあたり、ルーティングの登録が通常の物と違います。非同期ハンドラを使わないとダメなので、MapRouteではなくMapAsyncRouteでルーティング登録。とりあえず、Futuresのアセンブリ参照とMicrosoft.Web.Mvcのusingは忘れずに。

続いて、非同期化したいコントローラのベースクラスをControllerからAsyncControllerに変更しましょう。AsyncManagerやActionInvokerなんかがあるし。ここまでは決まり事です。

非同期アクションを実装する場合、何パターンかあります。ドキュメントを上から見ていくと以下のようなパターンが書かれてます。

  • IAsyncResultパターン
  • イベントパターン
  • デリゲートパターン

それぞれ書き方も違うので一つずつ見ていきます。画像のURLを渡して、そのバイナリを取得し、そのままレスポンスするというものをサンプルに書いてみます。

まずはIAsyncResult。これはアクションが呼び出される際に、アクション内で使用する引数の他にAsyncCallbackとobjectが渡されて来ます。"IAsyncResult を使用した非同期メソッドの呼び出し"これの一番うえのパターンでしょう。End~は勝手に呼び出してくれます。

    public IAsyncResult BeginLoad(string url, AsyncCallback cb, object state)
   {
     var req = WebRequest.Create(url);
     req.Method = "get";
     return req.BeginGetResponse(cb, req);
   }

   public ActionResult EndLoad(IAsyncResult ar)
   {
     WebRequest req = ar.AsyncState as WebRequest;
     var res = req.GetResponse();
     return new FileStreamResult(res.GetResponseStream(), res.ContentType);
   }
こんな感じでしょうか。時間のかかる処理向けというよりは、Begin/Endの組み合わせでIAsyncResultを返す非同期機能を呼び出すときに使うものですね。

次にイベントパターン。最初に呼び出されるアクションの戻り値はvoidにして置いて、引数も必要なものだけを定義。完了時に呼び出されるコールバックのサフィックスにCompletedをつけるルールる、そのコールバックがActionResultを返します。ここでAsyncManagerが登場します。AsyncManager.Parametersディクショナリにコールバックに渡される引数名と同じキーで値を入れておくと、勝手に引数に渡してくれます。コールバックがいつ呼び出されるのかというとAsyncManager.OutstandingOperations.Incrementした値がDecrementで0になったときみたいです。なので、複数の時間のかかる処理を平行して実行させるとき、それぞれの処理をQueueUserWorkItemに登録。同じ数だけインクリメントをしておくけど、処理終了時点でデクリメント。すべてのQueue内の処理が完了するとカウンタが0になるのでコールバックが呼ばれる流れですね。コールバックにはサフィックスとしてCompletedをアクション名につけたものを登録する掟です。

    public void LoadE1(string url)
   {
     AsyncManager.OutstandingOperations.Increment();
     ThreadPool.QueueUserWorkItem(w => {
       var req = WebRequest.Create(url);
       req.Method = "get";
       var res = req.GetResponse();
       AsyncManager.Parameters["stream"] = res.GetResponseStream();
       AsyncManager.Parameters["contentType"] = res.ContentType;

       AsyncManager.OutstandingOperations.Decrement();
     }, null);
   }

   public ActionResult LoadE1Completed(Stream stream, string contentType)
   {
     return new FileStreamResult(stream, contentType);
   }

こんな感じで動いてます。長時間の処理でIAsyncResultじゃない場合にはこれがいいかと。でも正直AsyncManager.Parametersに値を入れるのがこのタイミングでいいのかは自身ないです。Futuresのソース見れば分かると思うけど...。

同じくイベントパターンですが、今度はAsyncManager.RegisterTaskを使って実行したい処理を登録しておく方法です。これはIAsyncResultパターンとのコンビネーションみたいな感じです。ただし自分でインクリメントやデクリメントして処理数を管理しないです。RegisterTaskの2個目の引数に渡すendDelegateが完了したら勝手にカウンタを管理してCompletedを呼び出してくれる感じです。これも複数タスクを登録可能。

    public void LoadE2(string url)
   {
     var req = WebRequest.Create(url);
     req.Method = "get";
     AsyncManager.RegisterTask(
         callback => req.BeginGetResponse(callback,null),
         ar => {
           var res = req.EndGetResponse(ar);

           AsyncManager.Parameters["stream"] = res.GetResponseStream();
           AsyncManager.Parameters["contentType"] = res.ContentType;
         });
   }

   public ActionResult LoadE2Completed(Stream stream, string contentType)
   {
     return new FileStreamResult(stream, contentType);
   }

これで。

最後がデリゲートパターン。さっきもデリゲートじゃないか!と言われればごもっとも。何がデリゲートかというとアクションの戻り値がデリゲートです。これまでとは少し感じが違って1つのアクション(メソッド)しか定義しません。コールバックはどうするかというと、コールバックをこのアクションの戻り値にしてしまうというのが、デリゲートパターンの名前の由来(ウソです、知りません)。

    public Func<ActionResult> LoadD(string url)
   {
     var req = WebRequest.Create(url);
     WebResponse res = null;
     req.Method = "get";
     AsyncManager.RegisterTask(
         callback => req.BeginGetResponse(callback, null),
         ar => {
           res = req.EndGetResponse(ar);
         });

     return () => {
       return new FileStreamResult(res.GetResponseStream(), res.ContentType);
     };
   }

1つの定義で済むので完結に書けていいですね。これもRegisterTaskを使ってるので、複数登録してもすべてが終わるまでコールバックは呼ばれないですね。

だいたいこんな感じです。

通常のControllerとAsyncControllerを簡単に比べるためのアクションを書いてみます。3秒スリープして画像ファイルをレスポンスするという簡単なものです。

同期版

    public ActionResult Dog()
   {
     Thread.Sleep(3000);
     return File("/Content/dog.jpg", "image/jpeg");
   }

非同期版

    public void Dog()
   {
     AsyncManager.OutstandingOperations.Increment();
     ThreadPool.QueueUserWorkItem(w => {
       Thread.Sleep(3000);
       AsyncManager.Parameters["path"] = "/Content/dog.jpg";
       AsyncManager.Parameters["contentType"] = "image/jpeg";
       AsyncManager.OutstandingOperations.Decrement();
     }, null);
   }

   public ActionResult DogCompleted(string path, string contentType)
   {
     return File(path, contentType);
   }

実行するとどっちも3秒たってから画像が表示されます。

async2

前回同様Measure-CommandとTinyGetでパフォーマンスを測定。

PS C:\Program Files\IIS Resources\TinyGet>  Measure-Command {.\tinyget -srv:localhost
-r:27721 -uri:/Home/Dog -threads:50 -loop:1}

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 10
Milliseconds      : 165
Ticks             : 101652219
TotalDays         : 0.00011765303125
TotalHours        : 0.00282367275
TotalMinutes      : 0.169420365
TotalSeconds      : 10.1652219
TotalMilliseconds : 10165.2219

PS C:\Program Files\IIS Resources\TinyGet> Measure-Command {.\tinyget -srv:localhost
-r:27721 -uri:/Async/Dog -threads:50 -loop:1}

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 7
Milliseconds      : 314
Ticks             : 73144834
TotalDays         : 8.46583726851852E-05
TotalHours        : 0.00203180094444444
TotalMinutes      : 0.121908056666667
TotalSeconds      : 7.3144834
TotalMilliseconds : 7314.4834

気持ちだね...。サンプルが良くないから。 早くするというよりも、サーバーのコネクションを素早く解放して同時実効性能を上げるのが目的なんだから早さ見てもしょうがないですよね。

今回登場してもらったわんちゃんは"SPOILED-ROTTEN-TO-THE-CORE dog on Flickr - Photo Sharing!"です。

> UpdateModel で、コレクションであるプロパティの各要素の、一部のプロパティだけ更新する

Developer @ ADJUST : ASP.NET MVC - UpdateModel で、コレクションであるプロパティの各要素の、一部のプロパティだけ更新する

コメント欄に書こうと思ったんですが、サンプルが長すぎたのでこっちに書きます。

おっしゃるとおり、復元したいクラスがリストじゃない場合は簡単に制限をかけられるんですが、コレクションのインスタンスに対しての制限は少し面倒ですね。

復元したいインスタンスの項目を制限する場合、UpdateModelを使うなら

  • IncludeやExcludeを指定
  • インターフェースを指定
  • Form復元用のベースクラスを指定

の3パターンあると思います。

が、Listの場合、Listそのもののインスタンスを生成するところから始まるのでUpdateModel<List<interface>>(…)とは書けないですね。インスタンスが作れないので。かといってUpdateModel<List<FormBase>>(…)と書くと、すべてが復元されないので一発で欲しいインスタンスが生成できません。

Listに関する部分に限りこういう使い勝手になってしまうので、アイテム毎にUpdateModelを実行するのが簡単な解決策になります(たぶん)。

ところで、一般的にこういう形式のデータをどのように復元させているのか気になる所ですね。いろいろなコードを眺めるとLINQ to SQLなんかのオブジェクトを使ってる場合は、必要な項目だけ復元させてあとはオリジナルのインスタンスにAttachという形式が多いと思います。オリジナルのインスタンスに直接復元させないところがミソかなと。

一旦復元用のインスタンスに値を入れて、それをオリジナルにコピー(Attach)してしまうのがもう一つの手段として考えられるのではないかなと思う次第です。

  public class ViewModelBase<T>
 {
   public object Attach<T>(object target)
   {
     foreach (var iprop in typeof(T).GetProperties())
     {
       var srcProp = target.GetType().GetProperty(iprop.Name);
       object val = srcProp.GetValue(target, null);
       if (val!=null)
         this.GetType()
             .GetProperty(srcProp.Name)
             .SetValue(this, val, null);
     }

     return this;
   }
 }

 public interface IPerson
 {
   int? Age { get; set; }
 }

 public class Person : ViewModelBase<IPerson>
 {
   public int? Age { get; set; }
   public string Name { get; set; }
 }

 public class Team
 {
   public List<Person> People { get; set; }
 }
こういうモデルがあるとして(ちょっとダサイですが)。コントローラに↓こういう処理を書くようなイメージです。
    Team LoadTeam()
   {
     return new Team { People = new List<Person>{
         new Person{ Name="Boo",Age=11},
         new Person{ Name="Bar",Age=21},
         new Person{ Name="Foo",Age=43}
       }
     };
   }

   public ActionResult Index()
   {
     var team = LoadTeam();

     return View(team);
   }

   [AcceptVerbs(HttpVerbs.Post)]
   public ActionResult Index(List<Person> people)
   {
     var team = LoadTeam();
     for (int idx = 0; idx < team.People.Count; idx++)
       team.People[idx].Attach<IPerson>(people[idx]);

     return View(team);
   }
オリジナルのTeamにpeopleのアイテムを埋め込んでいく感じです。実際にはIDかなにかでAttachしたいインスタンスとされるインスタンスを結びつける(この例だと単純にインデックスで結びつけてます)ことにナルト思います。

いかがでしょうか?

2009年3月29日日曜日

NerdDinnerでYSlow

TEERA 2.0 » YSlow web page optimization for ASP.NET MVC

上記サイトでは一般的な話になってるけど、せっかくなのでNerdDinner(NerdDinner.com - Where Geeks Eat - Home)がどこまで最適化できるのか試してみようと思います(ビデオ見疲れたし)。

まずは現状↓こんな感じです。

nd1

nd2

※クリックで拡大。

見ての通り、あまり最適化の余地がない...。localhostからの取得自体が少なすぎ。Site.cssとHome/IndexのHTMLと、jQueryとMap.js。あとはVirtualEarthのサーバーから取得。

トータルGradeは60ポイントでD判定。

うぬ~。マッシュアップ恐るべし。

あまりいいサンプルじゃないのは分かってるけど、少し最適化。最適化手法そのものはどんなサイトでも通用する物なので。今回どこを最適化するかというとCSSとJavaScript。画像もないし、ETagもVirtualEarthがらみだし、CDNも勘弁。NerdDinnerサンプルではCSSは1個しかないけど圧縮して、キャッシュが効くように。同じくJavaScriptも圧縮してキャッシュが効くようにするけど、さらに動的に1つのファイルにしてしまう。今回はHome/Indexしかいじらないですが、Viewに埋め込まれてるJavaScriptも外だしにしてしまいます。

ようするに以前の投稿の続きです。スタティックハンドラはそのまま使います。JSONPが無いのでそこもスルー。

圧縮と縮小化、連結、キャッシュヘッダのコントロールを行うのに新しいコントローラを作成します。コントローラのアクションの結果が圧縮されたCSSかJavaScriptになるようにします。わざわざStaticFileHandlerじゃないのは複数ファイルを連結させたいからです。

まずは圧縮させるためのActionFilterを定義。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using System.Web.Mvc;
using System.IO.Compression;

namespace ClientTagHelpers
{
public class CompressAttribute : ActionFilterAttribute
{
public override void  OnActionExecuting(ActionExecutingContext filterContext)
{
 // base.OnResultExecuted(filterContext);

 var request = filterContext.HttpContext.Request;

 string acceptEncoding = request.Headers["Accept-Encoding"];

 if (string.IsNullOrEmpty(acceptEncoding)) return;

 acceptEncoding = acceptEncoding.ToUpperInvariant();

 var response = filterContext.HttpContext.Response;

 if (acceptEncoding.Contains("GZIP"))
 {
   response.AppendHeader("Content-encoding", "gzip");
   response.Filter = new GZipStream(response.Filter, CompressionMode.Compress);
 }
 else if (acceptEncoding.Contains("DEFLATE"))
 {
   response.AppendHeader("Content-encoding", "deflate");
   response.Filter = new DeflateStream(response.Filter, CompressionMode.Compress);
 }

}
}
}
なんてことないです。Response.Filterに圧縮用のストリームを指定するだけ。後はよしなにはからってくれます。使い方はこれをコントローラのアクションに指定するだけです。続いてコントローラ。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Text;
using System.IO;
using System.Web.UI;

using System.Web.Caching;
using ClientTagHelpers;

namespace NerdDinner.Controllers
{
public class UtilityController : Controller
{
[Compress, OutputCache(Duration = (30 * 24 * 3600), VaryByParam = "*")]
public ActionResult Compress(string src, string key, string ver)
{
 var mime = key.Equals("css", StringComparison.CurrentCultureIgnoreCase) ?
   "text/css" : "application/x-javascript";
 var path = TagRegisterHelper.GetKeyVirtualPath(key);
 var items = src.Split(',');

 if (items != null && items.Length > 0)
 {
   var cacheKey = src + key + ver;
   var responseText = (string)HttpContext.Cache.Get(cacheKey);
   if (responseText == null)
   {
     var srcText = new StringBuilder();
     foreach (var script in items)
     {
       string fullpath = Server.MapPath(path + script);
       if (System.IO.File.Exists(fullpath))
         srcText.Append(System.IO.File.ReadAllText(fullpath));
     }

     if (key.ToLower() == "css")
     {
       responseText = srcText.ToString();
     }
     else
     {
       var minJs = new StringBuilder();
       using (TextReader tr = new StringReader(srcText.ToString()))
       {
         using (TextWriter tw = new StringWriter(minJs))
         {
           new JavaScriptSupport.JavaScriptMinifier().Minify(tr, tw);
         }
       }
       responseText = minJs.ToString();
     }
     HttpContext.Cache.Insert(cacheKey, responseText);
   }

   return Content(responseText, mime );
 }

 return new EmptyResult();
}
}
}
Utility/Compress?src={カンマ区切りでファイル名}&key={種類}&ver={キャッシュさせるためのバージョン} ↑こんな感じのUrlで使用します。ルーティングを登録すればもう少し綺麗になりますね。気になる方はチャレンジしてみてください。

ここでJavaScriptの縮小化をするのにJavaScriotMinifierと言うのを使ってます。これはSmallSharpTools.Packer - Tracから取得出来ます。自分で書いてもいいです。単純にコメントと空白を削除するだけなので自分でも書けるところですが、ここでは楽します。変数名やファンクション名を最適化とかは無しです。自分でそこまでするならハフマン的なことをすればいいかも。

ファイルを1つに連結して縮小化のステップを毎回処理するのは大変(CPUとIOが)なので、HttpRuntimeのCacheに入れてしまいましょう。キャッシュのクリアは考えない富豪プログラミングで。パラメータで指定しているverには指定したファイル群の中で最大の最終更新日時を整数化したものを渡すように作れば上手く行く寸法です。

OutputCache属性はMVCにそもそも用意されてるものなので、そのまま利用しましょう。ここでは有効期限1ヶ月を指定してます。もっと長くてもいいです。どうせverの値が変われば再生成なのでお構いなし。

サーバー上でメモリに実体をキャッシュし、クライアントでも有効期限を指定してファイルキャッシュさせるのは無駄なように見えますが、違うUAからのアクセスならクライアントキャッシュは入って無いので、その時サーバー上のメモリキャッシュが賢く機能してくれます。その為には両方のキャッシュを上手く使う事が大事。中間にリバースプロキシを入れてそこでキャッシュさせたりすると、アプリケーションサーバーの負荷を下げるのに役立つよね。やったこと無いですが。

後は、Viewで使うヘルパーを。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

using System.Web.Routing;
using System.Web.Mvc;
using System.Text;
using System.IO;
using System.Web.Script.Serialization;

namespace ClientTagHelpers
{
public static class TagRegisterHelper
{
public static string SourceKey = "_sourceKeys";
public static string ModelKey = "_modelKeys";
public static string UrlKey = "_urlKeys";
static string _formatScript = "<script type=\"text/javascript\" src=\"{0}\"></script>\n";
static string _formatCss = "<link rel=\"stylesheet\" href=\"{0}\" type=\"text/css\" />\n";
static string _pageScriptPath = "~/Views/";

public static string ToJSON(object values)
{
 if (values != null)
 {
   #pragma warning disable 0618
   JavaScriptSerializer serializer = new JavaScriptSerializer();
   return serializer.Serialize(values);
   #pragma warning restore 0618
 }
 else
   return "";
}

public static Dictionary<string, string> PathList = new Dictionary<string, string>()
 {
   {"css","/Content/"},
   {"site","/Scripts/"},
   {"page","/ViewScripts/"},
   {"jsonp",""}
 };

static Dictionary<string, List<object>> GetContextItems(HttpContextBase context, string key)
{
 var items = context.Items[key] as Dictionary<string, List<object>>;
 if (items == null)
   context.Items[key] = items = new Dictionary<string, List<object>>();

 return items;
}

public static string GetKeyVirtualPath(string key)
{
 var path = PathList[key.ToLower()];
 if (key.Equals("page", StringComparison.CurrentCultureIgnoreCase))
   path = _pageScriptPath;

 return path;
}

static DateTime GetLastModify(string basePath, IEnumerable<string> fileNames)
{
 return fileNames.Max(fileName => File.GetLastWriteTime(basePath + fileName.Replace("/", "\\")));
}

// -------------------------------------

public static void RegisterViewScripts(this HtmlHelper helper)
{
 helper.RegisterViewScripts(null, null);
}

public static void RegisterViewScripts(this HtmlHelper helper, string scriptName)
{
 helper.RegisterViewScripts(scriptName, null);
}

public static void RegisterViewScripts(this HtmlHelper helper, object values)
{
 helper.RegisterViewScripts(null, values);
}

public static void RegisterViewScripts(this HtmlHelper helper, string scriptName, object values)
{
 // ViewPathからScriptファイル名を推測
 if (string.IsNullOrEmpty(scriptName))
 {
   var webFormView = helper.ViewContext.View as WebFormView;
   if (webFormView != null)
   {
     var viewFile = (helper.ViewContext.View as WebFormView).ViewPath;
     scriptName = viewFile.Replace(".aspx", "") + ".js";
   }
 }
 else if (!scriptName.StartsWith(_pageScriptPath))
   scriptName = _pageScriptPath + scriptName;

 // 実体パス
 var filepath = helper.ViewContext.HttpContext.Server.MapPath(scriptName);

 // ファイルがあるならリストに追加
 // ※ベースフォルダを除外したパス
 if (System.IO.File.Exists(filepath))
   helper.RegisterSource("page", scriptName.Replace(_pageScriptPath, ""));

 if(values!=null)
   helper.RegisterJSON(values);
}

public static void RegisterJSON(this HtmlHelper helper, string key, object value)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, ModelKey);

 // ModelDataの場合は同一キーで値を入れようとしてもダメよ。
 // 常に最初に入れた値だけが取り出せます。
 if (!items.Keys.Contains(key))
   items.Add(key, new List<object>());

 items[key].Add(value);
}

public static void RegisterJSON(this HtmlHelper helper, object values)
{
 var modelValues = new RouteValueDictionary(values);

 foreach (var modelValue in modelValues)
   helper.RegisterJSON(modelValue.Key, modelValue.Value);
}

public static void RegisterSource(this System.Web.Mvc.HtmlHelper helper, string key, string fileName)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 if (!items.ContainsKey(key))
   items[key] = new List<object>();

 if (fileName.StartsWith("~/"))
   fileName = VirtualPathUtility.ToAbsolute(fileName, helper.ViewContext.HttpContext.Request.ApplicationPath);

 items[key].Add(fileName);
}

// -------------------------------------
public static string RenderModelJSON(this HtmlHelper helper)
{
 var formatJSON = "<script type=\"text/javascript\">\n" +
                  "var pageModel = {0};\n" +
                  "</script>\n";
 string json = "{}";
 var values = GetContextItems(helper.ViewContext.HttpContext, ModelKey);
 if (values != null && values.Count > 0)
 {
   var modelData = values.Select(v=>new {
                             Key = v.Key,
                             Value = v.Value[0]
                         })
                         .ToDictionary(v=>v.Key, v=>v.Value);
   json = ToJSON(modelData);
 }

 return string.Format(formatJSON, json);
}

private static string ScriptTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 var sb = new StringBuilder();

 if (items.ContainsKey(key))
   foreach (var item in items[key])
     sb.Append(helper.ScriptTag(PathList[key] + item.ToString()));

 return sb.ToString();
}

public static string ScriptTag(this HtmlHelper helper, string source)
{
 return string.Format(_formatScript, source);
}

public static string RenderScriptTags(this HtmlHelper helper)
{
 return helper.RenderScriptTags("site") +
        helper.RenderScriptTags("jsonp") +
        helper.RenderModelJSON() +
        helper.RenderScriptTags("page");
}

public static string RenderScriptTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 var nonCompress = new[] { /*"page",*/ "jsonp" };
 if (nonCompress.Contains(key.ToLower()))
   return helper.ScriptTags(key);
 else
   return helper.CompressTags(key, _formatScript);
}

public static string RenderCssTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 return helper.CompressTags(key, _formatCss);
}

public static string RenderCssTags(this System.Web.Mvc.HtmlHelper helper, string[] fileNameList)
{
 var key = "css";
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 items[key] = new List<object>();
 foreach (var fileName in fileNameList)
   items[key].Add(fileName);

 return helper.CompressTags(key, _formatCss);
}

private static string CompressTags(this System.Web.Mvc.HtmlHelper helper, string key, string format)
{
 var basePath = GetKeyVirtualPath(key);
 var path = helper.ViewContext.HttpContext.Server.MapPath(basePath);
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 if (items.ContainsKey(key))
 {
   var list = GetContextItems(helper.ViewContext.HttpContext, SourceKey)[key].Cast<string>();
   var maxDate = GetLastModify(path, list);
   var ver = maxDate.ToFileTime().ToString();

   string names = list.Select(l => l).Aggregate((s, ss) => s + "," + ss);
   if (!string.IsNullOrEmpty(names))
     return string.Format(
              format,
              string.Format("/Utility/Compress?src={0}&key={1}&ver={2}",
              new[]{
             helper.ViewContext.HttpContext.Server.UrlEncode(names),
             key,
             ver})
            );
 }

 return "";
}
}
}

前回のエントリの中でも説明しましたが、CSS・サイト共通外部JS・ページ固有外部JS・ 無名クラスをJSONで展開の4パターンを上手いこと処理するヘルパーです。それぞれ固定のパスにファイルがあるという前提でタグを出力します。CSSは/Content、サイト共有のJavaScriptは/Scripts、ページ固有のJavaScriptはViewと同じフォルダにViewと同じ名前で作成(/ViewScripts/Home/Index.jsの形式でアクセス)。

Compressアクションに指定するverを生成するために、GetLastModifyがファイルに直接アクセスして最終更新日時を取得してるけど、ここでもHttpRuntimeのCacheをCacheDependencyを上手く利用してしまえば、実体へのアクセス(IO)はなくせるので、さらに改良の余地あり。

Viewも変更します。まずはSite.Master。
<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">

<head id="Head1" runat="server">
<title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
<% = Html.RenderCssTags(new[] { "site.css" }) %>
<meta content="Nerd, Dinner, Geek, Luncheon, Dweeb, Breakfast, Technology, Bar, Beer, Wonk" name="keywords" />
<meta name="description" content="Host and promote your own Nerd Dinner free!" />
<% Html.RegisterSource("site", "jquery-1.2.6.js"); %>
</head>

<body>
<!-- 省略 -->
<%= Html.RenderScriptTags() %>
</body>
</html>

ヘッダでCSSをlinkタグで書いていたのをHtml.RenderCssTagsに変更。引数は文字列配列でファイル名を並べてください。いくつ書いてもOKです。すべてを1つのファイルに連結して、圧縮したレスポンスを返すようにUtility/Compressを参照するlinkタグを生成します。同じくjQueryのscriptタグをHtml.RegisterScriptTagsに変更。最初の引数"site"はサイト共有を表します。閉じbodyの直前にあるHtml.RenderScriptTagsでRegisterしているすべてのスクリプト(サイト共有、ページ固有、無名クラス3つとも)を展開します。こうすればページの最後ですべてのスクリプトを展開出来るようになって、YSlow的にも高評価。

今回はHome/Indexしか最適化しない(全部は面倒)ので、Home/IndexのViewを少し変更します。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage" %>

<asp:Content ID="Title" ContentPlaceHolderID="TitleContent" runat="server">
Find a Dinner
</asp:Content>

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">

<script src="http://dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=6.2" type="text/javascript"></script>

<h2>Find a Dinner</h2>

<div id="mapDivLeft">

<div id="searchBox">
   Enter your location: <%= Html.TextBox("Location") %> or <%= Html.ActionLink("View All Upcoming Dinners", "Index", "Dinners") %>.
   <input id="search" type="submit" value="Search" />
</div>

<div id="theMap">
</div>

</div>

<div id="mapDivRight">
<div id="dinnerList"></div>
</div>

<% Html.RegisterSource("site", "Map.js");%>
<% Html.RegisterViewScripts();%>

</asp:Content>

ここでマッシュアップするためにVirtualEarthのサーバーからスクリプトを取得するようになってますね。これに関してもちゃんとページの最後にタグ出力しなきゃいけないところですが、そこまでは作ってないので、これまた興味のある人はRegisterOuterScripts的な関数を作ってみてSite.MasterのRenderで一括出力できるようにしてみてはどうでしょう。今回そこは作ってないので、直接ここに残したままにしときます。サイトで使うVirtualEarth用の関数群を保持してるMap.jsは、本来サイト共有のスクリプトなのでSite.Masterで一括して指定しておきたいところですが、無駄にすべてのページに反映されるのも良くないので、ViewでRegisterします。こうすると、サイト共有のスクリプトキャッシュが2種類作成されることになります。1つはjQuery+Mapと、もう一つはjQueryのみのキャッシュ。それほど迷惑な話でもないので、これでもいいんじゃないかなと思いますがどうですかね。最後のRegisterViewScriptsがページ固有の外部JSを登録してる部分です。Viewのパスから勝手にスクリプト名を判断してます。複数のViewで共有っていうこともあり得る(例えば、データの新規登録と編集では共通のスクリプトを使いたいけど、それはサイト共有じゃない)ので、スクリプト名を指定するオーバーロードもあります。細かいところはソースを見てね。

最後にViewScripts用のルートを登録すれば完成です。前投稿と同じですが再掲(Global.asax)。

          // -----------------------------------------
       // view scripts routing
       routes.MapRoute(
         "ViewScripts",
         "ViewScripts/{*scriptName}",
         null,
         new { scriptName = @"[\w]*\/[.|\w]*\.js" }
       ).RouteHandler = new ClientTagHelpers.ViewScriptRouteHandler();

これで、再度YSlow!

nd3

nd4

※クリックで拡大。

ちゃんとCSSとJavaScriptは圧縮されてファイルサイズも小さく(トータルが425KBから325KBに縮小)なってるけど、全体的には全く効果が無いに等しい...。Grade 67でDのままだし。7ポイントアップはしたけど。

VirtualEarthが有効期限の無いレスポンスを返してるのと、Etagがちゃんと制御されてないのが大きなところ。これ以上はどうしようもないかな~。

もっと、本格的なアプリケーションだとこれでずいぶん最適化される(CompressをHome/IndexアクションにつけるとかもOKだけど認証かかってる物のキャッシュは気をつけてね)し、画像なんかが多い場合も最適化の余地が残されてる可能性大なので。

興味ある人はお試しアレ。

今まで、プロジェクトのソースを直接書いてたけど、ダウンロード出来る方が試しやすいと気がついたので、貼り付けておきます。

そうそう、Prototype of VEToolkit + ASP.NET MVC 1.0 Component Checked InというサイトでVirtualEarthのMVC用ヘルパーを公開中です。Ajax.MapでJavaScript(NerdDinnerのMap.jsの部分)を出力してくれます。外部ファイルにならないけど、パラメータ指定がチェインしててなんかオシャレな感じ。でもこれなら無名クラスでしていして、内部でデフォルト値とマージするスタイルの方がいい気がするね。

        <%-- Create a Map and name it's global JavaScript variable "myMap" so it can be referenced from your own JavaScript code --%>
     <%=Ajax.Map("myMap")
         .SetCssClass("MapWithBorder")
         .SetCenter(new Location(44, -78))
         .SetZoom(6)
         .SetMapStyle(MapStyle.Road)
         .SetOnMapLoaded("MapLoaded")%>

↑こうやって使うんだけど、↓こうの方が分かりやすくないですか?

        <%=Ajax.Map("myMap")
         .Set(new {
           CssClass = "MapWithBorder",
           Center = new Location(44, -78),
           Zoom = 6,
           MapStyle = MapStyle.Road,
           OnMapLoaded = "MapLoaded"
         }) %>
         

この辺は好みだから、自分で作っちゃえばいいんだけどね!ちなみにソースを確認してて、面白いEnumヘルパー発見。EnumExtensionsクラスでEnumに拡張メソッドを指定してるんだけど、Enumに属性でJavaScriptに出力するときの文字列を指定しておくというもの。例えば↓。

    public enum MapMode : int
 {
     /// <summary>
     /// Displays the map in the traditional two dimensions
     /// </summary>
     [JavaScriptValue("VEMapMode.Mode2D")]
     Mode2D = 0,
     /// <summary>
     /// Loads the Virtual Earth 3D control, displays the map in three dimensions, and displays the 3D navigation control
     /// </summary>
     [JavaScriptValue("VEMapMode.Mode3D")]
     Mode3D = 1
 }

こうやって、Enumの属性に指定しておいて、出力するときにToJavaScriptValueの結果をレンダリング。賢い~!!

2009年3月17日火曜日

ASP.NET MVCでJavaScriptを上手いこと使う

How to iterate through objects in ViewData via javascript on the page/view? - Stack Overflow

この質問の回答としては、ページにscriptタグを書き、その中でJavaScriptを書きつつサーバーサイドのコード(foreachやViewDataの参照)も埋め込んでしまえばいいじゃないか、というもの。

コレまでもさんざん悩んで、ViewEngineの実装に寄り道したり右往左往したけど、結局はグローバルな変数にJavaScriptで使用したいデータをViewDataから取り出して登録しておき、ロジック(JavaScriptで書くクライアントでのっていう意味で)は外部JSファイルに。外部JSファイルからグローバル変数にアクセスすることで、Ajaxによるサーバーからのデータ取得や、サーバーサイドコードの埋め込みを不要にして、純粋なJavaScriptコードのみの外部JSファイルを作成する感じです。

まずJavaScriptの用途は、サイト全体で共通のコード(jQuery.jsやprototype.jsなどのフレームワーク、またはアプリケーション共通機能)と、そのページでしか必要のない機能に分けられる。さらにデータとしてはJSONPでサーバーサイドのデータをクライアントサイドに渡したり、サーバーサイドのデータをクライアントサイドにJSONにシリアライズしてページに直接埋め込む方法がある。もちろんXHRで取得や、DOMそのものをデータとして使う方法など他にもあるけど、今回はその辺はとりあげません。

整理すると処理コードとして

  • サイト共通
  • ページ固有

データコードとして

  • JSONP
  • JSONシリアライズのページ埋め込み

というのを今回のテーマとします。

サイト共通コードは、通常~/Scriptsフォルダに入れておくのがRC以降のテンプレートになってます。ページ固有コードは通常View内に直接scriptタグを書いて、その中に直接書くのがどこのチュートリアルにも書かれてるやり方になってます。が、View内に直接は書かないでViewと同じフォルダに外部JSファイルとして作成する方法をとります。なんでViewと同じフォルダに入れるかというと、スクリプトファイルを管理しやすくしたいからです。もちろん~/Scriptsに入れてもいいんだけど、そうするとViewと同じようなフォルダ構成(~/Content/Home/Index.jsとか)を作ってその中に入れるか、ルール通りのファイル名(Home.Index.jsとか)にして1つのフォルダ内にすべて保持するか。もちろんルール無用でViewとJSとを管理してもいいとは思うけど、簡単に管理しつつ、極力簡単なヘルパーでscriptタグを出力できるようにしたいので、Viewと同じフォルダ構成にしておき、アクション名.jsと命名するのがいいんじゃないかと。ただ、そうすると全く同じフォルダ構成を用意するというちょっとかっこ悪いことになり、スッキリしない!という理由でページ固有コードはViewと同じフォルダに保持するのがいいんじゃないかと思う次第です。

~/Viewsフォルダには通常aspx/ascxのいずれかを入れておく(WebFormViewEngine)んですが、そこに.jsファイルを置いておき、scriptタグでsrc指定するとどうなるかと言うと、そんなアクションありませんと怒られます。

~/Views/Home/Index.jsがあるからといって

<script type=”text/javascript” src=”/Home/Index.js”></script>

と書いてもきちんとダウンロードされないということです。

そりゃ、そうです。規約でそうなってるから。独自のMasterLocationFormatsやViewLocationFormatsを定義したとしても、BuildManager.CreateInstanceFromVirtualPathでJScriptのコンパイラが走ってあえなくエラー(クライアントサイドのコードなのにサーバーサイドのコードとしてコンパイルしようとする)。ViewEngineがらみをどうのこうのするのは、ちょっと面倒。

簡単な解決方法は、ページ固有コードをscriptタグのsrcとして指定する場合にはパスのルートに何かしら固定のフォルダ名を指定するようにして、そういうルーティングを行うようにしてしまう。これなら簡単。ルーティングが出来てもMvcRouteHandlerを使ったんじゃ結局コンパイルしようとするので、そこはファイルを直接返すだけの簡単なRouteHandlerを書いてしまいましょう。ようするにStaticFileHandlerなんだけど...。

IRouteHandlerの実装と、IHttpHandlerの実装。

  public class ViewScriptRouteHandler : IRouteHandler
 {
   public IHttpHandler GetHttpHandler(RequestContext requestContext)
   {
     string scriptName = requestContext.RouteData.GetRequiredString("scriptName");
     string scriptPath = string.Format("~/Views/{0}", scriptName);
     return new ViewScriptFileHandler(scriptPath);
   }
 }

 public class ViewScriptFileHandler : IHttpHandler
 {
   string virtualPath;

   public ViewScriptFileHandler(string path)
   {
     virtualPath = path;
   }

   public bool IsReusable
   {
     get { return true; }
   }

   public void ProcessRequest(HttpContext context)
   {
     var path = context.Server.MapPath(virtualPath);

     context.Response.ContentType = "application/x-javascript";
     context.Response.TransmitFile(path);
   }
 }

これをRouteCollectionに登録します。ページ固有コードなので/ViewScripts/コントローラ名/アクション名.jsというURLでアクセスさせたいと思います。MapRoute拡張メソッドを使った場合、登録したRouteが返ってくるので、それに直接ハンドラをセットするやり方です。

      routes.MapRoute(
       "ViewScripts",
       "ViewScripts/{*scriptName}",
       null,
       new { scriptName = @"[\w]*\/[.|\w]*\.js" }
     ).RouteHandler = new ViewScriptRouteHandler();

たったこれだけ。これだけのコードを書いておけば~/Views/Home/Index.jsを

<script type=”text/javascript” src=”/ViewScripts/Home/Index.js”></script>

として取得できます。あら簡単。

処理コードはとりあえずこれで組み込めるようになったので、こんどはJSONP。これはもう特に難しいことを考えずにコントローラのアクションとして実装してしまい、JavaScriptResultで返すのが一番簡単。

  [OutputCache(Location = OutputCacheLocation.None)]
 public ActionResult JsonpDate()
 {
   string result = "{}";
   try
   {
     result = Utility.ToJSON(new {Today = DateTime.Today.ToString(“yyyy/MM/dd”)});
   }
   catch { }

   return JavaScript(string.Format("var jsonp = {0};",result));
 }

例えば、HomeControllerに↑こんなアクションがあったとしたら

<script type=”text/javascript” src=”/Home/JsonpDate”></script>

と、書く事でjsonp変数に値が入ります。↓こう書いたのと同じ意味になります。

<script type="text/javascript">
var jsonp = {Today: '2009/03/17'};
</script>

ちなみにUtility.ToJSONの中身は単純。

    public static string ToJSON(object values)
   {
     if (values != null)
     {
#pragma warning disable 0618
       JavaScriptSerializer serializer = new JavaScriptSerializer();
       return serializer.Serialize(values);
#pragma warning restore 0618
     }
     else
       return "";
   }

System.Web.Script.SerializationのJavaScriptSerializerを使います。MVCの中でも同じように使ってるから問題無いでしょう。

最後にページ固有のデータをViewDataに入れておき、View内でヘルパーを使ってscriptタグに展開してしまうコードを作成します。使い方のイメージを先に書いてしまうと↓こんな感じです。

  <% = Html.RenderJSON(new {
      Today = DateTime.Today.ToString("yyyy年MM月dd日"),
      AjaxUrl = ViewData["Url"]
    }) %>

ViewData[“Url”]にアクション内で値を入れてるという前提で。

  public static string RenderJSON(this HtmlHelper helper, object values)
 {
   var modelValues = new RouteValueDictionary(values);
   var dict = new Dictionary<string, object>();
  
   foreach (var modelValue in modelValues)
     dict.Add(modelValue.Key, modelValue.Value);
    
   var formatJSON = "<script type=\"text/javascript\">\n" +
                    "var localjson = {0};\n" +
                    "</script>\n";
   string json = "{}";
  
   if (dict.Count > 0)
   {
     var modelData = dict.Select(v=>new {
                               Key = v.Key,
                               Value = v.Value
                           })
                         .ToDictionary(v=>v.Key, v=>v.Value);
     json = Utility.ToJSON(modelData);
   }

   return string.Format(formatJSON, json);
 }

こんな感じでしょうか。↓ViewData["Url"]に"どこそこ"とアクションで入れてればこう展開されます。

<script type="text/javascript">
var localjson = {Today: '2009/03/17', AjaxUrl: 'どこそこ'};
</script>

これで、共通コードは~/Contentなり~/Scriptsから取得、ページ固有はViewと同じフォルダ内にjsファイルとして作成したものを~/ViewScriptsから取得。Jsonpは通常のアクションとして実装し、ページ固有のデータはViewData経由でActionからViewに渡した物をJsonで展開。クライアントサイドでちゃんとevalするべきところではあるけどその辺はご愛敬ってことで。

これだけでも、コードとデータをきちんと分離は出来てるけど、もっと便利に簡単に使うために、スクリプトとデータのRegisterヘルパーと、それらをページの最後でまとめて書き出す、Renderヘルパーなんかを作っておいて、さらに圧縮(Compress)/縮小化(Minify)/連結(Merge)なんかも実装してたりするんですが、それはまた今度。いつの日か。

2009年3月2日月曜日

カスタムViewEngineを試す

ASP.NET MVCに限った話ではないんですが、JavaScriptをページで使う場合、外部ファイル(scriptタグのsrc属性で指定する)にするか、インラインでページに直接書く(scriptタグ内にコードを書く)かどっちかになる。

今時の作り方ならJavaScriptはページの最後で外部ファイルを取り込むのが、パフォーマンス的にもよろし、ということになってますね(ハイパフォーマンスWebサイト)。

ASP.NET MVCでも、もちろん外部JSファイルを使ったアプリケーションを作るわけですが、ここで少し悩みが出てきました。

処理コード(静的)としてのJavaScriptは簡単に外部に出せるから問題にはならないんですが、サーバーサイドで生成したデータを元に処理するコードの場合、そのデータをどうやって外部ファイルで使えばいいでしょうか。分かりにくい説明ですが、例えばサーバー上でデータベースから名前一覧を取得してViewDataに入れておいたとします(こればっかりは動的)。そのViewDataを外部JSファイルではもちろんそのまま参照できません。と、いうのもscriptタグで取り込んだ外部JSファイルとページそのもののリクエスト(MvcHandler)は別物だから、いくらコントローラのアクションでViewDataに値を入れたとしても外部JSファイルを取得したリクエスト(StaticFileHandler)では参照できないというのと、そもそもJSファイルが<%=~%>を解析して処理してくれないから。あたりまえですね~。

じゃぁ、どうすればデータ(サーバーサイドで動的生成)をコード(JavaScriptを静的に取得)に簡単に渡せるんだろう。

ページ専用のJavaScriptコードはどうしても、アクションで生成されるデータを使った動的コードにしたくなってしまう。でも、それだと外部ファイルじゃなくViewにscriptタグを書いてしまうことになって、なんかスッキリしない。

  1. 外部JSファイルを拡張子JSじゃなくASPXで作成し、JavaScript用のコントローラを作成(レスポンスのコンテンツタイプをapplication/x-javascriptに変更)し、動的にJSコードを生成するようにして(ViewファイルにJavaScriptコードを書く)、scriptタグでsrc指定。
  2. 外部JSファイルにはコードだけを書き、Viewに出力されるHTMLにscriptタグを書いておいて適当な変数にデータ(JSON)で入れておいて、外部JSファイルからはこれを参照する。

1の方法をとる場合、凄く分かりにくくなるのがコントローラが違うからViewとJavaScriptそれぞれでデータ取得のロジック(もちろんJavaScript用のコントローラではクライアントで必要なデータのみですが)を書かなきゃいけないからコードが散らばるうえに、VSで開発してるにも関わらずコードハイライトもインテリセンスも効かなくなる。

例えば...

// CharaControllerのViewアクション
public ActionResult Character() {
return View();
}

// JsControllerのJavaScriptアクション
public ActionResult Character() {
ViewData["chara"] = ToJson(new {firstName="ルフィ",lastName="モンキー"});
return View();
}

// ビュー
<body>
<!-- viewの定義 -->
</body>
<script type="text/javascript" src="JsController/Character"></script>

// Js/Character.aspx(JavaScript)
var chara = <% = ViewData["chara"] %>;
// 以降charaを使った処理
これだと2つのコントローラが必要になるし、ViewDataの生成と利用が離れすぎ。

綺麗にコードとデータを分離するなら、2の方法が正解ですよね。その場合Viewページにscriptタグを書く必要がありますが、あくまで処理コードは外部に分離できる。

例えば...

// CharaControllerのViewアクション
public ActionResult Character() {
ViewData["chara"] = ToJson(new {firstName="ルフィ",lastName="モンキー"});
return View();
}

// ビュー
<body>
<!-- viewの定義 -->
</body>
<script type="text/javascript">
var viewData = <% = ViewData["chara"] %>;
</script>
<script type="text/javascript" src="chara.js"></script>

// chara.js
var chara = viewData;
// 以降charaを使った処理

Viewアクション内でJavaScriptで利用するデータを生成しておき、外部スクリプトでのデータ参照はグローバル(この場合ならviewData)を見る。

どうも1の方法に固執しすぎてて、これを解決するためにカスタムのViewEngineを作ればいいんじゃないの?というおかしな路線に走ってしまって...。結局は2の方法にすることでViewEngine作る必要は無かったことに気がついたんだけど、その過程で今ネットで見つかるViewEngineの作り方が少し古いやり方な事に気がついたので、無駄にしないために、ココにメモとして残しておきます。

ASP.NET MVC Tip #25 – Unit Test Your Views without a Web Server Maarten Balliauw {blog} - Creating a custom ViewEngine for the ASP.NET MVC framework SingingEels : Creating a Custom View Engine in ASP.NET MVC Brad Wilson: Partial Rendering View Engines in ASP.NET MVC

ViewEngineを作るといっても、単にViewパスの検索場所を変更するためだけの目的もあれば、テンプレートエンジンを置き換えてしまう目的もあると思います。

パスを変えるだけならIViewEngineの実装はせず、PhilさんのAreasデモソース(Grouping Controllers with ASP.NET MVC)のようにWebFormViewEngineを派生させてViewLocationFormatsとMasterLocationFormatsをセットして、IViewは標準のWebFormViewを使えばいいですね。

テンプレートエンジンを変えてしまいたい場合は、IViewEngineとIViewそれぞれを実装することになりますが、今回は少し楽をしてIViewEngineにはVirtualPathProviderViewEngineを使うことにします。

何を作るかというと、拡張子jsの中に/$Key$/という形でテンプレートを入れとくと、ViewDataCollection内の同名Key値を埋め込むというテンプレート。これならJavaScriptのインテリセンスもコードハイライトも有効。

IViewEngineの実装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.Web.Hosting;

namespace Sample.Libraries
{
public class JavaScriptViewEngine : VirtualPathProviderViewEngine
{
public JavaScriptViewEngine()
{
  MasterLocationFormats = new string[0];

  ViewLocationFormats = new[]{
   "~/ViewScripts/{0}.js",
   "~/ViewScripts/Shared/{0}.js"
  };

  PartialViewLocationFormats = ViewLocationFormats;
}

protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
{
  return CreateView(controllerContext, partialPath, null);
}

protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
{
  return new JavaScriptView(viewPath);
}
}
}

IViewの実装。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Mvc;
using System.IO;
using System.Web;
using System.Text.RegularExpressions;

namespace Sample.Libraries
{
public class JavaScriptView : IView
{
private string _templatePath;

public JavaScriptView(string templatePath)
{
  _templatePath = templatePath;
}

public void Render(ViewContext viewContext, System.IO.TextWriter writer)
{
  var appPath = viewContext.HttpContext.Request.PhysicalApplicationPath;
  var filePath = VirtualPathUtility.ToAbsolute(_templatePath).Substring(1).Replace("/", "\\");
  var fullPath = Path.Combine(appPath, filePath);

  if (!File.Exists(fullPath))
    throw new InvalidOperationException("not exits javascript template file.");

  var template = File.ReadAllText(fullPath);

  writer.Write(Parse(template, viewContext.ViewData));
}

public string Parse(string contents, ViewDataDictionary viewData)
{
  return Regex.Replace(contents, @"\$\/(.+)\/\$", m => GetMatch(m, viewData));
}

protected virtual string GetMatch(Match m, ViewDataDictionary viewData)
{
  if (m.Success)
  {
    string key = m.Result("$1");
    if (viewData.ContainsKey(key))
      return viewData[key].ToString();
  }
  return String.Empty;
}
}
}

置換部分の処理はまるっきりStephenさんのコードです...。

拡張子jsのファイルはViewScriptsフォルダに入れておくようにしたものです。コントローラ名もフォマットに含めようと思ったんですが、そこはルーティングの登録を以下のようにしておくことでとりあえず必要無いな、と。でも、ViewScriptsフォルダ内はコントローラ名フォルダ/スクリプト名.jsでファイルを入れておきます。

      routes.MapRoute(
    "ViewScripts",
    "ViewScripts/{*path}",
    new { controller = "ViewScripts", action = "Index" }
  );

ViewEngineの登録も忘れずに。

ViewEngines.Engines.Add(new JavaScriptViewEngine());

あとはViewScriptsControllerを書くだけ。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;

namespace Sample.Controllers
{
public class ViewScriptsController : Controller
{
//
// GET: /ViewScripts/

public ActionResult Index(string path)
{
  ViewData["test"] = "{data:'sample'}";

  return View(path);
}

}
}

まずは動くコードを、という簡単なサンプルです。

テスト用にViewScripts/Test.jsを作成。

// test.js
var viewData = /$Test$/;
alert(viewData);

あとは、通常のViewでこれをインクルード。

<script type="text/javascript" src="/ViewScripts/Test.js"></script>

これで一応動くものが出来たわけですが、ここで2の方法でいいじゃん、と思い直してコードを破棄...。しかもこの方法だとViewScriptsControllerでいろんなViewDataを入れるためのコードが必要になって、面倒なことに。もっと早い段階で気がつけば良かったけど、カスタムViewEngineを書いてみる勉強になったから良しとします。

ちなみにカスタムViewEngineで最高のサンプルは今書いたこんな中途半端なコードじゃなくてStringTemplate Template Engineじゃないかと思われます。作ってみたい方は是非そちらを参照してみてください。

string-template-view-engine-mvc - Google Code

2009年2月25日水曜日

LINQで今日以降今年いっぱいの日曜の日付を表示

LINQ練習 #1 「LINQ to Object 基本」 - 悠希 - builder by ZDNet Japan

ここで書かれてるコードが↓これ。

List<DateTime> list;
list = new List<DateTime>();
for(int i=0;i<=365;i++) list.Add(DateTime.Today.AddDays(i));

int nowYear;
nowYear = DateTime.Today.Year;
var sunday = from M in list
            where M.DayOfWeek == DayOfWeek.Sunday &&
                  M.Year = nowYear select M;

foreach (var row in sunday) {
   Console.WriteLine(row.ToShortDateString());
}

※今年かどうか判定する条件の部分が代入になってるよ~。

せっかくLINQを使うっていうお題なんだから日付の生成部分もList<DateTime>に生成して入れておく、なんてことをしないで、そこもLINQに含めちゃった方がオシャレ感でると思うよ~。そう、例えば↓こんな感じでね。

var today = DateTime.Today;
var sunday = from date in
             from day in Enumerable.Range(0, 365)
             select today.AddDays(day)
            where date.DayOfWeek == DayOfWeek.Sunday &&
                  date.Year == today.Year
            select date; 

2009年2月23日月曜日

DataAnnotationsだけでの入力検証の盲点

以前の投稿(ASP.NET MVC RCの入力検証)で、如何にASP.NET MVCのDefaultModelBinder(IModelBinder)が汎用的になったかを取り上げましたね。

で、以前の投稿で見事に見逃してたのが「必須入力ではないけど、入力形式の不正メッセージを表示したいな」というところ。分かりにくいですね。例えば日付フィールドがあってモデルのプロパティはDateTimeなら当然日付形式の文字列じゃないとキャスト出来ないから、入力エラー。数値型ならintで当然アルファベットとか勘弁してくれよ、と。

必須フィールドならRequire属性でいいですよね。キャストに失敗した場合、何もセットされず型初期値(default(T))が入ったままだから、エラーメッセージに「ちゃんと入力してね(ハート)」って表示すれば。それでも、初期値が数値で0だと困る!って時にはNullable<Int32>とかでnullにしとけば、初期値のまま処理が進んじゃうって事も防げますから。

だけど、必須じゃない場合に「キャスト出来ませんでした」なんていうシステム固定のメッセージを出すのは、どうなのよ、なんて時があるもん。社内システムとかならそういうモンだから、で済むかもしれないけど、ネットに公開するならそういうメッセージはダサイ。いや、社内システムでもダサイけど、対象ユーザー層を考えれば、それでもまぁいいじゃん、っていうかね。

ちなみに、DataAnnotationsを使って、入力検証を実装した以前の実装だと、キャストエラー表示してくれないもんね。ModelStateDictionaryには(モデルを復元したタイミングで)ちゃんとエラーとして入ってるんだけど、ModelErrorクラスのErrorMessageにはメッセージが入って無くて、Exceptionプロパティに例外情報として入ってるから展開されないんです。

以前のテストプロジェクトにここで登場してもらいましょう。で、1箇所変更点として、PersonViewModelクラスのAgeプロパティについてるRequire属性を削除して、Range属性だけにしてみます。クラス定義は以下の通り。

public class PersonViewModel : BaseViewModel
{
 public int Id { get; set; }

 [Required(ErrorMessage="名前は?")]
 public string FirstName { get; set; }

 [Required(ErrorMessage="名字は?")]
 public string LastName { get; set; }

    [Range(0,150,ErrorMessage="0歳から150歳で")]
    public int? Age { get; set; }

 public List Weapons { get; set; }

 public PersonViewModel()
 {
   Weapons = new List();
 }

 public override string Error
 {
   get
   {
     if (Weapons == null || Weapons.Count == 0)
       return "武器、っていうか必殺技は?";

     return null;
   }
 }
}

太字のところですね。 これで、入力値に整数以外を入れて、ポストしたときのスクリーンショットが↓これです。

modelbind

ViewがRenderされるときに、ModelStateDictionaryがどうなってるかをブレークポイントをセットして確認してみましょう。

modelbind2

クリックすると大きく見れます。 Render時には、ModelState内のModelErrorは存在してるけど、ErrorMessageは""空文字で、ExceptionにInvalidOperationExceptionが入ってるのが分かります。

で、このExceptionをエラーメッセージとして表示するなら、そのまま取り出して、ErrorMessageに入れてしまうようなコードを書いてしまえばいいですね。ただ、エラーメッセージが今回の場合だと「17a は Int32 の有効な値ではありません。」と、出ちゃうんですよね。Int32って...。そんなこと表示されても普通の人は理解できないし。そもそもどのタイミングでメッセージの取り出し処理をすればいいでしょうね、って展開になります。

そこで、登場するのがIModelBinderのオーバーライド出来るメソッド群。これまたオレルールの方のエントリーの一番最後に書いてるIModelBinderのイベント発生順がキーになります。

結論から言うと、OnModelUpdatedのタイミング(モデルの復元完了時)に、ModelStateDictionary内のModelErrorに上記例外が含まれてるかチェックしてしまえばいい、という事になります。 ドンドン意味の分かりにくいエントリーになってきてますね~。

で、コードとしてはシンプルに↓こんな感じで動きます。LINQ部がかなり適当...。

public class ValidateModelBinder : DefaultModelBinder
{
 protected override void OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
 {
   base.OnModelUpdated(controllerContext, bindingContext);

   var modelStates = bindingContext.ModelState;


   // ここでModelStateDirctionaryにInvalidCastExceptionを含んだエラーが
   // ないかチェックして、あれば入力エラーメッセージをここでいれる。
   // ※表示するときに空メッセージ(InvalidCastException)は除外するから、
   //   コレクションには入れたままにしておく。
   // ModelErrorを消さずにそのまま残しておくことで、ViewPageでCSSクラス名
   // が追加されてどのフォームエレメントがエラーなのかは視覚的に
   // 判断できる。
   var key = "__InvalidOperationException__";
   if (!modelStates.Keys.Contains(key))
   {
     var isInvalidCast = (
                           from ms in modelStates
                           from err in ms.Value.Errors
                           where err.Exception is InvalidOperationException
                           select err
                         ).Count() > 0;
     if (isInvalidCast)
       modelStates.AddModelError(key, "入力形式の間違った項目があります。");
   }
 }
} 

こんな感じで、DefaultModelBinderを派生させたクラスを作成し、このModelBinderをDefaultBinderにしちゃいます。もう、これだけでいいっす。 なので、Global.asaxのApprication_Startの所に以下のコードを追加。

protected void Application_Start() { RegisterRoutes(RouteTable.Routes); ModelBinders.Binders.DefaultBinder = new ValidateModelBinder(); }

特定のViewModelでしか使わないよ!っていうならViewModelクラスの宣言時に

[ModelBinder(typeof(ValidateModelBinder))] public class モデルクラス {…}

って、書きましょう。

この状態で、もう一度動かしてみます。

modelbind3

今度はちゃんと、ValidationSummary()で表示されるようになりました。 今回のコードは無理矢理エラー項目のキーに"__InvalidOperationException__"と入れて、複数のエラーメッセージが出ないようにしてますが、もちろん項目毎に表示してもいいですよね。

若干気になるのは、なんでInvalidOperationExceptionを発生させるようにしてるのかな~、というところ。なんとなくだけど、InvalidCastExceptionのほうがシックリ来ないですかね~。そういうもんなんですか? ちなみにどこでInvalidOperationExceptionを発生させてるかというと、ASP.NET MVCに含まれるValueProviderResultクラスのConvertSimpleTypeメソッド。この中でTypeDescriptor.GetConverter()で取得したコンバーターのConvertToを呼び出すところらへん。

ココまで書いて思ったんだけど、ASP.NET MVCに関するエントリーだけでも全部こっちに持ってくればいいのかな。今はそれ以外の事を書く事もほとんどないし。そうしちゃおっかな。

ASP.NET MVCのエントリは全部移行しました。手作業で...。

dotnetConf2015 Japan

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