1. Omówienie
Cele
Z tego ćwiczenia w Codelabs dowiesz się, jak w Swift utworzyć aplikację z rekomendacjami restauracji popartą Firestore na iOS. Zapoznasz się z tymi zagadnieniami:
- Odczytywanie i zapisywanie danych w Firestore z poziomu aplikacji na iOS
- Nasłuchuj zmian w danych Firestore w czasie rzeczywistym
- Zabezpieczanie danych Firestore za pomocą uwierzytelniania Firebase i reguł zabezpieczeń
- Zapisywanie złożonych zapytań Firestore
Wymagania wstępne
Zanim rozpoczniesz to ćwiczenia w programie, sprawdź, czy masz zainstalowane:
- Xcode w wersji 14.0 (lub nowszej)
- CocoaPods w wersji 1.12.0 (lub nowszej)
2. Utwórz projekt konsoli Firebase
Dodaj Firebase do projektu
- Otwórz konsolę Firebase.
- Wybierz Utwórz nowy projekt i nadaj projektowi nazwę „Firestore iOS Codelabs”.
3. Pobierz przykładowy projekt
Pobierz kod
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 plik FriendlyEats.xcworkspace
w Xcode i uruchom go (Cmd+R). Aplikacja powinna się prawidłowo skompilować i zaraz po uruchomieniu natychmiast ulec awarii, ponieważ brakuje w niej pliku GoogleService-Info.plist
. Poprawimy to w następnym kroku.
Skonfiguruj Firebase
Aby utworzyć nowy projekt Firestore, postępuj zgodnie z dokumentacją. Gdy otrzymasz projekt, pobierz jego plik GoogleService-Info.plist
z konsoli Firebase i przeciągnij go do katalogu głównego projektu Xcode. Uruchom projekt jeszcze raz, aby upewnić się, że aplikacja jest skonfigurowana prawidłowo i nie ulega awarii przy uruchamianiu. Po zalogowaniu powinien wyświetlić się pusty ekran, taki jak w przykładzie poniżej. Jeśli nie możesz się zalogować, upewnij się, że w sekcji Uwierzytelnianie w konsoli Firebase jest włączona metoda logowania „E-mail/hasło”.
4. Zapisywanie danych w Firestore
W tej sekcji zapiszemy pewne dane w Firestore, abyśmy mogli zapełnić interfejs aplikacji. Można to zrobić ręcznie za pomocą konsoli Firebase, ale zrobimy to w samej aplikacji, aby zademonstrować podstawowy zapis w Firestore.
Głównym obiektem modelu w naszej aplikacji jest restauracja. Dane Firestore są podzielone na dokumenty, kolekcje i podkolekcje. Każdą restaurację zapiszemy jako dokument w kolekcji najwyższego poziomu o nazwie restaurants
. Więcej informacji o modelu danych Firestore znajdziesz w dokumentacji dotyczącej dokumentów i kolekcji.
Zanim będzie można dodać dane do Firestore, musimy uzyskać odniesienie do kolekcji restauracji. Dodaj ten kod do wewnętrznej pętli for w metodzie RestaurantsTableViewController.didTapPopulateButton(_:)
.
let collection = Firestore.firestore().collection("restaurants")
Skoro mamy już źródło informacji, możemy zapisać trochę danych. Dodaj poniższy kod 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 pochodzi z struktury Restaurant (struct).
To już prawie wszystko – zanim będziemy mogli zapisywać dokumenty w Firestore, musimy otworzyć reguły zabezpieczeń Firestore i opisać, które części naszej bazy danych mają być zapisywane przez poszczególnych użytkowników. Na razie tylko uwierzytelnieni użytkownicy mogą odczytywać i zapisywać dane w całej bazie danych. To trochę zbyt mało restrykcyjne w przypadku aplikacji w wersji produkcyjnej, ale podczas tworzenia aplikacji chcemy mieć pewność, że wszystko będzie na tyle spokojne, żeby nie powodować ciągłego problemów z uwierzytelnianiem podczas eksperymentów. Na końcu tego ćwiczenia w Codelabs porozmawiamy o tym, jak wzmocnić reguły zabezpieczeń i ograniczyć ryzyko niezamierzonych odczytów i zapisów.
Na karcie Reguły w konsoli Firebase dodaj poniższe reguły i kliknij Opublikuj.
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; } } }
Reguły zabezpieczeń omówimy szczegółowo później, ale jeśli się spieszysz, zapoznaj się z dokumentacją reguł zabezpieczeń.
Uruchom aplikację i zaloguj się. Następnie kliknij „Wypełnij”. w lewym górnym rogu, co spowoduje utworzenie grupy dokumentów dotyczących restauracji. Nie jest on jeszcze widoczny w aplikacji.
Następnie otwórz w konsoli Firebase kartę Firestore dane (Odpal dane). W kolekcji restauracji powinny być teraz widoczne nowe wpisy:
Gratulacje. Właśnie udało Ci się zapisać dane w Firestore z aplikacji na iOS. W następnej sekcji dowiesz się, jak pobrać dane z Firestore i wyświetlać 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 detektora zrzutów. Ten detektor będzie powiadamiany o wszystkich istniejących danych, które pasują do zapytania, oraz będą otrzymywać aktualizacje w czasie rzeczywistym.
Najpierw utwórzmy zapytanie, które wyświetli domyślną, niefiltrowaną listę restauracji. Zobacz implementację RestaurantsTableViewController.baseQuery()
:
return Firestore.firestore().collection("restaurants").limit(to: 50)
To zapytanie pobiera maksymalnie 50 restauracji z kolekcji najwyższego poziomu o nazwie „restauracje”. Po utworzeniu zapytania musimy dołączyć detektor zrzutów, aby wczytać dane z Firestore w aplikacji. Dodaj poniższy kod do metody RestaurantsTableViewController.observeQuery()
zaraz po wywołaniu funkcji 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 zapisuje ją lokalnie w tablicy. Wywołanie addSnapshotListener(_:)
dodaje do zapytania detektor zrzutów, który za każdym razem aktualizuje kontroler widoku danych po każdej zmianie danych na serwerze. Aktualizacje pobieramy automatycznie i nie musisz ręcznie wprowadzać zmian. Pamiętaj, że ten detektor zrzutów można wywołać 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 typu struct (patrz sekcja Restaurant.swift
) wyświetlanie danych wymaga tylko przypisania kilku właściwości widoku. Dodaj te wiersze do dokumentu RestaurantTableViewCell.populate(restaurant:)
w języku: 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 uzupełniania jest wywoływana z metody tableView(_:cellForRowAtIndexPath:)
źródła danych widoku tabeli, która odpowiada mapowaniu zbioru typów wartości z wcześniejszego okresu na poszczególne komórki widoku tabeli.
Uruchom aplikację jeszcze raz i sprawdź, czy restauracje, które widzieliśmy wcześniej w konsoli, są teraz widoczne w symulatorze lub na urządzeniu. Jeśli udało Ci się ukończyć tę sekcję, Twoja aplikacja może teraz odczytywać i zapisywać dane w Cloud Firestore.
6. Sortowanie i filtrowanie danych
Obecnie nasza aplikacja wyświetla listę restauracji, ale użytkownik nie może filtrować wyników według potrzeb. W tej sekcji włączysz filtrowanie przy użyciu zaawansowanych zapytań Firestore.
Oto przykład prostego zapytania, które pozwala pobrać wszystkie restauracje z dim sum:
let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")
Jak sama nazwa wskazuje, metoda whereField(_:isEqualTo:)
powoduje, że zapytanie pobiera tylko elementy kolekcji, których pola spełniają ustawione przez nas ograniczenia. Pobierze ona tylko te restauracje, w których category
ma wartość "Dim Sum"
.
W tej aplikacji użytkownik może połączyć kilka filtrów, aby utworzyć konkretne zapytania, takie jak „pizza w Krakowie”. lub „Owoce morza w Los Angeles wg popularności”.
Otwórz RestaurantsTableViewController.swift
i dodaj następujący blok kodu w środku elementu 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 kodu dodaje kilka klauzul whereField
i order
, aby utworzyć jedno złożone zapytanie na podstawie danych wejściowych użytkownika. Teraz zapytanie zwróci tylko te restauracje, które spełniają wymagania użytkownika.
Uruchom projekt i sprawdź, czy możesz filtrować według ceny, miasta i kategorii (wpisz dokładnie nazwę kategorii i miasta). Podczas testowania w dziennikach możesz zobaczyć takie błędy:
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 zapytań złożonych. Dzięki wymaganiu indeksów w zapytaniach usługa Firestore działa szybko na dużą skalę. Otwarcie linku z komunikatu o błędzie spowoduje automatyczne otwarcie interfejsu tworzenia indeksów w konsoli Firebase z wpisanymi prawidłowymi 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 przez użytkowników. Do tej pory wszystkie nasze teksty były bardzo szczegółowe i stosunkowo proste. W przypadku błędu, użytkownik powinien po prostu poprosić o ponowną próbę lub zrobić to automatycznie.
Aby dodać ocenę restauracji, musimy skoordynować kilka zapisów i odczytów. Najpierw należy przesłać opinię, a potem zaktualizować liczbę i średnią ocenę restauracji. Jeśli w jednej z tych usług wystąpi błąd, a w drugiej nie, powstanie niespójność – dane w jednej części bazy danych nie będą zgodne z danymi w innej.
Na szczęście Firestore udostępnia funkcję transakcji, która pozwala na wykonywanie wielu odczytów i zapisów w ramach jednej niepodzielnej operacji, co zapewnia spójność danych.
Dodaj poniższy kod pod wszystkimi deklaracjami Let w zasadzie 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)
}
}
}
Wszystkie operacje w bloku aktualizacji, które wykonujemy przy użyciu obiektu transakcji, będą traktowane przez Firestore jako jedna niepodzielna aktualizacja. Jeśli aktualizacja nie powiedzie się na serwerze, Firestore spróbuje kilka razy automatycznie ponawiać próby. Oznacza to najprawdopodobniej, że błąd występuje wielokrotnie, np. gdy urządzenie jest w trybie offline lub użytkownik nie jest upoważniony do zapisywania danych w ścieżce, na której próbuje zapisywać dane.
8. Reguły zabezpieczeń
Użytkownicy naszej aplikacji nie powinni być w stanie odczytywać ani zapisywać wszystkich danych w naszej bazie danych. Na przykład każdy użytkownik powinien mieć dostęp do ocen restauracji, ale tylko uwierzytelniony użytkownik powinien mieć możliwość wystawiania ocen. Napisanie dobrego kodu po stronie klienta nie wystarczy – musimy całkowicie określić nasz model zabezpieczeń danych w backendzie, aby był całkowicie bezpieczny. Z tej sekcji dowiesz się, jak używać reguł zabezpieczeń Firebase do ochrony swoich danych.
Najpierw przyjrzyjmy się regułom zabezpieczeń, które napisaliśmy na początku ćwiczeń z programowania. Otwórz konsolę Firebase i kliknij 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. Dodana przez nas zmienna warunkowa gwarantuje uwierzytelnianie żądania przed zezwoleniem użytkownikowi na wykonanie jakiejkolwiek czynności. Uniemożliwia to nieuwierzytelnionym użytkownikom korzystanie z interfejsu Firestore API do wprowadzania nieautoryzowanych zmian w Twoich danych. To dobry początek, ale możemy skorzystać z reguł Firestore, aby zrobić o wiele bardziej zaawansowane funkcje.
Ograniczmy zapisy opinii, aby identyfikator użytkownika opinii był zgodny z identyfikatorem uwierzytelnionego użytkownika. Dzięki temu użytkownicy nie będą mogli podszywać się pod inne osoby ani zamieszczać fałszywych opinii. Zastąp swoje reguły zabezpieczeń tymi:
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 pasuje do podkolekcji o nazwie ratings
dowolnego dokumentu należącego do kolekcji restaurants
. Warunkowość allow write
uniemożliwia przesłanie opinii, jeśli identyfikator użytkownika nie jest zgodny z identyfikatorem użytkownika. Druga instrukcja dopasowania umożliwia każdemu uwierzytelnionemu użytkownikowi odczytywanie i zapisywanie restauracji w bazie danych.
Takie rozwiązanie sprawdza się bardzo dobrze w przypadku naszych weryfikacji, ponieważ za pomocą reguł zabezpieczeń wyraźnie określiliśmy dorozumianą gwarancję, którą napisaliśmy wcześniej w aplikacji – że użytkownicy mogą pisać tylko własne opinie. Gdyby dodać do opinii funkcję edytowania lub usuwania, taki sam zestaw reguł uniemożliwiłby też użytkownikom modyfikowanie lub usuwanie opinii innych użytkowników recenzje produktów. Reguły Firestore mogą też być używane w bardziej szczegółowy sposób, aby ograniczyć zapisy do poszczególnych pól w dokumentach, a nie do całych dokumentów. Dzięki temu możemy umożliwić użytkownikom aktualizowanie tylko ocen, średniej oceny i liczby ocen restauracji, co eliminuje 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;
}
}
}
Podzieliliśmy uprawnienia do zapisu na tworzenie i aktualizowanie plików, by dowiedzieć się więcej o tym, które operacje powinny być dozwolone. Każdy użytkownik może zapisywać restauracje w bazie danych, zachowując działanie przycisku Wypełnij pole, który wprowadziliśmy na początku ćwiczeń z programowania, ale po zapisaniu restauracji nie można zmienić jej nazwy, lokalizacji, ceny ani kategorii. Dokładniej rzecz ujmując, ostatnia reguła wymaga, aby podczas operacji aktualizacji restauracji zachowały one nazwę, miasto, cenę i kategorię już istniejących pól w bazie danych.
Więcej informacji o tym, co możesz zrobić z regułami zabezpieczeń, znajdziesz w dokumentacji.
9. Podsumowanie
Dzięki temu ćwiczeniu w Codelabs omówiliśmy 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 w gałęzi codelab-complete
.
Więcej informacji o Firestore znajdziesz na tych stronach: