Atelier de programmation iOS Cloud Firestore

1. Vue d'ensemble

Objectifs

Dans cet atelier de programmation, vous allez créer une application de recommandation de restaurants basée sur Firestore sur iOS dans Swift. Vous apprendrez à :

  1. Lire et écrire des données sur Firestore à partir d'une application iOS
  2. Écoutez les changements dans les données Firestore en temps réel
  3. Utilisez l'authentification Firebase et les règles de sécurité pour sécuriser les données Firestore
  4. Écrire des requêtes Firestore complexes

Conditions préalables

Avant de commencer cet atelier de programmation, assurez-vous d'avoir installé :

  • Xcode version 14.0 (ou supérieure)
  • CocoaPods 1.12.0 (ou supérieur)

2. Créer un projet de console Firebase

Ajouter Firebase au projet

  1. Accédez à la console Firebase .
  2. Sélectionnez Créer un nouveau projet et nommez votre projet « Firestore iOS Codelab ».

3. Obtenez l'exemple de projet

Téléchargez le code

Commencez par cloner l' exemple de projet et exécutez pod update dans le répertoire du projet :

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

Ouvrez FriendlyEats.xcworkspace dans Xcode et exécutez-le (Cmd+R). L'application devrait se compiler correctement et planter immédiatement au lancement, car il lui manque un fichier GoogleService-Info.plist . Nous corrigerons cela à l’étape suivante.

Configurer Firebase

Suivez la documentation pour créer un nouveau projet Firestore. Une fois que vous avez votre projet, téléchargez le fichier GoogleService-Info.plist de votre projet depuis la console Firebase et faites-le glisser vers la racine du projet Xcode. Exécutez à nouveau le projet pour vous assurer que l'application se configure correctement et ne plante plus au lancement. Après vous être connecté, vous devriez voir un écran vide comme dans l’exemple ci-dessous. Si vous ne parvenez pas à vous connecter, assurez-vous d'avoir activé la méthode de connexion par e-mail/mot de passe dans la console Firebase sous Authentification.

d5225270159c040b.png

4. Écrire des données sur Firestore

Dans cette section, nous allons écrire certaines données dans Firestore afin de pouvoir remplir l'interface utilisateur de l'application. Cela peut être fait manuellement via la console Firebase , mais nous le ferons dans l'application elle-même pour démontrer une écriture de base sur Firestore.

L'objet modèle principal de notre application est un restaurant. Les données Firestore sont divisées en documents, collections et sous-collections. Nous stockerons chaque restaurant sous forme de document dans une collection de niveau supérieur appelée restaurants . Si vous souhaitez en savoir plus sur le modèle de données Firestore, lisez les documents et les collections dans la documentation .

Avant de pouvoir ajouter des données à Firestore, nous devons obtenir une référence à la collection des restaurants. Ajoutez ce qui suit à la boucle for interne dans la méthode RestaurantsTableViewController.didTapPopulateButton(_:) .

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

Maintenant que nous avons une référence de collection, nous pouvons écrire des données. Ajoutez ce qui suit juste après la dernière ligne de code que nous avons ajoutée :

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)

Le code ci-dessus ajoute un nouveau document à la collection restaurants. Les données du document proviennent d'un dictionnaire, que nous obtenons d'une structure Restaurant.

Nous y sommes presque : avant de pouvoir écrire des documents sur Firestore, nous devons ouvrir les règles de sécurité de Firestore et décrire quelles parties de notre base de données doivent être accessibles en écriture et par quels utilisateurs. Pour l'instant, nous autoriserons uniquement les utilisateurs authentifiés à lire et à écrire dans l'intégralité de la base de données. C'est un peu trop permissif pour une application de production, mais pendant le processus de création d'application, nous voulons quelque chose de suffisamment détendu pour ne pas rencontrer constamment de problèmes d'authentification lors de nos expérimentations. À la fin de cet atelier de programmation, nous expliquerons comment renforcer vos règles de sécurité et limiter les risques de lectures et d'écritures involontaires.

Dans l' onglet Règles de la console Firebase, ajoutez les règles suivantes, puis cliquez sur Publier .

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

Nous discuterons des règles de sécurité en détail plus tard, mais si vous êtes pressé, jetez un œil à la documentation sur les règles de sécurité .

Exécutez l'application et connectez-vous. Appuyez ensuite sur le bouton " Remplir " en haut à gauche, ce qui créera un lot de documents de restaurant, même si vous ne le verrez pas encore dans l'application.

Ensuite, accédez à l' onglet Données Firestore dans la console Firebase. Vous devriez maintenant voir de nouvelles entrées dans la collection de restaurants :

Capture d'écran 06/07/2017 à 12h45h38.png

Félicitations, vous venez d'écrire des données sur Firestore à partir d'une application iOS ! Dans la section suivante, vous apprendrez comment récupérer des données de Firestore et les afficher dans l'application.

