2012年4月13日金曜日

検索するSaaS

どうもどうも。月刊たけはらブログ。

先日AWSからCloudSearchが発表されて、なかなかホットな分野になりつつある検索SaaS。検索って言ってもサイトの検索じゃなくてドキュメントの検索。で、最近注目してたのがIndexTank。これをサービス提供してるSearchifyっていうのがあります。

Searchify - Hosted search as a service - IndexTank API

IndexTankで検索しても対して情報はないんだけど↓こんなです。

米LinkedIn、買収したIndexTankの検索技術をオープンソースに

IndexTankのサービス提供は IndexTank - hosted search you control っていうのが先にやってたんだけど、そこでのサービス提供がなくなって Searchify一本になった矢先のAWS CloudSearch。 熱い分野です!っていうか、選択肢のあまりない分野?

まぁ、そんなことはどうでもいいね。システムが提供する検索どーしよっかなー、自分でLuceneとか用意しようかなー、なんて考えてる人たくさんいますよね。SQLServerでのFulltext Searchか単なるLike検索か。規模が大きくなればなるほど、データが多くなればなるほど、困ったことになる検索。

Searchify、使ってみようじゃないか!

といっても、単純に使うのはすごく簡単。.NETからの利用に関してもライブラリが既にあるので、サンプルそのまま書けばあっさり検索できる。超簡単。

IndexTankDotNet - the IndexTank Client Library for .NET

サンプルを転載(ズルくない)。

1.Indexを取得。

IndexTankClient client = new IndexTankClient("<YOUR API URL HERE>");

Index index = client.GetIndex("<YOUR INDEX NAME HERE>");

2.ドキュメントの登録。

string documentId = "<A DOCUMENT IDENTIFIER>";
string documentText = "<TEXTUAL CONTENT OF DOCUMENT>";

Document document = new Document(documentId).AddField("text", documentText);

index.AddDocument(document);

3.ドキュメントの検索。

string queryText = "<TEXT TO SEARCH FOR>";

Query query = new Query(queryText);

SearchResult result = index.Search(query);

Console.WriteLine(string.Format("There were {0} matches found for '{1}'.", result.Matches, result.QueryText));
Console.WriteLine(string.Format("The search took {0} seconds.", result.SearchTime));

foreach (ResultDocument document in result.ResultDocuments)
{
   Console.WriteLine(string.Format("Document ID: {0}", document.DocumentId));
}

簡単です!ドキュメントを登録することで勝手にインデックスが構築されて検索可能になります。

考え方としてはIndexっていうのが検索スコープで、ドキュメントっていうのがコンテンツ。IndexTankの面白いところは、ドキュメントに対してフィールド(セマンティック項目)を定義できるところ。通常の全文検索項目は”text”っていう名前の項目。それ以外にも自分で好きなように項目(Field)を定義して、検索オプションに利用できます。さらにさらに、Valiablesっていう特別項目もあって、これを利用することで、更新頻度の高い項目を別枠で検索できるようになります(In-RAM)。通常のFieldはインデクサが頑張ってから検索可能になるのに対して、Variableは即座に検索に反映されるっていう使い分け。しかも、Variableは範囲検索もできるっていうのが特徴的。これAWS CloudSearchではまだできない。範囲検索超強力。

詳しくはドキュメントどうぞ。

www.indextank.com/_static/papers/IndexTank WhitePaper Technical.pdf

ドキュメントに書かれてるソースだけで、分かった気になるのはだらしないので、ちゃんとサンプルも書きました。

    public class Searchify
    {
        public string ApiUrl = "https://アカウント登録してね!";
        public string IndexName = "FreshTest";

        public Index GetIndex()
        {
            var client = new IndexTankClient(ApiUrl);
            return client.GetIndex(IndexName);
        }

        private Dictionary<string, Person> _mugiwara = new Dictionary<string, Person>
        {
            {"1",new Person{Name="モンキー・D・ルフィ",Date=new DateTime(2000,1,1),Spec = "ゴムゴム",Age = 19.0f, Bounty = 40000f,
                            Text="(麦わらのルフィ)声 - 田中真弓 本作の主人公。麦わらの一味船長。「ゴムゴムの実」の能力者のゴム人間。麦わら帽子がトレードマーク。夢は「海賊王」と「シャンクスとの再会」。"}},
            {"2",new Person{Name="ロロノア・ゾロ",Date=new DateTime(2000,1,1),Spec = "",Age = 21.0f, Bounty = 12000f,
                            Text="(海賊狩りのゾロ)声 - 中井和哉 麦わらの一味戦闘員。「三刀流」の剣士。クールでストイックな武士道精神の持ち主。世界一の大剣豪を目指している。"}},
            {"3",new Person{Name="ナミ",Date=new DateTime(2000,3,1),Spec = "",Age = 20.0f, Bounty = 1600f,
                            Text="(泥棒猫)声 - 岡村明美 麦わらの一味航海士。元は海賊専門の泥棒。お金・お宝に目がない。世界地図を描くのが夢。"}},
            {"4",new Person{Name="ウソップ",Date=new DateTime(2000,3,1,12,0,0),Spec = "",Age = 19.0f, Bounty = 3000f,
                            Text="(狙撃の王様そげキング)声 - 山口勝平 麦わらの一味狙撃手。臆病でお調子者ながら、器用で口八丁なパチンコの名手。父・ヤソップのような勇敢なる海の戦士を目指している。"}},
            {"5",new Person{Name="サンジ",Date=new DateTime(2000,7,1),Spec = "",Age = 21.0f, Bounty = 7700f,
                            Text="(黒足のサンジ)声 - 平田広明 麦わらの一味コック。コックの命である手を傷つけないように、足技で戦う。無類の女好き。伝説の海「オールブルー」を探す。"}},
            {"6",new Person{Name="トニートニー・チョッパー",Date=new DateTime(2000,7,1,12,0,0),Spec = "ヒトヒト",Age = 17.0f, Bounty = 0.005f,
                            Text="(わたあめ大好きチョッパー)声 - 大谷育江 麦わらの一味船医。「ヒトヒトの実」を食べた人間トナカイ。人獣型、人型、獣型に変形出来る。何でも治せる医者を目指している。"}},
            {"7",new Person{Name="ニコ・ロビン",Date=new DateTime(2000,7,2),Spec = "ハナハナ",Age = 30.0f, Bounty = 8000f,
                            Text="(悪魔の子)声 - 山口由里子 麦わらの一味考古学者。「ハナハナの実」の能力者。歴史上の「空白の百年」の謎を解き明かすため旅をしている。"}},
            {"8",new Person{Name="フランキー",Date=new DateTime(2000,8,1),Spec = "",Age = 36.0f, Bounty = 4400f,
                            Text="(鉄人(サイボーグ))声 - 矢尾一樹 麦わらの一味船大工。体中に武器を仕込んだサイボーグ。自分の作った船に乗り、その船が海の果てに辿り着くのを見届けることが夢。"}},
            {"9",new Person{Name="ブルック",Date=new DateTime(2000,9,1),Spec = "ヨミヨミ",Age = 90.0f, Bounty = 3300f,
                            Text="(鼻唄のブルック)声 - チョー 麦わらの一味音楽家。一度死んだが「ヨミヨミの実」でガイコツ姿で蘇ったアフロ剣士。リヴァース・マウンテンで別れた鯨のラブーンとの再会を誓う。"}},
        };

        public void CreateIndex()
        {
            var index = GetIndex();
            var documents = new List<Document>();
            foreach (var doc in _mugiwara)
            {
                // insert document
                Document document = new Document(doc.Key);
                document.AddField("text", string.Format("{0}\r\n{1}", doc.Value.Name, doc.Value.Text));
                document.AddField("spec", doc.Value.Spec);
                document.AddVariable(0, doc.Value.Age);
                document.AddVariable(1, (doc.Value.Date - new DateTime(1, 1, 1)).Days);

                documents.Add(document);
            }

            Console.WriteLine("start at " + DateTime.Now);
            index.AddDocuments(documents);
            Console.WriteLine("end at " + DateTime.Now);
        }
    }

見にくい!ほとんどデータ。麦わら屋。

            var searchify = new Searchify();
            searchify.CreateIndex();

って、実行すれば登録されます。

あとは、検索するだけ。

Searchifyの管理画面からも簡単な検索はできるので、そこからドキュメントが登録されてるかは確認できます。

searchify1

拡大してみてね。"麦わら"で検索したら9件出てきました。すべでのドキュメントに含まれてる単語だからね。っと、単語とは言ったけど、形態素ってわけじゃないっぽい。ちゃんとしたドキュメントは見つけられてないけど、N-gramだと思われる。多言語対応しようと思うとそのほうが都合いいもんね。

わざわざ適当な日付を利用してVariable項目に登録してるんですが、これを使って検索してみましょう。

            text = "声";
            query = new Query(text);
            var days = (new DateTime(2000, 3, 1) - new DateTime(1, 1, 1)).Days;
            results = index.Search(
                query.WithDocumentVariableFilter(1, 0f, days)
            );
            Console.WriteLine("search '{0}' {1} matches", text, results.Matches);
            foreach (var result in results.ResultDocuments)
            {
                Console.WriteLine(" {0}", result.DocumentId);
            }

 

searchify2

すべてのドキュメントに"声"っていうのが含まれてるので、”声”で検索しつつ、日付として2000/3/1以前のものを検索してます。うまく4件だけ表示されました。

ここで、ちょっと変なことしてるんですけど、そもそもVariableに指定できる項目はfloatのみ。文字列も入れられないし、日付も入れられない。なので、数値に変換した日付を入れてます。しかもfloatだから有効な仮数は10進数で7ケタ。なかなか厳しいけど、日付だけなら1/1/1からの経過日数でしのげるので、日数をセットしてます。

ちなみにIndexTankDotNetの実装に少し不具合もあって(IndexTankのRESTの不具合ともいえる?)、floatを文字列にしたときに指数表記(1.234e5とか)になると検索できない。ドキュメントの値として送信する場合はJSONで送られるんだけど、その場合はJSONが指数表記に対応してるからか、うまく登録されるんだけど、それを検索しようと思ってもQueryStringの指数表記までは解釈してくれない。ちょっと残念だけど、そこは気を付けましょうってことで(フィードバックすればいいかも)。

話を戻すと、Variableを利用すると範囲検索ができるので、とても便利っていうことです。

あと、Categories(Facet)っていう特別な項目(CloudSearchにもありますね)もあって、ドキュメントがどのカテゴリに含まれてるのか検索結果に含ませて返したり、条件指定したりできます。が、複数指定するのはできないっぽいので、複数のクラスタに属するドキュメントを検索する場合はFieldにスペース区切りで値を入れると検索できるようになります。

例)lang fieldに言語をスペース区切りで入れる

docid: takepara
lang:c# japascript sql

docid: suzuki
lang:c# php

こんな感じになってて検索キーワードに lang:c# って入れると両方とれるし、lang:phpって入れたら suzukiだけとれる。その辺はまたドキュメントどーぞ。

Query Syntax documentation – Searchify

ドキュメント件数が少ないと(数万とか数10万程度)だと、ネットワークのラウンドトリップのほうが気になるだろうけど、数100万超えてくるとSaaS使うメリットが出てくると思うので、とても魅力的なソリューションになりえると思います。

実運用を考えると、マスターデータとの同期でタイムラグが発生すると思います。ドキュメントの登録自体は複数ドキュメントの一括更新ができて、ある程度速度的には許容できるんだけど、Variableの更新は1件ずつしかできないのが、ちょっと残念。Variableこそ一括更新したいと思うんだけど。更新頻度の高いデータを大量に更新しないような方法をうまく考える必要はあるね。

なので、現実的な落としどころは、外部検索ではIDだけを取得して、更新頻度の高い情報と取得したIDをもとに内部で結合して利用する、っていうのがシステム設計として必要になると思う。その際、取得するIDがあまり多くならないようにしておくことも含め、いろいろ考えるところがありそう。とはいえ、実質1000件以上とか検索結果が出てきても、見るわけもなく。

