שאילתות גיאוגרפיות

לאפליקציות רבות יש מסמכים שנוספו לאינדקס לפי מיקומים פיזיים. לדוגמה, האפליקציה שלך עשויה לאפשר למשתמשים לגלוש בחנויות בקרבת מיקומם הנוכחי.

Cloud Firestore מאפשר רק סעיף טווח בודד לכל שאילתה מורכבת , מה שאומר שאיננו יכולים לבצע שאילתות גיאוגרפיות פשוט על ידי אחסון קווי רוחב ואורך כשדות נפרדים ושאילתה של תיבה תוחמת.

פתרון: גאואש

Geohash היא מערכת לקידוד זוג (latitude, longitude) למחרוזת Base32 אחת. במערכת Geohash העולם מחולק לרשת מלבנית. כל תו של מחרוזת Geohash מציינת אחת מ-32 חלוקות משנה של הקידומת hash. לדוגמה, ה-Geohash abcd הוא אחד מ-32 גיבובים של ארבעה תווים הכלולים במלואם בתוך ה-Geohash abc הגדול יותר.

ככל שהקידומת המשותפת בין שני גיבובים ארוכה יותר, כך הם קרובים יותר זה לזה. למשל abcdef קרוב יותר ל- abcdeg מאשר abcdff . אולם ההיפך אינו נכון! שני אזורים עשויים להיות קרובים מאוד אחד לשני תוך כדי גיאואש שונים מאוד:

גיאואש רחוקים זה מזה

אנו יכולים להשתמש ב-Geohashes כדי לאחסן ולשאול מסמכים לפי מיקום ב-Cloud Firestore ביעילות סבירה תוך צורך בשדה בודד שנוסף לאינדקס.

התקן את ספריית העזר

יצירה וניתוח של Geohashes כרוכים במתמטיקה מסובכת, אז יצרנו ספריות מסייעות כדי לפשט את החלקים הקשים ביותר באנדרואיד, אפל ואינטרנט:

אינטרנט

// 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

מָהִיר

הערה: מוצר זה אינו זמין ביעדי watchOS ו-App Clip.
// הוסף את זה לתרמיל Podfile שלך ​​'GeoFire/Utils'

Java

// Add this to your app/build.gradle
implementation 'com.firebase:geofire-android-common:3.1.0'

חנות Geohashes

עבור כל מסמך שברצונך לאינדקס לפי מיקום, תצטרך לאחסן שדה 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(() => {
  // ...
});

מָהִיר

הערה: מוצר זה אינו זמין ביעדי watchOS ו-App Clip.
// 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
    // ...
}

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) {
                // ...
            }
        });

שאילתה Geohashes

Geohashes מאפשרים לנו להעריך שאילתות שטח על ידי הצטרפות לקבוצת שאילתות בשדה 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
  // ...
});

מָהִיר

הערה: מוצר זה אינו זמין ביעדי watchOS ו-App Clip.
// 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)
}

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
                // ...
            }
        });

מגבלות

השימוש ב-Geohashes לחיפוש מיקומים נותן לנו יכולות חדשות, אך מגיע עם סט מגבלות משלו:

  • חיוביות כוזבות - שאילתה על ידי Geohash אינה מדויקת, ועליך לסנן תוצאות חיוביות כוזבות בצד הלקוח. הקריאות הנוספות האלה מוסיפות עלות ואחזור לאפליקציה שלך.
  • מקרי קצה - שיטת שאילתה זו מסתמכת על הערכת המרחק בין קווי אורך/קו רוחב. הדיוק של אומדן זה יורד ככל שנקודות מתקרבות לקוטב הצפוני או הדרומי, מה שאומר שלשאילתות Geohash יש יותר נקודות חיוביות שגויות בקווי רוחב קיצוניים.