Codelab su Cloud Firestore per iOS

1. Panoramica

Obiettivi

In questo codelab, creerai un'app di consigli sui ristoranti basata su Firestore su iOS in Swift. Imparerai come:

  1. Leggi e scrivi dati in Firestore da un'app per iOS
  2. Ascolta le modifiche nei dati Firestore in tempo reale
  3. Usare Firebase Authentication e le regole di sicurezza per proteggere i dati Firestore
  4. Scrivere query Firestore complesse

Prerequisiti

Prima di iniziare questo codelab, assicurati di aver installato:

  • Xcode versione 14.0 (o successive)
  • CocoaPods 1.12.0 (o versioni successive)

2. Creare un progetto nella Console Firebase

Aggiungi Firebase al progetto

  1. Vai alla Console Firebase.
  2. Seleziona Crea nuovo progetto e assegna al progetto il nome "Firestore iOS Codelab".

3. Ottieni il progetto di esempio

Scarica il codice

Inizia clonando il progetto di esempio ed eseguendo pod update nella directory del progetto:

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

Apri FriendlyEats.xcworkspace in Xcode ed eseguilo (Cmd+R). L'app dovrebbe compilarsi correttamente e avere subito un arresto anomalo all'avvio perché manca un file GoogleService-Info.plist. Lo correggeremo nel passaggio successivo.

Configura Firebase

Segui la documentazione per creare un nuovo progetto Firestore. Una volta creato il progetto, scarica il file GoogleService-Info.plist del progetto dalla console Firebase e trascinalo nella directory principale del progetto Xcode. Esegui di nuovo il progetto per assicurarti che l'app venga configurata correttamente e non abbia più arresti anomali all'avvio. Dopo aver eseguito l'accesso, dovresti visualizzare una schermata vuota come nell'esempio seguente. Se non riesci ad accedere, assicurati di aver abilitato il metodo di accesso tramite email/password nella console Firebase in Autenticazione.

d5225270159c040b.png

4. Scrivere dati in Firestore

In questa sezione scriveremo alcuni dati in Firestore per poter compilare l'interfaccia utente dell'app. Questa operazione può essere eseguita manualmente tramite la Console Firebase, ma la eseguiremo nell'app stessa per dimostrare una scrittura di Firestore di base.

L'oggetto modello principale nella nostra app è un ristorante. I dati di Firestore sono suddivisi in documenti, raccolte e sottor raccolte. Archivieremo ogni ristorante come documento in una raccolta di primo livello denominata restaurants. Se vuoi saperne di più sul modello di dati di Firestore, leggi i documenti e le raccolte nella documentazione.

Prima di poter aggiungere dati a Firestore, dobbiamo ottenere un riferimento alla raccolta dei ristoranti. Aggiungi quanto segue al ciclo for interno nel metodo RestaurantsTableViewController.didTapPopulateButton(_:).

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

Ora che abbiamo un riferimento alla raccolta, possiamo scrivere alcuni dati. Aggiungi il codice seguente subito dopo l'ultima riga di codice che abbiamo aggiunto:

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)

Il codice riportato sopra aggiunge un nuovo documento alla raccolta ristoranti. I dati del documento provengono da un dizionario, che otteniamo da una struttura Restaurant.

Ci siamo quasi: prima di poter scrivere documenti in Firestore, dobbiamo aprire le regole di sicurezza di Firestore e descrivere quali parti del nostro database devono essere scrivibili da quali utenti. Per il momento, consentiremo solo agli utenti autenticati di leggere e scrivere nell'intero database. Questo è un po' troppo permissivo per un'app di produzione, ma durante la procedura di creazione dell'app vogliamo qualcosa di abbastanza flessibile per non riscontrare costantemente problemi di autenticazione durante la sperimentazione. Al termine di questo codelab parleremo di come rafforzare le regole di sicurezza e limitare la possibilità di letture e scritture indesiderate.

Nella scheda Regole della Console Firebase, aggiungi le seguenti regole e poi fai clic su Pubblica.

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

Parleremo delle regole di sicurezza in dettaglio più avanti, ma se hai poco tempo, dai un'occhiata alla documentazione sulle regole di sicurezza.

Esegui l'app e accedi. Quindi tocca il pulsante "Compila" in alto a sinistra, che creerà un batch di documenti del ristorante, anche se non li vedrai ancora nell'app.

Poi vai alla scheda Dati Firestore nella Console Firebase. A questo punto dovresti vedere le nuove voci nella raccolta ristoranti:

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

Complimenti, hai appena scritto dati in Firestore da un'app per iOS. Nella sezione successiva imparerai a recuperare i dati da Firestore e a visualizzarli nell'app.

5. Visualizza i dati da Firestore

In questa sezione imparerai a recuperare i dati da Firestore e a visualizzarli nell'app. I due passaggi chiave sono la creazione di una query e l'aggiunta di un listener di istantanee. Questo ascoltatore riceverà una notifica di tutti i dati esistenti corrispondenti alla query e riceverà aggiornamenti in tempo reale.

Innanzitutto, costruiamo la query che restituirà l'elenco predefinito non filtrato dei ristoranti. Dai un'occhiata all'implementazione di RestaurantsTableViewController.baseQuery():

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

Questa query recupera fino a 50 ristoranti della raccolta di primo livello denominata "ristoranti". Ora che abbiamo una query, dobbiamo collegare un listener di snapshot per caricare i dati da Firestore nella nostra app. Aggiungi il seguente codice al metodo RestaurantsTableViewController.observeQuery() subito dopo la chiamata a 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()
}

Il codice riportato sopra scarica la raccolta da Firestore e la memorizza localmente in un array. La chiamata addSnapshotListener(_:) aggiunge alla query un listener snapshot che aggiorna il controller della visualizzazione ogni volta che i dati cambiano sul server. Riceviamo gli aggiornamenti automaticamente e non dobbiamo inviare manualmente le modifiche. Ricorda che questo ascoltatore di istantanee può essere invocato in qualsiasi momento a seguito di una modifica lato server, quindi è importante che la nostra app possa gestire le modifiche.

Dopo aver mappato i nostri dizionari alle strutture (vedi Restaurant.swift), la visualizzazione dei dati è solo una questione di assegnazione di alcune proprietà di visualizzazione. Aggiungi le seguenti righe a RestaurantTableViewCell.populate(restaurant:) in 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)

Questo metodo di compilazione viene chiamato dal metodo tableView(_:cellForRowAtIndexPath:) dell'origine dati della visualizzazione tabella, che si occupa di mappare la raccolta di tipi di valori precedenti alle singole celle della visualizzazione tabella.

Esegui di nuovo l'app e verifica che i ristoranti che abbiamo visto in precedenza nella console siano ora visibili sul simulatore o sul dispositivo. Se hai completato correttamente questa sezione, la tua app ora legge e scrive dati con Cloud Firestore.

391c0259bf05ac25.png

6. Ordinamento e filtri dei dati

Attualmente la nostra app mostra un elenco di ristoranti, ma l'utente non ha modo di filtrare in base alle proprie esigenze. In questa sezione utilizzerai le query avanzate di Firestore per attivare i filtri.

Di seguito è riportato un esempio di query semplice per recuperare tutti i ristoranti di Dim Sum:

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

Come suggerisce il nome, il metodo whereField(_:isEqualTo:) consente alla query di scaricare solo gli elementi della raccolta i cui campi soddisfano le limitazioni impostate. In questo caso, verranno scaricati solo i ristoranti in cui category è "Dim Sum".

In questa app l'utente può concatenare più filtri per creare query specifiche, ad esempio "Pizza a San Francisco" o "Frutti di mare a Los Angeles ordinati per popolarità".

Apri RestaurantsTableViewController.swift e aggiungi il seguente blocco di codice al centro di 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)
}

Lo snippet riportato sopra aggiunge più clausole whereField e order per creare una singola query composta in base all'input dell'utente. Ora la nostra query restituirà solo i ristoranti che soddisfano i requisiti dell'utente.

Esegui il tuo progetto e verifica di poter filtrare per prezzo, città e categoria (assicurati di digitare esattamente i nomi delle categorie e delle città). Durante il test, nei log potresti visualizzare errori simili a questo:

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

Questo perché Firestore richiede indici per la maggior parte delle query composte. La richiesta di indici per le query mantiene Firestore veloce su larga scala. Se apri il link dal messaggio di errore, si aprirà automaticamente l'interfaccia utente di creazione dell'indice nella Console Firebase con i parametri corretti inseriti. Per saperne di più sugli indici in Firestore, consulta la documentazione.

7. Scrittura dei dati in una transazione

In questa sezione, aggiungeremo la possibilità per gli utenti di inviare recensioni ai ristoranti. Finora, tutte le nostre scritture sono state atomiche e relativamente semplici. Se uno di questi ha generato un errore, probabilmente chiederemo all'utente di riprovare o di riprovare automaticamente.

Per aggiungere una valutazione a un ristorante, dobbiamo coordinare più letture e scritture. Innanzitutto, devi inviare la recensione stessa, dopodiché il numero di valutazioni e la valutazione media del ristorante devono essere aggiornati. Se una di queste non funziona, ma non l'altra, rimane uno stato incoerente in cui i dati in una parte del nostro database non corrispondono ai dati in un'altra.

Fortunatamente, Firestore fornisce funzionalità di transazione che ci consentono di eseguire più letture e scritture in un'unica operazione atomica, garantendo la coerenza dei dati.

Aggiungi il seguente codice sotto tutte le dichiarazioni let in 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)
    }
  }
}

All'interno del blocco di aggiornamento, tutte le operazioni che effettuiamo utilizzando l'oggetto Transaction verranno trattate come un singolo aggiornamento atomico da Firestore. Se l'aggiornamento non va a buon fine sul server, Firestore lo riproverà automaticamente alcune volte. Ciò significa che molto probabilmente la nostra condizione di errore è dovuta a un singolo errore che si verifica ripetutamente, ad esempio se il dispositivo è completamente offline o se l'utente non è autorizzato a scrivere sul percorso su cui sta tentando di scrivere.

8. Regole di sicurezza

Gli utenti della nostra app non devono essere in grado di leggere e scrivere ogni dato nel nostro database. Ad esempio, tutti dovrebbero essere in grado di vedere le valutazioni di un ristorante, ma solo un utente autenticato dovrebbe essere autorizzato a pubblicare una valutazione. Non è sufficiente scrivere buon codice sul client, dobbiamo specificare il nostro modello di sicurezza dei dati sul backend per garantire la massima sicurezza. In questa sezione apprenderemo come utilizzare le regole di sicurezza di Firebase per proteggere i nostri dati.

Innanzitutto, diamo un'occhiata più approfondita alle regole di sicurezza che abbiamo scritto all'inizio del codelab. Apri la Console Firebase e vai a Database > Regole nella scheda 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;
    }
  }
}

La variabile request nelle regole precedenti è una variabile globale disponibile in tutte le regole e la condizione che abbiamo aggiunto garantisce l'autenticazione della richiesta prima di consentire agli utenti di eseguire qualsiasi operazione. In questo modo, gli utenti non autenticati non possono utilizzare l'API Firestore per apportare modifiche non autorizzate ai tuoi dati. È un buon inizio, ma possiamo utilizzare le regole di Firestore per fare cose molto più potenti.

Limitiamo le scritture delle recensioni in modo che l'ID utente della recensione debba corrispondere all'ID dell'utente autenticato. In questo modo, gli utenti non possono rubare l'identità di altri utenti e lasciare recensioni fraudolente. Sostituisci le regole di sicurezza con quanto segue:

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

La prima istruzione di corrispondenza corrisponde alla raccolta secondaria denominata ratings di qualsiasi documento appartenente alla raccolta restaurants. La condizione allow write impedisce quindi l'invio di qualsiasi recensione se l'ID utente della recensione non corrisponde a quello dell'utente. La seconda istruzione di corrispondenza consente a qualsiasi utente autenticato di leggere e scrivere ristoranti nel database.

Questo funziona molto bene per le nostre recensioni, in quanto abbiamo utilizzato le regole di sicurezza per dichiarare esplicitamente la garanzia implicita che abbiamo scritto nella nostra app in precedenza: gli utenti possono scrivere solo le proprie recensioni. Se dovessimo aggiungere una funzione di modifica o eliminazione per le recensioni, lo stesso esatto insieme di regole impedirebbe anche agli utenti di modificare o eliminare le recensioni di altri utenti. Tuttavia, le regole di Firestore possono essere utilizzate anche in modo più granulare per limitare le scritture sui singoli campi all'interno dei documenti anziché sugli interi documenti stessi. Possiamo utilizzarlo per consentire agli utenti di aggiornare solo le valutazioni, la valutazione media e il numero di valutazioni di un ristorante, rimuovendo la possibilità che un utente malintenzionato modifichi il nome o la posizione di un ristorante.

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

In questo caso abbiamo suddiviso l'autorizzazione di scrittura in creazione e aggiornamento per poter specificare meglio quali operazioni devono essere consentite. Qualsiasi utente può scrivere ristoranti nel database, mantenendo la funzionalità del pulsante Completa creato all'inizio del codelab, ma una volta scritto il nome, la posizione, il prezzo e la categoria di un ristorante non è più possibile modificarne il nome. Nello specifico, l'ultima regola richiede che qualsiasi operazione di aggiornamento del ristorante mantenga lo stesso nome, la stessa città, lo stesso prezzo e la stessa categoria dei campi già esistenti nel database.

Per scoprire di più su cosa puoi fare con le regole di sicurezza, consulta la documentazione.

9. Conclusione

In questo codelab, hai appreso come eseguire letture e scritture di base e avanzate con Firestore, nonché come proteggere l'accesso ai dati con le regole di sicurezza. Puoi trovare la soluzione completa nel ramo codelab-complete.

Per saperne di più su Firestore, visita le seguenti risorse: