Cloud Firestore iOS Codelab

1. סקירה כללית

מטרות עסקיות

בשיעור ה-Codelab הזה תבנה ב-Swift אפליקציית המלצות למסעדות בגיבוי Firestore. תלמדו איך:

  1. קריאה וכתיבה של נתונים ב-Firestore מאפליקציה ל-iOS
  2. האזנה לשינויים בנתוני Firestore בזמן אמת
  3. שימוש בכללי אימות ואבטחה ב-Firebase כדי לאבטח את נתוני Firestore
  4. כתיבת שאילתות מורכבות ב-Firestore

דרישות מוקדמות

לפני שמתחילים את הקודלהב, צריך לוודא שהתקנתם את הרכיבים הבאים:

  • Xcode מגרסה 14.0 (או גרסה מתקדמת יותר)
  • CocoaPods 1.12.0 (או גרסה מתקדמת יותר)

2. יצירת פרויקט במסוף Firebase

הוספת Firebase לפרויקט

  1. נכנסים למסוף Firebase.
  2. בוחרים באפשרות Create New Project (יצירת פרויקט חדש) ומזינים את השם Firestore iOS Codelab.

3. הורדת פרויקט לדוגמה

הורדת הקוד

מתחילים בהעתקה (cloning) של הפרויקט לדוגמה והרצה של 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 בקטע Authentication (אימות).

d5225270159c040b.png

4. כתיבת נתונים ב-Firestore

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

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

לפני שאפשר להוסיף נתונים ל-Firestore, צריך לקבל הפניה לאוסף המסעדות. מוסיפים את הטקסט הבא ל-for loop הפנימי בשיטה 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 ולתאר אילו חלקים של מסד הנתונים שלנו צריכים להיות ניתנים לכתיבה על ידי אילו משתמשים. בשלב הזה, רק משתמשים מאומתים יוכלו לקרוא ולכתוב במסד הנתונים כולו. זה קצת יותר משוחרר מדי לאפליקציה בסביבת הייצור, אבל בתהליך פיתוח האפליקציה אנחנו רוצים משהו פחות מחמיר כדי שלא נתקלת כל הזמן בבעיות אימות בזמן הניסוי. בסוף שיעור ה-Codelab הזה נסביר איך להקשיח את כללי האבטחה ולהגביל את האפשרויות לקריאה וכתיבה בטעות.

בכרטיסייה Rules במסוף Firebase, מוסיפים את הכללים הבאים ולוחצים על Publish.

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 data' במסוף 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 לאפליקציה שלנו. צריך להוסיף את הקוד הבא ל-method 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(_:) מוסיפה לשאילתה מאזין לתמונה מיידית, שיעדכן את ה-View Controller בכל פעם שהנתונים משתנים בשרת. אנחנו מקבלים עדכונים באופן אוטומטי ואין צורך לשלוח שינויים באופן ידני. חשוב לזכור שאפשר להפעיל את מאזין קובץ ה-snapshot הזה בכל שלב כתוצאה משינוי בצד השרת, ולכן חשוב שהאפליקציה שלנו תוכל לטפל בשינויים.

אחרי שממפים את המילונים שלנו למבנים (ראו 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 מספק פונקציונליות של טרנזקציות שמאפשרת לנו לבצע מספר פעולות קריאה וכתיבה בפעולה אטומית אחת, וכך להבטיח שהנתונים שלנו יישארו עקביים.

מוסיפים את הקוד הבא מתחת לכל ההצהרות מסוג 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 ועוברים אל Database (מסד נתונים) > Rules (כללים) בכרטיסייה 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;
    }
  }
}

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

למידע נוסף על מה שאפשר לעשות עם כללי אבטחה, כדאי לעיין במסמכי התיעוד.

9. סיכום

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

מידע נוסף על Firestore זמין במקורות המידע הבאים: