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

2009年1月2日金曜日

ちょっとしたTips

ASP.NET MVCの情報源として外せないStackOverflowで↓こんな質問があがってたんです。

Compile Views in ASP.NET MVC - Stack Overflow

ViewPageのコンパイルって先送りされるじゃないですか。 んで、ページを表示させたときに、エラーが出て「あ、そっか」って気付くことも多いと思うんですよね。 対応として、プロジェクトファイルに以下の設定を書いておけばいいって、ことなんだけど面倒じゃないですか。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <Target Name="PrecompileWeb">
       <AspNetCompiler
           VirtualPath="/MyWebSite"
           PhysicalPath="c:\inetpub\wwwroot\MyWebSite\"
           TargetPath="c:\precompiledweb\MyWebSite\"
           Force="true"
           Debug="true"
       />
   </Target>
</Project>

もっと簡単にできる方法があってですね。 Code rant: Compiling aspx templates using aspnet_compiler

1.ウソコード書いてみる。

img.aspx

ViewData[a+b]なんて書いてるけど、そんな変数宣言もしてないし。 でも、ビルドしてもステータスバーみて分かるとおり「ビルド正常終了」する。

2.プロジェクトプロパティで設定する。

「ビルド後に実行するコマンドライン」に以下を書いておく。 C:\Windows\Microsoft.NET\Framework\v2.0.50727\aspnet_compiler.exe -v / -p "$(ProjectDir)\" ※環境によっては違うよ~。

img.aspx2

3.もう一度ビルドする。

img.aspx3

ちゃんと「ビルド失敗」するよ~。 ViewPage多いとちょっと時間かかるけど、ページみていきなりビックリしたり、ページ見るまでエラーに気がつかないなんてことが無くて、安心だね!

ちなみに、ASP.NET MVCの次のリリース(RC)では「ASP.NET MVC RC版では、プロジェクトのコンパイル・プロセスの一部としてビューを含めたい場合に使用できるビルトインのMSBuildタスクがあります。これにより、アプリケーションにあるすべてのビューやマスターページに含まれる構文やコードが検証され、もし問題があればビルド・エラーが発生します。」(ASP.NET MVCデザイン・ギャラリーとASP.NET MVC RC版におけるビューの改善 - @IT)ってことで、こんな事しなくてもいいみたいだけどね。

2008年12月30日火曜日

Repositoryで実装してみる

とりあえずデータアクセスはLINQ to SQLのみなんだけど、テストしやすくしたいし、DataContextクラスに大量のコードを書くのもいかがな物かと。で、やっぱりRepositoryパターンだよね!ってことで今更ながらコードを書いてみた次第なんです。

データストレージ層とデータアクセス層、サービス層の分離を明確にして、インターフェースベースの実装にしとくことでテストしやすくなるっていうことなんだけど、さらにデータストレージ層を置き換えも楽ちんとかいうのは、まぁ実際そんなこと滅多にするもんじゃないから普通のプロダクトにおいてそこがメリットになることはほぼなかったり...?

データモデルクラスをLINQ to SQL(L2S)のモデルの他に、純粋なデータクラスとして別途定義するのはコード量が増えるし、ちょっとイヤだな~、と思うところでそこはLINQ to SQLのモデルクラスをそのまま使うってことで勘弁してもらいます。クラスの変換とか余計な処理も必要になるしな~、と。

でね、IRepositoryでインターフェース定義して、その実装をLINQ to SQLを使って実装するんだけど、SubmitChangesってどこに書くのがいいんだか、ちょっと悩ましいところだったりしまして。 きちんとデータモデルを分けて定義しておけば、サービス層からデータアクセス層にデータクラスを渡して、その中でL2Sモデルに値をマッピングするのが王道パターンだとしても、それをやりたくない場合、サービス層でL2Sモデルに直接値を入れたりすることになるじゃないですか。わかりにくい説明でなんだかわかんなくなるな。

テーブルPeopleがあって、L2Sモデルクラスとして

public class Person
{
  public int Id {get;set;}
  public string FirstName {get;set;}
  public string LastName {get;set;}
}

っていうのがあったとしましょうよ(これがdbmlから自動生成されたとしてください)。 んで、そのDataContextクラスをTestDataContextとしましょう。 これに新しいデータを追加するなら

var db = new TestDataContext();
var person = new Person(){Id=1,FirstName="ロビン",LastName="ニコ"};
db.People.InsertOnSubmit(person);
db.SubmitChanges();

なんて書きますよね。 更新や削除なら

var db = new TestDataContext();

var person = db.People.FirstOrDefault(p=>p.Id=1);
if (person != null) {
  person.FirstName = "オルビア";
  db.SubmitChanges();  // ←これで更新確定
}
db.People.DeleteOnSubmit(person);
db.SubmitChanges();  // ←これで削除確定 

みたいな。 追加、更新、削除を持ったRepositoryにするなら

public interface IPeopleRepository {
  void Add(Person model);
  void Update();
  void Delete(Person model);
} 

とか、って書きたくなるけど、Updateは実質SubmitChangesだけじゃん!みたいな。 この例だと引数は最大3個でいいかもしれないけど、クラス次第でスゴイ引数の数になっちゃうから、もう一つ上で直接モデルに値を入れるほうがコード少なくて済むって話しです。 普通にRepositoryの関数内で都度SubmitChangesを呼び出すのが正しい実装、だと思う。

ASP.NET MVC Storefront Part 10: Shopping Cart Refactor and Authorization : The Official Microsoft ASP.NET Site

↑ここでもそういう書き方にリファクタリングしてるし。

そうすると、複数の更新をまとめてSbumitChangesしたい、なんてのはダメにするか、そういう関数をRepositoryに追加するってことになる。どっちもなんかヤダ。 んじゃ、データ操作の確定のためのSubmitChangesもサービス層で呼べばいいのかっていうと、単純にDataContextを上の層で持ちたくない(db.SubmitChanges()なんてコードをサービス層で書きたくない)。 結局、単純にSubmitChangesと同名のメソッドをRepositoryに実装して、それをサービス層で呼び出せばいいのかな?と。そしたらDataContextをサービス層が弄る必要ないし。

var repository = new PeopleRepository();
var p1 = new Person(){...};
var p2 = new Person(){...};
var p3 = new Person(){...};
repository.Add(p1);
repository.Add(p2);
repository.Add(p3);
repository.SubmitChanges(); 

↑こんな感じ?(コレクションにしてない&InsertAllSubmitにしてないっていうのは気にしないでね) で、ふと思い出した。Fluent Programming(Fluent interface - Wikipedia, the free encyclopedia)。 データ操作の戻り値として、IRepositoryを返せば(この場合ならIPeopleRepository)つなげられるな~、と。

public interface IPeopleRepository {
  IPeopleRepository Add(Person model);
  IPeopleRepository Delete(Person model);
  void SubmitChanges();
}

↑インターフェースはこう替える。 んで、実装は済んでるとして、呼び出し側では↓こう書く。

var repository = new PeopleRepository();
var p1 = new Person(){...};
var p2 = new Person(){...};
var p3 = new Person(){...};

repository.Add(p1);
         .Add(p2);
         .Add(p3);
         .SubmitChanges();

これなら間に、Deleteとかも連結出来るもんね。

他のRepositoryをどうするんだよ、って言うのは、まぁ...、そうなんだけど...。 とりあえず、同じRepositoryの場合はコレでいいですかね(結局L2Sモデルクラスをサービス層で使うんなら分離出来てないじゃないか、っていうのはその通りなんだけど...)。

2008年10月28日火曜日

UpdateModelのタイプ指定

ASP.NET MVCベータになってUpdateModelで「強く型付けされたホワイトリストフィルター」がありますね。これが悩ましくてですね。結局スマートな使い方はどうした物なのか未だに答えを見つけられないでいます。 そもそもViewDataをViewに渡す時ってどういう内容で渡しますか?

入力される項目と、表示しかしない項目があると思いますが、それってクラスを分けるもの? 単純に考えたら入力も表示も同じクラス内に混在させていいんじゃないの、って感じに実装すると思うんだけど、そうするとUpdateModelでちょっと面倒な事が起きたり起きなかったり。

例えば、表示専用のプロパティとしてLINQ to SQLのモデルクラスを持ってたりした場合なんて、切なくなりますよね。だってね、DefaultModelBinderでインスタンス作られると最悪Webサーバーがダウン。 その為にstring配列で復元対象のプロパティを指定したり、除外したりすることになります。ただ、単純に文字列をコードの中に埋め込むっていうのもシャキっとしない気がするんですよね(リフレクションでプロパティリストを自動取得するっていうやり方がシンプルで簡単だけど)。

だから、スコットさんのエントリに書いてあるように型指定して値を復元しようかな、と思い至る。 で、今度は保持してるプロパティにList<T>とかがあるとどうなるんだ、って話です。 前回までのエントリで配列なりListの取得が出来るのは分かってるんだけど、それを「強い型付け」で取得したいときどうしましょうかと。

  public interface IPerson
  {
    string Name { get; set; }
    int? Age { get; set; }
  }

  public class Person : IPerson
  {
    public string Name { get; set; }
    public int? Age { get; set; }
    public string Nickname { get; set; }
  }

  public interface IMyViewData
  {
    string TeamName { get; set; }
    int Level { get; set; }
  }

  public class MyViewData : IMyViewData
  {
    public string TeamName { get; set; }
    public int Level { get; set; }
    public List People { get; set; }

    public MyViewData()
    {
      People = new List();
    }
  } 

試しに↑こんなクラスを書いて。

var formData = new MyViewData();
UpdateModel(formData); 

と、実行するとですね、Peopleプロパティは復元されませんよね。 インターフェースにPeopleが無いから。 ※インターフェースに含めてプロパティの型をList<IPerson>にしても上手く行かない。 Peopleを強い型付けで復元したいときって、Prefixを指定してループで回して取得とか?

      var index = Request.Form["People.index"].Split(',').Length;
      for (var i = 0; i < index; i++)
      {
        var person = new Person();
        UpdateModel(person, string.Format("People[{0}]", i));
        formData.People.Add(person);
      } 

こうやって書けば、そりゃもちろん取得できるけど...。ダサい気がする。 IPersonじゃなくてPersonBaseクラスを定義して、PersonをインターフェースじゃなくてPersonBaseから派生させて保持する方法を考えてみた。

  public class PersonBase : IPerson
  {
    public string Name { get; set; }
    public int? Age { get; set; }
  }

  public class Person : PersonBase
  {
    public string Nickname { get; set; }
  }

...

UpdateModel>(formData.People, "People"); 

だけど、これってInvalidCastException。 Listの場合ってどうするのさ! コード量は少なくしたいっす。

次にListで取得して、それをListにキャストすればいいのかと。

var people = new List();
UpdateModel>(people, "People");
formData.People.AddRange(people.Cast()); 

これまたInvalidCastException。ダウンキャスト出来ないんだね~。そもそもCast()は何を使ってキャストしてるんですか。Reflectorで見た感じだとキャスト演算子(T)してるだけっぽいけど。 PersonBaseクラスにexplicitでPersonを返すメソッド書いたけど、派生クラスはダメよ!って怒られてダメだし...。 なので、PersonクラスのコンストラクタにPersonBaseを渡すほうほうに変えてみた。


public class Person : IPerson { public string Name { get; set; } public int? Age { get; set; } public string Nickname { get; set; } public Person() { } public Person(PersonBase person) { Name = person.Name; Age = person.Age; } }

Personクラスは↑こう変更。 で、UpdateModelの後のコードを↓こう変更。 formData.People.AddRange(people.Select(p=>new Person(p)); これは上手く動きますね。ViewDataのクラスのコンストラクタを追加する必要はあるけど、Controllerでは短いコードですむし。

Personクラスのコンストラクタはいじらずに、ConvertAllでConverterを渡しても結果同じだけど、コードはちょっと見づらい。Controllerに書くのもどうかと思うよね。

formData.People.AddRange(people.ConvertAll(
  new Converter(s => new Person() {
    Name = s.Name,
    Age = s.Age
  })
)); 

クラスの中に、違うクラスのコレクションや配列、リストをプロパティに持ってる場合の、UpdateModelのベストな使い方ってどう書くんですか...。 CustomMobelBinderを沢山作るのはちょっとヤダ(コード増えすぎ)。 教えて偉い人!

2008年10月22日水曜日

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

昨日のASP.NET MVC ベータの悩みのシンプルな解決方法はこれかな~。

UpdateModel(TryUpdateModel)で取得したForm値は、ModelStateDictionaryの値が優先されて、2回目以降のViewでInputExtentionsの引数の値を無視する、のこと。 昨日と同じようにまずはモデルクラス。

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

namespace MvcApplication.B1.Models
{
 public class Person
 {
   public string Name { get; set; }
   public int? Age { get; set; }
 }

 public class MyViewData
 {
   public string TeamName { get; set; }
   public int Level { get; set; }
   public List People { get; set; }

   public MyViewData()
   {
     People = new List();
   }
 }
} 

変更する必要も無かったけど、配列の時の使い方とそうじゃない場合を分かりやすくMyViewDataクラスのプロパティを追加。 今回もコントローラはHomeControllerってことで、以下のコードを追加。

    public ActionResult People()
   {
     var viewData = new MyViewData() {
       TeamName = "チーム座布団",
       Level = 11,
       People = new List() {
         new Person() { Name = "たけはら", Age = 33 },
         new Person() { Name = "まうり", Age = 26 },
         new Person() { Name = "しんたろ", Age = 21 }
       }
     };

     return View(viewData);
   }

   [ActionName("People"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult PeoplePost()
   {
     var formData = new MyViewData();

     if(TryUpdateModel(formData))
       formData.People.RemoveAt(1);

      ViewData.ModelState.Remove("People.index");
     if (ViewData.ModelState.IsValid)
       foreach (var ms in ViewData.ModelState.Where(ms => ms.Key.StartsWith("People")))
         ms.Value.AttemptedValue = null;

     return View(formData);
   } 

最後に、Views/Homeの下にPeople.aspxを追加。

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

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

<label>チーム名</label>
<% = Html.TextBox("TeamName") %>
<label>レベル</label>
<% = Html.TextBox("Level") %>
<table>
<%
 foreach(var person in ViewData.Model.People) {
   var row = ViewData.Model.People.IndexOf(person);
%>
<tr>
 <th>なまえ</th>
 <td>
 <% = Html.Hidden("People.index", row)%>
 <% = Html.TextBox("People[" + row + "].Name", person.Name)%>
 </td>
</tr>
<tr>
 <th>年齢</th>
 <td><% = Html.TextBox("People[" + row + "].Age", person.Age)%></td>
</tr>
<% } %>
</table>

<input type="submit" value="送信" />
<% } %>
</asp:Content>

Person クラスの配列になるPeopleプロパティの表示と取得をメインにしてるんで、hiddenでPeople.indexという名前で配列のインデックスを入れるのは昨日と同じだけど、Html.Hiddenヘルパーを使うのが違うところ。これはPostしたときに、必ずModelStateから削除するようにすることで値がCSVになっちゃわないように対応。これに関しては、UpdateModelでエラーが起きても関係なく削除しちゃって問題ないもんね。 で、Post時のコントローラでUpdateModelのエラーが無ければ、Peopleで始まるキーを持つModelStateの AtteptedValueにnullを入れることで、2回目以降のViewでも値がそのまま表示されるようにしてます。エラーの時には入力値がそのまま表示されるようにして欲しいから、エラー時にはnullを入れない。 これで、配列(List<T>だけど)のプロパティを持つクラスでも、UpdateModelで取得したり表示したり簡単にできるようになりました。

初回表示は↓。

img.aspx

送信ボタンを押した2回目の表示↓。 ちゃんと"まうり"が消えます。

img.aspx2

1回目の送信で入力エラーがあった場合の表示↓。

img.aspx3

やったね!

あと、Evalが拡張されて↓こう書くことで書式化が簡単になりました。

<% = ViewData.Eval("Age","{0:D4}") %>

※"0034"みたいに4桁0埋めの書き方。

2008年10月21日火曜日

ベータになって

ソースも公開されましたね~。

ASP.NET - Release: ASP.NET MVC Beta Source Code Release

これで少し救われた...。

今回の変更でかなりリファクタリングが進んで、いい感じですね。 だいたいの変更点はコンパイルエラーで判断できるので、まぁいいでしょう。 ちょっとビックリするのはMvcFuturesが一緒に配布されなくなったところ。 AntiForgeryToken/ValidateAntiForgeryTokenとFileResultを使ってたのでどうしようと悩む。 HtmlHelperが凄く整理(使う分には関係ないかもしんないけど)されてて、SubmitButtonが無くなったね。これまた使いたければ直に<input type="submit" />を書く。

と、思いきやMvcFuturesのbetaアセンブリがちゃんとダウンロード出来るようになってましたね。 ASP.NET - Release: ASP.NET MVC Beta Futures ※ダウンロード数が異様に少ない気がするのは気のせいか。あんまり誰も使わない?

がMvcFuturesにもFileResultが無く...(Html.SubmitButtonはこっちに入ってました)。 代わりにBinaryStreamResultっていうのがあって、これを使うことで対応出来る事が分かりました。 Streamをコンストラクタに渡さなきゃいけない(ファイル実体じゃなくてファイルのパスでもテスト出来ると思うけど、それじゃイカン!ヤカン!って事なんでしょうか)のが、変わったところ。

var fileStream = new System.IO.FileStream(filePath, System.IO.FileMode.Open);
var result = new BinaryStreamResult(fileStream) {
 ContentType = mimeType,       // MIMEタイプ
 FileDownloadName = fileName // Content-Dispositionのファイル名
}; 

Form の値を取得する部分が見た目それほど変化が無い(namespaceの変更とオーバーロードの変更)にも関わらず、内部では大幅な変更が入っている模様。それぞれのInputExtentions(CheckBox,Hidden,Password,RadioButton,TextBox)では privateのInputHelperを呼び出してinputタグを生成してるんだけど、その中でGetModelAttemptedValueを呼び出す。必ず呼び出す。これが曲者(ってわけじゃないけど)。 ModelState.AddModelErrorなんかが、今回の変更で入力値をあえて渡さなくていいようになってますよね。結局表示の段階でHtmlHelperのInputExtentionsを呼び出すから、それで前回入力値を取得して表示出来るっていう寸法で便利っちゃ便利なんです。が、しかしですよ、ということはInputExtentionsを使う限りは、 ViewData.ModelStateに入っている値が最優先で表示されるってことデスよ。いいじゃん、それで、と思う事なかれ。ModelState の値はPost時に確かに取得するけど、だからといってそれを表示したいわけじゃないってときもあるじゃないですか!ないですか!そうですか! UpdateModelを使って、Formの値を取得した場合、ModelStateにも同じ値が入るんで↓こうなる感じ。

public class Val
{
 public string MyValue {get;set;}
}

var  val = new Val();
UpdateMode(val); 

↑このとき、val.MyValueに"123"って入ってたとします。

Viewで

<%= Html.TextBox("MyValue", "456") %>

って書いててもデスよ、展開されるinputタグのvalueには"123"デスよ! 回避するにはどうするか。いまいち答えが見つけられず、とりあえずinputタグを直接書く(それだとエラーの時に自動でCssClassが追加されないし値も表示されない)...、か、ModelStateを消す? 泣けるっす...。 これだけじゃなく、配列(List<T>でも)の取得が出来るようになってたりするのに、いまいち書き方が分からずなところで、ソース公開だったので助かりました。以下にサンプルを。 まずはモデルクラスを定義。

using System; using System.Collections.Generic; using System.Linq; using System.Web; namespace MvcApplication.B1.Models { public class Person { public string Name { get; set; } public int? Age { get; set; } } public class MyViewData { public List People { get; set; } public MyViewData() { People = new List(); } } }

で、コントローラにアクション実装。

    public ActionResult People()
   {
     var viewData = new MyViewData();
     viewData.People = new List() {
       new Person() { Name = "たけはら", Age = 33 },
       new Person() { Name = "まうり", Age = 26 },
       new Person() { Name = "しんたろ", Age = 21 }
     };
     return View(viewData);
   }

   [ActionName("People"), AcceptVerbs(HttpVerbs.Post)]
   public ActionResult PeoplePost()
   {
     var formData = new MyViewData();

     UpdateModel(formData.People, "People.Person");

     formData.People.RemoveAt(1);

     return View(formData);
   } 

※これをHomeControllerに追加。 最後にViewを。Viewはコントローラと同じフォルダなので、今回はHomeに。

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

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

<table>
<% var row = 0; %>
<% foreach(var person in ViewData.Model.People) { %>
<tr>
 <th>なまえ</th>
 <td>
 <% = Html.TextBox("People.Person[" + row + "].Name", person.Name)%>
 <input type="hidden" name="People.Person.index" value="<%= row %>" />
 </td>
</tr>
<tr>
 <th>年齢</th>
 <td><% = Html.TextBox("People.Person[" + row + "].Age", person.Age)%></td>
</tr>
<%
 row++;
 }
%>
</table>

<input type="submit" value="送信" />

<% } %>
</asp:Content>

で、一発目動かして、/Home/Peopleにアクセス。 img.aspx

わかりやすいよね~。3人分の名前と年齢が出てきました。 で、今度はそのまま送信を押す。と、Postなんで違うアクション(PeoplePost)が実行されます。 コードを見て分かるとおり、UpdateModelで復元したあと、2番目のデータを削除して再表示。 このとき配列を復元するためにFormに"People.Person.index"っていうhiddenの値を埋め込むのがコツみたいですよ。なんでHtml.Hiddenを使わないのかっていうのは、以下の続きを。 で、この場合、「まうり」が消えた状態の2件が表示されるはずよね。 動かしてみましょうか?

img.aspx2

なんとまぁ「しんたろ」が消えてるじゃないっすか。 でも、ちょっと待てと。データは確かに「まうり」が消えてる。

img.aspx3

ね。これがGetModelAttemptedValueの値が優先されるときに起きる現象。 InputExtensionsのInputHelper関数の以下の行。

tagBuilder.MergeAttribute("value", attemptedValue ?? ((useViewData) ? htmlHelper.EvalString(name) : Convert.ToString(value, CultureInfo.CurrentUICulture)))

attemptedValueにはModelStateの値が入ってる(UpdateModelした時とか)から、Viewで引数に渡した値を全然無視。なもんで、Html.Hiddenでindexを入れてしまうと、カンマ区切りのpost値が勝手に埋め込まれてNG。 こういう使い方を実際にしちゃってるもんだから、設計を変えるのにどうしようかと悩み中デス。 今回の大きな変更部はやっぱりModelBinder周り。

ちゃんとアクションのパラメータに対するBindも実装(ガスブロ参照)されてるみたいだし。UpdateModel/TryUpdateModelなんかも併せて大幅改修な感じ?keyを渡すのがホワイトリスト(取得対象)/ブラックリスト(拒否対象)だったり、指定すら必要無くなってたり(これが嬉しいんだけど、値の保持し過ぎ問題を含む)、インターフェースで取得項目を指定する感じだったり。 ViewDataのクラスを作る場合、表示のタメだけに使いたいフィールドの定義なんかもするけど、それを UpdateModelで取得する必要が無いって時に、いろんな方法で対応出来るようになったのはいいことですね。今までのコードも今まで通り動く(ホワイトリスト方式)し。 今回のリリースで手痛い思いをするような作りをしてる人は少ないのかなと思いますが、個人的には設計を見直す必要があるので、ダサイ作り方をしてしまった(自分のプロジェクトがって意味です)と割り切って、考え直します...。

2008年9月16日火曜日

Feedの配信

もちろんASP.NET MVCで。

Feed自体はXMLなんで、XMLのレスポンスを返すようなPageを書けばそれで完了! でもそんなのかっこよくないよね。

んじゃどうすんだって、もちろんSyndicationFeedクラスでしょ! WCFじゃないと使えないってことはもちろんなくて、普通に使えるっしょ!

    public ActionResult Feed(string id, string format)
    {
      // データのロード
      var data = (LINQでアイテム抽出).Take(20).ToList();
     
      // 全体かタグのFeed
      var feed = new SyndicationFeed("フィードのタイトル", "", new Uri("サイトのURLとか"));
      var items = new List();
      foreach (var post in data)
      {
        items.Add(new SyndicationItem("アイテムのタイトル", "アイテムのコンテンツ", new Uri("アイテムのURL"), "ユニークなID", 更新日時));
      }
      feed.Items = items;

      SyndicationFeedFormatter formatter = null;
      string contentType;
      if (string.IsNullOrEmpty(format) || format.ToLower() == "atom")
      {
        formatter = new Atom10FeedFormatter(feed);
        contentType = "application/atom+xml";
      }
      else
      {
        formatter = new Rss20FeedFormatter(feed);
        contentType = "application/rss+xml";
      }

      var stream = new StringWriter();
      var xml = new XmlTextWriter(stream);
      formatter.WriteTo(xml);

      return Content("" + stream.ToString(), contentType);

    } 

↑こんなアクションを書く。

※XMLのヘッダが出力されないからって固定で書いて追加してるのはかっこわるい...。

これで、Viewなんて定義しなくてもいいよね!

話変わるけど、いつの間にやらStackoverflow.comが公開されてました。 Hottest "asp.net-mvc" Questions - Stack Overflow

↑とりあえずこのカテゴリだけでも超タメになるっす! Is anyone using the ASP.NET MVC Framework on live sites? - Stack Overflow この質問が切ない。

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年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て...。自重の倍て...。

2008年7月29日火曜日

IsMvcAjaxRequest

ナオキさんのサイトで取り上げられていたので、流行りに乗っかっていこうと思います! ASP.NET MVC Preview 4からAjaxが少しとりいれられてます。

クライアントサイドはMicrosoft Ajax Libraryがベース。 これの使いどころはやっぱり部分更新ですよね。ASP.NET AJAXならUpdatePanelのような動きと言えばわかりやすいかな?

とにかく動かしてみることにしましょう。 まずは、Preview 4のプロジェクトテンプレートで新しいプロジェクトを作成。 そしたら、AccountControllerとHomeContoroller、Views/AccoutとViews/Homeとか出てきます。 Views/Homeの中には最初に表示されるIndex.aspxとAboutページのAbout.aspxが出来てます。 とりあえずHomeControllerのIndexアクションとViews/Home/Index.aspxだけを使って試してみることにします。

初期のIndexアクションは↓。

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

return View();
}

