Mongooseを使って複数のMongoDBを1つのDBのように扱う方法

Tokyo Otaku ModeではNode.jsからMongoDBにアクセスするのにODMとしてMongooseを採用しています。

Mongoose(ODM)を利用するメリットとしては、

  1. collectionのSchema設計がコードに残る
  2. virtualなどデータ周りの機能がModelに集約できる
  3. populationが利用できる

などが上げられます。

どれもサービスを効率的に作る上で助かる機能ばかりですが、とくにpopulationは別ドキュメントのreference(_id)を持っているだけで、自動的にドキュメントに展開してくれる強力な機能です。

一方で、DBロック回避の目的やある程度の規模になってDBを分割するようになると、別DBのcollectionをpopulateできないという問題がでてきます。
Mongooseの仕様上、特定DBへのconnectionとModel(collection)が密に結びついているため、参照元のModelと同じconnectionを持っていないと、参照先に辿りつけないからです。

これを回避するため、Tokyo Otaku ModeではMongooseのconnectionに少し手を入れてDB間のpopulationを実現しています。

まず簡単にpopulationの機能を説明します。
以下の様な、テキストが保存されたPostsドキュメントとそのテキストを投稿したユーザーが保存されたUsersドキュメントがあるとします。

Posts.js 投稿データ
1
2
3
4
5
{
"_id" : ObjectID("540805939d1960ae87ab0149"),
"user": ObjectId("540805ad9d1960ae87ab014a"),
"text": "Message"
}
Users.js ユーザーデータ
1
2
3
4
{
"_id" : ObjectID("540805ad9d1960ae87ab014a"),
"name": "Test User"
}

こういうドキュメント構造に対して、populationを指定して実行すると、

1
2
3
Posts.findById("540805939d1960ae87ab0149")
.populate("user")
.exec(callback);

userが展開されて取得できます。

Posts.js 投稿データ
1
2
3
4
5
6
7
8
{
"_id" : ObjectID("540805939d1960ae87ab0149"),
"user": {
"_id" : ObjectID("540805ad9d1960ae87ab014a"),
"name": "Test User"
},
"text": "Message"
}

裏側では、populationで指定されたフィールド(user)のリファレンス先collection(Users)に接続してデータを取得・展開しているだけですが、自分自身と同じconnectionを利用するので、別DBのcollectionは未定義のModelと判断されてしまいます。

さて、では実際にどう解決しているのかコードベースで解説いたします。

設定情報

環境ごとの設定ファイルになります。
ModelがどのDBに存在するかのマッピングを設定するroutesと、それぞれの接続情報が書かれています。何も設定しないとmongodb1 DBに接続するようになっています。

conf/mongodb.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
###*
MongoDB接続に関わる設定

- masterName {String} デフォルトで接続するDB名(serversのキーを一つ指定する)
- servers {Object} サーバー情報
- key: object
- key {String} servers内で一意のキー
- object {Object} keyに結びつく接続情報
- host {String} host名
- port {Number} port番号
- database {String} データベース名
- replicaSet {Object} replicaSet時の設定
- routes {Object} Mongoose.Model名とserverのキーを対応させる
指定しない場合はmasterNameに指定されたサーバーに接続する
###
module.exports =
masterName: "mongodb1"
servers:
mongodb1:
replicaSet:
members: [
host: "mongodb1a"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"
,
host: "mongodb1b"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"
,
host: "arbiter1"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"
]
options:
replset:
rs_name: "mongodb1"
db:
slave_ok: true

mongodb2:
replicaSet:
members: [
host: "mongodb2a"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"
,
host: "mongodb2b"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"
,
host: "arbiter2"
port: 27018
database: "tokyootakumode"
username: "username"
password: "password"
]
options:
replset:
rs_name: "mongodb2"
db:
slave_ok: true

mongodb3:
host: "mongodb3"
port: 27017
database: "tokyootakumode"
username: "username"
password: "password"

routes:
Users: "mongodb1"
Posts: "mongodb2"
Rankings: "mongod3"

コネクションマネージャ

上記設定情報を元に、どのModelをどのconnectionに結びつけるかを決定し、実際にコネクションを管理します。
肝は最初の接続時に接続先connectionとModel情報をマッピングしておき、Modelを呼び出す際に、対象のconnectionに書き換える点です。

document/manager.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
var mongoose = require('mongoose');
var conf = require(__root + '/conf/mongodb');
var Connection = require('mongoose/lib/connection');
var Model = require('mongoose/lib/model');
var Schema = require('mongoose/lib/schema');

