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

2011年1月14日金曜日

IIS ExpressのVS2008/VS2010利用

IIS ExpressをVisual Studio 2010に統合するにはSP1を待たないとだめですよね。でも、そんなの待ちきれない!という人はたくさんいることでしょう。IIS Expressをコマンドラインから起動パラメータを確認してみましょう。

iisex1

c:\Program files(x86)\IIS Express\iisexpress /?

IIS Express :
------------------
iisexpress [/config:config-file] [/site:site-name] [/siteid:site-id] [/systray:true|false] [/trace:trace-level]
iisexpress /path:app-path [/port:port-number] [/clr:clr-version] [/systray:true|false] [/trace:trace-level]

/config:config-file
applicationhost.config  Documents  IISExpress\config\applicationhost.config

/site:site-name
applicationhost.config

/siteid:site-id
applicationhost.config  ID

/path:app-path
/config

/port:port-number
8080 /path

/clr:clr-version
.NET Framework  (: v2.0) v4.0 /path

/systray:true|false
   true

/trace:trace-level
'none''n''info''i''warning''w''error' 'e'

\:
iisexpress /site:WebSite1
  WebSite1

iisexpress /config:c:\myconfig\applicationhost.config


iisexpress /path:c:\myapp\ /port:80
'80  'c:\myapp'

いろいろあるけど、プロジェクトファイルのパスと実行時のポートを指定すれば概ね動くってことなので、プロジェクトのプロパティを以下のように指定しましょう。

iisex2

動作開始のところで「外部プログラムを起動する」を選択して
C:\Program Files (x86)\IIS Express\iisexpress.exe
を指定。コマンドライン引数に
/path:プロジェクトフォルダ /port:8080
を指定(ポートはかぶらなければ何でも良し)。

あとはいつもどおり実行(F5)すると、コンソールが立ち上がってIIS Express経由でWebアプリケーションが起動します。とりあえずはブラウザの自動起動はしないので、ブラウザを立ち上げて
http://localhost:8080
と、入れて自分で開きましょう。この状態でもデバッグできるのでとてもゴキゲンです!

iisex3

iisex4

しかも同じ方法でVisual Studio 2008からも使えるので超絶便利!VS2008からは/clr:v2.0でランタイムバージョン指定も忘れずに。なんせ本番環境と同一のIISでデバッグできるし並列でリクエスト処理してくれてチョッパヤだし!Cassiniさらば!

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年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月26日木曜日

ModelBinderでLINQ to SQLのモデルをそのまま使う

ASP.NET MVC Tip #49 - Use the LinqBinaryModelBinder in your Edit Actions

ステファン君それは無理じゃない?更新以前にバインドした時点で例外でるじゃん。

と、思ってたのは今は昔。RCでは何の問題もなくバインド出来るみたい。試してみたら、普通に出来た。ずいぶん前に試したときはデータベースコンテキストがスタティックじゃないと例外でたり(ModelBinderに気をつけねば)、そもそも直接DBO使うのはうんぬんかんぬん。綺麗な設計どうのじゃなくて、純粋に少ないコード量でどこまで出来るか、っていうのを考えたら直接使う事もあったりするのかもね。

これまでも使ってたサンプルはDBを使ってなかったので、改めてDBを用意して、LINQ to SQLのモデルを作成。

db1

これを利用するためのコントローラも定義。

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

using Mvc.RC.Models;

namespace Mvc.RC.Controllers
{
public class OnePeaceController : Controller
{
  OnePeaceDataContext _context = null;

  public OnePeaceController()
  {
    _context = new OnePeaceDataContext();
  }

  public ActionResult Index()
  {
    return List();
  }

  public ActionResult List()
  {
    var people = _context.Persons;

    return View("List", people);
  }

  public ActionResult Details(int id)
  {
    var people = _context.Persons;

    return View(people.First(p => p.Id == id));
  }

  [AcceptVerbs(HttpVerbs.Get)]
  public ActionResult Edit(int id)
  {
    var people = _context.Persons;

    return View(people.First(p => p.Id == id));
  }

  [AcceptVerbs(HttpVerbs.Post), ValidateInput(false)]
  public ActionResult Edit(int id, Person person)
  {
    try
    {
      _context.Persons.Attach(person, true);
      _context.SubmitChanges();

      return RedirectToAction("Index");
    }
    catch (Exception ex)
    {
      return View(person);
    }
  }
}
}

List/Detail/Editの各ViewはRCで追加されたAdd Viewコマンドでサラッと作成。こういう時にとても便利ですね。Attachのところでブレークポイントをセットして、まずはこのまま動かしてみたところ、DB使っててもホントに動く。信じてなかったわけじゃないけど、こうもあっさりと動くとちょっと感動する。

bind

↑Listページ。

bind2

↑Editページ。

bind4

↑普通にバインドされてる様子。

でも、このままステップ実行すると、確かに例外が発生。

bind3

エンティティは、バージョン メンバを宣言するか、または更新チェック ポリシーを 含まない場合にのみ、元の状態なしに変更したものをアタッチできます。

System.Exception {System.InvalidOperationException}

と、言うことで、簡単に更新チェックポリシーをオフにしてしまおうかとも思ったけど、それよりちゃんと競合チェックするようにtimestamp型の列をテーブルに追加して動かしてみます。

db2

↑ChangeStampという名前のtimestamp型の列を追加したモデル。

でも、timestamp型はSystem.Data.Linq.Binary型としてクラスが生成されるので、このままだとちゃんとモデルが復元されません。なによりViewにちゃんと出力してないし。まずはEditのViewにChangeStampをhiddenで展開するコードを追加。

            <p>
              <%= Html.Hidden("ChangeStamp",
                              Convert.ToBase64String(Model.ChangeStamp.ToArray())) %>
              <input type="submit" value="Save" />
          </p>
      </fieldset>

  <% } %>

  <div>
      <%=Html.ActionLink("Back to List", "Index") %>
  </div>

</asp:Content>

submitの手前に入れてます。Base64エンコードしてHiddenフィールドに。このままだとDefaultModelBinderが復元してくれくれないので、Global.asaxのApplication_Start時にASP.NET MVC Futuresに入ってるLinqBinaryModelBinderを登録。

    protected void Application_Start()
  {
    RegisterRoutes(RouteTable.Routes);
    ModelBinders.Binders.Add(typeof(System.Data.Linq.Binary),
                              new LinqBinaryModelBinder());
  }

もう、何もかもステファンさんのいいなりです。

この状態で動かしてみて、Viewが出力するHTMLソースのChangeStamp部分を確認してみたのが↓これ。

<input id="ChangeStamp" name="ChangeStamp" type="hidden" value="AAAAAAAAB9Q=" />

ちゃんと、エンコードされて出力されてます。あたりまえだっちゅーの。

bind5

これを更新するためにsubmitして、ブレークポイントでモデルの中身を確認。ちゃんとLinqBinaryModelBinderでBinary型も復元されてます。そのまま実行を続けても例外は出なくて、テーブルも更新されてました。で、更新された後のViewのCangeStampを確認してみる。

<input id="ChangeStamp" name="ChangeStamp" type="hidden" value="AAAAAAAAB9U=" />

ちゃんと、最初とは違う値が入ってますね~。

と、いうことで、ステファンさんのやったことをそのまま試したみたわけですが、これが出来るって事はLINQ to SQL+スキャッフォールディングで凄く簡単にDBを使ったアプリケーションを作成出来る事になりますね。Repositoryは作るにしても、ViewModelを作らずシンプルなコードで開発することが出来るのでWebForms並の生産性(ViewStateとデータバインディング)を実現できてるんじゃないかと思う次第です。

※ViewStateはModelStateが各inputフィールドの値を保持しつつHTMLにレンダリングしてくれたりするので。

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のエントリは全部移行しました。手作業で...。

2009年2月18日水曜日

DataAnnotationsのValidationAttributeを作る

何となくね、n以上の値のみ許可、っていうValidationAttributeが欲しいな、と思ったわけですよ。 でも、実際はRangeで最小値とdouble.MaxValue(intでもいいけど)とかで、代用出来るじゃないですか。 悩ましいけど、コードを見てコレってようは最小値を制限したいってことなんでしょ?っていうのをすぐ分かるようにしとくなら専用のValidationAttributeかなって思ってね。

で、思ったわけ。そもそもなんて言う名前のクラスにすれば適切なんですか、って。 辞書で調べたらor moreとか、aboveとか出てくるじゃない。でもなんかね~、クラス名にするにはしっくり来ない。

みんなどうしてるんだろ。気になる。結局はさ古典(昔、なんかで勉強したときに確かGTEとかって書いた気がするし)に則って、GreaterThanOrEqualAttributeって名前にしたんだけどさ。長いよね。ちなみにLINQのExpression Treeではどうしてるのかな、とMSDN見てみたら(ExpressionType 列挙体 (System.Linq.Expressions))、ExpressionType.GreaterThanOrEqualだね。じゃ、そういうことで。

   

public class GreaterThanOrEqualAttribute : ValidationAttribute { private Type targetType { get; set; } private object minValue { get; set; }

public GreaterThanOrEqualAttribute(Type type, object min) { targetType = type; minValue = min; }

public override bool IsValid(object value) { if (value == null) return false;

var val = Convert.ChangeType(value, targetType) as IComparable; var min = Convert.ChangeType(minValue, targetType) as IComparable; // 同値以上ならOK return val.CompareTo(min) > 0; } }

って、書いて動くんだけど、まぁ、ちょっと、ね。

最初から用意されてないってことはRange使えよっていう意図があるような気がするから、Range使いましょう。

2009年2月11日水曜日

ASP.NET MVC RCの入力検証

とにかく、簡単に検証したい。

RCになってからIDataErrorInfoをモデルクラスに実装することで、DefaultModelBinderがプロパティ毎にValidationを呼び出すようになったりました。なのでコレを上手く生かしたい。 なおかつIDataErrorInfoの実装時にプロパティ名毎の検証ロジックを自分でコーディングしないでDynamic Dataで導入された、DataAnnotationsを使うようにしたい。

結果的にかなりシンプルに実装出来ることが判明。自分でDefaultModelBinderを派生させたクラスを作る必要もなく! DataAnnotationsっていうのはナオキさんの書いてるASP.NET Dynamic Dataの記事「簡単なデータ編集はお任せ! ASP.NET Dynamic Dataアプリケーション:CodeZine」に分かりやすい説明があるのでそちらを見てね。

簡単に言うとクラスのプロパティに属性ベースで検証ルールとエラーメッセージを指定するもの。 データベースのモデルクラス(DBO)に、検証ルールを入れたいときなんかにはMetadataType属性をクラスに指定しとけば、別のクラスで検証ルール属性を定義できたり、そりゃ~もう、便利そうでたまらない機能ですよ。

ScreenCast - How to work with DataAnnotations (VS2008 SP1 Beta) - Noam King's Blog ↑ここでビデオでもどんなものか確認できます。

ちょっと古いしUIに関することにも触れてるけど、DataAnnotationsの簡単な使い方が分かると思います(動的検証はまた別の話なので今回は触れてません)。

DataAnnotationsそのものは検証を実行してくれないので、検証の実行は自分で書く。書くと行っても属性指定しているValidationAttributeクラス(各検証属性の基底クラス)のIsValid()を呼ぶだけなんだけどね。

この呼び出し部分をIDataErrorInfoのItemプロパティ(インデクサ)に書く。 入力検証はDBOじゃなくViewModelに属性を定義して、そこでコードを書くようにしたい。なので、イメージ的には↓こんな感じ(図にしたほうが分かりやすいんだけど、面倒くさい)。

ViewModel : IDataErrorInfo
{
// モデルのプロパティを定義
 [検証属性A]
プロパティA
 [検証属性B]

プロパティB

// IDataErrorInfoの実装
Error {
 モデルに対する検証
}
this[columnName] {
 プロパティに対する検証
  → 検証属性クラスのIsValidを呼ぶ
}
} 
簡単そうでしょ?

ということで、前に書いたコード(ASP.NET MVC RCでIDataErrorInfoの使い方)をベースに進めることにします。 ここでちょっと訂正です。↑このエントリーでList<WeaponViewModel>の復元がDefaultModelBinderでは出来ないと書いちゃってて、個別にUpdateModelを呼び出さないきゃいけないよね~、なんて思いっきり間違いを書いてました。

コード見て分かるとおりPersonViewModelクラスのWeaponsがプロパティじゃなくてパブリックメンバ変数になってるから復元出来ないだけでした。 プロパティにすればちゃんと復元されます。

何でかというと、DefaultModelBinderで値を復元するときに、どのプロパティを対象にするか抽出するのに TypeDescriptor.GetProperties()を使ってるから(DefaultModelBinderクラスの316行目 GetModelPropertiesのコード参照)。名前の通りメンバ変数じゃなくてプロパティを抽出する関数なんだよね。そりゃ復元されないわ。 ってことで、DefaultModelBinder最強! ごにょごにょ言うよりコード見た方がたぶん早いと思うんでコードを載せときます。

1.IDataErrorInfoの実装

  public class BaseViewModel : IDataErrorInfo
 {
   public virtual string Error
   {
     get { return null; }
   }

   public string this[string columnName]
   {
     get
     {
       // ここで検証実行させる
       return this.Validate(columnName);
     }
   }

   // ↑ここまでがIDataErrorInfoの実装

   private PropertyInfo GetProperty(string name)
   {
     return this.GetType()
                .GetProperties()
                .Where(p => p.Name == name)
                .FirstOrDefault();
   }

   private string Validate(string columnName)
   {
     var property = this.GetProperty(columnName);
     if (property == null)
       return "致命的!";

     return Validate(property);      // 検証
   }

   private string Validate(PropertyInfo property)
   {
     // 検証ルール取得
     var validators = property.GetCustomAttributes(typeof(ValidationAttribute), true);
     foreach (ValidationAttribute validator in validators)
     {
       var value = property.GetValue(this, null);
       // 検証!
        if (!validator.IsValid(value))
         return validator.ErrorMessage;
      }

     return null;
   }
 } 
BaseViewModelクラスがIDataErrorInfoを実装。ViewModelは基本的にこのクラスを派生させる。

何でかというとItemインデクサの処理はどのクラスでも全く一緒だからっていうのと、Errorはシンプルなモデルクラスなら空(null)でイイから。 太字の部分がValidationAttributeの呼び出し。1つのプロパティには複数の属性をセットしてもいいので、全部の検証がOKかどうかチェック。

2.ViewModelクラスの定義

  public class WeaponViewModel : BaseViewModel
 {
    [Required(ErrorMessage="タイプは絶対!")]
   [StringLength(10, ErrorMessage = "タイプは10文字以内でね")]
    public string Type { get; set; }

    [Required(ErrorMessage = "名前は絶対!")]
   [RegularExpression("[^a-zA-Z0-9]*", ErrorMessage = "名前に半角英数含んだらダメ")]
    public string Name { get; set; }
 }

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

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

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

    [Required(ErrorMessage="整数で入れてね")]
   [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;
     }
   }
 } 

※太字がDataAnnotationsのValiudationAttribute。 PersonViewModelではモデルのエラーチェックをしたい(Weaponsプロパティのアイテム数チェック)から、Errorをオーバーライド。 ホントはRequire属性で判定出来るんじゃないかと思ってたんだけど、Listに1件もポストされない時に呼び出されないってことが判明(復元対象の値が存在しないから)。 試しにValidationAttributeクラスを派生させて、ListRequireAttributeクラスなんてものを書いてみたけど、そもそも呼び出してくれないから意味なかった。

  public class ListRequireAttribute : ValidationAttribute
 {
   public override bool IsValid(object value)
   {
     var list = value as IList;

     if (list == null)
       return false;

     if (list.Count == 0)
       return false;

     return true;
   }
 } 

※全く無意味なクラス。 ↑ こんなクラスを書いても、List<T>の検証が呼び出されない(Listに1つでもアイテムが追加されるなら呼び出されるけど、何も追加されない場合はスルー)ので、IDataErrorInfo.Errorをオーバーライドしてモデル検証としてチェックするようにしました。 AgeプロパティがintじゃなくてNullable<Int32>なのは、何も入力しなかった場合にintだと0になっちゃうのと、整数じゃなくて実数を入れたときにも0になっちゃう(intの初期値)のが都合悪いからです。

