Laboratorium kodowania Cloud Firestore na iOS

1. Przegląd

Cele

Podczas tych zajęć z programowania zbudujesz aplikację rekomendującą restauracje wspieraną przez Firestore na iOS w Swift. Dowiesz się jak:

  1. Odczytuj i zapisuj dane w Firestore z aplikacji na iOS
  2. Słuchaj zmian w danych Firestore w czasie rzeczywistym
  3. Użyj uwierzytelniania Firebase i reguł bezpieczeństwa, aby zabezpieczyć dane Firestore
  4. Pisz złożone zapytania Firestore

Warunki wstępne

Przed rozpoczęciem ćwiczeń z kodowania upewnij się, że zainstalowałeś:

  • Wersja Xcode 14.0 (lub wyższa)
  • CocoaPods 1.12.0 (lub nowszy)

2. Utwórz projekt konsoli Firebase

Dodaj Firebase do projektu

  1. Przejdź do konsoli Firebase .
  2. Wybierz opcję Utwórz nowy projekt i nazwij swój projekt „Firestore iOS Codelab”.

3. Pobierz przykładowy projekt

Pobierz kod

Rozpocznij 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ę poprawnie skompilować i natychmiastowo zawiesić po uruchomieniu, ponieważ brakuje w niej pliku GoogleService-Info.plist . Poprawimy to w następnym kroku.

Skonfiguruj Firebase

Postępuj zgodnie z dokumentacją , aby utworzyć nowy projekt Firestore. Gdy już masz projekt, pobierz plik GoogleService-Info.plist swojego projektu z konsoli Firebase i przeciągnij go do katalogu głównego projektu Xcode. Uruchom projekt ponownie, aby upewnić się, że aplikacja jest poprawnie skonfigurowana i nie ulega już awarii podczas uruchamiania. Po zalogowaniu powinieneś zobaczyć pusty ekran, jak w przykładzie poniżej. Jeśli nie możesz się zalogować, upewnij się, że włączyłeś metodę logowania za pomocą adresu e-mail/hasła w konsoli Firebase w obszarze Uwierzytelnianie.

d5225270159c040b.png

4. Zapisz dane w Firestore

W tej sekcji zapiszemy pewne dane w Firestore, abyśmy mogli wypełnić interfejs aplikacji. Można to zrobić ręcznie za pomocą konsoli Firebase , ale zrobimy to w samej aplikacji, aby zademonstrować podstawowy zapis Firestore.

Głównym obiektem modelowym w naszej aplikacji jest restauracja. Dane Firestore są podzielone na dokumenty, kolekcje i podkolekcje. Każdą restaurację będziemy przechowywać jako dokument w kolekcji najwyższego poziomu zwanej restaurants . Jeśli chcesz dowiedzieć się więcej o modelu danych Firestore, przeczytaj o dokumentach i zbiorach w dokumentacji .

Zanim będziemy mogli dodać dane do Firestore, musimy uzyskać odniesienie do kolekcji restauracji. Dodaj następujący tekst do wewnętrznej pętli for w metodzie RestaurantsTableViewController.didTapPopulateButton(_:) .

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

Teraz, gdy mamy odwołanie do kolekcji, możemy zapisać pewne dane. Dodaj następujący tekst zaraz po ostatniej linijce kodu, którą 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 ze struktury Restaurant.

Już prawie u celu — zanim będziemy mogli pisać dokumenty w Firestore, musimy otworzyć reguły bezpieczeństwa Firestore i opisać, które części naszej bazy danych powinny być dostępne do zapisu dla poszczególnych użytkowników. Na razie zezwalamy tylko uwierzytelnionym użytkownikom na odczyt i zapis w całej bazie danych. Jest to trochę zbyt liberalne w przypadku aplikacji produkcyjnej, ale podczas procesu tworzenia aplikacji chcemy czegoś na tyle swobodnego, aby podczas eksperymentowania nie napotykać ciągle problemów z uwierzytelnianiem. Pod koniec tego ćwiczenia z programowania porozmawiamy o tym, jak zaostrzyć reguły bezpieczeństwa i ograniczyć możliwość niezamierzonych odczytów i zapisów.

Na karcie Reguły konsoli Firebase dodaj następujące reguły, a następnie kliknij Publikuj .

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

Zasady bezpieczeństwa omówimy szczegółowo później, ale jeśli się spieszysz, zajrzyj do dokumentacji reguł bezpieczeństwa .

Uruchom aplikację i zaloguj się. Następnie naciśnij przycisk „ Wypełnij ” w lewym górnym rogu, co spowoduje utworzenie partii dokumentów restauracji, chociaż nie zobaczysz tego jeszcze w aplikacji.

Następnie przejdź do karty danych Firestore w konsoli Firebase. Powinieneś teraz zobaczyć nowe wpisy w kolekcji restauracji:

Zrzut ekranu 2017-07-06 o 12.45.38.png

Gratulacje, właśnie zapisałeś dane do Firestore z aplikacji na iOS! W następnej sekcji dowiesz się, jak pobrać dane z Firestore i wyświetlić je w aplikacji.

5. Wyświetl dane z Firestore

W tej sekcji dowiesz się, jak pobrać dane z Firestore i wyświetlić je w aplikacji. Dwa kluczowe kroki to utworzenie zapytania i dodanie odbiornika migawek. Ten słuchacz zostanie powiadomiony o wszystkich istniejących danych pasujących do zapytania i otrzyma aktualizacje w czasie rzeczywistym.

Na początek skonstruujmy zapytanie, które będzie obsługiwało domyślną, niefiltrowaną listę restauracji. Przyjrzyj się implementacji 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 migawek, aby załadować dane z Firestore do naszej aplikacji. Dodaj następujący kod do metody RestaurantsTableViewController.observeQuery() zaraz 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 migawek, który będzie aktualizował kontroler widoku za każdym razem, gdy zmienią się dane na serwerze. Aktualizacje otrzymujemy automatycznie i nie musimy ręcznie wprowadzać zmian. Pamiętaj, że ten odbiornik migawek można wywołać w dowolnym momencie w wyniku zmiany po stronie serwera, dlatego ważne jest, aby nasza aplikacja mogła obsłużyć zmiany.

Po zmapowaniu naszych słowników na struktury (zobacz Restaurant.swift ), wyświetlenie danych sprowadza się jedynie do przypisania kilku właściwości widoku. Dodaj następujące wiersze do RestaurantTableViewCell.populate(restaurant:) w 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 wcześniejszych typów wartości na poszczególne komórki widoku tabeli.

Uruchom aplikację ponownie i sprawdź, czy restauracje, które widzieliśmy wcześniej w konsoli, są teraz widoczne na symulatorze lub urządzeniu. Jeśli pomyślnie ukończyłeś tę sekcję, Twoja aplikacja odczytuje i zapisuje dane w Cloud Firestore!

391c0259bf05ac25.png

6. Sortowanie i filtrowanie danych

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

Oto przykład prostego zapytania umożliwiającego pobranie wszystkich restauracji Dim Sum:

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

Jak sama nazwa wskazuje, metoda whereField(_:isEqualTo:) sprawi, że nasze zapytanie pobierze tylko elementy kolekcji, których pola spełniają ustawione przez nas ograniczenia. W tym przypadku pobrane zostaną tylko restauracje, których category to "Dim Sum" .

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

Otwórz plik RestaurantsTableViewController.swift i dodaj następujący blok kodu w środku 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)
}

Powyższy fragment dodaje wiele klauzul whereField i order w celu zbudowania pojedynczego zapytania złożonego na podstawie danych wprowadzonych przez użytkownika. Teraz nasze zapytanie zwróci tylko restauracje spełniające wymagania użytkownika.

Uruchom swój projekt i sprawdź, czy możesz filtrować według ceny, miasta i kategorii (pamiętaj, aby wpisać dokładnie nazwę kategorii i miasta). Podczas testowania możesz zobaczyć błędy w dziennikach, które wyglądają tak:

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 dla większości zapytań złożonych. Wymaganie indeksów w zapytaniach pozwala Firestore szybko działać na dużą skalę. Otwarcie linku z komunikatu o błędzie spowoduje automatyczne otwarcie interfejsu tworzenia indeksu w konsoli Firebase z wypełnionymi poprawnymi parametrami. Aby dowiedzieć się więcej o indeksach w Firestore, odwiedź dokumentację .

7. Zapisywanie danych w transakcji

W tej sekcji dodamy możliwość przesyłania przez użytkowników recenzji restauracjom. Jak dotąd wszystkie nasze zapisy były atomowe i stosunkowo proste. Jeśli którykolwiek z nich zawiera błąd, prawdopodobnie po prostu poprosimy użytkownika o ponowienie próby lub ponowienie próby automatycznie.

Aby dodać ocenę do restauracji, musimy skoordynować wiele odczytów i zapisów. Najpierw należy przesłać samą recenzję, a następnie zaktualizować liczbę ocen restauracji i średnią ocenę. Jeśli jeden z nich zawiedzie, ale drugi nie, pozostaniemy w niespójnym stanie, w którym dane w jednej części naszej bazy danych nie będą zgodne z danymi w innej.

Na szczęście Firestore zapewnia funkcjonalność transakcji, która pozwala nam wykonywać wielokrotne odczyty i zapisy w jednej niepodzielnej operacji, zapewniając spójność naszych danych.

