Ćwiczenia z programowania w Cloud Firestore na iOS

1. Omówienie

Cele

W tym ćwiczeniu z programowania utworzysz na platformie iOS aplikację do rekomendowania restauracji opartą na Firestore w języku Swift. Zapoznasz się z tymi zagadnieniami:

  1. Odczytywanie i zapisywanie danych w Firestore z aplikacji na iOS
  2. Słuchaj zmian w danych Firestore w czasie rzeczywistym
  3. Zabezpieczanie danych Firestore za pomocą uwierzytelniania i reguł zabezpieczeń Firebase
  4. Pisanie złożonych zapytań Firestore

Wymagania wstępne

Zanim zaczniesz korzystać z tego Codelab, upewnij się, że masz zainstalowane:

  • Xcode w wersji 14.0 (lub nowszej).
  • CocoaPods w wersji 1.12.0 (lub nowszej).

2. Tworzenie projektu w konsoli Firebase

Dodawanie Firebase do projektu

  1. Otwórz konsolę Firebase.
  2. Kliknij Utwórz nowy projekt i nadaj projektowi nazwę „Firestore iOS Codelab”.

3. Pobieranie przykładowego projektu

Pobieranie kodu

Zacznij od sklonowania przykładowego projektu i uruchomienia pod update w katalogu projektu:

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

Otwórz FriendlyEats.xcworkspace w Xcode i uruchom go (Cmd+R). Aplikacja powinna się skompilować prawidłowo i natychmiast ulec awarii po uruchomieniu, ponieważ brakuje jej pliku GoogleService-Info.plist. Sprostujemy to w następnym kroku.

Konfigurowanie Firebase

Aby utworzyć nowy projekt Firestore, postępuj zgodnie z dokumentacją. Po utworzeniu projektu pobierz z konsoli Firebase plik GoogleService-Info.plist i przeciągnij go do katalogu głównego projektu Xcode. Uruchom projekt ponownie, aby sprawdzić, czy aplikacja jest prawidłowo skonfigurowana i czy nie ulega już awarii podczas uruchamiania. Po zalogowaniu powinien pojawić się pusty ekran podobny do tego poniżej. Jeśli nie możesz się zalogować, sprawdź, czy w konsoli Firebase w sekcji Uwierzytelnianie włączona jest metoda logowania za pomocą adresu e-mail i hasła.

d5225270159c040b.png

4. Zapisywanie danych w Firestore

W tej sekcji zapiszemy w Firestore pewne dane, aby wypełnić interfejs użytkownika aplikacji. Możesz to zrobić ręcznie w konsoli Firebase, ale my zrobimy to w samej aplikacji, aby zademonstrować podstawowe zapisywanie w Firestore.

Głównym obiektem modelu w naszej aplikacji jest restauracja. Dane Firestore są dzielone na dokumenty, kolekcje i podkolekcje. Każdą restaurację będziemy przechowywać jako dokument w kolekcji najwyższego poziomu o nazwie restaurants. Aby dowiedzieć się więcej o modelu danych Firestore, przeczytaj w dokumentacji informacje o dokumentach i kolekcjach.

Zanim dodamy dane do Firestore, musimy uzyskać odwołanie do kolekcji restauracji. Dodaj ten kod do wewnętrznego fora w metodzie RestaurantsTableViewController.didTapPopulateButton(_:).

let collection = Firestore.firestore().collection("restaurants")

Teraz, gdy mamy odwołanie do kolekcji, możemy zapisać dane. Dodaj ten fragment kodu tuż za ostatnim wierszem kodu, który dodaliśmy:

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)

Powyższy kod dodaje nowy dokument do kolekcji restauracji. Dane dokumentu pochodzą ze słownika, który otrzymujemy z struktury Restaurant.

Już prawie jesteśmy gotowi – zanim będziemy mogli zapisywać dokumenty w Firestore, musimy ustawić reguły zabezpieczeń Firestore i opisać, którzy użytkownicy mają mieć możliwość zapisywania danych w poszczególnych częściach bazy danych. Obecnie zezwalamy na odczyt i zapisywanie danych w całości bazy danych tylko uwierzytelnionych użytkowników. To rozwiązanie jest nieco zbyt liberalne w przypadku aplikacji produkcyjnej, ale podczas tworzenia aplikacji chcemy, aby było ono wystarczająco elastyczne, aby podczas eksperymentowania nie napotykać ciągle problemów z uwierzytelnianiem. Na końcu tego Codelab omówimy zacieśnienie reguł zabezpieczeń i ograniczenie możliwości niezamierzonego odczytu i zapisu.

Na karcie Reguły w konsoli Firebase dodaj te reguły, a potem kliknij Opublikuj.

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;
    }
  }
}

Szczegółowo omówimy reguły zabezpieczeń w dalszej części artykułu, ale jeśli masz mało czasu, zapoznaj się z dokumentacją dotyczącą reguł zabezpieczeń.

Uruchom aplikację i zaloguj się. Następnie w lewym górnym rogu kliknij przycisk „Wypełnij”. Spowoduje to utworzenie partii dokumentów restauracji, których nie zobaczysz jeszcze w aplikacji.

Następnie w konsoli Firebase przejdź do karty danych Firestore. W kolekcji restauracji powinny się teraz pojawić nowe wpisy:

Screen Shot 2017-07-06 at 12.45.38 PM.png

Gratulacje! Właśnie zapisaliśmy dane z aplikacji na iOS do Firestore. W następnej sekcji dowiesz się, jak pobierać dane z Firestore i wyświetlać je w aplikacji.

5. Wyświetlanie danych z Firestore

Z tej sekcji dowiesz się, jak pobierać dane z Firestore i wyświetlać je w aplikacji. Najważniejszymi krokami są utworzenie zapytania i dodanie odbiornika zrzutu. Ten odbiorca będzie otrzymywać powiadomienia o wszystkich istniejących danych pasujących do zapytania i aktualizacje w czasie rzeczywistym.

Najpierw zbudujemy zapytanie, które będzie służyć do wyświetlania domyślnej, niezafiltrowanej listy restauracji. Spójrz na implementację RestaurantsTableViewController.baseQuery():

return Firestore.firestore().collection("restaurants").limit(to: 50)

To zapytanie pobiera do 50 restauracji z kolekcji najwyższego poziomu o nazwie „restauracje”. Teraz, gdy mamy zapytanie, musimy dołączyć odbiornik zrzutu, aby wczytywać dane z Firestore do aplikacji. Dodaj ten kod do metody RestaurantsTableViewController.observeQuery() tuż po wywołaniu metody 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()
}

Powyższy kod pobiera kolekcję z Firestore i przechowuje ją lokalnie w tablicy. Wywołanie addSnapshotListener(_:) dodaje do zapytania detektor zrzutu, który będzie aktualizować kontroler widoku za każdym razem, gdy dane na serwerze ulegną zmianie. Otrzymujemy aktualizacje automatycznie i nie musimy ręcznie przesyłać zmian. Pamiętaj, że ten odbiorca zrzutu może zostać wywołany w dowolnym momencie w wyniku zmiany po stronie serwera, dlatego ważne jest, aby nasza aplikacja mogła obsługiwać zmiany.

Po zmapowaniu naszych słowników na struktury (patrz Restaurant.swift) wyświetlanie danych polega tylko na przypisaniu kilku właściwości widoku. Dodaj te wiersze do pliku RestaurantTableViewCell.populate(restaurant:) w folderze 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)

Ta metoda wypełniania jest wywoływana z metody tableView(_:cellForRowAtIndexPath:) źródła danych widoku tabeli, która zajmuje się mapowaniem kolekcji typów wartości z poprzedniego widoku na poszczególne komórki widoku tabeli.

Uruchom aplikację ponownie i sprawdź, czy restauracje, które wcześniej były widoczne w konsoli, są teraz widoczne w symulatorze lub na urządzeniu. Jeśli udało Ci się ukończyć tę sekcję, Twoja aplikacja odczytuje i zapisze dane w Cloud Firestore.

