많은 앱에는 물리적 위치에 따라 색인이 생성되는 문서가 있습니다. 예를 들어 앱에서 사용자가 현재 위치 근처의 매장을 찾아볼 수 있습니다.
Firestore는 복합 쿼리당 단일 범위 절만 허용합니다. 즉, 위도와 경도를 별도의 필드로 저장하고 경계 상자를 쿼리하는 것만으로는 지역 쿼리를 수행할 수 없습니다.
솔루션: GeoHash
Geohash는 (latitude, longitude)
쌍을 단일 Base32 문자열로 인코딩하기 위한 시스템입니다. Geohash 시스템에서 세계는 직사각형 그리드로 나뉩니다.
Geohash 문자열의 각 문자는 프리픽스 해시의 32개 구획 중 하나를 지정합니다. 예를 들어 Geohash abcd
는 더 큰 Geohash abc
내에 완전히 포함된 32개의 4자 해시 중 하나입니다.
두 해시 간의 공유 프리픽스가 길수록 서로 더 가까워집니다. 예를 들어 abcdef
는 abcdff
보다 abcdeg
에 더 가깝습니다. 그러나 그 역은 반드시 성립하지 않습니다. 매우 다른 Geohash를 가진 두 영역도 서로 매우 가까울 수 있습니다.
Geohash를 사용하면 효율적으로 Firestore에서 위치별로 문서를 저장하고 쿼리할 수 있으며 단일 색인 생성 필드만 있으면 됩니다.
도우미 라이브러리 설치
Geohash를 만들고 파싱하는 작업은 까다롭기 때문에 Android, iOS, 웹에서 가장 어려운 부분을 요약한 도우미 라이브러리가 개발되었습니다.
웹
// 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
Swift
// Add this to your Podfile
pod 'GeoFire/Utils'
자바
Android
// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'
Geohash 저장
위치별로 색인을 생성하려는 각 문서에 대해 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(() => {
// ...
});
Swift
// 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
// ...
}
자바
Android
// 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를 사용하면 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
// ...
});
Swift
// Find cities within 50km of London
let center = CLLocationCoordinate2D(latitude: 51.5074, longitude: 0.1278)
let radiusInKilometers: Double = 50
// 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: radiusInKilometers)
let queries = queryBounds.compactMap { (any) -> Query? in
guard let bound = any as? GFGeoQueryBounds else { return nil }
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 <= radiusInKilometers {
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)
}
자바
Android
// 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의 쿼리가 정확하지 않으며 클라이언트 측에서 거짓양성 결과를 필터링해야 합니다. 이러한 추가 읽기로 인해 앱에 비용과 지연 시간이 추가됩니다.
- 에지 케이스 - 이 쿼리 메서드는 경도/위도의 거리 추정에 의존합니다. 지점이 북극 또는 남극에 가까워질수록 이 추정치의 정확도는 낮아집니다. 즉, Geohash 쿼리는 극위도에서 더 많은 거짓양성이 나타납니다.