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

1- نظرة عامة

الأهداف

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

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

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

قبل بدء هذا الدرس التطبيقي حول الترميز، تأكَّد من تثبيت ما يلي:

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

2- إنشاء مشروع على "وحدة تحكُّم Firebase"

إضافة Firebase إلى المشروع

  1. انتقِل إلى وحدة تحكُّم Firebase.
  2. اختَر إنشاء مشروع جديد وأدخِل اسمًا لمشروعك "درس تطبيقي حول ترميز iOS في متجر Firestore".

3- الحصول على نموذج المشروع

تنزيل الرمز

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

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

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

إعداد Firebase

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

d5225270159c040b.png

4. كتابة البيانات إلى Firestore

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

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

قبل أن نتمكن من إضافة البيانات إلى Firestore، نحتاج إلى الحصول على إشارة إلى مجموعة المطاعم. أضِف ما يلي إلى التكرار الحلقي الداخلي في الطريقة 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)

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

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

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

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

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

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

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

لقطة شاشة يوم 2017-07-06 في الساعة 12.45.38 مساءً.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 لتفعيل الفلترة.

في ما يلي مثال على طلب بسيط لجلب جميع مطاعم ديم سوم الصينية:

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 وظيفة المعاملات التي تتيح لنا إجراء عدة عمليات قراءة وكتاب في عملية واحدة بسيطة، ما يضمن اتّساق بياناتنا.

أضِف الرمز التالي أسفل جميع تعريفات السماح في 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 وانتقِل إلى Database > القواعد في علامة التبويب Firestore

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

والمتغيّر request في القواعد أعلاه هو متغيّر عمومي متاح في جميع القواعد، ويضمن المتغيّر المشروط الذي أضفناه مصادقة الطلب قبل السماح للمستخدمين بتنفيذ أي إجراء. يمنع هذا المستخدمين الذين لم تتم مصادقتهم من استخدام Firestore API لإجراء تغييرات غير مصرح بها على بياناتك. هذه بداية جيدة، ولكن يمكننا استخدام قواعد 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;
    }
  }
}

تتطابق عبارة المطابقة الأولى مع المجموعة الفرعية المسماة 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، يُرجى الانتقال إلى الموارد التالية: