2011年10月15日土曜日

TraceListener into MongoDB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    void context_BeginRequest(object sender, EventArgs e)
    {
      var httpContext = (sender as HttpApplication).Context;
      var startTime = httpContext.Items[ItemKey] = DateTime.Now;
    }

    void context_EndRequest(object sender, EventArgs e)
    {
      var httpContext = (sender as HttpApplication).Context;
      var startTime = (DateTime)httpContext.Items[ItemKey];

      Trace.WriteLine(new
      {
        Server = _serverName,
        RequestAt = startTime,
        Method = httpContext.Request.HttpMethod,
        Status = httpContext.Response.StatusCode,
        RawUrl = httpContext.Request.Url.ToString(),
        Milliseconds = (DateTime.Now - startTime).Milliseconds
      });
    }

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

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

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

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

      base.OnActionExecuting(filterContext);
    }

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

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

      base.OnActionExecuted(filterContext);
    }

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

      base.OnResultExecuting(filterContext);
    }

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

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

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