Index.aspxは↓。

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

なんとまぁ、スッキリしたものが出てきますわ。 ※ViewData["Title"]はShared/Site.Masterで使ってますよ。 で、ページにFORMを張り付けてPOSTさせてみよう! IndexアクションでPOSTした値を取得してViewDataに入れようじゃないですか。

public ActionResult Index()
{
 ViewData["Title"] = "Home Page";
 ViewData["Message"] = "Welcome to ASP.NET MVC!";
   
 ViewData["result"] = "";
 if (Request.HttpMethod.ToLower() == "post")
 {
   ViewData["result"] = string.Format("こんにちは、{0} さん!", Request.Form["yourName"]);
 }

 return View();
} 

↓これをIndex.aspxに追加(</p>の後に)。

    <% using (Html.Form("Home", "Index"))
      { %>

      名前は?<%= Html.TextBox("yourName") %>
      <input type="submit" value="ぼたん" />
      <span id="result"><%= ViewData["result"] %></span>
    
   <%} %>

こんな感じですね。POSTしたyourNameをViewData["result"]入れて、それをspanタグ内に表示するものです。ここまではすんなりです。 img.aspx

↑こんな表示になるんで、テキストボックスに適当になんか入れて「ぼたん」押すと、↓こんな感じでボタンの横に表示されます。

img.aspx2 とびっきり普通の処理です。

ここからです! IndexアクションへのPOSTが発生した場合、ブラウザからのものなのかXMLHttpRequestからのものなのかを簡単に判別する方法として、Request.IsMvcAjaxRequest()というのがあるので、それを使うことにします。 なので、Indexアクションを変更。

    public ActionResult Index()
   {
     ViewData["Title"] = "Home Page";
     ViewData["Message"] = "Welcome to ASP.NET MVC!";
   
     ViewData["result"] = "";
     if (Request.HttpMethod.ToLower() == "post")
     {
       ViewData["result"] = string.Format("こんにちは、{0} さん!", Request.Form["yourName"]);
        if (Request.IsMvcAjaxRequest())
         return Content((string)ViewData["result"]);
      }

     return View();
   } 

↑太字の部分が追加したコードです。 差を分かりやすくするのに、Index.aspxには追加でAjax.Formを入れることにします。 なので、Index.aspxの全体は↓。

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

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">
   <script src="/Content/MicrosoftAjax.debug.js" type="text/javascript"></script>
   <script src="/Content/MicrosoftMvcAjax.debug.js" type="text/javascript"></script>
 
   <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>
 
   <% using (Html.Form("Home", "Index"))
      { %>

      名前は?<%= Html.TextBox("yourName") %>
      <input type="submit" value="ぼたん" />
      <span id="result"><%= ViewData["result"] %></span>
    
   <%} %>
 
   <% using (Ajax.Form("Index", new AjaxOptions { UpdateTargetId = "result2" }))
      { %>
    
      名前は?<%= Html.TextBox("yourName") %>
      <input type="submit" value="ぼたん" />
      <span id="result2"><%= ViewData["result"] %></span>
    
   <% } %>
 
</asp:Content>

これを実行すると2つのテキストボックスとボタンが表示されます。 img.aspx3

フォームも2つあるからそりゃそうですね。 最初に上段のテキストボックスとボタンに”さる”と入れて上段のボタンを押します。

img.aspx4

ボタンの横に両方とも”さる”と出ます。 続いて、下段のテキストボックスに”いぬ”と入れて下段のボタンを押します。

img.aspx5 小さすぎて見にくい...。 まぁ、それはいいとして、下段だけが"いぬ"になりましたね。 結果だけを見ると分かりにくいんですけど、実際にコードを書くとですよ、上段のボタンはページ全体をPOSTで取得するのに対し、下段はボタン横のテキストのみAJAXで取得して書き換えてる動きになってるのが確認できると思います。

2008年5月29日木曜日

進化の過程をウキウキウォッチング

いや~、ずいぶん変わってしまいました。 Preview2からPreview3への移行をしてみようと作業してて思ったのが、簡単にできるようになったかもってところです。 もちのろんでASP.NET MVC Preview3ですよ! 分かりやすくRESTfulフィルター作ります。

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

namespace MvcApplication4.p3
{
 public class RESTfulAttribute : ActionFilterAttribute
 {
   public string Post { get; set; }
   public string Put { get; set; }
   public string Delete { get; set; }

   public RESTfulAttribute() : this("", "", "") { }
   public RESTfulAttribute(string post, string put, string delete)
   {
     Post = post;
     Put = put;
     Delete = delete;
   }

   public override void OnActionExecuting(ActionExecutingContext filterContext)
   {
     string httpMethod = filterContext.HttpContext.Request.HttpMethod.ToLower();
  
httpMethod = (filterContext.HttpContext.Request.Form["_method"] ??
filterContext.HttpContext.Request.HttpMethod).ToLower();

     var actions = new Dictionary() {
       {"post",Post!="" ? Post : filterContext.ActionMethod.Name + "Post" },
       {"put",Put!="" ? Put : filterContext.ActionMethod.Name + "Put"},
       {"delete",Delete!="" ? Delete : filterContext.ActionMethod.Name + "Delete"}
     };

     if (actions.ContainsKey(httpMethod) && actions[httpMethod] != "")
     {
       filterContext.Cancel = true;
     
       var controller = filterContext.Controller as Controller;
       var actionInvoker = new ControllerActionInvoker(controller.ControllerContext);
       actionInvoker.InvokeAction(actions[httpMethod], new Dictionary());
     
       return;
     }

     base.OnActionExecuting(filterContext);
   }
 }
} 

はぁ、もうこの時点で違うんだね。 InvokeActionがControllerじゃなくて、ControllerActionInvokerクラスに移動になりました。 素直に呼び出せるからこれの方がいいね。 2個目のパラメータの意味が不明。MVCのソース見てもExecuteで↑みたいにnewしてる。 RESTful属性をつけたアクションの実行時にPOST/PUT/DELETE毎にアクションを振り分ける処理です。 属性のパラメータで名前を指定しなかった場合は、自動で呼び出しアクション名+HTTP MethodをInvoke対象のアクション名にしてます。

ホントはGETのときにNew(新規フォーム)/Edit(編集フォーム)/Show(表示だけ)を振り分けるのもID見たりしてフィルターでやった方がよりカッコよしかも。あと、どの表現(XHTML、JSON、XMLとか)を返すかとかも拡張子みたいな形で分けるとなお素敵さアップ。 続いてHomeControllerにアクションを追加。

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

namespace MvcApplication4.p3.Controllers
{
 public class HomeController : Controller
 {
   public ActionResult Index()
   {
     ViewData["Title"] = "Home Page";
     ViewData["Message"] = "Welcome to ASP.NET MVC!";

     return View();
   }

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

     return View();
   }

   [RESTful]
   public ActionResult Resource(int? id)
   {
     return Content(string.Format("GET!({0}) - {1}", Request.Form["value"], id), "text/html");
   }

   public ActionResult ResourcePost()
   {
     return Content(string.Format("POST!({0})", Request.Form["value"]), "text/html");
   }
 
   public ActionResult ResourcePut(int id)
   {
     return Content(string.Format("PUT!({0}) - {1}", Request.Form["value"], id), "text/html");
   }
 
   public ActionResult ResourceDelete(int id)
   {
     return Content(string.Format("DELETE!({0}) - {1}", Request.Form["value"], id), "text/html");
   }

   public ActionResult Item()
   {
     ViewContext vc = new ViewContext(ControllerContext, "dummy", "", null, null);
     var page = new ViewPage();
     page.Html = new HtmlHelper(vc, page);
     page.Url = new UrlHelper(vc);

     string partial_html = page.Html.RenderUserControl("~/Views/UserControls/Item.ascx");
     return Content(partial_html, "text/html");
   }
 }
} 

Resourceって言う名前のアクションを定義して、RESTful属性をくっつけました。 前 (Preview2)まで、単純にテキストを出力するのがなかったから、RenderTextなんてのをControllerにくっつけてたんだけど、新たにContentって言うメソッドで出力できるようになりましたね!ちなみに今回は使ってないけどJsonもあるよ!

最後のItemアクションはユーザーコントロール(ascx)の実行結果を出力するため(パーシャルっす)のサンプル。これは前とほぼ変わってないけど、全体的に引数の数が増えてる感じ?

最後にページ部分。Home/Index.aspxを書き換えてます。

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

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">
<h2><%= Html.Encode(ViewData["Message"]) %></h2>

<p>
<a href="<%= Url.Action("Resource") %>/1" class="restful">GET</a>
<a href="<%= Url.Action("Resource") %>" class="restful">POST</a>
<a href="<%= Url.Action("Resource") %>/1" class="restful">PUT</a>
<a href="<%= Url.Action("Resource") %>/1" class="restful">DELETE</a>
</p>

<p>
 <a href="javascript://" class="partial">どろんじょ</a>
 <div id="partial"></div>
</p>

<script type="text/javascript">
Event.observe(window, 'load', function(){
 var baseAction = '<%= Url.Action("Resource") %>';
 $$('a.restful').each(function(anchor){
   anchor.observe('click',function(e){
     var method = anchor.innerHTML;
     var index = anchor.href.indexOf(baseAction);
     var url = index >= 0 ? anchor.href.substring(index) : baseAction;

     new Ajax.Request(url,{
       method: method,
       parameters:'value=restful',
       onComplete:function(ajax){
         alert(ajax.responseText);
       }
     });
     Event.stop(e);
   });
 });

 $$('a.partial').first().observe('click',function(e){
   new Ajax.Request('<%= Url.Action("Item") %>',{
     onSuccess:function(ajax){
       partial.innerHTML = ajax.responseText;
     }
   });
 });

});
</script>
</asp:Content>

UserControls/Item.ascxの中身は何でもいいんだけど、とりあえず↓。

<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="Item.ascx.cs" Inherits="MvcApplication4.p3.Views.UserControls.Item" %>
<div>
 <ul>
   <li>マジで恋する5秒前</li>
   <li>ムゴ、ン色っぽい</li>
 </ul>
</div>

処理を簡単にするために、Site.Masterでprototype.jsを読み込むようにしてます。 実行すると、最初が↓。 img.aspx

PUTリンクをクリックすると↓。

img.aspx2

んで、どろんじょリンクをクリックすると↓。

img.aspx3

これで今日からRESTful!

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

2008年3月21日金曜日

ASP.NET MVCでAjaxな認証

ASP.NET Ajaxとの相性が良くないよね、という思いからprototype.jsへ返り咲き。 jQueryにはすごいのがあったしね。あ、でもそこまで(Controllerを派生させてExecuteとRenderViewをオーバーライドして、Site.Masterか Ajax.Masterか切り替え。さらにクライアントサイドのjQueryで対象エレメント部だけを抽出しなおして描画に反映。すごくて感動した)のはちょっと面倒なので、軽くね。

  1. まず、普通にASP.NET MVC(Preview2)でプロジェクト作成。
  2. そしたらControllers/Models/Views/Contentフォルダができるので、このContentにprototype.jsを入れる。
  3. Controllersに名前を"SecureController"を作成(なんでもいいんだけど)。
  4. Controllersに名前を"ApiController"を作成(なんでもいいんだけど)。
  5. Views/Homeフォルダに"Mvc View Content Page"をテンプレートに"Login.aspx"を作成。
  6. 同じくViews/Apiフォルダ(作る)に"Mvc View Page"(ここ大事!)をテンプレートに"Login.aspx"を作成。
  7. Views/Secureフォルダ(作る)に"Mvc View Content Page"として"Index.aspx"を作成。

ここまでで準備完了。イメージ的には/Secure/Indexはログインしてないとアクセスできないよ、って言う感じです。 ルートのweb.configでForm認証使うことと、セキュアなパスを指定。

        <authentication mode="Forms">
           <forms loginUrl="/Home/Login"></forms>
       </authentication>

↑これと↓これ。

<location path="Secure"> <system.web> <authorization> <deny users="?"/> </authorization> </system.web> </location>

Views/Shared/Site.Masterのhead要素内にprototype.jsへのscriptタグを作成。

    <script src="/Content/prototype.js" type="text/javascript"></script> 

※常にルートを指すようにしてるけど、相対で参照するようにしたい場合は、ヘルパーを作りましょう。標準で用意してくれることを希望しつつ。 Views/Home/Indexの中身は単純にSecure/Indexへのリンクだけ。これをActionLink<T>で作成(なんかこれがお勧めなリンクの作り方っぽい)。

<%@ 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">
   <h2>Ajaxな認証機能</h2>
   <p>
   <%= Html.ActionLink<SecureController>(c=>c.Index(),"Secure Page") %>
   </p>
</asp:Content> 

※@importでコントローラのnamespaceを指定しておく。

img.aspx

"Secure Page"のリンク先は"/Secure/Index"(Indexは省略されてるけど)。 中身は単純に↓こんな感じにしときました。

<h2>要認証!!</h2>
ログインしてないとみれないよ。<br />
Welcome <b><%=User.Identity.Name %></b>!!

この段階でリンクをクリックすると認証してないので、単純に”Home/Login”にリダイレクトされます。なので、今度はそっちを用意。

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="MvcApplication1.Views.Home.Login" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContentPlaceHolder" runat="server">
<form>
 <table>
 <tr>
   <th>Longin ID</th>
   <td><%=Html.TextBox("id") %></td>
 </tr>
 <tr>
   <th>Password</th>
   <td><%=Html.Password("pass") %></td>
 </tr>
 <tr>
   <td colspan="2"><input type="button" value="Login" onclick="login()" /></td>
 </tr>
 </table>
</form>
<script type="text/javascript">
function login() {
 var params = $H({"id":$F("id"), "pass":$F("pass")}).toQueryString();

 new Ajax.Request("/Api/Login",{
   method:"post",
   postBody:params,
   onComplete:function(req){
     var status = req.status;
   
     //req.responseText
     if (status==200)
       alert("Success!! " + req.responseText);
     else
       alert("Fail... " + req.responseText);
   }
 });
}
</script>
</asp:Content> 

認証するためのリクエストパスが"Api/Login"になってるのと、認証結果はRESTfulっぽくステータスコードで判定。今回は成功すれば200(OK)、失敗すれば406(Not Acceptable)を利用(使い方があってるのか自信ないけど...)。 HTTPステータスコード – Wikipedia

続いて、Api/Loginの中身。今回は単純なIDとPasswordを利用してみます。実際にはMembershipを使うと思うけど。

    public void Login()
   {
     string id = this.ReadFromRequest("id");
     string pass = this.ReadFromRequest("pass");

     ViewData["login"] = false;
     if (id == "yama" && pass == "kawa")
     {
       ViewData["login"] = true;
       FormsAuthentication.SetAuthCookie(id, false);
     }

     RenderView("Login");
   } 

続いて、Views/Api/Login。 ここは戻り値を返すだけの機能にしたいし、レスポンス内容をJsonにしてみるために、Masterを参照しないページ(Mvc View Page)にしました。

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="MvcApplication1.Views.Api.Login" %>
(<%= ResponseJson() %>) 

ページの中身は↑これだけ。

public void Page_Load() { Response.ContentType = "application/json"; if ((bool)ViewData["login"] == true) Response.StatusCode = 200; else Response.StatusCode = 406; } public string ResponseJson() { return "{\"result\":" + (ViewData["login"]+"").ToLower() + "}"; }

↑コードもこれだけ。Jsonをベタ書きしてるけど、実際には上手いことシリアライズを使うでしょうね。 で、動かす! img.aspx2

あえて、違うID/Passwordを入れてみる。

img.aspx3

正しく入れる。

img.aspx4

この成功か失敗の判定はレスポンス内容のJsonをevalして判定せず、XHRのstatsプロパティが200かどうかで判定。RESTfulっぽい~。 画像だと分かりにくいけど、画面のリフレッシュは発生してないからね! 最後に、Home/Indexに戻って"Secure Page"のリンクをクリックすると~。

img.aspx5

イエ~イ。 実際にはControllerでSetAuthCookieやるより、Viewでやるの?ViewでJsonとか返したかったらControllerFactory使うの?えらい人教えて! これやってて思ったけど、承認はアクション毎にかけるのがMVCっぽいんだね(今回は違うけど)。リソースに対するアクションに権限があるかどうかの方が、コントローラに権限があるかどうかよりもRESTfulだし。フォルダに制限をかけるっていう発想じゃなくて、誰がどのリソースにどんな権限があるかってことだもんね。 結局ActionFilterAttributeで制限かけるのが王道になるのかな~。

dotnetConf2015 Japan

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