Dodaj następujący kod poniżej wszystkich deklaracji let w 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, które wykonujemy przy użyciu obiektu transakcji, będą traktowane przez Firestore jako pojedyncza aktualizacja atomowa. Jeśli aktualizacja na serwerze nie powiedzie się, Firestore automatycznie ponowi ją kilka razy. Oznacza to, że naszym warunkiem błędu jest najprawdopodobniej pojedynczy, powtarzający się błąd, na przykład jeśli urządzenie jest całkowicie offline lub użytkownik nie jest upoważniony do zapisu w ścieżce, w której próbuje pisać.

8. Zasady bezpieczeństwa

Użytkownicy naszej aplikacji nie powinni móc czytać i zapisywać wszystkich danych w naszej bazie danych. Na przykład każdy powinien mieć możliwość przeglądania ocen restauracji, ale tylko uwierzytelniony użytkownik powinien mieć możliwość zamieszczania ocen. Nie wystarczy napisać dobry kod na kliencie, musimy określić nasz model bezpieczeństwa danych na backendzie, aby był całkowicie bezpieczny. W tej sekcji dowiemy się, jak używać reguł bezpieczeństwa Firebase do ochrony naszych danych.

Najpierw przyjrzyjmy się bliżej regułom bezpieczeństwa, które napisaliśmy na początku ćwiczeń z kodowania. Otwórz konsolę Firebase i przejdź do Baza danych > Reguły na karcie 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;
    }
  }
}

Zmienna request w powyższych regułach jest zmienną globalną dostępną we wszystkich regułach, a dodany przez nas warunek zapewnia, że ​​żądanie zostanie uwierzytelnione przed umożliwieniem użytkownikom wykonania czegokolwiek. Uniemożliwia to nieuwierzytelnionym użytkownikom korzystanie z interfejsu API Firestore w celu wprowadzania nieautoryzowanych zmian w danych. To dobry początek, ale możemy używać reguł Firestore do robienia znacznie potężniejszych rzeczy.

Ograniczmy możliwość pisania recenzji, tak aby identyfikator użytkownika recenzji był zgodny z identyfikatorem użytkownika uwierzytelnionego. Dzięki temu użytkownicy nie będą mogli podszywać się pod innych i pozostawiać fałszywych recenzji. Zastąp reguły bezpieczeństwa następującymi:

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

Pierwsza instrukcja dopasowania dopasowuje podkolekcję o nazwie ratings dowolnego dokumentu należącego do kolekcji restaurants . allow write uniemożliwia wówczas przesłanie recenzji, jeśli identyfikator użytkownika recenzji nie jest zgodny z identyfikatorem użytkownika. Druga instrukcja dopasowania umożliwia każdemu uwierzytelnionemu użytkownikowi odczytywanie i zapisywanie restauracji w bazie danych.

Działa to naprawdę dobrze w przypadku naszych recenzji, ponieważ zastosowaliśmy reguły bezpieczeństwa, aby wyraźnie określić dorozumianą gwarancję, którą napisaliśmy wcześniej w naszej aplikacji – że użytkownicy mogą pisać tylko własne recenzje. Gdybyśmy dodali funkcję edycji lub usuwania recenzji, dokładnie ten sam zestaw reguł uniemożliwiłby użytkownikom modyfikowanie lub usuwanie recenzji innych użytkowników. Jednak reguł Firestore można również używać w bardziej szczegółowy sposób, aby ograniczyć zapisy w poszczególnych polach w dokumentach, a nie w samych dokumentach. Możemy to wykorzystać, aby umożliwić użytkownikom aktualizację jedynie ocen, średniej oceny i liczby ocen restauracji, eliminując możliwość zmiany nazwy lub lokalizacji restauracji przez złośliwego użytkownika.

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 miejscu podzieliliśmy nasze uprawnienia do zapisu na tworzenie i aktualizację, dzięki czemu możemy bardziej szczegółowo określić, które operacje powinny być dozwolone. Każdy użytkownik może zapisywać restauracje do bazy danych, zachowując funkcjonalność przycisku Wypełnij, który stworzyliśmy na początku ćwiczeń z programowania, ale po zapisaniu restauracji nie można zmienić jej nazwy, lokalizacji, ceny ani kategorii. Mówiąc dokładniej, ostatnia reguła wymaga, aby każda operacja aktualizacji restauracji zachowała tę samą nazwę, miasto, cenę i kategorię, co pola już istniejące w bazie danych.

Aby dowiedzieć się więcej o tym, co możesz zrobić z regułami bezpieczeństwa, zapoznaj się z dokumentacją .

9. Wniosek

Podczas tych zajęć z programowania nauczyłeś się podstawowego i zaawansowanego odczytu i zapisu w Firestore, a także tego, jak zabezpieczyć dostęp do danych za pomocą reguł bezpieczeństwa. Pełne rozwiązanie można znaleźć w gałęzi codelab-complete .

Aby dowiedzieć się więcej o Firestore, odwiedź następujące zasoby: