1. Обзор
Цели
В этой лабораторной работе вы создадите приложение с рекомендациями ресторанов на базе Firestore для iOS на языке Swift. Вы узнаете, как:
- Чтение и запись данных в Firestore из приложения iOS
- Отслеживайте изменения данных Firestore в режиме реального времени
- Используйте аутентификацию Firebase и правила безопасности для защиты данных Firestore
- Написание сложных запросов Firestore
Предпосылки
Перед началом работы над этой лабораторной работой убедитесь, что у вас установлено:
- 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 и примите их, а затем нажмите кнопку «Продолжить» .
- (Необязательно) Включите помощь ИИ в консоли Firebase (так называемая «Gemini в Firebase»).
- Для этой лабораторной работы вам не понадобится Google Analytics, поэтому отключите опцию Google Analytics.
- Нажмите «Создать проект» , дождитесь завершения подготовки проекта, а затем нажмите «Продолжить» .
Подключите свое приложение к Firebase
Создайте приложение iOS в новом проекте Firebase.
Скачайте файл GoogleService-Info.plist
вашего проекта из консоли Firebase и перетащите его в корень проекта 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 и описать, какие разделы нашей базы данных должны быть доступны для записи тем или иным пользователям. Пока мы разрешим чтение и запись во всей базе данных только аутентифицированным пользователям. Это немного слишком либерально для производственного приложения, но в процессе разработки приложения нам нужно что-то более мягкое, чтобы не сталкиваться с постоянными проблемами аутентификации во время экспериментов. В конце этой лабораторной работы мы обсудим, как ужесточить правила безопасности и ограничить вероятность непреднамеренного чтения и записи.
На вкладке «Правила» консоли 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. Теперь в коллекции ресторанов должны появиться новые записи:
Поздравляем! Вы только что записали данные в Firestore из приложения iOS! В следующем разделе вы узнаете, как извлечь данные из Firestore и отобразить их в приложении.
5. Отображение данных из Firestore
В этом разделе вы узнаете, как извлекать данные из Firestore и отображать их в приложении. Два ключевых шага — создание запроса и добавление прослушивателя снимков. Этот прослушиватель будет получать уведомления обо всех существующих данных, соответствующих запросу, и получать обновления в режиме реального времени.
Сначала создадим запрос, который будет выдавать список ресторанов по умолчанию без фильтрации. Взгляните на реализацию RestaurantsTableViewController.baseQuery()
:
return Firestore.firestore().collection("restaurants").limit(to: 50)
Этот запрос извлекает до 50 ресторанов из коллекции верхнего уровня «restaurants». Теперь, когда у нас есть запрос, нам нужно подключить прослушиватель снимков для загрузки данных из 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 для фильтрации.
Вот пример простого запроса для получения всех ресторанов 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
в правилах — это глобальная переменная, доступная во всех правилах, а добавленное нами условие гарантирует, что запрос будет аутентифицирован, прежде чем пользователи смогут выполнять какие-либо действия. Это предотвращает использование API Firestore неавторизованными пользователями для внесения несанкционированных изменений в ваши данные. Это хорошее начало, но мы можем использовать правила 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, посетите следующие ресурсы: