2008年10月30日木曜日

関連情報も続々登場

ASP.NET MVCベータの気になる関連情報。


その1.ASP.NET - Release: ASP.NET MVC Beta Source Code Release
まだそれほどダウンロードされてない気がするけど、これを見とかないことにはドキュメントが無い状態では効率よく開発するのは難しいよね。

その2.ASP.NET - Release: ASP.NET Dynamic Data 4.0 Preview 1
ASP.NET MVCとDynamic Dataの融合。
サンプルのブログが参考になります。

その3.SubSonic MVC Addin Updated for Beta 1 : Rob Conery
これまたスゴイんだけど、ScaffoldできるSubSonic。
リポジトリパターンのデータアクセスと、コントローラ、ビューまでをLINQ to SQLのモデルから生成。生成されるビューを_Templatesフォルダに作っておくことで、カスタマイズ可能。
Dynamic Dataでのお手軽作成に負けないほどの凄さ。

その4.Cloudy in Seattle : ASP.Net MVC Projects running on Windows Azure
これはまだ試せてないけど、Windows Azure上にASP.NET MVCをデプロイする方法。
衝撃的...。出来るかどうか、調べようと思ってた矢先のエントリでビックリ。
データアクセスがSQL Servicesだろうから、そっちの使い方も調べる必要があるけど、まずは動くっていうのが分かっただけでワクワクする。

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年10月16日木曜日

ASP.NET MVC Betaリリース

Download details: MVC Beta


codeplexにはまだ出てないんだけど、ASP.NET MVC Betaが出てましたね。
早速ナオキさんところで、取り上げられててビックリした。

ナオキにASP.NET(仮) : ASP.NET MVC Beta のインストール方法と面白い機能1つ紹介

Viewの追加が簡単になってるのと標準でJavaScriptの場所が/Scriptsになってるんですね。
最後の一文がプレッシャー...。
↓ここにサラッと一覧に書かれてるの発見。

Microsoft ASP.NET MVC Beta Released! - John Mandia's Points of Interest

でも、これだけだとやっぱり細かいと所は分からないので、リリースノートを読むことにします。
Preview 5からの変更点はそんなに多くないみたいな事が書かれてて、ちょっと一安心。
ただ、ソース(出るよね?)もドキュメントもこれからみたいだし、全然わかんない...。

スコット君の言ってた、アクションパラメータへの直接Bind指定の実装はされてるんだろうか(ASP.NET MVC Preview 5 and Form Posting Scenarios - ScottGu's Blog)?


Developer @ ADJUST : ASP.NET MVC でリダイレクト先アクションのURLにアンカーを指定したい - よりスマートな別解

↑こちらのサイトで話題になったUrlの取得もControllerにUrlという名前でUrlHelperが入ったことで、お手軽に出来るようになりましたね。

AcceptVerbs の判定に、独自の条件をつけれるようになったのかな~?作ってみないとよくわかんないですね。ModelStateDictionaryの AddModelErrorがキーとメッセージの2つの引数だけで良くなったみたいで、デフォルトのAccountControllerがそういう実装になってるのが、優しさを感じます(_FORMっていうキー値は適当につけただけなんだろうか。Html.FormがBeginFormに変更になってるのも優しさ?)。
いろいろありそうなので、とにかくいじってみます!

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の指定が悪いのかなんなのか。何にせよ、設定でちゃんと思い通りに出来なかったらコードを書けばいいじゃないか! ※負け犬...。

dotnetConf2015 Japan

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