2012年12月28日金曜日

なんかかんか

すっかりブログ、書いてないね。前回は7/30だってさ。

もう今年も終わっちゃうねー。

今年もいろいろありました。

来年もいろいろあるでしょうね。

書きたいモチベーションが上がってきたら、ブログ書く。きっと書く。いろいろと。

Cookieのキャッシュで認証がとんでもないことになるとか、YSOD誰が出すんでしたっけ、とか?

いやいや、そーじゃなくてゴルフ始めたんですよ。今まで使ってなかった筋肉使って、さらなるパンプアップ!ベン・ホーガンってすごいね。

ホッケーも楽しくやってるんだよ。新しい友だちできたり、外人が「ガイコクジンと言え!」とか面白い。

仕事はなんかよくわかんなくなってきたから一旦整理する。何する人なのかイマイチ中途半端な事になってるし。

あとはー、そーだなー。今後も楽しくやってくよ!

2012年7月30日月曜日

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

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

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

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

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

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

    return title;
  }
}

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

wcftest1

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

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

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

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

  return View();
}

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

wcftest2 wcftest3

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

wcftest4

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

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

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

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

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

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

  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    return View("Index");
  }
}

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

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

 

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

    return list[url];
  }
}

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

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

以上!

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

wcftest5

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

2012年7月16日月曜日

c#でインスタンスをFreeze

クラスをジェネレートする際にメタデータでFreezableかどうか保持して作るのが楽ちんなのかなー。

Castle.DynamicProxyとPostSharpで実装してみました。

DynamicProxy | Castle Project

どうやってインストールするんですか!と、思ったけど、NuGetですね。Castle.Core で。

The Creators of PostSharp – SharpCrafters

こっちもNuGetで。ただ、無料のBASICライセンスを別途取得する必要がありました。むかーし、登録したアカウントが邪魔してちょっと手間取った...。

Castle.DynamicProxy

まずはCastle。ベケットは出てこないよ。

Dynamic Proxy Tutorial « Krzysztof Koźmic on software

ここに書いてるとおりです!

ようするに動的に派生クラスを生成するFactoryを使ってインスタンスを作るのと、virtualなプロパティ/メソッドに対してインターセプトできるようになる、っていうのがCastle.DynamicProxyです。overrideね。

    public interface IFreezable
    {
        bool IsFrozen { get; }
        void Freeze();
    }

Freezeできるようにするためのインターフェース。CastleでもPostSharpでも同じのを使うよ。

    public class Animal
    {
        public virtual string Name { get; set; }
        public virtual int Age { get; set; }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", Name, Age);
        }
    }

こんなモデルクラスを用意します。もちろんvirtualで。だけど、これ自体がIFreezableを実装する必要はないので注意。

ここからは、ちょっと長めのコードになるけど、簡単に説明するとIFreezableを実装したInterceptorを実装し、それを注入したProxyインスタンスを生成。Proxyインスタンスではvirtualなものにアクセスした場合、Interceptorが実行されるのでその時都度Freezeされたかチェックする、っていう流れです。

proxy_pipeline

こんなイメージ。公式サイトより

    class FreezableInterceptor : IInterceptor, IFreezable
    {
        private bool _isFrozen;

        public void Freeze()
        {
            _isFrozen = true;
        }

        public bool IsFrozen
        {
            get { return _isFrozen; }
        }

        public void Intercept(IInvocation invocation)
        {
            if (_isFrozen && invocation.Method.Name.StartsWith("set_", StringComparison.OrdinalIgnoreCase))
            {
                throw new FreezableException("object frozen!");
            }

            invocation.Proceed();
        }
    }

Interceptorが↑これ。続いてヘルパーのコード。

    static class Freezable
    {
        private static readonly ProxyGenerator _generator = new ProxyGenerator();

        public static bool IsFreezable(object obj)
        {
            return AsFreezable(obj) != null;
        }

        private static IFreezable AsFreezable(object target)
        {
            if (target == null)
                return null;
            var accessor = target as IProxyTargetAccessor;
            if (accessor == null)
                return null;
            return accessor.GetInterceptors().OfType<FreezableInterceptor>().FirstOrDefault();
        }

        public static bool IsFrozen(object obj)
        {
            var freezable = AsFreezable(obj);
            return freezable != null && freezable.IsFrozen;
        }

        public static void Freeze(object freezable)
        {
            var interceptor = AsFreezable(freezable);
            if (interceptor == null)
                throw new FreezableException("Not freezalbe!");
            interceptor.Freeze();
        }

        public static TFreezable CreateInstance<TFreezable>() where TFreezable : class, new()
        {
            var freezableInterceptor = new FreezableInterceptor();
            var proxy = _generator.CreateClassProxy<TFreezable>(freezableInterceptor);
            return proxy;
        }
    }

こっちがちょっと長い。ProxyインスタンスはIProxyTargetAccessorを実装してるので、それを利用して、Interceptorを取得してFreezeしたりIsFrozeしたりです。

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void Freezeさせる()
        {
            var rex = Freezable.CreateInstance<Animal>();
            rex.Name = "Rex";
            Console.WriteLine(Freezable.IsFreezable(rex)
                                  ? "Rex is freezable!"
                                  : "Rex is not freezable. Something is not working");
            Console.WriteLine(rex.ToString());
            Console.WriteLine("Add 50 years");
            rex.Age += 50;
            Console.WriteLine("Age: {0}", rex.Age);

            Freezable.Freeze(rex);
            Console.WriteLine("Frozen? : " + Freezable.IsFrozen(rex));
            try
            {
                rex.Age++;
                Console.WriteLine("あれー?変更できるー。");
            }
            catch
            {
                Console.WriteLine("Frozen!!");
            }
        }

        [TestInitialize]
        public void Initialize()
        {
            var instance1 = new Animal();
            var instance2 = Freezable.CreateInstance<Animal>();            
        }

        [TestMethod]
        public void 普通のインスタンス作る()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000;i++ )
            {
                var instance = new Animal();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms",sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンス作る()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                //Freezable.Freeze(instance);
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンス作ってFreezeする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                Freezable.Freeze(instance);
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void 普通のインスタンスでプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Animal();
                instance.Age = 200;
                instance.Name = "Foo";
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンスでプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                instance.Age = 200;
                instance.Name = "Foo";
                //Freezable.Freeze(instance);                
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }
    }

動かしましょう。

  • Freezeさせる[0:00.218] Success
  • Proxyのインスタンスでプロパティアクセス[0:00.106] Success
  • Proxyのインスタンス作ってFreezeする[0:00.117] Success
  • Proxyのインスタンス作る[0:00.109] Success
  • 普通のインスタンスでプロパティアクセス[0:00.003] Success
  • 普通のインスタンス作る[0:00.002] Success

こんな感じです。Proxy経由は若干遅くなりますね。TestInitializeでわざわざインスタンス作ってるのは、Proxyの生成をここでやらせるためです。こうしとけば、各テストの計測では実行分のみの時間になるから。

DynamicProxyで、うーんと思ったのはvirtualの強制。まぁねー。クラス生成するんだし、そーだよなー、とは思いつつも。

PostSharp

続いてPostSharp。こっちはアセンブリを書き換えてインターセプト。クラス生成よりも強力。ちょっと使い方が分かりにくいところもあるけど、大体なんでもできちゃう系。

今回はInstanceLevelAspectでモデルクラスにインターフェース(IFreezable)を実装し、LocationInterceptionAspectでプロパティアクセスをインターセプト。OnSetValueでIsFrozenを確認してるよ。

    [Serializable]
    [IntroduceInterface(typeof(IFreezable))]
    class FreezableAttribute : InstanceLevelAspect, IFreezable
    {
        [NonSerialized]
        private bool _isFrozen;
        public bool IsFrozen
        {
            get { return _isFrozen; }
        }

        public void Freeze()
        {
            _isFrozen = true;
        }
    }

まずは、既存クラスにインターフェースを後付Mixin

    [Serializable]
    class FrozenAttribute : LocationInterceptionAspect
    {
        public override void OnSetValue(LocationInterceptionArgs args)
        {
            var freezable = args.Instance as IFreezable;
            if (freezable != null && freezable.IsFrozen)
            {
                throw new FreezableException(string.Format("frozen {0}", args.LocationFullName));
            }

            args.ProceedSetValue();
        }
    }

インターフェース実装されてるなら、Freezableだからプロパティアクセスにインターセプト。

これだけ。これだけだけど、これらを1個のAspectにしようとしてスゴイ遠回りした。結果、インターフェースのMixinとインターセプトは同一Aspectにできない、みたい。できる方法があるなら教えて!

    [Freezable, Frozen]
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", Name, Age);
        }
    }

モデルね。属性に2つ書かないといけないのが残念ポイントだけど、virtualでもないし、これならCollection/List/DictionaryいろいろReadOnlyにしてしまえます。ね。

    [TestClass]
    public class UnitTest2
    {
        [TestMethod]
        public void Freezeさせる()
        {
            var rex = new Person();
            rex.Name = "Rex";
            Console.WriteLine(rex.ToString());
            Console.WriteLine("Add 50 years");
            rex.Age += 50;
            Console.WriteLine("Age: {0}", rex.Age);

            rex.Freeze();
            Console.WriteLine("Frozen? : " + rex.IsFrozen());

            try
            {
                rex.Age++;
                Console.WriteLine("あれー?変更できるー。");
            }
            catch
            {
                Console.WriteLine("Frozen!!");
            }
        }

        [TestMethod]
        public void Newする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Freeze();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void NewしてFreezeする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Freeze();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Newしてプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Age = 200;
                instance.Name = "Foo";
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }
    }

こんなテストで実行。

  • Freezeさせる[0:00.084] Success
  • NewしてFreezeする[0:00.015] Success
  • Newしてプロパティアクセス[0:00.012] Success
  • Newする[0:00.004] Success

素のクラスに比べればそれでも遅くはなるけど、DynamicProxyよりは高速。そりゃそーだ。アセンブリ書き換えてんだから。

1個の属性でできるとうれしいなー。

いずれにせよ、モデルクラス1個ずつFreezableをプロパティごとに実装なんてコード生成以外にありえないと思うじゃない。でも、インターセプトするなら、それも何とか乗り越えられる。その際にProxyクラスにするか、アセンブリ書き換えるかは好みでいいとは思うけど、既存コードほとんどいじらず(Factoryでインスタンス作らなくていい)に済むし早いしでPostSharpが使いやすいかなーと思う海の日。

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に登録しましょう。

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

CompiledQueryの積極利用

とうとうゴルフ、始めました。まだ練習場でパカパカうつだけだけど。ホッケーとボルダリングとゴルフと筋トレ。多趣味になってきたね!

LINQ to SQL/LINQ to Entityを利用してると、CompiledQueryを使いたいけど、なんか面倒くさいよ、ってことありますよね。ないですか。いまさらか!的な内容ですけど。

CompiledQueryってなに~?という話もあるかもしれないけど、どういうメリットがあるのかというと以下のサイトに書いてるとおり。

LINQ To SQL Very Slow Performance Without Compile (CompileQuery) « Er. alokpandey's Blog

早いんです。なぜ早くなるのかというと、LINQ to SQL/LINQ to Entityの実行時っていろいろLINQ解析して、SQL組み立てるまでの事前準備(IQueryProvider.CreateQuery)と、SQLを実行してマッピングする実行処理(IQueryProvider.Execute)に分けて考えて、この事前処理の部分を使いまわすからですよね。QueryProvider大変だねー。

IQueryProvider インターフェイス (System.Linq)
チュートリアル : IQueryable LINQ プロバイダーの作成

.NET 3.5までだとLINQ to Entityって使えにくかった(シーケンスとの組み合わせとか)し、激烈に遅かったけど、.NET 4からはその辺ずいぶん改善されてて、さらに次のEF5に至っては、パフォーマンスもかなり向上するようで、楽しみでしょうがないっす。

Sneak Preview: Entity Framework 5.0 Performance Improvements - ADO.NET team blog - Site Home - MSDN Blogs

で、CompiledQueryを使うときの面倒くささって、あれですよ、事前にイロイロ用意してFuncデリゲートに登録しておかないといけないところですよね。

LINQ to SQL : Understanding Compiled Query - Wriju's BLOG - Site Home - MSDN Blogs

最初はAd Hocに書いて、パフォーマンス的に問題になるところを、ちょこちょこCompiledQueryにしていく、っていうのが王道なんでしょーか。面倒ですね。面倒です。だからQuery実行を勝手にキャッシュしてくれるようになるEF5のアプローチは大変興味深く、すぐにでも適用してしまいたいと思わずにはいられない。

DAL書き換えるのも面倒だしー。Repository書き換えるのも大変だしー。楽ちんにするなら、Ad Hocなクエリの部分だけ以下のように書くと、CompiledQueryにもしてくれてキャッシュもすると今までのコードもそのまま流用しやすくていいなー。

このアプローチを紹介してくれてるのが以下のエントリ。

Linq to Sql CompiledQuery container - Mitsu's blog - Site Home - MSDN Blogs

素敵ですね!

面倒なこと考えずに使えますね!でも、ちょっと待って。ちょっとだけ残念なのがSequence。CompiledQuery対象のLINQクエリの中で、パラメータで渡したIEnumerable的(Sequence parameter)な変数参照を行ってる場合、CompiledQueryは正しく機能しない。既知です既知!

linq to sql - Compiled queries and "Parameters cannot be sequences" - Stack Overflow

パッと見わかりにくいですね。どういう意味か。なので、先のエントリで提示されてるMyQueriesを使ったサンプルを使って確認。

class TestCQ
{
  public void Test1()
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test1", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where new[] { "Red" }.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test1:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test2()
  {
    var context = new AdventureWorksDataContext();
    var localArray = new[] { "Red" };
    var cq = MyQueries.Get("Test2", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where localArray.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test2:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test3(string[] array)
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test3", 
      (AdventureWorksDataContext db) =>
      from m in db.Product where array.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test3:" + cq(context).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }

  public void Test4(string[] array)
  {
    var context = new AdventureWorksDataContext();
    var cq = MyQueries.Get("Test4", 
      (AdventureWorksDataContext db, string[] option) =>
      from m in db.Product where option.Contains(m.Color) select m);
    try
    {
      Console.WriteLine("Test4:" + cq(context,array).Count());
    }
    catch (Exception ex)
    {
      Console.WriteLine(ex);
    }
  }
}

 

var cq = new TestCQ();

cq.Test1();
cq.Test2();
cq.Test3(new[] { "Black", "White" });
cq.Test3(new[] { "Blue" });
cq.Test4(new[] { "Black", "White" });
cq.Test4(new[] { "Blue" });

Test1はクエリの中で配列を作成。Test2はメソッドの中で宣言した配列をキャプチャ。Test3はメソッド引数で渡した配列をキャプチャ。最後のTest4はメソッド引数で渡した配列をCompiledQueryのパラメータとして利用(キャプチャしない)。

cq1

Test3実行が同一値。Test4実行時に「パラメーターをシーケンスにすることはできません。」。

ParameterExpressionだとダメなんだって。

.NET4でビルドするとExpressionをDebugViewで簡単に見れるから見てみよう!

  • Test1
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
           .NewArray System.String[] {
                "Red"
            }
    ,
            $m.Color)
    }

  • Test2
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
           .Constant<FastLinq.Program+TestCQ+<>c__DisplayClass42>(FastLinq.Program+TestCQ+<>c__DisplayClass42).localArray,
            $m.Color)
    }

  • Test3
    .Lambda #Lambda1<System.Func`2[FastLinq.Data.LS.AdventureWorksDataContext,System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(FastLinq.Data.LS.AdventureWorksDataContext $db)
    {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
            .Constant<FastLinq.Program+TestCQ+<>c__DisplayClass44>(FastLinq.Program+TestCQ+<>c__DisplayClass44).array,
            $m.Color)
    }

  • Test4
    .Lambda #Lambda1<System.Func`3[FastLinq.Data.LS.AdventureWorksDataContext,System.String[],System.Linq.IQueryable`1[FastLinq.Data.LS.Product]]>(
        FastLinq.Data.LS.AdventureWorksDataContext $db,
        System.String[] $option) {
        .Call System.Linq.Queryable.Where(
            $db.Product,
            '(.Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>))
    }

    .Lambda #Lambda2<System.Func`2[FastLinq.Data.LS.Product,System.Boolean]>(FastLinq.Data.LS.Product $m) {
        .Call System.Linq.Enumerable.Contains(
            $option,
            $m.Color)
    }

太字のとこが違うとこ。Test4はParameterExpression。

ちなみに、Test4の使い方をしないなら、以降の話はすっ飛ばしてもらっても大丈夫。MyQueriesを使うだけでパフォーマンスは劇的に向上します。LINQ to SQLに関して言えば.NET3.5で約3倍、.NET4で約2倍。LINQ to Entityだとあんまり効果なし。効果ってどこの効果かというと、CompiledQueryをキャッシュしたときの効果ね。

訳あって、Test4のパターンを利用する必要があるので、しょうがなくExpressionVisitorを使うことにしました。とはいっても、ParameterExpressionをConstantExpressionに置き換えることでTest4もうまく動くはず。実行時のパラメータの値でExpressionキャッシュしたい(じゃないと、SQLに変換したときのParameterの数が違うことになる)のと、CompiledQueryの作成とQueryの実行が離れた場所にあるっていう都合もあって、ちょっとわかりにくいコードになったんだけど、以下のような感じです。T4です。

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;