391c0259bf05ac25.png

6. Sortowanie i filtrowanie danych

Obecnie nasza aplikacja wyświetla listę restauracji, ale użytkownik nie może filtrować wyników według swoich potrzeb. W tej sekcji użyjesz zaawansowanych zapytań Firestore, aby włączyć filtrowanie.

Oto przykład prostego zapytania służącego do pobierania wszystkich restauracji serwujących dim sum:

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

Jak wskazuje nazwa, metoda whereField(_:isEqualTo:) spowoduje, że nasze zapytanie pobierze tylko elementy kolekcji, których pola spełniają określone przez nas ograniczenia. W tym przypadku pobierane będą tylko restauracje, w których category to "Dim Sum".

W tej aplikacji użytkownik może łączyć wiele filtrów, aby tworzyć konkretne zapytania, np. „Pizza w San Francisco” lub „Owoce morza w Los Angeles posortowane według popularności”.

Otwórz plik RestaurantsTableViewController.swift i dodaj do niego ten blok kodu: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)
}

Fragment kodu powyżej dodaje wiele klauzul whereFieldorder, aby utworzyć pojedyncze złożone zapytanie na podstawie danych wejściowych użytkownika. Teraz zapytanie zwróci tylko restauracje, które spełniają wymagania użytkownika.

Uruchom projekt i sprawdź, czy możesz filtrować według ceny, miasta i kategorii (upewnij się, że wpisujesz nazwy kategorii i miast dokładnie). Podczas testowania możesz zobaczyć w dziennikach błędy o takim wyglądzie:

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=...}

Dzieje się tak, ponieważ Firestore wymaga indeksów w przypadku większości złożonych zapytań. Wymaganie indeksów w zapytaniach pozwala zachować dużą szybkość Firestore na dużą skalę. Kliknięcie linku w wiadomości o błędzie spowoduje automatyczne otwarcie w konsoli Firebase interfejsu tworzenia indeksu z właściwymi parametrami. Więcej informacji o indeksach w Firestore znajdziesz w dokumentacji.

7. Zapisywanie danych w transakcji

W tej sekcji dodamy możliwość przesyłania opinii o restauracjach. Do tej pory wszystkie nasze operacje zapisu były atomowe i względnie proste. Jeśli któryś z nich nie zadziała, poprosimy użytkownika o powtórzenie próby lub wykonamy ją automatycznie.

Aby dodać ocenę restauracji, musimy skoordynować wiele operacji odczytu i zapisu. Najpierw należy przesłać opinię, a potem zaktualizować liczbę ocen i średnią ocenę restauracji. Jeśli jeden z tych testów się nie powiedzie, ale nie drugi, pozostaniemy w niezgodnym stanie, w którym dane w jednej części naszej bazy danych nie będą pasować do danych w innej.

Na szczęście Firestore udostępnia funkcję transakcji, która umożliwia wykonywanie wielu operacji odczytu i zapisu w ramach pojedynczej operacji atomowej, co zapewnia spójność danych.

Dodaj ten kod pod wszystkimi deklaracją zmiennych let w pliku 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)
    }
  }
}

Wewnątrz bloku aktualizacji wszystkie operacje wykonywane przez nas za pomocą obiektu transakcji będą traktowane przez Firestore jako pojedyncza operacja atomistyczna. Jeśli aktualizacja na serwerze się nie powiedzie, Firestore automatycznie spróbuje jeszcze kilka razy. Oznacza to, że błąd jest najprawdopodobniej pojedynczym błędem, który występuje wielokrotnie, np. gdy urządzenie jest całkowicie offline lub użytkownik nie ma uprawnień do zapisu na ścieżce, na której próbuje zapisać dane.

8. Reguły zabezpieczeń

