2012年2月25日土曜日

ControllerとApiController

ASP.NET MVC4 beta面白いですねー。

特にRESTfulなサービス実装を容易に実現するために導入されたApiControllerは強烈。

WCFのプロダクトとして開発が進められていたWCF Web APIが、名前を変えてASP.NET Web APIとして生まれ変わったのがその実態です。

そもそもWeb APIの出所がWCFで、その系譜もなかなか歴史のあるものだったわけですが、RESTfulならMVC、SOAPならWCFという住み分けを明確にするという意味も込めてのASP.NET Web APIなんじゃないかと勘ぐってるところです。

ホントのところは本国の開発チームしか知らないんだろーし、そんな理由はDeveloperには関係なかったりもするんだけど、気になって仕方ない。その辺はこっちに書いてみました。まるで根拠のない話ですから!フィクションですからね!

で、ですね、ApiControllerですけど。これまでのMVCだとIController実装のControllerBase、それを派生させたControllerを利用してましたね。そう、この名前が重要で、ApiControllerもControllerという名前。でも、その実態はIControlerではなくIHttpController実装。

はい~?何が違うの~?と、なりましょうね。そりゃーそうです。どっちも名前はControllerってなってるし、使い方もだいたい一緒で、見た目の違いはRouteCollectionへの登録時にMapRouteなのかMapHttpRouteなのか、くらいですからね。

だけど、だからといって、ApiControllerがIControllerの仲間だと思うのは大間違い!MapHttpRouteと、あえて違う拡張メソッドでの登録にしてるのには訳がある。

今のところソースも無いし、詳細を確認するにはなかなか厳しいところです。が、そんな時はJustDecompile!ベータも取れて若干の不具合はあるものの、大変便利なデコンパイラ。困ったらコレ。リバースエンジニアリングで黒判定?でも、知りたいし~。

まずはMVCの仕組み。

An Introduction to ASP.NET MVC Extensibility

↑こちらのPDFがかなり正確な感じです。

mvc_pipeline

PDFをダウンロードしてみてみてください。

スタートはRouteの登録からはじまり、Controller→ModelBind→Action Invoke→ActionResult→Result Invokeとひと通りの流れと、Resolver使ってる部分が書かれてます。素晴らしいですね。

コレとMVC3のソース(公開されてるから見ましょう!)を、見つつApiControllerを使った場合の比較をしてみます。分かってる範囲で。

Route

MVC

System.Web.Mvc.RouteCollectionExtensions.MapRoute(System.Web.Mvc.dll)
    RouteCollection.Add(
        name,
        System.Web.Routing.Route(MvcRouteHandler : IRouteHandler)
    )

どうってこと無いですね。MvcRouteHandlerをRouteクラスに渡してます。MvcRouteHandlerにはMvcHandlerを渡します。

Web API

System.Web.Http.RouteCollectionExtensions.MapHttpRoute(System.Web.Http.WebHost.dll)
    RouteCollection.Add(
        name,
        System.Web.Http.WebHost.Routing.HttpWebRoute(HttpControllerRouteHandler.Instance : IRouteHandler)
    )

こっちはいきなり違います。HttpWebRouteクラスを登録です。そこへはHttpControllerHandlerを渡すんです。

パンチ効いてますねー。

IRouteHandler.GetHttpHandlerがRouteに紐づいてるIHttpHandlerを取得するんですけど、ココからすでに別物です。つまり、Web APIで提供されるApiControllerっていうのはMVCで提供されているControllerとはまるで別物ということです!

それが、何を意味するかというと、ActionSelector、ModelBinder、ActionFilterなどなど、MVCで提供されていたものがすべてWeb APIでは別のアセンブリとして提供されてるということです!!

というのも、そもそもWCF Web APIで実装を進めていたものだしね。今のところ、双方で依存関係もないです。どこかで一本化するのかどうかは微妙ですね。

System.Web.HttpとSystem.Web.Mvcはそれぞれの道を歩みそうな気もする。もちろんSystem.Web.RoutingやSystem.ComponentModel.DataAnnotationsなんかは共通なんだけど。それらを利用する部分はまるで別物。

それを踏まえて、いま、出回ってる情報を見ると、なんでDataAnnotationsを使ったバインドやAuthorizationFilterとかのFilterが使えるんだよ、とことさら強調してるのかがわかると思います。新たに実装したんだから、自慢したい!っていうね。違うか...。

リクエストとレスポンス

MVC

Requestは基本的にASP.NETの仕組みと同じで最終的にActionResultを返す。

Web API

RequestはHttpMessageHandlerが仲介してHttpRequestMessageになって、HttpResponseMessageを返す。

普通の事書いてる感じがするけど、大違いなんです。Web APIではこのMessageっていうのが重要でそれに対して各種Pipelineが介入していく。この設計、まさにWCFって感じですね。よくできてるなー、と感心せざるを得ない。

ここで分離し、かつWebサーバーの存在をHttpServerというクラスで抽象化することで、APIの提供をWeb サーバーに限定せず、Self Hostへとつなげることが、あたかも自明な流れとして受け入れられる。ん?そんなことない?

HTTP Message Handlers: The Official Microsoft ASP.NET Site

IHttpHandler

MVC

ControllerFactoryから対象Controllerインスタンスを取得してExecute。Controller.ExecuteCoreに入った時の流れ。

PossiblyLoadTempData
IActionInvoker.InvokeAction(ControllerActionInvoker)
    ControllerDescriptor <= ReflectedControllerDescriptor
    ActionDesctiptor <= ControllerDescriptor.FindAction
    FilterInfo <= GetFilters
    InvokeAuthorizationFilters - IList<IAuthorizationFilter>
    AuthorizationContext
    ? InvokeAuthorizationFilters
    : GetParameterValues
        IModelBinder.BindModel : ParameterDescriptor
            DefaultModelBinder.SetProperty
                ModelValidator <= ModelValidatorProviders.Providers.GetValidators
                    DataAnnotationsModelValidatorProvider
                    DataErrorInfoModelValidatorProvider
                    ClientDataTypeModelValidatorProvider
      InvokeActionMethodWithFilters : IList<IActionFilter>
        InvokeActionMethod
            IActionDescriptor.Execute
      InvokeActionResultWithFilters : IList<IResultFilter>
        InvokeActionResult
            ActionResult.ExecuteResult
PossiblySaveTempData

わかりにく!メモってことで。そもそも素直な実装なので、コード見たほうが早い。

Web API

RequestからHttpRequestMessageを作成、ConfigurationとDispatcherを指定してHttpServeを作成。

HttpServer.SubmitRequestAsyncにHttpRequestMessageを渡して処理開始!HttpMessageHandler、HttpControllerDispatcherが大活躍になるのがここから。

HttpControllerDispatcher
SendAsync
SendAsyncInternal
    Initialize
       
        IHttpControllerActivator <= ServiceResolver.GetService || Activator.CreateInstance
        IHttpActionSelector <= ServiceResolver.GetService || Activator.CreateInstance
        IHttpActionInvoker <= ServiceResolver.GetService || Activator.CreateInstance
       
    IHttpControllerFactory <= ServiceResolver.GetControllerFactory
    IHttpControllerFactory.CreateController
        IHttpController <= DefaultHttpContollerFactory.CreateInstance
            ControllerDescriptor.HttpControllerActivator.Create
                TypeActivator.Create
                    Expression.New
               
    IHttpController.ExecuteAsync
   
        HttpControllerDescriptor <= HttpControllerContext.Descriptor
        HttpActionDescriptor <= HttpControllerDescriptor.HttpActionSelector.SelectAction
            IHttpActionSelector : ApiControllerActionSelector
        HttpActionContext
        FilterInfo <= HttpActionDescriptor.GetFilterPipeline
        IEnumerable<IActionFilter> <= FilterInfo.ActionFilters
        IEnumerable<IAuthorizationFilter> <= FilterInfo.AuthorizationFilters
        IEnumerable<IExceptionFilter> <= FilterInfo.ExceptionFilters

        InvokeActionWithExceptionFilters(taks)
            InvokeActionWithAuthorizationFilters
                IActionValueBinder <= ServiceResolver.GetActionValueBinder : DefaultActionValueBinder
                       
                IActionValueBinder.BindValueAsync
                    HttpActionBinding                           
                        CreateParameterBindings
                            BindToBody
                                ValidationModelBinder.BindModel
                                ModelValidationNode.Validate
                    DefaultActionValueBinder.BindParameterValue
                    IModelBinder.BindModel

                    MutableObjectModelBinder.SetProperty?
                    HttpActionContextExtensions.GetValidators?
                        DataAnnotationsModelValidatorProvider
                        ClientDataTypeModelValidatorProvider

                InvokeActionWithActionFilters
                    IHttpActionInvoker.HttpActionInvoker.InvokeActionAcyns : ApiControllerActionInvoker
                        ActionDescriptor.Execute(ControllerContext,ActionArguments)
            HttpActionExecuteContext

動かしながらの確認じゃないのでイイカゲン。だし、相変わらずメモで読みにくし。どんまい。Resolverがいろんな所で使われてるのと、ほとんどがTaskになってる。

今のところよくわかってないのがMutableObjectModelBinderがValidationするModelBinderなんだけど、こいつが発動するのはいつなのかというところ。この中のSetPropertyでModelValidatorがワサワサ動いてるっぽいから、コレを利用したバインドにならないと検証が動かないじゃないっすか。動いてることは間違いないんだけど(DataAnnotatinosでの検証がかかってるのはサンプルで確認できてるし)なー。今後の調査課題。

ここでMVCと明確に違うのがDataErrorInfoModelValidatorProviderが存在してないところ。なくてもいいと思うけど、IDataErrorInfo使ってるのを動かすのは自分でしこまないとダメってことですね。こういう所ではちょっと差がある。

Validating your models in ASP.NET Web API - Pablo M. Cibraro (aka Cibrax) ASP.NET MVC 4 public beta including ASP.NET Web API

↑この書き方のサンプルをオンラインでよく見かけますよね。MVCならAction内でModelState.IsValid見てたと思うけど、Web APIだとFilterにしちゃうのがいいの?ときになるところだと思いますが、これはAPIが返す結果が何なのかを考えれば妥当というかコレしかないね、と思えるところです。

どういうことかというとHTMLを返して、その中にエラーメッセージを含んでたりModelStateによる入力値を復元させることを前提にしてるアプリケーションとしてのMVC(どこのViewにModelを渡すのかをControllerが指定)。それに対してエラーですよ、ということを返せばいいだけのAPI(ControllerがModelを返すけど、APIから返るデータをViewというならViewはModelをFormatしたXML/JSON固定で不変)。何がどのようにエラーだったのかを判定して表示を制御するのはAPIを利用したアプリケーションの責任で表示としてのViewを制御してるのはAPIじゃない。なので、一律Filterでエラーの時の処理を決めてレスポンスすれば良い。MVCでも同じようにFilterでModelStateをみてエラーをレスポンスすることは可能ですが、その時のViewをFilterが判断することになって、う~ん、まいった、ですね。

んじゃ、ApiControllerのActionの中でModelStateにエラーを入れることができないの?っていうとそんなことはなくて、入れればいいです。で、OnActionExecutingじゃなくてOnActionExecutedでResponse変えるとか、HttpMessageHandlerで書き換えるとか、事後にエラーとしてしまう方法はイロイロです。

DefaultServiceResolver

ちょっと脱線。最初から登録されてるインスタンスたちはコレらでした。

  • IBuildManager
        DefaultBuildManager
  • IHttpControllerFactory
        DefaultHttpControllerFactory
  • IHttpControllerActivator
        DefaultHttpControllerActivator
  • IHttpActionSelector
        ApiControllerActionSelector
  • IHttpActionInvoker
        ApiControllerActionInvoker
  • ModelMetadataProvider
        EmptyModelMetadataProvider
  • IFormatterSelector
        FormatterSelector
  • IActionValueBinder
        DefaultActionValueBinder
  • IRequestContentReadPolicy
        DefaultRequestContentReadPolicy
  • IFilterProvider
        ConfigurationFilterProvider
        ActionDescriptorFilterProvider
        EnumerableEvaluatorFilterProvider
        QueryCompositionFilterProvider
  • ModelBinderProvider
        TypeMatchModelBinderProvider
        BinaryDataModelBinderProvider
        KeyValuePairModelBinderProvider
        ComplexModelDtoModelBinderProvider
        ArrayModelBinderProvider
        DictionaryModelBinderProvider
        CollectionModelBinderProvider
        TypeConverterModelBinderProvider
        MutableObjectModelBinderProvider
        CompositeModelBinderProvider
  • ModelValidatorProvider
        DataAnnotationsModelValidatorProvider
        ClientDataTypeModelValidatorProvider
  • ValueProviderFactory
        RouteDataValueProviderFactory
        QueryStringValueProviderFactory
  • ModelMetadataProvider
        CachedDataAnnotationsModelMetadataProvider
  • ILogger
        DiagnosticLogger

いっぱいあるねー。

resolvers

System.Web.Http.Services.DefaultServiceResolver。System.Web.Http.GlobalConfiguration.Configuration.ServiceResolverらへんです。

MVCそのもの(System.Web.Mvc)も楽しいんだけど、今後はASP.NET Web API(System.Web.Http)に対しても、目を光らせておこうと思う次第です。

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で更に効率良くリクエストを捌けるようになるね!

2012年2月12日日曜日

プロパティのバインドに失敗するときのエラーメッセージをカスタム

stackoverflow.com。 今でもたまに書き込んでます。

c# - Regular Expression stop crashing - Stack Overflow

随分前にも似たようなガッカリ感を味わったんだけど、今回もまた似たような気分を味わってます。とはいえ、質問の真意を履き違えての結果の可能性は否めない。

この質問、必須項目にしたDateTime型のプロパティへのバインドに失敗するから、正規表現でチェックしたんだけど、”0000/00/00 00:00:00”って入力するとエラーになるんだよね。なんで?って言う感じでしょーか。

質問者のモデルを見るとPostedは必須項目。DateTimeには有効な日時しか入れられないっていう条件をあわせて考えると、不正な文字列の時にエラーメッセージを表示できれば要件は満たす。かな。正規表現がどうのこうのっていう問題解決の仕方ではなく、プロパティにセット出来なかった時のエラーメッセージがカスタム出来ればいいかもね、と思ったんですねー。

private static string GetValueInvalidResource(ControllerContext controllerContext) {
	return GetUserResourceString(controllerContext, "PropertyValueInvalid") ?? MvcResources.DefaultModelBinder_ValueInvalid;
}

private static string GetValueRequiredResource(ControllerContext controllerContext) {
	return GetUserResourceString(controllerContext, "PropertyValueRequired") ?? MvcResources.DefaultModelBinder_ValueRequired;
}

↑このようにMVCのDefaultModelBinderではそんな時のエラーメッセージをプロパティ”個別”にカスタムはできません。どんなプロパティにも共通のメッセージ。

で考えた結果、投稿したコードが↓これ。

public class CustomModelBinder : DefaultModelBinder
{
  protected override void SetProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, object value)
  {
    base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);

    var propertyMetadata = bindingContext.PropertyMetadata[propertyDescriptor.Name];
    var invalidMessage = propertyMetadata.AdditionalValues.ContainsKey("PropertyValueInvalid")
                 ? (string)propertyMetadata.AdditionalValues["PropertyValueInvalid"]
                 : string.Empty;
    if (string.IsNullOrEmpty(invalidMessage))
    {
      return;
    }

    // code from DefaultModelBinder
    string fullPropertyKey = CreateSubPropertyName(bindingContext.ModelName, propertyDescriptor.Name);
    if (!bindingContext.ValueProvider.ContainsPrefix(fullPropertyKey))
    {
      return;
    }
    ModelState modelState = bindingContext.ModelState[fullPropertyKey];
    foreach (ModelError error in modelState.Errors.Where(err => String.IsNullOrEmpty(err.ErrorMessage) && err.Exception != null).ToList())
    {
      for (Exception exception = error.Exception; exception != null; exception = exception.InnerException)
      {
        if (exception is FormatException)
        {
          string displayName = propertyMetadata.GetDisplayName();
          string errorMessageTemplate = invalidMessage;
          string errorMessage = String.Format(CultureInfo.CurrentCulture, errorMessageTemplate,
                            modelState.Value.AttemptedValue, displayName);
          modelState.Errors.Remove(error);
          modelState.Errors.Add(errorMessage);
          break;
        }
      }
    }
  }
}

