2008年4月25日金曜日

ViewUserControlが恋しい

ASP.NET MVCと相変わらず格闘中。 で、Ajax的な機能を組み込んでるところで、とうとうViewUserControlのみのレンダリングが必要になってきました。ハァ~。先延ばしにしてもどこかに答えが出てくるわけじゃないんだな~。人気なさそうだしな~。

次のバージョンでどのくらい実装に変化が出るのかドキドキしつつ、とりあえずはPreview 2のソースを追っかけてどうやろうか思案。ViewEngineを実装したところで、ViewUserControlのレンダリング結果が欲しいだけなんだから、あんまり意味ない(よね?)。

とうとう、あれか、ComponentControllerの出番か。とか思ってサンプル作ってみたけど、全然関係なさげ。 ComponentController and ActionResult - ASP.NET Forums こんなの見ると萎える...。

ViewUserControlとComponentControllerはコンテキストが違うっつーだけなんすかね。 コントロールは同一コンテキスト(パイプライン)の中で、同じViewEngineに対してレンダリングするけど、コンポーネントは別のViewEngineへの出力を取得して、それを現在のコンテキストにマージ。そんな感じ? まぁ、いいや。 で、どうすっかな~とMVCのソースを眺めてたんだけども、UserControlExtentionsのコードをオマージュすればいいってところにたどり着きました。 最低限以下のコードをコントローラに書けばOKっす!

      ViewContext vc = new ViewContext(ControllerContext, "dummy", "", null,null);
      var page = new ViewPage();
      page.Html = new HtmlHelper(vc);

      string partial_html = page.Html.RenderUserControl("仮想パス/コントローラ.ascx");
      Response.Write(partial_html);

Pageの実体を参照しないViewContextを作って、空のViewPageにHtmlHelperと一緒に割り当て。 コントロールの中でUrlHelper使うなら、それもセットしておきましょう。

      page.Url = new UrlHelper(vc); 

もちろん、RenderUserControlだから独自ViewDataも渡せるし、そこは普通に。 無駄にPageインスタンス作るのが納得いかないけど、オリジナルもその手法だからこれでよかろうさ。 これで、HTMLの部分更新もコントロールをそのまま使って実装できるから簡単になるよね!

2008年4月1日火曜日

ASP.NET MVCでRESTful

いつまで続くこのシリーズ。 いい加減しつこいっすね...。

前回まででAjaxなForm認証ができるようになったので、今度はRESTfulに挑戦! で、ここで気になったのがWCF。以前にWCF使ってRESTfulだなんだとやってみたのに、改めて同じようなことを実装しなおすのはどうなんですか、という想いがこみ上げてくる。

でも、気になったので仕方ない。気になったもの負け。 今回はHTTP Methodをどうやってアクションに結びつけるのがいいのか結構悩みました。 出力するのはJSON固定で行こうと思うので、ControllerのViewEngineを切り替えずにお気軽JSON出力の仕組みは前回のまま。

ControllerFactoryがどうのこうの?カスタムActionFilterAttribute?そもそもルーティングの問題? イロイロ考えた結果、すごく簡単に実装する方法がありました。 「OnActionExecutionオーバーライドでよくね?」 HandleUnknownActionとかも考えてみたものの、これが一番簡単っぽいな、と。

  1. ActionFilterAttributeから派生したRESTfulFilterAttributeを作成。
  2. Controllerから派生したRESTfulControllerを作成。
  3. 通常のControllerを作って、親クラスをRESTfulControllerに変更。
  4. CRUDリクエストを受け付けるActionを決めて、その他のActionにRESTfulFilter属性を指定。
処理の流れとしては↓こんな感じ。
普通にルーティング(controller/action/{id})される。 ↓ RESTfulControllerのOnActionExecutionで実行前に処理を横取り。 ↓ HTTP Methodを取得。 ↓ GET以外(この制限はなくてもいいんだけど)ならRESTfulFilterのついたメソッドをリフレクションで抽出。 ↓ HTTP Methodに対応するActionをInvokeAction。同時に自身の実行をキャンセル。
簡単な例としてstaticなList<T>に対するCRUDを実装することにしてみました。 まずはActionFilterAttribute。
using System.Web.Mvc;

namespace MvcApplication1.Filters
{
 public class RESTfulFilterAttribute:ActionFilterAttribute
 {
   public string AttachedAction { get; set; }
   public string HttpMethod { get; set; }

   public RESTfulFilterAttribute() : this("", "") { }
   public RESTfulFilterAttribute(string action, string method)
   {
     AttachedAction = action;
     HttpMethod = method;
   }
 }
} 

AttachedActionっていうパラメータに、受付元のアクション名を入れる。ホントはこれを単純にstringじゃなくてUriTemplateとかにしたほうがカッコいいんだろうな~。 で、HttpMethodにはGET以外の処理したいHTTP Methodを入れる。 次に、ベースとなるコントローラを作成。 今回は1つしかRESTfulなコントローラを作ってないけど、他にも作るようならここで作ったControllerを親クラスに指定する。

using System; using System.Web.Mvc; using System.Reflection; using MvcApplication1.Filters; namespace MvcApplication1.Controllers { public class RESTfulController : Controller { protected override void OnActionExecuting(FilterExecutingContext filterContext) { string httpMethod = filterContext.HttpContext.Request.HttpMethod.ToLower(); if (httpMethod != "get") { string actionName = filterContext.ActionMethod.Name; Type ctrl = this.GetType(); if (ctrl.GetMethod(methodname).GetCustomAttributes(typeof(RESTfulFilterAttribute), false).Length != 0) return; var attrs = from mi in ctrl.GetMethods() from attr in mi.GetCustomAttributes(typeof(RESTfulFilterAttribute), false) select new { ActionName = mi.Name, Action = (RESTfulFilterAttribute)attr }; foreach (var attr in attrs) { if (attr.Action.AttachedAction == actionName && attr.Action.HttpMethod.ToLowner() == httpMethod) { filterContext.Cancel = true; InvokeAction(attr.ActionName); } } } } } }

追記 すいません。上記コード間違えてましたね。対応付けするアクション名を比較する部分がさっくり抜けてました。HttpMethodしかチェックしてないコードでしたね。 直しついでに、LINQにしておきました(少しだけカッコの数を減らせます)。 で、上記Controllerを親クラスにした、Controllerを作成。 内容としては、staticで持ってるList<T>に対するCRUD。単純ですね。 イメージはBookmarkを登録したりする感じにしてみたので、名前はBookmarksにしてます。 ※全然入力内容をチェックとかしてないから、あんまり意味ない...。

using System.Collections.Generic; using System.Web.Mvc; using MvcApplication1.Models; using MvcApplication1.Filters; namespace MvcApplication1.Controllers { public class Api2Controller : RESTfulController { public static List bookmarks = new List(); BookmarksViewData viewData = new BookmarksViewData(); public void Bookmarks(int? id) { if (id == null) InvokeAction("GetAll"); else InvokeAction("Get"); } public void GetAll() { viewData.data = bookmarks.ToArray(); viewData.result = true; RenderView("Bookmarks", viewData); } public void Get(int id) { if (bookmarks.Count > id) { viewData.data = new Bookmark[] { bookmarks[id] }; viewData.result = true; } else viewData.result = false; RenderView("Bookmarks", viewData); } [RESTfulFilter(AttachedAction = "Bookmarks", HttpMethod = "POST")] public void AddNew() { Bookmark bm = new Bookmark(); bm.Title = this.ReadFromRequest("title"); bm.Url = this.ReadFromRequest("url"); bookmarks.Add(bm); viewData.result = true; RenderView("Bookmarks", viewData); } [RESTfulFilter(AttachedAction = "Bookmarks", HttpMethod = "PUT")] public void Update(int id) { if (bookmarks.Count > id) { bookmarks[id].Title = this.ReadFromRequest("title"); bookmarks[id].Url = this.ReadFromRequest("url"); viewData.result = true; } else viewData.result = false; RenderView("Bookmarks", viewData); } [RESTfulFilter(AttachedAction = "Bookmarks", HttpMethod = "DELETE")] public void Delete(int id) { if (bookmarks.Count > id) { bookmarks.RemoveAt(id); viewData.result = true; } else viewData.result = false; RenderView("Bookmarks", viewData); } } }

この中のBookmarksっていうActionがすべてのHTTP Methodを受け付けるイメージです。 なのでアクセスするアドレスは↓こんな感じ。 GETで一覧取得 Bookmarks/ GETで1つ取得 Bookmarks/{id} POSTで新規作成 Bookmarks/ PUTで更新 Bookmarks/{id} DELETEで削除 Bookmarks/{id}

なんで、GETだけ特別なんだ...。そもそも受け付け用のアクションは定義しないで、HandleUnknownActionで全部処理しちゃってもいいね。

でも、直接個別のActionも呼び出せちゃうのはご愛敬。制限つけるならつけるで。 Bookmarkクラスは単純。

using System.Runtime.Serialization;

namespace MvcApplication1.Models
{
 [DataContract]
 public class Bookmark
 {
   [DataMember]
   public string Title { get; set; }
   [DataMember]
   public string Url { get; set; }
 }

 [DataContract]
 public class BookmarksViewData
 {
   [DataMember]
   public Bookmark[] data { get; set; }
   [DataMember]
   public bool result { get; set; }
 }
}

ついでに、ViewDataも定義しておきます。 コードばっかり書いて説明が少ないのは、面倒だからデス! さて次に、クライアント側の機能ですが、前に作ったのとほとんど変化なし。 前回のAjax認証のページにがっつり追加(Views/Home/Index)です。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>
<%@import Namespace="MvcApplication1.Controllers" %>
<asp:Content ID="indexContent" ContentPlaceHolderID="MainContentPlaceHolder" runat="server">
<form>
 ID:<%=Html.TextBox("id") %><br />
 Title:<%=Html.TextBox("title") %><br />
 Url:<%=Html.TextBox("url") %><br />
 <br />
 <h2>ASP.NET MVCで実装</h2>
 <input type="button" value="全取得" onclick="mvcCall('Bookmarks/','get')" />
 <input type="button" value="取得" onclick="mvcCall('Bookmarks/'+$F('id'),'get')" />
 <input type="button" value="追加" onclick="mvcCall('Bookmarks/','post')" />
 <input type="button" value="更新" onclick="mvcCall('Bookmarks/'+$F('id'),'put')" />
 <input type="button" value="削除" onclick="mvcCall('Bookmarks/'+$F('id'),'delete')" />
 <div id="mvcRes"></div>
</form>

<script type="text/javascript">
function mvcCall(action,method)
{
 $("mvcRes").innerHTML = "loading...";
 var params = $H({"title":$F("title"), "url":$F("url")}).toQueryString();
 new Ajax.Request("Api2/" + action,
 {
   method:method,
   postBody:params,
   requestHeaders: ["Content-type","application/x-www-form-urlencoded"],
   onComplete: function(ajax){
     $("mvcRes").innerHTML = ajax.responseText;
   }
 });
}
</script>
</asp:Content>

PUT/DELETE出来るようにprototype.jsの改造版使います。

img.aspx ↑こんな具合に動きます。 全取得とか追加とか削除とか。 ちなみにIDとして指定するのはListのインデックスなので0から始まる数字になります。あ、Ajaxの実行結果のViewを書いてなかったですね。

using System.Web.Mvc;

using System.Runtime.Serialization.Json;
using System.IO;
using System.Text;

using MvcApplication1.Models;
namespace MvcApplication1.Views.Api2
{
 public partial class Bookmarks : ViewPage
 {
   public string ToJSON(object obj)
   {
     DataContractJsonSerializer serializer = new DataContractJsonSerializer(obj.GetType());
     using (MemoryStream ms = new MemoryStream())
     {
       serializer.WriteObject(ms, obj);
       return Encoding.Default.GetString(ms.ToArray());
     }
   }

   public override void RenderView(ViewContext viewContext)
   {
     viewContext.HttpContext.Response.StatusCode = 200;
     viewContext.HttpContext.Response.ContentType = "application/json";
     string json = ToJSON(viewContext.ViewData);
     viewContext.HttpContext.Response.Write(json);
   }
 }
}

戻りのContentTypeに"application/json"と書くのを忘れずに。で、内容は単純にViewDataをJSONにシリアライズして出力するだけのものです。 ここまでで、一番つまずいたのがクライアント側からサーバーにデータを送るときのContentTypeの指定部分です。最初これを入れてなかった (Ajax.RequestのrequestHeadersに何も指定なし)おかげでPUT時の更新が常にnullという事態に陥りました。

GET とDELETEではQueryStringのidしか見ないから問題なし。POSTのときは指定がなくてもデフォルトで"application/x- www-form-urlencoded"が入るけど、PUTはそもそも改造版だから指定しないと入らなくて、いくらやってもサーバーで取得できない罠。 MVCのソース追っかけても単純にラッピングしてるだけでRequestを書き換えてるようにも見えなかったから、何がどうなってるのかすごく悩みました。 結局、Request.ContentTypeを見てみたら空っぽだったのを発見して、無事動くようになりました。prototype.jsに頼りきってたので罰が当たった。

ここまで来たら、次はこれのWCF版も欲しくなる。ルーティングとかどうなるんだろう...。

とかいろいろ悩んだものの、特に気にすることなくControllersとかViewsじゃないフォルダをルートに作ってそこにsvcを入れてしまえばいいことに気がついた。あえてMVCにルーティングしてもらうことないもんね。結局中身は以前作ったのと同じなので省略(RequestとResponseはJSON)...。 クライアント側だけ少し違う。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Index.aspx.cs" Inherits="MvcApplication1.Views.Home.Index" %>
<%@import Namespace="MvcApplication1.Controllers" %>
<asp:Content ID="indexContent" ContentPlaceHolderID="MainContentPlaceHolder" runat="server">
<form>
 ID:<%=Html.TextBox("id") %><br />
 Title:<%=Html.TextBox("title") %><br />
 Url:<%=Html.TextBox("url") %><br />
 <br />
 <h2>WCFで実装</h2>
 <input type="button" value="全取得" onclick="wcfCall('Bookmarks.svc/','get')" />
 <input type="button" value="取得" onclick="wcfCall('Bookmarks.svc/'+$F('id'),'get')" />
 <input type="button" value="追加" onclick="wcfCall('Bookmarks.svc/','post')" />
 <input type="button" value="更新" onclick="wcfCall('Bookmarks.svc/'+$F('id'),'put')" />
 <input type="button" value="削除" onclick="wcfCall('Bookmarks.svc/'+$F('id'),'delete')" />
 <div id="wcfRes"></div>
</form>

<script type="text/javascript">
function wcfCall(action,method)
{
 $("wcfRes").innerHTML = "loading...";
 var params = method=="post"||method=="put" ? $H({title:$F("title"),url:$F("url")}).toJSON() : "{}";
 new Ajax.Request("/Wcf/" + action,
 {
   method:method,
   postBody:params,
   requestHeaders: ["Content-type","application/json"],
   onComplete : function(ajax){
     $("wcfRes").innerHTML = ajax.responseText;
   }
 });
}

</script>
</asp:Content>

ContentTypeとpostBodyの中身がJSONになってるのと、リクエスト先が違うだけ。 結局、ASP.NET MVCだけで作るのと、WCFも組み合わせて作るのとどっちがいいんだろう。書かなきゃいけないコード量はほとんど違わないし(RESTfulControllerとRESTfulFilterは最初に1回書くだけだし)。 Action単位のアクセス制限とか、今回試してないけどその辺で違いが出てくるのかな~。 Nikhil Kothari's Weblog : Ajax with the ASP.NET MVC Framework

そうそう、↑ここにAjaxでの部分更新(ascx単位)のやり方が書いてた。部分更新だけなら実装はシンプルで、ViewUserControlのRenderViewを自分で呼び出して返すようなものでした。だけど、その周りにあるいろんな実装が大量でゲンナリ。 調べるだけなのはもう疲れたので、せっかくだからASP.NET MVCでなんか作ってみようっと。

TempDataとRailsのflash

ASP.NET MVCをちょこちょこ調べるシリーズの最後に何か作ってみようと思って、ヘコヘコやってるところなんだけど、TempDataにやられた...。

もちろんベータ版だし、最終的には解決されるんだろうとは思うけど、少なくとも今のバージョンでは、SQLServerをセッションストアにしたときに、TempDataが機能しませぬ。 TempDataはそもそもSessionを使って実装してるって言うのは、まぁそうだろうと(どっかに書いてたし)。RedirectToActionとかした場合に、コンテキストが切り替わって情報を持っていけないんだから納得です。 でも、そのTempDataの実装、TempDataCollectionが内部で使ってるのが Pair<Dictionary<string, object>, HashSet<string>>。これがシリアライズできない...。だからInProcならいいけど、SQLだとSessionに入らないです。もちろんStateサーバーもシリアライズできないからダメ。

どうしたものか。SessionをInProcで使うのってなんか、気持ち悪いじゃないですか(そんなこと無い?)。 なので、もっと簡単に同じ仕組みを作ってしまえばいいんじゃないかと思って、FlashData(railsの同じ仕組みはflashという)のを作ってみました。無駄な作業とか言わないで!

public class FlashDataCollection { const string SESSIONKEY = "RevasFlashDataSessionKey"; Dictionary _flashData; HttpContextBase _httpContext; public FlashDataCollection(HttpContextBase httpContext) { if (httpContext != null) { this._httpContext = httpContext; this._flashData = httpContext.Session[SESSIONKEY] as Dictionary; this._httpContext.Session.Remove(SESSIONKEY); } if (this._flashData == null) this._flashData = new Dictionary(); } public object this[string key] { get { if (this._flashData.ContainsKey(key)) return this._flashData[key]; else return null; } set { this._flashData[key] = value; this._httpContext.Session[SESSIONKEY] = this._flashData; } } }

こんなクラスで。 やってることは↓これだけ。

  • ControllerのExecuteでFlashDataCollectionをnew。
  • FlashDataCollectionのコンストラクタで、Sessionに入ってるならそっちを復元してSessionからRemove。
  • 無ければDictionary<string,object>をnew。
  • 後は、インデクサで値をセットするたびにSessionに入れなおす。
んで、ControllerはExecuteをoverrideしたCusomControllerを作っておいて、こっちを派生させる。

public FlashDataCollection FlashData = null; protected override void Execute(ControllerContext controllerContext) { this.FlashData = new FlashDataCollection(controllerContext.HttpContext); base.Execute(controllerContext); }

こんな感じでいいんじゃないかと。 Controller ではTempDataの代わりにFlashDataに値を入れて、RedirectToActionしたときとかにはこっちを参照。ただし、これだけだと、Viewで参照できない(Controllerに入れちゃってるんで)ので、今度はRenderViewをoverrideするかとも思ったけど、そこまでしなくても次のバージョンとかではTempDataもシリアライズできるようにしてくると(期待して)思うので、今回はあえて CustomViewDataを作って、そのViewDataクラスに値を入れ込んでいくって言う方法にしときました(分かりにくい説明だ...)。 たとえば、Home/Inputっていうアクションで入力した内容をHome/Saveって言うアクションにPOSTしたとき、入力値の検証なんかは ModelでやるからSaveで値を取得した後にエラーだと判明するわけだったりするじゃないですか。そしたらエラーメッセージと入力値を TempDataに入れて、Home/InputにRedirectToActionで飛ばすでしょ。そんな使い方のときにうまいことやりたいっす。 ViewDataはSerializable属性付けて自分で作ったクラスを使うようにしておけば、うまくいくっちゃ! そんなこんなでなかなか先に進まないっちゃ! ところで、RouteValueDictionaryのコンストラクタってちょっと不思議だな~、と思ってどうやってんのかと思ったら、普通にリフレクションしてるだけみたい。

routes.Add(new Route("{controller}/{action}/{id}", new MvcRouteHandler()) { Defaults = new RouteValueDictionary(new { action = "Index", id = "" }), });

↑この無名クラスをnewで初期化してるとこ。:action=>"Index",:id=""みたいな書き方で、Dictionaryになるんだよ?

public static System.Collections.IDictionary MakeDictionary(object withProperties) { System.Collections.IDictionary dic = new Dictionary(); foreach (var property in withProperties.GetType().GetProperties(BindingFlags.Public|BindingFlags.Instance)) { dic.Add(property.Name, property.GetValue(withProperties,null)); } return dic; }

↑こんな感じの実装でちゃんとDictionaryになるのね。シンプルでよろしだね。

Turn Anonymous Types into IDictionary of values - ISerializable - Roy Osherove's Blog