/**
* メインオブジェクト
*
*/
var manager = {

connections: {},
paths: {},

/**
* conf/mongodb.coffeeのroutesで指定された接続先へ接続させる
*
* @param {String} name
* @param {mongoose.Schema} schema
* @returns mongoose
*/
dispatch: function(name, schema) {
if (conf.routes && conf.routes[name]) {
if (!(this.connections[conf.routes[name]] instanceof Connection)) {
throw new Error('"' + conf.routes[name] + '" is not Mongoose connection.');
}
return this.connections[conf.routes[name]].model(name, schema);
}

return this.connections[conf.masterName].model(name, schema);
}
};

var routes = {};
var connections = [];

/**
* Connection.model(Model.db.model)の拡張
*
* Model設定時にModel名と接続先情報の関連付けを保存しておく(routes)
* {
* 'Posts': 'localhost:27017/tokyootakumode',
* 'Users': 'localhost:27018/tokyootakumode'
* }
*
* Model名(name)->接続DB名->コネクションを取得
*/
Connection.prototype.originalModel = Connection.prototype.model;
Connection.prototype.model = function(name, schema, collection) {

//初期化時はこちらを通る
if (schema instanceof Schema) {
if (this.options && this.options.replset && this.options.replset.rs_name) {
routes[name] = this.options.replset.rs_name + '/' + this.name;
} else {
routes[name] = this.host + ':' + this.port + '/' + this.name;
}
return Connection.prototype.originalModel.call(
this, name, schema, collection
);
}

//呼び出し時はこちらを通る
var dbName = manager.paths[routes[name]];
var connection = manager.connections[dbName];
return Connection.prototype.originalModel.call(
connection, name, schema, collection
);
}

//設定ファイルからコネクション情報抽出
for (var i in conf.servers) {
var server = conf.servers[i];

//レプリカセットへの接続
if (server.replicaSet) {
var options = server.replicaSet.options || {};
var uris = [];
server.replicaSet.members.forEach(function(member) {
uris.push('mongodb://' + member.username + ':' + member.password + '@'
+ member.host + ':' + member.port + '/' + member.database);
});
connections.push({
name: i,
connection: mongoose.createConnection(uris.join(','), options)
});

//シングルサーバーへの接続
} else {
var options = server.options || {};
connections.push({
name: i,
connection: mongoose.createConnection(
'mongodb://' + member.username + ':' + member.password + '@'
+ server.host + ':' + server.port + '/' + server.database, options)
});
}
}

//コネクション情報をmanagerオブジェクトへ代入
connections.forEach(function(connection) {
//実コネクション
manager.connections[connection.name] = connection.connection;

/**
* 接続先情報とコネクション名の関連付けを保存しておく
* {
* 'localhost:27017/tokyootakumode': 'mongodb1',
* 'localhost:27018/tokyootakumode': 'mongodb2'
* }
*/
if (connection.connection.options && connection.connection.options.replset
&& connection.connection.options.replset.rs_name) {
manager.paths[connection.connection.options.replset.rs_name + '/'
+ connection.connection.name] = connection.name;
} else {
manager.paths[connection.connection.host + ':' + connection.connection.
port + '/' + connection.connection.name] = connection.name;
}
});

module.exports = manager;

各モデル

通常、mongoose.modelに名前とSchemaを渡すところをmanager.dispatchに渡します。

Posts.js
1
2
3
4
5
6
7
8
9
10
11
12
13
var manager = require(__root + '/document/manager');
var Schema = require('mongoose').Schema
var Posts = new Schema({
"user": {
"type": Schema.ObjectId,
"ref": "Users"
}
"text": {
"type": String
}
});

module.exports = manager.dispatch('Posts', Posts);
Users.js
1
2
3
4
5
6
7
8
9
var manager = require(__root + '/document/manager');
var Schema = require('mongoose').Schema
var Users = new Schema({
"name": {
"type": String
}
});

module.exports = manager.dispatch('Users', Users);

最後に

このように、managerにconnectionの管理とModelのマッピング機能を一つにまとめ、各Modelでは特に接続先を意識せずにmongoose.modelmanager.dispatchに置き換えるだけで、分断されたDB間でもpopulationが利用できるようにしています。
DBの構成変更は設定ファイルを変更するだけでメンテナンスもしやすくなっています。

Tokyo Otaku ModeではNode.js/MongoDBを使い倒したい エンジニアを募集しています。
興味のある方はこちらからご応募ください。