5. Afficher les données de Firestore

Dans cette section, vous apprendrez comment récupérer des données de Firestore et les afficher dans l'application. Les deux étapes clés consistent à créer une requête et à ajouter un écouteur d'instantané. Cet auditeur sera informé de toutes les données existantes correspondant à la requête et recevra des mises à jour en temps réel.

Tout d’abord, construisons la requête qui servira la liste de restaurants par défaut et non filtrée. Jetez un œil à l’implémentation de RestaurantsTableViewController.baseQuery() :

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

Cette requête récupère jusqu'à 50 restaurants de la collection de niveau supérieur nommée "restaurants". Maintenant que nous avons une requête, nous devons attacher un écouteur d'instantané pour charger les données de Firestore dans notre application. Ajoutez le code suivant à la méthode RestaurantsTableViewController.observeQuery() juste après l'appel à 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()
}

Le code ci-dessus télécharge la collection depuis Firestore et la stocke localement dans un tableau. L'appel addSnapshotListener(_:) ajoute un écouteur d'instantané à la requête qui mettra à jour le contrôleur de vue à chaque fois que les données changent sur le serveur. Nous recevons automatiquement les mises à jour et n'avons pas besoin d'appliquer manuellement les modifications. N'oubliez pas que cet écouteur d'instantané peut être invoqué à tout moment à la suite d'une modification côté serveur. Il est donc important que notre application puisse gérer les modifications.

Après avoir mappé nos dictionnaires sur des structures (voir Restaurant.swift ), l'affichage des données consiste simplement à attribuer quelques propriétés de vue. Ajoutez les lignes suivantes à RestaurantTableViewCell.populate(restaurant:) dans 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)

Cette méthode populate est appelée à partir de la méthode tableView(_:cellForRowAtIndexPath:) de la source de données de la vue tabulaire, qui prend en charge le mappage de la collection de types de valeur d'avant aux cellules individuelles de la vue tabulaire.

Exécutez à nouveau l'application et vérifiez que les restaurants que nous avons vus plus tôt dans la console sont désormais visibles sur le simulateur ou l'appareil. Si vous avez terminé cette section avec succès, votre application lit et écrit désormais des données avec Cloud Firestore !

391c0259bf05ac25.png

6. Tri et filtrage des données

Actuellement, notre application affiche une liste de restaurants, mais l'utilisateur n'a aucun moyen de filtrer en fonction de ses besoins. Dans cette section, vous utiliserez les requêtes avancées de Firestore pour activer le filtrage.

Voici un exemple de requête simple pour récupérer tous les restaurants Dim Sum :

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

Comme son nom l'indique, la whereField(_:isEqualTo:) fera en sorte que notre requête télécharge uniquement les membres de la collection dont les champs répondent aux restrictions que nous avons définies. Dans ce cas, seuls les restaurants dont category est "Dim Sum" seront téléchargés.

Dans cette application, l'utilisateur peut enchaîner plusieurs filtres pour créer des requêtes spécifiques, comme « Pizza à San Francisco » ou « Fruits de mer à Los Angeles classés par popularité ».

Ouvrez RestaurantsTableViewController.swift et ajoutez le bloc de code suivant au milieu de 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)
}

L'extrait ci-dessus ajoute plusieurs clauses whereField et order pour créer une seule requête composée basée sur la saisie de l'utilisateur. Désormais, notre requête renverra uniquement les restaurants qui correspondent aux exigences de l'utilisateur.

Exécutez votre projet et vérifiez que vous pouvez filtrer par prix, ville et catégorie (assurez-vous de saisir exactement les noms de catégorie et de ville). Pendant les tests, vous pouvez voir des erreurs dans vos journaux ressemblant à ceci :

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

En effet, Firestore nécessite des index pour la plupart des requêtes composées. L'exigence d'index sur les requêtes permet à Firestore d'évoluer rapidement. L'ouverture du lien à partir du message d'erreur ouvrira automatiquement l'interface utilisateur de création d'index dans la console Firebase avec les paramètres corrects renseignés. Pour en savoir plus sur les index dans Firestore, visitez la documentation .

7. Écriture de données dans une transaction

Dans cette section, nous ajouterons la possibilité pour les utilisateurs de soumettre des avis sur les restaurants. Jusqu’à présent, toutes nos écritures ont été atomiques et relativement simples. Si l'un d'entre eux comporte une erreur, nous inviterons probablement simplement l'utilisateur à le réessayer ou à le réessayer automatiquement.

Afin d'ajouter une note à un restaurant, nous devons coordonner plusieurs lectures et écritures. L'avis lui-même doit d'abord être soumis, puis le nombre de notes et la note moyenne du restaurant doivent être mis à jour. Si l’un d’eux échoue mais pas l’autre, nous nous retrouvons dans un état incohérent où les données d’une partie de notre base de données ne correspondent pas aux données d’une autre.