namespace FastLinq
{
	public static class To
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>
		
        public static Expression<Func<<#=generics#>TR>> Expression<<#=generics#>TR>(Expression<Func<<#=generics#>TR>> expression)
        {
            return expression;
        }
	<# } #>
    }

    public abstract class QueryBase<TDB>
    {
        private static Dictionary<string, object> _cachedQuery = new Dictionary<string, object>();
        private static ReaderWriterLockSlim _locked = new ReaderWriterLockSlim();

	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n).ToArray()) + ",";#>
		
        public abstract Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query);
	<# } #>

		public static void Clear()
		{
			_cachedQuery.Clear();
		}

        private int GetOptionHash<T>(T option)
        {
            var values = option as IEnumerable;
            if (values != null)
            {
                return string.Join("\r\n", values.OfType<object>().Select(v => v.ToString()).ToArray()).GetHashCode();
            }
            return option.GetHashCode();
        }

		private object GetCache(string key, Func<object> compiledQueryFunctor)
		{
            _locked.EnterUpgradeableReadLock();
            try
            {
                object cachedQuery;
                if (_cachedQuery.TryGetValue(key, out cachedQuery))
                    return cachedQuery;

                var compiedQuery = compiledQueryFunctor();
                try
                {
                    _locked.EnterWriteLock();
                    if (!_cachedQuery.ContainsKey(key))
                    {
                        _cachedQuery[key] = compiedQuery;
                    }
                }
                finally
                {
                    _locked.ExitWriteLock();
                }
                return compiedQuery;
            }
            finally
            {
                _locked.ExitUpgradeableReadLock();
            }
        }

	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>
		<#var options = i==0 ? "" : "," + string.Join(",", Enumerable.Range(1,i).Select(n=>"option"+n));#>
		<#var formats = i==0 ? "" : ":" + string.Join(":", Enumerable.Range(1,i).Select(n=>"{" + n + "}"));#>
		<#var hashs = i==0 ? "" : "," + string.Join(",", Enumerable.Range(1,i).Select(n=>"GetOptionHash(option" + n + ")"));#>

		public Func<TDB, <#=generics#>TR> Fast<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query) where TR : class
        {
            Func<TDB, <#=generics#>TR> wrapper = (context<#=options#>) =>
            {
			<# if (i==0) { #>

				var key = string.Format("{0}", query.ToString().GetHashCode());
			<# } else { #>
				
				var replaces = new Dictionary<string, Expression>{
					<# for(var j=1; j<=i; j ++) {#>{query.Parameters[<#=j#>].Name, Expression.Constant(option<#=j#>)},
					<# } #>
				};
			
                query = new ParameterToConstantVisitor().Replace(query, replaces) as Expression<Func<TDB, <#=generics#>TR>>;
				var key = string.Format("{0}<#=formats#>", query.ToString().GetHashCode()<#=hashs#>);
			<# } #>

				var compiledQuery = GetCache(key, ()=>Compiled(query));
                return (compiledQuery as Func<TDB, <#=generics#>TR>)(context<#=options#>);
            };

            return wrapper;
        }
	<#}#>
    }
}

さらに以下のクラスを用意。

LINQ to SQL

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Linq.Expressions;

namespace FastLinq.LinqToSql
{
    public class Query<TDB> : QueryBase<TDB>
        where TDB : System.Data.Linq.DataContext
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>

		public override Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query)
        {
            return System.Data.Linq.CompiledQuery.Compile(query);
        }
	<#}#>
	
    }
}

LINQ to Entity

<#@ template debug="false" hostspecific="false" language="C#" #>
<#@ output extension=".cs" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Collections.Generic" #> 
using System;
using System.Linq.Expressions;

namespace FastLinq.LinqToEntity
{


    public class Query<TDB> : QueryBase<TDB>
        where TDB : System.Data.Objects.ObjectContext
    {
	<# for (var i=0;i<4;i++ ) {#>
		<#var generics = i==0 ? "" : string.Join(",", Enumerable.Range(1,i).Select(n=>"T"+n)) + ",";#>

		public override Func<TDB, <#=generics#>TR> Compiled<<#=generics#>TR>(Expression<Func<TDB, <#=generics#>TR>> query)
        {
            return System.Data.Objects.CompiledQuery.Compile(query);
        }
	<#}#>
	
    }
}

