いつまで続くこのシリーズ。
いい加減しつこいっすね...。
前回まででAjaxなForm認証ができるようになったので、今度はRESTfulに挑戦!
で、ここで気になったのがWCF。以前にWCF使ってRESTfulだなんだとやってみたのに、改めて同じようなことを実装しなおすのはどうなんですか、という想いがこみ上げてくる。
でも、気になったので仕方ない。気になったもの負け。
今回はHTTP Methodをどうやってアクションに結びつけるのがいいのか結構悩みました。
出力するのはJSON固定で行こうと思うので、ControllerのViewEngineを切り替えずにお気軽JSON出力の仕組みは前回のまま。
ControllerFactoryがどうのこうの?カスタムActionFilterAttribute?そもそもルーティングの問題?
イロイロ考えた結果、すごく簡単に実装する方法がありました。
「OnActionExecutionオーバーライドでよくね?」
HandleUnknownActionとかも考えてみたものの、これが一番簡単っぽいな、と。
- ActionFilterAttributeから派生したRESTfulFilterAttributeを作成。
- Controllerから派生したRESTfulControllerを作成。
- 通常のControllerを作って、親クラスをRESTfulControllerに変更。
- 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の改造版使います。
↑こんな具合に動きます。
全取得とか追加とか削除とか。
ちなみに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でなんか作ってみようっと。