多くのアプリには、物理的な場所によってインデックスが作成されたドキュメントがあります。たとえば、アプリでユーザーが現在地の近くの店舗を閲覧できるようにすることができます。
Cloud Firestore では、複合クエリごとに 1 つの範囲句のみが許可されます。つまり、緯度と経度を別々のフィールドとして保存し、境界ボックスをクエリするだけでは地理クエリを実行できません。
解決策: ジオハッシュ
Geohash は、 (latitude, longitude)
ペアを単一の Base32 文字列にエンコードするためのシステムです。 Geohash システムでは、世界は長方形のグリッドに分割されます。 Geohash 文字列の各文字は、プレフィックス ハッシュの 32 の下位区分の 1 つを指定します。たとえば、Geohash abcd
は、より大きな Geohash abc
に完全に含まれる 32 個の 4 文字のハッシュの 1 つです。
2 つのハッシュ間で共有されるプレフィックスが長ければ長いほど、それらは互いに近くなります。たとえば、 abcdef
はabcdeg
よりもabcdff
に近いです。ただし、その逆は正しくありません。非常に異なる Geohash を持ちながら、2 つの領域が互いに非常に接近している場合があります。
Geohash を使用すると、Cloud Firestore 内の位置ごとにドキュメントを保存してクエリを実行できますが、必要なインデックス フィールドは 1 つだけです。
ヘルパー ライブラリをインストールする
Geohash の作成と解析には難しい計算が必要になるため、Android、Apple、および Web で最も難しい部分を抽象化するヘルパー ライブラリを作成しました。
ウェブ
// Install from NPM. If you prefer to use a static .js file visit
// https://github.com/firebase/geofire-js/releases and download
// geofire-common.min.js from the latest version
npm install --save geofire-common
迅速
Kotlin+KTX
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.2.0'
Java
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'
ジオハッシュを保存する
場所ごとにインデックスを作成するドキュメントごとに、Geohash フィールドを保存する必要があります。
ウェブ
// Compute the GeoHash for a lat/lng point const lat = 51.5074; const lng = 0.1278; const hash = geofire.geohashForLocation([lat, lng]); // Add the hash and the lat/lng to the document. We will use the hash // for queries and the lat/lng for distance comparisons. const londonRef = db.collection('cities').doc('LON'); londonRef.update({ geohash: hash, lat: lat, lng: lng }).then(() => { // ... });
迅速
// Compute the GeoHash for a lat/lng point let latitude = 51.5074 let longitude = 0.12780 let location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) let hash = GFUtils.geoHash(forLocation: location) // Add the hash and the lat/lng to the document. We will use the hash // for queries and the lat/lng for distance comparisons. let documentData: [String: Any] = [ "geohash": hash, "lat": latitude, "lng": longitude ] let londonRef = db.collection("cities").document("LON") londonRef.updateData(documentData) { error in // ... }
Kotlin+KTX
// Compute the GeoHash for a lat/lng point val lat = 51.5074 val lng = 0.1278 val hash = GeoFireUtils.getGeoHashForLocation(GeoLocation(lat, lng)) // Add the hash and the lat/lng to the document. We will use the hash // for queries and the lat/lng for distance comparisons. val updates: MutableMap<String, Any> = mutableMapOf( "geohash" to hash, "lat" to lat, "lng" to lng ) val londonRef = db.collection("cities").document("LON") londonRef.update(updates) .addOnCompleteListener { // ... }
Java
// Compute the GeoHash for a lat/lng point double lat = 51.5074; double lng = 0.1278; String hash = GeoFireUtils.getGeoHashForLocation(new GeoLocation(lat, lng)); // Add the hash and the lat/lng to the document. We will use the hash // for queries and the lat/lng for distance comparisons. Map<String, Object> updates = new HashMap<>(); updates.put("geohash", hash); updates.put("lat", lat); updates.put("lng", lng); DocumentReference londonRef = db.collection("cities").document("LON"); londonRef.update(updates) .addOnCompleteListener(new OnCompleteListener<Void>() { @Override public void onComplete(@NonNull Task<Void> task) { // ... } });
ジオハッシュのクエリ
Geohash を使用すると、Geohash フィールドで一連のクエリを結合し、いくつかの誤検知を除外することで、エリア クエリを概算できます。
ウェブ
// Find cities within 50km of London const center = [51.5074, 0.1278]; const radiusInM = 50 * 1000; // Each item in 'bounds' represents a startAt/endAt pair. We have to issue // a separate query for each pair. There can be up to 9 pairs of bounds // depending on overlap, but in most cases there are 4. const bounds = geofire.geohashQueryBounds(center, radiusInM); const promises = []; for (const b of bounds) { const q = db.collection('cities') .orderBy('geohash') .startAt(b[0]) .endAt(b[1]); promises.push(q.get()); } // Collect all the query results together into a single list Promise.all(promises).then((snapshots) => { const matchingDocs = []; for (const snap of snapshots) { for (const doc of snap.docs) { const lat = doc.get('lat'); const lng = doc.get('lng'); // We have to filter out a few false positives due to GeoHash // accuracy, but most will match const distanceInKm = geofire.distanceBetween([lat, lng], center); const distanceInM = distanceInKm * 1000; if (distanceInM <= radiusInM) { matchingDocs.push(doc); } } } return matchingDocs; }).then((matchingDocs) => { // Process the matching documents // ... });
迅速
// Find cities within 50km of London let center = CLLocationCoordinate2D(latitude: 51.5074, longitude: 0.1278) let radiusInM: Double = 50 * 1000 // Each item in 'bounds' represents a startAt/endAt pair. We have to issue // a separate query for each pair. There can be up to 9 pairs of bounds // depending on overlap, but in most cases there are 4. let queryBounds = GFUtils.queryBounds(forLocation: center, withRadius: radiusInM) let queries = queryBounds.map { bound -> Query in return db.collection("cities") .order(by: "geohash") .start(at: [bound.startValue]) .end(at: [bound.endValue]) } var matchingDocs = [QueryDocumentSnapshot]() // Collect all the query results together into a single list func getDocumentsCompletion(snapshot: QuerySnapshot?, error: Error?) -> () { guard let documents = snapshot?.documents else { print("Unable to fetch snapshot data. \(String(describing: error))") return } for document in documents { let lat = document.data()["lat"] as? Double ?? 0 let lng = document.data()["lng"] as? Double ?? 0 let coordinates = CLLocation(latitude: lat, longitude: lng) let centerPoint = CLLocation(latitude: center.latitude, longitude: center.longitude) // We have to filter out a few false positives due to GeoHash accuracy, but // most will match let distance = GFUtils.distance(from: centerPoint, to: coordinates) if distance <= radiusInM { matchingDocs.append(document) } } } // After all callbacks have executed, matchingDocs contains the result. Note that this // sample does not demonstrate how to wait on all callbacks to complete. for query in queries { query.getDocuments(completion: getDocumentsCompletion) }
Kotlin+KTX
// Find cities within 50km of London val center = GeoLocation(51.5074, 0.1278) val radiusInM = 50.0 * 1000.0 // Each item in 'bounds' represents a startAt/endAt pair. We have to issue // a separate query for each pair. There can be up to 9 pairs of bounds // depending on overlap, but in most cases there are 4. val bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM) val tasks: MutableList<Task<QuerySnapshot>> = ArrayList() for (b in bounds) { val q = db.collection("cities") .orderBy("geohash") .startAt(b.startHash) .endAt(b.endHash) tasks.add(q.get()) } // Collect all the query results together into a single list Tasks.whenAllComplete(tasks) .addOnCompleteListener { val matchingDocs: MutableList<DocumentSnapshot> = ArrayList() for (task in tasks) { val snap = task.result for (doc in snap!!.documents) { val lat = doc.getDouble("lat")!! val lng = doc.getDouble("lng")!! // We have to filter out a few false positives due to GeoHash // accuracy, but most will match val docLocation = GeoLocation(lat, lng) val distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center) if (distanceInM <= radiusInM) { matchingDocs.add(doc) } } } // matchingDocs contains the results // ... }
Java
// Find cities within 50km of London final GeoLocation center = new GeoLocation(51.5074, 0.1278); final double radiusInM = 50 * 1000; // Each item in 'bounds' represents a startAt/endAt pair. We have to issue // a separate query for each pair. There can be up to 9 pairs of bounds // depending on overlap, but in most cases there are 4. List<GeoQueryBounds> bounds = GeoFireUtils.getGeoHashQueryBounds(center, radiusInM); final List<Task<QuerySnapshot>> tasks = new ArrayList<>(); for (GeoQueryBounds b : bounds) { Query q = db.collection("cities") .orderBy("geohash") .startAt(b.startHash) .endAt(b.endHash); tasks.add(q.get()); } // Collect all the query results together into a single list Tasks.whenAllComplete(tasks) .addOnCompleteListener(new OnCompleteListener<List<Task<?>>>() { @Override public void onComplete(@NonNull Task<List<Task<?>>> t) { List<DocumentSnapshot> matchingDocs = new ArrayList<>(); for (Task<QuerySnapshot> task : tasks) { QuerySnapshot snap = task.getResult(); for (DocumentSnapshot doc : snap.getDocuments()) { double lat = doc.getDouble("lat"); double lng = doc.getDouble("lng"); // We have to filter out a few false positives due to GeoHash // accuracy, but most will match GeoLocation docLocation = new GeoLocation(lat, lng); double distanceInM = GeoFireUtils.getDistanceBetween(docLocation, center); if (distanceInM <= radiusInM) { matchingDocs.add(doc); } } } // matchingDocs contains the results // ... } });
制限事項
場所のクエリにジオハッシュを使用すると、新しい機能が得られますが、独自の制限があります。
- 誤検知 - Geohash によるクエリは正確ではなく、クライアント側で誤検知の結果を除外する必要があります。これらの余分な読み取りにより、アプリのコストとレイテンシが増加します。
- エッジ ケース- このクエリ方法は、経度/緯度の線間の距離の推定に依存しています。この推定の精度は、ポイントが北極または南極に近づくにつれて低下します。これは、極度の緯度で Geohash クエリの誤検出が増えることを意味します。