Użytkownicy naszej aplikacji nie powinni mieć możliwości odczytu i zapisu wszystkich danych w naszej bazie danych. Na przykład każdy powinien mieć możliwość wyświetlania ocen restauracji, ale tylko uwierzytelniony użytkownik powinien mieć możliwość publikowania oceny. Nie wystarczy napisać dobrego kodu po stronie klienta. Aby zapewnić pełne bezpieczeństwo, musimy określić model zabezpieczeń danych po stronie serwera. W tej sekcji dowiesz się, jak chronić swoje dane za pomocą reguł bezpieczeństwa Firebase.

Najpierw przyjrzyjmy się bliżej regułom bezpieczeństwa napisanym na początku tego ćwiczenia. Otwórz konsolę Firebase i kliknij Baza danych > Reguły na karcie 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;
    }
  }
}

Zmienna request w regułach jest zmienną globalną dostępną we wszystkich regułach, a dodany przez nas warunek zapewnia uwierzytelnienie żądania przed zezwoleniem użytkownikom na wykonanie jakiejkolwiek czynności. Dzięki temu niezaufani użytkownicy nie będą mogli używać interfejsu Firestore API do wprowadzania nieautoryzowanych zmian w Twoich danych. To dobry początek, ale dzięki regułom Firestore możemy robić znacznie więcej.

Chcemy ograniczyć zapisywanie opinii, tak aby identyfikator użytkownika opinii był zgodny z identyfikatorem uwierzytelnionego użytkownika. Dzięki temu użytkownicy nie mogą podszywać się pod siebie nawzajem i pozostawiać fałszywych opinii.

Pierwsze wyrażenie dopasowania pasuje do podrzędnej kolekcji o nazwie ratings w przypadku dowolnego dokumentu należącego do kolekcji restaurants. Warunek allow write uniemożliwia przesłanie opinii, jeśli identyfikator użytkownika opinii nie pasuje do identyfikatora użytkownika. Drugie wyrażenie dopasowywania pozwala każdemu uwierzytelnionemu użytkownikowi odczytywać i zapisywać restauracje w bazie danych.

To rozwiązanie sprawdza się w przypadku opinii, ponieważ dzięki regułom bezpieczeństwa możemy wyraźnie określić gwarancję, która jest już zawarta w naszej aplikacji, a mianowicie że użytkownicy mogą pisać tylko własne opinie. Gdybyśmy dodali funkcję edycji lub usuwania opinii, ten sam zestaw zasad uniemożliwiłby użytkownikom modyfikowanie lub usuwanie opinii innych użytkowników. Reguły Firestore można jednak stosować bardziej szczegółowo, aby ograniczać zapisywanie w poszczególnych polach w dokumentach, a nie w całych dokumentach. Dzięki temu użytkownicy mogą aktualizować tylko oceny, średnią ocenę i liczbę ocen restauracji, co eliminuje możliwość zmiany nazwy lub lokalizacji restauracji przez złośliwych użytkowników.

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;
    }
  }
}

W tym przypadku podzieliliśmy uprawnienie do zapisu na tworzenie i aktualizowanie, aby dokładniej określić, które operacje powinny być dozwolone. Każdy użytkownik może zapisać restauracje w bazie danych, zachowując funkcjonalność przycisku „Wypełnij”, który został utworzony na początku tego ćwiczenia. Jednak po zapisaniu restauracji jej nazwa, lokalizacja, cena i kategoria nie mogą być zmieniane. W szczególności ostatnie reguły wymagają, aby w przypadku każdej operacji aktualizacji restauracji zachować nazwę, miasto, cenę i kategorię w już istniejących polach w bazie danych.

Więcej informacji o tym, co możesz zrobić z regułami zabezpieczeń, znajdziesz w dokumentacji.

9. Podsumowanie

W tym laboratorium programistycznym dowiesz się, jak wykonywać podstawowe i zaawansowane odczyty i zapisy w Firestore, a także jak zabezpieczyć dostęp do danych za pomocą reguł zabezpieczeń. Pełne rozwiązanie znajdziesz na gałęzi codelab-complete.

Więcej informacji o Firestore znajdziesz w tych materiałach: