الدرس التطبيقي حول ترميز Cloud Firestore لنظام التشغيل iOS

1. نظرة عامة

الأهداف

في هذا الدرس التطبيقي حول الترميز، ستنشئ تطبيقًا لاقتراح المطاعم يستند إلى Firestore على نظام التشغيل iOS باستخدام لغة Swift. ستتعرّف على كيفية:

  1. قراءة البيانات وكتابتها في Firestore من تطبيق iOS
  2. الاستماع إلى التغييرات في بيانات Firestore في الوقت الفعلي
  3. استخدام "مصادقة Firebase" وقواعد الأمان لتأمين بيانات Firestore
  4. كتابة طلبات بحث معقّدة في Firestore

المتطلبات الأساسية

قبل البدء في هذا الدرس العملي، تأكَّد من تثبيت ما يلي:

  • الإصدار 14.0 من Xcode (أو إصدار أحدث)
  • الإصدار 1.12.0 من CocoaPods (أو إصدار أحدث)

2. الحصول على المشروع التجريبي

تنزيل الرمز

ابدأ بنسخ المشروع النموذجي وتشغيل pod update في دليل المشروع:

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

افتح FriendlyEats.xcworkspace في Xcode وشغِّله (Cmd+R). يجب أن يتم تجميع التطبيق بشكل صحيح وأن يتعطّل فور تشغيله، لأنّه يفتقد إلى ملف GoogleService-Info.plist. سنصحّح ذلك في الخطوة التالية.

3- إعداد Firebase

إنشاء مشروع Firebase

  1. سجِّل الدخول إلى وحدة تحكّم Firebase باستخدام حسابك على Google.
  2. انقر على الزر لإنشاء مشروع جديد، ثم أدخِل اسم المشروع (على سبيل المثال، FriendlyEats).
  3. انقر على متابعة.
  4. إذا طُلب منك ذلك، راجِع بنود Firebase واقبلها، ثم انقر على متابعة.
  5. (اختياري) فعِّل ميزة "المساعدة المستندة إلى الذكاء الاصطناعي" في وحدة تحكّم Firebase (المعروفة باسم "Gemini في Firebase").
  6. في هذا الدرس العملي، لا تحتاج إلى "إحصاءات Google"، لذا أوقِف خيار "إحصاءات Google".
  7. انقر على إنشاء مشروع، وانتظِر إلى أن يتم توفير مشروعك، ثم انقر على متابعة.

ربط تطبيقك بمنصّة Firebase

أنشئ تطبيق iOS في مشروعك الجديد على Firebase.

نزِّل ملف GoogleService-Info.plist الخاص بمشروعك من وحدة تحكّم Firebase واسحبه إلى جذر مشروع Xcode. نفِّذ المشروع مرة أخرى للتأكّد من ضبط التطبيق بشكلٍ صحيح وعدم تعطُّله عند التشغيل. بعد تسجيل الدخول، من المفترض أن تظهر شاشة فارغة مثل المثال أدناه. إذا تعذّر عليك تسجيل الدخول، تأكَّد من أنّك فعّلت طريقة تسجيل الدخول باستخدام البريد الإلكتروني وكلمة المرور في وحدة تحكّم Firebase ضمن "المصادقة".

d5225270159c040b.png

4. كتابة البيانات في Firestore

في هذا القسم، سنكتب بعض البيانات إلى Firestore حتى نتمكّن من ملء واجهة مستخدم التطبيق. يمكن إجراء ذلك يدويًا من خلال وحدة تحكّم Firebase، ولكن سننفّذه في التطبيق نفسه لتوضيح عملية كتابة أساسية في Firestore.

عنصر النموذج الرئيسي في تطبيقنا هو مطعم. يتم تقسيم بيانات Firestore إلى مستندات ومجموعات ومجموعات فرعية. سنخزّن كل مطعم كمستند في مجموعة على أعلى مستوى تُسمى restaurants. إذا أردت معرفة المزيد من المعلومات عن نموذج بيانات Firestore، يمكنك الاطّلاع على المستندات والمجموعات في المستندات.

قبل أن نتمكّن من إضافة بيانات إلى Firestore، علينا الحصول على مرجع إلى مجموعة المطاعم. أضِف ما يلي إلى حلقة for الداخلية في طريقة RestaurantsTableViewController.didTapPopulateButton(_:).

let collection = Firestore.firestore().collection("restaurants")

بعد أن أصبح لدينا مرجع مجموعة، يمكننا كتابة بعض البيانات. أضِف ما يلي بعد آخر سطر من الرمز البرمجي الذي أضفناه:

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

يضيف الرمز أعلاه مستندًا جديدًا إلى مجموعة المطاعم. تأتي بيانات المستند من قاموس نحصل عليه من بنية Restaurant.

لقد اقتربنا من الانتهاء. قبل أن نتمكّن من كتابة المستندات في Firestore، علينا فتح قواعد الأمان في Firestore وتحديد الأجزاء التي يمكن للمستخدمين الكتابة فيها في قاعدة البيانات. في الوقت الحالي، سنسمح فقط للمستخدمين الذين تمّت المصادقة عليهم بالقراءة والكتابة في قاعدة البيانات بأكملها. هذا الإعداد متساهل بعض الشيء بالنسبة إلى تطبيق متاح للجميع، ولكن أثناء عملية إنشاء التطبيق، نريد إعدادًا مرنًا بما يكفي حتى لا نواجه مشاكل في المصادقة باستمرار أثناء التجربة. في نهاية هذا الدرس العملي، سنتحدّث عن كيفية تعزيز قواعد الأمان والحدّ من إمكانية إجراء عمليات قراءة وكتابة غير مقصودة.

في علامة التبويب "القواعد" في وحدة تحكّم Firebase، أضِف القواعد التالية ثم انقر على نشر.

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

سنتناول قواعد الأمان بالتفصيل لاحقًا، ولكن إذا كنت في عجلة من أمرك، يمكنك إلقاء نظرة على مستندات قواعد الأمان.

شغِّل التطبيق وسجِّل الدخول. بعد ذلك، انقر على الزر تعبئة في أعلى اليمين، ما سيؤدي إلى إنشاء مجموعة من مستندات المطاعم، ولكن لن يظهر ذلك في التطبيق بعد.

بعد ذلك، انتقِل إلى علامة التبويب "بيانات Firestore" في وحدة تحكّم Firebase. من المفترض أن تظهر الآن إدخالات جديدة في مجموعة المطاعم:

Screen Shot 2017-07-06 at 12.45.38 PM.png

تهانينا، لقد كتبت البيانات للتو في Firestore من تطبيق iOS. في القسم التالي، ستتعرّف على كيفية استرداد البيانات من Firestore وعرضها في التطبيق.

5- عرض البيانات من Firestore

في هذا القسم، ستتعرّف على كيفية استرداد البيانات من Firestore وعرضها في التطبيق. الخطوتان الرئيسيتان هما إنشاء طلب بحث وإضافة أداة معالجة لنتائج البحث. سيتم إعلام هذا المستمع بجميع البيانات الحالية التي تتطابق مع طلب البحث وسيتلقّى التعديلات في الوقت الفعلي.

أولاً، لننشئ طلب البحث الذي سيعرض القائمة التلقائية غير المفلترة للمطاعم. إليك مثالاً على تنفيذ RestaurantsTableViewController.baseQuery():

return Firestore.firestore().collection("restaurants").limit(to: 50)

يسترد طلب البحث هذا ما يصل إلى 50 مطعمًا من المجموعة ذات المستوى الأعلى المسماة "مطاعم". بعد أن أصبح لدينا طلب بحث، علينا ربط أداة معالجة اللقطات لتحميل البيانات من Firestore إلى تطبيقنا. أضِف الرمز التالي إلى الطريقة RestaurantsTableViewController.observeQuery() بعد استدعاء stopObserving() مباشرةً.

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

يعمل الرمز البرمجي أعلاه على تنزيل المجموعة من Firestore وتخزينها في مصفوفة على الجهاز. يضيف طلب addSnapshotListener(_:) أداة معالجة لقطات إلى الاستعلام، ما يؤدي إلى تعديل وحدة التحكّم في العرض في كل مرة تتغير فيها البيانات على الخادم. نحصل على التحديثات تلقائيًا ولا نضطر إلى إرسال التغييرات يدويًا. تذكَّر أنّه يمكن استدعاء أداة معالجة اللقطات هذه في أي وقت نتيجةً لتغيير من جهة الخادم، لذا من المهم أن يتمكّن تطبيقنا من التعامل مع التغييرات.

بعد ربط قواميسنا ببُنى البيانات (راجِع Restaurant.swift)، يصبح عرض البيانات مجرّد مسألة تعيين بعض خصائص العرض. أضِف الأسطر التالية إلى RestaurantTableViewCell.populate(restaurant:) في RestaurantsTableViewController.swift.

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

يتم استدعاء طريقة التعبئة هذه من طريقة tableView(_:cellForRowAtIndexPath:) لمصدر بيانات عرض الجدول، والتي تتولى ربط مجموعة أنواع القيم من قبل بخلايا عرض الجدول الفردية.

