技術者ブログ
クラウド型WAF「Scutum(スキュータム)」の開発者/エンジニアによるブログです。
金床“Kanatoko”をはじめとする株式会社ビットフォレストの技術チームが、“WAFを支える技術”をテーマに幅広く、不定期に更新中!

Javaプログラマから見たMongoDB

はじめに
PostgreSQLなどのRDBMSでは、コマンドラインのクライアントからSQL文を打ち込んでデータを操作することが一般的です。これに対し、MongoDBでは、コマンドラインのクライアント(mongoコマンド)からJavaScriptを使ってデータにアクセスすることができます。ただし、JavaScriptを使うといってもif文やfor文などを使うことはまれで、多くの場合は単純にJSON形式で記述したクエリーを関数の引数にして実行するだけになります。その際「find」「update」などの関数や「$gt」「$set」などのMongoDB独自のオペレータを用います。これは特定のプログラミング言語に特化した方法ではなく、少し抽象化された、MongoDB言語とでも呼べるものとなっています。
PHPやJavaなど、JavaScript以外で書かれたアプリケーションからMongoDBにアクセスする際も基本は同じです。mongoクライアントでアクセスする際のJSON形式のクエリーと同じように、それぞれのプログラミング言語の中で階層化されたデータ構造(Map、連想配列などと呼ばれるもの)を使ってクエリーを作成し、関数を実行することになります。そのため、プログラマはまずmongoクライアントでMongoDBの基本的な操作に慣れた後に、自分の使う言語でそれをどのように表現するのかをマッピングしていくことになります。
私は6月から7月にかけて、JavaからMongoDBにアクセスするコードを初めて書いてみました。今までSQLと、JDBCドライバのAPIを直接呼び出すJavaプログラミングに慣れていたため、非常に新鮮に感じる要素が多々ありました。まだMongoDBに慣れきっておらず、新鮮に感じる視点が残っているうちに、JavaからMongoDBにアクセスする場合にプログラマの立場から受けた印象や気になった点を中心に本エントリにまとめてみたいと思います。
ドライバのソースコードをEclipse上にインポートしておくと便利
JavaでMongoDBにアクセスするには専用のドライバを使います。これはMongoDBを開発している10gen社から正式にリリースされており、githubでソースコードも手に入れることができます(ライセンスはApache License Version 2.0です)。私はかなり早い段階でこのドライバのソースコードを自分の開発環境のEclipseに取り込み、開発中のアプリケーションからドライバ内のソースコードへ自由にジャンプできるようにしました。これが後々とても役に立ったので、Javaで開発される方には非常におすすめできる方法です。
ユーティリティクラスやメソッドを作ると便利
こちらにあるJavaチュートリアルの中で、「Inserting a Document」という項目に、下記のようなJSONとJavaコードのマッピングの例があります。
JSON形式{ "name" : "MongoDB", "type" : "database", "count" : 1, "info" : { x : 203, y : 102 } }Java形式
BasicDBObject doc = new BasicDBObject(); doc.put("name", "MongoDB"); doc.put("type", "database"); doc.put("count", 1); BasicDBObject info = new BasicDBObject(); info.put("x", 203); info.put("y", 102); doc.put("info", info);
上のJSON形式のコードはすっきりしており一目で構造が把握できるのに対し、下のJavaのコードはひどいことになっています。長年Javaプログラマをやっている私でも「これはひどいな」と思います。噂ではMongoDBのJavaドライバに絶望してnode.jsに走ってしまったプログラマもいたとかいないとか。
しかしJavaは元々こういう言語ですから、仕方ありません。Javaにはこのようなコードで見える範囲ではなく、もっと他の所にたくさんの良さがありますので、Javaプログラマであればこの程度は我慢して使うのがよいでしょう。上記のJavaのコードは次のようにインデントさせてみたりすると、少しだけ構造を把握しやすくなるかもしれません。
BasicDBObject doc = new BasicDBObject(); doc.put("name", "MongoDB"); doc.put("type", "database"); doc.put("count", 1); BasicDBObject info = new BasicDBObject(); info.put("x", 203); info.put("y", 102); doc.put("info", info);
さて、このようにJavaでは単純なクエリーを組み立てるだけでも冗長な記述が必要となるので、対策として、ユーティリティ的なクラスを作っておき、よくあるパターンの問い合わせについての記述を極力減らせるようにするとよいかと思います。私はJDBCプログラミングでもユーティリティ的なクラスを頻繁に使うことで冗長な記述を避けるようにしていたため、この辺りの事情はJDBCのAPIの場合とまるっきり同じだなぁと感じました。
まだまだMongoDBでの開発経験が少ないため大した例を示すことができず恐縮ですが、例えば以下のようなメソッドなどがあると便利に使えます。基本的な狙いはDBObjectやBasicDBObjectといった記述を呼び出し側コードで省略できるようにする、ということです。
public static List getList( DBCollection coll, String key, Object value ) { DBObject query = new BasicDBObject( key, value ); DBCursor cursor = coll.find( query ); List list = new ArrayList(); while( cursor.hasNext() ) { list.add( cursor.next() ); } return list; }
このメソッドは「key=value」という非常に単純な条件にマッチするドキュメントをすべて取得し、1つのListオブジェクトに格納して返すという動作をします(結果のドキュメント数が多すぎる場合にはメモリ不足を誘発する可能性があるコードとなっているので注意が必要です)。単純にコレクションにアクセスしたい場合にはこのメソッドを呼び出すだけで済みます。そのため呼び出し側のコード記述量が減り、またコードも読みやすくなります。
public static WriteResult updateOne( DBCollection coll, String queryKey, Object queryValue, String updateKey, Object updateValue ) { DBObject query = new BasicDBObject( queryKey, queryValue ); DBObject newObj = new BasicDBObject( "$set", new BasicDBObject( updateKey, updateValue ) ); return coll.update( query, newObj, false, false, WriteConcern.SAFE ); }
このメソッドはごく単純なアップデートを行う場合に使用します。queryKeyがqueryValueの値を持つドキュメントの、updateKeyの値をupdateValueにする、というものです。
このように少し工夫することでコードの記述量を減らすことができるチャンスがありそうです。ただし、複雑な構造を組み立てる場合は、結局BasicDBObjectにせっせとputすることになりそうで、Javaならではの苦行となりそうですが...。
オペレータは単純に文字列である
MongoDBユーザならすぐにおなじみとなる「$gt」「$set」「$inc」などのオペレータは、Javaのドライバ内では単純にStringとして扱われます。こちらのJavaDocにあるようにQueryOperatorクラス内のstaticフィールドとしていくつかが定義されていますが、これらの実装は単なるString型の変数であり、実装はずばり次のようになっています。
public class QueryOperators { public static final String GT = "$gt"; public static final String GTE = "$gte"; public static final String LT = "$lt"; public static final String LTE = "$lte"; public static final String NE = "$ne"; public static final String IN = "$in"; public static final String NIN = "$nin"; public static final String MOD = "$mod"; public static final String ALL = "$all"; public static final String SIZE = "$size"; public static final String EXISTS = "$exists"; public static final String WHERE = "$where"; public static final String NEAR = "$near"; }
私が「あれ?」と思ったのは、例えば「$inc」などはどこにも定義されていないことです。そのため、コードの中で積極的に上記の定義を使おうとしても、「$inc」などについては結局文字列として"$inc"と書いてしまう方が多いのではないかと思います。自分で新規にQueryOperatorsクラスのようなものを作って、そこに定義するのもひとつの手かもしれません。
特別な意味を持つオペレータが単純に文字列として扱われてしまうため、例えばユーザ入力に$がある場合などのエスケープが必要な場合なども想定されます。多くのアプリケーションではオペレータの部分にユーザ入力が入ってくることはないと思いますが、少し変わったことをやろうとしたときにセキュリティホールを作り込んでしまう可能性があるように感じました。せっかくのJavaなので、オペレータには独自のクラスが定義されており、型チェックが自動的に行われるような動作が行われればよかったのでは、と感じました(ドライバの実装上、何かしらの理由があって今のような形になっているのだろうと思いますが...)。
MongoExceptionはRuntimeExceptionを継承している
MongoDB関連の例外としてMongoExceptionクラスが定義されていますが、これはRuntimeExceptionを継承しています。つまり関数の定義において明示的にthrows MongoExceptionと記述する必要がありません。これはJavaにおいて賛否両論あるテーマとなっており、MongoDBがRuntimeExceptionを選んだのは、いかにも最近のソフトウェアらしい、と感じました。しかし私個人の好みはJDBCにおけるSQLExceptionのように必ず宣言する必要がある例外(checked exception)であったため、少し残念です。
DBクラス(オブジェクト)は抽象的な扱いである
MongoDBにおいて、JDBCプログラミングでのConnectionクラスに相当するのがDBクラスなのかと思っていたのですが、微妙に異なるようです。JDBCのConnectionクラスではcloseメソッドを呼ぶことで裏側にあるTCPコネクションが閉じられたりしますが、DBクラスにはそもそもcloseメソッドが存在しません(Mongoクラスに存在しています)。MongoDBではTCPコネクションレベルの、いわゆる「実際のコネクション」は抽象化されて扱われており、プログラマからは直接扱えないようになっています。
この抽象化は非常によくできており、たとえば一度MongoDBが落ちて再起動するようなことがあっても、ちょうどそのタイミングにJavaコード側からMongoDBにアクセスしていなければ、MongoDBが落ちたことに気づかずにコードを実行することができます。つまり、一度取得したDBクラスのインスタンスは、MongoDBの再起動後(バックグラウンドでTCPコネクションがすべて切断され、ドライバによって再び自動的に接続されなおした後)にもそのまま使用することができます。Mongoクラスのインスタンスについても同様で、「アクセスしてみたら、既にMongoDB側からcloseされていたので例外が投げられた」ようなことはなさそうです。これはJDBCに比べて良くできている点ではないかと思います。
スレッドセーフである
ドキュメントで明示的に「スレッドセーフである」と謳われており、安心してマルチスレッドな環境から使用することができます。
MongoDBでは書き込みが即座に(同時に接続している他のクライアントから見えるデータベースに)反映されるわけではないという性質がありますが、この挙動を自分のコード側から避けたい場合にはDBクラスのrequestStart()およびrequestDone()などを使用すればよいようです。この場合ドライバ内でThreadLocalクラスが使用され、書き込みとそれに続く読み込みが内部で同じコネクション(一貫したデータアクセスが提供され、書き込んだものは次の読み込みで見えるようになっている)によって実行されるようになります。
つまりドライバの実装は呼び出し側のスレッドを意識したコードとなっているので、requestStart()を呼び出した後に、DBクラスのインスタンスを別のスレッドに渡し、そちらでrequestDone()を呼び出す、ようなことをするとうまく動作しないと思われます(普通はそんなことはやらないと思いますが...)
Replica Sets使用時の挙動
私がMongoDBを選んだ理由のひとつが親切設計のレプリケーション機能です(具体的にはReplica Setsを利用しています)。Replica Sets使用時のJavaドライバの挙動について少し調べてみました。
書き込み先を選ぶ必要はない
Replica SetsではいくつかのMongoDBのノードのうち、プライマリに対して書き込みを行う必要がありますが、この「どれがプライマリで、どれがセカンダリか」の判断はドライバが行ってくれるため、アプリケーション側は一切気にする必要がありません。障害時にプライマリが切り替わった後も、ドライバが自動的にプライマリの変更などを検知し、アクセス先を変更してくれます。
1つのノードを教えてやればOK
アプリケーション側(具体的には、Mongoクラスのインスタンス)には複数あるReplica Setsのノードのうち、最低1つのノードの場所(IPアドレスあるいはホスト名、ポート番号)を教えてやれば、後は自動的にすべてやってくれます。
セカンダリだけ教えてもOK
アプリケーション側で意図せずにセカンダリの場所だけを教えてしまった場合でも、自動的にプライマリを見つけ、書き込みはそちらに行ってくれます。
プライマリを教える方がよい
ただし、前項に書いた「セカンダリの場所だけ教える」のは、可能であれば避けた方がよいようです。次のようなテストで、何度か例外が出ることを確認しました。
- Mongoクラスのインスタンスを新規に生成し、直後にデータ書き込みを行う
- Mongoクラスのインスタンスのcloseを呼び出す
- 上記を繰り返す
例外が出る原因について、半分は推測ですが、次のようなもののようです。
まず、Replica Sets使用時には、Javaドライバの内部で2つのスレッド(ReplicaSetStatus:UpdaterスレッドとMongoCleanerスレッド)が生成されます。これらのスレッド(のうち、おそらくReplicaSetStatus:Updaterの方)はMongoクラスのインスタンスが生成された直後に生成され、Replica Setsの状態を把握するためにMongoDBに接続し、プライマリやセカンダリなどがどこにいるのかを知り、それらにTCPコネクションを張ります。これには(おそらく1秒以下だと思われますが)時間がかかるため、この前に書き込みが行われてしまう場合にエラーが出ることがあるようです。
プライマリ側のアドレスのみを教えた場合には上記テストでエラーは出なかったので、プライマリ側のアドレスを教えるようにするのがよいでしょう。また、Mongoクラスのインスタンスを作成したら1秒ほどスリープさせるという方法でもエラーが出なくなったので、そのような方法もよいかもしれません。
多くのアプリケーションではMongoクラスのインスタンスは最初に1度作ったきり、それを使い続けると思います。そのような場合はこの問題についてあまり神経質になる必要はありません。
コンストラクタでレプリケーション種別を実質的に指定することになる
Javaアプリケーション側でMongoクラスのインスタンスを生成する際のコンストラクタの引数によって、どのレプリケーションを使用するか(あるいはレプリケーションを使用しないか)を指定することになります。このこと自体は慣れてしまえば何でもないのですが、初めての場合はわかりにくいかもしれません。MongoクラスのJavaDocにわかりやすいコード例があるので、具体的な記述方法についてはそちらを参照ください。
ひとつ覚えておくべきことは「Javaのコード側で明示的にレプリケーションのタイプを指定することになる」ということです。今までスタンドアローンのMongoDBにアクセスしていたアプリケーションをReplica Setsにアクセスするようにするためには、コンストラクタのコードを変える必要があります。変えない場合は、プライマリが移動した際に追いかけてくれません。
また、逆の場合(Replica SetsにアクセスしていたコードをスタンドアローンのMongoDBにアクセスさせる場合)はそのままでも動作してくれるようです。
コネクションがリークする場合がある
これは単純にバグだと思うのでここに書いているよりも報告したほうが良いのですが、見つけたので書いておきます。Mongoクラスのインスタンス1つに対して1つのコネクションがリークするだけの問題なので、普通はまったく気にする必要がないバグです。
Replica Setsではホスト名とIPアドレスなどが区別されます。Replica Setsを構成する際にホスト名(例えばnode1.example.jpなど)を設定情報として持っているにも関わらず、Javaコード側(具体的にはMongoクラスのインスタンスを生成する際のコンストラクタに渡す引数)にホスト名ではなくIPアドレスを渡してしまうと問題が起こります。ドライバははじめにそのIPアドレスで指定されたノードに接続し、Replica Setsの情報を取得しますが、その後さらに設定情報に基づいてホスト名ベースでのTCPコネクションを新規に取得します。
しばらく後に、Mongoクラスのインスタンスのclose()メソッドを呼び出した際、これらのホスト名ベースによって取得されたTCPコネクションは正しく破棄されますが、最初に作成されたIPアドレスベースのコネクションがcloseされず、netstatなどで状態を見るとEstablishedのまま残ってしまうことが確認できます。
先述したようにMongoクラスは普通一度作ったらそれっきり、という場合が多いと思います。この場合はこのコネクションリークは問題にはなりません。ただし、MongoDBにそれほど頻繁にアクセスを行うわけではないJavaアプリケーション(特にサーバなど)で、毎回アクセスするたびにMongoクラスのインスタンスを作り直しているような場合には深刻なリソースリークに繋がるおそれがあるので注意が必要です。
スレッドが2つ生成される
先ほどから書いているようにReplica Setsを使用する場合にはドライバ内部でスレッドが2つ生成されるので、Mongoクラスのインスタンスの作成はリソース的にはやや重い処理になります。ループ内部でMongoクラスのインスタンスを大量に作成するようなことは避けるようにしましょう(そんなことはまず行われないと思いますが...)。
動作環境など
はじめてのMongoDB/Javaアプリ開発を経験して感じたこと、気づいたことなどを簡単にまとめてみました。おそらく間違っている点や不足している点も多々あるかと思いますので、フィードバックを@kinyukaにいただければ幸いです。
なお、動作確認を行った環境はLinux X86_64、Java 1.6.0_22、MongoDB 1.8.1、Java-Mongoドライバー2.6.3です。