けさらんぱさらん

方向性は定めず、ただ思いつくままに

mongoDBもLINQで行こう!

この記事は、C# Advent Calender 2012 の13日目の記事です。
前日の記事は、@masakさんのC# 5.0 で変わった事 - foreach の破壊的変更です。

自分はあまり深いことは書けませんので最近ちょっと触り始めたmongoDBを操作する方法を紹介したいと思います。

始めに

mongoDBのバージョンは2.2.0でWindowsにインストールしています。
C#ドライバは1.7を使用します。(特に1.7でないと出来ないようなことはしていないです)

ざっくりmongoDBとは

突然、mongoDBをLINQで検索してもmongoDBが良く分からない方もいると思うので
まず簡単にmongoDBってなんなのって話。
mongoDBは、所謂NoSQLに分類されるデータベースです。
ドキュメント指向DBでスキーマレスなのです。
データはBSONと呼ばれるJSONちっくな構造で格納されます。
レプリケーションやシャーディングという機能でサーバ間に分散してデータを持つことができます。
ざっくり説明終わり!(ざっくり過ぎてすいません)

スキーマレスってどゆこと?

スキーマレスというのはスキーマが無いってことです。(あう、そのまま
RDBだと必ず存在するテーブル定義が無いんです。
分かりづらいですね
実際に見てみましょう!

まず何もコレクション(RDBで言うところのテーブル)が無い状態を確認する。

$ mongo
use advent_sample

f:id:cer1974:20121205233132p:plain
なにもDBが無い時にuse [データベース名]をするとデータベースが作成されます。

コレクションが存在するか確認してみます。

show collections


何も表示されないですね!

ここでRDBだとCreate文を流してテーブルを作る。
でもmongoDBは、ここでいきなりデータを挿入しちゃう

db.samples.insert( {name: "aaa", age: 20} );
show collections


テーブル定義的なものが無くていきなりデータが挿入できちゃいました。
更にデータを追加しますが今度はフィールド(RDBで言うこところのカラム)を増やして追加します。

db.samples.insert( {name: "bbb", age: 30, tel: "00-0000-0000"} );
db.samples.find();


find()メソッドで検索すると2ドキュメント表示されます。
2つのドキュメントで定義が異なるのが分かります。
ということでスキーマレスなんですね

今回使うデータ構造

商品の購入システムみたいなものにしてみます。
「注文」と「注文明細」があって「注文」は「注文明細」を複数持ちます。
「注文」は「会員」を参照するようにします。

クラス図だとこんな感じ
f:id:cer1974:20121205233717p:plain

MongoDriverのインストール

C#からmongoDBに接続するためには、公式からリリースされているMongoDriverを使用します。
MongoDriverは、NuGetで取得できるので簡単にインストールできます。

Install-Package mongocsharpdriver

まあGUIでやった方が楽ですけどね

モデルクラス

注文クラス
[BsonIgnoreExtraElements]
public class Order
{
    public BsonObjectId Id { get; set; }
    public DateTime OrderAt { get; set; }

    public ICollection<OrderDetail> OrderDetails { get; set; }
    public int MemberId { get; set; }
        
    [BsonIgnore]
    public Member Member { get; set; }
}

注文クラスは、注文明細(OrderDetail)をコレクションで持ちます。
会員(Member)は、mongoDB上、注文に含まれるわけでは無いので BsonIgnore を付けてマップ対象から除外します。

注文明細クラス
public class OrderDetail
{
    public string ItemName { get; set; }
    public int ItemCount { get; set; }
    public int SubTotal { get; set; }
}
会員クラス
public class Member
{
    public BsonObjectId Id { get; set; }
    public int Id { get; set; }
    public string MemberName { get; set; }
    public string EMail { get; set; }
}

データを挿入してみる

まずマスタとなるMemberを使ってデータを挿入してみます。

[TestMethod]
public void Member_Insert()
{
    MongoDatabase db = new MongoClient( "mongodb://127.0.0.1" ).GetServer().GetDatabase( "advent_sample" );
    MongoCollection<Member> memberCollection = db.GetCollection<Member>( "members" );

    WriteConcernResult result = memberCollection.Insert( 
            new Member { MemberId = 1, MemberName = "memberA", EMail = "memberA@example.com" } 
        );

    Assert.IsTrue( result.Ok );
}

さて挿入されたかmongoShellで確認してみます。
f:id:cer1974:20121209124745p:plain
ちゃんと出来ていますね。BsonIdは自動で振られます。

今度はデータ取得してみます。

[TestMethod]
public void Member_First()
{
    // Collectionを取得するところは煩雑なのでメソッド化
    var memberCollection = GetCollection();

    Member member = memberCollection.FindOne();

    Assert.IsNotNull( member );

    Console.WriteLine( String.Format( "bsonId:{0} memberId:{1}, name:{2}, emaile:{3}",
        member.Id, member.MemberId, member.MemberName, member.EMail ) );
}

出力結果を確認します。
f:id:cer1974:20121209212712p:plain
取れてますね。OK!

OrderとOrderDetailを挿入するためにデータを作成します。
mongoDBは、ドキュメント内にネストしてドキュメントを持てるので
Orderの中にOrderDetailを入れておいてOrderの挿入を行います。

[TestMethod]
public void Order_Insert()
{
    MongoDatabase db = new MongoClient( "mongodb://127.0.0.1" ).GetServer().GetDatabase( "advent_sample" );
    MongoCollection<Order> orderCollection =  db.GetCollection<Order>( "orders" );

    Order order = new Order
    {
        OrderAt = DateTime.Now,
        MemberId = 1,
        OrderDetails = new[]{
            new OrderDetail{ ItemName="テレビ", ItemCount=1, SubTotal=50000},
            new OrderDetail{ ItemName="DVD", ItemCount=5, SubTotal=15000},
        }
    };

    WriteConcernResult result = orderCollection.Insert( order );

    Assert.IsTrue( result.Ok );
}

mongoShellで確認してみます。
f:id:cer1974:20121209142901p:plain
ちゃんと挿入されていますね
分かりにくいですが、1つのドキュメントにネストしてドキュメントが存在しています。

C#からも取得してみます。

[TestMethod]
public void Order_First()
{
    // Collectionを取得するところは煩雑なのでメソッド化
    var orderCollection = GetCollection();

    Order order = orderCollection.FindOne();

    Assert.IsNotNull( order );
    Assert.IsTrue( order.OrderDetails.Any() );

    Console.WriteLine( String.Format( "bsonId:{0} orderAt:{1}, memberId:{2}",
        order.Id, order.OrderAt, order.MemberId ) );

    order.OrderDetails.ToList().ForEach( x => 
        Console.WriteLine( String.Format( "itemName:{0} itemCount:{1} subToal:{2}",
        x.ItemName, x.ItemCount, x.SubTotal ) ) );
}

f:id:cer1974:20121209213153p:plain
受注詳細のデータもちゃんと取得されてオブジェクトへのマッピングも出来てますね。

準備はここまでで終わりです。

データを検索してみる

はぁ~長くってすいません。
ようやくLINQの出番ですよ!

挿入したデータでいろいろと試してみます。
※データ適当に増やしています。

mongoDBでLINQを使うには、MongoDB.Driver.Linq名前空間をusingする必要があります。


まずは、普通にWhereメソッドでもやってみましょう。
会員IDが1の人の購入履歴を取得する。

[TestMethod]
public void Order_Where_Member_1()
{
    var orderCollection = GetCollection();

    var orders = orderCollection.AsQueryable().Where( x => x.MemberId == 1 ).ToList();

    orders.ForEach( x =>
            {
                WriteOrder( x );
                x.OrderDetails.ToList().ForEach( y => WriteOrderDetail( y ) );
            }
        );
}

結果
f:id:cer1974:20121209223952p:plain


Queryableなので当然といえば当然ですが、遅延評価されますので後から条件を追加することも出来ます。
会員IDが3で2012年12月9日の受注を一覧しますが
会員IDの条件と日付の条件は別なWhereメソッドで記述します。

※クエリ部分だけ記述します。

var temp = orderCollection.AsQueryable().Where( x => x.MemberId == 3 );
var orders = temp.Where( x => x.OrderAt >= new DateTime( 2012, 12, 9 ) && x.OrderAt < new DateTime( 2012, 12, 10 ) ).ToList();

結果
f:id:cer1974:20121210233659p:plain
結果としては正しいですね。でも1クエリで問い合わせたかは分からないのでログを確認してみます。

Mon Dec 10 23:31:41 [conn12] query advent_sample.orders query: { MemberId: 3, OrderAt: { $gte: new Date(1354978800000), $lt: new Date(1355065200000) } } ntoreturn:0 keyUpdates:0 locks(micros) r:182 nreturned:2 reslen:401 0ms

1クエリになってますね。

ネストされたドキュメント側のフィールドでも検索できます。

var orders = orderCollection.AsQueryable()
    .Where( x => x.OrderDetails.Any( y => y.ItemName == "CD" ) ).ToList();

結果
f:id:cer1974:20121211000053p:plain

ログはこんな感じ

Mon Dec 10 23:55:32 [conn16] query advent_sample.orders query: { OrderDetails: { $elemMatch: { ItemName: "CD" } } } ntoreturn:0 keyUpdates:0 locks(micros) r:214 nreturned:3 reslen:508 0ms

ちょっとデータを絞ってみます。上記から会員IDが3の人だけにします。

var orders = orderCollection.AsQueryable()
  .Where( x => x.MemberId == 3 && x.OrderDetails.Any( y => y.ItemName == "CD" ) ).ToList();

結果
f:id:cer1974:20121211000740p:plain

ログ

Tue Dec 11 00:03:22 [conn17] query advent_sample.orders query: { MemberId: 3, OrderDetails: { $elemMatch: { ItemName: "CD" } } } ntoreturn:0 keyUpdates:0 locks(micros) r:225 nreturned:2 reslen:382 0ms

こんな感じで普通のフィルタ処理であれば簡単にできます。


会員マスタと連結もしてみたいと思います。

[TestMethod]
public void Member_Reference()
{
    var orderCollection = GetCollection();
    var memberCollection = GetMemberCollection();

    var orders = orderCollection.AsQueryable()
        .Where( x => x.MemberId == 3 )
        .Join( memberCollection.AsQueryable(), o => o.MemberId, m => m.MemberId, ( o, m ) => new { o, m } )
        .ToList();

    // 適当ですいません;;
    orders.ForEach( x =>
        {
            Console.WriteLine( String.Format("{0},{1},{2},{3}", x.o.Id, x.o.MemberId, x.m.MemberName, x.m.EMail ));
        });
}

結果
f:id:cer1974:20121212002531p:plain
あふん!
Joinはサポートされていないというつれないエラー。
まあLINQでなくても元々mongoDBはJOINをサポートしていないですから当然の結果かと思います。
※MongoDBRefというクラスを使えばクライアント側でのJOINは可能なようです。

まあこのマスタとの結合はアプリ側でやってしまうのが良いのではないかと

[TestMethod]
public void Member_Reference2()
{
    var orderCollection = GetCollection();
    var memberCollection = GetMemberCollection();

    // 本来はキャッシュしたりする
    var members = memberCollection.FindAll();

    var orders = orderCollection.AsQueryable()
        .Where( x => x.MemberId == 3 ).AsEnumerable()
        .Join( members, o => o.MemberId, m => m.MemberId, ( o, m ) => { o.Member = m; return o; } )
        .ToList();

    // 今度は出力されるはずだからちゃんと書こう
    orders.ForEach( x =>
    {
        Console.WriteLine( 
            String.Format( "memberId:{0},memberName:{1},email:{2}", 
                x.Member.MemberId, x.Member.MemberName, x.Member.EMail ) );
        x.OrderDetails.ToList().ForEach( y => WriteOrderDetail( y ) );
    } );
}

結果
f:id:cer1974:20121212162834p:plain
今度はちゃんと結果を出力することが出来ました。

ログ

Wed Dec 12 16:25:38 [conn4] query advent_sample.orders query: { MemberId: 3 } ntoreturn:0 keyUpdates:0 locks(micros) r:154 nreturned:10 reslen:2438 0ms

ログを見るとMemberIdでの絞り込みしかされていません。Joinはアプリ側でやっていることがわかります。


mongoDBは、正規表現で検索することができます。
会員のメールアドレスがaから始まるco.jpドメインのものを検索します。

var member = memberCollection.AsQueryable()
        .Where( x => Regex.IsMatch( x.EMail, @"^a.*@.*co\.jp$" ) ).ToList();

結果
f:id:cer1974:20121212213147p:plain

ログ

Wed Dec 12 21:29:17 [conn8] query advent_sample.members query: { EMail: /^a.*@.*co\.jp$/ } ntoreturn:0 keyUpdates:0 locks(micros) r:6536 nreturned:1 reslen:110 6ms

データを集計してみる


まずはカウントでも

var orderCount = orderCollection.AsQueryable()
    .Where( x => x.OrderAt >= new DateTime( 2012, 12, 9 ) && x.OrderAt < new DateTime( 2012, 12, 10 ) )
    .Count();
}

