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についてまで書かれてて完敗。てへぺろ!

dotnetConf2015 Japan

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