شغِّل التطبيق مرة أخرى وتأكَّد من أنّ المطاعم التي رأيناها سابقًا في وحدة التحكّم تظهر الآن على المحاكي أو الجهاز. إذا أكملت هذا القسم بنجاح، سيتمكّن تطبيقك الآن من قراءة البيانات وكتابتها باستخدام Cloud Firestore.

391c0259bf05ac25.png

6. ترتيب البيانات وتصفيتها

يعرض تطبيقنا حاليًا قائمة بالمطاعم، ولكن لا يمكن للمستخدم الفلترة حسب احتياجاته. في هذا القسم، ستستخدم ميزة طلب البحث المتقدّم في Firestore لتفعيل الفلترة.

في ما يلي مثال على طلب بحث بسيط لجلب جميع مطاعم Dim Sum:

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

وكما يوحي الاسم، ستجعل طريقة whereField(_:isEqualTo:) الاستعلام ينزّل فقط عناصر المجموعة التي تستوفي حقولها القيود التي وضعناها. في هذه الحالة، سيتم تنزيل المطاعم التي تكون فيها قيمة category هي "Dim Sum" فقط.

في هذا التطبيق، يمكن للمستخدم ربط عدّة فلاتر لإنشاء طلبات بحث محدّدة، مثل "بيتزا في القاهرة" أو "مأكولات بحرية في الإسكندرية حسب التقييمات الأعلى".

افتح RestaurantsTableViewController.swift وأضِف مجموعة الرموز التالية إلى منتصف query(withCategory:city:price:sortBy:):

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

تضيف المقتطفات أعلاه عدّة عبارات whereField وorder لإنشاء طلب بحث مركّب واحد استنادًا إلى إدخال المستخدم. الآن، لن يعرض طلب البحث سوى المطاعم التي تستوفي متطلبات المستخدم.

نفِّذ مشروعك وتأكَّد من إمكانية الفلترة حسب السعر والمدينة والفئة (احرص على كتابة أسماء الفئات والمدن بدقة). أثناء الاختبار، قد تظهر لك أخطاء في سجلّاتك على النحو التالي:

Error fetching snapshot results: Error Domain=io.grpc Code=9
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..."
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

ويرجع ذلك إلى أنّ Firestore تتطلّب فهارس لمعظم الاستعلامات المركّبة. يؤدي فرض استخدام الفهارس في طلبات البحث إلى الحفاظ على سرعة Firestore عند توسيع نطاقها. سيؤدي فتح الرابط من رسالة الخطأ إلى فتح واجهة مستخدم إنشاء الفهرس تلقائيًا في وحدة تحكّم Firebase مع ملء المَعلمات الصحيحة. لمزيد من المعلومات عن الفهارس في Firestore، يُرجى الاطّلاع على المستندات.

7. كتابة البيانات في إحدى المعاملات

في هذا القسم، سنضيف إمكانية إرسال المستخدمين لمراجعات عن المطاعم. حتى الآن، كانت جميع عمليات الكتابة ذرية وبسيطة نسبيًا. إذا حدث خطأ في أي منها، من المرجّح أن نطلب من المستخدم إعادة المحاولة أو نعيد المحاولة تلقائيًا.

لإضافة تقييم إلى أحد المطاعم، علينا تنسيق عمليات قراءة وكتابة متعددة. يجب أولاً إرسال المراجعة، ثم تعديل عدد التقييمات ومتوسط التقييمات للمطعم. إذا تعذّر تنفيذ إحدى العمليتين ولكن لم يتعذّر تنفيذ الأخرى، سنكون في حالة غير متسقة حيث لا تتطابق البيانات في أحد أجزاء قاعدة البيانات مع البيانات في جزء آخر.

لحسن الحظ، توفّر Firestore وظيفة المعاملات التي تتيح لنا إجراء عمليات قراءة وكتابة متعددة في عملية ذرية واحدة، ما يضمن بقاء بياناتنا متسقة.

أضِف الرمز التالي أسفل جميع تعريفات let في RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:).

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

داخل كتلة التعديل، ستتعامل Firestore مع جميع العمليات التي نجريها باستخدام عنصر المعاملة على أنّها تعديل ذري واحد. إذا تعذّر إجراء التحديث على الخادم، ستعيد Firestore محاولة تنفيذه تلقائيًا عدة مرات. وهذا يعني أنّ حالة الخطأ لدينا هي على الأرجح خطأ واحد يحدث بشكل متكرر، مثلاً إذا كان الجهاز غير متصل بالإنترنت تمامًا أو إذا لم يكن لدى المستخدم إذن بالكتابة في المسار الذي يحاول الكتابة فيه.

8. قواعد الأمان

يجب ألا يتمكّن مستخدمو تطبيقنا من قراءة كل جزء من البيانات في قاعدة البيانات وكتابته. على سبيل المثال، يجب أن يتمكّن الجميع من الاطّلاع على تقييمات أحد المطاعم، ولكن يجب السماح للمستخدمين المصادَق عليهم فقط بنشر تقييم. لا يكفي كتابة رمز برمجي جيد على العميل، بل يجب تحديد نموذج أمان البيانات على الخلفية لضمان الأمان الكامل. في هذا القسم، سنتعرّف على كيفية استخدام قواعد الأمان في Firebase لحماية بياناتنا.

لنلقِ نظرة أولاً على قواعد الأمان التي كتبناها في بداية الدرس العملي. افتح وحدة تحكّم Firebase وانتقِل إلى قاعدة البيانات > القواعد في علامة التبويب Firestore.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

المتغيّر request في القواعد هو متغيّر عام متاح في جميع القواعد، ويضمن الشرط الذي أضفناه أنّه تمّت مصادقة الطلب قبل السماح للمستخدمين بتنفيذ أي إجراء. يمنع ذلك المستخدمين غير المصادق عليهم من استخدام واجهة برمجة التطبيقات Firestore API لإجراء تغييرات غير مصرَّح بها على بياناتك. هذه بداية جيدة، ولكن يمكننا استخدام قواعد Firestore لتنفيذ إجراءات أكثر فعالية.

نريد حصر كتابة المراجعات على المستخدمين الذين يتطابق رقم تعريفهم مع رقم تعريف المستخدم الذي تم إثبات هويته. يضمن ذلك عدم إمكانية انتحال المستخدمين لهوية بعضهم البعض وترك مراجعات احتيالية.

تطابق عبارة المطابقة الأولى المجموعة الفرعية المسماة ratings لأي مستند ينتمي إلى المجموعة restaurants. يمنع الشرط allow write بعد ذلك إرسال أي مراجعة إذا كان رقم تعريف المستخدم الخاص بالمراجعة لا يتطابق مع رقم تعريف المستخدم. تسمح عبارة المطابقة الثانية لأي مستخدم تمت مصادقته بقراءة المطاعم وكتابتها في قاعدة البيانات.

وقد حقّق ذلك نتائج رائعة في مراجعاتنا، إذ استخدمنا قواعد الأمان لتوضيح الضمان الضمني الذي كتبناه في تطبيقنا سابقًا، وهو أنّه لا يمكن للمستخدمين كتابة سوى مراجعاتهم الخاصة. في حال أضفنا وظيفة تعديل المراجعات أو حذفها، ستمنع مجموعة القواعد نفسها المستخدمين أيضًا من تعديل مراجعات المستخدمين الآخرين أو حذفها. ولكن يمكن أيضًا استخدام قواعد Firestore بطريقة أكثر تفصيلاً للحدّ من عمليات الكتابة في حقول فردية داخل المستندات بدلاً من المستندات بأكملها. يمكننا استخدام ذلك للسماح للمستخدمين بتعديل التقييمات ومتوسط التقييمات وعدد التقييمات الخاصة بمطعم فقط، ما يمنع المستخدمين المؤذيين من تغيير اسم المطعم أو موقعه الجغرافي.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null
                     && request.auth.uid == request.resource.data.userId;
      }

      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

في هذا المثال، قسّمنا إذن الكتابة إلى إنشاء وتعديل لنكون أكثر تحديدًا بشأن العمليات التي يجب السماح بها. يمكن لأي مستخدم كتابة بيانات المطاعم في قاعدة البيانات، مع الحفاظ على وظيفة الزر "تعبئة" الذي أنشأناه في بداية الدرس العملي، ولكن بعد كتابة بيانات المطعم، لا يمكن تغيير اسمه وموقعه الجغرافي وسعره وفئته. وبشكل أكثر تحديدًا، تتطلّب القاعدة الأخيرة أن تحافظ أي عملية تعديل على المطعم على الاسم والمدينة والسعر والفئة نفسها للحقول المتوفّرة حاليًا في قاعدة البيانات.

لمزيد من المعلومات حول ما يمكنك تنفيذه باستخدام قواعد الأمان، يمكنك الاطّلاع على المستندات.

9. الخاتمة

في هذا الدرس العملي، تعلّمت كيفية إجراء عمليات القراءة والكتابة الأساسية والمتقدّمة باستخدام Firestore، بالإضافة إلى كيفية تأمين الوصول إلى البيانات باستخدام قواعد الأمان. يمكنك العثور على الحلّ الكامل في فرع codelab-complete.

لمزيد من المعلومات حول Firestore، يُرجى الانتقال إلى المراجع التالية: