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月27日金曜日

DomainServiceが気になる

誰も彼もが気になってしかたがない.NET RIA Services。そうでもない?その中でも特に気になるのがデータ操作をラッピングしてサーバーサイドとのプロキシ動作を司るDomainServiceからはしばらく目が離せそうに無いですよね。得意のなんの脈絡もない展開ですいませんね。

.NET RIA Services - Building Data-Driven Applications with Microsoft Silverlight and Microsoft ASP.NET - MIX Videos

Microsoft ASP.NET 4.0 Data Access: Patterns for Success with Web Forms - MIX Videos

あれですよ、とにかく上記2つのMIX09セッションを見るべし、ですよ。英語だけど気にすんなよな!

ビデオ見た感じSilverlight3が目立ってる感じするかもしれないけど、そこじゃねっす。まじっす。気になるのはデータアクセス部分っす。Silverlight3からもASP.NETからも、はたまたJavaScript(これはちょっと特殊だけど)からも同一のサーバーサービスを呼び出してるよね。サーバーサービスを呼び出してるというか、サーバーサービス定義を元にジェネレートしたクライアント用クラスを使ってるよね。中身はサーバーサービスの呼び出しと、データ操作のトラッキングだろうと。サーバーサイド実装が何を使ってるのか気になる。ヒントはJavaScriptから呼び出してるデモの所で、DataService.axdがURIに含まれてるから、基本HttpHandler内に組み込まれてる機能なんだろうとは思うけど、それがWCFなのか独自実装(だとしたらパネーっす)なのか。Windows 7じゃないんだからWindows 7: Web Services in Native Code | pdc2008 | Channel 9ってことはないでしょうが。そんなこんなで"絶対読めよ"と↓こんな資料も。

Public Sector Developer Weblog : A MUST READ: Microsoft .NET RIA Services Overview

しょうがないからダウンロードして読んでみる。がんばる。読むというか見る...。

前半40ページくらいまで、こういう感じよ、こうやって書くよ、こんな感じで作るのよ、な説明が続くので、ビデオ見たのでそこはサラッと流す。EnableClientAccess属性が付いてるとクライアントコードをジェネレートするって書いてるけど、ジェネレートしなくてもサーバー上でだけでも使うならそれもよし。

こっからが、少し気になる部分の説明に入っていく。CRUD操作のR部分。デモでは何気なくGet~で書いたものをプロキシクラス呼び出すクライアントではLoad~って書いてロードしてるよね。ItemsSourceのところはコレクションプロパティ(this.Entities.GetEntityList<T>()って何が返るのかな~)を指定してるし。どういう風な関連付けがされてるのか気になるっす。空のコレクションを返しといて、あとはObserveに任せるのかな。Get~の部分はプレフィックスが「“Get”, “Fetch”, “Find”, “Query”, “Retrieve”, or “Select”」なら何でもいいみたいね。というか、Query属性をつけるなら、それすら関係無い。CoCなり。読み込みクエリーはLoad~が非同期で実行。

更新系もデモだとInsert/Update/Deleteがプレフィックスに付いてるけど、ここも何種類か規約でプレフィックスが用意されてる。データ取得と同じく、属性ベースでも指定可(Update/Insert/Delete属性)。クライアントでの更新結果はEntities.GetChanges()でEntityChangeSetを取得することですべて抽出できる。たぶんLINQ to SQLのDataContext.GetChangeSet()と同じようなものなんだろう。SubmitChanges()で更新情報をサーバーに送信して確定させるか、RejectChanges()で破棄。サーバー上では6つのパイプライン(クライアントから受信したもの→認可→検証→実行→永続化→同時実行エラー)に更新セットを流す(?)。トランザクション制御はやらないので、必要なら自分でオーバーライドしてTransactionScope使うべし。

Custom属性をつけたメソッド内での更新はクライアントのコンテキスト上で実行されるみたいで、ちゃんとクライアントてSubmitChangesしないとサーバーには反映されない。プロキシクラス内にそのまんまメソッドが展開されるんでしょうね。更新を伴わない場合はCustom属性じゃなくてServiceOperation属性。どっちも非同期みたい。

フィールドレベルの検証はDataAnnotationsを使ってMetadataクラスに定義。この辺はだいたいみんな同じような作り方になるのね。独自検証の場合はShared属性をつけたクラスで実装しとけば、それも自動生成に含めてくれるから、単純な検証以上の事をしたい場合はこれで。ビジネスロジックとしての検証はどこに書くのかな~?モデルクラスなのかな~?

後は認証や、認可の属性と続いてSilverlight、ASP.NET内で使うDomainDataSourceコントロールの説明と続く。ページングやらフィルタの指定もXAML内でできるのがカッコイイ。

後は、サンプルコードみたりしないとちょっとよく分からないけど、ビデオでは紹介されてない実装の細かいところはこのドキュメントにある程度書かれてる感じです。でも、もっと知りたいし、出来れば.NET RIA Services(Silverlight3環境必須)を入れなくてもDomainServiceだけでも試したいな~。コードサンプルが小野さんに教えてもらったASP.NET Dynamic Data 4.0 Preview 3にあるので、そっちを眺めてみようと思います。

2009年3月22日日曜日

初めてのAzure Storage

そういえばAzureのInviteが来てから、なんにも遊んでないことを思い出した。手を動かしてみよう。

Building Web Applications with Windows Azure - MIX Videos

ちょうどいい具合のビデオ。Azureがどういうものかは分かってるっていうことでそこは省略。ビデオの通りのデモを作るってだけの単純なエントリ。だけど、どこで何をしなきゃいけないのかは分かるようになると思われる。

まずは、Visual StudioでWebRoleプロジェクトを作成。これで8割完成。もちろん最新のSDK&Toolsを入れて無いとダメっす。

Download details: Windows Azure SDK Download details: Windows Azure Tools for Microsoft Visual Studio March 2009 CTP

Azure StorageとSDSと2種類のストレージサービスがあるけど、SDSは今回関係無いのであしからず。SDKを入れるとローカルでAzureプロジェクトが動くようにDevelopment Fabric(ローカル実行環境)とDevelopment Storage(ローカルストレージ)がインストールされます。今回はWeb実行環境はローカルなんだけど、ストレージはローカルストレージを使わずにAzure Storageを直接使うという内容なので、Development Storageは停止状態でOKです。

Azure Service Developer Portal

Azureのポータルでまずはストレージを作成。とりあえず初めてなので"FirstWeb"という名前にしてみた。

azurestorage1

後はビデオ通り。と、ここでエントリが終わってもつまんないから、とりあえずは手順を。

csdefにConfigurationSettingsを追加。

<?xml version="1.0" encoding="utf-8"?>
<ServiceDefinition name="CloudService2" xmlns="http://schemas.microsoft.com/ServiceHosting/2008/10/ServiceDefinition">
<WebRole name="WebRole">
  <InputEndpoints>
    <!-- Must use port 80 for http and port 443 for https when running in the cloud -->
    <InputEndpoint name="HttpIn" protocol="http" port="80" />
  </InputEndpoints>
  <ConfigurationSettings>
    <Setting name="AccountName" />
    <Setting name="AccountSharedKey" />
    <Setting name="TableStorageEndpoint" />
  </ConfigurationSettings>
</WebRole>
</ServiceDefinition>
これに合わせるように、cscfgにはvalueをセットしたものを書き込みます。もう、ここまでで9割完成。

次にモデルクラスを作成。エンティティ自体はPartition keyとRow keyさえあれば、あとはどんなスキームでもいいのでサンプル通りのメッセージクラスを作成。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Microsoft.Samples.ServiceHosting.StorageClient;
using System.Data.Services.Client;

namespace CloudService2_WebRole
{
public class ShortMessage : TableStorageEntity
{
  public string Message {get;set;}

  public ShortMessage(){}
  public ShortMessage(string pk, string rk, string message):base(pk,rk)
  {
    Message = message;
  }
}

public class MessageContext : TableStorageDataServiceContext
{
  public DataServiceQuery<ShortMessage> MessageTable
  {
    get
    {
      return CreateQuery<ShortMessage>("MessageTable");
    }
  }
}
}

テーブルの行に相当するエンティティはTableStorageEntityクラスから派生させる。自分でPartition KeyとRow Key書けばいらないけど、この実装が一番簡単。ストレージで管理したいテーブルのコンテキストをTableStorageDataServiceContextから派生。ここまではもう、こうする物だと覚えておこう。自分で全部実装してもいいけど、せっかくStorageClientのサンプルライブラリが提供されてるんだからそれを使う。.NET RIA Servicesに付いてくるサンプルはまた違う実装を使ってるので、なんならそっちを使う(Microsoft.Azure.StorageClient)っていうのも面白いかもね。中身はRESTfulな実装になってるから好みの実装で。

Development Storageを使う場合、"Create Test Storage Tables"を実行すればローカルにテーブルが作成されて簡単でいいんだけど、クラウドのAzure Storageを使う場合、クラウド内にちゃんとテーブル定義を作らないとダメ。ビデオではその部分をPythonのスクリプトを書いて実行してるけど、なんせマイマシンにはPythonの実行環境もないので、そこは他のサンプル同様Global.asaxに書いてしまうことにします。

    protected void Application_Start(object sender, EventArgs e)
  {
    try
    {
      var account = StorageAccountInfo.GetDefaultTableStorageAccountFromConfiguration();
      var tableStorage = TableStorage.Create(account);
      if (!tableStorage.DoesTableExist("MessageTable"))
        tableStorage.TryCreateTable("MessageTable");

    }
    catch{}
  }

一回作ってしまえば、クラス定義が変わるまで、存続するはずだからわざわざBeginRequestに書かなくてもいいんじゃないの?と常々思ってたので今回はApplication_Startに書いてます。コレでもちゃんと動くし。9割5分完成。

最後にdefault.aspxを書き換えて終わり。

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="CloudService2_WebRole._Default" %>

<!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 runat="server">
  <title>ShortMessage</title>
</head>
<body>
  <form id="form1" runat="server">
  <div>
  何か一言<asp:TextBox ID="InputMessage" runat="server"></asp:TextBox>
  <asp:Button ID="SendMessage" runat="server" Text="送信" OnClick="SendMessage_Click" />
  </div>

  <div>
    <asp:Repeater ID="MessageList" runat="server">
      <HeaderTemplate>
      <ul>
      </HeaderTemplate>
      <ItemTemplate>
      <li>メッセージ:<%# Eval("Message") %></li>
      </ItemTemplate>
      <FooterTemplate>
      </ul>
      </FooterTemplate>
    </asp:Repeater>
  </div>
  </form>
</body>
</html>

コードビハインドはこっち。

using System;
using System.Collections;
using System.Configuration;
using System.Data;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Xml.Linq;
using Microsoft.ServiceHosting.ServiceRuntime;

namespace CloudService2_WebRole
{
public partial class _Default : System.Web.UI.Page
{
  protected void Page_PreRender(object sender, EventArgs e)
  {
    MessageList.DataSource = (from m in new MessageContext().MessageTable
                              where m.PartitionKey == "FirstWeb"
                              select m);
    MessageList.DataBind();
  }

  protected void SendMessage_Click(object sender, EventArgs e)
  {
    var context = new MessageContext();
    context.AddObject("MessageTable",
      new ShortMessage(
        "FirstWeb",
        Guid.NewGuid().ToString(),
        InputMessage.Text));
    context.SaveChanges();

    InputMessage.Text = "";
  }
}
}

Page_LoadじゃなくてPreRenderにデータバインドを書くのを忘れずに。コレを動かす!

azurestorage2

見にくいけど、ローカルストレージは全部Stopped。WebRoleはローカルのFabricで動いてブラウザ内にはメッセージリストがちゃんと表示されてる。一応、Fiddlerで確認も。

azurestorage3

見にくいけど、2行目が最初の表示のバインドのためのリクエスト。3行目がメッセージのポスト時に実行されるPOST。4行目が、ポストバック後のバインドで取得するリクエスト。

単純なサンプルだけど、Azure Storage単体をAzureにホストしていないプログラムから利用するのが超簡単だというのが分かったので良しとしましょう!

初めてのLive Mesh

Mesh使ってファイル同期なんていうのは、目に見えるアプリケーションとして日常のなかで使う事もあると思うけど、Meshの凄さはそこじゃない、とみんな感じてる模様。

クラウドで保持してるエンティティをMeshデバイス間で自動で同期することの一つの実装としてのファイル共有(“P2P=ファイル共有=悪”と同じ発想にいっちゃうと何が起きてるのか分からなくなるから)。データの持ち方が階層構造になってて、アクセスはすべてAtomPubのRESTful。認証すらRESTful。スゴイね。

ベータとして一般に使えるMeshクライアントは、ファイル同期とリモートデスクトップだけしか遊べないけど、開発者向けのCTPを使うとそこでアプリケーションが開発出来て、カスタム同期アプリケーションを構築出来る。ただ、こっちはファイル同期とリモートデスクトップは使えないから、両方のバージョンを入れないとちょっと不便。この辺についてはいろんなサイトで取り上げてるから割愛。

デベロッパーとしては、開発者向けで少し遊んでみたいと思っちゃうよね。とりあえず利用申請(Connectで)もして、プロジェクトが作れるようになってるところまでは準備が出来てるものとします。

Download details: Live Framework Tools for Microsoft Visual Studio April 2009 CTP

↑ここからSDKとToolsがダウンロード出来るんだけど、これが少し罠を仕掛けられてて、このままだと日本語のVisual Studioにインストール出来ない。英語版じゃないとダメ!って言われてインストーラーが停止する。けど、これは選別トラップと見た。これをクリアしてこそMeshで開発するにふさわしい開発者だというメッセージなんじゃ。んな、わけないっつの。

lft

ダウンロードしたLiveFrameworkTools.exeは自己解凍形式のCABファイルなので、CABが解凍できるツールを使えば簡単に解凍できます。解凍後にLiveFrameworkSDK.msiとLiveFrameworkTools.msiが出てくるから、それを直接インストールすれば、上記メッセージ(言語環境チェックされない)も出てこないので日本語版でも開発することが出来るようになります。もちろん自己責任でってことにはなるけど。CTPだからそもそも自己責任だし(MSがダメだと言うなら諦めよう)。

lfi2

これで、開発出来る状態ができあがったね。

Mesh-Enabled Web Applications - MIX Videos

そしたら↑コレ見ましょう。前半デモがグダグダだけど、そこは無視。内容自体はグッとくる。面白そうでたまらない。なので「初めてのLive Mesh」アプリケーション開発をしてみる。

Silverlightも初めてだし、小粋な物は作れないので、ビデオで最初にデモしてる、ログインしてるユーザー名を表示する部分だけ作ってみることに。簡単だし。

LiveFrameworkSDK&Toolsを入れとけばVisual Studioでテンプレートが出てくるので、"Silverlight Mesh-enabled Web Application"を選択して作成。これで9割5分完成。

次に、XAMLにテキストブロックが最初から張り付いてるので、そこにx:Nameで"HelloMessage"と命名。9割7分。

<UserControl x:Class="MeshApp1Silverlight.Page"
   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   Width="400" Height="300">
   <Grid x:Name="LayoutRoot" Background="White">
       <TextBlock x:Name="HelloMessage">Hello from MeshApp1Silverlight</TextBlock>
   </Grid>
</UserControl>
コードビハインドのmeshAppLoadedに以下のコードを書く。
    // Called when application loads
   void meshAppLoaded(object sender, EventArgs e)
   {
     // Mesh application service object is now loaded and usable.
     HelloMessage.Text = "Hello! " + meshApp.LiveOperatingEnvironment.Mesh.ProvisionedUser.Name;
   }
完成。あとは、実行させるのみ。何も開発してないじゃないか!なんてことは言わないで...。

あとはビデオのやり方通りに淡々と決まり事を実施。新規なのでAzure上にLive Frameworkのプロジェクトを作成(Mesh用)。そこでzipファイルをアップロード。

mesh1

開発したもののアップロードはVSが自動でやってくれる(ボーッと待つ)。

mesh2

そしたら、ブラウザが立ち上がって、Live Desktopが表示されてアプリケーションが実行される。

mesh3

もちろんデスクトップにも自動でアイコンが表示されて、同じものがローカルマシンでも動かせます。それを起動。

mesh4

これで開発出来る環境が出来てることは分かったし、ここからデータを追加したりして同期するのを楽しめるコードを追加していく。でも...、ビデオの通りやれば出来るって分かってるんだけど、なんか面倒くさくなってきたので、今日はここまで。

アプリケーション毎にMesh上のどのオブジェクトを参照できるのか設定出来たり、ローカルLOEを経由しない(しないよね?)で直接Meshにデータを登録したり出来るみたいで、普通のWebアプリケーションから認証(Live ID)と認可(OAuthじゃないけどそんな感じのもの)するアプリケーションも作れたり、興味が尽きない。

あとは.NET RIA Servicesが強烈過ぎるのでそっちでも遊んでみたい。でも、Silverlight 3はまだ入れたくない気がしなくもなく。2と3のどっちも同じマシン内に入れて、それぞれ(MeshとRIA)で開発できるのかな。教えて偉い人。

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

IDataErrorInfoの危険ゾーン

ASP.NET MVCで入力検証を行う場合、以前の投稿でDataAnnotationsとIDataErrorInfoの組み合わせ最強!なんて言ってましたが、ASP.NET MVC eBook Tutorialを読んでいて、おやや?と思ったわけです。ナゼ、彼らはコレを使ってないんだろうかと。

このチュートリアルの中で実装されている入力検証ではIDataErrorInfoを使わず、ASP.NET MVC Preview 5 and Form Posting Scenarios - ScottGu's Blogで紹介されてるRuleViolationクラスを使う方法なんですよね。どういう検証ロジックになってるのかを公開されてるソースとPDFに書かれてる内容から整理してみます。

LINQ to SQLを使ってモデルを作成してるので、モデルクラスのOnValidateパーシャルメソッドを使って、データ更新時に検証ロジックが実行されるようにしてます。

LINQ to SQL (Part 5 - Binding UI using the ASP:LinqDataSource Control) - ScottGu's Blog

実行した検証結果はRuleViolationクラスのインスタンスとして、プロパティ名とエラーメッセージを保持。GetRuleViolations()で、プロパティ毎の検証を実施。この関数はIEnumerable<RuleViolation>でyield return new RuleViolation(~)の形式で各プロパティの検証結果を返すようにすることでLINQのAny()でエラーの有無をすべてチェックしなくても判定できるってことですね(実際はCount()==0でエラー無しっていうコードになってるけど)。OnValidate()内ではIsValid()を呼んで、エラーがあるなら例外(ApplicationException)を発生させてるんですね。

更新処理自体はRepositoryの中で実装してますが、実質InsertOnSubmit()してるだけ。それをAction内で実行するわけですが、入力エラーがある場合Repository.Save()を呼び出した段階で例外が発生するので、catch内でGetRuleViolations()を呼び出して、エラー詳細を取得。ヘルパーを用意しておき、RuleViolationをModelState.AddModelError経由でModelErrorとして展開。これでViewの処理時に自動でエラークラスもセットされるし、ValidationSummaryの表示なんかもされる。

ここまでで、検証ロジックについて触れてなかったですけど、そこはもうベタな感じで作ってる。String.IsNullOrEmpty()で必須チェックや、正規表現で形式チェック。どこにもDataAnnotationsなんて使ってないです。しかも検証処理の実行をDefaultModelBinderに任せないのでIDataErrorInfoの実装もしていない。

ちょっと長くなったけど、こんな感じです。

昨日のエントリでViewData.Eval()が便利だよ、って話をしました。ここでやっと今回の本題。MVCに用意されてるHtmlHelperのInputExtensions(Html.TextBoxとかの入力用ヘルパー)は、内部でこのViewData.Evalを実行してるんですね。で、そのEvalの中で呼び出されてるのが以下の関数。

  private static object EvalComplexExpression(object indexableObject, string expression) {
     foreach (ExpressionPair expressionPair in GetRightToLeftExpressions(expression)) {
         string subExpression = expressionPair.Left;
         string postExpression = expressionPair.Right;

         object subtarget = GetPropertyValue(indexableObject, subExpression);
         if (subtarget != null) {
             if (String.IsNullOrEmpty(postExpression))
                 return subtarget;

             object potential = EvalComplexExpression(subtarget, postExpression);
             if (potential != null) {
                 return potential;
             }
         }
     }
     return null;
 }

ViewDataDictionary.csの200行から抜粋。プロパティが別のクラスのインスタンスになってても取り出せるように再帰ですね(例えばModel.Address.ZipCodeとか)。ここでGetRightToLeftExpressions()で上手いこと取得したいプロパティをすべて取り出せるんですが、問題はそのプロパティを取り出す関数であるGetPropertyValue()。またしてもソースを引用しておきます。

  private static object GetPropertyValue(object container, string propertyName) {
     // This method handles one "segment" of a complex property expression

     // First, we try to evaluate the property based on its indexer
     object value = GetIndexedPropertyValue(container, propertyName);
     if (value != null) {
         return value;
     }

     // If the indexer didn't return anything useful, continue...

     // If the container is a ViewDataDictionary then treat its Model property
     // as the container instead of the ViewDataDictionary itself.
     ViewDataDictionary vdd = container as ViewDataDictionary;
     if (vdd != null) {
         container = vdd.Model;
     }

     // Second, we try to use PropertyDescriptors and treat the expression as a property name
     PropertyDescriptor descriptor = TypeDescriptor.GetProperties(container).Find(propertyName, true);
     if (descriptor == null) {
         return null;
     }

     return descriptor.GetValue(container);
 }

GetIndexedPropertyValue()ですよ。これが最初に値を取得するために呼び出されるんだけど、これが内部でGetProperty("Item",~)を呼び出すんですよね。

  private static object GetIndexedPropertyValue(object indexableObject, string key) {
     Type indexableType = indexableObject.GetType();

     ViewDataDictionary vdd = indexableObject as ViewDataDictionary;
     if (vdd != null) {
         return vdd[key];
     }

     MethodInfo containsKeyMethod = indexableType.GetMethod("ContainsKey", BindingFlags.Public | BindingFlags.Instance, null, new Type[] { typeof(string) }, null);
     if (containsKeyMethod != null) {
         if (!(bool)containsKeyMethod.Invoke(indexableObject, new object[] { key })) {
             return null;
         }
     }

     PropertyInfo info = indexableType.GetProperty("Item", BindingFlags.Public | BindingFlags.Instance, null, null, new Type[] { typeof(string) }, null);
     if (info != null) {
         return info.GetValue(indexableObject, new object[] { key });
     }

     PropertyInfo objectInfo = indexableType.GetProperty("Item", BindingFlags.Public | BindingFlags.Instance, null, null, new Type[] { typeof(object) }, null);
     if (objectInfo != null) {
         return objectInfo.GetValue(indexableObject, new object[] { key });
     }
     return null;
 }

しつこくソースを引用しますが、ViewDataDicstionaryそのものであれば、直接ディクショナリの値を返そうとしてます。でも、内部ではTryGetValueで一致するものがないからとnullを返します。そうなると、Modelを参照して、プロパティを取得するので、ViewData.Modelにセットされてるインスタンスのプロパティを取得するときはそのままインスタンスのプロパティを返してくることになるので、問題にはなりません。が、しかしですよ、このModelにセットしてるインスタンスが別のクラスのインスタンスを保持してて、それをプロパティとして公開してる場合にはですね、GetIndexedPropertyValue()が"Item"プロパティを参照するようになるんです。この”Item"っていうプロパティはデフォルトインデクサのプロパティ名みたいで、IDataErrorInfoを実装するっていうことはコレを実装することになるので、なんとまぁエラーメッセージが返ってきてInput要素に出力されてしまうんですね。最高に分かりにくい文章ですいません。サンプル書くと長くなりそうなんです...。なので、適当なコードで表現してみます。

public class Person : IDataErrorInfo
{
 [Required(ErrorMessage="名前は?")]
 public string FirstName { get; set; }
 [Required(ErrorMessage="名字は?")]
 public string LastName { get; set; }

 public Person Father { get; set; }
 public Person Mother { get; set; }

 public string Error {
   get { // model validation }
 }

 public string this[string key] {
   get { // property validation }
 }
}

こんな感じのクラスがあったとします(中途半端ですいません) 。これをViewData.ModelにセットしてViewを表示するとしましょう。

public ActionResult Details(int? id)
{
 var person = new Person() { Father = new Person(), Mother = new Person() };

 return View(person);
}

こんな感じでしょうかね。これを表示するとしましょう。そうするとPersonクラスのFatherとMotherプロパティは空のPersonインスタンスが入ってます。でも、PersonクラスはIDataErrorInfoを実装しててプロパティの検証コードが入ってます(DataAnnotationsの検証実行が入ってるとしてください)。そうするとですね、Viewで<%= Html.TextBox(“Father.FirstName”) %>なんて書いてると、ViewData.Eval経由で値を取得しようとするけど、上記理由のためIDataErrorInfoの実装のつもりで書いてるデフォルトインデクサにアクセスされて、エラーメッセージとしての"名前は?"がテキストボックスのvalueに設定されてしまうという罠。しかもコレはテキストボックスのinput要素を作成してるInputHelperの引数useViewDataにtrueをセットして呼び出した時にEvalが実行されるんだけど、プロパティの値がnullならtrueとなる実装。そもそもstringの初期値はnullだから、今回のような場合必ずDataAnnotationsの検証が実行されて、エラーメッセージが表示されてしまいます。はてさて、どうしたものか。初期値として空文字("")をセットするとかしておけばnullじゃないから、デフォルトインデクサにアクセスされないんだけど、なんか気持ち悪し。IDataErrorInfo使わない方がいいんですかね~。う~ん。う~~~~ん。どうすればいいのか。教えて偉い人!

2009年3月11日水曜日

Futuresに含まれるMvc Controls

RC2のFuturesアセンブリ(ソースでも)には、Mvc Controlsが含まれてます。System.Web.UI.Controlクラスの派生クラスとして作成されてるので、WebFormsで使うサーバーコントロールと同じですね。HTML+CSS+JavaScriptで作成するようなクライアントサイドのコントロールじゃなくてASPXのPage Lifecycleの中でコントロールツリーとして生成されるサーバーコントロールです。

Futuresに含まれるコントロールは以下の7つ。

  • Label
  • TextBox
  • Password
  • Hidden
  • DropDownList
  • ActionLink
  • Repeater

はて、ナゼMVCにこれらサーバーコントロールが含まれてるんだろう(Futuresだけど)。ポストバック(ASP.NETの)もViewStateも無いので、ポストバックされた後のコントロールツリー構築なんてことは発生しない(そもそもポストバック先がPageじゃなくてControllerなんだから)し、かといって、積極的にコントロールツリーを構築してからレンダリングするなんてことは全然見通しのいい完全制御出来るHTMLとは言い難い(オレはサーバーコントロールのレンダリング結果を完全に把握してるぜ!という話じゃなくてデス)。なので、考えられる理由は、<%~%>での埋め込みコードを減らして、見やすくしようという意図なのかな~、と推測してます。理由なんてどーでもいいんですけど。

ASP.NET MVC Release Candidate 2: I declare myself to be declarative! - Eilon Lipton's Blog

↑こちらでしっかりと紹介されてます。

試しに使って見ましょう。MVCのプロジェクトを新規に作成し、プロジェクトの参照設定にMicrosoft.Web.Mvcを追加しましょう。続いて、web.configの設定をしておきます。

<system.web>
 …
 <pages>
   <controls>
     <add tagPrefix="asp" namespace="System.Web.UI" assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
     <add tagPrefix="asp" namespace="System.Web.UI.WebControls" assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
     <add tagPrefix="mvc" namespace="Microsoft.Web.Mvc.Controls" assembly="Microsoft.Web.Mvc"/>
    </controls>
   <namespaces>
     <add namespace="System.Web.Mvc"/>
     <add namespace="System.Web.Mvc.Ajax"/>
     <add namespace="System.Web.Mvc.Html"/>
     <add namespace="System.Web.Routing"/>
     <add namespace="System.Linq"/>
     <add namespace="System.Collections.Generic"/>
     <add namespace="Microsoft.Web.Mvc"/>
    </namespaces>
 </pages>
 …
</system.web>
上記太字の部分、サーバーコントロールのプレフィックス登録と、ページでのネームスペース登録。これをやっておかないと記入が面倒なことになるので忘れずに。

HomeControllerのIndexアクションで出力用のデータをViewDataにセットしておきます。

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

namespace Mvc.RC2.Controllers
{
 [HandleError]
 public class HomeController : Controller
 {
   public ActionResult Index()
   {
     ViewData["Message"] = "Welcome to ASP.NET MVC!";
      ViewData["TextBox"] = "テキストボックスに表示するメッセージ";
     return View(new { Label = "ラベルに表示するメッセージ" });
    }

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

ViewDataにテキストを入れて渡す方法と、Viewへ匿名クラスを直接渡す方法を書いておきます。匿名クラスも簡単に取り出せるのを覚えておくとちょっと便利です。

これらを表示するタメにViews/Home/Index.aspxにコードを追加します。

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

<asp:Content ID="indexTitle" ContentPlaceHolderID="TitleContent" runat="server">
   Home Page
</asp:Content>

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">
   <h2><%= Html.Encode(ViewData["Message"]) %></h2>
   <p>
       To learn more about ASP.NET MVC visit <a href="http://asp.net/mvc" title="ASP.NET MVC Website">http://asp.net/mvc</a>.
   </p>
  
    <p>
     <mvc:TextBox runat="server" Name="TextBox"></mvc:TextBox>
     <mvc:Label runat="server" Name="Label"></mvc:Label>
   </p>
</asp:Content>

サーバーコントロールなのでrunat="server"を忘れずに。Nameで指定したのがエレメントのName属性にセットされるのと同時にViewDataから同名の値を取得して展開してくれます。 なので、これで出力されるHTMLは↓こうなります。

    <h2>Welcome to ASP.NET MVC!</h2>
   <p>
       To learn more about ASP.NET MVC visit <a href="http://asp.net/mvc" title="ASP.NET MVC Website">http://asp.net/mvc</a>.
   </p>
  
   <p>
     <input name="TextBox" type="text" value="テキストボックスに表示するメッセージ" />
     ラベルに表示するメッセージ
   </p>

匿名クラスの値がそのまま展開されてますね。これは内部でViewData.Eval(データーキー)を呼び出してるからです。なので、 MVC Controls特有の動きというわけではなく、自分でも普通に同じ方法で値を取得出来ます。Viewに以下のようなコードを書いてみます。

    <p>
     <div><%= ViewData.Eval("TextBox") %></div>
     <div><%= ViewData.Eval("Label") %></div>
   </p>

これは以下のようなHTMLとして展開されます。

    <p>
     <div>テキストボックスに表示するメッセージ</div>
     <div>ラベルに表示するメッセージ</div>
   </p>

匿名クラスの場合、Modelのクラスが不明なので、インテリセンスでの取得は出来ないですけど、Evalを使う事で取得は出来ます。例えば、RenderPartialするユーザーコントロール(ascx)に、匿名クラスで値を渡す時(View Modelクラスを書くほどでも無いけどViewDataをそのまま参照するのもコード見にくい、なんて時)でもユーザーコントロール内でViewData.Eval()で取得出来るので、何かと便利だったりします。

ちなみにこのmvc:LabelコントロールにはTruncateLengthとTruncateTextなんていうプロパティがあって、コレを指定することで自動で長い文字列をカットしてくれます。例えば先ほどのLabelの部分を以下のように書き換える。

    <p>
     <mvc:TextBox runat="server" Name="TextBox"></mvc:TextBox>
     <mvc:Label runat="server" Name="Label" TruncateLength="5" TruncateText="~"></mvc:Label>
   </p>

そうすると5文字にカットしてサフィックスをくっつけてくれます。HTMLは↓こうなります。

    <p>
     <input name="TextBox" type="text" value="テキストボックスに表示するメッセージ" />
     ラベルに表~
   </p>

TruncateTextは初期値として"..."がセットされてるので、何も指定しなければ今回の例でいうと「ラベルに表…」となります。

RepeaterはWeb FormsのRepeaterと同じように動くので、繰り返しをforeachで書きたくないなんて場合は利用するのもいいんじゃないかな。DataSource指定やDataBind()はしないのでコードビハインドも不要です。今後いろんなコントロールが出てくるかもしれないね。

最近のお気に入り

IMG_0457 IMG_0460 

Bluetoothスピーカー。

キラキラボディーでお気に入り。PC本体のスピーカーだとシャリシャリ感が気になるし、あんまり音も大きくならないもんね。ヘッドホンも聞きやすくていいんだけど、いちいち線をつなげて、耳に当ててとかっていうのが面倒。移動するとき外すのも面倒だし。音楽を聴いたりするわけじゃないから、音質はそんなに気にならないよ。ハンズフリーのプロファイル(?)は削除しとかないとなんか調子悪かった。

Amazon.co.jp: Bluetooth ステレオスピーカー BIT-STB2825S シルバー A2DP・HFP・HSP・ワンセグ音声SCMS-T: 家電・カメラ

IMG_0467 IMG_0468

でろ~ん。スライムじゃないよ~。キーボードを掃除するCyber Cleanっていう製品(スライムだな...)。こないだハンズで売ってるのを見かけて買っちゃった。ベトベトしないし、ちゃんとゴミを吸着してくれるし、なんか楽しいし。

Amazon.co.jp: アイリスオーヤマ サイバークリーン 135gプラスチックボトル入り PCP-135: 家電・カメラ

2009年3月8日日曜日

第2戦(Brass Division)

前回の反省を生かし、今回の試合では前半から強気の攻めで、試合の流れを持ってくることを心がけよう。そんな目標を事前に立てての試合。試合前にはメンバーが全然集まって無くて唖然としたけど、試合始まる直前にはなんとか2セット揃ってホッとした。目標を立てたのに人がいないことにアワアワし過ぎて、すっかり目標のことを忘れちゃってたよ。 前半からガンガンがんばれ作戦っていうこともあって、セットの組み合わせも1stタケ・ミズ・セト、2ndオグ・シゴ・ミチと今期から参加のシンタローと組むことになり、走りまくらなきゃいけない予感。前回の試合で走らなすぎたら見方からのブーイングで心折れそうになったのを思いだし、今回はちゃんと最初から攻め気も見せつつプレーしなきゃね。 珍しくイマイちゃんとユミちゃんがいないおかげで5メンズ1ウーマン。オニャノコいないと、ブラスではブーイングがでちゃうS40なんだけど...。 まぁ、そんなこと心配してもしょうがね。 試合開始するまで全然気がつかなかったけど、開始2分で3点...。おかしい。いや、得点できることはいいんだけど、いくらなんでもおかしい。そんなアッサリ目標通り得点できるなんておかしすぎる。相手ゴーリーをよくよく見てみると、なんかスケーティングがゴーリーのそれとは違う。横移動も前後移動もままならない感じで、ほぼゴールライン上で微動だにしてない。もしや、プレーヤーゴーリー?ちゃんとオスギに確認しとけば良かったけど、試合は始まっちゃってるし、相手ベンチに「ゴーリーの人プレーヤー?」なんて聞けるわけもなく。 そのまま、前半から攻め続けて、と言うか我守らずを全員がモットーとしてるんじゃないのかってくらいの守備放棄。カネコさんが初戦に引き続いての鉄壁っぷりを発揮してくれてたからよかったものの。そんな流れで8-0で前半終了。どうも外野からのブーイングが気になる...。 後半も相変わらずノーガード。流石に守りきれず目の前でリバウンドを叩かれて失点。自分の目の前っていうのが責任感じる。守ってたのに守ってなかったんじゃないのかっていう失点の仕方だったのが余計にたちが悪い。それでも、攻めに攻めて得点を重ねるんだけど、終了間際にまたしても自分がリンクに乗ってるときに失点...。なんかもう、プレーオフの時からそうだけど、こうも終了間際の失点が続くと切なくなるね。 試合終了後、オスギに確認したら、急遽ゴーリーがこれなくなったそうで、プレーヤーがゴーリーをしてたんだそうな。単純に得点しまくってたのはそんな理由からですよ。17-2と勝ったはいいけど全然勝った気もせず。失点には常に絡む自分を励ましたい。 今回の試合では自陣ベンチからのブーイングがなかったものの、外野からのブーイングが炸裂。日曜に戸塚に行っても「昨日アメージングに鬼が出た」だの「悪魔っているんだね」だの、さんざんな言われよう。これからは演技派を目指そう。必死のプレーでゴール前でこけるとか、シュートをカラぶるとか、見てて一生懸命なのは伝わるけど、コレといって活躍しないっていう...。もう、それしか、自分を守る方法が思いつかない。ぎゃふん。

2009年3月4日水曜日

ASP.NET MVC RC2リリース

when its going to be released - ASP.NET Forums

↑ここで発見。最後の発言が...。まぁ、そうですけど...みたいな。

いろいろ気になるところがあったんでしょうね。安易にRTMを出さず、品質上げるためにRC2リリースの決断に敬意を表します。

ASP.NET MVC Release Candidate 2

とはいえ、RC1との違いが単にインストーラだけなわけ無いですよね。しっかりとリリースノートを確認しましょう。

CodePlexにソース/Futuresも上がってるんですが、その下にDataAnnotationsを使ったModelBinderのサンプルが...。もっと早く出してくれれば、自分でサンプル書かなくても良かったのに...。

このDataAnnotationsModelBinderの実装を確認してみると、その内容がまた超かっこいいです。IDataErrorInfo使わずに、DefaultModelBinderを派生させて、OnPropertyValidatingのオーバーライドメソッド内でValidationAttribute属性の取得・実行とModelStateへのエラー投入を行ってる。入力検証の集約というより、DataAnnotationsはそれだけで検証を完結させておき、モデル自体の検証や、ビジネスルールの検証は切り離して行うという設計。IDataErrorInfoならErrorのオーバーライドでモデル検証も集約できるから、どっちが分かりやすいコード(このサンプルだとモデル検証は別途モデル層に実装しましょうということになる)になるかは、アプリケーション設計者の好みでどうぞ、ってことですかね。

リリースノートに書かれてる変更点(それほど多くない)をさらっと確認してみると、以下のような感じです。

  • .NET Framework 3.5SP1必須です。入れといてね。
  • サーバーインストールモードを用意しました。VS関係のインストールがないのでデブロイ環境へのインストールはこれを使いましょう。 msiexec /i AspNetMVC1-RC2.msi /l*v .\mvc.log MVC_SERVER_INSTALL="YES"
  • GACに入れるよ。
  • AntiForgeryのCookie出力時にパスを指定できるようにしました。なので、デプロイ環境(ルートからとかサブフォルダあるとか)に合わせてパスを調整できます。
  • DefaultModelBinderが出力するエラーメッセージをローカライズできるようにしました。resxファイルを独自に定義して分かりやすいエラーメッセージを登録しておきましょう(InvalidPropertyValueとPropertyValueRequiredの2個だけ)。
  • ValidationSummaryのオーバーロードが増えたよ。メッセージリストをul/liタグで展開する前にspanタグでのタイトル表示できるなり。
  • jQuery1.3.1にしたよ。
  • あとバグフィックス。DropDownListの例外。web.configのauthenticationの値をLogOnに。Site.Masterとかで使ってるheadタグがrunat="server"をちゃんと動くように。checkboxとradiobuttonをModelStateからちゃんと復元。ルートのDefault.aspx(ルーティングでコントローラ+アクション指定してるとルートアクセスでデフォルトのコントローラ+アクションが実行されるけど、これにOutputCacheを指定しても効かない)でOutputCacheがちゃんと効くように。

基本的にRC1のコードならそのまま動くっぽいですね。

追記:2009/03/04 16:50

System.Web.AbstractionsとSystem.Web.Routingの2つのアセンブリがSP1のものを利用するように変わったんですね。なので、System.Web.Mvcだけ(Futuresを利用するならMicrosoft.Web.Mvcも)を配布すればよくなりました。グッジョブ!

2009年3月2日月曜日

ASP.NET MVCの新しいサンプル&チュートリアル

ASP.NET MVC Sample Application at www.ASP.net/MVC

ASP.NET MVCでの新しいサンプル。チュートリアルにそって順に良くなっていく。

これはMVCerはみとくべき。

イテレ-ション1 Entity Frameworkをモデルにして、アドレス帳サンプルを作成。この手順はオーソドックスな最初の作り方。これでMVCでの作成ってこれまでとどう違うのかが分かるはず。

イテレーション2 デザインギャラリーからデザインテンプレートを取得したり、自分でデザインしたものを1で作ったものに適用させて、見た目を綺麗にしましょう。ヘルパーなんかも書いたりしてデザインしやすくしておきましょう。

イテレ-ション3 フォームの入力検証を実装。シンプルな検証方法で、DataAnnotationsなどを使うわけではないです。どこで検証を実施すればいいのか、検証結果がどうViewに反映されるのかをしっかり押さえておきます。

イテレーション4 機能を疎結合にしましょう。モデル層はRepositoryに任せ、ロジック層はServiceに移動させる。それぞれインターフェイスと実装を分けて書きましょう。

イテレーション5 ユニットテスト。これまで書いたコードにたいするテストコードを書きましょう。モックを使うとテストコードを楽にかけるのでお気に入りのモックを使いましょう。ここではMoqを使います。他にもRhino MocksやTypeMockなんかもあるよ。テストの初期化時にインターフェースのインスタンスを生成し、コンストラクタインジェクション。 Service層だけじゃなく、コントローラもテストコードを書きましょう。

イテレーション6 5でテストコードを書いたけど、ここからはテストドリブンな開発にしましょう。Group機能を追加する様子をテストを書くところから始めます。その後でテストをパスするコントローラコードを書き、繰り返して機能を実装していく。一通り出来たら、テストコードもあることだし、リファクタリングしてコードをService層に移動したり、Repository層に移動して、テストをパスしないコードが発生しないようにしましょう。

イテレーション7 最後にアプリケーションをAjax化して、使いやすさも向上させましょう。ページの部分更新をしたりするので、共通部分はコントロール化して、Ajaxでの更新時にPartialViewでページ全体じゃなく、必要な部分だけを返すようなコードにしましょう。

テーブルも2個しかないし、規模としては凄く小さいけど、これがASP.NET MVCの開発スタイルの基本になると思います。しっかり身につけて、テストもはしょったりしないようにしましょう(自分で言っときながら耳が痛い...)。

カスタム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年3月1日日曜日

Crystalized Intelligence

結晶性知性。 【コラム】IT資本論 (26) 8つのパラドクス - 学習パラドクス(4) 持続学習 | 経営 | マイコミジャーナル これを読んでいて、流動性知性はPCやネットで補完できる(外部記憶や計算能力)が、結晶性知性は「コンピュータによって模倣できない」と書かれてる。 専門知や経験知からしか発達していかないインテリジェンスだから、それもそうだろうとは思うけど(画期的なAIっていうドラえもん級の夢のソフトウェアが出れば別)、外部知性というかたちでPCとネットで有効活用出来るとは思う。 Wikiとかナレッジベースとかっていう形での実装ではなくて、もっとリアルタイムに近い形で。 久しぶりにネガティブレガシーと出会いました。 工業経済至上主義を未だ貫き(それはいい)、統計を完全否定し、ネットを不安視(あえて無視)することで現時点での自分の正当性を閉じた世界で証明しようとするその姿勢が面白かった。

dotnetConf2015 Japan

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