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

2012年4月30日月曜日

Web APIでのSession利用

いやもうまじで。そんなことしなくていいと思うんですけど。ステートレスでやればいいと思うんですけど。

c# - LinqToEntitiesDataController MVC 4 Single Page Application - Session variable - Stack Overflow

普通にやるとApiControllerってHttpContext.Curret.Sessionはnullですね。なぜかというと、Web API利用時にRouteに登録するHttpControllerRouteHandlerのGetHttpHandlerでは、IRequiresSessionStateインターフェース指定のないHttpControllerHandlerが使われているからです。

デスデス。それでいいと思います。

でも、どーしても使いたい、っていうなら、そーですね。IRequiresSessionStateを付けたものを用意しましょう。

public class SessionHttpControllerRouteHandler : HttpControllerRouteHandler
{
    protected override IHttpHandler GetHttpHandler(System.Web.Routing.RequestContext requestContext)
    {
        return new SessionHttpControllerHandler(requestContext.RouteData);
    }
}

public class SessionHttpControllerHandler : HttpControllerHandler, IRequiresSessionState
{
    public SessionHttpControllerHandler(RouteData routeData) : base(routeData) { }
}

このHttpControllerRouteHandlerをRouteTable登録に利用する。

routes.Add("SessionApis",
	new HttpWebRoute(
		url: "api/{controller}/{id}",
		defaults: new RouteValueDictionary(new {id = RouteParameter.Optional}),
		routeHandler: new SessionHttpControllerRouteHandler()
));

これだけ。

Web APIのデフォルトプロジェクトテンプレートで作成したHomeControllerのIndexでSessionに値を入れて、ValuesControllerのGetで参照してみる。

public class HomeController : Controller
{
	public ActionResult Index()
	{
		Session["message"] = "from Controller";
		return View();
	}
public class ValuesController : ApiController
{
	// GET /api/values
	public IEnumerable<string> Get()
	{

		var message = HttpContext.Current.Session["message"] + "";
		return new string[] { "value1", "value2", message };
	}

実行して、/api/valuesにアクセスしてみる。

sessionapi

ねー。

ちなみにLinqToEntitiesDataControllerはAreaRegistrationだから、そっちでRouteTableに登録しましょう。

っていうか、是非使わないような設計で。

2012年4月15日日曜日

ApiControllerのActionSelector規約

最近全然遊んでないなー、と思って。MVC4。

せっかくソースもダウンロードできるんだし、まずはApiControllerのアクションルーティング(どうやってActionを特定してるのか)を、ソースを見つつ確認してみようかと思い立ちました。深夜に突然。

とは言いつつも、すでにドキュメントがあったりするので、わざわざソース見なくてもいいじゃないかという、突っ込みは極力お控え願いたいところです。

Routing in ASP.NET Web API : Official Microsoft Site

ソースを見て確認するといっても、とっかかりがないとどこから見ていいのかわからないですよね。でも、MVCは昔からActionの特定に使うクラスがあります。ActionSelector。たぶんWeb Apiだとしても同じ名前で実装してると思うので、まずはActionSelectorで検索。

actionselector1

いっぱい出てきた...。でも、System.Web.Http配下にあるはず。なんでって、System.Web.MvcだとMVCのActionSelectorになっちゃって、ApiContoller用じゃないっていうのと、今回aspnetwebstackとして取り込んだ名前空間がSystem.Web.HttpとSystem.Net.Httpらへんだから。

案の定System.Web.Http配下にApiControllerActionSelectorっていうクラスがいます。たぶんこれを使ってActionの特定をしてるんでしょーね。この辺からチェック。

IHttpActionSelectorインターフェースを実装してるクラスになるから、インターフェース定義を見てみる。

   public interface IHttpActionSelector
    {
        /// <summary>
        /// Selects the action.
        /// </summary>
        /// <param name="controllerContext">The controller context.</param>
        /// <returns>The selected action.</returns>
        HttpActionDescriptor SelectAction(HttpControllerContext controllerContext);

        /// <summary>
        /// Returns a map, keyed by action string, of all <see cref="HttpActionDescriptor"/> that the selector can select. 
        /// This is primarily called by <see cref="System.Web.Http.Description.IApiExplorer"/> to discover all the possible actions in the controller.
        /// </summary>
        /// <param name="controllerDescriptor">The controller descriptor.</param>
        /// <returns>A map of <see cref="HttpActionDescriptor"/> that the selector can select, or null if the selector does not have a well-defined mapping of <see cref="HttpActionDescriptor"/>.</returns>
        ILookup<string, HttpActionDescriptor> GetActionMapping(HttpControllerDescriptor controllerDescriptor);
    }
}

これだけか。SelectActionが探す実体っぽいっすね。とりあえずクラスの実装を見る。

        public virtual HttpActionDescriptor SelectAction(HttpControllerContext controllerContext)
        {
            if (controllerContext == null)
            {
                throw Error.ArgumentNull("controllerContext");
            }

            ActionSelectorCacheItem internalSelector = GetInternalSelector(controllerContext.ControllerDescriptor);
            return internalSelector.SelectAction(controllerContext);
        }

またか。ActionSelectorCacheItemっていうクラスが探索実装してるっぽい。リフレクションとか使って探索するはずだから、キャッシュを利用するためにさらにクラスを挟んでるのも、これまでMVC実装と大差ない。

GetInternalSelectorを見てみると、概ねキャッシュ制御。なので、ActionSelectorCacheItemを見る。これ、private内部クラス。他で使うこともないし、外に漏らす必要もないからってことでしょう。

ControllerDescriptorを引数に持つコンストラクタで、Controllerの型からメソッド抽出(有効メソッドチェック判定あり=IsSpecialNameじゃないこととApiControllerの型チェック)。

ここで、ActionDescriptorのWeb API版、ReflectedHttpActionDescriptorを生成。以降これを利用。

ここまではどーでもいいね。準備してるだけじゃん!ここからActionSelectorCacheItem.SelectAction。やっと本質。

まずはRouteDataに”action”が入ってるか判定して値をとっておく。通常ApiControllerの場合、Action名をRouteに登録しないんだけど、登録しても正しくルーティングできるように。直後のコードでRouteDataの”action”が指定されてる場合は、準備しておいたApiControllerのMethodInfoから作り出したアクション一覧の中から、同名のMethod(Actionね)を実行対象として抽出。つまり、Action名はGet/Post/Put/Deleteじゃなきゃいけないというルールじゃないぜっ、てことですね。

// This filters out any incompatible verbs from the incoming action list
actionsFoundByHttpMethods = actionsFoundByName.Where(actionDescriptor => actionDescriptor.SupportedHttpMethods.Contains(incomingMethod)).ToArray();

と、あるとおり、リクエスト時のHttp Methodが抽出条件となります。なので、AcceptVerbsAttributeかHttpGetAttributeなどをActionに指定しておくと、RouteDataに”action”を指定したルーティングの場合、Action探索に引っかかって実行されるってことですね。きっと。

RouteDataの”action”を指定しないルーティングの場合はこっち。

// No {action} parameter, infer it from the verb.
actionsFoundByHttpMethods = FindActionsForVerb(incomingMethod);

今度はFindActionsForVerb。最終的にHttpActionDescriptorで定義されてる、SupportedHttpMethodsをチェックして、リクエスト Http Methodの紐付。

ちょっと横道にそれるけどSupportedHttpMethodsの判定部分も気になるからチェック。今度はReflectedHttpActionDescriptorクラスのGetSupportedHttpMethods。

IActionHttpMethodProviderから取得する属性ベースのHttp Method指定か、アクション名からの規約ベースのHttp Method判定。なるほど。今度はIActionHttpMethodProviderか。深い。と思ったけど、IActionHttpMethodProviderを検索すると出てくるのは↓この子達。

