ラベル 開発 の投稿を表示しています。 すべての投稿を表示
ラベル 開発 の投稿を表示しています。 すべての投稿を表示

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月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年1月2日月曜日

HttpBench on GitHub

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

HttpBench – GitHub

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

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

hb3

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

hb4

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

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

hb5 hb6

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

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

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

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

hb7

↑-c 200指定。

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

2011年8月19日金曜日

HighPerformanceSessionStateProvider

ASP.NET Univarsal Providers のセッションプロバイダを使ってみる (2) « ブチザッキ

カメさんのこのエントリーに対してこんなコメントをしてみたわけですが、具体的なコードを提示しないで案だけ出すって、そんな失礼なことがあっちゃいけねー。江戸っ子なら宵越しの銭はもってちゃいけねー。江戸っ子じゃないんですけどね。

まずは現状確認。カメさんのコードをいただきつつ。ASP.NET MVC3のサイトをデフォルトで作成。ASP.NET Universal ProvidersをNuGetでインストール。web.configでSessionStateの設定をCookieless="UseUri"とTimeout="1"に変更(検証が簡単だからね)。

using System;
using System.Net;

namespace ConsoleApplication1
{
  class Program
  {
    static void Main(string[] args)
    {
      System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();

      GetPage();

      int count = int.Parse(args[0]);

      for (int i = 0; i < count; i++)
      {
        sw.Reset();
        sw.Start();
        var success = GetPage();
        sw.Stop();
        Console.ForegroundColor = success ? ConsoleColor.White : ConsoleColor.Red;
        Console.WriteLine("{0}\t{1}\t{2}\t{3}", 
            DateTime.UtcNow.ToString("yyyy/MM/dd hh:mm:ss.fff"), 
            sw.Elapsed, i, success ? "OK" : "NG");
      }
    }

    static private bool GetPage()
    {
      var result = true;
      try
      {
        var request = WebRequest.Create("http://localhost:52855/");
        {
          using (var response = request.GetResponse())
          {

          }
        }
      }
      catch
      {
        result = false;
      }

      return result;
    }
  }
}

ずるくないもん!オマージュだもん!

コレとMVCサイトで実行した結果(クリックでズーム)。

up1

セッションを生成するコンソールは3個でCPUはMAX。白行はエラーなく進んだところで、赤行はASP.NET側でエラーになったところ。

up2

1分でセッションは切れるので、1分後からドカドカ古いセッションの削除が始まります。

概ね680セッションあたりで飽和。エラーと正常実行を繰り返す感じです。切ないですね。セッションの削除が同時実行されててんやわんやな例外。

これを解消するために、InitializeRequestをoverrideしたSessionStateProviderクラスを定義します。

やることはSessionsテーブルにExpires列のインデックスがなければ作成と、セッション初期化時の有効期限切れセッションの削除をEFのDeleteObjectじゃなくSQLのDelete文を実行するように。

ちょっと長いですけど。

using System;
using System.Configuration;
using System.Data.Common;
using System.Reflection;
using System.Web;
using System.Web.Providers;
using System.Web.Providers.Entities;

namespace UniversalProviders
{
  public class HighPerformanceSessionStateProvider : DefaultSessionStateProvider
  {
    private string _connectionStringName;
    private bool _initialized = false;

    public override void Initialize(string name, System.Collections.Specialized.NameValueCollection config)
    {
      _connectionStringName = config["connectionStringName"];

      base.Initialize(name, config);
    }

    private void ExecuteSql(ConnectionStringSettings connectionStringSettings, Action<DbProviderFactory, DbCommand> functor)
    {
      var providerName = connectionStringSettings.ProviderName;
      var factory = DbProviderFactories.GetFactory(providerName);
      using (var connection = factory.CreateConnection())
      {
        connection.ConnectionString = connectionStringSettings.ConnectionString;
        connection.Open();

        var command = connection.CreateCommand();

        functor(factory, command);
        command.ExecuteNonQuery();
        connection.Close();
      }
    }

    private void CreateSessionIndex(ConnectionStringSettings connectionStringSettings)
    {
      ExecuteSql(connectionStringSettings, (factory, command) =>
      {
        command.CommandText = @"IF not EXISTS (SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID(N'[dbo].[Sessions]') AND name = N'IX_Sessions')
begin
CREATE NONCLUSTERED INDEX [IX_Sessions] ON [dbo].[Sessions] 
(
[Expires] ASC
) ON [PRIMARY]
end
";
      });
    }

    private void RemoveExpiredSessions(ConnectionStringSettings connectionStringSettings)
    {
      ExecuteSql(connectionStringSettings, (factory, command) =>
      {
        command.CommandText = "delete Sessions where Expires < @0";
        var parameter = factory.CreateParameter();
        parameter.ParameterName = "@0";
        parameter.Value = DateTime.UtcNow;
        command.Parameters.Add(parameter);
      });
    }

    private MethodInfo GetCreateSessionEntities()
    {
      var modelHelper =
        Assembly.GetAssembly(typeof(Session))
                .GetType("System.Web.Providers.Entities.ModelHelper");
      return modelHelper.GetMethod("CreateSessionEntities", 
        BindingFlags.NonPublic | BindingFlags.Static);
    }

    public override void InitializeRequest(HttpContext context)
    {
      var connectionStringSettings = ConfigurationManager.ConnectionStrings[_connectionStringName];
      if (!_initialized)
      {
        var initializer = GetCreateSessionEntities();
        initializer.Invoke(null, new object[] { connectionStringSettings });
        CreateSessionIndex(connectionStringSettings);
        _initialized = true;
      }

      RemoveExpiredSessions(connectionStringSettings);
    }
  }
}

クラス名はもちろんHighPerformanceSessionStateProviderデス!

up3

<sessionState mode="Custom" customProvider="HighPerformanceSessionProvider" cookieless="UseUri" timeout="1">
  <providers>
    <add name="DefaultSessionProvider"
         type="System.Web.Providers.DefaultSessionStateProvider, System.Web.Providers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"
         connectionStringName="DefaultConnection" applicationName="/" />
    <add name="HighPerformanceSessionProvider"
         type="UniversalProviders.HighPerformanceSessionStateProvider, UniversalProviders"
         connectionStringName="DefaultConnection" applicationName="/" />
  </providers>
</sessionState>

Web.configも上記のように変更しちゃいましょう!customProviderの指定がDefaultSessionProviderでオリジナルに戻ります。

セッションが飽和するのが、940件くらいのところです。もちろん例外なんて起きませんよ。有効期限切れのレコード削除なんて何回実行したって、無いものはない!潔し!ちなみに先程の数値もそうですけど、コレが同時実行の限界値なわけではないです。マイノートPCの同時実行の限界くらいです。Webの受付のね。DbProviderFactoriesを利用することでEFと同じだけのポータビリティ(言い過ぎ)。

名前ほど早くないけど、エラーが起きないから実用的なんじゃん?読み込み性能は気にしてないでーす。あくまで新規セッション作成時のパフォーマンス向上委員会です。言い訳がましい...。

ご利用は計画的に!

Azureでは動かしてないので、そちらの検証はたぶんカメさんがその3でやってくれるんじゃないでしょーか。

2011年3月19日土曜日

EF4.1 CodeFirstでenumを使いたい

最近、いろんなお誘いメールがケータイに舞い込んできて、いやもうマジクリックしちゃうぞコノヤロー。そんなに誘惑するんじゃないよ!

なんか迷惑メールが地震以降強烈に増えましたね。

結局enumはサポートされないことが決定してしまったCodeFirst。しょうがないですね。RCからRTMまでの間で機能追加はないので、ここは潔く諦めましょう。

でも、やっぱりenumを使いたいですよね。プロパティをパースするときにenumかenumのジェネリックは完全にスルーされてデータベーステーブルがScaffoldingされます。なのでComplex Typeとしてenumをラップしたクラスを用意し「オレenumじゃないよ、全然関係ないから!」とEFを騙す必要があります。

Tip 23 – How to fake Enums in EF 4 - Meta-Me - Site Home - MSDN Blogs

古のテクニックですね。2009年です。今もこれしか方法がないみたいです。

コンソールアプリを作りながら同じようにやってみよー!VS2010SP1を前提にしますよ?いいですか?だってSQLCE4使ってみたいでしょ。なのでSQLCE4も入ってる前提で行きます。なきゃないで問題ないので、SQLCE4関連の部分は読み替えてください。

ソリューション作ったらまずは、NuGet!NuGetで必要なパッケージを入れてしまいましょう。以下の2つ。

