2008年9月9日火曜日

JavaScriptとCSSをまとめて圧縮する

もちろんASP.NET MVCで作るときの話。

ところでタイトル変だね。全部一緒にまとめて圧縮ってわけじゃないですよ。 JavaScriptもCSSも、外部ファイルにするでしょ。で、外部ファイルにする単位ってやっぱり機能や役割で分割すると思うんですよ。 分割したはいいけど、それらを個別にscriptタグやらlinkタグで読み込むとそれはそれはたくさんのHTTP要求が発生しますよ。

サイトのパフォーマンスをチェックするときに、YSlowって使うでしょ。使いませんか?そうですか。 いやいや、使うんですよ。 で、ハイパフォーマンスWebサイトにも書いてるけど ・HTTP要求の回数を減らしましょう。 ・JavaScriptとCSSは圧縮しましょう。 ・CSSはページの上部、JavaScriptは下部でインクルードしましょう。 ・キャッシュの有効期限設定しましょう。 っていうのが、オーソドックスなパフォーマンスを上げる方法になりますよね。 調べてみたんですよ。今作ってるやつどんなかな、って。 ひどいもんですよ。軒並みF判定ですよ。悲しいよね。 でも、そんなの分かってたことなんだよね。だって、最適化なんてしてないし。そろそろこの辺もちゃんと手をつけようと考えますわね。

ASP.NET MVC Action Filter - Caching and Compression - Kazi Manzur Rashid's Blog

もう、答え出ちゃってる感あるけど、まずはこのサイトを参考に。というか、もうそのまま。Cacheに関してはここのクラスを使わずOutputCacheフィルターを使います。 まずは、テスト用にプロジェクトを作成。 で、スタイルシートのStylesheet1.css、Stylesheet2.css、Stylesheet3.cssと、JavaScriptのJScript1.js、JScript2.js、JScript3.jsをContentの中に作成(中身は適当で)。 で、Helpersフォルダをルートに作って、その中にCompressAttribute.csとCompressHelper.csをそれぞれ“クラス”テンプレートで作成。

img.aspx

↑こんな感じになりましょう。

CompressAttribute.csの中身は先のページのままコピペ。 ヘッダをみて、圧縮ロジックを決めて、Response.Filterにセットするだけ。

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

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

namespace MvcApplication1.P5
{
 /// <summary>
 ///
 /// </summary>
 /// <seealso href="http://weblogs.asp.net/rashid/archive/2008/03/28/asp-net-mvc-action-filter-caching-and-compression.aspx">Original Source by Kazi Manzur Rashid</seealso>
 public class CompressAttribute : ActionFilterAttribute
 {
   /// <summary>
   /// Initializes a new instance of the <see cref="CompressAttribute"/> class.
   /// </summary>
   public CompressAttribute()
   {
   }

   /// <summary>
   /// Called when [action executing].
   /// </summary>
   /// <param name="filterContext">The filter context.</param>
   public override void OnActionExecuting(ActionExecutingContext filterContext)
   {
     HttpRequestBase request = filterContext.HttpContext.Request;

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

     if (string.IsNullOrEmpty(acceptEncoding))
       return;

     acceptEncoding = acceptEncoding.ToUpperInvariant();

     HttpResponseBase 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);
     }
   }
 }
}

続いて、ちょっと便利に使うためのヘルパー関数群を定義したCompressHelper.cs↓。

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

using System.Web.Mvc;
using System.Text;

namespace System.Web.Mvc
{
 public static class CompressHelper
 {
   public static string ItemsKey = "_compressKeys";
   static string _formatScript = "<script type=\"text/javascript\" src=\"{0}\"></script>";
   static string _formatCss = "<link rel=\"stylesheet\" href=\"{0}\" type=\"text/css\" />";

   static Dictionary<string, List<string>> getItems(System.Web.UI.Page page)
   {
     var items = page.Items[ItemsKey] as Dictionary<string, List<string>>;
     if (items == null)
       page.Items[ItemsKey] = items = new Dictionary<string, List<string>>();

     return items;
   }

   public static void AddCompressSrc(this System.Web.UI.Page page, string key, string srcPath)
   {
     var items = getItems(page);
     if (!items.ContainsKey(key))
       items[key] = new List<string>();

     items[key].Add(srcPath);
   }

   public static string ScriptTags(this System.Web.UI.Page page, string key)
   {
     var items = getItems(page);
   
     var sb = new StringBuilder();
     foreach (var item in items[key])
       sb.Append(string.Format(_formatScript, item));
   
     return sb.ToString();
   }

   public static string CompressScriptTag(this System.Web.UI.Page page, string key)
   {
     return CompressTag(page, key, "application/x-javascript", _formatScript);
   }

   public static string CompressCssTag(this System.Web.UI.Page page, string key)
   {
     return CompressTag(page, key, "text/css", _formatCss);
   }

   public static string CompressCssTag(this System.Web.UI.Page page, string[] srcList)
   {
     var key = Guid.NewGuid().ToString();
     var items = getItems(page);
     items[key] = new List<string>();
     foreach (var src in srcList)
       items[key].Add(src);

     return CompressTag(page, key, "text/css", _formatCss);
   }

   private static string CompressTag(this System.Web.UI.Page page, string key, string type, string format)
   {
     var items = getItems(page);
     var list = items[key] as List<string>;
     if (list != null && list.Count > 0)
       return string.Format(format,
         string.Format("/Home/Compress?src={0}&type={1}",
                       page.Server.UrlEncode(list.Aggregate((s, ss) => s + "," + ss)),
                       page.Server.UrlEncode(type)));

     return "";
   }
 }
}

namespaceをSystem.Web.Mvcにしてるのは、ページでImport書くのが面倒だから。HtmlHelperにしてないのはPage.Itemsを使いたいから。 Page.Items にそのページで使うJavaScriptやらCSSやらを入れておいてまとめて出力するのに使ってます。Page.Itemsに入れておけば、マスターファイルとページファイルのそれぞれで入れた値をマスターファイル(Site.Masterね)から簡単に取り出せるから。同じコンテキスト内(HTTP 要求内って意味でのコンテキスト)でのデータの受け渡しができる(Session使う必要ないし)。 CSSの場合はページ上部にまとめて書いちゃうからPage.Itemsに入れる必要はないんだけど、その辺は好みの問題ってことで。 名前をつけて置けば、名前ごとの圧縮ファイルにできると思って、keyを渡すようにします。ぶっちゃけ意味なし。 どうやって使うかというと、まずはSite.Masterのheadタグの部分を↓こんな感じに変えましょう。

<head>
   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
   <title><%= Html.Encode(ViewData["Title"]) %></title>
   <%=Page.CompressCssTag(new[] { "/Content/Site.css", "/Content/Stylesheet1.css", "/Content/Stylesheet2.css", "/Content/Stylesheet3.css" })%>
   <% Page.AddCompressSrc("msajax", "/Content/MicrosoftAjax.js"); %>
   <% Page.AddCompressSrc("msajax", "/Content/MicrosoftMvcAjax.js"); %>
</head>

CSSは文字列配列でがっつり渡す。JavaScriptはまだタグのレンダリングしたくないので、Page.Itemsに入れておく。headに書いてるのは定義場所が上の方が見やすいかな、と思ったから。 で、同じくSite.Masterのbody閉じ以降を↓こんな感じで。

</body>
<%=Page.CompressScriptTag("msajax")%>
<%=Page.CompressScriptTag("myscript")%>
</html>

ここまでで、"msajax"をキーにしたスクリプトは2つ定義してるけど、"myscript"をはどこで入れ点だよ!って思った?それはHome/Index.aspxの中で入れてるんですね~。 各ページごとのスクリプトがサイト共通のスクリプトよりも上位に展開されると、悲しい結末が訪れるから、ちゃんとHTMLの最後に展開されるようにしましょう。

で、Home/Index.aspxの最後の部分を↓こんな感じで。

    <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>
   <% Page.AddCompressSrc("myscript", "/Content/jscript1.js"); %>
   <% Page.AddCompressSrc("myscript", "/Content/jscript2.js"); %>
   <% Page.AddCompressSrc("myscript", "/Content/jscript3.js"); %>
</asp:Content>

これを実行すると展開されるHTML(Home/Index)は↓こう。

<!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>
   <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
   <title>Home Page</title>
   <link rel="stylesheet" href="/Home/Compress?src=%2fContent%2fSite.css%2c%2fContent%2fStylesheet1.css%2c%2fContent%2fStylesheet2.css%2c%2fContent%2fStylesheet3.css&type=text%2fcss" type="text/css" />
 
</head>

<body>
~省略~
</body>
<script type="text/javascript" src="/Home/Compress?src=%2fContent%2fMicrosoftAjax.js%2c%2fContent%2fMicrosoftMvcAjax.js&type=application%2fx-javascript"></script>
<script type="text/javascript" src="/Home/Compress?src=%2fContent%2fjscript1.js%2c%2fContent%2fjscript2.js%2c%2fContent%2fjscript3.js&type=application%2fx-javascript"></script>
</html>

これだけだと、外部ファイルはちゃんと読み込めませんね。 HomeコントローラーにCompressアクションを実装してませんでした。

    [Compress]
   [OutputCache(Duration=3600,VaryByParam="src")]
   public ActionResult Compress(string src, string type)
   {
     var items = src.Split(',');
     if (items != null && items.Length > 0)
     {
       var sb = new StringBuilder();
       foreach (var script in items)
       {
         string path = Server.MapPath(script);
         if (File.Exists(path))
           sb.Append(File.ReadAllText(path));
       }

       return new ContentResult() { Content = sb.ToString(), ContentType = type };
     }

     return new EmptyResult();
   } 

srcパラメータにまとめるファイル名をカンマ区切りで渡すと(渡すときにUrlEncodeしても、自動でUrlDecodeしてくれます)、ファイルを読み込んで1個にまとめて出力するようにしてます。 圧縮はCompressAttributeにお任せ。OutputCacheを指定して、パラメータ毎(外部ソース毎)に1時間キャッシュするように指定してみました。 CompressとOutputCacheを外してFirebugで見た結果が↓。

img.aspx2

※CSSのレスポンス

img.aspx3 ※JavaScriptのレスポンス。 どっちも圧縮されてないし、Expiresヘッダもないね。 で、CompressとOutputCacheをつけてFirebugで見た結果が↓。

img.aspx4 ※CSSのレスポンス。

img.aspx5 ※JavaScriptのレスポンス。

いずれもファイルサイズも小さくなってるし、Expiresヘッダもついてクライアントキャッシュが有効になってます。 ここまで来て圧縮なら「IISの静的ファイル圧縮使えば?」という思いが当然のごとく出てくるよね。もちろん試しましたよ!ファイルを1つにまとめる機能はないけど、圧縮はできるはずだもんね。でもね、なんかうまく出来なかったんですよ。CSSは出来てるのにJSが出来なかった。 httpCompressionのstaticTypesの指定が悪いのかなんなのか。何にせよ、設定でちゃんと思い通りに出来なかったらコードを書けばいいじゃないか! ※負け犬...。

2008年9月3日水曜日

ModelBinderに気をつけねば

前のエントリでModelBinderを使って、ActionのパラメータにDBから取ってきたエンティティモデルをそのまま渡す方法を書いちゃったけど、あれはダメでした。

DataContextをstaticにもつサンプルだったけど、そうだとしてもいつDisposeされるのかなんて分かんないから、試しに実装してみたらケチョンケチョン...。

で、正しい使い方はこちら↓。 How to use the ASP.NET MVC ModelBinder - Melvyn Harbour というのを、昨日ガスリー君のブログでも書かれててホッとした。 ASP.NET MVC Preview 5 and Form Posting Scenarios - ScottGu's Blog

サンプルをチェックしてると、ProductをModelBinderでとってるじゃないかと思えるかもしれないけど、あくまで新規登録時の空エンティティの時にしか使ってないですよね。 で、更新処理の時にはDataContextから取得したProductに対して、ModelUpdateを使って値の書き込み。 この方法だとですね、更新時にはProductBinderのGetValue走らない。サンプルだから両方乗せてるんだろうけど(ModelUpdateとModelBinder)、最初はどんな意味が込められてるのか混乱しちゃった。 ModelUpdateでセットされるデフォルトのエラーメッセージが気に入らなかったらどこで書き換えればいいのかはちょっと分かんなかった。リソースファイルに持ってるのをどうすればいいんだろか。

で、 ProductクラスはLINQ to SQLのクラスなんだけど、これに対してModelStateDictionaryへメッセージを突っ込むコードを書くと、クラスが密結合しすぎちゃうがために、あえてRuleViolationクラスを作って(後でModelStateDisctionaryに入れやすくするために)、 IRuleEntityを実装。

ProductクラスのOnValidateはSubmitOnChangeの時に自動で呼び出してくれるっていうのがミソですね! でも、実際の開発は、たぶんだけどLINQ to SQLのエンティティクラスに対して直接入力しないよね。もう一つ間にViewDataクラスをはさんで、ViewDataにDBから読み込んだ値を入れてFormに表示。更新の時にViewDataに読み込んだ後、エンティティクラスに値をマッピングしていって更新。そんな流れになると思うので、入力検証のサンプルが↓これ。

Maarten Balliauw {blog} - Form validation with ASP.NET MVC preview 5

このサンプルではViewDataに直接値を入れてるけど、ViewDataのクラスを用意してViewPage<UserViewData>でレンダリングをView(model)にするんだと、↓こんな感じになるんじゃないかと思いますがどうですかね。

public class UserViewData { public name {get;set;} public email {get;set;} public message {get;set;} }

Contactアクション(POST)の入力値の取得で

var viewData = new UserViewData(); ModelUpdate(viewData, new[]{"name","email","message"});

って、すれば個々に入力値を取得しなくてもviewDataに埋め込みますわね。 でも、それだと入力検証できませんわね。全部stringだし。 なので、UserViewDataクラスに検証用のメソッドを追加して、それを呼び出すときにModelStateDictionaryを渡すのが簡単でいいんじゃないかと思います。 例えば、↓こんな。

public bool Validate(ModelStateDictionary modelStates)
{
 if (string.IsNullOrEmpty(name))
  modelStates.AddModelError("name",name,"名前入れてね!");
 else if (name.length < 4)
  modelStates.AddModelError("name",name,"4文字以上で名前入れてね!");

return modelStates.IsValid;
}

で、アクションでは↓。

 var viewData = new UserViewData();
 if (TryModelUpdate(viewData, new[]{"name","email","message"})) {
  viewData.Validate(ViewData.ModelState);
 } 

なんかヘンテコなコード...。 ※ModelUpdateも中でModelStateDictionaryにエラー値を入れてくれます。キャストできないとか。 ※Prefixをつけた場合、今までは最後に'.'(ドット)を自分でつけなきゃいけなかったのに、自動でつくようになってちょっと涙目...。気がつくのに時間かかった。

ちなみにUpdateModelのキー名はmodelに持ってる項目だけにすべし。Modelの項目を指定して、Formにない場合はエラーにならないけど、その逆はエラー(FormにあってModelにない)になるので気をつけよう。 ModelState情報はうまく利用すれば、エラーフィールドを強調できる(class属性にinput-validation-errorが自動でつく)ので超便利です。

ModelState はRenderPartial時にViewDataDictionaryを渡さないと(ViewData.Modelだけだとダメ)ユーザーコントロールで取得できないから、入力項目を持つユーザーコントロールのRenderPartial時には問答無用でViewDataも渡すようにするのが吉!

その他気になったところ。

Default option label for DropDownList in ASP.NET MVC Preview 5 - Shiju Varghese's Blog

便利にはなるよね。でも、必須にしなくてもいいじゃないかと、思ってしまうんですよ。 LINQで取り出した、ソースの最初の行に空(ここでいうoptionLabel)のレコードを連結させるコードを書いてたから、それがなくなるのはいいんだけど。ちなみにotionLabelに空文字""を指定すると何も起きないからレンダリング結果は今まで通り。

Maarten Balliauw {blog} - ASP.NET MVC preview 5's AntiForgeryToken helper method and attribute Steve Sanderson’s blog » Blog Archive » Prevent Cross-Site Request Forgery (CSRF) using ASP.NET MVC’s AntiForgeryToken() helper 何に使うのかサッパリわからなかったけど、こうやって使うんだね。CookieとForm(HIDDEN)POSTの値を比較して有効なリクエストか判定。

file download as attachment in latest preview (like this blog post) - ASP.NET Forums

FileResultの簡単な使い方。FileResultはResponse.TransmitFileとかで結果を直接返さずにActionResultとして返すことで、テストしやすくなるよね。 AntiForgeryToken/FileResultともにMicrosoft.Web.Mvcに入ってるデス。

2008年9月1日月曜日

ActionNameとAcceptVerbs

やっと意味がわかりました。

How a Method Becomes An Action

Phil Haackさん(なんかインタビュービデオを見たんだけど、どう見てもモルダー...。ホントはFBIなんじゃん?)とこで細かく書かれてるんだけど、なんていうかさ、英語じゃん?

でも、くじけるわけにはいかねっす。作ってるプロダクトがまったく動かないからね。 今まで(Preview4)は、ControllerやActionの前後でなんかしたかったり、処理をざくっと注入するときはActionFilterAttributeクラスを派生したものを使ってました。 Preview5でも同じクラスはあるんですよ。だけど、これが罠でして。同じ名前のクラスなのにコロッとかわっててね。流石Preview版。

どう変わってるかというと、ActionFilterはもう前後に処理をはさむだけで、その中で別のActionを呼んだり、Actionそのものの実行をキャンセルするのは止めないか的な。 いや、filterContextにはResultプロパティがついてて、そこに結果を入れてしまえばいいんだけど。 例えば↓。

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

using System.Web.Mvc;

namespace MvcApplication1
{
  public class StopAttribute : ActionFilterAttribute
  {
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
      filterContext.Result = new ContentResult() { Content="途中で止める" };
    }
  }
} 

こんな感じでStopAttributeをActionFilterAttributeから派生して作っておいて、Home/Indexアクションにセットする。

    [Stop]
    public ActionResult Index()
    {
      ViewData["Title"] = "Home Page";
      ViewData["Message"] = "Welcome to ASP.NET MVC!";

      return View();
    } 

img.aspx

ばっちり中断して、違う結果を返してますね。 これはいいんですよ。別にこれがしたいわけじゃないんですよ。

RESTfulAttributeっていうのをずいぶん前に書いた(進化の過程をウキウキウォッチング)んですよ。 要は、GETの時に呼び出すActionとPOST/PUT/DELETEの時のActionを勝手に切り替えてくれるってもんです。でも、普通にブラウザからだとPUT/DELETEはダメなもんだから、prototype.jsの仕様に合わせて"_method"って名前でメソッド名を送信すると HTTP Methodじゃなくて、そっちをみて判断するようにしたものです(Railsっぽいよね!)。

これを実装するには、HTTP Methodを見て、Actionを切り替えるために元々のActionの実行をキャンセルして、HTTP Methodに合わせたActionを代わりに実効(ActionInvoke)して、その結果を返す必要があります。単純に考えたら filterContext.Resultに変わりになるActionの実行結果を入れてしまえばいいってことになるんだけどさ。もちろんそれも正解(上記サンプルのように実装できるし)。 でも、たぶん設計思想はそうじゃないっぽい。filterContext.Cancelがないし。

そこで出てきたのがActionNameAttributeとAcceptVerbsAttribute。ActionNameAttributeは、単純にAction名を別名に置き換えるもので、AcceptVerbsAttributeはHttpMethodを見て、実行可能な場合のみ(GETだけとかPOSTだけとか)Actionを実行するもの。 でね、それぞれ派生元のクラスがさ、ActionFilterAttributeじゃないんですよ。 ActionNameはAttributeクラス。AcceptVerbsはActionSelectionAttributeクラス。ActionSelectionAttributeクラスっていうのが今までなかったもので、今回追加されたんですね。 これは単純なabstractクラスで、

   public abstract bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo); 

たったこれだけ。

んじゃこのIsValidForRequestは何に使うのかというと、Actionの実行を許す時はtrueを返し、実行させたくないときはfalseを返す。 ウソじゃねっす!!試したっす! なので、こういうクラスが今回追加されてきたってことはですね、Actionの実行そのものと、Actionの実行を許可するかどうかは別々に実装すべきなんじゃないですかっていう設計思想なんじゃないかと。 いや、モルダーPhilさんがどう思ってるのかは知らないけど。 ※AuthorizeAttributeとかは別にActionSelectionAttribute派生じゃないから言い切れるわけじゃないっぺ!

なので、そういうことならそっちに合わせた方がカッコイイんじゃないかと。 作ってみたのがRESTfulVerbs。

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

using System.Web.Mvc;

namespace MvcApplication1
{
  public class RESTfulVerbsAttribute : ActionSelectionAttribute
  {
    // 有効なHTTP Method(複数可)
    public string HttpMethods { get; set; }

    public RESTfulVerbsAttribute() : this("GET,POST") { }
    public RESTfulVerbsAttribute(string methods)
    {
      HttpMethods = methods;
    }


    public override bool IsValidForRequest(ControllerContext controllerContext, System.Reflection.MethodInfo methodInfo)
    {
      string[] enableHttpMethods = HttpMethods.ToLower().Replace(" ", "").Split(',').Where(s => s.Length > 0).ToArray();

     string httpMethod = controllerContext.HttpContext.ToLower();

      // prototype.js対応
      if (httpMethod == "post")
        httpMethod = context.Request.Form["_method"] ?? httpMethod;

      return enableHttpMethods.Contains(httpMethod.ToLower());
    }
  }
} 

どうやって使うかというと↓。

    [RESTfulVerbs("GET")]
    public ActionResult Index()
    {
      return View();
    }

    [ActionName("Index"),RESTfulVerbs("POST")]
    public ActionResult IndexPost()
    {
      // Indexに対してのPOSTはここで実行
    }

    [ActionName("Index"),RESTfulVerbs("PUT,DELETE")]
    public ActionResult IndexPut()
    {
      // Indexに対してのPUT/DELETEはここで実行
    } 

簡単ね。ActionNameAttributeとのコンボです。 単純なんだけど、こういう形にしちゃうと既存コードの変更箇所が凄い多くなるのが痛い...。ガンバです! でもね、ひとつ前のRESTfulFilterAttributeの実装の時(ASP.NET MVCでRESTful)に思ったんだけど、今回みたいな実装にするっていうことはですよ、リクエスト毎にグルグルとActionNameで指定したActionを探すことになるよね、きっと。それが嫌だしちょっとカッコ悪いと思ったから2個目の実装にしたのに。 そんな負荷は微々たるもんだから気にするんじゃないってことかな?

ちなみにfilterContext.ResultにActionの実行結果を入れる版のRESTfulAttributeは↓こんな感じの変更です。

      Type ctrl = filterContext.Controller.GetType();
      if (actions.ContainsKey(httpMethod) && actions[httpMethod] != "")
      {
          MethodInfo method = ctrl.GetMethod(invokeAction);
          if (method != null)
          
filterContext.Result = ctrl.InvokeMember(invokeAction,
BindingFlags.InvokeMethod, null, filterContext.Controller,
filterContext.ActionParameters.Values.ToArray()) as ActionResult;
      } 

※こっちのほうが修正少なくて楽...。

早くソースが公開されないかな~。 とりあえず、今回実装されてるFilter一覧を見てたらすごく気になるもの発見。 まず、この2つは基本クラス。

  • ActionSelectionAttribute : Attribute public abstract bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo);
  • CustomModelBinderAttribute:Attribute protected CustomModelBinderAttribute();
で、今までもあったものを含めて使えるフィルターがこれ。
  • AcceptVerbsAttribute:ActionSelectionAttribute
  • ActionNameAttribute:Attribute
  • AuthorizeAttributeFilter:Attribute, IAuthorizationFilter
  • HandleErrorAttribute:FilterAttribute, IExceptionFilter
  • ModelBinderAttribute : CustomModelBinderAttribute
  • NonActionAttribute : ActionSelectionAttribute
  • OutputCacheAttribute : ActionFilterAttribute
最後に気になるフィルターがこれ。
  • MethodSelectionAttribute:Attribute public virtual MethodSelectionResult OnMethodSelected(ControllerContext controllerContext, string action, MethodInfo methodInfo); public virtual MethodSelectionResult OnMethodSelecting(ControllerContext controllerContext, string action, MethodInfo methodInfo);
何に使うんだろ...。

2008年8月30日土曜日

ModelBinderが素敵過ぎる

ASP.NET MVC Preview5の続き。

SingingEels : Model Binders in ASP.NET MVC

↑ここでサンプルダウンロードできるけど、DefaultModelBinderを派生させて独自Binderを定義しておくことで、Actionのパラメータをクラスの実体に置き換えることができるというもの。

とにかくダウンロードすればすぐわかるんだけど、少し解説。

まずはBinderのクラスを作成。これはDefaultModelBinderクラスを派生させましょう。 で、ConvertTypeメソッドをオーバーライドして、valueに入ってる値をdestinationTypeに変換してあげる。 例えば、ここのサンプルだとCustomersテーブルのID値をBase64にエンコードしたものをtargetCustomerという名前の QueryStringにしてActionLinkでリンクを生成して、そのリンクをクリックしたときのActionでCustomerクラスの実体が渡されるっていうものになってる。

this.Writer.Write(this.Html.ActionLink<HomeController>(c => c.Details(customer), customer.FullName));

↑これをIndex.aspx内で書いてて、リンク(aタグ)を出力してるんだけど、これの出力結果がたとえばID=1なら1をBase64エンコード('='は'_'に置換)して↓こうなる。

<a href="/Home/Details?targetCustomer=AQAAAA__" >Ivan Buckley</a>

これのアクション定義は↓。

public ActionResult Details(Customer targetCustomer){...}

んだけど、これはつまりデフォルトのルーティングをそのまま使うように変更してidという名前で渡すようにしてみるとですね、↓こうなるわけです。

アクション:public ActionResult Details(Customer id){...} リンク:<a href="/Home/Details/AQAAAA__" >Ivan Buckley</a>

これは、分かりやすくていいですよね。 型付きActionLinkで生成してるからIndex.aspxのほうは変更の必要なし。 わざわざアクションの中でIDからデータを取得するコードを書かなくても、MVCのハンドラがモデルに変換する処理をはさんでくれるというすぐれもの。 ただ、勝手に変換はしてくれないので、Global.asaxでModelBindersにどの型の変換をどのクラスで実行するかを登録しておく。

ModelBinders.Binders.Add(typeof(Customer), new MyCustomerBinder());

もう、楽しくてしょうがないね!

まさかまさかのPreview5

早いタイミングで出てきたのはいいけどPreview5とは。 実際、Controller変ったりViewEngine変ったりしてて大きな変更だからPreviewのままなんだろうね。 ※ViewEngineを作ったりはしんどそうだから、特に興味ないぜ!

img.aspx

ばびゅ~んとインストール。 後先考えずにインストール。 今までのプロジェクト動かないのはRelease Noteみたら書いてるから覚悟はしてたけど、ここまでダメだとは...。しかも、Sourceはまだ公開されてなくて、どうやって追っかけるんですか...。またしても見切り発車で先走り過ぎた。まぁ、いいや。

HtmlHelperが大きく変わって全く動かなくなりますね。 追加されたものとして↓。 HtmlHelper.RenderAction HtmlHelper.RenderRoute HtmlHelper.RenderPartial

今までのRenderUserControlが無くなって、これらが追加されてます。 これはViewDataの継承とかが全然変わるんじゃ...? と、思って簡単なテストプログラムを書いてみました。

まずはPreview 5で新規プロジェクト作成。

1.ModelsにUserViewData.cs(クラス)を追加。

namespace MvcApplication1.Models
{
public class UserViewData
{
  public string UserName { get; set; }
  public string ViewName { get; set; }
  public string Message { get; set; }
}
} 

2.Views/HomeにUsers.aspx(MVC View Content Page)を追加。

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Users.aspx.cs" Inherits="MvcApplication1.Views.Home.Users" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2>ユーザー</h2>

<h3>ページで表示するメッセージ:<% = Html.Encode(ViewData["ViewMessage"]) %></h3>

<%
 foreach(var user in ViewData.Model)
   Html.RenderPartial("~/Views/UserControls/User.ascx", user);
%>

</asp:Content>

コードビハインドで型指定

namespace MvcApplication1.Views.Home
{
 public partial class Users : ViewPage<List<Models.UserViewData>>
 {
 }
}

3.Viewsに"UserControls"フォルダ作成。 4.Views/UserControlsにUser.ascx(MVC View User Control)を追加。

<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="User.ascx.cs" Inherits="MvcApplication1.Views.UserControls.User" %>

<h5>コントロールで表示するメッセージ:<% = Html.Encode(ViewData["ViewMessage"]) %></h5>

<dl>
 <dt>ユーザー名</dt><dd><%= Html.Encode(ViewData.Model.UserName) %></dd>
 <dt>表示名</dt><dd><%= Html.Encode(ViewData.Model.ViewName) %></dd>
 <dt>メッセージ</dt><dd><%= Html.Encode(ViewData.Model.Message) %></dd>
</dl>

コードビハインドで型指定

namespace MvcApplication1.Views.UserControls
{
 public partial class User : System.Web.Mvc.ViewUserControl<Models.UserViewData>
 {
 }
}

5.HomeControllerにUsersアクション追加。

    public ActionResult Users()
  {
    ViewData["ViewMessage"] = "今日も雨が降ったり止んだりだね。";
    var users = new List()
    {
      new Models.UserViewData(){UserName = "takehara", ViewName="たけはら", Message="運動不足"},
      new Models.UserViewData(){UserName = "mauri", ViewName="マウリ", Message="ホッケーばっかり"},
      new Models.UserViewData(){UserName = "suzuki", ViewName="すずき", Message="ホッケーのみ"}
    };
 
    return View(users);
  }

6.Home/Index.aspxにリンク作成。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>

<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>

   <%= Html.ActionLink("ユーザーページ", "Users") %>  
</asp:Content>

で、これを実行した結果の画面が↓これ。 img.aspx2

相変わらず、ViewDataが参照できてないですね。 が!!

RenderPartialには違うオーバーロードがあってですね、User.aspxのコードを以下のように変更。

<%
 foreach(var user in ViewData.Model)
   Html.RenderPartial("~/Views/UserControls/User.ascx", user, ViewData);
%>

すると...。 img.aspx3

あびりーばぼー!!

ViewDataDictionaryの中身をUserControlの中から参照できるようになりました。 今までのRenderUserControlだと型指定してViewDataを渡すとディクショナリの中身は見れなくなってたんだけど、これでそのページで利用したい情報はすべてViewDataDictionaryに入れておくことで、全部のUserControlから参照できるようになりますね。 最高っす!!

ここで、RenderPartialが直接結果を返さないのがミソ。実行はギリギリまで遅らせてTestしやすくってことですな。モック使ってね。

次に変わってんのがHtmlHelper.ActionLink/RouteLinkのオーバーロード。object型の引数の解釈が先にHtmlAttributes。ちゃんとUrlが生成されなくてビビるので気をつけましょう。

今までのコードをなるべくそのまま使うなら↓こんな感じでnull渡しましょう。

Html.RouteLink("リンク", "RouteName", new {val1="1", val2="2"},null)

HtmlHelperはいろいろ変更が多そうだけど、ここら辺押さえておけば以降は大丈夫そう。 次はControllerでの変更点。 ビビるのはBindingHelperExtensionsが無くなってるとこ。 BindingHelperExtensions.UpdateFromが...。これがないとRequest.Formの値をまとめて取り出せないじゃないか。と、みんな同じようなことを思ったみたいでForum確認してみたらちゃんと答えが。 Controller.UpdateModelを使えと。なるほど。確かに取得できる。Request.Formを指定しなくても良くなってたり、TryUpdateModelを使えば例外も起きないっぽい。

さらに今まで、Executeの前後の処理をController.Executeをoverrideしてそこに実装してたけど、これがなんとinternalになって、overrideできなくなりました。 が、しかし、これはちゃんとRelease Noteに書いてるから、すんなりとExecuteCoreのoverrideへ切り替え。でも、Initializeのoverrideでいいような処理だから、こっちにしよ。 ActionFilterAttributeもなんか結構変わってて。

ActionMethod.Name何処にいったんですかね。リフレクションでどうのこうのって書いてるけど、RouteDataから引っ張ってくればいいんですかね? とりあえずはfilterContext.RouteData.Values["action"]でAction名はとれる。 Cancelもなくなってるし、アクションが実行されなかったかどうかはどこでセットすればいいんですかね? どうしよ的な変更箇所が多かったりする中で、RenderPartialヘルパーが実装されてたり、AcceptVerbでRESTfulっぽくアクションを書けたり(PUT/DELETEはどうすんの?_methodで書き換えれるのがいいんだけど.NET Reflectorで確認した限りではHttpMethodをそのまま判定に使ってるっぽいから、自分で書いたRESTfulAttributeクラスからの乗り換えはないかな)、AjaxHelperがSystem.Web.Mvc.Ajaxに移動してたり、よさげなこともあるから(他にも FileResultクラスがあったり)しっかりチェックしていこうと思うところです。 How to use the ASP.NET MVC ModelBinder - Melvyn Harbour ↑ModelBinderの使い方サンプル。

Maarten Balliauw {blog} - Form validation with ASP.NET MVC preview 5 ↑ModelStateを使った入力検証のサンプル。

これViewDataに入れたりして、エラー項目を保持するのを自分で実装しなきゃいけなかったりしてたけど、超いい感じ!

2008年8月29日金曜日

ASP.NET MVCの記事

待望の後編が!! もう一つのASP.NET 「ASP.NET MVC」を知る(後編):CodeZine 一通りの機能説明がされててナイスです!

とりあえず前後編読んでおけば何となくアプリケーション作れるんじゃない? あと、ActionResult(ViewResultとか)?

最近、知ったんだけど"The ASP.NET MVC Information Portal"っていうのができてて、これでもかってくらい外部のサイトとかFeedで集めまくってるのでちょっと楽しいです。

最近はSSLを使えるようにいろいろ試行錯誤。 Vistaだと自己証明証明書とかで簡単にテスト環境作れるのがいいね。 SSL に使用する証明書の構成 コマンドで作成すればもっと融通のきく勝手証明書が作れるのかな~? こっちはうまくできなかったけど。 で、SSLで通信できるようになったのはいいけど、今度はどうやってリンクをSSLにしましょうか、ってところですよ。

Html.ActionLinkやRouteLink、Url.Actionとかで吐き出すURLをどうすればいいんだろかと思って調べてたら、こんなすごいのを発見。 Steve Sanderson’s blog » Blog Archive » Adding HTTPS/SSL support to ASP.NET MVC routing 書いてるとおりにSystem.Web.Routingモジュールを外して、こっちに置き換えて、Global.asaxにRoute登録すると...。すげ~!!かっこよくHTTPSとHTTPが切り替わるように出力されてる。絶対URLと相対URLをうまく切り替えてて賢く動く。

でも、VisualStudioのWebDev.WebServerじゃ、SSLで動かせないからIISに設定し直してテストしてみると~!!なんとまぁ、ちゃんと動かない...。 Problem 1のところに本人も書いてるけど、同じ症状。残念です。"Some gremlin in the routing"だってさ。 でも、これを改善したという猛者がいたりして。 Dmitriy Nagirnyak: Fixing HTTPS Support in ASP.NET MVC Routing

なぬ~!!と速攻でこの修正を加えて試してみたけど、ダメだった...。なんでだろね。ソース追っかける元気が...。いつかきっと確認しときます。 あ、そういえば、SP1 RTMにアセンブリリダイレクトしてないからうまく動かないのかも?まぁ、いっか。 このままじゃ、面倒なことになるなと思ってたところで、代替案。 Troy Goode: SquaredRoot - SSL Links/URLs in MVC

使い方は超簡単。いや、そりゃそうだってなもんで。 こっちはRoutingがどうのこうのじゃなくて拡張メソッドでガリっとURL書き換え。 なので、自分で書き換えたい部分のコードを変更する必要あり。 ちょっと面倒だけど、確実なのでとりあえずはこの方法で進めることにしてみます。 ※Html.ActionLink(...).ToSslLink()とかUrl.Action(...).ToSslUrl()って感じで使います。 このままだと、いったんHTTPになった後にHTTPに戻しにくいので、同じ要領で↓こんなのも用意しときましょう。

    public static string ToHttpUrl(this string text)
    {
      if (Utility.UseSSL)
        return ToFullyQualifiedUrl(text).Replace("https:", "http:");
      else
        return text;
    }

    public static string ToHttpLink(this string text)
    {
      if (Utility.UseSSL)
        return ToFullyQualifiedLink(text).Replace("https:", "http:");
      else
        return text;
    } 