結果
f:id:cer1974:20121211235833p:plain

ログ

Tue Dec 11 23:56:36 [conn2] command advent_sample.$cmd command: { count: "orders", query: { OrderAt: { $gte: new Date(1354978800000), $lt: new Date(1355065200000) } } } ntoreturn:1 keyUpdates:0 locks(micros) r:139 reslen:48 0ms

まあこれでも結果はいっしょです。

var orderCount = orderCollection.AsQueryable()
    .Count( x => x.OrderAt >= new DateTime( 2012, 12, 9 ) && x.OrderAt < new DateTime( 2012, 12, 10 ) );

結果
f:id:cer1974:20121212000626p:plain

ログ

Wed Dec 12 00:04:11 [conn3] command advent_sample.$cmd command: { count: "orders", query: { OrderAt: { $gte: new Date(1354978800000), $lt: new Date(1355065200000) } } } ntoreturn:1 keyUpdates:0 locks(micros) r:148 reslen:48 0ms

ログ見るとクエリ的には同じものですね


合計値を求めてみます。

var sum = orderCollection.AsQueryable()
        .Sum( x => x.OrderDetails.Sum( y => y.SubTotal ) );

結果
f:id:cer1974:20121212215907p:plain
あら?例外ですね

Sumは実装されてないみたいですね
mongoDB自体も2.2.0で漸くSumとかできるようになったみたいです。ドライバの方はまだ実装されていないのかな?

var sum = orderCollection.AsQueryable()
    .Select( x => x.OrderDetails.Sum( y => y.SubTotal ) )
    .AsEnumerable().Sum();

これで出来ることは出来るのですが、全件アプリ側に持ってきてからの処理になるので
件数が増えたら死ねますね
※MapReduceという機能で集計はできるはずです。また別途、書ければ書きます。

最後に

mongoDBは、RDBでは難しいことが簡単に出来たりします。(逆に出来ないこともあります)
要件に依っては、RDBよりもmongoDBを使ったほうがうまくいくこともあると思いますので
興味があれば是非、試してみてください。
(プロトタイプとか作るときは、スキーマレスはうれしいですよね)
特にLINQが使えるのでC#erは、すぐに扱えるようになるんではないでしょうか?

なんかLINQメソッドもまだいっぱいあるのに中途半端な記事になってしまった感が否めませんが
詳しく知りたい方は、公式サイトをどうぞ

まあ何にせよ無事、折り返せたのでなによりです。
明日は、@takeshikさんです。よろしくお願いします。