install-package EFCodeFirst
install-package EFCodeFirst.SqlServerCompact

↑こうです。

efenum1 efenum2

WebActivatorは不要なので、参照設定から削除しましょう。WebActivatorについては先日書いたので、気になる人はチェックしてみてね。

無聊を託つ: WebActivatorでお手軽Bootstrapper

App_Start/SQLCEEntityFramework.csもEFCodeFirst 0.8ベースなので少しいじっちゃいます。といっても、DbDatabaseをDatabaseにするのとWebActivatorの属性を削除。あと、SqlCe用のnamespace(System.Data.Entity.Infrastructure)が変わってるので、そこも変更。

efenum3

これで準備完了。ワクワクするね!ワクワクの強要。

最初にモデルクラスをつくって、次にDbContext派生。行きます。

using System.Data.Entity;

namespace EFEnum
{
    public enum Role
    {
        Unknown,
        Sniper,
        Captain
    }

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public Role Role { get; set; }
    }

    public class EFEnumContext : DbContext
    {
        public DbSet<Person> People { get; set; }
    }
}

↑EFEnumContext.cs

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <connectionStrings>
    <add name="SampleContext" connectionString="Data Source=|DataDirectory|db.sdf;" providerName="System.Data.SqlServerCE.4.0"/>
  </connectionStrings>
  <system.data>
    <DbProviderFactories>
      <remove invariant="System.Data.SqlServerCe.4.0" />
      <add name="Microsoft SQL Server Compact Data Provider 4.0" invariant="System.Data.SqlServerCe.4.0" description=".NET Framework Data Provider for Microsoft SQL Server Compact" type="System.Data.SqlServerCe.SqlCeProviderFactory, System.Data.SqlServerCe, Version=4.0.0.0, Culture=neutral, PublicKeyToken=89845dcd8080cc91" />
    </DbProviderFactories>
  </system.data>
</configuration>

↑App.config。

とりあえず、enumのまま作ってどういう動作になるのかを見てみます。

using System;
using System.Linq;
using EFEnum.App_Start;

namespace EFEnum
{
    class Program
    {
        static void Main(string[] args)
        {
            SQLCEEntityFramework.Start();
            var db = new EFEnumContext();

            var query = from p in db.People
                        select p;
            var count = query.Count();
            Console.WriteLine("{0}人いるよ!", count);

            if (count == 0)
            {
                var person1 = new Person
                                  {
                                      Name = "ルフィー",
                                      Role = Role.Captain
                                  };
                var person2 = new Person
                                  {
                                      Name = "ウソップ",
                                      Role = Role.Sniper
                                  };
                db.People.Add(person1);
                db.People.Add(person2);
                db.SaveChanges();
            }

            count = query.Count();
            Console.WriteLine("{0}人いるよ!", count);
            foreach (var person in query)
            {
                Console.WriteLine(person.Name + string.Format("({0})", person.Role));
            }


            Console.ReadLine();
        }
    }
}

↑main.cs。

    public static class SQLCEEntityFramework {
        public static void Start() {
            Database.DefaultConnectionFactory = new SqlCeConnectionFactory("System.Data.SqlServerCe.4.0");

            // Sets the default database initialization code for working with Sql Server Compact databases
            // Uncomment this line and replace CONTEXT_NAME with the name of your DbContext if you are 
            // using your DbContext to create and manage your database
            Database.SetInitializer(new DropCreateCeDatabaseIfModelChanges<EFEnumContext>());
        }
    }

SQLCEEntityFrameworkのStartメソッドにDbContextのクラスを指定するのも忘れずに。

ちょっと、コード長いけどドンマイ。行くぜ!

efenum4

クリックして拡大してみてね。ちゃんと名前の横にロールが表示されました。これはAddするインスタンスに設定したものが、そのまま表示されただけです。次に一度終了して、もう一度実行してみます。

efenum5

そうすると、今度は"Unknown"と出ました。なんでかというと、なんとテーブルには保存されてないからです!そういう仕様なので驚かないでね。VSから確認してみます。

efenum6

efenum7

カラム無いですね。ちょっと切ないですね。

さぁ本題です!ちょこちょこっとクラス用意しましょう。

    public abstract class EnumWrapper<T>
    {
        private T _value;
        public string Value
        {
            get
            {
                return _value.ToString();
            }
            set
            {
                _value = (T)Enum.Parse(typeof(T), value, true);
            }
        }

        public static implicit operator string (EnumWrapper<T> value)
        {
            return value.Value;
        }
    }

これを基底クラスとしてenumの型毎にクラスを用意していきます。

    public class RoleWrapper : EnumWrapper<Role>
    {
        public static implicit operator RoleWrapper(Role value)
        {
            return new RoleWrapper { Value = value.ToString() };
        }
    }

enum型1個しかないので、1個つくりましょう。ホントはimplicit operatorも書きたくないんだけど、こればっかりはしょうがない。何かいい方法ありますかね~?最終的にはT4で自動生成(プロジェクト内のenumを全部列挙してしまえばいいかな)させちゃえばいいでしょう。

そして、モデルクラスをこのクラスを使うように変更します。

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public RoleWrapper Role { get; set; }
    }

このままだと、mainのConsole.WriteLineでクラス名がでちゃうのでそこはRole.Valueに変えておきましょう。そうして実行してみると...。

efenum9 efenum8

タターン!左が1回目。0件で追加して2件。右が2回目。2件あるから2件表示。ちゃんとロールも表示されますね。テーブル見てみましょう。

efenum10

カラムも追加されてるし、値も入ってる~。文字列にしてるのはパースしやすいのと、パッと見データ見ただけでわかるからっていうだけの理由です。intがよければそのように。

いいんじゃないでしょうか。こんな感じで。

packages含んじゃってるのでちょっと大きいですがご容赦を。

2011年3月12日土曜日

WebActivatorでお手軽Bootstrapper

いろんなセッションビデオやサンプルコードによく登場してるWebActivator。属性指定のしかたから、.NET4で登場したPreApplicationStartMethod(AssemblyInfo.csに指定するやつ)に関する何かだろうと勝手に解釈して、ちゃんと見てなかったんだけど、なんか急に見てみたくなった。

Light up your NuGets with startup code and WebActivator - Angle Bracket Percent - Site Home - MSDN Blogs

解釈はおおむね間違ってないんだけど、PreAppricationStartMethodだと1個しか指定できない(よね?)のと、フックポイントが起動直後しかない(そりゃそうだ)のを拡張してしまおうというものでした。なるほど。

Pre/Post/Shutdowの3箇所に仕込めるんですね。

  • PreApplicationStartMethod
  • PostApplicationStartMethod
  • ApplicationShutdownMethod

この3種類。うまいこと考えてますね。PreとPostはAOPっぽい感じです。タイミングはPreはそのままPreなんだけど、PostはMicrosoft.Web.Infrastructure.DynamicModuleHelper.DynamicModuleUtility.RegisterModule を使って動的にロードする初期化HttpModule(これ自体ちゃんとWebActivatorに含まれてるので気にすることは無いですね)のInitイベントで実行。ShutodownはそのHttpModuleのDispose時に実行。

David Ebbo: Register your HTTP modules at runtime without config

面白いですね。賢いですね~。アセンブリ属性にしてるのは使い勝手を考慮してのことでしょう。属性宣言とクラス実装が近くにあるはずだから、クラス属性にしてもいいような気もするけど、使い勝手は大事でしょう!

[assembly: WebActivator.PreApplicationStartMethod(typeof(クラス), "実行メソッド名")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(クラス), "実行メソッド名")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(クラス), "実行メソッド名")]

こんな感じでアセンブリ内に宣言します。いくつ宣言してもいいです。ActivationManager(これが標準のPreApplicationStartMethodで指定されてるよ)が実行時にbinフォルダのアセンブリから全部抽出してくれます。

davidebbo / WebActivator / overview – Bitbucket

オレオレBootstrapperを卒業するときがきましたね!

注意点として、HttpModuleを利用するPostとShutdownはAppDomainがロードされなおすたびに実行されるので、何度も実行されるのでその辺気をつけましょう。

とにかくNuGetで Install-Package webactivator とタイプしてインストールしてみて以下のコードで動かしてみました。

[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper1), "Pre")]
[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper2), "Pre")]
[assembly: WebActivator.PreApplicationStartMethod(typeof(Bootstrapper3), "Pre")]

[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper1), "Post")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper2), "Post")]
[assembly: WebActivator.PostApplicationStartMethod(typeof(Bootstrapper3), "Post")]

[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper1), "Shutdown")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper2), "Shutdown")]
[assembly: WebActivator.ApplicationShutdownMethod(typeof(Bootstrapper3), "Shutdown")]

namespace Mvc3
{
    public class Bootstrapper
    {
        public static void Pre(string name)
        {
            Console.WriteLine("Pre : "+name + " " + DateTime.Now);
        }

        public static void Post(string name)
        {
            Console.WriteLine("Post : " + name + " " + DateTime.Now);
        }

        public static void Shutdown(string name)
        {
            Console.WriteLine("Shutdown : " + name + " " + DateTime.Now);
        }
    }

    public class Bootstrapper1
    {
        private static string Name = "1番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

    public class Bootstrapper2
    {
        private static string Name = "2番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

    public class Bootstrapper3
    {
        private static string Name = "3番";
        public static void Pre()
        {
            Bootstrapper.Pre(Name);
        }

        public static void Post()
        {
            Bootstrapper.Post(Name);
        }

        public static void Shutdown()
        {
            Bootstrapper.Shutdown(Name);
        }
    }

}

VS2010SP1だとIIS Expressで実行ができるけど、あえて外部コマンドでIIS Expressを指定して実行してみます。

wa

コンソールのスクリーンショットを取りたかったからデス!これって統合されたらどこで見ればいいんだろか。出力ウィンドウだとちょっと違うじゃん?ん~。まぁ、いいか。おいおい分かるでしょう。

残念ながらShutdownは表示するまで待つのも設定変えるのも面倒(IIS ExpressだとidleTimeoueでシャットダウンしない?)なので未確認!雰囲気が伝わればいいかな、なんて。

2011年3月5日土曜日

ぐっと来てますか?

http://guttokita.cc

@jsakamotoさんからとてもナイスなフィードバックをTwitterでもらったので早速実装しました。

ODataによるデータ出力

http://guttokita.cc/feed.svc

上記URLでOData形式でデータを出力しました。後はUIを組み込めば過去のつぶやきを見放題!ぐへへ。UIをどうやってつくるかは考え中。

guttokitacc1

guttokitacc2

購読しやすい形式にはマッピングしてないので、購読用途には向いてないです。

OData と AtomPub - WCF Data Services を使用した AtomPub サーバーの構築

↑この辺で直接購読しやすくマッピングをしてみようかとも考えて動かしてみたんですが、なんか用途が違う気がしてきたのでやめました。購読用Feedは需要があれば別途用意します。

つぶやいた内容を直接見れる(パーマリンク化)

続いて、つぶやき内容をブラウザから直接見れるようにするパーマリンク化。これまではiPhoneでの利用を想定してたので、トップページからリンクをたどって見る方法しかなかったですが、ブラウザにURLを入れて直接アクセスできるようにしました。ODataと合わせて使うのがいいかも。

IMG_0171 IMG_0172 IMG_0173 IMG_0174 IMG_0175

トップからたどっていくと、つぶやきページの一番下にPermalinkというリンクがあるので、そこに指定されてるURLがパーマリンクです。

permalink1

permalink2

PCで見ると↑こんな感じです。

上記の場合 http://guttokita.cc/Tweets/27 がパーマリンク。

技術的な話

そもそもEF CodeFirstでデータアクセス層を実装してるので、すんなりとはODataになってくれませんでした。ちょびっとだけ手を加える必要があります。というのも、CodeFirstのデフォルトの挙動がプロキシクラスを生成するので、WCF Data Servicesから見たときにIQueryableに見えないといわれて怒られます。

odata

サーバーで要求の処理中にエラーが発生しました。例外メッセージは 'データ コンテキスト型 'SiteContext' に、要素型がエンティティ型でないトップレベルの IQueryable プロパティ 'Accounts' があります。IQueryable プロパティがエンティティ型であることを確認するか、データ コンテキスト型に対して IgnoreProperties 属性を指定して、このプロパティを無視してください。

EF CTP4 Tips & Tricks: WCF Data Service on DbContext « RoMiller.com

CTP4ですがここを参考にObjectContextのオプションでプロキシを生成しないように抑制しておく必要があります。

CTP5での書き方は↓こう。

protected override ObjectContext CreateDataSource()
{
  var db = new SiteContext();
  ((IObjectContextAdapter)db).ObjectContext.ContextOptions.ProxyCreationEnabled = false;
  return ((IObjectContextAdapter)db).ObjectContext;
}

SiteContextっていう名前でDbContextを作ってます。

リレーショナルなテーブルだとこれでもちょっと使いにくくて、フラットで十分じゃないかと思うわけです。であれば、ViewをDBに定義してそれをFeedにしたほうがいいと思ったんですが、CodeFirstでViewってどうするんでしょうね。どうやら普通にエンティティクラス書いてDbSetでDbContextに定義すればいいみたい。でも、それだとテーブル作られちゃうじゃないですか。テーブルをつくるな宣言ってどうするんでしょうね。よくわかんないな~。NotMappedはテーブルには適用されない。しょうがないのでInitializerのSeedでDrop TableとCreate Viewをするという暴挙にでました。何かしら方法があるといいんだけど。

ちなみにODataということは、いろんなフィルターや射影し直せるというわけで、自分のつぶやきの最新3件取得とかも簡単です。

http://guttokita.cc/feed.svc/Tweets?$filter=UserName eq 'takepara'&$orderby=CreateAt desc&$top=3

odata3

うっひょ~い!

URLに指定できるクエリオプションは以下のページをどうぞ

データ サービス リソースへのアクセス (WCF Data Services)

2011年2月26日土曜日

jQuery 1.5.1 リリースが嬉しい

jQuery: » jQuery 1.5.1 Released

出ましたね!待ちに待った1.5.1。近々jQuery Mobile alpha 4もくるみたいですね!

1.5.1になってIE9での不具合が解消されたおかげで、とうとう機能するようになりました。

ぐっと来た.cc
http://guttokita.cc

1.5.1にするまではひどいもんですよ。ロードした時点でjQueryさんが起こるもんだから、画面真っ黒。まだまだjQuery Mobileが本気じゃないみたいなので、Webkitバリのレンダリングにはならないけど、これでクマさんも使ってくれるようになることでしょう。ね!

http://razordo.it

も、同じ問題を抱えてたので、こっちは1.4.4を使ってたんですが、試してみたところ、大丈夫そうだったのでそれぞれ1.5.1にバージョンアップです!

嬉しさあまってThinkpad x201sも買っちゃいました。てへ。情報くれた@wakakoo サンクス!

2011年2月24日木曜日

マニフェスト Tips

RADWIMPSじゃなくてHTML5のキャッシュ。

やっとmanifestファイルを使ったキャッシュを組み込めました。

Offline resources in Firefox - MDC Doc Center
Safari Client-Side Storage and Offline Applications Programming Guide: HTML 5 Offline Application Cache

前回のエントリでjQuery Mobileを使ったぐっと来た.ccの話をしましたが、その時はmanifestが簡単に組み込めなくて断念してたんですが、移動中だとやっぱり遅さが気になるんです。

どうすれば簡単にmanifestを使ったキャッシュを使えるのか、ASP.NET MVCのアプリケーションに組み込めるのか、悩ましいところでしたが、なんとかかんとかキャッシュできました。

ローカルストレージを使ったりした、送信データのキューイングはまだ実装してないので、常時オンラインである必要があるのでOffline Applicationではないんですけどね。

gu

↑これ。

アプリの作り方として、ヘッダ部が認証状態に合わせて変化する+コンテンツ部も同じく認証状態によって内容が変化するので、Ajaxでそれぞれの部分を取得するようにメインページの実装を変更しました。こうすることで、メインページ(manifestを指定しているHTML)がキャッシュされてても、認証により変化する部分は都度リクエストがとんでキャッシュされずに済むようになります。ここまでは結構普通なんですけど、ここからjQuery Mobileに合わせた実装が必要になります。

と、いうのも動的にDOMを生成したとしても、それだけではjQuery Mobileのスタイルが適用されないから、です。はてさて困ったものです。いろいろ試して見つけたのが $.mobile.page() という関数。これを呼ぶことで対象のエレメントに対して強制的にjQuery Mobileのレンダリングを適用させることが出来ました。

JQM FAQ

javascript - jQuery Mobile - Dynamically creating form elements - Stack Overflow

素晴らしいですね。

ただ、素直にAjaxで取得したPartial htmlに対してpage()適用したりしちゃうと、ChromeでjQueryのコンフリクトエラーが発生!だめか~と思ったけど$(document).page()とすればあっさり解決。

今回の実装だと2つのPartialを取得するので、それぞれのリクエストの完了を待ち合わせが必要。リクエストのたびにレンダリングすればいいんだけどカッコ悪いじゃないっすか。そんな時にjQuery 1.5で追加された便利な関数があることを思い出した。

$.when(…).then(…);

jQuery.ajax() – jQuery API

新しく実装されたDeferred Object。jqXHR素敵すぎ。whenに同期したいDeferred Objectを並べて($.get()の戻り値とか)おくと、すべてが完了した時点でthenに渡したfunctionが実行される優れもの。

あとUnobtrusive validationをクライアントサイドでの動的フォームに適用するには $.validator.unobtrusive.parse(document); を呼び出しておくことも注意事項ですね。

Brad Wilson: Unobtrusive Client Validation in ASP.NET MVC 3

他にも面白そうなTipsがあったらエントリします~。

2011年2月19日土曜日

ドッグイアとjQuery Mobile

コンピュータ業界は進むのが早いっていう話じゃなくて、本を読んで気になるページに折り目をつける話です。

専門書でも漫画でも小説でも本なら何でも折り目をつけてしまう癖があります。最悪なのは借りた本に折り目をつけてしまうところ。マジすいません。読んだ本で気に入ったフレーズや大事なことだと思った部分を簡単に残しておきたいと思って、ブログに書いたりもするけど、なるべくその瞬間に書き残しておければより記憶に残るっていいなーと思ったわけです。ブクログとかいろいろ便利なサイトが世の中にはありますが、もっともっと特化してしまってもいいと思いませんか!手軽にささっと、となるとモバイルデバイスが利用対象になりましょう。と、いうか自分にとって使いやすいのは何かを考えたらiPhoneだなっていう。

iPhoneといえば最近何かと話題のjQuery Mobile。面白いですよね。まだまだコレだという作り方を見つけることが出来てないのですが。ASP.NET MVCとの相性も悪くない感じです。

そこで本やDVDを見てぐっと来た部分を簡単につぶやいて記録に残せるものを作ってみました。

IMG_0142 IMG_0143 IMG_0144

ぐっと来た.cc

jQuery MobileなのでiPhone/Android等スマートフォン専用です。PCでもそれっぽく動作しますが、まともなのはChromeだけ。Firefoxでも3.6系だと見た目だけはそれっぽいけど、動作(アニメーション)はしょぼしょぼです。HTML5+CSS3だからといってIE9はまるで対象外。パッチを当てても全然ダメだった。

gu1 gu2

左からChrome、Firefox、IE9。背景を黒ししてるのでひどいもんですよ。

使い方は簡単です。最初にサイトにアクセスすると「みんなのぐっと来た」が表示されます。

gu3 gu4

赤い枠の部分をクリックすると、Amazonの商品詳細ページへ。緑の枠の部分をクリックするとつぶやきの全文が表示されます。

右上の「Sign in」をクリックするとTwitterアカウントを使ってログイン。最近お気に入りのOAuthです。http://razordo.it も同じです。

ログインするとHome(検索と最近つぶやいた商品リスト)とPeople(みんなのぐっと来た)を切り替えるタブが出てくるので、初めてつぶやく商品は検索して探しましょう。んで、つぶやく!そしたらTwitterにつぶやき内容とAmazonへのリンクが一緒にポストされます。

That's it!

それだけの機能なんですが、ASP.NET MVC3+Razor+EF CodeFirst+jQuery Mobile+ExpressWeb(宣伝?)です。HTML5とはいえ別に新しいエレメント使ってなくてdata-*属性を指定するくらいなのお手軽実装です。

jQuery Mobileの特徴的な部分はすべてのページは最初に表示されるHTMLに従属する、的なところです。ページ遷移は基本的にAjaxでHTMLを先取りしておいたものを自ページ内にDIVエレメント(data-role="page")として展開して、それを表示するためにアニメーションするというものです。表示されてるのはui-page-activeクラスが指定されてる部分になります。

gu5

なのでページやドキュメントのロード時というのは基本的に最初のページロードの時になりますね。しかも、ページ自体をDIVエレメントで1ページ内に収めるし、キャッシュのためにエレメントは毎回削除されるわけじゃないので、ID属性が同じエレメントが多数発生して$(“#id”)がたくさんいるじゃないか、みたいな状況が出来上がります。

その辺はclassでのセレクタでアクティブなpageからの相対参照で書くようにとコツが必要になるので、慣れが必要でしょう。おや~?と思う動きをするときはだいたいこの辺の設計が関与してます。jQuery MobileっていうのをjQuery UIみたいにとらえると痛い目見るぜハニー。

全く別のフレームワークとして、ちゃんと認識しておくことが必要です。作り方が変わってくるからね。もちろん専用のイベントも用意されてるので、ページのロードじに何か処理をするとかは普通にかけます。コレ以外にもタッチ前提のスマートフォンだからkeydownなんかも発生しなくてびっくりした。そりゃそうなんだけど。

jQuery Mobile Docs – Events

あとはcache manifestがちょっと難しい。manifestを指定してるHTML自体が必ずキャッシュされるという理屈になかなか気がつかなかったです。この辺もうまいこと調整していけば晴れてスマートフォンでも最適なモバイルアプリが出来上がるわけですね。涙ぐましい。

なにはともあれ、それっぽく動くようになったので使ってみてね!あと、Amazonへのリンクは設定で自分アソシエイトIDを登録しておくとそれを使うようになってるので、ID持ってる人は登録しておくとチャリンチャリンと聞こえるかもね!

2011年2月6日日曜日

Razor Do It

出来ることはコード共有&実行。Razorの。

Razor構文で書いたコードをコピペして実行出来るアプリケーションです。

WebMatrixでの開発や、ASP.NET MVC3でRazor使うじゃないですか。んで、いろいろヘルパーとか書いたりするじゃないですか。それを共有したらおもしろいんじゃないかな~と思って作ってみました。

http://razordo.it

コードの共有だけじゃなくてサーバーサイドでビルドして動かせるようにすればその場で動きの確認もできるから、なお面白いかも~。なんて言い出したのが始まり。サーバーサイドで実行するからフロントのアプリケーションとは別のAppDomainをサンドボックスとして用意してそのなかで動かさないと、アプリケーション自体がハッキングされたり、いろいろ悪さ出来てしまってまずいじゃないですか(メール無限送信とか)。

すんなり出来ると思ってたんですよね、正直。でも、結構面倒だった。最初は普通にAppDomainを分離するだけで実装してみたんだけど、それだと動的ビルドが失敗。GACに入ったものからじゃないとダメ。Assembly自体がAppDomainを行き来できないから、外でビルドする必要もあるし。こりゃ駄目だと思って次に試したのがSimpleWorkerRequest。でもでもこれもちょっとダメみたい。ASP.NET on ASP.NETだと結局ホスト環境に依存する箇所があるみたい。よくわかんないや。これもたぶんGACがからむのかな?なんせ情報が少なかった。普通のアプリとしては難しくないんだけどね。Cassiniのソースとかめっちゃ見た。結局動やったかというと...。まぁ、細かい話はいいですね。興味ある人がたくさんいるならコードとか仕組みを詳しく公開します。

razordoit razordoit2

動かすだけならコードを貼りつけて"Execute"を押すだけ。簡単ですね。

ちなみにASP.NET MVC3で使われるWebViewPageではなく、WebMatrixでホストするときに使われるWebPageViewを基底クラスにしています。これもまた@inheritなどではなくガッチリBuildProvider!さらにカスタムIPermission指定でMedium trustよりも厳しい実行環境。

書いたコードを共有するには右上にある"Sign in with Twitter"でTwitterにログイン(OAuthで認証)しましょう。そうすると"Save”ボタンが出てくるので、保存出来るようになります。

保存するとURLが変わるので、そのURLをTweetするとか、いろんな方法で友達に知らせちゃいましょう!

razordoit3 razordoit4

↑こんな感じです。

http://razordo.it/now/b446

サンプル。

Razor do it nowですよ。最初思いつきでshowにしちゃったけどnowのほうがそれっぽくないッスか?どっちでもいいか。

保存したコードは、さらにコピーして改良して保存して、というふうにいじっていくといいかも~。かも~。コードのレーティングつけてすげー人とか見れるような仕組みになるのも面白いかも?かも~。

dotnetConf2015 Japan

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