はじめに
私はバックエンドにFirebaseを利用した事故マップというサービスを作っています。
このサービスはユーザーが自分が遭遇した事故情報をマップ上で投稿してシェアすることができ、
投稿された情報はFirestoreを使って管理をしています。
ユーザーは投稿の他にマップ上に投稿情報が無いか検索することができ、情報が見つかった場合は
マップ上に事故の発生場所にマーカーが配置されます。
この座標検索を実装するのが結構苦労したので、その理由と解決方法を紹介したいと思います。
苦労した理由
Firestoreは座標検索と相性が悪い
Firestoreは後述する理由で、ある範囲に存在する座標を検索するのがとても難しいです。
Firestoreとは
詳細な説明は省きますが、スキーマがきちっと決まったRDBとは異なる下記の特徴を持ったNoSQLデータベースです。
- ドキュメントとコレクションという概念を持ったデータベース
- 大きなハッシュテーブルのリスト
- コレクションというデータ配列の中にドキュメントと呼ばれるハッシュテーブルを持つ
- コレクション内のドキュメントは必ずしも、同じスキーマである必要はない
- そのため、Aにはname:という名前のフィールドがあってBにはそれが無いというのも全然OK
Firestoreは複数のフィールドを組み合わせた範囲検索に対応していない
言葉だと分かりづらいので例で説明します。まず下記のようなデータがあったとします。
{
{
id: "SF",
c: City{
Name: "San Francisco", State: "CA", Country: "USA", Capital: false, Population: 860000, Area: 121.4
},
},
{
id: "LA",
c: City{
Name: "Los Angeles", State: "CA", Country: "USA", Population: 3900000, Area: 1,302
},
},
{
id: "DC",
c: City{
Name: "Washington D.C.", Country: "USA", Capital: false, Population: 680000, Area: 177
},
},
}
このとき人口が900000以下で、面積が150以下の都市を絞り込みたいとした場合、
SQLではWHERE句をWHERE Population <= 900000 AND Area <= 150
と書くと思いますがこれがFirestoreではできません。
座標は緯度と経度の二つの軸で表されるためこれは致命的です。
え、別にプログラムで一度人口が900000以下のデータを取ってきて、ローカルでフィルタしてしまえば良いんじゃ無いの?と思う方もいるかも知れませんがそう甘くはありませんでした。
Firestoreは検索に引っ掛かったドキュメント単位で課金が発生する
Googleもこの辺うまく考えており、とりあえず全部のデータ引っ掛けてローカルでフィルタできないように、課金の基準はFirestoreから落ちてくるデータ容量ではなく、ドキュメントの数としています。
母数データが数十件であれば特に問題はないと思いますが、何万何十万とデータが存在した場合、一次元の条件検索だと万のオーダーの結果が返ってくるためお金がすぐにショートしてしまいます。
つまり、Firestore標準では
{
latitude: xxx,
longitude: xxx,
}
というようなドキュメントを扱うのは工夫が必要ということになります。
解決方法
結論から言うと、Geohashを使うことで解決できます。
Geohashとは
- 緯度と経度という二次元の情報をハッシュ文字列で表したもの
- 2点の座標が近ければ近いほど生成される文字列は類似したものになる
- その類似度で2点間の近接度を解釈することができる
Geohashを使えば { geohash: xxx }
という1つのフィールドで表せるため、範囲の検索ができるようになります。
だが自前でやると相当面倒くさい
言うのは簡単ですが、次の処理をやらないといけないため自前で書くのは大変です。
- Geohashの生成
- ハッシュ値の類似度比較ロジックを作る
- そのロジックをFirestoreのqueryに組み込む
GeoFireXを使えばそれ全部やってくれます
GeoFireXというライブラリを使えばこれらを全部やってくれます。
- Geohashの生成
→ geofirex.point()でgeopointを含むドキュメントを生成してくれる - ハッシュ値の類似度比較ロジックを作る
- そのロジックをFirestoreのqueryに組み込む
→ geofirex.point.query().within()の中で全部やってくれる
尚、geopointとはFirestoreの中で定義されている座標データを扱うための型のことです。
GeofireXが作ってくれるpointドキュメントはFirestore上は、Geohashとgeopointを含んだ形で下記のように見えます。
GeoFireXの使い方
インストール
事故マップはVue UIで作っているのでyarn add geofirex
でインストールしました。
保存
import firebase from "firebase";
import * as geofirex from "geofirex";
const geo = geofirex.init(firebase);
var db = firebase.firestore()
db.collection("markers").add({
geography: geo.point(35.546325349874174, 139.72273603069488),
})
.then(function(docRef) {
console.log(docRef.id)
})
.catch(function(error) {
console.log(error)
}
検索
import firebase from "firebase";
import * as geofirex from "geofirex";
const geo = geofirex.init(firebase);
var center = geo.point(35.546325349874174, 139.72273603069488);
var db = firebase.firestore();
var m = db.collection("markers");
var query = geo.query(m).within(center, 2, "geography"); // centerから半径2kmの座標を検索する
geofirex.get(query)
.then((hits) => {
hits.forEach(function(data) {
console.log(data);
})
})
最後に
ご紹介の通り、Firestoreは少し癖があるため、GeoFireXはFirestoreとマップを組み合わせたWebサービスの開発には必須のライブラリだと言えます。
残念ながらGeoFireXはnpmパッケージのためSwiftやJavaには対応していません。
Firestoreとマップを使ったネイティブアプリを作るためには別の方法を探さなければいけません。今後ネイティブアプリ向けの方法がわかったら改めてご紹介したいと思います。