今日も適当ダイアリー

PHP や Javascript や Symfony、BEAR.Sunday などのWeb周りのことを中心に。それ以外のことも気まぐれに投稿します。

MongoDBの地理空間のインデックスを試してみる

MongoDB面白いですね。
ドキュメントが親切なのでありがたいのですが、なかなか日本語の記事が無かったりするので、Geospatial Indexingについて試してみた事を投稿しますよ!

マニュアル:MongoDB internal corp site


Geospatial Indexing は MongoDB ver1.3.3以上で使えます。

地理空間のインデックスとは

読んで字のごとくなのですが、MongoDBでは二次元地理空間情報(多くの場合、緯度経度情報)をインデックスとして検索をかけることが出来ます。

最近のガラケースマートフォンからは簡単にGPS機能を利用することもできますし、PCブラウザなどでも位置情報を取得出来るようになったり、html5のGeolocation API(厳密にはHTML5に含まれてはいない)の登場などで、今後、ますます位置情報の利用頻度は高くなるんじゃないかと思います。

でも、位置情報って検索が面倒だったりして、そのままで使いやすい情報ではないです。

GeoHashやGeoHexなどというエリア(面)情報として文字列で保存する方法などもありますが、一長一短。

例えば、GeoHashは、文字数が長いほど詳細なエリアを表し、検索なども非常に使い勝手がいい(文字列比較で済む)のですが、その特徴上、距離でのソートが難しかったり、検索エリアの範囲を細かく指定しづらかったりと、デメリットもあります。

どんな技術も、制約と利便性のトレードオフなので、上手く使い分けてやる必要があります。

今回は、MongoDB の Geospatial Index を用いて「近い店検索」のようなものを実験してみます。

データの追加

今回は、テスト用なので渋谷付近にランダムなデータを100件ほど追加します。

以下はmongoシェルでの操作です。

// geotest というDBを使います
use geotest

// 渋谷付近の経度緯度
var baseLng = 139.65534000898916;
var baseLat = 35.63588652257869;
var diffLng = 139.75730686689930 - baseLng;
var diffLat = 35.67661462940957 - baseLat;

// カテゴリのリスト
var categories = ['カフェ', 'バー', 'レストラン'];

// ランダムに100件のデータを追加
for (var i=1; i<=100; i++) {
  var myLng = baseLng + (Math.random() * diffLng);
  var myLat = baseLat + (Math.random() * diffLat);

  var myCategory = categories[
    Math.floor(Math.random() * categories.length)
  ];

  // ランダムなデータを保存
  db.places.save({
    location: [myLng, myLat], // 位置情報
    category: myCategory      // カテゴリ
  });
}

shell で JavaScript が利用できるのは幸せですね。
ランダムデータの生成も楽ちんです。

Index を作成

次に、地理空間インデックスを作成します。

MongoDB では複合インデックスもOKなので、位置情報と共に、カテゴリ情報にもインデックスを張ります。

// インデックスを作成
db.places.ensureIndex({
  location : "2d", // location に Geospatial Index を張る
  category : 1     // category に Index を張る
});

検索してみる

完全一致で検索

db.places.find({ location : [139.66, 35.65] });

普通のインデックスで検索

db.places.find({ category : "レストラン" });

$near で検索

これだと、あまり意味がないのですが、$nearを使う事で、近いロケーションを検索することができます。
(実際には近い順に並び替えられる)

// [139.66, 35.65] の近くにある店を5件検索
db.places.find({ location : { $near : [139.66, 35.65] } }).limit(5);

もちろん、複合インデックスでの指定もOKです。

// [139.66, 35.65] の近くにあるカフェを5件検索
db.places.find({
  location : { $near : [139.66, 35.65] },
  category : "カフェ" }).limit(5);

$nearの注意点

$near は経度と緯度の1度が同じ距離を指すことを前提として計算が行われます。

例えば、

【基点】
経度:10°
緯度:-84°

から、

【点A】
経度:12°
緯度:-84°

【点B】
経度:10°
緯度:-85°

への距離を計算した場合、経度と緯度の1度が同じ距離である平面とすると、下図のような感じになるため、点Bの方が近いように見えます。

実験してみましょう。

// 点A の追加
db.places.save({
  location: [12, -84], name: 'A'
});

// 点B の追加
db.places.save({
  location: [10, -85], name: 'B'
});

// 基点から近いポイントの検索
db.places.findOne({
  location: {
    '$near': [10, -84]
  }
});

結果は下記のように点Aが返ります。

{
        "_id" : ObjectId("4cef9fb0a22c7137b609561a"),
        "location" : [
                10,
                -85
        ],
        "name" : "B"
}

ですが。。。。

実際には、地球は球体なので場所によって1度は変わります。

今回の経度・緯度の場合、下記のような間隔になると予想出来ます。

※実際の縮尺ではありません。

下記サイトで、地球を球体として二点間の距離を計算してみましょう。
2地点間の距離と方位角 - 高精度計算サイト

すると、

【基点-点A間の距離】23.270942 km
【基点-点B間の距離】111.319491 km

となり、実際には点Aの方が近い事がわかります。

この問題に解決するため、新しい球体のモデルがバージョン1.7.0で追加されました。

球体モデルを使用して求める

この先の球体モデルは MongoDB ver1.7.0 以上で使えます。

利用は簡単です。$near を $nearSphere に変更するだけです。

実際にやってみましょう。

db.places.findOne({
  location: {
    '$nearSphere': [10, -84]
  }
});

結果は下記のように正しく点Aが返るようになりました。

{
        "_id" : ObjectId("4cef9fada22c7137b6095619"),
        "location" : [
                12,
                -84
        ],
        "name" : "A"
}

なお、球体モデルを使用する場合には、位置情報を X(経度), Y(緯度) の順で使っていると仮定されるため、その順序を守る必要があります。

サンプルプログラム

今回は、GoogleMaps APIを使用してサンプルプログラムを作ってみました。

mongodbとのやり取りは下記のような形でPHPにて行っています。

<?php

$geo = new getGeoInfo();

// 見つける!
$geo->find(array(
    'location' => array(
        '$nearSphere' => array(
            (float) $lng,
            (float) $lat,
        ),
    ),
));

class getGeoInfo
{
    private $mongo;
    private $collection;

    public function __construct()
    {
        // mongodb に接続
        $this->mongo = new Mongo('mongodb://localhost:27077', array('persist' => 'myPersistId'));
        // collection を選択
        $this->collection = $this->mongo->selectDB('geotest201011')->selectCollection('places');
    }

    public function find($query = array(), $limit = null)
    {
        // findを実行して、カーソルを取得
        $cursor = $this->collection->find($query);

        // JSON形式で返す
        echo '[';
        $isFirst = true;
        $i = 1;
        while ($cursor->hasNext()) {
            if (!is_null($limit) && $limit < $i) break;
            $item = $cursor->getNext();
            $exportItem = array(
                'location' => $item['location'],
                'category' => $item['category'],
            );

            echo ($isFirst) ? '' : ', ';
            echo json_encode($exportItem);

            if ($isFirst) {
                $isFirst = false;
            }

            ++$i;
        }
        echo ']';
    }
}

以上、駆け足ですが、MongoDBでの地理空間インデックスのサンプルでした!