تحتوي العديد من التطبيقات على مستندات تمت فهرستها حسب المواقع الفعلية. على سبيل المثال، قد يسمح تطبيقك للمستخدمين بتصفح المتاجر القريبة من موقعهم الحالي.
يسمح Cloud Firestore فقط بجملة نطاق واحدة لكل استعلام مركب ، مما يعني أنه لا يمكننا إجراء استعلامات جغرافية ببساطة عن طريق تخزين خطوط الطول والعرض كحقول منفصلة والاستعلام عن مربع محيط.
الحل: جيوهاش
Geohash هو نظام لتشفير زوج (latitude, longitude)
في سلسلة Base32 واحدة. في نظام Geohash ينقسم العالم إلى شبكة مستطيلة. يحدد كل حرف في سلسلة Geohash واحدًا من 32 قسمًا فرعيًا لتجزئة البادئة. على سبيل المثال، يعد Geohash abcd
واحدًا من 32 تجزئة مكونة من أربعة أحرف موجودة بالكامل في Geohash abc
الأكبر.
كلما طالت البادئة المشتركة بين تجزئتين، كلما اقتربتا من بعضهما البعض. على سبيل المثال abcdef
أقرب إلى abcdeg
من abcdff
. ومع ذلك، فإن العكس ليس صحيحا! قد تكون هناك منطقتان قريبتان جدًا من بعضهما البعض مع وجود Geohashes مختلفة جدًا:
يمكننا استخدام Geohashes لتخزين المستندات والاستعلام عنها حسب الموضع في Cloud Firestore بكفاءة معقولة بينما تتطلب حقلًا واحدًا مفهرسًا فقط.
تثبيت المكتبة المساعدة
يتضمن إنشاء Geohashes وتحليلها بعض العمليات الحسابية الصعبة، لذلك أنشأنا مكتبات مساعدة لتجريد الأجزاء الأكثر صعوبة على Android وApple والويب:
واجهة برمجة تطبيقات الويب المعيارية
// 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
واجهة برمجة تطبيقات مساحة اسم الويب
// 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'
تخزين Geohashes
لكل مستند تريد فهرسته حسب الموقع، ستحتاج إلى تخزين حقل Geohash:
واجهة برمجة تطبيقات الويب المعيارية
import { doc, updateDoc } from 'firebase/firestore'; // 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 = doc(db, 'cities', 'LON'); await updateDoc(londonRef, { geohash: hash, lat: lat, lng: lng });
واجهة برمجة تطبيقات مساحة اسم الويب
// 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) { // ... } });
الاستعلام عن Geohashes
تتيح لنا Geohashes تقريب استعلامات المنطقة من خلال الانضمام إلى مجموعة من الاستعلامات في حقل Geohash ثم تصفية بعض النتائج الإيجابية الخاطئة:
واجهة برمجة تطبيقات الويب المعيارية
import { collection, query, orderBy, startAt, endAt, getDocs } from 'firebase/firestore'; // 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 = query( collection(db, 'cities'), orderBy('geohash'), startAt(b[0]), endAt(b[1])); promises.push(getDocs(q)); } // Collect all the query results together into a single list const snapshots = await Promise.all(promises); 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); } } }
واجهة برمجة تطبيقات مساحة اسم الويب
// 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 // ... } });
محددات
يمنحنا استخدام Geohashes للاستعلام عن المواقع إمكانات جديدة، ولكنه يأتي مع مجموعة القيود الخاصة به:
- الإيجابيات الكاذبة - الاستعلام عن طريق Geohash ليس دقيقًا، ويجب عليك تصفية النتائج الإيجابية الكاذبة من جانب العميل. تضيف هذه القراءات الإضافية التكلفة ووقت الاستجابة إلى تطبيقك.
- حالات الحافة - تعتمد طريقة الاستعلام هذه على تقدير المسافة بين خطوط الطول/خطوط العرض. تتناقص دقة هذا التقدير مع اقتراب النقاط من القطب الشمالي أو الجنوبي مما يعني أن استعلامات Geohash لها نتائج إيجابية خاطئة أكثر عند خطوط العرض القصوى.