以下のようなテストで計測。

 

var options = new {Colors = new List<string> {"Red"}, City = "Bothell", CompanyName = "Bike"};

var query = new LinqToSql.Query<AdventureWorksDataContext>();
var exp1a = To.Expression((AdventureWorksDataContext db, int option) => 
  from m in db.Product where options.Colors.Contains(m.Color) select m);
var exp1b = To.Expression((AdventureWorksDataContext db, List<string> option) => 
  from m in db.Product where option.Contains(m.Color) select m);
var exp2 = To.Expression((AdventureWorksDataContext db, string option) => 
  from m in db.Address where m.City == option select m);
var exp3 = To.Expression((AdventureWorksDataContext db, string option) => 
  from m in db.Customer where m.CompanyName.StartsWith(option) select m);

using (var connection = new System.Data.SqlClient.SqlConnection(
 ConfigurationManager.ConnectionStrings["AdventureWorks"].ConnectionString))
using (var context = new AdventureWorksDataContext(connection))
{
  context.ObjectTrackingEnabled = false;
  context.DeferredLoadingEnabled = false;
  Test("Ad hoc", () =>
  {
    (from m in context.Product 
     where options.Colors.Contains(m.Color) 
     select m).FirstOrDefault();
    (from m in context.Product 
     where new List<string> { "Red" }.Contains(m.Color) 
     select m).FirstOrDefault();
    (from m in context.Address 
     where m.City == options.City 
     select m).FirstOrDefault();
    (from m in context.Customer 
     where m.CompanyName.StartsWith(options.CompanyName) 
     select m).FirstOrDefault();
  });

  Test("Expression", () =>
  {
    exp1a.Compile()(context, 0).FirstOrDefault();
    exp1b.Compile()(context, new List<string> { "Red" }).FirstOrDefault();
    exp2.Compile()(context, options.City).FirstOrDefault();
    exp3.Compile()(context, options.CompanyName).FirstOrDefault();
  });

  Test("Fast 1", () =>
  {
    query.Fast(exp1a)(context, 1).FirstOrDefault();
    query.Fast(exp1b)(context, new List<string> { "Red" }).FirstOrDefault();
    query.Fast(exp2)(context, options.City).FirstOrDefault();
    query.Fast(exp3)(context, options.CompanyName).FirstOrDefault();
  });

  Test("Fast 2", () =>
  {
    query.Fast(
      (AdventureWorksDataContext db, int option) => 
        from m in db.Product where options.Colors.Contains(m.Color) select m
    )(context, 2).FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, List<string> option) => 
        from m in db.Product where option.Contains(m.Color) select m
    )(context, new List<string> { "Red" }).FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, string option) => 
        from m in db.Address where m.City == option select m
    )(context, "Bothell").FirstOrDefault();
    query.Fast(
      (AdventureWorksDataContext db, string option) => 
        from m in db.Customer where m.CompanyName.StartsWith(option) select m
    )(context, "Bike").FirstOrDefault();
  });

結果。

cq3

ちょっとわかりにくいけど。full4って書いたのが、DataContextともに.NET4。4+3.5がテストコードが.NET4でDBが.NET3.5。full3.5がいずれも.NET3.5。青が100回で赤が1000回実行したときの時間。

なので、Ad Hocと比較するとCompiledQueryキャッシュ実装だと.NET4の場合で概ね2倍。.NET3.5で概ね2.5倍。さすがCompiledQueryですね!パラメータのバリエーションがそれほど多くなかったり、参照が圧倒的に多いアプリケーション(WebでCMS的なものだったり、WebMatrix使ったWebPagesの実装だったりだと使いやすいかも)の場合、顕著にレスポンスが早くなります。

LINQ To SQL Very Slow Performance Without Compile (CompileQuery) « Er. alokpandey's Blog

Compiled Queries in Entity Framework : Don't Be Iffy

.NET 3.5の場合はExpressionVisitorを以下からコピペしておきましょう。Expressionとかマジ勘弁。

方法 : 式ツリー ビジタを実装する

是非どーぞー。ソースは以下から。

takepara/FastLinq · GitHub

ちなみにこれに加え、さらにDataキャッシュも加えると、ローカルDBに対してだとさらに20%くらい早くなって、ネットワーク越しだと2~3倍

目指せ、スケーラブルWebサイト!

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

dotnetConf2015 Japan

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