Cloud Firestore iOS Codelab

1. Обзор

Цели

В этой лаборатории кода вы создадите приложение для рекомендаций ресторанов на базе Firestore для iOS на Swift. Вы узнаете, как:

  1. Чтение и запись данных в Firestore из приложения iOS.
  2. Слушайте изменения в данных Firestore в режиме реального времени
  3. Используйте правила аутентификации и безопасности Firebase для защиты данных Firestore.
  4. Написание сложных запросов к Firestore

Предварительные условия

Перед запуском этой лаборатории убедитесь, что у вас установлены:

  • Xcode версии 14.0 (или выше)
  • CocoaPods 1.12.0 (или выше)

2. Создайте консольный проект Firebase.

Добавьте Firebase в проект

  1. Перейдите в консоль Firebase .
  2. Выберите «Создать новый проект» и назовите свой проект «Firestore iOS Codelab».

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, нам нужно получить ссылку на коллекцию ресторанов. Добавьте следующее во внутренний цикл 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 /{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. Теперь вы должны увидеть новые записи в коллекции ресторанов:

Снимок экрана 06.07.2017, 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 предоставляет функциональность транзакций, которая позволяет нам выполнять несколько операций чтения и записи за одну атомарную операцию, гарантируя, что наши данные остаются согласованными.

Добавьте следующий код под всеми объявлениями 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 /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

Переменная request в приведенных выше правилах — это глобальная переменная, доступная во всех правилах, а добавленное нами условие гарантирует, что запрос аутентифицируется, прежде чем пользователи смогут что-либо сделать. Это не позволяет неаутентифицированным пользователям использовать API Firestore для внесения несанкционированных изменений в ваши данные. Это хорошее начало, но мы можем использовать правила 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, посетите следующие ресурсы: