ラベル ASP.NET の投稿を表示しています。 すべての投稿を表示
ラベル ASP.NET の投稿を表示しています。 すべての投稿を表示

2012年7月30日月曜日

外部のWebサービスを呼び出す側の簡単なユニットテスト

外部システムとの連携で、テストしにくいこといろいろあると思うんだけど、その辺について先日会社でこうするといいんじゃないのー?って話した内容のおさらい。

外部システムって言っても、同一システムの一部であることも含めます。とにかく、テストをしたいと思ってる部分から接続する必要のあるものすべて。社外か社内かはあんまり関係なく。WCFかASMXかRESTかすら関係なく。ホントはDBもだけど、今回はHTTPベースのWebサービスということで。

まずは、わかりやすくWCFでWebサービスを作ってみます。別にWebAPIでもいいしー。なんでもいいんだけどー。用意するのはVS2010とFiddlerとブラウザとテキストエディタ。

[ServiceContract]
public class WebScraper
{
  [OperationContract]
  public string ExtractPageTitle(string url)
  {
    var title = "-unknown-";
    var client = new HttpClient();
    var html = string.Empty;
    client.GetStringAsync(url)
          .ContinueWith(response =>
          {
            html = response.Result;
          })
          .Wait();

    var match = Regex.Match(html, "<title>([^<]+)</title>");
    if(match.Success)
    {
      title = match.Groups[1].Value;
      if (!string.IsNullOrWhiteSpace(title))
          title = HttpUtility.HtmlDecode(title);
    }

    return title;
  }
}

なんでもいいんだけど。こんなの。外部Webサービスのつもりで。URLを指定して呼び出すと、ページタイトル取得する、っていう意味不明なWebサービスがありました。として。

wcftest1

WcfTestClientで実行してみて動くのを確認。大丈夫だね。

これを呼び出すMvcアプリケーションも用意。HomeControllerしかいないよ。

private string Logic(string value)
{
  return new string(value.Reverse().ToArray()).ToUpper();
}

public ActionResult Index(string url)
{
  ViewBag.Message = "URLを入れるとページのタイトルを取得するよ";
  if(!string.IsNullOrWhiteSpace(url))
  {
      var service = new ApiService.WebScraperClient();
      var title = service.ExtractPageTitle(url);
      ViewBag.Message = Logic(title);
  }

  return View();
}

Indexアクションでは、サービス参照の追加で作成したサービスクライアントクラスを実行してページタイトル取得した後に、反転して大文字にするロジックが含まれてるつもりです。

wcftest2 wcftest3

はいはい。雰囲気出てますね。

このIndexアクションのテストを書いてみました。

[TestMethod]
public void Index_マイクロソフト()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.Index("http://www.microsoft.com") as ViewResult;

  // Assert
  Assert.AreEqual("NOITAROPROC TFOSORCIM", result.ViewBag.Message);
}

[TestMethod]
public void Index_いろいろ()
{
  // Arrange
  var list = new[] { "http://www.google.com", "http://www.twitter.com", "http://takepara.blogspot.jp" };

  // Act
  var result = list.Select(url => new HomeController().Index(url) as ViewResult).ToArray();

  // Assert
  Assert.AreEqual("ELGOOG", result[0].ViewBag.Message);
  Assert.AreEqual("RETTIWT", result[1].ViewBag.Message);
  Assert.AreEqual("!だらかれこはみし楽お", result[2].ViewBag.Message);
}

これといって変なこともなく。HomeControllerのインスタンス作ってUrlを引数にIndexを呼び出したら、ページタイトルが反転して大文字になってるかどうかを確認してるだけです。

外部サービスがですね、ホントに社外のもので、そこのロジックがどういうものかは知らないっていうことになると、テストしたい部分ってどのになりますかね。通信経路ですかね?それとも、外部サービスのロジックですかね?ユニットテストでまわしたいとすると、そこってテストしたい部分じゃないと思います。自分書いたコードじゃないし、環境だし。

なので、ユニットテストしたいのは反転して大文字にするロジックですよね。たぶん。とはいえ、すでに存在するコードのテストをしたい、っていう場合もあるでしょう。みんなTDDなわけでもなく。そんな時はMVCを使って簡単なFakeサーバー。HTTP(S)だしー。なんでもいいでしょー。

実際の通信内容を保持しといて、リクエストのたびにその値を返せば開発環境で完結したテストが実行できて便利。何パターンか用意しておけばいいしね。その辺はテストしたい対象に合わせて良しなに用意しましょう。

今回は、上記テストがパスするように用意します。エラーのパターンがないのはご愛嬌。

public class FakeController : Controller
{
  private static string RequestBody(HttpContextBase context)
  {
      context.Request.InputStream.Position = 0;
      var reader = new StreamReader(context.Request.InputStream);
      return HttpUtility.UrlDecode(reader.ReadToEnd(), Encoding.UTF8);
  }

  private Dictionary<Func<HttpContextBase, bool>, Func<ActionResult>> _responseHandlers =
      new Dictionary<Func<HttpContextBase, bool>, Func<ActionResult>>
        {
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.microsoft.com"),
            ()=>new FilePathResult("~/App_Data/www.microsoft.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.google.com"),
            ()=>new FilePathResult("~/App_Data/www.google.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.twitter.com"),
            ()=>new FilePathResult("~/App_Data/www.twitter.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("takepara.blogspot.jp"),
            ()=>new FilePathResult("~/App_Data/takepara.blogspot.jp.txt", "text/xml; charset=utf-8")
          },
        };

    
  public ActionResult Api()
  {
    var handler = _responseHandlers.Where(_ => _.Key(HttpContext)).Select(_ => _.Value).FirstOrDefault();
    return handler !=null ? 
            handler() : 
            new HttpStatusCodeResult(500);
  }
}

リクエストに合わせて結果を返すだけなんですけどね。ローカルにファイルを作成するために、一度Fiddlerで一通り動かして、データを取得しておきましょう!今回の場合だとWebサービスをIISで動かすと都合がよかったです。Fiddlerの都合で。

<endpoint address="http://localhost/HttpApiServer.Real/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

こんな感じのendpointだったものを以下のようにFiddler通すように変更。

<endpoint address="http://ipv4.fiddler/HttpApiServer.Real/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

wcftest4

そうすると、www.microsoft.comへを指定した、API呼出しのRequestとResponseは画像のような結果になります。ここから、ResponseのBody部だけ抜き出して、ファイルに保存しておき、それをリクエストに合わせてレスポンスするのがFakeサーバーの役目です。

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><ExtractPageTitleResponse xmlns="http://tempuri.org/"><ExtractPageTitleResult>Microsoft Corporation</ExtractPageTitleResult></ExtractPageTitleResponse></s:Body></s:Envelope>

↑たとえばこれをwww.microsoft.com.txtっていうファイルにしておきます。どんなパスを指定してもFakeControllerのApiアクションにリクエストが届くようにルーティング登録とWeb。configで.svcの拡張子を外しておきましょう。.svc外しとかないとSystem.ServiceModel.Activationが横取りするのでApiアクションにリクエストが届かず、404エラーになっちゃいます。

public class MvcApplication : System.Web.HttpApplication
{
  public static void RegisterGlobalFilters(GlobalFilterCollection filters)
  {
      filters.Add(new HandleErrorAttribute());
  }

  public static void RegisterRoutes(RouteCollection routes)
  {
      routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

      routes.MapRoute(
          "FakeServer", // Route name
          "{*path}", // URL with parameters
          new { controller = "Fake", action = "Api" } // Parameter defaults
      );

  }

  protected void Application_Start()
  {
      AreaRegistration.RegisterAllAreas();

      RegisterGlobalFilters(GlobalFilters.Filters);
      RegisterRoutes(RouteTable.Routes);
  }
}

↑Global.asaxね。MapRouteだけいじってます。

<system.web>
  <compilation debug="true" targetFramework="4.0">
    <buildProviders>
      <remove extension=".svc"/>
    </buildProviders>
    <assemblies>
    :
    </assemblies>
  </compilation>

  <pages>
    <namespaces>
    :
    </namespaces>
  </pages>
</system.web>

↑web.configね。buildProvidersで.svcを削除してます。一通りファイルを用意して、テストのendpointをこのFakeサーバーに向けて実行します。

<endpoint address="http://localhost/HttpApiServer.Fake/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

ここまでが既に作成したものに対して、テスト実行したいときにとる手法で、WebサービスのFakeパターンですが、ユニットテストっていう意味ならテスト対象のIndexアクションが本当に通信するコードを呼び出さないほうが都合がいいと思います。

なので、これから作成するなら、Webサービスへのアクセスを抽象化し、ユニットテストで差し替えるようにすると、本来テストしたい部分に特化したテストができていいと思います。例えば以下のようにControllerのコードを変えてみます。

public interface IApiAdapter
{
  string Execute(string url);
}

public class HttpApiAdapter: IApiAdapter
{
  public string Execute(string url)
  {
    var service = new ApiService.WebScraperClient();
    return service.ExtractPageTitle(url);
  }
}

public class HomeController : Controller
{
  public static Func<IApiAdapter> ApiAdapterFactory = () => new HttpApiAdapter();

  private string Logic(string value)
  {
      return new string(value.Reverse().ToArray()).ToUpper();
  }

  public ActionResult Index(string url)
  {
    ViewBag.Message = "URLを入れるとページのタイトルを取得するよ";
    if (!string.IsNullOrWhiteSpace(url))
    {
      var adapter = ApiAdapterFactory();
      var title = adapter.Execute(url);
      ViewBag.Message = Logic(title);
    }

    return View("Index");
  }
}

IApiAdapterと、その実装であるHttpApiAdapterを用意して、インスタンスはファクトリメソッドから実行するように書き換えました。

テストのほうも同じく書き換えます。まずはFakeApiAdapterの追加。

 

public class FakeApiAdapter : IApiAdapter
{
  public string Execute(string url)
  {
    var list = new Dictionary<string,string> 
    { {"http://www.google.com","Google"}, 
      {"http://www.twitter.com","Twitter"}, 
      {"http://takepara.blogspot.jp","お楽しみはこれからだ!"},  };

    return list[url];
  }
}

あとはTestInitializerでファクトリメソッドのFuncデリゲート書き換え。

[TestInitialize]
public void Initialize()
{
  HomeController.ApiAdapterFactory = () => new FakeApiAdapter();
}

以上!

これで実際のプログラムにアクセスするときは、ClientBaseのWCFクライアントクラスから実Webサービス呼出しをするし、ユニットテストからは呼んだつもりで偽アダプター(Stub)が結果を返します。

wcftest5

準備するの面倒かもしれないけど、この辺もちゃんとテストしましょー。なるべく環境依存しなくて済むようにね。

2012年2月18日土曜日

ARRでの動的コンテンツのキャッシュ制御

ARR、頑張ってくれてます。立ち上げ当初は設定ミスなんかも重なっててんやわんやなこともあったけど、今となっては素晴らしいパフォーマンスを発揮してくれてます。

ARRを使うメリットとして、コネクションプーリングによるリクエストの制御の他に、SSLオフロードがかなり効果が出てます。証明書の管理も楽になるし。

ただキャッシュに関してはちょっと問題があって使ってなかったんです。基本的にVaryByCustomを使ったキャッシュ制御をしてるんだけど、どうもVaryByCustomが効いてない時があるみたい、っていう報告があって。

なので、原因がわかるまで、ARRでのキャッシュを泣く泣くオフ(もったいない!)にして、運用。

でも、やっと原因らしきものがわかった。

通常、出力キャッシュを利用する場合、自分でレスポンスヘッダを指定するなりResponseオブジェクトに指定するなり、MVCならOutputCache属性を使いましょう。ASP.NETならVaryByCustomを指定することで、自分でキャッシュ単位を細かく制御出来るようになるので、その機能を使えばPCサイトにケータイでアクセスしてきたらケータイサイトにリダイレクトしたい!なんてときに有効です(普通にキャッシュするとケータイにPCサイトが出ちゃうもんね)。

なので、↓こんな感じのコードを書いてザックリとUA判定をするようにします。

public static string GetDeviceName(HttpContextBase context)
{
	var ua = context.Request.UserAgent;
	if (string.IsNullOrWhiteSpace(ua))
		return "Unknown";

	var type = context.Request.Browser.Browser;
	if (!string.IsNullOrWhiteSpace(type) && type != "Unknown")
		return type;

	return ua.Split('/').First();
}

public override string GetVaryByCustomString(HttpContext context, string custom)
{
	if(custom != "device")
		return base.GetVaryByCustomString(context, custom);
	
	return GetDeviceName(new HttpContextWrapper(context));
}

UserAgentを見てればだいたいOKです。こうするとPCブラウザや、ケータイキャリア毎にそれっぽくキャッシュ制御できますよね。auはうるさいことになるけど。やりたい事はPCとモバイルでの判定だから、コレでほとんどうまくいきます。UAをSplict(‘/).First()だけでも大丈夫。

これを仕込んでるのと仕込んでないのとでの挙動の違いを分かりやすくテストしてみたら↓こんな感じに。Durationは10秒。

Viewの定義

@using ArrCacheTest
@{
    ViewBag.Title = "Home Page";
}

<h3>Browser Type : @MvcApplication.GetDeviceName(ViewContext.HttpContext)</h3>
<h4>User Agent : @Request.UserAgent</h4>

<div>Server Now = @DateTime.Now</div>
<div>Client Now = <span id="client_now"></span></div>

<script type="text/javascript">
    $(function (){$("#client_now").text(new Date().toString())})
</script>
arr1

すべて同じBrowser扱いだし、Server Nowが同じ。だって、キャッシュしてるHTMLが同じだから。

今度はVaryByCustomをOnにします。

arr2

ちゃんとブラウザ毎の判定になってるし、10秒以内のアクセスにもかかわらず、違うキャッシュをそれぞれのブラウザで利用。

arr3arr4

拡大しないと見えないけど、Server時間が同じなのにJSでしてるClient時間はちゃんとずれてるので、キャッシュを利用してるのがわかります。

これをARR配下に入れると、どーなるか。

ARRの設定として、ARRでリクエストを受けるサイトにポート80をバインドして、ノードサーバー用のサイトにポート8080をバインドするようにして、同一マシン内に構成。Application Request Routing Cacheを有効にして、ディスクにキャッシュもするようにしてます。

arr5

あららー。IEがFirefoxと同じキャッシュを見ちゃってます。これ、IEでアクセスする直前にChromeでアクセスするとChromeのキャッシュが出る。要するに直前のアクセスで生成されたキャッシュを利用しちゃってる。VaryByCustomどこ行った。

Chrome/Firefoxは問題なくて、ちゃんと自分用のキャッシュだけを利用。

不思議!摩訶不思議!

何がおきてこうなってるのかサッパリわからない状況だったので、ちょこちょこ設定を変えながら様子見。

最初に考えたのが、ディスクにHTMLがキャッシュされてて、それが返されてしまうんじゃないか説。でも、ディスクにはそんなものなかった。

次にIEだけ不思議とHTTP.sysのキャッシュを横取りして見ちゃうんじゃないか説。ローカルマシンでしか発生しないんじゃない?と疑って、IEだけVMからアクセスさせてみたけどそんなことなかった。

次。レスポンスヘッダのCache-Controlを見て、ARRがディスク以外のどこか(メモリしか無いけど)にキャッシュをしてしまうんじゃないか説。もしコレだとかなり厳しい。どうやってARRにHTMLだけスルーさせるのがいいか。悩んだ挙句、URL RewriteのoutboundRulesを思いついた。

<outboundRules>
    <!-- This rule changes the domain in the HTTP location header for redirection responses -->
    <rule name="CacheControl">
        <match serverVariable="RESPONSE_CACHE_CONTROL" pattern=".*" />
        <conditions>
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
        </conditions>
        <action type="Rewrite" value="no-cache"/>
    </rule>
    <rule name="Expires">
        <match serverVariable="RESPONSE_EXPIRES" pattern=".*" />
        <conditions>
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
        </conditions>
        <action type="Rewrite" value=""/>
    </rule>
    <rule name="LastModified">
        <match serverVariable="RESPONSE_LAST_MODIFIED" pattern=".*" />
        <conditions>
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="^text/html" />
        </conditions>
        <action type="Rewrite" value=""/>
    </rule>
</outboundRules>            

こんなルールを用意して、レスポンスヘッダの書き換え。ちなみにコレどこに書くのかスゴイ悩んだ。applicationHost.configに書いてもまるで効かないんだよ。ARR CacheのCache Control Rulesがどこに反映されるのか、探しに探した結果、inetpub\temp\appPoolsっていうフォルダにサイトごとのconfigを自動生成して、そっちに書きこんでた。すごー。何この仕様。ビックリよ。

なので、ARRサイト(転送先のノードサイトに書いちゃうとまるでキャッシュしなくなるの意味がない)のconfigに上記ルールを追記。

追記する前のレスポンスが↓これ。

arr6

Cache-Controlにpublic, max-age=10が入っててExpiresとLast-Modifiedにそれぞれ日付が入ってますね。

追記した後のレスポンスが↓これ。

arr7

Cache-Controlがno-cacheになって、ExpiresとLast-Modifiedがなくなりました。素晴らしい!これで再度3ブラウザでアクセスしてみる。

残念!まるで効果なし!

もう、そういう仕様だと諦めたくなったけど、リクエストヘッダに違うところがあるんじゃないかと確認。

arr8

IEだけCache-Controlがno-cache。他はmax-age=0。これかなー。コレによってサーバーの挙動が変わるのかー?

と、Fiddlerで確認しようと思ったら、コンテンツ圧縮してたから内容確認できない。ショーがないないーと思って、ARRサイト/ノードサイトそれぞれの動的コンテンツ圧縮をオフにしてみた。

ら!なんと!Fiddlerで確認するまでもなくちゃんと動き出した!outboundRulesも不要。

arr9

正確にはノードサイトだけ、動的コンテンツ圧縮をオフ。ARRサイトはそのまま圧縮オン設定。

ちゃんとVaryByCustom効いてる。ナンテコッタイ。ノードから圧縮したコンテンツをARRのリバースプロキシがどっかでキャッシュしちゃってるくさい。んで、それを返しちゃってるくさい。リクエストヘッダがCache-Control:no-cacheだと。Pragma:no-cacheの時も。なんでそうなってるのかの理由はよくわからない。仕様?

ノードの圧縮をオフにして、ARRの圧縮をオンにしておけば、外向けのトラフィックは少なくなるし、ノードのCPU負荷は下がるから、これが正しい設定だよねー。

これで、やっとキャッシュも有効にして、内部トラフィックを減らしつつ、ARRで更に効率良くリクエストを捌けるようになるね!

2011年12月31日土曜日

URLRewrite+CloudFrontでパフォーマンスを取り戻す

年末ですね。年末だからこそ大量のアクセスが発生することもありますね。コンシューマー向けのものだと、平時に比べれば多かったりしますよね。爆発的なアクセスになることもありましょう。おめーさん、容赦無いね!

特にHTTPS。コレはもうホントしんどいですよね。SSLアクセラレータとかSSLオフロードっていうか、そういう前段でさばくように構成して、実処理はHTTPのみにしとかないとHTTPSの処理に尋常じゃないCPU使用率を持っていかれたりしちゃいましょう。そーなると、本来の処理にCPUが割り当てられない。SSLハンドシェーク。悪魔のようです。でも、そんな構成すぐに取れない!そんな時の選択肢としてCDN。え?なんで?だってアクセスの総量が減ればその分処理量も減じゃない!コードチューニングより効果が高いこともあります。パフォーマンス20%アップのコードチューニングより200%アップ(することもある)のCDN。

お手軽なのはAmazon CloudFront。

  • カスタムオリジンで既存サーバーをそのまま利用することで、オリジンサーバーにコンテンツを事前にアップロードしなくてもいい
  • URLRewrite2.0から導入されてるOutboundRulesでのHTML書き換えで、CDNを指すようにしてしまうことで、レンダリングコードに(ほとんど)手を加えずにCDNが使えるようになる
  • CNAMEでカスタムドメインでもそれなりに(HTTPSのカスタムドメインはダメだけど)
  • コンテンツへのHTTPSアクセス負荷をCloudFrontに肩代わりさせれる(コレ!!)
  • 使ったぶんだけ課金は、いまさらですけどやっぱり素敵ポイント

もちろんIISね。Apacheもなんか書き換えありましたよね。mod_ext_filter?IIS以外はよく知らないです。

ココはひとつ、mvcPhotos(覚えてますか?)に実験台として登場してもらいましょう。

cf2

http://mvcphotos.takepara.com

まだ見れる状態になったままでした。そろそろ閉鎖しないと...。

http://mvcphotos.codeplex.com/

CloudFrontの登録とか、そういうのはいろんな人が書いてるので、その辺は省略。

cf3

こんな感じですね。コレといって何のへんてつもない設定。カスタムオリジンとProtocol PolicyをHttp Only、CNAMEでカスタムドメイン(HTTPSの時は割り当てられたドメインをそのまま使う)。

あとは、Web.configにoutboundRulesを追加(ExpressWebでURLRewrite使えると書かれてるけど、バージョンが見当たらなかったから不安だったけど、ちゃんと2.0以降が入ってる模様)。

    <rewrite>
      <outboundRules>
        <rule name="CloudFrontContents" preCondition="html" enabled="true">
          <match filterByTags="A,Img" pattern="^/Photos/Image/(.*)"/>
          <action type="Rewrite" value="//cdn.mvcphotos.takepara.com/Photos/Image/{R:1}"/>
        </rule>
        <preConditions>
          <preCondition name="html">
            <add input="{RESPONSE_CONTENT_TYPE}" pattern="text/html"/>
            <add input="{REQUEST_URI}" pattern="/mobile" negate="true"/>
          </preCondition>
        </preConditions>
      </outboundRules>
    </rewrite>

これだけ。これで、/mobile以外の時に/Photos/Imageへのアクセス(a hrefとimg src)すべてCDNへ変更します(詳しくはCreating Outbound Rules for URL Rewrite Module : URL Rewrite Module 2 : URL Rewrite Module : The Official Microsoft IIS Site )。/mobileを除外してるのには理由があります。mvcPhotosはHTTPSでのアクセスが無いので意味ないんですが、実際は有りましょう。その際、ケータイからのアクセスだとCloudFrontで使ってるワイルドカード証明書が残念なコトになります。まだまだ正常に処理できないですよね。

cf

なので、もともと素材の少ないケータイアクセスの場合(/mobile配下へのアクセス)は、CDNを利用しないようにして、コレまで通りのアクセスにしときます。PCやスマフォでのHTTPアクセスなら処理が負担にならない、って言う場合はconditionsを以下のように追加してHTTPSの時だけCDN参照するようにするのがいいでしょう。

        <rule name="CloudFrontContents" preCondition="html" enabled="true">
          <match filterByTags="A,Img" pattern="^/Photos/Image/(.*)"/>
          <action type="Rewrite" value="//cdn.mvcphotos.takepara.com/Photos/Image/{R:1}"/>
          <conditions>
            <add input="{HTTPS}" pattern="^on$"/>
          </conditions>
        </rule>

ただ今回、mvcPhotosのちょっと残念だったところと、Cloud Frontの制限が丁度マッチして、ちょびっとだけViewとスクリプトの変更がありました。

画像を返す部分をPhotosControllerにやらせてるんですが、そのパラメータをRoutingじゃなくてQueryStringで渡してたんです。が、Cloud FrontはQueryString無視します。なので、そこだけ変更してQueryStringでのサイズと変換方法の指定をRoutingパラメータにしました。

/Photos/Image/1?size=100&type=fit

↑こうだったものを↓こう。

/Photos/Image/1/100/fit

そのためにRoute登録。

    routes.MapRoute(
        "PhotoImage",
        "Photos/Image/{id}/{size}/{type}",
        new {controller = "Photos", action = "Image"});

コードいじってるじゃん!さーせん。でも、普通はいじる必要ないはず。あっても、ViewやScriptだけで済むはずです。サービスとして公開するならURLの設計もちゃんとするはずなので。今回の微調整もRouting以外はViewだけ。View内にknockoutで使うHtml Template埋め込んでるし、パス関連は全てそのテンプレートに展開してるから、OutboundRulesの対象になる。

これで、mvcPhotosにアクセスしてみましょう。

cf4 cf7

クリックして拡大すると見えると思いますが、/Photos/Image配下はCDNへ。その他の要求(JavaScript/CSS/Ajax)は自サーバーに行ってますね。もちろんホントはJavaScriptやCSSも持っていくのがいいでしょう。

cf5 cf6

↑こちらは/mobile配下。今度は同じ/Photos/Image配下のものもCDNに行かず、自サーバー参照のままです。

なんてお手軽。

