2009年3月29日日曜日

NerdDinnerでYSlow

TEERA 2.0 » YSlow web page optimization for ASP.NET MVC

上記サイトでは一般的な話になってるけど、せっかくなのでNerdDinner(NerdDinner.com - Where Geeks Eat - Home)がどこまで最適化できるのか試してみようと思います(ビデオ見疲れたし)。

まずは現状↓こんな感じです。

nd1

nd2

※クリックで拡大。

見ての通り、あまり最適化の余地がない...。localhostからの取得自体が少なすぎ。Site.cssとHome/IndexのHTMLと、jQueryとMap.js。あとはVirtualEarthのサーバーから取得。

トータルGradeは60ポイントでD判定。

うぬ~。マッシュアップ恐るべし。

あまりいいサンプルじゃないのは分かってるけど、少し最適化。最適化手法そのものはどんなサイトでも通用する物なので。今回どこを最適化するかというとCSSとJavaScript。画像もないし、ETagもVirtualEarthがらみだし、CDNも勘弁。NerdDinnerサンプルではCSSは1個しかないけど圧縮して、キャッシュが効くように。同じくJavaScriptも圧縮してキャッシュが効くようにするけど、さらに動的に1つのファイルにしてしまう。今回はHome/Indexしかいじらないですが、Viewに埋め込まれてるJavaScriptも外だしにしてしまいます。

ようするに以前の投稿の続きです。スタティックハンドラはそのまま使います。JSONPが無いのでそこもスルー。

圧縮と縮小化、連結、キャッシュヘッダのコントロールを行うのに新しいコントローラを作成します。コントローラのアクションの結果が圧縮されたCSSかJavaScriptになるようにします。わざわざStaticFileHandlerじゃないのは複数ファイルを連結させたいからです。

まずは圧縮させるためのActionFilterを定義。

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

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

namespace ClientTagHelpers
{
public class CompressAttribute : ActionFilterAttribute
{
public override void  OnActionExecuting(ActionExecutingContext filterContext)
{
 // base.OnResultExecuted(filterContext);

 var request = filterContext.HttpContext.Request;

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

 if (string.IsNullOrEmpty(acceptEncoding)) return;

 acceptEncoding = acceptEncoding.ToUpperInvariant();

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

}
}
}
なんてことないです。Response.Filterに圧縮用のストリームを指定するだけ。後はよしなにはからってくれます。使い方はこれをコントローラのアクションに指定するだけです。続いてコントローラ。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Text;
using System.IO;
using System.Web.UI;

using System.Web.Caching;
using ClientTagHelpers;

namespace NerdDinner.Controllers
{
public class UtilityController : Controller
{
[Compress, OutputCache(Duration = (30 * 24 * 3600), VaryByParam = "*")]
public ActionResult Compress(string src, string key, string ver)
{
 var mime = key.Equals("css", StringComparison.CurrentCultureIgnoreCase) ?
   "text/css" : "application/x-javascript";
 var path = TagRegisterHelper.GetKeyVirtualPath(key);
 var items = src.Split(',');

 if (items != null && items.Length > 0)
 {
   var cacheKey = src + key + ver;
   var responseText = (string)HttpContext.Cache.Get(cacheKey);
   if (responseText == null)
   {
     var srcText = new StringBuilder();
     foreach (var script in items)
     {
       string fullpath = Server.MapPath(path + script);
       if (System.IO.File.Exists(fullpath))
         srcText.Append(System.IO.File.ReadAllText(fullpath));
     }

     if (key.ToLower() == "css")
     {
       responseText = srcText.ToString();
     }
     else
     {
       var minJs = new StringBuilder();
       using (TextReader tr = new StringReader(srcText.ToString()))
       {
         using (TextWriter tw = new StringWriter(minJs))
         {
           new JavaScriptSupport.JavaScriptMinifier().Minify(tr, tw);
         }
       }
       responseText = minJs.ToString();
     }
     HttpContext.Cache.Insert(cacheKey, responseText);
   }

   return Content(responseText, mime );
 }

 return new EmptyResult();
}
}
}
Utility/Compress?src={カンマ区切りでファイル名}&key={種類}&ver={キャッシュさせるためのバージョン} ↑こんな感じのUrlで使用します。ルーティングを登録すればもう少し綺麗になりますね。気になる方はチャレンジしてみてください。

ここでJavaScriptの縮小化をするのにJavaScriotMinifierと言うのを使ってます。これはSmallSharpTools.Packer - Tracから取得出来ます。自分で書いてもいいです。単純にコメントと空白を削除するだけなので自分でも書けるところですが、ここでは楽します。変数名やファンクション名を最適化とかは無しです。自分でそこまでするならハフマン的なことをすればいいかも。

ファイルを1つに連結して縮小化のステップを毎回処理するのは大変(CPUとIOが)なので、HttpRuntimeのCacheに入れてしまいましょう。キャッシュのクリアは考えない富豪プログラミングで。パラメータで指定しているverには指定したファイル群の中で最大の最終更新日時を整数化したものを渡すように作れば上手く行く寸法です。

OutputCache属性はMVCにそもそも用意されてるものなので、そのまま利用しましょう。ここでは有効期限1ヶ月を指定してます。もっと長くてもいいです。どうせverの値が変われば再生成なのでお構いなし。

サーバー上でメモリに実体をキャッシュし、クライアントでも有効期限を指定してファイルキャッシュさせるのは無駄なように見えますが、違うUAからのアクセスならクライアントキャッシュは入って無いので、その時サーバー上のメモリキャッシュが賢く機能してくれます。その為には両方のキャッシュを上手く使う事が大事。中間にリバースプロキシを入れてそこでキャッシュさせたりすると、アプリケーションサーバーの負荷を下げるのに役立つよね。やったこと無いですが。

後は、Viewで使うヘルパーを。

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

using System.Web.Routing;
using System.Web.Mvc;
using System.Text;
using System.IO;
using System.Web.Script.Serialization;

namespace ClientTagHelpers
{
public static class TagRegisterHelper
{
public static string SourceKey = "_sourceKeys";
public static string ModelKey = "_modelKeys";
public static string UrlKey = "_urlKeys";
static string _formatScript = "<script type=\"text/javascript\" src=\"{0}\"></script>\n";
static string _formatCss = "<link rel=\"stylesheet\" href=\"{0}\" type=\"text/css\" />\n";
static string _pageScriptPath = "~/Views/";

public static string ToJSON(object values)
{
 if (values != null)
 {
   #pragma warning disable 0618
   JavaScriptSerializer serializer = new JavaScriptSerializer();
   return serializer.Serialize(values);
   #pragma warning restore 0618
 }
 else
   return "";
}

public static Dictionary<string, string> PathList = new Dictionary<string, string>()
 {
   {"css","/Content/"},
   {"site","/Scripts/"},
   {"page","/ViewScripts/"},
   {"jsonp",""}
 };

static Dictionary<string, List<object>> GetContextItems(HttpContextBase context, string key)
{
 var items = context.Items[key] as Dictionary<string, List<object>>;
 if (items == null)
   context.Items[key] = items = new Dictionary<string, List<object>>();

 return items;
}

public static string GetKeyVirtualPath(string key)
{
 var path = PathList[key.ToLower()];
 if (key.Equals("page", StringComparison.CurrentCultureIgnoreCase))
   path = _pageScriptPath;

 return path;
}

static DateTime GetLastModify(string basePath, IEnumerable<string> fileNames)
{
 return fileNames.Max(fileName => File.GetLastWriteTime(basePath + fileName.Replace("/", "\\")));
}

// -------------------------------------

public static void RegisterViewScripts(this HtmlHelper helper)
{
 helper.RegisterViewScripts(null, null);
}

public static void RegisterViewScripts(this HtmlHelper helper, string scriptName)
{
 helper.RegisterViewScripts(scriptName, null);
}

public static void RegisterViewScripts(this HtmlHelper helper, object values)
{
 helper.RegisterViewScripts(null, values);
}

public static void RegisterViewScripts(this HtmlHelper helper, string scriptName, object values)
{
 // ViewPathからScriptファイル名を推測
 if (string.IsNullOrEmpty(scriptName))
 {
   var webFormView = helper.ViewContext.View as WebFormView;
   if (webFormView != null)
   {
     var viewFile = (helper.ViewContext.View as WebFormView).ViewPath;
     scriptName = viewFile.Replace(".aspx", "") + ".js";
   }
 }
 else if (!scriptName.StartsWith(_pageScriptPath))
   scriptName = _pageScriptPath + scriptName;

 // 実体パス
 var filepath = helper.ViewContext.HttpContext.Server.MapPath(scriptName);

 // ファイルがあるならリストに追加
 // ※ベースフォルダを除外したパス
 if (System.IO.File.Exists(filepath))
   helper.RegisterSource("page", scriptName.Replace(_pageScriptPath, ""));

 if(values!=null)
   helper.RegisterJSON(values);
}

public static void RegisterJSON(this HtmlHelper helper, string key, object value)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, ModelKey);

 // ModelDataの場合は同一キーで値を入れようとしてもダメよ。
 // 常に最初に入れた値だけが取り出せます。
 if (!items.Keys.Contains(key))
   items.Add(key, new List<object>());

 items[key].Add(value);
}

public static void RegisterJSON(this HtmlHelper helper, object values)
{
 var modelValues = new RouteValueDictionary(values);

 foreach (var modelValue in modelValues)
   helper.RegisterJSON(modelValue.Key, modelValue.Value);
}

public static void RegisterSource(this System.Web.Mvc.HtmlHelper helper, string key, string fileName)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 if (!items.ContainsKey(key))
   items[key] = new List<object>();

 if (fileName.StartsWith("~/"))
   fileName = VirtualPathUtility.ToAbsolute(fileName, helper.ViewContext.HttpContext.Request.ApplicationPath);

 items[key].Add(fileName);
}

// -------------------------------------
public static string RenderModelJSON(this HtmlHelper helper)
{
 var formatJSON = "<script type=\"text/javascript\">\n" +
                  "var pageModel = {0};\n" +
                  "</script>\n";
 string json = "{}";
 var values = GetContextItems(helper.ViewContext.HttpContext, ModelKey);
 if (values != null && values.Count > 0)
 {
   var modelData = values.Select(v=>new {
                             Key = v.Key,
                             Value = v.Value[0]
                         })
                         .ToDictionary(v=>v.Key, v=>v.Value);
   json = ToJSON(modelData);
 }

 return string.Format(formatJSON, json);
}

private static string ScriptTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 var sb = new StringBuilder();

 if (items.ContainsKey(key))
   foreach (var item in items[key])
     sb.Append(helper.ScriptTag(PathList[key] + item.ToString()));

 return sb.ToString();
}

public static string ScriptTag(this HtmlHelper helper, string source)
{
 return string.Format(_formatScript, source);
}

public static string RenderScriptTags(this HtmlHelper helper)
{
 return helper.RenderScriptTags("site") +
        helper.RenderScriptTags("jsonp") +
        helper.RenderModelJSON() +
        helper.RenderScriptTags("page");
}

public static string RenderScriptTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 var nonCompress = new[] { /*"page",*/ "jsonp" };
 if (nonCompress.Contains(key.ToLower()))
   return helper.ScriptTags(key);
 else
   return helper.CompressTags(key, _formatScript);
}

public static string RenderCssTags(this System.Web.Mvc.HtmlHelper helper, string key)
{
 return helper.CompressTags(key, _formatCss);
}

public static string RenderCssTags(this System.Web.Mvc.HtmlHelper helper, string[] fileNameList)
{
 var key = "css";
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 items[key] = new List<object>();
 foreach (var fileName in fileNameList)
   items[key].Add(fileName);

 return helper.CompressTags(key, _formatCss);
}

private static string CompressTags(this System.Web.Mvc.HtmlHelper helper, string key, string format)
{
 var basePath = GetKeyVirtualPath(key);
 var path = helper.ViewContext.HttpContext.Server.MapPath(basePath);
 var items = GetContextItems(helper.ViewContext.HttpContext, SourceKey);
 if (items.ContainsKey(key))
 {
   var list = GetContextItems(helper.ViewContext.HttpContext, SourceKey)[key].Cast<string>();
   var maxDate = GetLastModify(path, list);
   var ver = maxDate.ToFileTime().ToString();

   string names = list.Select(l => l).Aggregate((s, ss) => s + "," + ss);
   if (!string.IsNullOrEmpty(names))
     return string.Format(
              format,
              string.Format("/Utility/Compress?src={0}&key={1}&ver={2}",
              new[]{
             helper.ViewContext.HttpContext.Server.UrlEncode(names),
             key,
             ver})
            );
 }

 return "";
}
}
}

前回のエントリの中でも説明しましたが、CSS・サイト共通外部JS・ページ固有外部JS・ 無名クラスをJSONで展開の4パターンを上手いこと処理するヘルパーです。それぞれ固定のパスにファイルがあるという前提でタグを出力します。CSSは/Content、サイト共有のJavaScriptは/Scripts、ページ固有のJavaScriptはViewと同じフォルダにViewと同じ名前で作成(/ViewScripts/Home/Index.jsの形式でアクセス)。

Compressアクションに指定するverを生成するために、GetLastModifyがファイルに直接アクセスして最終更新日時を取得してるけど、ここでもHttpRuntimeのCacheをCacheDependencyを上手く利用してしまえば、実体へのアクセス(IO)はなくせるので、さらに改良の余地あり。

Viewも変更します。まずはSite.Master。
<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>

<!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 id="Head1" runat="server">
<title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
<% = Html.RenderCssTags(new[] { "site.css" }) %>
<meta content="Nerd, Dinner, Geek, Luncheon, Dweeb, Breakfast, Technology, Bar, Beer, Wonk" name="keywords" />
<meta name="description" content="Host and promote your own Nerd Dinner free!" />
<% Html.RegisterSource("site", "jquery-1.2.6.js"); %>
</head>

<body>
<!-- 省略 -->
<%= Html.RenderScriptTags() %>
</body>
</html>

ヘッダでCSSをlinkタグで書いていたのをHtml.RenderCssTagsに変更。引数は文字列配列でファイル名を並べてください。いくつ書いてもOKです。すべてを1つのファイルに連結して、圧縮したレスポンスを返すようにUtility/Compressを参照するlinkタグを生成します。同じくjQueryのscriptタグをHtml.RegisterScriptTagsに変更。最初の引数"site"はサイト共有を表します。閉じbodyの直前にあるHtml.RenderScriptTagsでRegisterしているすべてのスクリプト(サイト共有、ページ固有、無名クラス3つとも)を展開します。こうすればページの最後ですべてのスクリプトを展開出来るようになって、YSlow的にも高評価。

今回はHome/Indexしか最適化しない(全部は面倒)ので、Home/IndexのViewを少し変更します。

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage" %>

<asp:Content ID="Title" ContentPlaceHolderID="TitleContent" runat="server">
Find a Dinner
</asp:Content>

<asp:Content ID="indexContent" ContentPlaceHolderID="MainContent" runat="server">

<script src="http://dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=6.2" type="text/javascript"></script>

<h2>Find a Dinner</h2>

<div id="mapDivLeft">

<div id="searchBox">
   Enter your location: <%= Html.TextBox("Location") %> or <%= Html.ActionLink("View All Upcoming Dinners", "Index", "Dinners") %>.
   <input id="search" type="submit" value="Search" />
</div>

<div id="theMap">
</div>

</div>

<div id="mapDivRight">
<div id="dinnerList"></div>
</div>

<% Html.RegisterSource("site", "Map.js");%>
<% Html.RegisterViewScripts();%>

</asp:Content>

ここでマッシュアップするためにVirtualEarthのサーバーからスクリプトを取得するようになってますね。これに関してもちゃんとページの最後にタグ出力しなきゃいけないところですが、そこまでは作ってないので、これまた興味のある人はRegisterOuterScripts的な関数を作ってみてSite.MasterのRenderで一括出力できるようにしてみてはどうでしょう。今回そこは作ってないので、直接ここに残したままにしときます。サイトで使うVirtualEarth用の関数群を保持してるMap.jsは、本来サイト共有のスクリプトなのでSite.Masterで一括して指定しておきたいところですが、無駄にすべてのページに反映されるのも良くないので、ViewでRegisterします。こうすると、サイト共有のスクリプトキャッシュが2種類作成されることになります。1つはjQuery+Mapと、もう一つはjQueryのみのキャッシュ。それほど迷惑な話でもないので、これでもいいんじゃないかなと思いますがどうですかね。最後のRegisterViewScriptsがページ固有の外部JSを登録してる部分です。Viewのパスから勝手にスクリプト名を判断してます。複数のViewで共有っていうこともあり得る(例えば、データの新規登録と編集では共通のスクリプトを使いたいけど、それはサイト共有じゃない)ので、スクリプト名を指定するオーバーロードもあります。細かいところはソースを見てね。

最後にViewScripts用のルートを登録すれば完成です。前投稿と同じですが再掲(Global.asax)。

          // -----------------------------------------
       // view scripts routing
       routes.MapRoute(
         "ViewScripts",
         "ViewScripts/{*scriptName}",
         null,
         new { scriptName = @"[\w]*\/[.|\w]*\.js" }
       ).RouteHandler = new ClientTagHelpers.ViewScriptRouteHandler();

これで、再度YSlow!

nd3

nd4

※クリックで拡大。

ちゃんとCSSとJavaScriptは圧縮されてファイルサイズも小さく(トータルが425KBから325KBに縮小)なってるけど、全体的には全く効果が無いに等しい...。Grade 67でDのままだし。7ポイントアップはしたけど。

VirtualEarthが有効期限の無いレスポンスを返してるのと、Etagがちゃんと制御されてないのが大きなところ。これ以上はどうしようもないかな~。

もっと、本格的なアプリケーションだとこれでずいぶん最適化される(CompressをHome/IndexアクションにつけるとかもOKだけど認証かかってる物のキャッシュは気をつけてね)し、画像なんかが多い場合も最適化の余地が残されてる可能性大なので。

興味ある人はお試しアレ。

今まで、プロジェクトのソースを直接書いてたけど、ダウンロード出来る方が試しやすいと気がついたので、貼り付けておきます。

そうそう、Prototype of VEToolkit + ASP.NET MVC 1.0 Component Checked InというサイトでVirtualEarthのMVC用ヘルパーを公開中です。Ajax.MapでJavaScript(NerdDinnerのMap.jsの部分)を出力してくれます。外部ファイルにならないけど、パラメータ指定がチェインしててなんかオシャレな感じ。でもこれなら無名クラスでしていして、内部でデフォルト値とマージするスタイルの方がいい気がするね。

        <%-- Create a Map and name it's global JavaScript variable "myMap" so it can be referenced from your own JavaScript code --%>
     <%=Ajax.Map("myMap")
         .SetCssClass("MapWithBorder")
         .SetCenter(new Location(44, -78))
         .SetZoom(6)
         .SetMapStyle(MapStyle.Road)
         .SetOnMapLoaded("MapLoaded")%>

↑こうやって使うんだけど、↓こうの方が分かりやすくないですか?

        <%=Ajax.Map("myMap")
         .Set(new {
           CssClass = "MapWithBorder",
           Center = new Location(44, -78),
           Zoom = 6,
           MapStyle = MapStyle.Road,
           OnMapLoaded = "MapLoaded"
         }) %>
         

この辺は好みだから、自分で作っちゃえばいいんだけどね!ちなみにソースを確認してて、面白いEnumヘルパー発見。EnumExtensionsクラスでEnumに拡張メソッドを指定してるんだけど、Enumに属性でJavaScriptに出力するときの文字列を指定しておくというもの。例えば↓。

    public enum MapMode : int
 {
     /// <summary>
     /// Displays the map in the traditional two dimensions
     /// </summary>
     [JavaScriptValue("VEMapMode.Mode2D")]
     Mode2D = 0,
     /// <summary>
     /// Loads the Virtual Earth 3D control, displays the map in three dimensions, and displays the 3D navigation control
     /// </summary>
     [JavaScriptValue("VEMapMode.Mode3D")]
     Mode3D = 1
 }

こうやって、Enumの属性に指定しておいて、出力するときにToJavaScriptValueの結果をレンダリング。賢い~!!