  • AcceptVerbsAttribute
  • HttpDeleteAttribute
  • HttpGetAttribute
  • HttpHeadAttribute
  • HttpOptionsAttribute
  • HttpPatchAttribute
  • HttpPostAttribute
  • HttpPutAttribute

見慣れた属性。Providerといいつつ、Attributeそのもの。それらの属性がついてたら、そこからサポートするHttp Methodだと判定ってことですね。一応HttpMethodsっていうゲッターがあるけど。

大体わかってきましたね。ここでApiControllerActionSelectorのSelectActionに戻る。ここまでのコードで対象となりそうなActionが特定できたので、もう大丈夫な感じがするけど、実はここまでの流れで抽出したHttpActionDescriptorは配列だったりする。まだ、1つに特定してない。なんでかというと、定義としては間違いなんだけど、たとえばHTTP Getに対応するアクションを複数かけちゃうじゃないですか。そんな時にはちゃんとAmbiguousMatchっていうエラーにしたいからですね。MVCもそうでした。

はい!仕組みが分かったところで、サンプル書いて思った通りの動きになるか確認してみましょう!

Web APIテンプレートで作ると作成されるルーティング定義とValuesControllerは以下のようになってますね。

routes.MapHttpRoute(
	name: "DefaultApi",
	routeTemplate: "api/{controller}/{id}",
	defaults: new { id = RouteParameter.Optional }
);
// GET /api/values
public IEnumerable<string> Get()
{
	return new string[] { "value1", "value2" };
}

// GET /api/values/5
public string Get(int id)
{
	return "value";
}

わかりやすくGetだけ取り上げます。

これ、ブラウザでそのままアクセスすると、それぞれ以下のように出てきますね。

/api/values

actionselector2

/api/values/1

actionselector3

うん。普通。

ルールその1:Action名がHttp Methodにそのまま対応する

です。

次。アクションメソッド名をいずれもFindに変更してみます。でも、ルーティングにはまだ"action"を登録しないので、AcceptVerbsかHttpGetかいずれかの属性していが必要になるよね。

// GET /api/values
[HttpGet]
public IEnumerable<string> Find()
{
	return new string[] { "value1", "value2" };
}

// GET /api/values/5
[AcceptVerbs("GET")]
public string Find(int id)
{
	return "value";
}

これで先ほどと同じURLでアクセスする。と、面倒なのでスクリーンショットは乗せないけど、同じ結果です。

ルールその2:Action名が何であれIActionHttpMethodProviderの実装属性を指定していたら、属性指定をHttp Methodに対応する

です。

続いて、FindとGetそれぞれがApiControllerに定義されていた場合どうなるのか見てみる。

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

// GET /api/values/5
public string Get(int id)
{
	return "value";
}

// GET /api/values
[HttpGet]
public IEnumerable<string> Find()
{
	return new string[] { "value1", "value2" };
}

// GET /api/values/5
[AcceptVerbs("GET")]
public string Find(int id)
{
	return "value";
}

actionselector4

エラーです。これがAmbiguousMatchです。

ルールその3:同一Http Methodを解釈するアクションが複数存在する場合はエラーになる

です。ただこれには例外があって、ルーティング登録で”action”指定したものがあって、アクション名を規約や属性だけで判定する場合じゃない場合にはエラーになりません。分かりにくいので実装。

アクション名を含んだルーティングを解釈できるようにRouteTableに登録。Global.asaxに以下の定義を追加。

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

	routes.MapHttpRoute(
		name: "DefaultApiActions",
		routeTemplate: "api/{controller}/{action}/{id}",
		defaults: new { id = RouteParameter.Optional },
		constraints:new {action="[^0-9]+"}
	);

	routes.MapHttpRoute(
		name: "DefaultApi",
		routeTemplate: "api/{controller}/{id}",
		defaults: new { id = RouteParameter.Optional }
	);

	routes.MapRoute(
		name: "Default",
		url: "{controller}/{action}/{id}",
		defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
	);
}

DefaultApiっていう名前で定義されてるのが、プロジェクトテンプレートで定義されるルーティングだけど、その前にDefaultApiActionsっていうのを定義してます。前に定義するのがミソ。ルーティングの解決順は登録順になるので。で、constraintsでactionは数値じゃないっていうルールを付加。これで、/api/values/1の1はactionとして解釈せず、DefaultApiの定義のほうのidとして解釈するルーティングに到達します。

続いて、先ほどのApiControllerのGetとFindのうち、FindにつけたHttpGetとAcceptVerbsを削除しておきましょう。

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

// GET /api/values/5
public string Get(int id)
{
	return "value";
}

// GET /api/values
public IEnumerable<string> Find()
{
	return new string[] { "value1", "value2" };
}

// GET /api/values/5
public string Find(int id)
{
	return "value";
}

今回はアクション名をURLに含めることで、ルーティングするというのを確認するので、以下のURLにそれぞれアクセスします。

/api/values
/api/values/1
/api/values/find
/api/values/find/1

actionselector5

わかりにくいけど、全部同じアプリケーションインスタンスに対してリクエストしてるよ。エラーにならずにそれぞれちゃんと取得できてるね!