3.DefaultModelBinderで値を復元

    [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(int id, PersonViewModel person)
   {
     if(ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   } 

ココまででコントローラでは普通に復元されるようになります。 入力エラーが分かりやすくなるように、Viewも少しいじったのでEdit.aspxのソースも貼り付けときます(と、言ってもValidationMessageを入れただけ)。

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

<asp:Content ID="Content1" ContentPlaceHolderID="head" runat="server">
   <title>Edit</title>
</asp:Content>

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

   <h2>Edit</h2>

   <%= Html.ValidationSummary() %>

   <% using (Html.BeginForm()) {%>

       <fieldset>
           <legend>Fields</legend>
           <p>
               <label for="Id">Id:</label>
               <%= Html.Encode(Model.Id) %>
           </p>
           <p>
               <label for="FirstName">FirstName:</label>
               <%= Html.TextBoxFor(p=>p.FirstName) %>
               <%= Html.ValidationMessage("FirstName", "*") %>
           </p>
           <p>
               <label for="LastName">LastName:</label>
               <%= Html.TextBoxFor(p=>p.LastName) %>
               <%= Html.ValidationMessage("LastName", "*") %>
           </p>
           <p>
               <label for="Age">Age:</label>
               <%= Html.TextBoxFor(p=>p.Age) %>
               <%= Html.ValidationMessage("Age", "*") %>
           </p>
         
           <p>
             <% foreach(var weapon in Model.Weapons){
                  var index = Model.Weapons.IndexOf(weapon);
                  %>
               タイプ
               <%= Html.TextBox(string.Format("Weapons[{0}].Type", index), weapon.Type)%>
               <%= Html.ValidationMessage(string.Format("Weapons[{0}].Type", index), "*")%>
               名前
               <%= Html.TextBox(string.Format("Weapons[{0}].Name", index), weapon.Name)%>
               <%= Html.ValidationMessage(string.Format("Weapons[{0}].Name", index), "*")%>
               <br />
             <% } %>
           </p>
           <% = Html.AntiForgeryToken() %>
           <p>
               <input type="submit" value="Save" />
           </p>
       </fieldset>

   <% } %>

   <div>
       <%=Html.ActionLink("Back to List", "Index") %>
   </div>

</asp:Content>

これを実行したときの画面が↓これら。

img.aspx10

前回同様、一覧。Editでルフィーを選択。

img.aspx11 名前とタイプのところが、List<WeaponsViewModel>の入力フォーム。 この状態のまま、Saveボタンを押してポストする。

img.aspx12

バッチリ復元されました。 まぁ、これだとDataAnnotations関係ないので、あえて入力エラーになるように、フォームの内容を変更。

img.aspx13 今度はこれをポスト。

img.aspx14 すると、DataAnnotationsがちゃんと動作してるのが確認できます。 すばらしい。

ただ、コレだとちょっとエラーメッセージが分かりにくいと思う。 メッセージそのものの文章が変とかっていう話じゃなくて、List<WeaponsViewModel>のどのアイテムがエラーなのかが分かりにくいんじゃないかと。

なので、エラーメッセージにアイテムのインデックスを含めたい。 でも、ValidationAttributeクラスのErrorMessageって固定文字列。動的に生成出来ないですよね。 これは参ったな~。DataAnnotationsの限界か!? と、思ったけど、ちょっと待て。 エラー情報は、どのフォームフィールドなのかと入力値(ValueProviderResult)、それとエラーメッセージ(ModelError)は自動でModelStateDictionaryに入るので、これを強制的に書き換えればいいんじゃないかななんて思ったわけです。 なので、エラーメッセージにインデックス番号を含めるように置き換える拡張メソッドをModelStateDictionaryに追加することにしました。

4.ModelStateDicrionaryの拡張メソッド

  public static class ModelStateExtensions
 {
   public static void ReplaceSequencialErrorMessage(this ModelStateDictionary modelState, string prefix, string format)
   {
     foreach (var ms in modelState)
     {
       var replaceErrors = new Dictionary();
       if (ms.Key.StartsWith(prefix + "["))
       {
         // 置き換え対象のエラー検索
         foreach (var error in ms.Value.Errors)
         {
           if (!string.IsNullOrEmpty(error.ErrorMessage))
           {
             var start = prefix.Length + 1;
             var end = ms.Key.IndexOf("]", start);
             if (end > start)
             {
               var indexVal = ms.Key.Substring(start, end - start);
               int index;
               if (int.TryParse(indexVal, out index))
               {
                 replaceErrors.Add(error, new ModelError(
                   format.NamedFormat(new { index = index + 1, message = error.ErrorMessage })
                 ));
               }
             }
           }
         }
       
         // 消して追加
         foreach (var e in replaceErrors)
         {
           ms.Value.Errors.Remove(e.Key);
           ms.Value.Errors.Add(e.Value);
         }
       }
     }
   }
 } 

ModelError のErrorMessageが直接書き換えできるならもっと簡単に書けるけど、残念ながらこのプロパティはリードオンリー(getterしかない)。なので、ModelErrorを削除してErrorMessageを書き換えたModelErrorを追加することで、この機能を実現。なんか遠回りだね。 エラーメッセージを置き換えたいプロパティ名と、どういう書式でエラーメッセージを書き換えるのかのフォーマット文字列を引数に指定する。ここで先日作成したNamedFormat(いまだ最速?)を使ってみました(ソース中太字のところ)。 これらを利用するようにアクションを少し変更。

    [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(int id, PersonViewModel person)
   {
     if(ModelState.IsValid)
       return RedirectToAction("Index");

      ModelState.ReplaceSequencialErrorMessage("Weapons", "{index}番目の{message}");
      return View(person);
   } 

※これまた1行追加するだけ。 この状態でさっきと同じ入力エラーを発生させる。

img.aspx15

5.DefaultModelBinderのイベント

ちょっと、長くなってきたけど最後にRCになって変更されたModelBinderのイベントについて少し確認してみました。と、言っても、DefaultModelBinderを派生させて各タイミングでログを出すだけなんですけどね。

リリースノート17ページの「ModelBinder API Changes」にまるっと全部書いてることですけど、以下のメソッドをオーバーライドすることで、各イベントに合わせて処理を実行出来るようになってます。 1. CreateModel 2. OnModelUpdating 3. GetModelProperties 4. BindProperty a. OnPropertyValidating b. SetProperty c. OnPropertyValidated 5. OnModelUpdated まんまですが、以下のような単純なクラスを書いて、PersonViewModelとWeaponsViewModelのModelBinderとして宣言してみました。

  public class DebugModelBinder : DefaultModelBinder
 {
   public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("BindModel - {0}", bindingContext.ModelName)
     );
     return base.BindModel(controllerContext, bindingContext);
   }


protected override void BindProperty(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor)
   {
     Debug.WriteLine(
       string.Format("BindProperty - {0}", propertyDescriptor.Name)
     );
     base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
   }


protected override object CreateModel(ControllerContext
controllerContext, ModelBindingContext bindingContext, Type modelType)
   {
     Debug.WriteLine(
       string.Format("CreateModel - {0}", modelType.Name)
     );
     return base.CreateModel(controllerContext, bindingContext, modelType);
   }


protected override System.ComponentModel.PropertyDescriptorCollection
GetModelProperties(ControllerContext controllerContext,
ModelBindingContext bindingContext)
   {
     Debug.WriteLine("GetModelProperties");
     return base.GetModelProperties(controllerContext, bindingContext);
   }

   protected override void OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("OnModelUpdated")
     );
     base.OnModelUpdated(controllerContext, bindingContext);
   }

   protected override bool OnModelUpdating(ControllerContext controllerContext, ModelBindingContext bindingContext)
   {
     Debug.WriteLine(
       string.Format("OnModelUpdating")
     );
     return base.OnModelUpdating(controllerContext, bindingContext);
   }


protected override void OnPropertyValidated(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("OnPropertyValidated - {0} = {1}",
                     propertyDescriptor.Name,
                     value )
     );
     base.OnPropertyValidated(controllerContext, bindingContext, propertyDescriptor, value);
   }


protected override bool OnPropertyValidating(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("OnPropertyValidating - {0} = {1}",
                     propertyDescriptor.Name,
                     value)
     );

     return base.OnPropertyValidating(controllerContext, bindingContext, propertyDescriptor, value);
   }


protected override void SetProperty(ControllerContext
controllerContext, ModelBindingContext bindingContext,
System.ComponentModel.PropertyDescriptor propertyDescriptor, object
value)
   {
     Debug.WriteLine(
       string.Format("SetProperty - {0} = {1}",
                     propertyDescriptor.Name,
                     value)
     );
     base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);
   }
 } 

※BindingContextに復元に必要なすべての情報が入ってます。 ViewModelの先頭で以下の属性を追加。

[ModelBinder(typeof(DebugModelBinder))] public class PersonViewModel : BaseViewModel {...}

これを試すために、ルフィーのデータをそのままポストしてみました。 出力された内容が↓こちら(インデントは自分で入れてます)。