不要な検索結果の切り捨て(件数は数万って出しても、ページングした最後のページに「もっと絞り込んでね」って出すとか)はGoogleでも普通にやるし、Amazon(お店のほうね)でも300件ほどで切り捨ててるし(検索結果が数万件って表示されてもページングは20まで)。

その辺、ちゃんと意識して効率よく使っていきましょう!

2012年3月2日金曜日

ApiControllerで認証する際にログインページにリダイレクトしたくない

@jsakamotoさんへ

そりゃそーですね!Apiなんだから。FormsAuthenticationの認証チケットを利用してWCFでもForm認証を使う、っていう話が前にありましたが、それはそれで王道なやり方じゃない、ですよね。APIとして認証するならOAuthとかなんでしょうかね。その場合の受けはHttpMessageHandler使ってやればいいのかな。

とはいえ今回は受けじゃなくてレスポンスのですしたね。

普通にAuthorize属性使うと401になるから問題無さそうだけど、MVC(WebFormsでも一緒)アプリケーションに組み込んだ場合、Form認証も入ってたりするはずなので、FormsAuthenticationModuleに途中でレスポンスコードを横取りされた結果、ログインページへのリダイレクト(302)になってしまう。そうなると、クライアントがブラウザならいいけど、APIを呼び出してるプログラムだったら、そんな~、ログインページとか困ります~、です。

Web APIの標準テンプレートを使った場合に、以下のようにHomeControllerとValuesControllerを書き換えたとします。

    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        public ActionResult Login()
        {
            return Content("ログインしてね!");
        }

        [Authorize]
        public ActionResult AccessDenied()
        {
            return Content("About");
        }
    }

 

[Authorize]
public class ValuesController : ApiController
{
	// GET /api/values
	public IEnumerable<string> Get()
	{
		return new string[] { "value1", "value2" };
	}
	:
	:
}

ログインページは以下のように変更。

<forms loginUrl="~/Home/Login" timeout="2880" />

そうするとApiControllerにAuthorizeつけてブラウザでアクセスすると「ログインしてね!」が表示されます。ステータスコードは302のあとページ表示で200。

api1

コラー!

なので、FormsAuthenticationModuleに書き換えられたステータスをカスタムモジュールで再度書き換えました。

using System;
using System.Web;
using System.Web.Http.WebHost;
using System.Web.Security;

namespace Mvc4BApi
{
    public class CustomAuthenticationModule : IHttpModule
    {
        public void Dispose()
        {
        }

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

        void context_EndRequest(object sender, EventArgs e)
        {
            var application = sender as HttpApplication;
            var response = application.Response;

            if (!(application.Context.CurrentHandler is HttpControllerHandler))
                return;

            if(response.StatusCode == 302 && response.RedirectLocation.StartsWith(FormsAuthentication.LoginUrl))
            {
                response.ClearHeaders();
                response.ClearContent();
                response.StatusCode = 401;
                application.CompleteRequest();
            }
        }
    }
}

うぬ。Web.configもモジュール使うようにしましょう。

  <system.webServer>
    <validation validateIntegratedModeConfiguration="false" />
    <modules runAllManagedModulesForAllRequests="true">
      <add name="customauth" type="Mvc4BApi.CustomAuthenticationModule" />
    </modules>
  </system.webServer>

そうすると、どーなるかっていうと。

api2

ステータスは401で中身なしになりました。

予め用意しといたAuthorize属性のついた/home/accessdeniedにアクセスすると。

api3

ちゃんとForm認証と同じ挙動ですね。ログインページにリダイレクト。

FormsAuthenticationModuleが仕込まれてると、これはもうHttpHandlerの実行タイミングじゃどうにもならないのtで、こういうやり方になると思います。標準で用意されてる認証モジュールって共存出来ないし。

ApiControllerはHttpControllerHandlerから実行されるので、実質HttpHandlerですよね。途中でMessageをいじるっていうのはそこに対するAOP的な動作。

それほどコードを書くわけでも無いので、コレでいいかなーって思いますが、いかがでしょーか?後は、素直に別サイトにしてしまう、とか...。

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サイト!

dotnetConf2015 Japan

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