Heureusement, Firestore fournit une fonctionnalité de transaction qui nous permet d'effectuer plusieurs lectures et écritures en une seule opération atomique, garantissant ainsi la cohérence de nos données.

Ajoutez le code suivant sous toutes les déclarations let dans 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)
    }
  }
}

À l'intérieur du bloc de mise à jour, toutes les opérations que nous effectuons à l'aide de l'objet de transaction seront traitées comme une seule mise à jour atomique par Firestore. Si la mise à jour échoue sur le serveur, Firestore réessayera automatiquement plusieurs fois. Cela signifie que notre condition d'erreur est très probablement une erreur unique se produisant de manière répétée, par exemple si l'appareil est complètement hors ligne ou si l'utilisateur n'est pas autorisé à écrire sur le chemin sur lequel il essaie d'écrire.

8. Règles de sécurité

Les utilisateurs de notre application ne devraient pas pouvoir lire et écrire toutes les données de notre base de données. Par exemple, tout le monde devrait pouvoir voir les notes d'un restaurant, mais seul un utilisateur authentifié devrait être autorisé à publier une note. Il ne suffit pas d'écrire du bon code sur le client, nous devons spécifier notre modèle de sécurité des données sur le backend pour être complètement sécurisé. Dans cette section, nous apprendrons comment utiliser les règles de sécurité de Firebase pour protéger nos données.

Tout d'abord, examinons de plus près les règles de sécurité que nous avons écrites au début de l'atelier de programmation. Ouvrez la console Firebase et accédez à Base de données > Règles dans l'onglet 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 variable request dans les règles ci-dessus est une variable globale disponible dans toutes les règles, et le conditionnel que nous avons ajouté garantit que la requête est authentifiée avant d'autoriser les utilisateurs à faire quoi que ce soit. Cela empêche les utilisateurs non authentifiés d'utiliser l'API Firestore pour apporter des modifications non autorisées à vos données. C'est un bon début, mais nous pouvons utiliser les règles de Firestore pour faire des choses beaucoup plus puissantes.

Limitons les écritures d'avis afin que l'ID utilisateur de l'avis doit correspondre à l'ID de l'utilisateur authentifié. Cela garantit que les utilisateurs ne peuvent pas usurper l'identité d'un autre et laisser des avis frauduleux. Remplacez vos règles de sécurité par les suivantes :

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 première instruction de correspondance correspond à la sous-collection nommée ratings de tout document appartenant à la collection restaurants . La condition allow write empêche ensuite la soumission de tout avis si l’ID utilisateur de l’avis ne correspond pas à celui de l’utilisateur. La deuxième instruction de correspondance permet à tout utilisateur authentifié de lire et d'écrire des restaurants dans la base de données.

Cela fonctionne très bien pour nos avis, car nous avons utilisé des règles de sécurité pour énoncer explicitement la garantie implicite que nous avons écrite plus tôt dans notre application : que les utilisateurs ne peuvent rédiger que leurs propres avis. Si nous devions ajouter une fonction de modification ou de suppression pour les avis, ce même ensemble de règles empêcherait également les utilisateurs de modifier ou de supprimer les avis d'autres utilisateurs. Mais les règles Firestore peuvent également être utilisées de manière plus granulaire pour limiter les écritures sur des champs individuels au sein des documents plutôt que sur l'ensemble des documents eux-mêmes. Nous pouvons utiliser cela pour permettre aux utilisateurs de mettre à jour uniquement les notes, la note moyenne et le nombre de notes d'un restaurant, supprimant ainsi la possibilité qu'un utilisateur malveillant modifie le nom ou l'emplacement d'un restaurant.

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

Ici, nous avons divisé notre autorisation d'écriture en création et mise à jour afin que nous puissions être plus précis sur les opérations qui doivent être autorisées. N'importe quel utilisateur peut écrire des restaurants dans la base de données, en préservant la fonctionnalité du bouton Remplir que nous avons créé au début de l'atelier de programmation, mais une fois qu'un restaurant est écrit, son nom, son emplacement, son prix et sa catégorie ne peuvent plus être modifiés. Plus précisément, la dernière règle exige que toute opération de mise à jour du restaurant conserve les mêmes nom, ville, prix et catégorie que les champs déjà existants dans la base de données.

Pour en savoir plus sur ce que vous pouvez faire avec les règles de sécurité, consultez la documentation .

9. Conclusion

Dans cet atelier de programmation, vous avez appris à effectuer des lectures et écritures de base et avancées avec Firestore, ainsi qu'à sécuriser l'accès aux données avec des règles de sécurité. Vous pouvez trouver la solution complète sur la branche codelab-complete .

Pour en savoir plus sur Firestore, visitez les ressources suivantes :