Dans ce didacticiel, nous allons concevoir une couche réseau légère qui simplifie la lecture et l’écriture de données dans l’ensemble de votre application.
Ne serait-il pas agréable d’avoir des appels d’API en une seule ligne ?
Nous connaissons tous la puissance de la suite Google Firebase, en particulier Cloud Firestore.
Il s’agit d’une base de données NoSQL composée de collections contenant des groupes de documents. Il est relativement simple à intégrer dans votre application et avant que vous ne vous en rendiez compte, vous lisez et écrivez des données.
Pourtant, parce que c’est si simple, vous pouvez souvent vous écarter de la dangereuse voie de l’écriture de code passe-partout peu complexe pour votre couche réseau. Nous sommes tous passés par là… trop de copypasta paresseux, pas assez d’amour et d’attention. Nous pouvons résoudre ce problème une fois pour toutes avec les génériques. Regardons ça!
Clause de non-responsabilité
Aux fins de cet article, les hypothèses suivantes sont en place :
- Vous avez choisi Cloud Firestore comme base de données (bien que des tactiques similaires puissent s’appliquer à la base de données en temps réel)
- Vous connaissez ou utilisez async/wait avec iOS 15 ou une version plus récente
- Vous avez créé votre projet Firebase dans la console
- Vous avez généré et importé le
GoogleService-Info.plist
dans Xcode - Vous avez ajouté
Firebase
etFirebaseFirestoreSwift
via un gestionnaire de dépendances (SPM, CocoaPods, etc…) - Vous avez configuré
Firebase
au sein de votreAppDelegate
La première chose que je fais est de créer la classe de service pour Firebase. Cette classe est assez légère dans ce cas – c’est purement un point de base pour l’expansion si j’apporte d’autres produits Firebase, comme Auth ou Storage. Je suis allé de l’avant et j’ai créé une énumération pour mon Collections
pour aider à la lisibilité et à la gestion des versions de l’API.
Pour ce didacticiel, mon exemple consiste à créer une application pour les bibliothèques locales qui souhaitent gérer leurs livres et leurs cartes de bibliothèque.
import Firebase
import FirebaseFirestoreSwift
import Foundation
import SwiftUIclass FirebaseService {
static let shared = FirebaseService()
let database = Firestore.firestore()
init() {
// Optional: Tweak the settings below to your app's needs.
let settings = FirestoreSettings()
settings.isPersistenceEnabled = true
settings.cacheSizeBytes = FirestoreCacheSizeUnlimited
database.settings = settings
}
}
enum Collections: String {
case books = "books-v1"
case cards = "cards-v1"
case library = "library-v1"
}
Recommandation: Prenez l’habitude de configurer une classe de service distincte pour chaque intégration tierce que vous utilisez, quelles que soient leurs fonctionnalités qui se chevauchent. Cela vous permet de découpler votre base de code pour évoluer rapidement lorsque les plates-formes ou les SDK changent, c’est-à-dire migrer votre base de données de Firebase vers AWS ou MongoDB sans encombrer votre couche de service dans le refactor.
Fonctions GET
Maintenant que notre service est configuré, nous pouvons nous concentrer sur notre tâche suivante – demander des données à Firestore. En raison de la structure des appels SDK, j’ai créé deux fonctions distinctes pour les requêtes uniques et par lots.
Pour une seule requête, nous pouvons structurer notre fonction Générique comme telle :
extension FirebaseService {
// 1.
func getOne<T: Decodable>(of type: T, with query: Query) async -> Result<T, Error> {
do {
// 2.
let querySnapshot = try await query.getDocuments()
if let document = querySnapshot.documents.first {
// 3.
let data = try document.data(as: T.self)
return .success(data)
} else {
print("Warning: \(#function) document not found")
return .failure(TutorialError.documentNotFound)
}
} catch let error {
print("Error: \(#function) couldn't access snapshot, \(error)")
return .failure(error)
}
}
}
- Cette fonction prend deux entrées, l’une étant le type (soupir les génériques n’étant pas déduits à moins que vous ne les transmettiez – suggestions bienvenues !) et une autre étant les requêtes Firebase que vous souhaitez appliquer et qui vous permettent de récupérer les données qui vous intéressent. Parce que nous essaierons de décoder nos données vers notre objet, nous voudrons que notre générique soit conforme à
Decodable
. - Nous utilisons le SDK Firebase pour obtenir les documents de la collection, puis, comme il s’agit d’une seule requête GET, nous pouvons simplement obtenir le premier document (en supposant que votre
Query
est suffisamment spécifique pour qu’un seul document soit renvoyé, c’est-à-dire la récupération par clé primaire). - Si le document existe, nous pouvons le décoder à partir des données en tant que telles et le retourner dans notre
Result
. Sinon, nous pouvons détecter toutes les erreurs et les traiter de manière appropriée ici.
De même, nous pouvons écrire une autre fonction pour obtenir les résultats par lots et l’ajouter à notre FirebaseService
extension.
func getMany<T: Decodable>(of type: T,with query: Query) async -> Result<[T], Error> {
do {
// 1.
var response: [T] = []
let querySnapshot = try await query.getDocuments()for document in querySnapshot.documents {
do {
// 2.
let data = try document.data(as: T.self)
response.append(data)
} catch let error {
print("Error: \(#function) document(s) not decoded from data, \(error)")
return .failure(error)
}
}
// 3.
return .success(response)
} catch let error {
print("Error: couldn't access snapshot, \(error)")
return .failure(error)
}
}
- Contrairement à notre requête unique, nous voudrons initialiser une liste d’objets à renvoyer dans notre réponse.
- Dans notre boucle for, nous pouvons effectuer un décodage similaire, mais ensuite l’ajouter à notre liste. Toutes les erreurs sont détectées ici et renvoient immédiatement un échec, quelle que soit la distance parcourue par notre boucle for. Une alternative pourrait être de simplement consigner l’erreur, mais continuer la boucle sans renvoyer l’échec.
- Une fois la boucle for terminée, nous retournons notre liste !
Se préparer à écrire
Avec notre GET
fonctions, nous avons pu nous assurer que nous pouvions récupérer les données à l’aide de Génériques en nous conformant aux Decodable
protocole. Cependant, pour l’écriture, nous avons besoin de quelque chose d’autre qui nous donne de la flexibilité à mesure que nous adaptons notre application. Entrer FirebaseIdentifiable
— le protocole le plus simple de tous les temps !
Étant donné que chaque document de notre base de données aura un identifiant unique et que chaque document sera nommé d’après son identifiant, il s’agit simplement d’une décision d’architecture. Nous avons également quelques astuces pratiques que nous pouvons utiliser plus tard pour rendre notre notation d’écriture super facile à Firestore (!!).
protocol FirebaseIdentifiable: Hashable, Codable {
var id: String { get set }
}
Fonctions POST et PUT
Celui que nous pouvons lire, nous devons l’écrire. POST
et PUT
sont extrêmement similaires avec seulement des différences infimes dans la façon dont nous appelons le document()
une fonction.
// 1.
func post<T: FirebaseIdentifiable>(_ value: T, to collection: String) async -> Result<T, Error> {
// 2.
let ref = database.collection(collection).document()
var valueToWrite: T = value
valueToWrite.id = ref.documentID
do {
//3.
try ref.setData(from: valueToWrite)
return .success(valueToWrite)
} catch let error {
print("Error: \(#function) in collection: \(collection), \(error)")
return .failure(error)
}
}func put<T: FirebaseIdentifiable>(_ value: T, to collection: String) async -> Result<T, Error> {
// 4.
let ref = database.collection(collection).document(value.id)
do {
try ref.setData(from: value)
return .success(value)
} catch let error {
print("Error: \(#function) in \(collection) for id: \(value.id), \(error)")
return .failure(error)
}
}
- Le
POST
etPUT
Les fonctions prennent toutes les deux les deux mêmes entrées, la valeur que vous souhaitez écrire et le nom de la collection dans laquelle vous écrivez. Vous remarquerez que nous exigeons maintenant que notre objet générique soit conforme àFirebaseIdentifiable
. Cependant, il convient de noter que ce protocole est conforme àCodable
qui est conforme àDecodable
donc c’est pareil. - Nous avons mis en place une référence à un nouveau document la collection de notre base de données. Nous attribuons également l’ID de notre valeur à l’ID de ce document et renvoyons cette valeur à l’expéditeur de l’appel pour suivre et gérer comme vous le souhaitez.
- Nous fixons enfin les données de la référence et notre valeur est écrite ! Nous renvoyons ensuite notre valeur dans notre
Result
puisqu’il est le plus à jour. - Pour notre
PUT
appel, puisque nous essayons d’accéder à un document déjà existant, nous établissons notre référence en utilisant l’ID de la valeur, qui, lorsqu’il est écrit à l’origine à l’étape 2, est également l’ID du document.
Fonctions SUPPRIMER
Firebase donne, Firebase enlève. Voici comment notre code de suppression semble également être ajouté à l’extension (indice : il est assez similaire à POST
et PUT
).
func delete<T: FirebaseIdentifiable>(_ value: T, in collection: String) async -> Result<Void, Error> {
let ref = database.collection(collection).document(value.id)
do {
// 1.
try await ref.delete()
return .success(())
} catch let error {
print("Error: \(#function) in \(collection) for id: \(value.id), \(error)")
return .failure(error)
}
}
- Au lieu de définir des données, le SDK Firestore les a déjà couvertes avec leur
delete()
une fonction. Cependant, si vous deviez écrire un objet nil en utilisantPUT
vous obtiendriez également le même résultat !
Maintenant que nos fonctions génériques sont configurées, nous pouvons commencer à effectuer des appels spécifiquement pour nos exemples de données ! Dans cette section, vous verrez comment nous pouvons appeler chacune de nos fonctions génériques, ainsi que quelques exemples ou cas d’utilisation pour vous aider à mieux visualiser.
Aller chercher des livres
En raison de la nature des requêtes et de la structure du SDK Firestore, j’ai implémenté uneBookService
qui se moque de la façon dont on pourrait potentiellement configurer le GET
demandes de demandes simples ou groupées :
struct BookService {
static func get(by id: String) async -> Result<Book, Error> {
do {
// 1.
let query = FirebaseService.shared.database
.collection(Collections.books.rawValue)
.whereField("id", isEqualTo: id)
// Note: You can add more compounding queries here.// 2.
let data = try await FirebaseService.shared.getOne(of: Book(), with: query).get()
return .success(data)
} catch let error {
return .failure(error)
}
}
static func get(for libraryID: String) async -> Result<[Book], Error> {
do {
// 3.
let query = FirebaseService.shared.database
.collection(Collections.books.rawValue)
.whereField("library_id", isEqualTo: libraryID)
let data = try await FirebaseService.shared.getMany(of: Book(), with: query).get()
return .success(data)
} catch let error {
return .failure(error)
}
}
}
- Nous structurons notre requête en fonction des
id
nous cherchons à aller chercher. Notez que nous pouvons également composer plus de requêtes ici – apprenez-en plus en recherchant les documents Firebase. - Nous passons notre requête dans le
FirebaseService
pour récupérer notre seule donnée. Très facile. - Semblable à l’étape 1 ci-dessus, nous pouvons effectuer une requête similaire ci-dessus, mais cette fois, utilisez un paramètre différent pour filtrer. D’ici, nous pouvons appeler
FirebaseService
pour récupérer nos données par lots.
Écrire quelques livres
Contrairement à la lecture à partir du Firestore, l’écriture est beaucoup plus facile car elle n’a pas besoin de requêtes. Vous pouvez configurer des fonctions au sein de votre couche API qui appellent le FirebaseService
mais si vous vouliez aller droit au but, vous pourriez appeler vos écritures directement depuis l’objet lui-même (!!) grâce à FirebaseIdentifiable
.
Cela a la possibilité de rationaliser votre code et de le rendre beaucoup plus lisible. Tout ce que vous avez à faire est de diriger l’objet vers la bonne collection :
extension FirebaseIdentifiable {
/// POST to Firebase
func post(to collection: String) async -> Result<Self, Error> {
return await FirebaseService.shared.post(self, to: collection)
}/// PUT to Firebase
func put(to collection: String) async -> Result<Self, Error> {
return await FirebaseService.shared.put(self, to: collection)
}
/// DELETE from Firebase
func delete(from collection: String) async -> Result<Void, Error> {
return await FirebaseService.shared.delete(self, in: collection)
}
}
Ici, vous pouvez voir qu’ils sont extrêmement similaires les uns aux autres et passent simplement par le même Result
vous attendez de la FirebaseService
lui-même.
Courtiser! Nous l’avons fait! Ou peut-être que nous ne l’avons pas fait et que vous avez défilé jusqu’à ce point de l’article. Quoi qu’il en soit, pour attacher un arc à ce didacticiel, voici quelques exemples de scénarios pour vraiment démontrer à quel point il est facile d’appeler l’API en utilisant cette approche. Il est robuste mais léger, ce qui permet à votre code d’être durable à mesure que vous grandissez et évoluez.
// ============================================
// EXAMPLE STRUCTS... in your models
// ============================================struct Library: FirebaseIdentifiable {
var id: String
var name: String
var address: String
var books: [String]
var cards: [String]
}
struct Book: FirebaseIdentifiable {
var id: String
var libraryID: String
var name: String
var author: String
var pages: Int
var isCheckedOut: Bool
}
struct Card: FirebaseIdentifiable {
var id: String
var libraryID: String
var name: String
var expired: Bool
}
// ============================================
// EXAMPLE FUNCTIONS... in your view models
// ============================================
func loadBooks() async {
do {
let books = try await BookService.get(for: someLibrary.id)
// do something with response
} catch let error {
// handle failure
}
}
func create(card: Card) async {
do {
let x = try await card.post(to: Collections.cards.rawValue).get()
// do something with response
} catch let error {
// handle failure
}
}
func update(book: Book) async {
do {
let x = try await book.put(to: Collections.books.rawValue).get()
// do something with response
} catch let error {
// handle failure
}
}
func delete(book: Book) async {
do {
try await book.delete().get()
// do something once deleted
} catch let error {
// handle failure
}
}