1. סקירה כללית
מטרות עסקיות
בסדנת התכנות הזו תיצרו אפליקציה להמלצות על מסעדות שמגובה על ידי Firestore ב-iOS באמצעות Swift. תלמדו איך:
- קריאת נתונים וכתיבתם ב-Firestore מאפליקציית iOS
- האזנה לשינויים בנתוני Firestore בזמן אמת
- שימוש באימות ב-Firebase ובכללי אבטחה לאבטחת נתונים ב-Firestore
- כתיבת שאילתות מורכבות ב-Firestore
דרישות מוקדמות
לפני שמתחילים את ה-codelab הזה, חשוב לוודא שהתקנתם את:
- Xcode מגרסה 14.0 (או גרסה מתקדמת יותר)
- CocoaPods 1.12.0 (או גרסה מתקדמת יותר)
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
- נכנסים למסוף Firebase באמצעות חשבון Google.
- לוחצים על הלחצן כדי ליצור פרויקט חדש, ואז מזינים שם לפרויקט (לדוגמה,
FriendlyEats
).
- לוחצים על המשך.
- אם מוצגת בקשה לעשות זאת, קוראים ומאשרים את התנאים של Firebase, ואז לוחצים על המשך.
- (אופציונלי) מפעילים את העזרה מבוססת-AI במסוף Firebase (שנקראת Gemini ב-Firebase).
- ב-codelab הזה לא צריך להשתמש ב-Google Analytics, ולכן משביתים את האפשרות Google Analytics.
- לוחצים על יצירת פרויקט, מחכים שהפרויקט יוקצה ולוחצים על המשך.
קישור האפליקציה ל-Firebase
יוצרים אפליקציית iOS בפרויקט Firebase החדש.
מורידים את קובץ GoogleService-Info.plist
של הפרויקט מ-Firebase Console וגוררים אותו אל תיקיית השורש של פרויקט Xcode. מריצים את הפרויקט שוב כדי לוודא שהאפליקציה מוגדרת בצורה נכונה ושלא מתרחשת יותר קריסה בהפעלה. אחרי הכניסה לחשבון, אמור להופיע מסך ריק כמו בדוגמה שלמטה. אם אתם לא מצליחים להיכנס, ודאו שהפעלתם את שיטת הכניסה באמצעות אימייל/סיסמה במסוף Firebase בקטע'אימות'.
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 ולתאר אילו חלקים במסד הנתונים שלנו צריכים להיות ניתנים לכתיבה על ידי משתמשים מסוימים. נכון לעכשיו, נאפשר רק למשתמשים מאומתים לקרוא ולכתוב לכל מסד הנתונים. הרשאות כאלה הן רחבות מדי לאפליקציה בשלב הייצור, אבל במהלך תהליך בניית האפליקציה אנחנו רוצים הרשאות מספיק רחבות כדי שלא נתקל כל הזמן בבעיות אימות בזמן הניסויים. בסוף ה-codelab נסביר איך להקשיח את כללי האבטחה ולהגביל את האפשרות של קריאות וכתיבות לא מכוונות.
בכרטיסייה Rules במסוף Firebase, מוסיפים את הכללים הבאים ואז לוחצים על Publish.
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; } } }
בהמשך נדון בכללי האבטחה בפירוט, אבל אם אתם ממהרים, תוכלו לעיין במסמכי התיעוד של כללי האבטחה.
מריצים את האפליקציה ונכנסים לחשבון. אחר כך מקישים על הלחצן Populate (אכלוס) בפינה הימנית העליונה, שיצור קבוצה של מסמכי מסעדות, אבל זה עדיין לא יופיע באפליקציה.
לאחר מכן, עוברים אל הכרטיסייה Firestore data (נתוני Firestore) במסוף Firebase. עכשיו אמורות להופיע רשומות חדשות באוסף המסעדות:
מזל טוב, הרגע כתבת נתונים ל-Firestore מאפליקציית iOS! בקטע הבא נסביר איך לאחזר נתונים מ-Firestore ולהציג אותם באפליקציה.
5. הצגת נתונים מ-Firestore
בקטע הזה נסביר איך לאחזר נתונים מ-Firestore ולהציג אותם באפליקציה. שני השלבים העיקריים הם יצירת שאילתה והוספת מאזין לתמונת מצב. המאזין הזה יקבל הודעה על כל הנתונים הקיימים שתואמים לשאילתה, ויקבל עדכונים בזמן אמת.
קודם ניצור את השאילתה שתציג את רשימת המסעדות שמוגדרת כברירת מחדל, ללא סינון. כדאי לעיין בהטמעה של RestaurantsTableViewController.baseQuery()
:
return Firestore.firestore().collection("restaurants").limit(to: 50)
השאילתה הזו מאחזרת עד 50 מסעדות מהאוסף ברמה העליונה שנקרא 'מסעדות'. עכשיו שיש לנו שאילתה, אנחנו צריכים לצרף listener של snapshot כדי לטעון נתונים מ-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.
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 כדי להגן על הנתונים שלנו.
קודם כול, נבחן לעומק את כללי האבטחה שכתבנו בתחילת ה-codelab. פותחים את מסוף Firebase ועוברים אל Database > Rules (מסד נתונים > כללים) בכרטיסייה 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;
}
}
}
כאן פיצלנו את הרשאת הכתיבה שלנו ליצירה ולעדכון, כדי שנוכל להיות ספציפיים יותר לגבי הפעולות שצריך לאפשר. כל משתמש יכול לכתוב מסעדות למסד הנתונים, וכך לשמור על הפונקציונליות של הלחצן Populate (אכלוס) שיצרנו בתחילת ה-codelab, אבל אחרי שכותבים מסעדה, אי אפשר לשנות את השם, המיקום, המחיר והקטגוריה שלה. באופן ספציפי יותר, הכלל האחרון מחייב שכל פעולת עדכון של מסעדה תשמור על אותו שם, עיר, מחיר וקטגוריה של השדות שכבר קיימים במסד הנתונים.
במסמכי התיעוד אפשר לקרוא מידע נוסף על הפעולות שאפשר לבצע באמצעות כללי אבטחה.
9. סיכום
ב-codelab הזה למדתם איך לבצע קריאות וכתיבות בסיסיות ומתקדמות באמצעות Firestore, וגם איך לאבטח את הגישה לנתונים באמצעות כללי אבטחה. אפשר למצוא את הפתרון המלא בcodelab-complete
ענף.
מידע נוסף על Firestore זמין במקורות המידע הבאים: