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
なにも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つのドキュメントで定義が異なるのが分かります。
ということでスキーマレスなんですね
今回使うデータ構造
商品の購入システムみたいなものにしてみます。
「注文」と「注文明細」があって「注文」は「注文明細」を複数持ちます。
「注文」は「会員」を参照するようにします。
クラス図だとこんな感じ
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で確認してみます。
ちゃんと出来ていますね。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 ) ); }
出力結果を確認します。
取れてますね。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で確認してみます。
ちゃんと挿入されていますね
分かりにくいですが、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 ) ) ); }
受注詳細のデータもちゃんと取得されてオブジェクトへのマッピングも出来てますね。
準備はここまでで終わりです。
データを検索してみる
はぁ~長くってすいません。
ようやく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 ) ); } ); }
結果
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();
結果
結果としては正しいですね。でも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();
結果
ログはこんな感じ
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();
結果
ログ
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 )); }); }
結果
あふん!
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 ) ); } ); }
結果
今度はちゃんと結果を出力することが出来ました。
ログ
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();
結果
ログ
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(); }
結果
ログ
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 ) );
結果
ログ
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 ) );
結果
あら?例外ですね
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さんです。よろしくお願いします。