実際HTTPSを利用してるサイトでBLOBコンテンツのダウンロードが大量に発生している場合、この方法でサーバーへのコネクションを1/10とかに抑えられることになって、ウハウハ。もちろんお金はかかりますけど。それでもサーバー増強やSSLアクセラレーションする機器の購入に比べれば、安いし経費で落とせます!HTTPSでのカスタムドメインが使えないのは気に入らない、って言うことがあるなら他の方法をとってくださいってことになります(ARRでSSLオフロードとかね、お金貰えればなんでもいいでしょう)。

とりあえず、今回のような方法でCDNを利用する時に、こういうふうにページ(View部)作るといいかもと、思ったことを書きだしておきます。

  • 素材を意味ごとに決まったフォルダに分けておく
    → パターンを増やすとその分の処理にCPU使っちゃう
  • スクリプトでスクリプトをロードしないほうがいいかも
    → スクリプト内のパスを書き換えるのが面倒
  • スクリプト内に極力パスを書かない
    → HTMLの属性を使う。DOMにデータを持たせとく。か、クライアントサイドのHTML Templateで。
  • 同じくCSSの中でimportしないほうがいいかも
    → 相対パスなら許容可能
  • HTML内のsrc/hrefはルートからの絶対パスで書いておく
    → 相対パスだとパターンがぶれるし、思わぬ参照先に。どうしても相対ならCSSにする
  • HTML内でスタイル指定しない
    → パスの書き換え問題。同じ理由でタグ要素にstyle属性も困る。

トリッキーなことしないで、メンテナンス性を考慮したページの作り方をしてれば、CDNへの振り向けはすんなり行きやすいという感じですね。

目指せ Ultra-Fast ASP.NET!

2011年11月20日日曜日

MongoDBにASP.NETのSessionを格納する - 完結編

前回のあらすじ

不思議な光る石のペンダントをつけた少女が空から降りてきた。天使が舞い降りたと思い走って近づく少年。その少女は伝説となった天空の城の王の末裔。少年もまた亡き父の言葉を信じ、天空の城の存在を信じていた。悪意を持った別の王の末裔が少女をさらい、城を悪用しようとすることから、少年は少女と城を守るため、海賊とともに空に旅立った。

ドキドキするストーリーですね!

...。

続きです。最終的に接続エラーが出て、処理が継続できない状態に陥るっていうところでした。問題解決する前に、現時点でのパフォーマンスを確認してみます。

前回同様Apache Benchで実行しますが、10000回だとエラーになってしまって計測に失敗してしまうので、桁を下げて1000回(-n 1000 –c 8)としてましょう。

計測方法はabを3回実行してRequest per secondの平均を取ります。

InProc

ms1

1回目:538.39
2回目:588.10
3回目:660.41

平均:595.63

StateServer

ms11

1回目:525.43
2回目:567.28
3回目:599.09

平均:563.93

SQLServer

ms2

1回目:282.39
2回目:370.53
3回目:352.21

平均:335.04

MongoDB

ms3

1回目:129.71
2回目:146.35
3回目:145.03

平均:140.36

うん。早くない。ちなみに無印UniversalProviderだと 30.15 とドンマイな感じ。

Universal Provider

ms4

ちなみに、5回で平均を取りたかったけど、MongoDB版が5回目で接続エラーになって取れなかったので3回にしました。これは良くない!まずはちゃんと10000回普通に動かせるようにしないと。

と、いうわけで、MongoDB C# Official Driverのドキュメントに目を通してみると、内部でConnection Poolingを利用していると書かれています。が、挙動からはPoolがちゃんと利用されてないんじゃないかと、疑ってみました。 だって、接続エラーって...。使いまわしてるならそんなことにならないはず、という根拠のない診断。うさんくさいドクターハウス。

CSharp Driver Tutorial – MongoDB

MongoDB C# Driver API Documentation - Table of Content

詳細はドキュメントから判断出来なかったので、ソースを追いかけてみました。オープンソースだといろいろ調べれていいですねー。

mongodb/mongo-csharp-driver – GitHub

チラチラ見た結果からいうと、MongoServerクラスに保持しているMongoServerInstanceがConnectionPoolを管理していて、MongoServer.Disconnectを呼ぶとすべてのPoolをクローズして回る仕組みのようです。なんでだろ。理由はいいとして、そういう作りなんだということは、finallyで必ずDisconnectを呼び出すのはよろしくないかなー。そんな事しなくても、Poolに保持しているものは10秒ごとにMaxConnectionLifeTime/MaxConnectionIdleTimeを確認して破棄されるようになってるみたいなので、ほっといてもいいんじゃね?みたいな。

と、いうわけで、ソース中のすべてのDisconnectを削除します。

その状態で、abで確認します。

3回実行した結果は525.43 / 599.09 / 572.34。平均:565.62。ワォ!一気に上がりました。せっかくなので10000回出来るかどうかもチェック。

ms5

行けるようになりましたー。ちゃんと接続を使い回してくれてるようです。パフォーマンスも接続を使いまわすことで一気に改善しました。んじゃ、なんで、そういう作りにしてなかったのか?っていうのが気になります。

理由はありました。どういうことかというと、Poolが保持しているMongoConnectionは生成された時点でReplicaSetのPrimaryを判定して保持しています。つまり、Failoverした直後、Poolが破棄されない限り接続エラーがおきて、正しくデータの書き込みができなくなるということでしょう。

abを実行し、その最中にstepDownでFailoverしてみます。

ms6ms7

Safemode detected an error 'not master'. (Response was { "err" : "not master", "code" : 10056, "n" : 0, "lastOp" : NumberLong("5676991341247070789"), "connectionId" : 13356, "ok" : 1.0 }).

masterじゃないダニ!と言われ、書き込みできないエラーが発生しました。SafeModeじゃなくても同じようにエラーになることは確認済み。

と、得意げにドヤ顔したところで、それはつまりDisconnectしなくなったからでしょ?と、おっしゃるとおりな理由でのエラーなんですけどね。つまり、Failoverした後もちゃんと接続が継続できるようにするために毎回Disconnectしてたわけですね。

あちゃー、これはどうしたものか。なんて、あたかも今、解決策を思いつくような振りになってますけど、事前に調査してわかってることを書いてるだけなんで...。てへ!