ルールその4:ルーティングの定義でactionを指定するようにすると、MVCと同じようにルーティングする

です。

うん!スッキリだね!あと、ApiControllerActionSelectorがIHttpActionSelectorの実装なんだけど、これどこで使われるのか検索してみると、出てくるのはSystem.Web.Http.Services.DefaultServiceResolver(IDependencyResolver実装)クラス。ここで、インターフェースと実装の紐付。ServiceLocator。DefaultServiceResolver自体はinternal classだけど、IDependencyResolverを自分で実装するなら、実装は差し替え可能ってことですね。いっぱい登録してるから見てみるといいと思います。

http://aspnetwebstack.codeplex.com/SourceControl/changeset/view/a0b7fe4a95fa#src%2fSystem.Web.Http%2fServices%2fDefaultServices.cs

※あ。ソース変わってる?んー。最新取得してもこれとちょっと違うなー。どんまい。

で、最初のドキュメントを見てみるとなんて書いてるかなー。

Routing in ASP.NET Web API : Official Microsoft Site

同じこと書いてるじゃないか!っていうオチ。 しかもNonActionについてまで書かれてて完敗。てへぺろ!

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)に対しても、目を光らせておこうと思う次第です。

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とあわせて業務系の人が扱えるようになると、嬉しいですよねー。

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月15日土曜日

TraceListener into MongoDB

たまには週間たけはらブログ。
ASP.NETでTraceListener使ってますか?今まで結構仕込んでおいたんだけど、ファイルやイベントログだと扱いにくいなー、なんて思ってませんか。思ってました。融通効かないなー、と。大規模サイトなんかでSQLServerに入れちゃうと、大変なことにナチャウヨ。
そこでMongoDB。みんな大好きMongoDB。ドキュメントの日本語化も着実に進んでるので、英語なんてー、と気にすることもあんまりないでしょう。そーでもないですか?いろいろ可愛いやつですよ!ログデータの保持なんて、もう、得意中の得意です。保持する構造さえちゃんとしておけば、RDBじゃ処理しにくいものもお気楽に扱えます。用途と使い方次第デスけどね。
Home - Docs-Japanese - 10gen Confluence
MongoDBって何よ?っていうのは、いろいろ検索してね。
MongoDB と NoSQL を試す
MongoDB と NoSQL を試す (第 2 部)
MongoDB と NoSQL を試す (第 3 部)
開発環境で超簡単な使い方はコンソールでmongodを起動しておいて、mongoで中身を確認。
ml
だんだんmongodをサービス起動しておきたくなりますが、最初は単なるプロセス起動。もちろん、内容確認をコンソールのmongoで行うのも、ハッカーみたいな雰囲気あっていいかもしれないけど、そんなの面倒なので実際はGUIのツールを使いましょう。
MongoVUE | Gui tools for MongoDB
ml2
他にもイロイロあるので、気に入ったのを選んで試してみましょう。
Admin UIs – MongoDB
このMongoDBにTraceListenerからmessageを保存するようにしちゃいましょー!
開発するならHTTPでのREST操作(MongoDBに最初からあります)よりも、Driverを使った開発のほうが楽チンぽんです。前までは、いろいろ使い勝手の問題もあったりとかしたけど、いまとなっては標準公開されてるもので十分です。
CSharp Language Center – MongoDB
CSharp Driver Tutorial - MongoDB
英語かよ!どんまい。
マニュアル - Docs-Japanese - 10gen Confluence
なんにせよNuGetで取得できるのが便利なところです。
Official MongoDB C# driver - 1.2 : NuGet gallery
TraceListenerって自分で用意したことなかったんだけど、TraceListenerクラス派生でいいってことなので、お手軽ですね。必須なoverrideも2個だけ。
public override void Write(string message)
{
  // ...
}

public override void WriteLine(string message)
{
  // ...
}
やれそうですね。
えいやっ!
using System.Configuration;
using System.Diagnostics;
using MongoDB.Bson;
using MongoDB.Driver;

namespace MongoListener
{
  public class MongoDbTraceListener : TraceListener
  {
    private readonly MongoServer _server;
    private readonly MongoDatabase _database;
    private readonly string _collectionName;