そうそう、最初のナオキさんの記事の最後のページにこのブログが紹介で載ってるんだけど、こういうときにブログのタイトルがこんなだとちょっと恥ずかしい...。

2008年8月9日土曜日

イベントの実行順が面白くて

SQL Server 2008に合わせて.NET Framework 3.5 SP1が見えてるところですね。 ウェイトリフティングの三宅選手がスナッチあげてる時に使ってるタオルがスティッチ。ジャークのときにはサメのキャラを期待。

ASP.NET MVCのControllerではoverride出来るイベント(Onなんちゃら)が6個ありまして。

  • OnAuthorization(承認)
  • OnException(例外)
  • OnActionExecuting(Action実行前)
  • OnActionExecuted(Action実行後)
  • OnResultExecuting(Result実行前)
  • OnResultExecuted(Result実行後)
このActionとResultって何なんですか?って気になるところだったりしませんか? 以下のようなコードをHomeControllerに書くとどんな出力がでるのか試してみるとよく分かります。

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

namespace MvcApplication1.Controllers
{
 [HandleError]
 public class HomeController : Controller
 {
    protected override void OnAuthorization(AuthorizationContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("OnAuthorization");
     base.OnAuthorization(filterContext);
   }

   protected override void OnException(ExceptionContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("OnException");
     base.OnException(filterContext);
   }

   protected override void Execute(ControllerContext controllerContext)
   {
     System.Diagnostics.Debug.WriteLine("before Execute");
     base.Execute(controllerContext);
     System.Diagnostics.Debug.WriteLine("after Execute");
   }

   protected override void OnActionExecuting(ActionExecutingContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("- OnActionExecuting");
     base.OnActionExecuting(filterContext);
   }

   protected override void OnActionExecuted(ActionExecutedContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("- OnActionExecuted");
     base.OnActionExecuted(filterContext);
   }

   protected override ViewResult View(string viewName, string masterName, object model)
   {
     System.Diagnostics.Debug.WriteLine("-- View");
     return base.View(viewName, masterName, model);
   }

   protected override void OnResultExecuting(ResultExecutingContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("- OnResultExecuting");
     base.OnResultExecuting(filterContext);
   }

   protected override void OnResultExecuted(ResultExecutedContext filterContext)
   {
     System.Diagnostics.Debug.WriteLine("- OnResultExecuted");
     base.OnResultExecuted(filterContext);
   }

   public ActionResult Index()
   {
      System.Diagnostics.Debug.WriteLine("-- Index action execute");

     ViewData["Title"] = "Home Page";
     ViewData["Message"] = "Welcome to ASP.NET MVC!";

     return View();
   }

   public ActionResult About()
   {
     ViewData["Title"] = "About Page";

     return View();
   }
 }
} 

で、これだけだとちょっと見落としちゃうタイミングがあるので、Views/Home/Index.aspxの先頭にもコード追加。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>
<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">

<% System.Diagnostics.Debug.WriteLine("-- page rendering"); %>

   <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>
</asp:Content>

これで、準備完了。 ※太字が追加したコードです。 実行してみると、デバッグ出力が↓こうなります。

before Execute OnAuthorization - OnActionExecuting -- Index action execute ← ここでアクション実行 -- View - OnActionExecuted - OnResultExecuting -- page rendering ← ここでASPXのレンダリング - OnResultExecuted after Execute

面白いでしょ? ASPX の実行はずいぶん後なんですよね。Viewの実行時にレンダリングされるわけじゃないっていうのが、なるほどなと思わずにはいられない。ちなみに Redirect/RedirectToAction/RedirectToRouteもContent/JsonもViewと同じタイミング。ASPX のレンダリングのタイミングと同じタイミングでResult実行されるのを気をつけておく必要ありです。 ControllerのExecuteの中でこんな順序で処理されてるっていうことが分かれば、いろいろできそうじゃないですか。 で、なんでこんなこと書いてるかというと、TempDataですよ。 この中でTempDataってどういうタイミングで保存復元されるんだろかと。 ソースを追いかけるとController.cs内のprotected internal virtual void Execute(ControllerContext controllerContext)に書かれてますね。 InvokeActionを呼び出す前に、TempData.Load(TempDataProvider)。InvokeAction後にTempData.Save(TempDataProvider)。 と、いうことはControllerのExecuteをoverrideしてbase.Executeの前後でTempDataにデータを入れても意味ないってことですよ(ね?)。 TempDataの出し入れのタイミングを間違えると、入れたのに取り出す時にはnullってことになりかねないので注意が必要です。

ViewDataに比べてあんまり注目されてない気がするTempDataだけど、結構使い道があって(メッセージ出力時や、ViewDataで使うモデルデータに関連するデータを入れたり)するので、積極果敢に攻めの姿勢で使っていこうと思うところですよ。 ※ただしTempDataはシリアライズの問題もあり、LINQ to SQLのモデルをそのまま入れることはできない(StateServerとSQLのSession変数に入れられないのと同じ理由)ので、匿名クラスとかに変換して入れたりします。 それにしても、スナッチ1回目で90kg上げる中国チン選手恐るべし!最終的にミスなしで95kgて...。自重の倍て...。

dotnetConf2015 Japan

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