つまり今回のエラーの原因はPoolから取り出したMongoConnectionがPrimary(Master)を指してないから例外が起きるわけです。そして、MongoServer.Disconnectを呼ぶことでPoolのすべてのコネクションが破棄されるというのはソース見て確認済み。つまりMongoDBに対する処理で例外が発生したらMongoServer.Disconnectを呼び出し、処理をリトライすることで、Failover後に決定したNodeに接続するようにしてあげればよい、という方法が取れましょう。MaxConnectionLifeTimeを短くしてみては?っていうのもあるでしょうが短すぎると接続エラーになりそうだし。

で、用意したのが以下のようなリトライ実行するヘルパー。

public class MongoDbInvokeSetting
{
  public string ServerName { get; set; }
  public string DatabaseName { get; set; }
  public int RetryCount { get; set; }
  public int RetrySeconds { get; set; }
}

public class MongoDbHelper
{
  /// <summary>
  /// リトライを繰り返すInvoker
  /// </summary>
  /// <param name="setting"></param>
  /// <param name="functor"></param>
  public static void RetryInvoker(MongoDbInvokeSetting setting, Action<MongoServer> functor)
  {
    var retry = setting.RetryCount;
    Exception exception = null;
    while (retry > 0)
    {
      var server = MongoServer.Create(setting.ServerName);
      try
      {
        functor(server);
        break;
      }
      catch (MongoException ex)
      {
        exception = ex;
        // ↓こいつでPool内の全コネクションクリアが走るはず。
        server.Disconnect();
      }
      retry--;

      // とりあえずn秒待ってみる。
      Thread.Sleep(setting.RetrySeconds * 1000);
    }

    // ここでイベントログに書き出すか、Exceptionを出力する必要がある。
    // 後続の処理に進んだら困るならException。
    // ログなら進んでもいいかもねー。
  }
}

ダサくない!ダサくないよ!これでいいんだもん!

このコードを書いてる途中、さすがに心配になったんですけど(これでいいのかっていうね)、そんなおり以下のページを見つけました。

File: REPLICA_SETS — MongoRuby-1.4.1

似たようなリトライさせてますね。だもんで、いいってことで。

これを使って実行する書き方は↓こう。

MongoDbHelper.RetryInvoker(GetSetting(), server =>
{
  // serverを使ってCollection検索したり、
  // データ追加したり...
});

GetSettingは設定を入れたものを返しましょう。サーバー名とかリトライ回数とか。

これつかって丸っとしたもので、実行しつつFailoverさせてみます(abでエラーが出ないことを確認しました)。

ms8

大成功!エラーも起きずFailover成功しました。データも欠落なくいけてます。

で、この状態でのパフォーマスンスを測定してみます。

3回実行したのが485.62 / 496.92 / 552.61 平均:511.72

遅くなったじゃん!っていう感じしますけど、あったまって無かったみたい。実質Disconnect削除のパフォーマンスと同じ数値でした。

でも~、これだと~、なんていうかパンチが弱い。十分いいとは思うんだけど~。どこか早くできそうなところないかなー、とMongoSessionStateStoreのソースを見ているとありましたねー。

private string Serialize(SessionStateItemCollection items)
{
  using (MemoryStream ms = new MemoryStream())
  using (BinaryWriter writer = new BinaryWriter(ms))
  {
    ...
    return Convert.ToBase64String(ms.ToArray());
  }
}

private SessionStateStoreData Deserialize(HttpContext context,
string serializedItems, int timeout)
{
  using (MemoryStream ms =
    new MemoryStream(Convert.FromBase64String(serializedItems)))
  {
    ...
  }
}

ほら、ここ。Base64に変換してstringにしてるじゃないですか。コレいらなくない?BsonBinaryArray使えばbyte[]をそのまま入れることが出来るんじゃないのー?と、気になったので試した結果が↓こちら。

3回実行したのが 542.95 / 538.68 / 534.19、 平均:538.61。微妙...。まぁ、いいか。

ms9

ちなみにこのままだと、Expireしたセッション削除が残り続けるので、SetAndReleaseItemExclusiveの中で2000回に一回くらいクリーンアップするように仕込んでみました。その状態で10000回実行。

ms10

10000回中5回クリーンアップしてるけど、まぁまぁ。外部タスクにしてしまえば影響はでないものなので、ここはいいでしょう。

ち・な・み・に、SQLServerだと平均335.04ですからね。いいじゃないですか。ねぇ。Shardingしてみても同じマシンだとあまり変化でなかったです。と、いうのもCPUを最も使ってるのがIISExpressのプロセスだったので、アプリケーションが遅いってことですから。あとは、マシン分けて計測しかないですが、少なくとも同一環境内ではMongoDBでのSessionStateStoreがStateServerに負けないくらいの速度を実現しました(何度か試したらStateServerはもっと早かったけどー、そこは情報操作!)。

2011年11月19日土曜日

MongoDBにASP.NETのSessionを格納する

ASP.NETのSessionといえば

  • InProc(アプリけーションプロセス内のインメモリステート管理)
    シリアライズコストも発生せず、同一プロセス内で言わばstatic Dictionary<string,object>の実装で最速。ただし、アプリけーションのリサイクルと同時にセッションが破棄されるのと複数プロセス間で共有できないです。
  • StateServer(専用プロセスによるインメモリステート管理)
    InProcの弱点である、アプリけーションのリサイクルによる破棄とプロセス間共有を実現出来るようにしたもの。プロセスをまたぐのでシリアライズコスト発生するけど、インメモリなのでInProcにつぐ速度を実現。ただし、シングルプロセスでの実装になるので、耐障害性という意味では弱い。ステートをホストしているマシンの障害や再起動などでセッションが破棄されてしまいます。
  • SQLServer(ストーレジに保持するステート管理)
    StateServerの弱点であり、耐障害性をクリアしたストレージ保存型でのセッション管理。シリアライズコスト+DBサーバーのパフォーマンス次第で遅くもなるし早くもなる。
  • Custom(SessionStateStoreProviderBaseを派生したカスタム実装)
    キャッシュクラスタ(Velocity)を利用したり、EntityFrameworkを利用した実装(UniversalProvider)を使ったりSessionStateStoreに対してイロイロです。DBをSQLServer以外にする場合にも利用されてます。

選択肢多くていいですね。標準のものもそれぞれ優秀で素晴らしいです。SessionIDの生成や、クライアントとのやり取りは別の仕組みで実装されてるので、SessionStateStoreProviderのカスタムとは分離されてて安心です。

MongoDB ASP.NET Session State Store Provider | AdaTheDev

最近、すっかりMongoDBに心奪われてる身としては、SessionをMongoDBに入れてしまいたいという衝動にかられてます。あれやこれやの問題もあり、SQLServerだけにたよるソリューションだとよろしくないなというのもありまして。

MongoDB、大変優秀ですね。ReplicaSet(レプリケーション)による可用性の確保(自動フェールオーバー有り)と、Sharding(データのパーティショニング)によるパフォーマンスの確保、素晴らしいです。

いろいろ試して部分的に利用を始めてる段階で、まだまだ自信をもって使いまくるって言う状況ではないですが、ちょっとずつです。ちょっとずつ使っていってSQLServer+MongoDBで行けるところまで行ってみようと企んでます。

まずはTraceListnerのMongoDB化。前回のエントリでそれっぽい感じのものを提示しましたが、これでサーバー群のトレース情報を低コストなストレージに保持出来るようになりますね。トレース情報くらいなら万が一情報欠落しても致命的になることも無いでしょう。しかし!セッションはそういうわけにはいきませんね。システムとして提供している表の機能に影響がでちゃいますからね。

そうなると、ReplicaSetの機能を利用することは必須となりましょう。そうするとFailoverの時の挙動を確認したりする必要も出てきますよね。そういうテストを繰り返してこそですよね。楽しい時間ですね。

先ずはReplicaSetでMongoDBを2つ起動。ArbitarっていうSQLServerでいうところのウィットネスサーバーを1台追加してないと投票結果が偶数とかになって次のPrimary決定時に困っちゃうことがあるので、3ノード起動します(詳しくはオフィシャルサイトでどーぞ)。

rem arbitar
start "arbitar" c:\mongo\bin\mongod.exe --port 27020 --replSet mongo --dbpath c:\mongo\dba

rem replicaset
start "mongo1" c:\mongo\bin\mongod.exe --rest --noauth --port 27031 --replSet mongo --oplogSize 20 --dbpath c:\mongo\dbr1
start "mongo2" c:\mongo\bin\mongod.exe --rest --noauth --port 27032 --replSet mongo --oplogSize 20 --dbpath c:\mongo\dbr2

これで起動してるので(c:\mongoに一式ある前提です)、まずは初期化。

27031のノードにMongoDB Shellで接続。

mongo localhost:27031

初期化コマンド順番に実行すればOKです。

rs.initiate()
rs.add("hostname:27031")
rs.add("hostname:27032")
rs.addArb("hostname:27020")

ms

まずはこの状態で公開されてるソースを利用してSessionを使ってMongoDBにデータを入れてみましょう。

MVC4DPでアプリケーションを作り、Home/IndexアクションでSessionに値をいれて、Home/Indexビューで表示するだけのものです。

public ActionResult Index()
{
  ViewBag.Message = "Modify this template to kick-start your ASP.NET MVC application.";
  Session["message"] = ViewBag.Message;

  return View();
}
<h2>from session:@Session["message"]</h2>

MongoSessionStateStore/MongoSessionStateStore.cs at master from AdaTheDev/MongoDB-ASP.NET-Session-State-Store - GitHub

↑ここからソースを取得して、プロジェクトに追加。後はNuGetでMongoDB Official Driverを入れておきましょう。

ms2

コメントに従い、接続文字列を指定するとレプリカセットにならないので、今回起動したMongoDBを指すように接続文字列を変更。

connectionString="mongodb://localhost:27031,localhost:27032/"

ms3

ちゃんと出来てますね。

MongoVUEで27031(primary)データを確認。

ms4

入ってるねー。

続いて27032(Secondary)のデータを確認。

ms5

もちろん入ってますね。

ここで、ReplicaSetを入れ替えましょう!

MongoDB ShellでPrimaryにつないで rs.stepDown() で強制フェールオーバーを実行。

ms6

ちゃんと切り替わりました。

この状態で再度先ほどの起動したブラウザでF5でリロード。ちゃんと動くなら、これでFailoverしたほうを参照して、表示されるはず!

ms7

ms11

タターン!うまくいきました。内部ではSafeModeっていう書き込み確認モードで動作するようになってるので、手堅いです。

27032(SecondaryからPrimaryに変更したノード)でSessionsコレクション(テーブルですね)を確認して見つつブラウザのF5リロードを繰り返すと、ちゃんとLockIdがカウントアップしていくので、読み込みも書き込みもFailover後にちゃんとできてるのが確認できます。

だがしかし!ここで大問題が!!

パフォーマンスを測定しようとApache Benchでリクエストを投げまくってみると、最後まで完了せずにエラーで途中終了してしまいます。

ms9

ms8

ms10

Unable to connect to the primary member of the replica set: システムのバッファー領域が不足しているか、またはキューがいっぱいなため、ソケット操作を実行できませんでした。

なかなかの男気あふれる強気のエラーメッセージ。
どーしたんだMongoSessionStateStore!
これが精一杯なのかOfficial Driver!

長くなったのでつづく...。

2011年10月29日土曜日

URLRewriteのoutboundRulesでセッションIDを含んだHTML内のURLを普通のURLに戻す、リベンジ

タイトル長っ!

無聊を託つ: Controllerを名前から生成するしHTMLを書き換えたりもしてみる

ちょっと前にエントリしました。が、間違えてました。思いっきり。これを信じてくれた人すいません。リベンジです!今度はこないだのよりだいぶマシ。

目的は、OutputCacheを利用する際にCookieless URL(セッションIDとかを含んだURL)をHTMLに保持してると、他の人とセッション共有しちゃうから、それを防ぐ!です。セキュリティ的に守らなきゃ、という理由だけでページ全体をno-cacheにするのはあまりにも富豪。

さらに、gzipで動的コンテンツを圧縮することで、CPUは多めに使うことがあるけど、OutputCacheで相殺。もちろんレスポンス性能が劇的に向上するので、ユーザーにとってはいいことづくし。

<rewrite>
  <outboundRules>
    <rule name="Sessionless" preCondition="html" enabled="true">
      <match filterByTags="A, Area, Base, Form, Frame, Head, IFrame, Img, Input, Link, Script, CustomTags" 
				customTags="All" 
				pattern="(.*)/\([SFA]\([^/]+\)\)/(.*)"/>
      <action type="Rewrite" value="{R:1}/{R:2}"/>
      <conditions>
        <add input="{REQUEST_URI}" matchType="Pattern" pattern="\)/mobile" ignoreCase="true" negate="true" />
      </conditions>
    </rule>
    <customTags>
      <tags name="All" />
    </customTags>
    <preConditions>
      <preCondition name="html">
        <add input="{RESPONSE_CONTENT_TYPE}" pattern="text/html" />
        <add input="{REQUEST_URI}" pattern="/mobile" negate="true" />
      </preCondition>
    </preConditions>
  </outboundRules>
</rewrite>

これが、たぶん正解のRewrite用のconfig。ちゃんと確認してみます。

せっかくなので、MVC4DPを利用してみましょう(意味なくはない)。プロジェクト作って実行すると表示される画面は↓こうですね。

rewrite1

この時のURLは http://localhost:61972/ です。

このままではやりにくいので、sessionStateでcookieless="UseUri"にします。

rewrite2

見た目が変わるわけじゃないです。URLを見てくださいね。今度は http://localhost:61972/(S(l2ulbdgxdrbdoyrozczknp1p))/ となっていますね。この状態でソースを確認します。

rewrite3

バッチリセッションIDがURLに含まれてますね。先ほどのconfigをsystem.webServer内に追記するとどうなるか。

rewrite4

セッションIDが消えました!概ね...。部分的に残ってる部分があるんですけど、それは多分仕様。と、いうのもAタグのhref属性の前にdata-dialog-title属性が入ってますよね。これがあるとURLRewriteが対象だと判断してくれないみたいです。試しに、_LogOnPartial.cshtmlを変更してみます。

@if (Request.IsAuthenticated) {
    <p>
        Hello, @Html.ActionLink(User.Identity.Name, "ChangePassword", "Account", null, new { @class = "username" })!
        @Html.ActionLink("Log Off", "LogOff", "Account")
    </p>
} else {
    <ul>
        <li>@Html.ActionLink("Register", "Register", "Account", routeValues: null, htmlAttributes: new { id = "registerLink", data_dialog_title = "Registration" })</li>
        <li>@Html.ActionLink("Log on", "LogOn", "Account", routeValues: null, htmlAttributes: new { id = "logonLink", data_dialog_title = "Identification" })</li>
    </ul>
}

↑こっちがオリジナル。で、↓こっちが修正版。違いはhtmlAttributesのdata_dialog_titleの有無。

@if (Request.IsAuthenticated) {
    <p>
        Hello, @Html.ActionLink(User.Identity.Name, "ChangePassword", "Account", null, new { @class = "username" })!
        @Html.ActionLink("Log Off", "LogOff", "Account")
    </p>
} else {
    <ul>
        <li>@Html.ActionLink("Register", "Register", "Account", routeValues: null, htmlAttributes: new { id = "registerLink" })</li>
        <li>@Html.ActionLink("Log on", "LogOn", "Account", routeValues: null, htmlAttributes: new { id = "logonLink" })</li>
    </ul>
}

そうするとレンダリングされるHTMLは↓こうなります。

rewrite5

RegisterとLog onのURLからもSessionIDが消えて正しくなりました。htmlAttributesで追加した属性はTagBuilderで展開されるときにアルファベット順に出力(SortedDictionary)されるんですね。なので、hrefより先にdata属性が展開される、と。この辺、どうするんでしょうね。正しくはURLRewriteのOutboud Providerが対応することなんだと思うけど...。

とりあえず、今のところスルー。さーせん。

ちなみに前回はこれを仮想ディレクトリ配下にデプロイせずに「出来たできた~」と浮かれてて、実はちゃんと消えないっていうダメっぷり。あと、SessionIDだけじゃなくCookielessの場合は認証チケット(F)も匿名ID(A)もURLに含まれるのにSだけ見てて、これまたちゃんと消えないっていうダメダメっぷり。

今回はちゃんと確認。

rewrite6

ローカルIISのmvc4dpにデプロイ。URLは view-source:http://localhost/mvc4dp/(S(cow2znspocwggst1mah50myt))/ です。これもちゃんとHTML中のURLからはちゃんとSessionIDが消えました!

でー。この状態で今度はアウトプットキャッシュをOnにします。そのためにHomeControllerに以下の追記。

    [OutputCache(Duration = 60)]
    public class HomeController : Controller
    {
        public ActionResult Index()
        {

これをVS実行環境で見てみる。

rewrite7

ちゃんと出力キャッシュが効いてる証に、max-ageとExpiresが出てますね。

ローカル環境でもキャッシュの有無で少しだけ、結果が違いますね。

rewrite8 rewrite9

少しだけね。で、このキャッシュされてるHTMLにはもちろんSessionIDは含まれてません。

ココからさらに動的圧縮をOnにするために以下の記述を追加。

    <urlCompression
      doStaticCompression="true"
      doDynamicCompression="true"
      dynamicCompressionBeforeCache="false" />

と、いいたいところですが、残念ながらこれはこのままでは機能しないんです。

URL Rewrite Outbound Rules w/ Compression : The Official Microsoft IIS Site

↑ココに書かれてる通り、 レジストリに項目追加が必要です。切ないですね。でも、まぁ、いいでしょう。

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\InetStp\Rewrite]
"LogRewrittenUrlEnabled"=dword:00000000

これをレジストリに追加。

で、いざ!と思いきや...。

rewrite10

ざんねーん。IIS Expressではどうもこのレジストリの値を見てくれないみたいです。対策も特に見つけられなかったです。なのでローカルのIISにデプロイしたほうで試します。

rewrite11 rewrite12

たたーん!出力キャッシュも効いてるし、gzipも効いてます。さらにHTML中のSession IDを持ってるURLもなくなってます!Content-Lengthが5.77KBから2.61KB。

標準(追加モジュールですけどMS謹製)でもココまで出来ました。

これ以上は独自のResponse Filterを書いてHTML中のURLを操作する方法になるでしょう(もちろんURL RewriteのProviderを実装という手もあるけど、それ書くくらいならFilterのほうが低コストじゃないですかねー)。

大規模サイトもコレで安心ですね。

ちなみに、ですけど、すべてのサイトでコレを設定すれば早くなるわけじゃないので用法・用量を守って正しくお使いください。