これを利用するためにモデル定義を以下のように変えてしまいましょう。

[Required]
[AdditionalMetadata(
 "PropertyValueInvalid",
 "Wrong Syntax Entered, Needed:day/Month/Year Hour:Minutes:Seconds")]
public DateTime? Posted { get; set; }

AdditionalMetadataにカスタムメッセージを指定して、プロパティのバインドがエラーになったらそのメッセージを表示する。素敵だと思うんだけどなー。

で、コメントが「this code is like giving a baby a gun」。ぎゃふん。

2012年1月29日日曜日

効率のいいリクエスト処理

Webサイトの運用、大変ですよね。お疲れ様デスよ。まったく。

普通にネットワーク構成するとハードウェアロードバランサの下にWebサーバー並べて負荷分散する感じになるでしょう。処理が追いつかなかったらWebサーバー追加だぜ!とか、富豪富豪。ふぇっふぇっふぇ。でも、そんな富豪ばかりじゃない。

Application Request Routing | IIS 拡張機能 | TechNet

略してARR。NLBみたいにネットワークの負荷分散じゃなくてHTTPでのリクエスト負荷分散。ソフトウェアLB。

ARRが単なるリバースプロキシだと思ったら大間違いでした。この子かなり優秀。

そもそも、SSLオフロードさせようと思って、パフォーマンステストをしてたんですけど、思いの外Request / Secが高くなる。変だなーと思って、HTTPSじゃなくてHTTPでも試してみたら、それでもRequest / Secが高い。なんで!?と思いパフォーマンスモニターでいろいろ見てたら、どーもノードサーバーの処理が重い時にはARRがリクエストのルーティングをちょっと保留してるくさい。レスポンスタイムを見て、重くなってきたのかどーかを判断してるのかなー。それ以外に指標がないと思うので、そーじゃないかという予想。

arr1

arr2

VS のロードテスト(今はまだUltimate使えるからね)でチェックしてみました。10~250ユーザーまで徐々に増やしていく設定でWebアプリケーションの動きをシミュレートさせる。下のグラフはパフォーマンスモニタ。

黄色がCPU、青がWeb Service:Current Connections、緑がW3SVC_W3WP:Request / Sec、赤がASP.NET v2.0:Request Queued。

まずは、ARRを通さずに直接リクエストした状態です。CPUは概ねフルに使いつつ、コネクション数が段階的に増え、リクエストキューに処理待ちリクエストが溜まっていく様子です。直感的にそーなるなー、っていう動きがそのまま再現できてますね。

arr3

どんどん負荷を上げる(ユーザー数を増やす)と、キューにたまる数が増えて、グラフ上Request / Secを上回ります。これはつまり同時処理性能を超えたリクエストがWebアプリケーションサーバーに来てるっていう状態ですね。さばけないからキューイング。ASP.NETのキューイング。

最終的にすべて終了すると↓こうなって、何もかも0に戻る。

arr4

ロードテスト結果。

arr5

Request / Secが281でページ/秒の平均が26.0です。

今度は全く同じ事をARR経由させて実行します。

arr6

arr7

この段階ですでに挙動が違うんですよね。下のグラフはWebアプリケーションサーバーの状態なんですけど、コネクション数の増え方が違う。段階的なんだけど、伸びかたが明らかに違う。この時のARRの同じグラフを見てみるとどーなってるか。

arr8

なるほどね。こっちにはユーザー数と同じ増え幅でコネクション数が増えてます。

ARRからノードサーバー(Webアプリケーションサーバー)へはTCP接続を使いまわしてる?コネクションプーリング。

arr9

ユーザー数をどんどん増やしていくと、リクエストキューにたまるのが直接接続時の挙動でしたが、ARRを経由した場合、キュー(赤)がたまるのに合わせてコネクション数(青)が増えてる。関連があるような挙動ですね。

で、観測を続けると、キューの数が減少に転じてます。不思議ですね。ユーザー数は同じでリクエスト数も同じように発生してるのに、Webアプリケーションサーバーではキューがたまりません。

その時のロードテストの状態。

arr10

Request / Secは298で落ちてない。若干上がってるくらい。

arr11

最終的にテスト実行完了時点でCPUやRequest/Sec、キューの数は0に落ちるのに、コネクション数(青)はまだ0にならない。しばらく高止まり。これはつまりARRがコネクションをすぐに切ってないということですね。

arr12

Request / Secが347でページ/秒の平均が32.4.0。ページの応答時間平均にいたっては6.45から4.20ですよ!約1.5倍。これはどういうことなのかというと、Webアプリケーションサーバーの同時処理性能が下がるようなリクエストをルーティングせず、しばらくARRでキューイングしておくことで、Webアプリケーションサーバーがベストパフォーマンスを出せるように調整してるから、じゃないでしょうか。実際にWebアプリケーションサーバーのキューの数はARRを経由させたほうが少ない状態を維持し、コネクション数も少ない。何度か試した結果コネクション数が半分の時もあるのに、ページの応答時間平均は常に概ね1.5倍早い。

無理に同時実行させないことで早くレスポンスを返す。結果的にRequest/Secも上がる。SSLオフロードでWebアプリケーションサーバーのCPU利用を実処理に専念させようとしただけなのに、この結果。恐るべしARR!

ちなみにARRとWebアプリケーションサーバーが同居した場合にはまるで性能向上しませんでした。してもいいかと思ったんだけど、そういう訳でもないんですね。IISとASP.NETの双方でのCPU利用(ネットワークも思いのほかCPU利用するのかもね)を最適化するならARRは別サーバーで用意しましょう。

ARR賢い。単なるリバースプロキシじゃない。ノードサーバーはIISに限定するものじゃないので、性能判断の指標はレスポンスタイムしか無いと思うけど、その辺の情報は見つけることが出来ませんでした。

今回はARR1台、ノードサーバー(Webアプリケーションサーバー)1台なので、こういう結果でしたが、ノードサーバーの台数が増えた場合にはどのように効率化されるのかは、環境次第だと思うので、自身の環境で試してみたらどーでしょー。

もし、仮に同じような性能向上をするならWebアプリケーションサーバーが4台以上ある場合、1台をARRにしたほうがレスポンス性能がいいということになるかもよー。

目指せ、続ハイパフォーマンスWebサイト!

2012年1月2日月曜日

HttpBench on GitHub

年明け早々です!お正月生まれのツール。

HttpBench – GitHub

ApacheBenchにはいつもお世話になってます。でも、同じようにHTTPSも計測したいです。absめ!

localhostに対してab –n 100 –c 10 http://localhost/

hb3

localhostに対してhb –n 100 –c 10 http://localhost

hb4

それっぽいー。けど、手抜きすぎー。

せっかくなのでIIS ExpressでHTTPSへのリクエストを確認。

hb5 hb6

左がHTTPで右がHTTPS(IIS Expressの勝手証明書)。ab(abs)だとうまくできないですよね。

abだと、すごくたくさんオプション指定できるし、計測も細かい。流石です。が、hbはまだ全然。とりあえず実行回数/コンカレント数/BASIC認証、HTTP GETだけ。

あ。バイナリ配布ってどうしよ...。いつかすると思う。今はそんな段階でもないからいいよね。

コンカレントをMaxDegreeOfParallelismじゃなくて、Threadを作って処理してます。普通にTaskでもよかったのかなー。

hb7

↑-c 200指定。

目指せハイパフォーマンスWebサイト!

2011年12月31日土曜日

MongoDBでお気楽MapReduce

MongoDBにはMapReduceの仕組みが用意されてて楽しそうですよね。

MapReduce - Docs-Japanese - 10gen Confluence

ちゃんと日本語。

で、コンソールで入力しながらの開発は面倒なので、こんな時はMongo VUE。安くならないかなー。

Yet another MongoDB Map Reduce tutorial | MongoVUE

使い方、これじゃよく分からない。

How to perform MapReduce operations in MongoVUE | MongoVUE

こっちですね!この手順どおりに実行すれば、ちゃんとMapReduce!Sharding環境ならそれぞれのShardで分散処理してくれます。

せっかくなので、以前作ったMongoTraceListnerのサンプルを利用して、各URLの処理時間の平均をMapReduceで計算してみます。

public class PerformanceTraceModule : IHttpModule
{
  private string ItemKey = "_mongoDbTraceStart";

  public PerformanceTraceModule()
  {
  }

  public void Init(HttpApplication context)
  {
      context.BeginRequest += new EventHandler(context_BeginRequest);
      context.EndRequest += new EventHandler(context_EndRequest);
  }

  void context_BeginRequest(object sender, EventArgs e)
  {
      var httpContext = (sender as HttpApplication).Context;
      var startTime = httpContext.Items[ItemKey] = DateTime.Now;
  }

  void context_EndRequest(object sender, EventArgs e)
  {
      var httpContext = (sender as HttpApplication).Context;
      var startTime = (DateTime)httpContext.Items[ItemKey];

      Trace.WriteLine(new
      {
          Method = httpContext.Request.HttpMethod,
          Status = httpContext.Response.StatusCode,
          RawUrl = httpContext.Request.Url.ToString(),
          Milliseconds = (DateTime.Now - startTime).Milliseconds
      });
  }

  public void Dispose()
  {
  }
}

このHttpModuleを仕掛けて処理時間をMongoDBに入れます。

実行!何度も何度もリロードやら、About、Loginへのアクセスを繰り返す。

mr2

データも溜まって来ました。

mr1

ここで、MapReduceを実行。

Map

mr3

Reduce

mr4

Finalize

mr5

In & Out

mr6

結果

mr7

ただ、ここでmap/reduce/finalizeのコードに間違いがあっても、わかりにくい。どこのなにがエラーなのかサッパリ。そんな時はコンソールを見てみましょう。サービス化してたらログファイル。

mr8

finalizeで適当にエラーコードを入れて実行するとコンソールにエラーメッセージが出てます。開発中はMongoDB Shellでやるか、ローカル実行してコンソール確認しながらすすめるのがいいかもー。

Mongo VUEやShellで見るだけじゃなくて、ASP.NET MVCのアプリケーションから見れるようにするために、以下のようにしてみました。

private MongoDatabase GetDatabase()
{
  var serverName = ConfigurationManager.AppSettings["MongoDb:Server"];
  var databaseName = ConfigurationManager.AppSettings["MongoDb:Database"];

  if (string.IsNullOrEmpty(serverName))
      serverName = "mongodb://localhost";

  if (string.IsNullOrEmpty(databaseName))
      databaseName = "TraceListner";

  var server = MongoServer.Create(serverName);
  return server.GetDatabase(databaseName);
}

private BsonJavaScript GetMapReduceCode(string name)
{
  return new BsonJavaScript(System.IO.File.ReadAllText(Server.MapPath("~/MapReduce/" + name)));
}

public ActionResult MapReduce()
{
  var map = GetMapReduceCode("map.js");
  var reduce = GetMapReduceCode("reduce.js");
  var finalize = GetMapReduceCode("finalize.js");

  var collection = GetDatabase().GetCollection("MyTrace");
  var options = new MapReduceOptionsBuilder();
  options.SetFinalize(finalize);
  options.SetSortOrder("_id");
  options.SetOutput(MapReduceOutput.Inline);

  var result = from r in collection.MapReduce(map, reduce, options).GetResults()
               select new
               {
                  RawUrl = (string)r["_id"],
                  Count = r["value"].AsBsonDocument["Count"].ToInt32(),
                  Elapsed = r["value"].AsBsonDocument["Elapsed"].ToInt32(),
                  Average = r["value"].AsBsonDocument["Average"].ToDouble()
               };
  return Json(result.ToArray().OrderByDescending(mr=>mr.Average), JsonRequestBehavior.AllowGet);
}

HomeControllerに追加。

mr9

やったー。出たー。あとは、見た目よろしくしていけばOKでしょう。今回は気にしないけど。

こんな感じでMongoDBにデータ入れとけばMapReduceで集計をMongoDBサーバーに任せられていいですね!

っていう、結論じゃなくて、結局はV8でスクリプト動かしてるから、大量のデータの場合そんなに早くなかったりしてショック。対象データをすべてフェッチしてC#で処理したほうが早かったりも...。

速度的には思ったほどじゃないけど、これはこれで面白いので、PowerPivotとあわせて業務系の人が扱えるようになると、嬉しいですよねー。

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!

dotnetConf2015 Japan

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