    public MongoDbTraceListener() : this("TraceData") { }
    public MongoDbTraceListener(string initializeData)
    {
      _collectionName = initializeData;
      var serverName = ConfigurationManager.AppSettings["MongoDb:Server"];
      var databaseName = ConfigurationManager.AppSettings["MongoDb:Database"];

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

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

      _server = MongoServer.Create(serverName);
      _database = _server.GetDatabase(databaseName);
    }

    private void Insert(BsonDocument document)
    {
      var collection = _database.GetCollection(_collectionName);
      collection.Insert(document);
    }

    private void InternalWrite(string message)
    {
      var document = new BsonDocument {{"Message", message}};
      Insert(document);
    }

    private void InternalWriteObject(object o)
    {
      var document = new BsonDocument();
      
      var type = o.GetType();
      foreach(var prop in type.GetProperties())
      {
          document.Add(prop.Name, prop.GetValue(o,new object[]{}).ToString());
      }
      Insert(document);
    }

    public override void Write(string message)
    {
      InternalWrite(message);
    }

    public override void WriteLine(string message)
    {
      InternalWrite(message);
    }

    public override void WriteLine(object o)
    {
      var type = o.GetType();
      if (type.Name.StartsWith("<>") && type.Name.Contains("AnonymousType"))
      {
          InternalWriteObject(o);
          return;
      }
      InternalWrite(o.ToString());
    }
  }
}
できたー。
後は、web.configに書いて使えるようにするだけですね!
  <appSettings>
    <add key="webpages:Version" value="1.0.0.0"/>
    <add key="ClientValidationEnabled" value="true"/>
    <add key="UnobtrusiveJavaScriptEnabled" value="true"/>
    
    <add key="MongoDb:Server" value="mongodb://localhost"/>
    <add key="MongoDb:Database" value="TraceListener"/>
  </appSettings>
<system.diagnostics>
  <trace autoflush="false" indentsize="4">
    <listeners>
      <add name="mongoListener" 
           type="MongoListner.MongoDbTraceListener, MongoListner"
           initializeData="MyTrace" />
      <remove name="Default" />
    </listeners>
  </trace>
</system.diagnostics>
  <system.web>
    <trace enabled="true"/>
あとはSystem.Diagnostics.Trace.Write/WriteLineです!すでにたくさん仕込んでいる場合にはコレでOK。
ASP.NET MVC3標準プロジェクトを作成して、MongoDbTraceListenerクラスを作成し、HomeControllerのIndexアクションにTraceを書いてみましょう。
ml4
わーお!素敵!!
MongoDBが違うマシンだったり(本番はそうしましょう)、Database名を変更したいときはappSettingsの値を変えてね。コレクション(テーブル相当)の名前を変えたかったらtrace/listnersのinitializeDataで指定してね。
ちなみに、これだけだとつまんないので、ここから少し拡張してパフォーマンス計測してみましょう。まずはIHttpModuleを実装して、リクエストの処理時間を計測するようにしてみます。
mvc-mini-profiler - A simple but effective mini-profiler for ASP.NET and WCF - Google Project Hosting
↑パフォーマンス計測ならこんな素敵なもの(NuGit.orgで実物見れますよ~)もありますが...。これから新規ならこっちのほうが...。いや、言うまい。
ml3
どりゃ!
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Web;

namespace MongoListner
{
  public class PerformanceTraceModule : IHttpModule
  {
    private string ItemKey = "_mongoDbTraceStart";
    private readonly string _serverName = "";

    public PerformanceTraceModule()
    {
      _serverName = Environment.MachineName;
    }

    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
      {
        Server = _serverName,
        RequestAt = startTime,
        Method = httpContext.Request.HttpMethod,
        Status = httpContext.Response.StatusCode,
        RawUrl = httpContext.Request.Url.ToString(),
        Milliseconds = (DateTime.Now - startTime).Milliseconds
      });
    }

    public void Dispose()
    {
    }
  }
}
これを利用するためにsystem.webServer/modulesに登録します。
  <system.webServer>
    <validation validateIntegratedModeConfiguration="false"/>
    <modules runAllManagedModulesForAllRequests="true">
      <add name="mongoListener" 
           type="MongoListner.PerformanceTraceModule, MongoListner" 
           preCondition="integratedMode"/>
    </modules>
  </system.webServer>
実行してみます。
ml5
BeginRequestからEndRequestの間を計測するものですが490msって...。遅すぎ!
ml6
と、思ったらF5リロードの2回目は5ms。そーだろそーだろ。ん?よく見たらStatusとMillisecondsがstringになってるー。TraceListenerのInternalWriteObjectでToStringしてたね。失敬。そこは修正しましょう。書きながら作る、作りながら書く!
private void InternalWriteObject(object o)
{
  var document = new BsonDocument();
  
  var type = o.GetType();
  foreach(var prop in type.GetProperties())
  {
    var value = BsonValue.Create(prop.GetValue(o,new object[]{}));
    document.Add(prop.Name, value);
  }
  Insert(document);
}
これでちゃんと型どおり。BsonValueにはいろいろあるのでドキュメント参照してみてください。
このHttpModuleがあればすべてのリクエストの処理時間が計測できますね!アクセスログのTimeTakenとダダカブリ。どんまい。
IIS 6.0 ログ ファイルの Time Taken フィールドは何を表し、何を意味していますか。 : IIS 6.0 についてよく寄せられる質問
ASP.NET MVCならActionFilter属性を使ってControllerでの処理時間とViewの処理時間をそれぞれ別で計測できてなお嬉しいはず。
そいやっ!
using System;
using System.Diagnostics;
using System.Web.Mvc;

namespace MongoListner
{
  public class PerformanceTraceAttribute : ActionFilterAttribute
  {
    private string ItemKey = "_mongoDbFilterTraceStart";
    private readonly string _serverName = "";

    public PerformanceTraceAttribute()
    {
      _serverName = Environment.MachineName;
    }

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
      filterContext.HttpContext.Items[ItemKey] = DateTime.Now;

      base.OnActionExecuting(filterContext);
    }

    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
      var startTime = (DateTime)filterContext.HttpContext.Items[ItemKey];

      Trace.WriteLine(new
      {
          Server = _serverName,
          ProcessAt = DateTime.Now,
          RawUrl = filterContext.HttpContext.Request.Url.ToString(),
          Method = filterContext.HttpContext.Request.HttpMethod,
          Controller = filterContext.RouteData.GetRequiredString("controller"),
          Action = filterContext.RouteData.GetRequiredString("action"),
          Milliseconds = (DateTime.Now - startTime).Milliseconds
      });

      base.OnActionExecuted(filterContext);
    }

    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
      filterContext.HttpContext.Items[ItemKey] = DateTime.Now;

      base.OnResultExecuting(filterContext);
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
      var startTime = (DateTime)filterContext.HttpContext.Items[ItemKey];
      var viewName = (string) null;
      if (filterContext.Result is ViewResult)
      {
        viewName = (filterContext.Result as ViewResult).ViewName;
      }
      Trace.WriteLine(new
      {
        Server = _serverName,
        ProcessAt = DateTime.Now,
        RawUrl = filterContext.HttpContext.Request.Url.ToString(),
        Result = viewName ?? filterContext.Result.GetType().Name,
        Controller = filterContext.RouteData.GetRequiredString("controller"),
        Action = filterContext.RouteData.GetRequiredString("action"),
        Milliseconds = (DateTime.Now - startTime).Milliseconds
      });

      base.OnResultExecuted(filterContext);
    }
  }
}
これを有効にするためにGlobal Filterに追加しておきましょう。
あとは実行するだけ!
ml7
ズームして見てね。
Controllerでの実行時間が223ms、ActionResultの実行時間が180ms、HttpModuleでの計測時間が497ms。差が94msありますね。そんなもんでしょう。ちなみにコレが初回実行時の計測で、2回目は↓。
ml8
Controllerでの実行時間が0ms、ActionResultの実行時間が0ms、HttpModuleでの計測時間が10ms。差が10ms。これまた、そんなもんでしょう。
ml9
こんな感じです!楽しいですね!
途中、MongoDBに入れるDocumentのカラムを変更したりしてるけど、MongoDB側へは何も手を加える必要がないんです。ドキュメント単位(テーブルなら行単位)にカラム構成が変更されてもお構いなしです。ちなみにサーバーさえいればDatabaseもCollectionも初回アクセス時に勝手に作られるので準備は不要。この手軽さと、レスポンス性能の高さがMongoDBの魅力です。
今回のプロジェクト一式は↓こちら。ローカルにMongoDBさえ入っていればそのまま動くはず。

※ファイルを小さくするために、packages/mongocsharpdriver.1.2を消してます。