BindModel - person CreateModel - PersonViewModel OnModelUpdating GetModelProperties BindProperty - Id OnPropertyValidating - Id = 1 SetProperty - Id = 1 OnPropertyValidated - Id = 1 BindProperty - FirstName OnPropertyValidating - FirstName = ルフィー SetProperty - FirstName = ルフィー OnPropertyValidated - FirstName = ルフィー BindProperty - LastName OnPropertyValidating - LastName = モンキー SetProperty - LastName = モンキー OnPropertyValidated - LastName = モンキー BindProperty - Age OnPropertyValidating - Age = 17 SetProperty - Age = 17 OnPropertyValidated - Age = 17 BindProperty - Weapons BindModel - Weapons[0] CreateModel - WeaponViewModel OnModelUpdating GetModelProperties BindProperty - Type OnPropertyValidating - Type = ゴムゴム SetProperty - Type = ゴムゴム OnPropertyValidated - Type = ゴムゴム BindProperty - Name OnPropertyValidating - Name = ガトリング SetProperty - Name = ガトリング OnPropertyValidated - Name = ガトリング OnModelUpdated BindModel - Weapons[1] CreateModel - WeaponViewModel OnModelUpdating GetModelProperties BindProperty - Type OnPropertyValidating - Type = ゴムゴム SetProperty - Type = ゴムゴム OnPropertyValidated - Type = ゴムゴム BindProperty - Name OnPropertyValidating - Name = 鞭 SetProperty - Name = 鞭 OnPropertyValidated - Name = 鞭 OnModelUpdated OnPropertyValidating - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] SetProperty - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] OnPropertyValidated - Weapons = System.Collections.Generic.List`1[Mvc.RC.Models.WeaponViewModel] OnModelUpdated

無駄に長い貼り付けになっちゃったけど、BindModelでインスタンス化する変数名(またはプロパティ名)を決定し、CreateModelでインスタンス作成、OnModelUpdating~OnModelUpdatedの中でプロパティ毎の処理。プロパティはモデルと同じように BindPropertyで対象を決めて、SetPropertyでセット。セットの前後でOnPropertyUpdatingと OnPropertyUpdatedが呼ばれる。

なので、ModelBinderの中で直接検証したりするときには、OnModelUpdatedでモデル検証、OnPropertyUpdated(セット前に検証するならOnPropertyValidating)でプロパティの検証でいいのかな。

思いの外、挙動の確認に手間取ったけど、リリース版でもModelBinderはこの流れだろうから、DataAnnotationsを使った検証はかなり有効な手段だと思います。お試しアレ。

2009年2月7日土曜日

強力になったDefaultModelBinder

配列を保持するときに、コレまでHiddenにプレフィックス+”.Index”の名前でインデックス番号を保持しておかないと、きちんと復元してくれなかったのが、Index無しでもちゃんと復元出来るようになってる!

DefaultModelBinderクラスのUpdateCollectionのコードのリファクタリングを進めて、Index値を内部でループで回すように変更した結果だね。

なので0から始まる連番じゃないのは困っちゃう(-1から始めるとか1,3,5とか)けど、基本的に連番にするだろうから問題ないと思われる。 そもそも連番じゃないなら、違うフィールド(Hidden)に持つなりするはずだし。

コレまで、このHiddenのIndexが曲者で、一度Postされたあとに消して(ModelStateDictionaryの値が自動で復元されるルールが適用されて) おかないときちんと復元出来なかったのが、Indexそのものを使用しなくなったおかげで、Indexの出力も削除も不要に。

RC ModelBinder breaking changes for collections - ASP.NET Forums

コレクションをInput要素に展開する場合に、若い番号の値群を削除してもModelStateから若い番号の値が復元されてしまうってことなんで、結局 Indexを持つFormを作る時には、自分でModelStateの値を消して再構築するなり、Input生成時に値を渡すようにするか、 ViewDataに入れとくかという事はやらないとね。

ベータになって

↑こういう問題があったのを↓解決させてた。

あぁ~、そうか、こうすればいいんだ

※AttemptedValueに直接null入れてるけど。

ベータの時のコードをRCに移植する際にエラーになってしまった物として、ModelStatesに入ってる値を消す方法がコレまで ModelState.Value.SetAttempedValueだったのが、RCから綺麗さっぱりそのメソッドは無くなって (ValueProvider経由のValueProviderResultで取得)、代わりにModelStates.SetModelValueでキー名とValueProviderResultを渡すようになったので、この問題に気がついた次第です。

Custom ModelBinder and Release Candidate - ASP.NET Forums

ベータの消し方 foreach(var ms in modelStates.Where(ms=>ms.Key == "消したいキー")) { ms.SetAttemptedValue(null); // これでModelStateの値が消える }

RCの消し方 foreach(var ms in modelStates.Where(ms=>ms.Key == "消したいキー")) { modelStates.SetModelValue( ms.Key, new ValueProviderResult(null,null,null) ); // これでModelStateの値が消える }

DefaultModelBinderがらみでもう一つ。 フォームポストされるデータを、アクションの引数にクラスを使って復元させるとき、クラスにHttpPostedFileBase(ファイルアップロード)を含んでいると、そのままじゃ復元してくれない罠。 何でだろね。あと、デフォルト動作としてValidateRequestが有効になるようになってる。

例えば、以下のようなアクションをデフォルトで作成されるHomeControllerに定義。

    [ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(string textArea, HttpPostedFileBase uploadFile)
   {
     return View();
   } 
んで、Indexページに以下のコードを書く。
  <% using (Html.BeginForm("Index", "Home", FormMethod.Post, new { enctype = "multipart/form-data" })) { %>

 <fieldset>
 <legend>フォームテスト</legend>
 <% = Html.TextArea("textArea")%><br />
 <input type="file" name="uploadFile" /><br />
 <% = Html.SubmitButton("send", "送信")%>
 </fieldset>

 <% } %>

img.aspx4 こんな感じの単純な物なんだけど。 例えばコレで、テキストエリアに"<script />"なんて入れて送信すると...。

img.aspx5 見慣れたエラーが出るね。 だけど、アクションにRCで導入されたValidateInputAttributeを指定して以下のような定義に書き換えると、ASP.NETの入力チェックがスルーされてコレまでと同じ動きをしてくれます。

    [ValidateInput(false), ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(string textArea, HttpPostedFileBase uploadFile)
   {
     return View();
   } 
Viewsフォルダのweb.configやaspxのPageディレクティブ指定のValidateRequestはページに対しての指定で、アクションに対する指定じゃないので、気をつけましょう。

WebFormsの時はPageにPostBackされてたけどMVCだとControllerにPostするので、その違いがこんな所に出てきてます。まぁ、デフォルト安全動作っていうのはいいことだね。ベータ以前から移行の場合は修正箇所は増えるけど。

肝心のファイルアップロードといえば、以下の通り。

img.aspx6

普通に入ってるね(Vistaにサンプルで入ってる写真をポスト)。 今度はアクションの引数に自作クラス(ViewModel)を用意して、DefaultModelBinderに復元してもらうようにする場合。

以下のようなクラスを用意。

  public class FormPost
 {
   public string textArea { get; set; }
   public HttpPostedFileBase uploadFile { get; set; }
 } 

んで、アクションを以下のように書き換え。

    [ValidateInput(false), ActionName("Index"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult IndexPost(FormPost post)
   {
     return View();
   } 

そうすると今度はどうなるかと言うと...。 ※Viewは書き換えてないです。

img.aspx7

post.uploadFileはnullになってますね。見にくいですけど。 これは、以下のようにViewを書き換えることでちゃんととれるようになります。

  <% using (Html.BeginForm("Index", "Home", FormMethod.Post, new { enctype = "multipart/form-data" })) { %>

 <fieldset>
 <legend>フォームテスト</legend>
 <% = Html.TextArea("textArea")%><br />
 <input type="file" name="uploadFile" /><br />
 <% = Html.Hidden("uploadFile.exists", true) %>
 <% = Html.SubmitButton("send", "送信")%>
 </fieldset>

 <% } %>

input=fileのフォーム要素と同じ名前+".exists"のhiddenを作成して、valueに"true"を入れる。 これだけなんだけど、なかなか気がつかないよね。

img.aspx8

今度はuploadFileがnullじゃな~い。 HttpPostedFileBase bug when binding - ASP.NET Forums

2009年2月2日月曜日

ASP.NET MVCで非同期リクエスト

Improve scalability in ASP.NET MVC using Asynchronous requests « Steve Sanderson’s blog まずは↑。

非同期のIHttpAsyncHandlerをMVCでも使おうよ、という話。 ソースは部分的。

で、このたびリリースされたASP.NET MVC RC(Refreshはお早めに)。 の、FuturesにAsyncControllerが含まれてる。

そりゃ~、もう気になって仕方ないよね。 とりあえずは、ソースを確認して、どういう構成で非同期実装してるのかを見ることにしたんだけど、どうにも要領を得ないな。

AsyncManager.RegisterTast

上記エントリだとアクション内でRegisterAsyncTaskを読んで、Begin/Endそれぞれのdelegateを登録するという流れなので、 RC Futuresのソースを眺めてて、パッと目につくのがAsyncManager.RegisterTast()。 名前からしてタスクを登録するメソッド。

public IAsyncResult RegisterTask(Func beginDelegate, AsyncCallback endDelegate) 

こんな宣言なのを見ると、上記エントリと同じ使い方でいいんじゃないかと思えるんだけど。 試しに書いたコードが↓。

    public void Image(string fileName)
    {
      AsyncManager.RegisterTask(cb => {
        Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        Thread.Sleep(3000);
        cb(null);
      }, delegate(IAsyncResult result) {
        Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
          new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName }));

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
        {
          File(path, "image/png").ExecuteResult(ControllerContext);
        }
        else
          Response.StatusCode = 404;
      });
    }

Add_Data/ImagesにPNG画像ファイルを入れて、そのファイル名を指定するとファイルを返すという簡単なもの。 動くのは動く。普通に。 でも、スレッドIDはbegin/endどっちも同じものしか使われてない様子。 開発環境だからかな~。なんでかな~。 レスポンスを返す部分で、ActionResult.ExecuteResult()を呼んで、その場でResponseしちゃうっていうのはちょっと違う気がしなくもない。

Action/ActionCompleted

次にソースを追っかけて気がついた。「// Is this the Foo() / FooCompleted() pattern?」なんてコメント発見。普通にアクションを書いて、同じ名前のアクション名+Completedっていうアクションを定義する方法。わかりにく!

    public void Image2(string fileName)
    {
      Thread.Sleep(3000);

      HttpContext.Items["params"] = fileName;
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, fileName}));
    }

    public ActionResult Image2Completed()
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
      
new object[] { Thread.CurrentThread.ManagedThreadId,
Thread.CurrentThread.IsThreadPoolThread, HttpContext.Items["params"]
}));

      var dir = Server.MapPath("~/App_Data/Images");
      var path = string.Format("{0}\\{1}", dir, HttpContext.Items["params"]);

      if (System.IO.File.Exists(path))
        return File(path, "image/png");
     
      Response.StatusCode = 404;
      return new EmptyResult();
    }

ようするに↑こうなんだけど。 んで、これだと、スレッドIDが変わることがあるから、なんか上手く行ってる気がしなくもない。 なんせ、リクエストを受け付けたときのパラメータがCompletedの方には渡されないから、HttpContext.Itemに入れてるのが、かなり自信ない。 でも、この書き方だと、Completedの戻り値がActionResultだから分かりやすいんじゃないかと思える。 スレッドIDも違うし。

BeginAction/EndAction

さらにコードを追いかけてると、今度は「// Is this the BeginFoo() / EndFoo() pattern?」と書かれてる。 ってことは、アクション名にBeginなんちゃら/Endなんちゃらってかいておくと、それを呼び出してくれるのかなと。ソースもAsyncActionMethodSelectorだし。 で、試した。 今度はBegin/Endのパラメータ指定に制限があって、Beginは戻り値IAsyncResultで引数AsyncCallback callback, object stateが必須。EndにはIAsyncResult resultが必須。

    public IAsyncResult BeginImage3(AsyncCallback callback, object state)
    {
      Debug.WriteLine(string.Format("request thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = WebRequest.Create("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
      HttpContext.Items["web"] = web;
      return web.BeginGetResponse(callback, state);
    }

    public void EndImage3(IAsyncResult result)
    {
      Debug.WriteLine(string.Format("response thread:{0}={1}({2})",
        new object[] { Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread, "test" }));

      var web = HttpContext.Items["web"] as WebRequest;
      WebResponse res = web.EndGetResponse(result);

      Response.ContentType = res.ContentType;

      var reader = new BinaryReader(res.GetResponseStream());
      Response.BinaryWrite(reader.ReadBytes((int)res.ContentLength));
    } 

追記 ここはHttpContext.Item使わなくてもBeginGetResponseにstateの代わりにweb渡せばEndAsync3でresult.AsyncStateでとれますね。

今度はFlickrの画像をWebRequestで取得するように変更。 戻り値が必要だし。 んで、デバッグ出力のスレッドIDはBeginとEndでちゃんと違う。 そんなこんなで、どうやって書くのが正しいのかいまいちよく分からないAsyncController。 ちなみにRouteは↓こんな感じで書いてます。

      routes.MapAsyncRoute(null,
        "Async3/{*url}",
        new { controller = "AsyncImages", action = "Image3" }
      );

      routes.MapAsyncRoute(null,
        "Async2/{fileName}",
        new { controller = "AsyncImages", action = "Image2" }
      );

      routes.MapAsyncRoute(null,
        "Async/{fileName}",
        new { controller = "AsyncImages", action = "Image" }
      ); 

悩ましいな~。なんて思ってた所に新たな非同期エントリ登場。

Extend ASP.NET MVC for Asynchronous Action - Happy Coding

これまた全然違う作り方してるんだよね。

Controllerはそのままに、AsyncMvcHandler実装(他にもいろいろあるけど)で乗り切る方法。 これだとControllerFactoryもいじる必要無いし、アクション単位でAsyncAction属性指定で非同期判別。 戻り値もActionResultだし。

サンプルコードが↓こんなの。

        [AsyncAction]
        public ActionResult AsyncAction(AsyncCallback asyncCallback, [AsyncState]object asyncState)
        {
          
SqlConnection conn = new SqlConnection("Data
Source=.\\sqlexpress;Initial Catalog=master;Integrated
Security=True;Asynchronous Processing=true");
            SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:03';", conn);
            conn.Open();
           
            return this.Async(
                cmd.BeginExecuteNonQuery(asyncCallback, asyncState),
                (ar) =>
                {
                    int value = cmd.EndExecuteNonQuery(ar);
                    conn.Close();
                    return this.View();
                });
        }

this.AsyncっていうのがControllerの拡張メソッド。これでEndProcessRequestの時に呼び出すdelegateを指定。スゴイよね。 コード量も少ないし。delegateの戻りがそのままアクションの戻りとして使われるし。 なんかコードみてるとasyncCallbackとasyncStateをCallContextに入れるようになってる。初めて見た。 コレだとスレッド単位のコンテキストオブジェクト管理が出来るっぽい。

ActionResultなんかはHttpContext.Itemに入れて、リクエストコンテキストで管理。 ちなみにコレのプロジェクトにベンチマーク用のコードが入っててこれいいじゃん!みたいな。 PowerShellとIIS6リソースキットに含まれるTinyGetを使って時間を計測。 シンプルで簡単なテストだから試してみた。

試すに当たって使った同期版のコードは↓。

      public ActionResult Sync(string fileName)
      {
        Thread.Sleep(3000);

        var dir = Server.MapPath("~/App_Data/Images");
        var path = string.Format("{0}\\{1}", dir, fileName);

        if (System.IO.File.Exists(path))
          return File(path, "image/png");

        return new EmptyResult();
      }

      public ActionResult Sync2(string url)
      {
        var web = new WebClient();

        var bytes = web.DownloadData("http://farm1.static.flickr.com/131/353753310_1ed04f694c_m.jpg");
        return File(bytes, web.ResponseHeaders["Content-Type"]);
      } 

Sync vs Async(Imageアクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.33秒、Async 14.68秒。

Sync vs Async2(Image2アクション)

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 21.40秒、Async 12.70秒。

ここまでの2個のテストではそれぞれ3秒のウェイトを入れてこの時間だから、非同期でリクエストスレッドを早く開放したほうが次のリクエストを続々と受け入れることが出来るようになるんだから、最終的にこのくらいの差が出てくるのも納得(だよね?)。

Sync2vs Async3(Image3アクション):Flickrから取得

Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Sync2/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Measure-Command {.\tinyget -srv:localhost -r:53976 -uri:/Async3/Cart.png -threads:50 -loop:1} [System.Threading.Thread]::Sleep(2000) Sync 3.84秒、Async 2.16秒。

コレに関してはあんまり意味ないね。Flickrからの取得にかかる時間が毎回一定なわけじゃないし。 Any good ideas to build an async action for scalability improvement? - ASP.NET Forums ここで、それぞれの設計について話をしてる。

OnAction~フィルターの動作も非同期にしないとっていう話? ふむ~。 じきにAsyncControllerの使い方を公開するってことなんでそれまで待つのがいいかもね。

2009年1月29日木曜日

ASP.NET MVC RCでIDataErrorInfoの使い方

ソース出てきたので早速IDataErrorInfoのチェック。 普通にソース見れるって幸せだね。

まずはIDataErrorInfoをどこで使ってるのか検索。 そしたらDefaultModelBinderでしか使ってないのが判明。 しかもDefaultModelBinderでは、OnModelUpdatedとOnPropertyValidatedの2箇所だけ。 なるほど。モデル全体の更新完了タイミングと、個々のプロパティ検証時に実行されるわけね。 こういう設計にしたいからModelBinderが大きく変わったんだね。 WPF的な?

IDataErrorInfoだとErrorプロパティとItem(this[string columnName])を実装するんだけど、その中に検証コードを書いてしまうと。んで、入力検証処理はココに集約しましょうと。 ってことは、アレだね、結局DataAnnotationsを使うのも同じだね。DataAnnotationsだとエラー情報を集約して ModelState.AddModelErrorを呼び出すコードは自分で書かなきゃいけないけど検証処理自体は属性ベースで簡単にできる。 IDataErrorInfoだと検証コードは自分で書かなきゃいけないけど、エラーはModelStateに自動で入れてくれる。

どっちもどっちだね。 試しにコードを書いてみた。 Scaffoldingとかも試してみたかったしね!

ビューモデルの定義

ModelsフォルダにPersonViewModelクラスを作成。

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

namespace Mvc.RC.Models
{
 public class WeaponViewModel
 {
   public string Type { get; set; }
   public string Name { get; set; }
 }

 public class PersonViewModel : IDataErrorInfo
 {
   public int Id { get; set; }
   public string FirstName { get; set; }
   public string LastName { get; set; }
   public int Age { get; set; }
   public List Weapons;

   public string Error
   {
     get {
       if (Id == 0 ||
           string.IsNullOrEmpty(FirstName) ||
           string.IsNullOrEmpty(LastName) ||
           Age == 0)
         return "ちゃんと全部の項目入れてね";

       return null;
     }
   }

   public string this[string columnName]
   {
     get {
       string error = null;
       switch (columnName)
       {
         case "FirstName":
           if (FirstName == "チョッパー")
             error = "禁句";
           break;
         case "LastName":
           if (LastName == "トニートニー")
             error = "禁句";
           break;
         case "Age":
           if (Age < 0)
             error = "0歳以上で";
           break;
       }

       return error;
     }
   }
 }
} 
※全体入力チェックでは未入力許すまじなもの。 ※項目入力チェックではチョッパーを入れたらエラーになるようなもの。

コントローラの追加

ControllersフォルダにPeopleControllerを作成。

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

namespace Mvc.RC.Controllers
{
 public class PeopleController : Controller
 {
   //
   // GET: /People/
   private List _people;

   public PeopleController()
   {
     _people = new List() {
    
new PersonViewModel{Id = 1, FirstName = "ルフィー", LastName = "モンキー", Age
= 17, Weapons = new List(){
         new WeaponViewModel{Type="ゴムゴム", Name="ガトリング"},
         new WeaponViewModel{Type="ゴムゴム", Name="鞭"}
       }},
       new PersonViewModel{Id = 2, FirstName = "ゾロ", LastName = "ロロノア", Age = 19, Weapons = new List(){
         new WeaponViewModel{Type="三刀流", Name="鬼斬り"},
         new WeaponViewModel{Type="一刀流居合", Name="獅子歌歌"}
       }},
       new PersonViewModel{Id = 3, FirstName = "ロビン", LastName = "ニコ", Age = 28, Weapons = new List(){
         new WeaponViewModel{Type="ハナハナ", Name="トレスフルール"},
         new WeaponViewModel{Type="ハナハナ", Name="シンコフルール"}
       }}
     };
   }

   public ActionResult Index()
   {
     return List();
   }

   public ActionResult List()
   {
     return View("List", _people);
   }

   public ActionResult Details(int id)
   {
     return View(_people.First(p => p.Id == id));
   }

   [AcceptVerbs(HttpVerbs.Get)]
   public ActionResult Edit(int id)
   {
     return View(_people.First(p => p.Id == id));
   }

   [AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
   public ActionResult Edit(PersonViewModel person)
   {
     if (ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   }
 }
}

※最初にデータをゴッソリ作ってるけど、毎回作成されるから更新しても意味なし! ※最後のEditアクションだけがポイント。

Viewの追加

Controllerの各アクションでAdd Viewを実行してみました。 List/Details/Editとそのままやってみた。 img.aspx

まずはList。 Add Viewから型を指定して作成するんだけど、型って単一モデルじゃないっすか。

img.aspx2

自分でListとかにするのかなとも思ったけどそのままモデルを選んで実行すると、賢 く 「System.Web.Mvc.ViewPage>」っ ていうinherits指定に。

img.aspx3

続いてDetail。 これは特に。そのまんま。

img.aspx4

最後にEdit。ここではWeaponsがどうなるんだろう?と思いつつ実行。 案の定、Weaponsに関しては生成されませんでした。 何でかというとAddView\List.ttですよ。T4ですよ! ウキウキしながらファイルを見てみると、FilterProperties(tt内で定義してる)でモデルからプロパティ一覧を取得。その処理はどうなってるかっていうとコレは単純で、IsBindableType(これまたtt内で定義)でプロパティを展開するかどうかチェック。

そのチェック方法が

if (type.IsPrimitive || type.Equals(typeof(string)) || type.Equals(typeof(DateTime)) || type.Equals(typeof(decimal)) || type.Equals(typeof(Guid)) || type.Equals(typeof(DateTimeOffset)) || type.Equals(typeof(TimeSpan)))

というわけでListやArray、Collectionやらは展開されないってことデス。 なので、自分でWeapons部分は書きます。

            <p>
             <% foreach(var weapon in Model.Weapons){
                  var index = Model.Weapons.IndexOf(weapon);
                  %>
               <%= Html.TextBox(string.Format("Weapons[{0}].Type", index), weapon.Type)%>
               <%= Html.TextBox(string.Format("Weapons[{0}].Name", index), weapon.Name)%>
               <%= Html.Hidden("Weapons.Index", index) %>
               <br />
             <% } %>
           </p>

書くって言っても、ココだけだし。 ※プレフィックス+Indexの名前でhidden作成するっていうのは前と変わらず。 ちなみにFuturesに入ってるHtmlHelperを使うと、TextBoxを以下のように書けます。

            <p>
               <label for="FirstName">FirstName:</label>
               <%= Html.TextBoxFor(p=>p.FirstName) %>
               <%= Html.ValidationMessage("FirstName", "*") %>
           </p>
           <p>
               <label for="LastName">LastName:</label>
               <%= Html.TextBoxFor(p=>p.LastName) %>
               <%= Html.ValidationMessage("LastName", "*") %>
           </p>
           <p>
               <label for="Age">Age:</label>
               <%= Html.TextBoxFor(p=>p.Age) %>
               <%= Html.ValidationMessage("Age", "*") %>
           </p>

Html.TextBoxForの部分ね。ValidationMessageが文字列じゃねーか、っていう突っ込みがもちろんあるよね。でも、ValidationMessageについては特にそういう拡張用意されてません。ちょっと中途半端。 コントローラのEditアクション(Post)のところで、ValidateAntiForgeryTokenを指定してるからHtml.Submit付近に<% = Html.AntiForgeryToken() %>を書いておきましょう。表示とPOST送信したのが同じユーザーエージェントかをCookieと hidden(__RequestVerificationToken)で勝手に確認してくれます。エラーならもちろんストップTHE処理。 POST 版Editの定義で引数がPersonViewMode personになってるけど、コレで動かすと残念ながらWeaponsは復元されませんでした(他の項目はOKね)。なので、上記のコントローラコードのままではダメですたい。コレについてはベータと変わらずだね。

なのでEditを変更。

    public ActionResult Edit(int id, PersonViewModel person)
   {
      person.Weapons = new List();
     if (ModelState.IsValid)
       UpdateModel(person.Weapons, "Weapons");
      if (ModelState.IsValid)
       return RedirectToAction("Index");

     return View(person);
   } 

先にnewしておいてUpdateModelでWeaponsを復元。その他の部分についてはそのまま。 コレで復元出来るんだけど、UpdateModelでブレークポイント書けるとちょっと面白い。 img.aspx5

まだUpdateModelを実行してないからWeaponsが復元されてないのはいいとして。 Id に値が入ってるよね!ね!引数idにももちろん入ってる(BeginFormのAction先のURIに含まれてるからルーティングでちゃんと入れてくれる)んだけどpersonクラスのIdも復元されてるっていうのが、今回改善された[Bind(Prefix=""])無しでも復元してくれるっていうヤツ。

肝心のIDataErrorInfoはどうなってるんだってとこですね。 Errorとthisの最初の部分にブレークポイント入れてみる。

img.aspx6

いつ止まるのか気になりますね! なりませんか?そうですか。

最初に書いたとおり、DefaultModelBinderのOnModelUpdatedとOnPropertyValidatedでIDataErrorInfoを使ってると書いたとおり、Editの最初(personを復元するタイミング)で止まります。 最初にOnPropertyValidatedがプロパティの数だけ呼び出されて、最後にOnModelUpdatedが呼び出される。順当。

他にも今回の改訂でCreateModel/OnModelUpdating/OnPropertyValidating/SetPropertyなんかが IModelBinderで定義されてるから、自分でModelBinderを作成するときにはそのタイミングで処理を入れたりも出来たりするみたいよ。 WPFみたいなノリで!

img.aspx7

で、Bindでエラーが発生すると↑こんな感じで。 Model.IsValidがエラーになる(ModelStateにエラー情報が入ってる)から、Weaponsも復元しないしリダイレクトもしない。ちょっと手抜きだけどエラーになるのが分かればよし! ※「ちゃんと全部の項目入れてね」のエラーはIDataErrorInfo.Errorプロパティが出力してる。

こんな感じなんで、DataAnnotationsでもまぁいいかな、なんて気がしなくもなく。データベースに問い合わせてビジネスロジック的にOKかどうかの検証とかココに入れるのはどうなんだ、って思えなくもないし。

dotnetConf2015 Japan

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