Atelier de programmation Cloud Firestore pour iOS

1. Présentation

Objectifs

Dans cet atelier de programmation, vous allez créer une application de recommandation de restaurants sur iOS en Swift, avec Firestore. Vous allez apprendre à effectuer les tâches suivantes :

  1. Lire et écrire des données dans Firestore à partir d'une application iOS
  2. Écouter les modifications des données Firestore en temps réel
  3. Utiliser Firebase Authentication et les règles de sécurité pour sécuriser les données Firestore
  4. Écrire des requêtes Firestore complexes

Prérequis

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

  • Xcode 14.0 (ou version ultérieure)
  • CocoaPods 1.12.0 (ou version ultérieure)

2. Créer un projet dans la console Firebase

Ajouter Firebase au projet

  1. Accédez à la console Firebase.
  2. Sélectionnez Create New Project (Créer un projet) et nommez votre projet "Firestore iOS Codelab" (Atelier de programmation iOS Firestore).

3. Obtenir l'exemple de projet

Télécharger 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 doit se compiler correctement et planter immédiatement au lancement, car il manque un fichier GoogleService-Info.plist. Nous allons corriger cela à l'étape suivante.

Configurer Firebase

Suivez la documentation pour créer un projet Firestore. Une fois votre projet obtenu, téléchargez le fichier GoogleService-Info.plist de votre projet depuis la console Firebase, puis faites-le glisser vers la racine du projet Xcode. Exécutez à nouveau le projet pour vous assurer que l'application est correctement configurée et qu'elle ne plante plus au lancement. Une fois connecté, un écran vide semblable à l'exemple ci-dessous s'affiche. Si vous ne parvenez pas à vous connecter, vérifiez que vous avez 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 dans Firestore

Dans cette section, nous allons écrire des données dans Firestore afin de pouvoir renseigner l'UI de l'application. Vous pouvez effectuer cette opération manuellement via la console Firebase, mais nous allons le faire dans l'application elle-même pour vous montrer comment écrire dans Firestore.

Le principal objet de modèle de notre application est un restaurant. Les données Firestore sont divisées en documents, collections et sous-collections. Nous allons stocker chaque restaurant sous forme de document dans une collection de premier niveau appelée restaurants. Pour en savoir plus sur le modèle de données de Firestore, consultez la documentation sur les documents et les collections.

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

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

Maintenant que nous disposons d'une référence de collection, nous pouvons écrire des données. Ajoutez le code suivant 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 à partir d'une structure Restaurant.

Nous y sommes presque. Avant de pouvoir écrire des documents dans Firestore, nous devons ouvrir les règles de sécurité de Firestore et décrire les parties de notre base de données qui doivent être accessibles en écriture par quels utilisateurs. Pour le moment, seuls les utilisateurs authentifiés pourront lire et écrire dans l'ensemble de la base de données. Cette configuration est un peu trop permissive pour une application de production, mais pendant le processus de création de l'application, nous souhaitons que les paramètres soient suffisamment souples pour ne pas rencontrer constamment de problèmes d'authentification lors des tests. À la fin de cet atelier de programmation, nous verrons comment renforcer vos règles de sécurité et limiter la possibilité de lectures et d'écritures involontaires.

Dans l'onglet Rules (Règles) de la console Firebase, ajoutez les règles suivantes, puis cliquez sur Publish (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 reviendrons plus tard sur les règles de sécurité, mais si vous êtes pressé, consultez 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 du restaurant, mais il ne s'affichera pas encore dans l'application.

Ensuite, accédez à l'onglet Données Firestore dans la console Firebase. De nouvelles entrées devraient maintenant s'afficher dans la collection de restaurants:

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

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

5. Afficher les données de Firestore

Dans cette section, vous allez apprendre à récupérer des données à partir de Firestore et à les afficher dans l'application. Les deux étapes clés sont la création d'une requête et l'ajout d'un écouteur d'instantanés. Cet écouteur sera averti de toutes les données existantes correspondant à la requête et sera mis à jour en temps réel.

Tout d'abord, construisons la requête qui diffusera la liste non filtrée des restaurants par défaut. Examinez l'implémentation de RestaurantsTableViewController.baseQuery():

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

Cette requête extrait jusqu'à 50 restaurants de la collection de premier niveau nommée "restaurants". Maintenant que nous avons une requête, nous devons associer un écouteur d'instantanés pour charger les données de Firestore dans notre application. Ajoutez le code suivant à la méthode RestaurantsTableViewController.observeQuery() juste après l'appel de 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 met à jour le contrôleur de vue chaque fois que les données changent sur le serveur. Nous recevons les mises à jour automatiquement et n'avons pas besoin d'appliquer manuellement les modifications. N'oubliez pas que cet écouteur d'instantané peut être appelé à tout moment en raison d'un changement côté serveur. Il est donc important que notre application puisse gérer les modifications.

Une fois nos dictionnaires mappés sur des structures (voir Restaurant.swift), il suffit d'attribuer quelques propriétés de vue pour afficher les données. 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 de remplissage est appelée à partir de la méthode tableView(_:cellForRowAtIndexPath:) de la source de données de la vue de tableau, qui se charge de mapper la collection de types de valeurs d'avant sur les cellules individuelles de la vue de tableau.

Exécutez à nouveau l'application et vérifiez que les restaurants que vous avez vus précédemment dans la console sont maintenant visibles dans le simulateur ou sur l'appareil. Si vous avez terminé cette section, votre application lit et écrit désormais des données avec Cloud Firestore.

391c0259bf05ac25.png

6. Trier et filtrer des données

Actuellement, notre application affiche la liste des restaurants, mais l'utilisateur ne peut pas la filtrer en fonction de ses besoins. Dans cette section, vous allez utiliser les requêtes avancées de Firestore pour activer le filtrage.

Voici un exemple de requête simple permettant de récupérer tous les restaurants de dim sum:

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

Comme son nom l'indique, la méthode whereField(_:isEqualTo:) limite les téléchargements déclenchés par la requête aux membres de la collection dont les champs correspondent aux restrictions que nous avons définies. Dans ce cas, il ne télécharge que les restaurants dont l'attribut category est "Dim Sum".

Dans cette application, l'utilisateur peut chaî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 de code ci-dessus ajoute plusieurs clauses whereField et order afin de générer une seule requête complexe en fonction des entrées utilisateur. Désormais, notre requête ne renverra que les restaurants qui correspondent aux besoins de l'utilisateur.

Exécutez votre projet et vérifiez que vous pouvez filtrer les résultats par prix, par ville et par catégorie (assurez-vous de saisir exactement le nom de la catégorie et de la ville). Lors des tests, des erreurs semblables à celles-ci peuvent s'afficher dans vos journaux:

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 une indexation préalable pour la plupart des requêtes complexes. Le fait d'exiger une indexation sur les requêtes permet à Firestore à être rapide à grande échelle. L'ouverture du lien à partir du message d'erreur entraîne l'ouverture automatique de 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, consultez la documentation.

7. Écrire des données dans une transaction

Dans cette section, nous allons ajouter la possibilité pour les utilisateurs d’envoyer des avis sur des restaurants. Jusqu'à présent, toutes nos écritures étaient indépendantes et relativement simples. En cas d'erreur, il vous suffisait de demander à l'utilisateur de réessayer, ou de le faire automatiquement.

Pour ajouter une note à un restaurant, nous devons coordonner plusieurs opérations de lecture et d'écriture. Tout d'abord, l'avis doit être envoyé, puis le nombre d'avis et la note moyenne du restaurant doivent être actualisés. Si l'une de ces opérations é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 plus à celles d'une autre.

Heureusement, Firestore propose une fonctionnalité de transaction qui nous permet d'effectuer plusieurs opérations de lecture et d'écriture en une seule opération indépendante afin de garantir 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)
    }
  }
}

Dans le bloc "update", toutes les opérations que nous effectuons à l'aide de l'objet transaction seront traitées par Firestore comme une seule mise à jour atomique. Si la mise à jour échoue sur le serveur, Firestore réessaie automatiquement plusieurs fois. Cela signifie que notre condition d'erreur est très probablement une erreur unique qui se répète de façon répétée, par exemple si l'appareil est complètement hors connexion ou si l'utilisateur n'est pas autorisé à écrire dans le chemin d'accès vers lequel il tente d'écrire.

8. Règles de sécurité

Les utilisateurs de notre application ne doivent pas pouvoir lire et écrire toutes les données de notre base de données. Par exemple, tout le monde doit pouvoir voir les notes d'un restaurant, mais seul un utilisateur authentifié doit être autorisé à en publier. Il ne suffit pas d'écrire un code approprié sur le client. Nous devons spécifier notre modèle de sécurité des données sur le backend pour qu'il soit totalement sécurisé. Dans cette section, nous allons apprendre à utiliser les règles de sécurité Firebase pour protéger nos données.

Commençons par examiner 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, puis accédez à Database > Rules (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 des règles ci-dessus est une variable globale disponible dans toutes les règles. La condition que nous avons ajoutée garantit que la requête est authentifiée avant d'autoriser les utilisateurs à effectuer 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 Firestore pour effectuer des tâches beaucoup plus puissantes.

Nous allons restreindre les écritures d'avis de sorte que l'ID utilisateur de l'avis corresponde à celui de l'utilisateur authentifié. Cela permet d'éviter que les utilisateurs ne s'imitent les uns les autres et ne laissent des avis frauduleux. Remplacez vos règles de sécurité par ce qui suit:

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 l'envoi d'un 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 intégrée à notre application plus tôt, à savoir que les utilisateurs ne peuvent écrire que leurs propres avis. Si nous ajoutions une fonction de modification ou de suppression pour les avis, cet ensemble de règles empêcherait également les utilisateurs de modifier ou de supprimer les avis d'autres utilisateurs. Toutefois, les règles Firestore peuvent également être utilisées de manière plus précise pour limiter les écritures sur des champs individuels dans des documents plutôt que sur l'ensemble des documents. Nous pouvons ainsi autoriser les utilisateurs à modifier uniquement les notes, la note moyenne et le nombre de notes d'un restaurant, ce qui empêche un utilisateur malveillant de modifier 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 deux : "créer" et "modifier", afin de pouvoir être plus précis sur les opérations autorisées. Tout utilisateur peut ajouter des restaurants à la base de données, ce qui préserve la fonctionnalité du bouton "Populate" (Remplir) que nous avons créé au début de l'atelier de programmation. Toutefois, une fois qu'un restaurant a été ajouté, 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 d'un restaurant conserve le même nom, la même ville, le même prix et la même catégorie que les champs déjà présents dans la base de données.

Pour en savoir plus sur les règles de sécurité, consultez la documentation.

9. Conclusion

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

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